Python 可变默认参数导致的分页故障:从现象到根因到修复

摘要:分页接口第二次调用就返回空?本文从问题现象追到根因,再到三种修复写法,一文搞定 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 故障如何发生

  1. 第一次调用:param 为共享的空字典,start 从 0 开始,循环递增到 500 退出,返回数据。此时共享字典中的 start 已被修改为 500
  2. 第二次调用:拿到同一个字典,start 已经是 500
  3. param["start"] = param.get("start", 0) ------ get 返回 500,不是 0
  4. while param["start"] < 500 ------ 500 不满足条件,循环体一次都不执行
  5. 返回空列表

由于循环未执行,共享字典中的 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 难以发现

  1. 首次调用永远正确。故障只在第二次及之后的调用中出现
  2. 单元测试容易漏掉。如果测试只调用一次方法,用例会通过
  3. 现象指向错误的方向。"第二次返回空"很容易被解读为后端数据问题、连接问题,而不是函数签名问题
  4. 代码 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 编码规范

  1. 默认参数不要使用可变对象(dict / list / set)
  2. 需要可变默认值时,统一使用 None + 函数内初始化
  3. 若必须接收外部传入的可变对象,先拷贝再使用(dict(x) / list(x) / copy.copy(x)
  4. 对需要维护状态的逻辑,优先封装成类或迭代器,而不是靠默认参数传递状态
  5. 静态检查工具(flake8 的 B006、pylint 的 W0102)可自动检出此类问题,建议纳入 CI

6.3 一句话结论

param={} 中的 {} 只在函数定义时创建一次,所有调用共享同一个对象。默认值使用可变对象等于埋雷,正确姿势是默认写 None、函数内再初始化。