Pull to refresh

Расширяем C# с помощью Roslyn. Безопасные вызовы

Reading time9 min
Views8.7K
У вас никогда не возникало ощущения, что в языке X, на котором вы в данный момент программируете чего-то не хватает? Какой-нибудь небольшой, но приятной плюшки, которая может и не сделала бы вашу жизнь абсолютно счастливой, но определенно добавила бы немало радостных моментов. И вот вы с черной завистью посматриваете на язык Y, в котором эта штуковина есть, грустно вздыхаете и тайком льете по ночам слезы бессилия в любимую подушку. Бывало?

Пожалуй, C# дает своим адептам и меньше поводов для такой зависти, в сравнении с многими другими, поскольку динамично развивается, добавляя все новые и новые упрощающие жизнь фичи. И все же, нет предела совершенству, причем для каждого из нас — своего.

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

Безопасные вызовы и монада Maybe


Идея безопасных вызовов заключается в том, чтобы избавиться от надоедающих проверок любых классов на null, которые являясь необходимостью, в то же время значительно засоряют код и ухудшают его читабельность. В то же время, нет никакого желания находиться под постоянной угрозой выпадения NullReferenceException.

Данная проблема решена в функциональных языках программирования с помощью монады Maybe, суть которой заключается в том, что после boxing'а использующийся в конвеерных вычислениях тип может содержать некоторое значение, либо значение Nothing. В случае, если предыдущее вычисление в конвеере дало некоторый результат, то производится следующее вычисление, если же оно вернуло Nothing, то вместо следующего вычисления вновь возвращается Nothing.

В С# созданы все условия для реализации данной монады — вместо Nothing используется null, для структурных типов могут использоваться их Nullable<T> версии. В принципе, идея уже витает в воздухе, и было несколько статей, в которых реализовывалась данная монада в C# с помощью LINQ. Одна из них принадлежит Дмитрию Нестеруку mezastel, есть еще и другая.

Но нельзя не отметить, что при всей заманчивости такого подхода, результирующий код с использованием монады выглядит весьма туманно, из-за необходимости использовать вместо прямых вызовов обертки из лямбда-функций и LINQ. Однако без синтаксических средств языка реализовать ее более элегантно вряд ли представляется возможным.

Достаточно элегантный, как мне показалось, способ реализации данной идеи я обнаружил в спецификации еще пока не созданного языка Kotlin для JDK от ребят из горячо мною любимой компании JetBrains (Null-safety). Как оказалось, такой есть уже в Groovy, возможно и еще где-то.

Итак, что же это за оператор безопасного вызова? Предположим, у нас есть выражение:
string text = SomeObject.ToString();

В случае, если SomeObject является null, мы неминуемо, как уже говорилось, получим NullReferenceException. Чтобы этого избежать, определим в дополнение к оператору прямого вызова '.' еще и оператор безопасного вызова '?.' который выглядит следующим образом:
string text = SomeObject?.ToString();

и представляет собой на самом деле выражение:
string text = SomeObject != null ? SomeObject.ToString() : null;

В случае, если безопасно вызываемый метод или свойство возвращает структурный тип, необходимо чтобы присваиваемая переменная имела тип Nullable.
int? count = SomeList?.Count;

Как и обычные вызовы, такие безопасные вызовы можно использовать цепочками, например:
int? length = SomeObject?.ToString()?.Length;

который преобразуется в выражение:
int? length = SomeObject != null ? SomeObject.ToString() != null ? SomeObject.ToString().Length : null : null;

Здесь скрывается некоторый недостаток предлагаемого мной преобразования, поскольку оно порождает дополнительные вызовы функций. На самом деле желательно было бы преобразовывать его, например, к виду:
var temp = SomeObject;
string text = null;
if (temp != null)
    text = temp.ToString();

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

Project Roslyn


Как вы может уже слышали, совсем недавно была выпущена CTP версия проекта Roslyn, в рамках которого разработчиками языков C# и VB были полностью переписаны компиляторы языков с использованием managed кода, и открыт доступ к этим компиляторам в виде API. С его помощью разработчики могут делать много полезных вещей, например очень удобно и просто анализировать, оптимизировать, генерировать код, писать экстеншны и код фиксы для студии, а возможно и собственные DSL. Выйдет она, правда, еще не скоро, аж через одну версию Visual Studio, но пощупать хочется уже сейчас.

Перейдем к решению нашей задачи и прежде всего представим, как бы нам хотелось видеть использование данного расширения языка в действии? Очевидно: мы пишем код, как обычно, в любимой IDE, используем где надо операторы безопасного вызова, жмем Build, во время компиляции написанная нами с помощью Project Roslyn утилита преобразует все это в синтаксически верный C#-код и вуа-ля, все скомпилировано. Спешу вас разочаровать — Roslyn не позволяет вмешиваться в процесс работы текущего компилятора csc.exe, что в принципе довольно объяснимо. Вполне вероятно, если в той самой vNext студии компилятор заменят на его Managed аналог, то такая возможность появится. Но пока ее нет.

