欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java编程思想 | 第1章 对象入门

程序员文章站 2024-03-17 17:44:52
...

"为什么面向对象的编程会在软件开发领域造成如此震憾的影响?"

面向对象编程(OOP)具有多方面的吸引力。对管理人员,它实现了更快和更廉价的开发与维护过程。对分析与设计人员,建模处理变得更加简单,能生成清晰、易于维护的设计方案。**对程序员,对象模型显得如此高雅和浅显。**此外,面向对象工具以及库的巨大威力使编程成为一项更使人愉悦的任务。

如果说它有缺点,那就是掌握它需付出的代价。思考对象的时候,需要采用形象思维,而不是程序化的思维。与程序化设计相比,对象的设计过程更具挑战性——特别是在尝试创建可重复使用(可再生)的对象时。

1.1 抽象的进步

所有编程语言的最终目的都是提供一种“抽象”方法。对一些早期语言来说,如 LISP 和 APL,它们的做法是 “从不同的角度观察世界”——“所有问题都归纳为列表”或“所有问题都归纳为算法”。PROLOG 则将所有问题都归纳为决策链。对于这些语言,我们认为它们一部分是面向基于“强制”的编程,另一部分则是专为处理图形符号设计的。每种方法都有自己特殊的用途,适合解决某一类的问题。但只要超出了它们力所能及的范围,就会显得非常笨拙。

面向对象的程序设计在此基础上则跨出了一大步,程序员可利用一些工具表达问题空间内的元素。由于这种表达非常普遍,所以不必受限于特定类型的问题。我们将问题空间中的元素以及它们在方案空间的表示物称作“对象”(Object)。

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

  1. **所有东西都是对象。**可将对象想象成一种新型变量;它保存着数据,但可要求它对自身进行操作。理论上讲,可从要解决的问题身上提出所有概念性的组件,然后在程序中将其表达为一个对象。
  2. 程序是一大堆对象的组合;通过消息传递,各对象知道自己该做些什么。具体来说,可将消息想象为一个调用请求,它调用的是从属于目标对象的一个子例程或函数。
  3. 每个对象都有自己的储存空间,可容纳其他对象。或者说,通过封装现有对象,可制作出新型对象。所以,尽管对象的概念非常简单,但在程序中却可达到任意高的复杂程度。
  4. 每个对象都有一种类型。根据语法,每个对象都是某个"类"的一个"实例"。其中,"类"(Class)是"类型"(Type)的同义词。一个类最重要的特征就是"能将什么消息发给它?"。
  5. 同一类所有对象都能接收到相同的消息。由于类型为"圆"(Circle)的一个对象也属于类型为"形状"(Shape)的一个对象,所以一个圆完全能接收形状消息。这意味着可让程序代码统一指挥"形状",令其自动控制所有符合"形状"描述的对象,其中自然包括"圆"。这一特性称为对象的"可替换性",是OOP 最重要的概念之一。

1.2 对象的接口

亚里士多德或许是认真研究"类型"概念的第一人,他曾谈及"鱼类和鸟类"的问题。在世界首例面向对象语言Simula-67 中,第一次用到了这样的一个概念:

所有对象——尽管各有特色——都属于某一系列对象的一部分,这些对象具有通用的特征和行为。在Simula-67 中,首次用到了class 这个关键字,它为程序引入了一个全新的类型(class 和 type 通常可互换使用;注释③)。

③:有些人进行了进一步的区分,他们强调“类型”决定了接口,而“类”是那个接口的一种特殊实现方式。

建好一个类后,可根据情况生成许多对象。随后,可将那些对象作为要解决问题中存在的元素进行处理。事实上,当我们进行面向对象的程序设计时,面临的最大一项挑战性就是:如何在"问题空间"(问题实际存在的地方)的元素与"方案空间"(对实际问题进行建模的地方,如计算机)的元素之间建立理想的"一对一"对应或映射关系。

如何利用对象完成真正有用的工作呢?必须有一种办法能向对象发出请求,令其做一些实际的事情,比如完成一次交易、在屏幕上画一些东西或者打开一个开关等等。每个对象仅能接受特定的请求。我们向对象发出的请求是通过它的"接口"(Interface)定义的,对象的"类型"或"类"则规定了它的接口形式。"类 型"与"接口"的等价或对应关系是面向对象程序设计的基础。

下面让我们以电灯泡为例:

Java编程思想 | 第1章 对象入门

在这个例子中,类型/类的名称是 Light,可向 Light 对象发出的请求包括包括打开(on)、关闭(off)、 变得更明亮(brighten)或者变得更暗淡(dim)。通过简单地声明一个名字(lt),我们为 Light 对象创建了一个"句柄"。然后用new 关键字新建类型为 Light 的一个对象。再用等号将其赋给句柄。为了向对象发送一条消息,我们列出句柄名(lt),再用一个句点符号(.)把它同消息名称(on)连接起来。从中可以看出,使用一些预先定义好的类时,我们在程序里采用的代码是非常简单和直观的。

