Unravelling `global`

While preparing my talk for PyCascades 2023 on this very blog post series of Python's syntactic sugar, I had an inkling that I could unravel the global statement. After talking to some folks after my talk, I realized that I could, in fact, unravel it! The trick was realizing what made globals (and built-ins) different from locals.

Python's scopes

Before nonlocal and closures, Python had a relatively simple set of  scoping rules that grouped everything into 3 namespaces (i.e. groupings of names, which is a somewhat technical name for "variables"):

  1. Any name created in a block (i.e. def), unless specified by a global statement, was local
  2. Anything at the top of a module or named in a global statement was global
  3. The builtins module contained everything built-in
💡
Technically this definition of the built-in namespace came later when the builtins module was introduced, but that's just historical context.
💡
For anyone wondering about __builtins__, it's actually an implementaiton detail of CPython, so I'm leaving it out of this discussion.

This was known at the LGB rule ( Local, Global, Built-ins). To make the rest of this  blog post easier to follow, assume that when I say "local" I am including nonlocal and closures are just fancy locals (you can read the actual scoping rules if you want the full details).

There is one key thing to notice about my outline of the LGB rule that makes locals unique compared to globals and built-ins: they must be created in the block where they reside. What that means is a local always comes into existence thanks to an assignment, which makes = and := very obvious syntax to signal what is a local name (and it's a piece of  syntax I don't think we can get rid of). Since we can look at an entire file's contents, we can also deduce what all the local names are with complete confidence and consider them taken care of by = (this is actually how Python itself decides what's local and what isn't).

Thus any name we come across which isn't a local is either a global or built-in name. Since you can't assign to the built-in namespace directly, we can disambiguate between globals and built-ins as by assignment; anything that's assigned to that isn't to a local is implicitly a global name. It's also important to note that all the global statement is doing is instructing Python to explicitly treat a name as a global instead of as a local when it comes to assignment. So if we can unravel assigning to a global name then we are done!

Unravelling global assignment

A very important tool we are going to use for this unvravelling is the globals() built-in function. What makes this such an important function for what we want to accomplish is that it "return[s] the dictionary implementing the current module namespace." Getting to treat the global namespace as a dictionary means that assigning to a global can be treated just like assigning to a dictionary key! That makes a direct unravelling of A = 42 be globals()["A"] = 42. But since we already unravelled subscription, we can unravel all the way down to just function calls: getattr(dict, "__setitem__")(globals(), "A", 42).

Unravelling the reading of a global name

But it turns out we can push things a bit farther and even unravel reading a global name (although this isn't really syntactic, so this is just an academic exercise)! Things get a little tricky when you try to read from a global name thanks to us having no syntactic way to tell a global name from a built-in name like we can for assignment. But since we have a distinct way to get both the globals and built-in namspaces via globals() and the builtins  module, respectively, it's straightforward to write code which looks things up appropriately. One way to do that in a single line would be globals()["A"] if "A" in globals() else builtins.A.

One little detail we do need to make sure to take care of, though, is to raise NameError if the name doesn't exist anywhere. So our one-liner is a bit too simplistic. Luckily, the full unravelling isn't tricky if we try to read a name of A:

import builtins as _builtins

if "A" in globals():
    globals()["A"]
else:
    try:
        _builtins.A
    except AttributeError:
        raise NameError("name 'A' is not defined")
Unravelling the reading of the name A