Methods and impl Blocks

A Rust struct or enum only describes data — the fields it holds and their types. It has no behavior by itself. An impl block (short for "implementation") is where you attach functions to a type. Functions defined inside an impl block are either methods, which take some form of self and act on a specific instance, or associated functions, which belong to the type itself and are usually used as constructors. This lesson covers both in depth, including exactly how self, &self, and &mut self interact with Rust’s ownership and borrowing rules.

Overview: How impl Blocks Work

Think of a struct definition as a blueprint for the shape of data, and an impl block as a toolbox of operations you can perform on that data. They are written separately, but the compiler links them together by type name. This separation is one of the ways Rust differs from class-based languages: there is no single class Rectangle { fields; methods; } unit. Instead you write struct Rectangle { ... } for the data, and one or more impl Rectangle { ... } blocks for the behavior. A type can even have several impl blocks (the compiler merges them), which is useful for keeping inherent methods separate from trait implementations later in the course.

The key idea that makes methods different from ordinary functions is the first parameter, which is some form of self. The shape of that parameter tells the compiler — and the borrow checker — exactly what kind of access the method needs to the instance it’s called on:

  • &self borrows the instance immutably. The method can read fields but not change them, and the caller can still use the instance afterward.
  • &mut self borrows the instance mutably. The method can modify fields, but Rust’s borrowing rules require that no other borrow of the instance is active at the same time, and the variable holding the instance must itself be declared mut.
  • self (no &) takes ownership of the instance. The method consumes it. Unless the type implements Copy, the original variable becomes invalid after the call, exactly as with any other move.
  • No self parameter at all makes it an associated function — not tied to any particular instance, called with Type::function(...) syntax instead of dot notation. The most common use is a constructor, conventionally named new.

This is not special-cased magic: the borrow checker treats the implicit borrow created by &self or &mut self exactly like any explicit reference you write yourself. That’s why the same rules you learn for references — one mutable borrow, or any number of immutable borrows, never both at once — apply directly to which methods you can call and when.

Syntax

struct TypeName {
    field: FieldType,
}

impl TypeName {
    fn associated_function(param: Type) -> ReturnType {
        // no `self` parameter; called as TypeName::associated_function(...)
    }

    fn method(&self) -> ReturnType {
        // borrows the instance immutably
    }

    fn method_mut(&mut self) -> ReturnType {
        // borrows the instance mutably
    }

    fn method_owned(self) -> ReturnType {
        // takes ownership; the instance cannot be used again afterward
    }
}
Form Meaning When to use
&self Immutable borrow of the instance Reading fields, computing a derived value (most common case)
&mut self Mutable borrow of the instance Modifying one or more fields in place
self Takes ownership, instance is consumed Transforming the value into something else, or a builder’s final step
no self Associated function, called via Type::name(...) Constructors and utility functions not tied to one instance

Examples

Example 1: Basic methods with &self

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn perimeter(&self) -> u32 {
        2 * (self.width + self.height)
    }
}

fn main() {
    let rect = Rectangle { width: 30, height: 50 };
    println!("Area: {}", rect.area());
    println!("Perimeter: {}", rect.perimeter());
}

Output:

Area: 1500
Perimeter: 160

Both area and perimeter take &self, so calling one doesn’t consume rect — it’s still available for the second call. Under the hood, rect.area() is sugar for Rectangle::area(&rect): the dot operator automatically creates the reference the method asks for.

Example 2: An associated function as a constructor

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn new(width: u32, height: u32) -> Rectangle {
        Rectangle { width, height }
    }

    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let big = Rectangle::new(30, 50);
    let small = Rectangle::new(10, 20);

    println!("Big area: {}", big.area());
    println!("Can big hold small? {}", big.can_hold(&small));
    println!("Can small hold big? {}", small.can_hold(&big));
}

Output:

Big area: 1500
Can big hold small? true
Can small hold big? false

new has no self parameter, so it’s called as Rectangle::new(30, 50), not on an existing instance — that’s exactly what makes it useful as a constructor. Inside new, Rectangle { width, height } uses field-init shorthand because the parameter names match the field names exactly. can_hold takes a second parameter, other: &Rectangle, showing that a method can borrow other values besides just self.

Example 3: Mutating with &mut self and consuming with self

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }

    fn increment(&mut self) {
        self.count += 1;
    }

    fn value(&self) -> u32 {
        self.count
    }

    fn into_final_value(self) -> u32 {
        self.count
    }
}

fn main() {
    let mut counter = Counter::new();
    counter.increment();
    counter.increment();
    counter.increment();

    println!("Current value: {}", counter.value());

    let final_value = counter.into_final_value();
    println!("Final value: {}", final_value);
}

Output:

Current value: 3
Final value: 3

increment needs &mut self because it writes to self.count, which is why counter is declared let mut counter. into_final_value takes self by value: calling it moves counter into the method, reads self.count out of it, and then counter is dropped at the end of the method body. Because we never try to use counter again after that call, the compiler is satisfied.

How impl Blocks Work Step by Step

