收藏 分享(赏)

第一章 对象引论.doc

上传人:wspkg9802 文档编号:7637254 上传时间:2019-05-22 格式:DOC 页数:33 大小:124.50KB
下载 相关 举报
第一章 对象引论.doc_第1页
第1页 / 共33页
第一章 对象引论.doc_第2页
第2页 / 共33页
第一章 对象引论.doc_第3页
第3页 / 共33页
第一章 对象引论.doc_第4页
第4页 / 共33页
第一章 对象引论.doc_第5页
第5页 / 共33页
点击查看更多>>
资源描述

1、第一章 对象引论“我们之所以将自然界分解,组织成为各种概念,并总结出其重要性,主要是因为我们知道我们的语言社区所共同持有的,并以我们的语言的形式所固定下来的一种约定除非赞成这个约定中所颁布的有关数据的组织和分类的内容,否则我们根本无法交谈。 ”Benjamin Lee Whorf(1897-1941) 计算机革命起源于机器,因此,编程语言的起源也始于对机器的模仿趋近。但是,计算机并非只是机器那么简单。计算机是头脑延伸的工具(就象 Steven Jobs 常喜欢说的“头脑的自行车”一样) ,同时还是一种不同类型的表达媒体。因此,这种工具看起来已经越来越不像机器,而更像我们头脑的一部分,以及一种诸

2、如写作、绘画、雕刻、动画、电影等的表达形式一样。面向对象程序设计(Object-oriented Programming, OOP)便是这种以计算机作为表达媒体的大潮中的一波。本章将向您介绍包括开发方法概述在内的 OOP 的基本概念。本章,乃至本书中,都假设您在过程型编程语言(Procedural Programming Language)方面已经具备了某些经验,当然不一定必须是 C。如果您认为您在阅读本书之前还需要在编程以及 C 语法方面多做些准备,您可以研读本书所附的培训光盘“Java 基础(Foundations for Java) ”。本章介绍的是背景性的和补充性的材料。许多人在没有了

3、解面向对象程序设计的全貌之前,感觉无法轻松自在地从事此类编程。因此,此处将引入众多的概念,以期助您建立对 OOP的扎实全面的见解。然而,还有些人可能直到在看到运行机制的某些实例之前,都无法了解面向对象程序设计的全貌,这些人如果没有代码在手,就会陷于困境并最终迷失方向。如果您属于后面的这个群体,并且渴望尽快获取 Java 语言的细节,那么您可以越过本章在此处越过本章并不会妨碍您编写程序和学习语言。但是,您最终还是会回到本章来填补您的知识,这样您才能够了解到为什么对象如此重要,以及怎样使用对象来进行设计。抽象过程所有编程语言都提供抽象(abstraction)机制。可以认为,你所能够解决的问题的复

4、杂性直接取决于抽象的类型和质量。我所谓的“类型”是指“你所抽象的是什么? ”汇编语言是对底层机器的小型抽象。接着出现的许多所谓“命令式(Imperative) ”语言(诸如 FORTRAN、BASIC、 C 等)都是对汇编语言的抽象。这些语言在汇编语言之上有了大幅的改进,但是它们所作的主要抽象仍要求你在解决问题时要基于计算机的结构,而不是基于你试图要解决的问题的结构来考量。程序员必须建立在机器模型(Machine Model) (位于你对问题建模所在的解空间(Solution Space)内,例如计算机)和实际待解问题模型(Problem Model) (位于问题所在的问题空间(Problem

5、 Space)内)之间的关联。建立这种映射(Mapping)是费力的,而且它不属于编程语言的内在性质,这使得程序难以编写,并且维护代价高昂。由此,产生了完整的“编程方法(Programming Method) ”产业。另一种对机器建模的方式就是对待解决问题建模。早期的编程语言,诸如 LISP 和 APL 都选择世界的某种特定视图(分别对应于“所有问题最终都是列表(List) ”或者“所有问题都是算法形式的(algorithmic) ”) 。PROLOG 则将所有问题都转换成为决策链(Chain of decisions) 。此外还产生了基于约束条件(constraint-based)编程的语言

6、和专门通过对图形符号操作来实现编程的语言(后者被证明限制性过强) 。这些方式对于它们被设计时所瞄准要解决的特定类型的问题都是不错的解决方案,但是一旦超出其特定领域,它们就力不从心了。面向对象方式(Object-oriented approach)通过向程序员提供用来表示在问题空间中的元素的工具而更进一步。这种表示方式具有足够的概括性,使得程序员不会受限于任何特定类型的问题。我们将问题空间中的元素及其在解空间中的表示成为“对象(Object) ”。(你还需要一些无法类比为问题空间元素的对象) 。这种思想的实质是:程序可以通过添加新类型的对象使自身适用于某个特定问题。因此,当你在阅读描述解决方案的

