The Book·Chapter 3·13 min

Common programming concepts

Variables, types, functions, and control flow. The grammar of every Rust file.

The concepts in this chapter are the ones every Rust program uses. Variables, types, functions, control flow. They will look familiar from any language, but the defaults are different. Immutable by default. Expressions instead of statements where possible. Exhaustive matching everywhere there is a choice. Those defaults shape the rest of the language and the code agents write under it.

Variables and mutability

let binds a name to a value. Bindings are immutable unless you opt in.

let x = 5;
x = 6;        // error: cannot assign twice to immutable variable
 
let mut y = 5;
y = 6;        // ok

This is the opposite of most languages. The defaults push you toward functional-style code where state changes are visible. When an agent reaches for let mut, ask whether it is necessary; the answer is often no.

Shadowing

Shadowing lets you reuse a name. The new binding fully replaces the old one, optionally with a different type.

let x = "5";
let x: i32 = x.parse().expect("not a number");
let x = x + 1;
println!("{}", x);   // 6

Three different x bindings live and die in this block. Each let introduces a new variable. Use this when the meaning of a value changes through a transformation; it is cleaner than x_string, x_int, x_plus_one.

const and static

const is a compile-time constant. The value must be a constant expression. The convention is SCREAMING_SNAKE_CASE.

const MAX_ATTEMPTS: u32 = 5;
const PI: f64 = 3.141592653589793;

static is also constant-by-default (or static mut for the rare case you want a global, which has its own caveats). For most code, const is what you want. Use static when you need a fixed memory address, typically for FFI or large lookup tables you do not want duplicated.

△ Bad
let mut name = String::from("ev");
name = name.to_uppercase();
let mut length = name.len();
length = length + 1;
◇ Good
let name = String::from("ev");
let name = name.to_uppercase();
let length = name.len() + 1;

The right side has no mut. Both bindings of name are immutable; the second shadows the first. length is computed in one expression. Immutable code is easier to reason about because a variable's value is fixed once you see the let.

Data types

Rust is statically typed. Every value has a type known at compile time. The compiler infers most of them; you only annotate when it cannot.

Scalars

Four families. Pick by what they hold.

FamilyExamplesNotes
Integersi8, i16, i32, i64, i128, isize, u8...usizeDefault is i32. usize is for indexing.
Floatsf32, f64Default is f64.
Booleansbooltrue or false. One byte.
CharacterscharA Unicode scalar value, four bytes. Not a byte.

A few habits worth forming early:

  • Use usize for collection indices. It is sized to fit the platform's address space. Vec::len() returns usize. array[i] expects usize.
  • Use i32 for general counting unless you have a reason. The default for an integer literal is i32, and arithmetic between mixed types requires explicit casts.
  • Use u32 for non-negative quantities where signedness would be misleading (line numbers, counts, capacities below billions).

Integer overflow in debug mode panics. In release mode it wraps. If you want explicit behavior, use the checked_, wrapping_, saturating_, or overflowing_ family of methods on integers.

Compound types

Two built-in compounds:

let pair: (i32, f64) = (5, 3.14);
let (a, b) = pair;
let first = pair.0;
 
let nums: [i32; 5] = [1, 2, 3, 4, 5];
let zeros: [i32; 100] = [0; 100];      // 100 zeros
let mid = nums[2];

Tuples are heterogeneous and fixed-length. Arrays are homogeneous and fixed-length. Both have known size at compile time and live on the stack.

For variable-length collections you want Vec<T> (heap, growable). For variable-length string data you want String (heap, owned UTF-8) or &str (borrowed UTF-8 view).

Functions

fn declares a function. Parameter types are required. The return type follows ->.

fn add(a: i32, b: i32) -> i32 {
    a + b
}

That last line has no semicolon and no return keyword. It is an expression, and the function returns its value.

Statements vs expressions

A statement performs an action and has no value: let x = 5;, fn f() { ... }. An expression evaluates to a value: 5, x + 1, if cond { 1 } else { 2 }. A semicolon turns an expression into a statement.

This distinction matters every time you write a function:

fn double(x: i32) -> i32 {
    x * 2          // expression, returns the value
}
 
fn double_broken(x: i32) -> i32 {
    x * 2;         // statement, returns ()
    // error: expected i32, found ()
}

() (the unit type) is what a statement evaluates to. A function whose body ends in a statement returns (), regardless of what the type signature says, and you get a compile error. Once you internalize "no semicolon = return," function bodies read naturally.

Early return

return is still available. Use it for early exits:

fn first_positive(xs: &[i32]) -> Option<i32> {
    for &x in xs {
        if x > 0 {
            return Some(x);
        }
    }
    None
}

The trailing None is the implicit return. Idiomatic Rust uses return only when you exit before the natural end of the function.

Control flow

if/else is an expression

let x = 5;
let kind = if x % 2 == 0 { "even" } else { "odd" };

Both arms must evaluate to the same type. There is no ternary operator because if/else already serves that role.

A common pattern is to use if chains for branched assignment:

let bucket = if score >= 90 {
    "A"
} else if score >= 80 {
    "B"
} else if score >= 70 {
    "C"
} else {
    "F"
};

For matching on enum variants or value patterns, prefer match (see chapter 6).

loop, while, for

Three loop constructs. Different ergonomics, same underlying machinery.

let mut i = 0;
loop {
    if i >= 5 { break; }
    println!("{}", i);
    i += 1;
}
 
let mut j = 0;
while j < 5 {
    println!("{}", j);
    j += 1;
}
 
for k in 0..5 {
    println!("{}", k);
}
 
for c in "rust".chars() {
    println!("{}", c);
}

Use for whenever you can. It is shorter, harder to get wrong, and clearer to readers. Reach for while when the condition is naturally at the top but does not fit a range or iterator. Reach for loop only when you genuinely want unconditional iteration with a break in the middle.

loop returns a value

loop is an expression that returns whatever value follows its break:

let result = loop {
    let candidate = next_attempt();
    if candidate.is_valid() {
        break candidate;
    }
};

break value exits the loop and yields value to the enclosing expression. Useful for retry-until-success patterns where the result is what you want to capture.

break, continue, labeled loops

break and continue work on the innermost loop by default. To target an outer loop, label it with 'name::

'outer: for i in 0..10 {
    for j in 0..10 {
        if i * j > 50 {
            break 'outer;
        }
    }
}

Used sparingly, labels are clear. Used often, they signal that the control flow is too complex; extract the inner work into a function.

Operators, briefly

Arithmetic (+ - * / %), comparison (== != < <= > >=), and logical (&& || !) work as you expect. Two surprises:

  • No automatic numeric coercion. let x: i32 = 5; let y: i64 = 10; x + y; fails. You must cast: x as i64 + y. This is a feature, not a footgun; mixed-type arithmetic is where overflow bugs hide.
  • Equality requires PartialEq. For your own structs and enums, you #[derive(PartialEq)] to enable ==. The compiler will not invent it.

Closing the loop

You now have the grammar of Rust: bindings, types, functions, branches, and loops. None of it is unusual on its own. What is unusual is how strict the defaults are: immutable by default, exhaustive by default, explicit casts only. Those defaults are doing real work, and you will feel them most when an agent's code refuses to compile.

The next chapter introduces the single largest concept that distinguishes Rust from other languages: ownership. Everything covered so far operates inside it. Once ownership clicks, the rest of the language stops being mysterious.