Mert Özen Her Satırda Daha İleri
Backend C# Koleksiyonlar ve Generics — #11

C# 13–14 ve .NET 9–10: OrderedDictionary, params Collections ve Fazlası

Mert Özen 7 Kas 2026 11 dk 1 görüntülenme
C# 13–14 ve .NET 9–10: OrderedDictionary, params Collections ve Fazlası

Dictionary'nin sıra sözü vermemesi ve .NET 9'un generic OrderedDictionary'si, .NET 10'un index döndüren TryAdd/TryGetValue'su, PriorityQueue.Remove, string ayırmadan alternate lookup, C# 13 params collections ve C# 14 extension members ile span dönüşümleri.

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

Önce Tahmin Et

Stok dictionary'sinden ilk kitabı siliyor, sonra yeni bir kitap ekliyoruz. Ekrana hangi sırayla yazılırlar? Yeni kitap sonda mı olur?

var stock = new Dictionary<string, int>
{
    ["Clean Code"] = 3,
    ["Refactoring"] = 0,
    ["Domain-Driven Design"] = 5,
};

stock.Remove("Clean Code");
stock["C# in Depth"] = 2;

Console.WriteLine(string.Join(", ", stock.Keys));

Dictionary Sıra Sözü Vermez

.NET 9 üzerinde çıktı şu:

C# in Depth, Refactoring, Domain-Driven Design

Yeni kitap en başta. Dictionary<TKey, TValue> element'lerini içeride bir array'de tutar. Hiç silme yapmadığın sürece dolaşma sırası ekleme sırasıyla örtüşür ve çoğu kişi bunun bir garanti olduğunu düşünür. Silme yapınca boşalan yer bir "boş yerler" listesine girer ve sonraki ekleme o yeri yeniden kullanır. Dokümantasyon bu konuda net: dolaşma sırası tanımsızdır. Bugün böyle çalışan kod, yarın başka bir implementasyonda başka sırayla çalışabilir.

Ekleme sırası gerçekten önemliyse (bir sepetteki kalemlerin eklendiği sıra, bir formun alan sırası, JSON'a yazılacak bir object'in key sırası) C# 10/.NET 6'da iki seçeneğin vardı. Ya bir List<TKey> ile bir Dictionary<TKey, TValue>'yi yan yana tutup ikisini senkron güncellerdin, ya da .NET Framework'ün ilk günlerinden kalan generic olmayan OrderedDictionary'yi kullanırdın:

using System.Collections;
using System.Collections.Specialized;

var stock = new OrderedDictionary
{
    ["Refactoring"] = 0,
    ["Domain-Driven Design"] = 5,
};
stock["C# in Depth"] = 2;

int ddd = (int)stock["Domain-Driven Design"]!;
object? first = stock[0];

foreach (DictionaryEntry entry in stock)
    Console.WriteLine($"{entry.Key}: {entry.Value}");

Console.WriteLine($"{ddd}, first value: {first}");
Refactoring: 0
Domain-Driven Design: 5
C# in Depth: 2
5, first value: 0

Sıra doğru ama bedeli ağır: key ve value'lar object, her okuma bir cast, her int değer boxing ile heap'e gidiyor ve derleyici yanlış tipte bir değer koymana engel olmuyor.

OrderedDictionary<TKey, TValue> (.NET 9)

.NET 9, System.Collections.Generic namespace'ine generic bir OrderedDictionary<TKey, TValue> ekledi. Tahmin sorusundaki programda sadece tipi değiştirdim:

var stock = new OrderedDictionary<string, int>
Refactoring, Domain-Driven Design, C# in Depth

Yeni kitap artık sonda. Tip, key ile O(1) ortalama erişimi korurken element'leri sıralı bir array'de tutar ve buna index ile erişen bir API ekler:

var reading = new OrderedDictionary<string, int>
{
    ["Refactoring"] = 120,
    ["Domain-Driven Design"] = 80,
};

reading.Insert(0, "Clean Code", 45);
reading.SetAt(2, 95);

KeyValuePair<string, int> next = reading.GetAt(0);
Console.WriteLine($"next: {next.Key} ({next.Value} pages left)");
Console.WriteLine($"Refactoring is #{reading.IndexOf("Refactoring") + 1}");

