21. 变量作用域与名称解析
当你在 Python 中创建一个变量时,它“存在”在哪里?函数能看到在它外部创建的变量吗?函数外部的代码能访问在函数内部创建的变量吗?这些问题都与作用域(scope)有关——也就是程序中某个名称可见并可以使用的区域。
理解作用域对于编写能正确且可预测运行的函数至关重要。缺少这方面的知识,你可能会不小心制造 bug:变量的值和你预期的不一致,或者对变量的修改并没有按预期持久保存。
在本章中,我们将探索 Python 如何确定某个名称引用的是哪个变量、如何控制变量在何处可访问,以及当你删除一个名称时会发生什么。到最后,你将理解支配 Python 程序中变量可见性的规则。
21.1) 局部变量与全局变量
Python 中的每个变量都存在于某个特定的作用域(scope)之内——这是一个变量名被定义并且可访问的代码区域。最基础的两种作用域是局部(local)与全局(global)。
理解全局作用域
在程序顶层——任何函数之外——创建的变量存在于全局作用域中。这些变量称为全局变量,在它们被定义之后,你的模块中任何位置都可以访问它们。
# 全局变量——在模块层级定义
total_users = 0
def show_user_count():
# 这个函数可以读取全局变量
print(f"Total users: {total_users}")
show_user_count() # Output: Total users: 0
print(total_users) # Output: 0在这个例子中,total_users 是一个全局变量。函数 show_user_count() 和模块层级的代码都可以访问它。你可以把全局变量理解为在整个程序文件中都是可见的。
理解局部作用域
在函数内部创建的变量存在于该函数的局部作用域中。这些变量称为局部变量,并且只能在定义它们的函数内部访问。一旦函数执行结束,局部变量就会消失。
def calculate_discount(price):
# discount_rate 是此函数的局部变量
discount_rate = 0.15
discount_amount = price * discount_rate
return discount_amount
result = calculate_discount(100)
print(result) # Output: 15.0
# 这会导致错误——这里不存在 discount_rate
# print(discount_rate) # NameError: name 'discount_rate' is not defined变量 discount_rate 和 discount_amount 只在 calculate_discount() 运行时存在。函数返回后,这些名称就不再存在了。其实这是一件好事——它能防止函数用临时变量把你的程序弄得很杂乱。
为什么局部作用域很重要
局部作用域提供了封装(encapsulation)——每个函数都有自己私有的工作空间。这意味着你可以在不同函数中使用相同的变量名而不会冲突:
def calculate_tax(amount):
rate = 0.08 # 局部变量
return amount * rate
def calculate_shipping(weight):
rate = 5.00 # 名称相同但不同的局部变量
return weight * rate
tax = calculate_tax(100)
shipping = calculate_shipping(3)
print(f"Tax: ${tax}") # Output: Tax: $8.0
print(f"Shipping: ${shipping}") # Output: Shipping: $15.0两个函数都使用了名为 rate 的变量,但它们在不同的局部作用域中,是完全独立的变量。在一个函数里对 rate 的更改不会影响另一个函数里的 rate。这种隔离让函数更可靠,也更容易理解。
在函数中读取全局变量
函数可以读取全局变量而无需任何特殊语法:
# 全局配置
max_login_attempts = 3
def check_login(password):
# 读取全局变量
if password == "secret123":
return "Login successful"
else:
return f"Invalid password. You have {max_login_attempts} attempts."
result = check_login("wrong")
print(result) # Output: Invalid password. You have 3 attempts.函数 check_login() 可以读取 max_login_attempts,因为它是全局变量。不过,这里有一个我们需要理解的重要限制。
赋值会创建局部变量的规则
作用域的棘手之处就在这里。如果你在函数内部对某个变量名进行赋值,Python 会创建一个同名的新的局部变量,即使已经存在同名的全局变量:
counter = 0 # 全局变量
def increment_counter():
# WARNING: 这会创建一个名为 counter 的新局部变量——仅用于演示
# PROBLEM: 在对局部 counter 赋值之前就尝试读取它
counter = counter + 1 # UnboundLocalError: local variable 'counter' referenced before assignment
print(counter)
# increment_counter() # This call results in UnboundLocalError这段代码失败的原因是:Python 看到了赋值语句 counter = counter + 1,就判定 counter 必定是一个局部变量。可当它要计算 counter + 1 时,局部变量 counter 还没有值——我们试图在赋值前使用它。
这是一个常见的困惑来源。规则是:只要函数体中任何位置对某个变量名进行了赋值,该名称在整个函数中都会被当作局部变量,即使是在赋值语句之前。
我们用更直观的例子来看:
message = "Hello" # 全局变量
def show_message():
print(message) # 这能工作——只是读取全局变量
def change_message():
# WARNING: 这演示了一个常见错误——仅用于演示
# PROBLEM: Python 看到下面的赋值,所以 message 在整个函数中都被当作局部变量
print(message) # UnboundLocalError!
message = "Goodbye" # 这会让 message 在整个函数中都变为局部变量
show_message() # Output: Hello
# change_message() # This call results in UnboundLocalError函数 show_message() 可以正常工作,因为它只读取 message。但 change_message() 会失败,因为第二行的赋值让 Python 在整个函数中都把 message 当作局部变量,包括赋值前的 print() 语句。
参数是局部变量
函数参数是局部变量,它们的初始值来自函数调用时传入的参数:
def greet(name): # 'name' 是一个局部变量
greeting = f"Hello, {name}!" # 'greeting' 也是局部变量
return greeting
message = greet("Alice")
print(message) # Output: Hello, Alice!
# 这里既不存在 'name' 也不存在 'greeting'
# print(name) # NameError参数 name 只在 greet() 函数内部存在。它在函数被调用时创建,在函数返回时消失。
实用示例:购物车计算
让我们看看在一个真实场景中,局部作用域与全局作用域是如何一起工作的:
# 全局配置
tax_rate = 0.08
free_shipping_threshold = 50
def calculate_total(subtotal):
# 本次计算使用的局部变量
tax = subtotal * tax_rate # 读取全局 tax_rate
# 确定运费
if subtotal >= free_shipping_threshold: # 读取全局阈值
shipping = 0
else:
shipping = 5.99
total = subtotal + tax + shipping
return total
# 针对不同购物车金额计算
cart1 = calculate_total(30)
cart2 = calculate_total(60)
print(f"Cart 1 total: ${cart1:.2f}") # Output: Cart 1 total: $38.39
print(f"Cart 2 total: ${cart2:.2f}") # Output: Cart 2 total: $64.80在这个例子中:
tax_rate和free_shipping_threshold是全局配置值subtotal、tax、shipping、total对每一次calculate_total()调用来说都是局部变量- 每次函数调用都会得到一套彼此独立的局部变量
- 函数可以读取全局配置,但不会修改它
这种关注点分离让代码更清晰:全局变量保存对所有地方都适用的配置,而局部变量保存仅对每次函数调用有效的临时计算结果。
21.2) 名称解析的 LEGB 规则
当 Python 遇到一个变量名时,它如何知道你指的是哪个变量?Python 遵循一种特定的查找顺序,称为 LEGB 规则(LEGB rule)。LEGB 代表 Local、Enclosing、Global、Built-in——Python 按照这个顺序搜索的四种作用域。
LEGB 中的四种作用域
让我们理解 LEGB 层级中的每一种作用域:
- Local (L):当前函数的作用域
- Enclosing (E):任意外围函数的作用域(包含当前函数的函数)
- Global (G):模块级作用域
- Built-in (B):Python 的内置名称,比如
print、len、int等
当你使用一个变量名时,Python 会按顺序搜索这些作用域:L → E → G → B。它使用找到的第一个匹配项,并停止继续搜索。
局部作用域:Python 首先查找的地方
Python 总是先检查局部作用域:
def calculate_price():
price = 100 # 局部变量
tax = 0.08 # 局部变量
total = price * (1 + tax)
return total
result = calculate_price()
print(result) # Output: 108.0当 Python 在 calculate_price() 内部看到 price、tax 和 total 时,它会在局部作用域中找到它们并使用这些值。查找在局部作用域就停止了——Python 不需要继续向外查找。
全局作用域:当局部作用域中没有时
如果名称在局部作用域中找不到,Python 会检查全局作用域:
# 全局变量
default_tax_rate = 0.08
default_currency = "USD"
def calculate_price(amount):
# 'amount' 是局部变量,会立即找到
# 'default_tax_rate' 不是局部变量,会在全局作用域中找到
total = amount * (1 + default_tax_rate)
return total
result = calculate_price(100)
print(result) # Output: 108.0当 Python 在函数内部遇到 default_tax_rate 时,它在局部作用域中找不到,就会去搜索全局作用域并在那里找到它。
内置作用域:Python 预定义的名称
如果名称在局部作用域或全局作用域中都找不到,Python 会检查内置作用域——也就是 Python 自动提供的名称:
def process_data(numbers):
# 'numbers' 是局部变量
# 'len' 既不是局部也不是全局——它是内置的
count = len(numbers)
# 'max' 也是内置的
maximum = max(numbers)
return count, maximum
data = [10, 25, 15, 30, 20]
result = process_data(data)
print(result) # Output: (5, 30)名称 len 和 max 并不是在你的代码中定义的——它们是 Python 提供的内置函数。当 Python 在局部与全局都找不到这些名称时,它会去内置作用域中查找并在那里找到它们。
封闭作用域:嵌套函数
当你有嵌套函数(在其他函数内部定义的函数)时,封闭作用域就会发挥作用。这也是 LEGB 中 “E” 的重要性所在:
def outer_function():
outer_var = "I'm from outer" # 对 inner_function 来说属于封闭作用域
def inner_function():
inner_var = "I'm from inner" # inner_function 的局部变量
# inner_function 可以看到 inner_var(局部)和 outer_var(封闭)
print(inner_var) # Output: I'm from inner
print(outer_var) # Output: I'm from outer
inner_function()
outer_function()对于 inner_function() 来说,outer_function() 的作用域是一个封闭作用域。当 inner_function() 引用 outer_var 时,Python 会按顺序搜索:
inner_function()的局部作用域——未找到outer_function()的封闭作用域——找到了!使用该值
LEGB 实战:简单示例
我们用一个清晰、直接的例子来看看四种作用域如何一起工作:
# 内置:len(由 Python 提供)
# 全局:multiplier
multiplier = 10
def outer(x):
# inner 的封闭作用域
y = 5
def inner(z):
# 局部作用域
# z 是局部变量 (L)
# y 来自封闭作用域 (E)
# multiplier 来自全局作用域 (G)
# len 来自内置作用域 (B)
result = len([z, y, multiplier]) # 使用全部四种作用域!
return z + y + multiplier
return inner(3)
answer = outer(100)
print(answer) # Output: 18当 Python 在 inner() 内部计算 z + y + multiplier 时:
- L (Local):找到
z = 3 - E (Enclosing):在
outer()中找到y = 5 - G (Global):找到
multiplier = 10 - B (Built-in):找到
len函数
这个例子清楚地展示了 Python 如何搜索这四种作用域来解析名称。
遮蔽:当内部作用域隐藏外部名称时
如果同一个名称在多个作用域中都存在,最内层的作用域会“获胜”——这称为遮蔽(shadowing):
value = "global"
def outer():
value = "enclosing"
def inner():
value = "local"
print(value) # Which value?
inner()
print(value) # Which value?
outer()
print(value) # Which value?Output:
local
enclosing
global每个 print() 语句看到的 value 都不同,因为 Python 在找到第一个匹配项后就停止了:
- 在
inner()内部:在局部找到value→ 打印 "local" - 在
outer()内部但在inner()外部:在outer()的作用域中找到value→ 打印 "enclosing" - 在模块层级:在全局中找到
value→ 打印 "global"
可视化 LEGB 搜索顺序
这个图展示了 Python 的搜索过程。它从最内层的作用域开始,逐步向外查找。如果名称在任何作用域中都找不到,Python 就会抛出 NameError。
为什么 LEGB 对编写函数很重要
理解 LEGB 能帮助你:
- 预测变量值:你能确切知道 Python 会使用哪个变量
- 避免命名冲突:你理解名称何时会相互遮蔽
- 设计更好的函数:你可以决定每个变量应该放在何种作用域中
- 调试作用域问题:当变量值不符合预期时,你可以沿着 LEGB 追踪查找过程
LEGB 规则是 Python 解析名称方式的基础。每次你使用一个变量时,Python 都在幕后遵循这条规则。
21.3) 谨慎使用 global 关键字
我们已经看到函数可以读取全局变量,但如果你需要在函数内部修改全局变量呢?这就要用到 global 关键字——但应该谨慎且尽量少用。
问题:赋值会创建局部变量
如前所述,在函数内部对变量赋值会创建一个局部变量:
counter = 0 # 全局变量
def increment():
# WARNING: 这会创建一个名为 counter 的新局部变量——仅用于演示
# PROBLEM: 在对局部 counter 赋值之前就尝试读取它
counter = counter + 1 # UnboundLocalError!
# increment() # This call results in UnboundLocalError这会失败,因为 Python 看到了赋值,就把 counter 当作整个函数中的局部变量。但我们试图在对它进行局部赋值之前读取 counter。
这是使用全局变量时最常见的错误之一。错误信息 UnboundLocalError: local variable 'counter' referenced before assignment 精确地说明了发生了什么:Python 判定 counter 是局部变量(因为有赋值),但你在给它赋值之前就尝试使用它。
解决方案:将变量声明为全局
global 关键字会告诉 Python:“不要创建这个名字的新局部变量,而是使用全局变量。”
counter = 0 # 全局变量
def increment():
global counter # 告诉 Python 使用全局 counter
counter = counter + 1 # 现在会修改全局变量
print(f"Before: {counter}") # Output: Before: 0
increment()
print(f"After: {counter}") # Output: After: 1
increment()
print(f"After again: {counter}") # Output: After again: 2global counter 声明必须在你使用该变量之前出现。它告诉 Python,在此函数中对 counter 的任何赋值都应该修改全局变量,而不是创建局部变量。
多个全局变量
你可以在一条语句中把多个变量声明为全局变量:
total_sales = 0
total_customers = 0
def record_sale(amount):
global total_sales, total_customers
total_sales += amount
total_customers += 1
print(f"Sales: ${total_sales}, Customers: {total_customers}")
# Output: Sales: $0, Customers: 0
record_sale(25.50)
record_sale(30.00)
print(f"Sales: ${total_sales}, Customers: {total_customers}")
# Output: Sales: $55.5, Customers: 2total_sales 和 total_customers 都被声明为全局变量,因此函数可以修改它们两者。
何时使用 global:共享状态
当你需要维护共享状态(shared state)时——也就是多个函数需要访问并修改的数据——使用 global 是合适的:
# 游戏状态
player_score = 0
player_lives = 3
game_over = False
def award_points(points):
global player_score
player_score += points
print(f"Score: {player_score}")
def lose_life():
global player_lives, game_over
player_lives -= 1
print(f"Lives remaining: {player_lives}")
if player_lives <= 0:
game_over = True
print("Game Over!")
def check_game_status():
# 只是读取全局变量——不需要 global 关键字
if game_over:
return "Game Over"
else:
return f"Playing - Score: {player_score}, Lives: {player_lives}"
# 进行游戏
award_points(100) # Output: Score: 100
award_points(50) # Output: Score: 150
lose_life() # Output: Lives remaining: 2
print(check_game_status()) # Output: Playing - Score: 150, Lives: 2这个例子展示了对 global 的恰当使用:多个函数需要修改共享的游戏状态。不过请注意,check_game_status() 不需要 global,因为它只是读取这些变量。
为什么应该谨慎使用 global
虽然 global 有时是必要的,但过度使用会让代码更难理解和维护。原因如下:
问题 1:隐藏的依赖关系
当函数修改全局变量时,从函数调用本身看不出哪些内容会发生变化:
total = 0
def add_to_total(value):
global total
total += value
# 这个函数做了什么?不读代码你看不出来
add_to_total(10)与返回值的写法对比:
def add_to_total(current_total, value):
return current_total + value
total = 0
total = add_to_total(total, 10) # 清晰:total 正在被更新第二个版本明确表示 total 正在被修改。
问题 2:测试变得更困难
修改全局状态的函数更难测试,因为你需要设置并重置全局变量:
# 难以测试——依赖全局状态
score = 0
def add_score(points):
global score
score += points
# 每个测试都需要重置 score
# Test 1
score = 0
add_score(10)
assert score == 10
# Test 2 - must reset score again
score = 0
add_score(20)
assert score == 20问题 3:函数不可复用
依赖特定全局变量的函数无法轻易在其他程序中复用:
# 这个函数只有在存在名为 'inventory' 的全局变量时才工作
inventory = []
def add_item(item):
global inventory
inventory.append(item)避免使用 global 的其他做法
很多情况下,你可以通过使用返回值与参数来避免 global:
不要修改全局状态:
# 使用 global(不太理想)
balance = 1000
def withdraw(amount):
global balance
if amount <= balance:
balance -= amount
return True
return False
withdraw(100)
print(balance) # Output: 900使用返回值:
# 使用返回值(更好)
def withdraw(balance, amount):
if amount <= balance:
return balance - amount, True
return balance, False
balance = 1000
balance, success = withdraw(balance, 100)
print(balance) # Output: 900第二种写法更灵活、更易测试、也更容易复用。
何时 global 确实合适
global 确实有一些合理用途:
- 确实需要全局的配置:
# 全应用范围设置
debug_mode = False
log_level = "INFO"
def enable_debug():
global debug_mode, log_level
debug_mode = True
log_level = "DEBUG"- 用于调试或统计的计数器:
# 用于调试:追踪函数调用次数
_function_call_count = 0
def tracked_function():
global _function_call_count
_function_call_count += 1
# ... rest of function关于 global 的关键要点
- 只有在确实需要修改模块级状态时才使用
global - 尽量优先使用返回值和参数
- 当你确实使用
global时,要说明为什么它是必要的 - 想想你的设计是否可以改进以避免
global - 记住:读取全局变量不需要
global关键字——只有修改时才需要
21.4) 使用 nonlocal 修改封闭函数中的变量
当你有嵌套函数时,你可能需要修改封闭函数作用域中的变量。nonlocal 关键字就是为此而生——它类似 global,但作用对象是封闭函数作用域,而不是全局作用域。
问题:修改封闭作用域中的变量
就像默认赋值会创建局部变量一样,封闭作用域也会出现同样的问题:
def outer():
count = 0 # outer 作用域中的变量
def inner():
# WARNING: 这会创建一个名为 count 的新局部变量——仅用于演示
# PROBLEM: 在对局部 count 赋值之前就尝试读取它
count = count + 1 # UnboundLocalError!
print(count)
inner()
# outer() # This call results in UnboundLocalErrorPython 在 inner() 中看到对 count 的赋值,就把它当作局部变量。但我们试图在局部赋值之前读取它,从而导致错误。
解决方案:nonlocal 关键字
nonlocal 关键字告诉 Python:“这个变量不是局部的——去封闭函数的作用域中找并使用那个变量。”
def outer():
count = 0 # outer 作用域中的变量
def inner():
nonlocal count # 使用 outer 作用域中的 count
count = count + 1
print(f"Count in inner: {count}")
print(f"Count before: {count}") # Output: Count before: 0
inner() # Output: Count in inner: 1
print(f"Count after: {count}") # Output: Count after: 1
outer()现在 inner() 就可以修改 outer() 作用域中的 count 变量了。由于我们修改的是封闭作用域中的实际变量,这个修改在 inner() 返回后仍然会保留。
为什么 nonlocal 很有用:会记住状态的函数
nonlocal 关键字支持一种强大的模式:内部函数可以维护并修改其封闭作用域中的状态。我们会在第 23 章详细学习闭包与工厂函数,但现在你只需要理解:nonlocal 允许内部函数修改封闭作用域中的变量。
下面是一个简单示例,展示 nonlocal 如何工作:
def create_counter():
count = 0 # 对 increment 来说,这是封闭作用域中的变量
def increment():
nonlocal count # 修改封闭作用域中的 count
count += 1
return count
return increment # 返回内部函数
# 创建一个计数器
counter1 = create_counter()
print(counter1()) # Output: 1
print(counter1()) # Output: 2
print(counter1()) # Output: 3
# 再创建一个相互独立的计数器
counter2 = create_counter()
print(counter2()) # Output: 1
print(counter2()) # Output: 2每次调用 create_counter() 都会创建一个新的 count 变量,以及一个新的 increment() 函数;increment() 可以通过 nonlocal 修改对应的那个 count。
nonlocal vs global
理解它们的区别很重要:
x = "global"
def outer():
x = "enclosing"
def use_global():
global x # 引用的是全局 x
print(f"use_global sees: {x}") # Output: use_global sees: global
def use_nonlocal():
nonlocal x # 引用的是 outer 的 x
print(f"use_nonlocal sees: {x}") # Output: use_nonlocal sees: enclosing
use_global()
use_nonlocal()
outer()global总是引用模块级作用域nonlocal引用最近的封闭函数作用域
何时不能使用 nonlocal
nonlocal 只适用于封闭函数作用域。你不能把它用于:
- 全局作用域(应使用
global):
x = "global"
def func():
nonlocal x # SyntaxError: no binding for nonlocal 'x' found
x = "modified"- 在任何封闭作用域中都不存在的变量:
def outer():
def inner():
nonlocal count # SyntaxError: no binding for nonlocal 'count' found关于 nonlocal 的关键要点
- 用
nonlocal来修改封闭函数作用域中的变量 nonlocal会在封闭函数作用域中搜索,而不是全局作用域- 读取封闭变量不需要
nonlocal——只有修改时才需要 nonlocal支持创建具有私有状态的函数这一强大模式- 我们会在第 23 章学习更多关于闭包与工厂函数的内容
nonlocal 关键字对创建维护私有状态的函数尤其有用,正如在计数器等示例中所能看到的那样。
21.5) 使用 del 删除名称(而非对象)及其含义
有时你需要从程序的命名空间中移除一个变量——例如在长时间运行的程序中释放内存、清理临时变量,或者从集合中移除条目。Python 的 del 语句可以处理这些任务,但理解它究竟做了什么、以及没做什么很重要。
Python 中的 del 语句经常被误解。它不会删除对象——它删除的是名称(names)(变量绑定)。理解这种区别对于理解 Python 如何管理内存与引用至关重要。
del 实际做了什么
del 语句会从当前作用域中移除一个名称:
x = 42
print(x) # Output: 42
del x
# print(x) # NameError: name 'x' is not defined执行 del x 后,名称 x 在当前作用域中就不再存在了。如果你尝试使用它,Python 会抛出 NameError,因为这个名称已经不再被定义。
删除名称 vs 删除对象
这就是关键洞察:del 移除的是名称,而不一定是该名称所引用的对象:
# 创建一个列表以及两个引用它的名称
original = [1, 2, 3]
reference = original # 两个名称都引用同一个列表
print(original) # Output: [1, 2, 3]
print(reference) # Output: [1, 2, 3]
# 删除其中一个名称
del original
# 列表仍然存在,因为 'reference' 仍然引用它
print(reference) # Output: [1, 2, 3]
# print(original) # NameError: name 'original' is not defined列表 [1, 2, 3] 仍然存在,因为 reference 仍然引用它。删除 original 仅仅移除了那个名称——并没有删除列表对象本身。
对象何时才会真正被删除
当某个对象不再被任何名称引用时,Python 会自动删除它。这称为垃圾回收(garbage collection):
data = [1, 2, 3] # 创建列表,'data' 引用它
del data # 删除名称 'data'
# 现在列表没有任何引用了,所以 Python 最终会删除它
# (这是自动发生的——你不需要做任何事)当我们删除 data 时,列表 [1, 2, 3] 没有任何剩余引用,因此 Python 的垃圾回收器最终会回收其内存。但这是自动发生的——你无法控制具体何时发生。
从集合中删除条目
del 语句也可以从集合中移除条目,但这与删除名称在本质上不同。当你对集合的索引或切片使用 del 时,你是在修改集合本身,而不是删除一个名称。
这是一个重要区分:当你写 del numbers[2] 时,你是在对列表对象调用一个特殊方法以移除元素。名称 numbers 仍然存在,并且仍然引用同一个列表对象——只是列表现在元素更少了。
# 按索引删除列表元素
numbers = [10, 20, 30, 40, 50]
del numbers[2] # 删除索引 2 处的元素
print(numbers) # Output: [10, 20, 40, 50]
# 删除列表切片
numbers = [10, 20, 30, 40, 50]
del numbers[1:3] # 删除索引 1 到 3(不含 3)的元素
print(numbers) # Output: [10, 40, 50]
# 删除字典条目
person = {'name': 'Alice', 'age': 30, 'city': 'Boston'}
del person['age']
print(person) # Output: {'name': 'Alice', 'city': 'Boston'}