Lewati ke konten

Pertanyaan Wawancara Rust

Ini adalah pertanyaan-pertanyaan yang muncul berulang kali di wawancara kerja Rust, dari posisi junior sampai senior. Setiap jawaban cukup singkat untuk diucapkan langsung, dan dilengkapi link ke pelajaran yang membahas topiknya lebih dalam.

Jangan menghafal jawaban ini kata per kata. Pewawancara bisa merasakannya, dan biasanya mereka akan mengajukan pertanyaan lanjutan untuk melihat apakah kamu benar-benar paham. Pakai halaman ini untuk mengecek apa yang sudah kamu kuasai, lalu buka pelajaran yang di-link untuk bagian yang masih terasa goyah. Kalau kamu bisa menjelaskan sebuah jawaban dengan kata-katamu sendiri dan menulis contoh kecilnya, berarti kamu sudah siap.

Cargo adalah build tool dan package manager resmi Rust. Cargo sudah ikut terpasang bersama Rust. Dengan satu tool ini kamu bisa:

  • membuat proyek (cargo new),
  • mem-build dan menjalankannya (cargo build, cargo run),
  • menjalankan tes (cargo test),
  • menambahkan library, yang disebut crate, dari crates.io (cargo add serde).

Pengaturan dan dependensi sebuah proyek ada di Cargo.toml. Versi pastinya dikunci di Cargo.lock, jadi hasil build bisa diulang dengan sama persis. Poin tambahan yang bagus: bahasa lain sering butuh beberapa tool berbeda untuk semua ini, tapi di Rust semua orang memakai Cargo, jadi setiap proyek terasa familier.

Pelajari lebih lanjut: Cargo dan crate, Mulai dengan Rust.

mut memungkinkan kamu mengubah nilai dari variabel yang sama. Tipenya harus tetap sama.

Shadowing (membayangi) berarti menulis let lagi dengan nama yang sama. Ini membuat variabel baru yang menutupi variabel lama. Variabel baru itu bahkan boleh punya tipe yang berbeda.

fn main() {
let spaces = " ";
let spaces = spaces.len(); // shadowing: variabel baru, sekarang berupa angka
println!("{spaces}");
let mut count = 1;
count += 1; // mut: variabel yang sama mendapat nilai baru
println!("{count}");
}

Output:

3
2
▶ Coba sendiri

Shadowing berguna untuk mengubah sebuah nilai selangkah demi selangkah (misalnya mem-parse teks menjadi angka) tanpa harus mengarang nama seperti input_str dan input_num. Dan variabel terakhirnya tetap immutable (tidak dapat diubah).

Pelajari lebih lanjut: Variabel.

Fungsi dipanggil saat runtime dengan jumlah argumen yang tetap dan bertipe. Makro berjalan saat compile time: ia menerima kode dan menulis kode tambahan sebelum compiler memeriksanya. Kamu bisa mengenali makro dari tanda !, seperti println!, vec!, dan format!.

Makro bisa melakukan hal-hal yang tidak bisa dilakukan fungsi, seperti menerima jumlah argumen yang berubah-ubah (println!("{} {}", a, b)) atau memeriksa format string saat compile time. Makro Rust juga bekerja pada struktur kode, bukan teks mentah, jadi terhindar dari bug-bug klasik makro di C:

macro_rules! square {
($x:expr) => {
$x * $x
};
}
fn main() {
// Di C, makro berbasis teks akan menghasilkan 2 + 1 * 2 + 1 = 5.
println!("{}", square!(2 + 1));
}

Output:

9
▶ Coba sendiri

Jujur saja soal kekurangannya: makro lebih sulit dibaca, ditulis, dan di-debug. Pakai fungsi kalau fungsi sudah cukup untuk pekerjaan itu.

Pelajari lebih lanjut: Makro.

Artinya, fitur tingkat tinggi tidak boleh membuat programmu lebih lambat daripada kode tingkat rendah yang kamu tulis sendiri dengan tangan. “Kamu tidak membayar untuk yang tidak kamu pakai, dan yang kamu pakai, tidak bisa kamu tulis lebih baik sendiri.”

Contohnya, rangkaian iterator seperti .iter().filter(...).map(...).sum() biasanya di-compile menjadi kode mesin yang sama dengan perulangan yang ditulis manual. Generics adalah contoh lainnya: compiler membuat salinan khusus untuk setiap tipe yang kamu pakai (disebut monomorphization), jadi tidak ada pencarian saat runtime.

Jujurlah soal bagian “cost”-nya: yang nol adalah biaya saat runtime. Kamu tetap membayarnya di waktu kompilasi, dan generics bisa membuat file program lebih besar.

