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

LINQ Gruplama: GroupBy, IGrouping ve ToLookup

Mert Özen 7 Kas 2026 10 dk 1 görüntülenme
LINQ Gruplama: GroupBy, IGrouping ve ToLookup

GroupBy'ın deferred, ToLookup'ın immediate çalışması ve bunun sonuca etkisi. IGrouping'in key ve element'leri, dokümante edilmiş grup sırası, gruplayıp özetlemek, element ve result selector overload'ları, query syntax'ta group by into, composite key'ler, ToLookup'ın olmayan key davranışı, comparer ile gruplama ve GroupBy'ın kaynağı buffer'laması.

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

Kitapçı verimize bu yazıda bir alan ekliyoruz: kitabın türü. 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, string Genre);

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

Kitapları türe göre iki farklı yolla grupluyoruz: biri GroupBy, diğeri ToLookup. Sonra listeye yeni bir bilim kurgu kitabı ekliyor ve iki taraftaki bilim kurgu sayısını soruyoruz. Bu kod ne yazdırır?

List<Book> books = new(Store.Books);

IEnumerable<IGrouping<string, Book>> byGenre = books.GroupBy(b => b.Genre);
ILookup<string, Book> lookup = books.ToLookup(b => b.Genre);

books.Add(new Book("978-0345391803", "The Hitchhiker's Guide to the Galaxy", "Douglas Adams", 9.99m, 3, 1979, "Science Fiction"));

Console.WriteLine($"GroupBy: {byGenre.First(g => g.Key == "Science Fiction").Count()}");
Console.WriteLine($"ToLookup: {lookup["Science Fiction"].Count()}");

GroupBy Deferred, ToLookup Hemen

Çıktı:

GroupBy: 6
ToLookup: 5

İki satır aynı gruplamayı tarif ediyor ama farklı anlarda çalışıyor. GroupBy deferred execution kullanır: byGenre değişkeninde gruplar yok, sadece "bu listeyi türe göre grupla" talimatı var. Talimat First çağrıldığında çalıştı ve o anda listede altı bilim kurgu kitabı vardı. ToLookup ise immediate çalışır: çağrıldığı satırda listeyi dolaşır, grupları kurar ve bitirir. Sonradan listeye eklenen kitabı görmez; onun için liste o anki haliyle dondurulmuştur.

Bir okulu düşün. GroupBy, müdürün "yarın sabah öğrencileri doğum ayına göre gruplara ayırın" diye bıraktığı nottur; yarın okulda kim varsa ona göre gruplanır. ToLookup ise bugün çekilmiş ve panoya asılmış bir fotoğraftır. İkisi de işe yarar; hangisini istediğini bilmen yeter. Bu yazıda önce GroupBy'ın döndürdüğü yapıyı, sonra gruplarla neler yapabileceğini, en son da ToLookup'ı ne zaman tercih edeceğini göreceğiz.

IGrouping: Key ve Element'ler

foreach (IGrouping<string, Book> group in Store.Books.GroupBy(b => b.Genre))
{
    Console.WriteLine($"{group.Key} ({group.Count()}): {string.Join(" | ", group.Select(b => b.Title))}");
}
Science Fiction (5): Dune | Foundation | I, Robot | Neuromancer | The Left Hand of Darkness
Dystopia (2): 1984 | Brave New World
Fantasy (2): The Hobbit | The Fellowship of the Ring
Satire (1): Animal Farm

GroupBy bir IEnumerable<IGrouping<TKey, TElement>> döndürür. Her IGrouping hem bir Key property'si taşır hem de kendisi bir IEnumerable<TElement>'dir. Yani bir grubun üzerinde Count, Select, Sum, kısacası bütün LINQ operatörlerini çalıştırabilirsin.

Sıraya dikkat et. Dokümantasyon burada bir garanti veriyor: gruplar, her key'i ilk üreten element'in kaynaktaki sırasına göre gelir; grubun içindeki element'ler de kaynaktaki sıralarını korur. Listenin ilk kitabı Dune bilim kurgu olduğu için o grup ilk sırada, Animal Farm en son yeni bir tür getirdiği için Satire sonda. Grupları alfabetik ya da büyüklüğe göre istiyorsan bunu OrderBy ile açıkça söylemen gerekir.

Her Gruptan Tek Satır

Gruplamanın asıl gücü, her grubu tek bir satıra indirmekte. SQL'deki GROUP BY ile aggregate fonksiyonlarının birlikteliği gibi:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var authors = Store.Books
    .GroupBy(b => b.Author)
    .Select(g => new
    {
        Author = g.Key,
        Books = g.Count(),
        Stock = g.Sum(b => b.Stock),
        Cheapest = g.MinBy(b => b.Price)!.Title, // grup asla boş değildir
    })
    .Where(x => x.Books > 1)
    .OrderByDescending(x => x.Stock);

foreach (var a in authors)
{
    Console.WriteLine($"{a.Author}: {a.Books} books, stock {a.Stock}, cheapest: {a.Cheapest}");
}
George Orwell: 2 books, stock 16, cheapest: Animal Farm
Isaac Asimov: 2 books, stock 5, cheapest: I, Robot
J.R.R. Tolkien: 2 books, stock 5, cheapest: The Hobbit

Pattern hep aynı: GroupBy ile böl, Select ile her gruptan bir özet üret, gerekirse Where ile filtrele ve sırala. Where(x => x.Books > 1) burada SQL'deki HAVING'in karşılığı. MinBy'dan sonraki !, nullable uyarısını susturmak için; bir önceki yazıda MinBy'ın boş sequence'ta null döndürdüğünü görmüştük, ama GroupBy'dan çıkan bir grup en az bir element içerir. Anonymous type kullandığımız için burada var zorunlu; tipin adı yok.

GroupBy'ın iki kullanışlı overload'u daha var. Biri grubun içine element'in tamamını değil bir parçasını koyar, diğeri her grubu doğrudan bir sonuca çevirir. Aynı işi query syntax ile de yazabilirsin:

IEnumerable<IGrouping<string, string>> titlesByGenre = Store.Books.GroupBy(b => b.Genre, b => b.Title);
Console.WriteLine(string.Join(" | ", titlesByGenre.Select(g => $"{g.Key}: {g.First()}")));

IEnumerable<string> stockByGenre = Store.Books.GroupBy(
    b => b.Genre,
    (genre, books) => $"{genre}: {books.Sum(b => b.Stock)}");
Console.WriteLine(string.Join(" | ", stockByGenre));

IEnumerable<string> report =
    from b in Store.Books
    group b.Title by b.Genre into g
    orderby g.Count() descending, g.Key
    select $"{g.Key}: {g.Count()}";
Console.WriteLine(string.Join(" | ", report));
Science Fiction: Dune | Dystopia: 1984 | Fantasy: The Hobbit | Satire: Animal Farm
Science Fiction: 15 | Dystopia: 8 | Fantasy: 5 | Satire: 9
Science Fiction: 5 | Dystopia: 2 | Fantasy: 2 | Satire: 1

İlk satırda element selector (b => b.Title) grupların içine sadece başlıkları koydu; grubun tipi artık IGrouping<string, string>. İkinci satırda result selector, key'i ve gruptaki kitapları alıp tek bir string üretti. Query syntax'ta group ... by ... into g, gruba bir isim verip sorguya devam etmeni sağlar. into olmadan group cümlesi sorguyu bitirir. Dikkat edersen query syntax'ta element ifadesi (b.Title) key'den (b.Genre) önce yazılıyor; method syntax'taki argüman sırasının tersi.

Composite Key

Bazen tek bir field yetmez: "her yazarın her on yılı" gibi. Key olarak birden fazla alan taşıyan bir değer verirsin:

var byAuthorAndDecade = Store.Books.GroupBy(b => new { b.Author, Decade = b.Year / 10 * 10 });

foreach (var g in byAuthorAndDecade.Where(g => g.Count() > 1))
{
    Console.WriteLine($"{g.Key.Author}, {g.Key.Decade}s: {string.Join(" | ", g.Select(b => b.Title))}");
}

int tupleGroups = Store.Books.GroupBy(b => (b.Author, b.Year / 10 * 10)).Count();
int classGroups = Store.Books.GroupBy(b => new DecadeKey(b.Author, b.Year / 10 * 10)).Count();
Console.WriteLine($"Anonymous type: {byAuthorAndDecade.Count()}, tuple: {tupleGroups}, class: {classGroups}");

class DecadeKey
{
    public DecadeKey(string author, int decade) => (Author, Decade) = (author, decade);

