Data and Control

Bindings, scalars, tuples, arrays, functions as expressions, and if, loop, and match as the control you actually write.

Data and control

You can already read let, fn, and if. This lesson fills those shapes in, in the order a program needs them: a binding, a number, a group of values, a function, a branch, then a print. Heap ownership waits until the next lesson.

Bindings

A binding is immutable unless you write mut. let x = 5 cannot be assigned again. That is deliberate: accidental changes are a large class of bugs, so the language makes the intent visible. let mut x = 5; x = 6 is allowed.

nameptr, len, capAdaheap bytespoints at
StatusThe name lives on the stack

let mut name = String::from("Ada") stores a pointer, a length, and a capacity in the stack frame. mut means this binding may change.

Shadowing

let x = 5; let x = x + 1 creates a new binding with the same name. It can even change type: let x = "five". Shadowing is not mutability. Shadow when a value is transformed in steps, such as a string that becomes a number. Use mut when the same variable evolves, such as a loop counter.

const, static, and let

FormRulesUse it for
letA runtime binding in a scope. Immutable unless you write mut.Configuration read at runtime, and loop counters when the binding is mut.
constAlways immutable, type required, value known at compile time, inlined. Name it SCREAMING_SNAKE.A fixed domain limit, such as const MAX_USERS: u32 = 10_000.
staticLives for the whole program at one address. Changing it needs unsafe. Skip that until much later.A global read-only name, used sparingly, such as static APP_NAME: &str.

Integers often infer as i32. Write the type when the width matters: let b: u64 = 10. Underscores are only for reading: 1_000_000.

01_variables_mutability.rsRust
fn main() {
    let x = 5;
    let mut y = 5;
    y = 6;
    let parsed = "42";
    let parsed: i32 = parsed.parse().unwrap_or(0);
    const MAX_USERS: u32 = 10_000;
    println!("{x} {y} {parsed} {MAX_USERS}");
}

Scalar types

A scalar is one value. The four kinds are integers, floating point, bool, and char. Pick the integer width on purpose.

WidthSignedUnsignedRange
8-biti8u8−128 to 127, or 0 to 255
16-biti16u16−32,768 to 32,767, or 0 to 65,535
32-biti32u32About ±2.1 billion, or 0 to about 4.3 billion
64-biti64u64Far past what an application counter needs
128-biti128u128Wider still, rarely needed
PointerisizeusizeMatches the address width. Indexes, sizes, and counts.
You are storingUse
Most application integersi32, the default inference
Lengths and indexesusize
Bytes and binary protocolsu8
An id that must not be negativeu64, or a dedicated id type later. Not a negative integer.
MoneyInteger cents, or a decimal library. Never f64.

Overflow, floats, bool, char

AdditionWhat you get
Plain + in a debug buildPanics, so you notice the bug
Plain + in a release buildWraps
checked_addAn Option. None means it overflowed. You will meet Option with enums. Here it only means the addition produced a number or it did not.
saturating_addClamps at the minimum or maximum
wrapping_addWraps on purpose
overflowing_addThe result and a flag
KindTypesNote
Floatf32, f64Inference picks f64. Do not compare computed floats with ==.
Booleantrue, falseDrives if, while, and &&, ||, !.
CharactercharOne Unicode scalar, four bytes: 'A', 'ß', '🚀'. A raw byte is a u8. Text is a string.

let m = n as u64 is an explicit cast and it can cut off bits. Fallible conversions have a safer form later. Literal suffixes set the type in place: 42u8, 3.14f32, 0xFF, 0b1010.

02_scalar_types.rsRust
fn main() {
    let a: i32 = 10;
    let b: u64 = a as u64;
    let sum = a.checked_add(100).unwrap_or(a);
    let price_cents: u32 = 1999;
    let letter: char = 'A';
    println!("{sum} {b} {price_cents} {letter}");
}

Tuples and arrays

A tuple groups a fixed number of values that may have different types: let t: (i32, f64, &str) = (1, 2.0, "x"). You pull them apart with let (a, b, c) = t or read one with t.0. The empty tuple () is what a function returns when it has no return type.

An array is a fixed length of one type, stored on the stack: let xs: [i32; 5] = [1, 2, 3, 4, 5] and let zeros = [0; 100]. xs[0] reads an element. An index past the end panics when the program runs.

[T; N]all N on the stackgrowable listlater lesson
StatusAn array’s size is part of the type

Use [T; N] when N is fixed and small: a buffer, a coordinate, a lookup of known length.

