Methods
A function's name may be qualified by a type, which makes it a method on that type:
struct Vec2 {
x: i32,
y: i32,
}
func Vec2.dot(self: &Vec2, other: &Vec2) -> i32 {
return self->x * other->x + self->y * other->y;
}
func Vec2.scaled(self: &Vec2, factor: i32) -> &Vec2 {
return new Vec2 { x: self->x * factor, y: self->y * factor };
}
func i32.clamped(self: i32, low: i32, high: i32) -> i32 {
if (self < low) { return low; }
if (self > high) { return high; }
return self;
}
func check() -> i32 {
let a: &Vec2 = new Vec2 { x: 3, y: 4 };
let b: &Vec2 = a.scaled(2);
// 3*6 + 4*8 == 50, clamped into 0..=20 gives 20.
return a.dot(b).clamped(0, 20) - 20;
}
export func check as "check";
The receiver goes in the first parameter, which must be named self. Calling
a.dot(b) passes a as self, so a method call is the same work as the direct call
Vec2.dot(a, b) — and that direct call is legal too, because of the single idea this
whole feature rests on:
A method is an ordinary function with a dotted name.
There is no method namespace, no receiver-based mangling, and no dispatch table. Vec2.dot
is one name in the one function namespace, so everything you can already do with a function
works unchanged. Reed's default core library uses this same mechanism for numeric postfix
operations such as i32.clz, i64.div_u, f64.sqrt, and i64.mul_wide_u.
type Un = func(i32) -> i32;
func i32.triple(self: i32) -> i32 {
return self * 3;
}
// Exported under any external name you like.
export func i32.triple as "triple";
// Referenced, and stored in a table or elem segment.
table Fns(1 ?Un) = { &i32.triple };
elem Spares: ?Un = { &i32.triple };
func check() -> i32 {
let r: &Un = &i32.triple;
// Three spellings, one function: by name, through a reference, as a method.
return i32.triple(1) + r(1) + (1).triple() - 9;
}
export func check as "check";
The emitted WAT shows the same thing — a method is just a func whose name contains a dot,
and a method call is just a call:
;; func i32.triple(...)
(func $i32.triple
(param $self_0 i32)
(result i32)
;; return self * 3;
(return (i32.mul (local.get $self_0) (i32.const 3))))
;; export func i32.triple as "triple";
(export "triple" (func $i32.triple))
The self shorthand
Writing the receiver's type out is redundant — the qualifier already said it — so the
first parameter may be written as bare self or &self, with no : type:
struct Vec2 { x: i32, y: i32 }
array Bytes { mut i32 }
func Vec2.sum(&self) -> i32 {
return self->x + self->y;
}
func Bytes.first(&self) -> i32 {
return self[0];
}
func i32.tripled(self) -> i32 {
return self * 3;
}
func check() -> i32 {
let v: &Vec2 = new Vec2 { x: 3, y: 4 };
let b: &Bytes = new Bytes { 11, 12 };
return v.sum() + b.first() + (2).tripled() - 24;
}
export func check as "check";
The sigil follows the receiver: &self where the receiver is a reference (a struct or
array), plain self where it is a value (a value type or a
packed struct). The two are not interchangeable, and writing the
wrong one is an error rather than a shrug:
func Vec2.sum(self) -> i32 { ... }
resolution error: method 'Vec2.sum' receives &Vec2, so its receiver shorthand
is '&self', not 'self'
That is deliberate. Vec2.sum(self) reads as taking a Vec2 by value and
i32.tripled(&self) reads as taking a reference to an integer; Reed has neither, so
accepting them would make the sigil decorative here while it stays load-bearing
everywhere else. There is no ?self at all — a nullable type has no methods to declare.
The shorthand is pure sugar: it expands to exactly self: <receiver type>, so everything
below applies to it unchanged, and the long form stays valid. self is also not a
reserved word — an ordinary function may still have a parameter named self, as long as
it gives it a type like any other.
What a receiver may be
| Receiver | self type | Long form | Shorthand |
|---|---|---|---|
| a value type | the type itself | func i32.clamped(self: i32, ...) | func i32.clamped(self, ...) |
| a declared struct | the non-null reference | func Vec2.dot(self: &Vec2, ...) | func Vec2.dot(&self, ...) |
| a declared array | the non-null reference | func Bytes.total(self: &Bytes) | func Bytes.total(&self) |
| a packed struct | the type itself | func Rgb.luma(self: Rgb) | func Rgb.luma(self) |
i32, i64, f32, f64, and v128 all work as receivers, including v128 — which has
no operators or literals of its own, so a method is a natural way to give it a readable
surface:
func v128.first_lane(self: v128) -> i32 {
return wasm.i32x4.extract_lane<0>(self);
}
func check() -> i32 {
let v: v128 = wasm.v128.const<7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>();
return v.first_lane() - 7;
}
export func check as "check";
A function type (type Un = func(i32) -> i32;) cannot be a receiver: it names a signature,
not a value with members. A nullable reference cannot either — see
nullable receivers below.
Inherited methods
A method declared on a struct is callable on any struct that inherits from it. A subtype's own method of the same name wins:
struct Shape {
kind: i32,
}
struct Circle : Shape {
radius: i32,
}
func Shape.describe(self: &Shape) -> i32 {
return self->kind;
}
func Circle.describe(self: &Circle) -> i32 {
return self->radius;
}
func Shape.kind_of(self: &Shape) -> i32 {
return self->kind;
}
func check() -> i32 {
let c: &Circle = new Circle { kind: 1, radius: 9 };
let s: &Shape = c;
// `Circle.describe` on the Circle-typed binding; `Shape.describe` on the Shape-typed
// one, even though it holds the very same Circle. Dispatch reads the *static* type.
// `kind_of` is inherited: Circle declares no such method, so Shape's is found.
return c.describe() - 9 + s.describe() - 1 + c.kind_of() - 1;
}
export func check as "check";
Dispatch is static. It resolves against the receiver expression's declared type, not
against what the value turns out to be at runtime. That is why s.describe() above calls
Shape.describe — and it is what keeps a method call exactly as cheap as the equivalent
function call, with no vtable and nothing to devirtualize.
Imported methods
A host function can be a method too, so a type has that method only if the import supplies it:
import "env"."triple" as func i32.triple(i32) -> i32;
func check() -> i32 {
let v: i32 = 7;
return v.triple();
}
export func check as "check";
An import declares parameter types with no names, so there is no self to name; the first
declared type still has to be the receiver's.
Literal receivers
A parenthesized literal has no type of its own, and a method's declared receiver type supplies one:
func i64.doubled(self: i64) -> i64 {
return self * 2;
}
func check() -> i32 {
let v: i64 = (21).doubled();
return v == 42 ? 0 : 1;
}
export func check as "check";
The parentheses are required: 21.doubled() starts lexing 21. as a float, which is true of
every method call on a numeric literal, not just this one.
If two value types declare a method by the same name, a bare literal receiver becomes ambiguous and needs an explicit type, since resolving it would otherwise depend on declaration order:
func i32.thing(self: i32) -> i32 { return self; }
func i64.thing(self: i64) -> i64 { return self; }
func check() -> i32 {
// error: integer literal requires a contextual 'i32' or 'i64' type
return (1).thing();
}
Give the receiver a typed binding, or call by name (i32.thing(1)).
Nullable receivers
A nullable reference has no methods. A method body dereferences its receiver, so a call on a possibly-null value would have to trap; narrowing first is the fix:
struct Node {
value: i32,
}
func Node.value_of(self: &Node) -> i32 {
return self->value;
}
func check() -> i32 {
let maybe: ?Node = new Node { value: 7 };
if (maybe is &Node) {
return maybe.value_of() - 7;
}
return 1;
}
export func check as "check";
Node.value_of(maybe) works inside that same narrowed branch, for the same reason. See
type tests and narrowing for the narrowing rules themselves.
Across files
pub makes a method visible to other files under its qualified name, because that is its
name. So an import spells it qualified:
// util.reed
pub struct Point {
x: i32,
y: i32,
}
pub func Point.sum(self: &Point) -> i32 {
return self->x + self->y;
}
pub func i32.triple(self: i32) -> i32 {
return self * 3;
}
// main.reed
use util.{Point, Point.sum};
use util.{i32.triple};
func check() -> i32 {
let p: &Point = new Point { x: 4, y: 5 };
return p.sum() - 9 + (2).triple() - 6;
}
export func check as "check";
Importing sum instead of Point.sum is an error, and the diagnostic names the spelling to
use. A glob import (use util.*;) brings methods in like any other visible name,
with no qualified spelling needed.
Nothing else is special here: the loader merges every file into one module before resolution, so a method in one file and its receiver type in another find each other with no cross-file rule at all. See Modules.
Rules and restrictions
A method declaration is rejected when:
- its name is a reserved method-postfix name, or its qualified name already exists. The
compiler still reserves array methods (
copy,fill) and memory/table/data/elem forms (grow,init,size, ...), because those are resolved without consulting declared methods. Numeric operations are different:clz,div_u,sqrt,copysign,mul_wide_u, and friends are ordinary methods supplied by the default core library. You cannot redeclarei32.clzorf64.sqrtbecause those qualified functions already exist, but methods such asPoint.clzandCounter.sqrtare just ordinary user methods. See the core library reference for the full list of names it owns. - its first parameter is missing, misnamed, or the wrong type. The receiver is
parameter zero, so
func i32.thing(value: i32)andfunc i32.thing(self: i64)are both errors. - the qualifier is not a type, or is a function type.
Methods may carry @inline. The receiver is still argument zero: a call
like x.m(y) evaluates x, then y, binds those values to self and the remaining
parameters, and substitutes the method body. As with any other @inline function, an inline
method is not emitted as a callable function and cannot be exported, referenced with &, or
placed in a table or elem segment.
Declaration order does not matter — a method may be written above the struct it applies to,
like every declaration kind except macro.
Evaluation order
The receiver is argument zero, so it is evaluated first, then each argument left to right, each exactly once:
global Log: mut i32 = 0;
func note(bit: i32) -> i32 {
Log = Log * 10 + bit;
return bit;
}
func i32.combine(self: i32, a: i32, b: i32) -> i32 {
return self * 100 + a * 10 + b;
}
func check() -> i32 {
let r: i32 = note(1).combine(note(2), note(3));
// Both the result and the log read 123: receiver, then arguments in order.
return r - 123 + Log - 123;
}
export func check as "check";