Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Gelişmiş Türler

Rust’ın tür sisteminde şimdiye kadar değindiğimiz ancak henüz ayrıntılı olarak ele almadığımız bazı özellikler vardır. İlk olarak newtype’ların (yeni türlerin) birer tür olarak neden yararlı olduğunu inceleyerek genel hatlarıyla newtype kavramını tartışacağız. Ardından newtype’lara benzeyen ancak anlambilimi (semantics) biraz farklı olan tür takma adlarına (type aliases) geçeceğiz. Ayrıca ! türünü ve dinamik boyutlu türleri (dynamically sized types) de ele alacağız.

Newtype Deseniyle Tür Güvenliği ve Soyutlama

Bu bölüm, daha önceki “Dış Trait’leri Newtype Deseniyle Uygulamak” alt bölümünü okuduğunuzu varsayar. Newtype deseni, şimdiye kadar ele aldıklarımızın ötesindeki görevler için de kullanışlıdır; değerlerin birbiriyle asla karıştırılmamasını derleme zamanında (statik olarak) zorunlu kılmak ve bir değerin birimini belirtmek bunların arasındadır. Birimleri göstermek amacıyla newtype kullanımının bir örneğini Liste 20-16’da görmüştünüz: Milimetreler ve Metreler struct’larının u32 değerlerini bir newtype içinde sarmaladığını hatırlayın. Parametresi Milimetreler türünde olan bir fonksiyon yazsaydık, bu fonksiyonu yanlışlıkla Metreler türünden bir değerle veya düz bir u32 ile çağırmaya çalışan bir programı derleyemezdik.

Newtype desenini bir türün bazı uygulama ayrıntılarını soyutlamak için de kullanabiliriz: Yeni tür, içteki özel (private) türün API’sinden farklı —örneğin kullanılabilir işlevselliği sınırlandıran— bir açık (public) API sunabilir.

Benzer şekilde newtype’lar iç temsil ayrıntılarını gizlemek için de kullanılabilir. Örneğin, bir kişinin kimlik numarasını adıyla ilişkilendirerek saklayan HashMap<i32, String> yapısını sarmalamak üzere bir Kisiler türü sağlayabiliriz. Kisiler türünü kullanan kod yalnızca sunduğumuz açık API ile —örneğin Kisiler koleksiyonuna bir isim string’i ekleyen bir metotla— etkileşime girer; bu kodun perde arkasında isimlere i32 türünde bir kimlik numarası atadığımızı bilmesine gerek kalmaz. Newtype deseni, Bölüm 18’deki “Uygulama Ayrıntılarını Gizleyen Kapsülleme” bölümünde ele aldığımız kapsüllemeyi elde etmenin hafif bir yoludur.

Tür Eşanlamlıları ve Tür Takma Adları

Rust, mevcut bir türe başka bir ad vermek amacıyla bir tür takma adı (type alias) bildirme olanağı sunar. Bunun için type anahtar kelimesini kullanırız. Örneğin i32 için Kilometreler takma adını şu şekilde oluşturabiliriz:

fn main() {
    type Kilometreler = i32;

    let x: i32 = 5;
    let y: Kilometreler = 5;

    println!("x + y = {}", x + y);
}

Artık Kilometreler takma adı, i32 için bir eşanlamlıdır (synonym); Liste 20-16’da oluşturduğumuz Milimetreler ve Metreler türlerinin aksine Kilometreler ayrı ve yeni bir tür değildir. Kilometreler türüne sahip değerler, i32 türündeki değerlerle birebir aynı şekilde ele alınır:

fn main() {
    type Kilometreler = i32;

    let x: i32 = 5;
    let y: Kilometreler = 5;

    println!("x + y = {}", x + y);
}

Kilometreler ve i32 aynı tür olduğundan her iki türdeki değerleri birbiriyle toplayabilir ve i32 parametresi alan fonksiyonlara Kilometreler değerleri geçirebiliriz. Ancak bu yöntemi kullandığımızda, az önce ele aldığımız newtype deseninin sağladığı tür denetimi avantajlarını elde edemeyiz. Başka bir deyişle, bir yerde Kilometreler ve i32 değerlerini karıştırırsak derleyici bize hata vermez.

