ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python *args 与 **kwargs:定义收集、调用解包与参数透传详解

Python *args 与 **kwargs:定义收集、调用解包与参数透传详解

1. 从一次 TypeError 说起:* 和 ** 在参数里到底扮演什么角色

我第一次被这两个星号绊住,是接手一段别人留下的日志封装代码。调用方按文档传了三个位置参数加一个retry=3,结果运行到一半直接抛TypeError: log_event() got an unexpected keyword argument 'retry'。翻到函数定义那一行才发现写的是def log_event(message, *args, **kwargs),看起来"什么都收",但真正的配置项被写进了**kwargs里,后面某处又用kwargs["retry"]硬取,键名拼错成了retires,于是一路带着None往下跑,直到某个分支才炸。这件事之后我才认真把*和**的两种身份拆开来看——它们在 Python 里其实是两套完全不同的机制,只是共用了同一个符号。

一句话概括:在函数定义的位置上,*args负责把多出来的位置参数打包成一个元组,**kwargs负责把多出来的关键字参数打包成一个字典;在函数调用的位置上,*负责把一个可迭代对象摊开成若干个位置实参,**负责把一个映射摊开成若干个关键字实参。定义端是"收",调用端是"放",方向正好相反。很多人写代码时把两者混着理解,写出来的签名看着能用,实际一传参就出问题。

1.1 报错信息里的两条线索

Python 在这件事上的报错其实很诚实,只是平时没人细读。takes 2 positional arguments but 3 were given说的是位置参数数量对不上,说明函数没有开*args这个口子;got an unexpected keyword argument说的是关键字参数没被任何形参接住,说明既没有同名形参,也没有**kwargs兜底;got multiple values for argument则是同一个形参被位置和关键字各喂了一次。这三类错误基本覆盖了新手 90% 的传参翻车场景,而它们的解法都指向同一件事:搞清楚当前函数签名到底允许哪些参数、以什么形式进来。

我后来养成了一个习惯,遇到这类报错先不看函数体,直接print(inspect.signature(目标函数)),把签名打出来对着看。签名是(a, b=1, *args, c, **kwargs)还是(a, b=1),决定了调用方能用什么姿势传参,比读十行函数体都快。这个方法在接手陌生代码库时尤其管用,尤其是那些被装饰器层层包裹过的函数,露出来的签名和真实签名经常不是一回事。

1.2 定义端"收集"与调用端"铺开"的分工

把两种角色分开记,很多疑惑会瞬间消失。定义端的星号是声明式的,它写在def里,告诉解释器"如果有多余的参数,别报错,按这个规则收起来";调用端的星号是表达式式的,它写在函数名后面的括号里,告诉解释器"把这个容器里的元素逐个拿出来当实参"。前者决定了函数能接受什么,后者决定了这次调用给什么。

