Python参数绑定实测指南(五种参数与三种星号)
作者:Bruce_xiaowei
一切都是从这样一个签名开始的:
def f(a, *args, b=1, **kw):
...
四个形参,四种不同的"收参数"方式。再加上调用处的 *、赋值处的 *、字面量里的 *,Python 里同一个星号至少背着四种互不相干的语义。这篇把它们一次拆干净。
实测环境:主实验 CPython 3.11.9(macOS,Apple Silicon);跨版本矩阵覆盖 3.7.8rc1 / 3.8.10 / 3.10.18 / 3.11.12 / 3.12.10 / 3.13.3 / 3.14.7。所有签名、报错文案、字节码均为本机实跑输出。
一、五种参数类型,不是四种
def f(a, *args, b=1, **kw) 里的每个形参在 inspect 眼里都有明确的"种类":
import inspect
def f(a, *args, b=1, **kw): ...
sig = inspect.signature(f)
print(sig) # (a, *args, b=1, **kw)
for name, p in sig.parameters.items():
print(name, p.kind, p.default)
a POSITIONAL_OR_KEYWORD <class 'inspect._empty'> args VAR_POSITIONAL <class 'inspect._empty'> b KEYWORD_ONLY 1 kw VAR_KEYWORD <class 'inspect._empty'>
底层 code 对象上对应的字段:
co_varnames : ('a', 'b', 'args', 'kw')
co_argcount : 1
co_posonlyargcount : 0
co_kwonlyargcount : 1
CO_VARARGS / CO_VARKEYWORDS : True / True
注意 co_varnames 里 b 排在 args 前面。实测顺序固定为"位置参数 → 仅关键字参数 → *args → **kw":仅关键字参数紧跟位置参数存放,两个收集器排在最后。这不是笔误,但也别写代码去依赖它。
co_flags 上同一次打印的结果是 15,其中 CO_VARARGS = 4、CO_VARKEYWORDS = 8——两个收集器的存在与否,就是从这两个二进制位上读出来的。
加上 3.8 引入的 /,一共五种:
| 种类 | 写法 | 能按位置传 | 能按关键字传 |
|---|---|---|---|
| 位置或关键字 | a | 是 | 是 |
| 仅位置 | a, / 之前 | 是 | 否 |
| 可变位置 | *args | 收集多余位置参数 | 否(关键字进 **kw) |
| 仅关键字 | * 之后 | 否 | 是 |
| 可变关键字 | **kw | 否 | 收集多余关键字 |
二、绑定顺序:先位置,再关键字,剩下的归收集器
实测一组调用,看每个参数落进哪个格子:
f(1) -> {'a': 1, 'args': (), 'b': 1, 'kw': {}}
f(1, 2) -> {'a': 1, 'args': (2,), 'b': 1, 'kw': {}}
f(1, 2, 3) -> {'a': 1, 'args': (2, 3), 'b': 1, 'kw': {}}
f(1, b=9) -> {'a': 1, 'args': (), 'b': 9, 'kw': {}}
f(a=1) -> {'a': 1, 'args': (), 'b': 1, 'kw': {}}
f(a=1, b=2) -> {'a': 1, 'args': (), 'b': 2, 'kw': {}}
f(1, *[2, 3]) -> {'a': 1, 'args': (2, 3), 'b': 1, 'kw': {}}
f(**{'a': 1, 'b': 2}) -> {'a': 1, 'args': (), 'b': 2, 'kw': {}}
f(1, **{'b': 2, 'c': 3}) -> {'a': 1, 'args': (), 'b': 2, 'kw': {'c': 3}}
规则可以严格按这个顺序执行:
- 先用位置实参从左到右填满"位置或关键字"和"仅位置"形参;
- 多余的位置实参进
*args; - 再处理关键字实参,填"位置或关键字"和"仅关键字"形参;
- 剩下的关键字进
**kw。
关键推论:*args 只吃位置参数,b 只能吃关键字。所以 f(1, 2, 3) 不会把 3 给 b,而 f(1, b=9) 里那个 9 也绝不会跑到 args 里去。
还有两个反直觉的落子:
f(1, **{'args': 2}) -> {'a': 1, 'args': (), 'b': 1, 'kw': {'args': 2}}
f(1, **{'kw': 2}) -> {'a': 1, 'args': (), 'b': 1, 'kw': {'kw': 2}}
名叫 args 的关键字参数不会进 *args,它老老实实落进 **kw。收集器只认语法位置,不认名字。
三、/:3.8 才有的位置专用参数
def g(a, /, b, *, c, d=4):
...
inspect.signature(g) 输出 (a, /, b, *, c, d=4)。a 要么按位置传,要么别传:
g(1, 2, c=3) -> {'a': 1, 'b': 2, 'c': 3, 'd': 4}
g(1, b=2, c=3) -> {'a': 1, 'b': 2, 'c': 3, 'd': 4}
g(1, b=2, c=3, d=9) -> {'a': 1, 'b': 2, 'c': 3, 'd': 9}
g(a=1, b=2, c=3) -> TypeError: g() got some positional-only arguments passed as keyword arguments: 'a'
g(1, 2, 3) -> TypeError: g() takes 2 positional arguments but 3 were given
两个细节值得记:
- 报错说的是
got some positional-only arguments passed as keyword arguments——这是一条专门的文案,和"多余关键字"完全不同,说明解释器准确识别出了"你想用关键字填一个仅位置参数"这个意图; - 当第三个位置参数撞上
*分界线时,报错退化成takes 2 positional arguments but 3 were given,数字 2 是a和b的总数。
3.7 上这个语法直接过不了编译:
3.7.8rc1 : def g(a, /, b, *, c): ... -> SyntaxError: invalid syntax
四、报错文案矩阵(跨版本,实测)
这是最容易在升级时"悄悄变了"的部分。先看调用期报错,同样是模块级函数,却分化出两种命运。
表 A:模块级函数
| 触发写法 | 3.7.8rc1 / 3.8.10 | 3.10.18 / 3.11.12 / 3.12.10 / 3.13.3 / 3.14.7 |
|---|---|---|
f(1, a=2) | f() got multiple values for argument 'a' | f() got multiple values for argument 'a' |
f() | f() missing 1 required positional argument: 'a' | f() missing 1 required positional argument: 'a' |
f(*1) | f() argument after * must be an iterable, not int | __main__.f() argument after * must be an iterable, not int |
f(1, *1) | f() argument after * must be an iterable, not int | Value after * must be an iterable, not int |
v(**'ab') | v() argument after ** must be a mapping, not str | __main__.v() argument after ** must be a mapping, not str |
f(**{1: 2}) | keywords must be strings | keywords must be strings |
表 B:实例方法
| 触发写法 | 3.7.8rc1 / 3.8.10 | 3.10.18 及以后 |
|---|---|---|
K().m(1, a=2) | m() got multiple values for argument 'a' | K.m() got multiple values for argument 'a' |
K.m(1) | m() missing 1 required positional argument: 'a' | K.m() missing 1 required positional argument: 'a' |
K.m() | m() missing 2 required positional arguments: 'self' and 'a' | K.m() missing 2 required positional arguments: 'self' and 'a' |
表 C:语法错误
| 触发写法 | 3.7.8rc1 / 3.8.10 | 3.10.18 | 3.11.12 及以后 |
|---|---|---|---|
a, *b, *c = [1,2,3] | two starred expressions in assignment | multiple starred expressions in assignment | multiple starred expressions in assignment |
def g(a, b, *, *, c) | invalid syntax | invalid syntax | * argument may appear only once |
def g(*, a, /, b) | invalid syntax | invalid syntax | / must be ahead of * |
def g(a, /, a) | 3.7: invalid syntax;3.8: duplicate argument 'a' | duplicate argument 'a' | duplicate argument 'a' |
def g(a, a) | duplicate argument 'a' in function definition | 同左 | 同左 |
*a = [1, 2] | starred assignment target must be in a list or tuple | 同左 | 同左 |
f(2, b=1, b=2) | keyword argument repeated: b | 同左 | 同左 |
四个可迁移的结论:
一、3.10 起的"限定名改造"只打到了方法,没打全函数。 方法调用从 m() 变成了 K.m(),但模块级函数的 got multiple values 和 missing ... argument 从 3.7 到 3.14 一字未变,始终是短名 f()。写正则匹配日志的代码,不能假设"3.10+ 一定有前缀"。
二、同一个模块级函数的报错,在 3.11.9 上会同时出现 f() 和 __main__.f() 两种前缀。 这不是笔误:
f(1, a=2) -> TypeError: f() got multiple values for argument 'a' f(*1) -> TypeError: __main__.f() argument after * must be an iterable, not int
前者由实参绑定逻辑产生,取的是 code 对象的短名 co_name;后者由实参准备逻辑产生,取的是限定名 __qualname__。两条路径、两套取名方式,3.10 只改了后者。所以同一份错误日志里前缀并存,是完全正常的——排查时别把它当成两个不同版本。
三、f(1, *1) 的文案和 f(*1) 也不是一条路径。 3.10+ 上前者是 Value after * must be an iterable(没有函数名),后者是 __main__.f() argument after * must be an iterable。因为星号出现在中间位置时,解释器还在准备实参阶段就报错了,根本没走到"要调用哪个函数"。同样的原因解释了 3.7/3.8 为什么两者文案相同——那时还没区分这两条路径。
四、同一条语法错误,3.11 起才有专门的措辞。 3.7~3.10 一律 invalid syntax,3.11+ 换成了指名道姓的 * argument may appear only once 和 / must be ahead of *。这对写教学材料很方便,对做版本兼容解析又是个负担。
另一条容易忽视的:缺多个参数时,文案是复数且用 and 连接。
K.m() -> TypeError: K.m() missing 2 required positional arguments: 'self' and 'a'
注意这里数的是 2——self 也是参数,这正是上一篇里 MyClass.method() 报错的那条路径。
五、星号在调用处:解包实参
nums = (1, 2, 3)
f(*nums) # f(1, 2, 3)
f(*[1], **{'b': 2}) # 位置 + 关键字混合解包
f(1, 2, *[3, 4], b=5, c=6) -> {'a': 1, 'args': (2, 3, 4), 'b': 5, 'kw': {'c': 6}}
f(*[1], *[2, 3]) -> {'a': 1, 'args': (2, 3), 'b': 1, 'kw': {}}
f(1, b=1, *[2, 3]) -> {'a': 1, 'args': (2, 3), 'b': 1, 'kw': {}}
- 一个调用里可以出现多个
*(PEP 448,3.5 起),它们按书写顺序依次展开; - 关键字实参写在
*前面语法合法,但会让人读不懂,别这么写; *后面必须是可迭代对象,**后面必须是映射:
f(*1) -> TypeError: __main__.f() argument after * must be an iterable, not int
f(**'ab') -> TypeError: __main__.f() argument after ** must be a mapping, not str
f(**{1: 2})-> TypeError: keywords must be strings
最后一条很值得记:** 的键必须是字符串。数字键在调用时会被单独拦下,报错文案里连函数名都没有。
六、星号在赋值处:解包与 UNPACK_EX
a, *rest = [1, 2, 3] # a=1, rest=[2, 3] *a, b = [1, 2, 3] # a=[1, 2], b=3 a, *b, c = [1, 2, 3, 4] # a=1, b=[2, 3], c=4 a, *rest = [1] # a=1, rest=[] <- 空也合法 head, *tail = (1,) # head=1, tail=[] x, *y = 'abc' # x='a', y=['b', 'c'] 字符串也能被解包 *a, = [1, 2] # a=[1, 2] <- 注意那个逗号,否则是普通赋值
星号的目标既可以是首、尾,也可以在中间,但一个赋值语句里只能有一个:
a, *b, *c = [1, 2, 3] -> SyntaxError: multiple starred expressions in assignment *a = [1, 2] -> SyntaxError: starred assignment target must be in a list or tuple
(第一条在 3.7/3.8 上的措辞是 two starred,见上一节的矩阵;第二条从 3.7.8rc1 到 3.14.7 完全一致。)
嵌套也是允许的,星号可以出现在任何一个子目标里:
a, (b, *c) = [1, [2, 3, 4]] -> {'a': 1, 'b': 2, 'c': [3, 4]}
a, [b, *c] = (1, (2, 3, 4)) -> {'a': 1, 'b': 2, 'c': [3, 4]}
a, *b, (c, d) = [1, 2, 3, 4, (5, 6)] -> {'a': 1, 'b': [2, 3, 4], 'c': 5, 'd': 6}
两种典型失败:
a, b = [1, 2, 3] -> ValueError: too many values to unpack (expected 2) a, *b = 5 -> TypeError: cannot unpack non-iterable int object
注意两者的异常类型不同:元素个数不对是 ValueError,对象根本不能迭代是 TypeError。这对写 except 分支很关键。
七、星号在字面量里:合并
a, b = [1, 2], (3, 4)
da, db = {'x': 1}, {'y': 2, 'x': 9}
[*a, *b] # [1, 2, 3, 4]
(*a, *b) # (1, 2, 3, 4)
{*a, *b} # {1, 2, 3, 4}
{**da, **db} # {'x': 9, 'y': 2} <- 后者覆盖前者
{**da, **db} 的"右边胜出"规则和函数调用的关键字规则一致,这也是"默认配置 + 用户配置"模式的标准写法:
cfg = {**DEFAULTS, **user_cfg}
八、字节码:三个专用指令
调用和解包都不是"调用内置函数",是解释器专门实现的一等语法。
实参解包(3.11.9):
# fn(*a)
PUSH_NULL
LOAD_FAST 0 (fn)
LOAD_FAST 1 (a)
CALL_FUNCTION_EX 0 <- 0 表示只有 * 没有 **
# fn(**k)
PUSH_NULL
LOAD_FAST 0 (fn)
LOAD_CONST 1 (())
BUILD_MAP 0
LOAD_FAST 1 (k)
DICT_MERGE 1 <- 把 k 合并进那个空字典
CALL_FUNCTION_EX 1 <- 1 表示带 **
# [*x, *y]
BUILD_LIST 0
LOAD_FAST 0 (x)
LIST_EXTEND 1
LOAD_FAST 1 (y)
LIST_EXTEND 1
# {**x, **y}
BUILD_MAP 0
LOAD_FAST 0 (x)
DICT_UPDATE 1
LOAD_FAST 1 (y)
DICT_UPDATE 1
注意 fn(**k) 里那个先被压栈的空字典和 DICT_MERGE:**k 不是"把 k 直接当关键字用",而是在运行时新建一个 dict,把展开的每一项合并进去。这就是 ** 有额外开销的直接原因。
解包赋值用的是 UNPACK_EX,它的操作数是一个 16 位整数,低字节 = 星号前的目标个数,高字节 = 星号后的目标个数:
# a, *rest = seq -> UNPACK_EX 1 (0x0001: 前 1, 后 0) LOAD_FAST 0 (seq) UNPACK_EX 1 STORE_FAST 1 (a) STORE_FAST 2 (rest) # a, *rest, z = seq -> UNPACK_EX 257 (0x0101: 前 1, 后 1) LOAD_FAST 0 (seq) EXTENDED_ARG 1 UNPACK_EX 257 STORE_FAST 1 (a) STORE_FAST 2 (rest) STORE_FAST 3 (z)
257 = 0x0101,两个 1 分别在低、高字节——把"前后各留一个"这个语义编码进了一个整数。顺便说,*rest 的结果永远是 list(哪怕源对象是 tuple 或字符串),这从 STORE_FAST 的目标值也能印证:UNPACK_EX 会为星号位置构造一个 list。
九、性能实测
同环境(3.11.9),各 100 万次,repeat=5 取最小值:
| 写法 | 耗时 |
|---|---|
target(1, 2, 3) | 0.0201 s |
target(*tup) | 0.0243 s |
target(*lst) | 0.0290 s |
target(*range(1, 4)) | 0.0785 s |
a_l + b_l | 0.0202 s |
[*a_l, *b_l] | 0.0269 s |
{**d1, **d2} | 0.0399 s |
d1.copy() 后 update(d2) | 0.0592 s |
dict(d1, **d2) | 0.0892 s |
结论:
*tup比直接传参贵约 21%,*lst贵约 44%——解包要遍历取元素,来源越"规整"越便宜;*range(1, 4)贵了近 3 倍(0.0785 s),因为要先把迭代器物化成实参。别在热路径里对生成器/range直接解包;a + b比[*a, *b]快约 33%,但后者能同时合并两个以上、且任何可迭代对象都行,语义更宽;- 字典合并里
{**d1, **d2}反而最快——比copy() + update()快约 33%,比dict(d1, **d2)(要构造关键字、还有键必须是字符串的限制)快一倍多。要合并字典,就用{**a, **b}。
十、四种星号语义速查
| 出现位置 | 例子 | 语义 | 运行时用什么 |
|---|---|---|---|
| 形参 | def f(a, *args, b=1, **kw) | 收集 | 参数区 + CO_VARARGS/CO_VARKEYWORDS |
| 实参 | f(*seq, **mapping) | 展开 | CALL_FUNCTION_EX + DICT_MERGE |
| 赋值目标 | a, *rest = seq | 收集(结果为 list) | UNPACK_EX |
| 字面量 | [*a, *b] / {**x, **y} | 合并 | LIST_EXTEND / DICT_UPDATE |
同一个符号,四种含义,唯一的共同点是"都跟可迭代对象的边界有关"。看到星号先问一句:它是在定义端还是调用端? 定义端意味着收集,调用端意味着展开,赋值和字面量则分别是这两者的赋值版和字面量版。
十一、实用招式
矩阵转置——星号最优雅的一次出手:
matrix = [[1, 2, 3], [4, 5, 6]] list(zip(*matrix)) # [(1, 4), (2, 5), (3, 6)]
zip(*matrix) 等价于 zip(matrix[0], matrix[1]),行数任你变化,代码一个字不用改。
打印分隔:
print(*['a', 'b'], sep='|') # a|b
函数透传——写装饰器时保持签名完整(前一篇提过):
def wrapper(*args, **kw):
return func(*args, **kw)
手工预检调用——比 try/except 更早发现问题:
sig = inspect.signature(f)
sig.bind(1, 2, b=3).arguments # {'a': 1, 'args': (2,), 'b': 3}
sig.bind(1, a=2) # TypeError: multiple values for argument 'a'
bind() 完全按真实调用规则校验,但不会真的执行函数——适合框架在派发前做参数校验。
十二、四个坑
坑一:可变默认值。这个跟星号无关,但和参数绑定同属"每次都有人栽"的级别:
def wrong(x, bucket=[]):
bucket.append(x)
return bucket
wrong(1) # [1]
wrong(2) # [1, 2] <- 第二次进来默认值已经被污染
wrong.__defaults__ # ([1, 2],) <- 默认值就存在函数对象上
默认值在函数定义时求值一次,之后所有调用共享同一个列表。改成 bucket=None 再加 if bucket is None: bucket = []。
坑二:重复关键字,一种报语法错、一种静默覆盖:
f(2, b=1, b=2) -> SyntaxError: keyword argument repeated: b
f(1, 2, **{'b': 3, 'b': 4}) -> {'a': 1, 'args': (2,), 'b': 4} <- 不报错,后者胜
字面量写两遍是编译期错误;字典里的重复键在构造字典时就已经被吃掉(后者覆盖前者),解释器根本看不到冲突。
坑三:关键字撞名字,两种不同文案,而且连前缀都不一样:
kf(1, a=2) -> TypeError: kf() got multiple values for argument 'a'
kf(1, **{'a': 2}, **{'a': 3})-> TypeError: __main__.kf() got multiple values for keyword argument 'a'
手动写关键字时是 argument、报错名是短名 kf();两个 ** 撞车时是 keyword argument、报错名却带上了 __main__. 前缀。原因和第四节结论二完全同源:前者是实参绑定在检查"这个关键字该给谁"时发现的重复,后者是实参准备阶段合并两个 dict 时发现的重复。一字之差、一前缀之差,来源不同,日志里别认错。
坑四:形参重名在 3.7 上会伪装成语法错:
def g(a, /, a): ... -> 3.7: SyntaxError: invalid syntax
3.8+: SyntaxError: duplicate argument 'a' in function definition
3.7 不支持 /,所以报的是笼统的 invalid syntax,真实原因被掩盖。跨版本排查时,先确认这份代码在目标版本上语法是否成立。
小结
一个签名 def f(a, *args, b=1, **kw),其实就是一张参数接收顺序表:
- 位置实参从左往右填,多余的进
*args; - 关键字实参填剩下的,多余的进
**kw; /之前只能按位置传,*之后只能按关键字传;*在定义端是"收集",在调用端是"展开",在赋值端是"收集成 list",在字面量里是"合并"。
真正会咬人的从来不是语法,而是那些只在特定版本、特定组合下才出现的措辞差异——所以上面的每一个文案都是从七个解释器里原样抄出来的,可以直接拿去对日志。
以上就是Python参数绑定实测指南(五种参数与三种星号)的详细内容,更多关于Python参数绑定实测的资料请关注脚本之家其它相关文章!
