JAVA编程思想(中文版)


“上帝赋予人类说话的能力,而言语又创造了思想,思想是人类对宇宙的量度。”

                  ——摘自《Prometheus Unbound》,Shelley

? 人类……极其受那些已经成为社会表达工具的特定语言的支配。想像一下,如果一个人可以不使用语言就能够从本质上适应现实世界,语言仅仅是解决具体的交流和反映问题时偶尔才用到的方式,我们会发现,这只能是一种幻想。事实上,“真实世界”在很大程度上是不知不觉地基于群体的语言习惯形成的。

                             ——摘自《The Status of Linguistics As A Science》,1929, Edward Sapir

    如同任何人类语言一样,Java提供了一种表达概念的方式。如果使用得当,随着问题变得更庞大更复杂,这种表达工具将会比别的可供选择的语言更为简单、灵活。

  我们不应该将Java仅仅看作是一些特性的集合—有一些特性在孤立状态下没有任何意义。只有在考虑到设计,而不仅仅是编码时,才能完整地运用 Java的各部分。而且,要按照这种方式来理解Java,必须理解在语言和编程中经常碰到的问题。这本书讨论的是编程问题,它们为什么成为问题,以及 Java已经采取什么样的方案来解决它们。因此,每章所阐述的特性集,都是基于我所看到的这一语言在解决特定类型问题时的方式。按照这种方式,我希望能够每次引导读者前进一点,直到Java思想意识成为你最自然不过的语言。

  自始至终,我一直持这样的观点:你需要在头脑中创建一个模型,以加强对这种语言的深入理解;如果你遇到了疑问,就将它反馈到头脑中的模型并推断出答案。
一开始,我只是将Java看作“又一种程序设计语言”。从许多方面看,它也的确如此。

    但随着时间流逝,以及对Java的深入研究,我渐渐发现,与我所见过的其他编程语言相比,Java有着完全不同的核心目的。

    程序设计其实是对复杂性的管理:待解决问题的复杂性,以及用来解决该问题的工具的复杂性。正是这种复杂性,导致多数程序设计项目失败。在我所知的所有程序设计语言中,几乎没有哪个将自己的设计目标专注于克服开发与维护程序的复杂性。当然,有些编程语言在设计决策时也曾考虑到复杂性的问题,然而,总是会有其他议题被认为更有必要加入到该语言中。于是不可避免地,正是这些所谓更必要的议题导致程序员最终“头撞南墙”。例如,C++选择向后兼容C(以便更容易吸引C程序员),以及具备C一样的高效率。这两点都是非常有益的设计目标,也确实促成了C++的成功,然而它们却暴露出更多的复杂性问题,而这也使得很多项目不得善终(你自然可以责怪程序员或者项目管理,但是,如果一种语言能够帮助你解决错误,那何乐而不为呢?)。再看一个例子,Visual Basic(VB)选择与Basic绑在一起,而Basic并未被设计为具备可扩展性的程序设计语言,结果呢,建立在VB之上的所有扩展都导致了无法维护的语法。还有Perl,它向后兼容awk、sed、grep,以及所有它打算替代的Unix工具,结果呢,人们开始指责Perl程序成了“不可阅读(write-only)的代码”(即,只要稍过一会儿,你就读不懂刚完成的程序了)。从另一个角度看,在设计C++、VB、Perl以及 Smalltalk之类的程序设计语言时,设计师也都为解决复杂性问题做了某种程度的工作。并且,正是解决某类特定问题的能力,成就了它们的成功。

    随着对Java的了解越来越深,Sun对Java的设计目标给我留下了最深刻印象,那就是:为程序员减少复杂性。用他们的话说就是:“我们关心的是,减少开发健壮代码所需的时间以及困难。”在早期,这个目标使得代码的运行并不快(Java程序的运行效率已经改善了),但它确实显著地缩短了代码的开发时间。与用C++开发相同的程序相比,采用Java只需一半甚至更少的开发时间。仅此一项,就已经能节约无法估量的时间与金钱了。然而Java并未止步于此。它开始着手解决日渐变得重要的各种复杂任务,例如多线程与网络编程,并将其作为语言特性或以工具库的形式纳入Java,这使得开发此类应用变得倍加简单。最终,Java解决了一些相当大的复杂性问题:跨平台编程、动态代码修改,甚至是安全的议题。它让你在面对其中任何一个问题时,都能从“举步维艰”到“起立鼓掌”。抛去我们都能看到的性能问题,Java确实非常精彩地履行了它的诺言:极大地提升程序员的生产率。

    同时,Java正从各个方面提升人们相互通讯的带宽。它使得一切都变得更容易:编写程序,团队合作,创建与用户交户的用户界面,在不同类型的机器上运行程序,以及编写通过因特网通信的程序。

    我认为,通讯变革的成果并不见得就是传输巨量的比特。我们所看到的真正变革是人与人之间的通讯变得更容易了:无论是一对一的通信,还是群体与群体之间,甚至整个星球之间的通信。我曾听闻,在足够多的人之间的相互联系之上,下一次变革将是一种全球意识的形成。Java说不定就是促进该变革的工具,至少,它所具备的可能性使我觉得,教授这门语言是非常有意义的一件事情
“我们之所以将自然界分解,组织成各种概念,并按其含义分类,主要是因为我们是整个口语交流社会共同遵守的协定的参与者,这个协定以语言的形式固定下来……除非赞成这个协定中规定的有关语言信息的组织和分类,否则我们根本无法交谈。”

—Benjamin Lee Whorf (1897~1941)

计算机革命起源于机器,因此,编程语言的产生也始于对机器的模仿。

但是,计算机并非只是机器那么简单。计算机是头脑延伸的工具(就像Steve Jobs常喜欢说的“头脑的自行车”一样),同时还是一种不同类型的表达媒体。因此,这种工具看起来已经越来越不像机器,而更像我们头脑的一部分,以及一种如写作、绘画、雕刻、动画、电影等一样的表达形式。面向对象程序设计(Object-oriented Programming, OOP)便是这种以计算机作为表达媒体的大趋势中的组成部分。

本章将向读者介绍包括开发方法概述在内的OOP的基本概念。本章,乃至本书中,都假设读者已经具备了某些编程经验(当然不一定是C的)。如果读者认为在阅读本书之前还需要在程序设计方面多做些准备,那么就应该去研读可以从www.MindView.net网站上下载的《C编程思想》(Thinking in C)的多媒体资料。

本章介绍的是背景性的和补充性的材料。许多人在没有了解面向对象程序设计的全貌之前,感觉无法轻松自在地从事此类编程。因此,此处将引入许多概念,以期帮助读者扎实地了解OOP。然而,还有些人可能在看到具体结构之前,无法了解面向对象程序设计的全貌,这些人如果没有代码在手,就会陷于困境并最终迷失方向。如果你属于后面这个群体,并且渴望尽快获取Java语言的细节,那么可以先越过本章—在此处越过本章并不会妨碍你编写程序和学习语言。但是,你最终还是要回到本章来补充所学知识,这样才能够了解到对象的重要性,以及怎样使用对象进行设计。

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

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

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

Alan Kay曾经总结了第一个成功的面向对象语言、同时也是Java所基于的语言之一的Smalltalk的五个基本特性,这些特性表现了一种纯粹的面向对象程序设计方式:

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

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

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

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

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

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


1.2   每个对象都有一个接口
亚里士多德大概是第一个深入研究类型(type)的哲学家,他曾提出过鱼类和鸟类这样的概念。所有的对象都是唯一的,但同时也是具有相同的特性和行为的对象所归属的类的一部分。这种思想被直接应用于第一个面向对象语言Simula-67,它在程序中使用基本关键字class来引入新的类型。

Simula,就像其名字一样,是为了开发诸如经典的“银行出纳员问题”(bank teller problem)这样的仿真程序而创建的。在银行出纳员问题中,有出纳、客户、账户、交易和货币单位等许多“对象”。在程序执行期间具有不同的状态而其他方面都相似的对象会被分组到对象的类中,这就是关键字class的由来。创建抽象数据类型(类)是面向对象程序设计的基本概念之一。抽象数据类型的运行方式与内置(built-in)类型几乎完全一致:你可以创建某一类型的变量(按照面向对象的说法,称其为对象或实例),然后操作这些变量(称其为发送消息或请求;发送消息,对象就知道要做什么)。每个类的成员或元素都具有某种共性:每个账户都有结余金额,每个出纳都可以处理存款请求等。同时,每个成员都有其自身的状态:每个账户都有不同的结余金额,每个出纳都有自己的姓名。因此,出纳、客户、账户、交易等都可以在计算机程序中被表示成唯一的实体。这些实体就是对象,每一个对象都属于定义了特性和行为的某个特定的类。

所以,尽管我们在面向对象程序设计中实际上进行的是创建新的数据类型,但事实上所有的面向对象程序设计语言都使用class这个关键词来表示数据类型。当看到类型一词时,可将其作为类来考虑,反之亦然。

因为类描述了具有相同特性(数据元素)和行为(功能)的对象集合,所以一个类实际上就是一个数据类型,例如所有浮点型数字具有相同的特性和行为集合。二者的差异在于,程序员通过定义类来适应问题,而不再被迫只能使用现有的用来表示机器中的存储单元的数据类型。可以根据需求,通过添加新的数据类型来扩展编程语言。编程系统欣然接受新的类,并且像对待内置类型一样地照管它们和进行类型检查。

面向对象方法并不是仅局限于构建仿真程序。无论你是否赞成以下观点,即任何程序都是你所设计的系统的一种仿真,面向对象技术的应用确实可以将大量的问题很容易地降解为一个简单的解决方案。

一旦类被建立,就可以随心所欲地创建类的任意个对象,然后去操作它们,就像它们是存在于你的待求解问题中的元素一样。事实上,面向对象程序设计的挑战之一,就是在问题空间的元素和解空间的对象之间创建一对一的映射。

但是,怎样才能获得有用的对象呢?必须有某种方式产生对对象的请求,使对象完成各种任务,如完成一笔交易、在屏幕上画图、打开开关等等。每个对象都只能满足某些请求,这些请求由对象的接口(interface)所定义,决定接口的便是类型。以电灯泡为例来做一个简单的比喻(如图所示):

 

 

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

上例中,类型/类的名称是Light,特定的Light对象的名称是lt,可以向Light对象发出的请求是:打开它、关闭它、将它调亮、将它调暗。你以下列方式创建了一个Light对象:定义这个对象的“引用”(lt),然后调用new方法来创建该类型的新对象。为了向对象发送消息,需要声明对象的名称,并以圆点符号连接一个消息请求。从预定义类的用户观点来看,这些差不多就是用对象来进行设计的全部。

前面的图是UML(Unified Modelling Language,统一建模语言)形式的图,每个类都用一个方框表示,类名在方框的顶部,你所关心的任何数据成员都描述在方框的中间部分,方法(隶属于此对象的、用来接收你发给此对象的消息的函数)在方框的底部。通常,只有类名和公共方法被示于UML设计图中,因此,方框的中部就像本例一样并未给出。如果只对类型感兴趣,那么方框的底部甚至也不需要给出。

 


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

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

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

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

 

 

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

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

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

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

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

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

 

 

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

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

 

(上面这张UML图用实心菱形表明了组合关系。我通常采用最简单的形式:仅仅用一条没有菱形的线来表示关联)。

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

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

 


1.6   继承

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

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

 

(这张UML图中的箭头从导出类指向基类,就像稍后你会看到的,通常会存在一个以上的导出类。)

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

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

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

 

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

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

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

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

 

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

 

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

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

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

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

 

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

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

 


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

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

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

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

 

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

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

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

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

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

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

 

这个方法可以与任何Shape对话,因此它是独立于任何它要绘制和擦除的对象的具体类型的。如果程序中其他部分用到了doSomething()方法:

 

对doSomething()的调用会自动地正确处理,而不管对象的确切类型。

这是一个相当令人惊奇的诀窍。看看下面这行代码:

 

当Circle被传入到预期接收Shape的方法中,究竟会发生什么。由于Circle可以被doSomething()看作是Shape,也就是说,doSomething()可以发送给Shape的任何消息,Circle都可以接收,那么,这么做是完全安全且合乎逻辑的。

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

 

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

 

注意这些代码并不是说“如果是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:

 

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

 

 

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

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

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

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

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

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

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

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

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

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

 


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

异常处理将错误处理直接置于编程语言中,有时甚至置于操作系统中。异常是一种对象,它从出错地点被“抛出”,并被专门设计用来处理特定类型错误的相应的异常处理器“捕获”。异常处理就像是与程序正常执行路径并行的、在错误发生时执行的另一条路径。因为它是另一条完全分离的执行路径,所以它不会干扰正常的执行代码。这往往使得代码编写变得简单,因为不需要被迫定期检查错误。此外,被抛出的异常不像方法返回的错误值和方法设置的用来表示错误条件的标志位那样可以被忽略。异常不能被忽略,所以它保证一定会在某处得到处理。最后需要指出的是:异常提供了一种从错误状况进行可靠恢复的途径。现在不再是只能退出程序,你可以经常进行校正,并恢复程序的执行,这些都有助于编写出更健壮的程序。

Java的异常处理在众多的编程语言中格外引人注目,因为Java一开始就内置了异常处理,而且强制你必须使用它。它是唯一可接受的错误报告方式。如果没有编写正确的处理异常的代码,那么就会得到一条编译时的出错消息。这种有保障的一致性有时会使得错误处理非常容易。