Tür eşanlamlılarının başlıca kullanım amacı tekrarı azaltmaktır. Örneğin elimizde şöyle uzun bir tür olabilir:

Box<dyn Fn() + Send + 'static>

Bu uzun türü kodun her yerinde fonksiyon imzalarına ve tür açıklamalarına yazmak yorucu ve hataya açık olabilir. Projenizin Liste 20-25’teki gibi kodlarla dolu olduğunu hayal edin.

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("selam"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}
Liste 20-25: Birçok yerde uzun bir tür kullanmak

Bir tür takma adı, tekrarı azaltarak bu kodu daha yönetilebilir hâle getirir. Liste 20-26’da bu uzun ve ayrıntılı tür için ErtelenenIs adında bir takma ad tanımladık; böylece türün tüm kullanımlarını daha kısa olan ErtelenenIs takma adıyla değiştirebiliriz.

fn main() {
    type ErtelenenIs = Box<dyn Fn() + Send + 'static>;

    let f: ErtelenenIs = Box::new(|| println!("selam"));

    fn takes_long_type(f: ErtelenenIs) {
        // --snip--
    }

    fn returns_long_type() -> ErtelenenIs {
        // --snip--
        Box::new(|| ())
    }
}
Liste 20-26: Tekrarı azaltmak için ErtelenenIs adlı bir tür takma adı tanımlamak

Bu kodu okumak ve yazmak çok daha kolaydır! Bir tür takma adı için anlamlı bir isim seçmek niyetinizi karşı tarafa aktarmanıza da yardımcı olur (İngilizce literatürde daha ileri bir zamanda değerlendirilecek kod parçalarını ifade etmek için thunk terimi kullanılır; bizim seçtiğimiz ErtelenenIs adı da saklanan bir kapanış için bu amacı doğrudan yansıtan uygun bir isimdir).

Tür takma adları, tekrarı azaltmak için Result<T, E> türüyle birlikte de yaygın olarak kullanılır. Standart kütüphanedeki std::io modülünü düşünün. G/Ç (I/O) işlemleri, işlemlerin başarısız olduğu durumları ele almak için çoğu zaman bir Result<T, E> döndürür. Bu kütüphanede olası tüm G/Ç hatalarını temsil eden bir std::io::Error struct’ı bulunur. Write trait’indeki şu fonksiyonlarda olduğu gibi, std::io içindeki fonksiyonların pek çoğu E parametresi std::io::Error olan Result<T, E> döndürür:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Result<..., Error> ifadesi çok fazla tekrarlanmaktadır. Bu nedenle std::io modülü şu tür takma adı bildirimine sahiptir:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Bu bildirim std::io modülü içinde yer aldığından, tam nitelikli std::io::Result<T> takma adını —yani E parametresi std::io::Error olarak doldurulmuş bir Result<T, E> türünü— kullanabiliriz. Böylece Write trait’inin fonksiyon imzaları şu görünüme kavuşur:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Tür takma adı iki açıdan yardımcı olur: Kodu yazmayı kolaylaştırır ve tüm std::io genelinde bize tutarlı bir arayüz sunar. Bu yalnızca bir takma ad olduğundan özünde bildiğimiz Result<T, E> türünden ibarettir; yani Result<T, E> üzerinde çalışan tüm metotları ve ? operatörü gibi özel sözdizimlerini onunla da doğrudan kullanabiliriz.

