Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri Compiler'lar ve Programlama Dilleri — #04

Semantic Analysis ve Code Generation: Type Checking, Symbol Table ve AST'den Expression Tree'ye

Mert Özen 15 Kas 2026 16 dk 7 görüntülenme
Semantic Analysis ve Code Generation: Type Checking, Symbol Table ve AST'den Expression Tree'ye

Semantic analysis, parse edilmiş ağaçtaki isimleri symbol table'a bağlayıp tipleri kontrol eder; price = "abc" gibi bir hata böylece filtre hiçbir kitaba uygulanmadan yakalanır. Bookstore filtre dili için binder, implicit conversion ve constant folding yazıyor, doğrulanmış ağacı Expression<Func<Book,bool>>'a derleyip hem bellekte çalıştırıyor hem EF Core'un parametreli SQL'e çevirdiğini ToQueryString ile gösteriyoruz; uzunluk sınırının neden gerektiğini ve Roslyn'deki karşılığını da görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404), Entity Framework Core 10.0.15 (SQL Server provider) ve Microsoft.CodeAnalysis.CSharp 5.12.0 ile derlenip xUnit v3 4.0.3 testleriyle doğrulanmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için yazdığımız filtre dilini bu yazıda Entity Framework Core'a (EF Core; .NET'in veritabanı erişim kütüphanesi) bağlıyoruz. Kullanıcı price < 300 and author = "Jane Austen" yazıyor, filtre SQL Server'a gidiyor. Birinci soru: üretilen SQL'de 300 ve Jane Austen nasıl görünür, sorgu metninin içine yazılmış literal olarak mı, yoksa parametre olarak mı?

İkinci soru: Kullanıcı title contains "50%_off" yazıyor. contains SQL'de LIKE'a dönüşecek. Kullanıcının yazdığı % ve _, LIKE'ın wildcard'ları gibi davranır mı, yoksa düz karakter olarak mı aranır?

Parse Etmek, Anlamak Değil

03. yazının parser'ı price = "abc" filtresini hiç itiraz etmeden kabul ediyor. Söz dizimi açısından kusursuz: bir alan, bir operatör, bir literal. Sorun anlamda: price bir sayı, "abc" bir string ve ikisi karşılaştırılamaz. Bunu bulan aşamanın adı semantic analysis. Kabaca iki iş yapıyor. Binding, her ismi tanımına bağlıyor: price, Book.Price property'si. Type checking ise her ifadenin tipini hesaplayıp kurallara uyup uymadığına bakıyor.

Interpreter bu kontrolü çalışırken, kitap kitap yapıyordu ve hata ancak ilgili satıra gelince görünüyordu. Şimdi aynı kontrolü filtre hiçbir kitaba uygulanmadan, bir kez yapacağız. Bu aşamanın çıktısı yeni bir ağaç: her node'un tipini, her ismin symbol'ünü bilen bir bound tree. Roslyn'in içinde de aynı ayrım var: parser bir syntax tree üretiyor, binder ondan bir bound tree kuruyor ve dışarıya SemanticModel API'si olarak açılıyor. Code generation syntax tree'den değil, bound tree'den yapılıyor.

Symbol Table ve Scope

Binding'in ilk ihtiyacı bir symbol table: hangi isimler var ve her biri neyi gösteriyor? Bizim dilde tek bir scope var, filtrenin okuyabildiği alanlar:

namespace Bookstore.Filter.Binding;

public enum FilterType { Int, Decimal, String, Bool, Error }

public sealed record FieldSymbol(string Name, FilterType Type, PropertyInfo Property);

// Dilin tek scope'u: filtrenin okuyabildiği alanlar. Bunun dışındaki her isim bilinmeyen isimdir.
public sealed class SymbolTable
{
    private readonly FrozenDictionary<string, FieldSymbol> _fields;

    public SymbolTable(IEnumerable<FieldSymbol> fields) =>
        _fields = fields.ToFrozenDictionary(f => f.Name, StringComparer.OrdinalIgnoreCase);

    public static SymbolTable ForBooks { get; } = new(
    [
        Field("isbn", FilterType.String, nameof(Book.Isbn)),
        Field("title", FilterType.String, nameof(Book.Title)),
        Field("author", FilterType.String, nameof(Book.Author)),
        Field("price", FilterType.Decimal, nameof(Book.Price)),
        Field("year", FilterType.Int, nameof(Book.Year)),
        Field("stock", FilterType.Int, nameof(Book.Stock)),
    ]);

    public IEnumerable<string> Names => _fields.Keys;

    public FieldSymbol? Lookup(string name) => _fields.GetValueOrDefault(name);

    private static FieldSymbol Field(string name, FilterType type, string property) =>
        new(name, type, typeof(Book).GetProperty(property)!);
}

Tabloyu typeof(Book).GetProperties() ile otomatik doldurmak cazip, ama bilerek elle yazdık. Symbol table aynı zamanda bir izin listesi: Book'a ileride CostPrice ya da SupplierId gibi müşterinin görmemesi gereken bir property eklenirse filtre dili onu kendiliğinden açığa çıkarmamalı. Tip de tabloda yazılı; price decimal, year int.

Gerçek bir programlama dilinde tek scope yetmez. C#'ta bir lambda parametresi, bir method'un local'i, class'ın field'ı ve using ile gelen tipler iç içe scope'lar oluşturur. İsim arama en içteki scope'tan başlar, bulamazsa bir dışarı çıkar. Roslyn bunu birbirine zincirlenmiş binder'larla yapıyor: lambda'nın binder'ı method'unkine, o da class'ınkine bağlı. Bizim dilde let ya da alt sorgu olmadığı için zincirin tek halkası var; ama kural aynı, isim ya bir symbol'e bağlanır ya da hata olur.

Type Checking ve Implicit Conversion

Binder AST'yi (abstract syntax tree; parser'ın ürettiği, kodun yapısını gösteren ağaç) geziyor ve her node için bir bound node üretiyor. Önce literal'lar, çünkü 300 ile 300.0 arasındaki fark burada ortaya çıkıyor:

