Введение

В процессе написания source generators (далее "генераторы") для наших внутренних нужд я столкнулся с тем, что на большой кодовой базе (>250 проектов в solution, большая часть из которых с multi-targeting) обычные генераторы работают, скажем так, небыстро, существенно влияя на производительность IntelliSense в Visual Studio (который и так не то чтобы порхает как бабочка в таких условиях). Наткнувшись на описание более современного API — incremental generators, я обрадовался и обновил наши генераторы, чтобы они его реализовывали, однако ожидаемого прироста скорости не увидел (он был, но незначительный). В результате поиска ответа на вопрос "почему так и что можно сделать?" и появилась эта статья.

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

Благодарю Татьяну Пушкарёву за редактирование и корректуру, а Анастасию Полунину и Александра Беспоясова за отзывы на статью.

Для чего используют генераторы

Генераторы позволяют добавлять (не изменять или удалять) исходный код либо на этапе сборки проекта, либо на этапе его разработки, при использовании совместимой IDE. В качестве исходных данных используются сущности проекта — исходный код или дополнительные (обычно текстовые) файлы, которые содержат инструкции для генератора.

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

  • автоматизация генерации boilerplate-кода, например, реализации типовых контрактов — интерфейсов и/или наборов операторов;

  • компиляция в C# доменно-специфических языков (DSL), например, регулярных выражений, языков разметки/шаблонов и т.д. для улучшения поддержки со стороны IDE, либо максимизации производительности;

  • конструирование различных семейств (например, родов) типов, для которых недостаточно функциональности generics.

Основной источник данных для большинства генераторов — объектно-ориентированная модель проекта и синтаксических деревьев исходного кода, включённого в него. Реализация этой модели является частью .NET Compiler SDK, также известного как Roslyn.

Так как целью статьи не ставится детальное введение в задачи и способы применения генераторов, то если вы с ними не знакомы, то для начала рекомендую прочесть разбор хорошего доклада Андрея Дятлова "Source Generators в действии". Приступать к дальнейшему чтению лучше уже располагая знаниями из этого доклада.

Зачем нужны "новые" генераторы

Через некоторое время после выхода генераторов первого поколения, реализующих ISourceGenerator, команда, разрабатывающая .NET Core, столкнулась с тем, что на больших проектах (Roslyn, CoreCLR) генераторы сильно тормозят работу Visual Studio, из-за того, что на любое изменение в исходном файле внутри проекта, перезапускаются все генераторы, которые привязаны к этому проекту. Если в проекте есть несколько тысяч файлов и несколько генераторов, которые перебирают синтаксические деревья, то редактирование любого из этих файлов превращается в пытку.
Для решения этой проблемы были предложены генераторы второго поколения, инкрементные генераторы, которые реализуют IIncrementalGenerator.

Основные отличия нового поколения:

  • явная спецификация источников данных генератора и производимых им результатов;

  • разделение этапа фильтрации синтаксического дерева и его преобразования;

  • кэширование на различных этапах преобразований.

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

Инкрементные генераторы

Основное отличие инкрементных генераторов от "классических" состоит в том, что подготовка к кодогенерации становится более гранулярной и менее императивной, процесс в большей степени, чем ранее, управляется его "заказчиком", например, IDE.

Центральным элементом определения инкрементного генератора является pipeline (далее "пайплайн") — описание преобразований, которые претерпевают данные на пути от источника к результату. Регистрация пайплайна происходит в реализации IIncrementalGenerator.Initialize (фактически, это единственный метод нового интерфейса). Его аргумент, имеющий тип IncrementalGeneratorInitializationContext, содержит несколько свойств, представляющих источники данных (далее "провайдеры") — их имена оканчиваются на Provider, например, CompilationProvider или SyntaxProvider (этот пример не совсем честный, про это чуть ниже). Также, этот контекст содержит методы регистрации пайплайнов, как результатов работы генератора — их имена имеют вид Register...Output, например, RegisterSourceOutput или RegisterPostInitializationOutput.