Not: Newtype Deseni (struct Km(i32);) ile Tür Takma Adı (type Km = i32;) Arasındaki Fark: Tür takma adı yalnızca başka bir ad verir; newtype ise ek bir öbek tahsisi gerektirmeden ayrı bir tür oluşturur. Newtype için içteki türle aynı bellek yerleşiminin garanti edilmesi ayrıca #[repr(transparent)] gerektirebilir. Derleme zamanındaki temel farklar şunlardır:

  • Newtype (struct Km(i32);), derleyicinin gözünde yepyeni ve bağımsız bir tür oluşturur. Değerlerin yanlışlıkla birbirine karıştırılmasını derleme zamanında engeller, iç alanı gizleyerek kapsülleme sağlar ve yetim kuralına (orphan rule) takılmadan dış trait’leri uygulamanıza olanak tanır. Buna karşılık içteki i32 türünün metotlarını ve + gibi operatörlerini kendiliğinden devralmaz (self.0 üzerinden erişmeniz veya ilgili trait’leri uygulamanız gerekir).
  • Tür takma adı (type Km = i32;) ise yeni bir tür oluşturmaz; yalnızca mevcut türe okunabilirliği artıran kısa bir eşanlamlı ad verir. Asıl türün tüm metotları, trait uygulamaları ve operatörleri herhangi bir sarmalama veya açma işlemi gerektirmeden doğrudan çalışır; ancak derleyici Km ile i32 arasında hiçbir tür ayrımı yapmaz ve üzerinde ayrıca dış trait uygulamanıza izin vermez.

Hiç Dönmeyen Never Türü

Rust’ta, hiçbir değere sahip olmadığı için tür kuramı terminolojisinde boş tür (empty type) olarak bilinen ! adında özel bir tür vardır. Biz onu never türü olarak adlandırmayı tercih ediyoruz; çünkü bir fonksiyon asla geri dönmeyeceğinde dönüş türünün yerinde durur. İşte bir örnek:

fn bar() -> ! {
    // --snip--
    panic!();
}

Bu kod, “bar fonksiyonu asla dönmez” şeklinde okunur. Dönüş türü ! olan (asla dönmeyen) fonksiyonlara ıraksayan fonksiyonlar (diverging functions) denir. ! türünde değerler oluşturamayacağımız için bar fonksiyonunun geri dönmesi hiçbir koşulda mümkün değildir.

Peki hiçbir zaman değer oluşturamayacağınız bir tür ne işe yarar? Sayı tahmin oyununun bir parçası olan Liste 2-5’teki kodu hatırlayın; o kodun küçük bir bölümünü burada Liste 20-27’de yeniden verdik.

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Sayıyı tahmin et!");

    let gizli_sayi = rand::rng().random_range(1..=100);

    println!("Gizli sayı: {gizli_sayi}");

    loop {
        println!("Lütfen tahmininizi girin.");

        let mut tahmin = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut tahmin)
            .expect("Satır okunamadı");

        let tahmin: u32 = match tahmin.trim().parse() {
            Ok(sayi) => sayi,
            Err(_) => continue,
        };

        println!("Tahmininiz: {tahmin}");

        // --snip--

        match tahmin.cmp(&gizli_sayi) {
            Ordering::Less => println!("Çok küçük!"),
            Ordering::Greater => println!("Çok büyük!"),
            Ordering::Equal => {
                println!("Kazandınız!");
                break;
            }
        }
    }
}
Liste 20-27: Kollarından biri continue ile biten bir match ifadesi

O sırada bu koddaki bazı ayrıntıları atlamıştık. Bölüm 6’daki “match Kontrol Akışı Yapısı” bölümünde, tüm match kollarının aynı türü döndürmesi gerektiğini ele almıştık. Dolayısıyla örneğin aşağıdaki kod çalışmaz:

fn main() {
    let tahmin = "3";
    let tahmin = match tahmin.trim().parse() {
        Ok(_) => 5,
        Err(_) => "merhaba",
    };
}

Bu kodda tahmin değişkeninin türünün aynı anda hem bir tam sayı hem de bir string olması gerekirdi; oysa Rust, tahmin değişkeninin yalnızca tek bir türe sahip olmasını şart koşar. Peki continue ne döndürür? Liste 20-27’de bir koldan u32 döndürüp diğer kolu continue ile bitirmemize nasıl izin verildi?

Tahmin edebileceğiniz gibi continue bir ! değerine sahiptir. Yani Rust, tahmin değişkeninin türünü hesaplarken her iki match koluna da bakar: ilki u32 değerine, ikincisi ise ! değerine sahiptir. ! asla bir değere sahip olamayacağından Rust, tahmin değişkeninin türünün u32 olduğuna karar verir.