private static BoundLiteral BindLiteral(Literal literal) => literal.Value switch
{
    // 300 bir int, 300.0 ve 49.90 decimal: decimal parse edildiği scale'i korur
    decimal d when d.Scale == 0 && d is >= int.MinValue and <= int.MaxValue => new BoundLiteral((int)d, FilterType.Int),
    decimal d => new BoundLiteral(d, FilterType.Decimal),
    string s => new BoundLiteral(s, FilterType.String),
    bool b => new BoundLiteral(b, FilterType.Bool),
    _ => throw new InvalidOperationException($"unexpected literal {literal.Value}"),
};

Asıl kurallar karşılaştırmada. contains yalnız string'lerle, = ve != aynı tiplerle ya da iki sayıyla, sıralama operatörleri yalnız sayılarla çalışıyor. String'leri < ile karşılaştırmaya izin vermiyoruz, çünkü sonucu bellekte string.CompareOrdinal, SQL Server'da kolonun collation'ı belirliyor; iki back-end'in aynı filtrede farklı kitaplar döndürmesini istemiyoruz.

private BoundExpr BindComparison(ComparisonExpr c)
{
    BoundExpr left = BindExpr(c.Left);
    BoundExpr right = BindExpr(c.Right);
    if (left.Type == FilterType.Error || right.Type == FilterType.Error)
        return new BoundError(); // sebep zaten raporlandı; ikinci bir hata ekleme

    string symbol = Operators.Symbol(c.Operator);
    bool numeric = IsNumeric(left.Type) && IsNumeric(right.Type);
    bool valid = c.Operator switch
    {
        ComparisonOperator.Contains => left.Type == FilterType.String && right.Type == FilterType.String,
        ComparisonOperator.Equal or ComparisonOperator.NotEqual => numeric || left.Type == right.Type,
        // string sıralaması collation'a bağlı olurdu, bu yüzden yalnız sayılar sıralanabilir
        _ => numeric,
    };
    if (!valid)
    {
        Error(c.Position, $"operator '{symbol}' cannot be applied to {Name(left.Type)} and {Name(right.Type)}");
        return new BoundError();
    }

    // implicit conversion: C#'taki gibi int, decimal'e genişler
    if (numeric && left.Type != right.Type)
    {
        if (left.Type == FilterType.Int)
            left = ToDecimal(left);
        else
            right = ToDecimal(right);
    }
    return new BoundComparison(left, c.Operator, right);
}

// literal hemen dönüştürülür (constant folding); alan ise her satırda çalışan bir conversion node'u alır
private static BoundExpr ToDecimal(BoundExpr operand) => operand is BoundLiteral { Value: int value }
    ? new BoundLiteral((decimal)value, FilterType.Decimal)
    : new BoundConversion(operand, FilterType.Decimal);

price < 300'de sol taraf decimal, sağ taraf int. C#'ın kuralı burada da geçerli: int'ten decimal'e implicit conversion var, tersine yok. Binder dönüşümü ağaca açıkça ekliyor. Kullanıcı yazmadığı hâlde ağaçta bir conversion node'u beliriyor; implicit conversion'ın compiler içindeki karşılığı tam olarak bu. Dönüştürülen bir literal ise beklemeye gerek yok: 300 bind sırasında 300m oluyor. Buna constant folding deniyor. 01. yazıda IL'de (Intermediate Language; .NET'in işlemciden bağımsız ara kodu) gördüğümüz ldc.i4 300 ve newobj Decimal::.ctor(int32) çifti de Roslyn'in 300m sabitini nasıl ürettiğiydi; çalışma anında bir int değişkenini dönüştürmüyordu.

BoundError'ın rolü önemli. prise < 300'de bilinmeyen isim için bir hata yazıyoruz, sonra karşılaştırmaya Error tipi geliyor ve karşılaştırma ikinci bir hata yazmadan BoundError dönüyor. Bu olmasa tek bir yazım hatası, onu kullanan her ifade için ayrı bir "cannot be applied to error and int" mesajı üretirdi.

$ dotnet run --project src/Bookstore.Filter.Cli -- check 'price = "abc"'
price = "abc"
      ^ error at 1:7: operator '=' cannot be applied to decimal and string
$ dotnet run --project src/Bookstore.Filter.Cli -- check 'prise < 300 and stock > 0'
prise < 300 and stock > 0
^ error at 1:1: unknown field 'prise'; did you mean 'price'?
$ dotnet run --project src/Bookstore.Filter.Cli -- check 'price and stock'
price and stock
^ error at 1:1: 'and' needs true or false, but this operand is decimal
price and stock
          ^ error at 1:11: 'and' needs true or false, but this operand is int
$ dotnet run --project src/Bookstore.Filter.Cli -- check 'title < "M"'
title < "M"
      ^ error at 1:7: operator '<' cannot be applied to string and string

03. yazıda price = "abc" Dune'u döndürdükten sonra, The Hobbit'te patlıyordu. Şimdi filtre hiçbir kitaba dokunmadan reddediliyor. "Did you mean" önerisi, bilinmeyen ismi tablodaki isimlerle Levenshtein mesafesine göre karşılaştırıyor; en fazla iki harf farkı varsa öneriyor.

Code Generation: Bound Tree'den Expression Tree'ye

Doğrulanmış ağacı çalıştırılabilir bir şeye çevirme zamanı. Hedef olarak Expression<Func<Book, bool>> seçiyoruz, çünkü .NET'te iki farklı back-end'in anladığı ortak bir IR (intermediate representation; front-end ile back-end arasındaki ara biçim): Compile() ile bellekte çalışan bir delegate'e, EF Core ile de SQL'e dönüşebiliyor. Expression Tree API'sinin kendisi C# İleri serisinin "C#'ta Expression Tree: Lambda'yı Veri Olarak Tutmak ve EF Core'a Köprü" yazısında anlatılıyor; burada bir compiler'ın back-end'i olarak kullanıyoruz:

public enum LiteralMode
{
    // değerler bir holder object'ten okunur; EF Core bunları SQL parametresine çevirir
    Parameterized,
    // değerler constant olarak gömülür; EF Core bunları SQL metnine yazar
    Inline,
}

// Code generation: bound tree -> Expression<Func<Book, bool>>. Ağaç her zaman tipi kontrol edilmiş bir bound tree'den
// kurulduğu için Expression.* factory method'ları hiçbir zaman uyumsuz operand tipleriyle karşılaşmaz.
public static class ExpressionCompiler
{
    private static readonly MethodInfo StringContains = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!;

    public static Expression<Func<Book, bool>> Compile(BoundExpr root, LiteralMode mode = LiteralMode.Parameterized)
    {
        ParameterExpression book = Expression.Parameter(typeof(Book), "book");
        return Expression.Lambda<Func<Book, bool>>(Emit(root, book, mode), book);
    }

    private static Expression Emit(BoundExpr node, ParameterExpression book, LiteralMode mode) => node switch
    {
        BoundField field => Expression.Property(book, field.Field.Property),
        BoundLiteral literal => EmitLiteral(literal, mode),
        BoundConversion conversion => Expression.Convert(Emit(conversion.Operand, book, mode), typeof(decimal)),
        BoundNot not => Expression.Not(Emit(not.Operand, book, mode)),
        BoundLogical { Operator: LogicalOperator.And } and =>
            Expression.AndAlso(Emit(and.Left, book, mode), Emit(and.Right, book, mode)),
        BoundLogical or => Expression.OrElse(Emit(or.Left, book, mode), Emit(or.Right, book, mode)),
        BoundComparison comparison => EmitComparison(comparison, book, mode),
        _ => throw new InvalidOperationException("cannot generate code for a filter with errors"),
    };

    private static Expression EmitLiteral(BoundLiteral literal, LiteralMode mode)
    {
        Type type = literal.Type switch
        {
            FilterType.Int => typeof(int),
            FilterType.Decimal => typeof(decimal),
            FilterType.String => typeof(string),
            _ => typeof(bool),
        };
        if (mode == LiteralMode.Inline)
            return Expression.Constant(literal.Value, type);

        // C# compiler'ının yakalanan bir local için ürettiği şeklin aynısı: bir closure object'inde field okuma
        object holder = Activator.CreateInstance(typeof(Captured<>).MakeGenericType(type), literal.Value)!;
        return Expression.Field(Expression.Constant(holder), "value");
    }

    private sealed class Captured<T>(T value)
    {
        // field adı SQL parametresinin adı olur (@value, @value1, ...)
        public readonly T value = value;
    }
}

Code generation adımı kısa, çünkü zor işi binder yaptı. Her bound node bir Expression factory çağrısına karşılık geliyor; and, AndAlso oluyor ve C#'taki && gibi kısa devre yapıyor, BoundConversion, Expression.Convert oluyor. Tip kontrolü yapılmamış bir ağaçtan aynı şeyi yapmaya kalksaydık Expression.LessThan, decimal ile int'i karşılaştırmayı reddedip InvalidOperationException fırlatırdı. Bütün pipeline'ı FilterCompiler.Compile bir araya getiriyor: uzunluk kontrolü, parse, bind, code generation.

$ dotnet run --project src/Bookstore.Filter.Cli -- check 'price < 300 and author = "Jane Austen"'
book => ((book.Price < value(Bookstore.Filter.CodeGen.ExpressionCompiler+Captured`1[System.Decimal]).value) AndAlso (book.Author == value(Bookstore.Filter.CodeGen.ExpressionCompiler+Captured`1[System.String]).value))
Emma                   Jane Austen       199.00 TRY  stock 7
Persuasion             Jane Austen       185.00 TRY  stock 3
Sense and Sensibility  Jane Austen       210.00 TRY  stock 5

