Rust Ownership Modeli: Temel Kurallar ve Örnekler

## Rust'ta Ownership'e Neden İhtiyaç Duyulur? Rust'ın bellek güvenliği yaklaşımının merkezinde **ownership (sahiplik)** modeli yer alır. C ve C++ gib...

Rust Ownership Modeli: Temel Kurallar ve Örnekler

Rust'ta Ownership'e Neden İhtiyaç Duyulur?

Rust'ın bellek güvenliği yaklaşımının merkezinde ownership (sahiplik) modeli yer alır. C ve C++ gibi dillerde bellek yönetimi geliştiricinin sorumluluğundadır; yanlış free çağrısı, çift serbest bırakma (double free) veya serbest bırakılmış belleğe erişim (use-after-free) gibi hatalar ortaya çıkabilir. Java, Go ve C# gibi diller ise çöp toplayıcı (garbage collector) kullanır; bu, çalışma zamanında ek maliyet ve öngörülemeyen duraklamalar anlamına gelir.

Rust, üçüncü bir yol izler: derleyici, bellek yaşam sürelerini derleme zamanında izler. Ownership kuralları ihlal edildiğinde kod derlenmez. Bu sayede çalışma zamanı maliyeti olmadan bellek güvenliği sağlanır.

Bu rehber, orta seviye geliştiriciler için ownership modelinin temel kurallarını, pratik kullanımını ve sık yapılan hataları ele alır. Örnekler Rust 2021 edition sözdizimini kullanır; ownership kuralları dilin kararlı sürümlerinde değişmemiştir.

Üç Temel Kural

Ownership modeli üç kural üzerine kuruludur:

  1. Her değerin bir sahibi (owner) vardır.
  2. Aynı anda yalnızca bir sahip olabilir.
  3. Sahip kapsam dışına çıktığında (scope dışı), değer otomatik olarak bırakılır (drop).

Bu kurallar derleyici tarafından denetlenir. Aşağıdaki örnek, üçüncü kuralı gösterir:

fn main() {
    {
        let s = String::from("merhaba");
        println!("{}", s);
    } // s bu noktada scope dışına çıkar, bellek serbest bırakılır
    // println!("{}", s); // derleme hatası: s artık geçerli değil
}

String türü heap üzerinde veri tutar. Blok sona erdiğinde Rust, drop mekanizmasını çağırarak bu belleği serbest bırakır. Geliştiricinin free çağırmasına gerek yoktur.

Move Semantics: Sahiplik Devri

Bir değer başka bir değişkene atandığında veya fonksiyona geçirildiğinde, sahiplik taşınır (move). Taşınma sonrası eski değişken artık kullanılamaz:

fn main() {
    let s1 = String::from("veri");
    let s2 = s1; // sahiplik s1'den s2'ye taşındı
    // println!("{}", s1); // derleme hatası: s1 artık geçersiz
    println!("{}", s2); // çalışır
}

Bu davranış, aynı heap belleğini iki değişkenin işaret edip çift serbest bırakmaya yol açmasını engeller. C++'taki std::move semantiğine benzer, ancak Rust'ta taşınma sonrası eski değişkenin kullanılması derleme hatasıdır; C++'ta ise tanımsız davranıştır.

Fonksiyon çağrıları da sahipliği taşır:

fn consume(s: String) {
    println!("{}", s);
} // s burada drop edilir

fn main() {
    let metin = String::from("içerik");
    consume(metin);
    // println!("{}", metin); // derleme hatası
}

Copy Türleri: Taşınmayan Değerler

Bazı türler heap kullanmadığı için kopyalanabilir. i32, f64, bool, char ve yalnızca Copy türlerden oluşan tuple'lar Copy trait'ini uygular. Bu türlerde atama taşınma değil, kopyalama yapar:

fn main() {
    let x = 5;
    let y = x; // kopyalama
    println!("{} {}", x, y); // ikisi de geçerli
}

String, Vec<T> ve içinde heap işaretçisi barındıran türler Copy değildir. Kendi tanımladığınız türler varsayılan olarak Copy değildir; ancak tüm alanları Copy ise #[derive(Copy, Clone)] ile bu davranışı ekleyebilirsiniz.

Borrowing: Sahipliği Taşımadan Erişim

Sahipliği taşımadan bir değere erişmek için referans (borrow) kullanılır. İki tür referans vardır:

  • Paylaşımlı referans (&T): Aynı anda birden fazla olabilir, ancak veri değiştirilemez.
  • Değiştirilebilir referans (&mut T): Aynı anda yalnızca bir tane olabilir ve veri değiştirilebilir.

Bu kısıt, veri yarışlarını (data race) derleme zamanında engeller:

fn uzunluk(s: &String) -> usize {
    s.len()
} // s burada drop edilmez, sadece ödünç alındı

fn main() {
    let metin = String::from("örnek");
    let n = uzunluk(&metin);
    println!("{} karakter", n);
    println!("{}", metin); // metin hâlâ geçerli
}

Değiştirilebilir referans örneği:

fn ekle(s: &mut String) {
    s.push_str(" dünya");
}

fn main() {
    let mut metin = String::from("merhaba");
    ekle(&mut metin);
    println!("{}", metin); // "merhaba dünya"
}

Aynı kapsamda hem &T hem &mut T bulunamaz. Bu kural, okuma sırasında verinin değişmesini önler:

fn main() {
    let mut s = String::from("veri");
    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2); // paylaşımlı referanslar bitti
    let r3 = &mut s; // artık değiştirilebilir referans alınabilir
    r3.push_str("!");
}

Lifetime: Referansların Geçerliliği

Bir referans, işaret ettiği veriden daha uzun yaşayamaz. Derleyici bunu lifetime analiziyle denetler:

fn main() {
    let r;
    {
        let s = String::from("kısa ömürlü");
        r = &s;
    } // s burada drop edilir
    // println!("{}", r); // derleme hatası: r, s'den uzun yaşıyor
}

Fonksiyon imzalarında lifetime belirtmek bazen gereklidir. Birden fazla referans alan ve döndürülen referansın hangisine bağlı olduğu belirsiz olan durumlarda açık lifetime gerekir:

fn uzun_olan<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Basit durumlarda derleyici lifetime'ları otomatik çıkarır (lifetime elision). Yalnızca belirsizlik olduğunda açıkça yazmanız gerekir.

Sahiplik ve Fonksiyon Dönüş Değerleri

Bir fonksiyon sahipliği alıp geri döndürebilir. Bu, değeri kullanmaya devam etmek istediğinizde yaygın bir kalıptır:

fn al_ve_geri_ver(s: String) -> String {
    s // sahiplik geri döner
}

fn main() {
    let s1 = String::from("veri");
    let s2 = al_ve_geri_ver(s1);
    // println!("{}", s1); // hata: s1 taşındı
    println!("{}", s2); // çalışır
}

Pratikte sahipliği sürekli ileri geri taşımak yerine referans kullanmak daha yaygındır. Referans yalnızca okuma veya sınırlı değiştirme gerektiğinde tercih edilir; sahiplik gerektiğinde (örneğin değeri bir koleksiyona eklerken) taşıma yapılır.

Yaygın Hatalar

Taşınmış değeri kullanmak. let s2 = s1; sonrası s1 kullanılamaz. Çözüm: ya s1.clone() ile derin kopya alın, ya da referans kullanın. clone maliyetlidir; gereksiz kullanmaktan kaçının.

Aynı anda birden fazla değiştirilebilir referans. let r1 = &mut s; let r2 = &mut s; derlenmez. Genellikle kapsamları ayırarak veya borrow'ları sıralı kullanarak çözülür.

Fonksiyona referans yerine değer geçirmek. fn f(s: String) çağrısı sahipliği alır. Yalnızca okuma gerekiyorsa fn f(s: &str) veya fn f(s: &String) tercih edin. &str daha esnektir; String ve string literal'leri kabul eder.

Döngü içinde taşıma. Bir koleksiyonu dolaşırken for x in vec sahipliği alır; döngü sonrası vec kullanılamaz. Sadece okuma için for x in &vec, değiştirme için for x in &mut vec kullanın.

Lifetime'ı gereksiz uzatmak. Çoğu durumda derleyici lifetime'ları çıkarır. Her referansa açık lifetime yazmak kodu gereksiz karmaşıklaştırır; yalnızca derleyici istediğinde ekleyin.

Güvenlik Etkileri

Ownership modeli, aşağıdaki bellek güvenliği sorunlarını derleme zamanında ortadan kaldırır:

  • Use-after-free: Referans, veriden uzun yaşayamaz.
  • Double free: Bir değerin tek sahibi olur; drop bir kez çağrılır.
  • Data race: Aynı anda birden fazla değiştirilebilir referans olamaz.
  • Null pointer dereference: Rust'ta null yoktur; yokluk Option<T> ile ifade edilir.
  • Buffer overflow: Dilim (slice) ve Vec erişimleri sınır denetimlidir (index operatörü panik yapar; get metodu Option döner).

Bu garantiler unsafe blokları dışında geçerlidir. unsafe içinde ham pointer kullanımı mümkündür; bu durumda güvenlik sorumluluğu geliştiriciye geçer.

Sonraki Adımlar

Ownership'i pekiştirmek için şu konuları incelemeniz önerilir: Rc<T> ve Arc<T> (paylaşımlı sahiplik), RefCell<T> (içsel değiştirilebilirlik), Cow<T> (copy-on-write), ve Drop trait'i ile özel temizlik mantığı. Bu türler, ownership kurallarının katı olduğu durumlarda esneklik sağlar; ancak her biri kendi maliyet ve kısıtlarını getirir.

İLGİLİ İÇERİKLER

Bunları da Okumak İsteyebilirsiniz