一个容易被忽略的细节是:调用端的*和**并不是只能配合*args/**kwargs使用。哪怕目标函数是def add(a, b):这种最普通的签名,你依然可以写add(*(1, 2))或者add(**{"a": 1, "b": 2}),效果和add(1, 2)完全一样。星号属于调用语法本身,跟被调函数长什么样没有绑定关系。反过来说,定义了*args的函数也可以被普通方式调用,此时args就是个空元组而已。

理解这层分工之后,我再看那些"万能转发"的代码就顺畅多了:外层def wrapper(*args, **kwargs)把收到的东西原样收起来,内层func(*args, **kwargs)再原样摊开送出去,中间不做任何解释。这套动作的本质就是"我不知道你传什么,所以我不碰,原样搬"。装饰器、代理层、事件总线、插件调度,几乎所有需要透传参数的场景都是这个套路。

2. 定义端的 *args:多余位置参数被装进元组

先看最基础的一段代码:

def collect(first, *rest): print(first, type(rest), rest) collect(1) collect(1, 2, 3) collect(1, "a", None, [4])

输出依次是1 <class 'tuple'> ()、1 <class 'tuple'> (2, 3)、1 <class 'tuple'> ('a', None, [4])。这里有个关键点值得强调:rest拿到的是tuple,不是 list。很多从其他语言转过来的人会下意识以为它是个可变序列,写rest.append(x)然后报AttributeError。Python 选 tuple 是有道理的——参数在函数调用那一刻就确定了,之后不应该被改动,用不可变类型天然防止了函数内部偷偷修改参数集合。

2.1 它拿到的是 tuple,不是 list

这个细节在实际项目里影响不小。如果你确实需要在函数内部对收集到的参数做增删,正确做法是先list(rest)转一下,或者干脆写items = list(rest)再操作。直接把rest当 list 用,在测试环境可能碰巧不报错(因为测试用例只传了一个参数,rest是空元组,某些操作在空元组上恰好不触发),上了生产才炸,这种 bug 排查起来非常费劲。

另一个相关的问题是:*args收集到的参数可以是任意对象,包括生成器、迭代器、函数、类本身。这意味着你不能假设args里的元素类型统一,除非你自己在函数开头做校验。我之前写过一个批量处理工具,签名是def process(*tasks),结果有调用方传了一个列表进来,整个列表被当成单个元素塞进了tasks,函数内部还按"每个元素是一个任务"去遍历,最后处理了一个 list 对象。修法是在文档里写清楚"每个任务单独作为位置参数传入",或者老老实实改成def process(tasks)接收一个列表,把是否摊开的决定权交给调用方。

2.2 *args 后面的普通参数去哪了

这是新手最容易迷惑的地方。写下def f(a, *args, b):时,b到底是什么?答案是:b变成了仅限关键字参数,只能通过f(1, 2, 3, b=4)这种形式传,写成f(1, 2, 3, 4)会报错说b缺失。原因很直观——*args已经把后面所有的位置参数都吃掉了,再想在它后面声明一个"普通位置参数"在语法上是矛盾的,所以 Python 干脆把*args之后的所有形参都划归为关键字专属。

这个规则有个非常实用的推论:如果你在参数列表里写了*args,那么它后面的所有参数都必须靠关键字传。反过来说,如果某个参数你希望调用方一定要写参数名,可以故意用*args或者单独一个*把它挡到后面去(详见第 4 节)。这是 Python 里表达"这个参数必须显式命名"的标准手法,比在文档里加粗提醒可靠得多。

还有一个更隐蔽的坑:*args之后不能再接带默认值的位置参数,因为语法上已经不允许了。有人试图写def f(*args, b=1)期望b是可选位置参数,实际b是可选关键字参数,f(1, 2)里 1 和 2 全进args,b仍是 1。这个差异导致函数行为跟预期完全不同,而代码本身不报错,属于典型的"能跑但不对"。

2.3 名字叫 args 只是约定,星号才是关键

args和kwargs这两个名字纯粹是社区约定,你完全可以写成def f(*items, **options),功能一模一样。约定俗成的好处是看签名就知道是可变参数,坏处是有时候会让人误以为名字有特殊含义。我见过有人写def f(*kwargs),试图收集关键字参数,结果拿到的是一个元组;也有人写def f(**args),然后惊讶于取出来的东西是字典而不是元组。名字骗人,星号从不骗人。

更麻烦的是有些库为了可读性用了自定义名字,比如def execute(*statements, **context),初看以为是业务参数,其实是可变参收集器。读这类代码时一定要盯着星号在谁前面,不要被名字带跑。我的做法是在 review 代码时,凡是看到*或**开头的形参,一律把它当作"兜底容器"处理,绝不在没有校验的情况下假设它的形状和元素类型。

顺带说一句命名风格的取舍。*args这种通用名字适合底层工具函数、装饰器、转发层,因为它们确实不关心具体内容;而业务函数如果用*args,最好换成有语义的名字,比如*extra_filters、*fallback_paths,让调用方一眼看出该往里塞什么。这条经验来自一次团队协作——一个函数签名是def query(*conditions),结果有人传了列名,有人传了 SQL 片段,有人传了字典,最后炸成一锅粥,改名的沟通成本远低于事后排查的成本。

3. 定义端的 **kwargs:关键字参数被收进字典

**kwargs的行为对称但细节不同。看这段:

def configure(name, **options): print(name, type(options), options) configure("cache") configure("cache", size=128, ttl=60)

输出是cache <class 'dict'> {}和cache <class 'dict'> {'size': 128, 'ttl': 60}。和*args拿到 tuple 不同,这里拿到的是标准的 dict,可以正常增删改查。这个差异是有意设计的——关键字参数天生就是"名字到值"的映射,用 dict 承载最自然。

3.1 键必须是字符串这条隐含约束

**kwargs里面装的键必须是字符串,这是 Python 语言层面的硬性要求,不是约定。如果你尝试f(**{1: "a"}),会直接报TypeError: keywords must be strings。这条约束在正常传参时几乎不会被察觉,因为f(a=1)写法本身就限定了键是标识符;但一旦引入动态构造的字典,问题就冒出来了。

我遇到过真实场景:一个任务调度器需要把配置字典透传给下游函数,配置是从配置文件(如 YAML/TOML)读出来的,理论上键都是字符串。但有一处合并逻辑用了dict.update(list_of_pairs),其中某对数据的键被序列化成了整数,整条链路一直是"能跑"的,直到某次下游函数签名变更导致这个字典被**展开,才突然报错。这类问题的排查成本极高,因为出错点和错误源头隔着好几层调用。我的经验是:任何准备被**展开的字典,在构造边界处就做一次键类型断言,代价几乎为零,收益是把问题挡在源头。

3.2 显式参数与 **kwargs 的抢夺战

当函数同时有显式关键字参数和**kwargs时,Python 的分配规则是"先给显式参数,剩下的再进kwargs"。也就是说:

def f(a, b=2, **kwargs): print(a, b, kwargs) f(1, b=3, c=4) # 1 3 {'c': 4} f(1, c=4) # 1 2 {'c': 4}

但如果同一个参数既通过位置、又通过显式关键字给了,就会触发got multiple values错误:

f(1, 2, b=3) # TypeError

这里有个容易踩的边界:如果显式参数是通过**字典传进去的,会怎样?答案是同样会触发冲突:

def g(a, b=2): pass g(1, **{"b": 3}) # 正常,1 3 g(1, **{"a": 2}) # TypeError: got multiple values for argument 'a'

第二种写法的行为常常让人意外,因为从字面上看g(1, **{"a": 2})像是"补一个 a",但 Python 认为位置参数已经把a占了,字典里的a是重复赋值。我在写配置合并工具时被这个坑过:用户传参是"位置参数 + 覆盖字典",覆盖字典里如果恰好包含第一个位置参数的名字,就得先把它 pop 掉再展开。这个处理逻辑后来被我写成了一个通用的merge_kwargs小函数,专门负责剔除冲突键。

4. 单独一个 * 和单独一个 /:两种"禁区"标记

除了成对出现的*args和**kwargs,Python 还允许在参数列表里单独写一个星号,或者单独写一个斜杠。这两个符号不带名字,作用是给参数划分"传参方式禁区"。它们的设计动机是让 API 的作者能精确控制调用方必须怎么写,而不是依赖文档提醒。

4.1 光秃秃的 * 是仅限关键字参数的分界线

def crop(image, *, width, height, keep_ratio=True): ... crop(img, width=100, height=80) # 合法 crop(img, 100, 80) # TypeError

单独一个*之后的参数全部只能通过关键字传。这个特性在库设计里非常有用,因为位置参数一旦超过两三个,调用方就很难记住顺序。crop(image, 100, 80)到底是先宽后高还是先高后宽?没人记得住。强制写成关键字之后,代码的可读性会显著提升,而且后续调整参数顺序也不会破坏调用方。

值得一提的是,单独的*和*args在划分关键字禁区这件事上效果是一样的。def f(a, *args, b)里的b也是仅限关键字。区别在于*args还额外接收多余的位置参数,单独*则直接拒绝多余的位置参数。所以如果你的函数不需要收集可变位置参数,只是想让后面的参数必须写名字,用单独*语义更准确,签名也更干净。

4.2 / 标记的仅限位置参数(Python 3.8 之后)

和*对称的是/,它出现在参数列表里,标记它前面的参数只能按位置传,不能按关键字传:

def load(path, /, mode="r"): ... load("a.txt") # 合法 load("a.txt", mode="rb") # 合法 load(path="a.txt") # TypeError

这个特性是从 C 实现的内置函数(如len、range)那里"反向借鉴"来的——那些函数的参数名对用户不可见,传关键字会失败。Python 3.8 之前,用户自己写的纯 Python 函数无法复现这个行为,3.8 之后用/就可以了。

实际项目里/用得比*少,但在一些场景下很有价值。比如你想保留参数名改动的自由:def scale(x, y, /, factor=2)里x和y的名字可以改,只要位置不变,对调用方毫无影响;而factor是关键字参数,名字一改就是破坏性变更。如果是写一个对性能敏感、调用极频繁的底层函数,位置参数传递也略快一点点(少了关键字匹配的开销),虽然这点差异在 Python 里通常可以忽略。

4.3 一条完整的参数排序规则

把/、普通参数、*、*args、**kwargs全放一起,规则就定死了:

位置参数类型示例传参方式
最前仅限位置参数a, b(在/之前)只能位置
中间普通参数c, d=1(在/之后、*之前)位置或关键字
之后可变位置参数或分隔符*args或*收集位置或禁止位置
再后仅限关键字参数e, f=2只能关键字
最后可变关键字参数**kwargs收集关键字

完整示例如下:

def full(a, b, /, c, d=1, *args, e, f=2, **kwargs): pass

这个签名虽然看着复杂,但只要你按表格从上往下读,每一段允许什么传法就一目了然。我在给团队做代码规范时,把这张表印成了 A4 贴在工位上,因为参数顺序写错导致的语法错误非常费时间——解释器只会告诉你 "positional argument follows keyword argument",不会告诉你具体该怎么排。

5. 调用端的解包:把一个容器摊开成多个实参

前面说的都是定义端,现在换个视角。调用端的星号解决的是"我手里有个容器,但函数要的是一串参数"这个问题。

def add(a, b, c): return a + b + c values = [1, 2, 3] print(add(*values)) # 6 options = {"a": 1, "b": 2, "c": 3} print(add(**options)) # 6

这段代码在心智上要理解成"values被拆成了三个独立实参传进去",而不是"values作为一个列表传进去"。区别在于:add(values)会让a拿到整个列表,然后b、c缺失报错;add(*values)则让a=1, b=2, c=3。

5.1 * 解包可迭代对象,** 解包映射

*后面跟的可以是任何可迭代对象:list、tuple、set、字符串、生成器、range、自定义实现了__iter__的对象。这带来一些有趣但危险的行为:

add(*"abc") # ValueError: 参数数量对不上,因为 "abc" 拆成 'a','b','c' add(*range(3)) # 6 add(*{1, 2, 3}) # 6,但顺序不确定

字符串被拆成字符、集合被拆成无序元素,这两点经常让新手困惑。如果字符串正好是三字符,add(*"abc")会以a='a', b='b', c='c'的形式成功执行,然后返回"abc",看起来"没报错"但结果完全不是预期的数值相加。所以解包字符串基本属于要避免的写法,真需要拆字符请显式写list(s)或者tuple(s),让意图更明确。

**后面跟的必须是映射类型(实现了keys()和__getitem__的对象),最常见的是 dict,也可以是collections.ChainMap之类的映射实现。普通的 list 或 tuple 放在**后面会直接报错。这个限制其实很合理——关键字参数必须有名字,列表没有名字。

5.2 重复赋值引发的 TypeError 从哪来

解包时最常见的报错是got multiple values for argument,根源在于位置和关键字两条通道同时给同一个形参赋值了:

def send(to, subject, body): pass params = {"to": "x", "subject": "y", "body": "z"} send("x", **params) # TypeError,to 被赋了两次

修法有两种:要么去掉多余的位置参数,要么在展开前把字典里冲突的键删掉:

params.pop("to") send("x", **params)

另一个常见来源是多个字典合并展开:

send(**d1, **d2)

Python 3.5 之后支持这种写法,规则是"后出现的覆盖先出现的",但如果同一个键在两个字典里都有、且目标函数没有对应形参能接住(也就是进**kwargs时),那么后一个会覆盖前一个,不报错。然而如果目标函数有具名形参,重复键就会触发上面那个错误。这个行为差异容易让人误判,我的做法是合并字典时显式用{**d1, **d2}先构造好再展开,逻辑更清楚,也能提前发现覆盖问题。

5.3 生成器只能被消费一次的解包陷阱

*解包会完整遍历可迭代对象,这意味着生成器在解包之后就被耗尽了:

def total(*nums): return sum(nums) gen = (x for x in range(5)) print(total(*gen)) # 10 print(total(*gen)) # 0,gen 已空

如果这个生成器本来要用于两处,第二处就会拿到空结果,而且不报错。这类 bug 特别隐蔽,因为程序照常运行,只是数据悄悄变少了。我的应对策略是:凡是要被*展开的迭代对象,如果不是 list/tuple 这类确知可重复遍历的类型,先list()一下,明确表示"这是要落地的数据"。多一次物化,换一份确定性,在绝大多数业务场景里是划算的。

6. 装饰器与转发:这对组合真正值钱的地方

前面都是零件,现在看主战场。*args和**kwargs被使用频率最高的地方,是各种需要"参数透传"的场景,其中最典型的就是装饰器。

6.1 通用包装器的签名透传

import functools def retry(times=3): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): last_error = None for attempt in range(times): try: return func(*args, **kwargs) except (ValueError, TimeoutError) as exc: last_error = exc raise last_error return wrapper return decorator @retry(times=2) def fetch(url, timeout=5): ...

这里的wrapper(*args, **kwargs)是关键:装饰器本身不知道被装饰函数长什么样,也不知道调用方会传什么,所以只能原封不动地收下来、再原封不动地送出去。如果写成def wrapper(url, timeout=5),那这个装饰器就只能用于fetch这种签名的函数,换一个函数就得重写。通用性正是靠这两个星号换来的。

这也解释了为什么装饰器代码里几乎看不到显式参数名。显式写出参数名意味着你要跟被装饰函数的签名耦合,而成熟的装饰器库(日志、缓存、限流、重试)都追求与业务函数解耦。*args和**kwargs是唯一能做到"不关心具体签名"的语法手段。

6.2 functools.wraps 保住的到底是什么

严格说functools.wraps不是*/**相关,但去掉它会立刻产生连锁反应。不加@functools.wraps(func)时,wrapper.__name__是"wrapper",wrapper.__doc__是None,wrapper.__module__也丢了。更实际的影响是inspect.signature(wrapper)返回的是(*args, **kwargs),而不是原函数的签名。

