У попередньому розділі ми навчилися замінювати рефлексію на compiled expressions для отримання швидкості прямого виклику. Але що, якщо ми можемо зовсім уникнути runtime overhead?
Source Generators (Генератори Вихідного Коду) — це compile-time технологія, введена в .NET 5 та C# 9, що дозволяє генерувати C# код під час компіляції на основі аналізу існуючого коду.
| Підхід | Коли виконується | Overhead | Типобезпека | IDE Support |
|---|---|---|---|---|
| Reflection | Runtime | 50-100x | ❌ | ❌ |
| Expression Trees | Runtime (компіляція 1 раз) | 1.2x після | ✅ | Partial |
| Source Generators | Compile-Time | 0x | ✅ | ✅ Full |
Source Generator — це спеціальний .NET компонент (NuGet пакет), що:
| Фреймворк | Що генерує | Навіщо |
|---|---|---|
| System.Text.Json | Серіалізатори для типів | Швидша серіалізація без рефлексії |
| ASP.NET Core Minimal APIs | Endpoint handlers | Оптимізація request processing |
| Entity Framework Core | Compiled models | Швидший startup без runtime model building |
| MVVM Toolkit | Property/Command boilerplate | Менше рукописного коду |
| Mapperly | Object-to-object mappers | AutoMapper без runtime overhead |
Roslyn — це компілятор C#/VB.NET, написаний на C#, який надає API для аналізу та модифікації коду.
Ієрархія:
Різниця з Expression Trees:
Генератор має бути окремою бібліотекою типу netstandard2.0:
dotnet new classlib -n MyGenerator
cd MyGenerator
dotnet add package Microsoft.CodeAnalysis.CSharp
dotnet add package Microsoft.CodeAnalysis.Analyzers
Важливо: У .csproj додати:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project>
Пояснення:
netstandard2.0 — вимога RoslynPrivateAssets="all" — не передавати залежності споживачамusing Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;
namespace MyGenerator;
[Generator]
public class HelloWorldGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// Опціонально: реєстрація callbacks
}
public void Execute(GeneratorExecutionContext context)
{
// Генеруємо код
string sourceCode = @"
namespace Generated
{
public static class HelloWorld
{
public static string GetMessage() => ""Hello from Source Generator!"";
}
}";
context.AddSource("HelloWorld.g.cs", SourceText.From(sourceCode, Encoding.UTF8));
}
}
Пояснення:
[Generator] — атрибут для позначення генератораISourceGenerator (застарілий, але простіший інтерфейс)AddSource — додає згенерований файл до компіляціїУ вашому основному проєкті:
<ItemGroup>
<ProjectReference Include="..\MyGenerator\MyGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
using Generated;
Console.WriteLine(HelloWorld.GetMessage());
// Output: "Hello from Source Generator!"
Execute:System.Diagnostics.Debugger.Launch(); // Attach debugger
.NET 6+ ввів IIncrementalGenerator — більш ефективну та композитну альтернативу ISourceGenerator.
Проблема: При зміні одного файлу компілятор запускає генератор знову. Якщо генератор аналізує весь проєкт → повільно.
Рішення: Incremental generators кешують результати та перевираховують тільки змінені частини.
using Microsoft.CodeAnalysis;
[Generator]
public class MyIncrementalGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1. Створити pipeline для аналізу коду
var classDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: (node, _) => node is ClassDeclarationSyntax,
transform: (ctx, _) => (ClassDeclarationSyntax)ctx.Node
);
// 2. Зареєструвати generation step
context.RegisterSourceOutput(classDeclarations, Execute);
}
private void Execute(SourceProductionContext context, ClassDeclarationSyntax classDecl)
{
// 3. Генерувати код для кожного класу
string className = classDecl.Identifier.Text;
string code = $"// Generated for {className}";
context.AddSource($"{className}.g.cs", code);
}
}
Pipeline Explanation:
Execute для кожного відфільтрованого nodeСтворимо генератор, що створює ToString() для класів з атрибутом [GenerateToString].
Спочатку створюємо атрибут (в основному проєкті або згенерований):
namespace MyAttributes;
[AttributeUsage(AttributeTargets.Class)]
public class GenerateToStringAttribute : Attribute { }
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
namespace MyGenerator;
[Generator]
public class ToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Фільтруємо класи з атрибутом [GenerateToString]
var classesWithAttribute = context.SyntaxProvider
.ForAttributeWithMetadataName(
"MyAttributes.GenerateToStringAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax,
transform: GetClassInfo
)
.Where(info => info is not null);
context.RegisterSourceOutput(classesWithAttribute, Execute!);
}
private static ClassInfo? GetClassInfo(
GeneratorAttributeSyntaxContext context,
CancellationToken ct)
{
if (context.TargetNode is not ClassDeclarationSyntax classDecl)
return null;
var symbol = context.TargetSymbol as INamedTypeSymbol;
if (symbol is null) return null;
// Отримати всі публічні властивості
var properties = symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.Select(p => p.Name)
.ToImmutableArray();
return new ClassInfo(
symbol.ContainingNamespace.ToDisplayString(),
symbol.Name,
properties
);
}
private void Execute(SourceProductionContext context, ClassInfo classInfo)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine($"namespace {classInfo.Namespace}");
sb.AppendLine("{");
sb.AppendLine($" partial class {classInfo.ClassName}");
sb.AppendLine(" {");
sb.AppendLine(" public override string ToString()");
sb.AppendLine(" {");
// Генерувати інтерполяцію
sb.Append($" return $\"{classInfo.ClassName} {{ ");
var propStrings = classInfo.Properties
.Select(p => $"{p} = {{{p}}}");
sb.Append(string.Join(", ", propStrings));
sb.AppendLine(" }\";");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
context.AddSource($"{classInfo.ClassName}.ToString.g.cs", sb.ToString());
}
private record ClassInfo(
string Namespace,
string ClassName,
ImmutableArray<string> Properties
);
}
using MyAttributes;
[GenerateToString]
public partial class User
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
// Згенерований код (User.ToString.g.cs):
// partial class User
// {
// public override string ToString()
// {
// return $"User { Id = {Id}, Name = {Name} }";
// }
// }
User user = new() { Id = 1, Name = "Alice" };
Console.WriteLine(user); // "User { Id = 1, Name = Alice }"
Ключові моменти:
partial class — необхідно для генераторівToString до існуючого класуcontext.SyntaxProvider.ForAttributeWithMetadataName(
fullyQualifiedMetadataName: "MyNamespace.MyAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax,
transform: (ctx, ct) => /* ... */
);
Переваги:
var targetNodes = context.SyntaxProvider.CreateSyntaxProvider(
predicate: (node, _) =>
{
// Фільтр на рівні синтаксису (швидко)
return node is ClassDeclarationSyntax cls &&
cls.AttributeLists.Count > 0;
},
transform: (ctx, _) =>
{
// Семантичний аналіз (повільно, але кешується)
var classDecl = (ClassDeclarationSyntax)ctx.Node;
var symbol = ctx.SemanticModel.GetDeclaredSymbol(classDecl);
return symbol?.GetAttributes()
.Any(a => a.AttributeClass?.Name == "MyAttribute") == true
? symbol
: null;
}
).Where(s => s is not null);
Predicate vs Transform:
predicate, щоб мінімізувати дорогі transform виклики.Підхід 1: StringBuilder (простіше):
var sb = new StringBuilder();
sb.AppendLine("public class MyClass");
sb.AppendLine("{");
sb.AppendLine(" public int Id { get; set; }");
sb.AppendLine("}");
Підхід 2: SyntaxFactory (типобезпечніше):
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
ClassDeclarationSyntax classDecl = ClassDeclaration("MyClass")
.AddModifiers(Token(SyntaxKind.PublicKeyword))
.AddMembers(
PropertyDeclaration(
PredefinedType(Token(SyntaxKind.IntKeyword)),
"Id"
)
.AddModifiers(Token(SyntaxKind.PublicKeyword))
.AddAccessorListAccessors(
AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken))
)
);
string code = classDecl.NormalizeWhitespace().ToFullString();
Порівняння:
| Метод | Плюси | Мінуси |
|---|---|---|
| StringBuilder | Простіше, читабельніше | Typo-prone, no validation |
| SyntaxFactory | Типобезпека, валідація | Verbosity, складніший API |
"""). Для складних — SyntaxFactory або Template Engines (Scriban, T4).У .csproj:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
Згенеровані файли з'являться в obj/Generated/.
public void Execute(...)
{
if (!System.Diagnostics.Debugger.IsAttached)
{
System.Diagnostics.Debugger.Launch();
}
// ...
}
При компіляції відкриється вікно для attach debugger.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Xunit;
public class GeneratorTests
{
[Fact]
public void GeneratesToString_ForMarkedClass()
{
string source = @"
using MyAttributes;
[GenerateToString]
public partial class User
{
public string Name { get; set; }
}";
var compilation = CreateCompilation(source);
var generator = new ToStringGenerator();
GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
driver = driver.RunGenerators(compilation);
var result = driver.GetRunResult();
Assert.Single(result.GeneratedTrees);
Assert.Contains("public override string ToString()",
result.GeneratedTrees[0].ToString());
}
private static Compilation CreateCompilation(string source)
{
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var references = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.Select(a => MetadataReference.CreateFromFile(a.Location));
return CSharpCompilation.Create("TestAssembly",
new[] { syntaxTree },
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
}
}
[GenerateEndpoint("/api/users", HttpMethod.Get)]
public static IResult GetUsers(IUserService service)
{
return Results.Ok(service.GetAll());
}
// Генератор створює:
// app.MapGet("/api/users", GetUsers);
[GenerateMapper(typeof(UserDto))]
public partial class User
{
public int Id { get; set; }
public string Name { get; set; }
// Генерується:
// public UserDto ToDto() => new() { Id = this.Id, Name = this.Name };
}
[Service(ServiceLifetime.Scoped)]
public class UserRepository : IUserRepository
{
// ...
}
// Генератор створює в статичному класі:
// services.AddScoped<IUserRepository, UserRepository>();
1. Partial Classes
partial класах, щоб користувач міг додавати власну логіку.2. Namespaces
3. Error Reporting
context.ReportDiagnostic() для інформативних помилок.4. Incremental
IIncrementalGenerator для performance.private void Execute(SourceProductionContext context, ClassInfo classInfo)
{
if (!classInfo.IsPartial)
{
var diagnostic = Diagnostic.Create(
new DiagnosticDescriptor(
id: "GEN001",
title: "Class must be partial",
messageFormat: "Class '{0}' must be declared as partial",
category: "Generator",
DiagnosticSeverity.Error,
isEnabledByDefault: true
),
Location.None,
classInfo.ClassName
);
context.ReportDiagnostic(diagnostic);
return;
}
// Генерувати код...
}
Створіть генератор для INotifyPropertyChanged:
[GenerateNotifyPropertyChanged]
public partial class ViewModel
{
private string _name;
// Генератор створить:
// public string Name
// {
// get => _name;
// set { _name = value; OnPropertyChanged(); }
// }
}
[GenerateBuilder]
public partial class User
{
public int Id { get; init; }
public string Name { get; init; }
}
// Генерується UserBuilder:
// var user = new UserBuilder()
// .WithId(1)
// .WithName("Alice")
// .Build();
public partial class RegisterDto
{
[Required]
[EmailAddress]
public string Email { get; set; }
// Генерується метод:
// public IEnumerable<ValidationError> Validate() { ... }
}
| Підхід | Compile-Time | Runtime Overhead | IDE Support | Use Case |
|---|---|---|---|---|
| T4 Templates | ✅ | None | ❌ Poor | Legacy projects |
| Reflection | ❌ | Very High | Partial | Flexibility needed |
| Expression Trees | ❌ | Low (after compile) | Partial | Runtime mapping |
| Source Generators | ✅ | None | ✅ Full | Modern .NET |
Попередня тема: Expression Trees: Швидка Альтернатива Рефлексії
Наступна тема: Multithreading (Low Level) (розділ 6.3)
Expression Trees: Швидка Альтернатива Рефлексії
Expression Trees та компільовані вирази як high-performance заміна рефлексії. System.Linq.Expressions API, побудова виразів програмно, компіляція в делегати, та практичні кейси.
Multithreading Fundamentals
Глибокий розбір класу Thread, життєвого циклу потоків, пріоритетів та основ багатопотокового програмування в .NET