在 CPython 3.11.9 / macOS (Apple Silicon) 上,Python 的参数解包并不是“简写语法”,而是调用协议的一部分:* 把可迭代对象摊开成位置参数,** 把字典摊开成关键字参数。下面围绕原文实测代码,整理调用侧、定义侧、报错、迭代器耗尽、内存开销、字节码与常见陷阱。
- def print_vector(x, y, z):
- print('<%s, %s %s>' % (x, y, z))
- print_vector(1, 0, 1)
- # <1, 0 1>
- tuple_vec = (2, 0, 2)
- list_vec = [2, 0, 2]
- print_vector(tuple_vec[0], tuple_vec[1], tuple_vec[2])
- # <2, 0 2>
- print_vector(*tuple_vec)
- # <2, 0 2>
- print_vector(*list_vec)
- # <2, 0 2>
- genexpr = (x * x for x in range(3))
- print_vector(*genexpr)
- # <0, 1 4>
- dict_vec = {'y': 0, 'z': 3, 'x': 1}
- print_vector(**dict_vec)
- # <1, 0 3>
- print_vector(*dict_vec)
- # <y, z x>
复制代码
注意格式串是 '<%s, %s %s>',第二个和第三个 %s 之间少了一个逗号,所以输出会是 <1, 0 1> 这种形式。这不是校对问题,而是本文所有输出长相的来源。
一、* 在调用侧:按位展开
print_vector(*tuple_vec) 的语义是:迭代 tuple_vec,元素依次占据 x、y、z 三个位置。它是位置绑定,与形参名字无关。只要对象实现迭代协议,* 就吃得下。实测对象包括 tuple、list、str、生成器、dict、range、set、Counter。str 会被逐字符拆开;dict 只取键;set 的摊开顺序依赖哈希,不能依赖。
- print_vector(*(2, 0, 2))
- print_vector(*[2, 0, 2])
- print_vector(*'abc') # 逐字符拆开
- print_vector(*(i for i in (2, 0, 2)))
- print_vector(*{'a': 1, 'b': 2, 'c': 3}) # 取键
- print_vector(*range(1, 4))
- print_vector(*{7, 8, 9}) # 顺序不确定
- print_vector(*Counter('aabbc')) # 取键
复制代码
结论一句话:* 不挑容器类型,只认迭代协议。对 set 使用 * 属于未定义顺序行为,一旦依赖它填参数,就可能翻车。
二、** 在调用侧:按名绑定
print_vector(**dict_vec) 得到 x=1、y=0、z=3,但 dict_vec 的插入顺序是 y、z、x。这说明 ** 不按顺序填,而是拿键去和形参名逐个配对。
- print_vector(**{'y': 0, 'z': 3, 'x': 1}) # <1, 0 3>
- print_vector(**{'x': 1, 'y': 0, 'z': 3}) # <1, 0 3>
- print_vector(**{'z': 3, 'x': 1, 'y': 0}) # <1, 0 3>
复制代码
同一个 dict,一个星号和两个星号结果完全不同:f(*d) 是位置绑定,数据来源是 d 的键,受插入顺序影响;f(**d) 是名字绑定,数据来源是 d 的键到值,完全不受顺序影响。print_vector(*dict_vec) 输出 <y, z x> 不是 bug,而是 * 只认迭代协议的必然结果。
三、解包失败时的真实报错
解包很容易失败,而且报错信息通常很具体。以下均为原文实测原文:
- print_vector(*(1, 2, 3, 4))
- # TypeError: print_vector() takes 3 positional arguments but 4 were given
- print_vector(*(1, 2))
- # TypeError: print_vector() missing 1 required positional argument: 'z'
- print_vector(**{'a': 1, 'b': 2, 'c': 3})
- # TypeError: print_vector() got an unexpected keyword argument 'a'
- print_vector(**{'x': 1, 'y': 2})
- # TypeError: print_vector() missing 1 required positional argument: 'z'
- print_vector(**{'x': 1, 'y': 2}, **{'z': 3, 'x': 9})
- # TypeError: print_vector() got multiple values for keyword argument 'x'
- print_vector(**{1: 'a', 2: 'b', 3: 'c'})
- # TypeError: keywords must be strings
- print_vector(1, **{'x': 2, 'y': 0, 'z': 3})
- # TypeError: print_vector() got multiple values for argument 'x'
- print_vector(*5)
- # TypeError: print_vector() argument after * must be an iterable, not int
复制代码
排查要点:摊开元素多于形参,报 takes N positional arguments but M were given;元素不足或迭代器耗尽,报 missing required positional argument;键名对不上,报 unexpected keyword argument;两次 ** 出现同一个键,报 got multiple values for keyword argument;某个参数既被位置传入又被 ** 传入,报 got multiple values for argument;** 的键不是字符串,报 keywords must be strings;* 后面不可迭代,报 argument after * must be an iterable。
四、形参名与键名必须精确匹配
如果函数显式形参叫 a、b、c,而字典键叫 x、y、z,** 会直接拒绝:
- def show(a, b, c):
- ...
- show(**{'x': box, 'y': box, 'z': box})
- # TypeError: show() got an unexpected keyword argument 'x'
复制代码
改成 x、y、z 后通过。** 的名字匹配是字符串精确匹配,且键必须是字符串。不过,如果函数用 **kw 兜底,键只要求是 str,不要求是合法标识符:
- def takes(**kw):
- return kw
- takes(**{'a-b': 1}) # {'a-b': 1}
复制代码
另外,解包不复制元素,只传引用。无论 * 还是 **,形参拿到的都是原对象本身。函数内修改可变元素,会反映到原容器。
五、解包会耗尽迭代器
生产环境常见隐蔽故障:生成器或迭代器被第一次解包消费后,第二次就空了。
- gen = (i for i in range(3))
- print_vector(*gen)
- # <0, 1 2>
- print_vector(*gen)
- # TypeError: print_vector() missing 3 required positional arguments: 'x', 'y', and 'z'
- gen2 = iter([1, 2, 3])
- print_vector(*gen2)
- # <1, 2 3>
- print_vector(*gen2)
- # TypeError: print_vector() missing 3 required positional arguments: 'x', 'y', and 'z'
复制代码
list、tuple 可以反复解包,因为它们可重复迭代。生成器、map/filter/zip 结果、文件对象、iter() 产物通常是一次性迭代器,解包即报废。需要多次使用,先 list(...) 物化。
六、解包会先全量物化
* 的展开不是惰性的。用 tracemalloc 实测:解包 1000000 个元素的生成器,收到 1000000 个位置参数,峰值内存 45.8 MiB;解包 range(1000000) 同样峰值 45.8 MiB。原本可以零内存流式处理的生成器,只要写成 consume(*gen_n(1000000)),就必须把 100 万个引用和整数对象堆成参数元组。
- def consume(*args):
- return len(args)
- def gen_n(n):
- for i in range(n):
- yield i
- consume(*gen_n(1_000_000))
复制代码
因此,* 适合展开小规模、已知长度的数据。处理大流时,应把迭代器直接交给 for 或接受 Iterable 的函数,而不是解包。原文还实测了解包 1000000 元素 list 和 10000000 元素 list,都不报错,说明 Python 3.11 的限制主要来自内存而非 C 栈。
七、解包开销与字节码
同一口径 timeit 对比:直接调用 target(1,0,1) 约 38.7 ns/次;原地解包 target(*args) 约 43.8 ns/次,多约 5 ns;再套一层转发函数约 59.3 ns/次,多约 20 ns。多出的成本来自构造参数元组和走 CALL_FUNCTION_EX。
- def target(a, b, c):
- return a + b + c
- def via_star(args):
- return target(*args)
复制代码
字节码层面,直接传参走 PRECALL + CALL,参数个数编译期已知,是定长快速通道;解包走 CALL_FUNCTION_EX,解释器需要在运行时现场摊开,天然更贵。CALL_FUNCTION_EX 的操作数 0 或 1 表示是否携带关键字参数。** 的落地动作是 DICT_MERGE,它负责把关键字字典合并并校验进调用协议,重复键、非字符串键都在这里被查出。转发函数 wrapper(*args, **kw) 几乎透明,主要开销来自多一次 Python 帧调用,而不是参数解包本身。
八、PEP 448:多处解包与字面量
Python 3.5 起,一次调用里可以出现多个 * / **:
- a, b = [1], (0,)
- print_vector(*a, *b, *[1]) # <1, 0 1>
- print_vector(*[1], 0, 1) # <1, 0 1>
- print_vector(1, **{'y': 0, 'z': 3}) # <1, 0 3>
- {**{'x': 1}, **{'y': 0, 'z': 3}} # {'x': 1, 'y': 0, 'z': 3}
- [*[1], *(0,), *[1]] # [1, 0, 1]
- {*[1, 2], *(2, 3)} # {1, 2, 3},去重
复制代码
列表 [*a, *b] 拼接保序;元组 (*a, *b) 拼接保序;集合 {*a, *b} 合并、自动去重且无序;字典 {**a, **b} 合并,右侧覆盖左侧同名键,语义类似 d.update()。显式关键字不能与 ** 中的同名键冲突,否则报 got multiple values for argument。
九、keyword-only 参数只能由 ** 提供
- def kwonly(a, *, b):
- return a + b
- kwonly(1, **{'b': 2}) # 3
- kwonly(1, 2)
- # TypeError: kwonly() takes 1 positional argument but 2 were given
复制代码
* 之后的 b 是 keyword-only 参数,位置参数永远到不了它,只有 ** 或显式 b=2 能填。这是 ** 不可替代的场景之一。
十、定义侧打包 vs 调用侧解包
同一个符号在定义侧和调用侧含义相反。定义侧 def f(*args) 是打包,把多余位置参数收成元组;def f(**kwargs) 是打包,把多余关键字参数收成字典。调用侧 f(*a) 是解包,摊开可迭代对象为位置参数;f(**d) 是解包,摊开字典为关键字参数。
- def collect(*args, **kwargs):
- return args, kwargs
- collect(*[1, 0, 1])
- # args = (1, 0, 1), kwargs = {}
- collect(**{'y': 0, 'z': 3, 'x': 1})
- # kwargs = {'y': 0, 'z': 3, 'x': 1}
- collect(*[1], **{'z': 3})
- # args = (1,), kwargs = {'z': 3}
复制代码
最经典的一对是 wrapper:
- def wrapper(*args, **kwargs): # 收集
- return func(*args, **kwargs) # 摊开
复制代码
收集和摊开必须成对出现,少一半就会丢参数。只要写了 *args, **kwargs,函数体内就要把两者原样透传。
十一、配置字典覆盖
** 最日常的用途是默认配置加局部覆盖:
- def connect(host='127.0.0.1', port=8080, timeout=30, retry=3):
- return 'host=%s port=%s timeout=%s retry=%s' % (host, port, timeout, retry)
- defaults = {'host': '127.0.0.1', 'port': 8080, 'timeout': 30, 'retry': 3}
- override = {'port': 443, 'timeout': 5}
- connect(**{**defaults, **override})
- # host=127.0.0.1 port=443 timeout=5 retry=3
复制代码
{**defaults, **override} 生成新字典,不改动 defaults。但不要这样写:
- connect(host='0.0.0.0', **defaults)
- # TypeError: connect() got multiple values for keyword argument 'host'
复制代码
因为 defaults 里也有 host。显式关键字与字典键撞名必然报错,正确姿势是先合并:connect(**{**defaults, 'host': '0.0.0.0'})。
十二、与 % 格式化的同源陷阱
起点代码里的 '<%s, %s %s>' % (x, y, z),本身就是元组被解包的另一种表现。它也有同类陷阱:
- '%s' % 1 # '1'
- '%s' % (1,) # '1'
- '%s' % (1, 2)
- # TypeError: not all arguments converted during string formatting
- '%(x)s' % {'x': 1} # '1'
复制代码
单值直接代入;单元素元组被视作参数列表;格式串只要 1 个参数却给 2 个,就报 not all arguments converted。命名字典替换 '%(x)s' % {'x': 1} 与 ** 一样,属于按名匹配。
综合来看:* 管位置,** 管名字;解包不复制元素,只传引用;一次性迭代器解包后会耗尽;解包会先全量物化,大流不要随便 *;显式关键字与字典键冲突时,先合并字典再统一传入。理解这些边界,比记住“星号可以简写调用”更重要。 |