对象导论学习笔记

”我们之所以将自然界分解,组织成各种概念,并按其含义分类,主要是因为我们是整个口语交流社会共同遵守的协定的参与者,这个协定以语言的形式固定下来······除非赞成这个协定中规定的有关语言信息的组织和分类,否则我们根本无法交谈。“

 

本章将向读者介绍包括开发方法概述在内的OOP的基本概念。

 

1.1 抽象过程

     所有编程语言都提供抽象机制。可以认为,人们所能够解决的问题的复杂性直接取决于抽象的类型和质量。

     所谓的”类型“是指”所要抽象的是什么?“

 

汇编语言是对底层机器的轻微抽象。接着出现的许多所谓”命令式“语言(如FORTRAN、BASIC、C等)都是对汇编语言的抽象。这些语言在汇编语言基础上有了大幅的改进,但是它们所做的主要抽象仍要求在解决问题时要基于计算机的结构,而不是基于所要解决的问题的结构来考虑。程序员必须建立起在机器模型(位于”解空间“内,这是你对问题建模的地方,例如计算机)和实际待解问题的模型(位于”问题空间“内,这是问题存在的地方,例如一项业务)之间的关联。建立这种映射是费力的,而且这不属于编程语言固有的功能,这使得程序难以编写,并且维护代价高昂,同事也产生了作为副产物的整个”编程方法“行业。

 

另一种对机器建模的方式就是只针对待解问题建模。早期的编程语言,如LISP和APL,都是 选择考虑世界的某些特定视图(分别对应于“所有问题最终都是列表”或者"所有问题都是算法形式的”)。PROLOG则将所有问题都转化成决策链。此外还产生了基于约束条件编程的语言和专门通过对图形符号操作来实现编程的语言(后者被证明限制性太强)。

 

面向对象方式通过向程序员提供表示问题空间中的元素的工具而更进了一步。这种表示方法非常通用,使得程序员不会受限于任何特定类型的问题。我们将问题空间中的元素及其在解空间中表示称为”对象“。(你还需要一些无法类比为问题空间元素的对象。)这种思想的实质是:程序可以通过添加新类型的对象使自身使用于某个特定问题。因此,当你在阅读描述解决方案的代码的同时,也是在阅读问题的表述。相比以前我们所使用的语言,这是一种更灵活和更强有力的语言抽象。所以,OOP允许根据问题来描述问题,而不是根据运行解决方案的计算机来描述问题。但是它仍然与计算机有联系:每个对象看起来都有点像一台微型计算机------它具有状态,还具有操作,用户可以要求对象执行这些操作。如果要对现实世界中的对象作类比,那么说它们都具有特性和行为似乎不错。

 

(个人理解)C语言就是编写一堆无组织的操作(方法或者说函数);而java的改变就是用对象来组织C中的那些无序的方法。

 

纯粹面向对象程序设计方式:

1)万物皆为对象。

将对象视为奇特的变量,它可以存储数据,除此之外,你还可以要求它在自身上执行操作。理论上讲你可以抽取待求解问题的任何概念化构件(狗、建筑物、服务等),将其表示为程序中的对象。

2)程序视对象的集合,它们通过发送消息来告知彼此所要做的。要想请求一个对象,就必须对该对象发送一条消息。更具体地说,可以把消息想象为对某个特定对象的方法的调用请求。

3)每个对象都有自己的由其他对象所构成的存储。换句话说,可以通过创建包含现有对象的包的方式来创建新类型的对象。因此,可以在程序中构建复杂的体系,同时将其复杂性隐藏在对象的简单性背后。

4)每个对象都拥有其类型。按照通用的说法,”每个对象都是某个类(class)的一个实例(instance)“,整个”类“就是”类型“的同义词。每个类最重要的区别于其他类的特性就是”可以发送什么样的消息给它“。

5)某一特定类型的所有对象都可以接收同样的消息。这是一句意味深长的表达,你在稍后便会看到。因为”圆形“类型的对象同时也是”几何形“类型的对象,所以一个”圆形“对象必定能够接受发送给”几何形“对象的信息。这意味着可以编写与”几何形“交互并且自动处理所有与几何形性质相关的事物的代码。这种可替代性(substitutability)是OOP中最强有力的概念之一。

 

Booch对对象提出了一个更加简洁的描述:

对象具有状态、行为和标识。这意味着每一个对象都可以拥有内部数据(它们给出了该对象的状态)和方法(它们产生行为),并且每一个对象都可以拥有内部数据(它们给出了该对象的状态)和方法(它们产生行为),并且每一个对象都可以唯一地与其他对象区分开来,具体说来,就是每一个对象在内存中都有一个唯一的地址。

 

1.2  每个对象都有一个接口

