Setiap kali Rust mengalokasikan variabel di stack atau memindahkan nilai antar fungsi, kompilator perlu menjawab satu pertanyaan: berapa byte ukuran nilai ini? Sebagian besar tipe yang telah kita pakai menjawab pertanyaan itu dengan angka tetap — i32 selalu 4 byte, &i32 selalu 8 byte pada arsitektur 64-bit. Tipe seperti itu disebut sized: ukurannya diketahui lengkap saat kompilasi. Namun ada keluarga tipe yang tidak bisa menjawab: [i32] bisa berisi 3 elemen atau 3 juta elemen, str bisa sepanjang 5 karakter atau 500, dan dyn Trait bisa menampung implementasi apa pun. Tipe-tipe itu disebut unsized atau DST (dynamically sized type). Section slice dan smart pointer sebelumnya sudah membuat kita akrab memakai tipe ini lewat &[T], &str, dan Box<dyn Trait>; chapter ini membuka mesin di baliknya — fat pointer — dan menjelaskan mengapa unsized type selalu harus bersembunyi di belakang pointer.

Ukuran yang Diketahui Saat Kompilasi #

Kompilator menghitung ukuran setiap tipe secara statis. Fungsi std::mem::size_of::<T>() memaparkan hasil perhitungan itu, dan size_of_val mengukur byte yang benar-benar ditempati sebuah nilai. Mari kita ukur beberapa tipe yang telah lalu:

rust
fn main() {
    println!("Ukuran i32      : {} byte", std::mem::size_of::<i32>());
    println!("Ukuran &i32     : {} byte", std::mem::size_of::<&i32>());
    println!("Ukuran [i32; 6] : {} byte", std::mem::size_of::<[i32; 6]>());
    println!("Ukuran &[i32]   : {} byte", std::mem::size_of::<&[i32]>());
    println!("Ukuran &str     : {} byte", std::mem::size_of::<&str>());
    println!("Ukuran String   : {} byte", std::mem::size_of::<String>());
}

Output yang Dihasilkan:

text
Ukuran i32      : 4 byte
Ukuran &i32     : 8 byte
Ukuran [i32; 6] : 24 byte
Ukuran &[i32]   : 16 byte
Ukuran &str     : 16 byte
Ukuran String   : 24 byte

Empat pengamatan penting dari output di atas:

  1. i32 = 4 byte: tipe sized paling sederhana; ukurannya tertanam di sistem tipe.
  2. &i32 = 8 byte: sebuah reference hanya menyimpan alamat, jadi ukurannya tetap 8 byte pada mesin 64-bit berapa pun ukuran nilai yang ditunjuk.
  3. [i32; 6] = 24 byte: array berukuran tetap adalah tipe sized karena jumlah elemennya menjadi bagian dari tipe — 6 elemen dikali 4 byte per elemen.
  4. &[i32] dan &str masing-masing 16 byte: keduanya dua kali ukuran reference biasa. Angka ini adalah petunjuk pertama keberadaan fat pointer yang akan kita bedah sebentar lagi.

Tipe yang Ukurannya Tidak Tetap #

Tipe [i32] — slice tanpa panjang — berbeda dari [i32; 6]: tipe tersebut tidak menyimpan jumlah elemen, sehingga kompilator tidak bisa menghitung ukurannya. Hal yang sama berlaku pada str dan dyn Trait. Karena itulah Rust menempatkan aturan ketat: nilai dengan tipe unsized tidak boleh berdiri sendiri sebagai variabel atau parameter. Percobaan melanggarnya berakhir di kompilasi:

rust
fn main() {
    let numbers: [i32] = [1, 2, 3];
    println!("Jumlah elemen: {}", numbers.len());
}

Pesan error lengkapnya:

text
error[E0308]: mismatched types
 --> main.rs:2:26
  |
2 |     let numbers: [i32] = [1, 2, 3];
  |                  -----   ^^^^^^^^^ expected `[i32]`, found `[i32; 3]`
  |                  |
  |                  expected due to this

error[E0277]: the size for values of type `[i32]` cannot be known at compilation time
 --> main.rs:2:9
  |
