I Thought It Was Taste: `&self`, `&mut self`, `self`, and What a Move Really Does
Every time I write a Rust method I stall on the same choice: &self, &mut self, or self. For a while I assumed it was a matter of taste — use &mut self if you need to mutate, &self otherwise, and self when you want to be a bit more forceful about it. It isn't.
This post covers what actually separates the three receivers, what a move really does, and why you'd reach for self at all when it costs you access to the original. It does not cover where exactly the mut in a builder's mut self attaches — I haven't dug into that yet. Every snippet and error message below came out of a local rustc 1.97.0.
Before we start — two terms
- move / ownership: in Rust every value has exactly one owner, and the value is cleaned up when that owner goes away. A move is a change of owner — the value goes to a new one, and the name that used to own it can no longer touch it.
- place / value: writing
c.namepoints at two things at once. The name slot insidec— the place — and the data sitting in that slot — the value. The Rust Reference splits these as place expressions and value expressions. This distinction is the spine of the whole post.
1. The three receivers are not a style choice
I understood that mutating meant &mut self. What I thought was that &self versus self was just taste. It isn't.
They do different things. There is exactly one fork in the road: is the instance still alive after the call?
struct Counter { count: u32 }
impl Counter {
fn get(&self) -> u32 { self.count } // read only
fn increment(&mut self) { self.count += 1; } // mutate
fn into_count(self) -> u32 { self.count } // consume — swallows the instance
}
| receiver | what it can do | original after the call | requirement on the caller |
|---|---|---|---|
&self | read only | alive (call it as often as you like) | none |
&mut self | mutate fields | alive | the variable must be let mut |
self | take a value out / convert / consume | invalidated (moved) | the original is sealed the moment you call |
let c = Config { name: "hello".into() };
let a = c.peek(); // &self — just a loan, c is still alive
let b = c.peek(); // so calling it again is fine
let s = c.take(); // self — ownership leaves. c is moved here
// c.peek(); // compile error: c has already been moved
&mut self isn't free either. The calling variable has to be declared let mut.
error[E0596]: cannot borrow `c` as mutable, as it is not declared as mutable
7 | c.increment();
| ^ cannot borrow as mutable
help: consider changing this to be mutable
6 | let mut c = Counter { count: 0 };
Reaching for &mut self on a method that changes nothing forces an unnecessary mut on everyone who uses the type. &self is the default; you move up only when you have to.
2. A move is not destruction
When I saw that calling a self method locked me out of the original, my first thought was that the thing had been taken apart. Was the value just gone? Was this like JavaScript's undefined? Neither.
What gets taken apart is not the value. It's the original binding's permission to use it.
let s = c.take_name();
// the value "hello" → perfectly alive. It just belongs to s now.
// the variable c → the compiler stamps it "do not use".
The value only changed owners; nothing was destroyed. Rust blocks the original side to prevent a double free — if two names each believe they own the same value, cleanup happens twice. One owner, so every other name gets shut off.
undefined lives on a different layer entirely.
JS undefined | a moved variable in Rust | |
|---|---|---|
| is there a value | yes, an actual one | the value left with its new owner; there is nothing here to talk about |
| when is it decided | runtime | compile time |
| what if you read it | you get undefined and the program keeps running | the compile is rejected. No binary is produced at all |
| when does it bite | much later, as a runtime crash | the code that would crash never ships |
A moved variable doesn't hold some special "empty" value. The compiler simply records in its ledger that this name is invalid from this point on. That's why it costs nothing at runtime — the move leaves no trace in the binary.
3. So why use self at all?
Even after all that it kept nagging at me. Why give up the original on purpose? Was this a performance thing, some kind of optimization? Both were the wrong question.
One frame has to go first. In Rust a move is not an optimization technique — it's the default assignment semantics. No method, no self, just a plain binding, and a move still happens.
let a = String::from("plain let binding, no method call at all");
let b = a; // not a method. Just an assignment
let n = eat(b); // passing it to a function is a move too
a ptr = 0x102b2dcb0
b ptr = 0x102b2dcb0 <- same buffer. And from here on, a is an E0382
So fn take(self) isn't a special technique. It's the rule already present in let b = a, applied to a method's first argument. Coming from C++ or JavaScript it reads as "copying is the default and zero-copy is the optimization," but Rust runs the other way: relocation is the default, and copying (clone()) is what you ask for explicitly.
The question has to flip too. Not "why use it when it destroys the original" but "what do I get in exchange for giving up the original name?" Three things.
You inherit the data without copying it
String::into_bytes takes self.
String ptr = 0x1056c5c50, len = 63
Vec<u8> ptr = 0x1056c5c50, len = 63 <- same address. The heap buffer was handed over as is
&self couldn't do this even in principle. You can't smuggle something out of a loan, so copying is the only option left.
s2 ptr = 0x1056c5cd0
to_vec ptr = 0x1056c5d10 <- freshly allocated, 63 bytes copied
Turning a String into a Vec<u8> costs either zero heap allocations or one. That's why the whole into_* family in the standard library takes self. The buffer can be reused precisely because the original name was given up — if that name stayed alive, two names would own one buffer, and the compiler could never hand it over.
The compiler blocks things that must not happen twice
impl Conn { fn close(self) { println!("closed"); } }
c.close();
c.close(); // the second one
error[E0382]: use of moved value: `c`
7 | c.close();
| ------- `c` moved due to this method call
8 | c.close();
| ^ value used here after move
note: `Conn::close` takes ownership of the receiver `self`, which moves `c`
Closing an already-closed connection doesn't compile. Instead of a runtime flag and if self.closed { panic!() }, the case was deleted at the type level. Here the sealing of the original isn't a side effect — it is the feature you wanted.
You didn't need the original anyway
Once every field has been taken out, the empty shell is useless; a builder has nothing to say after .build(). Make those &self and you force the caller to clone() an original nobody was going to use.
Framing it as performance gets it wrong
A move isn't free. A String itself is 24 bytes — pointer, length, capacity — and those 24 bytes may well be copied on the stack when it moves. What doesn't move is the actual data on the heap. Moving large structs around repeatedly appears to leave some of that stack copying behind (it's generally said to be optimized away, though I didn't verify that here). The accurate claim isn't "moves are fast" but "the heap data is never reallocated."
And reaching for self for performance when the ownership story isn't true backfires. The caller still wants the original, so they clone() it, and you've added a copy rather than removed one.
self is not an optimization choice; it's a lifetime declaration. You use it when "this instance is done here" is a true statement, and the performance follows from the declaration being right. If the original is still needed afterwards, use &self. Nothing is forced — you're choosing.
4. What moves is the value, not the place
This is where it clicked. I asked whether I was getting c.name itself or the value that had been sitting in c.name — and that turned out to be right.
c.name mixes two things together.
- place: the name slot inside the
cstruct. A memory location. - value: the data in that slot.
"hello".
c's name slot: [ "hello" ]
│ move — only the value relocates. The slot belongs to c and stays
▼
s: [ "hello" ] ← s owns this value now
A move in Rust is always the movement of a value, never of a place. Grab that one sentence and the previous sections collapse into it.
&self= showing the place for a moment. The value stays inc, socstays alive.self= relocating the value to a different place. The old slot empties out and the namecis sealed.
into_bytes inheriting the heap address in section 3 is the same story. What moved wasn't a place — it was the pointer value that had been sitting in one.
5. mut attaches to the binding, not to the value
Then I wondered whether let mut s = c.take() would let me mutate the result — even though c itself was never mut. It does. And what c was doesn't matter.
let c = Config { name: "hi".into() }; // c is immutable
let mut s = c.take(); // the receiving side declares mut afresh
s.push_str("!"); // fine. prints: hi!
A move transfers ownership and nothing else. Mutability is decided anew by the variable receiving it: let mut s and you can mutate, let s and you can't. There is no "I am mutable" tag riding along with the data. mut is a property of neither the type nor the value — it's the permission a given name currently holds.
And mutating s isn't "mutating c.name" in any sense. The value belongs to s now, and there is no c.name left to mutate. The place/value split from section 4 is doing the work again: the place stayed with c but is empty, and the value is in s.
6. self swallows the whole struct, even for one field
With both name and port on c, I figured calling take_name() would only carry off name and leave port behind. It doesn't.
struct Config { name: String, port: u32 }
impl Config {
fn take_name(self) -> String { self.name }
}
let c = Config { name: "hi".into(), port: 8080 };
let s = c.take_name();
println!("{}", c.port); // error
error[E0382]: borrow of moved value: `c`
7 | let s = c.take_name();
| ----------- `c` moved due to this method call
8 | println!("{} {}", s, c.port);
| ^^^^^^ value borrowed here after move
note: `Config::take_name` takes ownership of the receiver `self`, which moves `c`
The method doesn't receive name; it receives self, which is all of c. The unit of the transfer is set by the receiver, not by the return type. Returning just name still moves the whole struct, the "do not use" stamp lands on the name c as a whole, and port goes with it. The port that never left is quietly dropped when the method ends.
The exception — skip the method and only the field relocates
let c = Config { name: "hi".into(), port: 8080 };
let n = c.name; // move the field directly
println!("{} {}", n, c.port); // fine. prints: hi 8080
That's a partial move. Only the field that left is blocked.
error[E0382]: borrow of moved value: `c.name`
4 | let n = c.name;
| ------ value moved here
5 | println!("{} {}", n, c.name);
| ^^^^^^ value borrowed here after move
The error names c.name, not c. The ledger is kept per field.
c.take_name()→ the method swallowsself→ all ofcmoves →portblocked toolet n = c.name→ only the field relocates → partial move → the other fields survive
The fork is whether you go through a method or touch the field directly. If you want to get several fields out and keep them all, hand them over at once in a tuple.
fn into_parts(self) -> (String, String, u32) {
(self.name, self.host, self.port) // each one off to a new owner
}
The exception to the exception — Drop shuts partial moves down
impl Drop for Config { fn drop(&mut self) {} }
let n = c.name;
error[E0509]: cannot move out of type `Config`, which implements the `Drop` trait
5 | let n = c.name;
| ^^^^^^ cannot move out of here
drop(&mut self) receives the whole struct, intact, at the moment the value goes away. A half-emptied struct can't be handed to it, so the compiler blocks the taking side first. A partial move is only allowed when nobody will ever look at the struct as a whole again.
7. Copy types never move in the first place
Copy marks a type as one where copying the bits yields a complete duplicate. On assignment or argument passing, instead of invalidating the original, Rust makes one more copy. Not a relocation of the value — a duplication of it.
Within one struct, field types can split it.
struct Config { name: String, port: u32 }
let c = Config { name: "hi".into(), port: 8080 };
let p = c.port; // u32 is Copy
println!("{} {} {}", p, c.port, c.name); // fine. prints: 8080 8080 hi
c.port was taken out and yet both c.port and c.name are still there — the exact mirror image of let n = c.name sealing c.name back in section 6. Identical syntax; whether it relocates or duplicates depends on the type.
If the whole struct is Copy, even a self receiver leaves the original standing.
#[derive(Clone, Copy)]
struct Point { x: i32, y: i32 }
impl Point { fn into_x(self) -> i32 { self.x } }
let p = Point { x: 1, y: 2 };
let a = p.into_x();
let b = p.into_x(); // a second call goes through
println!("{} {} {}", a, b, p.y); // prints: 1 1 2
So the precise form of "taking self seals the original" is "when the type is not Copy." The fn close(self) design from section 3, the one that blocks a second call, does not hold for a Copy type.
The error message in section 6 was already spelling out that condition.
move occurs because `c` has type `Config`, which does not implement the `Copy` trait
To put it back together
&selfsays "let me have a look." The original stays; this is where you start.&mut selfsays "let me fix it." The original stays, but the caller now owes you alet mut.selfsays "I'm taking this." For getting a value out, for converting, for blocking a second call.- A move is a value relocating plus the original name being sealed. Not destruction, not
undefined. The seal is a static rule living in the compiler's ledger, so it costs nothing at runtime. - And a move isn't an exceptional event — it's the default. It's already there in
let b = a.
When I lose the thread, I go back to the place/value split. What moves is always the value; the place stays behind in the struct it belonged to. The thing I took for a style choice turned out to be a declaration about lifetime — am I ending this instance here or not — with performance as the reward for getting that declaration right.