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

C# Collection Interface'leri: IEnumerable, ICollection, IList ve yield return

Mert Özen 7 Kas 2026 11 dk 1 görüntülenme
C# Collection Interface'leri: IEnumerable, ICollection, IList ve yield return

IEnumerable, ICollection, IList ve IReadOnly* interface'leri neyi vaat eder, neyi etmez; array'in Add'de neden patladığı, iç listeyi güvenle dışarı açmak, parametre tipi seçmek ve yield return ile lazy iterator'ler yazmak.

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

Aşağıdaki programda LoadBooks bir local function ve gövdesinde yield return var. Sonra LINQ'in Count() method'unu iki kez çağırıyoruz. Ekranda hangi satırlar, hangi sırayla çıkar? "Loading books..." kaç kez yazılır?

IEnumerable<Book> LoadBooks()
{
    Console.WriteLine("Loading books...");
    yield return new Book("978-0135957059", "The Pragmatic Programmer", 3);
    yield return new Book("978-0132350884", "Clean Code", 0);
}

IEnumerable<Book> books = LoadBooks();
Console.WriteLine("Query is ready");
Console.WriteLine(books.Count());
Console.WriteLine(books.Count());

record Book(string Isbn, string Title, int Stock);

Bir Collection'ın Contract'ları

Çıktı şu:

Query is ready
Loading books...
2
Loading books...
2

"Query is ready" satırı "Loading books..."'tan önce geliyor ve kitaplar iki kez yükleniyor. Sebebi, LoadBooks() çağrısının method'un gövdesini hiç çalıştırmaması. Çağrı sana sadece bir IEnumerable<Book> verir; gövde, biri o sequence'ı dolaşmaya başladığında çalışır. Her Count() yeni bir dolaşma başlattığı için gövde de baştan çalışır. Bunun nasıl olduğuna yazının ikinci yarısında bakacağız. Önce elimizdeki collection'ların hangi contract'lara uyduğunu görelim.

Önceki yazılarda List<T>, Dictionary<TKey, TValue>, Queue<T> gibi somut class'larla çalıştık. Bu class'ların hepsi System.Collections.Generic namespace'indeki birkaç interface'i implemente eder. List<string>'e bunu reflection ile sorduk:

static string Name(Type type) =>
    type.IsGenericType
        ? $"{type.Name[..type.Name.IndexOf('`')]}<{string.Join(", ", type.GetGenericArguments().Select(Name))}>"
        : type.Name;

foreach (Type contract in typeof(List<string>).GetInterfaces())
{
    Console.WriteLine(Name(contract));
}
IList<String>
ICollection<String>
IEnumerable<String>
IEnumerable
IList
ICollection
IReadOnlyList<String>
IReadOnlyCollection<String>

Generic olmayan IEnumerable, IList ve ICollection generics öncesinden kalma; geriye dönük uyumluluk için oradalar ve yeni kodda onlara ihtiyacın olmaz. Generic olanlar iki aile oluşturuyor. Birincisi mutable aile: IEnumerable<T> → ICollection<T> → IList<T>. İkincisi read-only aile: IEnumerable<T> → IReadOnlyCollection<T> → IReadOnlyList<T>. Dictionary tarafında da IDictionary<TKey, TValue> ve IReadOnlyDictionary<TKey, TValue> var.

Bunu bir binanın katları gibi düşün. Zemin katta yalnızca "baştan sona yürüyebilirsin" yazıyor. Bir üst katta "kaç kişi olduğunu söylerim, ekleyip çıkarabilirsin" var. En üst katta "istediğin odaya numarasıyla girebilirsin". Karşı tarafa hangi katın anahtarını verirsen o kadarını yapabilir.

07-collection-interfaces-tr.png

IEnumerable<T>: Sadece Dolaşmak

IEnumerable<T>'nin tek method'u var: GetEnumerator(). Dönen IEnumerator<T> bir imleç gibi çalışır. MoveNext() bir sonraki element'e geçer ve geçebildiyse true döner, Current o anki element'i verir, Dispose() da iş bitince kaynakları bırakır. foreach bu üçlünün kısaltmasıdır. Derleyicinin bir foreach'i açtığında ürettiğine çok yakın bir kod şu:

var titles = new List<string> { "Clean Code", "Refactoring", "Domain-Driven Design" };

