Create your own
Lesson illustration

Implementing Struct Methods with `impl`

Hello! Welcome back to your journey into Solana development with Rust.

In our last lesson, we learned how to define structs to organize and group related data into custom types, using a simplified SolanaAccount as our running example. This gave us a way to represent the state of things in our programs.

However, data on its own is static. We also need to define the behavior that acts upon that data. In your experience with JavaScript and TypeScript, you'd add methods to a class to define its behavior. Rust uses a similar, yet distinct, pattern.

Today, we'll explore how to bring data and behavior together. You will learn how to implement methods on structs using impl blocks. This allows you to associate functions directly with your custom data types, making your code more organized, intuitive, and robust—a crucial skill for writing clear and maintainable Solana programs.

Bundling Behavior with impl

In Rust, you define methods for a struct in a separate impl block, which is short for "implementation." This block contains all the functions associated with that struct. This approach neatly separates the data definition (struct) from its behavior (impl).

Functions defined inside an impl block are called associated functions. Those that take a reference to an instance of the struct as their first parameter are called methods.

Let's begin by reading the core documentation on this topic. It introduces the impl block and the special self parameter, which represents the instance of the struct the method is called on.

Method Syntax - The Rust Programming Language

Please read the following two sections from 'The Rust Programming Language' book. They explain how to define methods within an impl block and introduce the all-important self parameter.

Read the sections titled 'Method Syntax' and 'Defining Methods'. Pay close attention to how the area function is transformed into a method within an impl block and the explanation of &self.

The self Parameter: &self, &mut self, and self

As you just read, the first parameter of any method is always self. This is analogous to this in JavaScript or Python. However, Rust is very explicit about how self is used, which ties directly back to the ownership model:

  1. &self (Immutable Borrow): This is the most common form. It gives the method read-only access to the struct's instance. You use this when you need to inspect the data without changing it.

  2. &mut self (Mutable Borrow): This gives the method read-write access. You use this when you need to modify the fields of the struct instance. To call a method like this, the struct instance itself must be declared as mutable (mut).

  3. self (Ownership): This takes full ownership of the instance. After a method with self is called, the original instance is moved and can no longer be used. This is less common but useful for methods that transform an instance into something else, like a builder pattern's final build() method.

Let's apply this to our SolanaAccount struct from the previous lesson.

#[derive(Debug)]
struct SolanaAccount {
    lamports: u64,
    owner: String,
    executable: bool,
}

// Implementation block for SolanaAccount
impl SolanaAccount {
    // A method to check if the account is a program (read-only)
    // It takes an immutable reference to self.
    fn is_program(&self) -> bool {
        self.executable
    }

    // A method to simulate a debit (modifies data)
    // It takes a mutable reference to self.
    fn debit(&mut self, amount: u64) {
        if self.lamports >= amount {
            self.lamports -= amount;
            println!("Debited {}. New balance: {}", amount, self.lamports);
        } else {
            println!("Insufficient funds to debit {}.", amount);
        }
    }
}

fn main() {
    // The instance must be `mut` to call `debit`.
    let mut wallet_account = SolanaAccount {
        lamports: 1_000_000_000, // 1 SOL
        owner: String::from("SystemProgram"),
        executable: false,
    };

    // Calling a method that takes &self
    println!("Is this a program account? {}", wallet_account.is_program());

    // Calling a method that takes &mut self
    wallet_account.debit(500_000_000);
    
    // Attempting to call debit on an immutable instance would fail:
    // let immutable_account = SolanaAccount { ... };
    // immutable_account.debit(100); // <-- Compile-time error!
}

The resource below provides another great example using a Dragon struct, clearly showing methods for both immutable and mutable access.

Implementing methods - Learn Rust

This article from Rustfinity gives another clear, practical example of implementing methods with both &self and &mut self.

Read the subsections 'Method with immutable reference to self' and 'Method with mutable reference to self'. Notice how make_sound only needs to read data (&self) while level_up needs to write to it (&mut self).

Associated Functions: The "Static Methods" of Rust

What about functions that are related to a struct but don't need an instance to work? For example, how do you create a new instance of a struct? In many languages, this is done with a constructor. In Rust, the convention is to use an associated function named new.

Associated functions are defined in the impl block just like methods, but they do not take self as their first parameter. They are called using the struct's name and the :: operator, not with dot notation on an instance. This is very similar to static methods in TypeScript/JavaScript classes.

The following video provides a very clear and quick distinction between instance methods (&self) and associated functions.

9. Structs

This short video from '300 seconds of Rust' does an excellent job of showing the difference between instance methods that operate on an instance and associated functions that act like constructors.

Watch from 01:22 to 03:38. Focus on how the describe method uses &self and is called with a dot (.), while the new function does not take self and is called with double colons (::).

