Skip to content

Bölüm 10 / 13

record ve struct

record, struct, init, değişmezlik (immutability).

12 dakikalık okuma

Bu hattın 1. dersi tip sistemini kurarken iki senet bırakmıştı: "struct yazacaksan readonly struct yaz — nedenini 10. ders açacak" ve "içerik eşitliğini elle yazmak yerine derleyiciye yazdırabilirsin — record". İkisini de bugün ödüyoruz. Ama ders sözdiziminden ibaret değil: asıl konu değişmezlik (immutability), yani bir nesnenin kurulduktan sonra değişmemesi kararıdır. Bu karar, Hat 02'nin 5. dersindeki yarış durumlarının bir sınıfını kökten yok eder, hata ayıklamayı kolaylaştırır ve Hat 04'ün tasarım derslerinde tekrar tekrar karşına çıkar.

01Ne öğreneceksin

  • Değişmezlik nedir, hangi hata sınıflarını ortadan kaldırır
  • readonly, init ve required: kur-ve-dondur kalıbı
  • record: derleyicinin senin yerine yazdığı beş üye ve with ifadesi
  • readonly struct, record struct, ref struct: değer tiplerinin modern yüzü
  • Değişmezliğin sınırları: sığ kopya tuzağı, koleksiyonlar, readonly ≠ değişmez
  • Karar tablosu: class / record / struct / record struct hangi durumda

02Neden değişmezlik

Bir nesne kurulduktan sonra hiç değişmiyorsa, hakkında düşünmek kolaydır: elindeki referans nereye giderse gitsin, taşıdığı değer aynıdır. Değişebilir (mutable) bir nesnede ise şu soruyu sürekli sormak zorundasındır: "bu listeyi verdiğim metot içeriğini değiştirdi mi?" Değişmezliğin dört somut kazancı vardır:

  • Eşzamanlılık: Hat 02'nin 5. dersindeki yarış durumu, iki thread'in aynı veriyi yazmasından doğar. Hiç yazılmayan veriye kilit gerekmez — değişmez nesne doğal olarak thread-safe'tir. Bu, kilit maliyetini değil, kilit ihtiyacını ortadan kaldırır.
  • Yan etki yok: nesneyi bir metoda geçirdiğinde "bozar mı" endişesi biter. 1. dersteki referans tipi kopyalama kafa karışıklığının büyük kısmı buharlaşır.
  • Sözlük anahtarı güvenliği: Hat 02'nin 9. dersindeki hash tablosu sözleşmesini hatırla — bir nesne sözlüğe konduktan sonra hash'i değişirse kayıt kaybolur. Değişmez tip bu tuzağı tanım gereği kapatır.
  • Hata ayıklama: "bu alan ne zaman bozuldu?" sorusu, alan hiç değişmiyorsa sorulmaz.

Bedeli de vardır: değişiklik istediğinde yeni nesne üretirsin. Küçük nesnelerde bu maliyet ihmal edilebilir (9. dersin gen 0 ekonomisi: kısa ömürlü çöp ucuzdur); büyük nesne grafiklerinde ya da sıcak döngülerde ölçmek gerekir. Kural olarak: varsayılanın değişmez olsun, değiştirilebilirliği gerektiğinde seç.

03Kur ve dondur: readonly, init, required

C#'ta bir alanın değiştirilemez olmasının üç ayrı aracı vardır ve üçü farklı zamanlarda kilitlenir:

AraçNe zaman atanabilirNerede kullanılır
constDerleme zamanı — değer koda gömülürGerçek sabitler (Math.PI gibi)
readonly alanTanımda ya da yapıcı içindeYapıcıdan gelen bağımlılıklar, hesaplanan sabitler
get; init;Yapıcıda ve nesne başlatıcıdaModern varsayılan: özellikleri isimle kurup dondurmak
requiredKurulumda zorunlu kılar (derleyici denetler)Yapıcı yazmadan zorunlu alan istemek

