首页/Python 与数据实践/作用域规则与闭包
Python 与数据实践 · 第 05 章

作用域规则与闭包

你一定遇到过这类"灵异事件":变量明明定义过,换到另一个函数里用,解释器却说"不认识这个名字";或者在函数里给外面的计数加一,加完一看,外面的值纹丝不动。这些现象背后的机制就是作用域

约 10 分钟4,184 字18 个代码示例

你一定遇到过这类"灵异事件":变量明明定义过,换到另一个函数里用,解释器却说"不认识这个名字";或者在函数里给外面的计数加一,加完一看,外面的值纹丝不动。这些现象背后的机制就是作用域。

作用域(Scope)划定了每个名字(变量、函数等)在代码中的可见范围。把这一章吃透,你就能准确预判任何一行代码到底取的是哪个值,也能明白 Python 里一个很优雅的技巧——闭包——是怎么运转的。


一、名字查找的固定路线:LEGB

Python 拿到一个名字时,不是东张西望地乱找,而是沿着一条由内向外的固定路线逐层搜索:

查找顺序 层级 名字的所在位置
1 Local 当前函数体内
2 Enclosing 外层嵌套函数
3 Global 模块(文件)最外层
4 Built-in 解释器内置名字(len、print 等)

哪一层先命中就用哪一层的值;四层全部落空,才会抛 NameError。

city = "模块层"          # G:全局作用域

def outer():
    city = "外层函数"     # E:嵌套作用域

    def inner():
        city = "内层函数"   # L:局部作用域
        print(city)      # 按 L → E → G → B 的顺序搜,L 层直接命中

    inner()

outer()  # 输出:内层函数

下面我们把这四层逐个拆开细看。


二、最里面的一层:局部作用域(Local)

写在函数体里的名字,只在函数执行期间"活着",出了函数体就谁也不认识它:

def show():
    inner_val = 88      # 局部名字,只在 show 内部可见
    print(inner_val)

show()        # 88
# print(inner_val)   # NameError!函数外面根本找不到它

容易忽略的一点:函数参数同样是局部名字。

def welcome(user):          # user 是 show 的局部名字
    text = f"你好,{user}"   # text 也是局部名字
    print(text)

welcome("小周")
# print(user)   # NameError!

三、最外面的一层:全局作用域(Global)

直接写在模块顶层(不缩进)的名字属于全局作用域,整个文件都能读到:

total = 0              # 全局名字

def report():
    print(total)       # 只是"读",没问题

report()  # 0

想在函数里改全局变量?

注意:在函数体里对某个名字做赋值,Python 会认定你要在函数内部新建一个局部名字,而不是修改外面的那个:

total = 0

def broken_add():
    total += 1       # UnboundLocalError!
                     # 赋值让 total 变成局部名字,可它还没被赋初值就要先读

# broken_add()

total += 1 等价于 total = total + 1,右边先要读 total,而解释器已把函数内的 total 判定为局部名字,于是"先读后写"卡在读这一步。

正确的做法是用 global 提前声明:

total = 0

def add():
    global total      # 声明:这里的 total 指向全局那个
    total += 1
    print(total)

add()        # 1
add()        # 2
print(total) # 2

四、夹在中间的一层:嵌套作用域(Enclosing)

函数可以套函数。内层函数虽然有自己的局部作用域,但外层函数的名字对它是"透明"的,可以直接读:

def outer():
    msg = "外层的问候"

    def inner():
        print(msg)     # 自己这层没有 msg,往外一层找到了

    inner()

outer()  # 外层的问候

想改外层函数的变量?

和全局的情形一模一样:直接赋值只会新建局部名字,想真正修改,必须用 nonlocal 声明:

def make_ticker():
    ticks = 0

    def tick():
        nonlocal ticks   # 声明:ticks 来自外层函数
        ticks += 1
        return ticks

    return tick

ticker = make_ticker()
print(ticker())  # 1
print(ticker())  # 2
print(ticker())  # 3

