What C++23's Explicit `this` Concedes About Forty Years of Method Design
Source: lobsters
The receiver is just a parameter. When you call vec.push(5), the compiler passes vec to the function as an argument; it just does not always look that way in the source. Every systems language that supports method syntax made a choice about how visible to make that parameter, and those choices compound into significant differences in how you reason about code.
C demonstrated the baseline. Without language-level method support, you write:
void vec_push(Vec *self, int value) {
self->data[self->len++] = value;
}
The receiver is an ordinary pointer with an ordinary name. No magic. When Bjarne Stroustrup designed C++ classes, he hid this mechanism. The this pointer is passed silently, accessible by name only when you need to be explicit. That choice traded visibility for ergonomics, and for a decade it seemed like a reasonable trade.
The cracks appeared with const. If a method does not modify the object, you declare it const, but const is a property of the hidden parameter, which means it lives in a special syntactic position outside the normal parameter list:
size_t Vec::len() const {
return this->length; // this is now const Vec*
}
When you want to generalize over both const and non-const access in templates, you end up writing two overloads that differ only in constness:
T& at(size_t i) { return data[i]; }
const T& at(size_t i) const { return data[i]; }
This is duplication the compiler could eliminate if the receiver were just a parameter. C++11 tried to address a related problem with ref-qualifiers, letting you overload on whether the object is an lvalue or rvalue:
void process() &; // called on lvalues
void process() &&; // called on rvalues
Ref-qualifiers are syntactically awkward and used rarely in practice. The underlying issue is that the receiver’s type properties are scattered across multiple syntactic positions rather than being unified in one place where the type system can reason about them uniformly.
C++23 Closes the Gap, Partially
Proposal P0847R7, accepted into C++23, introduces explicit this parameters. You can now write:
struct Vec {
template <typename Self>
auto& at(this Self&& self, size_t i) {
return self.data[i];
}
};
The this Self&& syntax deduces the cv-qualification and value category of the receiver at the call site. One template handles const and non-const, lvalue and rvalue. This also enables recursive lambdas without the old workaround:
// Before C++23: pass self explicitly
auto fib = [](auto& self, int n) -> int {
return n <= 1 ? n : self(self, n - 1) + self(self, n - 2);
};
fib(fib, 10);
// C++23: receiver deduced
auto fib = [](this auto self, int n) -> int {
return n <= 1 ? n : self(n - 1) + self(n - 2);
};
fib(10);
Deducing this also simplifies CRTP. The classic Curiously Recurring Template Pattern requires the base class to be templated on the derived type so it can downcast; with explicit this, the base can deduce the derived type in each method call without any extra template machinery:
struct Base {
template <typename Self>
void do_something(this Self& self) {
// self has the actual derived type
self.derived_method();
}
};
This is a genuine improvement. But it is opt-in and only necessary for cases where the implicit mechanism fails. The implicit this still exists for all other methods. C++ now has two receiver mechanisms running in parallel, one implicit and one explicit, with no plan to unify them.
Rust Built the Ownership System on the Receiver
Rust’s approach treats the receiver as an explicit parameter from the start, and the type of that parameter directly encodes ownership semantics:
impl Vec<i32> {
fn len(&self) -> usize { self.data.len() }
fn push(&mut self, value: i32) { self.data.push(value); }
fn into_raw(self) -> *mut i32 {
Box::into_raw(self.data.into_boxed_slice()) as _
}
}
The three common forms are &self (shared borrow), &mut self (exclusive borrow), and self (move). Each maps directly onto Rust’s ownership model. The compiler enforces that you cannot call a &mut self method through a shared reference, cannot call a consuming self method twice, and so on. The receiver is not special syntax that happens to interact with the type system; it is the type system applied to the first parameter.
&self and &mut self are syntactic sugar for self: &Self and self: &mut Self. This means the receiver participates in the same type inference and trait checking that any other parameter does. For async code and self-referential structures, you can write self: Pin<&mut Self> as a receiver type, which prevents the object from being moved in memory after the method is called. This constraint is impossible to express cleanly if the receiver is implicit, because there is nowhere to put the pinning type.
The arbitrary self types feature (stabilizing incrementally across editions) extends this further. Methods on reference-counted types can take self: Rc<Self> or self: Arc<Self>, making the calling convention visible at the definition site rather than requiring wrapper functions.
Go’s Explicit Receiver in a Separate Position
Go made the receiver explicit but placed it between the func keyword and the method name:
func (v *Vec) Push(value int) {
v.data = append(v.data, value)
}
func (v Vec) Len() int {
return len(v.data)
}
The asterisk distinguishes pointer receivers from value receivers. A pointer receiver operates on the original value and can mutate it; a value receiver operates on a copy. Go auto-dereferences at call sites, so v.Push(5) works even if v is a Vec rather than *Vec, as long as v is addressable. The ergonomics are reasonable, but the copy semantics are a common source of bugs: forgetting * on a receiver means mutations disappear silently.
Go’s placement of the receiver outside the parameter list has one interesting consequence. You can define methods on any named type in the same package, including types derived from primitives:
type Celsius float64
func (c Celsius) ToFahrenheit() float64 {
return float64(c)*9/5 + 32
}
This works because methods are attached to named types, not class bodies. The explicit receiver position makes this feel natural: c is just a named-type value, and the function takes it the same way it would take any other parameter.
Zig Eliminates the Distinction Entirely
Zig has no method syntax in its grammar. What looks like a method call is handled by a calling convention: if the first parameter of a function defined inside a struct has that struct’s type, you can invoke it with dot notation. The name of the first parameter is pure convention.
const Vec = struct {
const Self = @This();
data: []i32,
len: usize,
pub fn push(self: *Self, value: i32) void {
self.data[self.len] = value;
self.len += 1;
}
pub fn count(self: Self) usize {
return self.len;
}
};
@This() returns the enclosing type, which is useful for self-referential definitions. Mutability comes from the pointer: *Self for a mutable receiver, Self for a value copy. You could name the first parameter this, me, or s; the dot-call syntax works regardless.
This design has a practical consequence for code reading. There is no way to accidentally obscure whether a function can modify its receiver. The pointer is right there in the parameter list. A reader who has never seen Zig before can still tell at a glance which functions mutate the struct and which do not.
Odin takes a similar stance: no method syntax, just procedures that take a struct pointer by convention. The dot-call syntax is not available, but the design philosophy is the same: procedures and methods are the same thing.
What the Divergence Reveals
The xoria.org survey of methods across systems languages observes that the surface syntax varies widely but the underlying design questions are consistent: what is the receiver’s type, who owns it, and can it be mutated. Each language answers those questions in its grammar, and the answers tell you something about what the language designers thought programmers should have to think about.
The trend in languages designed after 2010 is toward making the receiver parameter behave like any other parameter. Zig treats it as exactly that. Rust gives it a special first position but full type expressiveness, so the receiver participates in ownership checking the same way all other parameters do. Go makes it explicit in a distinct syntactic slot, trading some uniformity for visual separation. C++23 adds an opt-in path to explicit receivers while leaving the original implicit mechanism intact.
C++‘s hidden this was a bet that programmers do not need to think about the receiver’s type most of the time. That bet was largely correct for simple member access and largely wrong for template metaprogramming, const-correct generic code, move semantics, and any pattern where the type of the receiver at the call site determines the behavior of the function. Deducing this is the committee acknowledging that bet had a limited scope.
Rust, Go, and Zig made a different bet: that visible receivers cost little at the call site and gain a lot in type expressiveness and readability. Twenty years of C++ const proliferation and CRTP workarounds suggest that bet was the better one, and the fact that C++ is now retrofitting it supports that reading.