Pelajari lebih lanjut: Iterator, Generics.

Ya. Method seperti .map() dan .filter() tidak melakukan apa-apa dengan sendirinya. Mereka hanya menyusun deskripsi pekerjaannya. Tidak ada yang terjadi sampai ada sesuatu yang mengonsumsi iterator itu, seperti .collect(), .sum(), atau perulangan for.

fn main() {
let numbers = vec![1, 2, 3];
let doubled = numbers.iter().map(|n| {
println!("menggandakan {n}");
n * 2
});
println!("Belum ada yang berjalan.");
let result: Vec<i32> = doubled.collect();
println!("{:?}", result);
}

Output:

Belum ada yang berjalan.
menggandakan 1
menggandakan 2
menggandakan 3
[2, 4, 6]
▶ Coba sendiri

Itulah juga sebabnya compiler memperingatkanmu kalau kamu menulis numbers.iter().map(...); tanpa pernah memakai hasilnya: kode itu tidak akan melakukan apa-apa.

Pelajari lebih lanjut: Iterator.

Ada tiga:

  1. Setiap nilai di Rust punya satu owner (pemilik), yaitu sebuah variabel.
  2. Hanya boleh ada satu owner pada satu waktu.
  3. Saat owner keluar dari scope, nilainya di-drop (memorinya dibebaskan).

Aturan-aturan ini membuat Rust bisa membebaskan memori tepat pada waktunya tanpa garbage collector, dan tanpa kamu harus memanggil free secara manual. Compiler yang memeriksanya, jadi kesalahan seperti “use after free” atau “double free” tidak mungkin terjadi di kode yang aman (safe).

Pelajari lebih lanjut: Ownership, Scope.

  • Move: meng-assign atau mengoper sebuah nilai berarti memindahkan ownership-nya. Variabel lama tidak bisa dipakai lagi. Ini perilaku default untuk tipe seperti String dan Vec. Yang disalin hanyalah pointer, panjang, dan kapasitas yang kecil.
  • Copy: untuk tipe kecil dan sederhana seperti i32, f64, bool, dan char, assignment membuat salinan bit-per-bit yang murah, dan kedua variabel tetap bisa dipakai.
  • Clone: .clone() adalah salinan dalam (deep copy) yang eksplisit. Untuk String, ia menyalin seluruh teks ke memori baru. Ini bisa mahal, makanya Rust memintamu menuliskannya secara jelas.
fn main() {
let x = 5;
let y = x; // Copy: x masih bisa dipakai
println!("{x} {y}");
let a = String::from("hai");
let b = a.clone(); // Clone: salinan penuh dari teksnya
let c = a; // Move: `a` tidak bisa dipakai lagi setelah baris ini
println!("{b} {c}");
}

Output:

5 5
hai hai
▶ Coba sendiri

Pelajari lebih lanjut: Ownership.

Apa itu trait Copy, dan tipe apa saja yang memilikinya?

Section titled “Apa itu trait Copy, dan tipe apa saja yang memilikinya?”

Copy adalah marker trait yang menyatakan “tipe ini bisa digandakan cukup dengan menyalin bit-bitnya”. Tipe yang Copy akan disalin, bukan di-move, saat di-assign.

Bilangan bulat, float, bool, char, referensi bersama (&T), serta tuple atau array yang hanya berisi tipe Copy, semuanya Copy. String, Vec, dan Box tidak, karena mereka memiliki memori di tempat lain, dan menyalin pointer-nya akan menghasilkan dua owner.

Kamu bisa men-derive-nya untuk tipe buatanmu sendiri, tapi hanya kalau setiap field-nya Copy:

#[derive(Clone, Copy)]
struct User {
name: String,
age: u32,
}
error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:2:8
|
1 | #[derive(Clone, Copy)]
| ---- in this derive macro expansion
2 | struct User {
| ^^^^
3 | name: String,
| ------------ this field does not implement `Copy`

Compiler bilang trait Copy tidak bisa diimplementasikan untuk tipe ini, karena field name (sebuah String) tidak mengimplementasikan Copy.

Copy selalu membutuhkan Clone juga. Selain itu, tipe yang mengimplementasikan Drop tidak bisa menjadi Copy.

Pelajari lebih lanjut: Trait yang sering dipakai, Ownership.

  • String adalah teks yang dimiliki (owned) dan bisa bertambah panjang, disimpan di heap. Kamu bisa mengubahnya, misalnya dengan push_str.
  • &str (sebuah “string slice”) adalah tampilan pinjaman ke sebuah teks. Isinya hanya pointer dan panjang. String literal seperti "halo" bertipe &str.

Aturan praktis yang bagus: fungsi yang hanya membaca teks sebaiknya menerima &str. Dengan begitu fungsi itu menerima keduanya, karena &String otomatis berubah menjadi &str.

fn greet(name: &str) {
println!("Halo, {name}!");
}
fn main() {
let owned: String = String::from("Ana");
let literal: &str = "Ben";
greet(&owned); // &String bisa dipakai di tempat yang meminta &str
greet(literal);
let mut full = owned;
full.push_str(" Smith"); // hanya String yang bisa bertambah panjang
greet(&full);
}

Output:

Halo, Ana!
Halo, Ben!
Halo, Ana Smith!
▶ Coba sendiri

Pelajari lebih lanjut: String, Slice.

Pada satu waktu, untuk satu nilai tertentu, kamu boleh punya salah satu dari:

  • referensi bersama (&T) sebanyak apa pun, atau
  • tepat satu referensi mutable (&mut T),

tapi tidak keduanya sekaligus. Dan setiap referensi harus selalu menunjuk ke data yang valid.

Kenapa? Kalau seseorang bisa mengubah data saat orang lain sedang membacanya, kamu akan mendapat bug seperti vektor yang memindahkan memorinya padahal kamu sedang memegang pointer ke dalamnya. Di dunia thread, itulah persisnya yang disebut data race. Rust mencegahnya saat kompilasi. Sejak edisi 2018, compiler pintar menentukan kapan sebuah peminjaman berakhir (ini disebut non-lexical lifetimes): peminjaman hanya berlaku sampai pemakaian terakhirnya, bukan sampai akhir blok.

Pelajari lebih lanjut: Borrowing.

Lifetime adalah sebutan compiler untuk “berapa lama sebuah referensi valid”. Setiap referensi punya lifetime. Hampir selalu compiler bisa menentukannya sendiri (ini disebut lifetime elision), jadi kamu tidak pernah melihatnya.

Kamu hanya perlu menuliskannya saat compiler tidak bisa tahu bagaimana referensi output berhubungan dengan input-nya, seperti fungsi yang mengembalikan salah satu dari dua string pinjaman:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let first = String::from("borrow checker");
let second = String::from("lifetimes");
println!("{}", longest(&first, &second));
}

Output:

borrow checker
▶ Coba sendiri

(String di contoh ini dibiarkan dalam bahasa Inggris karena hasilnya bergantung pada panjang teks.)

Poin penting untuk disampaikan saat wawancara: anotasi lifetime tidak mengubah berapa lama sesuatu hidup. Anotasi itu hanya menggambarkan hubungan, supaya compiler bisa memastikan tidak ada referensi yang hidup lebih lama dari data yang ditunjuknya.

Pelajari lebih lanjut: Lifetime.

Rust tidak punya null. Lalu apa penggantinya?

Section titled “Rust tidak punya null. Lalu apa penggantinya?”

Option<T>, sebuah enum dengan dua varian: Some(value) atau None. Karena tipenya berbeda dari T, compiler memaksamu menangani kasus None sebelum kamu bisa memakai nilainya. Kamu tidak mungkin lupa mengecek null, karena kodenya tidak akan bisa di-compile.

fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("Ana"),
2 => Some("Ben"),
_ => None,
}
}
fn main() {
for id in [1, 3] {
match find_user(id) {
Some(name) => println!("Ketemu {name}"),
None => println!("Tidak ada pengguna dengan id {id}"),
}
}
}

Output:

Ketemu Ana
Tidak ada pengguna dengan id 3
▶ Coba sendiri

Detail tambahan yang menarik: Option<&T> dan Option<Box<T>> tidak memakan memori ekstra dibandingkan pointer biasa. Secara internal, Rust memakai pola bit “null” untuk mewakili None, jadi keamanan ini sama sekali tidak ada biayanya.

Pelajari lebih lanjut: Option, If Let.

Apa itu trait? Apakah sama dengan interface?

Section titled “Apa itu trait? Apakah sama dengan interface?”

Trait mendefinisikan sekumpulan method yang bisa diimplementasikan oleh sebuah tipe. Trait mirip interface di Java, C#, atau Go, dan itu cara yang pas untuk memulai jawabanmu. Lalu sebutkan perbedaannya:

  • Trait bisa punya implementasi method default.
  • Kamu bisa mengimplementasikan trait untuk sebuah tipe setelah tipe itu ditulis, bahkan untuk tipe yang bukan buatanmu, misalnya mengimplementasikan trait buatanmu sendiri untuk i32. (“Orphan rule” menyatakan bahwa trait-nya atau tipenya harus milikmu.)
  • Trait dipakai sebagai batasan (bound) pada generics (T: Display), yang ditentukan saat kompilasi, dan juga sebagai trait object (dyn Display) saat runtime.
  • Rust tidak punya pewarisan class (inheritance). Trait ditambah komposisi menggantikannya.

