天天看點

《Python面向對象程式設計指南》——2.6 比較運算符方法

本節書摘來自異步社群《python面向對象程式設計指南》一書中的第2章,第2.6節,作者[美]steven f. lott, 張心韬 蘭亮 譯,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視。

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會嘗試改變運算順序。

《Python面向對象程式設計指南》——2.6 比較運算符方法

下面,我們通過一個例子看看這兩條規則是如何工作的,我們定義了一個隻包含其中一個運算符實作的類,然後把這個類用于另外一種操作。

下面是我們使用類中的一段代碼。

這段代碼基于21點的比較規則,花色對于大小不重要。我們省略了比較方法,看看當缺少比較運算符時,python将如何回退。這個類允許我們進行<比較。但是有趣的是,通過改變操作數的順序,python也可以使用這個類進行>比較。換句話說,xx是等價的。這遵從了鏡像反射法則;在第7章“建立數值類型”中,我們會再探讨這個部分。

當我們試圖評估不同的比較運算時就會看到這種現象。下面,我們建立兩個cards類,然後用不同的方式比較它們。

從代碼中,我們可以看到,two < three調用了two.__lt__(three)。

但是,對于two > three,由于沒有定義__gt__(),python使用three.__lt__(two)作為備用的比較方法。

預設情況下,__eq__()方法從object繼承而來,它比較不同對象的id值。當我們用于==或!=比較對象時,結果如下。

可以看到,結果和我們預期的不同。是以,我們通常都會需要重載預設的__eq__()實作。

此外,邏輯上,不同的運算符之間是沒有聯系的。但是從數學的角度來看,我們可以基于兩個運算符完成所有必需的比較運算。python沒有實作這種機制。相反,python預設認為下面的4組比較是等價的。

《Python面向對象程式設計指南》——2.6 比較運算符方法

這意味着,我們必須至少提供每組中的一個運算符。例如,我可以提供__eq__()、__ne__()、__lt__()和__le__()的實作。

@functools.total_ordering修飾符打破了這種預設行為的局限性,它可以從__eq__()或者__lt__()、__le__()、__gt__()和__ge__()的任意一個中推斷出其他的比較方法。在第7章“建立數值類型”中,我們會詳細探讨這種方法。

當設計比較運算符時,要考慮兩個因素。

如何比較同一個類的兩個對象。

如何比較不同類的對象。

對于一個有許多屬性的類,當我們研究它的比較運算符時,通常會覺得有很明顯的歧義。或許這些比較運算符的行為和我們的預期不完全相同。

再次考慮我們21點的例子。例如card1==card2這樣的表達式,很明顯,它們比較了rank和suit,對嗎?但是,這總是和我們的預期一緻嗎?畢竟,suit對于21點中的比較結果沒有影響。

如果我們想決定是否能分牌,我們必須決定下面兩個代碼片段哪一個更好。下面是第1個代碼段。

<code>if hand.cards[0] == hand.cards[1]</code>

下面是第2個代碼段。

<code>if hand.cards[0].rank == hand.cards[1].rank</code>

雖然其中一個更短,但是簡潔的并不總是最好的。如果我們比較牌時隻考慮rank,那麼當我們建立單元測試時會有問題,例如一個簡單的testcase.assertequal()方法就會接受很多不同的cards對象,但是一個單元測試應該隻關注正确的cards對象。

例如card1 &lt;= 7,很明顯,這個表達式想要比較的是rank。

我們是否需要在一些比較中比較cards對象所有的屬性,而在另一些比較中隻關注rank?如果我們想要按suit排序需要做什麼?而且,相等性比較必須同時計算哈希值。我們在哈希值的計算中使用了多個屬性值,那麼也必須在相等性比較中使用它們。在這種情況下,很明顯相等性的比較必須比較完整的card對象,因為在計算哈希值時使用了rank和suit。

但是,對于card對象間的排序比較,應該隻需要基于rank。類似地,如果和整數比較,也應該隻關注rank。對于判斷是否要發牌的情況,很明顯,用hand.cards[0]. rank == hand.cards[1].rank判斷是很好的方式,因為它遵守了發牌的規則。

下面我們通過一個更完整的blackjackcard類來看一下簡單的同類比較。

