RustSystems Programming

Rust Structs and Methods: Building Custom Data Types

TT
TopicTrick Team
Rust Structs and Methods: Building Custom Data Types

Rust Structs and Methods: Building Custom Data Types

As we leave the theoretical realm of the Borrow Checker and memory bounds, we must confront architectural reality: writing an entire codebase using only usize, tuples, and primitive arrays is unworkable.

To bridge the gap between machine code and complex business logic, you need to bundle related data together into single cohesive units.

In C++ and Java, this is done via Classes. In Rust, this is accomplished through Structs.

While Rust is not an Object-Oriented language in the traditional inheritance-based sense, it utilizes struct definitions and impl (implementation) blocks to provide identical encapsulation and method mechanics. Let's build our first custom types.


1. Defining and Instantiating Structs

A struct (short for structure) allows you to name and package multiple related values together. Unlike tuples, you must explicitly name every piece of data inside a struct, making the code vastly more readable and self-documenting.

Here is how you declare a user profile in Rust:

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

To create an instance of that struct, we use the name of the struct and then introduce curly braces containing key-value pairs matching our definition.

rust
fn main() {
    let mut user1 = User {
        email: String::from("admin@topictrick.com"),
        username: String::from("topicadmin"),
        active: true,
        sign_in_count: 1,
    };

    // Because user1 is 'mut', we can mutate fields using dot notation
    user1.email = String::from("newadmin@topictrick.com");
}

[!WARNING] Mutability is all or nothing. You cannot mark a specific field of a struct as mutable. The entire instance (user1) must either be mut or immutable.

The Struct Update Syntax

Often you will need to create a new struct instance that uses most of the values from an old instance, but changes one or two fields. Rust provides a beautiful "Update Syntax" .. to rapidly clone the remaining fields:

rust
let user2 = User {
    email: String::from("another@example.com"),
    ..user1 // Use all other fields from user1!
};

Note: Because username is a String (Heap data), using update syntax actually Executes a Partial Move. user1.username is no longer valid to use, but user1.active (a boolean on the Stack) was Copied safely!


2. Tuple Structs and Unit Structs

Rust supports two other highly specialized types of structs.

Tuple Structs

Tuple structs have the added mathematical meaning of a struct name, but lack explicit names for their fields. They are incredibly useful for defining rigid semantic boundaries where standard tuples would be too confusing.

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

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);

    // Even though they hold identical data shapes, 
    // passing a `Point` into a function expecting a `Color` will crash the compiler!
}

Unit-Like Structs

You can also define structs that have no data fields at all! These are called "unit-like structs" because they behave similarly to the empty tuple (). These are heavily used in Advanced Rust state-machine patterns, where you want to attach behavior (methods) to a type, but the type requires no raw physical data to be stored on the heap or stack.

rust
struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

3. Adding Logic: The impl Block

Data validation and processing shouldn't hover around arbitrarily in global functions. They should be deeply tied to the data they operate on.

We attach methods to a struct by defining an impl (Implementation) block.

rust
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // This is a Method. It takes a reference to 'self'
    fn area(&self) -> u32 {
        self.width * self.height
    }
    
    // We can also take a mutable reference to alter the struct!
    fn double_width(&mut self) {
        self.width *= 2;
    }
}

fn main() {
    let mut rect1 = Rectangle { width: 30, height: 50 };

    println!("The area is {} square pixels.", rect1.area());
    
    rect1.double_width();
    println!("The new area is {} square pixels.", rect1.area());
}

Understanding &self

Notice the first parameter is &self. This tells the compiler: “I am borrowing the instance of the Rectangle immutable so I can look at its width and height.”

If area had requested self (without the ampersand), it would Move the Rectangle into the method and consume it, destroying rect1 when the function ended! By requesting &self, the method safely borrows the data, returns an answer, and gives ownership right back.


4. Associated Functions (Constructors)

There is a distinct difference between "Methods" and "Associated Functions" in an impl block.

If a function defined inside the impl block does not have self as its first parameter, it is an Associated Function. It is associated with the Struct's namespace, but it does not operate on a specific instance of the struct.

These are primarily used as Constructors that return a fresh instance of the struct. By convention in Rust, the primary constructor is almost always named new.

rust
impl Rectangle {
    // This is an Associated Function (No 'self' parameter!)
    fn new(size: u32) -> Rectangle {
        Rectangle {
            width: size,
            height: size, // Force a perfect square
        }
    }
}

fn main() {
    // We call associated functions using the explicit namespace :: operator
    let sq = Rectangle::new(3);
}

This mirrors public static methods in languages like Java or C#.


5. The Derive Macro (#[derive(Debug)])

In the Rectangle implementation earlier, you might have noticed the #[derive(Debug)] notation hovering directly above the struct definition.

By default, if you try to println!("{}", rect1);, the Rust compiler will fail. println! inherently relies on a trait named Display. Rust expects you (the developer) to manually define exactly how a complex struct should be formatted for a human screen.

For rapid development and debugging, manually writing formatters is excruciating.

Instead, Rust provides the Debug trait. By explicitly adding the #[derive(Debug)] attribute, the compiler automatically generates a massive chunk of boilerplate macro code behind the scenes that allows you to print the state of the struct to the terminal instantly for logging.

rust
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };

    // Standard Debug output using {:?}
    println!("rect1 is {:?}", rect1); // Output: rect1 is Rectangle { width: 30, height: 50 }
    
    // Pretty-Printed Debug output using {:#?}
    // Ideal for giant nested Json-style structs
    println!("rect1 is {:#?}", rect1);
}
Classes (Java/C++)Structs (Rust)
Data and LogicDefined together in a single Class block.Separated: 'struct' for Data, 'impl' for Logic.
InheritanceHeavily utilized (class Dog extends Animal).Non-existent. Rust utilizes 'Traits' for shared behavior interfaces instead.
ConstructorsImplicit language-enforced instantiators.Standard Associated Functions (conventions like new()).
Accessing InstanceImplicit this. scope.Explicit &self passing.

Summary and Next Steps

Structs allow you to define the nouns of your domain. By tying together isolated variables into logical business units, you establish the foundation of your architecture. Through impl blocks, you define the verbs—how that data mutates, calculates, and evolves through its memory lifecycle while strictly adhering to the borrow checker's constraints.

But structs are inherently inflexible. They assume you know the exact shape of your data at all times. What happens when your data can be one of multiple entirely different things?

In the next module, we investigate one of the most powerful features of functional languages that Rust perfected: Enums.

Read next: Rust Enums, Option, and Result: Type-Safe Error Handling →



Quick Knowledge Check

When architecting a method inside an 'impl' block, why should you almost universally use '&self' instead of 'self' as the first parameter?

  1. Because 'self' triggers a memory Move, meaning the struct instance will be completely consumed and destroyed as soon as the method finishes executing. ✓
  2. Because '&self' bypasses the Borrow Checker, allowing multiple threads to execute the logical method simultaneously.
  3. Because the 'mut' keyword is prohibited inside impl blocks by the compiler.
  4. It is just a syntactic preference; 'self' and '&self' compile to the exact same underlying LLVM LLIR machine code.

Explanation: Passing 'self' without an ampersand transfers Ownership of the struct into the method. When the method hits its closing brace, 'drop' is called, and the original caller will no longer be able to use the object! &self creates a non-destructive read-only loan.