## 引言
这是第四章「函数」的第三篇。前两篇我们掌握了函数的定义、参数传递方式和作用域规则。但实际工程中,函数的接口往往比 `def func(a, b)` 复杂得多:有的函数需要接收任意数量的文件名、有的函数要处理不同格式的数据库查询条件、还有的函数需要对多个数据序列做合并操作。如果每个新场景都重新定义函数签名,代码就会迅速膨胀。
本篇要解决的核心问题是:**如何让一个函数接受数量不确定的参数?** Python 提供了两个强大的工具——`*args` 和 `**kwargs`——来应对这种需求。配合解包操作(`*list`、`**dict`),它们能让你写出既灵活又安全的接口。
读完本篇,你将能够:
- 理解 `*args` 如何将任意数量的位置参数打包为元组;
- 理解 `**kwargs` 如何将任意关键字参数打包为字典;
- 掌握解包操作的语法和三种实际用法(函数调用、赋值、字典合并);
- 记住参数顺序的硬性规则,避免 `SyntaxError`;
- 在实际项目中使用可变参数解决配置合并、日志解析、数据聚合等常见问题。
阅读前提:已掌握第一篇的函数定义、参数类型和第二篇的作用域概念。
## 概念与原理
### *args:可变位置参数
`*args` 是 Python 中「可变位置参数」的约定写法。`*` 表示「收集」,`args` 是 `arguments` 的缩写——它不是一个关键字,而是一个命名约定。你可以写 `*x` 或 `*whatever`,但为了代码可读性,几乎所有 Python 开发者都使用 `*args`。
当函数定义中包含 `*args` 时,所有额外的位置参数会被自动收集到一个**元组(tuple)**中:
```python
def sum_all(*args):
total = 0
for n in args:
total += n
return total
print(sum_all(1, 2, 3)) # 6
print(sum_all(10, 20, 30, 40)) # 100
print(sum_all()) # 0(args 是空元组 ())
```
`args` 在函数体内是一个普通的元组,你可以对它执行所有元组操作:索引、切片、迭代、`len()`、`in` 成员检查等。但注意,元组是不可变的,不能对它执行 `append()` 或 `remove()` 等操作——如果需要修改,先转为列表。
`*args` 也可以与其他参数组合使用。前面的位置参数正常接收,超出部分全部进入 `*args`:
```python
def log(level, message, *extra_details):
timestamp = "2024-01-15T10:30:00"
details = " ".join(str(d) for d in extra_details)
print(f"[{timestamp}] [{level}] {message} {details}".strip())
log("ERROR", "连接超时", "host=api.example.com", "port=443")
# [2024-01-15T10:30:00] [ERROR] 连接超时 host=api.example.com port=443
```
第一个参数 `level` 和第二个参数 `message` 是固定的位置参数,后面的所有额外位置参数都被收集到 `extra_details` 这个元组中。即使你没有传任何额外参数(`log("INFO", "正常")`),`extra_details` 也是一个合法的空元组 `()`,不会报错。
### **kwargs:可变关键字参数
`**kwargs` 是「可变关键字参数」的约定写法。`**` 表示「收集到字典」,`kwargs` 是 `keyword arguments` 的缩写。所有额外的关键字参数会被收集到一个**字典(dict)**中:
```python
def build_profile(**kwargs):
print(f"共有 {len(kwargs)} 个字段:")
for key, value in kwargs.items():
print(f" {key}: {value}")
build_profile(name="Alice", age=25, city="Beijing", occupation="engineer")
# 共有 4 个字段:
# name: Alice
# age: 25
# city: Beijing
# occupation: engineer
```
`**kwargs` 在函数体内是一个普通的字典,你可以用它做任何字典操作:查询键值、遍历、添加或删除条目。同样注意,键必须是字符串(关键字参数的名字本身就是字符串),值可以是任意类型。
### 解包:反过来的操作
如果说 `*args` 和 `**kwargs` 是「收集」操作,那么解包就是「展开」操作——把序列或字典拆开,逐个传给函数:
**`*` 解包列表/元组:**
```python
def greet(greeting, name, punctuation="!"):
return f"{greeting}, {name}{punctuation}"
names = ["World", "Alice"]
print(greet("Hello", *names)) # Hello, World!
# 解包后还可以混入其他参数
print(greet(*names, punctuation=".")) # Hello, World.
```
**`**` 解包字典:**
```python
user = {"greeting": "Hi", "name": "Bob", "punctuation": "?"}
print(greet(**user)) # Hi, Bob?
# 解包字典时,键必须匹配函数的参数名
config = {"host": "localhost", "port": 8080}
def connect(host, port):
return f"{host}:{port}"
print(connect(**config)) # localhost:8080
```
解包操作在函数调用中非常常见。它让你能够把已经存储在数据结构中的参数「动态地」传给函数,这是编写通用工具和框架的核心技巧之一。
### 赋值解包:不只是函数调用
解包操作(`*` 和 `**`)不仅可以用于函数调用,还可以用于变量赋值和数据结构构建。这种通用的语法让 Python 在处理序列和映射时非常灵活。
**序列解包(unpacking assignment):**
```python
# 基本解包
first, second, third = [1, 2, 3]
print(first, second, third) # 1 2 3
# 扩展解包:用 * 收集剩余元素
head, *middle, tail = [1, 2, 3, 4, 5]
print(f"head={head}, middle={middle}, tail={tail}")
# head=1, middle=[2, 3, 4], tail=5
# 只取第一个,其余全部收起
first, *rest = range(100)
print(first) # 0
print(len(rest)) # 99
```
扩展解包在解析结构化数据时非常有用。例如处理 CSV 文件的每一行:
```python
def parse_csv_row(line):
"""解析 CSV 行,前两个字段固定,后续字段全部放入 extras"""
parts = line.split(",")
name, age, *extras = parts
return {"name": name, "age": int(age), "extras": extras}
row = "Alice,25,Beijing,engineer,Python"
print(parse_csv_row(row))
# {'name': 'Alice', 'age': 25, 'extras': ['Beijing', 'engineer', 'Python']}
```
**字典解包(dict unpacking):**
```python
base = {"x": 1, "y": 2}
extended = {**base, "z": 3}
print(extended) # {'x': 1, 'y': 2, 'z': 3}
# 同名键:后面的覆盖前面的
merged = {**{"a": 1, "b": 2}, "b": 99, "c": 3}
print(merged) # {'a': 1, 'b': 99, 'c': 3}
```
字典解包在配置合并、数据迁移等场景中极为常见。它的语义与 `dict.update()` 一致,但更简洁、更易读,而且不会修改原始字典。
**迭代中的解包:**
```python
pairs = [("a", 1), ("b", 2), ("c", 3)]
for key, value in pairs:
print(f"{key} -> {value}")
# 嵌套解包
matrix = [[1, 2, 3], [4, 5, 6]]
for row in matrix:
first, *rest = row
print(f"首元素={first}, 其余={rest}")
```
### 参数顺序规则
Python 对函数定义和调用中的参数顺序有严格的规则,违反会报 `SyntaxError` 或 `TypeError`。记住下面的顺序:
**函数定义时的参数顺序:**
```text
def func(位置参数, *args, 关键字-only参数, **kwargs):
```
```text
- 位置参数(普通参数)在最前面;
- `*args` 在位置参数之后(如果有的话);
- 关键字-only 参数(`*` 后面的参数)在 `*args` 之后;
- `**kwargs` 在最后。
```
```python
def full_order(a, b, *args, c, d, **kwargs):
print(f"a={a}, b={b}, args={args}, c={c}, d={d}, kwargs={kwargs}")
full_order(1, 2, 3, 4, c=5, d=6, e=7, f=8)
# a=1, b=2, args=(3, 4), c=5, d=6, kwargs={'e': 7, 'f': 8}
```
**函数调用时的参数顺序:**
```text
func(位置参数, *可迭代对象, 关键字参数, **字典)
```
```text
- 位置参数最先;
- `*` 解包紧随其后;
- 关键字参数再后;
- `**` 解包最后。
```
```python
data = [1, 2]
extra = {"c": 5, "d": 6}
full_order(*data, 3, 4, **extra, e=7, f=8)
# a=1, b=2, args=(3, 4), c=5, d=6, kwargs={'e': 7, 'f': 8}
```
这些规则看似繁琐,但本质很简单:Python 先按位置分配参数,再按名字分配,`*` 和 `**` 分别负责收集和展开。只要记住「位置先于名字,收集先于展开」,就不会出错。
## 实现
下面通过五个递进的场景,展示可变参数和解包的实用价值。
### 场景一:通用数值统计器
最直观的应用是处理任意数量的数据点。标准库的 `sum()`、`max()`、`min()` 本身也接受多个位置参数,但自定义统计器可以扩展更多功能:
```python
def stats(*numbers):
"""计算任意数量数字的统计量"""
if not numbers:
return {"count": 0, "sum": 0, "mean": 0, "min": None, "max": None}
total = sum(numbers)
count = len(numbers)
return {
"count": count,
"sum": total,
"mean": total / count,
"min": min(numbers),
"max": max(numbers)
}
print(stats(10, 20, 30, 40, 50))
# {'count': 5, 'sum': 150, 'mean': 30.0, 'min': 10, 'max': 50}
print(stats(99)) # 单个值也正常工作
# {'count': 1, 'sum': 99, 'mean': 99.0, 'min': 99, 'max': 99}
```
这个函数可以接收任意数量的实参,而不需要调用方把它们包装成列表。当然,如果你已经有一个列表,也可以用 `*` 解包后传入:
```python
scores = [85, 92, 78, 90, 88]
print(stats(*scores)) # 与直接传参数效果相同
```
### 场景二:配置合并工具
在实际项目中,经常需要从多个来源合并配置(默认值、用户配置、环境变量)。可变参数和解包让这件事变得优雅:
```python
DEFAULT_CONFIG = {
"host": "localhost",
"port": 8080,
"debug": False,
"timeout": 30,
"retries": 3
}
def build_config(**overrides):
"""合并默认配置和用户覆盖"""
config = {**DEFAULT_CONFIG, **overrides}
return config
print(build_config(port=9090, debug=True))
# {'host': 'localhost', 'port': 9090, 'debug': True, 'timeout': 30, 'retries': 3}
print(build_config(host="api.example.com", timeout=60))
# {'host': 'api.example.com', 'port': 8080, 'debug': False, 'timeout': 60, 'retries': 3}
```
这里 `**overrides` 接收任意关键字参数作为覆盖项,`{**DEFAULT_CONFIG, **overrides}` 利用字典解包合并两个字典——后面的字典键会覆盖前面的同名键。这种模式在 ORM 查询构建、API 客户端配置、工具链参数传递中极为常见。
### 场景三:通用日志记录器
一个生产级的日志记录器需要处理不同数量的附加信息。`*args` 是完美的工具:
```python
import time
LOG_FORMATS = {
"simple": "[{time}] {level}: {msg}",
"verbose": "[{time}] [{level}] {msg} | extras: {extras}"
}
def log(level, msg, *extras, fmt="simple", timestamp=None):
"""通用日志函数,支持任意附加信息和格式化选项"""
ts = timestamp or time.strftime("%Y-%m-%d %H:%M:%S")
extra_str = " ".join(str(e) for e in extras)
if fmt == "simple":
print(LOG_FORMATS["simple"].format(time=ts, level=level, msg=msg))
elif fmt == "verbose":
print(LOG_FORMATS["verbose"].format(
time=ts, level=level, msg=msg, extras=extra_str
))
log("INFO", "服务器启动完成")
log("ERROR", "数据库连接失败", "host=db.internal", "port=5432", fmt="verbose")
log("WARN", "磁盘空间不足", remaining_gb=10, fmt="verbose")
```
输出:
```text
[2024-01-15 10:30:00] [INFO]: 服务器启动完成
[2024-01-15 10:30:00] [ERROR] 数据库连接失败 | extras: host=db.internal port=5432
[2024-01-15 10:30:00] [WARN] 磁盘空间不足 | extras:
```
```text
`*extras` 让日志函数可以接收任意数量的上下文信息,而 `fmt=` 关键字参数则控制输出格式,两者互不干扰。
### 场景四:SQL 查询构建器
在处理动态查询条件时,`**kwargs` 特别有用——调用方可以传入任意数量的过滤条件,函数负责组装 SQL:
```
```python
def build_query(table, **filters):
"""根据过滤条件构建 SELECT 语句"""
if not filters:
return f"SELECT * FROM {table}"
conditions = []
for key, value in filters.items():
if isinstance(value, str):
conditions.append(f"{key}='{value}'")
else:
conditions.append(f"{key}={value}")
return f"SELECT * FROM {table} WHERE {' AND '.join(conditions)}"
print(build_query("users", status="active", age=25))
# SELECT * FROM users WHERE status='active' AND age=25
print(build_query("orders", status="shipped", amount_gt=100))
# SELECT * FROM orders WHERE status='shipped' AND amount_gt=100
print(build_query("products"))
# SELECT * FROM products
```
注意:实际生产环境应使用参数化查询(`?` 占位符)防止 SQL 注入,这里仅展示 `**kwargs` 的结构化用法。
### 场景五:代理包装函数
在编写装饰器或代理函数时,`*args` 和 `**kwargs` 是转发参数的标准做法。这种模式让你在不修改目标函数的前提下,添加额外的功能:
```python
def logging_wrapper(func):
"""给任意函数加上调用日志"""
def wrapper(*args, **kwargs):
print(f"调用 {func.__name__},参数: args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} 返回: {result}")
return result
return wrapper
@logging_wrapper
def add(a, b):
return a + b
@logging_wrapper
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
add(3, 4)
# 调用 add,参数: args=(3, 4), kwargs={}
# add 返回: 7
greet("Alice", greeting="Hi")
# 调用 greet,参数: args=(), kwargs={'name': 'Alice', 'greeting': 'Hi'}
# greet 返回: Hi, Alice!
```
这里 `wrapper` 使用 `*args` 和 `**kwargs` 接收所有传入的参数,然后原样转发给 `func`。这样无论目标函数需要几个参数、什么名字的关键字参数,包装函数都能正确工作。这个模式是后面第 5 篇装饰器的基础。
### 场景六:批量数据处理管道
在数据处理流水线中,经常需要将多个处理步骤串联起来。`*args` 可以用来接收任意数量的处理函数,形成一个灵活的管道:
```python
def pipe(data, *processors):
"""将数据依次通过多个处理函数"""
for processor in processors:
data = processor(data)
return data
def clean(text):
return text.strip()
def upper(text):
return text.upper()
def add_tag(text):
return f"[LOG] {text}"
result = pipe(" hello world ", clean, upper, add_tag)
print(result) # [LOG] HELLO WORLD
```
这种管道模式在数据清洗、文本转换、文件处理等场景中非常实用。每个处理函数保持单一职责,管道函数负责调度,两者解耦。
## 易错点与陷阱
### 陷阱一:参数顺序错误导致 SyntaxError
定义函数时,`*args` 必须在普通参数之后、`**kwargs` 之前。违反顺序会直接报 `SyntaxError`:
```python
# 以下写法都是错误的(取消注释会报 SyntaxError):
# def bad(a, **kwargs, *args): # SyntaxError: cannot use *args after **kwargs
# pass
# def also_bad(*args, a, b): # SyntaxError: cannot use positional args after *args
# pass
# 正确写法
def good(a, b, *args, **kwargs):
pass
```
同理,在函数调用中,`**dict` 解包也必须放在所有位置参数和关键字参数之后:
```python
# 错误调用示例(注释掉以验证语法)
# func(1, 2, **{"a": 3}) # SyntaxError: ** must appear after all other arguments
# func(**{"a": 1}, 2) # SyntaxError: positional argument follows keyword argument
# func(1, 2, c=3, **{"a": 3}) # SyntaxError: ** unpacking must be last
# 正确调用
func(1, 2, a=3) # 正确
func(1, 2, **{"a": 3, "c": 4}) # 正确:** 永远在最后
```
记忆口诀:**`*` 在前,`**` 在后;调用时 `**` 永远最后**。
### 陷阱二:\* 解包空容器行为
`*` 解包空列表或空元组是完全合法的,此时相当于没有传入任何额外参数。然而这可能导致你遗漏了必要的参数:
```python
def greet(greeting, name):
return f"{greeting}, {name}!"
empty = []
# greet(*empty) # TypeError: greet() missing 2 required positional arguments
# *empty 解包后等价于没有参数,greeting 和 name 都缺了
```
在使用解包前,务必确认数据结构中包含足够的元素。可以用长度检查来防御:
```python
def safe_greet(data):
if len(data) < 2:
raise ValueError("需要至少两个元素:问候语和名字")
greeting, name, *extra = data
punct = extra[0] if extra else "!"
return f"{greeting}, {name}{punct}"
```
### 陷阱三:** 解包时键名冲突
当同时使用关键字参数和 `**dict` 解包时,如果键名与显式关键字参数重复,会报 `TypeError`:
```python
def show(a, b):
print(a, b)
show(a=1, **{"a": 2}) # TypeError: show() got multiple values for argument 'a'
```
这是防御性编程的好机会:如果你的函数需要同时接受显式参数和字典解包,应该在文档中明确说明不允许重叠的键名,或者在函数内部做校验。
### 陷阱四:解包时传入非可迭代对象
`*` 后面必须跟一个可迭代对象(列表、元组、字符串、生成器等)。传入数字、布尔值等不可迭代对象会报 `TypeError`:
```python
def add(a, b, c):
return a + b + c
add(1, 2, *3) # TypeError: 'int' object is not iterable
```
同样,`**` 后面必须跟一个字典或映射对象,不能传非字典类型:
```python
def show(**kwargs):
print(kwargs)
show(**"hello") # TypeError: 'str' does not support ** unpacking
```
### 陷阱五:\* 和 \*\* 在函数定义与调用中的不对称性
在函数**定义**中,`*` 和 `**` 用于**收集**多余参数;在函数**调用**中,它们用于**展开**数据结构。这个对称性容易让人混淆:
```python
def process(a, *args, **kwargs):
print(f"a={a}, args={args}, kwargs={kwargs}")
# 调用时展开
items = [1, 2, 3]
options = {"x": 10}
process(*items, **options)
# a=1, args=(2, 3), kwargs={'x': 10}
# 注意:* 和 ** 的"方向"是相反的
# 定义时:* 把多个值打包成一个 tuple
# 调用时:* 把一个 iterable 拆成多个值
```
理解这种方向性差异,可以避免在编写包装函数和代理模式时出现参数丢失或重复的错误。
## 小结
本篇系统讲解了 Python 可变参数和解包的两个核心工具:`*args` 和 `**kwargs`。`*args` 将任意数量的位置参数打包成元组,`**kwargs` 将任意关键字参数打包成字典——两者互补,覆盖了函数参数的大部分动态场景。解包操作(`*list` 和 `**dict`)则是收集的反向操作,让你能够把数据结构中的内容「展开」为函数的参数,也可以用于变量赋值和字典合并。
参数顺序是有硬性规则的:定义时位置→`*args`→关键字-only→`**kwargs`,调用时位置→`*`解包→关键字→`**`解包。遵守这些规则,代码就能既灵活又可靠。最后,我们通过六个真实场景(数值统计、配置合并、日志记录、SQL 构建、代理包装、数据管道),展示了这些技术在实际工程中的价值。
下一篇我们将学习 lambda 函数——一种更紧凑的匿名函数写法,它是高阶函数和函数式编程风格的基础。
## 练习与思考题
1. 编写函数 `merge_dicts(*dicts)`,接收任意数量的字典并合并为一个。后面的字典覆盖前面的同名键。例如 `merge_dicts({"a": 1}, {"b": 2}, {"a": 3})` 返回 `{"a": 3, "b": 2}`。
2. 下面这段代码输出什么?为什么?
```python
def foo(*args):
print(args)
foo(*[1, 2], *[3, 4])
```
3. 编写一个函数 `flatten(*sequences)`,接收任意数量的可迭代对象,将它们展平为一个列表。例如 `flatten([1, 2], [3, 4], [5])` 返回 `[1, 2, 3, 4, 5]`。
4. 思考题:Python 为什么用 `*args` 而不是直接让函数接受一个列表参数?从接口语义和调用便利性两个角度分析。