Pelajari lebih lanjut: Trait, Trait yang sering dipakai.

Keduanya membuat satu fungsi bisa bekerja dengan banyak tipe. Bedanya ada di kapan tipenya ditentukan:

  • Generics (fn f<T: Speak>(x: &T)) memakai static dispatch. Compiler membuat salinan fungsi yang terpisah untuk setiap tipe. Pemanggilannya secepat pemanggilan fungsi biasa, tapi programnya bisa jadi lebih besar.
  • Trait object (&dyn Speak, Box<dyn Speak>) memakai dynamic dispatch. Hanya ada satu salinan fungsi, dan method yang tepat dicari saat runtime lewat sebuah tabel (disebut “vtable”). Sedikit lebih lambat, tapi memungkinkanmu mencampur tipe-tipe yang berbeda dalam satu koleksi.
trait Speak {
fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Speak for Dog {
fn speak(&self) -> String {
String::from("Guk")
}
}
impl Speak for Cat {
fn speak(&self) -> String {
String::from("Meong")
}
}
fn speak_static<T: Speak>(animal: &T) -> String {
animal.speak()
}
fn speak_dynamic(animal: &dyn Speak) -> String {
animal.speak()
}
fn main() {
println!("{}", speak_static(&Dog));
println!("{}", speak_dynamic(&Cat));
// Hanya trait object yang bisa mencampur tipe dalam satu Vec:
let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &animals {
println!("{}", animal.speak());
}
}

Output:

Guk
Meong
Guk
Meong
▶ Coba sendiri

Aturan praktisnya: mulailah dengan generics. Pakai trait object kalau kamu butuh koleksi berisi tipe-tipe yang berbeda, atau ingin menekan waktu kompilasi dan ukuran file biner.

Pelajari lebih lanjut: Generics, Trait object.

Apa bedanya Box<dyn Trait> dan impl Trait?

Section titled “Apa bedanya Box<dyn Trait> dan impl Trait?”

Keduanya memungkinkanmu mengatakan “suatu tipe yang mengimplementasikan trait ini” tanpa menyebut namanya. Bedanya ada di berapa banyak tipe konkret yang diperbolehkan:

  • impl Trait berarti satu tipe tertentu yang diketahui compiler, tapi tidak perlu kamu tuliskan. Ini static dispatch: tanpa alokasi heap, tanpa vtable, secepat kalau kamu menyebut tipenya sendiri. Di posisi argumen (fn f(x: impl Display)), ini hanyalah cara singkat menulis generic. Di posisi return, inilah cara mengembalikan sesuatu yang tipenya panjang atau tidak bisa dinamai, seperti closure dan rangkaian iterator.
  • Box<dyn Trait> adalah trait object di heap. Tipe sebenarnya bisa berbeda setiap kali, ditentukan saat runtime, dan method-nya dicari lewat vtable. Harganya satu alokasi dan satu pemanggilan tidak langsung, tapi inilah satu-satunya cara untuk mengembalikan salah satu dari beberapa tipe, atau menyimpan mereka dalam satu Vec.
use std::fmt::Display;
// impl Trait: satu tipe tersembunyi, ditentukan saat kompilasi.
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}
// Box<dyn Trait>: tipe sebenarnya bisa berubah saat runtime.
fn label(as_number: bool) -> Box<dyn Display> {
if as_number {
Box::new(42)
} else {
Box::new("empat puluh dua")
}
}
fn main() {
let v: Vec<u32> = evens(10).collect();
println!("{v:?}");
println!("{}", label(true));
println!("{}", label(false));
}

Output:

[0, 2, 4, 6, 8]
42
empat puluh dua
▶ Coba sendiri

Detail bagus untuk disebutkan: kalau kamu mengubah label agar mengembalikan impl Display dan membuang box-nya, kode itu tidak akan bisa di-compile. Cabang if mengembalikan i32 dan cabang else mengembalikan &str, padahal impl Trait harus satu tipe saja. Aturan praktisnya: pakai impl Trait secara default, dan beralihlah ke Box<dyn Trait> kalau kamu benar-benar perlu memilih di antara beberapa tipe saat runtime.

Pelajari lebih lanjut: Trait object, Generics.

