The Receiver Is Not Just Syntax: What Method Design Reveals About Systems Languages
Source: lobsters
The method receiver sits in a strange position in most language discussions. It is the thing you type almost without thinking: self, this, &self, *s. It looks like ceremony. It is not. The receiver is where ownership, dispatch cost, and extensibility converge in a single syntax decision, and different systems languages have made radically different choices about what to put there.
A survey of methods across systems languages maps out the surface-level differences well. What it gestured toward but left room to develop is the philosophical weight behind each design: not just what the syntax does, but what it reveals about what the language trusts you with and what it refuses to hide.
C++ and the Forty-Year Hidden Pointer
C++ introduced this in the early 1980s as an implicit pointer to the current object. The design decision was pragmatic: member functions looked like free functions under the hood, and the object pointer was passed as a hidden first argument. This worked. It also made this untouchable as a parameter.
The most visible symptom is const overloading. Any class with element access needs two versions of the same accessor, one for const objects and one for non-const:
struct Buffer {
std::vector<uint8_t> data;
uint8_t& at(size_t i) { return data[i]; }
const uint8_t& at(size_t i) const { return data[i]; }
};
The bodies are identical. The difference is whether this is Buffer* or const Buffer*, and because this is implicit, there is no way to parameterize over that distinction. Every accessor pair in every C++ codebase is a doubled maintenance surface caused by a hidden parameter.
P0847, accepted into C++23 and called “deducing this,” makes the receiver nameable and deducible for the first time:
struct Buffer {
std::vector<uint8_t> data;
template<typename Self>
auto& at(this Self&& self, size_t i) {
return self.data[i];
}
};
Self deduces to Buffer&, const Buffer&, or Buffer&& depending on the call site. One definition. The feature also unlocks recursive lambdas without std::function overhead, and simplifies CRTP patterns by eliminating the need to cast this to a derived type:
// Before C++23: CRTP required a template parameter and a static_cast
template<typename Derived>
struct Shape {
void report() { static_cast<Derived*>(this)->compute(); }
};
// C++23: self deduces to the derived type at call time
struct Shape {
template<typename Self>
void report(this Self&& self) { self.compute(); }
};
That this took until 2023 to land is the real story. this was always a parameter. The language just refused to let you treat it as one for four decades. The proposal is narrow in scope, changing nothing about how ordinary methods work, but it demonstrates that hiding the receiver was never truly free. Maintenance costs, template workarounds, and the inability to write self-referential closures without heap allocation were all downstream of a single design decision made when C++ was new.
Rust: The Receiver as Ownership Contract
Rust made the receiver explicit from day one, and made it mean something specific. In an impl block, the first parameter carries the ownership story of the call:
impl EventQueue {
pub fn len(&self) -> usize {
self.events.len()
}
pub fn push(&mut self, event: Event) {
self.events.push(event);
}
pub fn drain(self) -> Vec<Event> {
self.events
}
}
These are not calling conventions with different permission levels attached by convention. They are different types. &self is a shared borrow. &mut self is an exclusive mutable borrow: the borrow checker guarantees no other live borrow of the same value exists at that call. Bare self moves the value into the function; the caller cannot use it after the call returns.
This makes the receiver a compiler-enforced contract visible at every call site. When you see a method taking &mut self, you know the call requires exclusive access and cannot overlap with any other borrow. When you see a method taking self, you know it is the last operation you can perform on that value.
Builder patterns exploit the consuming receiver form to encode state machine semantics:
let conn = ConnectionBuilder::new()
.host("localhost")
.port(5432)
.timeout(Duration::from_secs(10))
.build(); // consumes the builder; any further use is a compile error
Each method takes self and returns Self, threading ownership through the chain. Attempting to reuse the builder after calling build fails at compile time. The receiver form is doing work that in any other language would require runtime checks or documentation that nobody reads.
Rust also supports extended receiver types: self: Box<Self>, self: Arc<Self>, self: Pin<&mut Self>. These follow from the rule that any type implementing Deref<Target = Self> can serve as a receiver. Pin<&mut Self> appears in hand-written Future implementations where the object must not move in memory during an asynchronous operation. No other mainstream systems language puts that level of specificity in the receiver slot.
Zig: The Most Honest Design
Zig has no method concept. There are functions and there are structs. If a function’s first parameter is the struct type (or a pointer to it), you can call it with dot syntax, and the compiler rewrites it to a regular function call:
const RingBuffer = struct {
buf: []u8,
head: usize,
tail: usize,
pub fn push(self: *RingBuffer, byte: u8) void {
self.buf[self.tail % self.buf.len] = byte;
self.tail += 1;
}
pub fn pop(self: *RingBuffer) ?u8 {
if (self.head == self.tail) return null;
const b = self.buf[self.head % self.buf.len];
self.head += 1;
return b;
}
};
var rb = RingBuffer{ .buf = &buf, .head = 0, .tail = 0 };
rb.push(42); // exactly RingBuffer.push(&rb, 42)
Mutation requires a pointer because you wrote a pointer. Immutable access takes a value or a const pointer because that is what the parameter says. There is no const qualifier on the method, no mutating keyword, no implicit reference insertion. The receiver is explicit because it has to be.
Dynamic dispatch in Zig is similarly unambiguous. The standard library’s std.mem.Allocator is a struct containing a *anyopaque data pointer and a vtable struct with typed function pointers. Every indirection is visible in source. When you look at a Zig call site, you can determine whether it involves a pointer indirection without knowing anything about the type hierarchy, because there is no type hierarchy.
The tradeoff is ceremony at scale. Building systems with pluggable behavior requires constructing vtable structs by hand. Zig handles this with comptime interfaces, but the pattern is more explicit than Rust traits or C++ virtual functions. Zig’s position is that the ceremony is preferable to the hidden cost. For low-level systems code where you need to reason about every memory access, that is probably the correct call.
Go’s Pointer-Receiver Asymmetry
Go looks clean on the surface:
type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ }
func (c Counter) Val() int { return c.n }
Pointer receiver for mutation, value receiver for observation. The rule is simple. The interface satisfaction rules are where it gets subtle.
In Go, a value of type T satisfies an interface using only value-receiver methods. A value of type *T satisfies the interface using both value-receiver and pointer-receiver methods. If any method required by an interface uses a pointer receiver, only *T implements the interface:
type Incrementer interface {
Inc()
Val() int
}
var i Incrementer = Counter{} // compile error
var i Incrementer = &Counter{} // correct
The error message the compiler produces is accurate, but reading it requires understanding the method set rules. Inc is defined on *Counter, so Counter as a value type does not have Inc in its method set, so it does not implement Incrementer. Only *Counter does.
This asymmetry creates a practical rule that Go programmers learn early: if any method on a type needs a pointer receiver, use pointer receivers for all methods on that type. Mixing receiver types produces rules that are predictable once you know them but genuinely surprising before you do.
Go’s interface dispatch is a two-word pair (type information pointer, data pointer) and calls through interface variables are indirect dispatch, paying roughly the same cost as a C++ virtual call. Go is transparent about when this happens: if the static type is known, the call is direct; if it is an interface variable, it is not. That clarity is worth something even if the underlying cost is similar.
The Philosophical Division
Looking across these four languages, the receiver design is a compressed statement of what the language trusts you to track mentally versus what it enforces mechanically.
C++ trusted the programmer to manage const-correctness and live with the consequences of an implicit pointer. The cost was four decades of const duplication, a workaround ecosystem, and a 2023 standards proposal to retroactively address the root cause.
Rust decided the receiver was too important to leave implicit. The ownership model requires the receiver to carry information the borrow checker can reason about. The syntax is more verbose, but the verbosity does work: it is not just documentation of intent, it is a machine-checked invariant.
Zig decided the receiver did not exist as a concept. Functions are functions. Dot-call syntax is sugar. Nothing is hidden. This is intellectually clean and operationally useful for code where you need to reason about every memory access and every pointer indirection.
Go made the receiver explicit and the rules learnable, but left the interface satisfaction asymmetry as a sharp edge that the compiler catches but that requires domain knowledge to navigate.
I write most of my production code in Rust, building Discord bots and the async infrastructure around them. The &self / &mut self / self distinction felt like overhead in the first week and has felt like a feature every week since. The borrow checker’s enforcement of exclusive access through &mut self has caught real concurrency mistakes at compile time. The consuming receiver in builder patterns has made stateful initialization impossible to misuse.
C++23’s deducing this is a genuine improvement to a language worth reaching for in performance-critical library code. But the need for the feature is itself diagnostic: the hidden this was always causing friction, and it took long enough to fix that entire ecosystems of workarounds accumulated in the gap. Zig’s position remains the most principled, because there is nothing to retroactively fix. The receiver was never hidden. It was always just a parameter.