Реализация инкрементных генераторов состоит из следующих шагов:

  1. Определить необходимые провайдеры-источники.

  2. Скомбинировать провайдеры при помощи похожих на LINQ конструкций.

  3. Зарегистрировать получившийся пайплайн как результат работы генератора.

Важно понимать, что пайплайн это (как, например, цепочка LINQ-вызовов) лишь определение порядка исполнения участков кода, которое в дальнейшем использует процесс, управляющий генерацией кода.

Пайплайны и их использование

Элементов пайплайнов всего два: IncrementalValueProvider<TSource> и IncrementalValuesProvider<TSource> (внимание на "s" в "Values"), первый является провайдером одиночных значений типа TSource (можно провести аналогию с IEnumerable<TSource>), а второй — провайдером множественных значений (IEnumerable<IEnumerable<TSource>> соответственно). Может возникнуть вполне естественный вопрос — зачем нужно такое разделение? Оно необходимо, чтобы более естественно организовать работу с коллекциями объектов и построение взаимодействий между несколькими источниками значений внутри одного пайплайна. Для понимания того, как это работает, самое время обратить внимание на то, какие типы выступают в качестве значений TSource.

В качестве примера я обращусь к основному, как минимум для моих сценариев использования, провайдеру — SyntaxProvider. Его назначение — позволить использовать фрагменты синтаксического дерева существующего кода как исходные данные для генерации кода. Выше я писал, что причисляя SyntaxProvider к провайдерам, я немного лукавлю. Остальные свойства провайдеров имеют тип IncrementalValueProvider<> или IncrementalValuesProvider<>, но SyntaxProvider это не сам провайдер, а фабрика провайдеров, которая позволяет создавать провайдеры IncrementalValuesProvider<>, основанные на синтаксических деревьях. Такое усложнение вызвано необходимостью контролировать набор узлов синтаксического дерева, являющихся источниками данных, для предотвращения потерь производительности. То есть мы можем привязать преобразование не к целому исходному файлу, а к конкретному узлу синтаксического дерева, допустим, типу или вообще — фрагменту его объявления, например, имени класса. Фабрика таких провайдеров реализуется методом CreateSyntaxProvider, принимающий два аргумента — предикат, фильтрующий узлы синтаксических деревьев и преобразование, которое превращает узел синтаксического дерева в 0 или более результатов произвольного типа, который и будет использован как TSource в результирующем IncrementalValuesProvider<>.

Предлагаю посмотреть на, хоть и не слишком полезный, но несложный пример — генератор, который создаёт файл, содержащий список имён всех классов в текущем проекте:

[Generator]
public sealed class ClassNameListGenerator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    IncrementalValueProvider<ImmutableArray<SyntaxToken>> pipeline = 
        context.SyntaxProvider.CreateSyntaxProvider( // A
            (node, _) => node is ClassDeclarationSyntax, // B
            (syntax, _) => ((ClassDeclarationSyntax)syntax.Node).Identifier) // C
            .Collect(); // D

    context.RegisterSourceOutput(pipeline, Build); // E
  }

  private void Build(
      SourceProductionContext context, 
      ImmutableArray<SyntaxToken> source)
  {
    var text = string.Join("\n", source.Select(identifier => identifier.ValueText));
    text = "/*\n" + text + "\n*/";
    context.AddSource("ClassNames", text);
  }
}

На этапе "A" мы создаём провайдер, который использует предикат "B", выбирающий из синтаксического дерева узлы, являющиеся объявлениями классов. Затем провайдер вызывает преобразование "C", которое превращает полученный узел (так как он был отфильтрован предикатом выше он всегда имеет тип ClassDeclarationSyntax) в узел, содержащий идентификатор (имя) класса.

