Python中==运算符与is的全面深入解析:重载与实践技巧探讨

目录

一. ==运算符概述

1.1 ==用于判断对象的相等性

1.2 一个自定义==运算符行为的极端例子

二. is运算符概述

2.1 is用于判断对象的同一性

2.2 Python不允许重载is

三. 比较==和is给我们什么启发?

3.1 “从哲学的眼光看——”

3.2 Python的引用式变量——是标签而不是盒子

四. 让自定义类正确地支持==运算符

4.1 求解a == b时的一般逻辑

4.2 为二维向量类正确实现__eq__方法

4.3 不返回NotImplemented造成的隐患

4.4 求解a == b的二般情况

五. is的用武之地

5.1用来测试单例

5.2 用来测试哨符

六. 反直觉的驻留现象

6.1 驻留机制概述

 6.2 不可变类型产生驻留的机制简述

6.2.1 整数的驻留机制

6.2.2 字符串的驻留机制

6.2.3 元组的驻留机制

6.2.4 浮点数的驻留机制

6.3 我应该在代码中利用驻留机制吗?

七. 结束


这篇文章目标是面向初学者,当然有高人来审查的话我自然更高兴啦。

我见过不少Python程序员在刚开始时把==和is混淆,然后对结果困惑不已。所以,这篇文章介绍一下两者的区别和它们不同的关注点——相等性和同一性。顺便介绍变量的“标签”特性,怎么为自己的类实现相等比较,以及is的用武之地;最后再顺道提一下在了解之前令人摸不着头脑的驻留现象。

一不小心就写多了……现在这篇文章有8600字以上,希望能帮到你!

一. ==运算符概述

1.1 ==用于判断对象的相等性

在编程时,我们一般更关注对象的值是否相同,也就是对象存储的数据。==运算符的作用就是检查对象的值是否相同。例如:

lst1 = [1,2,3]
lst2 = [1,2,3]

print(lst1 == lst2)

结果自然是True

1.2 一个自定义==运算符行为的极端例子

 ==运算符可以重载。只要实现了对象的__eq__()特殊方法,我们就可以自定义==运算符的行为。如果实现方法不正确,那它其实就和普通函数没什么两样,无法让框架逻辑自洽。例如下面这个修改==行为的极端例子:

class str(str): #(1)
    def __eq__(self,other):
        return '这个结果是假,我说的!'

s1 = str('鲁迅') #(2)
s2 = str('周树人')

print(s1 == s2) #(3)

(1):我们用完全一样的名字str继承内置类型str,这样可以让过程显得更加荒谬。 我本来想要直接对字符串的__eq__方法打猴子补丁,结果想起来内置类型的运算符不能重载,所以改变了方式。

(2):显式调用str,这样构建的字符串才是用我们更改过后的str创建的。

(3):Python用我们新实现的__eq__方法来求解s1  == s2

运行这段代码,终端会打印以下结果:

这个结果是假,我说的!

新方法的返回值毫无意义,好歹应该是个bool类型吧?我会在后文阐述自己实现正确的__eq__方法所应该关注的细节。(见大标题四)

二. is运算符概述

2.1 is用于判断对象的同一性

is用于比较两者是不是同一对象,即它们在内存中的地址是否相同。Python的内置函数id()可以返回对象在内存中的地址,结果是一个整数。如下所示:

lst1 = [1,2,3]
lst2 = [1,2,3]

print(id(lst1),id(lst2))
print(lst1 == lst2,lst1 is lst2)

结果:

2816135571072 2816135569536
True False 

既然如此,难道is比较就不能返回True了吗?不是,如果两对象都是本类的单例,即唯一实例,is就会返回True。比较常见的单例有:None,布尔值,Ellipsis(省略号):

n1 = None
n2 = None
print(n1 is n2)
t1 = True
t2 = True
print(t1 is t2)
f1 = False
f2 = False
print(f1 is f2)
e1 = Ellipsis
e2 = Ellipsis
e3 = ...
print(e1 is e2,e1 is e3)

结果:

True
True     
True     
True True

