Skip to main content

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

Receiverself typeLong formShorthand
a value typethe type itselffunc i32.clamped(self: i32, ...)func i32.clamped(self, ...)
a declared structthe non-null referencefunc Vec2.dot(self: &Vec2, ...)func Vec2.dot(&self, ...)
a declared arraythe non-null referencefunc Bytes.total(self: &Bytes)func Bytes.total(&self)
a packed structthe type itselffunc 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 redeclare i32.clz or f64.sqrt because those qualified functions already exist, but methods such as Point.clz and Counter.sqrt are 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) and func 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";