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

LINQ Aggregation Operatörleri: Count, Sum, Average, Min, Max, Aggregate ve MinBy/MaxBy

Mert Özen 7 Kas 2026 11 dk 1 görüntülenme
LINQ Aggregation Operatörleri: Count, Sum, Average, Min, Max, Aggregate ve MinBy/MaxBy

Boş sequence'te Sum neden 0, Max neden exception döner ve nullable selector ile DefaultIfEmpty çözümleri. Count ile .NET 6 TryGetNonEnumeratedCount, Average'ın dönüş tipi, Sum'ın checked overflow'u, değer yerine element döndüren MinBy/MaxBy, Aggregate'in üç overload'u ve aynı sorguyu kaç kez enumerate ettiğin.

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

Alışveriş sepetinin özetini gösteren bir ekran yazıyorsun: kaç ürün var, toplam ne, en pahalısı ne kadar, ortalama fiyat ne. Test ederken sepeti boş bıraktın. Bu kod ne yazdırır?

List<decimal> cartPrices = new();

Console.WriteLine($"Count: {cartPrices.Count()}");
Console.WriteLine($"Sum: {cartPrices.Sum()}");
Console.WriteLine($"Max: {cartPrices.Max()}");
Console.WriteLine($"Average: {cartPrices.Average()}");

Boş Sequence: Sum Sıfır, Max Exception

