Neden yeni bir programlama dili öğrenmelisiniz?
David Marino'ya teşekkürlerle!
Karikatürün çevirisi:
Rust: Bu gece şöförlük yapacağım.
Dış ses: Biliyoruz.
Rust: Konuşurken yemek yeme çünkü boğazına takılırsa ölürsün. Haha.
Bu rehberin amacı kutsal kitabımız gibi İnternet'te bulabileceğiniz çeşitli kaynakları anlayacak kadar Rust okuryazarlığı aşılamaktır. Rehberi, bu programlama dilinin gücünü tonla şeyin arasına girmeden anlamak ve denemek için bir fırsat olarak düşünebilirsiniz.
Einstein'in dediği gibi, "Her şeyi olabildiğince yumuşak yapın, ama yumuş yumuş olmasın." (Ç.N: Einstein'in "Her şeyi daha sade yapın, ama basit değil" sözüne gönderme.) Rust'a dair öğrenecek epey şey var ve pek çok şey iyice kafanızı bulandırabilir. Burada kastedilen "yumuşaklık", zorluğuna karşın Rust'ın çözümlerinin uygulamalı olarak sunulmasıdır. Sorunları anlamak, hemen çözümleri görmekten çok daha faydalıdır. Bunu kayaçların süreçlerini anlamak için coğrafya dersinden sonra hemen dağ bayır gezintiye çıkmak gibi düşünebilirsiniz. Bunun elbette zorluğu olacak ama neticesi oldukça hoş olacak. Katılanlar oldukça memnun olacak ve birbirlerine yardımcı olmaktan çekinmeyecekler. (Buna benzer olarak) Rust kullanıcıları forumu (İngilizce) ve oldukça aktif bir subreddit (Bu da İngilizce) var. Aynı zamanda bazı sorularınız varsa sıkça sorular sorular (Herhâlde İngilizce)'a da bakabilirsiniz.
Ama hepsinden önce, neden durup dururken yeni bir dil öğrenelim ki? Bu öyle ya da böyle epey vakit ve enerji alan, durup dururken yapılmayacak bir iş. Bu iş size çok süper, über bir iş buldurmayacak olsa bile beyin kaslarınızı çalıştıracak ve sizi daha iyi bir programcı yapacaktır. İyi bir yatırım mı? Tartışılır. Yine de gerçek anlamda bir şey öğrenmezseniz zaman içerisinde durgunlaşacak ve on yılı aşkın aynı şeylere bakan birisi olacaksınız.
Rust'ın Parladığı Nokta
Rust statik ve güçlü tiplenen bir sistem programlama dilidir. Statik bütün tiplerin derleme zamanında bilinmesi anlamına gelir, güçlü ise programın çalışma mantığının tip mantığının dışına çıkmaması demektir. Başarılı bir derleme aynı zamanda C gibi tekinsiz bir dile göre çok daha fazla şeyi garanti ettiğiniz anlamına gelir. Sistem (Programlama)'dan kasıt makine için en uygun kodun sıkı bir bellek denetimi ile oluşturulmasıdır. Kullanım alanları biraz ilginç: işletim sistemleri, donanım sürücüleri ve bir işletim sistemi bile olmayan gömülü sistemler. Ancak Rust normal uygulamaları programlamak için de gayet uygundur.
C ve C++ ile Rust arasındaki en büyük fark doğal olarak emniyetli olmasıdır. Bütün bellek erişimleri denetlendiğinden kazara belleği darmaduman etmeniz mümkün değildir.
Rust'ın ana prensipleri şunlardır:
- Verinin emniyetli olarak ödünç alınmasını zorlamak
- Fonksiyon, metot ve kapamalar (closure) ile veriye müdahale etmek
- Verileri çokuzlu (tuple), yapılar (struct) ve numaralandırmalar (enum) ile bir araya toplamak
- Verileri örüntü eşleştirme (pattern matching) ile seçmek ve parçalarına ayırmak
- Verilerin görevini özellikler (trait) aracılığı ile tanımlamak
Cargo sayesinde oldukça hızlı büyüyen bir ekosistem olsa da biz çoğunlukla dilin temel özelliklerini anlamaya ve standart kütüphaneyi kullanmaya eğileceğiz. Tavsiyem çok sayıda ufak programlar yazmanızdır, bundan dolayı rustc
kullanmayı öğrenmek en önemli beceri olacaktır hâliyle. Bu rehberde bulacağınız pek çok örneği çalıştırmak için derleyen ve programı yürüten rrun
diye minik bir betik hazırladım:
rustc $1.rs && ./$1
Kurulum
Bu rehber makinenize Rust'ı kurmayı gerektirir. Neyse ki bunu yapmak çok basit.
$ curl https://sh.rustup.rs -sSf | sh
$ rustup component add rust-docs
Kararlı sürümü kullanmanızı tavsiye ediyorum; sonradan kararsız sürümü kurup aralarında geçiş yapmak da kolaydır.
Bu komut derleyiciyi Cargo paket yöneticisini, API belgelendirmesini ve Rust kitabını indirir. Uzun yolculukların hepsi bir adımla başlar ve bu adımı atmak zahmetsizdir.
rustup
komutu Rust kurulumunuzu kontrol eden komuttur. Yeni bir kararlı sürüm yayınlandığında yalnızca rustup
yazarak güncelleyebilirsiniz. rustup doc
ise resmi Rust belgelerini çevrimdışı olarak tarayıcınızda açar.
Muhtemelen beğendiğiniz bir editor vardır ve bu editörün temel Rust desteği varsa o editörü istediğiniz gibi kullanabilirsiniz. Öncelikle sözdiziminin renklendirilmesi ile yetinmenizi öneririm, programlarınız büyüdükçe daha ötesine geçersiniz.
Şahsen varsayılan olarak Rust desteği ile gelen nadir editörlerden birisi olan Geany'i beğeniyorum; paket yöneticisiyle kurulabildiği için Linux üzerinde kullanmak gayet kolaydır, diğer platformlarda da pekâlâ kullanılabilir.
Esas nokta Rust programlarını düzenleyebilmek, derleyebilmek ve çalıştırabilmektir. Programlamayı parmaklarınızla anlayın, kodu kendiniz yazın ve editörün kodu nasıl düzelteceğini öğrenin.
Zed Shaw'ın programlamayı Python ile öğrenme tavsiyesi geçen yıllara rağmen dil fark etmeksizin dikkate değerdir. Ona göre programlamayı öğrenmenin bir müzik enstürmanı öğrenmek gibidir - esas sır pratik ve sabırdır. Taiciçüen gibi yumuşak dövüş sanatlarının ve Yoga'nın bir güzel öğüdü vardır; gerilimi hissedin, ama aşırı gerilmeyin. Karanlık bir spor salonunda sert bir disiplini sadakatle zayıflamaya çalışmıyorsunuz; rahatlayın.
Kötü İngilizcemi veya yetersiz Rust bilgimi yakalayarak beni uyaran destekçilere teşekkürler etmek isterim, aynı zamanda David Marino'ya Rust'ı parlayan zırhı içerisinde sempatik fakat mantığına sıkı sıkıya bağlı bir şovalye olarak çizdiği için de teşekkürlerimi sunarım.
Steve Donovan © 2017-2018 MIT Lisans Versiyonu 0.4.0
Çeviri: Emrecan Şuşter
Bazı Çeviri Notları
Bu çeviriyi motamot çevirmek yerine, kaldı ki bunu makineler de yapabiliyor, içeriğinde de kültüre özgü değişiklikler yaptım. Maksat rehberin ana dilinde yazılmış gibi bir akıcılıkla okunabilmesi idi. Çoğu yerin birebir çevrilmediğini, bazı şeylerin eklendiğini de göreceksiniz. Bu İngilizce ile Türkçe arasındaki hem kültürel hem de anlamsal farklardan dolayıdır.
Epey yerleşmiş, Türkçesi de (göreceli olarak) oldukça anlamsız gelen bazı kavramları doğrudan İngilizce hâliyle metin içinde kullandım. (Heap ve Stack gibi) Ancak bunun dışında çoğu yerde Türkçe karşılıkları kullanmaya özen gösterdim ve yanların parantez içinde veya "/" işareti ile İngilizce karşılıklarını bildirdim. Terimler için "Rust Dili" projesinin sözlüğünden faydalandım.
Bunun dışında kod parçalarına dokunmadım, içindeki yorumları bile olduğu gibi bıraktım zira bu kod parçaları değiştiği zaman çıkacak ekstra iş gücünden çekindim.
Çeviriyi geliştirmek için her zaman çevirinin Github deposuna uğrayabilirsiniz.
Merhaba Dünya
"Merhaba Dünya"'nın esas amacı, C'nin ilk versiyonu yazıldığından beri, derleyiciyi test etmek ve gerçek bir program çalıştırmaktır.
// hello.rs fn main() { println!("Hello, World!"); }
$ rustc hello.rs
$ ./hello
Hello, World!
Rust'ta süslü ayraçlar ve noktalı virgül vardır, C++ tarzı yorum satırları bulunur ve bir de main
fonksiyonu bulunur. Şimdiye kadar bu kısmı tanıyorsunuz. Ünlem işareti, bunun bir makro çağrısı olduğunu gösterir. C++ programcıları için bu biraz caydırıcı olabilir, çünkü onların tek bildiği makrolar o abuk subuk C makrolarıdır - ama bu makroların çok daha yetenekli ve kabul edilebilir olduğunu rahatlıkla söyleyebilirim.
"Güzel de bu ünlem işaretini nereye sıkıştıracağımı nereden bileyim" diye aklından geçirenler olmuştur. Ancak derleyici beklemediğiniz kadar yardımsever; eğer ünlem işaretini unutursanız şunu görürsünüz:
#![allow(unused)] fn main() { error[E0425]: unresolved name `println` --> hello2.rs:2:5 | 2 | println("Hello, World!"); | ^^^^^^^ did you mean the macro `println!`? }
Bir dili öğrenmek o dilin hatalarıyla barışık olmak demektir. Derleyiciyi sizi azarlayan bir bilgisayar olarak görmek yerine katı ama dostane davranan bir yardımcı olarak görmeye çalışın, çünkü başlangıçta epeyce kırmızı yazılar göreceksiniz. Derleyicinin sizin hatalarınızı yüzünüze vurması, insanların sizin yüzünüze vurmasından kat kat daha iyidir.
Bir sonraki aşama değişken atamaktır.
// let1.rs fn main() { let answer = 42; println!("Hello {}", answer); }
Yazım hataları derleme zamanında anlaşılır, Python ya da JavaScript gibi çalışma zamanını beklemenize gerek yoktur. Bu, sizi daha sonra pek çok stresten kurtaracak! Eğer "answer" yerine "answr" yazarsam, derleyici bu konuda epey kibar davranır:
#![allow(unused)] fn main() { 4 | println!("Hello {}", answr); | ^^^^^ did you mean `answer`? }
println!
makrosu bir format karakter dizesi alır; Python3'te kullanılan formatlama stiline epey benzerdir.
Bir başka kullanışlı makro ise assert_eq!
. Bu Rust testlerinin direğidir, iki şeyin birbirine eşit olduğu varsayarsınız. (assert = varsaymak) Eğer eşit değillerse, panik.
// let2.rs fn main() { let answer = 42; assert_eq!(answer,42); }
Herhangi bir çıktı olmayacaktır. Ancak 42'yi 40 ile değiştirirseniz:
thread 'main' panicked at
'assertion failed: `(left == right)` (left: `42`, right: `40`)',
let2.rs:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.
Ve bu bizim Rust'taki karşımıza çıkan ilk çalışma zamanı hatası.
Döngüler ve Koşullamalar
Enteresan olan her şey tekrar tekrar yapılabilir:
// for1.rs fn main() { for i in 0..5 { println!("Hello {}", i); } }
Aralık (Range) kapsayıcı değildir, bundan dolayı i
'nin değeri 0 ila 4 arasında değişir. Dizilerin indekslerinin sıfırdan başladığı bir dilde pek de olağandışı değildir.
Enteresan şeyler de koşula bağlı olarak da gerçekleştirilebilir.
// for2.rs fn main() { for i in 0..5 { if i % 2 == 0 { println!("even {}", i); } else { println!("odd {}", i); } } }
even 0
odd 1
even 2
odd 3
even 4
i % 2
eğer i, 2'ye tam olarak bölünebiliyorsa sıfır olur; Rust C-tarzı operatörler kullanır. Koşulların etrafında parantez yoktur, tıpkı Go'daki gibi, ama blokların etrafında süslü parantezlerin kullanımı zorunludur.
Aynı şey, daha da ilginç bir yoldan yapılabilir:
// for3.rs fn main() { for i in 0..5 { let even_odd = if i % 2 == 0 {"even"} else {"odd"}; println!("{} {}", even_odd, i); } }
Klasik olarak, programlama dillerinde deyimler (statement) (If
gibi) ve ifadeler (expression) (1+i
gibi) bulunur. Rust'ta, her şeyin değeri olabilir ve bunlar bir ifade olabilir. C'nin o garabet "ternary/üçlü operatörüne" burada ihtiyacımız yok.
Aynı zamanda bloklarda noktalı virgül olmadığına da dikkat edin!
Şeyleri Şeylere Eklemek
Bilgisayarlar aritmatik konusunda epey iyidir. 0'dan 4'e kadar bütün sayıları toplamayı deneyelim.
// add1.rs fn main() { let sum = 0; for i in 0..5 { sum += i; } println!("sum is {}", sum); }
Ama derlenirken hata verecektir:
error[E0384]: re-assignment of immutable variable `sum`
--> add1.rs:5:9
3 | let sum = 0;
| --- first assignment to `sum`
4 | for i in 0..5 {
5 | sum += i;
| ^^^^^^^^ re-assignment of immutable variable
"Immutable"? Değişemeyen değişen mi? let
değişkenlerinin değeri sadece atanırken belirtilebilir. mut
ismindeki sihirli sözlük (nolur bu değişkeni değişebilir yap) işi halledecektir:
// add2.rs fn main() { let mut sum = 0; for i in 0..5 { sum += i; } println!("sum is {}", sum); }
Değişkenlerin varsayılan olarak yeniden yazılabilir olduğu dillerden geçerken bu biraz kafa karıştırıcı olabilir. Bir şeyi değişken yapan şey onun değerinin çalışma zamanında atanmasıdır, sabitlerin (constant) aksine. Bu kavramlar matematikte de kullanılır, mesela "let n be the largest number in set S (N'i S kümesi içerisindeki en büyük değer yap)" derken.
Değişkenlerin varsayılan olarak salt okunur olmasının ardında bir neden vardır. Büyük bir programda, değerlerin nerede atandığını bulmak oldukça güçleşebilir. Bundan dolayı Rust, değişimlerin bildirilmesini ister. Dilde zekice epey şey var ancak dil hiçbir şeyin örtük kalmamasına ayrıca özen gösteriyor.
Rust hem statik hem de güçlü tiplenen bir dildir - bu kavramlar genelde karıştırılır, ama C (statik ama zayıf tiplenen) ve Python'u (dinamik ama güçlü tiplenen) göz önüne getirin. Statik tiplemede tip derleme zamanında bilinir, dinamik tiplemede ise çalışma zamanında.
Tam da bu anda, Rust'ın sizden tipleri gizlediğini sezebilirsiniz. Mesela i
'nin tam değeri nedir? Derleyici için bu sorun değildir, 0'dan başlarken, tip çıkarımı ile (type referance) bu sayılar i32
(Dört bitlik işaretli tam sayı) oluverir.
Hadi net bir değişim yapalım, 0'ı 0.0 ile değiştirip hataları görelim:
error[E0277]: the trait bound `{float}: std::ops::AddAssign<{integer}>` is not satisfied
--> add3.rs:5:9
|
5 | sum += i;
| ^^^^^^^^ the trait `std::ops::AddAssign<{integer}>` is not implemented for `{float}`
|
Pekâlâ, şimdi güldük eğlendik ama bu da nesi? Bütün operatörler (Mesela +=
) bir özelliğe (trait) denk gelir ki özellik (trait) somut tiplere yeni özellikler ekleyen soyut arabirimlerdir. Özelliklerle daha sonra ilgileneceğiz, ama burada bilmeniz gereken bütün şey AddAssign
, +=
operatörünü sağlayan özelliğin adı olduğudur ve hata mesajının demek istediği şey bu özelliğin noktalı sayılara bu operatör tam sayılarla işlem yapmak için uygulanmadığıdır. (Operatör özelliklerinin tam listesi burada.)
Rust'ta her şey bellidir - sırf sizin gönlünüz olsun diye tam sayıyı noktalı sayıya gizlice çevirmeyecektir.
// add3.rs fn main() { let mut sum = 0.0; for i in 0..5 { sum += i as f64; } println!("sum is {}", sum); }
Fonksiyonların Tipleri de Apaçık Ortadadır
Fonksiyonlar da derleyicinin sizin için tipleri tahmin etmekle uğraşmayacağı yerlerden birisidir. Aslında bu üzerinde düşünülerek alınmış bir karardır çünkü Haskell gibi güçlü tip çıkarımlarına sahip dillerde tip isimleri nadiren yazılır. Aslında Haskell için tipleri açıkça yazmak iyi yaklaşımdır. Rust ise her zaman bunu mecbur tutar.
İşte tanımladığımız basit bir fonksiyon:
// fun1.rs fn sqr(x: f64) -> f64 { return x * x; } fn main() { let res = sqr(2.0); println!("square is {}", res); }
Rust biraz eski bir argüman bildirimi tarzı kullanmakta, tip isimden sonra gelir. Bu, Pascal gibi Algol'dan türemiş dillerde kullanılan tarzdır.
Hatırlatalım, tam sayı noktalı sayıya dönüşmez - eğer 2.0
'ı 2
ile değiştirirseniz nurtopu gibi bir hatanız olmuş olur:
#![allow(unused)] fn main() { 8 | let res = sqr(2); | ^ expected f64, found integral variable | }
Rust'da fonksiyonlarda çok az return
deyiminin kullanıldığının görürsünüz. Daha çok, şuna benzer ifadeler vardır:
#![allow(unused)] fn main() { fn sqr(x: f64) -> f64 { x * x } }
Fonksiyonun gövdesi ({ }
içi) tıpkı "ifade olarak kullanılan if"teki gibi son ifadenin değerini alır.
Noktalı virgülleri refleks olarak kazara ekleyebilirsiniz ve o zaman şöyle bir hata alırsınız:
#![allow(unused)] fn main() { | 3 | fn sqr(x: f64) -> f64 { | ^ expected f64, found () | = note: expected type `f64` = note: found type `()` help: consider removing this semicolon: --> fun2.rs:4:8 | 4 | x * x; | ^ }
()
tipi boş tiptir, yokluktur, void
dir, "nothing"dir, tasavvuftaki fakrdır. Rust'ta her şeyin değeri vardır, ama bazen sadece yoktur. Derleyici bunun sıkça karşılaşılan bir durum olduğunu bilir, ve size aslında yardım eder. (C++ derleyicileriyle vakit harcamış zavallı ruhlar bunun ne kadar faydalı olduğunun farkındadır.)
Return
kullanılmayan ifadelere biraz daha örnek verelim:
#![allow(unused)] fn main() { // absolute value of a floating-point number fn abs(x: f64) -> f64 { if x > 0.0 { x } else { -x } } // ensure the number always falls in the given range fn clamp(x: f64, x1: f64, x2: f64) -> f64 { if x < x1 { x1 } else if x > x2 { x2 } else { x } }
Return
kullanmak yanlış değil, ama kod onsuz daha temiz. Yine de, bir fonksiyondan erken dönmek için return
kullanabilirsiniz.
Bazı işlemler zarif bir yoldan özyinelemeli olarak yazılabilir:
#![allow(unused)] fn main() { fn factorial(n: u64) -> u64 { if n == 0 { 1 } else { n * factorial(n-1) } } }
Başta tuhaf görünebilir ve en iyisi kağıt kalemle örnekler üzerinde düşünmektir. Ancak, bir işlemi yapmanın en etkili yolu değildir.
Değerler aynı zamanda referans olarak da iletilebilir. &
ile yaratılmış bir referans *
dereferans edilebilir. (Ç.N: De- olumsuzlaştırma öneki)
fn by_ref(x: &i32) -> i32{ *x + 1 } fn main() { let i = 10; let res1 = by_ref(&i); let res2 = by_ref(&41); println!("{} {}", res1,res2); } // 11 42
Bir fonksiyonun argümanlarını değiştirebilmesini mi istiyorsunuz? Değişebilir referans (Mutable referance) kullanın:
// fun4.rs fn modifies(x: &mut f64) { *x = 1.0; } fn main() { let mut res = 0.0; modifies(&mut res); println!("res is {}", res); }
Bu C++'dan çok C'ye benzedi. Açıkça referansı (&
ile) belirtmelisiniz ve aynı şekilde *
ile deferans etmelisiniz. Sonra da mut
'u ekleyin çünkü varsayılan değişebilir değiller. (Bana hep C++ referansları C'ye göre gözden kaçırılmaya daha müsaitmiş gibi gelir.)
Temel olarak, Rust burada biraz bizi yoruyor ve fonksiyonlardan değer döndürmeye zorluyor. Neyse ki, Rust'ın "işlem başarılı, bu da sonucu" gibi güçlü ifadeleri olduğundan &mut
'u sıklıkla kullanmayız. Referans kullanmak, büyük bir nesnemiz olduğunda ve onu kopyalamak istemediğimizde dikkate değerdir.
"Değişkenden sonra tip gelir" tarzı let
için de gayet uyuyor, bir değişkenin türünü belirtmek istersek eğer:
#![allow(unused)] fn main() { let bigint: i64 = 0; }
Yolumuzu Yordamımızı Bilmek
Şimdi belgelendirmeye bakmanın tam zamanı. Belgeler makinenize yüklenmiş olmalı ve onu rustup doc --std
komutu ile tarayıcınızda açabilir olmalısınız.
Arama kutucuğunun en üstte olduğuna dikkat edin, zira bu sizin en yakın dostunuz olacak; çalışmak için İnternet'e gerek duymaz.
Diyelim ki matematiksel fonksiyonların nerede olduğunu merak ediyorsunuz, "cos" diye aratmanız yeterli. Klavyede dokunduğunuz ilk iki tuş her iki noktalı sayı tipi için de var olduğunu gösterir. Aradığımız işlem, değerin kendisinde metot olarak tanımlıdır, mesela şöyle:
#![allow(unused)] fn main() { let pi: f64 = 3.1416; let x = pi/2.0; let cosine = x.cos(); }
Sonuç sıfıra epey yakın çıkacaktır, belli ki tahmini değere değil gerçek PI sayısına ihtiyacımız var.
(Sahi, neden f64
diye belirtmemize gerek var ki? Aslına bakarsanız o olmadan değerimiz f32
veya f64
olabilir ki bunlar epey farklı şeyler.)
(Ç.N: Noktalı sayı tutan)
Cos
için verilen örneğe bakalım, bunu çalışabilir bir programa çevirdik. (assert!
de assert_eq!
'in amcaoğlu oluyor, verilen ifade kesinlikle doğru olmalıdır.)
fn main() { let x = 2.0 * std::f64::consts::PI; let abs_difference = (x.cos() - 1.0).abs(); assert!(abs_difference < 1e-10); }
std::f64::consts::PI
şu güzel ortamı iyice bozdu! ::
C++'daki anlamıyla aynı şeye denk geliyor, (Bazı dillerde yerine .
kullanılır) - bu tam yolu belirtilmiş bir isim. Bu tam adı, PI
için yaptığımız aramayı yaparken ikinci klavye tuşlamasında alıyoruz.
Şimdiye dek, bizim ufak Rust programımıza "Merhaba Dünya" tartışmalarında gündemi meşgul eden import
ya da include
gibi şeyleri eklemedik. Hadi, programımızı bir use
deyimi ile şenlendirelim:
use std::f64::consts; fn main() { let x = 2.0 * consts::PI; let abs_difference = (x.cos() - 1.0).abs(); assert!(abs_difference < 1e-10); }
Tamam da buna neden şimdiye dek ihtiyaç duymadık? Çünkü Rust prelude aracılığıyla, use
deyimini kullanmaya gerek bırakmadan pek çok temel işlevi görünür kılar (ama siz kullanana kadar yüklemez).
Diziler ve Dilimler
Bütün statik tiplenen dillerde diziler (array) bulunur, bu birden çok veriyi bellek içerisinde baştan sona kontrol eder. Diziler, sıfırdan itibaren indekslenir.
// array1.rs fn main() { let arr = [10, 20, 30, 40]; let first = arr[0]; println!("first {}", first); for i in 0..4 { println!("[{}] = {}", i,arr[i]); } println!("length {}", arr.len()); }
Ve çıktı:
first 10
[0] = 10
[1] = 20
[2] = 30
[3] = 40
length 4
Burada Rust dizinin büyüklüğünü net olarak bilir ve eğer arr[4]
'e erişmeye çalışırsanız derleme hatası alırsınız.
Yeni bir dil öğrenmek aynı zamanda diğer dillerden edindiğiniz alışkanlıkları da terk etmek demektir; eğer bir Pythonista iseniz bu köşeli parantezleri List
diye isimlendirebilirsiniz. Rust'taki List
'in muadiline daha sonra bakacağız, ancak diziler düşündüğünüz işi yapmıyor; sabit bir büyüklükleri vardır. (Eğer yalvarırsak) değişebilirler ancak yeni değerler ekleyemeyiz.
Diziler Rust'ta o kadar çok kullanılmaz, çünkü her dizi tipi uzunluğunun bilgisini de taşır. Mesela [i32; 4]
dizi tipine bakabilirsiniz; aynı zamanda [10, 20]
olan bir dizinin tipi de [i32; 2]
olacaktır vs, hepsinin farklı tipi vardır. Yani bunlar aslında fonksiyon argümanı olmaktan başka şeye yaramayan başıboş serserilerdir.
Esas sık kullanılanlar dilimlerdir. Bunları bir dizinin parçalanmış hâli1 olarak düşünebilirsiniz. Tıpkı dizilerin davrandığı gibi davranırlar ve uzunluklarını bilirler, C'deki gösterici (pointer) denen korkunç yaratıkların tam tersi olarak.
İki önemli noktaya dikkat edin - bir dilimin tipi nasıl yazıldığına ve fonksiyona ne zaman &
eklemeniz gerektiğine.
Ç.N: Esas çeviride "parçalanmış hâl" yerine "görünüm (view)" kelimesi kullanılıyor. İngilizce için cümle gayet geçerli, ancak Türkçe'de tuhaf duruyor.
// array2.rs // read as: slice of i32 fn sum(values: &[i32]) -> i32 { let mut res = 0; for i in 0..values.len() { res += values[i] } res } fn main() { let arr = [10,20,30,40]; // look at that & let res = sum(&arr); println!("sum {}", res); }
Sum
'da kodu bir saniyeliğine görmezden gelin ve &[i32]
'ye bakın. Rust dizileri ve dilimleri arasındaki ilişki C'deki diziler ve göstericiler arasındaki ilişkiye benzer, iki detay hariç - Rust dilimleri kendi uzunluğunun takibini yaparlar (ve eğer bu uzunluğun dışına çıkarlarsa paniklerler) sonra da &
'ı operatörünü kullanarak dilim olarak kullanmak istediğinizi açıkça belirtmeniz gereklidir.
Bir C programcısı &
'ı gördüğü zaman "falancanın adresi" diye okur, Rust programcısı ise "ödünç (borrow)" olarak. Bu, Rust öğrenirken dikkat etmeniz gereken kilit sözcüktür. Ödünç alma, esasında programlamada kullanılan genel bir terimdir ve (Dinamik dillerde her zaman olduğu gibi) referans olarak bir veriyi ya da C'de bir gösterici (pointer) yollamanıza denir. Ödünç alınan her şey esas sahibinde kalır.
Dilimleme ve Biçme
Bir diziyi {}
yolu ile ekrana yazamazsınız fakat hata ayıklama (debug) yani {:?}
ile ekrana yazdırabilirsiniz.
Bu:
// array3.rs fn main() { let ints = [1, 2, 3]; let floats = [1.1, 2.1, 3.1]; let strings = ["hello", "world"]; let ints_ints = [[1, 2], [10, 20]]; println!("ints {:?}", ints); println!("floats {:?}", floats); println!("strings {:?}", strings); println!("ints_ints {:?}", ints_ints); }
Bunu yazdırır:
#![allow(unused)] fn main() { ints [1, 2, 3] floats [1.1, 2.1, 3.1] strings ["hello", "world"] ints_ints [[1, 2], [10, 20]] }
Bu arada, dizilerin dizileri de olabilir ancak dizide sadece bir tipten değerler bulunmalıdır. Dizideki değerler verimlilikten dolayı bellekte yanyana bulunurlar ki bu erişim için oldukça faydalıdır.
Eğer bir değişkenin gerçek tipini merak ediyorsanız, size bir hile gösterebilirim. Bir değişkeni, geçersiz olduğunu bildiğiniz bir tiple bildirin:
#![allow(unused)] fn main() { let var: () = [1.1, 1.2]; }
İşte aradığınız şeyi gösteren hata:
3 | let var: () = [1.1, 1.2];
| ^^^^^^^^^^ expected (), found array of 2 elements
|
= note: expected type `()`
= note: found type `[{float}; 2]`
({float}
, "bir nevi noktalı sayı ama tam tipi henüz belirtilmedi." demektir.)
Dilimler size aynı dizinin farklı parçalarını sunar:
// slice1.rs fn main() { let ints = [1, 2, 3, 4, 5]; let slice1 = &ints[0..2]; let slice2 = &ints[1..]; // open range! println!("ints {:?}", ints); println!("slice1 {:?}", slice1); println!("slice2 {:?}", slice2); }
Bu, Python'daki dilim anlayışına epey yakındır ancak arada büyük bir fark vardır: Veri asla kopyalanmadı. Bu dilimler, bütün verilerini dizilerden ödünç alırlar. Dilimlerin dizilerle epey sıkı bir bağ vardır ve Rust bu bağın kopmaması için elinden gelen her şeyi kuvvetle yapar.
Opsiyonel Değerler (Optional Values)
Dilimler, diziler gibi, indekslenebilir. Rust, derleme zamanında dizinin değerini bilir, ama bir dilimin değeri ancak çalışma zamanında bilinebilir. Bundan dolayı, s[i]
kullanımı belleğin yanlış bir yerine erişmeye sebep olabilir ve bu durumda program panikleyecektir. Bu gerçekten de olmasını istediğiniz şey değildir - aradaki fark Florida'dan atılacak çok pahalı bir uydunun gökyüzünde parçalanması ile atışın güvenlice iptal edilmesine kadar varabilir. Ve burada hata yakalama mekanizmaları (exceptions) yok.
Şimdi hazır olun zira gelecek şey sizi şok edecek. Burada panikleyebilecek kodları try-bloğu ve hatayı yakala (try - catch) yapısı yok - en azından her gün kullandığınız şekliyle yok. Peki, Rust nasıl güvenli kalabiliyor?
İşte size bir paniklemeyen get
metotu. İyi de, bu ne dönüyor?
// slice2.rs fn main() { let ints = [1, 2, 3, 4, 5]; let slice = &ints; let first = slice.get(0); let last = slice.get(5); println!("first {:?}", first); println!("last {:?}", last); } // first Some(1) // last None
last
çuvalladı. (Sıfır temelli indekslemeyi unuttuk.) Ama None
diye bir şey döndü. first
için sorun yok, ama Some
diye bir şey dönüverdi. Option
tipini selamlayın! Bu tip Some
olabilir, None
olabilir.
Option
tipinin gayet faydalı metotları vardır:
#![allow(unused)] fn main() { println!("first {} {}", first.is_some(), first.is_none()); println!("last {} {}", last.is_some(), last.is_none()); println!("first value {}", first.unwrap()); // first true false // last false true // first value 1 }
Eğer last
üzerinde unwrap kullanırsanız nurtopu gibi bir paniğiniz olur. Bunun yerine en azından is_some
kullanabilirsiniz - varsayılan bir değeriniz varsa gayet faydalıdır:
#![allow(unused)] fn main() { let maybe_last = slice.get(5); let last = if maybe_last.is_some() { *maybe_last.unwrap() } else { -1 }; }
*
operatörünün kullanıldığına dikkat edin - Some
içindeki esas tip bir referans olan &i32
'dir. i32
verisini almak için veriyi deferans ediyoruz.
Bu biraz işi uzatıyor, onun yerine bir kısayol kullanabiliriz - unwrap_or
metodu Option
None
ise yerine bir değer atayabilir. Tipler muhakkak uyuşmalı -get
referans dönecek. Bundan ötürü bir &i32
olan &-1
'ı kullanmamız gereklidir. Şimdi tekrar *
operatörünü i32
değeri almak için kullanalım.
#![allow(unused)] fn main() { let last = *slice.get(5).unwrap_or(&-1); }
&
'ı unutmak gayet olası ama derleyici arkanızı toplayacaktır. -1
yazsaydık, rustc
şuna benzer bir hata verecekti: "&{integer} bekleniyordu ancak tam sayı alındı" ve eklerdi ki: "yardım: &-1
'ı deneyin" *
* "expected &{integer}, found integral variable", "help: try with &-1
"
Option
tipini veri taşıyan bir paket olarak zihninizde canlandırabilirsiniz, ya da hiçbir şey ifade etmeyen bir değer (None
). (Haskell'deki karşılığı Maybe
dir.) Bu tip tipi belirtilebilen herhangi bir veriyi barındırabilir. Bizim örneğimizde üzerinde çalıştığımız tip Option<&i32>
'dir, C++'ın "Genellemeler (generics)" yazılımı ile gösterirsek. Paketi açmak bir patlamaya sebep olabilir ancak bu mevzu Schrödinger'in kedisi kadar karmaşık değil ve önceden paketin içinde ne var bilebiliriz.
Rust fonksiyonlarının ve metotların bu tür paketleri döndürmesi gayet olağandır ve üzerinde uzmanlaşana kadar nasıl kullanıldığını öğrenin.
Vektörler
Ç.N: Doğrusunu isterseniz ilk gördüğümde bu vektörleri geometrideki vektörlerle ilişkili zannetmiştim. Yüzde yüz programlama deyimi, aklınıza farklı şeyler gelmesin. :)
Tekrar dilim metotlarına döneceğiz ancak vektörleri gözden geçirelim. Bunlar yeniden biçimlendirilebilen dizilerdir ve Python'un List
ine ve C++'ın std::vector
'üne epey benzerler. Rust'ın Vec
tipi ("vektör" olarak okunur.) dilimlere çok benzerler; esas farklılıkları ise vektöre yeni bir veri ekleyebiliyor olmanız - değişebilir (mutable) olarak değişkenin bildirilmesi kaydı ile.
// vec1.rs fn main() { let mut v = Vec::new(); v.push(10); v.push(20); v.push(30); let first = v[0]; // will panic if out-of-range let maybe_first = v.get(0); println!("v is {:?}", v); println!("first is {}", first); println!("maybe_first is {:?}", maybe_first); } // v is [10, 20, 30] // first is 10 // maybe_first is Some(10)
Yeni başlayanların başına sıklıkla gelen şey mut
eklemeyi unutmalarıdır; bunu yaparsanız dostça uyarılırsınız.
3 | let v = Vec::new();
| - use `mut v` here to make mutable
4 | v.push(10);
| ^ cannot borrow mutably
Vektörler ve dilimler arasında çok yakın bir bağ vardır.
// vec2.rs fn dump(arr: &[i32]) { println!("arr is {:?}", arr); } fn main() { let mut v = Vec::new(); v.push(10); v.push(20); v.push(30); dump(&v); let slice = &v[1..]; println!("slice is {:?}", slice); }
Ufak ama önemli ödünç alma operatörümüz &
, vektörü dilime çevirmeye zorluyor. (coercing). Ve bu pek mantıksız değil çünkü vektörler bellekte dinamik bir yer tutarlar ve dizi gibi çalışırlar.
Eğer dinamik tipli bir dilden geliyorsanız, sizinle bazı şeyleri konuşmanın vakti geldi. Sistem programlama dillerinde iki farklı bellek yönetim tarzı vardır: Yığıt (Stack) ve Öbek (Heap).2 Stack bellek üzerinde oldukça hızlı bir şekilde alan tahsis ederler ancak yapıları ancak bir kaç megabaytla çıkabilecek kadar sınırlıdır. Heap ise gigabaytlara kadar çıkabilir ancak alan tahsis etme süreci biraz meşakkatlidir ve bu bellek alanının sonradan temizlemesi gereklidir. Bazı sözüm ona "yönetilen (managed)" dillerde (Bunlar Java olur, Go olur, bazı sözde betik dilleri olur) bu tarz detaylar sizden gizlenir ve belediyemizin çöp toplayıcıları (garbage collector) tarafından bu pis işler halledilir. Sistem, bir verinin başka bir veriye referans gösterilmediğine emin olunca kullanılabilir bellek alanına geri döner.
Yığıt ve Öbek, benim çeviri standartlarıma göre bile aşırı yapay kalıyor. Bundan ötürü kafa karışıklığını ortadan kaldırmak için Heap ve Stack kelimelerinden devam ettim.
İşin özü bu durumun faydaları olsa da bazı sorunları da vardır. Stack ile oynamanın bazı tehlikeleri var ve içinde bulunduğunuz fonksiyonun dönüş adresini bozabilirsiniz, sonra da iğrenç bir şekilde can verirsiniz. Ya da daha da kötüsü, Hacker Okan'ın elini öpmek zorunda kalabilirsiniz.
İlk C programım (DOS'ta yazmıştım) tüm bilgisayarı çökertmişti. Unix sistemleri bu tarz şeylere karşı daha iyi tavır alırdı ve segfault mekanizması ile kontrolden çıkan süreçler "öldürülür". Peki, bu neden Rust'ın (ya da Go'nun) paniklemesinden daha kötüdür? Çünkü panik sorunun olduğu yerde meydana gelir, bütün program birbirine girdiğinde ve ev ödevlerine dadandığında değil. Panikler bellek için emniyetlidir (memory safe) çünkü belleğin canına okunmadan hemen önce gerçekleşirler. Bu, C'deki güvenlik sorunlarının yaygın bir nedenidir çünkü bütün bellek erişimleri emniyetsizdir ve işi bilen bir saldırgan bu güvensizlikten faydalanabilir.
Panikler kulağınıza korkunç ve plansız gelebilir ama Rust'ın panikleri bile yapılandırılmıştır - stack tek tek serbest bırakılır. Bellekte tahsis edilmiş alanı olan bütün veriler boşatılır ve geriye dönük bir rapor oluşturulur.
Peki çöp toplayıcıların (garbage collector) dezavantajları nedir? Birincisi belleği çok hoyratça kullanıyorlar, sizin için önemli olmayabilir ama gömülü mikroçiplerde bu çok fena bir sorun oluşturur. İkincisi, en olur olmaz zamanlarda belleği temizlemeye başlamasıdır. (Odanızda uzanmış telefonda sevgilinizle hassas bir konuşma yaparken birden odanızı temizlemeye kalkışan annenizi düşünün.) Gömülü sistemlerin olaylara gerçekleştiği anda yanıt vermesi gerekir ve planlanmamış bir temizliğe hiç tahammütleri yoktur. Roberto Lerusalimsch, Lua gibi çok zarif bir dinamik dilin baş tasarımcısı, çöp toplayıcılı bir yazılımın kullanıldığı uçakta asla uçmak isteyemeyeceğini söylemiştir.
Vektörlere geri dönelim, bir vektör yaratıldığı ya da düzenlendiğinde heap içerisinden alan tahsis eder ve bu tahsis edilen alanın sahibi olur. Vektör öldüğünde ya da bellekten temizlendiğinde, bellek de serbest bırakılır.
Döngüleyiciler (Iterators)
Rust bilinmezinin en temel noktasından henüz bahsetmedik - döngüleyiciler. Bir aralık (range) üzerinde kullanılan for döngüsü bir döngüleyici (iterator) kullanır. (0..n
Python3'teki range
fonksiyonuna benzer.)
Bir döngüleyiciyi fark etmek oldukça kolaydır. Option
değerini bize dönen next
metotuna sahip bir "objeye" döngüleyici deriz. None
dönene kadar, next
kullanabiliriz.
// iter1.rs fn main() { let mut iter = 0..3; assert_eq!(iter.next(), Some(0)); assert_eq!(iter.next(), Some(1)); assert_eq!(iter.next(), Some(2)); assert_eq!(iter.next(), None); }
for var in iter {}
'in yaptığı da tam olarak budur.
Bu size for döngüsü tanımlamanın faydasız bir yolu gibi görünebilir ancak rustc
'nin yapacağı akıl almaz optimizasyonların sonucunda While döngüsü kadar hızlı çalışacaktır.
Bir dizi üzerinde döngü kurmayı deneyelim:
// iter2.rs fn main() { let arr = [10, 20, 30]; for i in arr { println!("{}", i); } }
Elbette ki hata dönecek. Ama çıktıya bakınca:
4 | for i in arr {
| ^ the trait `std::iter::Iterator` is not implemented for `[{integer}; 3]`
|
= note: `[{integer}; 3]` is not an iterator; maybe try calling
`.iter()` or a similar method
= note: required by `std::iter::IntoIterator::into_iter`
Rustc
'nin tavsiyesiyle programımız bir kez daha çalıştı:
`// iter3.rs fn main() { let arr = [10, 20, 30]; for i in arr.iter() { println!("{}", i); } // slices will be converted implicitly to iterators... let slice = &arr; for i in slice { println!("{}", i); } }
Aslında, dizi üzerinde for i in 0..slice.len() {}
gibi bir kullanımdansa bu yöntem çok daha verimlidir çünkü Rust'ı obsesifçe her indeks operasyonunda bir ton şeyi kontrol etmeye yönlendirmemiş oluyoruz.
Bir de bir aralığın hepsini hızlıca toplamanın bir başka örneğine bakalım. Daha önce bir döngü ve mut
değişkenini kullanıyordum. Burada ise toplamanın "idiomatic" ve profesyonelce bir yolu var:
// sum1.rs fn main() { let sum: i32 = (0..5).sum(); println!("sum was {}", sum); let sum: i64 = [10, 20, 30].iter().sum(); println!("sum was {}", sum); }
Rust'ta tiplerin önceden bildirilmiş olması gereken durumlardan birisi olduğuna dikkat edin, aksi taktirde Rust tam olarak ne yapması gerektiğini bilemeyecektir. Burada farklı tam sayı tipleriyle çalışmaktayız ki bu sorun teşkil etmez. (Aynı zamanda, isim sıkıntısı çektiğiniz zamanlarda aynı ismi tekrar kullanmanız da sorun teşkil etmez.)
Bu bilgiyle birlikte, dilim metotları gözünüze biraz daha anlamlı gelecektir. (Belgelendirme hakkında bir bilgi; her belgenin sağ tarafında '[-]' işareti bulunur ve bu butona tıklayınca metot listesini kapatabilirsiniz. İlginizi çeken şeylerin detaylarını da genişletebilirsiniz. Şimdilik biraz tuhaf görünüyor, görmezden geliverin.)
windows
metotu size dilimlerin döngüleyicisini verir - birbiriyle örtüşen pencereler olarak değerler (Ç.N: Burada ne demek istediği benim için bile bir bilinmez.)
// slice4.rs fn main() { let ints = [1, 2, 3, 4, 5]; let slice = &ints; for s in slice.windows(2) { println!("window {:?}", s); } } // window [1, 2] // window [2, 3] // window [3, 4] // window [4, 5]
Chunks
da benzer işlevi yapabilir.
#![allow(unused)] fn main() { for s in slice.chunks(2) { println!("chunks {:?}", s); } // chunks [1, 2] // chunks [3, 4] // chunks [5] }
Vektörler Üzerine Biraz Daha Konuşalım
Bir vektör kurmak için vec!
isminde oldukça kullanışlı bir makromuz var. Bununla birlikte vektörün sonundaki verileri pop
ile silebiliriz ve vektörü bir başka vektörle genişletebiliriz (extend).
// vec3.rs fn main() { let mut v1 = vec![10, 20, 30, 40]; v1.pop(); let mut v2 = Vec::new(); v2.push(10); v2.push(20); v2.push(30); assert_eq!(v1, v2); v2.extend(0..2); assert_eq!(v2, &[10, 20, 30, 0, 1]); }
Vektörler birbirleriyle kıyaslanacağı zaman dilim olarak karşılaştırılır.
Vektörün belirli noktalarına insert
kullanarak verileri yerleştirebilirsiniz, remove
ile de silebilirsiniz. Bu vektörün sonuna veri eklemekten (push) ya da veri çıkartmaktan (pop) daha verimsizdir çünkü veriler yeni yer yaratmak için taşınır, büyük boyutlu vektörlerle çalışırken bu duruma dikkat etmeniz gerekir.
Vektörlerin bir büyüklüğü ve kapasitesi vardır. Eğer bir vektörü clear
ile temizlerseniz, büyüklüğü sıfır olur ama eski kapasitesini korur. Bu durumda push
vs ile yeni veriler eklerken yeniden bellek alanı tahsis edilmesi yalnızca eski kapasitenin aşımıyla gerekli olur.
Vektörler sıralanabilir ve içinde tekrarlayan veriler temizlenebilir - bu işlemler vektörü değiştirir. (Eğer önce vektörü kopyalamak isterseniz, clone
kullanın.)
// vec4.rs fn main() { let mut v1 = vec![1, 10, 5, 1, 2, 11, 2, 40]; v1.sort(); v1.dedup(); assert_eq!(v1, &[1, 2, 5, 10, 11, 40]); }
Karakter Dizileri (String)
Rust'taki karakter dizileri diğer dillerden biraz daha gelişkindir. String
tipi, Vec
gibi, belleği dinamik olarak tahsis eder ve yeniden boyutlandırılabilir. (C++'ın std::string
tipine çok benzer ancak Java'nın ve Python'nun değişemez karakter dizileri gibi değildir.) Ancak bir program, pek çok string
kalıbı (string literal) de barındırabilir ("merhaba" gibi) ve bir sistem programlama dili bunları çıktı dosyasının içinde barındırabilmelidir. Gömülü mikroçiplerde bunun anlamı, bunları pahalı RAM yerine ucuz ROM'a yerleştirmektir. (Düşük seviyeli cihazlar için, RAM'ın pahalılığı aynı zamanda enerji üretimi pahalılığıdır.) Bir sistem programlama dilinde iki tür karakter dizisi bulunmalıdır, statik ya da bellekte yeri tahsis edilmiş.
Yani "merhaba" bir String
değildir. Onun tipi &str
'dir. ("Karakter dizisi dilimi String Slice olarak okunur.") Bu ayrım, C++'daki const char*
ve std::string
arasındaki fark gibidir ancak &str
biraz daha kullanışlıdır. Doğrusu, &str
ve String
ilişki &[T]
ile Vec<T>
arasındaki ilişkiye çok benzer.
// string1.rs fn dump(s: &str) { println!("str '{}'", s); } fn main() { let text = "hello dolly"; // the string slice let s = text.to_string(); // it's now an allocated string dump(text); dump(&s); }
Tekrar edelim, ödünç alma operatörü tıpkı Vec<T>
'yi &[T]
'ye çevirmesi gibi String
'i de &str
'ye çevirir.
Aslında içten içe, String
aslında bir Vec<u8>
'dir ve &str
de bir &[u8]
'dir, ancak bu baytlar UTF-8'e kesinlikle uygun olmalıdır.
Vektör gibi, bir karakteri push
layabilirsiniz veyahut sonundaki karakteri pop
layabilirsiniz.
// string5.rs fn main() { let mut s = String::new(); // initially empty! s.push('H'); s.push_str("ello"); s.push(' '); s += "World!"; // short for `push_str` // remove the last char s.pop(); assert_eq!(s, "Hello World"); }
Pek çok tipi String'e to_string
diyerek çevirebilirsiniz. (Eğer onları "{}" ile ekranda gösterebiliyorsanız, çevrilebilirler.) format!
makrosu da tıpkı println!
gibi karmaşık karakter dizileri üretmek için kullanılabilir.
// string6.rs fn array_to_str(arr: &[i32]) -> String { let mut res = '['.to_string(); for v in arr { res += &v.to_string(); res.push(','); } res.pop(); res.push(']'); res } fn main() { let arr = array_to_str(&[10, 20, 30]); let res = format!("hello {}", arr); assert_eq!(res, "hello [10,20,30]"); }
v.to_string()
'in önündeki &
operatörüne dikkat edin - operatör bir karakter dizesi dilimi üzerinde tanımlanmış, String
'in kendisine değil, uyuşması için bazı detaylar eklememiz gerekiyor.
Dilimlerde kullanılan ifade şekli karakter dizilerinde de gösterilebilir:
// string2.rs fn main() { let text = "static"; let string = "dynamic".to_string(); let text_s = &text[1..]; let string_s = &string[2..4]; println!("slices {:?} {:?}", text_s, string_s); } // slices "tatic" "na"
Ancak karakter dizilerini indeksleyemezsiniz. Çünkü onlar tek ve gerçek kodlama olan UTF-8'i kullanırlar ki bu kodlamada bazı "karakterler" sadece baytların sayısı olabilir.
// string3.rs fn main() { let multilingual = "Hi! ¡Hola! привет!"; for ch in multilingual.chars() { print!("'{}' ", ch); } println!(""); println!("len {}", multilingual.len()); println!("count {}", multilingual.chars().count()); let maybe = multilingual.find('п'); if maybe.is_some() { let hi = &multilingual[maybe.unwrap()..]; println!("Russian hi {}", hi); } } // 'H' 'i' '!' ' ' '¡' 'H' 'o' 'l' 'a' '!' ' ' 'п' 'р' 'и' 'в' 'е' 'т' '!' // len 25 // count 18 // Russian hi привет!
Şimdi şuna bakalım - 25 baytımız var ama sadece 18 kataktere sahibiz! Fakat, eğer find
gibi bir metot kullanırsak (bulunması hâlinde) geçerli bir indeks elde alırsınız ve herhangi bir dilimleme doğru çalışacaktır.
(Rust'ın char
tipi 4-baytlık Unicode karakteridir. Karakter dizileri ise char
ların dizisi değildir!)
Karakter dizelerini dilimlemek vektör dilimlemek gibi riskli bir iştir, çünkü bayt "aralıkları" kullanılır. Alttaki koşulda karakter dizesi iki bayttan oluşur, bunun ilk baytını almaya çalışmak bir Unicode hatasıdır. Bundan dolayı karakter dizisi metotlarından gelen uygun aralıkları kullanmaya dikkat edin.
#![allow(unused)] fn main() { let s = "¡"; println!("{}", &s[0..1]); <-- bad, first byte of a multibyte character }
Karakter dizilerini parçalamak popüler ve faydalı bir meşgaledir. split_whitespace
metotu bir döngüleyici döner ve bunun ne yapacağımızı biz belirleriz. Genelde bir karakter dizisini daha ufak karakter dizilerinin vektörünü kurmak için buna ihtiyaç duyarız.
collect
ise çok geneldir ve neyi topladığımız (collect) hakkında bir ipucu ister - bundan dolayı açıkça tip belirtilir.
#![allow(unused)] fn main() { let text = "the red fox and the lazy dog"; let words: Vec<&str> = text.split_whitespace().collect(); // ["the", "red", "fox", "and", "the", "lazy", "dog"] }
Döngüleyicilerin extend
metotuyla da aynı işi yapabilirdiniz.
#![allow(unused)] fn main() { let mut words = Vec::new(); words.extend(text.split_whitespace()); }
Pek çok dilde bunları yapmak için bellekte ayrıca alanı tahsis edilmiş karakter dizilerine ihtiyacımız olurdu, oysa burada sadece bir ödünç alma olayı var. Tek tahsis edilen alan, dilimleri bellekte tutacak alandır.
Şu şirin çift-satıra bir bakınız; karakterler üzerine bir döngüleyici kuruyoruz ve boşluk olmayan karakterleri alıyoruz. Hatırlatalım, collect
ipucu ister. (Ve biz de karakterler vektörü istemiş olabiliriz)
#![allow(unused)] fn main() { let stripped: String = text.chars() .filter(|ch| ! ch.is_whitespace()).collect(); // theredfoxandthelazydog }
filter
metotu ise argüman olarak kapama (closure) alır, kapama dediğimiz de Rust'ın dilinde lambdalara veya anonim fonksiyonlara verdiğimiz isim. Argüman, işlevsel olarak çalışmayı bozmadığı için apaçık tip belirtme kuralını genişletebiliyoruz.
Tabii bunca şeyi apaçık bir döngü ile değişebilir bir vektöre karakter dizilerini iterek de yapabilirsiniz, ama şimdi yaptığımız daha kısa, daha okunaklı (alışınca tabii) ve denk bir hızda. Bir döngü kullanmak elbet ayıp değildir ama bu şekilde yazmanızı şiddetle tavsiye ederim.
Reklam Arası: Komut Satırından Argümanları Almak
Şimdiye kadar programlarımız neyin ne olduğundan habersiz bir şekilde kendi kendilerine yaşayıp gittiler. Artık onları gerçek dünya ile tanıştırmalıyız.
std::env::args
ile komut satırındaki argümanlara ulaşabilirsiniz; size bütün argümanları, programın ismi de dahil olmak üzere, birer karakter dizisi olarak döner.
// args0.rs fn main() { for arg in std::env::args() { println!("'{}'", arg); } }
src$ rustc args0.rs
src$ ./args0 42 'hello dolly' frodo
'./args0'
'42'
'hello dolly'
'frodo'
Bir Vec
dönse daha iyi olmaz mıydı? Bunu collect
ile bir vektöre çevirmek de pek zor değildir, hatta döngüleyicilerin skip
metotu ile programın adını atlayabilirsiniz.
#![allow(unused)] fn main() { let args: Vec<String> = std::env::args().skip(1).collect(); if args.len() > 0 { // we have args! ... } }
İşin en iyi tarafı, bütün dillerde bunu bu şekilde kullanıyor olmanız.
Biraz daha Rustça yaklaşım ise tek bir argümanı okumak. (Ve aynı zamanda bir tam sayı verisini okumak):
// args1.rs use std::env; fn main() { let first = env::args().nth(1).expect("please supply an argument"); let n: i32 = first.parse().expect("not an integer!"); // do your magic }
nth(1)
size bir döngüleyicinin ikinci verisini döner, expect
ise unwrap
gibi çalışır ancak bir de mesaj belirtmenize izin verir.
Bir karakter dizisini bir sayıya çevirmenin yolu gayet bariz, ancak dönüştürülecek tipi açıkça belirtmeniz gerekmekte - yoksa parse
bunu nereden bilecek?
Örüntü Eşleştirme (Matching)
string3.rs
dosyasındaki Rusça selamlamayı kullandığımız kodda aslında bu tarz durumları o şekilde çözmeyiz. Match
ile deneyelim:
#![allow(unused)] fn main() { match multilingual.find('п') { Some(idx) => { let hi = &multilingual[idx..]; println!("Russian hi {}", hi); }, None => println!("couldn't find the greeting, Товарищ") }; }
Match
şu kızılderili okunu barındıran, virgüllerle ayrılmış pek çok örüntü tanımından oluşur. Bu ifade, çok rahat bir şekilde Option
içerisinden ifadeyi ayıklayabilir ve idx
'e bağlayabilir. Bütün koşulların muhakkak karşılanması gerektiği için None
'u da ele alıyoruz.
Buna bir kere alışınca (yani, bir kaç kez yazınca) size ayrıca Option
tutacak ek bir değişken gerektiren is_some
yazmaktan çok daha rahat gelecek.
Ancak hatalarla ilgilenmiyorsanız, mahalleden if let
'i çağırabiliriz:
#![allow(unused)] fn main() { if let Some(idx) = multilingual.find('п') { println!("Russian hi {}", &multilingual[idx..]); } }
Match
C'deki switch
gibi de çalışabilir ve diğer Rust kurucuları gibi veri de dönebilir:
#![allow(unused)] fn main() { let text = match n { 0 => "zero", 1 => "one", 2 => "two", _ => "many", }; }
_
'ı C'deki default
olarak düşünebilirsiniz - varsayılan değer ifadesidir kendileri. Eğer bunu belirtmezseniz rustc
bunun bir hata olduğunu düşünür. (C++'da bekleyebileceğiniz ilgili koşullar hakkında pek çok şeyi belirtilen bir uyarıyı almak olur.)
Rust'ın match
deyimleri aralıkları da eşleştirebilir. Bu aralıkların üç noktalı olduğuna dikkat edin, bunlar kapsayan aralıklardır, mesela ilk koşul "3" sayısıyla eşleşecektir.
#![allow(unused)] fn main() { let text = match n { 0...3 => "small", 4...6 => "medium", _ => "large", }; }
Dosyaları Okumak
Bizim programlarımızı dünyaya açacak olan bir sonraki adımımız ise dosyaları okumaktır.
expect
'in unwrap
gibi çalıştığını ancak fazladan bir hata mesajı girmemize izin verdiğini aklınızda tutun. Şimdi birden çok hata hortlatacağız:
// file1.rs use std::env; use std::fs::File; use std::io::Read; fn main() { let first = env::args().nth(1).expect("please supply a filename"); let mut file = File::open(&first).expect("can't open the file"); let mut text = String::new(); file.read_to_string(&mut text).expect("can't read the file"); println!("file had {} bytes", text.len()); }
src$ file1 file1.rs
file had 366 bytes
src$ ./file1 frodo.txt
thread 'main' panicked at 'can't open the file: Error { repr: Os { code: 2, message: "No such file or directory" } }', ../src/libcore/result.rs:837
note: Run with `RUST_BACKTRACE=1` for a backtrace.
src$ file1 file1
thread 'main' panicked at 'can't read the file: Error { repr: Custom(Custom { kind: InvalidData, error: StringError("stream did not contain valid UTF-8") }) }', ../src/libcore/result.rs:837
note: Run with `RUST_BACKTRACE=1` for a backtrace.
Dosyanın var olmadığı veyahut okunmasına izin olmadığı durumlarda open
hata dönebilir, read_to_string
ise dosya içeriğinin UTF-8 olmaması durumunda hata döner. (Tabii, bu koşulda yerine read_to_end
kullanıp içeriği bayt vektörlerine koymak da bir seçenek.) Çok da büyük olmayan dosyaları tek hamlede okumak daha faydalı ve basittir.
Eğer diğer dillerde dosya işleme nasıl olur bir fikriniz varsa dosyanın ne zaman kapatılması gerektiğini düşünüyor olabilirsiniz. Eğer dosyaya bir şeyler yazdırsaydık kapatmamak veri kaybına sebebiyet verebilirdi ancak burada dosya kendiliğinden kapatılıyor ve fonksiyon sona erdiği zaman da file
değişkeni düşürülüyor.
Bu "hata hortlatma işi"ne biraz fazla alıştık galiba. Bütün programı böyle çökertebilen bir kodu kendi fonksiyonlarınıza yerleştirmek istemezsiniz. O zaman File::open
'ın ne döndüğüne bakalım. Eğer Option
bir şeyin varlığını ya da yokluğunu işaret ediyorsa Result
da bir şeyin olup olmadığını gösterir. İkisi de unwrap
'i bilir (ve amcaoğlu expect
i de) ancak biraz farklıdırlar. Result
, Ok
ve Err
için iki farklı tür parametre içerir. Result
"paketi" iki farklı kompartmana sahiptir, birisi Ok
ve diğeri de Err
.
fn good_or_bad(good: bool) -> Result<i32,String> { if good { Ok(42) } else { Err("bad".to_string()) } } fn main() { println!("{:?}",good_or_bad(true)); //Ok(42) println!("{:?}",good_or_bad(false)); //Err("bad") match good_or_bad(true) { Ok(n) => println!("Cool, I got {}",n), Err(e) => println!("Huh, I just got {}",e) } // Cool, I got 42 }
(Aslında "hata (error)" için seçtiğimiz tip biraz gereksiz - pek çok insan Rust
'ın hata tiplerine alışana kadar karakter dizelerini tercih eder.) Bu, bir veriyi ya da başka bir veriyi döndürmenin gayet uygun bir yoludur.
Dosya okumamızın bu şekli çökmez. Result
döner ve onu çağırana gelen verinin nasıl işlenmesi gerektiğini seçtirir.
// file2.rs use std::env; use std::fs::File; use std::io::Read; use std::io; fn read_to_string(filename: &str) -> Result<String,io::Error> { let mut file = match File::open(&filename) { Ok(f) => f, Err(e) => return Err(e), }; let mut text = String::new(); match file.read_to_string(&mut text) { Ok(_) => Ok(text), Err(e) => Err(e), } } fn main() { let file = env::args().nth(1).expect("please supply a filename"); let text = read_to_string(&file).expect("bad file man!"); println!("file had {} bytes", text.len()); }
Birinci eşleşme Ok
içindeki veriyi güvenli bir şekilde dışarı çıkartır ve eşleşmenin değeri yapar. Eğer bir Err
verisi ise, hatayı döner ve onu tekrar Err
içerisine paketler.
İkinci eşleşme ise Ok
içerisine paketlenmiş bir karakter dizesi döner ya da hatayı tekrar eder. Ok
içindeki esas veriye ihtiyacımız yok ondan dolayı _
ile yok sayıyoruz.
Bu biraz sıkıcı, yazdığımız kodu büyük kısmı hatayı işlemekten ibaret olunca "işin ruhunu" kaybediyoruz. Mesela Go'da bunu hissedersiniz, düzinesiyle erken dönen hataları kontrol etmeniz gerekir ya da sadece görmezden gelirsiniz. (Rust evreninde bu tuvalette ekmek çiğnemek kadar kötü bir şeydir.)
Neyse ki, bir kısayolumuz var.
std::io
modülü io::Result<T>
diye bir tipe sahiptir ki bu Result<T, io::Error>
ile aynı şeydir ve daha kolay yazılabilir.
#![allow(unused)] fn main() { fn read_to_string(filename: &str) -> io::Result<String> { let mut file = File::open(&filename)?; let mut text = String::new(); file.read_to_string(&mut text)?; Ok(text) } }
?
operatörü File::open
üzerinde denediğimiz eşleştirmelerle birebir aynı şeyi yapıyor; eğer sonuç hataysa hemen fonksiyonu döndürüyor. Değilse, Ok
sonucunu dönüyor. Sonuç olarak hâlâ daha karakter dizisini paketlememiz gerekiyor.
2017 senesi Rust için iyi bir yıldı ve ?
gibi karizmatik şeyler kararlı hâle geldi. Eski kodlarda try!
diye bir makroyu görebilirsiniz:
#![allow(unused)] fn main() { fn read_to_string(filename: &str) -> io::Result<String> { let mut file = try!(File::open(&filename)); let mut text = String::new(); try!(file.read_to_string(&mut text)); Ok(text) } }
Sonuç olarak, tek tek hataları bildirmeden güvenli Rust kodu yazmak düşündüğünüz kadar çirkin değil.
Yapılar, Numaralandırmalar ve Eşleştirme
Rust Lekta Movik Movik
Fazla ileri gitmiyor muyuz? Mesela kaçırdığımız bazı şeyler var:
// move1.rs fn main() { let s1 = "hello dolly".to_string(); let s2 = s1; println!("s1 {}", s1); }
Kod çalışınca da şu hatayı alırız:
error[E0382]: use of moved value: `s1`
--> move1.rs:5:22
|
4 | let s2 = s1;
| -- value moved here
5 | println!("s1 {}", s1);
| ^^ value used here after move
|
= note: move occurs because `s1` has type `std::string::String`,
which does not implement the `Copy` trait
Rust diğer dillerden biraz daha farklı davranır. Bütün değişkenleri birer referans olduğu dillerde (Java ve Python gibi) s2
s1
'in karakter dizesi objesine bir başka referans olur. C++'da ise s1
bir veridir ve s2
'ye kopyalanır. Ancak Rust veriyi taşır (move), karakter dizelerini ise kopyalanabilir bir tür olarak da görmez. ("does not implement the Copy trait" - "Kopyala özelliğini barındırmıyor")
Böyle bir şeyi sayılar gibi "ilkel (primitive)" tiplerde görmeyiz çünkü onlar sadece veridir; kopyalanabilmelerine izin vardır çünkü kopyalaması ucuzdur. Ama String
"Hello Dolly" için bellekte yer tahsis eder ve kopyalama daha fazla belleğin tahsis edilmesini ve karakterlerin tek tek kopyalanmasını içerir. Rust'ın bunu sessiz sedasız yapmasını bekleyemezsiniz.
String
'in bütün "Moby Dick"i barındırdığını düşünün. Bu karmaşık bir yapı (struct) olmazdı; sadece yazının bulunduğu bellek bölgesini tutan adresi, büyüklüğünü ve ne kadar bellekte alan tahsis edildiğini barındırırdı. Kopyalamak epey bir yük olurdu çünkü bellek heap bölgesinde tahsis edilmişti ve kopyalamanın kendisi de bellekte alan tahsis etmeyi gerektirirdi.
String
| addr | ---------> Call me Ishmael.....
| size | |
| cap | |
|
&str |
| addr | -------------------|
| size |
f64
| 8 bytes |
İkinci veri ise karakter dizisi dilimidir (&str
) ve String
ile aynı bellek alanına yönlendirir, büyüklüğü ile birlikte. Kopyalaması çok basit!
Üçüncü verimiz ise f64
- sadece 8 bayt tutuyor. Herhangi bir bellek alanına yönlendirilmiyor, yani kopyalaması onu taşımak kadar basit.
Copy
verileri bellekteki karşılıklarıyla tanımlanır ve Rust kopyaladığı zaman bu baytları sadece başka bir yere kopyalar. Buna benzer olarak Copy
olmayan bir veri ise sadece taşınır. C++'ın aksine kopyalama ve taşımada herhangi bir karmaşa yoktur.
Aynı şeyi bir fonksiyon çağrısı olarak yazmak da aynı soruna sebep olur:
// move2.rs fn dump(s: String) { println!("{}", s); } fn main() { let s1 = "hello dolly".to_string(); dump(s1); println!("s1 {}", s1); // <---error: 'value used here after move' }
Şimdi bir tercih yapmanız gerekiyor. Ya String
'i bir referans olarak kullanacaksınız ya da açık açık clone
metotu ile onu kopyalayacaksınız. Genelde ilk olan daha iyi bir seçenektir.
fn dump(s: &String) { println!("{}", s); } fn main() { let s1 = "hello dolly".to_string(); dump(&s1); println!("s1 {}", s1); }
Artık hatadan çok uzaktayız. Ancak String
referansını çok nadir görürsünüz, çünkü bir karakter dizisi kalıbını bu şekilde kullanmak gerçekten çirkin ve bu yolla geçici bir String
oluşturmak zorunda kalırsınız.
#![allow(unused)] fn main() { dump(&"hello world".to_string()); }
Onun yerine en iyi yol şudur:
#![allow(unused)] fn main() { fn dump(s: &str) { println!("{}", s); } }
Ve böylece dump(&s1)
ve dump("hello world")
kullanımlarının ikisi de geçerli olacaktır. (Burada Rust'ın Deref
zorlaması işin içine girer ve &String
'i &str
yapar.)
Sonuç olarak, Copy
olmayan bir değerin değişkene atanması bir konumdan öbürüne taşınmasıdır. Eğer bu olmasaydı Rust gizlice kopyalamak zorunda kalırdı ve bellek tahsislerini açıkça yapma sözünü gerçekleştiremezdi.
Değişkenlerin Kapsamları
Birinci kural, verileri kopyalamak yerine orijinal veriye referans göstermektir - yani "ödünç almak."
Ancak bir referans sahibinden daha uzun asla yaşayamaz!
Öncelikle Rust blok kapsamlı bir dildir. Değişkenler kendi blokları kadar yaşar:
#![allow(unused)] fn main() { { let a = 10; let b = "hello"; { let c = "hello".to_string(); // a,b and c are visible } // the string c is dropped // a,b are visible for i in 0..a { let b = &b[1..]; // original b is no longer visible - it is shadowed. } // the slice b is dropped // i is _not_ visible! } }
(i
gibi) Döngü değişkenleri biraz farklıdır, onlar sadece döngülerinin blokları için geçerlidir. Aynı isimle yeni bir değişken oluşturmak ("gölgelemek/shadowing") bir hata değildir ama kafa karıştırıcı olabilir.
Bir değişken "kapsam dışına çıkınca" düşürülür (dropped). Kullanılan her bir bellek tanesi geri dönüştürülür ve sistemden alınan kaynaklar iade edilir - örneğin, File
'ı düşürmek onu kapatır. Bu iyi bir şey. Kullanılmayan kaynaklar ihtiyaç olmayınca hemen geri teslim edilir.
(Rust'a özgü bir başka sorun da verinin taşınmış olmasına rağmen kapsam dahilinde görünmüş olmasıdır.)
Bu örnekte rs1
isminde bir referans hazırladık ve değerini sadece iç bloğun ömrü kadar uzun kalan tmp
'ye ayarladık.
01 // ref1.rs 02 fn main() { 03 let s1 = "hello dolly".to_string(); 04 let mut rs1 = &s1; 05 { 06 let tmp = "hello world".to_string(); 07 rs1 = &tmp; 08 } 09 println!("ref {}", rs1); 10 }
s1
'in verisini ödünç aldık ve sonra da tmp
'i ödünç aldık. Ancak tmp
, bloğun dışında yok!
error: `tmp` does not live long enough
--> ref1.rs:8:5
|
7 | rs1 = &tmp;
| --- borrow occurs here
8 | }
| ^ `tmp` dropped here while still borrowed
9 | println!("ref {}", rs1);
10 | }
| - borrowed value needs to live until here
Tmp
nerede? Gitti, yok, öldü o artık: düşürüldü. Rust sizi burada C'nin "işaretçiler (dangling pointer)" belasından koruyor - çoktan yitip gitmiş bir veriye işaret eden referanslardan yani.
Demetler (Tuple)
Bir fonksiyondan çoklu veriler dönmeyi gerektiren zamanlar muhakkak gelecek. Demetler bunun için gayet uygun bir gözümdür.
// tuple1.rs fn add_mul(x: f64, y: f64) -> (f64,f64) { (x + y, x * y) } fn main() { let t = add_mul(2.0,10.0); // can debug print println!("t {:?}", t); // can 'index' the values println!("add {} mul {}", t.0,t.1); // can _extract_ values let (add,mul) = t; println!("add {} mul {}", add,mul); } // t (12, 20) // add 12 mul 20 // add 12 mul 20
Demetlerin dizilerden temel farkları, demetlerin farklı tipler barındırabilmesidir.
#![allow(unused)] fn main() { let tuple = ("hello", 5, 'c'); assert_eq!(tuple.0, "hello"); assert_eq!(tuple.1, 5); assert_eq!(tuple.2, 'c'); }
Bazen Iterator
metotlarından karşınıza fırlarlar. enumerate
tıpkı Python'daki aynı isimli oluşturucu gibi çalışır:
#![allow(unused)] fn main() { for t in ["zero","one","two"].iter().enumerate() { print!(" {} {};",t.0,t.1); } // 0 zero; 1 one; 2 two; }
zip
ise iki döngüleyiciyi birbiriyle eşleştirir ve bir demet içerisinde veri dönen tek bir döngüleyici olarak birleştirir.
#![allow(unused)] fn main() { let names = ["ten","hundred","thousand"]; let nums = [10,100,1000]; for p in names.iter().zip(nums.iter()) { print!(" {} {};", p.0,p.1); } // ten 10; hundred 100; thousand 1000; }
Yapılar (Struct)
Demetler fena şeyler değiller ancak t.1
gibi bir anlam içermeyen parçalarını incelerken biraz kafa karıştırıcı olabilir.
Rust yapıları ise isimli alanlar (field) barındırır:
// struct1.rs struct Person { first_name: String, last_name: String } fn main() { let p = Person { first_name: "John".to_string(), last_name: "Smith".to_string() }; println!("person {} {}", p.first_name,p.last_name); }
Sizin bunu fark etmemenize rağmen yapıların verileri bellekte yanyana dururlar çünkü derleyici belleği verimliliğe göre düzenler, büyüklüğüne göre değil ve arada bazı boşluklar olabilir.
Bu yapıyı ilklemek (initalize) biraz garip görünebilir, bundan dolayı Person
yapısını oluşturmayı bir fonksiyon içerisine taşıyorum. Bu fonksiyon bir impl
bloğunun içerisine taşınarak Person
'a ait bir ilişkili fonksiyona (associated function) dönüştürülebilir.
// struct2.rs struct Person { first_name: String, last_name: String } impl Person { fn new(first: &str, name: &str) -> Person { Person { first_name: first.to_string(), last_name: name.to_string() } } } fn main() { let p = Person::new("John","Smith"); println!("person {} {}", p.first_name,p.last_name); }
new
ile ilişkili özel bir şey yok. C++ tarzı ::
notasyonu ile bu fonksiyona ulaşabiliyoruz.
Bir de argüman olarak kendisini referans alan (reference self) Person
metotunu hazırlayalım.
#![allow(unused)] fn main() { impl Person { ... fn full_name(&self) -> String { format!("{} {}", self.first_name, self.last_name) } } ... println!("fullname {}", p.full_name()); // fullname John Smith }
self
, bir referans olarak açıkça belirtildi. (&self
'i self: &Person
'un kısaltması olarak düşünebilirsiniz.)
Self
kelimesi struct
tipine atıfta bulunur - Person
yerine Self
yazdığınızı düşünebilirsiniz:
#![allow(unused)] fn main() { fn copy(&self) -> Self { Self::new(&self.first_name,&self.last_name) } }
Metotlar veri düzenlemek için kendilerini mutable self
olarak argüman alırlar.
#![allow(unused)] fn main() { fn set_first_name(&mut self, name: &str) { self.first_name = name.to_string(); } }
Ve sadece self
kullanıldığında veri taşınacaktır:
#![allow(unused)] fn main() { fn to_tuple(self) -> (String,String) { (self.first_name, self.last_name) } }
(Bunu bir de &self
ile deneyin ve yapıların (struct) kendi verileri konusunda ne kadar inatçı olduğunu bir de siz görün!)
v.to_tuple()
çağrıldığı zaman v
'nin taşındığını ve kullanılamaz hâle geldiğini göreceksiniz.
Özetlersek:
self
kullanılmazsa: fonksiyonları bu şekilde bağlayabilirsiniz,new
"oluşturucusu" gibi .&self
ile: Yapının verilerini kullanabilir ancak değiştiremezsiniz.&mut self
ile: Yapının verilerini düzenleyebilirsiniz.self
ile: Yapıyı yok edersiniz, yani içindeki verileri taşırsınız.
Eğer Person
'u veri ayıklama şeklinde ekrana yazdırırsanız, bilgilendirici bir hata alırsınız:
error[E0277]: the trait bound `Person: std::fmt::Debug` is not satisfied
--> struct2.rs:23:21
|
23 | println!("{:?}", p);
| ^ the trait `std::fmt::Debug` is not implemented for `Person`
|
= note: `Person` cannot be formatted using `:?`; if it is defined in your crate,
add `#[derive(Debug)]` or manually implement it
= note: required by `std::fmt::Debug::fmt`
Derleyici bazı tavsiyesine uyuyoruz ve Person
'un tanımı üstüne #[derive(Debug)]
ekliyoruz, böylece işe yarar bir çıktımız oluyor:
Person { first_name: "John", last_name: "Smith" }
Bu direktif, derleyicinin faydalı bir özellik olan Debug
'u eklemesine yarıyor ki bu da sizin kendi yapılarınızla (struct) ekrana yazdırarak pratik yapmanıza yardımcı olur. (Ya da format!
ile yazdırabilirsiniz). (Bunu varsayılan olarak gerçekleştirmek Rust'ın tarzı değil doğrusu.)
İşte minik programımızın son hâli:
// struct4.rs use std::fmt; #[derive(Debug)] struct Person { first_name: String, last_name: String } impl Person { fn new(first: &str, name: &str) -> Person { Person { first_name: first.to_string(), last_name: name.to_string() } } fn full_name(&self) -> String { format!("{} {}",self.first_name, self.last_name) } fn set_first_name(&mut self, name: &str) { self.first_name = name.to_string(); } fn to_tuple(self) -> (String,String) { (self.first_name, self.last_name) } } fn main() { let mut p = Person::new("John","Smith"); println!("{:?}", p); p.set_first_name("Jane"); println!("{:?}", p); println!("{:?}", p.to_tuple()); // p has now moved. } // Person { first_name: "John", last_name: "Smith" } // Person { first_name: "Jane", last_name: "Smith" } // ("Jane", "Smith")
Yaşam Sürelerinin Yüreğimizi Dağlamaya Başladığı O An
Yapıların çoğu zaman veri taşır ancak bazen referans taşıması da gerekebilir. Mesela düşünelim ki yapımıza karakter dizisi değeri yerine bir karakter dizisi dilimi ekleyeceğiz.
// life1.rs #[derive(Debug)] struct A { s: &str } fn main() { let a = A { s: "hello dammit" }; println!("{:?}", a); }
error[E0106]: missing lifetime specifier
--> life1.rs:5:8
|
5 | s: &str
| ^ expected lifetime parameter
Buradaki sorunu anlayabilmek için problemi bir de Rust'ın gözünden görmeniz gerekmekte. Rust, bir referansın ömrünün ne kadar uzun süreceğini hesaplamadan o referansa izin vermeyecektir. Bütün referanslar bir veriyi önüç alır ve her verinin bir yaşam süresi vardır. Referansların yaşam süreleri o verinin yaşam süresinden uzun olamaz. Rust, referansın geçersiz olduğu bir koşulun oluşma ihtimaline izin vermeyecektir.
Şimdi, karakter dizisi diliminin referansı bir String
değerini ya da "merhaba" gibi bir karakter dizisi kalıbını ödünç alır. Karakter dizesi kalıpları programın yaşamı boyunca yaşar ki buna "statik (static)" yaşam süresi deriz.
İşte şimdi tıkır tıkır çalışıyor - Rust'ın bir karakter dizisi kalıbının sürekli olarak var olacağını garanti etmiş olduk.
// life2.rs #[derive(Debug)] struct A { s: &'static str } fn main() { let a = A { s: "hello dammit" }; println!("{:?}", a); } // A { s: "hello dammit" }
Tabii bu hâli de çok şık görünmüyor ama net olmak için bazı bedeller ödemek gerekir.
Bunu bir fonksiyondan karakter dizisi dilimi döndürmek için de kullanabiliriz.
#![allow(unused)] fn main() { fn how(i: u32) -> &'static str { match i { 0 => "none", 1 => "one", _ => "many" } } }
Kısıtlayıcı olmasına karşın statik karakter dizilerinin bu tarz durumları için işe yarar.
Buna karşın, biz bir referansın yaşam ömrünü en az yapının ömrü kadar uzun olarak da belirtebiliriz.
// life3.rs #[derive(Debug)] struct A<'a> { s: &'a str } fn main() { let s = "I'm a little string".to_string(); let a = A { s: &s }; println!("{:?}", a); }
Yaşam ömürleri geleneksel olarak "a", "b" gibi harflerle belirtilir ancak siz dilerseniz "patlıcan" gibi kelimelerle de ifade edebilirsiniz.
Bu ekleme ile beraber, bizim A
yapısı ile s
karakter dizisi birbirine sıkı sıkıya bağlanmıştır: a
, s
ten ödünç alır ve o olmadan yaşayamaz.
Bu tanımla birlikte şu şekilde A
dönen bir fonksiyon yazabiliriz.
#![allow(unused)] fn main() { fn makes_a() -> A { let string = "I'm a little string".to_string(); A { s: &string } } }
Ancak bu sefer de A
'nın açıkça yaşam süresinin belirtilmesine ihtiyaç vardır - "expected lifetime parameter" (beklenilen yaşam süresi parametresi)
= help: this function's return type contains a borrowed value,
but there is no value for it to be borrowed from
= help: consider giving it a 'static lifetime
rustc
'nin verdiği tavsiyeye uyalım:
#![allow(unused)] fn main() { fn makes_a() -> A<'static> { let string = "I'm a little string".to_string(); A { s: &string } } }
Ve hatamız:
8 | A { s: &string }
| ^^^^^^ does not live long enough
9 | }
| - borrowed value only lives until here
Bunu güvenli bir şekilde yapmanın bir yolu yok, çünkü fonksiyon sona verdiği zaman string
düşecek ve string
e yapılan referanslar kendinden daha uzun süre yaşayamaz.
Bazen, bir yapının değer ve o değeri içeren bir referans taşıması iyi bir fikirmiş gibi görünebilir. Ama bu çok basit bir şekilde imkansızdır çünkü yapılar taşınabilir olmalıdır, ve her türlü taşınma referansı geçersiz kılacaktır. Üstelik bunu yapmanın bir gereği de yok - mesela yapınızın karakter dizisi alanı varsa ve bunun dilimlerini sunmaya ihtiyacınız varsa, indeks numaralarını tutabilir ve bir metot içerisinde gerçek dilimleri dönebilirsiniz.
Özellikler (Trait)
Rust'ta struct
'ın sınıf (class) olmadığına dikkat edin. class
kelimesinin anlamı diğer dillerde içi öylesine doldurulmuştur ki size nasıl düşüneceğinizi dikte eder hâle gelmiştir.
Şimdi şunlara dikkat edin: Rust'ta yapılar birbirini miras (inherit) alamaz; hepsi özgün tiplerdir. Alt-tip diye bir şey yok, o tarz şeyler sadece bir saçmalıktan ibaret.
Peki ya tipler arasındaki ilişkiler nasıl kurulur?
rustc
bazen "implementing X trait (X özelliğini uygulamak)" diye gevezelik eder ve şimdi özellikler (tipler) hakkında konuşmanın tam zamanı.
Aşağıdaki bir özellik tanımlamanın ve belirli tiplere nasıl uygulandığının örneğini görüyorsunuz.
// trait1.rs trait Show { fn show(&self) -> String; } impl Show for i32 { fn show(&self) -> String { format!("four-byte signed {}", self) } } impl Show for f64 { fn show(&self) -> String { format!("eight-byte float {}", self) } } fn main() { let answer = 42; let maybe_pi = 3.14; let s1 = answer.show(); let s2 = maybe_pi.show(); println!("show {}", s1); println!("show {}", s2); } // show four-byte signed 42 // show eight-byte float 3.14
Şahane; i32
ve f64
içerisine yeni bir metot ekledik.
Rust ile haşır neşir oldum diyebilmek için standart kütüphanedeki basit özellikleri de bilmeniz gerekir. (Ki genelde bir arada bulunurlar.)
Debug
epey yaygındır. Person
üzerinde #[derive(Debug)]
ile uyguladık, ancak isteseydik tam ismi görüntüleyecek şekilde de uygulayabilirdik.
#![allow(unused)] fn main() { use std::fmt; impl fmt::Debug for Person { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.full_name()) } } ... println!("{:?}", p); // John Smith }
write!
da epey kullanışlı bir makrodur - burada f
Write
özelliğini barındıran her şeyi temsil ediyor. (Mesela bu bir File
olabilir - ya da sadece bir String
)
Display
ise "{}" ile yazdırılabilen verileri kontrol ve tıpkı Debug
gibi uygulanır. Ve faydalı bir yan etki olarak, ToString
Display
'e sahip olan her türlü tipe uygulanır. Mesela Display
'ı Person
için uygularsak p.to_string()
de çalışır hâle gelir.
Clone
ise clone
metotunu tanımlar ve sadece #[derive(Clone)]
ile tanımlanabilir - eğer bütün alanların (fields) tipleri Clone
'a sahipse. (Ç.N: Clone - İngilizce Klonlamak)
Örnek: Noktalı sayı aralıklarının döngüleyicisi
Daha önce aralıklarla (range, 0..n
) karşılaştık ancak noktalı sayı kabul etmiyorlar. (Şansınızı zorlayabilirsiniz ancak pek de numarası olmayan 1.0'da takılıp kalırsınız.)
Bir döngüleyici (iterator) için yaptığımız gayriresmi tanımı hatırlayın; Some
veya None
dönebilen bir next
metotuna sahip yapı. Bu süreçte, döngüleyicinin kendisi düzenlenir ve döngülemenin durumu hakkında bilgi tutar. (Sonraki indeks gibi) Döngülenen verinin içeriği genellikle değişmez. (Ancak Vec::drain
gibi kendi verisini düzenleyen enteresan bir döngüleyiciyi de inceleyeceğiz.)
Ve şimdi de resmi tanımı görelim: "Iterator" özelliği
#![allow(unused)] fn main() { trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; ... } }
Iterator
için ilişkili tipi (associated type) de tanımış olduk. Bu özelliğin (trait) çalışması için bir tipe ihtiyaç vardır ve dönüş tipini de belirtmeniz gerekmektedir. next
metotu belirli bir tip belirtilmeden çalışabilir, sadece Self
üzerinden Item
'e atışta bulunulması yeterlidir.
f64
tipi için uygulanmış bir Iterator
, Iterator<Item=f64>
ile belirtilir ki bunu "f64 tipi ile ilişkilendirilmiş bir döngüleyici" olarak okuyabilirsiniz.
...
ile gösterilen kısım Iterator
ün tedarik ettiği metotlardır. Sadece Item
ve next
'i belirttikten sonra pek çok metot da sizin için sunulacaktır.
// trait3.rs struct FRange { val: f64, end: f64, incr: f64 } fn range(x1: f64, x2: f64, skip: f64) -> FRange { FRange {val: x1, end: x2, incr: skip} } impl Iterator for FRange { type Item = f64; fn next(&mut self) -> Option<Self::Item> { let res = self.val; if res >= self.end { None } else { self.val += self.incr; Some(res) } } } fn main() { for x in range(0.0, 1.0, 0.1) { println!("{} ", x); } }
Ve şöyle biçimsiz bir görüntüyü elde etmiş oluyoruz:
0
0.1
0.2
0.30000000000000004
0.4
0.5
0.6
0.7
0.7999999999999999
0.8999999999999999
0.9999999999999999
0.1
tam olarak noktalı sayı olarak gösterilemediğinden böyle tuhaf şeyler yaşıyoruz, minik bir formatlama yardımı ile bundan kurtulabiliriz. println!
kısımını şöyle düzeltelim:
#![allow(unused)] fn main() { println!("{:.1} ", x); }
Ve daha temiz bir çıktımız olmuş oluyor. (Bu formatlama "noktadan sonra bir rakam" anlamına geliyor.)
Şimdi bütün döngüleyici metotlarını kullanabiliriz, hadi bütün verileri bir vektörde toplayalım, daha da coşmak için bunu map
ile yapalım:
#![allow(unused)] fn main() { let v: Vec<f64> = range(0.0, 1.0, 0.1).map(|x| x.sin()).collect(); }
Genellenen Fonksiyonlar
Diyelim ki Debug
özelliiğine sahip herhangi bir tipi argüman olarak alan bir fonksiyon yazacağız. Burada jenerik fonksiyon kullanmamızın bir örneğini görüyorsunuz, herhangi bir verinin referansını argüman olarak alabilir. T
, tip parametresi oluyor ki fonksiyon ismi yazıldıktan hemen sonra tanımlandı:
#![allow(unused)] fn main() { fn dump<T> (value: &T) { println!("value is {:?}",value); } let n = 42; dump(&n); }
Ancak, Rust kelimenin tam anlamıyla T
tipi hakkında hiçbir şey bilmiyor.
error[E0277]: the trait bound `T: std::fmt::Debug` is not satisfied
...
= help: the trait `std::fmt::Debug` is not implemented for `T`
= help: consider adding a `where T: std::fmt::Debug` bound
Bunun çalışması için, T
'nin Debug
içermesi gerektiğinden bahsetmeliyiz:
#![allow(unused)] fn main() { fn dump<T> (value: &T) where T: std::fmt::Debug { println!("value is {:?}",value); } let n = 42; dump(&n); // value is 42 }
Rust'ın genellenen fonksiyonlarının tipe özellikleri sağlaması (trait bounds) gerekir - burada "T is any type that implements Debug" kısmını anlatıyoruz. (T, Debug'ı içeren herhangi bir tiptir) rustc
epey yardımcı oluyor ve hangi tipin tam olarak belirtilmesi gerektiğini bize bildiriyor.
Şimdi Rust, T
için tip bağlarını biliyor, artık derleyiciden mantıklı mesajlar alabiliriz.
#![allow(unused)] fn main() { struct Foo { name: String } let foo = Foo{name: "hello".to_string()}; dump(&foo) }
Buradaki hata ise "the trait std::fmt::Debug
is not implemented for Foo
(std::fmt::Debug
özelliği Foo
için uygulanmadı)"
Fonksiyonlar dinamik dillerde aslında genellenir çünkü değerler beraberinde türlerini taşırlar ve tür denetimi çalışma zamanı denetlenir - ya da başarısız olur. Karmaşık programlarda daha derleme zamanında tiplerin kontrol edilmesini ciddi anlamda isteriz! Bu dillerdeki bir programcı, derleme hatalarını sakince incelemek yerine programın çalışma anındadaki sürprizleri incelemek zorundadır. Murphy kanununa göre sorunlar en uygunsuz, ters zamanda ortaya çıkmaya meyillidir.
Bir sayının karesini almak jeneriktir; tam sayılar, noktalı sayılar ve çarpım operatörünü içeren her türlü şeyin karesini x*x
ile alabilirsiniz. Peki ya tip bağları?
// gen1.rs fn sqr<T> (x: T) -> T { x * x } fn main() { let res = sqr(10.0); println!("res {}",res); }
Sorun, Rust'ın T
'nin çarpılabilir olduğunu bilmemesidir.
error[E0369]: binary operation `*` cannot be applied to type `T`
--> gen1.rs:4:5
|
4 | x * x
| ^
|
note: an implementation of `std::ops::Mul` might be missing for `T`
--> gen1.rs:4:5
|
4 | x * x
| ^
Derleyicinin tavsiyesine uyarak bu tipi *
çarpım operatörünü barındıran ilgili özelliğe zorlamayı deneyelim.
#![allow(unused)] fn main() { fn sqr<T> (x: T) -> T where T: std::ops::Mul { x * x } }
Yine de hâlen daha çalışmıyor:
error[E0308]: mismatched types
--> gen2.rs:6:5
|
6 | x * x
| ^^^ expected type parameter, found associated type
|
= note: expected type `T`
= note: found type `<T as std::ops::Mul>::Output`
Bu tipi daha da kısıtlamayı deneyelim:
#![allow(unused)] fn main() { fn sqr<T> (x: T) -> T::Output where T: std::ops::Mul + Copy { x * x } }
(Ancak) şimdi oldu! Derleyiciyi sakince dinlemek sizi esas noktaya yaklaştırır, ta ki temizce program derlenene dek.
Tabii bunu C++
'da yapmak daha kolay.
template <typename T>
T sqr(x: T) {
return x * x;
}
Ama (dürüst olmak gerekirse), C++ laz müteahhit mantığını benimsiyor. C++'ın şablon (template) hataları berbattır çünkü derleyicinin tek bildiği şey bazı metotların ya da operatörlerin tanımlanıp tanımlanmadığıdır. C++ komitesi bu sorunu biliyor ve konseptler üzerinde çalışıyorlar ki bunlar daha çok özelliklerle kısıtlanmış tip parametrelerine çok benziyorlar.
Genellenmiş fonksiyonlar başta biraz zorlayıcı gelebilir ancak net olmak, ne tür değerleri güvenle kullanabileceğinizi sadece tanıma bakarak kullanabileceğiniz anlamına geliyor.
Bu fonksiyonlar çok biçimlinin tersi olarak tek biçimli olarak bilinir. (ÇN: Tek biçimli - monomorfik, çok biçimli - polimorfik) Fonksiyonun gövdesi her bir tip için ayrı ayrı derleme yapar. Çok biçimli fonksiyonlarda ise makine eşlesen her tip için aynı kodu kullanır, dinamik olarak doğru metota yönlendirir (dispatch).
Tek biçimlilik hızlı kod üretir, tipler için özelleştirilmiştir ve satır içi çalışabilirler. sqr(x)
görüldüğü anda hemen x*x
ile değiştirirlir. Ancak bunun dezavantajı, büyük genellenmiş fonksiyonların her için çok fazla kod üretmesidir ki buna kod şişmesi (code bloat) denir. Her zaman bir takas vardır ve deneyimli bir kişi hangi iş için doğru aracı seçeceğini bilmelidir.
Basit Numaralandırmalar
Numaralandırmalar (Enums) birkaç verisi bulunan tiplerdir. Örneğin, bir yön dört farklı şekil alabilir:
#![allow(unused)] fn main() { enum Direction { Up, Down, Left, Right } ... // `start` is type `Direction` let start = Direction::Left; }
Çeşitli metotlar alabilirler, tıpkı yapılar gibi. Match
ifadesi enum
tiplerini kontrol etmenin en basit yoludur.
#![allow(unused)] fn main() { impl Direction { fn as_str(&self) -> &'static str { match *self { // *self has type Direction Direction::Up => "Up", Direction::Down => "Down", Direction::Left => "Left", Direction::Right => "Right" } } } }
Noktalama da önemlidir. self
'ten önce *
operatörünü kullandığımıza dikkat edin. Unutması kolaydır çünkü çoğu zaman Rust böyle düşünür. (self.first_name
deriz, (*self).first_name
değil.) Fakat eşleştirmenin biraz daha net olması gerekir. Olduğu gibi bırakmak buna kadar varan bir sürü çıktıya sebep olur:
= note: expected type `&Direction`
= note: found type `Direction`
Çünkü self
&Direction
tipidir, bundan dolayı *
ile deferans ederiz.
Yapılar gibi numaralandırmalar da özellikleri içerebilir, #[derive(Debug)]
arkadaş da Direction
'a eklenebilir.
#![allow(unused)] fn main() { println!("start {:?}",start); // start Left }
Yani as_str
metotu aslında o kadar da gerekli değil, Debug
ile isimleri her zaman alabiliriz. (Ancak as_str
alan tahsis etmez, ki bu önemli olabilir.)
Ancak burada net bir sıralama aramamalısınız - numaralandırmalar tam sayı değeri barındırmaz.
(Ç.N: Numaralandırma olarak çevrilen enum
sözcüğü gerçekten de C ve C++'da sayılandırma işlemi için kullanılır ancak Rust'ta böyle bir özellik yoktur. C++'daki karşılığı enum
değil, enum class
'tır.)
Şimdi her Direction
değerinin ardılını gösteren bir metot yazdık. use
içinde yıldız jokerini kullanmak metotun içeriğine bütün numaralandırma değerlerini sıraladığı için epey kullanışlıdır.
#![allow(unused)] fn main() { fn next(&self) -> Direction { use Direction::*; match *self { Up => Right, Right => Down, Down => Left, Left => Up } } ... let mut d = start; for _ in 0..8 { println!("d {:?}", d); d = d.next(); } // d Left // d Up // d Right // d Down // d Left // d Up // d Right // d Down }
Bu şekilde istenen ve belirlenmiş düzende bütün yönleri sonsuza dek sıralamaya izin verir. Aslında bu oldukça basit bir durum makinesidir.
Numaralandırma verileri kıyaslanamaz:
assert_eq!(start, Direction::Left);
error[E0369]: binary operation `==` cannot be applied to type `Direction`
--> enum1.rs:42:5
|
42 | assert_eq!(start, Direction::Left);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
note: an implementation of `std::cmp::PartialEq` might be missing for `Direction`
--> enum1.rs:42:5
Çözüm, enum Direction
tanınımının üstüne #[derive(Debug,PartialEq)]
eklemektir.
Önemli bir nokta, Rust'ın kullanıcı tiplerinin bir eklenti ile birlikte gelmemesidir. Genel özellikleri (trait) ekleyerek onlara olağan davranışları verirsiniz. Bu yapılar için de geçerlidir - eğer bir yapıya PartialEq
verirseniz akla yatkın bir şey belirlenecek, tüm alanların PartialEq
'e sahip olduğunu düşünerek bir kıyas yapacaktır. Eğer alanlar buna sahip değilse, eşitliği tanımlananız gerekmektedir ki bunu açıkça tanımlamanıza izin vardır.
Rust'ta "C tarzı numaralandırmalar" da kullanılabilir.
// enum2.rs enum Speed { Slow = 10, Medium = 20, Fast = 50 } fn main() { let s = Speed::Slow; let speed = s as u32; println!("speed {}", speed); }
İlklendiği zaman tam sayı değeri alırlar ve tip dönüşümleriyle tam sayıya dönüşebilirler.
Bunun için sadece ilk isme değer vermeniz yeterlidir, diğerleri de bir arttırarak onu takip edecektir:
#![allow(unused)] fn main() { enum Difficulty { Easy = 1, Medium, // is 2 Hard // is 3 } }
Tabii isim diyince anlamı tam oturmadı, tıpkı her şeye "şey" demek gibi. Esas kullanılması gereken terim varyanttır - Speed
in varyantları Slow
, Medium
ve Fast
tır.
Numaralandırmalar doğal bir sıralama da alabilir, ancak bunu kibarca istemelisiniz. enum Speed
'in başına #[derive(PartialEq,PartialOrd)]
ekledikten sonra Speed::Fast > Speed::Slow
ve Speed::Medium != Speed::Slow
gibi ifadeler kullanılabilir olur.
Numaralandırmalar Tam Teçhizatlıyken
Rust'ın numaralandırmaları tam anlamıyla kullanıldığı zaman C'deki birliklerin (union) steroidli hâline benzer, tıpkı Ferrari ile Fiat Uno gibi. Çeşitli tiplerden verileri bir araya güvenlice toplamanın zorluğunu düşünün.
// enum3.rs #[derive(Debug)] enum Value { Number(f64), Str(String), Bool(bool) } fn main() { use Value::*; let n = Number(2.3); let s = Str("hello".to_string()); let b = Bool(true); println!("n {:?} s {:?} b {:?}", n,s,b); } // n Number(2.3) s Str("hello") b Bool(true)
Numaralandırma bu verilerden sadece birisini taşıyabilir, büyüklüğü bir varyantın en büyük değeri kadardır.
Şimdiye kadar bir süper araba etmese de numaralandırmaların kendilerini yazdırabilmeleri de güzel şey. Bunun yanında verilerinin ne tarz veriler olduğunu da biliyorlar ki bu match
'ın süpergücüdür.
#![allow(unused)] fn main() { fn eat_and_dump(v: Value) { use Value::*; match v { Number(n) => println!("number is {}", n), Str(s) => println!("string is '{}'", s), Bool(b) => println!("boolean is {}", b) } } .... eat_and_dump(n); eat_and_dump(s); eat_and_dump(b); //number is 2.3 //string is 'hello' //boolean is true }
(Result
ve Option
kardeşleri hatırladınız mı? Onlar da bir numaralandırma.)
eat_and_dump
fonksiyonu hiç fena değil ancak veriyi bir referans olarak iletsek iyi olur çünkü şu an verinin yerini taşıyor ve onu "yiyor":
#![allow(unused)] fn main() { fn dump(v: &Value) { use Value::*; match *v { // type of *v is Value Number(n) => println!("number is {}", n), Str(s) => println!("string is '{}'", s), Bool(b) => println!("boolean is {}", b) } } error[E0507]: cannot move out of borrowed content --> enum3.rs:12:11 | 12 | match *v { | ^^ cannot move out of borrowed content 13 | Number(n) => println!("number is {}",n), 14 | Str(s) => println!("string is '{}'",s), | - hint: to prevent move, use `ref s` or `ref mut s` }
Ödünç alınmış referanslarla yapamayacağınız bazı şeyler var. Rust, orijinal değerin içerisindeki karakter dizisini dışarı çıkartmanıza izin vermeyecektir. Number
üzerinde sorun yok çünkü f64
'ün kopyalanmasında bir sakınca yok ama String
Copy
'i içermez.
match
'ın kesin tipler hakkında seçici olduğunu söyledim, ipucunu takip edelim ve sıkıntı çıkartmayacaktır, şimdi içerideki karakter dizisine bir referans ödünç alıyoruz.
#![allow(unused)] fn main() { fn dump(v: &Value) { use Value::*; match *v { Number(n) => println!("number is {}", n), Str(ref s) => println!("string is '{}'", s), Bool(b) => println!("boolean is {}", b) } } .... dump(&s); // string is 'hello' }
Devam etmeden önce, başarılı bir Rust derlemesinin mutluluğu ile dolup taşmışken, bir saniye bekleyelim. Rustc
o kadar iyi ki sorunu tam olarak anlamadan onu çözmemizi sağlıyor.
Sorun, eşleştirmenin kesinliğinden ve ödünç kontrolünün kuralların çiğnenmemesinden kaynaklanıyor. Bu kurallardan birisi, sahipliği olan bir tipe dahil olan veriyi zart diye çekemiyor olmamızdan geliyor. Biraz C++ bilmek burada kafa karıştırabilir çünkü akla yatkın olsa bile C++ problemin yolunu kopyalayacaktır. Bir vektörden karakter dizesi alırken de aynı hatayı alabilirsiniz, mesela *v.get(0).unwrap
ile deneyin. (*
kullanmanızın sebebi indekslemenin referans dönmesi) Buna yapmanıza izin vermecektir. (Bu tarz durumlarda Clone
çok da kötü bir tercih olmayabilir.)
(Bu arada, v[0]
karakter dizeleri gibi kopyalanamaz verilerde tam olarak bundan dolayı çalışmayacaktır. &v[0]
ile ödünç almanız ya da v[0].clone()
kullanmanız gerekmektedir.)
match
kullanırken Str(s: String) =>
yerine Str(s)
yazıldığını görebilirsiniz. Yeni bir yerel değişken yaratılır. (bazen bağlama (binding) olarak anılır) Çoğu zaman tatmin edilen tip uyar, mesela veriyi alıp onun içinden çıkartırken. Ancak burada s: &String
yazmaya ihtiyacımız oldu ve ref
ile sadece String
'i ödünç almak istediğimizi bildirmiş olduk.
Burada da bir karakter dizisini dışarı çıkartıyoruz ve değerin daha sonra ne olacağını umursamıyoruz. _
geri kalan her şeyle eşleşecektir.
#![allow(unused)] fn main() { impl Value { fn to_str(self) -> Option<String> { match self { Value::Str(s) => Some(s), _ => None } } } ... println!("s? {:?}", s.to_str()); // s? Some("hello") // println!("{:?}", s) // error! s has moved... }
İsimlendirme önemlidir -, as_str
olarak değil de to_str
olarak tanımlamamıza dikkat edin. (Ç.N: To Str - Str'ye çevir, As Str - Str olarak) Bir karakter dizisini Option<&String>
olarak dönen bir metot yazabilirsiniz. (Referansın da numaralandırma değeri ile aynı yaşam süresinde olmasına gerek vardır) Ancak onu to_str
olarak isimlendirmemelisiniz.
to_str
örneğimizi şöyle yazabilirsiniz - tamamen aynı işi yapar:
#![allow(unused)] fn main() { fn to_str(self) -> Option<String> { if let Value::Str(s) = self { Some(s) } else { None } } }
Eşleştirme Hakkında Daha Fazlası
"()" kullanarak bir demeti dışarı çıkartabileceğinizi hatırladınız mı?
#![allow(unused)] fn main() { let t = (10,"hello".to_string()); ... let (n,s) = t; // t has been moved. It is No More // n is i32, s is String }
Bu parçalama işleminin özel bir durumudur; elimizdeki bazı veriler var ve (buradaki gibi) parçalara ayırmayı ya da verilerini ödünç almayı düşünebiliriz. Her iki durum da da bir bütünün parçalarına ulaşmaya çalışıyoruz.
Sözdizimi match
'taki gibi kullanılabilir. Burada açıkça ödünç alınmış verileri ödünç alıyoruz.
#![allow(unused)] fn main() { let (ref n,ref s) = t; // n and s are borrowed from t. It still lives! // n is &i32, s is &String }
Yapıları parçalamak da pekâlâ mümkün:
#![allow(unused)] fn main() { struct Point { x: f32, y: f32 } let p = Point{x:1.0,y:2.0}; ... let Point{x,y} = p; // p still lives, since x and y can and will be copied // both x and y are f32 }
match
'ı yeni örüntülerle tekrar inceleyelim. İlk iki örüntü let
parçalaması gibi çalışır - ilki ilk elemanı sıfır olan, ikinci indeksi karakter dizesi olan her türlü demetle eşleşir, ikincisi ise sadece (1, "hello")
ile eşleşir. Son koşulda ise olarak, bir değişken herhangi bir şeyle eşleşir. Eğer match
bir ifadeyi eşleştiriyorsa ancak bunu değişkene bağlamak istemiyorsanız bu epey kullanışlıdır. _
da bir değişken gibi çalışır ancak görmezden gelinir, bir match
'ı bitirmenin yaygın bir yoludur.
#![allow(unused)] fn main() { fn match_tuple(t: (i32,String)) { let text = match t { (0, s) => format!("zero {}", s), (1, ref s) if s == "hello" => format!("hello one!"), tt => format!("no match {:?}", tt), // or say _ => format!("no match") if you're not interested in the value }; println!("{}", text); } }
Peki neden sadece (1, "hello")
kullanmıyoruz? Eşleştirme kesin olarak çalışır ve derleyici de bundan bahsedecektir:
= note: expected type `std::string::String`
= note: found type `&'static str`
Neden ref s
'e ihtiyacımız var? Bu biraz belirsiz bir durum (E00008 numaralı hataya bakın.) ve eğer bir koşula bağlayacaksanız bunu ödünç almanız gerekir, koşula bağlamanız farklı bir bağlamda gerçekleştiğinden bellekteki alanın taşınması gerekebilir. Bu, işin en civcivli olduğu yerlerden birisi.
Eğer tipimiz &str
olsaydı bunu doğrudan eşleştirebilirdik:
#![allow(unused)] fn main() { match (42,"answer") { (42,"answer") => println!("yes"), _ => println!("no") }; }
match
için geçerli olan if let
için de geçerlidir. Bu mesela güzel bir örnek, bir Some
verimiz olduğu için içindeki veriyi çekebiliriz ve içinden sadece bir karakter dizisini çıkartabiliriz. İç içe geçmiş if let
ifadelerine ihtiyacımız da yok üstelik. Burada _
kullanıyoruz çünkü demetin ilk parçası ilgimizi çekmiyor.
#![allow(unused)] fn main() { let ot = Some((2,"hello".to_string()); if let Some((_,ref s)) = ot { assert_eq!(s, "hello"); } // we just borrowed the string, no 'destructive destructuring' }
parse
ile ilgili bir enteresan bir sorunumuz da var. (Ya da dönüş tipini bilmesi gereken fonksiyonlar için de bunu düşünebiliriz)
#![allow(unused)] fn main() { if let Ok(n) = "42".parse() { ... } }
n
'in tipi nedir? Bir ipucu vermeniz gerekir, ne tür bir tam sayılı değer bu? Hatta bu tam sayı mıdır?
#![allow(unused)] fn main() { if let Ok(n) = "42".parse::<i32>() { ... } }
Bu rezil söz diziminin adı "turbofish operatörüdür".
Eğer Result
dönen bir fonksiyonun içerisindeyseniz, soru işareti ile çok daha şık bir çözüm kullanabilirsiniz:
#![allow(unused)] fn main() { let n: i32 = "42".parse()?; }
Her neyse, herhangi bir parse
hatası Result
'ın hata tipine dönüştürülebilir bir tipe ihtiyaç duyar ki bunu sonra hata kontrolü kısmında ele alacağız.
Kapamalar (Closure)
Rust'ın gücünün büyük bir kısmı bu kapamalardan gelir. En basit hâliyle bir fonksiyonun kısa yoluna benzerler:
#![allow(unused)] fn main() { let f = |x| x * x; let res = f(10); println!("res {}", res); // res 100 }
Burada açıkça belirtilmiş bir tip yoktur - bir "10" tam sayı kalıbının kullanılmasına kadar her şey tahmin edilmiştir.
Ancak f
'i farklı farklı tipler için kullanırsak hata alırız - Rust f
'in tam sayılarla çalışması gerektiğine karar vermişti.
let res = f(10);
let resf = f(1.2);
|
8 | let resf = f(1.2);
| ^^^ expected integral variable, found floating-point variable
|
= note: expected type `{integer}`
= note: found type `{float}`
İlk kullanım x
için argümanı belirlemişti. Aslında yaptığımız şey şudur:
#![allow(unused)] fn main() { fn f (x: i32) -> i32 { x * x } }
Ancak açıkça tiplerin yazılmaması dışında fonksiyonlar ve kapamaların bir farkı daha vardır. Doğru fonksiyonunu inceleyelim:
#![allow(unused)] fn main() { let m = 2.0; let c = 1.0; let lin = |x| m*x + c; println!("res {} {}", lin(1.0), lin(2.0)); // res 3 5 }
Bunu fn
ile böyle yapamayız, kapsamının dışında kalan hiçbir şeyle fn
ilgilenmez. Buradaki kapama, m
ve c
'yi kendi kapsamı içerisine ödünç aldı.
Peki ya lin
'in tipi nedir? Ancak rustc
bilebilir. Aslında görünenin altında kapama, çağrılabilir bir (çağırma operatörünü içeren bir) yapıdır (struct). Şu şekilde yazılmış gibi davranır:
#![allow(unused)] fn main() { struct MyAnonymousClosure1<'a> { m: &'a f64, c: &'a f64 } impl <'a>MyAnonymousClosure1<'a> { fn call(&self, x: f64) -> f64 { self.m * x + self.c } } }
Derleyici bu konuda epey yardımcı oluyor ve basit bir kapamayı buna dönüştürüyor! Tek bilmeniz gereken kapama bir yapıdır ve verileri içinde bulunduğu çevreden ödünç alır. Bu referansların da bir yaşam ömrü vardır.
Bütün kapamaların benzersiz tipleri vardır ancak benzer özellikleri (trait) içerirler. Türü tam bilmesek de en azından jeneriklerde nasıl ifade edeceğimi biliyoruz:
#![allow(unused)] fn main() { fn apply<F>(x: f64, f: F) -> f64 where F: Fn(f64)->f64 { f(x) } ... let res1 = apply(3.0,lin); let res2 = apply(3.14, |x| x.sin()); }
El-meal: apply
Fn(f64)->f64
'e sahip herhangi bir T tipi ile çalışabilir - yani f64
alıp f64
dönen bir fonksiyon olabilir bu.
apply(3.0, lin)
şeklinde çağırdıktan sonra lin
'e erişmek şu tuhaf hatayı ortaya çıkartıyor:
let l = lin;
error[E0382]: use of moved value: `lin`
--> closure2.rs:22:9
|
16 | let res = apply(3.0,lin);
| --- value moved here
...
22 | let l = lin;
| ^ value used here after move
|
= note: move occurs because `lin` has type
`[closure@closure2.rs:12:15: 12:26 m:&f64, c:&f64]`,
which does not implement the `Copy` trait
Ve bu kadar, apply
bizim kapamamızı yedi. Ve ayrıca, rustc
'nin kullanmaya çalıştığı yapının (struct) gerçek tipi. Kapamaları bir yapı olarak düşünmek işi epey kolaylaştırıyor.
Bir kapama çağırmak aslında metot çağrısıdır: Üç tip fonksiyon özelliği (trait) üç tip metoda sahiptir:
Fn
,&self
olarak geçer.FnMut
,&mut self
olarak geçer.FnOnce
ise sadeceself
olarak geçer.
Bir kapama içerisinde yakalanmış referansları düzenlemek de mümkündür.
#![allow(unused)] fn main() { fn mutate<F>(mut f: F) where F: FnMut() { f() } let mut s = "world"; mutate(|| s = "hello"); assert_eq!(s, "hello"); }
mut
'a dikkat edin - f
'in değişebilir olması gerekiyor.
Yine de, ödünç alma ile ilgili kurallardan kaçınamazsınız. Şuna bakın:
#![allow(unused)] fn main() { let mut s = "world"; // closure does a mutable borrow of s let mut changer = || s = "world"; changer(); // does an immutable borrow of s assert_eq!(s, "world"); }
Çalışamaz! Çünkü s
'i assert
deyiminde ödünç alamıyoruz, çünkü daha önce changer
kapamasında değişken olarak ödünç almıştır. Kapama düşürülmediği sürece s
'e hiç kimse erişemez, bundan dolayı iç bir kapsam alanı içerisinde kullanarak yaşam süresini kontrol etmek en iyi çözümdür.
#![allow(unused)] fn main() { let mut s = "world"; { let mut changer = || s = "world"; changer(); } assert_eq!(s, "world"); }
Eğer Lua ve JavaScript gibi dillere aşinaysanız, bu dillerde basit olmasına karşın Rust'ta kapamaların bu denli karmaşık olduğunu merak ediyor olabilirsiniz. Bu, Rust'ın gizlice bellek tahsis etmemesi için gerekli bir bedeldir. JavaScript'te, mutate(function() {s = "hello";})
gibi bir ifadenin karşılığı her zaman dinamik bellek tahsis edilmiş kapamadır.
Bazen kapamaların verileri ödünç almasını değil direkt taşımasını isteyebilirsiniz.
#![allow(unused)] fn main() { let name = "dolly".to_string(); let age = 42; let c = move || { println!("name {} age {}", name,age); }; c(); println!("name {}",name); }
Burada alacağımız hata son println
'dadır: "taşınmış verinin kullanımı: name
(use of moved value: name
)". Burada tek bir çözüm var, kapamanın içine veriyi taşımak:
#![allow(unused)] fn main() { let cname = name.to_string(); let c = move || { println!("name {} age {}",cname,age); }; }
Neden taşıyan kapamalara ihtiyacımız var? Çünkü orijinal verinin erişilemeyeceği bir durumda onları çağırmamız gerekebilir. En basit örneği iş parçacıklarıdır. Taşıyan kapamalar ödünç almaz, bundan dolayı yaşam süresi açısından hiçbir sorunları olmaz.
Kapamaların esas kullanımı döngüleyici metotlarıdır. Noktalı sayılar için hazırladığımız range
döngüleyicisini hatırlayın. Kapama kullanarak bu döngüleyici (veya başka döngüleyiciler) üzerinde işlem yapmak gayet kolay:
#![allow(unused)] fn main() { let sine: Vec<f64> = range(0.0,1.0,0.1).map(|x| x.sin()).collect(); }
map
vektörler üzerinde tanımlanmadı (Bunu kullanan bir özellik (trait) yaratmak oldukça kolay olmasına rağmen) çünkü map
'ın yeni bir vektör yaratması gerekirdi. Bu şekilde elimizde seçeneklerimiz oluyor. Üstelik, geçici hiçbir öğe yaratılmış olmuyor:
#![allow(unused)] fn main() { let sum: f64 = range(0.0,1.0,0.1).map(|x| x.sin()).sum(); }
Tıpkı bir döngü yazmak kadar kadar hızlı. Eğer Rust kapamaları JavaScript kapamaları kadar "acısız" olsaydı bu performansı garanti edemezdik.
filter
da ayrıca bir iterator metotudur - geriye sadece koşullara uyanlar kalır:
#![allow(unused)] fn main() { let tuples = [(10,"ten"),(20,"twenty"),(30,"thirty"),(40,"forty")]; let iter = tuples.iter().filter(|t| t.0 > 20).map(|t| t.1); for name in iter { println!("{} ", name); } // thirty // forty }
Üç Tarz-ı Döngüleyici
Üç farklı çeşit (yine) üç basit argüman tipine denk düşüyor. Bir String
vektörümüz olduğunu düşünelim. Bunlar bizim döngüleyici tiplerimiz, ilk üçü aleni bir şekilde sonraki üçü de gizil bir şekilde belirtilmiştir.
#![allow(unused)] fn main() { for s in vec.iter() {...} // &String for s in vec.iter_mut() {...} // &mut String for s in vec.into_iter() {...} // String // implicit! for s in &vec {...} // &String for s in &mut vec {...} // &mut String for s in vec {...} // String }
Şahsen ben aleni bir şekilde ifade etmeyi tercih ediyorum, ancak iki formu da anlamak ve nasıl kullanıldığını bilmek önemlidir.
into_iter
vektörü tüketir ve içeriğindeki karakter dizilerini çıkartır, ve ardından artık vektör kullanılamaz - artık taşınmış olur. Pythonistalar alışkanlıktan for s in vec
dediği zaman başlarına bu gelir.
for s in &vec
şeklindeki gizil form muhtemelen kullanmak isteyeceğiniz şekildir, tıpkı fonksiyon argümanlarında &T
kullanmak gibi.
Üç çeşidi de anlamak önemlidir çünkü Rust tip tahminlerini epeyce kullanır - kapama argümanlarında tip bildirimlerini pek görmezsiniz. Bu iyi bir şey çünkü bu tiplerin hepsi yazılsaydı kafa şişirici olurdu. Ancak, bu ufak kodun bedeli gizil tiplerin ne olduğunu net olarak bilmenizin gerekmesidir!
map
döngüleyicinin değerini ne olursa olsun alır ve onu başka bir şeye dönüştürür, ancak filter
veriye bir referans alır. Aşağıda, iter
kullanıyoruz ve bundan dolayı döngüleyici tipi &String
tir. filter
'ın her veriyi referansını aldığını gözden kaçırmayın:
#![allow(unused)] fn main() { for n in vec.iter().map(|x: &String| x.len()) {...} // n is usize .... } for s in vec.iter().filter(|x: &&String| x.len() > 2) { // s is &String ... } }
Metotları çağırdığınız zaman Rust kendiliğinden dereferans eder, ondan dolayı sorunu pek anlamazsınız. Ancak |x: &&String| x == "one"
çalışmayacaktır çünkü operatörler tip eşleştirmesinden daha katıdır. rustc
, &str
ve &&String
'i kıyaslayacak bir operatör olmadığını bildirecektir. Bundan dolayı eşleşme yapabilmek için &&String
'i &String
e çevirmek için dereferans etmeniz gerekecektir.
#![allow(unused)] fn main() { for s in vec.iter().filter(|x: &&String| *x == "one") {...} // same as implicit form: for s in vec.iter().filter(|x| *x == "one") {...} }
Eğer tipleri bildirmeyi bırakırsanız, argümanı şu şekilde düzeltebilirsiniz ki bu sefer s'in tipi &String
olur.
#![allow(unused)] fn main() { for s in vec.iter().filter(|&x| x == "one") }
Ve çoğu zaman bu şekilde yazıldığını görürsünüz.
Dinamik Verili Yapılar
Kendisine refereans barındıran yapı tekniği çok güçlü bir tekniktir.
Aşağıda C ile yazılmış bir ikili ağacın temel tuğlasını görüyorsunuz. (C... Âdeta Beyoğlu'nun arka sokakları gibi... "Acaba başıma ne gelecek?" demeden dolaştığınız tarihî sokaklarda nefes kesici bir gezi...)
#![allow(unused)] fn main() { struct Node { const char *payload; struct Node *left; struct Node *right; }; }
Bunu doğrudan Node
alanlarını içererek yapamazsınız çünkü Node
'un büyüklüğü yine Node
'a dayanır. Ki bu hesaplanamaz. Bundan dolayı Node
yapılarının göstericilerini (pointer) kullanıyoruz, ki göstericinin boyutu her zaman kestirilebilir.
Eğer left
, NULL
değilse Node
'un left
tarafı bir başka Node
gösteriyordur ve bu böyle sonsuza kadar gidebilir.
Rust'ta NULL
yoktur (en azından bu güvensiz hâliyle yok), bu Option
'un işidir. Ancak Node
'u doğrudan Option
içerisine ekleyemezsiniz çünkü Node
'un boyutunu bilemezsiniz. (gibi gibi) Bu da Box
'un işidir, kendisinin sabit bir boyutu vardır ancak bellekte alanı tahsis edilmiş veriyi işaret eder.
İşte Rust'taki karşılığına bakalım, type
ile tipimize bir takma ad verdik:
#![allow(unused)] fn main() { type NodeBox = Option<Box<Node>>; #[derive(Debug)] struct Node { payload: String, left: NodeBox, right: NodeBox } }
(Rust işte böyle kalender meşreptir, ileriye dönük bildirimlere ihtiyacınız yoktur.)
Şimdi bunu test edelim:
impl Node { fn new(s: &str) -> Node { Node{payload: s.to_string(), left: None, right: None} } fn boxer(node: Node) -> NodeBox { Some(Box::new(node)) } fn set_left(&mut self, node: Node) { self.left = Self::boxer(node); } fn set_right(&mut self, node: Node) { self.right = Self::boxer(node); } } fn main() { let mut root = Node::new("root"); root.set_left(Node::new("left")); root.set_right(Node::new("right")); println!("arr {:#?}", root); }
Çıktı beklediğimizden çok daha iyi, "{:#?}" sağolsun. ("#" genişletilmiş demektir.)
root Node {
payload: "root",
left: Some(
Node {
payload: "left",
left: None,
right: None
}
),
right: Some(
Node {
payload: "right",
left: None,
right: None
}
)
}
Peki ya root
düşerse? Bütün alanlar da düşer, ağacın dalları
düşerse kendi alanlarını da kaybolur ve böyle devam eder. Box::new
, C++'daki new
anahtar kelimesine en çok ulaşacağınız alandır ancak delete
veyahut free
gibi bir kelimeye ihtiyacınız yoktur.
Bu ağacı kullanmak için bir yol bulmalıyız. Karakter dizilerinin sıralanabildiğine dikkat edin: "hede" < "hödö", "ayı" > "abi"; sözde alfabetik sıralama olarak anılır. (Aslını söylemek gerekirse, insan dillerinin çeşitliliğinden ve tuhaf kurallarına istinaden buna sözlüksel sıralama denir.)
Aşağıda Node
ları sözlüksel sıralamaya göre yerleştiren bir metot görüyorsunuz. Veriyi mevcut Node
ile kıyaslıyoruz - eğer küçükse soluna yerleştiriyoruz, değilse de sağına yerleştirmeye çalışıyoruz. Solda bir Node
olmayabilir, bundan dolayı set_left
kullanıyoruz.
fn insert(&mut self, data: &str) { if data < &self.payload { match self.left { Some(ref mut n) => n.insert(data), None => self.set_left(Self::new(data)), } } else { match self.right { Some(ref mut n) => n.insert(data), None => self.set_right(Self::new(data)), } } } ... fn main() { let mut root = Node::new("root"); root.insert("one"); root.insert("two"); root.insert("four"); println!("root {:#?}", root); }
match
'a dikkat edin - Box
içerisinden değişken bir referans çıkartıyoruz, eğer Option
'un içeriği Some
ise insert
kullanıyoruz. Değilse, sol tarafa yeni bir Node
ekliyoruz ve böyle devam ediyoruz. Box
, akıllı bir göstericidir; Node
metotlarını çağırmak için "kutudan çıkarmamıza" gerek yok!
İşte ağacımızın görüntüsü:
root Node {
payload: "root",
left: Some(
Node {
payload: "one",
left: Some(
Node {
payload: "four",
left: None,
right: None
}
),
right: None
}
),
right: Some(
Node {
payload: "two",
left: None,
right: None
}
)
}
Diğerlerinden daha "küçük" olan karakter dizileri sol eklenir, aksi durumda ise sağa eklenirler.
Şimdi gezinti zamanı. Bu iç-sıralı gezinmedir. (inorder traversal) - önce solu ziyaret ediyoruz, bir şeyler yapıyoruz ve sonra da sağa geçiyoruz.
#![allow(unused)] fn main() { fn visit(&self) { if let Some(ref left) = self.left { left.visit(); } println!("'{}'", self.payload); if let Some(ref right) = self.right { right.visit(); } } ... ... root.visit(); // 'four' // 'one' // 'root' // 'two' }
Karakter dizilerini bir sıralamaya göre geziyoruz! ref
'in if let
için kullanıldığına dikkat edin, match
ile aynı kurallara sahiptir.
Genellenen Yapılar
Önceki örneğimizde kullandığımız ikili ağaç yapısını düşünün. Bütün payload
tipleri için yeniden yazmak epey çıldırtıcı olurdu doğrusu. T
tip parametresiyle Node
'u yeniden jenerik şekilde yazıyoruz.
#![allow(unused)] fn main() { type NodeBox<T> = Option<Box<Node<T>>>; #[derive(Debug)] struct Node<T> { payload: T, left: NodeBox<T>, right: NodeBox<T> } }
Bu kullanım diller arasındaki farkları da belli ediyor. Payload
üzerindeki temel işlem karşılaştırmadır, bundan dolayı T ile <
kullanılabilmelidir ki PartialOrd
bunu sağlar. Tip parametresi impl
bloğu içerisinde özellik kısıtlamasıyla birlikte yazılmalıdır.
impl <T: PartialOrd> Node<T> { fn new(s: T) -> Node<T> { Node{payload: s, left: None, right: None} } fn boxer(node: Node<T>) -> NodeBox<T> { Some(Box::new(node)) } fn set_left(&mut self, node: Node<T>) { self.left = Self::boxer(node); } fn set_right(&mut self, node: Node<T>) { self.right = Self::boxer(node); } fn insert(&mut self, data: T) { if data < self.payload { match self.left { Some(ref mut n) => n.insert(data), None => self.set_left(Self::new(data)), } } else { match self.right { Some(ref mut n) => n.insert(data), None => self.set_right(Self::new(data)), } } } } fn main() { let mut root = Node::new("root".to_string()); root.insert("one".to_string()); root.insert("two".to_string()); root.insert("four".to_string()); println!("root {:#?}", root); }
Tıpkı C++ gibi genellenen yapımız tip parametrelerinin köşeli ayraçlarla gösterilmesine ihtiyaç duyar. Rust genellikle bu tür tip parametresini bağlamdan tahmin edebilecek kadar zekidir - Bunun Node<T>
olduğunu biliyor ve T
üzerinde insert
kullanıyor. İlk insert
tasarısı sadece String
ile takılıp kalmıştı. Ancak yeni kullanım uymuyorsa muhtemelen bir şekilde bunu bildirecektir.
Ancak, tipi uygun biçimde kısıtlamanız gerektiğine dikkat edin.
Dosya Sistemi ve Süreçler
Dosya Okumaya Farklı Bir Bakış
Birinci bölümün sonunda bütün dosyayı bir karakter dizisi içerisine aktarmayı göstermiştim. Doğal olarak bu her zaman iyi bir fikir olmayabilir, bu yüzden bir dosyayı satır satır okumayı göstereceğim.
fs::File
, io::Read
'ı tanımlar ki bu okunabilen her şeyin özelliğidir. (trait) Bu özellik, u8
ile bayt içeren bir dilimi dolduran read
metotunu tanımlar - bu özelliğin gerekli tek metotudur ve beraberinde gelen pek çok metotu bedavaya kapmış olursunuz, Iterator
gibi. Bir bayt vektörünü doldurmak için read_to_end
'u veya bir karakter dizisini doldurmak için read_to_string
'i kullanabilirsiniz - UTF-8 ile kodlanmış olması bir zorunluluktur.
Bu arabellek kullanılmayan (buffering) "saf" bir okumadır. Arabellekli okuma için read_line
ve lines
döngüleyicisini sunan io::BufRead
özelliğini kullanabilirsiniz. io::BufReader
, io::BufRead
'ın kullanımlarını okunabilir herhangi bir şey için sunacaktır.
fs::File
ise aynı zamanda io::Write
'ı da barındırır.
Bütün bu özelliklerin kullanılabilir olduğunu görmenin en kolay yolu use std::io::prelude::*
kullanmaktır.
#![allow(unused)] fn main() { use std::fs::File; use std::io; use std::io::prelude::*; fn read_all_lines(filename: &str) -> io::Result<()> { let file = File::open(&filename)?; let reader = io::BufReader::new(file); for line in reader.lines() { let line = line?; println!("{}", line); } Ok(()) } }
let line = line?
gözünüze biraz tuhaf görünebilir. Döngüleyiciden dönen line
aslında io::Result<String>
tipidir ve ?
ile onu paketinden dışarı çıkartıyoruz. Bunu yapmamızın sebebi döngü esnasında bir şeylerin yanlış gidebileceğidir. - Girdi/çıktı hataları, UTF-8 olmayan bir bayt serisini almak gibi şeyler.
Bir döngüleyici olarak lines
'i collect
ile bir vektöre kolayca çevirebiliriz ya da enumerate
döngüleyicisi ile her satırın sırasını öğrenebiliriz.
Yine de bütün satırları okumak için en iyi tercih bu değildir çünkü her satır için yeni bir String
tahsis edilir. En iyi yöntem read_line
kullanmaktır, biraz daha acayip görünse de. Satırın bir satır sonu karakteri içerdiğine dikkat edin ki bunu trim_right
ile siliyoruz.
#![allow(unused)] fn main() { let mut reader = io::BufReader::new(file); let mut buf = String::new(); while reader.read_line(&mut buf)? > 0 { { let line = buf.trim_right(); println!("{}", line); } buf.clear(); } }
Sonuçta çok daha az tahsis etme işlemimiz oluyor çünkü bir karakter dizisini temizlemek onun tahsis edilmiş alanını boşaltmıyor, bu alanı işgal eden yeni tahsis işlemleriyle uğraşmıyoruz.
Bu arada ödünç alma işlemini bozmamak için blok kullandığımız durumlardan birisiyle karşı karşıyayız. line
, buf
tarafından ödünç alınıyor ve bu ödünç buf
'ı tekrardan düzenlemeden önce belleği terk etmelidir. Yine Rust bizi aptalca bir şey yapmaktan alıkoymaya çalışıyor, ara belleği boşalttıktan sonra line
'a ulaşmak gibi. (Ödünç alma mekanizması bazen sizi darlayabilir. Rust bu kodu inceleyecek ve line
'ın, buf.clear()
'dan sonra kullanılmadığını görecektir, bu "sözcüksel olmayan yaşam sürelerini (non-lexical lifetimes)" ayıklamasından kaynaklanır.
Bu pek şık görünmüyor. Belki size arabelleğe referanslar dönen bir döngüleyici veremem ama en azından döngüleyiciye benzeyen bir şeyler verebilirim.
Şimdi jenerik bir yapı tanımlayalım; R
ismindeki tip parametremiz Read
içeren her tipi kabul edebilir. Bu yapının içeriğinde okuyucu (reader) ve referansını alabileceğimiz bir arabelleğimiz (buffer, ya da kısaca buf) olacak.
#![allow(unused)] fn main() { // file5.rs use std::fs::File; use std::io; use std::io::prelude::*; struct Lines<R> { reader: io::BufReader<R>, buf: String } impl <R: Read> Lines<R> { fn new(r: R) -> Lines<R> { Lines{reader: io::BufReader::new(r), buf: String::new()} } ... } }
Şimdi next
metotunu kullanalım. Tıpkı bir döngüleyici gibi Option
dönecek, None
döndüğü zaman döngüleyici başa dönmüş olacak. İçerisinden dönen tip Result
olacak çünkü read_line
başarısız olabilir ve asla hataları görmezden gelmiyoruz. Eğer başarısız olursa hatayı Some<Result>
olarak dönebiliriz. Eğer dosyanın doğal sınırı olan "sıfır baytları" okunursa bu bir hata değildir, sadece None
'dur.
#![allow(unused)] fn main() { fn next<'a>(&'a mut self) -> Option<io::Result<&'a str>>{ self.buf.clear(); match self.reader.read_line(&mut self.buf) { Ok(nbytes) => if nbytes == 0 { None // no more lines! } else { let line = self.buf.trim_right(); Some(Ok(line)) }, Err(e) => Some(Err(e)) } } }
Şimdi, yaşam sürelerinin nasıl çalıştığına dikkat edin. Açık bir yaşam süresi belirtmemiz gerekti çünkü Rust hiçbir zaman karakter dizelerini yaşam sürelerini belirtmeden ödünç almaya izin vermeyecektir ve burada ödünç alınan karakter dizisinin yaşam süresini self
ile birlikte belirtiyoruz.
Bu yaşam süresiyle birlikte bu tanım Iterator
özelliği ile uyumsuz. Ancak uyumlu olsaydı sorunları kolayca görebilirdik, collect
'in karakter dizileririnden vektör yapmaya çalıştığını düşünün. Bu mümkün değil zira hepsi aynı değişebilir (mutable) karakter dizisini ödünç almış olurdu! (Eğer bütün dosyayı bir karakter dizesine çevirmek isteseydiniz karakter dizelerinin lines
metotu karakter dizeleri dönebilirdi çünkü hepsi esas karakter dizilerinden ödünç alınmıştır.)
Neticedeki döngü çok daha temizdir ve ara belleğe alma işlemi pek çok kullanıcı tarafından görünmez.
#![allow(unused)] fn main() { fn read_all_lines(filename: &str) -> io::Result<()> { let file = File::open(&filename)?; let mut lines = Lines::new(file); while let Some(line) = lines.next() { let line = line?; println!("{}", line); } Ok(()) } }
Hatta eşleştirme karakter dizisi dilimini dışarı çıkartacağından, döngüyü bu şekilde de yazabilirsiniz:
#![allow(unused)] fn main() { while let Some(Ok(line)) = lines.next() { println!("{}", line)?; } }
Bunu yapmak isteyebilirsiniz ancak muhtemel hataları halının altına süpürmüş olursunuz; döngü bir hata söz konusu olduğu zaman sessizce duracaktır. Daha da ötesi, Rust'ın UTF-8
'e çeviremediği ilk yerde duracaktır. Gündelik kodlar için kabul edilebilir ancak kodu yayına aldığınız zaman kötüdür.
Dosyalara Yazmak
Debug
'u kullanırken write!
ile tanışmıştık - Write
'ın kullanıldığı her yerde aynı zamanda write!
kullanabiliriz. print!
demenin farklı bir yoluna bakalım:
#![allow(unused)] fn main() { let mut stdout = io::stdout(); ... write!(stdout,"answer is {}\n", 42).expect("write failed"); }
Eğer bir hata söz konusu olabilirse bunu idare edebilirsiniz. Bu genellikle gerçekleşmez ancak yine de söz konusu olabilir. Eğer bir girdi/çıktı işlemi yapıyor Bu genellikle kabul edilebilir çünkü bir dosyayla oynaşıyorsanız ?
eklemeniz gereken bazı yerler olabilir.
Ancak bir fark var; print!
, stdout
'u her yazım için kitler. Çoğu zaman istediğiniz şey budur çünkü çoklu süreçlerin yazıldığı programlarda stdout
'u kitlemezseniz çıktınız tuhaf bir şekilde karmaşıklaşabilir. Ancak çok fazla metin yolluyorsanız write!
çok daha hızlı davranacaktır.
Çeşitli dosyalar için write!
kullanmamız gerekiyor. out
, write_out
'un sonunda düşürüldüğü zaman dosya kapanır ki bu hem istenen şeydir hem de önemlidir.
// file6.rs use std::fs::File; use std::io; use std::io::prelude::*; fn write_out(f: &str) -> io::Result<()> { let mut out = File::create(f)?; write!(out,"answer is {}\n", 42)?; Ok(()) } fn main() { write_out("test.txt").expect("write failed"); }
Eğer performansı önemsiyorsanız Rust'ın varsayılan olarak önbelleğe alınmadığını bilmeniz gerekir. Her bir yazma talebi doğrudan işletim sistemine gönderilir ki bu işleri oldukça yavaşlatır. Bundan bahsediyorum çünkü diğer programlama dillerinde varsayılan davranış farklıdır ve Rust'ın betik dilleri tarafından geride bırakıldığını görmek sizi epeyce şaşırtabilir! Nasıl ki Read
'ın io::BufReader
'ı varsa io::BufWriter
ın da Write
'ı var.
Dosyalar, Konumlar ve Dizinler
Şimdi makinedeki Cargo dizinini bulan bir program yazalım. En basit yöntem ~/.cargo
altına bakmaktır. Ancak bu Unix kabuğu için geçerlidir, çoklu ortam desteği için env::home_dir
fonksiyonunu kullanacağız. (Başarısız olabilir ancak ev dizini olmayan bir bilgisayar da Rust araçlarını barındırmaz.)
Ç.N: env::home_dir
fonksiyonu beklenildiği gibi çalışmadığı için 1.29.0
'dan itibaren tedavülden kaldırılmıştır. Bu fonksiyonu kullanmayın, Windows ve Unix ortamları için ev dizinlerini kendi yöntemlerinizle bulmanızı veya bir crates.io'yu karıştırmanızı tavsiye ederim.
Sonra bir PathBuf
yaratalım ve push
metotunu parçalardan tam bir dosya konumu inşa etmek için kullanalım. (Bu /
gibi bir şeyle ile debelenmekten çok daha kolaydır.)
// file7.rs use std::env; use std::path::PathBuf; fn main() { let home = env::home_dir().expect("no home!"); let mut path = PathBuf::new(); path.push(home); path.push(".cargo"); if path.is_dir() { println!("{}", path.display()); } }
PathBuf
, String
gibi çalışır - karakterlerin büyüyebilen bir paketidir ancak konum inşa etmek için kendi araçlarını kullanır. Ancak özelliklerinin çoğunluğu Path
'ın referans versiyonundan gelir, tıpkı &str
gibi. Yani, mesela, is_dir
bir Path
metotudur.
Bu kulağınıza şüphe uyandıran bir miras alma (inheritance) tarzı gibi gelebilir, ancak bu Deref özelliğinin maharetidir. Gözünüze tıpkı String/&str
gibi görünebilir - bir PathBuf
referansı bir Path
referansına dönüşür/zorlanır. (coerced) ("Zorlamak (Coerce)" kelimesi biraz ağır kaçmış olabilir ancak bu Rust sizin için dönüşüm uyguladığı nadir yerlerden birisidir.)
#![allow(unused)] fn main() { fn foo(p: &Path) {...} ... let path = PathBuf::from(home); foo(&path); }
PathBuf
'un en yakın arkadaşı OsString
'tir, kendisi sistemden doğrudan aldığımız karakter dizilerini gösterir. (Aynı şekilde buna karşılık OsString/&OsStr
ilişkisi de vardır.)
Bu tarz karakter dizilerinin UTF-8 olacağını garanti edilmemiştir! Gerçek hayatta her şey karmaşıktır, özellikle "Her şey neden bu kadar zor" diye düşünürken. Sadede gelelim. Birincisi antik ASCII kodlamanın ve diğer diller için özel kodlamanın kullanıldığı yıllar oldu. İkincisi kendi aramızda konuştuğumuz dillerin kendisi de epey karmaşıktır. Mesela "noël" kelimesi beş Unikod kodu kadar yer tutar.
Modern işletim sistemlerinin dosya adlarının çoğu zaman Unikod olabileceği doğrudur. (Unix tarafı için UTF-8, Windows için UTF-16) Ama olmadığı zamanlar da vardır! Ve Rust bu olasılığı dikkatlice ele almalıdır. Örneğin Path
, as_os_str
diye &OsStr
dönen bir metota sahiptir. Ancak to_str
, bazen Option<&str>
döner. Yani her zaman mümkün değildir!
İnsanlar genelde bu konuda biraz takılır çünkü "karakter" ve "karakter dizesi"ne fazlasıyla alıştılar. Einstein'ın dediği gibi programlama dilleri sade olmalı, basit değil. Bir sistem programlama dilinin String/&str
ayrımına ihtiyacı vardır (ödünç alınmışa karşılık sahiplenmiş: kafaya epeyce yatıyor) ve Unikod karakter dizilerini standartlaştırmak için Unikod olmayan stilleri de kapsamalıdırlar - işte OsString/&OsStr
kardeşlerin doğuşu. Bunların içeriğinde String
benzeri enteresan metotlar bulunmadığına dikkat edin, çünkü tiplemelerinden tam olarak emin değiliz.
Ancak, insanlar dosya isimlerini olağan karakter dizileriymiş gibi işlemeye alışkınlardır ki kolayca dosya konumlarını kullanmak ve değiştirmek için Rust'ta PathBuf
vardır.
Bir konumun adresinin parçalarını temizleyebilmek için pop
kullanabilirsiniz. Mesela bulunduğumuz dizinden başlayalım.
// file8.rs use std::env; fn main() { let mut path = env::current_dir().expect("can't access current dir"); loop { println!("{}", path.display()); if ! path.pop() { break; } } } // /home/steve/rust/gentle-intro/code // /home/steve/rust/gentle-intro // /home/steve/rust // /home/steve // /home // /
Bu da daha kullanışlı bir hâli. Bir konfigrasyon dosyasını aramak için bir program yazdık ve bütün altdizinleri bu dosya için arıyoruz. Bunun için /home/steve/rust/config.txt
diye bir dosya yazdık ve program /home/steve/rust/gentle-intro/code
içerisinden başlıyor:
// file9.rs use std::env; fn main() { let mut path = env::current_dir().expect("can't access current dir"); loop { path.push("config.txt"); if path.is_file() { println!("gotcha {}", path.display()); break; } else { path.pop(); } if !path.pop() { break; } } } // gotcha /home/steve/rust/config.txt
Bu git'in nasıl çalıştığı depoyu nasıl bulduğunun yöntemidir de aynı zamanda.
Bir dosya hakkındaki bilgiler metaveri (metadata) olarak geçer. Her zaman olduğu gibi bir hata olabilir - "Bulunamadı" dışında mesela dosyayı okumamız için gerekli izinlere sahip olamayabiliriz.
// file10.rs use std::env; use std::path::Path; fn main() { let file = env::args().skip(1).next().unwrap_or("file10.rs".to_string()); let path = Path::new(&file); match path.metadata() { Ok(data) => { println!("type {:?}", data.file_type()); println!("len {}", data.len()); println!("perm {:?}", data.permissions()); println!("modified {:?}", data.modified()); }, Err(e) => println!("error {:?}", e) } } // type FileType(FileType { mode: 33204 }) // len 488 // perm Permissions(FilePermissions { mode: 436 }) // modified Ok(SystemTime { tv_sec: 1483866529, tv_nsec: 600495644 })
Bir dosyanın (bayt cinsinden) uzunluğu ve son değiştirilme tarihini bulmak oldukça kolaydır. (Ancak bunu yapamayacağımız zamanlar da vardır.) File
tipinin ilgili metotları is_dir
(Ç.N: Dizin midir?), is_file
(Ç.N: Dosya mıdır?) ve is_symlink
'dir. (Ç.N: "Sembolik bağ mıdır?")
permissions
da ilginç bir metottur. Rust platformlar arası geçerli olmaya özen gösterir ve bu da bir "en düşük ortak paydaya erişme" durumudur. Genel olarak, dosya salt okunur ise onu sorgulayabilirsiniz - "yetkiler (permissions) konsepti Unix'te oldukça geniştir ve kullanıcı/grup/diğerleri için okuma/yazma/çalıştırma yetkilerini barındırır.
Ancak Windows ile çalışmıyorsanız en azından yetki bitlerini öğrenmek için platforma yönelik özellikleri (trait) kapsama çağırabilirsiniz. (Genelde olduğu gibi özellikler kapsama çağrıldığı zaman etkinleşirler.) Sonrasında da programın dosyasını şöyle değiştirebiliriz:
#![allow(unused)] fn main() { use std::os::unix::fs::PermissionsExt; ... println!("perm {:o}",data.permissions().mode()); // perm 755 }
("{:o}" sekizli (octal) sayı sistemine göre biçimlendirir.)
(Windows'ta bir dosyanın çalıştırılıp çalıştırılamayacağı dosyanın uzantısından tanımlanır. Çalıştırılabilir dosya uzantıları PATHEXT
çevre değişkeninden öğrenilebilir - ".exe", ".bat" vs.)
Dosyalarla çalışmak için std::fs
bize pek çok faydalı fonksiyonlar sunar, bir dosyayı kopyalayıp taşımak, sembolik bağ kurmak ve dizin oluşturmak gibi.
Bir dizinin içeriğini öğrenmek için bir döngüleyici sunan std::fs::read_dir
'i kullanabilirsiniz. İşte karşınızda boyutları 1024 bayttan büyük olan ve uzantısı .rs
ile biten bütün dosyalar!
#![allow(unused)] fn main() { fn dump_dir(dir: &str) -> io::Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let data = entry.metadata()?; let path = entry.path(); if data.is_file() { if let Some(ex) = path.extension() { if ex == "rs" && data.len() > 1024 { println!("{} length {}", path.display(),data.len()); } } } } Ok(()) } // ./enum4.rs length 2401 // ./struct7.rs length 1151 // ./sexpr.rs length 7483 // ./struct6.rs length 1359 // ./new-sexpr.rs length 7719 }
read_dir
'in başarısız olabileceği aşikar ("bulunamadı" ya da "yetki yok" gibi bir hata çıkarabilir) ancak aynı zamanda her yeni bir girdiye erişmek de başarısız olabilir. (Okunmuş veriyi arabelleğe alan lines
döngüleyicini düşünün.) Ek olarak, ilgili girdi için uygun metaveriye ulaşamayabiliriz de. Bir dosyanın uzantısı da pekala olmayabilir, bunu ayrıca kontrol etmemiz gerekir.
Neden konumların üzerinde bir döngüleyici kullanmıyoruz? Unix'te opendir
sistem çağrısı bu şekilde çalışır ancak Windows'ta dosyaların metaverisini almadan üzerinde bir döngü kuramazsınız. Dolayısıyla platformlar arasındaki kodun en verimli çalışmasının en zarif yoludur.
Bu noktada "hatalarla boğuştuğunuz" için kendinizi bitkin hissedebilirsiniz. Ancak hatalar her zaman vardı - Rust sizin için yeni hatalar icat etmedi. Sadece hataları görmezden gelmemeniz için elinden geleni yapıyor. Herhangi bir işletim sistemi çağrısı başarısız olabilir.
Java ve Python gibi dillerde hata atarsınız (throw exceptions); Go ve Lua gibi diller ise size iki veri döner ve birincisi sonuç ikincisi de hata olur, Rust'ta kitaplık fonksiyonlarının hata oluşturması kötü bir davranış olarak kabul edilir. Bu nedenle pek çok hata denetimi vardır ve fonksiyonlar erkenden dönebilir.
Ya hata alırsınız ya da almazsınız, Rust bu yüzden Result
kullanır: hem hata hem de sonuç elde edemezsiniz. Ve soru işareti operatörü kontrol oldukça kolaylaştırır.
Süreçler
Esas ihtiyaç duyduğumuz şeylerden bir şey programların başka bir programı çalıştırması ya da süreç başlatmaktır. Programınız pek çok alt süreç çalıştırabilir ve alt süreçler üst süreçlerle pek çok ilişkide bulunabilir.
Bir programı, argümanları da beraberinde kullanmanızı sağlayan Command
yapısı ile çalıştırabilirsiniz.
use std::process::Command; fn main() { let status = Command::new("rustc") .arg("-V") .status() .expect("no rustc?"); println!("cool {} code {}", status.success(), status.code().unwrap()); } // rustc 1.15.0-nightly (8f02c429a 2016-12-15) // cool true code 0
new
programın adını alır (Eğer kesin bir dosya konumu değilse PATH
içinde arar), arg
yeni argümanlar ekler ve status
programın çalışmasını tetikler. Program çalışınca bir Result
alırsınız, Ok
programın çalıştığını belirtir ki içeriğinde ExitStatus
bulunur. Bizim örneğimizde programımız başarıyla çalıştı ve çıkış değeri olarak 0 döndü. (unwrap
kullanmamızın sebebi eğer program bir sinyal ile "öldürülürse" her zaman çıkış değerini öğrenemeyecek olmamız.)
Eğer -V
'yi -v
ile değiştirirsek rustc
başarısız olur.
error: no input filename given
cool false code 101
Üç farklı koşul gerçekleşebilir:
- Program var olmayabilir, çalıştırma iznimiz olmayabilir ya da kullanılamaz hâlde olabilir
- Program çalışmış ancak başarısız olmamış olabilir - sıfır olmayan çıkış değeri
- Program sıfır olan çıkış değeri ile sonuçlanmış olabilir. Yani başarılıdır!
Varsayılan olarak programın standart çıktısı (stdout/Standart output) ve standart hata çıktısı (stderr/Standart Error) terminale yönlendirilir.
Bazen çıktıyı yakalamakla da ilgilenebiliriz ki output
metotu bu işe yarar.
// process2.rs use std::process::Command; fn main() { let output = Command::new("rustc") .arg("-V") .output() .expect("no rustc?"); if output.status.success() { println!("ok!"); } println!("len stdout {} stderr {}", output.stdout.len(), output.stderr.len()); } // ok! // len stdout 44 stderr 0
status
ile alt süreçler sonuçlanana dek programımız durduruluyordu ve üç şey alıyorduk - sonuç (Önceden olduğu gibi), stdout
'un ve stderr
'un içeriği.
Şimdi ise yakaladığımız çıktı Vec<u8>
içerisinde tutuluyor - sadece bayt olarak. İşletim sisteminden aldığımız şeylerin her zaman geçerli bir UTF-8 karakter dizisi olamayacağını hatırlayın. Aslında, bunun bir karakter dizisi bile olamayacağını bilmemiz gerekiyor - programlar tuhaf ikili veriler dönebilirler.
Eğer çıktının UTF-8 olacağından eminsek bu vektörü ya da baytları String::from_utf8
ile dönüştürebiliriz. Sonuç Result
dönecektir çünkü dönüşümün gerçekleşeceğinden emin değiliz. İşi biraz daha gevşekçe yapan başka bir fonksiyonumuz var, String::from_utf8_lossy
, bununla çeviriyi deneyebilir ve dönüştürülemeyen karakterlerin yerlerine � koyabilirsiniz.
Aşağıda kabukta programda çalıştıran kullanışlı bir fonksiyon görmektesiniz. Programımız stderr
ile stdout
'u birleştirmek için sıradan bir kabuk tekniği kullanıyor. Kabuğun ismi Windows'ta biraz farklı ancak diğerleriyle de sorunsuz çalışacaktır.
#![allow(unused)] fn main() { fn shell(cmd: &str) -> (String,bool) { let cmd = format!("{} 2>&1",cmd); let shell = if cfg!(windows) {"cmd.exe"} else {"/bin/sh"}; let flag = if cfg!(windows) {"/c"} else {"-c"}; let output = Command::new(shell) .arg(flag) .arg(&cmd) .output() .expect("no shell?"); ( String::from_utf8_lossy(&output.stdout).trim_right().to_string(), output.status.success() ) } fn shell_success(cmd: &str) -> Option<String> { let (output,success) = shell(cmd); if success {Some(output)} else {None} } }
Sağ taraftaki boşlukları biraz törpülüyorum ve böylece shell("which rustc")
dediğimiz zaman doğrudan konumu alabiliyoruz.
Process
ile çalıştırılmış bir programın current_dir
ile çalışma dizinini, env
ile çevre değişkenlerini belirleyerek çalışma şeklini kontrol edebilirsiniz.
Şimdiye kadar programımız alt süreçlerin tamamlanmasını bekledi. Eğer spawn
metotunu kullanırsanız program size hemen döner ve programın basitçe bitişini bekler ki bu esnada gidip başka şeyler yapabiliriz. Aşağıdaki örnek stdout
ve stderr
in de aynı zamanda susturulmasına da örnektir.
// process5.rs use std::process::{Command,Stdio}; fn main() { let mut child = Command::new("rustc") .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("no rustc?"); let res = child.wait(); println!("res {:?}", res); }
Varsayılan olarak alt süreç üst sürecin standart girdisini ve çıktısını "miras alır". Ancak bu örnekte alt sürecin çıktısını "hiçliğe" yönlendirmiş olduk. Unix kabuğunda > /dev/null 2> /dev/null
demekle aynı şey yani.
Rustta yaptığımız bu şeyleri sistem kabuğu (sh
veya cmd
) ile de yapabilirdik. Ancak bu yolla tamamen programatik bir şekilde süreç oluşturmayı kontrol etmiş oldunuz.
Bu örnekte eğer sadece .stdout(Stdio::piped())
kullanmış olsaydık alt sürecin çıktısını bir izole etmiş olurduk. Sonra da child.stdout
üzerinden izole ettiğimiz çıktıyı doğrudan okuyabilirdik. (Read
özelliğini kullanıyor). Aynı şekilde doğrudan child.stdin
'e yazabilmek için de .stdout(Stdio::piped())
kullanabilirsiniz.
Ancak wait
yerine wait_with_output
'u kullansaydık bize Result<Output>
dönerdi ve alt sürecin çıktısı Output
'un stdout
alanında daha önce olduğu gibi Vec<u8>
olarak sunulurdu.
Child
yapısında aynı zamanda aleni bir şekilde kill
(öldür) metotu da bulunur.
Ç.N: Alt süreç İngilizce'de "çocuk süreç" anlamına gelen "Child process" olarak bahsedilir.
Modüller ve Cargo
Modüller
Programlar büyüdükçe onları bir dosyanın dışına taşımak ve fonksiyonlarla tipleri farklı isim alanlarına (namespace) taşımak gereklidir. Rust'ın bu iki şeye çözümü modüllerdir. (modules)
C ile başladı ama C ile bitmedi, bir süre sonra kendinizi primitive_display_set_width
gibi rezilce isimler koyarken bulabilirsiniz. Sadece dosya isimlerini keyfinizce isimlendirebiliyorsunuz.
Rust'ta aynı şeyi primitive::display::set_width
şeklinde isimlendirebiliyorsunuz. Üstelik use primitive::display
kullandıktan sonra bunu kısaca display::set_width
olarak çağırabilirsiniz. Hatta use primitive::display::set_width
dedikten sonra onu doğrudan set_width
diye çağırabilirsiniz fakat nasıl kullandığınıza dikkat etmelisiniz. rustc
tarafında sorun olmaz ancak sizin kafanız karışabilir. Ancak, bu sistemin çalışabilmesi için dosya isimlerinin basit bir kaç kurala bakması gereklidir.
Yeni bir anahtar kelimemiz var, mod
, bir bloğu içine yazılan tip ve fonksiyonlarla beraber topyekûn modül olarak ilan etmeye yarar.
mod foo { #[derive(Debug)] struct Foo { s: &'static str } } fn main() { let f = foo::Foo{s: "hello"}; println!("{:?}", f); }
Ancak bu çalışmayacaktır - "Foo yapısı gizlidir (struct Foo is private)" diye bir hata alacağız. Bunu çözmek için pub
anahtar kelimesi aracılığıyla Foo
'yu görünür kılmalıyız. Sonra bu hata "foo::Foo yapısının s alanı gizlidir (field s of struct foo::Foo is private)" olacaktır, pub
anahtar kelimesini alanın başına eklemeliyiz ki Foo::s
de görünür olsun. Sonra güzelinden çalışacaktır.
#![allow(unused)] fn main() { pub struct Foo { pub s: &'static str } }
Bir alanı açıkça pub
olarak belirmek bir modulün içerisinden neyin ulaşılabilir olduğunu seçmek demektir. Bir modülün içerisindeki erişebilen tiplere ve fonksiyonlara modulün arayüzü (interface) denir.
Bir yapının içindekileri gizlemek ve erişimi metotlarla sağlamak çoğunlukla doğru bir tercihtir.
mod foo { #[derive(Debug)] pub struct Foo { s: &'static str } impl Foo { pub fn new(s: &'static str) -> Foo { Foo{s: s} } } } fn main() { let f = foo::Foo::new("hello"); println!("{:?}", f); }
Neden bu yapıları gizlemek daha iyidir? Çünkü arayüzün canına okumadan ve modüle erişen diğer parçaların ayrıntılarıyla boğuşmadan onu değişebilirsiniz. Geniş ölçekli bir programın en büyük belası kodun birbirine girmeye olan meyilidir ki kodun gerekli parçasını izole etmeyi imkansız hâle getirir bu.
Cesur yeni dünyada modüller tek bir şeyi yapar ve kendi sırlarını kendilerine saklarlar.
Peki ne zaman gizlememeliyiz? Stroustrup'ın dediği gibi arayüzün kendisi kullanıldığı zaman, mesela struct Point{x: f32, y: f32}
.
Bir modülün içinde bütün nesneler birbirine görünürler. Burada herkesin birbirini tanıdığı ve sırlarını bildiği bir mahalle yaşantısı vardır.
Herkesin programı çeşitli dosyalara ayırmaya başladığı bir sınırı vardır. Ben mesela 500 satıra geldiğimde bunu düşünmeye başlıyorum ancak hepimiz 2000 satırdan sonra sıkılırız.
Peki ya bir programı çeşitli dosyalara nasıl ayırırız?
foo
kodunu foo.rs
içine koyalım.
#![allow(unused)] fn main() { // foo.rs #[derive(Debug)] pub struct Foo { s: &'static str } impl Foo { pub fn new(s: &'static str) -> Foo { Foo{s: s} } } }
Ve sonra da ana dosyada mod foo
deyimini bir blok olmadan kullanalım.
// mod3.rs mod foo; fn main() { let f = foo::Foo::new("hello"); println!("{:?}", f); }
Şimdi rustc mod3.rs
komutu herhangi bir hata olmadan derlenecektir. "Makefile"lar ile boğuşmaya hiç ihtiyacımız yok!
Ç.N: Makefile çoğunlukla C ve C++ ile kullanılan ancak Crystal, Go gibi yüksek seviye dillerde bile tercih edilen bir dosya. İşlevi birden çok kod dosyasını bir araya getirmek, onu yönetmektir.
Derleyici aynı zamanda MODULADI/mod.rs
içine de bakacaktır, mesela ben boo
isminde bir dizin açıp içerisine mod.rs
diye bir dosya yerleştirebilirim:
#![allow(unused)] fn main() { // boo/mod.rs pub fn answer()->u32 { 42 } }
Ana dosya bunu farklı bir dosyadaki farklı bir modül olarak tanımlayacaktır:
// mod3.rs mod foo; mod boo; fn main() { let f = foo::Foo::new("hello"); let res = boo::answer(); println!("{:?} {}", f,res); }
Şu ana kadar içinde main
fonksiyonu olan bir mod3.rs
dosyamızla beraber boo/mod.rs
dosyamız da vardır ki diğer modüller bunu boo
olarak görür. Genel alışkanlık, main
fonksiyonunu barındıran dosyanın adını main.rs
yapmaktır.
Neden bir şeyi yapmanın iki farklı yolu var? Çünkü boo/mod.rs
aracılığıyla boo
içerisinde yeni modüller tanımlayabilirsiniz. boo/mod.rs
'yi değiştirelim ve yeni bir modül ekleyelim - bunu dışarıdan erişilebilir olmasına dikkat edin. (pub
olmazsa bar
a sadece boo
modülü içerisinden erişebilirsiniz.)
#![allow(unused)] fn main() { // boo/mod.rs pub fn answer()->u32 { 42 } pub mod bar { pub fn question() -> &'static str { "the meaning of everything" } } }
Ç.N: Question: Soru, Answer: Cevap
Şimdi, cevabımızı anlamlandıracak bir sorumuz var. (bar
modülü, boo
'nun içindedir.)
#![allow(unused)] fn main() { let q = boo::bar::question(); }
Dilersek modül bloğunu boo/bar.rs
altına taşıyabiliriz.
#![allow(unused)] fn main() { // boo/bar.rs pub fn question() -> &'static str { "the meaning of everything" } }
Ve boo/mod.rs
şuna dönüşür:
#![allow(unused)] fn main() { // boo/mod.rs pub fn answer()->u32 { 42 } pub mod bar; }
Sonuç olarak modüller organizasyon ve erişilebilirlikle alakalı ve tercihen başka dosyalara erişebilir.
Lütfen use
'ın herhangi bir içe aktarma işleminde kullanılmadığını ve kısayol oluşturduğumuza not edin. Örneğin:
#![allow(unused)] fn main() { { use boo::bar; let q = bar::question(); ... } { use boo::bar::question(); let q = question(); ... } }
Bir başka önemli nokta ise Rust'ta parçalı derleme işlemlerinin bulunmamasıdır. Ana program ve onun modül dosyaları sil baştan yeniden derlenir. Büyük programların derlenme süresini kayda değer bir sürede uzatır, rustc
zaman içerisinde artımlı derlemede iyileşmesine rağmen.
Sandıklar
Ç.N: Crate kelimesinin yaygınlığından ötürü "sandık" ya da "crate" arasında aklımda uzunca bir süre düşündüm. Çünkü bu genel programlamaya ait bir kelime değil, Rust terminolojisinin bir parçası ki bu da onu çevrilmemesi gereken bir özel isim yapar. Ancak "Sandık" kelimesi gerçekten mantığa uygun ve "Sandık" olarak düşünmenin hiçbir zararı yok. "Crate" diyerek geçseydim, İngilizce bilmeyen kişiler için bunu salt ezberlenmesi gereken, anlamsız bir kelimeye dönüştürürdüm.
"Her bir derleme parçasına" sandık (crate) denir ki bu bir kütüphane veyahut çalıştırılabilir bir dosya olabilir.
Geçen bölümdeki dosyaları hep birlikte değil de ayrıca derlemek için, önce foo.rs
'ı bir statik kütüphane sandığına çevirelim.
src$ rustc foo.rs --crate-type=lib
src$ ls -l libfoo.rlib
-rw-rw-r-- 1 steve steve 7888 Jan 5 13:35 libfoo.rlib
Şimdi bunu bizim ana programımıza ilişkilendirebiliriz. (linking)
src$ rustc mod4.rs --extern foo=libfoo.rlib
Ana programımızın bu yeni yapıya uyum sağlaması gerekmektedir, extern
(Dışsal) ile kullandığımız isim ilişkilendirdiğimiz zaman kullandığımız isimle aynı olmalıdır. Yeni kütüphanemiz artık foo
modülü aracılığıyla görünür olacaktır:
// mod4.rs extern crate foo; fn main() { let f = foo::Foo::new("hello"); println!("{:?}", f); }
İnsanlar "Cargo! Cargo!" diye zikre başlamadan önce Rust'ın inşa araçlarını neden bu kadar düşük seviyeden gösterdiğini anlatmam için bana izin verin. Ben "Aletlerin Fıkhı"na dahilim ve bunları bilmek sizin Cargo ile yeni projeler yönetirken daha az "sinir"le karşılaşmanızı sağlar. Modüller basit dil işlevleridir ve Cargo olmadan da kullabilirler.
Şimdi, Rust'ın çalıştırılabilir dosyaları neden bu kadar büyük onu anlayalım:
src$ ls -lh mod4
-rwxrwxr-x 1 steve steve 3,4M Jan 5 13:39 mod4
Yarım dünya olmuş! Aslında bu çalıştırılabilir dosyada pek çok hata ayıklama bilgisi bulunur. Eğer niyetiniz bir hata ayıklayıcı kullanmaksa ve program paniklediği zaman anlamlı geri dönüşler almak istiyorsanız bu kötü bir şey değildir.
Hata ayıklama bilgisini silelim ve bir de böyle bakalım:
src$ strip mod4
src$ ls -lh mod4
-rwxrwxr-x 1 steve steve 300K Jan 5 13:49 mod4
Yine de basit bir şey için büyük bir dosya olduğunu düşünebilirsiniz ancak bu program Rust'ın standart kütüphanesine statik linklenmiştir. Bu iyi bir şey, bu programı doğru işletim sistemini kullanan herkesle paylaşabilirsiniz - Rust ile ilişkili hiçbir araca ihtiyaçları yoktur. (rustup
sayesinde farklı işletim sistemlerine ve platformlara derleyebilirsiniz.)
Rust'ın kütüphanelerine dinamik linkleyebiliriz ki bu koşulda gerçekten küçük çalıştırılabilir dosyalar elde ederiz.
src$ rustc -C prefer-dynamic mod4.rs --extern foo=libfoo.rlib
src$ ls -lh mod4
-rwxrwxr-x 1 steve steve 14K Jan 5 13:53 mod4
src$ ldd mod4
linux-vdso.so.1 => (0x00007fffa8746000)
libstd-b4054fae3db32020.so => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3cd47aa000)
/lib64/ld-linux-x86-64.so.2 (0x00007f3cd4d72000)
"not found (bulunamadı)" çıktısının sebebi rustup
'ın dinamik kütüphanelerinin sistem çapında kurulamamış olması. En azından Unix'te şöyle bir şey yapabiliriz. (Sembolik bağların en iyi çözüm olduğunu ben de biliyorum.)
src$ export LD_LIBRARY_PATH=~/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib
src$ ./mod4
Foo { s: "hello" }
Teorik olarak Rust'ın dinamik linklemeyle ilgili herhangi bir sorunu yok, tıpkı Go gibi. Sadece her altı haftada yeni bir stabil sürüm yayınlandığı için her şeyi tekrar tekrar derlemek biraz tuhaf kaçacaktır. Eğer her şeyin sizin için uygun olacağı bir stabil sürüm bulursanız, bunda sorun olmayacaktır. İşletim sistemlerinin paket yöneticileri Rust'ın standart kütüphanelerini sunmaya başlayacağı zaman dinamik linkleme daha popüler olacaktır.
Cargo
Java veya Python ile kıyaslarsanız Rust'ın standart kütüphanesi o kadar da büyük değildir, tabii yine de çoğu şeyini işletim sistemi kütüphanelerinden alan C ve C++'dan daha fazla şey bulursunuz.
Bu durumu telafi etmek için Cargo aracılığı ile crates.io'da yayınlanan topluluk kütüphanelerine ulaşabilirsiniz. Cargo sizin için doğru sürümü arayacak, kaynağı indirecek ve diğer bağımlılıkların kurulduğunu da kontrol edecektir.
JSON okuyan basit bir program yapalım. Bu veri formatı yaygın olarak kullanılır ancak standart kütüphaneye eklenemeyecek kadar da karmaşıktır. Bundan ötürü yeni bir Cargo projesi açıyoruz, "--bin" de ekliyoruz ki çalıştırılabilir bir proje yapalım yoksa kütüphane projesi hazırlar.
Ç.N: Hayır hazırlamaz. Çevrilen belgenin eskiliğinden dolayı böyle bahsetmiş. Varsayılan davranış çalıştırılabilir proje hazırlamaktır, kütüphanesi projesi başlatmak için --lib
kullanmanız gerekir. Yine de --bin
kullanabilirsiniz ancak buna gerek yoktur. Bu arada bahsi geçen JSON sandığı bu yazının yazıldığı tarihe (8 şubat 2022) iki yıldır güncellenmemiş görünmektedir. Rust'ta kullanılan esas JSON çözümü serde_json sandığıdır. Yazının devamını okuyabilirsiniz çünkü serde_json
ve json
sandıkları arasında pratikte pek fark yoktur. Kaldı ki bu bölümün ardından yazar serde_json
'u inceliyor.
test$ cargo init --bin test-json
Created binary (application) project
test$ cd test-json
test$ cat Cargo.toml
[package]
name = "test-json"
version = "0.1.0"
authors = ["Your Name <you@example.org>"]
[dependencies]
JSON sandığını kullanan bir proje yapmak için "Cargo.toml" dosyasını düzenleyin:
[dependencies]
json="0.11.4"
Sonra Cargo ile ilk derlememizi yapalım:
test-json$ cargo build
Updating registry `https://github.com/rust-lang/crates.io-index`
Downloading json v0.11.4
Compiling json v0.11.4
Compiling test-json v0.1.0 (file:///home/steve/c/rust/test/test-json)
Finished debug [unoptimized + debuginfo] target(s) in 1.75 secs
Programımızın main
dosyası hâlihazırda oluşturdu - "src" dizinindeki "main.rs" dosyasıdır. Şimdilik henüz "hello world" çıktısı vermekten başka bir şeye yaramıyor, hadi onu doğru düzgün bir test programına çevirelim.
"raw" karakter dizesinin nasıl kullanıldığına da dikkat edin - eğer kullanmasaydık kaçış dizelerini kullanmamız gerekirdi ki bu bayağı bir çirkinliğe sebep olurdu:
// test-json/src/main.rs extern crate json; fn main() { let doc = json::parse(r#" { "code": 200, "success": true, "payload": { "features": [ "awesome", "easyAPI", "lowLearningCurve" ] } } "#).expect("parse failed"); println!("debug {:?}", doc); println!("display {}", doc); }
Ç.N: Cargo aracılığıyla kurduğunuz sandıkların "extern crate sandık_adı" şeklinde çağrılmasına gerek yoktur.
Şimdi projeyi inşa edip çalıştırabilir - sadece main.rs
değişti.
test-json$ cargo run
Compiling test-json v0.1.0 (file:///home/steve/c/rust/test/test-json)
Finished debug [unoptimized + debuginfo] target(s) in 0.21 secs
Running `target/debug/test-json`
debug Object(Object { store: [("code", Number(Number { category: 1, exponent: 0, mantissa: 200 }),
0, 1), ("success", Boolean(true), 0, 2), ("payload", Object(Object { store: [("features",
Array([Short("awesome"), Short("easyAPI"), Short("lowLearningCurve")]), 0, 0)] }), 0, 0)] })
display {"code":200,"success":true,"payload":{"features":["awesome","easyAPI","lowLearningCurve"]}}
Hata ayıklama çıktısı JSON belgesi hakkında bazı iç detayları sundu ancak Display
özelliğini kullanan sade {}
bizim için taranmış JSON'u döner.
Şimdi JSON API'sini keşfe çıkalım. Eğer verileri dışarı çıkartamasaydık bunun pek anlamı olmazdı. as_TİP
metotu bize Option<TİP>
döner, bunun nedeni belirtilen alanın varlığı kesin değildir ve doğru tipe çevirmeyebiliriz.
#![allow(unused)] fn main() { let code = doc["code"].as_u32().unwrap_or(0); let success = doc["success"].as_bool().unwrap_or(false); assert_eq!(code, 200); assert_eq!(success, true); let features = &doc["payload"]["features"]; for v in features.members() { println!("{}", v.as_str().unwrap()); // MIGHT explode } // awesome // easyAPI // lowLearningCurve }
features
, JsonValue
tipine bir referanstır - referans olması gerekir çünkü veriyi JSON dökümanı dışına taşımış oluruz. Ayrıca bir tür dizi olduğunu bildiğimiz için members()
bize &JsonValue
üzerinde çalışan dolu bir döngüleyici dönecektir.
Ya eğer "payload"'ın "features" diye bir anahtarı olmasaydı? O zaman features
bir Null
olurdu, elimizde patlamazdı. Bu yaklaşım biraz serbestlik tanıyor ki bu JSON'un gevşek doğasına da pekâlâ uyuyor. Belgenin yapısını incelemek ve yapı uyuşmazsa hataları idare etmek size kalmış.
Bu yapıları düzenleyebilirsiniz. Eğer let mut doc
diye tanımlasaydık pekâlâ bunu yapabilirdik:
#![allow(unused)] fn main() { let features = &mut doc["payload"]["features"]; features.push("cargo!").expect("couldn't push"); }
push
başarısız olabilir çünkü features
bir dizi olmayabilir, bundan ötürü Result<()>
döner.
JSON kalıplarını kullanmak için gerçekten şık bir makromuz da var:
#![allow(unused)] fn main() { let data = object!{ "name" => "John Doe", "age" => 30, "numbers" => array![10,53,553] }; assert_eq!( data.dump(), r#"{"name":"John Doe","age":30,"numbers":[10,53,553]}"# ); }
Bunun çalışabilmesi için makroları JSON sandığından açıkça içe aktarmanız gerekir:
#![allow(unused)] fn main() { #[macro_use] extern crate json; }
Bu sandığın kötü bir tarafı da var, o da JSON'un dengesiz ve dinamik tipli doğasını Rust'ın statik ve yapılandırmış doğası arasında uyumsuzluğu dengeleyememiş olması. ("Readme" dosyasında bu sürtüşmeden ("friction") söz eder.) Eğer JSON'u Rust'ın veri yapılarına çevirmek isterseniz en sonunda pek çok düzenleme yapmanız gerekir çünkü elde ettiğiniz veriyi yapılarınıza uyacağınızdan emin olamazsınız! Bunun üstesinden gelebilmek için serde_json kullanabilirsiniz, bununla Rust veri yapılarınızı JSON'a serileştirebilir/çevirebilir (serialize) ya da JSON'dan Rust'a serisizleştirebilirsiniz/geri çevirebilirsiniz. (deserialize)
Ç.N: "Muvaffakiyetsizleştiricileştiriveremeyebileceklerimizdenmişsinizcesine" gibi bir karmaşa yarattığımın farkındayım. Bundan dolayı bazı yerlerde serialize ve deserialize için çevirmek karşılığını kullanacağım.
Bunun için cargo new --bin test-serde-json
ile çalıştırılabilir bir Cargo projesi başlatalım ve test-serde-json
dizinine girip Cargo.toml'u
değiştirelim. Buna benzer bir şey yapabiliriz:
[dependencies]
serde="0.9"
serde_derive="0.9"
serde_json="0.9"
src/main.rs
'yi şu şekilde dolduralım:
#[macro_use] extern crate serde_derive; extern crate serde_json; #[derive(Serialize, Deserialize, Debug)] struct Person { name: String, age: u8, address: Address, phones: Vec<String>, } #[derive(Serialize, Deserialize, Debug)] struct Address { street: String, city: String, } fn main() { let data = r#" { "name": "John Doe", "age": 43, "address": {"street": "main", "city":"Downtown"}, "phones":["27726550023"] } "#; let p: Person = serde_json::from_str(data).expect("deserialize error"); println!("Please call {} at the number {}", p.name, p.phones[0]); println!("{:#?}",p); }
derive
özelliğini daha çok görünüz ancak serde_derive
sandığı içerisinde Serialize
ve Deserialize
gibi önemli özellikleri içeren derive
lar bulunmaktadır. Sonuç, oluşturulan Rust yapısını gösterecektir:
Please call John Doe at the number 27726550023
Person {
name: "John Doe",
age: 43,
address: Address {
street: "main",
city: "Downtown"
},
phones: [
"27726550023"
]
}
Eğer bunu json
sandığı ile yapsaydınız pek çok satırda çoğu hata yönetimiyle ilişkili birkaç yüz tanecik çeviri kodu yazmanız gerekecekti. Bunaltıcı, batırması bir hataya bakar ve bunun için oturup uğraşmak anlamsız.
Eğer dışarıdan gelen iyi yapılandırılmış JSON'u işleyecekseniz (gerekirse alanları yeniden isimlendirebilirsiniz) ve başka programlarla ağ üzerinden veri paylaşacaksanız (Şu günlerde JSON'u herkes anlıyor) bu bariz en iyi çözümdür. serde
("SERialization DEserialization") hakkındaki ilgi çeken bir nokta diğer dosya türlerini de desteklemesidir, mesela Cargo'da kullanılan yapılandırması kolay ve popüler toml
da bunlardan birisidir. Eğer programınızın .toml dosyalarını yapılara çevirmesi gerekiyorsa bu tıpkı .json dosyalarında yaptığınız gibi bu yapıları hazırlayabilirsiniz.
Serileştirme, Java ve Go'da da benzerleri bulunan önemli bir tekniktir - ancak büyük bir fark vardır. Bu dillerde verinin yapısı çalışma zamanında yansıma (reflection) kullanılarak bulunur. Ancak bu koşulda serileştirme işlemi çalışma zamanında oluşturulur - bu çok daha verimlidir.
Cargo, Rust ekosisteminin ağır toplarından birisidir çünkü bizim için çok fazla işi halleder. Eğer o olmasaydı Github'dan tek tek kütüphaneleri indirmek, statik kütüphaneler olarak inşa etmek ve programa ilişkilendirmemiz gerekirdi. Bunu C++ projelerinde yapmak çiledir ve aynı çile Cargo olmasaydı Rust projelerinde de olacaktır. C++'ın çektirdiği çilenin eşi benzeri de yoktur bu arada, o yüzden diğer dillerin paket yöneticileriyle kıyaslamamız daha doğru olacaktır. (JavaScript için) npm, (Python için) pip sizin için bağımlılıkları kontrol eder ve indirir ancak programı dağıtmak zordur çünkü kullanıcının da sizin yerine NodeJS ve Python kurmuş olması gerekir. Ancak Rust programın bağımlılıkları statik linklenmiştir, ek bir bağımlılık gerekmeksizin istediğiniz kişiye yollayabilirsiniz.
Vali Kebabı
Basit bir yazının dışında herhangi bir veriyi işleme alacaksanız düzenli ifadeler (regular expressions) hayatınızı kolaylaştıracaktır. Bunlar pek çok dilde vardır ve sizin temel regex kalıplarına aşina olduğunuzu varsayıyorum. regex sandığını kullanmak için Cargo.toml dosyasına "[dependencies]" altına 'regex = "0.2.1"' ifadesini koymanız yererlidir.
Terk eğik çizgilerin özel anlamlar yaratmaması için "çiğ/raw karakter dizilerini" kullanacağım. İnsanın anlayacağı şekilde anlatırsak aşağıdaki düzenli ifade " ':' karakterinden önce iki rakam, sonrasında da herhangi uzunluktaki bir rakamı alın" anlamına gelmektedir:
#![allow(unused)] fn main() { extern crate regex; use regex::Regex; let re = Regex::new(r"(\d{2}):(\d+)").unwrap(); println!("{:?}", re.captures(" 10:230")); println!("{:?}", re.captures("[22:2]")); println!("{:?}", re.captures("10:x23")); // Some(Captures({0: Some("10:230"), 1: Some("10"), 2: Some("230")})) // Some(Captures({0: Some("22:2"), 1: Some("22"), 2: Some("2")})) // None }
Başarılı bir çıktı üç parçadan oluşur - bütün eşleşme ile iki parça olarak sayılar. Düzenli ifadeler genel varsayılan olarak mıhlanmamıştır (anchored), yani regex ifademiz ilk eşleşmeyi arayacak ve gerisine bakmayacaktır. (Eğer sadece "()" olarak bir düzenli ifade yazarsanız her şeyle eşleşecektir.)
Bu eşleşmeleri isimlendirebiliriz ve düzenli ifadeleri birden fazla satır hâlinde yazabiliriz, satırları da içine alacak şekilde. Mesela burada sonucu ilişkisel bir dizi olarak kullanabiliriz ve eşleşmeleri isme göre arayabiliriz.
#![allow(unused)] fn main() { let re = Regex::new(r"(?x) (?P<year>\d{4}) # the year - (?P<month>\d{2}) # the month - (?P<day>\d{2}) # the day ").expect("bad regex"); let caps = re.captures("2010-03-14").expect("match failed"); assert_eq!("2010", &caps["year"]); assert_eq!("03", &caps["month"]); assert_eq!("14", &caps["day"]); }
Düzenli ifadeler karakter dizilerini örüntü eşleştirmelere göre bölebilir ancak ne anlama geldiğini anlayamaz. Mesela ISO-tarzı bir sözdizimini belirtip eşleşeyebilirsiniz, ancak anlamsal olarak saçma sapan şeylere işaret edebilirler. Mesela ki "2014-24-52".
Bu koşulda ayrıca tarih-zaman işlemeye ihtiyacınız olabilir, bu bize chrono tarafından sunulur. Tarihleri üretirken zaman dilimini belirtmeniz de gerekebilir:
extern crate chrono; use chrono::*; fn main() { let date = Local.ymd(2010,3,14); println!("date was {}", date); } // date was 2010-03-14+02:00
Ancak bu tarz kullanımda kötü tarihler şuna sebep olabilir: panik! (Mesela sahte bir tarih kullanmayı deneyebilirsiniz.) Esas ihtiyacınız olan metot ymd_opt
'dir ki size LocalResult<Date>
döner.
#![allow(unused)] fn main() { let date = Local.ymd_opt(2010,3,14); println!("date was {:?}", date); // date was Single(2010-03-14+02:00) let date = Local.ymd_opt(2014,24,52); println!("date was {:?}", date); // date was None }
Doğrudan tarihi ve zamanı tarayabilirsiniz, standart UTC biçiminde ya da farklı biçimler'da olabilir. Bu hemen hemen aynı formatlar istediğiniz tarzda biçimlendirmenize yardımcı olur.
Bu iki sandıktan özellikle bahsettiğm çünkü normalde bunlar diğer dillerde standart kütüphanenin birer parçasıdır. Ve üstelik bu kütüphanelerin çok çok ilkel bir tipi aslında Rust'ın standart kütüphanesinin parçasıydı, ancak ayrıştırıldı. Bu bilinçli bir karardı, Rust takımı standart kütüphanenin kararlılığını oldukça ciddiye alıyor ve ekleyecekleri yeni özellikleri önce kararsız "nightly" yayınında sonra yarı kararlı "beta" yayınında test ederler ve en sonunda "stable" yayınına alırlar. Pek çok deneyim ve iyileştirme gereken kütüphaneleri bağımsız olarak geliştirmek ve Cargo ile erişilir kılmak çok daha iyidir. Sonuca bakarsak, bu iki sandık standarttır - tekrardan standart kütüphaneye alınmayacaktır ve ortadan kaybolmayacaklardır.
Standart Kütüphane Konteynırları
Belgeleri Anlamak
Bu kısımda kabaca size Rust'ın standart kütüphanesinin bilindik bazı kısımlarını tanıtacağım. Belgelendirme gayet iyi ancak bağlamı tanıtmak ve biraz örneğin kimseye zararı olmaz.
Hepsinden önce Rust belgelerini okumak biraz yorucu gelebilir, bundan dolayı bir örneği inceleyeceğiz ki bu örnek Vec
olacak. Kullanışlı bir tavsiye verelim, "[-]" belgeleri açıp kapamaya yarar. (Eğer rustup component add rust-src
ile belgeleri indirmişseniz yanında bir de "[src]" bağlantısını göreceksiniz. Metotların bir krokisine buradan ulaşabilirsiniz.
Dikkat etmeniz gereken ilk detay, bütün ilişkili metotların Vec
'in kendisinde tanımlanmadığıdır. Bunlar (çoğunlukla) push
gibi vektörü değiştiren metotlardır. Bazı metotlar ise sadece vektörlerin içinde tuttuğu tiplere göre değişkenlik gösterir. Mesela, dedup
'ı (kopyaları kaldır) sadece eşitliği denetlenebilir tipler üzerinde çalışır. Vec
tipinde kullanılan birden fazla impl
bloğu vardır ki bunlar içinde bulunduğu tiplerin çeşitliliğine göre şekillenmiştir.
Vec<T>
ile &[T]
arasında da özel bir ilişki olduğunu biliyoruz. Dilimler üzerinde çlışan her bir metot vektörler üzerinde doğrudan çalışacaktır, fazladan as_slice
gibi metotlar kullanmanıza hiç gerek yoktur. Bu ilişki Deref<Target=[T]>
ile gösterilir. Ayrıca bir vektörü referans olarak göstermek onu bir dilime çevirir - tip dönüşümlerinin nadiren gerçekleştiği nadir yerlerden birisidir. İlk öğeyi geri dönen first
gibi dilim metotları, ya da bunun tersini yapan last
, vektörler için de kullanılabilir. Metotların pek ciddi bir kısmı karakter dizilerini çağrıştırabilir, mesela split_at
dilimi belirli bir indekse göre ayırır, starts_with
bir vektörün belirli bir veri silsilesi ile başlayıp başlamadığını belirtir, contains
bir vektörün belirli bir veriyi içerip içermediğini belirtir.
Belirli bir verinin indeksini bulmak için Rust'ta search
metotu yoktur. Şimdi size size esas olayı anlatayım; eğer konteynırda metotu bulamazsanız, döngüleyici metotlarına bakın:
#![allow(unused)] fn main() { let v = vec![10,20,30,40,50]; assert_eq!(v.iter().position(|&i| i == 30).unwrap(), 2); }
( &
kullanmamızın sebebi döngüleyicinin referanslar üzerinde çalışmasıdır - alternatif olarak kıyaslamak için *i == 30
kullanabilirsiniz.)
Benzer şekilde vektörler üzerinde map
metotu yoktur çünkü iter().map(...).collect
ile aynı pekâlâ işi yapabilirsiniz. Rust, gerekmedikçe bellek tahsis etmeyi sevmez - çoğu zaman hâlihazırda bellekte yer tutan map
'ın bütün sonuçlarına ihtiyacınız olmaz.
Döngüleyici (iterator
) metotlarına aşina olmanızı tavsiye ederim çünkü iç içe girmiş döngülerle boğuşmadığınız iyi bir Rust kodu yazmak için elzemdirler. Her zaman olduğu gibi, büyük bir program yazarken bir anda onlarla güreşmek yerine döngüleyici metotlarını keşfetmek için minik programlar yazın.
Vec<T>
ve &[T]
metotları birbirleriyle ortak özellikleri (trait) paylaşırlar: vektörler kendi hata ayıklama bilgilerinin nasıl gösteirlebilirler. (Eğer bütün öğeler Debug
özelliğine sahipse.) Aynı şekilde, eğer bütün öğeleri klonlanabilirlerse kendileri de klonlanabilirler. Drop
özelliğine sahiptirler, bir vektör düşürüldüğü zaman bellekteki yerleri boşaltılır ve tek tek bütün öğeleri de düşürülür.
Extend
özelliği döngüleyicilerdeki değerlerin bir döngü içerisine herhangi bir döngü kurmadan eklenebileceğini ifade eder.
#![allow(unused)] fn main() { v.extend([60,70,80].iter()); let mut strings = vec!["hello".to_string(), "dolly".to_string()]; strings.extend(["you","are","fine"].iter().map(|s| s.to_string())); }
Aynı zamanda FromIterator
özelliği de vektörlerin döngüleyicilerden inşa edilebileceğini ifade eder. (Döngülerin collect
metotu bunu kullanır.)
Her konteynır döngülenebilir olmalıdır. Üç tarz-ı döngüleyiciyi hatırlayın.
#![allow(unused)] fn main() { for x in v {...} // returns T, consumes v for x in &v {...} // returns &T for x in &mut v {...} // returns &mut T }
for
deyimi IntoIterator
üzerinde iş yapar ve buna bağlı olarak üç farklı kullanımı vardır.
Bir de Index
(Bir vektörden okurken çalışan) bir de IndexMut
(Bir vektörü düzenlerken çalışan) ile kontrol edilen indekslememiz vardır. Pek çok şey yapabiliriz çünkü v[0..2]
gibi ifadelerle dilimlere indeksleyebilir ve dönebiliriz ya da sadece v[0]
ile ilk elemana referans alabiliriz.
From
özelliğinin de birtakım kullanımları vardır. Mesela Vec::from("hello".to_string())
size karakter dizelerinin özündeki Vec<u8>
tipindeki vektörü verecektir. Ancak şunu düşünebilirsiniz, zaten String
tipi için into_bytes
diye bir vektör varken bunun ne özelliği var? Bir işi yapmanın birden çok yolu olması saçma değil mi? Ancak bu, özelliklerin (traits) genellenen metotlar oluşturması için gerekliliktir.
Bazen Rust'ın tip sisteminin kısıtlamalarından illallah edebilirsiniz. Mesela PartialEq
boyutu 32'den az olan diziler için ayrıca tanımlanmıştır. (Bunu iyileştirecekler.) Bu vektörlerle dizileri doğrudan rahatça kıyaslamanızı sağlar ancak boyut sınırına dikkat etmelisiniz.
Belgelendirmenin diplerinde bazı gizli hazinelerle karşılaşabilirsiniz. Tıpkı Karol Kuczmarski'nin dediği gibi; "Kimse bu kadar arayıp taramaz.". Bir döngüleyicideki hataları nasıl yönetmelisiniz? Mesela bir döngüleyici üzerinde map
kullandığınızda bazı öğeler sorun çıkarabilir ve size Result
dönebilirler, böyle bir döngüleyici ile çalışacağınızı düşünün:
fn main() { let nums = ["5","52","65"]; let iter = nums.iter().map(|s| s.parse::<i32>()); let converted: Vec<_> = iter.collect(); println!("{:?}",converted); } //[Ok(5), Ok(52), Ok(65)]
Yeterince iyi, ama tek tek bütün hataları kontrol etmeniz gerekiyor - dikkatlice! Ancak Rust bu işin doğrusunu yapar, eğer vektörün Result
içerisinde barındırılmasını isterseniz - hepsi bu, eğer bir hata varsa bütün vektörü hatalı kabul edebiliriz.
#![allow(unused)] fn main() { let converted: Result<Vec<_>,_> = iter.collect(); //Ok([5, 52, 65]) }
Ya dönüşüm başarısız olursa? İlk hatada işi fazla uzatmadan hemen Err
döner. collect
'in nasıl da esnek olduğuna dair iyi bir örnek olduğunu düşünebiliriz. (Tip bildirimini tuhaf bulabilirsiniz. Vec<_>
kabaca bu bir vektör, Result<Vec<_>,_>
herhangi bir vektörün Result
tipi demektir. Siz ne istediğini belirttikten sonra Rust sizin yerinize işi çözer.)
Belgelendirmede epeyce detay var ancak ne olursa olsun C++'ın std::vector
hakkındaki bilgilendirmesinden çok daha anlaşılır ve net.
Öğelerin gerektiği gereksinimler konteynırın üzerinde yapılan işlemlere dayanır. Çoğunlukla elemanın tipinin karşılanması ve düşürülebilir olması (drop) yeterlidir ancak bazı fonksiyonların katı gereksinimleri vardır.
C++'da kendi başınızın çaresine bakmanız gerekir. Rust'ın ilk başta her şeyi aleni olarak beklemesi sizi ürkütebilir ancak kısıtlamaları anlarken herhangi bir Vec
metotunun gereksinimlerini de anlayacaksınız.
Kaynak kodlarını rustup component add rust-src
ile okumanızı tavsiye ederim, standart kütüphanenin kodları oldukça okunaklıdır ve metotların içeriği tanımlarından çok daha anlaşılırdır.
Sözlükler (Maps)
Sözlükler (HashMap) dilediğiniz veriye bir anahtar ile ulaşabilmenizi sağlar. Aman aman bir fikir değil ve dilerseniz aynı şeyi demet dizisi ile yapabilirsiniz:
#![allow(unused)] fn main() { let entries = [("one","eins"),("two","zwei"),("three","drei")]; if let Some(val) = entries.iter().find(|t| t.0 == "two") { assert_eq!(val.1,"zwei"); } }
Küçük sözlükler ve sadece anahtar denkliği gerektiren durumlar için üstteki örnek iş görür, ancak içerisinde bir şey aramanın süresi doğru orantıya tabidir - sözlüğün büyüklüğü ile doğru orantılı.
Pek çok anahtar/veri çifti gerektiği zaman bir HashMap
ile çalışmak çok çok daha verimlidir.
#![allow(unused)] fn main() { use std::collections::HashMap; let mut map = HashMap::new(); map.insert("one","eins"); map.insert("two","zwei"); map.insert("three","drei"); assert_eq! (map.contains_key("two"), true); assert_eq! (map.get("two"), Some(&"zwei")); }
&"zwei"
mı? get
ile verinin kendisini değil de referansını döndüğü için böyle bir şey görüyoruz. Eğer verinin tipi &str
ise pekâlâ &&str
alabiliriz. Alacağımız verinin referans olması gerekir çünkü çoğu zaman sahipli tiplerin değerlerini taşımak istemeyiz.
get_mut
tıpkı get
gibi çalışır ancak değişebilir bir referans döner. Şimdi karakter dizilerini sayılara çeviren bir sözlüğü inceleyelim ve "two" değerini güncellemeye çalışalım.
#![allow(unused)] fn main() { let mut map = HashMap::new(); map.insert("one",1); map.insert("two",2); map.insert("three",3); println!("before {}", map.get("two").unwrap()); { let mut mref = map.get_mut("two").unwrap(); *mref = 20; } println!("after {}", map.get("two").unwrap()); // before 2 // after 20 }
Referansı farklı bir bloğa aldığımıza dikkat edin - aksi taktirde sonuna kadar değişebilir bir referansımız olurdu ve Rust map
, map.get("two")
ile hiçbir şeyi ödünç almamıza izin vermezdi; değişebilir bir referans varken değişmez referanslara izin verilmez. (Eğer izin verilseydi, değişmez referansların geçerliliği şaibeli olurdu.) Bundan dolayı değişebilir referansı erkenden aradan çıkararak işi çözmüş oluyoruz.
Elbette bunun çok zarif bir API olduğunu söyleyemeyiz ama hatalara karşı daha dikkatli davranırız. Python olsa ters bir durumda hemen ekrana hata mesajları dizer ve C++ ise bize varsayılan veri dönerdi. (Aslında güzel bir çözüm ancak bazı sorunları var. Mesela a_map["two"]
0 döndüğü zaman "bulunamadı" mesajı ile gerçek sıfırın arasındaki farkı anlayamayız. Üstüne de fazladan bir girdi atanmış olur.)
Kimse unwrap
kullanmaz, örneklerde öyle değil tabii. Gördüğünüz çoğu Rust kodu da bağımsız örneklerden oluştuğu için yaygın olarak kullanıldığı kanısına kapılabilirsiniz. Ancak çoğu zaman bir eşleşmenin kullanılması daha olasıdır:
#![allow(unused)] fn main() { if let Some(v) = map.get("two") { let res = v + 1; assert_eq!(res, 3); } ... match map.get_mut("two") { Some(mref) => *mref = 20, None => panic!("_now_ we can panic!") } }
Dilenirse anahtar/veri ikilileri üzerinde döngü kurabilirsiniz ancak belli bir sırası yoktur.
#![allow(unused)] fn main() { for (k,v) in map.iter() { println!("key {} value {}", k,v); } // key one value eins // key three value drei // key two value zwei }
Ek olarak keys
ve values
'un döngüleyici dönen metotları vardır ki bu değerlerden vektör kullanmayı epeyce kolaylaştırır.
Örnek: Kelimeleri saymak
Metinleri anlamak için yapabileceğiniz keyifli işlerden birisi bir metinde kaç farklı kelime olduğunu sayabilmektir. Bir metni kelimelere bölmek split_whitespace
ile oldukça kolaydır ancak noktalama işaretlerine özen göstermemiz gerekir. Bundan dolayı kelimeler sadece alfabetik karakterden oluşacak şekilde bölünmelidir. Üstelik kelimeler işleme tamamen küçük harfli olarak alınmalıdır.
Bir sözlükte içeriği değiştirecek tarzdan bir şey aramak kolaydır ancak arama başarısız olduğu zaman ne yapacağını belirtmek biraz tuhaf kaçabilir. Neyse ki hata koşulunu kontrol etmek için gayet zarif bir çözümümüz var:
#![allow(unused)] fn main() { let mut map = HashMap::new(); for s in text.split(|c: char| !c.is_alphabetic()) { let word = s.to_lowercase(); let mut count = map.entry(word).or_insert(0); *count += 1; } }
Eğer aradığımız kelime sözlükte yoksa sözlüğe sıfır içeren yeni bir girdi yaratıyoruz ve onu sözlüğe sokuyoruz (insert). C++'daki sözlükler de aynen böyle çalışır tek fark burada varsayılan veri kendiliğinden gelmez ve net bir şekilde belirtilir.
Bu kapamada (closure) net bir tip belirttik ve tip de char
oluyor. Bunun nedeni split
tarafından kullanılan karakter dizilerinin Pattern
özelliğinin tuhaflığıdır. Ancak Rust burada sözlüğün anahtar tipinin String
, sözlüğün veri tipinin de i32
olduğunu çıkarabilir.
Gutenberg projesinden Sherlock Holmes'un maceraları'nı (The Adventures of Sherlock Holmes) kullanarak bunu güzelce test edebiliriz. (map.len()
ile) Öğreniyoruz ki birbirinden farklı toplam 8071 kelime kullanılmış.
Peki ya en çok kullanılan yirmi kelimeyi nasıl öğrenebiliriz? Öncelikle sözlüğümüzü bir (anahtar, veri) formatında demetlerle dolu bir vektöre çevirebiliriz. (Bu map
ı yok edecektir, çünkü into_iter
kullandık)
#![allow(unused)] fn main() { let mut entries: Vec<_> = map.into_iter().collect(); }
Sonra bunları azalacak şekilde dizelim. sort_by
, cmp
metotunun sonuçlarını bekleyecektir ki bu metot sayı tiplerinde bulunur.
#![allow(unused)] fn main() { entries.sort_by(|a,b| b.1.cmp(&a.1)); }
Ve bu sayıları ilk yirmi çıktıyı ekrana yazdıralım:
#![allow(unused)] fn main() { for e in entries.iter().take(20) { println!("{} {}", e.0, e.1); } }
(Sadece 0..20
üzerinde bir döngü kurabilirdiniz - bu kabul edilebilir ancak Rust'ın kendisine özgü tarzının dışına çıkmış olurduk - üstelik büyük döngüler için daha maliyetli olurdu.)
38765
the 5810
and 3088
i 3038
to 2823
of 2778
a 2701
in 1823
that 1767
it 1749
you 1572
he 1486
was 1411
his 1159
is 1150
my 1007
have 929
with 877
as 863
had 830
Listenin başında bir tuhaflık sezdiniz mi? O aslında boş bir kelime. split
metotu tek karaktere göre parçaladığı için iki noktalama işaretinin arasındaki boşlukklar da kelimeden sayılmış oldu.
Kümeler (Sets/HashSets)
Kümeleri sadece anahtarlarını umursadığınız sözlükler olarak düşünebilirsiniz, anahtarların karşılığı yoktur. Bundan dolayı insert
sadece tek bir veri alır ve dilerseniz contains
kullabilirsiniz.
Ç.N: Teknik olarak doğru olsa da buradaki tanımı karmaşık buldum. Kümeleri basitçe her verisi özgün olan, aynı veriyi ikinci kez almayan sırasız bir vektör gibi düşünebilirsiniz.
Diğer konteynırlar gibi bir döngüleyiciden HashSet
oluşturabilirsiniz. collect
ile bu işi yapabilirsiniz, tipi bildirdiğiniz sürece.
// set1.rs use std::collections::HashSet; fn make_set(words: &str) -> HashSet<&str> { words.split_whitespace().collect() } fn main() { let fruit = make_set("apple orange pear orange"); println!("{:?}", fruit); } // {"orange", "pear", "apple"}
Aynı anahtarın tekrar girmiş olmanız (beklenildiği gibi) hiçbir etki oluşturmaz ve bir verideki sıralaması önemli değildir.
Matematikteki setlerle yaptığınız işlemleri pekâlâ Rust ile de yapabilirsiniz:
#![allow(unused)] fn main() { let fruit = make_set("apple orange pear"); let colours = make_set("brown purple orange yellow"); for c in fruit.intersection(&colours) { println!("{:?}",c); } // "orange" }
Bütün işlemler döngüleyici döner ve collect
kullanarak onları tekrardan sete çevirebilirsiniz.
İşte bir kısayol, vektörleri nasıl kullanıyorsak aynı şekilde kullanabiliriz.
#![allow(unused)] fn main() { use std::hash::Hash; trait ToSet<T> { fn to_set(self) -> HashSet<T>; } impl <T,I> ToSet<T> for I where T: Eq + Hash, I: Iterator<Item=T> { fn to_set(self) -> HashSet<T> { self.collect() } } ... let intersect = fruit.intersection(&colours).to_set(); }
Bütün Rust jeneriklerinde olduğu gibi burada da tipleri özelliklerle kısıtlamanız gereklidir - yukarıdaki kod sadece eşitliği (Eq
) ve "hash fonksiyonu" (Hash
) bulunan tipler için çalışır. Iterator
diye bir tip bulunmadığını ve I
'nın Iterator
özelliğine sahip bir tip olması gerektiğini belirtiyoruz.
Standart kütüphane tiplerinine kendi metotlarımızı eklemek gözünüze biraz abartılı görünebilir ancak unutmayın ki kurallar var. Bunu sadece kendi özelliklerimize (trait) uygulayabiliriz. Eğer özelliğin ve yapının (struct) ikisi de aynı sandıktan geliyorsa (Mesela ki standart kütüphaneyi sunan "stdlib") bu tarz bir kullanıma izin verilmeyecektir. Bu şekilde bir dikkat dağınıklığından kurtulabiliyoruz.
Kendimizi bu zekice ve uygun kısayolu bulduğumuz için övmeye başlamadan önce yaratabileceği sonuçlara dikkat etmelisiniz. Eğer make_set
aşağıdaki gibi kullanılırsa, ki burada sahipli bir tip olan String
'in kümesi vardır, intersect
'in tipi sizi epeyce bir şaşırtabilir:
#![allow(unused)] fn main() { fn make_set(words: &str) -> HashSet<String> { words.split_whitespace().map(|s| s.to_string()).collect() } ... // intersect is HashSet<&String>! let intersect = fruit.intersection(&colours).to_set(); }
Rust sahipli karakter dizilerinin kopyalarını oluşturmadığı için aksi olamaz. intersect
'in içerisinde fruit
ten ödünç alınmış tek bir &String
bulunmakta. Bunun daha sonra size zorluk çıkaracağına yemin edebilirim, mesela ki yaşam sürelerini belirtmeye başalrken. Daha iyi bir çözüm, döngüleyicinin cloned
metotunu kullanarak kesişim için kendi sahipli tiplerinizi üretmenizdir.
#![allow(unused)] fn main() { // intersect is HashSet<String> - much better let intersect = fruit.intersection(&colours).cloned().to_set(); }
to_set
'in daha iyi bir tanımı, self.cloned().collect()
ile hazırlanabilir ki bir de bunu böyle denemenizi tavsiye ediyorum.
Örnek: İnteraktif Olarak Komut İşleme
Bir programın interaktif bir oturumu olması oldukça kullanışlı olabilir. Her bir satır kendi başına işleme alınır ve içindeki kelimelere bölünür; komut ilk bölümde yer alır ve geri kalan kelimeler ise komutun argümanları olur.
Bunun en akla yatan çözümlerinden birisi komut isimlerinden kapamalara (closure) ulaşılabilen bir sözlük inşa etmek olur. Peki ya nasıl kapamaları bir yerde barındıracağız? Hepsinin farklı boyutları olduğunu düşününce kulağa daha zor geliyor. En uygun çözüm, onların kopyalarını heap
'a kutulamaktır (box):
Hadi deneyelim:
#![allow(unused)] fn main() { let mut v = Vec::new(); v.push(Box::new(|x| x * x)); v.push(Box::new(|x| x / 2.0)); for f in v.iter() { let res = f(1.0); println!("res {}", res); } }
İkinci push
kullanımında çok net bir hata alacağız:
= note: expected type `[closure@closure4.rs:4:21: 4:28]`
= note: found type `[closure@closure4.rs:5:21: 5:28]`
note: no two closures, even if identical, have the same type
Ç.N: Aynı görünseler bile iki kapama asla aynı tipte olmayacaktır.
rustc
gereğinden fazla spesifik bir tip çıkarımında bulundu, bundan dolayı vektörün içindeki tipi kendimiz kutulanmış özellik tipi (boxed trait type) olarak belirtmeliyiz:
#![allow(unused)] fn main() { let mut v: Vec<Box<Fn(f64)->f64>> = Vec::new(); }
Şimdi kutulanmış kapamaları HashMap
(sözlük) tipi için de kullanabiliriz. Kapamalar bulundukları ortamlardan veri çekebildikleri için yaşam sürelerini takip etmeliyiz.
FnMut
u kullanmayı düşünebilirsiniz - çünkü yakaladıkları her türlü değişkenleri düzenleyebilirler. Ancak bir kapamaya tekabül eden birden fazla komutumuz bulunacağı için tekrar tekrar değişebilir referanslar alamazsınız.
Böylece kapamalar argümanlara değişebilir referanslar olarak erişir, karakter dizilerinin dilimleri de (&[&str]
) satırdaki argümanları alır. Tasarladığımız yapıda geri dönüşü Result
ile paketleyeceğiz - hata olarak en önce String
kullanacağız.
D
boyutu belli olan herhangi bir tipi gösterir.
#![allow(unused)] fn main() { type CliResult = Result<String,String>; struct Cli<'a,D> { data: D, callbacks: HashMap<String, Box<Fn(&mut D,&[&str])->CliResult + 'a>> } impl<'a,D: Sized> Cli<'a,D> { fn new(data: D) -> Cli<'a,D> { Cli{data: data, callbacks: HashMap::new()} } fn cmd<F>(&mut self, name: &str, callback: F) where F: Fn(&mut D, &[&str])->CliResult + 'a { self.callbacks.insert(name.to_string(),Box::new(callback)); } }
cmd
imzaya göre bir isim ve bir kapama alır, kapama kutulanmış ve sözlüğe girmiş olmalıdır. Fn
ise çevreden verileri ödünç alabilir ancak düzenleyemez demektir. Bu tarz genelleme metotları en kötüsüdür, imzasına bakarken kafanız karışık ancak içeriği pirüpak anlaşılırdır! Yaşam ömrünü belirtmeyi unutmak burada en sık yapılan hatalardandır - Rust, çevresine kısıtlanmış kapamaların yaşam ömürlerini unutmanızı hoş görmeyecektir!
Şimdi komutları inceleyelim ve çalıştıralım:
#![allow(unused)] fn main() { fn process(&mut self,line: &str) -> CliResult { let parts: Vec<_> = line.split_whitespace().collect(); if parts.len() == 0 { return Ok("".to_string()); } match self.callbacks.get(parts[0]) { Some(callback) => callback(&mut self.data,&parts[1..]), None => Err("no such command".to_string()) } } fn go(&mut self) { let mut buff = String::new(); while io::stdin().read_line(&mut buff).expect("error") > 0 { { let line = buff.trim_left(); let res = self.process(line); println!("{:?}", res); } buff.clear(); } } }
Gayet anlaşılır - satırları kelimelere ayırıp bir vektörde topluyoruz, ardından sözlükte ilk kelimeyi aratıyoruz ve sözlüğün döndüğü kapamayı değişebilir verilerimizle ve kelimenin geri kalanlarıyla çağırıyoruz. Boş satırlar görmezden gelinir ve hata olarak değerlendirilmez.
Şimdi kapamalarımızın olumlu ve olumsuz sonuçlar dönmesini kolaylaştırmak için yardımcı fonksiyonlar tanımlayalım. Burada zekice ufak bir detay var;
tanımladığımız genellenen fonksiyonların çalıştığı tipleri "String
"e çevirebilir.
#![allow(unused)] fn main() { fn ok<T: ToString>(s: T) -> CliResult { Ok(s.to_string()) } fn err<T: ToString>(s: T) -> CliResult { Err(s.to_string()) } }
İşte karşımızda ana programımız var. "ok(answer)
"ın nasıl çalıştığına dikkat edin - çünkü sayılar kendilerini nasıl karakter dizilerine çevrileceğini iyi bilirler!
use std::error::Error; fn main() { println!("Welcome to the Interactive Prompt! "); struct Data { answer: i32 } let mut cli = Cli::new(Data{answer: 42}); cli.cmd("go",|data,args| { if args.len() == 0 { return err("need 1 argument"); } data.answer = match args[0].parse::<i32>() { Ok(n) => n, Err(e) => return err(e.description()) }; println!("got {:?}", args); ok(data.answer) }); cli.cmd("show",|data,_| { ok(data.answer) }); cli.go(); }
Hataları biraz uyduruk bir yoldan ele aldık ve bu tarz durumlarda soru işareti operatörünün nasıl çalıştığının inceleyeceğiz. Basitçe std::num::ParseIntError
hatası std::errror::Errır
özelliğini (trait) içeriyor ki bu bulunduğumuz bloğa description
metotunu getiriyor - Rust özellikler erişilebilir olmadan üzerinde işlem yapmamıza izin vermez.
Ve çalıştıralım:
Welcome to the Interactive Prompt!
go 32
got ["32"]
Ok("32")
show
Ok("32")
goop one two three
Err("no such command")
go 42 one two three
got ["42", "one", "two", "three"]
Ok("42")
go boo!
Err("invalid digit found in string")
Denemek isteyeceğiniz pek çok iyileştirme olabilir. Mesela cmd
komutuna yardım satırını içeren üçüncü bir argüman ekleyebilir, help
komutuna bu üçüncü argümanla cevap verebilirdik. Ya da Cargo ile rustyline sandığını kullanarak komut düzenleme ve geçmişe dönmek konularını daha akılcı bir yoldan halledebiliriz.
Hataları Yönetmek
Hataları Yönetmenin Temelleri
Eğer soru işareti operatörünü kullanmazsanız Rust'ta hata yönetimi epey sıkıcı olabilir. Ancak bunu yapabilmek için bazen herhangi bir hatayı barındırabilecek bir Result
tipi oluşturabilmemiz gerekir. Bütün hatalar std::error::Error
dönebildiğine göre herhangi bir hatayı Box<Error>
ile gösterebiliriz.
Düşünün ki hem girdi/çıktı işlemlerinden gelen hatayı hem de karakter dizisini sayıya çevirmekten gelen hatayı aynı fonksiyon içinde dönmek istiyoruz:
#![allow(unused)] fn main() { // box-error.rs use std::fs::File; use std::io::prelude::*; use std::error::Error; fn run(file: &str) -> Result<i32,Box<Error>> { let mut file = File::open(file)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; Ok(contents.trim().parse()?) } }
Burada girdi/çıktı işlemleri için iki farklı soru işareti operatörü kullanılıyor (Ya dosya açılamazsa? Ya String
e çevrilemezse?) ve bir de çeviri için ayrıca bir soru işareti operatörü kullanıyoruz. En sonunda da sonucu Ok
ile dönüyoruz. Rust, parse
üzerinden dönen i32
tipi ile çalışabilir.
Result
tipi için bir kısayolu oluşturmak da oldukça olaydır:
#![allow(unused)] fn main() { type BoxResult<T> = Result<T,Box<Error>>; }
Ancak bizim programımızın kendisine özgü hata tipleri olacağı için kendi hata tiplerimizi hazırlamamız gerekecek. Bunun için gereken malzemeler:
- Tercihen
Debug
- Bir tutam
Display
- Ve son olarak, olmazsa olmazımız
Error
Eğer bunlar olmazsa hata tipiniz kafası nasıl eserse öyle çalışabilir.
#![allow(unused)] fn main() { // error1.rs use std::error::Error; use std::fmt; #[derive(Debug)] struct MyError { details: String } impl MyError { fn new(msg: &str) -> MyError { MyError{details: msg.to_string()} } } impl fmt::Display for MyError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f,"{}",self.details) } } impl Error for MyError { fn description(&self) -> &str { &self.details } } // a test function that returns our error result fn raises_my_error(yes: bool) -> Result<(),MyError> { if yes { Err(MyError::new("borked")) } else { Ok(()) } }
Sürekli sürekli Result<T, MyError>
yazmak biraz yorucu olduğu için çeşitli Rust modüllerinin kendi Result
tipleri vardır. Mesela Result<T,io::Error>
yazmak yerine io::Result<T>
yazabilirsiniz.
Sonraki örneğimizde çevrilemeyecek bir karakter dizisini ondalıklı sayıya çevirirken karşımıza çıkan hatayı kontrol etmemiz gerekecek.
Şimdiye kadar ?
ile elimizdeki ifadenin bir hata dönüp dönemeyeceğine bakarak çalıştı. Bunu belirleyen özellik (trait), From
özelliğidir. Box<Error>
ise From
'a sahip bütün Error
tiplerini kabul eder.
Devam etmeden önce kendi yarattığınız BoxResult
isimlendirmesini kullanabilir ve her şeyi tek elde toplayabilirsiniz, bu kendi yarattığımız hata tipini Box<Error>
'a döndürecektir. Ufak uygulamalar için pekâlâ iyi bir tercih olabilir. Ancak ben size diğer hata türlerini kendi hata türümüze dâhil edebileceğiniz daha iyi bir örnek göstereceğim.
ParseFloatError
, Error
'u içerdiğinden dolayı kendi içinde description()
'un da tanımlanmış olması gerek.
#![allow(unused)] fn main() { use std::num::ParseFloatError; impl From<ParseFloatError> for MyError { fn from(err: ParseFloatError) -> Self { MyError::new(err.description()) } } // and test! fn parse_f64(s: &str, yes: bool) -> Result<f64,MyError> { raises_my_error(yes)?; let x: f64 = s.parse()?; Ok(x) } }
İlk ?
için pek bir olay yok. (From
ile her tip kendisine dönüştürülür.) İkinci ?
ise ParseFloatError
hatasını MyError
'a çevirir.
Ve sonuç:
fn main() { println!(" {:?}", parse_f64("42",false)); println!(" {:?}", parse_f64("42",true)); println!(" {:?}", parse_f64("?42",false)); } // Ok(42) // Err(MyError { details: "borked" }) // Err(MyError { details: "invalid float literal" })
Birazcık işi yokuşa sürse de hiç de karmaşık değil. İşin tadını kaçıran kısım yazdığımız From
dönüşümleri yazdığımız hataların bizim MyError
ile iyi anlaşması gerektiği - ya da bunları hiç düşünmeyin Box<Error>
kullanın olsun bitsin. Yeni başlayanlar tek bir şeyi birden fazla yapabilmenin yolunu görünce kafaları karışır. Bir avakadoyu soymanın (ya da yeterince manyaksanız bir kediyi yüzmenin) her zaman başka bir yolu vardır. Bu esnekliğin bedeli birden çok seçeneğe sahip olmaktır. Hata kontrolü 200 satırlık bir program için büyük bir programdan daha basit olabilir. Ve eğer bu kıymetli kod atıklarınızı bir Cargo paketine dönüştürmek isterseniz hata işleme çok daha kıymetli bir hâle gelir.
Şu an için soru işareti operatörü yalnızca Result
için çalışmakta, Option
için değil, ve bu bir özelliktir, bir kısıtlama değil. Option
tipinin ok_or_else
isminde kendisini Result
'a dönüştüren bir metotu vardır. Mesela, düşünün ki bir HashMap
'ta aradığımız anahtar bulunmuyor:
#![allow(unused)] fn main() { let val = map.get("my_key").ok_or_else(|| MyError::new("my_key not defined"))?; }
Şimdi hatamız gayet anlaşılır bir şekilde dönmüş oldu! (Bu form içerisinde bir kapama kullanılıyor, böylece buradaki hata ancak gerekirse yaratılırsa olacak.)
Çeviri Notu: Yazarın dediğine karşın, daha sonra soru işareti operatörü Option
tipine dönüşebilir bir şekilde güncellendi. Yani artık şu kullanım geçerlidir:
let val = map.get("my_key")?
get
metotundan dönen None
değerini olduğu gibi ya da bir hata dönmek size kalmış. İki durumunda kendince artıları ve eksileri var. Yazarın bahsettiği gibi, Rust'ta bir şeyi yapmanın birden fazla yolu var. Daha fazla bilgi için Rust'ın referans kitabını ya da kutsal kitabı inceleyebilirsiniz.
error-chain ve hatalarla baş etme sanatı
Çeviri notu: Acı bir şekilde bahsetmeliyim ki, error-chain isimli sandık 2019 gibi tedavülden kaldırıldı. Bunun yerine failure
isminde alternatif bir sandığa kişiler yönlendirilmiş ancak o da 2020 yılı gibi tedavülden kaldırılmış. Benzer konseptleri sağlayan iki sandık var:
- Anyhow: Kabaca hataların tipi ne olursa olsun yönlendirebileceğiniz bir tip sunuyor.
- thiserror: Bir yapıyı
derive(Error)
gibi basit bir şekilde hata tipine dönüştürmeye yarar.
Yazının gerisini hem orijinal metni korumak hem belli başlı konseptleri tanıtmak hem de hata yönetiminin geçmişini göstermek için çeviriyorum.
Önemsiz olmayan uygulamalar için error_chain
sandığına göz atmalısınız. Minik bir makro bu kadar faydalı olabilir.
cargo new test-error-chain
komutuyla çalıştırılabilir bir sandık oluşturun ve oluşturulan dizinin içine girin. Cargo.toml
'un sonuna error-chain="0.8.1"
'i ekleyin.
error-chain
'in olayı elle tek tek yazmanız gereken hata tiplerinin tanımlarını sizin yerinize yazmaktır; yapılar oluşturmak ve Display
, Debug
, Error
gibi bir hata tipi yaratmak için kullanılan özellikleri eklemek. Aynı zamanda öntanımlı olarak From
özelliği de dâhil edilir ki normal karakter dizileri de hatalara dönüştürülebilirler.
İlk src/main.rs
dosyamız alttakine benzeyecektir. Main
içerisinden run
fonksiyonu çağrılıyor, hataları satır satır yazıyor ve programı sıfır olmayan bir çıkış kodu ile program sonlandırılıyor. Hepsi bu. error_chain
makrosu ile bütün gerekli tanımlar üretilmiş olacaktır, errors
modülünü gerekirse daha büyük programlarda kendi dosyasına yerleştirebilirsiniz. errors
modülünü global kapsama dağıtmamız gerekti çünkü kodumuzun üretilmiş özellikleri (trait) görebilmesi gerekliydi. Varsayılan olarak, bir adet Error
yapısı ve bu hatayla birlikte Result
tanımlanacaktır.
Burada foreign_links
kullanarak std::io::Error
'ün From
kullanarak bizim istediğimiz hata tipine dönüşmesini sağlıyoruz
#[macro_use] extern crate error_chain; mod errors { error_chain!{ foreign_links { Io(::std::io::Error); } } } use errors::*; fn run() -> Result<()> { use std::fs::File; File::open("file")?; Ok(()) } fn main() { if let Err(e) = run() { println!("error: {}", e); std::process::exit(1); } } // error: No such file or directory (os error 2)
foreign_links
hayatımızı oldukça kolaylaştırdı zira soru işareti operatörü artık std::io::Error
'u nasıl error::Error
'a dönüştüreceğini biliyor. (Kaputun altında tam da gerektiği gibi makromuz Form<std::io::Error>
dönüşümü tanımlıyor.)
Bütün olay run
içerisinde dönüyor; şimdi programa ilk argüman olarak verilen dosyanın ilk on satırını yazdırmayı deneyelim. Ortada verilmiş herhangi bir argüman olmayabilir, bunu bilemeyiz. Tek gereken şey Option<String>
'i bir Result<String>
'e dönüştürebilmek. Bunu yapabilmek için iki Option
metotumuz var ve ben en basit olanını seçtim. Error
tipimiz &str
için From
'u içerdiğinden basitçe bir karakter dizisiyle yeni bir hata oluşturabiliriz.
#![allow(unused)] fn main() { fn run() -> Result<()> { use std::env::args; use std::fs::File; use std::io::BufReader; use std::io::prelude::*; let file = args().skip(1).next() .ok_or(Error::from("provide a file"))?; let f = File::open(&file)?; let mut l = 0; for line in BufReader::new(f).lines() { let line = line?; println!("{}", line); l += 1; if l == 10 { break; } } Ok(()) } }
bail!
isminde hata "fırlatmak" için kullanılan küçük ama etkili makromuzu da görelim. Bunun yerine ok_or
kullanabilirdiniz:
#![allow(unused)] fn main() { let file = match args().skip(1).next() { Some(s) => s, None => bail!("provide a file") }; }
Tıpkı ?
gibi fonksiyondan erken döner. (early return)
Dönen hata içeriğinde ErrorKind
isimli bir enum barındırır, bu bizi farklı türlü hataları seçebilmemizi sağlar. (Error::from(str)
şeklinde oluşturduğunuz) her hata Msg
isimli varyantla eşleşir. Foreign_links
ile tanımladığımız Io
ise Girdi/Çıktı hatalarıyla eşleşir:
fn main() { if let Err(e) = run() { match e.kind() { &ErrorKind::Msg(ref s) => println!("msg {}",s), &ErrorKind::Io(ref s) => println!("io {}",s), } std::process::exit(1); } } // $ cargo run // msg provide a file // $ cargo run foo // io No such file or directory (os error 2)
Yeni tür hatalar eklemek de oldukça basittir. error_chain
içerisine errors
isimli bir kısım ekleyin:
#![allow(unused)] fn main() { error_chain!{ foreign_links { Io(::std::io::Error); } errors { NoArgument(t: String) { display("no argument provided: '{}'", t) } } } }
Bu oluşturduğumuz yeni tür hata için Display
'ın nasıl çalışacağını tanımlar. Ve şimdi "argüman yok" tarzı hataları daha spesifik bir şekilde tanımlamış olduk, ErrorKind::NoArgument
'e bir String
değeri verebiliriz:
#![allow(unused)] fn main() { let file = args().skip(1).next() .ok_or(ErrorKind::NoArgument("filename needed".to_string()))?; }
Şimdi eşleştirmeniz gereken yeni bir ErrorKind
daha var:
fn main() { if let Err(e) = run() { println!("error {}",e); match e.kind() { &ErrorKind::Msg(ref s) => println!("msg {}", s), &ErrorKind::Io(ref s) => println!("io {}", s), &ErrorKind::NoArgument(ref s) => println!("no argument {:?}", s), } std::process::exit(1); } } // cargo run // error no argument provided: 'filename needed' // no argument "filename needed"
Genellikle mümkün olduğunca hataları spesifikleştirmek daha kullanışlıdır, bilhassa bu bir kütüphane fonksiyonuysa! Bu türe göre eşleştirme tekniği geleneksel hata yönetimine oldukça benzer, sadece burada except
ya da catch
blokları yerine eşleştirme yöntemlerini kullanıyoruz.
Sonuç olarak, error-chain sizin yerinize bir Error
tipi oluşturur ve Result<T>
'i std::result::Result<T, Error>
olarak tanımlar. Error
ise içeriğinde ErrorKind
isimli bir enum barındırır ve varsayılan olarak karakter dizilerinden oluşan hatalarla eşleşen Msg
'i barındırır. Harici hataları da iki farklı iş yapan foregin_links
ile tanımlayabilirsiniz. Birincisi, yeni bir ErrorKind
varyantı oluşturabilirsiniz. İkincisi dış hataları kendi hatamıza çeviren From
tanımlarını hazırlar. Böylece kolaylıkla çeşitli hata türleri eklenebilir hâle gelir. Böylece artık kalıplaşmış olan pek çok koddan kurtulmuş oluyoruz.
Hataları Zincirlemek
Bu sandığın esas güzelliği hataları zincirlemek.
Bir kütüphane kullanıcısı olarak sadece gelişigüzel bir girdi/çıktı almak biraz can sıkar. Tamam, bir dosyayı açamadık. Ama hangi dosya? En basitinden, benim için önemli olan nokta nedir?
error_chain
(Tr: Hata zinciri) bu tarz aşırı genelleme sorununa karşı hata zincirleme çözümünü sunar. Dosyayı açmak istediğimiz zaman tembelce ?
kullanma alışkanlığımıza devam edebilir ve io::Error
'a dönüştürebiliriz, ya da hatayı zincirleyebiliriz.
#![allow(unused)] fn main() { // non-specific error let f = File::open(&file)?; // a specific chained error let f = File::open(&file).chain_err(|| "unable to read the damn file")?; }
Şimdi programımızın foreign_links
kullanmadan yazılan yeni bir versiyonuna bakalım.
#[macro_use] extern crate error_chain; mod errors { error_chain!{ } } use errors::*; fn run() -> Result<()> { use std::env::args; use std::fs::File; use std::io::BufReader; use std::io::prelude::*; let file = args().skip(1).next() .ok_or(Error::from("filename needed"))?; ///////// chain explicitly! /////////// let f = File::open(&file).chain_err(|| "unable to read the damn file")?; let mut l = 0; for line in BufReader::new(f).lines() { let line = line.chain_err(|| "cannot read a line")?; println!("{}", line); l += 1; if l == 10 { break; } } Ok(()) } fn main() { if let Err(e) = run() { println!("error {}", e); /////// look at the chain of errors... /////// for e in e.iter().skip(1) { println!("caused by: {}", e); } std::process::exit(1); } } // $ cargo run foo // error unable to read the damn file // caused by: No such file or directory (os error 2)
Görmüş olduğunuz üzere chain_err
metotu orijinal hatayı alıyor ve orijinal hatayı barındıran yeni bir hata yaratıyor - bu böyle sonsuza kadar gider. İlgili kapamalar hataya dönüştürülebilen herhangi bir veri dönebilir.
Rust makroları sizi pek çok şey yazmaktan kurtarabilir. error-chain
'in main
yerine geçebilecek ayrı bir makrosu bile vardır:
quick_main!(run);
(Zaten run
bütün olayın gerçekleştiği yerdir.)
Sistem Süreçleri, Ağlar and Paylaşım
Değişemez olanı Değiştirmek
Eğer biraz dik kafalıysanız (anladığım kadarıyla) ve ödünç alma kurallarını nasıl es geçebilmenin bir yolu olup olmadığını kara kara düşünüyor olabilirsiniz.
Bu minik programı bir inceleyin, derlenecek ve hiçbir hata vermeyecektir.
// cell.rs use std::cell::Cell; fn main() { let answer = Cell::new(42); assert_eq!(answer.get(), 42); answer.set(77); assert_eq!(answer.get(), 77); }
Evet, answer
değişkeni değişebilir olarak belirtilmemesine rağmen içeriği değişti!
Bu gayet emniyetli, çünkü içeriğindeki Cell
içindeki veri yalnızca set
veya get
ile erişilebilir. Bunun adı iç değişebilirliktir (interior mutability): Eğer bir v
isminde bir yapı (struct) tanımladıysam v
isimli yapı değiştirilebilirse v.a
da değiştirilebilir olur. Cell
, biraz bu kuralı rahatlatıyor çünkü set
aracılığıyla içeriğindeki değeri değiştirebiliyoruz.
Fakat, Cell
sadece Copy
özelliğine sahip tiplerle çalışır. (Mesela kullanıcının tanımadığı Copy
özelliğine sahip tiplerle veyahut ilkel tiplerle)
Farklı türden verilerle çalışmak için referansa ihtiyaç duyarız, değişebilir ya da değişemez olması fark etmeksizin. İşte bize RefCell
bize bu konuda yarımcı olur - içerideki veriye açıkça bir şekilde referanslarla erişirsiniz.
// refcell.rs use std::cell::RefCell; fn main() { let greeting = RefCell::new("hello".to_string()); assert_eq!(*greeting.borrow(), "hello"); assert_eq!(greeting.borrow().len(), 5); *greeting.borrow_mut() = "hola".to_string(); assert_eq!(*greeting.borrow(), "hola"); }
Dikkat edin, greeting
değişebilir bir değişken olarak bildirilmedi!
Rust'ın dereferans operatörü bir tık kafa karıştırıcı olabilir, çünkü çoğu zaman buna ihtiyaç duymazsınız - mesela greeting.borrow().len()
diye çağırsaydık metot kendiliğinden dereferans edeceği için sorun olmazdı. Ancak içeriden gelen greeting.borrow()
üzerinden gelen &String
veya greeting.borrow_mut()
üzerinden gelen &mut String
ile çalışmaya devam edebilmek adına *
eklemeniz gerekecektir.
RefCell
kullanmak her zaman emniyetli değildir, hâlen daha geri dönen metotların temel kurallara riayet etmesi gerekmektedir.
#![allow(unused)] fn main() { let mut gr = greeting.borrow_mut(); // gr is a mutable borrow *gr = "hola".to_string(); assert_eq!(*greeting.borrow(), "hola"); // <== we blow up here! .... thread 'main' panicked at 'already mutably borrowed: BorrowError' }
Eğer değişebilir bir referansınız hâlihazırda varsa tekrardan değişebilir referans oluşturamazsınız. Fakat bu sefer bu kural ihlali derleme zamanında değil çalışma zamanında anlaşılabilir. Çözüm, her zaman olduğu gibi, değişebilir referansları mümkün olduğunca az sayıda tutmaktır - örneğimizdeki kod için değişebilir referansımız gr
'ı kod blokları içerisinde tutmayı düşünebilirsiniz böylece referansımız tekrar ödünç almadan önce düşürülmüş olur.
Bu özellik iyi bir sebebiniz olmadan kullanmak isteyeceğiniz bir şey değil çünkü hatalarınızı derleme zamanında almayacaksınız. Bu tipler, olağan kuralların sizin işinizi engellediği ama doğrusunu ypatığınız durumlarda size dinamik ödünç alma
sunmak için vardır.
Paylaşılan Referanslar
Şimdiye dek, değer ve ödünç alınmış referanslar derleme zamanında açıkça biliniyordu. Veri sahiptir, referanslar ise onsuz var olamaz. Fakat her şey bu düzgün desen tasarımına uygun değildir, mesela düşünelim ki Rol
ve Oyuncu
diye iki yapımız (struct) var. Oyuncu
, Rol
objelerine referanslar bulunan bir vektör tutuyor. Burada veriler arasında net bir şekilde birebir eşleşme yok ve rustc
'yi buna ikna etmek işleri epeyce karmaşıklaştıracaktır.
Rc
tıpkı Box
gibi çalışır - heap belleği tahsis edilir ve veri bunun içine taşınır. Eğer bir Box
klonlarsanız, içindeki klonlanmış veriyi de beraberinde tutan bir bellek alanı tahsis eder. Fakat bir Rc
klonlamak bilgisayar için daha kolaydır, çünkü her klonlama yapacağınız zaman sadece verinin referans sayısını arttırır. Bu, bellek yönetimi için eski ve bilindik bir yöntemdir; mesela iOS ve MacOSlarda kullanılan Objective C'nin çalışma zamanında kullanılır. (Ç.N: Eğer ilgiliyseniz "Automatic Referance Counting" diye bir araştırma yapabilirsiniz.) Modern C++'da bu özellike str::shared_ptr
aracılığıyla bulunabilir.
Bir Rc
nin içinde bulunduğu kapsam sona erdiği zaman referans sayımı da bir azaltılır. Eğer bu sayı sıfır olursa bellek boşaltılır ve sahiplenilmiş olan veri de düşürülür.
// rc1.rs use std::rc::Rc; fn main() { let s = "hello dolly".to_string(); let rs1 = Rc::new(s); // s moves to heap; ref count 1 let rs2 = rs1.clone(); // ref count 2 println!("len {}, {}", rs1.len(), rs2.len()); } // both rs1 and rs2 drop, string dies.
Orijinal veriye istediğiniz kadar çok referans alabilirsiniz - bu daha önce bahsettiğimiz dinamik ödünç alma
dır. T
verisi ve onun referansları olan &T
'yi dikkatlice takip etmek zorunda değilsiniz. Bunun karşılığında makine çalışma zamanında biraz daha yorulmuş olur, bu yüzden ilk
seçiminiz bu olmamalıdır; fakat bu şekilde ödünç alma mekanizmasının sert kurallarına ters düşebilecek paylaşım tasarıları kurgulayabilirsiniz. Dikkat edin ki Rc
size değiştirilemez referanslar sağlayacaktır, aksi taktirde en basit ödünç alma kuralını ihlal ediyor olurduk. Bilirsiniz, huylu huyundan vazgeçmez.
Oyuncu
örneğinde, rollerimizi Vec<Rc<Rol>>
olarak tutabiliriz ve böylece rol ekleyip çıkartabiliriz ancak oluşturulduktan sonra rolleri değiştiremeyiz.
Fakat, ya Oyuncu
başka bir takıma referanslar bulunduruyorsa ve takım oyuncu referanslarını tek bir vektör içinde tutuyorsa? Her şey değiştirilemez olur çünkü Oyuncu
verilerinin Rc
olarak depolanması gerekir! Bu durumda RefCell
gerekli olur. Bu sefer bütün takım Vec<Rc<RefCell<Oyuncu>>>
içinde tutulabilir. Oyuncuyu borrow_mut
, aynı anda birden çok değişebilir referans tutmayacağımızdan emin olarak değiştirebiliriz. Mesela oyuncuya özel bir şeyler olursa bütün takımın güçleneceğine dair güçlü bir kuralımız var:
#![allow(unused)] fn main() { for p in &self.team { p.borrow_mut().make_stronger(); } }
Kod fena değil, ancak tipler biraz ürkünç görünebilir. Her zaman type
ile onlara daha basit isimler atayabiliriz:
#![allow(unused)] fn main() { type PlayerRef = Rc<RefCell<Player>>; }
Çoklu Sistem Süreçleri
Yaklaşık bir yirmi yıldır, saf işlem hızından çoklu çekirdeklere bir geçiş var. Son teknoloji bilgisayarlarımızdan tamamen verim almak için bütün çekirdekleri kullanmalıyız. Bunun en iyi yolu arkaplanda alt süreçler oluşturmaktır, tıpkı daha önceden gördüğümüz Command
ancak bu sefer bir senkranizasyon sorunumuz var; bu alt süreçleri beklemeden onların tamamlanıp tamamlanmadığından emin değiliz.
Ayrı iş süreçlerine ihtiyaç duymamızın tek sebebi bu değil elbette, bütün programı sırf bir girdi almak için bekletemezsiniz, mesela.
Alt süreçler üretmek oldukça basit, sadece arkaplanda işletilecek spawn
için bir kapama hazırlayın.
// thread1.rs use std::thread; use std::time; fn main() { thread::spawn(|| println!("hello")); thread::spawn(|| println!("dolly")); println!("so fine"); // wait a little bit thread::sleep(time::Duration::from_millis(100)); } // so fine // hello // dolly
Kod satırında gördüğünüz "wait a little bit", "az bekle" demektir ve pek de mantıklı bir çözüme benzemiyor. Bize dönen nesnelerin üzerinde join
çağırmak biraz daha mantıklı, ana süreç bu süreçlerin tamamlanmasını bekleyecektir.
// thread2.rs use std::thread; fn main() { let t = thread::spawn(|| { println!("hello"); }); println!("wait {:?}", t.join()); } // hello // wait Ok(())
İşte başka bir tür acayiplik: Alt süreci paniklemeye zorlayalım:
#![allow(unused)] fn main() { let t = thread::spawn(|| { println!("hello"); panic!("I give up!"); }); println!("wait {:?}", t.join()); }
Beklediğimiz gibi panikledi, ancak sadece panikleyen süreç öldü! Ekrana hata mesajını join
ile yazabiliyoruz, yani evet her paniğin sonu programın kapatılması değildir; ancak süreçler bilgisayar için yorucu bir işlemdir ve bu süreçleri kontrol etmenin bir yolu olarak görülmelidir.
hello
thread '<unnamed>' panicked at 'I give up!', thread2.rs:7
note: Run with `RUST_BACKTRACE=1` for a backtrace.
wait Err(Any)
Dönen objeler çeşitli alt süreçlerini takip için etmek için kullanılabilir.
// thread4.rs use std::thread; fn main() { let mut threads = Vec::new(); for i in 0..5 { let t = thread::spawn(move || { println!("hello {}", i); }); threads.push(t); } for t in threads { t.join().expect("thread failed"); } } // hello 0 // hello 2 // hello 4 // hello 3 // hello 1
Rust join
den dönen sonucu ele almamız için bize ısrar eder, mesela alt süreç panikleyebilir. (Bu gerçekleştiği zaman genelde programın tamamını durdurmazsınız, sadece hataları not edersiniz, yeniden denersiniz vs.)
Alt süreçlerin işleyişi için belirli bir sıra yoktur (Program her çalışmada farklı bir sıra verir), ve buna esas noktadır - onlar gerçekten bağımsız yürütme süreçleridir (independent threads of execution. Çoklu süreçler kolaydır, esas olay eşzamanlılıktır (concurrency) - birden çok sürecin süreci senkronize etmek ve yönetmek.
Süreçler Ödünç Almaz
Süreç kapamaları dışarıdan veri alabilir, ancak taşıyarak, ödünç alarak değil!
// thread3.rs use std::thread; fn main() { let name = "dolly".to_string(); let t = thread::spawn(|| { println!("hello {}", name); }); println!("wait {:?}", t.join()); }
Ve işte karşınızda size yardımcı olan hata mesajı:
error[E0373]: closure may outlive the current function, but it borrows `name`, which is owned by the current function
--> thread3.rs:6:27
|
6 | let t = thread::spawn(|| {
| ^^ may outlive borrowed value `name`
7 | println!("hello {}", name);
| ---- `name` is borrowed here
|
help: to force the closure to take ownership of `name` (and any other referenced variables), use the `move` keyword, as shown:
| let t = thread::spawn(move || {
Anlaşılabilir! Bir fonksiyon içerisinde üretilen süreci düşünün - fonksiyon çağrısı sona erdikten sonra bile çalışmaya devam eder ve name
dürüşürülebilir. Kapamamıza move
eklemek bu sorunu çözecektir.
Fakat bu bir taşımadır, yani name
sadece bir süreçte var olabilr! Referansları paylaşmanın mümkün olduğunu vurgulamak istiyorum, ancak static
ömre sahip olmalıdırlar:
#![allow(unused)] fn main() { let name = "dolly"; let t1 = thread::spawn(move || { println!("hello {}", name); }); let t2 = thread::spawn(move || { println!("goodbye {}", name); }); }
name
, bütün programın çalışma süresince var olacaktır (static
), bu yüzden rustc
kapama çalıştığı sürece name
değerinin de varlığından emin olacaktır. Ancak, havalı referansların static
ömürleri yoktur!
Alt süreçler ortak bir ortamı paylaşamazlar - bu Rust'ın kendi tarzıdır. Biraz detay girersek, olağan referansları paylaşamazlar çünkü kapamalar yakaladıkları verileri taşırlar.
Yine de Paylaşılan referanslar fena değil çünkü yaşam ömürleri "gerektiği kadardır" - ama bunun için Rc
kullanamazsınız. Çünkü Rc
alt süreçler arası emniyete sahip değildir (thread safe) - sadece süreçlerin olmadığı anlar için hızlı olmaya optimize edilmiştir. Neyse ki Rc
kullanırsanız derleme zamanında bir hata alırsınız, derleyici sizin arkanızı kollar.
Alt süreçler için std::sync::Arc
kullanmalısınız - "Arc"ın açılımı "Atomic Referance Counting" yani "Atomik Referans Sayımıdır". Hepsi bu, bu dost her şeyin tek bir işlemde değiştirileceğini garanti eder. Bu garantiyi sağlamak için de işlem gerçekleştirilirken kilitlenir ve o an sadece bir sürecin erişimine izin verilir. clone
kullanmak, bir kopya üretmekten bilgisayar için daha zahmetsizdir. (Ç.N: Buradaki clone
, baştan aşağıya bellekte veri kopyalayan std::clone
değil, referans klonlayan std::sync::Arc::clone
metotudur.)
// thread5.rs use std::thread; use std::sync::Arc; struct MyString(String); impl MyString { fn new(s: &str) -> MyString { MyString(s.to_string()) } } fn main() { let mut threads = Vec::new(); let name = Arc::new(MyString::new("dolly")); for i in 0..5 { let tname = name.clone(); let t = thread::spawn(move || { println!("hello {} count {}", tname.0, i); }); threads.push(t); } for t in threads { t.join().expect("thread failed"); } }
Bilinçli olarak String
barındıran bir tip oluşturdum, ("newtype" ya da yenitür) çünkü MyString
, Clone
özelliğini taşımayacak. Fakat paylaşılan referanslar klonlanabilir!
name
'e ait paylaşılan referanslar her yeni alt sürece clone
ile oluşturulan yeni referanslar olarak iletilir ve kapama içerisine taşınır. Biraz fazla kod yazdırıyor, ancak bu emniyetli bir örüntüdür. Emniyet, eşzamanlılık için epey önemlidir çünkü sorunlar pek öngörülebilir değildir. Program sizin bilgisayarınızda düzgünce çalışsa bile bir sunucuda patlayabilir, mesela haftasonu keyif yaparken. Daha da kötüsü, problemi semptomları bir tanı koymanıza yardımcı olmayabilir.
Kanallar
Süreçler arasında veri paylaşmanın çeşitli yolları var. Rust içerisinde, bunlardan birisi kanalları kullanmaktır. std::sync::mpsc::channel()
, alıcı kanal ve verici kanal olmak üzere bize iki veri tutan bir demet sunar. Her bir alt sürece verici clone
ile yollanır, ve referans üzerinde send
çağrılır. Ana süreç ise bu esnada alıcı üzerinde recv
i çağırır.
MPSC
'nin açılımı "Multiple Producer Single Consumer"dır, yani "Çoklu üretici, tek tüketici". Kanala veri yollamaya teşebbüs eden birden çok altsüreç oluşturacağız, ana sürecimiz de kanalı "tüketecek".
// thread9.rs use std::thread; use std::sync::mpsc; fn main() { let nthreads = 5; let (tx, rx) = mpsc::channel(); for i in 0..nthreads { let tx = tx.clone(); thread::spawn(move || { let response = format!("hello {}", i); tx.send(response).unwrap(); }); } for _ in 0..nthreads { println!("got {:?}", rx.recv()); } } // got Ok("hello 0") // got Ok("hello 1") // got Ok("hello 3") // got Ok("hello 4") // got Ok("hello 2")
Bu sefer join
kullanmaya ihtiyacımız yok çünkü alt süreçler kendilerini sonlandırmasından sonra cevaplarını dönecektir, fakat bu her an gerçekleşebilir. recv
süreci kilitleyecektir ve eğer yollayıcı kanal devredışı kalacaksa bir hata dönecektir. recv_timeout
ise belli bir süre boyunca bloklayacaktır ve ek olarak bir de zamanaşımı hatası dönebilecektir.
send
ise süreci kilitlemeyecektir, bu faydalıdır çünkü süreçler mesajın alınmasını beklemeye gerek duymaksızın bir veriyi kanala atıp devam edecektir. Ek olarak, kanalın bir belleği vardır, böylece birden çok send
metodu çalışabilir ve alıcıya sırasıyla mesaj iletilecektir.
Fakat, sürecin engellenmemesi aynı zamanda Ok
değerinin mesajın başarıyla iletildiği anlamına gelmediğini de işaret eder.
sync_channel
ise kanala veri yollarken süreci kilitler. Argüman sıfır olursa, alıcı mesajı recv
ile alana kadar süreç kilitlenir. Süreçler ya buluşmalıdır ya da randevulaşmalıdır. (Her zaman yabancı kökenli kelimeler kulağa bir tık daha teknik ve doğru gelir.)
#![allow(unused)] fn main() { let (tx, rx) = mpsc::sync_channel(0); let t1 = thread::spawn(move || { for i in 0..5 { tx.send(i).unwrap(); } }); for _ in 0..5 { let res = rx.recv().unwrap(); println!("{}",res); } t1.join().unwrap(); }
Burada send
kullanılmamışken recv
kullanarak bir hataya kolayca sebep olabilir, mesela döngüyü for i in 0..5
ile kurmak yerine for i in 0..4
kullanarak. Süreç sona erer, tx
düşer ve recv
başarısız olur. Bu aynı zamanda bir süreç paniklediği zaman da gerçekleşir, stack yavaşça çözülür ve bütün veriler düşürülür.
Eğer sync_channel
sıfır olmayan bir argümanla oluşturulursa, buna n
diyelim, bu sefer en fazla n
değeri alan bir sıra (queue) gibi davranır, send
sadece sırada bekleyen n
den fazla değer varsa süreci kilitleyecektir.
Kanallar güçlü tip (strongly type) mantığına uygundur, bu örnekte kanalın tipi i32
dir, ancak tip çıkarımı bunu biraz gizler. Eğer farklı türden verilere ihtiyacınız varsa, numaralandırmalar (enum) bunu ifade etmek için uygundur.
Senkronizasyon
Senkranizasyona bakalım. join
oldukça basit, tek işi bir iş parçacığı bitene kadar beklemek. sync_channel
ise iki kanalı birbirine senkronize ediyor - son örneğimizde üretilen alt süreç ve ana süreç tamamen birbirine kilitlenmişti.
Bariyer senkronizasyonu, bütün süreçlerin bir noktaya geldiği zaman diğer süreçlerin beklemesini içerir, sonra yollarına devam ederler. Bariyer, beklemesini istediğimiz süreçlerin toplam sayısıyla oluşturulur. Daha önce olduğu gibi Arc
aracılığıyla bu bariyeri diğer altsüreçlerle paylaşabilirsiniz.
// thread7.rs use std::thread; use std::sync::Arc; use std::sync::Barrier; fn main() { let nthreads = 5; let mut threads = Vec::new(); let barrier = Arc::new(Barrier::new(nthreads)); for i in 0..nthreads { let barrier = barrier.clone(); let t = thread::spawn(move || { println!("before wait {}", i); barrier.wait(); println!("after wait {}", i); }); threads.push(t); } for t in threads { t.join().unwrap(); } } // before wait 2 // before wait 0 // before wait 1 // before wait 3 // before wait 4 // after wait 4 // after wait 2 // after wait 3 // after wait 0 // after wait 1
Süreçler yine yarı-rastgele çalışırken bir anda birleşiyorlar ve sonra tekrar devam ediyorlar. Bu, devam ettirilebilir bir join
gibidir ve bütün süreçlerin belli bir işi yaptıktan sonra o işle devam etmesini istediğinizde kullanışlı olabilir.
Paylaşılmış Durumlar
Süreçler, kendi paylaşılmış durum bilgisini nasıl düzenler?
Aklınıza dinamik olarak paylaşılan değişebilir referans almak için kullandığımız Rc<RefCell<T>>
stratejisini getirin. RefCell
in süreçlerde kullanılan muadili ise Mutex
- değişken referansı lock
kullanarak alabilirsiniz. Referans var olduğu müddetçe diğer süreçler veriye erişemeyecektir. mutex
'in açılımı "Mutual Exclusion" yani "Karşılıklı Hariciyet" - bir sürecin erişmesi için kodun ilgili kısmını kilitliyoruz ve ardından kilidini açıyoruz. lock
ile kilitlersiniz ve referans düşünce de kilit kalkar.
// thread9.rs use std::thread; use std::sync::Arc; use std::sync::Mutex; fn main() { let answer = Arc::new(Mutex::new(42)); let answer_ref = answer.clone(); let t = thread::spawn(move || { let mut answer = answer_ref.lock().unwrap(); *answer = 55; }); t.join().unwrap(); let ar = answer.lock().unwrap(); assert_eq!(*ar, 55); }
RefCell
kadar kolay değil çünkü eğer kilidin olduğu bir süreç paniklerse mutex
daima kilitli kalabilir. (Böyle bir durumda, dokümentasyon açıkça süreci unwrap
ile terk etmeniz gerektiğini söyler çünkü bir şeyler çok yanlış gitmiştir.)
Bu sefer değişebilir referansları mümkün olduğunca az tutmak çok daha önemli; çünkü bu mutex
kapalı kaldıkça diğer süreçler de bloklanacaktır. Bu, kilitleyip bilgisayar için zor hesaplamaların yapmanın yeri bu değil! Yani, muhtemelen kodunuz şuna benzeyecek:
#![allow(unused)] fn main() { // ... do something in the thread // get a locked reference and use it briefly! { let mut data = data_ref.lock().unwrap(); // modify data } //... continue with the thread }
Yüksek Seviyeli İşlem
Belki de süreçleri yönetmenin daha yüksek seviyeli bir yolunu bulmak, süreçleri tek tek elle kontrol etmekten daha iyidir. Bunun bir örneği hesaplamaları paralel olarak yaptırmak ve sonuçları toplamak olabilir. Epey enteresan bir sandık olarak pipeliner1 sandığına bakabilirsiniz ki çok anlaşılır bir API'ya sahiptir. Deneysel bir "Merhaba Dünya"ye ne dersiniz? - bize çıktılar veren bir döngüleyici kurgulayalım ve n
adet işlemi paralel olarak çalıştıralım:
1: Görünüşe göre pipeliner sandığı Şubat 2020'den beri güncelleme almamış
extern crate pipeliner; use pipeliner::Pipeline; fn main() { for result in (0..10).with_threads(4).map(|x| x + 1) { println!("result: {}", result); } } // result: 1 // result: 2 // result: 5 // result: 3 // result: 6 // result: 7 // result: 8 // result: 9 // result: 10 // result: 4
Salakça bir örnek olduğunun farkındayız, çünkü ilgili operasyon zaten bilgisayar için zahmetsiz ancak paralel işlemler gerçekleştirmenin ne derece kolay olabileceğini göstermiş oldum.
Daha kullanışlı bir şey yapalım. Ağ işlemlerini paralel olarak gerçekleştirmek faydalı olabilir, çünkü genellikle uzun zaman alırlar ve işe başlamak için hepsinin tamamlanmasını beklemeyi istemezsiniz.
Örneğimiz biraz kötü (emin olun yapmanın çok daha iyi yolları var) ancak ne işe odaklanın. 4. Bölümde tanımladığımız shell
fonksiyonunu belli bir aralıktaki IP4 adreslerine ping
atmak için tekrar kullanacağız:
extern crate pipeliner; use pipeliner::Pipeline; use std::process::Command; fn shell(cmd: &str) -> (String,bool) { let cmd = format!("{} 2>&1",cmd); let output = Command::new("/bin/sh") .arg("-c") .arg(&cmd) .output() .expect("no shell?"); ( String::from_utf8_lossy(&output.stdout).trim_right().to_string(), output.status.success() ) } fn main() { let addresses: Vec<_> = (1..40).map(|n| format!("ping -c1 192.168.0.{}",n)).collect(); let n = addresses.len(); for result in addresses.with_threads(n).map(|s| shell(&s)) { if result.1 { println!("got: {}", result.0); } } }
Kendi ev ağımda sonuç şöyle bir şey:
got: PING 192.168.0.1 (192.168.0.1) 56(84) bytes of data.
64 bytes from 192.168.0.1: icmp_seq=1 ttl=64 time=43.2 ms
--- 192.168.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 43.284/43.284/43.284/0.000 ms
got: PING 192.168.0.18 (192.168.0.18) 56(84) bytes of data.
64 bytes from 192.168.0.18: icmp_seq=1 ttl=64 time=0.029 ms
--- 192.168.0.18 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.029/0.029/0.029/0.000 ms
got: PING 192.168.0.3 (192.168.0.3) 56(84) bytes of data.
64 bytes from 192.168.0.3: icmp_seq=1 ttl=64 time=110 ms
--- 192.168.0.3 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 110.008/110.008/110.008/0.000 ms
got: PING 192.168.0.5 (192.168.0.5) 56(84) bytes of data.
64 bytes from 192.168.0.5: icmp_seq=1 ttl=64 time=207 ms
...
Aktif adresler hızlıca bize bir saniyenin yarısı kadar bir sürede bize cevap verebiliyor, gerisi de olumsuz cevapları beklemek oluyor. Eğer paralel olarak çalıştırmasaydık, yaklaşık bir dakika boyunca sonuçları beklemek zorunda kalırdık! Ping zamanı gibi çıktıdan analiz ederek bulmayı düşünebilirsiniz, ancak bu sadece Linux üzerinde işe yarabilirdi. ping
her sistemde vardır ancak çıktı her platform için değişiklik gösterebilir. Eğer Rust ile çalışacak evrensel br ağ API hizmeti tasarlamak isterseniz, ağlar kısmına geçiş yapabiliriz.
Adresleri Çözümlemenin Daha İyi Bir Yolu
Eğer sadece neyin aktif olduğu bilmek istiyorsanız ve gelişmiş ping istatistikleri ilginizi çekmiyorsa, std::net::ToSocketAddrs
özelliği sizin için DNS çözümlemesi yapacaktır.
use std::net::*; fn main() { for res in "google.com:80".to_socket_addrs().expect("bad") { println!("got {:?}", res); } } // got V4(216.58.223.14:80) // got V6([2c0f:fb50:4002:803::200e]:80)
Bu bir döngüleyicidir çünkü genellikle bir alan adına birden çok arayüz bağlıdır, ikisi de Google'un arayüzleridir; birisi IPv4 ve diğeri IPv6 olmak üzere.
Şimdi pipeliner
örneğimizi masumca bu metotu yeniden yazmak için kullanabiliriz. Çoğu ağ protokolü hem bir adres hem de bir port kullanır:
extern crate pipeliner; use pipeliner::Pipeline; use std::net::*; fn main() { let addresses: Vec<_> = (1..40).map(|n| format!("192.168.0.{}:0",n)).collect(); let n = addresses.len(); for result in addresses.with_threads(n).map(|s| s.to_socket_addrs()) { println!("got: {:?}", result); } } // got: Ok(IntoIter([V4(192.168.0.1:0)])) // got: Ok(IntoIter([V4(192.168.0.39:0)])) // got: Ok(IntoIter([V4(192.168.0.2:0)])) // got: Ok(IntoIter([V4(192.168.0.3:0)])) // got: Ok(IntoIter([V4(192.168.0.5:0)])) // ....
Bu, ping
yollamaktan çok daha hızlıdır çünkü sadece bir IP adresinin geçerli olup olmadığını ölçüyoruz, eğer bir gerçek alan adlarının bir listesini kullansaydık DNS araştırması epey vakit alabilirdi ve bu paralleliğin nasıl önemli olabileceğini bize gösteriyor.
İlginç bir şekilde, bu "çalıştır ve unut" mantığında işliyor. Standart kütüphanede bulunan ve Debug
barındıran her şey, hata ayıklamak için de müthiş keşifler sunar. Dönngüleyici Result
(hâliyle Ok
) dönüyor ve bu Result
, IPv4 veyahut IPv6 varyantları olan bir numalandırma olan SocketAddr
barındıran bir IntoIter
içeriyor. Peki neden IntoIter
? Çünkü bir soketin birden çok adresi olabilir, (hem IPv4 hem de IPv6 adresi olması gibi.)
#![allow(unused)] fn main() { for result in addresses.with_threads(n) .map(|s| s.to_socket_addrs().unwrap().next().unwrap()) { println!("got: {:?}", result); } // got: V4(192.168.0.1:0) // got: V4(192.168.0.39:0) // got: V4(192.168.0.3:0) }
Bu da çalışıyor, ilginç olarak, bizim basit örneğimiz kadar işe yarıyor. İlk unwrap
Result
'tan kurtuluyor ve döngüleyiciden ilk çıkan değeri dışarı çıkartıyor. Result
genellikle bu durumda anlamsız adreslerde tetiklenir. (Mesela portu olmayan adres isimleri gibi.)
TCP İstemci Sunucusu
Rust, en çok kullanılan ve en yaygın ağ protokolü için gayet makul bir atayüz de sunar; TCP. TCP, hatalara karşı oldukça dayanıklıdır ve ağlarla örülü dünyamızın temel taşıdır - paketler onaylanarak gönderilir ve onaylanarak alınır. Bunun tersi olarak UDP ise paketleri hiçbir onay olmadan yollar. Bunun hakkında şöyle garabet bir espri vardır: "Sana UDP hakkında bir fıkra anlatabilirim ama muhtemelen kafan almayacak." (Ağlar hakkındaki espriler komiktir, sizin komikten ne anladığınıza göre değişir tabii.)
Fakat, hata kontrolü ağlarla uğraşırken çok önemlidir çünkü her an her şey bir anlığına oluşabilir.
TCP, bir istemci/sunucu modeliyle çalışır; sunucu adresi belli bir ağ portundan dinler ve istemci de sunucuya bağlanır. Bağlantı oluşturulduğunda ise istemci ve sunucu bir soket üzerinden haberleşebilir.
TcpStream::connect
, SoccetAddr
'a dönüştürülebilecek her şeyi kabul eder, kullandığımız düz karakter dizilerini de.
Rust'ta basit bir TCP istemcisi yazmak oldukça kolaydır - TcpStream
yapısı hem yazılabilir hem de okunabilirdir. Her zaman olduğu gibi, özellikleri kullanmak için, Read
, Write
ve diğer std::io
özelliklerini kapsamda görünür kılmalıyız.
// client.rs use std::net::TcpStream; use std::io::prelude::*; fn main() { let mut stream = TcpStream::connect("127.0.0.1:8000").expect("connection failed"); write!(stream,"hello from the client!\n").expect("write failed"); }
Sunucumuz pek karmaşık değil, bir dinleyici kuruyoruz ve bağlantıları bekliyoruz. Eğer bir istemci bağlanırsa, sunucu tarafında TcpStream
elde ederiz. Bu örnekte sunucuya gelen her şey bir karakter dizisine yazılmış olur.
// server.rs use std::net::TcpListener; use std::io::prelude::*; fn main() { let listener = TcpListener::bind("127.0.0.1:8000").expect("could not start server"); // accept connections and get a TcpStream for connection in listener.incoming() { match connection { Ok(mut stream) => { let mut text = String::new(); stream.read_to_string(&mut text).expect("read failed"); println!("got '{}'", text); } Err(e) => { println!("connection failed {}", e); } } } }
Port numarasını aşağı yukarı rastgele geçtim, ancak pek çok port özel anlamlar barındırır.
İki tarafında protokol üzerinde uzlaştığına dikkat edin. İstemci akışa yazı yazabileceğini düşünür ve sunucu da akıştan yazıyı okuyabileceğini umar. Eğer oyunu aynı kurallarla oynamazlarsa, bir tarafın engellendiği ve hiç gelmeyecek bir cevabı beklediği durumlar oluşur.
Hataların kontrolü önemlidir - ağ girdi çıktıları çeşitli sebeplerden aksayabilir ve hatalar dolunayda dosya sisteminin kurtadama dönüşmesi gibi acayip sebeplerlerl de geçerkleşebilir. Birileri kablolarla ip atlayabilir, öbür tarafın bilgisayarı çökebilir, falan fistan. Yazdığımız ufak sunucu çok da sağlam değil, çünkü ilk hata çökecektir.
Hataları düzgünce ele alan güçlü bir sunucu aşağıda bulunmaktadır. Akıştan bir satırı bir io::BufRead
üreten bir io::BufReader
aracılığıyla okur ve böylece çıktı üzerinde read_line
çağırabiliriz.
// server2.rs use std::net::{TcpListener, TcpStream}; use std::io::prelude::*; use std::io; fn handle_connection(stream: TcpStream) -> io::Result<()>{ let mut rdr = io::BufReader::new(stream); let mut text = String::new(); rdr.read_line(&mut text)?; println!("got '{}'", text.trim_right()); Ok(()) } fn main() { let listener = TcpListener::bind("127.0.0.1:8000").expect("could not start server"); // accept connections and get a TcpStream for connection in listener.incoming() { match connection { Ok(stream) => { if let Err(e) = handle_connection(stream) { println!("error {:?}", e); } } Err(e) => { print!("connection failed {}\n", e); } } } }
handle_connection
içindeki read_line
çökebilir ancak sonuçta hata emniyetli bir şekilde kontrol edilmiş olur.
Bu tarz tek yönlü iletişimler bazen kullanışlı olabilir - bu örnekte olduğu gibi. Ağda bulunan servislerin durumlarını, merkezi bir yerde toplamış oluyoruz. Fakat kibarca bir şekilde geri dönüş yapsak iyi olur, en azından "ok" deyip geçelim.
İşte bir "eko" sunucusu. İstemci, sonunda satır sonu işareti olan bir metni sunucuya yollar ve sunucuda ek bir satır sonu ekleyerek geri yollar - akış okunabilir ve yazılabilirdir.
// client_echo.rs use std::io::prelude::*; use std::net::TcpStream; fn main() { let mut stream = TcpStream::connect("127.0.0.1:8000").expect("connection failed"); let msg = "hello from the client!"; write!(stream,"{}\n", msg).expect("write failed"); let mut resp = String::new(); stream.read_to_string(&mut resp).expect("read failed"); let text = resp.trim_right(); assert_eq!(msg,text); }
Sunucu şimdi ilginç bir hâl aldı. Sadece handle_connection
u değiştirelim:
#![allow(unused)] fn main() { fn handle_connection(stream: TcpStream) -> io::Result<()>{ let mut ostream = stream.try_clone()?; let mut rdr = io::BufReader::new(stream); let mut text = String::new(); rdr.read_line(&mut text)?; ostream.write_all(text.as_bytes())?; Ok(()) } }
Bu yaygın olarak kullanılan yaygın br çift taraflı soket iletişimidir; BufReader
'a yollamak için bir satır istiyoruz - fakat "BufReader" akışı tüketiyor! Bu yüzden akışı klonlamalıyız ve aynı soketi işaret eden yeni bir yapı oluşturmalıyız. Böylece nihayet huzuru elde ediyoruz.
Rust ve Nesne Yönelimli Programlama
Her disiplinden birileri geliyor ve Nesne Yönelimli bir dilden gelmeniz olasılığınız oldukça yüksektir:
- "Sınıf"lar nesne (kimi zaman örnek (instance) deriz) üreten fabrikalar gibi çalışırlar.
- Sınılar diğer sınıfların (üst sınıf/ebeveyn) alanlarını (field) ve davranışlarını (metotlar) miras (inheritance) alabilir.
- Eğer B, A'dan miras alıyorsa, o zaman B aynı zamanda A olarak kabul edilebilir. (alttip/subtyping)
- Nesne kendi verilerini gizlemelidir (kapsülleme/encapsulation), sadece metotlarıyla etkileşime geçmelidir. Nesne yönelimli tasarım sınıfların (yani isimleri) ve yöntemlerin (yani sıfatları) tanımlandığı anlayıştır. Aynı zamanda nesne yönelimli tasarımda bunlar arasındaki ilişkiler de tanımlanır ve bu ilişkiler sahip olmak (has-a) ve onun türünden olmak (is-a) olarak tanımlanır.
Eski Star Trek serilerinde doktorun kaptana "Bu bir yaşam Jim, sadece bizim bildiğimiz yaşam değil" (It's Life, Jim, just not Life as we know it) dediği bir an vardır. Bu deyim aynen olduğu gibi Rust'taki nesne yönelimi anlayışını da açıklıyor: Önce "Bu ne ya?" diyorsunuz, çünkü Rust'taki veri yapıları (yapılar, numaralandırmalar ve demetler) pek de uçan kaçan tarzda değiller. Onlara metot tanımlayabilirsiniz, verinin kendisini gizleyebilirsiniz ve bütün kapsülleme yöntemlerini kullanabilirsiniz ancak her birisi birbiriyle alakasız tiplerdir. Alt tipler yoktur, miras alma yöntemi yoktur. (Sadece Deref
zorlamaları miras olarak kabul edilebilir 1)
Çevirmenin yorumu: Nesne yönelimli programlamada miras alan tip, doğal olarak miras aldığı tip olduğu kabul edilir. Rust'ta bir literatür farklılığı vardır, o da tipin aslında o olmadığı ancak zorlanarak dönüştürüldüğü vurgusudur. Yani bu bir miras alma mıdır? Evet, belki de. Ancak Rust, "coercion" kelimesi ile Rust'ın bir tipi başka bir tipe zorla dönüştürdüğünü vurgulamaktadır.
Çeşitli tipler arasındaki ilişkiler özellikler (trait) ile inşa edilir. Rust öğrenme sürecinin büyük bir kısmı standart kütüphanede bulunan özelliklerin nasıl çalıştığını anlamaktan geçer çünkü bu bütün verileri birbiriyle çalışmasına izin veren ve onlara anlamlar yükleyen bir sistemdir.
Özellik sistemi ilginçtir çünkü ana akım programlama dillerinde birebir benzeri yoktur. Tabii, bu dinamiklik ya da statiklik arasındaki farklı düşünürsek. Dinamik olarak Java ve Go arayüzlerine (interface) benzerler.
Özellik Nesneleri
Özellikleri anlatırken kullandığımız ilk örneği düşünün:
#![allow(unused)] fn main() { trait Show { fn show(&self) -> String; } impl Show for i32 { fn show(&self) -> String { format!("four-byte signed {}", self) } } impl Show for f64 { fn show(&self) -> String { format!("eight-byte float {}", self) } } }
Bu da büyük impl
bloklarıyla beraber ufak bir program:
trait Show { fn show(&self) -> String; } impl Show for i32 { fn show(&self) -> String { format!("four-byte signed {}", self) } } impl Show for f64 { fn show(&self) -> String { format!("eight-byte float {}", self) } } fn main() { let answer = 42; let maybe_pi = 3.14; let v: Vec<&Show> = vec![&answer,&maybe_pi]; for d in v.iter() { println!("show {}",d.show()); } } // show four-byte signed 42 // show eight-byte float 3.14
Burası Rust'ın tip bildirimi gerektirdiği bazı nadir noktalardan birisi - Show
özelliğini içeren herhangi bir vektörü açıkça istemek durumundayım. i32
ve f64
arasında hiçbir şey bağlantı olmadığına dikkat edin, fakat ikisi de show
metotuna sahip çünkü ikisi de aynı özelliğe sahip. Bu sanal (virtual) bir metottur, çünkü bu metot her tip için farklı bir kod çalıştırır ve çalışma zamanındaki duruma göre doğru yöntem çağrılır. Bu referanslara özellik nesneleri denir.
Ve bu farklı tipleri nasıl aynı vektöre koyabileceğinizin yoludur. Eğer Java veyahut Go temeliniz varsa, Show
'u bir arayüz (interface) olarak düşünebilirsiniz.
Biraz daha kurcalayalım ve değerleri bir Box
işaretçisi içine koyalım. Box
, heapta tahsis edilmiş alana yerleştirilen verinin referansını referansını temsil eder ve bir ödünç alma gibi çalışır - bu bir akıllı işaretçidir (smart pointer). Box
ortadan kalktığı zaman Drop
devreye girer ve bellek serbest bırakır.
#![allow(unused)] fn main() { trait Show { fn show(&self) -> String; } impl Show for i32 { fn show(&self) -> String { format!("four-byte signed {}", self) } } impl Show for f64 { fn show(&self) -> String { format!("eight-byte float {}", self) } } let answer = Box::new(42); let maybe_pi = Box::new(3.14); let show_list: Vec<Box<Show>> = vec![answer,maybe_pi]; for d in &show_list { println!("show {}",d.show()); } // show four-byte signed 42 // show eight-byte float 3.14 }
Buradaki fark, bu şekilde bu vektörü alıp bir yere referans takibi yapmaksızın başka yerlere ödünç verebilirsiniz. Vektör düşürüldüğü zaman, Box
nesneleri de düşürülür ve bellek yeniden tahsis edilebilir.
Hayvanat Bahçesi
Nesne yönelimli programlamadan ve miras alma işleminden bahsettiğimiz ilk andan itibaren hayvanlardan konuşmaya başlarız. Kulağa da fena gelmez, "Bak işte, kedi bir etoburdur ve etoburlar bir hayvandır". Ruby evreninden klasik bir sloganı analım: "Eğer vaklıyorsa, o bir ördektir". quack
(vak!) metotuna sahip bütün nesneler ördek olarak tanımlanabilir, biraz kulağa tuhaf gelse de.
#![allow(unused)] fn main() { trait Quack { fn quack(&self); } struct Duck (); impl Quack for Duck { fn quack(&self) { println!("quack!"); } } struct RandomBird { is_a_parrot: bool } impl Quack for RandomBird { fn quack(&self) { if !self.is_a_parrot { println!("quack!"); } else { println!("squawk!"); } } } let duck1 = Duck(); let duck2 = RandomBird{is_a_parrot: false}; let parrot = RandomBird{is_a_parrot: true}; let ducks: Vec<&Quack> = vec![&duck1,&duck2,&parrot]; for d in &ducks { d.quack(); } // quack! // quack! // squawk! }
Elimizde iki farklı tip var (o kadar işlevsiz ki hiçbir veri tutmuyorlar), ve evet, hepsinin quack()
metotu var. Bir tanesinin davranışı bir ördek için azıcık tuhaf, ancak yine de hepsi ortak bir metot ismini paylaşıyor ve Rust hepsini tip emniyetli bir şekilde bir araya getiriyor.
Tip emniyeti müthiş bir şey. Eğer tip emniyeti olmasaydı, ortalığı çalışma zamanında darmaduman edecek bir kediyi vakvak kardeşlerin arasına sokabilirdik.
Şimdi saçma bir şey yapalım:
#![allow(unused)] fn main() { // and why the hell not! impl Quack for i32 { fn quack(&self) { for i in 0..*self { print!("quack {} ",i); } println!(""); } } let int = 4; let ducks: Vec<&Quack> = vec![&duck1,&duck2,&parrot,&int]; ... // quack! // quack! // squawk! // quack 0 quack 1 quack 2 quack 3 }
Ne diyebilirim ki? Vaklıyorsa ördektir. İlginç olan şey, özellikleri herhangi bir şeye ekleyebilirsiniz, sadece "nesnelere" değil. (quack
bir referans olarak iletildiği için, defererans operatörü ile sayıyı alabilirsiniz.)
Yine de bunu sadece kendi sandığınızda tanımladığınız tipler üzerinde veya o sandıkta tanımlanmış özelliklerle yapabilirsiniz. Yani standart kütüphaneyi yamalı bohçaya (monkey patch) çeviremezsini, ki bu da Ruby vatandaşlarının başka bir alışkanlığı. (Çok da bayılan bir şey değildir doğrusu.)
Şimdiye kadar Quack
bir Java arayüzü gibi davrandı ve isterseniz modern Java tipleri arayüzleri gibi çeşitli uygulamaları eğer gereken metotları sağladıysanız tipinize ekleyebilirsiniz. (Iterator
özelliğini hatırlayın.)
Şimdiye kadar, özellikler bir tip tanımının parçası değildi ve isterseniz özellikleri istediğiniz tipe ekleyebilirsiniz, ancak aynı sandık kısıtlamasını gözden kaçırmayın.
Quack
özelliğine sahip bütün nesneleri referans olarak da görebilirsiniz:
#![allow(unused)] fn main() { fn quack_ref (q: &Quack) { q.quack(); } quack_ref(&d); }
İşte bu Rust'ın anladığı şekilde alttiplemedir.
Burada "Programlama Dilleri Kapıştırmaca Dersleri" verdiğimiz için, Go'nun ilginç vaklama yaklaşımını da not etmek istiyorum. Eğer Go'da tanımlanmış bir Quack
arayüzü varsa ve bir tipin quack
metotu varsa, o tip Quack
arayüzünü açık bir tanıma gerek duymadan kendisine implement eder. Bu Java'nın "Her şey tek tek tanımlanacak!" yaklaşımına ters düşer ve tip emnyetini biraz tehlikeye soksa da "ördek tiplemeye" izin verir. (Duck-typing)
Fakat ördek tipleme ile alakalı bir sorun var. Kötü bir nesne yönelimli programlamanın ilk işaretçisi çok fazla metotun run
gibi her kalıba girebilecek isimlere sahip olmasıdır. "Eğer run()
(Çalıştır) metotu varsa, o zaman Runnable
(Çalıştırılabilir) olmalıdır" vaklıyorsa o ördektir kadar zarif gelmiyor. Yani istemeden bir Go
arayüzü o tipte tanımlı olabilir. Rust için mesela, Debug
ve Display
aynı anda fmt
metotunu barındırır, fakar iki özelliğin çok farklı anlamları vardır.
Yani, Rust özellikleri bildiğimiz anlamda çok biçimli nesne yönelimli programlamaya izin verir. Peki ya miras? İnsanlar genellikle "Miras alma"yı kastederken Rust genellikle "Arayüz mirasından" bahseder. extend
yerine her zaman implements
kullanmayı tercih eden bir Java programcısı gibi. Ve bu aslında Alan Jolub tarafından tavsiye edilen bir alışkanlıktır. Der ki:
James Gosling'in (Java'nın yaratıcısı) sunduğu bir Java kullanıcıları gurubu konferansında bir keresinde bulunmuştum. O akılda kalıcı soru - cevap kısmında birisi şöyle bir soru sordu: "Eğer Java'yı yeniden tasarlasaydınız, neyi değiştirirdiniz?". O da şöyle bir cevap verdi: "Sınıf anlayışını terk ederdim". İnsanların kahkahası sona erdikten sonra sorunun sınıflar olmadığını ancak "miras alma" işleminden ziyade (extends ilişkisi) olduğundan bahsetti. "Arayüz mirası" (implements ilişkisi) çok daha tercih edilesi. Miras almayı mümkün olduğunca az yapmanız faydanıza olur.
Java gibi bir dilde bile, çok fazla sınıf üretiyor olabilirsiniz!
Miras alma anlayışının çok ciddi sorunları var, fakat oldukça akla yatkın görünüyor. Aşırı geniş bir sınıf olan Animal
ı tasarlıyoruz ve içerisine çok faydalı şeyler ekliyoruz (tehlikeli olsa bile), ve bizim Cat
sınıfımız da bu faydalı şeyleri kullanabiliyor. Hepsi bu, kodları tekrar kullanmanın bir yolu. Ancak kodun yeniden kullanımı aslında başka bir konudur.
Miras alma ve arayüz mirası arasındaki farkı anlamak Rust'ı anlarken oldukça önemlidir.
Özelliklerin size başka metotlar sağladığını unutmayın. Iterator
özelliğini düşünün, next
metotunu tanımladıktan sonra başka metotları zahmetsizce tipinize eklemiş olursunuz. Modern Java arayüzlerindeki "varsayılan" metotlar gibi. Alttaki örnekte name
kısmını tanımlıyoruz ve upper_case
bizim yerimize tanımlanmış oluyor. İstersek upper_case
metotunu yeniden yazabiliriz, ama bu gerekli değildir.
#![allow(unused)] fn main() { trait Named { fn name(&self) -> String; fn upper_case(&self) -> String { self.name().to_uppercase() } } struct Boo(); impl Named for Boo { fn name(&self) -> String { "boo".to_string() } } let f = Boo(); assert_eq!(f.name(),"boo".to_string()); assert_eq!(f.upper_case(),"BOO".to_string()); }
Bu da bir çeşit kodu tekrar kullanma yöntemi, evet, ama bunun verinin kendisinde geçerli olmadığını sadece arayüzde tanımlı olduğuna dikkat edin.
Ördekler ve Genellemeler
Burada Rustla yazılmış, genelleme kullanılarak yazılan "ördek" fonksiyonu gözünüze anlamsız gelmiş olabilir:
#![allow(unused)] fn main() { fn quack<Q> (q: &Q) where Q: Quack { q.quack(); } let d = Duck(); quack(&d); }
Tip parametresi, Quack
özelliğini barındıran herhangi bir şeyi işaret eder. Buradaki quack
ve önceki bölümde tanımladığımız quack_ref
arasında önemli bir fark var. Fonksiyonun içeriği, çağıran her bir tip için ayrıca oluşturulur ve sanal metotlara ihtiyacımız yok, bu tarz fonksiyonlar tamamen "satır içi" (inline) olabilir. Burada Quack
tipi genellenen tip üzerinde bir kısıtlama olarak kullanılır.
Bu da genellenen quack
metotumuzun C++ muadili (const
a dikkat edin):
template <class Q>
void quack(const Q& q) {
q.quack();
}
Tip parametresinin herhangi bir şeyle kısıtlanmadığına dikkat edin.
Bu daha çok derleme zamanında çalışan bir ördek tiplemeye benziyor - vaklamayan bir tipi iletirsek derleyici quack
diye bir metot olmadığından bahsedecektir. En azından sonra derleme zamanında keşfediliyor. Go'da direkt bütün tipin Quackable
arayüzüne sahip olması daha da kötü şeylere sebep olabilir. Daha karmaşık template
fonksiyonları ve sınıfları berbat hata mesajlarına yol açacaktır çünkü bu sefer genellenen tipler üzerinde hiçbir
kısıtlama olmayacaktır.
Vaklayan nesne üzerindeki işaretçilerle bir döngü tanımlamayı düşünebilirsiniz:
template <class It>
void quack_everyone (It start, It finish) {
for (It i = start; i != finish; i++) {
(*i)->quack();
}
}
Bu, her It
döngüleyici türü için çalışacaktır. Rust muadili az biraz daha ilginçtir:
#![allow(unused)] fn main() { fn quack_everyone <I> (iter: I) where I: Iterator<Item=Box<Quack>> { for d in iter { d.quack(); } } let ducks: Vec<Box<Quack>> = vec![Box::new(duck1),Box::new(duck2),Box::new(parrot),Box::new(int)]; quack_everyone(ducks.into_iter()); }
Rust'taki döngüleyiciler ördek tipli değildir ancak ilişkili tipler Iterator
özelliğini barındırmalıdır ve ilgili örneğimizde Box<Quack>
için döngüleyici tanımlanıyor. Hangi tiple çalışacağı hakkında bir belirsizlik yoktur ve ilişkili veriler muhakkak Quack
'ı tanımlamalıdır. Bazen bir Rust fonksiyonu yazarken fonksiyon imzası yazmaktan Rust'tan bıkabilirsiniz, bu yüzden standart kütüphanenin kaynak kodlarını dikkatlice okumanızı şiddetle öneririm - ancak fonksiyon yazmak fonksiyon yazmaktan daha kolaydır! Örneğimizdeki tek tip parametresi döngüleyici tipidir, yani bu sadece vektör döngüleyicisiyle değil, Box<Duck>
dizisi olan her şeyle çalışacaktır.
Miras Alma
Nesne yönelimli tasarımlı ilişkili en yaygın sorun nesnelerin bir "onun türünden olma" (is-a) ilişkisiyle tanımlanmasıdır, sahip olma ilişkileri (has-a) genellikle ihmal edilir. GoF, "Design Patterns" (Dizayn örüntüleri) kitabında yirmi iki yıl önce "Birleşkeleri mirasa tercih edin" demiş.
İşte size iyi bir örnek: Bir şirketin çalışanlarını modellemek istiyorsunuz ve Calisan
sizin için iyi bir sınıf ismi gibi görünüyor. Sonra, "Yönetici" bir çalışan türüdür (yani doğru) ve kendi hiyerarşimizi Yonetici
'yi Calisan
ın alt sınıfı olarak inşa ediyoruz kurguluyoruz. Zekice görünse de değil. İsimleri belirlemeye kendimizi kaptırmışken çalışanların ve yöneticilerin aslında birbirinden farklı canlılar olduğunu düşündük aslında. Belki de Calisan
sınıfı sadece bir Roller
dizisine sahip olmalıdır ve yöneticiyi daha fazla yetkiye sahip bir çalışan olarak tanımlamalıyız?
Taşıtları düşünelim, bisikletten damperli kamyonlara kadar geniş bir skalası var. Araçları kategorize etmenin çok farklı yolları var, üzerinde gittiği yoldan (şehir içinde, tarlada, rayda), kullandığı enerji türüne (dizel, hibrit, elektrikli vs), insan mı yoksa yük taşımacılığında mı kullanıldığı gibi. Sınıfların sabit bir hiyerarşisi, tek bir bakış açısı dışında diğer bakış açılarının görmezden gelindiği anlamına gelir. Gördüğünüz gibi, araçları çok farklı şekillerde sınıflandırabilirsiniz!
Rust için birleşkeler çok daha önemlidir çünkü başka bir sınıftan işlevleri olduğu gibi devrealmak tembelce bir yöntemdir.
Ödünç alma denetimi açısından da birleşkeler önemlidir çünkü çeşitli yapıların (struct) hangi alanlarının kullandıldığının takibi yapılabilir. Bir alanın değişken referansı alınırken diğer alanın değişmez referası ödünç alınabilir; bu yüzden yapılar kullanım rahatlığı için kendi metotlarına sahip olmalıdır. (Yapının dışsal arayüzü ise özellikler üzerinden sağlanabilir.)
Ayrık referansların net bir örneği bunu daha anlaşılır kılacaktır. Kendi String
alanları olan bir yapı tanımladık ve tek bir String
'in değişken referansını aldık.
#![allow(unused)] fn main() { struct Foo { one: String, two: String } impl Foo { fn borrow_one_mut(&mut self) -> &mut String { &mut self.one } .... } }
(Rust'ın isimlendirme anlayışının da aynı zamanda bir öneğidir - bu tarz metot isimleri _mut
ile sona ermelidir.)
Şimdi ilk metotu tekrar kullanarak iki String
alanını da ödünç alan bir yapı tanımlayalım:
#![allow(unused)] fn main() { fn borrow_both(&self) -> (&str,&str) { (self.borrow_one_mut(), &self.two) } }
Çalışamaz! self
'in değişmez referansını aldık ve aynı zamanda self
'in değişebilir referansını almaya çalışıyoruz. Eğer Rust bu tarz durumlara izin verseydi değişmez referansın değişemeyeceğini garanti edemeyebilirdi.
Çözüm basit:
#![allow(unused)] fn main() { fn borrow_both(&self) -> (&str,&str) { (&self.one, &self.two) } }
Bunda bir sorun yok çünkü bunların ikisini ödünç alma denetçisi bunların ikisini de bağımsız ödünç almalar olarak tanımlar. Alanların gelişigüzel yapılar olduğunu düşünün, rastgele çağırdığınız metotlar bu alanlar üzerinde çalışırken bir hataya sebep olmayacaktır.
Miras almanın sınırlandırılmış ancak önemli bir türü Deref özelliğidir, ismi "Dereferans" operatörü olan *
ile gelir. String
tipi Deref<Target=str>
özelliğine sahiptir ve &str
tipi için çalışacak bütün metotlar aynı zamanda String
ile de çalışabilir! Benzer şekilde, Foo
ile çalışan bütün metotlar aynı Box<Foo>
üzerinden de doğruca çalışabilir. Başta biraz... kontrolsüz bir şekilde pratik görünse de aslında epey kullanışlıdır. Rust'ın içerisinde basit bir mantık var, ancak kullanımı o kadar da akılcı görünmüyor. Sadece değişken bir türün ödünç alındığı zaman daha basit davranması gerketiği zaman kullanılmalıdır.
Rust'ta özellikler birbirini miras alabilir:
#![allow(unused)] fn main() { trait Show { fn show(&self) -> String; } trait Location { fn location(&self) -> String; } trait ShowTell: Show + Location {} }
Son özellik iki ayrıksı özelliği de barındırıyor, istenirse kendi içine metotlar tanımlanabilir.
Şimdi alıştığımız şeye geri döndük:
#![allow(unused)] fn main() { #[derive(Debug)] struct Foo { name: String, location: String } impl Foo { fn new(name: &str, location: &str) -> Foo { Foo{ name: name.to_string(), location: location.to_string() } } } impl Show for Foo { fn show(&self) -> String { self.name.clone() } } impl Location for Foo { fn location(&self) -> String { self.location.clone() } } impl ShowTell for Foo {} }
Eğer Foo
türünden foo
diye bir değerim varsa, bu türün değişkeni &Show
ve &Location
'u karşıladığı gibi (ikisini de barındıran) &ShowTell
'i de karşılayacak.
İşte işimize yaracak ufak bir makro:
#![allow(unused)] fn main() { macro_rules! dbg { ($x:expr) => { println!("{} = {:?}",stringify!($x),$x); } } }
($x
ile gösterilen) tek bir argümanı alıyor ve bu argüman bir "ifade" olmalıdır. Bu değeri ekrana yazdırıyoruz ve veriyi ve metinin metinleştirilmiş hâlini ekrana yazdırıyoruz. C programcıları burada bıyık altından gülebilir, ancak eğer 1 + 2
(bir ifade) değerini verirsem stringify!(1 + 2)
bize "1 + 2" şeklinde karakter dizisi verecektir. Bu, bize biraz kodları incelemek için yardımcı olacaktır:
#![allow(unused)] fn main() { let foo = Foo::new("Pete","bathroom"); dbg!(foo.show()); dbg!(foo.location()); let st: &ShowTell = &foo; dbg!(st.show()); dbg!(st.location()); fn show_it_all(r: &ShowTell) { dbg!(r.show()); dbg!(r.location()); } let boo = Foo::new("Alice","cupboard"); show_it_all(&boo); fn show(s: &Show) { dbg!(s.show()); } show(&boo); // foo.show() = "Pete" // foo.location() = "bathroom" // st.show() = "Pete" // st.location() = "bathroom" // r.show() = "Alice" // r.location() = "cupboard" // s.show() = "Alice" }
İşte bu nesne yönelimli programlamadır, sadece alıştığınız türden değil.
Show
referansına iletilen show
değerinin dinamik olarak ShowTell
olmayacağına dikkat edin! Daha dinamik sınıf sistemlerine sahip dillerin size objenin bir sınıfın nesnesi olup olmadığını denetleme imkanı verdiğine ve bu dinamik türe göre çağrı yapma izni verdiğine dikkat edin! Aslında bu pek de iyi bir fikir değildir ve Rust'ta bunu yapmanın bir yol yoktur çünkü Rust Show
referansının aslında ShowTell
referansı olduğunu unutmuştur bile.
Her zaman seçimleriniz vardır; özellik nesneleri aracılığıyla çok biçimlilik ya da özellik kısıtlamaları ile biçimlendirilmiş genelleme tanımlarıyla tek biçimlilik. Modern C++ ve Rust standart kütüphanesi genelleme yolunu tercih eder, ancak çok biçimlilik yolu da hâlen daha tercih edilebilir. Her zaman neyin karşılığında neyi aldığınızı bilmeniz gerekir - genellemeler daha hızlı kod üretir ve satır içi (inline) kullanılabilirler. Bunun neticesinde kodunuz şişebilir. (code bloat) Fakat her şeyin mümkün olduğunca hızlı olmasına da gerek yoktur - olağan bir program akışında birkaç defa bu gerçekleşebilir.
Sonuç olarak:
- Sınıfların rolü veri ve özellikler (trait) arasında paylaştırılmıştır.
- Yapılar ve numaralandırmalar veri gizlemesine ve metot tanımlanabilmesine rağmen fazla işleve sahip değildir.
- Alttiplemenin sınırlandırılmış bir türü
Deref
özelliği ile sağlanabilir. - Özellikler bir veri tutmaz ancak her tipe uygunlanabilirler. (Yalnızca yapılara değil.)
- Özellikler, başka özellikleri miras alabilir.
- Özellikler metotlar sunabilir, kodların tekrar kullanımını mümkün kılarlar
- Özellikler size sanal metotlar (çok biçimlilik) ve genelleme sınırlamaları (tek biçimlilik) sunabilirler.
Ördek: Windows API
Geleneksel nesne yönetimli programlamanın en çok kullanıldığı yerlerden birisi GUI (Ç.N: düğmeli menüyü arayüzler işte) kütüphaneleridir. EditControl
ve ListWindow
, Window
türünden sınıflardır falan filan. Bu, GUI kütüphanelerine Rust bağlantıları yazmayı biraz daha zorlaştırır.
Rust'ta Win32 programlaması direkt yapılabilir, ve orijinal C'den daha az gariptir. C'den C++'a geçer geçmez daha sade bir şey yapmak istedim ve kendi nesne tabanlı kodlarımı yazdım.
Tipik bir Win32 API fonksiyonu ShowWindow'dur ve bir pencerenin görünüp görünmediğini denetlemek için kullanılır. Şimdi, EditControl
'ün kendisine özgü nitelikleri bulunur fakat hepsi Win32'nin HWND
("pencere yönetimi") opak değeriyle yapılır. EditControl
'ün aynı zamanda show
metotu olmasını isteyebilirsiniz, genelde miras alma yöntemiyle halledilir. Fakat bu tipin her işlevi miras almasını istemeyebilirsiniz! Rust size güzel bir çözüm sunar, Window
özelliğini düşünelim:
#![allow(unused)] fn main() { trait Window { // you need to define this! fn get_hwnd(&self) -> HWND; // and all these will be provided fn show(&self, visible: bool) { unsafe { user32_sys::ShowWindow(self.get_hwnd(), if visible {1} else {0}) } } // ..... oodles of methods operating on Windows } }
Şimdi EditControl
yapısı sadece bir HWHD
'ye sahip olabilir ve Window
'u eklemek tek bir metotu tanımlayarak mümkün olabilir. EditControl
ise Window
özelliğini miras alan bir yapıdır ve daha geniş bir arayüz tanımlar. ComboBox
gibi düşünün - EditControl
gibi davranır ve bir ListWindow
da özellik mirası aracılığıyla eklenebilir.
Win32 API'sı ("32" artık "32-bit" anlamına gelmiyor) özünde nesne yönelimlidir, ancak daha eski bir şekilde, Alan Key'in tanımından etkilenmiş bir şekilde: nesneler gizli veriler tutar ve mesajlar üzerinden işlenir. Yani Windows uygulamalarının kalbi bir mesaj döngüsüdür ve çeşitli pencereler (pencere sınıfları da denir) kendi metotlarını kendi anahtar ifadeleriyle uygularlar. WM_SETTEXT
diye bir mesaj vardır fakat bunun koda dökülüş hâli biraz daha farklıdır: bir etiketin yazısı değişebilir ya pencere başlığı değişir.
Burada daha anlaşılır ve minimal bir Windows GUI frameworkü görebilirsiniz. Bana göre çok fazla unwrap
öğeleri var ve bunların çoğu hata bile değil. Bu, "NWG"nin mesajlaşmanın dinamik doğasına ters düşmesiyle alakalı. Daha tip güvenli bir arayüz sunmak için, hatalar derleme zamanında sunuluyor.
Rust programlama kitabında "Nesne yönelimli nedir?" üzerine güzel bir tartışma mevcuttur.
Çeviri notu: Bu bölümün sonlarına doğru bir dolu hata yapmış olabilirim, çünkü bu son kısımları yazarken saat gecenin üçüne yaklaşıyor ve kafa olarak biraz yorgundum. :)
Türk yazılımcısı çoğunlukla nesne yönelimli kavramını C# ve Java aracılığıyla öğrendiği için nesne yönelimi kavramı dilde
class
kelimesinin sunulmasıyla bağdaştırılıyor ve bu epeyce bir kafa karışıklığı yaratıyor. Nesne tabanlı programlama, kendi özünde verilerin soyutlanıp yazılımcıya daha çok anlam ifade eden "bütünlere" dönüştürülmesiyle alakalı bir tekniktir ve bu tekniğin uygulanması için verinin gizlenmesi, yalnızca metotlarla erişim gibi ilkeler vardır.class
ile tanımlanan yaplar, bu ilkeleri otomatik olarak inşa etmeye yarar. Ortadaclass
adı hiç geçmese bile nesne tabanlı programlama yapılabilir ve Lua, Nim, Nix gibi diller buna iyi bir örnek oluşturur. İşin özü, JavaScript da bu sınıfların listesindeydi ancak sonradanclass
kelimesi bazı prototip işlemlerini otomatikleştirmek için eklendi. Eklenene dek, JavaScript her zaman nesne tabanlıydı ancak "fonksiyonel" ve "nesne tabanlı" olmak arasında kalmış bir dil gibi düşünüldü; aslında JavaScript her zaman "prototip" üzerinden işleyen nesne yönelimli bir dildi.Bir literatür hatası olarak, saf fonksiyonel diller aynı zamanda nesne yönelimli olamayacağı için fonksiyonel programlama ve nesne yönelimli programlama arasında bir zıtlık varmış gibi anlaşıldı. Sonradan Go, Rust gibi klasik nesne yönelimli diller sınıfına uymayan diller "fonksiyonel" diller olarak anlaşıldı. Rust'ta fonksiyonel esintiler bulunmasına rağmen Rust saf bir fonksiyonel dil değildir, nesne yönelimi ile Rust'ın alakası neyse fonksiyonel programlama ile Rust'ın alakası o seviyede diyebilirim. Ha, fonksiyonel programlama "işlevsel, işe yarayan programlama" demek değildir diye not edelim.
Peki, neden Rust'ta sınıflar yok? Bu bölümde bahsedildiği gibi sınıflar çok fazla işi bir anda halleder ve bu kesinlikle Rust'ın doğasına uymaz. Rust'ta her şey açık, belirgin ve bilindiktir. Rust, sınıfların yaptığı şeyleri yapmanıza izin verecek araçları size sunar ancak kolaylık olsun diye gizlice bir şeyler hazırlamaz. Bu yüzden, Rust nesne yönelimli bir dil gibi görünmese de aslında nesne yönelimini size sunar.
Eğer nesne yönelimli programlamaya karşı bakış açınızı gözden geçirmek ve nesne yönelimli programlamanın yaratıcısından da neyin ne olduğunu öğrenmek isterseniz, bu bağlantıya tıklayabilirsiniz.
Yazıları Nom ile Ayrıştırmak
Nom, (burada anlatıldığı şekilde) öğrenmeye değer bir metin ayrıştırma için kullanılan bir Rust kütüphanesidir.
Eğer CSV veya JSON gibi türü bilinen bir veri türünü ayrıştırmak istiyorsanız bu işin özelleşmiş kütüphanelerden birisi olan Rust CSV veya [Bölüm 4'te] bahsedilen JavaScript kütüphanelerinden birisine bakmak isteyebilirsiniz.
Aynı şekilde, ini veya toml. gibi yapılandırma dosyaları için onlara özel kendisine özgü kütüphanelere göz atabilirsiniz. (serde_json'den bildiğimiz Serde Frameworkü ile uyumlu çalıştığı için Toml için hazırlanan kütüphane ayrıca hoştur.)
Fakat belli bir standarda ait olmayan, keyfe keder bir şekilde hazırlanmış verilerı taramak karakter dizileriyle geçireceğiniz sıkıcı saatlere işaret ediyor da olabilir. İlk fikir regex olur, ancak regex bir yerden sonra alabildiğine mantıksız bir şeye dönüşebilir. Nom, metin ayrılmanın güçlü ve sadece basit araçları birleştirmekten ibaret olduğu güzel bir yol sunar. Aynı zamanda regexlerin bir sınırı vardır, mesela HTML taramak için regex kullanamazsınız., fakat Nom ile HTML ayrıştırabilirsiniz. Hatta kendi programlama dilinizi yazmayı düşündüyseniz, Nom öğrenmek bu zorlu yolculuğun ilk adımı olabilir.
Nom öğrenmek için müthiş rehberler var, ancak ben biraz sindirerek gitmek istediğim için en basit yerden başlamak istiyorum. Bilmeniz gereken ilk şey, Nom baştan aşağıya makrolardan oluşur, ikincisi Nom karakter dizileri yerine bayt dilimleriyle çalışmayı tercih eder. Birinci şey, Nom'u kullanırken dikkatli olmanız gerektiğine işaret eder çünkü hata mesajlarından hiçbir şey anlamayabilirsiniz. İkincisi Nom'u herhangi bir veri türüyle kullanabileceğinizi işaret eder, sadece "metin" ayıklamak için değil. Nom kullanmış kişiler ikili biçimleri deşifre etmek veya dosya başlıklarını anlamak için kullandı. "UTF-8" ile kodlanmamış metinlerle de çalışabilirsiniz.
Nom'un son versiyonları karakter dizileriyle de çalışabilmeyi başladı, fakat karakter dizileriyle çalışabilen makroların sonunda _s
bulunur.
#[macro_use] extern crate nom; named!(get_greeting<&str,&str>, tag_s!("hi") ); fn main() { let res = get_greeting("hi there"); println!("{:?}",res); } // Done(" there", "hi")
named!
isimli makro (varsayılan olarak &[u8]
tipinden) girdi alıp ve sivri parantezlerin ikinci argümanının tipinden geri dönen fonksiyonlar oluşturur. tag_s!
ise kendisine iletilen karakter dizisi ile eşleşir, ve değer genellikle verilenin türünden olur. (Eğer &[u8]
ile çalışmak isterseniz, bunun yerine tag!
kullanabilirsiniz.)
Tanımladığımız get_greeting
ayrıştırıcısını bir &str
ile çağırabiliriz ve bize IResult dönecektir, bir de elbette ki eşleşen veriyi.
Boşlukları görmezden gelmek isteyebiliriz, tag!
makrosunu ws!
ile sarmalarsak aradığımız "hi" kelimesini eşleştirirken bütün boşluklar görmezden gelinecektir:
named!(get_greeting<&str,&str>, ws!(tag_s!("hi")) ); fn main() { let res = get_greeting("hi there"); println!("{:?}",res); } // Done("there", "hi")
Sonuç daha önce olduğu gibi "hi" olacaktır, ardında kalan karakter dizisi boşlukları kaldırılmış bir şekilde "there" olacaktır!
Tamam, "hi" eşleşmesi tıkırında çalışıyor ama bir şey yaramıyor. Hadi sadece "hi" yerine hem "hi" hem de "bye" kısmını eşleştirelim. alt!
makrosu ("alternatif") |
ile ayrılmış ayrıştırıcılardan birisiyle eşleşir. Aynı şekilde burada boşlukları okunaklı olması için kullanabilirsiniz:
#![allow(unused)] fn main() { named!(get_greeting<&str>, ws!(alt!(tag_s!("hi") | tag_s!("bye"))) ); println!("{:?}", get_greeting(" hi ")); println!("{:?}", get_greeting(" bye ")); println!("{:?}", get_greeting(" hola ")); // Done("", "hi") // Done("", "bye") // Error(Alt) }
Sonuncu hatalı, çünkü "hola" ile eşleşen bir metnimiz yok.
Doğrusu IResult
tipini iyice anlamamız gerekiyor ki daha ileriye gidebilelim; fakat neden bunu bir "regex" ifadesiyle kıyaslamıyoruz?
#![allow(unused)] fn main() { let greetings = Regex::new(r"\s*(hi|bye)\s*").expect("bad regex"); let caps = greetings.captures(" hi ").expect("match failed"); println!("{:?}",caps); // Captures({0: Some(" hi "), 1: Some("hi")}) }
Doğrusu Regex göze daha sade görünüyor! Sadece parantez içine |
koyduk ve bir tarafına "hi" diğer tarafına "bye" yerleştirdik. İlk sonuç girdi olarak aldığımız karakter dizisi, ikincisi de eşleşen ifade. (|
regex için sözde "çeşitlilik (alternation)" operatörüdür, alt!
makrosuna ilham vermiştir.)
Fakat bu basit bir regex olsa bile bir anda herkes karmaşıklaşabilir. İşin ilginci metinlerde sıkça kullanılan *
ve (
gibi karakterlerden kaçınmanız gerekir ve (hi)
veya (bye)
ile eşleşen bir regex ifadesi yazmak isterseniz sevimli regeximix \s*((hi | bye))\s*
gibi ucube bir hâl alacaktır. Bunun Nom muadili, gayet anlaşılır bir biçimde alt!(tag_s!("(hi)") | tag_s!("(bye)"))
şeklindedir.
İşin kötüsü regex
kütüphanesi ağır bir bağımlılıktır. Ananıza babanıza ancak verebileceğiniz bu i5 işlemcili laptota "Merhaba Dünya" seviyesi Nom örneklerinin derlenmesi sadece 0.55 saniye sürüyor. Fakat aynı şey regex için 0.90 saniye sürüyor. Aynı şekilde strip
komutu uygulanmış ikili programın boyutu 0.3Mb tutarken (Statik linklenmiş bir Rust programının tutabileceği en küçük boyut) Regex örneği için 0.8Mb tutmaktadır. (Ç.N: Gözünüze bunlar anlamsız salt istatiksel veriler gibi görünebilir, ancak program büyüdükçe bu kütüphaneler kullanıldıkça bu farkın nasıl da katlanarak artacağını gözünüzde canlandırın.)
Nom Ayrıştırıcısı Bize Ne Döner?
IResult tipi standart Result
tipinden daha çok şey döner. Üç ihtimal var:
Done
- başarılı - sonucu ve geri kalan baytları alırsınız.Error
- ayrıştırma başarısız - bir hata alırsınız.Imcomplete
- (tamamlanmadı) daha fazla veriye ihtiyaç vardır.
Hata ayrışma çıktısını bize dönebilen herhangi bir veriyi argüman olarak alan genellenen bir dump
fonksiyonu yazabiliriz. Bu örnek aynı zamanda bize bildiğimiz Result
'u dönen to_result
metodununu nasıl kullanılabileceğini de gösterir - bu metodu veriyi ya da hatayı istediğiniz durumların çoğunda sıkça kullanacaksınızdır.
#[macro_use] extern crate nom; use nom::IResult; use std::str::from_utf8; use std::fmt::Debug; fn dump<T: Debug>(res: IResult<&str,T>) { match res { IResult::Done(rest, value) => {println!("Done {:?} {:?}",rest,value)}, IResult::Error(err) => {println!("Err {:?}",err)}, IResult::Incomplete(needed) => {println!("Needed {:?}",needed)} } } fn main() { named!(get_greeting<&str,&str>, ws!( alt!( tag_s!("hi") | tag_s!("bye")) ) ); dump(get_greeting(" hi ")); dump(get_greeting(" bye hi")); dump(get_greeting(" hola ")); println!("result {:?}", get_greeting(" bye ").to_result()); } // Done "" "hi" // Done "hi" "bye" // Err Alt // result Ok("bye")
Ayrıştırıcılar bize ayrıştırılmamış verileri de dönüyor ve yeterince girdi almadıklarını da ortaya çıakrırlar, fakat genellikle to_result
'u tercih edeceksiniz.
Ayrıştırıcıları Birleştirmek
Selamlama örneğimizle devam edelim ve "hi" veya "bye", artı bir isimden oluşan bir selamlama tasarlayalım. nom::alpha
alfabetik karakter serileriyle eşleşecek pair!
ise iki ayrıştırıcıyı tek bir demekte birleştirecektir.
#![allow(unused)] fn main() { named!(full_greeting<&str,(&str,&str)>, pair!( get_greeting, nom::alpha ) ); println!("result {:?}", full_greeting(" hi Bob ").to_result()); // result Ok(("hi", "Bob")) }
Şimdi, selamlayıcımızın pek sosyal olduğunu veya kimsenin adını bilmediğini de hesaba katalım, ismi opsiyonel yapalım. Doğal olarak demetteki ikinci veri bir Option
olacaktır.
#![allow(unused)] fn main() { named!(full_greeting<&str, (&str,Option<&str>)>, pair!( get_greeting, opt!(nom::alpha) ) ); println!("result {:?}", full_greeting(" hi Bob ").to_result()); println!("result {:?}", full_greeting(" bye ?").to_result()); // result Ok(("hi", Some("Bob"))) // result Ok(("bye", None)) }
Selamlama için kullandığımız ayrıştırıcı ile isimleri yakalayan ayrıştırıcıyı birleştirmenin ve isim yakalamayı opsiyonel yapmanın ne seviye kolay olduğuna dikkat edin. Bu Nom'un geldiği gücün kaynağıdır ve bu yüzden ona "ayrıştırıcıları birleştiren kütüphane" (parse combinator library) denir. Basit ayrıştırıcılardan birleşerek inşa olan karmaşık ayrıştırıcılar inşa edebilir ve bunları teker teker test edebilirsiniz. (Buna eşdeğer bir regex bir Perl programı gibi görünmeye başlardı: çünkü regexlerin birleşmesi pek hayra alamet değildir.)
Fakat, henüz istediğimiz noktaya varamadık! full_greeting(" bye ")
bize bir Incomplete
hatası olarak dönecektir. Nom için "bye"dan sonra isim gelmelidir ve bu yüzden bizden isim namına bir şeyler isteyecektir. Bu bir akış ayrıştırıcısının (streaming parser) çalışmasının nasıl çalışması gerektiğidir, bu sayede dosyaları parça parça iletebilirsiniz; ancak burada Nom'a girdinin yetersiz olacağını bildirmemiz gerekir.
#![allow(unused)] fn main() { named!(full_greeting<&str,(&str,Option<&str>)>, pair!( get_greeting, opt!(complete!(nom::alpha)) ) ); println!("result {:?}", full_greeting(" bye ").to_result()); // result Ok(("bye", None)) }
Numaraları Ayrıştırmak
Nom bir dizi rakam serisini taramaya yarayan digit
fonksiyonuna sahiptir. map!
kullanarak bir yazıyı bir sayıya dönüştürebilir ve bir Result
tipi içinde geri dönebiliriz.
#![allow(unused)] fn main() { use nom::digit; use std::str::FromStr; use std::num::ParseIntError; named!(int8 <&str, Result<i8,ParseIntError>>, map!(digit, FromStr::from_str) ); named!(int32 <&str, Result<i32,ParseIntError>>, map!(digit, FromStr::from_str) ); println!("{:?}", int8("120")); println!("{:?}", int8("1200")); println!("{:?}", int8("x120")); println!("{:?}", int32("1202")); // Done("", Ok(120)) // Done("", Err(ParseIntError { kind: Overflow })) // Error(Digit) // Done("", Ok(1202)) }
Burada Result
'a dönüşebilen bir IResult
ayrıştırıcısı elde ederiz - ve elbette ki, burada mümkün olan birden çok hata vardır. Fonksiyonların içeriklerinin aynı olduğuna dikkat edin, esas dönüşüm fonksiyonun döndüğü tipe bağlıdır.
Sayıların işareti olabilir. Sayıları bir çift parça hâlinde yakabilirsiniz; önce bir işaret gelir ardından rakam gelir.
Mesela:
#![allow(unused)] fn main() { named!(signed_digits<&str, (Option<&str>,&str)>, pair!( opt!(alt!(tag_s!("+") | tag_s!("-"))), // maybe sign? digit ) ); println!("signed {:?}", signed_digits("4")); println!("signed {:?}", signed_digits("+12")); // signed Done("", (None, "4")) // signed Done("", (Some("+"), "12")) }
Eğer hedefe odaklıysanız ve ara sonuçları atlamak istiyorsanız, recognize!
istediğiniz şeyi verebilir.
#![allow(unused)] fn main() { named!(maybe_signed_digits<&str,&str>, recognize!(signed_digits) ); println!("signed {:?}", maybe_signed_digits("+12")); // signed Done("", "+12") }
Bu teknikle noktalı sayıları da yakalayabiliriz. Bu eşleşmeler üzerinden bayt dilimlerinden karakter dizilerine ulaşıyoruz. tuple!
, pair!
'in oluşturulan demetle ilgilenmediğimiz türünden bir muadili. complete!
ise "yarım kalan selamlama"da yaşadığımız sorunu çözmek için kullandığımız bir araç - "12", noktalı olmasa da aslında geçerli bir sayıdır.
#![allow(unused)] fn main() { named!(floating_point<&str,&str>, recognize!( tuple!( maybe_signed_digits, opt!(complete!(pair!( tag_s!("."), digit ))), opt!(complete!(pair!( alt!(tag_s!("e") | tag_s!("E")), maybe_signed_digits ))) ) ) ); }
Yardımcı olacak minik bir makro tanımlayarak bazı geçerli testler üretebilriz. Bu testler, floating _point
verilen metinden sayı yakalayabildiyse geçerli sonuç verecektir.
#![allow(unused)] fn main() { macro_rules! nom_eq { ($p:expr,$e:expr) => ( assert_eq!($p($e).to_result().unwrap(), $e) ) } nom_eq!(floating_point, "+2343"); nom_eq!(floating_point, "-2343"); nom_eq!(floating_point, "2343"); nom_eq!(floating_point, "2343.23"); nom_eq!(floating_point, "2e20"); nom_eq!(floating_point, "2.0e-6"); }
(Makrolar kodu biraz kirletilmiş gösterse de, testlerinizi hazırlamak faydalı bir şeydir.)
Ve metinleri ayrıştırıp noktalı sayılara çevirebilirsiniz. Burada akışa odaklanacağım ve hatayı uzaklaştıracağım:
#![allow(unused)] fn main() { named!(float64<f64>, map_res!(floating_point, FromStr::from_str) ); }
Lütfen birbirinden karmaşık testler ayrıştırıcılar oluşturmanın adım adım nasıl mümkün olduğuna dikkat edin, her bir parçayı ayrıca test edebilirsiniz. Bu, birleştirilmiş ayrıştırıcıların regexler üzerinde güçlü bir avantajıdır. Bu gayet klasik bir programlama taktiği olan "böl ve yönettir".
Çeşitli eşlemeler üzerinde işlemler
Sabit bir sayıda örüntüyü yakalayan ve bir Rust demeti dönen pairs!
ve tuple!
ile tanıştık.
Bir de many0
ve many1
var - ikisi de değişken sayıda örüntüyü bir vektör içerisinde tanımlar. İkisi artasındaki fark birisinin "sıfır veya daha fazla", diğerinin ise "bir veya daha fazla" şeyi yakalalıyor olmasıdır. (regexteki *
ve +
karakterini düşünün) Yani, many1!(ws(float64))
, "1 2 3" şeklinde bir karakter dizisi bize vec![1.0, 2.0, 3.0]
olarak dönmeyi tercih edecek ancak boş bir karakter dizisinde hata verecektir.
fold_many0
ise bir azaltma (reduce) işlemidir. Ayrıştırılan değerler tek bir değerde bir ikili operatör kullanılarak tek bir değerde toplanır. Mesela, Rust programcıları döngüleyicilerin içeriğini toplamak kullanmak için sum
gelmeden önce ne yapıyorsa bu da ona benzer; aşağıdaki fold
işleyici (accumulator) için bir başlangıç değerine (burada sıfır) sahiptir ve işleyicinin ne yapacağını bildirmesi için +
operatörünü kullanır.
#![allow(unused)] fn main() { let res = [1,2,3].iter().fold(0,|acc,v| acc + v); println!("{}",res); // 6 }
Nom muadili şöyledir:
#![allow(unused)] fn main() { named!(fold_sum<&str,f64>, fold_many1!( ws!(float64), 0.0, |acc, v| acc + v ) ); println!("fold {}", fold_sum("1 2 3").to_result().unwrap()); //fold 6 }
Şimdiye dek bütün ifadeleri yakalamaya çalıştık veya eşleşen baytları recognize!
ile aldık:
#![allow(unused)] fn main() { named!(pointf<(f64,&[u8],f64)>, tuple!( float64, tag_s!(","), float64 ) ); println!("got {:?}", nom_res!(pointf,"20,52.2").unwrap()); //got (20, ",", 52.2) }
Karmaşık ifadeler için, ayrıştırıcıların bütün sonuçlarını almış olmak bizi dağınık bir çalışma prensibine sokar! Daha iyisini yapabiliriz.
do_parse!
sadece ihtiyacınız olan değerlere erişmesinize izin verir. Yakalanan veriler >>
ile ayrılır - ilginizi çeken verileri isim: ayrıştırıcı
formatında işaretleyebilirsiniz. Son olarak, parantezler arasında kodunuzu belirtirsiniz.
#![allow(unused)] fn main() { #[derive(Debug)] struct Point { x: f64, y: f64 } named!(pointf<Point>, do_parse!( first: float64 >> tag_s!(",") >> second: float64 >> (Point{x: first, y: second}) ) ); println!("got {:?}", nom_res!(pointf,"20,52.2").unwrap()); // got Point { x: 20, y: 52.2 } }
İlgilenmediğimiz değerleri (bu örnekte olduğu gibi virgül) bir isme bağlamıyoruz ve iki noktalı sayıyı bir yapı oluşturmak için geçici isimlere atıyoruz. Parantezler içinde kalan kısım ise bir Rust kodu olmalı.
Aritmatik İfadeleri Ayrıştırmak
Gerekli bilgiler sayesinde basit aritmatik ifadeleri ayrıştırabiliriz. İşte regexlerle yapamayacağınız şeylere güzel bir örnek.
Aşağıda yapmaya çalıştığımız şey ifadelerimizi ayıklayacak şeyi basitten karmaşığa doğru inşa etmektir. İfadeler eklenip çıkartılabilir terimlerden (term) oluşur. Terimler ise çarpılıp bölünebilir faktörlerden oluşur. Ve (şimdilik), faktörler sadece noktalı sayılardır:
#![allow(unused)] fn main() { named!(factor<f64>, ws!(float64) ); named!(term<&str,f64>, do_parse!( init: factor >> res: fold_many0!( tuple!( alt!(tag_s!("*") | tag_s!("/")), factor ), init, |acc, v:(_,f64)| { if v.0 == "*" {acc * v.1} else {acc / v.1} } ) >> (res) )); named!(expr<&str,f64>, do_parse!( init: term >> res: fold_many0!( tuple!( alt!(tag_s!("+") | tag_s!("-")), term ), init, |acc, v:(_,f64)| { if v.0 == "+" {acc + v.1} else {acc - v.1} } ) >> (res) )); }
İfadelerimiz daha net ifade edilmiş oldu - bir ifade bir terimden ve artılı eksili daha fazla terimden oluşur. Onları biriktirmiyoruz, fakat uygun operatör vasıtasıyla işliyoruz. (fold) (Bunun gibi durumlarda Rust, ifadenin türünü tam olarak anlayamadığından işin içinden çıkamaz ve bir ipucu ister). Bu sayede işlem önceliğini sağlamış oluruz - *
her zaman +
gibi şeyler.
Noktalı sayılar için test ifadelerine ihtiyacımız olacak, ve bunun için bir sandık var..
Cargo.toml dosyanıza approx=0.1.1
satırını ekleyin ve işimize bakalım:
#![allow(unused)] fn main() { #[macro_use] extern crate approx; ... assert_relative_eq!(fold_sum("1 2 3").to_result().unwrap(), 6.0); }
Bir küçük bir test makrosu yazalım. stringify!
, ifadeyi bir karakter dizisi ifadesine dönüştürür ve bunu expr
içerisine argüman olarak iletebiliriz, sonra da sonucu Rust'ın bulacağı ifadenin sonucu ile kıyaslayalım:
#![allow(unused)] fn main() { macro_rules! expr_eq { ($e:expr) => (assert_relative_eq!( expr(stringify!($e).to_result().unwrap(), $e) ) } expr_eq!(2.3); expr_eq!(2.0 + 3.0 - 4.0); expr_eq!(2.0*3.0 - 4.0); }
Şükela - sadece birkaç satırla ifade işleyicisi tanımladık! Daha iyi olabilir. factor
içindeki numaralara bir alternatif ekleyebiliriz - parantez içindeki ifadeler için:
#![allow(unused)] fn main() { named!(factor<&str,f64>, alt!( ws!(float64) | ws!(delimited!( tag_s!("("), expr, tag_s!(")") )) ) ); expr_eq!(2.2*(1.1 + 4.5)/3.4); expr_eq!((1.0 + 2.0)*(3.0 + 4.0*(5.0 + 6.0))); }
Şükelalık ifadenin artık terimler açısından özyinemeli (recursively) olarak çağrılmasıdır!
delimited!
makrosunun özel sırrı parantezlerin iç içe olabilmesidir - Nom parantezlerin kapandığından emin olacaktır.
Regexin yapabileceklerinin çok çok ötesindeki ve strip
uygulanmış ikili dosyamız sadece 0.5Mb, ki hâlen daha ekrana "Merhaba Dünya" yazdıran regex programımızın yarısı kadar ediyor bu.
Rust ve Çektirdiği Çile
Rust'ın öğrenmesi pek çok "ana akım" dillere kıyasla öğrenmesi zor dillerden olduğunu söylemek doğru olur. Bazı istisnası insanlar o kadar da zor bulmuyor, fakat "istisna" ne anlama gelir buna dikkat edin - onlar istinaidir. Çoğu insan başta zorlanır ama sonra başarır. Başlangıçta çektiğiniz çilelerle neticede elde edeceğiniz şeyler arasında bir bağlantı yoktur.
Hepimiz bir yerlerden geliyoruz ve bu Python gibi "dinamik" veya "C++" gibi "statik" ana akım dilleriyle muhatap olmuş olduğumuz anlamına gelir. Her koşulda, Rust programlama anlayışımızı yeniden inşa etmeye zorlayacak kadar farklıdır. Zeki ve tecrübeli insanlar bu işin içine girip kendi zekiliklerinin fayda etmediğini görünce bir hayal kırıklığına uğruyorlar; kendisine yeterince değer vermeyenler de düşündükleri kadar "zeki" olmadıklarını düşünüyorlar.
Dinamik programlama tecrübesi olanlar (buna Java'yı da dahil edebilirim) her şeyin bir referans ve referansların da varsayılan olarak değişebilir olduğunu düşünür. Çöp toplayıcıları da bellek açısından emniyetli programlama yapmamıza yardımcı olur. Daha fazla bellek kullanımı ve öngörülememezlik pahasına da olsa JVM'i daha hızlı hâle getirmek için çok şey yapıldı. Bazen buna değer diye düşünülür, hiç modası geçmeyen bir fikir programcı üretkenliğinin bilgisayar performansından daha önemli olduğudur.
Fakat dünyadaki pek çok bilgisayarın - mesela arabalardaki gaz kelebeğini kontrol etmek gibi gibi hayati önemde şeyler yapan minik bir bilgisayarın - en ucuz laptop kadar kaynağa sahip değildir ve olaylara eşzamanlı (realtime) olarak cevap vermesi gerekir. Aynı şekilde yazılım altyapılarının doğru, sağlam ve hızlı olması gerekir. (Eski bir mühendislik kuralı). Şimdiye dek bu tarz işler kendi yapısı gereği hiçbir emniyet bulundurmayan C ve C++ ile yapıldı - bu raddedeki emniyetsizliğin toplam maaliyeti dikkat edilmesi gereken genel şey oldu. Bir programı çalışır hâle getirmek kolaydır, ama esas olay buradan sonra başlar.
Sistem programlama dillerinde bir çöp toplayıcısı bulunamaz çünkü onlar her şeyin üzerine inşa edildiği bina temelidir. Kaynakların sizin ihtiyaç duyduğunuz ve uygun gördüğünüz şekilde kullanılmasını sağlarlar.
Eğer çöp toplayıcısı olmazsa bellek başka türde şekillerde yönetilir. Manuel bellek yönetiminde belleği alırsınız, kullanırsınız ve bunu geri verirsiniz - kulağa kolay gibi gelse de aslında oldukça zordur. Üretken ve facia yaratmaya elverişli bir C programcısı olmak sizin için bir hafta sürer - ancak ne yaptığını bilen, bütün muhtemel hataları kontrol eden iyi bir C programcısı olmak yıllar sürer.
Rust belleği modern C++ gibi yönetir, nesneler yok edildiği zaman arka kalan bellek tekrar kullanılabilir. Box
ile heap üzerinde bellek tahsis edebilirsiniz, ancak bu fonksiyon bittiğinde Box
"kapsam sonuna" ulaşmış olur. new
diye bir şey var ancak delete
diye bir şey yok. File
oluşturabilirsiniz, (önemli bir kaynak olan) dosya iş bittiğinde kendiliğinden kapatılır. Buna Rust'ta düşürme (dropping) denir.
Kaynakları paylaşmanız gerekir, her şeyin kopyasını üretmek verimsizdir, işi ilginç kılar da budur. C++'da da referanslar vardır ve Rust referansları daha çok C işaretçilerine benzer - veriye ulaşmak için *r
kullanmanız gerekir ve veri referansını iletmek için de &
kullanırsınız.
Rust'ın ödünç alma denetçisi (borrow checker) esas veri yok edildikten sonra referansının var olma ihtimalini ortadan kaldırır.
Tip Çıkarımı
"Statik" ve "dinamik" arasındaki fark her şey değildir. Her şeyde olduğu gibi bu da çok katmanlı bir konudur. C, statik tipli (her değişkenin derleme zamanında tipi vardır) ancak zayıf tipli (weakly typed) (mesela void*
her şeye işaret edebilir) bir dildir; Python ise dinamik tipli (Tip değişkende tanımlanmaz değil veriyle beraber gelir) fakat güçlü tipli (strongly typed) bir dildir. Java ise statik/güçlümtraktır (akla uygun görünen fakat tehlikeli olabilen veri dönüştürme (?) mekanizmasıyla) ve Rust ise statik/güçlüdür; çalışma zamanında gizlice veri dönüştürmez.
Java her bir tipin ne olduğunun detaylıca yazdırdığı yaygın olarak bilinirken Rust tipleri tahmin (infer) etmeyi sever. Bu genelde zekicedir ancak fakat bazen hangi tiple çalıştığınızı merak edersiniz. Mesela let n = 100
gibi bir ifade görürsünüz ve merak edersiniz - bu sayının değeri ne? Varsayılan olarak bu i32
dir, dort bitlik işaretli sayı. Herkes C'nin belirsiz sayı tiplerinin (int
ve long
gibi) kötü bir fikir olduğundan emindir, açık olmak en iyisidir. Bazen tipi belirtebilirsiniz, mesela let n: u32 = 100
gibi ya da sayıyla beraber tipi de belirtebilirsiniz; let n = 100u32
gibi. Ama tip çıkarımı dediğimi şey bundan ibaret değil! Eğer let n = 100
yazarsanız rustc
bunun bir çeşit sayı olduğunu bilir. n
değişkenini bir yere iletirseniz ve fonksiyon u64
bekliyorsa n
'in tipi u64
olacaktır!
Bu noktadan sonra eğer u32
bekleyen bir yerde n
değişkenini kullanırsanız rustc
buna izin vermeyecektir çünkü n
'in tipi u64
olarak işaretlenmiştir ve bunu dönüştürmenin gizli ve kolay bir yolu olmayacaktır. İşte bu güçlü tiplemedir - "Interger overflow"dan muzdarip olana dek hayatınızı kolaylaştıracak ufak tip dönüşümleri Rust'ta yoktur. Açıkça n
değerini n as u32
olarak iletmeniz gerekir - Rust'ta tipler böyle dönüştürülür. Neyse ki, rustc
sorunları müdahale edilebilirken vermekte iyidir - derleyicinin tavsiyesiyle sorunu düzeltebilirsiniz.
Bu sayede Rust açık tip ifadeleri olmaksızın yazılabilir:
#![allow(unused)] fn main() { let mut v = Vec::new(); // v is deduced to have type Vec<i32> v.push(10); v.push(20); v.push("hello") <--- Bunu yapma işte, yapma }
Bir sayı vektörüne karakter dizisi iletememek bir sorun değil, özelliktir. Dinamik tipleme esnek olduğu kadar beladır da.
(Eğer bir vektör tipine hem sayı hem de karakter dizisi iletmeniz lazımsa, Rust'ın enum
tipleri bu işi emniyetlice görür.)
Bazen ufak bir ipucu vermeniz gerekir. collect
çok güzel bir döngüleyici metotudur, ama bazen ipucuna ihtiyaç duyar. Mesela char
üzerinde çalışan bir döngüleyicim var. collect
iki farklı biçimde çalışır.
#![allow(unused)] fn main() { // a vector of char ['h','e','l','l','o'] let v: Vec<_> = "hello".chars().collect(); // a string "doy" let m: String = "dolly".chars().filter(|&c| c != 'l').collect(); }
Bazen değişken tipinden emin olamazsanız bunun da ufak bir yolu var, o da rustc
'yi işleme alınan tipi bir hata mesajında yazdırmaktır:
#![allow(unused)] fn main() { let x: () = var; }
rustc
aşırı spesifik bir tip seçebilir. Burada farklı referansları &Debug
vektörüne koyuyoruz ancak tipleri açıkça belirtmemiz gereklidir.
#![allow(unused)] fn main() { use std::fmt::Debug; let answer = 42; let message = "hello"; let float = 2.7212; let display: Vec<&Debug> = vec![&message, &answer, &float]; for d in display { println!("got {:?}", d); } }
Değişebilir Referanslar
Kural basit: bir anda sadece değişken referans olabilir. Bunun sebebi değişimin her yerde gerçekleşmesinin önüne geçmek. Küçük programlar yazarken fark etmiyor olabilirsiniz ancak büyük kod yapılarında başınıza ciddi bela olabilir.
Bir diğer kural ise ortada değişemez referanslar varken değişebilir referans elde edemiyor oluşunuzdur. Aksi takdirde kimse bu değişemez referansların değişemeyeceğini garantezi edemezdi. C++ da değişemez referanslara sahiptir (mesela const string&
) gibi ancak başka birisinin string&
referansı alıp veriyi değiştiremeyeceğini garanti edemez.
Her referansın değişebilir olduğu dillerle çalışmaya alışıksanız bu size ters gelecektir. Emniyetsiz, "rahat" diller tamamen programcının kötü bir şey yapmayacağına ve kendi programını kesinlikle iyi bir şekilde anladığını düşünerek çalışır. Fakat bir kişiden daha fazla kişi tarafından geliştirilmiş büyük programlar bir kişinin programın ne olduğunu anlayıp anlamamasından çok daha ötededir.
İşin gıcık eden eden tarafı ödünç alma denetçisinin düşündüğümüz kadar zeki olmamasıdır.
#![allow(unused)] fn main() { let mut m = HashMap::new(); m.insert("one", 1); m.insert("two", 2); if let Some(r) = m.get_mut("one") { // <-- mutable borrow of m *r = 10; } else { m.insert("one", 1); // can't borrow mutably again! } }
Eğer None
elde etmişsek HashMap'ten hiçbir şey ödünç alınmıyor, yani burada gerçekten hiçbir şey ödünç alma kurallarını çiğnemiyor.
Hâliyle iş çirkinleşiyor:
#![allow(unused)] fn main() { let mut found = false; if let Some(r) = m.get_mut("one") { *r = 10; found = true; } if !found { m.insert("one", 1); } }
Rezalet olsa da işe yarıyor çünkü sadece "if" ifadesinde bir adet referasımız tutulmuş oluyor.
Daha iyisi HashMap
içindeki entry metotunu kullanmaktır.
#![allow(unused)] fn main() { use std::collections::hash_map::Entry; match m.entry("one") { Entry::Occupied(e) => { *e.into_mut() = 10; }, Entry::Vacant(e) => { e.insert(1); } }; }
Ödünç alma mekanizmasının bu sıralar sözcüksel olmayan yaşam süreleri (non-lexical lifetimes) hakkında daha yumuşak olacağı konuşuluyor.
Yine de ödünç alma mekanizması bazı önemli vakaları anlıyor. Mesela bir yapınız varsa alanları bağımsız olarak ödünç alınabilir. Birleşke mantığı işinize yarayacaktır, büyük yapılar (struct) kendi metotları olan daha ufak yapıları barındırmalıdır. Değişken metotları büyük yapılar içinde tanımlamak, tek bir alan değiştirilmiş olsa bile bütün yapının değiştirilemeyeceği durumlara yol açacaktır.
Değişebilir verilerin söz konusu olduğu durumlarda verilerin parçalarının bağımsız olarak işlendiği özel durumlar vardır. Örneğin değişebilir bir diliminiz varsa, split_at_mut
size değişebilir iki referans verecektir. Bu gayet emniyetlidir çünkü Rust bu iki dilimin kazara aynı verileri sahiplenmeyeceğinden emin olacaktır.
Referanslar ve Yaşam Süreleri
Rust, referasın veriden daha uzun süre yaşadığı durumlara izin vermez. Aksi taktirde ölü veriye işaret eden "ölü referanslarımız" bulurdu - segfault
kaçınılmaz olurdu.
rustc
genelde fonksiyonlardaki yaşam süreleri hakkında mantıklı çıkarımlar yapabilir:
fn pair(s: &str, ch: char) -> (&str, &str) { if let Some(idx) = s.find(ch) { (&s[0..idx], &s[idx+1..]) } else { (s, "") } } fn main() { let p = pair("hello:dolly", ':'); println!("{:?}", p); } // ("hello", "dolly")
Gayet emniyetlidir çünkü burada herhangi bir sınırlayıcı yoktur. rustc
iki karakter dizisinin de fonksiyona aktarılan karakter dizisinin referansları olduğuna karar verir.
Aslında fonksiyon tanımı şu şekildedir:
#![allow(unused)] fn main() { fn pair<'a>(s: &'a str, ch: char) -> (&'a str, &'a str) {...} }
Bu noktasyon çıktıdaki karakter dizilerinin en fazla girdideki karakter dizileri kadar yaşayacağına karar verir. Yaşam sürelerinin aynı olduğu anlamına gelmez bu, onları istediğimiz an düşürebiliriz, sadece "s
"ten fazla yaşamayacağını belirtiriz.
Yani, rustc
yaşam süresi saptaması (lifetime ellision/?) işimizi biraz daha rahatlatır.
Şimdi eğer fonksiyon iki karakter dizisi alırsa, yaşam sürelerini açıkça belirtmemiz gerekir ki hangi çıktının hangi girdiyi referans aldığını bilmiş olalım.
Bir referans tutan bir yapı tanımladığımız sırada neyin ne kadar yaşam ömrü olduğunu belirtmeliyiz:
#![allow(unused)] fn main() { struct Container<'a> { s: &'a str } }
Burada da yapının referanstan daha uzun süre yaşamayamayacağını vurgulamış oluyoruz. Hem yapılar hem de fonksiyonlar için yaşam süresinin <>
içerisinde tıpkı tip belirtilir gibi belirtilmesi gereklidir.
Kapamalar gayet akılcı ve güçlü bir özelliktir - Rust kapamalarının gücü buradan gelir. Eğer onları bir yerde saklamak isterseniz onlara bir yaşam süresi belirtmeniz gerekir. Çünkü kapamalar kendi özünde çevresinden referanslar alan ve çağrılabilen bir yapıdır. Mesela m
ve c
şeklinde değişemez referanslar alabilen linear
kapamasına bakalım.
#![allow(unused)] fn main() { let m = 2.0; let c = 0.5; let linear = |x| m*x + c; let sc = |x| m*x.cos() ... }
linear
ve sc
kapamalarının ikisi de Fn(x: f64) -> f64
özelliğine sahiptir ancak ikisi de aynı yaratık değildir - ikisinin de farklı tipleri ve boyutları vardır! Eğer saklamak isterseniz tiplerini Box<Fn(x: f64)->f64 + 'a>
olarak belirtilmelilerdir.
JavaScript veya Lua'da kapamaların nasıl da su gibi aktığına görmüşseniz size biraz sinir bozucu gelecektir. Ancak C++ da Rust ile aynı şeyi yapar ve sanal çağrılar için ufak bir bedel karşılığında farklı türden kapamaları saklamak için std::function
'a ihtiyaç duyar.
Karakter Dizileri
Rust'ta karakter dizilerinin başlangıçta sinir bozucu gelmesi olağandır. Onları üretmenin birden çok yolu vardır ve hepsi gereksiz detaylı gelir.
#![allow(unused)] fn main() { let s1 = "hello".to_string(); let s2 = String::from("dolly"); }
"hello" hâli hazırda bir karakter dizisi değil midir? Yani, bir şekilde. String
sahipli bir karakter dizisidir ve heap üzerinde yer tutar. Bir karakter dizisi kalıbı olan &str
(karakter dizisi dilimi) tipi ise (sabit olarak) çalıştırılabilir dosyanın içinde bekler ya da String
'ten ödünç alınarak oluşturulur. Sistem programlama dillerinin bu ayrıma ihtiyacı vardır - ufak bir mikrokontrollerı düşünür, azıcık RAM ve biraz daha fazla RM'u bulunur. Karakter dizisi kalıpları daha az enerji harcayan ve ucuz olan ROM'da ("read-only/salt okunur") depolanır.
Fakat bu C++'da çok daha kolay (diyebilirsiniz):
std::string s = "hello";
Kısaca evet, fakat bir karakter dizisinin örtükçe nasıl oluşturulduğunu da gizlemektedir. Rust bellek tahsis etme konusunda açık olmayı tercih eder, hâliyle to_string
gibi şeyler var. Öbür taraftan, C++ karakter dizilerini ödünç almak için c_str
kullanmalısınız ve C'nin karakter dizileri çok kullanışsızdır.
Neyse ki, Rust'ta işler çok daha iyi işliyor - String
ve &str
tiplerinin ikisinin de gerekli olduğunu bir kere kabul ederseniz. String
metotları çoğunlukla karakter dizisini değiştirmek içindir, mesela push
bir karakter ekler (alttan alta Vec<u8>
gibi çalışır). Fakat &str
metotlarının tamamına da sahiptir. Deref
mekanizması aracılığıyla bir String
aynı zamanda &str
olarak iletilebilir - bu yüzden nadiren fonksiyon tanımlarında &String
görürsünüz.
Çeşitli özellikler (trait) aracılığıyla &str
'den String
elde etmenin pek çok yolu var. Rust özelliklerin tiplerinin genellenerek çalışmasına izin verir. Pratik bir kural olarak, Display
özelliğine sahip her şey to_string
'e sahiptir; 42.to_string()
gibi.
Bazı operatörler beklediğiniz gibi davranmaz:
#![allow(unused)] fn main() { let s1 = "hello".to_string(); let s2 = s1.clone(); assert!(s1 == s2); // cool assert!(s1 == "hello"); // fine assert!(s1 == &s2); // WTF? }
Hatırlayın ki String
ve &String
birbirinden farklı tiplerdir ve ==
bu tarz komibasyonlarda tanımlı değildir. C++ programlamaya alışık bir kişi değerlerin yerine referansların konulduğunu görmeyi beklediğinden şaşırabilir. Ek olarak &s2
kendiğinden &str
olmayacaktır çünkü deref zorlaması bir &str
değişkeni veya argümanı atadığınız zaman çalışacaktır. (s2.as_str()
diye açıkça ifade etmek işinize yarayacaktır.)
Ancak bu harbiden bir "has***tir artık" denmeyi hakeder:
#![allow(unused)] fn main() { let s3 = s1 + s2; // <--- no can do }
İki String
değerini birleştiremezsiniz, ancak bir String
ile &str
'i birleştirebilirsiniz. Fakat &str
ile String
'i birleştiremezsiniz. Bu yüzden pek çok insan +
yerine format!
makrosunu tercih eder, biraz daha tutarlıdır ancak o kadar da verimli değildir.
Bazı karakter dizisi işlemleri de mevcuttur ancak farklı çalışır. Mesela pek çok dilin split
metotu vardır ki bu da bir karakter dizisini, karakter dizisi listesine dönüştürür. Rust'ta karakter dizisi metodu bir döngüleyici döner ve bunu "sonra" bir vektör içerisine toplayabilirsiniz.
#![allow(unused)] fn main() { let parts: Vec<_> = s.split(',').collect(); }
Eğer hızlıca bir vektör almak istiyorsanız biraz göze batabilir fakat yeni bir vektörü belelkte tahsis etmeden önce birkaç işlem yapabilirsiniz. Mesela parçalanmış bir metin içerisinden en uzun kelimeyi mi almak istiyorsunuz?
#![allow(unused)] fn main() { let max = s.split(',').map(|s| s.len()).max().unwrap(); }
(Eğer doş bir döngüleyici varsa maksimum değer de olmayacaktır ve bu durumu kontrol etmek için unwrap
kullanıyoruz.)
collect
metotu bize parçaların orijinal karakter dizisinden ödünç alındığı bir Vec<&str>
döner - sadece referanslar için bellekte alan tahsis etmemiz gerekir. C++'da çalıştığı gibi bir metot yok fakat son zamana dek her alt diziye ayrıca alan tahsis edilmesi gerekiyordu. (C++ 17 ile beraber Rust'taki &str
gibi çalışan std::string_view
geldi.)
Noktalı virgüller hakkında bir not
Noktalı virgüller bu dilde de zorunludur, fakat C'de noktalı virgül konulmaması gereken yerlerde Rust'ta da konulmaz, mesela {}
bloklarından sonra. Aynı zamanda enum
ve struct
içinde de onlara ihtiyaç yoktur. (Bu C'den gelen bir acayiplik) Fakat, eğer blok bir değer dönmeliyse noktalı virgüllere ihtiyaç kalmaz.
#![allow(unused)] fn main() { let msg = if ok {"ok"} else {"error"}; }
Bütün bir let
deyiminin ardından yine bir noktalı virgül koymak zorunda olduğumuza dikkat edin!
Eğer noktalı karakter dizisi kalıplarından sonra noktalı virgül koysaydık bize ()
dönerdi. (Nothing
veya void
gibi) Bu fonksiyondan değer dönerken karşılaşılan olağan bir hatadır.
#![allow(unused)] fn main() { fn sqr(x: f64) -> f64 { x * x; } }
rustc
size bu durumda açıklayıcı bir hata mesajıyla geri dönüş yapacaktır.
Rust, Haskell ve Ruby gibi expression-based bir dildir ve bu kavramı "ifade odaklı" olarak düşünebilirsiniz. Bu tarz dillerde her ifadenin bir değeri vardır
C++ ile Alakalı Konular
Rust'ta Değer Semantikleri Farklıdır
C++'da ilkel tipler gibi davranacak ve kendisini kopyalayacak tipler tanımlamak mümkündür. Ek olarak, bir değerin geçici bir bağımdan başka bir bağlama nasıl taşınacağını belirlemek adına taşıma oluşturucusu kullanılır.
Rust'ta ilkel tipler beklendiği gibi davranır fakat Copy
özelliği sadece kopyalanabilir türler içeriyorsa (yapılar, demetler veya numaralandırma) kullanıcının tanımladığı tiplere eklenebilir. Diğer tiplere Clone
eklenebilir ancak bu sefer de verilerin clone
metotunu çağırmanız gerekir. Rust, herhangi bir bellekte alan tahsis etme işleminin açıktan olmasını ister ve atama operatörlerini ya da kopyalama oluşturucularını gizlemez.
Yani, kopyalama ile taşıma her zaman birkaç biti hareket etmesi olarak tanımlanır ve geçersiz kılınamaz.
Eğer s1
Copy
özelliğini içermeyen bir türse s2 = s1
bir taşımaya sebep olur ve bu s1
'i tüketir! Eğer gerçek bir kopya üretmek istiyorsanız clone
kullanın.
Ödünç alma çoğu zaman kopyalamadan daha iyidir ancak bu sefer de ödünç alma kurallarını takip etmelisiniz. Neyse ki, ödünç alma işlemi yeniden düzenlenebilir bir davranıştır. Mesela String
, &str
olarak ödünç alınabilir ve &str
'nin değişmeyen metotlarını kullanabilir. Karakter dizisi dilimleri de C++'ın const char*
dan farksız c_str
anlayışındaki ödünç alma yöntemine kıyasla çok daha güçlüdür. &str
sahiplenilmiş birkaç baytın işaretçisinden (veya bir karakter dizisi kalıbından) ve boyut bilgisinden oluşur. Bu, bellek açısından oldukça verimli örüntüler kurmamıza yardımcı olur. Mesela bütün karakter dizilerinin bir karakter dizisinden ödünç alındığı bir Vec<&str>
oluşturulabilir - tek ihtiyacınız olan vektör için ek alan olacaktır:
Mesela, boşluklardan bölerken:
#![allow(unused)] fn main() { fn split_whitespace(s: &str) -> Vec<&str> { s.split_whitespace().collect() } }
Aynı şekilde, C++'daki s.substr(0,2)
her zaman karakter dizisinin kopyasını oluşturur ancak dilim sadece ödünç alır: &[0..2]
Buna benzer ilişki Vec<T>
ve &[T]
arasında da bulunur.
Paylaşılan Referanslar
C++'da bulunduğu gibi Rust için de akıllı işaretçiler (smart pointers) bulunur - mesela std::unique_ptr
muadili Box
tur. Bellek ve tahsis edilmiş diğer kaynaklar Box
kapsam dışına çıktığı zaman geri iade edildiğinden delete
kullanmaya ihtiyaç yoktur. (Rust RAII'yi epeyce benimsemiştir.)
#![allow(unused)] fn main() { let mut answer = Box::new("hello".to_string()); *answer = "world".to_string(); answer.push('!'); println!("{} {}", answer, answer.len()); }
İnsanlar başlangıçta to_string
'i başlangıçta pek sevmez ancak işleri açıktan yapmayı sağlar.
Açık dereferans operatörü olan *
önemli ancak metotlar üzerinde herhangi bir özel notasyon kullanmadığımıza dikkat edin. (Mesela burada (*answer).push('!') yok
)
Ödünç almanın sadece orijinal içeriğin sahibi belli olduğu zaman işe yaradığı açıktır. Çoğu tasarımda bu mümkün değildir.
Bu C++'da std::shared_ptr
'in kullanıldığı yerdir; kopyalama sadece veri üzerindeki referans sayısını attırır. Bunun da bir bedeli var, üstelik:
- veri sadece salt okunabilir olsa bile, sürekli referans sayımının arttırılması önbelleğin doğrulanamamasına sebep olabilir.
std::shared_ptr
süreçler arası emniyetlice paylaşılabilecek şekilde tasarlanmıştır ve kendi kilidini de beraberinde taşıması kaba maliyeti arttırmaktadır.
Rust'ta std::rc::Rc
da aynı zamanda referans sayımı yapan paylaşılan akıllı işaretçi gibi davranır. Fakat, bu sadece değişemez referanslar içindir! Eğer süreç anlamında emniyetli bir türünü istiyorsanız, std::sync::Arc
("Atomik Rc") kullanabilirsiniz. Rust iki farklı tür sunduğu için biraz tuhaf görünebilir fakat süreçlerin işin içine girmediği işlemler için kaba maliyeti arttırmamış olursunuz.
Değişemez referanslar olmalarının sebebi bunun Rust'ın bellek modelinin esaslarıyla olmasıyla alakalıdır. Fakat yine de sıyrılmanın bir yolu vardır: std::cell:RefCell
. Eğer paylaşılan referansınızı Rc<RefCell<T>>
olarak tanımlarsanız borrow_mut
ile değişebilir referans edinebilirsiniz. Bu sefer Rust kurallarını dinamik olarak uygularsınız - mesela zaten bir referans varken ek olarak borrow_mut
kullanırsanız bir paniğe sebep olacaktır.
Yine de bu hâlen daha emniyetlidir. Panikler bellekte yanlış bir yere dokunulduğu andan önce gerçekleşir! Tıpkı fırlatılan hatalar gibi, çağrı sırası teker teker boşaltılır. Bu derece yapılandırılmış bir süreç için talihsiz bir kelime seçimi diyebiliriz - bu panikleyerek kapanmak yerine sıralı bir temizliktir.
Rc<RefCell<T>>
tipi göze biraz biraz batıyor olabilir, ancak kullanılış şekli kesinlikle kötü değil. Burada, Rust (tekrardan) işlerin açıktan yürümesini tercih etmiş oluyor.
Ortak durumu eğer bellek açısından güvenli bir şekilde paylaşmak istiyorsanız Arc<T>
tek emniyetli yoldur. Eğer değişebilir erişimlere ihtiyaçlarınız varsa Rc<RefCell<T>>
yerine Arc<Mutex<T>>
kullanırsınız. Mutex
tanımlandığından biraz daha farklı çalışır, bu veri için bir kutu görevi görür. Veriyi lock
ile alır ve düzenlersiniz.
#![allow(unused)] fn main() { let answer = Arc::new(Mutex::new(10)); // in another thread .. { let mut answer_ref = answer.lock().unwrap(); *answer_ref = 42; } }
Neden unwrap
? Eğer kilidi elinde tutan süreç paniklerse lock
hata verir. (Resmi dokümentasyon bu tarz durumlarda unwrap
kullanmanın mantıklı olduğunu düşünür çünkü belli bir şeyler çok yanlış gitmiş. Panikler süreçlerin içerisinde yakalanabilir.)
Özel kilidi mümkün olduğu sürece kısa sürece tamamlayıp işi teslim etmek (Mutexlerde her zaman olduğu gibi) önemlidir. Onları sınırlı bir kapsam içerisinde tutmak yaygın bir tercihtir - değişebilir referans kapsam dışına çıktığı zaman kilit de sona ermiş olur.
C++'ın sadeliği ile bunu kıyaslayınca ("dostum sadece shared_ptr
kullan") göze biraz acayip görünüyor. Fakat bu şekilde paylaşılan veriler arasındaki herhangi bir düzenleme daha kolay fark ediliyor ve Mutex
kilidi örüntüsü süreç emniyetine yönlendiriyor.
Her şeyde olduğu gibi, paylaşılan referansları kullanırken dikkatli olun..
Döngüleyiciler
C++'da döngüleyiciler olağan bir yoldan yapılamaz; akıllı işaretçilere sahiptirler ve genellikle c.begin()
ile başlar ve c.end()
ile biterler. Döngüleyiciler üzerindeki operasyonlar yalnız başına şablon fonksiyonları olarak kullanılır, std::find_if
gibi.
Rust döngüleyicileri ise Iterator
özelliği ile tanımlanırlar; next
bize bir Option
döner ve Option
artık bir None
olduğu zaman işimiz biter.
Bilinen işlemler artık birer metot. Aşağıda find_if
'in muadilini görebilirsiniz. Bize bir Option
döner (çünkü hiç yoksa None
cevabı alırız) ve if let
deyimi aracılığıyla None
olmayan durumda değere erişebiliriz:
#![allow(unused)] fn main() { let arr = [10, 2, 30, 5]; if let Some(res) = arr.find(|x| x == 2) { // res is 2 } }
Emniyetsizlik ve Bağlı Listeler
Rust'ın standart kütüphanesinde unsafe
(emniyetsiz kod bloğu) kullanıldığı bir sır değil. Bu ödünç alma kontrolünün muhafazakar anlayışını ihlal etmez. "emniyetsiz (unsafe)" kelimenin özel bir anlamı olduğuna dikkat edin - Rust'ın derleme zamanında anlayamadığı işlemler. Rust'ın perspektifinden C++ her an ve her zaman emniyetsiz modda çalışır! Büyük uygulamalarda birkaç düzine satırlık emniyetsiz kod gerekiyorsa, ki bu da olabilir, bir hata olduğu zaman bu satırların bir insan tarafından dikkatlice incelenmesi yeterli olacaktır. Bilirsiniz, insanoğlu 100 bin satır üstü kodları okumakta pek başarılı değildir.
Bundan bahsediyorum çünkü ortada bir örüntü var, tecrübeli bir C++ programcısı bir ağaç yapısını ya da bağlı liste oluşturduğu zaman kendisini biraz yılgın hisseder. Pekâlâ, çift bağlı liste üretmek emniyetli Rust için mümkündür; Rc
akışı yönlendirir ve Weak
referanslar aradan çekilir. Ancak standart kütüphane daha fazla performans elde etmek için... işaretçileri kullanır.