Smart Pointer#
Box Smart Pointer#
Sebuah pointer, sebuah konsep umum untuk sebuah variabel yang menyimpan alamat memory. Alamat memory ini menunjuk - pointing pada sebuah data. Smart Pointer, atau pointer pintar namun, adalah struktur data yang tidak hanya berperilaku seperti sebuah pointer, namun juga memiliki kapabilitas lain. Konsep smart pointer ini berawal dari C++.
Di artikel ini, kita akan membahas penggunaan Box, sebuah smart pointer yang sangat umum digunakan di Rust. Box digunakan untuk menunjuk pada data di heap. Box memiliki kapabilitas untuk mengalokasikan data di heap dan menghapusnya ketika sudah tidak digunakan.
Penggunaan Box adalah sebagai berikut
fn main() {
let boxed_value = Box::new(10);
println!("{}", boxed_value);
}
Dalam kode diatas, kita mengalokasikan 10 yang merupakan sebuah integer - tipe primitif pada heap, yang seharusnya ada pada stack. Box tidak mengimplementasikan Copy
karena ia bukan tipe primitif sehingga, bila ingin menggunakannya berulang kali, kita harus menggunakan borrow (&), atau clone untuk variabel box.
fn main() {
let boxed_value = Box::new(10);
let clone_box = boxed_value.clone();
println!("{}", clone_box);
}
Penggunaan Box Lebih Mendalam#
Sekarang, lihat enum
berikut
#[derive(Debug)]
enum List<T> {
Cons(T, List<T>),
Nil,
}
enum
di atas merupakan struktur data Cons List
yang berasal dari bahasa Lisp. Struktur data diatas akan terus memuat data di dalam varian Cons
secara rekursif hingga ia menemukan Nil
. Setelah menemukan Nil
, ia akan berhenti disana tanpa data apapun lagi. Bila kalian sudah membaca bab enum
, kalian pasti mengerti cara kerjanya.
Namun, perlu ditegaskan bahwa enum
di atas tidak akan bekerja. Mengapa?
Rust harus mengetahui berapa besar ruang yang sebuah tipe ambil pada saat compile time. Sedangkan pada enum
diatas, ia bersifat rekursif yang dalam teori, dia dapat berulang selamanya - tidak terbatas. Ia dapat terus memuat varian Cons yang memuat tipe List yang berupa varian Cons
juga dan terus begitu. Rust tidak mengetahui berapa besar si enum
List
pada saat compile time.
Mari kita coba mengimplementasikan contoh Cons List diatas lalu kita compile.
#[derive(Debug)]
enum List<T> {
Cons(T, List<T>),
Nil,
}
use List::*;
fn main() {
let l = Cons(42, Cons(69, Cons(613, Nil)));
println!("{:?}", l);
}
Bila kita mengcompile kode diatas, kita akan mendapatkan error berikut:
2 | enum List<T> {
| ^^^^^^^^^^^^ recursive type has infinite size
3 | Cons(T, List<T>),
| ------- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
|
3 | Cons(T, Box<List<T>>),
Error diatas menunjukkan bahwa kita memiliki tipe rekursif dengan ukuran tidak terbatas - rekursif tanpa indirection. Error diatas juga membantu kita dengan memberitahu bahwa kita harus me-wrap List
dalam Box
. Indirection disini berarti daripada kita menyimpan nilai dari List
yang rekursif secara langsung, kita harus menyimpan sebuah pointer, yang mengarah kepada nilai dari List
- yaitu Box
tersebut.
Sebelum kita membahas mengapa Box menyelesaikan masalah diatas, mari kita bahas bagaimana compiler Rust menghitung enum yang non-rekursif.
enum Enum {
A,
B(i32, i32),
C(f64, i64, String),
}
Disini kita memiliki sebuah enum
dengan 3 varian dimana dua varian memiliki nilai didalamnya. Cara Rust menghitung besar enum
diatas adalah dengan mengecek setiap varian dan tipe nilai yang dimiliki varian dan mencari varian mana yang membutuhkan ruang paling banyak - atau varian dengan ukuran terbesar. Karena kita hanya bisa menggunakan satu varian dalam satu waktu, maka besar varian yang paling besar akan menjadi besar dari Enum
itu sendiri.
Namun, untuk Cons
List
kita, saat Rust bertemu dengan tipe List
dalam varian Cons
, ia akan kembali lagi pada List
, dan berulang terus seperti itu sehingga tidak ada cara untuk mengetahui berapa besar si varian Cons
kita dan Rust tidak akan tahu juga berapa besar enum
List
kita.
Sekarang, seperti yang Rust compiler sarankan, kita akan me-wrap List
kita di dalam Box
. Mari kita lakukan.
#[derive(Debug)]
enum List<T> {
Cons(T, Box<List<T>>),
Nil,
}
use List::*;
fn main() {
// Untuk me-wrap nilai dalam Box, gunakan Box::new(nilai)
let l = Cons(42, Box::new(Cons(69, Box::new(Cons(613, Box::new(Nil))))));
println!("{:?}", l);
}
Dan kode kita akan tercompile: .. code:
Cons(42, Cons(69, Cons(613, Nil)))
Lalu bagaimanakah Box
menyelesaikan masalah ini? Pertama-tama, Box
adalah sebuah pointer. Ukuran dari sebuah pointer itu tetap. Ukuran pointer tidak berdasarkan besar atau jumlah data yang dia tunjuk. Box
menunjuk pada nilai List
kita selanjutnya yang berada pada memori heap, bukan pada varian Cons
sehingga ini akan seperti menaruh sesuatu bersebelahan dengan sesuatu yang lain, bukan menaruh sesuatu didalam sesuatu yang lain dan Box
menunjuk pada sesuatu yang bersebelahan tersebut yang dalam hal ini adalah nilai dari List
yang di-wrap dalam Box
pada varian Cons
.
Kesimpulan: Pada Rust, usize
itu pointer-sized sehingga ukuran dari Cons
adalah ukuran dari tipe yang kita berikan pada genericnya, dan usize
karena kita menyimpan pointer.
Deref Trait#
Sebuah smart pointer adalah sebuah tipe yang mengimplementasikan trait Deref dan trait Drop. Di artikel kali ini, kita akan membahas tentang trait Deref yang membuat kita dapat memperlakukan sebuah pointer seperti sebuah reference biasa. Lalu apa maksud dari memperlakukan sebuah pointer seperti sebuah reference biasa?
Sebelum itu, mari kita membahas lebih lanjut tentang Dereferencing.
Dereferencing#
Dereferencing adalah sebuah cara untuk mengakses nilai dari sebuah lokasi memori yang ditunjuk oleh sebuah pointer. Pada Rust, seperti dalam bahasa seperti C++, kita menggunakan operator * untuk dereferencing.
Sekarang, mari kita lihat kode berikut
fn main() {
let a = 10;
let b = &a;
assert_eq!(10, a);
assert_eq!(10, b);
}
Kita menggunakan macro assert_eq! pada kode diatas untuk mengecek apakah sebuah nilai setara (equal) dengan nilai yang lainnya. Namun, hal yang akan terjadi saat kita compile kode diatas adalah compile error yakni sebagai berikut
error[E0277]: can't compare `{integer}` with `&{integer}`
--> deref.rs:7:2
|
7 | assert_eq!(10, b);
| ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
|
= help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
Error diatas menyatakan kalau kita tidak bisa membandingkan sebuah integer dengan sebuah reference kepada sebuah integer. Mereka adalah tipe yang berbeda sehingga kita harus menggunakan dereference operator.
Dereference operator menggunakan tanda asterisk (*)
fn main() {
let a = 10;
let b = &a;
assert_eq!(10, a);
assert_eq!(10, *b);
}
Pada Rust, sebuah reference (&) sebenarnya juga merupakan pointer. Jadi, variabel b diatas adalah sebuah pointer yang menyimpan alamat memory a dan menunjuk kepada dimana valuenya, 10 disimpan.
Pada assertion pertama, kita membandingkan 10 dan a, yang hasilnya adalah benar. Pada assertion kedua, kita membandingkan 10 dan b yang sudah kita dereference sehingga b disana merupakan value yang ia tunjuk, yaitu 10. Kode akan berjalan dengan baik.
Apa itu Trait Deref?#
Kita dapat memperlakukan sebuah smart pointer seperti sebuah reference biasa.
Untuk contoh yang lebih lanjut, kita akan mengganti kode diatas dan menggunakan sebuah smart pointer daripada sebuah reference. Kita akan menggunakan Box<T>
.
fn main() {
let a = 10;
let b = Box::new(a);
assert_eq!(10, a);
assert_eq!(10, *b);
}
Seperti reference, Box
juga menunjuk kepada nilai yang disimpan di suatu tempat di memori, yang dalam hal ini adalah 10
. Perbedaannya disini adalah b
menunjuk pada sebuah copy dari 10
karena value tipe primitif akan di-copy, bukan di-move ownershipnya.
Box<T>
merupakan sebuah smart pointer yang mengimplementasikan trait Deref
. Inilah yang dimaksud dengan memperlakukan sebuah pointer seperti sebuah reference biasa. Trait Deref
memperbolehkan dereference operator bekerja pada Box<T>
sama seperti ia bekerja pada reference biasa.
Untuk mengerti bagaimana itu bekerja, kita akan mendefinisikan sebuah smart pointer kita sendiri yang akan mengimplementasikan Deref
.
Mendefinisikan smart pointer kita sendiri#
Kita akan mendefiniskan sebuah smart pointer yang serupa dengan Box<T>
. Hanya saja, disini kita tidak akan menyimpan nilai pada heap. Disini kita akan berfokus pada dereference operator, bukan dimana lokasi data disimpan.
struct Kotak<T>(T);
impl<T> Kotak<T> {
fn new(x: T) -> Self {
Self(x)
}
}
Sekarang, kita ganti Box<T>
di fungsi main dengan Kotak<T>
kita.
fn main() {
let a = 10;
let b = Kotak::new(a);
assert_eq!(10, a);
assert_eq!(10, *b);
}
Namun pada kode diatas, kita akan mendapatkan error dimana kita tidak bisa melakukan dereference pada tipe Kotak
kita. Sekarang, mari kita implementasikan trait Deref
.
Mengimplementasikan Deref pada smart pointer kita#
Pertama-tama. panggil trait Deref
di baris paling atas kode.
use std::ops::Deref;
Lalu kita implementasikan pada Kotak<T>
.
impl<T> Deref for Kotak<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
Kalian tidak perlu terlalu mengkhawatirkan type Target = T
untuk sekarang. Itu adalah sebuah associated type yang akan kita bahas di lain waktu. Kalian juga bisa mengganti return type method deref
menjadi hanya &T
.
Trait Deref
mengharuskan kita untuk mengimplementasikan satu method bernama deref
yang menerima &self
, dan mengembalikan sebuah reference kepada inner data atau data didalam struct
kita. Ingat, kita disini memakai tuple struct
yang menggunakan index 0, 1, dan seterusnya untuk mengambil inner data.
Sekarang, kode kita akan terlihat seperti ini
use std::ops::Deref;
struct Kotak<T>(T);
impl<T> Kotak<T> {
fn new(x: T) -> Self {
Self(x)
}
}
impl<T> Deref for Kotak<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
fn main() {
let a = 10;
let b = Kotak::new(a);
assert_eq!(10, a);
assert_eq!(10, *b);
}
Dan assertion kedua kita akan berhasil. Kita akan dapat melakukan dereference pada tipe Kotak
kita. Kode akan dapat kita compile.
Tanpa trait Deref
, compiler hanya mengetahui cara dereference reference saja. Trait Deref
membuat compiler Rust untuk memanggil method deref
untuk semua tipe yang mengimplementasikannya - untuk mendapatkan sebuah reference kepada sebuah nilai (Self::Target atau &T kita diatas), yang si compiler tahu bagaimana cara dereferencenya.
Saat kita menggunakan operator dereference kepada sebuah nilai yang telah mengimplementasikan trait Deref, assertion kedua kita di atas contohnya, sebenarnya Rust memanggil kode seperti berikut:
assert_eq!(10, *(b.deref()));
Rust akan memanggil method deref
terlebih dahulu untuk mendapatkan reference kepada nilai kita, yang pada kasus diatas adalah 10
, lalu melakukan dereferencing dengan operator dereference sehingga kira-kira hal yang terjadi adalah berikut:
Kotak(10) -> deref() terpanggil -> &10 -> dereference operator digunakan -> 10
Karena Rust melakukan hal itu secara otomatis, kita tidak perlu memikirkan perlu atau tidaknya memanggil method deref
secara eksplisit sehingga kita bisa memperlakukan reference biasa, dan sebuah tipe yang mengimplementasikan trait Deref
dengan sama.
Lalu kenapa method deref
mengembalikan reference kepada suatu nilai bukan nilainya itu sendiri?
Tentunya itu berhubungan dengan ownership pada Rust. Kalau deref
mengembalikan nilainya secara langsung, maka ownership dari nilai tersebut akan di-move keluar dari tipe kita, yang dalam kasus ini, smart pointer kita Kotak<T>. Dan di banyak kasus saat kita menggunakan operator dereference, kita tidak mau itu terjadi.
Deref Coercion#
Kita telah melihat bagaimana trait Deref
bekerja. Sekarang, kita akan melihat bagaimana trait Deref
bekerja dengan Deref Coercion
.
Deref Coercion adalah sebuah fitur sangat praktis yang Rust akan secara otomatis gunakan pada dan hanya pada tipe yang mengimplementasikan trait Deref
ketika tipe tersebut dijadikan argumen untuk fungsi atau method. Deref Coercion akan mengubah sebuah reference dari satu tipe kepada sebuah reference dari tipe yang berbeda.
Untuk lebih jelasnya, mari kita lanjutkan kode diatas dengan menambahkan sebuah prosedur untuk mencetak &str.
fn main() {
let a = 10;
let b = Kotak::new(a);
assert_eq!(10, a);
assert_eq!(10, *b);
}
fn prosedur(a: &str) {
println!("A adalah: {}", a);
}
Sekarang, kita akan membuat sebuah variabel baru yang menggunakan smart pointer Kotak<T>
kita dan sebuah String untuk nilai didalam Kotak<T>
kita, lalu kita panggil prosedur kita dengan variabel tersebut sebagai argumen.
fn main() {
let a = 10;
let b = Kotak::new(a);
assert_eq!(10, a);
assert_eq!(10, *b);
let c = Kotak::new(String::from("Hai"));
prosedur(&c);
}
fn prosedur(a: &str) {
println!("A adalah: {}", a);
}
Seperti yang kalian lihat, prosedur prosedur
menerima &str
sebagai argumen. Diatas, kita memberikannya sebuah reference kepada Kotak<T>
yang memiliki String
didalamnya. Namun, kode diatas tidak akan error! Kode diatas akan berjalan dengan sempurna.
Apa yang terjadi disini?
Inilah yang terjadi:
Saat kita memakai operator reference di argumen prosedur pada variabel c
, method deref
akan terpanggil dan kita akan mendapatkan sebuah reference kepada String
, nilai yang kita wrap dalam Kotak<T>
.
&Kotak<String> -> &String
Lalu, karena String
juga mengimplementasikan trait Deref
, bila kita menggunakan operator dereference, String
akan mengembalikan sebuah &str
sehingga hal yang akan terjadi berikutnya adalah:
&Kotak<String> -> &String -> &str
Rust melakukannya dengan otomatis. Tanpa Deref Coercion, bila ingin melakukan hal seperti diatas, kita harus menuliskannya seperti ini:
prosedur(&(*c)[..]);
Disana kita melakukan dereference pada c
sehingga kita mendapat sebuah String
, kemudian &
dan [..]
(slice
yang berisi operator RangeFull
) akan membuat sebuah &str
dari si String
yang setara dengan panjang penuh (full range) si String
. Sangat merepotkan bukan? Kode akan lebih sulit ditulis dan lebih sulit dibaca. Terima kasih Deref Coercion!
Untuk mutable reference, kita harus menggunakan trait DerefMut
.
Rust melakukan Deref Coercion bila ia bertemu tipe dan implementasi trait dalam tiga kasus:
Dari
&T
ke&U
ketikaT: Deref<Target=U>
Dari
&mut T
ke&mut U
ketikaT: DerefMut<Target=U>
Dari
&mut T
ke&U
ketikaT: Deref<Target=U>