İlk satır expression tree'nin ToString()'i; altındakiler Compile() ile üretilen delegate'in SampleBooks.All.Where(...) sonucu. Elimizde artık aynı dilin iki implementation'ı var: 03'ün interpreter'ı ve derlenmiş delegate. Testlerden biri beş farklı filtre için ikisinin aynı kitapları döndürdüğünü karşılaştırıyor. Compiler'ları test etmenin en etkili yollarından biri bu: iki implementation'ı aynı girdilerle çalıştırıp sonuçları kıyaslamak.

Aynı Ağaç, SQL: EF Core ve ToQueryString

Expression tree'yi EF Core'a verince ikinci back-end devreye giriyor. ToQueryString() üretilecek SQL'i veritabanına bağlanmadan gösteriyor; BookstoreContext SQL Server provider'ıyla kurulu ama bu yazıdaki komutlar hiçbir bağlantı açmıyor:

static int Sql(string filter, LiteralMode mode)
{
    CompiledFilter compiled = FilterCompiler.Compile(filter, mode);
    if (!compiled.Success)
        return Report(filter, compiled.Diagnostics);

    using BookstoreContext db = BookstoreContext.ForSqlServer();
    Console.WriteLine(db.Books.Where(compiled.Expression!).ToQueryString());
    return 0;
}
$ dotnet run --project src/Bookstore.Filter.Cli -- sql 'price < 300 and author = "Jane Austen"'
DECLARE @value decimal(10,2) = 300.0;
DECLARE @value1 nvarchar(100) = N'Jane Austen';

SELECT [b].[Isbn], [b].[Author], [b].[Price], [b].[Stock], [b].[Title], [b].[Year]
FROM [Books] AS [b]
WHERE [b].[Price] < @value AND [b].[Author] = @value1
$ dotnet run --project src/Bookstore.Filter.Cli -- sql-inline 'price < 300 and author = "Jane Austen"'
SELECT [b].[Isbn], [b].[Author], [b].[Price], [b].[Stock], [b].[Title], [b].[Year]
FROM [Books] AS [b]
WHERE [b].[Price] < 300.0 AND [b].[Author] = N'Jane Austen'

İlk tahminin cevabı code generation'daki tercihe bağlı. Expression.Constant ile gömülen değerleri EF Core SQL metnine literal olarak yazıyor. Değeri bir object'in field'ından okursak, yani C# compiler'ının bir lambda'nın yakaladığı local değişken için ürettiği şekli taklit edersek, EF Core onu parametreye çeviriyor. Parametrenin adı da field'ın adından geliyor. Tipi bile kolonun mapping'inden çıkıyor: decimal(10,2) ve nvarchar(100), BookstoreContext'teki HasPrecision ve HasMaxLength ayarlarından geliyor.

Parametre tercihinin iki sebebi var. Birincisi SQL Server'ın plan cache'i: literal'lı sorguda her farklı fiyat ayrı bir sorgu metni ve ayrı bir execution plan demek, parametreli sorguda tek bir plan yeniden kullanılıyor. İkincisi güvenlik. Literal modda EF Core tırnakları doğru kaçırıyor, ama kullanıcı girdisinin hiçbir koşulda SQL metnine girmemesi daha güçlü bir garanti.

$ dotnet run --project src/Bookstore.Filter.Cli -- sql "author = \"x' OR 1=1 --\""
DECLARE @value nvarchar(100) = N'x'' OR 1=1 --';

SELECT [b].[Isbn], [b].[Author], [b].[Price], [b].[Stock], [b].[Title], [b].[Year]
FROM [Books] AS [b]
WHERE [b].[Author] = @value
$ dotnet run --project src/Bookstore.Filter.Cli -- sql 'title contains "50%_off"'
DECLARE @value_contains nvarchar(200) = N'%50\%\_off%';

SELECT [b].[Isbn], [b].[Author], [b].[Price], [b].[Stock], [b].[Title], [b].[Year]
FROM [Books] AS [b]
WHERE [b].[Title] LIKE @value_contains ESCAPE N'\'
$ dotnet run --project src/Bookstore.Filter.Cli -- sql 'year >= 1900.5 or title contains "Dune"'
DECLARE @value decimal(18,2) = 1900.5;
DECLARE @value1_contains nvarchar(200) = N'%Dune%';

