[.NET, Visual Studio, C#, Xamarin] Оживляем деревья выражений кодогенерацией

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
02-Янв-2021 01:31

Деревья выражений System.Linq.Expressions дают возможность выразить намерения не только самим кодом, но и его структурой, синтаксисом.Их создание из лямбда-выражений — это, по сути, синтаксический сахар, при котором пишется обычный код, а компилятор строит из него синтаксическое дерево (AST), которое в том числе включает ссылки на объекты в памяти, захватывает переменные. Это позволяет манипулировать не только данными, но и кодом, в контексте которого они используются: переписывать, дополнять, пересылать, а уже потом компилировать и выполнять.Run-time компиляция порождает производительные делегаты, которые часто быстрее тех, что компилируются во время сборки (за счет меньшего оверхеда). Однако сама компиляция происходит до десятков тысяч раз дольше, чем вызов результата компиляции.(бенчмарк)Извините, данный ресурс не поддреживается. :( ДействиеВремя, нсCached Compile Invoke0.5895 ± 0.0132 nsCompile and Invoke83,292.3139 ± 922.4315 nsЭто особенно обидно, когда выражение простое, например содержит только доступ к свойству (в библиотеках для маппинга, сериализации, дата-байндинга), вызову конструктора или метода (для IoC/DI решений).Скомпилированные делегаты обычно кэшируют, чтобы переиспользовать, но это не спасает в сценариях, когда первый доступ происходит к большому количеству за раз. В таких случаях время run-time компиляции выражений становится значимым и оттягивает запуск приложения или отдельных окон.Для уменьшения времени получения делегатов из деревьев выражений используют:
  • Встроенную интерпретацию.
    Необходимость использования интерпретатора вместо компилятора указывается соответствующим флагом:
    Expression.Compile(preferInterpretation: true)
    Происходит через рефлексию, но с накладными расходами на формирование стека инструкций.Для платформ Xamarin.iOS, Xamarin.watchOS, Xamarin.tvOS, Mono.PS4 и Mono.XBox стандартная компиляция через генерацию IL (System.Reflection.Emit) долгое время была недоступна и на данный момент под капотом всегда откатывается к этому варианту.
  • FastExpressionCompile от @dadhi.
    Ускоряет компиляцию за счет оптимизиpованной генерации IL и с меньшим количеством проверок совместимости.На платформах без поддержки JIT компиляции может использоваться только с включенным Mono Interpreter.
  • Ручную интерпретацию.
    Используется для оптимизации вызовов рефлексии под специальные сценарии использования, например для добавления кэширования отдельных вызовов.Интерпретируя вручную, уже можно воспользоваться способами ускорения рефлексии. Самые эффективные из них, например Fasterflect, используют System.Reflection.Emit и на некоторых платформах так же могут требовать включения Mono Interpreter.
Для случаев, когда производительности указанных методов недостаточно, напрашивается решение:Компилировать выражения или какие-то их части во время написания кода (design-time) или сборки (compile-time).Для compile-time компиляции делегатов к фрагментам деревьев выражений требуется сгенерировать соответствующий код.API доступа к делегатам не стоит генерировать вместе с ними, лучше держать его в отдельной сборке. Причина заключается в том, что анализ и использование деревьев выражений часто происходит в проектах, отдельных от их инициализации. Фреймворки для дата-байндинга и DI — это сторонние библиотеки, да и сами программы часто разбиваются на несколько сборок из соображений архитектуры.От самого API требуется только давать нужный делегат по ключу, как в словаре. У интересующих нас фрагментов кода: методов, конструкторов и свойств на стыке run-time и compile-time естественный идентификатор — это сигнатура. По ней генерируемый код будет класть делегаты в словарь, а клиенты забирать.Например, для класса со свойством
namespace Namespace
{
  public class TestClass
  {
    public int Property { get; set; }
  }
}
используемым внутри System.Linq.Expressions.Expression<T> лямбды
Expression<Func<TestClass, int>> expression = o => o.Property;
делегатами чтения и записи в общем виде являются
Func<object, object> _ = obj => ((Namespace.TestClass)obj).Property;
Action<object, object> _ => (t, m) => ((Namespace.TestClass)t).Property
  = (System.Int32)m;
и генерируемый код для их регистрации будет примерно таким:
namespace ExpressionDelegates.AccessorRegistration
{
  public static class ModuleInitializer
  {
    public static void Initialize()
    {
      ExpressionDelegates.Accessors.Add("Namespace.TestClass.Property",
        getter: obj => ((Namespace.TestClass)obj).Property,
        setter: (t, m) => ((Namespace.TestClass)t).Property = (System.Int32)m);
    }
  }
}
ГенерацияНаиболее известные решения для кодогенерации, на мой взгляд, это: Отдельная область применения есть у каждого решения, и только Roslyn Source Generators умеет анализировать исходный C# код даже в процессе его набора.Кроме того, именно Roslyn Source Generators видятся более или менее стандартом для кодогенерации, т. к. были представлены как фича основного компилятора языка и используют Roslyn API, используемый в анализаторах и code-fix.Принцип работы Roslyn Source Generators описан в дизайн-документе (местами не актуален!) и гайде.
Вкратце: для создания генератора требуется создать реализацию интерфейса
namespace Microsoft.CodeAnalysis
{
  public interface ISourceGenerator
  {
    void Initialize(GeneratorInitializationContext context);
    void Execute(GeneratorExecutionContext context);
  }
}
и подключить ее к проекту как анализатор.Метод Initialize пригодится для выполнения какой-либо единоразовой логики. GeneratorInitializationContext на данный момент может быть полезен только для подключения посетителя узлов синтаксиса кода.В Execute имеется контекст, из которого можно как собрать информацию по существующему коду, так и, собственно, добавить новый.Для каждого файла исходного кода Roslyn предоставляет синтаксическое дерево в виде объекта SyntaxTree:
GeneratorExecutionContext.Compilation.SyntaxTrees
а так же семантическую модель:
semanticModel =
  GeneratorExecutionContext.Compilation.GetSemanticModel(SyntaxTree)
Последняя нужна, чтобы по участку кода (узлу синтаксиса) понять его связи с другими частями программы, типами, другими сборками.Среди всех узлов синтаксических деревьев сборки нам нужно найти только интересующие нас лямбда-выражения типа System.Linq.Expressions.Expression<T> и отобрать из их узлов-потомков выражения, описывающие доступ к членам классов, создание объектов и вызов методов:Извините, данный ресурс не поддреживается. :( По семантике узла, так называемому символу (Symbol), можно определять:
  • типы, используемые выражением;
  • область видимости;
  • IsStatic, IsConst, IsReadOnly и другие характеристики.
На основе такой информации и будем генерировать подходящий код.В Roslyn API (Microsoft.CodeAnalysis) построить сигнатуру намного проще, чем c API рефлексии (System.Reflection). Достаточно сконвертировать символ в строку при помощи методаISymbol.ToDisplayString(SymbolDisplayFormat) c подходящим форматом:Извините, данный ресурс не поддреживается. :( Зная сигнатуры свойства/поля, его типа и обладателя формируем строки для добавления делегатов:Извините, данный ресурс не поддреживается. :( Оформляем код добавления делегатов в класс и отдаем компилятору:
var sourceBuilder = new StringBuilder(
@"namespace ExpressionDelegates.AccessorRegistration
{
  public static class ModuleInitializer
  {
    public static void Initialize()
    {");
      foreach (var line in registrationLines)
      {
        sourceBuilder.AppendLine();
        sourceBuilder.Append(' ', 6).Append(line);
      }
      sourceBuilder.Append(@"
    }
  }
}");
GeneratorExecutionContext.AddSource(
  "AccessorRegistration",
  SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
Этот код обязательно будет добавлен в сборку ...если генератор сможет отработать :)Дело в том, что хоть Source Generators технически и не фича языка, поддерживаются они только в проектах с C# 9+. Позволить такую роскошь без костылей и ограничений на данный момент могут только проекты на .NET 5.СовместимостьПоддержку Roslyn Source Generators API для .NET Standard, платформ .NET Core, .NET Framework и даже Xamarin поможет организовать Uno.SourceGeneration.Uno.SourceGeneration предоставляет собственные копии интерфейса ISourceGenerator и атрибута [Generator], которые при миграции на С# 9 меняются на оригинальные из пространства имен Microsoft.CodeAnalysis простым удалением импортов Uno:
using Uno.SourceGeneration;
using GeneratorAttribute = Uno.SourceGeneration.GeneratorAttribute;
using ISourceGenerator = Uno.SourceGeneration.ISourceGenerator;
Для подключения достаточно добавить несколько строк в файл проекта.Извините, данный ресурс не поддреживается. :( В проект, где генератор будет использоваться:
<ItemGroup>
  <SourceGenerator Include="PATH\TO\GENERATOR.dll" />
</ItemGroup>
Например, распространяя генератор через nuget, подключение можно осуществлять вложениемMSBuild props файла со следующим путём:Извините, данный ресурс не поддреживается. :( ИнициализацияТак как API для доступа к делегатам у нас в одной сборке, а код деревьев выражений и соответственно сгенерированные для них делегаты в других, необходим механизм их автоматической инициализации.Для этих целей отлично подходит Module Initializer. Это конструктор сборки (а точнее ее модуля), который запускается сразу после ее загрузки и до вызовов к остальному коду. Он давно есть в CLR, но к сожалению, в C# его поддержка c атрибутом [ModuleInitializer] добавлена только в 9 версии.Решение по добавлению конструктора в сборку с более широкой поддержкой платформ есть у Fody — плагин Fody.ModuleInit. После компиляции добавляет классы с именами ModuleInitializer в конструктор сборки. В такой класс и будем оборачивать инициализацию сгенерированных делегатов.Подключение Fody.ModuleInit через MSBuild свойства вместо FodyWeavers.xml исключит конфликты с другими Weaver-ами Fody в проекте клиента.ИспользованиеТаким образом, при сборке проекта:
  • Source Generator добавит в сборку код, регистрирующий делегаты для деревьев выражений, в обертке класса ModuleInitializer.
  • Fody.ModuleInit добавит ModuleInitializer в конструктор сборки.
  • Во время работы приложения при подгрузке сборки выполнится ModuleInitializer, и сгенерированные делегаты будут добавлены к использованию.
Проверяем:
Expression<Func<string, int>> expression = s => s.Length;
MemberInfo accessorInfo = ((MemberExpression)expression.Body).Member;
Accessor lengthAccessor = ExpressionDelegates.Accessors.Find(accessorInfo);
var length = lengthAccessor.Get("17 letters string");
// length == 17
При декомпиляции сборки видно, что сгенерированный код и инициализатор модуля на месте:
БенчмаркиСгенерированные делегаты выполняются немного медленнее, чем обычные из-за приведения типов и возможной упаковки параметров значимых типов.ДействиеВремя, нсВызов простого делегата конструктора4.6937 ± 0.0443Вызов сгенерированного делегата конструктора5.8940 ± 0.0459Поиск и вызов сгенерированного делегата конструктора191.1785 ± 2.0766Компиляция выражения и вызов конструктора88,701.7674 ± 962.4325Вызов простого делегата доступа к свойству1.7740 ± 0.0291Вызов сгенерированного делегата доступа к свойству5.8792 ± 0.1525Поиск и вызов сгенерированного делегата доступа к свойству163.2990 ± 1.4388Компиляция выражения и вызов геттера88,103.7519 ± 235.3721Вызов простого делегата метода1.1767 ± 0.0289Вызов сгенерированного делегата метода4.1000 ± 0.0185Поиск и вызов сгенерированного делегата метода186.4856 ± 2.5224Компиляция выражения и вызов метода83,292.3139 ± 922.4315Полный вариант таблицы, с бенчмарками интерпретации.А судя по результату профилирования поиска сгенерированного делегата, самое долгое — построение сигнатуры, ключа для поиска.
Flame-график бенчмарка поиска и вызова сгенерированного делегата доступа к свойствуИдеи насчёт оптимизации построения сигнатур по System.Reflection.MemberInfo приветствуются. Реализация на момент написания.ЗаключениеПо итогу получилось современное решение для кодогенерации с актуальной совместимостью и автоматической инициализацией.Полный код можно посмотреть на: github/ExpressionDelegates, а подключить через nuget.Для тех, кто будет пробовать Source Generators хотелось бы отметить несколько полезностей:
  • Source Generator Playground (github).
    Позволяет экспериментировать с Roslyn Source Generators в браузере, онлайн.
  • Окно визуализации синтаксиса для Visual Studio.
    Удобный инструмент для знакомства с Roslyn Syntax API на собственном коде.
  • Отлаживается Source Generator вызовом отладчика из его кода. Пример.
    Для этого нужен компонент Visual Studio «Just-In-Time debugger» и включенная настройка Tools -> Options -> Debugging -> Just-In-Time Debugging -> ☑ Managed.
  • В сгенерированных *.cs файлах срабатывают брейкпоинты, проверено в Visual Studio 16.8.
    При генерации через Uno.SourceGeneration файлы размещаются по пути: \obj\{configuration}\{platform}\g\.
    С Roslyn Source Generators их появление включается через MSBuild свойство EmitCompilerGeneratedFiles.
    Стандартный путь: \obj\{configuration}\{platform}\generated\, переопределяется в свойстве CompilerGeneratedFilesOutputPath.
  • Source Generators можно конфигурировать свойствами MSBuild.
    При использовании Uno.SourceGeneration значение получают вызовом
    GeneratorExecutionContext.GetMSBuildPropertyValue(string)
    Для Roslyn Source Generators требуемые свойства необходимо сперва отдельно обозначить в MSBuild группе CompilerVisibleProperty и только после вызывать:
    GeneratorExecutionContext.AnalyzerConfigOptions.GlobalOptions
      .TryGetValue("build_property.<PROPERTY_NAME>", out var propertyValue)
  • Из генератора можно кидать предупреждения и ошибки сборки.
    //Roslyn Source Generators
    GeneratorExecutionContext.ReportDiagnostic(Diagnostic)
    //Uno.SourceGeneration:
    GeneratorExecutionContext.GetLogger().Warn/Error().

===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_.net, #_visual_studio, #_c#, #_xamarin, #_linq, #_expression_trees, #_roslyn, #_source_generators, #_kodogeneratsija (кодогенерация), #_uno, #_fody, #_module_initializer, #_refleksija (рефлексия), #_derevja_vyrazhenij (деревья выражений), #_.net, #_visual_studio, #_c#, #_xamarin
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 18:56
Часовой пояс: UTC + 5