python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python参数绑定实测

Python参数绑定实测指南(五种参数与三种星号)

作者:Bruce_xiaowei

还在为Python星号*的各种用法头疼吗,本文一次拆解星号在参数收集、实参解包、赋值和字面量合并中的四种语义,并通过跨版本实测报错文案和性能数据,帮你避开常见陷阱,写出更高效的代码,需要的朋友可以参考下

一切都是从这样一个签名开始的:

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}}

规则可以严格按这个顺序执行:

  1. 先用位置实参从左到右填满"位置或关键字"和"仅位置"形参;
  2. 多余的位置实参进 *args;
  3. 再处理关键字实参,填"位置或关键字"和"仅关键字"形参;
  4. 剩下的关键字进 **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

两个细节值得记:

3.7 上这个语法直接过不了编译:

3.7.8rc1 : def g(a, /, b, *, c): ...  -> SyntaxError: invalid syntax

四、报错文案矩阵(跨版本,实测)

这是最容易在升级时"悄悄变了"的部分。先看调用期报错,同样是模块级函数,却分化出两种命运。

表 A:模块级函数

触发写法3.7.8rc1 / 3.8.103.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 intValue 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 stringskeywords must be strings

表 B:实例方法

触发写法3.7.8rc1 / 3.8.103.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.103.10.183.11.12 及以后
a, *b, *c = [1,2,3]two starred expressions in assignmentmultiple starred expressions in assignmentmultiple starred expressions in assignment
def g(a, b, *, *, c)invalid syntaxinvalid syntax* argument may appear only once
def g(*, a, /, b)invalid syntaxinvalid 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': {}}
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_l0.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

结论:

  1. *tup 比直接传参贵约 21%,*lst 贵约 44%——解包要遍历取元素,来源越"规整"越便宜;
  2. *range(1, 4) 贵了近 3 倍(0.0785 s),因为要先把迭代器物化成实参。别在热路径里对生成器/range 直接解包;
  3. a + b 比 [*a, *b] 快约 33%,但后者能同时合并两个以上、且任何可迭代对象都行,语义更宽;
  4. 字典合并里 {**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),其实就是一张参数接收顺序表:

真正会咬人的从来不是语法,而是那些只在特定版本、特定组合下才出现的措辞差异——所以上面的每一个文案都是从七个解释器里原样抄出来的,可以直接拿去对日志。

以上就是Python参数绑定实测指南(五种参数与三种星号)的详细内容,更多关于Python参数绑定实测的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文