reading.RemoveAt(0);
foreach ((string title, int pages) in reading)
    Console.WriteLine($"{title}: {pages}");
next: Clean Code (45 pages left)
Refactoring is #2
Refactoring: 120
Domain-Driven Design: 95

Insert belirli bir sıraya ekler, SetAt o sıradaki değeri değiştirir, GetAt sıradaki key/value çiftini verir, IndexOf bir key'in sırasını söyler, RemoveAt da sıradan siler. Bu tip IList<KeyValuePair<TKey, TValue>>'yi de implemente ettiği için hem dictionary hem liste gibi davranır. Bedeli şu: araya eklemek ya da ortadan silmek, arkadaki element'leri kaydırdığı için element sayısıyla orantılıdır. Sona ekleyip key ile okuduğun sürece Dictionary'ye yakın davranır.

.NET 10'un Eklemeleri

.NET 10, OrderedDictionary<TKey, TValue>'ye index döndüren iki overload ekledi: TryAdd(key, value, out int index) ve TryGetValue(key, out value, out int index). Sepete aynı kitap tekrar eklendiğinde adedi artırmak gibi işlerde key'i iki kez aramaktan kurtarır:

var cart = new OrderedDictionary<string, int>();
cart.Add("978-0132350884", 1);
cart.Add("978-0135957059", 2);

if (!cart.TryAdd("978-0132350884", 1, out int index))
{
    cart.SetAt(index, cart.GetAt(index).Value + 1);
    Console.WriteLine($"already in the cart at line {index + 1}");
}

if (cart.TryGetValue("978-0135957059", out int quantity, out int line))
    Console.WriteLine($"line {line + 1}: {quantity} pcs");

