Rust track

Lesson 12 · Part 3: Structs, Enums & Traits

Enums & Option

Defining enums, the Option type and pattern matching

Part 3: Structs, Enums & Traits5 min readFree lesson

An enum is a type that represents "exactly one of several shapes." While a struct is AND (field 1 and field 2), an enum is OR (variant A or variant B). Each variant can hold data, allowing you to represent anything from simple labels to complex sum types. The standard library's Option<T> is a representative enum — the secret to Rust having no nulls.

Learning Objectives

  • Define multiple variants using enum and create instances.
  • Understand the various forms in which a variant can hold data (tuple/struct/unit).
  • Attach methods to an enum with impl.
  • Learn the meaning of Option<T> and Some/None, and understand null safety.

Key Concepts

1) Shapes of an enum

enum IpAddr {
    V4(u8, u8, u8, u8),  //Tuple type
    V6(String),          //Single field
    Loopback,            //No data
}

Even within the same enum, the data shape can differ for each variant.

2) Attaching methods with impl

impl Coin {
    fn value_in_cents(&self) -> u32 {
        match self { /* ... */ }
    }
}

match will be covered in detail in the next chapter, but the above pattern is the most common form of enum + methods.

3) Option<T> — null safety

pub enum Option<T> {
    None,
    Some(T),
}

This type is used precisely in places where a value may or may not exist. Since the fact that "a value may not exist" is directly reflected in the type, users must explicitly handle it with match or if let.

fn first_even(nums: &[i32]) -> Option<i32> {
    for &n in nums {
        if n % 2 == 0 { return Some(n); }
    }
    None
}

4) Option Basic Handling Methods

Method Behavior
unwrap() Returns value if Some, panics if None (only in safe places)
unwrap_or(default) Default value when None
unwrap_or_else(|| ...) Calculate default value with a closure when None
map(|x| ...) Transform the value inside Some
and_then(|x| ...) Returns another Option when Some (chaining)
is_some(), is_none() Only checks presence
? operator Propagates None to the caller (Chapter 16)

5) Comparison with C# / Java null

  • C# / Java: Almost all reference types can be null, requiring checks every time.
  • Rust: By default, no null. Absence must always be indicated with Option<T>.

This difference completely blocks NullReferenceException / NullPointerException type bugs.

Examples

Example 1 — ex01_basic.rs : IpAddr enum

//Example 12-1: Enum — A type that represents 'one of these'
//
//Each variant can have data, and the shape of the data is also free.

#[derive(Debug)]
enum IpAddr {
    V4(u8, u8, u8, u8),    //Tuple-shaped data
    V6(String),            //One field
    Loopback,              //Variant without data
}

fn main() {
    let home = IpAddr::V4(127, 0, 0, 1);
    let server = IpAddr::V6(String::from("::1"));
    let lb = IpAddr::Loopback;

    println!("home   = {:?}", home);
    println!("server = {:?}", server);
    println!("lb     = {:?}", lb);
}

Example 2 — ex02_coin.rs : Coin enum and methods

//Example 12-2: Simple enum + method with no data
//
//A typical example of using match. Matching is covered in detail in the next lesson (13).

#[derive(Debug)]
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

impl Coin {
    fn value_in_cents(&self) -> u32 {
        match self {
            Coin::Penny => 1,
            Coin::Nickel => 5,
            Coin::Dime => 10,
            Coin::Quarter => 25,
        }
    }
}

fn main() {
    let purse = [Coin::Penny, Coin::Nickel, Coin::Dime, Coin::Quarter, Coin::Dime];

    let total: u32 = purse.iter().map(|c| c.value_in_cents()).sum();
    println!("Wallet = {:?}", purse);
    println!("Total = {total} cents");
}

Example 3 — ex03_option.rs : Option

//Example 3 of Chapter 12: Option<T> — null-safe type
//
//Option in the standard library is actually an enum:
//   pub enum Option<T> { None, Some(T) }
//The secret of Rust having no null.

fn find_even(nums: &[i32]) -> Option<i32> {
    for &n in nums {
        if n % 2 == 0 {
            return Some(n);
        }
    }
    None
}

fn main() {
    let result1 = find_even(&[1, 3, 5, 8, 9]);
    let result2 = find_even(&[1, 3, 5]);

    //The most basic handling: match
    match result1 {
        Some(n) => println!("First even: {n}"),
        None => println!("No even numbers"),
    }
    //unwrap_or: default value when None
    let v = result2.unwrap_or(-1);
    println!("Second result (default -1) = {v}");

    //The ? operator is covered in detail in Chapter 16.
}

Common Mistakes

Q. Do enums also need derive(Debug)?