值得注意的是,异常处理不是面向对象的特征—尽管在面向对象语言中异常常被表示成为一个对象。异常处理在面向对象语言出现之前就已经存在了。

 

 


1.12   并发编程
在计算机编程中有一个基本概念,就是在同一时刻处理多个任务的思想。许多程序设计问题都要求,程序能够停下正在做的工作,转而处理某个其他问题,然后再返回主进程。有许多方法可以实现这个目的。最初,程序员们用所掌握的有关机器底层的知识来编写中断服务程序,主进程的挂起是通过硬件中断来触发的。尽管这么做可以解决问题,但是其难度太大,而且不能移植,所以使得将程序移植到新型号的机器上时,既费时又费力。

有时中断对于处理时间性强的任务是必需的,但是对于大量的其他问题,我们只是想把问题切分成多个可独立运行的部分(任务),从而提高程序的响应能力。在程序中,这些彼此独立运行的部分称之为线程,上述概念被称为“并发”。并发最常见的例子就是用户界面。通过使用任务,用户可以在揿下按钮后快速得到一个响应,而不用被迫等待到程序完成当前任务为止。

通常,线程只是一种为单一处理器分配执行时间的手段。但是如果操作系统支持多处理器,那么每个任务都可以被指派给不同的处理器,并且它们是在真正地并行执行。在语言级别上,多线程所带来的便利之一便是程序员不用再操心机器上是有多个处理器还是只有一个处理器。由于程序在逻辑上被分为线程,所以如果机器拥有多个处理器,那么程序不需要特殊调整也能执行得更快。

所有这些都使得并发看起来相当简单,但是有一个隐患:共享资源。如果有多个并行任务都要访问同一项资源,那么就会出问题。例如,两个进程不能同时向一台打印机发送信息。为了解决这个问题,可以共享的资源,例如打印机,必须在使用期间被锁定。因此,整个过程是:某个任务锁定某项资源,完成其任务,然后释放资源锁,使其他任务可以使用这项资源。

Java的并发是内置于语言中的,Java SE5已经增添了大量额外的库支持。

 

 

 

1.13   Java与Internet
如果Java仅仅只是众多的程序设计语言中的一种,你可能就会问:为什么它如此重要?为什么它促使计算机编程语言向前迈进了革命性的一步?如果从传统的程序设计观点看,问题的答案似乎不太明显。尽管Java对于解决传统的单机程序设计问题非常有用,但同样重要的是,它解决了在万维网(WWW)上的程序设计问题。

1.13.1   Web是什么

Web一词乍一看有点神秘,就像“网上冲浪”、“表现”、“主页”一样。回头审视它的真实面貌有助于对它的理解,但是要这么做就必须先理解客户/服务器系统,它是计算技术中另一个充满了诸多疑惑的话题。

1. 客户/服务器计算技术

客户/服务器系统的核心思想是:系统具有一个中央信息存储池(central repository of information),用来存储某种数据,它通常存在于数据库中,你可以根据需要将它分发给某些人员或机器集群。客户/服务器概念的关键在于信息存储池的位置集中于中央,这使得它可以被修改,并且这些修改将被传播给信息消费者。总之,信息存储池、用于分发信息的软件以及信息与软件所驻留的机器或机群被总称为服务器。驻留在用户机器上的软件与服务器进行通信,以获取信息、处理信息,然后将它们显示在被称为客户机的用户机器上。

客户/服务器计算技术的基本概念并不复杂。问题在于你只有单一的服务器,却要同时为多个客户服务。通常,这会涉及数据库管理系统,因此设计者把数据“均衡”分布于数据表中,以取得最优的使用效果。此外,系统通常允许客户在服务器中插入新的信息。这意味着必须保证一个客户插入的新数据不会覆盖另一个客户插入的新数据,也不会在将其添加到数据库的过程中丢失(这被称为事务处理)。如果客户端软件发生变化,那么它必须被重新编译、调试并安装到客户端机器上,事实证明这比想像的要更加复杂与费力。如果想支持多种不同类型的计算机和操作系统,问题将更麻烦。最后还有一个最重要的性能问题:可能在任意时刻都有成百上千的客户向服务器发出请求,所以任何小的延迟都会产生重大影响。为了将延迟最小化,程序员努力减轻处理任务的负载,通常是分散给客户端机器处理,但有时也会使用所谓的中间件将负载分散给在服务器端的其他机器。(中间件也被用来提高可维护性。)

分发信息这个简单思想的复杂性实际上是有很多不同层次的,这使得整个问题可能看起来高深莫测。但是它仍然至关重要:算起来客户/服务器计算技术大概占了所有程序设计行为的一半,从制定订单、信用卡交易到包括股票市场、科学计算、政府、个人在内的任意类型的数据分发。过去我们所做的,都是针对某个问题发明一个单独的解决方案,所以每一次都要发明一个新的方案。这些方案难以开发且难以使用,而且用户对每一个方案都要学习新的接口。因此,整个客户/服务器问题需要彻底解决。

2. Web就是一台巨型服务器

Web实际上就是一个巨型客户/服务器系统,但稍微差一点,因为所有的服务器和客户机都同时共存于同一个网络中。你不需要了解这些,因为你所要关心的只是在某一时刻怎样连接到一台服务器上,并与之进行交互(即便你可能要满世界地查找你想要的服务器)。

最初只有一种很简单的单向过程:你对某个服务器产生一个请求,然后它返回给你一个文件,你的机器(也就是客户机)上的浏览器软件根据本地机器的格式来解读这个文件。但是很快人们就希望能够做得更多,而不仅仅是从服务器传递回页面。人们希望实现完整的客户/服务器能力,使得客户可以将信息反馈给服务器。例如,在服务器上进行数据库查找,将新信息添加到服务器以及下订单(这需要特殊的安全措施)。这些变革,正是我们在Web发展过程中所目睹的。

Web浏览器向前跨进了一大步,它包含了这样的概念:一段信息不经修改就可以在任意型号的计算机上显示。然而,最初的浏览器仍然相当原始,很快就因为加诸于其上的种种需要而陷入困境。浏览器并不具备显著的交互性,而且它趋向于使服务器和Internet阻塞,因为在任何时候,只要你需要完成通过编程来实现的任务,就必须将信息发回到服务器去处理。这使得即便是发现你的请求中的拼写错误也要花去数秒甚至是数分钟的时间。因为浏览器只是一个观察器,因此它甚至不能执行最简单的计算任务。(另一方面,它却是安全的,因为它在你的本地机器上不会执行任何程序,而这些程序有可能包含bug和病毒。)

为了解决这个问题,人们采用了各种不同的方法。首先,图形标准得到了增强,使得在浏览器中可以播放质量更好的动画和视频。剩下的问题通过引入在客户端浏览器中运行程序的能力就可以解决。这被称为“客户端编程”。

1.13.2   客户端编程

Web最初的“服务器-浏览器”设计是为了能够提供交互性的内容,但是其交互性完全由服务器提供。服务器产生静态页面,提供给只能解释并显示它们的客户端浏览器。基本的HTML(HyperText Markup Language,超文本标记语言)包含有简单的数据收集机制:文本输入框、复选框、单选框、列表和下拉式列表以及按钮—它只能被编程来实现复位表单上的数据或提交表单上的数据给服务器。这种提交动作通过所有的Web服务器都提供的通用网关接口(common gateway interface,CGI)传递。提交内容会告诉CGI应该如何处理它。最常见的动作就是运行一个在服务器中常被命名为“cgi-bin”的目录下的一个程序。(当点击了网页上的按钮时,如果观察浏览器窗口顶部的地址,有时可以看见“cgi-bin”的字样混迹在一串冗长和不知所云的字符中。)几乎所有的语言都可以用来编写这些程序,Perl已经成为最常见的选择,因为它被设计用来处理文本,并且是解释型语言,因此无论服务器的处理器和操作系统如何,它都适于安装。然而,Python(www.Python.org)已对其产生了重大的冲击,因为它更强大且更简单。

当今许多有影响力的网站完全构建于CGI之上的,实际上你几乎可以通过CGI做任何事。然而,构建于CGI程序之上的网站可能会迅速变得过于复杂而难以维护,并同时产生响应时间过长的问题。CGI程序的响应时间依赖于所必须发送的数据量的大小,以及服务器和Internet的负载。(此外,启动CGI程序也相当慢。)Web的最初设计者们并没有预见到网络带宽被人们开发的各种应用迅速耗尽。例如,任何形式的动态图形处理几乎都不可能连贯地执行,因为图形交互格式(graphic interchange format,GIF)的文件必须在服务器端创建每一个图形版本,并发送给客户端。再比如,你肯定经历过对Web输入表单进行数据验证的过程:你按下网页上的提交按钮;数据被发送回服务器;服务器启动一个CGI程序来检查、发现错误,并将错误组装为一个HTML页面,然后将这个页面发回给你;之后你必须回退一个页面,然后重新再试。这个过程不仅很慢,而且不太优雅。

问题的解决方法就是客户端编程。大多数运行Web浏览器的机器都是能够执行大型任务的强有力的引擎。在使用原始的静态HTML方式的情况下,它们只是闲在那里,等着服务器送来下一个页面。客户端编程意味着Web浏览器能用来执行任何它可以完成的工作,使得返回给用户的结果更加迅捷,而且使得你的网站更加具有交互性。

客户端编程的问题是:它与通常意义上的编程十分不同,参数几乎相同,而平台却不同。Web浏览器就像一个功能受限的操作系统。最终,你仍然必须编写程序,而且还得处理那些令人头晕眼花的成堆的问题,并以客户端编程的方式来产生解决方案。本节剩下的部分对客户端编程的问题和方法作一概述。

1. 插件

客户端编程所迈出的最重要的一步就是插件(plug-in)的开发。通过这种方式,程序员可以下载一段代码,并将其插入到浏览器中适当的位置,以此来为浏览器添加新功能。它告诉浏览器:从现在开始,你可以采取这个新行动了(只需要下载一次插件即可)。某些更快更强大的行为都是通过插件添加到服务器中的。但是编写插件并不是件轻松的事,也不是构建某特定网站的过程中所要做的事情。插件对于客户端编程的价值在于:它允许专家级的程序员不需经过浏览器生产厂商的许可,就可以开发某种语言扩展,并将它们添加到服务器中。因此,插件提供了一个“后门”,使得可以创建新的客户端编程语言(但是并不是所有的客户端编程语言都是以插件的形式实现的)。

2. 脚本语言

插件引发了浏览器脚本语言(scripting language)的开发。通过使用某种脚本语言,你可以将客户端程序的源代码直接嵌入到HTML页面中,解释这种语言的插件在HTML页面被显示时自动激活。脚本语言先天就相当易于理解,因为它们只是作为HTML页面一部分的简单文本,当服务器收到要获取该页面的请求时,它们可以被快速加载。此方法的缺点是代码会暴露给任何人去浏览(或窃取)。但是,通常不会使用脚本语言去做相当复杂的事情,所以这个缺点并不太严重。

如果你期望有一种脚本语言在Web浏览器不需要任何插件的情况下就可以得到支持,那它非JavaScript莫属(它与Java之间只存在表面上的相似,要想使用它,你必须在额外的学习曲线上攀爬。它之所以这样被命名只是因为想赶上Java潮流)。遗憾的是,大多数Web浏览器最初都是以彼此相异的方式来实现对JavaScript的支持的,这种差异甚至存在于同一种浏览器的不同版本之间。以ECMAScript的形式实现的JavaScript的标准化有助于此问题的解决,但是不同的浏览器为了跟上这一标准化趋势已经花费了相当长的时间(并且这种努力由于微软一直在推进它自己的VBScript形式的标准化日程而显得无所帮助,VBScript与JavaScript之间也存在着暧昧的相似性)。通常,你必须以JavaScript的某种最小公分母形式来编程,以使得你的程序可以在所有的浏览器上运行。JavaScript的错误处理的调试只能一团糟来形容。作为其使用艰难的证据,我们可以看到直到最近才有人创建了真正复杂的JavaScript脚本片段(Google在GMail),并且编写这样的脚本需要超然的奉献精神和超高的专业技巧。

这也表明,在Web浏览器内部使用的脚本语言实际上总是被用来解决特定类型的问题,主要是用来创建更丰富、更具有交互性的图形化用户界面(graphic user interface, GUI)。但是,脚本语言可以解决客户端编程中所遇到的百分之八十的问题。你的问题可能正好落在这百分之八十的范围之内,由于脚本语言提供了更容易、更快捷的开发方式,因此你应该在考虑诸如Java这样的更复杂的解决方案之前,先考虑脚本语言。

3. Java

如果脚本语言可以解决客户端编程百分之八十的问题的话,那么剩下那百分之二十(那才是真正难啃的硬骨头)又该怎么办呢?Java是处理它们最流行的解决方案。Java不仅是一种功能强大的、安全的、跨平台的、国际化的编程语言,而且它还在不断地被扩展,以提供更多的语言功能和类库,能够优雅地处理在传统编程语言中很难解决的问题,例如并发、数据库访问、网络编程和分布式计算。Java是通过applet以及使用Java Web Start来进行客户端编程的。

