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

    У вас никогда не возникало ощущения, что в языке 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, который мне значительно помог, приведен здесь.

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 7

      –3
      > И вот вы с черной завистью посматриваете на язык Y, в котором эта штуковина есть, грустно вздыхаете и тайком льете по ночам слезы бессилия в любимую подушку. Бывало?

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

      Такие вещи нужно встраивать в сам язык. Кроме упомянутого мной Лиспа с его макросами, есть Template Haskell, есть Nemerle, да даже обычный C имеет не самую сильную, но зато удобную и поэтому широко используемую макросистему (не говоря уже про бесконечные возможности расширения препроцессора C).
        0
        ну как я уже говорил, Roslyn на самом деле не совсем для расширения языка делается, это уж мои проблемы, что я попытался использовать его таким неспецификационным образом.)
        Возможно поэтому получилось коряво. Возможно это можно сделать как-то по другому и без использования Рослина, но я, честно, не знаю. Просто хотелось попробовать Roslyn, а писать какой-то банальный code fix не очень хотелось.
          0
          Хм. Ткните пожалуйста в макро-систему на С которая позволяет работать с AST во время компиляции и встроена в компилятор? Я просто сколько на С не писал такого чуда не видел. А #define и в C# есть.
            0
            ещё раз говорю: не самую сильную, зато удобную систему макросов. речь о том, что инструмент должен быть удобным, иначе смысл его исползования сильно снижается.
          +1
          а как распарсятся «true? test?.ChainTest: a» и «test?.ChainTest? a: b»? :)
            +1
            в первом случае фигня, он забывает про " :a"
            testStr = true ? test != null ? test.ChainTest : null;

            во втором, если предполагается, что ChainTest — булево поле, то вроде нормально:
            testStr = test != null ? test.ChainTest ? a : b : null;

            Да я думаю, это не единственные каверзные случаи :) Просто не было такой задачи, выискать все каверзные случаи.
            +1
            Когда информация хранится в БД, да и просто для сокращения количества проверок на null, мы используем принудительную инициализацию значений методами расширения .ToDefault(this object, string Value ), ToInt(this object, int Value ) и т.д.

            В этих методах проверяется исходное значение на null и происходит инициализация дефолтным при необходимости.

            Код получается чистый, вполне читабельный.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое