LINQ ve .NET 7–8: Order, Selector'süz ToDictionary ve Performans
.NET 7'nin key selector istemeyen Order ve OrderDescending operatörleri, IComparable eksikliğinin ancak runtime'da patlaması, kültüre duyarlı ve ordinal string sıralama, .NET 8'in selector'süz ToDictionary'si ve BenchmarkDotNet ile ölçülen vectorize edilmiş Max/Sum ile Order ve OrderBy(x => x) farkı.
Sürüm notu: Bu yazı .NET 7 ve .NET 8 ile gelen LINQ yeniliklerini anlatır; Order örnekleri C# 11 / .NET 7, ToDictionary ve ölçüm örnekleri C# 12 / .NET 8 hedefiyle .NET 10 SDK'da derlenip denenmiştir.
Önce Tahmin Et
.NET 7, key selector istemeyen iki sıralama operatörü getirdi: Order() ve OrderDescending(). İlk iki satırın ne yazdıracağı belli. Asıl soru üçüncü kısımda: Book bir record ve ona nasıl sıralanacağını hiç söylemedik. Bu kod derlenir mi? Derlenirse çalışınca ne olur?
int[] stock = { 12, 0, 3, 7 };
Console.WriteLine(string.Join(", ", stock.Order()));
Console.WriteLine(string.Join(", ", stock.OrderDescending()));
var books = new List<Book>
{
new("978-0321125217", "Domain-Driven Design", 54.00m, 3),
new("978-0132350884", "Clean Code", 37.90m, 12),
};
try
{
foreach (Book book in books.Order())
Console.WriteLine(book.Title);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
Console.WriteLine($"Inner: {ex.InnerException?.Message}");
}
public record Book(string Isbn, string Title, decimal Price, int Stock);
Order ve OrderDescending (.NET 7)
0, 3, 7, 12
12, 7, 3, 0
InvalidOperationException: Failed to compare two elements in the array.
Inner: At least one object must implement IComparable.
Kod derlendi ve sayılar beklendiği gibi sıralandı. books.Order() ise compile time'da değil, enumerate edildiği anda patladı. Order<T>'nin bir constraint'i yok; sıralama Comparer<T>.Default ile yapılıyor ve bu comparer, Book'un IComparable<Book> ya da IComparable implemente etmediğini ancak iki kitabı karşılaştırmaya çalıştığında fark ediyor. Exception'ın mesajı da içteki exception'ın mesajı da bunu söylüyor. Bu programı net7.0 hedefiyle derledim; makinede .NET 7 runtime'ı olmadığı için roll-forward ile .NET 8.0.25'te çalıştı.
.NET 6'da aynı sıralamayı key selector olarak element'in kendisini vererek yazardık: stock.OrderBy(x => x). Çalışır ama okuyana "burada bir key seçiliyor mu?" diye bir an düşündürür. Order() niyeti doğrudan söylüyor: element'leri doğal sıralarına göre diz. Aynı dosyayı net6.0 hedefinde derlediğimde derleyici yeni method'ları tanımadı:
Program.cs(2,43): error CS1061: 'int[]' does not contain a definition for 'Order' and no accessible extension method 'Order' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)
Program.cs(3,43): error CS1061: 'int[]' does not contain a definition for 'OrderDescending' and no accessible extension method 'OrderDescending' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)
Program.cs(13,33): error CS1061: 'List<Book>' does not contain a definition for 'Order' and no accessible extension method 'Order' accepting a first argument of type 'List<Book>' could be found (are you missing a using directive or an assembly reference?)
İlginç bir ayrıntı: aynı dosya net7.0 hedefi ve LangVersion 10 ile hatasız derlendi. Order() bir dil özelliği değil, bir library API'si; ihtiyacı olan şey C# 11 değil, .NET 7'nin System.Linq'i.
Doğal Sıra: IComparable ve Comparer
"Doğal sıra" sayılar ve tarihler için tartışmasız. String'lerde ise kültüre bağlı, kendi tiplerinde de sana bağlı:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
string[] tags = { "refactoring", "DDD", "architecture", "Clean Code" };
Console.WriteLine(string.Join(", ", tags.Order()));
Console.WriteLine(string.Join(", ", tags.Order(StringComparer.Ordinal)));
var books = new List<Book>
{
new("978-0321125217", "Domain-Driven Design", 54.00m, 3),
new("978-0132350884", "Clean Code", 37.90m, 12),
new("978-0201633610", "Design Patterns", 49.90m, 7),
};
foreach (Book book in books.Order())
Console.WriteLine($"{book.Isbn} {book.Title}");
public record Book(string Isbn, string Title, decimal Price, int Stock) : IComparable<Book>
{
// Kitabın doğal sırası: ISBN
public int CompareTo(Book? other) => string.CompareOrdinal(Isbn, other?.Isbn);
}
architecture, Clean Code, DDD, refactoring
Clean Code, DDD, architecture, refactoring
978-0132350884 Clean Code
978-0201633610 Design Patterns
978-0321125217 Domain-Driven Design
Varsayılan string comparer kültüre duyarlı karşılaştırma yapıyor: büyük-küçük harf farkını ikinci planda tutup alfabetik sıraya bakıyor. StringComparer.Ordinal ise karakter kodlarını karşılaştırıyor ve büyük harfler küçüklerden önce geliyor. Kültürü bu yüzden InvariantCulture'a sabitledim; tr-TR gibi bir kültürde harflerin sırası farklı olabilir. Ekranda kullanıcıya gösterilecek listelerde kültüre duyarlı sıralama doğru seçim, key, kod ya da dosya adı gibi makine için anlamlı değerlerde ordinal.
Book record'una IComparable<Book> ekleyince Order() çalıştı ve kitapları ISBN'e göre dizdi. Burada bir tasarım kararı verdin: bir kitabın "doğal sırası" ISBN'dir. Bu karar her zaman açık değildir; başlık mı, fiyat mı, yayın tarihi mi? Tipinin tek bir tartışmasız sırası yoksa IComparable implemente etme ve OrderBy(b => b.Price) gibi açık bir key yaz. Order()'ı sayılar, string'ler, tarihler ve Version gibi sırası belli tipler için sakla.
Selector'süz ToDictionary (.NET 8)
Bir dictionary'yi filtrelediğinde elinde bir IEnumerable<KeyValuePair<TKey, TValue>> kalır. Bunu tekrar dictionary'ye çevirmek için yıllarca aynı iki lambda'yı yazdık: p => p.Key ve p => p.Value. .NET 8 bu tekrarı kaldırdı:
var prices = new Dictionary<string, decimal>
{
["978-0132350884"] = 37.90m,
["978-0135957059"] = 42.50m,
["978-0321125217"] = 54.00m,
};
// .NET 7 ve öncesi
Dictionary<string, decimal> before = prices
.Where(p => p.Value > 40m)
.ToDictionary(p => p.Key, p => p.Value);
// .NET 8
Dictionary<string, decimal> expensive = prices.Where(p => p.Value > 40m).ToDictionary();
Console.WriteLine(string.Join(", ", expensive.Keys));
var books = new List<Book>
{
new("978-0132350884", "Clean Code", 37.90m, 12),
new("978-0135957059", "The Pragmatic Programmer", 42.50m, 0),
};
Dictionary<string, int> stock = books.Select(b => (b.Isbn, b.Stock)).ToDictionary();
Console.WriteLine(stock["978-0132350884"]);
Dictionary<string, decimal> ignoreCase = new[] { KeyValuePair.Create("isbn-a", 1m) }
.ToDictionary(StringComparer.OrdinalIgnoreCase);
Console.WriteLine(ignoreCase.ContainsKey("ISBN-A"));
try
{
var duplicate = new[] { ("978-0132350884", 12), ("978-0132350884", 5) }.ToDictionary();
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.Message);
}
public record Book(string Isbn, string Title, decimal Price, int Stock);
978-0135957059, 978-0321125217
12
True
An item with the same key has already been added. Key: 978-0132350884
Selector'süz ToDictionary() iki tür kaynağı kabul ediyor: KeyValuePair<TKey, TValue> sequence'ları ve iki element'li tuple sequence'ları. Tuple sürümü özellikle Select ile birlikte kullanışlı; (b.Isbn, b.Stock) yazıp doğrudan Dictionary<string, int> elde ettik. İkisinin de comparer alan overload'u var. Davranış eskisiyle aynı: aynı key iki kez gelirse ArgumentException atıyor ve mesajda hangi key olduğunu söylüyor.
Aynı dosyayı net7.0 hedefiyle derlediğimde, parametresiz her çağrı için şu hatayı aldım:
Program.cs(16,74): error CS1501: No overload for method 'ToDictionary' takes 0 arguments
.NET 6 ya da 7'de kalıyorsan iki lambda yazmak yerine kısa bir yol daha var. Dictionary<TKey, TValue>'nin bir IEnumerable<KeyValuePair<TKey, TValue>> alan constructor'ı .NET 6'da da mevcut:
var prices = new Dictionary<string, decimal>
{
["978-0132350884"] = 37.90m,
["978-0135957059"] = 42.50m,
["978-0321125217"] = 54.00m,
};
var expensive = new Dictionary<string, decimal>(prices.Where(p => p.Value > 40m));
Console.WriteLine(string.Join(", ", expensive.Keys));
978-0135957059, 978-0321125217
Performans: Ölçmeden Konuşma
.NET 7 ve 8'de LINQ'e çok az yeni operatör geldi; asıl yatırım performansa yapıldı. Stephen Toub'un .NET 7 ve .NET 8 performans yazılarına göre Min, Max ve Sum gibi aggregate operatörleri, array ve list gibi bellekte bitişik duran kaynaklarda vectorize edildi: işlemci tek komutla birden fazla sayıyı aynı anda işliyor. .NET 8'de de ToList, element'leri doğrudan listenin iç array'ine yazacak şekilde yeniden düzenlendi. Bu makinede .NET 6 ve 7 runtime'ları yok, o yüzden sürümleri birbiriyle değil, elle yazılmış bir döngüyle karşılaştırdım. Proje net8.0 hedefli, Release modda:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run([typeof(AggregateBenchmarks), typeof(SortBenchmarks)]);
[MemoryDiagnoser]
public class AggregateBenchmarks
{
private int[] _stock = null!;
[GlobalSetup]
public void Setup()
{
var random = new Random(42);
_stock = Enumerable.Range(0, 10_000).Select(_ => random.Next(0, 1_000)).ToArray();
}
[Benchmark]
public int MaxLoop()
{
int max = int.MinValue;
foreach (int value in _stock)
if (value > max)
max = value;
return max;
}
[Benchmark]
public int MaxLinq() => _stock.Max();
[Benchmark]
public int SumLoop()
{
int sum = 0;
foreach (int value in _stock)
sum += value;
return sum;
}
[Benchmark]
public int SumLinq() => _stock.Sum();
}
[MemoryDiagnoser]
public class SortBenchmarks
{
private int[] _stock = null!;
[GlobalSetup]
public void Setup()
{
var random = new Random(42);
_stock = Enumerable.Range(0, 1_000).Select(_ => random.Next(0, 1_000)).ToArray();
}
[Benchmark(Baseline = true)]
public int[] OrderByIdentity() => _stock.OrderBy(x => x).ToArray();
[Benchmark]
public int[] Order() => _stock.Order().ToArray();
}
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 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Median | Allocated |
|-------- |---------:|----------:|----------:|---------:|----------:|
| MaxLoop | 2.339 us | 0.0200 us | 0.0177 us | 2.331 us | - |
| MaxLinq | 1.095 us | 0.0219 us | 0.0277 us | 1.078 us | - |
| SumLoop | 2.359 us | 0.0337 us | 0.0299 us | 2.378 us | - |
| SumLinq | 1.164 us | 0.0127 us | 0.0119 us | 1.168 us | - |
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
|---------------- |----------:|----------:|----------:|------:|-------:|----------:|------------:|
| OrderByIdentity | 14.675 us | 0.1510 us | 0.1413 us | 1.00 | 1.9379 | 15.89 KB | 1.00 |
| Order | 5.337 us | 0.0703 us | 0.0658 us | 0.36 | 0.4807 | 3.96 KB | 0.25 |
İlk tabloda LINQ, 10.000 element'li bir int array'inde elle yazılmış foreach'in yaklaşık iki katı hızlı çıktı: Max için 1,10 µs'ye karşı 2,34 µs, Sum için 1,16 µs'ye karşı 2,36 µs. Sebebi vectorization; döngü sayıları birer birer işliyor, LINQ ise aynı anda birkaç tanesini. Bu, "LINQ her zaman döngüden yavaştır" ezberinin bir istisnası. BenchmarkDotNet bu koşuda MaxLinq için bir MultimodalDistribution uyarısı da verdi; ölçüm sırasında makinede başka build'ler çalışıyordu, o yüzden küçük farkları yorumlama, büyük resme bak.
İkinci tablo daha şaşırtıcı. Aynı 1.000 sayıyı Order() ile sıralamak, OrderBy(x => x)'in üçte birinden biraz fazla sürdü ve dörtte bir bellek ayırdı. 3,96 KB, aşağı yukarı 1.000 int'lik sonuç array'inin kendisi. OrderBy ise key'ler ve sıralama haritası için ek array'ler ayırıyor. Key'in element'in kendisi olduğunu bilmek, runtime'a bu ara yapıları kurmadan sıralama imkânı veriyor. Yani Order() bu ölçümde sadece daha okunaklı değil, daha ucuz da.
Yükseltirken Bilmen Gerekenler
Bu yazıdaki her şey library API'si; dil sürümüne değil hedef framework'e bağlı. TargetFramework'ü net8.0'a çektiğinde Order() da selector'süz ToDictionary() da gelir. Performans iyileştirmeleri ise kodu değiştirmeden gelir: aynı Max() çağrısı yeni runtime'da daha hızlı çalışır. Microsoft hedef framework'ün varsayılanından yeni bir LangVersion'ı desteklemediği için TFM'i yükseltmek ve LangVersion'ı hiç yazmamak en temiz yol.
Destek tarihlerini unutma. .NET 7'nin desteği Mayıs 2024'te bitti. .NET 8 LTS ama desteği 10 Kasım 2026'da sona eriyor. Bu yazıdaki her şey .NET 10'da (LTS, 14 Kasım 2028'e kadar destekli) aynen çalışıyor; yeni bir projede hedefin orası olmalı. Eski bir projeyi adım adım taşımayı C# Sürümleri serisinde ayrıca anlatıyoruz.
Sık Düşülen Tuzaklar
Order()'ı derleyicinin kontrol ettiğini sanmak. Açılışta gördüğümüz gibi IComparable eksikliği compile time'da değil, sorgu çalıştığında ortaya çıkıyor. Kendi tipinde Order() kullanan bir kod birim testine girmeden canlıya giderse ilk enumerate'te patlar.
String'leri kültürü düşünmeden sıralamak. Order() da OrderBy gibi varsayılan olarak mevcut kültürün kurallarını kullanıyor. Sunucunun kültürü değiştiğinde sıralama da değişebilir. Makine için anlamlı değerlerde Order(StringComparer.Ordinal) yaz.
Selector'süz ToDictionary()'nin tekrar eden key'leri tolere ettiğini sanmak. Yeni overload sadece yazımı kısaltıyor; tekrar eden key yine ArgumentException. Kaynakta tekrar olabiliyorsa önce DistinctBy(p => p.Key) ile hangisini tutacağına karar ver ya da gruplayıp birleştir.
Küçük Bir Deneme
★☆☆ Elindeki bir .NET 6 projesinde OrderBy(x => x) ve ToDictionary(p => p.Key, p => p.Value) geçen yerleri bul. Projeyi net8.0'a taşıdığında bunlardan kaçını sadeleştirebileceğini say.
★★☆ Book için fiyata, sonra başlığa göre sıralayan bir IComparer<Book> yaz ve books.Order(comparer) ile kullan. Aynı sonucu OrderBy(...).ThenBy(...) ile al; hangisi daha okunaklı?
★★★ Ölçümdeki AggregateBenchmarks'a List<int> ve IEnumerable<int> olarak verilen (ör. _stock.Where(_ => true)) kaynakları ekle. Vectorization hangi kaynakta devreye giriyor, hangisinde girmiyor?
Bir sonraki yazıda .NET 9'a geçiyoruz: grupları belleğe toplamadan sayan CountBy, key bazında biriktiren AggregateBy ve Select((x, i) => ...) hilesinin yerini alan Index().