C#'ta struct ve class Farkı: Kopyalama Semantiği ve readonly struct
Struct bir value type: property, indexer ve foreach'te gizlenen kopyalar, C# 10'da parametresiz constructor ile default farkı, struct'ların inheritance kuralları, readonly struct ve with ile immutable Money, boxing'in yarattığı ikinci kopya, struct eşitliği ve bir tipi ne zaman struct yapacağın.
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
Bir kitabın okuma ilerlemesini küçük bir tipte tutuyoruz ve bu kez class yerine struct yazıyoruz. Property'nin hem get'i hem set'i var, Read method'u da sayfa sayısını artırıyor. İki kez okuduktan sonra ekranda ne çıkar?
Book dune = new Book("Dune");
dune.Progress.Read(50);
dune.Progress.Read(30);
Console.WriteLine(dune.Progress.PagesRead);
public struct ReadingProgress
{
public int PagesRead;
public void Read(int pages)
{
PagesRead += pages;
}
}
public class Book
{
public string Title { get; }
public ReadingProgress Progress { get; set; }
public Book(string title)
{
Title = title;
}
}
ReadingProgress bir class olsaydı ve Progress'e bir object atanmış olsaydı cevap 80 olurdu. Struct ile cevap ne?
Değer Gibi Davranan Tip
Program 0 yazdırır, derleyici de tek bir uyarı vermez. Sebebi struct'ın bir value type olması. Class'larda değişken object'e giden bir referans tutuyordu; struct'ta değişken değerin kendisini tutar. Atama, parametre geçirme ve bir property'den değer okuma, değerin tamamını kopyalar. dune.Progress her okunduğunda get sana ilerlemenin bir kopyasını verir; Read(50) o kopyayı artırır ve kopya o satırın sonunda kaybolur. Kitabın içindeki asıl değer hiç değişmedi.

