第2章

类和对象:面向对象范型的建材




           2.1   类和对象导引
             面向对象范型使用类和对象的概念作为基本建筑材料。应用程序的分析、
           设计、实现模型一致地使用这些概念。通过现实世界中的例子来解释这些概念
           是最佳方案。如果有一屋子的人,你问:  “给你们所需的全部零件,谁能装配出
           一只闹钟”?最多有一两个人会举手。但如果你问他们“这个房间里谁能够把
           闹铃设到早上 9 点”,那么我可以放心地和你打赌,大多数人都会举手。大多数
           人会使用闹钟,但不会装配闹钟,这难道不荒谬吗?对这个问题,你最直接的
           反应当然是“当然不荒谬,你的问题才荒谬呢”  !

             在现实世界中,有很多东西是我们会使用但不会制造的,比如冰箱、汽车、
           复印机、计算机等等。这只是列举了一小部分。为什么我们可以不知道它们的
           实现却能轻松使用它们?因为它们被设计为通过一个精确定义的公有界面 1 来
           使用。这个公有界面极大地依赖于内部的实现,但又向用户隐藏了内部实现。
           这一设计策略还允许闹钟制造商把目前闹钟用到的 60 个小零件替换成进口的 3
           个子部件,而闹钟的使用者对此不会有意见。

             公有界面与实现的另一个例子可见于汽车行业。很少有驾车者介意机械点
           火系统(配电盘、电插座、电容器)到电子点火系统的转变。为什么?因为公
           有界面保持了一致,改变的只是实现。但是,请想象一下,如果你去汽车经销


1
    译注:原文为interface,既可译为“界面”也可译为“接口”。此中译本根据上下文选择其一。译者大体上的处理原则是,若
    interface是对人的,译作“界面”;若是对机器或程序的,译作“接口”
12                                       第2章   类和对象:面向对象范型的建材

商处购买新车,经销商递给你一把钥匙,并让你试车。你坐在驾驶座上,寻找点火装置的钥
匙孔。你从驾驶杆找到仪表板,再搜索相邻区域,却寻不着钥匙孔。你问经销商如何发动汽
车。经销商说,“噢,这个型号是这样的,你用钥匙打开旅行箱,然后你会看到一个红色按钮。
按一下那个按钮汽车就会启动了。你会感到不安,
                ”      因为汽车制造商改变了你熟悉的公有界面。

  面向对象范型的一个基本想法就是这样。所有构成系统的实现细节都应该隐藏在精确定
义并且一致的公有接口后面。使用这些构造的用户需要知道这个公有接口,但你不让他们看
见实现细节。这样,如果需要,实现者就可以改变实现细节,只要公有接口保持不变就行。
我经常旅行,可以向你保证,不需要知道实现细节就能使用闹钟实在很方便。我曾在许多旅
馆住过,用过很多种闹钟,有用电的,有需要上发条的,有依靠电池的,有数字型的,有模
拟型的。但是,我坐在飞机上时从未忧虑过不会使用将到达的旅馆房间里的闹钟。

  我提到“闹钟”这个词后大多数读者都会知道我指的是什么,虽然可能你身边并没有闹
钟。为什么?因为你曾经看见过很多闹钟,并且知道,所有的闹钟都有一些共同的属性,比
如时间、闹铃时间(都按小时和分钟显示)以及闹铃开关。你还知道,你看到过的所有闹钟
都允许你设置它们的时间和闹铃时间,并且允许你打开或者关闭闹铃。这样,你就有了一种
叫做“闹钟”的概念,这一概念用一个简洁的组合表示了所有闹钟的数据和行为。这种概念
称作类(class)。而你拿在手上的闹钟实物叫做闹钟类的对象(object)或者实例(instance)    。
类和对象之间的关系叫做实例化关系(instantiation relationship)。我们说,闹钟对象是从闹钟
类实例化(instantiate)而来,闹钟类是你遇到的所有闹钟对象的泛化(generalization)1(参
见图 2.1)。




                         图 2.1 闹钟和它的对象


  如果我告诉你,我的闹钟从我的床头几上跳起来,咬了我一口,然后去追邻居的猫了,
你一定会认为我疯了。但如果我告诉你,我的狗做了这些事情,你会觉得这挺合理的。这是

1
     译注:也有译为归纳、一般化的,此中译本选择“泛化”这一译法。
2.1   类和对象导引                                       13

因为,类的名字不仅意味着一组属性,还表示实体的行为。这种数据和行为的双向联系是面
向对象范型的基石之一。

      一个对象一定会有如下 4 个重要方面:

      1.它自己的身份标识(可能只是它在内存中的地址);

      2.它的类的属性(通常是静态的)和这些属性的值(通常是动态的);

      3.它的类的行为(从实现者的角度看);

      4.它的类的公开接口(从用户的角度看)。

  将这一讨论置于软件开发的语境,类可以被实现为一个结构定义以及一组可以处理这个
结构的操作。在过程式语言中,任给一个函数,很容易找出数据依赖性。只要检查函数实现
并看一下所有参数、返回值以及局部变量声明的数据类型就可以了。但是,如果你想要找出
一个数据定义的函数依赖性,那你就不得不检查全部代码,寻找依赖于这个数据的函数。而
在面向对象模型中,两种依赖性(函数对数据的依赖性和数据对函数的依赖性)都现成摆明
在那里了。对象是类数据类型的变量。它们的内部细节只对同它们的类关联的那组函数可见。
这种对内部细节的访问限制称作信息隐藏(information hiding)。在很多面向对象语言中,这
种隐藏不是强制的,这样我们就有了第一条(也是最重要的一条)经验原则。


      经验原则 2.1

      所有数据都应该隐藏在它所在的类内部。

  违反这条经验原则意味着你不重视可维护性。面向对象范型所带来的益处,大部分
归因于在设计阶段和实现阶段始终确保信息隐藏。如果你把数据设定为公有,那么就很
难判断系统哪部分的功能依赖于这个数据。事实上,这样一来,数据变动与函数的映射
关系就和面向动作范型一模一样了。我们不得不检查所有的函数以判断哪些函数依赖于
公有数据。

  有时开发者会争辩说,        “我需要把这个数据设为公有,因为……”在这种情况下,开发
者应该问自己,   “我到底要用这个数据来做什么?为什么不是类为我提供这个操作?”         在所有
这类情况下,问题出在类缺少了一个必需的操作。比如,考虑图 2.2 中的 File 类。开发者出
人意料地认为,byte_offset 数据成员应该是公有的,这样才能允许随机 I/O 访问。但是,我
们实际上需要的是执行随机访问任务的操作。          (如果你不是 C 程序员,那么我在这里补充说
明一下:fseek 和 ftell 和标准 C 库函数,用于执行文件的随机 I/O 访问。
                                            )冒昧地认为“我们
可以把这个数据设为公有,因为它永远也不会改变”的程序员请注意,Murphy 关于编程的一
条定理表明,这是第一个需要改变的数据。
14                             第2章   类和对象:面向对象范型的建材




                 图 2.2 不应出现的公有数据


  通过下面的例子,我们可以进一步描述数据隐藏带来的好处。这是一个点类,它的实现
