接手一份陌生代码时,我习惯先扫一遍函数签名。那些带*和**的形参,往往藏着这个模块最关键的调用约定——谁能传、传什么、传了之后去哪了,全在这两个符号里。Python 里*和**做参数这件事,表面看是入门第三章的内容,但真到写库、写框架、写装饰器的时候,能把它用对、用稳的人并不多。
这篇内容不谈语法糖式的花活,只讲工程里的用法:函数定义时怎么收集参数、函数调用时怎么展开参数、裸星号怎么给接口加保险、解包时有哪些几乎必踩的坑,以及类型注解时代这些可变参数该怎么写才不坑队友。适合刚过语法阶段想搞懂底层约定的人,也适合写了几年但一直靠试错调参的人。看完至少能做到一件事:看到def f(*args, **kwargs)时,能立刻判断它合不合理,而不是照着抄。
1. 星号的两个身份:打包器和解包器
很多人绕不明白*和**,根本原因不是语法记不住,而是没把它的两个身份分开看。同一个符号,出现在函数定义的括号里是一种作用,出现在函数调用的括号里是另一种作用,方向完全相反。前者叫打包,后者叫解包。把这两个方向分清楚了,后面所有细节都只是推论。
1.1 函数定义处:把散装参数收进容器
在def语句的参数列表里写*args,语义是"从当前位置开始,后面所有多余的位置参数,统统收进一个元组"。写**kwargs,语义是"所有多余的关键字参数,统统收进一个字典"。
def collect(first, *args, **kwargs): print("first:", first) print("args:", args, type(args)) print("kwargs:", kwargs, type(kwargs)) collect(1, 2, 3, 4, name="a", age=18) # first: 1 # args: (2, 3, 4) <class 'tuple'> # kwargs: {'name': 'a', 'age': 18} <class 'dict'>这里有个非常关键的点经常被忽略:参数名args和kwargs本身没有任何魔法,换成*items、**options完全一样。它们只是变量名,是社区里约定俗成的写法。真正起作用的只有星号的数量。
还有一个更隐蔽的认知偏差:args拿到的是元组,不是列表。这意味着它在函数内部是不可变的。我看过不少人在*args上直接调用.append(),然后一脸困惑地看报错。如果你需要在函数内部对它做修改,先自己转一次:items = list(args),这行代码不丢人,反而说明你知道边界在哪。
至于**kwargs拿到的字典,它是函数内部新建的一份,和调用方传进来的那个字典不是同一个对象。这一点在调试时很有用:你在函数里改kwargs,不会污染调用方。但要注意,如果值本身是可变对象(比如列表),那改的是共享的引用,这个后面会展开说。
1.2 函数调用处:把容器拆成散装参数
调用时写f(*some_iterable),语义是"把这个可迭代对象拆开,一个个作为位置参数传进去"。写f(**some_mapping),语义是"把这个映射拆开,键值对变成一个个键=值的关键字参数"。
def move(x, y, z): return x + y + z point = [1, 2, 3] print(move(*point)) # 6 params = {"x": 1, "y": 2, "z": 3} print(move(**params)) # 6对比一下就明白为什么说方向相反:定义处的星号是"多个变一个容器",调用处的星号是"一个容器变多个参数"。它们互为逆操作。你完全可以写一个函数,接收*args,然后在内部再把它*出去传给另一个函数,参数一个不丢,这叫透传。
这里必须提前说一个容易翻车的细节:f(*"abc")是合法的,结果等价于f("a", "b", "c")。因为字符串本身就是可迭代对象。同理,f(*{"a": 1, "b": 2})会把字典的键拆出来,变成f("a", "b")——很多人想传字典又想拆值,结果拆出了一堆键名,对着输出发懵。字典要拆成参数得用**,拆成值的迭代得显式写成f(*d.values())。
1.3 一张表看清四种写法的对照关系
刚接触时最容易混的是"定义"和"调用"两个位置上的"单星"和"双星"。我把它整理成一张表,建议直接对照自己的代码过一遍。
| 出现位置 | 写法 | 名称 | 作用方向 | 得到的数据类型 |
|---|---|---|---|---|
| 函数定义 | *args | 可变位置参数 | 收集多个位置参数 | tuple |
| 函数定义 | **kwargs | 可变关键字参数 | 收集多个关键字参数 | dict |
| 函数调用 | *iterable | 位置参数解包 | 把可迭代对象展开 | 不产生新对象 |
| 函数调用 | **mapping | 关键字参数解包 | 把映射展开为键值 | 不产生新对象 |
再加一条:*在容器字面量里也是解包([1, *a, 2]、{**a, **b}),在赋值语句左侧是"收集剩余项"(a, *rest = data)。这两处未来会继续出现,现在先种个印象,后面第四节会专门讲。
2. 参数签名设计的取舍:什么时候该用、什么时候是坏味道
能用和该用是两码事。*args, **kwargs威力大,但它同时也是"放弃签名表达力"的信号。一个函数的参数表如果只有星号,读代码的人等于失去了最直接的文档。我在评审里对这两个符号的容忍度,取决于它承担的责任。
2.1 *args 的合理边界:数量不定、类型一致的场景
*args最干净的用法,是那些"参数个数天然不固定,但每个参数地位对等"的场景。比如求和、拼接、批量写入、取最大值。这类函数里,第一个参数和第五个参数没有语义差别,都是同类数据。
def total(*numbers): return sum(numbers) def build_path(*segments): return "/".join(s.strip("/") for s in segments)反过来说,如果每个位置参数有明确含义,那就不该用*args。我见过这样的写法:
def create_user(*args): name, age, city = args # 靠下标硬记这个函数对外只暴露了一个"随便传"的入口,调用方必须去读函数体才知道要传三个值、顺序是什么、少一个会怎样。把它改回def create_user(name, age, city)立刻就好读了,IDE 也会给出提示,类型检查器能工作。多写的那几个字符,换来的是整个团队少踩坑。
我的判断标准很粗暴:如果你在函数体第一行就把args拆回了具名变量,那说明这些参数本来就有名字,只是你偷懒没写出来。这种情况直接改成普通参数。
2.2 **kwargs 的两种用法:配置透传和"留给未来"
**kwargs有价值的使用场景,我认为主要落在两类。
第一类是中间层透传。你的函数不是终点,只是链条中间的一环,上层配置要原样交给下层。最典型的是装饰器、请求封装、数据库连接封装。
def retry(times=3, **request_options): def deco(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper return deco这里用**kwargs不是为了"看起来灵活",而是真的没法在写这一层的时候把所有下游参数穷举完。这种场景下,把参数原地传下去是正确的。
第二类是大量可选配置。比如绘图函数,几十个样式参数,大部分调用只改其中两三个。全写成具名参数会非常冗长。不过现代 Python 更推荐主动把常用的那几个显式写出来,剩下的走**kwargs。
def plot(data, *, title=None, **style_options): ...这样既保留了主干的可读性,又没有把自己的扩展路堵死。
坏味道是什么?是函数体里对kwargs做大量kwargs.get("xxx")的取值,并且这些键从来没在文档里出现过。这等于实现了一套隐形的、没有类型检查的、拼写错了就静默失效的参数协议。这种代码最多留三个月,之后没人敢动。
2.3 裸星号:强制关键字参数,给调用方加一道保险
*单独出现、后面不跟变量名,这叫keyword-only 分隔符。它表示"从这个星号往后,所有参数都必须以关键字形式传,不许位置传"。
def send(message, *, urgent=False, silent=False): ... send("hello", True) # TypeError send("hello", urgent=True) # 正确为什么这个设计值得单独拿出来说?因为布尔参数是最容易传反的东西。send("hello", True, False)这种调用,除了写代码的人自己,没人知道那两个布尔值谁是谁。哪天接口加了个参数,或者调整了顺序,静默错误就埋进去了。加上裸星号,调用处会自动带上参数名,可读性直接翻倍,而且后续往签名里加新参数不会打乱任何已有调用。
提示:新增参数时优先加在
*之后并给默认值。老代码一行都不用改,新代码按需使用。这是我在维护公共工具函数时最常用的一招。
*后面也可以只跟一个**kwargs,比如def f(a, *, **kw)。这种写法语法合法但意义不大,一般不这么写。另外注意,裸星号在参数表里最多出现一次,重复写会直接SyntaxError。
2.4 斜杠分隔符:把位置参数锁死,兼容旧接口
从 Python 3.8 开始,参数表里可以出现/,表示它前面的参数只能用位置方式传,不能用关键字方式传。
def power(base, exponent, /): return base ** exponent power(2, 10) # 1024 power(base=2, exponent=10) # TypeError日常业务代码很少需要它。它真正的战场是库的兼容层:你有一个参数历史上叫name,后来想改成text,但外部大量代码在用name=...调用。这时候你把参数挪到/前面,就能防止新代码再用旧名字传参,为将来彻底改名铺路。标准库里的len(obj, /)、list.index(value, start, stop, /)都是这个思路。
用不上的时候别硬用。过早加/会让调用方失去用关键字表达意图的机会,可读性反而下降。判断依据只有一个:这个参数名是不是你打算长期维护的对外契约。不是的话,没必要锁。
3. 参数传递顺序规则与必守的语法约束
参数表的书写顺序不是随便排的,Python 对它有硬性规定。记住这套规则,能避免大量SyntaxError,也能帮你在读别人代码时迅速还原意图。
3.1 五段式顺序,背下这个模板就够了
完整的参数列表从左到右,依次是:
- 位置参数(可以有默认值)
/分隔符(可选)*args或裸*- keyword-only 参数(可以没有默认值)
**kwargs
一个把所有元素都用上的例子:
def demo(a, b=2, /, c=3, *args, d, e=5, **kwargs): return a, b, c, args, d, e, kwargs demo(1, 2, 3, 4, 5, d=6, f=7) # (1, 2, 3, (4, 5), 6, 5, {'f': 7})拆开看:1 和 2 是位置参数;a 和 b 被/锁死为仅位置;3 给了 c;4、5 被*args收进元组;d因为在*args之后,变成了必填的 keyword-only 参数;e是带默认值的 keyword-only;f=7落进kwargs。
最常见的错误是把普通位置参数写在*args后面:
def bad(*args, c): ...这其实不报错,但c变成了 keyword-only,必须写c=...才能传。很多人以为它还是普通参数,调用时按位置传,结果 TypeError。真正会报SyntaxError的是这种:
def bad2(*args, a, b=1, c): # 语法上是合法的 ... def bad3(**kwargs, a): # SyntaxError**kwargs必须是最后一个,任何参数都不能写在它后面。这条没有例外。
3.2 冲突检测:同名参数被赋值两次会怎样
这里有两层要分清,而且行为不一样,踩过坑的人应该印象深刻。
第一层:函数调用时的重复赋值。你既用位置传了,又用关键字传了同一个参数:
def f(x, y): ... f(1, x=2) # TypeError: f() got multiple values for argument 'x'注意这里报的错是x重复,不是你直觉以为的y。因为 1 被按位置分配给第一个参数x,然后x=2又要给它赋值一次。这个报错信息其实很准确,只是第一次看容易误解。凡是看到got multiple values for argument 'X',直接去数那个参数在位置上被覆盖了几次,基本就能定位。
第二层:多个**解包时的键冲突。Python 3.5 之后,函数调用里允许写多个**:
base = {"a": 1} extra = {"a": 2} {**base, **extra} # {'a': 2},字面量合并,后者覆盖前者 f(**base, **extra) # TypeError: got multiple values for keyword argument 'a'同样一件事,在字典字面量里是"后者覆盖前者",在函数调用里是直接报错。这个不对称是设计上有意为之:字面量合并你明确表达了覆盖意图,而函数调用里重复的键几乎一定是 bug,报错比静默覆盖安全得多。我在做配置合并时特别依赖这个区别——合并配置用{**a, **b},传参就老老实实一次传完。
3.3 解包可迭代对象时的三个隐藏成本
*解包的写法很漂亮,但它有几个不容易察觉的副作用,值得单独拎出来。
第一个,可迭代对象会被耗尽。如果传进来的是生成器,*会一次性把所有元素取出,生成器之后就是空的。
gen = (i for i in range(3)) print(*gen) # 0 1 2 print(*gen) # 空,什么都没打印第二个,大批量数据会整体进入内存。一个百万行的文件对象直接f(*open(...)),等于先把整个文件读进内存变成一个巨大的调用参数列表。这不是语法问题,是资源问题,但报错现场通常是一句莫名奇妙的内存错误,很难联想到解包。
第三个,参数数量过大时会遇到解释器层面的限制。具体阈值和版本、平台相关,不要记数字,记住原则就行:参数列表是用来表达"几个有意义的输入"的,不是用来搬运数据集合的。数据集合应该作为单个参数传进去,比如process(rows)而不是process(*rows)。
4. 高频实战场景拆解
语法讲完,进入真正会写到的场景。这几个位置是*和**出现频率最高的地方,也是写错代价最大的地方。
4.1 装饰器:参数透传的正确姿势
装饰器是*args, **kwargs最经典的舞台。包装函数不知道被包装函数有什么参数,只能原样接收、原样转交。
import functools import time def timed(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): start = time.perf_counter() try: return fn(*args, **kwargs) finally: cost = (time.perf_counter() - start) * 1000 print(f"{fn.__name__} 耗时 {cost:.2f} ms") return wrapper @timed def fetch(url, timeout=3): ... fetch("http://example.invalid", timeout=5)这里有三个点必须做到位,缺一个都会出问题。
functools.wraps不能省。它把原函数的__name__、__doc__、__module__、__qualname__复制到 wrapper 上。不写这行,所有被装饰的函数在日志里都叫wrapper,排查问题时会疯。__wrapped__属性也会被设置,一些自省工具依赖它。
return前面不要忘了return。包装函数不加return,原函数的返回值就丢了,而且不报错,静默返回None。我在别人的代码里见过因为这一行之差导致接口全部返回空的事故。
参数转发要用*args, **kwargs而不是把它们当整体传。fn(args)传的是一个元组,fn(*args)才是原参数。同理fn(kwargs)是错的,fn(**kwargs)才对。
如果装饰器自身要带参数,就需要再套一层:
def retry(times=3, delay=0.5): def deco(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): last = None for _ in range(times): try: return fn(*args, **kwargs) except Exception as e: last = e time.sleep(delay) raise last return wrapper return deco注意这里内层的wrapper依然要用*args, **kwargs,别因为外层多了参数就忘了这层。
4.2 继承与包装函数:super() 与星号参数一起用的场景
写类的时候,super().__init__(*args, **kwargs)是一种常见但需要谨慎的写法。它解决了"我不关心父类要什么参数,把它们原样递上去"的问题。
class BasePipeline: def __init__(self, name, *, debug=False, **options): self.name = name self.debug = debug self.options = options class ImagePipeline(BasePipeline): def __init__(self, name, *args, **kwargs): super().__init__(name, *args, **kwargs) self.kind = "image"这样父类以后新增参数,子类不需要跟着改。代价是子类的签名变得模糊,IDE 没法提示父类支持哪些参数。
我的做法是:如果父类参数是稳定的,就显式写出来;只有当父类是框架代码、参数经常变动,或者我在写多继承的 mixin 时,才用透传。多继承尤其要注意,super()会沿着 MRO 链一路走下去,每一层都得把*args, **kwargs完整往下传,少传一层后面的初始化就断了。这是 mixin 模式里最隐蔽的 bug 来源。
还有一个小细节:super().__init__(name, *args, **kwargs)里name被显式取走了,如果args里恰好还含有一个位置参数,它会被安排到父类的第二个参数上。写基类时把核心参数放在最前面并且用 keyword-only 保护起来,能大大降低这种错位风险。
4.3 容器字面量里的解包:合并字典、拼列表的干净写法
Python 3.5 引入的 PEP 448 把解包能力扩展到了容器字面量,这是日常写代码收益最高的部分之一。
defaults = {"timeout": 3, "retries": 2} overrides = {"timeout": 10} config = {**defaults, **overrides} # 后者覆盖前者 print(config) # {'timeout': 10, 'retries': 2} merged = [*list_a, *list_b] # 拼接列表 combined = (*t1, *t2) # 拼接元组 union = {*set_a, *set_b} # 并集用字典解包做配置合并,比dict(defaults, **overrides)和defaults.copy()加update()都更直观,而且是一步到位的表达式,可以嵌在函数调用里。
在容器字面量里,字典的键不要求是字符串:
d = {**{1: "a"}, **{2: "b"}} # 合法这一点和函数调用里的**完全不同——函数调用的关键字必须是字符串,这个差异后面第 5 节还会专门提。
4.4 打印与调试:print(*items) 与日志格式化
print(*items)是我日常用得最多的一行。默认print的sep是空格,把列表直接展开正好得到空格分隔的输出,不用手写" ".join(str(x) for x in items)。
items = ["a", "b", "c"] print(*items) # a b c print(*items, sep=", ") # a, b, c print(*items, sep="\n") # 每个一行 print("结果:", *items, sep=" | ") # 结果: | a | b | c最后一行要注意:混合了普通参数和解包时,sep会作用于所有输出项之间,包括你自己写的那段文字,所以经常需要在前面手动补分隔符。这类小细节在实际调试时非常容易让人困惑。
顺带提一个调试技巧:排查参数到底传了什么,直接在第一时间打印print(args, kwargs),比在 IDE 里加断点还快。元组和字典自带完整信息,一眼就能看出是位置参数串了还是关键字漏了。
4.5 赋值语句里的星号:收集剩余项
*在赋值左侧还可以做"收集剩余",这个能力经常和函数参数一起被提起。
first, *rest = [1, 2, 3, 4] print(first, rest) # 1 [2, 3, 4] head, *middle, tail = "abcde" print(head, middle, tail) # a ['b', 'c', 'd'] e注意两点:赋值里的*最多出现一次,手写两个会SyntaxError;rest拿到的永远是列表,哪怕原对象是元组或字符串。多个*虽然不允许,但可以嵌套用:先用一个星号把尾巴收出来,再对它做一次同样的操作。处理命令行参数、拆分 CSV 行、解析配置文件时,这个技巧比split加下标干净得多。
5. 踩坑实录与排查速查表
这一节全部是实际操作中撞过的墙。有些报错信息指向性很强,有些则相当具有欺骗性。
5.1 三类报错及其定位思路
第一类:TypeError: xxx() got an unexpected keyword argument 'yyy'。说明你的函数签名里没有**kwargs,但调用时传了一个它不认识的键。常见于拼写错误(timoutvstimeout),也常见于你把配置字典整个展开传进入了一个只接受固定参数的函数。定位方法:把**config临时换成print(config)先看一眼键名。
第二类:TypeError: keywords must be strings。这是**在函数调用里展开了一个含有非字符串键的字典。它和容器字面量合并的行为不同,是函数调用独有的限制。
def f(**kw): return kw f(**{1: "a"}) # TypeError: keywords must be strings f(**{str(1): "a"}) # {'1': 'a'},正常第三类:TypeError: f() takes 2 positional arguments but 3 were given。说明解包出来的元素个数超过了函数能接收的位置参数上限。这个报错信息其实已经很友好,数一下就行。麻烦的是它经常出现在多层透传里,你不知道是哪一层多传了。定位技巧:在每一层入口处打印len(args),逐层比对。
5.2 五个容易被忽略的坑
坑一:在*args后面加了普通参数,以为它还能按位置传。
def f(*args, flag): ... f(1, 2, True) # TypeError: 缺少 flag f(1, 2, flag=True) # 正确*args之后的所有参数都是 keyword-only,这个规则没有例外。
坑二:把可变对象作为kwargs的值,以为是隔离的。
def f(**kw): kw["items"].append(1) data = {"items": []} f(**data) print(data) # {'items': [1]}**展开时,字典是新造的,但里面装的是同一批对象的引用。值本身可变,修改就会穿透到调用方。这和直接传一个列表进去是完全一样的行为,只是被**的"看起来像复制"给骗了。
坑三:默认参数写成可变对象。这个坑和星号无关,但经常一起出现。
def f(a, box=[]): # 危险 box.append(a) return box f(1) # [1] f(2) # [1, 2],不是 [2]默认值在函数定义时求值,只创建一次。正确写法是box=None,函数内部再if box is None: box = []。我见过新手把它和*args混为一谈,以为*args也有这个问题——*args每次调用都是新建元组,没有这个隐患。
坑四:用*解包字符串。
def f(a, b): ... f(*"hi") # a='h', b='i'能跑,但绝大多数时候不是你想要的结果。同理,*一个字典得到的是键。解包前先确认对象类型,尤其是在处理用户输入或配置项时。
坑五:把**kwargs当成万能垃圾桶。
def handle(**kwargs): action = kwargs.pop("action") payload = kwargs.pop("payload", None) # 剩下的全忽略,传错了也不报错这种写法会静默吞掉所有拼写错误的参数。我在一次接口联调里,因为把payload写成paylaod,排查了将近半小时,最后发现函数默默忽略了这个键。后来我养成了习惯:凡是显式消费的键,都在签名里写出来,**kwargs只留给真正的下游透传。
5.3 速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
got multiple values for argument 'x' | 位置和关键字重复赋值 | 检查x是第几个位置参数 |
got an unexpected keyword argument | 签名里没这个键 | 核对拼写,或补**kwargs |
keywords must be strings | **展开的字典含非字符串键 | 转换键类型,或改用位置传 |
takes N positional but M given | 解包元素超出上限 | 逐层打印len(args) |
所有被装饰函数名都叫wrapper | 漏了functools.wraps | 补上装饰器 |
装饰后返回值变成None | wrapper 里漏了return | 检查return fn(*args, **kwargs) |
| 生成器第二次解包为空 | 生成器已被耗尽 | 先list()落地再用 |
6. 类型注解与团队协作规范
到了有类型检查的代码库,*args和**kwargs的注解语义和普通参数不一样,写错了不会报运行时错误,但会让静态检查形同虚设。
6.1 *args 和 **kwargs 的注解到底修饰谁
这是最容易写错的一处。*args: int的意思是每一个元素都是 int,不是"args 是 int",也不是"args 是 list[int]"。**kwargs: int同理,表示每一个值都是 int,键固定是字符串。
def f(*args: int, **kwargs: str) -> None: ... f(1, 2, 3, a="x") # 类型正确 f(1, "two", a="x") # 类型检查器报错很多人第一反应会写成*args: tuple[int, ...]或者**kwargs: dict[str, int],这是错的,类型检查器会直接告诉你。记住口诀:星号参数的注解描述的是元素,不是容器。
如果真的需要表达"元素类型不一样",那说明这个函数不该用*args。这是一个很好的自检信号:当你想给*args写Union类型时,通常意味着应该改成显式参数或者传一个结构化的对象。
6.2 用 TypedDict 和 Unpack 把 kwargs 变成有结构的契约
Python 3.12 起,typing里有了Unpack(3.11 及以下可从typing_extensions导入),它能把一个TypedDict展开成**kwargs的注解。这一招直接解决了"kwargs 是黑洞"的问题:键名和类型都被固定住了,传错的键会被静态检查逮住。
from typing import TypedDict, Unpack class ConnectOptions(TypedDict, total=False): host: str port: int timeout: float def connect(**options: Unpack[ConnectOptions]) -> None: host = options.get("host", "localhost") port = options.get("port", 5432) ... connect(host="db", port=5432) # 正确 connect(hots="db") # 类型检查器报错:未知的键total=False表示所有键都是可选的。如果部分必填,用Required和NotRequired精确控制。这个写法的好处很实在:函数体内部完全可以照常当字典用,但对外多了一份可校验的契约,调用方在 IDE 里也能获得键名补全。我在给内部工具库定义配置入口时,一律用这个模式替代裸**kwargs。
同样的机制也能用到*args上,Unpack[tuple[int, str]]表示"恰好两个参数,类型分别是 int 和 str"。不过实际用到的场景不多,参数一旦要精确定位,直接写具名参数更清楚。
6.3 ParamSpec:让装饰器不破坏类型信息
装饰器用*args, **kwargs透传,但类型检查器会因此丢掉原函数的签名。ParamSpec就是为这个场景设计的。
from typing import Callable, ParamSpec, TypeVar import functools P = ParamSpec("P") R = TypeVar("R") def timed(fn: Callable[P, R]) -> Callable[P, R]: @functools.wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return fn(*args, **kwargs) return wrapper关键在于Callable[P, R]到Callable[P, R]的输入输出保持一致,P把原参数的形状原封不动带了过去。这样在调用被装饰函数时,IDE 依然能提示正确的参数名和类型。没有ParamSpec的话,装饰后的函数在类型层面就退化成(*args: Any, **kwargs: Any) -> Any,静态检查基本失效。
wrapper里的P.args和P.kwargs是特殊标记,只能用在*args和**kwargs的注解位置,不能单独拿出来用。这一点如果忘了,类型检查器会给出很奇怪的报错。
6.4 我自己的评审清单
最后把我实际评审时用的几条拿来共享,基本能过滤掉 90% 的问题。
- 函数体前几行就把
*args拆成具名变量的,要求改成显式参数。 **kwargs里出现kwargs.pop()的,要求把消费的键写进签名,或者改用TypedDict约束。- 公共函数的布尔开关一律加
*强制关键字传参。 - 装饰器必须检查三件事:
functools.wraps、return、*args/**kwargs是否正确转发。 - 多层透传的链路,在关键节点打印一次
args和kwargs,确认没有参数在这一层被截断。 - 给
*args写Union类型的,视为设计问题,要求重构签名。
个人体会是,这两个符号真正的价值不在"灵活",而在于在明确知道边界的地方使用它。my_print(*items)、{**defaults, **overrides}、装饰器里的*args, **kwargs,这三个场景我用了很多年,几乎没出过问题。而凡是让**kwargs承载业务语义、又没做任何约束的代码,早晚会变成没人敢动的黑箱。