Работающий программист

Восход Roslyn. Часть 2. Диагностика

Тэд Ньюард
Джо Хаммел

В этой статье обсуждаются предварительные версии Visual Studio 2015 и .NET Compiler Platform под кодовым названием «Roslyn». Любая изложенная здесь информация, может быть изменена.

Ted NewardНа сегодняшний день читатели наверняка слышали о большой шумихе вокруг стратегий, к которым вроде бы стремится Microsoft в следующем поколении средств разработки: больше открытого исходного кода, больше кросс-платформенного кода, больше открытости и прозрачности. «Roslyn» — кодовое название проекта .NET Compiler Platform — играет здесь главную роль, будучи первой производственной инфраструктурой средств компиляции от Microsoft для модели открытой разработки. После объявления о том, что Roslyn теперь является компилятором, используемым самими группами Microsoft .NET Framework для компиляции .NET, Roslyn стал своего рода «открытием»: платформа и ее языковые средства создаются платформой и ее языковыми средствами. И, как вы увидите в этой статье, эти языковые средства можно использовать для создания дополнительных языковых средств, которые будут помогать в создании платформы.

Вы растерялись? Не стоит — скоро все станет понятно.

«Но мы не будем этого делать»

С тех пор как первый программист стал работать со вторым программистом — и счел, что он «делает все неправильно», по крайней мере, по мнению первого программиста, — в группах боролись за выработку некоего подобия единства и согласованности в том, как пишется код, как глубоко проверяются ошибки, как используются объекты и т. д. Исторически сложилось так, что это стало компетенцией стандартов кодирования — фактически набора правил, и каждому программисту предлагалось следовать ему при написании кода для компании. Иногда программисты даже удосуживаются прочитать эти правила. Но без какой-либо формы принуждения — обычно через проверенную временем практику рецензирования кода, в ходе которого каждый спорит о том, где должны находиться фигурные скобки и как следует называть переменные, — стандарты кодирования очень слабо влияют на общее качество кода.

Со временем, по мере взросления языковых средств разработчики сами стали присматриваться к инструментам, которые обеспечивают этот уровень принуждения. В конце концов, одно из того, в чем реально хорош компьютер, — это повторяющееся выполнение одних и тех же видов детализированного анализа — снова и снова, без сбоев, сомнений или ошибок. Если уж на то пошло, вспомните, что это часть работы компилятора: обнаруживать распространенные ошибки человека, которые могут привести к получению подверженного ошибкам коду, и проваливать компиляцию, чтобы программисты были вынуждены исправлять ошибки до того, как их заметят конечные пользователи. Инструменты, которые анализируют код и ищут ошибочные шаблоны, называются средствами статического анализа, и они могут помочь выявить «баги» задолго до запуска модульных тестов.

В .NET Framework всегда было трудно создавать и поддерживать такие инструменты. Средства статического анализа требуют значительных усилий в разработке и должны обновляться по мере развития языков и библиотек; в компаниях, где работают как с C#, так и с Visual Basic .NET, эти усилия удваиваются. Средства анализа двоичного кода, такие как FxCop, работают на уровне Intermediate Language (IL), избегая языковых сложностей. Однако на переходе от исходного кода к IL, как минимум, теряется структурная информация, что сильно затрудняет связывание выявленных проблем с тем уровнем, на котором работает программист, т. е. исходным кодом. Кроме того, средства анализа двоичного кода выполняются после компиляции, препятствуя обратной связи по типу IntelliSense в процессе программирования.

Однако Roslyn с самого начала создавался как расширяемая платформа. В Roslyn термин «анализатор» используется для описания расширений для анализа исходного кода, которые могут выполняться в фоне в то время, как разработчик программирует. Создавая анализатор, вы можете запросить Roslyn ввести в действие дополнительные виды правил более высокого порядка, помогающие исключать «баги» без необходимости запускать дополнительные средства.

Что могло пойти не так?

Как это ни печально, но следует согласиться, что время от времени мы видим код, подобный этому:

try
{
  int x = 5; int y = 0;
  // Здесь находится уйма кода
  int z = x / y;
}
catch (Exception ex)
{
  // TODO: вернуться и понять, что делать здесь
}

Зачастую этот TODO пишется с лучшими намерениями. Но, как гласит старая поговорка, благими намерениями вымощена дорога в ад. Естественно, стандарт кодирования говорит, что это плохо, но это лишь нарушение правил, если кто-то поймает вас на этом. Разумеется, сканирование текстового файла привело бы к обнаружению «TODO», однако код завален этими TODO, ни один из которых не скрывает столь скверных ошибок. И конечно, вы найдете эту строку кода только после того, как важная демонстрация «молча» провалится, и вам придется постепенно и со скрипом откатывать программу до того состояния, где вы сумеете найти этот код, который на самом деле должен был бы «громко» провалиться с исключением, а не простым игнорированием случившегося и продолжением работы программы в блаженном неведении неминуемого краха.

