Rust: Ownership’i Anlamak
Rust programlarında bellek yönetimini nasıl düzenlediğini belirleyen bir kurallar kümesidir. Tüm programlar çalışırken bilgisayarın belleğini nasıl kullandıklarını yönetmelidir. Bazı dillerde program çalışırken artık kullanılmayan bellekleri düzenli olarak arayan bir çöp toplayıcı vardır; diğer dillerde ise programcı belleği ayrıca ayırıp serbest bırakmak zorundadır. Rust ise üçüncü bir yöntem kullanır: bellek, bir sahiplik sistemi aracılığıyla yönetilir ve derleyici tarafından kontrol edilen bir kurallar kümesiyle. Eğer kurallardan biri ihlal edilirse, program derlenmez. Sahiplik özelliklerinin hiçbiri program çalışırken hızını yavaşlatmaz.a
Sahiplik, birçok programcı için yeni bir kavram olduğundan, alışılması biraz zaman alabilir. İyi haber, Rust ve sahiplik sisteminin kurallarını daha fazla tecrübe edindikçe, güvenli ve verimli bir kod geliştirmeye doğal olarak daha kolay bulacaksınız. Devam edin!
Sahiplik anladığınızda, Rust’ı benzersiz kılan özellikleri anlama altyapısını sağlayacaksınız.
Stack ve Heap Kavramları
Birçok programlama dilinde, stack ve heap hakkında çok fazla düşünmeniz gerekmez. Ancak Rust gibi bir sistem programlama dilinde bir değerin stack veya heap üzerinde olup olmadığı, dilin nasıl davrandığını ve neden belirli kararlar almanız gerektiğini etkiler. Sahiplik ile ilgili bazı bölümler, bu bölümde stack ve heap ile ilgisi olduğundan, burada kısa bir açıklama bulacaksınız.
Stack ve heap, kodunuzun çalışma sırasında kullanabileceği bellek bölümlerinin ikisi de olmasına rağmen, farklı şekillerde yapılandırılmışlardır. Stack, aldığı değerleri sıralar ve değerleri ters sırada kaldırır. Bu, son giren ilk çıkar olarak tanımlanır. Bir stack düşünün: daha fazla tabak eklediğinizde, tabakları yığının üstüne koyarsınız ve bir tabak ihtiyacınız olduğunda, en üstteki tabakı alırsınız. Orta veya altından tabak ekleme veya çıkarma işe yaramaz! Veri ekleme, stack’e itme olarak adlandırılır ve veri kaldırma, stack’ten çıkarma olarak adlandırılır. Stack’te depolanan tüm verilerin bilinen, sabit boyutta olması gerekir. Derleme zamanında bilinmeyen veya değişebilecek boyutta olan veriler heap üzerinde depolanmalıdır.
Heap, düzenli değildir: heap’e veri eklediğinizde, belirli bir alan boyutu talep edersiniz. Bellek ayırıcısı, yeterince büyük bir boşluk bulur, kullanımda olduğunu işaretler ve bir işaretçi döndürür, bu da o konumun adresidir. Bu işlem, heap üzerinde alan ayırmak olarak adlandırılır ve bazen sadece alan ayırmak olarak kısaltılır (stack’e veri itme alan ayırmak olarak kabul edilmez). İşaretçinin heap boyutunun bilinmesi nedeniyle, işaretçiyi stack üzerinde depolayabilirsiniz, ancak gerçek verilere erişmek istediğinizde, işaretçiyi takip etmelisiniz. Bir restoran da oturuyormuş gibi düşünün. Girip sayınızı söylediğinizde, personel herkese sığacak bir boş masa bulur ve sizi oraya yönlendirir. Gruptaki birisi geç geldiyse, sizi nereye oturduğunuzu sorabilirler.
Stack’e itme, heap üzerinde alan ayırmaktan daha hızlıdır, çünkü ayırıcının yeni verileri depolamak için bir yer aramaması gerekir; bu konum her zaman stack’in üstündedir. Karşılaştırma olarak, heap üzerinde alan ayırmak daha fazla çalışmayı gerektirir, çünkü ayırıcı önce verileri tutacak kadar büyük bir alan bulmalıdır ve sonraki ayırmayı hazırlamak için defter tutma işlemlerini gerçekleştirmelidir.
Heap’teki verilere erişmek, stack’teki verilere erişmekten daha yavaştır, çünkü oraya ulaşmak için bir işaretçiyi takip etmeniz gerekir. Günümüzün işlemcileri, bellekte daha az zıplamalar yapıyorsa daha hızlıdır. Restoran örneğini devam ettirerek, bir sunucunun birçok masadan sipariş almasını düşünün. Bir masadan tüm siparişleri almak, sonraki masaya geçmek en verimlidir. A masasından bir sipariş, sonra B masasından bir sipariş, sonra A masasından bir sipariş ve sonra B masasından bir sipariş almak çok daha yavaş bir süreç olurdu. Aynı mantıkla, bir işlemci, diğer verilere yakın olan verilerle (stack’te olduğu gibi) çalışırsa daha iyi iş yapabilir, çünkü daha uzakta olabilir (heap’te olabilir).
Kodunuz bir fonksiyonu çağırdığında, fonksiyona geçirilen değerler (muhtemelen hafızada veriye işaret eden işaretçiler dahil) ve fonksiyonun yerel değişkenleri yığına (stack) itilir. Fonksiyon bittiğinde, bu değerler yığından (stack) çıkarılır.
Heap üzerinde hangi kısımların hangi verileri kullandığını izlemek, heap üzerindeki yinelenen veri miktarını minimuma indirmek ve kullanılmayan veriyi temizlemek için alan kalmamasını sağlamak, hepsi sahiplik tarafından ele alınan sorunlardır. Sahiplik hakkında anladığınızda, stack ve heap hakkında çok düşünmeniz gerekmez ancak sahipliğin ana amacının heap verilerini yönetmek olduğunu anlamak, neden bu şekilde çalıştığını açıklayabilir.
Sahiplik(Ownership) Kuralları
Öncelikle, sahiplik kurallarına bakalım. Bu kuralları örneklerle açıkladığımız sürece aklınızda tutun:
· Rust’taki her değerin bir sahibi vardır.
· Bir anda sadece bir sahip olabilir.
· Sahip kapsam dışına çıktığında, değer atılacaktır.
· Değişken Kapsamı
Rust temel sözdizimini geçtikten sonra, örneklerde tüm fn main() { kodunu içermeyeceğiz, bu yüzden uygulamaya geçerken, aşağıdaki örnekleri el ile bir main işlevine koyduğunuzdan emin olun. Sonuç olarak, örneklerimiz biraz daha kısadır ve böylece gerçek detaylara odaklanabiliriz, çünkü boilerplate kod değil.
Sahiplik hakkında ilk bir örnek olarak, bazı değişkenlerin kapsamına bakacağız. Kapsam, bir öğenin geçerli olduğu bir programın aralığıdır. Aşağıdaki değişkeni inceleyin:
Değişken s, string literal olarak bir dizeyi atar, yani dizinin değeri programımızın metninde sıkıştırılmıştır. Değişken, tanımlandığı noktadan itibaren geçerlidir ve geçerliliği geçerli olan kapsamın sonuna kadardır. Listing 4–1, değişkenin geçerli olduğu yerleri yorumlarla birlikte bir program göstermektedir.
Başka bir değişle, burada iki önemli zaman noktası var:
· s kapsama girdiğinde, geçerlidir.
· Kapsam dışına çıkana kadar geçerlidir.
Bu noktada, kapsamlar ve değişkenlerin geçerli olma zamanları arasındaki ilişki, diğer programlama dillerindeki ile benzerdir. Şimdi, String türünü tanıtarak bu anlayışı geliştireceğiz.
String Veri Tipi
Rust dilinde, sabit değerleri depolamak için string literal kullanılır. Ancak bazı durumlarda sabit değerler yetersiz kalabilir: Örneğin, kullanıcıdan aldığımız bir girdiyi depolamak istersek. Bu gibi durumlar için Rust dilinde String adında bir tip bulunmaktadır. Bu tip, hafızada alan ayıran ve derleme zamanında bilinmeyen bir metni depolayabilen dinamik bir metindir. String tipini bir string literal’den oluşturmak için aşağıdaki gibi bir kod yazabilirsiniz:
Bu tür metinler değiştirilebilir
Bellek ve Atama
String literal için, derleme zamanında içeriği biliyoruz, bu nedenle metin doğrudan son çalıştırılabilir dosyaya yerleştirilir. Bu nedenle string literal’lar hızlı ve verimlidir. Ancak bu özellikler sadece string literal’ın değiştirilemezliğinden kaynaklanır. Maalesef, derleme zamanında bilinmeyen boyutta ve çalışma sırasında değişebilen bir metin parçasının herhangi bir kısmının belleğini binary dosyasına koyamayız.
String tipiyle, değiştirilebilir ve büyüyebilir bir metin parçasını desteklemek için, derleme zamanında bilinmeyen bir miktar bellek (heap) ayırmamız gerekir. Bu anlamına gelir:
Bellek, çalışma zamanında bellek ayırıcısından talep edilmelidir. Bu belleği String’imizle işimiz bittiğinde bellek ayırıcısına geri döndürebilmemiz gerekir. İlk kısım bizim tarafımızdan yapılır: String::from çağrısı yaptığımızda, uygulaması gereken belleği talep eder. Bu durum çok yaygın bir programlama dilidir.
Ancak, ikinci kısım farklıdır. Garbage Collector (GC) olan dillerde, GC kullanılmayan belleği izler ve temizler ve biz bununla ilgilenmemiz gerekmez. GC olmayan çoğu dilde, belleğin kullanılmayacağını belirleyip özellikle serbest bırakılması için kod çağırmak bizim sorumluluğumuzdadır, aynı zamanda istediğimiz gibi. Bu doğru bir şekilde yapmak tarihsel olarak zor bir programlama problemidir. Unutursak bellekte israf olur. Önce yaparsak geçersiz bir değişken olur. İki kez yaparsak bu da bir hata olur. Bir atama ile tam olarak bir serbest bırakma eşleştirmemiz gerekir.
Rust farklı bir yol izler: bellek, sahip olduğu değişkenin kapsamından çıktığında otomatik olarak geri döndürülür. Listing 4–1'de kapsam örneğimizin bir versiyonunu String yerine string literal kullanarak gösterebiliriz:
Bellek ayırıcısına String’imize ihtiyaç duyduğumuz belleği geri döndürebileceğimiz doğal bir nokta vardır: s kapsamından çıktığında. Bir değişken kapsamından çıktığında, Rust bizim için özel bir fonksiyon çağırır. Bu fonksiyon “drop” olarak adlandırılır ve belleği geri döndürecek kodu yazan String yazarının koyabileceği bir yerdir. Rust, kapatılacak süslü parantezde otomatik olarak drop’u çağırır.
Not: C++’da, bir öğenin yaşam döngüsü sonunda kaynakları serbest bırakmak bu desen “Resource Acquisition Is Initialization (RAII)” olarak adlandırılır. Rust’taki drop fonksiyonu RAII desenlerini kullanmışsanız tanıdık gelecektir.
Bu desen, Rust kodunun yazımında ciddi bir etkiye sahiptir. Şu anda basit görünebilir, ancak kodun davranışı, hafızada ayırdığımız veriyi birden fazla değişkenin kullanmasını istediğimiz daha karmaşık durumlarda beklenmedik olabilir. Şimdi bunların bazılarını inceleyelim.
Değişkenler ve Veriler Arasındaki Etkileşimler: Move
Çoklu değişkenler Rust’ta aynı veriyle farklı şekillerde etkileşebilir. Listing 4–2'de bir tam sayı kullanarak bir örnek inceleyelim.
Bu ne yaptığını muhtemelen tahmin edebiliriz: “x’e 5 değerini bağlayın; sonra x’deki değerin bir kopyasını yapın ve onu y’ye bağlayın”. Şimdi iki değişkenimiz var, x ve y, ve her ikisi de 5. Bu gerçekten de olmaktadır, çünkü tam sayılar sabit boyutlu basit değerlerdir ve bu iki 5 değeri yığına itilir.
Şimdi String versiyonuna bakalım:
Bu oldukça benzer görünüyor, bu yüzden nasıl çalıştığının aynı olacağını düşünebiliriz: yani, ikinci satır, s1'deki değerin bir kopyasını yaparak onu s2'ye bağlar. Ancak tam olarak bu olmuyor.
String’in altında neler olduğunu görmek için Şekil 4–1'e bakın. Bir String, soldaki üç bölümden oluşur: dize içeriğini tutan belleğe işaret eden bir işaretçi, bir uzunluk ve bir kapasite. Bu veri grubu yığında saklanır. Sağda, dizinin içeriğini tutan hafızadaki bellek bulunur.
Uzunluk, String’in içeriğinin kullandığı bayt cinsinden bellek miktarıdır. Kapasite, String’in ayırıcıdan aldığı toplam bayt cinsinden bellek miktarıdır. Uzunluk ve kapasitenin farkı önemlidir, ancak bu bağlamda değil, bu yüzden şimdilik kapasiteyi göz ardı etmek iyi olacaktır.
s1'i s2'ye atadığımızda, String verisi kopyalanır, yani yığına işaret eden işaretçi, uzunluk ve kapasiteyi kopyalarız. Hafızadaki verileri kopyalamazız. Başka bir deyişle, bellekteki veri temsili Şekil 4–2'de görülür.
Temsil, Rust’ın yerine hafıza verisini de kopyalaması durumunda görünmez. Eğer böyle bir şey yapmış olsaydı, s2 = s1 işlemi hafızadaki veriler büyük olsaydı, çalışma zamanı performansı açısından çok pahalı olabilirdi.
Daha önce, bir değişkenin kapsamından çıktığında Rust’ın otomatik olarak drop fonksiyonunu çağırdığını ve bu değişken için hafıza belleğini temizlediğini söyledik. Ancak Şekil 4–2, iki veri işaretçisinin aynı konumu gösteriyor. Bu bir sorun: s2 ve s1 kapsamından çıktığında, her iki değişken de aynı belleği serbest bırakmaya çalışacaktır. Bu, çift serbest hatası olarak bilinir ve önceden bahsettiğimiz bellek güvenliği hatalarından biridir. Belleği iki kez serbest bırakmak, bellek bozulmasına neden olabilir ve potansiyel olarak güvenlik açıklarına yol açabilir.
Bellek güvenliğini sağlamak için, s2 = s1 satırından sonra, Rust s1'i geçerli olmayan bir referans olarak kabul eder. Bu nedenle, s1 kapsamından çıktığında Rust’ın hiçbir şey serbest bırakması gerekmez. Geçerli olmayan bir referansı kullanmaya çalıştığınızda ne olacağını görün:
Bu kod derlenmez!
Bu hatayı görürsünüz, çünkü Rust geçerli olmayan referansı kullanmanızı engeller.
Eğer diğer dillere ait olan “shallow copy” ve “deep copy” terimlerini duymuşsanız, bir verinin kopyalanması sırasında sadece işaretçi, uzunluk ve kapasite kopyalanarak verinin kendisi kopyalanmaması (shallow copy yapılması) bir kavram olarak gelebilir. Ancak Rust dilinde bu durumda ilk değişken geçersiz kılınır ve bu nedenle shallow copy olarak adlandırılmaz, taşıma (move) olarak adlandırılır. Bu örnekte, s1 değişkeninin s2 değişkenine taşındığı söylenebilir. Bu durumda gerçekleşen işlem aşağıdaki resimde gösterilmiştir.
Bu problemi çözmüş olduk! Sadece s2 geçerli olduğundan, s2'nin scope’u dışına çıktığında tek başına bellek boşaltılacak ve işlem tamamlanmış olacaktır.
Ayrıca, bu durumda bir tasarım seçimi vardır ve Rust dilinde verinin “deep” kopyası otomatik olarak oluşturulmaz. Bu nedenle, herhangi bir otomatik kopyalama işleminin runtime performansı açısından maliyetli olmayacağı varsayılabilir.
Değişkenler ve Veriler Arasındaki Etkileşimler: Clone
Eğer String tipindeki heap verisinin kopyalanmasını (sadece stack verisi kopyalanmak yerine) gerçekleştirmek istersek, yaygın olarak kullanılan bir yöntem olan clone kullanabiliriz. Method sözdizimini “Chapter 5” bölümünde daha detaylı olarak inceleyeceğiz, ancak methodlar birçok programlama dilinde yaygın bir özellik olduğu için muhtemelen daha önce karşılaşmışsınızdır.
clone methodunun nasıl kullanıldığına dair bir örnek aşağıdaki gibidir:
Bu kod parçacığı çalışır ve aşağıdaki resimde gösterildiği gibi, heap verisi gerçekten de kopyalanır.
clone çağrısı gördüğünüzde, belirli bir kodun çalıştığını ve bu kodun pahalı olma ihtimalini bilmeniz gerekir. Bu, bir şeylerin farklı olduğu bir görsel işaretçidir.
Sadece Stack Verisi: Kopyalama
Henüz konuşmadığımız bir detay daha var. Bu, tamsayılar kullanan kod — Listing 4–2'de gösterilen kısım — çalışır ve geçerlidir:
Ancak bu kod öğrendiğimiz şeylerle çelişiyor gibi görünüyor: clone çağrısı yapmıyoruz, ancak x hala geçerli ve y’ye taşınmamış.
Bu neden, derleme zamanında bilinen bir boyuta sahip olan tamsayı gibi türlerin tamamıyla stack üzerinde depolandığındandır, bu nedenle gerçek değerlerin kopyalarını yapmak hızlıdır. Bu, y değişkenini oluşturduktan sonra x’in geçersiz olmamasını istemeyiz demektir. Diğer bir deyişle, burada derin ve shallow kopyalama arasında bir fark yoktur,
Bu neden, derleme zamanında bilinen bir boyuta sahip olan tamsayı gibi türlerin tamamıyla stack üzerinde depolandığındandır, bu nedenle gerçek değerlerin kopyalarını yapmak hızlıdır. Bu, y değişkenini oluşturduktan sonra x’in geçersiz olmamasını istemeyiz demektir. Diğer bir deyişle, burada derin ve shallow kopyalama arasında bir fark yoktur, bu nedenle clone çağrısı yapmak herhangi bir şeyi shallow kopyalamanın farklı bir yapıya sahip değildir.
Rust, tamsayı gibi stack üzerinde saklanan türler için kullanabileceğimiz özel bir notasyon olan Copy trait’i sağlar (bölüm 10'da trait’ler hakkında daha fazla konuşacağız). Eğer bir tür Copy trait’ini uygularsa, onu kullanan değişkenler hareket etmez, ancak basit bir şekilde kopyalanır ve başka bir değişkene atandıktan sonra hala geçerlidir.
Eğer bir tür veya parçaları Drop trait’ini uygulamışsa, Rust bir türü Copy notasyonu ile işaretleyemez. Eğer bir türün değerinin scope dışına çıktığında bir şeyler yapması gerektiğinde ve o türe Copy notasyonu eklersek, derleme zamanında bir hata alırız. Bir türünüze Copy notasyonunu ekleyip trait’i nasıl uygulayabileceğinizi öğrenmek için, “Türetilebilir Traitler” bölümüne bakınız.
Peki Copy trait’ini uygulayan türler nelerdir? Belirli bir tür için kesin olarak emin olmak için belgelere bakabilirsiniz, ancak genel bir kural olarak, basit skaler değerlerden oluşan herhangi bir grubun Copy trait’ini uygulayabileceği ve tümleştirme veya bir kaynak formu olmayan herhangi bir şeyin Copy trait’ini uygulayamayacağı söylenebilir. Burada Copy trait’ini uygulayan bazı türlerden bazıları:
· Tüm tamsayı türleri, örneğin u32.
· Boolean türü, bool, true ve false değerleri ile.
· Tüm ondalık noktalı türler, örneğin f64.
· Karakter türü, char.
· Tuplar, eğer içinde sadece Copy uygulayan türler bulunuyorsa. Örneğin, (i32, i32) Copy trait’ini uygular, ancak (i32, String) uygulamaz.
Rust, bir değerin bir fonksiyona geçirilme mekanizmasını, değerin bir değişkene atanması ile benzerdir. Bir değişkenin bir fonksiyona geçirilmesi, atama yaptığı gibi hareket edecektir veya kopyalanacaktır. Listing 4–3, birkaç açıklama içeren örnekler içermektedir. Bu açıklamalar, değişkenlerin scope’larının başlangıcı ve bitimini göstermektedir.
Eğer takes_ownership çağrısından sonra s’yi kullanmayı deneysek, Rust bir derleme zamanı hatası atar. Bu statik kontroller, hatalardan korur. main’de s ve x’i kullanarak kod ekleyin ve sahiplik kurallarının nerede kullanabileceğinizi ve nerede kullanmanızı engellediğini görün.
Dönüş Değerleri ve Scope
Değerleri döndürmek sahiplik transferi yapabilir. Listing 4–4 bir değeri döndüren bir fonksiyonun bir örneğini gösterir ve Listing 4–3'teki gibi benzer açıklamalarla birlikte.
Bir değişkenin sahipliği her seferinde aynı modeli izler: başka bir değişkene bir değer atamak onu hareket ettirir. dropYığındaki verileri içeren bir değişken kapsam dışına çıktığında , verilerin sahipliği başka bir değişkene taşınmadığı sürece değer tarafından temizlenir .
Bu işe yarasa da, mülkiyeti almak ve ardından her işlevle mülkiyeti geri vermek biraz sıkıcıdır. Ya bir fonksiyonun bir değer kullanmasına izin vermek istiyorsak, ancak mülkiyeti almıyorsak? İşlevin gövdesinden kaynaklanan ve geri döndürmek isteyebileceğimiz herhangi bir veriye ek olarak, ilettiğimiz her şeyin, tekrar kullanmak istiyorsak geri verilmesi gerekmesi oldukça can sıkıcıdır.
Rust, Liste 4–5'te gösterildiği gibi, bir demet kullanarak birden fazla değer döndürmemize izin veriyor.
Ancak bu, ortak olması gereken bir konsept için çok fazla tören ve çok fazla çalışma. Neyse ki Rust’ın, referans adı verilen, mülkiyeti aktarmadan bir değer kullanma özelliği var .