Start Debugging

Что такое генератор исходного кода и когда он мне нужен?

Понятное руководство по генераторам исходного кода в C#: что они на самом деле делают, как работает пайплайн IIncrementalGenerator, когда они выигрывают у рефлексии или T4 и в каких случаях к ним не стоит прибегать. С работающими примерами на .NET 11 и C# 14.

Генератор исходного кода - это фрагмент кода, который компилятор C# выполняет во время компиляции вашего проекта и который может читать ваш код и добавлять новые файлы C# в ту же компиляцию. Он работает во время компиляции, выдаёт обычный исходный код, который затем компилятор компилирует так, будто вы написали его сами, и не добавляет никаких затрат во время выполнения, кроме самого порождённого кода. Он нужен вам, когда иначе вы платили бы за рефлексию во время выполнения, писали бы повторяющийся шаблонный код вручную или запускали бы отдельный шаг генерации кода вне сборки, и вы хотите, чтобы порождённый код был строго типизированным, отлаживаемым, совместимым с обрезкой (trimming) и пригодным для Native AOT. Если у вас нет ни одной из этих проблем, вам почти наверняка не нужно писать генератор. Это руководство охватывает .NET 11 (preview 5) и C# 14, но механика применима к любому проекту на .NET 6 или новее.

Что на самом деле означает “выполняется внутри компилятора”

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

Это ограничение “только добавлять” и есть весь замысел. Порождённый код входит в компиляцию как дополнительные файлы, и преобладающий приём - это partial-член: вы пишете половину класса вручную, помечаете её как partial, а генератор выдаёт другую половину. Обе половины компилируются вместе в один тип. Поскольку выход - это настоящий C#, который превращается в настоящий IL, всё, что идёт дальше, обращается с ним как с кодом, который написали вы: IntelliSense его видит, отладчик заходит в него, линкер может его обрезать, а Native AOT может скомпилировать его заранее. Никакой рефлексии во время выполнения, никакого Reflection.Emit, никаких динамических прокси.

Это ключевая ментальная модель. Генератор исходного кода - это не система макросов и не скрипт после сборки. Это функция времени компиляции из “вашего кода” в “ещё больше вашего кода”.

Почему он выигрывает у альтернатив, которые заменяет

До генераторов исходного кода (появившихся в .NET 5, Roslyn 3.8) было три способа избежать написания шаблонного кода вручную: рефлексия, эмиссия IL и внешние генераторы кода вроде шаблонов T4. У каждого есть реальная цена, которую генератор исходного кода устраняет.

Рефлексия во время выполнения (вспомните классические JSON-сериализаторы, контейнеры внедрения зависимостей, мапперы объектов) исследует типы при запуске и либо интерпретирует их при каждом вызове, либо один раз строит динамический метод и кеширует его. Это работает, но взимает налог на запуск, невидима для триммера (поэтому раздувает обрезанные и AOT-сборки либо ломает их совсем), и цена ложится на ваших пользователей, а не на вашу сборку. System.InvalidOperationException или System.PlatformNotSupportedException из рефлексии проявляется только во время выполнения, нередко в продакшене. Именно этот режим отказа мы разобрали в почему код с интенсивной рефлексией ломается под Native AOT.

T4 и другие внешние генераторы работают как отдельный шаг, обычно встроенный в сборку через собственный инструментарий. Они не видят семантическую модель (парсят текст, а не символы), порождённые файлы лежат на диске и рассинхронизируются, и в CI они неудобны. Генераторы исходного кода работают внутри той же компиляции, видят полностью разрешённые символы и никогда не записывают устаревший файл в ваш репозиторий.

Генератор исходного кода переносит всю эту работу на время компиляции и выдаёт простой, статически скомпилированный C#. Сериализатор не рефлексирует над вашим типом при запуске; у него уже есть точный код для его чтения и записи. Именно поэтому встроенный генератор исходного кода System.Text.Json - единственный путь работы с JSON, который функционирует под Native AOT, о чём мы говорили в System.Text.Json против Newtonsoft.Json в 2026 году.

Генераторы, которыми вы уже пользуетесь

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

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

Как устроен генератор: инкрементальный пайплайн

Существует два интерфейса генератора, и актуален только один. Исходный ISourceGenerator (пара Initialize/Execute, которая получала всю компиляцию и запускалась при каждом нажатии клавиши) устарел. Исходный код Roslyn говорит об этом прямо: “ISourceGenerator is deprecated and should not be implemented. Please implement IIncrementalGenerator instead.” (см. замечание об устаревании ISourceGenerator в dotnet/roslyn). Для всего нового используйте IIncrementalGenerator.

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