init (C# 9), set'in kurulum anıyla sınırlı hâlidir: nesne başlatıcı sözdizimiyle atanabilir, sonra kilitlenir.

public class Fatura
{
    public required string No { get; init; }      // required: vermezsen DERLENMEZ
    public required decimal Tutar { get; init; }
    public DateTime Tarih { get; init; } = DateTime.UtcNow;
}

var f = new Fatura { No = "F-2026-1", Tutar = 1500m };   // kurulum: serbest
// f.Tutar = 2000m;                                       // HATA: init-only, kilitlendi

required (C# 11), "yapıcı yazmadan zorunluluk" ihtiyacını çözer: eskiden zorunlu alanlar için yapıcı parametresi yazmak, isteğe bağlı alanlar için nesne başlatıcı kullanmak gerekiyordu; artık ikisi bir arada olabilir.

Kritik uyarı: readonly ve init yalnız alanın kendisini korur, işaret ettiği nesneyi değil. 1. dersin değer/referans ayrımı burada tekrar ısırır:

public class Sepet
{
    public readonly List<string> Urunler = new();
}

var s = new Sepet();
// s.Urunler = new List<string>();   // HATA — referansı değiştiremezsin
s.Urunler.Add("kalem");               // SERBEST — işaret ettiği listenin İÇİ değişebilir!

Gerçek değişmezlik için koleksiyonun da değişmez olması gerekir: IReadOnlyList<T> (yalnız arayüzü daraltır, alttaki liste hâlâ değişebilir) ya da ImmutableArray<T> / ImmutableList<T> (gerçekten değişmez). Bu ayrım, kod incelemelerinde en çok gözden kaçan noktalardan biridir.

04record: derleyicinin yazdığı beş üye

1. dersin senedi: iki nesnenin "aynı" sayılması için Equals, GetHashCode, == ve okunur bir ToString yazman gerekiyordu — hepsi doğru yazılması zor, birbirine bağlı, sıkıcı kod. record (C# 9) tam olarak bunu üstlenir.

public record Adres(string Sehir, string Ilce, string PostaKodu);   // POZİSYONEL sözdizimi

var a1 = new Adres("Ankara", "Çankaya", "06100");
var a2 = new Adres("Ankara", "Çankaya", "06100");

Console.WriteLine(a1 == a2);                 // True  — İÇERİK eşitliği (class olsaydı False)
Console.WriteLine(ReferenceEquals(a1, a2));  // False — iki ayrı nesne
Console.WriteLine(a1);                       // Adres { Sehir = Ankara, Ilce = Çankaya, PostaKodu = 06100 }

var a3 = a1 with { Ilce = "Keçiören" };      // KOPYALA-DEĞİŞTİR: a1 dokunulmadan kaldı
var (sehir, ilce, pk) = a1;                  // Deconstruct: parçalara ayır

Bu tek satırın karşılığında derleyicinin ürettikleri:

  1. Init-only özellikler: pozisyonel parametreler public get; init; özelliklere dönüşür — yani record varsayılan olarak değişmezdir.
  2. Değer eşitliği: Equals, GetHashCode, == ve != tüm alanları karşılaştıracak biçimde yazılır. (1. dersin sözleşmesi otomatik sağlanır: eşit nesnelerin hash'i de eşittir.)
  3. Okunur ToString: tip adı + alanlar. Loglama ve hata ayıklamada gözle görülür bir kazançtır.
  4. with ifadesi: kopya yapıcısı üzerinden "şunu değiştirerek kopyala".
  5. Deconstruct: pozisyonel record'larda parçalara ayırma — 12. dersteki desen eşleştirmesiyle birleşince çok işe yarar.

Ayrıntılar, mülakatın "biliyor mu" sorularıdır:

  • record varsayılan olarak bir referans tipidir (record class yazmakla aynı şey). Yani heap'te yaşar; sadece eşitlik davranışı değişir.
  • Pozisyonel yazım zorunlu değildir; gövdeli de yazabilirsin (public record Adres { public string Sehir { get; init; } }).
  • Kalıtımda tip de karşılaştırılır: record'lar gizli bir EqualityContract üyesi taşır; aynı alanlara sahip bir taban ve türemiş record örneği eşit değildir. Bu, Hat 01'in 13. dersindeki Equals tuzağına (simetri ihlali) karşı doğru davranıştır.
  • Üretilen üyeleri ezebilirsin: kendi ToString'ini ya da Equals'ını yazarsan derleyici seninkini kullanır.
  • with sığ kopyalar — 6. bölümün konusu, ve en sık yapılan hata.

05Değer tiplerinin modern yüzü

1. derste "struct'ı ne zaman yazarsın" sorusunu açmış, kararı buraya bırakmıştık. Üç modern biçim vardır:

readonly struct — tüm alanları değişmez olan struct. Derleyici bunu denetler; ama asıl kazanç performanstadır. Değişebilir bir struct'ı readonly bir alanda ya da in parametresinde kullandığında derleyici, "birileri bunu değiştirmesin" diye savunma amaçlı kopya (defensive copy) üretir — yani sen kopyalamaktan kaçınmak için struct seçmişken, derleyici arka planda sessizce kopyalar. readonly struct yazmak bu kopyaların hiçbirini gerektirmez:

public readonly struct Para
{
    public decimal Tutar { get; }
    public string Birim { get; }

    public Para(decimal tutar, string birim) => (Tutar, Birim) = (tutar, birim);

    public Para Ekle(Para b) => Birim == b.Birim
        ? new Para(Tutar + b.Tutar, Birim)                 // YENİ değer döner, kendini değiştirmez
        : throw new InvalidOperationException("Birimler farklı.");
}

record struct (C# 10) — record'un ürettiği üyeleri değer tipine getirir. Burada bir ince tuzak vardır ve mülakatta sorulur: record class'ın pozisyonel özellikleri init'tir, ama record struct'ınkiler değiştirilebilir (set). Değişmezlik istiyorsan açıkça yazmalısın:

public record struct Nokta(int X, int Y);              // X ve Y DEĞİŞTİRİLEBİLİR
public readonly record struct Koordinat(int X, int Y); // istediğin bu: değişmez değer tipi

ref struct — yalnız stack'te yaşayabilen tip (Span<T> bunun en bilinen örneğidir). Heap'e çıkamaz: sınıf alanı olamaz, kutulanamaz (1. dersteki boxing yasak), async metotlarda ve lambda'larda kullanılamaz. Sebebi tek cümlede: ömrü stack çerçevesine bağlıdır, kaçarsa geçersiz belleğe işaret eder. 3. dersteki string.AsSpan() ile tahsissiz metin işleme tam olarak bu tipin üzerine kuruludur.

Peki ne zaman struct? 1. dersin kararını bu derste tamamlıyoruz — dört koşul birden sağlanmalı:

  1. Tip mantıksal olarak tek bir değeri temsil ediyor (koordinat, para, tarih aralığı, ölçü birimi).
  2. Küçük — kaba eşik ~16 bayt; büyüdükçe her kopyalama pahalılaşır ve avantaj tersine döner.
  3. Değişmez — readonly struct.
  4. Sık kutulanmıyor — object'e, arayüze ya da generic olmayan koleksiyona konuluyorsa kazanç boşa gider (1. dersin boxing tablosu; 5. dersin generics çözümü).

Dördü birden sağlanmıyorsa class yaz. Struct, ölçtüğün bir sıcak noktanın optimizasyonudur — varsayılan tasarım aracı değil.

06Değişmezliğin sınırı: sığ kopya

Bu bölüm, "record kullanıyorum, o hâlde nesnem değişmez" cümlesinin neden yanlış olabileceğini anlatır. with ifadesi ve record'un kopya yapıcısı sığ (shallow) çalışır: alanlar birebir kopyalanır, referans tipi alanlar ise aynı nesneyi işaret etmeye devam eder.

public record Siparis(string No, List<string> Kalemler);

var s1 = new Siparis("S-1", new List<string> { "kalem" });
var s2 = s1 with { No = "S-2" };      // yalnız No değişti sanılıyor

s2.Kalemler.Add("defter");
Console.WriteLine(s1.Kalemler.Count); // 2 — s1 DE değişti: liste PAYLAŞILIYOR

Aynı sebeple değer eşitliği de beklediğin gibi çalışmaz: iki record'un Kalemler listeleri aynı içeriğe sahip ayrı listelerse, List<T> referans eşitliği kullandığı için record'lar eşit çıkmaz. Kural: bir record ancak alanları kadar değişmezdir. Çözüm, koleksiyon alanlarını gerçekten değişmez tiplerle taşımaktır:

public record Siparis(string No, ImmutableArray<string> Kalemler);   // hem değişmez hem değer eşitliği uyumlu

İki not daha. Birincisi, record ile class arasındaki fark yalnızca derleyicinin ürettikleridir; record da sıradan bir sınıftır, metot yazabilir, arayüz uygulayabilir, kalıtım kurabilir. İkincisi, class'a init özellikler yazarak da değişmezlik elde edebilirsin — record'un kattığı şey değişmezlik değil, değer eşitliği + with + ToString paketidir. "Nesnem kimliğiyle mi değeriyle mi tanımlanıyor?" sorusu, seçimi belirleyen sorudur.

07Karar tablosu ve saha kullanımı

İhtiyaçSeçimGerekçe
Kimliği olan, durumu değişen varlık (Müşteri, Sipariş, kullanıcı oturumu) class İki müşteri aynı ada sahip olsa da aynı müşteri değildir — referans kimliği doğru olan
Veri taşıyıcı: API isteği/yanıtı, DTO, olay (event) mesajı, sorgu sonucu record Değeriyle tanımlanır; değişmezlik + okunur ToString + değer eşitliği bedava
Yapılandırma nesnesi, sonuç sarmalayıcı, önbellek anahtarı record Anahtar olarak kullanılabilir; hash sözleşmesi otomatik doğru
Küçük, değişmez, tek değer (koordinat, para, aralık) ve ölçülmüş sıcak yol readonly record struct Heap tahsisi yok + değer eşitliği; kutulanmadığı sürece hızlı
Tahsissiz metin/bellek işleme ref struct (Span<T>) Stack'te kalır, kopya üretmez — 3. dersin AsSpan yolu

Üç saha uyarısı:

  • EF Core varlıklarını (entity) record yapma. Veritabanı varlığı kimliğiyle (birincil anahtar) tanımlanır ve değişim takibi referans kimliğine dayanır; değer eşitliği burada yanlış modeldir. Ama sorgu sonucu için yazdığın projeksiyon/DTO'lar record olmalıdır (Hat 07).
  • Serileştirme: JSON'a yazma tarafı sorunsuzdur; okuma tarafında init ve required ile parametreli yapıcılar System.Text.Json tarafından desteklenir, ama tek bir public yapıcı olması ya da açık işaretleme gerekir. Bir API sözleşmesini record'a çevirdiğinde deserileştirmeyi test et.
  • Değişmezlik, katmanlar arasında en çok API sınırında değer üretir: gelen istek nesnesi asla değişmemeli, çünkü doğrulama sonrası kimse onu tahrif edememelidir. Hat 04'ün SOLID dersleri ve Hat 19'un olay tabanlı mimarisi (bir olay geçmişin kaydıdır, değiştirilemez) aynı fikri büyütür.

08Mini alıştırma — Eşitliği ve kopyayı sına

Yaklaşık 20 dakika · dotnet run

  1. class ve record farkı: aynı üç alana sahip bir class ve bir record yaz. Her birinden aynı değerlerle iki örnek üret; ==, Equals, GetHashCode ve ToString sonuçlarını yan yana yazdır. Farkları tabloya dök.
  2. with ve sığ kopya: içinde List<string> alanı olan bir record yaz; with ile kopyala, kopyanın listesine eleman ekle ve orijinali yazdır. Sonra alanı ImmutableArray<string> yapıp aynı deneyi tekrarla — ayrıca iki record'un == sonucunu iki sürümde karşılaştır.
  3. init kilidi: required + init özellikli bir sınıf yaz. (a) Zorunlu alanı vermeden kurmayı dene, (b) kurduktan sonra değiştirmeyi dene. İki derleyici hata kodunu ve mesajını not et — sınavda "hangi satır derlenmez" diye sorulur.
  4. readonly struct etkisi: bir struct Sayac yaz (içinde Artir() metodu alanı değiştirsin). Bunu bir sınıfın readonly alanı yap, Artir() çağır ve değerin değişmediğini gör — savunma amaçlı kopya budur. Sonra readonly struct'a çevir: derleyici ne diyor?
  5. record struct tuzağı: record struct Nokta(int X, int Y) tanımla ve bir örneğin X'ini değiştirmeyi dene — derlendi mi? readonly record struct'a çevirip tekrarla. Farkı tek cümleyle yaz.

09Kendini sına

record ile class arasındaki fark nedir? Derleyici record için neler üretir?

record varsayılan olarak yine bir referans tipidir (record class); fark, derleyicinin ürettiği üyelerdedir. Bunlar: pozisyonel parametrelerden türeyen init-only public özellikler; tüm alanları karşılaştıran Equals, GetHashCode, == ve != (yani kimlik değil değer eşitliği); tip adı ve alanları yazan okunur bir ToString; kopya yapıcısı üzerinden çalışan with ifadesi; pozisyonel record'larda Deconstruct. Ayrıca gizli bir EqualityContract üyesi taşır: aynı alanlara sahip bir taban record ile türemiş record örneği eşit sayılmaz, bu da elle yazılan Equals'ta sık yapılan simetri hatasını önler. Üretilen üyeler ezilebilir. Seçimi belirleyen soru şudur: nesne kimliğiyle mi (class) değeriyle mi (record) tanımlanıyor.

init, readonly ve required ne zaman devreye girer? "readonly bir alan nesneyi değişmez yapar" doğru mu?

const derleme zamanında sabitlenir ve değeri çağıran koda gömülür; readonly alan tanımda ya da yapıcıda atanabilir; init-only özellik yapıcıda ve nesne başlatıcıda atanabilir, kurulumdan sonra kilitlenir; required ise atama zamanını değil zorunluluğu ifade eder — alan verilmeden nesne kurulursa derleme hatası olur, böylece yapıcı yazmadan zorunlu alan tanımlanabilir. "readonly nesneyi değişmez yapar" yanlıştır: readonly ve init yalnız alanın kendisini, yani referansın hangi nesneyi gösterdiğini korur; işaret edilen nesnenin içi değişebilir. readonly bir List alanına eleman eklenebilir. Gerçek değişmezlik için taşınan tipin de değişmez olması gerekir — IReadOnlyList yalnız arayüzü daraltır, ImmutableArray/ImmutableList ise gerçekten değişmezdir.

with ifadesi tam olarak ne yapar, sığ kopya neden sorun çıkarır?

with, mevcut nesnenin kopyasını üretip yalnız belirtilen özellikleri değiştirir; orijinale dokunmaz. Ancak kopya sığdır: alanlar birebir kopyalanır, referans tipi alanlar aynı nesneyi göstermeye devam eder. Bu yüzden içinde List taşıyan bir record'un with ile üretilmiş kopyasında listeye eleman eklemek orijinali de değiştirir — nesne değişmez sanılırken paylaşılan durum yaşamaya devam eder. Aynı sebeple değer eşitliği de bozulur: iki record'un liste alanları aynı içeriğe sahip ayrı nesnelerse, List referans eşitliği kullandığı için record'lar eşit çıkmaz. Kural: bir record ancak alanları kadar değişmezdir; koleksiyonları ImmutableArray gibi gerçekten değişmez tiplerle taşımak gerekir.

readonly struct ne kazandırır? Struct'ı hangi dört koşul birlikte sağlandığında seçersin?

readonly struct, tüm alanların değişmez olduğunu derleyiciye garanti eder; kazanç sadece güvenlik değil performanstır. Değişebilir bir struct readonly bir alanda ya da in parametresinde kullanıldığında derleyici, değerin değiştirilmesini engellemek için savunma amaçlı kopya üretir — yani kopyalamadan kaçınmak için seçilen struct sessizce kopyalanır. readonly struct'ta bu kopyalar gerekmez. Struct seçimi için dört koşul birlikte aranır: tip mantıksal olarak tek bir değeri temsil etmeli, küçük olmalı (kaba eşik ~16 bayt), değişmez olmalı ve sık kutulanmamalı — kutulanıyorsa heap tahsisinden kaçınma kazancı zaten kaybolur. Dördü birden sağlanmıyorsa class doğru seçimdir; struct ölçülmüş bir sıcak noktanın optimizasyonudur. Ek ayrıntı: record struct'ın pozisyonel özellikleri record class'ın aksine değiştirilebilirdir, değişmezlik için readonly record struct yazmak gerekir.

10Sınav köşesi

Yazılıda nasıl sorulur

(1) Fark soruları: "record ile class farkı", "struct ile class farkı" (1. dersle çapraz), "record ile record struct farkı". (2) Çıktı sorusu: aynı değerlerle kurulmuş iki record için == ne döner (True), aynısı class için (False). (3) "Hangi satır derlenmez?": init özelliğe kurulumdan sonra atama; required alanı vermeden nesne kurma. (4) Kavram: with ne yapar, kopya sığ mı derin mi (sığ). (5) readonly bir List alanına Add yapılabilir mi (evet — klasik tuzak sorusu). (6) Karar sorusu: "koordinat tipini nasıl tanımlarsın, neden?" (readonly record struct; küçük, değişmez, tek değer). (7) ref struct/Span<T> neden sınıf alanı olamaz.

Sözlüde nasıl anlatılır — 2 dakikalık taslak

"record ve struct'ı anlat" için düzen: (1) Çekirdek — "asıl konu değişmezlik: kurulduktan sonra değişmeyen nesne kilitsiz paylaşılabilir, yan etki üretmez, sözlük anahtarı olarak güvenlidir." (2) Araçlar — "init ve required ile kur-ve-dondur yaparım; record ise değer eşitliğini, with'i ve okunur ToString'i derleyiciye yazdırır." (3) Karar dili — "kimliği olan varlıklar class, veri taşıyan DTO ve olay mesajları record; küçük, değişmez ve tek değer temsil eden tipler ölçtüysem readonly record struct." (4) Kıdem vuruşu — "iki tuzağı bilirim: with sığ kopyalar, bu yüzden record ancak alanları kadar değişmezdir — koleksiyonları ImmutableArray ile taşırım; ve record struct'ın pozisyonel özellikleri record class'ın aksine değiştirilebilirdir, o yüzden readonly yazarım."