7、代码的同时,也是在阅读问题的表述。相比以前我们所拥有的所有语言,这是一种更灵活和更强有力的语言抽象。 所以,OOP 允许以问题的形式来描述问题,而不是以执行解决方案的计算机的形式来描述问题。但是它仍然与计算机有联系:每个对象看起来都有点像一台微型计算机它具有状态,并且能够执行你赋予它的各种操作。如果要在现实世界中对对象作类比,那么说它们都具有特性(Characteristic)和行为(Behavior)似乎不错。 Alan Kay 曾经总结了第一个成功的面向对象语言,同时也是 Java 赖为根基的语言之一的Smalltalk 的五个基本特性,这些特性表现了一种纯粹的面向对象程序设计方式: 1.

8、万物皆为对象。将对象视为奇特的变量,它可以存储数据,除此之外,你还可以要求它在自身上执行操作。理论上讲,你可以抽取待解问题的任何概念化构件(狗、建筑物、服务等) ,将其表示为程序中的对象。 2.程序是对象的集合,它们彼此通过发送消息来调用对方。要想产生一个对对象的请求,就必须对该对象发送一条消息。更具体地说,你可以把消息想象为对某个特定对象的方法的调用请求。3.每个对象都拥有由其它对象所构成的存储。你可以通过创建包含现有对象集合的包的方式来创建新类型的对象。因此,你可以在程序中构建复杂的体系,同时将其复杂性通过对象的质朴性得以屏蔽。 4.每个对象都拥有其类型(Type) 。按照通用的说法, “

9、每个对象都是某个类(Class)的一个实例(Instance) ”,其中“类”就是“类型”的同义词。每个类中最重要的区别于其它类的特性就是“你可以发送什么消息给它?” 5.某一特定类型的所有对象都可以接收(Receive)同样的消息。这是一句意味深长的表述,你在稍后便会看到。因为“圆形(circle) ”类型的对象同时也是“几何形(shape) ”类型的对象,所以一个“圆形”对象必定能够接受(accept)发送给“几何形”对象的消息。这意味着你可以编写与“几何形”交互并自动处理所有与几何形性质相关的事物的的代码。这种“可替代性(substitutability) ”是 OOP 中最强有力的概念

10、之一。 Booch 提出了一个对对象的更加简洁的描述:对象拥有状态(State) 、行为(Behaviour)和标识(Identity)。这意味着每一个对象都可以拥有内部数据(它们给出了该对象的状态)和方法(它们产生行为) ,并且每一个对象都可以唯一地与其他对象相区分开,具体说来,就是每一个对象在内存中都有一个唯一的地址。1 某些编程语言的设计者认为面向对象编程本身不足以轻松地解决所有编程问题,所以他们提倡将不同的方式结合到多重聚和编程语言中(multipleparadigm programming language) 。您可以查阅 Timothy Budd 的 Multipleparadig

11、m Programming in Leda 一书(Addison-Wesley 1995) 2 这确实显得有一点过于受限了,因为对象可以存在于不同的机器和地址空间中,它们还可以被存储在硬盘上。在这些情况下,对象的标识就必须由内存地址之外的某些东西来确定了。每个对象都有一个接口亚里士多德大概是第一个深入研究类型(Type)的哲学家,他曾提出过鱼类和鸟类(the class of fishes and the class of birds)这样的概念。所有的对象都是唯一的,但同时也是具有相同的特性和行为的对象所归属的类的一部分,这种思想被直接应用于第一个面向对象语言 Simula-67,它在程序中

12、使用基本关键词 class 来引入新的类型。 Simula,就像其名字一样,是为了开发诸如经典的“银行出纳员问题(Bank teller problem) ”这样的仿真程序而创建的。在银行出纳员问题中,有出纳员、客户、账户、交易和货币单位等许多“对象” 。在程序执行期间具有不同的状态而其他方面都相似的对象会被分组到对象的类中,这就是关键词 class 的由来。创建抽象数据类型(类)是面向对象程序设计的基本概念之一。抽象数据类型的运行方式与内置(built-in)类型几乎完全一致:你可以创建某一类型的变量(按照面向对象的说法,称其为对象或实例) ,然后操作这些变量(称其为发送消息或请求;你发送消

13、息,对象就能够知道需要做什么) 。每个类的成员(member)或元素(element)都共享相同的性质:每个账户都有结余金额,每个出纳都可以处理存款请求等。同时,每个成员都有其自身的状态:每个账户都有不同的结余金额,每个出纳都有自己的名称。因此,出纳、客户、账户、交易等都可以在计算机程序中被表示成为唯一的实体(entity) 。这些实体就是对象,每一个对象都属于定义了特性和行为的某个特定的类。所以,尽管我们在面向对象程序设计中实际所作的是创建新的数据类型,但事实上所有的面向对象程序设计语言都使用 Class 关键词来表示数据类型。当你看到类型(Type)一词时,请将其作为类(Class)来考虑

