Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Apa itu Ownership?

Ownership (Kepemilikan) adalah sekumpulan aturan yang ngatur gimana program Rust ngelola memori. Semua program harus ngatur cara mereka pake memori komputer pas lagi jalan. Beberapa bahasa punya garbage collection (GC) yang secara rutin nyari memori yang udah nggak kepake pas programnya jalan; di bahasa lain, programmer harus secara eksplisit ngalokasiin dan ngebebasin memorinya. Rust pake pendekatan ketiga: memori dikelola lewat sistem ownership dengan sekumpulan aturan yang dicek sama compiler. Kalau ada aturan yang dilanggar, programnya nggak bakal ke-compile. Nggak ada satu pun fitur dari ownership yang bakal bikin program kita jadi lemot pas lagi jalan.

Karena ownership itu konsep baru buat banyak programmer, emang butuh waktu buat terbiasa. Kabar baiknya, makin kita berpengalaman sama Rust dan aturan sistem ownership-nya, kita bakal makin gampang buat nulis kode yang aman dan efisien secara alami. Semangat terus ya!

Pas kita paham ownership, kita bakal punya pondasi yang kuat buat mahamin fitur-fitur yang bikin Rust unik. Di bab ini, kita bakal belajar ownership lewat beberapa contoh yang fokus ke struktur data yang sangat umum: strings.

Stack dan Heap

Banyak bahasa pemrograman nggak nuntut kita buat sering-sering mikirin soal stack sama heap. Tapi di bahasa pemrograman sistem kayak Rust, apakah sebuah nilai ada di stack atau heap itu ngaruh ke gimana bahasanya berperilaku dan kenapa kita harus ngambil keputusan tertentu. Bagian-bagian dari ownership bakal dijelasin hubungannya sama stack dan heap nanti di bab ini, jadi ini penjelasan singkat buat persiapan.

Baik stack maupun heap adalah bagian dari memori yang tersedia buat dipake kode kita pas runtime, tapi mereka disusun dengan cara yang beda. Stack nyimpen nilai sesuai urutan yang dia dapet terus ngapus nilainya dengan urutan kebalikannya. Ini disebut last in, first out (LIFO). Bayangin tumpukan piring: pas kita nambahin piring lagi, kita taruh di atas tumpukannya, dan pas kita butuh piring, kita ambil satu dari paling atas. Nambahin atau ngambil piring dari tengah atau bawah nggak bakal semudah itu! Nambahin data disebut pushing onto the stack, dan ngambil data disebut popping off the stack. Semua data yang disimpan di stack harus punya ukuran yang udah tau dan tetap. Data dengan ukuran yang nggak tau pas compile time atau ukuran yang mungkin berubah harus disimpan di heap.

Heap itu kurang teratur: pas kita naruh data di heap, kita minta sejumlah tempat tertentu. Memory allocator bakal nemuin tempat kosong di heap yang cukup gede, nandain tempat itu lagi dipake, terus balikin sebuah pointer, yaitu alamat dari lokasi itu. Proses ini disebut allocating on the heap dan kadang disingkat jadi allocating doang (naruh nilai ke stack nggak dianggap sebagai allocating). Karena pointer ke heap itu ukurannya udah tau dan tetap, kita bisa nyimpen pointer-nya di stack, tapi pas kita mau datanya benar-benar, kita harus ngikutin pointer-nya. Bayangin kayak duduk di restoran. Pas masuk, kita bilang jumlah orang di grup kita, terus pelayannya nemuin meja kosong yang pas buat semuanya terus nganterin kita ke sana. Kalau ada temen kita yang telat dateng, mereka bisa nanya kita duduk di mana buat nemuin kita.

Pushing to the stack itu lebih cepet daripada allocating on the heap karena allocator nggak perlu cari-cari tempat buat nyimpen data baru; lokasinya selalu di paling atas stack. Sebagai perbandingan, ngalokasiin tempat di heap butuh kerja ekstra karena allocator harus nemuin dulu tempat yang cukup gede buat nampung datanya terus ngelakuin pembukuan buat persiapan alokasi selanjutnya.