采用了直角坐标系(参见图 2.3)。天真的设计者可能会争辩说,我们可以把点的 x 坐标和 y
坐标设为公有,因为实现永远也不会改变。但是,不可避免地,某些新的需求会迫使你改用
极坐标系,从而会影响使用这个点类的所有用户。如果我们把数据隐藏起来,那么只有类的
实现者需要改变他们的代码。




                 图 2.3 公有数据的危险性




2.2   消息和方法
  对象应当被看作机器,机器只为提出恰当请求的人执行公有接口所定义的操作。因
为对象独立于使用者,也因为一些实现了面向对象概念的早期语言的语法,术语“发送
消息”用于描述执行对象的行为。当消息被发送至对象,它必须判断是否理解该消息。
如果理解,那么对象就把消息映射为一个函数调用,并把自身作为隐含的第一个参数传
递过去。对解释语言而言,判断是否理解一个消息是在运行时完成的,而编译语言则是
在编译时完成的。
2.2   消息和方法                                                  15

  对象行为的名称(或者原型)被称作消息(message)       。许多面向对象语言都支持重载函
数(overloaded function)或者操作符。这一构造的约定是,系统中的两个函数可以有相同的
名字,只要它们的参数类型不同(类内重载)或者所属的类不同(类间重载)就可以了。闹
钟类可以有两个不同的 set_time 消息,一个消息用两个整数作为参数,另一个消息用一个字
符串作为参数。这是一个类内重载的例子。
        void AlarmClock::set_time(int hours, int minutes);
        void AlarmClock::set_time(String time);

  此外,闹钟和手表可能都有 set_time 消息,它们可能都以两个整数作为参数。这是一个
类间重载的例子。
        void AlarmClock::set_time(int hours, int minutes);
        void Watch::set_time(int hours, int minutes);

   值得一提的是,消息的组成部分包括函数名、参数类型、返回值类型,以及消息所属的
类。这是类的使用者所需知道的主要信息。在一些语言和/或系统中,可能还会有其他信息,
比如消息抛出的异常的类型,以及其他相关的同步信息(比如,消息是同步的还是异步的)          。
类的实现者必须知道如何实现消息。消息的实现,也即实现消息的代码,     被称作方法 (method)。
一旦控制进入方法内部,对接收消息的对象的全部数据成员都是通过隐含的第一个参数引用
的。这个隐含的第一个参数在很多语言中都称作“self 对象” (C++则偏爱称其为“this 对象”。
                                                  )
对象所能响应的消息列表被称作对象的协议(protocol)。

  类/对象可以响应两种特殊的消息。第一种是用于为了创建类的对象而调用的操作。这称
为类的构造函数(constructor)。类可以有多个构造函数,每个构造函数接受一组不同的初始
化参数。例如,我们可以通过传递 5 个整数参数分别指明小时、分钟、闹铃小时、闹铃分钟、
闹铃状态来构造闹钟;我们也可以传递两个字符串和一个整数参数,每个字符串都是“小时:
分钟”格式,分别表明时间和闹铃时间;而整数则表明闹铃状态。有的类甚至可以有十几个
或者更多构造函数。

  类/对象能够响应的第二种特殊的消息是在把对象从系统删除之前清除对象内容的操作。
这个操作称为类的析构函数(destructor)。大多数面向对象语言每个类都只有一个析构函数,
因为在运行时需要做出的任何决定都可以保存为对象状态的一部分,没有必要再给方法传递
额外的参数。我们将在书中多处提及构造函数和析构函数。你可以认为它们是面向对象范型
的初始化和清除机制。


      经验原则 2.2

      类的使用者必须依赖类的公有接口,但类不能依赖它的使用者。

      这条经验原则背后的基本原理是可复用性。闹钟可以用于卧室(参见图 2.4)。使用闹钟
16                                         第2章   类和对象:面向对象范型的建材

的人显然依赖于闹钟的公有界面。但是,闹钟不应当依赖于那个人。如果闹钟依赖于使用者,
比如说那个在卧室中用闹钟的人,那么闹钟就无法被用来制造定时锁保险箱,除非把那个人
也绑定在保险箱上。这样的依赖性是不受欢迎的,因为我们想要把闹钟用于其他的领域,而
不想为此依赖于使用者。所以,最好把闹钟看作一个小型机器,这个小型机器对它的使用者
一无所知,它仅仅是执行定义于公有界面的行为,而不管发送消息的是谁。




                              图 2.4 使用闹钟



      经验原则 2.3

      尽量减少类的协议中的消息。

    就在几年前,还有人撰文提倡刚好与这条经验原则相反的实践。当时是这样说的:关于
这个类的操作,凡是类的实现者能想象到的,将来就会有用户用到。那么,既然如此,为什
么不实现这些操作呢?如果你采纳这样的经验原则,那么你肯定会钟爱我的链表类——它的
公有接口有 4 000 个操作。问题时,当你想对两个链表对象执行合并操作时,你认为链表类
一定提供了这个操作,所以你依照字母顺序检查消息列表,但是却找不到哪个操作是以
merge、union、combine 或者你知道的其他同义词命名的。不幸的是,真正的操作是一个重载
的加号(在 C++中是 operator+)  。庞大的公有接口的问题是,你永远都无法找到你想要找的
东西。这严重损害了接口的可复用性。而如果让接口最小化,我们就可以让系统易于理解,
并使组件易于复用。


      经验原则 2.4

  实现所有类都理解的最基本公有接口[例如,拷贝操作(深拷贝与浅拷贝)、相等性判
断、正确输出内容、从 ASCII 描述解析等等]。

  如果一个开发者设计和实现的类要被另一个开发者在其他应用程序中复用,那么提供一
                1
个常用的最小公有接口常常很有用。这个最小公有接口包含的功能是人们合理地预期每个类

1
     译注:特别是Framework设计尤其如此。很多Framework设计时都在根类中提供了这一最小公有接口(单根继承结构)。
2.2   消息和方法                                        17

都会有的。我们可以把这个接口当作了解可复用软件代码中类的行为的基础。我们将在第 9
章中更详细地探讨关于这个最小公有接口的事项。


      经验原则 2.5

      不要把实现细节(例如放置共用代码的私有函数)放到类的公有接口中。

    这条经验原则用于为使用者降低类接口的复杂性。基本想法是,类的使用者不想在公
有接口中看见他们不用的成员。  这些成员属于类的私有区域。如果类的两个方法有一段公
共代码,  那么就可以创建一个防止这些公共代码的私有函数。把这些公共代码封装成一个
独立方法常常会带来方便,  但是这个方法并不是一个新的操作,它只是类中两个操作的实
现细节。因为是实现细节,所以它应当放在类的私有区域中,而不是公共区域中(参见图
2.5)。




                   图 2.5 公共代码私有函数示例


    为了让你对公共代码私有函数有更贴近实际的了解,你可以认为类 X 是一个链表,f1
和 f2 是函数 insert 和 remove,公共代码私有函数 f 是在链表中找到插入点或者删除点位置
的操作。


      经验原则 2.6

      不要以用户无法使用或不感兴趣的东西扰乱类的公有接口。

  这条经验原则与前一条是相关的,因为类的用户不会想调用公共代码函数,所以把
