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

C# 11–12 ve .NET 7–8: Generic Math, Frozen ve Collection Expressions

Mert Özen 7 Kas 2026 12 dk 1 görüntülenme
C# 11–12 ve .NET 7–8: Generic Math, Frozen ve Collection Expressions

C# 10'da yazılamayan generic toplama, static abstract member'lar ve INumber<T> ile generic math; bir kez kurulup çok okunan FrozenDictionary ve FrozenSet'in BenchmarkDotNet ölçümü; köşeli parantezli collection expression'lar, spread ve CollectionBuilder.

Sürüm notu: Bu yazı C# 11–12 ve .NET 7–8 ile gelen generics ve collection yeniliklerini anlatır; generic math örnekleri C# 11 / .NET 7, Frozen ve collection expression örnekleri C# 12 / .NET 8 hedefiyle .NET 10 SDK'da derlenip denenmiştir.

Önce Tahmin Et

C# 12 ile collection'ları köşeli parantezle yazabiliyoruz. Aşağıdaki program derlenir. Sence son üç satır hangi runtime tiplerini yazdırır? Özellikle IEnumerable<int> ve IList<int> gibi interface'lere atanan expression'ların arkasında hangi class duruyor?

int[] prices = [40, 45, 50];
List<int> stocks = [.. prices, 7];
IEnumerable<int> none = [];
IEnumerable<int> some = [1, 2, 3];
IList<int> editable = [1, 2, 3];

Console.WriteLine(stocks.Count);
Console.WriteLine(none.GetType().Name);
Console.WriteLine(some.GetType().Name);
Console.WriteLine(editable.GetType().Name);

