DEV Community

Cover image for Rust for Gophers, Lesson 3: Structs, Enums, and Match
Syam SV
Syam SV

Posted on

Rust for Gophers, Lesson 3: Structs, Enums, and Match

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
};
Enter fullscreen mode Exit fullscreen mode

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 / ""
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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,
}
Enter fullscreen mode Exit fullscreen mode

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()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
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,
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

(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 }
}
Enter fullscreen mode Exit fullscreen mode
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(),
    }
}
Enter fullscreen mode Exit fullscreen mode

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 &Command gives references. Because cmd is a reference, msg binds as &String, not String, so nothing is moved out of the borrowed value. This feature is called "default binding modes" and it's why you rarely write & or ref in patterns.
  • Exhaustiveness is your refactoring tool. Add a Command::Jump variant, and every match that 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 Stack wrapping a Vec<i64>, with #[derive(Debug, Default)].
  • [ ] Methods with the right receivers: new() -> Self, push(v: i64), pop() -> Option<i64>, peek() -> Option<&i64>, len() -> usize. Choosing &self vs &mut self for 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 } and enum Token { Num(i64), Op(Op) }.
  • [ ] fn parse_token(s: &str) -> Option<Token>, written as one match on the string.
  • [ ] A method impl Op { fn apply(self, a: i64, b: i64) -> Option<i64> } that returns None on overflow and on division by zero.
  • [ ] fn eval(expr: &str) -> Option<i64> using your Stack. Use ? so you never write nested matches on pop().
  • [ ] main uses let else for the missing argument and exits with code 1 on any error. No unwrap(), no .clone().
  • [ ] Tests for eval: the examples above, plus empty input, an unknown token like x, and leftover values like "1 2".
  • [ ] cargo fmt --check && cargo clippy -- -D warnings && cargo test pass.

Hints (read only when stuck)

  1. Most Stack methods are one line that forwards to a Vec method. Look at Vec::pop and slice::last in the docs.
  2. In parse_token, a _ => arm can try s.parse::<i64>(). That returns a Result; .ok() turns it into an Option, and .map(Token::Num) wraps the number. Yes, an enum variant can be passed as a function.
  3. Integers have checked_add, checked_sub, checked_mul and checked_div, and each returns an Option. checked_div already handles division by zero.
  4. Op holds no data, so derive Clone, Copy on it. Then apply can take self by value without moving anything important.
  5. Gotcha: in a binary crate, a method nobody calls triggers a dead_code warning, and -D warnings turns that into an error. Use len() and peek() in eval to check that exactly one value remains and to read it. That's their natural job anyway.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

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.