14、,反之亦然。 3 既然类被描述成了具有相同特性(数据元素)和行为(功能)的对象集合,那么一个类就确实是一个数据类型,就像所有浮点型数字具有相同的特性和行为集合一样。二者的差异在于,程序员通过定义类来适应问题,而不再被强制只能使用现有的被设计用来表示在机器中的存储单元的数据类型。你可以根据需求,通过添加新的数据类型来扩展编程语言。编程系统欣然接受新的类,并且给予它们与内置类型相同的管护和类型检查(Type-checking) 。面向对象方法并不是仅局限于构件仿真程序。无论你是否同意任何程序都是你所设计的系统的一个仿真的观念,面向对象技术确实可以将大量的问题降解为一个简单的解决方案。一旦类被建立,

15、你想要创建该类的多少个对象,就可以创建多少个了,然后去操作它们,就像它们是存在于你的待解问题中的元素一样。事实上,面向对象程序设计的挑战之一,就是在问题空间的元素和解空间的对象之间创建一对一的映射。但是,你怎样才能获得对你有用的对象呢?必须有某种方式产生对对象的请求,使对象完成诸如完成一笔交易、在屏幕上画图、打开开关之类的任务。每个对象都只能满足某些请求,这些请求由对象的接口(Interface)所定义,决定接口的便是类型(Type) 。以电灯泡为例来做一个简单的比喻: 3 有些人对此还是区别对待的,他们声称类型决定了接口,而类是该接口的一个特定实现。Light lt = new Light(

16、);lt.on(); 接口定义了你能够对某一特定对象发出的请求。但是,在程序中必须有满足这些请求的代码。这些代码与隐藏的数据一起构成了实现(implementation) 。从过程型编程的观点来看,这并不太复杂。在类型中,每一个可能的请求都有一个方法与之相关联,当你向对象发送请求时,与之相关联的方法就会被调用。此过程通常被总结为:你向某个对象发送消息(产生请求) ,这个对象便知道此消息的目的,然后执行对应的程序代码。上例中,类型/类的名称是 Light,特定的 Light 对象的名称是 lt,你可以向 Light 对象发出的请求是:打开它、关闭它、将它调亮、将它调暗。你以这种方式创建了一个 L

17、ight对象:定义这个对象的“引用(reference) ”(lt) ,然后调用 new 方法来创建该类型的新对象。为了向对象发送消息,你需要声明对象的名称,并以圆点符号连接一个消息请求。从预定义类的用户观点来看,这些差不多就是用对象来进行设计的全部。前面的图是 UML(Unified Modelling Language)形式的图,每个类都用一个方框表示,类名在方框的顶部,你所关心的任何数据成员(data member)都描述在方框的中间部分,方法(隶属于此对象的,用来接收你发给此对象的消息的函数)在方框的底部。通常,只有类名和公共方法(Public Method)被示于 UML 设计图中,

18、因此,方框的中部并不绘出。如果你只对类型感兴趣,那么方框的底部甚至也不需要被绘出。每个对象都提供服务当你正是如开发或理解一个程序设计时,最好的方法之一就是将对象想象为“服务提供者(Service Provider) ”。你的程序本身将向用户提供服务,它将通过调用其它对象提供的服务来实现这一目的。你的目标就是去创建(或者最好是在现有代码库中寻找)能够提供理想的服务来解决问题的对象集合。着手从事这件事的方式之一是询问“如果我可以将问题从表象中抽取出来,那么什么样的对象可以马上解决我的问题呢?”例如,假设你正在创建一个簿记(Bookkeeping)系统,你可以想象系统应该具有某些包括了预定义的簿记输

