Variabel

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 --> C

Penggunaan 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 otomatis

Scope 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); // ✓
}
TipePerilaku AssignmentAlasan
i8, i16, i32, i64, i128, isizeCopyUkuran tetap di stack
u8, u16, u32, u64, u128, usizeCopyUkuran tetap di stack
f32, f64CopyUkuran tetap di stack
bool, charCopyUkuran tetap di stack
(T, U) jika T dan U CopyCopySemua elemen di stack
[T; N] jika T CopyCopySemua elemen di stack
StringMoveData di heap, ukuran dinamis
Vec<T>MoveData di heap, ukuran dinamis
Box<T>MovePointer 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 defaultlet x = 5 tidak bisa diubah. Tambahkan mut hanya 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 ≠ mutasilet x = x + 1 membuat variabel baru; bisa mengubah tipe sekaligus. Berguna untuk transformasi bertahap dengan nama yang sama.
  • Scope menentukan kapan memori dibebaskan — tidak ada garbage collector; drop dipanggil 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.
  • &str lebih baik dari &String sebagai parameter&str menerima 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, serta while let.

← Sebelumnya: Komentar   Berikutnya: Konstanta →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact