tail calls

This commit is contained in:
Chris Wanstrath 2025-10-05 19:07:28 -07:00
parent 66c46677e6
commit 45e4c29df4
3 changed files with 271 additions and 5 deletions

View File

@ -54,7 +54,7 @@ It's where Shrimp live.
### Functions
- [x] MAKE_FUNCTION
- [x] CALL
- [ ] TAIL_CALL
- [x] TAIL_CALL
- [x] RETURN
### Arrays
@ -77,7 +77,7 @@ It's where Shrimp live.
## Test Status
**66 tests passing** covering:
**70 tests passing** covering:
- All stack operations (PUSH, POP, DUP)
- All arithmetic operations (ADD, SUB, MUL, DIV, MOD)
- All comparison operations (EQ, NEQ, LT, GT, LTE, GTE)
@ -86,9 +86,10 @@ It's where Shrimp live.
- Control flow with **relative jumps** (JUMP, JUMP_IF_FALSE, JUMP_IF_TRUE, BREAK, CONTINUE)
- All array operations (MAKE_ARRAY, ARRAY_GET, ARRAY_SET, ARRAY_PUSH, ARRAY_LEN)
- All dictionary operations (MAKE_DICT, DICT_GET, DICT_SET, DICT_HAS)
- Basic function operations (MAKE_FUNCTION, CALL, RETURN) with parameter binding
- Function operations (MAKE_FUNCTION, CALL, TAIL_CALL, RETURN) with parameter binding
- **Tail call optimization** with unbounded recursion (10,000+ iterations without stack overflow)
- Exception handling (PUSH_TRY, PUSH_FINALLY, POP_TRY, THROW) with nested try/finally blocks and call stack unwinding
- TypeScript interop (CALL_NATIVE) with sync and async functions
- Native function interop (CALL_NATIVE) with sync and async functions
- HALT instruction
## Design Decisions
@ -98,4 +99,4 @@ It's where Shrimp live.
- **Short-circuiting via compiler**: No AND/OR opcodes—compilers use JUMP patterns for proper short-circuit evaluation
🚧 **Still TODO**:
- Advanced function features (TAIL_CALL, variadic params, kwargs, default parameters)
- Advanced function features (variadic params, kwargs, named arguments in CALL)

View File

