[譯] Python 學習 —— __init__() 方法 4

注:原書做者 Steven F. Lott,原書名爲 Mastering Object-oriented Pythonpython

沒有__init__()的無狀態對象

下面這個示例,是一個簡化去掉了__init__()的類。這是一個常見的Strategy設計模式對象。策略對象插入到主對象來實現一種算法或者決策。它可能依賴主對象的數據,策略對象自身可能沒有任何數據。咱們常常設計策略類來遵循Flyweight設計模式:咱們避免在Strategy對象內部進行存儲。全部提供給Strategy的值都是做爲方法的參數值。Strategy對象自己能夠是無狀態的。這更可能是爲了方法函數的集合而非其餘。c++

在本例中,咱們爲Player實例提供了遊戲策略。下面是一個抓牌和減小其餘賭注的策略示例(比較笨的策略):程序員

class GameStrategy:
    def insurance(self, hand):
        return False
    def split(self, hand):
        return False
    def double(self, hand):
        return False
    def hit(self, hand):
        return sum(c.hard for c in hand.cards) <= 17

每一個方法都須要當前的Hand做爲參數值。決策是基於可用信息的,也就是指莊家的牌和閒家的牌。算法

咱們可使用不一樣的Player實例來構建單個策略實例,以下面代碼片斷所示:編程

dumb = GameStrategy()

咱們能夠想象創造一組相關的策略類,在21點中玩家能夠針對各類決策使用不一樣的規則。設計模式

一些額外的類定義

如前所述,一個玩家有兩個策略:一個用於下注,一個用於出牌。每一個Player實例都與模擬計算執行器有一序列的交互。咱們稱計算執行器爲Table類。數據結構

Table類須要Player實例提供如下事件:編程語言

  • 玩家必須基於下注策略來設置初始賭注。ide

  • 玩家將獲得一手牌。函數

  • 若是手牌是可分離的,玩家必須決定是分離或不基於出牌策略。這能夠建立額外的Hand實例。在一些賭場,額外的一手牌也是可分離的。

  • 對於每一個Hand實例,玩家必須基於出牌策略來決定是要牌、加倍或停牌。

  • 玩家會得到獎金,而後基於輸贏狀況調整下注策略。

從這,咱們能夠看到Table類有許多API方法來得到賭注,建立Hand對象提供分裂、分解每一手牌、付清賭注。這個對象跟蹤了一組Players的出牌狀態。

如下是處理賭注和牌的Table類:

class Table:
    def __init__(self):
        self.deck = Deck()
    def place_bet(self, amount):
        print("Bet", amount)
    def get_hand(self):
        try:
            self.hand = Hand2(d.pop(), d.pop(), d.pop())
            self.hole_card = d.pop()
        except IndexError:
            # Out of cards: need to shuffle.
            self.deck = Deck()
            return self.get_hand()
        print("Deal", self.hand)
        return self.hand
    def can_insure(self, hand):
        return hand.dealer_card.insure

Player使用Table類來接收賭注,建立一個Hand對象,出牌時根據這手牌來決定是否買保險。使用額外方法去獲取牌並決定償還。

get_hand()中展現的異常處理不是一個精確的賭場玩牌模型。這可能會致使微小的統計偏差。更精確的模擬須要編寫一副牌,當空的時候能夠從新洗牌,而不是拋出異常。

爲了正確地交互和模擬現實出牌,Player類須要一個下注策略。下注策略是一個有狀態的對象,決定了初始賭注。各類下注策略調整賭注一般都是基於遊戲的輸贏。

理想狀況下,咱們渴望有一組下注策略對象。Python的裝飾器模塊容許咱們建立一個抽象超類。一個非正式的方法建立策略對象引起的異常必須由子類實現。

咱們定義了一個抽象超類,此外還有一個具體子類定義了固定下注策略,以下所示:

class BettingStrategy:
    def bet(self):
        raise NotImplementedError("No bet method")
    def record_win(self):
        pass
    def record_loss(self):
        pass

class Flat(BettingStrategy):
    def bet(self):
        return 1

超類定義了帶有默認值的方法。抽象超類中的基本bet()方法拋出異常。子類必須覆蓋bet()方法。其餘方法能夠提供默認值。這裏給上一節的遊戲策略添加了下注策略,咱們能夠看看Player類周圍更復雜的__init__()方法。

咱們能夠利用abc模塊正式化抽象超類的定義。就像下面的代碼片斷那樣:

import abc
class BettingStrategy2(metaclass=abc.ABCMeta):
    @abstractmethod
    def bet(self):
        return 1
    def record_win(self):
        pass
    def record_loss(self):
       pass

這樣作的優點在於建立了BettingStrategy2的實例,不會形成任何子類bet()的失敗。若是咱們試圖經過未實現的抽象方法來建立這個類的實例,它將引起一個異常來替代建立對象。

是的,抽象方法有一個實現。它能夠經過super().bet()來訪問。

多策略的__init__()

咱們可從各類來源建立對象。例如,咱們可能須要複製一個對象做爲建立備份或凍結一個對象的一部分,以便它能夠做爲字典的鍵或被置入集合中;這是內置類setfrozenset背後的想法。

有幾個整體設計模式,它們有多種方法來構建一個對象。一個設計模式就是一個複雜的__init__(),稱爲多策略初始化。同時,有多個類級別的(靜態)構造函數的方法。

這些都是不兼容的方法。他們有徹底不一樣的接口。

避免克隆方法

在Python中,一個克隆方法不必複製一個不須要的對象。使用克隆技術代表多是未能理解Python中的面向對象設計原則。

克隆方法封裝了在錯誤的地方建立對象的常識。被克隆的源對象不能瞭解經過克隆創建的目標對象的結構。然而,若是源對象提供了一個合理的、獲得了良好封裝的接口,反向(目標對象有源對象相關的內容)是能夠接受的。

咱們這裏展現的例子是有效的克隆,由於它們很簡單。咱們將在下一章展開它們。然而,展現這些基本技術是用來作更多的事情,而不是瑣碎的克隆,咱們看看將可變對象Hand凍結爲不可變對象。

下面能夠經過兩種方式建立Hand對象的示例:

class Hand3:
    def __init__(self, *args, **kw):
      if len(args) == 1 and isinstance(args[0], Hand3):
          # Clone an existing hand; often a bad idea
          other = args[0]
          self.dealer_card = other.dealer_card
          self.cards = other.cards
      else:
          # Build a fresh, new hand.
          dealer_card, *cards = args
          self.dealer_card =  dealer_card
          self.cards = list(cards)

第一種狀況,從現有的Hand3對象建立Hand3實例。第二種狀況,從單獨的Card實例建立Hand3對象。

frozenset對象的類似之處在於可由單獨的項目或現有set對象建立。咱們將在下一章學習建立不可變對象。使用像下面代碼片斷這樣的構造,從現有的Hand建立一個新的Hand使得咱們能夠建立一個Hand對象的備份:

h = Hand(deck.pop(), deck.pop(), deck.pop())
memento = Hand(h)

咱們保存Hand對象到memento變量中。這能夠用來比較最後處理的牌與原來手牌,或者咱們能夠在集合或映射中使用時凍結它。

1. 更復雜的初始化選擇

爲了編寫一個多策略初始化,咱們常常被迫放棄特定的命名參數。這種設計的優勢是靈活,但缺點是不透明的、毫無心義的參數命名。它須要大量的用例文檔來解釋變形。

咱們還能夠擴大咱們的初始化來分裂Hand對象。分裂Hand對象的結果是隻是另外一個構造函數。下面的代碼片斷說明了如何分裂Hand對象:

class Hand4:
    def __init__(self, *args, **kw):
        if len(args) == 1 and isinstance(args[0], Hand4):
            # Clone an existing handl often a bad idea
            other = args[0]
            self.dealer_card = other.dealer_card
            self.cards= other.cards
        elif len(args) == 2 and isinstance(args[0], Hand4) and 'split' in kw:
            # Split an existing hand
            other, card = args
            self.dealer_card = other.dealer_card
            self.cards = [other.cards[kw['split']], card]
        elif len(args) == 3:
            # Build a fresh, new hand.
            dealer_card, *cards = args
            self.dealer_card =  dealer_card
            self.cards = list(cards)
        else:
            raise TypeError("Invalid constructor args={0!r} kw={1!r}".format(args, kw))
    def __str__(self):
        return ", ".join(map(str, self.cards))

這個設計包括得到額外的牌來創建合適的、分裂的手牌。當咱們從一個Hand4對象建立一個Hand4對象,咱們提供一個分裂的關鍵字參數,它從原Hand4對象使用Card類索引。

下面的代碼片斷展現了咱們如何使用被分裂的手牌:

d = Deck()
h = Hand4(d.pop(), d.pop(), d.pop())
s1 = Hand4(h, d.pop(), split=0)
s2 = Hand4(h, d.pop(), split=1)

咱們建立了一個Hand4初始化的h實例並分裂到兩個其餘Hand4實例,s1s2,並處理額外的Card類。21點的規則只容許最初的手牌有兩個牌值相等。

雖然這個__init__()方法至關複雜,它的優勢是能夠並行的方式從現有集建立fronzenset。缺點是它須要一個大文檔字符串來解釋這些變化。

2. 初始化靜態方法

當咱們有多種方法來建立一個對象時,有時會更清晰的使用靜態方法來建立並返回實例,而不是複雜的__init__()方法。

也可使用類方法做爲替代初始化,可是有一個實實在在的優點在於接收類做爲參數的方法。在凍結或分裂Hand對象的狀況下,咱們可能須要建立兩個新的靜態方法凍結或分離對象。使用靜態方法做爲代理構造函數是一個小小的語法變化,但當組織代碼的時候它擁有巨大的優點。

下面是一個有靜態方法的Hand,可用於從現有的Hand實例構建新的Hand實例:

class Hand5:
    def __init__(self, dealer_card, *cards):
        self.dealer_card = dealer_card
        self.cards = list(cards)
    @staticmethod
    def freeze(other):
        hand = Hand5(other.dealer_card, *other.cards)
        return hand
    @staticmethod
    def split(other, card0, card1 ):
        hand0 = Hand5(other.dealer_card, other.cards[0], card0)
        hand1 = Hand5(other.dealer_card, other.cards[1], card1)
        return hand0, hand1
    def __str__(self):
        return ", ".join(map(str, self.cards))

一個方法凍結或建立一個備份。另外一個方法分裂Hand5實例來建立兩個Hand5實例。

這更具可讀性並保存參數名的使用來解釋接口。

下面的代碼片斷展現了咱們如何經過這個版本分裂Hand5實例:

d = Deck()
h = Hand5(d.pop(), d.pop(), d.pop())
s1, s2 = Hand5.split(h, d.pop(), d.pop())

咱們建立了一個初始的Hand5h實例,分裂成兩個手牌,s1和s2,處理每個額外的Card類。split()靜態方法比__init__()簡單得多。然而,它不遵循從現有的set對象建立fronzenset對象的模式。

更多的__init__()技巧

咱們會看看一些其餘更高級的__init__()技巧。在前面的部分這些不是那麼廣泛有用的技術。

下面是Player類的定義,使用了兩個策略對象和table對象。這展現了一個看起來並不舒服的__init__()方法:

class Player:
    def __init__(self, table, bet_strategy, game_strategy):
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table = table
    def game(self):
        self.table.place_bet(self.bet_strategy.bet())
        self.hand = self.table.get_hand()
        if self.table.can_insure(self.hand):
            if self.game_strategy.insurance(self.hand):
                self.table.insure(self.bet_strategy.bet())
        # Yet more... Elided for now

Player__init__()方法彷佛只是統計。只是簡單傳遞命名好的參數到相同命名的實例變量。若是咱們有大量的參數,簡單地傳遞參數到內部變量會產生過多看似冗餘的代碼。

咱們能夠以下使用Player類(和相關對象):

table = Table()
flat_bet = Flat()
dumb = GameStrategy()
p = Player(table, flat_bet, dumb)
p.game()

咱們能夠經過簡單的傳遞關鍵字參數值到內部實例變量來提供一個很是短的和很是靈活的初始化。

下面是使用關鍵字參數值構建Player類的示例:

class Player2:
    def __init__(self, **kw):
        """Must provide table, bet_strategy, game_strategy."""
        self.__dict__.update(kw)
    def game(self):
        self.table.place_bet(self.bet_strategy.bet())
        self.hand= self.table.get_hand()
        if self.table.can_insure(self.hand):
            if self.game_strategy.insurance(self.hand):
                self.table.insure(self.bet_strategy.bet())
        # etc.

