LINQ Deferred Execution: Sorgu Ne Zaman Çalışır? IEnumerable ve IQueryable
LINQ sorgusunun tanımlandığında değil dolaşıldığında çalışması, element element streaming, hemen çalışan ve non-streaming operatörler, çoklu enumeration'ın bedeli ve Func ile expression tree üzerinden IEnumerable ile IQueryable farkı.
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
Sorguyu tanımlıyoruz, ardından listeye bir kitap ekleyip fiyat sınırını yükseltiyoruz ve ancak ondan sonra sonucu soruyoruz. Count() kaç yazar, foreach hangi kitapları listeler? Sorgu tanımlandığı andaki duruma göre mi çalışır, yoksa sonradan yaptığımız değişiklikleri de görür mü?
var books = new List<Book>
{
new("978-0132350884", "Clean Code", 37.90m, 12),
new("978-0135957059", "The Pragmatic Programmer", 42.50m, 0),
new("978-0321125217", "Domain-Driven Design", 54.99m, 3),
new("978-1617294532", "C# in Depth", 44.99m, 5),
};
decimal maxPrice = 40m;
IEnumerable<Book> cheap = books.Where(b => b.Price < maxPrice);
books.Add(new("978-0201633610", "Design Patterns", 29.99m, 7));
maxPrice = 50m;
Console.WriteLine(cheap.Count());
foreach (Book book in cheap)
Console.WriteLine(book.Title);
record Book(string Isbn, string Title, decimal Price, int Stock);
Sorgu Bir Tarif, Sonuç Değil
Çıktı şu:
4
Clean Code
The Pragmatic Programmer
C# in Depth
Design Patterns
Sorgu tanımlandığında listede üç kitap vardı ve sınır 40'tı; o anda çalışsaydı yalnızca Clean Code dönerdi. Oysa sonuçta dört kitap var: sonradan eklenen Design Patterns da, sınır 50'ye çıktığı için artık uyan The Pragmatic Programmer ile C# in Depth de listede. Demek ki books.Where(...) satırı hiçbir kitabı filtrelemedi. Yaptığı tek şey, "books'u dolaş, fiyatı maxPrice'tan küçük olanları ver" diyen bir object oluşturmaktı. Filtreleme, Count() bu object'i dolaştığı anda, o anki liste ve o anki maxPrice ile yapıldı.
Buna deferred execution (ertelenmiş çalışma) denir. Sorgu bir yemek tarifi gibidir: tarifi yazmak yemek pişirmek değildir, malzemeler de tarifi yazdığın anda değil, pişirmeye başladığın anda dolaptan alınır. Arada dolaba yeni bir şey koyduysan o da tencereye girer.
maxPrice'ın etkisi ayrıca dikkat istiyor. Lambda, değişkenin değerini değil değişkenin kendisini yakalar; bu bir closure'dır. Sorgu çalıştığında lambda maxPrice'ı o an okur ve 50'yi görür. Sorgu tanımlandıktan sonra değiştirilen her yakalanmış değişken, sonucu sessizce değiştirir.
Element Element Streaming
Peki runtime'da iş nasıl yürüyor? Önce bütün listeyi filtreleyip sonra hepsini dönüştürüyor mu? Where ve Select'in içine birer log satırı koyalım:
IEnumerable<string> query = books
.Where(b =>
{
Console.WriteLine($" Where: {b.Title}");
return b.Stock > 0;
})
.Select(b =>
{
Console.WriteLine($" Select: {b.Title}");
return b.Isbn;
});
Console.WriteLine("Query defined");
foreach (string isbn in query)
Console.WriteLine($"foreach got {isbn}");
Query defined
Where: Clean Code
Select: Clean Code
foreach got 978-0132350884
Where: The Pragmatic Programmer
Where: Domain-Driven Design
Select: Domain-Driven Design
foreach got 978-0321125217
Where: C# in Depth
Select: C# in Depth
foreach got 978-1617294532
"Query defined" ilk satırda: tanım sırasında iki lambda'nın hiçbiri çalışmadı. Asıl ilginç olan sonrası. foreach ilk element'i istediğinde Select kendi kaynağından, yani Where'den bir element ister; Where de listeden element çekmeye başlar, koşulu sağlayan ilkini bulunca onu yukarı verir. Clean Code bu yolla üç katmanı geçip foreach'e ulaşıyor ve ancak ondan sonra ikinci kitaba bakılıyor. The Pragmatic Programmer Where'de eleniyor ve Select'e hiç uğramıyor.

