Python Class Scopes and Nonlocals

The following is a reply to a 2023 LP5E book-errata post, relocated here from the official publisher errata page. The errata page breaks indentation in code, and doesn't support meatier replies like this one. To view the original post, click the button below.

name = 'global'

# del name

def func():
name = 'enclosing'
class Cls:
print(name) # 1st print
name = name # Cls.name attr
print(name) # 2nd print

# running func() outputs:
# 1st print => global
# 2nd print => global
#
# but uncommenting del name
# throws NameError
#
# Isn't name supposed to reference
# 'enclosing' from func's local scope,
# according to LEGB scope lookup protocol?
#
# python --version => 3.10.6
# Operating System => Ubuntu
func()

Reply

This post refers to the section Nested Classes: The LEGB Scopes Rule Revisited which is at page 904 in recent printings and 875 earlier. First off, it's impossible to tell for certain what you're trying to show, because all your code's indentation was lost in the post (an issue with this errata system, regrettably).

That said, I believe you have stumbled onto an obscure inconsistency in how Python processes the local scopes of functions and classes, and an old bug in the implementation of class scopes. Specifically, this seems to have already been raised on the Python bug tracker by two reports:

Given that this bug hasn't been resolved in Python for some 14 years, it may now be permanent, so I'll provide some context for readers here. To get started, consider the following snippet, where => marks output:

x = 'global'

def f():
   x = x        # error: unbound local reference (statically determined)
   print(x)     # never reached (but if x=x removed, => global)

f()

class c:
    x = x       # okay: finds in enclosing scope, assigned to attribute
    print(x)    # => global

i = c()
print(i.x)      # => global

The f() function call throws an UnboundLocalError exception, as it should: per the book (see Chapter 21's Gotchas), assigning x classifies it as a local variable statically when the def is processed, and before any calls. Because this classification applies to the entire function, the reference to x on the right is in error: local x is unassigned.

By confusing contrast, though, the class and c() instance-creation call work without error: the assignment to x in c creates attribute c.x when the class statement runs (not when the instance is later created), and the reference to x on the right happily looks up x in the surrounding global scope—unlike the def!

This partly reflects timing: functions classify locals for later use (calls), but classes use names immediately (as the class statement itself runs, line by line) to make attributes. The LEGB rule applies to classes for all other purposes (where the class statement is L); but they differ in their recognition of local (i.e., class-scope) variables.

Now, in your case (assuming I'm recreating it accurately), the same thing happens when classes are nested—an assignment of a global to a same-named local works, and effectively hides the same name in the enclosing scope:

name = 'global'

#del name   # makes the name=name in Nested1 fail

def f():
    name = 'enclosing'
    def nested():
        #name = name      # error: local name referenced before assignment
        print(name)       # => enclosing (but only if comment out name=name)
    return nested

f()()

def f():
    name = 'enclosing'
    class Nested1:
        name = name       # okay: assigns local/attr to global's value!
        print(name)       # => global (now looked up in local/class)
    return Nested1

f()()                     # deleting this avoids exc if del name: call time

def f():
    name = 'enclosing'
    class Nested2:        # but removing name=name makes LEGB work as usual!
        print(name)       # => enclosing (unlike Nested1!)
    return Nested2

f()()

Here, the name=name in Nested1 is resolved when Nested1's code runs (during an f() call). As an artifact of implementation, the reference to name on the right skips the enclosing scope and falls back on the global scope—contrary to the LEGB rule.

Further, because the name=name in the Nested1 class statement simply sets local name to the value of global name global, deleting name from the global scope before this will make this statement fail with an exception, as you noticed.

This exception can happen only if a class relies on a global-scope name that's deleted (rare to be sure). More disturbingly, though, this means that such an assignment won't pick up the same name in the enclosing def per LEGB, as it probably should.

For example, removing name=name in Nested2 causes the enclosing scope to be correctly used for its name reference. This seems buggy; the references to name in Nested1 and Nested2 should work the same, but something about the name=name assignment in Nested1 throws this off.

Oddest of all, this occurs only when assigning a name to its identical self—using any name that differs from the global suffices to correctly access the enclosing def's name:

name1 = 'global'
def f():
    name1 = 'enclosing'
    class Nested1:
        name1 = name1     # skips enclosing scope for name1 ref (bug!)
        print(name1)      # => global
    return Nested1
f()()

name1 = 'global'
def f():
    name1 = 'enclosing'   # different names breaks the spell
    class Nested3:
        name2 = name1     # uses enclosing scope for name1 ref (LEGB)
        print(name2)      # => enclosing!
    return Nested3
f()()

Alternatively, adding nonlocal statements to both function and class skirt the issue too, by ensuring that all name assignments are mapped to the enclosing def—and in the same way:

name = 'global'    # irrelevant here
del name           # irrelevant here

def f():
    name = 'enclosing'
    def nested():
        nonlocal name    # adding this makes all lines work
        name = name      # mapped to enclosing def
        print(name)      # => enclosing
    return nested
f()()

def f():
    name = 'enclosing'
    class Nested:
        nonlocal name    # adding this makes all lines work, per LEGB
        name = name      # mapped to enclosing def
        print(name)      # => enclosing
    return Nested
f()()

Hence, you might avoid this drama by adding nonlocal to forcibly map to the enclosing def. Better yet, don't use the same name for variables in multiple scopes; that's confusing even on a good day (this post did so as a demo, but real code should do better).

Thanks for the interesting query. This seems too obscure to cover in the book and may be fixed in Python at any time, but it may warrant a perplexing sidebar in a future edition. For now, feel free to escalate on the Python bugs tracker if you care enough to do so.



[Home page] Books Code Blog Python Author Train Find ©M.Lutz