亚里士多德大概是第一位深入研究类型(type)的哲学家,他曾经提出过鱼类和鸟类这样的概念。所有的对象都是唯一的,但同时也是具有相同的特性和行为的对象所归属的类的一部分。

 

 

类描述了具有相同特性(数据元素)和行为(功能)的对象集合,所以一个类实际上就是一个数据类型,例如所有浮点型数字具有相同的特性和行为集合。两者的差异在于,程序员通过定义类型适应问题,而不再被迫只能使用现有的用来表示机器中的存储单元的数据类型。

 

面向对象程序设计的挑战之一,就是问题空间的元素和解空间的对象之间创建一对一的映射(意思就是类型定义要准确 )

 

但是,怎样才成获得有用的对象呢?必须有某种方式产生对对象的请求,使对象完成各种任务。每个对象都只能满足某些请求,这些请求由对象的接口(interface)所定义,决定接口的便是类型。

接口确定了对某一特定对象所能发出的请求。但是,在程序中必须有满足这些请求的代码。这些代码与隐藏的数据一起构成了实现。从过程型编程的观点来看,这并不太复杂。在类型中,每一个可能的请求都有一个方法与之相关联,当向对象发送请求时,与之相关联的方法就会被调用。此过程通常被概括为:向某个对象”发送消息“(产生请求),这个对象便知道此消息的目的,然后执行对应的程序代码。

 

1.3每个对象都提供服务(实现类型抽象的思路)

     当正在试图开发或理解一个程序设计时,最好的方法之一就是将对象想象为”服务提供者“。程序本身将向用户提供服务,它将通过调用其他对象提供服务来实现这一目的。你的目的就是去创建(或者最好是在现有代码库中寻找)能够提供理想的服务来解决问题的一系列对象。

     着手从事这件事的一种方式就是问一下自己:”如果我可以将问题从表象中抽取出来,那么什么样的对象可以马上解决我的问题呢?“例如,假设你正在创建一个簿记系统,那么可以想象,系统应该具有某些包括了预定义的簿记输入屏幕的对象,一个执行簿记计算的对象集合,以及一个处理在不同的打印机上打印100659_bzBr_3420885.png和开 100805_bdUl_3420885.png 的对象。也许上述对象中的某些已经存在了,但是对于那些并不存在的对象,它们看起来像什么样子?它们能够提供哪些服务?它们需要哪些对象才能履行它们的义务?如果持续这样做,那么最终你会说”那个对象看起来如此简单,可以坐下来写代码了“,或者会说”我肯定那个对象已经存在了“。这是将问题分解为对象集合的一种合理方式。

     将对象看作是服务提供者还有一个附带的好处:它有助于提高对象的内聚性。高内聚是软件设计的基本质量要求之一:这意味着一个软件构件(例如一个对象,当然它也有可能是指一个方法或者一个对象库)的各个方面”组合“的很好。人们在设计对象时所面临的一个问题是,将过多了解所有的格式和打印技术。你可能会发现,这些功能对于一个对象来说太多了,你需要的是三个甚至更多个对象,其中,一个对象可以是所有可能的100709_tXxc_3420885.png排版的目录,它可以被用来查询有关如何打印一张100723_05IW_3420885.png的信息;另一个对象(或对象集合)可以是一个通用的打印接口,它知道有关所有不同类型的打印机的信息(但是不包含任何有关簿记的内容,它更应该是一个需要购买而不是自己编写的对象);第三个对象通过调用另外两个对象的服务来完成打印任务。这样,每个对象都有一个它所能提供的内聚的集合。在良好的面向对象设计中,每个对象都可以很好地完成一项任务,但是它并不试图做更多的事。就像在这里看到的,不仅允许通过购买获得某些对象(打印机接口对象),而且还可以创建能够在别处复用的新对象(100745_ZQj0_3420885.png排版目录对象)。

     将对象作为服务提供者看待是一件伟大的简化工具,这不仅在设计过程中非常有用,而且当其他人试图理解你的代码或重用某个对象时,如果它们看出了整个对象所能提供的服务的价值,它会使调整对象以适应其设计的过程变得简单得多。

 

1.4被隐藏的具体实现

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

     在任何相互关系中,具有关系所涉及的各方都遵守的边界是十分重要的事情。当创建一个类库时,就建立了与客户端程序员之间的关系,他们同样也是程序员,但是他们是使用你的类库来构建应用、或者构建更大的类库的程序员。如果所有的类成员对任何人都是可用的,那么客户端程序员就可以对类做任何事情,而不受任何约束。即使你希望客户端程序员不要直接操作你的类中的某些成员,但是如果没有任何访问控制,将无法阻止此事发生。所有东西都将赤裸裸地暴露于世人面前。

因此,访问控制的第一个存在原因就是让客户端程序员无法触及他们不应该触及的部分-------这些部分对数据类型的内部操作来说是必需的,但并不是用户解决待定问题所需的接口的一部分。这对客户端程序员来说其实是一项服务,因此他们可以很容易地看出哪些东西对他们来说很重要,而哪些东西可以忽略。

     访问控制的第二个存在原因就是允许库设计者可以改变类内部的工作方式而不用担心会影响到客户端程序员。例如,你可能为了减轻开发任务而以某种简单的方式实现了某个特定类,但稍后发现你必须改写它才能使其运行得更快。如果接口和实现可以清晰地分离并得以保护,那么你就可以轻而易举地完成这项工作。

     java用三个关键字在类的内部设定边界:public、private、protected。这些访问指定词(access specifier)决定了紧跟其后被定义的东西可以被谁使用。public表示紧随其后的元素对任何人都是可用的,而private整个关键字表示除类型创建者和类型的内部方法之外的任何人都不能访问的元素。private就像你与客户端程序员之间的一堵墙,如果有人试图访问private成员,就会在编译时得到错误信息。protected关键字与private作用相当,差别仅在于继承的类可以访问protected成员,但是不能访问private成员。稍后将会对继承进行介绍。

     java还有一种默认的访问权限,当没有使用前面提到的任何访问指定词时,它将发挥作用。这种权限通常被称为包访问权限,因为在这种权限下,类可以访问在同一个包(库构件)中的其他类的成员,但是在包之外,这些成员如同指定了private一样。

 

1.5复用具体实现

     一旦类被创建并被测试完,那么它就应该(在理想情况下)代表一个有用的代码单元。事实证明,这种复用性并不容易达到我们所希望的那种程度,产生一个可复用的对象设计需要丰富的经验和敏锐的洞察力。但是一旦你有了这样的设计,它就可供复用。代码复用时面向对象程序设计语言所提供的最了不起的优点之一。

     最简单地复用某个类的方式就是直接使用该类的一个对象,此外也可以将那个类的一个对象置于某个新的类中。我们称其为”创建一个成员对象“。新的类可以由任意数量、任意类型的其他对象以任意可以实现新的类中想要的功能的方式所组成。因为是在使用现有的类合成新的类,所以这种概念被称为组合(composition),如果组合是动态发生的,那么它通常被称为聚合(aggregateion)。组合经常被视为”has-a“(拥有)关系,就像我们常说的”汽车拥有引擎“一样。

 

     组合带来了极大的灵活性。新类的成员对象通常都被声明为private,使得使用新类的客户端程序员不能访问他们。这也是得你可以在不干扰现有客户端代码的情况下,修改这些成员。也可以在运行时修改这些成员对象,以实现动态修改程序的行为。下面将要讨论的继承并不具备这样的灵活性,因为编译器必须对通过继承而创建的类施加编译时的限制。

     由于继承在面向对象程序设计中如此重要,所以它经常被高度强调,于是程序员新手就会有这样的印象:处处都应该使用继承。这会导致难以使用并过分复杂的设计。实际上,在建立新类时,应该首先考虑组合,因为它更加简单灵活。如果采用这种方式,设计会变得更加清晰。一旦有了一些经验之后,便能够看出必须使用继承的场合了。

 

1.6继承

     对象这种观念,本身就时十分方便的工具,使得你可以通过概念将数据和功能封装到一起,因此可以对问题空间的观念给出恰当的表示,而不用受制于必须使用底层机器语言。这些概念用关键字class来表示,它们形成了编程语言中的基本单位。

     遗憾的是,这样做还是有很多麻烦:在创建了一个类之后,即使另一个新类与其具有相似的功能,你还是得重新创建一个新类。如果我们能够以现有的类为基础,复制它,然后通过添加和修改整个副本来创建新类那就要好多了。通过继承便可以达到这样的效果,不过也有例外,当源类(被称为基类、超类或者父类)发生变动时,被修改的”副本“(被称为导出类、继承类或者子类)也会反映出这些变动。

     类型不仅仅只是描述了作用于一个对象集合上的约束条件,同时还有与其他类型之间的关系。两个类型可以有相同的特性和行为,但是其中一个类型可能比另一个含有更多的特性,并且可以处理更多的消息(或以不同的方式来处理消息)。继承使用基类型和导出类型的概念表示了这种类型之间的相似性。一个基类型包含其所有导出类型所共享的特性和行为。可以创建一个基类型来表示系统中某些对象的核心概念,从基类型中导出其他类型,来表示此核心可以被实现的各种不同方式。

     以垃圾回收机为例,它用来归类散落的垃圾。”垃圾“是基类型,每一件垃圾都有重量、价值等特性,可以被切碎、溶化或分解。在此基础上,可以通过添加额外的特性(例如瓶子有颜色)或行为(例如铅罐可以被压碎,铁罐可以被磁化)导出更具体的垃圾类型。此外,某些行为可能不同(例如纸的价值取决于其类型和状态)。可以通过使用继承来构建一个类型层次结构,以此来表示待求解的某种类型的问题。

