[译] 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__()方法的各类设计方案。在下一章,咱们将看一看特别的以及一些高级的方法。

相关文章
相关标签/搜索