2 |     let numbers: [i32] = [1, 2, 3];
  |         ^^^^^^^ doesn't have a size known at compile-time
  |
  = help: the trait `Sized` is not implemented for `[i32]`
  = note: all local variables must have a statically known size
help: consider borrowing here
  |
2 |     let numbers: &[i32] = [1, 2, 3];
  |                  +

error: aborting due to 2 previous errors

Some errors have detailed explanations: E0277, E0308.
For more information about an error, try `rustc --explain E0277`.

Dua error tersebut saling melengkapi. E0277 menyebut inti masalahnya: Sized tidak diimplementasikan untuk [i32], dan setiap variabel lokal harus punya ukuran statis. E0308 muncul karena array literal [1, 2, 3] bertipe [i32; 3] yang tidak bisa dijadikan [i32] begitu saja. Perhatikan saran perbaikan dari kompilator: tambahkan & sehingga variabel menjadi &[i32] — reference ke slice. Inilah pintu keluar resminya.

Fat Pointer di Balik Referensi Slice #

Mengapa &[i32] berukuran 16 byte, dua kali &i32? Karena reference ke tipe unsized adalah fat pointer: dua kata data yang berjalan berpasangan. Untuk &[i32] dan &str, pasangan itu terdiri dari alamat byte pertama data dan jumlah elemen (atau jumlah byte untuk str). Mari kita bedah isi fat pointer dengan nyata:

rust
fn main() {
    let numbers: [i32; 6] = [10, 20, 30, 40, 50, 60];
    let slice: &[i32] = &numbers[1..4];

    println!("Panjang slice: {}", slice.len());
    println!("Isi slice: {:?}", slice);
    println!("Ukuran data slice: {} byte", std::mem::size_of_val(slice));
}

Output yang Dihasilkan:

text
Panjang slice: 3
Isi slice: [20, 30, 40]
Ukuran data slice: 12 byte

Mari kita bedah kode di atas:

  1. Baris 2 (let numbers: [i32; 6] = ...): array pemilik data — tipe sized [i32; 6] sepanjang 24 byte di stack.
  2. Baris 3 (let slice: &[i32] = &numbers[1..4];): operasi pengindeksan rentang menghasilkan fat pointer 16 byte: satu alamat menuju elemen 20 di dalam array, plus panjang 3. Tidak ada data yang disalin — hanya alamat dan panjang yang dicatat.
  3. Baris 5 (slice.len()): panjang diambil dari separuh kedua fat pointer, bukan dihitung ulang dari data. Berbeda dari Vec, str juga menyimpan panjang di fat pointer, bukan di dalam data.
  4. Baris 7 (std::mem::size_of_val(slice)): mengalikan panjang fat pointer dengan ukuran per elemen — 3 elemen dikali 4 byte = 12 byte. Angka ini mengukur data yang ditunjuk, bukan ukuran fat pointer-nya.

Struktur fat pointer dapat digambarkan sebagai berikut:

Diagram di atas menegaskan pembagian kerja: pointer data menunjuk awal jendela, length menentukan lebarnya. Dua slice berbeda dari array yang sama hanya berbeda pada pasangan angka 16 byte ini — alamat dan panjang — tanpa menyentuh data aslinya. Mekanisme yang sama berlaku untuk &str: string literal dan hasil String dipandang lewat fat pointer berisi alamat byte pertama dan panjang byte teks.

Fat Pointer untuk dyn Trait #

Bentuk kedua fat pointer muncul pada dyn Trait. Pada Chapter Trait Object kita memakai &dyn Trait dan Box<dyn Trait> tanpa membahas ukurannya; kini kita bisa membedahnya. Fat pointer dyn Trait tetap berukuran 16 byte, tetapi pasangan kata keduanya berbeda: alamat nilai konkret plus alamat vtable — tabel berisi alamat fungsi-fungsi milik implementasi yang sedang dibungkus. Itulah sebabnya pemanggilan method lewat dyn Trait disebut dynamic dispatch:

rust
trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

struct Square {
    side: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        3.14159 * self.radius * self.radius
    }
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn main() {
    let circle = Circle { radius: 7.0 };
    let square = Square { side: 5.0 };

    let shapes: [&dyn Shape; 2] = [&circle, &square];

    println!("Ukuran &dyn Shape: {} byte", std::mem::size_of::<&dyn Shape>());

    for shape in shapes {
        println!("Luas: {:.2}", shape.area());
    }
}