applet是只在Web浏览器中运行的小程序,它是作为网页的一部分而自动下载的(就像网页中的图片被自动下载一样)。当applet被激活时,它便开始执行一个程序,这正是它优雅之处:它提供一种分发软件的方法,一旦用户需要客户端软件时,就自动从服务器把客户端软件分发给用户。用户获取最新版本的客户端软件时不会产生错误,而且也不需要很麻烦的重新安装过程。Java的这种设计方式,使得程序员只需创建单一的程序,而只要一台计算机有浏览器,且浏览器具有内置的Java解释器(大多数的机器都如此),那么这个程序就可以自动在这台计算机上运行。由于Java是一种成熟的编程语言,所以在提出对服务器的请求之前和之后,可以在客户端尽可能多地做些事情。例如,不必跨网络地发送一张请求表单来检查自己是否填写了错误的日期或其他参数,客户端计算机就可以快速地标出错误数据,而不用等待服务器作出标记并给你传回图片。这不仅立即就获得了高速度和快速的响应能力,而且也降低了网络流量和服务器负载,从而不会使整个Internet的速度都慢了下来。

4. 备选方案

老实说,Java applet没有达到当初它所吹嘘的境界。当Java首度出现时,似乎大家最欢欣鼓舞的莫过于applet了,因为它们最终将解决严峻的客户端可编程性问题,从而提高基于互联网的应用的可响应性,同时降低它们对带宽的需求。人们展望到了大量的可能性。

实际上,你可以发现在Web上确实存在一些非常灵巧的applet,但是压倒性的向applet的迁移却始终未发生。这其中最大的问题可能在于安装Java运行时环境(JRE)所必需的10MB带宽对于一般的用户来说过于恐怖了,而微软没有选择在IE(Internet Explorer)中包含JRE这一事实也许就此已经封杀了applet的命运。无论怎样,Java applet始终没有得到大规模应用。

尽管如此,applet和Java Web Start应用在某些情况下仍旧很有价值。无论何时,只要你想控制用户的机器,例如在一个公司的内部,使用这些技术来发布和更新客户端应用就显得非常恰当,并且这可以节省大量的时间、人力和财力,特别是你需要频繁地更新的时候。

在“图形化用户界面”一章中,我们将看到一种折中的新技术,Macromedia的Flex,它允许你创建基于Flash的与applet相当的应用。因为Flash Player在超过98%的Web浏览器上都可用(包含Windows, Linux和Mac操作系统上的浏览器),因此它被认为是事实上已被接受的标准。安装和更新Flash Player都十分快捷。ActionScript语言是基于ECMAScript的,因此我们对它应该很熟悉,但是Flex使得我们在编程时无需担心浏览器相关性,因此,它远比JavaScript要吸引人得多。对于客户端编程而言,这是一种值得考虑的备选方案。

5. .NET和C#

曾几何时,Java applet的主要竞争对手是微软的ActiveX—尽管它要求客户端必须运行Windows平台。从那以后,微软以.NET平台和C#编程语言的形式推出了与Java全面竞争的对手。.NET平台大致相当于Java虚拟机(JVM,即执行Java程序的软件平台)和Java类库,而C#毫无疑问与Java有类似之处。这当然是微软在编程语言与编程环境这块竞技场上所做出的最出色的成果。当然,他们有相当大的有利条件:他们可以看得到Java在什么方面做得好,在什么方面做得还不够好,然后基于此去构建,并要具备Java不具备的优点。这是自从Java出现以来,Java所碰到的真正的竞争。因此,Sun的Java设计者们已经认真仔细地去研究了C#,以及为什么程序员们可能会转而使用它,然后通过在Java SES中对Java做出的重大改进而做出了回应。

目前,.NET主要受攻击的地方和人们所关心的最重要的问题就是,微软是否会允许将它完全地移植到其他平台上。微软宣称这么做没有问题,而且Mono项目(www.go-mono.com)已经有了一个在Linux上运行的.NET的部分实现;但是,在该实现完成及微软不会排斥其中的任何部分之前,.NET作为一种跨平台的解决方案仍旧是一场高风险的赌博。

6. Internet与Intranet

Web是最常用的解决客户/服务器问题的方案,因此,即便是解决这个问题的一个子集,特别是公司内部的典型的客户/服务器问题,也一样可以使用这项技术。如果采用传统的客户/服务器方式,可能会遇到客户端计算机有多种型号的问题,也可能会遇到安装新的客户端软件的麻烦,而它们都可以很方便地通过Web浏览器和客户端编程得以解决。当Web技术仅限用于特定公司的信息网络时,它就被称为Intranet(企业内部网)。Intranet比Internet提供了更高的安全性,因为可以物理地控制对公司内部服务器的访问。从培训的角度看,似乎一旦人们理解了浏览器的基本概念后,对他们来说,处理网页和applet的外观差异就会容易得多,因此对新型系统的学习曲线也就减缓了。

安全问题把我们带到了一个领域,这似乎是在客户端编程世界自动形成的。如果程序运行在Internet之上,那么就不可能知道它将运行在什么样的平台之上,因此,要格外小心,不要传播有bug的代码。你需要跨平台的、安全的语言,就像脚本语言和Java。

如果程序运行与Intranet上,那么可能会受到不同的限制。企业内所有的机器都采用Intel/Windows平台并不是什么稀奇的事。在Intranet上,你可以对你自己的代码质量负责,并且在发现bug之后可以修复它们。此外,你可能已经有了以前使用更传统的客户/服务器方式编写的遗留代码,因此,你必须在每一次升级时都要物理地重装客户端程序。在安装升级程序时所浪费的时间是迁移到浏览器方式上的最主要的原因,因为在浏览器方式下,升级是透明的、自动的(Java Web Start也是解决此问题的方式之一)。如果你身处这样的Intranet之中,那么最有意义的方式就是选择一条能够使用现有代码库的最短的捷径,而不是用一种新语言重新编写你的代码。

当面对各种令人眼花缭乱的解决客户端编程问题的方案时,最好的方法就是进行性价比分析。认真考虑问题的各种限制,然后思考哪种解决方案可以成为最短的捷径。既然客户端编程仍然需要编程,那么针对自己的特殊应用选取最快的开发方式总是最好的做法。为那些在程序开发中不可避免的问题提早作准备是一种积极的态度。

1.13.3   服务器端编程

前面的讨论忽略了服务器端编程的话题,它是Java已经取得巨大成功的因素之一。当提出对服务器的请求后,会发生什么呢?大部分时间,请求只是要求“给我发送一个文件”,之后浏览器会以某种适当的形式解释这个文件,例如将其作为HTML页面、图片、Java applet或脚本程序等来解释。

更复杂的对服务器的请求通常涉及数据库事务。常见的情形是复杂的数据库搜索请求,然后服务器将结果进行格式编排,使其成为一个HTML页面发回给客户端。(当然,如果客户端通过Java或脚本程序具备了更多的智能,那么服务器可以将原始的数据发回,然后在客户端进行格式编排,这样会更快,而且服务器的负载将更小。)另一种常见情形是,当你要加入一个团体或下订单时,可能想在数据库中注册自己的名字,这将涉及对数据库的修改。这些数据库请求必须通过服务器端的某些代码来处理,这就是所谓的服务器端编程。过去,服务器端编程都是通过使用Perl、Python、C++或其他某种语言编写CGI程序而实现的,但却造成了从此之后更加复杂的系统。其中就包括基于Java的Web服务器,它让你用Java编写被称为servlet的程序来实现服务器端编程。servlet及其衍生物JSP,是许多开发网站的公司迁移到Java上的两个主要的原因,尤其是因为它们消除了处理具有不同能力的浏览器时所遇到的问题。服务器端编程的话题在《企业Java编程思想》(Thinking in Enterprise Java)一书中有所论述。

 

 


1.14   总结

你知道过程型语言看起来像什么样子:数据定义和函数调用。想了解此类程序的含义,你必须忙上一阵,需要通读函数调用和低层概念,以在脑海里建立一个模型。这正是我们在设计过程式程序时,需要中间表示形式的原因。这些程序总是容易把人搞糊涂,因为它们使用的表示术语更加面向计算机而不是你要解决的问题。

因为OOP在你能够在过程型语言中找到的概念的基础上,又添加了许多新概念,所以你可能会很自然地假设:由此而产生的Java程序比等价的过程型程序要复杂得多。但是,你会感到很惊喜:编写良好的Java程序通常比过程型程序要简单得多,而且也易于理解得多。你看到的只是有关下面两部分内容的定义:用来表示问题空间概念的对象(而不是有关计算机表示方式的相关内容),以及发送给这些对象的用来表示在此空间内的行为的消息。面向对象程序设计带给人们的喜悦之一就是:对于设计良好的程序,通过阅读它就可以很容易地理解其代码。通常,其代码也会少很多,因为许多问题都可以通过重用现有的类库代码而得到解决。

OOP和Java也许并不适合所有的人。重要的是要正确评估自己的需求,并决定Java是否能够最好地满足这些需求,还是使用其他编程系统(包括你当前正在使用的)才是更好的选择。如果知道自己的需求在可预见的未来会变得非常特殊化,并且Java可能不能满足你的具体限制,那么就应该去考察其他的选择(我特别推荐读者看看Python,参见www.Python.org)。即使最终仍旧选择Java作为编程语言,至少也要理解还有哪些选项可供选择,并且对为什么选择这个方向要有清楚的认识。

 

 

一般的类和方法,只能使用具体的类型:要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。

在面向对象编程语言中,多态算是一种泛化机制。例如,你可以将方法的参数类型设为基类,那么该方法就可以接受从这个基类中导出的任何类作为参数。这样的方法更加通用一些,可应用的地方也多一些。在类的内部也是如此,凡是需要说明类型的地方,如果都使用基类,确实能够具备更好的灵活性。但是,考虑到除了final类不能扩展,其他任何类都可以被扩展,所以这种灵活性大多数时候也会有一些性能损耗。

有时候,拘泥于单继承体系,也会使程序受限太多。如果方法的参数是一个接口,而不是一个类,这种限制就放松了许多。因为任何实现了该接口的类都能够满足该方法,这也包括暂时还不存在的类。这就给予客户端程序员一种选择,他可以通过实现一个接口来满足类或方法。因此,接口允许我们快捷地实现类继承,也使我们有机会创建一个新类来做到这一点。

可是有的时候,即便使用了接口,对程序的约束也还是太强了。因为一旦指明了接口,它就要求你的代码必须使用特定的接口。而我们希望达到的目的是编写更通用的代码,要使代码能够应用于“某种不具体的类型”,而不是一个具体的接口或类。

这就是Java SE5的重大变化之一:泛型的概念。泛型实现了参数化类型的概念,使代码可以应用于多种类型。“泛型”这个术语的意思是:“适用于许多许多的类型”。泛型在编程语言中出现时,其最初的目的是希望类或方法能够具备最广泛的表达能力。如何做到这一点呢,正是通过解耦类或方法与所使用的类型之间的约束。稍后你将看到,Java中的泛型并没有这么高的追求,实际上,你可能会质疑,Java中的术语“泛型”是否适合用来描述这一功能。

如果你从未接触过参数化类型机制,那么,在学习了Java中的泛型之后,你会发现,对这门语言而言,泛型确实是一个很有益的补充。在你创建参数化类型的一个实例时,编译器会为你负责转型操作,并且保证类型的正确性。这应该是一个进步。

然而,如果你了解其他语言(例如C++)中的参数化类型机制,你就会发现,有些以前能做到的事情,使用Java的泛型机制却无法做到。使用别人已经构建好的泛型类型会相当容易。但是如果你要自己创建一个泛型实例,就会遇到许多令你吃惊的事情。在本章中,我的任务之一就是向你解释,Java中的泛型是怎样发展成现在这样的。

这并非是说Java的泛型毫无用处。在很多情况下,它们可以使代码更直接更优雅。不过,如果你具备其他语言的经验,而那种语言实现了更纯粹的泛型,那么,Java可能令你失望了。在本章中,我们会介绍Java泛型的优点与局限,希望这能够帮助你更有效地使用Java的这个新功能。

15.1   与C++的比较
Java的设计者曾说过,设计这门语言的灵感主要来自C++。尽管如此,学习Java时,基本上可以不用参考C++。我也是尽力这样做的,除非,与C++的比较能够加深你的理解。

Java中的泛型就需要与C++进行一番比较,理由有二:首先,了解C++模板的某些方面,有助于你理解泛型的基础。同时,非常重要的一点是,你可以了解Java泛型的局限是什么,以及为什么会有这些限制。最终的目的是帮助你理解,Java泛型的边界在哪里。根据我的经验,理解了边界所在,你才能成为程序高手。因为只有知道了某个技术不能做到什么,你才能更好地做到所能做的(部分原因是,不必浪费时间在死胡同里乱转)。

第二个原因是,在Java社区中,人们普遍对C++模板有一种误解,而这种误解可能会误导你,令你在理解泛型的意图时产生偏差。

因此,在本章中会介绍一些C++模板的例子,不过我也会尽量控制它们的篇幅。

 

 

 


15.2   简单泛型
有许多原因促成了泛型的出现,而最引人注目的一个原因,就是为了创造容器类。(关于容器类,你可以参考第11章和第17章这两章。)容器,就是存放要使用的对象的地方。数组也是如此,不过与简单的数组相比,容器类更加灵活,具备更多不同的功能。事实上,所有的程序,在运行时都要求你持有一大堆对象,所以,容器类算得上最具重用性的类库之一。

我们先来看看一个只能持有单个对象的类。当然了,这个类可以明确指定其持有的对象的类型:

 

