Python解包详解:从序列解包到函数参数 unpacking

📅 2026/7/15 10:41:50 👁️ 阅读次数 📝 编程学习
Python解包详解:从序列解包到函数参数 unpacking

1. 什么是 Python 中的解包?一句话说清它到底在干啥

“Unpacking in Python”——这个词刚接触时,很多人第一反应是:“这不就是把 zip 文件解压吗?”其实完全不是。它和文件压缩解压毫无关系,而是 Python 里一种极其自然、极其高频、几乎每天都在用,但新手却常常“用了却不知其名”的核心语法机制。简单说:解包,就是把一个可迭代对象(比如列表、元组、字典、字符串)里的元素,一次性“倒出来”,分别赋值给多个变量的过程。比如a, b, c = [1, 2, 3]这行代码,表面看是赋值,本质就是一次标准的序列解包;再比如name, age, city = user_info,只要user_info是个三元素的元组,这行就稳稳运行。它不是某个库的高级功能,而是 Python 解释器原生支持的底层语法糖,从 Python 2.0 就存在,到今天已演化得极为成熟。我带过不少转行学 Python 的学员,发现一个共性现象:他们能熟练写for item in my_list:,却在看到first, *middle, last = my_list时愣住,甚至怀疑自己写的不是 Python。这恰恰说明,解包不是“可选项”,而是理解 Python 数据流逻辑的一把钥匙——它把“整体”和“个体”的关系表达得无比清晰。对初学者,它是让代码变简洁的第一步;对老手,它是写出高可读性、低冗余度函数签名的关键;对数据工程师,它是处理 CSV 行、API 响应、数据库记录的默认姿势。你不需要安装任何包,也不需要导入特殊模块,只要你的 Python 版本 ≥2.0(现在谁还用这么老的版本?),解包就已经在你键盘底下静静待命了。

2. 解包的底层逻辑与设计哲学:为什么 Python 要这样设计?

2.1 它不是语法糖,而是语言契约的一部分

很多人误以为解包只是“写起来方便”,其实它背后承载着 Python 的核心设计哲学:显式优于隐式,简单优于复杂,可读性至关重要。我们来对比两种写法:

# 方式一:传统索引取值(隐式、冗长、易错) data = ['Alice', 28, 'Shanghai'] name = data[0] age = data[1] city = data[2] # 方式二:解包(显式、简洁、自文档化) name, age, city = data

表面看只是少写了三行,但深层差异巨大。方式一中,data[0]到底代表什么?只有上下文或注释能告诉你;而方式二中,变量名name,age,city直接声明了每个位置的语义,无需额外解释。Python 解释器在执行a, b, c = iterable时,并非简单地“拆开再赋值”,而是触发了一套完整的协议:它会先检查右侧对象是否实现了__iter__方法(即是否可迭代),然后调用其迭代器,逐个取出元素,再按左侧变量顺序一一绑定。这个过程是原子性的——要么全部成功,要么抛出ValueError: too many values to unpacknot enough values to unpack。这种“全有或全无”的特性,让解包天然具备强校验能力。我在做金融数据清洗时,API 返回的每条记录固定为 7 个字段:[symbol, open_price, high, low, close, volume, timestamp]。如果用索引取值,某天接口悄悄多返回一个字段,close = data[4]可能就错位成volume,导致后续所有计算全盘错误,且极难排查;而用解包symbol, open_p, high, low, close, vol, ts = row,一旦字段数不对,程序立刻报错中断,反而保护了数据完整性。这就是设计哲学落地的真实价值:它用语法强制你面对结构契约,而不是靠人肉记忆去维护。

2.2 解包与“可迭代协议”的深度绑定

Python 中一切皆对象,而“可迭代性”是对象的一种能力,不是类型决定的。一个类只要实现了__iter__方法(返回迭代器),或者实现了__getitem__方法(支持整数索引且索引越界时抛IndexError),它就被视为可迭代对象,就能参与解包。这意味着解包的适用范围远超listtuple。举个实际例子:我曾写过一个日志解析器,需要从一行文本中提取时间、IP、状态码、响应大小四个字段,原始行是"2023-10-05T08:32:15Z 192.168.1.100 200 1245"。最直接的做法是line.split(),得到['2023-10-05T08:32:15Z', '192.168.1.100', '200', '1245'],然后解包。但更优雅的是自定义一个LogLine类:

