In this post about expressions I mentioned that Kotlin features return expressions, which is a rather surprising feature. Let's see it in action:
// Kotlin code:
fun getCapital(country: Country) {
val city = country?.capital ?: return "Paris"
// this won't run if we could not find a capital
logger.log("We've found the capital")
return city
}
Contrary to try or throw expressions, that can be simulated (in JavaScript, Python...) with a function [1], [2], there's no way to use a "return() function" to mimic them (it would exit from that function itself, not from the calling one). Well, it came to my mind that maybe we could use a trick in JavaScript with eval() (I already knew that it would not work in Python with exec()), but no, it does not work in JavaScript either.
// JavaScript code:
function demo() {
eval("return 42;");
console.log("This will never run");
}
console.log(demo());
// Output: SyntaxError: Illegal return statement
JavaScript gives us a SyntaxError when we try that because that return can not work in the way we intend (returning from the enclosing function) so it prevents us from trying it. The code that eval compiles and runs is running inside the eval function, it's not as if it were magically placed inline in the enclosing function, so return (or break, or continue) would just return from eval itself, not from the enclosing function, and to prevent confusion, JavaScript forbids it.
The reason why I thought that maybe this would be possible is because as I had already explained in this previous post JavaScript eval() is more powerful than Python exec(), as it allows us modifying and even adding variables to the enclosing function. As a reminder:
// JavaScript code:
function declareNewVariable() {
// has to be declared as "var" rather than let to make it accessible outside the block
let block = "var a = 'Bonjour';";
eval(block);
console.log(`a: ${a}`)
}
declareNewVariable();
// a: Bonjour
This works because when JavaScript compiles and executes a "block" of code with eval() it gives it access to the scope chain of the enclosing function.
Python could have also implemented this feature, but it would be very problematic in performance terms. Each Python function stores its variables in an array (I think it's the f_localsplus attribute of the internal frame/interpreter object, not to be confused with the higher level PyFrameObject wrapper), and the bytecode access to variables by index in that array (using LOAD_FAST, STORE_FAST instructions), not by name . exec() accepts an arbitrary dictionary to be used as locals, meaning that it will access to that custom locals or to the one created from the real locals, as a dictionary lookup (with LOAD_NAME, STORE_NAME). Basically there's not an easy way to reconcile both approaches. Well, indeed exec() could have been designed as receiving by default a write-through proxy like the one created by frame.f_locals. That would allow modifying variables from the enclosing function, but would not work for adding variables to it (see this post). So I guess Python designers have seen it as more coherent to prevent both cases rather than having one case work (modification of variable) and another case not (addition of a new variable). As for the PyFrameObject stuff that I mention, some GPT information:
In Python 3.11+, the local variables and execution state are stored in interpreter frames (also called "internal frames"), which are lower-level C structures that are much more lightweight than the old PyFrameObject.
When you call sys._getframe() or use debugging tools, CPython creates a PyFrameObject on-demand that acts as a Python-accessible wrapper around the internal frame data. This wrapper is what you can inspect from Python code, but it's only created when needed.
So all in all we can say (well, a GPT says...)
Bottom line: Neither Python’s exec() nor JavaScript’s eval() can magically splice control-flow into the caller’s code. They both create separate compilation units. JavaScript feels “closer” because eval() shares lexical scope, but the AST boundaries still apply.
After all this, one interesting question comes up, is there any language where the equivalent to eval/exec allows us returning from the enclosing function? The answer is Yes, Ruby (and obviously it also allows modifying and adding new variables to the enclosing function). Additionally notice that ruby also supports return expressions (well, everything in ruby is an expression).
# ruby code:
def example
result = eval("return 5")
puts "This won't execute"
end
example # returns 5
Ruby's eval is much more powerful than JavaScript's or Python's - it truly executes code as if it were written inline in the enclosing context.
The "as if" is important. It's not that Ruby compiles the code passed to eval and somehow embeds it in the middle of the currently running function. That could be possible I guess in a Tree parsing interpreter, modifying the AST of the current function, but Ruby has long ago moved to bytecode and JIT. What really happens is this
Ruby's eval compiles the string to bytecode and then executes it in the context of the provided binding, which includes:
- Local variables
- self (the current object)
- The control flow context (the call stack frame)
That last part is key. When you pass a Binding object, you're not just passing variables - you're passing a reference to the actual execution frame. So when the evaled code does return, break, or next (Ruby's continue), it operates on that captured frame.
Here's where it gets wild.
The Binding object idea (an object that represents the execution context of a function) is amazing. By default (when you don't explicitly provide a binding object) the binding object represents the current execution frame, but you can even pass as binding the execution frame of another function!!! You can get access to variables from another function, and if that function is still active (it's up in the call stack) you can even return from that function, meaning you can make control flow jump from one function to another one up in the stack chain!
eval operates on a Binding object (which you can pass explicitly), and that binding captures the complete execution context - local variables, self, the surrounding scope, everything. You can even capture and pass bindings around
Just notice that Python allows a small subset of the binding object functionality by allowing us to explicitly provide custom dictionaries as locals and globals to exec().