这些函数放在公有接口中只会扰乱类的公有接口。它们并不是类的新操作。有些语言,
比如 C++,允许在公有接口中错误地包含其他类型的函数。例如,在 C++中把抽象类的
构造函数放在那个类的公有接口中是合法的,虽然当类的使用者试图使用这样的构造函
数时编译器会报告一条语法错误。若遵循更一般化的经验原则 2.6,那么这些问题就不
会发生了。
18                               第2章   类和对象:面向对象范型的建材




2.3     类耦合与内聚
  一些经验原则用于解决类的耦合与内聚问题。我们努力让类更紧密地内聚,并尽量降低
类间耦合程度。这和在面向动作范型中试图让函数更紧密地内聚并尽量降低函数间的耦合程
度的努力是一致的。函数中的紧密内聚意味着组成函数的所有代码都是紧密相关的。函数间
的松耦合意味着当一个函数想要使用另一个函数时,它应当在总是从同一点进入该函数,并
从同一点退出。这样,我们就可以得出这样的面向动作的经验原则:“函数应当只有一条返回
语句。”

  在面向对象范型中,我们把松耦合和紧内聚的目标映射到了类的层次。类之间有 5 种形
式的耦合关系:零耦合(nil coupling)是最佳的,因为这意味着两个类丝毫不依赖于对方。
你可以去掉一个类,而不会影响另一个。当然,如果只用到零耦合,你无法创建有意义的应
用程序。若只用到零耦合,我们最多只能创建类库,这样的类库由一系列的独立类组成,这
些类相互之间没有影响。导出耦合(export coupling)则表明,一个类依赖于另一个类的公有
接口。 1 也就是说,这个类用到另一个类的一个或多个公有操作。授权耦合(overt coupling)
则意味着一个类经允许使用另一个类的实现细节。C++的友元机制是授权耦合的典型例子。
一个C++类X可以声明类Y是它的友元。这样,Y的方法就获得授权可以访问X的实现细节。
自行耦合(covert coupling)和授权耦合差不多,也是类Y访问类X的实现细节,但区别在于
类Y是未经授权的。如果我们发明一种语言机制,允许类Y声明自身是X的友元并且将使用X
的实现细节,那么X和Y就是自行耦合的。最后一种耦合是暗中耦合(surreptitious coupling),
这种耦合是指类X通过某种方式知道了Y的实现细节。如果类X使用类Y的公有数据成员,那
么X就和Y暗中耦合。暗中耦合是最危险的耦合形式,因为它在Y的行为和X的实现之间建立
了很强的隐式依赖关系。


      经验原则 2.7

  类之间应该零耦合,或者只有导出耦合关系。也即,一个类要么同另一个类毫无关系,
要么只使用另一个类的公有接口中的操作。

  所有其他形式的耦合都允许类把实现细节暴露给其他类,这样就在两个类的实现之间建
立了隐含依赖关系。将来如果一个类想要修改它的实现,那么这些隐含依赖关系总会带来维
护问题。


1
     译注:在Eiffel中可以显式地声明导出关系。
2.3   类耦合与内聚                                                        19

      类内聚努力确保类内部的所有元素都是紧密关联的。有一些经验原则牵涉到这一属性。


      经验原则 2.8

      类应当只表示一个关键抽象。

     一个关键抽象(key abstraction)被定义成领域模型中的一个主要实体。关键抽象经常以
名词形式出现,并伴随着需求规约。每个关键抽象都应当只映射到一个类。如果它被映射到
多个类,那么设计者可能是把每个功能都表示为一个类了。如果多个关键抽象被映射到了同
一个类,那么设计者可能在创建一个集中化的系统。这些类经常被称为含糊的类(vague
classes),并且需要分割成两个或多个类,每个类表示一个关键抽象。第 3 章我们将更详尽地
探讨这两种不良设计。


      经验原则 2.9

      把相关的数据和行为集中放置。

     如果违反这条经验原则,那么开发者就不得不按以往方式编程。为了实现单一的系统需
求,开发者不得不改动系统的两处或者多处。其实这两处(或者多处)是同一个关键抽象,
所以应当用同一个类表示。设计者应当留意那些通过 get 之类操作从别的对象中获取数据的
对象。这种类型的行为暗示着这条经验原则被违反了。考虑一下一个烤炉类的使用者想要在
烧烤之前预热烤炉。用户应当只需要发送给烤炉一条 are_you_preheated?()消息就可以了。烤
炉应当可以测试自己的温度是否已经达到了需要的温度,                      并且测试其他预热需要满足的条件。
如果用户为了知道烤炉是否已经预热,需要问烤炉目前温度、期待温度、燃气阀的状态、常
燃火状态等等,那么就违反了这条经验原则。烤炉拥有这些温度和燃气烹饪设备的信息,它
应当自行判断这个对象是否已经预热了。留意那些为了实现不正确的预热方式而需要用到的
get 方法(比如,get_actualtemp()、get_desiredtemp()、get_valvestatus()等等)是很重要的。


      经验原则 2.10

      把不相关的信息放在另一个类中(也即:互不沟通的行为)
                               。

  开发者应当留意这样的类:方法的一个子集操作数据成员的一个真子集 1 。极端情况是,
一个类有一半方法操作一半数据成员,另一半方法则操作另一半数据成员(见图 2.6)。


1
    译注:子集(subset)和真子集(proper subset)的区别在于,一个集合是其本身的子集,但不是其本身的真子集。
20                            第2章    类和对象:面向对象范型的建材




               图 2.6 具有互不沟通的行为的类


  这是一个更接近现实世界的例子。请考虑词典类。对于小型词典,最好的实现是属性列表
(单词和它们定义的列表),但是对大型词典来说,哈希表更好(更快)  。两种辞典的实现都需
要提供增加单词和寻找单词的能力。图 2.7 展示了一个具有互不沟通的行为的词典类设计。




             图 2.7 互不沟通的行为(现实世界例子)


  这个解决方案假设词典类的使用者知道词典将会有多大。他们需要做出决定,是使用哈
希表实现的词典还是链表实现的词典。一般而言,在类名中显示实现细节并让用户来做这样
的选择不是好主意。一个更好的解决方案留在第 5 章讲述,因为它要用到继承。在那个解决
方案中,一个单一的词典类把它的实现隐藏为内部细节。如果词典的大小增长到了一个事先
定下的临界值,词典类会决定改变实现。



2.4   动态语义
  除了固定的数据和行为的描述之外,对象在运行时还随着其数据描述的动态取值具有局
部状态(即当时的“快照”。类的对象的所有可能状态的集合以及状态间合法的变换称为类
            )
2.4   动态语义                                      21