1.3 实现方案的隐藏

从根本上来说,大致有两方面的人员涉足面向对象的编程:"类创建者"(创建新数据类型的人)以及"客户程序员"(在自己的应用程序中采用现成数据类型的人)。对客户程序员来讲,最主要的目标就是收集一个充斥着各种类编程"工具箱",以便快速开发符合自己要求的应用。而对类创建者来说,他们的目标是从头构建一个类,只向客户程序员开放有必要开放的接口。其他所有细节都隐藏起来。——隐藏之后客户程序员就不能接触和改变那些细节,所以原创者不用担心自己的作品会受到非法修改,可确保它们不会对其他人造成影响。

**"接口"(Interface)规定了可对一个特定的对象发出哪些请求。**然而,必须在某个地方存在着一些代码,以便满足这些请求。这些代码与那些隐藏起来的数据便叫作"隐藏的实现"。站在程式化编程编写(Procedureal Programming)的角度,整个问题并不显得复杂。一种类型含有与每种可能的请求关联起来的函数。一旦向对象发出一个特定的请求,就会调用那个函数。我们通常将这个过程总结为向对象"发送一个请求"。对象的职责就是觉得如何对这个请求作出反应(执行相应的代码)。对于任何关系,重要一点是让牵连到的所有成员都遵守相同的规则。创建一个库时,相当于同客户程序员建立了一种关系。对方也是程序员,但他们的目标是组合出一个特定的应用(程序),或者用您的库构建一个更大的库。

如果任何人都能使用一个类的所有成员,那么客户程序员可对那个类做任何事情,没有办法强制他们遵守任何约束。即便非常不愿客户程序员直接操作类内包含的一些成员,但倘若未进行访问控制,就没有办法阻止这一情况的发生——所有东西都会暴露无遗。

有两方面的原因促使我们控制对成员的访问:

  • 1、防止程序员接触他们不该接触的东西——通常是内部数据类型的设计思想。若只是为了解决特定的问题,用户只需要操作接口即可,毋需明白这些信息。我们向用户提供的实际是一种服务,因为他们很容易就可看出哪些对自己非常重要,以及哪些可忽略不计。
  • 2、进行控制访问的第二个原因是允许库设计人员修改内部结构,不用担心它会对客户程序员造成了什么影响。例如,我们最开始可能设计了一个形式简单的类,以便简化开发。以后又决定进行改写,使其更快地运行。若接口与实现方法早已隔离开,并分别受到保护,就可放心做到这一点,只要求用户重新链接一下即可。

Java采用三个显示(明确)关键字以及一个隐式(暗示)关键字来设置类边界:public、private、protected 以及暗示性的 friendly。若未明确指示其他关键字,则默认为后者。

  • public (公共):意味着后续的定义任何人君克使用
  • private (私有):意味着除您自己、类型的创建者以及那个类型的内部函数成员,其他任何人都不能访问后续的定义信息。private 在您和客户程序员之间竖起了一堵墙。若有人视图访问私有成员,就会得到一个编译期错误。
  • friendly (友好的):涉及"包装"或"封装"(Package)的概念——即Java用来构建库的方法。若某样东西是"友好的",意味着它只能在这个包装的范围内使用(所以这一访问级别有时也叫做"包装访问")。
  • protected (受保护的):与"private"相似,只是一个继承的类可访问受保护的成员,但不能访问私有成员。

1.4 方案的重复使用

创建并测试好一个类后,它应(从理想的角度)代表一个有用的代码单位。

许多人认为代码或设计方案的重复使用是面向对象的程序设计提供的最伟大的一种杠杆。为重复使用一个类,最简单的办法是仅直接使用那个类的对象。但同时也能够将那个类的一个对象置入一个新类。我们把这叫作"创建一个成员对象"。新类可由任意数量和类型的其他对象构成。只要心累达到了设计要求即可。这个概念叫作"组织"——在现有类的基础上组织一个新类。有时,我们也将组织称作"包含"关系,比如"一辆车包含了一个变速箱"。

对象的组织具有极大的灵活性。新类的"成员对象"通常设为"私有"(Private),使用这个类的客户程序员不能访问它们。这样一来,我们可在不干扰客户代码的前提下,从容地修改那些成员。也可以在"运行期"更改成员,这进一步增大了灵活性。后面要讲到的"继承"并不具备这种灵活性,因为编译器必须对通过继承创建的类加以限制。

由于继承的重要性,所以在面向对象的程序设计中,它经常被重点强调。作为新加入这一领域的程序员,或许早已先入为主地认为“继承应当随处可见”。沿这种思路产生的设计将是非常笨拙的,会大大增加程序的 复杂程度。相反,新建类的时候,首先应考虑“组织”对象;这样做显得更加简单和灵活。利用对象的组织,我们的设计可保持清爽。一旦需要用到继承,就会明显意识到这一点。

