Structs & Methods

Rust · Lesson 11 of 31 6 min read Updated

A student has a name, a roll number, a city and a fee status. Keeping these in four separate variables quickly becomes a mess. A struct (short for structure) groups related values under one name, so you can pass a whole student around as one thing.

In this lesson you will define structs, create and update instances, print them for debugging, use tuple structs to make your types safer, and give structs their own functions called methods.

Defining and using a struct

A struct definition lists fields, each with a name and a type. You create an instance by giving every field a value:

Rust
#[derive(Debug)]
struct Student {
    name: String,
    roll_no: u32,
    city: String,
    fees_paid: bool,
}

fn main() {
    let mut aarav = Student {
        name: String::from("Aarav"),
        roll_no: 17,
        city: String::from("Rohtak"),
        fees_paid: false,
    };
    aarav.fees_paid = true;

    println!("{} (roll {}) from {}", aarav.name, aarav.roll_no, aarav.city);
    println!("{aarav:?}");
    println!("{aarav:#?}");
}
Output
Aarav (roll 17) from Rohtak
Student { name: "Aarav", roll_no: 17, city: "Rohtak", fees_paid: true }
Student {
    name: "Aarav",
    roll_no: 17,
    city: "Rohtak",
    fees_paid: true,
}
  • Struct names use PascalCase (Student); field names use snake_case.
  • Read and write fields with a dot: aarav.fees_paid. To change any field, the whole instance must be mut; Rust has no "mutable fields".
  • The struct owns its String fields, so when aarav is dropped, its name and city are freed too.
  • #[derive(Debug)] asks the compiler to write the code that prints the struct with {:?} and {:#?}. Without it, {:?} does not compile. You will learn how to write a user-friendly {} format yourself in Traits.

Shorthands: field init and struct update

When a variable has the same name as a field, you can write the name once. This is the field init shorthand. The struct update syntax ..other fills every field you did not list from another instance:

Rust
// Student struct as above

fn new_student(name: &str, roll_no: u32) -> Student {
    let city = String::from("Rohtak");
    Student {
        name: name.to_string(),
        roll_no,          // same as roll_no: roll_no
        city,             // same as city: city
        fees_paid: false,
    }
}

fn main() {
    let isha = new_student("Isha", 18);
    let kabir = Student {
        name: String::from("Kabir"),
        roll_no: 19,
        ..isha
    };
    println!("{} and {} both live in {}", isha.name, kabir.name, kabir.city);
}
Output
Isha and Kabir both live in Rohtak

Careful: ..isha moves isha.city (a String) into kabir. Afterwards isha.name is still usable, but isha.city is not, and isha as a whole cannot be used. Fields of Copy types such as fees_paid are simply copied.

Tuple structs and the newtype pattern

A tuple struct has a name but unnamed fields, which you read by position. A tuple struct with a single field is called a newtype. It costs nothing at run time, yet it stops you from mixing up values that happen to share a type:

Rust
#[derive(Debug)]
struct Rupees(u32);

#[derive(Debug)]
struct Kilometres(u32);

fn taxi_fare(distance: Kilometres) -> Rupees {
    Rupees(50 + distance.0 * 18)
}

fn main() {
    let fare = taxi_fare(Kilometres(12));
    println!("{fare:?}");
    println!("Pay Rs. {}", fare.0);
    // taxi_fare(Rupees(12));   // error: expected `Kilometres`, found `Rupees`
}
Output
Rupees(266)
Pay Rs. 266

With plain u32 parameters, passing a price where a distance was expected would compile and give a wrong bill. With newtypes, the compiler catches it. There are also unit-like structs with no fields at all (struct Offline;), useful as markers once you learn traits.

Methods with impl

Methods are functions that belong to a type. You write them inside an impl block. A method's first parameter is self, the instance it is called on, written in one of three forms:

First parameterMeaningCaller can use the instance afterwards?
&selfBorrows the instance to read itYes
&mut selfBorrows the instance to change itYes
selfTakes ownership of the instanceNo, it has been moved

Functions in an impl block without self are associated functions, called with Type::name(...). The usual constructor is an associated function named new. Inside an impl block, Self is a short name for the type.

