ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从TypeError到参数转发:**keywords与**kwargs机制解析

从TypeError到参数转发:**keywords与**kwargs机制解析

1. 一个TypeError引出的问题:**keywords在签名里到底扮演什么角色

线上日志里跳出TypeError: xxx() got an unexpected keyword argument 'retry_times',我盯着这行看了半分钟——函数签名里明明写着**keywords,按说任何关键字参数都该被它收下,怎么会"unexpected"?顺着调用栈往回翻,发现问题出在一个包装层:外层函数接了**keywords,转发时却只挑了固定几个键往下传,剩下的被默默吞掉了。这类事故和**keywords(社区里更常见的写法是**kwargs)本身无关,问题在于很多人只知道它"能接任意关键字参数",却说不清它接住之后变成了什么、转发时又该怎么摊开。

这篇内容就是把**keywords这个东西从签名到运行期完整拆一遍。它适合三类人:写过def f(**kwargs)但没细想过内部机制的人;接手别人代码、被一层层参数转发绕晕的人;还有准备做代码评审、想给团队定一套参数规范的人。读完之后你至少能做到三件事:看一眼签名就知道参数会流向哪里;遇到"参数丢了"或"参数重复"的报错能快速定位;知道什么时候该用**keywords,什么时候用了反而是给自己埋雷。

先给一个最基础但极容易被含糊过去的结论:函数定义里的**keywords是一个收集器,它把所有没被前面形参匹配掉的关键字参数,装进一个新建的 dict;函数调用里的f(**mapping)是一个摊平器,它把一个映射对象拆成一组"键=值"的关键字参数。两个**长得一样,干的事完全相反。分不清这两侧,后面所有的困惑都是从这儿长出来的。

1.1 报错栈里那行"unexpected keyword argument"的来历

Python 在函数调用时匹配参数的顺序是固定的:先按位置填,再按名字填,剩下的才轮到**keywords。如果签名里压根没有**keywords,任何没被显式形参接住的关键字参数都会触发got an unexpected keyword argument;反过来,只要签名末尾有**keywords,这类报错就不该出现,除非报错来自下游函数。

这就解释了开头那个案例:错误信息里的函数名不是最外层的包装函数,而是它调用进去的第三个函数。包装层用**keywords全收了参数,转发时却写成inner(url, timeout=keywords.get('timeout', 3)),retry_times从头到尾没被传下去,自然也就没出现在下游的签名匹配里。看报错时第一件事不是改签名,而是确认报错函数和你以为的那个函数是不是同一个。

我习惯用两步验证。第一步,把print(func.__code__.co_varnames[:func.__code__.co_argcount])打出来,确认形参列表到底长什么样;第二步,在包装层入口加一行print(sorted(keywords)),看参数到这一层时还剩下哪些键。两边一对照,"在哪一层丢的"立刻就清楚了。这个手法比翻代码快得多,尤其是包装层嵌套三层以上的时候。

1.2 同一个星号,收集与解包是两套动作

用一个生活化的比方:定义侧的**keywords像收件箱,快递员(调用方)把没写具体收件人的包裹统统扔进去,收件箱本身是一个字典;调用侧的f(**d)像拆包台,你把一个写着地址清单的册子摊开,一件件按清单上的名字分派出去。收件箱是你自己的,拆包台动的是别人给的册子。

这个区分带来几个很实际的推论。第一,**keywords拿到的永远是新字典,哪怕调用方传的是一个现成的 dict,函数内部对它做增删改都不会影响调用方那个对象——但注意,字典是浅拷贝,值如果是可变对象,改里面的内容照样会外泄。第二,调用侧f(**d)要求d的键必须是字符串,否则在调用发生的那一刻就报TypeError,根本进不了函数体。第三,定义侧对键没有任何格式要求,f(**{'1': 2})对def f(**keywords)是完全合法的,keywords里就是一个{'1': 2},能不能用是业务逻辑的事,语言层面不管。

很多人把这两侧混成一句话"**kwargs就是接收任意参数",然后在调用侧写出f(keywords)这种把整个字典当一个位置参数塞进去的写法,调试半天才发现少了一个星号。这种错我在代码评审里见过不止一次,尤其是从别的语言转过来的人,习惯了"传个对象进去",对 Python 的展开语法没有肌肉记忆。

2. 从字节码视角看**:打包成 dict、摊平成键值对

想真正把**keywords用稳,值得花几分钟看看它在字节码层面做了什么。不需要背指令名,但知道"这里有一次 dict 构造"和"这里有一次键遍历",能帮你判断性能开销在哪、副作用可能从哪来。用dis.dis()反汇编一个最简函数,你会看到定义侧对应的是一条BUILD_MAP加COPY_FREE_VARS之类的组合,调用侧则是CALL_FUNCTION_KW或较新版本里的CALL配合关键字元组。结论比指令名重要:每一次带**keywords的调用,至少多一次字典创建。

这个开销在普通业务代码里可以忽略,但在每秒几万次的热路径上就不是小事了。我做过一个粗糙的对照测试,一个只做加法的空壳函数,显式两参数版本和高频**keywords版本,在本地机器上跑一千万次,差距大概在 1.5 到 2 倍之间,具体数字随版本和机器波动,但量级关系是稳定的。所以我的建议很直接:对外暴露的 API、需要向前兼容的接口用**keywords;内部高频调用的核心循环,老老实实写显式参数。

2.1 定义侧:形参表末尾的收纳筐

定义侧的规则可以用一句话概括:**keywords必须出现在形参表的最后,它前面可以有普通形参、默认值形参、*args、仅关键字形参,它后面什么都不能有。写成def f(**keywords, a)是语法错误,解释器在编译期就拒绝,连运行的机会都不给。这条限制不是随意定的,因为收集器一旦开始工作,就没有"后面还剩哪些没匹配"的概念了。

收集的判定标准是"有没有被前面的形参按名字匹配掉"。举个具体例子:

def handler(url, timeout=3, **keywords): print(url, timeout, keywords) handler("/api", retries=2, verbose=True) # /api 3 {'retries': 2, 'verbose': True} handler("/api", timeout=5, retries=2) # /api 5 {'retries': 2}

第一次调用里timeout没传,用了默认值 3,retries和verbose被收进keywords;第二次调用timeout被显式匹配,只剩retries进收纳筐。这个行为看着理所当然,但它意味着一个重要的工程含义:形参名就是对外契约的一部分。你哪天把timeout改名成time_out,所有用timeout=5调用的地方都会悄悄从"匹配形参"变成"塞进keywords",如果函数体里只读timeout变量,外部传的值就彻底失效,还不报错。这种静默失效比直接报错难查十倍。

我个人的防御做法是:凡是**keywords收进来的东西,函数体开头要么显式消费,要么显式拒绝。写一段白名单校验,不在名单里的键直接抛TypeError,比让它们烂在字典里强。多写五行代码,省掉半夜被叫起来排查"配置没生效"的可能。

2.2 调用侧:映射对象如何被摊开

调用侧的f(**d)在较新的 Python 版本里接受的不只是 dict,任何实现了keys()和__getitem__的映射对象都可以。这带来一个实用的副作用:你可以传自定义的只读配置对象进去,不必先转成 dict。但代价是,如果这个映射的keys()和__getitem__行为不一致(比如keys()返回了某个键,__getitem__却抛KeyError),报错会出现在调用点而不是你的业务逻辑里,栈信息看着会很莫名其妙。

还有一条容易被忽略的语法糖:同一次调用里可以出现多个**。

base = {"timeout": 3, "retries": 2} override = {"timeout": 10} handler("/api", **base, **override) # 等价于 handler("/api", timeout=10, retries=2)

后出现的键覆盖先出现的,这一点和{**base, **override}的合并语义一致,不报错。但要注意,如果同一个键既以显式关键字形式出现、又出现在**里,那就不是覆盖而是冲突了:

handler("/api", timeout=10, **{"timeout": 3}) # TypeError: handler() got multiple values for keyword argument 'timeout'

显式写出来的和展开出来的,在解释器眼里是两个独立来源,重复就是错。这个差异坑过不少人——用{**a, **b}合并配置时习惯"后者覆盖前者",转到函数调用侧就忘了规则变了。

2.3 键必须是字符串这条硬约束

f(**d)的d里如果有非字符串键,调用当场失败:

handler("/api", **{1: "x"}) # TypeError: keywords must be strings

这条约束的根源在于关键字参数的键在语言层面就是标识符,必须是字符串。理解这一点,能顺带解释另一类困惑:为什么dict(**d)有时表现得比{**d}严格。字面量合并{**d}接受任意可哈希键,因为它只是在构造一个普通字典;而dict(**d)走的是"关键字参数"通道,键必须是字符串。这两者不是同一个机制,别混着记。

实际项目里踩到这条坑的典型场景是:从外部读进来的 JSON 或配置表,键可能被解析成数字(某些序列化格式会把数字键转成字符串,某些不会),一路透传到函数调用点才炸。稳妥的做法是在边界层就把键统一成字符串,别指望下游函数帮你兜。

3. 形参排布不是玄学:位置、*、/、**的先后规则

参数顺序这件事,说复杂也复杂,说简单就一句话:位置参数在前,可变位置参数居中,仅关键字参数靠后,可变关键字参数收尾。但中间夹着 Python 3.8 引入的/(仅位置参数分隔符),很多人第一次看到def f(a, b, /, c, *, d, **kw)这种签名会直接懵。其实把它拆成四段地带就清楚了,每一段的分隔符都是一道"墙"。

我在团队里做过一次小范围的问卷,十来个人里能一次写对完整形参顺序的不到一半,错得最多的是把*args放在默认值参数前面。这个错误在语法上不报错,但语义会变形——def f(a=1, *args)是合法的,可一旦有人按位置传参,a就抢先吃掉了第一个位置值,args从第二个开始收,和作者的本意往往相反。规则不难,难的是形成条件反射。

3.1 合法顺序与常见写法对照

把合法顺序做成一张表,比背口诀管用:

位置形参形式作用能否省略
1pos_only, /仅位置参数,不能用名字传可以
2normal普通参数,位置或名字都行可以
3normal=default带默认值的普通参数可以
4*args收集多余位置参数为元组可以
5*单独一个星号,标记后面只能按名字传可以
6kw_only=default仅关键字参数可以
7**keywords收集多余关键字参数为字典可以

整张表全是"可以",意思是最简签名def f()也合法,所有段都是可选的,但顺序不能乱。有个细节值得单独说:*args和单独的*不能同时出现两次,def f(*args, *more)是语法错误;**keywords后面不能再跟任何形参,def f(**kw, x=1)直接 SyntaxError。

再看几种真实项目里高频出现的组合:

# 组合一:装饰器包装层,宽进宽出 def wrapper(*args, **keywords): return func(*args, **keywords) # 组合二:禁止位置传参的配置接口 def configure(*, host, port=80, **extra): ... # 组合三:仅位置参数保护 API 稳定性 def distance(p1, p2, /): ...

组合三是很多人没用过的:加了/之后,调用方写distance(p1=..., p2=...)会直接报错。它的价值在于给函数留出改名的自由——参数名不再是对外契约,以后想把p1改成point_a不影响任何调用方。库作者尤其该重视这个特性,公开 API 加个/,未来重构的成本会低非常多。

3.2 仅位置参数与仅关键字参数划出的三段地带

/和*把形参表切成了三段:/之前只能按位置传,/和*之间两种都行,*之后只能按名字传。理解这三段,很多"为什么这个参数传不进去"的疑问会自动消失。

def request(method, url, /, timeout=3, *, retries=0, **keywords): ...

在这个签名里,method和url是仅位置的,写request(method="GET", url="/x")会报TypeError: request() got some positional-only arguments passed as keyword arguments;timeout两种都行;retries只能按名字传,写request("GET", "/x", 3, 2)里的2会被判定为多余的位置参数(因为没有*args接),报错。最后**keywords兜住所有剩下的。

三段地带的划法有一个非常实用的应用:强制调用方写清楚参数含义。retries这种语义不太直观的数字,放在*后面强制用retries=2传,代码可读性立刻不一样。我在配置类接口里基本都会这么写,宁可让调用方多敲几个字符,也不要读代码的人去猜第三个位置的2是什么意思。

3.3 重复赋值引发的三类报错

参数传递出问题时,报错信息其实很有规律,把三类分清楚,定位速度能快一个数量级:

报错信息片段触发场景典型原因
got multiple values for argument 'x'同一个形参被位置和名字各赋一次f(1, x=2)而签名是def f(x)
got multiple values for keyword argument 'x'显式关键字与**展开撞车f(x=1, **{"x": 2})
got an unexpected keyword argument 'x'签名里没有对应形参也没有**keywords拼写错误或下游签名变了
keywords must be strings**展开的映射含非字符串键配置加载时键类型没统一