Bu bir çekme (pull) modelidir. Fabrikadaki bant gibi değil, sipariş usulü çalışan bir mutfak gibi: her tabak, en sondaki tüketici "bir tane daha" dediğinde hazırlanır. Arada ara liste oluşmaz; sorguya kaç operatör eklersen ekle, her element zinciri tek başına geçer. Önceki yazıda ara adımlara isim vermenin maliyetsiz olduğunu söylemiştik; sebebi bu.
Hemen Çalışan Operatörler
Her LINQ operatörü deferred değildir. Kabaca şöyle ayırabilirsin: sequence döndürenler (Where, Select, OrderBy, Take…) deferred çalışır. Tek bir değer döndürenler (Count, Sum, Max, First, Any…) ve sonucu yeni bir collection'a dolduranlar (ToList, ToArray, ToDictionary, ToHashSet) hemen çalışır. Bir değeri ya da dolu bir collection'ı geri vermek için kaynağı o anda dolaşmak zorundadırlar.
IEnumerable<Book> inStock = books.Where(b => b.Stock > 0);
List<Book> snapshot = books.Where(b => b.Stock > 0).ToList();
int countThen = books.Count(b => b.Stock > 0);
books.Add(new("978-0201633610", "Design Patterns", 29.99m, 7));
Console.WriteLine($"deferred: {inStock.Count()}");
Console.WriteLine($"ToList: {snapshot.Count}");
Console.WriteLine($"Count(): {countThen}");
deferred: 4
ToList: 3
Count(): 3
Üç değişken de kitap eklenmeden önce tanımlandı. inStock hâlâ bir tarif, bu yüzden Count() çağrıldığında yeni kitabı da gördü. snapshot ise ToList() anında doldurulmuş ayrı bir liste; o anın fotoğrafı. countThen de o anda hesaplanmış bir sayı. Bir sorgunun sonucunu belli bir anda dondurmak istiyorsan onu ToList() ya da ToArray() ile materialize edersin (somutlaştırırsın).
Streaming ve Non-Streaming Operatörler
Deferred operatörler de kendi içinde ikiye ayrılır. Where, Select, Take gibi streaming (akan) operatörler her element'i aldıkları anda işleyip yukarı verir. OrderBy, GroupBy, Reverse gibi non-streaming (biriktiren) operatörler ise ilk element'i verebilmek için kaynağın tamamını okumak zorundadır. En ucuz kitabı, bütün kitaplara bakmadan bilemezsin:
bool Log(Book b)
{
Console.WriteLine($" checked {b.Title}");
return b.Stock > 0;
}
Console.WriteLine("Where + Take(1):");
foreach (Book book in books.Where(Log).Take(1))
Console.WriteLine($"-> {book.Title}");
Console.WriteLine("Where + OrderBy + Take(1):");
foreach (Book book in books.Where(Log).OrderBy(b => b.Price).Take(1))
Console.WriteLine($"-> {book.Title}");
Where + Take(1):
checked Clean Code
-> Clean Code
Where + OrderBy + Take(1):
checked Clean Code
checked The Pragmatic Programmer
checked Domain-Driven Design
checked C# in Depth
-> Clean Code
İlk sorguda Take(1) bir element aldıktan sonra durdu; liste yalnızca bir kez kontrol edildi. İkinci sorguda Take(1) yine tek element istiyor, ama aradaki OrderBy ilk MoveNext()'te bütün kaynağı okuyup sıralıyor. Non-streaming operatörler de deferred çalışır, yani sorgu tanımlanınca hiçbir şey yapmazlar; ama dolaşma başladığında bütün kaynağı belleğe alırlar. Bu ayrım, sonsuz bir sequence'ta ya da milyonlarca satırlık bir dosyada sorgunun çalışıp çalışmayacağını belirler.
Aynı Sorguyu İki Kez Çalıştırmak
Tarif benzetmesinin bir bedeli var: her dolaşma tarifi baştan uygular. Sorgu ucuzsa sorun yok, ama içinde pahalı bir hesap, bir dosya okuması ya da bir servis çağrısı varsa durum değişir:
int calls = 0;
IEnumerable<decimal> pricesWithTax = books.Select(b =>
{
calls++;
return b.Price * 1.2m;
});
decimal total = pricesWithTax.Sum();
decimal highest = pricesWithTax.Max();
int lines = pricesWithTax.Count();
Console.WriteLine($"lazy: {calls} selector calls");
calls = 0;
List<decimal> materialized = pricesWithTax.ToList();
total = materialized.Sum();
highest = materialized.Max();
lines = materialized.Count;
Console.WriteLine($"materialized: {calls} selector calls");
lazy: 12 selector calls
materialized: 4 selector calls
Dört kitaplık listede Sum, Max ve Count sorguyu üç kez baştan çalıştırdı ve selector 12 kez çağrıldı. Count()'un bile selector'ü çağırdığına dikkat et: .NET burada selector'ü atlamıyor; yan etkisi olabilecek bir lambda'yı sessizce es geçmek programın davranışını değiştirirdi. Sonucu bir kez ToList() ile materialize ettiğinde selector dört kez çalışıyor, sonraki üç işlem listenin üzerinde yapılıyor. Kural basit: bir sorgunun sonucunu birden fazla kez kullanacaksan bir kez materialize et.
Deferred execution'ın ikinci bedeli hatanın yerini değiştirmesi. Hatalı bir veri, sorgu tanımlandığında değil, dolaşıldığında patlar:
string[] rawStocks = { "12", "7", "x", "3" };
IEnumerable<int> stocks = rawStocks.Select(int.Parse);
Console.WriteLine("Query defined, no exception yet");
try
{
foreach (int stock in stocks)
Console.WriteLine(stock);
}
catch (FormatException ex)
{
Console.WriteLine($"FormatException: {ex.Message}");
}
Query defined, no exception yet
12
7
FormatException: The input string 'x' was not in a correct format.
Sorgu tanımlandı, ilk iki element başarıyla basıldı, exception ancak üçüncü element işlenirken geldi. Gerçek bir projede sorgu bir repository'de kurulup bir controller'da dolaşılabilir; stack trace seni hatalı veriye değil, dolaşmanın yapıldığı satıra götürür. Bu makinede .NET 6 runtime'ı kurulu olmadığından programlar DOTNET_ROLL_FORWARD=Major ile .NET 8 runtime'ında çalıştı; exception mesajlarının metni runtime sürümüne göre değişebilir.
IEnumerable ve IQueryable
Buraya kadar her şey bellekteydi ve sorguyu çalıştıran bizim process'imizdi. EF Core gibi bir veritabanı provider'ı ile çalıştığında aynı Where çağrısı bambaşka bir yola gider. Farkı anlamak için önce aynı lambda'nın iki farklı tipe atanmasına bakalım:
using System.Linq.Expressions;
Func<Book, bool> asDelegate = b => b.Price < 40m;
Expression<Func<Book, bool>> asExpression = b => b.Price < 40m;
Console.WriteLine(asDelegate);
Console.WriteLine(asExpression);
Console.WriteLine(asExpression.Body.NodeType);
IQueryable<Book> source = books.AsQueryable();
IQueryable<Book> cheap = source.Where(b => b.Price < 40m);
Console.WriteLine(cheap.Expression);
Console.WriteLine(cheap.Count());
System.Func`2[Book,System.Boolean]
b => (b.Price < 40)
LessThan
System.Collections.Generic.List`1[Book].Where(b => (b.Price < 40))
1
Func<Book, bool> derlenmiş, çalıştırılmaya hazır bir delegate. Ona içinde ne yazdığını soramazsın; yazdırdığında sadece tip adını görürsün. Expression<Func<Book, bool>> ise aynı lambda'nın bir veri yapısı olarak hâli, yani bir expression tree. Derleyici kodu çalıştırılabilir hâle getirmek yerine "b.Price ile 40'ı LessThan ile karşılaştır" diyen bir object tree'si kurar ve bu tree gezilip yorumlanabilir.
LINQ'in iki ayrı ailesi bu iki tipe göre ayrılır. Enumerable class'ındaki operatörler IEnumerable<T> üzerinde çalışır ve Func alır: kodu bellekte, element element çalıştırırlar. Queryable class'ındaki operatörler IQueryable<T> üzerinde çalışır ve Expression<Func> alır: hiçbir şey çalıştırmaz, sadece expression tree'yi büyütürler. Yukarıdaki cheap.Expression çıktısında bunu görüyorsun: Where çağrısı tree'nin bir parçası olmuş. Sorgu dolaşıldığında tree provider'a verilir. AsQueryable() ile elde ettiğimiz bellek içi provider ağacı derleyip çalıştırdı; EF Core ise aynı ağacı SQL'e çevirip veritabanına gönderir. EF Core serisinin LINQ sorguları yazısında ToQueryString() ile gördüğümüz SQL, bu tree'nin çevirisidir.
Bu ayrım, parametre tipini seçerken gerçek bir hataya dönüşebilir:
IQueryable<Book> source = books.AsQueryable();
IEnumerable<Book> viaEnumerable = CheapEnumerable(source);
IQueryable<Book> viaQueryable = CheapQueryable(source);
Console.WriteLine(viaEnumerable is IQueryable<Book>);
Console.WriteLine(viaQueryable is IQueryable<Book>);
static IEnumerable<Book> CheapEnumerable(IEnumerable<Book> books) =>
books.Where(b => b.Price < 40m);
static IQueryable<Book> CheapQueryable(IQueryable<Book> books) =>
books.Where(b => b.Price < 40m);
False
True
İki method'un gövdesi harfi harfine aynı. Ama CheapEnumerable'ın parametresi IEnumerable<Book> olduğu için derleyici Enumerable.Where'i seçiyor ve sonuç artık bir IQueryable değil. Overload seçimi runtime'daki tipe değil, compile time'daki tipe göre yapılır. EF Core ile bu, filtrenin SQL'e girmemesi demektir: veritabanından tablonun tamamı çekilir ve filtre bellekte uygulanır. Test verisinde fark edilmez, milyon satırlık tabloda production'da fark edilir. Sorguyu bilerek bellekte sürdürmek istiyorsan bunu AsEnumerable() ile açıkça yaz. EF Core serisinde bu tuzağı gerçek SQL çıktısıyla görmüştük.
Sık Düşülen Tuzaklar
Expression tree'ye her lambda'yı verebileceğini sanmak. Expression tree expression'ları temsil eder; süslü parantezli, gövdesinde statement olan bir lambda'yı expression tree'ye çeviremezsin:
using System.Linq.Expressions;
Expression<Func<Book, bool>> cheap = b =>
{
return b.Price < 40m;
};
record Book(string Isbn, string Title, decimal Price, int Stock);
Program.cs(3,38): error CS0834: A lambda expression with a statement body cannot be converted to an expression tree
Derlenen her expression'ın çevrilebileceğini sanmak da ayrı bir tuzak. Kendi yazdığın bir C# method'unu Where içinde çağırırsan kod derlenir, çünkü expression tree o çağrıyı da temsil edebilir. Ama EF Core o method'u SQL'e çeviremez ve sorgu çalıştığında exception atar. IQueryable için yazdığın lambda'da provider'ın tanıdığı şeyleri kullan.
ToList()'i erken çağırmak. dbContext.Books.ToList().Where(...) önce bütün tabloyu belleğe alır, sonra filtreler. Materialize etmek doğru bir araç, ama yeri filtreleme ve projection'dan sonra.
Ömrü biten bir kaynağa deferred sorgu döndürmek. Bir method içinde using ile açtığın bir DbContext ya da dosya üzerinde kurduğun sorguyu materialize etmeden döndürürsen, çağıran taraf dolaşmaya başladığında kaynak çoktan kapanmış olur. Kaynağın ömrü bitmeden ToList() çağır.
Yakalanan değişkeni sonradan değiştirmek. Tahmin sorusundaki maxPrice gibi, sorgu tanımlandıktan sonra değiştirdiğin her değişken sonucu etkiler. Sorgunun o anki değerle çalışmasını istiyorsan değeri yerel bir sabite kopyala ya da sonucu hemen materialize et.
Küçük Bir Deneme
★☆☆ Tahmin sorusundaki programda cheap'i books.Where(b => b.Price < maxPrice).ToList() ile oluştur. Çıktı nasıl değişir, neden?
★★☆ Log'lu Where/Select örneğinde Select'ten sonra .OrderBy(isbn => isbn) ekle. Log satırlarının sırası nasıl değişiyor? Sonra OrderBy'ı Where'den önceye taşı ve tekrar bak.
★★★ IEnumerable<int> döndüren ve sonsuza kadar sipariş numarası üreten bir iterator yaz. Ona Where(n => n % 7 == 0).Take(3) uygula ve programın bittiğini gör. Sonra araya OrderBy(n => n) koy ve neden hiç bitmediğini bu yazıdaki iki operatör türüyle açıkla.
Artık bir sorgunun ne zaman ve nasıl çalıştığını biliyoruz. Bir sonraki yazıda bu bilgiyi en sık kullanılan iki işte kullanacağız: Where ile filtrelemek ve OrderBy, ThenBy ve Reverse ile sıralamak; sıralamanın stability'si ve kültüre bağlı string sıralaması da işin içinde.