Output yang Dihasilkan:

text
Ukuran &dyn Shape: 16 byte
Luas: 153.94
Luas: 25.00

Mari kita bedah bagian kuncinya:

  1. Baris 29 (let shapes: [&dyn Shape; 2] = [&circle, &square];): array berisi dua fat pointer — keduanya bertipe &dyn Shape yang berukuran 16 byte. Nilai circle dan square tetap tinggal di stack sebagai tipe konkret; hanya pandangannya yang diubah.
  2. Fat pointer &circle: kata pertama berisi alamat struct Circle, kata kedua berisi alamat vtable khusus Shape for Circle. Vtable inilah yang menyimpan alamat fungsi area versi Circle.
  3. Fat pointer &square: pasangan serupa, tetapi vtable-nya milik Shape for Square.
  4. Baris 33-35 (for shape in shapes { ... }): pemanggilan shape.area() membaca alamat fungsi dari vtable milik masing-masing elemen, lalu melompat ke sana. Baris output “153.94” dihasilkan versi Circle, dan “25.00” dihasilkan versi Square — perbedaan perilaku dari fat pointer yang berbeda vtable-nya.

Melemaskan Batasan Sized dengan ?Sized #

Terakhir, mari kita buka rahasia di balik aturan “semua harus sized”. Parameter generik secara bawaan menerima batasan implisit T: Sized. Batasannya bisa dilonggarkan dengan penulisan T: ?Sized — dibaca “T boleh sized atau tidak”. Dengan batasannya dilonggarkan, fungsi generik dapat bekerja untuk str, [T], maupun tipe sized biasa, asalkan nilai diakses lewat reference:

rust
fn print_data_size<T: ?Sized>(value: &T) {
    println!("{} byte", std::mem::size_of_val(value));
}

fn main() {
    print_data_size(&42i32);
    print_data_size("Bandung");

    let names = [String::from("Budi"), String::from("Siti")];
    print_data_size(&names[..]);
}

Output yang Dihasilkan:

text
4 byte
7 byte
48 byte

Mari kita bedah kode di atas:

  1. Baris 1 (fn print_data_size<T: ?Sized>(value: &T) {): batas ?Sized menghapus tuntutan bahwa T harus punya ukuran compile-time. Nilai datang selalu sebagai &T, dan fat pointer-lah yang mengurus ukuran runtime bila T unsized.
  2. Baris 6 (print_data_size(&42i32);): T menjadi i32 — tipe sized; &i32 tetap reference biasa 8 byte, dan data terukur 4 byte.
  3. Baris 7 (print_data_size("Bandung");): T menjadi str — tipe unsized; argument menjadi fat pointer &str 16 byte, dan size_of_val membaca panjang 7 byte dari fat pointer tersebut.
  4. Baris 10 (print_data_size(&names[..]);): T menjadi [String] — unsized; hasil &names[..] adalah fat pointer menuju dua String di dalam array, sehingga data terukur 48 byte (dua struct String masing-masing 24 byte).

Satu fungsi dengan delapan baris kini melayani tipe sized dan unsized sekaligus. Signature ?Sized seperti ini banyak dipakai di standard library — misalnya banyak method pada Box menerima T: ?Sized — dan menjadi alat utama saat kita menulis abstraksi sendiri di atas DST.

Ringkasan #

Tipe sized punya ukuran tetap yang dihitung kompilator, dan seluruh parameter generik secara bawaan menuntutnya. Tipe unsized — [T], str, dyn Trait — tidak punya ukuran compile-time, sehingga tidak boleh berdiri sendiri sebagai variabel; pelanggarannya dilaporkan lewat E0277 yang menyarankan peminjaman. Kedua keluarga tipe ini bertemu di fat pointer: &[T] dan &str membawa (pointer, length) berukuran 16 byte, &dyn Trait membawa (pointer data, vtable) berukuran 16 byte, dan batas ?Sized membolehkan fungsi generik menerima keduanya. Setelah mesin pointer selesai dibedah, kita berpindah ke type parameter yang bahkan tidak menyimpan data sama sekali: PhantomData pada Chapter berikutnya.