2478 字
约 8 分钟
13
S10-A27 · 相等与同一:为什么"看起来一样"的表达式结果不同

一段读配置的代码上线后偶尔不走生产分支:if env == "prod" 在测试里永远为真,线上偶尔为假。追下去发现那段代码做的是 env = sys.argv[1],而从命令行取到的字符串没有驻留,与字面量 "prod" 不是同一个对象——于是有人把它改成了 is,问题从"偶尔"变成"必然"。

这个坑的根源是把两件事混成一件:身份(identity)与相等(equality)。Python 里 is 比的是"是不是同一个对象",== 比的是"值是否相等",而"值是否相等"又可以由你自定义。三者一混,就会出现"看起来一样、结果不同"。


1. 三个概念,三条判定路径

运算符 判定内容 谁来定义 典型误用
is 对象身份(id() 是否相同) 解释器 用来比字符串/数字
== 值相等 类型的 __eq__ 以为它比的是内容形状
hash() 相等对象的哈希必须相同 __hash__ 可变对象当 dict key

第二行最容易出意外:__eq__ 返回的不一定是布尔,还可能是 NotImplemented——表示"我不认识对方类型,让 Python 去问对方"。所以 a == b 的最终结果可能来自 b.__eq__(a),这解释了为什么"自定义类的相等"总在跨类型比较时表现怪。

// 一个最小的三方博弈:结果由谁定义
class A:
    def __eq__(self, other): return NotImplemented     # 让位
class B:
    def __eq__(self, other): return True               # 接住
A() == B()      # True —— 因为 A 让位,B 接住了
// 判据:写 __eq__ 时要处理"对方不是我这类"的情况,返回 NotImplemented 而不是 False

2. 小整数缓存与字符串驻留:实现细节,不是语言保证

CPython 缓存 -5..256 的整数,编译期常量字符串会驻留,标识符形状的字符串也会。这些都是实现优化,语言规范不保证。

对象 是否可能同一 依赖什么
-5..256 的整数 是(CPython 缓存) 实现细节
编译期字面量字符串 是(驻留) 编译方式、是否拼接
运行时构造的字符串 否 sys.argv、input()、网络读取
大整数 否 不缓存
空元组 () 是(单例) 实现细节

第三行是事故来源:同一段代码,字面量比较"碰巧"通过,从外部输入取值就失败。而它在单元测试里几乎必然通过,因为测试数据通常是字面量。

// 三种"看起来相等"的字符串,身份关系完全不同
a = "prod"                       # 字面量 → 驻留
b = "pro" + "d"                  # 编译期常量折叠 → 驻留
c = sys.argv[1]                  # 运行时构造 → 不驻留
a is b      # True(CPython 下)
a is c      # False(即使内容一样)
a == c      # True ← 只有这个是对的

3. 浮点相等:== 在这里几乎总是错的

0.1 + 0.2 == 0.3 为 False,这不是 Python 的 bug,是二进制浮点的定义。而由此派生的两类事故很常见:金额比较漏一笔、阈值判断抖动。

比较方式 适用 风险
a == b 精确二进制可表示的值 十进制小数几乎都不满足
math.isclose(a, b) 物理量、统计量 需要选 rel_tol/abs_tol
Decimal("0.1") == Decimal("0.2") 金额、精确十进制 运算变慢,且要与 float 隔离
整数化(分为单位) 金额 需要显式换算

第四行是工程上最稳的做法:金额不用浮点,也不一定要用 Decimal,直接存整数分。这样比较、累加、序列化都没有精度问题,代价是要在展示层做一次除法。

// 一个足够用的判断:这次比较是不是"值域连续"的
from math import isclose
isclose(0.1 + 0.2, 0.3)          # True,默认 rel_tol=1e-09
0.1 + 0.2 == 0.3                 # False
// 金额场景:int 分 + 显式格式化,比 Decimal 更少踩坑
assert 1999 == 19.99 * 100       # False! 浮点乘法也会错

4. __hash__ 契约:相等则同哈希,反过来不要求

定义 __eq__ 之后,Python 会把 __hash__ 设为 None——对象立刻变得不可哈希,不能做 dict key / set 元素。这是为了避免"相等但哈希不同"的破坏性状态。

操作 结果 原因
class X: def __eq__... X() 不可哈希 __hash__ 被自动置 None
同时定义 __hash__ 可哈希 契约由你保证
@dataclass 不可哈希 默认 eq=True, frozen=False
@dataclass(frozen=True) 可哈希 生成 __hash__
namedtuple 可哈希 天生不可变