Вот минимальный, но полный генератор. Он находит классы, помеченные [AutoToString], и выдаёт переопределение ToString(), перечисляющее публичные свойства.

// Generator project: netstandard2.0, references Microsoft.CodeAnalysis.CSharp
// .NET 11 preview 5, Roslyn 4.x, C# 14
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

[Generator]
public sealed class AutoToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. Cheap syntactic filter, then a semantic transform into a small,
        //    value-equatable model. Returning a record (not a symbol) is what
        //    lets Roslyn cache this step and skip work when nothing changed.
        var classes = context.SyntaxProvider.ForAttributeWithMetadataName(
            "AutoToStringAttribute",
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, _) =>
            {
                var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
                var props = symbol.GetMembers()
                    .OfType<IPropertySymbol>()
                    .Where(p => p.DeclaredAccessibility == Accessibility.Public)
                    .Select(p => p.Name)
                    .ToImmutableArray();
                return new Model(symbol.ContainingNamespace.ToDisplayString(),
                                 symbol.Name, props);
            });

        // 2. Emit one source file per matched class.
        context.RegisterSourceOutput(classes, static (spc, model) =>
        {
            var sb = new StringBuilder();
            sb.AppendLine($"namespace {model.Namespace};");
            sb.AppendLine($"partial class {model.Name}");
            sb.AppendLine("{");
            sb.AppendLine("    public override string ToString() =>");
            var body = string.Join(" + \", \" + ",
                model.Properties.Select(p => $"\"{p}=\" + {p}"));
            sb.AppendLine($"        {body};");
            sb.AppendLine("}");
            spc.AddSource($"{model.Name}.AutoToString.g.cs", sb.ToString());
        });
    }

    private record Model(string Namespace, string Name,
                         ImmutableArray<string> Properties);
}

Сторона потребителя - это просто атрибут и partial class:

// Consumer project, C# 14
[AutoToString]
public partial class Order
{
    public int Id { get; set; }
    public string Customer { get; set; } = "";
}

// Elsewhere: new Order { Id = 7, Customer = "Acme" }.ToString()
// => "Id=7, Customer=Acme"   (the ToString override is generated)

Две детали несут на себе почти весь вес. Во-первых, ForAttributeWithMetadataName - это быстрая точка входа, добавленная в Roslyn 4.3: она позволяет Roslyn заранее отфильтровать узлы, которые действительно несут ваш атрибут, вместо обхода каждого синтаксического узла, и это самый сильный рычаг производительности в настоящем генераторе. Во-вторых, transform возвращает маленький record (Model), а не INamedTypeSymbol. Это важнее, чем кажется: инкрементальность зависит от равенства по значению. Как сказано в cookbook Roslyn, по пайплайну должны течь типы, сравниваемые по значению, такие как record, struct, кортежи и ImmutableArray<T>, потому что как только шаг возвращает значение, равное своему предыдущему выходу, Roslyn останавливается и переиспользует закешированный результат ниже по потоку. Передайте Symbol или Compilation через пайплайн - и вы полностью разрушите кеширование, потому что эти типы большие, сравниваются по ссылке и меняются при каждом нажатии клавиши.

Когда стоит к нему прибегнуть

Пишите или берите на вооружение генератор исходного кода, когда верно всё перечисленное:

  1. Код механический и выводим из чего-то, что уже есть в исходнике (форма типа, атрибут, сигнатура метода). Если человек, пишущий его, просто переписывал бы, генератор может это сделать.
  2. Сейчас вы платите за это рефлексией во время выполнения, и эта цена реальна: задержка запуска, аллокации на горячем пути или несовместимость с обрезкой/AOT.
  3. Вы хотите, чтобы результат был отлаживаемым и статически типизированным, а не динамическим методом, в который вы не можете зайти отладчиком.

Самые явные выигрыши: сериализация, сопоставление DTO, регистрация внедрения зависимостей, INotifyPropertyChanged, строго типизированная привязка конфигурации, генерация клиентов из контракта и замена любого горячего пути с Activator.CreateInstance или Expression.Compile. Если вы целитесь в Native AOT, расчёт ещё сильнее склоняется к генераторам, поскольку именно подходы на основе рефлексии там и ломаются. Это ограничение мы прошли в использование Native AOT с minimal API в ASP.NET Core.

Когда он вам не нужен (и писать его не стоит)

Генераторы недёшевы ни в разработке, ни в сопровождении. Не пишите собственный, когда:

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

Подводные камни, которые кусают первыми

Несколько вещей сбивают с толку всех в первый раз:

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

Источники и дополнительное чтение

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад