Mert Özen Her Satırda Daha İleri
Backend C# İleri Seviye — #07

C#'ta Expression Tree: Lambda'yı Veri Olarak Tutmak ve EF Core'a Köprü

Mert Özen 7 Kas 2026 11 dk 1 görüntülenme
C#'ta Expression Tree: Lambda'yı Veri Olarak Tutmak ve EF Core'a Köprü

Func ile Expression<Func> farkı, ağacın node'larına bakmak ve Compile, Expression factory method'larıyla dinamik filtre kurmak, ExpressionVisitor ile küçük bir SQL çevirici, IQueryable ve EF Core 6'nın ToQueryString çıktısı, yakalanan değişkenin parametreye dönüşmesi, çevrilemeyen method hatası, CS0834, CS8072, CS8110 gibi sınırlar ve BenchmarkDotNet ile Compile'ın 70 mikrosaniyelik bedeli.

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

Harfi harfine aynı lambda'yı iki farklı tipe atıyoruz: biri Func<Book, bool>, diğeri Expression<Func<Book, bool>>. Sonra ikisini de Console.WriteLine ile yazdırıyoruz. İki satır aynı mı olur?

using System.Linq.Expressions;

Func<Book, bool> asFunc = b => b.Price > 50m;
Expression<Func<Book, bool>> asExpression = b => b.Price > 50m;

Console.WriteLine(asFunc);
Console.WriteLine(asExpression);

public record Book(string Title, decimal Price, int Stock);

İlk yazıda bir delegate'in ToString()'inin yalnızca tip adını verdiğini görmüştük. İkinci satır için bir tahminin var mı?

Kod Değil, Veri

System.Func`2[Book,System.Boolean]
b => (b.Price > 50)

İlk satır bir delegate: derlenmiş, çalışmaya hazır bir method'un adresi. İçinde ne yazdığını sormanın yolu yok, yalnızca çağırabilirsin. İkinci satır ise lambda'nın kendisini, okunabilir bir hâlde geri verdi. Çünkü lambda bir Expression<TDelegate>'e atandığında compiler onu IL'e derlemez; onun yerine lambda'yı tarif eden bir ağaç kuran kod üretir. Ağacın her node'u bir expression'dır: parametre, property erişimi, sabit, karşılaştırma. Bu ağacın içine bakabilirsin:

using System.Linq.Expressions;

Expression<Func<Book, bool>> isExpensive = b => b.Price > 50m;

BinaryExpression body = (BinaryExpression)isExpensive.Body;
MemberExpression left = (MemberExpression)body.Left;
ConstantExpression right = (ConstantExpression)body.Right;

Console.WriteLine($"parameter: {isExpensive.Parameters[0].Name} ({isExpensive.Parameters[0].Type.Name})");
Console.WriteLine($"body: {body.NodeType}");
Console.WriteLine($"left: {left.NodeType} -> {left.Member.Name}");
Console.WriteLine($"right: {right.NodeType} -> {right.Value} ({right.Type.Name})");

Func<Book, bool> compiled = isExpensive.Compile();
Console.WriteLine(compiled(new Book("Dune", 45.90m, 3)));
Console.WriteLine(compiled(new Book("TAOCP Box Set", 249.00m, 2)));

public record Book(string Title, decimal Price, int Stock);
parameter: b (Book)
body: GreaterThan
left: MemberAccess -> Price
right: Constant -> 50 (Decimal)
False
True

Ağacın kökü bir LambdaExpression: bir parametre listesi ve bir gövde. Gövde GreaterThan türünde bir BinaryExpression; solunda Price property'sine erişen bir MemberExpression, sağında decimal tipinde 50 değerini taşıyan bir ConstantExpression var. Bir yemek tarifi gibi düşün: delegate pişmiş yemek, expression tree ise tarif. Yemeği yiyebilirsin ama içindekileri okuyamazsın; tarifi okuyabilir, değiştirebilir, başka bir mutfağın diline çevirebilir ya da sonunda Compile() ile pişirebilirsin.

07-expression-tree-tr.png

Ağacı Elle Kurmak: Dinamik Filtre

Compiler'ın yaptığını sen de yapabilirsin. Expression class'ının static factory method'larıyla ağacı parça parça kurarsın. Bunun gerçek hayattaki karşılığı, kullanıcının bir ekranda seçtiği filtreyi runtime'da sorguya çevirmek. Property adı ve değer metin olarak gelir; hangi kombinasyonun seçileceğini compile time'da bilemezsin:

using System.Globalization;
using System.Linq.Expressions;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

List<Book> books = new List<Book>
{
    new Book("Dune", 45.90m, 3),
    new Book("Clean Code", 62.00m, 0),
    new Book("Refactoring", 58.50m, 12)
};

// kullanıcının seçtiği filtre: property adı, operatör ve değer metin olarak gelir
(string Property, string Operator, string Value)[] filters =
{
    ("Price", ">", "50"),
    ("Stock", "==", "0")
};

foreach ((string property, string op, string value) in filters)
{
    Expression<Func<Book, bool>> filter = BuildFilter<Book>(property, op, value);
    Func<Book, bool> predicate = filter.Compile();
    Console.WriteLine($"{filter}: {string.Join(", ", books.Where(predicate).Select(b => b.Title))}");
}

static Expression<Func<T, bool>> BuildFilter<T>(string propertyName, string op, string value)
{
    ParameterExpression item = Expression.Parameter(typeof(T), "item");
    MemberExpression property = Expression.Property(item, propertyName);
    object typedValue = Convert.ChangeType(value, property.Type, CultureInfo.InvariantCulture);
    ConstantExpression constant = Expression.Constant(typedValue, property.Type);

    Expression body = op switch
    {
        ">" => Expression.GreaterThan(property, constant),
        "<" => Expression.LessThan(property, constant),
        "==" => Expression.Equal(property, constant),
        _ => throw new NotSupportedException($"Operator '{op}' is not supported.")
    };

    return Expression.Lambda<Func<T, bool>>(body, item);
}

public record Book(string Title, decimal Price, int Stock);
item => (item.Price > 50): Clean Code, Refactoring
item => (item.Stock == 0): Clean Code

Önceki yazıdaki reflection burada da var: Expression.Property property'yi adıyla bulur ve property.Type ile tipini öğrenip metni decimal'e ya da int'e çeviririz. Fark şu: reflection'da her çağrıda GetValue yapıyorduk, burada ağacı bir kez derleyip normal bir delegate elde ediyoruz. Daha da önemlisi, aynı Expression<Func<Book, bool>>'i Compile etmeden bir veritabanı sorgusuna da verebilirsin. Biraz sonra bunu EF Core ile yapacağız.

ExpressionVisitor ile Ağacı Gezmek

Bir LINQ provider'ın, örneğin EF Core'un, yaptığı iş özünde şudur: expression tree'yi node node gezmek ve her node'u hedef dile çevirmek. .NET bu gezinti için ExpressionVisitor base class'ını verir; her node tipi için override edebileceğin bir VisitXxx method'u vardır. Çok küçük bir SQL çeviricisi yazalım:

using System.Globalization;
using System.Linq.Expressions;
using System.Text;

decimal maxPrice = 30m;

Console.WriteLine(WhereTranslator.Translate(b => b.Price > 50m && b.Stock > 0));
Console.WriteLine(WhereTranslator.Translate(b => b.Author == "Frank Herbert" || b.Price < maxPrice));

try
{
    Console.WriteLine(WhereTranslator.Translate(b => b.Title.StartsWith("D")));
}
catch (NotSupportedException ex)
{
    Console.WriteLine($"not supported: {ex.Message}");
}

public record Book(string Title, string Author, decimal Price, int Stock);

public class WhereTranslator : ExpressionVisitor
{
    private readonly StringBuilder _sql = new StringBuilder();

    public static string Translate(Expression<Func<Book, bool>> predicate)
    {
        WhereTranslator translator = new WhereTranslator();
        translator.Visit(predicate.Body);
        return "WHERE " + translator._sql;
    }

    protected override Expression VisitBinary(BinaryExpression node)
    {
        _sql.Append('(');
        Visit(node.Left);
        _sql.Append(node.NodeType switch
        {
            ExpressionType.AndAlso => " AND ",
            ExpressionType.OrElse => " OR ",
            ExpressionType.Equal => " = ",
            ExpressionType.NotEqual => " <> ",
            ExpressionType.GreaterThan => " > ",
            ExpressionType.LessThan => " < ",
            _ => throw new NotSupportedException($"Operator {node.NodeType} is not supported.")
        });
        Visit(node.Right);
        _sql.Append(')');
        return node;
    }

    protected override Expression VisitMember(MemberExpression node)
    {
        // parametrenin property'si: kolon adı
        if (node.Expression is ParameterExpression)
        {
            _sql.Append(node.Member.Name);
            return node;
        }

        // yakalanan değişken: değerini hesapla
        object? value = Expression.Lambda(node).Compile().DynamicInvoke();
        AppendValue(value);
        return node;
    }

    protected override Expression VisitConstant(ConstantExpression node)
    {
        AppendValue(node.Value);
        return node;
    }

    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        throw new NotSupportedException($"Method {node.Method.Name} is not supported.");
    }

    private void AppendValue(object? value)
    {
        _sql.Append(value switch
        {
            null => "NULL",
            string text => $"'{text.Replace("'", "''")}'",
            IFormattable number => number.ToString(null, CultureInfo.InvariantCulture),
            _ => value.ToString()
        });
    }
}
WHERE ((Price > 50) AND (Stock > 0))
WHERE ((Author = 'Frank Herbert') OR (Price < 30))
not supported: Method StartsWith is not supported.

Küçük bir class, C# lambda'larını SQL'e çevirdi. && AndAlso node'u oldu, == Equal oldu, property'ler kolon adına dönüştü. İkinci sorgudaki maxPrice ilginç: lambda onu yakaladığı için ağaçta bir sabit olarak değil, ikinci yazıda gördüğümüz display class'ın bir field'ına erişim olarak duruyor. Çevirici bu node'un parametreye değil başka bir object'e ait olduğunu görüp değerini hesapladı. StartsWith'i ise tanımıyor ve bunu açıkça söylüyor; gerçek bir provider'ın bilmediği bir method'la karşılaştığında yaptığı da tam olarak bu.

Bu çevirici değerleri SQL metnine gömüyor ve tırnakları kaçırmak dışında hiçbir koruma yapmıyor; öğrenmek için yeterli, üretim için değil. Gerçek provider'lar değerleri parametre olarak gönderir. Bunu da şimdi göreceğiz.

IQueryable ve EF Core: Aynı Lambda, İki Yol

LINQ'in iki yüzü burada birleşiyor. Enumerable.Where bir Func<T, bool> alır ve bellekteki bir collection'ı tek tek dolaşır. Queryable.Where ise bir Expression<Func<T, bool>> alır ve çalıştırmaz, sorgunun ağacına bir node ekler. Lambda'yı yazarken hangisinin seçileceğini sen değil, kaynağın tipi belirler: IQueryable<T> üzerindeyken compiler lambda'nı bir expression tree'ye çevirir. EF Core ile görelim. ToQueryString() SQL'i üretir ama veritabanına bağlanmaz; bu yüzden örnek bir SQL Server'a ihtiyaç duymadan çalışıyor:

using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;

using BookstoreContext db = new BookstoreContext();

decimal minPrice = 50m;
IQueryable<Book> query = db.Books
    .Where(b => b.Price > minPrice && b.Stock > 0)
    .OrderBy(b => b.Title);

Console.WriteLine(query.Expression);
Console.WriteLine();
Console.WriteLine(query.ToQueryString());
Console.WriteLine();

try
{
    Console.WriteLine(db.Books.Where(b => BookRules.IsBestseller(b)).ToQueryString());
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";

    [Precision(10, 2)]
    public decimal Price { get; set; }

    public int Stock { get; set; }
}

public static class BookRules
{
    public static bool IsBestseller(Book book) => book.Stock > 100;
}

public class BookstoreContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlServer("Server=localhost;Database=Bookstore;Trusted_Connection=True");
    }
}
[Microsoft.EntityFrameworkCore.Query.QueryRootExpression].Where(b => ((b.Price > value(Program+<>c__DisplayClass0_0).minPrice) AndAlso (b.Stock > 0))).OrderBy(b => b.Title)

DECLARE @__minPrice_0 decimal(10,2)(10) = 50.0;

SELECT [b].[Id], [b].[Price], [b].[Stock], [b].[Title]
FROM [Books] AS [b]
WHERE ([b].[Price] > @__minPrice_0) AND ([b].[Stock] > 0)
ORDER BY [b].[Title]

The LINQ expression 'DbSet<Book>()
    .Where(b => BookRules.IsBestseller(b))' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

net6.0 hedefli bu projede EF Core 6.0.36 kullandık. İlk çıktı query.Expression: Where ve OrderBy çağrıları hiçbir şey çalıştırmadan tek bir ağaçta birikmiş. value(Program+<>c__DisplayClass0_0).minPrice yine yakalanan değişkenin display class'taki field'ı. EF Core da bizim çeviricimiz gibi bu node'u tanıdı, ama değeri SQL'e gömmek yerine @__minPrice_0 adlı bir parametreye çevirdi. SQL injection'a karşı korumanın yanında, aynı sorgu farklı bir minPrice ile çalıştığında veritabanının sorgu planını yeniden kullanmasını da sağlar. DECLARE satırındaki tip yazımı EF Core 6'nın ToQueryString çıktısına özgü bir tuhaflık; asıl sorgu parametreyle gönderilir.

İkinci sorgu çevrilemedi. IsBestseller bir C# method'u; EF Core onun gövdesini göremez, ağaçta yalnızca "bu method'u çağır" diyen bir MethodCallExpression var ve SQL'de bunun karşılığı yok. Çözüm ya koşulu sorgunun içine doğrudan yazmak (b.Stock > 100), ya da kuralı bir Expression<Func<Book, bool>> olarak tanımlayıp Where'e onu vermek. Bu ayrımı ve client evaluation'ı EF Core serisinin LINQ sorguları yazısında ayrıntılı anlattık.

Expression Tree'nin Sınırları

Her lambda bir expression tree'ye dönüşemez. Ağaç, dilin daha eski ve sınırlı bir alt kümesini temsil eder:

using System.Linq.Expressions;

Expression<Func<Book, bool>> withBody = b =>
{
    return b.Price > 50m;
};

public record Book(string Title, decimal Price, int Stock);
Program.cs(3,41): error CS0834: A lambda expression with a statement body cannot be converted to an expression tree

Süslü parantezli statement lambda olmaz; tek bir expression olmalı. Denediğim diğer yasaklar: b.Subtitle?.Length için error CS8072: An expression tree lambda may not contain a null propagating operator., b.Price is > 50m and < 100m için error CS8122: An expression tree may not contain an 'is' pattern-matching operator., switch expression için CS8514, (b.Title, b.Stock) gibi bir tuple literal için CS8143. EF Core örneğinde ilk denememde IsBestseller'ı top-level kodda bir local function olarak yazmıştım; o da error CS8110: An expression tree may not contain a reference to a local function ile reddedildi, bu yüzden static bir class'a taşıdım. Compiler'ın expression tree'ye çevirebildiği dil özellikleri uzun zamandır genişlemiyor; ?. ve pattern matching gibi sonradan gelenler bu listeye girmedi. EF Core sorgusunda ?. yerine b.Subtitle != null ? ... : ... yazmak zorunda kalmanın sebebi bu.

Compile'ın Bedeli

Compile() ağacı runtime'da IL'e çevirir ve JIT'ten geçirir; bu ucuz bir iş değil. BenchmarkDotNet ile normal bir lambda'yı, bir kez derlenip saklanan bir ağacı ve her çağrıda yeniden derlenen bir ağacı karşılaştırdım:

[MemoryDiagnoser]
[InProcess]
public class CompileBenchmarks
{
    private readonly Book _book = new Book("Dune", 45.90m, 3);
    private readonly Func<Book, bool> _lambda = b => b.Price > 50m && b.Stock > 0;
    private readonly Expression<Func<Book, bool>> _expression = b => b.Price > 50m && b.Stock > 0;
    private readonly Func<Book, bool> _compiled;

    public CompileBenchmarks()
    {
        _compiled = _expression.Compile();
    }

    [Benchmark(Baseline = true)]
    public bool Lambda() => _lambda(_book);

    [Benchmark]
    public bool CachedCompiled() => _compiled(_book);

    [Benchmark]
    public bool CompileEveryTime() => _expression.Compile()(_book);
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
  [Host] : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a

| Method           | Mean          | Error         | StdDev        | Ratio     | RatioSD  | Gen0   | Gen1   | Allocated | Alloc Ratio |
|----------------- |--------------:|--------------:|--------------:|----------:|---------:|-------:|-------:|----------:|------------:|
| Lambda           |      2.099 ns |     0.0038 ns |     0.0033 ns |      1.00 |     0.00 |      - |      - |         - |          NA |
| CachedCompiled   |      1.863 ns |     0.0025 ns |     0.0021 ns |      0.89 |     0.00 |      - |      - |         - |          NA |
| CompileEveryTime | 70,188.028 ns | 1,386.5887 ns | 2,984.7786 ns | 33,432.53 | 1,410.81 | 0.4883 | 0.2441 |    4836 B |          NA |

Önceki yazıdaki gibi proje net6.0 hedefli, Release modda ve roll-forward ile .NET 8.0.25 üzerinde in-process koştu. Bir kez derlenmiş ağaç normal lambda kadar hızlı, ölçümde biraz daha hızlı bile çıktı; nanosaniyenin altındaki bu fark pratikte önemsiz. Her çağrıda Compile etmek ise yaklaşık 70 mikrosaniye ve 4,8 KB allocation; normal çağrının otuz bin katından fazla. Kural reflection'daki ile aynı: ağacı kur, bir kez derle, delegate'i sakla. EF Core da çevirdiği sorguları bu yüzden cache'e alır.

Sık Düşülen Tuzaklar

IQueryable'ı yanlışlıkla IEnumerable'a çevirmek. Bir repository method'u IEnumerable<Book> döndürürse, çağıranın eklediği Where artık Enumerable.Where olur: bütün tablo belleğe çekilir ve filtre C#'ta çalışır. Kod derlenir, testler geçer, yalnızca üretimde yavaşlar.

Sorguda çevrilemeyen method çağırmak. IsBestseller örneğindeki gibi, C#'ta tamamen geçerli bir method SQL'e çevrilemeyebilir. Hata derlemede değil, sorgu çalıştığında gelir.

Her istekte Compile etmek. Dinamik filtreyi her istekte yeniden kurup derlemek, ölçtüğümüz 70 mikrosaniyeyi her isteğe ekler. Aynı filtre tekrar ediyorsa derlenmiş delegate'i bir anahtarla sakla.

Expression tree'yi gereksiz yere kullanmak. Bir LINQ provider yazmıyorsan, dinamik bir sorgu kurmuyorsan ya da bir expression'ı incelemen gerekmiyorsa düz bir Func yeterlidir. Expression tree esneklik verir, okunabilirliği ve hızı ise geri alır.

Küçük Bir Deneme

★☆☆ BuildFilter'a ">=" ve "!=" operatörlerini ekle (Expression.GreaterThanOrEqual, Expression.NotEqual) ve iki filtreyi Expression.AndAlso ile birleştiren bir Combine method'u yaz.

★★☆ WhereTranslator'a StartsWith desteği ekle: VisitMethodCall'da method adı StartsWith ise Title LIKE 'D%' üret. Argümandaki % karakterini nasıl ele alman gerektiğini düşün.

★★★ EF Core örneğinde IsBestseller'ı public static Expression<Func<Book, bool>> IsBestseller => b => b.Stock > 100; şeklinde yeniden tanımla, db.Books.Where(BookRules.IsBestseller) ile kullan ve ToQueryString() çıktısında WHERE satırının oluştuğunu gör.

Bu yazıyla ilk yazıda başlayan yolun sonuna geldik: bir method'u değer gibi taşımaktan, aynı lambda'yı incelenecek bir veri olarak tutmaya. Sıradaki yazıda yönü bellek tarafına çeviriyoruz: Span<T> ve Memory<T> ile allocation yapmadan string'ler ve byte array'leri üzerinde çalışmak.