不过,这个类的可重用性就不怎么样了,它无法持有其他类型的任何对象。我们可不希望为碰到的每个类型都编写一个新的类。

在Java SE5之前,我们可以让这个类直接持有Object类型的对象:

 

现在,Holder2可以存储任何类型的对象,在这个例子中,只用了一个Holder2对象,却先后三次存储了三种不同类型的对象。

有些情况下,我们确实希望容器能够同时持有多种类型的对象。但是,通常而言,我们只会使用容器来存储一种类型的对象。泛型的主要目的之一就是用来指定容器要持有什么类型的对象,而且由编译器来保证类型的正确性。

因此,与其使用Object,我们更喜欢暂时不指定类型,而是稍后再决定具体使用什么类型。要达到这个目的,需要使用类型参数,用尖括号括住,放在类名后面。然后在使用这个类的时候,再用实际的类型替换此类型参数。在下面的例子中,T就是类型参数:

 

现在,当你创建Holder3对象时,必须指明想持有什么类型的对象,将其置于尖括号内。就像main()中那样。然后,你就只能在Holder3中存入该类型(或其子类,因为多态与泛型不冲突)的对象了。并且,在你从Holder3中取出它持有的对象时,自动地就是正确的类型。

这就是Java泛型的核心概念:告诉编译器想使用什么类型,然后编译器帮你处理一切细节。

一般而言,你可以认为泛型与其他的类型差不多,只不过它们碰巧有类型参数罢了。稍后我们会看到,在使用泛型时,我们只需指定它们的名称以及类型参数列表即可。

练习1:(1) 配合typeinfo.pets类库,用Holder3来证明,如果指定Holder3可以持有某个基类类型,那么它也能持有导出类型。

练习2:(1) 创建一个Holder类,使其能够持有具有相同类型的3个对象,并提供相应的读写方法访问这些对象,以及一个可以初始化其持有的3个对象的构造器。

15.2.1   一个元组类库

仅一次方法调用就能返回多个对象,你应该经常需要这样的功能吧。可是return语句只允许返回单个对象,因此,解决办法就是创建一个对象,用它来持有想要返回的多个对象。当然,可以在每次需要的时候,专门创建一个类来完成这样的工作。可是有了泛型,我们就能够一次性地解决该问题,以后再也不用在这个问题上浪费时间了。同时,我们在编译期就能确保类型安全。

这个概念称为元组(tuple),它是将一组对象直接打包存储于其中的一个单一对象。这个容器对象允许读取其中元素,但是不允许向其中存放新的对象。(这个概念也称为数据传送对象,或信使。)

通常,元组可以具有任意长度,同时,元组中的对象可以是任意不同的类型。不过,我们希望能够为每一个对象指明其类型,并且从容器中读取出来时,能够得到正确的类型。要处理不同长度的问题,我们需要创建多个不同的元组。下面的程序是一个2维元组,它能够持有两个对象:

 

 

构造器捕获了要存储的对象,而toString()是一个便利函数,用来显示列表中的值。注意,元组隐含地保持了其中元素的次序。

第一次阅读上面的代码时,你也许会想,这不是违反了Java编程的安全性原则吗?first和second应该声明为private,然后提供getFirst()和getSecond()之类的访问方法才对呀?让我们仔细看看这个例子中的安全性:客户端程序可以读取first和second对象,然后可以随心所欲地使用这两个对象。但是,它们却无法将其他值赋予first或second。因为final声明为你买了相同的安全保险,而且这种格式更简洁明了。

还有另一种设计考虑,即你确实希望允许客户端程序员改变first或second所引用的对象。然而,采用以上的形式无疑是更安全的做法,这样的话,如果程序员想要使用具有不同元素的元组,就强制要求他们另外创建一个新的TwoTuple对象。

我们可以利用继承机制实现长度更长的元组。从下面的例子中可以看到,增加类型参数是件很简单的事情:

 

为了使用元组,你只需定义一个长度适合的元组,将其作为方法的返回值,然后在return语句中创建该元组,并返回即可。

 

由于有了泛型,你可以很容易地创建元组,令其返回一组任意类型的对象。而你所要做的,只是编写表达式而已。

通过ttsi.first = "there"语句的错误,我们可以看出,final声明确实能够保护public元素,在对象被构造出来之后,声明为final的元素便不能被再赋予其他值了。

在上面的程序中,new表达式确实有点罗嗦。本章稍后会介绍,如何利用泛型方法简化这样的表达式。

练习3:(1) 使用泛型编写一个SixTuple类,并测试它。

练习4:(3) “泛型化”innerclasses/Sequence.java类。

15.2.2   一个堆栈类

接下来我们看一个稍微复杂一点的例子:传统的下推堆栈。在第11章中,我们看到,这个堆栈是作为net.mindview.util.Stack类,用一个LinkedList实现的。在那个例子中,LinkedList本身已经具备了创建堆栈所必需的方法,而Stack可以通过两个泛型的类Stack和LinkedList的组合来创建。在那个示例中,我们可以看出,泛型类型也就是另一种类型罢了(稍候我们会看到一些例外的情况)。

现在我们不用LinkedList,来实现自己的内部链式存储机制。

 

内部类Node也是一个泛型,它拥有自己的类型参数。

这个例子使用了一个末端哨兵(end sentinel)来判断堆栈何时为空。这个末端哨兵是在构造LinkedStack时创建的。然后,每调用一次push()方法,就会创建一个Node对象,并将其链接到前一个Node对象。当你调用pop()方法时,总是返回top.item,然后丢弃当前top所指的Node,并将top转移到下一个Node,除非你已经碰到了末端哨兵,这时候就不再移动top了。如果已经到了末端,客户端程序还继续调用pop()方法,它只能得到null,说明堆栈已经空了。

练习5:(2) 移除Node类上的类型参数,并修改LinkedStack.java的代码,证明内部类可以访问其外部类的类型参数。

15.2.3   RandomList

作为容器的另一个例子,假设我们需要一个持有特定类型对象的列表,每次调用其上的select()方法时,它可以随机地选取一个元素。如果我们希望以此构建一个可以应用于各种类型的对象的工具,就需要使用泛型:

 

 

练习6:(1) 使用RandomList来处理两种额外的不同类型的元素,要区别于main()中已经用过的类型。

 

 

 

15.3   泛型接口
泛型也可以应用于接口。例如生成器(generator),这是一种专门负责创建对象的类。实际上,这是工厂方法设计模式的一种应用。不过,当使用生成器创建新的对象时,它不需要任何参数,而工厂方法一般需要参数。也就是说,生成器无需额外的信息就知道如何创建新对象。

一般而言,一个生成器只定义一个方法,该方法用以产生新的对象。在这里,就是next()方法。我将它收录在我的标准工具类库中:

 

方法next()的返回类型是参数化的T。正如你所见到的,接口使用泛型与类使用泛型没什么区别。

为了演示如何实现Generator接口,我们还需要一些别的类。例如,Coffee类层次结构如下:

 

 

现在,我们可以编写一个类,实现Generator接口,它能够随机生成不同类型的Coffee对象:

 

参数化的Generator接口确保next()的返回值是参数的类型。CoffeeGenerator同时还实现了Iterable接口,所以它可以在循环语句中使用。不过,它还需要一个“末端哨兵”来判断何时停止,这正是第二个构造器的功能。

下面的类是Generator接口的另一个实现,它负责生成Fibonacci数列:

 

虽然我们在Fibonacci类的里里外外使用的都是int类型,但是其类型参数却是Integer。这个例子引出了Java泛型的一个局限性:基本类型无法作为类型参数。不过,Java SE5具备了自动打包和自动拆包的功能,可以很方便地在基本类型和其相应的包装器类型之间进行转换。通过这个例子中Fibonacci类对int的使用,我们已经看到了这种效果。

如果还想更进一步,编写一个实现了Iterable的Fibonacci生成器。我们的一个选择是重写这个类,令其实现Iterable接口。不过,你并不是总能拥有源代码的控制权,并且,除非必须这么做,否则,我们也不愿意重写一个类。而且我们还有另一种选择,就是创建一个适配器(adapter)来实现所需的接口,我们在前面介绍过这个设计模式。

有多种方法可以实现适配器。例如,可以通过继承来创建适配器类:

 

 

如果要在循环语句中使用IterableFibonacci,必须向IterableFibonacci的构造器提供一个边界值,然后hasNext()方法才能知道何时应该返回false。

练习7:(2) 使用组合代替继承,适配Fibonacci使其成为Iterable。

练习8:(2) 模仿Coffee示例的样子,根据你喜爱的电影人物,创建一个StoryCharacters的类层次结构,将它们划分为GoodGuys和BadGuys。再按照CoffeeGenerator的形式,编写一个StoryCharacters的生成器。

 

 


15.4   泛型方法
到目前为止,我们看到的泛型,都是应用于整个类上。但同样可以在类中包含参数化方法,而这个方法所在的类可以是泛型类,也可以不是泛型类。也就是说,是否拥有泛型方法,与其所在的类是否是泛型没有关系。

泛型方法使得该方法能够独立于类而产生变化。以下是一个基本的指导原则:无论何时,只要你能做到,你就应该尽量使用泛型方法。也就是说,如果使用泛型方法可以取代将整个类泛型化,那么就应该只使用泛型方法,因为它可以使事情更清楚明白。另外,对于一个static的方法而言,无法访问泛型类的类型参数,所以,如果static方法需要使用泛型能力,就必须使其成为泛型方法。

要定义泛型方法,只需将泛型参数列表置于返回值之前,就像下面这样:

 

GenericMethods并不是参数化的,尽管这个类和其内部的方法可以被同时参数化,但是在这个例子中,只有方法f()拥有类型参数。这是由该方法的返回类型前面的类型参数列表指明的。

注意,当使用泛型类时,必须在创建对象的时候指定类型参数的值,而使用泛型方法的时候,通常不必指明参数类型,因为编译器会为我们找出具体的类型。这称为类型参数推断(type argument inference)。因此,我们可以像调用普通方法一样调用f(),而且就好像是f()被无限次地重载过。它甚至可以接受GenericMethods作为其类型参数。

如果调用f()时传入基本类型,自动打包机制就会介入其中,将基本类型的值包装为对应的对象。事实上,泛型方法与自动打包避免了许多以前我们不得不自己编写出来的代码。

练习9:(1) 修改GenericMethods.java类,使f()可以接受三个类型各不相同的参数。

练习10:(1) 修改前一个练习,将方法f()的其中一个参数修改为非参数化的类型。

15.4.1   杠杆利用类型参数推断

人们对泛型有一个抱怨,使用泛型有时候需要向程序中加入更多的代码。考虑第11章中的holding/MapOfList.java类,如果要创建一个持有List的Map,就要像下面这样:

 

(本章稍后会介绍表达式中问号与extends的用法。)看到了吧,你在重复自己做过的事情,编译器本来应该能够从泛型参数列表中的一个参数推断出另一个参数。唉,可惜的是,编译器暂时还做不到。然而,在泛型方法中,类型参数推断可以为我们简化一部分工作。例如,我们可以编写一个工具类,它包含各种各样的static方法,专门用来创建各种常用的容器对象:

 

main()方法演示了如何使用这个工具类,类型参数推断避免了重复的泛型参数列表。它同样可以应用于holding/MapOfList.java:

 

对于类型参数推断而言,这是一个有趣的例子。不过,很难说它为我们带来了多少好处。如果某人阅读以上代码,他必须分析理解工具类New,以及New所隐含的功能。而这似乎与不使用New时(具有重复的类型参数列表的定义)的工作效率差不多。这真够讽刺的,要知道,我们引入New工具类的目的,正是为了使代码简单易读。不过,如果标准Java类库要是能添加类似New.java这样的工具类的话,我们还是应该使用这样的工具类。

类型推断只对赋值操作有效,其他时候并不起作用。如果你将一个泛型方法调用的结果(例如New.map())作为参数,传递给另一个方法,这时编译器并不会执行类型推断。在这种情况下,编译器认为:调用泛型方法后,其返回值被赋给一个Object类型的变量。下面的例子证明了这一点:

 

练习11:(1) 创建自己的若干个类来测试New.java,并确保New可以正确地与它们一起工作。

显式的类型说明

在泛型方法中,可以显式地指明类型,不过这种语法很少使用。要显式地指明类型,必须在点操作符与方法名之间插入尖括号,然后把类型置于尖括号内。如果是在定义该方法的类的内部,必须在点操作符之前使用this关键字,如果是使用static的方法,必须在点操作符之前加上类名。使用这种语法,可以解决LimitsOfInference.java中的问题:

 

当然,这种语法抵消了New类为我们带来的好处(即省去了大量的类型说明),不过,只有在编写非赋值语句时,我们才需要这样的额外说明。

练习12:(1) 使用显式的类型说明来重复前一个练习。

15.4.2   可变参数与泛型方法

泛型方法与可变参数列表能够很好地共存:

 

 

makeList()方法展示了与标准类库中java.util.Arrays.asList()方法相同的功能。

15.4.3   用于Generator的泛型方法

利用生成器,我们可以很方便地填充一个Collection,而泛型化这种操作是具有实际意义的:

 

请注意,fill()方法是如何透明地应用于Coffee和Integer的容器和生成器。

