**keywords这个写法,很多人第一次是在别人的函数签名里撞见的,看着像什么高级语法糖,剥开其实就一句话:把调用时多出来的关键字参数,一锅端地收进一个字典。你平时看到的**kwargs跟它是同一个东西,kwargs只是大家约定俗成的缩写,换成keywords、options、extra完全等价,解释器根本不看你叫它什么,只认前面那两个星号。这篇内容就把这个符号的两副面孔彻底讲透:函数定义侧它是收集器,函数调用侧它是展开器;顺带说明TypeError: got multiple values for keyword argument到底是谁在报、装饰器里为什么要写*args, **keywords而不是只写**keywords、配置合并时哪一行代码悄悄污染了全局默认值。适合三类人:写过函数但没系统研究过参数机制的新手、被各种关键字参数报错反复折磨过的中级开发者、以及准备写公共库或框架、需要给使用者留扩展点的人。下面这些全部是我在实际项目里踩坑踩出来的,代码可以直接拿去跑。
1. 把双星号这件事拆到骨头里
1.1 一个星号和两个星号的分工
*args和**keywords经常被放在一起讲,但它们的职责划分其实非常清晰,我习惯用一个搬家的场景来解释。搬家公司的货车来了,你有一堆箱子没贴标签,工人只能按到达顺序把它们码成一排,谁在第一个谁在第二个,位置就是身份——这就是*args,它把调用时多出来的位置参数按顺序收进一个元组。另一批箱子每个都贴着名字标签,工人不看顺序,只看标签上的字——这就是**keywords,它把多出来的关键字参数按名字收进一个字典。
两者的判断依据完全不同。位置参数靠"你排第几"来匹配形参,关键字参数靠"你叫什么名字"来匹配形参,所以**keywords收到的永远是一个键为字符串的字典,键就是调用方写的那个参数名。写def f(*args, **keywords)的函数,等于对调用方说"你随便传,位置的我按顺序接,带名字的我按标签接",这也是为什么装饰器里的包装函数几乎都是这个签名——它是对参数最宽容的写法。
再补一句容易搞混的:*和**在函数定义里是"收集",在函数调用里是"展开",同一个符号出现在不同位置,语义正好相反。下面会分别讲。
1.2 名字是自由的:keywords、kwargs、options 随便叫
先说一个很多人绕了很久的误区:**kwargs里的kwargs不是关键字,是普通的形参名,跟a、b、count没有任何身份差异。
def f_one(**kwargs): return kwargs def f_two(**keywords): return keywords def f_three(**options): return options f_one(a=1) # {'a': 1} f_two(a=1) # {'a': 1} f_three(a=1) # {'a': 1}三个函数在字节码层面完全一样。kwargs只是 "keyword arguments" 的缩写,因为写的人多了就成了行业习惯,**keywords是另一种常见写法,语义上更直白一点。你在自己项目里用哪个都行,唯一的要求是团队内保持一致,别同一个代码库里两种混着用。
名字自由带来一个不太直观的副作用:如果形参名叫keywords,而调用方恰好传了一个同名参数,它不会报错,而是被收进字典里,变成{'keywords': 1}这种看着有点别扭但完全合法的结果。所以别拿keywords这个名字再去当业务参数名,容易自己给自己下套。同样的坑在def f(**kwargs)里也一样成立,f(kwargs=1)得到的是{'kwargs': 1},不是错误。
1.3 收集与解包:同一个符号的两个方向
把双星号理解成"一个符号、两个方向"是最省力的心智模型。
定义侧(收集):def f(**keywords)中的**在告诉解释器"把调用时所有没被前面形参认领的关键字参数,打包成字典塞给keywords"。注意关键词是"没被认领",已经匹配给固定形参的不会进字典。
def f(a, **keywords): print(a, keywords) f(1, b=2, c=3) # 1 {'b': 2, 'c': 3} f(a=1, b=2) # 1 {'b': 2}调用侧(展开):f(**mapping)中的**在告诉解释器"把这个映射拆开,每个键值对当作一个键=值传递"。它要求被展开的对象是一个映射(mapping),键必须是字符串。
def g(x, y): return x + y params = {'x': 10, 'y': 20} g(**params) # 30这两个方向是互逆的,经常配合使用:def wrapper(*args, **keywords): return func(*args, **keywords)这行代码里,左边是收集,右边是展开,一句完成了"接收一切 → 原样转发"。理解了这个对称性,后面所有花式写法都是它的变体。
2. 函数定义侧的**keywords:收集端的规矩
2.1 参数顺序是硬约束,写错就是 SyntaxError
参数列表的排列顺序不是随便写的,Python 有一条强制规则,违反了直接在函数定义那一行抛SyntaxError,连运行都跑不到。
# 合法 def f(a, b=1, *args, c, d=2, **keywords): ... # 非法:**keywords 必须在最后 def f(**keywords, a): ... # SyntaxError # 非法:默认参数后又出现位置参数 def f(a=1, b): ... # SyntaxError # 非法:但 *args 之后的参数可以是无默认值的关键字专属参数 def f(*args, c): ... # 合法,c 必须用关键字传记住一条序列:普通位置参数 → 带默认值的参数 →*args(或单独的*)→ 关键字专属参数 →**keywords。**keywords永远排在最后,因为它的语义是"收尾的兜底收集器",如果它前面还有能接收关键字参数的形参,解释器就没法确定谁该优先了。
我见过不少人图省事写def f(**keywords, a=1),想表达"a 有默认值,其他随便传",这直接语法错误。正确的写法是def f(a=1, **keywords),a是普通带默认值的参数,**keywords收剩下的。这两种写法的调用效果几乎一致,但前者根本不成立。
2.2 关键字专属参数与**keywords的边界
一个单独的*是个分水岭,它后面跟的形参只能通过关键字传入,这叫关键字专属参数(keyword-only)。它和**keywords经常一起出现,但职责完全不同:关键字专属参数是"我明确要这几个,而且必须带名字给",**keywords是"剩下的我全要,不管有几个"。
def build(name, *, timeout=30, **extras): print(name, timeout, extras) build('job') # job 30 {} build('job', timeout=10) # job 10 {} build('job', timeout=10, retry=3) # job 10 {'retry': 3} build('job', 10) # TypeError: 多了位置参数这种写法在写库的时候特别有用:timeout这类核心参数明确列出来,能被 IDE 补全、能被类型检查覆盖、能被文档工具抓出来;extras留给使用者传那些未来可能新增的选项,不用每次都改函数签名。
我自己定的一条经验是:核心参数用显式形参,扩展字段用**keywords,两者别混。如果一个函数里**keywords收了十几种键,那就不是"灵活",是"没有设计",调用方根本不知道能传什么,全靠翻源码或者文档,这在长期维护里是灾难。
2.3 每次调用都是新字典:这条特性救过很多人
**keywords收到的字典,是每次调用时新建的。这点看着是细节,实际非常关键,它顺手避开了 Python 里最经典的坑之一——可变默认值。
对比一下:
# 经典坑:默认值只创建一次,被所有调用共享 def bad(item, bucket=[]): bucket.append(item) return bucket bad(1) # [1] bad(2) # [1, 2] 越积越多 # 没有这个问题:每次调用都是新字典 def good(**keywords): keywords.setdefault('bucket', []).append(1) return keywords所以def f(**keywords)不存在"上一次调用的参数残留到下一次"这种问题,你可以放心在函数里往keywords上随意增删改,不会影响其他调用。
但有一个变体坑要小心:字典是新建的,字典里装的值不一定。如果调用方传的是可变对象,函数里修改那个对象的内容,会原样传播回调用方。
def f(**keywords): if 'config' in keywords: keywords['config']['debug'] = True # 直接改了调用方的 dict my_config = {'debug': False} f(config=my_config) print(my_config) # {'debug': True} 被改了需要隔离就先做一次浅拷贝,嵌套结构深拷贝,别嫌麻烦,这种 bug 在线上排查起来能耗掉半天。
3. 调用侧的**解包:报错最密集的地方
3.1 键必须是字符串,这是硬性检查
调用侧展开的时候,解释器会对映射的键做检查:键必须是字符串,否则直接TypeError。
def f(**keywords): return keywords f(**{'a': 1, 'b': 2}) # {'a': 1, 'b': 2} f(**{1: 'x'}) # TypeError: keywords must be strings f(**{'a': 1, 2: 'x'}) # TypeError: keywords must be strings原因不复杂:参数名在 Python 里表示的是一串字符,"第 1 个参数"和"名字叫 1 的参数"是两码事,位置参数由*args去接,关键字参数的键只能是字符串。
不过这里有个容易踩的灰色地带:CPython 只检查键是字符串,不检查它是不是合法的标识符。所以f(**{'a-b': 1})这种在 CPython 3 上居然能跑通,得到{'a-b': 1}。但这属于实现细节,别去依赖它——这种键在后面**二次转发、inspect.signature校验、类型检查器分析的时候都会出问题,而且一旦有个函数显式声明了a_b形参,这个参数永远匹配不上。我见过有人用这种写法传 HTTP 头之类的名字,短时间能用,重构时全炸。
顺带说,展开的对象不必是dict本身,任何实现了映射协议的对象都可以:
from collections import UserDict class MyOptions(UserDict): pass f(**MyOptions({'x': 1})) # {'x': 1}判断标准其实很宽松,有keys()和__getitem__就行。条例上写的是"mapping",不是"dict"。
3.2 重复键在函数调用里是错误,在字典字面量里是覆盖
这一条是报错率最高的地方,而且两处行为完全相反,必须记清楚。
函数调用侧:重复键 = 直接报错。
def f(a=0, **keywords): return a, keywords f(a=1, **{'a': 2}) # TypeError: f() got multiple values for keyword argument 'a' f(**{'a': 1}, **{'a': 2}) # TypeError: f() got multiple values for keyword argument 'a'解释器的逻辑是:同一个参数名出现两次,到底听谁的?它选择不猜,直接拒绝。哪怕两个值一模一样也照样报错,检查的是"键出现过两次"这个事实,不是"值是否冲突"。
字典字面量侧:重复键 = 后面的覆盖前面的。
{**{'a': 1}, **{'a': 2}} # {'a': 2} {**{'a': 1}, 'a': 3} # {'a': 3}同样是**,在字典字面量里是"合并",后写的赢;在函数调用里是"传参",重复就炸。我刚接触的时候在这上面栽过,写配置合并的时候用f(**defaults, **user_options)想实现"用户覆盖默认",结果一有重复键就崩,改成f(**{**defaults, **user_options})才对——先合并成新字典,再展开一次。
还有一个更隐蔽的变体:位置参数和关键字参数指向同一个形参,也会报错,但错误信息不一样。
def g(a, b): return a + b g(1, a=2) # TypeError: g() got multiple values for argument 'a'注意这里是argument,不是keyword argument,措辞不一样。看到这两种报错,第一反应都是"同一个名字被喂了两次",然后顺着调用链去找那个重复的键。
3.3 解包的顺序与优先级
多个**同时出现在一次调用里,Python 3.5 之后是允许的,展开顺序从左到右,但因为没有覆盖行为,重复键照样报错,所以顺序本身不影响结果——除非值本身有副作用(比如展开的是个带副作用的映射对象,这属于极端情况)。
真正需要在意顺序的是普通关键字参数和解包混用:
def h(**keywords): return keywords h(x=1, **{'y': 2}) # 合法,{'x': 1, 'y': 2} h(**{'y': 2}, x=1) # 合法,Python 3.5+ 允许关键字参数出现在 ** 之后在 Python 3.5 之前,**只能放在最后,现在放中间也行。虽然语法放开了,但我还是建议把**写在最后,一是读起来顺,二是很多人的认知还停在老版本上,别在代码风格上制造无谓的沟通成本。
4. 实战场景:**keywords真正该出场的地方
4.1 装饰器与包装函数:透传的标准姿势
装饰器是**keywords出现密度最高的地方,因为包装函数根本不知道被包装的函数长什么样,只能全收全发。
import functools import time def timed(func): @functools.wraps(func) def wrapper(*args, **keywords): start = time.perf_counter() try: return func(*args, **keywords) finally: cost = time.perf_counter() - start print(f'{func.__name__} 耗时 {cost:.4f}s') return wrapper这几行里有三个点值得单独说。
第一,*args和**keywords必须成对出现。只写**keywords的话,被包装函数一旦被位置参数调用就会直接崩,因为位置参数根本没地方放。
def broken(func): def wrapper(**keywords): # 少了 *args return func(**keywords) return wrapper @broken def add(a, b): return a + b add(1, 2) # TypeError: wrapper() takes 0 positional arguments but 2 were given第二,functools.wraps必须加上。它不只是好看,它把原函数的__name__、__doc__、__module__、__qualname__以及__wrapped__都复制过去了。少了它,日志里全是wrapper,排查问题的时候你根本不知道是哪个函数出的错,help()和自动生成的文档也会一片空白。
第三,包装器会让签名"失真"。哪怕加了functools.wraps,包装函数在运行时实际的签名还是(*args, **keywords),只是inspect.signature会顺着__wrapped__去找原函数。有些基于函数签名做参数注入的框架(比如做依赖注入的 Web 框架、做 fixture 注入的测试框架),如果它内部用了follow_wrapped=False或者自己解析了__code__.co_varnames,就会完全看不到原参数名,注入直接失效。我遇到过一次,排查了两小时才发现是装饰器顺序问题——把注入装饰器放到最外层就好了。这类问题没法靠读文档预判,只能实际测。
4.2 配置合并:三种写法的对比与选型
配置合并是**最实用的场景之一,写法有好几种,坑也多,我把它们摊开对比一下。
DEFAULTS = {'timeout': 30, 'retries': 3, 'verbose': False} def connect(**overrides): cfg = {**DEFAULTS, **overrides} return cfg| 写法 | 代码 | 优点 | 隐患 |
|---|---|---|---|
| 字典合并 | {**DEFAULTS, **overrides} | 一行搞定,不改原对象,重复键自动覆盖 | 只做浅合并 |
| dict 构造 | dict(DEFAULTS, **overrides) | 语义接近 | 键必须全为字符串,否则炸 |
| 原地更新 | DEFAULTS.update(overrides) | 最快 | 永久污染模块级默认值,后面所有调用都中招 |
| 显式循环 | for k, v in overrides.items(): ... | 可以逐项校验、做类型转换 | 代码长 |
我最开始图方便用的是DEFAULTS.update(overrides),本地测没问题,上线以后另一个调用方发现默认配置莫名其妙变了——因为第一次调用的overrides把模块级的DEFAULTS改掉了,后续所有请求都带着上一次的残留。这种 bug 复现条件苛刻,测试环境跑一百次可能都撞不上。
dict(DEFAULTS, **overrides)这个写法我也用,但在键不全是字符串的时候会挂:
DEFAULTS = {1: 'a'} dict(DEFAULTS, **{2: 'b'}) # TypeError: keywords must be strings配置里的键一般还是字符串,所以这个问题不常见,但如果你的配置拿来存"用户 ID → 配置"这种映射,就很容易撞上。所以现在我的默认选择是{**DEFAULTS, **overrides},它对键的类型没要求,也绝不修改原对象。
还有一个必须提醒的:这些都是浅合并。嵌套字典只会被整体替换,不会递归合并:
DEFAULTS = {'db': {'host': 'localhost', 'port': 5432}} overrides = {'db': {'host': 'remote'}} merged = {**DEFAULTS, **overrides} # {'db': {'host': 'remote'}} port 丢了需要递归合并就自己写,或者显式把嵌套层拆平。别指望**帮你做深合并,它没这个能力。
4.3 类继承链上的super().__init__(**keywords)
写继承的时候,把**keywords一路往上传是个常见模式,它能让子类不必关心父类新增了哪些参数。但这条链有个尽头必须注意。
class Base: def __init__(self, **keywords): self.options = dict(keywords) class Child(Base): def __init__(self, name, **keywords): super().__init__(**keywords) self.name = name Child('job', timeout=10) # OK,options == {'timeout': 10}看着挺顺,问题出在链条末端。所有类最终都会走到object,而object.__init__不接受任何额外参数:
class Leaf: def __init__(self, **keywords): super().__init__(**keywords) # 这里会炸 Leaf(timeout=10) # TypeError: object.__init__() takes exactly one argument处理方式有几种,我常用的是在链条末端显式停止透传,或者在一个明确的"基类"里把keywords消化掉,不再往上丢:
class Root: def __init__(self, **keywords): self.options = dict(keywords) # 不调用 super().__init__(**keywords) class Child(Root): def __init__(self, name, **keywords): super().__init__(**keywords) self.name = name多继承的时候情况更复杂,兄弟类的__init__会不会被调用、参数会不会被谁悄悄吞掉,取决于 MRO 顺序。我的经验是:只在单继承或明确设计的合作式多继承里用参数透传,其余场景老老实实把参数列清楚,少一点"灵活",多一点可预测。真要用,写几个单测把每个分支都覆盖一遍,别靠脑子推。
5. 让**keywords对类型检查和 IDE 友好
5.1**kwargs: int标注的到底是什么
给**keywords加类型标注,很多人第一反应是"标注这个字典",其实不是。
def f(**keywords: int) -> None: ...这行的意思是:每个关键字参数的值都是int,键一律是str。等价于keywords: dict[str, int]。所以f(a=1, b=2)通过检查,f(a='x')会被类型检查器标红。
这个区别在实际用的时候最明显的表现是:你不能用dict[str, int]这种方式去标**keywords,语法上不放。
def f(**keywords: dict[str, int]): # 错了,这表示"每个值都是 dict[str, int]" ...同理,*args: int表示"每个位置参数都是 int",也不是"args 是 list[int]"。这两个标注的语义都是"元素类型",不是"容器类型",很容易看错。
5.2Unpack[TypedDict]:把黑盒变成明盒
**keywords: int只约束了值的类型,约束不了"允许哪些键",所以拼写错误依然会被放过。Python 3.11 之后有了更精确的写法,配合TypedDict可以把键也钉死。
from typing import TypedDict, Unpack class ConnectOptions(TypedDict, total=False): timeout: int retries: int verbose: bool def connect(**options: Unpack[ConnectOptions]) -> None: ... connect(timeout=10, retries=3) # 通过 connect(timout=10) # 类型检查器报错:未知键total=False表示所有键都是可选的,需要必填就把某几个键单独拆成另一个TypedDict。3.11 之前的版本从typing_extensions里导入Unpack,用法完全一样。
这套写法的收益很直接:IDE 会给你补全所有合法键名,类型检查器会在 CI 阶段拦住拼写错误,函数文档里也不用再手写一长串"可接受参数列表"。代价是多写十几行定义。我的判断标准是:这个扩展点如果只有内部两三个地方用,不值得;如果是给外部使用者开的公共接口,非常值得——它能省掉大量"这个参数名到底怎么拼"的沟通。
5.3 文档、签名与functools.wraps的配合
**keywords天生对文档不友好,因为它不出现在签名里(指具体键名),help()打出来只有一行**keywords。所以 docstring 里必须写清楚。
def fetch(url, **keywords): """发起一次请求。 参数: url: 目标地址。 **keywords: timeout (float): 超时秒数,默认 30。 retries (int): 重试次数,默认 3。 headers (dict): 附加请求头。 """写库的时候我还会加一层运行时校验,代价很小但收益很大:
_ALLOWED = {'timeout', 'retries', 'headers'} def fetch(url, **keywords): unknown = set(keywords) - _ALLOWED if unknown: raise TypeError(f'不支持的参数: {sorted(unknown)}')这行代码解决的就是我在下一节要重点讲的那个"最危险的问题"——拼写错误被静默吞掉。多写三行,省掉无数次排查。
6. 排错实录与使用边界
6.1 常见报错速查表
下面这些是我这些年实际撞过的,按出现频率排序:
| 报错信息 | 触发场景 | 处理方向 |
|---|---|---|
got an unexpected keyword argument 'x' | 函数没声明**keywords,调用方却传了多余的键 | 检查调用方是不是把旧参数名传过来了,或者函数该加**keywords |
got multiple values for keyword argument 'x' | 同一个键通过两种途径传了两次 | 找出重复来源,通常是f(a=1, **d)里d也含a |
got multiple values for argument 'a' | 位置参数和关键字参数指向同一形参 | 检查是不是f(1, a=2)这种写法 |
keywords must be strings | 展开的映射里有非字符串键 | 检查键的类型,可能混进了整数或元组 |
argument after ** must be a mapping, not list | 展开了一个列表或普通可迭代对象 | 先转成 dict,或者改用*展开 |
object.__init__() takes exactly one argument | 继承链末端还在往上传**keywords | 在最后一个自定义基类里终止透传 |
f() takes 0 positional arguments but 2 were given | 包装函数只写了**keywords没写*args | 补上*args |
这些报错信息本身写得还算清楚,顺着关键词搜基本都能定位。真正难缠的是下面这一类:不报错,但结果不对。
6.2 几个不报错但更危险的静默坑
坑一:拼写错误被吞掉。这是**keywords最大的副作用。函数一旦声明了**keywords,它对任何多余的关键字都是宽容的,f(timeout=10)和f(timout=10)一样能跑,只是后者被收进字典里从此没人理。
def connect(**keywords): timeout = keywords.get('timeout', 30) # 用户传了 timout,永远走默认值 return timeout connect(timout=5) # 返回 30,静默失败这个问题在配置类接口里杀伤力最大:用户以为改了配置,实际什么都没生效,日志里又看不出异常。上面那三行白名单校验就是专门治它的,强烈建议加。
坑二:get的默认值写错对象。keywords.get('retries', 3)和keywords.get('retries') or 3在用户传0的时候行为完全不同:前者返回0,后者返回3。因为这个or陷阱,我见过一个"最大重试 0 次"的配置被悄悄改成 3 次,测试环境跑了半个月才发现。
坑三:键名大小写敏感。f(**{'Timeout': 10})和f(**{'timeout': 10})是两个完全不同的键,字典不会做任何规范化。如果你的配置来自环境变量或命令行,记得统一转小写再合并,否则大小写不一致的问题会以"某个配置偶尔不生效"的形式出现。
坑四:依赖插入顺序。Python 3.7 之后字典保持插入顺序,**keywords收到的键顺序就是调用方传的顺序。大多数时候这没啥影响,但如果你的逻辑里对键做了"取第一个"这类操作,就隐式依赖了调用方写法,非常脆。要么显式排序,要么别依赖顺序。
6.3 什么时候应该果断放弃**keywords
写了这么久,我的结论是:**keywords是个好工具,但它被滥用的程度远超过它该被使用的程度。
该用它的场景,基本就三类:一是装饰器、包装函数这种"原样转发"的中间层,你确实不知道也不该知道下游签名;二是给使用者开扩展点的公共接口,比如某个组件的extra_options,未来要加东西不想改签名;三是配置合并这种天然就是字典结构的场合。
不该用它的场景,反而更值得列出来:函数参数固定且不超过五个的,老老实实列出来,IDE 补全、类型检查、文档生成全靠它;公共 API 尤其如此,你写def query(sql, **keywords),使用者得翻三遍文档才知道能传什么;有明确业务语义的参数更不能塞进**keywords,create_user(name, age)比create_user(**options)可读性高出不止一个量级。
我个人的红线是:一个函数的**keywords如果预期会被传超过三种键,就该重新设计签名了。真到了那一步,用TypedDict或者一个显式的配置对象(比如 dataclass)来承载,比裸字典强太多——至少字段是可见的,默认值是写在一处的,缺字段时会在构造阶段就报错,而不是在函数体里某次keywords.get才发现是None。
最后分享一个小技巧,我几乎在每个需要透传的项目里都会写这个工具函数:
def pick(mapping, allowed): """从映射里挑出白名单内的键,其余丢弃。""" return {k: v for k, v in mapping.items() if k in allowed}配合**pick(keywords, _ALLOWED)使用,能在一层层透传里把不该往下走的参数及时拦掉。我踩过最久的一个坑就是参数被一路透传到第三层,最后被某个底层函数的**keywords悄悄吃掉,全链路没有任何报错,只能靠一层层打日志反推。加上这个pick之后,问题在第二层就暴露了。这类防御性代码看着啰嗦,但在多人协作的项目里,它省下来的排查时间远超写它的成本。