LINQ Set İşlemleri: Distinct, Union, Intersect, Except ve DistinctBy
Distinct'in eşitliği nereden aldığı ve eşitliği olmayan class'ta neden hiçbir şey silmediği. Union, Intersect ve Except'in sonucu tekilleştirmesi, Concat ve Where ile farkları, dokümante edilen sıra, comparer ile karşılaştırma, .NET 6 DistinctBy/UnionBy ve key listesi alan IntersectBy/ExceptBy, CS0411 hatası, HashSet ve deferred execution.
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
Bir bülten formundan gelen kayıtlarda aynı kişi iki kez görünüyor. Tekrarları Distinct ile temizliyoruz. Ardından bir istek listesini sepetle birleştiriyoruz; bir kez Concat, bir kez Union ile. Bu kod ne yazdırır?
List<Customer> signups = new()
{
new Customer("Ada", "ada@example.com"),
new Customer("Alan", "alan@example.com"),
new Customer("Ada", "ada@example.com"),
};
Console.WriteLine($"Distinct: {signups.Distinct().Count()}");
string[] wishlist = { "Dune", "1984", "Dune" };
string[] cart = { "1984", "The Hobbit" };
Console.WriteLine($"Concat: {wishlist.Concat(cart).Count()}, Union: {wishlist.Union(cart).Count()}");
class Customer
{
public Customer(string name, string email) => (Name, Email) = (name, email);
public string Name { get; }
public string Email { get; }
}
Distinct Neye Göre Eşit Sayar
Çıktı:
Distinct: 3
Concat: 5, Union: 3
Distinct hiçbir şeyi silmedi; Ada iki kez listede. Buna karşılık Union, Concat'in beş element'ini üçe indirdi. İkisi de aynı soruyu soruyor: "bu iki element eşit mi?" Cevabı veren, tipin varsayılan equality comparer'ı (EqualityComparer<T>.Default). string değer eşitliğiyle çalışır, bu yüzden iki "Dune" aynı sayıldı. Customer ise Equals ve GetHashCode'u override etmeyen sıradan bir class; iki ayrı new Customer(...) içerikleri aynı olsa da bellekte iki ayrı object ve LINQ onları farklı görür.
Bu yazının konusu olan set (küme) işlemlerinin hepsi bu eşitlik tanımına dayanır: Distinct, Union, Intersect, Except ve .NET 6 ile gelen DistinctBy, UnionBy, IntersectBy, ExceptBy. Bir not: bunlar Join ile aynı şey değil. Join iki sequence'ın element'lerini bir key üzerinden eşleştirip yeni satırlar üretir, set işlemleri ise aynı tipte iki sequence'ın element'lerini birbiriyle karşılaştırır. Join'i bir sonraki yazıda ayrıca ele alacağız.
Eşitliği düzeltmenin üç yolu var:
List<Customer> signups = new()
{
new Customer("Ada", "ada@example.com"),
new Customer("Alan", "alan@example.com"),
new Customer("Ada", "ada@example.com"),
};
Console.WriteLine($"Distinct on record: {signups.Distinct().Count()}");
Console.WriteLine($"Distinct emails: {string.Join(" | ", signups.Select(c => c.Email).Distinct())}");
Console.WriteLine($"Anonymous type: {signups.Select(c => new { c.Name, c.Email }).Distinct().Count()}");
record Customer(string Name, string Email); // Equals ve GetHashCode derleyiciden
Distinct on record: 2
Distinct emails: ada@example.com | alan@example.com
Anonymous type: 2
record, derleyicinin bütün property'lere bakan Equals ve GetHashCode üretmesini sağlar ve Distinct kendiliğinden doğru çalışır. Tipi değiştiremiyorsan önce karşılaştırmak istediğin field'a projekte edip öyle Distinct çağırabilirsin; ama elinde artık müşteriler değil e-postalar kalır. Anonymous type da değer eşitliği taşıdığı için birden fazla field'ı birlikte karşılaştırmanın kısa bir yoludur.
Union, Intersect, Except: Set Mantığı
Okuldaki Venn şemalarını hatırla: birleşim, kesişim ve fark. Bir istek listesi ve satın alınmış kitaplar üzerinden bakalım:
string[] wishlist = { "Dune", "1984", "Dune", "Foundation", "Neuromancer" };
string[] purchased = { "1984", "The Hobbit", "1984" };
Console.WriteLine($"Union: {string.Join(" | ", wishlist.Union(purchased))}");
Console.WriteLine($"Intersect: {string.Join(" | ", wishlist.Intersect(purchased))}");
Console.WriteLine($"Except: {string.Join(" | ", wishlist.Except(purchased))}");
Console.WriteLine($"Where: {string.Join(" | ", wishlist.Where(t => !purchased.Contains(t)))}");
Union: Dune | 1984 | Foundation | Neuromancer | The Hobbit
Intersect: 1984
Except: Dune | Foundation | Neuromancer
Where: Dune | Dune | Foundation | Neuromancer
Union önce ilk, sonra ikinci sequence'ı dolaştı ve daha önce vermediği her element'i verdi. Intersect ikisinde de olanları, Except ilkinde olup ikincisinde olmayanları bıraktı. Dikkat edilmesi gereken nokta, üçünün de sonucu tekilleştirmesi. İstek listesinde "Dune" iki kez vardı; Except'in sonucunda bir kez görünüyor. Aynı işi Where ve Contains ile yazınca tekrar korunuyor. Hangisini istediğine göre seç: "satın alınmamış kitap çeşitleri" sorusunun cevabı Except, "satın alınmamış istek satırları" sorusunun cevabı Where.
Sıraya gelince: dokümantasyon Union için ilk sequence'ın, sonra ikincisinin sırasını, Intersect için ilk sequence'ın sırasını tarif ediyor. Distinct için ise sonucun sırasız olduğunu söylüyor. Bugünkü implementasyon ilk görülme sırasını koruyor, ama sıra senin için önemliyse buna contract gibi güvenme ve OrderBy ekle.
Comparer ile Karşılaştırmak
E-posta adresleri büyük-küçük harfe duyarsızdır, ama string'in varsayılan eşitliği değildir. Bülten listesinde olmayan müşterileri bulmak istediğimizde fark hemen ortaya çıkıyor:
string[] newsletter = { "ada@example.com", "Alan@Example.com" };
string[] customers = { "ADA@example.com", "grace@example.com", "alan@example.com" };
Console.WriteLine(string.Join(" | ", customers.Except(newsletter)));
Console.WriteLine(string.Join(" | ", customers.Except(newsletter, StringComparer.OrdinalIgnoreCase)));
Console.WriteLine(string.Join(" | ", newsletter.Union(customers, StringComparer.OrdinalIgnoreCase)));
ADA@example.com | grace@example.com | alan@example.com
grace@example.com
ada@example.com | Alan@Example.com | grace@example.com
Comparer'sız Except, "ADA@example.com" ile "ada@example.com"u farklı saydı ve üç müşterinin hiçbirini elemedi. StringComparer.OrdinalIgnoreCase verince sadece Grace kaldı. Union da aynı comparer ile hem iki listeyi birleştirdi hem de farklı yazılmış aynı adresleri tek bir kayda indirdi; tutulan yazım, ilk karşılaşılan. Bütün set operatörlerinin bir IEqualityComparer<T> alan overload'u var. Kendi comparer'ını yazacaksan Koleksiyonlar ve Generics serisinin Dictionary yazısındaki kuralı hatırla: eşit saydığın iki değer aynı hash'i üretmeli.
DistinctBy ve UnionBy (.NET 6)
.NET 6 öncesinde "her yazardan bir kitap" demek için ya GroupBy(b => b.Author).Select(g => g.First()) yazılırdı ya da yalnızca bu iş için bir comparer class'ı. *By varyantları eşitliği bir key selector ile tanımlamanı sağlar. Bundan sonraki örnekler serinin önceki yazıları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),
};
}
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
IEnumerable<string> onePerAuthor = Store.Books
.DistinctBy(b => b.Author) // her yazardan ilk kitap
.Select(b => b.Title);
Console.WriteLine(string.Join(" | ", onePerAuthor));
List<Book> featured = new() { Store.Books[0], Store.Books[2] };
List<Book> newArrivals = new() { Store.Books[2] with { Price = 7.99m }, Store.Books[9] };
IEnumerable<Book> homepage = featured.UnionBy(newArrivals, b => b.Isbn);
Console.WriteLine(string.Join(" | ", homepage.Select(b => $"{b.Title} {b.Price}")));
Console.WriteLine($"Union on records: {featured.Union(newArrivals).Count()} books");
Dune | Foundation | 1984 | The Hobbit | Brave New World | Neuromancer | The Left Hand of Darkness
Dune 12.99 | 1984 8.99 | The Left Hand of Darkness 9.49
Union on records: 4 books
DistinctBy(b => b.Author) her yazarın listede ilk gelen kitabını tuttu: Asimov'dan Foundation, Orwell'den 1984, Tolkien'den The Hobbit. Kitapların geri kalanı aynı key'i tekrar ürettiği için atlandı. Sonuçta element'lerin kendisi döner, key'ler değil; Select(b => b.Author).Distinct() ile bu yüzden farklı.
UnionBy örneği bir ana sayfa vitrinini birleştiriyor. "Yeni gelenler" listesinde 1984'ün indirimli bir kopyası var. ISBN'e göre birleştirince ilk listedeki kayıt kazandı ve fiyat 8.99 kaldı; aynı key'e sahip ikinci kayıt atlandı. Düz Union ise record'un bütün property'lerine baktı, fiyatı farklı iki 1984'ü ayrı kitap saydı ve dört sonuç verdi. Kayıtların bazı field'ları değişebiliyorsa, "aynı kitap" tanımını *By ile açıkça vermek daha güvenli.
IntersectBy ve ExceptBy: İkinci Parametre Key Listesidir
Bu iki operatörün signature'ı ilk bakışta şaşırtır. İkinci parametre ilk sequence ile aynı tipte değil, key tipinde bir sequence'tır:
string[] soldOutIsbns = { "978-0553293357", "978-0441569595" };
IEnumerable<string> soldOut = Store.Books.IntersectBy(soldOutIsbns, b => b.Isbn).Select(b => b.Title);
int available = Store.Books.ExceptBy(soldOutIsbns, b => b.Isbn).Count();
Console.WriteLine($"Sold out: {string.Join(" | ", soldOut)}; available: {available}");
List<Book> inCart = Store.Books.Take(3).ToList();
IEnumerable<string> notInCart = Store.Books
.ExceptBy(inCart.Select(b => b.Isbn), b => b.Isbn) // ikinci parametre key'lerin listesi
.Select(b => b.Title);
Console.WriteLine($"Not in cart: {notInCart.Count()}, first: {notInCart.First()}");
Sold out: Foundation | Neuromancer; available: 8
Not in cart: 7, first: The Hobbit
Tasarım gerçek bir ihtiyaçtan geliyor. Çoğu zaman elinde karşılaştıracağın ikinci bir Book listesi değil, bir ISBN listesi vardır: tükenen ürünlerin kodları, bir API'den gelen id'ler, kullanıcının işaretlediği satırlar. ExceptBy(soldOutIsbns, b => b.Isbn) bu durumu doğrudan ifade ediyor. İkinci bir Book listen varsa onu önce key'lere projekte edersin: inCart.Select(b => b.Isbn). Bunu unutup listeyi doğrudan verirsen derleyici durdurur:
List<Book> inCart = Store.Books.Take(3).ToList();
IEnumerable<Book> notInCart = Store.Books.ExceptBy(inCart, b => b.Isbn);
Program.cs(3,43): error CS0411: The type arguments for method 'Enumerable.ExceptBy<TSource, TKey>(IEnumerable<TSource>, IEnumerable<TKey>, Func<TSource, TKey>)' cannot be inferred from the usage. Try specifying the type arguments explicitly.
Hata mesajı ilk okuyuşta yardımcı değil. Derleyici TKey'i iki yerden çıkarmaya çalışıyor: ikinci argümandan Book, key selector'dan string. İkisi çelişince tip çıkarımı (type inference) başarısız oluyor. Bu mesajı *By operatörlerinde görürsen ilk bakacağın yer ikinci argümanın tipi olsun.
İçeride Ne Oluyor: HashSet ve Deferred Execution
Set operatörleri içeride bir hash set kullanır; .NET 6 ile LINQ'in kendi küçük set implementasyonu yerini HashSet<T>'e bıraktı. Bu yüzden Except ikinci sequence'ı bir kez set'e doldurur ve ilk sequence'taki her element için tek bir hash araması yapar: toplam iş kabaca n + m. Where(x => !list.Contains(x)) ise her element için listeyi baştan tarar: n × m. On kitapta fark görünmez; kayıt sayısı büyüdükçe ikinci yolun maliyeti karesel olarak artar. Bunu serinin performans yazısında ölçeceğiz. Bu operatörler deferred execution ile çalıştığı için ikinci sequence'ın ne zaman okunduğu da önemli:
List<string> blockedIsbns = new() { "978-0441172719" };
IEnumerable<Book> visible = Store.Books.ExceptBy(blockedIsbns, b => b.Isbn);
Console.WriteLine($"First count: {visible.Count()}");
blockedIsbns.Add("978-0451524935"); // sorgu tanımlandıktan sonra
Console.WriteLine($"Second count: {visible.Count()}");
First count: 9
Second count: 8
Sorgu tanımlandığında engelli listesinde tek ISBN vardı. Listeye ikinci ISBN eklendikten sonra aynı visible sorgusu bir kitap daha eksik döndü. ExceptBy ikinci sequence'ı her enumerate edilişinde yeniden okur. Bu bazen tam istediğin şeydir (canlı bir engelli listesi), bazen de sürpriz bir bug kaynağı. Sabit bir sonuç istiyorsan sorguyu tanımladığın yerde ToList() ile materialize et.
Sık Düşülen Tuzaklar
Eşitlik tanımlamamış bir class üzerinde Distinct çağırmak. Hiçbir şey silinmez ve hata da almazsın. record, DistinctBy ya da bir comparer kullan.
Union ile Concat'i karıştırmak. Concat iki sequence'ı olduğu gibi uç uca ekler, Union tekrarları atar. Tekrarlar anlamlıysa (sipariş satırları gibi) Concat kullan.
Except ve Intersect'in ilk sequence'taki tekrarları koruyacağını sanmak. Sonuç tekilleştirilmiş bir set'tir; satır sayısı önemliyse Where ile bir HashSet kullan.
Kullanıcı girdisini (e-posta, kullanıcı adı) comparer'sız karşılaştırmak. Büyük-küçük harf farkı yüzünden aynı kişi iki kez sayılır; StringComparer.OrdinalIgnoreCase ver.
ExceptBy/IntersectBy'a ikinci parametre olarak element listesi vermek. Key listesi vermen gerekir; CS0411 görürsen ilk buna bak.
Distinct'in sırasına güvenmek. Dokümantasyon sırasız diyor; sıra önemliyse açıkça sırala.
Küçük Bir Deneme
★☆☆ İki müşterinin istek listelerini (string array) al ve ikisinin de istediği kitapları, sadece birinin istediği kitapları ve hiç tekrar olmadan bütün kitapları ayrı ayrı yazdır.
★★☆ DistinctBy kullanmadan "her yazarın en ucuz kitabını" bul. Sonra aynı soruyu DistinctBy ile çözmeye çalış; neden önce sıralaman gerektiğini açıkla.
★★★ E-posta adreslerini hem büyük-küçük harfe duyarsız hem de baştaki ve sondaki boşlukları yok sayarak karşılaştıran bir IEqualityComparer<string> yaz. Except ile dene, sonra GetHashCode'u bilerek yanlış (sadece Trim yapan) yazıp sonucun nasıl bozulduğunu gözlemle.
Set işlemleri aynı tipteki iki sequence'ı karşılaştırıyordu. Bir sonraki yazıda farklı tipteki iki sequence'ı bir key üzerinden eşleştiren Join ve GroupJoin'e, ve .NET 6'da hazır operatörü olmayan left join pattern'ına geçiyoruz.