SELECT [b].[Isbn], [b].[Author], [b].[Price], [b].[Stock], [b].[Title], [b].[Year]
FROM [Books] AS [b]
WHERE CAST([b].[Year] AS decimal(18,2)) >= @value OR [b].[Title] LIKE @value1_contains ESCAPE N'\'

İkinci tahminin cevabı da burada. EF Core, string.Contains'i parametreyle çevirirken kullanıcının % ve _ karakterlerini ters bölüyle kaçırıyor ve sorguya ESCAPE ekliyor; 50%_off düz metin olarak aranıyor. Bunu varsaymadık, testlerden biri SQL metnini kontrol ediyor; EF Core'un bir sonraki sürümü bu davranışı değiştirirse test kırılacak.

Son çıktıda implicit conversion'ın SQL'deki izi var. year bir int kolon, 1900.5 bir decimal; binder conversion'ı alan tarafına koydu ve SQL'de bu CAST([b].[Year] AS decimal(18,2)) oldu. Kolonun kendisine uygulanan bir fonksiyon, SQL Server'ın o kolondaki bir index'i seek için kullanmasını genellikle engeller. Conversion'ın hangi tarafa konduğu bellekte fark etmiyor, veritabanında sorgu planını değiştiriyor.

Roslyn'de Karşılığı: SyntaxTree ve SemanticModel

Yazdığımız aşamaların C# compiler'ındaki karşılıklarını Microsoft.CodeAnalysis.CSharp paketiyle görebiliriz. Örnek, Bookstore sorgusuna benzeyen iki method'u parse ediyor; biri doğru, biri b.Price == "abc" hatasını içeriyor:

// 1. yalnız syntax: parser şekli biliyor, anlamı bilmiyor
SyntaxTree tree = CSharpSyntaxTree.ParseText(code);
Console.WriteLine($"syntax errors: {tree.GetDiagnostics().Count()}");

// 2. compilation referansları ekliyor; semantic model "bu isim ne demek?" sorusunu cevaplıyor
IEnumerable<MetadataReference> references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
    .Split(Path.PathSeparator)
    .Select(path => MetadataReference.CreateFromFile(path));
CSharpCompilation compilation = CSharpCompilation.Create("Demo", [tree], references,
    new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
SemanticModel model = compilation.GetSemanticModel(tree);

foreach (BinaryExpressionSyntax binary in tree.GetRoot().DescendantNodes().OfType<BinaryExpressionSyntax>()
             .Where(b => !b.IsKind(SyntaxKind.LogicalAndExpression)))
{
    string op = model.GetSymbolInfo(binary).Symbol?.ToDisplayString() ?? "(no operator found)";
    TypeInfo right = model.GetTypeInfo(binary.Right);
    Conversion conversion = model.GetConversion(binary.Right);
    string kind = conversion.IsIdentity ? "identity" : conversion.IsNumeric ? "implicit numeric" : "none";
    Console.WriteLine($"{binary,-26} {op}");
    Console.WriteLine($"{"",-26} right operand: {right.Type} -> {right.ConvertedType} ({kind})");
}

MemberAccessExpressionSyntax price = tree.GetRoot().DescendantNodes().OfType<MemberAccessExpressionSyntax>()
    .First(m => m.Name.Identifier.Text == "Price");
Console.WriteLine($"b.Price binds to: {model.GetSymbolInfo(price).Symbol?.ToDisplayString()} ({model.GetSymbolInfo(price).Symbol?.Kind})");

// 3. binder'ın hataları ancak bir compilation olunca ortaya çıkıyor; her makinede İngilizce biçimlenir
foreach (Diagnostic diagnostic in compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error))
    Console.WriteLine(CSharpDiagnosticFormatter.Instance.Format(diagnostic, CultureInfo.InvariantCulture));
