Sem Göksu
Sem Göksu
Yazılım · Yolculuk · Fenerbahçe
C#

C# Ustası — Bölüm 1: Incremental Source Generators ile Derleme Zamanında Kod Üretmek

C# Ustasi serisinin ilk bolumu: Incremental Source Generators ile derleme zamaninda tip-guvenli kod uretmeyi ogreniyoruz. Roslyn pipeline'i, ISourceGenerator vs IIncrementalGenerator farki, equatable modellerle caching, attribute-driven kod uretimi, generator testleri ve Regex/JSON/LibraryImport gibi gercek ornekler.

05 Kasım 2025 10 dk okuma 6 0

Merhaba arkadaşlar, bu makalemizde C# dünyasının uzun zamandır en çok merak edilen, en güçlü ama aynı zamanda biraz da "sihirli" görünen özelliklerinden biri olan Incremental Source Generators'ı adım adım inceleyeceğiz. Bu yazıyla birlikte yepyeni bir seriye başlıyoruz — C# Ustası. 5 bölümlük bu dizide C#'ın en derin konularına dalacağız, ezbere değil gerçekten "neden" sorusunun peşinden gideceğiz. Başlamadan önce bir kahve alın, bu ilk bölüm uzun sürecek çünkü derleyicinin içine gireceğiz.

Source Generator Nedir, Hangi Derdi Çözer?
Yazılımcılık kariyerimin büyük bir kısmı "tekrar eden boilerplate kodu yazma" mücadelesiyle geçti. INotifyPropertyChanged implementasyonları, DTO - entity mapping'leri, dependency injection kayıtları, JSON serialization için tip-güvenli helper'lar — hepsi aynı kalıbın kopyası. Bu işi otomatikleştirmek için yıllarca T4 template'leri, Roslyn analyzer'ları, runtime'da Reflection.Emit ile IL yazma gibi yöntemlere başvurduk. Hepsi ya yavaştı, ya da build pipeline'ına entegre olmuyordu.

Source Generator'lar tam bu noktada devreye girer. Derleyicinin bir aşamasında çalışan, mevcut kodu okuyan ve yeni C# kodu üreten eklentilerdir. Üretilen kod normal C# kodu gibi derlemeye dahil olur, IDE'de IntelliSense verir, debug edilebilir. En önemli farkı: reflection değil, derleme zamanı. Çıktı IL'i tamamen statik, AOT dostu, sıfır runtime maliyetli.

Roslyn Derleyici Pipeline'ına Kısa Bir Bakış
Source Generator'ları anlamak için önce Roslyn'in ne yaptığını bilmek lazım. Bir .cs dosyası şu yoldan geçer:

- Lexer: Kaynak metni token'lara ayırır (public, class, Foo, { ...).
- Parser: Token'lardan Syntax Tree kurar — sözdizimsel yapı.
- Semantic Model: Syntax tree üzerinden sembolleri çözer (bu tip ne, bu metod hangi namespace'te, hangi base class'ı var).
- Binding: Tüm semantik analiz tamamlanır, tipler doğrulanır.
- IL Emit: Assembly'ye IL kodu yazılır.

Source Generator, semantic model hazırlandıktan sonra, IL emit'ten önce çalışır. Elimize bir Compilation objesi gelir — o anki projenin tüm syntax tree'lerine ve semantic model'ine erişebildiğimiz dev bir yapı. Biz bu bilgilerden kendi .g.cs dosyalarımızı üretip derlemeye ekleriz.

ISourceGenerator vs IIncrementalGenerator — Neden İkincisi Kazandı?
İlk versiyonda (.NET 5 civarı) ISourceGenerator interface'i vardı. Basit ama bir sorunu vardı: her derlemede baştan çalışıyordu. Siz bir karakter değiştirdiğinizde generator tüm syntax tree'yi baştan taramak zorundaydı. Büyük projelerde IDE'niz donuyordu.

.NET 6 ile gelen IIncrementalGenerator tamamen farklı bir model getirdi: pipeline. Roslyn her adımda bir equatable değer üretir, eğer bu değer bir önceki derlemeyle aynıysa sonraki adımları çalıştırmaz. Yani caching built-in. Bu, performansı %100 - %1000 arası artırdı, ve Microsoft artık yeni yazan herkese incremental yazmamızı tavsiye ediyor.

