Sepanjang section ini kita berkali-kali menjanjikan bahwa “kompilator membuat salinan kode per tipe” — saat menjelaskan inferensi, saat menjelaskan static dispatch, saat menjelaskan kenapa bound tidak berbiaya runtime. Chapter penutup ini melunasi janji tersebut sampai ke buktinya: kita akan mengompilasi program generik nyata, membedah berkas binernya dengan perkakas nm, lalu membuktikan dari dalam bahasa Rust itu sendiri bahwa satu definisi sumber benar-benar menjadi banyak fungsi mesin. Di ujung pembuktian berdiri frasa yang sering disebut sebagai jualan utama Rust: zero-cost abstraction — abstraksi yang bisa kita pakai tanpa membayar satu siklus CPU pun saat program berjalan, karena seluruh biayanya sudah dilunasi pada saat kompilasi.

Satu Definisi, Salinan per Tipe #

Program berikut memakai fungsi largest generik untuk tiga koleksi — dua di antaranya bertipe f64:

rust
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let scores = [72, 85, 90, 68];
    let ratings = [4.2, 3.8, 4.9, 4.5];
    let temperatures = [31.5, 29.9, 34.1, 30.2];

    println!("Skor tertinggi: {}", largest(&scores));
    println!("Rating tertinggi: {}", largest(&ratings));
    println!("Suhu tertinggi: {}", largest(&temperatures));
}

Output yang Dihasilkan:

text
Skor tertinggi: 90
Rating tertinggi: 4.9
Suhu tertinggi: 34.1

Mari kita bedah pemanggilan-pemanggilan di baris-baris penting:

  1. Baris 16 (largest(&scores)): T terikat menjadi i32 — instansiasi pertama.
  2. Baris 17 (largest(&ratings)): T terikat menjadi f64 — instansiasi kedua.
  3. Baris 18 (largest(&temperatures)): T terikat menjadi f64 — sama seperti baris 17, bukan instansiasi baru. Tiga pemanggilan, tetapi hanya dua tipe berbeda, dan hanya dua salinan kode yang akan dihasilkan.

Mekanisme Monomorphisasi di Balik Layar #

Monomorphisasi — harfiahnya “mengubah dari banyak bentuk menjadi satu bentuk per salinan” — adalah tahap setelah seluruh tipe terverifikasi. Alurnya bekerja empat langkah: pertama, kompilator memeriksa body largest satu kali dengan T sebagai simbol plus bound PartialOrd sebagai jaminannya; kedua, kompilator mengumpulkan semua tipe konkret yang terpakai pemanggil di seluruh program (i32 dan f64); ketiga, untuk tiap tipe terkumpul, kompilator mensubstitusi T lalu menghasilkan kode mesin khusus — perbandingan i32 memakai instruksi integer, perbandingan f64 memakai instruksi floating point; keempat, nama generik lenyap dari berkas biner — yang tersisa hanyalah fungsi-fungsi konkret.

Diagram di atas menegaskan pembagiannya: satu definisi sumber bercabang menjadi salinan-salinan tipe spesifik, dan setiap pemanggilan dialirkan ke salinan milik tipenya — dua pemanggilan f64 berbagi salinan yang sama. Notasi largest::<i32> dan largest::<f64> adalah cara Rust menuliskan instansiasi tersebut, dan sebentar lagi kita akan melihat kedua nama itu secara harfiah di dalam berkas biner.

Melihat Bukti di Biner Program #

Simpan program di atas sebagai main.rs, lalu jalankan tiga perintah berikut di terminal:

bash
rustc --edition 2021 main.rs -o monomorph
ls -lh monomorph
nm -C monomorph | grep largest

Output yang Dihasilkan:

text
-rwxr-xr-x 1 z z 4.4M Oct 13 21:10 monomorph
0000000000015e40 t main::largest::<f64>
0000000000015f00 t main::largest::<i32>

Mari kita bedah arti setiap baris output:

  1. Baris pertama (ls -lh monomorph): ukuran berkas biner — sekitar 4.4 MB pada eksekusi penulis. Sebagian besar ukurannya adalah standard library yang ditautkan; angka pastinya berbeda-beda tergantung versi rustc dan platform.
  2. Baris kedua (nm -C monomorph menghasilkan main::largest::<f64>): perkakas nm dari paket binutils mendaftar semua simbol (nama fungsi) di dalam biner, dan opsi -C menerjemahkan nama terenkode Rust menjadi bentuk terbaca. Baris ini membuktikan ada fungsi fisik bernama largest::<f64> di alamat tertentu.
  3. Baris ketiga (main::largest::<i32>): salinan kedua di alamat yang berbeda. Tidak ada fungsi ketiga — dua pemanggilan f64 berbagi satu salinan, persis seperti analisis kita. Juga tidak ada largest generik: T tidak pernah berlayar sampai runtime.

