· 7 min read ·

The Design Space Behind Method Receivers in Systems Languages

Source: lobsters

When you write obj.method() in any systems language, something has to decide what self means inside that method. In most object-oriented languages, this question is answered once and forgotten. In systems programming languages, it keeps coming back because ownership, mutability, and memory layout are first-class concerns, not implementation details the runtime hides.

The xoria.org article on methods in systems languages surveys how several modern languages handle this space. Rather than recapping that survey, it is worth tracing the underlying design pressures that forced each language to make different choices, and examining where those choices lead in practice.

C++‘s Original Sin: The Hidden Parameter

C++ inherited method dispatch from Simula and Smalltalk, languages designed around a different set of constraints. When Bjarne Stroustrup designed C++ in the early 1980s, methods became functions with a hidden this parameter. The pointer was implicit, the type was derived from the class, and the mechanism was invisible to the programmer.

This worked for decades. It also quietly locked in a set of assumptions: the receiver is always a pointer (or behaves like one), const is a property of the method rather than the receiver binding, and overloading const vs. non-const methods requires duplicate implementations.

struct Buffer {
    const char* data() const;  // const this
    char* data();               // non-const this
};

Both methods do the same thing, differ only in the const-ness of this, and must be written twice. Template tricks like CRTP let you deduplicate this with some ceremony, but the root cause is that this cannot be treated as an ordinary parameter.

C++23 finally addressed this directly with the explicit object parameter, commonly called “deducing this” after proposal P0847R7. You can now write:

struct Buffer {
    template <typename Self>
    auto& data(this Self& self) {
        return self.internal_ptr;
    }
};

The single template method works for both const and non-const callers. More significantly, this becomes a named, typed, ordinary parameter that you can pass to other functions or inspect in templates. It took forty years, two revisions of the standard, and a fairly involved proposal process to get here, which says something about how deeply the original design was embedded in the ecosystem.

Deducing this also simplifies CRTP patterns considerably. What previously required a base class template that cast this to the derived type can now be expressed as a single method taking a deduced Self parameter, with no inheritance required.

Go: The Receiver as a Commitment

Go takes a simpler position. Methods are functions where the receiver is an explicit first parameter with a special syntactic position:

func (b *Buffer) Write(p []byte) (int, error) {
    // b is a *Buffer
}

func (b Buffer) Len() int {
    // b is a copy of Buffer
}

The choice between value receiver (Buffer) and pointer receiver (*Buffer) is a deliberate design decision with concrete consequences. A value receiver gets a copy of the struct; a pointer receiver gets a reference to the original. Go’s interface satisfaction rules add another constraint: if any method in a type’s set uses a pointer receiver, you cannot satisfy an interface with a value of that type, only with a pointer to it.

This means you generally commit to one receiver kind per type. The Go FAQ recommends pointer receivers when the method needs to modify the receiver or when the struct is large enough that copying is wasteful. In practice, most non-trivial types use pointer receivers throughout, which makes the distinction feel somewhat ceremonial for many programs.

The design is consistent and learnable. It does not solve the duplicate-for-const problem because Go has no const, but it sidesteps the problem entirely through a simpler type system.

Rust: Receivers as Ownership Signals

Rust’s receiver design is where this question gets genuinely interesting, because the receiver kind is not just a performance hint or a syntactic choice. It carries ownership information that the borrow checker enforces.

impl Buffer {
    fn len(&self) -> usize { ... }          // shared reference
    fn push(&mut self, byte: u8) { ... }    // exclusive reference
    fn into_bytes(self) -> Vec<u8> { ... }  // consumes the buffer
}

The three common forms are &self (shared borrow), &mut self (exclusive mutable borrow), and self (takes ownership, caller cannot use the value afterward). This is not a style convention. The compiler enforces that a method taking &self cannot mutate the receiver, that &mut self cannot coexist with another live borrow of the same value, and that a method taking self by value consumes it.