第一类和第二类看起来像,但根因不同。第一类是"位置 + 名字"的冲突,常见于给函数加了**keywords之后忘了某个位置参数还能按名字传,结果调用方两种方式都用了。第二类是"两个来源的关键字",多半出现在多层配置合并的场景:默认配置用显式关键字写死,用户配置用**展开,两边键一重叠就炸。

我处理这类问题的固定动作是:先看报错里的参数名,再去 grep 那个参数名在调用点的所有出现形式。命令行工具grep -rn "retries=" --include=*.py .配合grep -rn "\*\*"交叉一下,五分钟内基本都能定位。这类问题的可怕之处不在难查,而在于它可能只在特定配置组合下触发,单测覆盖不到就等着线上了。

4. 转发场景:**keywords真正值钱的地方

前面讲的都是机制,这一节讲它最有价值的使用场景:参数转发。你写一个装饰器、一层中间件、一个子类,核心诉求都是"我不关心里面有什么参数,原样传给下一层"。如果没有**keywords,你得为每个被包装的函数手写一遍参数列表,一旦被包装的函数签名变了,包装层就失效了。**keywords在这里不是便利,是必需品。

但便利的背后有一个代价,很多人没意识到:过度转发会让参数溯源变得极其困难。你在一个五层嵌套的调用链里看到一个timeout参数,想搞清楚它最终传给了谁、默认值到底是多少,得一层层点进去。签名越宽,可追溯性越差,这是设计上的权衡,不是纯粹的语法问题。我的判断标准是:包装层(装饰器、中间件、代理)用**keywords全量转发,业务函数用显式参数,两边不在同一个函数里混用。

4.1 装饰器透传时的三个失手点

装饰器是最典型的转发场景,也是最容易写错的地方。三个高频失手点,每一个我都见过实际事故。

第一个,忘了*args。只写def wrapper(**keywords)的装饰器,装饰一个带位置参数的函数会直接崩。因为被装饰函数f(a, b)调用时外部传的是位置参数,wrapper(**keywords)根本接不住位置值。正确写法永远是def wrapper(*args, **keywords),两个都要。

第二个,忘了返回被包装函数的返回值。

def trace(func): def wrapper(*args, **keywords): print("call", func.__name__) func(*args, **keywords) # 少了 return return wrapper

这段代码不报错,但所有被装饰的函数返回值都变成None。如果被装饰的函数本来靠返回值传递结果,下游会拿到一片None,报错位置离根因十万八千里。这种错我在评审中抓过至少三次,视觉上太容易滑过去了。

第三个,functools.wraps的缺失引发签名信息丢失。不加@functools.wraps(func),inspect.signature(wrapper)看到的是(*args, **keywords),所有依赖签名做参数校验、文档生成、依赖注入的框架都会拿到错误信息。加了之后,__wrapped__属性会被设置,inspect.signature默认会顺着这条链找回真实签名,工具链才能正常工作。

import functools def trace(func): @functools.wraps(func) def wrapper(*args, **keywords): print("call", func.__name__) return func(*args, **keywords) return wrapper

这十行代码值得存进你的代码片段库。看起来平平无奇,但三个坑它全填上了。

4.2 子类初始化与配置字典的向下传递

面向对象里的经典场景是super().__init__(**keywords)。子类接一堆参数,自己消费几个,剩下的原样交给父类。这个模式优雅,但有两个前提必须成立才安全。

前提一,父类的签名你真的清楚。如果父类在某个版本里删了一个参数,子类这边还在无脑透传,报错会出现在子类的__init__里,看起来像是子类写错了。反过来,如果父类新增了一个必填参数,子类的透传正好能自动适配,这正是这个模式的价值所在。

前提二,不要让**keywords一路通到底。我见过最深的一个继承链,从最底层的配置类到最顶层的业务类一共七层,每一层都是**keywords全量转发。结果是给某个类加参数时,根本不知道它会被哪几层消费,也没法静态检查。这种结构改成"每层显式声明自己消费的参数,只把明确定义的扩展字段往下传"会好很多,代价是每个参数要写两遍。

配置合并也是个高频场景。多套配置覆盖时,我习惯用{**defaults, **user_config, **runtime_override}一层层叠,最后一次性Client(**merged)传进去。这样合并逻辑集中在一处,比在函数调用里堆多个**好读,出问题时打印merged就能看到最终生效的值。这个模式我用得非常多,几乎每个需要分层的配置系统里都会出现。

4.3 回调签名兼容:宽收窄放的策略

写回调注册接口时有个常见矛盾:你希望回调函数能收到足够的上下文,但不希望每次给上下文加字段都破坏已有回调。解法是反过来:让注册侧宽收,调用侧按需取值。

def register(callback): def dispatch(**context): callback(**context) return dispatch

这种设计的好处是回调只声明自己关心的参数:

def on_error(message, **rest): print("error:", message) def on_metric(name, value, **rest): print(name, value)

两个回调签名完全不同,但都能挂到同一个 dispatch 上。只要回调都留一个**rest兜底,上下文以后加多少个字段都不会崩。这套"宽收窄放"的模式在事件系统、插件机制、钩子函数里都适用,代价是静态检查能力弱了——rest里到底有什么,类型检查器说不清楚,只能靠约定和文档。

这里插一句个人经验:用这个模式时,务必在 context 里带一个版本号或者字段清单。回调方拿到一堆未知的**rest时,至少能通过context.get("schema_version")判断当前语义,未来做兼容性处理时有依据。这个小字段帮我省过好几次麻烦。

5. 函数调用之外:{**a, **b}、dict()与浅拷贝

**这个符号在函数签名和函数调用之外还有第三种出现场合:字典字面量的合并。{**a, **b}是 Python 3.5 之后的标准写法,很多人把它和dict(a, **b)当成完全等价的两个东西,实际行为有细微但重要的差别。搞清楚这层差别,能避免一类很隐蔽的 bug。

先明确它们的共同点:两者都是浅合并,合并结果是一个新字典,但值都是原对象的引用;两者都是后面的键覆盖前面的键。共同点覆盖了 90% 的使用场景,剩下 10% 的差异正好落在键盘冲突的处理上。

5.1 字面量合并与构造调用的行为差异

{**a, **b}的规则非常干脆:后出现的同名键直接覆盖先出现的,不报警不报错,这是语言层面明确规定的语义。而dict(a, **b)走的是另一条路径——a作为位置参数传给dict构造函数,**b作为关键字参数展开,如果a里已经有同一个键,在 CPython 上会触发TypeError: dict() got multiple values for keyword argument 'x'。同一份数据,换一种写法,一个静默覆盖一个直接报错,差别相当大。

a = {"x": 1} b = {"x": 2} merged = {**a, **b} # {"x": 2} merged2 = dict(a, **b) # CPython 上抛 TypeError # 想用 dict() 实现覆盖语义,得先合并再构造 merged3 = dict({**a, **b}) # 这样才对

我的建议是直接统一到{**a, **b},语义清楚、报错少、可读性好。dict(a, **b)这种写法唯一还有点优势的场景是用关键字形式追加几个固定字段:dict(config, source="cli"),读起来确实自然,但只在键确定不冲突时用。至于具体版本的行为细节,各版本实现可能有微调,我的习惯是写测试验证,别背结论——一条assert merged["x"] == 2比记十篇博客都可靠。

还有一个容易被忽视的变体:d.update(**b)。这个写法要求b的键全是字符串,并且如果d里已有同名键,update是覆盖而非报错,和dict(a, **b)又不一样。三种写法的行为差异值得做成一张速查表贴在心里:

写法键冲突行为键类型要求返回
{**a, **b}后者覆盖任意可哈希新字典
dict(a, **b)报错字符串新字典
a.update(**b)后者覆盖字符串None(原地改)

5.2 合并出来的字典是新的,但里面装的还是旧引用

浅合并这四个字,真正踩过坑的人才知道它的分量。{**a, **b}确实创建了一个新的字典对象,但键值对里的值是原对象的引用,没有任何拷贝动作。如果值是列表、字典、自定义对象,你改合并结果里的值,原字典里的那个值跟着变。

defaults = {"retry": [1, 2], "level": "info"} merged = {**defaults} merged["retry"].append(3) print(defaults["retry"]) # [1, 2, 3] 原对象被改了

这种 bug 特别阴险,因为表面上你已经"拷贝"了配置,心理上完全放松了警惕。它往往表现为"改了一处配置,另一处莫名其妙也跟着变了",排查时根本想不到去看合并那一步。我在一个配置加载模块里踩过一次,现象是两个不同模块的日志级别联动变化,查了整整一个下午,最后定位到合并时共用了同一个列表对象。

解决办法分两档。简单场景用copy.deepcopy整体深拷贝,代价是复杂对象可能拷不动或者很慢;要求精确控制的场景,在合并前对已知的可变字段做单独拷贝,比如{"retry": list(defaults["retry"]), **b}。我更倾向后一种,因为深拷贝是个不太可控的操作,遇到自定义类还要处理__deepcopy__,把边界画清楚更省心。

顺带说一个和**keywords有关的浅拷贝细节:函数定义侧的**keywords收集容器也是新建的 dict,但收集进去的值同样是引用。所以你在函数内部keywords["items"].append(x),调用方传进来的那个列表会被改。这条结论很容易被"我已经用**拷贝过了"的错觉盖住,实际并没有安全到哪去。

6. 类型标注与工程约束:别让**keywords变成黑箱

到了静态检查这一步,**keywords的问题会集中暴露:它把参数契约从签名里抹掉了,类型检查器看到的只是一个开放字典。团队里用类型检查工具的话,一个到处是**kwargs的代码库基本等于放弃了对参数的类型约束。这不是危言耸听,是我在几个中型项目里反复验证过的结论——一旦某个模块的核心函数开始用**keywords兜底,关键词补全和类型提示会迅速失效,两个月后没人知道里面能传什么。

但也不能一刀切禁止。折中方案是有的:给标注、给文档、给运行时校验,三件套配齐,**keywords就能从黑箱变成半透明。这一节讲具体怎么写。

6.1 标注写的是值的类型,不是键

最容易写错的一行是:

def configure(**options: str) -> None: ...

这个标注的含义是"每个参数的值都是str",不是"参数名是str"。很多人第一眼会误解成后者,因为它读起来太像"键的类型"。键永远被约束为字符串,不需要也不应该标注。如果你想表达"值的类型有几种可能",用联合类型,比如**options: int | str | None。

标注还有个进阶写法是标 Callable 参数,这在转发场景里很有用:

from typing import Callable, Any def build(name: str, factory: Callable[..., Any], **overrides: Any) -> Any: return factory(name=name, **overrides)

这里的...表示factory接受任意参数,Any表示overrides的值不限制。信息量不大,但至少告诉了读代码的人"这里确实什么都可能传"。真要有约束,就得上下一节的东西。

6.2 TypedDict 配合 Unpack 的写法

Python 3.11 之后(typing_extensions里更早就有),可以给**keywords标注一个TypedDict,键和值都能约束住:

from typing import TypedDict from typing_extensions import Unpack class RequestOptions(TypedDict, total=False): timeout: int retries: int headers: dict[str, str] def request(url: str, **options: Unpack[RequestOptions]) -> None: ...

total=False表示所有键都可选。这样写之后,类型检查器能识别出request("/x", timeout=3)合法,request("/x", timeout="3")报错,request("/x", unknown=1)也能提示。这套写法我在新项目里已经全面铺开了,它对可维护性的提升比预期大得多——有约束的**keywords和没约束的**keywords,代码质量是两个档次。

代价是写法更长了,还要维护一个TypedDict定义。我的判断标准是:如果一个函数的**keywords需要被复用于三个以上调用点,就值得定义TypedDict;只在一个内部工具函数里用一次,懒一点也没关系。另外老版本解释器上得装typing_extensions,做兼容的话要注意依赖声明。

6.3 评审清单:什么时候该把它拆成显式参数

代码评审时看到**keywords,我基本会按下面这份清单过一遍:

检查项不通过的表现建议
是否有明确消费点收进来后只get两三个键改成显式形参
是否有白名单未知键被静默忽略加校验或抛错
是否全量转发直接传给多个下游函数明确每个下游要什么
是否有文档函数说明里没列可用键补文档或改用TypedDict
是否热路径高频调用的核心循环改显式参数

第三条是全量转发里最危险的一种:a(**keywords); b(**keywords); c(**keywords)。这种写法看起来很省事,实际上把三个下游函数的契约绑死在一起了,任何一个下游改了签名,这个函数就成了隐性故障点,而且报错栈里的函数名永远是下游的,没人会第一时间想到是这里传过去的。我宁愿多写三行显式传参,也不要这种耦合。