19、入屏幕的对象,一个执行簿记计算的对象集合,以及一个处理在不同的打印机上打印支票和开发票的对象。也许上述对象中的某些已经存在了,但是对于那些并不存在的对象,它们看起来什么样?它们能够提供哪些服务?它们需要哪些对象才能履行它们的义务?如果你持续这样做,你最终会发现你将到达这样一个节点:你会说“那个对象看起来很简单,以至可以坐下来写代码了” ,或者会说“我肯定那个对象已经存在了” 。这是将问题分解为对象集合的一种合理方式。将对象看作是服务提供者还有一个附加的好处:它有助于提高对象的内聚性(cohesiveness) 。高内聚是软件设计的基本质量要求之一:这意味着一个软件构件(例如一个对象,尽管它也有

20、可能被用来指代一个方法或一个对象库)的各个方面“组合(fit together) ”得很好。人们在设计对象时所面临的一个问题是将过多的功能都填塞在一个对象中。例如,在你的检查打印模式模块中,你可以设计一个对象,它了解所有的格式和打印技术。你可能会发现这些功能对于一个对象来说太多了,你需要的是三个甚至更多个对象,其中,一个对象可以是所有可能的支票排版的目录,它可以被用来查询有关如何打印一张支票的信息;另一个对象或是对象集合可以是一个通用的打印接口,它知道有关所有不同类型的打印机的信息(但是不包含任何有关簿记的内容,它更应该是一个需要购买而不是自己编写的对象) ;第三个对象通过调用另外两个对象的服

21、务来完成打印任务。因此,每个对象都有一个它所能提供服务的高内聚的集合。在良好的面向对象设计中,每个对象都可以很好地完成一项任务,但是它并不试图多更多的事。就像在这里看到的,不仅允许某些对象可以通过购买获得(打印机接口对象) ,而且还使对象在某处重用成为可能(支票排版目录对象) 。将对象作为服务提供者看待是一件伟大的简化工具,它不仅在设计过程中非常有用,而且当其他人试图理解你的代码或重用某个对象时(如果他们看出了这个对象所能提供的服务的价值所在的话) ,它会使将对象调整到适应其设计的过程变得简单得多。被隐藏的具体实现将程序开发人员按照角色分为类创建者(class creator,那些创建新数据类

22、型的程序员)和客户端程序员 4(client programmer,那些在其应用中使用数据类型的类消费者)是大有裨益的。客户端程序员的目标是收集各种用来实现快速应用开发(Rapid Application Development)的类。类创建者的目标是构建类,该类只向客户端程序员暴露必需的部分,而隐藏其它所有部分。为什么要这样呢?因为如果加以隐藏,那么客户端程序员将不能够访问它,这意味着类创建者可以任意修改被隐藏的部分,而不用担心对其他任何人造成影响。被隐藏的部分通常代表对象内部脆弱的部分,它们很容易被粗心的或不知内情的客户端程序员所毁坏,因此将实现隐藏起来可以减少程序的 Bug。实现隐藏的概

23、念再怎么强调也不会过分。在任何相互关系中,具有关系所涉及的各方都遵守的边界是十分重要的事情。当你创建一个类库(Library)时,你就建立了与客户端程序员之间的关系,他们同样也是程序员,但是他们是使用你的类库来构建应用,或者是构建更大的类库的程序员。如果所有的类成员(Member)对任何人都是可用的,那么客户端程序员就可以对类作任何事情,而不受任何约束。即使你希望客户端程序员不要直接操作你的类中的某些成员,但是如果没有任何访问控制,将无法阻止此事发生。所有东西都将赤裸裸地暴露于世前。4 关于这个术语的表述,我得感谢我的朋友 Scott Meyers。因此,访问控制的第一个存在原因就是让客户端程

24、序员无法触及他们不应该触及的部分这些部分对数据类型的内部操作来说是必需的,但并不是用户需要的用来解决特定问题的接口的一部分。这对用户来说其实是一项服务,因为他们可以很容易地看出哪些东西对他们来说很重要,而哪些东西可以忽略。访问控制的第二个存在原因就是允许库设计者可以改变类内部的工作方式而不用担心是否会影响到客户端程序员。例如,你可能为了减轻开发任务而以某种简单的方式实现了某个特定类,但稍后你就发现你必须改写它才能使其运行得更快。如果接口和实现可以清晰地分离并得以保护,那么你就可以轻而易举地完成这项工作。 Java 使用三个关键字来在类的内部设定边界:public、private、protect

