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

Advanced Types (Tipe Tingkat Lanjut)

Sistem tipe Rust punya beberapa fitur yang sejauh ini cuma kita sebut aja tapi belum benar-benar kita bahas. Kita bakal mulai dengan membahas newtypes secara umum sembari kita menyelidiki kenapa newtypes itu berguna sebagai tipe. Terus kita bakal lanjut ke type aliases (alias tipe), sebuah fitur yang mirip sama newtypes tapi punya semantik yang agak beda. Kita juga bakal ngebahas tipe ! dan dynamically sized types (tipe-tipe yang berukuran dinamis).

Memakai Newtype Pattern Buat Keamanan Tipe dan Abstraksi

Bagian ini berasumsi kalau kita udah ngebaca bagian sebelumnya “Memakai Newtype Pattern Buat Mengimplementasikan External Traits”. Newtype pattern (pola tipe baru) ini juga berguna buat hal-hal di luar dari apa yang udah kita bahas sejauh ini, termasuk secara statis menegakkan aturan supaya nilai-nilai tidak pernah tertukar (confused) dan buat mengindikasikan satuan (units) dari sebuah nilai. Kita udah lihat contoh pemakaian newtypes buat mengindikasikan satuan di Listing 20-16: ingat kembali kalau struct Millimeters dan Meters itu membungkus nilai u32 di dalam sebuah newtype. Kalau kita nulis sebuah fungsi dengan parameter bertipe Millimeters, kita tidak bakal bisa men-compile program yang secara tidak sengaja mencoba memanggil fungsi tersebut dengan nilai bertipe Meters atau nilai u32 biasa.

Kita juga bisa memakai newtype pattern buat mengabstraksi beberapa detail implementasi dari sebuah tipe: si tipe baru tersebut bisa ngekspos API public yang mana berbeda dari API milik tipe private yang ada di dalamnya.

Newtypes juga bisa menyembunyikan (hide) implementasi internal. Misalnya, kita bisa aja menyediakan tipe People buat ngebungkus sebuah HashMap<i32, String> yang nyimpan ID seseorang yang diasosiasikan dengan nama mereka. Kode yang memakai People cuma bakal berinteraksi sama API public yang kita sediakan, kayak method buat nambahin string nama ke dalam koleksi People; kode tersebut tidak perlu tahu kalau kita secara internal menaruh nilai ID i32 ke nama-nama tersebut. Newtype pattern adalah cara yang ringan (lightweight) buat mendapatkan enkapsulasi (encapsulation) guna menyembunyikan detail implementasi, yang mana udah kita bahas di “Encapsulation (Enkapsulasi) yang Menyembunyikan Detail Implementasi” di Bab 18.

Membikin Sinonim Tipe dengan Type Aliases

Rust menyediakan kemampuan buat mendeklarasikan type alias (alias tipe) buat ngasih nama lain ke tipe yang udah ada. Buat hal ini kita memakai keyword type. Misalnya, kita bisa membikin alias Kilometers buat i32 kayak gini:

fn main() {
    type Kilometers = i32;

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

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

Sekarang si alias Kilometers adalah sebuah sinonim buat i32; beda sama tipe Millimeters dan Meters yang kita bikin di Listing 20-16, Kilometers bukanlah sebuah tipe baru yang terpisah. Nilai-nilai yang punya tipe Kilometers bakal diperlakukan persis sama kayak nilai-nilai bertipe i32:

fn main() {
    type Kilometers = i32;

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

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

Karena Kilometers dan i32 adalah tipe yang sama, kita bisa menjumlahkan nilai dari kedua tipe tersebut dan kita bisa ngoper nilai Kilometers ke fungsi-fungsi yang menerima parameter i32. Namun, dengan memakai cara ini, kita tidak dapetin keuntungan pengecekan tipe (type-checking benefits) yang kita dapat dari pemakaian newtype pattern yang dibahas sebelumnya. Dengan kata lain, kalau kita nyampur aduk (mix up) nilai Kilometers dan i32 di suatu tempat, compiler tidak bakal ngasih kita error.

Kegunaan utama (main use case) dari sinonim tipe adalah buat ngurangin pengulangan (repetition). Misalnya, kita mungkin punya tipe yang panjang sekali kayak gini:

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

Nulisin tipe sepanjang ini di signatures fungsi dan sebagai anotasi tipe di semua tempat di kode kita bisa jadi melelahkan dan rentan kena error (error prone). Bayangin aja kalau punya sebuah project yang penuh dengan kode kayak yang ada di Listing 20-25.

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

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

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-25: Memakai tipe yang panjang di banyak tempat

Sebuah type alias ngebikin kode ini jadi lebih gampang dikelola dengan cara ngurangin pengulangan tersebut. Di Listing 20-26, kita memperkenalkan sebuah alias bernama Thunk buat tipe yang panjang (verbose) tadi dan bisa mengganti semua penggunaan dari tipe tersebut dengan si alias Thunk yang lebih pendek.

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

    let f: Thunk = Box::new(|| println!("hi"));

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

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-26: Memperkenalkan sebuah type alias, Thunk, buat ngurangin pengulangan

Kode ini jadinya jauh lebih gampang buat dibaca dan ditulis! Milih nama yang punya makna (meaningful) buat type alias juga bisa ngebantu ngomunikasiin niat (intent) kita (thunk adalah kata yang artinya kode yang bakal dievaluasi nanti, jadi ini adalah nama yang tepat buat sebuah closure yang lagi disimpen).

Type aliases juga umumnya dipakai bareng sama tipe Result<T, E> buat ngurangin pengulangan. Coba perhatikan modul std::io di standard library. Operasi I/O (input/output) itu sering sekali mengembalikan Result<T, E> buat menangani situasi-situasi pas operasinya gagal jalan. Library ini punya struct std::io::Error yang merepresentasikan semua kemungkinan error I/O. Banyak dari fungsi-fungsi di std::io bakal mengembalikan Result<T, E> di mana si E itu adalah std::io::Error, kayak misalnya fungsi-fungsi di dalam trait Write ini:

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>;
}

Bagian Result<..., Error> itu diulang berkali-kali. Karena hal itu, std::io punya deklarasi type alias berikut ini:

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<()>;
}

Karena deklarasi ini ada di dalam modul std::io, kita bisa memakai alias fully qualified std::io::Result<T>; yakni, sebuah Result<T, E> dengan si E udah diisi sebagai std::io::Error. Alhasil, signatures dari fungsi-fungsi di trait Write kelihatannya jadi kayak gini deh:

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<()>;
}

Type alias ini sangat ngebantu dalam dua hal: dia ngebikin kodenya jadi lebih gampang ditulis dan dia ngasih kita sebuah antarmuka (interface) yang konsisten di seluruh std::io. Karena dia cuma sebuah alias, dia sebenernya cuma Result<T, E> biasa aja, yang berarti kita bisa memakai method apa pun yang berlaku buat Result<T, E> dengannya, sekaligus juga sintaks-sintaks spesial kayak operator ?.

Tipe Never (Tak Pernah) yang Tidak Pernah Mengembalikan Apa-apa

Rust punya tipe spesial bernama ! yang mana dikenal di bahasa gaulnya teori tipe sebagai empty type (tipe kosong) karena dia tidak punya nilai sama sekali. Kita lebih milih menyebutnya never type (tipe tak pernah) karena dia berdiri menempati posisi dari tipe kembalian saat sebuah fungsi tidak bakal pernah mengembalikan (never return) apa-apa. Ini adalah contohnya:

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

Kode ini dibaca sebagai “fungsi bar mengembalikan never.” Fungsi-fungsi yang mengembalikan never disebut diverging functions (fungsi divergen). Kita tidak bisa membikin nilai dari tipe !, jadi si bar itu emang tidak mungkin bisa mengembalikan apa-apa.

Tapi apa gunanya coba sebuah tipe yang kita tidak bisa bikin nilai buatnya sama sekali? Ingat kembali kode dari Listing 2-5, bagian dari game tebak angka; kita udah menaruh sedikit dari bagian kode itu di sini di Listing 20-27.

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

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

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

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 20-27: Sebuah match dengan arm yang berakhir dengan continue

