“类”,那个类具有自己的通用特征与行为。
因此,在面向对象的程序设计中,尽管我们真正要做的是新建各种各样的数据“类型”(Type),但几乎所有面向对象的程序设计语言都采用了“class”关键字。当您看到“type”这个字的时候,请同时想到
“class”;反之亦然。
建好一个类后,可根据情况生成许多对象。随后,可将那些对象作为要解决问题中存在的元素进行处理。事实上,当我们进行面向对象的程序设计时,面临的最大一项挑战性就是:如何在“问题空间”(问题实际存在的地方)的元素与“方案空间”(对实际问题进行建模的地方,如计算机)的元素之间建立理想的“一对一”对应或映射关系。
如何利用对象完成真正有用的工作呢?
必须有一种办法能向对象发出请求,令其做一些实际的事情,比如完成一次交易、在屏幕上画一些东西或者打开一个开关等等。每个对象仅能接受特定的请求。我们向对象发出的请求是通过它的“接口”(Interface)定义的,对象的“类型”或“类”则规定了它的接口形式。“类型”与“接口”的等价或对应关系是面向对象程序设计的基础。
在这个例子中,类型/类的名称是 Light,可向 Light 对象发出的请求包括包括打开(on)、关闭(off)、变得更明亮(brighten)或者变得更暗淡(dim)。通过简单地声明一个名字(lt),我们为 Light 对象创建了一个“句柄”。然后用new 关键字新建类型为 Light 的一个对象。再用等号将其赋给句柄。为了向对象发送一条消息,我们列出句柄名(lt),再用一个句点符号(.)把它同消息名称(on)连接起来。从中可以看出,使用一些预先定义好的类时,我们在程序里采用的代码是非常简单和直观的。
Java 采用三个显式(明确)关键字以及一个隐式(暗示)关键字来设置类边界:public,private,
protected 以及暗示性的friendly。若未明确指定其他关键字,则默认为后者。这些关键字的使用和含义都是相当直观的,它们决定了谁能使用后续的定义内容。“public”(公共)意味着后续的定义任何人均可使用。而在另一方面,“private”(私有)意味着除您自己、类型的创建者以及那个类型的内部函数成员,其他任何人都不能访问后续的定义信息。private 在您与客户程序员之间竖起了一堵墙。若有人试图访问私有
成员,就会得到一个编译期错误。“friendly”(友好的)涉及“包装”或“封装”(Package)的概念——即Java 用来构建库的方法。若某样东西是“友好的”,意味着它只能在这个包装的范围内使用(所以这一访问级别有时也叫作“包装访问”)。“protected”(受保护的)与“private”相似,只是一个继承的类可访问受保护的成员,但不能访问私有成员。继承的问题不久就要谈到。
这个函数可与任何“几何形状”(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 class 中继承)。
1 .7.1 集合与继承器
针对一个特定问题的解决,如果事先不知道需要多少个对象,或者它们的持续时间有多长,那么也不知道如何保存那些对象。既然如此,怎样才能知道那些对象要求多少空间呢?事先上根本无法提前知道,除非进入运行期。
在面向对象的设计中,大多数问题的解决办法似乎都有些轻率——只是简单地创建另一种类型的对象。用于解决特定问题的新型对象容纳了指向其他对象的句柄。当然,也可以用数组来做同样的事情,那是大多数语言都具有的一种功能。但不能只看到这一点。这种新对象通常叫作“集合”(亦叫作一个“容器”,但 AWT在不同的场合应用了这个术语,所以本书将一直沿用“集合”的称呼。在需要的时候,集合会自动扩充自
己,以便适应我们在其中置入的任何东西。所以我们事先不必知道要在一个集合里容下多少东西。只需创建一个集合,以后的工作让它自己负责好了。
幸运的是,设计优良的 OOP 语言都配套提供了一系列集合。在 C++中,它们是以“标准模板库”(STL)的形式提供的。Object Pascal 用自己的“可视组件库”(VCL)提供集合。Smalltalk 提供了一套非常完整的集合。而 Java 也用自己的标准库提供了集合。在某些库中,一个常规集合便可满足人们的大多数要求;而在另
一些库中(特别是 C++的库),则面向不同的需求提供了不同类型的集合。例如,可以用一个矢量统一对所有元素的访问方式;一个链接列表则用于保证所有元素的插入统一。所以我们能根据自己的需要选择适当的类型。其中包括集、队列、散列表、树、堆栈等等。
所有集合都提供了相应的读写功能。将某样东西置入集合时,采用的方式是十分明显的。有一个叫作“推”
(Push)、“添加”(Add)或其他类似名字的函数用于做这件事情。但将数据从集合中取出的时候,方式却
并不总是那么明显。如果是一个数组形式的实体,比如一个矢量(Vector),那么也许能用索引运算符或函数。但在许多情况下,这样做往往会无功而返。此外,单选定函数的功能是非常有限的。如果想对集合中的一系列元素进行操纵或比较,而不是仅仅面向一个,这时又该怎么办呢?
办法就是使用一个“继续器”(Iterator),它属于一种对象,负责选择集合内的元素,并把它们提供给继承器的用户。作为一个类,它也提供了一级抽象。利用这一级抽象,可将集合细节与用于访问那个集合的代码隔离开。通过继承器的作用,集合被抽象成一个简单的序列。继承器允许我们遍历那个序列,同时毋需关心基础结构是什么——换言之,不管它是一个矢量、一个链接列表、一个堆栈,还是其他什么东西。这样一来,我们就可以灵活地改变基础数据,不会对程序里的代码造成干扰。Java 最开始(在 1.0 和 1.1 版中)提供的是一个标准继承器,名为 Enumeration(枚举),为它的所有集合类提供服务。Java 1.2 新增一个更复杂的集合库,其中包含了一个名为 Iterator 的继承器,可以做比老式的Enumeration 更多的事情。
从设计角度出发,我们需要的是一个全功能的序列。通过对它的操纵,应该能解决自己的问题。如果一种类型的序列即可满足我们的所有要求,那么完全没有必要再换用不同的类型。有两方面的原因促使我们需要对集合作出选择。首先,集合提供了不同的接口类型以及外部行为。堆栈的接口与行为与队列的不同,而队列的接口与行为又与一个集(Set)或列表的不同。利用这个特征,我们解决问题时便有更大的灵活性。
其次,不同的集合在进行特定操作时往往有不同的效率。最好的例子便是矢量(Vector)和列表(List)的区别。它们都属于简单的序列,拥有完全一致的接口和外部行为。但在执行一些特定的任务时,需要的开销却是完全不同的。对矢量内的元素进行的随机访问(存取)是一种常时操作;无论我们选择的选择是什么,
需要的时间量都是相同的。但在一个链接列表中,若想到处移动,并随机挑选一个元素,就需付出“惨重”的代价。而且假设某个元素位于列表较远的地方,找到它所需的时间也会长许多。但在另一方面,如果想在序列中部插入一个元素,用列表就比用矢量划算得多。这些以及其他操作都有不同的执行效率,具体取决于序列的基础结构是什么。在设计阶段,我们可以先从一个列表开始。最后调整性能的时候,再根据情况把它换成矢量。由于抽象是通过继承器进行的,所以能在两者方便地切换,对代码的影响则显得微不足道。
最后,记住集合只是一个用来放置对象的储藏所。如果那个储藏所能满足我们的所有需要,就完全没必要关心它具体是如何实现的(这是大多数类型对象的一个基本概念)。如果在一个编程环境中工作,它由于其他因素(比如在Windows 下运行,或者由垃圾收集器带来了开销)产生了内在的开销,那么矢量和链接列表之间在系统开销上的差异就或许不是一个大问题。我们可能只需要一种类型的序列。甚至可以想象有一个“完美”的集合抽象,它能根据自己的使用方式自动改变基层的实现方式。