摘要:分页接口第二次调用就返回空?本文从问题现象追到根因,再到三种修复写法,一文搞定 Python 函数默认值这个经典陷阱。
一、故障现象
一个分页拉取数据的接口,逻辑为从 start=0 起每次递增 limit,直到取完数据。接口在连续调用时表现出异常:
python
client = Client()
print(client.get_data()) # 第 1 次: [0, 100, 200, 300, 400]
print(client.get_data()) # 第 2 次: [] ← 返回空
print(client.get_data()) # 第 3 次: [] ← 永远为空
第一次调用完全正常,第二次起返回空列表。
排查方向容易被误导:调用方并未传入 start=500,但服务端记录的请求参数显示 start 已经变成了 500。看起来像"参数被凭空改了"。
二、问题代码
python
class Client:
def get_data(self, param: dict = {}) -> list: # 问题出在这里
res_data = []
param["start"] = param.get("start", 0)
param["limit"] = param.get("limit", 100)
while param["start"] < 500:
res_data.append(param["start"])
param["start"] += param["limit"]
return res_data
函数签名为 param 提供了默认值 {},从语法上看不出任何问题。
三、根因分析
3.1 Python 默认参数的求值时机
Python 中,函数的默认参数在函数定义时求值一次 ,并将结果绑定到函数的 __defaults__ 属性上。后续每次调用,如果没有传入该参数,就会复用这个已存在的对象,而不是重新创建一个。
这意味着 def get_data(self, param={}) 中的 {}:
- 不是"每次调用创建一个新字典"
- 而是"定义函数时创建了一个字典,所有未传参的调用共享它"
3.2 用对象 id 验证共享
在方法内打印 param 的对象 id:
python
logger.info("本次拿到的 param 对象 id: %s", id(param))
三次调用的输出:
text
本次拿到的 param 对象 id: 2350173998720
本次拿到的 param 对象 id: 2350173998720
本次拿到的 param 对象 id: 2350173998720
三次调用拿到的是同一个对象。
3.3 故障如何发生
- 第一次调用:
param为共享的空字典,start从 0 开始,循环递增到 500 退出,返回数据。此时共享字典中的start已被修改为 500 - 第二次调用:拿到同一个字典,
start已经是 500 param["start"] = param.get("start", 0)------get返回 500,不是 0while param["start"] < 500------ 500 不满足条件,循环体一次都不执行- 返回空列表
由于循环未执行,共享字典中的 start 不会被再次修改,因此第 3 次及之后永远停在 500,每次都返回空列表。
3.4 最小复现
同样的机制,用一个纯函数即可复现:
python
def append_to(element, target=[]):
target.append(element)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] ← 不是 [2]
print(append_to(3)) # [1, 2, 3]
target 的默认列表被三次调用共享,元素不断累积。
四、为什么这类 bug 难以发现
- 首次调用永远正确。故障只在第二次及之后的调用中出现
- 单元测试容易漏掉。如果测试只调用一次方法,用例会通过
- 现象指向错误的方向。"第二次返回空"很容易被解读为后端数据问题、连接问题,而不是函数签名问题
- 代码 review 不易察觉 。
param={}是合法语法,看惯了容易滑过去
五、三种修复方案
5.1 方案一:默认值用 None 哨兵(推荐)
python
class FixedClientWithNone:
"""默认值用 None 哨兵,进函数后再创建可变容器。"""
def get_data(self, param: Optional[dict] = None) -> list:
if param is None: # 关键:函数内部再创建新字典
param = {}
param.setdefault("start", 0)
param.setdefault("limit", 100)
res_data = []
while param["start"] < 500:
res_data.append(param["start"])
param["start"] += param["limit"]
return res_data
默认值改为 None(不可变对象,不存在跨调用共享),真正的字典在每次调用时才重新创建。函数接口形状完全不变,调用方无需改动。
需要注意的副作用:如果调用方传入了自己的 param 字典,方案一会通过 setdefault 直接在其上补默认值,会改写调用方的对象。若不希望产生这种副作用,应改用方案二。
5.2 方案二:保留字典接口,内部拷贝
python
class FixedClientWithCopy:
"""默认值用 None 兜底,并拷贝一份,绝不改写调用方对象。"""
def get_data(self, param: Optional[dict] = None) -> list:
param = dict(param or {}) # 拷贝一份,不改写调用方对象
start = param.get("start", 0)
limit = param.get("limit", 100)
res_data = []
while start < 500:
res_data.append(start)
start += limit
return res_data
默认值改为 None,函数内用 dict(param or {}) 创建新字典。既避免了跨调用共享,又不会污染调用方传入的对象------这正是它与方案一的关键区别。
5.3 方案三:将游标封装为迭代器
python
class Paginator:
"""把分页游标封装成迭代器,状态不外泄给调用方。"""
def __init__(self, total: int = 500, page_size: int = 100) -> None:
self.total = total
self.page_size = page_size
def pages(self):
"""逐页产出 (offset, limit),直到 offset >= total。"""
offset = 0
while offset < self.total:
yield offset, self.page_size
offset += self.page_size
class FixedClientWithPaginator:
"""用分页器替代手工维护游标。"""
def get_data(self, paginator: Optional[Paginator] = None) -> list:
paginator = paginator or Paginator()
res_data = []
for offset, limit in paginator.pages():
res_data.append(offset)
return res_data
游标状态封装在 Paginator 实例内部,调用方无法直接修改,从设计层面消除了这类问题。这也是三种方案中最彻底的。
六、总结与规范
6.1 默认值安全性速查
| 默认值 | 类型 | 是否安全 |
|---|---|---|
param={} |
dict(可变) | ❌ 有坑 |
items=[] |
list(可变) | ❌ 有坑 |
seen=set() |
set(可变) | ❌ 有坑 |
param=None |
NoneType(不可变) | ✅ 安全 |
start=0 |
int(不可变) | ✅ 安全 |
name="abc" |
str(不可变) | ✅ 安全 |
flag=True |
bool(不可变) | ✅ 安全 |
data=() |
tuple(不可变) | ✅ 安全 |
6.2 编码规范
- 默认参数不要使用可变对象(dict / list / set)
- 需要可变默认值时,统一使用
None+ 函数内初始化 - 若必须接收外部传入的可变对象,先拷贝再使用(
dict(x)/list(x)/copy.copy(x)) - 对需要维护状态的逻辑,优先封装成类或迭代器,而不是靠默认参数传递状态
- 静态检查工具(flake8 的 B006、pylint 的 W0102)可自动检出此类问题,建议纳入 CI
6.3 一句话结论
param={} 中的 {} 只在函数定义时创建一次,所有调用共享同一个对象。默认值使用可变对象等于埋雷,正确姿势是默认写 None、函数内再初始化。