《Python面向对象编程指南》——2.6 比较运算符方法

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就可以很好地与整数进行比较。

时间: 2024-07-28 16:48:23

《Python面向对象编程指南》——2.6 比较运算符方法的相关文章

《Python面向对象编程指南》——导读

前 言 本书主要介绍Python语言的高级特性,特别是如何编写高质量的Python程序.这通常意味着编写高性能且拥有良好可维护性的程序.同时,我们也会探究不同的设计方案并确定究竟是哪种方案提供了最佳性能.而对于一些正在寻找解决方案的问题,这也是一种很好的方式. 本书的大部分内容将介绍一种给定设计的不同替代方案.一些方案性能更好,另一些方案更加简单或者更加适合于特定领域的问题.最重要的是,找到最好的算法和最优的数据结构,以最少的开销换取最大的价值.时间就是金钱,高效的程序会为它们的用户创造更多的价

《Python面向对象编程指南》——1.5 通过工厂函数调用__init()__

1.5 通过工厂函数调用__init()__ 我们可以使用工厂函数来完成所有Card对象的创建,这比枚举52张牌的方式好很多.在Python中,实现工厂有两种途径. 定义一个函数,返回不同类的对象. 定义一个类,包含了创建对象的方法.这是完整的工厂设计模式,正如设计模式书中提到的.在类似Java这样的语言里,工厂类层次结构是必需的,因为语言本身不支持可以脱离类而单独存在的函数. 在Python里,类定义不是必需的.仅当特别复杂的情形,工厂类才是不错的选择.Python的优势之一是,对于只需要简单

《Python面向对象编程指南》——2.7 __del__()方法

2.7 __del__()方法 __del__()方法有一个让人费解的使用场景. 这个方法的目的是在将一个对象从内存中清除之前,可以有机会做一些清理工作.如果使用上下文管理对象或者with语句来处理这种需求会更加清晰,这也是第5章"可调用对象和上下文的使用"的内容.对于Python的垃圾回收机制而言,创建一个上下文比使用__del__()更加容易预判. 但是,如果一个Python对象包含了一些操作系统的资源,__del__()方法是把资源从程序中释放的最后机会.例如,引用了一个打开的文

《Python面向对象编程指南》——第1部分 用特殊方法实现Python风格的类 第1章 __init__()方法 1.1 隐式的基类——object

第1部分 用特殊方法实现Python风格的类 init()方法 与Python无缝集成--基本特殊方法 属性访问.特性和修饰符 抽象基类设计的一致性 可调用对象和上下文的使用 创建容器和集合 创建数值类型 装饰器和Mixins--横切方面 用特殊方法实现 Python风格的类 通过重写特殊方法来完成对Python内部机制的调用,在Python中是很普遍的.例如len()函数就可以重写一个类的__len__()方法. 这意味着对于像(len(x))这样的通用公共接口,任何类(例如,声明一个类叫ti

《Python面向对象编程指南》——2.8 __new__()方法和不可变对象

2.8 __new__()方法和不可变对象 __new__方法的一个用途是初始化不可变对象.__new__()方法中允许创建未初始化的对象.这允许我们在__init__()方法被调用之前先设置对象的属性. 由于不可变类的__init__()方法很难重载,因此__new__方法提供了一种扩展这种类的方法. 下面是一个错误定义的类,我们定义了float的一个包含单位信息的版本. class Float_Fail( float ): def __init__( self, value, unit ):

《Python面向对象编程指南》——第2章 与Python无缝集成——基本特殊方法 2.1 __repr__()和__str__()方法

第2章 与Python无缝集成--基本特殊方法 Python中有一些特殊方法,它们允许我们的类和Python更好地集成.在标准库参考(Standard Library Reference)中,它们被称为基本特殊方法,是与Python的其他特性无缝集成的基础. 例如,我们用字符串来表示一个对象的值.Object基类包含了__repr__()和__str__()的默认实现,它们提供了一个对象的字符串描述.遗憾的是,这些默认的实现不够详细.我们几乎总会想重写它们中的一个或两个.我们还会介绍__form

《Python面向对象编程指南》——2.10 总结

2.10 总结 我们已经介绍了许多基本的特殊方法,它们是我们在设计任何类时的基本特性.这些方法已经包含在每个类中,只是它们的默认行为不一定能满足我们的需求. 我们几乎总是需要重载__repr__().__str__().和__format__().这些方法的默认实现不是非常有用. 我们几乎不需要重载__bool__()方法,除非我们想自定义集合.这是第6章"创建容器和集合"的主题. 我们常常需要重载比较运算符和__hash__()方法.默认的实现只适合于比较简单不可变对象,但是不适用于

《Python面向对象编程指南》——1.7 简单的组合对象

1.7 简单的组合对象 一个组合对象也可以称作容器.我们会从一个简单的组合对象开始介绍:一副牌.这是一个基本的集合对象.我们的确可以简单地使用一个list来代替一副牌(deck)对象. 在设计一个类之前,我们需要考虑这样的一个问题:简单地使用list是合适的做法吗? 可以使用random.shuffle()函数完成洗牌操作,使用deck.pop()来完成发牌操作. 一些程序员可能会过早定义新类,正如像使用内置类一样,违反了一些面向对象的设计原则.比如像下面的这个设计. d= [card6(r+1

《Python面向对象编程指南》——1.12 更多的__init__()技术

1.12 更多的__init__()技术 我们再来看一下其他一些更高级的__init__()技术的应用.相比前面的介绍,它们的应用场景不是特别常见. 以下是Player类的定义,初始化使用了两个策略对象和一个table对象.这个__init__()函数看起来不够漂亮. class Player: def __init__( self, table, bet_strategy, game_strategy ): self.bet_strategy = bet_strategy self.game_