Akses data di heap umumnya lebih lambat daripada akses data di stack karena kita harus ngikutin pointer buat nyampe ke sana. Prosesor zaman sekarang bakal lebih cepet kalau mereka nggak terlalu banyak lompat-lompat di memori. Lanjutin analoginya, bayangin seorang pelayan di restoran yang ngambil orderan dari banyak meja. Bakal paling efisien kalau dia ngambil semua orderan di satu meja sebelum lanjut ke meja berikutnya. Ngambil orderan dari meja A, terus meja B, terus meja A lagi, terus meja B lagi bakal jadi proses yang jauh lebih lambat. Dengan cara yang sama, prosesor biasanya bisa ngerjain tugasnya lebih baik kalau dia kerja sama data yang deket sama data lainnya (kayak di stack) bukannya yang jauh (kayak yang mungkin terjadi di heap).

Pas kode kita manggil sebuah fungsi, nilai-nilai yang dimasukin ke fungsinya (termasuk, mungkin, pointer ke data di heap) sama variabel lokal fungsinya bakal di-push ke stack. Pas fungsinya kelar, nilai-nilai itu bakal di-pop keluar dari stack.

Mantau bagian kode mana yang lagi pake data apa di heap, minimalisir jumlah data duplikat di heap, dan ngebersihin data yang udah nggak kepake di heap biar nggak keabisan tempat adalah masalah-masalah yang diselesein sama ownership. Sekali kita paham ownership, kita nggak bakal butuh sering- sering mikirin soal stack sama heap, tapi tau kalau tujuan utama ownership adalah buat ngelola data heap bisa bantu jelasin kenapa dia cara kerjanya kayak gitu.

Aturan Ownership

Pertama, yuk kita liat aturan-aturan ownership. Inget terus aturan ini pas kita ngerjain contoh-contoh yang bakal ngejelasin aturan ini:

  • Tiap nilai di Rust punya seorang owner (pemilik).
  • Cuma boleh ada satu owner dalam satu waktu.
  • Pas owner-nya keluar dari scope, nilainya bakal di-drop (dihapus).

Scope Variabel

Sekarang setelah kita ngelewatin sintaks dasar Rust, kita nggak bakal masukin semua kode fn main() { di contoh-contohnya, jadi kalau kita lagi ngikutin, pastiin buat masukin contoh-contoh berikut ke dalem fungsi main secara manual. Hasilnya, contoh-contoh kita bakal lebih singkat, biar kita bisa fokus ke detail aslinya bukannya kode boilerplate.

Sebagai contoh pertama dari ownership, kita bakal liat scope dari beberapa variabel. Sebuah scope adalah range di dalem program di mana sebuah item itu valid. Coba liat variabel ini:

#![allow(unused)]
fn main() {
let s = "hello";
}

Variabel s ngerujuk ke sebuah literal string, di mana nilai string-nya di-hardcoded ke teks program kita. Variabelnya valid dari titik di mana dia dideklarasikan sampe akhir dari scope saat ini. Listing 4-1 nunjukin program dengan komentar yang dianotasi di mana variabel s bakal valid.

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}
Listing 4-1: Sebuah variabel dan scope di mana dia valid

Dengan kata lain, ada dua titik waktu penting di sini:

  • Pas s masuk ke dalam scope, dia jadi valid.
  • Dia tetep valid sampe dia keluar dari scope.

Sampai titik ini, hubungan antara scope sama kapan variabel itu valid mirip sama bahasa pemrograman lainnya. Sekarang kita bakal kembangin pemahaman ini dengan ngenalin tipe String.

Tipe String

Buat gambarin aturan ownership, kita butuh tipe data yang lebih kompleks dari yang udah kita bahas di bagian “Tipe Data” di Bab 3. Tipe-tipe yang udah dibahas sebelumnya ukurannya udah tau, bisa disimpan di stack dan di-pop keluar dari stack pas scope-nya abis, dan bisa di-copy secara cepet dan gampang buat bikin instance baru yang independen kalau bagian kode lain perlu pake nilai yang sama di scope yang beda. Tapi kita mau liat data yang disimpan di heap dan eksplor gimana Rust tau kapan harus ngebersihin data itu, dan tipe String adalah contoh yang oke sekali.

Kita bakal fokus ke bagian-bagian String yang terkait sama ownership. Aspek-aspek ini juga berlaku buat tipe data kompleks lainnya, baik yang disediain standard library maupun yang kita bikin sendiri. Kita bakal bahas String lebih dalem di Bab 8.

