Sunday 19 March 2023

JavaScript vs Python Closures

I rencently published a post comparing the dynamic creation of a function in JavaScript and Python, through the use of the magic functions eval and execute, that dynamically compile a string of code into bytecodes to be run by the VM.

For most cases javascript eval and Python execute are equally powerful, but eval has some freak and rarely used superpowers that Python lacks. Let's see:

Difference 1. In JavaScript ff the block passed to eval defines a new variable, this variable is accessible from the code in the function that invoked eval.


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


As I've said, trying to do something similar in Python won't work:


def declare_new_variable():
    block = "a = 'Bonjour'";
    exec(block);
    print(f"a: {a}")

declare_new_variable()

# Exception:
# NameError: name 'a' is not defined

I've found a discussion about this limitation where they mention this:

You can't use exec() to set locals in a function, unless the name was already assigned to in a given function. This is a hard limitation due optimisations to how a local namespace in a function is accessed

Difference 2. In JavaScript ff the block passed to eval defines a new function, this function can become a closure, as it traps other variables defined both in the block itself and in the function invoking eval. This makes sense with how the scope chain works in javascript


function closureInEval() {
    let counter = 0;
    // both options work fine:
    // option 1, using a function declaration
    // let fnSt = "function printAndCount(){ console.log(counter); counter++;}";
    // eval(fnSt);
    //option 2, using an expression
    let printAndCount = eval("() => { console.log(counter); counter++;}");
    printAndCount();
    printAndCount();
    printAndCount();
}

closureInEval();

// 0
// 1
// 2

As I've said, trying to do something similar in Python won't work:


def test_closure_with_modify_access_1():
    fn_st = "\n".join([
    "def print_and_count(msg):", 
    "    nonlocal counter",
    "    counter += 1",
    "    print(str(counter) + ' - ' + msg)"
    ])

    # this does not work. The function that I define inside exec can not bind to the counter external variable as a nonlocal for the closure
    def create_eval_closure():
        d = {}
        counter = 0
        exec(fn_st + "\n" + "d['fn'] = " + "print_and_count")
        return d["fn"]

    print_and_count = create_eval_closure()
    print_and_count("a")
    print_and_count("b")
    print_and_count("c")

test_closure_with_modify_access_1()
# no binding for nonlocal 'counter' found (, line 2)

We get a no binding for nonlocal 'counter' found error cause the counter variable has not been trapped by the new function that we have just compiled. I've found some discussions about this issue, like this one, where they mention this:

When you pass a string to exec or eval, it compiles that string to a code object before considering globals or locals... There's no way for compile to know that a is a freevar

The above example will also fail if we declare the counter variable as part of the string passed to execute(), I mean:


    fn_st = "\n".join([
    "counter = 0",        
    "def print_and_count(msg):", 
    "    nonlocal counter",
    "    counter += 1",
    "    print(str(counter) + ' - ' + msg)"
    ])

What is perfectly fine in Python is passing to execute() a string with a wrapper function that defines a new variable and creates another function that is accessing to that outer variable. In this case this inner function becomes a closure trapping that variable defined in the same execute block.


def create_function(fn_st, fn_name):
    d = {}
    exec(fn_st + "\n" + "d['fn'] = " + fn_name)
    return d["fn"]

def test_closure_with_modify_access_3():
    # I can create a closure, but wrapping the closure creation in another function
    fn_st = "\n".join([
    "def create_closure():",
    "       counter = 0",
    "       def print_and_count(msg):", 
    "           nonlocal counter",
    "           counter += 1",
    "           print(str(counter) + ' - ' + msg)",
    "       return print_and_count"
    ])

    closure_creator = create_function(fn_st, "create_closure")
    print_and_count = closure_creator()
    print_and_count("a")
    print_and_count("b")
    print_and_count("c")

# 1 - a
# 2 - b
# 3 - c


No comments:

Post a Comment