练习13:(4) 重载fill()方法,使其参数与返回值的类型为Collection的导出类:List、Queue和Set。通过这种方式,我们就不会丢失容器的类型。能够在重载时区分List和LinkedList吗?

15.4.4   一个通用的Generator

下面的程序可以为任何类构造一个Generator,只要该类具有默认的构造器。为了减少类型声明,它提供了一个泛型方法,用以生成BasicGenerator:

 

 

这个类提供了一个基本实现,用以生成某个类的对象。这个类必需具备两个特点:(1)它必须声明为public。(因为BasicGenerator与要处理的类在不同的包中,所以该类必须声明为public,并且不只具有包内访问权限。)(2)它必须具备默认的构造器(无参数的构造器)。要创建这样的BasicGenerator对象,只需调用create()方法,并传入想要生成的类型。泛型化的create()方法允许执行BasicGenerator.create(MyType.class),而不必执行麻烦的new Basic-Generator(MyType.class)。

例如,下面是一个具有默认构造器的简单的类:

 

CountedObject类能够记录下它创建了多少个CountedObject实例,并通过toString()方法告诉我们其编号。

使用BasicGenerator,你可以很容易地为CountedObject创建一个Generator:

 

可以看到,使用泛型方法创建Generator对象,大大减少了我们要编写的代码。Java泛型要求传入Class对象,以便也可以在create()方法中用它进行类型推断。

练习14:(1) 修改BasicGeneratorDemo.java类,使其显式地构造Generator(也就是不使用create()方法,而是使用显式的构造器)。

15.4.5   简化元组的使用

有了类型参数推断,再加上static方法,我们可以重新编写之前看到的元组工具,使其成为更通用的工具类库。在这个类中,我们通过重载static方法创建元组:

 

下面是修改后的TupleTest.java,用来测试Tuple.java:

 

注意,方法f()返回一个参数化的TwoTuple对象,而f2()返回的是非参数化的TwoTuple对象。在这个例子中,编译器并没有关于f2()的警告信息,因为我们并没有将其返回值作为参数化对象使用。在某种意义上,它被“向上转型”为一个非参数化的TwoTuple。然而,如果试图将f2()的返回值转型为参数化的TwoTuple,编译器就会发出警告。

练习15:(1) 验证前面的陈述是否属实。

练习16:(2) 为Tuple.java添加一个SixTuple,并在TupleTest2.java中进行测试。

15.4.6   一个Set实用工具

作为泛型方法的另一个示例,我们看看如何用Set来表达数学中的关系式。通过使用泛型方法,可以很方便地做到这一点,而且可以应用于多种类型:

 

在前三个方法中,都将第一个参数Set复制了一份,将Set中的所有引用都存入一个新的HashSet对象中,因此,我们并未直接修改参数中的Set。返回的值是一个全新的Set对象。

这四个方法表达了如下的数学集合操作:union()返回一个Set,它将两个参数合并在一起;intersection()返回的Set只包含两个参数共有的部分;difference()方法从superset中移除subset包含的元素;complement()返回的Set包含除了交集之外的所有元素。下面提供了一个enum,它包含各种水彩画的颜色。我们将用它来演示以上这些方法的功能和效果。

 

为了方便起见(可以直接使用enum中的元素名),下面的示例以static的方式引入Watercolors。这个示例使用了EnumSet,这是Java SE5中的新工具,用来从enum直接创建Set。(在第19章中,我们会详细介绍EnumSet。)在这里,我们向static方法EnumSet.range()传入某个范围的第一个元素与最后一个元素,然后它将返回一个Set,其中包含该范围内的所有元素:

 

我们可以从输出中看到各种关系运算的结果。

下面的示例使用Sets.difference()打印出java.util包中各种Collection类与Map类之间的方法差异:

 

 

在第11章的“总结”中,我们使用了这个程序的输出结果。

练习17:(4) 研究JDK文档中有关EnumSet的部分,你会看到它定义了clone()方法。然而,在Sets.java中,你却不能复制Set接口中的引用。请试着修改Sets.java,使其不但能接受一般的Set接口,而且能直接接受EnumSet,并使用clone()而不是创建新的HashSet对象。

 

 

15.5   匿名内部类
泛型还可以应用于内部类以及匿名内部类。下面的示例使用匿名内部类实现了Generator接口:

 

 

Customer和Teller类都只有private的构造器,这可以强制你必须使用Generator对象。Customer有一个generator()方法,每次执行它都会生成一个新的Generator对象。我们其实不需要多个Generator对象,Teller就只创建了一个public的generator对象。在main()方法中可以看到,这两种创建Generator的方式都在fill()中用到了。

由于Customer中的generator()方法,以及Teller中的Generator对象都声明成了static的,所以它们无法作为接口的一部分,因此无法用接口这种特定的惯用法来泛化这二者。尽管如此,它们在fill()方法中都工作得很好。

在第21章中,我们还会看到关于这个排队问题的另一个版本。

练习18:(3) 遵循BackTeller.java的形式,创建一个Ocean中BigFish吃LittleFish的例子。

 

 


15.6   构建复杂模型
泛型的一个重要好处是能够简单而安全地创建复杂的模型。例如,我们可以很容易地创建List元组:

 

尽管这看上去有些冗长(特别是迭代器的创建),但最终还是没有用过多的代码就得到了一个相当强大的数据结构。

下面是另一个示例,它展示了使用泛型类型来构建复杂模型是多么的简单。即使每个类都是作为一个构建块创建的,但是其整个还是包含许多部分。在本例中,构建的模型是一个零售店,它包含走廊、货架和商品:

 

 

正如我们在Store.toString()中看到的,其结果是许多层容器,但是它们是类型安全且可管理的。令人印象深刻之处是组装这个的模型十分容易,并不会成为智力挑战。

练习19:(2) 遵循Store.java的形式,构建一个容器化的货船模型。

 

 


15.7   擦除的神秘之处
当你开始更深入地钻研泛型时,会发现有大量的东西初看起来是没有意义的。例如,尽管可以声明ArrayList.class,但是不能声明ArrayList.class。请考虑下面的情况:

 

 

ArrayList和ArrayList很容易被认为是不同的类型。不同的类型在行为方面肯定不同,例如,如果尝试着将一个Integer放入ArrayList,所得到的行为(将失败)与把一个Integer放入ArrayList(将成功)所得到的行为完全不同。但是上面的程序会认为它们是相同的类型。

下面是的示例是对这个谜题的一个补充:

 

根据JDK文档的描述,Class.getTypeParameters()将“返回一个TypeVariable对象数组,表示有泛型声明所声明的类型参数……”这好像是在暗示你可能发现参数类型的信息,但是,正如你从输出中所看到的,你能够发现的只是用作参数占位符的标识符,这并非有用的信息。

因此,残酷的现实是:

在泛型代码内部,无法获得任何有关泛型参数类型的信息。

因此,你可以知道诸如类型参数标识符和泛型类型边界这类的信息—你却无法知道用来创建某个特定实例的实际的类型参数。如果你曾经是C++程序员,那么这个事实肯定让你觉得很沮丧,在使用Java泛型工作时它是必须处理的最基本的问题。

Java泛型是使用擦除来实现的,这意味着当你在使用泛型时,任何具体的类型信息都被擦除了,你唯一知道的就是你在使用一个对象。因此List和List在运行时事实上是相同的类型。这两种形式都被擦除成它们的“原生”类型,即List。理解擦除以及应该如何处理它,是你在学习Java泛型时面临的最大障碍,这也是我们在本节将要探讨的内容。

15.7.1   C++的方式

下面是使用模版的C++示例,你将注意到用于参数化类型的语法十分相似,因为Java是受C++的启发:

 

Manipulator类存储了一个类型T的对象,有意思的地方是manipulate()方法,它在obj上调用方法f()。它怎么能知道f()方法是为类型参数T而存在的呢?当你实例化这个模版时,C++编译器将进行检查,因此在Manipulator被实例化的这一刻,它看到HasF拥有一个方法f()。如果情况并非如此,就会得到一个编译期错误,这样类型安全就得到了保障。

用C++编写这种代码很简单,因为当模版被实例化时,模版代码知道其模版参数的类型。Java泛型就不同了。下面是HasF的Java版本:

 

如果我们将这个示例的其余代码都翻译成Java,那么这些代码将不能编译:

 

由于有了擦除,Java编译器无法将manipulate()必须能够在obj上调用f()这一需求映射到HasF拥有f()这一事实上。为了调用f(),我们必须协助泛型类,给定泛型类的边界,以此告知编译器只能接受遵循这个边界的类型。这里重用了extends关键字。由于有了边界,下面的代码就可以编译了:

 

边界声明T必须具有类型HasF或者从HasF导出的类型。如果情况确实如此,那么就可以安全地在obj上调用f()了。

我们说泛型类型参数将擦除到它的第一个边界(它可能会有多个边界,稍候你就会看到),我们还提到了类型参数的擦除。编译器实际上会把类型参数替换为它的擦除,就像上面的示例一样。T擦除到了HasF,就好像在类的声明中用HasF替换了T一样。

你可能已经正确地观察到,在Manipulation2.java中,泛型没有贡献任何好处。只需很容易地自己去执行擦除,就可以创建出没有泛型的类:

 

这提出了很重要的一点:只有当你希望使用的类型参数比某个具体类型(以及它的所有子类型)更加“泛化”时—也就是说,当你希望代码能够跨多个类工作时,使用泛型才有所帮助。因此,类型参数和它们在有用的泛型代码中的应用,通常比简单的类替换要更复杂。但是,不能因此而认为形式的任何东西而都是有缺陷的。例如,如果某个类有一个返回T的方法,那么泛型就有所帮助,因为它们之后将返回确切的类型:

 

必须查看所有的代码,并确定它是否“足够复杂”到必须使用泛型的程度。

我们将在本章稍后介绍有关边界的更多细节。

练习20:(1) 创建一个具有两个方法的接口,以及一个实现了这个接口并添加了另一个方法的类。在另一个类中,创建一个泛型方法,它的参数类型由这个接口定义了边界,并展示该接口中的方法在这个泛型方法中都是可调用的。在main()方法中传递一个实现类的实例给这个泛型方法。

15.7.2   迁移兼容性

为了减少潜在的关于擦除的混淆,你必须清楚地认识到这不是一个语言特性。它是Java的泛型实现中的一种折中,因为泛型不是Java语言出现时就有的组成部分,所以这种折中是必需的。这种折中会使你痛苦,因此你需要习惯它并了解为什么它会是这样。

如果泛型在Java 1.0中就已经是其一部分了,那么这个特性将不会使用擦除来实现—它将使用具体化,使类型参数保持为第一类实体,因此你就能够在类型参数上执行基于类型的语言操作和反射操作。你将在本章稍后看到,擦除减少了泛型的泛化性。泛型在Java中仍旧是有用的,只是不如它们本来设想的那么有用,而原因就是擦除。

在基于擦除的实现中,泛型类型被当作第二类类型处理,即不能在某些重要的上下文环境中使用的类型。泛型类型只有在静态类型检查期间才出现,在此之后,程序中的所有泛型类型都将被擦除,替换为它们的非泛型上界。例如,诸如List这样的类型注解将被擦除为List,而普通的类型变量在未指定边界的情况下将被擦除为Object。

擦除的核心动机是它使得泛化的客户端可以用非泛化的类库来使用,反之亦然,这经常被称为“迁移兼容性”。在理想情况下,当所有事物都可以同时被泛化时,我们就可以专注于此。在现实中,即使程序员只编写泛型代码,他们也必须处理在Java SE5之前编写的非泛型类库。那些类库的作者可能从没有想过要泛化它们的代码,或者可能刚刚开始接触泛型。

因此Java泛型不仅必须支持向后兼容性,即现有的代码和类文件仍旧合法,并且继续保持其之前的含义;而且还要支持迁移兼容性,使得类库按照它们自己的步调变为泛型的,并且当某个类库变为泛型时,不会破坏依赖于它的代码和应用程序。在决定这就是目标之后,Java设计者们和从事此问题相关工作的各个团队决策认为擦除是唯一可行的解决方案。通过允许非泛型代码与泛型代码共存,擦除使得这种向着泛型的迁移成为可能。

例如,假设某个应用程序具有两个类库X和Y,并且Y还要使用类库Z。随着Java SE5的出现,这个应用程序和这些类库的创建者最终可能希望迁移到泛型上。但是,当进行这种迁移时,他们有着不同动机和限制。为了实现迁移兼容性,每个类库和应用程序都必须与其他所有的部分是否使用了泛型无关。这样,它们必须不具备探测其他类库是否使用了泛型的能力。因此,某个特定的类库使用了泛型这样的证据必须被“擦除”。

如果没有某种类型的迁移途径,所有已经构建了很长时间的类库就需要与希望迁移到Java泛型上的开发者们说再见了。但是,类库是编程语言无可争议的一部分,它们对生产效率会产生最重要的影响,因此这不是一种可以接受的代价。擦除是否是最佳的或者唯一的迁移途径,还需要时间来证明。

15.7.3   擦除的问题

因此,擦除主要的正当理由是从非泛化代码到泛化代码的转变过程,以及在不破坏现有类库的情况下,将泛型融入Java语言。擦除使得现有的非泛型客户端代码能够在不改变的情况下继续使用,直至客户端准备好用泛型重写这些代码。这是一个崇高的动机,因为它不会突然间破坏所有现有的代码。

