一段读配置的代码上线后偶尔不走生产分支: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. 自测
is与==分别由谁定义?__eq__返回NotImplemented意味着什么?- 为什么"字面量比较通过、来自
sys.argv的比较失败"?这依赖哪个实现细节? 0.1 + 0.2 == 0.3为什么是False?金额场景的两种稳妥做法是什么?- 定义
__eq__之后为什么对象变得不可哈希?@dataclass(frozen=True)改变了什么? id()在什么条件下才可以用来判断"是不是同一个对象"?is唯一不可替代的用途是什么?为什么哨兵要用object()?
9. 一句话总结
is 比身份、== 比值,而"值"由 __eq__ 定义——三者混用就会得到"看起来一样、结果不同"。小整数缓存与字符串驻留是 CPython 的实现优化,不是语言保证,所以任何拿 is 比值的代码都会在某个构造路径上失效。判据:is 只用于单例(None、枚举、自己造的 object() 哨兵),其余一律 ==;要当 dict key 就 frozen=True,要金额就存整数分。