Chapter 9: Structs in Rust
Structs are a cornerstone of Rust’s type system, allowing you to create custom data types by grouping related data fields into a single, named entity. This concept is directly comparable to C’s struct. Like C structs, Rust structs aggregate fields where each field can have a different type, and instances typically have a fixed size known at compile time.
However, Rust enhances the concept significantly. Rust structs enforce memory safety through the ownership system and allow associated functions and methods to be defined, providing behavior encapsulation similar to classes in object-oriented languages like C++ or Java, but without inheritance.
In this chapter, we will cover:
- Defining struct types (including named-field, tuple, and unit structs) and creating instances
- Understanding struct fields and accessing/modifying them
- Basic operations like assignment and comparison (via traits)
- Destructuring structs and moving fields out
- Field initialization shorthand and the struct update syntax
- Using default values with the
Defaulttrait - Defining behavior with methods and associated functions (
implblocks) - Understanding the
self,&self, and&mut selfparameters - Implementing getters and setters for controlled access
- Ownership rules concerning structs and their fields
- Using references and lifetimes within structs
- Creating generic structs for type flexibility
- Deriving common traits like
Debug(for printing),Clone, andPartialEq(for comparison) - Struct memory layout considerations (
#[repr(C)]) - Visibility (
pub) and modules overview - Exercises for practice
9.1 Introduction to Structs and Comparison with C
In Rust, structs allow developers to define custom data types composed of several related values, called fields. While similar to C’s struct, Rust introduces important distinctions and variations.
The most common form is a struct with named fields:
Rust:
struct Person {
name: String,
age: u8,
}
C:
struct Person {
char* name; // Often a pointer, manual memory management needed
uint8_t age;
};
Key differences and enhancements in Rust include:
- Memory Safety: Rust’s ownership and borrowing rules guarantee memory safety at compile time, preventing issues like use-after-free or data races that can occur with C structs containing pointers. Fields like
Stringmanage their own memory. - Methods and Behavior: Rust structs can have associated functions and methods defined in separate
implblocks. This bundles data and behavior logically, unlike C where functions operating on structs are defined globally or rely on function pointers. - Struct Variants: While named-field structs are common, Rust also offers tuple structs (with unnamed fields accessed by index) and unit-like structs (with no fields at all). These variants serve specific purposes, discussed later.
- No Inheritance: Unlike classes in C++, Rust structs do not support implementation inheritance. Code reuse and polymorphism are achieved through traits and composition.
Rust structs combine the data aggregation capabilities of C structs with enhanced safety, associated behavior, and different structural variants, forming a powerful tool for building complex data structures.
9.2 Defining, Instantiating, and Accessing Structs
Defining and using structs in Rust involves declaring the structure type and then creating instances using struct literal syntax.
9.2.1 Struct Definitions
The general syntax for defining a named-field struct is:
struct StructName {
field1: Type1,
field2: Type2,
// additional fields...
} // Optional comma after the last field inside } is also allowed
Here, field1, field2, etc., are the fields of the struct, each defined with a name: Type. Field definitions listed within the curly braces {} are separated by commas (,).
A comma is permitted after the very last field definition before the closing brace }. This trailing comma is optional but idiomatic (common practice) in Rust for several reasons:
- Easier Version Control: When adding a new field at the end, you only need to add one line. Without the trailing comma, you’d have to modify two lines (add the new line and add a comma to the previously last line), making version control diffs slightly cleaner.
- Simplified Reordering: Reordering fields is easier as all lines consistently end with a comma.
- Code Generation: Can simplify code that automatically generates struct definitions.
- Consistency: Automatic formatters like
rustfmttypically enforce or prefer the trailing comma for consistency.
Concrete examples:
#![allow(unused)]
fn main() {
struct Point {
x: f64,
y: f64, // Trailing comma here is optional but idiomatic
}
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64, // Trailing comma here too
}
}
- Naming Convention: Struct names typically use
PascalCase, while field names usesnake_case. - Field Types: Fields can hold any valid Rust type, including primitives, strings, collections, or other structs.
- Scope: Struct definitions are usually placed at the module level but can be defined within functions if needed locally.
9.2.2 Instantiating Structs
To create an instance (instantiate) a struct, use the struct name followed by curly braces containing key: value pairs for each field. This syntax is called a struct literal. The order of fields in the literal doesn’t need to match the definition.
struct Point {
x: f64,
y: f64,
}
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
fn main() {
let active = true;
let x = 0.0;
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active, // active: active,
sign_in_count: 1,
};
let origin = Point { x, y: 0.0 };
}
All fields must be specified during instantiation unless default values or the struct update syntax are involved (covered later). If a local variable or parameter shares the same name as a struct field, you can use the shorthand by writing the name once instead of the full field_name: field_name form.
9.2.3 Accessing Fields
Access struct fields using dot notation (.), similar to C.
println!("User email: {}", user1.email); // Accesses the email field
println!("Origin x: {}", origin.x); // Accesses the x field
Field access is generally very efficient, comparable to C struct member access (see Section 9.11 on Memory Layout).
Note: In Rust, the dot syntax is always used for field access—even when working with references to structs. Unlike C, where pointers require the -> operator, Rust automatically dereferences when needed.
9.2.4 Mutability
Struct instances are immutable by default. To modify fields, the entire instance binding must be declared mutable using mut. Rust does not allow marking individual fields as mutable within an immutable struct instance.
struct Point { x: f64, y: f64 }
fn main() {
let mut p = Point { x: 1.0, y: 2.0 };
p.x = 1.5; // Allowed because `p` is mutable
println!("New x: {}", p.x);
let p2 = Point { x: 0.0, y: 0.0 };
// p2.x = 0.5; // Error! Cannot assign to field of immutable binding `p2`
}
If fine-grained mutability is needed, consider using multiple structs or exploring Rust’s interior mutability patterns (covered in a later chapter).
9.2.5 Field Access on Borrowed Structs
When working with references to struct instances—either as function parameters or local references—you can access fields according to standard borrowing rules:
- Immutable references allow reading field values.
- Mutable references allow both reading and modifying field values. However, you cannot move a field out of a borrowed struct. Moving would invalidate the original struct instance, which Rust disallows for borrowed data.
Here’s an example illustrating these rules:
struct T {
a: String,
}
fn main() {
let mut t = T { a: "X".to_owned() };
update(&mut t);
}
fn update(x: &mut T) {
// Read access to a field through a reference
println!("{}", &x.a[0..1]);
// Write access to a field through a mutable reference
x.a.replace_range(0..1, "Y");
println!("{}", &x.a[0..1]);
// Attempting to move the field out of the struct would fail:
// let moved = x.a;
// error[E0507]: cannot move out of `x.a` which is behind a mutable reference
}
9.2.6 Destructuring Structs with let Bindings
Pattern matching can be used with let to destructure a struct instance, binding its fields to new variables. This can also move fields out of the struct if the field type isn’t Copy.
#[derive(Debug)] // Added for printing the remaining struct
struct Person {
name: String, // Not Copy
age: u8, // Copy
}
fn main() {
let person = Person {
name: String::from("Alice"),
age: 30,
};
// Destructure `person`, binding fields to variables with the same names.
// `age` is copied, `name` is moved.
let Person { name, age } = person;
println!("Name: {}, Age: {}", name, age); // Name: Alice, Age: 30
// `person` cannot be used fully here because `name` was moved out.
// Accessing `person.age` would still be okay (as u8 is Copy),
// but accessing `person.name` or `person` as a whole is not.
// println!("Original person: {:?}", person); // Error: use of moved value: `person`
println!("Original age: {}", person.age); // This specific line compiles
// Renaming during destructuring
let person2 = Person { name: String::from("Bob"), age: 25 };
let Person { name: n, age: a } = person2;
println!("n = {}, a = {}", n, a); // n = Bob, a = 25
}
Destructuring provides a concise way to extract values, but be mindful of ownership: moving a field out makes the original struct partially (or fully, if all fields are moved) inaccessible.
9.2.7 Destructuring in Function Parameters
Structs can also be destructured directly in function parameters, providing immediate access to fields within the function body. Ownership rules apply similarly: if the struct itself is passed by value and fields are destructured, non-Copy fields are moved from the original struct passed by the caller.
struct Point {
x: i32,
y: i32,
}
// Destructure the Point directly in the function signature (takes ownership)
fn print_coordinates(Point { x, y }: Point) {
println!("Coordinates: ({}, {})", x, y);
}
// Destructure a reference to a Point (borrows)
fn print_coordinates_ref(&Point { x, y }: &Point) {
println!("Ref Coordinates: ({}, {})", x, y);
}
fn main() {
let p = Point { x: 10, y: 20 };
// `p` is moved into the function because Point is not Copy by default.
// If Point derived Copy, `p` would be copied instead.
print_coordinates(p);
let p2 = Point { x: 30, y: 40 };
// `p2` is borrowed immutably. Destructuring works on the reference.
print_coordinates_ref(&p2);
println!("p2.x after ref call: {}", p2.x); // p2 is still valid
}
Destructuring in parameters enhances clarity by avoiding repetitive point.x, point.y access.
9.3 Field Init Shorthand and Struct Update Syntax
Rust provides convenient syntax for initializing and updating structs.
9.3.1 Field Init Shorthand
If function parameters or local variables have the same names as struct fields, you can use a shorthand notation during instantiation.
struct User { active: bool, username: String, email: String, sign_in_count: u64 }
fn build_user(email: String, username: String) -> User {
User {
email, // Shorthand for email: email
username, // Shorthand for username: username
active: true,
sign_in_count: 1,
}
}
This reduces redundancy.
9.3.2 Struct Update Syntax
You can create a new struct instance using some explicitly specified fields and taking the rest from another instance using the .. syntax, which must appear last in the list of fields.
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
fn main() {
let user1 = User {
email: String::from("user1@example.com"),
username: String::from("userone"),
active: true,
sign_in_count: 1,
};
let user2 = User {
email: String::from("user2@example.com"),
// `username`, `active`, `sign_in_count` will be taken from user1
..user1
};
println!("User 2 username: {}", user2.username);
// Ownership consideration:
// `email` was specified anew for `user2`.
// Fields taken via `..user1` (`username`, `active`, `sign_in_count`) are
// moved if they are not `Copy`, or copied if they are `Copy`.
// Since `username` (String) is not Copy, it is moved from `user1`.
// `active` (bool) and `sign_in_count` (u64) are Copy, so they are copied.
// Therefore, `user1` is now partially moved.
// println!("User 1 email: {}", user1.email); // OK: email was not moved
// println!("User 1 active: {}", user1.active); // OK: active was copied
// println!("User 1 username: {}", user1.username); Error! user1.username was moved
}
The struct update syntax moves or copies the remaining fields based on whether they implement the Copy trait.
9.4 Default Values and the Default Trait
Often, it’s useful to create a struct instance with default values. Rust provides the Default trait for this.
9.4.1 Deriving Default
If all fields in a struct themselves implement Default, you can derive Default for your struct.
#[derive(Default, Debug)]
struct AppConfig {
server_address: String, // Default is ""
port: u16, // Default is 0
timeout_ms: u32, // Default is 0
}
fn main() {
let config: AppConfig = Default::default();
// Or let config = AppConfig::default();
println!("Default config: {:?}", config);
// Output: AppConfig { server_address: "", port: 0, timeout_ms: 0 }
// Combine with struct update syntax
let custom_config = AppConfig {
port: 8080,
..Default::default() // Use defaults for other fields
};
println!("Custom config: {:?}", custom_config);
// Output: AppConfig { server_address: "", port: 8080, timeout_ms: 0 }
}
9.4.2 Implementing Default Manually
If deriving isn’t suitable, implement Default manually.
struct ConnectionSettings {
retries: u8,
use_tls: bool,
}
impl Default for ConnectionSettings {
fn default() -> Self {
ConnectionSettings {
retries: 3, // Custom default
use_tls: true, // Custom default
}
}
}
fn main() {
let settings = ConnectionSettings::default();
println!("Default retries: {}", settings.retries); // 3
}
9.5 Tuple Structs and Unit-Like Structs
Besides named-field structs, Rust has two other variants.
9.5.1 Tuple Structs
Tuple structs have a name but unnamed fields, defined using parentheses (). Access fields using index notation (.0, .1, etc.).
struct Color(u8, u8, u8); // Represents RGB
struct Point2D(f64, f64); // Represents coordinates
fn main() {
let black = Color(0, 0, 0);
let origin = Point2D(0.0, 0.0);
println!("Red component: {}", black.0);
println!("Y-coordinate: {}", origin.1);
}
Tuple structs are useful when the field names are obvious from the context or when you want to give a tuple a distinct type name, improving type safety. Even if two tuple structs have the same field types, they are considered different types.
9.5.2 The Newtype Pattern
A common and powerful use case for tuple structs with a single field is the newtype pattern. This involves wrapping an existing type (like i32, f64, or even String) in a new struct to create a distinct type. This pattern provides two main benefits:
- Enhanced Type Safety: It prevents accidental mixing of values that have the same underlying representation but different semantic meanings.
- Implementing Traits: It allows you to implement traits (which define behaviors) specifically for your new type, even if the underlying type already has implementations or you’re not allowed to implement the trait for the base type directly (due to Rust’s orphan rule).
Example: Type Safety with Units
Consider representing distances. Using plain integers could lead to errors if units are mixed.
// Add derive for Debug, Copy, Clone, PartialEq for easier use in examples
#[derive(Debug, Copy, Clone, PartialEq)]
struct Millimeters(u32);
#[derive(Debug, Copy, Clone, PartialEq)]
struct Meters(u32);
fn main() {
let length_mm = Millimeters(5000);
let length_m = Meters(5);
// The compiler prevents mixing these types, even though both wrap a u32:
// print_length_mm(length_m); // Compile Error! Expected Millimeters, found Meters
print_length_mm(length_mm); // OK
}
fn print_length_mm(mm: Millimeters) {
// We access the inner value using tuple index syntax `.0`
println!("Length: {} mm", mm.0);
}
Even though both Millimeters and Meters internally hold a u32, the compiler treats them as distinct types, enforcing unit correctness at compile time.
Example: Implementing Behavior (Traits)
A key advantage is adding specific behaviors. Let’s allow Millimeters values to be added together or multiplied by a scalar factor by implementing the standard Add and Mul traits.
use std::ops::{Add, Mul}; // Import the traits
#[derive(Debug, Copy, Clone, PartialEq)] // Added Copy for Add example
struct Millimeters(u32);
// Implement the `Add` trait for Millimeters
impl Add for Millimeters {
type Output = Self; // Adding two Millimeters results in Millimeters
// self: Millimeters, other: Millimeters
fn add(self, other: Self) -> Self::Output {
// Add the inner u32 values and wrap the result in a new Millimeters
Millimeters(self.0 + other.0)
}
}
// Implement the `Mul` trait for multiplying Millimeters by a u32 scalar
impl Mul<u32> for Millimeters {
type Output = Self; // Multiplying Millimeters by u32 results in Millimeters
// self: Millimeters, factor: u32
fn mul(self, factor: u32) -> Self::Output {
// Multiply the inner u32 value and wrap the result
Millimeters(self.0 * factor)
}
}
fn main() {
let len1 = Millimeters(150);
let len2 = Millimeters(75);
// Use the implemented Add trait
let total_length = len1 + len2;
println!("{:?} + {:?} = {:?}", len1, len2, total_length);
// Output: Millimeters(150) + Millimeters(75) = Millimeters(225)
// Use the implemented Mul trait
let factor = 3;
let scaled_length = len1 * factor;
println!("{:?} * {} = {:?}", len1, factor, scaled_length);
// Output: Millimeters(150) * 3 = Millimeters(450)
// Note: We did not implement adding Millimeters to Meters,
// nor multiplying Millimeters by Millimeters. The type system
// still prevents operations we haven't explicitly defined.
// let m = Meters(1);
// let invalid = len1 + m; // Compile Error! Cannot add Meters to Millimeters
}
The newtype pattern, therefore, allows you to leverage Rust’s strong type system not just for passive checks but also to define precisely which operations are valid and meaningful for your custom types, enhancing both safety and code clarity. This is particularly useful for modeling domain-specific units, identifiers, or other constrained values.
9.5.3 Unit-Like Structs
Unit-like structs have no fields. They are defined simply with struct StructName;.
#[derive(Debug, PartialEq, Eq)] // Added derive for comparison
struct Marker; // A unit-like struct, often used as a marker
fn main() {
let m1 = Marker;
let m2 = Marker;
// These instances occupy no memory (zero-sized type)
println!("Markers are equal: {}", m1 == m2); // true
}
They are useful as markers or when implementing a trait that doesn’t require associated data.
9.6 Methods and Associated Functions (impl Blocks)
Behavior is added to structs using implementation blocks (impl).
struct Rectangle {
width: u32,
height: u32,
}
// Implementation block for Rectangle
impl Rectangle {
// Associated function (like static method/constructor)
fn square(size: u32) -> Self {
Self { width: size, height: size }
}
// Method (&self: immutable borrow)
fn area(&self) -> u32 {
self.width * self.height
}
// Method (&mut self: mutable borrow)
fn double_width(&mut self) {
self.width *= 2;
}
// Method (self: takes ownership)
fn describe(self) -> String {
format!("Rectangle {}x{}", self.width, self.height)
// `self` is consumed here.
}
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
let mut rect2 = Rectangle::square(25); // Call associated function
println!("Area of rect1: {}", rect1.area()); // Call method
rect2.double_width();
println!("New width of rect2: {}", rect2.width);
let description = rect1.describe(); // rect1 is moved and consumed
println!("Description: {}", description);
// println!("{}", rect1.width); // Error! `rect1` was moved by `describe`
}
9.6.1 Associated Functions vs. Methods
- Associated Functions: Do not take
self. Called viaStructName::function_name(). Used for constructors or type-related utilities. - Methods: Take
self,&self, or&mut selfas the first parameter. Called viainstance.method_name(). Operate on an instance.
9.6.2 The self Parameter Variations
&self: Borrows immutably (read-only access to fields).&mut self: Borrows mutably (read/write access to fields). Requires the instance binding to bemut.self: Takes ownership (moves the instance into the method). The instance cannot be used afterwards unless returned.
Rust’s method call syntax often handles borrowing/dereferencing automatically (instance.method()).
9.7 Getters and Setters
Methods can provide controlled access (getters) or validated modification (setters) for fields, especially private ones.
pub struct Circle { // Assume this is in a library module
radius: f64, // Private field
}
impl Circle {
// Public constructor (associated function)
pub fn new(radius: f64) -> Option<Self> {
if radius >= 0.0 { Some(Circle { radius }) } else { None }
}
// Public getter
pub fn radius(&self) -> f64 { self.radius }
// Public setter with validation
pub fn set_radius(&mut self, new_radius: f64) -> Result<(), &'static str> {
if new_radius >= 0.0 {
self.radius = new_radius; Ok(())
} else {
Err("Radius cannot be negative")
}
}
// Calculated property (getter-like)
pub fn diameter(&self) -> f64 { self.radius * 2.0 }
}
fn main() {
let mut c = Circle::new(10.0).expect("Creation failed");
println!("Radius: {}", c.radius()); // Use getter
println!("Diameter: {}", c.diameter());
if let Err(e) = c.set_radius(-5.0) { // Use setter
println!("Error setting radius: {}", e);
}
let _ = c.set_radius(15.0);
println!("New radius: {}", c.radius());
}
While direct public field access is common within the same module for simple cases, getters/setters are crucial for enforcing invariants and defining stable public APIs across modules.
9.8 Structs and Ownership
Ownership rules apply consistently to structs and their fields.
9.8.1 Owned Fields
Structs typically own their fields. When the struct goes out of scope, it drops its owned fields, freeing resources (like the memory held by a String).
struct DataContainer {
id: u32, // Copy type
data: String, // Owned, non-Copy type
}
fn main() {
{
let container = DataContainer {
id: 1,
data: String::from("Owned data"),
};
println!("Container created with id: {}", container.id);
} // `container` goes out of scope. `container.data` (the String) is dropped.
println!("Container dropped.");
}
Assignment of structs follows ownership rules: if the struct type implements Copy, assignment copies the bits. If not, assignment moves ownership.
9.8.2 Fields Containing References (Borrowing)
Structs can hold references, borrowing data owned elsewhere. Lifetime annotations ('a) are required to ensure references don’t outlive the data they point to.
// `'a` ensures references inside PersonView live at least as long as PersonView.
struct PersonView<'a> {
name: &'a str, // Borrows a string slice
age: &'a u8, // Borrows a reference to a u8
}
fn main() {
let name_data = String::from("Alice");
let age_data: u8 = 30;
let person_view: PersonView;
{ // Inner scope
person_view = PersonView {
name: &name_data, // Borrow name_data
age: &age_data, // Borrow age_data
};
// Valid because name_data and age_data outlive person_view within this scope
println!("View: Name = {}, Age = {}", person_view.name, *person_view.age);
} // `person_view` goes out of scope here. Borrows end.
println!("Original name: {}, Original age: {}", name_data, age_data);
}
Lifetimes prevent dangling pointers, a major safety feature compared to manual pointer management in C.
9.9 Generic Structs
Structs can be generic, allowing them to work with different concrete types.
// Generic struct `Point<T>`
struct Point<T> {
x: T,
y: T,
}
// Generic struct with multiple type parameters
struct Pair<T, U> {
first: T,
second: U,
}
fn main() {
// Instantiate with inferred types
let integer_point = Point { x: 5, y: 10 }; // Point<i32>
let float_point = Point { x: 1.0, y: 4.0 }; // Point<f64>
let pair = Pair { first: "hello", second: 123 }; // Pair<&str, i32>
println!("Int Point: x={}, y={}", integer_point.x, integer_point.y);
println!("Float Point: x={}, y={}", float_point.x, float_point.y);
println!("Pair: first={}, second={}", pair.first, pair.second);
}
9.9.1 Methods on Generic Structs
Methods can be defined on generic structs using impl<T>.
struct Point<T> { x: T, y: T }
impl<T> Point<T> {
// This method works for any T
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("x coordinate: {}", p.x());
}
9.9.2 Constraining Generic Types with Trait Bounds
Trait bounds restrict generic types to those implementing specific traits, enabling methods that require certain capabilities.
use std::fmt::Display; // For printing
use std::ops::Add; // For addition
struct Container<T> {
value: T,
}
impl<T> Container<T> {
fn new(value: T) -> Self { Container { value } }
}
// Method only available if T implements Display
impl<T: Display> Container<T> {
fn print(&self) {
println!("Container holds: {}", self.value);
}
}
// Method only available if T implements Add<Output=T> + Copy
// (Requires T can be added to itself and is copyable)
impl<T: Add<Output = T> + Copy> Container<T> {
fn add_to_self(&self) -> T {
self.value + self.value // Requires Add and Copy
}
}
fn main() {
let c_int = Container::new(10);
c_int.print(); // Works (i32 implements Display)
println!("Doubled: {}", c_int.add_to_self()); // Works (i32 implements Add + Copy)
let c_str = Container::new("hello");
c_str.print(); // Works (&str implements Display)
// println!("Doubled: {}", c_str.add_to_self()); Error! &str doesn't impl Add, Copy
}
Trait bounds are central to Rust’s polymorphism and type safety with generics.
9.10 Derived Traits and Common Operations
Traits define shared behavior. Rust’s #[derive] attribute automatically implements common traits, enabling standard operations on structs.
Commonly derived traits include:
Debug: Enables printing structs using{:?}(debug format). Essential for debugging. For user-facing output, implement theDisplaytrait manually.Clone: Enables creating deep copies via.clone(). Requires all fields to beClone.Copy: Enables implicit bitwise copying on assignment, function calls, etc. Structs can only beCopyif all their fields are alsoCopy. Assignment (let y = x;) movesxif the type is notCopy, but copiesxif it isCopy.PartialEq,Eq: Enable comparison (==,!=). Requires all fields to implement the respective trait(s).PartialOrd,Ord: Enable ordering (<,>, etc.). Requires all fields to implement the respective trait(s).Default: Enables creation of default instances. (Covered earlier).Hash: Allows use in hash maps/sets. Requires all fields to beHash.
Example Enabling Operations:
// Deriving traits enables common operations
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct SimplePoint {
x: i32,
y: i32,
}
fn main() {
let p1 = SimplePoint { x: 1, y: 2 };
let p2 = p1; // **Assignment**: Copies p1 because SimplePoint is Copy
let p3 = p1.clone(); // **Cloning**: Explicitly creates a copy
println!("Debug print: {:?}", p1); // **Printing (Debug)**
println!("Comparison: p1 == p2 is {}", p1 == p2); // **Comparison** (PartialEq)
println!("Ordering: p1 < p3 is {}", p1 < p3); // **Ordering** (PartialOrd)
use std::collections::HashSet;
let mut points = HashSet::new();
points.insert(p1); // **Hashing** (Hash + Eq required for HashSet)
println!("Set contains p1: {}", points.contains(&p1));
}
Deriving traits is idiomatic for providing standard behaviors concisely. Manually implementing traits offers customization when needed.
9.11 Memory Layout and Performance
For C programmers, understanding struct memory layout is important.
- Field Reordering: By default, Rust does not guarantee the order of fields in memory. The compiler is free to reorder fields to optimize for padding or alignment, potentially making the struct smaller or field access faster. This differs from C where field order is guaranteed.
#[repr(C)]: To guarantee C-compatible field ordering and padding/alignment behavior, you can apply the#[repr(C)]attribute to the struct definition:
This is essential when interoperating with C code (FFI) or requiring a specific layout for reasons like serialization or memory mapping.#![allow(unused)] fn main() { #[repr(C)] struct CCompatiblePoint { x: f64, y: f64, } }- Alignment and Padding: Rust follows platform-specific alignment rules, similar to C compilers. Padding bytes may be inserted between fields or at the end of the struct to ensure fields are properly aligned, which can impact the total size of the struct.
- Access Performance: Accessing a struct field using dot notation (
instance.field) typically requires adding a constant offset (determined at compile time) to the memory address of the struct instance, just like in C, making it very fast.
Unless C interoperability or a specific layout is required, it’s usually best to let the Rust compiler optimize the layout by omitting #[repr(C)].
9.12 Visibility and Modules
By default, structs and their fields are private to the module they are defined in. Use pub to expose them.
// In module `geometry`
pub struct Shape { // Public struct
pub name: String, // Public field
sides: u32, // Private field (default)
}
struct InternalData { // Private struct (default)
pub value: i32, // allowed, but pub has no effect
config: u8,
}
impl Shape {
pub fn new(name: String, sides: u32) -> Self { // Public constructor
Shape { name, sides }
}
// ... methods ...
}
Key visibility rules:
pub struct: Makes the struct type usable outside its defining module.pub field: Makes a field accessible outside the module if the struct itself is accessible.- Private fields/methods: Cannot be accessed directly from outside the module, even if the struct type is public. Access is typically provided via public methods (like getters/setters).
pubfield in a private struct: A field markedpubinside a struct that is notpubhas no effect.
This system enforces encapsulation, allowing modules to control their public API.
9.13 Summary
This chapter covered Rust structs, highlighting their similarities and differences compared to C structs. We explored data organization, behavior association, memory safety, and performance aspects.
Key takeaways include:
- Structs group named fields; variants include tuple and unit structs.
- Instances are created using struct literals; access fields via dot notation.
- Operations like assignment and comparison are typically enabled by derived traits (
Copy,PartialEq). Printing usesDebugorDisplay. - Destructuring extracts fields, potentially moving non-
Copydata out. - Ownership dictates how structs and their fields are managed (drop, move, copy). Lifetimes ensure safety for borrowed fields.
- Methods (
implblocks) associate behavior (&self,&mut self,self). - Generics create reusable struct definitions; trait bounds constrain them.
- Memory layout is optimized by default;
#[repr(C)]ensures C compatibility. - Visibility (
pub) controls encapsulation at the module level.
Structs are foundational in Rust for creating custom, safe, and efficient data types.
9.14 Exercises
Practice applying the concepts learned in this chapter.
Click to see the list of suggested exercises
Exercise 1: Basic Struct and Methods
Define a Circle struct with a radius field (type f64). Implement the following in an impl block:
- An associated function
new(radius: f64) -> Circleto create a circle. - A method
area(&self) -> f64to calculate the area (π * r^2). Usestd::f64::consts::PI. - A method
grow(&mut self, factor: f64)that increases the radius byfactor.
Instantiate a circle, calculate its area, grow it, and calculate the new area.
use std::f64::consts::PI;
struct Circle {
radius: f64,
}
impl Circle {
// Associated function (constructor)
fn new(radius: f64) -> Self {
Circle { radius }
}
// Method to calculate area
fn area(&self) -> f64 {
PI * self.radius * self.radius
}
// Method to grow the circle
fn grow(&mut self, factor: f64) {
self.radius += factor;
}
}
fn main() {
let mut c = Circle::new(5.0);
println!("Initial Area: {}", c.area());
c.grow(2.0);
println!("Radius after growing: {}", c.radius);
println!("New Area: {}", c.area());
}
Exercise 2: Tuple Struct and Newtype Pattern
Create a tuple struct Kilograms(f64) to represent weight. Implement the Add trait from std::ops::Add for it, so you can add two Kilograms values together. Demonstrate its usage.
use std::ops::Add;
#[derive(Debug)] // Add Debug for printing
struct Kilograms(f64);
// Implement the Add trait for Kilograms
impl Add for Kilograms {
type Output = Self; // Result of adding two Kilograms is Kilograms
fn add(self, other: Self) -> Self {
Kilograms(self.0 + other.0) // Access inner f64 using .0
}
}
fn main() {
let weight1 = Kilograms(10.5);
let weight2 = Kilograms(5.2);
let total_weight = weight1 + weight2; // Uses the implemented Add trait
println!("Total weight: {:?}", total_weight); // e.g., Total weight: Kilograms(15.7)
println!("Value: {}", total_weight.0); // Access the inner value
}
Exercise 3: Struct with References and Lifetimes
Define a struct DataView<'a> that holds an immutable reference (&'a [u8]) to a slice of bytes. Implement a method len(&self) -> usize that returns the length of the slice. Demonstrate creating an instance and calling the method.
struct DataView<'a> {
data: &'a [u8],
}
impl<'a> DataView<'a> {
fn len(&self) -> usize {
self.data.len()
}
}
fn main() {
let my_data: Vec<u8> = vec![10, 20, 30, 40, 50];
// Create a view of part of the data (elements at index 1, 2, 3)
let data_view = DataView { data: &my_data[1..4] };
println!("Data slice: {:?}", data_view.data); // e.g., Data slice: [20, 30, 40]
println!("Length of view: {}", data_view.len()); // e.g., Length of view: 3
}
Exercise 4: Generic Struct with Trait Bounds
Create a generic struct MinMax<T> that holds two values of type T. Implement a method get_min(&self) -> &T that returns a reference to the smaller of the two values. This method should only be available if T implements the PartialOrd trait. Demonstrate its usage with numbers and potentially strings.
use std::cmp::PartialOrd;
struct MinMax<T> {
val1: T,
val2: T,
}
impl<T: PartialOrd> MinMax<T> {
// This method only exists if T can be partially ordered
fn get_min(&self) -> &T {
if self.val1 <= self.val2 {
&self.val1
} else {
&self.val2
}
}
}
// We can still have methods that don't require PartialOrd
impl<T> MinMax<T> {
fn new(v1: T, v2: T) -> Self {
MinMax { val1: v1, val2: v2 }
}
}
fn main() {
let numbers = MinMax::new(15, 8);
println!("Min number: {}", numbers.get_min()); // 8
let strings = MinMax::new("zebra", "ant");
println!("Min string: {}", strings.get_min()); // "ant"
// struct Unorderable; // A struct that doesn't implement PartialOrd
// let custom = MinMax::new(Unorderable, Unorderable);
// // custom.get_min(); // Error! Unorderable does not implement PartialOrd
}
Exercise 5: Destructuring, Update Syntax, and Printing
Define a Config struct with fields host: String, port: u16, use_https: bool.
- Derive
DebugandDefault. - Create a default
Configinstance and print it using debug format. - Create a new
Configinstance, overriding only thehostfield using struct update syntax and the default instance. Print this instance too. - Write a function
print_host_only(&Config { ref host, .. }: &Config)that uses destructuring to print only the host. Call this function.
#[derive(Default, Debug)] // Derive Default and Debug
struct Config {
host: String,
port: u16,
use_https: bool,
}
// Function using destructuring in parameter
fn print_host_only(&Config { ref host, .. }: &Config) { // Use 'ref' to borrow String
println!("Host from function: {}", host);
}
fn main() {
// 1. Create and print default config
let default_config = Config::default();
println!("Default config: {:?}", default_config);
// 2. Create and print custom config using struct update syntax
let custom_config = Config {
host: String::from("api.example.com"),
..default_config // Use default values for port and use_https
};
println!("Custom config: {:?}", custom_config);
// 3. Call function that destructures the parameter
print_host_only(&custom_config); // Pass a reference
}