【Thinking In Java 3rd】第一章对象引论

来源:互联网 发布:windows api教程 编辑:程序博客网 时间:2024/05/22 06:59

第一章对象引论
“我们之所以将自然界分解,组织成为各种概念,并总结出其重要性,主要是因为我们知道我们的语言社区所共同持有的,并以我们的语言的形式所固定下来的一种约定…除非赞成这个约定中所颁布的有关数据的组织和分类的内容,否则我们根本无法交谈。”Benjamin Lee Whorf(1897-1941)
计算机革命起源于机器,因此,编程语言的起源也始于对机器的模仿趋近。
但是,计算机并非只是机器那么简单。计算机是头脑延伸的工具(就象Steven Jobs常喜欢说的“头脑的自行车”一样),同时还是一种不同类型的表达媒体。因此,这种工具看起来已经越来越不像机器,而更像我们头脑的一部分,以及一种诸如写作、绘画、雕刻、动画、电影等的表达形式一样。面向对象程序设计(Object-oriented Programming, OOP)便是这种以计算机作为表达媒体的大潮中的一波。
本章将向您介绍包括开发方法概述在内的OOP的基本概念。本章,乃至本书中,都假设您在过程型编程语言(Procedural Programming Language)方面已经具备了某些经验,当然不一定必须是C。如果您认为您在阅读本书之前还需要在编程以及C语法方面多做些准备,您可以研读本书所附的培训光盘“Java基础(Foundations for Java)”。
本章介绍的是背景性的和补充性的材料。许多人在没有了解面向对象程序设计的全貌之前,感觉无法轻松自在地从事此类编程。因此,此处将引入众多的概念,以期助您建立对OOP的扎实全面的见解。然而,还有些人可能直到在看到运行机制的某些实例之前,都无法了解面向对象程序设计的全貌,这些人如果没有代码在手,就会陷于困境并最终迷失方向。如果您属于后面的这个群体,并且渴望尽快获取Java语言的细节,那么您可以越过本章——在此处越过本章并不会妨碍您编写程序和学习语言。但是,您最终还是会回到本章来填补您的知识,这样您才能够了解到为什么对象如此重要,以及怎样使用对象来进行设计。
抽象过程
所有编程语言都提供抽象(abstraction)机制。可以认为,你所能够解决的问题的复杂性直接取决于抽象的类型和质量。我所谓的“类型”是指“你所抽象的是什么?”汇编语言是对底层机器的小型抽象。接着出现的许多所谓“命令式(Imperative)”语言(诸如FORTRAN、BASIC、C等)都是对汇编语言的抽象。这些语言在汇编语言之上有了大幅的改进,但是它们所作的主要抽象仍要求你在解决问题时要基于计算机的结构,而不是基于你试图要解决的问题的结构来考量。程序员必须建立在机器模型(Machine Model)(位于你对问题建模所在的解空间(Solution Space)内,例如计算机)和实际待解问题模型(Problem Model)(位于问题所在的问题空间(Problem Space)内)之间的关联。建立这种映射(Mapping)是费力的,而且它不属于编程语言的内在性质,这使得程序难以编写,并且维护代价高昂。由此,产生了完整的“编程方法(Programming Method)”产业。
另一种对机器建模的方式就是对待解决问题建模。早期的编程语言,诸如LISP和APL都选择世界的某种特定视图(分别对应于“所有问题最终都是列表(List)”或者“所有问题都是算法形式的(algorithmic)”)。PROLOG则将所有问题都转换成为决策链(Chain of decisions)。此外还产生了基于约束条件(constraint-based)编程的语言和专门通过对图形符号操作来实现编程的语言(后者被证明限制性过强)。这些方式对于它们被设计时所瞄准要解决的特定类型的问题都是不错的解决方案,但是一旦超出其特定领域,它们就力不从心了。
面向对象方式(Object-oriented approach)通过向程序员提供用来表示在问题空间中的元素的工具而更进一步。这种表示方式具有足够的概括性,使得程序员不会受限于任何特定类型的问题。我们将问题空间中的元素及其在解空间中的表示成为“对象(Object)”。(你还需要一些无法类比为问题空间元素的对象)。这种思想的实质是:程序可以通过添加新类型的对象使自身适用于某个特定问题。因此,当你在阅读描述解决方案的代码的同时,也是在阅读问题的表述。相比以前我们所拥有的所有语言,这是一种更灵活和更强有力的语言抽象。1所以,OOP允许以问题的形式来描述问题,而不是以执行解决方案的计算机的形式来描述问题。但是它仍然与计算机有联系:每个对象看起来都有点像一台微型计算机——它具有状态,并且能够执行你赋予它的各种操作。如果要在现实世界中对对象作类比,那么说它们都具有特性(Characteristic)和行为(Behavior)似乎不错。
Alan Kay曾经总结了第一个成功的面向对象语言,同时也是Java赖为根基的语言之一的Smalltalk的五个基本特性,这些特性表现了一种纯粹的面向对象程序设计方式:
1. 万物皆为对象。将对象视为奇特的变量,它可以存储数据,除此之外,你还可以要求它在自身上执行操作。理论上讲,你可以抽取待解问题的任何概念化构件(狗、建筑物、服务等),将其表示为程序中的对象。
2. 程序是对象的集合,它们彼此通过发送消息来调用对方。要想产生一个对对象的请求,就必须对该对象发送一条消息。更具体地说,你可以把消息想象为对某个特定对象的方法的调用请求。
3. 每个对象都拥有由其它对象所构成的存储。你可以通过创建包含现有对象集合的包的方式来创建新类型的对象。因此,你可以在程序中构建复杂的体系,同时将其复杂性通过对象的质朴性得以屏蔽。
4. 每个对象都拥有其类型(Type)。按照通用的说法,“每个对象都是某个类(Class)的一个实例(Instance)”,其中“类”就是“类型”的同义词。每个类中最重要的区别于其它类的特性就是“你可以发送什么消息给它?”
5. 某一特定类型的所有对象都可以接收(Receive)同样的消息。这是一句意味深长的表述,你在稍后便会看到。因为“圆形(circle)”类型的对象同时也是“几何形(shape)”类型的对象,所以一个“圆形”对象必定能够接受(accept)发送给“几何形”对象的消息。这意味着你可以编写与“几何形”交互并自动处理所有与几何形性质相关的事物的的代码。这种“可替代性(substitutability)”是OOP中最强有力的概念之一。
Booch 提出了一个对对象的更加简洁的描述:对象拥有状态(State)、行为(Behaviour)和标识(Identity)。这意味着每一个对象都可以拥有内部数据(它们给出了该对象的状态)和方法(它们产生行为),并且每一个对象都可以唯一地与其他对象相区分开,具体说来,就是每一个对象在内存中都有一个唯一的地址。2
1 某些编程语言的设计者认为面向对象编程本身不足以轻松地解决所有编程问题,所以他们提倡将不同的方式结合到多重聚和编程语言中(multipleparadigm programming language)。您可以查阅Timothy Budd的Multipleparadigm Programming in Leda一书(Addison-Wesley 1995)
2 这确实显得有一点过于受限了,因为对象可以存在于不同的机器和地址空间中,它们还可以被存储在硬盘上。在这些情况下,对象的标识就必须由内存地址之外的某些东西来确定了。
每个对象都有一个接口
亚里士多德大概是第一个深入研究类型(Type)的哲学家,他曾提出过鱼类和鸟类(the class of fishes and the class of birds)这样的概念。所有的对象都是唯一的,但同时也是具有相同的特性和行为的对象所归属的类的一部分,这种思想被直接应用于第一个面向对象语言Simula-67,它在程序中使用基本关键词class来引入新的类型。
Simula,就像其名字一样,是为了开发诸如经典的“银行出纳员问题(Bank teller problem)”这样的仿真程序而创建的。在银行出纳员问题中,有出纳员、客户、账户、交易和货币单位等许多“对象”。在程序执行期间具有不同的状态而其他方面都相似的对象会被分组到对象的类中,这就是关键词class的由来。创建抽象数据类型(类)是面向对象程序设计的基本概念之一。抽象数据类型的运行方式与内置(built-in)类型几乎完全一致:你可以创建某一类型的变量(按照面向对象的说法,程其为对象或实例),然后操作这些变量(称其为发送消息或请求;你发送消息,对象就能够知道需要做什么)。每个类的成员(member)或元素(element)都共享相同的性质:每个账户都有结余金额,每个出纳都可以处理存款请求等。同时,每个成员都有其自身的状态:每个账户都有不同的结余金额,每个出纳都有自己的名称。因此,出纳、客户、账户、交易等都可以在计算机程序中被表示成为唯一的实体(entity)。这些实体就是对象,每一个对象都属于定义了特性和行为的某个特定的类。
所以,尽管我们在面向对象程序设计中实际所作的是创建新的数据类型,但事实上所有的面向对象程序设计语言都使用Class关键词来表示数据类型。当你看到类型(Type)一词时,请将其作为类(Class)来考虑,反之亦然。3
既然类被描述成了具有相同特性(数据元素)和行为(功能)的对象集合,那么一个类就确实是一个数据类型,就像所有浮点型数字具有相同的特性和行为集合一样。二者的差异在于,程序员通过定义类来适应问题,而不再被强制只能使用现有的被设计用来表示在机器中的存储单元的数据类型。你可以根据需求,通过添加新的数据类型来扩展编程语言。编程系统欣然接受新的类,并且给予它们与内置类型相同的管护和类型检查(Type-checking)。
面向对象方法并不是仅局限于构件仿真程序。无论你是否同意任何程序都是你所设计的系统的一个仿真的观念,面向对象技术确实可以将大量的问题降解为一个简单的解决方案。
一旦类被建立,你想要创建该类的多少个对象,就可以创建多少个了,然后去操作它们,就像它们是存在于你的待解问题中的元素一样。事实上,面向对象程序设计的挑战之一,就是在问题空间的元素和解空间的对象之间创建一对一的映射。
但是,你怎样才能获得对你有用的对象呢?必须有某种方式产生对对象的请求,使对象完成诸如完成一笔交易、在屏幕上画图、打开开关之类的任务。每个对象都只能满足某些请求,这些请求由对象的接口(Interface)所定义,决定接口的便是类型(Type)。以电灯泡为例来做一个简单的比喻:
3 有些人对此还是去别对待的,他们声称类型决定了接口,而类是该接口的一个特定实现。
Light lt = new Light();
lt.on();
接口定义了你能够对某一特定对象发出的请求。但是,在程序中必须有满足这些请求的代码。这些代码与隐藏的数据一起构成了实现(implementation)。从过程型编程的观点来看,这并不太复杂。在类型中,每一个可能的请求都有一个方法与之相关联,当你向对象发送请求时,与之相关联的方法就会被调用。此过程通常被总结为:你向某个对象发送消息(产生请求),这个对象便知道此消息的目的,然后执行对应的程序代码。
上例中,类型/类的名称是Light,特定的Light对象的名称是lt,你可以向Light对象发出的请求是:打开它、关闭它、将它调亮、将它调暗。你以这种方式创建了一个Light对象:定义这个对象的“引用(reference)”(lt),然后调用new方法来创建该类型的新对象。为了向对象发送消息,你需要声明对象的名称,并以圆点符号连接一个消息请求。从预定义类的用户观点来看,这些差不多就是用对象来进行设计的全部。
前面的图是UML(Unified Modelling Language)形式的图,每个类都用一个方框表示,类名在方框的顶部,你所关心的任何数据成员(data member)都描述在方框的中间部分,方法(隶属于此对象的,用来接收你发给此对象的消息的函数)在方框的底部。通常,只有类名和公共方法(Public Method)被示于UML设计图中,因此,方框的中部并不绘出。如果你只对类型感兴趣,那么方框的底部甚至也不需要被绘出。
每个对象都提供服务
当你正是如开发或理解一个程序设计时,最好的方法之一就是将对象想象为“服务提供者(Service Provider)”。你的程序本身将向用户提供服务,它将通过调用其它对象提供的服务来实现这一目的。你的目标就是去创建(或者最好是在现有代码库中寻找)能够提供理想的服务来解决问题的对象集合。
着手从事这件事的方式之一是询问“如果我可以将问题从表象中抽取出来,那么什么样的对象可以马上解决我的问题呢?”例如,假设你正在创建一个簿记(Bookkeeping)系统,你可以想象系统应该具有某些包括了预定义的簿记输入屏幕的对象,一个执行簿记计算的对象集合,以及一个处理在不同的打印机上打印支票和开发票的对象。也许上述对象中的某些已经存在了,但是对于那些并不存在的对象,它们看起来什么样?它们能够提供哪些服务?它
们需要哪些对象才能履行它们的义务?如果你持续这样做,你最终会发现你将到达这样一个节点:你会说“那个对象看起来很简单,以至可以坐下来写代码了”,或者会说“我肯定那个对象已经存在了”。这是将问题分解为对象集合的一种合理方式。
将对象看作是服务提供者还有一个附加的好处:它有助于提高对象的内聚性(cohesiveness)。高内聚是软件设计的基本质量要求之一:这意味着一个软件构件(例如一个对象,尽管它也有可能被用来指代一个方法或一个对象库)的各个方面“组合(fit together)”得很好。人们在设计对象时所面临的一个问题是将过多的功能都填塞在一个对象中。例如,在你的检查打印模式模块中,你可以设计一个对象,它了解所有的格式和打印技术。你可能会发现这些功能对于一个对象来说太多了,你需要的是三个甚至更多个对象,其中,一个对象可以是所有可能的支票排版的目录,它可以被用来查询有关如何打印一张支票的信息;另一个对象或是对象集合可以是一个通用的打印接口,它知道有关所有不同类型的打印机的信息(但是不包含任何有关簿记的内容,它更应该是一个需要购买而不是自己编写的对象);第三个对象通过调用另外两个对象的服务来完成打印任务。因此,每个对象都有一个它所能提供服务的高内聚的集合。在良好的面向对象设计中,每个对象都可以很好地完成一项任务,但是它并不试图多更多的事。就像在这里看到的,不仅允许某些对象可以通过购买获得(打印机接口对象),而且还使对象在某处重用成为可能(支票排版目录对象)。
将对象作为服务提供者看待是一件伟大的简化工具,它不仅在设计过程中非常有用,而且当其他人试图理解你的代码或重用某个对象时(如果他们看出了这个对象所能提供的服务的价值所在的话),它会使将对象调整到适应其设计的过程变得简单得多。
被隐藏的具体实现
将程序开发人员按照角色分为类创建者(class creator,那些创建新数据类型的程序员)和客户端程序员4(client programmer,那些在其应用中使用数据类型的类消费者)是大有裨益的。客户端程序员的目标是收集各种用来实现快速应用开发(Rapid Application Development)的类。类创建者的目标是构建类,该类只向客户端程序员暴露必需的部分,而隐藏其它所有部分。为什么要这样呢?因为如果加以隐藏,那么客户端程序员将不能够访问它,这意味着类创建者可以任意修改被隐藏的部分,而不用担心对其他任何人造成影响。被隐藏的部分通常代表对象内部脆弱的部分,它们很容易被粗心的或不知内情的客户端程序员所毁坏,因此将实现隐藏起来可以减少程序的Bug。
实现隐藏的概念再怎么强调也不会过分。在任何相互关系中,具有关系所涉及的各方都遵守的边界是十分重要的事情。当你创建一个类库(Library)时,你就建立了与客户端程序员之间的关系,他们同样也是程序员,但是他们是使用你的类库来构建应用,或者是构建更大的类库的程序员。如果所有的类成员(Member)对任何人都是可用的,那么客户端程序员就可以对类作任何事情,而不受任何约束。即使你希望客户端程序员不要直接操作你的类中的某些成员,但是如果没有任何访问控制,将无法阻止此事发生。所有东西都将赤裸裸地暴露于世前。
4 关于这个术语的表述,我的感谢我的朋友Scott Meyers
因此,访问控制的第一个存在原因就是让客户端程序员无法触及他们不应该触及的部分——这些部分对数据类型的内部操作来说是必需的,但并不是用户需要的用来解决特定问题的接口的一部分。这对用户来说其实是一项服务,因为他们可以很容易地看出哪些东西对他们来说很重要,而哪些东西可以忽略。
访问控制的第二个存在原因就是允许库设计者可以改变类内部的工作方式而不用担心是否会影响到客户端程序员。例如,你可能为了减轻开发任务而以某种简单的方式实现了某个特定类,但稍后你就发现你必须改写它才能使其运行得更快。如果接口和实现可以清晰地分离并得以保护,那么你就可以轻而易举地完成这项工作。
Java使用三个关键字来在类的内部设定边界:public、private、protected。它们的含义和用法非常易懂。这些“访问指定词(access specifier)”决定了紧跟其后被定义的东西可以被谁使用。public表示紧随其后的元素对任何人都是可用的,另一方面,private这个关键字表示除类型创建者和该类型的内部方法之外的任何人都不能访问的元素。private就像你与客户端程序员之间的一堵砖墙,如果有人试图访问private成员,就会在编译时刻得到错误信息。protected关键字与private作用相当,差别仅在于继承类(Inheriting class)可以访问protected成员,但是不能访问private成员。稍后将会对继承(Inheritance)进行介绍。
Java还有一种缺省(default)的访问权限,当你没有使用前面提到的任何访问指定词时,它将发挥作用。这种权限通常被称为“包访问权限(package access)”,因为在这种权限下,类可以访问在同一个包中的其它类的成员,但是在包之外,这些成员如同private一样。
复用具体实现
一旦类被开发并被测试完成,那么它就应该(理想情况下)代表一个有用的代码单元。事实证明,这种复用性(reusability)并不容易达到我们所希望的那种程度,产生一个可复用的对象设计需要丰富的经验和敏锐的洞察力。但是一旦你拥有了这样的一个设计,它就会请求被复用。代码复用是面向对象程序设计语言所提供的最了不起的优点之一。
最简单的复用某个类的方式就是直接使用该类的一个对象,此外你也可以将该类的一个对象置于某个新的类中。我们称其为“创建一个成员对象”。新的类可以由任意数量、任意类型的其它对象以任意可以实现新的类中想要的功能的方式所组成。因为你在使用现有的类合成新的类,所以这种概念被称为组合(composition),如果组合式动态发生的,那么它通常被称为聚合(aggregation)。组合经常被视为“has-a”(拥有)关系,就像我们常说的“小汽车拥有引擎”一样。
(这张UML图用实心菱形声明有一辆小汽车,它表明了组合关系。我通常采用最简单的形式:仅仅是一条没有菱形的线来表示关联(association)。5)
5 通常对于大多数图来说,这样表示已经足够了,你并不需要关心你所使用的是聚合还是组合。
组合带来了极大的灵活性。新类的成员对象通常都被声明为private,使得使用该类的客户端程序员不能访问它们。这也使得你可以在不干扰现有客户端代码的情况下,修改这些成员。你也可以在运行时刻修改这些成员对象,以实现动态修改程序的行为。下面将要讨论的继承(inheritance)并被具备这样的灵活性,因为编译器必须对通过集成而创建的类施加编译时刻的限制。
由于继承在面向对象程序设计中如此重要,所以它经常被高度强调,于是程序员新手就会有这样的印象:处处都应该使用继承。这会导致难以使用并过分复杂的设计。实际上,在建立新类时,你应该首先考虑组合,因为它更加简单而灵活。如果你采用这种方式,你的设计会变得更加清晰。一旦有了一些经验之后,你便能够看透必须使用继承的场合。
继承:复用接口
对象这种观念,本身就是十分方便的工具,使得你可以通过概念(concept)将数据和功能封装到一起,因此你可以对问题域的观念给出恰当的表示,而不用受制于必须使用底层机器语言。这些概念用关键字class来表示,形成了编程语言中的基本单位。
遗憾的是,这样做还是有很多麻烦,在创建了一个类之后,即使另一个新类与其具有相似的功能,你还是得重新创建一个新类。如果我们能够以现有的类为基础,复制它,然后通过添加和修改这个副本来创建新类那就要好得多了。通过继承便可以达到这样的效果,不过也有例外,当源类(被称为基类( base class)、超类(super class)或父类(parent class))发生变动时,被修改的“副本”(被称为导出类(derived class)、继承类(inherited class)或子类(subclass, child class))也会反映出这些变动。
(这张UML图中的箭头从导出类指向基类,就像稍后你会看到的,通常会存在一个以上的导出类)
类型不仅仅只是描述了作用于一个对象集合之上的约束条件,同时还有与其它类型之间的关系。两个类型可以有相同的特性和行为,但是其中一个类型可能比另一个含有更多的特性,并且可以处理更多的消息(或以不同的方式来处理消息)。继承使用基类和导出类的概念表示了这种类型之间的相似性。一个基类包含其所有导出类共享的特性和行为。你可以创建一个基类来表示系统中某些对象的核心概念,从基类中导出其它的类,来表示此核心可以被实现的各种不同方式。
以垃圾回收机(trash-recycling machine)为例,它用来归类散落的垃圾。trash是基类,每一件垃圾都有重量、价值等特性,可以被切碎、熔化或分解。在此基础上,可以通过添加额外的特性(例如瓶子有颜色)或行为(例如铝罐可以被压碎,铁罐可以被磁化)导出更具体的垃圾类型。此外,某些行为可能不同(例如纸的价值依赖其类型和状态)。你可以通过使用继承来构建一个类型层次结构(type hierarchy)来表示你的待解问题相关联的类型。
第二个例子是经典的在计算机辅助设计系统或游戏仿真系统中可能被泳道的几何形(shape)的例子。基类是shape,每一个shape都具有尺寸、颜色、位置等,同时每一个shape都可以被绘制、擦除、移动和着色等。在此基础上,可以导出(继承出)具体的几何形状——圆形、正方形、三角形等——每一种都具有额外的特性和行为,例如某些形状可以被翻转。某些行为可能并不相同,例如面积计算。类型层次结构同时体现了几何形状之间的相似性和相异性。
将解决方案转换成为问题术语的描述是大有裨益的,因为你不需要在问题描述和解决方案描述之间建立众多的中介模型。通过使用对象,类型层次结构成为了主要模型,因此,你可以直接从真实世界中对系统进行描述过渡到用代码对系统进行描述。事实上,对使用面向对象设计的人们来说,困难之一是从开始到结束太过于简单。对于训练有素、善于寻找复杂的解决方案的头脑来说,可能会在一开始被这种简单性给难倒。
当你继承现有类型时,也就创造了新的类型。这个新的类型不仅包括现有类型的所有成员(尽管private成员被隐藏了起来,并且不可访问),而且更重要的是它复制了基类的接口。也就是说,所有可以发送给基类对象的消息同时也可以发送出导出类。由于我们通过可发送消息的类型可知类的类型,所以这也就意味着导出类与基类具有相同的类型。在前面的例子中,“一个圆形也就是一个几何形状”。通过继承而产生的类型等价(type equivalence)是理解面向对象程序设计方法内涵的重要门槛。
由于基类和导出类具有相同的基础接口,所以伴随此接口的必定有某些具体实现。也就是说,当对象接收到特定消息时,必须有某些代码去执行。如果你只是简单地继承一个类而并不作其他任何事,那么在基类接口中的方法将会直接继承到导出类中。这意味着导出类的对象不仅与基类拥有相同的类型,而且还拥有相同的行为,这样做并没有什么特别的意义。
有两种方法可以使基类与导出类产生差异。第一种方法非常直接:直接在导出类中添加新方法。这些新方法并不是基类接口的一部分。这意味着基类不能直接满足你的所有需求,因此你必需添加更多的方法。这种对继承简单而基本的使用方式,有时对你的问题来说确实是一种完美的解决方式。但是,你应该仔细考虑是否存在你的基类也需要这些额外方法的可能性。这种设计的发现与迭代过程在面向对象程序设计中会经常发生。
虽然继承有时可能意味着在接口中添加新方法(尤其是在以extends关键字表示继承的Java中),但并非总需如此。第二种以及其它使导出类和基类之间产生差异的方法是改变现有基类的方法的行为。这被称之为重载(overriding)。
要想重载某个方法,可以直接在导出类中创建该方法的新定义即可。你可以说:“此时,我
正在使用相同的接口方法,但是我想在新类型中做些不同的事情。”
是一个(is-a)与像是一个(is-like-a)关系
对于继承可能会引发某种争论:继承应该只重载基类的方法(而并不添加在基类中没有的新方法)吗?如果这样做,就意味着导出类和基类是完全相同的类型,因为它们具有完全相同的接口。结果你可以用一个导出类对象来完全替代一个基类对象。这可以被视为“纯粹替代(pure substitution)”,通常称之为“替代法则(substitution principle)”。在某种意义上,这是一种处理继承的理想方式。我们经常将这种情况下的基类与导出类之间的关系称为“is-a”关系,因为你可以说“一个圆形就是一个几何形状”。判断是否继承,就是要确定你是否可以用is-a来描述类之间的关系,并使之具有实际意义。
有时你必须在导处类型中添加新的接口元素,这样也就扩展了接口并创建了新的类型。这个新的类型仍然可以替代基类,但是这种替代并不完美,因为基类无法访问你新添加的方法。这种情况我们可以描述为“is-like-a”关系。新类型具有旧类型的接口,但是它还包含其他方法,所以你不能说它们完全相同。以空调为例,假设你的房子里已经布线安装好了所有的冷气设备的控制器,也就是说,你的房子具备了让你控制冷气设备的接口。想象一下,如果空调坏了,你用一个既能制冷又能制热的热力泵替换了它,那么这个热力泵就“is-like-a(像是一个)”空调,但是它可以做更多的事。因为你的房子的控制系统被设计为只能控制冷气设备,所以它只能和新对象中的制冷部分进行通信。尽管新对象的接口已经被扩展了,但是现有系统除了源接口之外,对其他东西一无所知。
当然,在你看过这个设计之后,你会发现很显然,Cooling System这个基类不够一般化,应该将其更名为“温度控制系统”,使其可以包括制热功能,这样我们就可以套用替代法则了。这张图说明了在真实世界中进行设计时可能会发生的事情。
当你看到替代法则时,很容易会认为这种方式“纯粹替代”是唯一可行的方式,而且事实上言此方式,你的设计会显得很好。但是你会发现有时同样很明显你必须在导处类接口中添加新方法。只要仔细审视,两种方法的使用场合应该是相当明显的。
伴随多态的可互换对象
在处理类型的层次结构时,你经常想把一个对象不要当作它所属的特定类型来对待,而是将其当作其基类的对象来对待。这使得你可以编写出不依赖于特定类型的代码。在shape的例子中,方法都是用来操作泛化(generic)形状的,不管它们是圆形、正方形、三角形还是其他什么尚未定义的形状。所有的几何形状都可以被绘制、被擦除、被移动,所以这些方法都是直接对一个shape对象发送消息,并不用担心这个对象如何处理该消息。
这样的代码是不会受添加新类型的影响的,而且添加新类型是扩展一个面向对象程序已处理新情况的最常用方式。例如,你可以从shape中导出一个新的子类型pentagon(无边形),而并不需要修改处理泛化几何形状的方法。通过导出新的子类型而轻松扩展设计的能力是封装改动的基本方式之一。这种能力可以极大地改善我们的设计,同时也降低了软件维护的代价。
但是,在试图将导出类型的对象当作他们的泛化基类对象来看待时(把圆形看作是几何形状,把自行车看作是交通工具,把鸬鹚看作是鸟等等),仍然存在一个问题。如果某个方法是要泛化几何形状绘制自己,泛化交通工具前进,或者是泛化的鸟类移动,那么编译器在编译时是不可能知道应该执行哪一段代码的。这就是关键所在:当发送这样的消息时,程序员并不想知道哪一段代码将被执行;绘图(draw)方法可以被等同地应用于圆形、正方形、三角形之上, 而对象会依据自身的具体类型来执行恰当的代码。如果你不需要知道哪一段代码会被执行,那么当你添加新的子类型时,不需要更改方法调用的代码,就能够执行不同的代码。因此,编译器无法精确地了解哪一段代码将会被执行,那么它该怎么办呢?例如,在下面的图中,BirdController对象仅仅处理泛化的Bird对象,而不了解它们的确切类型。从BirdController的角度看,这么做非常方便,因为不需要编写特别的代码来判定要处理的Bird对象的确切类型或是Bird对象的行为。当move()方法被调用时,即便忽略Bird的具体类型,也会产生正确的行为(鹅跑、飞或游泳,企鹅跑或游泳),那么,这又是如何发生的呢?
这个问题的答案,也是面向对象程序设计的最重要的妙诀:编译器不可能产生传统意义上的函数调用(function call)。一个非面向对象(non-OOP)编译器产生的函数调用会引起所谓的“前期绑定(early binding)”,这个术语你可能以前从未听说过,因为你从未想过函数调用的其他方式。这么做意味着编译器将产生对一个具体函数名字的调用,而链接器(linker)将这个调用解析到将要被执行代码的绝对地址(absolute address)。在OOP中,程序直到运行时刻才能够确定代码的地址,所以当消息发送到一个泛化对象时,必须采用其他的机制。
为了解决这个问题,面向对象程序设计语言使用了“后期绑定(late binding)”的概念。当你向对象发送消息时,被调用的代码直到运行时刻才能被确定。编译器确保被调用方法存在,并对调用参数(argument)和返回值(return value)执行类型检查(无法提供此类保证的语言被称为是弱类型的(weakly typed)),但是并不知道将会被执行的确切代码。
为了执行后期绑定,Java使用一小段特殊的代码来替代绝对地址调用。这段代码使用在对象中存储的信息来计算方法体的地址(这个过程将在第7章中详述)。这样,根据这一小段代码的内容,每一个对象都可以具有不同的行为表现。当你向一个对象发送消息时,该对象就能够知道对这条消息应该做些什么。
在某些语言中,你必须明确地声明希望某个方法具备后期绑定属性所带来的灵活性(C++是使用virtual关键字来实现的)。在这些语言中,方法在缺省情况下不是动态绑定的。而在Java中,动态绑定是缺省行为,你不需要添加额外的关键字来实现多态(polymorphism)。
在来看看几何形状的例子。整个类族(其中所有的类都基于相同一致的接口)在本章前面已有图示。为了说明多态,我们要编写一段代码,它忽略类型的具体细节,仅仅和基类交互。这段代码和类型特定信息是分离的(decoupled),这样做使代码编写更为简单,也更易于理解。而且,如果通过继承机制添加一个新类型,例如Hexagon,你编写的代码对Shape的新类型的处理与对已有类型的处理会同样出色。正因为如此,可以称这个程序是可扩展的(extensible)。
如果用Java来编写一个方法(后面很快你就会学到如何编写):
void doStuff(Shape s) {
s.erase();
// ...
s.draw();
}
这个方法可以与任何Shape交谈,因此它是独立于任何它要绘制和擦除的对象的具体类型的。如果程序中其他部分用到了doStuff()方法:
Circle c = new Circle();
Triangle t = new Triangle();
Line l = new Line();
doStuff(c);
doStuff(t);
doStuff(l);
对doStuff()的调用会被自动地正确处理,而不管对象的确切类型。
这是一个相当令人惊奇的诀窍。看看下面这行代码:
doStuff(c);
如果被传入到预期接收Shape的方法中,究竟会发生什么呢?由于Circle可以被doStuff()看作是Shape,也就是说,doStuff()可以发送给Shape的任何消息,Circle都可以接收,那么,这么做是完全安全且合乎逻辑的。
我们把将导出类看作是它的基类的过程称为“向上转型(upcasting)”。“转型(cast)”这个名称的灵感来自于模型铸造的塑模动作,而“向上(up)”这个词来源于继承图的典型布局方式:通常基类在顶部,而导出类在其下部散开。因此,转型为一个基类就是在继承图中向上移动,即“向上转型(upcasting)”。
一个面向对象程序肯定会在某处包含向上转型,因为这正是你如何将自己从必须知道确切类型中解放出来的关键。让我们再看看在doStuff()中的代码:
s.erase();
// ...
s.draw();
注意这些代码并不是说“如果你是Circle,请这样做;如果你是Square,请那些做;……”。如果你编写了那种检查Shape实际上所有可能类型的代码,那么这段代码肯定是杂乱不堪的,而且你需要在每次添加了新类型的Shape之后去修改这段代码。这里你所要表达的意思仅仅是“你是一个Shape,我知道你可以erase()和draw()你自己,那么去做吧,但是要注意细节的正确性。”
doStuff()的代码给人印象深刻之处在于,不知何故,总是做了该做的。调用Circle的draw()方法所执行的代码与调用Square或Line的draw()方法所执行的代码是不同的,但是当draw()消息被发送给一个匿名的(anonymous)的Shape时,也会基于该Shape的实际类型产生正确的行为。这相当神奇,因为就象在前面提到的,当Java编译器在编译doStuff()的代码时,并不能确切知道doStuff()要处理的确切类型。所以通常你会期望它的编译结果是调用基类Shape的erase()和draw()版本,而不是具体的Circle、Square或是Line的版本。正是因为多态才使得事情总是能够被正确处理。编译器和运行系统会处理相关的细节,你需要马上知道的只是事情会发生,更重要的是怎样通过它来设计。当你向一个对象发送消息时,即使涉及向上转型,该对象也知道要执行什么样的正确行为。
抽象基类和接口
通常在一个设计中,你会希望基类仅仅表示其导出类的接口,也就是说,你不希望任何人创建基类的实际对象,而只是希望他们将对象向上转型到基类,所以它的接口将派上用场。这是通过使用abstract关键字把类标识成为抽象类来实现的。如果有人试图创建一个抽象类的对象,编译器都会加以阻止。这是支持某种特殊设计的工具。
你也可以使用abstract关键字来描述尚未被实现的方法,就象一个存根,用来表示“这是一个从此类中继承出的所有类型都具有的接口方法,但是此刻我没有为它设计任何具体实现。”抽象方法只能在抽象类内部创建,当该类被继承时,抽象方法必须被实现,否则继承类仍然是一个抽象类。创建抽象方法使得你可以将一个方法置于接口中而不必被迫为此方法提供可能毫无意义的方法体。
Interface(接口)这个关键字比抽象类的概念更进了一步,它压根不允许有任何方法定义。接口是一个非常方便而通用的工具,因为它提供了接口与实现的完美分离。此外,只要你愿意,你就可以将多个接口组合到一起,与之相对照的,你要继承多个一般类或抽象类却是不可能的。
对象的创建、使用和生命周期
从技术上说,OOP只是涉及抽象数据类型、继承和多态,但是其他议题至少也同样重要,本节将涵盖这些议题。
对象最重要的要素之一便是它们的生成和销毁。对象的数据位于何处?怎样控制对象的生命周期?关于此存在着不同的处理哲学。C++认为效率控制是最重要的议题,所以提供选择给程序员。为了追求最大的执行速度,对象的存储空间和生命周期可以在编写程序时确定,这可以通过将对象置于堆栈(它们有时被称为自动变量(automatic variable)或限域变量(scoped variable))或静态存储区域内来实现。这种方式将存储空间分配和释放置于优先考虑的位置,某些情况下这样控制非常有价值,但是,也牺牲掉了灵活性,因为你必须在编写程序时知道对象确切的数量、生命周期和类型。如果你试图解决更一般化的问题,例如计算机辅助设计、仓库管理或者是空中交通控制,这种方式都显得过于受限了。
第二种方式是在被称为堆(heap)的内存池中动态地创建对象。在这种方式中,你直到运行时刻才知道需要多少对象?它们的生命周期如何?以及它们的具体类型是什么?这些问题的答案只能在程序运行时相关代码被执行到的那一刻才能确定。如果你需要一个新对象,你可以在你需要的时刻直接在堆中创建。因为存储空间是在运行时刻被动态管理的,所以需要大量的时间在堆中分配存储空间,这可能要远远大于在堆栈中创建存储空间的时间。(在堆栈中创建存储空间通常只需要一条将栈顶指针向下移动的汇编指令,另一条汇编指令对应释放存储空间所需的将栈顶指针向上移动。创建堆存储空间的时间以来于存储机制的设计)。动态方式有这样一个逻辑假设:对象趋向于变得复杂,所以查找和释放存储空间的开销不会对对象的创建造成重大冲击。动态方式所带来的更大的灵活性正是解决一般化编程问题的要点所在。
Java完全采用了第二种方式6。每当你想要创建新对象时,就要使用new关键字来构建此对象的动态实例。
还有另一个议题,就是对象生命周期。对于允许在堆栈上创建对象的语言,编译器可以确定对象存活的时间有多久,并可以自动销毁它。然而,如果你在堆上创建对象,编译器就会对它的生命周期一无所知。在像C++这类的语言中,你必须通过编程方式来确定何时销毁对象,这可能会因为你不能正确处理而导致内存泄漏(这在C++程序中是常见的问题)。Java提供了被称为“垃圾回收器(garbage collector)”的机制,它可以自动发现对象何时不再被使用,并继而销毁它。垃圾回收器非常有用,因为它减少了你必须考虑的议题和你必须编写的代码。更重要的是,垃圾回收器提供了更高层的保障,可以避免暗藏的内存泄漏问题(这个问题已经使许多C++项目折戟沉沙)。
集合(collection)与迭代器(iterator)
如果你不知道在解决某个特定问题时需要多少个对象,或者它们将存活多久,那么你就不可能知道如何存储这些对象。你如何才能知道需要多少空间来创建这些对象呢?答案是你不可能知道,因为这类信息只有在运行时刻才能获得。
对于面向对象设计中的大多数问题而言,这个问题的解决方案似乎过于简单:创建另一种对象的类型。解决这个特定问题的新的对象类型持有对其它对象的引用。当然,你可以用在大多数语言中都可获得的数组类型来实现相同的功能。但是这个通常被称为容器(container,也被称为集合(collection),但是Java类库以不同的含义使用这个术语,所以本书将使用容器这个词)的新对象将在任何需要时可扩充自己的容量以容纳你放置于其中的所有东西。因此你不需要知道将来会把多少个对象置于容器中,只需要创建一个容器对象,然后让它处理所有细节。
幸运的是,好的OOP语言都具有作为开发包一部分的一组容器。在C++中,容器是标准C++类库的一部分,有时也称为标准模板类库(Standard Template Library, STL)。Object Pascal在其可视化构件库(Visual Component Library)中具有容器。Smalltalk提供了一个非常完备的容器集。Java在其标准类库中也有容器。在某些类库中,通用容器足够满足所有的需要,但是在其它类库(例如Java)中,具有满足不同需要的不同类型的容器,例如List类(列表,用于存储序列),Map类(散列表,也被称为关联数组,用来建立对象之间的关联),Set类(集类,用于存储一类对象)。容器类库还可能包括Queue(队列)、Tree(树)、Stack(堆栈)等。
所有容器都有某种方式来处理元素的置入和取出。某些通用的方法用来在容器中添加元素,而另一些用来将元素取出。但是取出元素可能问题会更多一些,因为单一选取(single-selection)的方法是很受限的。如果你想操作或是比较容器中的一组元素时,用什么方式来替代单一选取呢?
6 稍候您将看到,原始类型只是一种特例。
解决的方法是迭代器(iterator),它是一个用来选取容器中的元素,并把它呈现给迭代器用户的对象。作为一个类,它也提供了某种抽象层次。这种抽象可以用来把容器的细节从访问容器的代码中分离出来。容器通过迭代器被抽象为仅仅是一个序列(sequence)。迭代器允许你遍历这个序列而不用担心底层的结构,也就是说,不用关心它是一个Arraylist、LinkedList、Stack还是其他什么东西。这给你提供了极大的灵活性,使得你不用干扰你的程序代码就可以十分方便地修改底层数据结构。Java的1.0和1.1版本有一个为所有容器类设计的被称为Enumeration的标准迭代器,Java 2增加了一个完备得多的容器类库,其中包含一个被称为Iterator的比老式的Enumeration能做得更多的迭代器。
从设计的观点来看,你真正需要的只是一个可以被操作,从而解决问题的序列。如果单一类型的序列可以满足你的所有需要,那么就没有理由设计不同种类的序列了。有两个原因使得你还是需要对容器有所选择。第一,不同容器提供了不同类型的接口和外部行。堆栈与队列就具备不同的接口和行为,也不同于集合(set)和列表。其中某种容器提供的问题解决方案可能比其它容器要灵活的多。第二,不同的容器对于某些操作具有不同的效率。最好的例子就是两种List的比较:ArrayList和LinkedList。它们都是具有相同接口和外部行为的简单的序列,但是它们对某些操作所花费的代价却有天壤之别。在ArrayList中随机访问元素是一个花费固定时间的操作,但是,对LinkedList来说,随即选取元素需要在列表中移动,其代价是高昂的,访问越靠近表尾的元素,花费的时间越长。另一方面,如果你想在序列中间插入一个元素,LinkedList的开销却比ArrayList要小。上述以及其它操作的效率,依序列底层结构的不同而存在很大的差异。在设计阶段,你开始可以使用LinkedList,在优化系统性能时,改用ArrayList。基类List和迭代器所带来的抽象把你在容器之间进行转换时对代码产生的影响降到了最低。
单根继承结构
在OOP中有一个议题,自C++面世以来变得非常瞩目,那就是是否所有的类最终都继承自单一的基类。在Java中(事实上还包括除C++以外的所有OOP语言)的答案是yes,这个终极基类的名字为Object。事实证明,单根继承结构带来了很多好处。
在单根继承结构中的所有对象都具有一个共用接口,所以它们归根到底都是相同的基本类型。另一种(C++所提供的)是你无法确保所有对象都属于同一个基本类型。从向后兼容的角度看,这么做能够更好地适应C模型,而且受限较少,但是当你要进行完全的面向对象程序设计时,你必须都要构建自己的继承体系,使得它可以提供其他OOP语言内置的便利。并且在你获得的任何新类库中,总会用到一些不兼容的接口,你需要花费力气(并有可能要通过多重继承)来使得新接口融入你的设计之中。这么做以换取C++额外的灵活性是否值得呢?如果你需要的话——你在C上面投资巨大——那这么做就很有价值。如果你刚刚从头开始,那么像Java这样的选择通常会更高效高产。
单根继承结构(例如Java所提供的)保证所有对象都具备某些功能。因此你了解在你的系统中你可以在每个对象上都可以执行的某些基本操作。单根继承结构以及在堆上创建所有对象,极大地简化了参数传递(这在C++中是十分复杂的话题之一)。
单根继承结构使垃圾回收器(内置于Java中)的实现变得容易得多。其必需的支持功能可置于基类中,这样,垃圾回收器就可以发送恰当的消息给系统中的每一个对象。如果没有单
根继承结构以及通过引用来操作对象的系统特性,要实现垃圾回收器非常困难。
由于所有对象都保证具有运行时刻类型信息(run time type information),因此你不会因无法确定对象的类型而陷入僵局。这对异常处理这样的系统级操作显得尤其重要,并且给编程带来了更大的灵活性。
向下转型(downcasting)与模板/泛型(template/generic)
为了复用上述容器,我们让它们都可以存储Java中的一个通用类型:Object。单根继承结构意味着所有东西都是对象,所以可以存储Object的容器可以存储任何东西7。这使得容器很容易被复用。
要使用这样的容器,你只需在其中置入对象引用(object reference),稍后还可以将它们取回。但是由于容器只存储Object,所以当你将对象引用置入容器时,它必须被向上转型为Object,因此它会丢失其身份。当你把它取回时,你获取了一个对Object对象的引用,而不是对你置入时的那个类型对象的引用。所以,你怎样才能将它变回先前你置入容器中的具有实用接口的对象呢?
这里再度用到了转型,但这一次不是向继承结构的上层转型为一个更泛化的类型,而是向下转型为更具体的类型。这种转型的方式称为向下转型(downcasting)。你可以知道向上转型是安全的,例如Circle是一种Shape类型,但是你无法知道某个Object是Circle还是Shape,所以除非你确切知道你要处理的对象的类型,否则向下转型几乎是不安全的。
然而向下转型并非彻底是危险的,因为如果你向下转型为错误的类型,你会得到被称为异常(exception)的运行时刻错误,稍后我会介绍什么是异常。尽管如此,当你从容器中取出对象引用时,还是必须要由某种方式来记住这些它们究竟是什么类型,这样你才能执行正确的向下转型。
向下转型和运行时刻的检查需要额外的程序运行时间和程序员心血。那么创建知道自己所保存对象的类型的容器,从而消除向下转型的需求和犯错误的可能不是更有意义吗?其解决方案被称为参数化类型(parameterized type)机制。参数化类型就是编译器可以自动定制作用于特定类型之上的类。例如,通过使用参数化类型,编译器可以定制一个只接纳和取出Shape对象的容器。
参数化类型是C++的重要组成部分,部分原因是C++压根没有单根继承结构。在C++中,实现参数化类型的关键字是“template(模板)”。Java目前并没有参数化类型,因为通过使用单根继承结构可以达到相同的目的,尽管其机制显得比较笨拙。但是,目前已经有了一份采用与C++ 模板极其相似的语法来实现参数化类型的提案,我们期待在下一个Java版本中看到参数化类型。
7 很遗憾,原始类型排除在外。本书稍后会详细讨论这一点。
确保正确清除
每个对象为了生存都需要资源,尤其是内存。当我们不再一个对象时,它必须被清除掉使其占有的资源可以被释放和重用。在相对简单的编程情况下,怎样清除对象看起来似乎不是什么挑战:你创建了对象,根据需要使用它,然后它应该被销毁。然而,你很可能会遇到相对复杂的情况。
例如,假设你正在为某个机场设计空中交通管理系统(同样的模型在仓库货柜管理系统、录像带出租系统或是宠物寄宿店也适用)。一开始问题似乎很简单:创建一个容器来保存所有的飞机,然后为每一架进入控制交通控制区域的飞机创建一个新的飞机对象,并将其置于容器中。对于清除工作,只需在飞机离开此区域时删除相关的飞机对象即可。
但是,可能还另有某个系统记录着有关飞机的数据,也许这些数据不需要像主要的控制功能那样立刻受到人们的注意。例如,它可能记录着所有飞离飞机场的小型飞机的飞行计划。因此你需要有第二个容器用来存放小型飞机,无论何时,只要创建的是小型飞机对象,那么它同时也应该置入第二个容器内。然后某个后台进程在空闲时间对第二个容器内的对象执行操作。
现在问题变得更困难了:你怎样才能知道何时销毁这些对象呢?当你处理完某个对象之后,系统其他的某部分可能正在处理它。在其他许多场合中也会遇到同样的问题,在必须明确删除对象的编程系统中(例如C++),此问题会变得十分复杂。
Java的垃圾回收器被设计用来处理内存释放问题(尽管它不包括清除对象的其他方面)。垃圾回收器“知道”对象何时不再被使用,并自动释放该对象的内存。这与所有对象都是继承自单根基类Object,以及你只能以一种方式创建对象——在堆上创建这两个特性一起,使得用Java编程的过程较之用C++编程要简单得多,你要做的决策和要克服的障碍都要少得多。
垃圾回收与效率和灵活性
如果这么做完美无瑕,那为什么C++没有采用呢?因为你必须要为编程的方便付出代价,这个代价就是运行时刻的开销。就像前面提到的,在C++中,你在堆栈中创建对象,在这种情况下,它们可以自动被清除(但是你无法得到在运行时刻你想要得到的灵活性)。在堆栈上创建对象是为对象分配和释放存储空间最有效的途径。在堆上创建对象可能代价就要高昂得多。总是从某个基类继承以及所有的方法调用都是多态的也需要较小的开销。但是垃圾回收器是一个特殊的问题,因为你从来都不确切了解它将于何时启动并将持续多久。这意味着一个Java程序的执行速度会有前后不一致的情况,因此你在某些场合不能使用它,例如强调程序的执行速度要一致的场合。(此类程序通常被称为实时程序,尽管不是所有的实时编程问题都是如此严苛。)
C++语言的设计者努力争取C程序员的支持,(他们几乎已经成功,但是)却不想添加任何影响速度的功能,也不想添加任何能够使程序员在选择使用C的场合转而选择C++的新功能。这个目标是实现了,但是付出的代价是在用C++编程时复杂性极高。
异常处理:处理错误
自从编程语言问世以来,错误处理就始终是最困难的问题之一。因为设计一个良好的错误处理机制非常困难,所以许多语言直接略去这个问题,将其交给程序库设计者处理,而这些设计者也只是提出了一些不彻底的方法,这些方法可用于许多很容易就可以绕过此问题的场合,而且其解决方式通常也只是忽略此问题。大多数错误处理机制的主要问题在于,它们都依赖于程序员自身的警惕性,这种警惕性是靠遵循人们已经达成一致的惯例而保持的,而这种惯例并不是编程语言所强制的。如果程序员不够警惕——通常是因为他们太忙——这些机制就很容易被忽视。
异常处理将错误处理直接置于编程语言中,有时甚至置于操作系统中。异常是一种对象,它从出错地点被“抛出(thrown)”,并被适当的专门被设计用来处理特定类型异常的异常处理器“捕获(caught)”。异常处理就像是与程序正常执行路径并行的,在错误发生时执行的另一条路径。因为它是另一条完全分离的执行路径,所以它不会干扰正常的执行代码。这使得代码编写变得简单了,因为你不需要被迫定期检查错误。此外,被抛出的异常不像方法返回的错误值和方法设置的用来表示错误条件的标志位那样可以被忽略。异常不能被忽略,所以它保证一定会在某处被处理。最后需要指出的是:异常提供了一种从错误状况进行可靠恢复的途径。现在不再是只能推出程序,你可以经常进行校正,并恢复程序的执行,这些都有助你编写出健壮性好得多的程序。
Java的异常处理在众多的编程语言中格外引人注目,因为Java一开始就内置了异常处理,而且强制你必须使用它。如果你没有编写正确的处理异常的代码,那么你就会得到一条编译时刻的出错消息。这种得到确保的一致性有时会使得错误处理非常容易。
值得注意的是,异常处理不是面向对象的特征,尽管在面向对象语言中异常通常被表示成为一个对象。异常处理在面向对象语言出现之前就已经存在了。
并发(concurrency)
在计算机编程中有一个基本概念,就是在同一时刻处理多个任务(task)的思想。许多程序设计问题都需要程序能够停下正在做的工作,转而处理某个其它问题,然后再返回主进程(main process)。有许多方法可以实现这个目的。最初,程序员们用所掌握的有关机器底层的知识来编写中断服务程序(interrupt service routine),主进程的挂起(supension)是通过硬件终端来触发的。尽管这么做可以解决问题,但是其难度太大,而且不能够移植,所以使得将程序移植到新型号的机器上时,既费时又费力。
有时中断对于处理时间临界(time-critical)的任务是必需的,但是对于大量的其它问题,我们只是想把问题切分成多个可独立运行的部分,从而提高程序的响应能力。在程序中,这些彼此独立运行的部分称之为线程(thread),上述概念被称为“并发(concurrency)”或“多线程(multithreading)”。多线程最常见的例子就是用户界面。通过使用线程,用户可以在揿
下按钮后快速得到一个响应,而不用强制等待直到程序完成当前任务为止。
通常,线程只是一种为单一处理器分配执行时间的手段。但是如果操作系统支持多处理器,那么每个线程都可以被指派给不同的处理器,并且它们是在真正地并行执行。在语言级别上多线程所带来的便利之一便是程序员不用再操心机器上有多个处理器还是只有一个处理器。由于程序被逻辑化分为线程,所以如果机器拥有多个处理器,那么程序将在不需要特殊调整的情况下执行得更快。
所有这些都使得线程看起来相当简单,但是有一个隐患:共享资源。如果有超过一个的并行线程都要访问同一项资源,那么就会出问题。例如,两个进程不能同时向一台打印机发送信息。为了解决这个问题,可以共享的资源,例如打印机,必须在被使用期间锁定。因此,整个过程是;某个线程锁定某项资源,完成其任务,然后释放资源锁,使其它线程可以使用这项资源。
Java的线程机制是内置于其中的,它使此复杂课题变得简单得多了。线程机制被对象层次所支持,因此线程的执行可以用对象来表示。Java同时也提供了限制性资源锁定功能,它可以锁定任何对象所占用的内存(毕竟这也算是某种共享资源),使得同一时刻只能有一个线程在使用它。这是通过synchronized关键字来实现的。其它类型的资源必须由程序员显式地锁定,通常是通过创建一个表示锁的对象,所有线程在访问资源之前先检查这个对象。
持久性
当你创建了一个对象之后,只要你需要它,它就一直存活着,但是在程序终止后,它无论如何都不能存活了。在某些场合,如果对象在程序非执行状态下仍然能够存活,并保存其相关信息,将非常有用。当你下一次重新启动程序时,这个对象能够重生,并且拥有与上一次程序执行时相同的信息。当然,你可以通过将信息写入文件或数据库中达到相同的效果,但是在“万物皆为对象”的精神下,能够将对象声明为持久的(persistent),并让语言系统为你处理所有细节,不是非常方便吗?
Java提供对“轻量级持久性(lightweight persistent)”的支持,这意味着你可以很容易地将对象存储在磁盘上,并在以后取回它们。称之为“轻量级”是因为你仍然得创建显式的调用来执行存储和取回操作。轻量级持久性可以通过对象序列化(object serialization,第12章介绍)或Java数据对象(JDO,Java Data Object,在《企业Java编程思想(Thinking in Enterprise Java)》一书中有介绍)来实现。
Java与Internet
如果Java仅仅只是众多的程序设计语言中的一种,你可能就会问:为什么它如此重要?为什么它促使计算机编程语言向前迈进了革命性的一步?如果从传统的程序设计观点看,问题的答案似乎不太明显。尽管Java对于解决传统的单机程序设计问题非常有用,但同样重要的是,它能够解决在万维网(world wide web)上的程序设计问题。
Web是什么?
Web一词乍一看有点神秘,就象“网上冲浪(surfing)”、“表现(presence)”、“主页(home page)”一样。我们回头审视它的真实面貌有助于对它的理解,但是要这么做就必须先理解客户/服务器(client/server)系统,它使计算技术中另一个充满了诸多疑惑的话题。
客户/服务器计算技术
客户/服务器系统的核心思想是:系统具有一个中央信息存储池(central repository of information),用来存储某种数据,它通常存在于数据库中,你可以根据需要将它分发给某个人员或机器集群。客户/服务器概念的关键在于信息存储池的位置集中于中央,这使得它可以被修改,并且这些修改将被传播给信息消费者。总之,信息存储池是用于分发信息的软件,信息与软件的宿主机器(或机器的集群)被称为服务器(server)。宿主于远程机器上的软件与服务器进行通信,以获取信息、处理信息,然后将它们显示在被称为客户(client)的远程机器上。
客户机/服务器计算技术的基本概念并不复杂。问题在于你只有单一的服务器,却要同时为多个客户服务。通常,这都会涉及数据库管理系统,因此设计者“权衡”数据置于数据表(table)中的结构,以取得最优的使用效果。此外,系统通常允许客户在服务器中插入新的信息。这意味着你必须保证一个客户插入的新数据不会覆盖另一个客户插入的新数据,也不会在将其添加到数据库的过程中丢失(这被称为事务处理(transaction processing))。如果客户端软件发生变化,那么它必须被重新编译、调试并安装到客户端机器上,事实证明这比你想象中的要更加复杂与费力。如果想支持多种不同类型的计算机和操作系统,问题将更麻烦。最后还有一个最重要的性能问题:可能在任意时刻都有成百上千的客户向服务器发出请求,那么随便多么小的延迟都会产生重大影响。为了将延迟最小化,程序员努力地减轻处理任务的负载,通常是分散给客户端机器处理,但有时也会使用所谓“中间件(middleware)”将负载分散给在服务器端的其它机器。(中间件也被用来提高可维护性(maintainability))
分发信息这个简单思想的复杂性实际上是有很多不同层次的,这使得整个问题可能看起来高深莫测得让人绝望。但是它仍然至关重要:算起来客户/服务器计算技术大概占了所有程序设计行为的一半,从制定订单、信用卡交易到包括股票市场、科学计算、政府、个人在内的任意类型的数据分发。过去我们所作的,都是针对某个问题发明一个单独的解决方案,所以每一次都要发明一个新的方案。这些方案难以开发并难以使用,而且用户对每一个方案都要学习新的接口。因此,整个客户/服务器问题需要彻底地解决。
Web就是一台巨型服务器
Web实际上就是一个巨型客户/服务器系统,但是比其稍微差一点,因为所有的服务器和客
户机都同时共存于同一个网络中。你不需要了解这些,因为你所要关心的只是在某一时刻怎样连接到一台服务器上,并与之进行交互(即便你可能要满世界地查找你想要的服务器)。
最初只有一种很简单的单向过程(one-way process):你对某个服务器产生一个请求,然后它返回给你一个文件,你的机器(也就是客户机)上的浏览器软件根据本地机器的格式来解读这个文件。但是很快人们就希望能够做得更多,而不仅仅是从服务器传递会页面。他们希望实现完整的客户/服务器能力,使得客户可以将信息反馈给服务器。例如,在服务器上进行数据库查找、将新信息添加到服务器以及下订单(这需要比原始系统提供的安全性更高的安全保障)。这些变革,正是我们在Web发展过程中一直目睹的。
Web浏览器是向前跨进的一大步,它包含了这样的概念:一段信息不经修改就可以在任意型号的计算机上显示。然而,浏览器仍然相当原始,很快就因为加诸于其上的种种需要而陷入困境。浏览器并不具备显著的交互性,而且它趋向于使服务器和Internet阻塞,因为在任何时候,只要你需要完成通过编程来实现的任务,就必须将信息发回到服务器去处理。这使得即便是发现你的请求中的拼写错误也要花去数秒甚至是数分钟的时间。因为浏览器只是一个视图工具,因此它甚至不能执行最简单的计算任务。(另一方面,它却是安全的,因为它在你的本地机器上不会执行任何程序,而这些程序有可能包含bug和病毒。)
为了解决这个问题,人们采用了各种不同的方法。首先,图形标准得到了增强,使得在浏览器中可以播放质量更好的动画和视频。剩下的问题通过引入在客户端浏览器中运行程序的能力就可以解决。这被称为“客户端编程(client-side programming)”。
客户端编程
Web最初的“服务器-浏览器”设计是为了能够提供交互性的内容,但是其交互性完全由服务器提供。服务器产生静态页面,提供给只能解释并显示它们的客户端浏览器。基本的HTML(HyperText Markup Language,超文本标记语言)包含有简单的数据收集机制:文本输入框(text-entry box)、复选框(check box)、单选框(radio box)、列表(list)和下拉式列表(drop-down list)等,以及只能被编程用来实现复位(reset)表单上的数据或提交(submit)表单上的数据给服务器的按钮。这种提交动作传递给所有的Web服务器都提供的通用网关接口(common gateway interface,CGI)。提交内容会告诉CGI应该如何处理它。最常见的动作就是运行一个在服务器中通常被命名为“cgi-bin”的目录下的一个程序。(当你点击了网页上的按钮时,如果你观察你的浏览器窗口顶部的地址,有时你可以看见“cgi-bin”的字样混迹在一串冗长不知所云的字符中。)几乎所有的语言都可以用来编写这些程序,Perl已经成为了最常见的选择,因为它被设计用来处理文本,并且解释型语言,因此无论服务器的处理器和操作系统如何,它都可以被安装于其上。然而,Python(我的最爱,请查看www.Python.org)以对其产生了重大的冲击,因为它更强大且更简单。
当今许多有影响力的网站都是完全构建于CGI之上的,实际上你几乎可以通过CGI做任何事。然而,构建于CGI程序之上的网站可能会迅速变得过于复杂而难以维护,并同时产生响应时间过长的问题。CGI程序的响应时间依赖于必须发送的数据量的大小,以及服务器和Internet的负载,此外,CGI程序的初始化也相当慢。Web的最初设计者们并没有预见到网络带宽(bandwidth)被人们开发的各种应用迅速耗尽。例如,任何形式的动态图形处理几
乎都不可能被连贯地执行,因为图形交互格式(graphic interchange format,GIF)的文件对每一种版本的图形都必须在服务器端创建,并发送给客户端。再比如,你肯定处理过像验证输入表单那样简单的事情:你按下网页上的提交(Submit)按钮;数据被封装发送回服务器;服务器启动一个CGI程序来检查发现错误,并将错误组装为一个用来通知你的HTML页面,然后将这个页面发回给你;你之后必须回退一个页面,然后重新再试。这个过程不仅很慢,而且不太优雅。
问题的解决方法就是客户端编程(client-side programming)。大多数运行web浏览器的机器都是能够执行大型任务的强有力的引擎。在使用原始的静态HTML方式的情况下,它们只是空闲地愣在那里,等着服务器送来下一个页面。客户端编程意味着Web浏览器被用来执行任何它可以完成的工作,使得返回给用户的结果更加迅捷,而且使得你的网站更加具有交互性。
客户端编程的问题是:它与通常意义上的编程十分不同,参数几乎相同,而平台却不同。Web浏览器就象一个功能受限的操作系统。当然,你仍然的编写程序,而且还得处理那些令人头晕眼花的成堆的问题,并以客户端编程的方式来产生解决方案。本节剩下的部分将带你纵览有关客户端编程的话题和方式。
插件(Plug-in)
客户端编程所迈出的最重要的一步就是插件(plug-in)的开发。通过这种方式,程序员可以下载一段代码,并将其插入到浏览器中适当的位置,以此来为浏览器添加新功能。它告诉浏览器:从现在开始,你可以执行这个新行为了(你只需要下载一次插件即可)。某些更快更强大的行为都是通过插件添加到服务器中的,但是编写插件并不是件轻松的事,也不像你希望的那样成为构建某特定网站的过程中而作的事情。插件对于客户端编程的价值在于:它允许专家级的程序员不需经过浏览器生产厂商的许可,就可以开发某种新语言,并将其添加到服务器中。因此,插件提供了一个“后门(back door)”,使得可以创建新的客户端编程语言(但是并不是所有的客户端编程语言都是以插件的形式实现的)。
脚本语言(scripting language)
插件引发了脚本语言(scripting language)的大爆炸。通过使用某种脚本语言,你可以将客户端程序的源代码直接嵌入到HTML页面中,解释这种语言的插件在HTML页面被显示时自动激活。脚本语言先天就相当易于理解,因为它们只是作为HTML页面一部分的简单文本,当服务器收到要获取该个页面的请求时,它们可以被快速加载。此方法的缺点是你的代码会被暴露给任何人去浏览(或窃取)。但是,通常你不会使用脚本语言去做相当复杂的事情,所以这个缺点并不太严重。
这也点出了在Web浏览器内部使用的脚本语言实际上总是被用来解决特定类型的问题,主要是用来创建更丰富、更具有交互性的图形化用户界面(GUI,graphic user interface)。但是,脚本语言确实可以解决客户端编程中所遇到的百分之八十的问题。你的问题可能正好落在这
百分之八十的范围之内,由于脚本语言提供了更容易、更快捷的开发方式,因此你应该在考虑诸如Java或ActiveX之类的更复杂的解决方案之前,先考虑脚本语言。
最常被讨论的浏览器脚本语言包括:JavaScript(它与Java并没有任何关系,它之所以这样被命名只是因为想赶上Java的市场浪潮),VBScript(它看起来很像Visual BASIC),和Tcl/Tk(流行的GUI构建语言)。还有一些脚本语言没有列在这里,当然还有更多的脚本语言还处于开发阶段。
JavaScript可能是最被广泛支持的一种脚本语言。网景(Netscape)的Navigator和微软(Microsoft)的Internet Explorer(IE)都提供对它的内置支持。遗憾的是,JavaScript在这两种浏览器中的风格有很大的不同(可以从www.Mozilla.com上自由下载的Mozilla 浏览器支持有可能在某天被普遍支持的ECMAScript)。此外,从市场上可以得到的有关JavaScript的书可能比有关其它浏览器语言的书要更多,而且某些工具能够用JavaScript自动地生成页面。但是,如果你已经熟练掌握了Visual BASIC 或Tcl/Tk,那么使用这些脚本语言比学习全新一种脚本语言要更有生产效率。因为你已经可以投入全部精力去解决有关Web的相关问题。
Java
如果脚本语言可以解决客户端编程百分之八十的问题的话,那么剩下那百分之二十(那才是真正难啃的硬骨头)又该怎么办呢?Java是处理它们最流行的解决方案。Java不仅是一种功能强大的、被构建为安全的、跨平台的、国际化的编程语言,而且它还在不断地被扩展,以提供更多的语言功能,以及更多的能够优雅地处理在传统编程语言中很难解决的问题的类库,例如多线程(multithreading)、数据库访问(database access)、网络编程(network programming)和分布式计算(distributed computing)。Java是通过applet以及使用Java Web Start来进行客户端编程的。
Applet是只在Web浏览器中运行的小程序。Applet是作为网页的一部分被自动下载的(就象网页中的图片被自动下载一样)。当applet被激活时,它便开始执行程序。这正是它优雅之处:它提供了一种一旦用户需要客户端软件时,就可以自动地从服务器分发客户端软件给用户的方法。当用户获取了最新版本的客户端软件时,并不会有错误产生,而且也不需要很麻烦的重新安装过程。因为Java的这种设计方式,使得程序员只需创建单一的程序,而只要一台计算机有浏览器,且浏览器具有内置的Java解释器(大多数的机器都如此),那么这个程序就可以在这台计算机上运行。由于Java是一种成熟的编程语言,所以在创建了到服务器的请求之前和之后,你可以在客户端尽可能多地做些事情。例如,你不必跨网络地发送一张请求表单去检查你是否填写了错误的日期或其它参数,你的客户端计算机就可以快速地标出错误数据,而不用等待服务器作出标记并传回一张图片给你。你不仅立即就获得了高速度和快速的响应能力,而且也降低了网络流量和服务器负载,从而不会使整个Internet的速度都慢了下来。
Java applet胜过脚本语言程序的优势之一就是它是以被编译过的形式存在的,因此其源代码对客户端来说是不可见的。另一方面,虽然反编译Java applet并不需要花费多少力气,但是隐藏你的代码通常并不是一个重要的话题。有另外两个因素是很重要。就象你在本书稍后的
部分会看到的那样,如果编译过的applet很大的话,那么就需要额外的时间去下载它。脚本语言程序只是被作为Web页面的一部分文本而集成到了Web页面中(通常比较小,并减少了对服务器的访问)。这对Web网站的响应能力来说很重要。另一个因素是非常重要的“学习曲线(learning curve)”。如果你是一个Visual BASIC程序员,那么转而学习VBScript可能是最快的解决方案(假设你可以限制你的客户只能用windows平台),而且由于它或许能够解决大多数典型的客户/服务器问题,所以你可能很那对学习Java报以公正的态度。如果你已经对脚本语言很有经验了,那么你在考虑付诸于Java之前,应该先看看JavaScript或VBScript是否满足你的要求,这样做对你会很有好处的,因为它们也许能够更方便地满足你的要求,而且使你更具生产力。
.NET和C#
曾几何时,Java applet的主要竞争对手是微软的ActiveX,尽管它要求客户端必须运行Windows平台。从那以后,微软以.NET平台和C#编程语言的形式推出了与Java全面竞争的对手。.NET平台大致相当于Java虚拟机(virtual machine)和Java类库(library),而C#毫无疑问具有与Java类似之处。这当然是微软在编程语言与编程环境这块竞技场上所做出的最出色的成果。当然,他们有相当大的有利条件可以利用:他们可以看得到Java在什么方面做得好,在什么方面做得还不够好,然后基于此去构建,并要具备Java不具备的优点。这是自从Java出现以来,它所碰到的真正的竞争,如果事情真如微软所想,那么其结果只能是Sun的Java设计者们认真仔细地去研究C#,去发现为什么程序员们可能会转而使用它,然后通过对Java做出根本的改进而对微软做出回应。
目前,.NET主要受攻击的地方和人们所关心的最重要的问题就是微软是否会允许将它完全地移植到其它平台上。他们宣称这么做没有问题,而且Mono项目(www.go-mono.com)已经有了一个在Linux上运行的.NET的部分实现,但是,只要此实现完成,并且微软不会排斥其中的任何部分之日还为来临,.NET作为一种跨平台的解决方案都仍旧是异常高风险的赌博。
要想学习更多的有关.NET和C#的知识,请阅读Larry O’Brien和Bruce Eckel撰写的《C#编程思想(Think in C#)》一书(Prentice Hall,2003)。
安全性(Security)
通过Internet自动下载并运行程序听起来就像是病毒制作者的梦想。当你在某个网站上点击之后,可能会随HTML页面自动下载任意数量的东西:GIF文件、脚本代码、编译过得Java代码和ActiveX控件。这些东西有些是良性的,例如GIF就是无害的,脚本语言能作的事情也很有限。在Java的设计中,applet也只能运行在受安全保护的“沙盒(sandbox)”中,沙盒使applet无法写磁盘或是访问沙盒之外的内存。
微软的ActiveX正好位于走了另一个极端。使用ActiveX编程就像对windows编程——你可以随心所欲做任何事。因此如果你点击某页面下载了一个ActiveX控件,那么此控件就可能
会破坏你的磁盘上的文件系统。当然,你在计算机上加载的程序如果不被限制运行在Web浏览器内部,就也有可能造成这样的破坏。下载自BBS的病毒长久以来一直是一个严重的问题,Internet迅猛的发展速度更是加剧了这个问题。
“数字签名(digital signature)”看起来像是一种解决方案,凭此签名可以检验代码以确定代码的作者。这种做法基于这样的思想:病毒能够发作是因为它的创建者可以匿名,因此如果消除匿名行为,那么就可以强制每个人都要为自己的行为负责。这看起来是一个不错的计划,因为它使得程序更加实用,但是我怀疑它是否真的能根除恶意的祸害。而且,如果程序中存在非故意的破坏性Bug,那么它仍然会引发问题。
Java的方法是通过沙盒来防止这类问题的发生。存在于本地浏览器中的Java解释器在加载applet的时候检查其是否含有不恰当的指令,特别是,applet不能往磁盘上写文件或从磁盘上删除文件(这正是病毒赖以生存与发作的基础)。Applet通常被认为是安全的,而这正是可靠的客户/服务器系统的要点所在,所以在Java语言中的任何可能会滋生病毒bug都会被快速修复。(值得注意的是,浏览器软件事实上都会强制执行这些安全限制,某些浏览器甚至允许你选择不同的安全级别,以提供不同级别的系统访问能力。)
你可能会质疑,不能向本地磁盘写文件的限制是否过于严苛。例如,你可能想构建一个本地数据库或存储数据以备在稍后下线之后仍能使用。尽管人们最初的梦想是最终要让所有人都可以在线进行所有重要的工作,但是很快这个梦想就被证明是不切实际的(尽管有朝一日,低成本的网络设备可以满足绝大多数用户的需要)。“签名applet”是问题的解决方案之一,它们使用公钥加密(public-key encryption)来验证applet是否确实来自于它所宣称的来源。虽然签名applet仍然可能会毁掉你的磁盘,但是既然你现在已经让applet的创建者负责任,那么他们就不会有恶意的行为。Java提供了一个数字签名(digital signature)的框架,因此,如果需要的话,你最终是可以让applet步出沙盒之外的。第14章包含了一个怎样对applet签名的例子。
此外,Java Web Start也是一种相对比较新的,用来便捷地部署不需要在Web浏览器中就可以独立运行的程序的方法。这项技术在解决许多与“在浏览器内部运行程序”相关的客户端问题方面很有潜力。Web Start程序可以是签过名的,或者是在每一次执行有可能危及本地系统的操作时,向客户端索要相应的权限。第14章有一个简单的例子以及有关Java Web Start的解释。
数字签名遗漏了一个重要的问题,那就是人们在Internet上游荡的速度。如果你下载了一个有问题的程序,并且这个程序执行了某些不恰当的动作,那么需要多久你才能发现它造成的损害呢?也许是数天,甚至是数周。到那时,你怎样才能追踪到是这个造成损害的程序呢?你又怎样才能知道在那时它干了哪些好事呢?
Internet与Intranet
Web是最常用的解决客户/服务器问题的方案,因此,即便是解决这个问题的一个子集,特别是在一个公司内部的典型的客户/服务器问题,也一样可以使用这项技术。如果采用传统
的客户/服务器方式,你可能会遇到客户端计算机有多种型号的问题,也可能会遇到安装新的客户端软件的麻烦,而它们都可以很方便地通过Web浏览器和客户端编程得以解决。当Web技术仅限用于特定公司的信息网络时,它就被称为Intranet(企业内部网)。Intranet比Internet提供了更高的安全性,因为你可以从物理上控制对公司内部服务器的访问。从培训的角度看,似乎一旦人们理解了浏览器的基本概念后,对他们来说,处理网页和applet的外观差异就会容易得多,因此对新型系统的学习曲线也就减缓了。
安全问题把我们带到了一个客户端编程世界自动形成的领域。如果你的程序运行在Internet之上,那么你就不可能知道它将运行在什么样的平台之上,因此,你要格外地小心,不要传播由Bug的代码。你需要跨平台的、安全的语言,就像脚本语言和Java。
如果你的程序运行与Intranet上,那么你可能会受到不同的限制。企业内所有的机器都采用Intel/Windows平台并不是什么稀奇的事。在Intranet上,你可以对你自己的代码质量负责,并且在发现Bug之后可以修复它们,此外,你可能已经有了以前使用更传统的客户/服务器方式编写的遗产代码,因此,你必须在每一次作升级时都要在物理上重装客户端程序。在安装升级程序时所浪费的时间是迁移到浏览器方式上的最主要的原因,因为在浏览器方式下,升级是透明的、自动的(Java Web Start也是解决此问题的方式之一)。如果你身处这样的Intranet之中,那么最有意义的方式就是选择一条能够使用现有代码库最短的捷径,而不是用一种新语言重新编写你的代码。
当你面对各种令人眼花缭乱的解决客户端编程问题的方案时,最好的方法就是进行性价比分析。认真考虑你的问题的各种限制,然后思考那种解决方案可以成为最短的捷径。既然客户端编程仍然需要编程,那么针对你的特殊应用选取最快的开发方式,总是最好的做法。为那些在程序开发中不可避免的问题提早作准备是一种积极的态度。
服务器端编程
前面的讨论没有涉及服务器端编程的话题。当你产生了到服务器的请求后,会发生什么呢?大部分时候,请求只是要求“给我发送一个文件”,之后浏览器会以某种适当的形式解释这个文件,例如将其作为HTML页面、图片、Java applet或脚本程序等来解释。更复杂的到服务器的请求通常涉及数据库事务。常见的情形是复杂的数据库查询请求,然后服务器将结果组装成为一个HTML页面发回给客户端。(当然,如果客户端通过Java或脚本程序具备了更多的智能,那么服务器可以将原始的数据发回,然后在客户端组装,这样会更快,而且服务器的负载将更小。)另一种常见情形是,当你要加入一个团体或下订单时,可能想在数据库中注册你的名字,这将涉及对数据库的修改。这些数据库请求必须通过服务器端的某些代码来处理,这就是所谓的服务器端编程。过去,服务器端编程都是通过使用Perl、Python、C++或其它某种语言来编写CGI程序而实现的,这使得更加复杂的系统出现了。其中包括基于Java的Web服务器,它让你用Java编写被称为Servlet的程序来实现服务器端编程。Servlet及其衍生物JSP,是许多开发网站的公司迁移到Java上的两个主要的原因,尤其是因为它们消除了处理具有不同能力的浏览器时所遇到的问题(这些话题在《企业Java编程思想(Thinking in Enterprise Java)》一书中有论述)。
应用
Java引起人们的注意很大程度上始于applet。Java 确实是一种通用的编程语言,至少在理论上可以解决各种用其它语言能够解决的问题。正像前面指出的那样,可能存在其它更有效的方式去解决客户/服务器问题。当你离开applet的竞技场时(同时也从其限制中解脱了,例如不能写磁盘的限制),你便步入了通用应用系统的世界,这里的系统都是独立运行的,不需要浏览器,就像其它普通程序一样。在这里,Java的威力不仅在于它的可移植性(portability),还包括它的可编程性(programmability)。就在你阅读此书的同时,Java已经具备了许多功能,让你创建健壮的程序,而花费的时间比使用Java之前的任何编程语言都更少。
不过你要意识到这是好坏掺半的事。你为Java带来的改进所付出的代价是降低了执行速度(尽管在此领域人们正在努力,例如,被称为“hotspot”的性能改善技术已经添加到新版本的Java中了)。像任何语言一样,Java也具有使其不适合解决某类问题的天生的限制。然而,Java是快速进化的语言,而且每当发布一个新版本时,它都会因为能够解决更多的问题而变得越来越具有吸引力。
Java为什么成功
Java能够取得如此的成功是因为它在设计时的目标就定位在要解决当今程序员们所面临的众多问题。Java的基本目标之一就是要提高生产率。生产率来源于许多方面,但是Java希望在语言方面相对于它前辈有明显的提高,从而为程序员提供更大的便利。
系统易于表达、易于理解
被设计用来适应问题的“类”,在表达问题的能力上显得更强。这意味着当你编写代码时,你是在用问题空间的术语(“将垫圈放进盒子中”)而不是计算机,也就是解空间的术语(“设置芯片上的一位,表示继电器将被关闭”)来描述解决方案。你可以用更高层的概念来处理问题,并且只用一行代码就可以做更多的事。
易于表达所带来的另一个好处就是易于维护,维护(如果报告可信的话)在程序生命周期中所占的成本比例极大。如果程序易于理解,那么它必定易于维护。同时,这也降低了创建和维护文档的费用。
通过类库得到最大的支持
创建程序最快捷的方式就是使用已经编写好的代码:类库。Java的主要目标之一就是要使类库更易于使用。这是通过将类库转型为新的数据类型(类)来实现的,因此,引入类库意味
着在语言中添加了新的数据类型。因为Java编译器会留意类库的使用方式——确保正确的初始化和垃圾回收,并保证其方法被正确调用,因此,你只需专注于你想让类库做些什么,而不必关心应如何去做。
错误处理
在C语言中,错误处理一直是一个声名狼籍的问题,而且经常被忽视——经常只能靠上帝保佑了。如果你在构建一个大型的、复杂的程序,那么没有什么比在程序某处暗藏了一个错误,而你却没有任何能够暗示它在何处的线索更糟糕的事情了。Java异常处理(exception handling)便是一种能够确保错误必须报告,而且必须有所动作作为其响应的机制。
大型程序设计
许多传统语言在程序大小和复杂度方面都有内置的限制。例如,BASIC可能对某类问题地解决能力非常强,可以快速地创建解决方案,但是如果程序长度超过数页,或者超出该语言正常的题域之外,它就会像“在非常粘稠的液体中游泳”。没有明确的界线来表示何时你所使用的语言会导致最终的失败,即使有这样的界线,你也会忽视它。你总不能说:“我的BASIC程序太大了,我不得不用C来重写它!”相反,你会试着将几行代码硬塞进去,以便在程序中增加新功能。因此,不知不觉中,你就付出了额外的开销。
Java具备编写大型程序的能力——也就是说,它消除了小型程序和大型程序之间的复杂度界线。在编写“hello, world”风格的小应用程序时,你当然不必使用OOP,但是当你需要用到时,这些功能随手可得。而且,对小型程序和大型程序,编译器都会一视同仁地、积极地找出因Bug而产生的错误。
Java与C++
Java看起来很像C++,因此,很自然地,看起来C++将会被Java取代。但是我开始怀疑这种逻辑了。C++仍然有某些功能是Java不具备的,尽管关于Java终有一日会与C++一样快,甚至更快的承诺层出不穷,我们也看到了Java在稳步地提高,但是至今并没有什么令人瞩目的突破。而且,人们对C++仍持续地保持着兴趣,因此我不认为C++会在近期内消亡。所有的编程语言看起来都不会永远地消亡。
于是我开始考虑:Java的能力适用的战场与C++的稍有不同,C++并不会去尝试为某类问题量身订造。当然,它也采用了大量的方法来解决各种特定问题。某些C++工具结合了类库、构件模型和代码生成工具,以解决开发视窗型终端用户应用(微软Windows应用)过程中的问题。然而,绝大多数Windows应用的开发者使用的是什么呢?是微软的Visual BASIC(VB),尽管VB所产生的代码在程序仅仅只有几页长的情况下就已经变得难以管理了(而且其语法也肯定让人迷惑不解)。虽然VB如此成功、如此流行,它却不是一个很好的语言设计范例。
如果能够在拥有VB的简易性及其强大威力的同时,而又不会产生难以管理的代码,那该有多好啊。这正是我认为Java终会光芒四散的原因所在:它会是下一个VB8。你可能会,也可能不会因听到这种说法而感到害怕,但是想想看, Java作了那么多的事情,都是为了使程序员能够更容易地解决诸如网络、跨平台UI之类的应用级问题,可是它仍然具备了编程语言的设计特征,它允许创建非常大型且极具灵活性的代码。此外,Java的类型检查和错误处理相对于绝大多数其他语言来说,都有很大的提高,这使得你可以在编程生产力方面产生显著的阶越。
如果你基本上是从头开发所有的代码,那么在简单性方面要胜过C++的Java可以显著地缩短你的开发时间。有传言(我是从一些原先使用C++,后来转投Java阵营的开发团队那里听来的)称,用Java的开发速度超过C++的两倍。如果Java的性能对你来说不是问题,或者你可以以某种方式加以弥补,那么纯粹考虑“时间-市场”因素,你是很难不选择Java而选择C++的。
最大的问题还是性能。在原始的Java解释器中,解释型的Java运行速度曾经非常慢,甚至比C要慢20至50倍。随着时间的推移,这一点已经有了很大的改进(特别是最近的Java版本),但是仍然有很大的差距。论及计算机,无非就是速度。如果你在计算机上做事情的速度并没有快很多,也许你就会宁愿手工完成它。(我曾听过有人建议,如果你需要较快的执行速度,那么你可以先使用Java开发以获取较短的开发时间,然后再使用某种工具和支持类库将你编写的代码翻译成为C++代码。)
使Java适用于许多开发项目的关键,就是出现了能够提升速度的技术,例如所谓“即时(just-in-time,JIT)”编译器、Sun自己的“hotspot”技术,以及“本地代码编译器(native code compiler)”。当然,所有的本地代码编译器抹煞了编译过的代码可以跨平台执行这一非常吸引用户的特性,但是它们同时也带来了接近C和C++的执行速度。而且,跨平台编译Java程序比起C和C++来要容易得多。(理论上讲,你只须重新编译,但是其他语言也都这么承诺过。)
总结
本章试图让你体验一下面向对象程序设计和Java中各种宽泛的话题,包括为什么面向对象程序设计与众不同,以及为什么Java格外与众不同。
OOP和Java也许并不适合所有的人。重要的是正确评估你自己的需求,并决定Java是否能够最好地满足这些需求,或者你使用其它编程系统(包括你当前正在使用的)是更好的选择。如果你知道你的需求在可预见的未来会变得非常特殊化,并且Java可能不能满足你的具体限制,那么你就应该去考察其它的选择(我特别推荐你看看Python,www.Python.org)。即使最终你选择了Java作为你的编程语言,你至少要理解还有哪些选项可供选择,并且对为什么选择这个方向要有清楚的认识。
8 微软总是在强调它不象C# 和.NET “那么快”,许多人都产生过这样的问题:VB的程序员是否愿意转而使用其它语言,是否会使用Java, C#, 或者是VB.NET
你知道过程型语言看起来像什么样子:数据定义和函数调用。想了解此类程序的含义,你得忙上一阵,需要通读函数调用和低层概念,以在你那脑海里建立一个模型。这正是我们在设计过程式程序时,需要中介表示方式的原因。这些程序总是容易把人搞糊涂,因为它们使用的表示术语更加面向计算机而不是你要解决的问题。
因为Java在你能够在过程型语言中找到的概念的基础上,又添加了许多新概念,所以你可能会很自然地假设:Java程序中的main()方法比C程序中等价的方法要复杂得多。但是,你会感到很惊喜:编写良好的Java程序通常比C程序要简单的多,而且也易于理解得多。你看到的只是有关下面两部分内容的定义:用来表示问题空间概念的对象(而不是有关计算机表示方式的相关内容),以及发送给这些对象的用来表示在此空间内的行为的消息。面向对象程序设计来给人们的喜悦之一就是:对于设计良好的程序,通过阅读它就可以很容易地理解其代码。通常,其代码也会少很多,因为许多问题都可以通过重用现有的类库代码而得到解决。

原创粉丝点击