Kita udah liat literal string, di mana nilai string-nya di-hardcoded ke program kita. Literal string emang nyaman, tapi mereka nggak cocok buat semua situasi di mana kita mungkin mau pake teks. Salah satu alasannya karena mereka itu immutable. Alasan lainnya karena nggak semua nilai string bisa diketahuin pas kita nulis kode: misalnya, gimana kalau kita mau ngambil input user terus nyimpannya? Buat situasi kayak gini, Rust punya tipe string kedua, yaitu String. Tipe ini ngelola data yang dialokasikan di heap dan makanya dia bisa nyimpen sejumlah teks yang ukurannya nggak kita ketahuin pas compile time. Kita bisa bikin sebuah String dari literal string pake fungsi from, kayak gini:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

Operator titik dua ganda :: ngebolehin kita buat ngelempokin fungsi from ini di bawah tipe String bukannya pake nama kayak string_from. Kita bakal bahas sintaks ini lebih lanjut di bagian “Sintaks Method” di Bab 5, dan pas kita bahas soal namespacing pake modul di “Path buat Ngerujuk Item di Pohon Modul” di Bab 7.

Jenis string ini bisa diubah (mutated):

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

Jadi, apa bedanya di sini? Kenapa String bisa diubah tapi literal nggak bisa? Bedanya ada di gimana kedua tipe ini nanganin memori.

Memori dan Alokasi

Di kasus literal string, kita tau isinya pas compile time, jadi teksnya di-hardcoded langsung ke file executable final-nya. Ini kenapa literal string itu cepet dan efisien. Tapi sifat-sifat ini cuma dateng dari sifat immutability literal string-nya. Sayangnya, kita nggak bisa naruh sepotong memori ke dalem biner buat tiap teks yang ukurannya nggak tau pas compile time dan ukurannya mungkin berubah pas lagi jalanin programnya.

Dengan tipe String, buat support sepotong teks yang mutable dan bisa nambah ukurannya, kita perlu ngalokasiin sejumlah memori di heap, yang nggak tau pas compile time, buat nampung isinya. Ini artinya:

  • Memorinya harus diminta dari memory allocator pas runtime.
  • Kita butuh cara buat balikin memori ini ke allocator pas kita udah selese pake String kita.

Bagian pertama itu kita yang ngerjain: pas kita manggil String::from, implementasinya minta memori yang dia butuhin. Ini hal yang cukup universal di bahasa pemrograman.

Tapi, bagian kedua itu beda. Di bahasa yang punya garbage collector (GC), GC bakal terus mantau dan ngebersihin memori yang udah nggak dipake lagi, dan kita nggak perlu mikirin itu. Di kebanyakan bahasa tanpa GC, itu tanggung jawab kita buat ngenalin kapan memori udah nggak dipake lagi terus manggil kode buat secara eksplisit ngebebasinnya, sama kayak pas kita memintanya. Ngelakuin ini dengan bener secara historis udah jadi masalah pemrograman yang susah. Kalau kita lupa, kita bakal buang-buang memori. Kalau kita lakuin terlalu cepet, kita bakal punya variabel yang nggak valid. Kalau kita lakuin dua kali, itu juga sebuah bug. Kita perlu masangin tepat satu allocate sama tepat satu free.

Rust ngambil jalur yang beda: memorinya otomatis dibalikin begitu variabel yang punya (owns) memori itu keluar dari scope. Ini versi contoh scope kita dari Listing 4-1 pake String bukannya literal string:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

Ada titik waktu alami di mana kita bisa balikin memori yang dibutuhin String kita ke allocator: pas s keluar dari scope. Pas sebuah variabel keluar dari scope, Rust manggil fungsi khusus buat kita. Fungsi ini namanya drop, dan di situlah pembuat String bisa naruh kode buat balikin memorinya. Rust manggil drop secara otomatis di kurung kurawal tutup.

Catatan: Di C++, pola nge-dealokasi resource di akhir masa hidup sebuah item ini kadang disebut Resource Acquisition Is Initialization (RAII). Fungsi drop di Rust bakal terasa familiar kalau kita pernah pake pola-pola RAII.

Pola ini punya pengaruh yang sangat dalem ke gimana kode Rust ditulis. Mungkin keliatan simpel sekarang, tapi perilaku kodenya bisa jadi nggak terduga di situasi yang lebih ribet pas kita mau punya banyak variabel pake data yang udah kita alokasiin di heap. Yuk kita eksplor beberapa situasi itu sekarang.