1.5 继承:重新使用接口

我们费尽心思做出一种数据类型后,假如不得不又新建一种类型,令其实现大致相同的功能,那会是一件非常令人难受的事情。但若能利用现成的数据类型,对其进行“继承”,根据情况进行添加和修改,情况就显得理想多了。“继承”正是针对这个目标而设计的。在继承过程中,若原始类(正式名称叫作基础类、超类或父类)发生了变化,修改过的继承类(或子类)也会反映出这种变化。在 Java 语言中,继承是通过 extends 关键字实现的。

使用继承时,相当于创建了一个新类。这个新类不仅包含了现有类型的所有成员(尽管private成员被隐藏起来,且不能访问),但更重要的是,它复制了基础类的接口。也就是说,可向基础类的对象发送的所有消息亦可原样发给衍生了的对象。根据可以发送的消息。我们能知道类的类型。这意味着衍生类具有与基础类相同的类型!为真正理解面向对象程序设计的含义,首先必须认识到这种类型的等价关系。

由于基础类和衍生类具有相同的接口,所以那个接口必须进行特殊的设计。也就是说,对象接收到一条特定的消息后,必须有一个"方法"能够执行。若只是简单地继承一个类,并不做其他任何事情,来自基础类接口的方法就会直接照搬到衍生类。这意味着衍生类的对象不仅有相同的类型,也有同样的行为,这一后果通常是我们不愿见到的。

有两种做法可将新得的衍生类与原来的基础类区分开。第一种做法十分简单;为衍生类添加新函数(功能)。这些新函数并非基础类接口的一部分。进行这种处理时,一般都是意识到基础类不能满足我们的要求,所以需要添加更多的函数。这是一种最简单、最基本的继承用法。

1.5.1 改善基础类

尽管 extends 关键字暗示着我们要为接口"扩展"新功能,但是实情并非如此。为区分我们的心累,第二个办法是改变基础类一个现有函数的行为。我们将其称作"改善"那个函数。

为改善一个函数,只需为衍生类的函数简历一个新定义即可。我们的目标是:"尽管使用的函数接口未变,但它的新版本具有不同的表现"。

1.5.2 等价与类似关系

针对继承可能会产生这样的一个争论:继承只能改善原基础类的函数吗?若答案是肯定的,则衍生类就是与基础类完全相同的类型,因为都拥有完全相同的接口。这样造成的结果就是:我们完全能够将衍生类的一个对象换成基础类的一个对象!可将其想象成一种"纯替换"。在某种意义上,这是进行继承的一种理想方式。

但在许多时候我们必须为衍生类型加入新的接口元素。所以不仅扩展了接口,也创建了一种新类型。这种新类型仍可替换成基础类型,但这种替换并不是完美的,因为不堪在基础类里访问新函数。我们将其称作"类似"关系;新类型拥有旧类型的接口,但也包含了其他函数,所以不能说它们是完全等价的。比如:假设我们的房间连接好了用于制冷的各种控制器;也就是说,我们已拥有必要的"接口"来控制制冷。假设现在机器出了故障,我们把它换成一台新型的冷、热两用空调,冬天和夏天均可用。所以它们只限于同新机器的制冷部分打交道。新机器的接口已得到了扩展,但现有的系统不知道除原始接口以外的任何东西。

尽管大多数时候"纯替换"已经足够,但您会发现在某些情况下,仍然有明显的理由需要在衍生类的基础上增添新功能。

1.6 多形对象的互换使用

通常,继承最终会以创建一系列类收场,所有类都建立在统一的接口基础上。我们用一幅颠倒的树形图来阐明这一点。

Java编程思想 | 第1章 对象入门

对这样的一系列类,我们要进行的一项重要处理就是将衍生类的对象当做基础类的一个对象对待。这一点是非常重要的,它意味着我们只需编写单一的代码,令其忽略类型的特定细节,只与基础类打交道。这样一样,那些代码就可与类型信息分开。所以更易编写,也更易理解。此外,若通过继承添加了一种新类型,如"三角形",那么我们为"几何形状"新类型编写的代码会像在旧类型里一样良好地工作。所以说程序具备了"扩展能力",具有"扩展性"。

以上面的例子为基础,假设我们用 Java 写了这样一个函数

void doStuff(Shape s) {
 s.erase();
 // ...
 s.draw();
    
}    

这个函数可与任何“几何形状”(Shape)通信,所以完全独立于它要描绘(draw)和删除(erase)的任何特定类型的对象。如果我们在其他一些程序里使用 doStuff()函数:

Circle c = new Circle();
Triangle t = new Triangle();
Line l = new Line();
doStuff(c);
doStuff(t);
doStuff(l);

那么对 doStuff() 的调用会自动良好地工作,无论对象的具体类型是什么。

这实际是一个非常有用的编程技巧。请考虑下面这行代码:

doStuff(c);

此时,一个 Circle(圆)句柄传递给一个本来期待 Shape(形状)句柄的函数。由于圆是一种几何形状,所以doStuff()能正确地进行处理。也就是说,凡是 doStuff()能发给一个 Shape 的消息,Circle 也能接收。所以这样做是安全的,不会造成错误。我们将这种把衍生类型当作它的基本类型处理的过程叫作“Upcasting”(上溯造型)。其中,“cast”(造型)是指根据一个现成的模型创建;而“Up”(向上)表明继承的方向是从“上面”来的——即基础类位于顶部,而衍生类在下方展开。所以,根据基础类进行造型就是一个从上面继承的过程,即“Upcasting”。在面向对象的程序里,通常都要用到上溯造型技术。这是避免去调查准确类型的一个好办法。请看看doStuff()里的代码:

s.erase();
// ...
s.draw();

注意它并未这样表达:"如果你是一个Circle,就这样做;如果你是一个Square,就那样做;等等"。若那样编写代码,就需检查一个Shape 所有可能的类型,如圆、矩形等等。这显然是非常麻烦的,而且每次添加了一种新的 Shape 类型后,都要相应地进行修改。在这儿,我们只需说:"你是一种几何形状,我知道你能将自己删掉,即erase();请自己采取那个行动,并自己去控制所有的细节吧。"

1.6.1 动态绑定

在 doStuff() 的代码里,最让人吃惊的是尽管我们没作出任何特殊指示,采取的操作也是完全正确和恰当的。我们知道,为 Circle 调用 draw() 时执行的代码与为一个 Square 或 Line 调用 draw() 时执行的代码是不同的。但在将 draw() 消息发给一个匿名 Shape 时,根据 Shape 句柄当时连接的实际类型,会相应地采取正确的操作。这当然令人惊讶,因为当 Java 编译器为 doStuff() 编译代码时,它并不知道自己要操作的准确类型是什么。尽管我们确实可以保证最终会为 Shape 调用 erase(),为 Shape 调用draw(),但并不能保证为特定的 Circle,Square 或者 Line 调用什么。然而最后采取的操作同样是正确的,这是怎么做到的呢?将一条消息发给对象时,如果并不知道对方的具体类型是什么,但采取的行动同样是正确的,这种情况就叫 作“多形性”(Polymorphism)。**对面向对象的程序设计语言来说,它们用以实现多形性的方法叫作“动态绑定”。**编译器和运行期系统会负责对所有细节的控制;我们只需知道会发生什么事情,而且更重要的是,如何利用它帮助自己设计程序。有些语言要求我们用一个特殊的关键字来允许动态绑定。在C++中,这个关键字是 virtual。在Java 中,我们则完全不必记住添加一个关键字,因为函数的动态绑定是自动进行的。所以在将一条消息发给对象时,我们完全可以肯定对象会采取正确的行动,即使其中涉及上溯造型之类的处理。

1.6.2 抽象的基础类和接口

设计程序时,我们经常都希望基础类只为自己的衍生类提供一个接口。也就是说,我们不想其他任何人实际创建基础类的一个对象,只对上溯造型成它,以便使用它们的接口。为达成这个目的,需要把那个类变成"抽象的"——使用 abstract 关键字。若有人试图创建抽象类的一个对象,编译器就会阻止他们。这种工具可有效强制实行一种特殊的设计。

亦可用 abstract 关键字描述一个尚未实现的方法——作为一个"根"使用,指出:"这是适用于从这个类继承的所有类型的一个接口函数,但目前没有对它任何形式的实现。"**抽象方法只能在一个抽象类里创建。**继承了一个类后,那个方法就必须实现,否则继承的类也会变成"抽象"类。通过创建一个抽象方法,我们可以将一个方法置入接口中,不必再为那个方法提供可能毫无意义的主体代码。

Interface(接口)关键字将抽象类的概念更延伸了一步,它完全静止了所有的函数定义。"接口"是一种相当有效和常用的工具。如果您愿意,也可以将多个接口的都合并到一起(不能从多个普通 class 或 abstract calss中继承)。

1.7 对象的创建和存在时间

从技术角度说,OOP(面向对象程序设计)只是涉及抽象的数据类型、继承以及多形性,但另一些问题也可能显得非常重要。最重要的问题之一是:

对象的创建及销毁方式。对象需要的数据位于哪儿,如何控制对象的"存在时间"呢?

针对这个问题,解决的方案是各异其趣的。C++ 认为程序的执行效率是最重要的一个问题,所以它允许程序员做出选择。为获得最快的运行速度,储存以及存在时间可在编写程序时决定,只需将对象放置在堆栈或者静态储存区域即可。这样便为储存空间的分配和释放提供了一个优先级。某些情况下,这种优先级的控制是非常有价值的。然而,我们同时也牺牲了灵活性,因为在编写程序时,必须知道对象的准确的数量、存在时间、以及类型。如果要解决的是一个较常规的问题,如计算机辅助设计、仓储管理或者空中交通控制,这一方法就显得太局限了。