Sebuah match harus menangani setiap kemungkinan nilai (exhaustive), kalau tidak kodenya tidak bisa di-compile. Artinya, saat kamu menambahkan varian baru ke sebuah enum, compiler menunjukkan setiap tempat yang perlu diperbarui. Di bahasa lain, case yang terlupa di sebuah switch diam-diam tidak melakukan apa-apa.

Kamu bisa memakai _ sebagai penangkap semua kasus, tapi hati-hati: penangkap semua kasus juga menyembunyikan varian baru yang kamu tambahkan nanti, jadi kamu kehilangan jaring pengaman itu.

Pelajari lebih lanjut: Match, Pattern matching, Enum.

Bagaimana Rust menangani error tanpa exception?

Section titled “Bagaimana Rust menangani error tanpa exception?”

Error yang bisa dipulihkan adalah nilai biasa bertipe Result<T, E>: entah Ok(value) atau Err(error). Fungsi yang bisa gagal menyatakannya di tipe return-nya, dan pemanggilnya wajib menanganinya. Tidak ada yang tiba-tiba melompat ke atas stack secara tak terlihat seperti exception.

Keuntungannya: dari signature sebuah fungsi kamu bisa melihat bahwa fungsi itu mungkin gagal, dan compiler memperingatkanmu kalau kamu mengabaikan sebuah Result. Harganya adalah sedikit lebih banyak mengetik, yang sebagian besar dihilangkan oleh operator ?.

Pelajari lebih lanjut: Result.

Kapan sebaiknya memakai panic! alih-alih mengembalikan Result?

Section titled “Kapan sebaiknya memakai panic! alih-alih mengembalikan Result?”
  • Kembalikan Result kalau kegagalan itu memang bisa terjadi dan pemanggilnya masuk akal untuk menanganinya: file yang tidak ada, input pengguna yang salah, koneksi jaringan yang timeout.
  • Pakai panic kalau sesuatu adalah bug, artinya program sampai di keadaan yang “seharusnya mustahil”, dan melanjutkan program justru salah. Mengakses indeks melewati akhir sebuah vektor menyebabkan panic karena alasan ini.

Panic juga tidak masalah di tes, contoh, dan prototipe cepat. Di sebuah library, hindari panic pada input yang dikendalikan pemanggil. Kembalikan error dan biarkan mereka yang memutuskan.

Pelajari lebih lanjut: Panic, Result.

  • unwrap() memberimu nilai di dalam Ok/Some, atau panic dengan pesan umum.
  • expect("pesan") melakukan hal yang sama, tapi panic dengan pesan buatanmu. Pakai untuk menjelaskan kenapa kamu yakin operasi itu tidak mungkin gagal.
  • ? tidak panic. Saat bertemu Err/None, ia mengembalikan error-nya dari fungsi saat ini, supaya pemanggil bisa menanganinya. Fungsinya sendiri harus mengembalikan Result atau Option.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> {
let n: i32 = text.parse()?; // kalau error, kembalikan ke pemanggil
Ok(n * 2)
}
fn main() {
println!("{:?}", double("21"));
println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("angka yang ditulis langsung di kode pasti bisa di-parse");
println!("{n}");
}

Output:

Ok(42)
Err(ParseIntError { kind: InvalidDigit })
7
▶ Coba sendiri

Di kode produksi, utamakan ?. Pakai expect kalau kegagalan berarti ada bug, dan simpan unwrap untuk tes dan eksperimen cepat.

Pelajari lebih lanjut: Result, Option.

Bagaimana cara membuat tipe error sendiri?

Section titled “Bagaimana cara membuat tipe error sendiri?”

Biasanya dengan sebuah enum yang mendaftar cara-cara kodemu bisa gagal, ditambah implementasi std::fmt::Display dan std::error::Error. Mengimplementasikan From untuk tipe error lain membuat ? bisa mengonversinya secara otomatis.

Dalam praktiknya, banyak proyek memakai crate bantuan: thiserror untuk mendefinisikan tipe error di library, dan anyhow untuk aplikasi yang hanya perlu meneruskan error ke atas beserta konteksnya. Untuk program cepat, Box<dyn Error> bisa menampung error apa pun.

Pelajari lebih lanjut: Error kustom.

