Box<T>

Alokasi heap dan tipe rekursif dengan ukuran tak diketahui saat kompilasi.

Daftar isi

Topik ini membahas Box<T>, yaitu smart pointer paling sederhana pada Rust yang menempatkan satu nilai di heap lalu membebaskan alokasinya secara otomatis saat kepemilikan berakhir. Pembahasan mencakup makna istilah smart pointer, perilaku Box yang tetap mengikuti aturan kepemilikan biasa, serta peran utamanya sebagai penyelesai tipe rekursif yang ukurannya tidak dapat ditentukan saat kompilasi. Setelah membaca topik ini, keputusan membungkus nilai dengan Box dapat diambil dengan alasan ukuran dan bentuk tipe, bukan karena kebiasaan dari bahasa lain, dan pesan error E0072 pada definisi tipe rekursif dapat dibaca sampai ke usulan perbaikannya.

Smart pointer sebagai pemilik dengan perilaku penunjuk#

Referensi meminjam nilai tanpa memilikinya, sedangkan smart pointer memiliki nilai yang ditunjuknya sekaligus bertanggung jawab atas pembersihan memori nilai itu. Perilaku ganda tersebut dibangun di atas dua trait. Trait Deref mengizinkan kode mengakses nilai di balik penunjuk, baik lewat operator bintang * maupun lewat penerusan otomatis pada pemanggilan metode. Trait Drop menjalankan pembebasan alokasi ketika pemiliknya keluar dari lingkup. Dengan definisi ini, String dan Vec<T> sesungguhnya juga termasuk kategori tersebut, sebagaimana dirinci pada catatan berikut.

Box<T> adalah anggota paling sederhana dari keluarga tersebut. Seluruh isinya hanya satu penunjuk ke satu alokasi di heap, tanpa penghitung kepemilikan, tanpa pengunci, dan tanpa pemeriksaan tambahan apa pun. Karena bentuknya yang minimal, Box menjadi bahan dasar bagi tipe pembungkus lain yang membangun perilaku lebih kaya di atasnya. Di luar peran sebagai bahan dasar, Box dipakai sendirian ketika ukuran atau bentuk sebuah tipe menuntut alokasi di heap, dan dua kebutuhan itulah yang dibahas pada bagian-bagian berikutnya.

Alokasi di heap dengan Box#

Box::new(nilai) memindahkan nilai yang diberikan ke alokasi baru di heap, lalu menempatkan struktur Box di stack yang berisi satu penunjuk ke alokasi tersebut. Pada kompiler 64 bit, ukuran Box<T> adalah delapan byte selama T memiliki ukuran yang pasti, karena satu-satunya isinya adalah alamat. Bagi tipe yang ukurannya tidak pasti, misalnya potongan atau trait object, penunjuknya membawa informasi tambahan sehingga strukturnya lebih lebar, tetapi kasus itu berada di luar contoh-contoh topik ini. Sifat inilah yang menjadi kunci pada pembahasan tipe rekursif, karena ukuran Box diketahui tanpa perlu mengetahui ukuran nilai yang dibawanya. Nilai yang dibungkus tetap dapat dipakai tanpa persiapan, karena Box meneruskan trait seperti Display milik nilai di dalamnya.

rust box-sederhana.rs
fn main() {
    let bilangan = Box::new(24);

    println!("nilai di heap   : {}", bilangan);
    println!("nilai dibuka    : {}", *bilangan + 10);
    println!("ukuran Box<i32> : {}", std::mem::size_of::<Box<i32>>());
    println!("ukuran i32      : {}", std::mem::size_of::<i32>());
}
text Keluaran
nilai di heap   : 24
nilai dibuka    : 34
ukuran Box<i32> : 8
ukuran i32      : 4

Keluaran tersebut memperlihatkan dua hal. Pencetakan bilangan pada baris pertama berlangsung tanpa operator tambahan karena trait Display milik i32 diteruskan oleh Box. Baris kedua memakai operator bintang * untuk membuka penunjuk dan mengambil nilai mentahnya sebelum dijumlahkan. Dua baris terakhir menegaskan perbedaan tempat tinggal: struktur Box di stack selebar delapan byte, sedangkan nilai i32 sebenarnya berukuran empat byte dan ikut disimpan di heap. Diagram berikut merangkum tata letak memorinya.

stackbilangan: Box<i32>penunjuk, 8 byteheap24alokasi Box::newukuran di stack tetap 8 byte, berapa pun ukuran nilai yang dibawa
Box menyimpan satu penunjuk di stack menuju nilai yang dialokasikan di heap

Sebagai pemilik penuh, Box tunduk pada seluruh aturan kepemilikan yang berlaku bagi nilai biasa. Penugasan let b = a; atas Box memindahkan kepemilikan sehingga a tidak valid lagi, dan pengiriman Box ke fungsi menyerahkan kepemilikannya kepada parameter. Ketika pemilik keluar dari lingkup, Drop milik Box membebaskan alokasi heapnya tepat satu kali. Perbedaan Box dibandingkan nilai biasa hanya pada tempat nilai disimpan dan satu tingkat penunjuk yang dilalui saat mengaksesnya.

Tipe rekursif memerlukan penunjuk berukuran tetap#

Kompilator harus dapat menghitung ukuran setiap nilai yang hidup di stack pada saat kompilasi agar tata letak memori dapat disusun. Kebutuhan ini bertabrakan dengan definisi tipe yang memuat dirinya sendiri secara utuh, karena ukuran tipe seperti itu bergantung pada ukuran tipe itu sendiri dan penghitungannya berulang tanpa batas. Contoh yang sering dijumpai adalah daftar berantai yang didefinisikan sebagai enum dengan satu variant pembawa sisa daftar. Penolakannya dapat diperiksa langsung dengan rustc pada program berikut.

