a, *rest = lst 取头、*args 收参数、**kwargs 收关键字
从语法糖到协议契约 · 一条主线贯通解包机制的理论、字节码与工程实践
摘要
星号(*)与双星号(**)是Python中最容易被“用会”却最难“说清”的语法家族。本文提出一条贯穿全文的分析主线:解包不是语法糖,而是一种“协议契约”——它在赋值、调用、字面量、类型标注四个层面,统一了“序列展开”与“映射展开”的语义边界。围绕这条主线,文章依次拆解 a, *rest = lst 的取头语义、*args 与 **kwargs 的收参契约、PEP 448 引入的多重解包、PEP 646 的变长泛型标注,并结合 CPython 字节码(UNPACK_EX、CALL_FUNCTION_EX、BUILD_MAP_UNPACK 等)与性能基准,给出可落地的工程路径与前沿预判。
本文评述:把星号理解为“协议”而非“技巧”,是区分“会用”与“用对”的分水岭。所有数据来源均已标注,模拟数据单独说明。
目录
1. 引言:为什么星号值得单独写一篇长文
在Python的日常编码中,星号出现的频率极高,却极少有人系统梳理它的语义边界。初学者记住的是“*args 收位置参数、**kwargs 收关键字参数”这样的口诀;中级开发者知道 a, *rest = lst 可以取头;而真正把星号用出“工程味”的人,往往能说清它在赋值、调用、字面量、类型标注四个场景下的统一逻辑。
这条统一逻辑,本文称之为“解包即协议”。星号本身不产生数据,它只是一个“展开指令”:告诉解释器把某个可迭代对象或映射对象的内容,按位置或按键值铺开到当前上下文中。理解这一点,就能把散落各处的星号用法收束成一条主线。
本文评述:把星号当作“展开指令”而非“参数收集器”,是理解其全部行为的关键。收集只是展开在函数签名处的对偶表现,二者是同一枚硬币的两面。
从语言演进看,星号的能力是逐步扩张的。PEP 3132(2007,Python 3.0)引入了扩展可迭代解包 a, *rest = lst;PEP 448(2015,Python 3.5)允许在函数调用和字面量中多重解包;PEP 646(2021,Python 3.11)引入 TypeVarTuple 支持变长泛型标注。这条时间线本身就是“解包协议不断正规化”的证据。
本文的目标读者是有一定Python基础、希望把星号用法从“经验”升级为“体系”的工程师。全文将围绕四个层面展开:语义层(协议)、实现层(字节码)、性能层(基准)、工程层(实践与标注)。每一层都服务于同一条主线,避免碎片化罗列。
2. 语义根基:可迭代协议与映射协议
2.1 单星号依赖可迭代协议
单星号 * 的展开对象必须实现 __iter__ 或 __getitem__(旧式序列协议)。这意味着列表、元组、字符串、集合、生成器、range、dict(迭代键)、numpy 数组等都可以被单星号展开。Python 官方文档在“Unpacking Argument Lists”一节中明确:单星号要求对象是可迭代的。
一个常被忽略的细节是:展开是“消费式”的。对生成器使用单星号,会把它彻底耗尽。这一点在工程中极易踩坑——如果你把一个生成器既用于 *gen 展开,又想在后续再次迭代它,第二次会得到空结果。
def gen():
yield 1
yield 2
g = gen()
print([*g]) # [1, 2]
print([*g]) # [] —— 生成器已被耗尽
2.2 双星号依赖映射协议
双星号 ** 的展开对象必须是映射(mapping),即实现 keys() 与 __getitem__ 的对象。dict 是最典型的例子,但 collections.ChainMap、types.MappingProxyType 等也满足条件。注意:双星号展开要求键必须是字符串(在函数调用场景下),否则会抛出 TypeError: keywords must be strings。
这里有一个语义分叉值得强调:在函数调用中,**mapping 的键必须是字符串;但在字典字面量 {**mapping} 中,键可以是任意可哈希对象。这是 PEP 448 之后才明确的差异,很多教程没有区分。
本文评述:把“键类型约束”单独列出来,是因为它是最容易在重构中翻车的地方——把一个用于字典字面量的 {**d} 直接搬到函数调用里,若 d 含非字符串键,运行期才会报错。
3. a, *rest = lst:取头语义的完整拆解
3.1 基本形式与边界行为
PEP 3132 定义的扩展解包,允许在赋值目标中最多出现一个带星号的名字。它把“剩余元素”收集为一个列表(注意:不是元组,无论右侧是什么类型)。这是与 *args 收集为元组的关键差异。
head, *rest = [1, 2, 3, 4]
print(head, rest) # 1 [2, 3, 4]
*init, last = (1, 2, 3)
print(init, last) # [1, 2] 3
first, *mid, last = "abcde"
print(first, mid, last) # a ['b', 'c', 'd'] e
a, *b = [] # ValueError: not enough values to unpack
边界行为值得逐条记录:当右侧元素不足以填充星号以外的目标时,抛出 ValueError;当星号目标恰好为空时,得到空列表而非 None;星号目标不能单独出现(*a = lst 在 Python 3 中是语法错误,因为等价于普通赋值)。
3.2 嵌套解包与模式匹配的对照
赋值解包支持嵌套,这使它具备了轻量级“模式匹配”的能力。Python 3.10 引入的 match 语句(PEP 634)在结构模式中同样支持星号,但语义有微妙差异:match 中的 *rest 收集为列表,且模式匹配失败时不会抛异常而是走下一个 case。
# 嵌套赋值解包
(a, b), *rest = [(1, 2), 3, 4]
print(a, b, rest) # 1 2 [3, 4]
# match 结构模式
def handle(cmd):
match cmd:
case ["go", direction]:
return f"move {direction}"
case ["take", *items]:
return f"take {items}"
case _:
return "unknown"
本文评述:赋值解包与 match 模式共享“星号收集”的语法外观,但错误处理哲学不同——前者是“失败即异常”,后者是“失败即继续匹配”。在解析命令行、协议帧这类场景中,match 的容错性更合适;而在契约明确的内部函数里,赋值解包的快速失败更利于暴露 bug。
3.3 取头的工程价值:从“切片+索引”到“一次解包”
在没有扩展解包的时代,取头通常写成 head, rest = lst[0], lst[1:]。这种写法有两个问题:一是对不支持切片但支持迭代的对象(如生成器、迭代器)不适用;二是切片会复制数据,在大列表上有额外开销。扩展解包则对任意可迭代对象统一生效。
在递归算法、链表处理、解析器实现中,head, *tail = seq 几乎成了函数式风格的标配。但要注意:对生成器使用它会把整个生成器物化为列表,失去惰性优势。若只需头部,用 next(it) 更合适。
4. *args 与 **kwargs:函数签名的收参契约
4.1 参数分类的完整图谱
Python 函数参数分为五类:位置或关键字参数(POSITIONAL_OR_KEYWORD)、仅位置参数(POSITIONAL_ONLY,用 / 分隔)、仅关键字参数(KEYWORD_ONLY,用 * 分隔)、变长位置参数(VAR_POSITIONAL,即 *args)、变长关键字参数(VAR_KEYWORD,即 **kwargs)。这套分类由 PEP 570(2018,Python 3.8)正式确立。
def full(a, b, /, c, d, *args, e, f, **kwargs):
...
# a, b 仅位置;c, d 位置或关键字;args 收多余位置
# e, f 仅关键字;kwargs 收多余关键字
本文评述:/ 与 * 的组合,本质上是把“参数传递方式”也纳入了 API 契约。库作者用它们锁定调用方式,避免用户依赖参数名(便于未来重命名)或误用位置传参。这是星号在“接口设计”层面的价值,远超“收参数”本身。
4.2 收集与展开的对偶性
函数定义处的 *args 是“收集”,调用处的 *iterable 是“展开”,二者互为对偶。这种对偶性让“转发”变得极其自然:装饰器、包装函数、代理对象都可以用 def wrapper(*args, **kwargs): return func(*args, **kwargs) 一行完成透传。
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"call {func.__name__}")
return func(*args, **kwargs)
return wrapper
但透传并非没有代价。*args, **kwargs 会丢失原始签名信息,导致 IDE 无法提示参数、类型检查器无法校验。工程上的补救手段是 functools.wraps 配合 typing.ParamSpec(PEP 612,Python 3.10),后者能在类型层面保留签名。
4.3 关键字参数冲突与合并顺序
当 **kwargs 与显式关键字参数同时出现时,若键重复,Python 会抛出 TypeError: got multiple values for keyword argument。这是“契约优先”的体现:显式参数优先于展开参数,冲突即错误,不做静默覆盖。
def f(x):
return x
d = {"x": 1}
f(2, **d) # TypeError: got multiple values for argument 'x'
# 字典字面量则允许后者覆盖前者
merged = {**{"a": 1}, **{"a": 2}}
print(merged) # {'a': 2}
本文评述:函数调用“冲突即报错”与字典字面量“后者覆盖”的差异,是设计上的有意区分——函数参数是契约,契约不能含糊;字典合并是数据操作,数据允许覆盖。理解这层意图,就不会觉得二者“不一致”是缺陷。
5. PEP 448:多重解包与字面量展开
5.1 从“单次”到“任意次”
PEP 448 之前,函数调用中 *args 只能出现一次,且不能与位置参数混用在任意位置。PEP 448 放开了限制:函数调用、列表/元组/集合/字典字面量中都可以出现任意多个 * 与 ** 展开,且位置自由。
a, b, c = [1], [2], [3]
print([*a, *b, *c]) # [1, 2, 3]
print((*a, 0, *b)) # (1, 0, 2)
print({*a, *b, *c}) # {1, 2, 3}
d1, d2 = {"x": 1}, {"y": 2}
print({**d1, **d2, "z": 3}) # {'x': 1, 'y': 2, 'z': 3}
# 函数调用中的多重展开
def g(*args, **kwargs):
return args, kwargs
print(g(*a, *b, **d1, **d2))
5.2 与“拼接”操作的语义对比
PEP 448 让 [*a, *b] 成为 a + b 的替代。二者结果相同,但语义不同:+ 要求两侧同类型(list + list),而 [*a, *b] 接受任意可迭代对象,且总是产出列表。在需要“混合类型拼接”时,解包更通用。
本文评述:Python 3.9 引入的字典合并运算符 | 与 {**d1, **d2} 功能重叠,但后者可以在合并的同时插入字面量键值对,表达力更强。在配置合并这类“默认值 + 覆盖值 + 临时值”的场景,{**defaults, **overrides, "extra": 1} 的写法最直观。
5.3 展开顺序与覆盖规则
字典字面量中的 ** 展开按从左到右顺序执行,后出现的键覆盖先出现的。这一规则在 PEP 448 中被明确,且与 dict.update 的顺序语义一致。工程含义是:把“基础配置”放前面,“覆盖配置”放后面,即可实现层次化配置。
6. 字节码视角:CPython 到底做了什么
6.1 关键字节码指令
理解解包的实现,最直接的方式是看 dis 输出。CPython 为解包准备了一组专用指令:UNPACK_SEQUENCE(固定长度解包)、UNPACK_EX(带星号的扩展解包)、CALL_FUNCTION_EX(带 * 或 ** 的调用)、BUILD_LIST_UNPACK、BUILD_TUPLE_UNPACK、BUILD_MAP_UNPACK 等。
import dis
def f(lst):
a, *rest = lst
return a
dis.dis(f)
在 CPython 3.11 上,上述代码会生成 UNPACK_EX 1 指令。操作数 1 编码了“星号前有 1 个目标、星号后有 0 个目标”的信息(低 8 位为星号前目标数,高 8 位为星号后目标数)。解释器据此从栈顶对象中取出相应元素,并把剩余部分收集为列表。
6.2 CALL_FUNCTION_EX 的调用路径
当函数调用中出现 * 或 ** 时,CPython 使用 CALL_FUNCTION_EX。它从栈上弹出可调用对象、位置参数元组、关键字参数字典,然后调用。这意味着展开操作在调用前已经完成了“物化”——*gen 会把生成器全部展开成元组,再传入函数。
本文评述:这解释了一个常见性能直觉——用 *args 展开大生成器,内存峰值等于生成器全部元素之和。若函数本身支持迭代器(如 sum、any),直接传生成器对象比展开更省内存。
6.3 3.11 与 3.12 的优化动向
CPython 3.11 引入了“自适应解释器”(PEP 659),对热点字节码进行特化。3.12 进一步优化了调用协议。根据 CPython 官方发布说明与 GitHub 上的 cpython/Objects/call.c 变更记录,带星号调用的路径在近年持续被优化,但“展开需物化”这一语义约束没有改变。这是语言语义与实现优化之间的边界。
7. 性能基准:解包 vs 切片 vs 索引
7.1 基准设计说明
为给出可复现的对比,笔者设计了一组微基准,使用 timeit 在 CPython 3.12、Linux x86_64、Intel i7-12700 环境下运行,每个用例执行 100 万次取最小值。以下数据为模拟整合数据,用于说明量级差异,非权威基准,实际数值随环境变化。
7.2 结论与工程取舍
基准显示:解包不是性能最优解,但它的价值在于表达力与通用性。在热路径上,若已知对象是列表且只需头部,lst[0] 更快;若需要“取头 + 保留剩余”且对象可能是任意可迭代,解包是唯一简洁写法。工程判断应基于“可读性收益 vs 性能损耗”,而非盲目追求微优化。
本文评述:把性能数据当作“决策依据”而非“教条”很重要。在绝大多数业务代码中,解包带来的可读性提升远超其纳秒级开销;只有在每秒百万次调用的解析器内核里,才值得为 lst[0] 替换解包。
8. 类型标注:从 *args: int 到 PEP 646 变长泛型
8.1 基础标注
在类型标注中,*args: int 表示每个位置参数都是 int,**kwargs: str 表示每个关键字参数值都是 str。注意:标注的是“元素类型”,不是“容器类型”。这是初学者常见误解。
def f(*args: int, **kwargs: str) -> None:
...
# args 的类型是 tuple[int, ...]
# kwargs 的类型是 dict[str, str]
8.2 PEP 646:变长泛型与 TypeVarTuple
PEP 646(Python 3.11)引入 TypeVarTuple,让“变长元组”可以在类型层面精确表达。典型场景是数组形状标注:Array[float, Height, Width]。这解决了长期以来“形状信息无法静态校验”的痛点。
from typing import TypeVarTuple, Generic
Shape = TypeVarTuple("Shape")
class Array(Generic[*Shape]):
def shape(self) -> tuple[*Shape]:
...
# 使用
a: Array[int, int] = ...
reveal_type(a.shape()) # tuple[int, int]
本文评述:PEP 646 的意义在于把“星号”从运行期语法提升为编译期类型构造器。Generic[*Shape] 中的星号与 *args 中的星号共享同一种“展开”直觉,但作用域从值层面迁移到了类型层面。这是“解包即协议”主线在类型系统中的延伸。
8.3 ParamSpec 与装饰器标注
PEP 612(Python 3.10)的 ParamSpec 解决了装饰器透传签名的标注难题。它把“被装饰函数的参数列表”作为一个整体类型变量捕获,再原样转发。配合 Concatenate,还能在参数列表前后追加参数。
from typing import ParamSpec, TypeVar, Callable
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
本文评述:P.args 与 P.kwargs 是星号在类型标注中的“专用形式”,它们不能脱离 ParamSpec 单独使用。这一约束保证了类型检查器能正确追踪签名,是工程中写“类型安全装饰器”的标准姿势。
9. 工程实践:API 设计、装饰器与数据管道
9.1 API 设计中的参数策略
设计库函数时,是否使用 *args, **kwargs 是一个权衡。过度使用会削弱接口的自描述性;完全不用又难以透传。工程上的经验法则是:面向用户的公共 API 尽量显式声明参数,仅在“透传层”(装饰器、代理、适配器)使用变长参数。
Python 标准库中 functools.partial、functools.wraps、contextlib.contextmanager 都是变长参数的合理用例。而 requests.get 这类公共 API 则显式声明 params、headers 等具名参数,便于文档与提示。
9.2 数据管道中的解包
在 ETL 与数据处理中,解包常用于“记录拆分”。例如从 CSV 读入的行,用 id_, *fields = row 分离主键与字段。这种写法比索引更抗字段增减——新增字段时无需改动拆分逻辑。
def parse_rows(rows):
for row in rows:
record_id, *values = row
yield {"id": record_id, "values": values}
本文评述:解包在数据管道中的真正优势是“契约弹性”——它把“前 N 个字段固定、其余可变”的假设直接写进代码结构。相比硬编码索引,这种写法在 schema 演进时更稳健。
9.3 与结构化数据的配合
在 dataclasses、attrs、Pydantic 等库中,**kwargs 常用于“从字典构造对象”。例如 Model(**data)。但要注意:若 data 含模型未定义的键,行为取决于库——Pydantic 默认忽略或报错,dataclasses 直接抛 TypeError。工程中应显式过滤键,避免把外部数据直接展开进构造函数。
10. 常见陷阱与反模式清单
- 生成器耗尽:对生成器解包后再次迭代得到空结果。对策:先物化为列表,或改用
itertools.tee。 - 键类型错误:把含非字符串键的字典用
**展开进函数调用,运行期才报错。对策:调用前校验键类型。 - 签名丢失:装饰器用
*args, **kwargs透传却不加functools.wraps,导致文档与提示失效。 - 静默覆盖:误以为函数调用中的
**kwargs会覆盖显式参数,实际会抛异常。 - 星号位置错误:在赋值中写多个星号(
a, *b, *c = lst)是语法错误。 - 内存放大:对超大可迭代对象用
*展开,导致内存峰值飙升。 - 类型标注误用:把
*args: list[int]理解为“args 是列表”,实际是“每个参数是 list[int]”。
本文评述:这份清单的共同根源,是把“展开”误当成“复制”或“合并”。只要牢记星号是“按协议铺开”,上述陷阱大多可以提前规避。
11. 前沿预判:解包协议的下一步
11.1 类型系统的持续深化
PEP 646 之后,社区在讨论更细粒度的变长类型标注,例如对 **kwargs
微信扫一扫分享
打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。
💬 评论 (0)
评论功能已关闭

