🚧 We are still building the lighthouse. Hang tight!

projectlighthouse logoprojectlighthouse

Sign in with your Google or GitHub account to get started

Structs and Methods

Learn to create custom types with structs, implement methods, and understand ownership in structs. Master value receivers vs pointer receivers and associated functions.

Programs model real-world concepts. A user has a name, an email address, and an account status. An HTTP request has a method, a URL, headers, and a body. A point in 2D space has x and y coordinates. Primitives alone can't capture these relationships. Structs let you bundle related data into custom types with names that communicate meaning.

Methods let you define behavior for these types. A rectangle struct can have an area method. A user struct can have a login method. Structs plus methods enable object-oriented-style programming without inheritance—Rust favors composition and trait-based polymorphism instead.

Defining Structs

A struct groups multiple values under a single name:

struct User {
    username: String,
    email: String,
    active: bool,
    sign_in_count: u64,
}

Each piece of data is a field with a name and type. Fields can be any type—primitives, other structs, enums, or collections.

Create instances by specifying values for each field:

let user = User {
    username: String::from("alice"),
    email: String::from("[email protected]"),
    active: true,
    sign_in_count: 1,
};

Field order in instantiation doesn't need to match the definition order. The field names disambiguate.

Access fields with dot notation:

println!("Username: {}", user.username);

To modify fields, the entire instance must be mutable:

let mut user = User {
    username: String::from("alice"),
    email: String::from("[email protected]"),
    active: true,
    sign_in_count: 1,
};

user.email = String::from("[email protected]");

Rust doesn't allow marking individual fields as mutable. Either the whole instance is mutable or none of it is.

Field Init Shorthand

When a variable has the same name as a field, use shorthand syntax:

fn build_user(username: String, email: String) -> User {
    User {
        username,
        email,
        active: true,
        sign_in_count: 1,
    }
}

Instead of writing username: username, you write just username. The compiler infers that the field gets the variable's value.

Struct Update Syntax

Creating a new instance from an existing instance while changing some fields uses struct update syntax:

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

let user2 = User {
    email: String::from("[email protected]"),
    ..user1
};

The ..user1 syntax fills remaining fields from user1. This copies or moves values depending on whether they implement Copy. Primitives and types implementing Copy copy. Owned types like String move:

println!("{}", user1.username); // Error: value moved
println!("{}", user1.active);   // OK: bool implements Copy

The username field moved to user2. The active field copied because bool implements Copy.

Tuple Structs

Tuple structs have fields without names:

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

let black = Color(0, 0, 0);
let origin = Point(0, 0);

Access fields by index:

println!("R: {}, G: {}, B: {}", black.0, black.1, black.2);

Tuple structs are useful when the struct name provides enough meaning and field names would be redundant. They also create distinct types—Color and Point are different types even though both contain three or two integers.

Unit Structs

Unit structs have no fields:

struct AlwaysTrue;

let instance = AlwaysTrue;

Unit structs are useful for implementing traits on a type that doesn't need to store data.

Ownership and Structs

Struct fields follow ownership rules. When a struct owns heap-allocated data, the struct's lifetime determines when that data drops:

struct Message {
    content: String,
}

{
    let msg = Message {
        content: String::from("Hello"),
    };
    // Use msg...
} // msg goes out of scope, String drops

When msg drops, the content field drops, freeing the heap-allocated string data.

Passing a struct to a function transfers ownership unless you pass a reference:

fn print_message(msg: Message) {
    println!("{}", msg.content);
}

let msg = Message {
    content: String::from("Hello"),
};
print_message(msg);
// msg is invalid here

To retain ownership, pass a reference:

fn print_message(msg: &Message) {
    println!("{}", msg.content);
}

let msg = Message {
    content: String::from("Hello"),
};
print_message(&msg);
// msg is still valid here

Methods

Methods are functions defined in the context of a struct. The first parameter is always self, representing the instance:

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

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

The impl block defines methods for Rectangle. The method area takes &self—a reference to the instance. Inside the method, access fields with self.width and self.height.

Call methods using dot notation:

let rect = Rectangle {
    width: 30,
    height: 50,
};
println!("Area: {}", rect.area());