На этапе "D" вызывается метод Collect, который превращает IncrementalValuesProvider<TSource> в IncrementalValueProvider<ImmutableArray<TSource>>, группируя полученные значения в один провайдер — так как мы пытаемся сгенерировать один файл для всех имён, имена нам нужны в качестве одного массива данных, а не их потока (перечисления).

В завершение, на этапе "E" регистрируется результат, использующий построенный пайплайн в качестве источника для генерации кода при помощи метода Build, который генерирует файл с именем "ClassNames" (расширение ".cs" будет подставлено автоматически) с очевидным содержимым.

Регистрация результатов

Для регистрации результатов у типа IncrementalGeneratorInitializationContext есть метод RegisterSourceOutput, используемый в большинстве случаев, и ещё два более редко используемых: RegisterPostInitializationOutput и RegisterImplementationSourceOutput.

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

Метод RegisterImplementationSourceOutput аналогичен RegisterSourceOutput, с одним существенным отличием — его содержимое используется только при компиляции, но не используется при синтаксическом анализе (на самом деле используется, но не учитывается во всех сценариях). Этим методом стоит пользоваться для оптимизации, когда, допустим вместе с публичным типом вы генерируете internal-тип, содержащий какие-то дополнительные методы или инструменты для публичного. Чтобы не замедлять IDE анализом лишнего типа, который кроме вашего сгенерированного типа никто использовать не должен, как раз и стоит использовать RegisterImplementationSourceOutput.

Менее похож на них метод RegisterPostInitializationOutput, который подходит только для добавления кода, не изменяющегося в течение времени жизни всего генератора, например, каких-либо типов, используемых в динамически генерируемом исходном коде. Такому подходу обычно есть лучшая альтернатива — использование библиотек, но этот момент я планирую осветить во второй части.

Построение пайплайна

Если посмотреть на построение пайплайна, то сходство с LINQ очевидно — используется "ленивая" модель применения, основная функциональность помещена в методы-расширения, центральным элементом которых является представление данных в виде провайдера.

Основных методов для построения (не считая перегрузок) шесть: Select, SelectMany, Where, Collect и Combine, кроме того немного особняком стоит метод WithComparer.

Методы Select и SelectMany делают то же самое, что и одноимённые методы в LINQ — позволяют преобразовать один провайдер (одиночный или множественный) в другой, преобразовав значения исходного провайдера. Останавливаться на них я не стану, они работают так, как вы ожидаете. SelectMany, конечно же, всегда возвращает провайдер множественных значений (который может быть и пустым).

Метод Where является обёрткой вокруг SelectMany, которая принимает предикат и позволяет отфильтровать ненужные значения. Самый популярный шаблон его использования — игнорирование null, которые могут появиться, допустим, в провайдере SyntaxProvider на этапе преобразования. Дело в том, что в предикате семантическая модель синтаксического дерева недоступна (и правильно, потому что предикат должен быть максимально быстрым, про это я расскажу позже). При этом она доступна в преобразовании (см. GeneratorSyntaxContext.SemanticModel), но преобразование не может не вернуть значения для провайдера. Однако оно может вернуть null, который можно сразу же отфильтровать:

var pipeline = context.SyntaxProvider.CreateSyntaxProvider(/* ... */, Transform)
  .Where(target => target is not null)! // A
  /* ... */;

private static Tuple<TypeDeclarationSyntax, INamedTypeSymbol>? Transform(
    GeneratorSyntaxContext context, 
    CancellationToken cancellationToken)
{
  var declaration = (TypeDeclarationSyntax)context.Node.Parent!.Parent!;
  return context.SemanticModel.GetDeclaredSymbol(declaration, cancellationToken) is INamedTypeSymbol type
      ? new(declaration, type)
      : null;
}