25、ed。它们的含义和用法非常易懂。这些“访问指定词(access specifier) ”决定了紧跟其后被定义的东西可以被谁使用。public 表示紧随其后的元素对任何人都是可用的,另一方面,private 这个关键字表示除类型创建者和该类型的内部方法之外的任何人都不能访问的元素。private就像你与客户端程序员之间的一堵砖墙,如果有人试图访问 private 成员,就会在编译时刻得到错误信息。protected 关键字与 private 作用相当,差别仅在于继承类(Inheriting class)可以访问 protected 成员,但是不能访问 private 成员。稍后将会对继承(Inh

26、eritance)进行介绍。 Java 还有一种缺省(default)的访问权限,当你没有使用前面提到的任何访问指定词时,它将发挥作用。这种权限通常被称为“包访问权限(package access) ”,因为在这种权限下,类可以访问在同一个包中的其它类的成员,但是在包之外,这些成员如同 private 一样。复用具体实现一旦类被开发并被测试完成,那么它就应该(理想情况下)代表一个有用的代码单元。事实证明,这种复用性(reusability)并不容易达到我们所希望的那种程度,产生一个可复用的对象设计需要丰富的经验和敏锐的洞察力。但是一旦你拥有了这样的一个设计,它就会请求被复用。代码复用是面向对象

27、程序设计语言所提供的最了不起的优点之一。最简单的复用某个类的方式就是直接使用该类的一个对象,此外你也可以将该类的一个对象置于某个新的类中。我们称其为“创建一个成员对象” 。新的类可以由任意数量、任意类型的其它对象以任意可以实现新的类中想要的功能的方式所组成。因为你在使用现有的类合成新的类,所以这种概念被称为组合(composition) ,如果组合式动态发生的,那么它通常被称为聚合(aggregation)。组合经常被视为“has-a” (拥有)关系,就像我们常说的“小汽车拥有引擎”一样。 (这张 UML 图用实心菱形声明有一辆小汽车,它表明了组合关系。我通常采用最简单的形式:仅仅是一条没有菱

28、形的线来表示关联(association) 。5) 5 通常对于大多数图来说,这样表示已经足够了,你并不需要关心你所使用的是聚合还是组合。组合带来了极大的灵活性。新类的成员对象通常都被声明为 private,使得使用该类的客户端程序员不能访问它们。这也使得你可以在不干扰现有客户端代码的情况下,修改这些成员。你也可以在运行时刻修改这些成员对象,以实现动态修改程序的行为。下面将要讨论的继承(inheritance)并被具备这样的灵活性,因为编译器必须对通过集成而创建的类施加编译时刻的限制。由于继承在面向对象程序设计中如此重要,所以它经常被高度强调,于是程序员新手就会有这样的印象:处处都应该使用继承

29、。这会导致难以使用并过分复杂的设计。实际上,在建立新类时,你应该首先考虑组合,因为它更加简单而灵活。如果你采用这种方式,你的设计会变得更加清晰。一旦有了一些经验之后,你便能够看透必须使用继承的场合。继承:复用接口对象这种观念,本身就是十分方便的工具,使得你可以通过概念(concept)将数据和功能封装到一起,因此你可以对问题域的观念给出恰当的表示,而不用受制于必须使用底层机器语言。这些概念用关键字 class 来表示,形成了编程语言中的基本单位。遗憾的是,这样做还是有很多麻烦,在创建了一个类之后,即使另一个新类与其具有相似的功能,你还是得重新创建一个新类。如果我们能够以现有的类为基础,复制它,

30、然后通过添加和修改这个副本来创建新类那就要好得多了。通过继承便可以达到这样的效果,不过也有例外,当源类(被称为基类(base class)、超类(super class)或父类(parent class))发生变动时,被修改的“副本” (被称为导出类 (derived class)、继承类(inherited class)或子类(subclass, child class))也会反映出这些变动。(这张 UML 图中的箭头从导出类指向基类,就像稍后你会看到的,通常会存在一个以上的导出类)类型不仅仅只是描述了作用于一个对象集合之上的约束条件,同时还有与其它类型之间的关系。两个类型可以有相同的特性和

31、行为,但是其中一个类型可能比另一个含有更多的特性,并且可以处理更多的消息(或以不同的方式来处理消息) 。继承使用基类和导出类的概念表示了这种类型之间的相似性。一个基类包含其所有导出类共享的特性和行为。你可以创建一个基类来表示系统中某些对象的核心概念,从基类中导出其它的类,来表示此核心可以被实现的各种不同方式。以垃圾回收机(trash-recycling machine)为例,它用来归类散落的垃圾。 trash 是基类,每一件垃圾都有重量、价值等特性,可以被切碎、熔化或分解。在此基础上,可以通过添加额外的特性(例如瓶子有颜色)或行为(例如铝罐可以被压碎,铁罐可以被磁化)导出更具体的垃圾类型。此外

