C#'ta Source Generator: IIncrementalGenerator ile Compile Time'da Kod Üretmek
Derleme sırasında kod ekleyen bir incremental generator yazmak: netstandard2.0 projesi ve Roslyn sürümü, pipeline ve cache, üretilen kodu görmek, partial ve hint name tuzakları.
Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.
Önce Tahmin Et
İki dosyalık küçük bir console uygulaması. Program.cs, Book ve Author üzerinde Describe() method'unu çağırıyor:
using Bookstore;
var book = new Book { Isbn = "978-0441013593", Title = "Dune", Price = 9.99m };
Console.WriteLine(book.Describe());
var author = new Author { Name = "Frank Herbert", BookCount = 23 };
Console.WriteLine(author.Describe());
Models.cs'te ise iki class var, ama Describe() diye bir method yok. GenerateDescribe attribute'u da projede hiçbir dosyada tanımlı değil. Bu proje derlenir mi?
namespace Bookstore;
[GenerateDescribe]
public partial class Book
{
public string Isbn { get; init; } = "";
public string Title { get; init; } = "";
public decimal Price { get; init; }
}
[GenerateDescribe]
public partial class Author
{
public string Name { get; init; } = "";
public int BookCount { get; init; }
}
Kodu Compiler'ın İçinde Yazmak
Derleniyor ve çalışıyor:
Book { Isbn = 978-0441013593, Title = Dune, Price = 9.99 }
Author { Name = Frank Herbert, BookCount = 23 }
Eksik parçaları bir source generator yazdı. Source generator, compiler'a eklenen ve derleme sırasında çalışan küçük bir programdır: senin kodunu okur ve derlemeye yeni C# dosyaları ekler. Burada generator önce GenerateDescribeAttribute class'ını, sonra attribute'u taşıyan her class için Describe() method'unu içeren bir partial class üretti. Compiler bu dosyaları sanki sen yazmışsın gibi derledi.
İki sınırı baştan bilmek lazım. Generator sadece kod ekler; senin yazdığın kodu değiştiremez, bir method'un içine satır sokamaz. Bu yüzden Book ve Author partial: generator class'ın ikinci yarısını ayrı bir dosyada yazıyor. İkincisi, generator compile time'da çalışır, runtime'da değil. Attribute ve reflection yazısında aynı işi reflection ile yapabilirdik (property'leri runtime'da dolaşmak), ama her çağrıda maliyet ödenir ve trimming ya da Native AOT ile sorun çıkarabilir. Generator'ın ürettiği kod ise sıradan, statik C#: hızlı ve compiler'ın gözü önünde.
.NET 6'nın kendisi de bu yolu kullanıyor: System.Text.Json'un source generation modu serialization kodunu compile time'da üretiyor, [LoggerMessage] attribute'u yüksek performanslı logging method'larını yazıyor. Bir önceki yazıda bahsettiğimiz [LibraryImport] ve .NET 7'nin Regex generator'ı da aynı ailedendir.
Generator Projesi
Generator ayrı bir class library'dir. Hedefi netstandard2.0 olmak zorundadır, çünkü compiler hem Visual Studio'nun içinde (.NET Framework üzerinde) hem de dotnet build'de (.NET üzerinde) çalışır ve generator ikisine de yüklenebilmelidir:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>10</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
</ItemGroup>
</Project>
Microsoft.CodeAnalysis.CSharp paketi, compiler'ın (Roslyn) API'si. Sürüm seçimi önemli: generator, kendisini yükleyen compiler'dan daha yeni bir Roslyn'e referans veremez. 4.0.1, Microsoft'un sürüm tablosuna göre Visual Studio 2022'nin ilk sürümüyle eşleşen Roslyn; yani .NET 6 ile çıkan compiler. Bu sürüme referans veren bir generator, C# 10 dönemindeki her araçta çalışır. PrivateAssets="all" bu paketlerin generator'ı kullanan projelere sızmamasını sağlar; IsRoslynComponent ise Visual Studio'da generator'ı debug edebilmek için.
Generator'ın Kendisi
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Bookstore.Generators;
[Generator]
public sealed class DescribeGenerator : IIncrementalGenerator
{
private const string AttributeName = "Bookstore.GenerateDescribeAttribute";
private const string AttributeSource = @"// <auto-generated/>
namespace Bookstore
{
[System.AttributeUsage(System.AttributeTargets.Class)]
internal sealed class GenerateDescribeAttribute : System.Attribute
{
}
}
";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1) Attribute'un kendisini her derlemeye ekle
context.RegisterPostInitializationOutput(static ctx =>
ctx.AddSource("GenerateDescribeAttribute.g.cs", SourceText.From(AttributeSource, Encoding.UTF8)));
// 2) Attribute'u olan class'ları bul ve ihtiyacımız olanı çıkar
var classes = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => node is ClassDeclarationSyntax c && c.AttributeLists.Count > 0,
transform: static (ctx, ct) => ToModel(ctx, ct))
.Where(static m => m is not null);
// 3) Her class için bir dosya üret
context.RegisterSourceOutput(classes, static (spc, model) =>
spc.AddSource($"{model!.Value.Name}.Describe.g.cs", SourceText.From(model.Value.Source, Encoding.UTF8)));
}
private static (string Name, string Source)? ToModel(GeneratorSyntaxContext ctx, System.Threading.CancellationToken ct)
{
if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct) is not INamedTypeSymbol type)
return null;
bool marked = type.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == AttributeName);
if (!marked)
return null;
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.Select(p => $"{p.Name} = {{{p.Name}}}");
string body = string.Join(", ", properties);
string ns = type.ContainingNamespace.IsGlobalNamespace ? "" : type.ContainingNamespace.ToDisplayString();
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
if (ns.Length > 0)
sb.AppendLine($"namespace {ns}").AppendLine("{");
sb.AppendLine($" partial class {type.Name}");
sb.AppendLine(" {");
sb.AppendLine($" public string Describe() => global::System.FormattableString.Invariant($\"{type.Name} {{{{ {body} }}}}\");");
sb.AppendLine(" }");
if (ns.Length > 0)
sb.AppendLine("}");
return (type.Name, sb.ToString());
}
}
[Generator] attribute'u ve IIncrementalGenerator interface'i compiler'a "beni çalıştır" der. Tek method Initialize ve içinde kod üretmiyoruz; bir pipeline tarif ediyoruz. Üç adım var.
Birincisi RegisterPostInitializationOutput: kullanıcının kodundan bağımsız, her derlemede eklenecek sabit kod. Attribute'u buradan veriyoruz, böylece generator'ı kullanan projenin ayrı bir paket referansına ihtiyacı kalmıyor. Attribute internal, çünkü her proje kendi kopyasını alıyor.
İkincisi CreateSyntaxProvider: iki aşamalı bir filtre. predicate syntax tree'deki her node için çağrılır, bu yüzden ucuz olmalı: sadece "attribute'u olan bir class mı?" diye bakıyoruz. Geçenler için transform çalışır; burada semantic model'e soruyoruz: bu class'ın attribute'larından biri gerçekten Bookstore.GenerateDescribeAttribute mı? (Sadece isme bakmak yetmez; başka bir namespace'te aynı isimde bir attribute olabilir.) Uyuyorsa public property'leri toplayıp üreteceğimiz kaynağı hazırlıyoruz.
Üçüncüsü RegisterSourceOutput: pipeline'dan çıkan her model için AddSource ile bir dosya ekliyoruz. İlk parametre dosyanın adı (hint name); generator içinde benzersiz olmalı.
"Incremental" Ne Demek
Generator'lar sadece dotnet build'de değil, IDE'de de çalışır: sen yazdıkça, her tuşta. Bir generator her seferinde bütün projeyi baştan tarasaydı editör donardı. C# 9 ile gelen ilk API (ISourceGenerator) tam olarak bu sorunu yaşadı; Roslyn 4.0 ile, yani .NET 6 döneminde gelen IIncrementalGenerator bunu pipeline modeliyle çözdü. Roslyn ekibi artık yeni generator'ların incremental API ile yazılmasını öneriyor.
Fikir şu: pipeline'ın her adımının çıktısı cache'e alınır. Bir dosyayı değiştirdiğinde sadece o dosyanın node'ları tekrar predicate'ten geçer. transform yeni bir model üretir, ve bu model öncekine eşitse sonraki adımlar hiç çalışmaz. Bu yüzden modelin eşitlik karşılaştırması kritik. Bizim modelimiz (string Name, string Source) tuple'ı; tuple ve string değer eşitliğiyle karşılaştırılır. Book'un bir method gövdesini değiştirdiğinde property listesi aynı kaldığı için model de aynıdır ve dosya yeniden üretilmez.
Roslyn 4.3.1 (Visual Studio 2022 17.3) ile gelen ForAttributeWithMetadataName, "şu attribute'u taşıyan tipleri bul" işini compiler'ın kendi indeksleriyle çok daha hızlı yapar ve bugün önerilen yol budur. 4.0.1'e referans veren projemizde bu method yok (derlemeyi denediğimde CS1061 verdi); 4.3.1 ile ikinci adım şuna dönüşür:
var classes = context.SyntaxProvider.ForAttributeWithMetadataName(
"Bookstore.GenerateDescribeAttribute",
predicate: static (node, _) => true,
transform: static (ctx, _) => ((INamedTypeSymbol)ctx.TargetSymbol).Name);
Generator'ı Kullanmak ve Üretilen Kodu Görmek
Uygulama projesi generator'a sıradan bir proje referansıyla değil, analyzer olarak bağlanır:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>10</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Bookstore.Generators\Bookstore.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
OutputItemType="Analyzer" generator'ı compiler'a yükler; ReferenceOutputAssembly="false" generator DLL'inin uygulamanın bir bağımlılığı olmasını engeller. Generator'ı NuGet paketi olarak dağıtırsan DLL paketin analyzers/dotnet/cs klasörüne konur ve aynı etkiyi verir.
EmitCompilerGeneratedFiles ise üretilen dosyaları diske yazar. Normalde sadece bellekte yaşarlar; IDE'de "Go to Definition" ile görebilirsin, ama CLI'da bu ayar olmadan göremezsin. Build sonrası obj klasöründe:
obj/Debug/net6.0/generated/Bookstore.Generators/Bookstore.Generators.DescribeGenerator/Author.Describe.g.cs
obj/Debug/net6.0/generated/Bookstore.Generators/Bookstore.Generators.DescribeGenerator/Book.Describe.g.cs
obj/Debug/net6.0/generated/Bookstore.Generators/Bookstore.Generators.DescribeGenerator/GenerateDescribeAttribute.g.cs
Book için üretilen dosya:
// <auto-generated/>
namespace Bookstore
{
partial class Book
{
public string Describe() => global::System.FormattableString.Invariant($"Book {{ Isbn = {Isbn}, Title = {Title}, Price = {Price} }}");
}
}
Üç detaya dikkat. // <auto-generated/> yorumu, analyzer'ların ve stil kurallarının bu dosyada uyarı vermemesini sağlar. Namespace'i blok olarak yazdık, file-scoped değil; generator'ın çıktısı, onu kullanan projenin dil sürümünü bilmeden de derlenebilmeli. global:: öneki ve FormattableString.Invariant ise üretilen kodun kullanıcının using'lerine ve kültürüne bağımlı olmamasını sağlıyor; Price Türkçe bir makinede de 9.99 yazıyor.
Sık Düşülen Tuzaklar
partial'ı unutmak. Attribute'u partial olmayan bir class'a koyarsan, generator yine ikinci yarıyı üretir ve compiler iki tanımı birleştiremez:
namespace Bookstore;
[GenerateDescribe]
public class Order
{
public int Id { get; init; }
public decimal Total { get; init; }
}
Order.cs(4,14): error CS0260: Missing partial modifier on declaration of type 'Order'; another partial declaration of this type exists
Hata, kullanıcının dosyasında çıkıyor ama sebebi görünmüyor; kafa karıştırıcı. Olgun generator'lar bu durumu kendileri yakalayıp ReportDiagnostic ile "bu class partial olmalı" diye açık bir uyarı verir. Kendi generator'ına eklemeye değer ilk özellik bu.
Cache'i bozan modeller. transform'dan ISymbol, SyntaxNode, SemanticModel ya da ImmutableArray<T> döndürmek ilk bakışta pratik görünür. Ama bunlar ya her derlemede yeni object'tir ya da değer eşitliği yoktur; her tuşta eşit olmayan bir model çıkar ve "incremental" generator her seferinde baştan çalışır. Modeli string'ler, sayılar ve değer eşitliği olan kendi tiplerinle kur.
Aynı hint name'i iki kez kullanmak. İki farklı namespace'te Book adında class varsa bizim generator'ımız iki kez Book.Describe.g.cs eklemeye çalışır:
namespace Bookstore
{
[GenerateDescribe]
public partial class Book
{
public string Title { get; init; } = "";
}
}
namespace Bookstore.Archive
{
[GenerateDescribe]
public partial class Book
{
public int Year { get; init; }
}
}
CSC : warning CS8785: Generator 'DescribeGenerator' failed to generate source. It will not contribute to the output and compilation errors may occur as a result. Exception was of type 'ArgumentException' with message 'The hintName 'Book.Describe.g.cs' of the added source file must be unique within a generator. (Parameter 'hintName')'.
Program.cs(1,40): error CS1061: 'Book' does not contain a definition for 'Describe' and no accessible extension method 'Describe' accepting a first argument of type 'Book' could be found (are you missing a using directive or an assembly reference?)
Program.cs(2,48): error CS1061: 'Book' does not contain a definition for 'Describe' and no accessible extension method 'Describe' accepting a first argument of type 'Book' could be found (are you missing a using directive or an assembly reference?)
Generator'ın içindeki exception build'i durdurmadı; compiler onu CS8785 uyarısına çevirdi ve generator'ın hiçbir çıktısını derlemeye almadı. Sonuç, asıl sebepten uzakta görünen CS1061 hataları. Uyarı listesini okumayan biri saatlerce "Describe neden yok?" diye arar. Gerçek bir generator'da hint name'e namespace'i de katarsın.
IDE'nin eski generator'ı tutması. Generator'ın kodunu değiştirdiğinde CLI build hemen yeni sürümü kullanır, ama IDE'ler yüklü generator'ı bir süre cache'te tutabilir. "Değişikliğim neden görünmüyor?" sorusunun cevabı çoğu zaman IDE'yi yeniden başlatmaktır. Generator'ı doğrulamanın asıl yolu ise unit test: CSharpGeneratorDriver ile bir derleme oluşturup üretilen kaynağı kontrol etmek. Unit test'i serinin son yazısında konuşacağız.
Küçük Bir Deneme
★☆☆ Book'a public int Stock { get; init; } ekle ve build al. Üretilen dosyada ne değişti? private bir property eklersen ne olur?
★★☆ Generator'a bir diagnostic ekle: attribute'lu class partial değilse (ClassDeclarationSyntax.Modifiers'a bakarak) kod üretme, onun yerine BS0001 kimlikli, "Class 'Order' must be partial" mesajlı bir hata raporla. NoPartial projesinde artık hangi hatayı görüyorsun?
★★★ Hint name'e namespace'i ekle ve Dup projesindeki iki Book ile tekrar derle. Sonra modeli tuple yerine kendi record'unla kur (netstandard2.0'da record için gereken IsExternalInit sorununu nasıl çözeceğini araştır).
Generator'lar, compile time'da kod üreterek reflection'ın runtime maliyetinden kaçmanın yoluydu. Bir sonraki yazıda "daha hızlı" iddialarını nasıl kanıtlayacağımıza bakıyoruz: allocation, struct'lar ve BenchmarkDotNet ile ölçmek.