Variabel #
Variabel di Rust terlihat biasa di permukaan — kamu menulis let x = 5 dan selesai. Tapi di balik sintaks sederhana itu, Rust menerapkan aturan-aturan yang tidak ada di bahasa lain: variabel immutable secara default, sistem ownership yang menentukan kapan memori dibebaskan, dan aturan borrowing yang mencegah bug konkuren sebelum program bahkan dijalankan. Semua aturan ini ditegakkan oleh compiler, bukan runtime — artinya jika kode kamu berhasil dikompilasi, sejumlah besar kelas bug memori sudah tereliminasi secara struktural. Artikel ini membahas cara kerja variabel Rust dari deklarasi paling sederhana hingga nuansa shadowing, scope, dan interaksinya dengan sistem memori.
Deklarasi dengan let
#
Semua variabel lokal di Rust dideklarasikan dengan kata kunci let. Tanpa tambahan apapun, variabel yang dideklarasikan bersifat immutable — nilainya tidak bisa diubah setelah ditetapkan pertama kali.
fn main() {
let x = 5;
println!("x = {}", x);
// ANTI-PATTERN: mencoba mengubah variabel immutable
x = 6; // error[E0384]: cannot assign twice to immutable variable `x`
}
Pesan error dari compiler Rust sangat deskriptif — ia tidak hanya memberi tahu apa yang salah, tapi juga menunjukkan baris mana yang pertama kali menetapkan nilai dan menyarankan solusinya (let mut).
Immutability by default bukan sekadar pilihan gaya. Ia mendorong kamu berpikir eksplisit: apakah data ini perlu berubah? Jika tidak, biarkan immutable. Ini mengurangi jumlah state yang harus dilacak saat membaca kode, dan memungkinkan compiler melakukan optimasi lebih agresif.
Deklarasi Tanpa Inisialisasi #
Rust mengizinkan kamu mendeklarasikan variabel tanpa langsung memberinya nilai, selama variabel itu pasti sudah diinisialisasi sebelum pertama kali digunakan. Compiler melacak alur ini secara statik — bukan hanya mewajibkan inisialisasi di saat deklarasi.
fn main() {
let nilai; // deklarasi tanpa inisialisasi — valid
// ANTI-PATTERN: menggunakan sebelum diinisialisasi
// println!("{}", nilai); // error[E0381]: used binding `nilai` isn't initialized
nilai = 42; // inisialisasi
println!("nilai = {}", nilai); // ✓ sekarang valid
// Pola umum: inisialisasi bersyarat
let status;
let kode = 200;
if kode == 200 {
status = "OK";
} else {
status = "Error";
}
// ✓ Compiler memverifikasi bahwa status pasti terinisialisasi
// di semua jalur eksekusi sebelum digunakan di sini
println!("Status: {}", status);
}
Mutabilitas dengan mut
#
Tambahkan mut setelah let untuk membuat variabel yang nilainya bisa diubah.
fn main() {
let mut skor = 0;
println!("Skor awal: {}", skor);
skor += 10;
skor += 25;
println!("Skor akhir: {}", skor); // 35
let mut nama = String::from("Budi");
nama.push_str(" Santoso"); // method yang membutuhkan &mut self
println!("Nama lengkap: {}", nama);
}
mut hanya berlaku untuk satu binding — ia tidak “menular” ke data di balik referensi secara otomatis. Ini penting saat bekerja dengan referensi:
fn main() {
let mut angka = 10;
// &mut angka — referensi mutable ke angka
let r = &mut angka;
*r += 5; // dereferensi untuk mengubah nilai
println!("angka = {}", angka); // 15
}
Kapan Memilih mut vs Immutable
#
Gunakan mut hanya jika variabel memang perlu berubah. Ini bukan soal performa — compiler Rust mengoptimasi keduanya dengan baik. Ini soal komunikasi ke pembaca kode: variabel mut memberi sinyal “nilai ini akan berubah di suatu titik”, sementara variabel biasa menjamin “nilai ini stabil dari sini ke akhir scope”.
// ANTI-PATTERN: deklarasi mut padahal tidak pernah diubah
fn luas_persegi(sisi: f64) -> f64 {
let mut hasil = sisi * sisi; // compiler akan memperingatkan: variable does not need to be mutable
hasil // langsung dikembalikan tanpa diubah
}
// BENAR: immutable karena memang tidak berubah
fn luas_persegi(sisi: f64) -> f64 {
let hasil = sisi * sisi;
hasil
}
// BENAR: atau langsung sebagai expression
fn luas_persegi(sisi: f64) -> f64 {
sisi * sisi
}
Type Inference dan Anotasi Tipe #
Rust adalah bahasa statically typed — tipe semua variabel diketahui saat kompilasi. Tapi kamu tidak selalu perlu menuliskannya, karena compiler Rust punya sistem type inference yang kuat.
fn main() {
// Type inference — compiler menyimpulkan tipe dari nilai
let a = 42; // i32 (default untuk integer literal)
let b = 3.14; // f64 (default untuk float literal)
let c = true; // bool
let d = 'z'; // char
let e = "halo"; // &str
// Anotasi tipe eksplisit — ditulis setelah nama variabel dengan titik dua
let f: u8 = 255;
let g: f32 = 2.5;
let h: i64 = -1_000_000;
// Suffix pada literal — alternatif anotasi untuk literal numerik
let i = 42u8;
let j = 3.14f32;
let k = 1_000_000i64;
println!("{} {} {} {} {}", a, b, c, d, e);
}
Anotasi tipe wajib dalam situasi ini:
fn main() {
// 1. Saat compiler tidak bisa menginfer dari konteks
let angka: i32; // tanpa nilai awal, tipe harus eksplisit
angka = 10;
// 2. Saat ada ambiguitas — misalnya parsing string ke angka
let parsed: u32 = "42".parse().unwrap(); // tanpa anotasi, compiler tidak tahu tipe apa yang diinginkan
// 3. Saat ingin tipe yang berbeda dari default
let kecil: i8 = 100; // default-nya i32, tapi kita mau i8
// 4. Untuk koleksi generik
let daftar: Vec<String> = Vec::new();
println!("{} {} {} {:?}", angka, parsed, kecil, daftar);
}
Shadowing #
Shadowing adalah fitur di mana kamu mendeklarasikan variabel baru dengan nama yang sama menggunakan let lagi. Variabel lama “tersembunyi” oleh variabel baru dalam scope yang tersisa.
fn main() {
let x = 5;
println!("x pertama: {}", x); // 5
let x = x + 1; // variabel BARU bernama x, bernilai 6
println!("x kedua: {}", x); // 6
{
let x = x * 2; // variabel BARU lagi, hanya dalam blok ini
println!("x dalam blok: {}", x); // 12
}
println!("x setelah blok: {}", x); // 6 — variabel x dari blok dalam sudah tidak ada
}
Shadowing vs Mutasi — Perbedaan Kritis #
Shadowing dan mut terlihat serupa tapi berbeda secara fundamental:
fn main() {
// Shadowing: membuat variabel BARU — bisa ganti tipe
let spasi = " "; // tipe: &str
let spasi = spasi.len(); // tipe: usize — BERBEDA tipe, ini valid dengan shadowing
println!("Jumlah spasi: {}", spasi);
// ANTI-PATTERN: mencoba ganti tipe dengan mut — tidak bisa
let mut teks = "hello"; // tipe: &str
teks = teks.len(); // error[E0308]: mismatched types — expected `&str`, found `usize`
}
flowchart TD
A{Perlu mengubah nilai variabel?}
A -- Ya --> B{Perlu mengubah tipe sekaligus?}
A -- Tidak --> F[Gunakan variabel immutable biasa]
B -- Ya --> C[Gunakan Shadowing\nlet x = transformasi_x]
B -- Tidak --> D{Perubahan terjadi berkali-kali\natau di dalam loop?}
D -- Ya --> E[Gunakan mut\nlet mut x = ...]
D -- Tidak --> CPenggunaan Shadowing yang Idiomatis #
Shadowing sangat berguna untuk transformasi bertahap pada nilai yang sama — tanpa harus menciptakan nama variabel baru di setiap langkah.
fn proses_input(input: &str) -> u32 {
// Setiap langkah "menyempurnakan" input dengan nama yang sama
let input = input.trim(); // &str → &str (bersih dari whitespace)
let input = input.to_lowercase(); // &str → String
let input = input.replace('-', ""); // String → String (hapus tanda hubung)
let input: u32 = input.parse().expect("Bukan angka valid"); // String → u32
input
}
fn main() {
let hasil = proses_input(" 123-456 ");
println!("Hasil: {}", hasil); // 123456
}
Tanpa shadowing, kamu harus membuat input_trimmed, input_lower, input_cleaned, input_parsed — nama yang tidak menambah kejelasan apapun.
Scope dan Drop Otomatis #
Scope adalah rentang kode di mana sebuah variabel valid dan bisa digunakan. Di Rust, scope punya implikasi langsung terhadap memori: ketika variabel keluar dari scope-nya, Rust memanggil fungsi drop secara otomatis untuk membebaskan memori yang dialokasikan variabel tersebut. Tidak ada garbage collector — pembebasan memori terjadi di titik yang deterministik dan bisa diprediksi.
fn main() {
let a = String::from("luar"); // a masuk scope
{
let b = String::from("dalam"); // b masuk scope
println!("a = {}, b = {}", a, b); // keduanya valid di sini
} // b keluar scope → Rust memanggil drop(b) → memori b dibebaskan
println!("a = {}", a); // a masih valid
// println!("b = {}", b); // error: b tidak ditemukan di scope ini
} // a keluar scope → drop(a) → memori a dibebaskan
stateDiagram-v2
[*] --> Deklarasi: let x = nilai
Deklarasi --> Valid: Dalam scope
Valid --> Valid: Digunakan, dimodifikasi (jika mut)
Valid --> Drop: Keluar dari scope
Drop --> [*]: Memori dibebaskan otomatisScope sebagai Alat Kontrol Memori #
Kamu bisa menggunakan blok {} secara eksplisit untuk mengontrol kapan sebuah nilai di-drop — berguna untuk sumber daya seperti koneksi database, file, atau lock.
fn main() {
// Skenario: lock mutex hanya dibutuhkan untuk operasi tertentu
use std::sync::Mutex;
let data = Mutex::new(vec![1, 2, 3]);
// ANTI-PATTERN: lock dipegang selalu hingga akhir fungsi
let mut guard = data.lock().unwrap();
guard.push(4);
// Lock masih aktif sampai akhir main — memblokir thread lain lebih lama dari perlu
// BENAR: batasi scope lock dengan blok eksplisit
{
let mut guard = data.lock().unwrap();
guard.push(4);
} // lock dilepas di sini — thread lain bisa mengakses data lebih cepat
println!("Selesai");
}
Ownership — Variabel sebagai Pemilik Data #
Setiap nilai di Rust punya tepat satu owner (pemilik) pada satu waktu. Ketika ownership berpindah, pemilik lama tidak bisa lagi mengakses nilai tersebut. Aturan ini ditegakkan oleh compiler, bukan runtime.
Move Semantics #
Untuk tipe yang dialokasikan di heap (seperti String, Vec, dll.), assignment memindahkan ownership — bukan menyalin data.
fn main() {
let s1 = String::from("halo");
let s2 = s1; // ownership PINDAH dari s1 ke s2
// ANTI-PATTERN: menggunakan s1 setelah di-move
println!("{}", s1); // error[E0382]: borrow of moved value: `s1`
// BENAR: gunakan s2
println!("{}", s2); // ✓
}
Kenapa Rust melakukan ini? Karena jika dua variabel menunjuk ke data heap yang sama, siapa yang bertanggung jawab membebaskan memorinya? Dengan aturan satu owner, tidak ada ambiguitas — s2 yang bertanggung jawab, dan ketika s2 keluar scope, memorinya dibebaskan tepat sekali.
Copy Types — Tipe yang Tidak Di-move #
Tipe yang sepenuhnya hidup di stack (ukuran diketahui saat kompilasi, biaya salin murah) mengimplementasikan trait Copy. Untuk tipe ini, assignment menyalin nilai — ownership tidak berpindah.
fn main() {
// Tipe Copy: integer, float, bool, char, tuple dari Copy types
let x = 5;
let y = x; // COPY — x tetap valid
println!("x = {}, y = {}", x, y); // keduanya valid ✓
let a = true;
let b = a;
println!("{} {}", a, b); // ✓
// Tipe NON-Copy: String, Vec, Box, dan tipe heap lainnya
let s1 = String::from("halo");
let s2 = s1; // MOVE — s1 tidak valid lagi
// println!("{}", s1); // error ✗
println!("{}", s2); // ✓
}
| Tipe | Perilaku Assignment | Alasan |
|---|---|---|
i8, i16, i32, i64, i128, isize | Copy | Ukuran tetap di stack |
u8, u16, u32, u64, u128, usize | Copy | Ukuran tetap di stack |
f32, f64 | Copy | Ukuran tetap di stack |
bool, char | Copy | Ukuran tetap di stack |
(T, U) jika T dan U Copy | Copy | Semua elemen di stack |
[T; N] jika T Copy | Copy | Semua elemen di stack |
String | Move | Data di heap, ukuran dinamis |
Vec<T> | Move | Data di heap, ukuran dinamis |
Box<T> | Move | Pointer ke heap |
Clone — Salin Eksplisit #
Jika kamu perlu dua salinan independen dari nilai heap, gunakan .clone():
fn main() {
let s1 = String::from("halo");
let s2 = s1.clone(); // buat salinan lengkap di heap
// Keduanya valid dan independen
println!("s1 = {}, s2 = {}", s1, s2);
// ANTI-PATTERN: clone berlebihan pada tipe Copy
let x = 5;
let y = x.clone(); // tidak salah, tapi berlebihan — cukup tulis `let y = x`
}
.clone()melakukan deep copy yang bisa mahal untuk data besar. Jangan gunakan.clone()sebagai pelarian dari error borrow checker sebelum benar-benar memahami apakah copy memang dibutuhkan. Seringkali borrowing (&T) adalah solusi yang lebih tepat.
Borrowing — Meminjam Tanpa Mengambil Ownership #
Borrowing memungkinkan kamu memberikan akses ke nilai tanpa memindahkan ownership-nya. Kamu “meminjamkan” nilai — si peminjam bisa menggunakannya, tapi pemilik asli tetap bertanggung jawab atas drop.
Immutable Borrow (&T)
#
fn panjang_string(s: &String) -> usize {
s.len()
// s keluar scope, tapi tidak di-drop — karena hanya borrowed, bukan owned
}
fn main() {
let s = String::from("halo dunia");
let panjang = panjang_string(&s); // pinjamkan s, bukan pindahkan
println!("'{}' punya {} karakter", s, panjang); // s masih valid ✓
}
Boleh ada banyak immutable borrow bersamaan — membaca data secara paralel tidak menimbulkan masalah:
fn main() {
let s = String::from("data");
let r1 = &s;
let r2 = &s;
let r3 = &s;
// Ketiganya valid bersamaan — semuanya hanya membaca
println!("{} {} {}", r1, r2, r3);
}
Mutable Borrow (&mut T)
#
Mutable borrow memberi akses baca dan tulis ke nilai tanpa mengambil ownership-nya. Aturannya ketat: hanya satu mutable borrow yang boleh aktif pada satu waktu, dan tidak boleh ada immutable borrow bersamaan dengan mutable borrow.
fn tambahkan_kata(s: &mut String) {
s.push_str(", dunia");
}
fn main() {
let mut s = String::from("halo");
tambahkan_kata(&mut s);
println!("{}", s); // "halo, dunia"
// ANTI-PATTERN: dua mutable borrow bersamaan
let mut data = String::from("test");
let r1 = &mut data;
let r2 = &mut data; // error[E0499]: cannot borrow `data` as mutable more than once at a time
println!("{} {}", r1, r2);
// ANTI-PATTERN: immutable dan mutable borrow bersamaan
let mut nilai = 5;
let baca = &nilai; // immutable borrow
let tulis = &mut nilai; // error[E0502]: cannot borrow `nilai` as mutable because it is also borrowed as immutable
println!("{} {}", baca, tulis);
}
Aturan ini mencegah data race — kondisi di mana dua bagian kode mengakses data yang sama secara bersamaan dan setidaknya satu dari mereka menulis. Data race menyebabkan bug yang sangat sulit dilacak di bahasa lain; di Rust, ia mustahil terjadi karena compiler menolaknya.
Slice — Referensi ke Sebagian Data #
Slice adalah referensi ke bagian dari koleksi — bukan salinan, bukan owner, tapi pandangan ke porsi tertentu dari data yang sudah ada. Slice selalu berupa borrow.
String Slice (&str)
#
fn main() {
let kalimat = String::from("halo dunia");
// Slice dengan range indeks byte
let kata_pertama = &kalimat[0..4]; // "halo"
let kata_kedua = &kalimat[5..10]; // "dunia"
// Range bisa dipersingkat
let awal = &kalimat[..4]; // dari indeks 0 — sama dengan [0..4]
let akhir = &kalimat[5..]; // hingga akhir — sama dengan [5..10]
let semua = &kalimat[..]; // seluruh string
println!("{} | {} | {}", kata_pertama, kata_kedua, semua);
}
Indeks pada string slice mengacu ke byte, bukan karakter. Memotong di tengah karakter multibyte (seperti karakter Unicode non-ASCII) menyebabkan panic di runtime. Untuk memotong berdasarkan karakter, gunakan iterator: s.chars().take(n).collect::<String>().Fungsi yang Menerima &str
#
Tipe &str lebih fleksibel dari &String sebagai parameter fungsi — ia bisa menerima keduanya:
// ANTI-PATTERN: parameter terlalu spesifik
fn cetak(s: &String) {
println!("{}", s);
}
// BENAR: &str lebih generik — menerima &String, &str literal, dan slice
fn cetak(s: &str) {
println!("{}", s);
}
fn main() {
let owned = String::from("dari String");
let literal = "dari string literal";
cetak(&owned); // &String dikonversi otomatis ke &str ✓
cetak(literal); // &str langsung ✓
cetak(&owned[5..]); // slice ✓
}
Array Slice #
Slice juga bekerja pada array dan Vec:
fn jumlahkan(slice: &[i32]) -> i32 {
slice.iter().sum()
}
fn main() {
let arr = [1, 2, 3, 4, 5];
let vec = vec![10, 20, 30, 40, 50];
println!("Jumlah arr: {}", jumlahkan(&arr)); // seluruh array
println!("Jumlah 3 tengah: {}", jumlahkan(&arr[1..4])); // [2, 3, 4]
println!("Jumlah vec: {}", jumlahkan(&vec)); // seluruh vec
}
Pola Deklarasi yang Idiomatis #
Beberapa pola deklarasi variabel yang sering muncul di kode Rust idiomatis dan perlu kamu kenali:
fn main() {
// 1. Destructuring tuple
let (x, y, z) = (1, 2.0, "tiga");
println!("{} {} {}", x, y, z);
// 2. Destructuring struct
struct Titik { x: f64, y: f64 }
let titik = Titik { x: 3.0, y: 4.0 };
let Titik { x: px, y: py } = titik;
println!("Titik: ({}, {})", px, py);
// 3. Ignore nilai dengan underscore
let (penting, _, juga_penting) = (1, 2, 3);
println!("{} {}", penting, juga_penting);
// 4. Variabel yang sengaja tidak digunakan — prefix underscore mencegah warning
let _debug_value = hitung_sesuatu(); // tidak dipakai, tapi tidak memicu warning
// 5. Binding di match dan if let
let angka = Some(42);
if let Some(n) = angka {
println!("Nilainya: {}", n);
}
// 6. while let untuk iterasi
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("Diambil: {}", top);
}
}
fn hitung_sesuatu() -> i32 { 42 }
Ringkasan #
- Immutable by default —
let x = 5tidak bisa diubah. Tambahkanmuthanya jika nilai memang perlu berubah: ini komunikasi eksplisit ke pembaca kode.- Deklarasi tanpa inisialisasi valid — selama compiler memverifikasi bahwa variabel pasti diinisialisasi di semua jalur sebelum digunakan.
- Shadowing ≠ mutasi —
let x = x + 1membuat variabel baru; bisa mengubah tipe sekaligus. Berguna untuk transformasi bertahap dengan nama yang sama.- Scope menentukan kapan memori dibebaskan — tidak ada garbage collector;
dropdipanggil otomatis saat variabel keluar scope, di titik yang deterministik dan bisa diprediksi.- Copy types vs Move types — integer, float, bool, char di-copy saat assignment; String, Vec, dan tipe heap lain di-move. Setelah move, owner lama tidak valid.
- Clone untuk salinan eksplisit —
.clone()membuat salinan independen di heap; gunakan hanya jika salinan memang dibutuhkan, bukan sebagai pelarian dari borrow checker.- Immutable borrow (
&T) boleh banyak bersamaan — karena membaca paralel aman. Mutable borrow (&mut T) hanya satu pada satu waktu dan tidak bisa bersamaan dengan borrow lain.&strlebih baik dari&Stringsebagai parameter —&strmenerima string literal,&String, dan slice sekaligus; lebih fleksibel tanpa biaya tambahan.- Destructuring adalah pola idiomatis Rust — berlaku untuk tuple, struct, enum, dan di konteks
let,match,if let, sertawhile let.