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.
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
| Form | Rules | Use it for |
|---|---|---|
let | A runtime binding in a scope. Immutable unless you write mut. | Configuration read at runtime, and loop counters when the binding is mut. |
const | Always 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. |
static | Lives 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.
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.
| Width | Signed | Unsigned | Range |
|---|---|---|---|
| 8-bit | i8 | u8 | −128 to 127, or 0 to 255 |
| 16-bit | i16 | u16 | −32,768 to 32,767, or 0 to 65,535 |
| 32-bit | i32 | u32 | About ±2.1 billion, or 0 to about 4.3 billion |
| 64-bit | i64 | u64 | Far past what an application counter needs |
| 128-bit | i128 | u128 | Wider still, rarely needed |
| Pointer | isize | usize | Matches the address width. Indexes, sizes, and counts. |
| You are storing | Use |
|---|---|
| Most application integers | i32, the default inference |
| Lengths and indexes | usize |
| Bytes and binary protocols | u8 |
| An id that must not be negative | u64, or a dedicated id type later. Not a negative integer. |
| Money | Integer cents, or a decimal library. Never f64. |
Overflow, floats, bool, char
| Addition | What you get |
|---|---|
Plain + in a debug build | Panics, so you notice the bug |
Plain + in a release build | Wraps |
checked_add | An 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_add | Clamps at the minimum or maximum |
wrapping_add | Wraps on purpose |
overflowing_add | The result and a flag |
| Kind | Types | Note |
|---|---|---|
| Float | f32, f64 | Inference picks f64. Do not compare computed floats with ==. |
| Boolean | true, false | Drives if, while, and &&, ||, !. |
| Character | char | One 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.
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.
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.
| Grouping | Shape | Reach for it when |
|---|---|---|
| Tuple | Fixed count, mixed types, t.0 | A short grouping or a light pair of return values |
| Array | [T; N], one type, stack | N is fixed and small: a buffer, a coordinate, a known lookup |
| Slice | &[T], pointer and length | You only need a view of elements someone else owns |
| Struct | Named fields, later lesson | A public API, or you are writing point.0 in real code |
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.
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.
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.
loop repeats until break. while checks a condition first. for x in iterable walks a collection. Prefer for over a hand-written index.
| Form | What it does |
|---|---|
if | An expression. Both branches that produce a value share a type. |
loop | Repeats until break. Only this form can break with a value. |
while | Checks a condition before each pass. |
for | Walks a collection. Prefer this over a hand-written index. |
continue | Starts the next pass. |
break | Leaves the loop. A label before a nested loop names which one. |
| Range | Includes |
|---|---|
1..5 | 1, 2, 3, 4 |
1..=5 | 1, 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.
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
| Macro | Writes |
|---|---|
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 |
| Placeholder | Meaning |
|---|---|
{} | 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. |
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.
fn main() {
let id = 42;
println!("id={id}");
eprintln!("debug id={id:?}");
assert!(id > 0);
assert_eq!(id, 42);
}