Core Syntax #
Rust was designed on the principle that memory safety and high performance don’t have to be traded off against each other. As a result, Rust’s syntax feels different from languages like Python, Java, or Go — there are concepts like ownership, borrowing, and lifetimes that have no equivalent elsewhere. This article gives a comprehensive overview of the Rust syntax elements you’ll encounter every day: from declaring variables that are immutable by default, to how match replaces switch in a far more expressive way. Understanding these foundations is a prerequisite before diving into the deeper topics covered in later articles.
Anatomy of a Rust Program #
Every runnable Rust program has a single entry point: the main function. Understanding its structure from the start helps you read other people’s Rust code faster.
// This is the simplest runnable Rust program
fn main() {
// println! is a macro, not an ordinary function
// The exclamation mark (!) distinguishes a macro from a function call
println!("Hello, Rust!");
// Variables are declared with let
let angka = 42;
let pesan = "The value is";
// Format strings use {} as placeholders
println!("{}: {}", pesan, angka);
}
A few things stand out immediately compared to other languages:
- The
!after a function name means it’s a macro, not an ordinary function call.println!,vec!,format!are all macros. - Every statement ends with a semicolon (
;), except expressions that are return values. - Rust is a statically typed language — the type of every variable is known at compile time, although the compiler can often infer it automatically.
Comments #
Rust supports three comment styles, each with a different purpose. The difference between ordinary comments and documentation comments isn’t just style — documentation comments are actually processed by the rustdoc tool to generate HTML documentation.
// Single-line comment — ignored by the compiler
/*
Block comment — can span
multiple lines at once
*/
/// Documentation comment for the item below it (function, struct, etc.)
/// Supports Markdown syntax and is processed by rustdoc.
///
/// # Examples
///
/// ```
/// let hasil = tambah(2, 3);
/// assert_eq!(hasil, 5);
/// ```
fn tambah(a: i32, b: i32) -> i32 {
a + b
}
//! Documentation comment for the module or crate itself
//! Usually lives on the first line of a lib.rs or main.rs file
The /// comment has a unique feature: code blocks inside it (between triple backticks) can be run as doc tests by cargo test. This keeps code examples in the documentation in sync with the actual implementation.
Variables and Mutability #
Variables in Rust are immutable by default. This isn’t just a convention — the compiler will refuse to compile if you try to change the value of a variable that wasn’t declared mut. This design decision pushes you to be explicit about which data needs to change and which doesn’t.
fn main() {
// ANTI-PATTERN: trying to change an immutable variable
let x = 5;
x = 6; // error[E0384]: cannot assign twice to immutable variable `x`
// CORRECT: declare mut if you really need to change it
let mut y = 5;
y = 6; // ✓ valid
println!("y = {}", y);
}
Shadowing #
Rust has the concept of shadowing — you can declare a new variable with the same name using let again. This is different from mutation: the old variable is replaced by a new one, which can even have a different type.
fn main() {
let angka = 5;
// Shadowing: create a new variable with the same name
let angka = angka + 1; // angka = 6
// Shadowing can even change the type
let angka = angka.to_string(); // now angka is a String, not an i32
println!("angka = {}", angka); // angka = "6"
// ANTI-PATTERN: using mut to "change the type"
// let mut nilai = 5;
// nilai = "lima"; // error — you can't change the type of a mut variable
}
Constants #
Constants are declared with const, not let. Unlike immutable variables, constants must always have an explicit type annotation, their value must be known at compile time, and they’re valid across the entire scope.
// Constants use SCREAMING_SNAKE_CASE
const KECEPATAN_CAHAYA: u64 = 299_792_458; // m/s
// Underscores can be used as digit separators for readability
const POPULASI_BUMI: u64 = 8_000_000_000;
fn main() {
println!("Speed of light: {} m/s", KECEPATAN_CAHAYA);
}
Data Types #
Rust has a rich type system. The compiler infers types automatically in most cases, but understanding the basic types matters for writing correct and efficient code.
Scalar Types #
Scalar types represent a single value. There are four main categories:
| Category | Type | Size | Range / Notes |
|---|---|---|---|
| Signed integer | i8 | 1 byte | -128 to 127 |
i16 | 2 bytes | -32,768 to 32,767 | |
i32 | 4 bytes | Default for integers | |
i64 | 8 bytes | Over 9 quadrillion | |
i128 | 16 bytes | Very large | |
isize | Platform | 32 or 64 bits | |
| Unsigned integer | u8 | 1 byte | 0 to 255 |
u16 | 2 bytes | 0 to 65,535 | |
u32 | 4 bytes | 0 to ~4.3 billion | |
u64 | 8 bytes | 0 to ~18.4 quintillion | |
u128 | 16 bytes | Very large | |
usize | Platform | Used for indexing | |
| Float | f32 | 4 bytes | Single precision |
f64 | 8 bytes | Double precision (default) | |
| Boolean | bool | 1 byte | true or false |
| Character | char | 4 bytes | Unicode scalar value |
fn main() {
// Integers — use a suffix for an explicit type
let a: i32 = -42;
let b = 1_000_000u64; // u64 suffix, underscore for readability
let c = 0xFF; // hexadecimal
let d = 0b1111_0000; // binary
let e = 0o77; // octal
// Floats
let f: f64 = 3.14159;
let g = 2.0f32; // f32 suffix
// Booleans
let benar: bool = true;
let salah = false;
// Char — use single quotes, not double quotes
let huruf = 'A';
let emoji = '🦀'; // Rust char supports all of Unicode
let karakter_cina = '中';
println!("{} {} {} {} {}", a, f, benar, huruf, emoji);
}
Compound Types #
Compound types group several values into one.
fn main() {
// Tuple: a collection of values that can have different types
let koordinat: (f64, f64, f64) = (1.0, 2.5, -0.3);
// Access via index
let x = koordinat.0;
let y = koordinat.1;
// Destructuring — the more idiomatic way
let (px, py, pz) = koordinat;
println!("Position: {}, {}, {}", px, py, pz);
// Array: a collection of values with the SAME type and FIXED length
let angka: [i32; 5] = [1, 2, 3, 4, 5];
let semua_nol = [0; 10]; // 10 elements, all zero
// Access via index
println!("First element: {}", angka[0]);
println!("Array length: {}", angka.len());
// ANTI-PATTERN: accessing an index out of bounds
// let x = angka[10]; // runtime panic: index out of bounds
}
Arrays in Rust differ fromVec<T>. Arrays have a fixed size and are allocated on the stack, whileVec<T>is dynamically sized and allocated on the heap. For collections whose size isn’t known at compile time, useVec<T>.
Control Flow #
If and If-Let #
if in Rust is an expression, not a statement. That means it can return a value and be used on the right-hand side of an assignment.
fn main() {
let suhu = 28;
// if as an ordinary statement
if suhu > 30 {
println!("Hot");
} else if suhu > 20 {
println!("Comfortable");
} else {
println!("Cold");
}
// CORRECT: if as an expression — returns a value
let kondisi = if suhu > 30 { "panas" } else { "sejuk" };
println!("Weather: {}", kondisi);
// ANTI-PATTERN: every branch must have the same type
// let kondisi = if suhu > 30 { "panas" } else { 0 };
// error: `if` and `else` have incompatible types
}
Match #
match is one of the most powerful features in Rust. It forces you to handle every possibility — the compiler errors if any case isn’t handled.
fn main() {
let angka = 7;
// ANTI-PATTERN: chained if-else for pattern matching
// if angka == 1 { ... } else if angka == 2 { ... } — verbose and not exhaustive
// CORRECT: use match
match angka {
1 => println!("One"),
2 | 3 => println!("Two or Three"), // multiple patterns
4..=6 => println!("Four to Six"), // range pattern
n if n > 6 => println!("Greater than 6: {}", n), // guard condition
_ => println!("Something else"), // wildcard — required if not exhaustive
}
// match can also return a value
let deskripsi = match angka {
1..=3 => "kecil",
4..=6 => "sedang",
_ => "besar",
};
println!("Number {}: {}", angka, deskripsi);
}
Loop, While, and For #
Rust has three looping constructs, each for a different case.
fn main() {
// loop — an unbounded loop, stopped with break
// Can return a value via break
let mut counter = 0;
let hasil = loop {
counter += 1;
if counter == 10 {
break counter * 2; // return a value from the loop
}
};
println!("Loop result: {}", hasil); // 20
// while — loops while the condition is true
let mut n = 0;
while n < 5 {
print!("{} ", n);
n += 1;
}
println!();
// for — the most idiomatic way to iterate over collections
let koleksi = [10, 20, 30, 40, 50];
for elemen in koleksi {
print!("{} ", elemen);
}
println!();
// Ranges: 0..5 (exclusive), 0..=5 (inclusive)
for i in 0..5 {
print!("{} ", i); // 0 1 2 3 4
}
println!();
// Enumerate to get the index and value at the same time
for (indeks, nilai) in koleksi.iter().enumerate() {
println!("[{}] = {}", indeks, nilai);
}
}
Functions #
Functions in Rust are declared with fn. Parameters always require explicit type annotations — there’s no type inference for function parameters.
// Function without a return value (return type () — the unit type)
fn sapa(nama: &str) {
println!("Hello, {}!", nama);
}
// Function with a return value — the type after ->
fn tambah(a: i32, b: i32) -> i32 {
// CORRECT: an expression without a semicolon is the implicit return value
a + b
}
// ANTI-PATTERN: using explicit return for a case that isn't an early return
fn kurang(a: i32, b: i32) -> i32 {
return a - b; // redundant for a simple case
}
// CORRECT: explicit return is only for early returns
fn pembagian_aman(a: i32, b: i32) -> Option<i32> {
if b == 0 {
return None; // early return — explicit return makes sense here
}
Some(a / b) // implicit return at the end of the function
}
fn main() {
sapa("Rustacean");
println!("3 + 4 = {}", tambah(3, 4));
match pembagian_aman(10, 0) {
Some(hasil) => println!("Result: {}", hasil),
None => println!("Cannot divide by zero"),
}
}
Closures #
Closures are anonymous functions that can capture variables from their surrounding scope.
fn main() {
let pengali = 3;
// The closure captures `pengali` from the outer scope
let kalikan = |x: i32| x * pengali;
println!("5 x {} = {}", pengali, kalikan(5)); // 15
// Closures are often used with iterator methods
let angka = vec![1, 2, 3, 4, 5];
let genap: Vec<i32> = angka.iter()
.filter(|&&x| x % 2 == 0)
.copied()
.collect();
let dikuadratkan: Vec<i32> = angka.iter()
.map(|&x| x * x)
.collect();
println!("Even: {:?}", genap); // [2, 4]
println!("Squares: {:?}", dikuadratkan); // [1, 4, 9, 16, 25]
}
Structs #
Structs are Rust’s way of creating custom data types with named fields. They’re the equivalent of classes in other languages, but without inheritance.
// Struct definition
struct Persegi {
lebar: u32,
tinggi: u32,
}
// Implementing methods for the struct with an impl block
impl Persegi {
// Associated function (not a method) — no self parameter
// Usually used as a constructor
fn baru(lebar: u32, tinggi: u32) -> Persegi {
Persegi { lebar, tinggi } // field shorthand when names match the variables
}
// Method — the first parameter is always &self, &mut self, or self
fn luas(&self) -> u32 {
self.lebar * self.tinggi
}
fn keliling(&self) -> u32 {
2 * (self.lebar + self.tinggi)
}
fn perbesar(&mut self, faktor: u32) {
self.lebar *= faktor;
self.tinggi *= faktor;
}
}
fn main() {
let mut p = Persegi::baru(10, 5);
println!("Area: {}", p.luas()); // 50
println!("Perimeter: {}", p.keliling()); // 30
p.perbesar(2);
println!("After scaling — Area: {}", p.luas()); // 200
// Struct update syntax — copy fields from another struct
let p2 = Persegi { lebar: 20, ..p };
println!("p2 — Width: {}, Height: {}", p2.lebar, p2.tinggi); // 20, 10
}
Enums #
Enums in Rust are far more expressive than enums in other languages — every variant can carry data of a different type.
// Enum with various kinds of variants
enum Pesan {
Keluar, // variant without data
Pindah { x: i32, y: i32 }, // variant with named fields
Tulis(String), // variant with a single value
GantiWarna(u8, u8, u8), // variant with multiple values
}
impl Pesan {
fn proses(&self) {
match self {
Pesan::Keluar => println!("Program exiting"),
Pesan::Pindah { x, y } => println!("Move to ({}, {})", x, y),
Pesan::Tulis(teks) => println!("Writing: {}", teks),
Pesan::GantiWarna(r, g, b) => println!("Color: #{:02X}{:02X}{:02X}", r, g, b),
}
}
}
fn main() {
let pesan_list = vec![
Pesan::Pindah { x: 10, y: 20 },
Pesan::Tulis(String::from("Hello")),
Pesan::GantiWarna(255, 128, 0),
Pesan::Keluar,
];
for pesan in &pesan_list {
pesan.proses();
}
}
Option and Result — The Two Most Important Enums #
Option<T> and Result<T, E> are Rust’s built-in enums that form the foundation of error handling and optional values. Rust has no null — Option<T> is its safe replacement.
// Option<T> — a value that may or may not exist
// enum Option<T> {
// Some(T),
// None,
// }
fn cari_elemen(koleksi: &[i32], target: i32) -> Option<usize> {
for (i, &val) in koleksi.iter().enumerate() {
if val == target {
return Some(i);
}
}
None
}
// Result<T, E> — an operation that can succeed or fail
// enum Result<T, E> {
// Ok(T),
// Err(E),
// }
fn bagi(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("Cannot divide by zero"))
} else {
Ok(a / b)
}
}
fn main() {
let data = [3, 7, 1, 9, 4];
// Handling Option
match cari_elemen(&data, 9) {
Some(indeks) => println!("Found at index {}", indeks),
None => println!("Not found"),
}
// The concise way with if let
if let Some(idx) = cari_elemen(&data, 7) {
println!("7 is at index {}", idx);
}
// Handling Result
match bagi(10.0, 3.0) {
Ok(hasil) => println!("Result: {:.4}", hasil),
Err(e) => println!("Error: {}", e),
}
// The ? operator — automatic error propagation (inside functions that return Result)
// Covered in more detail in the Error Handling article
}
Traits #
Traits define behavior that can be shared across types. The concept is similar to interfaces in Java or Go, but more flexible — you can implement a trait for types you create as well as types that already exist in other libraries.
// Trait definition
trait Deskripsi {
// Method that must be implemented
fn deskripsikan(&self) -> String;
// Method with a default implementation — can be overridden
fn cetak(&self) {
println!("{}", self.deskripsikan());
}
}
struct Pengguna {
nama: String,
email: String,
}
struct Produk {
nama: String,
harga: f64,
}
// Implementing the trait for Pengguna
impl Deskripsi for Pengguna {
fn deskripsikan(&self) -> String {
format!("User: {} ({})", self.nama, self.email)
}
}
// Implementing the trait for Produk
impl Deskripsi for Produk {
fn deskripsikan(&self) -> String {
format!("Product: {} — Rp{:.0}", self.nama, self.harga)
}
// Override the default implementation
fn cetak(&self) {
println!("=== {} ===", self.deskripsikan());
}
}
// Trait as a function parameter — generic with a trait bound
fn tampilkan<T: Deskripsi>(item: &T) {
item.cetak();
}
fn main() {
let user = Pengguna {
nama: String::from("Budi"),
email: String::from("[email protected]"),
};
let produk = Produk {
nama: String::from("Laptop"),
harga: 15_000_000.0,
};
tampilkan(&user);
tampilkan(&produk);
}
Ownership, Borrowing, and Lifetimes #
These are the most unique concepts in Rust — they don’t exist in any other commonly used language. Ownership is the mechanism that lets Rust guarantee memory safety without a garbage collector.
flowchart TD
A[Value created] --> B{Who owns it?}
B --> C[One owner at a time]
C --> D{What happens next?}
D --> E["Move — ownership transfers\nthe old owner can no longer use it"]
D --> F["Borrow &T — read-only borrow\nmany can coexist"]
D --> G["Borrow &mut T — read-write borrow\nonly one at a time"]
D --> H["Clone — make a copy\nthe old owner stays valid"]
E --> I[The new owner is responsible for the drop]
F --> I
G --> I
H --> I
I --> J[The value is dropped automatically when the owner leaves scope]Ownership #
fn main() {
// A String is allocated on the heap — ownership can be transferred
let s1 = String::from("halo");
let s2 = s1; // MOVE: s1 can no longer be used
// ANTI-PATTERN: using a variable after it's been moved
// println!("{}", s1); // error[E0382]: borrow of moved value: `s1`
// CORRECT: use s2, or clone s1 before the move
println!("{}", s2);
let s3 = String::from("dunia");
let s4 = s3.clone(); // clone: make a copy, both are valid
println!("{} and {}", s3, s4);
// Copy types (integers, floats, bool, char) are not moved — always copied
let x = 5;
let y = x; // copy, not move
println!("{} {}", x, y); // both are valid
}
Borrowing #
Borrowing lets you use a value without taking ownership of it.
fn hitung_panjang(s: &String) -> usize { // &String = borrow, not take ownership
s.len()
} // s leaves scope, but isn't dropped because it was only borrowed
fn tambahkan_kata(s: &mut String) { // &mut = mutable borrow
s.push_str(", dunia");
}
fn main() {
let s = String::from("halo");
// Immutable borrow — s is still valid after the call
let panjang = hitung_panjang(&s);
println!("Length of '{}' is {}", s, panjang);
let mut s2 = String::from("halo");
tambahkan_kata(&mut s2);
println!("{}", s2); // "halo, dunia"
// ANTI-PATTERN: more than one mutable borrow at the same time
let mut s3 = String::from("test");
let r1 = &mut s3;
// let r2 = &mut s3; // error: cannot borrow s3 as mutable more than once
println!("{}", r1);
}
Lifetimes #
Lifetimes are how Rust ensures references are always valid while they’re in use. In many cases the compiler can infer lifetimes automatically, but for functions that return a reference derived from several parameters, you need an explicit annotation.
// Without a lifetime annotation, the compiler can't know
// whether the return value refers to x or y
// The 'a annotation means: the return value has a lifetime at least as long as x and y
fn terpanjang<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let string1 = String::from("a long string");
let hasil;
{
let string2 = String::from("xyz");
hasil = terpanjang(string1.as_str(), string2.as_str());
println!("Longest string: {}", hasil); // ✓ valid here
}
// println!("{}", hasil); // error — string2 is no longer valid
}
Modules and Visibility #
Rust uses a module system to organize code. All items (functions, structs, enums, etc.) are private by default — they must be explicitly marked pub to be accessible from outside the module.
// Modules can be defined inline or in separate files
mod geometri {
// Private by default — only accessible inside this module
fn luas_internal(lebar: f64, tinggi: f64) -> f64 {
lebar * tinggi
}
// pub — accessible from outside the module
pub struct Persegi {
pub lebar: f64, // pub field — accessible from outside
tinggi: f64, // private field — not accessible from outside
}
impl Persegi {
pub fn baru(lebar: f64, tinggi: f64) -> Self {
Persegi { lebar, tinggi }
}
pub fn luas(&self) -> f64 {
luas_internal(self.lebar, self.tinggi) // allowed to access private functions in the same module
}
}
pub mod lingkaran {
pub fn luas(radius: f64) -> f64 {
std::f64::consts::PI * radius * radius
}
}
}
fn main() {
// use to shorten paths
use geometri::Persegi;
use geometri::lingkaran;
let p = Persegi::baru(10.0, 5.0);
println!("Rectangle area: {}", p.luas());
println!("Width: {}", p.lebar); // ✓ pub field
// println!("{}", p.tinggi); // ✗ error: private field
println!("Circle area r=7: {:.2}", lingkaran::luas(7.0));
}
Summary #
- Immutable by default — variables in Rust can’t be changed unless declared with
mut. This isn’t a limitation but a design that pushes you toward safer code.- Shadowing ≠ mutation —
let x = x + 1creates a new variable, not changes the old one. It can even change the type at the same time.matchenforces exhaustiveness — every possibility must be handled, and the compiler errors if any is missed. Use_as a wildcard for other cases.- Expression-oriented —
if,match, and code blocks{}can return values. An expression without a semicolon at the end of a function is the implicit return value.Option<T>replaces null — there’s no null in Rust. A value that may not exist is represented asSome(value)orNone.Result<T, E>for errors — operations that can fail return aResult. The?operator dramatically shortens error propagation.- Ownership has three rules — every value has exactly one owner; the owner can be moved, borrowed (
&T), or mutably borrowed (&mut T); the value is dropped automatically when the owner leaves scope.- Everything is private by default — functions, structs, and fields must be explicitly marked
pubto be accessible from outside the module.- Traits are behavioral contracts — they can be implemented for any type, even types from other libraries, as long as you define the trait.