Bu davranışı tanımlamanın biçimsel yolu, ! türündeki ifadelerin başka herhangi bir türe zorlanabilmesidir (coerced). Bu match kolunu continue ile bitirmemize izin verilir; çünkü continue bir değer döndürmez, bunun yerine kontrol akışını döngünün en başına geri taşır ve dolayısıyla Err durumunda tahmin değişkenine hiçbir zaman bir değer atamayız.

Never türü panic! makrosuyla birlikte de kullanışlıdır. Bir değer üretmek ya da paniklemek için Option<T> değerleri üzerinde çağırdığımız unwrap fonksiyonunun şu tanımını hatırlayın:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("`Option::unwrap()` bir `None` değeri üzerinde çağrıldı"),
        }
    }
}

Bu kodda da Liste 20-27’deki match ifadesiyle aynı şey gerçekleşir: Rust, val değişkeninin T türünde ve panic! ifadesinin ! türünde olduğunu görür; dolayısıyla match ifadesinin genel sonucu T olur. Bu kod çalışır çünkü panic! bir değer üretmez; programı sonlandırır. None durumunda unwrap içinden bir değer döndürmeyeceğimiz için bu kod geçerlidir.

! türüne sahip son bir ifade de loop döngüsüdür:

fn main() {
    print!("sonsuzca ");

    loop {
        print!("ve hep ");
    }
}

Burada döngü hiçbir zaman sona ermez; bu yüzden ifadenin değeri ! olur. Ancak döngünün içine bir break ekleseydik bu geçerli olmazdı; çünkü döngü break ifadesine ulaştığında sonlanırdı.

Not: Standart kütüphanede karşılaştığınız std::convert::Infallible (örneğin asla başarısız olmayacak tür dönüşümlerindeki TryFrom uygulamalarında kullanılan Result<T, Infallible>), boş tür kavramının pratik bir karşılığıdır. Kararlı (stable) Rust’ta ! türü şimdilik yalnızca fonksiyon dönüş türü konumunda kullanılabilir; onu jenerik bir tür argümanı (Result<T, !>) gibi başka konumlarda kullanmak hâlâ kararsız (unstable) bir özelliktir. Bu yüzden standart kütüphane, hiçbir varyantı bulunmayan pub enum Infallible {} tanımını sunar. Hiçbir varyantı olmayan bir enum’ın da tıpkı ! gibi çalışma zamanında örneği oluşturulamaz; dolayısıyla Result<T, Infallible> döndüren bir işlemin Err üretmesi derleme zamanında imkânsızdır ve gelecekteki Rust sürümlerinde Infallible türünün doğrudan ! için bir tür takma adına dönüştürülmesi hedeflenmektedir.

Dinamik Boyutlu Türler ve Sized Trait’i

Rust’ın, belirli bir türdeki bir değer için ne kadar bellek alanı ayıracağı gibi türlerine ilişkin bazı ayrıntıları bilmesi gerekir. Bu gereksinim, tür sisteminin bir köşesini ilk bakışta biraz kafa karıştırıcı hâle getirir: dinamik boyutlu türler (dynamically sized types) kavramı. Bazen DST’ler veya boyutsuz türler (unsized types) olarak da adlandırılan bu türler, boyutunu yalnızca çalışma zamanında bilebileceğimiz değerleri kullanarak kod yazmamıza olanak tanır.

Gelin kitap boyunca kullandığımız str adlı dinamik boyutlu türün ayrıntılarına inelim. Evet, yanlış duymadınız: &str değil, tek başına str bir DST’dir. Kullanıcının girdiği bir metni saklamak gibi pek çok durumda string’in ne kadar uzun olduğunu çalışma zamanına kadar bilemeyiz. Bu durum, str türünde bir değişken oluşturamayacağımız ve str türünde bir argüman alamayacağımız anlamına gelir. Çalışmayan aşağıdaki kodu inceleyelim:

fn main() {
    let s1: str = "Hey merhaba!";
    let s2: str = "Nasıl gidiyor?";
}