Interaksi Variabel dan Data dengan Move

Beberapa variabel bisa berinteraksi sama data yang sama dengan berbagai cara di Rust. Yuk kita liat contoh pake integer di Listing 4-2.

fn main() {
    let x = 5;
    let y = x;
}
Listing 4-2: Assign nilai integer variabel x ke y

Kita mungkin bisa nebak apa yang dilakuin kode ini: “bind nilai 5 ke x; terus bikin copy dari nilai di x terus bind ke y.” Kita sekarang punya dua variabel, x sama y, dan keduanya sama dengan 5. Ini emang bener yang terjadi, karena integer adalah nilai simpel dengan ukuran yang udah tau dan tetap, dan dua nilai 5 ini di-push ke stack.

Sekarang yuk liat versi String-nya:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

Ini keliatannya mirip sekali, jadi kita mungkin asumsikan kalau cara kerjanya bakal sama: yaitu, baris kedua bakal bikin copy dari nilai di s1 terus bind ke s2. Tapi nggak gitu yang sebenernya terjadi.

Coba liat Gambar 4-1 buat liat apa yang terjadi di String di balik layar. Sebuah String disusun dari tiga bagian, yang ditunjukin di sebelah kiri: sebuah pointer ke memori yang nampung isi string-nya, sebuah length (panjang), dan sebuah capacity (kapasitas). Grup data ini disimpan di stack. Di sebelah kanan adalah memori di heap yang nampung isinya.

Dua tabel: tabel pertama isinya representasi s1 di stack, terdiri 
dari length (5), capacity (5), dan sebuah pointer ke nilai pertama di tabel 
kedua. Tabel kedua isinya representasi data string di heap, byte demi byte.

Gambar 4-1: Representasi di memori dari sebuah String yang nampung nilai "hello" yang di-bind ke s1

Length itu seberapa banyak memori, dalam byte, yang lagi dipake isinya String saat ini. Capacity itu total jumlah memori, dalam byte, yang diterima String dari allocator. Perbedaan antara length sama capacity itu penting, tapi nggak di konteks ini, jadi buat sekarang, cuekin aja capacity-nya.

Pas kita nge-assign s1 ke s2, data String-nya di-copy, artinya kita copy pointer, length, dan capacity yang ada di stack. Kita nggak copy data yang ada di heap yang dirujuk sama pointer-nya. Dengan kata lain, representasi data di memori keliatannya kayak Gambar 4-2.

Tiga tabel: tabel s1 dan s2 merepresentasikan string itu di stack, 
masing-masing, dan keduanya nunjuk ke data string yang sama di heap.

Gambar 4-2: Representasi di memori dari variabel s2 yang punya copy dari pointer, length, dan capacity dari s1

Representasinya nggak keliatan kayak Gambar 4-3, yang merupakan penampakan memori kalau misalnya Rust malah ikut copy data heap-nya juga. Kalau Rust lakuin ini, operasi s2 = s1 bisa jadi sangat mahal dalam hal performa Pas runtime kalau datanya di heap itu sangat besar.

Empat tabel: dua tabel merepresentasikan data stack buat s1 dan s2, 
dan masing-masing nunjuk ke copy data string-nya sendiri di heap.

Gambar 4-3: Kemungkinan lain soal apa yang mungkin dilakuin s2 = s1 kalau Rust ikut copy data heap-nya juga

Tadi kita bilang kalau pas sebuah variabel keluar dari scope, Rust otomatis manggil fungsi drop dan ngebersihin memori heap buat variabel itu. Tapi Gambar 4-2 nunjukin kedua pointer data nunjuk ke lokasi yang sama. Ini masalah: pas s2 sama s1 keluar dari scope, mereka berdua bakal nyoba buat ngebebasin memori yang sama. Ini dikenal sebagai double free error dan merupakan salah satu bug memory safety yang kita sebutin sebelumnya. Ngebebasin memori dua kali bisa bikin kerusakan memori (memory corruption), yang berpotensi memicu kerentanan keamanan.

Buat mastiin keamanan memori, setelah baris let s2 = s1;, Rust nganggep s1 udah nggak valid lagi. Makanya, Rust nggak perlu ngebebasin apa pun pas s1 keluar dari scope. Coba liat apa yang terjadi pas kita nyoba pake s1 setelah s2 dibuat; nggak bakal bisa:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