Rust automatically references or dereferences when calling methods. Writing rect.area() is equivalent to (&rect).area(). The compiler inserts the reference automatically because the method signature specifies &self.

Taking Ownership, Borrowing, or Mutable Borrowing

Methods can take self, &self, or &mut self:

impl Rectangle {
    // Borrow immutably
    fn area(&self) -> u32 {
        self.width * self.height
    }

    // Borrow mutably
    fn grow(&mut self, amount: u32) {
        self.width += amount;
        self.height += amount;
    }

    // Take ownership
    fn into_square(self) -> Rectangle {
        let size = self.width.max(self.height);
        Rectangle {
            width: size,
            height: size,
        }
    }
}

The method area borrows immutably—reading fields without modifying.

The method grow borrows mutably—modifying fields.

The method into_square takes ownership—consuming the rectangle and returning a new one. After calling into_square, the original instance is invalid.

let mut rect = Rectangle { width: 10, height: 20 };
rect.grow(5);
println!("{}", rect.area());

let square = rect.into_square();
// rect is invalid here

Associated Functions

Functions defined in an impl block that don't take self are associated functions, not methods. They're associated with the type but don't operate on an instance:

impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle {
            width: size,
            height: size,
        }
    }
}

Call associated functions using :: syntax:

let sq = Rectangle::square(10);

Associated functions often serve as constructors. The String::from function you've used is an associated function.

Multiple impl Blocks

You can have multiple impl blocks for the same struct:

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

impl Rectangle {
    fn perimeter(&self) -> u32 {
        2 * (self.width + self.height)
    }
}

Both blocks define methods on Rectangle. Multiple blocks are allowed but usually unnecessary unless you're implementing traits (covered later).

Method Chaining

Methods that return &self or &mut self enable chaining:

impl Rectangle {
    fn set_width(&mut self, width: u32) -> &mut Self {
        self.width = width;
        self
    }

    fn set_height(&mut self, height: u32) -> &mut Self {
        self.height = height;
        self
    }
}

let mut rect = Rectangle { width: 0, height: 0 };
rect.set_width(30).set_height(50);

Each method returns &mut self, allowing the next method to be called immediately.

Computed vs Stored Fields

Structs typically store data. Methods compute derived values:

struct Circle {
    radius: f64,
}

impl Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }

    fn circumference(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.radius
    }
}

The struct stores only the radius. Area and circumference are computed on demand. This avoids storing redundant data and keeps the struct small.

When a value is expensive to compute and used frequently, consider caching it in the struct. Tradeoffs exist between memory usage and computation time.

Encapsulation Through Privacy

By default, struct fields are private to the module defining them. Code outside the module can't access private fields directly:

mod geometry {
    pub struct Rectangle {
        width: u32,
        height: u32,
    }

    impl Rectangle {
        pub fn new(width: u32, height: u32) -> Rectangle {
            Rectangle { width, height }
        }

        pub fn area(&self) -> u32 {
            self.width * self.height
        }
    }
}

use geometry::Rectangle;

let rect = Rectangle::new(10, 20);
println!("{}", rect.area());
// println!("{}", rect.width); // Error: field is private

The fields width and height are private. Code outside the geometry module can't access them. The constructor new and method area are public, providing controlled access.

To make fields public, use the pub keyword:

pub struct Point {
    pub x: i32,
    pub y: i32,
}

Public fields let external code read and modify them directly. Use this when the struct is a simple data container without invariants to enforce.

Why Structs and Methods Matter

Structs let you model domain concepts with clear types. Instead of passing around tuples (u32, u32) representing width and height, you pass a Rectangle. The type system enforces that you don't confuse a rectangle with a point or a color.

Methods attach behavior to data. Instead of free functions that take a rectangle as a parameter, you define methods on Rectangle itself. Code reads naturally: rect.area() instead of calculate_area(rect).

Ownership rules apply to structs like any other type. When a struct owns its data, dropping the struct drops the data. When a struct borrows data through references, lifetime rules ensure the borrowed data outlives the struct.

Structs don't provide inheritance. Rust favors composition—building complex types by including other types as fields—and traits—defining shared behavior across types. This avoids the fragility and complexity of inheritance hierarchies while retaining the benefits of polymorphism and code reuse.

Notes & Highlights

© 2025 projectlighthouse. All rights reserved.