在这些测试中,使用==也可以得到相同的结果,但是is执行得要更快。除了测试单例,is还常用来测试哨符,也叫哨兵符,其实就是占位符。除了按定义检查对象的同一性外,在后文我会更详细地阐述is运算符的用武之处。(见大标题五)

2.2 Python不允许重载is

不像C++那样灵活,Python允许重载的运算符是有限的,且不许创造新运算符。Python中没有重载is的渠道。Java之父詹姆斯 高斯林明确反对支持运算符重载,而Python之父吉多 范罗苏姆做了一些折中措施,因为适当的重载有利于构建良好的代码结构。毕竟“实用胜于纯粹”。

(在C++中,通过宏进行文本替换可以模拟运算符的行为,从而“创造”像=),XD,O_O,(TωT)这样奇奇怪怪的“运算符”。很好玩吧,可是容易出问题呀。)

三. 比较==和is给我们什么启发?

3.1 “从哲学的眼光看——”

==和is的不同行为表明了内容身份这两个概念的不同。我们举个有趣的例子,照应本文的封面:

class Character:
    def __init__(self,description: str)->None:
        self.description = description
    def __eq__(self,other: 'Character')->bool:
        return self.description == other.description

sakuya = Character('操纵时间程度的能力,与吸血鬼元素有关') #十六夜 咲夜
dio = Character('操纵时间程度的能力,与吸血鬼元素有关') #迪奥 布兰度

print(sakuya == dio)
print(sakuya is dio)

结果:

True
False 

如果仅从“操纵时间”和“吸血鬼元素” 这两个方面看,十六夜咲夜和迪奥 布兰度都具备这样的特征;所以我们可以说在被关注特征的内容方面,咲夜 等同于(==) 迪奥。但是,我们不能通过这两个条件来断言咲夜 是(is) 迪奥,完美潇洒的女仆长和JoJo中的大反派身份不相同。我们用Python推翻了对于“咲夜是Dio”的滑稽论证。

PS:如果你不定义Character类,直接把字符串赋给两人,可能会发现“咲夜is迪奥”通过了,至少我的电脑发生了这样的事。这可能会引起咲夜厨的震怒,不过背后的原因是驻留现象。我会在本文最后提一下驻留。

3.2 Python的引用式变量——是标签而不是盒子

一般的教程可能会用盒子来比喻变量,在Python中这是不对的。更适合的喻体应该是标签。先举一个例子:

a = [1,2]
b = a
print(id(a),id(b))
print(a is b)

结果:

1833440945792 1833440945792
True 

如果用盒子来理解a和b,这样的结果就会不同于预期:b = a复制出了和a一样的盒子,但是从哲学的眼光看,世上没有两片完全相同的树叶,所以a is b为假——这与运行结果相悖。

接下来的例子将更加无懈可击:

a = [1,2,3] #(1)
b = a #(2)
b.append(114514) #(3)
b.remove(2)
print(a)

(1): a是一个列表

(2):b赋值为a

(3):b追加了一个很臭的数,且去除了2。

结果:

[1, 3, 114514]

如果不摒弃“变量是盒子” 这样的概念,就无法解释为什么修改b也修改了a。更好的比喻是,变量是标签:

 所以,变量其实只是对象的引用。a和b都是列表对象[1,2,3]的引用,如果认为a是它的本名,那么就可以说b是[1,2,3]列表对象的别名

四. 让自定义类正确地支持==运算符

如果对象不自己实现__eq__方法,Python解释器就会使用从object类那里继承的__eq__方法。而这个方法比较对象的id,就相当于is。所以,没有__eq__方法的自定义类的不同实例使用==判断的结果总是False:

class Vector:
    '''一个二维向量'''
    def __init__(self,x: float,y: float)->None:
        self.x = x
        self.y = y

v1 = Vector(1,2)
v2 = Vector(1,2)
print(v1 == v2)

结果:

False 

下面我们来分析Python求解a == b时的逻辑。

4.1 求解a == b时的一般逻辑

有一般当然有二般,我会在稍后略微说一下二般情况,那种情况不太常见。

为了求解a == b,Python解释器做下面的工作:

1.尝试调用a.__eq__(b),以结果为准。除非没有此方法或返回NotImplemented,若是如此…