class LogLine: def __init__(self, raw_line): self.parts = raw_line.split() def __iter__(self): return iter(self.parts) # 支持 len() 和索引访问,增强兼容性 def __len__(self): return len(self.parts) def __getitem__(self, index): return self.parts[index] # 使用时完全透明 log = LogLine("2023-10-05T08:32:15Z 192.168.1.100 200 1245") time, ip, status, size = log # 完美解包!

这里没有用list,但解包照样工作。因为LogLine遵循了 Python 的可迭代协议,解释器就认可它。这种设计让解包成为一种通用的数据“接口适配器”,而不是针对某几个内置类型的特例。再比如,dict默认解包的是键(k1, k2 = {'a':1, 'b':2}得到'a''b'),但你可以用d.items()解包键值对:k, v = list(my_dict.items())[0],或者更地道的for k, v in my_dict.items(): ...——后者本质上是每次循环都进行一次解包。所以,理解解包,首先要理解 Python 的协议(Protocol)思想:它不看你叫什么名字,只看你有没有提供约定好的方法。这是 Python 灵活性的根基,也是解包能如此普适的原因。

2.3 解包不是“拆开”,而是“结构匹配”

一个常被忽略的关键点是:解包的本质是模式匹配(Pattern Matching),而非简单的“切分”。Python 3.10 引入了match/case语法,但早在那之前,解包就已经是轻量级的模式匹配了。看这个经典例子:

# 匹配固定长度结构 x, y = (10, 20) # 成功:两个变量匹配两个元素 # 匹配可变长度结构(星号解包) first, *middle, last = [1, 2, 3, 4, 5] # first=1, middle=[2,3,4], last=5 # 匹配嵌套结构(嵌套解包) (a, b), (c, d) = [(1, 2), (3, 4)] # a=1,b=2,c=3,d=4

注意第三行:[(1, 2), (3, 4)]是一个包含两个元组的列表,左侧(a, b), (c, d)不是一个元组,而是两个并列的元组模式。解释器会先将列表解包为两个元素,再对每个元素(本身是元组)分别进行解包。这种嵌套能力,让解包能自然地处理树状、层级化的数据。我在处理 JSON API 响应时经常遇到:response = {"data": [{"id":1,"name":"A"},{"id":2,"name":"B"}], "meta": {"count":2}}。要提取第一个用户的 ID 和 name,可以这样写:

# 一步到位,无需中间变量 ((first_user_id, first_user_name), *_), _ = ( [(item["id"], item["name"]) for item in response["data"]], response["meta"] )

虽然这行有点炫技,但它清晰表达了意图:我要的是data列表中每个对象的(id, name)元组构成的列表,然后取第一个元组,再解包它的两个字段。整个过程是声明式的,而不是命令式的。这种“结构即代码”的思维,正是 Python 高效开发的精髓。它要求你提前想清楚数据的形状(Shape),然后用同样形状的变量模式去“套”它。一旦形状不匹配,立刻报错,逼你修正假设。这比写一堆if 'key' in dict的防御性代码,要干净利落得多。

3. 四大核心解包场景与实操细节:从入门到进阶

3.1 序列解包(Sequence Unpacking):最基础也最常用

这是所有解包的起点,适用于任何长度固定的可迭代对象。核心规则就两条:变量数必须等于元素数,或使用星号*处理可变部分。先看最标准的三元素解包:

# 标准三元组解包 point_3d = (10.5, -3.2, 7.8) x, y, z = point_3d print(f"x={x}, y={y}, z={z}") # x=10.5, y=-3.2, z=7.8

这里point_3d是一个tuple,但换成listrange(3)甚至str都行:a, b, c = "xyz"同样成立。关键在于“三个东西倒进三个杯子”。但现实数据 rarely 完美。比如你有一个四元素列表coords = [1, 2, 3, 4],但你只关心前两个坐标,后面是冗余信息。这时不能硬写x, y = coords,会报错。正确做法是用_占位符(约定俗成表示“我不关心这个值”):

x, y, _, _ = coords # x=1, y=2, 忽略后两个 # 更 Pythonic 的写法是用星号收集多余项 x, y, *rest = coords # x=1, y=2, rest=[3,4]