第二个例子是经典的几何形的例子,这在计算机辅助设计系统或游戏仿真系统中可能被用到。基类是几何形,每一个几何形都具有尺寸、颜色、位置等,同时每一个几何形都可以被绘制、擦除、移动和着色等。在此基础上,可以导出(继承出)具体的几何形状------圆形、正方形、三角形等-------每一种都具有额外的特性和行为,例如某些形状可以被翻转。某些行为可能并不相同,例如计算几何形状的面积。类型层次结构同时体现了几何形状之间的相似性和差异性

095309_UlPv_3420885.png

     以同样的术语将解决方案转换成问题是大有裨益的,因为不需要在问题描述和解决方案描述之间建立许多中间模型。通过使用对象,类型层次结构成为了主要模型,因此,可以直接从真实世界中对系统的描述过渡到用代码对系统进行描述。事实上,对使用面向对象设计的人们来说,困难之一是从开始到结束过于简单。对于训练有素、善于寻找复杂的解决方案的头脑来说,可能会在一开始被这种简单性给难道。

     当继承现有类型时,也就创造了新的类型。这个新的类型不仅包括现有类型的所有成员(尽管private成员被隐藏了起来,并且不可访问),而且更重要的是它复制了基类的接口。也就是说,所有可以发送给基类对象的消息同时也可以发送给导出类对象。由于通过发送给类的信息的类型可知类的类型,所以这也就意味着导出类与基类具有相同的类型。在前面的例子中,”一个圆形也就是一个几何形“。通过继承而产生的类型等价性是理解面向对象程序设计方法内涵的重要门槛。

     由于基类和导出类具有相同的基础接口,所以伴随此接口的必定有某些具体实现。也就是说,当对象接收到特定消息时,必须有某些代码去执行。如果只是简单地继承一个类而并不做其他任何事,那么在基类接口中的方法将会直接继承到导出类中。这意味着导出类的对象不仅与基类拥有相同的类型,而且还拥有相同的行为,这样做没有什么特别意义。

     有两种方法可以使基类和导出类产生差异。第一种方法非常直接:直接在导出类中添加新方法。这些新方法并不是基类接口的一部分。这意味着基类不能直接满足你的所有需求,因此必需添加更多的方法。这种对继承简单而基本的使用方式,有时对问题来说确实使一种完美的解决方式。但是,应该仔细考虑是否存在基类也需要这些额外方法的可能性。这种设计的发现与迭代过程在面向对象程序设计中会经常发生。

095345_dhN3_3420885.png

     虽然继承有时可能意味着在接口中添加新方法(尤其是在以extends关键字表示继承的java中),但并非总需要如此。第二种也是更重要的一种使导出类和基类之间产生差异的方法是改变现有基类的方法的行为,这被称之为覆盖(overridiing)那个方法。

095410_Ndy3_3420885.png

要想覆盖某个方法,可以直接在导出类种创建该方法的新定义即可。你可以说:”此时,我正在使用相同的接口方法,但是我想在新类型种做些不同的事情。“

 

1.6.1 ”是一个“与”像是一个“关系

     对于继承可能会引发某种争论:继承应该只覆盖基类的方法(而并不添加在基类中没有的新方法)吗?如果这样做,就意味着导出类和基类是完全相同的类型,因为它们具有完全相同的接口。结果可以用一个导出类对象来完全替代一个基类对象。这可以被视为纯粹替代,通常称之为替代原则。在某种意义上,这是一种处理继承的理想方式。我们经常将这种情况下的基类与导出类之间的关系称为is-a(是一个)关系,因为可以说”一个圆形就是一个几何形状“。判断是否继承,就是要确定是否可以用is-a来描述类之间的关系,并使之具有实际意义。

     有时必须在导出类型中添加新的接口元素,这样也就扩展了接口。这个新的类型仍然可以替代基类,但是这种替代并不完美,因为基类无法访问新添加的方法。这种情况我们可以描述为is-like-a(像是一个)关系。新类型具有旧类型的接口,但是它还包含其他方法,所以不能说它们完全相同。以空调为例,假设房子里已经布线安装好了所有的冷气设备的控制器,也就是说,房子具备了让你控制冷气设备的接口。想象一下,如果空调坏了,你用一个既能制冷又能制热的热力泵替换了它,那么这个热力泵就is-like-a空调,但是它可以做更多的事。因为房子的控制系统被设计为只能控制冷气设备,所以它只能和新对象中的制冷部分进行通信。尽管新对象的接口已经被扩展了,但是现有系统除了原来接口之外,对其他东西一无所知。

095457_Ro3A_3420885.png

     当然,在看过这个设计之后,很显然会发现,制冷系统这个基类不够一般化,应该将其 更名为”温度控制系统“,使其可以包括制热功能,这样我们旧可以套用替代原则了,这张图说明了在真实世界中进行设计时可能会发生的事情。

     当你看到替代原则时,很容易会认为这种方式(纯粹替代)是唯一可行的方式,而且事实上,用这种方式设计是很好的。但是你会时常发现,同样显然的是你必须在导出类的接口中添加新方法。这要仔细审视,两种方法的使用场合应该是相当明显的。

 

1.7 伴随多态的可互换对象

     在处理类型的层次结构时,经常想把一个对象不当作它所属的特定类型来对待,而是将其当作其基类的对象来对待。这使得人们可以编写出不依赖于特定类型的代码。在”几何形“的例子中,方法操作的都是泛化(generic)的形状,而不关心它们是圆形、正方形、三角形还是其他什么尚未定义的形状。所有的几何形状都可以被绘制、擦除和移动,所以这些方法都是直接对一个几何形对象发送消息;它们不用担心对象将如何处理消息。

     这样的代码是不会受添加新类型影响的,而且添加新类型是扩展一个面向对象程序以便处理新情况的最常用方式。例如,可以从”几何形“中导出一个新的子类型”五角形“,而并不需要修改处理泛化几何形状的方法。通过导出新的子类型而轻松扩展设计的能力是对改动进行封装的基本方式之一。这种能力可以极大地改善我们的设计,同时也降低软件维护的代价。

     但是,在试图将导出类型的对象当作其泛化基类型对象来看待时(把圆形看作是几何形,把自行车看作是交通工具,把鸬鹚看作是鸟等等),仍然存在一个问题。如果某个方法要让泛化几何形状绘制自己、让泛化交通工具行驶,或者让泛化的鸟类移动,那么编译器在编译时是不可能知道应该执行哪一段代码的。这就是关键所在:当发送这样的消息时,当发送这样的消息时,程序员并不想知道哪一段代码将被执行;绘图方法可以被等同地应用于圆形、正方形、三角形,而对象会依据自身的具体类型来执行恰当的代码。

     如果不需要知道哪一段代码被执行,那么当添加新的子类型时,不需要更改调用它的方法,它就能够执行不同的代码。因此,编译器无法精确地了解哪一段代码将会被执行,那么它该怎么办呢?例如,在下面的图中,BirdController对象仅仅处理泛化的Bird对象,而不了解它们的确切类型。从BirdController的角度看,这么做非常方便,因为不需要编写特别的代码来判定要处理的Bird对象的确切类型或其行为。当move()方法被调用时,即便忽略Bird的具体类型,也会产生正确的行为(Goose(鹅)走、飞或游泳,Penguin(企鹅)走或游泳),那么,这是如何发生的呢?

095529_RGHH_3420885.png

这个问题的结果,也是面向对象程序设计的最重要的妙诀:编译器不可能产生传统意义上的函数调用。一个非面向对象编译器产生的函数调用会引起所谓的前期绑定,这个术语你可能以前从未听说过,可能从未想过函数调用的其他方式。这么做意味着编译器将产生对一个具体函数名字的调用,而运行时将这个调用解析到将要被执行的代码的绝对地址。然而在OOP中,程序直到运行时才能够确定代码的地址,所以当消息发送到一个泛化对象时,必须采用其他的机制。

     为了解决这个问题,面向对象程序设计语言使用了后期绑定的概念。当向对象发送消息时,被调用的代码直到运行时才能确定。编译器确保被调用方法的存在,并对调用参数和返回值执行类型检查(无法提供此类保证的语言被称为是弱类型的),但是并不知道将被执行的确切代码。

     为了执行后期绑定,Java使用一小段特殊的代码来替代绝对地址调用。这段代码使用在对象中存储的信息来计算方法体的地址(这个过程将在第8章中详述)。这样,根据这一小段代码的内容,每一个对象都可以具有不同的行为表现。当向一个对象发送消息时,该对象就能够知道对这条消息应该做些什么。

     在某些语言中,必须明确地声明希望某个方法具备后期绑定属性所带来的灵活性(C++是使用virtual关键字来实现的)。在这些语言中,方法在默认情况下不是动态绑定的。而在Java中,动态绑定是默认行为,不需要添加额外的关键字来实现多态。

     再来看看几何形状的例子。整个类族(其中所有的类都基于相同的一致接口)在本章前面已有图示。为了说明多态,我们来编写一段代码,它忽略类型的具体细节,仅仅和基类交互。这段代码和具体类型信息是分离的(decoupled),这样做使代码编写更为简单,也更易于理解。而且,如果通过继承机制添加一个新类型,例如Hexagon(六边形),所编写的代码对Shape(几何形)的新类型的处理与对 已有类型的处理会同样出色。正因为如此,可以称这个程序是可扩展的。