第二个方法是在一个内存池中动态创建对象,该内存也叫"堆"或者"内存堆"。若采用这种方式,除非进入运行期,否则根本不知道到底需要多少个对象,也不知道它们的存在时间有多长,以及准确的类型是什么。这些参数都在程序正式运行时在决定的。若需一个新对象,只需要在需要它的时候在内存堆里简单地创建它即可。由于存储空间的管理是运行期间动态进行的,所以在内存堆里分配储存空间的时间比在堆栈里创建的时间长得多(在堆栈里创建储存空间一般只需要一个简单的指令,将堆栈指针向下或向下移动即可)。由于动态创建方法使对象本来就倾与复杂,所以查找存储空间以及释放它所需的额外开销不会为对象的创建造成明显的影响。除此之外,更大的灵活性对于常规编程问题的解决是至关重要的。

C++允许我们觉得是在写程序时创建对象,还是在运行期间创建,这种控制方法更加灵活,大家或许认为既然它如此灵活,那么无论如何都应在内存堆里创建对象,而不是在堆栈中创建。但还要考虑另一个问题,也就是对象的"存在时间"或者"生存时间"(Lifetime)。若在堆栈或者静态储存空间里创建一个对象,编译器会判断对象的持续时间有多长,到时会自动销毁它。程序员可用两种方法来销毁一个对象:用程序化的方式决定何时销毁对象,或者利用运行环境提供的一种"垃圾收集器"特性,自动寻找那些不再使用的对象,并将其清除。当然,垃圾收集器显得方便得多,但要求所有应用程序都必须容忍垃圾收集器的存在,并能默许垃圾收集带来的额外开销。但这并不符合 C++语言的设计宗旨,所以未能包括到C++里。但Java确实提供了一个垃圾收集器 。

1.7.1 集合与继承器

针对一个特定问题的解决,如果事先不知道需要多少个对象,或者它们的持续时间有多长,那么也不知道如何保存那些对象。既然如此,怎样才能知道那些对象要求多少空间呢?事先根本无法提前知道,除非进入运行期。

在面向对象的设计中,大多数问题的解决方法似乎都有些轻率——只是简单地创建另一种类型的对象。用于解决特点问题的新型对象容纳了指向其他对象的句柄。当然,也可以用数组来做同样的事情,那是大多数语言都具有的一种功能。但不能只看到这一点。这种对象通常叫作"集合",在需要的时候,集合会自动扩充自己,以便适应我们在其中置入的任何东西。所以我们事先不必知道要在一个集合里容下多少东西。

设计优良的OOP语言读配套提供了一系列集合。在某些库中,一个常规集合便可满足人们的大多数要求;而在另一些库中(特别是C++)的库,则面向不同的需求提供了不同类型的集合。例如,可以用矢量统一对所有元素的访问方式;一个链接列表则用于保证所有元素的插入统一。所以我们能根据自己的需要选择适当的类型。其中包括集、队列、散列表、树、堆栈等等。

所有集合都提供了相应的读写功能。将某样东西放入集合时,采用的方式是十分明显的。用"推"(Push)、"添加"(Add)或其他类似名字的函数用于做这件事情。但将数据从集合中取出的时候,方式却并不总是那么明显。如果是一个数组形式的实体,比如一个矢量(Vector),那么也许能用索引运算符或函数。此外,单选定函数的功能是非常有限的。如果想对集合中的一系列元素进行操纵或比较,而不是仅仅面向一个,这时又该怎么办呢?

可以使用一个"迭代器"(Iterator),它属于一种对象,负责选择集合内的元素,并把它们提供给继承器的用户。作为一个类,它也提供了一级抽象。利用这一级抽象,可将集合细节与用于访问的那个集合代码隔离开。通过集成器的作用,集合会被抽象成一个简单的序列。继承器运行我们遍历那个序列,同时毋需关心基础结构是什么——换言之,不管它是一个矢量、一个链接列表、一个堆栈,还是其他什么东西。这样一来,我们就可以灵活地改变基础数据,不会对程序里的代码造成干扰。

1.7.2 单根结构

在面向对象的程序设计中,由于C++的引入而显得尤为突出的一个问题是:所有类最终是否都应从单独一个基础类继承。在Java中(与其他几乎所有OOP语言一样),这个终极基础类叫"Object"。这种"单根结构"具有许多方面的优点。

单根结构中的所有对象都有一个通用的接口,所以它们最终都属于相同的类型。另一种方案(就像C++那样)单根结构中的所有对象都有一个通用接口,它们最终都属于相同的基本类型。从向后兼容的角度看,这一方案可与C模型更好地配合,而且可以认为它的限制更少一些。

单根结构中的所有对象(比如所有Java对象)都可以保证拥有一些特定的功能。在自己的系统中,我们知道对每个对象都能进行一些基本操作。一个单根结构,加上所有对象都在内存堆中创建,可以极大的简化参数的传递。