Здесь преобразование Transform создаёт ссылочный кортеж, содержащий узел синтаксического дерева и семантическую модель типа, объявляемого в узле. Если соответствующей узлу модели нет (например, узел содержит ошибку, приводящую к тому, что тип сгенерировать невозможно), либо она неожиданного типа, то преобразование возвращает null. Чтобы в дальнейшем не наткнуться на тип, с которым не имеет смысла работать, на этапе "A" применяется Where, который отфильтровывает неподходящие значения-кортежи. Таким образом, получается что преобразование, опционально возвращающее null плюс отфильтровывающий его Where, это полезный шаблон для осуществления уточняющей фильтрации данных для кодогенерации на основании семантической модели.

Метод Collect используют для того, чтобы превратить провайдер множественных значений в провайдер одиночных значений, представленных массивом (ImmutableArray<>). Этим методом стоит пользоваться как можно реже, поскольку он может отрицательно влиять на производительность. Более подробное описание этой проблемы и типовой путь решения будут приведены ниже.

Для описания двух оставшихся методов требуется описание основного преимущества инкрементных генераторов — кэширования и его основных проблемных мест.

Кэширование пайплайна

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

При внесении изменений в исходные данные (синтаксическое дерево исходного кода, настройки проекта и т.д.), среда разработки запускает исполнение пайплайна заново. При этом в начале каждого этапа она сверяет значения, поставляемые провайдером с теми, которые сохранены в кэше. Если значение не изменилось, то оно не передаётся дальше по пайплайну, и не будет влиять на код, генерируемый для вносимых изменений. Вполне логично, что если генератор генерирует по одному файлу для каждого из классов и зависит только от их объявлений, то если редактируется класс A, скорее всего совершенно необязательно генерировать код для каких-либо других классов, а можно воспользоваться кодом, сгенерированным в прошлой итерации.

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

Гранулярность исходных данных пайплайна определяется провайдером исходных значений (членом IncrementalGeneratorInitializationContext). Из них лишь SyntaxProvider позволяет контролировать гранулярность данных при помощи указания предиката узлов синтаксического дерева. Остальные провайдеры имеют фиксированную гранулярность, смена любого из их параметров приводит к инвалидации ветви пайплайна, привязанной к этому провайдеру.

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

По умолчанию, сравнение значений провайдеров на равенство осуществляется при помощи стандартного EqualityComparer. Узлы синтаксического дерева, например, обычно реализуют IEquatable<>, поэтому для них существует вполне очевидное определение равенства, но если вы производите какие-то собственные объекты внутри пайплайна, для них реализацию IEquatable<> почти всегда нужно реализовывать самостоятельно! Если вы упустите этот момент, то генератор будет работать, но эффективность кэширования может снизиться до нуля, и пользователи вашего генератора в больших проектах будут не слишком рады сотням миллисекунд ожидания после нажатия клавиши.

Бывает так, что для одного и того же типа значения в пайплайне нужно использовать разные способы сравнения, либо тип, являющийся значением определён не вами. Для этого пригодится метод WithComparer, позволяющий указывать реализацию IEquatable<> для значения провайдера. Основная польза этого метода будет раскрыта в следующем разделе.

Эффективные пайплайны

Для иллюстрации важности правильного выбора исходных данных обратимся к примеру с ClassNameListGenerator (назовём его IneffectiveClassNameListGenerator, скоро поймёте почему). Обратите внимание, что на шаге "C" в ClassNameListGenerator, результатом провайдера являлось не само объявление класса (ClassDeclarationSyntax, а его часть — идентификатор типа). Почему бы не сделать так, чтобы значением провайдера было объявление класса, и обращаться к его идентификатору уже внутри метода Build? Попробуем:

[Generator]
public sealed class IneffectiveClassNameListGenerator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    IncrementalValueProvider<ImmutableArray<ClassDeclarationSyntax>> pipeline = 
        context.SyntaxProvider.CreateSyntaxProvider( // A
            (node, _) => node is ClassDeclarationSyntax, // B
            (syntax, _) => (ClassDeclarationSyntax)syntax.Node) // C
            .Collect(); // D

    context.RegisterSourceOutput(pipeline, Build); // E
  }

  private void Build(
      SourceProductionContext context, 
      ImmutableArray<ClassDeclarationSyntax> source)
  {
    var text = string.Join(
        "\n", 
        source.Select(classDeclaration => classDeclaration.Identifier.ValueText));
    text = "/*\n" + text + "\n*/";
    context.AddSource("ClassNames", text);
  }
}

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

