The Receiver Is Not Just Syntax: Method Design Across Systems Languages
Source: lobsters
Every language that supports methods has to decide what to do with the object the method is called on. In object-oriented terminology that object is the receiver, and the mechanism for passing it into the method body is one of the more revealing design decisions in any language. It is not just syntax. The choice encodes what the language values: implicit convenience, explicit ownership, or the elimination of the concept entirely.
The 2023 comparison at xoria.org is a useful survey of the landscape. This post goes deeper on a few languages, with code, and traces the historical thread connecting them.
Where Implicit this Came From
C++ inherited its method model from Simula and Smalltalk via Bjarne Stroustrup’s “C with Classes,” which preceded the C++ name. Simula 67, generally credited as the origin of class-based OOP, introduced the idea that a procedure could be associated with a class and implicitly receive the object as a hidden argument. The mechanism was practical for the era: you write obj.method() and the compiler arranges to pass obj as an implicit pointer to the function body.
C++ preserved this design with this, a pointer implicitly available inside any non-static member function. The type of this inside a non-const method on Foo is Foo* const, meaning the pointer itself is const (you cannot reseat it) but the pointed-to object is mutable. Inside a const method, this becomes const Foo* const.
struct Counter {
int value = 0;
void increment() {
this->value += 1; // explicit dereference, but usually written as: value += 1
}
int get() const {
return this->value;
}
};
The problem with this design is that this is not a named parameter in the function signature. Its constness is expressed via a qualifier on the function declaration, not on the argument list. Overloading on const-ness requires writing two versions of the same function. Forwarding this through a template is historically impossible without significant gymnastics, because the type of this is not deducible as an ordinary template parameter. Writing std::forward-style perfect-forwarding wrappers for member functions required CRTP macros or significant boilerplate.
C++23’s Retroactive Fix: Deducing this
P0847R7, adopted into C++23, adds explicit object parameters. A member function can now declare the receiver explicitly as its first parameter using a named form with the this keyword:
struct Widget {
std::string name;
// Single function handles both const and non-const callers
template <typename Self>
auto& get_name(this Self& self) {
return self.name;
}
// Recursive lambda was previously impossible without a workaround
auto make_counter() {
return [count = 0](this auto& self) mutable -> int {
return ++count;
};
}
};
The this Self& syntax makes the receiver type deducible, enabling a single template to serve both const and non-const callers, and to forward the receiver with the correct value category. It also eliminates the need for CRTP boilerplate in some patterns, and enables recursive lambda expressions that previously required awkward external wrappers.
This is a meaningful improvement. It is also a retrofit added roughly 40 years after C++ was designed. The new syntax coexists with the old one and will do so indefinitely; existing codebases will not migrate. The two styles will appear side by side for decades, and programmers will need to understand both.
Rust: Ownership Encoded in the Receiver Type
Rust makes the receiver an explicit part of every method signature, and encodes ownership semantics directly into its type. The Rust Reference on method-call expressions defines three standard receiver forms:
&selfis a shared reference: readable but not mutable, and other shared references may coexist&mut selfis an exclusive mutable reference: the method can mutate, and no other reference to the value may exist for the duration of the callselftakes ownership: the method consumes the value, and the caller cannot use it afterward
struct Counter {
value: u32,
}
impl Counter {
pub fn increment(&mut self) {
self.value += 1;
}
pub fn get(&self) -> u32 {
self.value
}
pub fn into_value(self) -> u32 {
self.value // Counter is consumed; the caller cannot use it after this returns
}
}
These receiver distinctions are not conventions. They are enforced by the borrow checker. If you declare fn increment(&self) and try to mutate self.value, the compiler rejects the program. The receiver type is a contract, and the type system holds it.
The consuming receiver, self, enables patterns that are difficult to express in C++. Builder patterns use it to chain calls that transform a value and hand back ownership. Typestate patterns use it to model state machines: a TcpConnection method fn close(self) consumes the connection, and the type system prevents any further use. There is no separate “moved from” state to reason about; the type simply ceases to exist at the call site.
Rust also supports Box<Self>, Rc<Self>, Arc<Self>, and custom pointer types as receivers, provided they implement the Deref trait. This is why methods can be called through smart pointers without explicit dereferencing.
The Coherence Rule
Rust separates method definitions from type definitions. Methods live in impl blocks, which can appear in any file in the same crate, and trait implementations can be written in any crate that owns either the trait or the type. The orphan rule, also called the coherence rule, restricts this: you can implement a trait for a type only if you own at least one of the two. This prevents two crates from providing conflicting implementations of the same trait for the same type, which would make method dispatch ambiguous.
The rule creates friction when you want to implement a serialization trait from one external crate for a type from another. The standard workaround is the newtype pattern: wrap the foreign type in a local struct and implement the trait on the wrapper. It is a small cost for a guarantee that there is always exactly one valid dispatch path.
Go: Explicit Value vs. Pointer Receivers
Go’s method design is explicit but deliberately minimal. The Go specification on method declarations defines methods as functions with a receiver listed before the function name, as a named, typed parameter.
type Counter struct {
value int
}
func (c Counter) Get() int {
return c.value // operates on a copy
}
func (c *Counter) Increment() {
c.value++ // mutates the original
}
The choice between value and pointer receivers is the central design decision. A pointer receiver (*Counter) allows mutation of the underlying value. A value receiver (Counter) operates on a copy; mutations do not propagate. Go does not conflate these two distinctions; you choose when you write the declaration.
Go applies automatic address-taking at call sites for addressable values. If c is a local variable, c.Increment() is valid even though c is not a pointer, because the compiler takes c’s address implicitly. The reverse also applies: pointer values are automatically dereferenced for value-receiver calls. This keeps call sites clean, at the cost of making the receiver type less visible when reading the call site in isolation.
Because Go has no ownership semantics, the value/pointer distinction is purely about mutation and cost. There is no concept of consuming a value in a method call. The model is simpler than Rust’s by design.
Zig: No Methods, Just Namespaced Functions
Zig has no methods in any formal sense. The Zig documentation on structs describes structs as namespaces that can contain function declarations. Any function in a struct namespace whose first parameter is the struct type can be called with dot syntax. The distinction from a free function is entirely syntactic.
const Counter = struct {
value: u32,
pub fn increment(self: *Counter) void {
self.value += 1;
}
pub fn get(self: Counter) u32 {
return self.value;
}
};
var c = Counter{ .value = 0 };
c.increment(); // desugars to Counter.increment(&c)
const v = c.get(); // desugars to Counter.get(c)
The name self is a convention, not a keyword. The first parameter can be named anything. The type of that parameter determines the semantics in exactly the same way as any other function in Zig: Counter is a value copy, *Counter is a mutable pointer, *const Counter is a read-only pointer.
This approach is consistent with Zig’s principle of eliminating hidden mechanisms. There is no implicit pointer, no qualifier syntax, no special treatment in the type system. The cost is that the language provides no enforcement that the receiver comes first, or that it is even present. The ecosystem converges on self by convention, but a function that takes the struct type as its third parameter would also be callable with dot syntax. Whether that is a problem in practice depends on whether you think convention alone is sufficient enforcement.
Swift: mutating for Value Type Methods
Swift distinguishes between value types (structs and enums) and reference types (classes) in a way that directly shapes method design. Methods on classes can always mutate self, because self is a reference. Methods on structs receive an immutable copy of self by default; adding mutating to the declaration switches the receiver to a mutable in-out parameter.
struct Counter {
var value: Int = 0
func get() -> Int {
return value
}
mutating func increment() {
value += 1
}
}
The mutating annotation has protocol implications. A protocol can declare a requirement as mutating, which means any conforming value type must mark its implementation accordingly, but a class conforming to the same protocol cannot and does not use mutating. This creates an asymmetry between value and reference type conformances that is sometimes surprising but is a consequence of making mutation visible in the type system for value types.
D: More Granular Qualifiers
D extends C++‘s binary const/non-const receiver model with additional qualifiers. Member functions can be qualified with const (cannot mutate), immutable (expects a fully immutable object), or inout (propagates mutability from the argument to any transitively reachable data). The inout qualifier addresses the problem that C++ handles poorly: writing a single function that works correctly on both mutable and const inputs without duplicating the implementation.
D also has shared as a qualifier for thread-safe access, which integrates with its concurrency model. The system is more expressive than C++‘s but also more complex to fully understand; the interaction between const, immutable, and inout in D is not trivial.
The Design Space
Comparing these languages, the receiver mechanism sits on a spectrum from fully implicit to fully explicit, and from semantically inert to semantically rich.
C++ is implicit and semantically limited: the receiver is a hidden pointer, and the type system knows only whether it is const or not. Deducing this in C++23 moves C++ toward explicitness but does not change the underlying semantics, only their expressibility in templates.
Rust is explicit and semantically rich: the receiver type carries ownership information, and the type system enforces it. Every method signature tells you whether you can call it with a shared reference, an exclusive reference, or by giving up ownership.
Go is explicit and semantically moderate: you write the receiver type in the declaration, and the pointer/value distinction is meaningful, but there is no ownership model to enforce.
Zig is explicit by necessity: there is no receiver mechanism at all. Every parameter is just a parameter. This is the most honest design, because it acknowledges that a method is a function with a convention about its first argument, nothing more.
The progression from C++‘s legacy design toward Rust’s ownership-aware receivers is not accidental. Each language learned from the limitations of its predecessors. The receiver is where a language’s theory of safety meets its theory of ergonomics, and the design decisions made there shape how easy it is to write correct code, how much the compiler can verify for you, and what kinds of bugs fall out of the model entirely.