Multiple Returns and Varargs
Three concepts: list adjustment, the "all values" sentinel, and varargs.
This is the feature where the two backends are most likely to diverge, which is exactly why differential testing was built before it arrived. Expect Lab 17 to break your test suite. That is the system working.
Concept 1: The Call Site Decides
1. Concept
A function can return any number of values. How many of them survive is decided by where the call appears, not by the function.
local function three() return 1, 2, 3 end
local a, b, c, d = three() -- 1 2 3 nil adjusted UP with nil
local x, y = three() -- 1 2 adjusted DOWN
print(three()) -- 1 2 3 LAST in a list → all of them
print(three(), "z") -- 1 z not last → truncated to ONE
print((three())) -- 1 parentheses ALWAYS truncate
local t = {three()} -- #t == 3
local u = {three(), "z"} -- #u == 2
2. Problem
Every other expression in the language produces exactly one value, and the compiler relies on that:
compile_expr leaves exactly one value on the stack.
Calls break the invariant, and they break it in a way that depends on syntactic context.
3. Mental model
Most expressions produce one value. Two — a call and
...— produce a list. A list is adjusted to the number of values its context wants: padded withnil, or truncated. Only in the final position of a list does the list stay open.
local a, b, c = f(), g()
│ └── LAST in the list → contributes ALL its values
└─────── not last → truncated to exactly ONE
so: a = (first value of f)
b = (first value of g)
c = (second value of g), or nil
4. Implementation
The rule lives in one function in each backend, and that is the only way to keep them in step:
#![allow(unused)] fn main() { /// Compile a list of expressions producing exactly `want` values on the stack. /// `want == MULTI` means "leave the last expression open". fn compile_exprlist(&mut self, exprs: &[Expr], want: Want) -> Result<()> { if exprs.is_empty() { if let Want::Exactly(n) = want { for _ in 0..n { self.emit(Op::LoadNil, span) } } return Ok(()); } // Every expression EXCEPT the last is truncated to one value. for e in &exprs[..exprs.len() - 1] { self.compile_expr(e)?; } let last = exprs.last().unwrap(); match (is_multi_valued(last), want) { // The last expression is a call or `...`, and the context wants all: // leave it open with the 255 sentinel. (true, Want::All) => self.compile_multi(last, 255)?, // The context wants exactly n: ask the call for exactly what remains. (true, Want::Exactly(n)) => { let already = exprs.len() - 1; self.compile_multi(last, (n.saturating_sub(already)) as u8)?; } // An ordinary expression: one value, then pad. (false, w) => { self.compile_expr(last)?; if let Want::Exactly(n) = w { for _ in exprs.len()..n { self.emit(Op::LoadNil, span) } } } } Ok(()) } /// A call or `...` is multi-valued. A PARENTHESIZED one is not — which is the /// only reason Expr::Paren exists. fn is_multi_valued(e: &Expr) -> bool { matches!(e, Expr::Call { .. } | Expr::Method { .. } | Expr::Vararg { .. }) } }
Expr::Paren is the whole reason
the AST chapter's "no Paren node" rule
has an exception. (f()) must truncate to one value, so the parentheses carry meaning and cannot
be discarded at parse time. Add the node in Lab 17, and add a comment on it saying it exists for
exactly this and nothing else — otherwise someone will "clean it up".
5. Alternatives
| Option | How multiple values are returned | Systems |
|---|---|---|
| A. Open lists with context adjustment (ours, Lua) | the stack, with a count | Lua, Common Lisp (values), Scheme |
| B. Return a tuple/array object | one heap object | Python, JavaScript (destructuring), Rust |
| C. Out-parameters | caller-supplied slots | C, Go(ish) |
| D. Single return only | — | Java before records; forces a wrapper class |
Option B is by far the most common modern answer and it is simpler: one value, one object,
destructuring at the call site is sugar. Its cost is an allocation per multi-return, which for a
language that returns value, error from everything is a lot of allocation. Lua's design is an
allocation-avoidance measure, and that is worth knowing — it explains why the complexity is
concentrated in the calling convention rather than in the type system.
6. Decision
A. Ember is Lua-like, and every idiom that makes Lua pleasant —
local ok, err = pcall(f),for k, v in pairs(t),return nil, "message"— depends on it.
7. Tradeoffs
| We gain | We lose |
|---|---|
| Zero-allocation multiple returns | The compile_expr = +1 invariant gains an exception |
Idiomatic ok, err and iterator protocols | A syntactic rule ((f())) with semantic force |
select('#', ...) can distinguish "three args" from "three non-nil args" | Two backends with a genuinely fiddly rule to keep in step |
8. Production concerns
- The result count is attacker-controlled.
return table.unpack(t)with a million-elementtpushes a million values onto the stack. Cap it: Lua errors with "too many results to unpack". Ember checks against the memory budget and a hard result limit, before pushing. - Every list context must use the one function. Assignment right-hand sides,
localinitializers, call arguments, table constructors, andreturnstatements are all lists. If any of them hand-rolls the adjustment, it will be subtly different — and it will be the one the corpus does not cover.
9. References
rg -n 'LUA_MULTRET|luaD_poscall|moveresults|adjust_assign' ldo.c lparser.c
rg -n 'OP_CALL|OP_RETURN|OP_VARARG' lvm.c lopcodes.h
- Lua's
ldo.c:moveresultsis the adjustment, andLUA_MULTRETis the sentinel (defined as-1; Ember uses255because its operand is unsigned). - Lua's
lparser.c:adjust_assignandluaK_setreturns— the compiler half. - Lua 5.4 Reference Manual §3.4.12 ("The Length Operator" is elsewhere; look for §3.4 on adjustment
and §6.1's
select). - Common Lisp's
values/multiple-value-bind, for the same idea with different syntax and a 40-year head start.
Concept 2: The Sentinel and the Stack
1–3. Concept, problem, mental model
A count operand of 255 means "however many there are". The VM cannot know the number at compile time —
f()might return two values today and five tomorrow — so the instruction says "all", and the runtime stack top supplies the answer.
return f() ← how many values? Whatever f returns.
CALL 0, 255 ← "call f with 0 args, keep ALL results"
RETURN 255 ← "return everything above the frame's base"
The stack top is the count. That is why a stack machine handles this gracefully and why the
adjustment logic lives in CALL and RETURN rather than anywhere else.
4. Implementation
#![allow(unused)] fn main() { // In do_return: `n == 255` means "everything from `base` upward". let count = if n == 255 { self.stack.len() - frame.result_base } else { n as usize }; // In do_call's completion: adjust to what the CALLER asked for. match frame.want { 255 => self.stack.extend_from_slice(&results), // all of them w => for i in 0..w as usize { self.stack.push(results.get(i).copied().unwrap_or(Value::Nil)) }, } }
Warning:
255is a sentinel in au8operand, so a function genuinely returning 255 values is indistinguishable from "all". Lua avoids this by using a biased encoding — itsBoperand is "count + 1", so0means multret and1means zero results. Ember should do the same, and if yours does not, add the bias in Lab 17 and note indocs/limitations.mdwhat the previous encoding could not express. A sentinel that collides with a legal value is a bug waiting for a generated program.
8. Production concerns
- The differential test will find your bugs here. Multiple returns are the feature where the
tree walker (which returns a
Vec<Value>) and the VM (which leaves values on a shared stack) have the most structurally different implementations. That is precisely why the comparison is valuable — and why Lab 17's first run will fail. nilinside a returned list is not a terminator.return 1, nil, 3returns three values. Any implementation that trims trailingnils or stops at the first one is wrong, andselect('#', ...)is how a script observes the difference.
Concept 3: Varargs
1. Concept
... in a function's parameter list makes it variadic: extra arguments are collected and made
available through ..., which is itself a multi-valued expression.
local function sum(...)
local n = select('#', ...) -- how many were PASSED, including nils
local t = {...} -- a table of them; #t stops at the first nil
local a, b = ... -- adjusted, like any list
return n
end
print(sum(1, nil, 3)) -- 3
2–3. Problem and mental model
The extra arguments are already on the stack — the caller pushed them and
CALLleft them below the frame's declared parameters.VARARG ncopies them up to the top. Nothing is allocated unless the script asks for a table.
caller pushed: f a₁ a₂ a₃ a₄ a₅
f declares (x, ...) nparams = 1
stack: [ f ][ a₁ ][ a₂ ][ a₃ ][ a₄ ][ a₅ ][ ...frame slots... ]
│ └──────── the varargs ────────┘
└── slot 0 (x)
VARARG 255 → copies a₂..a₅ to the top
4. Implementation
The frame must remember how many extras there were, because nparams alone cannot recover it:
#![allow(unused)] fn main() { pub struct CallFrame { // ... vararg_base: usize, // stack index of the first extra argument n_varargs: usize, // how many. Recorded at CALL; not derivable later. } Op::Vararg(n) => { let f = self.frame(); let count = if n == 255 { f.n_varargs } else { n as usize }; for i in 0..count { let v = if i < f.n_varargs { self.stack[f.vararg_base + i] } else { Value::Nil }; self.push(v); } } }
5–7. Alternatives, decision, tradeoffs
| Option | ... is | Cost |
|---|---|---|
A. Stack region + VARARG (ours, Lua) | a stack range | Zero allocation; n_varargs must be recorded |
| B. A table, always | arg (Lua 5.0's design, removed in 5.1) | One allocation per variadic call; {...} becomes free |
| C. A slice/array object | Python's *args, JS's arguments | Allocation per call, uniform semantics |
Lua had option B and moved away from it, which is the interesting data point: the arg table cost
an allocation on every variadic call, including the overwhelming majority that just forward their
arguments (function f(...) return g(...) end). The forwarding case is why A wins, and it is a
good example of a design decided by the common case rather than the convenient one.
8. Production concerns
select('#', ...)and#{...}are different, and the difference is trailingnils.sum(1, nil, 3)hasselect('#')of 3 and#{...}of... whatever your#does with a hole. Both are correct and they answer different questions: "how many were passed" versus "how long is this table". Test both; a corpus case for each.- A variadic call's extras are GC roots, because they sit in the stack region below
baseand the stack is a root set. That works automatically — but only ifdo_returntruncates toret_to(below the varargs) rather than tobase. Get that wrong and the varargs survive the call. table.unpackplus...is an amplification vector.f(table.unpack(huge))turns a large table into a large argument list into a large vararg region. One limit — max results/arguments — covers all three, and it belongs with the other limits in Section 5.
9. References
- Lua 5.4 Reference Manual §3.4.11 (function calls) and §6.1's
select. - The Lua 5.0 → 5.1 change notes on removing the
argtable — the rationale for option A over B. - Lua's
ldo.c:luaD_precall's vararg handling andOP_VARARGPREPinlvm.c(5.4 added a dedicated preparation opcode, which is a nice piece of evidence that this is fiddly enough to deserve one).
Things to Notice
- The call site decides the count, which makes a purely syntactic rule (
is it last in the list?) into a runtime behavior. That is unusual and it is the source of every surprise here. (f())is the one place parentheses carry meaning, and it is why the AST needs aParennode it otherwise would not have. A rule that costs a node.- Lua's design avoids allocation, at the cost of complexity in the calling convention. Option B (tuples) moves the complexity into the type system and pays an allocation. Neither is free.
- A sentinel that collides with a legal value is a bug. Bias the encoding, as Lua does.
nilis not a terminator in a value list.select('#')exists precisely to say so.- Lua tried the "just make it a table" design and removed it, because argument forwarding is the common case. Design for what programs do.
- This is where the differential test earns its keep. Two structurally different implementations of one fiddly rule is exactly the situation it was built for.
Validation / Self-check
- Give five contexts in which
three()yields a different number of values, and state the rule. - Why does
Expr::Parenexist, given that the AST otherwise discards parentheses? - What does the
255operand mean, and what is wrong with using255rather than a biased count? - Where does the adjustment logic live, and why must every list context use the same function?
- Draw the stack for a call to
f(x, ...)with five arguments. Where do the varargs live? - Why can
n_varargsnot be recomputed fromnparamsand the stack? - Give the difference between
select('#', ...)and#{...}, with an argument list that distinguishes them. - Why did Lua remove the
argtable? What case decided it? - Why is this the feature most likely to break differential tests, and why is that a good sign?
Next: Metatables.