上面这个结构就是闭包:tick 函数被返回到外面单独使用,但它仍然牢牢"记得"外层的 ticks。外层函数早已执行结束,这个变量却因为被内层函数引用而一直存活——闭包让函数带上了记忆。

global 和 nonlocal 一句话区分:

关键字 指向哪里
global 模块顶层的全局名字
nonlocal 最近一层外层函数里的名字

五、两个高频翻车现场

现场 1:同一个函数里又读又写全局变量

n = 5

def update():
    print(n)     # 先读
    n = 6        # 后写 → 函数刚编译完,n 就被判定为局部名字了!

# update()  # UnboundLocalError

为什么会这样: Python 编译函数时(还没运行)就确定好了每个名字的作用域。函数体内只要存在对 n 的赋值语句,n 在整个函数里就都是局部名字——包括那行看起来无害的 print(n)。

修正方法:

n = 5

def update():
    global n
    print(n)
    n = 6

update()   # 5
print(n)   # 6

现场 2:可变对象当默认参数

def collect_tag(tag, tags=[]):
    tags.append(tag)
    return tags

print(collect_tag("python"))  # ['python']
print(collect_tag("ai"))      # ['python', 'ai'] —— 第二次怎么带着第一次的?

原因:默认值在 def 语句执行的那一刻就求值了,而且只创建一次。两次调用共享的是同一个列表对象。

修正方法: 默认值给 None,函数体内再现场创建:

def collect_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

六、速查卡:三种典型结构

# 结构 1:普通函数,局部赋值不碰全局
who = "全局的"

def func():
    who = "局部的"    # 新建局部名字,全局的 who 毫发无损
    print(who)       # 局部的

func()
print(who)           # 全局的

# 结构 2:嵌套函数,各自的名字互不干扰
def outer():
    who = "外层的"

    def inner():
        who = "内层的"   # inner 自己的局部名字
        print(who)      # 内层的

    inner()
    print(who)          # 外层的

outer()

# 结构 3:nonlocal 真正改到外层
def outer():
    who = "外层的"

    def inner():
        nonlocal who
        who = "被改写了"  # 修改的是外层那个 who

    inner()
    print(who)            # 被改写了

outer()

作业

作业一:作用域判断

先别运行,逐行推演下面代码的三个输出,把答案写进注释里:

n = "模块层"

def first():
    n = "函数层"

    def second():
        print(n)   # ?

    second()
    print(n)       # ?

first()
print(n)          # ?

作业二:global 的走向

先别运行,推演下面代码的三个输出(注意 inner 里声明的是 global 而不是 nonlocal):

score = 0

def outer():
    score = 100

    def inner():
        global score
        score += 1
        print(score)   # ?

    inner()
    print(score)       # ?

outer()
print(score)           # ?

作业三:修复代码

下面的代码用于统计"处理了多少个数据集":每处理完一批数字,全局计数器加一。但现在运行必报错,请修好它:

datasets_done = 0

def analyze(numbers):
    result = sum(numbers) / len(numbers)
    # 处理完成,计数 +1
    datasets_done += 1
    return result

print(analyze([1, 2, 3]))
print(analyze([4, 5, 6]))
print(f"共处理了 {datasets_done} 个数据集")

作业四:批量定价器(闭包工厂)

实现 make_pricer(unit_price):接收单价,返回一个新函数;返回的函数接收数量 qty,返回 unit_price * qty(总价)。

必须用闭包实现,不许用 global。

apple = make_pricer(3)
print(apple(5))    # 15
print(apple(10))   # 30

pear = make_pricer(2)
print(pear(7))     # 14

作业五:闭包管库存

实现 create_inventory(initial_stock),返回两个函数:

  • restock(n):进货 n 件,返回最新库存
  • ship(n):发货 n 件;库存不够就返回 "库存不足",否则返回最新库存

要求用闭包保存库存状态,不允许把库存变量暴露到外面。

restock, ship = create_inventory(100)
print(restock(50))   # 150
print(ship(30))      # 120
print(ship(200))     # 库存不足

下一章:06 · 类的定义与实例化