利用单根结构,我们可以方便地实现一个垃圾回收器。与此有关的必要支出可安装与基础类中,而垃圾收集器可将适当的消息发送给系统内的任何对象。如果没有这种单根结构,而且系统通过一个句柄来操纵对象,那么实现垃圾收集器的途径会有很大的不同,而且会面临许多问题。

1.8 多线程

在计算机编程中,一个基本的概念就是同时对多个任务加以控制。许多程序设计问题都要求程序能够停下手头的工作,改为处理其他一些问题,再返回主进程。可以通过多种途径达到这个目的。最开始的时候,那些拥有机器低级知识的程序员编写一些"中断服务例程",主进程的暂停是通过硬件级的中断实现的。尽管这是一种有用的方法,但编出的程序很难移植,由此造成了另一类的代价高昂问题。

有些时候,中断对那些实时性很强的任务来说是很有必要的。但还存在其他许多问题,它们只要求将问题划分进入独立运行的程序片断中,使整个程序能更迅速地响应用户的请求。在一个程序中,这些独立运行的片段叫作"线程" (Thread),利用它的编程概念叫作"多线程处理"。多线程处理一个常见的例子就是用户界面。利用线程,用户可按下一个按钮,然后程序会立即作出响应,而不是让用户等待程序完成了当前任务以后才开始响应。

最开始,线程只是用于分配单个处理器的处理时间的一种工具。但假如操作系统本身支持多个处理器,那么每个线程都可分配给一个不同的处理器,真正进入"并行运算"状态。从程序设计语言的角度看看,多线程操作最有价值的特性之一就是程序员不必关心到底使用了多少个处理器。程序从逻辑意义上被分割为数个线程。

但有一个问题必须要注意:共享资源,如果有多个线程同时运行,而且它们视图访问相同的资源,就会遇到一个问题。举个例子来说,两个进程不能将信息同时发送给一台打印机。为解决这个问题,**对那些可共享的资源来说,它们在使用期间必须进入锁定状态。**所以一个线程可将资源锁定,在完成了它的任务后,再解开(释放)这个锁,使其他先可以接着使用同样的资源。

Java 的多线程机制已内建到语言中,这使一个可能较为复杂的问题变得简单起来。对多线程处理的支持实在对象这一级支持的,所以一个执行线程可表达为一个对象。Java 也提供了有限的资源锁定方案。它能锁定任何对象占用的内存(内存实际是多种共享资源的一种),所以同一时间只能由一个线程使用特定的内存空间。为达到这个目的,需要使用 synchronized 关键字。其他类型的资源必须由程序员明确锁定,这通常要求程序员创建一个对象,用它代表一把锁,所有线程在访问那个资源时都必须检查这把锁。

1.9 Web端

  • 1、客户端/服务器 (CS架构)

    客户端/服务器系统的基本思想是我们能在一个统一的地方集中存放信息资源。一般将数据集中保存在某个数据库中,根据其他人或者机器请求将信息投递给对方。客户机/服务器概述的一个关键在于信息是"集中存放"的。所以我们能方便地更改信息,然后将修改过的信息发放给信息的消费者。将各种元素集中到一起,信息仓库、用于投递信息的软件以及软件所在的那台机器,它们联合起来便叫作"服务器"(Server)。面对那些驻留在远程机器上的软件,它们需要与服务器通信,取回信息,进行适当的处理,然后在远程机器上显示出来,这些就叫"客户端"(Client)。

    这里要注意的一个主要问题是单个服务器需要同时向多个客户提供服务。在这一机制中,通常少不了一套数据库管理系统,使设计人员能够将数据布局封装到表格中,以获得最优的使用。除此以外,系统经常运行客户将新信息插入一个服务器。这意味着必须确保客户的新数据不会与其他客户的新数据冲突。或者说需要保证那些数据在加入数据库的时候不会丢失("用数据库的术语来说,叫作事务处理")。客户软件发生了改变之后,它们必须在客户机器上构建、调试以及安装。性能的问题显得尤为重要:可能会有数百个客户同时向服务器发出请求。所以任何微小的延武都是不能忽视的。为尽可能缓解潜伏的问题,程序员需要谨慎的分散任务的处理负担。一般可以考虑让客户端分担部分处理任务,有时也可分派给服务器所在地的其他机器("中间件")。

  • Web是一个巨大的服务器

    Web实际就是一套规模巨大的客户端/服务器系统。但它的情况要更复杂一些,因为所有服务器和客户都同时存在于单个网络上面。我们没必要了解更进一步的细节,因为唯一要关心的就是一次建立同一个服务器的连接,并同它打交道(即使困难要在全世界的范围内搜索正确的服务器)。

    最开始的时候,这是一个简单的单向操作过程。我们向一个服务器发出请求,它向我们回传一个文件,由于本机的浏览器(或是客户端程序)负责解释和格式化,并将其展示出来。但人们不久就不满足于只从一个服务器传递网页。他们希望获得完全的客户端/服务器能力,使客户端也能反馈一些信息到服务器。比如希望对服务器上的数据进行检索,向服务器添加新信息。或者下一份订单等等(这也提供了比以前的系统更高的安全要求)。在Web 的发展过程中,我们可以很清晰地看出这些令人心喜的变化。

    Web 浏览器的发展终于迈出了重要的一步:某些信息可在任何类型的计算机上显示出来,毋需任何改动。

