多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
[TOC] 在软件开发中,设计常被认为是要在编程之前完成的步骤。实际上,分析、编程、设计常常是重叠的、组合的或是交织在一起的。这章,我们将讨论以下问题: * 面向对象意味着什么? * 面向对象设计和面向对象编程之间的差异是什么? * 面向对象设计的基本原则 * 统一模型设计语言(UML),如果它还没那么邪恶的话 ## 什么是面向对象 正如大家所知道的,对象是一个我们能感觉和操纵的有形的东西。我们最早接触的对象通常是婴儿玩具。木块、塑料形状和拼图块是最常见的对象。婴儿很快就知道某些对象会做某些事情:钟声响起,按钮被按下,拉杆被拉动。 </b> 软件开发中对象的定义并没有太大的不同。软件对象通常不是你能拿得起、感觉得到的有形的东西,但是它们是可以做某些事情和在它们之上做某些事情的模型。形式上,对象是数据和相关行为的集合。 </b> 知道一个对象是什么后,那面向对象又意味着什么?面向简单来说就是*指向*。所以面向对象意味着功能上指向建模对象。这是用于复杂系统建模的众多技术之一,通过描述交互对象的数据和行为的集合。 </b> 如果你读过任何宣传,你可能会遇到面向对象的术语,面向对象分析、面向对象设计、面向对象分析和设计以及面向对象编程。这些都是一般情况下高度相关的面向对象的概念。 </b> 事实上,分析、设计和编程都是软件开发的阶段。称之为面向对象只是意味着我们将追求某种软件开发的风格。 </b> **面向对象分析(OOA)**,是一个观察问题、系统或任务(某人想将之变成应用程序),辨识对象及对象之间交互的过程。分析阶段决定要做什么。 </b> 分析阶段的输出是一组需求。如果我们在一个步骤中完成分析阶段,我们将会得到一项任务,比如,我需要将一个网站变成一组需求。例如:网站访问者应该可以(*斜体*表示动作、**黑体**表示对象): * *回顾*我们的**历史** * *申请***工作** * *浏览*、*比较*、*订购***产品** 在某些方面,分析是一个误称。婴儿在玩拼图游戏时没有分析块和如何拼图。相反,婴儿探索周边的环境,操纵形状,看看它们放在哪里比较好。更好的说法可能是面向对象的探索。在软件开发中,初始阶段分析包括采访客户,研究他们的流程,以及消除各种不确定性。 </b> **对象导向设计(OOD)**,是将一系列的需求转化为实现规格。设计人员必须命名对象,指定行为,并正式指定哪些对象可以在其他对象之上做些什么。设计阶段是关于如何做事情的。 </b> 设计阶段的输出是一个实现规格书。我们将把OOA中获得的一系列需求转换为一组类和接口,这些类和接口可以在(理想情况下)用任何面向对象的编程语言实现。 </b> **对象导向编程(OOP)**,这个过程将设计转换为一个工作流程。如果世界很理想,我们就可以沿着理想之路,一个接一个的,以完美的顺序,像所有旧教科书告诉我们那样完成一项工作。 </b> 然而,世界像往常一样,它其实更加黑暗。不管我们多么努力地分开这些阶段,在设计时,我们总是发现有需要进一步分析的东西。当我们正在编程时,我们总是发现设计过程中没有澄清的特性。 </b> 大多数二十一世纪的软件开发都采用迭代开发模式。在迭代开发中,对任务的一小部分进行建模、设计,然后对程序进行评审和扩展,以改进每一个特征,同时在一系列较短开发周期中加入新特征。 </b> 本书的其余部分是关于面向对象编程的,但是在本章中,我们将在设计环境中介绍基本的面向对象原则。这使我们能够理解这些(相当简单的)概念而不必争论使用什么样的语法或python解释器。 </b> ## 对象和类 一个对象是一组具有相关行为的数据集合。我们如何区分对象类型呢?苹果和桔子虽然都是对象,但两者不可比较。苹果和桔子并不经常出现在编程语言中。假设我们正在为水果农场设计仓储管理。为了简化这个例子,我们可以假设将苹果放入桶中,桔子放在篮子里。 </b> 现在,我们有四种物品:苹果、桔子、篮子和桶。在面向对象建模里面,用于表达*对象类型*的术语是类。因此,用技术术语,我们现在有四种对象的类。 </b> 一个对象和一个类有什么区别?类描述对象。它们就像创建对象的蓝图。例如有三个桔子在你前面的桌子上,每个桔子都是不同的对象,但这三个桔子都有与一个类相关的属性和行为:桔子的一般类。 </b> 我们库存系统中四种对象类之间的关系,可以使用统一建模语言(通常称为UML,因为三个字母的缩写词永远不会过时)类图来描述。这是我们的第一张类图: ![](https://box.kancloud.cn/1f58ee365a25c28228a327f4e97b481b_263x163.png) 这张图显示了**桔子**与**篮子**有某种关联,**苹果**在与**桶**有某种关联。关联是最基本的类之间的关系。 </b> UML在管理人员中很受欢迎,偶尔被程序员贬损。UML图的语法一般很明显,不必读教程。UML很容易画,很直观。毕竟,很多人在描述类之间的关系,自然会画出有线条的方框。基于这些直观图的标准使程序员很容易与设计师、经理和其他人沟通。 </b> 然而,一些程序员认为UML是在浪费时间。开发过程是迭代的,图在实现之前是多余的,而且维护这些正式的图只会浪费时间,对任何人都没有好处。 </b> 对于不同的公司结构,这可能是真的,也可能不是真的。然而,每个由不止一个人组成的编程团队偶尔还是要坐下来讨论当前正在开发的子系统的细节。UML是在这些头脑风暴会议中非常有用,以便快速和轻松地沟通。甚至那些嘲笑正式类图的组织也倾向于在设计会议或团队讨论中使用一些非正式版本的UML。 </b> 此外,你不得不与之沟通的最重要的人,恐怕是你自己。我们都认为我们能记住我们所做的设计决策,但到最后总是变成*为什么我要这样做*?,这种遗忘一直隐藏在我们的未来之中。如果我们在开始设计时,就做一份初始的设计图,我们最终会发现这将是一份有用的参考资料。 </b> 然而,本章并不打算成为UML中的教程。很多资料可以在互联网上获得,还有许多有关该主题的书籍。UML所涵盖的不仅仅是类和对象图;它还具有用于用例、部署、状态更改和活动的语法。我们将在面向对象设计用到一些常用的类图语法。你会发现你可以通过例子来了解这些结构,然后下意识地选择在自己的团队或个人设计过程中使用有启发作用的UML语法。 </b> 我们最初的设计图虽然正确,但并不能提醒我们苹果是装在桶里的,或是一个苹果可以放在多少个桶里。它只告诉我们苹果与桶有关。类与类之间的联系往往是显而易见的,并不需要额外的解释,但我们可以根据需要增加进一步的解释。 </b> UML的优点是大多数东西都是可选的。我们只需要设计图中的信息对于当前情况有意义就好了。在白板上快速地把框之间用线连接起来。等到正式的文档,我们可以再更详细地讨论。对于苹果和桶,我们可以有信心的判断,**许多苹果可以放在一个桶里**,**一个苹果只能放在一个桶里**,所以我们可以强化我们的类图: ![](https://box.kancloud.cn/e0111eb7774e44a1f82fb7cc95ba1f5c_266x206.png) 这张图告诉我们,桔子**放在**篮子里,上面有一个小箭头,表示什么对象在什么对象里面。它还告诉我们可以在关联的两边使用对象数量。一个**篮子**可以装许多(用*表示)**桔子**。任何一个**桔子**只能放在一个**篮子**。这个被称为对象的多重性。你还可以听到它被描述为基数。这些是实际上有些不同的术语。基数是指集合,而多重性则表示这个数字的大小。 </b> 我经常忘记多重性是在关系的哪一边发生的。最接近类的那个数实际指示的是类之间关系中另一端类所需要的数量。苹果和桶的例子,从左到右阅读,**苹果**类的许多实例(即许多苹果对象)可以放在任何一个**桶**中。从右到右阅读,只有一个**桶**可以与任何一个**苹果**相关联。 ## 定义属性和行为 现在我们掌握了一些面向对象的基本术语。对象是可以相互关联的类的实例。对象实例是具有自己的数据和行为集的特殊对象,桌子上的一个特定的桔子可以说是一个普通桔子类的实例。这很简单,但是与每个对象相关联的数据和行为是什么呢? ### 数据描述了对象 让我们从数据开始。数据通常代表特定对象的个体特征。一个类可以定义被所有对象共享的特定特征集。对于给定的特征,任何特定对象都可以具有不同的数据值。例如,我们桌上的三个桔子(如果我们还没有吃掉的话),每个的重量可能都不同,那么重量可以作为桔子类的一个属性。桔子类的所有实例都有一个重量属性,但每个桔子重量属性的值可能都不同。属性值不必是唯一的,任何两个桔子的重量可能相同。作为一个更现实的例子是,两个表示不同客户的姓属性可能具有相同的值。 </b> 属性通常被称为**成员**或**特性**。一些作者建议这些术语具有不同的含义,通常属性是可设置的,而特性是只读的。在Python中,“只读”的概念毫无意义,所以在这本书中,我们将看到这两个术语可以互换使用。另外,正如我们将在第5章“何时使用面向对象编程”中讨论的,特性关键字在python中对于特定类型的属性具有特殊的含义。 ![](https://box.kancloud.cn/2bcc60967df5256f9f0183b642ee6342_384x363.png) </b> 在我们的水果仓储应用中,果农可能想知道桔子是从哪个果园来的,采摘的时间,以及它的重量。他们可能还想跟踪每个篮子的存放位置。苹果可能有颜色属性,桶可能有不同的大小。这些属性中的一些也可能属于多个类(我们也可能想知道什么时候摘得苹果),但是对于这个第一个例子,我们只在类图中添加几个不同的属性。 ![](https://box.kancloud.cn/51c571b12eed1f4763aae06cd7455b45_523x390.png) 根据我们的设计需要的详细程度,我们还可以为每个属性指定类型。对于大多数编程语言,属性类型通常指主类型,如整数、浮点数、字符串、字节或布尔函数。但是,属性类型也可以是数据结构,如列表、树或图,或者更常用的其他类。这些可以在设计阶段或在编程阶段定义。一种编程语言的基本类型或对象在其他语言中多少会有一些不一样。 </b> 通常,我们不需要在设计阶段太关注数据类型,而是在编程阶段选择具体的实现方法。对于设计阶段,起个通用的名字就足够了。如果我们的设计需要列表容器类型,Java程序员可以选择`LinkedList`或`ArrayList`,而Python程序员(就是我们!)可以选择内建的`List`或`Tuple`。 </b> 到目前为止,在我们的水果农场例子中,我们的属性都是基本的主类型。然而,有一些隐式属性可以使关联显式化。对于给定的桔子,我们可能需要一个装这个桔子的篮子属性。 ### 行为即动作 现在,我们知道了什么是数据,那什么是行为呢?行为是指可能发生在对象上的动作。可以在特定对象所属的类上执行的行为称为**方法**。就编程而言,方法类似于结构化编程中的函数,但它们可以神奇地访问所有与这个对象关联的数据。和函数一样,方法也可以接受**参数**和**返回值**。 </b> 方法参数就是一个**传入**到被调用方法的一个对象列表。(对象列表更官方一点的说法是**实参**)。这些参数被方法使用,以执行任何需要完成的行为或任务。返回值是执行完任务的结果。 </b> 我们已经将“比较苹果和桔子”的例子扩展到一个基本的(如果牵强的话)库存应用程序中。我们再把它扩展一点,看看它是否完蛋。 </b> 一个可以与桔子有关的动作是**pick**动作。如果你考虑实现时,**pick**通过更新一个桔子的篮子属性将桔子放到一个篮子里。与此同时,将这个桔子添加到**篮子**的**桔子**列表中。所以,**pick**需要知道它处理的是哪一个篮子。我们通过为**pick**方法提供一个**篮子**参数做到这一点。因为我们的果农也卖果汁,我们可以在**桔子**上再加一个**squeeze**方法。当执行`squeeze`方法时,可能会返回一定数量的果汁,同时也把榨过汁的**桔子**从**篮子**里移走。 </b> **篮子**可以有一个**卖出**动作。当一个篮子售出时,我们的库存系统可能会更新一些尚未指定对象的数据,用来会计和利润计算。或者,我们的一篮子橙子可能在我们出售之前就坏了。所以我们添加了一个**废弃**方法。让我们将这些方法添加到类图中: ![](https://box.kancloud.cn/db9db91fba216537a2bb3229d1d9b493_752x269.png) 向一组独立的对象添加模型和方法,我们可以创建一个交互式对象**系统**。系统中的每个对象都是某个类的成员。这些类定义了对象可以使用的数据类型和对象可以调用的方法。每个对象中的数据可以与在同一类中其他对象的数据处于不同的状态,因为状态差异,每个对象对方法调用的反应可能不同。 </b> 面向对象的分析和设计都是为了弄清这些对象是什么。以及他们应该如何互动。下一节描述了可以使用的原则使这些交互尽可能简单直观。 ## 隐藏细节、创建公共接口 OOD中给对象建模的关键是确定该对象的公共**接口**长什么样。接口是其他对象可以用来与该对象交互的属性和方法的集合。它们不需要,通常也不被允许访问对象的内部工作。一个现实世界中常见的例子是电视。我们的电视接口是一个遥控器。遥控器上的每个按钮表示一个可以对电视对象调用的方法。当我们调用对象、访问这些方法时,我们不关心电视是否从天线、电缆,或者卫星天线接收信号。我们不在乎发送什么电子信号来调整音量,或声音是否发送到扬声器或耳机。如果我们拆开电视机探究内部工作是如何进行的,例如,如何将输出信号分配到外置扬声器或是耳机,我们都将违法保修政策。 </b> 隐藏对象实现或功能细节的过程被称为**信息隐藏**。有时也称为**封装**,但是封装实际上是一个更全面的术语。被封装的数据也不一定要隐藏。从字面上来说,封装就是创造一个时间胶囊。如果你把一堆信息放进一个时间胶囊里,把它锁起来埋起来,它就被封装起来,信息被隐藏起来。另一方面,如果时间胶囊没有被掩埋,并且被解锁或包裹透明的塑料,里面的东西仍然是密封的,但信息没有被隐藏。 </b> 封装和信息隐藏的区别很大程度上是不相关的,特别是在设计层面。许多参考文献认为这些术语可互换。作为Python程序员,我们实际上没有或不需要信息隐藏,(我们将在第2章“Python中的对象”讨论这个细节),因此寻求一个更为包容的封装定义是合适的。 </b> 然而,公共接口非常重要。它需要精心设计,因为将来很难改变它。更改接口将破坏任何调用它的客户端对象。例如,我们可以根据自己的喜好更改内部结构,使其更有效,或通过网络和本地访问数据,客户端对象则不需要变更代码,仍然能够使用公共接口与内部机构进行对话。另一方面,如果我们通过更改公共接口中用于公开访问的属性名称,或更改能被方法接受的实参类型或顺序,那么客户端对象也必须跟着修改。对于公共接口,尽可能保持简单。永远记住,设计一个对象接口,应该基于怎样简单使用对象的基础上,没事把代码弄得很复杂,那是扯淡。(这个建议也适用于用户界面)。 </b> 记住,程序对象可以代表真实的对象,但不是把它们变成真实的对象。它们是真实对象的模型。模型最伟大的天赋之一是能够忽略不相关的细节。我小时候造的模型车看起来很像真正的雷鸟1956,但它跑不了,传动轴也转不起来。在我学会开车之前,这些细节过于复杂和无关紧要。模型是一个真实概念的**抽象**。 </b> **抽象**是另外一个与封装和信息隐藏有关的面向对象概念。简单地说,抽象意味着,我们处理细节的层次,刚好适合给定的任务。这是一个从内部细节提取公共接口的过程。司机需要与方向盘、油门踏板和刹车踏板相互作用。但电机、传动系和制动子系统的工作对司机毫无意义。另一方面,机修工则在另外一个不同的抽象层级上工作,调教引擎和制动真空度。下面是一个有两个不同抽象层级的汽车例子: ![](https://box.kancloud.cn/6f83c5312b3f65d58b79d3366600dd1c_508x408.png) 现在,我们有几个新术语,它们有类似的概念。简单说就几句话:抽象是,使用可分离的公共接口和私有接口,将信息封装的过程。私有接口可以实现信息隐藏。 </b> 说这么多,我们得让模型变得容易理解,毕竟我们得考虑那些与之交互的对象的想法。这意味着我们得注意一些小细节。确保方法和属性具有合理的名称。我们做系统分析时,对象通常表示原始问题中的名词,而方法通常是动词。属性通常可以作为形容词使用,尽管如果属性引用的是另一个属于当前对象的对象,它可能还是个名词。相应地命名类、属性和方法。 </b> 不要试图给所有将来可能有用的对象或动作建模。去给那些系统需要执行的任务建模,让设计自然而然地落入一个适当的抽象级别。这不是说我们不应该考虑未来可能的设计调整。我们的设计应该是开放的,以满足未来的需求。然而,当抽象接口的时候,尝试给需要建模的内容建模就好,而不是更多。 </b> 在设计界面时,试着把自己放在对象的鞋子里,想象一下对象对隐私有强烈的偏好。不允许其他对象访问对象数据,除非你觉得其他对象拥有这些数据符合你自身的利益。不要给这些对象一个接口,来强制你执行特定的任务,除非你确定你希望他们能这样对你。 ## 组合 到目前为止,我们学会了将系统设计成一组相互作用的对象,其中每次交互包括在适当的抽象级别上查看对象。但是我们还不知道如何创建这些抽象级别。有很多种方法可以做到这一点,我们将在第8章“字符串和序列化”、第9章“迭代器模式”讨论高级设计模式。但是大多数设计模式都依赖**组合**和**继承**这两个基本的面向对象原则。组合比较简单,让我们从它开始。 </b> 组合是将几个对象集合在一起以创建一个新对象的协议。当一个对象是另一个对象的一部分时,组合通常是一个不错的选择。我们已经在机械示例中看到了组合的第一个例子。汽车是由发动机、变速器、起动机、前照灯和挡风玻璃组成,除此之外还有许多其他零件。发动机则由活塞、曲轴和气门等组成。在本例中,组合是提供抽象级别的一种好方法。汽车对象可以提供驾驶员所需的接口,同时对于一个机修工来说,汽车对象提供了一个更深的抽象层,机修工可以访问它的组件部分。当然,如果机修工需要更多信息来诊断故障或调整发动机,那么他需要一个更深的抽象层。 </b> 这是一个常见的组合介绍性示例,但当涉及到设计计算机系统时,就不太有用了。物理对象很容易分解成组件对象。至少从古希腊人开始,人们就这样做了,人们最初假设原子是物质的最小单位(当然,那时没有粒子加速器)。计算机系统一般都没有物理对象那么复杂,但从系统中识别出组件对象也不是件很容易的事情。 </b> 面向对象系统中的对象只是偶尔代表物理对象,例如人、书或电话。然而,更常见的是,它们代表抽象的想法。人们有名字,书有书名,电话用来打电话。打电话、书名、账户、姓名、任命和付款,通常不被看作是物理世界中的对象,但它们在计算机系统是最常见的建模的组件。 </b> 让我们尝试建模一个实际的更面向计算机的示例。我们将研究一个计算机化的国际象棋游戏的设计。这是一个在80年代和90年代,学术界非常流行的消遣方式。人们预测,有一天,计算机将能够打败人类的象棋大师。这发生在1997年(IBM的深蓝击败了世界象棋冠军加里卡斯帕罗夫),对这个问题的兴趣随后就减弱了,尽管仍然有许多计算机和人类之间进行的象棋比赛。(计算机通常获胜) </b> 两个玩家之间在进行一场象棋游戏。这是使用一个棋盘的国际象棋比赛,该棋盘在8 x 8格中包含64个位置。棋盘上有两组棋子,各16个,两个玩家可以选择不同的方式交替移动棋子。每一个棋子都可以吃掉另外一个棋子。每次移动棋子后,棋盘都需要在电脑屏幕上绘制更新后的局面。 </b> 我用*斜体*描述识别一些可能的对象,使用**粗体**表示关键方法。这是由OOA到设计常见的第一步。在这一点上,为了强调组合,我们将重点放在棋盘上,不太关心玩家或不同类型的棋子。 </b> 让我们从可能的最高抽象级别开始。我们有两个队员,轮流移动棋子: ![](https://box.kancloud.cn/8b8d2a3077185cba1cb3d072234ea1ff_521x212.png) 这是什么?它不像我们以前的类图。那是因为它不是类图!这是一个对象图,也称为实例图。它描述处于特定状态的系统,并描述特定实例对象,而不是类之间的交互。记住,两个玩家都是在同一个类中的成员,类图看起来有点不同: ![](https://box.kancloud.cn/0ffcc535a419df5961fd3d9fb9ce9e4c_293x100.png) 图中显示两个玩家可以与一个棋盘交互。它也表示任何一个棋手每次只能用同一副棋子。 </b> 但是,我们讨论的是组合,而不是UML,所以让我们考虑一下**象棋集合**是由什么组成的。我们现在不在乎棋手是由什么组成的。我们可以假设棋手有心脏和大脑,以及其他器官,但是这些与我们的模型无关。事实上,没有什么能阻止深蓝这位棋手,这位棋手既没有心脏也没有大脑。 </b> 国际象棋是由一块棋盘和32块棋子组成的。棋盘进一步包括64个位置。你可能会争论棋子不是国际象棋的一部分,因为你可以用另外一组棋子来代替国际象棋中的棋子。虽然这在计算机化的国际象棋中不太可能发生,但它把我们引向另外一个概念——**聚合**。 </b> 聚合几乎与组合完全相同。区别在于,总的来说聚合对象可以独立存在。一个位置不可能属于不同的棋盘,或独立存在,它只能和其他位置组成棋盘之后,才有意义。所以我们说棋盘是由位置组合而成的。但是棋子可以独立于国际象棋,棋子与国际象棋的关系是聚合。 </b> 区分聚合和组合的另一种方法是考虑对象的寿命。如果组合(外部)对象控制(内部)对象被创建和销毁,组合是最合适的。如果相关对象是独立于组合对象创建的,或者可以比该对象命长,聚合关系更有意义。另外,请记住组合是聚合;聚合只是一种更一般的组合形式。任何组合关系也是一种聚合关系,但反过来并不成立。 </b> 让我们描述一下当前的国际象棋组合,并添加一些属性到对象,用于形成组合关系: ![](https://box.kancloud.cn/9c2b22a51a18d6eac57bde771914c8d4_795x278.png) 组合关系用UML表示为一个实心菱形。空心菱形表示聚合关系。你会注意到棋盘和棋子被存储为国际象棋集合的独立部分,并以相同的方法指向同一个参考,同时作为国际象棋集合的两个属性被存储起来。这表明,在实践中,一旦你通过设计阶段,组合和聚合的区别是可以忽略不计的。但是,当你的团队讨论时,它可以帮助区分这两者不同的对象交互方式。通常,你可以把它们当作一回事来对待,当你需要区分它们时,知道它们之间的区别还是挺重要的事情。(这是工作中的抽象)。 ## 继承 我们讨论了对象之间的三种类型的关系:关联、组合、和聚合。然而,我们还没有完全定义我们的国际象棋,这些工具似乎没有给我们所需要的一切力量。我们讨论了一个棋手可能是人类,也可能是一个具有人工智能的软件。所以,不能说一个棋手和人类一定有*关联*,或者说一个人工智能是棋手对象中的*一部分*。我们真正需要的关系是,能够说“深蓝*是一个*棋手”或“加里卡斯帕罗夫*是一个*棋手”。 </b> 这种*是一个*关系就是**继承**。继承太出名了,是面向对象编程中已知的和过度使用的关系。继承有点像家谱。我祖父姓菲利普斯,我父亲继承了这个姓氏。接下来是我从他那里继承这个姓氏(还有蓝眼睛和写作的嗜好)。 </b> 在面向对象编程中,和人类继承特征和行为类似,一个类可以从另一个类继承属性和方法。 </b> 例如,我们的象棋集中有32个棋子,但只有6种棋子类型(兵、车、主教、骑士、国王和王后),每种棋子移动时的行为不同。所有这些类型的类都具有一些共有的属性,例如颜色和它们都是象棋的一部分,但它们也有独特的属性,例如形状、在棋盘上做出不同的移动。让我们看看这六个角色类型是如何从棋类继承的: ![](https://box.kancloud.cn/27ed83f13d4cc0c54033e45b878fb075_727x335.png) 箭头暗示这些个角色类继承自**棋**类。所有角色类型都自动从基类继承了**国际象棋**属性和**颜色**属性。每个角色提供不同的形状属性(将在在渲染棋盘时显示,译注:你得告诉计算机,你的棋子都长什么样子,这样计算机才知道它得在棋盘上画些什么,这个就叫渲染),还得为每个棋子指定不同的**移动**方法。 </b> 我们实际上知道棋类的所有子类都需要一个移动方法。否则,当我们试图移动棋子时,这会变得很混乱。假如我们想创造一个新版本的国际象棋游戏,并额外新加了一种棋子(巫师)。我们当前的设计允许我们不给这个“巫师”定义**移动**方法。当我们要求移动“巫师”的时候,“巫师”就不知所措了。 </b> 我们可以通过在**棋**类上创建一个“傻瓜”移动方法来解决这个问题。其他子类可以**重写**这个移动方法。像这个“巫师”子类,没有自己的移动方法,就可以默认使用棋类的“傻瓜”移动方法,这条“傻瓜”移动方法可能就是弹出一条错误消息:**我不能动**。 </b> 由于可以在子类中重写方法,所以我们可以创建非常强大的面向对象系统。例如,如果我们想实现一个人工智能棋手类,我们给这个棋手类定义一个`calculate_move`方法,接受一个棋盘对象,然后决定选择哪个棋子移动到哪里。一个非常基础的类可能随机选择一个棋子和方向,接着相应地移动它。我们也可以在深蓝子类中重写这个方法。第一种类合适一个未经训练的初学者,后者将将是一位大师。更重要的是,类中的其他方法,例如通知棋盘哪些棋子被移动,这些无论是初学者还是大师,都是一样的方法,则无需更改,这些方法完全可以在两个类之间分享。 </b> 在国际象棋这个例子,提供一个默认的移动方法是没有意义的(译注:每个子类的走法都不同,所以设计一个默认移动方法有“嘛用”哪,所以同学们哪,抽象的本质就是了解业务啊)。我们需要做的是为每个子类定义移动方法。我们可以通过将**棋子**类定义为一个带有**抽象**移动方法的**抽象类**,来实现这个目的。抽象方法意味着“我们命令这个方法必须存在在任何非抽象子类中,但我们拒绝在这个类中实现它。” </b> 实际上,确实存在这种根本不需要实现任何方法的类。这样一个类会简单地告诉我们类应该做什么,但绝对没有关于如何做的建议。在面向对象的术语中,这种类被称为**接口**。 ### 继承提供抽象 **多态性**是指根据要实现的子类,对类进行不同的处理。我们已经看到在我们描述的国际象棋系统里它们是怎么实现的。如果我们再往前走两步,我们可能会看到**棋盘**对象可以接受来自玩家的移动指令,并且调用棋子的**移动**函数。棋盘不需要知道它正在处理什么类型的棋子。它所要做的就是调用`move`方法,具体怎么移动,则是由子类来控制的,如果子类是骑士,就按骑士的移动方法,如果子类是士兵,就按士兵的移动方法。 </b> 多态性很酷,但它在Python中很少使用。Python又往前走了一步。python允许对象的子类可以像父类一样被对待。在python中棋盘对象可以接受任何有**移动**方法的对象,无论是国际象棋的象,汽车,或是鸭子。当调用**移动**方法时,国际象棋的**象**将在棋盘上沿对角线移动,汽车将行驶到某个地方,鸭子会游泳或游泳,这取决于它的心情。 </b> python中的这种多态性通常被称为鸭子类型:“如果像鸭子一样走路或像鸭子一样游泳,那就是鸭子。我们不在乎它是不是真的*是一*只鸭子(继承),只要它能游泳或散步就行了。鹅和天鹅也很容易提供我们寻找的鸭子般的行为。这使得未来的设计师能够创建新类型的鸟,而不实际指定水生鸟类之间的继承关系。它还允许他们创建他们从未计划过完全不同的行为。例如,未来的设计师能够创造一只行走的、游泳的企鹅,和鸭子共用相同的接口,但不用暗示企鹅是鸭子。 ### 多重继承 当我们在自己的家谱中考虑继承时,我们可以看到我们继承了来自多个父类的功能。当陌生人告诉一位骄傲的母亲,她儿子有他父亲的眼睛,她通常会回答:“是的,但是他拥有我的鼻子。” </b> 面向对象的设计也可以具有这样的**多重继承**特性,这使得子类从多个父类继承功能。实际上,多重继承可能是一项棘手的业务,一些编程语言(特别是Java)严格禁止它。但是,多重继承可以有很多用途。通常,它可以用于创建具有两组不同行为的对象。例如,一个被设计用于连接扫描仪并通过传真发送扫描文档的对象,可以通过从两个不同的扫描仪对象和传真对象继承来创建。 </b> 只要两个类具有不同的接口,通常不会对子类从它们中继承造成什么严重的影响。但是,如果我们从两个提供重叠(相同名字)接口的类那里继承,就会变得混乱。例如,如果我们有一个具有移动方法的摩托车类,以及一个具有移动方法的船类,我们想把它们合并成最终的两栖车辆,当我们调用移动方法时,如何让生成的类知道要做什么?在设计层面,这需要要解释,在实现层面,每个编程语言都有决定调用哪种父类方法的不同方式,或者按什么顺序调用。 </b> 通常,最好的方法是避免使用多重继承。如果你有一个展示出来的设计存在多重继承,你可能做错了。后退一步,再次分析系统,然后看看你是否可以删除多重继承关系,用其他关联或组合设计。 </b> 继承是用于扩展行为的一个非常强大的工具。它也是面向对象设计相对于早期编程范式的一种最显著的市场化优势。因此,继承通常是面向对象程序员所接触的第一个工具。然而,它重要的是要认识到拥有锤子不会把螺丝钉变成钉子。继承是一种完美的解决方案,显然是针对对象之间的关系而言,所以它可能被滥用。程序员经常使用继承在两种对象之间共享代码,但两个对象之间并没有什么关系。虽然这不一定一个糟糕的设计,这是一个可怕的机会,问他们为什么这么决定,换一种不同的关系或设计模式或许是合适的。 ## 案例研究 让我们将所有新学到的面向对象知识,通过一组面向对象设计迭代,在一个现实世界的例子中,将它们联系在一起。我们要构建的系统模型是图书馆目录。几个世纪以来,图书馆一直在跟踪它们的库存,最初使用卡片目录,最近使用电子目录。现代图书馆有基于网络的目录,我们可以在家里查询。 </b> 让我们从分析开始。当地的图书管理员要求我们写一个新的卡片目录程序,因为他们古老的基于DOS的程序是丑陋和过时的。他并没有给我们太多细节,但在我们开始询问更多信息之前,让我们考虑一下我们已经知道的关于图书馆目录的内容。 </b> 目录包含书籍列表。人们通过特定的标题或特定的作者,搜索某些主题的书籍。书籍可以通过国际标准书号(ISBN)唯一标识。每本书都有杜威十进制系统(DDS)编号,用于帮助在特定货架上查找书籍。 </b> 这个简单的分析告诉我们系统中存在一些明显的对象。我们很快将**书籍**确定为最重要的对象,并提及到多个属性,如作者、标题、主题、ISBN和DDS编号,看起来目录有点像图书经理。 </b> 我们还注意到一些可能需要或不需要在系统建模的对象。为了编目,我们通过作者搜索一本书,只需要这本书的`author_ name`属性。然而,作者也是对象,我们可能希望存储有关作者的其他数据。当我们思考这个问题时,我们可能会记得有些书有多位作者。突然间,对于图书对象,仅仅定义单一`author_ name`属性似乎有点傻。定义与每本书相关联的作者列表显然是个好主意。 </b> 作者和书之间的关系显然是关联关系,因为你永远都不能说“书是作者”(不是继承),也不能说“书有作者”,虽然在语法上是正确的,但并不意味着作者是书的一部分(这不是聚合)。实际上,任何一个作者都可能与多本书相关联。 </b> 我们还应该注意名词(名词总是可以看作代表对象)*货架*。货架是需要在编目系统中建模的对象吗?我们如何识别单个货架?如果一本书存储在一个架子的末端,另外一本书被插在这个书架的前面,它只好移到下一个架子的开始,接着会发生什么? </b> DDS的设计是为了帮助在图书馆找到实体书。因此,不管存储在哪个书架,通过书籍的DDS属性都应该足以定位它。所以,至少在目前,从我们的对象竞争列表中删除货架对象。 </b> 系统中的另一个有争议的对象是用户。我们需要知道关于某个特定的用户的信息吗?比如他们的姓名、地址或过期书籍的列表?到目前为止,图书管理员只告诉我们他们想要一份目录;他们并没有提及跟踪订阅或过期通知。在我们的头脑中,我们也注意到作者和用户都是特殊类型的人;可能这里将会有一个有用的继承关系。 </b> 为了编目的目的,我们决定暂时不需要识别用户。我们可以假定用户将搜索目录,但我们不必主动在系统中为他们建模,除了提供一个允许他们搜索的接口。 </b> 我们为书籍确定了一些属性,但是目录需要什么属性吗?有没有一个图书馆有多个目录?我们需要单独识别它们吗?显然,目录中必须以某种方式包含书的集合,但这个列表可能并不是公共接口的一部分。 </b> 那么行为呢?目录显然需要一种搜索方法,或是是几个按作者、标题和主题进行搜索的不同的方法。书籍需要什么行为吗?会不会需要预览方法吗?或者通过首页属性来识别预览而不是用预览方法? </b> 前面讨论的问题都是面向对象分析阶段的一部分。但在回答这些问题中,我们已经确定了一些属于设计部分的关键对象。事实上,你刚才看到的就是分析和设计之间的微迭代。 </b> 很可能,这些迭代都发生在与图书管理员的初始会议上。然而,在这次会议之前,我们可以为对象勾画出一个最基本的设计。我们已经具体确定了: ![](https://box.kancloud.cn/80711e0cfc0f7ac60e653161f6c06271_328x274.png) 有了这张基本的类图和一支铅笔,我们可以和图书管理员一起,互动地改进类图。他们告诉我们这是个好的开始,但图书馆提供的不止有书籍,他们也提供DVD、杂志和CD,这些都没有ISBN,或DDS号码。所有这些类型的项目都可以由UPC数字唯一标识。我们提醒图书管理员,它们必须在货架,这些物品可能不是按UPC组织的。图书管理员解释说每种类型都以不同的方式组织。CD大多是有声读物,它们只有几十个存货,所以是按作者的姓整理的。DVD分为流派,并按标题进一步组织。杂志是按标题组织的,然后按卷和发行号再细分。书和我们猜测的一样,按DDS编号组织。 </b> 如果以前没有面向对象的设计经验,我们可以考虑添加将DVD、CD、杂志和书籍的列表分别放到我们的目录中,然后按顺序搜索每个目录。问题是,除了某些扩展属性,包括识别项目的物理位置,这些项目的很多行为都是相同的。这就是一个继承!我们快速更新了我们的UML图: ![](https://box.kancloud.cn/fbe4b8df80d6c270ddd8608845a55eac_497x279.png) 图书馆员理解我们草图的要点,但对**定位**功能有一点困惑。我们通过用户正在搜索特定“兔子”这个词为例来解释。用户开始向目录发送搜索请求。目录查询其内部项目列表,并发现一本书和一张DVD的标题中含有“兔子”。到目前为止,目录不关心它所持有的类型(DVD、书籍、CD或杂志);就目录而言,这些类型是相同的。但是,用户想知道如何找到物理项,如果目录只是返回标题列表,并没什么用。因此,它对它发现的两个项目调用**locate**方法。这本书的**locate**方法返回一个DDS编号,该编号可用于标识存放书的位置。通过返回DVD的类型和标题来定位DVD。用户可以访问DVD部分,查找包含该流派的部分,并查找特定的按标题排序的DVD。 </b> 正如我们所解释的,我们绘制了一个UML序列图来解释对象之间如何通信: ![](https://box.kancloud.cn/5b91260a3c8e2954c97123b4c9d96cc0_382x396.png) 类图描述了类之间的关系,而序列图表描述了对象之间传递特定消息的序列。挂在每个对象上的虚线行是描述对象生存期的生命线。这个每条生命线上更宽的盒子表示该对象中处在活动状态(没有盒子的地方,对象基本上是闲置的,等待着发生什么)。这个生命线之间的水平箭头表示特定的消息。实心箭头表示正在调用的方法,而带实心箭头的虚线表示方法返回值。 </b> 半箭头表示发送到对象或从对象发送的异步消息。异步消息通常意味着第一个对象调用第二个对象的方法,然后立即返回。经过一些处理后,第二个对象调用第一个对象的方法来给它一个值。这与常规调用不同,常规调用会立即返回一个值。 </b> 序列图和所有UML图一样,只有需要它们时才有用。为了绘制UML图而画图实际上是没有意义的。但是,当你需要在两个对象之间交流一系列的互动时,序列图是一个非常有用的工具。 </b> 不幸的是,我们的类图到目前为止仍然是一个混乱的设计。我们注意到DVD上的演员和CD上的艺术家都是各种各样的人,但他们完全不同于书的作者。图书管理员还提醒我们,他们的大多数CD是有声读物,所以有作者而不是艺术家。 </b> 我们如何处理为一个题目做出贡献的不同类型的人?明显的实现是创建一个具有姓名和其他相关详细信息的`Person`类,然后为艺术家、作者和演员创建此类的子类。然而,这里真的需要继承吗?为了搜索和编目的目的,我们并不关心表演和写作是两种截然不同的活动。如果我们做一个经济模拟的话,给演员和作者分别创建不同的类是有意义的,顺便再加上不同的`calculate_income`和`perform_job`方法,但用于编目目的,很可能只要知道这个人是如何贡献项目就足够了。我们认识到所有项都有一个或多个`Contributor`对象,因此我们把书籍中的作者关系移动到父类: ![](https://box.kancloud.cn/1bf134e721c4a6179637c291b5bd61bd_505x307.png) **贡献者**/**库项**之间的关系是**多对多**的,因为关系两端都有`*`字符。任何一个图书馆项可能有多个贡献者(例如,一个DVD有几个演员和一个导演)。许多作者写了很多书,所以他们会被附在多个项上。 </b> 这一小小的变化,虽然看起来有点干净和简单,却失去了一些至关重要的信息。我们仍然可以知道是谁对一个特殊的图书馆项目做出了贡献,但我们不知道他们是如何贡献的。他们是导演还是演员?他们写了有声读物,或者只是叙述这本书的声音? </b> 如果我们在**贡献者**类加上一个`contributor_type`属性,但在与多才多艺的人打交道时,例如既写过书又导演过电影的人,这一类会分崩离析。 </b> 一种选择是在**库项**子类上加上我们需要的信息,例如**书籍**上的**作者**或**CD**上的**艺术家**,然后把这些属性都指向**贡献者**类。问题在于这就是我们失去了很多多态的优雅。如果我们想列出一个项目的贡献者,我们必须在该项目上寻找特定的属性,例如**作者**或**演员**。我们可以通过在**库项**类上添加`getContributors`方法来缓解这种情况。这样子类可以重写。目录也不必知道正在查询对象的属性;我们抽象了公共接口: ![](https://box.kancloud.cn/17cacdb2c6e8ab52ad4036890667d873_555x456.png) 只要看一下这个类图,就知道我们好像做错了什么。它是笨重而易碎。它可以做我们所需要的一切,但感觉很难维护或扩展。关系太多,类大多会受到任何一个类修改的影响。看起来像意大利面和肉丸。 </b> 既然我们已经知道继承可以作为一种选项,并且发现它是我们所需要的,我们可能会回顾我们以前的基于组合的类图,**贡献者**直接连接到**库项**。经过思考,我们可以看到只需再为一个全新的类添加一个用于识别贡献者类型的关系。这是面向对象设计中的一个重要步骤。我们在设计里添加了一个新类用于*支持*其他对象,而不是作为初始要求的一部分建模。我们正在**重构**设计使系统中的对象更加便利,而不是针对现实生活中的对象。在程序或设计的维护中,重构是一个必不可少的过程。重构的目标是通过移动代码、删除重复代码或复杂关系改进设计,使得设计更简单,更优雅。 </b> 这个新类由一个**贡献者**类和一个额外的类型属性(贡献者对给定**库项**所做的贡献类型)组成。有可能对于一个特定的**库项**有很多这样的贡献者,并且一个贡献者可以以同样的方式为不同的项目做出贡献。设计的更好的类图如下: ![](https://box.kancloud.cn/2b8c86822b36fcdd91ecff14a94d18da_577x290.png) 乍看起来,这种组合关系并不像继承关系那么自然。但是,它的优点是允许我们添加新的贡献者类型,而不用在设计中添加新类。当子类具有某种特殊性时,继承是最有用的。特殊性意味着可以创造或者改变子类的属性或行为,使其有所不同于父类。仅仅为识别不同类型的对象创建空类是愚蠢的(在Java程序员和其他“一切皆对象”的程序员,这是一种不太普遍的态度,但在更务实的python设计师那里是很常见的)。如果我们查看类图的继承版本,我们可以看到一堆实际上不做任何事情的子类: ![](https://box.kancloud.cn/763d0aed5c5aff82d3e5d129f0a5d0a4_638x298.png) 有时侯,认识到何时不使用面向对象原则很重要。这个不使用继承的例子很好地提醒我们,对象只是工具,而不是规则。 </b> ## 总结 在本章中,我们对面向对象范式的术语进行了一次旋风式的介绍,重点介绍了面向对象的设计。我们可以把对象分为不同的类,并通过类接口描述这些对象的属性和行为。类描述了对象、抽象, 封装和信息隐藏等高度相关的概念。有很多对象之间的各种关系,包括关联、组合以及继承。UML语法对于娱乐和沟通很有用。 </b> 在下一章中,我们将探讨如何在Python中实现类和方法。