The method receiver is the part of a function signature most languages treat as bookkeeping. Whether it is the implicit this pointer in C++, the explicit self in Rust, or the named receiver in Go, most documentation describes it as a calling convention: a way of passing the object the method operates on. That framing undersells the design decision embedded in each approach.
The receiver does not just describe how the object is passed. It communicates what the caller is giving up, what the callee can do with it, and under what conditions the call is even legal. Different systems languages have arrived at very different answers to how much of that information belongs in the type system versus how much gets left to documentation and convention.
Go: One Bit of Information
Go gives you two choices for a method receiver: value or pointer.
type Counter struct{ count int }
func (c Counter) Value() int { return c.count } // value receiver: copy
func (c *Counter) Increment() { c.count++ } // pointer receiver: mutates original
The distinction is meaningful. A value receiver gets a copy of the struct; a pointer receiver gets the address of the original. If you need to modify state, you use a pointer receiver. If you only read, either works, though the Go FAQ recommends consistency: if any method on a type uses a pointer receiver, all should.
This simplicity has a hidden sting. The Go specification’s method set rules state that a value of type T has only the method set of T (value receiver methods), while a pointer *T has the method set of both T and *T. This matters the moment you assign to an interface:
type Stringer interface{ String() string }
type MyInt int
func (m *MyInt) String() string { return fmt.Sprintf("%d", *m) }
var i MyInt = 42
var s Stringer = &i // OK
var s2 Stringer = i // compile error: MyInt does not implement Stringer
The error is correct. Go cannot implicitly take the address of a value when assigning to an interface, because the interface stores a copy and taking the address of a copy would mislead the caller about which value gets modified. But the consequence is that a single receiver choice, made when you first write a method, determines whether a value of your type can satisfy interfaces directly or only through a pointer. That is a significant downstream effect for a two-option decision.
What Go’s receiver system cannot express: whether a method consumes the receiver, whether the caller retains any claim to the value after the call, or whether the value must be in a particular memory configuration to be used at all.
C++: Everything Hidden, Nothing Enforced
C++ went the other direction: hide the receiver entirely. The compiler injects a this pointer into every non-static member function. The type of this is constrained by the cv-qualifiers and ref-qualifiers on the method:
struct Buffer {
const char* data() const; // this: const Buffer*
char* data(); // this: Buffer*
void consume() &&; // callable only on rvalues
};
The ability to overload on rvalue-ness (the && ref-qualifier) does let C++ express something like a consuming method: you can write a method that only compiles when called on a temporary or an explicitly moved value. But the compiler does not enforce that the object is gone after the call. The caller can still access a moved-from object; the language leaves it in an unspecified but destructible state. The contract exists in documentation, not in the type system.
The deeper limitation is that this is always a pointer; you cannot make a method that receives the object by value and consumes it in the strict sense. Any workaround requires template tricks or additional overloads, and the resulting duplicate const/non-const overloads are a well-known C++ maintenance burden.
The C++23 deducing-this proposal (P0847) addresses a significant portion of this. By making the receiver an explicit, deducible first parameter, it allows a single template to replace four const/non-const/lvalue/rvalue overloads, and enables genuine value receivers:
struct Builder {
template<typename Self>
auto&& field(this Self&& self, std::string v) {
self.field_ = std::move(v);
return std::forward<Self>(self);
}
// Value receiver: genuinely consumes the object
Product build(this Builder self) {
return Product{std::move(self.field_)};
}
private:
std::string field_;
};
After calling .build() on a Builder, the Builder is gone in a real sense: it was passed by value and moved into the function. C++ still does not prevent you from accessing the moved-from builder afterward, but at least the by-value receiver makes the intent legible in the signature itself.
Rust: The Receiver as an Ownership Contract
Rust’s receiver types are where the concept of “the receiver encodes a promise” becomes most concrete. The choices available in an impl block include:
impl MyType {
fn read(&self) {} // shared borrow: any number may coexist
fn write(&mut self) {} // exclusive borrow: no other references allowed
fn consume(self) {} // moved in: caller loses access after the call
fn boxed(self: Box<Self>) {} // consumes a heap allocation
fn shared(self: Arc<Self>) {} // takes ownership of a reference count
fn pinned(self: Pin<&mut Self>) {} // exclusive borrow, movement prevented
}
Each of these is not a calling convention choice; it is a statement about invariants the borrow checker will enforce. Call a &self method and you can have ten other references to the object in scope simultaneously. Call a &mut self method and the compiler guarantees no other reference exists for the duration of the call. Call a self method and the compiler rejects any use of the original binding afterward.
The consuming receiver (self by value) enables builder APIs with a compile-time guarantee that no other systems language provides:
struct Request {
url: String,
timeout: Option<Duration>,
}
impl Request {
fn url(mut self, url: impl Into<String>) -> Self {
self.url = url.into();
self
}
fn timeout(mut self, d: Duration) -> Self {
self.timeout = Some(d);
self
}
fn send(self) -> Response {
// self is consumed; the caller cannot reuse the half-built request
http::execute(self.url, self.timeout)
}
}
let resp = Request::new()
.url("https://example.com/api")
.timeout(Duration::from_secs(10))
.send();
// Any attempt to use the builder after .send() is a compile error
Go has no consuming receivers. C++ has move semantics but no enforcement. Zig has explicit pointer-vs-value but no borrow checker. Rust’s consuming receiver makes certain categories of use-after-call bugs unrepresentable.
Pin<&mut Self>: The Deepest Receiver
The most unusual entry in Rust’s receiver catalog is self: Pin<&mut Self>. It is the receiver type required by the Future trait, and it exists because async state machines have a property no other common object has: after they begin executing, they must not move in memory.
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
When the compiler desugars an async fn into a state machine, that machine can contain references to its own fields: a local variable in one await arm may reference a buffer created in an earlier arm. If the state machine moved in memory, those self-referential pointers would become dangling. Pin<&mut Self> is a mutable reference wrapped in a type that statically prevents the object from being moved after pinning. The receiver carries the invariant directly in the type.
No calling convention trick produces this. It requires the type system to reason about memory location stability, which is why it appears as a receiver type rather than a documentation note or a runtime check. This is the endpoint of the “receiver as contract” design: the receiver type is not just describing how the object is passed, it is describing a constraint on the object’s entire lifetime that the compiler verifies at each call site.
Zig: The Minimal Position
Zig takes the opposite stance from Rust. There is no method syntax in the language; there are only functions. The apparent method call v.scale(2.0) is syntactic sugar for Vec2.scale(&v, 2.0), resolved by the compiler based on the type of the first parameter:
const Vec2 = struct {
x: f32,
y: f32,
pub fn scale(self: *Vec2, factor: f32) void {
self.x *= factor;
self.y *= factor;
}
};
var v = Vec2{ .x = 3, .y = 4 };
v.scale(2.0); // compiler inserts &v automatically
The receiver’s type in the signature is the complete specification. *Vec2 means a pointer. Vec2 means a copy. There is no separate annotation system, no overloading on const-ness, and no borrow checker. What you see in the function signature is precisely what the function receives. The Zig documentation treats methods as a namespace convenience rather than a language feature.
Zig cannot express consuming semantics at the type system level. A function that takes Vec2 by value operates on a copy; the original is unaffected. If you want to communicate that an operation logically consumes the object, you document it. Runtime polymorphism requires an explicit vtable struct, written by hand, which makes the dispatch cost visible:
const Drawable = struct {
ptr: *anyopaque,
drawFn: *const fn (*anyopaque) void,
pub fn draw(self: Drawable) void {
self.drawFn(self.ptr);
}
};
There is no hidden indirection. You wrote the vtable; you see the vtable.
What the Disagreement Is About
These four languages have each drawn a line on how much of an ownership model belongs in the receiver type. Go: almost none, just mutability. C++ (pre-23): almost none, with the mechanism hidden by convention. C++23: more, through deducible templates and value receivers. Rust: a great deal, through ownership types enforced by the borrow checker. Zig: the minimum, with explicit pointer types and no enforcement beyond that.
The source article on methods in systems programming languages surveys this design space and finds that the differences are not cosmetic. Each receiver design enables certain API patterns and forecloses others. Rust’s consuming receiver makes certain categories of use-after-call bugs impossible to write. Zig’s approach makes certain categories of hidden cost impossible to introduce. Go’s simplicity makes certain interface design decisions impossible to defer.
None of these positions is strictly superior. They reflect different answers to the same question: how much of the ownership model should the language verify for you, and what are you willing to pay in complexity for that verification. The trend across recent language versions, including C++23’s deducing-this and Rust’s ongoing work on arbitrary self types, is toward more information in the receiver rather than less. What that suggests is that the ergonomics cost of explicit receivers has turned out lower than the debugging cost of receivers that hide too much.