1.9.1 客户端编程

Web 最初采用的 "服务器—浏览器"方案可提供交互式内容,但这种交互能力完全由服务器提供,为服务器和因特网带来了不小的负担。服务器一般为客户浏览器产生静态网页,由后者简单地解释并显示出来。基本 HTML 语言提供了简单的数据收集机制:文字输入框、复选框、单选钮、列表以及下拉列表等,另外还有一个按钮,只能由程序规定重新设置表单中的数据,以便回传给服务器。用户提交的信息通过所有 Web 服务器均能支持的 "通用网关接口" (CGI)回传到服务器。包含在提交数据中的文字指示 CGI 该如何操作。

1.9.2 服务器端编程

如果向服务器发出一股请求,会发生什么事情?大多数时候的请求都是很简单的一个"把这个文件发给我"。浏览器随后会按适当的形式解释这个文件:作为 HTML 页面、一幅图、一个 Java 程序片、一个脚本程序等等。向服务器发出的较复杂的请求通常涉及到对一个数据库进行操作(事务处理)。其中最常见的就是发出一个数据库检索命令,得到结果后,服务器会把它格式化成 HTML 页,并作为结果传回来(当然,假如客户通过 Java 或者某种脚本语言具有了更高的智能,那么原始数据就能在客户端发送和格式化:这样做速度可以更快,也能减轻服务器的负担)。另外,有时需要在数据库中注册自己的名字(比如加入一个组时),或者向服务器发出一份订单,这就涉及到对那个数据库的修改。这类服务器请求必须通过服务器端的一些代码进行,我们称其为"服务器端的编程"。在传统意义上,服务器端编程是用 Perl 和 CGI 脚本进行的,但更复杂的系统已经出现。其中包括基于 Java 的 Web服务器,它允许我们用 Java 进行所有服务器端编程,写出的程序就叫作"小程序服务"(Servlet)。

1.10 不要迷失

在整个开发过程中,最重要的事情就是:不要将自己迷失!但事实上这种事情很容易发生。大多数方法都设计用来解决最大范围内的问题。当然,也存在一些特别困难的项目,需要作者付出更为艰辛的努力,或者付出更大的代价。但是,大多数项目都是比较"常规的",所以一般都能作出成功的分析与设计,而且只需用到推荐的一小部分方法。

也就是说,加入你正在考察一种特殊的方法,其中包含了大量的细节,并推荐了许多步骤和文档。时刻提醒自己注意以下几个问题:

  • 对象是什么?(怎样将自己的项目分割成一系列单独的组件?)
  • 它们的接口是什么?(需要将消息发给每一个对象?)

在确定了对象和它们的接口后,便可着手编写一个程序。出于对多方面原因的考虑,可能还需要比这更多的说明及文档,但掌握的资料绝对不能比这还少。