When you write rect.area(), the compiler performs method lookup: it looks at the type of rect, searches every impl block for that type for a function named area, and checks the shape of its first parameter. Because area takes &self, the compiler automatically inserts a reference, so the call behaves as Rectangle::area(&rect). You never write the & yourself when calling — this is called automatic referencing, and it also works in reverse (automatic dereferencing) when rect is itself behind a reference or a smart pointer like Box<Rectangle>.

If the method instead takes &mut self, the compiler requires that the binding be declared mut and that no other borrow of the same value is alive at the call site — the exact same rule that governs any &mut reference. If the method takes self by value, the call is a genuine move: ownership of the instance transfers into the method’s stack frame, and the compiler marks the original variable as no longer usable, just as it would for let b = a; with a non-Copy type.

It’s worth being clear that methods are not stored inside the struct’s memory layout — a Rectangle in memory is just two u32 values, nothing more. impl blocks exist purely at compile time to tell the compiler which plain functions are associated with which type, and dot-call syntax is resolved statically (no runtime dispatch, no vtable) unless you later opt into trait objects with dyn.

Common Mistakes

Mistake 1: Using a value after a consuming method moved it

A method that takes self by value moves the instance into itself. Trying to use the original variable afterward is a compile error, not a runtime bug — the borrow checker catches it before the program ever runs.

struct Counter {
    count: u32,
}

impl Counter {
    fn into_final_value(self) -> u32 {
        self.count
    }
}

fn main() {
    let counter = Counter { count: 3 };
    let final_value = counter.into_final_value();
    println!("{}", counter.count); // error: counter was moved above
}

Compiler error:

error[E0382]: borrow of moved value: `counter`
  |
  | let final_value = counter.into_final_value();
  |                    ------- value moved here
  | println!("{}", counter.count);
  |                 ^^^^^^^ value borrowed here after move

The fix is simply to stop using counter after the consuming call, and to use the value that the method returned instead:

struct Counter {
    count: u32,
}

impl Counter {
    fn into_final_value(self) -> u32 {
        self.count
    }
}

let counter = Counter { count: 3 };
let final_value = counter.into_final_value();
println!("{}", final_value);

Mistake 2: Calling a &mut self method on a non-mut binding

If a method needs &mut self, the variable holding the instance must be declared with mut, even though the call site itself (counter.increment()) looks identical to any other method call.

struct Counter {
    count: u32,
}

impl Counter {
    fn increment(&mut self) {
        self.count += 1;
    }
}

fn main() {
    let counter = Counter { count: 0 };
    counter.increment();
    println!("{}", counter.count);
}

Compiler error:

error[E0596]: cannot borrow `counter` as mutable, as it is not declared as mutable
  |
  | let counter = Counter { count: 0 };
  |     ------- help: consider changing this to be mutable: `mut counter`
  | counter.increment();
  | ^^^^^^^ cannot borrow as mutable

The fix is to add mut to the binding:

struct Counter {
    count: u32,
}

impl Counter {
    fn increment(&mut self) {
        self.count += 1;
    }
}

let mut counter = Counter { count: 0 };
counter.increment();
println!("{}", counter.count);

Best Practices

  • Default to &self unless the method genuinely needs to mutate (&mut self) or must take ownership (self) — the less access you request, the more flexible the method is for callers.
  • Use an associated function named new as the conventional constructor; if a type has several sensible ways to construct it, name the others descriptively (from_str, with_capacity).
  • Name consuming methods so the ownership transfer is obvious, following the standard library’s convention: into_x for a cheap ownership-transferring conversion, to_x for one that clones or allocates.
  • Keep related methods in one impl block for a type; reach for multiple impl blocks mainly to separate inherent methods from trait implementations, or when using generics conditionally.
  • Prefer returning borrowed data (&str, a slice) over owned data (String, Vec<T>) from a method when the caller only needs to read it — it avoids an unnecessary allocation.
  • Document public methods with /// doc comments describing what they take, return, and any panics, so cargo doc produces useful reference pages.

Practice Exercises

  • Write a Circle struct with a single f64 field named radius. Implement an associated function new(radius: f64) -> Circle and two methods, area(&self) -> f64 and circumference(&self) -> f64, using std::f64::consts::PI. Print both values for a circle of radius 3.0.
  • Add a method reset(&mut self) to the Counter type from this lesson that sets count back to 0. Call increment twice, then reset, then print value() — the expected output is 0.
  • Write a Point struct with x: i32 and y: i32. Add a method into_tuple(self) -> (i32, i32) that consumes the point and returns its coordinates as a tuple. Try calling a second method on the original Point variable afterward and observe the compiler error, then remove that line so it compiles.

Summary

  • An impl block attaches functions to a struct or enum; the functions are either methods (take some form of self) or associated functions (no self, called via Type::name(...)).
  • &self borrows immutably, &mut self borrows mutably, and plain self takes ownership and consumes the instance — the same borrowing rules that apply to any reference apply here too.
  • Dot-call syntax like rect.area() is compiler sugar that automatically inserts the reference a method’s self parameter requires.
  • Associated functions with no self, conventionally named new, are the standard way to construct a type.
  • A type can have multiple impl blocks; the compiler treats them as one merged set of methods.
  • Calling a self-consuming method moves the instance; calling a &mut self method requires a mut binding and no other active borrow — both are enforced entirely at compile time.