C# Generic Class, Generic Method ve Constraint'ler
Kendi generic class ve method'larını yazmak: Page<T> ile sayfalama, tip çıkarımının sınırları, default ve constraint'siz T? tuzağı. class, struct, new(), notnull, unmanaged ve Enum constraint'leri gerçek derleyici hatalarıyla; closed tip başına static ve generic kodda == tuzağı.
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
İki fiyattan büyük olanı döndüren küçük bir yardımcı yazmak istiyoruz. Aynı fonksiyon tarihler ve kitap adları için de işe yarasın diye onu generic yaptık. Bu kod derlenir mi?
static T Largest<T>(T first, T second) => first > second ? first : second;
Console.WriteLine(Largest(12.99m, 9.50m));
Derleyici T Hakkında Ne Bilir?
Derlenmez:
Program.cs(1,43): error CS0019: Operator '>' cannot be applied to operands of type 'T' and 'T'
decimal için > elbette var, ama derleyici Largest'ı çağrıldığı yerden bağımsız olarak, tek bir kez derler. O anda T hakkında bildiği tek şey bunun bir tip olduğu. T bir Book da olabilir, bir Stream da. Derleyici bu yüzden T üzerinde yalnızca her tipte bulunan member'lara izin verir: Equals, GetHashCode, ToString, GetType.
Derleyiciye daha fazlasını söylemenin yolu constraint. where T : IComparable<T> yazdığında "buraya sadece kendini aynı tipten bir değerle karşılaştırabilen tipler gelebilir" demiş olursun. Karşılığında CompareTo method'unu kullanma hakkı kazanırsın:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Largest(12.99m, 9.50m));
Console.WriteLine(Largest("Dune", "Foundation"));
Console.WriteLine(Largest(new DateOnly(2021, 11, 8), new DateOnly(2005, 11, 7)));
static T Largest<T>(T first, T second) where T : IComparable<T>
=> first.CompareTo(second) >= 0 ? first : second;
12.99
Foundation
11/08/2021
Constraint iki yönlü bir contract'tır. Method'un içi o yeteneğe güvenebilir, çağıran taraf da şartı sağlamak zorundadır. IComparable<Book> implemente etmeyen bir record Book ile Largest(dune, foundation) çağırırsan hata bu sefer çağrının yapıldığı satırda çıkar:
Program.cs(3,19): error CS0311: The type 'Book' cannot be used as type parameter 'T' in the generic type or method 'Largest<T>(T, T)'. There is no implicit reference conversion from 'Book' to 'System.IComparable<Book>'.
Bunu bir iş ilanı gibi düşün. "Ehliyet şart" yazan bir ilan, işe aldığın kişiye araba verebileceğini garanti eder; ehliyeti olmayan da başvuru aşamasında elenir. İş başladıktan sonra sürpriz çıkmaz.
Kendi Generic Class'ın: Page<T>
Web API'lerde liste döndüren her endpoint bir noktada sayfalamaya ihtiyaç duyar. Kitaplar için ayrı, yazarlar için ayrı bir sayfa class'ı yazmak yerine tipi parametre yaparız:
List<Book> books = new()
{
new("978-0441172719", "Dune", 12.99m),
new("978-0553293357", "Foundation", 9.50m),
new("978-0261102217", "The Hobbit", 10.99m),
new("978-0451524935", "1984", 8.99m),
new("978-0060850524", "Brave New World", 11.49m),
};
List<string> authors = new() { "Frank Herbert", "Isaac Asimov", "J. R. R. Tolkien" };
Page<Book> secondPage = Paginate(books, pageNumber: 2, pageSize: 2);
Console.WriteLine($"Page {secondPage.PageNumber}/{secondPage.TotalPages}, next: {secondPage.HasNext}");
foreach (Book book in secondPage.Items)
{
Console.WriteLine($" {book.Title}");
}
Page<string> authorPage = Paginate(authors, 1, 10);
Console.WriteLine($"Authors: {string.Join(", ", authorPage.Items)}, next: {authorPage.HasNext}");
static Page<T> Paginate<T>(IReadOnlyList<T> source, int pageNumber, int pageSize)
{
List<T> items = source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList();
return new Page<T>(items, pageNumber, pageSize, source.Count);
}
public sealed class Page<T>
{
public Page(IReadOnlyList<T> items, int pageNumber, int pageSize, int totalCount)
{
Items = items;
PageNumber = pageNumber;
PageSize = pageSize;
TotalCount = totalCount;
}
public IReadOnlyList<T> Items { get; }
public int PageNumber { get; }
public int PageSize { get; }
public int TotalCount { get; }
public int TotalPages => (TotalCount + PageSize - 1) / PageSize; // yukarı yuvarlanan bölme
public bool HasNext => PageNumber < TotalPages;
}
record Book(string Isbn, string Title, decimal Price);
Page 2/3, next: True
The Hobbit
1984
Authors: Frank Herbert, Isaac Asimov, J. R. R. Tolkien, next: False
Page<T> içinde T'ye hiçbir şey yapmıyoruz; sadece taşıyoruz. Constraint gerekmemesinin sebebi bu. Bir generic tip, T üzerinde işlem yapmadığı sürece en geniş hâliyle kalmalı. Type parameter adları için de bir gelenek var: tek parametre varsa T, birden fazlaysa anlamlı adlar ve başta T harfi: TKey, TValue, TEntity.
Generic Method: Tip Çıkarımı ve default
Paginate(books, 2, 2) çağrısında <Book> yazmadık. Derleyici T'yi argümanlardan çıkardı: ilk parametre IReadOnlyList<T>, gelen değer List<Book>, demek ki T Book. Bu çıkarım yalnızca argümanlara bakar, dönüş tipine ya da sonucun atandığı değişkene bakmaz:
List<Book> empty = CreateList();
List<Book> books = CreateList<Book>();
Console.WriteLine(books.Count);
static List<T> CreateList<T>() => new List<T>();
record Book(string Isbn, string Title, decimal Price);
Program.cs(1,20): error CS0411: The type arguments for method 'CreateList<T>()' cannot be inferred from the usage. Try specifying the type arguments explicitly.
İkinci satırdaki gibi tipi açıkça yazınca sorun kalmaz.
Generic kodda "boş değer" de bir soru. T bir reference type ise boş değer null, int ise 0, bir struct ise bütün field'ları sıfırlanmış hâli. default bunların hepsini kapsar. Asıl dikkat edilmesi gereken yer, constraint'siz T?:
List<int> stockCounts = new() { 3, 0, 12 };
List<string> titles = new() { "Dune", "Foundation" };
Console.WriteLine(FindFirst(stockCounts, s => s > 100)); // null değil, 0
Console.WriteLine(FindFirst(titles, t => t.StartsWith("Z")) ?? "(null)");
Console.WriteLine(ParseEnum<OrderStatus>("shipped"));
Console.WriteLine(ParseEnum<OrderStatus>("lost")?.ToString() ?? "(null)");
static T? FindFirst<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (T item in source)
{
if (predicate(item))
{
return item;
}
}
return default;
}
static TEnum? ParseEnum<TEnum>(string text) where TEnum : struct, Enum
=> Enum.TryParse(text, ignoreCase: true, out TEnum value) ? value : null;
enum OrderStatus { Pending, Paid, Shipped, Delivered }
0
(null)
Shipped
(null)
İlk satır şaşırtıcı. Stokta 100'den fazla olan kitap yok, ama sonuç 0; "stoğu sıfır olan bir kitap bulundu" ile "hiçbir şey bulunamadı" ayırt edilemiyor. Sebebi şu: constraint'siz bir T için T? (C# 9'dan beri), T bir reference type ise "null olabilir" anlamına gelir, value type ise hiçbir şey değiştirmez. int için dönüş tipi düz int'tir ve default sıfırdır. ParseEnum'da ise struct constraint'i var; orada TEnum? gerçekten Nullable<TEnum> olur ve null döndürebiliriz. Value type'larda "bulunamadı"yı ayırt etmen gerekiyorsa bool TryFind(..., out T result) pattern'ı daha dürüst bir tasarım.
Constraint'lerle Söz Vermek
Gerçek projelerde constraint'i en sık repository ve service katmanında görürsün. Elimizde her entity'nin bir Id'si olduğunu söyleyen bir interface var:
InMemoryRepository<Book> books = new();
books.Add(new Book(1, "978-0441172719", "Dune"));
books.Add(new Book(2, "978-0553293357", "Foundation"));
Console.WriteLine(books.GetById(2)?.Title ?? "(not found)");
Console.WriteLine(books.GetById(7)?.Title ?? "(not found)");
interface IEntity
{
int Id { get; }
}
class InMemoryRepository<TEntity> where TEntity : class, IEntity
{
private readonly Dictionary<int, TEntity> _items = new();
public void Add(TEntity entity) => _items.Add(entity.Id, entity);
public TEntity? GetById(int id) => _items.TryGetValue(id, out TEntity? entity) ? entity : null;
}
record Book(int Id, string Isbn, string Title) : IEntity;
Foundation
(not found)
İki constraint birlikte çalışıyor. IEntity sayesinde entity.Id yazabiliyoruz. class sayesinde TEntity? gerçekten "null olabilir bir referans" anlamına geliyor ve null döndürebiliyoruz. class'ı silersen derleyici hemen itiraz eder:
Program.cs(19,95): error CS0403: Cannot convert null to type parameter 'TEntity' because it could be a non-nullable value type. Consider using 'default(TEntity)' instead.
Bir struct da IEntity implemente edebilir ve struct'a null atanamaz. Derleyici bu ihtimali gördüğü için seni durdurur.
Constraint Kataloğu
C# 10'da kullanabileceğin constraint'lerin çoğunu tek bir programda topladım:
using System.Runtime.CompilerServices;
Console.WriteLine(ParseEnum<OrderStatus>("paid"));
Console.WriteLine(SizeOf<Isbn13>());
Console.WriteLine(CreateDefault<ShoppingCart>().Items.Count);
Cache<string, decimal> prices = new();
prices.Set("978-0441172719", 12.99m);
Console.WriteLine(prices.Count);
static TEnum? ParseEnum<TEnum>(string text) where TEnum : struct, Enum
=> Enum.TryParse(text, ignoreCase: true, out TEnum value) ? value : null;
static int SizeOf<T>() where T : unmanaged => Unsafe.SizeOf<T>();
static T CreateDefault<T>() where T : new() => new T();
class Cache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _items = new();
public int Count => _items.Count;
public void Set(TKey key, TValue value) => _items[key] = value;
}
class ShoppingCart
{
public List<string> Items { get; } = new();
}
enum OrderStatus { Pending, Paid, Shipped, Delivered }
readonly struct Isbn13
{
public Isbn13(long value) => Value = value;
public long Value { get; }
}
readonly record struct Money(decimal Amount, string Currency);
record Book(string Isbn, string Title, decimal Price);
Çıktı sırayla Paid, 8, 0 ve 1. Şimdi Console.WriteLine(prices.Count); satırının altına kuralları bozan dört satır ekleyelim:
Cache<string?, int> stock = new();
ShoppingCart? cart = ParseEnum<ShoppingCart>("open");
Book blank = CreateDefault<Book>();
int moneySize = SizeOf<Money>();
Program.cs(11,7): warning CS8714: The type 'string?' cannot be used as type parameter 'TKey' in the generic type or method 'Cache<TKey, TValue>'. Nullability of type argument 'string?' doesn't match 'notnull' constraint.
Program.cs(12,22): error CS0453: The type 'ShoppingCart' must be a non-nullable value type in order to use it as parameter 'TEnum' in the generic type or method 'ParseEnum<TEnum>(string)'
Program.cs(13,14): error CS0310: 'Book' must be a non-abstract type with a public parameterless constructor in order to use it as parameter 'T' in the generic type or method 'CreateDefault<T>()'
Program.cs(14,17): error CS8377: The type 'Money' must be a non-nullable value type, along with all fields at any level of nesting, in order to use it as parameter 'T' in the generic type or method 'SizeOf<T>()'
struct, T'nin nullable olmayan bir value type olmasını ister. Enum (C# 7.3) ile birleşince tam olarak "bir enum" demiş olursun. class, reference type ister; nullable context'te class? null olabilen reference type'lara da izin verir.
new(), public ve parametresiz bir constructor ister; Book positional record olduğu için parametresiz constructor'ı yok. unmanaged (C# 7.3), içinde hiçbir seviyede referans barındırmayan value type ister; Money'nin içindeki string onu bu gruptan çıkarıyor, sadece long tutan Isbn13 ise geçiyor. notnull ise nullable reference types ile birlikte gelir ve ihlali hata değil uyarı üretir. Dictionary<TKey, TValue>'nin kendi tanımında da where TKey : notnull yazar.
Bunlara ek olarak bir base class (where T : OrderItem), bir ya da daha fazla interface ve başka bir type parameter (where T : TBase) da constraint olabilir. Sıralama kuralı katı: class, struct, unmanaged ya da notnull en başa, new() en sona yazılır. where T : new(), class yazarsan CS0401 ("The new() constraint must be the last restrictive constraint specified") ve CS0449 hatalarını birlikte alırsın.
Her Closed Tipin Kendi static'i
Generic bir class'taki static member, bütün T'ler arasında paylaşılmaz. Her closed tip kendi kopyasını taşır:
Counter<Book>.Increment();
Counter<Book>.Increment();
Counter<Author>.Increment();
Console.WriteLine($"Book: {Counter<Book>.Count}, Author: {Counter<Author>.Count}");
static class Counter<T>
{
public static int Count { get; private set; }
public static void Increment() => Count++;
}
record Book(string Isbn, string Title, decimal Price);
record Author(string Name);
Book: 2, Author: 1
Counter<Book> ve Counter<Author> runtime için iki ayrı tip olduğundan sayaçları da ayrı. Farkında değilsen "uygulama genelinde tek bir sayaç" sandığın şey tip başına bir sayaç çıkar. Farkındaysan bu davranış işine de yarar: tipe göre hesaplanan bir sonucu (örneğin reflection ile okunmuş bir property listesini) generic static bir class'ta tutmak, dictionary aramasına gerek bırakmayan hızlı bir cache pattern'ıdır.
Sık Düşülen Tuzaklar
class constraint'i varken == kullanmak. Derleyici bu durumda referans karşılaştırması yapar, tipin kendi == operatörüne bakmaz:
string title = "Dune";
string typed = new string(new[] { 'D', 'u', 'n', 'e' });
Console.WriteLine(title == typed);
Console.WriteLine(Same(title, typed));
Console.WriteLine(SameByEquals(title, typed));
static bool Same<T>(T first, T second) where T : class => first == second;
static bool SameByEquals<T>(T first, T second) => EqualityComparer<T>.Default.Equals(first, second);
Çıktı True, False, True. Aynı iki string, generic method'un içinde "farklı" çıkıyor ve derleyici hiçbir uyarı vermiyor. Generic kodda eşitlik için EqualityComparer<T>.Default kullan; hem IEquatable<T>'yi hem de override edilmiş Equals'ı dikkate alır.
new T() ile oluşturulan object'in constructor'ı exception fırlatırsa ne olur? Derleyici new T()'yi Activator.CreateInstance<T>() çağrısına çevirir ve exception wrap edilmiş gelir. Constructor'ı InvalidOperationException fırlatan bir class ile denediğimde yakaladığım tip TargetInvocationException oldu; asıl hata InnerException'daydı. catch (InvalidOperationException) yazdıysan o blok hiç çalışmaz.
Gereğinden fazla constraint koymak. where T : class yazdığın her method, bir gün struct ile çağrılmak istendiğinde engel olur. Constraint'i method'un içinde gerçekten kullandığın bir yetenek için koy.
Küçük Bir Deneme
★☆☆ Largest'ın yanına Smallest'ı yaz ve DateOnly değerleriyle dene.
★★☆ Book'a IComparable<Book> ekle (fiyata göre karşılaştırsın) ve CS0311 hatasının kaybolduğunu gör. Largest(dune, foundation) artık ne döndürüyor?
★★★ FindFirst'ü bool TryFind<T>(IEnumerable<T> source, Func<T, bool> predicate, out T result) signature'ıyla yeniden yaz; stok sayılarında "bulunamadı" ile "sıfır bulundu"nun artık ayırt edilebildiğini göster.
Generic tipleri yazmayı da gördük. Bir sonraki yazıda en çok kullanacağın generic tipe, List<T>'ye yakından bakıyoruz: ekleme ve silme method'ları, Capacity'nin nasıl büyüdüğü, sıralama ve arama.