Polymorphism adalah salah satu konsep yang bikin developer dari bahasa lain penasaran saat pertama kali belajar Rust. Di C++, ada virtual function dan vtable yang sudah tertanam di dalam objek. Di Rust, jawabannya datang dalam dua bentuk: generics untuk static dispatch, dan trait object atau dyn Trait untuk dynamic dispatch. Artikel ini akan membedah bagaimana dyn Trait bekerja di level memori, lengkap dengan eksperimen yang bisa lo jalankan sendiri.
Pendekatan ini terinspirasi dari tulisan Sofía Belén López Vicens, seorang developer C++ yang sedang menjelajahi Rust dan mendokumentasikan eksperimennya secara detail. Poin penting yang dia tekankan di awal: jangan mencoba memahami Rust dengan cara memaksakan paralel 1:1 dengan C++. Rust bukan C++ dengan sintaks berbeda. Tapi memahami perbedaan filosofinya justru bikin lo menghargai desain kedua bahasa.
Masalah yang Mau Dipecahkan
Bayangkan lo punya kumpulan bentuk: lingkaran, persegi, segitiga. Lo mau memanggil draw() untuk masing-masing. Di C++, solusi paling umum adalah virtual function. Vtable pointer hidup di dalam objek, dan dispatch terjadi otomatis saat lo memanggil method lewat pointer ke base class.
Rust punya padanan untuk pendekatan ini, yaitu dyn Trait, dan itu yang akan kita pahami. Tapi sebelum sampai ke sana, ada satu teknik C++ lain yang menarik untuk dibandingkan: CRTP atau Curiously Recurring Template Pattern. Ini adalah polymorphism yang diselesaikan saat kompilasi, tanpa vtable sama sekali. Sayangnya sintaksnya cukup bikin pusing.
Rust menawarkan padanan CRTP yang jauh lebih bersih: monomorphization lewat generics. Compiler membuat salinan fungsi terpisah untuk setiap tipe yang dipakai. Tidak ada biaya runtime sama sekali, tapi tipe harus diketahui saat kompilasi. Mari lihat kodenya.
Static Dispatch dengan Generics
Contoh paling sederhana dimulai dengan mendefinisikan sebuah trait dan dua tipe yang mengimplementasikannya:
trait Draw {
fn draw(&self) -> &str;
}
struct Circle;
struct Square;
impl Draw for Circle {
fn draw(&self) -> &str { "Drawing a circle" }
}
impl Draw for Square {
fn draw(&self) -> &str { "Drawing a square" }
}
fn draw_shape<T: Draw>(shape: T) {
println!("{}", shape.draw());
}
fn main() {
let circle = Circle;
let square = Square;
draw_shape(circle);
draw_shape(square);
}
Di balik layar, compiler menghasilkan dua fungsi terpisah: draw_shape::<Circle> dan draw_shape::<Square>. Setiap fungsi sudah tahu persis tipe apa yang dia tangani, jadi pemanggilan draw() bisa di-resolve langsung tanpa indirection. Ini static dispatch: nol biaya runtime, tapi konsekuensinya ukuran kode bertambah karena setiap kombinasi tipe menghasilkan salinan fungsi sendiri.
Perbedaan filosofis dengan template C++ cukup menarik. Di C++, constraint-nya implisit: template menerima tipe apa pun yang kebetulan punya method .draw(). Kalau tipe tidak punya method itu, error baru muncul saat kompilasi dengan pesan yang kadang membingungkan. Di Rust, kontraknya eksplisit: lo menulis implement Draw for Square, dan compiler memastikan kontrak itu dipenuhi di tempat definisi, bukan di tempat pemakaian.
Side Quest: Zero-Sized Types
Sebelum masuk ke dynamic dispatch, ada temuan menarik yang layak disinggung: ukuran Circle dan Square. Ketika penulis eksperimen mencoba mengukur keduanya untuk dibandingkan dengan wide pointer, hasilnya mengejutkan. Di C++, standar mewajibkan setiap objek punya ukuran minimal 1 byte, supaya dua objek berbeda selalu punya alamat berbeda. Tapi Rust mengembalikan angka 0.
Inilah yang disebut zero-sized type (ZST). Struct tanpa field tidak perlu alokasi memori sama sekali. Pertanyaannya, bagaimana Rust menjaga identitas dua nilai ZST yang berbeda? Jawabannya: lewat ownership, bukan alamat memori. Borrow checker melacak setiap binding sebagai nama dengan owner berbeda, jadi lo tidak perlu membandingkan alamat untuk membedakan dua nilai.
Kalau lo penasaran dan mencoba mengambil alamat ZST, hasilnya tergantung mode kompilasi. Di debug mode, compiler memberi masing-masing ZST dummy stack slot supaya debugger bisa melacaknya, dan alamatnya akan berbeda 1 byte. Di release mode, alamatnya bisa saja sama persis, karena compiler tidak membuat jaminan apa pun soal alamat ZST. Ini detail kecil yang membuka mata: cara Rust dan C++ memandang identitas objek memang berbeda fundamental.
Dynamic Dispatch dengan dyn Trait
Sekarang bagian utamanya. Versi dynamic dispatch dari contoh sebelumnya hampir identik secara sintaks:
fn draw_shape(shape: &dyn Draw) {
println!("{}", shape.draw());
}
fn main() {
let circle = Circle;
let square = Square;
draw_shape(&circle);
draw_shape(&square);
}
Satu-satunya perbedaan di permukaan adalah &dyn Draw menggantikan <T: Draw>. Tapi di bawahnya, terjadi sesuatu yang fundamental berbeda. Ukuran referensinya saja sudah berbeda: &Circle berukuran 8 byte, sedangkan &dyn Draw berukuran 16 byte.
Referensi ke trait object adalah wide pointer: dua pointer yang berdampingan. Pointer pertama menunjuk ke data, pointer kedua menunjuk ke vtable. Vtable inilah yang memberi tahu Rust draw() mana yang harus dipanggil saat runtime. Eksperimen transmute bisa membuktikan ini:
fn inspect(shape: &dyn Draw) {
let (data_ptr, vtable_ptr) = unsafe {
std::mem::transmute::<&dyn Draw, (usize, usize)>(shape)
};
println!("data ptr: {:#x}", data_ptr);
println!("vtable ptr: {:#x}", vtable_ptr);
}
std::mem::transmute melakukan salinan bit-for-bit dari &dyn Draw menjadi tuple dua usize. Ini butuh unsafe karena compiler tidak bisa menjamin bahwa pola bit arbitrer membentuk nilai yang valid untuk tipe tujuan. Hasilnya konsisten: objek dari tipe yang sama berbagi satu vtable, sementara data pointer berubah per instance. Dua Circle punya vtable yang sama persis, tapi data pointer berbeda. Circle dan Square punya vtable berbeda.
Kenapa Dynamic Dispatch Diperlukan
Pertanyaan yang wajar muncul: kalau static dispatch lebih cepat dan aman, kapan dynamic dispatch benar-benar dibutuhkan? Jawabannya muncul saat lo ingin menyimpan campuran tipe dalam satu koleksi.
// tidak akan compile!
let shapes = vec![Circle, Square];
Vec<T> mengharuskan setiap elemen punya tipe dan ukuran yang sama persis. Circle dan Square adalah tipe yang tidak saling berhubungan dan bisa punya ukuran berbeda. Tidak ada base class seperti di C++ yang bisa dipakai sebagai tipe elemen.
Di sinilah dyn Trait masuk. Box<dyn Draw> adalah cara Rust mengalokasikan nilai di heap dan memilikinya lewat pointer. Karena Box pada dasarnya adalah wide pointer, ukurannya selalu sama, 16 byte, tidak peduli apa isinya. Jadi Vec<Box<dyn Draw>> bisa menampung Circle, Square, dan tipe lain yang mengimplementasikan Draw sekaligus.
Perbedaan filosofi dengan C++ di sini sangat tajam. Di C++, pilihan antara static dan dynamic dispatch ditentukan di level class: kalau lo menandai method sebagai virtual, class itu akan selalu memakai dynamic dispatch. Vtable pointer ada di dalam setiap objek, menambah overhead ke setiap instance meskipun polymorphism tidak pernah dipakai. Di Rust, keputusan ada di call site. Circle tetaplah Circle, tidak tahu apa-apa tentang dispatch. Lo yang memilih: pakai &Circle untuk static dispatch, atau &dyn Draw untuk dynamic. Overhead vtable hanya muncul kalau lo benar-benar minta dynamic dispatch.
Satu Vtable untuk Setiap Pasangan Tipe dan Trait
Eksperimen lain yang membuka wawasan: apa yang terjadi kalau satu tipe mengimplementasikan lebih dari satu trait? Bayangkan Duck yang bisa Fly dan Swim sekaligus.
trait Fly { fn fly(&self) -> &str; }
trait Swim { fn swim(&self) -> &str; }
struct Duck;
impl Fly for Duck {
fn fly(&self) -> &str { "Duck flies!" }
}
impl Swim for Duck {
fn swim(&self) -> &str { "Duck swims!" }
}
fn main() {
let duck = Duck;
let fly_obj: &dyn Fly = &duck;
let swim_obj: &dyn Swim = &duck;
// transmute untuk melihat data & vtable pointer
}
Hasilnya menarik. fly_obj dan swim_obj berbagi data pointer yang sama, karena keduanya adalah view dinamis dari objek duck yang sama. Tapi vtable pointer-nya berbeda. Satu vtable untuk pasangan (Duck, Fly), dan satu lagi untuk (Duck, Swim). Ini menegaskan poin inti: vtable di Rust bukan bagian dari objek seperti di C++, melainkan data statis eksternal yang dipasangkan dengan objek saat lo meminta dynamic dispatch. Duck tetaplah Duck, mau dia terbang, berenang, atau mengimplementasikan seratus trait lain.
Object Safety: Kenapa Tidak Semua Trait Bisa Jadi dyn
Kalau dynamic dispatch sebagus itu, kenapa tidak semua trait bisa dipakai sebagai dyn Trait? Rust punya aturan yang disebut object safety, dan ada dua larangan utama yang perlu dipahami.
Larangan pertama: method tidak boleh mengembalikan Self. Contoh klasiknya adalah Clone, yang punya fn clone(&self) -> Self. Self adalah placeholder untuk tipe yang sedang mengimplementasikan trait. Untuk Circle, Self berarti Circle. Karena method ini mengembalikan Self by value, pemanggil harus tahu tipe konkretnya untuk menentukan berapa banyak memori yang harus dialokasikan. Lewat vtable, pemanggil tidak tahu tipe konkret, jadi compiler menolak. Di C++ masalah ini tidak ada karena virtual dispatch selalu lewat pointer, dan tipe return-nya juga selalu pointer.
Larangan kedua: method tidak boleh punya parameter generik. Bayangkan trait Serialize dengan fn serialize<T>(&self, output: &mut T). Compiler akan butuh entri vtable terpisah untuk setiap kemungkinan T: serialize::<File>, serialize::<String>, serialize::<Vec<u8>>, dan seterusnya tanpa batas. Itu mustahil, jadi kode yang mencoba membuat Box<dyn Serialize> tidak akan compile. Menariknya, C++ menghadapi tembok yang sama: virtual method template juga tidak bisa dibuat, karena alasan yang identik.
Kesimpulan
Eksperimen membedah vtable Rust ini menghasilkan beberapa pemahaman inti. Monomorphization di Rust dan CRTP di C++ sama-sama polymorphism saat kompilasi, tanpa biaya runtime, dengan konsekuensi ukuran kode yang bertambah. dyn Trait dan virtual function C++ sama-sama memakai vtable untuk dynamic dispatch, tapi dengan perbedaan penting: di C++, vtable pointer tinggal di dalam objek dan menambah overhead ke setiap instance, sedangkan di Rust vtable pointer hanya muncul saat lo eksplisit memakai &dyn Trait atau Box<dyn Trait>.
Ada juga pelajaran tentang ZST: C++ mewajibkan objek minimal 1 byte karena melacak identitas lewat alamat memori saat runtime, sedangkan Rust melacak identitas lewat ownership saat kompilasi, sehingga zero-sized type bisa eksis tanpa alokasi. Perbedaan kecil yang mencerminkan perbedaan filosofi besar: C++ menyerahkan banyak keputusan ke runtime, Rust menyerahkan ke compiler dan sistem tipe.
Buat developer yang baru pindah ke Rust, eksperimen macam ini jauh lebih berharga daripada sekadar membaca dokumentasi. Menjalankan kode, melihat ukuran tipe, dan membongkar representasi memori dengan transmute membangun intuisi yang tidak bisa didapat dari teori. Dan intuisi itulah yang bakal menolong lo saat memutuskan kapan pakai generics dan kapan dyn Trait di codebase beneran.
💬 Komentar (0)
Belum ada komentar. Jadilah yang pertama! 💬