В стандарте кодирования, вероятно, предусмотрен такой случай: всегда генерируйте исключение или всегда протоколируйте исключение в стандартный диагностический поток, или делайте и то, и другое, или...., но, впрочем, без принуждения это просто бумажка, которую никто не читает.

С помощью Roslyn вы можете создавать объект диагностики (diagnostic), который обнаруживает это и даже (при соответствующем конфигурировании) работает с Visual Studio Team Foundation Server, чтобы предотвратить отправку этого кода в систему контроля версий, пока не будет исправлен тот пустой блок catch.

Диагностика в Roslyn

На момент написания этой статьи проект Roslyn находится на стадии предварительной версии и устанавливается в составе Visual Studio 2015 Preview. После установки шаблонов из Visual Studio 2015 Preview SDK и Roslyn SDK объекты диагностики можно писать, используя предоставляемый шаблон Extensibility — Diagnostic with Code Fix (NuGet + VSIX). Для начала, как показано на рис. 1, выберите диагностический шаблон и присвойте проекту имя EmptyCatchDiagnostic.

Шаблон проекта Diagnostic with Code Fix (NuGet + VSIX)
Рис. 1. Шаблон проекта Diagnostic with Code Fix (NuGet + VSIX)

Второй этап — написание Syntax Node Analyzer, который проходит дерево абстрактного синтаксиса (Abstract Syntax Tree, AST) в поисках пустых блоков catch. Крошечный фрагмент AST приведен на рис. 2. Хорошая новость в том, что компилятор Roslyn проходит AST за вас. Вам нужно лишь предоставить код, где будут анализироваться интересующие вас узлы. (Для тех, кто знаком с классическими проектировочными шаблонами «Банды Четырех», это шаблон Visitor.) Ваш анализатор должен наследовать от абстрактного базового класса DiagnosticAnalyzer и реализовать эти два метода:

public abstract
  ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
public abstract void Initialize(AnalysisContext context);

Абстрактное синтаксическое дерево в Roslyn для фрагмента кода «if (score > 100) grade = "A++";»
Рис. 2. Абстрактное синтаксическое дерево в Roslyn для фрагмента кода «if (score > 100) grade = "A++";»

Condition Условие
Else Else
Statement Выражение
Left Левая часть
Right Правая часть
Identifier Идентификатор
Token Лексема
Value Значение

Метод SupportedDiagnostics прост, он возвращает описание каждого анализатора, который вы предлагаете Roslyn. Метод Initialize — то место, где вы регистрируете код своего анализатора в Roslyn. В ходе инициализации вы сообщаете Roslyn две вещи: вид интересующих вас узлов и код, который следует выполнять, когда при компиляции встречается один из этих узлов. Поскольку Visual Studio выполняет компиляцию в фоне, эти вызовы будут происходить в то время, как пользователь редактирует свой код, что обеспечивает мгновенную обратную связь по возможным ошибкам.

Начните с модификации заранее сгенерированного шаблонного кода в то, что нужно вам для диагностики пустых блоков catch. Это можно найти в файле исходного кода DiagnosticAnalyzer.cs в проекте EmptyCatchDiagnostic (решение будет содержать дополнительные проекты, которые вы можете смело игнорировать). В следующем коде полужирным выделены изменения, касающиеся заранее сгенерированного кода. Сначала некоторые строки, описывающие ваш Diagnostic:

internal const string Title = "Catch Block is Empty";
internal const string MessageFormat =  
  "'{0}' is empty, app could be unknowingly missing exceptions";
internal const string Category = "Safety";

Сгенерированный метод SupportedDiagnostics корректен; вам нужно изменить лишь метод Initialize, чтобы зарегистрировать свою процедуру синтаксического анализа, AnalyzeSyntax:

public override void Initialize(AnalysisContext context)
{
  context.RegisterSyntaxNodeAction<SyntaxKind>(
    AnalyzeSyntax, SyntaxKind.CatchClause);
}

В процессе регистрации вы также сообщаете Roslyn, что заинтересованы только в блоках catch в AST. Это резко сокращает количество узлов, передаваемых вам, и помогает сохранять анализатор ясным, простым и выполняющим только одну задачу.

При компиляции, когда в AST встречается узел блока catch, вызывается ваш метод анализа AnalyzeSyntax. В этом методе вы анализируете количество выражений в блоке catch и, если оно равно нулю, отображаете диагностическое предупреждение о пустом блоке. Как показано на рис. 3, когда ваш анализатор обнаруживает пустой блок catch, вы создание новое диагностическое предупреждение, позиционируете его в том месте, где находится ключевое слово catch, и выводите его.

Рис. 3. Анализ блоков catch

