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(|| ())
}
}
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(|| ())
}
}
Thunk, buat ngurangin pengulanganKode 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;
}
}
}
}
match dengan arm yang berakhir dengan continueWaktu 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!