Waktu itu, kita mengabaikan (skipped over) beberapa detail di kode ini. Di “Konstruk Control Flow match di Bab 6, kita ngebahas kalau match arms itu semuanya wajib mengembalikan tipe yang sama. Jadi, misalnya, kode berikut ini tidak bakal jalan:

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

Tipe dari guess di kode ini wajib jadi integer sekaligus string, padahal Rust mewajibkan guess buat cuma punya satu tipe aja. Terus si continue itu ngembaliin apa dong? Gimana ceritanya kita dibolehin buat mengembalikan nilai u32 dari satu arm padahal punya arm lain yang berakhir dengan continue di Listing 20-27?

Seperti yang mungkin udah kita tebak, continue itu punya nilai !. Yaitu, saat Rust menghitung tipe dari guess, dia ngelihat ke kedua match arms tersebut, yang pertama bernilai u32 dan yang terakhir (latter) bernilai !. Karena ! itu tidak bakal pernah bisa punya nilai, Rust memutuskan kalau tipe dari guess adalah u32.

Cara formal buat mendeskripsikan perilaku ini adalah bahwa ekspresi-ekspresi dari tipe ! itu bisa dipaksa (coerced) menjadi tipe apa aja yang lain. Kita dibolehin buat mengakhiri match arm ini dengan continue karena continue tidak mengembalikan nilai; sebagai gantinya, dia memindahkan kontrol kembali ke atas perulangannya (loop), jadi di kasus Err, kita tidak pernah memberikan sebuah nilai ke guess.

Tipe never ini berguna bareng macro panic! juga. Ingat kembali fungsi unwrap yang kita panggil pada nilai-nilai Option<T> buat menghasilkan nilai atau jadi panic dengan definisi seperti ini:

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!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

Di kode ini, hal yang sama juga terjadi kayak yang ada di match di Listing 20-27: Rust ngelihat kalau val punya tipe T dan panic! punya tipe !, jadi hasil dari keseluruhan ekspresi match tersebut adalah T. Kode ini bisa jalan karena panic! tidak memproduksi sebuah nilai; dia sekadar memberhentikan programnya. Di kasus None, kita tidak bakal mengembalikan nilai dari unwrap, jadi kode ini itu valid.

Satu ekspresi terakhir yang punya tipe ! adalah sebuah loop:

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

    loop {
        print!("and ever ");
    }
}

Di sini, loop tersebut tidak pernah berakhir, jadi nilai dari ekspresinya adalah !. Namun, ini tidak bakal benar kalau seandainya kita memasukkan break, karena perulangan tersebut bakal dihentikan pas dia mencapai break.

Tipe yang Berukuran Dinamis (Dynamically Sized Types) dan Trait Sized

Rust perlu tahu detail-detail tertentu tentang tipe-tipenya, seperti seberapa banyak ruang yang harus dialokasikan buat menyimpan sebuah nilai dari suatu tipe tertentu. Hal ini ngebikin satu sudut dari sistem tipenya jadi agak membingungkan pada awalnya: yakni konsep tentang dynamically sized types (tipe-tipe yang berukuran dinamis). Terkadang disebut juga sebagai DSTs atau unsized types (tipe tanpa ukuran tetap), tipe-tipe ini membiarkan kita nulis kode yang memakai nilai-nilai yang mana ukurannya cuma bisa kita ketahui saat runtime.

Mari kita gali detail-detail dari sebuah tipe berukuran dinamis yang bernama str, yang mana udah sering kita pakai di sepanjang buku ini. Yap benar, bukan &str, melainkan si str itu sendiri sendirian, dia itu adalah sebuah DST. Di banyak kasus, kayak misalnya pas lagi nyimpan teks yang dimasukkan (entered) oleh user, kita tidak bisa tahu seberapa panjang string-nya tersebut sampai runtime datang. Itu artinya kita tidak bisa membikin variabel dengan tipe str, dan kita juga tidak bisa memakai argumen bertipe str. Coba perhatikan kode berikut, yang mana tidak bisa jalan:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust butuh tahu seberapa besar memori yang harus dialokasikan buat nilai apa pun dari suatu tipe tertentu, dan semua nilai dari sebuah tipe itu diwajibkan buat memakai jumlah ruang memori yang sama. Kalau seandainya Rust ngebolehin kita buat nulis kode ini, kedua nilai str ini pasti dituntut buat menempati jumlah ruang yang sama besarnya. Tapi kenyataannya panjang mereka itu berbeda: s1 butuh penyimpanan memori 12 bytes dan s2 butuh 15 bytes. Inilah alasan kenapa mustahil buat ngebikin variabel yang menampung sebuah tipe berukuran dinamis secara langsung.