İlk Generator'ımız — [ToString] Attribute'u
Pratik bir örnekle başlayalım. Hedefimiz: record'lara [ToString] attribute'u eklediğimizde otomatik olarak güzel formatlı bir ToString() üretsin:

[ToString]
public partial record User(int Id, string Name, string Email);

// Generator şunu üretecek:
// partial record User
// {
//     public override string ToString() => $"User {{ Id = {Id}, Name = {Name}, Email = {Email} }}";
// }

Projemizi kuralım. İki proje gerekiyor: generator'ın kendisi (netstandard2.0), ve onu kullanacak tüketici proje:

dotnet new classlib -n ToString.Generator -f netstandard2.0
dotnet new console -n ToString.Sample -f net9.0

Generator projesine Roslyn paketlerini ekleyelim:

<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.11.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
  </ItemGroup>
</Project>

Dikkat: netstandard2.0 zorunlu çünkü Roslyn bu target'ta yüklenir. LangVersion'u latest yapıp yine de modern C# yazabiliriz. IsRoslynComponent IDE'ye bu projenin bir generator olduğunu söyler.

Visual Studio tarafında projeyi açtığınızda Solution Explorer'da "Dependencies" düğümü altında "Analyzers" diye bir alt düğüm göreceksiniz. Generator projenize referans veren tüketici projeyi build aldığınızda, bu Analyzers düğümünün altında sizin DLL'iniz belirir ve altındaki alt dalda üretilen .g.cs dosyaları listelenir. Üstüne çift tıklarsanız bellekteki snapshot'ı disk gibi açar — yani generator'ın o anda neyi ürettiğini IDE'den direkt izleyebilirsiniz. Çoğu developer bu özelliği bilmediği için debugger'sız generator debug ediyor; oysa ekran bu kadar açık.

Incremental source generator projesi Solution Explorer'da
Visual Studio 2022 — Analyzer projesi ve üretilen kod klasörü (Generated / SourceGenerator.g.cs)

Generator'ın Kalbi — Initialize Metodu
Incremental generator yazarken tek bir method implement ediyoruz: Initialize. Bu method bir pipeline kurar ve Roslyn her derlemede bu pipeline'ı çalıştırır.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

[Generator]
public class ToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. Attribute'u tüketici projeye enjekte et
        context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
            "ToStringAttribute.g.cs",
            """
            namespace ToString.Generators
            {
                [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
                internal sealed class ToStringAttribute : System.Attribute { }
            }
            """));

        // 2. [ToString] işaretli tipleri bulan pipeline
        var pipeline = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                fullyQualifiedMetadataName: "ToString.Generators.ToStringAttribute",
                predicate: static (node, _) => node is TypeDeclarationSyntax,
                transform: static (ctx, _) => ExtractModel(ctx))
            .Where(static m => m is not null);

        // 3. Her bulunan model için kod üret
        context.RegisterSourceOutput(pipeline, static (spc, model) =>
        {
            if (model is null) return;
            var source = GenerateToString(model);
            spc.AddSource($"{model.TypeName}.ToString.g.cs", source);
        });
    }
}

Üç aşama var:

- PostInitialization: Attribute'un kendisini tüketici projeye enjekte ediyoruz. Böylece kullanıcının ayrı bir package yüklemesine gerek kalmıyor.
- ForAttributeWithMetadataName: [ToString] işaretli tipleri ister — bu API çok kritik çünkü tüm syntax tree'yi taramak yerine sadece ilgili node'ları döndürüyor. Büyük projelerde performans hayat kurtarıyor.
- RegisterSourceOutput: Modelden kod üretir.

Equatable Model — Caching'in Sırrı
Incremental pipeline'ın hızlı olmasının sebebi, her adımdan çıkan değerin structural equality'ye sahip olması. Eğer transform iki derlemede aynı değeri döndürürse, Roslyn "zaten ürettim, cache'den al" der. Bu yüzden modellerimizi record olarak yazmalı, collection'ları EquatableArray gibi sequence-equal yapan tiplerle taşımalıyız.

internal sealed record ToStringModel(
    string Namespace,
    string TypeName,
    string TypeKind, // "class", "struct", "record"
    EquatableArray<string> MemberNames);

