Chapter 5: Structs in Rust
Structs (structures) and enums are the primary building blocks for creating custom types in Rust. While tuples allow you to group multiple values of different types together, structs offer greater flexibility by naming both the type itself and its individual fields. This chapter explores how to define named-field structs, tuple structs, and unit-like structs, as well as how to attach behavior to structs using methods and associated functions inside implementation blocks.
1. Defining and Instantiating Named-Field Structs
A struct allows you to group related data into a unified custom data type. Structs are similar to object attributes in object-oriented programming languages.
To define a struct, use the struct keyword followed by the struct name in PascalCase. Inside curly brackets, declare the names and types of the fields:
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
Key Type Considerations
In this definition, username and email use the owned String type rather than string slices (&str). This choice ensures that an instance of the struct owns all of its data for as long as the struct remains valid.
Note: Struct fields can store references (
&str), but doing so requires using lifetimes to ensure that the referenced data remains valid for as long as the struct exists. Lifetimes are covered in Chapter 10.
Instantiating a Struct
To create an instance, supply the struct name followed by key-value pairs inside curly brackets. Field assignment does not need to follow the exact order of the struct declaration:
fn main() {
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
}
Fields are accessed using dot notation:
let name = user1.username;
2. Mutability Rules for Struct Instances
In Rust, mutability is an attribute of the variable instance, not individual fields. You cannot mark specific fields as mutable while leaving others immutable.
To modify any field within a struct instance, the entire instance must be declared with let mut:
fn main() {
let mut user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
// Valid because user1 is declared as mutable
user1.username = String::from("anothername567");
}
3. Builder Functions and Field Init Shorthand
When constructing struct instances inside helper functions, parameter names frequently match field names. Rather than repeating field names explicitly, Rust provides the field init shorthand syntax.
Naive vs. Shorthand Implementation
// Verbose construction
fn build_user_verbose(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}
// Idiomatic field init shorthand
fn build_user(email: String, username: String) -> User {
User {
email, // Automatically binds parameter 'email' to field 'email'
username, // Automatically binds parameter 'username' to field 'username'
active: true,
sign_in_count: 1,
}
}
4. Struct Update Syntax
When you need to create a new instance of a struct that shares most of its values with an existing instance, use the struct update syntax (..).
fn main() {
let user1 = User {
email: String::from("user1@example.com"),
username: String::from("user1"),
active: true,
sign_in_count: 1,
};
// Creating user2 manually without update syntax
let user2 = User {
email: String::from("user2@example.com"),
username: String::from("user2"),
active: user1.active,
sign_in_count: user1.sign_in_count,
};
// Idiomatic approach using struct update syntax
let user3 = User {
email: String::from("user3@example.com"),
username: String::from("user3"),
..user2 // Sets active and sign_in_count from user2
};
}
The syntax ..user2 explicitly specifies that any fields not explicitly set should take the values from the corresponding fields of user2.
5. Alternative Struct Forms: Tuple Structs and Unit-Like Structs
Rust supports three main struct forms depending on how data should be named and accessed:
Tuple Structs and Type Safety
Tuple structs allow you to create distinct types out of raw tuples. Even if two tuple structs share identical element types, they are distinct types and cannot be used interchangeably:
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
fn main() {
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
// Accessing elements via index
let red_value = black.0;
let x_coord = origin.0;
}
Type Safety Rule: A function expecting a
Pointparameter will reject aColorargument during compilation, despite both wrapping(i32, i32, i32).
6. Real-World Refactoring: Expressing Domain Models
To understand the practical value of structs, consider a program designed to calculate the area of a rectangle. We can trace its evolution through three stages of code design.
Architectural Evolution
Stage 1: Naive (Independent Variables)
fn main() {
let width1 = 30;
let height1 = 50;
println!("Area: {} sq pixels", area(width1, height1));
}
fn area(width: u32, height: u32) -> u32 {
width * height
}
Problem: width1 and height1 are logically tied together, but nothing in the program signature enforces or communicates this relationship.
Stage 2: Intermediate (Tuples)
fn main() {
let rect1 = (30, 50);
println!("Area: {} sq pixels", area(rect1));
}
fn area(dimensions: (u32, u32)) -> u32 {
dimensions.0 * dimensions.1
}
Problem: dimensions.0 and dimensions.1 lack semantic clarity. It is unclear whether index 0 represents width or height, introducing potential operational bugs.
Stage 3: Idiomatic (Named Struct)
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
// Pass a reference to borrow ownership instead of moving rect1
println!("Area: {} sq pixels", area(&rect1));
}
fn area(rectangle: &Rectangle) -> u32 {
rectangle.width * rectangle.height
}
Advantage: Passing &Rectangle conveys explicit domain meaning and preserves ownership of rect1 in main.
7. Printing and Debugging Structs with Derived Traits
By default, standard print macros (println!({})) use the std::fmt::Display trait. Basic primitive types implement Display automatically, but custom structs do not because Rust does not assume a default output format.
Attempting to print a struct using {} results in a compiler error:
error[E0277]: Rectangledoesn't implementstd::fmt::Display``
Enabling Debug Output
To print developer-facing debug information, opt into formatting via the std::fmt::Debug trait:
- Add the attribute
#[derive(Debug)]above the struct declaration. - Use debug formatting placeholders inside string literals:
{:?}for inline debug print.{:#?}for multiline pretty-printed output.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
// Inline printing
println!("rect1 is {:?}", rect1);
// Pretty-printed output on multiple lines
println!("rect1 formatted:\n{:#?}", rect1);
}
8. Encapsulating Behavior: Methods and Implementation Blocks
While standalone functions can take structs as parameters, encapsulating operations directly on the struct type improves organization and readability.
Defining Methods
Methods are defined within an implementation block (impl). Unlike standalone functions, the first parameter of a method is always self, which represents the instance on which the method is invoked.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
// &self borrows the Rectangle instance immutably
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
// Method invocation using dot notation
println!("Area: {} sq pixels", rect1.area());
}
Self Ownership Variations
&self: Borrows the instance immutably (most common).&mut self: Borrows the instance mutably to alter state.self: Consumes ownership of the instance (used when transforming or moving the data).
Automatic Referencing and Dereferencing
In C or C++, calling methods on object pointers requires different syntax than calling them directly on object values:
Rust performs automatic referencing and dereferencing. When you invoke a method using rect.area(), Rust automatically inserts &, &mut, or * so that the caller matches the method signature:
// Rust expands this:
rect.area();
// Into this under the hood:
(&rect).area();
9. Methods with Multiple Parameters
Methods can take multiple parameters alongside self. For example, a method checking if one rectangle can entirely contain another:
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
let rect2 = Rectangle { width: 10, height: 40 };
let rect3 = Rectangle { width: 60, height: 45 };
println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2)); // true
println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3)); // false
}
10. Associated Functions and Multiple impl Blocks
Functions defined inside an impl block that do not take self as a parameter are called associated functions. They are tied to the type itself rather than a specific instance (similar to static methods in other languages).
Constructor Example
Associated functions are often used as constructors that return a new instance of the struct:
impl Rectangle {
// Associated function (no 'self' parameter)
fn square(size: u32) -> Rectangle {
Rectangle {
width: size,
height: size,
}
}
}
Multiple impl Blocks
Rust allows a single struct to have multiple impl blocks. While combining methods into one block is common, separating them into distinct blocks can be useful when dealing with generic types or traits (covered in Chapter 10).
// First implementation block: instance methods
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
// Second implementation block: associated constructor functions
impl Rectangle {
fn square(size: u32) -> Rectangle {
Rectangle {
width: size,
height: size,
}
}
}
fn main() {
// Invoking an associated function using namespace syntax (::)
let square_rect = Rectangle::square(20);
println!("Square Area: {}", square_rect.area());
}
Summary and Key Takeaways
Functions vs. Methods vs. Associated Functions
Key Concepts
- Data Grouping: Structs create custom types with named fields, providing clearer semantic intent than tuples.
- Mutability Granularity: Mutability applies to the entire struct instance variable (
let mut). Individual fields cannot be selectively marked mutable. - Field Init Shorthand: Simplifies constructor functions when local variable names match struct field names.
- Struct Update Syntax: Uses
..instancesyntax to copy or move unspecified field values from an existing struct instance. - Tuple Structs: Provide type safety wrapper semantics around unnamed tuple elements.
- Debug Inspection: Applying
#[derive(Debug)]enables debug formatting via{:?}(inline) or{:#?}(pretty-print). - Encapsulation: Implementation (
impl) blocks house methods (which borrow or takeself) and associated functions (which operate on the struct namespace using::). - Automatic Referencing: Rust automatically handles referencing (
&) and dereferencing (*) when invoking methods, eliminating pointer-specific syntax like->.