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:
&selfborrows the instance immutably. The method can read fields but not change them, and the caller can still use the instance afterward.&mut selfborrows 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 declaredmut.self(no&) takes ownership of the instance. The method consumes it. Unless the type implementsCopy, the original variable becomes invalid after the call, exactly as with any other move.- No
selfparameter at all makes it an associated function — not tied to any particular instance, called withType::function(...)syntax instead of dot notation. The most common use is a constructor, conventionally namednew.
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
&selfunless 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
newas 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_xfor a cheap ownership-transferring conversion,to_xfor one that clones or allocates. - Keep related methods in one
implblock for a type; reach for multipleimplblocks 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, socargo docproduces useful reference pages.
Practice Exercises
- Write a
Circlestruct with a singlef64field namedradius. Implement an associated functionnew(radius: f64) -> Circleand two methods,area(&self) -> f64andcircumference(&self) -> f64, usingstd::f64::consts::PI. Print both values for a circle of radius3.0. - Add a method
reset(&mut self)to theCountertype from this lesson that setscountback to0. Callincrementtwice, thenreset, then printvalue()— the expected output is0. - Write a
Pointstruct withx: i32andy: i32. Add a methodinto_tuple(self) -> (i32, i32)that consumes the point and returns its coordinates as a tuple. Try calling a second method on the originalPointvariable afterward and observe the compiler error, then remove that line so it compiles.
Summary
- An
implblock attaches functions to a struct or enum; the functions are either methods (take some form ofself) or associated functions (noself, called viaType::name(...)). &selfborrows immutably,&mut selfborrows mutably, and plainselftakes 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’sselfparameter requires. - Associated functions with no
self, conventionally namednew, are the standard way to construct a type. - A type can have multiple
implblocks; the compiler treats them as one merged set of methods. - Calling a
self-consuming method moves the instance; calling a&mut selfmethod requires amutbinding and no other active borrow — both are enforced entirely at compile time.