如果用Java来编写一个方法(后边很快你就会学习如果编写):

095604_ORNL_3420885.png095613_GdgR_3420885.png

如上图,建基类(几何形),定义方法;依次建里导出类  圆形 、三角形、等等

095637_7ESI_3420885.png

     把导出类看做是它的基类的过程称为向上转型(upcasting)。转型(cast)这个名称的灵感来自于模型铸造动作;而向上(up)这个词来源于继承图的典型布局方式:通常基类在顶部,而导出类在其下部散开。因此,转型为一个基类就是在继承图中向上移动,即”向上转型“

095700_IUDh_3420885.png

     一个面向对象程序肯定会在某处包含向上转型,因此这正是将自己从必须知道确切类型中解放出来的关键,让我们再看看doSomething()中的代码:

shape.erase();

shape.draw();

     注意这些代码并不是说”如果是Circle,请这样做;如果是Square,请那样做·······“。如果编写了那种检查Shape所有实际可能类型的代码,那么这段代码肯定是杂乱不堪的,而且在每次添加了Shape的新类型之后都要去修改这段代码。这里所要表达的意思仅仅是”你是一个Shape,我知道你可以erase()和draw()你自己,那么去做吧,但是要注意细节的正确性。“

     doSomething()的代码给人印象深刻之处在于,不知何故,它总是做了该做的。调用Circle的draw()方法所执行的代码与调用Square或Line的draw()方法所执行的代码是不同的,而且当draw()消息被发送给一个匿名的Shape时,也会基于该Shape的实际类型产生正确的行为。这相当神奇,因为就像在前面提到的,当java编译器在编译doSomething()的代码时,并不能确切知道doSomething()要处理的确切类型。所以通常会期望它的编译结果是调用基类Shape的erase()和draw()版本,而不是具体的Circle、Square或Line的相应版本。正是因为多态才使得事情总是能够被正确处理。编译器和运行系统会处理相关的细节,你需要马上知道的只是事情会发生,更重要的是怎样通过它设计。当向一个对象发送消息时,即使涉及向上转型,该对象也知道要执行什么样的正确行为。

 

1.8 单根继承结构

     在OOP中,自C++面世以来就已变得非常瞩目的一个问题就是,是否所有的类最终都继承自单一的基类。在Java中(事实上还包括除C++以外的所有OOP语言),结果是yes,这个终极基类的名字就是Object。事实证明,单根继承结构带来了很多好处。

     在单根继承结构中的所有对象都具有一个共用接口,所以它们归根到底都是相同的基本类型。另一种(C++所提供的)结构是无法确保所有对象都属于同一个基本类型。从向后兼容的角度看,这么做能够更好地适应C模型,而且受限较少,但是当要进行完全的面向对象程序设计时,则必须构建自己的继承体系,使得它可以提供其他OOP语言内置的便利。而且在所获得的任何新类库中,总会用到一些不兼容的接口,需要花力气(有可能要通过多重继承)来使新接口融入你的设计之中。这么做来换取C++额外的灵活性是否值得呢?如果需要的话------如果在C上面投资巨大,这么做就很有价值。如果是刚刚从头开始,那么像java这样的选择通常会有更高的效率。

     单根继承结构保证所有对象都具备某些功能。因此你知道,在你的系统中你可以在每个对象上执行某些基本操作。所有对象都可以很容易地在堆上创建,而参数传递也得到了极大的简化。

     单根继承结构使垃圾回收器的实现变得容易得多,而垃圾回收器正是Java相对C++的重要改进之一。由于所有对象都保证具有其类型信息,因此不会因无法确定对象的类型而陷入僵局。这对于系统级操作(如异常处理)显得尤其重要,并且给编程带来了更大的灵活性。

 

    1.9 容器

     通常说来,如果不知道在解决某个特定问题时需要多少个对象,或者它们将存活多久,那么就不可能知道如何存储这些对象。如何才能知道需要多少空间来创建这些对象呢?结果是你不可能知道,因为这类信息只有在运行时才能获得。

     对于面向对象设计中的大多数问题而言,这个问题的解决方案似乎过于轻率:创建另一种对象类型。这种新的对象类型持有对其他对象的引用。当然,你可以用在大多数语言中都有的数组类型来实现相同的功能。但是这个通常被称为容器(也称为集合,不过Java类库以不同的含义使用”集合“这个术语,所以本书将使用”容器“这个词)的新对象,在任何需要时都可扩充自己以容纳你置于其中的所有东西。因此不需要知道将来会把多少个对象置于容器中,只需要创建一个容器对象,然后让它处理所有细节。

     幸运的是,好的OOP语言都有一组容器,它们作为开发包的一部分。在C++中,容器是标准C++类库的一部分,经常被称为标准模板类库(Standard Template Library,STL)。Object Pascal在其可视化构件库(Visual Component Library,VCL)中有容器;Smalltalk提供了一个非常完备的容器集;Java在其标准类库中也包含有大量的容器。在某些类库中,一两个通用容器足够满足所有的需要;但是在其他类库(例如Java)中,具有满足不同需要的各种类型的容器,例如List(用于存储序列),Map(也被称为关联数组,用来建立对象之间的关联),Set(每种对象类型只持有一个),以及诸如队列、树、堆栈等更多的构件。

     从设计的观点来看,真正需要的只是一个可以被操作,从而解决问题的序列。如果单一类型的容器可以满足所有需要,那么就没有理由设计不同种类的序列了。然而还是需要对容器有所选择,这有两个原因。第一,不同容器提供了不同类型的接口和外部行为。堆栈相比于队列就具备不同的接口和行为,也不同于集合和列表的接口和行为。它们之中的某种容器提供的解决方案可能比其他容器要灵活的多。第二,不同的容器对于某些操作具有不同的效率。最好的例子就是两个List的比较:ArrayList和LinkedList。它们都是具有相同接口和外部行为的简单的序列,但是它们对某些操作所花费的代价却有天壤之别。在ArrayList中,随机访问元素是一个花费固定时间的操作;但是,对LinkedList来说,随机选取元素需要在列表中移动,这种代价是高昂的,访问越靠近表尾的元素,花费的时间越长。而另一方面,如果想在序列中间插入一个元素,LinkedList的开销却比ArrayList要小。上述操作以及其他操作的效率,依序列底层结构的不同而存在很大的差异。我们可以在一开始使用LinkedList构建程序,而在优化系统性能时改用ArrayList。接口List所带来的抽象,把在容器之间进行转换时对代码产生的影响降到最小限度。

 