这意味着基于签名做校验的框架会失效。比如某个参数校验中间件会读取目标函数的签名来判断必填项,装饰器一包,签名就从(url, timeout=5)变成了(*args, **kwargs),校验逻辑直接失效,所有参数都变成"可选的",错误就漏到了运行时。我遇到过一次参数校验完全失灵的事故,排查了很久才发现是某个新加的装饰器忘了写functools.wraps。

加了之后,inspect.signature能通过__wrapped__属性追溯到原函数,拿到真实签名。需要注意的是__wrapped__只是一个属性,不是魔法,它的作用是让标准库的inspect模块在解析签名时自动跟随。如果你用了一些自研的反射工具,很可能没处理这个字段,那就得自己读__wrapped__。

6.3 类型标注写在 *args 和 **kwargs 上的含义

Python 3.5 之后可以给它们加类型标注,但语义要理解准确:

def f(*args: int, **kwargs: str) -> None: ...

*args: int表示每一个位置参数都是int,args本身的类型是tuple[int, ...];**kwargs: str表示每一个关键字参数的值都是str,kwargs本身的类型是dict[str, str]。写成*args: tuple[int, ...]是错的,那表示每个位置参数都是一个元组。

这个细节在使用mypy或pyright做静态检查时会被严格校验。我见过把注解写成**kwargs: dict的代码,静态检查器会要求每个关键字参数的值都是 dict,传一个timeout=5就报错。正确写法是**kwargs: Any(如果不关心值类型),或者用Unpack[TypedDict](如果需要精确描述键集合,Python 3.11 之后配合typing_extensions可用)。

