Rust Ownership — Cara Rust Mengelola Memori
Rust Programming Language
Aldimhr • 15-02-2025 • 12 min read
Secara umum, terdapat dua pendekatan yang digunakan bahasa pemrograman untuk mengatur memori.
Pertama “Safety First”, menggunakan Garbage Collection untuk mengatur kapan data harus dihapus dari memori. Konsep ini digunakan dalam bahasa pemrograman seperti Python, Java, C#.
Kedua “Control first”, artinya programmer atau pembuatnya harus menentukan sendiri kapan data dihapus dari memori. Konsep ini digunakan dalam bahasa pemrograman C dan C++.
Perbedaan dari kedua konsep ini bisa dilihat dari siapa yang bertanggung jawab terhadap memori. Konsep pertama, sistem atau runtime bahasa yang bertanggung jawab terhadap alokasi atau de-alokasi memori (otomatis). Konsep kedua, manusia yang melakukan alokasi atau de-alokasi memori (manual).
Rust dengan konsep _Ownership-_nya, bertujuan untuk menggabungkan kedua konsep diatas, pembuat kode memiliki kontrol terhadap memori, juga memastikan keamaannya melalui compile-time checks, alih-alih menggunakan GC.
Ownership
Ownership di Rust secara umum dapat diartikan sebagai aturan dari kepemilikan (owner) sebuah object. Owner akan menentukan masa hidup dari object, ketika owner keluar dari scope maka owner beserta valuenya akan dihapus.
Mari kita lihat potongan kode Rust berikut
fn print_padovan() {
let mut padovan = vec![1,1,1]; // init
for i in 3..5 {
let next = padovan[i-3] + padovan[i-2];
padovan.push(next);
}
println!("P(1..5) = {:?}", padovan);
} // dropped
Owner di Rust menentukan _lifetime-_nya. Seperti kode di atas, padovan
sebagai owner dari value vec![1,1,1]
mendaftarkan dirinya di block memori yang ditandai dengan // init
lalu owner akan dihapus saat keluar dari scope yang ditandai dengan // dropped
. Setiap kali owner keluar dari scope, nilai yang dimilikinya akan dihapus.
Jika kita coba gambarkan dalam memori dari hasil akhir variabel padovan
akan terlihat seperti ini
Object padovan
memiliki 3 bagian dalam memori, pointer yang menunjukkan alamat ke heap (buffer), kapasitas data yang bisa ditampung buffer (capacity), dan panjang data yang saat ini ditampung (length).
Ketika padovan
keluar dari scope-nya, buffer akan dihapus atau dikosongkan — dalam istilah Rust, ini disebut ‘dropped’.
Contoh lain dari Ownership di Rust yaitu Box type. Box<T>
merupakan pointer ke value dari tipe T yang disimpan di heap. Ketika kita memanggil Box::new(v)
maka block heap akan dikosongkan untuk menampung value v
dan mengembalikan Box
yang mengarah ke value tersebut. Karena Box
adalah pemilik value, maka ketika Box
dropped, block di heap akan dikosongkan kembali.
Kita coba lihat kode Box
berikut
{
let point = Box::new((0.1, 0.2)); // point diinisiasi
let label = format!("{:?}", point); // label diinisiasi
assert_eq!(label, "(0.1, 0.2)");
} // point dan label akan didrop disini
Ketika program memanggil Box::new
maka beberapa block di heap dikosongkan untuk menampung value (0.1, 0.2)
dan pointer ke value tersebut akan dibentuk. Di dalam memori akan terlihat seperti ini
Variabel point
dan label
punya pointer ke masing-masing nilai di heap. Sama seperti sebelumnya, ketika keduanya keluar dari scope maka akan dropped beserta valuenya.
Hanya variabel yang merupakan owner dari value, struct owner dari fields, tuple, array dan vector owner dari element.
Contoh
struct Person {
name: String,
birth: i32
}
let mut composers: Vec<Person> = Vec::new();
composers.push(Person {name: "A".to_string(), birth: 1900});
composers.push(Person {name: "B".to_string(), birth: 1800});
composers.push(Person {name: "C".to_string(), birth: 2000});
for composer in composers {
println!("{}, born {}", composer.name, composer.birth);
}
composers
merupakan Vec<Person>
, sebuah vector yang memiliki elemen struct, dimana setiap struct memiliki field name
dan birth
. Di memori akan terlihat seperti ini
Ada beberapa hubungan kepemilikan dari gambar diatas, composers
owner dari vector, vector owner dari element, setiap Person
owner dari fields-nya dan string owner dari text. Sama seperti aturan sebelumnya, ketika composers
keluar dari scope maka value dan semua turunannya akan dikosongkan atau menjadi invalid ketika ada pointer yang mengarah ke alamat yang sama.
Selain lifetime, turunan kepemilikan, ownership di rust juga memiliki aturan seperti
- Kita bisa “memindahkan” owner dari variabel satu ke variabel yang lain.
- Kita bisa “meminjam” referensi ke value dari sebuah variabel; artinya referensi bukan pemilik value, dan akan dibatasi lifetime ownernya.
- Ada standar library
Rc
danArc
yang memungkinkan satu value memiliki lebih dari satu owner menggunakan reference-counter, dengan beberapa aturan. - Tipe data sederhana seperti integers, floating-point numbers, dan character dikecualikan dalam aturan ownership. Tipe data sederhana memiliki konsep sendiri dengan nama
Copy types
.
Di Rust, sebagian besar operasi seperti assignment nilai ke variabel, penggunaan variabel di fungsi, atau return value dari fungsi, di-handle dengan memindahkan ownership.
Ownership transfer
Kita coba lihat bagaimana Python mengatasi ownership dari contoh kode berikut
s = ['udon', 'ramen', 'soba']
t = s
u = s
Setiap object Python akan membawa reference count, untuk melacak berapa banyak pointer yang mereferensi ke alamat yang sama. Jika kita lihat pada baris pertama ketika value ['udon', 'ramen', 'soba']
diinisiasi ke variabel s, reference count berjumlah 1. Di memori akan terlihat seperti ini
Lalu apa yang terjadi ketika program menjalankan baris ke 2 dan 3 dari kode diatas? Python akan menggunakan alamat yang sama seperti variabel s, dan menambahkan reference count, jadi ada 3 Object yang mereferensi ke alamat yang sama. Di memori akan terlihat seperti ini
Proses ini bisa dibilang ‘murah’ karena memori tidak tumbuh menjadi besar, hanya menggunakan 1 alamat memori untuk 3 variabel. Tapi Python harus menjaga reference count untuk tahu kapan value harus dropped.
Sekarang kita coba lihat apa yang terjadi di C++
using namespace std;
vector<string> s = { "A", "B", "C" };
vector<string> t = s;
vector<string> u = s;
Pada baris pertama ketika variabel s diinisiasi, di memori akan terlihat seperti ini
Ketika baris 2 dan 3 dijalankan, C++ akan melakukan deep copy dari object sumbernya, sehingga terpisah antara variabel satu dan lainnya.
Proses C++ bisa disebut ‘mahal’ karena akan memakan memori lebih banyak dibanding dengan Python. Keuntungan dari proses ini jadi lebih mudah untuk mengatur kapan data harus dihapus dari memori tanpa bergantung pada variabel lain.
Lanjut, kita coba lihat bagaimana perlakuan yang sama di Rust
let s = vec!["A".to_string(), "B".to_string(), "C".to_string()];
let t = s;
let u = s;
Baris pertama variabel s diinisiasi
Baris kedua, variabel s dipindahkan kepemilikannya ke variabel t
Setelah baris kedua dieksekusi, yang terjadi adalah perpindahan kepemilikan, dari variabel s
ke variabel t
. Ini yang maksud dengan “hanya boleh satu owner untuk satu value”. Setelah ownership berpindah ke variabel t
, maka variabel s
jadi invalid karena tidak mengarah ke alamat memori manapun.
Lalu, ketika program menjalankan baris ketiga akan muncul Error. Karena variabel s
sudah invalid. Maka tidak boleh ada variabel lain yang mengarah variabel s
.
Di Rust, bisa juga misalnya kita ingin proses yang sama seperti C++, menggunakan clone()
di object yang ingin kita salin.
let s = vec!["A".to_string(), "B".to_string(), "C".to_string()];
let t = s.clone();
let u = s.clone();
Kita juga bisa meniru proses yang sama seperti Python menggunakan Rc
atau Arc
, kita akan bahas lebih detail nanti.
Kita coba lihat ownership dikondisi yang lain, seperti merubah value dari variabel
let mut s = "Init".to_string();
s = "Update".to_string(); // value "init" akan dropped disini
Ketika value dari variabel s diubah menjadi string “Update” maka saat itu juga value sebelumnya (“Init”) dihapus di memori.
Bagaimana jika kita memindahkan ownership-nya lalu menginisiasi nilai baru?
let mut s = "Init".to_string();
let t = s; // value "init" pindah kesini
s = "Update".to_string(); // variabel s akan memiliki value baru,
// string "Update"
Variabel t
akan mengambil ownership dari variabel s
yang berisi string “Init”, lalu update variabel s
dengan alamat memori baru yaitu string “Update”. Jadi, kedua object masih ada di memori hanya saja pemiliknya berbeda.
Lanjut, kita coba lihat apa yang terjadi di Control Flow
Control Flow
Ownership bisa berpindah ke variabel lain, atau bisa juga berpindah ke parameter function, atau return value dari function akan berpindah ke pemanggilnya dan lainnya.
Sekarang kita coba lihat kondisi ownership di control flow
let x = vec![1, 2, 3];
if c {
f(x); // value dari x bisa berpindah kesini
} else {
g(x); // value dari x juga bisa berpindah kesini
}
h(x); // value sudah dipindahkan, maka disini akan Error
// karena x sudah invalid
Hal yang sama terjadi ketika kita memanggil variabel yang sama berulang kali dalam loop
let mut x = vec![1, 2, 3];
while f() {
g(x); // error saat iterasi ke 2
}
Saat iterasi pertama x
akan dipindahkan ke function g
, lalu saat iterasi ke 2, function g
mencoba memanggil x
, dimana x
sudah dipindahkan saat iterasi 1, maka akan Error karena mencoba akses invalid value.
Kasus ini tidak terjadi ketika kita assign value baru saat looping dilakukan
let mut x = vec![1, 2, 3];
while f() {
g(x); // error saat iterasi ke 2
x = h();
}
e(x);
Kode diatas tidak ada masalah, karena saat iterasi pertama x
akan dipindahkan ke function g
, lalu x
diinisiasi kembali dengan function h
. Ketika iterasi ke 2 dilakukan, x
punya value baru dari return value function h
di iterasi sebelumnya. Begitu juga ketika kita mencoba akses diluar looping, karena x
masih valid hingga iterasi terakhir dilakukan.
Lanjut, apa yang terjadi ketika kita mencoba memindahkan sebagian value dari indexed content. Misalnya mengambil kepemilikan value pertama dari array, atau vector.
Indexed Value
Dicontoh sebelumnya kita sudah bahas, jika owner dipindahkan, maka akan jadi invalid saat diakses kembali. Tapi ternyata tidak semua value dapat menjadi invalid_._ Contohnya
// membuat vector dari string "101", "102" .. "105"
let mut v = Vec::new();
for i in 101..106 {
v.push(i.to_string());
}
// kita coba pindahkan owner dari v[2] dan v[4] ke variabel lain
let third = v[2]; // Error: Cannot move out of index of Vec
let fifth = v[4]; // disini juga akan Error
Jika kita mengikuti aturan sebelumnya, maka Rust perlu mengingat elemen ke 3 dan ke 5 saat menjadi invalid_,_ dan informasi ini perlu diingat hingga vector v
dropped. Kondisi ini, menurut Rust kurang efektif untuk system programming; menurutnya vector tetap vector, satu kesatuan utuh.
Rust lebih menyarankan untuk mengakses value dari vector menggunakan reference jika kita ingin menggunakan element dari vector tanpa memindahkan kepemilikan.
// membuat vector dari string "101", "102" .. "105"
let mut v = Vec::new();
for i in 101..106 {
v.push(i.to_string());
}
// menggunakan reference untuk akses element dari vector v
let third = &v[2];
let fifth = &v[4];
Lalu bagaimana jika kita ingin memindahkan kepemilikan dari salah satu element vector. Rust menyediakan beberapa cara
// membuat vector dari string "101", "102" .. "105"
let mut v = Vec::new();
for i in 101..106 {
v.push(i.to_string());
}
// 1. Mengambil elemen terakhir dari vector
let last = v.pop().expect("vector empty!");
assert_eq!(last, "105");
// 2. Menghapus value lalu mengisi kembali dengan element yang masih ada
let second = v.swap_remove(1);
assert_eq!(second, "102");
// 3. Menukar dengan value lain
let third = std::mem::replace(&mut v[2], "substitute".to_string());
assert_eq!(third, "103");
// hasil akhir dari vector v
assert_eq!(v, vec!["101", "104", "substitute"]);
Copy Types
Rust menganggap deep copy seperti di C++ akan ‘mahal’ jika dilakukan pada tipe data seperti vector, string, dan lainnya, karena berpotensi memakan banyak memori.
Pemindahan ownership menjaga beberapa tipe data tersebut jelas dan ‘murah’ Tapi, untuk tipe data yang simple seperti character, integer, Rust beranggapan tidak memerlukan konsep ownership karena tidak dibutuhkan.
Kita coba bandingkan 2 tipe data yang menggunakan ownership dan copy
let string1 = "A".to_string();
let string2 = string1;
let num1: i32 = 36;
let num2 = num1;
Di memori akan terlihat seperti ini
string1
dan string2
memiliki tipe data String, jadi tipe data ini dianggap akan memerlukan memori yang banyak jika menggunakan konsep copy, jadi tipe data String menggunakan konsep ownership.
Sementara num1
dan num2
memiliki perlakuan yang berbeda. Setiap kali di assign ke variabel yang lain, nilai yang dimiliki variabel sebelumnya akan dicopy dan variabel sebelumnya masih valid_._
Beberapa tipe data yang menggunakan konsep copy antara lain
- Semua tipe primitive: i32, u64, bool, char
- Array dengan element copy: [i32; 5]
- Tuple dengan semua element copy: (i32, bool)
Sedangkan tipe data yang menggunakan konsep ownership antar lain
- String
- Vec
- Box
- &mut T
- Struct atau enum yang memiliki field yang bukan copy type
Bagaimana dengan type yang kita definisikan sendiri? seperti struct
dan enum
secara default tidak menggunakan konsep copy
struct Label { number: i32 }
fn print(l: Label) {
println!("STAMP: {}", l.number);
}
let l = Label { number: 3 };
print(l);
println!("Label number is: {}", l.number); // Error: karena 'l' sudah
// berpindah ke function print()
Karena Label bukan copy type, maka ketika memberikan ownership ke function print()
akan error saat memanggilnya kembali di println!()
, karena l
sudah invalid.
Seperti yang disebutkan sebelumnya, struct
atau enum
yang memiliki element copy type akan menggunakan sifat dari copy type. Ini harus dilakukan secara eksplisit dengan menambahkan #[derive(Copy, Clone)]
.
#[derive(Copy, Clone)]
struct Label { number: i32 }
fn print(l: Label) {
println!("STAMP: {}", l.number);
}
let l = Label { number: 3 };
print(l);
println!("Label number is: {}", l.number); // berhasil,
// menggunakan konsep copy type
Tapi penggunakan #(derive(Copy, Clone)]
akan error ketika tidak semua element merupakan copy type.
#[derive(Copy, Clone)]
struct Label { name: String, number: i32 }
// error karena String bukan copy type
Rc dan Arc
Sejauh ini kita bisa menggunakan copy
untuk deep copy seperti yang C++ lakukan. Selain itu kita juga berbagi Ownership seperti apa yang Python lakukan, dengan Rc
dan Arc
.
Rc
(reference count) dan Arc
(atomic reference count) sebenarnya sangat mirip. Bedanya, Arc
aman digunakan untuk berbagi antar thread, sedangkan Rc
aman digunakan untuk non-thread untuk update reference count.
Kita coba lihat contoh python sebelumnya, di Rust akan menjadi seperti ini
use std::rc::Rc;
let s: Rc<String> = Rc::new("A".to_string())
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();
Clone Rc<T>
bukan berarti kita copy T, tapi kita menambahkan pointer lain ke variabel tersebut dan menambah reference count. Jadi dalam kode diatas memori yang sama dengan s
memiliki reference count sebanyak 3 (s, t, u).
Sama seperti aturan sebelumnya, ketika pointer Rc
keluar dari scope, maka semua value akan dropped.
Kita juga tidak dapat merubah value dari Rc
karena value dari Rc
bersifat immutable, artinya value tidak dapat berubah.
Selanjutnya mungkin perlu kita cari tahu juga terkait dengan reference, sifat dari &
dan &mut
dan bagaimana penggunaannya di Rust.
https://www.oreilly.com/library/view/programming-rust-2nd/9781492052586/
https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- Rust
- Rust Ownership