class MyClass
{
  public void MyMethod()
  {
    // Test comment
  }
}

Идентификатор в синтаксическом дереве это строка MyClass, а объявление — это всё содержимое блока кода выше. Соответственно, если вы вносите исправление, допустим, в объявление метода MyClass.MyMethod, то идентификатор типа не меняется, ведь класс по-прежнему называется MyClass, а вот объявление типа изменяется. Нужно понимать, что синтаксические деревья включают в себя и элементы, не влияющие на скомпилированный код, например, комментарии или пробельные символы за пределами строковых констант. То есть даже редактирование комментария "Test comment" или изменение отступов в коде, приведёт к изменению объявления класса.
В случае ClassNameListGenerator, список имён классов будет генерироваться только при редактировании их имён. В случае же IneffectiveClassNameListGeneratorпри любом редактировании объявления класса! Таким образом, при выборе исходных данных, используя SyntaxProvider критически важно выбирать минимально необходимые для генератора данные, чтобы сократить количество генераций кода.

Предыдущий пример рассматривает важность выбора преобразования в SyntaxProvider, но у него есть ещё и предикат. Его необходимо делать максимально селективным в отношении узлов синтаксического дерева, при этом он должен быть быстрым (и, желательно, не выделять память), так как эти предикаты вызываются на любое изменение исходного кода в проекте. В качестве примера улучшения селективности могу привести следующий пример-рекомендацию. Допустим, вы хотите генерировать что-либо для всех классов, отмеченных каким-либо атрибутом. Первое что приходит в голову в качестве предиката, раз нам нужны классы — node is ClassDeclarationSyntax. Однако давайте подумаем, в среднем (если мы не говорим о сборке с DTO, размеченными для сериализации), атрибуты встречаются в исходном коде реже, чем объявления классов. Таким образом, в предикате выгоднее "целиться" в атрибут, проверяя чтобы он имел соответствующее имя и был привязан к классу:

return node is AttributeSyntax attribute
  && attribute.Name is SimpleNameSyntax name // Эта проверка упрощена для примера
  && name.Identifier.Text == "MyAttribute"   // Не используйте такую проверку в реальном коде
  && attribute.Parent?.Parent is ClassDeclarationSyntax;

(почему проверка выше не совсем правильная, расскажу в следующей части)

Продолжим исследовать построение пайплайна. Методы Select, SelectMany и Where, так же как и WithComparer просто продлевают ветку существующего пайплайна на один этап. При этом, в IncrementalGeneratorInitializationContext есть несколько провайдеров. Что делать, если генератору нужен доступ к синтаксическому дереву через SyntaxProvider, при этом он хочет использовать какие-то параметры проекта через CompilationProvider, либо кэшировать реализацию генератора? Для этого существует метод Combine, который комбинирует два провайдера, порождая третий, являющийся произведением множеств значений обоих провайдеров. Наиболее типичным его применением является комбинирование провайдера данных синтаксического дерева и одного из других провайдеров.

Приведу пример такого использования для кэширования состояния генератора (в данном случае StringBuilder):