1.9.1 参数化类型

      在Java SE5出现之前,容器存储的对象都只具有Java中的通用类型:Object。单根继承结构意味着所有东西都是Object类型,所以可以存储Object的容器可以存储任何东西。这使得容器很容易被复用。

     要使用这样的容器,只需在其中置入对象引用,稍后还可以将它们取回。但是由于容器只存储Object,所以当将对象引用置入容器时,它必须被向上转型为Object,因此它会丢失其身份。当把它取回时,就获取了一个Object对象的引用,而不是对置入时的那个类型的对象的引用。所以,怎样才能将它变回先前置入容器中时的具有实用接口的对象呢?

     这里再度用到了转型,但这一次不是向继承结构的上层转型为一个更泛化的类型,而是向下转型为更具体的类型。这种转型的方式称为向下转型。我们知道,向上转型是安全的,例如Circle是一种Shape类型;但是不知道某个Object是Circle还是Shape,所以除非确切知道所要处理的对象的类型,否则向下转型几乎是不安全的。

     然而向下转型并非彻底是是危险的,因为如果向下转型为错误的类型,就会得到被称为异常的运行时错误,稍后会介绍什么时异常。尽管如此,当从容器中取出对象引用时,还是必须要以某种方式记住这些对象究竟时什么类型,这样才能执行正确的向下转型。

     向下转型和运行时的检查需要额外的程序运行时间,也需要程序员付出更多的心血。那么创建这样的容器,它知道自己所保存的对象的类型,从而不需要向下转型以及消除犯错误的可能,这样不是更有意义吗?这种解决方案被称为参数化类型机制。参数化类型就是一个编译器可以自动定制作用于特定类型上的类。例如,通过使用参数化类型编译器可以定制一个只接纳和取出Shape对象的容器。

     Java SE5的重大变化之一就是增加了参数化类型,在java中它称为泛型。一对尖括号,中间包含类型信息,通过这些特征就可以识别对泛型的使用。例如,可以用下面这样的语句来创建一个存储Shape的ArrayList:

ArrayList<Shape> shapes = new ArrayList<Shape>();

     为了利用泛型的优点,很多标准类库构件都已经进行了修改。就像我们将要看到的那样,泛型对本书中的许多代码都产生了重要的影响。

 

