Skip to main content
Rust beginner Lesson 11 of 30

Structs in Rust

Define structs, implement methods and associated functions, use #[derive], and understand the different struct forms.

Defining Structs

Structs are the primary way to group related data under a single named type. They solve the same problem as classes in other languages — bundling data that belongs together — but Rust keeps data definition and behaviour separate: the struct keyword defines the fields, and a separate impl block adds methods. Every field must be initialised when creating an instance; there are no implicit zero-values or defaults.

struct Rectangle {
    width: f64,
    height: f64,
}

fn main() {
    let rect = Rectangle {
        width: 30.0,
        height: 50.0,
    };

    // Access fields with dot notation
    println!("width: {}, height: {}", rect.width, rect.height);
}

Mutable Structs

In Rust, mutability is a property of the binding, not the field. The entire struct instance must be declared mut to modify any field — you cannot mark individual fields as mutable. This keeps the mutability model consistent with the rest of the language.

fn main() {
    let mut rect = Rectangle { width: 10.0, height: 20.0 };
    rect.width = 15.0; // allowed because rect is mut
    println!("{}", rect.width); // 15
}

Struct Update Syntax

When creating a new instance that shares most fields with an existing one, the ..existing syntax copies the remaining fields. This is concise and avoids repeating field names that have not changed. Note that .. performs a move for non-Copy fields, so the source struct may become partially moved.

struct User {
    username: String,
    email: String,
    active: bool,
    login_count: u32,
}

fn main() {
    let user1 = User {
        username: String::from("alice"),
        email: String::from("[email protected]"),
        active: true,
        login_count: 1,
    };

    // Only specify fields that differ; ..user1 fills in the rest
    let user2 = User {
        email: String::from("[email protected]"),
        username: String::from("bob"),
        ..user1 // active and login_count are copied from user1
    };

    println!("{} ({})", user2.username, user2.email);
}

Tuple Structs

Tuple structs have a name but no field names — their fields are accessed by index. They are useful when you want the type-safety benefit of a named type (so Color and Point cannot be confused despite having the same shape) without the overhead of field names.

struct Color(u8, u8, u8);
struct Point(f64, f64, f64);

fn main() {
    let red = Color(255, 0, 0);
    let origin = Point(0.0, 0.0, 0.0);

    // Access by positional index with dot notation
    println!("Red: ({}, {}, {})", red.0, red.1, red.2);
    println!("Origin: ({}, {}, {})", origin.0, origin.1, origin.2);
}

Color and Point are distinct types even though they have the same field layout — the compiler will not let you pass a Color where a Point is expected.

Unit-Like Structs

Structs with no fields are called unit-like structs. They carry no data but are still a distinct type, which makes them useful as marker types or as the implementing type for a trait that has no state requirements.

struct AlwaysEqual; // no fields, no parentheses, no braces

fn main() {
    let _subject = AlwaysEqual;
}

impl Blocks — Methods

Methods are defined inside impl blocks and are called with dot notation on instances. The first parameter determines the ownership semantics: &self for read-only access, &mut self to modify the instance, and self to consume it.

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    // &self — shared borrow: read-only, does not consume the Rectangle
    fn area(&self) -> f64 {
        self.width * self.height
    }

    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }

    fn is_square(&self) -> bool {
        (self.width - self.height).abs() < f64::EPSILON
    }

    // &mut self — mutable borrow: can modify fields, does not consume
    fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }

    // self — takes ownership: consumes the Rectangle, caller can no longer use it
    fn into_tuple(self) -> (f64, f64) {
        (self.width, self.height)
    }
}

fn main() {
    let mut rect = Rectangle { width: 10.0, height: 5.0 };

    println!("area: {}", rect.area());           // 50
    println!("perimeter: {}", rect.perimeter()); // 30
    println!("square: {}", rect.is_square());    // false

    rect.scale(2.0);
    println!("after scale: {}x{}", rect.width, rect.height); // 20x10

    let (w, h) = rect.into_tuple(); // rect is consumed here
    println!("{} {}", w, h);
}

Associated Functions (Static Methods)

Associated functions live in an impl block but do not take self. They are called on the type itself with :: syntax rather than on an instance. The conventional name for a constructor is new, though Rust does not enforce this — any associated function that returns Self can serve as a constructor.

impl Rectangle {
    // Constructor — returns a new Rectangle, called as Rectangle::new(...)
    fn new(width: f64, height: f64) -> Rectangle {
        Rectangle { width, height } // shorthand when variable name matches field name
    }

