本节书摘来自异步社区《Python面向对象编程指南》一书中的第2章,第2.6节,作者[美]Steven F. Lott, 张心韬 兰亮 译,更多章节内容可以访问云栖社区“异步社区”公众号查看。
2.6 比较运算符方法
Python有6个比较运算符。这些运算符分别对应一个特殊方法的实现。根据文档,运算符和特殊方法的对应关系如下所示。
- x < y调用x.__lt__(y)。
- x <=y调用x.__le__(y)。
- x == y调用x.__eq__(y)。
- x != y调用x.__ne__(y)。
- x > y调用x.__gt__(y)。
- x >= y调用x.__ge__(y)。
我们会在第7章“创建数值类型”中再探讨比较运算符。
对于实际上使用了哪个比较运算符,还有一条规则。这些规则依赖于作为左操作数的对象定义需要的特殊方法。如果这个对象没有定义,Python会尝试改变运算顺序。
下面,我们通过一个例子看看这两条规则是如何工作的,我们定义了一个只包含其中一个运算符实现的类,然后把这个类用于另外一种操作。
下面是我们使用类中的一段代码。
class BlackJackCard_p:
def __init__( self, rank, suit ):
self.rank= rank
self.suit= suit
def __lt__( self, other ):
print( "Compare {0} < {1}".format( self, other ) )
return self.rank < other.rank
def __str__( self ):
return "{rank}{suit}".format( **self.__dict__ )
这段代码基于21点的比较规则,花色对于大小不重要。我们省略了比较方法,看看当缺少比较运算符时,Python将如何回退。这个类允许我们进行<比较。但是有趣的是,通过改变操作数的顺序,Python也可以使用这个类进行>比较。换句话说,xx是等价的。这遵从了镜像反射法则;在第7章“创建数值类型”中,我们会再探讨这个部分。
当我们试图评估不同的比较运算时就会看到这种现象。下面,我们创建两个Cards类,然后用不同的方式比较它们。
>>> two = BlackJackCard_p( 2, '' )
>>> three = BlackJackCard_p( 3, '' )
>>> two < three
Compare 2 < 3
True
>>> two > three
Compare 3 < 2
False
>>> two == three
False
>>> two <= three
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unorderable types: BlackJackCard_p() <= BlackJackCard_p()
从代码中,我们可以看到,two < three调用了two.__lt__(three)。
但是,对于two > three,由于没有定义__gt__(),Python使用three.__lt__(two)作为备用的比较方法。
默认情况下,__eq__()方法从object继承而来,它比较不同对象的ID值。当我们用于==或!=比较对象时,结果如下。
>>> two_c = BlackJackCard_p( 2, '' )
>>>two == two_c
False
可以看到,结果和我们预期的不同。所以,我们通常都会需要重载默认的__eq__()实现。
此外,逻辑上,不同的运算符之间是没有联系的。但是从数学的角度来看,我们可以基于两个运算符完成所有必需的比较运算。Python没有实现这种机制。相反,Python默认认为下面的4组比较是等价的。
这意味着,我们必须至少提供每组中的一个运算符。例如,我可以提供__eq__()、__ne__()、__lt__()和__le__()的实现。
@functools.total_ordering修饰符打破了这种默认行为的局限性,它可以从__eq__()或者__lt__()、__le__()、__gt__()和__ge__()的任意一个中推断出其他的比较方法。在第7章“创建数值类型”中,我们会详细探讨这种方法。
2.6.1 设计比较运算
当设计比较运算符时,要考虑两个因素。
- 如何比较同一个类的两个对象。
- 如何比较不同类的对象。
对于一个有许多属性的类,当我们研究它的比较运算符时,通常会觉得有很明显的歧义。或许这些比较运算符的行为和我们的预期不完全相同。
再次考虑我们21点的例子。例如card1==card2这样的表达式,很明显,它们比较了rank和suit,对吗?但是,这总是和我们的预期一致吗?毕竟,suit对于21点中的比较结果没有影响。
如果我们想决定是否能分牌,我们必须决定下面两个代码片段哪一个更好。下面是第1个代码段。
if hand.cards[0] == hand.cards[1]
下面是第2个代码段。
if hand.cards[0].rank == hand.cards[1].rank
虽然其中一个更短,但是简洁的并不总是最好的。如果我们比较牌时只考虑rank,那么当我们创建单元测试时会有问题,例如一个简单的TestCase.assertEqual()方法就会接受很多不同的Cards对象,但是一个单元测试应该只关注正确的Cards对象。
例如card1 <= 7,很明显,这个表达式想要比较的是rank。
我们是否需要在一些比较中比较Cards对象所有的属性,而在另一些比较中只关注rank?如果我们想要按suit排序需要做什么?而且,相等性比较必须同时计算哈希值。我们在哈希值的计算中使用了多个属性值,那么也必须在相等性比较中使用它们。在这种情况下,很明显相等性的比较必须比较完整的Card对象,因为在计算哈希值时使用了rank和suit。
但是,对于Card对象间的排序比较,应该只需要基于rank。类似地,如果和整数比较,也应该只关注rank。对于判断是否要发牌的情况,很明显,用hand.cards[0]. rank == hand.cards[1].rank判断是很好的方式,因为它遵守了发牌的规则。
2.6.2 实现同一个类的对象比较
下面我们通过一个更完整的BlackJackCard类来看一下简单的同类比较。
class BlackJackCard:
def __init__( self, rank, suit, hard, soft ):
self.rank= rank
self.suit= suit
self.hard= hard
self.soft= soft
def __lt__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank < other.rank
def __le__( self, other ):
try:
return self.rank <= other.rank
except AttributeError:
return NotImplemented
def __gt__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank > other.rank
def __ge__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank >= other.rank
def __eq__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank == other.rank and self.suit == other.suit
def __ne__( self, other ):
if not isinstance( other, BlackJackCard ): return
NotImplemented
return self.rank != other.rank and self.suit != other.suit
def __str__( self ):
return "{rank}{suit}".format( **self.__dict__)
现在我们定义了6个比较运算符。
我们已经展示了两种类型检查的方法:显式的和隐式的。显式的类型检查调用了isinstance()。隐式的类型检查使用了一个try:语句块。理论上,使用try:语句块有一个小小的优点:它避免了重复的类名称。有的人完全可能会想创建一种和这个BlackJackCard兼容的Card类的变种,但是并没有适当地定义为一个子类。这时候使用isinstance()有可能导致一个原本正确的类出现异常。
使用try:语句块可以让一个碰巧也有一个rank属性的类仍然可以正常工作。不用担心这样会带来什么难,因为它除了在此处被真正使用外,这个类在程序的其他部分都无法被正常使用。而且,谁会真的去比较一个Card的实例和一个金融系统中恰好有rank属性的类呢?
后面的例子中,我们主要会关注try:语句块的使用。isinstance()方法是Python中惯用的方式,而且也被广泛应用。我们通过显式地返回NotImplemented告诉Python这个运算符在当前类型中还没有实现。这样,Python 可以尝试交换操作数的顺序来看看另外一个操作数是否提供了对应的实现。如果没有找到正确的运算符,那么Python会抛出TypeError异常。
我们没有给出3个子类和工厂函数:card21()的代码,它们作为本章的习题。
我们也没有给出类内比较的代码,这个我们会在下一个部分中详细讲解。用上面定义的这个类,我们可以成功地比较不同的牌。下面是一个创建并比较3张牌的例子。
>>> two = card21( 2, '' )
>>> three = card21( 3, '' )
>>> two_c = card21( 2, '' )
用上面定义的Cards类,我们可以进行像下面这样的一系列比较。
>>> two == two_c
False
>>> two.rank == two_c.rank
True
>>> two< three
True
>>> two_c < three
True
这个类的行为与我们预期的一致。
2.6.3 实现不同类的对象比较
我们会继续以BlackJackCard类为例来看看当两个比较运算中的两个操作数属于不同的类时会发生什么。
下面我们将一个Card实例和一个int值进行比较。
>>> two = card21( 2, '' )
>>> two < 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unorderable types: Number21Card() < int()
>>> two > 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unorderable types: Number21Card() > int()
可以看到,这和我们预期的行为一致,BlackJackCard的子类Number21Card没有实现必需的特殊方法,所以产生了一个TypeError异常。
但是,再考虑下面的两个例子。
>>> two == 2
False
>>> two == 3
False
为什么用等号比较可以返回结果呢?因为当Python遇到NotImplemented的值时,会尝试交换两个操作数的顺序。在这个例子中,由于整型的值定义了一个int.__eq__()方法,所以可以和一个非数值类型的对象比较。
2.6.4 硬总和、软总和和多态
接下来,我们定义Hand类,这样它可以有意义地比较不同的类。和其他的比较一样,我们必须确定我们要比较的内容。
对于Hand类之间相等性的比较,我们应该比较所有的牌。
而对于Hand类之间顺序的比较,我们需要比较每一个Hand对象的属性。对于与int值的比较,我们应该将当前Hand对象的总和与int值进行比较。为了获得当前总和,我们需要弄清21点中硬总和与软总和的细微差别。
当手上有一张A牌时,下面是两种可能的总和。
- 软总和把A牌当作11点。如果软总和超过21点,那么这张A牌就不可用。
- 硬总和把A牌当作1点。
也就是说,手中牌的总和不是简单地累加所有的牌面值。
首先,我们需要确定手中是否有A牌。然后,我们才能确定是否有一个可用的(小于或者等于21点)的软总和。否则,我们就要使用硬总和。
对于确定子类与基类的关系逻辑的实现是否依赖于isinstance(),是判断多态使用是否合理的标志。通常,这样的做法不符合基本的封装原则。一个好的子类定义应该只依赖于相同的方法签名。理想状态下,类的定义是不可见的,我们也没有必要知道类内部的细节。而不合理的多态则会广泛地使用isinstance()。在一些情况下,isinstance()是必需的,尤其是当使用Python内置的类时。但是,我们不应该向内置类中追加任何方法函数,而且为了加入一个多态的方法而去使用继承也是不值得的。
在一些没有继承的特殊方法中,我们可以看到必须使用isinstance()来实现不同类的对象间的交互。在下一个部分中,我们会展示在没有关系的类间使用isinstance()的方法。
对于与Card相关的类,我们希望用一个方法(或者一个属性)就可以识别一张A牌,而不需要调用isinstance()。这个方法是一个多态的辅助方法,它可以确保我们能够辨别不同的牌。
这里,我们有两个选择。
- 新增一个类级别的属性。
- 新增一个方法。
由于保险注的存在,有两个原因让我们检测是否有A牌。如果庄家牌是A牌,那么就会触发一个保险注。如果庄家或者玩家的手上有A牌,那么需要对比软总和与硬总和。
对于A牌而言,硬总和与软总和总是需要通过card.soft-card.hard的值来区分。仔细看看AceCard的定义就可以知道这个值是10。但是,仔细地分析这个类的实现,我们就会发现这个版本的实现会破坏封装性。
我们可以把BlackJackCard看作不可见的,所以我们仅仅需要比较card.soft- card.hard!=0的值是否为真。如果结果为真,那么我们就可以用硬总和与软总和算出手中牌的总和。
下面是total方法的一种实现,它使用硬总和与软总和的差值计算出当前手中牌的总和。
def total( self ):
delta_soft = max( c.soft-c.hard for c in self.cards )
hard = sum( c.hard for c in self.cards )
if hard+delta_soft <= 21: return hard+delta_soft
return hard
我们用delta_soft记录硬总和与软总和之间的最大差值。对于其他牌而言,这个差值是0。但是对于A牌,这个差值不是0。
得到了delta_soft和硬总和之后,我们就可以决定返回值是什么。如果hard + delta_soft小于或者等于21,那么就返回软总和。如果软总和大于21,那么就返回硬总和。
我们可以考虑把21定义为宏。有时候一个有意义的名字比一个字面值更有用。但是,因为21在21点中几乎不可能变成其他值,所以很难找到其他比21更有意义的名字。
2.6.5 不同类比较的例子
定义了Hand对象的总和之后,我们可以合理地定义Hand实例间的比较函数和Hand与int间的比较函数。为了确定我们在进行哪种类型的比较,必须使用isinstance()。
下面是定义了比较方法的Hand类的部分代码。
class Hand:
def __init__( self, dealer_card, *cards ):
self.dealer_card= dealer_card
self.cards= list(cards)
def __str__( self ):
return ", ".join( map(str, self.cards) )
def __repr__( self ):
return "{__class__.__name__}({dealer_card!r}, {_cards_str})".format(
__class__=self.__class__,
_cards_str=", ".join( map(repr, self.cards) ),
**self.__dict__ )
def __eq__( self, other ):
if isinstance(other,int):
return self.total() == other
try:
return (self.cards == other.cards
and self.dealer_card == other.dealer_card)
except AttributeError:
return NotImplemented
def __lt__( self, other ):
if isinstance(other,int):
return self.total() < other
try:
return self.total() < other.total()
except AttributeError:
return NotImplemented
def __le__( self, other ):
if isinstance(other,int):
return self.total() <= other
try:
return self.total() <= other.total()
except AttributeError:
return NotImplemented
__hash__ = None
def total( self ):
delta_soft = max( c.soft-c.hard for c in self.cards )
hard = sum( c.hard for c in self.cards )
if hard+delta_soft <= 21: return hard+delta_soft
return hard
这里我们只定义了3个比较方法。
为了和Hand对象交互,我们需要一些Card对象。
>>> two = card21( 2, '' )
>>> three = card21( 3, '' )
>>> two_c = card21( 2, '' )
>>> ace = card21( 1, '' )
>>> cards = [ ace, two, two_c, three ]
我们会把这些牌用于两个不同Hand对象。
第1个Hand对象有一张不相关的庄家牌和我们上面创建的4张牌,包括一张A牌:
>>> h= Hand( card21(10,''), *cards )
>>> print(h)
A, 2, 2, 3
>>> h.total()
18
软总和是18,硬总和是8。
下面是第2个Hand对象,除了上面第1个Hand对象的4张牌,还包括了另一张牌。
>>> h2= Hand( card21(10,''), card21(5,''), *cards )
>>> print(h2)
5, A, 2, 2, 3
>>> h2.total()
13
硬总和是13,由于总和超过了21点,所以没有软总和。
从下面的代码中可以看到,Hand对象之间的比较结果和我们预期的一致。
>>> h < h2
False
>>> h > h2
True
我们可以用比较运算符对Hand对象排序。
我们也可以像下面这样把Hand对象和int比较。
>>> h == 18
True
>>> h < 19
True
>>> h > 17
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unorderable types: Hand() > int()
只要Python没有强制使用后备的比较方法,Hand对象和整数的比较就可以很好地工作。上面的例子也展示了当没有定义__gt__()方法时会发生什么。Python检查另一个操作数,但是整数17也没有任何与Hand相关的__lt__()方法定义。
我们可以添加必要的__gt__()和__ge__()函数,这样Hand就可以很好地与整数进行比较。