AI 技术博客
返回首页
Python 基础 27 分钟阅读

可变参数:*args、**kwargs 与解包

Python可变参数argskwargs解包

引言

这是第四章「函数」的第三篇。前两篇我们掌握了函数的定义、参数传递方式和作用域规则。但实际工程中,函数的接口往往比 def func(a, b) 复杂得多:有的函数需要接收任意数量的文件名、有的函数要处理不同格式的数据库查询条件、还有的函数需要对多个数据序列做合并操作。如果每个新场景都重新定义函数签名,代码就会迅速膨胀。

本篇要解决的核心问题是:如何让一个函数接受数量不确定的参数? Python 提供了两个强大的工具——*args 和 **kwargs——来应对这种需求。配合解包操作(*list、**dict),它们能让你写出既灵活又安全的接口。

读完本篇,你将能够:

  • 理解 *args 如何将任意数量的位置参数打包为元组;
  • 理解 **kwargs 如何将任意关键字参数打包为字典;
  • 掌握解包操作的语法和三种实际用法(函数调用、赋值、字典合并);
  • 记住参数顺序的硬性规则,避免 SyntaxError;
  • 在实际项目中使用可变参数解决配置合并、日志解析、数据聚合等常见问题。

阅读前提:已掌握第一篇的函数定义、参数类型和第二篇的作用域概念。

概念与原理

*args:可变位置参数

*args 是 Python 中「可变位置参数」的约定写法。* 表示「收集」,args 是 arguments 的缩写——它不是一个关键字,而是一个命名约定。你可以写 *x 或 *whatever,但为了代码可读性,几乎所有 Python 开发者都使用 *args。

当函数定义中包含 *args 时,所有额外的位置参数会被自动收集到一个**元组(tuple)**中:

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:

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)**中:

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 是「收集」操作,那么解包就是「展开」操作——把序列或字典拆开,逐个传给函数:

* 解包列表/元组:

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.

** 解包字典:

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):

# 基本解包
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 文件的每一行:

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):

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() 一致,但更简洁、更易读,而且不会修改原始字典。

迭代中的解包:

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。记住下面的顺序:

函数定义时的参数顺序:

def func(位置参数, *args, 关键字-only参数, **kwargs):

- 位置参数(普通参数)在最前面;
- `*args` 在位置参数之后(如果有的话);
- 关键字-only 参数(`*` 后面的参数)在 `*args` 之后;
- `**kwargs` 在最后。
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}

函数调用时的参数顺序:

func(位置参数, *可迭代对象, 关键字参数, **字典)

- 位置参数最先;
- `*` 解包紧随其后;
- 关键字参数再后;
- `**` 解包最后。
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() 本身也接受多个位置参数,但自定义统计器可以扩展更多功能:

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}

这个函数可以接收任意数量的实参,而不需要调用方把它们包装成列表。当然,如果你已经有一个列表,也可以用 * 解包后传入:

scores = [85, 92, 78, 90, 88]
print(stats(*scores))  # 与直接传参数效果相同

场景二:配置合并工具

在实际项目中,经常需要从多个来源合并配置(默认值、用户配置、环境变量)。可变参数和解包让这件事变得优雅:

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 是完美的工具:

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")

输出:

[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: 

`*extras` 让日志函数可以接收任意数量的上下文信息,而 `fmt=` 关键字参数则控制输出格式,两者互不干扰。

### 场景四:SQL 查询构建器

在处理动态查询条件时,`**kwargs` 特别有用——调用方可以传入任意数量的过滤条件,函数负责组装 SQL:
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 是转发参数的标准做法。这种模式让你在不修改目标函数的前提下,添加额外的功能:

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 可以用来接收任意数量的处理函数,形成一个灵活的管道:

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:

# 以下写法都是错误的(取消注释会报 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 解包也必须放在所有位置参数和关键字参数之后:

# 错误调用示例(注释掉以验证语法)
# 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}) # 正确:** 永远在最后

记忆口诀:* 在前,** 在后;调用时 ** 永远最后。

陷阱二:* 解包空容器行为

* 解包空列表或空元组是完全合法的,此时相当于没有传入任何额外参数。然而这可能导致你遗漏了必要的参数:

def greet(greeting, name):
    return f"{greeting}, {name}!"

empty = []
# greet(*empty)  # TypeError: greet() missing 2 required positional arguments
# *empty 解包后等价于没有参数,greeting 和 name 都缺了

在使用解包前,务必确认数据结构中包含足够的元素。可以用长度检查来防御:

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:

def show(a, b):
    print(a, b)

show(a=1, **{"a": 2})  # TypeError: show() got multiple values for argument 'a'

这是防御性编程的好机会:如果你的函数需要同时接受显式参数和字典解包,应该在文档中明确说明不允许重叠的键名,或者在函数内部做校验。

陷阱四:解包时传入非可迭代对象

* 后面必须跟一个可迭代对象(列表、元组、字符串、生成器等)。传入数字、布尔值等不可迭代对象会报 TypeError:

def add(a, b, c):
    return a + b + c

add(1, 2, *3)  # TypeError: 'int' object is not iterable

同样,** 后面必须跟一个字典或映射对象,不能传非字典类型:

def show(**kwargs):
    print(kwargs)

show(**"hello")  # TypeError: 'str' does not support ** unpacking

陷阱五:* 和 ** 在函数定义与调用中的不对称性

在函数定义中,* 和 ** 用于收集多余参数;在函数调用中,它们用于展开数据结构。这个对称性容易让人混淆:

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. 下面这段代码输出什么?为什么?

    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 而不是直接让函数接受一个列表参数?从接口语义和调用便利性两个角度分析。