В то же время, существует аж два обходных пути:
  1. Можно создать свой собственный компилятор взамен нынешнему csc.exe с использованием все того же Roslyn API, и изменить свою build-систему, заменив csc.exe на свой аналог, включив в него помимо дефолтной компиляции (довольно, кстати, просто программирующейся) свои предварительные преобразования кода.
  2. Вы можете использовать свою консольную программу в качестве Pre-Build задачи, которая преобразует файлы исходного кода и сохраняет полученные новые исходники в папку Obj. Очень похожим образом осуществляется в данный момент компиляция WPF, когда xaml файлы в фазе pre-build преобразуются в .g.cs файлы.


Project Roslyn предоставляет несколько видов функциональности, однако одна из ключевых — построение, разбор и преобразование абстрактного синтаксического дерева. Именно эту его функциональность мы и будем использовать далее.

Имплементация


Конечно, все написанное ниже лишь пример, страдает от множества пороков и не может использоваться в реальности без существенных доработок, однако показывает, что такие вещи сделать в принципе можно.
Перейдем к реализации. Для того, чтобы написать программу, нам прежде всего надо установить Roslyn SDK, который скачивается по ссылке, также предварительно придется поставить Service Pack 1 для Visual Studio 2010, и Visual Studio 2010 SDK SP1.
После всех этих операций в меню создания новых проектов появится подпункт Roslyn, который включает в себя несколько шаблонов проектов (некоторые из которых могут интегрироваться в IDE). Мы создадим простое консольное приложение.
Для примера будем использовать следующий «исходный код»:
public class Example
{
    public const string CODE =
    @"using System;
    using System.Linq;
    using System.Windows;

    namespace HelloWorld
    {
        public class TestClass
        {
            public string TestField;
            public string TestProperty { get; set; }
            public string TestMethod() { return null; }
            public string TestMethod2(int k, string p) { return null; }
            public TestClass ChainTest;
        }

        public class OtherClass
        {

            public void Test()
            {
                TestClass test;
                string testStr1;
                testStr1 = test?.TestField;
                string testStr3 = test?.TestProperty;
                string testStr4 = test?.TestMethod();
                string testStr5 = test?.TestMethod2(100, testStr3);
                var test3 = test?.ChainTest?.TestField;
            }
        }
    }";
}

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

Прежде всего, необходимо по файлу с исходным кодом построить абстрактное синтаксическое дерево. Делается это в два счета:
SyntaxTree tree = SyntaxTree.ParseCompilationUnit(Example.CODE);
SyntaxNode root = tree.Root;

Синтаксическое дерево задается классом SyntaxTree и представляет собой, как ни странно, дерево узлов, наследуемых от базового типа SyntaxNode, каждый из которых представляет некоторое выражение — бинарные выражения, условные выражения, выражения вызова методов, определения свойств и переменных. Естественно, абсолютно любая конструкция C# может быть отображена некоторым экзмепляром класса-наследника SyntaxNode. Кроме того, класс SyntaxTree содержит в себе наборы SyntaxToken, определяющих разбор исходного кода на уровне минимальных синтаксических блоков — ключевых слов, литералов, идентификаторов и пунктуации (фигурные и круглые скобки, запятые, точки с запятыми). Наконец, SyntaxTree в содержит в себе элементы SyntaxTrivia — те, которые по большому счету не важны для понимания кода — пробелы и табуляции, комментарии, директивы препроцессора и.т.д.

Тут следует знать одну небольшую деталь — Roslyn является очень толерантным к синтаксическому разбору файлов. То есть, хотя по-хорошему, ему для разбора надо подавать синтаксически корректный исходный код, на самом деле он абсолютно любой текст пытается некоторым образом преобразовать в некоторое AST. В том числе и наш синтаксически неверный код. Этим фактом мы и воспользуемся. Попробуем построить синтаксическое дерево, и выяснить, каким же образом Roslyn отображает в дереве наш оператор безопасного вызова.

Оказывается все просто: с точки зрения Roslyn выражение test?.TestField является тернарным оператором с условием — «test», выражением «когда верно» — ".TestField", и пустым выражением «когда неверно». Вооружившись этой информацией, будем преобразовывать наше дерево. Тут натыкаемся еще на одну особенность Roslyn — строимое им синтаксическое дерево является неизменяемым, т. е. поправить что-либо прямо в имеющейся структуре не получится. Но не беда. Roslyn предлагает для такой операции использовать класс SyntaxRewriter, который наследует класс SyntaxVisitor, который, как следует из названия, имплментирует небезызвестный паттерн Visitor. Он содержит в себе множество виртуальных методов, обрабатывающих посещение узла каждого конкретного типа (например VisitFieldDeclaration, VisitEnumMemberDeclaration,… всего их порядка 180 штук).

Нам необходимо создать своего наследника класса SyntaxRewriter и переопределить метод VisitConditionalExpression, который вызывается, когда визитор обходит выражение, являющееся тернарным оператором. Далее я приведу целиком код имплементации, тем более, что он невелик, и добавлю лишь некоторые пояснения:
// Находит в синтаксическом дереве операторы безопасного вызова и заменяет их на тернарные операторы
public class SafeCallRewriter : SyntaxRewriter
{
    //Был ли в данный проход заменен хотя бы один оператор ?.
    public bool IsSafeCallRewrited { get; set; }

