Python教程

Python:is 和 == 的区别:比较值用等号、判断 None 用 is 的规矩

👤 为我痴狂 👁 3 阅读 ❤ 0 点赞 ➦ 0 分享 📅 2026-10-16
首页› 理学› Python教程› 正文
Python:is 和 == 的区别
比较值用等号、判断 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 实现层规定了「实际是什么」,而优化层(如常量折叠、驻留)则规定了「在什么情况下实现会偏离朴素预期」。很多看似矛盾的现象,其实是三个层次叠加的结果。

层次 关注点 典型表现
语言规范 语义定义 is 判断身份,== 调用 __eq__
CPython 实现 内存与对象头 id() 返回 PyObject* 地址
优化层 缓存与驻留 小整数复用、字符串 intern

本文评述:把「规范」与「实现」分开讨论,是避免陷入「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 / True / False 稳定(单例) is
小整数(-5~256) 实现相关 ==
字符串字面量 实现相关(可能驻留) ==
列表 / 字典 每次创建不同 ==
自定义哨兵对象 稳定(模块级单例) is

四、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 的结果可能不同。理解这一点,有助于解释为什么「同样的代码换个写法结果就变了」。

表达式 是否编译期折叠 is 结果可预测性
"a" * 3 是 高(通常驻留)
"a" * n 否 低
256 是(小整数缓存) 高
257 字面量可能复用 中(同一代码块内)

六、陷阱图谱:可变默认参数、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 决策流程图

把前面的讨论浓缩成一套可操作的判断流程,是本文的核心目标之一。下面给出一个决策顺序:

  1. 第一步:判断比较对象是否为单例(None、True、False、NotImplemented、Ellipsis、自定义哨兵)。若是,用 is。
  2. 第二步:判断是否需要比较内容或数值。若是,用 ==。
  3. 第三步:判断对象是否为 NaN 或缺失值。若是,用专用函数(math.isnan、pandas.isna)。
  4. 第四步:判断是否在性能热点中,且语义允许用身份判断。若是,可考虑 is,但需注释说明理由。
  5. 第五步:其余情况一律用 ==。

8.2 代码审查清单

检查项 反例 正例
None 判断 x == None x is None
布尔判断 x == True x is True 或直接 if x
字符串比较 s is "ok" s == "ok"
整数比较 n is 1000 n == 1000
哨兵判断 x == _MISSING x is _MISSING

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 或 == 之前,先问自己:我关心的是「是不是同一个对象」,还是「值是否相等」?答案清楚了,运算符自然就选对了。

拓展资源

分享到

💬
微信
📷
朋友圈
🐧
QQ好友
🌐
QQ空间
👁
微博
📌
钉钉
🔗
复制链接
📑
复制图文

微信扫一扫分享

打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。

💬 评论 (0)

评论功能已关闭

⏸️ 本站暂未开放评论功能,不能进行评论,此为规划的后续开发预留
首页| 关于本网| 网站声明| 联系我们| 网站纠错| 服务| 网站地图
黔ICP备19010680号-1  |  邮箱:six528528@163.com
贵公网安备 52010302001819号
Copyright 2019-2026 http://www.databrush.com/ All rights reserved.
QQ
QQ扫一扫
Logo
DBN数据刷