2.再尝试调用b.__eq__(a),以结果为准。除非没有此方法或返回NotImplemented,若是如此…

3.Python只好调用继承自object类的__eq__方法,比较对象的id。

(PS: not implemented的意思是 不执行;未实现;未执行;未实施;不生效

由此可见,==运算符不应该抛出异常,我们应该以上面的逻辑为基础编写代码。下面我们来看为Vector类实现的__eq__方法,从而支持相等判断:

4.2 为二维向量类正确实现__eq__方法

from typing import Iterator

class Vector:
    '''一个二维向量'''
    def __init__(self,x: float,y: float)->None:
        self.x = x
        self.y = y
    def __iter__(self)->Iterator: #(1)
        yield self.x
        yield self.y
    def __eq__(self,other):
        try:
            return tuple(self) == tuple(other) #(2)
        except TypeError: #(3)
            return NotImplemented

v1 = Vector(1,2)
v2 = Vector(1,2)

print(v1 == v2) #(4)
print(v1 == [1,2]) #(5)
print(v1 == 1) #(6)

结果:

True
True 
False 

(1):实现__iter__方法是为了让Vector实例可迭代,因为我要用到tuple(self)。如果你没学过yield句法, 请忽略这一步,我们的重点在__eq__方法。

(2):__eq__方法把两者都变为元组然后比较,这里如果other是不可迭代对象,调用tuple(other)就会抛出TypeError。

(3):捕获TypeError,然后不是一棒子打死返回False,而是返回NotImplemented,让Python尝试调用other.__eq__(self)。

(4):这个判断能按照预期返回True。

(5):观察我们的__eq__方法,会发现Vector类很宽容:不要求对方也是Vector类。所以这个判断结果是True。

(6):我们来分析这个结果。首先调用v1.__eq__(1),然后抛出TypeError并被捕获,返回NotImplemented。之后,Python尝试调用1.__eq__(v1),但是int类型根本不知道Vector是个啥,所以再次抛出NotImplemented。最后,Python比较两者的id,返回False。

4.3 不返回NotImplemented造成的隐患

那要是一棒子打死返回False会如何呢?那样的话,可能导致结果自相矛盾。下面我们定义一个严格的类TwoNumbers来演示,它不像Vector那样宽容:你要返回True必须得是我的同类,不然我就一棒子打死返回False。

from typing import Iterator

class Vector:
    '''一个二维向量'''
    def __init__(self,x: float,y: float)->None:
        self.x = x
        self.y = y
    def __iter__(self)->Iterator: 
        yield self.x
        yield self.y
    def __eq__(self,other):
        try:
            return tuple(self) == tuple(other) 
        except TypeError: 
            return NotImplemented
class TwoNumbers:
    '''两个数'''
    def __init__(self,x: float,y: float)->None:
        self.x = x
        self.y = y
    def __iter__(self)->Iterator: 
        yield self.x
        yield self.y
    def __eq__(self,other: 'TwoNumbers'): #(1)
        if isinstance(other,TwoNumbers):
            return tuple(self) == tuple(other)
        else:
            return False

v = Vector(1,2)
t = TwoNumbers(1,2)

print(v == t) #(2)
print(t == v) #(3)

结果:

True
False 

(1):先用isinstance()显式检查other是不是TwoNumbers及其子类的实例,如果是再比较元组相不相等。不然,直接返回False。

(2):v的__eq__方法很宽容,所以返回True。

(3):t检查发现,v不是自己人,直接当机立断返回False。

可以看到,现在==的结果与对象的顺序有关,这不是我们想要的。更改措施是,让返回False的那一步改为返回NotImplemented:

#--省略多行--
    def __eq__(self,other: 'TwoNumbers'):
        if isinstance(other,TwoNumbers):
            return tuple(self) == tuple(other)
        else:
            return NotImplemented

v = Vector(1,2)
t = TwoNumbers(1,2)

print(v == t)
print(t == v)

结果:

True
True 

4.4 求解a == b的二般情况

这种情况并不常见,简单地说就是:如果a和b中有一个是另一个的子类,则优先调用子类的__eq__方法。请看下面的例子:

class Dad:
    def __eq__(self,other):
        return '调用了Dad的方法'
class Son(Dad):
    def __eq__(self,other):
        return '调用了Son的方法'

d = Dad()
s = Son()

print(d == s)
print(s == d)

结果:

调用了Son的方法
调用了Son的方法 

可见,由于Son继承自Dad,所以无论是d == s还是s == d,都是先调用s.__eq__(d)。这么做的原因是,子类覆盖父类的方法往往更加具体和健壮。至于多重继承中的行为,我们就不讨论了,我怕写一大篇最后把自己弄晕啦……

五. is的用武之地

我们已经谈论了==运算符很久,下面来谈谈不幸被我冷落的is运算符。

5.1用来测试单例

为什么使用is来测试单例?下面是一些原因:

1.唯一性的保障:
单例模式确保类只有一个实例,而使用 is 运算符可以快速且准确地检查两个变量是否指向同一个对象。所以检查单例非常适合is发挥。
2.性能优势:
is 运算符直接比较对象的内存地址,速度非常快。相比之下,因为==可以重载,Python在处理==时需要检查,例如对象是不是实现了__eq__方法,会有性能开销。
3.语义清晰:
is 运算符明确表示判断两个变量是否是同一个对象。这种语义在单例模式中非常合适,因为它强调的是对象的身份而不是内容。

一般常用的是,用is来检查对象是不是None。用来测试哨符的优势其实也是上面那三条。

5.2 用来测试哨符

哨符其实就是占位符,我们可以通过一个非单例类来创造属于我们的哨符。这个类可以是我们实现的没有主体的空类(当然,有方法更好,比如实现__repr__或__str__让哨符输出一个人能看懂的格式),也可以是object类。下面是创建哨符的例子:

class Placeholder: #注:Placeholder是占位符的意思
    '''用于创建哨符的类'''
class ReadablePlaceholder:
    '''用于创建输出友好的哨符的类'''
    def __repr__(self)->str:
        return '<我是占位符>'

MY_P = Placeholder()
MY_RP = ReadablePlaceholder()
MY_OP = object()

for P in (MY_P,MY_RP,MY_OP):
    print(P)

结果:

<__main__.Placeholder object at 0x000001AA3B8FFCB0>
<我是占位符>
<object object at 0x000001AA38F210E0>

可见,实现了__repr__的那个占位符的打印结果最像是人能看懂的。 

那么哨符有什么用呢?我们知道,当使用一个有while True的无限循环时,我们常制定类似这样的规则:

"输入一个整数,输入'quit'以退出"

哨符可以代替那个quit,且使用is而不是==来测试,这样速度更快且意图更明确。下面我们写一个用到哨符的程序,它原封不动地打印接受的值,直到输入哨符STOP:

STOP = object()

inputs = [
    '吹牛逼呢,你用过吗?',
    '这叫俄罗斯大哨符!',
    '高级程序员咋的,高级程序员你也没用过。',
    '你只能看着你三哥用!',
    '这叫实力,懂吗?',
    '加纳!',
    STOP,
    '这一句无法执行'
]

while True:
    for i in inputs:
        if i is STOP:
            print('这叫停止命令,懂吗!')
            break
        else:
            print(i)
    break

 请注意,这里有两个break语句,第一个跳出for循环,第二个跳出while循环。

结果:

吹牛逼呢,你用过吗?
这叫俄罗斯大哨符!
高级程序员咋的,高级程序员你也没用过。
你只能看着你三哥用!
这叫实力,懂吗?
加纳!
这叫停止命令,懂吗!

六. 反直觉的驻留现象

6.1 驻留机制概述

这应该可以说是个冷知识…吧?因为知道驻留除了让你对Python了解加深,比其他不知道的程序员认识更深入外,对于实际解决问题没有任何帮助。我们不应该依赖驻留机制。下面先看一个符合我们直觉的例子:

n1 = 42
n2 = 42

s1 = 'abc'
s2 = 'abc'

t1 = (1,2,3)
t2 = (1,2,3)

print(n1 == n2)
print(s1 == s2)
print(t1 == t2)

显然,结果应该是;

True
True
True 

那么,下面的测试呢?

print(n1 is n2)
print(s1 is s2)
print(t1 is t2)

我们可是花了不少时间区分==和is,所以,肯定是3个False!这么信誓旦旦地想着,然后一运行,结果是 :

True
True
True

这说明这三组变量是同一个对象的引用。啊?2.1中列表的测试那里不是不同对象吗?为什么会这样? 这是因为我们现在看到的数据:整数,字符串,元组都是不可变对象;而列表是可变对象。为了增强性能,Python对不可变对象施加了小把戏,这就是驻留现象。具体解释一下:

Python的驻留机制是一种内存优化技术,通过创建共享相同值的不可变对象(如整数、字符串等)来减少内存占用并提升性能。其核心思想是:若多个变量需要存储相同的不可变数据,Python会尽可能让它们指向同一个内存地址

 6.2 不可变类型产生驻留的机制简述

下面我们谈谈整数,字符串,以及元组驻留的条件。

6.2.1 整数的驻留机制

属于-5到256的整数叫做小整数池,一般地,这些整数会发生驻留现象。不过也有例外,不知道现在CPython是不是做了优化,我的电脑对很大的整数也驻留,也可能只是我电脑的缘故。我个人更偏向于认为是电脑的原因:

small_num1 = 42
small_num2 = 42

big_num1 = 1145141919810
big_num2 = 1145141919810

print(small_num1 is small_num2)
print(big_num1 is big_num2)

你的电脑可能显示:

True

False

我的电脑的输出:

True
True 

6.2.2 字符串的驻留机制

短字符串(如长度≤20且仅含字母、数字、下划线)通常会被自动驻留;

长或复杂字符串(如含空格或特殊符号)默认不驻留,但可通过sys.intern()强制驻留。

例如:

import sys  

s1 = "hello"  
s2 = "hello"

s3 = sys.intern("hello world!")  
s4 = sys.intern("hello world!")  

print(s1 is s2)
print(s3 is s4)

结果:

True
True 

不过这个也不确定,我的电脑对很长很复杂的字符串都能驻留:

sakuya = '操纵时间程度的能力,与吸血鬼元素有关。我写这一部分是为了超过20个字。'
dio = '操纵时间程度的能力,与吸血鬼元素有关。我写这一部分是为了超过20个字。'

print(sakuya is dio)

我的电脑的结果:

True 

结果又荒谬了起来……

6.2.3 元组的驻留机制

仅含不可变类型且内容相同的元组可能被驻留,但依赖实现细节。比如我的电脑(你丫是真犟啊!哪个反例都有你)驻留了(1,2,3)这个元组。

不过,很大可能会发生的是,创建元组的副本其实只是创建了别名,请看下例:

t1 = (1,2,3)

t2 = tuple(t1)
t3 = t1[:]

print(t2 is t1)
print(t3 is t1)

结果:

True
True 

6.2.4 浮点数的驻留机制

一般地,浮点数不驻留。但是,你可能猜到了,我的倔脾气电脑让浮点数驻留了:

f1 = 3.1415926
f2 = 3.1415926

print(f1 is f2)

你的电脑可能显示:

False

我的电脑的输出:

True 

6.3 我应该在代码中利用驻留机制吗?

不要用。我在本部分开头说过一次,这里再强调一遍。

驻留是 CPython 的优化机制,而非语言强制要求。大量利用驻留机制会增加代码的不确定性(见我那“最美逆行者”般的电脑),并且对于可读性也是灾难,不了解驻留的程序员可能会对你的代码产生困惑。

七. 结束

哎呀不行了!我写不动了……写这篇文章大概花了我7个小时,我已经尽可能做到全面和深入,并配上合适或有趣的例子。不过有错误也在所难免,只希望真有错的话不要坑到大家……

总之,希望这篇文章对你有帮助,我得一边儿凉快去啦。

作者:解析几何太难啦QAQ

物联沃分享整理
物联沃-IOTWORD物联网 » Python中==运算符与is的全面深入解析:重载与实践技巧探讨

发表回复