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

LINQ ve .NET 9: CountBy, AggregateBy ve Index

Mert Özen 7 Kas 2026 11 dk 2 görüntülenme
LINQ ve .NET 9: CountBy, AggregateBy ve Index

.NET 9'da LINQ: dictionary değil sequence döndüren CountBy, key bazında biriktiren AggregateBy ve GroupBy'a göre bellek farkının BenchmarkDotNet ölçümü, Select((x, i) => ...) yerine Index(), Queryable karşılıkları ve mutable seed tuzağı.

Sürüm notu: Bu yazı .NET 9 ile gelen LINQ yeniliklerini anlatır; kodlar C# 13 / .NET 9 hedefiyle .NET 10 SDK'da derlenip denenmiştir.

Önce Tahmin Et

.NET 9'un en çok beklenen LINQ eklemesi CountBy: bir key'e göre gruplayıp saymayı tek adımda yapıyor. Kitaplarımızı kategoriye göre sayalım ve Architecture kategorisinde kaç kitap olduğunu yazdıralım. Sence bu kod derlenir mi? Derlenirse ne yazar?

List<Book> books =
[
    new("978-0132350884", "Clean Code", "Craftsmanship", 37.90m, 12),
    new("978-0134757599", "Refactoring", "Craftsmanship", 47.00m, 0),
    new("978-0321125217", "Domain-Driven Design", "Architecture", 54.00m, 3),
    new("978-0201633610", "Design Patterns", "Architecture", 49.90m, 7),
    new("978-0135957059", "The Pragmatic Programmer", "Craftsmanship", 42.50m, 5),
];

var counts = books.CountBy(b => b.Category);
Console.WriteLine(counts["Architecture"]);

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

CountBy Ne Döndürür

Derlenmez:

Program.cs(11,19): error CS0021: Cannot apply indexing with [] to an expression of type 'IEnumerable<KeyValuePair<string, int>>'

İsmi ve yaptığı iş bir dictionary çağrıştırıyor ama CountBy bir Dictionary döndürmüyor; IEnumerable<KeyValuePair<string, int>> döndürüyor. Bunun iki sonucu var. Birincisi, indexer yok; belirli bir key'in sayısına bakmak istiyorsan önce dictionary'ye çevirmen gerekiyor. İkincisi, bu bir sequence ve diğer LINQ operatörleri gibi deferred execution ile çalışıyor: counts değişkeni sayımın sonucu değil, sayımın tarifi. Her enumerate edişinde kaynak baştan sayılır.

Dictionary'ye çevirmenin en kısa yolu bir önceki yazıda gördüğümüz, .NET 8'in selector'süz ToDictionary()'si. İki sürümün eklemeleri böylece birbirini tamamlıyor.

.NET 8 ve Öncesinde Saymak

Aynı sayımı .NET 6'dan beri GroupBy ile yapıyoruz. İki yolu yan yana koyalım:

List<Book> books =
[
    new("978-0132350884", "Clean Code", "Craftsmanship", 37.90m, 12),
    new("978-0134757599", "Refactoring", "Craftsmanship", 47.00m, 0),
    new("978-0321125217", "Domain-Driven Design", "Architecture", 54.00m, 3),
    new("978-0201633610", "Design Patterns", "Architecture", 49.90m, 7),
    new("978-0135957059", "The Pragmatic Programmer", "Craftsmanship", 42.50m, 5),
];

// .NET 8 ve öncesi: önce gruplar, sonra sayarız
foreach (IGrouping<string, Book> group in books.GroupBy(b => b.Category))
    Console.WriteLine($"{group.Key}: {group.Count()}");

// .NET 9: doğrudan sayım
foreach ((string category, int count) in books.CountBy(b => b.Category))
    Console.WriteLine($"{category}: {count}");

Dictionary<string, int> byCategory = books.CountBy(b => b.Category).ToDictionary();
Console.WriteLine(byCategory["Architecture"]);

string[] tags = ["linq", "LINQ", "async", "Linq"];
foreach (KeyValuePair<string, int> pair in tags.CountBy(t => t, StringComparer.OrdinalIgnoreCase))
    Console.WriteLine(pair);

public record Book(string Isbn, string Title, string Category, decimal Price, int Stock);
Craftsmanship: 3
Architecture: 2
Craftsmanship: 3
Architecture: 2
2
[linq, 3]
[async, 1]

Sonuç aynı ama arkada olan farklı. GroupBy, her kategori için bir grup oluşturur ve kategoriye ait her kitabı o grubun içine koyar. Elinde kategori başına bir liste vardır ve sen o listenin sadece uzunluğuyla ilgilenirsin. CountBy ise kitapları hiçbir yerde biriktirmez; key başına tek bir sayaç tutar. Sonuçlar KeyValuePair olduğu için foreach'te doğrudan (string category, int count) diye parçalayabiliyorsun.

Son örnek comparer alan overload. Etiketleri büyük-küçük harf ayrımı yapmadan saydık; linq, LINQ ve Linq aynı key'e düştü. Sonuçta key olarak ilk görülen yazım, yani linq kaldı. KeyValuePair'in kendi ToString()'i de [linq, 3] biçimini üretiyor.

AggregateBy

Saymak, key bazında biriktirmenin özel bir hâli. Genel hâli AggregateBy: her key için bir başlangıç değeri ve her element'te o değeri güncelleyen bir fonksiyon verirsin. Müşteri başına ciro için:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

List<Order> orders =
[
    new(1001, 1, 122.80m),
    new(1002, 2, 54.00m),
    new(1003, 3, 80.40m),
    new(1004, 1, 49.90m),
    new(1005, 3, 103.90m),
];

// .NET 8 ve öncesi
foreach (IGrouping<int, Order> group in orders.GroupBy(o => o.CustomerId))
    Console.WriteLine($"Customer {group.Key}: {group.Sum(o => o.Total)}");

// .NET 9
IEnumerable<KeyValuePair<int, decimal>> revenue = orders.AggregateBy(
    o => o.CustomerId,
    seed: 0m,
    (total, order) => total + order.Total);

foreach ((int customerId, decimal total) in revenue)
    Console.WriteLine($"Customer {customerId}: {total}");

// Accumulator bir tuple: sipariş sayısı ve en büyük sipariş
var stats = orders.AggregateBy(
    o => o.CustomerId,
    seed: (Count: 0, Largest: 0m),
    (acc, order) => (acc.Count + 1, Math.Max(acc.Largest, order.Total)));

foreach (KeyValuePair<int, (int Count, decimal Largest)> pair in stats)
    Console.WriteLine($"Customer {pair.Key}: count {pair.Value.Count}, largest {pair.Value.Largest}");

public record Order(int Id, int CustomerId, decimal Total);
Customer 1: 172.70
Customer 2: 54.00
Customer 3: 184.30
Customer 1: 172.70
Customer 2: 54.00
Customer 3: 184.30
Customer 1: count 2, largest 122.80
Customer 2: count 1, largest 54.00
Customer 3: count 2, largest 103.90

AggregateBy'ı, LINQ'in aggregation yazısında gördüğümüz Aggregate'in key başına çalışan sürümü gibi düşün. seed, her yeni key için accumulator'ın başlangıç değeri; üçüncü parametre de mevcut accumulator ile sıradaki element'ten yenisini üreten fonksiyon. Accumulator tek bir sayı olmak zorunda değil. Son örnekte bir tuple ile tek geçişte hem sipariş sayısını hem de en büyük siparişi tuttuk; GroupBy ile bunu ya iki ayrı aggregation ile ya da grubun içinde iki geçişle yapardın.

Ölçüm: Gruplamadan Toplamak

.NET 9'un sürüm notları bu iki operatörü, ara gruplar oluşturmadan key bazında aggregation yapmanın yolu olarak tanıtıyor. 100.000 siparişi 1.000 müşteriye göre sayıp toplayarak ölçtük:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run([typeof(CountBenchmarks), typeof(SumBenchmarks)]);

public record Order(int Id, int CustomerId, decimal Total);

