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

本节书摘来自异步社区《Python面向对象编程指南》一书中的第1章,第1.5节,作者[美]Steven F. Lott, 张心韬 兰亮 译,更多章节内容可以访问云栖社区“异步社区”公众号查看。

1.5 通过工厂函数调用__init()__

我们可以使用工厂函数来完成所有Card对象的创建,这比枚举52张牌的方式好很多。在Python中,实现工厂有两种途径。

  • 定义一个函数,返回不同类的对象。
  • 定义一个类,包含了创建对象的方法。这是完整的工厂设计模式,正如设计模式书中提到的。在类似Java这样的语言里,工厂类层次结构是必需的,因为语言本身不支持可以脱离类而单独存在的函数。

在Python里,类定义不是必需的。仅当特别复杂的情形,工厂类才是不错的选择。Python的优势之一是,对于只需要简单地定义一个函数就能做到的事情没必要去定义类层次结构。


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

如果需要,我们总可以将函数重写为合适的可调用对象。进行工厂模式设计时,也可以将可调用对象进一步重构为工厂类的层次结构。我们将在第5章“可调用对象和上下文的使用”中详细介绍可调用对象。

从大体上来看,类定义的优势是:可以通过继承来使得代码可以被更好地重用。工厂类封装了类本身的层次结构以及对象构建的复杂过程。对于已有的工厂类,可以通过添加子类的方式来完成扩展,这样就获得了工厂类的多态设计,不同的工厂类名有相同的方法签名并可以在调用时通过替换对象来改变具体实现。

这种类级别的多态机制对于类似Java和C++这样的编译型语言来说是非常有用的,可以在编译器在生成目标代码时决定类和方法的实现细节。

如果可替代的工厂类并没有重用任何代码,那么类层次结构在Python中并没有多大作用,完全可以使用函数来替代。

以下是用来生成Card子类对象的一个工厂函数的例子。

def card( rank, suit ):
  if rank == 1: return AceCard( 'A', suit )
  elif 2 <= rank < 11: return NumberCard( str(rank), suit )
  elif 11 <= rank < 14:
    name = { 11: 'J', 12: 'Q', 13: 'K' }[rank]
    return FaceCard( name, suit )
  else:
    raise Exception( "Rank out of range" )

这个函数通过传入牌面值rank和花色值suit来创建Card对象。这样一来,创建对象的工作更简便了。我们已经把创造对象的过程封装在了单独的工厂函数内,外界无需了解对象层次结构以及多态的工作细节就可以通过调用工厂函数来创建对象。

如下代码演示了如何使用工厂函数来构造deck对象。

deck = [card(rank, suit)
  for rank in range(1,14)
    for suit in (Club, Diamond, Heart, Spade)]

这段代码枚举了所有牌面值和花色的牌,完成了52张牌对象的创建。

1.5.1 错误的工厂设计和模糊的else语句

这里需要注意card()函数里的if语句。并没有使用一个catch-all else语句做一些其他步骤,而只是单纯地抛出了一个异常。像这样的catch-all else语句的使用方式是有争议的。

一方面,else语句不能不做任何事情,因为这将隐藏微小的设计错误。另一方面,一些else语句的意图已经很明显了。

因此,避免模糊的else语句是非常重要的。

关于这一点,可以参照以下工厂函数的定义。

def card2( rank, suit ):
  if rank == 1: return AceCard( 'A', suit )
  elif 2 <= rank < 11: return NumberCard( str(rank), suit )
  else:
    name = { 11: 'J', 12: 'Q', 13: 'K' }[rank]
    return FaceCard( name, suit )

创建纸牌对象可以通过如下代码实现。

deck2 = [card2(rank, suit) for rank in range(13) for suit in (Club, 
Diamond, Heart, Spade)]

这是最好的方式吗?如果if条件更复杂些呢?

一些程序员可以很快理解这样的if语句,而另一些则会纠结于是否要对if语句的逻辑做进一步划分。

作为高级的Python程序员,我们不应该把else语句的意图留给读者去推断,条件语句的意图应当是非常直接的。


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

1.5.2 使用elif简化设计来获得一致性

工厂方法card()中包括了两个很常见的结构。

  • if-elif序列。
  • 映射。

为了简单化,重构将是更好的选择。

我们总可以使用elif条件语句代替映射。(是的,总可以。反过来却不行;把elif条件转换为映射有时是有风险的。)

以下是没有使用映射Card工厂类的实现。

def card3( rank, suit ):
  if rank == 1: return AceCard( 'A', suit )
  elif 2 <= rank < 11: return NumberCard( str(rank), suit )
  elif rank == 11:
    return FaceCard( 'J', suit )
  elif rank == 12:
    return FaceCard( 'Q', suit )
  elif rank == 13:
    return FaceCard( 'K', suit )
  else:
    raise Exception( "Rank out of range" )

这里重写了card()工厂方法,将映射转换为了elif语句。比起前一个版本,这个函数在实现上获得了更好的一致性。

1.5.3 使用映射和类来简化设计

在一些情形下,可以使用映射而非这样的一个elif条件语句链。如果认为使用一个elif条件语句链是表达逻辑的唯一明智的方式,那么很容易会发现,它看起来很复杂。对于简单的情形,做同样的事情采用映射完成的代码可以更好地工作,而且代码的可读性也更强。

由于类是第1级别的对象,从rank参数映射到对象是很容易的事情。

这个Card工厂类就是使用映射实现的版本。