@ -399,6 +399,42 @@ export class VM {
this.pc = fn.body - 1
break
case OpCode.TAIL_CALL:
const tailArgCount = instruction.operand as number
const tailArgs: Value[] = []
for (let i = 0; i < tailArgCount; i++)
tailArgs.unshift(this.stack.pop()!)
const tailFn = this.stack.pop()!
if (tailFn.type !== 'function')
throw new Error('TAIL_CALL: not a function')
this.scope = new Scope(tailFn.parentScope)
// same logic as CALL
for (let i = 0; i < tailFn.params.length; i++) {
const paramName = tailFn.params[i]!
const argValue = tailArgs[i]
if (argValue !== undefined) {
this.scope.set(paramName, argValue)
} else if (tailFn.defaults[paramName] !== undefined) {
const defaultIdx = tailFn.defaults[paramName]!
const defaultValue = this.constants[defaultIdx]!
if (defaultValue.type === 'function_def')
throw new Error('Default value cannot be a function definition')
this.scope.set(paramName, defaultValue)
} else {
this.scope.set(paramName, toValue(null))
}
}
// subtract 1 because PC was incremented
this.pc = tailFn.body - 1
break
case OpCode.RETURN:
const returnValue = this.stack.length ? this.stack.pop()! : toValue(null)

229
tests/tail-call.test.ts Normal file
View File

@ -0,0 +1,229 @@
import { test, expect } from "bun:test"
import { VM } from "#vm"
import { OpCode } from "#opcode"
import { toValue } from "#value"
test("TAIL_CALL - basic tail recursive countdown", async () => {
// Tail recursive function that counts down to 0
// function countdown(n) {
// if (n === 0) return "done"
// return countdown(n - 1) // tail call
// }
const vm = new VM({
instructions: [
// 0: Setup - create function and call it
{ op: OpCode.MAKE_FUNCTION, operand: 0 },
{ op: OpCode.STORE, operand: 'countdown' },
{ op: OpCode.LOAD, operand: 'countdown' },
{ op: OpCode.PUSH, operand: 1 }, // start with 5
{ op: OpCode.CALL, operand: 1 },
{ op: OpCode.HALT },
// 6: Function body
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 2 }, // 0
{ op: OpCode.EQ },
{ op: OpCode.JUMP_IF_FALSE, operand: 2 }, // if n !== 0, jump to recursive case
{ op: OpCode.PUSH, operand: 3 }, // return "done"
{ op: OpCode.RETURN },
// 12: Recursive case (tail call)
{ op: OpCode.LOAD, operand: 'countdown' },
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 4 }, // 1
{ op: OpCode.SUB },
{ op: OpCode.TAIL_CALL, operand: 1 } // tail call with n-1
],
constants: [
{
type: 'function_def',
params: ['n'],
defaults: {},
body: 6,
variadic: false,
kwargs: false
},
toValue(5),
toValue(0),
toValue('done'),
toValue(1)
]
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'done' })
})
test("TAIL_CALL - tail recursive sum with accumulator", async () => {
// function sum(n, acc = 0) {
// if (n === 0) return acc
// return sum(n - 1, acc + n) // tail call
// }
const vm = new VM({
instructions: [
// 0: Setup
{ op: OpCode.MAKE_FUNCTION, operand: 0 },
{ op: OpCode.STORE, operand: 'sum' },
{ op: OpCode.LOAD, operand: 'sum' },
{ op: OpCode.PUSH, operand: 1 }, // n = 10
{ op: OpCode.PUSH, operand: 2 }, // acc = 0
{ op: OpCode.CALL, operand: 2 },
{ op: OpCode.HALT },
// 7: Function body
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 2 }, // 0
{ op: OpCode.EQ },
{ op: OpCode.JUMP_IF_FALSE, operand: 2 },
{ op: OpCode.LOAD, operand: 'acc' },
{ op: OpCode.RETURN },
// 13: Recursive case
{ op: OpCode.LOAD, operand: 'sum' },
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 3 }, // 1
{ op: OpCode.SUB },
{ op: OpCode.LOAD, operand: 'acc' },
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.ADD },
{ op: OpCode.TAIL_CALL, operand: 2 }
],
constants: [
{
type: 'function_def',
params: ['n', 'acc'],
defaults: {},
body: 7,
variadic: false,
kwargs: false
},
toValue(10),
toValue(0),
toValue(1)
]
})
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 55 }) // sum of 1..10
})
test("TAIL_CALL - doesn't overflow stack with deep recursion", async () => {
// This would overflow the stack with regular CALL
// but should work fine with TAIL_CALL
const vm = new VM({
instructions: [
// 0: Setup
{ op: OpCode.MAKE_FUNCTION, operand: 0 },
{ op: OpCode.STORE, operand: 'deep' },
{ op: OpCode.LOAD, operand: 'deep' },
{ op: OpCode.PUSH, operand: 1 }, // 10000 iterations
{ op: OpCode.CALL, operand: 1 },
{ op: OpCode.HALT },
// 6: Function body
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 2 }, // 0
{ op: OpCode.LTE },
{ op: OpCode.JUMP_IF_FALSE, operand: 2 },
{ op: OpCode.PUSH, operand: 3 }, // "success"
{ op: OpCode.RETURN },
// 12: Tail recursive case
{ op: OpCode.LOAD, operand: 'deep' },
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 4 }, // 1
{ op: OpCode.SUB },
{ op: OpCode.TAIL_CALL, operand: 1 }
],
constants: [
{
type: 'function_def',
params: ['n'],
defaults: {},
body: 6,
variadic: false,
kwargs: false
},
toValue(10000), // This would overflow with regular recursion
toValue(0),
toValue('success'),
toValue(1)
]
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'success' })
})
test("TAIL_CALL - tail call to different function", async () => {
// TAIL_CALL can call a different function (mutual recursion)
// function even(n) { return n === 0 ? true : odd(n - 1) }
// function odd(n) { return n === 0 ? false : even(n - 1) }
const vm = new VM({
instructions: [
// 0: Setup both functions
{ op: OpCode.MAKE_FUNCTION, operand: 0 }, // even
{ op: OpCode.STORE, operand: 'even' },
{ op: OpCode.MAKE_FUNCTION, operand: 1 }, // odd
{ op: OpCode.STORE, operand: 'odd' },
{ op: OpCode.LOAD, operand: 'even' },
{ op: OpCode.PUSH, operand: 2 }, // test with 7
{ op: OpCode.CALL, operand: 1 },
{ op: OpCode.HALT },
// 8: even function body
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 3 }, // 0
{ op: OpCode.EQ },
{ op: OpCode.JUMP_IF_FALSE, operand: 2 },
{ op: OpCode.PUSH, operand: 4 }, // true
{ op: OpCode.RETURN },
// Tail call to odd
{ op: OpCode.LOAD, operand: 'odd' },
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 5 }, // 1
{ op: OpCode.SUB },
{ op: OpCode.TAIL_CALL, operand: 1 },
// 19: odd function body
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 3 }, // 0
{ op: OpCode.EQ },
{ op: OpCode.JUMP_IF_FALSE, operand: 2 },
{ op: OpCode.PUSH, operand: 6 }, // false
{ op: OpCode.RETURN },
// Tail call to even
{ op: OpCode.LOAD, operand: 'even' },
{ op: OpCode.LOAD, operand: 'n' },
{ op: OpCode.PUSH, operand: 5 }, // 1
{ op: OpCode.SUB },
{ op: OpCode.TAIL_CALL, operand: 1 }
],
constants: [
{
type: 'function_def',
params: ['n'],
defaults: {},
body: 8, // even body
variadic: false,
kwargs: false
},
{
type: 'function_def',
params: ['n'],
defaults: {},
body: 19, // odd body
variadic: false,
kwargs: false
},
toValue(7),
toValue(0),
toValue(true),
toValue(1),
toValue(false)
]
})
const result = await vm.run()
expect(result).toEqual({ type: 'boolean', value: false }) // 7 is odd
})