Python教程

Python:星号解包全家桶:a, *rest = lst 取头、*args 收参数、**kwargs 收关键字

👤 为我痴狂 👁 4 阅读 ❤ 0 点赞 ➦ 0 分享 📅 2026-10-16
首页› 理学› Python教程› 正文
Python:星号解包全家桶
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] 接受任意可迭代对象,且总是产出列表。在需要“混合类型拼接”时,解包更通用。

操作 类型要求 结果类型 适用场景
a + b同类型同类型已知同类型
[*a, *b]任意可迭代list混合来源
{**d1, **d2}映射dict配置合并
d1 | d2dictdictPython 3.9+

本文评述: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 万次取最小值。以下数据为模拟整合数据,用于说明量级差异,非权威基准,实际数值随环境变化。

操作 代码 相对耗时 备注
索引取头lst[0]1.0x最快
解包取头a, *r = lst约 2.5x需构建 rest 列表
切片取尾lst[1:]约 1.8x复制数据
固定解包a, b = lst约 1.2x无列表构建
展开调用f(*lst)约 3x物化为元组

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. 常见陷阱与反模式清单

  1. 生成器耗尽:对生成器解包后再次迭代得到空结果。对策:先物化为列表,或改用 itertools.tee。
  2. 键类型错误:把含非字符串键的字典用 ** 展开进函数调用,运行期才报错。对策:调用前校验键类型。
  3. 签名丢失:装饰器用 *args, **kwargs 透传却不加 functools.wraps,导致文档与提示失效。
  4. 静默覆盖:误以为函数调用中的 **kwargs 会覆盖显式参数,实际会抛异常。
  5. 星号位置错误:在赋值中写多个星号(a, *b, *c = lst)是语法错误。
  6. 内存放大:对超大可迭代对象用 * 展开,导致内存峰值飙升。
  7. 类型标注误用:把 *args: list[int] 理解为“args 是列表”,实际是“每个参数是 list[int]”。

本文评述:这份清单的共同根源,是把“展开”误当成“复制”或“合并”。只要牢记星号是“按协议铺开”,上述陷阱大多可以提前规避。

11. 前沿预判:解包协议的下一步

11.1 类型系统的持续深化

PEP 646 之后,社区在讨论更细粒度的变长类型标注,例如对 **kwargs

🔒 复制本站文章内容需登录并达到 L3。当前:未登录

分享到

💬
微信
📷
朋友圈
🐧
QQ好友
🌐
QQ空间
👁
微博
📌
钉钉
🔗
复制链接
📑
复制图文

微信扫一扫分享

打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。

💬 评论 (0)

评论功能已关闭

⏸️ 本站暂未开放评论功能,不能进行评论,此为规划的后续开发预留
首页| 关于本网| 网站声明| 联系我们| 网站纠错| 服务| 网站地图
黔ICP备19010680号-1  |  邮箱:six528528@163.com
贵公网安备 52010302001819号
Copyright 2019-2026 http://www.databrush.com/ All rights reserved.
QQ
QQ扫一扫
Logo
DBN数据刷