[Generator]
public sealed class BuilderClassNameListGenerator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    IncrementalValueProvider<(ImmutableArray<SyntaxToken> Left, Builder Right)> pipeline =
        context.SyntaxProvider.CreateSyntaxProvider(
            (node, _) => node is ClassDeclarationSyntax,
            (syntax, _) => ((ClassDeclarationSyntax)syntax.Node).Identifier)
            .Collect()
            .Combine(context.CompilationProvider.Select((_, _) => new Builder())) // A
            .WithComparer(PipelineComparer.Instance); // B

    context.RegisterSourceOutput(pipeline, Build);
  }

  private void Build(
    SourceProductionContext context, 
    (ImmutableArray<SyntaxToken> Left, Builder Right) source)
    => source.Right.Build(context, source.Left); // C

  private sealed class Builder
  {
    private readonly StringBuilder _builder = new();

    public void Build(
        SourceProductionContext context, 
        ImmutableArray<SyntaxToken> source)
    {
        try
        {
            _builder.Append("/*");
            foreach (var identifier in source)
            {
                _builder.Append('\n');
                _builder.AppendLine(identifier.ValueText);
            }

            _builder.Append("\n*/");
            context.AddSource("ClassNames", _builder.ToString());
        }
        finally
        {
            _builder.Clear();
        }
    }
  }

  private sealed class PipelineComparer : 
      IEqualityComparer<(ImmutableArray<SyntaxToken> Target, Builder _)>
  {
    public static PipelineComparer Instance { get; } = new();

    public bool Equals(
        (ImmutableArray<SyntaxToken> Target, Builder _) x,
        (ImmutableArray<SyntaxToken> Target, Builder _) y)
        => x.Target.SequenceEqual(y.Target); // D

    // Реализация далека от идеала, только для примера
    public int GetHashCode((ImmutableArray<SyntaxToken> Target, Builder _) obj)
        => obj.Target.FirstOrDefault().GetHashCode(); 
  }
}

Здесь на этапе "A" применяется метод Combine, объединяющий два провайдера в один, значениями которого является кортеж из двух элементов, у которого Left это значение левого, относительно выражения Combine, провайдера: ImmutableArray<SyntaxToken>, а Right — правого: Builder, который создаётся для каждого объекта Compilation, то есть для каждого проекта. Этап "B" позволяет указать особую реализацию сравнения, которая на этапе "D" будет игнорировать конкретные Builder так, чтобы не перегенерировать код, если изменится только Builder из-за смены настроек проекта (в нашем случае это допустимо, потому что Builder не использует настройки проекта для кодогенерации). На этапе "C" мы адаптируем вызов, чтобы обратиться к нашему Builder, передав ему необходимые данные. Таким образом, в рамках одного проекта, объект Builder не будет пересоздаваться каждый раз, что позволит увеличить производительность генератора.

Вероятно после прочтения этого раздела стало понятно, почему Collect обычно является плохим вариантом, если нужно сгенерировать несколько файлов, например, по одному для каждого набора синтаксических деревьев. Он "убивает" ленивое исполнение пайплайна и кэширование за счёт превращения потока значений в их массив. Если, например, он находится на финальном этапе и содержит несколько узлов синтаксического дерева, для каждого из которых генерируется код, то обновление любого из этих узлов приведёт к генерации кода для всех из них. Получается что Collect в общем случае это зло, и имеет смысл только для агрегирования результатов? В теории да, но на практике — пора перейти к следующему разделу.

Некоторые нюансы пайплайнов

Если бы всё работало как задумывалось (как нечасто это бывает), то Collect был бы нужен крайне редко. Но из-за проблемы, описанной в dotnet/runtime#57991, фактически рекомендуется делать Collect в конце любого пайплайна. Получается что эффективного кэширования не получить? Не отчаивайтесь, после Collect можно продолжить пайплайн так, чтобы превратить его в провайдер множественных значений, сделав SelectMany(values => values). Это приводит нас к другому нюансу, даже скорее граблям, на которые часто наступают при разработке генератора в первый раз.