整个过程可划分为三个阶段:

  • 阶段 0:拟出一个计划

    第一步是决定在后面的过程中采取哪些步骤。这听起来似乎很简单(事实上,我们这儿说的一切都似乎很简单),但很常见的一种情况是:有些人甚至没有进入阶段 1,便忙忙慌慌地开始编写代码。如果你的计划本来就是“直接开始开始编码”,那样做当然也无可非议(若对自己要解决的问题已有很透彻的理解,便可考虑那样做)。但最低程度也应同意自己该有个计划。

    在这个阶段,可能要决定一些必要的附加处理结构。但非常不幸,有些程序员写程序时喜欢随心所欲,他们认为“该完成的时候自然会完成”。这样做刚开始可能不会有什么问题,但我觉得假如能在整个过程中设置几个标志,或者“路标”,将更有益于你集中注意力。这恐怕比单纯地为了“完成工作”而工作好得多。至少,在达到了一个又一个的目标,经过了一个接一个的路标以后,可对自己的进度有清晰的把握,干劲也会相应地提高,不会产生“路遥漫漫无期”的感觉。

  • 阶段 1:要制作什么?

    这个阶段称为“建立需求分析和系统规格”。需求分析的意思是“建立一系列规则,根据它判断任务什么时候完成,以及客户怎样才能满意”。系统规格则表示“这里是一些具体的说明,让你知道程序需要做什么(而不是怎样做)才能满足要求”。需求分析实际就是你和客户之间的一份合约(即使客户就在本公司内部工作,或者是其他对象及系统)。系统规格是对所面临问题的*别的一种揭示,我们依据它判断任务是否完成,以及需要花多长的时间。由于这些都需要取得参与者的一致同意,所以我建议尽可能地简化它们——最好采用列表和基本图表的形式—— 以节省时间。可能还会面临另一些限制,需要把它们扩充成为更大的文档。

    我们特别要注意将重点放在这一阶段的核心问题上,不要纠缠于细枝末节。这个核心问题就是:决定采用什么系统。对这个问题,最有价值的工具就是一个名为“使用条件”的集合。对那些采用“假如……,系统该怎样做?”形式的问题,这便是最有说服力的回答。例如,“假如客户需要提取一张现金支票,但当时又没 有这么多的现金储备,那么自动取款机该怎样反应?”对这个问题,“使用条件”可以指示自动取款机在那种“条件”下的正确操作。

    应尽可能总结出自己系统的一套完整的“使用条件”或者“应用场合”。一旦完成这个工作,就相当于摸清了想让系统完成的核心任务。由于将重点放在“使用条件”上,一个很好的效果就是它们总能让你放精力放在最关键的东西上,并防止自己分心于对完成任务关系不大的其他事情上面。也就是说,只要掌握了一套完整的“使用条件”,就可以对自己的系统作出清晰的描述,并转移到下一个阶段。在这一阶段,也有可能无法完全掌握系统日后的各种应用场合,但这也没有关系。只要肯花时间,所有问题都会自然而然暴露出来。不要过份在意系统规格的“完美”,否则也容易产生挫败感和焦燥情绪。

    在这一阶段,最好用几个简单的段落对自己的系统作出描述,然后围绕它们再进行扩充,添加一些“名词”和“动词”。“名词”自然成为对象,而“动词”自然成为要整合到对象接口中的“方法”。只要亲自试着做一做,就会发现这是多么有用的一个工具;有些时候,它能帮助你完成绝大多数的工作。尽管仍处在初级阶段,但这时的一些日程安排也可能会非常管用。我们现在对自己要构建的东西应该有了一个较全面的认识,所以可能已经感觉到了它大概会花多长的时间来完成。此时要考虑多方面的因素:如果估 计出一个较长的日程,那么公司也许决定不再继续下去;或者一名主管已经估算出了这个项目要花多长的时间,并会试着影响你的估计。但无论如何,最好从一开始就草拟出一份“诚实”的时间表,以后再进行一些暂时难以作出的决策。目前有许多技术可帮助我们计算出准确的日程安排(就象那些预测股票市场起落的技术),但通常最好的方法还是依赖自己的经验和直觉(不要忘记,直觉也要建立在经验上)。感觉一下大概需要花多长的时间,然后将这个时间加倍,再加上 10%。你的感觉可能是正确的;“也许”能在那个时间里 完成。但“加倍”使那个时间更加充裕,“10%”的时间则用于进行最后的推敲和深化。但同时也要对此向上级主管作出适当的解释,无论对方有什么抱怨和修改,只要明确地告诉他们:这样的一个日程安排,只是我的一个估计!

  • 阶段 2:如何构建?

    在这一阶段,必须拿出一套设计方案,并解释其中包含的各类对象在外观上是什么样子,以及相互间是如何沟通的。此时可考虑采用一种特殊的图表工具:“统一建模语言”(UML)。作为第1 阶段中的描述工具,UML 也是很有帮助的。此外,还可用它在第2 阶段中处理一些图表(如流程图)。当然并非一定要使用 UML,但它对你会很有帮助,特别是在希望描绘一张详尽的图表,让许多人在一起研究的时候。除 UML 外,还可选择对对象以及它们的接口进行文字化描述。

    作出了对对象以及它们的接口的说明后,就完成了第 2 阶段的工作。当然,这些工作可能并不完全。有些工作可能要等到进入阶段 3 才能得知。但这已经足够了。我们真正需要关心的是最终找出所有的对象。能早些发现当然好,但OOP 提供了足够完美的结构,以后再找出它们也不迟。

  • 阶段 3:开始创建

    读这本书的可能是程序员,现在进入的正是你可能最感兴趣的阶段。由于手头上有一个计划——无论它有多么简要,而且在正式编码前掌握了正确的设计结构,所以会发现接下去的工作比一开始就埋头写程序要简单得多。而这正是我们想达到的目的。让代码做到我们想做的事情,这是所有程序项目最终的目标。但切不要急功冒进,否则只有得不偿失。根据我的经验,最后先拿出一套较为全面的方案,使其尽可能设想周全,能 满足尽可能多的要求。给我的感觉,编程更象一门艺术,不能只是作为技术活来看待。所有付出最终都会得到回报。作为真正的程序员,这并非可有可无的一种素质。全面的思考、周密的准备、良好的构造不仅使程序更易构建与调试,也使其更易理解和维护,而那正是一套软件赢利的必要条件。 构建好系统,并令其运行起来后,必须进行实际检验,以前做的那些需求分析和系统规格便可派上用场了。全面地考察自己的程序,确定提出的所有要求均已满足。