先看一段会让你怀疑人生的代码。假设你用==判断两个字典是否相等,返回True;转头用is判断同一个问题,结果成了False。第一次遇到这个现象的人,大概率会盯着屏幕愣三秒,然后怀疑自己是不是写错了变量名。
a = {"name": "python", "level": 3} b = {"name": "python", "level": 3} print(a == b) # True print(a is b) # False print(a is not b) # True同样的内容,为什么一个相等一个不相等?问题就出在is、in、==这三个经常被放在一起讨论,实际上却各管一摊的运算符上。它们仨长得像,干的事完全不同:is判断的是“是不是同一个对象”,==判断的是“值相不相等”,in判断的是“在不在容器里”。这篇就围绕这三者的区别,从底层原理、代码实测、常见翻车场景到调试技巧一层层拆开讲,适合刚入门 Python 的同学,也适合写了几年代码但一直没细究过这块的老手。
1. 从一次翻车现场说起:三个“相等”到底有什么不同
1.1 一个让你怀疑人生的代码片段
先把开头那个例子再展开一点。我当年刚转 Python 那会儿,写过一个配置对比逻辑,从两个接口返回里各取一个配置项,然后判断“这俩配置是不是一样”。当时顺手写的is,结果线上出了一次配置不生效的事故,查了半天才发现问题不在业务逻辑,而在is和==用混了。
cfg1 = {"timeout": 30, "retry": 3} cfg2 = {"timeout": 30, "retry": 3} if cfg1 is cfg2: print("配置相同,跳过更新") else: print("配置不同,执行更新") # 实际走的是这里两个变量内容一模一样,但因为它们是两个独立创建的字典对象,is果断返回False,程序每次都走“更新”分支,白白消耗资源。反过来,如果当时用==,这个问题根本不会出现。
1.2 一句话说清:身份、值、成员关系
拿双胞胎来类比。is问的是:“你俩是同一个人吗?”哪怕长得一模一样,只要是两个人,答案就是False。==问的是:“你俩长得一样吗?”双胞胎长得一样,答案就是True。in问的是:“这个人在这群人里吗?”它根本不关心你是不是双胞胎,只关心“有没有这么个人”。
放到 Python 的世界里:
is:比较的是对象身份,也就是两个名字是否指向同一个内存对象,本质是比较id()值。==:比较的是对象的值,也就是内容是否相等,本质是调用__eq__方法。in:比较的是成员关系,判断一个元素是否存在于某个容器中,本质是调用__contains__或迭代查找。
1.3 三者使用边界对照表
先给一个总览表,后文每个细节都会围绕这个表展开:
| 运算符 | 比较维度 | 底层依据 | 典型使用场景 | 容易踩的坑 |
|---|---|---|---|---|
is | 对象身份 | id()是否相同 | 判断None、单例、是否同一引用 | 误用为值比较,受缓存和驻留影响 |
== | 对象值 | __eq__方法返回值 | 数字、字符串、容器内容判等 | 没重写__eq__的自定义类退化为身份比较 |
in | 成员关系 | __contains__/ 迭代 / 哈希查找 | 列表、字典、集合的元素判断 | 字典in判断的是键不是值,集合要求元素可哈希 |
不管看起来多像,这三个运算符在 Python 解释器里走的是完全不同的执行路径。接下来逐个拆开看底层。
2. 拆开看底层:is、==、in 各自的执行逻辑
2.1 is:比的是内存地址,翻译成代码就是 id() 相等
is是三个运算符里逻辑最简单的,它不关心对象的“内容”是什么,只关心“内存地址”是否一致。你可以理解成每个人都有一个唯一的身份证号,就算两个变量名不同,只要它们指向同一个对象,身份证号就是同一个。
在 Python 里,获取这个“身份证号”的函数是id()。看下面的例子:
x = [1, 2, 3] y = x # y 和 x 绑定到同一个列表对象 z = [1, 2, 3] # z 是新建的列表对象 print(id(x), id(y), id(z)) print(x is y) # True,id(x) == id(y) print(x is z) # False,id(x) != id(z)从输出可以看到,id(x)和id(y)完全一样,id(z)是另一个值。is本质上就是id(a) == id(b)的简化写法。也正因为如此,is的比较是不需要“读内容”的,它是指针级别的比较,非常快。
2.2 ==:比的是值,走的是eq协议
==走的是面向对象里的“相等协议”。Python 有一个约定:当你写a == b的时候,解释器实际上会尝试调用a.__eq__(b)。
内置类型基本都实现了这套协议。整数和整数比数值,字符串和字符串比字符序列,列表和列表比元素,字典和字典比键值对。也正因为如此,两个分别创建的字典,只要键值完全一样,==就会返回True,哪怕它们的内存地址八竿子打不着。
这里的坑在于:object 基类里默认的__eq__实现,其实就是身份比较。也就是说,如果不重写__eq__,那a == b的效果等同于a is b。这点很关键,后文自定义类的翻车现场就是从这里来的。
2.3 in:比的是成员关系,走的是contains与iter
in看起来和“相等”沾边,其实完全不在一套体系里。它的问题是:“这个元素在不在这个集合里?”执行机制取决于容器类型:
- 实现了
__contains__的对象,直接调用该方法判断。 - 没有
__contains__,但有__iter__的对象,逐个迭代比较,每次迭代内部用==判断当前元素和目标是否相等。 - 集合
set、frozenset和字典的键,走的是哈希查找,先算hash(),再比对目标。
print(3 in [1, 2, 3]) # True,线性扫描 print("ell" in "hello") # True,子串匹配 print("name" in {"name": "python"}) # True,字典判断的是键注意最后一行:字典用in判断的是键,不是值。想判断值里有没有某个东西,得写value in d.values(),这个细节新手经常搞混。
2.4 一个对象可以重名,但内存地址永远唯一
把三个运算符放在一起理解时,你脑子里最好有一个画面:Python 的变量名只是贴到对象上的标签,不是对象本身。
s1 = "abc" s2 = s1 s3 = "abc"这里s1和s2是同一个对象的两个标签,所以s1 is s2为True。s3是单独创建的对象,哪怕内容一样,s1 is s3也可能为False。而==不管这些标签怎么贴,只看指向的内容是否相同。
理解了这个“标签-对象”模型,再去看is、==、in的区别就顺了:is问标签是否贴在同一对象上,==问对象内容是否一样,in问某容器里有没有符合条件的元素。
3. 实操验证:跑一遍代码看清全部细节
3.1 搭建测试环境
下面的所有实测代码,我使用的是 Python 3.11 的 CPython 官方解释器。不同 Python 实现(比如 PyPy、Jython)在某些细节上可能表现不同,但这不影响对核心机制的理解。建议你也开一个 REPL 或者新建一个脚本文件,跟着敲一遍,输出结果能让你记一辈子。
3.2 整数与字符串:缓存与驻留的甜蜜陷阱
先把最容易让人迷惑的整数拿出来。猜猜下面这段代码的输出:
a = 256 b = 256 print(a is b) # 出乎意料:True c = 257 d = 257 print(c is d) # 这才是“正常”:False第一组输出是True,第二组是False。原因在于 CPython 启动时预创建了-5到256范围内的小整数对象,不管你后续代码里哪个变量赋值为256,拿到的都是同一个缓存对象。而257不在缓存范围内,每次赋值都会新建对象。
字符串方面同样存在类似机制。名字像“合法标识符”的字符串常量,解释器可能直接复用一个驻留对象:
s1 = "hello" s2 = "hello" print(s1 is s2) # 可能是 True,因为字符串驻留机制 s1 = "hello world" s2 = "hello world" print(s1 is s2) # 可能是 False,含空格的字符串一般不驻留注意我的用词:都是“可能”。字符串驻留是 CPython 的编译期优化,不是语言规范,不同版本、不同运行环境都可能变。实操中请记住,字符串值的比较必须用==,赌驻留机制等于给自己埋雷。
3.3 可变对象:列表、字典的 is 与 == 表现
可变对象的表现就比较“规矩”了。两个内容相同的列表,==返回True,is返回False,因为它们是两个对象。
l1 = [1, 2, 3] l2 = [1, 2, 3] print(l1 == l2) # True print(l1 is l2) # False d1 = {"k": 1} d2 = {"k": 1} print(d1 == d2) # True print(d1 is d2) # False再看别名场景:
l1 = [1, 2, 3] l2 = l1 l2.append(4) print(l1) # [1, 2, 3, 4],被 l2 改了 print(l1 is l2) # True,因为就是同一个对象这里顺带提醒:如果你只是想复制一个列表然后独立修改,别直接写l2 = l1,要用l2 = l1.copy()或l2 = list(l1)。is在这里最直观的价值就是这——判断两个变量到底是不是同一个对象,修改一个会不会影响另一个。
3.4 自定义类:不重写eq时会发生什么
自定义类的坑最为隐蔽。先定义一个最简单的类:
class Something: def __init__(self, value): self.value = value a = Something(5) b = Something(5) print(a == b) # False print(a is b) # False明明a.value和b.value都是5,a == b却是False。原因就是前面提到的:object默认的__eq__只做身份比较,不重写就等于is。
要让它按“内容”比较,必须动手重写:
class Something: def __init__(self, value): self.value = value def __eq__(self, other): if not isinstance(other, Something): return NotImplemented return self.value == other.value a = Something(5) b = Something(5) print(a == b) # True加了__eq__之后,a == b才会按value比较。注意返回NotImplemented这个细节,它表示“我不会拿自己和这种类型比较”,让解释器尝试反向调用对方的__eq__,比直接返回False更符合 Python 的协议规范。
3.5 测试结论汇总
把上面的实测结果整理成一张表,以后忘了可以翻回来查:
| 对象类型 | ==判断依据 | is判断依据 | in在容器中如何工作 |
|---|---|---|---|
| 整数 | 数值大小 | 对象地址,受小整数缓存影响 | 线性扫描,元素逐个== |
| 字符串 | 字符序列内容 | 对象地址,受驻留机制影响 | 子串匹配 |
| 列表 | 逐项比较元素 | 对象地址 | 线性扫描,元素逐个== |
| 字典 | 键值对逐项比较 | 对象地址 | 判断的是键,走哈希查找 |
| 集合 | 元素集合是否一致 | 对象地址 | 哈希查找,要求元素可哈希 |
| 自定义类 | __eq__方法返回值 | 对象地址 | 若容器迭代,元素逐个与目标== |
4. 机制探源:小整数缓存、字符串驻留与自定义协议
4.1 小整数缓存:Python 偷偷缓存了 -5 到 256
为什么偏偏是-5到256?因为 CPython 认为这个范围内的整数在程序里出现频率极高,比如循环计数、列表索引、小范围数值计算。与其每次使用都临时malloc一个新对象,不如解释器启动时就一次性创建好,用的时候直接引用,省内存也省时间。
这个缓存带来的典型现象是:
x = 100 y = 100 print(x is y) # True m = -6 n = -6 print(m is n) # False,-6 不在缓存区间内注意两点。第一,这个行为是 CPython 的实现细节,语言规范并没有强制要求其他解释器也这么做。第二,即便在 CPython 里,一旦数值超出缓存区间,is的结果就可能随运行环境变化,完全不可控。所以硬性结论只有一个:整数值比较只用==。
4.2 字符串驻留:哪些字符串会被“内部化”
字符串驻留的目的和小整数缓存类似:相同内容的不可变字符串如果能在解释器内部共享同一个对象,就能节省内存,还能让比较变得更快。
CPython 的驻留策略大致倾向于这几类:
- 看起来像合法标识符的字符串常量(纯字母、数字、下划线组合,且不是数字开头)。
- 空字符串。
- 编译时可以确定内容相同的字符串字面量。
看这个例子:
s1 = "hello_world" s2 = "hello_world" print(s1 is s2) # 大概率 True s3 = "hello world" s4 = "hello world" print(s3 is s4) # 大概率 False但“大概率”不是“一定”。如果你用input()从终端读入一个字符串,或者从文件里读取一行,即使内容和某个常量完全一样,也不会被驻留。这种不确定性意味着字符串相等判断永远应该用==,这不是建议,是纪律。
4.3eq的返回值与布尔上下文
再往深处说一点。==调用的__eq__方法,返回值并不一定非得是布尔值。在一些第三方库里,它可能返回一个数组或者别的可转换类型。最典型的就是数据计算库里的数组对象,两个数组做==会得到一个布尔数组,而不是单个True/False。
import numpy as np a = np.array([1, 2, 3]) b = np.array([1, 2, 3]) result = (a == b) # 返回 array([ True, True, True]) if result: # 直接 if 会报错 print("相等")if result这句会触发ValueError: The truth value of an array... is ambiguous。所以自己写__eq__的时候,尽量返回明确的True、False或NotImplemented,让调用方的if判断没有惊喜。
4.4 什么时候该用 is,什么时候该用 ==,什么时候该用 in
实操中最实用的其实是这一套选择逻辑:
- 判断
None、True、False,用is。这是 Python 官方的推荐写法,比如if x is None,不写if x == None。因为这三个是单例对象,用is语义准确,速度还快。 - 判断值是否相等,用
==。整数、浮点数、字符串、列表、字典,凡是你关心“内容”,一律==。 - 判断元素在不在容器里,用
in。不要用index()去捕获异常,不要先遍历再手工==,in就是干这个的。 - 判断两个变量是否指向同一个可变对象,用
is。典型场景是检查某个变量有没有被重新赋值,或者确认别名关系。 - 判断单例模式的实例、枚举值等,用
is。这些对象在程序生命周期内只有一个实例,身份比较语义完全正确。
时刻问自己:我到底要比较身份,还是比较内容,还是判断存在性?选对了,代码的意图就清晰了。
5. 常见问题排查与避坑记录
5.1 症状一:明明两个数相等,is 却返回 False
曾经有个同事写了一段缓存命中判断,用is比较接口返回的版本号。版本号是整数,大部分时候落在-5到256,一切正常。某天版本号涨到了300,缓存突然全部失效,每次请求都回源。排查完才发现是is的问题。
这种 bug 最大的危害在于不稳定:不是永远错,而是“看运气”错。数值小的时候正常,数值大了就崩。修复方法很简单——把is换成==,并且以后数值比较一律不用is。
5.2 症状二:自定义类判等完全失效
另一种常见场景是写业务类时不重写__eq__,然后拿两个新建对象做相等判断。这时候类的==退化成is,明明value一样却判不相等。
排查思路分三步:
- 确认类里有没有
__eq__方法。没有的话,默认走object的身份比较。 - 确认
__eq__里有没有适当的类型检查并返回NotImplemented。如果直接写死返回False,会导致反向比较逻辑失效。 - 确认重写
__eq__后,__hash__是否有问题。Python 中重写__eq__会让默认的__hash__被置为None,实例立刻变得不可哈希,不能再放进set或作为dict的键。如果业务上确实需要哈希,必须同时重写__hash__。
class Something: def __init__(self, value): self.value = value def __eq__(self, other): if not isinstance(other, Something): return NotImplemented return self.value == other.value def __hash__(self): return hash(self.value) a = Something(5) print(hash(a)) # 正常 print(len({a, a})) # 1,可以放进 set 了5.3 症状三:in 判断集合成员时“莫名其妙”报错
最常见的报错是TypeError: unhashable type: 'list'。很多人试图把列表放进set或作为字典键,然后问“为什么in不能工作”。原因很简单:set和字典键依赖哈希表,元素必须是可哈希的,而list是可变对象,没有合法哈希。
s = set() s.add([1, 2, 3]) # TypeError如果确实需要判断“某个列表是否在某个列表集合中”,有两个选择:把内部列表转成tuple(不可变、可哈希),或者改用普通列表循环判断。另外提醒一下,字符串的in判断的是子串,"ell" in "hello"返回True,这和“元素在列表里”的语义不一样,别混。
5.4 速查表:值得记住的判断规则
| 判断需求 | 正确写法 | 容易犯的错 |
|---|---|---|
| 整数或浮点数相等 | == | is,受小整数缓存影响 |
| 字符串相等 | == | is,驻留机制不稳定 |
判断None | is None | == None,语义不准确 |
| 列表元素存在 | in | 用index()再捕获异常 |
| 字典键存在 | in dict | 误以为是判值存在 |
| 自定义类内容相等 | 重写__eq__后用== | 不重写默认是is行为 |
| 检查引用是否同一对象 | is | 用==,无法区分副本 |
6. 进阶调试与性能参考
6.1 用 sys.intern 观察字符串驻留
如果看完上面内容还想亲眼确认字符串驻留的行为,可以用sys.intern主动把字符串“驻留”起来,然后观察is的结果:
import sys s1 = "hello_world" s2 = sys.intern("hello_world") print(s1 is s2) # True,因为 s1 本身已被驻留 s3 = "hello_" + "world" # 动态拼接得到的字符串 s4 = sys.intern(s3) print(s3 is s4) # False print(s3 == s4) # Truesys.intern的真实用途是大型程序里大量重复字符串比较时的内存优化:先把字符串都intern,之后就可以用is这种指针比较代替昂贵的逐字符比较。但前提是你能保证所有相关字符串都走了intern,否则就是给自己挖坑。日常开发完全不需要用它。
6.2 用 id() 和引用追踪工具验证对象身份
排查is相关诡异行为时,直接打印id()是最快的定位手段:
a = 257 b = 257 print(id(a), id(b)) # 地址不同,所以 a is b 为 False c = [1, 2] d = c[:] # 切片复制,内容相同但对象不同 print(id(c), id(d)) # 地址不同 print(c == d) # True print(c is d) # False如果想看某个对象被哪些变量引用,可以用垃圾回收模块的引用追踪工具,不过这个接口要慎用,它只在对象被垃圾回收器跟踪时有效,像小整数这种常驻对象压根不会被追踪。
6.3 is 与 == 的性能差距
单看执行速度,is确实比==快,因为is只比较地址,而==可能要逐字节比较字符串、逐项比较列表。但在真实业务里,这个差距一般小到可以忽略。用一个基准测试看看:
import timeit a = "x" * 1000 b = "x" * 1000 t_eq = timeit.timeit("a == b", number=10_000_000, globals={"a": a, "b": b}) t_is = timeit.timeit("a is b", number=10_000_000, globals={"a": a, "b": b}) print(t_eq) # 字符串内容比较,耗时相对高 print(t_is) # 地址比较,耗时低一两秒的绝对值差异在个别场景确实存在,但为了这点性能去赌is的语义是否正确,属于本末倒置。记住一个朴素的判断标准:正确性永远优先于微优化。只有当你能确保两个对象是同一个对象(比如用sys.intern统一过,或者判断单例)时,is带来的性能收益才值得认真考虑。
6.4 写出不容易翻车的判断逻辑
最后分享一套我自己的编码习惯。写任何比较逻辑之前,先问三个问题:
- 我要比的是“内容”还是“身份”?内容用
==,身份用is。 - 这个对象有没有可能被修改?可变对象的内容相等不等于身份相等,别混。
- 我要判断的“在不在”是哪个容器?字典先分清键还是值,集合先确认元素可不可哈希。
有了这三个问题打底,再配合本章前面的速查表,基本能把 90% 的is、in、==相关 bug 消灭在编码阶段。
我自己的体会是,这类基础语法问题拿到面试题里是送分题,拿到真实项目里就是送命题。很多人写了几年代码,天天用==判断字符串和数字,从来没翻过车,就误以为自己完全掌握了is,直到某一天数据规模上来、对象缓存行为变化,才发现隐患一直都在。希望这篇能把这三个运算符的边界彻底讲清楚,让你以后无论写业务代码还是读别人的项目,都不再被这种“看似相等实则不等”的问题绊住脚。