· 6 min read ·

The Receiver Problem: What Method Design Tells You About a Systems Language

Source: lobsters

Methods are a surface-level syntactic concern until you realize they encode fundamental assumptions about ownership, dispatch cost, and abstraction. This 2023 survey compares how several systems-focused languages handle them, and the differences are sharper than they look.

The C Non-Answer

C has no methods. It has functions and structs, and the ecosystem evolved conventions to bridge them. A file-handling library might expose FILE *fopen(...), int fclose(FILE *), size_t fread(void *, size_t, size_t, FILE *), where the struct pointer is always the last argument, or always the first, depending on which decade the library was written in. There is no enforcement, no tooling support, and no way to express “this function logically belongs to this type.”

The workaround that became idiomatic in C codebases that need polymorphism is the function pointer struct:

struct Allocator {
    void *(*alloc)(struct Allocator *self, size_t size);
    void  (*free) (struct Allocator *self, void *ptr);
};

This is a vtable, manually constructed. It works. It is also entirely opaque to the type system. The compiler cannot verify that an Allocator implementation provides the right signatures, and call sites look like alloc.alloc(&alloc, size), which is not ergonomic.

C++ and the Cost of Going First

C++ attached methods to types, added virtual dispatch through compiler-generated vtables, and introduced inheritance. For systems programming, the cost question is whether calling a method is a direct call or an indirect call through a pointer. Non-virtual methods in C++ compile to direct calls; the compiler knows the target at compile time. Virtual calls add one level of indirection: load the vtable pointer from the object, load the function pointer from the vtable, then call through it.

That indirection has a measurable cost in tight loops because it defeats branch prediction and prevents inlining. The performance-conscious C++ programmer ends up annotating types final to help the devirtualization pass, or avoids virtual dispatch in hot paths entirely. The language gives you methods that feel free and sometimes are not.

C++20’s concepts add a way to constrain templates without paying for virtual dispatch, which is the language’s belated acknowledgment that the static-vs-dynamic question mattered from the start.

Go: The Structural Interface Compromise

Go made a clean cut. Methods have explicit receiver syntax:

func (b *Buffer) Write(p []byte) (n int, err error) { ... }

Interfaces are structural: if your type has the right methods, it satisfies the interface, with no explicit declaration. This is elegant for library interoperability. A third-party type you cannot modify automatically satisfies your interface if it has the methods you need.

The receiver is explicit about pointer versus value semantics. A value receiver (b Buffer) gets a copy; a pointer receiver (b *Buffer) can mutate the original. The method sets differ: T includes only value-receiver methods, while *T includes both value and pointer receiver methods. If any method in an interface requires a pointer receiver, only *T satisfies that interface, not T, and Go enforces this at compile time.

Dynamic dispatch through interfaces uses a two-word representation: a pointer to the type information and a pointer to the data. Interface calls pay the indirection cost, and Go is transparent about when that happens. If you are calling through an interface variable, you pay for it.

Rust: The Receiver Taxonomy

Rust has impl blocks, which separate method definitions from type definitions:

impl Buffer {
    pub fn write(&mut self, data: &[u8]) { ... }
    pub fn into_bytes(self) -> Vec<u8> { ... }
    pub fn len(&self) -> usize { self.data.len() }
}

The receiver type is part of the borrow checker’s vocabulary. &self borrows immutably, &mut self borrows mutably, and self consumes the value. These are not just calling conventions; they participate in lifetime analysis. The compiler guarantees that a mutable borrow is exclusive, so &mut self methods cannot be called while any other borrow of the same value is live.

Trait-based dispatch gives Rust two distinct dispatch modes:

fn process<T: Serialize>(value: &T) { ... }  // monomorphized, static dispatch
fn process(value: &dyn Serialize) { ... }    // fat pointer + vtable, dynamic dispatch

The first form generates a separate compiled function for each concrete type. The second uses a fat pointer with a vtable, similar to C++‘s virtual methods. The caller chooses which model applies at each call site, and the distinction is syntactically clear.

impl Trait for Type blocks can appear anywhere, including in third-party crates, as long as either the trait or the type is local to the crate providing the implementation. This is the orphan rule, and it prevents conflicting implementations while still allowing genuine extension.

Zig: Methods as Convention

Zig has no method syntax in its type system, and yet it has method call syntax. If a struct declares a procedure that takes the struct type as its first parameter, you can call it with dot notation:

const ArrayList = struct {
    items: []u8,

    pub fn append(self: *ArrayList, item: u8) void {
        // ...
    }
};

var list = ArrayList{ .items = &.{} };
list.append(42);  // sugar for ArrayList.append(&list, 42)

The transformation is purely syntactic. There is no vtable, no hidden pointer, no dispatch mechanism. “Methods” in Zig are functions that happen to take their parent type as the first argument, and the dot-call syntax is shorthand. This means there is nothing to learn about method dispatch in Zig beyond ordinary function call semantics.

Dynamic dispatch in Zig is explicit and manual, often done with *const anyopaque plus a function pointer struct, which is the C pattern brought forward. The language is developing more ergonomic interface mechanisms through tagged unions and comptime interfaces, but the core philosophy remains: no cost you did not explicitly request.

Odin and the UFCS Question

Odin follows a similar convention to Zig: procedures take explicit receiver arguments, and dot-call syntax works as sugar when the first parameter matches the type of the receiver. Several other languages, including D and Nim, formalize this as Uniform Function Call Syntax (UFCS): any function f(x, ...) can be called as x.f(...) regardless of where f is defined.

UFCS is a strong argument against the need for methods as a first-class concept. If the only thing that makes x.write(data) better than write(x, data) is aesthetics and IDE autocomplete, then baking methods into the type system may be overcomplicated. The question is whether you also need the type system to enforce the relationship, for encapsulation or trait coherence.

Rust’s trait system enforces coherence: each (Trait, Type) pair can only have one implementation in a given compilation, which prevents ambiguity. UFCS languages generally do not offer that guarantee, which makes large codebases harder to audit for conflicting behavior.

What the Design Choice Actually Signals

The way a language handles methods compresses several orthogonal design decisions: ownership (who holds the receiver and in what form), dispatch cost (static versus dynamic, and how visible the cost is to the programmer), and extensibility (can you add methods to types you do not own, and under what rules).

C answered “no methods” and left everything to convention. C++ answered “methods everywhere, virtual dispatch available, you figure out the cost.” Go drew a clean line between static and interface dispatch. Rust separated the borrow semantics from the dispatch mechanism and gave explicit control over both. Zig said methods are fiction and gave you the fiction anyway as syntax sugar, with nothing hidden behind it.

None of these is wrong. They reflect different priorities. If you are writing an operating system kernel, Zig’s “no hidden costs” philosophy aligns well with the mental model you need. If you are building a large application with pluggable components, Rust’s trait system gives you safety guarantees that manual vtables in C cannot provide. If you care about fast iteration and readable code, Go’s structural interfaces reduce ceremony without hiding the dispatch cost.

The original survey of these patterns is worth reading for the side-by-side comparisons, particularly its treatment of how languages handle methods defined outside the type’s original module. That specific question, extension methods versus orphan rules versus open classes, turns out to be where design philosophies diverge most sharply.

For systems programming specifically, the meaningful divide is between languages where you can read a call site and know whether it involves a pointer indirection, and languages where that requires understanding the full type hierarchy. The first category tends to produce code that is easier to reason about under performance constraints. The second tends to compose more flexibly at the cost of local clarity.

The trend in newer systems languages is toward the first category. That is probably the right direction.

Was this interesting?