Çıktı (stack trace'in geri kalanını kısalttım; makinede .NET 6 runtime'ı olmadığı için örnekler net6.0 hedefleyip DOTNET_ROLL_FORWARD=Major ile .NET 8 runtime'ında çalıştı, mesaj metinleri runtime sürümüne göre küçük farklar gösterebilir):

Count: 0
Sum: 0
Unhandled exception. System.InvalidOperationException: Sequence contains no elements
   at System.Linq.ThrowHelper.ThrowNoElementsException()
   at System.Linq.Enumerable.Max(IEnumerable`1 source)

Count 0 dedi, Sum 0 dedi, Max ise programı düşürdü. Average'a hiç sıra gelmedi; gelseydi o da aynı exception'ı fırlatırdı. Bu ayrımın bir mantığı var. Boş bir sepetin toplamı gerçekten 0'dır: hiçbir şeyi toplamamak, toplamanın etkisiz elemanını verir. Ama boş bir sepetin en pahalı ürünü yoktur, ortalaması da yoktur; 0 demek yalan söylemek olurdu. decimal null olamadığı için LINQ'in elinde exception'dan başka bir cevap kalmıyor.

Bu yazı LINQ'in aggregation (toplama) operatörlerini anlatıyor: bir sequence'ı tek bir değere indiren Count, Sum, Average, Min, Max, bunların genel hali olan Aggregate ve .NET 6 ile gelen MinBy/MaxBy. Hepsi immediate execution ile çalışır: çağırdığın anda sequence'ı dolaşır ve sonucu döndürür.

Bundan sonraki örnekler partitioning yazısındaki kitapçı verisini kullanıyor. Aşağıdaki dosyayı Data.cs adıyla Program.cs'in yanına koy:

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

static class Store
{
    public static List<Book> Books { get; } = new()
    {
        new("978-0441172719", "Dune", "Frank Herbert", 12.99m, 4, 1965),
        new("978-0553293357", "Foundation", "Isaac Asimov", 9.99m, 0, 1951),
        new("978-0451524935", "1984", "George Orwell", 8.99m, 7, 1949),
        new("978-0261102217", "The Hobbit", "J.R.R. Tolkien", 10.99m, 2, 1937),
        new("978-0547928210", "The Fellowship of the Ring", "J.R.R. Tolkien", 15.99m, 3, 1954),
        new("978-0553382563", "I, Robot", "Isaac Asimov", 7.99m, 5, 1950),
        new("978-0060850524", "Brave New World", "Aldous Huxley", 11.49m, 1, 1932),
        new("978-0451526342", "Animal Farm", "George Orwell", 6.99m, 9, 1945),
        new("978-0441569595", "Neuromancer", "William Gibson", 10.49m, 0, 1984),
        new("978-0441478125", "The Left Hand of Darkness", "Ursula K. Le Guin", 9.49m, 6, 1969),
    };
}

Boş sepet sorununu çözmenin iki yaygın yolu var:

List<Book> noBooks = new();

decimal? maxOrNull = noBooks.Max(b => (decimal?)b.Price);
decimal maxOrZero = noBooks.Select(b => b.Price).DefaultIfEmpty(0m).Max();
decimal? averageOrNull = noBooks.Average(b => (decimal?)b.Price);
decimal? sumOfNullable = noBooks.Sum(b => (decimal?)b.Price);

Console.WriteLine($"Max as decimal?: {maxOrNull?.ToString() ?? "null"}");
Console.WriteLine($"DefaultIfEmpty(0).Max(): {maxOrZero}");
Console.WriteLine($"Average as decimal?: {averageOrNull?.ToString() ?? "null"}");
Console.WriteLine($"Sum as decimal?: {sumOfNullable?.ToString() ?? "null"}");
Max as decimal?: null
DefaultIfEmpty(0).Max(): 0
Average as decimal?: null
Sum as decimal?: 0

Selector'ı nullable bir tipe çevirdiğinde ((decimal?)b.Price) farklı bir overload çalışır. Boş sequence'ta Max ve Average artık exception fırlatmaz, null döndürür; "değer yok" bilgisi tipin içinde taşınır. Dikkat: nullable Sum bile 0 döndürdü, null değil. İkinci yol, DefaultIfEmpty(0m) ile boş sequence'ı tek element'li bir sequence'a çevirmek. Kısa ve okunaklı, ama "sepet boş" ile "en pahalı ürün 0 lira" arasındaki farkı siler. Ekranda "boş sepet" mesajı göstereceksen nullable yolu, raporda sadece bir sayı lazımsa DefaultIfEmpty'yi seç.

Count, LongCount ve TryGetNonEnumeratedCount

List<Book> books = Store.Books;
IEnumerable<Book> cheap = books.Where(b => b.Price < 10m);

Console.WriteLine($"Count(): {books.Count()}, out of stock: {books.Count(b => b.Stock == 0)}, LongCount(): {books.LongCount()}");

Console.WriteLine($"List: {books.TryGetNonEnumeratedCount(out int listCount)} ({listCount})");
Console.WriteLine($"Select over List: {books.Select(b => b.Title).TryGetNonEnumeratedCount(out int selectCount)} ({selectCount})");
Console.WriteLine($"Where: {cheap.TryGetNonEnumeratedCount(out int whereCount)} ({whereCount})");
Count(): 10, out of stock: 2, LongCount(): 10
List: True (10)
Select over List: True (10)
Where: False (0)

Count(), kaynak bir ICollection<T> ise (List<T>, array, HashSet<T>) doğrudan onun Count property'sini okur, element'leri saymaz. Predicate alan Count(b => b.Stock == 0) ise her zaman bütün sequence'ı dolaşır. LongCount aynı işi long döndürerek yapar; 2 milyardan fazla element sayacağın nadir durumlar için var.

.NET 6 ile gelen TryGetNonEnumeratedCount şu soruyu sorar: "Sayıyı, element'leri dolaşmadan öğrenebilir miyim?" Listede cevap evet. Listenin üzerindeki Select'te de evet, çünkü Select element sayısını değiştirmez ve LINQ bunu bilir. Where'de cevap hayır: kaç element'in koşulu tutacağını öğrenmenin tek yolu hepsine bakmak. Bu method, bir sequence'ı işlemeden önce kapasite ayırmak isteyen kodlarda işe yarar; sayı ucuzsa al, değilse zorlama. Bir sequence'ta hiç element olup olmadığını merak ediyorsan da Count() > 0 değil Any() yaz; neden öyle olduğunu serinin Any/All yazısında ölçerek göreceğiz.

Sum ve Average: Dönüş Tipi ve Overflow

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

int totalStock = Store.Books.Sum(b => b.Stock);
decimal stockValue = Store.Books.Sum(b => b.Price * b.Stock);
double averageStock = Store.Books.Average(b => b.Stock); // int'in ortalaması double
decimal averagePrice = Store.Books.Average(b => b.Price);

Console.WriteLine($"Total stock: {totalStock}, stock value: {stockValue}");
Console.WriteLine($"Average stock: {averageStock}, average price: {averagePrice}");

int[] pageViews = { int.MaxValue, 1 };

int loopTotal = 0;
foreach (int v in pageViews)
{
    loopTotal += v; // varsayılan olarak unchecked
}
Console.WriteLine($"foreach: {loopTotal}");

try
{
    Console.WriteLine(pageViews.Sum());
}
catch (OverflowException ex)
{
    Console.WriteLine($"Sum(): {ex.GetType().Name}: {ex.Message}");
}

Console.WriteLine($"Sum as long: {pageViews.Sum(v => (long)v)}");
Total stock: 37, stock value: 356.13
Average stock: 3.7, average price: 10.54
foreach: -2147483648
Sum(): OverflowException: Arithmetic operation resulted in an overflow.
Sum as long: 2147483648

Sum selector'ın tipini korur: stoklar int, stok değeri decimal. Average'ta durum farklı. int bir sequence'ın ortalaması double döner, çünkü 37'yi 10'a bölmenin sonucu 3 değil 3.7'dir; LINQ senin yerine tamsayı bölmesi yapmaz. decimal sequence'ın ortalaması ise decimal kalır, para hesabı için istediğin tam da bu.

Asıl sürpriz overflow'da. Elle yazdığın foreach döngüsü int.MaxValue + 1'i sessizce -2147483648'e çevirdi; C#'ta aritmetik varsayılan olarak unchecked'tır. Sum() ise OverflowException fırlattı, çünkü LINQ'in int ve long toplamları checked çalışır. Bu iyi bir şey: yanlış bir toplamı sessizce rapora basmaktansa patlamak daha iyidir. Büyük sayıları toplayacaksan selector'da long'a çevir, Sum(v => (long)v) gibi.

Min, Max ve MinBy/MaxBy: Değer mi, Element mi?

"En pahalı kitap hangisi?" sorusu aslında iki farklı soru olabilir: en yüksek fiyat kaç, ya da o fiyata sahip kitap hangisi.

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

decimal highestPrice = Store.Books.Max(b => b.Price);
Book sortedFirst = Store.Books.OrderByDescending(b => b.Price).First();
Book? mostExpensive = Store.Books.MaxBy(b => b.Price);
Book? oldest = Store.Books.MinBy(b => b.Year);
Book? firstOutOfStock = Store.Books.MinBy(b => b.Stock); // iki kitabın stoğu 0

Console.WriteLine($"Max: {highestPrice}");
Console.WriteLine($"OrderByDescending().First(): {sortedFirst.Title}");
Console.WriteLine($"MaxBy: {mostExpensive?.Title}, MinBy(Year): {oldest?.Title} ({oldest?.Year})");
Console.WriteLine($"MinBy(Stock): {firstOutOfStock?.Title}");

List<Book> noBooks = new();
Console.WriteLine($"MaxBy on empty List<Book>: {noBooks.MaxBy(b => b.Price)?.Title ?? "null"}");

try
{
    int[] noNumbers = Array.Empty<int>();
    noNumbers.MaxBy(n => n);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"MaxBy on empty int[]: {ex.Message}");
}

try
{
    Book? max = Store.Books.Max();
}
catch (ArgumentException ex)
{
    Console.WriteLine($"Max() on Book: {ex.Message}");
}
Max: 15.99
OrderByDescending().First(): The Fellowship of the Ring
MaxBy: The Fellowship of the Ring, MinBy(Year): Brave New World (1932)
MinBy(Stock): Foundation
MaxBy on empty List<Book>: null
MaxBy on empty int[]: Sequence contains no elements
Max() on Book: At least one object must implement IComparable.

Max(b => b.Price) sana bir decimal verir, kitabın kendisini değil. .NET 6 öncesinde kitabı almak için ya OrderByDescending(...).First() yazılırdı ya da önce maksimum fiyat bulunup sonra o fiyatla tekrar arama yapılırdı. İlki bütün listeyi sıralar (O(n log n)), ikincisi listeyi iki kez dolaşır. MaxBy ve MinBy tek geçişte, sıralama yapmadan element'i döndürür. Eşitlik durumunda ilk geleni verirler: stoğu 0 olan iki kitaptan listede önce gelen Foundation seçildi.

Boş sequence'ta bu ikisi tipine göre davranır. Book gibi bir reference type için null döndürür, bu yüzden dönüş tipi Book?. int gibi bir value type için ise Max gibi exception fırlatır. Son satır da sık görülen bir hatayı gösteriyor: selector vermeden Max() çağırırsan LINQ element'leri birbiriyle karşılaştırmaya çalışır. Book bir record ve değer eşitliği var, ama sıralama bilgisi yok; IComparable implemente etmediği için exception alırsın. Hangi field'a göre karşılaştıracağını her zaman sen söyle.

Aggregate: Kendi Aggregation'ını Yazmak

Sum, Max ve diğerleri aslında aynı fikrin özel halleri: bir başlangıç değerinden yola çık, her element'te bu değeri güncelle, sonunda tek bir sonuç kalsın. Aggregate bu fikrin kendisi. Fonksiyonel programlamada adı fold ya da reduce'tur:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

string titles = Store.Books
    .Take(3)
    .Select(b => b.Title)
    .Aggregate((joined, title) => joined + ", " + title); // seed yok: ilk element başlangıç değeri
Console.WriteLine(titles);

decimal[] discounts = { 0.10m, 0.05m };
decimal finalPrice = discounts.Aggregate(100m, (price, discount) => price * (1 - discount));
Console.WriteLine($"100 after discounts: {finalPrice}");

(decimal Min, decimal Max) range = Store.Books.Aggregate(
    (Min: decimal.MaxValue, Max: decimal.MinValue),
    (acc, b) => (Math.Min(acc.Min, b.Price), Math.Max(acc.Max, b.Price)));
Console.WriteLine($"Price range: {range.Min} - {range.Max}");

string initials = Store.Books.Aggregate(
    "",
    (acc, b) => acc + b.Title[0],
    acc => acc.ToLowerInvariant()); // sonucu son bir kez dönüştür
Console.WriteLine($"Initials: {initials}");

try
{
    new List<string>().Aggregate((a, b) => a + b);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Aggregate without seed on empty: {ex.Message}");
}
Dune, Foundation, 1984
100 after discounts: 85.5000
Price range: 6.99 - 15.99
Initials: df1ttibant
Aggregate without seed on empty: Sequence contains no elements

Üç overload var. Seed'siz olanı ilk element'i başlangıç değeri olarak kullanır; boş sequence'ta başlayacak bir değer olmadığı için exception fırlatır. Seed alanı (100m) boş sequence'ta seed'i olduğu gibi döndürür. Üçüncüsü sonuca son bir dönüşüm uygular. İndirim örneği Aggregate'in neden gerekli olduğunu gösteriyor: art arda uygulanan yüzde 10 ve yüzde 5 indirim yüzde 15 etmez, sonuç 85.50'dir. Çıktıdaki 85.5000 da decimal'ın bir özelliği: çarpmada iki sayının ondalık basamak sayısı (scale) toplanır ve ToString bu basamakları gösterir.

Tuple seed'li örnek en düşük ve en yüksek fiyatı tek geçişte buldu. String birleştirme örneğini ise gerçek kodda string.Join ile yaz; Aggregate ile string toplamak her adımda yeni bir string üretir. Aggregate'e hazır bir operatörün karşılamadığı bir hesap için başvur. Sum yazılabilecek yerde Aggregate yazmak okuyanı yorar.

Kaç Kez Enumerate Ediyorsun?

Aggregation operatörleri immediate çalışır; her biri çağrıldığı anda kaynağı baştan sona dolaşır. Kaynak deferred bir sorguysa her çağrı sorguyu yeniden çalıştırır:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

int reads = 0;
IEnumerable<Book> inStock = Store.Books.Where(b =>
{
    reads++;
    return b.Stock > 0;
});

decimal total = inStock.Sum(b => b.Price);
int count = inStock.Count();
decimal max = inStock.Max(b => b.Price);
Console.WriteLine($"Three aggregates on the query: {reads} reads");

reads = 0;
List<Book> snapshot = inStock.ToList();
total = snapshot.Sum(b => b.Price);
count = snapshot.Count;
max = snapshot.Max(b => b.Price);
Console.WriteLine($"ToList, then three aggregates: {reads} reads");

reads = 0;
(int Count, decimal Total, decimal Max) stats = inStock.Aggregate(
    (Count: 0, Total: 0m, Max: 0m),
    (acc, b) => (acc.Count + 1, acc.Total + b.Price, Math.Max(acc.Max, b.Price)));
Console.WriteLine($"One Aggregate: {reads} reads -> {stats}");
Three aggregates on the query: 30 reads
ToList, then three aggregates: 10 reads
One Aggregate: 10 reads -> (8, 84.92, 15.99)

Aynı Where sorgusu üzerinde üç aggregation, koşulu 30 kez çalıştırdı: her operatör on kitabı ayrı ayrı filtreledi. Bellekteki on kitap için bu önemsiz. Ama sorgu bir dosyayı okuyorsa, bir veritabanına gidiyorsa ya da pahalı bir hesap yapıyorsa, aynı işi üç kez ödemiş olursun. Sorguyu bir kez ToList() ile materialize etmek bunu 10'a indirdi. Ayrıca snapshot.Count artık hiç dolaşma yapmadan listenin property'sini okuyor. Tek bir Aggregate ile üç değeri birden hesaplamak da tek geçişte bitiyor; çok büyük ve streaming bir kaynakta (milyonlarca satırlık bir dosya gibi) listeye almadan özet çıkarmanın yolu bu. Çoklu enumeration'ı ve ne zaman materialize etmen gerektiğini serinin performans yazısında BenchmarkDotNet ile ölçeceğiz.

Sık Düşülen Tuzaklar

Boş olabilecek bir sequence'ta Max, Min ya da Average çağırmak. Filtreden hiçbir şey geçmediği gün InvalidOperationException alırsın. Nullable selector ya da DefaultIfEmpty kullan, veya önce Any() ile kontrol et.

int ortalamanın int döneceğini sanmak. Average double döndürür; sonucu int'e cast edersen ondalığı kendi elinle atmış olursun.

Büyük int toplamlarında overflow'u unutmak. Sum checked çalışır ve OverflowException fırlatır; sayıların büyüyebileceği yerde selector'da long'a çevir.

Element'i bulmak için sıralamak. OrderByDescending(x => x.Price).First() bütün listeyi sıralar; .NET 6'dan beri MaxBy aynı işi tek geçişte yapar.

Selector'süz Max()'ı kendi tiplerinde çağırmak. Tip IComparable değilse exception alırsın; hangi field ile karşılaştıracağını açıkça ver.

Aynı deferred sorgu üzerinde art arda Count, Sum, Max çağırmak. Her biri sorguyu baştan çalıştırır. Önce materialize et ya da tek bir Aggregate ile hesapla.

Küçük Bir Deneme

★☆☆ Stokta olmayan kitapların ortalama fiyatını bul. Sonra koşulu hiçbir kitabın tutmadığı bir değerle (b.Stock > 100) değiştir ve kodun patlamaması için iki farklı çözüm yaz.

★★☆ MinBy ve MaxBy kullanmadan, sadece Aggregate ile en eski ve en yeni kitabı (başlıklarıyla birlikte) tek geçişte bul.

★★★ Her kitabın stok değerini (Price * Stock) hesapla ve toplam stok değerinin yüzde kaçını hangi kitabın oluşturduğunu yazdır. Kaynağı kaç kez dolaştığını bir sayaçla ölç ve sayıyı en aza indir.

Aggregation operatörleri bütün sequence'ı tek bir sayıya indiriyordu. Çoğu zaman ise "her yazar için", "her tür için" diye sorarız. Bir sonraki yazıda sequence'ı key'e göre parçalayan GroupBy ve ToLookup'a geçiyoruz.