using (IEnumerator<string> enumerator = ((IEnumerable<string>)titles).GetEnumerator())
{
    while (enumerator.MoveNext())
    {
        string title = enumerator.Current;
        Console.WriteLine(title);
    }
}
Clean Code
Refactoring
Domain-Driven Design

Burada listeyi bilerek IEnumerable<string>'e çevirip enumerator'ü interface üzerinden aldık. foreach'i doğrudan bir List<T> üzerinde yazdığında derleyici List<T>.Enumerator adlı struct'ı kullanır ve heap'te enumerator object'i oluşmaz. Aynı listeyi IEnumerable<T> olarak alan bir method'da ise enumerator interface üzerinden gelir ve struct boxing'e uğrar. Çoğu kodda bu fark ölçülmeye değmez, ama sıcak döngülerde somut tiple dolaşmanın neden biraz daha ucuz olduğunu açıklar.

IEnumerable<T>'nin söz vermediği şeyler de en az verdikleri kadar önemli. Kaç element olduğunu bilmez, index ile erişim sunmaz ve iki kez dolaştığında aynı sonucu vereceğini garanti etmez. Tahmin sorusundaki iki "Loading books..." satırı bu son maddenin sonucu.

ICollection<T> ve IList<T>

ICollection<T> dolaşmanın üstüne Count, Add, Remove, Clear, Contains, CopyTo ve IsReadOnly ekler. IList<T> buna sıra ve index kavramını getirir: indexer (list[0]), IndexOf, Insert ve RemoveAt. List<T> ikisini de eksiksiz implemente eder. Şaşırtıcı olan array'ler:

IList<string> shelf = new[] { "Clean Code", "Refactoring" };

Console.WriteLine(shelf.IsReadOnly);
shelf[0] = "Clean Architecture";
Console.WriteLine(shelf[0]);

try
{
    shelf.Add("Design Patterns");
}
catch (NotSupportedException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
True
Clean Architecture
NotSupportedException: Collection was of a fixed size.

Array IList<T>'yi implemente ediyor ama boyutu sabit. IsReadOnly bu yüzden true dönüyor; indexer ile değer değiştirmek çalışıyor, Add ise derleniyor ama runtime'da NotSupportedException fırlatıyor. Interface "bu işlemi yapabilirim" demiyor, "bu işlemin adı burada" diyor. ReadOnlyCollection<T> da IList<T>'yi implemente eder ve değiştiren her çağrıda aynı exception'ı atar. Bir method'a IList<T> parametresi koyup içinde Add çağırıyorsan, birinin oraya array göndereceğini hesaba kat.

IReadOnly* ile Kontrollü Paylaşım

Read-only interface'lerin asıl kullanım yeri, bir class'ın iç durumunu dışarı açmak. Siparişin kalemlerini göstermek istiyorsun ama kimsenin AddItem'daki kuralı atlayıp listeyle oynamasını istemiyorsun:

var order = new Order(1001);
order.AddItem(new OrderItem("978-0132350884", 2));
order.AddItem(new OrderItem("978-0135957059", 1));

IReadOnlyList<OrderItem> items = order.Items;
Console.WriteLine($"Items: {items.Count}, first: {items[0].Isbn}");

if (items is List<OrderItem> backdoor)
{
    backdoor.Clear();
}
Console.WriteLine($"Items after the cast: {order.Items.Count}");

class Order
{
    private readonly List<OrderItem> _items = new();

    public Order(int id) => Id = id;

    public int Id { get; }
    public IReadOnlyList<OrderItem> Items => _items;

    public void AddItem(OrderItem item)
    {
        if (item.Quantity <= 0)
            throw new ArgumentOutOfRangeException(nameof(item), "Quantity must be positive.");
        _items.Add(item);
    }
}

record OrderItem(string Isbn, int Quantity);
Items: 2, first: 978-0132350884
Items after the cast: 0

Items property'si listeyi IReadOnlyList<OrderItem> olarak döndürüyor; çağıran taraf derleyici açısından Add ya da Clear göremiyor. Ama dönen object hâlâ aynı List<OrderItem> ve basit bir type pattern ile geri çevrilip temizlenebiliyor. Interface bir duvar değil, bir tabela. Kodu yazan ekip içindeyse tabela çoğu zaman yeter. Bir library yazıyorsan ya da bu verinin bozulması pahalıysa listeyi bir wrapper ile ver:

public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

Programın geri kalanı aynı kaldı ve sona Console.WriteLine(order.Items.GetType().Name); satırını ekledim. Bu kez çıktı:

Items: 2, first: 978-0132350884
Items after the cast: 2
ReadOnlyCollection`1

AsReadOnly(), listeyi kopyalamadan saran bir ReadOnlyCollection<T> döndürür. Type pattern artık tutmuyor, zorla cast denersen InvalidCastException alırsın. Yalnız şunu unutma: wrapper bir pencere, fotoğraf değil. Order içeride listeye yeni kalem eklerse, elinde Items tutan herkes o kalemi görür. Hiç değişmeyecek bir collection istiyorsan aradığın şey immutable collection'lar; onlar bir sonraki yazının konusu.

Parametre ve Dönüş Tipini Seçmek

Pratik kural şu: parametrede ihtiyacının en azını iste, dönüşte elindekinin en kullanışlısını ver. Sadece foreach ile dolaşacaksan IEnumerable<T> iste; çağıran sana array, liste, HashSet<T> ya da bir iterator verebilsin. Element sayısına ya da index'e ihtiyacın varsa bunu tipe yaz:

using System.Globalization;

var items = new List<OrderItem>
{
    new("978-0132350884", 2, 37.90m),
    new("978-0135957059", 1, 42.50m),
};

Console.WriteLine(Total(items).ToString(CultureInfo.InvariantCulture));
Console.WriteLine(Describe(items));

static decimal Total(IEnumerable<OrderItem> items)
{
    decimal total = 0;
    foreach (OrderItem item in items)
        total += item.Quantity * item.UnitPrice;
    return total;
}

static string Describe(IReadOnlyList<OrderItem> items) =>
    items.Count == 0 ? "Empty order" : $"{items.Count} lines, first: {items[0].Isbn}";

record OrderItem(string Isbn, int Quantity, decimal UnitPrice);
118.30
2 lines, first: 978-0132350884

Total yalnızca dolaşıyor, o yüzden IEnumerable<OrderItem> istiyor. Describe ise Count ve index kullanıyor; parametresi IReadOnlyList<OrderItem> olduğu için signature'ı "bana sayısı belli, tekrar tekrar dolaşılabilen bir şey ver" diyor. Aynı method IEnumerable<T> alsaydı, içeride sayıyı öğrenmek için ya kaynağı bir kez fazladan dolaşacak ya da kopyalayacaktı.

Dönüş tarafında, elinde zaten bir liste varken onu IEnumerable<T>'ye indirgemek çağıranı yoksullaştırır: sayıyı öğrenmek için dolaşmak ya da ToList() ile kopyalamak zorunda kalır. İç durumu açıyorsan IReadOnlyList<T>, yeni oluşturduğun bir sonucu veriyorsan List<T> ya da array döndürmek dürüst bir seçim. IEnumerable<T> dönüşünü gerçekten lazy (tembel) bir sequence ürettiğin yerlere sakla. Bu da bizi yield return'e getiriyor.

yield return ile Kendi Sequence'ını Üretmek

Bir method'un dönüş tipi IEnumerable<T> ya da IEnumerator<T> ise ve gövdesinde yield return varsa, derleyici o method'u bir iterator'e çevirir. Arka planda MoveNext() ve Current'ı implemente eden gizli bir class üretir. Method'un yerel değişkenleri bu class'ın field'larına taşınır ve her yield return bir "burada dur, kaldığın yeri hatırla" noktası olur. Kitaptaki ayraç gibi: her MoveNext() ayraçtan okumaya devam eder, bir sonraki yield return'e gelince ayracı oraya koyup durur.

var books = new List<Book>
{
    new("978-0135957059", "The Pragmatic Programmer", 3),
    new("978-0132350884", "Clean Code", 0),
    new("978-0321125217", "Domain-Driven Design", 5),
};

foreach (Book book in InStock(books))
{
    Console.WriteLine($"  got: {book.Title}");
}

foreach (int number in OrderNumbers(1001))
{
    if (number > 1003)
        break;
    Console.WriteLine($"Order #{number}");
}

static IEnumerable<Book> InStock(IEnumerable<Book> books)
{
    foreach (Book book in books)
    {
        Console.WriteLine($"checking: {book.Title}");
        if (book.Stock > 0)
            yield return book;
    }
}

static IEnumerable<int> OrderNumbers(int start)
{
    int current = start;
    while (true)
        yield return current++;
}

record Book(string Isbn, string Title, int Stock);
checking: The Pragmatic Programmer
  got: The Pragmatic Programmer
checking: Clean Code
checking: Domain-Driven Design
  got: Domain-Driven Design
Order #1001
Order #1002
Order #1003

Çıktıdaki sıra her şeyi anlatıyor. Iterator önce bütün kitapları kontrol edip sonra bir liste döndürmüyor. Bir kitap kontrol ediliyor, uygunsa hemen tüketiciye veriliyor, sonra bir sonrakine geçiliyor. InStock ara liste oluşturmadığı için milyonlarca kitaplık bir kaynakta da sabit bellekle çalışır. OrderNumbers ise hiç bitmeyen bir sequence. İçindeki while (true) programı kilitlemiyor, çünkü her sayı ancak istendiğinde üretiliyor ve tüketici break ile çıkınca üretim de duruyor. Sequence'ı gövdenin içinden erken bitirmek istersen yield break yazarsın.

Erken çıkmanın bir sonucu daha var. foreach bittiğinde ya da break ile kesildiğinde enumerator'ün Dispose()'u çağrılır ve iterator'deki finally blokları çalışır:

foreach (string line in ReadCatalog())
{
    Console.WriteLine(line);
    if (line == "Clean Code")
        break;
}

static IEnumerable<string> ReadCatalog()
{
    Console.WriteLine("catalog opened");
    try
    {
        yield return "The Pragmatic Programmer";
        yield return "Clean Code";
        yield return "Domain-Driven Design";
    }
    finally
    {
        Console.WriteLine("catalog closed");
    }
}
catalog opened
The Pragmatic Programmer
Clean Code
catalog closed

Üçüncü kitap hiç okunmadı ama "catalog closed" yine yazıldı. Dosya ya da veritabanı bağlantısı açan iterator'ler bu sayede kaynak sızdırmaz; yeter ki onları foreach ile ya da bir using içinde tüket.

Lazy Çalışmanın Faturası

Lazy evaluation bazen hatayı yanlış yere taşır. Aşağıdaki iterator geçersiz limiti reddediyor. Peki ne zaman?

var books = new List<Book> { new("978-0132350884", "Clean Code", 37.90m) };

IEnumerable<Book> query = CheaperThan(books, -5);
Console.WriteLine("Query created");

try
{
    foreach (Book book in query)
        Console.WriteLine(book.Title);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine($"Thrown during foreach: {ex.ParamName}");
}

static IEnumerable<Book> CheaperThan(IEnumerable<Book> books, decimal limit)
{
    if (limit <= 0)
        throw new ArgumentOutOfRangeException(nameof(limit));

    foreach (Book book in books)
    {
        if (book.Price < limit)
            yield return book;
    }
}

record Book(string Isbn, string Title, decimal Price);
Query created
Thrown during foreach: limit

Kontrol de gövdenin bir parçası olduğu için method çağrıldığında değil, ilk MoveNext()'te çalıştı. Gerçek bir projede sorgu bir katmanda oluşturulup başka bir katmanda dolaşılır; exception'ın stack trace'i seni hatanın yapıldığı satıra değil, dolaşıldığı satıra götürür. Çözüm, method'u ikiye bölmek. Dıştaki normal method argümanları hemen kontrol eder, içerideki local function iterator olur:

var books = new List<Book> { new("978-0132350884", "Clean Code", 37.90m) };

try
{
    IEnumerable<Book> query = CheaperThan(books, -5);
    Console.WriteLine("Query created");
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine($"Thrown immediately: {ex.ParamName}");
}

static IEnumerable<Book> CheaperThan(IEnumerable<Book> books, decimal limit)
{
    if (limit <= 0)
        throw new ArgumentOutOfRangeException(nameof(limit));

    return Iterate();

    IEnumerable<Book> Iterate()
    {
        foreach (Book book in books)
        {
            if (book.Price < limit)
                yield return book;
        }
    }
}

record Book(string Isbn, string Title, decimal Price);
Thrown immediately: limit

Dış method'da yield olmadığı için derleyici onu iterator'e çevirmez ve throw çağrı anında çalışır. .NET'in kendi LINQ operatörleri de argüman kontrolünü bu şekilde hemen yapar.

Iterator'lerin derleyicinin koyduğu kuralları da var. yield return, catch bloğu olan bir try'ın içinde yazılamaz; finally ile sorun yok. Iterator'ün ref, in ya da out parametresi olamaz (CS1623). Bozuk stok değerlerini atlamak için şunu yazarsan:

foreach (int stock in ParseStocks(new[] { "3", "x", "5" }))
    Console.WriteLine(stock);

static IEnumerable<int> ParseStocks(string[] parts)
{
    foreach (string part in parts)
    {
        try
        {
            yield return int.Parse(part);
        }
        catch (FormatException)
        {
        }
    }
}

derleyici durdurur:

Program.cs(10,13): error CS1626: Cannot yield a value in the body of a try block with a catch clause

En temiz çözüm exception'a hiç girmemek: if (int.TryParse(part, out int stock)) koşulunun içinde yield return stock; yazarsın ve program 3 ile 5'i basar. Dönüşüm ancak exception ile yapılabiliyorsa try/catch'i yield içermeyen yardımcı bir method'a taşı.

Sık Düşülen Tuzaklar

Aynı IEnumerable<T>'yi birkaç kez dolaşmak. Kaynak bir iterator ise her dolaşma işi baştan yapar: dosyayı yeniden okur, hesabı yeniden yapar. Birden fazla kullanacaksan bir kez ToList() ile materialize et ya da parametre tipini IReadOnlyCollection<T> yap. Bu konuya LINQ serisinde deferred execution'ı anlatırken geri döneceğiz.

Dolaşırken collection'ı değiştirmek. foreach içinde aynı listeden Remove çağırmak bir sonraki adımda exception ile biter:

var cart = new List<string> { "978-0132350884", "978-0135957059", "978-0321125217" };

try
{
    foreach (string isbn in cart)
    {
        if (isbn.EndsWith("4"))
            cart.Remove(isbn);
    }
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

cart.RemoveAll(isbn => isbn.EndsWith("9"));
Console.WriteLine(string.Join(", ", cart));
Collection was modified; enumeration operation may not execute.
978-0321125217

Liste her değişiklikte iç sürüm numarasını artırır, enumerator da her adımda bu numarayı kontrol eder. Koşula uyanları silmek için RemoveAll kullan ya da index ile sondan başa doğru dolaş.

IReadOnlyList<T>'yi immutable sanmak. Read-only interface yalnızca senin elindeki referansın yetkisini kısar. Asıl sahip listeyi değiştirmeye devam edebilir, dalgın bir çağıran da cast ile arka kapıdan girebilir.

Interface'teki her method'un çalışacağını varsaymak. ICollection<T>.Add array'de ve ReadOnlyCollection<T>'de NotSupportedException atar. Emin değilsen IsReadOnly'ye bak, daha iyisi daha dar bir interface iste.

Iterator'deki kontrolün anında çalıştığını sanmak. Argüman kontrolü ya da loglama gibi "hemen olmalı" dediğin işler iterator gövdesindeyse ilk MoveNext()'e kadar ertelenir.

Küçük Bir Deneme

★☆☆ Tahmin sorusundaki programda books'u LoadBooks().ToList() ile oluştur. "Loading books..." kaç kez yazılıyor, "Query is ready"'den önce mi sonra mı?

★★☆ IEnumerable<OrderItem> ExpensiveItems(IEnumerable<OrderItem> items, decimal minPrice) adında bir iterator yaz. Negatif minPrice'ı çağrı anında reddetsin, birim fiyatı eşiğin üstündeki kalemleri döndürsün.

★★★ yield kullanmadan, IEnumerable<int> ve IEnumerator<int>'i elle implemente eden bir OrderNumberSequence class'ı yaz; MoveNext, Current, Reset ve Dispose'u doldur. Sonra aynı işi yapan iterator ile yan yana koy ve derleyicinin senin yerine ne kadar kod yazdığına bak.

Bu yazıda read-only view'un immutability anlamına gelmediğini gördük. Bir sonraki yazıda System.Collections.Immutable'a geçiyoruz: gerçekten değişmeyen collection'lar nasıl çalışır, ne zaman işe yarar ve bir iş için doğru collection'ı seçerken karmaşıklık açısından hangi sorulara bakmalısın.