Series: Learn Rust from Scratch | Post 5 of 11
Introduction
So far our programs have worked with simple, individual values. Real-world programs need to group related data together. In Rust, structs (short for structures) are how you create custom data types.
In this post we'll cover:
- Defining and instantiating structs
- Tuple structs
- Adding behavior with
implblocks and methods - Associated functions (like constructors)
- Printing structs with
#[derive(Debug)]
Defining a Struct
A struct lets you name and group related fields:
// Define the struct
struct User {
username: String,
email: String,
age: u32,
is_active: bool,
}
fn main() {
// Create an instance of the struct
let user1 = User {
username: String::from("neo_coder"),
email: String::from("[email protected]"),
age: 25,
is_active: true,
};
// Access fields with dot notation
println!("Username: {}", user1.username);
println!("Email: {}", user1.email);
println!("Age: {}", user1.age);
println!("Active: {}", user1.is_active);
}
Output:
Username: neo_coder
Email: [email protected]
Age: 25
Active: true
Mutable Structs
To modify fields, the entire struct instance must be mut:
struct Counter {
count: u32,
label: String,
}
fn main() {
let mut counter = Counter {
count: 0,
label: String::from("clicks"),
};
counter.count += 1;
counter.count += 1;
counter.count += 1;
println!("{}: {}", counter.label, counter.count);
}
Output:
clicks: 3
Note: In Rust, the entire struct is either mutable or immutable — you can't mark individual fields as mutable.
Field Init Shorthand
When a variable name matches a field name, you can use shorthand:
struct Point {
x: f64,
y: f64,
}
fn create_point(x: f64, y: f64) -> Point {
// Instead of: Point { x: x, y: y }
Point { x, y } // Shorthand — when variable name matches field name
}
fn main() {
let p = create_point(3.0, 4.5);
println!("Point: ({}, {})", p.x, p.y);
}
Struct Update Syntax
Create a new instance from an existing one, changing only some fields:
struct User {
username: String,
email: String,
age: u32,
is_active: bool,
}
fn main() {
let user1 = User {
username: String::from("neo_coder"),
email: String::from("[email protected]"),
age: 25,
is_active: true,
};
// Create user2 based on user1 — only change email and username
let user2 = User {
username: String::from("alice"),
email: String::from("[email protected]"),
..user1 // Fill remaining fields from user1
};
println!("user2: {} | age: {} | active: {}", user2.username, user2.age, user2.is_active);
}
Output:
user2: alice | age: 25 | active: true
Note:
..user1must come last. Also, fields that are moved (likeString) won't be usable inuser1after this.
Tuple Structs
Tuple structs are structs without named fields — just types. Useful for creating distinct types from the same underlying data:
struct Color(u8, u8, u8); // RGB color
struct Point3D(f64, f64, f64); // 3D coordinates
fn main() {
let red = Color(255, 0, 0);
let green = Color(0, 255, 0);
let blue = Color(0, 0, 255);
let origin = Point3D(0.0, 0.0, 0.0);
// Access by index
println!("Red: ({}, {}, {})", red.0, red.1, red.2);
println!("Green: ({}, {}, {})", green.0, green.1, green.2);
println!("Origin: ({}, {}, {})", origin.0, origin.1, origin.2);
// Even though both use three numbers, Color and Point3D are DIFFERENT types
// You can't accidentally pass a Color where a Point3D is expected
}
Output:
Red: (255, 0, 0)
Green: (0, 255, 0)
Origin: (0, 0, 0)
Printing Structs with #[derive(Debug)]
By default, you can't println! a struct. Add #[derive(Debug)] to enable it:
#[derive(Debug)] // This derive attribute adds debug printing capability
struct Rectangle {
width: f64,
height: f64,
}
fn main() {
let rect = Rectangle { width: 10.0, height: 5.0 };
// {:?} — compact debug format
println!("rect = {:?}", rect);
// {:#?} — pretty-printed debug format (great for complex structs)
println!("rect = {:#?}", rect);
// dbg! macro — prints to stderr with file and line number
dbg!(&rect);
}
Output:
rect = Rectangle { width: 10.0, height: 5.0 }
rect = Rectangle {
width: 10.0,
height: 5.0,
}
[src/main.rs:17] &rect = Rectangle {
width: 10.0,
height: 5.0,
}
Methods with impl
Methods are functions defined on a struct. They always take self as the first parameter.
#[derive(Debug)]
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
// `&self` means: borrow the Rectangle immutably (just read)
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
}
// `&mut self` means: borrow the Rectangle mutably (can modify fields)
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
// Takes another Rectangle as a parameter
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
fn main() {
let mut rect1 = Rectangle { width: 10.0, height: 5.0 };
let rect2 = Rectangle { width: 4.0, height: 3.0 };
println!("Area: {}", rect1.area());
println!("Perimeter: {}", rect1.perimeter());
println!("Is square? {}", rect1.is_square());
println!("Can hold rect2? {}", rect1.can_hold(&rect2));
rect1.scale(2.0); // Mutably borrows rect1
println!("After scaling: {:?}", rect1);
}
Output:
Area: 50
Perimeter: 30
Is square? false
Can hold rect2? true
After scaling: Rectangle { width: 20.0, height: 10.0 }
Associated Functions (Constructors)
Associated functions are like static methods — they belong to the struct type but don't take self. They're commonly used as constructors:
#[derive(Debug)]
struct Circle {
radius: f64,
}
impl Circle {
// Associated function — called with Circle::new(...)
fn new(radius: f64) -> Circle {
Circle { radius }
}
// Another associated function — a named constructor
fn unit() -> Circle {
Circle { radius: 1.0 }
}
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn circumference(&self) -> f64 {
2.0 * std::f64::consts::PI * self.radius
}
}
fn main() {
let c1 = Circle::new(5.0); // Call with ::
let c2 = Circle::unit(); // Unit circle
println!("Circle 1 radius: {}", c1.radius);
println!("Circle 1 area: {:.4}", c1.area());
println!("Circle 1 circumference: {:.4}", c1.circumference());
println!("Unit circle radius: {}", c2.radius);
println!("Unit circle area: {:.4}", c2.area());
}
Output:
Circle 1 radius: 5
Circle 1 area: 78.5398
Circle 1 circumference: 31.4159
Unit circle radius: 1
Unit circle area: 3.1416
Multiple impl Blocks
You can split impl into multiple blocks — useful for organization:
#[derive(Debug)]
struct Temperature {
celsius: f64,
}
impl Temperature {
fn new(celsius: f64) -> Self { // `Self` is an alias for the struct type
Temperature { celsius }
}
fn celsius(&self) -> f64 {
self.celsius
}
}
// Second impl block for conversions
impl Temperature {
fn fahrenheit(&self) -> f64 {
self.celsius * 9.0 / 5.0 + 32.0
}
fn kelvin(&self) -> f64 {
self.celsius + 273.15
}
fn is_boiling(&self) -> bool {
self.celsius >= 100.0
}
}
fn main() {
let water_boiling = Temperature::new(100.0);
println!("Celsius: {:.2}°C", water_boiling.celsius());
println!("Fahrenheit: {:.2}°F", water_boiling.fahrenheit());
println!("Kelvin: {:.2}K", water_boiling.kelvin());
println!("Boiling? {}", water_boiling.is_boiling());
}
Output:
Celsius: 100.00°C
Fahrenheit: 212.00°F
Kelvin: 373.15K
Boiling? true
A Complete Example: A Bank Account
#[derive(Debug)]
struct BankAccount {
owner: String,
balance: f64,
}
impl BankAccount {
/// Create a new account with a starting balance
fn new(owner: &str, initial_balance: f64) -> Self {
BankAccount {
owner: String::from(owner),
balance: initial_balance,
}
}
/// Deposit money — returns the new balance
fn deposit(&mut self, amount: f64) -> f64 {
if amount > 0.0 {
self.balance += amount;
println!("Deposited ${:.2}. New balance: ${:.2}", amount, self.balance);
} else {
println!("Invalid deposit amount.");
}
self.balance
}
/// Withdraw money — returns Ok(new_balance) or Err message
fn withdraw(&mut self, amount: f64) -> Result<f64, String> {
if amount <= 0.0 {
return Err(String::from("Invalid withdrawal amount."));
}
if amount > self.balance {
return Err(format!("Insufficient funds. Balance: ${:.2}", self.balance));
}
self.balance -= amount;
println!("Withdrew ${:.2}. New balance: ${:.2}", amount, self.balance);
Ok(self.balance)
}
fn show_balance(&self) {
println!("Account owner: {} | Balance: ${:.2}", self.owner, self.balance);
}
}
fn main() {
let mut account = BankAccount::new("Neo", 1000.0);
account.show_balance();
account.deposit(500.0);
account.deposit(250.0);
match account.withdraw(200.0) {
Ok(_) => {},
Err(msg) => println!("Error: {}", msg),
}
match account.withdraw(5000.0) {
Ok(_) => {},
Err(msg) => println!("Error: {}", msg),
}
account.show_balance();
}
Output:
Account owner: Neo | Balance: $1000.00
Deposited $500.00. New balance: $1500.00
Deposited $250.00. New balance: $1750.00
Withdrew $200.00. New balance: $1550.00
Error: Insufficient funds. Balance: $1550.00
Account owner: Neo | Balance: $1550.00
Summary
In this post you learned:
- ✅ How to define structs with named fields
- ✅ Field init shorthand and struct update syntax (
..other) - ✅ Tuple structs for simple type-safe wrappers
- ✅
#[derive(Debug)]for printing structs - ✅ Methods with
implusing&self,&mut self, or consumingself - ✅ Associated functions (constructors) called with
TypeName::function() - ✅ Multiple
implblocks for organization
What's Next?
In Post 6, we'll explore Enums and Pattern Matching — one of Rust's most powerful features. You'll learn about Option<T>, Result<T, E>, and the match expression.
Next Post: Enums and Pattern Matching in Rust →
Part of the Learn Rust from Scratch series on CodeWithNeo


Leave a Reply