Variables #
Variables in Rust look ordinary on the surface — you write let x = 5 and you’re done. But behind that simple syntax, Rust enforces rules that don’t exist in other languages: variables are immutable by default, an ownership system determines when memory is freed, and borrowing rules prevent concurrency bugs before the program even runs. All of these rules are enforced by the compiler, not the runtime — which means if your code compiles, a huge class of memory bugs has already been structurally eliminated. This article covers how Rust variables work, from the simplest declaration to the nuances of shadowing, scope, and their interaction with the memory system.
Declaring with let
#
Every local variable in Rust is declared with the let keyword. Without any additions, the declared variable is immutable — its value can’t be changed after it’s first set.
fn main() {
let x = 5;
println!("x = {}", x);
// ANTI-PATTERN: trying to change an immutable variable
x = 6; // error[E0384]: cannot assign twice to immutable variable `x`
}
Rust’s compiler error messages are very descriptive — they don’t just tell you what’s wrong, they also point at the line that first set the value and suggest the fix (let mut).
Immutability by default isn’t just a stylistic choice. It pushes you to think explicitly: does this data need to change? If not, leave it immutable. This reduces the amount of state you have to track while reading code, and lets the compiler apply more aggressive optimizations.
Declaration Without Initialization #
Rust lets you declare a variable without assigning a value right away, as long as the variable is definitely initialized before it’s first used. The compiler tracks this flow statically — it doesn’t just require initialization at declaration time.
fn main() {
let nilai; // declaration without initialization — valid
// ANTI-PATTERN: using it before initialization
// println!("{}", nilai); // error[E0381]: used binding `nilai` isn't initialized
nilai = 42; // initialization
println!("nilai = {}", nilai); // ✓ now valid
// Common pattern: conditional initialization
let status;
let kode = 200;
if kode == 200 {
status = "OK";
} else {
status = "Error";
}
// ✓ The compiler verifies that status is definitely initialized
// on every execution path before it's used here
println!("Status: {}", status);
}
Mutability with mut
#
Add mut after let to create a variable whose value can be changed.
fn main() {
let mut skor = 0;
println!("Initial score: {}", skor);
skor += 10;
skor += 25;
println!("Final score: {}", skor); // 35
let mut nama = String::from("Budi");
nama.push_str(" Santoso"); // a method that requires &mut self
println!("Full name: {}", nama);
}
mut only applies to a single binding — it doesn’t automatically “spread” to the data behind references. This matters when working with references:
fn main() {
let mut angka = 10;
// &mut angka — a mutable reference to angka
let r = &mut angka;
*r += 5; // dereference to change the value
println!("angka = {}", angka); // 15
}
When to Choose mut vs Immutable
#
Use mut only when the variable actually needs to change. This isn’t about performance — the Rust compiler optimizes both well. It’s about communicating with the reader: a mut variable signals “this value will change at some point”, while an ordinary variable guarantees “this value stays stable from here to the end of the scope”.
// ANTI-PATTERN: declaring mut when it's never changed
fn luas_persegi(sisi: f64) -> f64 {
let mut hasil = sisi * sisi; // the compiler warns: variable does not need to be mutable
hasil // returned directly without being changed
}
// CORRECT: immutable because it doesn't change
fn luas_persegi(sisi: f64) -> f64 {
let hasil = sisi * sisi;
hasil
}
// CORRECT: or directly as an expression
fn luas_persegi(sisi: f64) -> f64 {
sisi * sisi
}
Type Inference and Type Annotations #
Rust is a statically typed language — the type of every variable is known at compile time. But you don’t always have to write it out, because the Rust compiler has a powerful type inference system.
fn main() {
// Type inference — the compiler infers the type from the value
let a = 42; // i32 (the default for integer literals)
let b = 3.14; // f64 (the default for float literals)
let c = true; // bool
let d = 'z'; // char
let e = "halo"; // &str
// Explicit type annotation — written after the variable name with a colon
let f: u8 = 255;
let g: f32 = 2.5;
let h: i64 = -1_000_000;
// Literal suffixes — an annotation alternative for numeric literals
let i = 42u8;
let j = 3.14f32;
let k = 1_000_000i64;
println!("{} {} {} {} {}", a, b, c, d, e);
}
Type annotations are required in these situations:
fn main() {
// 1. When the compiler can't infer from context
let angka: i32; // without an initial value, the type must be explicit
angka = 10;
// 2. When there's ambiguity — e.g. parsing a string into a number
let parsed: u32 = "42".parse().unwrap(); // without an annotation, the compiler doesn't know which type you want
// 3. When you want a type different from the default
let kecil: i8 = 100; // the default is i32, but we want i8
// 4. For generic collections
let daftar: Vec<String> = Vec::new();
println!("{} {} {} {:?}", angka, parsed, kecil, daftar);
}
Shadowing #
Shadowing is a feature where you declare a new variable with the same name using let again. The old variable is “hidden” by the new one for the rest of the scope.
fn main() {
let x = 5;
println!("first x: {}", x); // 5
let x = x + 1; // a NEW variable named x, with the value 6
println!("second x: {}", x); // 6
{
let x = x * 2; // yet another NEW variable, only inside this block
println!("x in block: {}", x); // 12
}
println!("x after block: {}", x); // 6 — the inner block's x no longer exists
}
Shadowing vs Mutation — The Critical Difference #
Shadowing and mut look similar but are fundamentally different:
fn main() {
// Shadowing: creates a NEW variable — can change the type
let spasi = " "; // type: &str
let spasi = spasi.len(); // type: usize — a DIFFERENT type, valid with shadowing
println!("Number of spaces: {}", spasi);
// ANTI-PATTERN: trying to change the type with mut — impossible
let mut teks = "hello"; // type: &str
teks = teks.len(); // error[E0308]: mismatched types — expected `&str`, found `usize`
}
flowchart TD
A{Need to change the variable's value?}
A -- Yes --> B{Need to change the type too?}
A -- No --> F[Use an ordinary immutable variable]
B -- Yes --> C[Use shadowing\nlet x = transform_x]
B -- No --> D{Does the change happen many times\nor inside a loop?}
D -- Yes --> E[Use mut\nlet mut x = ...]
D -- No --> CIdiomatic Uses of Shadowing #
Shadowing is very useful for step-by-step transformations of the same value — without having to invent a new variable name at every step.
fn proses_input(input: &str) -> u32 {
// Every step "refines" the input under the same name
let input = input.trim(); // &str → &str (cleaned of whitespace)
let input = input.to_lowercase(); // &str → String
let input = input.replace('-', ""); // String → String (remove hyphens)
let input: u32 = input.parse().expect("Not a valid number"); // String → u32
input
}
fn main() {
let hasil = proses_input(" 123-456 ");
println!("Result: {}", hasil); // 123456
}
Without shadowing, you’d have to create input_trimmed, input_lower, input_cleaned, input_parsed — names that don’t add any clarity at all.
Scope and Automatic Drop #
A scope is the range of code where a variable is valid and usable. In Rust, scope has a direct implication for memory: when a variable leaves its scope, Rust calls the drop function automatically to free the memory the variable allocated. There’s no garbage collector — memory is freed at a deterministic, predictable point.
fn main() {
let a = String::from("luar"); // a enters scope
{
let b = String::from("dalam"); // b enters scope
println!("a = {}, b = {}", a, b); // both are valid here
} // b leaves scope → Rust calls drop(b) → b's memory is freed
println!("a = {}", a); // a is still valid
// println!("b = {}", b); // error: b doesn't exist in this scope
} // a leaves scope → drop(a) → a's memory is freed
stateDiagram-v2
[*] --> Deklarasi: let x = nilai
Deklarasi --> Valid: In scope
Valid --> Valid: Used, modified (if mut)
Valid --> Drop: Leaves scope
Drop --> [*]: Memory freed automaticallyScope as a Memory Control Tool #
You can use {} blocks explicitly to control when a value gets dropped — useful for resources like database connections, files, or locks.
fn main() {
// Scenario: a mutex lock is only needed for certain operations
use std::sync::Mutex;
let data = Mutex::new(vec![1, 2, 3]);
// ANTI-PATTERN: holding the lock until the end of the function
let mut guard = data.lock().unwrap();
guard.push(4);
// The lock stays active until main ends — blocking other threads longer than necessary
// CORRECT: limit the lock's scope with an explicit block
{
let mut guard = data.lock().unwrap();
guard.push(4);
} // the lock is released here — other threads can access the data sooner
println!("Done");
}
Ownership — Variables as Data Owners #
Every value in Rust has exactly one owner at a time. When ownership moves, the old owner can no longer access the value. This rule is enforced by the compiler, not the runtime.
Move Semantics #
For types allocated on the heap (like String, Vec, etc.), assignment moves ownership — it doesn’t copy the data.
fn main() {
let s1 = String::from("halo");
let s2 = s1; // ownership MOVES from s1 to s2
// ANTI-PATTERN: using s1 after it's been moved
println!("{}", s1); // error[E0382]: borrow of moved value: `s1`
// CORRECT: use s2
println!("{}", s2); // ✓
}
Why does Rust do this? Because if two variables pointed at the same heap data, who would be responsible for freeing the memory? With the one-owner rule, there’s no ambiguity — s2 is responsible, and when s2 leaves scope, the memory is freed exactly once.
Copy Types — Types That Aren’t Moved #
Types that live entirely on the stack (size known at compile time, cheap to copy) implement the Copy trait. For these types, assignment copies the value — ownership doesn’t move.
fn main() {
// Copy types: integers, floats, bools, chars, tuples of Copy types
let x = 5;
let y = x; // COPY — x stays valid
println!("x = {}, y = {}", x, y); // both valid ✓
let a = true;
let b = a;
println!("{} {}", a, b); // ✓
// NON-Copy types: String, Vec, Box, and other heap types
let s1 = String::from("halo");
let s2 = s1; // MOVE — s1 is no longer valid
// println!("{}", s1); // error ✗
println!("{}", s2); // ✓
}
| Type | Assignment Behavior | Reason |
|---|---|---|
i8, i16, i32, i64, i128, isize | Copy | Fixed size on the stack |
u8, u16, u32, u64, u128, usize | Copy | Fixed size on the stack |
f32, f64 | Copy | Fixed size on the stack |
bool, char | Copy | Fixed size on the stack |
(T, U) if T and U are Copy | Copy | All elements on the stack |
[T; N] if T is Copy | Copy | All elements on the stack |
String | Move | Data on the heap, dynamic size |
Vec<T> | Move | Data on the heap, dynamic size |
Box<T> | Move | Pointer to the heap |
Clone — Explicit Copying #
If you need two independent copies of a heap value, use .clone():
fn main() {
let s1 = String::from("halo");
let s2 = s1.clone(); // make a complete copy on the heap
// Both are valid and independent
println!("s1 = {}, s2 = {}", s1, s2);
// ANTI-PATTERN: redundant cloning on Copy types
let x = 5;
let y = x.clone(); // not wrong, but redundant — just write `let y = x`
}
.clone()performs a deep copy that can be expensive for large data. Don’t use.clone()as an escape hatch from borrow checker errors before really understanding whether a copy is needed. Often borrowing (&T) is the more appropriate solution.
Borrowing — Lending Without Taking Ownership #
Borrowing lets you give access to a value without moving its ownership. You “lend” the value — the borrower can use it, but the original owner stays responsible for the drop.
Immutable Borrow (&T)
#
fn panjang_string(s: &String) -> usize {
s.len()
// s leaves scope, but isn't dropped — because it was borrowed, not owned
}
fn main() {
let s = String::from("halo dunia");
let panjang = panjang_string(&s); // lend s, don't move it
println!("'{}' has {} characters", s, panjang); // s is still valid ✓
}
Many immutable borrows can coexist — reading data in parallel doesn’t cause problems:
fn main() {
let s = String::from("data");
let r1 = &s;
let r2 = &s;
let r3 = &s;
// All three are valid at the same time — they only read
println!("{} {} {}", r1, r2, r3);
}
Mutable Borrow (&mut T)
#
A mutable borrow gives read and write access to a value without taking ownership of it. The rules are strict: only one mutable borrow can be active at a time, and no immutable borrow can coexist with a mutable one.
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: two mutable borrows at the same time
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 and mutable borrows at the same time
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);
}
These rules prevent data races — the condition where two parts of code access the same data at the same time and at least one of them writes. Data races cause bugs that are very hard to track down in other languages; in Rust, they’re impossible because the compiler rejects them.
Slices — References to Part of the Data #
A slice is a reference to part of a collection — not a copy, not an owner, but a view into a specific portion of existing data. A slice is always a borrow.
String Slices (&str)
#
fn main() {
let kalimat = String::from("halo dunia");
// Slices with byte index ranges
let kata_pertama = &kalimat[0..4]; // "halo"
let kata_kedua = &kalimat[5..10]; // "dunia"
// Ranges can be shortened
let awal = &kalimat[..4]; // from index 0 — same as [0..4]
let akhir = &kalimat[5..]; // to the end — same as [5..10]
let semua = &kalimat[..]; // the whole string
println!("{} | {} | {}", kata_pertama, kata_kedua, semua);
}
Indexes in a string slice refer to bytes, not characters. Slicing in the middle of a multibyte character (like non-ASCII Unicode characters) causes a runtime panic. To slice by characters, use an iterator: s.chars().take(n).collect::<String>().Functions That Accept &str
#
The &str type is more flexible than &String as a function parameter — it accepts both:
// ANTI-PATTERN: parameter too specific
fn cetak(s: &String) {
println!("{}", s);
}
// CORRECT: &str is more generic — accepts &String, &str literals, and slices
fn cetak(s: &str) {
println!("{}", s);
}
fn main() {
let owned = String::from("from String");
let literal = "from a string literal";
cetak(&owned); // &String converts to &str automatically ✓
cetak(literal); // &str directly ✓
cetak(&owned[5..]); // slice ✓
}
Array Slices #
Slices also work on arrays and Vecs:
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!("Sum of arr: {}", jumlahkan(&arr)); // the whole array
println!("Sum of middle 3: {}", jumlahkan(&arr[1..4])); // [2, 3, 4]
println!("Sum of vec: {}", jumlahkan(&vec)); // the whole vec
}
Idiomatic Declaration Patterns #
A few variable declaration patterns appear often in idiomatic Rust code and are worth recognizing:
fn main() {
// 1. Tuple destructuring
let (x, y, z) = (1, 2.0, "tiga");
println!("{} {} {}", x, y, z);
// 2. Struct destructuring
struct Titik { x: f64, y: f64 }
let titik = Titik { x: 3.0, y: 4.0 };
let Titik { x: px, y: py } = titik;
println!("Point: ({}, {})", px, py);
// 3. Ignore values with an underscore
let (penting, _, juga_penting) = (1, 2, 3);
println!("{} {}", penting, juga_penting);
// 4. Intentionally unused variables — an underscore prefix prevents the warning
let _debug_value = hitung_sesuatu(); // unused, but doesn't trigger a warning
// 5. Binding in match and if let
let angka = Some(42);
if let Some(n) = angka {
println!("The value: {}", n);
}
// 6. while let for iteration
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("Popped: {}", top);
}
}
fn hitung_sesuatu() -> i32 { 42 }
Summary #
- Immutable by default —
let x = 5can’t be changed. Addmutonly when the value really needs to change: it’s an explicit message to the code reader.- Declaration without initialization is valid — as long as the compiler verifies the variable is definitely initialized on every path before use.
- Shadowing ≠ mutation —
let x = x + 1creates a new variable; it can change the type at the same time. Useful for step-by-step transformations under the same name.- Scope determines when memory is freed — there’s no garbage collector;
dropis called automatically when a variable leaves scope, at a deterministic, predictable point.- Copy types vs Move types — integers, floats, bools, and chars are copied on assignment; String, Vec, and other heap types are moved. After a move, the old owner is invalid.
- Clone for explicit copies —
.clone()creates an independent copy on the heap; use it only when a copy is actually needed, not as an escape hatch from the borrow checker.- Immutable borrows (
&T) can be many at once — because parallel reads are safe. Mutable borrows (&mut T) are limited to one at a time and can’t coexist with any other borrow.&stris better than&Stringas a parameter —&straccepts string literals,&String, and slices all at once; more flexible at no extra cost.- Destructuring is an idiomatic Rust pattern — it works on tuples, structs, enums, and in
let,match,if let, andwhile letcontexts.