public static class TestData
{
    public static List<Order> Orders()
    {
        var random = new Random(42);
        return Enumerable.Range(1, 100_000)
            .Select(i => new Order(i, random.Next(1, 1_001), random.Next(10, 200)))
            .ToList();
    }
}

[MemoryDiagnoser]
public class CountBenchmarks
{
    private List<Order> _orders = null!;

    [GlobalSetup]
    public void Setup() => _orders = TestData.Orders();

    [Benchmark(Baseline = true)]
    public Dictionary<int, int> GroupByCount() =>
        _orders.GroupBy(o => o.CustomerId).ToDictionary(g => g.Key, g => g.Count());

    [Benchmark]
    public Dictionary<int, int> CountBy() =>
        _orders.CountBy(o => o.CustomerId).ToDictionary();
}

[MemoryDiagnoser]
public class SumBenchmarks
{
    private List<Order> _orders = null!;

    [GlobalSetup]
    public void Setup() => _orders = TestData.Orders();

    [Benchmark(Baseline = true)]
    public Dictionary<int, decimal> GroupBySum() =>
        _orders.GroupBy(o => o.CustomerId).ToDictionary(g => g.Key, g => g.Sum(o => o.Total));

    [Benchmark]
    public Dictionary<int, decimal> AggregateBy() =>
        _orders.AggregateBy(o => o.CustomerId, 0m, (total, o) => total + o.Total).ToDictionary();
}
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 10.0.13 (10.0.13, 10.0.1326.50504), Arm64 RyuJIT armv8.0-a
  DefaultJob : .NET 10.0.13 (10.0.13, 10.0.1326.50504), Arm64 RyuJIT armv8.0-a

| Method       | Mean     | Error   | StdDev  | Ratio | Gen0     | Gen1     | Allocated  | Alloc Ratio |
|------------- |---------:|--------:|--------:|------:|---------:|---------:|-----------:|------------:|
| GroupByCount | 963.0 us | 3.36 us | 2.98 us |  1.00 | 283.2031 | 140.6250 | 2326.09 KB |        1.00 |
| CountBy      | 720.7 us | 5.69 us | 5.32 us |  0.75 |  16.6016 |   3.9063 |  143.06 KB |        0.06 |

| Method      | Mean       | Error   | StdDev  | Ratio | Gen0     | Gen1     | Allocated  | Alloc Ratio |
|------------ |-----------:|--------:|--------:|------:|---------:|---------:|-----------:|------------:|
| GroupBySum  | 1,349.6 us | 8.30 us | 6.48 us |  1.00 | 294.9219 | 136.7188 | 2414.08 KB |        1.00 |
| AggregateBy |   551.1 us | 7.89 us | 7.38 us |  0.41 |  31.2500 |   9.7656 |  256.61 KB |        0.11 |

Proje net9.0 hedefli ve Release modda derlendi. Makinede .NET 9 runtime'ı olmadığı için BenchmarkDotNet roll-forward ile .NET 10.0.13'te koştu; yani sayılar .NET 10'un implementasyonunu ölçüyor. Ölçüm sırasında makinede başka build'ler de çalışıyordu. Hata payları küçük çıktı ama yine de yüzde birkaçlık farkları yorumlamaya çalışma.

Asıl fark bellekte. GroupBy ile saymak 2,3 MB ayırdı, CountBy 143 KB; yaklaşık 16 kat az. Süre farkı sayımda daha mütevazı, yüzde 25 civarı. Aggregation'da AggregateBy, GroupBy'ın ayırdığının dokuzda birini ayırdı ve işi yarıdan kısa sürede bitirdi (551 µs'ye karşı 1.350 µs). GroupBy 100.000 siparişin her birini bir gruba kopyalıyor ve bu grupların büyüyen iç array'leri garbage collector'a iş çıkarıyor; Gen0 ve Gen1 sütunlarındaki sayılar bunu gösteriyor. Grubun içindeki element'lere gerçekten ihtiyacın yoksa, yani sadece sayıyor ya da topluyorsan, CountBy ve AggregateBy hem daha kısa hem daha ucuz.

Index()

Bir listeyi sıra numarasıyla yazdırmak için yıllarca Select'in index alan overload'unu kullandık. .NET 9 bunun için ayrı bir operatör getirdi:

List<Book> books =
[
    new("978-0132350884", "Clean Code", "Craftsmanship", 37.90m, 12),
    new("978-0134757599", "Refactoring", "Craftsmanship", 47.00m, 0),
    new("978-0321125217", "Domain-Driven Design", "Architecture", 54.00m, 3),
    new("978-0201633610", "Design Patterns", "Architecture", 49.90m, 7),
    new("978-0135957059", "The Pragmatic Programmer", "Craftsmanship", 42.50m, 5),
];

foreach ((int index, Book book) in books.Index())
    Console.WriteLine($"{index + 1}. {book.Title}");

// .NET 8 ve öncesindeki karşılığı
IEnumerable<(int Index, Book Item)> before = books.Select((book, index) => (index, book));

(int Index, Book Item) first = books.Index().First();
Console.WriteLine($"{first.Index}: {first.Item.Title}");

Console.WriteLine(string.Join(", ", books.Where(b => b.Stock > 0).Index().Select(x => x.Index)));
Console.WriteLine(string.Join(", ", books.Index().Where(x => x.Item.Stock > 0).Select(x => x.Index)));

public record Book(string Isbn, string Title, string Category, decimal Price, int Stock);
1. Clean Code
2. Refactoring
3. Domain-Driven Design
4. Design Patterns
5. The Pragmatic Programmer
0: Clean Code
0, 1, 2, 3
0, 2, 3, 4

Index(), her element'i (int Index, T Item) adlı bir tuple'a sarıyor. Tuple'ın alan adları belli olduğu için first.Index ve first.Item diye okunabiliyor, foreach'te de doğrudan parçalanabiliyor. Index 0'dan başlıyor; kullanıcıya göstereceğin sıra numarası için 1 eklemeyi unutma.

Son iki satır önemli bir ayrıntıyı gösteriyor: index, Index()'in zincirde durduğu yerdeki sırayı verir. Önce filtreleyip sonra Index() çağırırsan stoktaki kitaplar 0'dan yeniden numaralanır. Önce Index() çağırıp sonra filtrelersen, kitapların orijinal listedeki yerini korursun; stoğu sıfır olan Refactoring'in 1 numarası boşta kalır. Hangisini istediğine göre Index()'in yerini seç.

Yükseltirken Bilmen Gerekenler

Üç operatör de library API'si ve net9.0 hedefi gerektiriyor. Aynı dosyayı net8.0 hedefiyle derlediğimde her CountBy çağrısı için şu hatayı aldım:

Program.cs(17,48): error CS1061: 'List<Book>' does not contain a definition for 'CountBy' and no accessible extension method 'CountBy' accepting a first argument of type 'List<Book>' could be found (are you missing a using directive or an assembly reference?)

Operatörlerin Queryable karşılıkları da .NET 9'da geldi; IQueryable üzerinde çağırdığında sorgu expression tree'ye ekleniyor:

List<Book> books =
[
    new("978-0132350884", "Clean Code", "Craftsmanship", 37.90m, 12),
    new("978-0134757599", "Refactoring", "Craftsmanship", 47.00m, 0),
    new("978-0321125217", "Domain-Driven Design", "Architecture", 54.00m, 3),
    new("978-0201633610", "Design Patterns", "Architecture", 49.90m, 7),
    new("978-0135957059", "The Pragmatic Programmer", "Craftsmanship", 42.50m, 5),
];

IQueryable<Book> query = books.AsQueryable();

IQueryable<KeyValuePair<string, int>> counts = query.CountBy(b => b.Category);
Console.WriteLine(counts.Expression);

IQueryable<(int Index, Book Item)> indexed = query.Index();
Console.WriteLine(indexed.Expression);

public record Book(string Isbn, string Title, string Category, decimal Price, int Stock);
System.Collections.Generic.List`1[Book].CountBy(b => b.Category, null)
System.Collections.Generic.List`1[Book].Index()

Burada yalnızca API'nin varlığını gösteriyoruz. Bir veritabanı provider'ının bu çağrıları SQL'e çevirip çeviremediği ayrı bir konu; EF Core'da kullanmadan önce kendi sürümünde dene ve üretilen SQL'e bak.

Destek tarafında .NET 9 bir STS sürümü ve desteği .NET 8 ile aynı gün, 10 Kasım 2026'da bitiyor. Bu yazıdaki her şey .NET 10'da aynen çalışıyor; yükseltiyorsan .NET 9'da durma, doğrudan .NET 10'a geç.

Sık Düşülen Tuzaklar

AggregateBy'a mutable bir seed vermek. Kitap başlıklarını kategori başına bir listede toplamak istediğini düşün:

List<Book> books =
[
    new("978-0132350884", "Clean Code", "Craftsmanship", 37.90m, 12),
    new("978-0134757599", "Refactoring", "Craftsmanship", 47.00m, 0),
    new("978-0321125217", "Domain-Driven Design", "Architecture", 54.00m, 3),
    new("978-0201633610", "Design Patterns", "Architecture", 49.90m, 7),
    new("978-0135957059", "The Pragmatic Programmer", "Craftsmanship", 42.50m, 5),
];

var shared = books.AggregateBy(
    b => b.Category,
    seed: new List<string>(),
    (titles, book) =>
    {
        titles.Add(book.Title);
        return titles;
    });

foreach ((string category, List<string> titles) in shared)
    Console.WriteLine($"{category}: {titles.Count} titles");

var separate = books.AggregateBy(
    b => b.Category,
    seedSelector: _ => new List<string>(),
    (titles, book) =>
    {
        titles.Add(book.Title);
        return titles;
    });

foreach ((string category, List<string> titles) in separate)
    Console.WriteLine($"{category}: {titles.Count} titles");

public record Book(string Isbn, string Title, string Category, decimal Price, int Stock);
Craftsmanship: 5 titles
Architecture: 5 titles
Craftsmanship: 3 titles
Architecture: 2 titles

İlk denemede iki kategoride de beş başlık var. seed: new List<string>() bir kez değerlendirilir ve aynı liste instance'ı her yeni key için başlangıç değeri olarak kullanılır; bütün kategoriler aynı listeye yazmış olur. Seed bir value type ise, sayı ya da tuple gibi, bu sorun çıkmaz, çünkü her key kendi kopyasını alır. Reference type bir accumulator kullanacaksan seedSelector overload'unu kullan: _ => new List<string>() her key için ayrı bir liste oluşturur. Bu özel örnekte asıl doğru araç zaten GroupBy, çünkü element'lerin kendisine ihtiyacın var.

CountBy sonucunu sıralı sanmak. Bu yazıdaki örneklerde sonuçlar key'lerin ilk görüldüğü sırayla geldi. Bir rapor bu sıraya güveniyorsa, sırayı OrderByDescending(p => p.Value) gibi açık bir sıralamayla kendin belirle.

CountBy sonucunu tekrar tekrar enumerate etmek. Sonuç deferred olduğu için her foreach kaynağı baştan sayar. Aynı sayımı birden fazla yerde kullanacaksan bir kez ToDictionary() ile materialize et.

Küçük Bir Deneme

★☆☆ Gerçek veri yazısındaki siparişlerde her kitabın kaç farklı siparişte geçtiğini SelectMany ve CountBy ile bul.

★★☆ AggregateBy ile kategori başına en ucuz ve en pahalı kitabın fiyatını tek geçişte hesapla. Seed olarak (Min: decimal.MaxValue, Max: decimal.MinValue) kullanmak neden güvenli?

★★★ Ölçümdeki CountBenchmarks'a müşteri sayısını parametre yapan bir [Params(10, 1_000, 50_000)] ekle. Key sayısı arttıkça CountBy'ın bellek avantajı nasıl değişiyor? Sebebini tahmin et, sonra ölç.

Bir sonraki yazıda .NET 10'a geçiyoruz: join yazısındaki GroupJoin pattern'ının yerini alan LeftJoin ve RightJoin, Shuffle, generic math üzerine kurulu Sequence ve sonunda BCL'e giren async LINQ.