Keduanya menyelesaikan dua sisi dari masalah yang sama, jadi jawaban jujurnya adalah “sering kali keduanya”:

  • thiserror untuk library, atau kode apa pun yang pemanggilnya perlu bereaksi terhadap error tertentu. Kamu menulis enum error sendiri, dan #[derive(Error)] mengisi kode boilerplate Display dan Error. Pemanggil mendapat tipe sungguhan yang bisa di-match, seperti ConfigError::MissingField.
  • anyhow untuk aplikasi, di mana kebanyakan error cukup dilaporkan, tidak perlu ditangani satu per satu. Setiap fungsi mengembalikan anyhow::Result<T>, ? menerima hampir semua tipe error, dan .context("saat membaca config") menambahkan penjelasan yang ramah manusia di atasnya. Kekurangannya: pemanggil tidak lagi bisa dengan mudah melakukan match pada error-nya.

Susunan yang umum adalah thiserror di dalam crate library dan anyhow di main serta bagian lain dari program biner. Tidak ada yang ajaib dari kedua crate ini. Keduanya hanya menghilangkan boilerplate di atas trait standar std::error::Error, jadi kamu selalu bisa menulis hal yang sama secara manual.

Pelajari lebih lanjut: anyhow dan thiserror, Error kustom.

Tidak. Memori dibebaskan saat owner-nya keluar dari scope, pada titik yang sudah diketahui compiler sebelumnya (ini disebut RAII, dan method pembersihnya adalah Drop). Hasilnya performa yang bisa diprediksi, tanpa jeda karena GC.

Kalau kamu benar-benar butuh ownership bersama, kamu memilihnya sendiri dengan reference counting (Rc atau Arc), yang membebaskan nilainya saat owner terakhir sudah tidak ada.

Pelajari lebih lanjut: Ownership, Smart pointer.

  • Box<T>: satu owner, nilainya di heap. Pakai untuk tipe rekursif (seperti tree atau linked list), nilai yang besar, atau trait object (Box<dyn Trait>).
  • Rc<T>: beberapa owner di satu thread. Ia menghitung referensi dan membebaskan nilainya saat hitungannya mencapai nol. Datanya hanya bisa dibaca.
  • Arc<T>: seperti Rc, tapi hitungannya atomic, jadi aman dibagi antar-thread. Sedikit lebih lambat dari Rc.
  • RefCell<T>: memungkinkanmu mengubah data lewat referensi bersama (“interior mutability”). Aturan borrowing dicek saat runtime, bukan saat kompilasi, dan melanggarnya akan menyebabkan panic.

Mereka bisa digabung. Rc<RefCell<T>> artinya “dibagi bersama dan bisa diubah, di satu thread”. Arc<Mutex<T>> adalah versi yang aman untuk thread.

use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2]));
let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow());
println!("jumlah owner: {}", Rc::strong_count(&shared));
}

Output:

[1, 2, 3]
jumlah owner: 2
▶ Coba sendiri

Pelajari lebih lanjut: Smart pointer.

Bisa, dan jujur saja mengakuinya. Rust menjamin keamanan memori (tidak ada use-after-free, tidak ada dangling pointer, tidak ada data race), tapi kebocoran memori tidak dianggap tidak aman. Dua cara yang umum:

  • Siklus Rc: dua nilai yang saling menunjuk dengan Rc tidak pernah mencapai hitungan nol. Solusinya, jadikan salah satu arahnya referensi Weak.
  • Fungsi seperti Box::leak dan std::mem::forget, yang memang sengaja membocorkan memori.

Di kode sehari-hari, kebocoran jarang terjadi, karena ownership membebaskan hampir semuanya secara otomatis.

Pelajari lebih lanjut: Smart pointer.

unsafe menandai sebuah blok (atau fungsi) tempat kamu bisa melakukan lima hal tambahan yang tidak bisa dicek oleh compiler, seperti men-dereference raw pointer, memanggil fungsi unsafe (termasuk kode C lewat FFI), atau mengakses static yang mutable. Di dalamnya, kamu yang berjanji bahwa aturan-aturannya dipatuhi.

Penting: unsafe tidak mematikan borrow checker atau pemeriksaan lainnya. Ia hanya membuka beberapa operasi tambahan itu.

unsafe dipakai untuk berkomunikasi dengan library C, menulis struktur data tingkat rendah, dan memeras performa di tempat compiler tidak bisa membuktikan bahwa sesuatu aman. Standard library sendiri memakai unsafe di dalam API yang aman seperti Vec. Praktik yang baik: buat blok unsafe sekecil mungkin, bungkus dengan API yang aman, dan tulis komentar // SAFETY: yang menjelaskan kenapa kodenya benar. Sebagian besar kode aplikasi tidak pernah membutuhkannya.

Pelajari lebih lanjut: Unsafe Rust.

Keduanya adalah marker trait yang dipakai compiler untuk menjaga keamanan thread:

  • Send: nilai bertipe ini bisa dipindahkan ke thread lain.
  • Sync: nilai bertipe ini bisa dibagi lewat referensi antar-thread (&T adalah Send).

