*args 与 **kwargs

用明确的函数签名收集、解包和转发可变参数,并避免隐藏函数契约、静默接受拼错的选项。

难度 进阶 时长 标准深度约 17分钟
版本 Python 3.14
what

在函数定义中,*args 把未匹配的位置实参收集成元组,**kwargs 把未匹配的关键字实参收集成字典。

trap

全盘接收参数的签名会隐藏允许的选项;盲目转发还可能造成关键字重复、保留拼写错误,或让数据越过错误的 API 边界。

fix

明确写出具名形参,只在参数确实可变的边界使用 ***,并在转发前验证或移除包装器拥有的选项。

是什么,为什么存在

*args**kwargs 是两种 可变形参(variadic parameter) 的惯用写法。在函数定义中,一个前导 * 收集多余的位置实参,两个前导 ** 收集多余的关键字实参。名称本身没有特殊之处:*items**options 的行为相同,而且往往更能说明内容。

形参(parameter) 是函数声明的输入槽, 实参(argument) 是调用方提供的值或表达式。区分这两个术语后,规则就更容易说清:Python 先把实参绑定到形参,再把剩余实参打包进可变形参。

这套语法解决了两类实际接口问题。有些操作天然需要接收数量可变的同类值,例如接收多个字段的格式化函数。包装器和装饰器也可能需要转发调用,而调用的确切形状由另一个可调用对象决定。

可变形参不能代替接口设计。如果函数支持 timeoutretriesheaders,把它们写进签名能让拼写错误立即失败,也能为编辑器、文档工具和类型检查器提供有效信息。只有允许的键集合确实开放,或者归下游可调用对象所有时,才应使用 **kwargs

同样的星号用于调用位置时含义相反。*iterable 把其中的元素作为位置实参提供,**mapping 把以字符串为键的条目作为关键字实参提供。这叫作 实参解包(argument unpacking) ,不同于定义中的可变形参打包。

装饰器、适配器、通过 super() 协作的类、测试参数化辅助函数,以及同时接收固定控制项与可扩展载荷的 API 中,都会遇到这两个方向。设计时最重要的问题是:灵活边界从哪里开始,每个选项由哪一层拥有。

工作原理

Python 会给 函数签名(function signature) 中的每个形参分类。/* 分隔符与可变形参共同决定调用方能以什么方式提供各个值。

形参种类定义形式允许的调用形式
仅限位置位于 / 之前按位置传入
位置或关键字位于 / 之后、* 之前按位置或关键字传入
可变位置*items零个或多个位置实参
仅限关键字位于 **items 之后按关键字传入
可变关键字**options零个或多个未匹配的关键字实参

看下面这个签名:

def render(template, /, context=None, *fragments, escape=True, **attributes): ...

template 仅限位置传入。context 可以按位置或关键字传入,fragments 接收额外的位置实参,escape 仅限关键字传入,attributes 接收其他关键字。函数不需要收集额外位置实参时,可以用裸 * 标记仅限关键字的形参。

位置绑定

Python 从左到右,把每个 位置实参(positional argument) 分配给可接受位置传入的形参。这些槽位填满后,可变位置形参把其余值接收为一个新元组。没有多余值时,该形参就是空元组。

/ 之前的形参仅限位置传入。它们在源码中的名称属于实现细节,不是调用方可用的关键字名。公共形参名称可能改变,或者同一拼写需要保留给 **kwargs 时,这项规则很有用。

调用中,位置实参不能出现在 **mapping 之后。调用语法对带星号的可迭代对象更宽松,但其中的元素仍会进入位置实参流。应根据最终的绑定关系理解调用,不要只看 *iterable 表达式在源码中的位置。

关键字绑定

每个显式的 关键字实参(keyword argument) 以及 **mapping 中的条目都按名称绑定。关键字不能填入仅限位置的形参。一个形参也不能收到多个值,无论冲突来自位置实参与关键字、两个解包映射,还是显式关键字与映射条目。

具名形参匹配完毕后,**options 会把未匹配的关键字接收进一个新字典。如果没有可变关键字形参,任何未匹配的名称都会引发 TypeError。没有未匹配的关键字时,该形参就是空字典。

** 解包的映射必须使用字符串键。如果被调用函数有 **kwargs 形参,字符串键不必是有效的 Python 标识符,因此即使无法写出 capture(content-type="json")capture(**{"content-type": "json"}) 也可以工作。这类键只能通过字典操作访问。

求值与绑定是两步

Python 会先对实参表达式求值,再进入函数体。求值遵循源码顺序,因此后面的重复关键字或意外关键字即使导致绑定失败,实参表达式中的副作用也可能已经发生。不要指望被调用函数阻止构造调用时已经触发的副作用。

随后,绑定过程把完整的位置输入与关键字输入同签名核对。缺少必需形参、没有收集器却多出位置实参、没有收集器却出现意外关键字,以及重复提供值,都会引发 TypeError。这些情况下,函数体不会开始执行。

调用方没有提供值时,默认值会填入符合条件的形参。默认值不会吸收拼错的关键字。全盘接收的 **kwargs 会改变这种行为,把拼错的名称当作新字典条目接受,因此灵活签名需要显式验证。

容器与对象标识

函数内部的可变位置参数值是元组,可变关键字参数值是字典。元组不能调整长度,字典则可在局部修改。这些容器性质无法说明其中对象是否可变。

如果调用方提供一个列表,args[0] 与调用方仍引用同一个列表。从任何一处修改该列表,另一处都能观察到。类似地,即使给 kwargs 顶层分配新键不会改变调用方解包的映射,修改 kwargs["headers"]["Accept"] 仍可能改变调用方拥有的嵌套字典。

因此,打包容器只在最外层提供结构隔离。如果函数需要拥有嵌套的可变值,就应复制它;如果本来就要修改,则应记录并测试这项约定。星号不会创建深拷贝。

定义顺序

完整顺序依次是:仅限位置形参、/、位置或关键字形参、可变位置形参或裸 *、仅限关键字形参,最后是可变关键字形参。并非每种形参都必须出现。

在同一个位置形参组中,带默认值的形参后面不能出现必需形参。仅限关键字形参没有这项限制:调用方会写出名称,因此必需形参与带默认值的形参可以按任意顺序出现。

*args 并不表示它后面的每个形参也会被收集。位于其后的具名形参仅限关键字传入,而且会在未匹配的名称进入 **kwargs 前完成绑定。因此,def send(*messages, retry=False, **metadata) 既保持灵活,也明确声明了自身拥有的控制项。

协作式方法调用

多重继承有时会把 **kwargs 用作协作通道。每个初始化方法明确写出并消费自己拥有的形参,再调用 super().__init__(**kwargs),让方法解析顺序中的下一个实现消费自己的部分。调用链末端的类应拒绝剩余选项,而不是直接丢弃。

这种模式要求每个参与类都遵守同一契约。只要有一个初始化方法遗漏 super()、重复转发某个选项,或者消费了另一个类拥有的名称,调用链就会断裂。它是受控类层次结构中的协议,不是到处接受任意配置的理由。

协作式控制项最好使用仅限关键字的形参,因为即使基类顺序改变,其含义仍然不变。通过多个不相关的初始化方法转发位置参数,会让所有类耦合到同一套槽位顺序,重构风险很高。

选择灵活边界

应把收集器放在你能理解其可变性的层次。聚合函数可以拥有全部 *values;装饰器可以透明转发两类参数流;HTTP 适配器可以拥有三个具名控制项,并拒绝其他选项。即使实现中都有星号,这些契约也不相同。

如果收集内容同质或范围明确,应按角色命名,例如 *paths**headers**changes。包装器确实不关心被包装函数的领域时,可以保留 argskwargs。名称无法强制契约,却能告诉审查者应该寻找哪种契约。

选项集合稳定后,应把它们提升为具名形参。这样通常能改善文档与兼容性,因为新增仅限关键字的形参不会干扰已有的位置调用。完成提升后,只有未知键仍然有意义时,才保留 **kwargs

不要只因下游函数很灵活,就暴露同等灵活性。外层 API 可能需要更严格的安全、兼容性或所有权策略。转发是一项接口决策,不是机械捷径。

测试应从两个方向覆盖边界。既要验证被调用方收到的值,也要验证调用方提供不受支持的形状时看到的错误,因为任何一侧发生漂移,正常路径仍可能继续通过。

即使当前所有调用方都在同一个代码库中,也应把最终签名当作公开文档。

示例

收集值并命名控制项

这个函数接收一个固定的位置订单 ID、任意数量的商品名称、一个具名控制项以及开放的元数据。领域名称比惯用的 argskwargs 更能说明内容。

collect_order.py
def summarize_order(order_id, /, *items, currency="USD", **metadata):
    print(f"order: {order_id}")
    print(f"items: {items}")
    print(f"currency: {currency}")
    print(f"metadata: {metadata}")


summarize_order(
    "A-17",
    "notebook",
    "pen",
    currency="EUR",
    priority=True,
    warehouse="west",
)
order: A-17
items: ('notebook', 'pen')
currency: EUR
metadata: {'priority': True, 'warehouse': 'west'}

order_id 位于 / 之前,因此不能按关键字传入。两个商品名称组成一个元组。currency 绑定到已经声明的仅限关键字形参,所以只有 prioritywarehouse 留给 metadata

输出中字典的插入顺序遵循调用中的关键字顺序。只有 API 明确定义了顺序时,才能让逻辑依赖它;大多数选项处理都应按名称选择键。

解包调用方的数据

调用位置的解包能让容器中已有的数据满足明确的签名。可以使用多个 ** 表达式,前提是同一个键没有被提供两次。

unpack_schedule.py
def schedule(job, owner, /, *, retries=2, urgent=False):
    return (
        f"job={job}, owner={owner}, "
        f"retries={retries}, urgent={urgent}"
    )


identity = ("backup", "Mina")
retry_policy = {"retries": 4}
priority = {"urgent": True}

print(schedule(*identity, **retry_policy, **priority))
job=backup, owner=Mina, retries=4, urgent=True

元组元素填入 jobowner,两个映射填入仅限关键字的形参。schedule() 仍然严格:映射中出现未知键或重复的 retries 键时,会在函数体运行前引发 TypeError

这种严格性在配置边界很有用。外部数据应先经过验证与规范化,再执行解包;随后可以让明确的签名捕获配置模式与函数契约之间的漂移。

转发调用而不丢失元数据

装饰器的运行时实现通常无法写出每个被包装签名。它可以收集并转发两类参数流,但应为内省工具保留被包装函数的元数据。

trace_price.py
from functools import wraps
from inspect import signature


def trace(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}: args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"returned {result}")
        return result

    return wrapper


@trace
def quote_price(sku, quantity, /, *, discount=0):
    return quantity * (1200 - discount)


print(signature(quote_price))
print(quote_price("BK-7", 2, discount=100))
(sku, quantity, /, *, discount=0)
calling quote_price: args=('BK-7', 2), kwargs={'discount': 100}
returned 2200
2200

functools.wraps() 会设置 __wrapped__ 并复制用于标识函数的元数据。inspect.signature() 默认跟随 __wrapped__,所以工具看到的是公开的 quote_price 契约,而不是实现细节 (*args, **kwargs)

转发之后,被包装的可调用对象仍会收到最终调用,因此 Python 的绑定检查得以保留。转发不会验证装饰器的日志策略:这个小型示例会打印值,生产日志则必须删去凭据与个人数据。

转发前消费自己拥有的选项

适配器应把自己拥有的选项与代其他层接收的选项分开。这个版本刻意不设置下游全盘接收器,因此拼写错误会在适配器边界得到明确的错误。

option_boundary.py
def transport(url, /, *, timeout, headers):
    return f"GET {url} timeout={timeout} headers={headers}"


def fetch(url, /, **options):
    timeout = options.pop("timeout", 5)
    headers = options.pop("headers", {})
    if options:
        unknown = ", ".join(sorted(options))
        raise TypeError(f"unknown fetch options: {unknown}")
    return transport(url, timeout=timeout, headers=headers)


print(fetch("https://example.test", timeout=2, headers={"Accept": "text/plain"}))

try:
    fetch("https://example.test", timeuot=2)
except TypeError as error:
    print(error)
GET https://example.test timeout=2 headers={'Accept': 'text/plain'}
unknown fetch options: timeuot

options 已经是函数局部的外层字典,因此删除顶层键不会从用于 fetch(**config) 的映射中删除它们。嵌套的 headers 字典仍然共享;这里的 transport() 只读取它。

如果适配器确实需要继续转发更多选项,应定义允许列表或记录下游签名,用 pop() 删除适配器拥有的键,再把其余选项转发一次。还要明确决定:适配器选项会覆盖调用方的值、拒绝该值,还是让调用方的值优先。

陷阱

全盘接收会隐藏拼写错误

修复方法: 优先使用 *, timeout=5 这样的具名仅限关键字形参。确实需要全盘接收时,应消费已识别的键,并拒绝剩余内容。测试一个拼错的选项,因为正常路径测试无法暴露这项错误。

这也是 API 演进问题。接受所有名称的函数无法区分未来选项与当前拼写错误。严格边界能让变更保持明确,也能使弃用路径可观察。

转发会产生重复值

修复方法: 构造调用前先选择策略。可以拒绝调用方提供的值,可以在局部选项字典上用 setdefault() 提供后备值,也可以删除该键并明确覆盖它。不要指望显式关键字与解包关键字的顺序解决冲突。

多个 ** 映射之间也遵循同一规则。{**defaults, **overrides} 这样的字典显示会用后面的值覆盖重复键,但函数调用会拒绝重复项。不要把一个上下文的合并语义搬到另一个上下文。

打包容器只提供浅层隔离

修复方法: 应在可能发生修改的层次跟踪对象标识。添加字段前复制嵌套的标头字典,尽量使用不可变输入,或者记录函数会修改调用方状态。只测试顶层映射会漏掉这条别名关系。

执行 kwargs["processed"] = True 不会修改被解包进调用的映射。执行 kwargs["headers"]["X-Trace"] = value 却可能修改嵌套的 headers 映射。这两项事实并不冲突,因为外层与内层对象的标识不同。

星号解包可能接收错误的可迭代形状

修复方法: 标量不应带 * 传入,外部数据应在解包前验证容器形状。需要重复使用一次性可迭代对象时,应在所有权边界一次性具体化,并明确相应的内存成本。

只有把可迭代对象消费完并构造位置实参后,长度不匹配才会以 TypeError 暴露。如果一个集合在概念上是一个形参,而不是多个槽位,就不要在调用位置解包它。

透明包装器不会自动获得精确类型

修复方法: 使用 ParamSpec 与被包装函数的返回类型标注保持签名的装饰器,并用 @wraps 服务运行时内省。如果包装器增加或删除形参,应公开变化后的契约,而不是宣称完全透明。

@wraps 不会让不兼容的包装器变得可调用。包装器插入位置值或删除关键字后,仍可能违反原始签名。应通过装饰后的函数覆盖仅限位置、仅限关键字、带默认值以及错误调用。

开放式关键字转发会跨越边界

修复方法: 按拥有者划分选项,并为每个下游调用构造经过审查的映射。记录日志前删除敏感内容,在信任边界拒绝未知键,并测试敏感或不受支持的名称无法继续传播。

database_timeout 这样的前缀可以减少意外冲突,但多个组件各自拥有设置后,嵌套配置对象会更清楚。一个全局 **kwargs 命名空间无法扩展成可靠的配置模型。

深入 绑定边界情况

绑定边界情况

调用语法允许使用多个 *iterable**mapping 表达式。每个可迭代对象贡献位置值,每个映射贡献关键字键值对。这样可以组合调用,但不会放宽目标签名或重复值规则。

实参表达式从左到右求值,而带星号的位置值仍然参与位置绑定。因此,把显式关键字与后置的星号可迭代对象混用时,有些调用虽然合法,却很难阅读。即使语法允许其他顺序,也应把位置数据组织在关键字数据之前。

每个 ** 操作数都必须是映射,而不能只是键值对的可迭代对象。它的键必须是字符串。任何关键字来源之间只要出现重复字符串键,就会引发 TypeError,即使目标函数会把未匹配名称收进 **kwargs

不是标识符的字符串键与非字符串键并不相同。capture(**{"content-type": "json"}) 可以把 "content-type" 放进可变关键字字典,因为该键是字符串。capture(**{1: "json"}) 会引发 TypeError,而且直接关键字语法也无法写出带连字符的键。

仅限位置形参会刻意分开两个命名空间。给定 def replace(name, /, **changes),调用 replace("record", name="display") 会把第一个值绑定到仅限位置形参,把关键字 name 留给 changes。如果没有 /,该形参会收到两个值,绑定随即失败。

这种模式适合底层通用 API,但普通调用方可能感到意外。只有关键字命名空间确实需要这个名称时才使用它,不要把它当作选项模型划分不当的日常补丁。

错误发生在函数体之前

Python 会先完成实参求值与绑定,再执行函数体中的第一条语句。因此,被调用函数内部的 try 无法捕获自身的实参缺失、值重复或意外关键字错误。确实需要恢复时,应由调用方或外层包装器捕获该 TypeError

不要用一个宽泛的 TypeError 捕获同时包住绑定与函数执行。被调用函数可能因为自身缺陷在函数体中引发 TypeError,将它当成签名错误会掩盖问题。适配器可以用 inspect.Signature.bind() 验证即将发生的调用,而不执行函数体。

bind() 应用签名的绑定规则并返回 BoundArguments;必需形参缺失或值冲突时,它会引发 TypeErrorbind_partial() 刻意允许缺少必需实参,适合偏应用,不适合验证完整调用。apply_defaults() 可以把省略的默认值填入已有的绑定结果。

内省与类型

inspect.signature(callable) 会公开形参名称、种类、默认值与注解。形参种类分别对应 POSITIONAL_ONLYPOSITIONAL_OR_KEYWORDVAR_POSITIONALKEYWORD_ONLYVAR_KEYWORD。这种词汇比把 **kwargs 之前的一切都叫作“普通参数”更准确。

inspect.signature() 默认会跟随 functools.wraps() 创建的 __wrapped__ 链。因此,装饰器示例报告的是原始签名。改变公开调用契约的自定义装饰器可能需要明确设置 __signature__,但这份元数据必须与包装器实际接受的调用一致。

*values: int 上的注解描述每个被收集的位置值都是 int,不是把运行时元组整体标成 int。类似地,**labels: str 描述每个关键字值都是 str;有效调用边界上的键必然是字符串。普通 Python 执行不会强制这些注解。

如果有限个关键字名称对应不同的值类型,可以把 **kwargs 标为 Unpack[SomeTypedDict]。类型检查器便能分析必需键、可选键、值类型与意外名称。运行时收到的仍然是字典,不可信数据仍需验证。

对于透明的高阶函数,ParamSpec 可以捕获可调用对象签名中的位置部分与关键字部分。用 *args: P.args**kwargs: P.kwargs 标注包装器,再返回 Callable[P, R],可以保留被包装对象与其调用方之间的关系。它不能代替服务运行时元数据的 @wraps

类型系统无法挽救刻意含糊的接口。如果适配器接受任意键,却只转发其中一部分,应尽量直接表达允许的模式。静态精度与运行时拒绝策略应描述同一个所有权边界。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 函数
下一篇 装饰器 Type hints 即将上线 Inspect 即将上线 functools 函数工具
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?