    // Alternative constructor for the special case of a square
    fn square(size: f64) -> Rectangle {
        Rectangle { width: size, height: size }
    }
}

fn main() {
    let rect = Rectangle::new(10.0, 5.0);
    let sq   = Rectangle::square(4.0);
    println!("{}x{}", rect.width, rect.height); // 10x5
    println!("{}x{}", sq.width, sq.height);     // 4x4
}

Rust uses :: for associated functions everywhere in the standard library: String::new(), Vec::with_capacity(), HashMap::new().

Multiple impl Blocks

A type can have multiple impl blocks. This is useful for organisation — for example, separating core methods from trait implementations — and is required when implementing generic traits.

impl Rectangle {
    fn area(&self) -> f64 { self.width * self.height }
}

impl Rectangle {
    // A second impl block for methods that depend on another Rectangle
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

Deriving Common Traits

The #[derive] attribute instructs the compiler to auto-implement standard traits. This eliminates boilerplate for common operations like printing, cloning, and equality testing. Derivable traits only work if all fields also implement the trait.

#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}

fn main() {
    let p1 = Point { x: 1.0, y: 2.0 };
    let p2 = p1.clone(); // Clone gives us a deep copy

    println!("{:?}", p1);     // Debug: Point { x: 1.0, y: 2.0 }
    println!("{:#?}", p1);    // Debug with pretty-printing
    println!("{}", p1 == p2); // PartialEq: true
}

Commonly derived traits:

TraitProvides
Debug{:?} formatting
Clone.clone() method
CopyCopy semantics (requires Clone)
PartialEq== and != operators
EqTotal equality (for HashMap keys etc.)
PartialOrd<, >, <=, >=
OrdTotal ordering (for sorting)
HashUse as HashMap/HashSet key
DefaultType::default() constructor

Display Formatting

Debug is for developer output. Implement std::fmt::Display when you want to control how your type appears in user-facing output with {}. Display is not derivable — you write it yourself to define exactly the format.

use std::fmt;

struct Matrix {
    a: f64, b: f64,
    c: f64, d: f64,
}

impl fmt::Display for Matrix {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        // write! into the formatter — return its Result
        write!(f, "[ {:.2}  {:.2} ]\n[ {:.2}  {:.2} ]",
               self.a, self.b, self.c, self.d)
    }
}

fn main() {
    let m = Matrix { a: 1.0, b: 2.0, c: 3.0, d: 4.0 };
    println!("{}", m);
    // [ 1.00  2.00 ]
    // [ 3.00  4.00 ]
}

A Complete Example: 2D Vector

This example brings together struct definition, multiple impl blocks, derived traits, and operator overloading via the Add trait:

#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 {
    x: f64,
    y: f64,
}

impl Vec2 {
    fn new(x: f64, y: f64) -> Self { Self { x, y } }
    fn zero() -> Self { Self::new(0.0, 0.0) }

    fn length(&self) -> f64 {
        // Euclidean distance from origin
        (self.x * self.x + self.y * self.y).sqrt()
    }

    fn normalize(&self) -> Self {
        let len = self.length();
        Self::new(self.x / len, self.y / len)
    }

    fn dot(&self, other: &Vec2) -> f64 {
        self.x * other.x + self.y * other.y
    }
}

// Implement the + operator by implementing the Add trait
impl std::ops::Add for Vec2 {
    type Output = Vec2;
    fn add(self, other: Vec2) -> Vec2 {
        Vec2::new(self.x + other.x, self.y + other.y)
    }
}

fn main() {
    let a = Vec2::new(3.0, 4.0);
    let b = Vec2::new(1.0, 0.0);

    println!("length of a: {}", a.length());        // 5
    println!("a + b: {:?}", a + b);                 // Vec2 { x: 4.0, y: 4.0 }
    println!("dot product: {}", a.dot(&b));          // 3
    println!("normalized a: {:?}", a.normalize());
}

Frequently Asked Questions

What is the difference between a method and an associated function?
Methods take self as their first parameter and are called on instances (e.g. rect.area()). Associated functions do not take self and are called on the type itself (e.g. String::new()).
What does #[derive] do?
#[derive] is a macro that automatically implements common traits like Debug, Clone, PartialEq for your struct without writing boilerplate.
Can Rust structs have methods like classes?
Yes. You define methods in an impl block. Rust separates data definition (struct) from behaviour (impl), but the result is similar to a class.