Reified types and layouts
Types in rut are not a compile-time fiction. Every value’s exact type is
available at runtime — generics included: a Cache<i32> and a
Cache<str> are different types to the VM, with distinct identities
and distinct method sets. This chapter explains where that reified
reality lives, what it is used for, and how values are actually laid
out.
The mental model
Think of the VM as keeping a type table: one descriptor per instantiated type — its kind, its field list, its trait impls, and for composites the method tables its instances point at. Every heap cell’s header names an entry in that table. A value never “forgets” its type, because its type is one field read away.
Types themselves are not first-class script values — you cannot put a type in a variable or write a function over types. What you get is the language-facing surfaces of reification:
istype tests —x is Circle(exact) andx is Drawable(capability: does this value’s type have a registered impl?).- Checked erasure —
opaque.downcast<T>(o)reads the box’s runtime type and recovers the payload, ornilon a mismatch. - Host-boundary checks — native functions declare parameter types once; every call is checked against them.
- Debugging and traces — error messages, backtraces, and formatter output name real runtime types.
Why reification is load-bearing
The reason rut can check everything cheaply is that the runtime type is always reachable:
- The host boundary needs no coercion code. A native fn declares
(Opaque, str, str) -> nilonce; the VM checks each argument’s runtime type at the crossing. There is no bridge-side “re-parse-and-pray” layer, because a wrong-shaped call fails loudly at the boundary. - Erasure is checked, not blind.
opaque(v)stamps the box;opaque.downcast<T>consults the stamp. A box answers type tests by the box —o is Tmisses for every payload type — so the only way back to the payload is the checked recovery. Erasure without reification would beany; with reification it is a sealed box. - Distinct instantiations.
Vec<f32>andVec<f64>are different types everywhere — in the type table, in method resolution, at the host boundary. Nothing about a generic’s type argument is erased. - Serialization and tooling. Reflection walks the same descriptors, which is how userland JSON encoding is complete without annotations or macros on your types.
Slots: the untagged hot path
Inside the VM, registers and record fields are untagged 8-byte slots. The bytecode is typed — every register’s static type is recorded in the function’s signature and re-verified at load — so hot paths carry no type tags at all:
#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
pub union Slot {
pub i: i64, // ints, bool: the canonical scalar width
pub f: f64, // floats: f32 widened
pub r: *mut CellVal, // every non-primitive: a cell handle
}
}
A primitive is its bits; a composite is a handle to a cell. The tagged world — a value that carries its type with it — exists only at the host boundary, where Rust code that cannot trust static types receives checked, typed values.
Record layout: one slot per field
Both struct and class values live in a cell: a header (refcount +
type id), a pointer to the type’s vtable when the type has methods, and
the payload — a slot array, one untagged slot per field, in
declaration order.
- Primitive fields are widened into their slot; composite fields are cell handles.
- Visibility, generic parameters, and impl blocks elsewhere add nothing — the payload depends only on the field list.
- Field access is by index; there are no hidden members and no inheritance, so there is no base-class prefix and nothing to walk.
- Fixed arrays of primitives (
[f32]) keep their elements flat and packed at machine width — the one place values live inline. Growable sequences are rut-library classes over such arrays.
Consequences:
size_of/align_ofdo not exist, deliberately. Layout is an implementation detail, not an API — rut has no C-ABI struct surface for hosts to mirror. Hosts see records only through the checked boundary (see the host boundary).- A record’s memory cost is its field count times 8 bytes plus the cell header, which is exactly what the heap budget charges.
- Widening a value to a trait type allocates nothing: the trait-typed value is the same cell, reinterpreted through its vtable.
Vtables: one per type, attached at construction
A cell’s vtable is filled when the value is constructed and never
changes. It names the exact type and holds one entry per trait method
the type implements, keyed by a global (trait instantiation, method) id
— Slice<Point> and Slice<str> have separate slot sets, because they
are separate trait instantiations.
A call through a trait object is two loads and an indirect jump:
d.draw(g) ; d: Drawable, draw has global slot 3
obj <- d.cell
code <- obj.vtable.slots[3]
call code(d, g)
An inherent method call — c.area() on a concrete c — compiles to a
direct call with no table involved. The dispatch rules that choose
between the two are the subject of the next chapter,
traits and dispatch.
Type tests, lowered
Every type test is a small, pure read:
- Concrete test (
d is Circle): load the value’s runtime type id, compare. When the receiver’s static type already answers, the compiler folds it to a constant. - Trait probe (
x is Drawable): load the type id, scan the descriptor’s registered impl list. Pure in its inputs, so repeated probes deduplicate and invariant ones hoist. - Downcast (
opaque.downcast<T>(o)): the same type-id compare, followed by the guarded payload extract.
There is no type_of(x) returning a manipulable value and no runtime
layout introspection — the descriptors serve the VM, the checks, and
tooling, not userland metaprogramming. Reflection over data (walking
fields to serialize) is a library facility built on the same tables; see
the reference on reflection.
What this buys you
- Type errors are runtime facts, not conventions: a host call, a downcast, or a trait probe is checked against the same table the VM dispatches through.
- One runtime truth per value — the vtable that answers dispatch is
the same one that answers
is. - The tag-free hot path keeps the interpreter’s arithmetic and field traffic at raw slot speed while reification costs only what the program’s dynamic features actually use.