32、,某些行为可能不同(例如纸的价值依赖其类型和状态) 。你可以通过使用继承来构建一个类型层次结构(type hierarchy)来表示你的待解问题相关联的类型。第二个例子是经典的在计算机辅助设计系统或游戏仿真系统中可能被用到的几何形(shape)的例子。基类是 shape,每一个 shape 都具有尺寸、颜色、位置等,同时每一个 shape 都可以被绘制、擦除、移动和着色等。在此基础上,可以导出(继承出)具体的几何形状圆形、正方形、三角形等每一种都具有额外的特性和行为,例如某些形状可以被翻转。某些行为可能并不相同,例如面积计算。类型层次结构同时体现了几何形状之间的相似性和相异性。将解决方案转换成

33、为问题术语的描述是大有裨益的,因为你不需要在问题描述和解决方案描述之间建立众多的中介模型。通过使用对象,类型层次结构成为了主要模型,因此,你可以直接从真实世界中对系统进行描述过渡到用代码对系统进行描述。事实上,对使用面向对象设计的人们来说,困难之一是从开始到结束太过于简单。对于训练有素、善于寻找复杂的解决方案的头脑来说,可能会在一开始被这种简单性给难倒。当你继承现有类型时,也就创造了新的类型。这个新的类型不仅包括现有类型的所有成员(尽管 private 成员被隐藏了起来,并且不可访问) ,而且更重要的是它复制了基类的接口。也就是说,所有可以发送给基类对象的消息同时也可以发送出导出类。由于我们通

34、过可发送消息的类型可知类的类型,所以这也就意味着导出类与基类具有相同的类型。在前面的例子中,“一个圆形也就是一个几何形状” 。通过继承而产生的类型等价(type equivalence)是理解面向对象程序设计方法内涵的重要门槛。由于基类和导出类具有相同的基础接口,所以伴随此接口的必定有某些具体实现。也就是说,当对象接收到特定消息时,必须有某些代码去执行。如果你只是简单地继承一个类而并不作其他任何事,那么在基类接口中的方法将会直接继承到导出类中。这意味着导出类的对象不仅与基类拥有相同的类型,而且还拥有相同的行为,这样做并没有什么特别的意义。有两种方法可以使基类与导出类产生差异。第一种方法非常直接

35、:直接在导出类中添加新方法。这些新方法并不是基类接口的一部分。这意味着基类不能直接满足你的所有需求,因此你必需添加更多的方法。这种对继承简单而基本的使用方式,有时对你的问题来说确实是一种完美的解决方式。但是,你应该仔细考虑是否存在你的基类也需要这些额外方法的可能性。这种设计的发现与迭代过程在面向对象程序设计中会经常发生。虽然继承有时可能意味着在接口中添加新方法(尤其是在以 extends 关键字表示继承的Java 中) ,但并非总需如此。第二种以及其它使导出类和基类之间产生差异的方法是改变现有基类的方法的行为。这被称之为重载(overriding) 。要想重载某个方法,可以直接在导出类中创建该

36、方法的新定义即可。你可以说:“此时,我正在使用相同的接口方法,但是我想在新类型中做些不同的事情。 ”是一个(is-a)与像是一个(is-like-a)关系对于继承可能会引发某种争论:继承应该只重载基类的方法(而并不添加在基类中没有的新方法)吗?如果这样做,就意味着导出类和基类是完全相同的类型,因为它们具有完全相同的接口。结果你可以用一个导出类对象来完全替代一个基类对象。这可以被视为“纯粹替代(pure substitution) ”,通常称之为“替代法则(substitution principle) ”。在某种意义上,这是一种处理继承的理想方式。我们经常将这种情况下的基类与导出类之间的关系称

37、为 “is-a”关系,因为你可以说“一个圆形就是一个几何形状” 。判断是否继承,就是要确定你是否可以用 is-a 来描述类之间的关系,并使之具有实际意义。有时你必须在导处类型中添加新的接口元素,这样也就扩展了接口并创建了新的类型。这个新的类型仍然可以替代基类,但是这种替代并不完美,因为基类无法访问你新添加的方法。这种情况我们可以描述为“is-like-a”关系。新类型具有旧类型的接口,但是它还包含其他方法,所以你不能说它们完全相同。以空调为例,假设你的房子里已经布线安装好了所有的冷气设备的控制器,也就是说,你的房子具备了让你控制冷气设备的接口。想象一下,如果空调坏了,你用一个既能制冷又能制热的

