Ambil satu array angka, lalu tulis ini di JavaScript:
const total = angka
.filter((x) => x % 2 === 0)
.map((x) => x * 2)
.reduce((a, b) => a + b, 0);
Satu baris, enak dibaca, rapi banget, kan? 😅 Tapi kita semua tahu harganya: filter bikin array baru, map bikin array baru lagi, lalu reduce jalan di atas hasilnya. Buat array berisi sejuta angka, itu artinya dua array sementara yang harus dialokasikan, dibuang, dan dibersihkan Garbage Collector, padahal hasil akhirnya cuma satu bilangan.
Itu sebabnya programmer yang berburu performa tinggi sering turun kasta: nulis loop for mentah, pakai pointer manual, dan merelakan kodenya jadi lebih panjang demi satu-dua milidetik. Selama beberapa dekade, itu memang aturan tak tertulisnya: makin tinggi level abstraksi, makin mahal ongkos runtime-nya.
Rust menantang aturan itu dengan prinsip yang diwarisi dari dunia C++: Zero-Cost Abstractions. Bjarne Stroustrup, pencipta C++, merumuskannya seperti ini:
Secara umum, implementasi C++ mengikuti prinsip zero-overhead: yang nggak kamu pakai, nggak kamu bayar. Dan yang kamu pakai, nggak bisa kamu tulis sendiri dengan lebih efisien.
Kedengarannya kayak slogan marketing, ya. Makanya kita nggak akan percaya begitu saja. Yuk, kita bongkar assembly-nya sendiri, pakai rustc 1.99 dengan LLVM di belakangnya.
Poin Penting
- Zero-cost abstraction bukan berarti bebas biaya, tapi memindahkan biaya itu ke tahap kompilasi supaya runtime-nya bersih.
- Monomorphization menyalin fungsi generik untuk tiap tipe konkret, jadi kompiler bisa membuang lapisan abstraksinya sepenuhnya. Fungsi yang isinya dua pemanggilan trait menyusut jadi
mov eax, 8danret. - Rantai iterator di Rust terukur secepat loop manual karena nggak ada alokasi heap, pengecekan batas hilang, dan loop-nya berubah jadi instruksi SIMD.
- Closure diterjemahkan jadi struct anonim di stack, dan trait Fn, FnMut, atau FnOnce yang dipilih kompiler menunjukkan cara dia memakai variabel di sekitarnya.
- Yang tetap dibayar: waktu build lebih lama dan ukuran biner yang bisa membengkak akibat duplikasi fungsi untuk tiap tipe konkret.
Jadi apa sih "zero-cost" itu?
Kata "zero" di sini bukan berarti gratis tanpa ongkos sama sekali. Ongkosnya tetap ada, cuma tempat membayarnya yang dipindah: dari runtime ke compile-time. Biayanya pindah, bukan hilang.
rustc dan LLVM bekerja ekstra keras saat proses build. Mereka menyalin fungsi generik untuk tiap tipe konkret, membuka abstraksi berlapis jadi kode datar, lalu membuang seluruh pembukuan yang cuma jadi beban. Yang tersisa di berkas biner adalah instruksi mesin biasa, sering kali sama persis dengan versi manualnya.
Tiga mekanisme utama yang dipakai untuk itu: monomorphization, iterator, dan closure.
Rahasia 1: monomorphization, polimorfisme tanpa lompatan runtime
Di bahasa yang pakai dynamic dispatch seperti Java atau Python, memanggil method dari sebuah interface berarti CPU harus mencari alamat fungsinya dulu di runtime, biasanya lewat vtable. Biaya satu lompatan emang kecil, tapi efeknya besar: kompiler cuma bisa melihat dari luar fungsi yang dipanggil, jadi dia kehilangan kesempatan membuang kode mati atau menyatukan perhitungan.
Rust bisa memilih jalur lain. Coba kode ini:
trait Hitung {
fn nilai(&self) -> i32;
}
struct A;
impl Hitung for A {
fn nilai(&self) -> i32 { 3 }
}
struct B;
impl Hitung for B {
fn nilai(&self) -> i32 { 5 }
}
fn pakai<T: Hitung>(x: T) -> i32 {
x.nilai()
}
pub fn statis() -> i32 {
pakai(A) + pakai(B)
}
pub fn dinamis(d: &dyn Hitung) -> i32 {
d.nilai()
}
Dua fungsi terakhir hasilnya sama-sama 8, tapi jalannya beda jauh. Ini yang dihasilkan rustc -O untuk statis() nih:
statis:
mov eax, 8
ret
Dua instruksi 🤯 Tanpa pemanggilan fungsi, tanpa tabel method, tanpa jejak A atau B. Kompiler sudah menghitung semuanya di compile-time dan menyisakan jawabannya saja.
Sekarang versi &dyn, satu-satunya cara Rust memaksa dynamic dispatch:
dinamis:
jmp qword ptr [rsi + 24]
Satu instruksi, tapi isinya lompat ke alamat yang baru diketahui saat program jalan, dibaca dari vtable pada offset 24. Kompiler berhenti di situ: dia nggak tahu fungsi apa yang bakal dipanggil, jadi nggak ada yang bisa di-inline.
Proses penyalinan inilah yang disebut monomorphization. Waktu kamu menulis pakai::<A>() dan pakai::<B>(), Rust benar-benar membuat dua fungsi berbeda di berkas biner, satu untuk A dan satu untuk B. Kamu tetap bisa memaksa dynamic dispatch kapan pun kamu mau, dan kadang itu memang keputusan yang benar. Bedanya, di Rust itu pilihan, bukan satu-satunya jalan.
Rahasia 2: iterator secepat loop manual
Balik ke masalah di pembuka: rantai .filter().map().sum() di Rust.
let numbers: Vec<i32> = (1..=10).collect();
let functional: i32 = numbers
.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * 2)
.sum();
let mut manual = 0;
for &x in &numbers {
if x % 2 == 0 {
manual += x * 2;
}
}
println!("iterator = {functional}, for loop = {manual}");
Dijalankan, hasilnya iterator = 60, for loop = 60, sama seperti yang kita harapkan.
Yang menarik bukan hasilnya, tapi apa yang terjadi setelah kompilasi. Dua fungsi itu dikompilasi terpisah dengan rustc -O -C panic=abort, lalu instruksinya dihitung satu per satu:
- Versi iterator: 53 instruksi, nol instruksi
call. - Versi loop manual: 57 instruksi, nol instruksi
call.
Versi iterator malah punya empat instruksi lebih sedikit, bukan lebih banyak. Dua-duanya juga berisi instruksi vektor yang sama: movdqu, pand, paddd, dan pcmpeqd. Artinya loop-nya diubah jadi SIMD 128-bit yang memproses empat bilangan i32 sekaligus. Kalau -C target-feature=+avx2 ditambahkan, keduanya naik ke instruksi ymm dan jumlahnya jadi 103 lawan 107, dengan pola yang sama.
Gimana kompiler bisa sejauh itu? Tiga hal:
- Tanpa alokasi di heap. Iterator Rust tuh struct kecil di stack yang cuma menyimpan posisi penelusuran. Nggak ada objek baru yang dibuat di tengah rantai.
- Pengecekan batas ikut hilang. Waktu kamu menulis
array[i], kompiler wajib menyisipkan pemeriksaani < lendi runtime supaya akses di luar batas bisa dicegah. Lewat iterator, panjang dan arah penelusurannya bisa dibuktikan di compile-time, jadi pengecekan per elemen itu nggak perlu ada lagi. Di assembly di atas, yang tersisa cuma penambahan pointer biasa. - Hasilnya jadi ramah vektorisasi. Rantai iterator adalah mesin status yang bersih dan tanpa efek samping, jadi LLVM gampang mengenalinya sebagai pola map-filter-reduce dan menggantinya dengan instruksi SIMD.
Ini bukan cuma teori di kertas. Buku resmi The Rust Programming Language pernah mengukur dua versi fungsi pencarian kata di teks Sherlock Holmes: versi loop 19.620.300 ns per iterasi, versi iterator 19.234.900 ns. Versi iterator sedikit lebih cepat. Angka itu dari mesin penulis buku di edisi pertama, jadi anggap petunjuk arah, bukan patokan mutlak.
Rahasia 3: closure itu cuma struct anonim
Di bahasa ber-Garbage Collector, closure yang menangkap variabel dari luar biasanya butuh alokasi di heap: nilai yang ditangkap dibungkus jadi objek supaya umurnya lebih panjang dari fungsi asalnya.
Di Rust caranya beda. Closure diterjemahkan jadi struct anonim yang isinya variabel yang ditangkap, dan disimpan di stack seperti nilai biasa:
let batas = 5;
let dibaca = |x: i32| x > batas; // cuma membaca -> Fn
let mut hitungan = 0;
let mut menambah = |x: i32| { hitungan += x; }; // mengubah -> FnMut
let pemilik = String::from("halo");
let memindah = move || pemilik.len(); // memindahkan -> FnOnce
Bedanya cuma soal apa yang dilakukan closure itu ke variabel di sekitarnya, dan kompiler yang menentukannya:
- Kalau closure-nya cuma membaca, dia meminjam referensi biasa dan mengimplementasikan trait
Fn. - Kalau dia mengubah nilainya, pinjamannya jadi
&mutdan trait-nyaFnMut. - Kalau dia mengambil kepemilikannya, nilainya pindah ke dalam struct, dan trait-nya
FnOnce.
Nggak ada allocator yang dilibatkan, karena semuanya hidup di stack. Dijalankan, outputnya: dibaca(7) balik true, hitungan berakhir di 7, dan panjang pemilik tetap 4 walaupun isinya sudah dipindah masuk ke dalam closure.
Pajak yang tetap harus dibayar
Kalau abstraksinya benar-benar nol ongkos, kenapa nggak semua bahasa begitu? Karena ongkosnya nggak hilang, cuma pindah tempat.
Waktu build lebih lama. Analisis borrow checker, monomorphization, dan optimasi agresif memang kerja berat. Tapi hati-hati dengan penjelasan populer yang menyalahkan monomorphization sendirian: salah satu pengembang kompiler Rust pernah bilang eksperimen di masa awal Rust menunjukkan monomorphization bukan penyebab utamanya, karena versi dinamis pun butuh banyak kode pembukuan yang harus dioptimasi. Penyebab sebenarnya tersebar, dari pohon dependensi yang besar sampai tahap optimasi LLVM itu sendiri.
Ukuran biner bisa membengkak. Setiap tipe konkret menghasilkan salinan fungsinya sendiri. Pakai Vec<i32>, Vec<u64>, dan Vec<String> di satu program, kamu dapat tiga salinan kode yang mirip. Di program kecil efeknya tipis; di proyek besar, code bloat itu nyata. Salah satu obatnya: pakai &dyn Trait untuk kode yang dipanggil jarang dan nggak butuh cepat. Seperti yang kita lihat di atas, itu memang benar-benar menghemat kode.
Jadi harga zero-cost abstraction bukan nol. Yang berubah cuma siapa yang membayarnya: mesin build kamu dan pembaca kodenya nanti, bukan CPU pengguna.
Penutup
Yang bikin prinsip ini menarik bukan sekadar performanya, tapi izin yang dia berikan: kamu nggak perlu merasa bersalah nulis iterator berantai, nyusun trait modular, atau bikin closure di kode yang kritis secara performa. Kalau memang ada biayanya, biaya itu ditaruh di tempat yang jauh lebih murah buat kamu bayar.
Kalau kamu mau lihat sendiri assembly-nya, tempel kode di atas ke Compiler Explorer, pilih rustc versi stabil, lalu tambahkan -O -C panic=abort di kolom argumen. Cara paling cepat buat berhenti percaya dan mulai memeriksa. Selamat ber-systems programming ria! 🦀