函数、参数、作用域与闭包
本节目标
查询函数定义、参数规则、作用域、闭包和装饰语法入口。
函数把操作封装成可调用对象,参数规定调用边界,词法作用域决定函数体如何解析名称。本章接续上一章的表达式与控制流,围绕三个问题展开:定义何时执行、调用如何绑定参数、名称从哪里取得。这三个问题也是后文排查默认值与闭包行为的索引。
函数定义与函数对象
执行 def 语句时,Python 创建函数对象并将其绑定到函数名,但不会把函数体当作一次调用来执行。函数对象与其他对象一样,可以改绑到另一个名称、放进容器、作为参数传入,或返回给调用者。
def greet(name):
return f"Hello, {name}!"
say_hello = greet
message = say_hello("Python")
由于定义语句本身在运行时执行,条件或另一个函数中的 def 只有在控制流到达时才创建对象;默认参数表达式和装饰器也受这一定义时机影响。真正调用函数时,Python 为本次调用建立局部执行帧,绑定参数和局部名称后再执行函数体。不同调用拥有各自的局部帧,却仍可能通过外层对象或闭包共享状态。
函数名仍是普通绑定。随后执行 greet = other 只会让名称改指其他对象,不会改造原函数;此前保存在 say_hello 中的引用依旧可调用它。表达式 target(...) 先取得 target 引用的对象,再要求对象可调用,并不关心该名称是否来自 def。
调用、返回值与多值返回
调用表达式提供实参,只有参数绑定成功后才会进入函数体。return value 立即结束当前调用并将一个对象交回调用者;只写 return,或运行到函数体末尾仍未遇到显式 return,结果都是 None。
def record(message):
print(message)
result = record("saved")
assert result is None
Python 的一次函数调用只返回一个对象。所谓“多值返回”是由逗号构造元组:return width, height 等价于 return (width, height)。调用者可以保留这个元组,也可以用解包接收它。
def bounds(values):
return min(values), max(values)
pair = bounds([3, 1, 4])
lowest, highest = pair
return 的作用范围仅是它所在的函数,不会替调用者退出循环或外围函数。以副作用为主的函数即便不需要结果,也应让调用者明确其返回 None;终端中打印的内容不是函数返回值。
位置限定、位置或关键字与关键字限定参数
形参写在函数定义中,实参出现在调用处,两者不能混为一谈。完整的形参类别按以下顺序排列,/ 和 * 标出类别边界:
def request(pos_only, /, pos_or_keyword, *args, keyword_only, **kwargs):
...
| 类别 | 定义位置 | 调用方式 |
|---|---|---|
| 位置限定参数 | / 之前 | 只能按位置传入 |
| 位置或关键字参数 | / 之后、* 之前 | 可按位置或关键字传入 |
| 可变位置参数 | *args | 收集额外位置实参为元组 |
| 关键字限定参数 | * 或 *args 之后 | 必须按关键字传入 |
| 可变关键字参数 | **kwargs | 收集额外关键字实参为字典 |
代表脚本的 describe(course, /, *, level=1, tags=()) 把 course 设为位置限定,把 level 和 tags 设为关键字限定。因此正确调用是 describe("python", level=1, tags=("syntax", "typing"));写 describe(course="python") 或把 level 作为第二个位置实参都会在进入函数体前得到 TypeError。
位置限定参数让实现者日后可以更改形参名称而不破坏调用;函数同时声明 **kwargs 时,同名字典键仍可通过 **mapping 进入 kwargs。关键字限定参数则把重要选项的含义显露在调用处。这些边界应服务接口语义,无须为了展示语法而让每个函数都承担全部类别。
默认参数的求值时机
默认参数表达式只在执行函数定义时求值一次,不会随每次调用重新求值。None、数字和字符串等不可变默认值通常符合直觉;可变默认对象则会被所有省略该实参的调用共享。
def append_item(item, items=[]):
items.append(item)
return items
上面的列表只创建一次,所以连续调用 append_item("a")、append_item("b") 会操作同一个列表。需要每次调用获得新列表时,常用 None 作为哨兵,在函数体内创建对象:
def append_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
调用者仍可显式传入列表,让函数修改指定对象。如果 None 本身是有效输入,就要换用唯一哨兵,例如模块级 MISSING = object(),并通过 is 判断。共享默认对象偶尔可用于刻意设计的缓存,但隐含状态不易察觉,通常更适合命名后显式管理。
*args、**kwargs 与调用解包
定义中的 *args 和 **kwargs 用于收集:前者把未绑定的额外位置实参收集为元组,后者把未绑定的额外关键字实参收集为字典。名称 args、kwargs 只是惯例,真正决定语法的是星号。
def forward(prefix, *values, uppercase=False, **options):
return prefix, values, uppercase, options
调用处的 *iterable 与 **mapping 执行解包:前者展开为位置实参,后者把字符串键展开为关键字实参。被调函数绑定形参时发生收集,调用者构造实参时发生解包,两者方向相反。
values = ["syntax", "typing"]
options = {"uppercase": True, "separator": "|"}
result = forward("course", *values, **options)
解包不会绕过普通绑定规则。同一形参收到两次值、位置实参出现在受限位置,或 ** 映射包含非字符串键,调用都会失败。*args、**kwargs 适合转发未知参数;固定选项若也全部藏在其中,错误会延迟到更深的调用才暴露,接口也更难阅读。
词法作用域与 LEGB 查找
函数读取名称时,Python 按词法嵌套关系查找,通常缩写为 LEGB:当前函数的 Local(局部)作用域、外层函数的 Enclosing(闭包)作用域、当前模块的 Global(全局)作用域,最后是 Builtins(内置)作用域。查找关系由源码嵌套结构决定,不会因调用者不同而转去读取调用者的局部变量。
函数体里只要某个名称存在普通赋值,编译器通常就把它判定为该函数的局部名称;这个判断作用于整个函数体,而不是从赋值那一行才开始。于是下面读取的是一个尚未绑定的局部名称,运行时会抛出 UnboundLocalError,并不会先读取同名全局变量:
count = 10
def increment():
print(count)
count = count + 1
参数同样属于局部绑定。沿 LEGB 完全找不到名称会得到 NameError;名称已被判定为局部、读取时却尚未绑定,通常会得到更具体的 UnboundLocalError。排查时应先确认名称归属与数据流,移动语句或依赖调用顺序不能把词法作用域变成动态作用域。
global、nonlocal 与重新绑定
global name 声明函数中的 name 重新绑定模块全局作用域的名称;nonlocal name 声明它重新绑定最近一层已经存在该名称的外层函数作用域。两者关注的是重新绑定名称,而不是读取名称。只读取外层名称不需要声明;调用外层列表的 append 等原地修改对象的操作也没有重新绑定该名称。
status = "draft"
def publish():
global status
status = "published"
nonlocal 既不能凭空创建外层绑定,也不会指向模块全局。代表脚本中的 increment 借它将 current 依次重绑定为新整数。global 引入模块状态依赖,nonlocal 让多个调用共享封闭状态;状态变化复杂时,显式传入并返回状态,或交给职责清楚的对象保存,往往更容易测试和理解。
闭包与捕获状态
引用外层局部名称的内层函数可以连同保存这些绑定的词法环境一起返回,这就是闭包。被捕获的自由变量存放在闭包单元(cell)中,所以外层调用结束后,返回的函数仍能访问 current、name 等绑定。闭包保留的是活绑定,不是函数创建时自动复制的值快照。
这也解释了循环中的晚绑定:多个内层函数若都引用同一个循环变量,等到以后调用时通常会读到该变量最终绑定的值。
readers = [lambda: value for value in range(3)]
assert [reader() for reader in readers] == [2, 2, 2]
captured = [lambda value=value: value for value in range(3)]
assert [reader() for reader in captured] == [0, 1, 2]
修正版在每次求值 lambda 表达式、创建函数对象时求值默认参数,把当轮值保存为各函数自己的参数默认值。代表脚本还展示位置限定与关键字限定接口、通过 nonlocal 更新的计数状态,以及只读外层名称的闭包;固定输入使输出保持确定。
def describe(course, /, *, level=1, tags=()):
joined_tags = "|".join(tags)
return f"course:{course},level:{level},tags={joined_tags}"
def make_counter(start=0):
current = start
def increment():
nonlocal current
current += 1
return current
return increment
def make_label(name):
def label():
return name
return label
counter = make_counter()
counts = [counter(), counter(), counter()]
label = make_label("Python")
print(f"signature={describe('python', level=1, tags=('syntax', 'typing'))}")
print(f"counter={','.join(str(value) for value in counts)}")
print(f"closure-name={label()}")
signature=course:python,level:1,tags=syntax|typing
counter=1,2,3
closure-name=Python
每次调用 counter() 都访问同一个 current 闭包单元,依次产生 1、2、3;label() 则读取保存下来的 name。输出只依赖明确传入的字符串、固定调用次数和固定序列顺序,不依赖对象地址、集合迭代顺序或其他实现细节。
lambda 与小型可调用对象
lambda parameters: expression 创建函数对象,但函数体只能是一个表达式,表达式结果会自动成为返回值。它适合作为排序键或短小转换,例如 sorted(names, key=lambda name: name.casefold())。lambda 仍遵循普通函数的参数绑定和 LEGB 规则,也同样可能形成闭包并遇到晚绑定。
一个表达式并不等于“应该把复杂逻辑挤成一行”。需要多条语句、分支说明、异常处理、文档字符串,或一个便于追踪的稳定名称时,应使用 def。给 lambda 赋一个长期使用的名称通常也不如直接写命名函数清楚。
装饰器入口与函数设计检查
装饰器接收一个可调用对象并返回要绑定的新对象。最基本的 @decorator 语法是定义后重新绑定的简写:
@decorator
def greet(name):
return f"Hello, {name}!"
上面在语义上相当于先创建原函数,再执行 greet = decorator(greet);这个装饰过程发生在定义语句执行时,而不是每次调用时。多个装饰器会从最靠近函数的那一个开始应用。装饰器可以返回包装函数,也可以返回其他可调用对象,但本章只建立这层语法等价关系;更完整的包装、带参数装饰器与描述符行为留待后续章节。
函数设计与审查可以逐项核对以下条件:
- 用可变对象作默认值,导致不同调用意外共享状态;需要新对象时使用
None或唯一哨兵模式。 - 在循环中创建闭包却期待立即捕获每轮值;闭包按绑定晚读取,需要时用默认参数明确捕获。
- 把赋值目标误认为会先从全局读取;局部名称在绑定前读取会得到
UnboundLocalError。 - 滥用
global或nonlocal隐藏数据流;重新绑定确有必要时才声明,复杂状态优先显式传递。 - 转发所有
*args、**kwargs却不说明接口,令重复绑定和拼写错误延迟到深层调用才出现。 - 包装函数丢失原函数名称、文档或签名信息;真正编写包装器时通常使用
functools.wraps,并验证包装后的调用约定。 - 在装饰器执行阶段加入难以察觉的副作用;模块导入可能执行函数定义,从而触发这些副作用。
本章聚焦普通同步函数、参数绑定、词法作用域和闭包,不展开类方法协议或完整装饰器框架。继续阅读类型标注,了解如何为这些函数接口补充供工具读取的类型信息,以及类型信息与运行时行为的边界。