Nazım Can Altınova

Rust'ta Ownership Kavramı

Merhaba arkadaşlar, geçen seferki yazımda Rust programlama dili hakkında bir ön bilgi verip, onda bulduğum olumlu ve olumsuz gözlemlerimi ve düşüncelerimi aktarmaya çalışmıştım. Yazıya buradan ulaşabilirsiniz. Aslında ownership (sahiplik), borrowing (borçlanma) ve lifetime (kullanım süresi, yaşam) konularını o yazının içinde açıklamak istemiştim fakat daha sonra yazının çok uzayacağını ve ana konudan sapacağını düşündüğüm için ayrı ayrı yazılar halinde yazmak istedim. Rust programlama dilini diğer dillerden ayıran özelliklerin başında bu üçü geliyor bence. Başka bir programlama dilini biliyor ve Rust’a geçiş yapıyorsanız, diğer dillerin perspektiflerinden baktığımızda tamamen mantıklı olan bir kodu çalıştırdığınızda Rust’ta çalışmadığını, hata verdiğini görebiliyorsunuz :) Bunun en büyük nedeni tabii ki Rust programlama dilinin bakış açısına göre (ownership, borrowing ve lifetime mantığına göre) düşünemiyor olmamız. Ayrıca Rust’ın diğer dillere göre çok daha katı bir dil olduğunu söylemek gerekir sanırım. Rust bazı şeyleri runtime’da kontrol etmek veya yazılımcının inisiyatifine bırakmak yerine derleme sırasında yüzünüze çarpabiliyor. Bunların önüne geçmek için bu üç konu hakkında iyi bir bilgiye sahip olmak gerekiyor. Açık olması için elimden geldiğince örnekli bir biçimde anlatmaya çalışacağım.

Ownership Kavramı

Diğer dillerde işler nasıl yürüyor?

Düşük seviyeli, garbage collection‘ı olmayan dillerde; değişkenlerin, sakladıkları kaynakların serbest bırakılmalarından yazılımcının kendisi sorumludur. Genelde o kaynak ile işlemler bittiğinde ve ona bir daha ihtiyaç duyulmadığında kaynak serbest bırakılır ve kullanılan bellek alanı iade edilir. Örneğin C dilinde pointer’lar ile bu işlemi yapmak isterseniz şu şekilde yazmanız gerekir:

int main(int argc, const char * argv[]) {
    char *str;
    str = (char *) malloc(10); // Bellekte bir yer allocate ediyoruz.
    // str ile birtakım işlemler...

    free(str); // str pointer'ını ve kullandığı resource'u serbest bırakıyoruz.

    return 0;
}

Basit bir şekilde bir char pointer’ı tanımladık ve bu pointer için malloc ile 10 karakterlik bir bellek alanı ayırdık. Ardından pointer ile işimiz bittiğinde, free fonksiyonu ile kaynağı serbest bırakmamız gerekti. Eğer bırakmazsak program bu kaynağı işletim sistemine iade edemeyecek ve bu kaynak bellekte yer tutmaya devam edecektir. Ta ki program sonlanana kadar. Bu olaya bellek sızıntısı (memory leak) adı veriliyor. Büyük çaplı programlarda eğer dikkat edilmezse çok fazla baş ağrıtabilecek bir sorun. Yüksek seviyeli, garbage collection’a sahip dillerde bunlar garbage collection’ın yapmakla yükümlü olduğu şeylerdir. Bu nedenle o dillerde bunlara çok fazla dikkat etmemiz gerekmiyor.

Peki Rust’ta durumlar nasıl?

Rust’ta ise işler biraz farklı yürüyor. Rust’ın, düşük seviyeli bir fonksiyonel programlama dili olmasından dolayı garbage collection’ı bulunmuyor. Fakat Rust bu yazıda değineceğimiz ownership ve diğer yazılarımda değineceğim borrowing ve lifetime sayesinde garbage collection’ı olmayan diğer dillere göre bir adım öne çıkıyor. Bunlar sayesinde bir resource’u kendimiz serbest bırakmamıza gerek kalmadan, compiler bu işi garbage collection olmadan halledebiliyor. Fakat bu özellik bize aynı zamanda bazı kısıtlamalar getiriyor. Buradaki ana kavram ownership. Şimdi hemen Rust’ın bu işi ownership ile nasıl hallettiğine geçelim.

Rust’ta her değişken, kendi kaynaklarını serbest bırakmaktan kendisi sorumludur (C gibi dillerde bundan yazılımcılar, daha yüksek seviyeli dillerde ise garbage collector sorumludur). Bir kaynağın sadece bir sahibi (owner) olabilir. Tabii bazı değişkenler hiçbir kaynağa sahip olmayabilir de (örneğin referanslar). Bir başka değişkene atama yaptığımızda (örn. let x = y;) veya bir değişkeni fonksiyona parametre olarak verdiğimizde (örn. fonk(x)) o kaynağın sahipliği de aktarılır. Buna Rust terminolojisinde move (yani taşıma) adı veriliyor. Bir kaynağı taşıdığımızda, artık o kaynağın bir önceki sahibi kullanılamaz olur. Bu da silinen bir kaynağı hala işaret eden bir pointer’ın olmasının önüne geçiyor.