    public string Author { get; }
    public int Decade { get; }
}
Isaac Asimov, 1950s: Foundation | I, Robot
George Orwell, 1940s: 1984 | Animal Farm
Anonymous type: 8, tuple: 8, class: 10

Anonymous type ve tuple key'ler sorunsuz çalıştı: Asimov'un iki kitabı 1950'lerde, Orwell'in iki kitabı 1940'larda bir araya geldi, toplam 8 grup oluştu. İkisi de değer eşitliğiyle karşılaştırılır; aynı alan değerlerine sahip iki anonymous object eşit sayılır. Kendi yazdığın DecadeKey class'ı ise Equals ve GetHashCode'u override etmediği için reference eşitliğine düştü ve her kitap kendi grubunda kaldı: 10 grup. Koleksiyonlar ve Generics serisinin Dictionary yazısında gördüğümüz kural burada da geçerli; GroupBy içeride key'leri hash'leyerek eşleştirir. Key için bir tip yazacaksan record kullan.

ToLookup: Okumaya Hazır Bir Index

Aynı gruplara defalarca erişeceksen GroupBy'ı her seferinde yeniden çalıştırmak yerine bir kez ToLookup ile kurmak daha mantıklıdır:

ILookup<string, Book> byAuthor = Store.Books.ToLookup(b => b.Author);

Console.WriteLine($"Tolkien: {string.Join(", ", byAuthor["J.R.R. Tolkien"].Select(b => b.Title))}");
Console.WriteLine($"Stephen King: {byAuthor["Stephen King"].Count()} books");
Console.WriteLine($"Contains Asimov: {byAuthor.Contains("Isaac Asimov")}, authors: {byAuthor.Count}");

string?[] tags = { "new", null, "sale", null };
Console.WriteLine($"null key: {tags.ToLookup(t => t)[null].Count()}");

Dictionary<string, List<Book>> dictionary = Store.Books
    .GroupBy(b => b.Author)
    .ToDictionary(g => g.Key, g => g.ToList());

try
{
    List<Book> kingBooks = dictionary["Stephen King"];
}
catch (KeyNotFoundException ex)
{
    Console.WriteLine(ex.Message);
}
Tolkien: The Hobbit, The Fellowship of the Ring
Stephen King: 0 books
Contains Asimov: True, authors: 7
null key: 2
The given key 'Stephen King' was not present in the dictionary.

ILookup<TKey, TElement> bir dictionary'ye benzer ama birkaç önemli farkı var. Indexer'ı olmayan bir key için exception fırlatmaz, boş bir sequence döndürür: "Stephen King" için 0 kitap. Aynı soruyu Dictionary<string, List<Book>>'ye sorduğunda KeyNotFoundException aldık. null key'i de kabul eder; etiketlerdeki iki null kendi grubunda toplandı. Son olarak lookup değiştirilemez: Add ya da Remove method'u yoktur.

Pratikte şöyle seçebilirsin: bir kez gruplayıp sonuçları dolaşacaksan GroupBy. Gruplara key ile tekrar tekrar erişeceksen, örneğin bir döngü içinde her müşteri için siparişlerini bulacaksan, ToLookup. Sonradan element ekleyip çıkaracağın değişken bir yapı lazımsa Dictionary<TKey, List<T>>.

Comparer ile Gruplama

Elle girilmiş verilerde aynı tür farklı yazılmış olabilir. Varsayılan string eşitliği büyük-küçük harfe duyarlıdır:

string[] genres = { "Science Fiction", "science fiction", "Fantasy", "SCIENCE FICTION", "fantasy" };

IEnumerable<string> exact = genres.GroupBy(x => x).Select(g => $"{g.Key}: {g.Count()}");
Console.WriteLine(string.Join(" | ", exact));

IEnumerable<string> ignoreCase = genres
    .GroupBy(x => x, StringComparer.OrdinalIgnoreCase)
    .Select(g => $"{g.Key}: {g.Count()}");
Console.WriteLine(string.Join(" | ", ignoreCase));
Science Fiction: 1 | science fiction: 1 | Fantasy: 1 | SCIENCE FICTION: 1 | fantasy: 1
Science Fiction: 3 | Fantasy: 2

