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:
- Her değerin bir sahibi (owner) vardır.
- Aynı anda yalnızca bir sahip olabilir.
- 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
nullyoktur; yoklukOption<T>ile ifade edilir. - Buffer overflow: Dilim (slice) ve
Vecerişimleri sınır denetimlidir (index operatörü panik yapar;getmetoduOptiondö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.