Bir şeyi de açıklığa kavuşturmak lazım. C’de olduğu gibi stack’te depolanan (stack allocated) değişkenler için bu dediğimiz ownership geçerli değil. Yani integer, bool, char gibi primitive’ler için. Bu bahsettiğimiz, heap’te depolanan (heap allocated) değişkenlerin pointer’ları için geçerli. Bu bahsettiğimi birkaç örnekle açıklayayım:

fn main() {
    // 'Stack' allocated integer değeri
    let x = 5i32;

    // 'x' değişkenini `y` değişkenine 'kopyalıyoruz' (taşımıyoruz). - Herhangi bir kaynak taşınmıyor.
    let y = x;

    // İki değişken de birbirinden bağımsız olarak kullanılabilir.
    println!("x değişkeni: {}, y değişkeni: {}", x, y);
}

Gördüğünüz gibi üst taraftaki kod normal bir C kodu mantığına eşdeğer gibi.

Şimdi heap allocated bir değişkene gelelim. Bu sefer bu ownership dediğimiz kavram geçerli olacak:

fn main() {
    // `a` bir 'heap' allocated integer değerin pointer'ı.
    let a = Box::new(5i32);
    println!("a'nın taşıdığı değer: {}", a);

    // Artık a değişkenini b'ye 'taşıyoruz'. Bundan sonra a kullanım dışı olacak. Eğer a'yı kullanmaya çalışırsak compiler'dan 'use of moved value' diye bir hata alırız.
    let b = a; // <----- a'nın kaynağı taşındığı için kullanılabilirliği burada sonlanıyor.

    println!("b'nin taşıdığı değer: {}", b); // Sorunsuz çalışacak
    println!("a'nın taşıdığı değer: {}", a); // Taşınmış bir değişkeni kullanmaya çalışıyorsun diye hata verecek!
}

Gördüğünüz gibi bir kaynak sadece bir değişken (pointer) tarafından kullanılabildi. İlk pointer artık geçerliliğini yitirdi.
Eğer bir fonksiyona parametre olarak verirsek, artık o kaynağın geçerli olduğu kısım fonksiyon bloğunun içi oluyor. Gönderdiğimiz yerdeki değişken artık kullanılamaz bir hal alıyor:

// Parametre olarak verilen Box'ın sahipliğini alacak. Bu nedenle fonksiyon bitiminde c'nin taşıdığı kaynak silinecek
fn box_sil(c: Box<i32>) {
    println!("c'yi istediğimiz gibi kullanıyoruz: {}", c);

} // <----- Burada artık c'nin geçerli olduğu blok sonlandığı için c değişkeni ve taşıdığı kaynak siliniyor.

fn main() {
    let a = Box::new(5i32);
    println!("a'nın taşıdığı değer: {}", a);

    box_sil(a); // <----- Burada a'nın kaynağını taşıdığımız için artık a kullanılamaz oluyor.
    println!("a'nın taşıdığı değer: {}", a); // a kullanılamaz olduğu için hata verecektir.
}  // <----- Eğer box_sil'i kullanmasaydık değişken burada kendiliğinden silinecekti.

Ayrıca son olarak bahsetmem gereken olay ownership ile mutability (değişebilirlik) arasındaki ilişki. Mutability yani bir değişkenin değişebilir olup olmaması Rust’ta mut anahtar kelimesi ile sağlanıyordu. Kısaca hatırlamak gerekirse:

let x = 10;
x = 20; // Değişkenler Rust'ta varsayılan olarak değiştirilemez (immutable) olduğu için hata verecektir.

let mut y = 15;
y = 25; // Burada 'mut' anahtar kelimesi kullandığımız için değişebilir (mutable) bir değişken elde ettik. Buradaki kod sorunsuz çalışacaktır.

Bir değişkenin mutable olup olmadığı sahipliğin transferi sırasında değiştirilebilir. Yani değiştirilemez bir değeri değiştirilebilir olarak başka bir değişkene şu şekilde aktarabilirsiniz:

fn main() {
    let immutable_vec = vec![1, 2, 3];
    println!("immutable_vec değişkeni: {:?}", immutable_vec);

    // Değiştirilemez bir değeri değiştirilebilir olarak başka bir değişkene taşıyoruz.
    let mut mutable_vec = immutable_vec;
    println!("mutable_vec değişkeni: {:?}", mutable_vec);

    // Artık bu değişkeni istediğimiz gibi değiştirebiliriz.
    mutable_vec.push(4);
    println!("şimdi de mutable_vec değişkeni: {:?}", mutable_vec);
}

Evet, artık Rust’ta ownership’in ne olduğunu öğrenmiş olduk. Bunun ardından bu kavramların devamı olan borrowing ve lifetime üzerine yazmayı planlıyorum. Umarım sizin için yararlı bir yazı olmuştur. Diğer yazılarımda görüşmek üzere, şimdilik hoşça kalın!