本节书摘来自异步社区《Python面向对象编程指南》一书中的第1章,第1.12节,作者[美]Steven F. Lott, 张心韬 兰亮 译,更多章节内容可以访问云栖社区“异步社区”公众号查看。
1.12 更多的__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__()函数。
这种实现方式的弊端在于存在一些变量在子类中没有提供文档说明。当仅需要添加一个变量时,可能需要改变整个类层次结构。第1个变量添加之后往往还会需要第2个和第3个。在设计的开始,我们应当考虑设计一些灵活的子类,而不是完美的基类。
我们可以(而且应该)像下面代码段那样同时使用位置变量和关键字变量。
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 )
这种方式看起来比完全开放的定义更明智。我们把必需的参数设为位置参数,把可选参数通过关键字参数传入。这也演示了如何通过 extra关键字参数把可选参数传入__init__()函数。
这样的灵活性,基于关键字的初始化依赖于我们是否已经定义了相对透明的类。这种实现需要特别关注一下命名,因为关键字参数名是开放式的,要避免调试过程中发生命名冲突。
1.12.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()函数检查了每个类型的合法性。
我们编写了玩牌游戏模拟器并通过不断地改变GameStrategy类来进行实验。由于它们都很简单(只有4个函数),继承的好处不够凸显,我们可以单独定义每个子类而不再定义基类。
正如本例中所演示的,我们将不得不创建子类,目的只是为了通过初始化过程的错误检查,而未能从抽象基类继承到任何可用的代码。
其中一个最大的鸭子类型问题是关于数值类型的,不同的数值类型会在不同的上下文工作。试图验证参数类型也许会导致原本工作很好的一个数值类型不再工作。当试图验证时,在Python中我们有以下两种选择。
- 为不是很广泛的集合类型加验证。一旦代码不工作了我们将会知道本该允许使用的类型被禁止了。
- 针对相对广泛的集合类型,通常不考虑加验证,一旦代码不工作了我们将会知道我们使用了一个不允许使用的类型。
这两点基本表达了相同的意思。代码某一天可能会无效,要么是因为一个本该允许的类型被禁止了,要么是使用了被禁止的类型。
面临这样一个问题:为什么要限制未来潜在的使用场景?
而通常没有一个合理的理由来说明这一点。
为了不为以后的应用场景带来阻碍,可以考虑提供文档、测试和调试日志来帮助其他程序员理解哪些类型限制是可以被处理的。为了使工作量最小化,无论如何我们都必须提供文档、日志和测试用例。
以下是一段示例文档,用于说明类所需的参数。
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
当使用这个类时,就会从文档得知类的参数需求。可以传入任何类型。如果类型和期望的类型不兼容,那么代码将会不工作。理想情况下,我们会使用文档测试(doctest)和单元测试(unittest)来发现这些异常的场景。
1.12.2 初始化、封装和私有化
关于Python中的私有化可以概括为:大家都是成年人。
面向对象设计使得接口和实现有了很大的差别,这也是封装的意义。一个类封装了一种数据结构、一个算法和一个外部接口等,程序设计的目的是要把接口与实现分离。
然而,没有编程语言会暴露出所有设计的细节。对于Python,也是如此。
关于类设计的一个方面,这一点没有用代码演示:对象中有关私有(实现)和公有(接口)函数或属性的差异。有些编程语言只是在概念上支持私有(C++或Java是两个例子)已经很复杂了。这类语言中的访问修饰符包括了私有、保护、公有和“未指定”,可以理解为半私有。私有关键字经常被错误使用,为子类的定义带来了没必要的复杂性。
Python中私有的概念很简单,如下所示。
- 基本都是公有。源代码随时可修改,大家都是成年人,没有什么是可以真正被隐藏的。
- 传统上,我们会使用命名来表明哪些不是完全公有的。它们通常是容易变化的具体实现细节,然而并不存在正式的、概念上的私有。
Python中的部分函数以_命名,标记为不完全公有。help()函数通常会忽略这类函数。可以使用像Sphinx这样的工具从文档中查找出它们的命名。
Python的内部命名以__起始(和结尾)。这也是Python如何避免内部和外部应用程序发生冲突的方式。这些内部集合的命名方式完全只是参考。毕竟,没有必要在代码中试图使用__前缀来定义一个“超级私有”的属性或函数。如果这样做的话就为以后制造了一个潜在的麻烦,当新版本的Python发布并使用了同样命名的函数或属性时,就会有命名冲突。我们还有可能和新版本中的其他名称发生冲突。
Python中关于可见度的命名规则如下所示。
- 大部分名称是公有的。
- 以_开始的名字通常不完全公有。使用它们来命名那些经常变化的函数,这些函数通常是实现细节。
- 以__作为前缀和后缀的函数通常是Python内部的。程序中不该使用;命名要参考编程语言的定义。
通常,Python中的命名是根据函数(或属性)的目的来定义的,并提供文档说明。通常接口函数会有说明文档以及文档测试的例子,而实现细节的函数就不必了,提供简单的说明就可以了。
对于刚接触Python的程序员,有时会对私有化不是很常用而感到惊讶。可对于已经熟悉Python的程序员也会同样惊讶于,为了不必要的私有和公有定义的顺序而浪费很多脑细胞。因为函数名和文档已经把意图描述的很明白了。