Terus apa yang harus kita lakuin? Di kasus ini, kita sebenarnya udah tahu jawabannya: kita harus membikin tipe dari s1 dan s2 menjadi &str ketimbang str. Ingat kembali dari “String Slices” di Bab 4 kalau struktur data slice itu cuma sekadar menyimpan posisi awal (starting position) dan panjang (length) dari slice tersebut. Jadi, meskipun &T itu merupakan sebuah nilai tunggal yang menyimpan alamat memori di mana si T tersebut berada, sebuah &str itu terdiri dari dua nilai: alamat dari si str dan juga panjangnya. Alhasil, kita bisa tahu pasti ukuran dari nilai sebuah &str saat compile time: ukurannya adalah dua kali panjang dari sebuah usize. Yaitu, kita selalu tahu ukuran dari sebuah &str, tidak peduli sepanjang apa pun string yang ia tunjuk tersebut. Secara umum, beginilah cara gimana tipe berukuran dinamis itu dipakai di Rust: mereka punya ekstra sedikit metadata yang menyimpan besaran ukuran dari informasi yang dinamis tersebut. Aturan emas (golden rule) dari tipe yang berukuran dinamis adalah kita harus selalu menaruh nilai-nilai dari tipe berukuran dinamis tersebut di balik (behind) semacam pointer.

Kita bisa menggabungkan str dengan berbagai macam pointer lainnya: misalnya, Box<str> atau Rc<str>. Faktanya, kita udah ngelihat hal ini sebelumnya tapi dengan tipe berukuran dinamis yang berbeda: yakni, traits. Setiap trait itu adalah sebuah tipe berukuran dinamis yang bisa kita rujuk (refer to) dengan memakai nama dari trait tersebut. Di “Memakai Trait Objects Buat Mengabstraksi Perilaku Bersama” di Bab 18, kita nyebutin kalau buat memakai traits sebagai trait objects, kita wajib menaruh mereka di balik sebuah pointer, seperti &dyn Trait atau Box<dyn Trait> (Rc<dyn Trait> juga bisa jalan kok).

Buat bekerja sama DSTs, Rust menyediakan trait Sized buat menentukan apakah ukuran dari suatu tipe itu bisa diketahui saat compile time atau tidak. Trait ini secara otomatis diimplementasikan buat semua hal yang ukurannya bisa diketahui saat compile time. Selain itu, Rust juga secara implisit menambahkan batasan (bound) pada Sized ke semua fungsi generik (generic function). Yakni, definisi fungsi generik kayak gini:

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

itu sebenernya bakal diperlakukan seolah-olah kita udah nulis kayak gini:

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

Secara bawaan (by default), fungsi-fungsi generik cuma bakal bekerja buat tipe-tipe yang ukurannya itu diketahui pas compile time. Namun, kita bisa memakai sintaks spesial berikut ini buat mengendurkan (relax) pembatasan tersebut:

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

Batasan trait bound pada ?Sized itu artinya “T mungkin Sized atau mungkin juga tidak Sized” dan notasi ini menimpa (overrides) sifat bawaan yang mewajibkan tipe generik buat harus punya ukuran yang udah diketahui pas compile time. Sintaks ?Trait yang punya arti (meaning) kayak gini cuma tersedia buat trait Sized doang ya, tidak bisa dipakai buat traits yang lain.

Perhatikan juga kalau kita juga mengubah tipe dari parameter t dari asalnya T menjadi &T. Karena tipe tersebut bisa aja tidak Sized, kita wajib memakai dia di balik semacam pointer. Di kasus ini, kita milih buat memakai reference (referensi).

Berikutnya, kita bakal membahas tentang fungsi dan closures!