private static ToStringModel? ExtractModel(GeneratorAttributeSyntaxContext ctx)
{
    if (ctx.TargetSymbol is not INamedTypeSymbol symbol) return null;

    var members = symbol.GetMembers()
        .OfType<IPropertySymbol>()
        .Where(p => p.DeclaredAccessibility == Accessibility.Public)
        .Select(p => p.Name)
        .ToImmutableArray();

    return new ToStringModel(
        Namespace: symbol.ContainingNamespace.IsGlobalNamespace
            ? "" : symbol.ContainingNamespace.ToDisplayString(),
        TypeName: symbol.Name,
        TypeKind: symbol.IsRecord ? "record" : (symbol.IsValueType ? "struct" : "class"),
        MemberNames: new EquatableArray<string>(members));
}

Eğer ImmutableArray kullansaydım her bir iki derlemede referans karşılaştırması false dönecekti ve cache çalışmayacaktı. EquatableArray, Microsoft'un kaynaklarında da örnek verdiği küçük bir wrapper — elemanları tek tek karşılaştırır.

Kod Üretim Kısmı
Artık modele sahibiz, şimdi üreteceğimiz kodu string olarak inşa ediyoruz:

private static string GenerateToString(ToStringModel model)
{
    var sb = new StringBuilder();
    if (!string.IsNullOrEmpty(model.Namespace))
        sb.AppendLine($"namespace {model.Namespace};").AppendLine();

    sb.AppendLine($"partial {model.TypeKind} {model.TypeName}");
    sb.AppendLine("{");
    sb.Append("    public override string ToString() => $\"");
    sb.Append(model.TypeName);
    sb.Append(" {{ ");

    for (int i = 0; i < model.MemberNames.Length; i++)
    {
        if (i > 0) sb.Append(", ");
        var m = model.MemberNames[i];
        sb.Append($"{m} = {{{m}}}");
    }

    sb.AppendLine(" }}\";");
    sb.AppendLine("}");
    return sb.ToString();
}

StringBuilder ile elle concat ediyorum çünkü generator kodu sıcak yolda — hız önemli. Daha karmaşık senaryolarda SyntaxFactory ile programatik tree kurabilirsiniz, ama benim tecrübem şu: basit örneklerde string concat en pratik.

Tüketici Projede Kullanım
Tüketici csproj'unda generator'ı referans veriyoruz (NuGet ile değil, Analyzer referansı):

<ItemGroup>
  <ProjectReference Include="..\ToString.Generator\ToString.Generator.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

Sonra Program.cs:

using ToString.Generators;

var u = new User(1, "Sem", "sem@example.com");
System.Console.WriteLine(u);
// User { Id = 1, Name = Sem, Email = sem@example.com }

[ToString]
public partial record User(int Id, string Name, string Email);

Generator Testleri
Generator yazmanın en kolay kısmı yazmak, en zor kısmı test etmek. Neyse ki Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing veya daha ham yol olarak CSharpGeneratorDriver var:

[Fact]
public void ToString_generator_emits_ToString_for_record()
{
    var source = """
        using ToString.Generators;
        namespace Demo;
        [ToString] public partial record Person(int Id, string Name);
        """;

    var compilation = CSharpCompilation.Create("Demo",
        new[] { CSharpSyntaxTree.ParseText(source) },
        new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) });

    var driver = CSharpGeneratorDriver.Create(new ToStringGenerator())
        .RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics);

    var result = driver.GetRunResult();
    var generated = result.GeneratedTrees
        .Single(t => t.FilePath.EndsWith("Person.ToString.g.cs"))
        .ToString();

    Assert.Contains("public override string ToString()", generated);
    Assert.Empty(diagnostics);
}

AdditionalText — Kod Dışı Veriden Kod Üretmek
Bazen üretilmesi gereken kod C# dosyalarında değil, JSON veya CSV gibi dış dosyalarda tanımlıdır (örn. feature flag'lar). Bu durumda AdditionalFiles kullanırız:

<ItemGroup>
  <AdditionalFiles Include="feature-flags.json" />
</ItemGroup>
var flagsPipeline = context.AdditionalTextsProvider
    .Where(static f => f.Path.EndsWith("feature-flags.json"))
    .Select(static (f, ct) => f.GetText(ct)?.ToString() ?? "")
    .Where(static text => !string.IsNullOrEmpty(text));

context.RegisterSourceOutput(flagsPipeline, static (spc, json) =>
{
    // JSON'u parse et, her bayrak için property üret
});

Gerçek Dünyadan Örnekler
Teoriden çıkıp gerçek kütüphanelere bakınca generator'ların gücünü görüyoruz:

