面向程序设计(Object-oriented Programming,OOP)
汇编语言是对底层机器的轻微抽象。
接着出现的许多所谓“命令式”语言(如FORTRAN、BASIC、C等)都是对汇编语言的抽象。这些语言在汇编语言基础上有了大幅的改进,但是它们所作的主要抽象仍要求在解决问题时要基于计算机的结构,而不是基于所要解决的问题的结构来考虑。
另一种对机器建模的方式就是对待解问题建模。早期的编程语言,如LISP和APL,都选择考虑世界的某些特定视图(分别对应于“所有问题最终都是列表”或者“所有问题都是算法格式的”)。PROLOG则将所有问题都转换成决策链。此外还产生了基于约束条件编程的语言和专门通过对图形符号操作来实现编程的语言(后者被证明限制性过强)。这些方式对于它们所要解决的特定类型的问题都是不错的解决方案,但是一旦超出其特定领域,它们就力不从心了。
面向对象方式通过向程序员提供表示问题空间的元素的工具而更进了一步。这种表示方式十分通用,使得程序员不会受限于任何特定类型的问题。我们将问题空间中的元素及其在解空间的表示称为“对象”。(你还需要一些无法类比为问题空间元素的对象。)这种思想的实质是:程序可以通过添加新类型的对象使自身适用于某个特定问题。
Alan Kay曾经总结了第一个成功的面向对象语言、同时也是Java所基于语言之一的Smalltalk的五个基本特性,这些特性表现了一种纯粹的面向对象程序设计方式:
1)万物皆为对象。
**2)程序是对象的集合,它们通过发送消息来告知彼此所要做的。**要想请求一个对象,就必须对该对象发送一条消息。更具体的说,可以把消息想象为对某个特定对象的方法的调用请求。
**3)每一个对象都有自己的由其他对象所构成的存储。**换句话说,可以创建包含现有对象的包的方式来创建新类型的对象。因此,可以在程序中构建复杂的体系,同时将其复杂性隐藏在对象的简单性背后。
4)每个对象都拥有其类型。按照通用的说法,“每个对象都是某个类(class)的一个实例(instance)”,这里“类”就是“类型”的同义词。每个类最重要的区别于其他类的特性就是“可以发送什么样的消息给它”。
**5)某一特定类型的所有对象都可以接受同样的消息。这是一句意味深长的表述,你在稍后便会看到。因为“圆形”类型的对象同时也是“几何形”类型的对象,所以一个“圆形”对象必定能够接受发送给“几何形”对象的消息。这意味着可以编写与“几何形”交互并自动处理所有与几何形性质相关的事物的代码。这种可替代性(substitutability)**是OOP中最强有力的概念之一。
Booch对对象提出了一个更加简洁的描述:对象具有状态、行为和标识。这意味着每一个对象都可以拥有内部数据(它们给出了该对象的状态)和方法(它们产生行为),并且每一个对象都可以唯一地与其他对象区分开来,具体说来,就是每一个对象在内存中都有一个唯一的地址。
所有的对象都是唯一的,但同时也是具有相同特性和行为的对象所归属的类的一部分。这种思想被直接应用于第一个面向对象语言Simula-67,它在程序中使用基本关键字class来引入新的类型。
simula,是为了开发诸如经典的“银行出纳员问题”(bank teller problem)这样的仿真程序而创建的。
在程序执行期间具有不同状态而其他方面都相似的对象会被分组到对象的类中,这就是关键字class的由来。创建抽象数据类型(类)是面向对象程序设计的基本概念之一。
因为类描述了具有相同特性(数据元素)和行为(功能)的对象集合,所以一个类实际上就是一个数据类型,例如所有浮点型数字具有相同的特性和行为集合。二者差异在于。程序员通过定义类来适应问题,而不再被迫只能使用现有的用来表示机器中的存储单元的数据类型。
每个对象都只能满足某些请求,这些请求由对象的**接口(interface)**所定义,决定接口的便是类型。
接口确定了对某一特定对象所能发出的请求。但是,在程序中必须有满足这些请求的代码,这些代码与隐藏的数据一起构成了实现。
UML(United Modeling Language,统一建模语言)
当正在试图开发或理解一个程序设计时,最好的方法之一就是将对象想象为“服务提供者”。程序本身将向用户提供服务,它将通过其他对象提供的服务来实现这一目的。你的目的就是去创建(或者最好是在现有代码库中寻找)能够提供理想的服务来解决问题的一系列对象。
将对象看作是服务提供者还有一个附带的好处:它有助于提高对象的内聚性。高内聚是软件设计的基本质量要求之一:这意味着一个软件构件(例如一个对象,当然它也有可能是指一个方法或一个对象库)的各个方面“组合”得很好。
将程序开发人员按照角色分为类创建者(那些创建新数据类型的程序员)和客户端程序员(那些在其应用中使用数据类型的类消费者)是大有裨益的。客户端程序员的目标是收集各种用来实现快速应用开发的类。类创建者的目标是构建类,这种类只向客户端程序员暴露必须的部分,而隐藏其他部分。
访问控制的第一个存在原因就是让客户端程序员无法触及他们不应该触及的部分——这部分对数据类型的内部操作是必需的,但并不是用户解决特定问题所需的接口的一部分。
访问控制的第二个存在原因就是允许库设计者可以改变类内部的工作方式而不用担心会影响到客户端程序员。
Java用三个关键字在类的内部设定边界:public、private、protected。这些访问指定词(access specifier)决定了紧跟其后被定义的东西可以被谁使用。public表示紧随其后的元素对任何人都是可用的,而private这个关键字表示除类型创建者和类型内部方法之外任何人都不能访问的元素。protected关键字与private相当,差别仅在于继承的类可以访问protected成员,但是不能访问private成员。
Java还有一种默认的访问权限,当没有使用前面提到的任何访问指定词时,它将发挥作用。这种权限通常被称为包访问权限,因为在这种权限下,类可以访问在同一个包(库构件)中的其他类的成员,但是在包之外,这些成员就如图制定了private一样。
最简单的复用某个类的方式就是直接使用该类的一个对象,此外也可以将那个类的一个对象置于某个新的类中。我们称其为**“创建一个成员对象”。新的类可以由任意数量、任意类型的其他对象以任意可以实现新的类中想要的功能的方式所组成。因为是在使用现有的类合成新的类,所以这种概念被称为组合(composition),如果组合是动态发生的,那么它通常被称为聚合(aggregation)**。组合经常被视为“has-a”(拥有)关系,就像我们常说的“汽车拥有引擎”一样。
组合带来了极大的灵活性。新类的成员对象通常都被声明为private,使得使用新类的客户端程序员不能访问它们。这也使得你可以在不干扰现有客户端代码的情况下,修改这些成员。也可以在运行时修改这些成员对象,以实现动态修改程序的行为。下面将要讨论的继承并不具备这样的灵活性,因为编译器必须对通过继承而创建的类施加编译时的限制。
实际上,在建立新类时,应该首先考虑组合,因为它更加简单灵活。如果采用这种方式,设计会变得更加清晰。一旦有了一些经验后,便能看出必须使用继承的场合了。
如果我们能够以现有的类为基础,复制它,然后通过添加和修改这个副本来创建新类那就要好多了。通过继承便可以达到这样的效果,不过也有例外,当源类(被称为基类、超类或父类)发生变动时,被修改的“副本”(被称为导出类、继承类或子类)也会反映出这些变动。
类型不仅仅只是描述了作用于一个对象集合上的约束条件,同时还有与其他类型之间的关系。两个类型可以有相同的特性和行为,但是其中一个类型可能比另一个含有更多的特性,并且可以处理更多的信息(或以不同的方式来处理消息)。继承使用基类型和导出类型的概念表示了这种类型之间的相似性。一个基类型包含其所有导出类型所共享的特性和行为。
当继承现有类型时,也就创造了新的类型。这个新类型不仅包括现有类型的所有成员(尽管private成员被隐藏了起来,而且不可访问),而且更重要的是它复制了基类的接口。所以这也就意味着导出类与基类具有相同的类型。
有两种方法可以使基类与导出类产生差异。第一种方法非常直接:直接在导出类中添加新方法。这些新方法不是基类接口的一部分。
虽然继承有时可能意味着在接口中添加新方法(尤其是在以extends关键字表示继承的Java中),但并非总需如此。第二种也是更重要的一种是改变现有基类的方法的行为,这被称之为**覆盖(overriding)**那个方法。
对于继承可能会引发某种争论:继承应该只覆盖基类的方法(而并不添加在基类中没有的新方法)吗?如果这样做,就意味着导出类和基类是完全相同的类型,因为它们具有完全相同的接口。结果可以用一个导出类对象来完全替代一个基类对象。这可以被视为纯粹替代,通常称之为替代原则。在某种意义上,这是一种处理继承的理想方式。我们将这种情况下的基类与导出类之间的关系成为is-a(是一个)关系。判断是否继承,就是要确定是否可以用is-a来描述类之间的关系,并使之具有实际意义。
有时必须在导出类型中添加新的接口元素,这样也就扩展了接口。这个新的类型仍然可以替代基类,但是这种替代并不完美,因为基类无法访问新添加的方法。这种情况我们可以描述为is-like-a(像是一个)关系。新类型具有旧类型的接口,但是它还包含其他方法,所以不能说它们完全相同。
当你看到替代原则时,很容易会认为这种方式(纯粹替代)是唯一可行的方式,而且事实上,用这种方式设计是很好的。但是你会时常发现,同样显然的是你必须在导出类的接口中添加新方法。只要仔细审视,两种方法的使用场合应该是相当明显的。
在处理类型的层次结构时,经常想把一个对象不当做它所属的特定类型来对待,而是将其当作其基类的对象来对待。这使得人们可以编写出不依赖于特定类型的代码。方法操作的都是**泛化(generic)**的形状。
这样的代码是不会受添加新类型影响的,而且添加新类型是扩展一个面向对象程序以便处理新情况的最常用方式。通过导出新的子类型而轻松扩展设计的能力是对改动进行封装的基本方式之一。这种能力可以极大地改善我们的射击,同时也降低软件维护的代价。
试图将导出类的对象当做其泛化基类型对象来看待时,仍然存在一个问题:如果某个方法要操作某个泛化的类,编译器无法精确地了解哪一段代码将被执行。
这个问题的答案,也就是面向对象程序设计的最重要的妙诀:编译器不可能产生传统意义上的函数调用。一个非面向对象编程的编译器产生的函数调用会引起所谓的前期绑定。这么做意味着编译器将产生一个具体函数名字的调用,而运行时将这个调用解析到将要执行代码的绝对地址。然而在OOP中,程序直到运行时才能够确定代码的地址,所以当消息发送到一个泛化对象时,必须采用其他的机制。
为了解决这个问题,面向对象程序设计语言使用了后期绑定的概念。当对象发送消息时,被调用的代码直到运行时才能确定。编译器确保被调用方法的存在,并对调用参数和返回值执行类型检查(无法提供此类保证的语言被称为是弱类型的),但是并不知道将被执行的确切代码。
为了执行后期绑定,Java使用一小段特殊代码来替代绝对地址调用。这段代码使用在对象中存储的信息来计算方法体的地址(这个过程将在第8章中详述)。这样,根据这一小段代码的内容,每一个对象都可以具有不同的行为表现。当向一个对象发送消息时,该对象就能够知道对这条消息应该做些什么。
在某些语言中,必须明确地声明希望某个方法具备后期绑定属性所带来的灵活性(C++是使用virtual关键字来实现的)。在这些语言中,方法在默认情况下不是动态绑定的。而这Java中,动态绑定是默认行为,不需要添加额外的关键字来实现多态。
把将导出类看作是它的基类的过程称为向上转型(upcasting)。**转型(cast)这个名称的灵感来自于模型铸造的塑模动作;而向上(up)**这个词来源于继承图的典型布局方式:通常基类在顶部,而导出类在其下部三开。因此,转型为一个基类就是在继承图中向上移动,即“向上转型”。
一个面向对象程序肯定会在某处包含向上转型,因为这正是将自己从必须知道确切类型中解放出来的关键。
在OOP中,自C++面世以来就已变得非常瞩目的一个问题就是,是否所有的类最终都继承自单一的基类。在Java中(事实上还包括除C++以外的所有OOP语言),答案是yes,这个终极基类的名字就是Object。事实证明,单根继承结构带来了很多好处。
在单根继承结构中的所有对象都具有一个公共接口,所以它们归根到底都是相同的基本类型。另一种(C++所提供的)结构是无法确保所有对象都属于同一个基本类型。从向后兼容的角度看,这么做能够更好地适应C模型,而且受限较少,但是当要进行完全的面向对象程序设计时,则必须构建自己的继承体系,使得它可以提供其他OOP语言内置的便利。并且在所获得的任何新类库中,总会用到些不兼容的接口,需要花力气(有可能要通过多重继承)来使新接口融入你的设计之中。这么做来换取C++额外的灵活性是否值得呢?如果需要的话——如果在C上面投资巨大,这么做就很有价值。如果是刚刚从头开始,那么像Java这样的选择通常会有更高的生产率。
单根继承结构保证所有对象都具备某些功能。因此你知道,在你的系统中你可以在每一个对象上执行某些基本操作。所有对象都可以很容易地在堆上创建,而参数传递也得到了极大的简化。
单根继承结构使垃圾回收器的实现变得容易得多,而垃圾回收器正是Java相对C++的重要改进之一。由于所有对象都保证具有其类型信息,因此不会因无法确定对象的类型而陷入僵局。这对于系统级操作(如异常处理)显得尤其重要,并且给编程带来了更大的灵活性。
通常说来,如果不知道在解决某个特定问题时需要多少个对象,或者它们将存活多久,那么就不可能知道如何存储这些对象。如何才能知道需要多少空间来创建这些对象呢?答案是你不可能知道,因为这类信息只有在运行时才能获得。
对于面向对象射击中的大多数问题而言,这个问题的解决方案似乎过于轻率:创建另一种对象类型。这种新的对象类型持有对其他对象的引用。当然,你可以用在大多数语言中都有的数组类型来实现相同的功能。但是这个通常被称为容器(也称为集合,不过Java类库以不同的含义使用“集合”这个术语,所以本书将使用“容器”这个词)的新对象,在任何需要时都可扩充自己以容纳你置于其中的所有东西。
幸运的是,好的OOP语言都有一组容器,它们作为开发包的一部分。在C++中,容器是标准C++类库的一部分,经常被称为标准模板库(Standard Template Library, STL)。Object Pascal在其**可视化构件库(Visual Component Library, VCL)**中有容器;Smalltalk提供了一个非常完备的容器集;Java在其标准类库中也包含有大量的容器。在某些类库中,一两个通用容器足够满足所有的需要;但是在其他类库(例如Java)中,具有满足不同需要的各种类型的容器,例如List(用于存储序列),Map(也被称为关联数组,用来建立对象之间的关联),Set(每种对象类型只持有一个),以及诸如队列、树、堆栈等更多的构件。
需要对容器有所选择,这有两个原因。第一,不同容器提供了不同类型的接口和外部行为。堆栈相比于队列就具备不同的接口和行为,也不同于集合和列表的接口和行为。它们之中的某种容器提供的解决方案可能比其他容器要灵活得多。第二,不同的容器对于某些操作具有不同的效率。最好的例子就是两种List的比较:ArrayList和LinkedList。它们都是具有相同接口和外部行为的简单的序列,但是它们对某些操作所花费的代价却有天壤之别。接口List所带来的抽象,把在容器之间进行转换时对代码的影响降到最小限度。
在Java SE5出现之前,容器存储的对象都只具有Java中的通用类型:Object。单根继承结构意味着所有东西都是Object类型,所以可以存储Object的容器可以存储任何东西(它们不能持有基本类型,但是Java SE5 的自动包装功能使得这项限制几乎不成什么问题了)。这使得容器很容易被复用。
怎样才能将向上转型成Object的对象便会先前置入容器中时的具有实用接口的对象呢?
这里再度用到了转型,向下转型为更具体的类型,这种转型的方式称为向下转型。除非确切知道所要处理的对象的类型,否则向下转型几乎是不安全的。当从容器中取出对象引用时,还是必须要以某种方式记住这些对象究竟是什么类型,这样才能执行正确的向下转型。
创建这样的容器,它知道自己所保存的对象的类型,从而不需要向下转型以及消除犯错误的可能,这种解决方案被称为参数化类型机制。参数化类型就是一个编译器可以自动定制作用于特定类型上的类。例如,通过使用参数化类型,编译器可以定制一个只接纳和取出Shape对象的容器。
Java SE5的重大变化之一就是增加了参数化类型,在Java中它称为泛型。一对尖括号,中间包含类型信息,通过这些特征就可以识别对泛型的使用。例如,可以用下面这样的语句来创建一个存储Shape的ArrayList:
ArrayList<Shape> shapes = new ArrayList<Shape>();
对象数据位于何处?怎样控制对象的生命周期?C++认为效率控制是最重要的议题,所以给程序员提供了选择的权力。为了追求最大的执行速度,对象的存储空间和生命周期可以在编写程序时确定,这可以通过将对象置于堆栈(它们有时被称为自动变量(automatic variable)或限域变量(scoped variable))或静态存储区域内来实现。这种方式将存储空间分配和释放置于优先考虑的位置,某些情况下这样的控制非常有价值。但是,也牺牲了灵活性,因为必须在编写程序时知道对象确切的数量、生命周期和类型。
第二种是在被称为**堆(heap)**的内存池中动态地创建对象。在这种方式中,直到运行时才知道需要多少对象,它们的生命周期如何,以及它们具体类型是什么。因为存储空间是在运行时动态管理的,所以需要大量的时间在堆中分配存储空间,这可能要远远大于在堆栈中创建存储空间的时间。在堆栈中创建存储空间和释放存储空间通常各需要一条汇编指令即可,分别对应将堆栈指针向下移动和将栈顶指针向上移动。而创建堆存储空间的时间依赖于存储机制的射击。
动态方式有一个一般性的逻辑假设:对象趋向于变得复杂,所以查找和释放存储空间的开销不会对对象的创建造成重大冲击。动态方式所带来的更大的灵活性正是解决一般化编程问题的要点所在。
Java完全采用了动态内存分配方式。每当想要创建新对象时,就要使用new关键字来构建此对象的动态实例。
还有一个议题,就是对象的生命周期。对于允许在堆栈上创建对象的语言,编译器可以确定对象存活的时间,并可以自动销毁它。然而,如果是在堆上创建对象,编译器就会对它的生命周期一无所知。在像C++这样的语言中,必须通过编程方式来确定何时销毁对象,这可能会因为不能正确处理而导致内存泄露。Java提供了被称为“垃圾回收器”的机制,它可以自动发现对象何时不再被使用,继而销毁它。垃圾回收器非常有用,因为它减少了所必须考虑的议题和必须编写的代码。更重要的是,垃圾回收器提供了更高层的保障,可以避免暗藏的内存泄露问题,这个问题已经使许多C++项目折戟沉沙。
Java的垃圾回收器被设计用来处理内存释放问题(尽管它不包括清理对象的其他方面)。垃圾回收器“知道”对象何时不再被使用,并自动释放对象占用的内存。这一点同所有对象都是继承自单根基类Object以及只能以一种方式创建对象(在堆上创建)这两个特性结合起来,使得用Java编程的过程较之用C++编程要简单得多。
异常处理将错误处理直接置于编程语言中,有时甚至置于操作系统中。异常是一种对象,它从出错地点被“抛出”,并被专门设计用来处理特定类型错误的相应的异常处理器“捕获”,不需要被迫定期检查错误。异常不能被忽略,所以它保证一定会在某处得到处理。异常提供了一种从错误状况进行可靠恢复的途径。
Java的异常处理在众多的编程语言中格外引人注目,因为Java一开始就内置了异常处理,而且强制你必须使用它。
值得注意的是,异常处理不是面向对象的特征——尽管在面向对象语言中异常常被表示成为一个对象。异常处理在面向对象语言出现之前就已经存在了。
想把问题切分成多个可独立运行的部分(任务),从而提高程序的响应能力。在程序中,这些彼此独立的部分称之为线程,上述概念被称为“并发”。
Java的并发是内置于语言中的。
客户/服务器(C/S)系统的核心思想是:系统具有一个中央信息存储池(central repository of information),用来存储某种数据,它通常存在于数据库中,你可以根据需要将它分发给某些人员或机器集群。客户/服务器概念的关键在于信息存储池的位置集中于中央。信息存储池、用于分发信息的软件以及信息与软件所驻留的机器或机群被总称为服务器。驻留在用户机器上的软件与服务器进行通信,以获取信息、处理信息,然后将它们显示在被称为客户机的用户机器上。
事务处理
中间件
为了解决浏览器只是一个观察器而不能执行最简单的计算任务这个问题,首先,图形标准得到了增强,使得在浏览器中可以播放质量更好的动画和视频。剩下的问题通过引入在客户端浏览器中运行程序的能力就可以解决。这被称为“客户端编程”。
HTML(HyperText Markup Language,超文本标记语言)
通用网关接口(common gateway interface,CGI)
Perl已经成为编写服务器中名为“cgi-bin”的目录下的程序的最常见的选择,因为它被设计用来处理文本,而且是解释型语言,因此无论服务器的处理器和操作系统如何,它都适用于安装。然而,Python已对其产生了重大的冲击,因为它更强大且更简单。
图形交互格式(graphic interchange format, GIF)
插件(plug-in)
后门
脚本语言(scripting language)
一种脚本语言在Web浏览器不需要任何插件的情况下就可以得到支持:JavaScript(它和Java之间只存在表面上的相似,要想使用它,你必须在额外的学习曲线上攀爬。它之所以被这样命名只是因为想赶上Java潮流)。
图形化用户界面(graphic user interface,GUI)
如果脚本语言可以解决客户端百分之八十的问题的话,剩下那百分之二十的难啃的硬骨头最流行的解决方案就是Java。Java不仅是一种功能强大的、安全的、跨平台的、国际化的编程语言,而且它还在不断地被扩展,以提供更多的语言功能和类库,能够优雅地处理在传统编程语言中很难解决的问题,例如并发、数据库访问、网络编程和分布式计算。Java是通过applet以及使用Java Web Start来进行客户端编程的。
applet是只在Web浏览器中运行的小程序,它是作为网页的一部分而自动下载的(就像网页中的图片被自动下载一样)。
Intranet(企业内部网)
Java编写被称为servlet的程序来实现服务器端编程