*args、**kwargs 与解包
可变参数与序列/字典解包:函数签名里的星号完全指南。
*args、**kwargs 与解包
星号是 Python 参数系统里最”一词多义”的符号:写在函数定义里是收集(把零散实参打包成元组/字典),写在调用处是展开(把序列/字典拆成一堆实参);夹在参数列表中间还承担”分界线”的角色,划分仅位置与仅关键字参数。本篇围绕虚拟歌手音乐平台的票务与曲单场景,把这几个方向一次理清,最后落到签名设计的实践原则。
前置知识
学习目标
- 能区分定义侧收集(
*args/**kwargs)与调用侧解包(*/**)这两个相反方向。 - 会用
/与*划分仅位置、普通、仅关键字三段参数。 - 能用解包合并序列与字典,并解释键冲突时的覆盖规则。
- 会读带
*args/**kwargs的第三方签名并安全转发。 - 形成”明写优于收集”的签名设计直觉。
1. 定义侧收集:*args 与 **kwargs
# stage.py —— 舞台调度:定义侧收集参数
def schedule_concert(headliner, *guests, **options):
"""安排一场演唱会。
Args:
headliner: 头牌歌姬,必须显式给出。
*guests: 嘉宾歌姬,收集为元组,可为空。
**options: 附加配置,收集为字典,可为空。
"""
print(f"头牌:{headliner}")
print(f"嘉宾:{guests}") # 元组
print(f"配置:{options}") # 字典
schedule_concert("miku", "teto", "luka", encore=True, duration=180)
# 头牌:miku
# 嘉宾:('teto', 'luka')
# 配置:{'encore': True, 'duration': 180}
# dispatch.py —— 收集型签名的典型用途:事件注册器
handlers = {}
def on(event, *args, **kwargs):
"""注册监听器:参数形状交给回调自己决定"""
handlers.setdefault(event, []).append((args, kwargs))
def emit(event):
for args, kwargs in handlers.get(event, []):
print(f"{event} 触发,参数:{args} {kwargs}")
on("concert-open", "c001", seat_limit=5000)
on("concert-open", "c002")
emit("concert-open")
讲解:
*args把”多出来的位置参数”收成元组;**kwargs把”多出来的关键字参数”收成字典。名字可以换(*songs、**opts),args/kwargs 只是约定。- 两者都可选:实参不足时得到空元组/空字典,不会报错——这让函数天然支持”最少只传头牌”的调用方式。
- 声明顺序固定:普通参数 ->
*args-> 仅关键字参数 ->**kwargs。头牌必须给,嘉宾与配置随缘,正符合业务直觉;这也让最简调用只剩一个参数,接口天然易用。 - 收集型签名最常见的两类用途是注册器(
on("play", *args)收集监听参数)与委托包装器(转发给内部实现);业务函数尽量明写参数,收集留给”透传”场景。 - 注册器的价值在于把”参数形状”的决定权交给回调:on/emit 只负责收集与转发,
*args/**kwargs是唯一不侵入业务双方的写法。 - 一个帮助记忆的心智模型:定义侧的星号是”容器”(把散装收进元组/字典),调用侧的星号是”漏斗”(把容器倒回散装)——记住收与倒,其余规则都能现场推出来。
2. 调用侧解包:* 与 ** 的另一面
# unpack_call.py —— 同一对星号,在调用处是"展开"
def book_ticket(concert_id, seat, price, insurance=False):
print(f"订单:{concert_id}|座位 {seat}|票价 {price}|保险 {insurance}")
seat_info = ["c001", "A12", 680] # 座位三要素恰好按位置对应
book_ticket(*seat_info) # 等价 book_ticket("c001", "A12", 680)
buyer = {"insurance": True} # 可选项用字典维护,随业务增减
book_ticket(*seat_info, **buyer) # 等价 book_ticket("c001", "A12", 680, insurance=True)
# 混合书写:解包、位置、关键字可自由组合
book_ticket(*seat_info[:1], "B03", price=480)
# unpack_mix.py —— 解包与"捕获剩余":一键拆分首尾
first, *rest = ["miku", "teto", "luka", "luka"]
print(first, rest) # miku ['teto', 'luka', 'luka']
head, *mid, tail = sorted([98, 87, 92, 76])
print(head, mid, tail) # 去掉最高最低分,取中段求均值
讲解:
- 调用处的
*把序列展开成一串位置参数,**把字典展开成一串关键字参数——与定义侧的”收集”方向恰好相反,两者共享同一套符号。 - 字典的键必须是与形参名匹配的字符串,出现多余键或不存在的形参都会抛
TypeError,这是天然的拼写检查;习惯把可选参数集中放在一个配置字典里,调用前先过滤一次。 - 解包的价值在”零胶水装配”:座位数据在列表里、选项在配置字典里,调用时一行铺开,不需要写
book_ticket(info[0], info[1], info[2])这种下标搬运。星号语义在赋值侧同样成立:first, *rest = songs用星号捕获剩余元素,与调用侧解包互为镜像。 - 解包还有两处日常出镜:日志输出
print(*names, sep=", ")把列表铺成一行,函数返回return a, *rest合法(返回元组)——同一套语义处处复用。
3. 仅位置与仅关键字参数:/ 与 * 的分界
# signature.py —— 用 / 与 * 给参数划分三段身份
def ticket_price(base, /, seat="自由席", *, fast_pass=False):
"""base 仅位置;seat 位置关键字皆可;fast_pass 仅关键字。"""
total = base + (150 if fast_pass else 0)
return f"{seat} 票价 {total}"
print(ticket_price(680, "A12")) # 正确:base 按位置传
print(ticket_price(680, seat="A12", fast_pass=True)) # 正确:开关写名字
# ticket_price(base=680) # TypeError:base 在 / 之前,仅限位置
print(ticket_price(680, True)) # 能跑但语义错乱:True 被当成 seat
讲解:
/之前的参数仅限位置:形参名是实现细节,重命名不会破坏任何调用方——标准库大量用它保持演进自由,自己库的”纯数据”参数也建议加/。*之后的参数必须按关键字传:布尔开关、可选配置强制写名字,调用点一眼看清fast_pass=True是什么意思,避免ticket_price(680, True)这种靠位置猜语义的错误。
# keyword_only.py —— 仅关键字参数的收益:调用点即文档
def transfer(ticket, *, to_fan, keep_copy=False):
print(f"票 {ticket} 转给 {to_fan},保留副本 {keep_copy}")
transfer("A12", to_fan="粉丝乙") # 清晰:转给谁、是否保留一目了然
# transfer("A12", "粉丝乙") # TypeError:to_fan 必须按关键字传
- 三段记法:
/在左守位置,*在右守关键字,中间是普通区。完整顺序为:仅位置、普通、*args或*、仅关键字、**kwargs。 - 标准库的
sorted(data, /, *, key=None, reverse=False)是教科书式签名:数据仅位置、配置仅关键字——模仿这个结构写自己的函数不会错。
4. 解包合并序列与字典
# merge.py —— 解包做合并:曲单与应援色配置
song_a = ["星之歌", "回声"]
song_b = ["Tell Your World", "Melt"]
print([*song_a, *song_b]) # 列表解包合并,元素逐个铺平
defaults = {"lang": "zh", "theme": "#39c5bb"}
custom = {"theme": "#eba9ee", "seat": "A12"} # 粉丝定制了应援色
merged = {**defaults, **custom} # 后者覆盖前者
print(merged) # {'lang': 'zh', 'theme': '#eba9ee', 'seat': 'A12'}
print({**defaults, "seat": "A12"}) # 混入字面量也合法
print(defaults | custom) # 3.9+ 的 | 运算符,语义相同
讲解:
[*a, *b]能合并任意可迭代对象(列表、元组、生成器),(*a, *b)、{*a, *b}对应元组与集合版本。{**defaults, **custom}的覆盖规则是后者覆盖前者——所以默认值在前、用户配置在后,顺序反了就会用默认值覆盖用户的选择。- 3.9+ 的
dict | dict更简洁,但{**a, **b}可以在合并的同时塞进字面量键值,一个表达式完成”默认值 + 定制项”。解包也常配合排序取最值:a, *rest = sorted(scores)把第一名与后续一次分离,比切片加索引更不易越界。
5. 签名设计最佳实践
# design.py —— 从 *args 滥用到清晰签名
# 反例:什么都收,调用方只能翻源码猜参数
# def create_song(*args, **kwargs): ...
# 正例:必填明写并锁位置,开关仅关键字,真正不定长才交给 *
def create_song(title, producer, /, *, singers=(), bpm=120, lyrics=None):
return {
"title": title,
"producer": producer,
"singers": list(singers), # 元组转列表,调用方好处理
"bpm": bpm,
"lyrics": lyrics,
}
song = create_song("星之歌", "DECO*27", singers=("miku",), bpm=128)
print(song["singers"], song["bpm"])
讲解:
- 参数设计的默认次序:必填的明写(必要时加
/锁位置),开关与可选配置放*之后仅关键字,只有”同类数据流不定长”才用*args,配置透传才用**kwargs。 - 不要用
*args传”结构化数据”:接口接受任意位置参数会让调用顺序变成隐式契约;该传列表的传列表(singers=("miku",))。 - 装饰器与代理函数转发参数时才写
def wrapper(*args, **kwargs): return f(*args, **kwargs),并配合functools.wraps保留原函数元信息(见 装饰器)。 - 类型标注与签名相辅相成:
singers: tuple[str, ...] = ()精确表达了”不定长但同型”,比裸*args更能让调用方与静态检查器安心(类型体系见 类型注解与 mypy)。 - 标准库里随处可见这套设计:
print(*objects, sep=..., end=...)的 sep/end 仅关键字、sorted(data, /, *, key=None, reverse=False)的 key/reverse 仅关键字——读完本篇再读标准库签名会非常顺。 - 签名是 API 的合同,改动要讲兼容:新增参数给默认值并放进仅关键字区,废弃参数先标弃用再删除——这套纪律让”明写”不至于变成”改不动”。
# typed_signature.py —— 类型标注与星号组合:既灵活又可查
def create_song(
title: str,
producer: str,
/,
*,
singers: tuple[str, ...] = (),
bpm: int = 120,
) -> dict:
return {"title": title, "producer": producer, "singers": singers, "bpm": bpm}
print(create_song("回声", "ryuryu", singers=("luka",), bpm=96))
6. 进阶:用 inspect 读懂签名
面对第三方库的 *args/**kwargs,源码之外还有更可靠的入口:inspect 模块能把任意函数的签名还原成结构化对象,也能在不真正调用的情况下校验一组实参能否匹配。
# signature_introspect.py —— 读取并校验函数签名
import inspect
def ticket_price(base, /, seat="自由席", *, fast_pass=False):
return base + (150 if fast_pass else 0)
sig = inspect.signature(ticket_price)
print(sig) # (base, /, seat='自由席', *, fast_pass=False)
# 每个参数的种类一目了然:仅位置 / 普通 / 仅关键字
for name, param in sig.parameters.items():
print(name, "->", param.kind.name)
# bind:不执行函数,先校验这组实参能否与签名对上
bound = sig.bind(680, fast_pass=True)
print(bound.arguments) # {'base': 680, 'fast_pass': True}
讲解:
inspect.signature对带*args/**kwargs的函数同样有效,VAR_POSITIONAL 与 VAR_KEYWORD 会如实出现在参数表中——调试第三方包装器时先看它,比翻文档快。param.kind区分五种参数种类,写”参数转发/适配层”时靠它判断哪些实参能安全透传;配合bind_partial还能只校验前几个参数,做渐进式适配。sig.bind(...)只做匹配不执行:参数校验器、注册表、依赖注入容器常用它实现”调用前先验证”,不匹配会抛TypeError,与真实调用完全同源。inspect.signature也能读类与方法的签名:框架的依赖注入、ORM 的字段收集,底层都靠签名对象——读懂它,就读懂了一半的 Python 框架魔法。- 把签名对象存下来还能做”签名审计”:遍历包的公开函数,找出仍接受
*args/**kwargs的旧式接口,排进重构计划。
易错点与最佳实践
-
kwargs 的键拼错静默失效:调用传
singers=("miku",),形参写的是vocalists,多余键全部溜进**kwargs被无声丢弃。必填参数明写 + 可选参数仅关键字,让拼错在TypeError阶段暴露。 -
*以为 args 是列表,试图原地修改:每次调用都新建元组,
append直接报AttributeError:
# 错误:元组没有 append
# def add_guest(*guests): guests.append("teto")
# 正确:转成列表再操作,或从一开始就接受列表参数
def add_guest(*guests):
roster = list(guests)
roster.append("teto")
return roster
- 调用解包的键与形参不匹配:字典里有形参没有的键会抛
TypeError: unexpected keyword argument。先过滤或显式挑选需要的键,不要把任意配置字典整体灌进函数:
# 错误:配置里有函数不认识的键
# book_ticket("c001", "A12", 680, **{"coupon": "X", "vip": True})
# 正确:按需挑选
config = {"coupon": "X", "insurance": True}
book_ticket("c001", "A12", 680, **{k: config[k] for k in ("insurance",) if k in config})
-
合并字典顺序写反:
{**custom, **defaults}让默认值覆盖了用户配置。固定口诀”默认在前,定制在后”;用|时同理是右操作数胜出。 -
转发时忘记二次解包:包装函数把
args/kwargs当普通参数传下去,形参只收到一个元组和一个字典:
# 错误:单层传递,f 只收到两个参数
# def wrapper(*args, **kwargs): return f(args, kwargs)
# 正确:调用处再次解包
def wrapper(*args, **kwargs):
return f(*args, **kwargs)
本篇小结
- 星号一词两义:定义侧收集(
*args成元组、**kwargs成字典),调用侧展开(*seq铺位置、**dct铺关键字),方向相反、符号同一。 /与*是签名里的两道分界线:仅位置参数保护形参名的演进自由,仅关键字参数保护调用点的可读性;两道线一起用就是最稳的公共签名。- 解包合并是装配数据的标准姿势:
[*a, *b]铺平序列,{**defaults, **custom}合并字典且后者胜出。 - 签名设计优先级:明写必填 > 仅关键字开关 >
*args/**kwargs兜底;转发场景(装饰器、代理)才是可变参数的主场。 - 报错是朋友:
TypeError指出的多余键、缺参、位置错误,正是这套显式签名体系在替你挡住静默 bug——把每次 TypeError 都当成一次签名教育,而不是麻烦。
动手实践
- 通用重试装饰器:写一个
retry(times=3)装饰器,能装饰任意签名的购票函数并转发参数,用functools.wraps保留元信息。提示:def wrapper(*args, **kwargs)配合调用侧双星解包;测试时分别装饰位置参数与关键字参数风格的函数。 - 配置合并器:实现
merge_settings(defaults, *overrides),按传入顺序依次覆盖,返回合并后的字典,并保证用户配置永远压过默认值。提示:循环里用{**acc, **item}或acc |= item,最后用”默认在前”的单测验证覆盖方向。 - 签名重构:找一个
def f(*args, **kwargs)风格的旧函数(或自己上篇的作业),按”必填明写、开关仅关键字”重构成新签名,并让旧调用方式仍然可用。提示:新签名加/与*分段,旧入口用*args, **kwargs转发到新函数,两代签名并存过渡。重构时逐条核对新旧调用的参数形态,确保没有任何调用方被静默改变行为。