The missing piece of your data model
You just defined a Player struct with health, position, and inventory fields. It compiles. It stores data. But right now it is just a passive bag of bytes. You want to call player.take_damage(10) or player.move_to(x, y). In Python or JavaScript, you would add those functions directly inside the class or attach them to a prototype. Rust takes a different path. You never modify the struct definition to add behavior. Instead, you attach it separately using an impl block.
Separating data from behavior
Rust treats structure definition and method implementation as two distinct steps. The struct keyword declares what the data looks like in memory. The impl keyword declares what you can do with it. This separation is intentional. It prevents circular dependencies, keeps large files organized, and lets you split related behavior across multiple files if your project grows.
Think of a struct as a hardware chassis. The impl block is the firmware you flash onto it. The chassis does not change. You just add new capabilities that operate on the existing hardware.
The minimal setup
Here is the smallest working example. You define the struct, then open an impl block for that exact type name. Inside, you write functions. The first parameter becomes the receiver.
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
/// Calculate the area without taking ownership.
fn area(&self) -> u32 {
// &self borrows the instance so the caller keeps it.
self.width * self.height
}
}
fn main() {
let rect = Rectangle { width: 10, height: 5 };
// Dot syntax calls the method. The compiler rewrites this.
println!("Area: {}", rect.area());
}
The &self parameter is the key. It tells the compiler that this method needs read access to the struct instance. When you write rect.area(), the compiler secretly rewrites it to Rectangle::area(&rect). The dot syntax is just sugar. The method is still a regular function under the hood.
Trust the dot syntax. It is doing exactly what you expect, just with stricter borrowing rules.
The four forms of self
Methods in Rust are not one-size-fits-all. The first parameter determines how the method interacts with the instance. You will see four distinct patterns. Each one changes the borrowing rules, the performance characteristics, and what the caller can do afterward.
Borrowing for reads: &self
Use &self when the method only needs to inspect the data. It creates an immutable borrow. The caller keeps full ownership and can continue using the struct immediately after the call. This is the default choice for the vast majority of methods. Getters, calculators, and debug printers all use this form.
impl Rectangle {
/// Check if the rectangle is a square.
fn is_square(&self) -> bool {
// Immutable borrow. The caller still owns rect.
self.width == self.height
}
}
Borrowing for writes: &mut self
Use &mut self when the method needs to modify the instance. It creates a mutable borrow. The compiler enforces exclusive access. No other references to the struct can exist while this method runs. The caller gets the struct back, updated, once the method finishes.
impl Rectangle {
/// Scale both dimensions by a factor.
fn scale(&mut self, factor: u32) {
// Mutable borrow. Caller cannot read or write rect during this call.
self.width *= factor;
self.height *= factor;
}
}
Consuming the instance: self
Use self by value when the method needs to take ownership. The instance is moved into the function. The caller loses access to it. This pattern appears when you are transforming the struct into something else, extracting its inner data, or intentionally destroying it.
impl Rectangle {
/// Convert the rectangle into a string representation and consume it.
fn to_owned_label(self) -> String {
// self is moved. The original variable is gone after this call.
format!("Rect({}x{})", self.width, self.height)
}
}
Associated functions: no self
Use a parameterless function inside impl when you need a constructor or a utility that does not require an existing instance. These are called associated functions. They live in the type namespace but do not operate on a specific object. The community convention is to name constructors new().
impl Rectangle {
/// Create a new rectangle with equal width and height.
fn square(size: u32) -> Rectangle {
// No self parameter. This is a constructor, not a method.
Rectangle { width: size, height: size }
}
}
What the compiler actually does
When you write rect.area(), the compiler performs a lookup. It checks if Rectangle has an inherent method named area. If it finds one, it verifies the signature matches the call site. If rect is immutable, the compiler only considers &self methods. If rect is mutable, it considers &mut self and &self methods. It automatically inserts the borrow if needed.
This automatic borrowing is why rect.area() works even though the signature is fn area(&self). The compiler sees you are calling a method on an immutable binding and injects the &. If you call a &mut self method on an immutable binding, the compiler rejects it with E0596 (cannot borrow as mutable). You must declare the variable with let mut rect = ... first.
If you try to call a self method on a borrowed reference, you get E0382 (use of moved value). The compiler will not let you steal ownership from a reference. You must own the value to move it.
Keep the borrowing rules in mind. The compiler is not being difficult. It is preventing you from accidentally destroying data or creating data races.
Realistic example: a game inventory slot
Let's put the pieces together in a scenario closer to actual application code. You are building an inventory system. Each slot holds an item, a quantity, and a flag indicating whether it is locked.
struct InventorySlot {
item_name: String,
quantity: u32,
is_locked: bool,
}
impl InventorySlot {
/// Create a new empty slot.
fn new() -> InventorySlot {
InventorySlot {
item_name: String::from("Empty"),
quantity: 0,
is_locked: false,
}
}
/// Check if the slot contains anything.
fn is_empty(&self) -> bool {
// Read-only check. Safe to call from multiple threads if wrapped in Arc.
self.quantity == 0
}
/// Add items to the slot if it is not locked.
fn add(&mut self, amount: u32) -> bool {
// Mutable borrow. Returns false if locked.
if self.is_locked {
return false;
}
self.quantity += amount;
true
}
/// Extract all items and reset the slot.
fn drain(self) -> u32 {
// Takes ownership. Returns the quantity and leaves the slot in a default state.
let qty = self.quantity;
self.quantity = 0;
qty
}
}
fn main() {
let mut slot = InventorySlot::new();
slot.add(5);
println!("Has items: {}", !slot.is_empty());
// Calling drain moves the slot. slot is unusable after this.
let recovered = slot.drain();
println!("Recovered: {}", recovered);
}
Notice how each method chooses the exact level of access it needs. is_empty only borrows. add mutates. drain consumes. The compiler enforces these boundaries at compile time. You never get a surprise at runtime where a method silently mutates shared state.
Treat method signatures as contracts. The first parameter tells the caller exactly what rights they are temporarily surrendering.
Common pitfalls and how to fix them
Beginners run into three predictable friction points when writing methods.
Forgetting the borrow on the call site. You write let x = rect.area() but rect is declared as let mut rect. The compiler automatically inserts the & for &self methods, so this usually works. But if you explicitly write Rectangle::area(&rect), you are doing the compiler's job manually. Stick to dot syntax. It handles the borrowing automatically.
Mixing up inherent methods and trait methods. You can define a method named len on your struct. You can also implement the std::fmt::Display trait and define fmt. They live in different namespaces. Inherent methods always win. If you define fn show(&self) on your struct, and later implement a trait that also has fn show(&self), the trait method becomes inaccessible unless you call it explicitly with TraitName::show(&obj). This is by design. Inherent methods are your private API. Traits are your public interface.
Splitting impl blocks incorrectly. Rust allows multiple impl blocks for the same struct. You can split them across files, or group them by functionality. The compiler merges them. But you cannot define the same method name twice across different impl blocks. The compiler will reject duplicate definitions. Use multiple blocks for organization, not for overriding.
If the compiler complains about trait bounds or missing implementations, check E0277. It means you are trying to use a type in a context that requires a trait it does not implement. Methods do not automatically grant trait implementations. You must implement traits explicitly.
Organize your impl blocks by responsibility. Keep constructors together. Keep mutators together. Keep getters together. The compiler does not care. Your future self will.
When to use which form
Use &self when you only need to read fields or compute a value without changing the struct. Use &mut self when you need to update fields, push to a vector, or modify internal state. Use self by value when you are transforming the struct into a different type, extracting owned data, or intentionally consuming it to prevent further use. Use parameterless associated functions when you need constructors, factory methods, or type-level utilities that do not require an instance. Reach for trait implementations when you want to integrate with the standard library or allow other crates to treat your type polymorphically.