Rust · lesson 11 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Structs 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:
| Trait | Provides |
|---|---|
Debug | {:?} formatting |
Clone | .clone() method |
Copy | Copy semantics (requires Clone) |
PartialEq | == and != operators |
Eq | Total equality (for HashMap keys etc.) |
PartialOrd | <, >, <=, >= |
Ord | Total ordering (for sorting) |
Hash | Use as HashMap/HashSet key |
Default | Type::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
-
In the struct update example, delete the line
username: String::from("bob"),so..user1supplies the username too. Keep the finalprintln!that readsuser1.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..user1now moves theStringout ofuser1. The struct is partially moved:user1.emailanduser1.activewould still be readable, butuser1.usernameis not. -
Remove
CopyfromVec2’s derive list (keepClone) and addprintln!("{:?}", a);aftera + b. Read the error, then fix it without addingCopyback.Solution
error[E0382]: borrow of moved value: `a` ... 17 | println!("a + b: {:?}", a + b); | ----- `a` moved due to usage in operatorAdd::addtakesselfby value, so the left operand is moved. The compiler’s suggested fix isa.clone() + b. -
Implement
fmt::DisplayforVec2so thatprintln!("{}", 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 printsVec2 { x: 3.0, y: 4.0 }— the two traits are independent. -
Try
println!("{}", p1);on thePointfrom the derive example (it derivesDebug, notDisplay). 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 -
Add an associated function
Rectangle::from_area_ratio(area: f64, ratio: f64) -> Rectanglethat pickswidth = (area * ratio).sqrt()andheight = area / width. What doesRectangle::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 } }widthis√100 = 10,heightis50 / 10 = 5— printing{}x{}gives10x5, the same rectangleRectangle::new(10.0, 5.0)builds.