Fark yan yana daha net görünür:
ProgressStruct first = new ProgressStruct();
ProgressStruct second = first;
second.PagesRead = 120;
Console.WriteLine($"struct: {first.PagesRead} / {second.PagesRead}");
ProgressClass third = new ProgressClass();
ProgressClass fourth = third;
fourth.PagesRead = 120;
Console.WriteLine($"class: {third.PagesRead} / {fourth.PagesRead}");
Book dune = new Book("Dune");
ReadingProgress progress = dune.Progress;
progress.Read(50);
dune.Progress = progress;
Console.WriteLine($"fixed: {dune.Progress.PagesRead}");
public struct ProgressStruct
{
public int PagesRead;
}
public class ProgressClass
{
public int PagesRead;
}
public struct ReadingProgress
{
public int PagesRead;
public void Read(int pages)
{
PagesRead += pages;
}
}
public class Book
{
public string Title { get; }
public ReadingProgress Progress { get; set; }
public Book(string title)
{
Title = title;
}
}
struct: 0 / 120
class: 120 / 120
fixed: 50
İlk yazıdaki ev ve adres benzetmesine dönersek: class'ta iki kâğıda aynı adres yazılıydı. Struct'ta ise elinde adres değil, bir fotokopi var; fotokopinin üstüne yazmak aslını değiştirmez. Değeri kitapta değiştirmenin yolu üçüncü blokta: kopyayı al, değiştir, geri yaz. Bu dans, struct'ları değiştirilebilir yapmanın neden kötü bir fikir olduğunun ilk ipucu.
Kopya Nerede Gizlenir
Property tek yer değil. Collection'lar ve foreach da değer döner, ve bu kez derleyici seni durdurur:
List<Stock> list = new List<Stock> { new Stock(), new Stock() };
Stock[] array = new Stock[2];
list[0].Count = 5;
array[0].Count = 5;
foreach (Stock item in list)
{
item.Count = 10;
}
public struct Stock
{
public int Count;
}
Program.cs(4,1): error CS1612: Cannot modify the return value of 'List<Stock>.this[int]' because it is not a variable
Program.cs(9,5): error CS1654: Cannot modify members of 'item' because it is a 'foreach iteration variable'
list[0] bir method çağrısıdır (indexer) ve bir kopya döner; kopyanın field'ını değiştirmek anlamsız olduğu için derleyici reddeder. array[0] satırı ise hata vermedi. Array element'i bir kopya değil, array'in içindeki gerçek bellek alanıdır; o satırı tek başına çalıştırdığımda array[0].Count 5 oldu. foreach değişkeni de bir kopyadır ve read-only'dir. Peki tahmin örneğinde neden hata yoktu? Çünkü orada bir field'a atama değil, bir method çağrısı vardı. Derleyici method'un değeri değiştireceğini bilemez ve kopya üzerinde çağırmana izin verir.
struct'ın Kuralları
Struct'lar class'lara çok benzer: field, property, method, constructor ve interface implementasyonu taşıyabilirler. Ama birkaç önemli farkları var:
Quantity viaNew = new Quantity();
Quantity viaDefault = default;
Quantity[] array = new Quantity[2];
Quantity explicitValue = new Quantity(3);
Console.WriteLine($"{viaNew.Value} {viaDefault.Value} {array[0].Value} {explicitValue.Value}");
public struct Quantity
{
public int Value { get; }
public Quantity()
{
Value = 1;
}
public Quantity(int value)
{
Value = value;
}
}
1 0 0 3
Her struct'ın bir varsayılan değeri vardır: bütün field'ları sıfır, false ya da null. Struct asla null olmaz; yazılmamış bir struct "boş" değil "sıfırlanmış"tır. C# 10'dan önce struct'a parametresiz constructor yazamıyordun; C# 10 buna izin verdi. Ama çıktı bir inceliği gösteriyor: new Quantity() senin constructor'ını çalıştırıp 1 verdi, default ve array element'leri ise constructor'ı hiç çalıştırmadan sıfırla doldu. Yani struct'ının "her zaman en az 1" gibi bir kuralı varsa, constructor bunu garanti edemez; default her zaman arka kapıdır.
Diğer kurallar derleyiciden:
public struct Quantity
{
public int Value { get; } = 1;
}
public struct Money
{
public decimal Amount;
}
public struct DiscountedMoney : Money
{
}
Program.cs(1,15): error CS8983: A 'struct' with field initializers must include an explicitly declared constructor.
Program.cs(11,33): error CS0527: Type 'Money' in interface list is not an interface
C# 10 struct'larda field initializer'a da izin veriyor, ama yalnızca struct açıkça bir constructor tanımlıyorsa. İkinci hata daha temel: struct'lar inheritance'a katılmaz. Bir struct başka bir struct'tan ya da class'tan türeyemez, ondan da türetilemez; iki noktadan sonra yalnızca interface yazabilirsin. Her struct dolaylı olarak System.ValueType'tan türer ve otomatik olarak sealed'dır.
readonly struct
Buraya kadarki bütün sorunların kaynağı aynı: değiştirilebilir struct. Çözüm de basit: struct'ı immutable yap ve değişiklik gerektiğinde yeni bir değer üret. Bookstore'da para tutarı bunun için ideal bir aday:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Money price = new Money(45.90m, "EUR");
Money shipping = new Money(3.00m, "EUR");
Money total = price.Add(shipping);
Money discounted = price with { Amount = 39.90m };
Console.WriteLine(total);
Console.WriteLine(discounted);
Console.WriteLine(price);
try
{
price.Add(new Money(10m, "USD"));
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
public readonly struct Money
{
public decimal Amount { get; init; }
public string Currency { get; init; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public Money Add(Money other)
{
if (other.Currency != Currency)
{
throw new InvalidOperationException($"Cannot add {other.Currency} to {Currency}.");
}
return new Money(Amount + other.Amount, Currency);
}
public override string ToString()
{
return $"{Amount} {Currency}";
}
}
48.90 EUR
39.90 EUR
45.90 EUR
Cannot add USD to EUR.
readonly struct (C# 7.2) derleyiciye "bu tipin hiçbir instance member'ı değişemez" sözü verir ve derleyici bunu denetler. Add fiyatı değiştirmiyor, yeni bir Money döndürüyor; price en sonda hâlâ 45.90. with expression'ı C# 10 ile struct'larda da kullanılabilir oldu: değerin bir kopyasını alır ve yalnızca belirttiğin property'leri değiştirir. Para birimi kontrolü de değerin yanında; EUR ile USD'yi toplamak mümkün değil. decimal, DateTime ve Guid da aynı mantıkla tasarlanmış immutable value type'lardır.
Kuralı bozmaya çalışırsan:
public readonly struct Money
{
public decimal Amount;
public string Currency { get; set; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
}
Program.cs(3,20): error CS8340: Instance fields of readonly structs must be readonly.
Program.cs(4,19): error CS8341: Auto-implemented instance properties in readonly structs must be readonly.
readonly struct'ın ikinci bir kazancı performans: derleyici, değerin bir method çağrısında değişmeyeceğini bildiği için in parametrelerinde ve readonly field'larda savunma amaçlı kopya almak zorunda kalmaz. Bu ayrıntıya ileri seviye serisinin ref ve in yazısında döneceğiz.
Boxing: Struct'ın Kılık Değiştirmesi
Bir struct'ı object ya da bir interface tipindeki değişkene atadığında runtime onu heap'te bir kutuya koyar. Buna boxing denir ve kutunun içindeki, orijinalin bir kopyasıdır:
StockLevel level = new StockLevel();
IStockLevel boxed = level;
boxed.Receive(5);
level.Receive(2);
Console.WriteLine($"level: {level.Count}, boxed: {boxed.Count}");
object asObject = level;
level.Receive(10);
Console.WriteLine($"level: {level.Count}, unboxed copy: {((StockLevel)asObject).Count}");
public interface IStockLevel
{
int Count { get; }
void Receive(int quantity);
}
public struct StockLevel : IStockLevel
{
public int Count { get; private set; }
public void Receive(int quantity)
{
Count += quantity;
}
}
level: 2, boxed: 5
level: 12, unboxed copy: 2
Atama anında iki ayrı stok oluştu ve birbirinden habersiz büyüdü. Kutunun içindeki 5'e çıkarken orijinal 2'de kaldı; asObject'e atadığımız an 2'nin fotoğrafını çekti ve sonraki artışı görmedi. Interface'ler struct'larla birlikte kullanıldığında bu sürpriz kolayca ortaya çıkar. Boxing'in bir de maliyeti var: her kutu yeni bir heap allocation'dır ve garbage collector'a iş çıkarır. Generics'in neden icat edildiğini anlatan koleksiyon serisinin ilk yazısı tam bu maliyetle başlıyor.
Struct'larda Eşitlik
Class'larda Equals varsayılan olarak kimliği karşılaştırıyordu. Struct'larda ise içeriği karşılaştırır:
Money a = new Money(45.90m, "EUR");
Money b = new Money(45.90m, "EUR");
Console.WriteLine(a.Equals(b));
Console.WriteLine(a == b);
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
}
Program.cs(5,19): error CS0019: Operator '==' cannot be applied to operands of type 'Money' and 'Money'
Struct'lar için == kendiliğinden tanımlı değildir. Son satırı silince a.Equals(b) True yazdı. ValueType.Equals iki struct'ın field'larını tek tek karşılaştırır; ama genel amaçlı olduğu için, struct reference tipte field taşıyorsa bunu reflection ile yapar ve yavaştır. Dictionary key'i olarak kullanacağın ya da sık karşılaştıracağın bir struct'ta Equals, GetHashCode ve =='yi kendin yazmalısın. Bunu elle yapmak eşitlik yazısının konusu; bir sonraki yazıdaki record struct ise hepsini senin yerine üretiyor.
Ne Zaman struct
Microsoft'un .NET tasarım rehberindeki "Choosing Between Class and Struct" bölümü, bir tip şu özelliklerin hepsini taşımıyorsa struct yapmamayı öneriyor: tek bir değeri temsil eder (sayı, tarih, para gibi), boyutu 16 byte'ın altındadır, immutable'dır ve sık sık boxing'e uğramaz. Boyutlara bakalım:
using System.Runtime.CompilerServices;
Console.WriteLine($"int: {Unsafe.SizeOf<int>()}");
Console.WriteLine($"DateTime: {Unsafe.SizeOf<DateTime>()}");
Console.WriteLine($"decimal: {Unsafe.SizeOf<decimal>()}");
Console.WriteLine($"Guid: {Unsafe.SizeOf<Guid>()}");
Console.WriteLine($"Money: {Unsafe.SizeOf<Money>()}");
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
}
int: 4
DateTime: 8
decimal: 16
Guid: 16
Money: 24
Bizim Money'imiz 24 byte ile rehberin sınırını biraz aşıyor: 16 byte'lık decimal ve string'e giden 8 byte'lık bir referans. Bu bir yasak değil; 16 byte bir kopyalama maliyeti uyarısıdır ve her atama, her parametre 24 byte kopyalar. Para gibi değer anlamı güçlü, immutable bir tip için bu kabul edilebilir bir bedel. Kitap, sipariş, müşteri gibi kimliği olan, zamanla değişen ve çok sayıda field'ı olan tipler ise her zaman class'tır. Varsayılan seçim class; struct'ı ölçerek ve bilerek seçersin.
Sık Düşülen Tuzaklar
Değiştirilebilir struct. Bu yazıdaki hataların neredeyse hepsi struct'ın değiştirilebilir olmasından çıktı. Bir struct yazıyorsan readonly struct ile başla; değişiklik gerekiyorsa yeni bir değer döndür.
Struct'ın constructor'ına güvenmek. default, array'ler ve henüz atanmamış field'lar constructor'ı atlar. Struct'ın sıfır hâli de geçerli bir değer olmalı, ya da kodun onu açıkça kontrol etmeli.
Performans için körlemesine struct. "Struct stack'te durur, daha hızlıdır" cümlesi yarı doğrudur. Struct bir class'ın field'ıysa o class'la birlikte heap'tedir; büyük struct'lar her kopyalamada maliyet çıkarır ve interface üzerinden kullanılan struct'lar boxing'e uğrar. Performans iddiasını BenchmarkDotNet ile ölçmeden kabul etme; bunu ileri seviye serisinin performans yazısında yapacağız.
Struct'ı interface değişkeninde tutup değiştirmek. Boxing bölümündeki gibi iki ayrı kopyayla uğraştığını fark etmeyebilirsin. Immutable struct bu sorunu da ortadan kaldırır.
Küçük Bir Deneme
★☆☆ Money'e Multiply(int quantity) method'u ekle ve yeni bir Money dönsün. 45.90 EUR'luk bir kitabın üç adetinin toplamını yazdır ve price'ın değişmediğini kontrol et.
★★☆ Tahmin örneğindeki ReadingProgress'i readonly struct yap: Read artık yeni bir değer döndürsün. Book'a Read(int pages) method'u ekleyip içeride Progress = Progress.Read(pages); yaz. Çağıran kodun artık kopya tuzağına düşemediğini gör.
★★★ Money'i Dictionary<Money, string>'in key'i olarak kullan, 100.000 farklı değer ekle ve süreyi Stopwatch ile ölç. Sonra Money'e kendi Equals(Money other) ve GetHashCode() member'larını yazıp tekrar ölç. Fark nereden geliyor?
Bu yazıdaki Money'i hazırlamak için bir hayli tören yaptık: readonly struct, constructor, ToString override'ı, eşitlik için ayrıca yazılacak üç member. Bir sonraki yazıda record ve record struct'a geçiyoruz: C# 9 ve 10'un getirdiği bu tipler value equality'yi, ToString'i ve with desteğini tek satırlık bir tanımdan üretiyor.