Благодаря удобству работы с синтаксическим деревом (низкий поклон разработчикам Roslyn), часто забываешь, что это именно синтаксическое дерево, а не результат рефлексии уже скомпилированного кода. Обычно одному объявлению синтаксического дерева соответствует один объект в результирующей сборке, но не всегда, и часто это происходит с популярными для кодогенерации узлами, например, объявлениями типов. Нюанс в том, что типы могут быть отмечены как partial (а для кодогенерации это очень частый сценарий), и, как следствие, для одного типа может существовать несколько объявлений. Если генерировать код для каждого из таких объявлений, то пользователя генератора будет ждать неприятный сюрприз в виде сломанной сборки. Для предотвращения такой проблемы стоит использовать Distinct для коллекции типов с особенным IEqualityComparer<>. В этот раз рассмотрим не полный генератор, а небольшой фрагмент:

var comparer = new CustomComparer();
var pipeline = context.SyntaxProvider.CreateSyntaxProvider(/* ... */)
  .WithComparer(comparer) // A
  .Collect() // B
  .SelectMany((targets, _) => targets.Distinct(comparer)); // C

В этом примере сочетается несколько рекомендаций. На этапе "A" мы устанавливаем реализацию сравнения, которая сравнивает только значимые типы (получая их, например, при помощи SemanticModel.GetDeclaredSymbol). На этапе "B" мы делаем Collect, чтобы решить проблему с багом, описанную в начале текущего раздела. А на этапе "C" мы решаем и проблему с Collect, и проблему с несколькими объявлениями одним махом.

Кроме описанного выше, не стоит забывать, что генераторы кода в основном работают внутри среды разработки, интерактивность которой не должна страдать. В большей степени это обеспечивается правильным конструированием пайплайнов, но есть ещё один момент. Многие делегаты, передаваемые провайдерам получают в качестве аргумента CancellationToken. Его очень рекомендуется передавать дальше, а в случае осуществления любой нетривиальной работы, например, потенциально длинных циклов — проверять самостоятельно. В преобразование для SyntaxProvider.CreateSyntaxProvider, например, CancellationToken передаётся как самостоятельный аргумент (при использовании семантической модели вы найдёте куда его передать), а в делегат для IncrementalGeneratorInitializationContext.RegisterSourceOutput — как одноимённое свойство передаваемого SourceProductionContext (и здесь, если ваш генератор совершает длительную работу, во время неё стоит проверять context.CancellationToken.IsCancellationRequested).

Выводы

Если вдруг вы решили не читать статью, то "чтобы всё было хорошо" нужно:

  • использовать максимально селективный, при этом быстрый предикат для SyntaxProvider.CreateSyntaxProvider;

  • убедиться, что все значения сравниваются правильно (реализуют IEquatable<> и/или используют WithComparer);

  • использовать на финальном этапе Collect, а сразу за ним -- SelectMany, вероятно с Distinct внутри.

В следующей части:

  • подходы к генерации исходного кода и неочевидные моменты;

  • способы реализации общей функциональности;

  • диагностика и обработка ошибок;

  • популярные шаблоны;

  • библиотека для упрощения написания генераторов;

  • описание третьей части.

Источники

  1. Дятлов Андрей, Source Generators в действии — очень подробный доклад про классические source generators, много хороших примеров

  2. Lock Andrew, Source generator updates: incremental generators — переход от классических генераторов к инкрементным

  3. Gerr Pawel, Incremental Roslyn Source Generators In .NET 6 — детальный разбор инкрементных генераторов

  4. Lock Andrew, Series: Creating a source generator — ещё один детальный разбор инкрементных генераторов

  5. Разработчики Roslyn, Incremental Generators