的动态语义(dynamic semantics)。动态语义允许对象对其生命期的两个不同时候发来的相同
消息作出不同的回应。例如,看这个抽象例子:
        Method junk for the class X
             if (local state #1) then
              F do something
             else if (local state #2) then
                 do something different
        End Method

  对象的动态语义是任何面向对象设计的有机组成部分。一般而言,任何具有有意义动态语
义的类都应当用一个状态转换图(参见图 2.8)来把这些动态语义归档。具有有意义动态语义
的类是指具有有限状态和精确定义的状态变换的类。图 2.9 所示的状态转换图详细描述了某个
操作系统中进程的动态语义。它表明,进程的状态可以是就绪、当前进程、阻塞、睡眠和退出。
此外,进程创建时只能是就绪状态,它们只能在退出状态被销毁,它们只能在当前进程状态时




                             图 2.8 状态转换图表示法




                        图 2.9 某种操作系统中过程的状态转换
22                                       第2章   类和对象:面向对象范型的建材

才能退出。这些信息对于为类及其对象创建测试集(test suite)非常有用。有些设计者偶尔会
把动态语义建模成静态语义,  这会导致类的数目大量膨胀——这是面向对象范型中的一个严重
问题。我们将在第 5 章讨论继承关系的时候探讨这个问题以及避免这个问题的方法。



2.5    抽象类
  除了我们已经讨论过的类,还有一种重要的抽象类型是我们需要探讨的。请思考下列问
题:你曾经吃过水果吗?你曾经吃过开胃菜吗?你曾经吃过甜点吗?很多人对这 3 个问题的
答案都是“是”。只要你对这 3 个问题中的任一个回答了“是”,请你接着思考下面的问题:
水果尝起来味道如何?一份甜点有多少卡路里的热量?一份开胃菜价格是多少?

  我可以说,没有人吃过“水果”。很多人吃过苹果、香蕉或者桔子,但没有人吃过一个 3
斤重的、红色的就叫做“水果”的东西。类似地,当你坐在餐厅中,服务员走来问你想吃些
什么时,你回答“一份开胃菜、一份主菜还有一份甜点”,如果这时服务员就转身走了,你就
有麻烦了,因为你喜欢虾,而不喜欢瓜(两种可能的开胃菜)。我们认可,没有“水果”“开
                                       、
胃菜”或者“甜点”这样的对象,但是这些名词确实表达了有用的信息。如果我拿起一只闹
钟对你说:“你觉得我的水果怎么样?”你会认为我疯了;而如果我拿起一只苹果问同样的问
题,你就会觉得很正常。“水果”这个称谓表达了有用的信息,虽然你不能创建水果对象。事
实上,它是一个类(概念),但不知道如何实例化它这种类型的对象。

      不知道如何实例化对象的类称为抽象类(abstract class)。

      知道如何实例化对象的类称为具体类(concrete class)。

  请留心我们经常使用的术语“抽象数据类型”(ADT)。有的时候,它被用作“类”的同
义词,并且不区分抽象类和具体类。

  在面向对象范型中 ,抽象类的一个重要用途是帮助创建继承层次结构。它们表达了类
别名称(见图 2.10)。我们将在第 5 章讨论它们的用处。




                        图 2.10 类表示类属信息
2.6   角色与类                                       23




2.6    角色与类


      经验原则 2.11

      确保你为之建模的抽象概念是类,而不只是对象扮演的角色。

  “母亲”或者“父亲”是不是类,还是某个“人”对象所扮演的角色?答案取决于设计
者为之建模的领域是什么。如果在给定的领域中,母亲和父亲具有不同的行为,那么或许他
们应当被建模为类。如果他们的行为相同,那么他们只是“人”类的对象所扮演的不同角色。
例如,我们可以把家庭看作“父亲”类的对象、“母亲”类的对象和几个“子女”类的对象所
构成的对象,也可以把家庭看作一个称为“父亲”的“人”对象、一个称为“母亲”的“人”
对象和一组称为“子女”的“人”对象构成的对象(参见图 2.11)。区分只在于不同的行为。
在创建不同的类之前,请确保它们的行为确实是不同的,而不是每个角色只使用“人”的能
力的一个子集。请记住,一个对象只用到它的类的行为的一个子集是毫无问题的。




                  图 2.11 一个家庭的两种视图


    有些设计者的做法是,测试一下公有接口中有没有哪个成员对于特定的角色无法使用。
如果有这样的成员,那么就意味着需要另一个类。如果它只是没有被用到,那么它只是被用
作多个角色的同一个类。例如,如果“母亲”的一个操作是 go_into_labor()(分娩),那么“父
亲”最好实现为另一个独立的类,因为父亲是无法分娩的。但是,如果这个家庭生活在一个
父系社会中,只有母亲才会执行 change_diaper()(换尿布)方法,那么“母亲”只是“人”
类所扮演的一个角色。得出这一结论的理由是因为如果有必要的话,父亲也可以执行
change_diaper()方法。但是,在更抽象的领域,若那个领域中“无法执行”与设计者或者领域
选择“不去执行”的差异并不明显,那么这种方法就难以奏效了。

  在设计过程中,面向对象设计者需要决定是否把一个特定的角色塑造成一个类。这就意
味着我们还需要一条经验原则来指导这一决定。下面的章节将尝试给出这样的经验原则,但
24                                第2章   类和对象:面向对象范型的建材

我对结果并不完全满意,因为这条经验原则并不是在所有领域中都适用的。



术语表
     Abstract class

     抽象类。不知道如何实例化自身对象的类。

     Class

  类。以双向联系的方式封装数据和行为的构造。与现实世界中的一个概念对应。抽象数
据类型(ADT)是类的同义词。

     Concrete class

     具体类。知道如何实例化自身对象的类。

     Constructor

     构造函数。类的一个特殊的操作,负责创建/初始化该类的对象。

     Destructor

     析构函数。类的一个特殊的操作,负责销毁/清除该类的对象。

     Dynamic semantic

  动态语义。类的对象所能具有的所有可能状态,以及这些状态之间被允许的转换的集合。
常用状态转换图来表示。

     Information hiding

     信息隐藏。类向该类的对象的使用者隐藏它的实现细节的能力。

     Instantiation relationship

     实例化关系。类和它的对象之间的关系。我们说类实例化对象。

     Key abstraction

  关键抽象。关键抽象被定义成领域模型中的一个主要实体。关键抽象经常表现为领域词
汇中的一个名词。

     Message

  消息。类中定义的操作的名称。在强类型语言中,消息可以包含名称、返回类型以及操
作参数类型(也即操作的原型)。
经验原则小结                                         25

  Method

  方法。消息的实现。

  Object

  对象。属于它的类的一个样例,包含它自己的标识、类的行为、类的接口、类的数据的
一份拷贝。也称为类的实例。

  Overloaded function

  重载函数。系统中的两个函数可以有相同的名字的能力,只要它们的参数类型不同(类
内重载)或者所属的类不同(类间重载)。

  Protocol

  协议。类能响应的消息列表。

  Self object

  Self 对象。控制位于方法内部时,接受消息的对象的引用。



经验原则小结
  经验原则 2.1      所有数据都应当隐藏在它所在的类内部。

  经验原则 2.2      类的使用者必须依赖类的公有接口,但类不能依赖它的使用者。

  经验原则 2.3      尽量减少类的协议中的消息。

   经验原则 2.4 实现所有类都理解的最基本公有接口[例如,拷贝操作(深拷贝与浅拷
贝)、相等性判断、正确输出内容、从 ASCII 描述解析等等]
                              。

  经验原则 2.5      不要把实现细节(例如放置共用代码的私有函数)放到类的公有接口中。

  经验原则 2.6      不要以用户无法使用或不感兴趣的东西扰乱类的公有接口。

  经验原则 2.7 类之间应该零耦合,或者只有导出耦合关系。也即,一个类要么同另一
个类毫无关系,要么只使用另一个类的公有接口中的操作。

  经验原则 2.8      类应当只表示一个关键抽象。

  经验原则 2.9      把相关的数据和行为集中放置。

  经验原则 2.10      把不相关的信息放在另一个类中(也即:互不沟通的行为)。

  经验原则 2.11      确保你为之建模的抽象概念是类,而不只是对象扮演的角色。

Ood启思录02

  • 1.
    第2章 类和对象:面向对象范型的建材 2.1 类和对象导引 面向对象范型使用类和对象的概念作为基本建筑材料。应用程序的分析、 设计、实现模型一致地使用这些概念。通过现实世界中的例子来解释这些概念 是最佳方案。如果有一屋子的人,你问: “给你们所需的全部零件,谁能装配出 一只闹钟”?最多有一两个人会举手。但如果你问他们“这个房间里谁能够把 闹铃设到早上 9 点”,那么我可以放心地和你打赌,大多数人都会举手。大多数 人会使用闹钟,但不会装配闹钟,这难道不荒谬吗?对这个问题,你最直接的 反应当然是“当然不荒谬,你的问题才荒谬呢” ! 在现实世界中,有很多东西是我们会使用但不会制造的,比如冰箱、汽车、 复印机、计算机等等。这只是列举了一小部分。为什么我们可以不知道它们的 实现却能轻松使用它们?因为它们被设计为通过一个精确定义的公有界面 1 来 使用。这个公有界面极大地依赖于内部的实现,但又向用户隐藏了内部实现。 这一设计策略还允许闹钟制造商把目前闹钟用到的 60 个小零件替换成进口的 3 个子部件,而闹钟的使用者对此不会有意见。 公有界面与实现的另一个例子可见于汽车行业。很少有驾车者介意机械点 火系统(配电盘、电插座、电容器)到电子点火系统的转变。为什么?因为公 有界面保持了一致,改变的只是实现。但是,请想象一下,如果你去汽车经销 1 译注:原文为interface,既可译为“界面”也可译为“接口”。此中译本根据上下文选择其一。译者大体上的处理原则是,若 interface是对人的,译作“界面”;若是对机器或程序的,译作“接口”
  • 2.
    12 第2章 类和对象:面向对象范型的建材 商处购买新车,经销商递给你一把钥匙,并让你试车。你坐在驾驶座上,寻找点火装置的钥 匙孔。你从驾驶杆找到仪表板,再搜索相邻区域,却寻不着钥匙孔。你问经销商如何发动汽 车。经销商说,“噢,这个型号是这样的,你用钥匙打开旅行箱,然后你会看到一个红色按钮。 按一下那个按钮汽车就会启动了。你会感到不安, ” 因为汽车制造商改变了你熟悉的公有界面。 面向对象范型的一个基本想法就是这样。所有构成系统的实现细节都应该隐藏在精确定 义并且一致的公有接口后面。使用这些构造的用户需要知道这个公有接口,但你不让他们看 见实现细节。这样,如果需要,实现者就可以改变实现细节,只要公有接口保持不变就行。 我经常旅行,可以向你保证,不需要知道实现细节就能使用闹钟实在很方便。我曾在许多旅 馆住过,用过很多种闹钟,有用电的,有需要上发条的,有依靠电池的,有数字型的,有模 拟型的。但是,我坐在飞机上时从未忧虑过不会使用将到达的旅馆房间里的闹钟。 我提到“闹钟”这个词后大多数读者都会知道我指的是什么,虽然可能你身边并没有闹 钟。为什么?因为你曾经看见过很多闹钟,并且知道,所有的闹钟都有一些共同的属性,比 如时间、闹铃时间(都按小时和分钟显示)以及闹铃开关。你还知道,你看到过的所有闹钟 都允许你设置它们的时间和闹铃时间,并且允许你打开或者关闭闹铃。这样,你就有了一种 叫做“闹钟”的概念,这一概念用一个简洁的组合表示了所有闹钟的数据和行为。这种概念 称作类(class)。而你拿在手上的闹钟实物叫做闹钟类的对象(object)或者实例(instance) 。 类和对象之间的关系叫做实例化关系(instantiation relationship)。我们说,闹钟对象是从闹钟 类实例化(instantiate)而来,闹钟类是你遇到的所有闹钟对象的泛化(generalization)1(参 见图 2.1)。 图 2.1 闹钟和它的对象 如果我告诉你,我的闹钟从我的床头几上跳起来,咬了我一口,然后去追邻居的猫了, 你一定会认为我疯了。但如果我告诉你,我的狗做了这些事情,你会觉得这挺合理的。这是 1 译注:也有译为归纳、一般化的,此中译本选择“泛化”这一译法。
  • 3.
    2.1 类和对象导引 13 因为,类的名字不仅意味着一组属性,还表示实体的行为。这种数据和行为的双向联系是面 向对象范型的基石之一。 一个对象一定会有如下 4 个重要方面: 1.它自己的身份标识(可能只是它在内存中的地址); 2.它的类的属性(通常是静态的)和这些属性的值(通常是动态的); 3.它的类的行为(从实现者的角度看); 4.它的类的公开接口(从用户的角度看)。 将这一讨论置于软件开发的语境,类可以被实现为一个结构定义以及一组可以处理这个 结构的操作。在过程式语言中,任给一个函数,很容易找出数据依赖性。只要检查函数实现 并看一下所有参数、返回值以及局部变量声明的数据类型就可以了。但是,如果你想要找出 一个数据定义的函数依赖性,那你就不得不检查全部代码,寻找依赖于这个数据的函数。而 在面向对象模型中,两种依赖性(函数对数据的依赖性和数据对函数的依赖性)都现成摆明 在那里了。对象是类数据类型的变量。它们的内部细节只对同它们的类关联的那组函数可见。 这种对内部细节的访问限制称作信息隐藏(information hiding)。在很多面向对象语言中,这 种隐藏不是强制的,这样我们就有了第一条(也是最重要的一条)经验原则。 经验原则 2.1 所有数据都应该隐藏在它所在的类内部。 违反这条经验原则意味着你不重视可维护性。面向对象范型所带来的益处,大部分 归因于在设计阶段和实现阶段始终确保信息隐藏。如果你把数据设定为公有,那么就很 难判断系统哪部分的功能依赖于这个数据。事实上,这样一来,数据变动与函数的映射 关系就和面向动作范型一模一样了。我们不得不检查所有的函数以判断哪些函数依赖于 公有数据。 有时开发者会争辩说, “我需要把这个数据设为公有,因为……”在这种情况下,开发 者应该问自己, “我到底要用这个数据来做什么?为什么不是类为我提供这个操作?” 在所有 这类情况下,问题出在类缺少了一个必需的操作。比如,考虑图 2.2 中的 File 类。开发者出 人意料地认为,byte_offset 数据成员应该是公有的,这样才能允许随机 I/O 访问。但是,我 们实际上需要的是执行随机访问任务的操作。 (如果你不是 C 程序员,那么我在这里补充说 明一下:fseek 和 ftell 和标准 C 库函数,用于执行文件的随机 I/O 访问。 )冒昧地认为“我们 可以把这个数据设为公有,因为它永远也不会改变”的程序员请注意,Murphy 关于编程的一 条定理表明,这是第一个需要改变的数据。
  • 4.
    14 第2章 类和对象:面向对象范型的建材 图 2.2 不应出现的公有数据 通过下面的例子,我们可以进一步描述数据隐藏带来的好处。这是一个点类,它的实现 采用了直角坐标系(参见图 2.3)。天真的设计者可能会争辩说,我们可以把点的 x 坐标和 y 坐标设为公有,因为实现永远也不会改变。但是,不可避免地,某些新的需求会迫使你改用 极坐标系,从而会影响使用这个点类的所有用户。如果我们把数据隐藏起来,那么只有类的 实现者需要改变他们的代码。 图 2.3 公有数据的危险性 2.2 消息和方法 对象应当被看作机器,机器只为提出恰当请求的人执行公有接口所定义的操作。因 为对象独立于使用者,也因为一些实现了面向对象概念的早期语言的语法,术语“发送 消息”用于描述执行对象的行为。当消息被发送至对象,它必须判断是否理解该消息。 如果理解,那么对象就把消息映射为一个函数调用,并把自身作为隐含的第一个参数传 递过去。对解释语言而言,判断是否理解一个消息是在运行时完成的,而编译语言则是 在编译时完成的。
  • 5.
    2.2 消息和方法 15 对象行为的名称(或者原型)被称作消息(message) 。许多面向对象语言都支持重载函 数(overloaded function)或者操作符。这一构造的约定是,系统中的两个函数可以有相同的 名字,只要它们的参数类型不同(类内重载)或者所属的类不同(类间重载)就可以了。闹 钟类可以有两个不同的 set_time 消息,一个消息用两个整数作为参数,另一个消息用一个字 符串作为参数。这是一个类内重载的例子。 void AlarmClock::set_time(int hours, int minutes); void AlarmClock::set_time(String time); 此外,闹钟和手表可能都有 set_time 消息,它们可能都以两个整数作为参数。这是一个 类间重载的例子。 void AlarmClock::set_time(int hours, int minutes); void Watch::set_time(int hours, int minutes); 值得一提的是,消息的组成部分包括函数名、参数类型、返回值类型,以及消息所属的 类。这是类的使用者所需知道的主要信息。在一些语言和/或系统中,可能还会有其他信息, 比如消息抛出的异常的类型,以及其他相关的同步信息(比如,消息是同步的还是异步的) 。 类的实现者必须知道如何实现消息。消息的实现,也即实现消息的代码, 被称作方法 (method)。 一旦控制进入方法内部,对接收消息的对象的全部数据成员都是通过隐含的第一个参数引用 的。这个隐含的第一个参数在很多语言中都称作“self 对象” (C++则偏爱称其为“this 对象”。 ) 对象所能响应的消息列表被称作对象的协议(protocol)。 类/对象可以响应两种特殊的消息。第一种是用于为了创建类的对象而调用的操作。这称 为类的构造函数(constructor)。类可以有多个构造函数,每个构造函数接受一组不同的初始 化参数。例如,我们可以通过传递 5 个整数参数分别指明小时、分钟、闹铃小时、闹铃分钟、 闹铃状态来构造闹钟;我们也可以传递两个字符串和一个整数参数,每个字符串都是“小时: 分钟”格式,分别表明时间和闹铃时间;而整数则表明闹铃状态。有的类甚至可以有十几个 或者更多构造函数。 类/对象能够响应的第二种特殊的消息是在把对象从系统删除之前清除对象内容的操作。 这个操作称为类的析构函数(destructor)。大多数面向对象语言每个类都只有一个析构函数, 因为在运行时需要做出的任何决定都可以保存为对象状态的一部分,没有必要再给方法传递 额外的参数。我们将在书中多处提及构造函数和析构函数。你可以认为它们是面向对象范型 的初始化和清除机制。 经验原则 2.2 类的使用者必须依赖类的公有接口,但类不能依赖它的使用者。 这条经验原则背后的基本原理是可复用性。闹钟可以用于卧室(参见图 2.4)。使用闹钟
  • 6.
    16 第2章 类和对象:面向对象范型的建材 的人显然依赖于闹钟的公有界面。但是,闹钟不应当依赖于那个人。如果闹钟依赖于使用者, 比如说那个在卧室中用闹钟的人,那么闹钟就无法被用来制造定时锁保险箱,除非把那个人 也绑定在保险箱上。这样的依赖性是不受欢迎的,因为我们想要把闹钟用于其他的领域,而 不想为此依赖于使用者。所以,最好把闹钟看作一个小型机器,这个小型机器对它的使用者 一无所知,它仅仅是执行定义于公有界面的行为,而不管发送消息的是谁。 图 2.4 使用闹钟 经验原则 2.3 尽量减少类的协议中的消息。 就在几年前,还有人撰文提倡刚好与这条经验原则相反的实践。当时是这样说的:关于 这个类的操作,凡是类的实现者能想象到的,将来就会有用户用到。那么,既然如此,为什 么不实现这些操作呢?如果你采纳这样的经验原则,那么你肯定会钟爱我的链表类——它的 公有接口有 4 000 个操作。问题时,当你想对两个链表对象执行合并操作时,你认为链表类 一定提供了这个操作,所以你依照字母顺序检查消息列表,但是却找不到哪个操作是以 merge、union、combine 或者你知道的其他同义词命名的。不幸的是,真正的操作是一个重载 的加号(在 C++中是 operator+) 。庞大的公有接口的问题是,你永远都无法找到你想要找的 东西。这严重损害了接口的可复用性。而如果让接口最小化,我们就可以让系统易于理解, 并使组件易于复用。 经验原则 2.4 实现所有类都理解的最基本公有接口[例如,拷贝操作(深拷贝与浅拷贝)、相等性判 断、正确输出内容、从 ASCII 描述解析等等]。 如果一个开发者设计和实现的类要被另一个开发者在其他应用程序中复用,那么提供一 1 个常用的最小公有接口常常很有用。这个最小公有接口包含的功能是人们合理地预期每个类 1 译注:特别是Framework设计尤其如此。很多Framework设计时都在根类中提供了这一最小公有接口(单根继承结构)。
  • 7.
    2.2 消息和方法 17 都会有的。我们可以把这个接口当作了解可复用软件代码中类的行为的基础。我们将在第 9 章中更详细地探讨关于这个最小公有接口的事项。 经验原则 2.5 不要把实现细节(例如放置共用代码的私有函数)放到类的公有接口中。 这条经验原则用于为使用者降低类接口的复杂性。基本想法是,类的使用者不想在公 有接口中看见他们不用的成员。 这些成员属于类的私有区域。如果类的两个方法有一段公 共代码, 那么就可以创建一个防止这些公共代码的私有函数。把这些公共代码封装成一个 独立方法常常会带来方便, 但是这个方法并不是一个新的操作,它只是类中两个操作的实 现细节。因为是实现细节,所以它应当放在类的私有区域中,而不是公共区域中(参见图 2.5)。 图 2.5 公共代码私有函数示例 为了让你对公共代码私有函数有更贴近实际的了解,你可以认为类 X 是一个链表,f1 和 f2 是函数 insert 和 remove,公共代码私有函数 f 是在链表中找到插入点或者删除点位置 的操作。 经验原则 2.6 不要以用户无法使用或不感兴趣的东西扰乱类的公有接口。 这条经验原则与前一条是相关的,因为类的用户不会想调用公共代码函数,所以把 这些函数放在公有接口中只会扰乱类的公有接口。它们并不是类的新操作。有些语言, 比如 C++,允许在公有接口中错误地包含其他类型的函数。例如,在 C++中把抽象类的 构造函数放在那个类的公有接口中是合法的,虽然当类的使用者试图使用这样的构造函 数时编译器会报告一条语法错误。若遵循更一般化的经验原则 2.6,那么这些问题就不 会发生了。
  • 8.
    18 第2章 类和对象:面向对象范型的建材 2.3 类耦合与内聚 一些经验原则用于解决类的耦合与内聚问题。我们努力让类更紧密地内聚,并尽量降低 类间耦合程度。这和在面向动作范型中试图让函数更紧密地内聚并尽量降低函数间的耦合程 度的努力是一致的。函数中的紧密内聚意味着组成函数的所有代码都是紧密相关的。函数间 的松耦合意味着当一个函数想要使用另一个函数时,它应当在总是从同一点进入该函数,并 从同一点退出。这样,我们就可以得出这样的面向动作的经验原则:“函数应当只有一条返回 语句。” 在面向对象范型中,我们把松耦合和紧内聚的目标映射到了类的层次。类之间有 5 种形 式的耦合关系:零耦合(nil coupling)是最佳的,因为这意味着两个类丝毫不依赖于对方。 你可以去掉一个类,而不会影响另一个。当然,如果只用到零耦合,你无法创建有意义的应 用程序。若只用到零耦合,我们最多只能创建类库,这样的类库由一系列的独立类组成,这 些类相互之间没有影响。导出耦合(export coupling)则表明,一个类依赖于另一个类的公有 接口。 1 也就是说,这个类用到另一个类的一个或多个公有操作。授权耦合(overt coupling) 则意味着一个类经允许使用另一个类的实现细节。C++的友元机制是授权耦合的典型例子。 一个C++类X可以声明类Y是它的友元。这样,Y的方法就获得授权可以访问X的实现细节。 自行耦合(covert coupling)和授权耦合差不多,也是类Y访问类X的实现细节,但区别在于 类Y是未经授权的。如果我们发明一种语言机制,允许类Y声明自身是X的友元并且将使用X 的实现细节,那么X和Y就是自行耦合的。最后一种耦合是暗中耦合(surreptitious coupling), 这种耦合是指类X通过某种方式知道了Y的实现细节。如果类X使用类Y的公有数据成员,那 么X就和Y暗中耦合。暗中耦合是最危险的耦合形式,因为它在Y的行为和X的实现之间建立 了很强的隐式依赖关系。 经验原则 2.7 类之间应该零耦合,或者只有导出耦合关系。也即,一个类要么同另一个类毫无关系, 要么只使用另一个类的公有接口中的操作。 所有其他形式的耦合都允许类把实现细节暴露给其他类,这样就在两个类的实现之间建 立了隐含依赖关系。将来如果一个类想要修改它的实现,那么这些隐含依赖关系总会带来维 护问题。 1 译注:在Eiffel中可以显式地声明导出关系。
  • 9.
    2.3 类耦合与内聚 19 类内聚努力确保类内部的所有元素都是紧密关联的。有一些经验原则牵涉到这一属性。 经验原则 2.8 类应当只表示一个关键抽象。 一个关键抽象(key abstraction)被定义成领域模型中的一个主要实体。关键抽象经常以 名词形式出现,并伴随着需求规约。每个关键抽象都应当只映射到一个类。如果它被映射到 多个类,那么设计者可能是把每个功能都表示为一个类了。如果多个关键抽象被映射到了同 一个类,那么设计者可能在创建一个集中化的系统。这些类经常被称为含糊的类(vague classes),并且需要分割成两个或多个类,每个类表示一个关键抽象。第 3 章我们将更详尽地 探讨这两种不良设计。 经验原则 2.9 把相关的数据和行为集中放置。 如果违反这条经验原则,那么开发者就不得不按以往方式编程。为了实现单一的系统需 求,开发者不得不改动系统的两处或者多处。其实这两处(或者多处)是同一个关键抽象, 所以应当用同一个类表示。设计者应当留意那些通过 get 之类操作从别的对象中获取数据的 对象。这种类型的行为暗示着这条经验原则被违反了。考虑一下一个烤炉类的使用者想要在 烧烤之前预热烤炉。用户应当只需要发送给烤炉一条 are_you_preheated?()消息就可以了。烤 炉应当可以测试自己的温度是否已经达到了需要的温度, 并且测试其他预热需要满足的条件。 如果用户为了知道烤炉是否已经预热,需要问烤炉目前温度、期待温度、燃气阀的状态、常 燃火状态等等,那么就违反了这条经验原则。烤炉拥有这些温度和燃气烹饪设备的信息,它 应当自行判断这个对象是否已经预热了。留意那些为了实现不正确的预热方式而需要用到的 get 方法(比如,get_actualtemp()、get_desiredtemp()、get_valvestatus()等等)是很重要的。 经验原则 2.10 把不相关的信息放在另一个类中(也即:互不沟通的行为) 。 开发者应当留意这样的类:方法的一个子集操作数据成员的一个真子集 1 。极端情况是, 一个类有一半方法操作一半数据成员,另一半方法则操作另一半数据成员(见图 2.6)。 1 译注:子集(subset)和真子集(proper subset)的区别在于,一个集合是其本身的子集,但不是其本身的真子集。
  • 10.
    20 第2章 类和对象:面向对象范型的建材 图 2.6 具有互不沟通的行为的类 这是一个更接近现实世界的例子。请考虑词典类。对于小型词典,最好的实现是属性列表 (单词和它们定义的列表),但是对大型词典来说,哈希表更好(更快) 。两种辞典的实现都需 要提供增加单词和寻找单词的能力。图 2.7 展示了一个具有互不沟通的行为的词典类设计。 图 2.7 互不沟通的行为(现实世界例子) 这个解决方案假设词典类的使用者知道词典将会有多大。他们需要做出决定,是使用哈 希表实现的词典还是链表实现的词典。一般而言,在类名中显示实现细节并让用户来做这样 的选择不是好主意。一个更好的解决方案留在第 5 章讲述,因为它要用到继承。在那个解决 方案中,一个单一的词典类把它的实现隐藏为内部细节。如果词典的大小增长到了一个事先 定下的临界值,词典类会决定改变实现。 2.4 动态语义 除了固定的数据和行为的描述之外,对象在运行时还随着其数据描述的动态取值具有局 部状态(即当时的“快照”。类的对象的所有可能状态的集合以及状态间合法的变换称为类 )
  • 11.
    2.4 动态语义 21 的动态语义(dynamic semantics)。动态语义允许对象对其生命期的两个不同时候发来的相同 消息作出不同的回应。例如,看这个抽象例子: Method junk for the class X if (local state #1) then F do something else if (local state #2) then do something different End Method 对象的动态语义是任何面向对象设计的有机组成部分。一般而言,任何具有有意义动态语 义的类都应当用一个状态转换图(参见图 2.8)来把这些动态语义归档。具有有意义动态语义 的类是指具有有限状态和精确定义的状态变换的类。图 2.9 所示的状态转换图详细描述了某个 操作系统中进程的动态语义。它表明,进程的状态可以是就绪、当前进程、阻塞、睡眠和退出。 此外,进程创建时只能是就绪状态,它们只能在退出状态被销毁,它们只能在当前进程状态时 图 2.8 状态转换图表示法 图 2.9 某种操作系统中过程的状态转换
  • 12.
    22 第2章 类和对象:面向对象范型的建材 才能退出。这些信息对于为类及其对象创建测试集(test suite)非常有用。有些设计者偶尔会 把动态语义建模成静态语义, 这会导致类的数目大量膨胀——这是面向对象范型中的一个严重 问题。我们将在第 5 章讨论继承关系的时候探讨这个问题以及避免这个问题的方法。 2.5 抽象类 除了我们已经讨论过的类,还有一种重要的抽象类型是我们需要探讨的。请思考下列问 题:你曾经吃过水果吗?你曾经吃过开胃菜吗?你曾经吃过甜点吗?很多人对这 3 个问题的 答案都是“是”。只要你对这 3 个问题中的任一个回答了“是”,请你接着思考下面的问题: 水果尝起来味道如何?一份甜点有多少卡路里的热量?一份开胃菜价格是多少? 我可以说,没有人吃过“水果”。很多人吃过苹果、香蕉或者桔子,但没有人吃过一个 3 斤重的、红色的就叫做“水果”的东西。类似地,当你坐在餐厅中,服务员走来问你想吃些 什么时,你回答“一份开胃菜、一份主菜还有一份甜点”,如果这时服务员就转身走了,你就 有麻烦了,因为你喜欢虾,而不喜欢瓜(两种可能的开胃菜)。我们认可,没有“水果”“开 、 胃菜”或者“甜点”这样的对象,但是这些名词确实表达了有用的信息。如果我拿起一只闹 钟对你说:“你觉得我的水果怎么样?”你会认为我疯了;而如果我拿起一只苹果问同样的问 题,你就会觉得很正常。“水果”这个称谓表达了有用的信息,虽然你不能创建水果对象。事 实上,它是一个类(概念),但不知道如何实例化它这种类型的对象。 不知道如何实例化对象的类称为抽象类(abstract class)。 知道如何实例化对象的类称为具体类(concrete class)。 请留心我们经常使用的术语“抽象数据类型”(ADT)。有的时候,它被用作“类”的同 义词,并且不区分抽象类和具体类。 在面向对象范型中 ,抽象类的一个重要用途是帮助创建继承层次结构。它们表达了类 别名称(见图 2.10)。我们将在第 5 章讨论它们的用处。 图 2.10 类表示类属信息
  • 13.
    2.6 角色与类 23 2.6 角色与类 经验原则 2.11 确保你为之建模的抽象概念是类,而不只是对象扮演的角色。 “母亲”或者“父亲”是不是类,还是某个“人”对象所扮演的角色?答案取决于设计 者为之建模的领域是什么。如果在给定的领域中,母亲和父亲具有不同的行为,那么或许他 们应当被建模为类。如果他们的行为相同,那么他们只是“人”类的对象所扮演的不同角色。 例如,我们可以把家庭看作“父亲”类的对象、“母亲”类的对象和几个“子女”类的对象所 构成的对象,也可以把家庭看作一个称为“父亲”的“人”对象、一个称为“母亲”的“人” 对象和一组称为“子女”的“人”对象构成的对象(参见图 2.11)。区分只在于不同的行为。 在创建不同的类之前,请确保它们的行为确实是不同的,而不是每个角色只使用“人”的能 力的一个子集。请记住,一个对象只用到它的类的行为的一个子集是毫无问题的。 图 2.11 一个家庭的两种视图 有些设计者的做法是,测试一下公有接口中有没有哪个成员对于特定的角色无法使用。 如果有这样的成员,那么就意味着需要另一个类。如果它只是没有被用到,那么它只是被用 作多个角色的同一个类。例如,如果“母亲”的一个操作是 go_into_labor()(分娩),那么“父 亲”最好实现为另一个独立的类,因为父亲是无法分娩的。但是,如果这个家庭生活在一个 父系社会中,只有母亲才会执行 change_diaper()(换尿布)方法,那么“母亲”只是“人” 类所扮演的一个角色。得出这一结论的理由是因为如果有必要的话,父亲也可以执行 change_diaper()方法。但是,在更抽象的领域,若那个领域中“无法执行”与设计者或者领域 选择“不去执行”的差异并不明显,那么这种方法就难以奏效了。 在设计过程中,面向对象设计者需要决定是否把一个特定的角色塑造成一个类。这就意 味着我们还需要一条经验原则来指导这一决定。下面的章节将尝试给出这样的经验原则,但
  • 14.
    24 第2章 类和对象:面向对象范型的建材 我对结果并不完全满意,因为这条经验原则并不是在所有领域中都适用的。 术语表 Abstract class 抽象类。不知道如何实例化自身对象的类。 Class 类。以双向联系的方式封装数据和行为的构造。与现实世界中的一个概念对应。抽象数 据类型(ADT)是类的同义词。 Concrete class 具体类。知道如何实例化自身对象的类。 Constructor 构造函数。类的一个特殊的操作,负责创建/初始化该类的对象。 Destructor 析构函数。类的一个特殊的操作,负责销毁/清除该类的对象。 Dynamic semantic 动态语义。类的对象所能具有的所有可能状态,以及这些状态之间被允许的转换的集合。 常用状态转换图来表示。 Information hiding 信息隐藏。类向该类的对象的使用者隐藏它的实现细节的能力。 Instantiation relationship 实例化关系。类和它的对象之间的关系。我们说类实例化对象。 Key abstraction 关键抽象。关键抽象被定义成领域模型中的一个主要实体。关键抽象经常表现为领域词 汇中的一个名词。 Message 消息。类中定义的操作的名称。在强类型语言中,消息可以包含名称、返回类型以及操 作参数类型(也即操作的原型)。
  • 15.
    经验原则小结 25 Method 方法。消息的实现。 Object 对象。属于它的类的一个样例,包含它自己的标识、类的行为、类的接口、类的数据的 一份拷贝。也称为类的实例。 Overloaded function 重载函数。系统中的两个函数可以有相同的名字的能力,只要它们的参数类型不同(类 内重载)或者所属的类不同(类间重载)。 Protocol 协议。类能响应的消息列表。 Self object Self 对象。控制位于方法内部时,接受消息的对象的引用。 经验原则小结 经验原则 2.1 所有数据都应当隐藏在它所在的类内部。 经验原则 2.2 类的使用者必须依赖类的公有接口,但类不能依赖它的使用者。 经验原则 2.3 尽量减少类的协议中的消息。 经验原则 2.4 实现所有类都理解的最基本公有接口[例如,拷贝操作(深拷贝与浅拷 贝)、相等性判断、正确输出内容、从 ASCII 描述解析等等] 。 经验原则 2.5 不要把实现细节(例如放置共用代码的私有函数)放到类的公有接口中。 经验原则 2.6 不要以用户无法使用或不感兴趣的东西扰乱类的公有接口。 经验原则 2.7 类之间应该零耦合,或者只有导出耦合关系。也即,一个类要么同另一 个类毫无关系,要么只使用另一个类的公有接口中的操作。 经验原则 2.8 类应当只表示一个关键抽象。 经验原则 2.9 把相关的数据和行为集中放置。 经验原则 2.10 把不相关的信息放在另一个类中(也即:互不沟通的行为)。 经验原则 2.11 确保你为之建模的抽象概念是类,而不只是对象扮演的角色。