Let's add a conventional new function to our SolanaAccount. This standardizes how we create new accounts.

#[derive(Debug)]
struct SolanaAccount {
    lamports: u64,
    owner: String,
    executable: bool,
}

impl SolanaAccount {
    // Associated function to create a new, standard user wallet.
    // It is NOT a method because it doesn't take `self`.
    fn new_user_wallet(initial_lamports: u64) -> Self {
        // `Self` (uppercase) is an alias for the type of the impl block,
        // which is `SolanaAccount` in this case.
        Self {
            lamports: initial_lamports,
            owner: String::from("SystemProgram"), // Default owner for user wallets
            executable: false,
        }
    }

    fn debit(&mut self, amount: u64) {
        if self.lamports >= amount {
            self.lamports -= amount;
        }
    }
}

fn main() {
    // Call the associated function using `::` syntax.
    let mut wallet1 = SolanaAccount::new_user_wallet(500_000);
    println!("Created wallet: {:#?}", wallet1);

    wallet1.debit(100_000);
    println!("Wallet after debit: {:#?}", wallet1);
}

Methods with Additional Parameters

Methods can, of course, take more parameters than just self. Any additional parameters are listed after self.

Let's add a can_afford_debit method to our SolanaAccount to check if a withdrawal is possible before attempting it.

impl SolanaAccount {
    // ... other methods ...

    // Method with an additional parameter.
    // It takes an immutable borrow of self and a u64 amount.
    fn can_afford_debit(&self, amount: u64) -> bool {
        self.lamports >= amount
    }
}

fn main() {
    let wallet = SolanaAccount::new_user_wallet(1000);
    let transaction_cost = 1500;

    if wallet.can_afford_debit(transaction_cost) {
        println!("Transaction approved.");
        // wallet.debit(transaction_cost); // This would require `wallet` to be `mut`
    } else {
        println!("Transaction denied: insufficient funds.");
    }
}

This pattern is extremely common. The official Rust book also has a great example of this with its Rectangle struct, which you can check out under the "Methods with More Parameters" section of the "Method Syntax" chapter we looked at earlier.

Test your understanding!

You're building a simple on-chain game. Your task is to model a Player character.

  1. Define a Player struct with health (u32) and mana (u32) fields.
  2. Implement an impl block for Player.
  3. Add an associated function new() that creates a new player with 100 health and 100 mana.
  4. Add a method cast_spell(&mut self, cost: u32) that subtracts the mana cost from the player's mana.
  5. Add a method is_dead(&self) -> bool that returns true if the player's health is 0.
  6. In main, create a new player, cast a spell with a cost of 30, and print their remaining mana.
Show answer

Here's a complete solution:

// 1. Define the struct
#[derive(Debug)]
struct Player {
    health: u32,
    mana: u32,
}

// 2. Implement the impl block
impl Player {
    // 3. Associated function `new`
    fn new() -> Self {
        Self {
            health: 100,
            mana: 100,
        }
    }

    // 4. Method to cast a spell
    fn cast_spell(&mut self, cost: u32) {
        if self.mana >= cost {
            self.mana -= cost;
        } else {
            println!("Not enough mana!");
        }
    }

    // 5. Method to check if player is dead
    fn is_dead(&self) -> bool {
        self.health == 0
    }
}

fn main() {
    // 6. Use the implementation
    let mut player1 = Player::new();
    println!("New player created: {:?}", player1);

    player1.cast_spell(30);
    println!("Player cast a spell. Remaining mana: {}", player1.mana);
    
    println!("Is player dead? {}", player1.is_dead());
}

Conclusion

Great job! You have now learned how to attach behavior directly to your data structures. This is a fundamental concept for building well-organized and intuitive APIs, both for libraries and for Solana programs. By grouping related functions with the data they operate on, your code becomes much easier to discover, use, and maintain.

Here are the key takeaways from this lesson:

  • Methods are defined within an impl block and are associated with a struct.
  • The first parameter of a method is always self, which can be &self (immutable borrow), &mut self (mutable borrow), or self (takes ownership).
  • Methods that modify the struct's data require &mut self and can only be called on mutable instances.
  • Associated functions are also defined in an impl block but do not take self. They are often used as constructors (e.g., fn new()) and are called with the :: syntax (e.g., Player::new()).

So far, we've seen how structs allow us to represent data that is a collection of different fields (A and B and C). In our next lesson, we will explore enums, which allow us to define a type that can be one of several possible variants (A or B or C). This is another critical tool for modeling state and handling different outcomes in your programs, such as the possibility of a value being present or absent (Option) or an operation succeeding or failing (Result).

Can't find a good explanation? Sign up and we'll make it for you

Sign up