Method Return and Lexical Scoping in SCLang
I’ve been researching how the SuperCollider interpreter supports exceptions, and I’ve learned some subtle business
involving the method return operator ^
, which I thought I’d share.
Lexical Scoping⌗
SuperCollider has two primary mechanisms for holding code, allowing users to define methods on classes and literal functions. Functions support lexical scoping, for instance, in the following code:
Foo {
*bar {
var first = 1;
var second = 2;
var func = { first = first + second; first; };
^func;
}
}
(
var func = Foo.bar;
func.value.postln; // prints 3
func.value.postln; // prints 5
func.value.postln; // prints 7
)
Not only are the local variables first
and second
accessible in the literal function func
, but the interpreter
also persists their state with func
.
Method Returns⌗
Notice the ^func
command on the last line of the *bar
class method in the previous example. The method return
operator is necessary here because if a method doesn’t specify a return value, the interpreter supplies this
as the
return. Since this is a class method, omitting that ^func
line means that calls to Foo.bar
will return the class
Foo
as the value of this
.
So why not add a ^first
line to the function definition of func
? Why the naked first;
statement? Unlike methods,
functions always return the last value evaluated. I could omit the first;
statement because the value of the
assignment first = first + second
is first
, but I’ve included it for clarity.
Some folks have called the caret, ^
, the method return operator, to clarify its behavior. And, if you use it inside
a function, it will return to the caller frame on function creation:
Fizz {
*buzz {
var func = { |val| ^val; };
"beginning".postln;
func.value("middle").postln;
"end".postln;
}
}
(
Fizz.buzz; // prints "beginning", returns "middle"
)
What happened there is the ^val
inside of func
returned to the caller from *buzz
directly.
Lexical Scoping Includes Method Return⌗
For regular programming in SuperCollider, most folks have internalized the guidance that they should avoid the method return operator in functions because of reasons, and leave it at that. However, researching exception support in the class library taught me an interpreter edge case when we combine the lexical scoping and method return language features. Consider the following simplified exception-handling code:
Handler {
classvar <handler;
*wrap { |func|
var value = Handler.prWrap(func);
^value;
}
*prWrap { |func|
var value;
handler = { |error| ^error; };
value = f.value;
^value;
}
}
(
var func = { Handler.handler.value(\handled); \unhandled; };
Handler.wrap(func); // returns 'handled'
)
In this example, wrap
calls prWrap
, creates the handler
function, saving wrap
’s stack frame as the caller. When
func
invokes handler
, the method return in handler
returns to wrap
regardless of the invocation context. In
other words, the interpreter includes the caller frame in a function’s lexical scope.
It’s possible to construct functions that return to caller frames no longer in scope. Let’s move the handler
creation
to a oneTime
setup function:
Handler {
classvar <handler;
// This is brittle code, don't re-use.
*oneTime {
handler = { |error| ^error; };
}
*wrap { |func|
var value = Handler.prWrap(func);
^value;
}
*prWrap { |func|
var value = func.value;
^value;
}
}
(
var func = { Handler.handler.value(\handled); \unhandled; };
Handler.oneTime;
Handler.wrap(func);
)
This example works because the outmost frame is the caller frame for Handler.oneTime
, and it still exists when func
invokes handler
. Note the interpreter updates the instruction pointer on a stack frame on every method invocation, so
when we don’t repeat the call to Handler.wrap(func)
infinitely.
If we add a layer of indirection to the handler
creation:
Handler {
classvar <handler;
*oneTime {
Handler.prOneTime;
}
*prOneTime {
handler = { |error| ^error; };
}
*wrap { |func|
var value = Handler.prWrap(func);
^value;
}
*prWrap { |func|
var value = func.value;
^value;
}
}
(
var func = { Handler.handler.value(\handled); \unhandled; };
Handler.oneTime;
Handler.wrap(func);
) // ERROR: 'Meta_Handler-prOneTime' Out of context return of value: handled
Now, the caller frame during handler
setup is for oneTime
, which is out of scope at invocation time, and the
interpreter reports an error.
Hadron Emulation⌗
I’ve gone back and forth a few times, but currently, Hadron compiles methods that respect the C ABI and use the C stack
frame for both function and method invocation. I’m uncertain how to emulate this kind of return behavior from functions,
perhaps with some setjmp/longjmp
trickery. However, the problem with this approach is that it doesn’t allow
instruction pointer updates in the caller frame, so I’m a bit stumped for the moment.
[Updated 2022-10-10]: Clarified code examples based on feedback from Nathan Ho.