Beyond the basic three, Rust supports arbitrary self types, allowing self: Box<Self>, self: Rc<Self>, self: Arc<Self>, and with the arbitrary_self_types feature, anything that implements Deref<Target = Self>. This makes method call syntax available even when the object lives behind a smart pointer. In async code, self: Pin<&mut Self> appears frequently in hand-written Future implementations:

impl Future for MyFuture {
    type Output = ();
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        // ...
    }
}

That is a level of specificity no other mainstream systems language puts in the receiver position. The tradeoff is that reading Rust method signatures requires understanding ownership, which has a genuine learning curve. When you see fn process(self) vs. fn process(&self), the difference is not aesthetic; it determines whether the caller retains access to the value after the call.

Rust also enforces what it calls orphan rules: you can only implement methods (via impl blocks) on a type if either the type or the trait being implemented is defined in the current crate. This prevents two different libraries from providing conflicting method implementations for the same type, a problem that becomes acute once method call syntax is available for types you did not define. The workaround is the newtype pattern, wrapping a foreign type in a local struct to gain the right to add methods.

Zig: No Special Syntax at All

Zig takes the most radical position by refusing to make the receiver special in any way. There is no method syntax distinct from function syntax. By convention, methods are functions defined in a struct’s namespace with a first parameter named self, but this is purely convention:

const Buffer = struct {
    data: []u8,
    len: usize,

    pub fn push(self: *Buffer, byte: u8) void {
        self.data[self.len] = byte;
        self.len += 1;
    }
};

You call it as buf.push(42), and Zig desugars this to Buffer.push(&buf, 42). The pointer-vs-value distinction is explicit in the type of self. There is no implicit reference, no const qualifier on the method, no special receiver grammar.

This reflects Zig’s broader design philosophy: make implicit behavior explicit. The approach means there is nothing to learn about receiver semantics beyond what you already know about function parameters and pointer types. The tradeoff is that you lose some expressive surface. You cannot write the consuming-self pattern from Rust, since Zig does not have move semantics in the same sense. You express ownership transfer through explicit allocation and deallocation rather than through the type system.

Swift: Mutation as an Annotation

Swift’s approach to value types handles mutation differently from all of the above. Methods on value types (structs and enums) are immutable by default; methods that modify the receiver must be marked mutating:

struct Buffer {
    var data: [UInt8]

    mutating func push(_ byte: UInt8) {
        data.append(byte)
    }

    func count() -> Int {
        return data.count
    }
}

The mutating keyword is a promise to the caller that this method may change the value. Swift enforces this at the call site: you cannot call a mutating method on a let-bound value. Unlike Rust’s borrow checker, Swift does not track aliasing at compile time for value types, relying instead on copy-on-write semantics for performance in the standard library’s collection types.

What the Differences Reveal

The receiver position in a method signature is a small surface with a lot of load-bearing decisions underneath. In C++, it encoded object identity and lifetime through a hidden pointer. In Go, it encodes whether you are working with a copy or a reference. In Rust, it encodes ownership and mutability in a way the compiler verifies across the entire program. In Zig, it deliberately refuses to encode anything special, treating the receiver as an ordinary pointer argument. In Swift, it separates the question of mutability from the question of reference versus value.

None of these approaches is universally correct. They reflect different answers to the question of how much the language should help you reason about memory, and how explicit that help should be.

C++23’s deducing this is particularly telling. It demonstrates that even a language with decades to get comfortable with its receiver model found the implicit design limiting enough to revisit. The proposal was not about adding new capabilities so much as correcting a structural asymmetry: this was always a parameter, but it was never treated as one. Making it explicit unlocked a set of patterns, particularly around const-correct method deduplication and recursive lambdas, that required awkward workarounds before.

Reading the receiver syntax of an unfamiliar systems language tells you more about its memory model than almost any other single feature. It is the point where design values about ownership, safety, and explicitness become concrete syntax.

Was this interesting?