// Вызывается, когда Roslyn встречает блок catch
private static void AnalyzeSyntax(
  SyntaxNodeAnalysisContext context)
{
  // Приводим тип к известному
  var catchBlock = context.Node as CatchClauseSyntax;
  // Если catch присутствует, должен быть его блок,
  // поэтому проверяем, пустой ли он
  if (catchBlock?.Block.Statements.Count == 0)
  {
    // Блок пуст, создаем и выводим
    // диагностическое предупреждение
    var diagnostic = Diagnostic.Create(Rule,
      catchBlock.CatchKeyword.GetLocation(), "Catch block");
    context.ReportDiagnostic(diagnostic);
  }
}

Третий этап — создание и запуск объекта диагностики. То, что произойдет после этого, по-настоящему интересно и имеет смысл, если вы поразмыслите об этом. Вы создаете управляемый компилятором объект диагностики, но как же его тогда протестировать? А просто запускаете Visual Studio, устанавливаете диагностику, открываете проект с пустыми блоками catch и смотрите, что происходит! Это иллюстрирует рис. 4. Тип проекта по умолчанию — установщик VSIX, поэтому, когда вы «запускаете» проект, Visual Studio запускает другой экземпляр Visual Studio и выполняет установщик для него. Как только второй экземпляр готов, вы можете тестировать. Увы, автоматическое тестирование диагностики пока выходит за рамки проекта, но, если диагностика проста и сконцентрирована на одной задаче, то проверить ее вручную нетрудно.

Visual Studio выполняет Empty Catch Block Diagnostic в другом экземпляре Visual Studio
Рис. 4. Visual Studio выполняет Empty Catch Block Diagnostic в другом экземпляре Visual Studio

Не стой столбом, исправь!

К сожалению, утилита, указывающая ошибки, которые она могла бы легко устранять, но не делает этого, реально раздражает. Смахивает на ситуацию, когда ваш кузен долго наблюдает за тем, как вы пытаетесь открыть дверь, и лишь спустя несколько часов упоминает, что она заперта, а потом еще больше времени смотрит, как вы пытаетесь найти другой способ попасть в дом, прежде чем упомянуть о том, что у него есть ключ.

Roslyn не хочет быть таким.

Исправление кода (Code Fix) выдает разработчику одно или более предложений, как исправить проблему, обнаруженную анализатором. В случае пустого блока catch простое исправление — добавить выражение throw, чтобы любое захваченное исключение сразу же генерировалось повторно. На рис. 5 показано, что исправление кода появляется в Visual Studio как привычная всплывающая подсказка.

Code Fix, предлагающий поместить throw внутрь пустого блока catch
Рис. 5. Code Fix, предлагающий поместить throw внутрь пустого блока catch

В этом случае обратите внимание на другой, заранее сгенерированный файл исходного кода в проекте — на CodeFixProvider.cs. Вы должны наследовать от абстрактного базового класса CodeFixProvider и реализовать три метода. Основным методом является ComputeFixesAsync, который выдает разработчику предложения:

public sealed override async Task ComputeFixesAsync(CodeFixContext context)

Когда анализатор сообщает о проблеме, этот метод вызывается Visual Studio IDE, чтобы проверить, предлагаются ли для нее какие-то исправления кода. Если да, IDE показывает всплывающую подсказку с предложениями, из которых разработчик может выбрать нужное. Если одно из предложений выбрано, данный документ (который указывает на AST для файла исходного кода) обновляется предложенным исправлением.

То есть исправление кода — это не более чем предлагаемая модификация AST. Изменяя AST, исправление передается на остальные стадии компиляции так, будто этот код был написан самим разработчиком. В данном случае предложение состоит в добавлении выражения throw. На рис. 6 приведена абстрактная схема того, что происходит при этом.

Обновление абстрактного синтаксического дерева (AST)
Рис. 6. Обновление абстрактного синтаксического дерева (AST)

Таким образом, ваш метод создает новое поддерево для замены существующего в AST поддерева с блоком catch. Вы формируете новое поддерево снизу вверх: новое выражение throw, затем список, содержащий выражение, потом блок, ограничивающий список, и, наконец, catch, завершающий блок:

public sealed override async Task ComputeFixesAsync(
  CodeFixContext context)
{
  // Создаем новый блок со списком,
  // который содержит выражение throw
  var throwStmt = SyntaxFactory.ThrowStatement();
  var stmtList = new SyntaxList<
    StatementSyntax>().Add(throwStmt);
  var newBlock = SyntaxFactory.Block().
    WithStatements(stmtList);
  // Создаем новый, заменяющий блок catch
  // с нашим выражением throw
  var newCatchBlock = SyntaxFactory.CatchClause().
    WithBlock(newBlock).WithAdditionalAnnotations(
    Microsoft.CodeAnalysis.Formatting.Formatter.Annotation);

Следующий этап — получение корня AST для данного файла исходного кода, поиск блока catch, идентифицированного анализатором, и создание нового AST. Например, newRoot обозначает только что созданный новый корень AST для данного файла исходного кода:

var root = await context.Document.GetSyntaxRootAsync(
    context.CancellationToken).ConfigureAwait(false);
  var diagnostic = context.Diagnostics.First();
  var diagnosticSpan = diagnostic.Location.SourceSpan;
  // Это ключевое слово catch
  var token = root.FindToken(diagnosticSpan.Start);
  // Это блок catch
  var catchBlock = token.Parent as CatchClauseSyntax;
  // Создаем новое AST
  var newRoot = root.ReplaceNode(catchBlock, newCatchBlock);

Последний этап — регистрация действия кода (code action), которое вызовет ваше исправление и обновит AST:

var codeAction =
    CodeAction.Create("throw", context.Document.WithSyntaxRoot(newRoot));
  context.RegisterFix(codeAction, diagnostic);
}

По ряду веских причин большинство структур данных в Roslyn являются неизменяемыми, в том числе AST. Здесь это особенно хороший выбор, потому что вам незачем обновлять AST, пока разработчик действительно не выберет исправление кода. Поскольку существующее AST неизменяемое, метод возвращает новое AST, и IDE заменяет им текущее AST, если выбрано исправление кода.

Возможно, вы обеспокоены тем, что неизменяемость влечет за собой высокие издержки использования больших объемов памяти. Если AST неизменяемо, означает ли это, что полная копия нужна при внесении каждого изменения? К счастью, в AST сохраняются лишь различия (на основании того, что легче сохранять дельты, чем иметь дело с проблемами параллельной обработки и согласованности, которые возникли бы при создании полностью изменяемого AST), и это позволяет минимизировать объем копируемых данных.

Новые возможности

Roslyn создает некоторые новые возможности, раскрывая компилятор (и IDE тоже!). Годами C# навязывал себя в качестве «строго типизированного» языка, и предполагалось, что заблаговременная компиляция помогает уменьшить количество ошибок. По сути, в C# даже было предпринято несколько шагов, чтобы попытаться предотвратить ошибки, распространенные в других языках (например, обработку сравнений целых чисел как булевых значений, что приводило к печально известному «багу» с if (x = 0), который часто досаждал разработчикам на C++). Но компиляторам всегда приходилось быть чрезвычайно избирательными в том, какие правила они могли или должны были бы применять, потому что эти решения были завязаны на всю индустрию, а в разных организациях зачастую были разные мнения по поводу того, что «слишком строго» или «слишком свободно». Теперь, когда Microsoft открывает разработчикам внутренние механизмы компилятора, вы можете вводить собственные правила в отношении кода без необходимости становиться экспертом в области компиляторов.

За подробностями о том, как приступить к работе с Roslyn, обращайтесь на страницу проекта Roslyn (roslyn.codeplex.com). Если вы хотите еще больше углубиться в детали синтаксического и лексического разбора, то можете почитать самые разнообразные книги, в том числе знаменитую «Dragon Book», официально опубликованную под названием «Compilers: Principles, Techniques & Tools» (Addison Wesley, 2006); она написана авторами Эйхоу (Aho), Лемом (Lam), Сети (Sethi) и Уллменом (Ullman). Тем, кто интересуется подходом, в большей мере ориентированным на .NET, советую прочесть «Compiling for the .NET Common Language Runtime (CLR)» (Prentice Hall, 2001) за авторством Джона Гафа (John Gough) или книгу Рональда Мака (Ronald Mak) «Writing Compilers and Interpeters: A Software Engineering Approach» (Wiley, 2009).

Удачи в кодировании!


Тэд Ньюард*(Ted Neward) — директор iTrellis по технологиям (эта компания предоставляет консалтинговые услуги). Автор и соавтор многочисленных книг, в том числе «Professional F# 2.0» (Wrox, 2010), более сотни статей, часто выступает на многих конференциях по всему миру; кроме того, имеет звание Microsoft MVP в области F#. С ним можно связаться по адресу ted@tedneward.com или ted@itrellis.com, если вы заинтересованы в сотрудничестве.*

Джо Хаммел (Joe Hummel) — научный сотрудник и доцент Университета Иллинойса в Чикаго, автор контента для Pluralsight.com, Visual C++ MVP и частный консультант. Получил степень кандидата наук в Университетском центре Ирвина в области высокопроизводительных вычислений, интересуется параллельными вычислениями. Живет в окрестностях Чикаго, и, когда он не плавает по морю, с ним можно связаться по адресу joe@joehummel.net.

Выражаем благодарность за рецензирование статьи эксперту Microsoft Кевину Пилч-Биссону (Kevin Pilch-Bisson).