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

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

## 引言 这是第四章「函数」的第三篇。前两篇我们掌握了函数的定义、参数传递方式和作用域规则。但实际工程中,函数的接口往往比 `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` 而不是直接让函数接受一个列表参数?从接口语义和调用便利性两个角度分析。