Kita bakal dapet error kayak gini karena Rust ngelarang kita pake referensi yang udah nggak valid:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Kalau kita pernah denger istilah shallow copy (copy dangkal) sama deep copy (copy dalem) pas lagi belajar bahasa lain, konsep nyalin pointer, length, dan capacity tanpa nyalin datanya mungkin kedengeran kayak lagi bikin shallow copy. Tapi karena Rust juga ngebatalin variabel pertamanya, bukannya disebut shallow copy, ini dikenal sebagai move (pindah). Di contoh ini, kita bakal bilang kalau s1 udah di-move ke dalem s2. Jadi, apa yang benar-benar terjadi ditunjukin di Gambar 4-4.

Tiga tabel: tabel s1 dan s2 merepresentasikan string itu di stack, 
masing-masing, dan keduanya nunjuk ke data string yang sama di heap. Tabel s1 
di-grayed out karena s1 udah nggak valid; cuma s2 yang bisa dipake buat akses 
data heap-nya.

Gambar 4-4: Representasi di memori setelah s1 udah dibatalkan

Itu nyelesein masalah kita! Dengan cuma s2 yang valid, pas dia keluar dari scope cuma dia sendiri yang bakal ngebebasin memorinya, dan beres deh.

Sebagai tambahan, ada pilihan desain yang tersirat dari sini: Rust nggak bakal pernah otomatis bikin “deep” copy dari data kita. Makanya, penyalinan otomatis apa pun bisa diasumsikan nggak mahal dalam hal performa pas runtime.

Scope dan Assignment

Kebalikan dari ini juga bener buat hubungan antara scoping, ownership, dan memori yang dibebasin lewat fungsi drop juga. Pas kita ngasih nilai yang bener-bener baru ke variabel yang udah ada, Rust bakal manggil drop dan ngebebasin memori nilai aslinya langsung. Coba liat kode ini, contohnya:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

Kita awalnya mendeklarasikan variabel s terus di-bind ke sebuah String dengan nilai "hello". Terus kita langsung bikin String baru dengan nilai "ahoy" terus di-assign ke s. Di titik ini, nggak ada apa pun yang ngerujuk ke nilai asli di heap sama sekali.

Satu tabel s merepresentasikan nilai string di stack, nunjuk ke 
potongan data string kedua (ahoy) di heap, dengan data string asli (hello) 
di-grayed out karena udah nggak bisa diakses lagi.

Gambar 4-5: Representasi di memori setelah nilai awal udah diganti seluruhnya.

String aslinya makanya langsung keluar dari scope. Rust bakal jalanin fungsi drop padanya dan memorinya bakal langsung dibebasin. Pas kita nyetak nilainya di akhir, hasilnya bakal "ahoy, world!".

Interaksi Variabel dan Data dengan Clone

Kalau kita emang mau copy data heap dari String secara dalem (deeply copy), nggak cuma data stack-nya aja, kita bisa pake method umum namanya clone. Kita bakal bahas sintaks method di Bab 5, tapi karena method adalah fitur umum di banyak bahasa pemrograman, kita mungkin udah pernah liat sebelumnya.

Ini contoh method clone beraksi:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

Ini jalan dengan lancar dan secara eksplisit ngasilin perilaku yang ditunjukin di Gambar 4-3, di mana data heap-nya emang ikut di-copy.

Pas kita liat pemanggilan ke clone, kita tau kalau ada sejumlah kode sembarang yang lagi dijalankan dan kode itu mungkin mahal harganya. Ini adalah indikator visual kalau ada sesuatu yang beda yang lagi terjadi.

Data Khusus Stack: Copy

Ada hal unik lain yang belum kita bahas. Kode yang pake integer ini—yang sebagiannya ditunjukin di Listing 4-2—jalan dan valid:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

Tapi kode ini kayaknya bertentangan sama apa yang baru aja kita pelajari: kita nggak manggil clone, tapi x tetep valid dan nggak di-move ke dalem y.

Alasannya karena tipe-tipe kayak integer yang ukurannya udah tau pas compile time disimpan seluruhnya di stack, jadi nyalin nilai aslinya itu cepet buat dilakuin. Itu artinya nggak ada alasan kenapa kita mau ngelarang x buat tetep valid setelah kita bikin variabel y. Dengan kata lain, nggak ada bedanya antara deep copy sama shallow copy di sini, jadi manggil clone nggak bakal ngelakuin hal yang beda dari shallow copy biasa, makanya kita bisa ngelewatinnya.