- Regex Source Generator: [GeneratedRegex("^\\d+$")] attribute'u, runtime'da compile ettiğiniz regex'i derleme zamanında üretip sıfır allocation çalıştırır.
- LibraryImport (P/Invoke): [DllImport]'un modern versiyonu, marshalling kodunu kendisi üretir.
- JSON Source Generator (System.Text.Json): Reflection olmadan serialize/deserialize kodu üretir — AOT için kritik.
- ASP.NET minimal API delegate factory: Endpoint delegate'lerini derleme zamanında binding kodlu üretir, startup'ı hızlandırır.

Performans — Neden Incremental Caching Bu Kadar Önemli?
Bir projem vardı, 2000 class'lı eski monolith. Üzerinde ISourceGenerator bazlı bir serializer yazmıştım. VS'te her karakter yazdığımda 400-500 ms yaşıyordu. Aynı generator'ı incremental'a çevirince 20-30 ms'e indi — sadece etkilenen tip için yeniden üretim yaptığı için. Bu, kullanıcının IDE deneyimini sıfırdan bire çevirir.

Anahtar: pipeline'ınızın her adımında equatable olun. Roslyn'in RunGeneratorsAndUpdateCompilation'dan gelen GetRunResult() üzerinden TrackedSteps'e bakıp caching'in işlediğini doğrulayın.

Visual Studio tarafında generator debug etmek için generator projesinde Properties/launchSettings.json oluşturup "commandName": "DebugRoslynComponent" profili eklerseniz, F5'e bastığınızda VS ikinci bir VS instance'ı başlatır ve sizin generator'ı target proje üzerinde çalıştırır — breakpoint'ler orada tutar. Eski tip Output penceresi bilgisi için de Tools > Options > Projects and Solutions > Build And Run altında "MSBuild project build output verbosity"i "Diagnostic"e çekin; her build'de generator'ın ne kadar sürdüğü, kaç dosya ürettiği Output penceresinde satır satır yazar.

Tuzaklar ve Anti-Pattern'ler
Generator yazarken en sık düştüğüm tuzaklar:

- Symbol referansı tutmak: ISymbol, INamedTypeSymbol gibi Roslyn objelerini modelde tutmayın. Bunlar compilation'a bağımlı, sonraki derlemede artık geçerli değiller. Yerine string, int gibi primitive'lere dönüştürün.
- Exception fırlatmak: Generator içinde exception fırlarsa tüm derleme patlar. try/catch ile yakalayıp Diagnostic olarak raporlayın.
- IO yapmak: Generator dosya sistemine erişmemeli (AdditionalFiles hariç). Cache'i bozar.
- Global state: Static field'da veri biriktirmeyin. Paralel derleme bozulur.
- Çok fazla dosya üretmek: Her tip için 10 dosya üretmeyin. Tek dosyada partial olarak toplayın.

Ne Zaman Source Generator Kullanmamalıyız?
Generator bir çekiç değil. Tüm problemler çivi değil:

- Kod çok küçükse: 3-5 satır tekrar için generator yazmak overkill. Manuel yazın.
- Dinamik veri gerekiyorsa: Generator derleme zamanında çalışır, runtime verisine erişemez. DB bağlantısı, HTTP çağrısı yapamazsınız.
- Debug önemliyse: Generator kodu debug etmek biraz çetrefilli. Önce manuel prototiple, sonra generator'laştırın.
- Ekibiniz hazır değilse: Roslyn API'si öğrenme eğrisi var. Sadece "kısa yol" için kullanmak istiyorsanız önce ekip bilincini hazırlayın.

Özet
Source Generator'lar reflection ve T4 gibi eski yöntemlerin modern, tip-güvenli, AOT dostu halefi. IIncrementalGenerator pipeline'ı ile devasa projelerde bile milisaniye seviyesinde çalışırlar. Bu bölümde pipeline'ın nasıl kurulacağını, equatable model'lerin caching için neden kritik olduğunu, testleri, AdditionalFiles ile metadata-driven üretimi ve gerçek dünyadaki kullanım alanlarını gördük.

Bir sonraki bölümde Span<T> ve Memory<T> ile sıfır allocation dünyasına gireceğiz — bir byte bile heap'e taşınmadan binlerce parse işlemi yapacağız. Görüşürüz!

Paylaş:

Yorumlar (0)

Henüz yorum yok. İlk yorumu sen yap!

Yorum bırak

* Yorumlar moderasyon sonrası yayınlanır. E-posta gizli tutulur.