Lexical Scope In Python

Quick and dirty summary

In any other modern languages (which follow lexical scope structure), the example below intuitively works.

def fun():
	item = 3
	def double():
		item *= 2
		return item
		
	return double()

fun()

But it doesn’t, it results in an error: UnboundLocalError: local variable ‘item’ referenced before assignment.

This doesn’t make sense to me, the variable item should clearly be accessible inside the function double().

In Python, outer variables are generally accessible to reference, but are unchangeable.

You can change a variable by declaring that variable in your scope as nonlocal to modify it.

def fun():
	item = 3
	def double():
		nonlocal item
		item *= 2
		return item
		
	return double()

print(fun()) # prints 6, no error

(For other hacky ways of solving the situation and more information on this, read more below).

Lexical Scope in Python

An informal definition of Lexical Scope: the ability of a function scope to access variables from its parent scope, and the limitation of being unable to access child function scopes inside of it.

Here I will outline the dirty rules concerning scope in Python. Pay extra pedantic attention to italicized words, as these are very specific rules:

This is where it gets less intuitive for me:

This is what the nonlocal keyword is for. Here’s the example from earlier:

def fun():
	item = 3
	def double():
		nonlocal item
		item *= 2
		return item
		
	return double()

print(fun()) # prints 6, no error

nonlocal specifies the identifier item “to refer to previously bound variable(s) in the nearest enclosing scope excluding globals”. In this case, the closest definition of item found is 3.

Interestingly (as specified in the documentation linked above), nonlocal looks for bound variables excluding globals. So this:

item = 7
def fun():
	def double():
		nonlocal item
		item *= 2
	
	double()

fun()
print(item)

results in a Syntax error: no binding for nonlocal 'item' found.

So, you’d have to utilize the global keyword for this situation.

item = 7
def fun():
	def double():
		global item
		item *= 2
		return item
		
	return double()
	
print(fun()) # prints 14

Cursed Solutions: Warning

Remember in the annoying rules earlier where its mentioned that you can access outer variables, just not modify them?

item = 7
def fun():
	def dummy():
		print(item)
		return item
	
	return dummy()

print(fun())
# output will be:
# 7
# 7

This is valid. Problems only arise when reassigning those variables. This seems to stem from the lack of a variable declaration keyword.

But because we have access to a variable, we are able to mutate it.

class Item:
	def __init__(self, value):
		self.value = value

item = Item(12)
print(item.value) # 12

def fun():
	item.value = 7
	
fun()
print(item.value) # 7

See what I mean? You can do the same with a list.

item = [4]
print(item[0]) # 4

def fun():
	item[0] = 7

fun()
print(item[0]) # 7

(I saw someone use this in their leetcode solution instead of nonlocal and I was appalled.)

It seems that before nonlocal, in situations which nonlocal solves, you’d have to do this.

Personal notes

I shockingly never ran into these specifics until 2 situations recently to learn about nonlocal: