学习目标:掌握 Python 的调用约定——传参语义(call-by-sharing)、参数分类(位置/关键字/
*args/**kwargs)、封包解包的两面性;理解命名空间与闭包的底层机制;建立"函数是一等公民"的心智模型,能用匿名函数、高阶函数和生成器写出声明式、流式的数据处理代码。
函数是程序组织的基本单元。第 2 章把函数定义为"可复用的代码块",但 Python 的函数远不止于此——它是一等公民(可以作为值传递)、是带有复杂调用约定的接口(位置/关键字/默认值/限定参数)、是名字解析的边界(作用域与闭包)、也是惰性计算与函数式编程的载体。
Python 的函数设计体现了两个核心哲学:"可读性优先"(关键字参数、限定参数让调用自文档化)和**"显式优于隐式"**(global/nonlocal 声明、lambda 的单表达式约束)。本章不仅讲语法,更用字节码揭穿"参数如何组装""名字如何查找""闭包如何捕获"这三个底层问题。
本章与前面章节的关系:第 2 章 2.2 节给出了函数的快速概览(定义/调用/return);第 4 章 4.9 节讲了赋值侧的序列解包(a, b = ...、*rest);第 5 章讲了迭代器协议与生成器表达式。本章在此基础上系统化:把"封包解包"从赋值侧扩展到函数调用侧与定义侧(6.3),把"函数对象"上升到一等公民(6.1),并用字节码揭穿名字解析与闭包的底层机制(6.4)。深浅拷贝专题中的"引用语义"是理解 6.2 传参语义的前提。
函数是"延迟执行的代码块",同时也是"可以被传递的值"。这两个身份构成了理解 Python 函数的起点:前者对应 def 的语法与调用机制,后者对应函数的一等公民地位。
定义不等于执行。def 是一条可执行语句(在第 2 章提到 Python 万物皆对象),它只做一件事:创建一个函数对象并把它绑定到名字。函数体内部的代码要等到"调用"时才运行。
>>> def greet(name):
... return f"Hello, {name}!"
... # 注意:定义到这里结束,函数体尚未执行
>>> greet # 名字绑定到一个函数对象
<function greet at 0x...>
>>> greet("World") # 调用——此刻函数体才执行
'Hello, World!'return 的语义:
return立即结束函数并返回值;函数内可有多条return- 没有
return或return后不带表达式 → 返回None return之后的代码永不执行(不可达代码)
>>> def f():
... return 1
... print("不可达") # 编译不报错,但永远不会运行
>>> f()
1
>>> def g():
... pass
>>> print(g()) # 没有 return → None
None
⚠️ 陷阱:写"会返回真值的函数"时忘了写return,是 Python 最常见的新手 bug——函数静默返回None,不抛任何错误。排查技巧:bool(func_result)与预期不符时,先检查函数体是否有return。
函数签名:函数名 + 形参列表构成签名。用 inspect.signature 可以程序化地检查:
>>> import inspect
>>> def process(data, *, strict=False, timeout=None): ...
>>> inspect.signature(process)
<Signature (data, *, strict=False, timeout=None)>
>>> str(inspect.signature(process))
'(data, *, strict=False, timeout=None)'inspect.signature 是框架/装饰器在运行时反射函数签名的标准工具(衔接第 12 章元编程)。
字节码:函数是如何被创建的?
def 编译为 MAKE_FUNCTION 指令。看一个简单例子:
>>> import dis
>>> def make():
... def inner():
... return 42
... return inner
>>> dis.dis(make)
1 0 RESUME 0
2 2 LOAD_CONST 1 (<code object inner at 0x..., file "<stdin>", line 2>)
4 MAKE_FUNCTION 0
6 STORE_FAST 0 (inner)
3 8 LOAD_FAST 0 (inner)
10 RETURN_VALUELOAD_CONST 把一个代码对象(code object)压栈,MAKE_FUNCTION 把它包装成函数对象,STORE_FAST 绑定到局部名 inner。这说明:
- 函数体在编译期就编译为字节码并打包进代码对象(字节码 + 常量 + 名字表)
def语句创建函数对象 → 函数对象持有对代码对象的引用- 每次执行
def(例如在循环里)都会新建一个函数对象,即使它们的代码相同
调用时发生了什么? 调用 f(...) 会创建一个栈帧(frame)——包含局部变量、求值栈、指令指针,函数体字节码在帧上执行。这就是"函数调用栈"的底层:每次调用压入一帧,返回时弹出。
>>> dis.dis(lambda: 1)
1 0 RESUME 0
2 LOAD_CONST 1 (1)
4 RETURN_VALUE工程影响:递归或深嵌套调用会消耗栈空间,CPython 的默认递归上限是 1000 层(详见 6.1.4)。理解"调用 = 建帧",就能理解为什么无限递归会
RecursionError。
函数是对象——这意味着它有类型、有属性、可以被赋给变量、放进容器、作为参数传递或作为返回值。这正是"函数式编程"的地基。
>>> def add(a, b):
... """返回两数之和"""
... return a + b
>>> type(add)
<class 'function'>
>>> add.__name__ # 函数名(不带模块限定)
'add'
>>> add.__qualname__ # 限定名(含类/嵌套层级)
'add'
>>> add.__doc__ # docstring
'返回两数之和'
>>> add.__code__ # 底层代码对象
<code object add at 0x..., file "<stdin>", line 1>函数可以像值一样使用:
>>> # 赋给变量——变量成为函数的别名
>>> f = add
>>> f(1, 2)
3
>>> # 放进容器
>>> ops = [add, lambda a, b: a - b]
>>> ops[0](3, 4)
7
>>> # 作为参数(这就是"高阶函数",详见 6.5)
>>> def apply(func, x, y):
... return func(x, y)
>>> apply(add, 3, 4)
7
>>> # 作为返回值(工厂函数,详见 6.4.4 闭包)
>>> def make_add(n):
... def add_n(x):
... return x + n
... return add_n
>>> add_10 = make_add(10)
>>> add_10(5)
15"调用"的底层:callable 与 __call__ 协议。函数之所以能被 () 调用,是因为 function 类型实现了 __call__ 魔术方法。更准确地说,f(x) 等价于 type(f).__call__(f, x)——这是第 4 章运算符重载协议的延续。
>>> callable(add) # 函数是可调用的
True
>>> callable(42)
False
>>> callable(int) # 类型本身也可调用(构造对象)
Truecallable() 检查一个对象是否实现了 __call__。任何对象都可以通过定义 __call__ 变成"可调用对象":
>>> class Multiplier:
... """带状态的"函数"——每次调用乘上 factor"""
... def __init__(self, factor):
... self.factor = factor
... def __call__(self, x):
... return x * self.factor
>>> double = Multiplier(2)
>>> double(10) # 像函数一样调用!
20
>>> callable(double)
True可调用对象 = 函数 + 状态,是"带记忆的函数"。它预览了第 7 章 OOP:当函数需要跨调用保存状态时,类实例(__call__)往往比闭包更清晰。选型建议:轻量状态用闭包,复杂状态用可调用对象。
跨语言对比:
- C:函数不是值,只能通过函数指针间接传递,没有闭包
- Java:函数不是值,用"只有一个方法的接口"(
Runnable/Comparator)模拟;Java 8 之后才有方法引用和 lambda- JavaScript / Rust:函数是一等值,与 Python 类似;Rust 用
Fn/FnMut/FnOncetrait 区分捕获方式(比 Python 更精细)
Python 的"万物皆对象 + 一等函数"让它成为函数式编程语言里最易入门的之一——不需要学习新概念,map/filter/sorted(key=) 天然可用。
docstring 是函数的第一个字符串字面量,作为文档存在 __doc__ 属性里:
>>> def area(width, height):
... """计算矩形面积。
...
... 参数是正数,否则结果无意义。
... """
... return width * height
>>> area.__doc__.splitlines()[0]
'计算矩形面积。'
>>> help(area) # 交互式帮助 = 渲染 docstring实战建议:docstring 约定见 PEP 257。一行函数用单行 docstring;复杂函数用三引号多行,写明参数、返回值、异常。这是"肘后备急"哲学的体现——代码即文档。
函数注解(annotations):在形参和返回值后标注类型,存入 __annotations__:
>>> def add(a: int, b: int) -> int:
... return a + b
>>> add.__annotations__
{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}关键认知:注解不参与运行时。 Python 不会因为注解是 int 就阻止你传字符串——注解是"给人和工具看的元数据",不是类型检查。类型检查由外部工具(mypy/pyright)或运行时检查器完成。
>>> add("1", "2") # 不会报错——注解只是元数据
'12'版本注意(注解的演进,衔接附录 A):
- PEP 3107(Py3.0):引入注解语法
- PEP 484(Py3.5):
typing模块,注解成为类型提示的事实标准- PEP 563(Py3.7,
from __future__ import annotations):注解延迟为字符串求值,解决前向引用- PEP 649 / PEP 749(Py3.14+):注解懒求值成为默认,彻底解决自引用类型与性能问题
递归是"函数调用自己"的编程范式。三要素:基线条件(终止)、递归步(向基线收敛)、不变量(每层问题变小)。
>>> def factorial(n):
... """n! = n * (n-1) * ... * 1"""
... if n <= 1: # 基线条件
... return 1
... return n * factorial(n - 1) # 递归步:n-1 < n,收敛
>>> factorial(5)
120为什么 CPython 不优化尾递归:许多函数式语言(Scheme、Haskell)对"递归调用在最后一步"的尾递归做优化,让递归不消耗额外栈帧。CPython 明确不做(Guido 在 2009 年的邮件里给出理由:会让字节码调试栈混乱,且"更好的办法是改用循环")。所以 Python 里深递归会撞上 RecursionError:
>>> def forever():
... return forever()
>>> forever()
RecursionError: maximum recursion depth exceeded
>>> import sys
>>> sys.getrecursionlimit() # 默认 1000 层
1000
>>> sys.setrecursionlimit(5000) # 可以调大,但改栈大小有栈溢出的 C 层风险实战建议:Python 中能用循环就用循环,递归用于"问题天然递归"的场景——树的遍历、分治、目录结构。而"经典递归适合写"的例子(斐波那契)在 Python 里会指数级爆炸,必须配合记忆化:
>>> from functools import lru_cache
>>> @lru_cache(maxsize=None)
... def fib(n):
... return n if n < 2 else fib(n - 1) + fib(n - 2)
>>> fib(100) # 无缓存时这行会跑几万年
354224848179261915075实战:目录树遍历(递归的教科书场景):
>>> import os
>>> def walk(path, depth=0):
... """递归打印目录树"""
... for entry in sorted(os.scandir(path), key=lambda e: e.name):
... prefix = " " * depth + ("└ " if entry.is_dir() else "· ")
... print(prefix + entry.name)
... if entry.is_dir():
... walk(entry.path, depth + 1)递归每深入一层就创建一个新栈帧,理解这一点(结合 6.1.1 的帧模型),就明白为什么深目录用 os.walk(迭代式,不耗栈)更安全。
工程影响:递归深度 = 栈帧数量 × 每帧大小。解析深嵌套的 JSON/XML 或用递归处理深度超过 ~900 的数据结构时,优先考虑显式栈(用
list模拟)的迭代版本。
函数对象携带大量可检查的元数据——这是"肘后备急"场景下最常用的参考。
>>> def f(a, b=10, *args, c=20, **kwargs): ...
>>> f.__defaults__ # 位置参数的默认值(元组)
(10,)
>>> f.__kwdefaults__ # keyword-only 参数的默认值(字典)
{'c': 20}
>>> f.__annotations__ # 注解字典(如果标注了)
{}
>>> f.__code__ # 底层代码对象
<code object f at 0x..., file "<stdin>", line 1>
>>> f.__dict__ # 自定义属性字典——函数可以"挂"状态
{}常用函数属性一览:
| 属性 | 含义 | 场景 |
|---|---|---|
__name__ / __qualname__ |
名字 / 限定名 | 日志、调试、装饰器 |
__defaults__ |
位置参数默认值元组 | 检查默认参数、元编程 |
__kwdefaults__ |
keyword-only 默认值字典 | 同上 |
__annotations__ |
注解字典 | 类型检查工具 |
__code__ |
代码对象(字节码 + 常量 + 名字表) | 字节码分析 |
__dict__ |
自定义属性字典 | 函数挂载元数据 |
__closure__ |
cell 元组(闭包捕获的变量) | 检查闭包状态 |
inspect 模块——比裸属性更友好的运行时检查(衔接第 12 章元编程):
>>> import inspect
>>> inspect.signature(f) # 完整签名对象
<Signature (a, b=10, *args, c=20, **kwargs)>
>>> inspect.getfullargspec(f)
FullArgSpec(args=['a', 'b'], varargs='args', varkw='kwargs', defaults=(10,),
kwonlyargs=['c'], kwonlydefaults={'c': 20}, annotations={})
>>> inspect.isfunction(f) # 谓词:是否普通函数
True
>>> inspect.isgeneratorfunction(f) # 是否生成器函数
False
>>> inspect.getsource(f) # 还原源码(交互式里不可得)"函数挂属性"——极简状态容器:
>>> def calls():
... """统计被调用次数——状态挂在函数对象上"""
... calls.count += 1
... return calls.count
>>> calls.count = 0 # 函数对象也是对象,能挂属性
>>> calls(); calls(); calls()
3实战建议:
func.__name__在日志/装饰器/调度系统里是标配。用属性挂状态是"极简计数器",但跨调用状态优先用闭包(6.4.4)或可调用对象(6.1.2)——属性挂载是全局单例,多实例会互相踩;且它依赖外部"先初始化属性",易出错。
这一节回答两个问题:参数是怎么传进去的(语义)?有哪些声明方式(分类)?先讲语义,因为不理解"按对象传递",参数分类(尤其默认参数陷阱)就会显得莫名其妙。
Python 的传参模式是"按对象传递"(call-by-sharing),也叫"传对象引用"。它是值传递和引用传递的折中:
| 模式 | 机制 | 典型语言 |
|---|---|---|
| 传值(call-by-value) | 拷贝实参的值,函数内修改不影响调用方 | C、C++(默认)、Java 基本类型 |
| 传引用(call-by-reference) | 传实参的地址,函数内修改反映到调用方 | C++(引用参数)、C#(ref) |
| 按对象传递(call-by-sharing) | 传实参引用的对象,对象可变则修改可见,重新绑定则不可见 | Python、Java(对象)、JavaScript、Ruby |
用深浅拷贝专题的"变量 = 标签"模型理解:形参和实参是指向同一对象的两个标签。
>>> def f(lst):
... lst.append(1) # 通过形参修改了对象本身
>>> a = []
>>> f(a)
>>> a # 调用方看到了修改——对象是同一个
[1]
>>> def g(lst):
... lst = [100] # 重新绑定形参——只是让形参指向新对象
>>> b = []
>>> g(b)
>>> b # 调用方不受影响——b 仍指向原对象
[]判断规则一句话:形参修改对象的内容(.append、d[k]=v、x.attr=)→ 调用方可见;形参重新绑定(= 赋值给形参)→ 调用方不可见。 这与第 4 章讲的"就地修改 vs 重新绑定"完全一致。
为什么"不可变对象像值传递"? 因为不可变对象(int/str/tuple)无法被修改,函数内任何"修改"都是重新绑定(n = n + 1),自然不影响调用方——看起来就像值传递。但本质没变:传的还是同一个对象,只是不可变对象改不动。
>>> def inc(n):
... n += 1 # 重新绑定,不影响调用方
... return n
>>> x = 1
>>> inc(x)
2
>>> x
1
⚠️ 陷阱(可变默认参数与传参语义联动):lst.append(1)修改共享对象这一点,是大量隐蔽 bug 的来源。例如把列表当"累加器"传给多个调用:>>> def accumulate(data, result=[]): # ❌ 见 6.2.3,这里先看语义层面 ... result.extend(data) ... return result正确姿势是返回新对象或显式创建累加器。不可变参数永远安全,可变参数要格外小心"是否希望调用方看到修改"。
字节码视角——调用时实参如何传递:
>>> def f(a, b):
... return a + b
>>> dis.dis(f) # 函数体:形参从帧的局部变量数组读取
1 0 RESUME 0
2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE调用方 f(x, y) 编译为:LOAD_FAST x → LOAD_FAST y → CALL 2。CPython 把实参的引用依次压栈,CALL 2 取出 2 个引用建立新帧并把它们绑定为形参。传递的是引用,不是拷贝——这就是 call-by-sharing 的字节码证据。
跨语言对比:
- C:
int f(int x)拷贝;void f(int* x)传地址(可改)- Java:基本类型传值,对象传引用(与 Python 的可变对象一致)
- Python 独特之处:没有"C 指针"这种主动表达引用的能力,传参语义完全由对象可变性决定。这降低了心智负担,但要求开发者对"可变/不可变"敏感
Python 有两种传参/收参方式,各有一个"侧":
| 侧 | 位置参数 | 关键字参数 |
|---|---|---|
| 调用侧(传) | f(1, 2) |
f(a=1, b=2) |
| 定义侧(收) | def f(a, b) |
同上,名字就是关键字 |
>>> def describe(name, age, city):
... return f"{name}, {age}岁, 来自{city}"
>>> describe("小明", 25, "上海") # 全部按位置
'小明, 25岁, 来自上海'
>>> describe(name="小明", city="上海", age=25) # 全部按关键字——顺序无所谓
'小明, 25岁, 来自上海'
>>> describe("小明", city="上海", age=25) # 混合:位置在前,关键字在后
'小明, 25岁, 来自上海'调用规则:
- 位置参数必须在关键字参数之前(
f(1, a=2)合法,f(a=2, 1)语法错误) - 同一个参数不能既按位置又按关键字(重复赋值)
>>> f(1, a=2) # ❌ TypeError: got multiple values for argument 'a'
>>> f(a=2, 1) # ❌ SyntaxError: positional argument follows keyword argument设计哲学:关键字参数 = 自文档化调用。对比两种调用的可读性:
>>> sorted(nums, True) # ❌ True 是什么意思?
>>> sorted(nums, reverse=True) # ✅ 一目了然Python 明确鼓励对"非显然"的参数用关键字传参。标准库大量使用这个模式(open(file, mode='r', encoding='utf-8')),因为函数名已经说明"做什么",关键字参数说明"怎么做"。
实战建议:调用第三方函数时,除了第一个位置参数,其余尽量用关键字——既自文档化,又对"函数签名未来加参数"更健壮。
默认参数让函数在"未传该参数"时使用预置值:
>>> def greet(name, greeting="你好"):
... return f"{greeting}, {name}!"
>>> greet("小明")
'你好, 小明!'
>>> greet("小明", "早上好")
'早上好, 小明!'核心机制:默认值在 def 执行时求值一次,且只求值一次。 这是 Python 与多数语言最大的不同,也是无数 bug 的根源。
>>> import datetime
>>> def log(msg, when=datetime.datetime.now()): # ❌ when 在 def 时就固定了
... return f"[{when}] {msg}"
>>> log("第一条") # 时间戳 = def 时刻
>>> import time; time.sleep(2)
>>> log("第二条") # 时间戳没变!仍是 def 时刻可变默认参数陷阱(衔接深浅拷贝专题)——默认值 []/{}/set() 是同一个对象,被所有调用共享:
>>> def add_item(item, lst=[]): # ❌ 经典陷阱
... lst.append(item)
... return lst
>>> add_item("a")
['a']
>>> add_item("b") # 期望 ['b'],实际 ['a', 'b']!
['a', 'b']
>>> add_item.__defaults__ # 默认值是一个共享的列表对象
(['a', 'b'],)正确模式:用 None 哨兵 + 函数体内惰性创建:
>>> def add_item(item, lst=None): # ✅
... if lst is None:
... lst = []
... lst.append(item)
... return lst
>>> add_item("a")
['a']
>>> add_item("b") # 每次都是新列表
['b']
⚠️ 陷阱:为什么None哨兵安全而[]不安全?因为每次调用if lst is None: lst = []都执行——创建新列表;而def f(lst=[])只执行一次——共享旧列表。不可变默认值永远安全(0、""、None、tuple),可变默认值几乎总是 bug。
为什么 Python 不每次都重新求值默认值? 这是设计选择:默认值在定义期绑定,可以用 __defaults__ 检查,也让 CPython 把默认值存为函数对象的常量。Guido 承认这是"最大的设计失误之一"(2009 年 he talked about it),但因为要改就得让默认值在每次调用重建——语义大改,代价过高,于是保留并用"None 哨兵"作为惯用法。
性能小实验:
None哨兵版本每次调用多一个if lst is None判断,但换来的是正确性。永远用正确性换这一点点性能。对纯函数(无可变默认参数)来说,默认参数反而是优化——CPython 无需为缺省参数传值。
Python 3 引入两种"限定"语法,让函数作者强制调用方使用特定传参方式:
关键字限定(keyword-only)——* 分隔符(PEP 3102,Py3.0):
>>> def f(a, *, b): # b 只能用关键字传
... return a + b
>>> f(1, b=2)
3
>>> f(1, 2) # ❌ TypeError: f() takes 1 positional argument but 2 were given位置限定(positional-only)——/ 分隔符(PEP 570,Py3.8):
>>> def f(a, /, b): # a 只能用位置传
... return a + b
>>> f(1, 2)
3
>>> f(1, b=2)
3
>>> f(a=1, b=2) # ❌ TypeError: f() got some positional-only arguments passed as keyword arguments: 'a'两者可以组合,形成完整的签名语法(/ 在 * 之前):
>>> def f(a, /, b, *, c): # a 位置限定;c 关键字限定;b 都行
... ...标准库为什么用 /? 看 pow 的签名:
>>> help(pow)
pow(x, y, z=None, /)/ 让 CPython 保留"用 C 实现、参数是真正的位置"的自由——不承诺参数名,允许未来改名而不破坏调用方。这是 API 设计原则:参数名一旦被关键字调用使用,就成了公共 API 的一部分,改名会破坏代码。/ 帮你解除这个承诺。
自定义 API 何时用哪种?
| 场景 | 用 *(keyword-only) |
用 /(positional-only) |
|---|---|---|
| 参数含义不显然、需名字说明 | def find(items, *, case_sensitive=False) |
|
| 未来可能改名的内部实现参数 | def _internal(a, /) |
|
| 保持 C 扩展/旧实现的灵活性 | 标准库大量用 | |
| 禁止调用方依赖顺序 | 让所有可选参数 keyword-only |
实战建议:新 API 的默认惯例——核心数据参数用位置,选项/标志用 keyword-only(
*)。这既自文档化,又让未来扩展选项而不破坏调用。参考sorted(iterable, *, key=None, reverse=False)的签名设计。
调用 f(a, b, c=3) 时,CPython 的字节码做了三件事:求值实参、打包成调用、绑定形参。
三种调用字节码(版本注意:Python 3.12 起统一为 CALL,3.13 后关键字调用是 CALL_KW):
>>> def f(a, b=0, *, c=1): ...
>>> dis.dis("f(1, 2, c=3)")
0 0 RESUME 0
2 LOAD_NAME 0 (f)
4 LOAD_CONST 0 (1)
6 LOAD_CONST 1 (2)
8 LOAD_CONST 2 ('c')
10 LOAD_CONST 3 (3)
12 CALL_KW 3 # 2 位置 + 1 关键字,共 3 个实参
20 RETURN_VALUECALL_KW 3:操作数 3 = 实参总数,关键字名字('c')从栈顶弹出并按顺序配对。
*args/**kwargs 的组装代价:
>>> def f(*args, **kwargs): ...
>>> dis.dis("f(1, 2, x=3)")
...
10 CALL_KW 2 # 普通调用:2 个实参
...而解包调用 f(*lst) 编译为 CALL_FUNCTION_EX——运行时展开:
>>> def f(a, b, c): return a + b + c
>>> dis.dis("f(*[1, 2, 3])") # Python 3.12 实测
0 0 RESUME 0
1 2 PUSH_NULL
4 LOAD_NAME 0 (f)
6 BUILD_LIST 0
8 LOAD_CONST 0 ((1, 2, 3))
10 LIST_EXTEND 1
12 CALL_FUNCTION_EX 0 # 把 [1,2,3] 展开为 3 个实参
14 RETURN_VALUE实际调用 f(*lst) 的指令 CALL_FUNCTION_EX(3.11–3.14 同名;3.14 起不再带操作数)把序列逐个拆开填入形参——这个展开发生在 C 层,且会先创建临时元组,这就是它比普通调用稍慢的原因之一。
性能:函数调用本身有开销(建帧 + 形参绑定)。高频循环里:
- 直接位置传参最快
- 关键字传参稍慢(要构造名字对)
*args/**kwargs解包调用最慢(运行时拆装元组/字典)看一个量级参考:
>>> from timeit import timeit >>> def f(a, b=0, c=0): return a + b + c >>> def kw(a, b=0, c=0): return a + b + c >>> timeit("f(1, 2, 3)", globals=locals()) # 位置 0.041 >>> timeit("f(1, 2, c=3)", globals=locals()) # 关键字 0.053 >>> timeit("f(*[1,2,3])", globals=locals()) # 解包 0.078差值在纳秒级,除极端热路径外不值得为此牺牲可读性。库作者倾向位置参数(
numpy的np.zeros(shape, dtype=...))正是为了让热路径调用更快、签名更短。
版本注意:调用指令在不同 CPython 版本名字不同——3.11 用
CALL_FUNCTION/CALL_METHOD/CALL_FUNCTION_KW/CALL_FUNCTION_EX;3.12 统一为CALL(关键字参数改用KW_NAMES指令提供名字表);3.13 起关键字调用改回CALL_KW;解包调用一直是CALL_FUNCTION_EX(3.14 起简化掉操作数,没有叫CALL_EX的指令)。本书示例按 3.14 风格标注,读者在自己版本上看到的指令名可能不同,语义一致。
* 和 ** 是 Python 中"上下文相关"的运算符——同一个符号在不同位置做相反的事情。理解它们的关键是硬币模型:
- 解包(unpack):把一个集合拆开成多个独立的值
- 封包(pack):把多个独立的值收拢成一个集合
| 位置 | * 做什么 |
示例 |
|---|---|---|
| 赋值左侧(第4章已讲) | 解包:收集剩余元素 | a, *rest = lst |
函数调用 f(*seq) |
解包:拆开为位置参数 | f(*[1, 2, 3]) |
函数定义 def f(*args) |
封包:收拢为元组 | def f(*args) |
字面量 [*a](PEP 448) |
展开:合并进新容器 | [1, *a, 2] |
第 4 章讲了赋值侧解包。本章把它扩展到函数的两侧,并补全 PEP 448。
回顾赋值侧(详见第 4 章 4.9 节):a, *rest = lst 把首元素给 a,其余收进 rest。
>>> a, *rest = [1, 2, 3, 4]
>>> a, rest
(1, [2, 3, 4])调用侧解包——f(*seq):把序列/迭代器的元素逐个展开为位置参数:
>>> def add(a, b, c):
... return a + b + c
>>> add(*[1, 2, 3]) # 等价于 add(1, 2, 3)
6
>>> nums = (10, 20)
>>> add(0, *nums) # 可以混用:位置参数 + 解包
30f(**mapping):把字典展开为关键字参数(键必须是字符串):
>>> def describe(name, age):
... return f"{name} {age}岁"
>>> person = {"name": "小明", "age": 25}
>>> describe(**person) # 等价于 describe(name="小明", age=25)
'小明 25岁'
>>> describe(**{"name": "小明", "wrong": 1}) # ❌ 键必须是形参名
TypeError: describe() got an unexpected keyword argument 'wrong'多返回值天然配合解包——函数返回元组,调用侧一行拆开:
>>> def min_max(nums):
... return min(nums), max(nums)
>>> lo, hi = min_max([3, 1, 4, 1, 5])
>>> lo, hi
(1, 5)实战建议:
*/**解包是"变量参数接口"的标准桥接。例如把从数据库读出的记录(字典)直接喂给构造函数:User(**row)。ORM 和配置系统大量使用。
PEP 448(Py3.5):字面量里的展开。解包不再局限于函数调用和赋值,*/** 可以直接用在容器字面量里:
>>> [1, *[2, 3], 4] # 列表展开
[1, 2, 3, 4]
>>> {*[1, 2], *[2, 3]} # 集合展开(自动去重)
{1, 2, 3}
>>> {'x': 1, **{'y': 2}} # 字典展开——后出现的键覆盖先前的
{'x': 1, 'y': 2}
>>> {'x': 1, **{'x': 9}} # 后者覆盖
{'x': 9}
>>> (*[1, 2], 3) # 元组展开
(1, 2, 3)
>>> print(*[1, 2, 3]) # 最常用的:print 的多参数
1 2 3PEP 448 之前,合并列表要 a + b(只支持两个),合并字典要循环 update。现在 [*a, *b]/{**a, **b} 是合并容器的最简洁写法。
⚠️ 陷阱:*解包会"消耗"迭代器。解包的对象是迭代器(如生成器)时,解包会把它取空:>>> it = iter([1, 2, 3]) >>> first = next(it) >>> [*it] # 剩下 [2, 3] [2, 3] >>> [*it] # 已耗尽 []生成器只能遍历一次(详见 6.6)。解包时请确认它是"可重复的序列"还是"一次性迭代器"。
定义侧,*args 把任意数量的位置参数收拢为元组,**kwargs 把任意数量的关键字参数收拢为字典:
>>> def show(*args, **kwargs):
... print("args:", args)
... print("kwargs:", kwargs)
>>> show(1, 2, 3, name="小明", age=25)
args: (1, 2, 3)
kwargs: {'name': '小明', 'age': 25}名字只是约定。关键符号是 * 和 **,名字通常用 args/kwargs(业界惯例),但可以任意:
>>> def total(*nums):
... return sum(nums)
>>> total(1, 2, 3, 4)
10封包与解包是逆操作——def f(*args) 与 f(*lst) 互为对方:
>>> def f(*args): return args # 封包:多个值 → 元组
>>> f(1, 2, 3) # (1, 2, 3)
(1, 2, 3)
>>> f(*[1, 2, 3]) # 解包:列表 → 多个值 → 再封包
(1, 2, 3)*args 与显式参数混用:* 前的参数正常绑定,多余的全收进 args;**kwargs 必须放最后:
>>> def config(host, port=80, *paths, **options):
... print(host, port, paths, options)
>>> config("localhost", 8080, "/a", "/b", timeout=5)
localhost 8080 ('/a', '/b') {'timeout': 5}注意:
**kwargs只能出现一次且必须在所有参数之后;*args出现在位置参数之后、关键字限定参数之前(衔接 6.2.4 的*分隔符——def f(*args, key)里key是 keyword-only)。
通用转发(forwarding)——*args, **kwargs 是"原样转交所有参数"的惯用法,也是装饰器的地基(衔接第 12 章):
>>> def logged(func):
... def wrapper(*args, **kwargs):
... print(f"调用 {func.__name__}({args}, {kwargs})")
... return func(*args, **kwargs) # 原样转交
... return wrapper
>>> @logged
... def add(a, b): return a + b
>>> add(1, 2)
调用 add((1, 2), {})
3wrapper(*args, **kwargs) 不关心被包装函数有几个参数、什么名字——全部收拢再全部展开,参数被透传。这是"通用接口适配器"的通用机制(functools.wraps 会让 wrapper 保留原函数的元信息,详见第 12 章)。
⚠️ 陷阱 1:**kwargs的键与显式参数冲突。如果被转交的函数某个参数与 wrapper 的显式参数同名,调用时可能重复赋值:>>> def wrapper(a, **kwargs): ... return f(a=a, **kwargs) # ❌ 若 kwargs 里有 'a',f 收到两个 a >>> wrapper(1, a=2) TypeError: f() got multiple values for argument 'a'
⚠️ 陷阱 2:只转发*args不转发**kwargs(或反之),会把带关键字参数的调用方静默破坏。透传必须成对。
参数组合全景——一个函数定义可以同时用上所有形态:
>>> def complex_api(a, /, b, *args, c=10, d=20, **kwargs):
... """位置限定 a;普通 b;多余位置 → args;keyword-only c,d;多余关键字 → kwargs"""
... return a, b, args, c, d, kwargs
>>> complex_api(1, 2, 3, 4, c=30, e=5)
(1, 2, (3, 4), 30, 20, {'e': 5})完整参数顺序(从左到右):positional-only / → 普通位置 → *args → keyword-only → **kwargs。
实战:可变参数 API 的签名设计。标准库到处是 *args/**kwargs 的设计:
>>> print(*objects, sep=' ', end='\n') # 任意多个打印对象
>>> "{} {}!".format("Hello", "World") # 格式化模板的任意参数
>>> max(3, 1, 4, 1, 5) # 任意多个比较对象反模式 1:滥用 **kwargs 掩盖签名。def f(**kwargs) 让调用方无法用 help/编辑器看到参数名——违背"显式优于隐式":
>>> def connect(**kwargs): # ❌ 反模式——kwargs 里到底要什么?
... return kwargs.get("host", "localhost"), kwargs.get("port", 5432)
>>> # ✅ 显式写出参数,同时用 **kwargs 兜底未知选项:
>>> def connect(host="localhost", port=5432, **kwargs): ...反模式 2:解包使用过度。[*a] 会复制列表(新建),而 a 是引用。在需要原对象时用 * 解包是隐蔽的浅拷贝(衔接深浅拷贝专题):
>>> a = [1, 2, 3]
>>> b = a # 引用——b is a
>>> c = [*a] # 新列表——c is not a
>>> c is a
False工程影响:
[*a]/list(a)/a[:]/a.copy()都是浅拷贝,各有语境偏好。[*a]在"合并进新列表"时最自然,单独复制时a.copy()意图更明确。
把 6.2 的参数分类与 6.3 的封包解包组合起来,设计一个生产级签名的示例。目标:一个"筛选 + 排序 + 截断 + 自定义转换"的迷你数据处理函数。
def process_rows(rows, /, *, filters=(), sort_key=None, reverse=False,
limit=None, transform=None, **options):
"""处理记录列表。
参数设计:
- rows 位置限定(核心数据,未来可改实现细节)
- filters 接收可调用对象元组,逐条过滤
- sort_key/reverse/limit/transform 都是 keyword-only(自文档化)
- **options 兜底扩展,不阻塞未来加参数
"""
result = [r for r in rows if all(f(r) for f in filters)]
if sort_key is not None or reverse:
result = sorted(result, key=sort_key, reverse=reverse)
if transform is not None:
result = [transform(r) for r in result]
if limit is not None:
result = result[:limit]
return result调用侧——可读性与扩展性同时成立:
>>> from operator import itemgetter
>>> data = [{"name": "小明", "score": 90, "age": 25},
... {"name": "小红", "score": 85, "age": 23},
... {"name": "小刚", "score": 95, "age": 26}]
>>> process_rows(data, filters=(lambda r: r["age"] >= 24,), sort_key=itemgetter("score"),
... reverse=True, limit=2, transform=itemgetter("name"))
['小刚', '小明']这个签名示范了 6.2/6.3 的全部知识点:
/位置限定rows——核心数据与"选项"分离,未来可改内部实现(6.2.4)*之后全部 keyword-only——filters/sort_key/reverse/limit的含义靠名字说清(6.2.4)- 默认值用不可变
()/None——无共享状态陷阱(6.2.3) **options兜底——未来加参数不破坏调用方,但别让签名变黑洞(6.3.4 反模式)filters收"函数元组"——高阶函数思想的封装(6.5)
工程影响:这就是标准库/主流库(
sorted、filter、pandas.DataFrame.query)的签名哲学——核心数据走位置,选项走关键字,未来走**。读懂这个模式,就能读懂大多数库的 API 设计。
函数体里出现一个名字时,Python 去哪找它?这一节回答名字解析(LEGB)与闭包捕获(cell)两个底层问题。
命名空间是"名字 → 对象"的映射。Python 有四种,按查找顺序排列:
| 层 | 名称 | 内容 | 何时建立 |
|---|---|---|---|
| L | Local | 当前函数/代码块的局部变量 | 每次调用函数时 |
| E | Enclosing | 外层(嵌套)函数的局部变量 | 定义嵌套函数时 |
| G | Global | 当前模块的全局变量 | 模块加载时 |
| B | Builtin | builtins 模块 |
解释器启动时 |
名字查找从内向外:L → E → G → B,找到就停。
>>> import builtins
>>> len # Builtin 层
<built-in function len>
>>> x = 10 # Global 层
>>> def f():
... y = 20 # Local 层(f 的局部)
... print(x, y) # x → Global,y → Local
>>> f()
10 20
>>> dir(builtins) # 内置命名空间
['ArithmeticError', ..., 'len', 'list', 'print', ...]遮蔽(shadowing):局部变量与外层同名时,局部优先:
>>> x = "global"
>>> def f():
... x = "local" # 遮蔽全局 x
... print(x)
>>> f()
local
>>> x # 全局不受影响
'global'设计哲学:为什么全局访问比局部慢? 局部变量存于栈帧的数组里,
LOAD_FAST按索引 O(1) 取;全局/内置要查字典(哈希查找)。CPython 对内置层还做了逐级退避查找。实测同操作全局约慢 1.5–2 倍。热循环里把全局对象先绑定到局部是经典优化(见下)。
>>> import math
>>> def with_global(n):
... return math.sqrt(n) # 每次查 Global→Builtin 字典
>>> def with_local(n):
... m = math # 绑定到局部,一次查找
... return m.sqrt(n)
>>> from timeit import timeit
>>> timeit("with_global(100)", globals=locals())
0.078
>>> timeit("with_local(100)", globals=locals())
0.063实战建议:循环里反复调用
len/max/math.sqrt等时,先local = len再循环,能稳定提速(第 5 章 5.2.5 的"缓存方法引用"同款思路)。量级微小,热路径值得。
变量的"归属"在编译期就决定了,不是运行时。编译器扫描整个函数体,发现 x = ... 就把 x 标记为局部——即使这个赋值在函数末尾。
字节码对照:看三种名字分别编译成什么指令。
>>> x = 10 # 模块级
>>> def read_local():
... y = 20
... return y
>>> def read_global():
... return x
>>> def read_builtin():
... return len
>>> dis.dis(read_local)
1 0 RESUME 0
2 2 LOAD_CONST 1 (20)
4 STORE_FAST 0 (y) # 局部 → STORE_FAST
3 6 LOAD_FAST 0 (y) # 局部 → LOAD_FAST(数组索引)
10 RETURN_VALUE
>>> dis.dis(read_global)
1 0 RESUME 0
2 LOAD_GLOBAL 0 (x) # 全局 → LOAD_GLOBAL(字典查找)
10 RETURN_VALUE
>>> dis.dis(read_builtin)
1 0 RESUME 0
2 LOAD_GLOBAL 0 (len) # 内置也走 LOAD_GLOBAL,查不到再退到 builtins
10 RETURN_VALUE三个经典谜题,字节码一解释就通:
谜题 1:为什么这个报错?(赋值在后面的 print 却找不到名字)
>>> x = 10
>>> def f():
... print(x) # 编译期认为 x 是局部(因为下一行有赋值)
... x = 20
>>> f()
UnboundLocalError: local variable 'x' referenced before assignment因为编译器把 x 标记为局部 → print(x) 编译为 LOAD_FAST x,而局部 x 还没赋值 → 报"引用未赋值"。在函数内赋值过的名字,整个函数体都把它当局部。
谜题 2:def 里的默认参数为什么能读到外部? 默认值在 def 执行时求值(6.2.3),当时处于模块作用域,所以 def f(x=default_var) 能读到全局 default_var——但这是"求值",不是"运行时查找"。默认值是一次性快照。
谜题 3:嵌套函数读外层变量为什么是 LOAD_DEREF? 见 6.4.4 闭包的字节码。
版本注意:Python 3.11 起
LOAD_GLOBAL附带CACHE指令(内联缓存),同一名字第二次查找显著变快;3.13 的"内联缓存"进一步加速。这也是"局部 vs 全局"差距在逐步缩小的原因之一,但原理不变。
赋值默认创建局部变量——在函数内 x = 1 不修改全局 x,而是新建局部。要想修改外层/全局,需要声明:
global 声明:
>>> counter = 0
>>> def inc():
... global counter # 声明:我要修改全局 counter
... counter += 1
>>> inc(); inc()
>>> counter
2去掉 global 会怎样?
>>> def inc():
... counter += 1 # ❌ UnboundLocalError(见 6.4.2 谜题 1)nonlocal 声明(PEP 3104,Py3):修改外层函数的变量(闭包里)用 nonlocal,而不是 global:
>>> def make_counter():
... count = 0
... def inc():
... nonlocal count # 修改外层 make_counter 的 count
... count += 1
... return count
... return inc
>>> c = make_counter()
>>> c(), c(), c()
(1, 2, 3)为什么需要 nonlocal? 因为"函数内赋值 = 局部变量"的规则对嵌套函数同样成立——如果不声明,inc 里的 count += 1 会让编译器把 count 当成 inc 的局部(谜题 1 重现):
>>> def make_counter():
... count = 0
... def inc():
... count += 1 # ❌ 没声明 nonlocal → UnboundLocalError
... return count
... return inc
>>> make_counter()()
UnboundLocalError: local variable 'count' referenced before assignment设计哲学:
global/nonlocal是"显式优于隐式"的体现——修改外部作用域是有副作用的操作,Python 要求你显式声明。对比 JavaScript 隐式提升全局,Python 的声明让"谁改了什么"在代码里可见。但global/nonlocal仍是应少用的机制:函数与其外部环境耦合越深,越难测试(详见 6.4.5 的替代方案)。
只读闭包不需要 nonlocal:只读取外层变量不需要声明,只有赋值(含 +=、.append 之后的重新绑定)才需要:
>>> def make_adder(n):
... def add(x):
... return x + n # 只读 n,无需 nonlocal
... return add
>>> add5 = make_adder(5)
>>> add5(3)
8
⚠️ 陷阱:lst.append(...)这类方法调用不是赋值,不需要nonlocal(它改的是对象内容,不是重新绑定名字)。而count += 1、lst = [...]是重新绑定,需要声明。判断标准:等号左边是不是这个名字。
闭包(closure)= 函数 + 它捕获的外部变量。当一个内层函数引用了外层函数的变量,Python 会把这个变量存进一个 cell 对象,让内层函数在外层函数返回后仍能访问它。
>>> def outer():
... msg = "Hello"
... def inner():
... return msg # 捕获 msg
... return inner
>>> f = outer()
>>> outer() 已经返回,但:
>>> f() # 仍能访问 msg!
'Hello'底层:cell 对象与字节码:
>>> dis.dis(outer)
1 0 RESUME 0
2 2 LOAD_CONST 1 ('Hello')
4 STORE_DEREF 0 (msg) # 存进 cell,而非普通局部
3 6 LOAD_CONST 2 (<code object inner...>)
8 MAKE_FUNCTION 8 # 带闭包标志
10 STORE_FAST 0 (inner)
4 12 LOAD_FAST 0 (inner)
14 RETURN_VALUE
>>> dis.dis(f)
1 0 RESUME 0
2 2 LOAD_DEREF 0 (msg) # 从 cell 读取
4 RETURN_VALUE外层用 STORE_DEREF(存进 cell)、内层用 LOAD_DEREF(从 cell 读)。MAKE_FUNCTION 8 的操作数 8 = 闭包标志,告诉解释器"这个函数带闭包"。可以检查:
>>> f.__closure__ # cell 对象元组
(<cell at 0x...: str object at 0x...>,)
>>> f.__closure__[0].cell_contents # 取出 cell 里的值
'Hello'工程影响——闭包与内存:cell 让外层变量生命周期延长到闭包被回收为止。每次调用
make_counter()都新建一组 cell(状态独立)。需要跨调用保存状态时,闭包是"无类版状态对象";状态复杂时优先可调用对象(6.1.2)。
工厂函数模式——按参数定制函数(高阶函数 + 闭包的典型组合):
>>> def make_multiplier(factor):
... def multiply(x):
... return x * factor
... return multiply
>>> double = make_multiplier(2)
>>> triple = make_multiplier(3)
>>> double(10), triple(10)
(20, 30)装饰器示例:闭包的直接应用。 装饰器本质是"把函数当参数的工厂函数"——它接收一个函数,返回一个包装后的函数。@timed 只是把 slow = timed(slow) 这行赋值"提前写好"的语法糖:
import time
def timed(func):
"""装饰器:打印被装饰函数的执行耗时"""
def wrapper(*args, **kwargs): # 闭包:捕获 func
start = time.perf_counter()
result = func(*args, **kwargs) # 原样转发(6.3.3 参数透传)
print(f"{func.__name__} 耗时 {time.perf_counter() - start:.6f}s")
return result
return wrapper>>> @timed # 语法糖:等价于 slow = timed(slow)
... def slow():
... time.sleep(0.1)
... return "done"
>>> slow() # 调用的是包装后的 slow
slow 耗时 0.100209s # 耗时数值随机器而变
'done'timed 完全复用了本节刚学的三样东西:闭包(wrapper 捕获 func)、高阶函数(把函数当参数/返回值)、参数透传(6.3.3 的 *args, **kwargs)——没有引入任何新概念,@ 只是换了个写法。
衔接第 12 章:这里只到"会写会用"。装饰器的完整体系——
functools.wraps保留元信息(不加它slow.__name__会变成wrapper)、带参装饰器、类装饰器、栈叠顺序、与描述符/元类的联系——在第 12 章元编程展开。届时你会发现@property、@classmethod、@dataclass全是这套机制的实例。
late binding 陷阱(晚期绑定)——循环里创建的 lambda 全部捕获同一个循环变量(不是各自的值):
>>> funcs = [lambda: i for i in range(3)]
>>> [f() for f in funcs] # ❌ 期望 [0, 1, 2]
[2, 2, 2]因为 lambda 捕获的是 cell i,循环结束后 i 停在 2,所有 lambda 读到同一个 2。修复:默认参数快照(6.2.3 说默认值在 def 时求值——这里派上用场):
>>> funcs = [lambda i=i: i for i in range(3)] # ✅ 默认参数捕获当前值
>>> [f() for f in funcs]
[0, 1, 2]i=i 在 lambda 创建时把当前 i 的值求值并绑定为默认参数——每个 lambda 拿到了独立快照。
⚠️ 陷阱:lambda: i与lambda i=i: i的区别,正是"捕获变量 vs 捕获值"的区别。任何"在循环里创建函数"的代码(回调、sorted(key=...)、线程)都可能踩中晚期绑定。第 5 章 5.2.6 的 for-else 陷阱同源。
跨语言对比:
| 语言 | 闭包捕获 | 晚期绑定修复 |
|---|---|---|
| Python | 捕获 cell(变量),函数体内赋值需 nonlocal |
默认参数快照 |
| JavaScript | 捕获变量;var 函数级作用域更易踩中;let 每次迭代新绑定 |
let 天然修复 |
| Rust | Fn/FnMut/FnOnce 按捕获方式分 trait |
借用/所有权检查 |
| C++ | lambda 捕获列表 [x](值)/[&x](引用) |
按值捕获 |
Python 的"晚绑定 + 默认参数快照"修复,本质是把 JS 社区熟悉的 let 语义手动做出来。
globals() 返回模块级命名空间字典,locals() 返回当前局部命名空间:
>>> x = 1
>>> globals()['x']
1
>>> globals()['y'] = 2 # 可以写!等价于 y = 2
>>> y
2
>>> def f():
... z = 3
... return locals() # 只读快照
>>> f()
{'z': 3}
⚠️ 陷阱:locals()的返回值是只读快照——写入它不生效(CPython 编译期已把局部变量固化为数组,运行时改字典无用):>>> def f(): ... a = 1 ... locals()['a'] = 99 # ❌ 无效 ... return a >>> f() 1
反模式:动态创建变量名。用 exec/setattr/globals()[name] 动态生成 var1/var2... 是反模式——名字不在源代码里,无法静态分析、易冲突、难调试:
>>> for i in range(5):
... globals()[f"v{i}"] = i # ❌ 反模式:动态命名空间
>>> v3
3
>>> # ✅ 正确:用容器
>>> data = {f"v{i}": i for i in range(5)}实战建议:需要"按名字存取变量"时,用字典(
data[key])而非操纵命名空间。需要把数据转为属性时用类实例或dataclasses(第 7 章)。命名空间操作只留给元编程/调试器这类场景(第 12 章)。
函数既能被"调用",也能被"传递"。lambda 让你在需要函数的地方直接写函数;高阶函数让函数成为其他函数的参数或返回值。两者合起来,就是 Python 函数式编程的核心。
lambda 是匿名函数的语法糖——一个只能包含单个表达式的迷你函数:
>>> add = lambda a, b: a + b # 相当于 def add(a, b): return a + b
>>> add(1, 2)
3
>>> (lambda x: x * 2)(5) # 就地定义、就地调用(很少这么用)
10语法约束:
| 能做 | 不能做 |
|---|---|
单个表达式(x + 1、x[1]、x.y) |
语句:if/for/while/return/assert |
| 调用函数、运算符表达式 | 多行代码、赋值表达式以外的一切语句 |
默认参数、*args/**kwargs |
docstring |
>>> lambda x: if x: return 1 # ❌ SyntaxError
>>> lambda x: 1 if x else 0 # ✅ 用三元表达式代替 if设计哲学:为什么 Guido 把 lambda 限制为单表达式? 这是 1990 年代讨论的延续。支持语句的 lambda 会引入两套可读性问题(缩进?多行?),而 Python 有完整的语句级工具(
def、推导式、if)。Guido 的观点:能用一个表达式表达的逻辑才值得匿名;需要语句说明的逻辑应该命名。这符合"可读性优先"——lambda 是简洁的工具,不是替代def的完整方案。
选型矩阵:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 一次性的简单转换/比较 | lambda |
就地、无需取名 |
| 逻辑超过一行 | def |
可读性、可测试、有名字 |
| 需要跨调用保存状态 | 可调用对象(__call__)或闭包 |
lambda 无状态 |
| 需要 docstring/类型注解 | def |
lambda 没有 docstring |
| 调试时需要定位 | def |
lambda 的 __name__ 是 <lambda> |
>>> f = lambda x: x + 1
>>> f.__name__ # ❌ 调试困难:所有 lambda 都叫这个名字
'<lambda>'
>>> import inspect
>>> inspect.getsourcefile(f)
None # lambda 没有源文件位置信息
⚠️ 陷阱:lambda 的作用域与其他函数一样——不创建新的作用域块,但会创建新的名字空间。lambda 里的x遵循 LEGB(6.4)。循环中创建 lambda 会踩中 6.4.4 的晚期绑定陷阱:[lambda: i for i in range(3)]得到三个都返回 2 的函数。别嵌套 lambda(lambda x: lambda y: ...可读性崩坏)。
高阶函数(higher-order function)= 接受函数作为参数、或返回函数的函数。标准库的核心高阶函数:
key 参数——sorted/min/max:不直接比较元素,而是先经 key 函数变换再比较:
>>> words = ["apple", "Banana", "cherry", "Date"]
>>> sorted(words) # 默认按字典序(大写在前)
['Banana', 'Date', 'apple', 'cherry']
>>> sorted(words, key=str.lower) # 忽略大小写
['apple', 'Banana', 'cherry', 'Date']
>>> sorted(words, key=len) # 按长度
['Date', 'apple', 'Banana', 'cherry']
>>> max(words, key=len) # min/max 同样支持 key
'cherry'key 的设计哲学:Python 3 用 key 取代了 Python 2 的 cmp 比较函数参数。原因(PEP 8 的推荐):
- key 函数只调用 n 次(每个元素一次),而 cmp 要调用 O(n log n) 次——
sorted用了 DSU(Decorate-Sort-Undecorate) 技巧,先变换、再排序、再还原 - key 更容易写(
str.lower一行 vs 手写比较函数) - key 是"纯数据转换",cmp 是"比较逻辑",前者更不易错
>>> # DSU 是 sorted(key=) 的底层:Decorate → Sort → Undecorate
>>> [(str.lower(w), w) for w in words] # decorate
>>> # 排序后取 [1] 元素即还原性能:
key的 n 次调用可以缓存(如functools.cache),而cmp每次比较都重新调用——大数据量下key的优势是数量级的。
map/filter/reduce——经典函数式三件套:
>>> list(map(lambda x: x * 2, range(5))) # 映射:[0, 2, 4, 6, 8]
[0, 2, 4, 6, 8]
>>> list(filter(lambda x: x % 2 == 0, range(10))) # 过滤:[0, 2, 4, 6, 8]
[0, 2, 4, 6, 8]
>>> from functools import reduce
>>> reduce(lambda a, b: a + b, range(1, 6)) # 归约:15(1+2+3+4+5)
15map 支持多可迭代对象(并行映射)——这比 zip + 列表推导式更直接:
>>> list(map(lambda a, b: a + b, [1, 2, 3], [10, 20, 30]))
[11, 22, 33]
>>> list(map(pow, [2, 3, 4], [5, 2, 1])) # pow 直接可用
[32, 9, 4]关键点:map/filter 返回惰性迭代器,必须 list() 才产生列表。这也是它们"省内存"的原因。
设计哲学:为什么社区"推导式优先于 map/filter"? PEP 202/279 时代 Python 社区达成的共识——推导式更可读:
>>> [x * 2 for x in range(5)] # ✅ 比 map 直观 >>> [x for x in range(10) if x % 2 == 0] # ✅ 比 filter 直观推导式把"过滤"和"映射"写在同一个表达式里,而
map+filter需要嵌套。map的独特价值只剩两个:多可迭代对象并行、以及把内置函数(如pow)直接当作映射器。在能选推导式时,推导式是默认答案(衔接第 5 章推导式性能分析——推导式还有专用字节码)。
functools 工具箱:
>>> from functools import partial, reduce, cmp_to_key
>>> def power(base, exp): return base ** exp
>>> square = partial(power, exp=2) # 部分应用:固定 exp=2
>>> square(5)
25
>>> def by_len_cmp(a, b): # 旧式比较函数
... return len(a) - len(b)
>>> sorted(words, key=cmp_to_key(by_len_cmp)) # 适配为新 key 体系
['Date', 'apple', 'Banana', 'cherry']partial 部分应用 = 提前固定部分参数,生成"参数更少"的新函数。经典用途:给回调绑定上下文、把多参函数适配成单参接口(map 需要一个单参函数,partial(power, exp=2) 正好)。
跨语言对比:
- JavaScript:
Array.prototype.map/filter/reduce是方法(附着在数组上),不是独立函数——写法arr.map(f)- Rust:
iter().map().filter().fold()链式迭代器,类型系统保证不变量- Python:独立函数
map(f, it),与itertools模块组合成管道。Python 的 map/filter 是"函数",推导式是"语法"——两条路都给你
纯函数 vs 副作用。纯函数 = 输出只由输入决定、无外部副作用(不修改全局、不写文件、不依赖时间/随机)。纯函数的好处:可测、可缓存、可并发。
>>> # 纯函数:同样的输入 → 同样的输出
>>> def pure(x): return x * 2
>>> # 不纯函数:依赖/修改外部状态
>>> total = 0
>>> def impure(x):
... global total
... total += x # 副作用:改了全局
... return total工程影响:副作用是 bug 温床——难测试(要构造环境)、难并行(共享状态竞争)、难缓存(结果不可复用)。函数式风格 = 尽量写纯函数,把副作用收敛到边界(I/O 层)。这与第 8 章异常、第 11 章并发的设计思想一致。
管道模式(pipeline)——把数据处理写成"一串变换",每个环节是纯函数:
>>> from functools import reduce
>>> def pipe(*funcs):
... """函数组合:pipe(f, g)(x) == g(f(x))"""
... def run(x):
... return reduce(lambda v, f: f(v), funcs, x)
... return run
>>> process = pipe(
... lambda s: s.strip().lower(),
... lambda s: s.replace(" ", "_"),
... len,
... )
>>> process(" Hello World ")
12管道模式的 Python 惯用法常直接用生成器/推导式串联(见 6.6.4),或者用第三方库(toolz)的 pipe。核心思想一致:数据流经一串纯变换,每个环节独立可测。
性能取舍:推导式 / 生成器 / 显式循环何时用哪个?
| 需求 | 选型 | 理由 |
|---|---|---|
| 结果需要全部在内存、逻辑简单 | 列表推导式 | 最快(专用字节码)、最可读 |
| 数据量大、逐元素处理 | 生成器表达式/生成器函数 | 惰性,省内存 |
| 结果需要多次迭代/索引 | 列表 | 生成器单次消费 |
| 逻辑复杂(多分支、副作用) | 显式 for |
推导式/生成器不可读 |
需要提前 break |
显式 for 或 itertools.takewhile |
推导式无法中途退出 |
>>> # 中等规模:推导式最简洁
>>> [x * x for x in range(1000) if x % 2]
>>> # 大规模流式:生成器表达式
>>> sum(x * x for x in range(10**8)) # 不建中间列表实战建议:规则一句话——逻辑一个表达式能写完用推导式,写不完用循环;数据大到装不下内存用生成器。别为了"函数式"而函数式:
reduce在 Python 里常被sum/max/推导式替代,多数map/filter用推导式更好。
实战案例:纯函数配置验证管道——用一个完整例子演示 纯函数 + partial + 管道组合。
>>> from functools import partial
>>> def require(config, key, default=None):
... """确保 key 存在,缺省补默认值——返回新字典,不修改输入(纯函数)"""
... out = dict(config)
... out.setdefault(key, default)
... return out
>>> def coerce(config, key, fn):
... out = dict(config)
... if key in out:
... out[key] = fn(out[key]) # 类型转换
... return out
>>> def validate(config, rules):
... """按规则表逐条处理配置——纯函数管道"""
... for rule in rules:
... config = rule(config) # 每个规则是 (dict) -> dict
... return config规则表 + 管道执行:
>>> # 用 partial 预绑定规则参数,让每个规则成为单参纯函数
>>> rules = [
... partial(require, key="host", default="localhost"),
... partial(require, key="port", default=5432),
... partial(coerce, key="port", fn=int),
... partial(coerce, key="retries", fn=int),
... ]
>>> validate({"host": "db1", "retries": "5"}, rules)
{'host': 'db1', 'port': 5432, 'retries': 5}这个模式的价值:
- 纯函数:每个规则不修改输入、不依赖外部状态 → 顺序可换、可单测、可复用(6.5.3)
partial预绑定:把"多参逻辑"适配成管道要求的"单参接口"(6.5.2)- 数据驱动:规则表是数据(列表),管道是通用逻辑 → 新增规则只需加一行
partial(...) - 对比"手写一串 if 填默认值":管道让每步独立、可插拔、可测
实战建议:配置归一化、参数校验、请求中间件、数据清洗都是"纯函数管道"的天然场景。它把 6.5 的函数式工具箱(
partial、纯函数、reduce)串成了一条可落地的生产线。
普通函数 return 一个结果;生成器函数 yield 一个接一个地产出——把"计算一整批"变成"计算一个、暂停、再要、再算"的流。这是第 5 章迭代器与生成器表达式的函数形态。
变身规则:只要函数体里含 yield,调用它返回的不是结果,而是一个生成器对象,函数体不立即执行:
>>> def countdown(n):
... print("开始倒计时")
... while n > 0:
... yield n
... n -= 1
>>> g = countdown(3) # 只是创建生成器——函数体没执行!
>>> print("未调用 next,上面那行 print 不会出现")
未调用 next,上面那行 print 不会出现
>>> next(g) # 首次 next 才执行到第一个 yield
开始倒计时
3
>>> next(g)
2
>>> next(g)
1
>>> next(g) # 耗尽 → StopIteration
Traceback (most recent call last):
...
StopIteration三种"产出序列"的方式对比(衔接第 5 章):
| 方式 | 求值 | 内存 | 代码 |
|---|---|---|---|
| 列表 | 立即全部算好 | 全量 | [x * 2 for x in range(n)] |
| 生成器表达式 | 惰性 | O(1) | (x * 2 for x in range(n)) |
| 生成器函数 | 惰性 + 可含语句 | O(1) | def gen(): ... yield x |
生成器表达式只能写单个表达式;生成器函数可以写任意逻辑(循环、if、异常处理、嵌套)。需要复杂产出逻辑时,生成器函数是唯一选择:
>>> def dedup(sorted_iterable):
... """去重(要求输入已排序)——需要跨迭代状态,生成器表达式做不了"""
... last = None
... for item in sorted_iterable:
... if item != last:
... yield item
... last = item
>>> list(dedup([1, 1, 2, 3, 3, 3, 4]))
[1, 2, 3, 4]惰性求值与单次消费:
>>> import itertools
>>> def naturals():
... n = 0
... while True:
... yield n
... n += 1
>>> evens = (x for x in naturals() if x % 2 == 0)
>>> next(evens), next(evens), next(evens) # 无限序列!惰性使这成为可能
(0, 2, 4)
⚠️ 陷阱:生成器只能遍历一次。第二次for直接空:>>> g = (x for x in range(3)) >>> list(g); list(g) [0, 1, 2] [] # 已耗尽需要重复使用就转成列表,或每次重新创建生成器。
执行模型:生成器对象内部保存一个冻结的栈帧。next() 恢复帧执行到下一个 yield 暂停并返回值;下一次 next() 从暂停处继续。这就是"状态机"——每 yield 一次,函数停在一个状态。
字节码:
>>> def gen():
... yield 1
... yield 2
>>> dis.dis(gen)
0 0 RESUME 0
1 2 LOAD_CONST 1 (1)
4 YIELD_VALUE 0 # 产出并暂停
6 RESUME 0
2 8 LOAD_CONST 2 (2)
10 YIELD_VALUE 0 # 再次产出
12 RESUME 0
14 LOAD_CONST 0 (None)
16 RETURN_VALUE # 无 yield 可产 → StopIteration与普通函数的 RETURN_VALUE 不同,YIELD_VALUE 把帧挂起而不是弹出。生成器对象有帧相关信息:
>>> g = gen()
>>> g.gi_frame # 被冻结的帧(未启动时为 None)
<frame at 0x..., file '<stdin>', line 1, code gen>
>>> g.gi_code # 代码对象
<code object gen at 0x..., file '<stdin>', line 1>与迭代器协议对接(衔接第 5 章):生成器对象本身就是迭代器——实现了 __iter__(返回自身)和 __next__(恢复执行),耗尽时抛 StopIteration。所以 for x in gen(): 直接可用,list(gen)/sum(gen) 也可用。
工程影响:生成器让"无限/超大数据"变得可处理——每时刻只存在一个元素。对比列表全量载入,处理 10 亿行日志、无限数论序列、流式网络数据时,这是唯一可行的内存方案。
yield 是双向通道:不仅向外产出值,还能接收外部 send() 的数据:
>>> def echo():
... received = yield "ready" # 先产出,等待外部发送
... yield f"收到: {received}"
>>> g = echo()
>>> next(g) # 启动到第一个 yield
'ready'
>>> g.send("Hello") # 向 yield 表达式发送值
'收到: Hello'yield 表达式的结果 = 外部 send() 传来的值(没 send 时是 None)。这让生成器可以双向通信——协程(coroutine)的雏形:
>>> def counter():
... n = 0
... while True:
... received = yield n # 产出当前值,等待外部指令
... if received is not None: # 收到数值 → 重置
... n = received
... else: # 没收到 → 自增
... n += 1
>>> c = counter()
>>> next(c), next(c), next(c) # 0, 1, 2
(0, 1, 2)
>>> c.send(100) # 重置到 100
100
>>> next(c)
101版本注意:
send()由 PEP 342(Python 2.5)引入,是 Python 协程史的起点。async/await(第 11 章)正是建立在生成器协程之上的语法层升级——理解send/yield的双向通道,理解async def会容易得多。
yield from(PEP 380,Py3.3):把子生成器"委托"出去,扁平化嵌套:
>>> def chain(*iterables):
... for it in iterables:
... yield from it # 逐个产出子迭代器的元素
>>> list(chain([1, 2], [3, 4], [5]))
[1, 2, 3, 4, 5]yield from 不止是 for x in it: yield x 的缩写——它转发 send/throw/close 到子生成器,且子生成器的 return 值会成为 yield from 表达式的值:
>>> def inner():
... yield 1
... return "完成" # yield from 会拿到这个返回值
>>> def outer():
... result = yield from inner()
... yield result
>>> list(outer())
[1, '完成']实战建议:扁平化嵌套生成器时用
yield from而不是手动for——它更简洁且正确转发控制流。itertools.chain(*iterables)是标准库的同款实现。
流式读大文件——逐行处理,内存恒定:
>>> def read_lines_clean(path):
... """逐行读取并跳过空行和注释(流式,不整文件载入)"""
... with open(path, encoding="utf-8") as f:
... for line in f:
... line = line.strip()
... if line and not line.startswith("#"):
... yield line无限序列(与 itertools 组合):
>>> import itertools
>>> def fibonacci():
... a, b = 0, 1
... while True:
... yield a
... a, b = b, a + b
>>> list(itertools.islice(fibonacci(), 10)) # 取前 10 个
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
>>> list(itertools.islice(itertools.takewhile(lambda x: x < 100, fibonacci()), 20))
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]生成器管道——每层一个生成器,数据流式穿过(不建中间列表):
>>> def integers():
... n = 0
... while True:
... yield n
... n += 1
>>> squares = (x * x for x in integers())
>>> evens_sq = (x for x in squares if x % 2 == 0)
>>> first10 = list(itertools.islice(evens_sq, 10))
>>> first10
[0, 4, 16, 36, 64, 100, 144, 196, 256, 324]内存对比实验——同一任务,列表全量 vs 生成器流式:
>>> import sys, itertools
>>> def square_list(n):
... return [i * i for i in range(n)]
>>> def square_gen(n):
... return (i * i for i in range(n))
>>> big = 10**7
>>> sys.getsizeof(square_list(big)) # 列表:~89 MB 内存(指针数组)
89095160
>>> g = square_gen(big)
>>> sys.getsizeof(g) # 生成器:常数级(~200 字节)
200
>>> sum(g) # 但能算出和,内存不变
333333283333335000000工程影响:
square_list的指针数组占 ~89 MB(且这不含各平方数的int对象本体——实际内存更大),square_gen恒为 ~200 字节——对内存敏感的数据处理(大数据、日志、流媒体),生成器是默认选择。代价是单次消费和逐元素开销,批量场景要权衡(详见 6.5.3 的选型表)。
| 主题 | 核心要点 |
|---|---|
| 函数定义 | def 是创建函数对象的可执行语句,函数体延迟执行。return 缺省返回 None。def 编译为 MAKE_FUNCTION,调用时建栈帧 |
| 一等公民 | 函数是对象:可赋值、入容器、作参数/返回值。callable() 与 __call__ 协议让任何对象可调用(衔接第 7 章) |
| 注解 | 注解只存元数据(__annotations__),不参与运行时类型检查。PEP 3107 → 484 → 563 → 649 演进 |
| 传参语义 | 按对象传递(call-by-sharing):形参修改对象内容 → 调用方可见;重新绑定 → 不可见。判断依据是可变性 |
| 默认参数 | 默认值在 def 时求值一次。可变默认参数共享同一对象是经典 bug,用 None 哨兵 + 函数体内惰性创建 |
| 限定参数 | * 之后 keyword-only(PEP 3102);/ 之前 positional-only(PEP 570)。/ 释放"参数名承诺",适合内部实现 |
| 封包解包 | */** 是硬币两面:定义侧 *args/**kwargs 收拢,调用侧 f(*seq)/f(**m) 拆开。PEP 448 扩展到字面量 [*a]/{**d}。* 会消耗迭代器 |
| 参数透传 | def wrapper(*args, **kwargs): return f(*args, **kwargs) 是装饰器地基,透传必须成对 |
| 命名空间 | LEGB 查找链(Local→Enclosing→Global→Builtin)。变量归属编译期确定,LOAD_FAST/LOAD_GLOBAL/LOAD_DEREF 对应三种来源 |
global/nonlocal |
函数内赋值默认建局部;改全局用 global,改外层函数变量用 nonlocal。只读外层不需要声明 |
| 闭包 | 闭包 = 函数 + cell 捕获变量,外层 STORE_DEREF/内层 LOAD_DEREF。晚期绑定陷阱:循环中建 lambda 捕获同一个变量,用 lambda i=i: 快照修复。装饰器示例:闭包 + 高阶函数 + 参数透传的组合(细讲见第 12 章) |
lambda |
单表达式匿名函数。Guido 拒绝语句级 lambda(可读性)。选型:一行逻辑用 lambda,其余用 def 或可调用对象 |
| 高阶函数 | key=(DSU 机制)比 cmp 高效且易写;map/filter 惰性、reduce 在 functools;partial 部分应用。推导式优先于 map/filter |
| 纯函数 | 纯函数可测/可缓存/可并发。函数式风格 = 写纯函数,副作用收敛到 I/O 边界 |
| 生成器函数 | 含 yield 即生成器:调用返回生成器对象、惰性产出、单次消费、状态机(YIELD_VALUE 挂起帧)。send/yield from 是协程基础(衔接第 11 章) |
| 数据管道 | 生成器让超大数据/无限序列可处理,内存恒定(O(1)),每时刻只存一个元素 |
-
预测输出——函数对象:运行以下代码,写出输出并解释为什么。
def f(): print("函数体执行") return 42 result = f print(result()) print(f.__name__)
-
传参语义判断:以下每个函数对调用方的影响是什么?区分"修改了对象"和"重新绑定"。解释每个输出的原因。
a = [1, 2, 3] def f1(lst): lst.append(4) def f2(lst): lst = lst + [4] def f3(lst): lst[0] = 99 def f4(lst): lst.extend([4]) f1(a); print(a) # ? f2(a); print(a) # ? f3(a); print(a) # ? f4(a); print(a) # ?
-
修复默认参数 bug:下面的函数希望"每次调用返回一个以参数开头的独立列表",但行为错误。修复它,并解释
__defaults__里发生了什么。def make_prefix(prefix, result=[]): result.insert(0, prefix) return result print(make_prefix("A")) # 期望 ['A'] print(make_prefix("B")) # 期望 ['B'],实际 ['B', 'A']!
-
手写
*args/**kwargs转发:写一个装饰器timed(func),打印函数的执行耗时(用time.perf_counter),同时原样转发所有参数并返回原结果。要求:无论被装饰函数接受什么签名都能正确工作,最后用functools.wraps保留元信息(查文档)。 -
签名设计:为一个"按条件搜索用户"的 API 设计签名,要求满足:
keyword是唯一必填的位置参数limit、offset、sort_by都是可选且只能用关键字传(keyword-only)- 未来可能改名
sort_by,所以它应该是 positional-only?还是保持 keyword-only?讨论这个权衡(参考 6.2.4 的/与*设计原则)。
-
LEGB 预测:下列每个函数会打印什么?还是报错?如果是
UnboundLocalError,解释编译器为什么这么做。x = "global" def a(): print(x) def b(): print(x) x = "local" def c(): global x x = "changed" a() # ? b() # ? c(); print(x) # ?
-
闭包计数器:用
nonlocal实现make_multiplier(n)——返回一个函数,每次调用返回当前计数 × n,计数每次自增。要求:m = make_multiplier(3) m() # 0? 3? —— 自定规则,但要每次调用产生不同结果且共享状态
再解释:如果不写
nonlocal会怎样,为什么。 -
修复晚期绑定:下面代码想得到
[0, 1, 2],实际得到[2, 2, 2]。用两种方式修复(默认参数快照 + 工厂函数),并解释底层 cell 机制。funcs = [] for i in range(3): funcs.append(lambda: i) print([f() for f in funcs])
-
高阶函数重构:用
sorted/key=和推导式重写下面这段命令式代码,并比较可读性。数据:students = [("小明", 90), ("小红", 85), ("小刚", 95)](名字, 分数)。# 命令式:按分数降序排列名字 pairs = [] for name, score in students: pairs.append((score, name)) pairs.sort(reverse=True) result = [name for score, name in pairs]
要求写出:
sorted(students, key=lambda s: s[1], reverse=True)及用operator.itemgetter的版本。 -
生成器实现——大文件行数统计:写一个生成器
count_words(path),流式读取文件(不整体载入),返回(行号, 单词数)的序列,while或for均可。用 100MB 左右的文本测试,对比"整体read().split()"实现的内存差异(用tracemalloc测量峰值内存)。 -
生成器管道:只用生成器表达式和
itertools,实现"求出前 N 个既是平方数又是回文数的数"。要求全程惰性、不建中间列表。提示:itertools.islice+itertools.takewhile。 -
深度思考——推导式 vs
map/filter:给出三个场景,分别论证"用推导式"和"用map/filter"更合理,并给出原因。再从字节码/性能角度解释为什么推导式通常更快(衔接第 5 章 5.3)。最后讨论:Python 社区对map/filter的态度是什么,为什么reduce反而被从内置里挪到functools?
进入下一章的准备:
- ✅ 理解
def创建函数对象、函数体延迟执行、调用时建栈帧 - ✅ 掌握"函数是一等公民"——可作为参数、返回值、入容器;
__call__协议 - ✅ 分清传参语义:修改对象 vs 重新绑定(call-by-sharing)
- ✅ 理解默认参数在
def时求值一次,能用None哨兵规避可变默认参数陷阱 - ✅ 会用
*args/**kwargs封包与解包,能写参数透传的wrapper - ✅ 理解 PEP 3102(keyword-only)与 PEP 570(positional-only)及 API 设计含义
- ✅ 掌握 LEGB 查找规则,能解释
UnboundLocalError的编译期根源 - ✅ 会用
global/nonlocal修改外部作用域,并知道何时不该用 - ✅ 理解闭包的 cell 机制与晚期绑定陷阱,能修复循环中建函数的问题
- ✅ 会用
lambda、sorted(key=)、map/filter、partial,并懂得推导式优先原则 - ✅ 能写生成器函数,理解惰性求值、单次消费与内存优势
- ✅ 认识
send/yield from是协程的底层,为第 7 章 OOP、第 11 章异步打底