3.1 对象的本质
识别物理对象的能力是人类在早期就掌握的技能。一个色彩鲜艳的球会吸引婴儿的注意力,但是通常情况下,如果把球藏起来,小孩不会试图去寻找它。当对象离开了她的视野时,根据她的判断,这个对象就不存在了。一般直到接近一岁时,小孩才会建立起所谓的“对象概念”,这种能力对于将来认知的发展相当重要。向一个一岁小孩展示一个球,然后再藏起来,她通常会寻找这个球,即使这个球不在视野之中。通过对象的概念,小孩意识到对象具有持久性和标识符,这与施加在对象上的操作无关[1]。
3.1.1 什么是对象,什么不是对象
第1章中,我们非正式地将对象定义为一个可以触摸的实体,展示了一些定义良好的行为。从人类认知的角度来看,对象可以是下列事物之一。
■ 一个可以触摸或可以看见的东西;
■ 在智力上可以理解的东西;
■ 可以指导思考或行动的东西。
我们对非正式的定义进行了补充,一个对象反映了某一部分的真实存在,因此它是在时间和空间中存在的某种东西。在软件中,“对象”这个术语首先正式出现在Simula语言中,对象通常存在于Simula程序中,用于模拟真实世界的某个方面[2]。
我们在软件开发过程中关注的对象不仅仅是真实世界中的对象。在设计过程中,我们创造出一些重要的对象,它们与其他对象的协作形成了一些机制,从而提供某种更高级的行为[3]。Jacobson等人将“控制对象”定义为“将事件过程组织起来的对象,负责与其他对象的通信”[62]。这导致了Smith和Tockey的更明确的定义,他们认为,“一个对象代表了单个可识别的项、单元或实体,它可以是真实的,也可以是抽象的,在问题域中承担定义良好的角色”[4]。
考虑一个制造工厂,它处理各种材料,制造多种产品,如自行车架和飞机机翼。制造工厂通常被划分成独立的车间:机械、化工、电子等。车间又进一步划分成单元,每个单元中放着一组机器设备,如模压机、锻压机、车床等。在制造生产线上,我们可以看到装着原材料的桶。这些原材料用在一些化学处理过程中,制备一些复合材料,这些复合材料又经过成型,用于生产自行车架、飞机机翼和其他一些部件。前面提到的这些可触摸的东西都是对象。车床定义了一个明确的边界,将它与它处理的复合材料区分开来。自行车架定义了一个明确的边界,将它与生产自行车架的机器区分开来。
某些对象可能有明确的概念边界,但其代表的是不可触摸的事件或过程。例如,在制造工厂中的一个化学处理过程可能被作为一个对象,因为它具有明确的概念边界,通过一组有序的操作,在不同的时间与某些其他对象打交道,并展示出定义良好的行为。类似地,考虑一个对实心体建模的CAD/CAM系统。有一个立方体和一个球体相交,它们的相交线是一条不规则的曲线。虽然它离开了球体或立方体就不存在,但这条线仍是一个对象,它有明确定义的概念边界。
一个对象具有状态,展示某种定义良好的行为,具有唯一的标识符
某些对象可能是可触摸的,但是物理边界不太清晰。像河流、雾、人群就属于这种类型的对象。[1]就像一个人拿着锤子就喜欢把世界上所有的东西看作钉子一样,具有面向对象思想的开发者开始认为世界上所有的东西都是对象。这种观点有点幼稚,因为某些东西显然不是对象。例如,美和色彩这样的属性就不是对象,爱和恨这样的感情也不是对象。但是,这些东西有可能成为其他对象的属性。例如,我们可以说一个男人(一个对象)爱他的妻子(另一个对象),或者说某只猫(又一个对象)是灰色的。
因此,说对象是有明确定义的边界的东西是有意义的,但还不足以让我们能够区分不同的对象,也无法让我们判断这种抽象的品质。根据我们的经验,建议使用下面的定义:
“对象是一个具有状态、行为和标识符的实体。结构和行为类似的对象定义在它们共同的类中。‘实例’和‘对象’这两个术语可以互换使用。”
在接下来的3.1.2节中将会更详细地讨论状态、行为和标识符的概念。
3.1.2 状态
考虑一个分发软饮料的自动售货机。这类对象的一般行为是当某人在投币口塞进钱,并按下选择按钮时,机器就会提供一种饮料。如果用户先选择饮料再向投币口塞钱会怎样?大部分自动售货机什么也不会干,因为用户违反了它们基本的操作假定。换言之,用户没有注意到(先进行了选择)自动售货机处于一种状态(等待塞钱)。类似地,假设用户忽略了警示灯上说的“只收准确的零钱”,而放入了多余的钱。大多数机器不会为用户着想,它们会很高兴地吞下多余的钱。
在这些情况下,可以看到对象的行为如何受到它的历史的影响:人们操作一个对象的次序是重要的。这种行为依赖事件或依赖时间的原因在于对象内部存在状态。例如,自动售货机的一个基本状态是用户塞入了一定数量的钱,但还没有进行选择。其他重要的属性包括可找的零钱数和软饮料的数量。
从这个例子中,我们可以得到下面的基本定义:
“对象的状态包括这个对象的所有属性(通常是静态的)以及每个属性当前的值(通常是动态的)。”
自动售货机的另一个属性是它可以接受钱币。这是一个静态(即固定)属性,意味着这是自动售货机的一项基本特征。与之相对的是,某一时刻它实际接受的金额代表着这个属性的动态值,它受到对机器操作次序的影响。当用户塞入钱币时,这个数量就增加了;当提供产品之后,这个数量就减少了。说这些值“通常是动态的”,是因为在某些情况下这些值是静态的。例如,一台自动售货机的流水号就是一个静态的属性和值。
属性是一种内在或独特的特征、特点、品质或特性,使一个对象区别于别的对象。例如,电梯的一个基本属性就是它只能够上下运动,而不能水平运动。属性通常是静态的,因为这样的特征是不可更改的,是对象的根本本质。说“通常是静态的”,是因为在某些情况下,对象的属性会改变。例如,考虑一个能从环境中学习的机器人。它可能开始将一个出现的对象当成是一个固定的障碍物,后来却发现这个对象是一个可以打开的门。在这种情况下,随着知识的增长,机器人在构建世界的概念模型时创建的这个对象获得了新的属性。
所有的对象都有某种值,这个值可能是一个简单的数量,也可能代表另一个对象。例如,电梯的状态可能包括一个值3,这表示电梯当前所处的楼层。在自动售货机的例子中,它的状态包含许多其他对象,如一些软饮料。每个软饮料实际上都是不同的对象,它们的属性与自动售货机的属性不同(它们可以被喝掉,而自动售货机不可以),而且它们操作方式也完全不同。因此,我们可以区分对象和简单的值:像数字3这样简单的数量是“不受时间影响的、不可变化的、不可实例化的”,而对象是“有存在时间的、可以变化的、有状态的、可以实例化的,可以被创建、销毁和共享”[6]。
每个对象都有状态。这一事实意味着,每个对象都会在物理世界或计算机内存中占据一定的空间。
可以说,系统中所有的对象都封装了某种状态,系统中所有的状态都由对象所封装。封装一个对象的状态只是开始,这并不足以让我们刻画出在开发过程中发现的这种抽象的全部含义(请参考示例3-1,它展示了一个简单的抽象如何演化)。出于这个原因,必须考虑对象的行为。
示例3-1
考虑一个雇员记录的抽象。图3-1用统一建模语言(UML)的类表示法展示了这个抽象。(关于UML表示法的更多内容,请参见第5章。)
这个抽象的每一部分都代表了雇员抽象的一种属性。这个抽象不是一个对象,因为它没有代表某个特定的实例。当在具体化时,例如,我们有两个不同的对象——Tom和Kaitlyn,每个对象都在内存中占据了一定的空间(参见图3-2)。
图3-1 Employee类和属性
图3-2 Employee对象Tom和Kaitlyn
这些对象不会与其他对象共享空间,虽然它们都有相同的属性。它们的状态具有共同的表现形式。
与暴露对象的状态相比,封装对象的状态是一项好的工程实践。例如,可以把这个抽象(类)修改成如图3-3所示的样子。
图3-3 具有保护属性和公有操作的Employee类
这个抽象比前一个更复杂一些,但出于某些原因,它更好一些。具体一点说,它的内部表示形式被隐藏了(保护属性,用#表示),外部客户不能访问。如果要修改它的表示形式,需要重新编译一些代码,但是从语义上说,外部客户不会受到这种修改的影响(换言之,原有的代码不会被破坏)。
同时,通过显式地声明客户可以对这个类的对象进行的某些操作(职责),我们记录了关于问题空间的某些决定。具体来说,我们让所有的客户(公有的,用+表示)有权取得雇员的name(姓名)、social security number(社会保险号)、department(部门)。本章稍后将讨论可见性(即公有、保护、私有和包可见性)。
3.1.3 行为
没有对象是孤立存在的。对象与对象之间会相互操作。因此,我们可以这样说:
“行为是对象在状态改变和消息传递方面的动作和反应的方式。”
换言之,对象的行为代表了它外部可见的活动。
操作是某种动作,一个对象对另一个对象执行这个操作,目的是获得反应。例如,客户可能调用append和pop操作,分别使一个队列对象增长或缩减。客户也可能调用length操作,它返回一个值,表示队列对象的大小,但不会改变队列本身的状态。
在Java中,客户可以在一个对象上执行的操作通常被声明为方法。像C++这样的语言来自于更过程化的早期语言,在这些语言中,我们说一个对象调用另一个对象的成员函数。在Smalltalk这样的纯面向对象的语言中,我们说一个对象向另一个对象传递一个消息。一般来说,一个消息就是一个对象执行了另一个对象的操作,虽然底层的分发机制是不一样的。方便起见,我们互换使用“操作”和“消息”这两个术语。
消息传递只是定义对象行为的一个方面,我们对行为的定义还指出,对象的行为会受到其状态的影响。考虑自动售货机的例子。我们可以调用某个操作选择商品,但自动售货的行为取决于它的状态。如果没有塞进足够购买所选商品的钱,自动售货机可能什么都不会做。如果我们提供了足够的零钱,自动售货机会收下零钱,然后给出我们选择的商品(然后改变它的状态)。因此可以说,一个对象的行为是它的状态以及施加在它上面的操作的函数。这个副作用的概念让我们改进了状态的定义:
“一个对象的状态代表了它的行为的累积效果。”
大部分有意思的对象没有静态状态,它们的状态包含了一些属性,这些属性的值在对象活动时被修改并被查询。一个对象的行为包括了其操作的总和。接下来将讨论操作、它们与对象职责的关系以及它们怎样让对象实现其职责。
1.操作
一个操作代表了一个类提供给它的对象的一种服务。在实践中,我们发现一个客户通常执行一个对象的五种操作。[2]其中三种最常见的操作如下。
■ 修改操作:更改一个对象的状态的操作。
■ 选择操作:访问一个对象的状态但并不更改这个状态的操作。
■ 遍历操作:以一种定义良好的方式访问一个对象的所有部分的操作。
另外两种操作是公共的,它们体现了创建和销毁一个类的实例的需要。
■ 构造操作:创建一个对象并初始化它的状态的操作。
■ 析构操作:释放一个对象的状态并销毁对象本身的操作。
在 C++中,构造操作和析构操作是作为类定义的一部分被定义的。但在Java中,只有构造操作,没有析构操作。在Smalltalk中,这样的操作通常是元类(即类的类)协议的一部分。
2.角色和职责
一个对象的所有方法共同构成了它的协议。因此,一个对象的协议定义了对象允许的行为的封装,构成了这个对象完整的静态视图和动态视图。对于大多数有用的抽象来说,将这个较大的协议分成逻辑上的行为分组是有意义的。这些分组划分了对象的行为空间,表明了一个对象可以扮演的角色。角色是一个对象戴上的一个面具[8],它定义了一种抽象与它的客户之间的契约。
对象可以扮演许多不同的角色
“职责意味着表达对象的一种目标以及它在系统中的位置。一个对象的职责是它为支持的所有契约提供的全部服务。”[9]换言之,可以说一个对象的状态和行为共同决定了这个对象可以扮演的角色,这又实现了这种抽象的职责。
实际上,大多数有意思的对象在它们的生命周期中扮演许多不同的角色。考虑下面的例子[10]。
■ 一个银行账户可能扮演一个现金资产的角色,账户的所有者可以向它存钱或者从它取钱。但是,对于税务检查来说,这个账户可能扮演这样一个角色,即每年都必须报告它的股息。
■ 对于一个交易者,一股股票代表了一个有价值的实体,可以买进或卖出。对于律师,同样的股票代表了一种法律手段,它包含了某种权利。
■ 在一天中,同一个人可能扮演母亲、医生、园丁和电影评论员的角色。
对象扮演的角色是动态的,同时又是互斥的。在股票的例子中,它的角色有些重叠,但每个角色都固定地与和这股股票打交道的客户有关。在人的例子中,她的角色非常动态,不时地在转换。
对象在它们的生命周期中扮演许多不同的角色
在后面的章节中将讨论到,我们常常从检查对象扮演的不同角色开始分析问题。在设计时,我们细化这些角色,设计出特定的操作,实现每个角色的职责。
3.对象像自动机
对象中存在状态,这意味着操作调用的次序非常重要。从而,导致了这样一种思想,即每个对象就像一个微小的、独立的自动机[11]。实际上,对于某些对象来说,这种事件和操作的时间顺序非常普遍,所以我们可以很好地将这种对象的行为总结为一个等价的有限状态自动机。第5章中将展示一种层次结构的有限状态自动机的表示法,我们可以利用自动机来表达这些语义。
继续自动机的比喻,我们可以区分对象是主动的还是被动的。主动的对象有自己的控制线程,而被动的对象则没有。主动的对象通常是自动的,这意味着它们不需要由其他对象操作,就能表现出一些行为。而对于被动对象来说,只有在显式地操作它时,才会发生状态变化。从这个角度来看,我们系统中的主动对象是控制的中心。如果系统包含多个控制线程,通常会有多个主动对象。串行式的系统,通常只有一个主动对象,例如,有一个主要的对象负责管理事件循环分发消息。在这样的架构中,所有其他的对象都是被动的,它们的行为最终是由那个主动对象发出的消息触发的。在其他类型的串行式系统架构中(如事务处理系统),没有明显的核心主动对象,所以控制一般分散在系统的被动对象中。
3.1.4 标识符
Khoshafian和Copeland提出了这样的标识符定义:“标识符是一个对象的属性,它区分这个对象与其他所有对象。”[12]
他们继续注解道,“大多数程序设计语言和数据库语言使用变量名称来区分临时对象,混淆了定址能力和标识符。大多数数据库系统使用标识符主键来区分持久对象,混淆了数据值和标识符。”不能够区分对象的名称和对象本身,这导致了面向对象编程中的许多错误。
示例3-2展示了维护创建的对象的标识符的重要性,并展示了标识符非常容易丧失而无法恢复。
示例3-2
考虑代表一个显示项的类。一个显示项是所有以GUI为中心的系统中的一种共同抽象:它是所有在某个窗口中显示的对象的基类,所以记录了所有这类对象公有的结构和行为。客户希望能够画出、选择并移动显示项,并查询它们被选择的位置和状态。每个显示项都有一个位置,由坐标X和Y标出。
假定我们实例化了一些DisplayItem类,如图3-4a)所示。具体来说,我们实例化这些类的操作在内存中占据了四个位置,它们的名称分别是item1、item2、item3和item4。这里,item1是一个独立的DisplayItem对象的名称,但其他三个名称是指向DisplayItem对象的指针。只有item2和item3真正指向了独立的DisplayItem对象(因为在它们的声明中,我们分配了新的DisplayItem对象),item4有意地没有指向任何对象。而且,item2和item3所指向的对象是匿名的,我们只能间接地通过它们的指针访问这些对象。
每个对象的唯一标识符(不一定是名称)是在对象的整个生命周期中都被保持的,即使它的状态改变时也是如此。这有点像禅宗的河流问题:即使河中流淌的水已经不一样了,明天的河流还是今天的河流吗?例如,让我们移动item1。我们可以访问item2所指的对象,取得它的位置,然后将item1移到同样的位置。
图3-4 对象标识符
另外,如果让item4等于item3,就可以利用item4来引用item3所指向的对象。利用item4,可以将这个对象移动到一个新的位置,例如,X=38,Y=100。图3-4b)展示了这些结果。这里我们看到,item1和item2所指的对象具有同样的位置状态,item4和item3现在指向了同一个对象。请注意,我们说“item2所指的对象”,而不说“对象item2”。前一种方式更准确,虽然有时候我们互换使用这两种说法。
虽然item1和item2所指的对象具有相同的状态,但它们代表不同的对象。另外请注意,我们通过新的间接名称item4进行操作,改变了item3所指的对象的状态。这就是所谓的“结构共享”,意思是对某个对象可以用多种方式命名。换言之,一个对象有许多别名。结构共享是面向对象编程中许多问题的根源。如果没有意识到通过别名来操作一个对象的副作用,常常会导致内存泄漏、非法内存访问甚至更糟糕的、未预期的状态改变。例如,我们销毁了item3所指的对象,那么item4的指针值就没有意义了,这种情况称为“悬空的引用(dangling reference)”。
考虑图3-4c),它展示了修改item2指针的值,令它指向item1的结果。现在item2指向了item1对象。不幸的是,我们引入了内存泄漏:原来item2所指的对象不再有名称了,既没有直接名称,也没有间接名称,所以它的标识符被遗失了。在Smalltalk和Java这样的语言中,这样的对象会被垃圾收集,它们的空间会被自动回收;但在C++这样的语言中,它们的存储空间不会被释放,直到创建它们的程序终止运行为止。尤其对于长时间运行的程序来说,这样的内存泄漏要么引起麻烦,要么带来灾难。[3]