本节摘要:函数在 Python 里是一等对象:可赋值、可传参、可存容器、可当返回值。它还随身携带一个环境——闭包。本节讲函数对象的内部构成、LEGB 名字查找规则、参数系统的全部形态(默认、可变、仅位置/仅关键字、*args/**kwargs),并解剖一个闭包计数器。
1.2 节说过函数是对象,现在看看它身上挂着什么:
>>> def greet(name, prefix="Hi"): ... """向对方问好""" ... return f"{prefix}, {name}!" >>> type(greet) <class 'function'> >>> greet.__name__, greet.__doc__, greet.__defaults__ ('greet', '向对方问好', ('Hi',)) >>> print(greet.__code__.co_varnames) # 函数体内出现的全部局部名 ('name', 'prefix')
函数对象自带三样行李:代码对象(编译后的字节码与局部名表)、全局命名空间引用、默认值元组。__defaults__ 尤其眼熟——1.3 节可变默认参数坑的案发现场正是这里。
一等公民的直接好处是函数可以当数据用。一个把"策略"参数化的排序例子:
records = [("ann", 92), ("bob", 85), ("cy", 91)] by_score = sorted(records, key=lambda kv: kv[1], reverse=True) # [('ann', 92), ('cy', 91), ('bob', 85)]
key 接收的不是"结果",是"怎么算结果"的函数——把行为作为参数传递,这是从"面向过程"跨向高阶抽象的第一步。
赋值创建名字,但名字住在哪里?Python 的命名空间从内到外四层,查找严格按 LEGB 顺序:
关键规则只有一条:函数内出现赋值语句,该名字就默认为局部名——哪怕赋值发生在使用之后:
>>> counter = 10 >>> def bump(): ... counter = counter + 1 # 赋值使 counter 成为局部名 ... return counter >>> bump() UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value
报错不是"找不到全局变量",而是"局部变量尚未赋值就被引用"——解释器在编译期就决定了 counter 是局部的。想改全局(或外层)变量,必须显式声明 global counter 或 nonlocal counter。而没有赋值、只读取的名字,自然沿 LEGB 向外找:
>>> threshold = 0.5 >>> def classify(x): ... return "high" if x > threshold else "low" # 读全局,合法 >>> classify(0.7) 'high'

工程建议:全局变量能少用就少用,跨模块共享状态用配置对象或第 5 章的模块级约定;真的需要写全局时,把 global 声明写在函数第一行并写注释说明理由。
现代 Python 的参数排序规则:**仅位置 → 位置或关键字 → *args(或裸 *)→ 仅关键字 → kwargs:
def connect(host, port=5432, *, timeout=3.0, pool=True, **extra): ... connect("db.local") # 位置 + 默认 connect("db.local", 6432, timeout=5) # timeout 传位置会报错——在 * 之后 # TypeError: connect() takes 2 positional arguments
仅关键字参数(* 之后的)是 API 设计的自觉:调用点必须写出参数名,可读性与防错同时到手。标准库里 sorted(items, key=..., reverse=...) 就是这么设计的。/ 标记则反方向划出"仅位置"区,适合参数名无语义的场合(如 pow(x, y, z))。
*args 与 **kwargs 的本质是调用侧的收集与解包,成对出现:
>>> def audit(*args, **kwargs): ... print(args, kwargs) >>> audit(1, 2, mode="fast") (1, 2) {'mode': 'fast'} >>> parts, opts = (1, 2), {"mode": "fast"} >>> audit(*parts, **opts) # 解包再传入,还原上一次调用 (1, 2) {'mode': 'fast'}
定义侧收集、调用侧解包,是同一枚硬币的两面。装饰器(第 7 章)之所以能"透传任意签名",全靠这对搭档。
函数不仅能传出去,还能记住定义处的名字。这种"函数 + 环境"的打包就是闭包:
def make_counter(start=0, step=1): count = start def bump(): nonlocal count # 改写外层函数的变量 count += step return count return bump # 把内层函数连同环境一起返回 c1 = make_counter() c2 = make_counter(start=100) c1(), c1(), c2() # (1, 2, 101) —— 两个计数器互不干扰
c1 与 c2 各自"闭"住了一份独立的 count。证据存在函数对象的 __closure__ 里:
>>> c1.__closure__[0].cell_contents 2
闭包是 Python 实现"轻量状态对象"的手段——不想为一个计数器定义一个类时,闭包就是答案。JS 出身的读者会倍感亲切。
经典的延迟绑定坑也来自闭包:循环里创建的函数共享同一个循环变量,调用时才查它的终值:
>>> fns = [lambda: i for i in range(3)] >>> [f() for f in fns] [2, 2, 2] # 不是 0,1,2! >>> fns = [lambda i=i: i for i in range(3)] # 默认参数在定义时求值,钉住当前值 >>> [f() for f in fns] [0, 1, 2]
记住模型就不再踩坑:闭包捕获的是变量本身,不是创建那一刻的值。
lambda 是"表达式版的单行函数",限制(只能是表达式)正是它的用途说明——给 key、回调这类微型策略用;逻辑超过一行就命名成 def 函数,别硬撑 lambda。最后,任何实现 __call__ 的对象都能像函数一样调用,类的实例也可以"当函数用"——可调用性的统一抽象在第 7 章装饰器里还会回来。
⚠️ 常见坑:可变默认参数(1.3 节,配合
__defaults__复查);忘记nonlocal导致 UnboundLocalError;闭包延迟绑定;**kwargs键与显式关键字参数重名报multiple values。
💡 关键直觉:函数 = 代码对象 + 环境引用。参数系统的一切形态,只是"收集"与"解包"在定义侧与调用侧的组合。
__code__、__defaults__、__closure__,可当值传递。*/*args → 仅关键字 → **kwargs;仅关键字参数是好 API 的标志。*args/**kwargs 收,调用侧 */** 展开。nonlocal 可改写;延迟绑定坑用默认参数钉值。第 4 章把"携带环境的可调用"升级为完整的对象体系——类,本质上是"生产实例的 callable"。