7. 几个我踩过的坑和取舍建议

7.1 可变默认值与 *args 的混淆

这两个知识点经常被放在一起讲,但它们是不同的问题。可变默认值指的是def f(items=[])这种写法,默认列表在函数定义时创建一次、被所有调用共享;而*args是每次调用新建一个元组,不存在共享问题。混淆它们会导致奇怪的结论,比如"用*args代替默认参数"——这不是一个可选项,因为语义完全不同:*args是可变数量的位置参数,默认参数是单个可选参数。

真正需要防的是可变默认值那个坑:

def append_tag(tag, tags=[]): tags.append(tag) return tags

连续调用两次会得到累加的结果。标准修法是tags=None然后在函数内初始化:

def append_tag(tag, tags=None): tags = [] if tags is None else tags tags.append(tag) return tags

这个问题和*args的唯一联系是:两者都发生在参数列表里,都容易被顺手写错。分开记,别混着记。

7.2 滥用 **kwargs 让函数签名失去可读性

**kwargs好用,但它是可读性的天敌。一个函数如果写成def handle(event, **kwargs),调用方完全不知道能传什么、该传什么,只能翻文档或者读源码。我在维护一个内部 SDK 时深受其害——早期为了"向后兼容",把所有新增参数都塞进**kwargs,两年后函数签名变成def process(payload, **kwargs),文档里列了 27 个可选键,没有一个 IDE 能提示出来,每次接新需求都要翻半天。

