· 6 min read ·

C++23 Made the Receiver Explicit: What Forty Years of Hidden `this` Was Costing

Source: lobsters

For four decades, C++ passed this to every non-static member function as a hidden first argument. You write obj.method(arg), the compiler inserts an implicit pointer to obj, and the pointer’s constness derives from the method’s cv-qualifier rather than anything you write explicitly. There is no way to name it, take its address in a lambda without extra syntax, or parameterize on its type.

C++23 added P0847, “Deducing this, which changes this by allowing member functions to declare an explicit self parameter. The syntax is small. The implications reach further than the feature description suggests, particularly when viewed alongside the receiver designs that Rust and Zig established long before. A 2023 survey of method design across systems languages covers many of these comparisons directly; the C++23 angle is worth following through in more depth.

The Const Overload Problem

The most common motivation in P0847 is const duplication. Any C++ class with element access needs two versions of the same accessor:

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 points to Buffer or const Buffer, which changes the return type. The standard workaround before C++23 was a cast trick: implement the const version, then implement the non-const version by casting const this away and calling the const version. It works and it is unpleasant.

With deducing this, you write the function once:

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&, Buffer&&, or const Buffer&& depending on the call. The return type follows via auto&. One definition covers all cases. For classes with many accessors, this reduces the maintenance surface considerably; a bug in shared logic was previously possible to fix in one overload and miss in the other.

CRTP Without the Template Parameter

The Curiously Recurring Template Pattern (CRTP) is an established C++ idiom for static polymorphism. The classic form requires the base class to be a template parameterized on the derived type:

template<typename Derived>
struct Shape {
    void describe() {
        auto area = static_cast<Derived*>(this)->area();
        std::printf("area: %.2f\n", area);
    }
};

struct Circle : Shape<Circle> {
    double radius;
    double area() const { return M_PI * radius * radius; }
};

The cast and the template parameter are boilerplate. With deducing this, the same static dispatch works without either:

struct Shape {
    template<typename Self>
    void describe(this Self&& self) {
        auto area = self.area();
        std::printf("area: %.2f\n", area);
    }
};

struct Circle : Shape {
    double radius;
    double area() const { return M_PI * radius * radius; }
};

When you call circle.describe(), Self deduces to Circle, and self.area() dispatches directly to Circle::area() at compile time. No vtable, no cast, no template parameter propagated through the inheritance hierarchy. For performance-sensitive code where polymorphism without virtual dispatch matters, this pattern cleans up considerably.

There is a third case worth noting: recursive lambdas, which before C++23 required std::function wrappers with their associated heap allocation and type erasure overhead. With deducing this, they work directly:

auto fib = [](this auto self, int n) -> int {
    return n <= 1 ? n : self(n - 1) + self(n - 2);
};

The lambda names its first parameter self, which deduces to the lambda’s own closure type. The compiler generates a direct call with no indirection and no allocation.

Rust’s Receiver as Ownership Annotation

Rust made the receiver explicit from the start. In an impl block, the first parameter determines the calling convention and participates in the borrow checker:

impl Vec2 {
    fn length(&self) -> f32 {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    fn scale(&mut self, factor: f32) {
        self.x *= factor;
        self.y *= factor;
    }

    fn into_tuple(self) -> (f32, f32) {
        (self.x, self.y)
    }
}

&self, &mut self, and bare self are not keywords with special semantics; they are standard Rust reference and move types applied to the first parameter. &mut self requires exclusive access to the receiver for the duration of the call, which the borrow checker enforces at every call site. Bare self consumes the value, and the compiler prevents any further use of it after the call returns.

The consequence is that Rust avoids the const overload problem entirely. The same at method works for both mutable and immutable access because &self and &mut self are distinct types and the compiler infers which to use from context. There is no need for two definitions because there was never a hidden parameter concealing the distinction.

Rust also supports extended receiver types: self: Box<Self> for methods that consume an owned heap allocation, self: Arc<Self> for shared-ownership contexts, and self: Pin<&mut Self> for futures that must not move in memory. These work because the receiver is a parameter, and any type implementing Deref<Target = Self> qualifies. The Rust reference on implementations treats them as extensions of the same mechanism rather than special cases. In a language with an implicit this, expressing “this method requires the receiver to be pinned” would require separate syntax or a design workaround.

Zig Removes the Distinction Entirely

Zig has no method concept in its language specification. A function defined in a struct’s namespace can be called with dot syntax if its first parameter is the struct’s type, a pointer to it, or a const pointer to it:

const Vec2 = struct {
    x: f32,
    y: f32,

    pub fn length(self: Vec2) f32 {
        return @sqrt(self.x * self.x + self.y * self.y);
    }

    pub fn scale(self: *Vec2, factor: f32) void {
        self.x *= factor;
        self.y *= factor;
    }
};

v.scale(2.0) is syntactic sugar; the compiler rewrites it to Vec2.scale(&v, 2.0). Whether the caller receives a copy, a mutable pointer, or a const pointer is explicit in the parameter type, not inferred from a cv-qualifier on the function. Dynamic dispatch, when needed, uses explicit vtable structs with typed function pointers, and every indirection is visible in source. The standard library’s std.mem.Allocator is the canonical example: a struct holding a data pointer and a vtable pointer, where each method is a function pointer whose first parameter is *anyopaque.

Zig’s design documentation frames this as part of a broader principle: there should be no hidden control flow and no hidden allocations. An implicit this pointer, inserted by the compiler without appearing in the function signature, runs against that principle in a small but consistent way.

What the Pattern Reveals

The trajectory from C++ to Rust to Zig runs consistently toward fewer hidden parameters. C++ introduced this as an implementation detail of member functions, and for the common case the abstraction holds cleanly. Const overloads, CRTP patterns, and self-referential lambdas are the cases where it failed; each required a workaround specifically because the parameter was not nameable or deducible.

Deducing this addresses each of these by making the receiver a named, deducible parameter. The C++23 feature is narrow in scope, changing nothing about how ordinary methods work, but it demonstrates that explicit is better in the cases where the implicit form was creating friction. Rust showed earlier that the receiver could carry ownership information the compiler enforces at every call site. Zig showed that the method concept itself was optional, that calling convention and parameter type were sufficient.

C++23 arrives at the same conclusion from the opposite direction, adding opt-in explicitness to a language that spent forty years without it. The direction of travel across these languages is consistent, and the reasons accumulate in roughly the same order: maintainability first, then expressiveness, then performance, then the realization that hiding the receiver was never free.

Was this interesting?