現在我們定義了6個比較運算符。

我們已經展示了兩種類型檢查的方法:顯式的和隐式的。顯式的類型檢查調用了isinstance()。隐式的類型檢查使用了一個try:語句塊。理論上,使用try:語句塊有一個小小的優點:它避免了重複的類名稱。有的人完全可能會想建立一種和這個blackjackcard相容的card類的變種,但是并沒有适當地定義為一個子類。這時候使用isinstance()有可能導緻一個原本正确的類出現異常。

使用try:語句塊可以讓一個碰巧也有一個rank屬性的類仍然可以正常工作。不用擔心這樣會帶來什麼難,因為它除了在此處被真正使用外,這個類在程式的其他部分都無法被正常使用。而且,誰會真的去比較一個card的執行個體和一個金融系統中恰好有rank屬性的類呢?

後面的例子中,我們主要會關注try:語句塊的使用。isinstance()方法是python中慣用的方式,而且也被廣泛應用。我們通過顯式地傳回notimplemented告訴python這個運算符在目前類型中還沒有實作。這樣,python 可以嘗試交換操作數的順序來看看另外一個操作數是否提供了對應的實作。如果沒有找到正确的運算符,那麼python會抛出typeerror異常。

我們沒有給出3個子類和工廠函數:card21()的代碼,它們作為本章的習題。

我們也沒有給出類内比較的代碼,這個我們會在下一個部分中詳細講解。用上面定義的這個類,我們可以成功地比較不同的牌。下面是一個建立并比較3張牌的例子。

用上面定義的cards類,我們可以進行像下面這樣的一系列比較。

這個類的行為與我們預期的一緻。

我們會繼續以blackjackcard類為例來看看當兩個比較運算中的兩個操作數屬于不同的類時會發生什麼。

下面我們将一個card執行個體和一個int值進行比較。

可以看到,這和我們預期的行為一緻,blackjackcard的子類number21card沒有實作必需的特殊方法,是以産生了一個typeerror異常。

但是,再考慮下面的兩個例子。

為什麼用等号比較可以傳回結果呢?因為當python遇到notimplemented的值時,會嘗試交換兩個操作數的順序。在這個例子中,由于整型的值定義了一個int.__eq__()方法,是以可以和一個非數值類型的對象比較。

接下來,我們定義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方法的一種實作,它使用硬總和與軟總和的內插補點計算出目前手中牌的總和。

我們用delta_soft記錄硬總和與軟總和之間的最大內插補點。對于其他牌而言,這個內插補點是0。但是對于a牌,這個內插補點不是0。

得到了delta_soft和硬總和之後,我們就可以決定傳回值是什麼。如果hard + delta_soft小于或者等于21,那麼就傳回軟總和。如果軟總和大于21,那麼就傳回硬總和。

我們可以考慮把21定義為宏。有時候一個有意義的名字比一個字面值更有用。但是,因為21在21點中幾乎不可能變成其他值,是以很難找到其他比21更有意義的名字。

定義了hand對象的總和之後,我們可以合理地定義hand執行個體間的比較函數和hand與int間的比較函數。為了确定我們在進行哪種類型的比較,必須使用isinstance()。

下面是定義了比較方法的hand類的部分代碼。

這裡我們隻定義了3個比較方法。

為了和hand對象互動,我們需要一些card對象。

我們會把這些牌用于兩個不同hand對象。

第1個hand對象有一張不相關的莊家牌和我們上面建立的4張牌,包括一張a牌:

軟總和是18,硬總和是8。

下面是第2個hand對象,除了上面第1個hand對象的4張牌,還包括了另一張牌。

硬總和是13,由于總和超過了21點,是以沒有軟總和。

從下面的代碼中可以看到,hand對象之間的比較結果和我們預期的一緻。

我們可以用比較運算符對hand對象排序。

我們也可以像下面這樣把hand對象和int比較。

隻要python沒有強制使用後備的比較方法,hand對象和整數的比較就可以很好地工作。上面的例子也展示了當沒有定義__gt__()方法時會發生什麼。python檢查另一個操作數,但是整數17也沒有任何與hand相關的__lt__()方法定義。

我們可以添加必要的__gt__()和__ge__()函數,這樣hand就可以很好地與整數進行比較。