Skip to main content
Rust · lesson 11 of 30
Rust beginner Lesson 11 of 30

Structs in Rust

Define Rust structs, implement methods and associated functions, use update syntax and #[derive], implement Display, and overload operators.

By SkillByExample · Updated

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);
}
$ rustc structs_defining_structs.rs && ./structs_defining_structs
width: 30, height: 50

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.

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

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

Remove mut from the let and the assignment becomes a compile error (cannot assign to rect.width, as rect is not declared as mutable) — there is no way to make just width writable.

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("alice@example.com"),
        active: true,
        login_count: 1,
    };

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

    println!("{} ({}) active={} logins={}", user2.username, user2.email, user2.active, user2.login_count);
    println!("user1 still usable: {}", user1.username);
}
$ rustc structs_struct_update_syntax.rs && ./structs_struct_update_syntax
bob (bob@example.com) active=true logins=1
user1 still usable: alice

user1 survives here because the only fields ..user1 took were active and login_count — both Copy. Leave out username: String::from("bob") and ..user1 would move user1.username into user2, making user1.username unusable afterwards.

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);
}
$ rustc structs_tuple_structs.rs && ./structs_tuple_structs
Red: (255, 0, 0)
Origin: (0, 0, 0)

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

impl AlwaysEqual {
    fn describe(&self) -> &'static str {
        "a value with no data"
    }
}

fn main() {
    let subject = AlwaysEqual;
    println!("{}", subject.describe());
    println!("size: {} bytes", std::mem::size_of::<AlwaysEqual>());
}
$ rustc structs_unit_like_structs.rs && ./structs_unit_like_structs
a value with no data
size: 0 bytes

A unit-like struct occupies zero bytes — it exists only in the type system, yet it can still have methods and implement traits.

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());
    println!("perimeter: {}", rect.perimeter());
    println!("square: {}", rect.is_square());

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

    let (w, h) = rect.into_tuple(); // rect is consumed here
    println!("{} {}", w, h);
}
$ rustc structs_impl_blocks_methods.rs && ./structs_impl_blocks_methods
area: 50
perimeter: 30
square: false
after scale: 20x10
20 10

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.

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

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);
    println!("{}x{}", sq.width, sq.height);
}
$ rustc structs_associated_functions_static_methods.rs && ./structs_associated_functions_static_methods
10x5
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.

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

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
    }
}

fn main() {
    let big = Rectangle { width: 30.0, height: 50.0 };
    let small = Rectangle { width: 10.0, height: 40.0 };
    println!("big area: {}", big.area());
    println!("big holds small: {}", big.can_hold(&small));
    println!("small holds big: {}", small.can_hold(&big));
}
$ rustc structs_multiple_impl_blocks.rs && ./structs_multiple_impl_blocks
big area: 1500
big holds small: true
small holds big: false

Callers can’t tell the two blocks apart — area and can_hold are both just methods on Rectangle.

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
    println!("{:#?}", p1);    // Debug with pretty-printing
    println!("{}", p1 == p2); // PartialEq
}
$ rustc structs_deriving_common_traits.rs && ./structs_deriving_common_traits
Point { x: 1.0, y: 2.0 }
Point {
    x: 1.0,
    y: 2.0,
}
true

{:#?} is the same Debug impl with a flag that puts one field per line — worth using for nested structs.

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);
}
$ rustc structs_display_formatting.rs && ./structs_display_formatting
[ 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());
    println!("a + b: {:?}", a + b);
    println!("dot product: {}", a.dot(&b));
    println!("normalized a: {:?}", a.normalize());
    println!("a + zero == a: {}", a + Vec2::zero() == a);
}
$ rustc structs_a_complete_example_2d_vector.rs && ./structs_a_complete_example_2d_vector
length of a: 5
a + b: Vec2 { x: 4.0, y: 4.0 }
dot product: 3
normalized a: Vec2 { x: 0.6, y: 0.8 }
a + zero == a: true

a + b works on values, and a is still usable on the next line — because Vec2 derives Copy, the add(self, ...) call copies it instead of consuming it. Remove Copy from the derive list and a is moved by the first +. Note that {} prints 5 rather than 5.0 for an f64 whose value is a whole number, while {:?} keeps the .0.

Practice

  1. In the struct update example, delete the line username: String::from("bob"), so ..user1 supplies the username too. Keep the final println! that reads user1.username. What happens?

    Solution
    error[E0382]: borrow of moved value: `user1.username`
    ...
       = note: move occurs because `user1.username` has type `String`, which does not implement the `Copy` trait

    ..user1 now moves the String out of user1. The struct is partially moved: user1.email and user1.active would still be readable, but user1.username is not.

  2. Remove Copy from Vec2’s derive list (keep Clone) and add println!("{:?}", a); after a + b. Read the error, then fix it without adding Copy back.

    Solution
    error[E0382]: borrow of moved value: `a`
    ...
    17 |     println!("a + b: {:?}", a + b);
       |                             ----- `a` moved due to usage in operator

    Add::add takes self by value, so the left operand is moved. The compiler’s suggested fix is a.clone() + b.

  3. Implement fmt::Display for Vec2 so that println!("{}", a) prints (3.0, 4.0) while {:?} keeps the derived format.

    Solution
    use std::fmt;
    
    impl fmt::Display for Vec2 {
        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
            write!(f, "({:.1}, {:.1})", self.x, self.y)
        }
    }

    {} then prints (3.0, 4.0) and {:?} still prints Vec2 { x: 3.0, y: 4.0 } — the two traits are independent.

  4. Try println!("{}", p1); on the Point from the derive example (it derives Debug, not Display). What does the compiler tell you to use instead?

    Solution
    error[E0277]: `Point` doesn't implement `std::fmt::Display`
    ...
      = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
  5. Add an associated function Rectangle::from_area_ratio(area: f64, ratio: f64) -> Rectangle that picks width = (area * ratio).sqrt() and height = area / width. What does Rectangle::from_area_ratio(50.0, 2.0) produce?

    Solution
    fn from_area_ratio(area: f64, ratio: f64) -> Rectangle {
        let width = (area * ratio).sqrt();
        Rectangle { width, height: area / width }
    }

    width is √100 = 10, height is 50 / 10 = 5 — printing {}x{} gives 10x5, the same rectangle Rectangle::new(10.0, 5.0) builds.

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.