$ dotnet run --project samples/RoslynSemantic
syntax errors: 0
b.Price < 300              decimal.operator <(decimal, decimal)
                           right operand: int -> decimal (implicit numeric)
b.Author == "Jane Austen"  string.operator ==(string, string)
                           right operand: string -> string (identity)
b.Price == "abc"           (no operator found)
                           right operand: string -> string (identity)
b.Price binds to: Book.Price (Property)
(11,26): error CS0019: Operator '==' cannot be applied to operands of type 'decimal' and 'string'

Çıktı bizim binder'ımızın yaptıklarının aynısı. Syntax tree'de hata yok; b.Price == "abc" söz dizimi açısından geçerli. b.Price bir property symbol'üne bağlanıyor. < için decimal'in operatörü seçiliyor ve sağdaki 300 için implicit numeric conversion kaydediliyor; bizim ToDecimal'ın yaptığı da bu. "abc" için uygun operatör bulunamıyor ve CS0019 hatası ancak compilation'dan, yani binder'dan geliyor. Bunlara elle bakmak istersen SharpLab (sharplab.io) bir C# satırının syntax tree'sini, IL'ini ve JIT'in (just-in-time; IL'i çalışma anında makine koduna çeviren compiler) çıktısını tarayıcıda gösteriyor. Visual Studio'da .NET Compiler Platform SDK bileşeni kuruluysa View > Other Windows > Syntax Visualizer penceresi de editördeki dosyanın ağacını canlı olarak gösteriyor.

Bu ayrım, C#'ı genişleten araçların pipeline'da nereye takıldığını da açıklıyor. Analyzer'lar syntax tree'ye ve semantic model'e bakıp diagnostic üretiyor; kendi kuralını yazmak ilerideki Statik Analiz serisinin konusu. Source Generator'lar compilation'ı (syntax ve semantic bilgiyle birlikte) okuyup yeni kaynak dosyalar ekliyor, compiler da emit'ten önce onları derlemeye katıyor. Yazımı C# İleri serisinin "C#'ta Source Generator: IIncrementalGenerator ile Compile Time'da Kod Üretmek" yazısında.

Üretimde Dikkat

Kullanıcıdan gelen bir sorgu dili attack surface'in bir parçası. Bizim dilde SQL injection yapısal olarak kapalı: alan adları yalnız symbol table'dan gelebiliyor, değerler her zaman parametre oluyor ve SQL'i biz değil EF Core üretiyor. Ama injection tek risk değil; kaynak tüketimi de var. 03. yazıda derinlik sınırını koymuştuk. Bu yeterli değil:

$ dotnet run --project src/Bookstore.Filter.Cli -- chain 5000
chain: 69995 chars, 0 parse error(s), AST depth 5001
bound: 0 error(s)
limited: error at 1:1: filter is 69995 characters long (limit 1000)
$ dotnet run --project src/Bookstore.Filter.Cli -- chain 200000
chain: 2799995 chars, 0 parse error(s), AST depth 200001
Stack overflow.
Repeated 22715 times:
--------------------------------
   at Bookstore.Filter.Binding.Binder.BindLogical(Bookstore.Filter.Syntax.BinaryExpr)
   at Bookstore.Filter.Binding.Binder.BindExpr(Bookstore.Filter.Syntax.Expr)
--------------------------------
   at Bookstore.Filter.Binding.Binder.Bind(Bookstore.Filter.Syntax.Expr, Bookstore.Filter.Binding.SymbolTable)

chain komutu stock > 0 and stock > 0 and ... zincirini uzunluk sınırını atlayarak parse ve bind ediyor. Pratt parser 200.000 koşulu tek seviyede, hatasız topladı; ama AST'nin derinliği 200.001 ve ağacı recursive gezen binder process'i düşürdü. Parser'ın derinlik sınırı burada hiç devreye girmedi. FilterCompiler.Compile bu yüzden her şeyden önce girdiyi 1.000 karakterle sınırlıyor; bu, ağacın derinliğini de birkaç yüzle sınırlıyor. Arama kutusuna yazılan bir filtre için 1.000 karakter fazlasıyla yeterli.

Veritabanı tarafındaki maliyeti de unutma. title contains "a", LIKE '%a%' olarak bütün tabloyu tarar. Büyük bir katalogda contains'i en az birkaç karakterle sınırlamak, sorguya bir TOP ya da sayfalama eklemek ve EF Core'un command timeout'unu düşük tutmak gerekiyor. Bunlar dilin değil, onu çalıştıran API'nin sorumluluğu; ASP.NET Core Web API serisinin "Pagination, Filtering ve Sorting" yazısı ve rate limiting'i anlatan .NET 7 yenilikleri yazısı bu tarafı ele alıyor.

Compile() her çağrıda yeni IL üretip JIT'e veriyor. Aynı filtre metni sık geliyorsa derlenmiş delegate'i filtre metnine göre cache'le; EF Core da kendi tarafında parametreli sorguların derlenmiş hâlini cache'liyor ve bu, parametre kullanmanın bir başka faydası.

Sık Düşülen Tuzaklar

İki back-end'in aynı anlamı taşıdığını varsaymak. Bellekteki delegate string.Contains ile ordinal, yani büyük/küçük harfe duyarlı karşılaştırıyor. SQL Server'da sonuç kolonun collation'ına bağlı ve yaygın kullanılan varsayılan collation'lar büyük/küçük harfe duyarsız. title contains "dune" bellekte hiçbir şey döndürmezken veritabanında Dune'u bulabilir. Bu yazıda sorguları veritabanında çalıştırmadık; bu fark bilinen bir davranış ve dili tasarlarken ya kabul edilmeli ya da iki tarafta da açıkça aynı kural uygulanmalı.

Symbol table'ı reflection ile otomatik doldurmak. Entity'ye eklenen her yeni property kendiliğinden filtrelenebilir hâle gelir. Filtrelenebilir alanlar bilinçli bir izin listesi olmalı.

Tip kontrolünü code generation'a bırakmak. Expression.Equal(decimal, string) exception fırlatır ama ne satır ne sütun söyler. Hatayı kullanıcının anlayacağı yerde, binder'da yakala; code generation yalnız geçerli ağaç görsün.

Implicit conversion'ı rastgele tarafa koymak. Literal'ı dönüştürmek bedava, kolonu dönüştürmek her satırda bir maliyet ve veritabanında index kaybı demek. Mümkün olan her yerde sabit tarafı dönüştür.

Küçük Bir Deneme

★☆☆ BookstoreContext'te Author kolonunun HasMaxLength(100) ayarını kaldırıp sql komutunu yeniden çalıştır. Parametrenin tipi nasıl değişti? Bunun plan cache'e etkisini düşün.

★★☆ Dile published adında bir DateOnly alanı ve published >= 2020-01-01 biçiminde tarih literal'ı ekle. Lexer, binder ve code generation'da nereleri değiştirdin? ToQueryString ile üretilen SQL'i kontrol eden bir test yaz.

★★★ year >= 1900.5 gibi int kolon ve kesirli literal karşılaştırmalarında conversion'ı kolona değil literal'a uygulayan bir kural yaz (year >= 1901 ile eşdeğer). Hangi operatörlerde yukarı, hangilerinde aşağı yuvarlaman gerekiyor? Kuralını bellekteki delegate ile interpreter'ı karşılaştıran bir testle doğrula.

Front-end tamamlandı: metin token'a, token ağaca, ağaç doğrulanmış bir bound tree'ye, o da delegate ya da SQL'e dönüştü. Bir sonraki yazıda back-end'in diğer ucuna, .NET runtime'ına geçiyoruz: IL'in stack tabanlı yapısını, tiered compilation'ı, Dynamic PGO'yu (profile-guided optimization; çalışma anında toplanan profile göre optimizasyon), inlining'i ve .NET 10'un escape analysis'ini DOTNET_JitDisasm çıktılarıyla inceleyeceğiz.