爲了簡潔而犧牲了大量可讀性。它跨越到一個潛在的默默無聞的領域。

由於__init__()方法減小到一行,它消除了某種程度上「累贅」的方法。這個累贅,不管如何,是被傳遞到每一個單獨的對象構造函數表達式中。咱們必須將關鍵字添加到對象初始化表達式中,由於咱們再也不使用位置參數,以下面代碼片斷所示:

p2 = Player2(table=table, bet_strategy=flat_bet, game_strategy=dumb)

爲何這樣作呢?

它有一個潛在的優點。這樣的類定義是至關易於擴展的。咱們可能只有幾個特定的擔心,提供額外關鍵字參數給構造函數。

下面是預期的用例:

>>> p1 = Player2(table=table, bet_strategy=flat_bet, game_strategy=dumb)
>>> p1.game()

下面是一個額外的用例:

>>> p2 = Player2(table=table, bet_strategy=flat_bet, game_strategy=dumb, log_name="Flat/Dumb")
>>> p2.game()

咱們添加了一個與類定義無關的log_name屬性。也許,這能夠被用做統計分析的一部分。Player2.log_name屬性能夠用來註釋日誌或其餘數據的收集。

咱們能添加的東西是有限的;咱們只能添加沒有與內部使用的命名相沖突的參數。類實現的常識是須要的,用於建立沒有濫用已在使用的關鍵字的子類。因爲**kw參數提供了不多的信息,咱們須要仔細閱讀。在大多數狀況下,比起檢查實現細節咱們寧願相信類是正常工做的。

在超類的定義中是能夠作到基於關鍵字的初始化的,對於使用超類來實現子類會變得稍微的簡單些。咱們能夠避免編寫一個額外的__init__()方法到每一個子類,當子類的惟一特性包括了簡單新實例變量。

這樣作的缺點是,咱們已經模糊了沒有正式經過子類定義記錄的實例變量。若是隻是一個小變量,整個子類可能有太多的編程開銷用於給一個類添加單個變量。然而,一個小變量經常會致使第二個、第三個。不久,咱們將會認識到一個子類會比一個極其靈活的超類還要更智能。

咱們能夠(也應該)經過混合的位置和關鍵字實現生成這些,以下面的代碼片斷所示:

class Player3(Player):
    def __init__(self, table, bet_strategy, game_strategy, **extras):
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table= table
        self.__dict__.update(extras)

這比徹底開放定義更明智。咱們已經取得了所需的位置參數。咱們留下任何非必需參數做爲關鍵字。這個闡明瞭__init__()給出的任何額外的關鍵字參數的使用。

這種靈活的關鍵字初始化取決於咱們是否有相對透明的類定義。這種開放的態度面對改變須要注意避免調試名稱衝突,由於關鍵字參數名是開放式的。

1. 初始化類型驗證

類型驗證不多是一個合理的要求。在某種程度上,是沒有對Python徹底理解。名義目標是驗證全部參數是不是一個合適的類型。試圖這樣作的緣由主要是由於適當的定義每每是過於狹隘以致於沒有什麼真正的用途。

這不一樣於確認對象知足其餘條件。數字範圍檢查,例如,防止無限循環的必要。

咱們能夠製造問題去試圖作些什麼,就像下面__init__()方法中那樣:

class ValidPlayer:
    def __init__(self, table, bet_strategy, game_strategy):
        assert isinstance(table, Table)
        assert isinstance(bet_strategy, BettingStrategy)
        assert isinstance(game_strategy, GameStrategy)
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table = table

isinstance()方法檢查、規避Python的標準鴨子類型

咱們寫一個賭場遊戲模擬是爲了嘗試不斷變化的GameStrategy。這些很簡單(僅僅四個方法),幾乎沒有從超類的繼承中獲得任何幫助。咱們能夠獨立的定義缺少總體的超類。

這個示例中所示的初始化錯誤檢查,將迫使咱們經過錯誤檢查的建立子類。沒有可用的代碼是繼承自抽象超類。