Huruf t kecil sebelum tiap baris menandakan simbol internal (lokal pada berkas biner itu). Alamat dan tanggal pada output penulis akan berbeda di komputer kita, tetapi dua baris nama largest ini stabil muncul selama programnya sama.

Pembuktian dari Dalam Bahasa Rust #

Kita tidak harus percaya perkakas binutils. Bahasa Rust sendiri memungkinkan kita menyimpan fungsi ke dalam variabel bertipe function pointer, lalu membandingkan alamat kedua instansiasi sebagai angka:

rust
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let for_scores: fn(&[i32]) -> &i32 = largest;
    let for_ratings: fn(&[f64]) -> &f64 = largest;

    println!("Alamat keduanya sama: {}", for_scores as usize == for_ratings as usize);
    println!("Nilai terbesar skor: {}", for_scores(&[72, 85, 90]));
}

Output yang Dihasilkan:

text
Alamat keduanya sama: false
Nilai terbesar skor: 90

Mari kita bedah kode di atas baris per baris:

  1. Baris 12 (let for_scores: fn(&[i32]) -> &i32 = largest;): fn(&[i32]) -> &i32 adalah tipe function pointer — “fungsi yang menerima slice i32 dan mengembalikan reference i32”. Nilai largest otomatis dikonversi menjadi pointer menuju salinan kode largest::<i32> milik tipe tersebut.
  2. Baris 13 (let for_ratings: fn(&[f64]) -> &f64 = largest;): konversi yang sama terjadi untuk f64, menghasilkan pointer menuju salinan largest::<f64>. Kedua variabel berasal dari satu nama fungsi sumber.
  3. Baris 15 (for_scores as usize == for_ratings as usize): cast as usize membaca alamat memory yang ditunjuk tiap pointer sebagai angka. Perbandingan menghasilkan false — dua alamat berbeda, dua fungsi mesin berbeda. Jika generics berjalan lewat satu fungsi serbagam di runtime, kedua alamat akan sama.
  4. Baris 16 (for_scores(&[72, 85, 90])): function pointer dipanggil persis seperti fungsi biasa dan mengembalikan 90 — salinan i32 bekerja sebagaimana mestinya.

Zero-Cost Abstraction dan Harga Sebenarnya #

Salinan largest::<i32> yang dihasilkan monomorphisasi setara dengan fungsi konkret yang kita tulis tangan — tanpa pembungkus tambahan, tanpa pemeriksaan tipe saat berjalan, tanpa tabel method yang harus dirujuk. Pemanggilan largest(&scores) dialirkan langsung ke alamat kode largest::<i32>; karena target pemanggilan sudah pasti sejak kompilasi (static dispatch), kompilator bahkan bebas menyisipkan body ke pemanggilan (inlining) jika menguntungkan. Inilah yang dimaksud zero-cost abstraction: kita menulis sekali secara generik, biner berjalan seperti kode khusus yang ditulis per tipe.

Ketidakberbiayaan di runtime dibayar dua mata uang lain. Pertama, compile time: setiap kombinasi tipe yang dipakai harus diverifikasi dan dikodekan ulang, sehingga proyek dengan ratusan instansiasi generics memakan waktu kompilasi lebih panjang. Kedua, ukuran biner: setiap tipe baru menambah satu salinan kode — pada fungsi besar yang diinstansiasi untuk puluhan tipe, salinan-salinan ini menumpuk menjadi code bloat. Mitigasinya ada pada desain: bound yang tepat menjaga fungsi tetap sederhana, dan logika yang sama sekali besar bisa dibagi menjadi fungsi non-generik yang dipanggil oleh pembungkus generik tipis. Rust juga menyediakan jalur berlawanan — dynamic dispatch dengan satu salinan kode plus tabel method yang dirujuk saat runtime — yang akan dibahas pada Section Traits; monomorphisasi dan dynamic dispatch adalah dua ujung satu timbangan, dan Rust membiarkan kita memilih per kasus.

Ringkasan #

Monomorphisasi mengubah satu definisi generik menjadi salinan kode tipe-spesifik untuk setiap tipe yang benar-benar dipakai pemanggil — tiga pemanggilan dengan dua tipe menghasilkan tepat dua salinan, dan T lenyap dari biner. Perkakas nm -C memperlihatkan kedua salinan itu sebagai simbol largest::<f64> dan largest::<i32>, sementara perbandingan alamat function pointer dari dalam bahasa membuktikan keduanya adalah fungsi mesin yang berbeda. Hasil akhirnya adalah zero-cost abstraction: abstraksi generik tanpa biaya runtime berkat static dispatch, dengan harga berupa compile time dan ukuran biner yang tumbuh mengikuti jumlah instansiasi. Section Generics usai — pengetahuan <T>, struct dan enum generik, method, serta trait bound kini menjadi bekal untuk Section Traits yang membongkar kontrak-kontrak itu sampai ke akarnya.