Step 02 of 17

Rust basics

Write Rust with variables, mutability, shadowing, scalar and compound types, functions, and control flow built from expressions.

16 min 8 sections

Before ownership and borrowing, you need the everyday syntax: how to declare values, which types exist, how functions are written, and how branching and loops work. Much of it will look familiar if you know Go or TypeScript. Two ideas will not: variables are immutable unless you say otherwise, and almost everything is an expression that produces a value. Both shape how idiomatic Rust reads.

Variables, mutability and shadowing

let binds a value to a name. The compiler infers the type, and you can write it explicitly after a colon:

rust
fn main() {
    let x = 5;          // i32, inferred
    let y: f64 = 2.5;   // explicit type
    let name = "Rust";  // &str, a string slice
    println!("{x} {y} {name}");
}

Bindings are immutable by default. To change one, declare it mut:

rust
fn main() {
    let mut total = 0;
    total += 10;
    total += 5;
    println!("{total}"); // 15
}

Immutability by default is a readability feature: when you see let, you know the value won't change below; when you see let mut, you know to watch it.

Shadowing declares a new variable with the same name. It's common when you transform a value step by step, and the type can change:

rust
fn main() {
    let input = "  42  ";
    let input = input.trim();            // &str
    let input: u32 = input.parse().unwrap(); // now a u32
    println!("{}", input + 1);           // 43
}

Shadowing isn't mutation: each let creates a new binding, and the old one is just no longer reachable by that name. It saves you inventing names like input_str and input_num.

Constants use const, need a type annotation, are written in SCREAMING_SNAKE_CASE and must be computable at compile time:

rust
const MAX_RETRIES: u32 = 3;
const TIMEOUT_MS: u64 = 30 * 1_000; // underscores make numbers readable

Scalar types

KindTypesNotes
Signed integersi8, i16, i32, i64, i128, isizei32 is the default for integer literals
Unsigned integersu8, u16, u32, u64, u128, usizeusize is pointer-sized; used for indexes and lengths
Floatsf32, f64f64 is the default
Booleanbooltrue / false
CharactercharA Unicode scalar value, 4 bytes: 'a', 'é', '🦀'

Rust never converts numeric types implicitly. Adding an i32 to an i64 is a compile error. Convert explicitly with as (which truncates or wraps silently) or, safer, with From/TryFrom:

rust
fn main() {
    let small: i32 = 300;
    let big: i64 = small as i64 + 1;          // widening with as: always fine
    let byte = small as u8;                   // 44: 300 wraps modulo 256, silently
    let checked = u8::try_from(small);        // Err(...): 300 doesn't fit
    let ok: i64 = i64::from(small);           // From is only implemented for lossless conversions
    println!("{big} {byte} {checked:?} {ok}");
}

What about overflow? let x: u8 = 255; x + 1 panics in debug builds and wraps to 0 in release builds. When wrapping or saturating is what you want, say so: x.wrapping_add(1), x.saturating_add(1) or x.checked_add(1) (which returns None on overflow).

Compound types: tuples and arrays

A tuple groups a fixed number of values of possibly different types. Access fields by position or destructure them:

rust
fn main() {
    let point: (i32, i32, &str) = (3, -1, "A");
    let (x, y, label) = point;      // destructuring
    println!("{label}: {x},{y} / {}", point.0);
}

The empty tuple () is called unit. It's the value of expressions that don't produce anything useful, and the return type of functions that return nothing.

An array has a fixed length known at compile time, and every element has the same type. Its type includes the length: [i32; 5].

rust
fn main() {
    let primes = [2, 3, 5, 7, 11];
    let zeros = [0u8; 1024];       // 1024 zero bytes
    println!("{} {}", primes[0], primes.len());
    println!("{}", zeros.len());

    let i = 10;
    // primes[i] would panic at runtime: index out of bounds: the len is 5 but the index is 10
    println!("{:?}", primes.get(i)); // None: the non-panicking alternative
}

Arrays live on the stack and are rarely what you want for collections that grow. That's Vec<T>, covered in the collections lesson. Rust always checks indexes; an out-of-bounds read is a panic, never silent memory corruption.

Functions

rust
fn area(width: f64, height: f64) -> f64 {
    width * height
}

fn greet(name: &str) {
    println!("Hello, {name}");
}

fn main() {
    greet("Lin");
    println!("{}", area(3.0, 4.5));
}

Parameters must have types; there's no inference across function signatures. That's deliberate: a signature is a contract, and the compiler can check every call against it without reading the body. The return type comes after ->; leaving it out means the function returns ().

Notice that area has no return and no semicolon on its last line. That's the next section.

Statements and expressions

In Rust, nearly everything is an expression: it evaluates to a value. A statement performs an action and produces nothing. Adding a semicolon turns an expression into a statement.

A block { ... } is an expression whose value is its last expression, if that line has no semicolon:

rust
fn main() {
    let y = {
        let x = 3;
        x * 2 // no semicolon: this is the block's value
    };
    println!("{y}"); // 6
}