Comparer'sız sürüm beş ayrı tür buldu. StringComparer.OrdinalIgnoreCase ile üç bilim kurgu ve iki fantastik kitap bir araya geldi. Grubun Key'i, o grubu ilk açan değerdir: "Science Fiction". Rapora normalize edilmiş bir isim basmak istiyorsan key selector'da kendin normalize et, ama ToUpper() yerine kültürden bağımsız ToUpperInvariant() kullan; Türkçe kültürde "science"ın büyüğü "SCİENCE" olur.

GroupBy Kaynağı Nasıl Okur

GroupBy deferred bir operatör, ama Take gibi lazy değil. Bir grubu verebilmek için o key'e ait bütün element'leri toplamış olması gerekir ve bunu ancak kaynağın sonuna gelince bilebilir:

int reads = 0;
IEnumerable<IGrouping<string, Book>> groups = Store.Books
    .Where(b =>
    {
        reads++;
        return true;
    })
    .GroupBy(b => b.Genre);

Console.WriteLine($"After defining: {reads}");

IGrouping<string, Book> first = groups.First();
Console.WriteLine($"After First(): {reads} (first group: {first.Key})");

int groupCount = groups.Count();
Console.WriteLine($"After Count(): {reads} ({groupCount} groups)");
After defining: 0
After First(): 10 (first group: Science Fiction)
After Count(): 20 (4 groups)

Tanımlama anında hiçbir şey okunmadı. Ama sadece ilk grubu istediğimizde bile on kitabın hepsi okundu: GroupBy ilk MoveNext'te bütün kaynağı buffer'lar. Ardından Count() çağrıldığında sorgu baştan çalıştı ve sayaç 20'ye çıktı. Bunun iki sonucu var. Birincisi, sonsuz ya da çok büyük bir kaynakta GroupBy bütün veriyi belleğe alır. İkincisi, gruplanmış sonucu birden fazla kez kullanacaksan bir kez ToList() ya da ToLookup() ile sabitle.

Veritabanında durum farklı: EF Core, ardından bir aggregation gelen GroupBy'ı SQL'deki GROUP BY'a çevirir ve gruplama sunucuda yapılır. Ayrıntıları EF Core serisinin LINQ sorguları yazısında bulabilirsin. Sadece saymak ya da toplamak için grupluyorsan .NET 9'daki CountBy ve AggregateBy ara gruplar oluşturmadan aynı sonucu verir; onları LINQ serisinin .NET 9 yazısında göreceğiz.

Sık Düşülen Tuzaklar

GroupBy sonucunu bir değişkende tutup kaynağın değişmeyeceğini varsaymak. Sorgu her enumerate edildiğinde yeniden gruplar; sabit bir görüntü istiyorsan ToLookup ya da ToList kullan.

Eşitlik tanımlamamış bir class'ı key yapmak. Her instance ayrı grup olur. Anonymous type, tuple ya da record kullan.

Grupların alfabetik ya da büyüklüğe göre geleceğini sanmak. Sıra, key'lerin kaynakta ilk görüldüğü sıradır; başka bir sıra istiyorsan OrderBy ekle.

Dictionary gibi kullanırken Dictionary'nin exception'ını unutmak. GroupBy(...).ToDictionary(...) olmayan key'de KeyNotFoundException fırlatır; ILookup boş sequence döndürür. Hangisinin davranışını istediğine karar ver.

Elle girilmiş metinleri comparer'sız gruplamak. "Fantasy" ve "fantasy" iki ayrı grup olur; StringComparer.OrdinalIgnoreCase ver.

Sadece ilk grubu almanın ucuz olduğunu sanmak. GroupBy(...).First() bile bütün kaynağı okur.

Küçük Bir Deneme

★☆☆ Kitapları yayın yılının on yılına göre grupla ve her on yıl için kitap sayısını eskiden yeniye sıralı yazdır.

★★☆ Her türün en pahalı kitabını ve o türün toplam stok değerini (Price * Stock) tek bir sorguyla üret. Sonra aynı sorguyu query syntax ile yaz.

★★★ ToLookup kullanmadan, Dictionary<string, List<Book>> ve bir foreach ile kendi "lookup"ını kur. Olmayan key'de boş bir sonuç döndüren bir Get method'u ekle ve gruplardaki element sırasının GroupBy ile aynı olduğunu kontrol et.

Gruplama tek bir sequence'ı parçalara ayırıyordu. Bir sonraki yazıda iki sequence'ı karşılaştıran set işlemlerine geçiyoruz: Distinct, Union, Intersect, Except ve .NET 6 ile gelen *By varyantları.