Memakai Trait Objects Buat Mengabstraksi Perilaku Bersama
Di Bab 8, kita sempat nyebut kalau salah satu batasan dari vectors adalah
bahwa mereka cuma bisa nyimpan elemen dari satu tipe aja. Kita membikin sebuah
solusi buat ngakalin hal ini (workaround) di Listing 8-9 di mana kita
mendefinisikan enum SpreadsheetCell yang punya varian-varian buat menampung
integers, floats, dan teks. Ini berarti kita bisa nyimpan tipe data yang
berbeda-beda di setiap sel tapi tetap punya sebuah vector yang merepresentasikan
satu baris sel. Ini adalah solusi yang sangat bagus kalau item-item yang mau kita
tukar-tukar (interchangeable) itu merupakan serangkaian tipe tetap yang udah
kita tahu pas kode kita di-compile.
Namun, kadang-kadang kita pengen supaya user library kita bisa memperluas
serangkaian tipe yang valid di suatu situasi tertentu. Buat nunjukin gimana
kita bisa mencapai hal ini, kita bakal membikin contoh alat graphical user
interface (GUI) yang beriterasi ngelewatin sebuah daftar (list) item,
lalu memanggil method draw pada masing-masing item tersebut buat menggambarnya
ke layar—sebuah teknik yang umum buat alat-alat GUI. Kita bakal membikin sebuah
library crate bernama gui yang mengandung struktur dari library GUI
tersebut. Crate ini mungkin bakal nyertakan beberapa tipe buat dipakai
orang-orang, kayak Button atau TextField. Selain itu, para pengguna gui
bakal pengen bikin tipe-tipe mereka sendiri yang bisa digambar: contohnya,
satu programmer mungkin bakal nambahin Image dan yang lain mungkin bakal nambahin
SelectBox.
Pas lagi nulis library-nya, kita tidak bisa tahu dan tidak bisa mendefinisikan
semua tipe yang mungkin pengen dibikin sama programmer lain nantinya. Tapi kita
tahu kalau gui itu perlu melacak (keep track of) banyak nilai dari
tipe yang berbeda-beda, dan dia perlu manggil method draw pada setiap
nilai yang bertipe beda-beda ini. Dia tidak perlu tahu persis apa yang bakal
terjadi pas kita manggil method draw tersebut, dia cuma butuh tahu kalau nilai
itu punya method tersebut dan tersedia buat kita panggil.
Buat ngelakuin ini di bahasa pemrograman yang punya inheritance (pewarisan),
kita mungkin bakal mendefinisikan sebuah class bernama Component yang
punya sebuah method bernama draw padanya. Kelas-kelas lainnya, kayak Button,
Image, dan SelectBox, bakal mewarisi (inherit) dari Component dan dengan
gitu bakal mewarisi juga method draw-nya. Mereka masing-masing bisa menimpa
(override) method draw tersebut buat mendefinisikan perilaku kustom mereka
sendiri, tapi framework-nya bisa memperlakukan semua tipe-tipe tersebut
seolah-olah mereka adalah instance dari Component dan memanggil draw pada
mereka. Tapi karena Rust tidak punya inheritance, kita butuh cara lain buat
menstrukturkan library gui tersebut supaya user bisa bikin tipe-tipe
baru yang kompatibel sama library kita.
Mendefinisikan sebuah Trait untuk Perilaku Umum (Common Behavior)
Buat mengimplementasikan perilaku yang kita pengen ada di gui, kita bakal
mendefinisikan sebuah trait bernama Draw yang bakal punya satu method
bernama draw. Terus kita bisa mendefinisikan sebuah vector yang menerima
sebuah trait object (objek trait). Sebuah trait object menunjuk ke sebuah
instance dari suatu tipe yang mengimplementasikan trait yang sudah kita
tentukan, dan juga menunjuk ke sebuah tabel yang dipakai buat nyari trait methods
pada tipe tersebut saat runtime. Kita membikin trait object dengan
menentukan semacam pointer, kayak referensi & atau smart pointer
Box<T>, lalu keyword dyn, dan lalu menentukan trait yang relevan. (Kita
bakal ngomongin alasan kenapa trait objects harus memakai pointer di
“Tipe-tipe yang Berukuran Dinamis dan Trait Sized”
di Bab 20.) Kita bisa memakai trait objects buat menggantikan tipe generik
atau tipe konkret. Di mana pun kita memakai sebuah trait object, sistem tipe
Rust bakal memastikan saat compile time bahwa nilai apa pun yang dipakai di
konteks tersebut bakal mengimplementasikan trait dari trait object itu.
Akibatnya, kita tidak perlu tahu semua tipe yang mungkin ada saat compile time.
Kita udah sempat nyebut kalau di Rust, kita menahan diri buat tidak nyebut
structs dan enums sebagai “objek” buat ngebedain mereka dari objek di bahasa
pemrograman lain. Di sebuah struct atau enum, data di fields struct-nya
dan perilakunya di blok impl itu dipisahkan, sedangkan di bahasa pemrograman lain,
data dan perilaku yang digabungkan jadi satu konsep itu yang sering kali
dilabeli sebagai sebuah objek. Trait objects berbeda dari objek di bahasa
lain dalam hal kita tidak bisa menambahkan data ke sebuah trait object. Trait
objects tidak berguna secara umum (generally useful) layaknya objek di bahasa
lain: tujuan spesifik mereka adalah memungkinkan abstraksi melewati berbagai
perilaku umum (common behavior).
Listing 18-3 menunjukkan gimana cara mendefinisikan sebuah trait bernama Draw
dengan satu method bernama draw.
pub trait Draw {
fn draw(&self);
}
DrawSintaks ini seharusnya udah kerasa familier dari pembahasan kita soal gimana
cara mendefinisikan traits di Bab 10. Berikutnya datang beberapa sintaks baru:
Listing 18-4 mendefinisikan sebuah struct bernama Screen yang memegang sebuah
vector bernama components. Vector ini bertipe Box<dyn Draw>, yang mana adalah
sebuah trait object; dia jadi pengganti (stand-in) buat tipe apa pun di dalam
sebuah Box yang mengimplementasikan trait Draw.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen dengan sebuah field components yang memegang sebuah vector berisi trait objects yang mengimplementasikan trait DrawPada struct Screen, kita bakal mendefinisikan sebuah method bernama run
yang bakal memanggil method draw pada masing-masing komponen (components)-nya,
seperti yang ditunjukkan di Listing 18-5.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
run pada Screen yang memanggil method draw pada tiap komponenIni bekerja dengan cara yang beda dari pas kita mendefinisikan sebuah struct
yang memakai sebuah parameter tipe generik (generic type parameter) dengan
trait bounds. Sebuah parameter tipe generik cuma bisa digantikan (substituted)
oleh satu tipe konkret aja pada satu waktu, sedangkan trait objects memungkinkan
banyak tipe konkret buat ngisi posisi trait object tersebut saat runtime.
Misalnya, kita bisa saja mendefinisikan struct Screen memakai tipe generik
dan sebuah trait bound, seperti di Listing 18-6.
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
Screen dan method run-nya memakai generik dan trait boundsCara ini membatasi kita pada satu instance Screen yang punya daftar komponen yang
semuanya bertipe Button atau semuanya bertipe TextField. Kalau kita emang
cuma bakal punya koleksi yang homogen (sama semua jenisnya), memakai generik
dan trait bounds lebih disarankan karena definisi-definisinya bakal
di-monomorphize (diubah ke tipe spesifik) saat compile time buat memakai
tipe-tipe konkret tersebut.
Di sisi lain, dengan memakai method yang menggunakan trait objects, satu
instance Screen bisa memegang sebuah Vec<T> yang berisi sebuah Box<Button>
sekaligus sebuah Box<TextField>. Mari kita lihat gimana cara kerja ini, dan
nanti kita bakal ngebahas soal implikasi performa runtime-nya.
Mengimplementasikan Trait-nya
Sekarang kita bakal menambahkan beberapa tipe yang mengimplementasikan trait
Draw. Kita bakal menyediakan tipe Button. Sekali lagi, sebenarnya
mengimplementasikan library GUI itu ada di luar cakupan buku ini, jadi method
draw di sini tidak bakal punya implementasi yang berguna di isi fungsinya. Buat
ngebayangin seperti apa rupa dari implementasinya, sebuah struct Button
mungkin punya fields buat width, height, dan label, seperti yang
ditunjukkan di Listing 18-7.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// code to actually draw a button
}
}
Button yang mengimplementasikan trait DrawFields width, height, dan label pada Button bakal beda dari fields
pada komponen-komponen lain; misalnya, tipe TextField mungkin punya fields
yang sama ditambah sebuah field placeholder. Masing-masing dari tipe yang mau
kita gambar di layar bakal mengimplementasikan trait Draw tapi mereka bakal
memakai kode yang berbeda di dalam method draw buat mendefinisikan gimana cara
menggambar tipe khusus tersebut, seperti yang dimiliki Button di sini
(tanpa kode GUI aslinya, seperti yang disebutkan sebelumnya). Tipe Button,
misalnya, mungkin punya blok impl tambahan yang mengandung method-method yang
berkaitan dengan apa yang terjadi saat seorang user mengklik tombol (button) itu.
Method-method semacam ini tidak bakal berlaku buat tipe kayak TextField.
Kalau seseorang yang memakai library kita memutuskan buat mengimplementasikan
sebuah struct SelectBox yang punya fields width, height, dan options,
mereka bakal mengimplementasikan trait Draw pada tipe SelectBox tersebut juga,
seperti yang ditunjukkan di Listing 18-8.
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
fn main() {}
gui dan mengimplementasikan trait Draw pada sebuah struct SelectBoxUser dari library kita sekarang bisa menulis fungsi main mereka buat bikin
sebuah instance Screen. Ke instance Screen ini, mereka bisa menambahkan
sebuah SelectBox dan sebuah Button dengan menaruh masing-masing tipe
tersebut di dalam sebuah Box<T> buat menjadikannya trait object. Terus
mereka bisa manggil method run pada instance Screen tersebut, yang mana
bakal manggil draw pada masing-masing komponen. Listing 18-9 menunjukkan
implementasi ini.
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
Pas kita nulis library-nya, kita tidak tahu kalau seseorang mungkin bakal
nambahin tipe SelectBox, tapi implementasi Screen kita nyatanya bisa
beroperasi pada tipe yang baru tersebut dan menggambarnya karena SelectBox
mengimplementasikan trait Draw, yang artinya dia mengimplementasikan method
draw.
Konsep ini—yaitu peduli hanya pada pesan apa yang direspon sama sebuah nilai
ketimbang peduli sama tipe konkret dari nilai tersebut—itu mirip sama konsep
duck typing (tipe bebek) di bahasa pemrograman dengan tipe dinamis (dynamically
typed languages): kalau dia jalan kayak bebek dan kwek kayak bebek, maka dia pasti
bebek! Di dalam implementasi dari run pada Screen di Listing 18-5, run tidak
perlu tahu apa tipe konkret dari masing-masing komponen tersebut. Dia tidak
ngecek apakah sebuah komponen itu adalah instance dari sebuah Button atau sebuah
SelectBox, dia cuma manggil method draw pada komponen tersebut. Dengan
menentukan Box<dyn Draw> sebagai tipe dari nilai-nilai di vector components,
kita udah mendefinisikan Screen buat butuh nilai-nilai yang bisa kita
panggil method draw-nya.
Keuntungan dari memakai trait objects dan sistem tipe Rust buat nulis kode yang mirip sama kode yang memakai duck typing adalah bahwa kita tidak perlu repot ngecek apakah sebuah nilai mengimplementasikan suatu method tertentu atau enggak saat runtime, atau khawatir dapat error kalau sebuah nilai ternyata tidak mengimplementasikan sebuah method tapi kita tetep memanggilnya. Rust tidak bakal mengompilasi kode kita kalau nilai-nilainya tidak mengimplementasikan traits yang dibutuhkan sama trait objects tersebut.
Misalnya, Listing 18-10 menunjukkan apa yang terjadi kalau kita mencoba bikin
sebuah Screen dengan sebuah String sebagai komponennya.
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
Kita bakal dapat error ini karena String tidak mengimplementasikan trait
Draw:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the trait `Draw` is implemented for `Button`
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
Error ini ngasih tahu kita kalau entah kita itu memberikan sesuatu ke Screen yang
sebenarnya tidak kita maksudkan untuk kita berikan dan oleh karena itu kita harusnya
memberikan tipe yang berbeda, atau kita harusnya mengimplementasikan Draw pada
String supaya Screen bisa memanggil draw pada String tersebut.
Trait Objects Melakukan Dynamic Dispatch
Ingat kembali di “Performa Kode yang Memakai Generik” di Bab 10 pembahasan kita soal proses monomorphization (monomorfisasi) yang dilakukan pada tipe generik oleh compiler: compiler membikin (generates) implementasi fungsi dan method yang non-generik (spesifik) buat setiap tipe konkret yang kita pakai buat gantiin parameter tipe generik tersebut. Kode hasil dari monomorphization ini melakukan static dispatch (pengiriman statis), yaitu ketika compiler sudah tahu method apa yang kita panggil saat compile time. Ini berlawanan dengan dynamic dispatch (pengiriman dinamis), yaitu ketika compiler tidak bisa tahu pasti pas compile time method mana yang kita panggil. Di kasus dynamic dispatch, compiler menghasilkan (emits) kode yang saat runtime baru bakal mencari tahu method mana yang harus dipanggil.
Saat kita memakai trait objects, Rust pasti memakai dynamic dispatch. Compiler tidak tahu semua tipe yang mungkin dipakai bersama kode yang lagi memakai trait objects tersebut, jadi dia tidak tahu method yang mana (yang diimplementasikan pada tipe yang mana) yang harus dipanggil. Alih-alih begitu, saat runtime, Rust memakai pointer yang ada di dalam trait object buat mencari tahu method mana yang harus dipanggil. Pencarian (lookup) ini menimbulkan beban runtime (runtime cost) yang tidak terjadi pada static dispatch. Dynamic dispatch juga mencegah compiler dari memilih buat meng-inline (menyisipkan kode secara langsung) kode dari sebuah method, yang mana berakibat mencegah (prevents) dilakukannya beberapa optimasi lain. Rust juga punya beberapa aturan soal di mana kita boleh dan tidak boleh memakai dynamic dispatch, yang disebut dyn compatibility (kompatibilitas dinamis). Aturan-aturan itu ada di luar cakupan pembahasan ini, tapi kita bisa baca lebih lanjut soal itu di referensinya. Namun, kita dapat kebebasan ekstra (extra flexibility) di dalam kode yang kita tulis di Listing 18-5 dan berhasil mendukung tipe baru di Listing 18-9, jadi ini adalah trade-off (pertukaran) yang patut dipertimbangkan.