Rust, belirli bir türün herhangi bir değeri için ne kadar bellek ayıracağını bilmek zorundadır ve bir türün tüm değerleri aynı miktarda bellek kullanmalıdır. Rust bu kodu yazmamıza izin verseydi, bu iki str değerinin bellekte aynı miktarda yer kaplaması gerekirdi. Oysa uzunlukları farklıdır: s1 için 12 bayt, s2 için ise 15 bayt depolama alanı gerekir ("Nasıl gidiyor?" metnindeki 'ı' harfi UTF-8 kodlamasında 2 bayt yer kaplar). İşte bu nedenle dinamik boyutlu bir türü doğrudan tutan bir değişken oluşturmak mümkün değildir.

Peki bu durumda ne yaparız? Burada cevabı zaten biliyorsunuz: s1 ve s2’nin türünü str yerine string dilimi (&str) yaparız. Bölüm 4’teki “String Dilimleri” bölümünden, dilim veri yapısının yalnızca başlangıç konumunu ve dilimin uzunluğunu sakladığını hatırlayın. Dolayısıyla &T, T değerinin bulunduğu bellek adresini saklayan tek bir değer olmasına rağmen bir string dilimi iki değerden oluşur: str verisinin adresi ve uzunluğu. (Bu tür iki parçalı işaretçilere geniş işaretçi (fat pointer) de denir.) Bu sayede bir string dilimi değerinin boyutunu derleme zamanında bilebiliriz: Boyutu bir usize uzunluğunun iki katıdır. Yani başvurduğu string ne kadar uzun olursa olsun, bir string diliminin boyutunu her zaman biliriz. Genel olarak Rust’ta dinamik boyutlu türler bu şekilde kullanılır: Dinamik bilginin boyutunu saklayan fazladan bir parça üstveriye (metadata) sahiptirler. Dinamik boyutlu türlerin altın kuralı şudur: Dinamik boyutlu türlerin değerlerini her zaman bir tür işaretçinin arkasına koymalıyız.

str türünü her türlü işaretçiyle birleştirebiliriz: örneğin Box<str> veya Rc<str>. Aslında bunu daha önce farklı bir dinamik boyutlu türle görmüştünüz: trait’ler. Her trait, dyn anahtar kelimesiyle birlikte adını kullanarak (dyn Trait) başvurabileceğimiz dinamik boyutlu bir türdür. Bölüm 18’deki “Ortak Davranışı Soyutlamak İçin Trait Nesnelerini Kullanma” bölümünde, trait’leri trait nesneleri olarak kullanabilmek için onları &dyn Trait veya Box<dyn Trait> gibi bir işaretçinin arkasına koymamız gerektiğinden bahsetmiştik (Rc<dyn Trait> de aynı şekilde çalışır).

DST’lerle çalışabilmek için Rust, bir türün boyutunun derleme zamanında bilinip bilinmediğini belirlemek üzere Sized trait’ini sağlar. Bu trait, boyutu derleme zamanında bilinen her şey için otomatik olarak uygulanır. Buna ek olarak Rust, her jenerik fonksiyona örtük olarak Sized üzerine bir sınır ekler. Yani şöyle bir jenerik fonksiyon tanımı:

fn generic<T>(t: T) {
    // --snip--
}

gerçekte sanki şunu yazmışız gibi ele alınır:

fn generic<T: Sized>(t: T) {
    // --snip--
}

Varsayılan olarak jenerik fonksiyonlar yalnızca derleme zamanında bilinen bir boyuta sahip türler üzerinde çalışır. Ancak bu kısıtlamayı gevşetmek için aşağıdaki özel sözdizimini kullanabilirsiniz:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

?Sized üzerindeki bir trait sınırı, “T türü Sized olabilir de olmayabilir de” anlamına gelir ve bu gösterim, jenerik türlerin derleme zamanında bilinen bir boyuta sahip olması gerektiği varsayılanını geçersiz kılar. Bu anlama sahip ?Trait sözdizimi yalnızca Sized için kullanılabilir; başka hiçbir trait için geçerli değildir.

Ayrıca t parametresinin türünü T’den &T’ye değiştirdiğimize dikkat edin. Tür Sized olmayabileceği için onu bir tür işaretçinin arkasında kullanmamız gerekir. Bu örnekte bir referans seçtik.

Sırada fonksiyonlar ve kapanışlar var!