*rest是解包的灵魂扩展。它必须是唯一的,且只能出现在左侧变量列表的某个位置(开头、中间、结尾均可)。*后面的变量名可以是任意合法标识符,rest只是习惯。*的结果永远是一个list,即使只收集一个元素或零个元素。比如:

# 星号在开头 *head, tail = [1, 2, 3, 4] # head=[1,2,3], tail=4 # 星号在中间(最实用!) first, *middle, last = ['a', 'b', 'c', 'd', 'e'] # first='a', middle=['b','c','d'], last='e' # 星号在结尾 head, *tail = [1, 2, 3, 4] # head=1, tail=[2,3,4] # 星号收集零个元素 a, *empty, b = [1, 2] # a=1, empty=[], b=2 —— 完全合法!

提示:*解包时,如果右侧可迭代对象为空,*变量会得到空列表[];但如果右侧元素数不足以满足非星号变量,依然会报ValueError。例如a, b, *c = [1]会失败,因为ab都需要值。

实操心得:我在代码审查中发现,新手常犯的错误是滥用*。比如处理一个已知长度为 5 的列表,却写a, *b, c = my_list,这会让b变成一个长度为 3 的列表,而你真正想要的可能是a, b, c, d, e = my_list。过度使用*会削弱类型提示和 IDE 的自动补全能力。我的建议是:优先用固定数量解包,仅在确实需要处理可变长度时才引入*这样代码意图更清晰,调试也更容易。

3.2 字典解包(Dictionary Unpacking):键值对的精准捕获

字典解包有两种截然不同的模式,极易混淆,必须分清:

模式一:解包字典的键(默认行为)

user = {'name': 'Bob', 'age': 35, 'city': 'Beijing'} k1, k2, k3 = user # k1='name', k2='age', k3='city' # 注意:这里解包的是 key,不是 value!