Rust
struct Wallet {
    owner: String,
    balance: u32,
}

impl Wallet {
    fn new(owner: &str) -> Self {
        Self { owner: owner.to_string(), balance: 0 }
    }

    fn add_money(&mut self, amount: u32) {
        self.balance += amount;
    }

    fn pay(&mut self, amount: u32) -> bool {
        if amount > self.balance {
            return false;
        }
        self.balance -= amount;
        true
    }

    fn summary(&self) -> String {
        format!("{} has Rs. {}", self.owner, self.balance)
    }

    fn close(self) -> u32 {
        println!("Closing wallet of {}", self.owner);
        self.balance
    }
}

fn main() {
    let mut wallet = Wallet::new("Priya");
    wallet.add_money(500);
    let paid_small = wallet.pay(180);
    let paid_big = wallet.pay(400);
    println!("Paid 180? {paid_small}. Paid 400? {paid_big}");
    println!("{}", wallet.summary());

    let refund = wallet.close();
    println!("Refunded Rs. {refund}");
    // wallet.summary();   // error: `wallet` was moved by `close`
}
Output
Paid 180? true. Paid 400? false
Priya has Rs. 320
Closing wallet of Priya
Refunded Rs. 320

You call methods with a dot, and Rust adds the & or &mut for you: wallet.add_money(500) really means Wallet::add_money(&mut wallet, 500). close takes self, so it consumes the wallet. That is a good design for actions after which the object must not be used again.

A type can have several impl blocks, and methods may share names with fields (a method balance(&self) next to a field balance is common for read-only access).

Common mistakes

  • Trying to make one field mutable. Mutability belongs to the binding: declare the whole instance with let mut.
  • Forgetting #[derive(Debug)]. Without it, printing with {:?} fails with "doesn't implement Debug".
  • Using self when &self is enough. A method taking self moves the instance, so the caller loses it. Read-only methods should take &self.
  • Forgetting that ..other moves fields. If you still need the old instance, clone the fields you take.

Key points

  • A struct groups named fields; create instances with Name { field: value, ... } and use the fields with a dot.
  • #[derive(Debug)] enables {:?} and {:#?} printing; dbg! is handy for quick checks.
  • Field init shorthand and ..other update syntax reduce repetition, but ..other can move fields.
  • Tuple structs and newtypes like Rupees(u32) make the compiler catch mixed-up values.
  • Methods live in impl blocks and take &self, &mut self or self; new is the usual constructor.

Exercise

Create a struct Book for a library in Pune with fields title: String, author: String and copies: u32. Add new(title: &str, author: &str, copies: u32) -> Self, a method lend(&mut self) -> bool that reduces copies if at least one is available, and a method label(&self) -> String that returns text like "Godaan by Premchand (2 left)". Lend the book three times when it has 2 copies and print the results.

Show solution
Rust
struct Book {
    title: String,
    author: String,
    copies: u32,
}

impl Book {
    fn new(title: &str, author: &str, copies: u32) -> Self {
        Self { title: title.to_string(), author: author.to_string(), copies }
    }

    fn lend(&mut self) -> bool {
        if self.copies == 0 {
            return false;
        }
        self.copies -= 1;
        true
    }

    fn label(&self) -> String {
        format!("{} by {} ({} left)", self.title, self.author, self.copies)
    }
}

fn main() {
    let mut book = Book::new("Godaan", "Premchand", 2);
    println!("{}", book.label());
    for _ in 0..3 {
        println!("Lent: {}", book.lend());
    }
    println!("{}", book.label());
}
Output
Godaan by Premchand (2 left)
Lent: true
Lent: true
Lent: false
Godaan by Premchand (0 left)

lend takes &mut self because it changes copies, while label only reads, so it takes &self. The _ in the loop means the loop counter is not needed.

Quick Quiz

1. What does #[derive(Debug)] do for a struct?

2. Which first parameter should a method have if it only reads fields?

3. How do you call an associated function new on Wallet?

4. What is the benefit of struct Rupees(u32); over a plain u32?

Learn Rust with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call