C# Value Type ve Reference Type: Atama, Kopyalama ve string'in Özel Durumu
C#'ta value type ve reference type farkı: atamada ve method'a geçirirken ne kopyalanır, string neden value type gibi davranır, stack ve heap anlatımının doğru kısmı ve eksik kısmı, eşitlik, boxing ve collection'daki struct tuzakları.
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ğıda iki küçük tip var: fiyat etiketini tutan bir struct ve kitabı tutan bir class. İkisiyle de aynı şeyi yapıyoruz: bir değişkeni diğerine atıyor, sonra ikinci değişken üzerinden bir değeri değiştiriyoruz. Ekrana hangi iki sayı yazılır?
var tagA = new PriceTag { Amount = 10m };
var tagB = tagA;
tagB.Amount = 99m;
var bookA = new Book { Title = "Dune", Stock = 3 };
var bookB = bookA;
bookB.Stock = 0;
Console.WriteLine(tagA.Amount);
Console.WriteLine(bookA.Stock);
struct PriceTag
{
public decimal Amount;
}
class Book
{
public string Title = "";
public int Stock;
}
10
0
Etiket 10'da kaldı, kitabın stoğu 0 oldu. Kod neredeyse birebir aynı, sonuç tamamen farklı. Sebep, PriceTag'in bir value type, Book'un ise bir reference type olması. tagB = tagA satırı etiketin bütün içeriğini kopyaladı; elimizde birbirinden bağımsız iki etiket var. bookB = bookA ise kitabı kopyalamadı, aynı kitabı gösteren ikinci bir referans oluşturdu. Array'ler yazısında gördüğümüz davranışın genel kuralı bu. Örneklerde field'ları basitlik için public yazdık; property'leri ve doğru tasarımı OOP serisinde göreceğiz.
İki Tür Tip
C#'taki her tip bu iki gruptan birine girer. Hangisinde olduğunu runtime'a da sorabilirsin:
Console.WriteLine(typeof(int).IsValueType);
Console.WriteLine(typeof(decimal).IsValueType);
Console.WriteLine(typeof(DateTime).IsValueType);
Console.WriteLine(typeof(bool).IsValueType);
Console.WriteLine(typeof(string).IsValueType);
Console.WriteLine(typeof(int[]).IsValueType);
Console.WriteLine(typeof(List<int>).IsValueType);
True
True
True
True
False
False
False
Value type'lar: bütün sayı tipleri (int, long, double, decimal…), bool, char, DateTime gibi BCL'deki struct'lar, enum'lar ve senin struct ile tanımladığın her şey. Reference type'lar: class ile tanımlanan her şey, string, array'ler, List<T> gibi collection'lar, interface'ler ve delegate'ler.
Microsoft'un dokümanlarındaki tanım işin özünü veriyor: value type bir değişken, değerin kendisini içerir; reference type bir değişken ise değerin bulunduğu yere giden bir referans içerir. Bir benzetme: value type bir belgenin fotokopisini vermek gibidir; karşı taraf üzerine ne yazarsa yazsın senin belgen değişmez. Reference type ise evin adresini vermek gibidir; adresi alan kişi eve gidip duvarı boyarsa, sen eve döndüğünde duvarı boyanmış bulursun.
Atama: Kopya mı, Referans mı?