def card4( rank, suit ):
  class_= {1: AceCard, 11: FaceCard, 12: FaceCard,
    13: FaceCard}.get(rank, NumberCard)
  return class_( rank, suit )

我们把rank映射为对象,然后又把rank值和suit值作为参数传入Card构造函数来创建Card实例。

也可以使用一个defaultdict类,然而比起简单的静态映射其实并没有简化多少。下例就是它的实现。

defaultdict( lambda: NumberCard, {1: AceCard, 11: FaceCard, 12: 
FaceCard, 13: FaceCard} )

defaultdict类的默认构造函数必须是无参的。我们使用了一个lambda构造函数作为常量的封装函数。这个函数有个很明显的缺陷,缺少从1到A和13到K的映射。当试图添加这段代码逻辑时,就遇到了个问题。

我们需要修改映射逻辑,除了提供Card子类,还需要提供rank对象的字符串结果。如何实现这两部分的映射?有4种常见的方案。

  • 可以建立两个并行的映射。此处并不推荐这种做法,后面的章节会说明为什么这样做是不值得的。
  • 可以映射为一个二元组。当然,这个方案也有一些弊端。
  • 可以映射为partial()函数。partial()函数是fun``ctools模块的一个功能。
  • 也可以考虑修改类定义来完成映射逻辑。在下一节里会介绍如何在子类中重写__init()__函数来完成这个方案。

对于每个方案我们会通过具体示例逐一演示。

1.并行映射

以下是此方案代码的基本实现。

class_= {1: AceCard, 11: FaceCard, 12: FaceCard, 13: FaceCard 
}.get(rank, NumberCard)
rank_str= {1:'A', 11:'J', 12:'Q', 13:'K'}.get(rank,str(rank))
return class_( rank_str, suit)

这样是不值得的。这种实现方式带来了映射键1、11、12和13的逻辑重复。重复是糟糕的,因为软件更新后通常会带来对并行结构多余的维护成本。


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

2.映射到一个牌面值的元组

以下代码演示了如何映射到二元组的基本实现。

class_, rank_str= {
  1:  (AceCard,'A'),
  11: (FaceCard,'J'),
  12: (FaceCard,'Q'),
  13: (FaceCard,'K'),
  }.get(rank, (NumberCard, str(rank)))
return class_( rank_str, suit )

这个方案看起来还不错。并没有太多代码来完成特殊情形的处理。接下来我们会看到当需要修改Card类层次结构时:添加一个Card子类时,如何来修改和扩展。

从rank值映射为类对象是很少见的,而且两个参数中只有一个用于对象的初始化。从rank映射到一个相对简单的类或函数对象,而不必提供目的不明确的参数,这才是明智的选择。

3.partial函数设计

除了映射到二元组函数和只提供一个参数来实例化的方案外,我们还可以创建partial()函数。这个函数可以用来实现可选参数。我们会从functools库中使用partial()函数创建一个带有rank参数的部分类。

以下演示了如何建立从rank到partial()函数的映射来完成对象的初始化。

from functools import partial
part_class= {
  1: partial(AceCard,'A'),
  11: partial(FaceCard,'J'),
  12: partial(FaceCard,'Q'),
  13: partial(FaceCard,'K'),
  }.get(rank, partial(NumberCard, str(rank)))
return part_class( suit )

通过调用partial()函数然后赋值给part_class,完成了与rank对象的关联。可以使用同样的方式创建suit对象,并完成最终Card对象的创建。partial()函数的使用在函数式编程中是很常见的,当使用的是函数而非对象方法的时候就可以考虑使用。

大致上,partial()函数在面向对象编程中不是很常用。我们可以简单地提供构造函数的不同版本来做同样的事情。partial()函数和构造对象时的流畅接口很类似。

4.工厂模式的流畅API设计

有时我们定义在类中的方法必须按特定的顺序来调用。这种按顺序调用的方法和创建partial()函数的方式非常类似。

假如有这样的函数调用x.a().b()。对于x(a,b)这个函数,放在partial()函数的实现就可以是先调用x.a()再调用b()函数,这种方式可以理解为x(a)(b)。

这意味着Python在管理状态方面提供了两种选择。我们可以直接更新对象或者对具有状态的对象使用partial()函数。由于两种方式是等价的,因而可以把partial()函数重构为工厂对象创建的流畅接口。我们在流畅接口函数中设置可以反馈self值的rank对象,然后传入花色类从而创建Card实例。

如下是Card工厂流畅接口的定义,包含了两个函数,它们必须按顺序调用。

class CardFactory:
  def rank( self, rank ):
    self.class_, self.rank_str= {
      1:(AceCard,'A'),
      11:(FaceCard,'J'),
      12:(FaceCard,'Q'),
      13:(FaceCard,'K'),
      }.get(rank, (NumberCard, str(rank)))
    return self
  def suit( self, suit ):
    return self.class_( self.rank_str, suit)

先是使用rank()函数更新了构造函数的状态,然后通过suit()函数创造了最终的Card对象。

这个工厂类可以以如下方式来使用。

card8 = CardFactory()
deck8 = [card8.rank(r+1).suit(s) for r in range(13) for s in (Club, 
Diamond, Heart, Spade)]

我们先实例化一个工厂对象,然后再创建Card实例。这种方式并没有利用__init()__在Card类层次结构中的作用,改变的是调用者创建对象的方式。

上一篇:常用JS验证函数总结


下一篇:[LeetCode] Convert BST to Greater Tree 将二叉搜索树BST转为较大树