Skip to content
CryoCryo home
LanguageAbstraction

12Generics

Generics let you write code once and have the compiler specialise it for each concrete type used. The model is monomorphisation: at compile time, every distinct instantiation produces a dedicated copy of the code. There is no boxing, no vtable, and no runtime type information.

12.1 Type Parameters

Type parameters are declared in angle brackets after the name. By convention they use single uppercase letters: T for a general type, E for an error type, K and V for key / value, A for an allocator.

12.2 Generic Structs

type struct Box<T> {
    ptr: T*;

    static new(value: T) -> Box<T> {
        const p: T* = malloc(sizeof(T)) as T*;
        *p = value;
        return Box { ptr: p };
    }

    deref(&this) -> T {
        return *this.ptr;
    }

    drop(mut &this) -> void {
        free(this.ptr);
    }
}

A struct can have multiple type parameters, and a parameter can have a default. The standard library uses defaults extensively to make the common case ergonomic:

type struct Array<T, A = GlobalAlloc> { /* ... */ }
type struct HashMap<K, V, A = GlobalAlloc> { /* ... */ }

Calling Array<int>::new() uses GlobalAlloc; calling Array<int, Arena>::new_in(my_arena) parameterises the container over a custom allocator.

12.3 Generic Enums

type enum Result<T, E> {
    Ok(T);
    Err(E);
}

Each Result<T, E> instantiation is a distinct compile-time type.

12.4 Generic Functions

function identity<T>(x: T) -> T {
    return x;
}

const n: int = identity<int>(42);

12.5 Generic Implement Blocks

When you add methods to a generic type via implement, the block itself is generic. Methods may introduce additional type parameters:

implement enum Result<T, E> {
    is_ok(&this) -> boolean {
        match (this) {
            Result::Ok(_)  => { return true;  }
            Result::Err(_) => { return false; }
        }
    }

    map<U>(&this, op: (T) -> U) -> Result<U, E> {
        match (this) {
            Result::Ok(value) => { return Result::Ok(op(value)); }
            Result::Err(err)  => { return Result::Err(err);      }
        }
    }
}

Result's parameters <T, E> are fixed by the type; map introduces an additional <U>.

12.6 Monomorphisation

When the compiler sees:

const a: Pair<int>    = Pair<int>::new(1, 2);
const b: Pair<string> = Pair<string>::new("x", "y");

it generates two independent types and two specialised function bodies, one for each instantiation. There is no shared dispatch; every call is a direct call to a fully-typed function. The trade-off is binary size: each instantiation produces its own code.

The pipeline driver lives in compiler/src/compiler/types/monomorphizer.cryo (Phase 6a in instance.cryo), invoked after type resolution and trait-bound validation but before function-body type checking. The follow-on compiler/src/compiler/passes/specialization.cryo walks already-typed bodies and rewrites generic call sites to point at the monomorphised callees.

12.7 static match — Compile-Time Type Dispatch

static match selects a body by inspecting a type parameter at compile time. It is the mechanism that lets one generic function accept several unrelated types without a trait bound and without runtime dispatch.

static match (T) {
    u8      => { this.write_u8(value); }
    i8      => { this.write_u8(value as u8); }
    boolean => { this.write_u8(if (value) { 1 as u8 } else { 0 as u8 }); }
}

The subject is a type, not a value, and each arm is a type rather than a pattern. During monomorphisation the compiler knows what T is, keeps the one matching arm, and discards the rest — so there is no branch and no runtime cost in the emitted code.

Arms may group types with |, and _ is the wildcard:

static match (T) {
    u8 | u16 | u32 | u64 => { this.push_display(item) }
    string               => { this.push_view_bytes(Str::from(item)) }
    _                    => { core::panic("unsupported", FILE, LINE); }
}

Omitting _ makes the arm list the accepted-type list. A T that matches no arm is a compile error. This is deliberate and is how the standard library expresses "this generic accepts exactly these types" without inventing a trait:

// `String::try_push` accepts strings, integers, floats and boolean.
// Any other T is rejected at compile time - there is no `_` arm.
try_push<T>(mut &this, item: T) -> Result<(), AllocError> {
    return static match (T) {
        String  => { this.push_view_bytes(item.as_str()) }
        Str     => { this.push_view_bytes(item) }
        string  => { this.push_view_bytes(Str::from(item)) }
        u8 | u16 | u32 | u64 |
        i8 | i16 | i32 | i64 |
        f32 | f64 |
        boolean => { this.push_display(item) }
    };
}

Statement and expression position both work. As an expression, every arm's body yields the value of its final expression, and all arms must agree on a type — as in the try_push example above, where each arm yields Result<(), AllocError>.

Discarded arms are not type-checked against the wrong type. Because pruning happens before the body is checked, an arm may use operations that are valid only for its own type — item.as_str() in the String arm above is not an error when T is u8, since that arm no longer exists in the u8 instantiation. This is the property that makes the construct useful: without it, every arm would have to compile for every T.

The subject may be any type parameter in scope — the enclosing type's parameter, or one introduced by the method itself:

type struct Atomic<T> {
    // `T` here comes from the type.
    load(&this) -> T { return static match (T) { /* ... */ }; }
}

implement struct String {
    // `T` here is introduced by the method.
    push<T>(mut &this, item: T) -> void { /* static match (T) ... */ }
}

static match is also permitted in a trait's default method body, which is what lets io::Write::write<T> dispatch on the payload type without each implementor restating the arms.

When to reach for it. Prefer a trait bound when the operation is genuinely the same across types and the set is open. Reach for static match when the set of accepted types is closed and each one needs different code — width-specific integer handling, or a conversion whose implementation differs per source type. The standard library uses it for Hash::fold, String::push/try_push, Str::from, Atomic<T>, and io::Write::write.