Ownership at the Call Site: How Systems Languages Design the Method Receiver
Source: lobsters
The method receiver is a small surface with large consequences. In every struct-oriented systems language, some mechanism must give a method access to the object it operates on. How a language handles that position, what it hides, what it names, and what types it permits there, encodes that language’s theory of mutation, ownership, and the cost of abstraction.
A 2023 survey at xoria.org maps how several systems languages approach this, and the pattern is consistent: every language designed after C++ moved toward a receiver that is visible, named, and semantically charged. C++ itself, after four decades, followed in C++23. Examining each design in detail shows what the choices enable and foreclose.
C: The Convention
C has no method concept in the specification. Struct-oriented APIs use functions that take the struct pointer as a regular first argument, named self by convention only. Runtime polymorphism means building function pointer tables explicitly:
struct Allocator {
void *(*alloc)(struct Allocator *self, size_t size);
void (*free) (struct Allocator *self, void *ptr);
};
Call sites carry the redundancy: a.alloc(&a, size). Nothing is hidden. The receiver is exactly what it appears to be: a pointer in argument position. This transparency is C’s constraint and its advantage, since profiling is unambiguous and there is no dispatch mechanism to reason around.
C++: Hiding the Pointer
C++ promoted the receiver to a language concept: this, an implicit pointer to the current object available in every non-static member function, not appearing in the parameter list and not nameable by user code.
For the common case this is purely convenient, but friction appears at the edges. Any accessor returning a reference needs two nearly identical bodies for const and non-const contexts:
uint8_t& at(size_t i) { return data_[i]; }
const uint8_t& at(size_t i) const { return data_[i]; }
The bodies are identical; only the cv-qualification of this differs. The Curiously Recurring Template Pattern requires static_cast<Derived*>(this) in base class methods: correct but opaque. Recursive lambdas surface the sharpest version of the problem, since a lambda cannot name itself through this:
std::function<int(int)> fib = [&fib](int n) -> int {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
};
The heap allocation and type erasure from std::function exist entirely because the implicit parameter was not nameable.
C++23: Deducing This
P0847R7, merged into C++23, introduces explicit object parameters. The receiver becomes a named, deduced template parameter:
template<typename Self>
auto& at(this Self&& self, size_t i) {
return self.data_[i];
}
One definition handles all cv-qualifications. CRTP loses its template boilerplate on the base class. Recursive lambdas require no allocation:
auto fib = [](this auto self, int n) -> int {
return n <= 1 ? n : self(n - 1) + self(n - 2);
};
The committee vote was 65 to 0, reflecting how clearly the implicit this had been creating friction across unrelated use cases. The feature applies only to explicit opt-in functions, preserving compatibility with existing code, but it converges on the principle the newer languages had held from the start: the receiver is a parameter, and naming it is better than hiding it.
What is notable is that the three problems it solves, const duplication, CRTP verbosity, and recursive lambdas, had been accepted as normal for decades. Workarounds existed, so the pain was managed rather than eliminated. The 65-to-0 vote suggests the cumulative case eventually became unanswerable.
Go: Named but Constrained
Go placed the receiver before the function name from the beginning:
func (b *Buffer) Write(p []byte) (int, error) { ... }
func (b Buffer) Len() int { ... }
Pointer receiver operates on the original; value receiver operates on a copy. Interface dispatch uses a two-word fat pointer, one word for type metadata and one for the data, making the cost visible in the type system: calling through an interface variable incurs the indirection, and this is apparent from the declared type.
The constraint Go accepted is that methods cannot carry type parameters. When generics arrived in Go 1.18, parameterized methods were excluded to keep interface satisfaction rules tractable. Abstractions that feel natural as methods must sometimes live as package-level functions instead. This is a deliberate tradeoff, not an oversight, but it limits how much generic behavior can be attached to a type.
There is also a coherence rule that catches Go newcomers: if any method on T uses a pointer receiver, only *T satisfies interfaces requiring those methods, not T. This is enforced at compile time. In practice, most non-trivial types end up using pointer receivers throughout, so the value/pointer distinction becomes a declaration of mutation intent rather than a meaningful dispatch choice.
Rust: The Receiver as Type System Input
Rust’s receiver is a first-class parameter whose type participates in borrow checking. The receiver taxonomy is:
| Receiver | Semantics |
|---|---|
&self | Shared borrow; caller retains ownership |
&mut self | Exclusive mutable borrow; no concurrent live borrows |
self | Moves ownership into the method; value is consumed |
self: Box<Self> | Takes ownership of a heap-allocated value |
self: Pin<&mut Self> | Value must not move in memory |
The Pin<&mut Self> receiver is specific to async code. Generators can produce self-referential data, where a future holds references into its own fields across suspension points. Moving such a value would invalidate those references. Pin prevents the move at the type level:
impl Future for MyFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
// compiler ensures self cannot be moved here
}
}
Any type implementing Deref<Target = Self> can serve as a receiver, which is why my_box.len() on a Box<Vec<u8>> resolves correctly through the auto-deref chain. The compiler tries T::foo(x), then &T::foo(&x), then &mut T::foo(&mut x), then follows Deref and repeats.
Dynamic dispatch is opt-in via &dyn Trait. Static dispatch via generics is the default, and the compiler produces concrete, inlinable code per type with no vtable indirection. The payoff of treating the receiver as a regular typed parameter is that the full type system applies to it. Pin<&mut Self> expresses a memory-safety guarantee that an implicit, unnameable pointer could not carry.
Zig: Syntax Sugar Over a Parameter
Zig removes the method concept from the specification entirely. Dot-call syntax is sugar the compiler rewrites at the call site:
pub fn scale(self: *Vec2, factor: f32) void {
self.x *= factor;
self.y *= factor;
}
var v = Vec2{ .x = 1.0, .y = 2.0 };
v.scale(2.0); // Vec2.scale(&v, 2.0) after rewrite
Mutability follows from the pointer type in the parameter declaration: *Vec2 for mutation, Vec2 for a copy. Zig provides no vtable mechanism; runtime polymorphism requires building function pointer structs explicitly, as in C. The standard library’s std.mem.Allocator is the canonical example: a struct with a context pointer and a typed function pointer table, every indirection visible in source.
Comptime dispatch covers the common polymorphism case at zero cost:
fn process(value: anytype) void {
value.run();
}
anytype resolves at compile time. If the concrete type lacks run(), the error names the specific type that failed. There is no fat pointer and no indirection; the call inlines completely. This is consistent with Zig’s stated design principle of no hidden control flow and no hidden allocations: an implicit this pointer would contradict that principle in a small but consistent way.
What the Design Choice Encodes
Go chose simplicity and got a clean named receiver with real constraints on generic methods. Rust chose expressiveness and got an ownership-aware receiver capable of carrying guarantees like Pin. Zig chose transparency and ended up with a language where methods are a fiction layered over ordinary function calls. C++ chose compatibility and took four decades to adopt an explicit receiver syntax the others had from the start.
The receiver position is where a language states what it believes about mutation, ownership, and the cost of calling through an abstraction. A named, typed receiver makes that statement legible at the declaration site, before you read the body. The survey at xoria.org traces this convergence across the systems language landscape, and the direction is unmistakable: explicit is winning.