擦除的代价是显著的。泛型不能用于显式地引用运行时类型的操作之中,例如转型、instanceof操作和new表达式。因为所有关于参数的类型信息都丢失了,无论何时,当你在编写泛型代码时,必须时刻提醒自己,你只是看起来好像拥有有关参数的类型信息而已。因此,如果你编写了下面这样的代码段:

 

那么,看起来当你在创建Foo的实例时:

 

class Foo中的代码应该知道现在工作于Cat之上,而泛型语法也在强烈暗示:在整个类中的各个地方,类型T都在被替换。但是事实并非如此,无论何时,当你在编写这个类的代码时,必须提醒自己:“不,它只是一个Object。”

另外,擦除和迁移兼容性意味着,使用泛型并不是强制的,尽管你可能希望这样:

 

 

Derived2继承自GenericBase,但是没有任何泛型参数,而编译器不会发出任何警告。警告在set()被调用时才会出现。

为了关闭警告,Java提供了一个注解,我们可以在列表中看到它(这个注解在Java SE5之前的版本中不支持):

 

注意,这个注解被放置在可以产生这类警告的方法之上,而不是整个类上。当你要关闭警告时,最好是尽量地“聚焦”,这样就不会因为过于宽泛地关闭警告,而导致意外地遮蔽掉真正的问题。

可以推断,Derived3产生的错误意味着编译器期望得到一个原生基类。

当你希望将类型参数不要仅仅当作Object处理时,就需要付出额外努力来管理边界,并且与在C++、Ada和Eiffel这样的语言中获得参数化类型相比,你需要付出多得多的努力来获得少得多的回报。这并不是说,对于大多数的编程问题而言,这些语言通常都会比Java更得心应手;这只是说,它们的参数化类型机制比Java的更灵活、更强大。

15.7.4    边界处的动作

正是因为有了擦除,我发现泛型最令人困惑的方面源自这样一个事实,即可以表示没有任何意义的事物。例如:

 

 

即使kind被存储为Class,擦除也意味着它实际将被存储为Class,没有任何参数。因此,当你在使用它时,例如在创建数组时,Array.newInstance()实际上并未拥有kind所蕴含的类型信息,因此这不会产生具体的结果,所以必须转型,这将产生一条令你无法满意的警告。

注意,对于在泛型中创建数组,使用Array.newInstance()是推荐的方式。

如果我们要创建一个容器而不是数组,情况就有些不同了:

 

编译器不会给出任何警告,尽管我们(从擦除中)知道在create()内部的new ArrayList中的被移除了—在运行时,这个类的内部没有任何,因此这看起来毫无意义。但是如果你遵从这种思路,并将这个表达式改为new ArrayList(),编译器就会给出警告。

在本例中,这是否真的毫无意义呢?如果返回list之前,将某些对象放入其中,就像下面这样,情况又会如何呢?

 

即使编译器无法知道有关create()中的T的任何信息,但是它仍旧可以在编译期确保你放置到result中的对象具有T类型,使其适合ArrayList。因此,即使擦除在方法或类内部移除了有关实际类型的信息,编译器仍旧可以确保在方法或类中使用的类型的内部一致性。

因为擦除在方法体中移除了类型信息,所以在运行时的问题就是边界:即对象进入和离开方法的地点。这些正是编译器在编译期执行类型检查并插入转型代码的地点。请考虑下面的非泛型示例:

 

 

如果用javap -c SimpleHolder反编译这个类,就可以得到下面的(经过编辑的)内容:

 

set()和get()方法将直接存储和产生值,而转型是在调用get()的时候接受检查的。

现在将泛型合并到上面的代码中:

 

从get()返回之后的转型消失了,但是我们还知道传递给set()的值在编译期会接受检查。下面是相关的字节码:

 

 

所产生的字节码是相同的。对进入set()的类型进行检查是不需要的,因为这将由编译器执行。而对从get()返回的值进行转型仍旧是需要的,但这与你自己必须执行的操作是一样的—此处它将由编译器自动插入,因此你写入(和读取)的代码的噪声将更小。

由于所产生的get()和set()的字节码相同,所以在泛型中的所有动作都发生在边界处—对传递进来的值进行额外的编译期检查,并插入对传递出去的值的转型。这有助于澄清对擦除的混淆,记住,“边界就是发生动作的地方。”

 

 

 

15.8   擦除的补偿
正如我们看到的,擦除丢失了在泛型代码中执行某些操作的能力。任何在运行时需要知道确切类型信息的操作都将无法工作:

 

偶尔可以绕过这些问题来编程,但是有时必须通过引入类型标签来对擦除进行补偿。这意味着你需要显式地传递你的类型的Class对象,以便你可以在类型表达式中使用它。

例如,在前面示例中对使用instanceof的尝试最终失败了,因为其类型信息已经被擦除了。如果引入类型标签,就可以转而使用动态的isInstance():

 

 

编译器将确保类型标签可以匹配泛型参数。

练习21:(4) 修改ClassTypeCapture.java,添加一个Map>,一个addType (String typename,Classkind)方法和一个createNew(String typename)方法。createNew()将产生一个与其参数字符串相关联的类的新实例,或者产生一条错误消息。

15.8.1   创建类型实例

在Erased.java中对创建一个new T()的尝试将无法实现,部分原因是因为擦除,而另一部分原因是因为编译器不能验证T具有默认(无参)构造器。但是在C++中,这种操作很自然、很直观,并且很安全(它是在编译期受到检查的):

 

Java中的解决方案是传递一个工厂对象,并使用它来创建新的实例。最便利的工厂对象就是Class对象,因此如果使用类型标签,那么你就可以使用newInstance()来创建这个类型的新对象:

 

 

这可以编译,但是会因ClassAsFactory而失败,因为Integer没有任何默认的构造器。因为这个错误不是在编译期捕获的,所以Sun的伙计们对这种方式并不赞成,他们建议使用显式的工厂,并将限制其类型,使得只能接受实现了这个工厂的类:

 

注意,这确实只是传递Class的一种变体。两种方式都传递了工厂对象,Class碰巧是内建的工厂对象,而上面的方式创建了一个显式的工厂对象,但是你却获得了编译期检查。

另一种方式是模版方法设计模式。在下面的示例中,get()是模版方法,而create()是在子类中定义的、用来产生子类类型的对象:

 

 

练习22:(6) 使用类型标签与反射来创建一个方法,它将使用newInstance()的参数版本来创建某个类的对象,这个类拥有一个具有参数的构造器。

练习23:(1) 修改FactoryConstraint.java,使得create()可以接受一个参数。

练习24:(3) 修改练习21,使得工厂对象是由一个Map而不是Class持有的。

15.8.2   泛型数组

正如你在Erased.java中所见,不能创建泛型数组。一般的解决方案是在任何想要创建泛型数组的地方都使用ArrayList:

 

这里你将获得数组的行为,以及由泛型提供的编译期的类型安全。

有时,你仍旧希望创建泛型类型的数组(例如,ArrayList内部使用的是数组)。有趣的是,可以按照编译器喜欢的方式来定义一个引用,例如:

 

编译器将接受这个程序,而不会产生任何警告。但是,永远都不能创建这个确切类型的数组(包括类型参数),因此这有一点令人困惑。既然所有数组无论它们持有的类型如何,都具有相同的结构(每个数组槽位的尺寸和数组的布局),那么看起来你应该能够创建一个Object数组,并将其转型为所希望的数组类型。事实上这可以编译,但是不能运行,它将产生ClassCase-Exception:

 

 

问题在于数组将跟踪它们的实际类型,而这个类型是在数组被创建时确定的,因此,即使gia已经被转型为Generic[],但是这个信息只存在于编译期(并且如果没有@Suppress Warnings注解,你将得到有关这个转型的警告)。在运行时,它仍旧是Object数组,而这将引发问题。成功创建泛型数组的唯一方式就是创建一个被擦除类型的新数组,然后对其转型。

让我们看一个更复杂的示例。考虑一个简单的泛型数组包装器:

 

与前面相同,我们并不能声明T[] array = new T[sz],因此我们创建了一个对象数组,然后将其转型。

rep()方法将返回T[],它在main()中将用于gai,因此应该是Integer[],但是如果调用它,并尝试着将结果作为Integer[]引用来捕获,就会得到ClassCastException,这还是因为实际的运行时类型是Object[]。

如果在注释掉@SuppressWarnings注解之后再编译GenericArray.java,编译器就会产生警告:

 

在这种情况下,我们将只获得单个的警告,并且相信这事关转型。但是如果真的想要确定是否是这么回事,就应该用-Xlint:unchecked来编译:

 

 

这确实是对转型的抱怨。因为警告会变得令人迷惑,所以一旦我们验证某个特定警告是可预期的,那么我们的上策就是用@SuppressWarnings关闭它。通过这种方式,当警告确实出现时,我们就可以真正地展开对它的调查了。

因为有了擦除,数组的运行时类型就只能是Object[]。如果我们立即将其转型为T[],那么在编译期该数组的实际类型就将丢失,而编译器可能会错过某些潜在的错误检查。正因为这样,最好是在集合内部使用Object[],然后当你使用数组元素时,添加一个对T的转型。让我们看看这是如何作用于GenericArray.java示例的:

 

初看起来,这好像没多大变化,只是转型挪了地方。如果没有@SuppressWarnings注解,你仍旧会得到unchecked警告。但是,现在的内部表示是Object[]而不是T[]。当get()被调用时,它将对象转型为T,这实际上是正确的类型,因此这是安全的。然而,如果你调用rep(),它还是尝试着将Object[]转型为T[],这仍旧是不正确的,将在编译期产生警告,在运行时产生异常。因此,没有任何方式可以推翻底层的数组类型,它只能是Object[]。在内部将array当作Object[]而不是T[]处理的优势是:我们不太可能忘记这个数组的运行时类型,从而意外地引入缺陷(尽管大多数也可能是所有这类缺陷都可以在运行时快速地探测到)。

对于新代码,应该传递一个类型标记。在这种情况下,GenericArray看起来会像下面这样:

 

 

类型标记Class被传递到构造器中,以便从擦除中恢复,使得我们可以创建需要的实际类型的数组,尽管从转型中产生的警告必须用@SuppressWarnings压制住。一旦我们获得了实际类型,就可以返回它,并获得想要的结果,就像在main()中看到的那样。该数组的运行时类型是确切类型T[]。

遗憾的是,如果查看Java SE5标准类库中的源代码,你就会看到从Object数组到参数化类型的转型遍及各处。例如,下面是经过整理和简化之后的从Collection中复制ArrayList的构造器:

 

如果你通读ArrayList.java,就会发现它充满了这种转型。如果我们编译它,又会发生什么呢?

 

可以十分肯定,标准类库会产生大量的警告。如果你曾经用过C++,特别是ANSI C之前的版本,你就会记得警告的特殊效果:当你发现可以忽略它们时,你就可以忽略。正是因为这个原因,最好是从编译器中不要发出任何消息,除非程序员必须对其进行响应。

Neal Gafter(Java SE5的领导开发者之一)在他的博客中指出,在重写Java类库时,他十分懒散,而我们不应该像他那样。Neal还指出,在不破坏现有接口的情况下,他将无法修改某些Java类库代码。因此,即使在Java类库源代码中出现了某些惯用法,也不能表示这就是正确的解决之道。当查看类库代码时,你不能认为它就是应该在自己的代码中遵循的示例。

 

 

第11章介绍了Java容器类库的概念和基本功能,这些对于使用容器来说已经足够了。本章将更深入地探索这个重要的类库。

为了充分利用容器类库,你需要了解比第11章中介绍的内容更多的知识,但是本章依赖于高级特性(例如泛型),因此被安排在了全书较为靠后的位置。

在对容器有了更加完备的了解之后,你将学习散列机制是如何工作的,以及在使用散列容器时怎样编写hashCode()和equals()方法。你还将学习为什么某些容器会有不同版本的实现,以及怎样在它们之间进行选择。本章最后将以对通用便利工具和特殊类的探索作为结束。

17.1   完整的容器分类法
第11.14节展示了Java容器类库的简化图。下面是集合类库更加完备的图。包括抽象类和遗留构件(不包括Queue的实现):

 

Java SE5新添加了:

?Queue接口(正如在第11章所介绍,LinkedList已经为实现该接口做了修改)及其实现PriorityQueue和各种风格的BlockingQueue,其中BlockingQueue将在第21章中介绍。

?ConcurrentMap接口及其实现ConcurrentHashMap,它们也是用于多线程机制的,同样会在第21章中介绍。

?CopyOnWriteArrayList和CopyOnWriteArraySet,它们也是用于多线程机制的。

?EnumSet和EnumMap,为使用enum而设计的Set和Map的特殊实现,将在第19章中介绍。

?在Collections类中的多个便利方法。

虚线框表示abstract类,你可以看到大量的类的名字都是以Abstract开头的。这些类可能初看起来有点令人困惑,但是它们只是部分实现了特定接口的工具。例如,如果你在创建自己的Set,那么并不用从Set接口开始并实现其中的全部方法,只需从AbstractSet继承,然后执行一些创建新类必需的工作。但是,事实上容器类库包含足够多的功能,任何时刻都可以满足你的需求,因此你通常可以忽略以Abstract开头的这些类,

 

 


17.2   填充容器
虽然容器打印的问题解决了,容器的填充仍然像java.util.Arrays一样面临同样的不足。就像Arrays一样,相应的Collections类也有一些实用的static方法,其中包括fill()。与Arrays版本一样,此fill()方法也是只复制同一个对象引用来填充整个容器的,并且只对List对象有用,但是所产生的列表可以传递给构造器或addAll()方法:

 

这个示例展示了两种用对单个对象的引用来填充Collection的方式,第一种是使用Collections.nCopies()创建传递给构造器的List,这里填充的是ArrayList。

StringAddress的toString()方法调用Object.toString()并产生该类的名字,后面紧跟该对象的散列码的无符号十六进制表示(通过hashCode()生成的)。从输出中你可以看到所有引用都被设置为指向相同的对象,在第二种方法的Collection.fill()被调用之后也是如此。fill()方法的用处更有限,因为它只能替换已经在List中存在的元素,而不能添加新的元素。

17.2.1   一种Generator解决方案

事实上,所有的Collection子类型都有一个接收另一个Collection对象的构造器,用所接收的Collection对象中的元素来填充新的容器。为了更加容易地创建测试数据,我们需要做的是构建接收Generator(在第15章中定义并在第16章中深入探讨过)和quantity数值并将它们作为构造器参数的类:

 

这个类使用Generator在容器中放置所需数量的对象,然后所产生的容器可以传递给任何Collection的构造器,这个构造器会把其中的数据复制到自身中。addAll()方法是所有Collection子类型的一部分,它也可以用来组装现有的Collection。

泛型便利方法可以减少在使用类时所必需的类型检查数量。

CollectionData是适配器设计模式的一个实例,它将Generator适配到Collection的构造器上。

下面是初始化LinkedHashSet的一个示例:

 

这些元素的顺序与它们的插入顺序相同,因为LinkedHashSet维护的是保持了插入顺序的链接列表。

在第16章中定义的所有操作符现在通过CollectionData适配器都是可用的。下面是使用了其中两个操作符的示例:

 

RandomGenerator.String所产生的String长度是通过构造器参数控制的。

17.2.2   Map生成器

我们可以对Map使用相同的方式,但是这需要有一个Pair类,因为为了组装Map,每次调用Generator的next()方法都必须产生一个对象对(一个键和一个值):

 

key和value域都是public和final的,这是为了使Pair成为只读的数据传输对象(或信使)。

Map适配器现在可以使用各种不同的Generator、Iterator和常量值的组合来填充Map初始化对象:

 

 

这给了你一个机会,去选择使用单一的Generator>、两个分离的Generator、一个Generator和一个常量值、一个Iterable(包括任何Collection)和一个Generator,还是一个Iterable和一个单一值。泛型便利方法可以减少在创建MapData类时所必需的类型检查数量。

下面是一个使用MapData的示例。LettersGenerator通过产生一个Iterator还实现了Iterable,通过这种方式,它可以被用来测试MapData.map()方法,而这些方法都需要用到Iterable:

 

 

这个示例也使用了第16章中的生成器。

可以使用工具来创建任何用于Map或Collection的生成数据集,然后通过构造器或Map.putAll()和Collection.addAll()方法来初始化Map和Collection。

17.2.3   使用Abstract类

对于产生用于容器的测试数据问题,另一种解决方式是创建定制的Collection和Map实现。每个java.util容器都有其自己的Abstract类,它们提供了该容器的部分实现,因此你必须做的只是去实现那些产生想要的容器所必需的方法。如果所产生的容器是只读的,就像它通常用的测试数据那样,那么你需要提供的方法数量将减少到最少。

尽管在本例中不是特别需要,但是下面的解决方案还是提供了一个机会来演示另一种设计模式:享元。你可以在普通的解决方案需要过多的对象,或者产生普通对象太占用空间时使用享元。享元模式使得对象的一部分可以被具体化,因此,与对象中的所有事物都包含在对象内部不同,我们可以在更加高效的外部表中查找对象的一部分或整体(或者通过某些其他节省空间的计算来产生对象的一部分或整体)。

这个示例的关键之处在于演示通过继承java.util.Abstract来创建定制的Map和Collection到底有多简单。为了创建只读的Map,可以继承AbstractMap并实现entrySet()。为了创建只读的Set,可以继承AbstractSet并实现iterator()和size()。

本例中使用的数据集是由世界上的国家以及它们的首都构成的Map。capitals()方法产生国家与首都的Map,name()方法产生国名的List。在两种情况中,你都可以通过提供表所需尺寸的int参数来获取部分列表:

 

 

 

 

 

 

 

 

二维数组String DATA是public的,因此它可以在其他地方使用。FlyweightMap必须实现entrySet()方法,它需要定制的Set实现和定制的Map.Entry类。这里正是享元部分:每个Map.Entry对象都只存储了它的索引,而不是实际的键和值。当你调用getKey()和getValue()时,它们会使用该索引来返回恰当的DATA元素。EntrySet可以确保它的size不会大于DATA。

你可以在EntrySet.Iterator中看到享元其他部分的实现。与为DATA中的每个数据对都创建Map.Entry对象不同,每个迭代器只有一个Map.Entry。Entry对象被用作数据的视窗,它只包含在静态字符串数组中的索引。你每次调用迭代器的next()方法时,Entry中的index都会递增,使其指向下一个元素对,然后从next()返回该Iterator所持有的单一的Entry对象。

select()方法将产生一个包含指定尺寸的EntrySet的FlyweightMap,它会被用于重载过的capitals()和names()方法,正如在main()中所演示的那样。

对于某些测试,Countries的尺寸受限会成为问题。我们可以采用与产生定制容器相同的方式来解决,其中定制容器是经过初始化的,并且具有任意尺寸的数据集。下面的类是一个List,它可以具有任意尺寸,并且用Integer数据(有效地)进行了预初始化:

 

为了从AbstractList创建只读的List,你必须实现get()和size()。这里再次使用了享元解决方案:当你寻找值时,get()将产生它,因此这个List实际上并不必组装。

下面是包含经过预初始化,并且都是唯一的Integer和String对的Map,它可以具有任意尺寸:

 

 

这里使用的是LinkedHashSet,而不是定制的Set类,因此享元并未完全实现。

练习1:(1) 创建一个List(用ArrayList和LinkedList都尝试一下),然后用Countries来填充。对该列表排序并打印,然后将Collections.shuffle()方法重复地应用于该列表,并且每次都打印它,这样你就可以看到shuffle()方法是如何每次都将列表随机打乱的了。

练习2:(2) 生成一个Map和Set,使其包含所有以字母A开头的国家。

练习3:(1) 使用Countries,用同样的数据多次填充Set,然后验证此Set中没有重复的元素。使用HashSet、LinkedHashSet和TreeSet做此测试。

练习4:(2) 创建一个Collection初始化器,它将打开一个文件,并用TextFile将其断开为单词,然后将这些单词作为所产生的Collection的数据源使用。请演示它是可以工作的。

练习5:(3) 修改CountingMapData.java,通过添加像Countries.java中那样的定制EntrySet类,来完全实现享元。

 

 

17.3   Collection的功能方法
下面的表格列出了可以通过Collection执行的所有操作(不包括从Object继承而来的方法)。因此,它们也是可通过Set或List执行的所有操作(List还有额外的功能)。Map不是继承自Collection的,所以会另行介绍。

 

请注意,其中不包括随机访问所选择元素的get()方法。因为Collection包括Set,而Set是自己维护内部顺序的(这使得随机访问变得没有意义)。因此,如果想检查Collection中的元素,那就必须使用迭代器。

下面的例子展示了所有这些方法。虽然任何实现了Collection的类都可以使用这些方法,但示例中使用ArrayList,以说明各种Collection子类的“最基本的共同特性”:

 

 

 

创建ArrayList来保存不同的数据集,然后向上转型为Collection,所以很明显,代码只用到了Collection接口。main()用简单的练习展示了Collection中的所有方法。

本章后面的小节将介绍List、Set和Map的各种实现,每种情况都会(以星号)标出默认的选择。对遗留类Vector、Stack和Hashtable的描述放到了本章的末尾,尽管你不应该使用这些类,但是在老的代码中仍就会看到它们。

 

 


17.4   可选操作
执行各种不同的添加和移除的方法在Collection接口中都是可选操作。这意味着实现类并不需要为这些方法提供功能定义。

这是一种很不寻常的接口定义方式。正如你所看到的那样,接口是面向对象设计中的契约,它声明“无论你选择如何实现该接口,我保证你可以向该接口发送这些消息。”但是可选操作违反这个非常基本的原则,它声明调用某些方法将不会执行有意义的行为,相反,它们会抛出异常。这看起来好像是编译期类型安全被抛弃了!

事情并不那么糟。如果一个操作是可选的,编译器仍旧会严格要求你只能调用该接口中的方法。这与动态语言不同,动态语言可以在任何对象上调用任何方法,并且可以在运行时发现某个特定调用是否可以工作。另外,将Collection当作参数接受的大部分方法只会从该Collection中读取,而Collection的读取方法都不是可选的。

为什么你会将方法定义为可选的呢?那是因为这样做可以防止在设计中出现接口爆炸的情况。容器类库中的其他设计看起来总是为了描述每个主题的各种变体,而最终患上了令人困惑的接口过剩症。甚至这么做仍不能捕捉接口的各种特例,因为总是有人会发明新的接口。“未获支持的操作”这种方式可以实现Java容器类库的一个重要目标:容器应该易学易用。未获支持的操作是一种特例,可以延迟到需要时再实现。但是,为了让这种方式能够工作:

1. UnsupportedOperationException必须是一种罕见事件。即,对于大多数类来说,所有操作都应该可以工作,只有在特例中才会有未获支持的操作。在Java容器类库中确实如此,因为你在99%的时间里面使用的容器类,如ArrayList、LinkedList、HashSet和HashMap,以及其他的具体实现,都支持所有的操作。这种设计留下了一个“后门”,如果你想创建新的Collection,但是没有为Collection接口中的所有方法都提供有意义的定义,那么它仍旧适合现有的类库。

2. 如果一个操作是未获支持的,那么在实现接口的时候可能就会导致UnsupportedOperation-Exception异常,而不是将产品程序交给客户以后才出现此异常,这种情况是有道理的。毕竟,它表示编程上有错误:使用了不正确的接口实现。

值得注意的是,未获支持的操作只有在运行时才能探测到,因此它们表示动态类型检查。如果你以前使用的是像C++这样的静态类型语言,那么可能会觉得Java也只是另一种静态类型语言,但是它还具有大量的动态类型机制,因此很难说它到底是哪一种类型的语言。一旦开始注意到这一点了,你就会看到Java中动态类型检查的其他例子。

17.4.1   未获支持的操作

最常见的未获支持的操作,都来源于背后由固定尺寸的数据结构支持的容器。当你用Arrays.asList()将数组转换为List时,就会得到这样的容器。你还可以通过使用Collections类中“不可修改”的方法,选择创建任何会抛出UnsupportedOperationException的容器(包括Map)。下面的示例包括这两种情况:

 

因为Arrays.asList()会生成一个List,它基于一个固定大小的数组,仅支持那些不会改变数组大小的操作,对它而言是有道理的。任何会引起对底层数据结构的尺寸进行修改的方法都会产生一个UnsupportedOperationException异常,以表示对未获支持操作的调用(一个编程错误)。

注意,应该把Arrays.asList()的结果作为构造器的参数传递给任何Collection(或者使用addAll()方法,或Collections.addAll()静态方法),这样可以生成允许使用所有的方法的普通容器—这在main()中的第一个对test()的调用中得到了展示,这样的调用会产生新的尺寸可调的底层数据结构。Collections类中的“不可修改”的方法将容器包装到了一个代理中,只要你执行任何试图修改容器的操作,这个代理都会产生UnsupportedOperationException异常。使用这些方法的目标就是产生“常量”容器对象。“不可修改”的Collections方法的完整列表将在稍后介绍。

test()中的最后一个try语句块将检查作为List的一部分的set()方法。这很有趣,因为你可以看到“未获支持的操作”这一技术的粒度来的是多么方便—所产生的“接口”可以在Arrays.asList()返回的对象和Collections.unmodifiableList()返回的对象之间,在一个方法的粒度上产生变化。Arrays.asList()返回固定尺寸的List,而Collections.unmodifiableList()产生不可修改的列表。正如从输出中所看到的,修改Arrays.asList()返回的List中的元素是可以的,因为这没有违反该List“尺寸固定”这一特性。但是很明显,unmodifiableList()的结果在任何情况下都应该不是可修改的。如果使用的是接口,那么还需要两个附加的接口,一个具有可以工作的set()方法,另外一个没有,因为附加的接口对于Collection的各种不可修改的子类型来说是必需的。

对于将容器作为参数接受的方法,其文档应该指定哪些可选方法必须实现。

练习6:(2) 注意,List具有附加的“可选”操作,它们不包含在Collection中。编写一个Unsupported.java版本,测试这些附加的可选操作。

 

 


17.5   List的功能方法
正如你所看到的,基本的List很容易使用:大多数时候只是调用add()添加对象,使用get()一次取出一个元素,以及调用iterator()获取用于该序列的Iterator。

下面例子中的每个方法都涵盖了一组不同的动作:basicTest()中包含每个List都可以执行的操作;iterMotion()使用Iterator遍历元素;对应的iterManipulation()使用Iterator修改元素;testVisual()用以查看List的操作效果;还有一些LinkedList专用的操作。

 

 

 

 

