Skip to content

The book · 10

Comptime and reflection

book.md · 97 lines · 3 min read

Goal: run logic at compile time, parametrize types (generics), inspect types with reflect(T), and lock down contracts with assert/assume/@requires.

Comptime: the pure core running in the compiler

Section titled “Comptime: the pure core running in the compiler”

comptime is the “evaluate this at compile time” modifier. It applies to a binding, a block, a loop, a match, and a fn:

const SIZE := 64 // const = comptime let
comptime {
assert (SIZE <= 1024) // checked at build time
}

type is a compile-time value: types are first-class values. A generic is just a function that takes/returns type, executed by the compiler.

Generics: [...] is compile time, (...) is runtime

Section titled “Generics: [...] is compile time, (...) is runtime”
decl List[T] { items: [*]T; len: usize; cap: usize } // sugar for a comptime fn that returns type
fn repeat[n: int](s: string) -> string // n: compile time; s: runtime
fn zeros[N: usize]() -> [N]int // N sizes the array at compile time

Each distinct value in [...] generates a specialized function, that is, monomorphization.

Constraints are declared, with a strict symbol convention: + means implements an interface, : is a whitelist (belongs to a set):

fn serialize[T + Serializable](v: T) -> []byte // capability
fn encode[T: {i8, u16, bool}](v: T) -> []byte // whitelist
fn proc[T: {i8, u16} + Serializable](v: T) -> []byte // combined

The error blows up at the instantiation boundary, clean, and not three layers deep like in Zig’s duck typing.

Two forms, choose by need:

  • T + Trait / HKT generates monomorphization (static; necessary when the return depends on T, e.g. map[A,B](xs: C[A]) -> C[B]).
  • an interface value generates a vtable (erased), chapter 11.

Reflection: reflect(T) (the compiler package)

Section titled “Reflection: reflect(T) (the compiler package)”

reflect(T) exposes a type’s structure to comptime code: it is what auto-derives serialize/hash without you writing anything. The shape of the type is a variant-set, matched with an exhaustive comptime match:

use compiler.{reflect, fail}
fn serialize[T](v: T, out: mut Buffer) {
comptime match reflect(T).kind {
Struct(s) => comptime loop f in s.fields { serialize(f.get(v), out) } // f.get reads the field
Tuple(parts) => comptime loop p in parts { serialize(p.get(v), out) } // positional
Enum(e) => { put_varint(out, e.tag(v)); /* + active variant's payload */ }
Slice(_) => { put_varint(out, v.len); loop x in v { serialize(x, out) } } // length-prefix + N
Array(_, _) => loop x in v { serialize(x, out) } // n comes from the type
Optional(_) => { out.write_byte(v.is_present() as byte); if v.is_present() { serialize(v.or_panic(), out) } }
String => { put_varint(out, v.bytes.len); out.write(v.bytes) } // count + UTF-8 bytes
Int(_) | Float(_) | Bool => out.write(v.to_bytes())
Ptr(_) | ManyPtr(_) | Channel(_) | Function(_) | RawPtr
=> fail("does not serialize: an address does not cross the boundary") // fail, from compiler
_ => fail("non-serializable kind") // no value falls here, all 10 kinds above
}
}

In practice you never see reflect: you write [T + Serializable] and the derivation runs underneath. Whoever drops down to reflect(T) is a lib author (encoding/json/hash).

The containment of danger (chapter 13) has two levels. A boundary contract (@requires / postcondition) makes a function safe to call: the compiler checks at every call (static where it can prove, panic where it can’t):

@requires(i < buf.len)
fn at(buf: Buffer, i: usize) -> u8 == buf.bytes[i] // terse postcondition in the return type
{ return unsafe raw_read(buf.ptr + i) assume "valid ptr: Buffer invariant + the @requires bounds" }

Knocking down an operation in the body: assert (cond) checks (panic if false); assume "reason" trusts (no check, no license for UB). Two keywords, one intent each.

Next: 11 · Interfaces