1.6 继承
我们讨论了对象之间的3种关系:关联、组合与聚合。然而,我们还没完全设计好象棋游戏,并且这几种关系似乎仍不够用。我们讨论的玩家可能是人类,也可能是一段人工智能代码。如果我们说“玩家(Player)和人类是关联关系”,或者说“人工智能实现是玩家对象的组成部分之一”,好像都不大对。我们真正需要描述的是“深蓝机器人是一个玩家”,或者“Gary Kasparov是一个玩家”。
“是一个”这种关系是由继承(Inheritance)产生的。继承是面向对象编程中最有名、最广为人知,也最被过度使用的一种关系。继承有点儿像族谱树。本书作者之一是Dusty Phillips,他的爷爷姓Phillips,而他爸爸继承了这一姓氏,他又从他爸爸那里继承了这一姓氏。与人类继承特征和姓氏不同,在面向对象编程中,一个类可以从另一个类那里继承属性和方法。
例如,在一副国际象棋中有32枚棋子,但只有6种不同的类型(卒、车、象、马、国王和王后),每种类型的棋子在移动时的行为各不相同。所有这些棋子的类有许多共同的属性,如颜色、所属象棋等,但它们同时拥有唯一的形状,以及不同的移动规则。我们来看一下,这6种类型的棋子是如何继承自Piece类的,如图1.10所示。
图1.10 棋子如何继承自Piece类
空心箭头形状代表每种棋子类都继承自Piece类。所有的子类都自动从父类中继承了chess_set和color属性。每种棋子都有不同的shape属性(当渲染棋盘时被绘制在屏幕上)以及不同的move方法,用于移动到新的位置上。
我们知道所有的Piece类的子类都需要有一个move方法,否则当棋盘需要移动一枚棋子时不知道该怎么办。假如我们想要创建一个新版的象棋游戏,那么可以在里面加入一种新的棋子(巫师,Wizard)。如果这个新类没有move方法,棋盘在要移动这种棋子时就会被卡住,而我们现在的设计无法阻止这种事情的发生。
我们可以通过给Piece类创建一个假的move方法来解决这个问题。这个方法可能只会抛出一个错误提示信息:这枚棋子无法被移动,而子类用具体的实现来重写(Override)这个方法。[4]
在子类中重写方法能够让我们开发出非常强大的面向对象系统。例如,我们要实现一个具有人工智能的Player类,假设名为DeepBluePlayer,我们可以在父类的Player类中提供一个calculate_move方法,决定移动哪一枚棋子和移动到什么位置上。父类可能只是随机选择一枚棋子和方向进行移动,我们可以在DeepBluePlayer子类中用更智能的逻辑重写这个方法。前者可能只适合与新手对抗,而后者可以挑战大师级的选手。重要的是,这个类的其他方法(比如通知棋盘选中了哪枚棋子等)完全不用改动,它们的实现可以在两个类中共享。
在Piece的示例中,为move方法提供一个默认的实现并没有什么意义。我们要做的是要求在子类中必须有move方法。要做到这一点,可以将Piece创建为抽象类(Abstract Class),并将move方法声明为抽象方法(Abstract Method)。抽象方法基本上就是说:
“在当前类中不提供方法的具体实现,但我们要求所有非抽象的子类必须实现这一方法。”
实际上,我们可以创建一个不实现任何方法的抽象类。这个类只告诉我们它应该做什么,但是完全不告诉我们要如何去做。在某些编程语言中,这种完全抽象的类也被叫作接口(Interface)。在Python中可以定义只包含抽象方法的类,但这极少见。[5]