7. 实际项目里踩过的几个坑与排查手法

前面讲的多数是"应该怎么写",这一节讲"写错了会怎么样"。选的三个坑都是我自己或者身边同行真实踩过的,现象和根因的关联性都不直观,正好用来演示排查思路。

7.1 可变默认值和 kwargs 混着用

经典的可变默认值问题,和**keywords结合后会变得更微妙:

def collect(**options): items = options.get("items", []) items.append("x") return items

这段代码每次调用看起来都正常,因为options是新建的 dict,options.get("items", [])在没传items时确实返回一个新的空列表。问题出在传了的情况:调用方传进来的那个列表被原地改了,调用方往往对此毫无察觉。

shared = [] collect(items=shared) collect(items=shared) print(shared) # ['x', 'x']

修法是在函数入口对可变参数做一次拷贝,items = list(options.get("items", ())),用元组当默认值也更稳妥。这个坑的本质不是**keywords的错,而是"传入的引用被修改"这个问题在宽签名函数里更隐蔽——显式参数def collect(items=None)至少签名上能看出items是个可选项,**keywords里藏的键就得靠读函数体才知道。

7.2 无脑全量透传给下游

开头那个retry_times丢失的案例是"漏传",反过来的错误是"乱传":

def process(payload, **options): return upload(payload, **options)

upload的签名里根本没有timeout这个参数,但调用方传了,process无脑透传过去,报错upload() got an unexpected keyword argument 'timeout'。报错指向upload,但问题在process没有做筛选,而upload是被无辜牵连的。这类错误在接口重构时集中爆发:下游删了一个参数,所有无脑透传的上游全部报错,排查路径还都指向下游。

我的改法是给转发加一层显式映射,或者至少加个注释说明哪些键会往下走:

def process(payload, **options): forwarded = {k: options[k] for k in ("timeout", "chunk_size") if k in options} return upload(payload, **forwarded)

多写一行推导式,换来源头可查。这个习惯是从一次线上事故后养成的,那次是三个上游同时透传到一个下游,下游改了参数名,三个上游全崩,而错误信息里的调用栈长得吓人,定位花了近两个小时。

7.3 定位问题的顺序:签名、调用点、包装层

最后说一下我的通用排查顺序,遇到任何和**keywords相关的怪事,按这个顺序走,基本不会绕远路。

第一步,打印真实签名。inspect.signature(func)比看源码可靠,尤其是函数被装饰过、被functools.partial包过、或者是 C 扩展实现的时候。如果签名里出现(*args, **keywords),说明有一层包装没加functools.wraps,真实签名被藏起来了,得顺着__wrapped__往下找。

第二步,在调用点打印展开前的字典。print(sorted(d.keys())),确认参数在进入函数之前是什么样。这一步能区分"参数压根没传"和"传了但被吃了"两种完全不同的情况。

第三步,在函数入口打印收到的字典。print(sorted(keywords.keys())),两边一对比,就知道在哪一层丢的。如果入口处的键和调用点一致,说明问题在下游的转发;如果已经少了,说明问题在当前函数的签名匹配上。

这个三步法我用了几百次,从没失手过。工具都是内置的,不需要装任何东西,复制粘贴就能跑。唯一要注意的是别忘了用完删掉那些print——我有次把一个调试用的print留在核心路径上,日志量直接翻了十倍。

8. 结语之外的一点个人体会

**keywords这个语法点,入门时觉得简单,用久了反而会越来越谨慎。它给的是灵活性,拿走的是契约。什么时候该用,取决于你更怕哪一种痛:怕改签名时牵一发动全身,就用它做转发;怕三个月后没人知道这里能传什么,就老老实实写显式参数加上TypedDict。

我现在的默认立场是:公开接口收窄,内部转发放宽。对外的函数把参数一个个写清楚,必要时加/保护参数名;对内的装饰器、中间件、分发器用*args, **keywords全量接住,配上functools.wraps和TypedDict标注。这样既保住了重构时的自由度,又不至于让代码变成黑箱。

如果你手上正好有个到处透传的模块想收拾,建议从"打印签名"开始,先把每一层的真实签名列出来贴在文档里,再决定哪些层该收窄。先看清楚,再动手,比凭感觉改要稳得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进