In the previous tutorial, we learned borrowing and references. Now we learn how to create custom types with structs and attach behavior to them with methods.
If you have used classes in Kotlin, Java, or Python, structs will feel familiar. Rust does not have classes, but structs with impl blocks give you the same power — without inheritance.
Defining a Struct
A struct groups related data together:
struct User {
name: String,
email: String,
age: u32,
active: bool,
}
Each field has a name and a type. This is similar to a data class in Kotlin or a class in Python.
Creating Instances
You create a struct instance by providing values for every field:
fn main() {
let user = User {
name: String::from("Alex"),
email: String::from("alex@example.com"),
age: 28,
active: true,
};
println!("Name: {}", user.name);
println!("Age: {}", user.age);
}
Every field must have a value. Rust has no null or undefined.
Mutable Instances
To modify fields, the entire instance must be mutable. You cannot make individual fields mutable:
fn main() {
let mut user = User {
name: String::from("Alex"),
email: String::from("alex@example.com"),
age: 28,
active: true,
};
user.age = 29; // OK — entire struct is mut
println!("Updated age: {}", user.age);
}
Field Init Shorthand
When variable names match field names, you can use the shorthand:
fn create_user(name: String, email: String) -> User {
User {
name, // Same as name: name
email, // Same as email: email
age: 0,
active: true,
}
}
Struct Update Syntax
Create a new struct from an existing one, changing only some fields:
fn main() {
let user1 = User {
name: String::from("Alex"),
email: String::from("alex@example.com"),
age: 28,
active: true,
};
let user2 = User {
email: String::from("sam@example.com"),
name: String::from("Sam"),
..user1 // Copy remaining fields from user1
};
println!("User2 age: {}", user2.age); // 28 (from user1)
}
Note: If the update uses fields that are String (or any non-Copy type), those fields move from the original. After the update, those fields in user1 are no longer valid. In the example above, user1.age and user1.active still work because they are Copy types.
Impl Blocks — Adding Methods
Methods are functions attached to a struct. You define them in an impl block:
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
}
fn main() {
let rect = Rectangle { width: 10.0, height: 5.0 };
println!("Area: {}", rect.area());
println!("Perimeter: {}", rect.perimeter());
}
Method Parameters: &self, &mut self, self
The first parameter of a method determines how it accesses the struct:
| Parameter | Meaning | Use When |
|---|---|---|
&self | Immutable borrow | Reading data |
&mut self | Mutable borrow | Modifying data |
self | Takes ownership | Consuming/transforming the struct |
impl Rectangle {
// Read only
fn area(&self) -> f64 {
self.width * self.height
}
// Modify the struct
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
// Consume the struct and return something new
fn into_square(self) -> Rectangle {
let side = self.width.max(self.height);
Rectangle { width: side, height: side }
}
}
Most methods use &self. Use &mut self when you need to modify data. Use self (taking ownership) only when you want to transform the struct into something else.
Associated Functions (Constructors)
Functions in an impl block that do NOT take self are called associated functions. You call them with :: instead of .:
impl Rectangle {
fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height }
}
fn square(size: f64) -> Rectangle {
Rectangle { width: size, height: size }
}
}
fn main() {
let rect = Rectangle::new(10.0, 5.0);
let sq = Rectangle::square(7.0);
println!("Rectangle area: {}", rect.area());
println!("Square area: {}", sq.area());
}
Rectangle::new() is the Rust convention for constructors. There is no special new keyword — it is just a naming convention.
Tuple Structs
Tuple structs have fields but no field names. They are useful for simple wrappers:
struct Color(u8, u8, u8);
struct Meters(f64);
fn main() {
let red = Color(255, 0, 0);
let distance = Meters(42.5);
println!("Red value: {}", red.0);
println!("Distance: {} meters", distance.0);
}
Even though Color and Meters both wrap numbers, they are different types. You cannot mix them by accident. This is called the newtype pattern.
Derive Macros
Rust can automatically implement common traits with #[derive]:
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let p1 = Point { x: 1.0, y: 2.0 };
// Debug — print with {:?}
println!("Debug: {:?}", p1); // Point { x: 1.0, y: 2.0 }
// Clone — create an independent copy
let p2 = p1.clone();
// PartialEq — compare with ==
println!("Equal: {}", p1 == p2); // true
}
Common derive macros:
| Macro | What It Does |
|---|---|
Debug | Enables {:?} printing |
Clone | Enables .clone() |
PartialEq | Enables == and != |
Eq | Full equality (requires PartialEq) |
Hash | Enables use in HashMap keys |
Default | Provides Type::default() |
Copy | Enables implicit copy (only for stack-only types) |
Implementing Display
Debug gives you {:?} output. For nicer {} output, implement Display:
use std::fmt;
struct Temperature {
celsius: f64,
}
impl fmt::Display for Temperature {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.1}°C", self.celsius)
}
}
fn main() {
let temp = Temperature { celsius: 23.5 };
println!("Temperature: {}", temp); // Temperature: 23.5°C
}
Display is for user-facing output. Debug is for developer-facing output. You implement Display manually because Rust cannot guess how you want your type to look.
Multiple Impl Blocks
You can split methods across multiple impl blocks. This is useful for organizing code:
struct Player {
name: String,
score: u32,
level: u32,
}
impl Player {
fn new(name: String) -> Player {
Player { name, score: 0, level: 1 }
}
}
impl Player {
fn add_score(&mut self, points: u32) {
self.score += points;
if self.score >= self.level * 100 {
self.level += 1;
}
}
fn summary(&self) -> String {
format!("{} — Level {} ({} points)", self.name, self.level, self.score)
}
}
Structs That Own vs Borrow
Structs can own their data or borrow it. For now, use owned types like String instead of &str:
// Good for beginners — struct owns its data
struct Article {
title: String,
body: String,
}
// Advanced — struct borrows data (requires lifetimes)
// struct ArticleRef<'a> {
// title: &'a str,
// body: &'a str,
// }
Structs with references need lifetime annotations. We will cover this in a future tutorial.
A Complete Example
Let’s put it all together with a BankAccount struct:
use std::fmt;
#[derive(Debug, Clone)]
struct BankAccount {
owner: String,
balance: f64,
}
impl BankAccount {
fn new(owner: String, initial_balance: f64) -> BankAccount {
BankAccount {
owner,
balance: initial_balance,
}
}
fn deposit(&mut self, amount: f64) {
if amount > 0.0 {
self.balance += amount;
}
}
fn withdraw(&mut self, amount: f64) -> bool {
if amount > 0.0 && amount <= self.balance {
self.balance -= amount;
true
} else {
false
}
}
fn balance(&self) -> f64 {
self.balance
}
}
impl fmt::Display for BankAccount {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Account({}, ${:.2})", self.owner, self.balance)
}
}
fn main() {
let mut account = BankAccount::new(String::from("Alex"), 1000.0);
println!("{}", account); // Account(Alex, $1000.00)
account.deposit(500.0);
println!("After deposit: {}", account);
let success = account.withdraw(200.0);
println!("Withdraw success: {}", success);
println!("Final: {}", account);
}
Summary
| Concept | Syntax | Purpose |
|---|---|---|
| Struct | struct Name { field: Type } | Group related data |
| Instance | Name { field: value } | Create a value |
| Method | fn name(&self) in impl | Behavior on a type |
| Associated fn | fn name() in impl | Constructors, utilities |
| Tuple struct | struct Name(Type) | Simple wrappers |
| Display | impl fmt::Display | Pretty printing |
| Derive | #[derive(Debug, Clone)] | Auto-implement traits |
Source Code
Related Tutorials
- Rust Tutorial #5: Borrowing and References — how &self and &mut self work
- Rust Tutorial #4: Ownership — why structs own their data
- Rust Tutorial #7: Enums and Pattern Matching — coming next
What’s Next?
Structs let you model data with named fields. But sometimes a value can be one of several different things. That is what enums are for. In the next tutorial, we learn enums and pattern matching — one of Rust’s most powerful features.