Kebanyakan tipe memiliki keduanya, dan compiler mengimplementasikannya secara otomatis. Pengecualian yang terkenal adalah Rc, karena hitungannya tidak aman untuk thread. Coba kirim Rc ke sebuah thread, dan kodenya tidak akan bisa di-compile:

use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let handle = thread::spawn(move || {
println!("{data}");
});
handle.join().unwrap();
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
...
= help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`

Compiler bilang Rc<i32> tidak bisa dikirim antar-thread dengan aman, karena Rc<i32> tidak mengimplementasikan trait Send.

Ganti Rc dengan Arc, dan kodenya berhasil. Inilah sebagian besar yang dimaksud orang dengan “fearless concurrency” (konkurensi tanpa rasa takut) di Rust: kesalahan keamanan thread menjadi error saat kompilasi.

Pelajari lebih lanjut: Konkurensi.

Bagaimana cara membagi data yang bisa diubah antar-thread?

Section titled “Bagaimana cara membagi data yang bisa diubah antar-thread?”

Jawaban klasiknya adalah Arc<Mutex<T>>. Arc membuat beberapa thread bisa memiliki nilai itu. Mutex memastikan hanya satu thread pada satu waktu yang bisa mengubahnya: kamu memanggil .lock(), lalu mendapat sebuah guard yang otomatis membuka kunci saat keluar dari scope.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Total: {}", *counter.lock().unwrap());
}

Output:

Total: 4000
▶ Coba sendiri

Sebutkan juga alternatifnya: alih-alih berbagi memori, kirim pesan antar-thread lewat channel (std::sync::mpsc). Dan untuk penghitung sederhana, tipe atomic seperti AtomicUsize tidak membutuhkan lock.

Pelajari lebih lanjut: Konkurensi.

Apa bedanya channel dan shared state dengan Mutex?

Section titled “Apa bedanya channel dan shared state dengan Mutex?”

Keduanya adalah dua gaya untuk membagi pekerjaan antar-thread:

  • Channel (std::sync::mpsc) mengirim pesan. Sebuah nilai dipindahkan ke dalam channel dan keluar di sisi lain, jadi hanya satu thread yang memilikinya pada satu waktu. Cocok untuk pipeline dan worker pool: “para worker menghasilkan hasil, satu thread mengumpulkannya.” Biasanya lebih mudah dipahami, karena tidak ada dua thread yang menyentuh data yang sama.
  • Shared state (Arc<Mutex<T>>) berarti beberapa thread memakai data yang sama, bergantian lewat sebuah lock. Cocok kalau banyak thread perlu membaca dan memperbarui satu struktur, seperti cache atau tabel penghitung. Risikonya adalah deadlock, dan program jadi lambat kalau thread terus-menerus menunggu lock.

Berikut penghitung dari jawaban sebelumnya, ditulis ulang dengan channel. Setiap worker menghitung sendiri, tanpa lock, lalu mengirim hasilnya:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for _ in 0..4 {
let tx = tx.clone();
thread::spawn(move || {
let mut count = 0;
for _ in 0..1000 {
count += 1;
}
tx.send(count).unwrap();
});
}
drop(tx); // buang pengirim aslinya, kalau tidak rx.iter() tidak akan pernah selesai
let total: i32 = rx.iter().sum();
println!("Total: {total}");
}

Output:

Total: 4000
▶ Coba sendiri

Baris drop(tx) adalah detail bagus untuk disebutkan: rx.iter() terus menunggu sampai semua pengirim sudah tidak ada, jadi kalau baris itu terlupa, program akan menggantung. Di kedua gaya, Rust memeriksa keamanan thread saat kompilasi. Pilihannya tergantung desain mana yang lebih jelas untuk masalahmu, dan banyak program memakai keduanya.

Pelajari lebih lanjut: Channel, Konkurensi.

Apakah Rust mencegah semua bug konkurensi?

Section titled “Apakah Rust mencegah semua bug konkurensi?”

Tidak, dan mengatakannya menunjukkan pemahaman yang sungguhan. Safe Rust mencegah data race (dua thread menyentuh memori yang sama pada saat bersamaan, dengan setidaknya satu yang menulis). Rust tidak mencegah:

  • Deadlock, misalnya dua thread yang masing-masing menunggu lock yang dipegang thread lainnya.
  • Race condition di logikamu, misalnya mengecek sebuah nilai lalu bertindak berdasarkan nilai itu setelah thread lain mengubahnya.

Jadi Rust menghilangkan satu kelompok besar bug yang sulit, tapi kamu tetap perlu merancang penguncianmu dengan hati-hati.

Pelajari lebih lanjut: Konkurensi.

  • Thread dijalankan oleh sistem operasi. Masing-masing punya stack sendiri, dan OS berpindah-pindah di antara mereka. Thread sangat bagus untuk pekerjaan yang berat di CPU. Tapi ribuan thread memakan banyak memori.
  • Task async jauh lebih ringan. Sebuah async fn mengembalikan Future, dan sebuah runtime (biasanya Tokio) menjalankan banyak future di beberapa thread saja. Saat sebuah task sedang menunggu (jaringan, timer, file), .await membiarkan task lain berjalan. Ini ideal untuk program yang banyak menunggu, seperti web server yang melayani ribuan koneksi.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await; // pura-pura menunggu jaringan
format!("{name} selesai")
}
#[tokio::main]
async fn main() {
// Keduanya berjalan bersamaan, jadi ini makan waktu sekitar 200ms, bukan 300ms.
let (a, b) = tokio::join!(fetch("pertama", 200), fetch("kedua", 100));
println!("{a}");
println!("{b}");
}

Output:

pertama selesai
kedua selesai
▶ Coba sendiri

Dua detail jujur yang disukai pewawancara: future di Rust bersifat lazy dan tidak melakukan apa-apa sampai di-await, dan standard library tidak menyertakan runtime, jadi kamu sendiri yang memilihnya. Selain itu, kode yang memblokir (seperti perhitungan panjang atau std::thread::sleep) di dalam task async akan memblokir task-task lain di thread yang sama.

Pelajari lebih lanjut: Async/Await, Konkurensi.

  • Mulai dari yang sederhana, lalu masuk lebih dalam. Beri jawaban satu kalimat dulu (“Rc adalah pointer dengan reference counting untuk ownership bersama di satu thread”). Lalu tambahkan detail kalau mereka tampak tertarik.
  • Jelaskan alasannya. Aturan-aturan Rust ada untuk mencegah bug tertentu. Mengatakan “aturan borrowing mencegah data race dan dangling pointer” lebih kuat daripada sekadar membacakan aturannya.
  • Pakai contoh kecil. Menulis tiga baris kode, atau mendeskripsikannya, sering kali menjelaskan lebih banyak daripada pidato panjang.
  • Jujurlah soal trade-off. Rust punya harga: waktu kompilasi yang lebih lama, kurva belajar, dan lebih banyak mengetik untuk penanganan error. Mengakuinya menunjukkan pengalaman, bukan kelemahan.
  • Katakan “saya tidak tahu” kalau memang tidak tahu, lalu bernalarlah sambil berbicara. “Saya belum pernah memakai Pin secara langsung, tapi saya tahu ia berkaitan dengan future yang tidak boleh berpindah di memori” adalah jawaban yang bagus.
  • Siapkan sebuah cerita. Pewawancara sering bertanya, “Ceritakan saat borrow checker menghentikanmu.” Pikirkan satu contoh nyata dari kodemu sendiri, dan apa yang kamu pelajari darinya.
  • Berlatih dengan menulis kode. Halaman Latihan Rust serta proyek Game tebak angka dan Daftar tugas CLI adalah pemanasan yang bagus.

Kuis singkat

1. Smart pointer mana yang kamu pakai untuk membagi data yang hanya dibaca antar-thread?

2. Apa yang dilakukan operator ? saat bertemu Err?

3. Mana yang dicegah safe Rust saat kompilasi?

4. Apakah anotasi lifetime mengubah berapa lama sebuah nilai hidup?

Skor: 0 / 4

  • The Rust Programming Language (the Rust Book) (bahasa Inggris): buku resminya. Sebagian besar jawaban di halaman ini diambil langsung dari bab-bab tentang ownership, trait, error, dan konkurensi.
  • Rust by Example (bahasa Inggris): contoh-contoh singkat yang bisa dijalankan, berguna untuk menyegarkan ingatan soal sintaks tepat sebelum wawancara.
  • The Rust Reference (bahasa Inggris): aturan bahasa yang presisi, untuk saat pewawancara bertanya “tapi persisnya kapan itu terjadi?”
  • The Rustonomicon (bahasa Inggris): panduan tentang unsafe Rust, dengan latar belakang yang lebih dalam soal Send, Sync, dan tata letak memori untuk pertanyaan tingkat senior.
  • Asynchronous Programming in Rust (bahasa Inggris): bagaimana future, async/.await, dan runtime saling terhubung, di buku async resmi dari proyek Rust.