比较值用等号、判断 None 用 is 的规矩
从对象身份到值等价——一条贯穿 CPython 内存模型、字节码语义与工程规范的分析主线
摘要
在 Python 的日常编码中,is 与 == 是两个出现频率极高、却极易被混用的运算符。前者比较的是对象的身份(identity),后者比较的是对象的值(equality)。这条看似简单的区分,向下牵涉 CPython 的对象模型、引用计数、内存地址与缓存机制,向上影响代码的正确性、可读性与性能。本文以「身份与值等价」为贯穿全文的分析主线,先厘清两个运算符在语言规范与 CPython 实现层面的确切语义,再逐层剖析 None 单例、小整数缓存、字符串驻留、可变默认参数、NaN 与自定义 __eq__ 等典型场景,随后给出可落地的工程规范、静态检查与代码审查清单,并展望类型系统与运行时优化对这一问题的影响。全文力求在理论深度与工程实用性之间取得平衡,为读者建立一套可检验、可迁移的判断准则。
目录
一、引言:一个被低估的基础命题
几乎每一本 Python 入门书都会写上一句:「比较值用 ==,判断 None 用 is」。这句话本身没有错,但它更像一条口诀,而不是一套可推导的规则。当开发者遇到 a = 256; b = 256; a is b 返回 True,而把 256 换成 257 后却可能返回 False 时,口诀就失效了。真正能解释这些现象的,是「身份」与「值等价」这两个概念在 CPython 中的具体实现。
笔者认为,把 is 与 == 的讨论停留在「用法口诀」层面,是一种知识上的偷懒。它掩盖了三个更值得追问的问题:第一,语言规范究竟如何定义这两个运算符?第二,CPython 用什么数据结构来支撑「身份」这一概念?第三,为什么某些对象(如 None)在语义上被设计成必须用身份判断?只有把这三层讲清楚,口诀才会从记忆负担变成推理结论。
本文的主线由此确立:身份是对象在内存中的唯一标识,值等价是对象在语义上的可比关系,二者在语言层面被刻意分离,在实现层面又通过缓存与驻留产生交集。沿着这条主线,我们会从语言规范走到 CPython 源码,从字节码走到工程实践,最后落到一份可以直接贴进团队规范文档的检查清单。
本文评述:把基础语法点写成深度文章,难点不在于「讲得多」,而在于「讲得可检验」。凡是涉及实现细节的论断,本文都尽量给出可复现的验证方式,而非停留在断言层面。
二、语义溯源:身份、值等价与语言规范
2.1 语言参考中的定义
Python 语言参考(Language Reference)在「Comparisons」一节中明确区分了两类运算符:身份运算符 is / is not 与比较运算符 == / != / < 等。规范指出,x is y 为真当且仅当 x 和 y 是同一个对象;而 x == y 的真值由对象的 __eq__ 方法决定,默认实现(object.__eq__)退化为身份比较。这一条默认实现是理解全部混淆的起点。
值得注意的是,规范还说明 is 运算符无法被重载。任何类都不能通过定义 __is__ 来改变其行为——因为根本不存在这样的特殊方法。相比之下,== 的语义完全交给用户代码控制。这意味着 is 的语义是封闭的、实现相关的,而 == 的语义是开放的、可定制的。
2.2 三个层次:语言、实现与优化
要准确理解这两个运算符,需要区分三个层次。语言规范层规定了「应该是什么」,CPython 实现层规定了「实际是什么」,而优化层(如常量折叠、驻留)则规定了「在什么情况下实现会偏离朴素预期」。很多看似矛盾的现象,其实是三个层次叠加的结果。
本文评述:把「规范」与「实现」分开讨论,是避免陷入「Python 就是这么规定的」式循环论证的关键。规范给出的是可移植的语义承诺,实现给出的是当前解释器的具体行为。写代码时应当依赖前者,调试时则需要理解后者。
2.3 与其他语言的横向对照
Java 用 == 比较引用、用 equals() 比较值;JavaScript 用 === 区分严格相等与宽松相等;C# 则通过 ReferenceEquals 与 Equals 提供两套 API。Python 的独特之处在于:它用两个视觉上差异极小的符号(is 与 ==)承载了语义上差异极大的两种判断,且身份判断无法被重载。这种设计在带来简洁性的同时,也提高了误用的代价。
笔者认为,Python 选择让 is 不可重载,是一个深思熟虑的决定。它保证了「身份」这一概念在任何对象上都具有一致、可预测的含义,从而让 None 判断、哨兵对象等模式得以可靠运行。如果 is 可被重载,整个生态中大量依赖身份语义的代码都会变得脆弱。
三、CPython 实现:对象头、地址与字节码
3.1 PyObject 与引用计数
在 CPython 中,每一个 Python 对象在 C 层面都是一个 PyObject 结构体。其最前面的两个字段是 ob_refcnt(引用计数)和 ob_type(类型指针)。id(obj) 返回的正是这个结构体在内存中的地址。因此,a is b 在 CPython 中本质上等价于比较两个 PyObject* 指针是否相等。
这里有一个容易被忽略的细节:id() 返回的整数在对象生命周期内唯一且稳定,但对象被回收后,其地址可能被新对象复用。因此,把 id() 的结果长期保存并用于后续比较,是一种危险的做法。这一点在 CPython 官方文档中也有明确提示。
3.2 字节码层面的差异
用 dis 模块可以直接观察两个运算符编译后的字节码差异。下面是一段可复现的验证代码:
import dis
def compare_is(a, b):
return a is b
def compare_eq(a, b):
return a == b
dis.dis(compare_is)
dis.dis(compare_eq)
在 Python 3.11 及更早版本中,a is b 会编译为 IS_OP 指令,而 a == b 会编译为 COMPARE_OP 指令。前者直接比较指针,后者需要经过类型分发、调用 __eq__ 或 tp_richcompare 槽位。从字节码就能看出,is 是一条「轻量」指令,而 == 是一条「重量」指令。
Python 3.12 对字节码做了一轮较大调整,引入了自适应解释器(adaptive interpreter)与专门的比较指令族。但无论指令如何命名,is 走指针比较、== 走协议调用这一根本区别没有改变。本文评述:理解字节码层面的差异,有助于解释为什么在高频循环中把 == 换成 is(在语义允许时)能带来可测量的性能收益,也能解释为什么这种替换在语义不允许时会引入 bug。
3.3 身份的可观察性边界
并非所有对象都适合用 is 判断。对于不可变对象,解释器有权复用或新建实例,因此身份不具有语义承诺。对于可变对象,身份通常稳定,但两个内容相同的列表仍然是不同对象。真正把身份作为语义契约来使用的,主要是单例对象(如 None、True、False、NotImplemented、Ellipsis)以及显式创建的哨兵对象。
四、None 为什么必须用 is:单例语义与工程约定
4.1 None 的单例本质
在 CPython 中,None 是一个全局唯一的对象,定义在 C 源码中,整个解释器生命周期内只有一个实例。任何对 None 的引用都指向同一块内存。因此 x is None 与 x == None 在绝大多数情况下结果相同,但前者更准确地表达了「x 是否就是这个唯一的空值对象」这一意图。
PEP 8 明确建议:「与 None 这样的单例比较时,应使用 is 或 is not,而不是 ==。」这条建议背后有三重理由:语义准确、性能更优、避免自定义 __eq__ 带来的意外。
4.2 自定义 __eq__ 带来的风险
考虑一个重载了 __eq__ 的类,它可能对 None 返回 True,或者抛出自定义异常。例如某些 ORM 框架的字段对象、某些数值库的缺失值标记,都可能让 x == None 的行为偏离直觉。而 is 不受任何用户代码影响,永远只做指针比较。
class Weird:
def __eq__(self, other):
return True # 对任何对象都返回 True
w = Weird()
print(w == None) # True —— 危险!
print(w is None) # False —— 正确
这个例子虽然极端,但它清晰地展示了 == 的语义可以被用户代码完全劫持。本文评述:在涉及 None 判断的场景中,is 不仅是一种风格偏好,更是一种防御性编程手段。它把判断的语义锁定在语言层面,排除了第三方代码的干扰。
4.3 哨兵对象模式
与 None 判断同源的,是哨兵对象(sentinel)模式。当函数需要区分「参数未传入」与「参数显式传入 None」时,可以创建一个模块级唯一对象作为哨兵:
_MISSING = object()
def get_config(key, default=_MISSING):
if default is _MISSING:
raise KeyError(key)
return default
这种模式在标准库与主流框架中广泛存在,其正确性完全依赖于 is 的身份语义。如果改用 ==,则任何重载了 __eq__ 的传入值都可能被误判为哨兵。
五、缓存与驻留:小整数、字符串与 intern 机制
5.1 小整数缓存
CPython 在启动时预先创建了 -5 到 256 之间的整数对象,并在整个解释器生命周期内复用。因此,这个范围内的整数,任意两次引用都指向同一对象,is 判断为 True。超出这个范围,每次运算可能创建新对象,is 的结果就变得不可预测。
需要强调的是,这个缓存区间是 CPython 的实现细节,并非语言规范的一部分。PyPy、Jython 等实现可能有不同的策略。因此,任何依赖小整数 is 为 True 的代码都是不可移植的。
5.2 字符串驻留
字符串驻留(interning)是另一个容易引发困惑的机制。CPython 会对符合标识符规则的字符串字面量、编译期常量等进行驻留,使相同内容的字符串共享同一对象。但通过运行时拼接、格式化、读取文件等方式得到的字符串,通常不会被自动驻留。
a = "hello"
b = "hello"
print(a is b) # 通常为 True(编译期驻留)
c = "hel" + "lo"
print(c is a) # 可能为 True(常量折叠)
d = "".join(["hel", "lo"])
print(d is a) # 通常为 False(运行时构造)
可以通过 sys.intern() 显式驻留字符串,这在需要大量字符串比较的场景(如解析器、编译器)中能显著降低内存占用并加速比较。但显式驻留应当基于性能剖析结果,而非盲目使用。
本文评述:字符串驻留的存在,使得「用 is 比较字符串」在某些测试中「看起来能用」,从而埋下隐患。一旦输入来源变化(如从命令行参数、网络响应中获取),驻留假设失效,bug 就会显现。因此,字符串比较应始终使用 ==。
5.3 常量折叠与编译期优化
CPython 的编译器会对常量表达式进行折叠。例如 "a" * 3 会在编译期直接变成 "aaa",而 "a" * n(n 为变量)则只能在运行时计算。这导致同一段逻辑在不同写法下,is 的结果可能不同。理解这一点,有助于解释为什么「同样的代码换个写法结果就变了」。
六、陷阱图谱:可变默认参数、NaN 与自定义 __eq__
6.1 可变默认参数
可变默认参数是 Python 最著名的陷阱之一,而它与 is / == 的混淆常常同时出现。默认参数在函数定义时求值一次,之后被所有调用共享。如果默认值是列表,多次调用会累积修改。检测这一问题的常见写法是 if x is None,而不是 if x == None。
# 反例
def append_bad(item, target=[]):
target.append(item)
return target
# 正例
def append_good(item, target=None):
if target is None:
target = []
target.append(item)
return target
这里 is None 的作用是准确识别「调用方未提供参数」这一状态。如果写成 if not target,则传入空列表时会被误判;如果写成 if target == None,则依赖 __eq__ 行为,不够稳健。
6.2 NaN 的诡异行为
浮点数的 NaN(Not a Number)是 IEEE 754 标准中定义的特殊值,它与任何值(包括自身)比较都不相等。这意味着 float('nan') == float('nan') 返回 False。但 is 比较的是身份,如果两个变量引用同一个 NaN 对象,is 会返回 True。
import math
nan1 = float('nan')
nan2 = float('nan')
print(nan1 == nan2) # False
print(nan1 is nan2) # False(不同对象)
print(nan1 is nan1) # True(同一对象)
# 正确判断 NaN 的方式
print(math.isnan(nan1)) # True
本文评述:NaN 是「值等价」与「身份」分离的最纯粹案例。它提醒我们,== 的语义由对象的类型决定,而不同类型的相等性定义可能完全违反直觉。在数值计算、数据清洗场景中,必须使用 math.isnan() 或 pandas.isna() 等专用函数,而非依赖 ==。
6.3 自定义 __eq__ 与 __hash__ 的契约
当类重载 __eq__ 时,默认的 __hash__ 会被置为 None,导致实例不可哈希。如果希望对象既能比较又能放入集合,必须同时实现 __hash__,并保证「相等对象哈希值相同」这一契约。违反该契约会导致字典、集合行为异常,且这类 bug 往往难以定位。
此外,__eq__ 应当对无法比较的类型返回 NotImplemented,而非 False,以便 Python 尝试反向比较。这是许多自定义类容易忽略的细节。
七、性能视角:is 与 == 的开销差异与适用边界
7.1 微基准测试方法
要量化两者的性能差异,可以使用 timeit 模块。下面是一段可复现的基准测试代码:
import timeit
setup = "x = 1000; y = 1000"
t_is = timeit.timeit("x is y", setup=setup, number=10_000_000)
t_eq = timeit.timeit("x == y", setup=setup, number=10_000_000)
print(f"is: {t_is:.4f}s")
print(f"==: {t_eq:.4f}s")
在笔者的测试环境(CPython 3.12,Linux x86_64)中,is 通常比 == 快数倍。但需要强调:这是模拟/整合数据,具体倍数随版本、平台、对象类型而变化。对于整数,== 本身也有快速路径;对于复杂对象,差异会更明显。
7.2 何时性能差异值得关注
在绝大多数业务代码中,is 与 == 的性能差异可以忽略。真正值得关注的场景有三类:一是超高频循环中的热点判断;二是大规模数据去重、集合运算;三是解释器、编译器、序列化框架等基础设施代码。在这些场景中,把语义允许的 == 替换为 is 可能带来可测量的收益,但前提是语义正确。
本文评述:性能优化必须以语义正确为前提。把x == None改成x is None是安全的;把s == "ok"改成s is "ok"则是危险的。判断标准不是「哪个快」,而是「哪个语义正确」。
八、工程规范:可落地的判断准则与检查清单
8.1 决策流程图
把前面的讨论浓缩成一套可操作的判断流程,是本文的核心目标之一。下面给出一个决策顺序:
- 第一步:判断比较对象是否为单例(None、True、False、NotImplemented、Ellipsis、自定义哨兵)。若是,用
is。 - 第二步:判断是否需要比较内容或数值。若是,用
==。 - 第三步:判断对象是否为 NaN 或缺失值。若是,用专用函数(
math.isnan、pandas.isna)。 - 第四步:判断是否在性能热点中,且语义允许用身份判断。若是,可考虑
is,但需注释说明理由。 - 第五步:其余情况一律用
==。
8.2 代码审查清单
8.3 静态检查与工具链
主流静态检查工具已经内置了相关规则。例如 flake8 的 E711(与 None 比较应使用 is)和 E712(与 True/False 比较应使用 is 或直接判断),以及 Ruff 中对应的规则集。把这些规则纳入 CI,可以在代码合并前拦截大部分误用。
# 使用 Ruff 检查
ruff check --select E711,E712 your_project/
# 使用 flake8 检查
flake8 --select=E711,E712 your_project/
此外,mypy 等类型检查器在严格模式下也能发现部分问题。对于团队协作,建议把「None 判断必须用 is」写入编码规范,并配合 pre-commit 钩子自动检查。
九、前沿预判:类型系统、JIT 与静态分析的演进
9.1 类型系统对身份语义的刻画
随着 PEP 484 类型注解的普及,类型检查器对 Optional[T] 的收窄(narrowing)能力不断增强。当代码写出 if x is None: return 后,mypy 能在后续分支中把 x 的类型收窄为 T。这种收窄之所以可靠,正是因为 is 的语义封闭、不可重载。本文评述:类型系统与身份语义之间存在天然的契合,未来可能出现更多基于身份判断的类型收窄规则,进一步提升静态分析精度。
9.2 JIT 与自适应解释器
Python 3.13 引入了实验性的 JIT 编译器(基于 copy-and-patch 技术),3.12 则强化了自适应解释器。这些优化会改变 == 的运行时开销,但不会改变其语义。对于 is,由于它本身就是一条极轻量的指针比较,优化空间有限。因此,随着解释器性能提升,is 与 == 的性能差距可能缩小,但语义差异永远存在。
9.3 静态分析工具的进化方向
未来的静态分析工具可能不再满足于检测 x == None 这类表层问题,而是结合数据流分析,判断某个变量是否可能被驻留、是否可能为单例,从而给出更精准的建议。例如,工具可以识别出「这个字符串来自用户输入,不应使用 is 比较」并给出警告。这类分析依赖对 CPython 实现细节的建模,工程难度较高,但价值明显。
十、结语:把规矩变成肌肉记忆
回到最初的口诀:「比较值用等号,判断 None 用 is」。经过前文的层层剖析,这句话已经不再是一条需要死记的规则,而是一个可以推导的结论。身份是对象在内存中的唯一标识,值等价是对象在语义上的可比关系;is 锁定前者,== 委托后者。当比较对象是单例时,身份判断既准确又高效;当比较对象是普通值时,值等价才是正确选择。
笔者认为,掌握这一区分的关键不在于记住更多规则,而在于建立「先问语义、再问性能」的思维习惯。每一次写下 is 或 == 之前,先问自己:我关心的是「是不是同一个对象」,还是「值是否相等」?答案清楚了,运算符自然就选对了。
拓展资源
- Python 官方文档 · Comparisons:https://docs.python.org/3/reference/expressions.html#comparisons
- Python 官方文档 · Data model(__eq__ / __hash__):https://docs.python.org/3/reference/datamodel.html
- PEP 8 · Programming Recommendations:https://peps.python.org/pep-0008/
- Ruff 规则 E711 / E712:https://docs.astral.sh/ruff/rules/
- Real Python · Python is 与 == 教程:🔒 复制本站文章内容需登录并达到 L3。当前:未登录
微信扫一扫分享
打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。
💬 评论 (0)
评论功能已关闭

