Iter 7: first-class function references (no capture)
Top-level fn names are now usable as values, fn-typed parameters can be called as `f(args)`. KISS slice of "closures + HOFs + TIR": no TIR, no heap, no ABI shift — that bundle stays in Iter 8 where closure-with-capture and lambdas land together. Codegen: - Type::Fn lowers to LLVM `ptr`; sig travels via a per-fn-body `ssa_fn_sigs` sidetable on `Emitter`. - Term::Var falls through to `resolve_top_level_fn` when the name is not a local; emits `@ail_<m>_<def>` and registers the sig. - Term::App splits: static callee (builtin / current-module / qualified) keeps the existing direct path; otherwise lower the callee, look up the sig, emit indirect `call <ret> (<param-tys>) %fn(args...)`. - emit_fn registers fn-typed params in the sidetable on entry. - If branches propagate a fn-pointer sig to their phi when both arms match. Typechecker: unchanged. It already accepted `synth(callee)` against Type::Fn; the only blocker was codegen rejecting non-Var callees. Tests: 48 green (was 47). New `examples/hof.ail.json` and e2e `higher_order_apply_inc` exercise `apply(inc, 41) == 42` end-to-end. DESIGN.md: "What is not (yet) supported" rewritten — closures-with- capture and lambdas remain pending, first-class fn-refs added as positive bullet. Smoke-test list extended with `hof.ail.json`. JOURNAL.md: Iter 7 entry with rationale, scope choice, architecture self-check, Iter 8 plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-5
@@ -229,11 +229,13 @@ ail build <module> — full pipeline → binary
|
||||
|
||||
## What is not (yet) supported
|
||||
|
||||
Snapshot of the boundary at the end of Iter 6. Items move out of this list
|
||||
Snapshot of the boundary at the end of Iter 7. Items move out of this list
|
||||
as iterations land; the JOURNAL records the exact iteration.
|
||||
|
||||
- No closures / higher-order functions. Will require a typed IR stage (TIR)
|
||||
before lowering.
|
||||
- No closures with capture / no anonymous lambdas. Both will require a
|
||||
typed IR stage (TIR) and closure conversion in lowering. First-class
|
||||
function references (top-level fns as values, fn-typed parameters,
|
||||
indirect calls) **are** supported as of Iter 7 — see below.
|
||||
- No effect handlers — only the built-in IO and Diverge ops.
|
||||
- No refinements / SMT escalation.
|
||||
- No cross-module ADTs. ADTs are local to a module; ctor names must be
|
||||
@@ -253,6 +255,14 @@ What **is** supported (and used as the smoke test for the pipeline):
|
||||
pattern are restricted to `Var` / `Wild`.
|
||||
- **Imports + qualified cross-module references** via dotted names
|
||||
(Iter 5).
|
||||
- **First-class function references** (Iter 7). A top-level fn name (or
|
||||
qualified `prefix.def`) used as a `Term::Var` is a fn-pointer value of
|
||||
AILang type `Type::Fn { ... }` and LLVM type `ptr`. Fn-typed parameters
|
||||
may be called directly as `f(args)`. No capture, no lambdas — the only
|
||||
way to construct a fn-value is to refer to a top-level def.
|
||||
|
||||
Pipeline regression smoke test: `examples/sum.ail.json` produces a
|
||||
binary that prints 55.
|
||||
Pipeline regression smoke tests:
|
||||
|
||||
- `examples/sum.ail.json` → prints 55 (recursion, arithmetic).
|
||||
- `examples/list.ail.json` → prints 42 (ADTs + match).
|
||||
- `examples/hof.ail.json` → prints 42 (first-class fn-refs, indirect call).
|
||||
|
||||
@@ -430,3 +430,92 @@ stage, closure conversion in lowering, and a heap-aware ABI. The
|
||||
multi-diag refactor in Iter 6 was scoped intentionally minimal — when
|
||||
TIR lands, intra-def diagnostics become structurally cheap and Task #20
|
||||
gets revisited.
|
||||
|
||||
## 2026-05-07 — Iter 7 done: first-class function references (no capture)
|
||||
|
||||
Iter 6 outlined Iter 7 as "closures + HOFs + TIR". KISS course-correct
|
||||
on inspection: that bundle had three independent things in it, and the
|
||||
HOF use-cases (passing functions around, calling through fn-typed
|
||||
parameters) need none of TIR or capture. Splitting paid off — what
|
||||
landed here is ~120 LOC of codegen, no TIR, no heap, no ABI churn.
|
||||
Closures with capture stay queued for Iter 8 (where TIR is the
|
||||
correct precondition).
|
||||
|
||||
**What works now:**
|
||||
- Top-level fn name (or qualified `prefix.def`) used as a value yields
|
||||
an LLVM fn-pointer (`@ail_<m>_<def>`, type `ptr`).
|
||||
- Fn-typed parameters can be called as `f(args)` — the body emits an
|
||||
indirect `call <ret> (<param-tys>) %f(...)`.
|
||||
- Pass through `let`: `let g = inc in g(x)` works (the local just
|
||||
aliases the global SSA, the sidetable lookup still hits).
|
||||
- Pass to another fn: `apply(inc, 41) == 42` — see
|
||||
`examples/hof.ail.json`, exercised end-to-end.
|
||||
|
||||
**What does not (yet) work — by design:**
|
||||
- No anonymous lambdas. The only fn-value source is a top-level def
|
||||
reference.
|
||||
- No capture. A fn-value is always a constant pointer to a top-level
|
||||
def; there is no environment to allocate.
|
||||
- Both deferred to Iter 8 where they share the TIR + closure-conversion
|
||||
preconditions.
|
||||
|
||||
**Implementation, in order of where the rubber meets the road:**
|
||||
|
||||
1. `llvm_type` learned `Type::Fn { .. } -> "ptr"`. The actual signature
|
||||
travels separately. New helper `fn_sig_from_type` lifts an AILang
|
||||
fn-type into an `FnSig` (LLVM types only).
|
||||
2. `Emitter` got a sidetable: `ssa_fn_sigs: BTreeMap<String, FnSig>`,
|
||||
keyed by SSA value (or `@global`). It's reset per function body.
|
||||
3. At `emit_fn` entry, every fn-typed parameter registers
|
||||
`(%arg_<name>, sig)` in the sidetable.
|
||||
4. `lower_term(Term::Var)` now falls through to a top-level fn lookup
|
||||
(`resolve_top_level_fn`) when the name isn't a local. The returned
|
||||
SSA is the global symbol; the sidetable gets the sig.
|
||||
5. `lower_term(Term::App)` dispatches:
|
||||
- if callee is a `Var` AND not shadowed AND statically known
|
||||
(`is_static_callee` covers builtin operators, qualified
|
||||
`prefix.def`, current-module fns), keep the existing direct
|
||||
`lower_app` path — no extra indirection in the IR;
|
||||
- otherwise lower the callee, expect type `ptr`, look up the sig in
|
||||
the sidetable, emit `emit_indirect_call`.
|
||||
6. `Term::If` propagates the sig to its phi SSA when both branches are
|
||||
fn-pointers with matching sigs (cheap two-line copy; no separate
|
||||
test, falls out of the `apply`-on-conditional pattern).
|
||||
|
||||
**Why no typechecker change?** The typechecker already accepted
|
||||
fn-typed locals (`Term::Var` against `env.globals`, App via `synth(callee)`
|
||||
unifying with `Type::Fn`). The only blocker was `MVP: callee must be a
|
||||
variable` in codegen.
|
||||
|
||||
**Tests:** 48 green (previously 47).
|
||||
|
||||
- `crates/ail/tests/e2e.rs::higher_order_apply_inc` builds and runs
|
||||
`examples/hof.ail.json`, asserts the binary prints `42`.
|
||||
- Existing tests unchanged (incl. snapshot tests around the IR
|
||||
emission for `sum`, `list`, `max3`).
|
||||
|
||||
**Architecture self-check:**
|
||||
|
||||
- *Would I use this language now?* Yes for `apply`-style and "pass a
|
||||
predicate" patterns. Still no for capturing closures (`let n = 3 in
|
||||
map(\x -> x + n, xs)`-equivalent), but the ergonomic gap shrank.
|
||||
- *Consistency:* DESIGN.md "What is not (yet) supported" rewritten in
|
||||
the same edit; first-class fn-refs now have a positive bullet, the
|
||||
closures bullet is precise about what it means (no capture, no
|
||||
lambdas).
|
||||
- *Visualisation:* `ail describe`/`manifest` already render fn-typed
|
||||
params correctly via the existing `pretty::type_to_string`
|
||||
(`((Int) -> Int, Int) -> Int`). No tooling change required.
|
||||
- *KISS:* every alternative I considered (full `LocalType` enum,
|
||||
swapping `(String, String)` returns to a typed wrapper, lifting
|
||||
lambdas to defs as syntactic sugar) was strictly more code than the
|
||||
sidetable approach, with no expressivity gain.
|
||||
|
||||
**Plan iteration 8:**
|
||||
|
||||
Closures with capture, anonymous lambdas, the typed IR (TIR) layer,
|
||||
closure conversion in lowering. Now that we have indirect calls
|
||||
working, the main delta is: a fn-value also needs an environment
|
||||
pointer, the sidetable becomes per-value (heap-allocated), and the
|
||||
calling convention shifts to `(env_ptr, args...)`. Touches every
|
||||
existing call path — that's why it gets its own iteration.
|
||||
|
||||
Reference in New Issue
Block a user