Learn · step 6 of 8
A generic that crosses a boundary
Inside a module a generic monomorphizes. Crossing a module boundary it is supposed to pass a dictionary keyed on GC shape, and that half of the rule has nothing behind it yet, so the artifact carries the body and the consumer compiles it again.
What the rule says
R-4 Generic calls crossing a module boundary pass a dictionary keyed on GC shape. Within a module, monomorphisation. @[Monomorphize] forces specialisation across a boundary.R-4 SPEC.md, line 41Run it
This step is grounded in samples/iyi/immutable.iyi, 112 lines in the repository, read out of the tree when this page is built.
Run it in your browser from the module the compiler built for it.
Step four gave a generic type a trait, and step five typed a generic body against its bound. Neither asked what the compiler emits. This step does, and it is the one step in this path whose rule is mostly a plan.
The rule draws a boundary. Inside a module, monomorphization: a body per set of types, which is what Crystal does and what every C++ template does. Across a module boundary, a dictionary keyed on GC shape: one compiled body, serving every type whose values are laid out the same way, with the operations it needs passed in.
The boundary, in a program#
std/list is a generic type in a module of its own, and this sample is its
consumer.
# Two ways of naming the same type, both across a module boundary. The second
# lets the argument be inferred, and it is written that way on purpose: the
# artifact carries no machine code for a generic, so `List(String)` is a type
# this file makes, and inference puts `new` on `List(T)` instead of on the
# instance. That difference once linked undefined and nothing here caught it,
# because every line in this file wrote the argument out.
nums = List(Int32).new([3, 1, 4, 1, 5])
words = List.new(["pear", "fig", "plum", "kiwi"])Look at the reason written into that comment. The artifact carries no machine
code for a generic, so List(String) is a type this file makes. The producing
module compiled nothing for it, because it could not know which types would
turn up.
That is R-1 applied to a generic, and it is also what stands in for R-4's first sentence today: the body travels in the artifact and the consumer compiles it again, for the types it uses. Monomorphization, across the boundary the rule says a dictionary crosses.
The round trip in the same file is the proof that nothing else is happening.
# All of the above, with `src/std` deleted
# ---------------------------------------------------------------------------
#
# ./bin/crystal build --no-codegen --emit-iyimod /tmp/mods samples/iyi/immutable.iyi
# mv src/std /tmp/std-elsewhere
# ./bin/crystal build --no-codegen --use-iyimod /tmp/mods samples/iyi/immutable.iyi # compiles
# mv /tmp/std-elsewhere src/std
#
# This file is the hard case for R-1's contract, not `modules.iyi`: what has to
# survive the round trip is a generic struct, a 575-line trait with an
# associated type, and a generic impl that answers it. `mod dump --declarations
# /tmp/mods/std/list.iyimod` is what the second build reads — the twelve
# headers with no bodies, and `impl … for Std::List::List(T) forall T` with its
# `type Elem = T`. Every call above is checked against those and against
# nothing else (SPEC.md IV.1f).The second build reads the declarations and the bodies out of the artifact and compiles against those alone. A generic struct, a trait with an associated type, and a generic impl that answers it all survive having their source moved away, which is step one's contract holding at its hardest point.
What the rule has already bought#
A dictionary is one compiled body serving many types. That is only possible if a generic type's method set can be known without knowing its parameter, and three refusals in step four are what keep that true: a bound is a trait and nothing else, a specialized impl is refused, and a blanket impl is refused. Every one of them reads as a restriction on the programmer and is a prerequisite for the compiled body.
impl Show for Box(T) forall T
def show : String
"Box(#{value})"
end
endOne impl, every instantiation. Under R-4 that is one body per GC shape. Today it is one body per instantiation, and the sample says so itself rather than leaving a reader to assume the better answer.
# * Under R-4 an impl on a generic type should be compiled once per GC shape
# rather than once per instantiation. Today this monomorphises, exactly the
# way Crystal does.What the dictionary requires#
A body shared by several types still has to tell the collector which words in a value are pointers, so the pointer map has to travel with the shape. That is what a GC shape is, and it is why SPEC.md II.5 turned this rule into a dependency: a conservative collector guesses at pointers by scanning and cannot supply a per shape map. Replacing it is a prerequisite of R-4 rather than a runtime decision to be taken afterwards.
The measurement is where this stops being an argument.
bench/generic_boundary.py reads the sample corpus's artifacts by section
table and reports what travels: the declarations a consumer compiles against,
the bodies it compiles again, and the object code it only links. It asserts
the two ends of the rule as it is actually built, that a module with no
generic ships object code and no body and a module that is all generics ships
every body and no object code, and it holds the corpus wide share of bodies
under a line set with headroom. A tripwire rather than a target: what the
dictionary would take out of a consumer's build, kept honest on every push.
Forced monomorphization#
R-4's third sentence names an annotation, @[Monomorphize], for forcing
specialization across a boundary. It is the part of the rule with nothing
behind it at all, and the compiler does not pretend otherwise. Write it and
the compiler rejects the name as unknown rather than issuing a warning,
accepting it silently, or falling back to a slower build.
Break it
the annotation R-4 names, asked of a compiler that has no such name
# Breaks R-4 by asking for the one clause of it that has nothing behind it.
#
# R-4's third sentence says `@[Monomorphize]` forces specialisation across a
# module boundary. SPEC.md III.9 records what that is worth today: nothing in
# the compiler mentions the annotation, a dictionary or a GC shape. So this is
# not a feature that misbehaves, it is a name the compiler has never heard,
# and the rejection below is a compiler declining to accept a promise it
# cannot keep rather than compiling the program as though the rule were built.
#
# What runs instead of the dictionary is the rule's second sentence applied
# everywhere: a generic's body travels in the artifact and every consumer
# compiles it again for the types it uses. `bench/generic_boundary.py` is the
# measurement of what that costs a consumer's front end.
module diagnostics/r4_dictionary
@[Monomorphize]
pub def twice(x : T) : Array(T) forall T
[x, x]
end
puts twice(1).size$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/r4_dictionary.iyi Showing last frame. Use --error-trace for full trace. In records/break/r4_dictionary.iyi:16:3 16 | @[Monomorphize] ^----------- Error: undefined constant Monomorphize
@[Monomorphize] forces specialisation across a boundary.SPEC.md, line 41. Recorded run, exit code 1, iyi 0.17.0 (built on Crystal 1.22.0-dev [fe3db8354] (2026-10-14)), commit fe3db8354f56. Regenerate with npm run record:diagnostics.What the rule bought
What the rule has bought so far is the shape that makes a dictionary possible at all: a bound is a trait and nothing else, there are no specialized impls and no blanket impls, so a generic type's method set is knowable without knowing its parameter, which is what one compiled body serving many types requires. What it has not bought is the dictionary, and bench/generic_boundary.py is what measures the gap on every push rather than leaving it as a sentence in a specification.
Standing on
samples/iyi/immutable.iyi- the two ways of naming a generic across a boundary, the round trip with
src/stdmoved away, and the note on what carries no machine code bench/generic_boundary.py- R-4 measured, what travels in an artifact by section, and the two structural facts it asserts about a module with no generic and a module that is all generics
samples/iyi/generics.iyi- the impls over generic types, and the sample's own note that this monomorphizes today
SPEC.md- II.5 for why a dictionary needs a precise heap layout, and III.9 for what stands in for R-4 today