查看: 201|回复: 0

Python函数参数解包*与**按位按名及报错解析

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
在 CPython 3.11.9 / macOS (Apple Silicon) 上,Python 的参数解包并不是“简写语法”,而是调用协议的一部分:* 把可迭代对象摊开成位置参数,** 把字典摊开成关键字参数。下面围绕原文实测代码,整理调用侧、定义侧、报错、迭代器耗尽、内存开销、字节码与常见陷阱。
  1. def print_vector(x, y, z):
  2.     print('<%s, %s %s>' % (x, y, z))
  3. print_vector(1, 0, 1)
  4. # <1, 0 1>
  5. tuple_vec = (2, 0, 2)
  6. list_vec = [2, 0, 2]
  7. print_vector(tuple_vec[0], tuple_vec[1], tuple_vec[2])
  8. # <2, 0 2>
  9. print_vector(*tuple_vec)
  10. # <2, 0 2>
  11. print_vector(*list_vec)
  12. # <2, 0 2>
  13. genexpr = (x * x for x in range(3))
  14. print_vector(*genexpr)
  15. # <0, 1 4>
  16. dict_vec = {'y': 0, 'z': 3, 'x': 1}
  17. print_vector(**dict_vec)
  18. # <1, 0 3>
  19. print_vector(*dict_vec)
  20. # <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 的摊开顺序依赖哈希,不能依赖。
  1. print_vector(*(2, 0, 2))
  2. print_vector(*[2, 0, 2])
  3. print_vector(*'abc')       # 逐字符拆开
  4. print_vector(*(i for i in (2, 0, 2)))
  5. print_vector(*{'a': 1, 'b': 2, 'c': 3})  # 取键
  6. print_vector(*range(1, 4))
  7. print_vector(*{7, 8, 9})   # 顺序不确定
  8. print_vector(*Counter('aabbc'))  # 取键
复制代码

结论一句话:* 不挑容器类型,只认迭代协议。对 set 使用 * 属于未定义顺序行为,一旦依赖它填参数,就可能翻车。

二、** 在调用侧:按名绑定

print_vector(**dict_vec) 得到 x=1、y=0、z=3,但 dict_vec 的插入顺序是 y、z、x。这说明 ** 不按顺序填,而是拿键去和形参名逐个配对。
  1. print_vector(**{'y': 0, 'z': 3, 'x': 1})  # <1, 0 3>
  2. print_vector(**{'x': 1, 'y': 0, 'z': 3})  # <1, 0 3>
  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,而是 * 只认迭代协议的必然结果。

三、解包失败时的真实报错

解包很容易失败,而且报错信息通常很具体。以下均为原文实测原文:
  1. print_vector(*(1, 2, 3, 4))
  2. # TypeError: print_vector() takes 3 positional arguments but 4 were given
  3. print_vector(*(1, 2))
  4. # TypeError: print_vector() missing 1 required positional argument: 'z'
  5. print_vector(**{'a': 1, 'b': 2, 'c': 3})
  6. # TypeError: print_vector() got an unexpected keyword argument 'a'
  7. print_vector(**{'x': 1, 'y': 2})
  8. # TypeError: print_vector() missing 1 required positional argument: 'z'
  9. print_vector(**{'x': 1, 'y': 2}, **{'z': 3, 'x': 9})
  10. # TypeError: print_vector() got multiple values for keyword argument 'x'
  11. print_vector(**{1: 'a', 2: 'b', 3: 'c'})
  12. # TypeError: keywords must be strings
  13. print_vector(1, **{'x': 2, 'y': 0, 'z': 3})
  14. # TypeError: print_vector() got multiple values for argument 'x'
  15. print_vector(*5)
  16. # 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,** 会直接拒绝:
  1. def show(a, b, c):
  2.     ...
  3. show(**{'x': box, 'y': box, 'z': box})
  4. # TypeError: show() got an unexpected keyword argument 'x'
复制代码

改成 x、y、z 后通过。** 的名字匹配是字符串精确匹配,且键必须是字符串。不过,如果函数用 **kw 兜底,键只要求是 str,不要求是合法标识符:
  1. def takes(**kw):
  2.     return kw
  3. takes(**{'a-b': 1})  # {'a-b': 1}
复制代码

另外,解包不复制元素,只传引用。无论 * 还是 **,形参拿到的都是原对象本身。函数内修改可变元素,会反映到原容器。

五、解包会耗尽迭代器

生产环境常见隐蔽故障:生成器或迭代器被第一次解包消费后,第二次就空了。
  1. gen = (i for i in range(3))
  2. print_vector(*gen)
  3. # <0, 1 2>
  4. print_vector(*gen)
  5. # TypeError: print_vector() missing 3 required positional arguments: 'x', 'y', and 'z'
  6. gen2 = iter([1, 2, 3])
  7. print_vector(*gen2)
  8. # <1, 2 3>
  9. print_vector(*gen2)
  10. # 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 万个引用和整数对象堆成参数元组。
  1. def consume(*args):
  2.     return len(args)
  3. def gen_n(n):
  4.     for i in range(n):
  5.         yield i
  6. 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。
  1. def target(a, b, c):
  2.     return a + b + c
  3. def via_star(args):
  4.     return target(*args)
