Step 02 of 17
Rust basics
Write Rust with variables, mutability, shadowing, scalar and compound types, functions, and control flow built from expressions.
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:
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:
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:
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:
const MAX_RETRIES: u32 = 3;
const TIMEOUT_MS: u64 = 30 * 1_000; // underscores make numbers readableScalar types
| Kind | Types | Notes |
|---|---|---|
| Signed integers | i8, i16, i32, i64, i128, isize | i32 is the default for integer literals |
| Unsigned integers | u8, u16, u32, u64, u128, usize | usize is pointer-sized; used for indexes and lengths |
| Floats | f32, f64 | f64 is the default |
| Boolean | bool | true / false |
| Character | char | A 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:
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:
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].
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
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:
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.
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:
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 valueWith 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.
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:
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:
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:
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:
- Write
fn fizzbuzz(n: u32) -> Stringthat returns"FizzBuzz","Fizz","Buzz"or the number as a string. Useifas an expression and noreturnkeyword. - In
main, loop over1..=20and print each result. - Then add
fn sum_until(limit: u32) -> u32that usesloopandbreak valueto add 1, 2, 3, … until the running sum would exceedlimit, and returns the last sum that didn't. - Bonus: what happens to
sum_untilif you change the types tou8and pass300? Try it in debug and in release.
Show solution
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 mutto allow change, and shadowing to transform a value under the same name. - Numeric types never convert implicitly. Use
asfor widening,try_fromwhen data could be lost, andchecked_/wrapping_/saturating_methods for overflow. - Blocks,
if,matchandloopare 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
forover ranges and iterators to index loops: same speed, no bounds bugs.
Check yourself
Quiz
0/4 correct · 4 left
1.What does this print? let x = 5; let x = x * 2; { let x = 0; } println!("{x}");
2.fn double(n: i32) -> i32 { n * 2; } fails to compile. Why?
3.let a: i32 = 10; let b: i64 = 20; What happens with a + b?
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.