最大的一個鴨子類型問題就圍繞數值類型。不一樣的數值類型將工做在不一樣的上下文中。試圖驗證類型的爭論可能會阻止一個完美合理的數值類型正常工做。當嘗試驗證時,咱們有如下兩個選擇在Python中:

  • 咱們編寫驗證,這樣一個相對狹窄的集合類型是容許的,總有一天代碼會由於聰明的新類型被禁止而中斷。

  • 咱們避開驗證,這樣一個相對普遍的集合類型是容許的,總有一天代碼會由於不聰明地類型被使用而中斷。

注意,兩個本質上是相同的。代碼可能有一天被中斷。要麼由於禁止使用即便它是聰明,要麼由於不聰明的使用。

讓它

通常來講,更好的Python風格就是簡單地容許使用任何類型的數據。

咱們將在第4章《一致設計的基本知識》回到這個問題。

這個問題是:爲何限制將來潛在的用例?

一般回答是,沒有理由限制將來潛在的用例。

比起阻止一個聰明的,但多是意料以外的用例,咱們能夠提供文檔、測試和調試日誌幫助其餘程序員理解任何能夠處理的限制類型。咱們必須提供文檔、日誌和測試用例,這樣額外的工做開銷最小。

下面是一個示例文檔字符串,它提供了對類的預期:

class Player:
    def __init__(self, table, bet_strategy, game_strategy):
        """Creates a new player associated with a table,
            and configured with proper betting and play strategies
            :param table: an instance of :class:`Table`
            :param bet_strategy: an instance of :class:`BettingStrategy`
            :param  game_strategy: an instance of :class:`GameStrategy`
        """
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table = table

程序員使用這個類已經被警告了限制類型是什麼。其餘類型的使用是被容許的。若是類型不符合預期,執行會中斷。理想狀況下,咱們將使用unittestdoctest來發現bug。

2. 初始化、封裝和私有

通常Python關於私有的政策能夠總結以下:咱們都是成年人了。

面向對象的設計有顯式接口和實現之間的區別。這是封裝的結果。類封裝了數據結構、算法、一個外部接口或者一些有意義的事情。這個想法是從實現細節封裝分離基於類的接口。

可是,沒有編程語言反映了每個設計細節。Python中,一般狀況下,並無考慮都用顯式代碼實現全部設計。

類的設計,一方面是沒有徹底在代碼中有私有(實現)和公有(接口)方法或屬性對象的區別。私有的概念主要來自(c++或Java)語言,這已經很複雜了。這些語言設置包括如私有、保護、和公有以及「未指定」,這是一種半專用的。私有關鍵字的使用不當,一般使得子類定義產生沒必要要的困難。

Python私有的概念很簡單,以下

  • 本質上都是公有的。源代碼是可用的。咱們都是成年人。沒有什麼能夠真正隱藏的。

  • 通常來講,咱們會把一些名字的方式公開。他們廣泛實現細節,若有變動,恕不另行通知,可是沒有正式的私有的概念。

在部分Python中,命名以_開頭的通常是非公有的。help()函數一般忽略了這些方法。Sphinx等工具能夠從文檔隱藏這些名字。

Python的內部命名是以__開始(結束)的。這就是Python保持內部不與應用程序的命名起衝突。這些內部的集合名稱徹底是由語言內部參考定義的。此外,在咱們的代碼中嘗試使用__試圖建立「超級私人」屬性或方法是沒有任何好處的。一旦Python的發行版本開始使用咱們選擇內部使用的命名,會形成潛在的問題。一樣,咱們使用這些命名極可能與內部命名發生衝突。

Python的命名規則以下:

  • 大多數命名是公有的。

  • _開頭的都是非公有的。使用它們來實現細節是真正可能發生變化的。

  • __開頭或結尾的命名是Python內部的。咱們不能這樣命名;咱們使用語言參考定義的名稱。

通常狀況下,Python方法使用文檔和好的命名來表達一個方法(或屬性)的意圖。一般,接口方法會有複雜的文檔,可能包括doctest的示例,而實現方法將有更多的簡寫文檔,極可能沒有doctest示例。

新手Python程序員,有時奇怪私有沒有獲得更普遍的使用。而經驗豐富的Python程序員,卻驚訝於爲了整理並不實用的私有和公有聲明去消耗大腦的卡路里,由於從方法的命名和文檔中就能知道變量名的意圖。

總結

在本章中,咱們回顧了__init__()方法的各類設計方案。在下一章,咱們將看一看特別的以及一些高級的方法。

相關文章
相關標籤/搜索