模式二:解包字典的键值对(需显式调用.items()

user = {'name': 'Bob', 'age': 35} # 正确:用 items() 得到 (key, value) 元组 (k1, v1), (k2, v2) = user.items() # k1='name', v1='Bob', k2='age', v2=35 # 更常见的是在 for 循环中 for key, value in user.items(): print(f"{key}: {value}")

为什么user.items()能解包?因为dict_items对象是可迭代的,它产生的每个元素就是一个二元元组(key, value),所以(k, v)这个模式正好匹配。这是解包嵌套结构的典型应用。另一个重要场景是函数参数传递。假设你有一个配置字典config = {'host': 'localhost', 'port': 8000, 'debug': True},而你的函数定义为def connect(host, port, debug=False)。你可以用双星号**将字典“展开”为关键字参数:

def connect(host, port, debug=False): print(f"Connecting to {host}:{port}, debug={debug}") config = {'host': 'localhost', 'port': 8000, 'debug': True} connect(**config) # 等价于 connect(host='localhost', port=8000, debug=True)

**是字典解包的专用操作符,它要求字典的键必须是函数的合法参数名,且不能有重复或缺失。如果config里多了个'timeout'键,而connect函数不接受它,就会报TypeError: connect() got an unexpected keyword argument 'timeout'。这其实是好事——它强制你在调用前确保参数契约一致。我在微服务间调用时,就用这个技巧统一管理配置:所有服务的启动参数都从一个 YAML 文件加载为字典,然后service.run(**config),既灵活又安全。

注意:单星号*用于解包序列(如列表、元组),双星号**用于解包字典(映射)。混用会报错,比如func(*my_list, **my_dict)是合法的,但func(**my_dict, *my_list)在 Python <3.5 会报错(参数顺序问题),3.5+ 允许,但语义上*应该在**前,符合常规调用习惯。

3.3 函数参数解包(Function Call Unpacking):让调用更灵活

这是解包威力最大的场景之一,它彻底打破了“函数调用必须写死参数”的限制。核心就两个操作符:*用于位置参数,**用于关键字参数。我们用一个真实案例说明:假设你正在写一个数据分析脚本,需要对一组数字求各种统计量,而statistics模块的函数(如mean,median,stdev)都接受一个可迭代对象作为参数。但你的数据可能来自不同源头:一个列表、一个生成器、甚至一个数据库查询结果集。用解包,你可以统一处理:

import statistics # 数据源1:列表 data_list = [1, 2, 3, 4, 5] # 数据源2:生成器(内存友好) def data_generator(): for i in range(1, 6): yield i # 数据源3:元组 data_tuple = (1, 2, 3, 4, 5) # 统一调用方式:用 * 解包任何可迭代对象 print(statistics.mean(*[data_list])) # 错!这是把整个列表当一个参数传 print(statistics.mean(data_list)) # 对!但不够通用 # 正确的通用写法: def safe_stats(data_iterable): # 确保 data_iterable 是可迭代的,然后直接传给 statistics 函数 # statistics 函数内部会迭代它,所以无需解包! return { 'mean': statistics.mean(data_iterable), 'median': statistics.median(data_iterable), 'stdev': statistics.stdev(data_iterable) if len(list(data_iterable)) > 1 else 0 } # 但等等,上面的 stdev 计算有问题:list(data_iterable) 会耗尽生成器! # 正确做法是先转成 list,再解包给各个函数 data_as_list = list(data_generator()) # 转成 list,可多次使用 stats = { 'mean': statistics.mean(*[data_as_list]), # 还是错!mean 只接受一个参数 'median': statistics.median(data_as_list), # 对 }

我故意在这里绕了个弯,是为了强调一个关键点:*解包是用于“把一个可迭代对象的元素,变成多个独立的参数”,而不是“把一个对象传给一个只接受一个参数的函数”。statistics.mean()只接受一个参数(一个可迭代对象),所以mean(*data_list)是错的,因为*data_list会把[1,2,3,4,5]展开成mean(1,2,3,4,5),而mean并不接受五个位置参数。正确的用法是mean(data_list)。那么*什么时候用?看这个例子:

def add_three(a, b, c): return a + b + c numbers = [10, 20, 30] result = add_three(*numbers) # 等价于 add_three(10, 20, 30)

这里add_three明确要求三个位置参数,*numbers完美匹配。再看更复杂的混合参数:

def process_data(name, *scores, threshold=60, **metadata): print(f"Processing {name} with scores {scores}") print(f"Threshold: {threshold}, Metadata: {metadata}") # 调用方式1:纯位置参数 process_data('Alice', 85, 92, 78) # 调用方式2:用 * 解包分数列表 scores_list = [85, 92, 78] process_data('Alice', *scores_list) # 调用方式3:混合 * 和 ** meta = {'department': 'Engineering', 'year': 2023} process_data('Alice', *scores_list, threshold=70, **meta)

这个函数签名def process_data(name, *scores, threshold=60, **metadata)本身就是一个解包的教科书案例:*scores收集所有额外的位置参数(变成一个 tuple),**metadata收集所有额外的关键字参数(变成一个 dict)。这让你的函数接口既严格(name必须提供),又开放(可以接收任意多的分数和任意多的元数据)。我在设计一个日志聚合服务的 API 时,就用这种签名:def log_event(event_type, *args, level='INFO', **kwargs),前端可以发log_event('user_login', user_id=123, ip='192.168.1.1'),也可以发log_event('db_query', 'SELECT * FROM users', duration_ms=12.5, rows=100),后端用同一套逻辑处理,代码复用率极高。

3.4 嵌套与高级解包:处理复杂数据结构的利器

当数据不再是扁平的,而是嵌套的字典、列表、元组组合时,解包的嵌套能力就凸显出来了。我们以一个常见的 Web API 响应为例:

# 模拟一个典型的 REST API 响应 api_response = { "status": "success", "data": [ { "id": 101, "product": {"name": "Laptop", "price": 999.99}, "orders": [{"qty": 2, "date": "2023-10-01"}, {"qty": 1, "date": "2023-10-05"}] }, { "id": 102, "product": {"name": "Mouse", "price": 29.99}, "orders": [] } ], "pagination": {"page": 1, "per_page": 10, "total": 2} } # 目标:提取第一个产品的名称和价格,以及它的第一个订单数量 # 方法1:传统链式访问(冗长且易错) if api_response["status"] == "success" and api_response["data"]: first_item = api_response["data"][0] product_name = first_item["product"]["name"] product_price = first_item["product"]["price"] first_order_qty = first_item["orders"][0]["qty"] if first_item["orders"] else 0 # 方法2:嵌套解包(清晰、安全、声明式) status, (first_item, *other_items), pagination = ( api_response["status"], api_response["data"], api_response["pagination"] ) # 现在解包 first_item id_, product, orders = first_item # 再解包 product 字典的键值对 (_, name), (_, price) = product.items() # 注意:dict.items() 返回无序,但这里只有两个键 # 解包 orders 列表的第一个订单(如果存在) if orders: (first_order,) = orders # 这里用 (first_order,) 是单元素元组解包,注意逗号! qty = first_order["qty"] else: qty = 0

这段代码看起来有点绕,但它的优势在于:每一行都在描述“数据应该长什么样”,而不是“怎么一步步拿到它”。如果api_response["data"]是空的,第二行first_item, *other_items = api_response["data"]就会直接报错ValueError: not enough values to unpack,而不是静默地让first_item变成None导致后续崩溃。这种“快速失败”(Fail Fast)原则,是健壮系统的基础。更进一步,Python 3.10 的结构化模式匹配(Structural Pattern Matching)让这件事更优雅:

match api_response: case {"status": "success", "data": [{"product": {"name": name, "price": price}, "orders": [first_order, *_]} | [] as orders], "pagination": _}: # 这里 name, price, first_order 都已绑定 print(f"Product: {name}, Price: {price}") if orders: print(f"First order qty: {first_order['qty']}")

虽然模式匹配语法更强大,但解包是它的基础,且兼容所有 Python 版本。所以,掌握嵌套解包,是你迈向高级 Python 开发的必经之路。我的经验是:在写数据处理逻辑前,先在纸上画出数据的 JSON 结构图,然后用解包语法“描摹”一遍那个结构。这个过程本身就能帮你发现数据中的边界情况(比如空列表、缺失字段),从而提前写好防御逻辑。

4. 实操全流程:从零开始构建一个解包驱动的数据清洗工具

4.1 项目背景与需求分析

我们来做一个真实的、可立即上手的项目:一个 CSV 文件的智能清洗与转换工具。假设你收到了一份销售数据 CSV,内容如下(sales.csv):

date,product,category,quantity,unit_price,total_revenue,region 2023-10-01,Laptop,Electronics,2,999.99,1999.98,North 2023-10-01,Mouse,Electronics,5,29.99,149.95,South 2023-10-02,Keyboard,Electronics,1,79.99,79.99,East 2023-10-02,Monitor,Electronics,1,299.99,299.99,West

需求很明确:

  1. 读取 CSV,跳过表头;
  2. 对每一行,提取date,product,quantity,unit_price,total_revenue,region
  3. quantityunit_price转为数字(int/float);
  4. 计算profit(假设利润率为 15%,即total_revenue * 0.15);
  5. 输出一个新 CSV,包含date,product,region,profit四列。

传统做法是用csv.reader,然后对每一行row[0],row[1]... 手动索引。但用解包,我们可以让代码像数据结构一样“呼吸”。

4.2 工具选型与环境准备

这个项目不需要任何第三方库,纯 Python 标准库即可。我们用csv模块读取,sysos处理文件路径。唯一要注意的是 Python 版本:确保 ≥3.6,因为我们要用 f-string 和一些现代语法。创建一个新文件sales_cleaner.py

4.3 核心代码实现与逐行解析

import csv import sys from pathlib import Path def clean_sales_data(input_path: str, output_path: str): """ 清洗销售数据 CSV。 输入:CSV 文件路径(含表头) 输出:新 CSV 文件路径(仅含 date, product, region, profit 四列) """ input_file = Path(input_path) output_file = Path(output_path) # 1. 打开输入文件,用 csv.reader 读取 with input_file.open(newline='') as f_in: reader = csv.reader(f_in) # 2. 读取并跳过表头 header = next(reader) # header 是一个列表,如 ['date','product',...] print(f"Detected header: {header}") # 3. 定义我们关心的字段索引(更健壮的方式是用 csv.DictReader, # 但这里坚持用解包,所以先确定位置) # 假设 CSV 字段顺序固定,我们期望:date, product, category, quantity, unit_price, total_revenue, region # 我们需要索引:0(date), 1(product), 3(quantity), 4(unit_price), 5(total_revenue), 6(region) # 用解包,我们希望一行数据能直接映射到这些变量 # 所以定义一个“模式”:我们只取这6个位置,忽略 category(索引2) # 这就是解包的“选择性提取”能力 # 4. 准备输出文件 with output_file.open('w', newline='') as f_out: writer = csv.writer(f_out) # 写入新表头 writer.writerow(['date', 'product', 'region', 'profit']) # 5. 逐行处理 for line_num, row in enumerate(reader, start=2): # start=2 因为第1行是 header try: # 关键步骤:解包!我们只关心6个字段,用 _ 忽略 category date, product, _, quantity_str, unit_price_str, total_revenue_str, region = row # 6. 类型转换(解包后,所有值都是字符串,需要转换) quantity = int(quantity_str) unit_price = float(unit_price_str) total_revenue = float(total_revenue_str) # 7. 计算 profit(15% 利润率) profit = total_revenue * 0.15 # 8. 写入新行(再次解包,这次是输出) writer.writerow([date, product, region, round(profit, 2)]) except ValueError as e: print(f"Warning: Invalid number format in row {line_num}: {row}. Skipping. Error: {e}") continue except IndexError as e: print(f"Warning: Row {line_num} has insufficient columns: {row}. Skipping. Error: {e}") continue print(f"Cleaning completed. Output saved to {output_file}") # 9. 主程序入口 if __name__ == "__main__": if len(sys.argv) != 3: print("Usage: python sales_cleaner.py <input_csv> <output_csv>") sys.exit(1) input_csv = sys.argv[1] output_csv = sys.argv[2] clean_sales_data(input_csv, output_csv)

现在,让我们运行它:

python sales_cleaner.py sales.csv cleaned_sales.csv

输出的cleaned_sales.csv将是:

date,product,region,profit 2023-10-01,Laptop,North,299.99 2023-10-01,Mouse,South,22.49 2023-10-02,Keyboard,East,12.0 2023-10-02,Monitor,West,45.0

4.4 代码亮点与经验总结

这段代码的精华,全在第5步的解包语句:

date, product, _, quantity_str, unit_price_str, total_revenue_str, region = row
  • 精准选择:我们用_明确忽略了category字段,代码意图一目了然。如果未来 CSV 增加了新列(比如discount),只要它不在我们解包的模式里,就不会影响现有逻辑。
  • 错误即刻暴露:如果某行只有6列(缺少region),解包会立刻报ValueError: not enough values to unpack,而不是让region变成None,导致后续写入错误数据。
  • 类型转换解耦:解包只负责“拆分”,转换只负责“解析”,职责清晰。quantity_str这个变量名本身就暗示了它的类型,避免了row[3]这种无意义的索引。
  • 异常处理粒度合适:我们捕获了ValueError(数字格式错误)和IndexError(列数不足),并给出清晰的警告信息,指出哪一行出了问题,方便人工核查。

实操心得:我在实际项目中,曾用类似逻辑处理过一个包含 200 多万行的电商订单 CSV。最初用索引访问,花了两天时间才定位到一个隐藏的、某几行末尾多了一个逗号导致字段错位的 bug。改用解包后,程序在读取到第一行错位数据时就立刻报错,我马上就知道是数据源的问题,而不是代码逻辑的问题。这节省了大量调试时间。所以,解包不仅是让代码更短,更是让错误更早、更明确地暴露出来。这是它在生产环境中最大的价值。

5. 常见陷阱、避坑指南与独家调试技巧

5.1 五大高频错误与解决方案

错误现象错误代码示例根本原因正确写法调试技巧
变量数不匹配a, b = [1]右侧只有一个元素,左侧要两个变量a, = [1](单元素元组,注意逗号)或a, *b = [1]运行时报ValueError: not enough values to unpack,看报错行和右侧对象的len()
星号位置错误*a, *b = [1,2,3]一个赋值语句中只能有一个*a, *b = [1,2,3]*a, b = [1,2,3]SyntaxError: two starred expressions in assignment,IDE 通常会高亮
字典解包混淆k, v = {'a':1, 'b':2}默认解包的是键,不是键值对k, v = list(my_dict.items())[0]for k, v in my_dict.items(): ...print(list(my_dict))查看默认解包内容
生成器耗尽gen = (x for x in range(3)); a, b = gen; c, d = gen生成器只能迭代一次,第二次gen已空data = list(gen); a, b = data[:2]; c, d = data[2:]itertools.tee()复制迭代器,或直接转list
函数调用解包错位func(**config, *args)*args必须在**kwargs之前(Python <3.5)`func(*args