Skip to content
Advertisement

Loose late binding v. strict late binding

While reading Python’s Execution model documentation, I realized that Python’s free variables do not seem to have a strict late binding property where a name binding occurring in any code block can be used for name resolution. Indeed, executing:

def f():
    return x

def g():
    x = 0
    return f()

print(g())

raises:

NameError: name 'x' is not defined

They have rather a loose late binding property where only a name binding occurring in an outer code block of the code block introducing the free variable can be used for name resolution. Indeed executing

def f():
    return x

x = 0
print(f())

prints:

0

What are the benefits and drawbacks of the loose late binding property compared to the strict late binding property?

Advertisement

Answer

This is generally known as dynamic scoping and static scoping. Roughly speaking, dynamic scoping determines scope by call nesting and static scoping determines scope by declaration nesting.

In general, dynamic scoping is very easy to implement for any language with a call stack – a name lookup simply searches the current stack linearly. In contrast, static scoping is more complex, requiring several distinct scopes with their own lifetime.

However, static scoping is generally easier to understand, since the scope of a variable never changes – a name lookup has to be resolved once and will always point to the same scope. In contrast, dynamic scoping is more brittle, with names being resolved in different or no scope when calling a function.


Python’s scoping rules are mainly defined by PEP 227 introducing nested scoping (“closures”) and PEP 3104 introducing writable nested scoping (nonlocal). The primary use-case of such static scoping is to allow higher-order functions (“function-producing-function”) to automatically parameterise inner functions; this is commonly used for callbacks, decorators or factory-functions.

def adder(base=0):  # factory function returns a new, parameterised function
    def add(x):
        return base + x  # inner function is implicitly parameterised by base
    return add

Both PEPs codify how Python handles the complications of static scoping. In specific, scope is resolved once at compile time – every name is thereafter strictly either global, nonlocal or local. In return, static scoping allows to optimise variable access – variables are read either from a fast array of locals, an indirecting array of closure cells, or a slow global dictionary.

An artefact of this statically scoped name resolution is UnboundLocalError : a name may be scoped locally but not yet assigned locally. Even though there is some value assigned to the name somewhere, static scoping forbids accessing it.

>>> some_name = 42
>>> def ask():
...     print("the answer is", some_name)
...     some_name = 13
...
>>> ask()
UnboundLocalError: local variable 'some_name' referenced before assignment

Various means exist to circumvent this, but they all come down to the programmer having to explicitly define how to resolve a name.


While Python does not natively implement dynamic scoping, it can in be easily emulated. Since dynamic scoping is identical to a stack of scopes for each stack of calls, this can be implemented explicitly.

Python natively provides threading.local to contextualise a variable to each call stack. Similarly, contextvars allows to explicitly contextualise a variable – this is useful for e.g. async code which sidesteps the regular call stack. A naive dynamic scope for threads can be built as a literal scope stack that is thread local:

import contextlib
import threading


class DynamicScope(threading.local):  # instance data is local to each thread
    """Dynamic scope that supports assignment via a context manager"""
    def __init__(self):
        super().__setattr__('_scopes', [])  # keep stack of scopes

    @contextlib.contextmanager  # a context enforces pairs of set/unset operations
    def assign(self, **names):
        self._scopes.append(names)  # push new assignments to stack
        yield self                  # suspend to allow calling other functions
        self._scopes.pop()          # clear new assignments from stack

    def __getattr__(self, item):
        for sub_scope in reversed(self._scopes):  # linearly search through scopes
            try:
                return sub_scope[item]
            except KeyError:
                pass
        raise NameError(f"name {item!r} not dynamically defined")

    def __setattr__(self, key, value):
        raise TypeError(f'{self.__class__.__name__!r} does not support assignment')

This allows to globally define a dynamic scope, to which a name can be assigned for a restricted duration. Assigned names are automatically visible in called functions.

scope = DynamicScope()

def print_answer():
    print(scope.answer)  # read from scope and hope something is assigned

def guess_answer():
    # assign to scope before calling function that uses the scope
    with scope.assign(answer=42):
        print_answer()

with scope.assign(answer=13):
    print_answer()  # 13
    guess_answer()  # 42
    print_answer()  # 13
print_answer()      # NameError: name 'answer' not dynamically defined
User contributions licensed under: CC BY-SA
7 People found this is helpful
Advertisement