· 7 min read ·

What Forty Years of Hidden `this` Finally Concedes: Method Receivers Across Systems Languages

Source: lobsters

The method is so common in object-oriented code that its design rarely invites scrutiny. You write obj.method(), the language resolves the call, and you proceed. In systems programming, where the cost of a copy, the validity of a reference, and the provenance of a mutation all carry real weight, the design of the method receiver encodes meaningful constraints. A 2023 survey from xoria.org examines how several systems languages approach this design space, and the differences are worth examining beyond the surface.

Forty Years of Hidden this

C++ inherited its method model from a design decision made in the early 1980s: every non-static member function receives a hidden pointer called this, passed implicitly by the compiler. You reference it explicitly only to resolve ambiguity; otherwise it is invisible.

This invisibility produced a problem that took roughly forty years to address. Consider a getter that must work on both const and non-const objects:

class Buffer {
public:
    char& at(size_t i)             { return data_[i]; }
    const char& at(size_t i) const { return data_[i]; }
private:
    std::vector<char> data_;
};

Two overloads, identical bodies, differentiated only by the const qualifier that modifies the implicit this. For a trivial one-liner this is merely annoying. For longer methods that do real work before returning a reference or iterator, the duplication becomes maintenance debt. The conventional workaround, casting away const in the non-const overload to delegate to the const version, is technically correct and instructively ugly.

P0847R7, “Deducing this”, adopted into C++23, addresses this by making the receiver explicit and deducible. The function receives a first parameter annotated with this:

class Buffer {
public:
    template <typename Self>
    auto& at(this Self&& self, size_t i) {
        return self.data_[i];
    }
private:
    std::vector<char> data_;
};

The Self parameter deduces to Buffer&, const Buffer&, or Buffer&& depending on the call site. One definition covers three cases. The feature also enables recursive lambdas without std::function overhead:

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

CRTP patterns simplify too: the explicit object parameter deduces to the most-derived type, removing the need for a template base class parameter in many common uses.

What this feature concedes is worth naming. C++ required four decades and a language revision to make the receiver visible at the source level. The implicit-this assumption was embedded so deeply that recovering expressiveness required a new syntactic category, not just a library change.

Rust: The Receiver as Ownership Declaration

Rust made the receiver explicit from the beginning and tied it to the ownership and borrowing system. Every method in an impl block takes a first parameter that declares the calling contract:

impl Buffer {
    fn len(&self) -> usize         { self.data.len() }
    fn push(&mut self, b: u8)      { self.data.push(b) }
    fn into_bytes(self) -> Vec<u8> { self.data }
}

&self borrows immutably; multiple callers can hold shared references simultaneously, and the borrow checker enforces this. &mut self borrows mutably and excludes all other borrows for the duration of the call. self consumes the value; the caller’s binding becomes invalid after the call, enforced at compile time.

The receiver type is informative before you read the method body. into_bytes does not merely suggest consumption by its name; the type system requires it. In a large codebase this distinction reduces the work of understanding method contracts. You do not need to audit a body to determine whether a call can invalidate a reference held elsewhere in the same scope.

Rust also supports non-standard receivers through the arbitrary_self_types feature, allowing Box<Self>, Arc<Self>, Rc<Self>, and custom smart pointer types as receivers, provided they implement the Receiver trait. A method declared as fn process(self: Arc<Self>) is callable as arc_value.process() without special syntax. The ownership model tracks through smart pointers the same way it tracks through plain references, so method dispatch stays consistent regardless of how a value is stored.

The separation of methods from type definitions through impl blocks also has structural implications. Multiple impl blocks can exist for the same type; traits add methods through their own impl blocks. This cleanly separates data layout from behavior, at the cost of introducing a coherence problem that Rust resolves through orphan rules, which we will return to.

Go: Methods on Any Named Type

Go’s receiver design is explicit and structurally simpler. A method is a function with a named receiver parameter:

func (b *Buffer) Push(byte byte) {
    b.data = append(b.data, byte)
}

func (b Buffer) Len() int {
    return len(b.data)
}

