Internals
Monomorphization Specification
This document details the design and implementation of the Monomorphization phase in the Qoqi compiler.
Objective
Monomorphization is the process of generating concrete, specialized versions of generic functions and types for each unique set of type arguments used in the program. This transformation eliminates all generic type parameters, producing a fully concrete AST that can be lowered to IR without any polymorphism.
Motivation
Qoqi uses monomorphization (similar to Rust, C++, and other statically-typed languages with generics) rather than runtime polymorphism for several reasons:
- Performance: Each instantiation is optimized for specific types with no runtime type dispatch overhead
- Simplicity: The VM doesn't need to handle polymorphic types or generic dispatch
- Type Safety: All type relationships are resolved at compile time
- Code Size Trade-off: Acceptable duplication of code in exchange for performance and simplicity
Example
Given this generic function:
fn identity<T>(x: T) -> T {
return x;
}
fn main() -> Int {
let a = identity<Int>(42);
let b = identity<String>("hello");
return a;
}
Monomorphization produces:
fn identity_Int(x: Int) -> Int {
return x;
}
fn identity_String(x: String) -> String {
return x;
}
fn main() -> Int {
let a = identity_Int(42);
let b = identity_String("hello");
return a;
}
Input and Output
Input:
*ast.Program(from type checking)*types.Info(type information for all expressions)*resolve.Table(name resolution information)
Output:
*ast.Program(transformed with all generics instantiated)*MonomorphizationInfo(mapping of original generic declarations to their monomorphized instances)
Algorithm Overview
The monomorphization process consists of four main phases:
Phase 1: Instantiation Discovery
Goal: Find all uses of generic functions and types with concrete type arguments.
Process:
- Walk the entire AST
- For each call to a generic function:
- Record the call site
- Extract the type arguments (explicit or inferred)
- Create an instantiation key:
(FnName, [TypeArg1, TypeArg2, ...])
- For each use of a generic struct/enum:
- Record the usage site
- Extract the type arguments
- Create an instantiation key:
(TypeName, [TypeArg1, TypeArg2, ...])
Data Structure:
type InstantiationKey struct {
BaseName string
TypeArgs []types.Type
}
type Instantiation struct {
Key InstantiationKey
Decl ast.Node // Original generic declaration
UseSites []ast.Node
}
Phase 2: Dependency Resolution
Goal: Handle transitive dependencies where one generic function calls another.
Process:
- For each known instantiation, find the instantiations its body needs: if
foo<T>callsbar<T>, thenfoo<Int>requiresbar<Int>. Type parameters are substituted wherever they occur in the type arguments (bar<[T]>becomesbar<[Int]>). - Repeat for the ones just found, until nothing new turns up.
- Instances are generated in the order they were found, which is the same on every run.
Instances that need each other, or themselves, are not a problem: sorted<T> calling sorted<T> is recursion, and sorted<Int> is one instance however often it is needed. What cannot be compiled is a function whose every instance needs a new, larger one. That shows as a chain of new instantiations that does not end; it is cut off after 100 links and reported as E3002.
Example:
fn map<T, U>(xs: [T], f: fn(T) -> U) -> [U] { ... }
fn double(x: Int) -> Int { return x * 2; }
fn main() -> [Int] {
let nums = [1, 2, 3];
return map<Int, Int>(nums, double); // Instantiates map_Int_Int
}
Phase 3: Code Generation
Goal: Generate specialized versions of generic declarations.
Process:
For each instantiation:
- Clone the AST of the generic declaration
- Create a unique name:
original_name+_+ mangled type args- Example:
identity<Int>→identity_Int - Example:
Vec<Int>→Vec_Int - Example:
map<Int, String>→map_Int_String
- Example:
- Substitute type parameters throughout the cloned AST:
- Replace all
TypeParamnodes with concrete types - Update type annotations in parameters, return types, and local variables
- Replace all
- Update the symbol table to register the new concrete function/type
- Add the new declaration to the program
Type Substitution Details:
type TypeSubstitution map[string]types.Type
func (s TypeSubstitution) Apply(t types.Type) types.Type {
switch typ := t.(type) {
case *types.TypeParam:
if concrete, ok := s[typ.Name]; ok {
return concrete
}
return t
case *types.ArrayType:
return types.NewArray(s.Apply(typ.ElementType))
case *types.FunctionType:
params := make([]types.Type, len(typ.Params))
for i, p := range typ.Params {
params[i] = s.Apply(p)
}
return types.NewFunction(params, s.Apply(typ.ReturnType))
case *types.StructType:
// Handle struct fields recursively
// ...
default:
return t
}
}
Phase 4: Call Site Rewriting
Goal: Update all call sites to use the monomorphized versions.
Process:
- For each generic function call:
- Replace the function name with the mangled monomorphized name
- Remove type arguments from the call expression
- For each generic type instantiation:
- Replace the type reference with the concrete type name
- Update the resolution table to point to new declarations
Example Transformation:
// Before
let result = identity<Int>(42);
// After
let result = identity_Int(42);
Generic structs are not rewritten. A literal Box<Int> { value: 1 } keeps
its name and type arguments, and so does every type annotation mentioning
Box<Int>. The reasons:
- The rest of the program refers to the struct as
Box<...>(parameter and variable types,impl<T> Box<T>). Renaming only the literal toBox_Intwould make the value a different type from every place it is used, and its methods, which belong toBox, would not be found. - Nothing is gained at runtime: a struct value does not carry its type
arguments, so one
Boxserves all instantiations. Likewise the methods ofimpl<T> Box<T>are compiled once, not per instantiation.
The concrete struct declarations (Box_Int) are still generated in phase 3
and appended to the program; they are currently unused. Generic functions
are rewritten as described above.
Type arguments come from the checker. A call written without type
arguments, first([1, 2]), has them inferred during type checking and written
into the AST (first<Int>([1, 2])), so by the time monomorphization runs every
call it can instantiate is explicit. E3001 remains for calls where neither
the arguments nor the expected result type determine a type parameter.
Function expressions and casts are traversed: a generic call inside a
lambda is discovered and rewritten, and the type in value as T is
substituted when a generic function is instantiated.
Name Mangling Scheme
To generate unique names for monomorphized instances:
Format: {base_name}_{type1}_{type2}_..._typeN}
Rules:
- Built-in types use their name:
Int,Float,String,Bool - Arrays use
Array_{element_type}:[Int]→Array_Int - Structs use their name:
Point→Point - Generic structs flatten:
Vec<Int>→Vec_Int - Functions encode params and return:
fn(Int) -> String→Fn_Int_String - Nested generics flatten recursively:
Vec<Vec<Int>>→Vec_Vec_Int
Collision Handling: If two different generic functions have the same base name and type arguments, they will collide. This is a name resolution error that should be caught earlier.
Implementation Structure
Package: internal/monomorph
package monomorph
type Monomorphizer struct {
prog *ast.Program
typeInfo *types.Info
resolvTable *resolve.Table
errs *errors.Handler
// Tracks all discovered instantiations
instantiations map[InstantiationKey]*Instantiation
// Tracks generated monomorphic functions/types
generated map[InstantiationKey]ast.Node
// Original generic declarations
generics map[string]ast.Node
}
func NewMonomorphizer(
prog *ast.Program,
typeInfo *types.Info,
resolvTable *resolve.Table,
errs *errors.Handler,
) *Monomorphizer
func (m *Monomorphizer) Monomorphize() (*ast.Program, *MonomorphizationInfo)
Key Functions
// Phase 1: Discovery
func (m *Monomorphizer) discoverInstantiations() error
// Phase 2: Dependencies
func (m *Monomorphizer) resolveDependencies() ([]InstantiationKey, error)
// Phase 3: Generation
func (m *Monomorphizer) generateInstance(key InstantiationKey) (ast.Node, error)
func (m *Monomorphizer) cloneAndSubstitute(decl ast.Node, subst TypeSubstitution) ast.Node
// Phase 4: Rewriting
func (m *Monomorphizer) rewriteCallSites() error
// Utilities
func (m *Monomorphizer) mangleName(baseName string, typeArgs []types.Type) string
func (m *Monomorphizer) extractTypeArgs(node ast.Node) []types.Type
Error Handling
E3001: Unbounded Generics
Description: Generic function/type is used but type arguments cannot be inferred.
Example:
fn identity<T>(x: T) -> T { return x; }
fn main() -> Void {
// Error: Cannot infer type T
let x = identity(42); // Missing explicit type argument
}
Error Message: "Cannot infer type arguments for generic function 'identity'. Provide explicit type arguments: identity
E3002: Infinite Instantiation
Description: A generic function whose instances each require another instance with a larger type, without end.
Example:
fn recurse<T>(x: T) -> T {
return recurse<[T]>([x]); // Infinite: T -> [T] -> [[T]] -> ...
}
A generic function that calls itself with the same type arguments is ordinary recursion and compiles:
fn count<T>(items: [T], from: Int) -> Int {
if from >= items.len() { return 0; }
return 1 + count<T>(items, from + 1);
}
Error Message: "Generic function 'recurse' causes infinite instantiation. Detected cycle in type argument expansion."
E3003: Type Argument Mismatch
Description: Number of type arguments doesn't match number of type parameters.
Example:
fn pair<T, U>(x: T, y: U) -> (T, U) { ... }
fn main() -> Void {
// Error: Expected 2 type arguments, got 1
let p = pair<Int>(1, 2);
}
Error Message: "Generic function 'pair' expects 2 type arguments, but got 1"
Note on Duplicate Instantiations: Multiple calls to the same generic with the same type arguments is normal and expected behavior. The monomorphizer will generate only one concrete instance and reuse it for all call sites:
fn identity<T>(x: T) -> T { return x; }
fn main() -> Int {
let a = identity<Int>(1);
let b = identity<Int>(2);
let c = identity<Int>(3);
// All three calls use the same identity_Int function
return a + b + c;
}
This is an implementation detail handled by the instantiation cache - the monomorphizer tracks which instances have already been generated and reuses them.
Type Inference for Generics
In many cases, type arguments can be inferred from usage context:
fn identity<T>(x: T) -> T { return x; }
fn main() -> Int {
// Explicit: identity<Int>(42)
// Inferred: identity(42) - T inferred as Int from argument type
let x = identity(42);
return x;
}
Inference Algorithm:
- Collect constraints from argument types
- Unify type parameters with concrete types
- Propagate constraints through the function signature
- If all type parameters can be resolved, use inferred types
- Otherwise, require explicit type arguments
Implementation: Type inference is performed during type checking, before monomorphization. The type checker should annotate generic calls with their inferred type arguments.
Interaction with Other Phases
Input from Type Checking
The type checker must:
- Validate that generic declarations are well-formed
- Infer type arguments for generic calls where possible
- Record the inferred/explicit type arguments in
types.Info - Ensure trait bounds are satisfied (future feature)
Output to IR Generation
After monomorphization:
- The AST contains only concrete types
- All function calls are to concrete functions
- No
TypeParamnodes remain in the tree - IR generation can assume all types are known
Testing Strategy
Unit Tests
-
Basic Instantiation:
- Generic function with single type parameter
- Generic struct with single type parameter
- Multiple instantiations of the same generic
-
Multiple Type Parameters:
- Function with 2+ type parameters
- Different orderings and combinations
-
Nested Generics:
Vec<Vec<Int>>- Generic function returning generic type
-
Transitive Dependencies:
- Generic function A calls generic function B
- Verify both are instantiated correctly
-
Type Inference:
- Inferred type arguments from context
- Partial inference with some explicit args
Integration Tests
-
End-to-End:
- Source code with generics → Parse → Resolve → Type Check → Monomorphize → Verify AST
- Compare generated function names and signatures
-
Complex Programs:
- Generic standard library functions (map, filter, fold)
- Generic data structures (Vec, Option, Result)
Error Tests
- Verify all error codes are triggered appropriately
- Test error recovery (continue after errors to report multiple issues)
Performance Considerations
Compile-Time Impact
Monomorphization can significantly increase:
- Compilation time: Proportional to number of instantiations
- Memory usage: Cloning AST nodes for each instance
- Binary size: Each instantiation adds code
Mitigations:
- Instantiation Caching: Reuse previously generated instances
- Lazy Instantiation: Only instantiate what's actually used
- Deduplication: Detect structurally identical functions
Runtime Impact
Monomorphization improves runtime performance:
- No virtual dispatch: Direct function calls
- Optimization: Each instance can be optimized independently
- Inlining: Compiler can inline monomorphized functions more easily