C# is not tail recursive. It could be. The IL that it compiles to
supports tail recursion, but the C# compiler doesn’t generate the
tail call instruction. It would be a simple thing to add: when the
compiler emits a call instruction, it could check if the next
instruction is a return, and if so, emit a tail call instruction.
This could be controlled by a compiler flag so only us weirdos who
want this feature would enable it.
But until the C# compiler adds this feature, we have to resort to
other methods. I chose to use a trampoline at each call site. This
is a small segment of code that awaits the result of the function
call. If the callee wants to tail call, it returns the tail call
target to the caller, which performs the call on the callee’s behalf.
This requires a modification to the calling conventions.
EvalStep is the virtual method that all S-code objects
implement to perform an evaluation. Its signature is this:
abstract class Control : SchemeObject
{
public abstract TailRecursionFlag EvalStep (out object answer,
ref Control expression,
ref Environment environment);
}
The result of the evaluation is returned in the answer
parameter. This is an out parameter, so the answer is
allocated in the caller and a pointer to it is passed to the callee.
The callee returns the answer by modifying it in the callers
stack frame.
The expression and environment parameters
are the expected parameters for a call to Eval. They,
too, are allocated in the caller’s frame and references to them are
passed to the callee. The callee is allowed to modify the caller’s
values of these variables.
The returned value is a TailRecursionFlag. This is
either 1, indicating that a value has been returned in the answer,
or 0, indicating that the caller should perform
another EvalStep. To return a value, the callee
modifies the answer. To perform a tail call, the callee
modifies the expression and environment
references and returns 0.
Any caller must call EvalStep as follows: The caller
allocates an answer variable to receive the answer of
the call. It also allocates an expression,
and environment variable to pass to the callee. It
then calls EvalStep in a loop until the callee returns
a TailRecursionFlag of 1, indicating that
the answer has been set to the return value.
In the EvalStep for an S-code Conditional
we see an example of the calling convention:
object ev;
Control unev = predicate;
Environment env = environment;
while (unev.EvalStep (out ev, ref unev, ref env) == TailRecursionFlag.TailCall) { };
We are making a recursive call to evaluate
the predicate. We set up ev to receive
the result of the evaluation. We set up unev
and env to hold the expression and environment to pass
to EvalStep. unev.EvalStep does the eval
dispatch via virtual function dispatch.
If the predicate returns a TailRecursionFlag
of ReturnValue, the loop will exit. The predicate is
assumed to have put the return value in the ev
variable.
If the predicate wants to tail call, it will modify the values
of unev and env to the new expression and
new environment, and return a TailRecursionFlag
of TailCall. The loop will iterate, using the new
value of unev and env to again dispatch a
call to EvalStep.
When the while loop exits, the ev
variable will contain the return value of the predicate. Control
may be returned to the while loop several times before
the loop exits. This is the trampoline in action.
Conditional expressions don’t return a value. They either tail call
the consequent or the alternative. The EvalStep for a
conditional ends like this:
answer = null;
expression = (ev is bool evb && evb == false) ? alternative :
return TailRecursionFlag.TailCall;
}
The answer variable in the caller is set to null.
out parameters must always be assigned to before the
function exits, so this just keeps the compiler happy. If the
return value of calling EvalStep on the predicate is
the boolean false, we set the expression in the caller
to the alternative, otherwise the consequent. This is the target of
our tail call to EvalStep. For the scode for a
conditional, we leave the environment alone — the
tail call uses the same environment unchanged. We finally
return TailRecursionFlag.TailCall so that the caller’s
trampoline makes another iteration around its while.
It will call EvalStep on the alternative or consequent
that we stuffed in the caller’s expression.
This little song and dance is performed at every recursive call
to EvalStep making EvalStep behave as a
tail-recursive function. This calling convention is about half the
speed of a normal C# method call. It is the cost of using a
trampoline for tail recursion.
First Class Continuations
There is one more obscure reason that the control might return to
us when evaluating the predicate. If some function further down the
call chain invokes call-with-current-continuation, we
need to copy the stack. The callee indicates this by returning a
magic return value of Special.UnwindStack. The callee
sets the caller’s environment to
an UnwinderState that will accumulate the stack frames
as we unwind the stack. So our calling convention says we need to
check the return value of EvalStep, and if it
is Special.UnwindStack, we allocate
a ConditionalFrame on the heap that will contain the
state of the current stack frame. We AddFrame to
the UnwinderState. We propagate this up the stack by
putting it in the caller’s environment, setting the
caller’s value of answer
to Special.UnwindStack and
returning TailRecursionFlag.ReturnValue to stop the
caller’s trampoline loop.
The full code of EvalStep for an
S-code if expression is this:
public override TailRecursionFlag EvalStep (out object answer,
ref Control expression,
ref Environment environment)
{
object ev;
Control unev = predicate;
Environment env = environment;
// Tail recursion trampoline.
while (unev.EvalStep (out ev, ref unev, ref env) == TailRecursionFlag.TailCall) { };
// Support for first class continuations.
if (ev == Special.UnwindStack)
{
((UnwinderState) env).AddFrame (new ConditionalFrame (this, environment));
environment = env;
answer = Special.UnwindStack;
return TailRecursionFlag.ReturnValue;
}
// Tail call EvalStep on the consequent or alternative.
answer = null;
expression = (ev is bool evb && evb == false) ? alternative : consequent;
return TailRecursionFlag.TailCall;
}
First class continuations allow you unload and reload the pending
call chain. We see that at each call site, we must check the return
value and, if it is Special.UnwindStack, we create a
new Frame on the heap and add it to the unwinder state
befor we propagate the Special.UnwindStack up the call
chain.
At the very top of the call chain, we have the outermost call
to EvalStep. If the Special.UnwindStack
value is returned to this call, the stack has been unwound and the
UnwinderState is sitting in the environment variable.
We need to rewind the stack and put the stack frames back on the
stack. We create a RewindState from
the UnwinderState. Each time we PopFrame
from the RewindState, we get a deeper frame. We reload
the stack by getting the outermost frame from
the RewindState and calling EvalStep on
it. The EvalStep for a Frame sets up the
trampoline loop, calls PopFrame to get the next frame,
and calls EvalStep on it. When we run out of stack
frames to reload, the stack is reloaded and we return control the
innermost frame so it can continue where it left off. This is the
rewind loop.
The EvalStep for a Frame, after making
the recursive call to EvalStep on the next frame,
continues with code that is a duplicate of the code in the original
frame before the cotinuation was captured. A specific example will
make this clear. If an if expression is on the stack
when it is uwound, a ConditionalFrame is created.
A ConditionalFrame is a subclass
of SubproblemFrame which has this EvalStep
method:
public override TailRecursionFlag EvalStep (out object answer,
ref Control expression,
ref Environment environment)
{
object temp;
Control expr = ((RewindState) environment).PopFrame ();
Environment env = environment;
while (expr.EvalStep (out temp, ref expr, ref env) == TailRecursionFlag.TailCall) { };
if (temp == Special.UnwindStack)
{
((UnwinderState) env).AppendContinuationFrames (continuation);
environment = env;
answer = Special.UnwindStack;
return TailRecursionFlag.ReturnValue;
}
expression = this.expression;
environment = this.environment;
return Continue (out answer, ref expression, ref environment, temp);
}
public abstract TailRecursionFlag Continue (out object answer,
ref Control expression,
ref Environment environment,
object value);
That is, the EvalStep of
the SubproblemFrame establishes a trampoline, pops the
next frame from the RewindState, and invokes
its EvalStep method. When an answer is returned,
the SubproblemFrame calls its Continue
method.
The Continue method is a virtual method that is
implemented by each subclass of SubproblemFrame. It
finishes the work of the frame. In the case of
a ConditionalFrame, the Continue method is
this:
public override TailRecursionFlag Continue (out object answer,
ref Control expression,
ref Environment environment,
object value)
{
answer = null;
expression = value is bool bvalue && bvalue == false
? SCode.EnsureSCode (this.expression.Alternative)
: SCode.EnsureSCode (this.expression.Consequent);
return TailRecursionFlag.TailCall;
}
compare this to the code in the original
Conditional:
// Tail call EvalStep on the consequent or alternative.
answer = null;
expression = (ev is bool evb && evb == false) ? alternative : consequent;
return TailRecursionFlag.TailCall;
There are only superficial differences: the Continue
method gets the value returned by the predicate in an argument
rather than in a local variable. It type checks the alternative and
consequent components of the if expression by
calling SCode.EnsureSCode. Otherwise, the code does
the same thing.
It is not possible to actually rewind the stack with the original
set of pending methods. What we do instead is rewind the stack with
methods that do the same thing as the original pending methods. It
is close enough. The same values will be computed.
There is one place you can see the difference. If you look at the
stack trace in the debugger before you capture a continuation, you
will see the pending recursive calls to the
S-code EvalStep methods. If you look at the stack
trace in the debugger after you capture a continuation, you will
instead see pending calls to the EvalStep methods of a
set of frames. The pending frames are in the same order and have
names similar to the original pending methods. They compute the
same values, too. But the debugger can notice that these are not
the originals.