Collection Expressions (C# 12)

Çıktı:

4
Int32[]
<>z__ReadOnlyArray`1
List`1

Aynı [...] yazımı, hedef tipe göre farklı bir collection üretiyor. Hedef array ise array, List<int> ise liste oluşturuluyor. Hedef bir interface olduğunda seçimi derleyici yapıyor: boş bir IEnumerable<int> için paylaşılan boş array'i kullanıyor, dolu bir IEnumerable<int> için derleyicinin kendi ürettiği, dışarıdan değiştirilemeyen <>z__ReadOnlyArray tipini koyuyor, mutable bir contract olan IList<int> için de bir List<int> veriyor. Kodu yazarken bunu düşünmen gerekmiyor; ama sonuçlarından biri tuzaklar bölümünde karşımıza çıkacak.

C# 10 ve .NET 6'da collection'ları her tip için ayrı bir yazımla kuruyorduk: array için new[] { 3, 0, 5 }, liste için collection initializer ile new() { "Clean Code", "Refactoring" }, immutable array için ImmutableArray.Create(...), iki collection'ı birleştirmek için new List<string>(titles) { "Domain-Driven Design" } ya da AddRange. C# 12 bunların hepsini tek bir yazıma indiriyor:

using System.Collections.Immutable;

int[] stocks = [3, 0, 5];
List<string> titles = ["Clean Code", "Refactoring"];
List<string> reading = [.. titles, "Domain-Driven Design"];
ImmutableArray<string> shelf = ["The Pragmatic Programmer", .. titles];
Span<int> buffer = [1, 2, 3];
string[] none = [];

Console.WriteLine(string.Join(", ", reading));
Console.WriteLine($"{shelf.Length} on the shelf, first: {shelf[0]}");
Console.WriteLine($"{stocks.Length + buffer.Length + none.Length} numbers");
Clean Code, Refactoring, Domain-Driven Design
3 on the shelf, first: The Pragmatic Programmer
6 numbers

.. spread element'i başka bir collection'ın element'lerini olduğu yere açar; listenin başına, ortasına ya da sonuna koyabilirsin. [] hedef tipin boş hâlidir. Span<int>'e atanan expression, C# 10'da stackalloc ile yazdığın şeyin yerini alır. ImmutableArray<string> satırının derlenmesinin sebebi de bir sonraki başlıkta göreceğimiz attribute.

Aynı dosyayı LangVersion 10 ile derlediğimde derleyici her köşeli parantez için aynı hatayı verdi; ilk satırı şu:

Program.cs(3,16): error CS8936: Feature 'collection expressions' is not available in C# 10.0. Please use language version 12.0 or greater.

Kendi Tipin İçin CollectionBuilder

.NET 8 ile gelen CollectionBuilderAttribute, kendi collection tipinin de köşeli parantezle oluşturulabilmesini sağlar. Derleyiciye "bu tipi oluşturmak için şu static method'u çağır, element'leri de ReadOnlySpan<T> olarak ver" dersin:

using System.Collections;
using System.Runtime.CompilerServices;

BookShelf shelf = ["Clean Code", "Refactoring", "Domain-Driven Design"];
Console.WriteLine($"{shelf.Count} books, first: {shelf.First()}");

[CollectionBuilder(typeof(BookShelf), nameof(Create))]
class BookShelf : IEnumerable<string>
{
    private readonly string[] _titles;

    private BookShelf(string[] titles) => _titles = titles;

    public int Count => _titles.Length;

    public static BookShelf Create(ReadOnlySpan<string> titles) => new(titles.ToArray());

    public IEnumerator<string> GetEnumerator() => ((IEnumerable<string>)_titles).GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
3 books, first: Clean Code

Derleyici element'leri bir span'e koyup BookShelf.Create'i çağırdı. ImmutableArray<T> ve diğer immutable tipler .NET 8'de bu attribute ile işaretlendiği için onları da köşeli parantezle kurabiliyoruz.

C# 10'da Generic Toplama Neden Yazılamıyordu

Serinin başında generic method'ları ve constraint'leri görmüştük. Bir sipariş satırındaki adetleri, fiyatları ya da indirim oranlarını toplayan tek bir generic method yazmak istesen C# 10 seni durdururdu:

Console.WriteLine(Sum(new[] { 3, 0, 5 }));

static T Sum<T>(IEnumerable<T> values)
{
    T total = default!;
    foreach (T value in values)
        total += value;
    return total;
}
Program.cs(7,9): error CS0019: Operator '+=' cannot be applied to operands of type 'T' and 'T'

Derleyici T hakkında hiçbir şey bilmediği için +'yı çözemiyor. C# 10'da bunu ifade edecek bir constraint de yoktu: interface'ler instance method'ları tarif edebiliyordu ama operatörler ve Zero gibi değerler static'tir. Bu yüzden ya her tip için ayrı overload yazardın ya da toplama işini bir Func<T, T, T> olarak dışarıdan isterdin. LINQ'in Sum'ının int, long, float, double, decimal ve bunların nullable hâlleri için ayrı ayrı overload'ları olmasının sebebi bu.

Generic Math (C# 11 / .NET 7)

C# 11, interface'lere static abstract member yazabilmeyi getirdi. .NET 7 de bunun üstüne System.Numerics namespace'inde bir interface ailesi kurdu: INumber<T>, IAdditionOperators<TSelf, TOther, TResult>, IAdditiveIdentity<TSelf, TResult> ve daha fazlası. int, decimal, double gibi built-in tipler bu interface'leri implemente eder. Aynı method artık yazılabiliyor:

using System.Globalization;
using System.Numerics;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Console.WriteLine(Sum(new[] { 3, 0, 5 }));
Console.WriteLine(Sum(new[] { 37.90m, 42.50m }));
Console.WriteLine(Sum(new[] { 1.5, 2.25 }));

static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (T value in values)
        total += value;
    return total;
}
8
80.40
3.75

T.Zero satırına dikkat: bir instance üzerinden değil, tip parametresinin kendisi üzerinden static bir member'a erişiyoruz. where T : INumber<T> constraint'i, T olarak gelen her tipin Zero property'sine ve + operatörüne sahip olduğunu garanti ediyor ve derleyici her çağrıda doğru implementasyonu buluyor.

static abstract ile Kendi Contract'ın

INumber<T> geniş bir contract: karşılaştırma, çarpma, bölme, parse ve daha fazlasını ister. Para tutarı gibi kendi tipin için hepsine ihtiyacın yok. İhtiyacın olan tek yetenek toplamaksa, constraint'i sadece onu isteyecek şekilde daraltırsın. Aynı mekanizmayı kendi interface'lerinde de kullanabilirsin:

using System.Globalization;
using System.Numerics;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Console.WriteLine(Total(new[] { new Money(37.90m), new Money(42.50m) }));
Console.WriteLine(Total(new[] { 3, 0, 5 }));
Console.WriteLine(Label<EBook>());

static T Total<T>(IEnumerable<T> values)
    where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
    T total = T.AdditiveIdentity;
    foreach (T value in values)
        total += value;
    return total;
}

static string Label<T>() where T : ICatalogItem => $"{typeof(T).Name}: {T.Category}";

interface ICatalogItem
{
    static abstract string Category { get; }
}

record EBook(string Isbn, string Title) : ICatalogItem
{
    public static string Category => "Digital";
}

readonly record struct Money(decimal Amount)
    : IAdditionOperators<Money, Money, Money>, IAdditiveIdentity<Money, Money>
{
    public static Money AdditiveIdentity => new(0m);

    public static Money operator +(Money left, Money right) => new(left.Amount + right.Amount);
}
Money { Amount = 80.40 }
8
EBook: Digital

Total artık hem Money hem de int için çalışıyor, çünkü ikisi de toplama operatörünü ve toplamanın etkisiz elemanını sağlıyor. ICatalogItem'daki static abstract string Category ise instance gerektirmeyen bir contract: Label<EBook>() hiç EBook oluşturmadan kategorisini okuyor. C# 10'da bunu reflection ya da her tip için ayrı bir dictionary girdisiyle yapardın.

Aynı dosyayı net7.0 hedefinde ama LangVersion 10 ile derlersen API'ler yerinde olsa da dil özelliği yoktur. Çıktıdan iki satır:

Program.cs(23,28): error CS8703: The modifier 'abstract' is not valid for this item in C# 10.0. Please use language version '11.0' or greater.
Program.cs(36,34): error CS8706: 'Money.operator +(Money, Money)' cannot implement interface member 'IAdditionOperators<Money, Money, Money>.operator +(Money, Money)' in type 'Money' because feature 'static abstract members in interfaces' is not available in C# 10.0. Please use language version '11.0' or greater.

Frozen Collection'lar (.NET 8)

Uygulamanın açılışında yüklenen ve ömrü boyunca hiç değişmeyen veriler çok yaygın: ülke kodları, kategori ağacı, fiyat listesi, izinli ISBN önekleri. C# 10/.NET 6'da bunları ya bir Dictionary ile tutup kimsenin dokunmamasını umardın ya da ImmutableDictionary kullanırdın. İkincisi immutability verir ama okuma tarafında O(log n) öder; önceki yazıda bunu karmaşıklık tablosunda görmüştük. .NET 8, System.Collections.Frozen namespace'inde üçüncü bir yol getirdi: FrozenDictionary<TKey, TValue> ve FrozenSet<T>.

using System.Collections.Frozen;
using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var source = new Dictionary<string, decimal>
{
    ["978-0132350884"] = 37.90m,
    ["978-0135957059"] = 42.50m,
    ["978-0321125217"] = 54.00m,
};

FrozenDictionary<string, decimal> prices = source.ToFrozenDictionary();
FrozenSet<string> bestsellers = new[] { "978-0132350884", "978-0321125217" }.ToFrozenSet();

source["978-0132350884"] = 29.90m;

Console.WriteLine(prices["978-0132350884"]);
Console.WriteLine(bestsellers.Contains("978-0135957059"));
Console.WriteLine(prices.GetType().Name);

try
{
    ((IDictionary<string, decimal>)prices).Add("978-1617294532", 39.99m);
}
catch (NotSupportedException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
37.90
False
LengthBucketsFrozenDictionary`1
NotSupportedException: Specified method is not supported.

ToFrozenDictionary() kaynağın o anki kopyasını alır; kaynak dictionary'yi sonradan değiştirdik ama dondurulmuş fiyat yerinde kaldı. Frozen bir collection'da ImmutableDictionary'deki gibi "değişmiş kopyayı döndüren" Add ya da SetItem yoktur; bir kez kurulur ve sonra yalnızca okunur. IDictionary<TKey, TValue>'yi implemente ettiği için değiştiren method'lar interface üzerinden görünür ama NotSupportedException atar.

Asıl fark oluşturma anında. ToFrozenDictionary() key'lere bakıp duruma göre bir implementasyon seçer; çıktıdaki LengthBucketsFrozenDictionary adı, bu küçük dictionary'de key'lerin uzunluklarına göre bir düzen kurulduğunu söylüyor. Bu analiz oluşturmayı pahalılaştırır, karşılığında okumayı hızlandırır.

Ölçüm: Okuma ve Oluşturma

1.000 ISBN'lik bir fiyat listesinde bütün key'leri bir kez aramayı ve aynı listeyi oluşturmayı üç tiple ölçtük:

using System.Collections.Frozen;
using System.Collections.Immutable;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run([typeof(LookupBenchmarks), typeof(BuildBenchmarks)]);

public static class Catalog
{
    public static KeyValuePair<string, decimal>[] Create(int count) =>
        Enumerable.Range(0, count)
            .Select(i => KeyValuePair.Create($"978-{i:D9}", 10m + i % 50))
            .ToArray();
}

[InProcess]
[MemoryDiagnoser]
public class LookupBenchmarks
{
    private string[] _keys = null!;
    private Dictionary<string, decimal> _dictionary = null!;
    private FrozenDictionary<string, decimal> _frozen = null!;
    private ImmutableDictionary<string, decimal> _immutable = null!;

    [GlobalSetup]
    public void Setup()
    {
        KeyValuePair<string, decimal>[] items = Catalog.Create(1_000);
        _keys = items.Select(item => item.Key).ToArray();
        _dictionary = new Dictionary<string, decimal>(items);
        _frozen = items.ToFrozenDictionary();
        _immutable = items.ToImmutableDictionary();
    }

    [Benchmark(Baseline = true)]
    public decimal Dictionary()
    {
        decimal sum = 0;
        foreach (string key in _keys)
            if (_dictionary.TryGetValue(key, out decimal price))
                sum += price;
        return sum;
    }

    [Benchmark]
    public decimal Frozen()
    {
        decimal sum = 0;
        foreach (string key in _keys)
            if (_frozen.TryGetValue(key, out decimal price))
                sum += price;
        return sum;
    }

    [Benchmark]
    public decimal Immutable()
    {
        decimal sum = 0;
        foreach (string key in _keys)
            if (_immutable.TryGetValue(key, out decimal price))
                sum += price;
        return sum;
    }
}

[InProcess]
[MemoryDiagnoser]
public class BuildBenchmarks
{
    private KeyValuePair<string, decimal>[] _items = null!;

    [GlobalSetup]
    public void Setup() => _items = Catalog.Create(1_000);

    [Benchmark(Baseline = true)]
    public Dictionary<string, decimal> BuildDictionary() => new(_items);

    [Benchmark]
    public FrozenDictionary<string, decimal> BuildFrozen() => _items.ToFrozenDictionary();

    [Benchmark]
    public ImmutableDictionary<string, decimal> BuildImmutable() => _items.ToImmutableDictionary();
}
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

| Method     | Mean      | Error     | StdDev    | Ratio | RatioSD | Allocated | Alloc Ratio |
|----------- |----------:|----------:|----------:|------:|--------:|----------:|------------:|
| Dictionary |  9.134 us | 0.1736 us | 0.1782 us |  1.00 |    0.03 |         - |          NA |
| Frozen     |  5.765 us | 0.0153 us | 0.0128 us |  0.63 |    0.01 |         - |          NA |
| Immutable  | 14.058 us | 0.0638 us | 0.0498 us |  1.54 |    0.03 |         - |          NA |

| Method          | Mean      | Error     | StdDev    | Ratio | RatioSD | Gen0    | Gen1   | Allocated | Alloc Ratio |
|---------------- |----------:|----------:|----------:|------:|--------:|--------:|-------:|----------:|------------:|
| BuildDictionary |  8.746 us | 0.1286 us | 0.1203 us |  1.00 |    0.02 |  4.7455 |      - |  38.91 KB |        1.00 |
| BuildFrozen     | 55.791 us | 0.8955 us | 0.8376 us |  6.38 |    0.13 | 29.3579 | 5.6763 | 240.19 KB |        6.17 |
| BuildImmutable  | 77.894 us | 0.5185 us | 0.4597 us |  8.91 |    0.13 |  8.5449 | 1.4648 |  70.38 KB |        1.81 |

Proje net8.0 hedefli, Release modda ve .NET 8.0.25 runtime'ında in-process çalıştı. BenchmarkDotNet paketinin bağımlılığı olarak System.Collections.Immutable 9.0.0 geldiği için Frozen ve immutable tipler o paketin implementasyonundan ölçüldü; .NET 8'in kendi kopyasıyla sayılar biraz farklı olabilir. Okuma tarafında FrozenDictionary, 1.000 aramayı Dictionary'nin yaklaşık üçte iki süresinde (5,77 µs'ye karşı 9,13 µs) bitirdi; ImmutableDictionary ise yüzde 54 daha yavaş kaldı. Oluşturma tarafında tablo tersine dönüyor: aynı 1.000 girdiyi dondurmak bir Dictionary kurmaktan 6,4 kat uzun sürdü ve 6 kat fazla bellek ayırdı.

Karar kuralı bundan çıkıyor: Frozen collection'ı bir kez kurup çok sayıda okuma yapacağın yerlerde kullan, tipik olarak uygulama açılışında oluşturulup singleton olarak tutulan verilerde. Her istekte yeniden oluşturulan bir dictionary'yi dondurmak, kazancı oluşturma maliyetinde kaybetmek demek.

Yükseltirken Bilmen Gerekenler

Bu özelliklerin iki ayağı var ve ikisi de gerekli. Collection expression, static abstract member'lar ve spread dil özellikleridir, LangVersion'a bağlıdır. INumber<T>, FrozenDictionary ve CollectionBuilderAttribute ise library API'leridir, TargetFramework'e bağlıdır. SDK, LangVersion'ı varsayılan olarak hedef framework'ten seçer: net7.0 için C# 11, net8.0 için C# 12. Microsoft, hedef framework'ün varsayılanından yeni bir dil sürümü kullanmayı desteklemediğini açıkça söylüyor; bu yüzden çoğu projede LangVersion'ı hiç yazmayıp TFM'i yükseltmek en temiz yol.

Destek tarihlerini de 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ışır; yeni bir projeye başlıyorsan 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

var ile collection expression yazmak. Köşeli parantezin tek başına bir tipi yoktur, tipi hedeften alır:

var titles = ["Clean Code", "Refactoring"];
Program.cs(1,14): error CS9176: There is no target type for the collection expression.

Değişkenin tipini açıkça yaz: string[] titles = [...] ya da List<string> titles = [...].

Interface'e atanan collection expression'ı somut tipe cast etmek. Tahmin sorusunda gördüğümüz gibi read-only interface'lerin arkasında derleyicinin ürettiği bir tip olabilir:

IReadOnlyList<string> titles = ["Clean Code", "Refactoring"];

try
{
    var list = (List<string>)titles;
}
catch (InvalidCastException)
{
    Console.WriteLine($"Not a List<string>: {titles.GetType().Name}");
}
Not a List<string>: <>z__ReadOnlyArray`1

Liste lazımsa hedef tipi List<string> yap; bir interface'e atadıysan da o interface'in contract'ıyla yetin.

Constraint'i fazla geniş tutmak. where T : INumber<T> yazdığında kendi Money tipin o method'u kullanamaz, çünkü INumber<T>'nin istediği her şeyi implemente etmiyor:

using System.Numerics;

Console.WriteLine(Sum(new[] { new Money(37.90m), new Money(42.50m) }));

static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (T value in values)
        total += value;
    return total;
}

readonly record struct Money(decimal Amount);
Program.cs(3,19): error CS0315: The type 'Money' cannot be used as type parameter 'T' in the generic type or method 'Sum<T>(IEnumerable<T>)'. There is no boxing conversion from 'Money' to 'System.Numerics.INumber<Money>'.

Gerçekten ihtiyacın olan yeteneği iste: toplamak için IAdditionOperators ve IAdditiveIdentity yeterli.

Frozen collection'ı sık oluşturmak. Oluşturma maliyeti ölçümde gördüğümüz gibi normal bir dictionary'nin katlarıdır. Kısa ömürlü ya da sık değişen veride Dictionary kullanmaya devam et.

Küçük Bir Deneme

★☆☆ List<string> all = [.. fiction, .. technical, "Clean Code"]; satırıyla iki listeyi ve bir kitabı birleştir; aynı işi C# 10'da nasıl yazacağını da yaz ve iki satırı karşılaştır.

★★☆ static T Max<T>(IEnumerable<T> values) where T : INumber<T> method'unu yaz. Boş sequence'ta ne yapmalı? T.Zero döndürmek mi, exception atmak mı? Kararını gerekçelendir.

★★★ ISBN önekinden yayınevi adına giden bir FrozenDictionary<string, string> kur ve 10.000 arama için Dictionary ile BenchmarkDotNet'te karşılaştır. Key sayısını 10'dan 100.000'e çıkardıkça oluşturma süresi nasıl değişiyor?

Bir sonraki yazıda C# 13–14 ve .NET 9–10'a geçiyoruz: ekleme sırasını koruyan generic OrderedDictionary<TKey, TValue>, params'ın array dışındaki collection'larla çalışması, string ayırmadan dictionary'de arama yapmayı sağlayan alternate lookup ve .NET 10'un collection'lara getirdiği küçük ama kullanışlı eklemeler.