feat: multiple return values — bare-paren signatures, named returns, must-set, defaults
A function may return multiple values via a bare-paren return signature: `-> (A, B)` / `-> (x: A, y: B)` / `-> (A, B, !)` (error always the last slot), and `-> ()` is `void`. This is DISTINCT from a `Tuple(…)` value — return-position only (a dedicated `ReturnTypeExpr` AST node resolving to a reused `.tuple` TypeId); a parameter / field / variable annotation `x: (A, B)` is rejected. A single-value `-> (T, !)` stays a plain failable (= `-> T !`). Returns use the bare comma form `return a, b` / `return x = a, y = b` (no `.( … )` literal). Consume by destructuring (`a, b := f()`) or single-bind + field access (`c := f(); c.sum`); a failable bound value holds only the value slots (the error stays on the `!` channel). Named return slots are in-scope assignable locals; with no explicit `return` the implicit return is synthesized from them. Path-sensitive definite-assignment enforces the must-set rule, and a slot may carry a default that exempts it. Validation rejects arity mismatches, out-of-slot-order named elements, a slot/parameter name collision, a comma list from a single-value function, and a multi-return signature used as a value type. Examples 0202-0213; readme + specs updated. issues/0197 files a pre-existing annotated-assignment type-check gap (`x: i32 = "hi"` segfaults) surfaced by the adversarial review.
This commit is contained in:
100
current/CHECKPOINT-MULTIRET.md
Normal file
100
current/CHECKPOINT-MULTIRET.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# CHECKPOINT-MULTIRET — bare-paren multi-value + named returns
|
||||
|
||||
Plan: `current/PLAN-MULTIRET.md`. Branch: `feat/multi-return`.
|
||||
|
||||
## Last completed step
|
||||
**Phases 0–3 implemented** (final suite + snapshot capture in progress). Examples
|
||||
renumbered to the free `types` block 0202–0206 (0130/0131 already had duplicate
|
||||
existing owners).
|
||||
- **Phase 0** — empty `()` in the type path → `void`. (0202)
|
||||
- **Phase 1** — multi-return SIGNATURES `(A, B)` / `(x: A, y: B)` / `(A, B, !)`
|
||||
(≥2 value slots) parse to a `tuple_type_expr` tagged `is_multi_return`; a
|
||||
single-value `(T, !)` is a plain failable (= `-> T !`). Return resolver yields
|
||||
the reused tuple TypeId; `resolveParamType` rejects a multi-return tuple
|
||||
(return-position-only). Consumed by destructuring. (0203, 0204)
|
||||
- **Phase 2** — bare comma `return v1, v2` (positional) / `return x = v, y = w`
|
||||
(named): the return parser builds the same `tuple_literal` the `.(…)` form
|
||||
produces. Single positional `return v` unchanged. (used throughout 0203–0205)
|
||||
- **Phase 3** — NAMED-return slots are in-scope assignable LOCALS: bound as
|
||||
zero-init allocas (`bindNamedReturnSlots`), the implicit return is synthesized
|
||||
from them (`synthesizeNamedReturn` → reuses `lowerReturn`), and the MUST-SET
|
||||
rule errors on an unset/undefaulted slot (`bodyAssignsTo`, path-insensitive
|
||||
MVP). Works with the failable error channel too. (0205 positive, 0206 negative)
|
||||
|
||||
Earlier foundation: parser `collectGenericNames` descends tuple/optional/function
|
||||
nodes; generic.zig `extractTypeParam` handle the `(value, !)` tuple.
|
||||
|
||||
## Current state (works, verified by probes)
|
||||
- `() -> ()` ≡ void; `-> (A, B)` / `-> (x: A, y: B)` / `-> (A, B, !)` multi-return.
|
||||
- `return a, b` / `return x = a, y = b` bare comma; named-return locals + implicit
|
||||
return + must-set diagnostic; failable named multi-return.
|
||||
- `(A, B)` in a PARAM slot → loud diagnostic.
|
||||
- Representation: `TupleTypeExpr.is_multi_return` flag + `Lowering.named_return_names`
|
||||
state (reuses tuple ABI; no new TypeInfo variant; multi-return-ness derivable
|
||||
from the FnDecl AST).
|
||||
|
||||
## Post-Phase-3 changes (this session)
|
||||
- **Representation refactored to a dedicated `ReturnTypeExpr` AST node** (user
|
||||
preferred it over the `TupleTypeExpr.is_multi_return` flag). Resolves to a
|
||||
reused `.tuple` TypeId via the shared `internTupleLike` helper. Forced
|
||||
`.return_type_expr` arms onto the exhaustive `node.data` switches (sema,
|
||||
semantic_diagnostics) — the coverage benefit. Param-position reject + the
|
||||
named-return-locals / must-set sites now key off `.return_type_expr`.
|
||||
- **Destructure-only enforcement REVERSED** (user): single-binding a multi-return
|
||||
is ALLOWED. `c := f(); c.sum` works (the result is a tuple of the value slots).
|
||||
For a failable multi-return, `c := f() catch …` binds only the value slots —
|
||||
the error stays on the `!` channel (verified). The `callIsMultiReturn` reject
|
||||
was removed. Examples 0203/0204 updated to show single-bind + field access
|
||||
(output byte-identical, snapshots unchanged).
|
||||
|
||||
## Phase 4 — named-return DEFAULTS (done, suite pending)
|
||||
`-> (sum: i32 = 0, good: bool)`: the parser parses `= <expr>` per slot into
|
||||
`ReturnTypeExpr.field_defaults`; `bindNamedReturnSlots` seeds a defaulted slot
|
||||
with its (lowered+coerced) default; a defaulted slot is EXEMPT from the must-set
|
||||
rule. Also fixed `hasFnBodyAfterArrow` (the fn-def-vs-type-const lookahead) to
|
||||
skip `=` + literal tokens in the return-type scan — otherwise a `=` made the decl
|
||||
misread as a bodyless type-const ("expected ';'" at the body `{`). Lock: 0207.
|
||||
|
||||
## Adversarial-review fixes (this session)
|
||||
An adversarial review found 8 issues; fixed the soundness + silent-wrong ones:
|
||||
- **#1 (segfault on a conditionally-assigned non-scalar slot)** → must-set is now
|
||||
PATH-SENSITIVE definite-assignment (`definitelyAssigns`, stmt.zig): a slot not
|
||||
assigned on every non-diverging path (and undefaulted) is a COMPILE ERROR, not
|
||||
a runtime garbage read. `return`/`raise` count as divergence; `if` needs both
|
||||
branches; `push` bodies count; `match` needs an else arm + all arms.
|
||||
- **#2 (wrong-type default → segfault)** → `bindNamedReturnSlots` type-checks a
|
||||
default via the coercion classifier (`.none` ⇒ diagnostic). (NOTE: the same
|
||||
silent bitcast/segfault exists for ANY annotated assignment `x: i32 = "hi"` — a
|
||||
broader PRE-EXISTING type-checking gap, not multi-return-specific.)
|
||||
- **#3 / #8 (return arity garbage)** + **#4 (named elements ignored)** →
|
||||
`validateMultiReturn` (stmt.zig, called from `lowerReturn`): rejects a bare
|
||||
value where ≥2 are required, wrong arity, a comma list from a single-value fn,
|
||||
and named elements out of slot order. (Reordering-by-name is a future nicety;
|
||||
for now a mismatch is a loud error, never silent-wrong.)
|
||||
- **#5 (slot shadows param)** → collision diagnostic in `bindNamedReturnSlots`.
|
||||
- **#7 (push/defer false must-set error)** → subsumed by the DA rewrite (push
|
||||
bodies count; defer correctly does NOT, as it runs after the implicit return).
|
||||
|
||||
## Known limitations / next
|
||||
- **#6 (design gap, NOT UB)**: a `ReturnTypeExpr` is still silently accepted in
|
||||
struct-field / var-annotation / generic-arg / closure-RETURN positions (resolves
|
||||
to a coherent tuple). Only the PARAM position is rejected. Rejecting the rest
|
||||
needs checks at several value-resolution sites; deferred (no soundness impact).
|
||||
- **Reordering named return elements by name** (vs requiring slot order) — future.
|
||||
- **PRE-EXISTING**: annotated-assignment type mismatch (`x: i32 = "hi"`) segfaults
|
||||
— a general type-checking gap surfaced by the review; may warrant an issue.
|
||||
- Multi-return CLOSURE-TYPE values / lambda literals deferred (D3).
|
||||
- Docs: readme.md / specs.md not yet updated for multi-return (docs-track rule).
|
||||
|
||||
## Known issues
|
||||
- (none yet)
|
||||
|
||||
## Log
|
||||
- Pivoted here from the Io-unification Phase 3 (true cancellation), which is
|
||||
PAUSED at its blocker: capturing a failable closure into a nested closure loses
|
||||
its failability (`worker() catch` → operand type 'unresolved'; repro
|
||||
`.sx-tmp/pD.sx`/`pE.sx`). That capture-typing gap is unrelated to multi-return
|
||||
and waits for a later session. The Io-Phase-3 stdlib edits (core/sched/io +
|
||||
example 1825) were REVERTED to keep the tree green; the multi-return-relevant
|
||||
compiler changes were kept.
|
||||
- Foundation landed + suite green; plan + checkpoint written.
|
||||
156
current/PLAN-MULTIRET.md
Normal file
156
current/PLAN-MULTIRET.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# PLAN-MULTIRET — bare-paren multi-value returns + named returns
|
||||
|
||||
## Why
|
||||
sx already has multi-value returns, but only in a verbose spelling:
|
||||
`-> Tuple(A, B)` / `-> Tuple(x: A, y: B)` types and `return .(a, b)` /
|
||||
`return .(x = a, y = b)` tuple-literal returns. Destructuring (`a, b := f()`),
|
||||
named/positional field access (`r.x` / `r.0`), and value-carrying failables
|
||||
(`Tuple(A, B) !E`) all work on top of the existing `.tuple` TypeId.
|
||||
|
||||
The user wants the ergonomic, canonical surface:
|
||||
|
||||
```sx
|
||||
a :: () -> () { } // () ≡ void
|
||||
two :: () -> (i32, bool) { return 42, true; } // bare-paren type + bare comma return
|
||||
b :: (f1: i32, f2: i32) -> (sum: i32, good: bool) { // named returns are in-scope locals
|
||||
good = true;
|
||||
sum = f1 + f2; // implicit return: all named slots set
|
||||
}
|
||||
b2 :: (f1: i32, f2: i32) -> (sum: i32, good: bool) {
|
||||
return f1 + f2, f2 > 42; // bare comma return still works
|
||||
}
|
||||
read :: () -> (i32, bool, !) { ... } // error channel ALWAYS the last slot
|
||||
```
|
||||
|
||||
Rules (from the user):
|
||||
- **`() -> ()` ≡ `() -> void`.**
|
||||
- **A multi-return signature is NOT a tuple — it just REUSES the tuple machinery.**
|
||||
`-> (i32, bool)` / `-> (x: i32, y: bool)` mean "this function returns multiple
|
||||
values", a DISTINCT thing from `-> Tuple(i32, bool)` (which returns one tuple
|
||||
value). The bare-paren form is valid ONLY as a function/closure RETURN
|
||||
signature — `x: (A, B)` (a variable/param/field annotation) stays REJECTED;
|
||||
`Tuple(…)` is the spelling for an actual tuple value type.
|
||||
- **Consumption — destructure OR single-bind (REVISED 2026-06-27).** A
|
||||
multi-return result may be DESTRUCTURED (`s, g := b2()`) OR bound to a single
|
||||
name and reached by field (`c := b2(); c.sum` / `c.0`). The earlier
|
||||
destructure-only rule (single-bind = error) was REVERSED by the user — single
|
||||
binding is allowed; the bound value behaves like a tuple of the value slots.
|
||||
- **Failable: the error stays SEPARATE.** For `-> (sum, good, !)`, a bound
|
||||
value (`c := f() catch …` / `try`) holds ONLY the value slots — the error
|
||||
rides the `!` channel and is NEVER part of `c` (no `c.err`). This falls out of
|
||||
the existing failable machinery (catch/try strip the error before binding).
|
||||
- **Failable: the error channel is always the LAST slot** (`(A, B, !)`).
|
||||
- **Bare comma return**: `return v1, v2;` maps positionally to the return slots —
|
||||
no `.(…)` tuple literal needed.
|
||||
- **Named returns are assignable locals.** With no explicit `return`, an implicit
|
||||
return at end-of-body synthesizes the result from the named locals. **A named
|
||||
return that is neither assigned on the path nor given a default is a COMPILE
|
||||
ERROR.** A named slot may carry a default (`(sum: i32 = 0, good: bool)`); a
|
||||
defaulted slot needn't be assigned.
|
||||
|
||||
## Representation (how "not a tuple, reuse machinery" is realized) — AS BUILT
|
||||
- A dedicated AST node **`ReturnTypeExpr`** (`field_types` + optional
|
||||
`field_names`, same shape as a tuple) is produced by the parser for a bare-paren
|
||||
result list with **≥2 value slots** (`(A, B)`, `(x: A, y: B)`, `(A, B, !)`). A
|
||||
single-value `(T, !)` stays a `tuple_type_expr` (a plain failable, `= -> T !`).
|
||||
An EMPTY `()` parses to the `void` type.
|
||||
- It resolves (type_resolver `internTupleLike`, shared with `tuple_type_expr`) to
|
||||
a reused `.tuple` TypeId — full ABI / failable / destructure / field-access
|
||||
machinery reuse. Its distinct MEANING lives in the AST node, not the TypeId.
|
||||
- Position gating: the node is valid only in a return slot. `resolveParamType`
|
||||
rejects a `ReturnTypeExpr` parameter annotation ("multi-return is return-only;
|
||||
use Tuple(…)"). Being a distinct node, its mere appearance in a value-type
|
||||
position is categorically an error (no flag to check) — exhaustive `switch`es
|
||||
over `node.data` were forced to add a `.return_type_expr` arm (coverage).
|
||||
- Consumption: destructure (`a, b := f()`) or single-bind + field access
|
||||
(`c := f(); c.sum`). No single source of truth needed at call sites — the
|
||||
result is just a tuple value.
|
||||
- SCOPE: multi-return on `name :: (...) -> (…) { }` function declarations first.
|
||||
Multi-return CLOSURE-TYPE values (`cb: Closure() -> (A, B)`) and lambda
|
||||
literals are a later phase.
|
||||
|
||||
## What already exists (re-use, do NOT rebuild)
|
||||
- `tuple_type_expr` → `.tuple` TypeId with optional `names` (type_resolver.zig
|
||||
`resolveCompound`).
|
||||
- Named + positional tuple field access `r.x` / `r.0` (expr.zig
|
||||
`lowerFieldAccessOnType`).
|
||||
- Destructuring `a, b := f()` (`DestructureDecl`, stmt.zig).
|
||||
- Value-carrying failable assembly `(T1, …, !)` (error.zig
|
||||
`lowerFailableSuccessReturn` / `emitTupleRet`) — error in the last slot.
|
||||
- `return .(a, b)` / `return .(x = a, y = b)` tuple-literal returns (stmt.zig
|
||||
`lowerReturn`).
|
||||
- Generic inference through a failable/tuple closure return (this session's
|
||||
parser `collectGenericNames` + generic.zig `extractTypeParam` tuple arms).
|
||||
|
||||
## Foundation already landed (uncommitted, suite-green)
|
||||
- **parser.zig** — `collectGenericNames` descends tuple/optional/function nodes
|
||||
(so `Closure() -> $R !` binds `$R`); the bare-paren result-list path builds a
|
||||
failable `tuple_type_expr` when it ends in `!` (`(A, B, !)` parses).
|
||||
- **generic.zig** — `extractTypeParam` / `matchTypeParam[Static]` handle the
|
||||
`(value, !)` tuple so `$R` infers from a closure ARG's failable return.
|
||||
|
||||
## Phases (each: implement → lock with an example → `zig build test` green)
|
||||
|
||||
0. **`() -> ()` = void (parser).** Isolated, unambiguous. An empty `()` in the
|
||||
paren type path resolves to `void`. Lock: `a :: () -> () { }`.
|
||||
|
||||
1. **Multi-return signatures `-> (A, B)` / `-> (x: A, y: B)` / `-> (A, B, !)`
|
||||
(parser + AST + resolution).** Add the `multi_return_type` AST node; the parser
|
||||
produces it for a bare-paren result list (return position). The return resolver
|
||||
lowers it to a `.tuple` TypeId and sets `Function.multi_return`; the general
|
||||
resolver rejects it (return-position only). Returns still use the existing
|
||||
`return .(…)` literal in this phase (bare comma is Phase 2). Consumption is
|
||||
destructuring `a, b := f()` (existing machinery). Lock: positional + named +
|
||||
failable multi-return examples, each destructured.
|
||||
|
||||
2. **Destructure-only enforcement + bare comma `return v1, v2` (parser + lowering).**
|
||||
(a) Reject using a multi-return call as a single value (`r := f()`, an arg, an
|
||||
operand) — read `Function.multi_return` at the binding/use site; only
|
||||
destructuring is allowed. (b) Extend the return statement to parse a
|
||||
comma-separated value list and lower it to the same multi-slot return the
|
||||
`.(…)` literal produces (error slot stays implicit for failables). Single-value
|
||||
`return v` unchanged. Lock: `-> (i64, bool) { return 7, true; }`, a failable
|
||||
variant, and a negative example (`r := f()` → diagnostic).
|
||||
|
||||
3. **Named-return locals + must-set rule (sema/lowering).** For a named return
|
||||
`-> (x: A, y: B)`, bind each name as an in-scope assignable local (alloca). On
|
||||
a path that reaches end-of-body with NO explicit `return`, synthesize the
|
||||
implicit return from the named locals. Diagnose loudly if any named slot is
|
||||
neither assigned on that path nor defaulted (no silent zero-fill). Explicit
|
||||
`return v1, v2` / `return .(…)` still override. Lock: the
|
||||
`b :: (...) -> (sum, good) { good = true; sum = ... }` example + a negative
|
||||
example (unset slot → diagnostic).
|
||||
|
||||
4. **Named-return defaults `(sum: i32 = 0, good: bool)`.** A slot with a default
|
||||
is exempt from the must-set rule; the default fills it at the implicit (or
|
||||
partial explicit) return. Lock: an example mixing a defaulted + a required
|
||||
slot.
|
||||
|
||||
## Open decisions (Decisions Log)
|
||||
- **D1 — multi-return is NOT a tuple; return-position-only.** *Chosen* (user
|
||||
directive). Realized via a distinct **`ReturnTypeExpr` AST node** (the user
|
||||
preferred a dedicated node over a `TupleTypeExpr.is_multi_return` flag — it
|
||||
makes "not a tuple" true at the AST level and makes position-gating
|
||||
categorical) that resolves to a reused `.tuple` TypeId. A new `.tuple`-like
|
||||
TypeInfo variant was rejected — it would ripple through every exhaustive type
|
||||
switch for no ABI benefit. **Destructure-only was REVERSED** (see Rules):
|
||||
single-binding a multi-return result is allowed (field access on the value
|
||||
slots); the failable error stays on the separate `!` channel.
|
||||
- **D2 (Phase 3) — storage for named-return locals.** Lean: an alloca per named
|
||||
slot bound in the function scope under its name; the implicit return reads them
|
||||
into the result tuple. Revisit if the must-set analysis wants SSA-style
|
||||
definite-assignment instead of an alloca + per-path check.
|
||||
- **D3 — multi-return closure-type values / lambda literals.** Deferred past the
|
||||
function-decl phases (needs a `ClosureInfo.multi_return` flag). Phases 0–4 cover
|
||||
named function declarations only.
|
||||
|
||||
## Validation (every phase)
|
||||
- `zig build && zig build test` green (full corpus).
|
||||
- New `examples/<category>/…` locked with snapshots; review the diff for `.ir`
|
||||
churn only where expected (the prelude type table is untouched by this stream,
|
||||
so churn should be minimal/none).
|
||||
- Adversarial review of each phase before it lands.
|
||||
|
||||
## Category for examples
|
||||
Multi-return is a core type/return feature — use the `types` block (`01xx`),
|
||||
next free numbers, unless a better fit emerges.
|
||||
Reference in New Issue
Block a user