Python 默认参数只求值一次:可变默认值、None 与哨兵对象
一、只求值一次,是针对哪一次定义
函数签名里的 items=[] 很像“调用时如果没有传列表,就创建一个列表”。实际规则不同:默认表达式在执行函数定义时求值,同一个函数对象后续调用会复用已经计算好的默认值,不会因为又调用一次而重新执行这个表达式。
因此,“只求值一次”不是说整个进程里只执行一次所有同名默认表达式。如果一个外层函数每次运行都会执行内部的 def,内部默认表达式也会重新求值。至于结果是否为新对象,仍取决于表达式:[] 会建立列表,而一个指向已有列表的名称仍可能得到那个已有对象。
默认表达式里的函数调用也遵守这一规则。若将“取得当前值”的调用写在参数默认值中,它发生在定义执行时;需要每次缺省调用都重新取得值,就应把计算放到函数体相应分支中。本文用计数器验证时机,不依赖时钟或随机结果。
二、共享默认列表会累积修改
def collect_shared(item, items=[]):
items.append(item)
return items
first = collect_shared("A")
second = collect_shared("B")
assert first is second
assert first == ["A", "B"]
def collect_fresh(item, items=None):
if items is None:
items = []
items.append(item)
return items
one = collect_fresh("one")
two = collect_fresh("two")
assert one is not two
assert one == ["one"]
assert two == ["two"]
supplied = []
assert collect_fresh("caller", supplied) is supplied
assert supplied == ["caller"]
print("默认列表共享与按调用新建验证通过")
第一段里,两次缺省调用拿到同一个列表。更容易忽略的是,first 并没有保存第一次返回值的独立快照;它一直指向这个列表,所以第二次追加之后,查看 first 也会看到两项。
第二段把默认值改为 None,在函数体内判断后建立新列表。每次进入这个分支,列表表达式才实际执行,因此两次缺省调用得到不同列表。若调用者显式提供列表,函数则直接修改并返回它;这是示例的接口约定,并不承诺保护调用方传入的数据。
三、None 方案需要明确接口含义
items=None 配合 if items is None: 会把“没有提供参数”和“明确传入 None”都解释为需要新建列表。只要这正是接口要表达的含义,它就是直接的处理方式。
不要为了简写而无条件改成 items = items or []。空列表同样是假值,这会把调用者明确传入的空列表替换成另一个列表,改变对象身份和后续修改落到哪里。上面的示例用 is None,所以传入空列表会保留它本身。
这也不意味着收集函数接受所有假值。它随后要调用 append(),输入仍需要符合预期容器契约;传入 0 或空字符串是否有效,是另一个类型和接口问题。缺省判断只负责识别需要建立默认对象的情况。
四、None 有业务含义时使用独立哨兵
有些接口需要区分三种情况:省略参数表示采用默认配置;显式传入 None 表示关闭该配置;传入其他值则保留调用方选择。这时再用 None 同时表示缺省,就丢失了区别。
_MISSING = object()
def resolve_option(value=_MISSING):
if value is _MISSING:
return {"mode": "default"}
return value
assert resolve_option() == {"mode": "default"}
assert resolve_option(None) is None
assert resolve_option(0) == 0
assert resolve_option(False) is False
assert resolve_option("") == ""
supplied = []
assert resolve_option(supplied) is supplied
assert resolve_option() is not resolve_option()
print("省略参数与显式值区分验证通过")
object() 创建一个独立标记,默认参数保存这个对象。函数用 is 检查身份,而不是把用户值与某个特殊字符串或数值比较。这样既能保留显式 None,也不会因为值为假就走缺省分支;还避免了用户类型自定义相等比较对缺省识别的干扰。
这个标记需要保持稳定。不能在判断时重新调用 object(),也不应在函数定义后把 _MISSING 重新绑定为新对象:默认参数已经保存旧对象,比较目标却会改变。它只是接口标记;调用者若显式传入同一个哨兵对象,仍会被当作缺省,不能依靠它区分调用来源或建立权限边界。
五、名称重新绑定不等于修改默认对象
若定义时写的是 value=some_list,默认值保存的是当时求值所得对象。随后执行 some_list = other_list,只是让外部名称改指另一个对象,不会自动替换函数已经保存的默认值。相反,对原列表执行 append() 之类的原地修改,缺省调用仍可能看到变化。
所以需要检查的是默认值指向什么,以及哪些路径会修改它。风险不限于函数体直接调用 append():返回这个默认对象之后,调用方也可能修改它;把列表放进元组默认值里,也没有让内部列表变成不可变对象。
另一方面,函数体中把局部参数重新绑定到新对象,不等于原地修改默认对象。例如列表相加后赋回局部名称与在原列表上追加,效果不同。判断是否发生跨调用累积,应观察对象身份和实际修改方式,不能仅凭签名中出现容器字面量就推断结果。
如果共享状态本来就是设计目标,应明确其所有权、生命周期和使用约束。把状态交给清楚可见的对象或调用方管理,通常也更方便复位和验证;需要并发访问时,还要由相应设计处理同步。本例没有验证并发行为。
六、用调用顺序和身份检查验证约定
这类问题往往在单次调用中看不出来。验证时应在同一个函数对象上连续缺省调用,再检查返回对象是否相同;同时覆盖显式空列表、显式 None 和其他接口允许的值。需要每次建立新对象时,应同时检查内容与身份,而不是只比较两次初始内容是否相等。
本文两个代码块已在独立 CPython 3.9.6 进程运行,另有十二项标准库内存检查,覆盖默认列表复用、显式容器、按调用创建、None 含义、哨兵身份、名称绑定、默认表达式执行次数与顺序,以及重新绑定和嵌套可变对象。所有输入都为自制有限数据,没有读取私人项目、访问网络服务或使用实际时间作为断言依据。
参考:Python Tutorial 的 Default Argument Values;Python Language Reference 的 Function definitions;Built-in Constants 的 None;Built-in Functions 的 object;对象身份比较规则及 Programming FAQ 的默认值共享说明。
- 点赞
- 收藏
- 关注作者
评论(0)