Generic functions instantiated at their call sites, spiked

This commit is contained in:
Joseph Ferano 2026-09-13 13:13:10 +07:00
parent 97cb77d949
commit cb56fc14b1
2 changed files with 346 additions and 2 deletions

View File

@ -73,6 +73,35 @@ type env = {
because a handler is called from wherever the signal was and cannot be a
branch in the function that established it. *)
mutable lifted : Tast.fn list;
(* ── Generics by monomorphisation (spike, milestone 5) ────────────────
A generic [defn] is *not* in [fns]: its signature mentions type variables
and nothing can be called at it. It lives here, as the AST it was written
as, and every call site turns it into an ordinary function with concrete
types. Odin's model exactly [find_or_generate_polymorphic_procedure]
keeps the source [Entity] and hangs generated ones off it. *)
generics : (string, Ast.fn) Hashtbl.t;
(* Its signature as *written*: parameter and return types with [Types.Var]
in them. This is the pattern a call site matches its argument types
against to bind the variables. *)
gsigs : (string, string list * Types.t list * Types.t) Hashtbl.t;
(* The instantiation cache. Odin's [gen_procs] list, keyed the way Odin keys
it: a linear scan comparing whole concrete signatures with
[are_types_identical] here [Types.equal] pairwise. Same types twice
means one copy. *)
insts : (string, (Types.t list * Types.t * string) list ref) Hashtbl.t;
(* The copies themselves, in the order they were generated. They are
ordinary [Tast.fn]s from here down; nothing in a backend knows they were
ever generic. *)
mutable instances : Tast.fn list;
(* The type variables in scope while a generic signature is being resolved.
Empty everywhere else, which is what keeps the lowercase rejection at
[resolve_name] the default. *)
mutable tyvars : string list;
(* What each of them is bound to while one instantiation's body is checked.
[resolve_name] consults it before anything else, so the body resolves
[t] to [i32] and every node under it is concrete. *)
mutable subst : (string * Types.t) list;
}
let new_env () = {
@ -87,6 +116,12 @@ let new_env () = {
fns = Hashtbl.create 32;
globals = Hashtbl.create 16;
lifted = [];
generics = Hashtbl.create 8;
gsigs = Hashtbl.create 8;
insts = Hashtbl.create 8;
instances = [];
tyvars = [];
subst = [];
}
(* Where a named type was declared, and what it has, as a note.
@ -520,6 +555,33 @@ and near_miss env n =
List.find_opt (fun c -> c <> n && one_edit n c) candidates
and resolve_name env ~seen loc n =
(* ── Type variables, with a sigil at the binding site ─────────────────
[$t] *introduces* a variable and bare [t] uses it, which is Odin's
spelling ([$T] in the signature, [T] in the body). The sigil is read as
an ordinary symbol character, so the whole decision lives here: nothing
in the reader, the parser or the AST knows the character means anything.
Which names are variables is decided before this is ever called
[signature_tyvars] scans the signature for the sigil and puts the bare
names in [env.tyvars] so an unknown lowercase name is still the
unknown-type error it always was. That is the point of the sigil: without
one, a mistyped type name silently became a type parameter and made the
function more permissive than it was written to be. *)
let bare = if n <> "" && n.[0] = '$' then String.sub n 1 (String.length n - 1) else n in
match List.assoc_opt bare env.subst with
(* Inside an instantiation: the variable is this concrete type, and every
node checked under it is as concrete as if it had been written out. *)
| Some t -> t
| None ->
if List.mem bare env.tyvars then Types.Var bare
else if n <> bare then
(* A sigil somewhere that is not a [defn] signature: a struct field, a
global, a [let] annotation. There is nowhere for it to bind, so it is
the error rather than a variable with no scope. *)
Loc.failk "check/unbound-type-variable" loc
"%s introduces a type variable, and only a defn signature can — write \
the concrete type here" n
else
match Types.ikind_of_name n with
| Some k -> Types.Int k
| None ->
@ -570,6 +632,123 @@ and array_len env loc = function
fail loc "%s is not a compile-time integer constant, so it cannot be \
an array length" n)
(* ── Generics: the four operations monomorphisation needs ───────────────
Naming a variable, binding one from an argument, substituting the binding
back in, and spelling the result as a symbol. Everything else about the
feature is where these are called from. *)
(* The variables a signature introduces: every [$t] written in it, in the
order written, once each. Only a [defn] signature is scanned, which is what
makes the binding site a *place* and not merely a spelling. *)
let signature_tyvars (fn : Ast.fn) =
let acc = ref [] in
let name loc n =
if n <> "" && n.[0] = '$' then begin
let bare = String.sub n 1 (String.length n - 1) in
if bare = "" then fail loc "$ on its own does not name a type variable";
(* [$i32] would shadow a machine type inside the body and read as one
everywhere else. There is no reason to want it. *)
if List.mem bare Types.primitive_names
|| Types.ikind_of_name bare <> None
|| Types.fkind_of_name bare <> None then
fail loc "%s is a type, so $%s cannot be a type variable" bare bare;
if not (List.mem bare !acc) then acc := bare :: !acc
end
in
let rec ty (t : Ast.texpr) =
match t.Ast.t with
| Ast.Tname n -> name t.Ast.tloc n
| Ast.Tslice e -> ty e
| Ast.Tarray (_, e) -> ty e
| Ast.Tmap (k, v) -> ty k; ty v
(* The head of an application is a constructor — [Ptr], [Option], [Vec] —
and a variable cannot stand there: this spike is generic over types,
not over type constructors. A [$t] inside the arguments is ordinary. *)
| Ast.Tapp (_, args) -> List.iter ty args
| Ast.Tfn (ps, r) -> List.iter ty ps; ty r
in
List.iter (fun (p : Ast.field) -> ty p.Ast.fty) fn.Ast.params;
(match fn.Ast.ret with Some r -> ty r | None -> ());
List.rev !acc
(* Bind the variables in a parameter's written type from the type an argument
turned out to have. Odin's [is_polymorphic_type_assignable], structurally
and with the same rule: a variable already bound must match what it is
bound to, so [(pair 1 2.0)] over [a $t b $t] is a refusal and not a
second instantiation. *)
let rec bind_ty subst (pat : Types.t) (arg : Types.t) =
match pat, arg with
| Types.Var v, a ->
(match List.assoc_opt v !subst with
| None -> subst := (v, a) :: !subst; true
| Some b -> Types.equal a b)
| Types.Slice p, Types.Slice a
| Types.Ptr p, Types.Ptr a
| Types.Vec p, Types.Vec a
| Types.Pool p, Types.Pool a
| Types.Handle p, Types.Handle a
| Types.Option p, Types.Option a -> bind_ty subst p a
| Types.Array (n, p), Types.Array (m, a) -> Int64.equal n m && bind_ty subst p a
| Types.Map (k, v), Types.Map (k', v') ->
bind_ty subst k k' && bind_ty subst v v'
| Types.Fn (ps, r), Types.Fn (ps', r') ->
List.length ps = List.length ps'
&& List.for_all2 (bind_ty subst) ps ps' && bind_ty subst r r'
(* Nothing generic left on the pattern side: this is ordinary type
equality, and [Never] fits anywhere exactly as it does elsewhere. *)
| p, a -> Types.fits ~expected:p ~actual:a
let rec subst_ty subst (t : Types.t) =
match t with
| Types.Var v -> (match List.assoc_opt v subst with Some c -> c | None -> t)
| Types.Slice e -> Types.Slice (subst_ty subst e)
| Types.Array (n, e) -> Types.Array (n, subst_ty subst e)
| Types.Map (k, v) -> Types.Map (subst_ty subst k, subst_ty subst v)
| Types.Ptr e -> Types.Ptr (subst_ty subst e)
| Types.Vec e -> Types.Vec (subst_ty subst e)
| Types.Pool e -> Types.Pool (subst_ty subst e)
| Types.Handle e -> Types.Handle (subst_ty subst e)
| Types.Option e -> Types.Option (subst_ty subst e)
| Types.Fn (ps, r) -> Types.Fn (List.map (subst_ty subst) ps, subst_ty subst r)
| t -> t
(* Does this resolved type still mention a variable? *)
let rec generic_ty (t : Types.t) =
match t with
| Types.Var _ -> true
| Types.Slice e | Types.Array (_, e) | Types.Ptr e | Types.Vec e
| Types.Pool e | Types.Handle e | Types.Option e -> generic_ty e
| Types.Map (k, v) -> generic_ty k || generic_ty v
| Types.Fn (ps, r) -> List.exists generic_ty ps || generic_ty r
| _ -> false
(* How a concrete type is spelled inside an instantiation's name. The prelude
already writes this by hand [filter-i32], [sum-f32], [append-i64] so a
generated name reads like the handwritten one it replaces, which is what a
backtrace, a [Reach] edge and a dev-build cell all end up showing.
[Types.to_string] cannot serve: [[i32]] and [(Vec i32)] are not symbols. *)
let rec mangle_ty (t : Types.t) =
match t with
| Types.Unit -> "unit"
| Types.Slice e -> "slice-" ^ mangle_ty e
| Types.Array (n, e) -> Printf.sprintf "arr%Ld-%s" n (mangle_ty e)
| Types.Map (k, v) -> Printf.sprintf "map-%s-%s" (mangle_ty k) (mangle_ty v)
| Types.Ptr e -> "ptr-" ^ mangle_ty e
| Types.Vec e -> "vec-" ^ mangle_ty e
| Types.Pool e -> "pool-" ^ mangle_ty e
| Types.Handle e -> "handle-" ^ mangle_ty e
| Types.Option e -> "opt-" ^ mangle_ty e
| Types.Fn (ps, r) ->
Printf.sprintf "fn-%s-to-%s"
(String.concat "-" (List.map mangle_ty ps)) (mangle_ty r)
| t -> Types.to_string t
(* [check_fn] is defined after the expression checker and an instantiation is
made from inside it, so the knot is tied here and closed at the bottom of
the file. One forward reference rather than moving a 90-line function. *)
let check_fn_ref : (env -> Ast.fn -> Tast.fn) ref =
ref (fun _ _ -> assert false)
(* ── Small helpers over the AST ────────────────────────────────────── *)
(* Untyped literals: their machine type comes from context, so when one is an
@ -4225,6 +4404,9 @@ and named_call ctx ~want loc name args =
(match lookup ctx name with
| Some b -> call_value ctx ~want loc (mk loc b.bty (Tast.Local b.slot)) args
| None -> assert false)
| _ when Hashtbl.mem ctx.env.gsigs name ->
let vars, params, ret = Hashtbl.find ctx.env.gsigs name in
generic_call ctx ~want loc name vars params ret args
| _ ->
match Hashtbl.find_opt ctx.env.fns name with
| Some (params, ret) ->
@ -4257,6 +4439,97 @@ and named_call ctx ~want loc name args =
(Printf.sprintf "the call %s into an imported package" name) 4
else Loc.failk "check/unknown-function" loc "unknown function %s" name
(* ── A call to a generic function ───────────────────────────────────────
The whole of instantiation, and it is at the call site because the call
site is the only place the concrete types exist. Odin does the same thing
in the same place: [check_expr.cpp]'s
[find_or_generate_polymorphic_procedure] runs from call checking, builds
the concrete proc type from the operands, scans the base entity's
[gen_procs] for an [are_types_identical] match, and generates a new
[Entity] only on a miss. *)
and generic_call ctx ~want loc name vars pats pret args =
if List.length args <> List.length pats then
fail loc "%s takes %d argument%s, given %d" name (List.length pats)
(if List.length pats = 1 then "" else "s") (List.length args);
(* Arguments first, and with no expectation where the parameter's type still
mentions a variable there is nothing to expect until the argument has
said what it is. So an untyped literal falls to its own default and
[(id 3)] instantiates at i32, which is the one place inference at a
generic call site is weaker than at a monomorphic one. *)
let targs =
map2_lr
(fun p a -> if generic_ty p then check ctx a else check ctx ~want:p a)
pats args
in
let subst = ref [] in
List.iter2
(fun p (a : Tast.expr) ->
if not (bind_ty subst p a.Tast.ty) then
fail a.Tast.loc "%s expects %s here, found %s" name
(Types.to_string p) (Types.to_string a.Tast.ty))
pats targs;
(* Every variable has to be determined by an argument. A return-only
variable has nothing to bind it there is no explicit instantiation
syntax by design (plan.org) so it is refused here, where the signature
can be named, rather than producing a copy with a hole in it. *)
List.iter
(fun v ->
if not (List.mem_assoc v !subst) then
fail loc
"%s's type variable $%s is not determined by any argument — a \
generic function is instantiated from its call site, and there is \
no syntax for naming the type" name v)
vars;
let cparams = List.map (subst_ty !subst) pats in
let cret = subst_ty !subst pret in
let sym = instantiate ctx.env loc name vars !subst cparams cret in
expect loc ~want (mk loc cret (Tast.Call (sym, targs)))
(* Cache or generate, Odin's loop. The key is the whole concrete signature
compared pairwise with [Types.equal] [are_types_identical] so calling
at the same type twice makes one copy. *)
and instantiate env loc gname vars subst cparams cret =
let cache =
match Hashtbl.find_opt env.insts gname with
| Some r -> r
| None -> let r = ref [] in Hashtbl.replace env.insts gname r; r
in
let same (ps, r, _) =
List.length ps = List.length cparams
&& List.for_all2 Types.equal ps cparams && Types.equal r cret
in
match List.find_opt same !cache with
| Some (_, _, sym) -> sym
| None ->
let sym =
gname ^ "-"
^ String.concat "-" (List.map (fun v -> mangle_ty (List.assoc v subst)) vars)
in
if Hashtbl.mem env.fns sym then
fail loc
"%s at these types is called %s, and %s is already defined — rename \
one of them" gname sym sym;
(* The entry goes in *before* the body is checked, which is what makes a
recursive generic function terminate: the call to itself at the same
types finds this and does not generate a second copy. *)
cache := (cparams, cret, sym) :: !cache;
Hashtbl.replace env.fns sym (cparams, cret);
let fn = Hashtbl.find env.generics gname in
let saved_subst = env.subst and saved_vars = env.tyvars in
(* Inside the copy there are no variables left: [resolve_name] answers
[t] with the concrete type, so every node the body produces is as
concrete as one written out by hand. *)
env.subst <- List.map (fun v -> (v, List.assoc v subst)) vars;
env.tyvars <- [];
let restore () = env.subst <- saved_subst; env.tyvars <- saved_vars in
let tfn =
match !check_fn_ref env { fn with Ast.name = sym } with
| tfn -> restore (); tfn
| exception e -> restore (); raise e
in
env.instances <- tfn :: env.instances;
sym
and is_cast name =
Types.ikind_of_name name <> None || Types.fkind_of_name name <> None
@ -4524,13 +4797,24 @@ let collect env (decls : Ast.decl list) =
Hashtbl.replace env.cases c.Tast.vname (n, c))
cases
| Ast.Defn fn ->
(* A signature that introduces a type variable is a *pattern*, not a
signature: it goes in [gsigs] and the function goes nowhere near
[fns], because nothing can be called at [t]. Every call site turns
it into an ordinary entry. *)
let vars = signature_tyvars fn in
env.tyvars <- vars;
let params =
List.map (fun (p : Ast.field) -> resolve env p.Ast.fty) fn.Ast.params
in
let ret =
match fn.Ast.ret with None -> Types.Unit | Some t -> resolve env t
in
Hashtbl.replace env.fns fn.Ast.name (params, ret)
env.tyvars <- [];
if vars = [] then Hashtbl.replace env.fns fn.Ast.name (params, ret)
else begin
Hashtbl.replace env.generics fn.Ast.name fn;
Hashtbl.replace env.gsigs fn.Ast.name (vars, params, ret)
end
| Ast.Defvar (n, t, _) ->
let ty = match t with
| Some t -> resolve env t
@ -4597,7 +4881,7 @@ let check_finite env =
(* ── Declarations: pass 2, check bodies ────────────────────────────── *)
let check_fn env (fn : Ast.fn) : Tast.fn =
let rec check_fn env (fn : Ast.fn) : Tast.fn =
let params, ret = Hashtbl.find env.fns fn.Ast.name in
let ctx = { env; ret; slots = 0; slot_tys = []; slot_names = []; scope = []; defers = [];
outer = []; outer_what = None; in_frames = None; loops = []; tail = false; in_defer = false; defer_ok = false; defer_block = "a nested form"; dead = []; borrow = false;
@ -4684,6 +4968,35 @@ let check_fn env (fn : Ast.fn) : Tast.fn =
normal path has them spliced into [body] above. *)
ret; body; fdefers = ctx.defers; fparent = None; floc = fn.Ast.nloc }
(* The generic body, checked once with its variables abstract. Nothing is kept
the [Tast.fn] it produces is thrown away, and so is anything it lifted
because a generic function has no code: only its instantiations do. What is
kept is the *refusal*: an operator an unconstrained variable does not
support fails here, at the definition, naming the variable, rather than at
whichever call site happened to instantiate it at a type that worked.
The holes in it are real and are the report's business: [println] is
plan.org's one compiler-provided exception and this pass rejects it, and
move-only-ness is not decidable abstractly at all [Types.is_move_only
(Var _)] is false, but the same variable at [(Vec i32)] is move-only. *)
and check_generic env (fn : Ast.fn) =
let vars, params, ret = Hashtbl.find env.gsigs fn.Ast.name in
let saved_lifted = env.lifted and saved_vars = env.tyvars in
env.tyvars <- vars;
Hashtbl.replace env.fns fn.Ast.name (params, ret);
let finish () =
Hashtbl.remove env.fns fn.Ast.name;
env.lifted <- saved_lifted;
env.tyvars <- saved_vars
in
(match check_fn env fn with
| _ -> finish ()
| exception e -> finish (); raise e)
(* The knot from [instantiate]: a call site makes a copy, and making one is
checking a function. *)
let () = check_fn_ref := check_fn
(* A global of move-only type is refused. The dead set is per function, so two
functions each freeing the same global is a double free nothing here could
see; and within one function a global read does not go through [var]'s move
@ -4809,6 +5122,22 @@ let build_program ~keep_going (decls : Ast.decl list) : Tast.program * env =
check_finite env;
let s = Loc.sink ~on:keep_going in
ignore (Loc.caught s (fun () -> check_main env));
(* Every generic body, checked once with its variables left abstract, and
the result thrown away. This is the pass plan.org's rule needs and Odin
has no equivalent of: Odin checks a polymorphic body only per
instantiation, so [a + b] over a [$T] compiles there and fails only if
nobody ever calls it at a numeric type. plan.org says the opposite an
unconstrained variable supports only what every type supports, and [=],
[<], [+] and [hash] over one are *rejected, not silently instantiated*.
Rejecting them means type-checking the body with nothing substituted,
which is this, and it is a second pass over the same source. *)
List.iter
(fun (d : Ast.decl) ->
match d.Ast.d with
| Ast.Defn fn when Hashtbl.mem env.gsigs fn.Ast.name ->
ignore (Loc.caught s (fun () -> check_generic env fn))
| _ -> ())
decls;
let globals =
List.filter_map
(fun d -> Option.join (Loc.caught s (fun () -> check_global env d)))
@ -4818,6 +5147,9 @@ let build_program ~keep_going (decls : Ast.decl list) : Tast.program * env =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with
(* A generic [defn] does not reach the typed IR at all. Only its
instantiations do, and they are collected below. *)
| Ast.Defn fn when Hashtbl.mem env.gsigs fn.Ast.name -> None
| Ast.Defn fn -> Loc.caught s (fun () -> check_fn env fn)
| _ -> None)
decls
@ -4827,6 +5159,11 @@ let build_program ~keep_going (decls : Ast.decl list) : Tast.program * env =
from here down; nothing in the backend knows they were written inside
something else. *)
let fns = fns @ List.rev env.lifted in
(* The copies generics turned into, in the order they were generated. Like a
lifted clause they are ordinary functions from here down but unlike one
they are reached *by name* from arbitrary call sites, so they carry no
[fparent] and a dev build gives each its own cell. *)
let fns = fns @ List.rev env.instances in
(* Sorted, so the emitted IR is reproducible build to build: a Hashtbl's
fold order is not. *)
let values name tbl =

7
spike/generics/id.flan Normal file
View File

@ -0,0 +1,7 @@
(defn id [x $t] t x)
(defn main [] ()
(println (id 3))
(println (id 4.5))
(println (id 7))
(println (id true)))