在basicTest()和iterMotion()方法中,调用只是为了演示正确的语法,虽然取得了返回值,却没有使用。某些情况则根本没有捕获返回值。使用这些方法前,应该查询JDK帮助文档,以充分了解各种方法的用途。

练习7:(4) 分别创建一个ArrayList和LinkedList,用Countries.names()生成器来填充每个容器。用普通的Iterator打印每个列表,然后用ListIterator按隔一个位置插入一个对象的方式把一个表插入到另一个表中。现在,从第1个表的末尾开始,向前移动执行插入操作。

练习8:(7) 创建一个泛型的单向链表类SList,为了简单起见,不要让它去实现List接口。列表中的每个Link对象都应该包含一个对列表中下一个元素而不是前一个元素的引用(与这个类相比,LinkedList是双向链表,它包含两个方向的链接)。创建你自己的SListIterator,同样为了简单起见,不要实现ListIterator。SList中除了toString()之外唯一的方法应该是iterator(),它将产生一个SListIterator。在SList中插入和移除元素的唯一方式就是通过SListIterator。编写代码来演示SList。

 


17.6   Set和存储顺序
在第11章中的Set示例对可以用基本的Set执行的操作,提供了很好的介绍。但是那些示例很方便地使用了诸如Integer和String这样的Java预定义的类型,这些类型被设计为可以在容器内部使用。当你创建自己的类型时,要意识到Set需要一种方式来维护存储顺序,而存储顺序如何维护,则是在Set的不同实现之间会有所变化。因此,不同的Set实现不仅具有不同的行为,而且它们对于可以在特定的Set中放置的元素的类型也有不同的要求:

 

在HashSet上打星号表示,如果没有其他的限制,这就应该是你默认的选择,因为它对速度进行了优化。

定义hashCode()的机制将在本章稍后进行介绍。你必须为散列存储和树型存储都创建一个equals()方法,但是hashCode()只有在这个类将会被置于HashSet(这是有可能的,因为它通常是你的Set实现的首选)或者LinkedHashSet中时才是必需的。但是,对于良好的编程风格而言,你应该在覆盖equals()方法时,总是同时覆盖hashCode()方法。

下面的示例演示了为了成功地使用特定的Set实现类型而必须定义的方法:

 

 

 

为了证明哪些方法对于某种特定的Set是必需的,并且同时还要避免代码重复,我们创建了三个类。基类SetType只存储一个int,并且通过toString()方法产生它的值。因为所有在Set中存储的类都必须具有equals()方法,因此在基类中也有该方法。其等价性是基于这个int类型的i的值来确定的。

HashType继承自SetType,并且添加了hashCode()方法,该方法对于放置到Set的散列实现中的对象来说是必需的。

TreeType实现了Comparable接口,如果一个对象被用于任何种类的排序容器中,例如SortedSet(TreeSet是其唯一实现),那么它必须实现这个接口。注意,在compareTo()中,我没有使用“简洁明了”的形式return i-i2,因为这是一个常见的编程错误,它只有在i和i2都是无符号的int(如果Java确实有unsigned关键字的话,但实际上并没有)时才能正确工作。对于Java的有符号int,它就会出错,因为int不够大,不足以表现两个有符号int的差。例如i是很大的正整数,而j是很大的负整数,i-j就会溢出并且返回负值,这就不正确了。

你通常会希望compareTo()方法可以产生与equals()方法一致的自然排序。如果equals()对于某个特定比较产生true,那么compareTo()对于该比较应该返回0,如果equals()对于某个比较产生false,那么compareTo()对于该比较应该返回非0值。

在TypesForSets中,fill()和test()方法都是用泛型定义的,这是为了避免代码重复。为了验证某个Set的行为,test()会在被测Set上调用fill()三次,尝试着在其中引入重复对象。fill()方法可以接受任何类型的Set,以及其相同类型Class对象,它使用Class对象来发现并接受int参数的构造器,然后调用该构造器将元素添加到Set中。

从输出中可以看到,HashSet以某种神秘的顺序保存所有的元素(这将在本章稍后进行解释),LinkedHashSet按照元素插入的顺序保存元素,而TreeSet按照排序顺序维护元素(按照compareTo()的实现方式,这里维护的是降序)。

如果我们尝试着将没有恰当地支持必需的操作的类型用于需要这些方法的Set,那么就会有大麻烦了。对于没有重新定义hashCode()方法的SetType或TreeType,如果将它们放置到任何散列实现中都会产生重复值,这样就违反了Set的基本契约。这相当烦人,因为这甚至不会有运行时错误。但是,默认的hashCode()是合法的,因此这是合法的行为,即便它不正确。确保这种程序的正确性的唯一可靠方法就是将单元测试合并到你的构建系统中(请查看http://MindView. net/Books/BetterJava处的补充材料以了结更多的信息)。

如果我们尝试着在TreeSet中使用没有实现Comparable的类型,那么你将会得到更确定的结果:在TreeSet试图将该对象当作Comparable使用时,将抛出一个异常。

17.6.1   SortedSet

SortedSet中的元素可以保证处于排序状态,这使得它可以通过在SortedSet接口中的下列方法提供附加的功能:Comparator comparator()返回当前Set使用的Comparator;或者返回null,表示以自然方式排序。

Object first() 返回容器中的第一个元素。

Object last() 返回容器中的最末一个元素。

SortedSet subSet(fromElement, toElement) 生成此Set的子集,范围从fromElement(包含)到toElement(不包含)。

SortedSet headSet(toElement) 生成此Set的子集,由小于toElement的元素组成。

SortedSet tailSet(fromElement) 生成此Set的子集,由大于或等于fromElement的元素组成。

下面是一个简单的示例:

 

 

注意,SortedSet的意思是“按对象的比较函数对元素排序”,而不是指“元素插入的次序”。插入顺序可以用LinkedHashSet来保存。

练习9:(2) 使用RandomGenerator.String来填充TreeSet,但是要使用字母序排序。打印这个TreeSet,并验证其排序顺序。

练习10:(7) 使用LinkedList作为底层实现,定义你自己的SortedSet。

 

 


17.7   队列
除了并发应用,Queue在Java SE5中仅有的两个实现是LinkedList和PriorityQueue,它们的差异在于排序行为而不是性能。下面是涉及Queue实现的大部分操作的基本示例(并非所有的操作在本例中都能工作),包括基于并发的Queue。你可以将元素从队列的一端插入,并于另一端将它们抽取出来:

 

 

你可以看到,除了优先级队列,Queue将精确地按照元素被置于Queue中的顺序产生它们。

17.7.1   优先级队列

在第11章曾经给出过优先级队列的一个简单介绍。其中更有趣的问题是to-do列表,该列表中每个对象都包含一个字符串和一个主要的以及次要的优先级值。该列表的排序顺序也是通过实现Comparable而进行控制的:

 

 

你可以看到各个项的排序是如何因为使用了优先级队列而得以自动发生的。

练习11:(2) 创建一个类,它包含一个Integer,其值通过使用java.util.Random被初始化为0到100之间的某个值。使用这个Integer域来实现Comparable。用这个类的对象来填充PriorityQueue,然后使用poll()抽取这些值以展示该队列将按照我们预期的顺序产生这些值。

17.7.2   双向队列

双向队列(双端队列)就像是一个队列,但是你可以在任何一端添加或移除元素。在LinkedList中包含支持双向队列的方法,但是在Java标准类库中没有任何显式的用于双向队列的接口。因此,LinkedList无法去实现这样的接口,你也无法像在前面的示例中转型到Queue那样去向上转型到Deque。但是,你可以使用组合来创建一个Deque类,并直接从LinkedList中暴露相关的方法:

 

如果将这个Deque用于自己的程序中,你可能会发现,为了使它实用,还需要增加其他方法。

下面是对Deque类的简单测试:

 

 

你不太可能在两端都放入元素并抽取它们,因此,Deque不如Queue那样常用。

 

 

17.8   理解Map
正如你在第11章中所学到的,映射表(也称为关联数组)的基本思想是它维护的是键-值(对)关联,因此你可以使用键来查找值。标准的Java类库中包含了Map的几种基本实现,包括:HashMap,TreeMap,LinkedHashMap,WeakHashMap,ConcurrentHashMap,IdentityHashMap。它们都有同样的基本接口Map,但是行为特性各不相同,这表现在效率、键值对的保存及呈现次序、对象的保存周期、映射表如何在多线程程序中工作和判定“键”等价的策略等方面。Map接口实现的数量应该可以让你感觉到这种工具的重要性。

你可以获得对Map更深入的理解,这有助于观察关联数组是如何创建的。下面是一个极其简单的实现:

 

 

关联数组中的基本方法是put()和get(),但是为了容易显示,toString()方法被覆盖为可以打印键-值对。为了展示它可以工作,main()用字符串对加载了一个AssociativeArray,并打印了所产生的映射表,随后是获取一个值的get()。

为了使用get()方法,你需要传递想要查找的key,然后它会将与之相关联的值作为结果返回,或者在找不到的情况下返回null。get()方法使用的可能是能想象到的效率最差的方式来定位值的:从数组的头部开始,使用equals()方法依次比较键。但这里的关键是简单性而不是效率。

因此上面的版本是说明性的,但是缺乏效率,并且由于具有固定的尺寸而显得很不灵活。幸运的是,在java.util中的各种Map都没有这些问题,并且都可以替代到上面的示例中。

练习12:(1) 在AssociativeArray.java的main()中替代为使用HashMap、TreeMap和LinkedHashMap。

练习13:(4) 使用AssociativeArray.java来创建一个单词出现次数的计数器,用String映射到Integer。使用本书中的net.mindview.util.TextFile工具打开一个文本文件,并使用空格和标点符号将该文件断开为单词,然后计数该文件中各个单词出现的次数。

17.8.1   性能

性能是映射表中的一个重要问题,当在get()中使用线性搜索时,执行速度会相当地慢,而这正是HashMap提高速度的地方。HashMap使用了特殊的值,称作散列码,来取代对键的缓慢搜索。散列码是“相对唯一”的、用以代表对象的int值,它是通过将该对象的某些信息进行转换而生成的。hashCode()是根类Object中的方法,因此所有Java对象都能产生散列码。HashMap就是使用对象的hashCode()进行快速查询的,此方法能够显著提高性能。

下面是基本的Map实现。在HashMap上打星号表示如果没有其他的限制,它就应该成为你的默认选择,因为它对速度进行了优化。其他实现强调了其他的特性,因此都不如HashMap快。

 

散列是映射中存储元素时最常用的方式。稍后你将会了解散列机制是如何工作的。

对Map中使用的键的要求与对Set中的元素的要求一样,在TypesForSets.java中展示了这一点。任何键都必须具有一个equals()方法;如果键被用于散列Map,那么它必须还具有恰当的hashCode()方法;如果键被用于TreeMap,那么它必须实现Comparable。

下面的示例展示了通过Map接口可用的操作,这里使用了前面定义过的CountingMapData测试数据集:

 

 

printKeys()展示了如何生成Map的Collection视图。keySet()方法返回由Map的键组成的Set。因为在Java SE5提供了改进的打印支持,你可以直接打印values()方法的结果,该方法会产生一个包含Map中所有“值”的Collection。(注意,键必须是唯一的,而值可以有重复。)由于这些Collection背后是由Map支持的,所以对Collection的任何改动都会反映到与之相关联的Map。

此程序的剩余部分提供了每种Map操作的简单示例,并测试了每种基本类型的Map。

练习14:(3) 说明java.util.Properties在上面的程序中可以工作。

17.8.2   SortedMap

使用SortedMap(TreeMap是其现阶段的唯一实现),可以确保键处于排序状态。这使得它具有额外的功能,这些功能由SortedMap接口中的下列方法提供:

Comparator comparator():返回当前Map使用的Comparator;或者返回null,表示以自然方式排序。T firstKey()返回Map中的第一个键。T lastKey()返回Map中的最末一个键。SortedMap subMap(fromKey, toKey)生成此Map的子集,范围由fromKey(包含)到toKey(不包含)的键确定。SortedMap headMap(toKey)生成此Map的子集,由键小于toKey的所有键值对组成。SortedMap tailMap(fromKey)生成此Map的子集,由键大于或等于fromKey的所有键值对组成。

下面的例子与SortedSetDemo.java相似,演示了TreeMap新增的功能:

 

 

此处,键值对是按键的次序排列的。TreeMap中的次序是有意义的,因此“位置”的概念才有意义,所以才能取得第一个和最后一个元素,并且可以提取Map的子集。

17.8.3   LinkedHashMap

为了提高速度,LinkedHashMap散列化所有的元素,但是在遍历键值对时,却又以元素的插入顺序返回键值对(System.out.println()会迭代遍历该映射,因此可以看到遍历的结果)。此外,可以在构造器中设定LinkedHashMap,使之采用基于访问的最近最少使用(LRU)算法,于是没有被访问过的(可被看作需要删除的)元素就会出现在队列的前面。对于需要定期清理元素以节省空间的程序来说,此功能使得程序很容易得以实现。下面就是一个简单的例子,它演示了LinkedHashMap的这两种特点:

 

在输出中可以看到,键值对是以插入的顺序进行遍历的,甚至LRU算法的版本也是如此。但是,在LRU版本中,在(只)访问过前面六个元素后,最后三个元素移到了队列前面。然后再一次访问元素“o”时,它就被移到队列后端了。

 

 

 

你可能感兴趣的:(JAVA编程思想(中文版))