后来的改法是:能具名的参数一律具名,只在确实需要透传给下游、且内容完全由调用方决定的场景才保留**kwargs。比如def request(url, *, method="GET", headers=None, **extra),前几个参数是核心配置,必须能自动补全和类型检查;extra是给特殊场景留的口子,接受"传了就有、不传就没"的灵活性。这个划分标准我认为可以推广:签名里出现**kwargs时,问自己一句"这些键名我能不能列举出来",能列举就具名,列举不出来才用**。

7.3 关于性能的一点点实测感受

经常有人问*args和**kwargs会不会慢。我做过粗略对比:一个空的def f(a, b): pass和def f(*args, **kwargs): pass,后者在调用时需要打包 tuple 和 dict,单次开销确实高一些,量级在几十纳秒。这个差异在业务代码里完全可以忽略,因为一次网络请求是毫秒级、一次数据库查询是几十毫秒级,参数打包的开销连零头都不算。

真正需要在意的是热点路径上的极端情况,比如一个被调用上百万次的数值计算函数。那种场景下确实应该用显式参数,但理由是"可读性 + 可被 C 层优化",而不是"星号慢"。我个人的判断是:普通业务代码按可读性优先选参数形式,性能敏感代码按 profile 结果决定,不要凭感觉预优化。见过太多为了躲一个根本不存在的性能问题,把函数签名搞得极其难用的代码,得不偿失。

最后分享一个小技巧,用来快速检查一个函数的真实签名和解包情况:

import inspect def probe(func, *args, **kwargs): print(inspect.signature(func)) bound = inspect.signature(func).bind(*args, **kwargs) print(bound.arguments) probe(some_function, 1, 2, mode="fast")

bind会按真实签名分配参数,不执行函数体,能在调用前就把参数错误暴露出来。这个方法在写测试用例或者做参数调试时非常好用,比反复触发TypeError再回头改代码高效得多。

返回列表