HashSet<string> tags = ["csharp", "dotnet"];
IReadOnlySet<string> readOnlyTags = tags.AsReadOnly();
Console.WriteLine($"{cart.GetAt(0).Value} pcs, {readOnlyTags.Count} tags, {readOnlyTags.GetType().Name}");
already in the cart at line 1
line 2: 2 pcs
2 pcs, 2 tags, ReadOnlySet`1

TryAdd başarısız olduğunda bile var olan element'in index'ini verdiği için SetAt doğrudan o satıra gidiyor. Aynı program net9.0 hedefinde derlenmiyor:

Program.cs(5,11): error CS1501: No overload for method 'TryAdd' takes 3 arguments
Program.cs(11,10): error CS1501: No overload for method 'TryGetValue' takes 3 arguments
Program.cs(15,42): error CS0411: The type arguments for method 'CollectionExtensions.AsReadOnly<T>(IList<T>)' cannot be inferred from the usage. Try specifying the type arguments explicitly.

Son satırdaki AsReadOnly de .NET 10'da geldi: ISet<T> için bir extension method ve .NET 9'da eklenen ReadOnlySet<T> wrapper'ını döndürüyor. .NET 9'da new ReadOnlySet<string>(tags) yazman gerekirdi. .NET 9 ve 10'un referans assembly'lerini karşılaştırdığımda collection tarafında başka eklemeler de çıktı: PriorityQueue<TElement, TPriority>.Capacity property'si, ReadOnlySpan alan FrozenDictionary.Create ve aynı key tekrar gelirse sonuncuyu tutan ImmutableDictionary.CreateRangeWithOverwrite, collection expression'lar için ReadOnlyCollection.CreateCollection ve ReadOnlyCollection.CreateSet.

.NET 9'un Diğer Collection Eklemeleri

PriorityQueue<TElement, TPriority> .NET 6'da geldiğinde bir element'in önceliğini değiştirmenin yolu yoktu. .NET 9'daki Remove ile "çıkar ve yeni öncelikle tekrar ekle" pattern'ını kurabiliyorsun:

var queue = new PriorityQueue<string, int>();
queue.Enqueue("ORD-1001", 3);
queue.Enqueue("ORD-1002", 2);
queue.Enqueue("ORD-1003", 1);

if (queue.Remove("ORD-1001", out string? order, out int oldPriority))
{
    queue.Enqueue(order, 0);
    Console.WriteLine($"{order}: priority {oldPriority} -> 0");
}

while (queue.TryDequeue(out string? next, out int priority))
    Console.WriteLine($"{next} ({priority})");
ORD-1001: priority 3 -> 0
ORD-1001 (0)
ORD-1003 (1)
ORD-1002 (2)

Remove queue'yu baştan sona tarayıp eşleşen element'i bulur, yani O(n) çalışır; sık sık öncelik güncelliyorsan bunu hesaba kat.

İkinci yenilik alternate lookup. Bir dosyadan ya da HTTP isteğinden gelen satırı parçalara ayırıp her parçayı dictionary'de aramak istediğinde, her parça için bir string oluşturmak zorundaydın. .NET 9'da Dictionary, HashSet, ConcurrentDictionary, FrozenDictionary ve FrozenSet başka bir key tipiyle arama yapan bir view verebiliyor:

var stock = new Dictionary<string, int>
{
    ["978-0132350884"] = 3,
    ["978-0135957059"] = 0,
    ["978-0321125217"] = 5,
};

Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> lookup =
    stock.GetAlternateLookup<ReadOnlySpan<char>>();

ReadOnlySpan<char> line = "978-0132350884;978-0321125217;978-1617294532";
foreach (Range range in line.Split(';'))
{
    ReadOnlySpan<char> isbn = line[range];
    string status = lookup.TryGetValue(isbn, out int count) ? $"{count} in stock" : "unknown";
    Console.WriteLine($"{isbn}: {status}");
}
978-0132350884: 3 in stock
978-0321125217: 5 in stock
978-1617294532: unknown

line[range] yeni bir string oluşturmuyor, orijinal string'in bir bölümüne bakan bir span veriyor; dictionary de aramayı o span ile yapıyor. Bunun çalışması için dictionary'nin comparer'ının IAlternateEqualityComparer<ReadOnlySpan<char>, string>'yi desteklemesi gerekir; varsayılan string comparer ve StringComparer.Ordinal/OrdinalIgnoreCase bunu destekler. Span üzerinde Split da .NET 9'da gelen bir API.

Ayrıca .NET 9'da Dictionary, HashSet, Queue ve Stack bir Capacity property'si kazandı; ReadOnlyCollection<T> ve ReadOnlyDictionary<TKey, TValue> wrapper'larının yanına da ReadOnlySet<T> katıldı.

params Collections (C# 13)

C# 10'da params sadece array ile çalışırdı: params string[] isbns yazar, her çağrıda derleyicinin bir array oluşturmasına razı olurdun. C# 13 params'ı collection expression'ların hedef olabildiği tiplere genişletti: Span<T>, ReadOnlySpan<T>, IEnumerable<T>, IReadOnlyList<T>, List<T> ve diğerleri:

var stock = new Dictionary<string, int>
{
    ["978-0132350884"] = 3,
    ["978-0135957059"] = 0,
    ["978-0321125217"] = 5,
};

Console.WriteLine(CountInStock(stock, "978-0132350884", "978-0135957059"));
Console.WriteLine(CountInStock(stock));
Console.WriteLine(Describe(["Clean Code", "Refactoring"]));
Console.WriteLine(Describe(stock.Keys));

static int CountInStock(Dictionary<string, int> stock, params ReadOnlySpan<string> isbns)
{
    int count = 0;
    foreach (string isbn in isbns)
    {
        if (stock.TryGetValue(isbn, out int quantity) && quantity > 0)
            count++;
    }
    return count;
}

static string Describe(params IEnumerable<string> items) => string.Join(" | ", items);
1
0
Clean Code | Refactoring
978-0132350884 | 978-0135957059 | 978-0321125217

params ReadOnlySpan<string> ile çağrıdaki argümanlar heap'te bir array oluşturmadan method'a geçebiliyor. params IEnumerable<string> ise hem tek tek argüman hem de elindeki herhangi bir sequence'ı kabul ediyor; stock.Keys'i olduğu gibi verdik. Aynı dosya LangVersion 12 ile derlenmiyor:

Program.cs(13,56): error CS9202: Feature 'params collections' is not available in C# 12.0. Please use language version 13.0 or greater.
Program.cs(24,24): error CS9202: Feature 'params collections' is not available in C# 12.0. Please use language version 13.0 or greater.

Bu özellik sadece senin method'larını ilgilendirmiyor. .NET 9 BCL'ye string.Join, string.Concat, Task.WhenAll ve Task.WhenAny gibi sık kullanılan method'ların ReadOnlySpan alan overload'larını ekledi. Var olan string.Join(", ", a, b, c) çağrın, projeni net9.0'a yükseltip yeniden derlediğinde kod değişmeden array ayırmayan overload'a bağlanır.

C# 14: Extension Members ve Span Dönüşümleri

.NET 10 ile gelen C# 14'te collection'larla çalışırken işine yarayacak üç değişiklik var. Birincisi extension members: artık sadece extension method değil, extension property, static extension member ve operatör de yazabiliyorsun. İkincisi span'ler için yeni implicit dönüşümler; array'i, ReadOnlySpan<T> alan bir extension method'un receiver'ı olarak kullanabiliyorsun. Üçüncüsü nameof'un bağlanmamış generic tiplerle çalışması:

decimal[] prices = [37.90m, 42.50m, 54.00m];
List<string> wishlist = [];

Console.WriteLine(prices.CountAbove(40m));
Console.WriteLine(wishlist.IsEmpty);
Console.WriteLine($"{nameof(List<>)}, {nameof(Dictionary<,>)}");

static class BookstoreExtensions
{
    extension<T>(IReadOnlyCollection<T> source)
    {
        public bool IsEmpty => source.Count == 0;
    }

    public static int CountAbove(this ReadOnlySpan<decimal> values, decimal limit)
    {
        int count = 0;
        foreach (decimal value in values)
        {
            if (value > limit)
                count++;
        }
        return count;
    }
}
2
True
List, Dictionary

extension<T>(IReadOnlyCollection<T> source) bloğu, içindeki member'ları bu tipe ait gibi gösterir; wishlist.IsEmpty bir property gibi okunuyor. C# 13'te extension property yazmanın yolu yoktu, IsEmpty() diye bir method yazardın. Aynı dosya LangVersion 13 ile şu hatayı veriyor:

Program.cs(10,5): error CS9260: Feature 'extensions' is not available in C# 13.0. Please use language version 14.0 or greater.

Span tarafı daha sessiz bir değişiklik. Extension block'u çıkarıp sadece CountAbove'u bıraktığımda, C# 13 ile derleme şu hatayı verdi:

Program.cs(2,19): error CS1929: 'decimal[]' does not contain a definition for 'CountAbove' and the best extension method overload 'BookstoreExtensions.CountAbove(ReadOnlySpan<decimal>, decimal)' requires a receiver of type 'System.ReadOnlySpan<decimal>'

C# 13'te array'den span'e dönüşüm bir kullanıcı tanımlı dönüşümdü ve extension method receiver'larında dikkate alınmıyordu; prices.AsSpan().CountAbove(40m) yazman gerekiyordu. C# 14 bu dönüşümü dile aldı. Bunun bir yan etkisi de var: C# 14 bazı çağrılarda span alan overload'ları öncelikli seçer. .NET 10 bunu dengelemek için Enumerable.Reverse(T[]) gibi overload'lar ekledi; bu kombinasyonu .NET 10 öncesi bir hedefte zorla kullanırsan beklemediğin bir overload'a bağlanabilirsin.

Yükseltme ve Destek Tarihleri

.NET 9 bir STS sürümü ve desteği 10 Kasım 2026'da bitiyor. Bu yazıdaki .NET 9 API'lerinin hepsi .NET 10'da da var; .NET 10 LTS ve 14 Kasım 2028'e kadar destekli. Önceki yazıda söylediğimiz kural burada da geçerli: dil özellikleri LangVersion'a (net9.0 için varsayılan C# 13, net10.0 için C# 14), API'ler TargetFramework'e bağlı ve en temiz yükseltme TFM'i değiştirip LangVersion'ı varsayılanda bırakmak.

Seri burada bitmiyor. .NET 11 ve C# 15'in 10 Kasım 2026'da çıkması planlanıyor; Microsoft'un "What's new in C# 15" sayfasında collection expression'lara argüman verebilmek ([with(capacity: n), .. values] gibi) şimdiden yer alıyor. Sürüm çıktığında bu seriye yeni bir yazı ekleyeceğiz.

Sık Düşülen Tuzaklar

Dictionary sırasına güvenmek. Testte çalışan sıra, ilk silmeden sonra değişir. Sıra bir gereksinimse OrderedDictionary<TKey, TValue> kullan, sadece key'e göre sıralı bir çıktı istiyorsan SortedDictionary ya da çıktıyı yazarken sırala.

OrderedDictionary'yi ortadan yoğun şekilde değiştirmek. Insert, RemoveAt ve key ile Remove arkadaki element'leri kaydırır. Binlerce element'li bir yapıda sık sık baştan siliyorsan maliyet büyür.

params ReadOnlySpan<T>'yi bir lambda içinde kullanmak. Span bir ref struct'tır, heap'e taşınamaz ve lambda'lara, async method'lara ya da iterator'lere giremez:

Console.WriteLine(FindFirst(new Dictionary<string, int>(), "978-0132350884"));

static string? FindFirst(Dictionary<string, int> stock, params ReadOnlySpan<string> isbns)
{
    Func<string?> first = () => isbns.Length > 0 ? isbns[0] : null;
    return first();
}
Program.cs(5,33): error CS9108: Cannot use parameter 'isbns' that has ref-like type inside an anonymous method, lambda expression, query expression, or local function
Program.cs(5,52): error CS9108: Cannot use parameter 'isbns' that has ref-like type inside an anonymous method, lambda expression, query expression, or local function

Değeri lambda'da kullanman gerekiyorsa önce isbns[0]'ı yerel bir değişkene al, ya da parametreyi params IEnumerable<string> yap.

Her comparer'ın alternate lookup desteklediğini sanmak. Kendi yazdığın bir IEqualityComparer<string> span ile aramayı bilmez:

var ignoreCase = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["Clean Code"] = 3 };
Console.WriteLine(ignoreCase.TryGetAlternateLookup<ReadOnlySpan<char>>(out _));

var custom = new Dictionary<string, int>(new IsbnComparer()) { ["978-0132350884"] = 3 };
Console.WriteLine(custom.TryGetAlternateLookup<ReadOnlySpan<char>>(out _));

try
{
    custom.GetAlternateLookup<ReadOnlySpan<char>>();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

class IsbnComparer : IEqualityComparer<string>
{
    public bool Equals(string? x, string? y) => string.Equals(x?.Replace("-", ""), y?.Replace("-", ""));
    public int GetHashCode(string obj) => obj.Replace("-", "").GetHashCode();
}
True
False
The collection's comparer does not support the requested operation.

Comparer'ı dışarıdan geliyorsa TryGetAlternateLookup kullan; kendi comparer'ında span desteği istiyorsan IAlternateEqualityComparer<ReadOnlySpan<char>, string>'yi de implemente et.

Küçük Bir Deneme

★☆☆ Tahmin sorusundaki programı OrderedDictionary<string, int> ile çalıştır, sonra silinen kitabı Insert(0, ...) ile tekrar başa koy ve sırayı yazdır.

★★☆ Bir sipariş queue'sunda müşteri VIP olduğunda siparişin önceliğini PriorityQueue.Remove ile güncelleyen bir Promote(string orderId) method'u yaz. Sipariş queue'da yoksa ne yapmalı?

★★★ IsbnComparer'a IAlternateEqualityComparer<ReadOnlySpan<char>, string> implementasyonu ekle; tireleri yok sayan Equals ve GetHashCode'u span üzerinde, string oluşturmadan yaz ve TryGetAlternateLookup'ın artık true döndüğünü gör.

C# Koleksiyonlar ve Generics serisi bu yazıyla tamamlandı. Collection'ları kurmayı, seçmeyi ve dolaşmayı artık biliyorsun; sırada onları sorgulamak var. Bir sonraki seri LINQ: Where, Select, GroupBy ve diğer operatörlerin arkasında bu serideki IEnumerable<T>, yield return ve deferred execution'ın nasıl çalıştığını göreceksin.