The choice between pointer receiver and value receiver is explicit and consequential. A pointer receiver copies a pointer and permits mutation. A value receiver copies the entire struct. The Go FAQ recommends using pointer receivers consistently across a type if any method needs to mutate state. The method set of T includes only value-receiver methods; the method set of *T includes both. Mixing receiver styles causes confusing behavior when the type is stored in an interface value, since an interface holding T cannot call pointer-receiver methods.

Where Go’s design is most interesting is scope. Methods may be defined on any named type declared in the same package, not only structs. Named integer types, named function types, named slice types: all accept method definitions. The standard library’s http.HandlerFunc is a named function type with a ServeHTTP method, making it satisfy http.Handler without a wrapper struct or an inheritance hierarchy. That pattern, a named type as a lightweight interface adapter, appears throughout idiomatic Go code.

The package boundary as the extensibility unit is deliberate. You cannot define methods on types from other packages, which prevents conflicts when multiple libraries operate on the same foreign type. Extension requires a wrapper, which is verbose but unambiguous.

Zig: Convention Without Dispatch

Zig has no method dispatch mechanism. What reads as method syntax is syntactic sugar over a plain function call with no semantic distinction:

const Buffer = struct {
    data: std.ArrayList(u8),

    pub fn push(self: *Buffer, byte: u8) !void {
        try self.data.append(byte);
    }
};

// These two calls are identical:
try buffer.push(42);
try Buffer.push(&buffer, 42);

If the first parameter of a namespace function is the containing struct type or a pointer to it, the compiler permits dot-call syntax. No vtable, no dispatch table, nothing hidden. The Zig language reference describes struct functions as a convention rather than a language concept, which is unusually direct about the nature of the abstraction.

Dynamic dispatch in Zig requires explicit construction: a struct holding function pointers and an *anyopaque data pointer, following conventions the standard library establishes for Allocator, Writer, and similar interfaces. This aligns with Zig’s stated goal of no hidden control flow and no hidden allocations. The tradeoff is that the type system does not distinguish a struct method from a free function that happens to accept a struct pointer. The semantic weight of “this code belongs to this type” is absent; only naming and calling conventions carry that meaning.

Who Gets to Add Methods

Every language that supports methods implicitly answers a question about extensibility: who can attach new behavior to a type they did not define?

In Go, only the package that defines the type can add methods to it. This is unambiguous and conflict-free. To add behavior to a foreign type, you define a local wrapper.

Rust enforces coherence through orphan rules. You can implement a trait for a type only if your crate owns at least one of them. You cannot implement Display for Vec<i32> in your own crate because both come from the standard library. This prevents two crates from providing conflicting implementations of the same trait for the same type. The practical cost is the newtype pattern: when you need a foreign trait on a foreign type, you wrap the foreign type in a local struct and implement the trait on the wrapper.

D’s Uniform Function Call Syntax takes the opposite position. Any free function whose first parameter is of type T can be called with dot syntax on a value of T. No registration step, no coherence requirement. The D standard library’s std.algorithm and std.range are built entirely around this: filter, map, sort, and joiner are free functions that compose into lazy pipelines through dot-call syntax. The cost is that the source of a dot-called function is less obvious, since any in-scope function with a matching first parameter becomes callable this way.

C++ has received multiple UFCS proposals, including N4165 (2014) and P0251 (2016). All have failed, primarily over ambiguity in overload resolution when both member functions and free functions are candidates for the same call expression, and the risk of silently changing the behavior of code that relied on Argument-Dependent Lookup. C++20 Ranges addressed similar compositional needs through constrained function objects and operator| overloading, without touching name lookup rules.

What the Receiver Reveals

The design of the method receiver is a small surface area with large implications. Each language’s choice encodes a position on what the type system should express about the relationship between a value and the code that operates on it.

C++‘s implicit receiver prioritized backward compatibility with its Simula-derived heritage, and the debt accumulated over forty years until C++23 began addressing it. Rust’s typed receiver makes ownership semantics visible at every call site, trading concision for static verifiability. Go’s named receiver keeps extension natural within a package and draws a clear boundary at package edges. Zig’s non-receiver is a convention the compiler assists with, with no semantic weight beyond that.

These are not different approaches to the same problem so much as different answers to the question of where the cognitive load should sit: between the programmer reading a call and the language enforcing a contract. That distribution shapes systems-level code in ways that compound over the lifetime of a codebase, long before performance or allocation patterns become the primary concern.

Was this interesting?