    protected override SyntaxNode VisitConditionalExpression(ConditionalExpressionSyntax node)
    {
        if (IsSafeCallExpression(node))
        {
            //Строим expression для объекта, проверяемого на null
            string identTxt = node.Condition.GetText();
            ExpressionSyntax ident = Syntax.ParseExpression(identTxt);

            //Строим expression для кода, вызываемого при успешной проверка на != null
            string exprTxt = node.WhenTrue.GetText();
            exprTxt = exprTxt.Substring(1, exprTxt.Length - 1);//убираем точку из записи выражения
            exprTxt = identTxt + '.' + exprTxt;
            ExpressionSyntax expr = Syntax.ParseExpression(exprTxt);

            ExpressionSyntax synt =
                Syntax.ConditionalExpression(//тернарный оператор
                condition: Syntax.BinaryExpression(//проверяемое условие ident != null
                    SyntaxKind.NotEqualsExpression,
                    left: ident, //левый операнд - проверяемый объект
                    right: Syntax.LiteralExpression(SyntaxKind.NullLiteralExpression)), //литерал null
                whenTrue: expr,
                whenFalse: Syntax.LiteralExpression(SyntaxKind.NullLiteralExpression));
            IsSafeCallRewrited = true;
            return synt;

        }
        return base.VisitConditionalExpression(node);
    }

    //Является ли тернарный оператор на самом деле оператором безопасного вызова
    private bool IsSafeCallExpression(ConditionalExpressionSyntax node)
    {
        return node.WhenTrue.GetText()[0] == '.';
    }
}

Отмечу, что первая моя реализация пыталась работать только с логической структурой AST, брезгуя работой с текстовым представлением выражений, но сложность ее очень скоро стала превышать все мыслимые пределы. Одних только функций для определения безопасного вызова и его типа было три штуки: для полей и свойств, для вызова методов, для цепочек безопасных вызовов, ибо все это представлялось разными наследниками класса SyntaxNode, и еще множество функций для преобразования различных типов безопасных операторов. Совершенно выдохнувшись, я выбросил первый вариант в мусорку и во второй раз я воспользовался удобными функциями GetText и ParseExpression, которые предоставляет Roslyn и некоторыми грязными хаками на уровне строк :).

Также советую обратить внимание на процесс создания синтаксического узла (в данном случае ConditionalExpression) и приятность использования в этом случае такой фишки C#, как именованные параметры. Ручаюсь, если бы ее не было, в процессе построения синтаксических узлов можно было бы сойти с ума.

Приведем теперь код основной процедуры:
static void Main(string[] args)
{
    //Строим синтаксическое дерево
    SyntaxTree tree = SyntaxTree.ParseCompilationUnit(Example.CODE);
    SyntaxNode root = tree.Root;
    SafeCallRewriter rewriter = new SafeCallRewriter();
    do
    {
        rewriter.IsSafeCallRewrited = false;
        //Обходим дерево, производя заданные операции в различных типах узлов и переписывая дерево
        root = rewriter.Visit(root);
    } while (rewriter.IsSafeCallRewrited);//за предыдущий проход был найден и преобразован хоть 1 maybe-оператор

    root = root.Format();//программный Ctrl+K, Ctrl+D

    Console.WriteLine(root.ToString());
}

Поясню, что несколько перезаписей дерева необходимо для того, чтобы обработать цепочки вызовов. Конечно это можно было сделать рекурсией, но пожалуй в данном случае это только затуманило бы код. Также обратите внимание на чудесную функцию Format. Она программно делает заданное стилистическое форматирование кода, т.е. добавляет в AST все необходимые SyntaxTrivia.

В результате имеем следующий код:
using System;
using System.Linq;
using System.Windows;

namespace HelloWorld
{
    public class TestClass
    {
        public string TestField;
        public string TestProperty
        {
            get;
            set;
        }

        public string TestMethod()
        {
            return null;
        }

        public string TestMethod2(int k, string p)
        {
            return null;
        }

        public TestClass ChainTest;
    }

    public class OtherClass
    {
        public void Test()
        {
            TestClass test;
            string testStr1;
            testStr1 = test != null ? test.TestField : null;
            string testStr3 = test != null ? test.TestProperty : null;
            string testStr4 = test != null ? test.TestMethod() : null;
            string testStr5 = test != null ? test.TestMethod2(100, testStr3) : null;
            var test3 = test != null ? test.ChainTest != null ? test.ChainTest.TestField : null : null;
        }
    }
}

Итак, первое знакомство с Roslyn прошло успешно, и перспективы его в целом, не обязательно для написания языковых расширений, видятся очень неплохие. Возможно, если есть энтузиасты, этим можно было бы заняться глубже и серьезнее. В C# же есть еще много, чего нам не хватает. :)

P. S. Еще один пример подобного использования Roslyn, который мне значительно помог, приведен здесь.
Tags:
Hubs:
+31
Comments7

Articles