38、热力泵替换了它,那么这个热力泵就“is-like-a(像是一个) ”空调,但是它可以做更多的事。因为你的房子的控制系统被设计为只能控制冷气设备,所以它只能和新对象中的制冷部分进行通信。尽管新对象的接口已经被扩展了,但是现有系统除了源接口之外,对其他东西一无所知。当然,在你看过这个设计之后,你会发现很显然,Cooling System 这个基类不够一般化,应该将其更名为“温度控制系统” ,使其可以包括制热功能,这样我们就可以套用替代法则了。这张图说明了在真实世界中进行设计时可能会发生的事情。当你看到替代法则时,很容易会认为这种方式“纯粹替代”是唯一可行的方式,而且事实上以此方式,你的设计会显得很

39、好。但是你会发现有时同样很明显你必须在导出类接口中添加新方法。只要仔细审视,两种方法的使用场合应该是相当明显的。伴随多态的可互换对象在处理类型的层次结构时,你经常想把一个对象不要当作它所属的特定类型来对待,而是将其当作其基类的对象来对待。这使得你可以编写出不依赖于特定类型的代码。在 shape 的例子中,方法都是用来操作泛化(generic)形状的,不管它们是圆形、正方形、三角形还是其他什么尚未定义的形状。所有的几何形状都可以被绘制、被擦除、被移动,所以这些方法都是直接对一个 shape 对象发送消息,并不用担心这个对象如何处理该消息。这样的代码是不会受添加新类型的影响的,而且添加新类型是扩展

40、一个面向对象程序已处理新情况的最常用方式。例如,你可以从 shape 中导出一个新的子类型 pentagon(五边形) ,而并不需要修改处理泛化几何形状的方法。通过导出新的子类型而轻松扩展设计的能力是封装改动的基本方式之一。这种能力可以极大地改善我们的设计,同时也降低了软件维护的代价。但是,在试图将导出类型的对象当作他们的泛化基类对象来看待时(把圆形看作是几何形状,把自行车看作是交通工具,把鸬鹚看作是鸟等等) ,仍然存在一个问题。如果某个方法是要泛化几何形状绘制自己,泛化交通工具前进,或者是泛化的鸟类移动,那么编译器在编译时是不可能知道应该执行哪一段代码的。这就是关键所在:当发送这样的消息时,

41、程序员并不想知道哪一段代码将被执行;绘图(draw)方法可以被等同地应用于圆形、正方形、三角形之上,而对象会依据自身的具体类型来执行恰当的代码。如果你不需要知道哪一段代码会被执行,那么当你添加新的子类型时,不需要更改方法调用的代码,就能够执行不同的代码。因此,编译器无法精确地了解哪一段代码将会被执行,那么它该怎么办呢?例如,在下面的图中,BirdController 对象仅仅处理泛化的 Bird 对象,而不了解它们的确切类型。从 BirdController 的角度看,这么做非常方便,因为不需要编写特别的代码来判定要处理的 Bird 对象的确切类型或是 Bird 对象的行为。当 move()方

42、法被调用时,即便忽略 Bird 的具体类型,也会产生正确的行为(鹅跑、飞或游泳,企鹅跑或游泳) ,那么,这又是如何发生的呢?这个问题的答案,也是面向对象程序设计的最重要的妙诀:编译器不可能产生传统意义上的函数调用(function call) 。一个非面向对象(non-OOP)编译器产生的函数调用会引起所谓的“前期绑定(early binding) ”,这个术语你可能以前从未听说过,因为你从未想过函数调用的其他方式。这么做意味着编译器将产生对一个具体函数名字的调用,而链接器(linker)将这个调用解析到将要被执行代码的绝对地址(absolute address) 。在 OOP 中,程序直到运

43、行时刻才能够确定代码的地址,所以当消息发送到一个泛化对象时,必须采用其他的机制。为了解决这个问题,面向对象程序设计语言使用了“后期绑定(late binding) ”的概念。当你向对象发送消息时,被调用的代码直到运行时刻才能被确定。编译器确保被调用方法存在,并对调用参数(argument)和返回值(return value)执行类型检查(无法提供此类保证的语言被称为是弱类型的(weakly typed) ) ) ,但是并不知道将会被执行的确切代码。为了执行后期绑定,Java 使用一小段特殊的代码来替代绝对地址调用。这段代码使用在对象中存储的信息来计算方法体的地址(这个过程将在第 7 章中详述)

44、 。这样,根据这一小段代码的内容,每一个对象都可以具有不同的行为表现。当你向一个对象发送消息时,该对象就能够知道对这条消息应该做些什么。在某些语言中,你必须明确地声明希望某个方法具备后期绑定属性所带来的灵活性(C+是使用 virtual 关键字来实现的) 。在这些语言中,方法在缺省情况下不是动态绑定的。而在 Java 中,动态绑定是缺省行为,你不需要添加额外的关键字来实现多态(polymorphism) 。再来看看几何形状的例子。整个类族(其中所有的类都基于相同一致的接口)在本章前面已有图示。为了说明多态,我们要编写一段代码,它忽略类型的具体细节,仅仅和基类交互。这段代码和类型特定信息是分离的