A. Required to output as {:?}. Usually, something like #[derive(Debug, Clone, PartialEq)] is often attached above the enum definition.

Q. Is Option::None different from null in other languages?

A. Yes, it is different. Null requires a check every time it is used as "any reference type can be empty." None is limited to the Option<T> type, so the compiler explicitly shows through the type "this slot can be empty."

Q. Can I use let v = opt.unwrap(); freely?

A. It is dangerous. A panic occurs when it is None. Except for clearly Some positions (e.g., a just-created value), use match/if let/?/unwrap_or.

Q. I want variants to share the same data shape.

A. It is possible. Within the same enum, you can freely mix different shapes for each variant (like IpAddr in Example 1, where V4 is a tuple, V6 has a single field, Loopback has no data).

Summary

  • enum is a type of "one of several shapes" — each variant can have data.
  • You can attach methods to an enum with impl.
  • Option<T> provides null safety as a standard library enum.
  • unwrap should only be used where safety is obvious; otherwise, use match/if let/?.

Try it yourself

Solve the assignment below. The reference solution is shown below.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/enums/Cargo.tomlTOML
[package]
name = "enums"
version = "0.1.0"
edition = "2021"
default-run = "enums"

[dependencies]
src/enums/src/main.rsRust
fn main() {
    println!("This crate has three executable examples.");
    println!();
    println!("  cargo run --bin ex01_basic");
    println!("  cargo run --bin ex02_coin");
    println!("  cargo run --bin ex03_option");
}

Assignment

Problem 1 — Area of Shape enum

  • File name: homework_01/src/main.rs
  • Key concept: enum + data, match, method

Requirements

  • Define the Shape enum.
  • Circle(f64) — radius
  • Rectangle(f64, f64) — width, height
  • Triangle(f64, f64) — base, height
  • Method area(&self) -> f64 returns the area of each shape.
  • Print the areas of the three shapes in main.

Expected output

Circle(5.0)         area = 78.54
Rectangle(4.0, 3.0) area = 12.00
Triangle(6.0, 4.0)  area = 12.00

Hint

  • std::f64::consts::PI.
  • Triangle area = 0.5 * base * height.
  • match self { Shape::Circle(r) => ..., ... }.

Problem 2 — safe_div (handling division by 0)

  • File name: homework_02/src/main.rs
  • Key concept: Option<f64>, pattern handling

Requirements

  • Define the function safe_div(a: f64, b: f64) -> Option<f64>.
  • If b == 0.0 then None, otherwise Some(a / b).
  • In main, handle the results of the two calls (10.0, 2.0) and (7.0, 0.0) as follows.
    • Some: Result: 5.00
    • None: Cannot divide by 0

Expected output

Result: 5.00
Cannot divide by 0

Hint

  • if b == 0.0 { None } else { Some(a / b) }.
  • match result { Some(v) => println!("Result: {:.2}", v), None => println!("Cannot divide by zero") }.

Check your answers

Try solving it yourself first, then compare your answer with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/Cargo.tomlTOML
[package]
name = "homework_01"
version = "0.1.0"
edition = "2021"

[dependencies]
homework_01/src/main.rsRust
//Key Points:
//- Each variant of an enum can have different types of data but be handled as a single type.
//- You can destructure a variant with match while simultaneously binding the field values.

use std::f64::consts::PI;

enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
    Triangle(f64, f64),
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle(r) => PI * r * r,
            Shape::Rectangle(w, h) => w * h,
            Shape::Triangle(b, h) => 0.5 * b * h,
        }
    }
}

fn main() {
    let shapes = [
        Shape::Circle(5.0),
        Shape::Rectangle(4.0, 3.0),
        Shape::Triangle(6.0, 4.0),
    ];

    let labels = ["Circle(5.0)        ", "Rectangle(4.0, 3.0)", "Triangle(6.0, 4.0) "];
    for (label, shape) in labels.iter().zip(shapes.iter()) {
        println!("The width of {label} = {:.2}", shape.area());
    }
}
homework_02/Cargo.tomlTOML
[package]
name = "homework_02"
version = "0.1.0"
edition = "2021"

[dependencies]
homework_02/src/main.rsRust
//Key Points:
//- "There may be no result" expressed as an Option forces the caller to branch.
//- Floating-point comparison is generally avoided using `==` for safety,
//but exact values like 0.0 are safe to compare with ==.

fn safe_div(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    let cases = [(10.0, 2.0), (7.0, 0.0)];
    for (a, b) in cases.iter() {
        match safe_div(*a, *b) {
            Some(v) => println!("Result: {:.2}", v),
            None => println!("Cannot divide by 0"),
        }
    }
}

Learn this lesson with a mentor

Work through the Rust track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now