bash
export PATH="$HOME/.cargo/bin:$PATH"
# contoh kode yang ditolak kompilator: tipe rekursif tanpa perantara
printf 'enum Daftar {\n    Kons(i32, Daftar),\n    Kosong,\n}\n\nfn main() {\n    let _d = Daftar::Kosong;\n}\n' > salah.rs
rustc --edition=2021 salah.rs 2>&1 | sed -n '1,14p'
text Keluaran
error[E0072]: recursive type `Daftar` has infinite size
 --> salah.rs:1:1
  |
1 | enum Daftar {
  | ^^^^^^^^^^^
2 |     Kons(i32, Daftar),
  |               ------ recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Kons(i32, Box<Daftar>),
  |               ++++      +

error: aborting due to 1 previous error

Pesan error tersebut menyebut akar masalahnya dengan tepat pada baris recursive without indirection, yaitu variant Kons memuat Daftar secara utuh sehingga ukuran Daftar bergantung pada ukuran Daftar itu sendiri dan penghitungannya tidak pernah selesai. Usulan perbaikannya juga tercantum, yakni menyisipkan perantara seperti Box<Daftar>. Box menyelesaikan kebuntuan tersebut karena isinya hanya satu penunjuk sepanjang delapan byte, sehingga ukurannya tidak bergantung pada ukuran data yang dibawa. Dengan perantara ini ukuran setiap simpul menjadi tetap, dan rantai daftar dapat memanjang seberapa pun sesuai kebutuhan program saat berjalan.

Daftar berantai dengan Box#

Definisi yang telah diperbaiki dapat dipakai untuk membangun daftar sesungguhnya. Setiap simpul Kons memuat satu nilai dan satu Box<Daftar> yang menunjuk sisa daftar, sedangkan variant Kosong menutup rantainya. Program berikut menghitung panjang dan jumlah nilai daftar secara rekursif, lalu mencetak ukuran satu simpul untuk membuktikan bahwa ukuran tidak bergantung pada panjang daftar.

rust daftar-rantai.rs
// daftar berantai: setiap simpul memuat satu nilai dan sisa daftar di heap
enum Daftar {
    Kons(i32, Box<Daftar>),
    Kosong,
}

use Daftar::{Kons, Kosong};

fn panjang(daftar: &Daftar) -> i32 {
    match daftar {
        Kons(_, lanjutan) => 1 + panjang(lanjutan),
        Kosong => 0,
    }
}

fn jumlah(daftar: &Daftar) -> i32 {
    match daftar {
        Kons(nilai, lanjutan) => nilai + jumlah(lanjutan),
        Kosong => 0,
    }
}

fn main() {
    let daftar = Kons(10, Box::new(Kons(24, Box::new(Kons(100, Box::new(Kosong))))));

    println!("panjang daftar : {}", panjang(&daftar));
    println!("jumlah nilai   : {}", jumlah(&daftar));
    println!("ukuran simpul  : {}", std::mem::size_of::<Daftar>());
}
text Keluaran
panjang daftar : 3
jumlah nilai   : 134
ukuran simpul  : 16

Keluaran tersebut menunjukkan daftar tiga simpul dengan jumlah nilai 134, dan setiap simpul berukuran 16 byte berapa pun panjang daftar yang dibentuknya. Pada pola Kons(_, lanjutan), nilai lanjutan bertipe &Box<Daftar> dan diteruskan ke pemanggilan rekursif sebagai &Daftar melalui penerusan Deref, sehingga penelusuran berjalan tanpa membuka Box secara manual. Kepemilikan tersusun rapi secara berantai: simpul pertama memiliki simpul kedua lewat Box-nya, simpul kedua memiliki simpul ketiga, dan ketika pemilik daftar keluar dari lingkup, pembebasan berjalan dari simpul pertama sampai variant Kosong secara berturut-turut.

Kesalahan umum#

Kesalahan pertama adalah membungkus segala nilai dengan Box karena terbawa kebiasaan bahasa yang selalu menempatkan objek di heap. Akses melalui Box menambah satu tingkat penunjuk, dan alokasi heap lebih lambat daripada penempatan langsung di stack, sehingga Box yang tidak diperlukan justru menambah biaya tanpa manfaat. Bungkus nilai dengan Box ketika bentuk tipenya memang meminta, yaitu tipe rekursif, nilai besar yang sering dipindahkan, atau tipe yang ukurannya tidak seragam seperti trait object dyn Trait yang juga membutuhkan penunjuk berukuran tetap.

Kesalahan kedua adalah mengharapkan Box mengubah semantik kepemilikan, misalnya menganggap dua variabel dapat memegang Box yang sama sebagaimana referensi objek pada bahasa dengan garbage collection. Penugasan Box tetap merupakan move, dan pemakaian nama lama setelah penugasan ditolak dengan error E0382. Box hanya memindahkan tempat tinggal nilai ke heap dan tidak menghadirkan kepemilikan bersama, sehingga kebutuhan yang berbeda menuntut tipe pembungkus yang memang dirancang untuk kebutuhan itu.

Ringkasan#

  • Box<T> menempatkan satu nilai di heap dan berukuran delapan byte di stack selama nilai yang dibawanya berukuran pasti.
  • Box adalah pemilik penuh, sehingga aturan move, pengiriman ke fungsi, dan drop berlaku sama seperti nilai biasa.
  • Akses nilai berlangsung otomatis lewat Deref atau eksplisit lewat operator *, dengan biaya runtime satu tingkat penunjuk.
  • Tipe rekursif memerlukan perantara Box agar ukurannya hingga, dan definisi tanpa perantara ditolak dengan error E0072 lengkap dengan usulan penyisipannya.

Referensi#