Learn Rust Series):Welcome back. In episode 1 we spent almost all our time on the why of Rust: ownership, borrowing, the borrow checker, and the promise of memory safety with no garbage collector. That was the mental model. Today we start writing actual code in earnest, and we begin where every program begins -- with variables, types, and functions.
These three topics sound basic, and in most languages they are. In Rust they are quietly full of decisions that will shape everything you write later. Immutability by default, no implicit numeric conversion, expressions that evaluate to values -- none of this is arbitrary. Each choice is Rust nudging you toward code that is correct by construction. So let us go through them carefully, because getting comfortable here makes the harder episodes ahead feel a lot smaller.
Nota bene: if you are coming from Python or Go (as quite some of you are), I will keep comparing against them. The contrast is still the fastest way to see what Rust is actually doing.
At the end of episode 1 I asked you to make a String, move it into a second variable, try to print the first one, and then fix it two different ways. Let us walk through the solution before we move on, because it sets up everything about today.
First, the version that does NOT compile -- the whole point of the exercise was to see this error with your own eyes:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}"); // ERROR: value borrowed here after move
}
s1 owned a heap-allocated string, let s2 = s1 moved that ownership into s2, and after a move the original binding is dead. The first fix is to borrow in stead of moving, using a reference so the value is never given away:
fn describe(s: &String) -> usize {
s.len() // we only read s; we never take ownership
}
fn main() {
let s1 = String::from("hello");
let n = describe(&s1);
println!("{s1} is {n} bytes"); // s1 is still ours
}
The &s1 lends the string to describe, which reads it and hands it back. Because we never moved s1, it is still valid on the last line. The second fix is to clone, which makes a genuinely independent copy of the heap data so both variables own their own allocation:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{s1} and {s2}"); // both valid, both independent
}
The key insight is the triangle from last time: move transfers ownership (cheap, but the original dies), borrow grants temporary access (cheap, nothing is copied, nothing dies), and clone duplicates the data (both live, but you paid for a copy). Reach for borrowing first, clone only when you actually need two independent owners. Right, on to today.
Here is the first thing that trips people up. In Rust, a variable you declare with let cannot be reassigned:
fn main() {
let x = 5;
x = 6; // ERROR: cannot assign twice to immutable variable `x`
println!("{x}");
}
Coming from Python, JavaScript, or basically any mainstream language, this feels backwards. A variable that you cannot change? Isn't that the whole point of a variable? Having said that, once you see the reasoning it is hard to unsee.
Immutability by default means that when you read let total = compute(); and then see total used two hundred lines later, you know it still holds what compute() returned. Nothing snuck in and changed it. In a large codebase that guarantee is worth a great deal -- a huge fraction of real bugs are "this value was not what I thought it was at this point". Rust flips the default so that mutation is something you opt into, visibly, exactly where it happens. The compiler also gets to optimize more aggressively when it knows a value never changes.
When you genuinely need to change a value, you say so with mut:
fn main() {
let mut x = 5;
println!("x starts at {x}");
x = 6;
println!("x is now {x}");
}
That one keyword is the whole difference. let x is a promise that x never changes; let mut x is an explicit statement that it might. The value of this is that mutation becomes searchable and visible. When you are hunting a bug and you see let mut, you know that binding is a place where state moves. When you see plain let, you can mentally rule it out. This is a small thing that pays off constantly.
Now for something that looks like mutation but is not. Rust lets you declare a new variable with the same name as an old one. This is called shadowing:
fn main() {
let spaces = " "; // spaces is a &str (string slice)
let spaces = spaces.len(); // spaces is now a usize -- a brand new variable
println!("{spaces} has value 3");
}
Look closely: the second spaces is a completely different variable that happens to reuse the name. The first one held a string; the second holds a number. This is legal, and it is idiomatic Rust. It matters because it lets you transform a value through a few steps while keeping one clear name, without inventing spaces_str, spaces_len, spaces_final and so on.
The crucial difference from mut: shadowing can change the type, mutation cannot. With let mut spaces = " "; you could assign another string to it, but never a number -- the type is fixed at declaration. Shadowing creates a fresh binding each time, so the type is free to change. Use mut when you want the same value to evolve; use shadowing when you want to reuse a name for a genuinely new value (often of a new type). They look similar and they are not the same tool.
Alongside variables there are constants, declared with const. A constant must have an explicit type, is always immutable (no mut allowed, ever), and can be declared at any scope including the top level of a file:
const MAX_USERS: u32 = 100_000;
fn main() {
println!("the cap is {MAX_USERS}");
}
Two things to notice. Constants are conventionally written in SCREAMING_SNAKE_CASE, and you can put underscores inside number literals (100_000) purely for readability -- the compiler ignores them. A constant is not just an immutable variable: its value is computed at compile time and inlined wherever it is used, so it has no runtime storage cost at all. Reach for const when you have a fixed value that the whole program should agree on, like a limit, a version number, or a physical constant.
Rust has four scalar types -- integers, floating-point numbers, booleans, and characters. Let us start with integers, because Rust gives you far more control here than a scripting language does:
fn main() {
let a: i32 = -42; // signed 32-bit
let b: u8 = 255; // unsigned 8-bit, range 0..=255
let big: u64 = 18_000_000_000;
let hex = 0xff; // 255 in hexadecimal
let binary = 0b1010_0001; // 161 in binary
println!("{a} {b} {big} {hex} {binary}");
}
Integer types are written as a sign plus a size: i for signed, u for unsigned, followed by the bit width. So i8, i16, i32, i64, i128 and their unsigned partners u8 through u128. There are also isize and usize, which match the pointer width of your machine (64-bit on a modern computer) and which you will mostly meet as the type of array indices and lengths.
Why so many? Because in systems programming the size matters. A u8 is exactly one byte, perfect for raw data and colours. A u64 counts things far beyond four billion. Picking the right width is about correctness and memory, not just taste. If you do not annotate, Rust defaults an integer to i32, which is a sensible middle ground.
Here is where Rust refuses to sweep something under the rug. What happens when a u8 (max 255) is pushed past its limit? In a debug build, Rust panics -- it stops the program rather than silently giving you a wrong number. In a release build, for speed, it wraps around. Because "it depends on the build" is a terrible thing to rely on by accident, Rust gives you explicit methods to say exactly what you want:
fn main() {
let x: u8 = 250;
let wrapped = x.wrapping_add(10); // 260 wraps around to 4
println!("wrapped: {wrapped}");
let checked = x.checked_add(10); // returns an Option: None on overflow
println!("checked: {checked:?}");
}
wrapping_add says "wrap around on purpose", which is genuinely what you want for things like hashes and checksums. checked_add says "give me the answer, or tell me it overflowed" by returning an Option (that None you saw in episode 1, the type that replaces null). There is also saturating_add, which clamps to the maximum in stead of wrapping. The lesson is that overflow is a real event with a real decision attached, and Rust makes you make it, rather than pretending large numbers never happen. Note the {checked:?} -- the :? asks for the debug representation, which is how you print types like Option that do not have a plain display form.
The other three scalar types are more familiar:
fn main() {
let pi = 3.14159_f64; // 64-bit float (the default)
let half: f32 = 0.5; // 32-bit float
let is_ready = true; // bool: true or false
let letter = 'R'; // char: a single Unicode scalar value
let digit = '7';
println!("{pi} {half} {is_ready} {letter}{digit}");
}
Floating-point comes in f64 (the default, double precision) and f32 (single precision). A bool is exactly true or false and takes one byte. The interesting one is char, written with single quotes. Unlike C, where a char is one byte, a Rust char is four bytes and holds any single Unicode scalar value -- a letter, a digit, a Greek symbol, an emoji, a Japanese kanji. Strings are a separate topic for a later episode (they are more subtle than they look), but it is worth knowing now that a char is a full Unicode value, not a byte.
You have probably noticed that I sometimes annotate a type (let a: i32 = -42;) and sometimes do not (let x = 5;). Rust has powerful type inference: the compiler looks at how a value is used and figures out its type for you, so you rarely have to spell it out. This gives you a scripting-language feel with static-typing guarantees.
But inference is where Rust's strictness shows its teeth. Rust will never implicitly convert one numeric type to another, not even from a smaller type to a larger one that could obviously hold it:
fn main() {
let x: i32 = 10;
let y: i64 = 20;
let z = x + y; // ERROR: mismatched types, expected i32, found i64
println!("{z}");
}
In C, Python, or JavaScript this "just works" -- the language quietly promotes x and adds. Rust flatly refuses, because those silent promotions are a classic source of subtle bugs (lost precision, unexpected signs, truncation). If you want the conversion, you ask for it explicitly with the as keyword:
fn main() {
let x: i32 = 10;
let y: i64 = 20;
let z = x as i64 + y; // explicit cast: no surprises
println!("{z}");
}
Now the intent is on the page. x as i64 says "yes, I know these are different types, convert x and I accept the consequences". It reads as slightly more verbose than C, and that verbosity is the feature -- every conversion is a decision you made, not one the compiler made behind your back.
Beyond the scalars, Rust has two primitive compound types that group values together. A tuple is a fixed-size, ordered collection where each position can be a different type:
fn main() {
let point: (i32, i32, &str) = (3, 4, "origin-ish");
let (px, py, label) = point; // destructuring
println!("{px}, {py} ({label})");
println!("first field via index: {}", point.0);
let days = [31, 28, 31, 30, 31]; // an array: [i32; 5]
println!("March has {} days", days[2]);
let zeros = [0u8; 4]; // [0, 0, 0, 0], length 4
println!("zeros has length {}", zeros.len());
}
A tuple groups a fixed number of values of possibly different types, and you can pull them apart by destructuring (let (px, py, label) = point;) or reach a single field by position (point.0, point.1, and so on). Tuples are how a function returns more than one value, which we will use in a moment.
An array, written with square brackets, is a fixed-length sequence where every element has the same type. Its type is written [i32; 5] -- element type and length together. Arrays live on the stack and their length is fixed at compile time; when you want a growable, heap-allocated sequence you reach for a Vec, which gets its own episode later. The [0u8; 4] syntax is a handy shortcut for "four copies of this value". And accessing an array out of bounds does not read random memory the way C would -- Rust checks the index and panics with a clear message, another memory-safety guarantee earning its keep.
Functions are declared with fn, take typed parameters, and optionally return a typed value:
fn greet(name: &str) {
println!("Hello, {name}!");
}
fn add(a: i32, b: i32) -> i32 {
a + b // no semicolon -- this is the return value
}
fn main() {
greet("Rustacean");
let sum = add(2, 3);
println!("2 + 3 = {sum}");
}
Every parameter must have a type annotation -- Rust never infers parameter types, on purpose, because a function signature is a contract and contracts should be explicit. The return type comes after the -> arrow. greet returns nothing (technically it returns the empty tuple (), called the unit type, which just means "no meaningful value"). add returns an i32.
Now look carefully at the last line of add: it is a + b with no semicolon. That is not a typo, and it is the single most important detail in this whole episode.
Rust draws a sharp line between two things. A statement performs an action and returns nothing. An expression evaluates to a value. let x = 5; is a statement. 5 + 3 is an expression. In Rust this distinction is load-bearing, because a function returns the value of its final expression -- if that last line has no semicolon, its value becomes the return value.
This goes further than you might expect. A block delimited by { } is itself an expression that evaluates to its last expression, and if is an expression too:
fn main() {
let y = {
let x = 3;
x + 1 // no semicolon -> the block evaluates to 4
};
println!("y = {y}");
let label = if y > 3 { "big" } else { "small" }; // if returns a value
println!("y is {label}");
}
The block assigned to y runs its inner statements and then evaluates to x + 1, so y becomes 4. The if is not a statement that happens to set a variable -- it is an expression whose value is one branch or the other, which is why we can assign it directly to label. Coming from Python this is like the ternary a if cond else b, except here the ordinary if does it, and there is no separate ternary operator at all. This is what people mean when they call Rust an "expression-oriented" language, and once it clicks you will lean on it constantly.
Because the semicolon decides whether something is an expression (a value) or a statement (nothing), one stray semicolon can break a function in a way that confuses every newcomer:
fn add_one(x: i32) -> i32 {
x + 1; // ERROR: the semicolon makes this a statement returning (), not i32
}
This looks obviously correct and it does not compile. The trailing semicolon turns x + 1 from the function's return value into a throwaway statement, so the function now falls off the end returning () -- the unit type -- while its signature promised an i32. The compiler's error is a model of clarity here; it will point at the -> i32 and note the mismatched (), and often suggest removing the semicolon. Delete it and the function returns x + 1 as intended. You will make this exact mistake in your first week, recognise the error message on sight, and never really fear it again. ;-)
Three exercises, from gentle to a little chewier. Full solutions in the next episode, as usual.
Write a function c_to_f(celsius: f64) -> f64 that converts Celsius to Fahrenheit (multiply by 9.0 / 5.0, then add 32.0). Call it from main and print a couple of values. Watch your float literals -- mixing f64 and integer literals will make the compiler complain, which is exactly the lesson.
Start with let input = "42";. Using shadowing, rebind input to the number 42 as an i32 (look up .parse() -- you will need to tell it the target type, e.g. let input: i32 = input.parse().unwrap();). Print the number multiplied by 2, and notice how the same name went from text to integer.
Write a function min_max(a: i32, b: i32, c: i32) -> (i32, i32) that returns both the smallest and the largest of three numbers as a tuple. In main, call it and destructure the result into two named variables before printing them.
Sit especially with exercise 3 -- returning a tuple and destructuring it is a pattern you will use everywhere in Rust, long before we get to fancier ways of bundling data.
mut opts a binding into change, and that visibility is the point.i8 through u128, plus isize/usize), and integer overflow is a real decision with wrapping_, checked_, and saturating_ methods rather than a silent surprise.as.That semicolon rule and the expression-orientation are the ideas most worth internalising today, because they run through everything ahead. Next time we go straight into the heart of the language -- how Rust lets you share and change data safely -- and you will finally see the borrow checker do its real work up close.