Install the toolchain, write your first program, and learn the fundamentals of Rust — the language built for speed and memory safety without a garbage collector.
src/main.rs and run with cargo run, followed by the exact output it produces. Hover any code block and click Copy to grab it.The fastest way to keep what you learn is to teach it. The physicist Richard Feynman's trick was simple: if you can't explain something in plain words, you don't really understand it yet. For each section, work these four steps:
The Try it boxes show a finished program and its output; the ✎ Exercise boxes are yours to complete. Each exercise is a whole program: paste it over src/main.rs in a Cargo project (see section 3) and run cargo run. The compiler's error messages are part of the lesson — read them before revealing the solution.
A programming language is a precise, written notation for telling a computer what to do. Rust began in 2006 as a personal project of Graydon Hoare, an engineer at Mozilla, and reached version 1.0 in 2015. It set out to solve a problem that had dogged systems programming for decades. Languages like C and C++ are fast because they give the programmer direct control of memory, but that same control causes most of their serious bugs: crashes, corrupted data, and security holes. Languages with a garbage collector (Java, Go, Python, C#) avoid those bugs, but pay for it with a runtime that pauses to clean up memory. Rust aimed to deliver both: C-level speed and memory safety, with no garbage collector.
It achieves this with ownership, a set of rules about which part of the program is responsible for each piece of memory. The rules are checked by the compiler, so memory is freed at exactly the right moment and whole classes of bugs, such as use-after-free, double-free, and data races between threads, become compile-time errors instead of runtime disasters. Rust is statically typed and compiled to native machine code, like C.
The trade-off is that Rust's compiler is famously strict, and it takes time to learn to work with it rather than against it. In return, its error messages are unusually helpful, and code that compiles is far more likely to be correct. Rust is now used in the Linux kernel, Firefox, Android, Windows, and cloud infrastructure at Amazon, Microsoft, Google, and Cloudflare.
.rs extensionIn two sentences, tell a friend what problem Rust solves that neither C nor Python solves alone.
C is fast because it lets you manage memory yourself, but one slip can crash the program or open a security hole. Python is safe because a helper cleans up memory for you while the program runs, but that helper costs speed. Rust checks your memory handling before the program even runs, so you get C's speed without the slips and without the helper.
Before you can write Rust you need a toolchain, the set of programs that turns source code into something runnable. Rust's toolchain has three core parts. rustc is the compiler, which translates .rs files into an executable. cargo is the build tool and package manager, which you'll use for almost everything. The standard library provides the collections, strings, file access, and other building blocks every program needs.
The official way to install all of these is rustup, the toolchain manager. It installs everything into your home directory (~/.cargo and ~/.rustup), so no administrator rights are needed, and it keeps Rust current with a single command, rustup update. Rust ships a new stable version every six weeks. Avoid your operating system's package manager for Rust, because its version is often months out of date. rustup also manages several release channels side by side: stable (what you should use), beta, and nightly (experimental features).
Rust also has editions (2015, 2018, 2021, 2024): occasional opt-in sets of language changes. Every crate declares its edition in Cargo.toml, and crates of different editions work together seamlessly, so an edition upgrade never breaks old code.
Open a terminal and run the one-line installer, then follow the prompts (press 1 for the standard install):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
When it finishes, either restart your terminal or load the new environment into the current shell:
source "$HOME/.cargo/env"
Download and run rustup-init.exe from https://rustup.rs. Rust on Windows needs the Microsoft C++ build tools — the installer will offer to install them, or grab the "Desktop development with C++" workload from the Visual Studio Installer. On Windows you can also install via a package manager:
winget install Rustlang.Rustup
In any terminal, check the versions:
rustc --version # e.g. rustc 1.82.0
cargo --version # e.g. cargo 1.82.0
rustup update. To uninstall everything cleanly: rustup self uninstall.Why does Rust's own installer beat apt install rustc? Explain it to someone who has never programmed.
Rust releases a new version every six weeks, and tutorials and libraries quickly start to rely on the newest one. Your operating system's app store only refreshes its copy occasionally, so it's often months behind, like following a recipe written for a newer oven. rustup gets you the current version, and one command keeps it current.
Real programs are more than one file. They have dependencies on other people's code, tests, build settings, and release builds. Cargo is Rust's single tool for all of it, and nearly every Rust project in the world uses it the same way. That uniformity is a big part of why Rust is pleasant to work with: once you know Cargo, you can build any Rust project.
Cargo organises code into packages. A package is a folder containing a Cargo.toml file, the manifest, which records the package's name, version, edition, and dependencies. Source code lives in src/. A package produces one or more crates, Rust's unit of compilation: a binary crate is a runnable program with a main function (starting in src/main.rs), and a library crate is reusable code for other programs (starting in src/lib.rs). To use someone else's library, you add one line to [dependencies] in Cargo.toml, and Cargo downloads it from crates.io, the public registry, the next time you build.
Cargo builds in two profiles. The default dev profile compiles quickly and includes debugging information, and it's what cargo run uses while you're working. The release profile (cargo build --release) takes longer to compile but applies heavy optimisation, often making the program many times faster. Build output goes into a target/ folder, which you never edit or commit to version control.
Cargo creates projects, compiles them, runs them, downloads dependencies, and runs tests. You'll rarely call rustc directly. Create a new project:
cargo new hello
cd hello
That scaffolds a folder:
hello/
├─ Cargo.toml # project name, version, dependencies
└─ src/
└─ main.rs # your code — starts with a hello-world main()
The generated src/main.rs already prints a greeting. Build and run it in one step with cargo run:
fn main() {
println!("Hello, world!");
}
cargo run Compiling hello v0.1.0 (/path/to/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.34s
Running `target/debug/hello`
Hello, world!
The first three lines are Cargo reporting its work; the last line is your program's output. In every "Try it" box below, we show only your program's output — Cargo's build lines are trimmed for clarity.
| Command | What it does |
|---|---|
cargo run | Compile (if needed) and run the program |
cargo build | Compile only — binary lands in target/debug/ |
cargo build --release | Optimized build in target/release/ — much faster, slower to compile |
cargo check | Type-check without producing a binary — the fastest feedback loop |
cargo test | Run all tests in the project |
cargo fmt | Auto-format your code to the standard style |
cargo clippy | Lint for common mistakes and non-idiomatic code |
rustc main.rs then ./main works. But use Cargo for anything real; it's the whole ecosystem.What's the difference between cargo run and cargo build --release? Explain it with cooking.
cargo run is cooking for yourself on a weeknight: quick, good enough, and you get to taste it straight away. cargo build --release is preparing for a dinner party: it takes much longer, but the result is polished and far faster to “serve”. Develop with the quick one; ship the release build.
Every executable Rust program has a function named main. It is the program's entry point: execution begins at its first line, and the program ends when main returns. Functions are declared with the keyword fn, followed by the name, a parameter list in parentheses, and a body in curly braces.
A Rust function body is a sequence of statements, most ending with a semicolon. You'll immediately notice that printing is written println!(…), with an exclamation mark. That marks a macro: code that generates other code at compile time. println! is a macro rather than a function because it takes a variable number of arguments, and it checks your format string against them while compiling, so a missing argument is an error before the program ever runs. Inside the format string, {} is a placeholder filled by the following arguments in order, and {name} inserts the variable name directly.
Code can be organised into modules and brought into scope with use, as in use std::collections::HashMap;. The :: separates path segments, much like folders in a file path. Comments begin with //. The standard formatting tool cargo fmt lays out your code in the community style automatically, so you never need to argue about indentation.
main function.! such as println!.::-separated name for an item, such as std::collections::HashMap.fn declares a function; main is the entry pointprintln! ends in ! because it's a macro, not a function — macros are how Rust does variadic, format-checked printing;, and curly braces { } define blocks and scope{} in the format string is a placeholder, filled in order; {name} pulls a variable in directly (Rust 2021+)fn main() {
let name = "Alice";
let age = 30;
println!("Hello, World!");
println!("{} is {} years old.", name, age);
println!("{name} is {age} years old.");
}
Hello, World! Alice is 30 years old. Alice is 30 years old.
src/main.rs in your hello project and run cargo run. Other macros you'll use constantly: print!, format! (returns a String instead of printing), vec! (builds a vector), and panic! (crashes with a message).Fill in the two println! calls. Use a {} placeholder for the first and inline {language}/{year} names for the second.
fn main() {
let language = "Rust";
let year = 2015;
// TODO: print "Hello, Rust!" using a {} placeholder
// TODO: print "Rust 1.0 shipped in 2015." using {language} and {year}
}
fn main() {
let language = "Rust";
let year = 2015;
println!("Hello, {}!", language);
println!("{language} 1.0 shipped in {year}.");
}
Hello, Rust! Rust 1.0 shipped in 2015.
Why is it println! with an exclamation mark and not println? What does the ! buy you?
The ! means it's a macro, a little code-writer that runs while the program is being compiled. Because it runs at compile time, it can read your format string and check it: if you write two {} placeholders but give only one value, the compiler stops you, instead of the mistake showing up while the program runs.
A variable is a name bound to a value. In Rust you create one with let, as in let age = 30;. The first surprise for most newcomers is that variables are immutable by default: once bound, the value cannot change. To allow changes you must opt in with let mut. This is deliberate. Most values in a program never need to change, and making the few that do stand out (mut) makes code easier to reason about and prevents accidental modification.
Rust is statically typed, but you rarely write types for local variables, because the compiler infers them from how the value is used. You can always add an annotation, as in let price: f64 = 9.99;, and sometimes you must, such as when parsing text where the target type isn't otherwise clear. Rust's numeric types are explicit about their size: i32 is a signed 32-bit integer, u8 an unsigned 8-bit one, and f64 a 64-bit float. Rust never converts between them silently.
Two related ideas complete the picture. Shadowing means declaring a new variable with the same name using let again; the new binding hides the old one and may even have a different type. It's commonly used to transform a value in steps, such as turning text into a number, while keeping one sensible name. A constant, declared with const, must have an explicit type and a value known at compile time, is named in SCREAMING_SNAKE_CASE, and can be declared outside any function.
let, creating a new variable that hides the old one.// Variables are IMMUTABLE by default
let x = 5;
// x = 6; // ERROR — cannot assign twice to an immutable variable
// Add `mut` to make a variable changeable
let mut count = 0;
count = count + 1;
// Explicit type annotation (usually inferred, but you can be specific)
let price: f64 = 9.99;
let active: bool = true;
// Shadowing — reuse a name with `let`, even changing the type
let spaces = " "; // &str
let spaces = spaces.len(); // now usize — a new variable, same name
// Constants — always typed, ALL_CAPS, valid anywhere
const MAX_POINTS: u32 = 100_000;
| Type | Description | Example |
|---|---|---|
i8 i16 i32 i64 i128 | Signed integers (i32 is the default) | -42 |
u8 u16 u32 u64 u128 | Unsigned integers | 42 |
usize / isize | Pointer-sized int — used for indexing | 0 |
f32, f64 | Floating point (f64 is the default) | 3.14 |
bool | Boolean | true |
char | A single Unicode scalar (4 bytes) | 'A' |
&str / String | Borrowed text / owned, growable text | "hi" |
fn main() {
let name = "Alice";
let age = 30;
let mut count = 0;
count += 1;
println!("{name} is {age}, count is {count}");
// Shadow `spaces`: it starts as text, becomes its length
let spaces = " ";
let spaces = spaces.len();
println!("spaces is now {spaces}");
}
Alice is 30, count is 1 spaces is now 3
let defines a variable. Immutable-by-default is a core Rust idea: you opt in to mutation with mut, so anything that can change is visible at a glance.If you declare a variable and never read it, Rust compiles and runs your program anyway — but it prints a friendly warning pointing right at the unused name. This is one of the first compiler messages every Rust beginner sees, so let's trigger it on purpose:
fn main() {
let used = 10;
let unused = 99; // declared but never read
println!("used = {used}");
}
cargo run printswarning: unused variable: `unused` --> src/main.rs:3:9 | 3 | let unused = 99; | ^^^^^^ help: if this is intentional, prefix it with an underscore: `_unused` | = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default warning: `hello` (bin "hello") generated 1 warning used = 10
Read the message top to bottom: it's a warning (yellow, not a red error), it names the file and line (src/main.rs:3:9), it underlines the exact culprit, and it even suggests the fix — rename unused to _unused (a leading underscore tells Rust "I meant to leave this unused"). Crucially, the very last line is your program's real output: used = 10. The program still built and ran.
println!("used = {used}") to println!("value = {unused_typo}") and you'll get a red error[E0425]: cannot find value instead — and no program output at all.This won't compile yet. Fix count so it can change, shadow input as a parsed i32, and declare the missing constant.
// TODO: declare a constant MAX_SCORE of type u32 with value 100
fn main() {
let count = 0; // TODO: this needs to change below
count += 1;
count += 2;
println!("count = {count}");
let input = "42";
// TODO: shadow input as an i32 using input.parse().unwrap()
println!("input + 1 = {}", input + 1);
let score: u32 = 87;
println!("{score} out of {MAX_SCORE}");
}
const MAX_SCORE: u32 = 100;
fn main() {
let mut count = 0;
count += 1;
count += 2;
println!("count = {count}");
let input = "42";
let input: i32 = input.parse().unwrap();
println!("input + 1 = {}", input + 1);
let score: u32 = 87;
println!("{score} out of {MAX_SCORE}");
}
count = 3 input + 1 = 43 87 out of 100
Why would a language make variables unchangeable by default? Isn't that just more typing?
Think of a shared document. If everyone can edit everything, you're never sure a paragraph still says what you last read. If most paragraphs are locked and the few editable ones are clearly marked, you know exactly where changes can happen. mut is that marker. When reading Rust code, anything without it is guaranteed to stay the same.
An operator is a symbol that performs an operation on one or more values, called operands. Rust's operators will look familiar from other C-family languages: arithmetic (+ - * / %), comparison (== != < > <= >=), logical (&& || !), and compound assignment (+= -= *= /= %=). A combination of values and operators that produces a result is an expression.
As in C, dividing two integers performs integer division and discards the remainder: 7 / 2 is 3. What's different is Rust's strictness about types: both operands of an arithmetic operator must have the same type. You cannot add an i32 to an f64, or even to an i64, without converting one explicitly using as, as in 7 as f64 / 2.0. This feels fussy at first, but it eliminates a whole category of silent conversion bugs. Rust also has no ++ or --; write n += 1. There is no exponent operator either; numbers have methods instead, such as 2i32.pow(10).
Rust takes integer overflow seriously. If an addition produces a result too large for its type, a debug build panics (stops with an error) rather than silently wrapping around to a wrong number. When wrapping is what you actually want, you ask for it explicitly with methods such as wrapping_add, or use checked_add, which returns None on overflow.
7 / 2 is 3.x as f64 converts x to a float.let (a, b) = (10, 3);
a + b // 13
a - b // 7
a * b // 30
a / b // 3 — integer division
a % b // 1 — remainder
// Comparison
a == b // false
a != b // true
a < b // false
// Logical
a > 5 && b < 5 // true — AND
a > 5 || b > 5 // true — OR
!(a == b) // true — NOT
++ or -- operators. Use n += 1 and n -= 1 instead (the compound forms -=, *=, /=, %= all work too).fn main() {
let (a, b) = (10, 3);
println!("sum={}, diff={}, prod={}", a + b, a - b, a * b);
println!("quotient={}, remainder={}", a / b, a % b);
println!("a > b is {}", a > b);
let mut n = 5;
n += 1;
println!("n after += 1: {n}");
}
sum=13, diff=7, prod=30 quotient=3, remainder=1 a > b is true n after += 1: 6
A carton holds 5 eggs. Replace each 0 so the program reports cartons and leftovers, compares integer and float division using as, computes a power, and checks a range with &&.
fn main() {
let eggs = 17;
let per_box = 5;
println!("Full boxes: {}", 0); // TODO: use /
println!("Left over: {}", 0); // TODO: use %
println!("7 / 2 = {}, but 7 as f64 / 2.0 = {}", 0, 0.0); // TODO
println!("2^10 = {}", 0); // TODO: 2i32.pow(...)
println!("in range? {}", false); // TODO: eggs over 10 AND under 20
}
fn main() {
let eggs = 17;
let per_box = 5;
println!("Full boxes: {}", eggs / per_box);
println!("Left over: {}", eggs % per_box);
println!("7 / 2 = {}, but 7 as f64 / 2.0 = {}", 7 / 2, 7 as f64 / 2.0);
println!("2^10 = {}", 2i32.pow(10));
println!("in range? {}", eggs > 10 && eggs < 20);
}
Full boxes: 3 Left over: 2 7 / 2 = 3, but 7 as f64 / 2.0 = 3.5 2^10 = 1024 in range? true
Why won't Rust let you add an i32 and an f64 directly, when most languages just do it?
Mixing them means one has to be converted, and conversions can quietly lose information: a huge integer might not fit exactly in a float, and a float's fraction is lost going the other way. Other languages pick a conversion for you, silently. Rust makes you write as, so every conversion, and every possible loss, is visible in the code where a reviewer can see it.
Text in Rust comes in two main forms, and understanding why is a key step in learning the language. A String is owned, growable text: it holds its characters in memory on the heap that it is responsible for, and you can append to it with push_str and push. A &str (pronounced “string slice”) is a borrowed view into text that someone else owns. It can look at the text but not change or keep it. String literals such as "hello" are &str, pointing at text baked into the program itself. You convert a slice into an owned String with String::from("hi") or "hi".to_string(), and a &String works wherever a &str is expected.
Rust strings are always valid UTF-8, the encoding that can represent every language's characters. An English letter takes one byte, but characters such as é or emoji take several. That is why len() returns the length in bytes, not characters, and why you cannot index a string with s[0]: a byte position might fall in the middle of a character. To work with characters, iterate over s.chars(). To split text into words, use split_whitespace().
The format! macro builds a new String using the same syntax as println!, and is the usual way to assemble text from values. A useful rule of thumb for function signatures: accept &str as a parameter, since it works with both literals and borrowed Strings, and return a String when you create new text for the caller to keep.
String from a format string and values.// Make an owned, growable String
let mut s = String::from("Hello");
s.push_str(", world"); // append a &str
s.push('!'); // append a single char
// Useful methods
"hello".to_uppercase() // "HELLO"
" hi ".trim() // "hi"
"hello".contains("ell") // true
"hello".len() // 5 — length in BYTES
&str as function arguments (it accepts both literals and borrowed Strings), and return String when you're handing back owned text.fn main() {
let mut s = String::from("Hello");
s.push_str(", world");
s.push('!');
println!("{s}");
let first = "Alice";
let last = "Smith";
let full = format!("{first} {last}");
println!("{full} has {} characters", full.len());
println!("{}", "hello".to_uppercase());
// parse() turns text into a number
let n: i32 = "42".parse().unwrap();
println!("parsed then doubled: {}", n * 2);
}
Hello, world! Alice Smith has 11 characters HELLO parsed then doubled: 84
Finish capitalize so it upper-cases a word's first letter, then build the full name and the initials “A.L.” from the words.
fn capitalize(word: &str) -> String {
let mut chars = word.chars();
match chars.next() {
// TODO: Some(first) => first letter upper-cased + the rest (chars.as_str())
None => String::new(),
}
}
fn main() {
let raw = " ada lovelace ";
let mut words = Vec::new();
for word in raw.split_whitespace() {
words.push(capitalize(word));
}
let full = String::new(); // TODO: join the words with a space
let mut initials = String::new();
// TODO: for each word, push its first char and a '.'
println!("{full} ({} chars)", full.len());
println!("Initials: {initials}");
}
fn capitalize(word: &str) -> String {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn main() {
let raw = " ada lovelace ";
let mut words = Vec::new();
for word in raw.split_whitespace() {
words.push(capitalize(word));
}
let full = words.join(" ");
let mut initials = String::new();
for word in &words {
initials.push(word.chars().next().unwrap());
initials.push('.');
}
println!("{full} ({} chars)", full.len());
println!("Initials: {initials}");
}
Ada Lovelace (12 chars) Initials: A.L.
What's the difference between a String and a &str? Explain it with a book and a bookmark.
A String is a notebook you own: you can write more pages into it, and when you're done it's yours to throw away. A &str is a sticky note pointing at a passage in someone else's book: you can read that passage, but you can't write in the book, and the note is only useful while the book still exists. That's why functions that only need to read text take a &str.
Programs work with groups of values, and Rust gives you a few core collections to hold them. An array, [i32; 3], holds a fixed number of elements of one type, and its length is part of its type. A vector, Vec<T>, is a growable list stored on the heap. It is the collection you'll use most, created with Vec::new() or the vec! macro and extended with push. Both are indexed from zero. Unlike C, Rust checks every index: reading past the end causes an immediate, clear panic rather than silently reading unrelated memory. v.get(i) returns an Option instead, for when an index might be out of range.
A slice, written &v[1..3], is a borrowed view of a contiguous run of elements, the collection counterpart of &str. Functions that only need to read a sequence usually accept a slice (&[i32]), which works for arrays and vectors alike. A tuple, such as ("Ada", 36), groups a fixed number of values that may have different types. You read its parts with .0 and .1, or unpack them with let (name, age) = person;.
A HashMap<K, V>, from std::collections, stores key–value pairs for fast lookup by key. Its entry API handles the common “insert or update” pattern neatly: *counts.entry(word).or_insert(0) += 1 starts a missing key at zero, then adds one. Iterating a HashMap visits keys in an unpredictable order; use a BTreeMap if you need them sorted. To print any of these collections while debugging, use the {:?} debug format.
&v[1..3].// Fixed-size array
let a: [i32; 3] = [10, 20, 30];
// Growable vector — use these in practice
let mut v = vec![1, 2, 3];
v.push(4); // add to the end
// Tuple — mixed types, access by position
let point = (3, 4, "origin");
point.0 // 3
use std::collections::HashMap;
fn main() {
let mut fruits = vec!["apple", "banana", "cherry"];
fruits.push("date");
println!("fruits: {:?} (len {})", fruits, fruits.len());
println!("first: {}", fruits[0]);
// Destructure a tuple into named variables
let point = (3, 4, "origin");
let (x, y, label) = point;
println!("x={x}, y={y}, label={label}");
let mut ages = HashMap::new();
ages.insert("Alice", 30);
ages.insert("Bob", 25);
if let Some(age) = ages.get("Alice") {
println!("Alice is {age}");
}
}
fruits: ["apple", "banana", "cherry", "date"] (len 4) first: apple x=3, y=4, label=origin Alice is 30
{:?} is the "debug" format — it prints structures like vectors as ["apple", "banana", ...]. Plain {} only works for types that define a human-facing display; {:?} works for almost everything while you're exploring.Add 95 and sort, print the top two with a slice, then count words with a HashMap and the entry API.
use std::collections::HashMap;
fn main() {
let mut scores = vec![88, 92, 75];
// TODO: push 95, then sort
println!("Scores: {:?}", scores);
println!("Top two: {:?}", &scores[..]); // TODO: slice only the last two
let mut counts = HashMap::new();
for word in "the cat and the hat".split_whitespace() {
// TODO: *counts.entry(...).or_insert(...) += 1;
}
println!("the = {}, cat = {}", counts["the"], counts["cat"]);
}
use std::collections::HashMap;
fn main() {
let mut scores = vec![88, 92, 75];
scores.push(95);
scores.sort();
println!("Scores: {:?}", scores);
println!("Top two: {:?}", &scores[scores.len() - 2..]);
let mut counts = HashMap::new();
for word in "the cat and the hat".split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
println!("the = {}, cat = {}", counts["the"], counts["cat"]);
}
Scores: [75, 88, 92, 95] Top two: [92, 95] the = 2, cat = 1
When would you use a tuple instead of a vector? Give an everyday example of each.
A vector is a shopping list: any number of items, all the same kind of thing, and it can grow. A tuple is a luggage tag: exactly a fixed few pieces of information, each of a different kind, such as a name, a flight number, and a weight. If you know in advance precisely what goes in each slot, use a tuple (or a struct); if it's “some number of the same thing”, use a vector.
matchControl flow decides which code runs. Rust's if takes a condition, which must be a genuine bool, since Rust never treats numbers as true or false, and runs the block that follows. else if and else add further branches, and the condition needs no parentheses. The key idea is that in Rust, if is an expression: it produces a value. That means you can write let parity = if n % 2 == 0 { "even" } else { "odd" };, and both branches must produce the same type.
match compares a value against a series of patterns and runs the arm of the first one that fits. Patterns can be literal values, several values joined with |, ranges such as 90..=100, tuples, enum variants, and more, and they can pull data out of the value as they match. The wildcard _ matches anything. Like if, a match is an expression, so every arm produces a value of the same type.
Most importantly, match must be exhaustive: the compiler checks that every possible value is handled by some arm. If you forget a case, such as a new enum variant added months later, the program doesn't compile until you handle it. This turns a whole category of “forgot to handle that case” bugs into compile-time errors.
if, match, and blocks are expressions.match: a literal, range, variant, and so on.match.// if is an expression — it returns a value
let grade = if score >= 60 { "pass" } else { "fail" };
// match — the first arm that fits runs; _ is the catch-all
match day {
"Fri" | "Sat" => println!("End of week"),
_ => println!("Other"),
}
fn main() {
let score = 75;
let grade = if score >= 90 { "A" } else if score >= 75 { "B" } else { "C" };
println!("grade: {grade}");
let day = "Fri";
match day {
"Mon" => println!("Monday"),
"Fri" | "Sat" => println!("End of week"),
_ => println!("Other"),
}
// match can test number ranges and return a value
let label = match score {
0..=59 => "fail",
60..=100 => "pass",
_ => "invalid",
};
println!("{score} is a {label}");
}
grade: B End of week 75 is a pass
Write grade as a match on ranges (90–100 A, 80–89 B, 70–79 C, 60–69 D, anything else F) and set parity using if as an expression.
fn grade(score: u32) -> char {
match score {
// TODO: range arms such as 90..=100 => 'A', ending with _ => 'F'
}
}
fn main() {
println!("{} {} {} {}", grade(95), grade(82), grade(64), grade(40));
let n = 7;
let parity = ""; // TODO: if ... { "even" } else { "odd" }
println!("{n} is {parity}");
}
fn grade(score: u32) -> char {
match score {
90..=100 => 'A',
80..=89 => 'B',
70..=79 => 'C',
60..=69 => 'D',
_ => 'F',
}
}
fn main() {
println!("{} {} {} {}", grade(95), grade(82), grade(64), grade(40));
let n = 7;
let parity = if n % 2 == 0 { "even" } else { "odd" };
println!("{n} is {parity}");
}
A B D F 7 is odd
Why is it a good thing that Rust refuses to compile a match that misses a case?
It's like a checklist a pilot can't skip. Suppose someone later adds a new kind of payment. In most languages, every place that handles payments quietly ignores the new kind until a customer hits the bug. In Rust, every match on payments stops compiling and points to exactly where the new case needs handling. The reminder comes from the compiler, not from an angry customer.
A loop repeats a block of code. Rust has three loop keywords, each with a clear purpose. loop repeats forever until you break out of it, and is useful for retrying something or waiting for a condition that is easiest to check in the middle. Because loop is an expression, break value can hand a result back out: let found = loop { … break x; };. while condition repeats as long as the condition stays true.
The for loop is the one you'll use most. It walks over anything iterable: a collection, or a range of numbers. 0..5 is the range 0, 1, 2, 3, 4 (the end is excluded), while 1..=5 includes the end. Looping over a collection comes in three flavours, and they connect directly to ownership: for x in &v borrows each element, for x in &mut v borrows each one mutably so you can change it, and for x in v consumes the vector, taking ownership of each element. Use .iter().enumerate() when you also need the index.
Inside any loop, break exits and continue skips to the next iteration. In nested loops you can attach a label such as 'outer: and write break 'outer to leave several levels at once. Because for loops iterate over the collection directly instead of managing an index by hand, the off-by-one errors that plague C loops are much rarer in Rust.
a..b (end excluded) or a..=b (end included), a sequence of numbers.for loop can walk: ranges, arrays, vectors, maps, and so on.break x leaves a loop and makes x the loop's result.// Range: 0, 1, 2, 3, 4
for i in 0..5 { println!("{i}"); }
// Index + value together with .enumerate()
for (i, item) in list.iter().enumerate() { /* ... */ }
break to exit a loop and continue to skip to the next iteration — same as most languages.fn main() {
let mut sum = 0;
for i in 1..=5 {
sum += i;
}
println!("sum of 1..=5 = {sum}");
let fruits = vec!["apple", "banana"];
for (i, fruit) in fruits.iter().enumerate() {
println!("{i}: {fruit}");
}
// loop returns the value you break with
let mut i = 0;
let doubled = loop {
i += 1;
if i == 10 {
break i * 2;
}
};
println!("doubled: {doubled}");
}
sum of 1..=5 = 15 0: apple 1: banana doubled: 20
Write FizzBuzz for 1 to 15 with a for over an inclusive range, then use loop with break returning a value to find the first power of 2 above 1000.
fn main() {
let mut parts = Vec::new();
// TODO: for i in 1..=15, push "FizzBuzz", "Fizz", "Buzz" or i.to_string()
println!("{}", parts.join(" "));
let mut power = 1;
let first_big = loop {
// TODO: double power; break with it once it exceeds 1000
};
println!("first power of 2 over 1000: {first_big}");
}
fn main() {
let mut parts = Vec::new();
for i in 1..=15 {
if i % 15 == 0 {
parts.push(String::from("FizzBuzz"));
} else if i % 3 == 0 {
parts.push(String::from("Fizz"));
} else if i % 5 == 0 {
parts.push(String::from("Buzz"));
} else {
parts.push(i.to_string());
}
}
println!("{}", parts.join(" "));
let mut power = 1;
let first_big = loop {
power *= 2;
if power > 1000 {
break power;
}
};
println!("first power of 2 over 1000: {first_big}");
}
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz first power of 2 over 1000: 1024
Why does 1..5 stop at 4? Explain why excluding the end is actually convenient.
Think of the range as the fence posts you walk between: you start at post 1 and stop when you reach post 5. That makes 0..n give exactly n numbers, which are exactly the valid indexes of a list of length n, and a..b always has b - a items. When you really want to include the end, ..= says so explicitly.
A function is a named, reusable block of code. You declare one with fn, followed by its name, its parameters in parentheses, and, after an arrow ->, its return type. Every parameter must have a type annotation. Rust deliberately requires types on function signatures, even though it infers them inside function bodies, because the signature is the contract that callers depend on. Function and variable names use snake_case by convention.
Rust distinguishes statements, which perform an action and produce no value, from expressions, which evaluate to a value. A function body is a block, and a block's value is its final expression, written without a trailing semicolon. So fn square(x: i32) -> i32 { x * x } returns x * x. Adding a semicolon, x * x;, turns it into a statement, the block then produces the empty value (), and the compiler reports a type mismatch. Use the return keyword for leaving a function early.
A function returns exactly one value, but that value can be a tuple, which is the idiomatic way to return several results: fn min_max(v: &[i32]) -> (i32, i32). Functions can call themselves (recursion). Rust also has closures: anonymous functions written |x| x * 2 that can capture variables from their surroundings, used heavily with iterator methods such as map and filter.
|args| body, that can capture surrounding variables.fn greet(name: &str) -> String {
format!("Hello, {name}!") // no semicolon = returned
}
fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 {
return 0.0; // early return
}
a / b
}
fn main() {
println!("{}", greet("Alice"));
println!("10 / 3 = {}", divide(10.0, 3.0));
// A closure — an inline anonymous function
let double = |n: i32| n * 2;
println!("double(5) = {}", double(5));
}
Hello, Alice! 10 / 3 = 3.3333333333333335 double(5) = 10
10 / 3 prints all those digits — that's an honest f64 (64-bit floating point). Use a format precision like {:.2} to round: println!("{:.2}", 10.0 / 3.0) prints 3.33.Write the three function bodies. max and factorial should return a tail expression (no return, no final semicolon), and min_max returns a tuple.
fn max(a: i32, b: i32) -> i32 {
// TODO: if/else as the tail expression
}
fn factorial(n: u64) -> u64 {
// TODO: 1 when n <= 1, otherwise n * factorial(n - 1)
}
fn min_max(values: &[i32]) -> (i32, i32) {
// TODO: track lo and hi through the slice, then return (lo, hi)
}
fn main() {
println!("max(4, 9) = {}", max(4, 9));
println!("5! = {}", factorial(5));
let (lo, hi) = min_max(&[4, 1, 9, 3]);
println!("Min: {lo}, Max: {hi}");
}
fn max(a: i32, b: i32) -> i32 {
if a > b { a } else { b }
}
fn factorial(n: u64) -> u64 {
if n <= 1 { 1 } else { n * factorial(n - 1) }
}
fn min_max(values: &[i32]) -> (i32, i32) {
let mut lo = values[0];
let mut hi = values[0];
for &v in values {
if v < lo { lo = v; }
if v > hi { hi = v; }
}
(lo, hi)
}
fn main() {
println!("max(4, 9) = {}", max(4, 9));
println!("5! = {}", factorial(5));
let (lo, hi) = min_max(&[4, 1, 9, 3]);
println!("Min: {lo}, Max: {hi}");
}
max(4, 9) = 9 5! = 120 Min: 1, Max: 9
Why does adding a semicolon after the last line of a function break it? Explain the difference in plain words.
Without the semicolon, the last line is an answer: “the result is x * x”. With the semicolon it becomes an instruction: “compute x * x, then carry on”, and the answer is thrown away. So the function reaches its end having produced nothing, while its signature promised a number, and the compiler points out the broken promise.
Every program must decide when memory is released. Languages with a garbage collector let a runtime find unused memory and free it periodically, at some cost in speed and predictability. C makes the programmer call free by hand, and gets it wrong often enough to cause many of the world's security bugs. Rust takes a third path: ownership, a set of rules the compiler checks, so that it can insert the cleanup at exactly the right point itself. There's no runtime cost and no forgotten free.
The core idea is that each value has a single owner, and when the owner goes out of scope (reaches the end of its block), the value is dropped and its memory freed. Assigning a heap value such as a String to another variable, or passing it to a function, moves ownership, and the original variable can no longer be used. That rule is what prevents two variables from both trying to free the same memory. Simple values such as integers are Copy: they're copied instead of moved, because copying them is trivial. To duplicate a heap value on purpose, call .clone().
Moving would be tiresome if every function call gave your data away, so Rust lets you borrow instead. A reference &value lets a function read data without owning it, and &mut value lets it modify the data. The borrow checker enforces one rule: at any moment you may have either any number of shared references or exactly one mutable reference, never both. It also guarantees a reference can never outlive the data it points to, which is what rules out dangling pointers.
& or &mut) without taking ownership.This is the idea that makes Rust Rust. It's how the language guarantees memory safety with no garbage collector. Three rules:
To let another function use a value without taking ownership, you borrow it with & (a reference). Add &mut to borrow it mutably.
fn length(s: &String) -> usize {
s.len() // borrow: read, don't take
}
fn shout(s: &mut String) {
s.push_str("!!!"); // mutable borrow: change it
}
fn main() {
let s1 = String::from("hello");
let s2 = s1; // ownership MOVES to s2; s1 is now invalid
println!("{s2}");
let name = String::from("Alice");
let n = length(&name); // borrow it — name stays usable
println!("{name} is {n} chars");
let mut msg = String::from("hi");
shout(&mut msg);
println!("{msg}");
}
hello Alice is 5 chars hi!!!
&) or exactly one mutable reference (&mut), but never both at once. That single rule is what eliminates data races at compile time. Try adding println!("{s1}") after the move — the compiler stops you with error[E0382]: borrow of moved value.This version doesn't compile: both functions take ownership, so greeting is moved away. Change the signatures and calls to borrow instead (&str for reading, &mut String for changing), then make an independent copy with clone.
fn length(s: String) -> usize { // TODO: borrow as &str instead
s.len()
}
fn exclaim(mut s: String) { // TODO: borrow as &mut String instead
s.push_str("!!!");
}
fn main() {
let greeting = String::from("Hello"); // TODO: must be mutable to lend &mut
exclaim(greeting); // TODO: lend it mutably
let n = length(greeting); // TODO: lend it immutably
let copy = greeting; // TODO: clone instead of moving
println!("{greeting} ({n} bytes)");
println!("copy: {copy}");
}
fn length(s: &str) -> usize {
s.len()
}
fn exclaim(s: &mut String) {
s.push_str("!!!");
}
fn main() {
let mut greeting = String::from("Hello");
exclaim(&mut greeting);
let n = length(&greeting);
let copy = greeting.clone();
println!("{greeting} ({n} bytes)");
println!("copy: {copy}");
}
Hello!!! (8 bytes) copy: Hello!!!
Explain moving vs. borrowing using a library book.
Moving is giving the book away: it's theirs now, and you can't read it any more. Borrowing (&) is letting a friend read it while you still own it; many friends can read at once, as long as nobody is writing in it. A mutable borrow (&mut) is lending it to exactly one person to annotate, and while they have it, nobody else may even read it, so no one ever sees a half-written note.
A struct is a custom type that groups related values, called fields, under one name. Each field has its own name and type: struct Account { owner: String, balance: u32 }. You create an instance by naming the struct and supplying every field, as in Account { owner: name, balance: 0 }, and read fields with a dot: acct.balance. As with variables, an instance is immutable unless it's bound with let mut.
Behaviour is attached to a struct in an impl (implementation) block. Functions inside it that take self as their first parameter are methods, called with dot syntax. The form of self spells out, in the signature, exactly what the method does to the value: &self only reads it, &mut self modifies it, and plain self takes ownership and consumes it. A reader can tell at a glance which methods can change an object.
Functions in an impl block that don't take self are associated functions, called with ::. The conventional constructor is an associated function named new: Account::new("Ada"). Rust has no special constructor syntax and no inheritance; structs share behaviour through traits instead (section 16). Adding #[derive(Debug)] above a struct lets you print it with {:?}.
impl without self, such as new, called with ::.struct Person {
name: String,
age: u32,
}
impl Person {
fn new(name: &str, age: u32) -> Person {
Person { name: name.to_string(), age }
}
fn describe(&self) -> String {
format!("{} is {} years old.", self.name, self.age)
}
fn birthday(&mut self) {
self.age += 1;
}
}
fn main() {
let mut p = Person::new("Alice", 30);
println!("{}", p.describe());
p.birthday();
println!("after birthday, age is {}", p.age);
}
Alice is 30 years old. after birthday, age is 31
#[derive(Debug)] above a struct to get automatic debug printing, then println!("{p:?}") shows all its fields — invaluable while learning.Complete the impl block: deposit and withdraw need &mut self, and withdraw must refuse (return false) if the money isn't there.
struct BankAccount {
owner: String,
balance: u32,
}
impl BankAccount {
fn new(owner: &str) -> BankAccount {
BankAccount { owner: String::from(owner), balance: 0 }
}
// TODO: fn deposit(&mut self, amount: u32)
// TODO: fn withdraw(&mut self, amount: u32) -> bool
fn summary(&self) -> String {
format!("{}: {}", self.owner, self.balance)
}
}
fn main() {
let mut acct = BankAccount::new("Ada");
acct.deposit(100);
println!("{}", acct.summary());
println!("Withdraw 250? {}", acct.withdraw(250));
println!("Withdraw 40? {}", acct.withdraw(40));
println!("{}", acct.summary());
}
struct BankAccount {
owner: String,
balance: u32,
}
impl BankAccount {
fn new(owner: &str) -> BankAccount {
BankAccount { owner: String::from(owner), balance: 0 }
}
fn deposit(&mut self, amount: u32) {
self.balance += amount;
}
fn withdraw(&mut self, amount: u32) -> bool {
if amount > self.balance {
return false;
}
self.balance -= amount;
true
}
fn summary(&self) -> String {
format!("{}: {}", self.owner, self.balance)
}
}
fn main() {
let mut acct = BankAccount::new("Ada");
acct.deposit(100);
println!("{}", acct.summary());
println!("Withdraw 250? {}", acct.withdraw(250));
println!("Withdraw 40? {}", acct.withdraw(40));
println!("{}", acct.summary());
}
Ada: 100 Withdraw 250? false Withdraw 40? true Ada: 60
Why does summary take &self but deposit takes &mut self? What does that tell someone reading the code?
It's a label on the tin. &self promises “I'll only look”, so summary can be called on any account, even one you're not allowed to change. &mut self announces “I will change this account”, and the compiler only lets you call it on a mut account. Just from the signatures, a reader knows which calls can alter the balance.
Option & ResultAn enum (enumeration) defines a type by listing the variants it can be: a value of the type is exactly one of them. enum Coin { Penny, Nickel, Dime, Quarter } says a coin is one of those four and nothing else. Rust's enums go much further than those of most languages, because each variant can carry its own data, as in Circle(f64) or Rectangle(f64, f64). This makes enums the natural way to model “one of several different shapes of thing”. They pair with match, which unpacks the data and, being exhaustive, makes you handle every variant.
The standard library uses enums to solve one of programming's most famous problems. Many languages have null, a value meaning “nothing” that can hide in any variable, so forgetting to check for it is a very common crash. Rust has no null. Instead, a value that might be absent has the type Option<T>, an enum with two variants: Some(value) and None. Because an Option<i32> is a different type from an i32, you cannot use it as a number until you've dealt with the None case, and the compiler makes sure you do.
Result<T, E> applies the same idea to operations that can fail. It is either Ok(value) or Err(error), and the caller must decide what to do about the error. Both enums come with many helper methods, such as unwrap_or, map, and is_some, and if let Some(x) = opt { … } is a compact alternative to match when you only care about one variant.
Some(T) or None: Rust's type-checked replacement for null.match that handles one pattern and ignores the rest.enum Shape {
Circle(f64), // carries a radius
Rectangle(f64, f64), // carries width, height
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => 3.14159 * r * r,
Shape::Rectangle(w, h) => w * h,
}
}
fn main() {
let shapes = vec![Shape::Circle(2.0), Shape::Rectangle(3.0, 4.0)];
for s in &shapes {
println!("area = {}", area(s));
}
let maybe: Option<i32> = Some(5);
match maybe {
Some(n) => println!("got {n}"),
None => println!("nothing"),
}
}
area = 12.56636 area = 12 got 5
null, "a value might be missing" is written into the type as Option<T>. The compiler forces you to handle the None case — that's how Rust abolishes null-pointer crashes.Finish cents with a match covering all four coins, and make first_even return Some of the first even number, or None if there isn't one.
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn cents(coin: &Coin) -> u32 {
match coin {
// TODO: one arm per coin: 1, 5, 10, 25
}
}
fn first_even(values: &[i32]) -> Option<i32> {
// TODO: return Some(v) for the first even v; otherwise None
}
fn main() {
let purse = [Coin::Penny, Coin::Nickel, Coin::Dime, Coin::Quarter];
let mut total = 0;
for coin in &purse {
total += cents(coin);
}
println!("Total: {total} cents");
for list in [vec![3, 4, 5], vec![1, 3]] {
match first_even(&list) {
Some(n) => println!("First even in {:?}: {n}", list),
None => println!("First even in {:?}: none", list),
}
}
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn cents(coin: &Coin) -> u32 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
fn first_even(values: &[i32]) -> Option<i32> {
for &v in values {
if v % 2 == 0 {
return Some(v);
}
}
None
}
fn main() {
let purse = [Coin::Penny, Coin::Nickel, Coin::Dime, Coin::Quarter];
let mut total = 0;
for coin in &purse {
total += cents(coin);
}
println!("Total: {total} cents");
for list in [vec![3, 4, 5], vec![1, 3]] {
match first_even(&list) {
Some(n) => println!("First even in {:?}: {n}", list),
None => println!("First even in {:?}: none", list),
}
}
}
Total: 41 cents First even in [3, 4, 5]: 4 First even in [1, 3]: none
How is Option safer than null? Explain it with a parcel.
With null, every parcel might secretly be empty, and you only find out when you reach inside and your hand closes on nothing: the program crashes. With Option, a parcel that might be empty is labelled “might be empty” on the outside, and you're not allowed to use what's inside until you've opened it and handled both “something” and “nothing”. Parcels without that label are guaranteed to be full.
Rust divides errors into two kinds. Unrecoverable errors are bugs, situations that should never happen, such as indexing past the end of a vector. For these the program panics: it stops with a message, rather than continuing in a corrupted state. You can trigger one yourself with panic!, and .unwrap() and .expect("msg") panic if their Option or Result holds nothing useful. Recoverable errors are expected failures, such as a missing file, bad user input, or a network timeout, and these are handled as ordinary values.
Rust has no exceptions. A function that can fail says so in its return type, Result<T, E>, returning Ok(value) on success and Err(error) on failure. Because the failure is part of the type, callers cannot overlook it: they must match on the result, use a helper such as unwrap_or, or explicitly pass it on. Errors never fly invisibly up the call stack, which makes it clear from a function's signature exactly how it can fail.
Passing an error on to your own caller is so common that Rust has a dedicated operator. Writing ? after a Result means: if it's Ok, unwrap the value and continue; if it's Err, return that error from the current function immediately. It can only be used in a function that itself returns a Result (or Option). To convert one error type into another along the way, use map_err, as in text.parse().map_err(|_| "not a number"). Chains of ? let fallible code read almost as simply as code that can't fail.
Ok(T) or Err(E): the return type of a function that can fail.Ok or returns the Err to the caller immediately.use std::num::ParseIntError;
// Returns Ok(number) on success or Err(reason) on failure
fn double_str(s: &str) -> Result<i32, ParseIntError> {
let n: i32 = s.parse()?; // ? — on Err, return it now
Ok(n * 2)
}
fn main() {
match double_str("21") {
Ok(v) => println!("doubled: {v}"),
Err(e) => println!("bad input: {e}"),
}
match double_str("oops") {
Ok(v) => println!("doubled: {v}"),
Err(e) => println!("bad input: {e}"),
}
}
doubled: 42 bad input: invalid digit found in string
.unwrap() gives you the value or panics (crashes) on failure, and .expect("message") does the same with your own message. Reach for match or ? in real code, and reserve .unwrap() for experiments and cases you've proven can't fail.Finish parse_age (a parse failure becomes "'abc' is not a number"; negatives become "age cannot be negative"), then write total_age in one line using ?. The |_| in map_err is a closure that ignores the original error.
fn parse_age(text: &str) -> Result<i32, String> {
let age: i32 = text
.parse()
.map_err(|_| format!("'{text}' is not a number"))?;
// TODO: return an Err if age is negative, otherwise Ok(age)
}
fn total_age(a: &str, b: &str) -> Result<i32, String> {
// TODO: parse both with ? and add them, wrapped in Ok(...)
}
fn main() {
for input in ["42", "-5", "abc"] {
match parse_age(input) {
Ok(age) => println!("Age: {age}"),
Err(e) => println!("Error: {e}"),
}
}
println!("{:?}", total_age("30", "42"));
println!("{:?}", total_age("30", "old"));
}
fn parse_age(text: &str) -> Result<i32, String> {
let age: i32 = text
.parse()
.map_err(|_| format!("'{text}' is not a number"))?;
if age < 0 {
return Err(String::from("age cannot be negative"));
}
Ok(age)
}
fn total_age(a: &str, b: &str) -> Result<i32, String> {
Ok(parse_age(a)? + parse_age(b)?)
}
fn main() {
for input in ["42", "-5", "abc"] {
match parse_age(input) {
Ok(age) => println!("Age: {age}"),
Err(e) => println!("Error: {e}"),
}
}
println!("{:?}", total_age("30", "42"));
println!("{:?}", total_age("30", "old"));
}
Age: 42
Error: age cannot be negative
Error: 'abc' is not a number
Ok(72)
Err("'old' is not a number")
What does the ? operator do? Explain it using a relay race.
Each function is a runner passing the baton (the value) along. ? says: “if I received a good baton, keep running with it; if what I received is a note saying something went wrong, stop and hand that note straight back to whoever passed to me.” Nobody has to write the same “if error, go back” check at every leg; the one character does it.
A trait defines shared behaviour: a set of methods that a type promises to provide. It is Rust's counterpart to an interface in other languages. You declare the methods with trait Shape { fn area(&self) -> f64; }, then write impl Shape for Circle { … } for each type that should have that behaviour. A trait can also supply default methods, written once in terms of the required ones, which every implementing type receives automatically unless it overrides them. Many standard traits can be implemented for you with #[derive(…)], such as Debug, Clone, and PartialEq.
Generics let you write code once for many types. A generic function declares a type parameter in angle brackets, such as fn largest<T>(items: &[T]) -> T, and a trait bound states what that type must be able to do: <T: PartialOrd> means “any T that can be compared with <.” Inside the function, you can use only what the bounds promise, and the compiler checks every call site. The shorthand fn report(s: &impl Shape) means the same as a bounded type parameter.
Rust compiles generic code by monomorphisation: it generates a specialised copy of the function for each concrete type you actually use, so generic code runs exactly as fast as code written by hand for that type. When you genuinely need a collection that mixes different types sharing a trait, you use a trait object, Box<dyn Shape>, which chooses the method at run time instead. Traits and generics together are how Rust stays both fast and reusable.
T: PartialOrd.trait Describable {
fn describe(&self) -> String;
}
struct Dog {
name: String,
}
impl Describable for Dog {
fn describe(&self) -> String {
format!("{} is a dog", self.name)
}
}
// Works for any T that can be compared and copied
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut max = list[0];
for &item in list {
if item > max {
max = item;
}
}
max
}
fn main() {
let d = Dog { name: String::from("Rex") };
println!("{}", d.describe());
println!("largest int: {}", largest(&[3, 7, 2, 9, 4]));
println!("largest float: {}", largest(&[1.5, 0.2, 3.8]));
}
Rex is a dog largest int: 9 largest float: 3.8
largest function worked on both integers and floats above.Circle implements Shape. Implement it for Rectangle too, and notice that both get describe for free from the trait's default method, and that the generic report accepts either.
trait Shape {
fn name(&self) -> String;
fn area(&self) -> f64;
fn describe(&self) -> String {
format!("{} has area {:.2}", self.name(), self.area())
}
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn name(&self) -> String {
String::from("Circle")
}
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
// TODO: impl Shape for Rectangle (name "Rectangle", area width * height)
fn report<T: Shape>(shape: &T) {
println!("{}", shape.describe());
}
fn main() {
report(&Circle { radius: 1.0 });
report(&Rectangle { width: 3.0, height: 4.0 });
}
trait Shape {
fn name(&self) -> String;
fn area(&self) -> f64;
fn describe(&self) -> String {
format!("{} has area {:.2}", self.name(), self.area())
}
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn name(&self) -> String {
String::from("Circle")
}
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn name(&self) -> String {
String::from("Rectangle")
}
fn area(&self) -> f64 {
self.width * self.height
}
}
fn report<T: Shape>(shape: &T) {
println!("{}", shape.describe());
}
fn main() {
report(&Circle { radius: 1.0 });
report(&Rectangle { width: 3.0, height: 4.0 });
}
Circle has area 3.14 Rectangle has area 12.00
What does the bound in fn report<T: Shape> promise, and to whom? Explain it with a job advert.
It's a job advert saying “any applicant welcome, as long as you hold the Shape certificate”. The function (the employer) can then rely on every applicant being able to report its name and area, because that's what the certificate guarantees. The compiler is the recruiter: it turns away any type that hasn't implemented Shape before the program ever runs.
match, vectors, and loops to work on a real program.