PHP has no native generic syntax, so Manticore reads generics from docblocks
(@template, @var C<T>, @param T, @return T, T[], array<K,V>) and the
callable(A): R shape. Two lowering strategies back the same annotations —
erasure (one shared body, type variable rides as a tagged cell) and
reification / monomorphization (a concrete specialized copy, zero boxing) —
and the compiler picks the fast one wherever it is sound.
Every generics feature below has a worked case under tests/aot/cases/generics_*.
When probing correctness, use float: a string/object value survives
erasure by accident (a cell carries its own tag), so only a float (or the 2^50
raw-int probe) exposes a representation bug.
Generics are docblock-driven by design, so the source stays valid PHP and
runs unchanged under Zend php (which the cold bootstrap and tools/difftest.sh
rely on): /** @param Pt[] $pts */ or /** @var Box<float> $b */.
Manticore also parses an inline generic type in a type position —
function dump(array<Pt> $pts), Box<float> — as a compiler extension
(array_of_class_generic.php). It is fully supported, but it is not valid PHP
syntax, so a file using it no longer runs under Zend. Prefer the docblock form
unless the file is Manticore-only; reach for inline <…> only when you have
opted out of Zend compatibility.
/** @template T */
final class Box {
/** @var T[] */ private array $items = [];
/** @param T $x */ public function add($x): void { $this->items[] = $x; }
/** @return T */ public function get(int $i) { return $this->items[$i]; }
}
/** @var Box<float> $b */
$b = new Box();
$b->add(1.5);
echo $b->get(0) + $b->get(1); // float arithmetic, correctOne compiled Box serves every instantiation. Inside the shared body T is
erased and travels as a tagged cell; the call site knows the binding
(Box<float>) and refines the result of get() from it — so + does float math
and . does string concat, not raw-i64 nonsense. Bindings can be scalars,
objects, or other classes (Box<int>, Box<string>, Box<Tag>).
/** @template T of Animal */
final class Pen { /* … @param T / @return T … */ }A bound is not just a check — it changes codegen. An unbound T knows nothing
about its value, so it erases to a tagged cell. A bounded T of Animal is
known to be an object, so it erases to obj<Animal> — a raw pointer, no boxing.
The bounded form emits zero boxing ops where the unbound one emits several,
and ->speak() dispatches virtually on the real runtime class.
/** @template T = int */
final class Counter { /* … */ }
$c = new Counter(); // no <…> at the use site → T binds to int
$c->add(10);
echo $c->get(0) + $c->get(1);/** @template T */ /** @template T */
abstract class Base { /* @return T */ } interface Coll { /* @return T */ }
/** @template T @extends Base<T> */ /** @template T @implements Coll<T> */
final class Bag extends Base {} final class ListColl implements Coll { /* … */ }
/** @var Bag<float> $f */ $f = new Bag(); // reaches Base as Base<float>
/** @var Coll<string> $c */ $c = new ListColl();The generic member is declared on the base/interface; the receiver binds the
child's parameters, and climbing the chain re-maps the arguments (Bag<float> →
Base<float>).
/** @template T */
trait Items { /* @var T[] / @param T / @return T … */ }
final class Floats { /** @use Items<float> */ use Items; }
final class Names { /** @use Items<string> */ use Items; }This is the one place generics buy speed for free. A trait is copied into each
using class, so the binding is substituted at the source: T never becomes a
type variable — it lowers straight to float/string, every member comes out
concrete, and zero boxing ops are emitted. Prefer a generic trait over a
generic class when each binding lives in its own class.
When a site owns the construction of a bound container, Manticore builds a real specialized class instead of the erased body:
/** @var Box<float> $b */
$b = new Box(); // this site constructs it → Box<float> is reifiedThe specialization has float-typed properties and a float-typed body — zero
boxing outside the erased thunks. Properties:
- A specialization is a subclass of its origin, so
instanceof,catch, and compile-time dispatch already see it, and it reports its origin's name toget_class()/::class. - Static properties stay on the origin (one slot per class, shared by every binding) — a specialization declares none.
- The erased boundary is bridged by thunks. A bare
Box $bhas no binding, andBox<float>::getreturns a raw double whileBox<string>::getreturns a raw pointer — both i64, indistinguishable to that caller. Each specialized method gets a second entry (the erased thunk) that boxes its result / unboxes its args; the dispatch switch calls the thunk when the receiver is erased.
A type hint may NOT name a specialization it does not construct. A slot typed
Box<float> that is handed an object from elsewhere (which may be an erased
instance) stays erased and goes through the thunks. Only the @var … = new …
pair — where the site both declares and constructs — reifies. (generics_reified.php,
generics_reified_fields.php.)
Even without @template, a function whose behaviour depends on an erased
parameter — a bare array with an unknown element, or a callable — is
specialized per concrete call-site shape:
function first(array $a) { return $a[0]; }
first([1, 2, 3]); // → first$mono over vec[int]
first(["a", "b"]); // → first$mono over vec[string]
usort($rows, fn($a, $b) => $a->k <=> $b->k); // usort specialized per closureMonomorphize clones the callee per distinct concrete argument shape (array
element type, or the identity of a concrete closure), re-types each copy, and
repoints the call — so a helper used over int[] and string[] in one program
gets two exact copies, no boxing. The dynamic / reflective / name-addressed entry
keeps the single erased copy as the fallback. Design:
docs/design/monomorphization.md, docs/design/monomorphize-callable-dim.md.
| Form | Bodies | Type variable | Boxing |
|---|---|---|---|
Erased class (@template) |
one shared | tagged cell | per erased value |
Bounded (T of C) |
one shared | obj<C> raw ptr |
none for the receiver |
Generic trait (@use T<X>) |
copied per class | substituted → concrete | none |
Reified (@var C<X> = new C) |
one per binding | concrete field/body | none (thunks bridge erased use) |
| Monomorphization | one per call shape | concrete param | none on the fast path |
@var C<X>reifies only where the site constructs the value; a bareC $xhanded in from elsewhere stays erased (the soundness rule above).- The type variable of an erased class is a cell — correctness is guaranteed,
but boxing cost remains; reach for a trait or a reified
@var … = new …when the hot path needs it. @implements C<T>is inert — nothing reads it. Only@template,@extendsand@useare consumed by the lowering; an interface type argument has to come from a@var C<X>at the construction site. Written for documentation value, it costs nothing and does nothing.- The inline
<…>extension is surface-level: the parser swallows the angle-bracketed text and preserves it inside the type name string. There is no generic-type AST node, so it feeds the same docblock-driven engine — it is a spelling, not a second mechanism.