1.4 隐藏的实现
应当将程序开发人员分成类创建者(负责创建新的数据类型)和客户端程序员(在编程中使用这些类的人)。客户端程序员的目标是收集足够多的类来进行快速程序开发。类创建者的目标则是设计一个类,并且提供客户端程序员所必需的部分而将其他部分隐藏起来。为何要这么做呢?因为如果将客户端程序员不需要知道的内容隐藏起来,他们就无法接触它,也就意味着,类创建者可以自行修改隐藏的内容而不用担心是否会对客户端程序员的使用造成影响。而且隐藏部分通常是一个对象中非常脆弱的部分,很容易被一些粗心或不知其细节的客户端程序员破坏,所以将实现隐藏起来可以减少程序的bug。(这里要清楚类创建者编写类,客户端程序员使用类来编写程序交给最终用户)
在任何关系中,关系所涉及到的每一部分之间都应当有着明确的界限,this is important。当你在构建一个类库(library)时,你便和客户端程序员建立起了联系,他们同样也是程序员,但他们是要利用你所构建的库来创建应用,没准儿是要构建一个更大的库,如果一个类的所有成员对每个人都是可见的,那么客户端程序员就可以对这个类想干什么就干什么。尽管你可能的确希望客户端程序员不要去操纵类里的一些成员,但如果没有访问控制(access control)的话你根本阻止不了这样的事情,你的类就像是在客户端程序员面前裸奔一样。
所以访问控制的首要目的就是让客户端程序员别碰他们不该碰的地方,也就是那些实现类内部操作的部分。这实际上也是为客户端程序员提供的一种服务,因为他们可以很容易的看出哪些东西很重要,哪些东西可以忽略。
其次,访问控制让类库的设计者在修改类的内部工作时无需关心这样的修改会对客户端程序员造成什么样的影响。举个栗子!你为了快速实现一个类,写了一些很简单的代码,而后来你又想改写这个类来让它运行得更快。如果接口和实现界限分明并且都受到了保护,那此举将变得易如反掌。
Java用了三个一看就懂的关键字来为类的内部设定界限:public,private和protected。这些访问标识符(access specifiers)决定了谁能够使用它们标识的内容。public所标识的元素可以被任何人使用。而private所标识的内容只能被类的创建者和类的内部方法所访问。private实现了类的创建者和客户端程序员之间的界限。如果他们想访问private成员,那就会得到一个编译错误。protected和private有点类似,不同之处在于继承类可以访问protected成员但不能访问private成员,关于继承(inheritance)我们一会儿再说。
如果你没有使用上面所说的三个访问标识符,那么Java就会实现一个默认(default)的访问权限。这个东西经常叫做包访问权限(package access),因为在同一个包(类库组件)中的类可以互相访问各个类中没有被访问标识符所标识的成员,而在这个包之外的这种无标识的成员对于它们来说则是private的。
1.5 重用实现
理想情况下,在一个类完成了创建和测试之后,它应该展现出它的优良品质。事实证明可重用性(reusability)并不像人们想象的那样轻而易举的就能体现出来,为了设计出一个可重用的对象需要一定的经验和洞察力。当你设计好这样一个类之后,它就可被重用。代码的可重用性是OOP的又一大亮点。这里要注意区分可重用性和OOP的五条基本性质中最后一条提到的可替代性,而可重用性实际上是体现了基本性质中的第二条和第三条。
重用一个类最简单的方式就是直接用它的对象,或者把这个对象加入到一个新的类里。后一种方法我们称作“创建一个成员对象”,为了实现你需要的功能,你可以以各种方式将其他类的对象组装到你所创建的新类中。你通过已经存在的类来构建新的类,这个概念被称作组合(composition),如果这一过程是动态实现的,那么就叫做聚合(aggregation)。“组合”通常被称作“has-a”关系,比如一辆汽车有一个(has a)发动机。注意,虽然这里只提到了“has-a”关系,但这只解释了本段第一句话中提到的两种方法的后一种方法,而前一种方法说的其实就是直接调用可重用对象,即“use-a”关系。
![](http://img1.51cto.com/album/2185559/131737142683.jpg)
组合带来了巨大的灵活性。一般情况下新类中的成员对象都写成private的,使客户端程序员无法访问它们。这样你在修改这些成员时就不会影响到客户端代码。你甚至可以在运行时改变成员对象,从而动态的改变程序的行为。下面将要介绍的继承就没有如此大的灵活性了,因为在编译时,编译器要对通过继承创建的类加以限制。
继承这一概念在面向对象程序设计中非常重要,通常会被着重强调,以至于新手们会觉得这种思想应该用在任何地方,而这会导致过多复杂的设计并且让创建出的类难以使用。所以你在创建一个新的类时首先应想到的是组合,因为它更简单而且更灵活。这样下去,你的设计就会变得清新了,当你有了一些经验之后,你就会知道啥时候用继承,啥时候用组合了。
1.6 继承
对象被认为是一个快捷工具,它允许你根据“概念”将数据和功能打包到一起,从而体现出对于“问题域”的一些合适的想法,而不是被迫使用底层机器的语法。Java中通过使用class关键字将这些“概念”视为程序的基本单元。
看上去有些遗憾,因为当你创建完一个类,又被迫创建另一个新的但却和已经创建好的类有着相似功能的类,你会觉得很痛苦。是啊!如果我们能把已经存在的类复制一下,做一些添加和修改,那样就会轻松好多啊!别担心,”继承“来啦!它就是干这个的!继承还带来一个额外的影响,就是当原始类发生变化之后,复制品也会反映出这些变化。原始类被称作基类(base class)、超类(superclass)或父类(parent class),其复制品被称作派生类(derived class)、继承类(inherited class)、次类(subclass)或子类(child class)。
![](http://img1.51cto.com/album/2185559/131737142803.jpg)
一个类型不光描述了对象集合的约束条件,还和其他类型存在着联系。两种类型可以有共同的特性和行为,其中一个类型或许有着更多的特性并能处理更多的消息或以不同的方式处理消息。“继承”通过使用“基类”和“派生类”这样的概念体现出了类与类之间的这种相似性。所有派生于同一基类的类型共享着基类所拥有的特性和行为。你创建一个基类来体现系统中一些对象的核心思想,然后从这个基类派生出其他的类型来表达这一核心内容的不同实现方式。
举个栗子!有这么一种垃圾回收机器,它能将垃圾分类。假设基类就是“垃圾”,每一种垃圾都有重量、价值以及一些其他的属性,能被撕成碎片,能被熔解、分解。细数的话就会发现,每一种派生出的垃圾都有额外的特性和行为(比如瓶子有颜色、易拉罐能被压扁、钢铁有磁性)。而且它们的行为还都会有不同,比如纸张的价值取决于它的品质和完好程度。通过继承,你可以建立一个类的层次结构,用它来表现出你正在解决的是一个怎样的问题。
再举个栗子!拿上文中说到的典型的“几何形”来说,这或许在一个计算机辅助设计系统或模拟游戏中很有用。“几何形”是基类,有大小、颜色、位置属性,有可被绘制、被擦除、移动、上色等行为。同上例,每一种派生(或继承)于“几何形“的类型,圆形、正方形、三角形等等,都有额外的属性和行为,比如有的几何形可以被翻滚,当你计算几何形面积时又有不同的行为(计算圆形面积和三角形面积的方法当然不一样)。类的层次结构体现出各种几何形之间的相似和不同。
![](http://img1.51cto.com/album/2185559/131737143092.jpg)
将解决方案和所遇到的问题置于同一位置是有益的,从对问题的描述过渡到对解决方案的描述,不许要许多中间模型。使用对象的观点,类层次便是主要模型,有了类层次,就能直接从对于真实世界中系统的描述过渡到代码世界中对系统的描述。的确,人们在进行面向对象程序设计时遇到的难处之一就是它太过轻而易举的就将现实系统描述为代码。善于思考复杂问题的大脑往往会倒在这种简单的方式面前。
当你从一个已有的类实现一个继承时,也就创建了一个新的类。这个新的类不光包含基类的所有成员(private成员自然是被隐藏起来无法访问),更重要的是它还复制了基类的接口。这也就是说,基类能收到什么样的消息,派生类就也能。因为我们通过一个类所能接收怎样的消息来识别这个类,那这就意味着派生类和基类是同一种类型。前文中书“一个圆是一个几何形”,理解这种通过继承实现的类与类之间的等价性是理解面向对象程序设计的一个基本方法。
因为基类有什么样的接口派生类就有什么样的接口,所以派生类中肯定也应该有基类接口的实现。就是说在接收一个消息之后应该有一些代码会被执行。如果你仅仅继承了一个类而别的啥也不干,那基类接口中的方法就自然而然的掉到了派生了中,这就意味着派生类和基类不光类型相同,连行为也相同了,这可就太没劲了,你说是吧,要它还干啥呀,你说是吧,嗯?
好!重点到了!你有两种方式让你的派生类和基类变得不一样。第一种方式很直接,就是在派生类中增加新的方法,那么这些新的方法就不是基类接口中的方法了,当然你这样做的原因肯定是因为基类能做的满足不了你的需求,所以你添加了新的方法。通过这种简洁明了的方式体现继承有时会成为解决问题的完美方案。然而,你也应当仔细观察,看看你的基类是否也需要你新添加的这些方法。这种重复发现的过程会经常发生在面向对象程序设计中。
![](http://img1.51cto.com/album/2185559/131737143369.jpg)
尽管继承有时会意味着你要添加新的方法,但并不总是这样(特别是在Java中,实现继承的关键字是extends)。让派生类有别于基类的第二种也是更重要的一种方式是改变基类中方法的行为。术语叫“重写”或“覆盖”(overriding)。
![](http://img1.51cto.com/album/2185559/131737143525.jpg)
重写或覆盖一个方法其实就是为派生类中的方法创建新的定义,也就是编写新的行为。假设你现在的名字叫“派生类”,你敲开“接口”家的大门,跟他说“接口,我要用你提供的方法,但是我想让他替我干点别的。”
1.6.1 “is-a”与“is-like-a”
关于继承,有这样一种争论:继承是不是只能重写基类中的方法而不能添加基类中没有的方法呢?这是说由于派生类和基类有相同的接口所以它们就完全是同一个类型了。导致你可以用一个派生类的对象去替换一个基类的对象。这种替换被认为是完全替换(pure substitution),常被成为“替代原则”(substitution principle)。某种程度上说这也是看待继承的理想方式。这时我们经常把基类和派生类之间的关系称为“is-a”关系,因为你看“一个圆形是一个(is a)几何形”。看类和类之间是不是继承关系可以看你是否能描述出它们之间的“is-a”关系,并让其具有实际意义。
有时你需要在派生类中添加新的元素。这个派生类仍可替换基类,但由于派生类中新添加进的方法是基类中没有的,所以这种替换并不像上一种那么完美。这种类和类之间的关系可以描述为“is-like-a”关系(这术语是我自己发明的)。派生类享有基类的接口,但它却也包含着其他的方法,所以不能说它们完全相同。举个栗子!拿空调来说。假设你的房子已经布好了制冷系统的控制线路,也就是为你提供了操控制冷的接口,如果你的空调毁了,你弄来个热力泵,就是既可以加热又可以降温的那种。那么这个热力泵就is-like-a空调,但热力泵能加热空调却不能。由于你房子的控制系统是专为冷气设备设计的,所以对于这个新来的对象,制冷系统只能和热力泵制冷的部分进行交流,虽然热力泵能加热,但制冷系统却丝毫不知。
![](http://img1.51cto.com/album/2185559/131737143878.jpg)
当然,当你看到这样的设计之后肯定会觉得“制冷系统”这个基类不够通用,应该叫“温度控制系统”,这样就既能加热又能降温了,这样便可以套用替代原则。这张图就说明了在现实世界中进行设计时会出现的情况。
当你看到替代原则时会很容易的觉得这是做事情的唯一方法(pure substitution),如果你的设计也是这样工作的,那就更炫了。但你也会发现有的时候有着同样明显的信号提示你必须要在派生类中添加新的方法。所以你要仔细观察,这样两种方法的使用场合就能分辨的清了。
1.7 通过多态实现对象的代替
当你在处理一个类的层次结构时,有时可能想把它当成它的基类来处理。这种方式允许你编写一些通用的代码,而不局限于某种特定的类型。举个栗子!还拿“几何形”来说,操纵所有几何形的通用方法并不会去关心这个几何形是圆啊,正方形啊,三角形啊,或者是什么还没定义的几何形。所有几何形都可被绘制、擦除、移动,所以这些方法仅仅就是向一个几何形对象传递一个消息,而不去考虑这个对象会如何去处理它。
这样的代码不会由于添加新的类型而受到影响,而添加新的类型也是最常用的扩展一个面向对象程序的方式,从而解决新的问题。举个栗子!你可以从“几何形”派生出一个“五角形”而不修改那些只负责处理一般几何形的方法。通过派生新的子类来扩展设计,是对改动进行封装的基本方式之一。这种方式极大的改善了设计,降低了软件维护成本。
然而,在当我们试图将派生类对象当作它们的基类看待时,就会出现一个问题(“圆”看成“几何形”,“自行车”看成“交通工具”,“海鸟”看成“鸟”,等等)。如果一个方法要绘制一个“几何形”,或者启动一个“交通工具”,亦或是移动一只“鸟”,编译器在编译时并不知道到底会执行哪些代码。这是亮点,当消息发出后,程序员并不“希望”知道哪些代码会执行。绘制方法可以应用在“圆”、“正方形”、“三角形”,对象会根据它的特定类型来运行相应的代码。
如果你不是必须确定到底应当执行哪些代码,那么当你添加一个新的子类后,无需改变调用它的方法就能执行不同的代码。因此编译器不会知道哪些代码被执行了,那么,这一切究竟是如何发生的的呢?举个栗子!在下面这个表中,BirdController类的对象调用泛型类Bird的对象,但BirdController并不知道它们的具体类型。从BirdController的角度看,这是很有益的,因为这样就不用编写特殊的代码去判断到底是在调用哪种Bird以及Bird的行为是怎样的。那么到底是怎样实现调用了move()方法之后,不管Bird的具体类型是什么都能执行正确的代码呢(调用move()之后Goose会走,飞,还是游,Penguin会走还是游)?
![](http://img1.51cto.com/album/2185559/131737144046.jpg)
问题的答案正是面向对象程序设计时的一个难点:编译器并不会执行传统意义上的函数调用。在非OOP编译器中,我们将函数调用称作“预绑定”(early binding),可能你没听过这个词儿,那是应为你可能觉得不会有其他的函数调用方式。它的意思是说,编译器生成一个函数调用后,运行时系统解析出所要执行的代码的地址。在OOP中,程序在运行之前无法确定代码的地址,所以一个消息传给一个泛型对象时,必须采用其他的应对机制。
为了解决这个问题,面向对象语言采用了“后绑定”(late binding)的概念。当你向一个对象发送一条消息,直到运行时才能决定执行哪些代码。编译器能够保证相应的方法确实存在,并能对参数和返回值进行类型检查,但却不知到要执行哪些代码。
为了实现“后绑定”,Java采用了一小段特殊的代码来替代“预绑定”。这段代码能够根据对象内部存储的信息来计算出方法体的地址(这个过程会在“泛型”那一章中详细介绍)。因此,每一个对象会根据这一小段代码产生不同的行为。当你向一个对象传递一个消息,这个对象便在真正意义上推断出了要如何处理这个消息。
在一些语言中,你必须显示声明你要让一个方法采用“后绑定”机制(C++中用virtual关键字来实现)。在这些语言中,默认情况下的方法不是动态绑定的。而在Java中,动态绑定是默认的行为,你不必为了实现多态而去记忆一些其他的关键字。
再来看“几何形”那个例子。它们这一大家子已经在前面的例子中画出来了(所有的类都基于同样的接口)。为了展示一下多态性,我们想编写一段独立的代码,它不关心具体类型,直接和基类交流。由于这段代码不用关心与具体类型有关的信息,因此编写容易,理解起来也非常轻松。如果一个新的类型,比如说Hexagon(六边形),添加到了这个层次结构中,咱们已经编写的那段代码会象处理那些其他类型一样很好的应对这个新的Shape(几何形)。因此,整个程序就成了“可扩展的”(extensible)。
比如写一段这样的Java代码(你很快就会学到如何编写):
void doSomething(Shape shape)
{
shape.erase();
// ...
shape.draw();
}
这个方法可以和任何Shape交互,所以它独立于具体的类型来进行绘制和擦除。如果在程序的某个地方调用这个方法:
Circle circle = new Circle();
Triangle triangle = new Triangle();
Line line = new Line();
doSomething(circle);
doSomething(triangle);
doSomething(line);
doSomething()将会正确处理,而不管对象的具体类型。
这是个很有意思的地方,看这行代码:
doSomething(circle);
一个Circle进入了一个方法,而这个方法想要的是一个(is a)Shape。因为一个Circle是一个(is a)Shape,所以doSomething()也就认为它是一个(is a)Shape。因为doSomething()传递给Shape的所有信息都能被Circle接收。所以这样的行为是安全的并且符合逻辑的。
我们把这种将一个派生类看成是基类的过程称为“向上转型”(Upcasting)。使用“转型"(cast)是因为这个过程类似于模型铸造,“向上”(up)是根据继承层次结构图的方向提出的:基类在上,派生类呈扇形向下展开。因此,向基类转型就是将视角顺着继承层次结构图向上看:即“向上转型”。(如下图)
![](http://img1.51cto.com/album/2185559/131737144283.jpg)
一个面向对象程序中肯定会包含某种向上转型,因为你在设计时会遇到不用知道确切类型的情况,而“向上转型”正是解决这种问题的方式。再来看doSomething()里的代码:
shape.erase();
// ...
shape.draw();
注意这里并没有说“如果是一个圆,应该......这样做,如果是一个正方形,应该......这样做,等等。”如果你是这样写代码的话,就要检查Shape所有可能的实际类型,这样就太复杂了,而且每当你添加一种新的Shape时,你就要修改这里的代码,来判断如果是这种类型的Shape该怎么做。所以,在上面的代码中,你只需表达这样的意思:你是个Shape,我知道你能erase()和draw(),干吧,注意细节就行了。
我想doSomething()最让人印象深刻的地方就是,不知为何,它总是执行了正确的功能,做了该做的。调用Circle的draw()执行的代码和调用Square或Line的draw()执行的代码是不同的,当消息“draw()”发送给一个匿名的Shape时,总是会根据这个Shape的实际类型来产生正确的行为。太牛逼了太牛逼了!正如前面提到的,Java编译器在编译doSomething()的这段代码时,并不知道它要处理的确切类型。所以你觉得它可能会执行Shape的erase()和draw(),而不是Circle、Square或Line的。正是有了“多态性”才保证了总能发生正确的事。编译器和运行时系统负责处理细节,你需要知道的就是“它真的发生了!它就是发生了!不管别人信不信,反正你要信!”,更重要的是你需要知道如何利用“多态”来进行设计。当你向一个对象传送一条消息,即便发生了“向上转型”,对象也会知道what is correct。