That's why a function body can end with a bare expression: the body is a block, and its value is the return value. return exists for early exits.

rust
fn classify(n: i32) -> &'static str {
    if n < 0 {
        return "negative"; // early exit
    }
    if n == 0 { "zero" } else { "positive" } // final expression
}

fn main() {
    println!("{} {} {}", classify(-4), classify(0), classify(9));
}

A classic first error is adding a semicolon to the last line:

text
error[E0308]: mismatched types
 --> src/main.rs:1:24
  |
1 | fn area(w: f64, h: f64) -> f64 {
  |    ----                    ^^^ expected `f64`, found `()`
2 |     w * h;
  |          - help: remove this semicolon to return this value

With the semicolon, the block's value is (), which isn't an f64.

Control flow

if is an expression, so it replaces the ternary operator other languages have. The condition must be a bool; there's no truthiness, so if count { } doesn't compile.

rust
fn main() {
    let temp = 31;
    let label = if temp > 30 { "hot" } else if temp > 15 { "mild" } else { "cold" };
    println!("{label}");
}

Both branches must have the same type.

Rust has three loops. loop repeats forever until you break, and can return a value from break:

rust
fn main() {
    let mut attempts = 0;
    let result = loop {
        attempts += 1;
        if attempts == 3 {
            break attempts * 10; // the loop's value
        }
    };
    println!("{result}"); // 30
}

while loops on a condition. for iterates over anything iterable, and is what you'll use most:

rust
fn main() {
    for i in 0..3 { print!("{i} "); }        // 0 1 2   (end-exclusive)
    for i in (1..=3).rev() { print!("{i} "); } // 3 2 1  (end-inclusive, reversed)
    println!();

    let names = ["ana", "bo", "cy"];
    for (i, name) in names.iter().enumerate() {
        println!("{i}: {name}");
    }
}

Loops can be labelled to break out of an outer loop from an inner one:

rust
fn main() {
    'outer: for x in 0..10 {
        for y in 0..10 {
            if x * y == 42 {
                println!("found {x} x {y}");
                break 'outer;
            }
        }
    }
}

A for over a range compiles to the same machine code as a C-style index loop, and it can't go out of bounds. Prefer it to while i < len.

Practice

Write a program that prints the first 20 lines of FizzBuzz, but build each line as a value instead of printing in every branch:

  1. Write fn fizzbuzz(n: u32) -> String that returns "FizzBuzz", "Fizz", "Buzz" or the number as a string. Use if as an expression and no return keyword.
  2. In main, loop over 1..=20 and print each result.
  3. Then add fn sum_until(limit: u32) -> u32 that uses loop and break value to add 1, 2, 3, … until the running sum would exceed limit, and returns the last sum that didn't.
  4. Bonus: what happens to sum_until if you change the types to u8 and pass 300? Try it in debug and in release.
Show solution
rust
fn fizzbuzz(n: u32) -> String {
    if n % 15 == 0 {
        "FizzBuzz".to_string()
    } else if n % 3 == 0 {
        "Fizz".to_string()
    } else if n % 5 == 0 {
        "Buzz".to_string()
    } else {
        n.to_string()
    }
}

fn sum_until(limit: u32) -> u32 {
    let mut sum = 0;
    let mut i = 1;
    loop {
        if sum + i > limit {
            break sum;
        }
        sum += i;
        i += 1;
    }
}

fn main() {
    for n in 1..=20 {
        println!("{}", fizzbuzz(n));
    }
    println!("{}", sum_until(100)); // 91 = 1 + 2 + ... + 13
}

Every branch returns a String, so the whole if is a String expression and becomes the function's return value. String literals are &str, so they need .to_string() to match the number branch; the borrowing lesson explains the difference.

Bonus: with u8, limit: u8 can't even hold 300; the compiler rejects the literal (literal out of range for u8). With a limit of 255, the running sum stays under 255, but sum + i reaches 276 when it checks the next number. That panics with "attempt to add with overflow" in a debug build, and silently wraps in release, which returns the wrong answer. Using sum.checked_add(i) handles it correctly.

Key takeaways

  • Bindings are immutable by default; use let mut to allow change, and shadowing to transform a value under the same name.
  • Numeric types never convert implicitly. Use as for widening, try_from when data could be lost, and checked_/wrapping_/saturating_ methods for overflow.
  • Blocks, if, match and loop are expressions. A function returns its final expression; a trailing semicolon turns it into ().
  • Function signatures always spell out types; inside bodies, inference does the work.
  • Prefer for over ranges and iterators to index loops: same speed, no bounds bugs.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What does this print? let x = 5; let x = x * 2; { let x = 0; } println!("{x}");

  2. 2.fn double(n: i32) -> i32 { n * 2; } fails to compile. Why?

  3. 3.let a: i32 = 10; let b: i64 = 20; What happens with a + b?

  4. 4.What's the value of result? let mut n = 0; let result = loop { n += 4; if n > 10 { break n; } };

Finished reading?

Mark it complete to fill in your roadmap.