1. Structs
Rust structs look like Go structs, with three differences that matter: there are no zero values, there is no embedding, and behaviour like printing or comparing is opted into with derive.
#[derive(Debug, Clone, PartialEq)]
struct User {
name: String, // owned: the struct owns its data (Lesson 2)
email: String,
active: bool,
login_count: u64,
}
let u = User {
name: String::from("syam"),
email: String::from("s@x.dev"),
active: true,
login_count: 0, // every field must be set; no implicit zero values
};
let u2 = User {
name: String::from("ana"),
..u.clone() // struct update syntax: take the remaining fields from another value
};
No zero values. Go silently fills omitted fields with 0, "" or nil. Rust makes you write every field, so adding a field to a struct turns every construction site into a compile error until you handle it. That's a refactoring safety net. When you do want defaults, opt in:
#[derive(Debug, Default)]
struct Config {
port: u16,
verbose: bool,
name: String,
}
let c = Config { port: 3000, ..Default::default() }; // the rest is 0 / false / ""
Struct fields are almost always owned types (String, Vec<T>), not references. A struct holding &str needs a lifetime annotation and can't outlive the data it borrows. That's sometimes right (Lesson 8), but owned fields are the default choice.
Tuple structs and the newtype pattern
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Meters(f64);
let d = Meters(3.0);
let raw = d.0; // fields are accessed by position
Wrapping a primitive in a one-field struct is called a newtype. It costs nothing at runtime, but Meters and Feet (or UserId and OrderId) become different types the compiler won't mix up. Go's type UserID int64 is similar, but Go converts between them freely with UserID(x). Industry Rust uses newtypes heavily for IDs and validated values.
derive: opting in to behaviour
| Derive | Gives you | Go equivalent |
|---|---|---|
Debug |
{:?} printing |
%v / %+v (automatic in Go) |
Clone |
.clone() deep copy |
Manual copying |
Copy |
Implicit bitwise copy; only if every field is Copy
|
Value semantics for small structs |
PartialEq, Eq
|
== and !=
|
== on comparable structs |
PartialOrd, Ord
|
<, >, sorting |
No built-in equivalent |
Hash |
Usable as a HashMap key |
Map keys must be comparable |
Default |
T::default() |
Zero values |
A good habit is to derive Debug on every type, and Clone/PartialEq when they make sense. Copy is only for small, plain-data types; never derive it on something that owns heap memory (the compiler won't let you anyway).
2. impl blocks: methods and constructors
Methods live in an impl block separate from the struct definition, much like Go's func (u *User) ... methods live outside the type declaration. The difference is the receiver: Go has two kinds (value and pointer), Rust has three, and they are exactly the three ownership modes from Lesson 2.
impl User {
// Associated function (no self): Rust's constructor convention, like Go's NewUser
fn new(name: &str, email: &str) -> Self {
Self {
name: name.to_string(),
email: email.to_string(),
active: true,
login_count: 0,
}
}
fn display_name(&self) -> &str { // borrows self read-only
&self.name
}
fn record_login(&mut self) { // borrows self exclusively, may modify
self.login_count += 1;
}
fn into_email(self) -> String { // takes ownership; u is unusable afterwards
self.email
}
}
let mut u = User::new("syam", "s@x.dev"); // :: calls an associated function
u.record_login(); // . calls a method; &mut u is taken automatically
let email = u.into_email(); // u is moved here
| Receiver | Go equivalent | Use when |
|---|---|---|
&self |
func (u User) or func (u *User) that only reads |
Getters, computations. Most methods |
&mut self |
func (u *User) that writes |
Changing state |
self |
No equivalent | Consuming or converting: into_* methods, builders |
Naming conventions you will see everywhere: new for the main constructor, with_* for alternatives (Vec::with_capacity), as_* for cheap borrowed views (as_str), to_* for expensive conversions that allocate (to_string), and into_* for conversions that consume self. Getters are named after the field (name()), never get_name().
Self (capital S) is an alias for the type the impl is for. Use it in constructors and return types so renaming the type later only touches one line.
The method call u.record_login() is sugar for User::record_login(&mut u). Rust auto-borrows the receiver, which is why you never write (&mut u).record_login(). Go does the same thing when it takes &u for pointer-receiver methods.
3. Enums: the feature Go doesn't have
A Rust enum is a sum type: a value that is exactly one of several variants, where each variant can carry its own data. This is the single feature Go programmers miss most once they've used it.
The simple case looks like Go's iota constants:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Status {
Active,
Suspended,
Deleted,
}
The difference is that Status is a real type. A Go type Status int accepts Status(42), which is a status that doesn't exist. A Rust Status can only ever be one of those three values.
The powerful case is variants with data:
enum Shape {
Circle { radius: f64 }, // struct-like variant
Rect { w: f64, h: f64 },
Triangle(f64, f64, f64), // tuple-like variant
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rect { w, h } => w * h,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
}
How you would write this in Go:
type Shape interface{ Area() float64 }
type Circle struct{ Radius float64 }
type Rect struct{ W, H float64 }
// ... plus a type switch somewhere:
switch s := shape.(type) {
case Circle: ...
case Rect: ...
// forgot Triangle? compiles fine, silently does nothing
}
| Go interface + type switch | Rust enum + match
|
|
|---|---|---|
| Set of variants | Open: anyone can implement the interface | Closed: exactly the variants listed |
| Missing a case | Compiles; bug at runtime | Compile error |
| Can be nil | Yes | No |
| Memory | Usually boxed behind an interface pointer, often a heap allocation | Stored inline: size of the largest variant plus a tag |
Rust also has the open kind of polymorphism (traits, Lesson 5). Use an enum when you know all the cases (tokens, commands, HTTP methods, states in a state machine, errors). Use a trait when other code needs to add new types. In real Rust code, enums are everywhere: Option, Result, JSON values, AST nodes, and the messages sent between threads.
4. Option<T>: no more nil
Rust has no nil. A value that might be absent has the type Option<T>, which is just an ordinary enum from the standard library:
enum Option<T> {
Some(T),
None,
}
The point is that Option<User> and User are different types. You cannot call .name on an Option<User>; you must first handle the None case. So the Go bug "forgot to check for nil, panicked in production" becomes a compile error. And when a function returns plain User, you know for certain it's there.
It also costs nothing: Option<&User> is the same size as &User, because the compiler uses the impossible null address to represent None.
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> {
users.iter().find(|u| u.name == name)
}
(The <'a> is needed because there are two reference inputs, as in Drill 5. It says the returned reference borrows from users, not from name.)
Everyday ways to handle an Option
// 1. match: handle both cases explicitly
match find_user(&users, "ana") {
Some(u) => println!("found {}", u.email),
None => println!("not found"),
}
// 2. if let: you only care about the Some case
if let Some(u) = find_user(&users, "ana") {
println!("found {}", u.email);
}
// 3. let else: unwrap or leave the function early (Go's `if x == nil { return }`)
let Some(u) = find_user(&users, "ana") else {
return;
};
// 4. Combinators: transform without unwrapping
let name = find_user(&users, "zed")
.map(|u| u.name.as_str()) // Option<&User> -> Option<&str>
.unwrap_or("nobody"); // fall back to a default
// 5. The ? operator: inside a function returning Option, return None early
fn first_even(xs: &[i32]) -> Option<i32> {
let first = xs.first()?; // if None, return None from first_even right now
if first % 2 == 0 { Some(*first) } else { None }
}
| Method | Does | Use it when |
|---|---|---|
map(f) |
Transforms the inner value if Some
|
Chaining a computation |
and_then(f) |
Like map, but f returns an Option itself |
Chaining steps that can each fail |
unwrap_or(v) / unwrap_or_default()
|
Gives the value or a fallback | A sensible default exists |
unwrap_or_else(f) |
Fallback computed lazily | The fallback is expensive |
ok_or(err) |
Turns Option into Result
|
Lesson 4 |
is_some() / is_none()
|
Tests without taking the value | Only for checks; prefer if let when you'll use the value |
copied() / cloned()
|
Option<&T> to Option<T>
|
Getting an owned value out of a lookup |
unwrap() / expect("why")
|
Panics on None
|
Tests and genuinely impossible cases only |
On unwrap(): in production code, treat it as a red flag. Each one is a potential panic. If you are certain it can't fail, use expect("reason it can't fail") so the panic message explains your assumption. In reviews, I'll ask you to justify each one.
5. Pattern matching in depth
A pattern describes the shape of a value and pulls pieces out of it in one step. Patterns appear in match, if let, let else, while let, let, and function parameters.
enum Command {
Quit,
Move { x: i32, y: i32 },
Say(String),
}
fn run(cmd: &Command) -> String {
match cmd {
Command::Quit => "bye".to_string(),
Command::Move { x: 0, y } => format!("vertical {y}"), // literal inside a pattern
Command::Move { x, y } if x == y => format!("diagonal {x}"), // match guard
Command::Move { x, y } => format!("move {x},{y}"),
Command::Say(msg) => msg.clone(),
}
}
Things to notice:
- Arms are checked top to bottom, and the first match wins. Put specific patterns before general ones. The compiler warns about an arm that can never be reached.
-
Matching on
&Commandgives references. Becausecmdis a reference,msgbinds as&String, notString, so nothing is moved out of the borrowed value. This feature is called "default binding modes" and it's why you rarely write&orrefin patterns. -
Exhaustiveness is your refactoring tool. Add a
Command::Jumpvariant, and everymatchthat doesn't handle it becomes a compile error that lists exactly where to update. Avoid a lazy_ =>arm on your own enums, because it switches that check off.
The pattern toolbox
match age {
0 => "newborn",
n @ 1..=12 if n < 3 => "toddler", // @ binds the value while testing a range
1..=12 => "child",
13..=19 => "teen",
_ => "adult",
}
match point {
(0, 0) => println!("origin"),
(x, y) if x == -y => println!("anti-diagonal"),
(x, _) => println!("x={x}"), // _ ignores a part
}
let User { name, email, .. } = user; // destructure a struct; .. ignores the rest
let is_gone = matches!(status, Status::Suspended | Status::Deleted); // bool from a pattern
if let, let else, while let
if let Command::Say(msg) = &cmd { // one variant you care about
println!("{msg}");
}
let Some(expr) = env::args().nth(1) else { // unwrap or exit: flat code, no nesting
eprintln!("usage: rpn <expr>");
process::exit(1);
};
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() { // loop until the pattern stops matching
print!("{top} "); // 3 2 1
}
let else is the closest thing to Go's guard-clause style (if err != nil { return }). It keeps the happy path unindented, and the else block must leave the scope (return, break, continue, or exit). Industry Rust uses it heavily at the top of functions.
6. Exercise: a stack, and an RPN calculator on top of it
You'll build the stack data structure, then use it to evaluate Reverse Polish Notation, where the operator comes after its operands: 3 4 + 2 * means (3 + 4) * 2. It's a classic stack problem, and it exercises structs, methods, enums with data, match and Option together.
cargo new rpn && cd rpn
cargo run -- "3 4 + 2 *" # 14
cargo run -- "10 2 -" # 8
cargo run -- "-3 4 *" # -12 (a negative number, not the operator)
cargo run -- "1 0 /" # stderr: error: invalid expression, exit 1
cargo run -- "1 +" # stderr: error: invalid expression, exit 1
cargo run # stderr: usage: rpn "<expression>", exit 1
How RPN evaluation works: read tokens left to right. A number gets pushed. An operator pops two values (the first pop is the right operand), applies itself, and pushes the result. At the end, exactly one value must remain on the stack.
Part A: the stack
- [ ]
struct Stackwrapping aVec<i64>, with#[derive(Debug, Default)]. - [ ] Methods with the right receivers:
new() -> Self,push(v: i64),pop() -> Option<i64>,peek() -> Option<&i64>,len() -> usize. Choosing&selfvs&mut selffor each is part of the exercise. - [ ] Tests: pop on an empty stack, push then peek, and LIFO order after several pushes.
Part B: the calculator
- [ ]
enum Op { Add, Sub, Mul, Div }andenum Token { Num(i64), Op(Op) }. - [ ]
fn parse_token(s: &str) -> Option<Token>, written as onematchon the string. - [ ] A method
impl Op { fn apply(self, a: i64, b: i64) -> Option<i64> }that returnsNoneon overflow and on division by zero. - [ ]
fn eval(expr: &str) -> Option<i64>using yourStack. Use?so you never write nested matches onpop(). - [ ]
mainuseslet elsefor the missing argument and exits with code 1 on any error. Nounwrap(), no.clone(). - [ ] Tests for
eval: the examples above, plus empty input, an unknown token likex, and leftover values like"1 2". - [ ]
cargo fmt --check && cargo clippy -- -D warnings && cargo testpass.
Hints (read only when stuck)
- Most
Stackmethods are one line that forwards to aVecmethod. Look atVec::popandslice::lastin the docs. - In
parse_token, a_ =>arm can trys.parse::<i64>(). That returns aResult;.ok()turns it into anOption, and.map(Token::Num)wraps the number. Yes, an enum variant can be passed as a function. - Integers have
checked_add,checked_sub,checked_mulandchecked_div, and each returns anOption.checked_divalready handles division by zero. -
Opholds no data, so deriveClone, Copyon it. Thenapplycan takeselfby value without moving anything important. -
Gotcha: in a binary crate, a method nobody calls triggers a
dead_codewarning, and-D warningsturns that into an error. Uselen()andpeek()inevalto check that exactly one value remains and to read it. That's their natural job anyway.
Top comments (1)
The newtype example is a nice way to make invalid combinations harder to express. A constructor that validates the underlying value can also protect an invariant, provided the inner field is not exposed for arbitrary construction.