1.10 对象的创建和生命期

     在使用对象时,最关键的问题之一便是它们的生成和销毁方式。每个对象为了生存都需要资源,尤其时内存。当我们不再需要一个对象时,它必须被清理掉,使其占有的资源可以被释放和重用。在相对简单的编程情况下,怎样清理对象看起来似乎不是什么挑战:你创建了对象,根据需要使用它,然后它应该被销毁。然而,你很可能会遇到相对复杂的情况。

     例如,假设你正在为某个机场设计空中交通管理系统(同样的模型在仓库货柜管理系统、录像带出租系统或宠物寄宿店也适用)。一开始问题似乎很简单:创建一个容器来保存所有的飞机,然后为每一架进入空中交通控制区的飞机创建一个新的飞机对象,并将其置于容器中。对于清理工作,只需在飞机离开此区域时删除相关的飞机对象即可。

     但是,可能还有别的系统记录着有关飞机的数据,也许这些数据不需要像主要控制功能那样立即引人注意。例如,它可能记录了所有飞离机场的小型飞机的飞行计划。因此你需要有第二个容器来存放小型飞机;无论何时,只要创建的使小型飞机对象,那么它同时也应该置入第二个容器内。然后某个后台进程在空闲时对第二个容器内的对象进行操作。

     现在问题变得更困难了:怎样才能知道何时销毁这些对象呢?当处理完某个对象之后,系统某个其他部分可能还在处理它。在其他许多场合中会遇到同样的问题,在必须明确删除对象的编程系统中(例如C++),此问题会变得十分复杂。

     对象的数据位于何处?怎样控制对象的生命周期?C++认为效率控制是最重要的议题,所以给程序员提供了选择的权利。为了追求最大的执行速度,对象的存储空间和生命周期可以在编写程序时确定,这可以通过将对象置于堆栈(它们有时被为自动变量 (automatic variable)或限域变量(scoped variable))或静态存储区域内来实现。这种方式将存储空间分配和释放置于优先考虑的位置,某些情况下这样控制非常有价值。但是,也牺牲了灵活性,因为必须在编写程序时知道对象确切的数量、生命周期和类型。如果试图解决更一般化的问题,例如计算机辅助设计、仓库管理或者空中交通控制,这种方式就显得过于受限了。

     第二种方式是在被称为堆(heap)的内存池中动态地创建对象。在这种方式中,直到运行时才知道需要多少对象,它们的生命周期如何,以及它们的具体类型时什么。这些问题的结果只能在程序运行时相关代码被执行到的那一刻才能确定。如果需要一个新对象,可以在需要的时刻直接在堆中创建。因为存储空间是在运行时被动态管理的,所以需要大量的时间在堆中分配存储空间,这可能要远远大于在堆栈中创建存储空间的时间。在堆栈中创建存储空间和释放存储空间通常各需要一条汇编指令即可,分别对应将栈顶指针向下移动和将栈顶指针向上移动。创建堆存储空间的时间依赖于存储机制的设计。

     动态方式有这样一个一般性的逻辑假设:对象趋向于变得复杂,所以查找和释放存储空间的开销不会对对象的创建造成重大冲击。动态方式所带来的更大的灵活性正是解决一般化编程问题的要点所在。

     java完全采用了动态内存分配方式。每当想要创建新对象时,就要使用new关键字来构建此对象的动态实例。

     还有一个议题,就是对象生命周期。对于允许在堆栈上创建对象的语言,编译器可以确定对象存活的时间,并可以自动销毁它。然而,如果是在堆上创建对象,编译器就会对它的生命周期一无所知。在像C++这样的语言中,必须通过编程方式来确定何时销毁对象,这可能会因为不能正确处理而导致内存泄露(这在C++程序中是常见的问题)。Java提供了被称为“垃圾回收器”的机制,它可以自动发现对象何时不再被使用,并继而销毁它。垃圾回收器非常有用,因为它减少了所必须考虑的议题和必须编写的代码。更重要的是,垃圾回收器提供了更高层的保障,可以避免暗藏的内存泄露问题,这个问题已经使许多C++项目折戟沉沙。

     Java的垃圾回收器被设计用来处理内存释放问题(尽管它不包括清理对象的其他方面)。垃圾回收器“知道”对象何时不再被使用,并且自动释放对象占用的内存。这一点同所有对象都是继承自单根基类Object以及只能以一种方式创建对象(在堆上创建)这两个特性结合起来,使得用Java编程的过程较之用C++编程要简单得多,所要做出的决策和要克服的障碍也少得多。

 

1.11 异常处理:处理错误

     自从编程语言问世以来,错误处理就始终是最困难的问题之一。因为设计一个良好的错误处理机制非常困难,所以许多语言直接略去这个问题,将其交给程序库设计者处理,而这些设计者也只是提出了一些不彻底的方法,这些方法可用于许多很容易就可以绕过此问题的场合,而且其解决方式通常也只是忽略此问题。大多数错误处理机制的主要问题在于,它们都依赖于程序员自身的警惕性,这种警惕性来源于一种共同的约定,而不是编程语言所强制的。如果程序员不够警惕------通常是因为他们太忙,这些机制就很容易被忽视。

 

 

转载于:https://my.oschina.net/u/3420885/blog/1580725

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值