The Receiver Is the Easy Part: How Systems Languages Actually Differ on Methods
Source: lobsters
At first glance, the method syntax debate looks like a bikeshedding exercise. Whether you write rect.area() or area(&rect) is a surface-level concern. But a careful survey of how systems languages handle methods reveals that the design decisions underneath are substantial: where methods live relative to type definitions, whether the receiver is named and visible, how mutation is signaled, and whether dynamic dispatch is opt-in or pervasive. These choices interact with ownership models, compilation strategies, and the cost of indirection in ways that compound over a codebase.
The interesting axis is not method syntax. It is what the receiver reveals.
The C Baseline: Everything Is Manual
C has no methods, and that is a feature. The language forces you to write what actually happens:
struct Vec2 { float x, y; };
float Vec2_dot(const struct Vec2 *a, const struct Vec2 *b) {
return a->x * b->x + a->y * b->y;
}
The receiver is just the first argument. Naming conventions (Vec2_) provide namespacing. When you want runtime polymorphism, you build it yourself:
struct AnimalVtable {
void (*speak)(struct Animal *);
void (*destroy)(struct Animal *);
};
struct Animal {
const struct AnimalVtable *vtable;
char *name;
};
This is what the Linux kernel does with struct file_operations and struct net_device_ops. GObject and Cairo take it further, layering a full type hierarchy on top of C structs. The cost of this approach is that every abstraction layer you build must be maintained by hand. The benefit is that nothing is hidden. You can read the vtable initialization and know exactly what gets called.
Every more sophisticated language is building structure on top of what C forces you to spell out manually. The design differences lie in what gets hidden and what stays visible.
C++ and the Invisible Receiver
C++ hides this. It is an implicit pointer, unnameable in the parameter list, not forwarding-compatible, and unavailable for template deduction. For decades, writing a single method that worked correctly for both lvalue and rvalue instances of the class required three overloads:
T& value() & { return m_value; }
const T& value() const & { return m_value; }
T value() && { return std::move(m_value); }
This is not a contrived edge case. Any accessor that returns a member by value or reference hits this problem immediately. Libraries like Abseil and folly have large swaths of code that exists purely because of this limitation.
C++23 fixes it with P0847R7, the explicit object parameter (commonly called “deducing this”):
struct Rect {
int w, h;
template<typename Self>
auto& value(this Self&& self) {
return std::forward<Self>(self).m_value;
}
int area(this const Rect& self) {
return self.w * self.h;
}
};
The this keyword now appears as a parameter prefix rather than a hidden qualifier. It can be named, deduced, and forwarded. Recursive lambdas become possible ([](this auto& self, int n) { ... }). CRTP patterns that previously required macros or template gymnastics become straightforward.
What this amounts to is C++ acknowledging, twenty years after Rust started designing explicitly, that the named receiver is better. The convergence is not accidental.
Rust: Ownership at the Signature
Rust makes the receiver a first-class part of every method signature. You pick one of several forms:
impl Rect {
fn new(w: u32, h: u32) -> Self { Rect { w, h } } // associated function, no receiver
fn area(&self) -> u32 { self.w * self.h } // shared borrow
fn scale(&mut self, f: u32) { self.w *= f; self.h *= f; } // exclusive borrow
fn consume(self) -> u64 { (self.w * self.h) as u64 } // move, caller loses ownership
}
The receiver tells you something real. &self means this call cannot modify the value and the caller retains ownership. &mut self means exclusive access is required for this call. self means the value is consumed. This is information that was previously buried in function bodies or documentation.
Dynamic dispatch is opt-in and visible at the call site:
fn static_call<T: Animal>(a: &T) { a.speak(); } // monomorphized, zero overhead
fn dynamic_call(a: &dyn Animal) { a.speak(); } // fat pointer, vtable dispatch
&dyn Animal is a 16-byte fat pointer: one pointer to the data, one to the vtable. The vtable holds the method pointers plus drop glue, size, and alignment. Choosing &dyn Animal instead of a generic bound is an explicit architectural decision, not something that happens invisibly because a class has a virtual method.
Rust’s method resolution during a call x.foo() involves an auto-deref chain: try T::foo(x), then &T::foo(&x), then &mut T::foo(&mut x), then deref T to its target type and repeat. This means my_box.len() on a Box<Vec<u8>> works without explicit dereferencing. The ergonomics are good, but the chain can surprise you when two traits in scope both offer a method by the same name. The fully qualified syntax <Type as Trait>::method(&value) resolves the ambiguity, and it’s worth knowing how to write it.
Zig: Methods Are Not Real
Zig takes the most minimal stance of any language in this space. There are no methods. There is dot-call syntax that is pure sugar:
const Vec2 = struct {
x: f32,
y: f32,
pub fn dot(self: Vec2, other: Vec2) f32 {
return self.x * other.x + self.y * other.y;
}
pub fn scale(self: *Vec2, factor: f32) void {
self.x *= factor;
self.y *= factor;
}
};
var v = Vec2{ .x = 1.0, .y = 2.0 };
v.scale(2.0); // exactly Vec2.scale(&v, 2.0)
The pointer vs. value distinction is in the type annotation on self, not a keyword. self: Vec2 takes a copy. self: *Vec2 takes a mutable pointer. There is no ambiguity, no implicit conversion, nothing hidden.
Runtime polymorphism in Zig requires you to build it: tagged unions for closed sets, or structs of function pointers for open dispatch. Comptime polymorphism covers most of what traits or templates handle in other languages:
fn printAnimal(animal: anytype) void {
animal.speak(); // resolved at compile time; any type with a speak() method works
}
anytype is duck-typed at comptime. There is no vtable, no fat pointer, no indirection. If the concrete type does not have a speak method, you get a compile error with the specific type in the message.
Zig’s stance is that methods are a presentation layer on top of functions, and the language should be honest about that rather than build machinery around the illusion.
Go: Methods Anywhere, Generics Nowhere on Methods
Go occupies a different position. Methods can be defined on any named type in the same package, including non-struct types:
type Celsius float64
func (c Celsius) ToFahrenheit() float64 {
return float64(c)*9/5 + 32
}
The receiver is named explicitly in the function signature, separate from the parameter list. Value vs. pointer receiver has concrete implications for interface satisfaction: if any method in an interface requires a pointer receiver, only the pointer type satisfies the interface, not the value type. This trips up Go newcomers reliably, and the language could do more to surface the error earlier.
Go 1.18 added generics, but with a deliberate restriction: you cannot parameterize methods with additional type parameters. Only the receiver type’s parameters are available to methods. This is a documented design choice that keeps method dispatch simple and avoids the complexity of Haskell-style type class inference. Whether it is the right call depends on how much generic programming you need to do on methods, and the answer for most Go code is “not much.”
The Spectre Problem with Virtual Dispatch
Any language that uses vtables for dynamic dispatch runs into the post-Spectre cost of indirect calls. Before Spectre v2 (branch target injection) was disclosed in 2018, a virtual call on x86-64 was roughly: load the vptr from the object, index into the vtable, indirect call. Two loads and one indirect branch, costing perhaps 3-5 cycles on a warm cache path.
After Spectre, indirect branches became a problem. The Linux kernel, compilers, and system libraries adopted retpolines: a software mitigation that prevents speculative execution from racing ahead through an indirect branch by substituting the indirect call with a return-trampolining sequence. On pre-eIBRS hardware (Skylake and earlier without hardware mitigation), this costs 15-30 extra cycles per indirect call.
Rust’s dyn Trait, C++ virtual calls, and C function pointer calls all pay this tax on older hardware. Intel’s eIBRS (available on Skylake-SP and Ice Lake and newer) restores hardware isolation and eliminates the need for software retpolines, so the cost largely disappears on recent server hardware. But the underlying point stands: indirect dispatch has a measurable cost, and making it opt-in rather than default is a defensible systems programming decision.
The Trend Is Explicit
Looking across C++23’s deducing-this, Rust’s receiver forms, Zig’s honest sugar, and Go’s named receivers, the direction in systems language design is consistent. Newer languages and recent revisions to older ones are moving toward:
- Named receivers that appear in the parameter list, not as invisible qualifiers
- Mutation and ownership signaled at the signature, not discovered in the body
- Methods defined outside the type (impl blocks, extensions, package-level definitions) so types stay navigable
- Dynamic dispatch as an explicit opt-in, with static dispatch as the default path
C++23 taking the explicit receiver from Rust rather than the other direction is a reasonable signal about where the ergonomics and correctness arguments are converging. The deducing-this paper spent years in committee and landed with broad support. That does not happen unless a significant portion of experienced C++ programmers agree the old model was limiting.
For anyone writing a new systems language or designing an API in an existing one, the question of where methods live and what the receiver looks like is worth settling early. The surface syntax is the easy part. The semantics it encodes, ownership, mutability, dispatch strategy, are what actually determine how a codebase scales.