45、(decoupled) ,这样做使代码编写更为简单,也更易于理解。而且,如果通过继承机制添加一个新类型,例如 Hexagon,你编写的代码对 Shape 的新类型的处理与对已有类型的处理会同样出色。正因为如此,可以称这个程序是可扩展的(extensible) 。如果用 Java 来编写一个方法(后面很快你就会学到如何编写): void doStuff(Shape s) s.erase(); / . s.draw(); 这个方法可以与任何 Shape 交谈,因此它是独立于任何它要绘制和擦除的对象的具体类型的。如果程序中其他部分用到了 doStuff()方法: Circle c = new Cir

46、cle(); Triangle t = new Triangle(); Line l = new Line(); doStuff(c); doStuff(t); doStuff(l);对 doStuff()的调用会被自动地正确处理,而不管对象的确切类型。这是一个相当令人惊奇的诀窍。看看下面这行代码: doStuff(c); 如果被传入到预期接收 Shape 的方法中,究竟会发生什么呢?由于 Circle 可以被 doStuff()看作是 Shape,也就是说,doStuff()可以发送给 Shape 的任何消息, Circle都可以接收,那么,这么做是完全安全且合乎逻辑的。我们把将导出类看作是

47、它的基类的过程称为“向上转型(upcasting) ”。 “转型( cast) ”这个名称的灵感来自于模型铸造的塑模动作,而“向上( up) ”这个词来源于继承图的典型布局方式:通常基类在顶部,而导出类在其下部散开。因此,转型为一个基类就是在继承图中向上移动,即“向上转型(upcasting) ”。一个面向对象程序肯定会在某处包含向上转型,因为这正是你如何将自己从必须知道确切类型中解放出来的关键。让我们再看看在 doStuff()中的代码: s.erase(); / . s.draw(); 注意这些代码并不是说“如果你是 Circle,请这样做;如果你是 Square,请那些做;”。如果你编写

48、了那种检查 Shape 实际上所有可能类型的代码,那么这段代码肯定是杂乱不堪的,而且你需要在每次添加了新类型的 Shape 之后去修改这段代码。这里你所要表达的意思仅仅是“你是一个 Shape,我知道你可以 erase()和 draw()你自己,那么去做吧,但是要注意细节的正确性。 ” doStuff()的代码给人印象深刻之处在于,不知何故,总是做了该做的。调用 Circle 的draw()方法所执行的代码与调用 Square 或 Line 的 draw()方法所执行的代码是不同的,但是当 draw()消息被发送给一个匿名的(anonymous)的 Shape 时,也会基于该 Shape 的实

49、际类型产生正确的行为。这相当神奇,因为就象在前面提到的,当 Java 编译器在编译 doStuff()的代码时,并不能确切知道 doStuff()要处理的确切类型。所以通常你会期望它的编译结果是调用基类 Shape 的 erase()和 draw()版本,而不是具体的 Circle、Square或是 Line 的版本。正是因为多态才使得事情总是能够被正确处理。编译器和运行系统会处理相关的细节,你需要马上知道的只是事情会发生,更重要的是怎样通过它来设计。当你向一个对象发送消息时,即使涉及向上转型,该对象也知道要执行什么样的正确行为。抽象基类和接口通常在一个设计中,你会希望基类仅仅表示其导出类的接口,也就是说,你不希望任何人创建基类的实际对象,而只是希望他们将对象向上转型到基类,所以它的接口将派上用场。这是通过使用 abstract 关键字把类标识成为抽象类来实现的。如果有人试图创建一个抽象类的对象,编译器都会加以阻止。这是支持某种特殊设计的工具。你也可以使用 abstract 关键字来描述尚未被实现的方法,就象一个存根,用来表示“这是一个从此类中继承出的所有类型都具有的接口方法,但是此刻我没有为它设计任何具体实现。 ”抽象方法只能在抽象类内部创建,当该类被继承时,抽象方法必须被实现,否则继承类仍然是一个抽象类。创建抽象方法使得你可以将一个方法置

展开阅读全文
相关资源
猜你喜欢
相关搜索
资源标签

当前位置:首页 > 企业管理 > 管理学资料

本站链接:文库   一言   我酷   合作


客服QQ:2549714901微博号:道客多多官方知乎号:道客多多

经营许可证编号: 粤ICP备2021046453号世界地图

道客多多©版权所有2020-2025营业执照举报