Tail Call Optimization¶
Current Status¶
Detection: ✅ Implemented Transformation: ✅ Implemented
The Onion compiler detects tail-recursive methods during compilation and rewrites
them into a while(true) loop, so a self-recursive call in tail position no longer
grows the JVM call stack (preventing StackOverflowError for deep recursion, e.g.
10,000+ calls).
Only direct self-recursion is rewritten by this phase; mutual recursion between
two or more methods is handled separately by MutualRecursionOptimization, which
runs immediately afterward in the pipeline.
How It Works¶
Detection Phase¶
The compiler analyzes each method to identify tail-recursive calls:
- Tail Position Analysis: Checks if the last statement (or statements in control-flow branches) is a self-call
- Recursive Search: Searches through
StatementBlockandIfStatementnodes to find tail calls - Method Matching: Verifies that the call target matches the current method (same name, class, and parameter types)
- Dispatch Safety: Only methods that cannot be overridden — private, static, or final — are eligible. A self-call in a public overridable method could be dynamically dispatched to a subclass override at runtime, so rewriting its body into a loop would silently change behavior; such methods are skipped.
- Mutual-Recursion Exclusion: A method annotated
@TailRecursive(used to mark participants in mutual recursion) is skipped here and left forMutualRecursionOptimization.
Supported Patterns¶
The detector recognizes tail recursion in:
- Direct tail calls:
return method(args) - Conditional branches: both
thenandelsebranches ofifstatements - Nested blocks: recursive search through statement blocks
Example¶
def factorial(n: Int, acc: Int): Int {
if (n <= 1) {
return acc
}
return factorial(n - 1, n * acc) // ✅ detected and transformed
}
Transformation Phase¶
Once a method is confirmed tail-recursive and eligible, it is rewritten:
- Loop Variable Allocation: Each parameter gets a corresponding loop variable
- Parameter Rewriting: Every reference to a parameter within the body is rewritten to reference its loop variable instead
- Loop Construction: The (rewritten) body is wrapped in a
while(true)loop - Tail Call Replacement: Each tail call is replaced with loop-variable updates
followed by the loop continuing (no
continuekeyword needed — falling through to the end of thewhile(true)body re-enters it)
// Before
def factorial(n: Int, acc: Int): Int {
if (n <= 1) return acc
return factorial(n - 1, n * acc)
}
// After (conceptually)
def factorial(n: Int, acc: Int): Int {
while (true) {
if (n <= 1) return acc
val n_next = n - 1
val acc_next = n * acc
n = n_next
acc = acc_next
// loop restarts
}
}
Mutual Recursion with @TailRecursive¶
Two or more methods that tail-call each other (isEven calling isOdd, isOdd
calling isEven) are not rewritten by the detection phase above — each call
targets a different method, so the "self-call" check never matches. Mark every
method in the group @TailRecursive and MutualRecursionOptimization merges them
into one state-machine method instead:
class Parity {
private:
@TailRecursive
def isEven(n: Int): Boolean {
if n == 0 { return true }
return isOdd(n - 1)
}
@TailRecursive
def isOdd(n: Int): Boolean {
if n == 0 { return false }
return isEven(n - 1)
}
public:
def check(n: Int): Boolean = isEven(n)
}
The group is optimized only when all of the following hold — MutualRecursionOptimization.validateGroup:
- every method in the group is private (same dispatch-safety reasoning as direct TCO: a public method could be overridden, so rewriting it into a shared loop could silently change behavior)
- every method has the same return type
- every method has the same parameter count and parameter types
- every tail call from a group member targets another member of the same group (a tail call out of the group leaves it ineligible)
A group that fails validation is not an error — the annotation is simply
ineffective, and the methods keep calling each other as ordinary (non-tail)
calls. That is easy to miss, since nothing points at the annotation until deep
enough recursion overflows the JVM stack at runtime. To catch this at compile
time instead, the compiler emits W0016 for every method in a @TailRecursive
group that could not be optimized, naming the failed requirement (e.g. "All
methods must be private for mutual recursion optimization"). See
--Wno to suppress it if the group is
intentionally left unoptimized.
Viewing the Optimization¶
Compile with the --verbose flag to trace which methods are transformed and why
others are skipped:
Sample output:
[TCO] Method YourClass.factorial: hasTailCall=true
[TCO] Optimizing tail-recursive method: YourClass.factorial
[TCO] Skipping overridable method: YourClass.someOverridableMethod
[TCO] Skipping @TailRecursive annotated method: YourClass.mutuallyRecursiveMethod
Implementation Details¶
File Location¶
- Source:
src/main/scala/onion/compiler/optimization/TailCallOptimization.scala - Pipeline Integration: runs between
TypingandAsmCodeGeneration, immediately beforeMutualRecursionOptimization
Compiler Pipeline¶
Parsing → Rewriting → Typing → [TailCallOptimization] → MutualRecursionOptimization → AsmCodeGeneration
Testing¶
- Spec:
src/test/scala/onion/compiler/tools/TailCallOptimizationSpec.scala - Example programs in
src/test/run/:tail_recursion_factorial.on,tail_recursion_simple.on,tail_recursion_direct.on,tail_recursion_private.on,tail_recursion_public.on,tail_recursion_test.on
References¶
- Tail Call Optimization (Wikipedia)
- Related implementation: Scala's
@tailrecannotation - Similar optimization in functional languages: Haskell, Scheme, OCaml