Skip to main content

Generics

Reed has no type parameters, and structurally cannot get them: a compilation unit becomes one WebAssembly GC module with nominal, source-derived type names and no mangling. What it has instead is a comptime func: a compile-time function whose parameters are kinded (a type, a name, a number) and whose body is a template of declarations. Instantiating it writes those declarations into your module, as if you had typed them.

comptime func box_of(T: type, Name: ident, prefix: ident) {
  pub struct $Name { value: mut $T }

  pub func [<new_ $prefix>](v: $T) -> &$Name {
    return new $Name { value: v };
  }

  pub func $Name.get(self: &$Name) -> $T { return self->value; }
  pub func $Name.set(self: &$Name, v: $T) { self->value = v; }
}

comptime box_of(i32, IntBox, int_box);
comptime box_of(f64, FloatBox, float_box);

func check() -> i32 {
  let i: &IntBox = new_int_box(20);
  i.set(i.get() + 2);

  let f: &FloatBox = new_float_box(0.5);

  if (i.get() != 22) { return 1; }
  if (f.get() != 0.5) { return 2; }
  return 0;
}

export func check as "check";

IntBox and FloatBox are two genuinely different WebAssembly GC types, with unboxed fields and static dispatch. This is monomorphization, done in the source language rather than by a type checker.

Defining a generator

comptime func name(param: kind, ...) { declaration* }

Each parameter names what its argument must be:

KindThe argument must beUsed in the body as
typea complete type: i32, &Point, ?any$T, in any type position
identexactly one identifier$Name, and inside [<...>] to build new names
i32, i64a compile-time-known integer$N, and by bare name in comptime if/comptime for
booltrue/false, or a comptime expressionas above
stringa string literalstr!(Name), [<...>] (never at runtime)

The body is a template. $T and $Name substitute an argument's tokens, [<a $b>] pastes fragments into one new identifier, and str!(x) builds a string literal — the same three forms comptime for/comptime if use, because this is the same expansion pass.

A generator's name must be snake_case, and, like a macro, it must be defined before it is instantiated: expansion is a single forward pass.

Instantiating

comptime name(argument, ...);

Declaration position only — a generator produces declarations, so there is nothing an expression-position instantiation could mean.

The declarations it produces are ordinary declarations from that point on. They occupy the same flat namespace, obey the same naming rules, and are exported, referenced, and emitted exactly like ones you wrote out. Your editor's outline lists them; go-to-definition finds them.

Why two names, Name and prefix

Reed's naming convention is enforced, not cosmetic: a type must be PascalCase and a function must be snake_case, and a PascalCase name may not contain an underscore at all. So a generator cannot derive new_IntBox from IntBox — that is a resolution error, not a style nit. Generators that declare both a type and functions therefore take one name of each kind.

Why not a macro?

Macros can also generate declarations, and for token-shaped work they remain the right tool — $(...) repetition, arbitrary tt splicing, and expression-position expansion are all things a comptime func deliberately does not do. Three things a macro cannot do are exactly what a container generator needs:

Arity is checked. A macro's expr fragment swallows commas, so a one-capture pattern matches a two-argument call; a macro physically cannot reject a mis-arity invocation. A generator names what it wanted:

comptime func 'box_of' takes 3 arguments (T: type, Name: ident, prefix: ident),
but 2 were supplied

A type argument is really parsed. A macro's $T:ty is matched by shape — it accepts ?nonsense and the failure surfaces somewhere inside the expansion. A type parameter runs the argument through the real type grammar, at the call:

comptime func 'box_of' parameter 'T' expects a type, but '?' is not one

Diagnostics name parameters. A failed macro invocation reports why each arm did not match. A failed instantiation reports which parameter rejected which argument.

Composition

A generator body may contain anything a module can, including comptime for, comptime if, a macro invocation, or another instantiation. A generator forwarding its own parameters to another is the ordinary way to build a bigger one out of smaller pieces:

comptime func slot_of(T: type, Name: ident, prefix: ident) {
  pub struct $Name { value: mut $T }
  pub func [<new_ $prefix>](v: $T) -> &$Name { return new $Name { value: v }; }
  pub func $Name.get(self: &$Name) -> $T { return self->value; }
}

comptime func two_slots_of(T: type, Base: ident, prefix: ident) {
  comptime slot_of($T, [<$Base A>], [<$prefix _a>]);
  comptime slot_of($T, [<$Base B>], [<$prefix _b>]);
}

comptime two_slots_of(i32, Reg, reg);

func check() -> i32 {
  let a: &RegA = new_reg_a(30);
  let b: &RegB = new_reg_b(12);
  return a.get() - b.get() - 18;
}

export func check as "check";

A value parameter is readable by bare name in a nested comptime if/comptime for, so a generator can vary what it declares by its arguments, not just what types they use:

comptime func counters(N: i32, Base: ident, prefix: ident) {
  comptime for i in 0..N {
    global [<$Base $i>]: mut i32 = $i;
    pub func [<$prefix _read $i>]() -> i32 { return [<$Base $i>]; }
  }

  comptime if (N > 2) {
    pub func [<$prefix _is_wide>]() -> i32 { return 1; }
  } else {
    pub func [<$prefix _is_wide>]() -> i32 { return 0; }
  }
}

comptime counters(3, Slot, slot);

func check() -> i32 {
  return slot_read0() + slot_read1() + slot_read2() + slot_is_wide() - 4;
}

export func check as "check";

Instantiating the same thing twice is fine

Requesting an instantiation that already happened, with the same arguments, does nothing — it is not a duplicate-declaration error. This matters more than it looks: a compilation unit is merged into one module, so a library that instantiates a container on your behalf and your own instantiation of the same one would otherwise collide, with neither side able to see the other.

Two instantiations with different arguments that declare the same name do still collide, as they must — they really are two different declarations of one name.

A generator's extra declarations are not your problem

A generator typically declares more than any one caller uses. Those extras are not reported as unused, and the ones nothing reaches are not emitted into your module. You cannot edit someone else's generator to silence a warning, so the compiler does not raise one.

The standard library's generic containers

std.vec is this feature applied to the obvious case:

use std.vec.*;

comptime vec_of(i32, IntVec, int_vec);
comptime vec_search_of(i32, IntVec);

func check() -> i32 {
  let v: &IntVec = new_int_vec();
  for i in 0..25 { v.push(i * 2); }

  if (v.len() != 25) { return 1; }
  if (v.at(3) != 6) { return 2; }
  if (v.index_of(8) != 4) { return 3; }

  v.reverse();
  if (v.at(0) != 48) { return 4; }
  return 0;
}

export func check as "check";

Limits

  • A vec_of element type must be defaultable — a numeric type, or a nullable ?Foo. Growth allocates a default-initialized array, and a non-null &Foo has no default value. A container of structs is comptime vec_of(?Point, PointVec, point_vec);, and at returns ?Point, which you narrow with is.
  • A generator cannot recurse, directly or through a chain of others, and a module is limited to 1,000 instantiations.
  • A /// comment inside a generator body does not reach the generated declaration. Comments are trivia the expansion pass does not carry. Document the generator itself; its doc comment shows on hover over both the definition and every instantiation.
  • Renaming a generator is refused by the language server. The declarations it produced are indistinguishable from hand-written ones by the time renaming could run, so a rename would be unable to tell what it should touch.