复制代码

字节码层面,直接传参走 PRECALL + CALL,参数个数编译期已知,是定长快速通道;解包走 CALL_FUNCTION_EX,解释器需要在运行时现场摊开,天然更贵。CALL_FUNCTION_EX 的操作数 0 或 1 表示是否携带关键字参数。** 的落地动作是 DICT_MERGE,它负责把关键字字典合并并校验进调用协议,重复键、非字符串键都在这里被查出。转发函数 wrapper(*args, **kw) 几乎透明,主要开销来自多一次 Python 帧调用,而不是参数解包本身。

八、PEP 448:多处解包与字面量

Python 3.5 起,一次调用里可以出现多个 * / **:
  1. a, b = [1], (0,)
  2. print_vector(*a, *b, *[1])  # <1, 0 1>
  3. print_vector(*[1], 0, 1)    # <1, 0 1>
  4. print_vector(1, **{'y': 0, 'z': 3})  # <1, 0 3>
  5. {**{'x': 1}, **{'y': 0, 'z': 3}}  # {'x': 1, 'y': 0, 'z': 3}
  6. [*[1], *(0,), *[1]]               # [1, 0, 1]
  7. {*[1, 2], *(2, 3)}                # {1, 2, 3},去重
复制代码

列表 [*a, *b] 拼接保序;元组 (*a, *b) 拼接保序;集合 {*a, *b} 合并、自动去重且无序;字典 {**a, **b} 合并,右侧覆盖左侧同名键,语义类似 d.update()。显式关键字不能与 ** 中的同名键冲突,否则报 got multiple values for argument。

九、keyword-only 参数只能由 ** 提供
  1. def kwonly(a, *, b):
  2.     return a + b
  3. kwonly(1, **{'b': 2})  # 3
  4. kwonly(1, 2)
  5. # 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) 是解包,摊开字典为关键字参数。
  1. def collect(*args, **kwargs):
  2.     return args, kwargs
  3. collect(*[1, 0, 1])
  4. # args = (1, 0, 1), kwargs = {}
  5. collect(**{'y': 0, 'z': 3, 'x': 1})
  6. # kwargs = {'y': 0, 'z': 3, 'x': 1}
  7. collect(*[1], **{'z': 3})
  8. # args = (1,), kwargs = {'z': 3}
复制代码

最经典的一对是 wrapper:
  1. def wrapper(*args, **kwargs):  # 收集
  2.     return func(*args, **kwargs)  # 摊开
复制代码

收集和摊开必须成对出现,少一半就会丢参数。只要写了 *args, **kwargs,函数体内就要把两者原样透传。

十一、配置字典覆盖

** 最日常的用途是默认配置加局部覆盖:
  1. def connect(host='127.0.0.1', port=8080, timeout=30, retry=3):
  2.     return 'host=%s port=%s timeout=%s retry=%s' % (host, port, timeout, retry)
  3. defaults = {'host': '127.0.0.1', 'port': 8080, 'timeout': 30, 'retry': 3}
  4. override = {'port': 443, 'timeout': 5}
  5. connect(**{**defaults, **override})
  6. # host=127.0.0.1 port=443 timeout=5 retry=3
复制代码

{**defaults, **override} 生成新字典,不改动 defaults。但不要这样写:
  1. connect(host='0.0.0.0', **defaults)
  2. # TypeError: connect() got multiple values for keyword argument 'host'
复制代码

因为 defaults 里也有 host。显式关键字与字典键撞名必然报错,正确姿势是先合并:connect(**{**defaults, 'host': '0.0.0.0'})。

十二、与 % 格式化的同源陷阱

起点代码里的 '<%s, %s %s>' % (x, y, z),本身就是元组被解包的另一种表现。它也有同类陷阱:
  1. '%s' % 1          # '1'
  2. '%s' % (1,)       # '1'
  3. '%s' % (1, 2)
  4. # TypeError: not all arguments converted during string formatting
  5. '%(x)s' % {'x': 1}  # '1'
复制代码

单值直接代入;单元素元组被视作参数列表;格式串只要 1 个参数却给 2 个,就报 not all arguments converted。命名字典替换 '%(x)s' % {'x': 1} 与 ** 一样,属于按名匹配。

综合来看:* 管位置,** 管名字;解包不复制元素,只传引用;一次性迭代器解包后会耗尽;解包会先全量物化,大流不要随便 *;显式关键字与字典键冲突时,先合并字典再统一传入。理解这些边界,比记住“星号可以简写调用”更重要。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-9-17 13:17 , Processed in 0.019757 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部