Atama operatörü her zaman değişkenin içindekini kopyalar. Fark, "değişkenin içinde ne var" sorusunun cevabında. PriceTag değişkeninin içinde etiketin kendisi var, kopyalanan da o. Book değişkeninin içinde ise sadece kitaba giden referans var; kopyalanan da o referans. Bu yüzden bookA ve bookB aynı object'e bakıyor ve biri üzerinden yapılan değişiklik diğerinden de görünüyor.
Bu bakış açısı, ileride göreceğin birçok davranışı tek bir kurala indirir. "Reference type'lar referansla atanır" demek yerine "her şey değerle atanır, reference type'ın değeri de bir referanstır" demek daha doğru ve daha az kafa karıştırıcı.
Method'lara Geçirmek
Aynı kural method parametreleri için de geçerli. Önceki yazıda parametrelerin değerle geçtiğini görmüştük; şimdi bunun reference type'larda ne anlama geldiğine bakalım:
var book = new Book { Title = "Dune", Stock = 3 };
SellOne(book);
Console.WriteLine(book.Stock);
Replace(book);
Console.WriteLine(book.Title);
ReplaceRef(ref book);
Console.WriteLine(book.Title);
static void SellOne(Book b)
{
b.Stock--;
}
static void Replace(Book b)
{
b = new Book { Title = "Emma", Stock = 10 };
}
static void ReplaceRef(ref Book b)
{
b = new Book { Title = "Emma", Stock = 10 };
}
class Book
{
public string Title = "";
public int Stock;
}
2
Dune
Emma
Üç çağrı, üç farklı sonuç. SellOne referansın kopyasını aldı, ama kopya da aynı kitabı gösterdiği için b.Stock-- asıl kitabı değiştirdi: stok 2 oldu. Replace ise kendi referans kopyasını yepyeni bir kitaba yönlendirdi; çağıranın book değişkeni bundan etkilenmedi, başlık "Dune" kaldı. ReplaceRef değişkenin kendisini ref ile aldığı için çağıranın değişkenini yeni kitaba çevirebildi.
Aynı denemeyi struct ile yapınca tablo değişiyor:
var tag = new PriceTag { Amount = 10m };
Increase(tag);
Console.WriteLine(tag.Amount);
IncreaseRef(ref tag);
Console.WriteLine(tag.Amount);
static void Increase(PriceTag t)
{
t.Amount += 5m;
}
static void IncreaseRef(ref PriceTag t)
{
t.Amount += 5m;
}
struct PriceTag
{
public decimal Amount;
}
10
15
Increase etiketin bir kopyasında çalıştı, asıl etiket 10'da kaldı. IncreaseRef ise ref sayesinde asıl değişkene ulaştı. Kısacası method içinde bir reference type'ın içeriğini değiştirmek çağırana yansır, parametreye yeni bir object atamak yansımaz; value type'ta ise ref olmadan hiçbir değişiklik yansımaz.
string'in Özel Durumu
string bir reference type, ama çoğu zaman value type gibi davranır:
string title = "Dune";
string other = title;
other += " Messiah";
Console.WriteLine(title);
Console.WriteLine(other);
string a = "Dune";
string b = string.Concat("Du", "ne");
Console.WriteLine(a == b);
Console.WriteLine(ReferenceEquals(a, b));
string upper = title.ToUpperInvariant();
Console.WriteLine(title);
Console.WriteLine(upper);
Dune
Dune Messiah
True
False
Dune
DUNE
other += " Messiah" satırı title'ı etkilemedi. Çünkü string immutable'dır: bir string object'i oluşturulduktan sonra asla değişmez. += var olan string'i değiştirmedi; yeni bir string oluşturup other'ı ona yönlendirdi. ToUpperInvariant da aynı şekilde yeni bir string döndürüyor, title olduğu gibi kalıyor.
Karşılaştırma da string'e özel. a ve b bellekte iki ayrı object (ReferenceEquals bunu False ile gösteriyor), ama == içeriklerini karşılaştırdığı için True döndürdü. String'in == operatörü bu iş için özel olarak tanımlanmıştır. Immutable olması ve içerikle karşılaştırılması, string'i günlük kullanımda bir value type kadar güvenli kılar. String'ler yazısında StringBuilder'ı konuşurken bu immutability'nin performans tarafını görmüştük.
Stack ve Heap: Doğru ve Abartısız
Bu konu anlatılırken genelde "value type'lar stack'te, reference type'lar heap'te yaşar" denir. Cümlenin yarısı doğru, yarısı eksik.
Doğru olan kısım: reference type bir object, garbage collector'ın yönettiği managed heap'te oluşturulur. Değişkende tutulan şey bu object'e giden referanstır. Object'e hiçbir referans kalmadığında garbage collector onun belleğini bir süre sonra geri alır.
Eksik olan kısım: bir value type'ın nerede durduğu, onu neyin içinde tanımladığına bağlıdır. Method içindeki bir int değişken genellikle stack'te (ya da doğrudan bir CPU register'ında) durur. Ama bir class'ın int field'ı, o class'ın object'iyle birlikte heap'te durur. PriceTag[] array'inin element'leri de array'in içinde, yani heap'te. Bir lambda'nın yakaladığı local değişken de heap'e taşınır.
Bu yüzden value type ile reference type arasındaki farkı "nerede durdukları" ile değil, "atandıklarında ve method'a verildiklerinde ne kopyalandığı" ile düşün. Yazdığın kodun doğruluğunu belirleyen o; bellekte nereye konduğu runtime'ın bir uygulama ayrıntısı ve C# dil kuralları bu konuda bir şey vaat etmez. Performansla ilgilendiğinde allocation konusuna C# İleri Seviye serisinde döneceğiz.
Eşitlik
== operatörü de iki dünyada farklı çalışır:
var x = new Book { Title = "Dune", Stock = 3 };
var y = new Book { Title = "Dune", Stock = 3 };
var z = x;
Console.WriteLine(x == y);
Console.WriteLine(x == z);
var p = new PriceTag { Amount = 10m };
var q = new PriceTag { Amount = 10m };
Console.WriteLine(p.Equals(q));
struct PriceTag
{
public decimal Amount;
}
class Book
{
public string Title = "";
public int Stock;
}
False
True
True
İki Book'un bütün field'ları aynı olduğu hâlde x == y False döndü. Class'larda == varsayılan olarak "bu iki referans aynı object'i mi gösteriyor?" diye sorar; x == z bu yüzden True. Struct'larda ise Equals field'ları tek tek karşılaştırır ve True döndürür. Peki struct'ta == kullanırsak?
var p = new PriceTag { Amount = 10m };
var q = new PriceTag { Amount = 10m };
Console.WriteLine(p == q);
struct PriceTag
{
public decimal Amount;
}
Program.cs(3,19): error CS0019: Operator '==' cannot be applied to operands of type 'PriceTag' and 'PriceTag'
Struct'lar için == operatörünü sen tanımlamadıkça kullanamazsın. Varsayılan Equals da bazı durumlarda reflection kullandığı için yavaştır. Kendi eşitlik kuralını yazmayı ve C# 9 ile gelen, value equality'yi hazır getiren record'u OOP serisinde göreceğiz.
Boxing'e Kısa Bir Dönüş
Tip dönüşümleri yazısında boxing'e ilk bir bakış atmıştık. Bir value type'ı object gibi bir reference type'a atadığında runtime değeri heap'te yeni bir kutuya kopyalar:
int stock = 3;
object boxed = stock;
stock = 10;
Console.WriteLine(boxed);
Console.WriteLine(stock);
int unboxed = (int)boxed;
Console.WriteLine(unboxed);
3
10
3
stock'u 10 yaptıktan sonra bile kutudaki değer 3. Kutu, atama anındaki değerin bir kopyasını taşıyor; asıl değişkenle bağı yok. Unboxing de kutudaki değeri tekrar bir value type'a kopyalar. Her boxing bir heap allocation demektir; döngü içinde sık sık boxing yapan kod gereksiz yere garbage collector'a iş çıkarır. Generic'ler tam olarak bu sorunu çözmek için var, Koleksiyonlar ve Generics serisinin ilk yazısında göreceğiz.
Sık Düşülen Tuzaklar
Collection'daki bir struct'ı yerinde değiştirmeye çalışmak. List<T>'nin indexer'ı element'in bir kopyasını döndürür; o kopyayı değiştirmek anlamsız olduğu için compiler izin vermez:
var tags = new List<PriceTag> { new PriceTag { Amount = 10m } };
tags[0].Amount = 20m;
struct PriceTag
{
public decimal Amount;
}
Program.cs(2,1): error CS1612: Cannot modify the return value of 'List<PriceTag>.this[int]' because it is not a variable
Array'de ise aynı satır çalışır, çünkü array'in element'i doğrudan bir değişken gibi erişilebilir. List'te yapman gereken, kopyayı alıp değiştirmek ve geri yazmak:
PriceTag[] array = { new PriceTag { Amount = 10m } };
array[0].Amount = 20m;
Console.WriteLine(array[0].Amount);
var list = new List<PriceTag> { new PriceTag { Amount = 10m } };
PriceTag copy = list[0];
copy.Amount = 20m;
Console.WriteLine(list[0].Amount);
list[0] = copy;
Console.WriteLine(list[0].Amount);
struct PriceTag
{
public decimal Amount;
}
20
10
20
Bu kafa karışıklığının asıl çözümü, struct'ları değiştirilemez (immutable) tasarlamak. Değişebilen struct'lar bu tür sürprizlerin baş kaynağıdır; readonly struct'ı OOP serisinde göreceğiz.
"Kopyaladım" sanmak. Bir object'in field'larını yeni bir object'e tek tek atamak, içteki reference type'ları kopyalamaz; ikisi aynı listeyi paylaşmaya devam eder:
var original = new Order { Customer = "Ada", Items = new List<string> { "Dune" } };
var copy = new Order { Customer = original.Customer, Items = original.Items };
copy.Items.Add("Emma");
Console.WriteLine(string.Join(", ", original.Items));
var deepCopy = new Order { Customer = original.Customer, Items = new List<string>(original.Items) };
deepCopy.Items.Add("Ulysses");
Console.WriteLine(string.Join(", ", original.Items));
class Order
{
public string Customer = "";
public List<string> Items = new();
}
Dune, Emma
Dune, Emma
İlk kopya (shallow copy) aynı Items listesini gösterdiği için "Emma" orijinale de eklendi. İkincide new List<string>(original.Items) ile listenin de kopyasını aldık (deep copy) ve orijinal etkilenmedi.
null ihtimalini unutmak. Bir reference type değişkenin içinde hiçbir object'e gitmeyen bir referans, yani null olabilir. Value type'ta bu mümkün değildir; bir int her zaman bir sayıdır. Bu farkın sonuçları bir sonraki yazının konusu.
Küçük Bir Deneme
★☆☆ Yazının başındaki örnekte PriceTag'i class'a, Book'u struct'a çevir ve çıktının nasıl değiştiğini önceden tahmin edip dene.
★★☆ Book class'ına Copy() adında, aynı değerlere sahip yeni bir Book döndüren bir method ekle. Order örneğindeki gibi içinde bir List<string> olan bir field ekle ve Copy()'nin shallow mı deep mi kopyaladığını test eden kodu yaz.
★★★ List<PriceTag> içindeki bütün etiketlerin fiyatını yüzde 10 artıran bir method yaz. Önce foreach ile dene ve compiler'ın ne dediğine bak; sonra for ve geri yazma ile çöz.
Reference type'ların bir değeri daha var: hiçbir şey. Bir sonraki yazıda null'u ele alıyoruz: Nullable<T> ve int?, C# 8 ile gelen nullable reference types, !, ?. ve ?? operatörleri ve null'ı kontrol etmek yerine tasarımla yönetmek.