Rust punya anotasi khusus namanya trait Copy yang bisa kita taruh di tipe-tipe yang disimpan di stack, kayak integer (kita bakal bahas traits lebih banyak di Bab 10). Kalau sebuah tipe mengimplementasikan trait Copy, variabel yang pakenya nggak bakal di-move, tapi lebih ke disalin secara sepele, bikin mereka tetep valid setelah di-assign ke variabel lain.

Rust nggak bakal ngebolehin kita ngasih anotasi Copy ke sebuah tipe kalau tipe itu, atau bagian apa pun darinya, udah mengimplementasikan trait Drop. Kalau tipenya butuh sesuatu yang khusus terjadi pas nilainya keluar dari scope terus kita nambahin anotasi Copy ke tipe itu, kita bakal dapet compile-time error. Buat belajar gimana cara nambahin anotasi Copy ke tipe kita buat mengimplementasikan trait-nya, liat “Derivable Traits” di Lampiran C.

Jadi, tipe apa aja yang mengimplementasikan trait Copy? Kita bisa cek dokumentasi buat tipe tertentu buat mastiin, tapi sebagai aturan umum, kumpulan nilai scalar simpel apa pun bisa mengimplementasikan Copy, dan nggak ada satu pun yang butuh alokasi atau bentuk resource apa pun yang bisa mengimplementasikan Copy. Ini beberapa tipe yang mengimplementasikan Copy:

  • Semua tipe integer, kayak u32.
  • Tipe Boolean, bool, dengan nilai true sama false.
  • Semua tipe floating-point, kayak f64.
  • Tipe karakter, char.
  • Tuple, kalau isinya cuma tipe-tipe yang juga mengimplementasikan Copy. Contohnya, (i32, i32) mengimplementasikan Copy, tapi (i32, String) nggak.

Ownership dan Fungsi

Mekanisme masukin nilai ke sebuah fungsi mirip sama pas kita ngasih nilai ke sebuah variabel. Masukin variabel ke fungsi bakal nge-move atau copy, sama kayak assignment. Listing 4-3 punya contoh dengan beberapa anotasi yang nunjukin di mana variabel masuk dan keluar dari scope.

Filename: src/main.rs
fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
Listing 4-3: Fungsi dengan ownership dan scope yang dianotasi

Kalau kita nyoba pake s setelah manggil takes_ownership, Rust bakal ngelepar compile-time error. Pengecekan statis ini ngelindungin kita dari kesalahan. Coba tambahin kode ke main yang pake s sama x buat liat di mana kita bisa pake mereka dan di mana aturan ownership ngelarang kita buat ngelakuin itu.

Nilai Return dan Scope

Balikin nilai (returning values) juga bisa mentransfer ownership. Listing 4-4 nunjukin contoh fungsi yang balikin sebuah nilai, dengan anotasi yang mirip kayak di Listing 4-3.

Filename: src/main.rs
fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}
Listing 4-4: Transfer ownership dari nilai return

Ownership sebuah variabel ngikutin pola yang sama tiap kalinya: ngasih nilai ke variabel lain bakal nge-move nilainya. Pas sebuah variabel yang isinya data di heap keluar dari scope, nilainya bakal dibersihin sama drop kecuali kalau ownership datanya udah di-move ke variabel lain.

Walaupun ini jalan, ngambil ownership terus balikin lagi di tiap fungsi itu agak ribet. Gimana kalau kita mau ngebolehin sebuah fungsi pake sebuah nilai tapi nggak usah ngambil ownership-nya? Agak nyebelin kan kalau apa pun yang kita masukin juga harus dibalikin lagi kalau kita mau pake lagi, ditambah data apa pun hasil dari body fungsinya yang mungkin juga mau kita balikin.

Rust ngebolehin kita buat balikin banyak nilai pake tuple, kayak yang ditunjukin di Listing 4-5.

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}
Listing 4-5: Balikin ownership dari parameter

Tapi ini terlalu banyak upacaranya (ceremony) dan kerjaan sekali buat konsep yang harusnya umum. Untungnya buat kita, Rust punya fitur buat pake sebuah nilai tanpa mentransfer ownership, namanya references (referensi).