C#'ta Partial, Nested Type, Anonymous Type ve Tuple
Anonymous type'ların eşitliği ve sınırları, tuple ile birden fazla değer döndürmek, tuple adlarının sadece compiler için var olması, partial class/method, nested type ve hangisini ne zaman seçeceğin.
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
Üç anonymous object oluşturuyoruz. a ile b birebir aynı, c'de ise sadece property'lerin sırası farklı. Dört satır ne yazar?
var a = new { Title = "Dune", Price = 12.99m };
var b = new { Title = "Dune", Price = 12.99m };
var c = new { Price = 12.99m, Title = "Dune" };
Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(a.GetType() == b.GetType());
Console.WriteLine(a.GetType() == c.GetType());
Anonymous Type: İsimsiz Ama Tipli
Çıktı:
False
True
True
False
new { ... } yazdığında compiler senin için adı olmayan bir class üretir. Aynı assembly içinde aynı adlara, aynı tiplere ve aynı sıraya sahip iki anonymous object aynı class'ı paylaşır; a ve b'nin tipi bu yüzden aynı. c'de sıra farklı olduğu için compiler ayrı bir class üretti. Üretilen class Equals ve GetHashCode'u property değerlerine göre override eder, ama == operatörü tanımlamaz. Sonuç: Equals value equality'ye bakar, == ise class'larda olduğu gibi referansa. Operatörlerin neden ayrı bir mekanizma olduğunu iki yazı önce görmüştük.
Anonymous type'ların doğal yeri, LINQ'ta bir ara sonucu şekillendirmek:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var books = new[]
{
new Book("978-0441013593", "Dune", 12.99m),
new Book("978-0141439518", "Emma", 7.50m),
new Book("978-0547928227", "The Hobbit", 9.99m)
};
var cheap = books
.Where(b => b.Price < 10m)
.Select(b => new { b.Title, b.Price });
foreach (var item in cheap)
Console.WriteLine(item);
var first = cheap.First();
var onSale = first with { Price = 5m };
Console.WriteLine(onSale);
public record Book(string Isbn, string Title, decimal Price);
{ Title = Emma, Price = 7.50 }
{ Title = The Hobbit, Price = 9.99 }
{ Title = Emma, Price = 5 }
new { b.Title, b.Price } yazımında property adlarını vermedik; compiler onları expression'dan çıkardı. Anonymous type'ların property'leri read-only'dir; değiştirmeye çalışırsan derleme durur:
var item = new { Title = "Dune", Price = 12.99m };
item.Price = 9.99m;
Program.cs(2,1): error CS0200: Property or indexer '<anonymous type: string Title, decimal Price>.Price' cannot be assigned to -- it is read only
Değiştirilmiş bir kopya istiyorsan C# 10'dan beri with var; yukarıdaki onSale satırı bunu gösteriyor. En büyük sınırlama ise adın olmaması. Bir method'un dönüş tipine ya da bir parametreye anonymous type yazamazsın; çünkü yazacak bir ad yok. var sadece local değişkenlerde çalıştığı için anonymous type'lar pratikte tek bir method'un içinde yaşar. Dışarı taşıman gerekiyorsa sıra tuple'a ya da record'a gelir.
Tuple: Birden Fazla Değer Döndürmek
Bir method'dan iki değer döndürmek için eskiden out parametreleri ya da küçük bir yardımcı class gerekirdi. C# 7'den beri value tuple'lar var:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var prices = new[] { 12.99m, 7.50m, 9.99m };
var range = PriceRange(prices);
Console.WriteLine(range);
Console.WriteLine($"{range.Min} - {range.Max}");
var (min, max) = PriceRange(prices);
Console.WriteLine(max - min);
decimal low;
(low, var high) = PriceRange(prices);
Console.WriteLine($"{low}..{high}");
string first = "Dune", second = "Emma";
(first, second) = (second, first);
Console.WriteLine($"{first} {second}");
range.Min = 0m;
Console.WriteLine(range);
static (decimal Min, decimal Max) PriceRange(IEnumerable<decimal> values) => (values.Min(), values.Max());
(7.50, 12.99)
7.50 - 12.99
5.49
7.50..12.99
Emma Dune
(0, 12.99)
Dönüş tipindeki (decimal Min, decimal Max) hem tipi hem de field'ların adlarını tarif ediyor. Sonucu tek bir değişkende tutup range.Min ile okuyabilir ya da var (min, max) ile hemen parçalarına ayırabilirsin. Üçüncü yazım C# 10'un küçük bir eklemesi: deconstruction'da daha önce tanımlanmış bir değişkenle (low) yeni tanımlanan bir değişkeni (var high) karıştırabiliyorsun. Bu ekleme öncesinde ya hepsi yeni ya hepsi var olan olmak zorundaydı.
İki değişkenin yerini değiştirmek de tuple ile tek satır. Son iki satır ise önemli bir ayrıntıyı gösteriyor: value tuple bir struct ve field'ları değiştirilebilir. range.Min = 0m derlenir. Record'larla gelen immutability alışkanlığını tuple'larda arama.
Tuple İsimleri Sadece Compiler İçin
Min ve Max adları ne kadar gerçek? Bakalım:
using System.Globalization;
using System.Text.Json;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
(decimal Min, decimal Max) range = (7.50m, 12.99m);
(decimal Cheapest, decimal Priciest) other = (7.50m, 12.99m);
Console.WriteLine(range == other);
Console.WriteLine(range.Item1);
object boxed = range;
Console.WriteLine(boxed.GetType());
Console.WriteLine(JsonSerializer.Serialize(range));
True
7.50
System.ValueTuple`2[System.Decimal,System.Decimal]
{}
Adları farklı iki tuple == ile eşit çıktı; tuple eşitliği (C# 7.3) adlara değil, sırasıyla field'ların değerlerine bakar. Item1 hâlâ erişilebilir, çünkü tuple'ın gerçek tipi ValueTuple<decimal, decimal> ve field'larının gerçek adları Item1, Item2. Min ve Max, compiler'ın kaynak kodda sana sunduğu takma adlar; derlenmiş koda sadece member signature'larına (method dönüş tipi, parametre, field) eklenen TupleElementNames attribute'u olarak geçer, runtime'daki object'te yoktur.
Son satır bunun pratik bir sonucu: System.Text.Json varsayılan olarak sadece public property'leri yazar, ValueTuple'ın ise property'si değil field'ları var. Sonuç boş bir JSON object'i. Bir API'den tuple döndürmeyi düşünüyorsan bu satırı hatırla; Response modelleri için tuple yerine record kullanılmasının bir sebebi de bu.
Partial Class ve Partial Method
partial, bir class'ın tanımını birden fazla parçaya bölmeye izin verir. Compiler bütün parçaları birleştirip tek bir class üretir:
var book = new Book("Dune");
book.Rename("Dune Messiah");
Console.WriteLine(book.Title);
public partial class Book
{
public Book(string title) => Title = title;
public string Title { get; private set; }
public void Rename(string newTitle)
{
OnRenaming(Title, newTitle);
Title = newTitle;
}
partial void OnRenaming(string oldTitle, string newTitle);
}
public partial class Book
{
partial void OnRenaming(string oldTitle, string newTitle) =>
Console.WriteLine($"{oldTitle} -> {newTitle}");
}
Dune -> Dune Messiah
Dune Messiah
Parçalar genelde ayrı dosyalarda durur; buradaki örnekte tek dosyada gösterdik. Asıl kullanım alanı, kodun bir kısmını bir aracın ürettiği durumlar: WinForms designer'ın ürettiği form kodu, gRPC ve Entity Framework araçlarının ürettiği class'lar ve source generator'lar. Araç kendi dosyasını her seferinde baştan yazar, sen kendi parçana dokunursun ve ikiniz birbirinizin kodunu overwrite etmezsiniz. Source generator'ları İleri Seviye serisinde göreceğiz.
OnRenaming bir partial method: bir parçada signature'ı, başka bir parçada body'si var. Access modifier'ı olmayan, void dönen bir partial method'un body'si yazılmazsa compiler çağrıyı tamamen siler; üretilen kod "istersen buraya hook ekle" diyebilir. C# 9 ile partial method'lar dönüş değeri, out parametresi ve access modifier da alabilir hâle geldi; ama o zaman body zorunlu:
public partial class Book
{
public partial bool IsValid();
}
Program.cs(3,25): error CS8795: Partial method 'Book.IsValid()' must have an implementation part because it has accessibility modifiers.
Bir uyarı: partial'ı iki bin satırlık bir class'ı dört dosyaya bölüp "düzenledim" demek için kullanma. Class yine iki bin satır ve yine çok iş yapıyor; sadece artık dört dosyada arıyorsun. Bunun ilacı bir önceki yazılarda konuştuğumuz tek sorumluluk ve composition.
Nested Type: Class İçinde Class
Bir tipi başka bir tipin içinde tanımlayabilirsin. Bunun iki faydası var: tip, ait olduğu yere bağlı görünür ve dış tipin private member'larına erişebilir:
var order = new Order.Builder(1001)
.AddLine("978-0441013593", 2)
.AddLine("978-0141439518", 1)
.Build();
Console.WriteLine($"{order.Id}: {order.Lines.Count} lines");
Console.WriteLine(order.Lines[0]);
Console.WriteLine(typeof(Order.Builder).FullName);
public sealed class Order
{
private readonly List<Line> _lines = new();
private Order(int id) => Id = id;
public int Id { get; }
public IReadOnlyList<Line> Lines => _lines;
public sealed record Line(string Isbn, int Quantity);
public sealed class Builder
{
private readonly Order _order;
public Builder(int id) => _order = new Order(id);
public Builder AddLine(string isbn, int quantity)
{
_order._lines.Add(new Line(isbn, quantity));
return this;
}
public Order Build() => _order;
}
}
1001: 2 lines
Line { Isbn = 978-0441013593, Quantity = 2 }
Order+Builder
Order'ın constructor'ı private; dışarıdan new Order(...) yazılamaz. Ama Builder, Order'ın içinde tanımlandığı için hem o constructor'ı çağırabiliyor hem de private _lines listesine ekleme yapabiliyor. Böylece sipariş satırları sadece builder üzerinden eklenebiliyor. Dışarıdan nested tipe Order.Builder ve Order.Line diye erişilir; reflection'daki tam adı Order+Builder.
Nested type'ı, dış tip olmadan anlamı olmayan küçük yardımcılar için kullan. Kendi başına anlamlı ve birçok yerde kullanılacak bir tipi bir class'ın içine gömmek, her kullanımda Outer.Inner yazmayı gerektirir ve keşfedilmesini zorlaştırır.
Hangisi Ne Zaman
Anonymous type, tuple ve record birbirine yakın şeyler yapar; seçerken veriyi ne kadar uzağa taşıyacağına bak. Tek bir method içinde, çoğunlukla bir LINQ sorgusunda ara sonuç şekillendiriyorsan anonymous type. Bir private ya da internal yardımcı method'dan iki üç değer döndürüyorsan, değerler hemen parçalanacaksa tuple. Veri public bir API'den geçiyor, serialize ediliyor ya da birden fazla katman dolaşıyorsa adı, dokümantasyonu ve immutability'si olan bir record.
Partial ve nested type ise başka bir soruya cevap verir: kod nerede yaşamalı? Araç tarafından üretilen kodu el yazısından ayırmak için partial, dış tipe sıkıca bağlı bir yardımcı için nested.
Sık Düşülen Tuzaklar
Anonymous object'leri == ile karşılaştırmak. Açılıştaki örnekte gördüğün gibi == referansa bakar; değer karşılaştırması için Equals kullan.
Tuple adlarının runtime'da yaşadığını sanmak. Serialization, reflection ve dynamic adları görmez; JSON'da boş object ya da Item1 görürsün. Dışarıya açılan veri için record kullan.
Para değerinin biçimini decimal'a bırakmak. Anonymous type örneğinde Emma'nın fiyatı 7.50 yazarken with ile ürettiğimiz kopya Price = 5 yazdı, 5.00 değil. decimal, değeri yanında bir ölçek (scale) de taşır ve bu ölçek değerin nereden geldiğine bağlıdır: 7.50m literal'ı iki basamak, 5m sıfır basamak taşır. Kullanıcıya gösterdiğin her para değerini ToString("0.00") gibi açık bir formatla yaz; ölçeğe güvenme.
Uzun tuple'lar. (string, string, decimal, int, bool) dönen bir method'u çağıran kişi beşinci field'ın ne anlama geldiğini bilemez. Üçten fazla field'ın varsa ya da aynı tuple birden fazla method'da dolaşıyorsa bir record yaz.
Küçük Bir Deneme
★☆☆ books array'inden her kitabın başlığını ve KDV'li fiyatını (Price * 1.2m) içeren anonymous object'ler üret ve yazdır. Sonra aynı sonucu bir tuple listesiyle yap.
★★☆ PriceRange'i, ortalama fiyatı da döndürecek şekilde (decimal Min, decimal Max, decimal Average) olarak genişlet. Çağıran tarafta sadece Average lazımsa deconstruction'da discard (_) kullanarak diğerlerini at.
★★★ Order.Builder'da Build() çağrıldıktan sonra AddLine çağrılırsa ne oluyor? Builder'ın, Build'den sonra exception fırlatmasını sağla ve bunu kanıtlayan küçük bir deneme yaz.
Tip tanımlamanın bütün araçlarını gördük. Bir sonraki yazıda bunları bir araya getirip sık karşılaşılan üç design pattern'e bakıyoruz: object oluşturmayı tek bir yere toplayan factory, davranışı değiştirilebilir parçalara ayıran strategy ve değişiklikleri haber veren observer; hepsi modern C# ile.