第三行是高频坑:给 dataclass 加了 __eq__(默认就有)又拿它当 dict key,直接 TypeError: unhashable type。而 field(compare=False) 的字段若参与哈希计算,会出现"两个不等的对象哈希相同"——哈希冲突本身合法,但会让性能退化。

// dataclass 的三种配置,对应三种能力
@dataclass                 # eq=True, frozen=False → 可比较、不可哈希
@dataclass(frozen=True)    # → 可比较、可哈希(推荐做 key)
@dataclass(eq=False)       # → 按身份比较、可哈希(默认 object 行为)
// 判据:要当 dict key 就必须 frozen=True;要可变就必须放弃哈希

5. id() 会复用:它只在对象活着时唯一

id() 返回对象的内存地址。对象被回收后,新对象可能拿到同一个地址——所以"id 相同"只在两个对象同时存活时才有意义。

场景 id 是否可依赖 说明
两个对象都在作用域内 可以 地址不会变
对象已离开作用域被回收 不可以 地址被复用
跨进程 不可以 地址空间独立
序列化后反序列化 不可以 新对象、新地址
// id() 的两种误导
t1 = id(obj); del obj; t2 = id(new_obj)    // 可能 t1 == t2,地址被复用
日志写 "id=1402345… 还是同一个对象"         // 结论可能是反的
// 正确做法:给对象显式编号(业务 id / uuid4),日志打那个,不打 id()

这条在调试时尤其误导:日志里打印 id(obj) 想追踪"是不是同一个对象",而两个不同对象在不同时刻拿到同一个 id,会得出完全相反的结论。要追踪对象身份,用显式的业务 id 或一个 uuid 字段,不要用 id()。


6. 排错:五种"看起来相等却不相等"

现象 八成原因 处理方向
同样的字符串 is 有时 True 有时 False 一方是运行时构造、未驻留 改 ==
assert x is True 偶发失败 x 是 1 或 numpy.bool_ 改 == True 或 bool(x)
dataclass 当 dict key 报不可哈希 frozen=False 加 frozen=True
金额比较漏一笔 浮点相等 用整数分或 Decimal
自定义 __eq__ 后 set 行为异常 只定义了 __eq__ 没定义 __hash__ 两者一起定义

第二行值得单独说:1 is True 为 False(不同类型),但 1 == True 为 True。所以用 is 判布尔值时,任何"真值但非 True 对象"都会漏过——包括 numpy.bool_、1、非空字符串。


7. 边界:什么时候 is 是对的

用法 正确 理由
x is None ✅ None 是单例,PEP 8 推荐
x is True / is False ⚠️ 仅当确定是 bool 用 == 或 bool() 更稳
哨兵对象 _MISSING = object() ✅ 唯一实例,无法被"值相等"误判
枚举成员 Color.RED is c ✅ 枚举成员是单例
字符串/数字比较 ❌ 依赖驻留与缓存,不可移植

第三行的哨兵模式是 is 唯一不可替代的用途:你需要一个"绝不会与任何合法值相等"的标记,用 None 会被 Optional 语义污染,用字符串会被业务值撞上,用 object() 就没有这个问题。

判断口径一句话:is 只在"比的是单例"时有意义(None、True/False、枚举、自己造的哨兵),== 才用于值比较;反过来用(拿 is 比值)会把"实现优化"当成"语言保证",而它在不同解释器、不同构造方式下随时失效。


8. 自测

  1. is 与 == 分别由谁定义?__eq__ 返回 NotImplemented 意味着什么?
  2. 为什么"字面量比较通过、来自 sys.argv 的比较失败"?这依赖哪个实现细节?
  3. 0.1 + 0.2 == 0.3 为什么是 False?金额场景的两种稳妥做法是什么?
  4. 定义 __eq__ 之后为什么对象变得不可哈希?@dataclass(frozen=True) 改变了什么?
  5. id() 在什么条件下才可以用来判断"是不是同一个对象"?
  6. is 唯一不可替代的用途是什么?为什么哨兵要用 object()?

9. 一句话总结

is 比身份、== 比值,而"值"由 __eq__ 定义——三者混用就会得到"看起来一样、结果不同"。小整数缓存与字符串驻留是 CPython 的实现优化,不是语言保证,所以任何拿 is 比值的代码都会在某个构造路径上失效。判据:is 只用于单例(None、枚举、自己造的 object() 哨兵),其余一律 ==;要当 dict key 就 frozen=True,要金额就存整数分。

S10-A27 · 相等与同一:为什么"看起来一样"的表达式结果不同
https://www.clxhxhhr.top/posts/6733/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。