A slice is a view of contiguous elements. It does not own them: let s: &[i32] = &xs[1..3]. The ownership lesson explains why that view is safe. For now, the pattern is enough: the left side names pieces, the right side is the value.

GroupingShapeReach for it when
TupleFixed count, mixed types, t.0A short grouping or a light pair of return values
Array[T; N], one type, stackN is fixed and small: a buffer, a coordinate, a known lookup
Slice&[T], pointer and lengthYou only need a view of elements someone else owns
StructNamed fields, later lessonA public API, or you are writing point.0 in real code
03_compound_types.rsRust
fn main() {
    let point: (i32, i32) = (3, 4);
    let (x, y) = point;
    let xs: [i32; 4] = [1, 2, 3, 4];
    let mid: &[i32] = &xs[1..3];
    println!("{x},{y} first={} mid0={}", xs[0], mid[0]);
}

Functions

fn name(param: Type) -> ReturnType { } is the shape. No return type means the function returns (). The last line without a semicolon is the value that flows out. A semicolon turns that line into a statement and the function would return (), which fails to compile if you promised an i32.

let x = f(3)callerfn fn + 1value
StatusThe body is an expression

fn double(n: i32) -> i32 { n * 2 } has no semicolon, so the product is the return value.

Passing a value into a function either copies it or moves it. You already know i32 and bool are small. Those copy, and the caller still has them. A String owns a heap buffer. Passing it by value gives the function that buffer, and the caller no longer has it, unless you pass a borrow with &. The next lesson states the three rules. A function that never returns is written -> !. panic! is one way to do that. A method is a function attached with impl and &self. Methods arrive with structs, not here.

04_functions_expressions.rsRust
fn clamp(n: i32, lo: i32, hi: i32) -> i32 {
    if n < lo {
        return lo;
    }
    if n > hi {
        return hi;
    }
    n
}

fn label(n: i32) -> &'static str {
    if n < 0 { "neg" } else if n == 0 { "zero" } else { "pos" }
}

fn main() {
    println!("{} {}", clamp(150, 0, 100), label(-3));
}

Control flow

if is an expression: let msg = if ok { "yes" } else { "no" }. Both branches that produce a value must have the same type.

itemfrom collectionprocessbodydoneno next
Statusfor is the usual loop

loop repeats until break. while checks a condition first. for x in iterable walks a collection. Prefer for over a hand-written index.

FormWhat it does
ifAn expression. Both branches that produce a value share a type.
loopRepeats until break. Only this form can break with a value.
whileChecks a condition before each pass.
forWalks a collection. Prefer this over a hand-written index.
continueStarts the next pass.
breakLeaves the loop. A label before a nested loop names which one.
RangeIncludes
1..51, 2, 3, 4
1..=51, 2, 3, 4, 5

match must name every possibility. match code { 200 => "ok", 404 => "missing", other => "other" } uses a name or _ for the rest. That exhaustiveness is what stops a forgotten case. Matching on enums is the lesson after structs. Prefer match over a long if chain on codes. Prefer an early return over a deep nest. An infinite loop needs a visible way out.

05_control_flow.rsRust
fn http_label(code: u16) -> &'static str {
    match code {
        200 => "ok",
        404 => "missing",
        _ => "other",
    }
}

fn main() {
    println!("{}", http_label(404));
    let mut n = 0;
    loop {
        n += 1;
        if n == 3 { break; }
    }
    for i in 0..3 {
        println!("i={i}");
    }
    while n > 0 {
        n -= 1;
    }
}

Printing and panics

MacroWrites
print!Standard output, no newline
println!Standard output, then a newline
eprint!Standard error, no newline
eprintln!Standard error, then a newline. Diagnostics belong here.
format!An owned string, nowhere yet
PlaceholderMeaning
{}User-facing form, Display
{:?}Developer form, Debug. A type can derive it.
{:#?}Pretty Debug
{:x}Hex
{:b}Binary
{:.2}Float precision
{id}Captures the name. Needs a recent edition.
recoverablehand back to callerpanic!must not continue
StatusA user mistake is recoverable

Bad input should come back as a value the caller can handle. That value is Result, taught after enums. Do not crash the process for it.

assert! and assert_eq! fail the process when a condition is wrong. debug_assert! disappears in a release build, so use it for expensive checks. expect on an Option or Result panics with a message. That is fine in a short example. A library should return the error instead. That policy is the error-handling lesson, after you can model the success and failure cases as enums.

06_comments_printing_panics.rsRust
fn main() {
    let id = 42;
    println!("id={id}");
    eprintln!("debug id={id:?}");
    assert!(id > 0);
    assert_eq!(id, 42);
}