Комментарии (11)


  1. AgentFire
    00.00.0000 00:00

    Интересно, а есть генераторы, которые бы позволили генерировать .NET типы с методами "для удобства работы разработчика и т.п. IntelliSense", но без костыля в виде Source, т.е. шарпового кода? Строготипизированные, так сказать, генераторы сразу готовых типов (классов, структур, интерфейсов и т.д.) вместо текста-кода, который потом компилируется во всё то же самое?


    1. onyxmaster Автор
      00.00.0000 00:00

      «Сразу готовых» имеются в виду DLL? Тогда пришлось бы делать компиляцию прямо во время генерации, кажется это будет не слишком быстро.

      А в чём, по-вашему, проблема с использованием исходного кода как источника для компиляции сгенерированного кода?


      1. AgentFire
        00.00.0000 00:00

        Ну смотрите.

        Исходная задача: иметь динамически изменяющуюся (от работы разработчика с кодом проекта) инфраструктуру из генерируемых типов и членов этих типов, а также помощь разработчику в виде подсветки IntelliSense. Это если я правильно понимаю полноту всей задачи, которую призван решить инструмент Source Generators.

        Здесь следует обратить внимание на то, что нигде в задаче прямо не сказано, что требуется генерировать именно исходный код в виде текста. Да и какой в этом может быть смысл, если подумать? Нажать F12 и посмотреть реализацию какого-нибудь автосгенерированного метода или всего типа? Но для этого подойдёт и обычный встроенный в IDE-шку декомпилятор.

        Из чего следует, что генерация сперва корректного кода, и только затем его последующая интерпретация инструментариями Source Generators во временные недосборки и их частичное подключение к среде разработки - процесс весьма неоптимальный. Мы с лёгкостью могли бы генерировать сразу типы, члены типов и инструкции для тел методов этих типов.

        Если приводить аналогию, то это как если бы для обращения к какому-то классу-сервису, который ожидает от нас определенную DTO с данными, сперва генерировали бы текстовый JSON этой модели, а затем десериализовывали бы её в требуемую DTO-шку.

        Поэтому и возникает резонный вопрос, а зачем нам эти танцы с генерацией текста, когда логичнее (и правильнее, и оптимальнее) было бы генерировать сразу нужные структуры для решения поставленной задачи.


        1. onyxmaster Автор
          00.00.0000 00:00

          Да, но текст на C# генерировать значительно проще чем IL =) В смысле для головы программиста.


          1. AgentFire
            00.00.0000 00:00

            Мне кажется, не должно быть сложно придумать способ, работающий без Reflection.Emit.

            IDE-шка ведь имеет представление о нашем коде без какой-либо компиляции, прямо в процессе написания кода. Значит, у неё в памяти уже есть модельки, представляющие собой описание юзеровых типов и их членов. Осталось только прикрутить генерацию именно этих самых моделек (вынеся их в public api) в output инструмента "source" generators.

            А тел методов можно и через делегаты реализовывать.


            1. onyxmaster Автор
              00.00.0000 00:00

              Эти модельки в общем-то и есть Roslyn, и такой способ генерации есть, но он всё равно в итоге генерирует текстовый файл, который разбирается им же в IDE.


            1. Proydemte
              00.00.0000 00:00
              +1

              У f# есть такая вещи как "type provider" (например для sql) наверное можно сделать type provider и для исходников c# кода.


        1. Proydemte
          00.00.0000 00:00
          +1

          Debug сильно лучше, когда по коду идёшь вместо IL.


    1. zetroot
      00.00.0000 00:00
      +1

      Есть System.Reflection.Emit, но он будет работать в run time. Можно придумать что-нибудь с реализацией интерфейсов или абстрактным базовым классом, чтобы в compile time работал itellisense.

      Кажется что в compile time без хоть каких то исходников не обойтись никак:-)


  1. Proydemte
    00.00.0000 00:00
    +2

    Было бы хорошо иметь возможность указать что соурс генератор "тяжёлый" и должен исполняться только на build, а не при каждом чихе.


    Или по крайней мере передовать генератору какое событие послужило его вызову (изменение текста, билд или что-то ещё), чтобы генератор мог сам решить что делать.


    Ну и самое интересное это сделать новый тип генераторов, которые на билд меняли существий исходный код, а не создавали новый. Тогда AOP можно было бы действительно внедрить в повседневную практику.


  1. egorozh
    00.00.0000 00:00
    +1

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