面向对象简述

编程语言是用于创建应用程序的思维模式。语言本身可以从写作、绘画、雕塑、动画、电影制作等表达方式中获取灵感,而面向对象编程(Object-Oriented Programming, OOP)则是用计算机作为表达媒介的一种尝试。

文章大部分内容来自《OnJava8》和王争老师的《设计模式之美》,有兴趣可以阅读原文~

抽象

所有编程语言都是一种抽象。甚至可以说,我们能够解决的问题的复杂程度直接取决于抽象的类型和质量。这里提到的“类型”的含义是“你要抽象的是什么”。比如,汇编语言是对计算机底层的一个极简化的抽象。还有许多所谓的命令式编程语言(比如FORTRAN、BASIC和C语言等)都是各自对汇编语言的抽象。虽然这些语言已经取得了长足的进步,但它们主要的抽象方式依然要求你根据计算机的结构而非问题的结构来思考。于是,程序员必须在机器模型(也叫作“解决方案空间”,即实际解决问题的方式,比如计算机)和实际解决的问题模型(也叫作“问题空间”,即问题实际存在之处,比如来源于某个业务)之间建立关联。建立这种关联需要耗费很大的精力,而且它是与编程语言无关的,这一切都导致程序难以编写且不易维护。

构建机器模型的一种代替方案是针对需要解决的问题构建问题模型。早期的一些编程语言(比如LISP和APL)会采取特定的视角看待周遭问题(例如,“所有问题最终都可以用列表呈现”或者“所有问题都是算法问题”),Prolog语言则会将所有问题都转换为决策链。这些语言要么是基于约束性的编程语言,要么是专门用来操作图形符号的编程语言。这些编程语言都能够出色地解决一些特定的问题,因为它们正是为此而生的。然而,一旦遇到它们专属领域以外的问题,它们就显得无能为力了。

面向对象编程则更进一步,它为程序员提供了一些能够呈现问题空间元素的工具。这种呈现方式具备足够的通用性,使得程序员不再局限于特定的问题。而这些问题空间中的元素及其解决方案空间中的具体呈现,我们称其为“对象”(需要注意的是,有些对象并不支持问题空间的类比)。其背后的理念则是,通过添加各种新的对象,程序可以将自己改编为一种描述问题的语言。于是,你阅读的既是解决方案的代码,也是表述问题的文字。这种灵活且强大的语言抽象能力是前所未有的。因此,面向对象编程描述问题的依据是实际的问题,而非用于执行解决方案的计算机。不过,它们之间依然存在联系,这是因为从某种意义上来说,对象也类似于一台小型计算机——每一个对象都具有状态,并且可以执行一些特定的操作。这一特点与现实中的事物极为相似,它们都具有各自的行为和特征。

面向对象这种描述问题,呈现问题空间的抽象方式也带来了面向对象最大的问题,如何找到和构建需要的对象?如何将现实世界和抽象的对象联系起来?对象之间如何协作?等问题。

SmallTalk是历史上第一门获得成功的面向对象语言,并且为后续出现的Java语言提供了灵感。Alan Kay总结了SmallTalk语言的5个基本特征,这些特征代表了纯粹的面向对象编程的方式。

  1. **万物皆对象。**你可以把对象想象为一种神奇的变量,它可以存储数据,同时你可以“发出请求”,让它执行一些操作。对于你想要解决的问题中的任何元素,你都可以在程序中用对象来呈现(比如狗、建筑、服务等)。
  2. **一段程序实际上就是多个对象通过发送消息来通知彼此要干什么。**当你向一个对象“发送消息”时,实际情况是你发送了一个请求去调用该对象的某个方法。
  3. **从内存角度而言,每一个对象都是由其他更为基础的对象组成的。**换句话说,通过将现有的几个对象打包在一起,你就创建了一种新的对象。这种做法展现了对象的简单性,同时隐藏了程序的复杂性。
  4. 每一个对象都有类型。具体而言,每一个对象都是通过某个生成的实例,这里说的“类”就(几乎)等同于“类型”。一个类最为显著的特性是“你可以发送什么消息给它”。
  5. 同一类型的对象可以接收相同的消息。稍后你就会意识到这句话的丰富含义。举例来说,因为一个“圆形”对象同样也是一个“形状”对象,所以“圆形”也可以接收“形状”类型的消息。这就意味着,你为“形状”对象编写的代码自然可以适用于任何的“形状”子类对象。这种可替换性是面向对象编程的一个基石。

Grady Booch对对象做了一种更为简洁的描述:

对象具有状态、行为及标识。

这意味着对象可以拥有属于自己的内部数据(赋予其状态)、方法(用于产生行为),同时每一个对象都有别于其他对象。也就是说,每一个对象在内存中都有唯一的地址。

类和对象

创建抽象数据类型(即“类”)是面向对象编程的一个基本概念。抽象数据类型的工作原理和内置类型几乎一样:你可以创建某种类型的变量(在面向对象领域,这些变量叫作“对象”或“实例”),随后你就可以操作这些变量(叫作“发送消息”或“发送请求”,即你发送指令给对象,然后对象自行决定怎么处理)。同一类型的所有成员(或元素)都具有一些共性,比如:每一个账户都有余额,每一位出纳员都能处理存款业务。同时,每一个成员都具有自己的专属状态,比如:每一个账户的余额都是不同的,每一位出纳员都有名字。因此,对于所有这些成员,包括每一位出纳员、每一位顾客、每一个账户,以及每一笔交易等,我们都能够在程序中用一个唯一的实体来表示。这种实体就是对象,同时每一个对象所归属的类决定了对象具有何种行为特征。

虽然我们在面向对象编程中会创建新的数据类型,但实际上所有面向对象编程语言都会使用class这个关键字。所以当你看到“类型”(type)这个词的时候,请第一时间想到“类”(class),反之亦然。有时候我们会将两者加以区分,将类型(type)定义为接口,而类(class)则是接口的具体实现。

因为类描述了一系列具有相同特征(即数据元素)和行为(即功能方法)的对象,而即便是浮点数这种内置数据类型也具有一系列的行为和特征,所以类其实就是数据类型。抽象数据类型和内置数据类型的区别是,程序员可以通过定义一个新的类来解决问题,而非受限于已有的数据类型。这些已有的数据类型其设计本意是为了呈现机器内的存储单元,你可以根据实际的需求创建新的数据类型,同时扩展编程语言的能力。此外,编程系统对于新的类十分友好,比如也会为新的类提供类型检查等功能,就像对待内置数据类型一样。

面向对象编程的作用并不局限于模拟。无论你是否同意“任何程序都是对系统的一种模拟”,面向对象编程技巧都可以帮你将众多复杂的问题简化。

一旦创建了一个类,就可以用它创建任意多个对象,然后在操作这些对象时,可以把它们视为存在于问题空间的元素。实话实说,面向对象编程的一大挑战就是,如何在问题空间的元素和解决方案空间的对象之间建立一对一的关联。

使用对象提供的接口与对象进行交互

那么,如何能让一个对象真正发挥其作用呢?答案是向对象发送请求,比如让它完成一次交易、在屏幕上画个图形或者打开一个开关等。对象能够接受什么请求,是由它的“接口”(interface)决定的,而对象所归属的类定义了这些接口。

img
Light lt = new Light();
lt.on();

图1-1中的接口定义了你能够向这个对象发送的请求。此外,也必然存在一些代码用于响应这些请求。这些代码再加上隐藏的数据,叫作“实现”(implementation)。对于每一个请求,类都有一个方法与之对应。当你向一个对象发送特定的请求时,对应的方法就会被调用。我们通常会这样描述该过程:向对象“发送消息”(即发出请求),然后由对象决定如何处理(即运行对应的代码)。

在上面的例子中,类的名字是LightLight所生成的对象的名字是lt,我们能够对Light对象发出的请求是开灯(on())、关灯(off())、灯光变亮(brighten())以及灯光变暗(dim())。通过定义一个“引用”即lt,以及用new关键字生成一个新对象,我们就创建了一个Light对象。此外,如果你需要向对象发送消息,可以用一个英文句号(.)将对象名和请求(即方法)连接起来。如果我们只是使用内置类,那么基本上关于对象编程的内容就是以上了。

对象是服务的提供者

当你开发一个面向对象程序或理解其设计时,一个上佳的方法是将对象想象成“服务提供者”。你的程序本身也是为用户提供服务的,它通过使用其他对象提供的服务来做到这一点。所以,你的任务是创建(更好的情况是,从已有的库中找到)一些提供对应服务以解决问题的对象。

可以先从一个问题开始:“如果我能从魔术帽里变出一些对象,究竟什么对象才能解决我的问题呢?”比如,你要创建一个记账系统,于是你可能会需要一些预设的输入页面对象、负责计算的对象,以及连接各种打印机以打印支票和发票的对象。其中有些对象也许已经存在,那么其他不存在的对象应该是什么样的呢?它们应该提供哪些服务,同时它们还需要哪些其他对象的支持呢?如果继续深入的话,到了最后,你要么会说“编写这个对象的代码应该很简单”,要么会说“我确信这个对象早已存在”。这种将问题拆解为一系列对象的方法确实行之有效。

把对象视为服务提供商还有一个额外的好处,即提升了对象的聚合程度。说到这里,就需要提到软件设计领域中一个体现基础品质的术语——“高内聚性”(high cohesion),这指的是设计的组件(比如对象、方法或者对象库等)无论从哪个方面看都整合得很好。人们在设计对象时很容易犯的一个错误就是为对象添加太多的功能。例如,在一个打印支票的程序里,你一开始可能会认为需要一个既能排版又能打印的对象。然后,你发现这些功能对于一个对象而言太多了,其实你需要3个或者更多对象来负责这些功能。比如,一个对象包含了所有可能的打印布局,通过查找它可以知道如何打印一张支票。另一个或一组对象则作为通用打印接口,负责连接所有不同型号的打印机(但不负责记账,也许你需要购买该功能而非自行创建)。还有一个对象负责整合前两个对象提供的服务以完成打印任务。因此,每一个对象都提供了一种配套服务。在面向对象领域,出色的设计往往意味着一个对象只做好一件事,绝不贪多。这条原则不只适用于那些从外部购买的对象(比如打印接口对象),也适用于那些可复用的对象(比如支票排版对象)。

把对象视为服务提供商,不仅对你设计对象的过程有所帮助,也有利于他人阅读你的代码或复用这些对象。换句话说,如果别人因为对象提供的服务而认识到它的价值,那么他就会更加轻松地在自己的设计中使用这个对象。

封装

我们可以把程序员划分为两大阵营:一是“类的创建者”(负责创建新数据类型的人),二是“客户程序员”(在自己的应用程序里使用现有数据类型的人)。客户程序员的诉求是收集一个装满了各种类的工具箱,以便自己能够快速开发应用程序。而类的创建者则负责在创建新的类时,只暴露必要的接口给客户程序员,同时隐藏其他所有不必要的信息。为什么要这么做呢?这是因为,如果这些信息对于客户程序员而言是不可见的,那么类的创建者就可以任意修改隐藏的信息,而无须担心对其他任何人造成影响。隐藏的代码通常代表着一个对象内部脆弱的部分,如果轻易暴露给粗心或经验不足的客户程序员,就可能在顷刻之间被破坏殆尽。所以,隐藏代码的具体实现可以有效减少程序bug。

所有的关系都需要被相关各方一致遵守的边界。当你创建了一个库,那么你就和使用它的客户程序员建立了一种关系。该客户程序员通过使用你的代码来构建一个应用,也可能将其用于构建成一个更大的库。如果一个类的所有成员都对所有人可见,那么客户程序员就可以恣意妄为,而且我们无法强制他遵守规定。也许你的预期是客户程序员不会直接操作任何类的成员,但是如果没有访问控制的话,你就无法实现这一点,因为所有的一切都暴露在对方面前了。

所以我们设置访问控制的首要原因就是防止客户程序员接触到他们本不该触碰的内容,即那些用于数据类型内部运转的代码,而非那些用于解决特定问题的接口部分。这种做法实际上为客户程序员提供了一种服务,因为他们很容易就可以知道哪些信息对他们来说是重要的,哪些则是无须关心的(请注意这也是一个富有哲理的决策。比如有些编程语言认为,如果程序员希望访问底层信息,就应该允许他们访问)。

**设置访问控制的第二个原因则是,让库的设计者在改变类的内部工作机制时,不用担心影响到使用该类的客户程序员。**例如,你为了开发方便而创建了一个简单的类,之后你发现必须重写这个类以提升它的运行效率。如果接口部分和实现部分已经被分离和保护起来了,那么你就可以轻松地重写它。

复用

如果一个类经过了充分测试,其代码就应该是有效且可复用的(理想情况)。不过,要实现这种复用性并不像想象的那么简单。创建可复用的对象设计需要大量的经验和洞见。然而,一旦你拥有了可复用的设计,不复用就可惜了。代码复用是我们使用面向对象编程的理由之一。

复用一个类最简单的方法是直接使用这个类所生成的对象,不过你也可以把这个对象放到另一个新类里面。新创建的类可以由任意数量和类型的对象组成,也可以任意组合这些对象,以满足想要的功能。因为利用已有的类组合成一个新的类,所以这个概念叫作“组合”(composition)。如果组合是动态的,通常叫作“聚合”(aggregation)。组合通常代表一种“有”(has-a)的关系,比如“汽车有发动机”(见图1-2)。

img

图1-2

图1-2中用箭头表示了一辆汽车的组合关系。而我习惯用一种更简单的方式,即一条没有箭头的直线来表达两者之间的关联。

组合为我们提供了极大的灵活性。这些在你的类内部创建的对象通常具有private属性,所以其他使用这个类的客户程序员无法访问它们。这也意味着,就算我们修改了这些内部对象,也不会影响外部已有的代码。此外,你还可以在运行时改变这些内部对象,从而动态调整程序的行为。下一节要讲述的继承机制则不具备这种灵活性,因为编译器对使用继承创建的类设置了一些编译时的限制。

继承常被视为面向对象编程的重中之重,因此容易给新手程序员留下这样的印象:处处都应该使用继承。而实际上,这种全盘继承的做法会导致设计变得十分别扭和过于复杂。所以相比之下,在创建新类时应该首先考虑组合,因为使用组合更为简单灵活,设计也更为清晰简洁。一旦你拥有了足够的经验,何时使用继承就会变得非常清晰了。

继承

对象本身的理念是提供一种便捷的工具。对象可以根据定义的概念来封装数据和功能,从而展现给人们对应的问题空间的概念,而不是强迫程序员操作机器底层。在编程语言里,这些基础概念通过关键字class得以呈现。

然而,当我们大费周折才创建了一个类之后,如果不得不再创建一个与之前功能极为相近的类,这种滋味一定不太好受。如果我们能够复制现有的类,并且在该复制类的基础上再做一些增补的话,那就太妙了。实际上,这就是继承给我们带来的好处,除了一点:如果最初的类(叫作“基类”“超类”或“父类”)发生了变化,那么被修改的“复制”类(叫作“派生类”“继承类”或“子类”)同样会发生变化(见图1-3)。

img

图1-3

图1-3中的箭头从子类指向其基类。之后你将看到,子类通常会有多个。

一个类呈现的内容不只是对象能做什么、不能做什么,它还可以关联其他的类。两个类可以拥有相同的行为和特征,但一个类可以比另一个类拥有更多的特征,以及处理更多的消息(或者用不同的方式处理消息)。继承通过基类和子类的概念来表述这种相似性,即基类拥有的所有特征和行为都可以与子类共享。也就是说,你可以通过基类呈现核心思想,从基类所派生出的众多子类则为其核心思想提供了不同的实现方式。

举个例子。一个垃圾收集器需要对垃圾进行分类。我们创建的基类是“垃圾”,具体的每一件垃圾都有各自不同的重量、价值,并且可以被切碎、溶解或者分解等。于是,更为具体的垃圾子类就出现了,并且带有额外的特征(比如,一个瓶子有颜色,一块金属有磁性等)和行为(比如你可以压扁一个铝罐)。此外,有些行为还可以产生不同的效果(比如纸质垃圾的价值取决于它的类型和状态)。通过继承,我们创建了一种“类型层次”(type hierarchy)以表述那些需要根据具体类型来解决的问题。

还有一个常见的例子是形状,你可能在计算机辅助设计系统或模拟游戏中碰过到。具体来说,基类就是“形状”(Shape),而每一个具体的形状都具有大小、颜色、位置等信息,并且可以被绘制(draw())、清除(erase())、移动(move())、着色(getColorsetColor)等。接下来,基类Shape可以派生出特定类型的形状,比如圆形(Circle)、矩形(Square)、三角形(Triangle)等,每一个具体形状都可以拥有额外的行为和特征,比如某些形状可以被翻转(见图1-4)。有些行为背后的逻辑是不同的,比如计算不同形状的面积的方法就各不相同。所以,类型层次既体现了不同类之间的相似性,又展现了它们之间的差异。

img

图1-4

问题和解决方案都使用相同的表达方式是非常有用的,因为这样就不再需要一个中间模型将问题翻译为解决方案。在面向对象领域,类型层次是该模型的一个重要特征,它让你可以方便地从现实世界中的系统转换到代码世界的系统。不过现实情况是,有些人由于习惯了复杂的解决方案,因此对于面向对象的简约性反而会有些不适应。

继承已有的类将产生新类。这个新的子类不但会继承其基类的所有成员(虽然private成员是隐藏且不可访问的),而且更重要的是,子类也会继承基类的接口。也就是说,所有基类对象能够接收的消息,子类对象也一样能够接收。我们可以通过一个类所接收的消息来确定其类型,所以从这一点来说,子类和基类拥有相同的类型。引用之前的例子,就是“圆形是一个形状”。所以,掌握这种通过继承表现出来的类型相同的特性,是理解面向对象编程的基础方法之一。

既然基类和子类拥有相同的基础接口,就必然存在接口的具体实现。这意味着,当一个对象接收到特定的消息时,就会执行对应的代码。如果你继承了一个类并且不做任何修改的话,这个基类的方法就会原封不动地被子类所继承。也就是说,子类的对象不但和基类具有相同的类型,而且不出所料的是,它们的行为也是相同的。

有两种方法可以区分子类和基类。第一种方法非常简单直接:为子类添加新的方法(见图1-5)。因为这些方法并非来自基类,所以背后的逻辑可能是,基类的行为和你的预期不符,于是你添加了新的方法以满足自己的需求。有时候,继承的这种基础用法能够完美地解决你面临的问题。不过,你需要慎重考虑是否基类也需要这些新的方法(还有一个替代方案是考虑使用“组合”)。在面向对象编程领域里,这种对设计进行发现和迭代的情况非常普遍。

img

图1-5

虽然有时候继承意味着需要为子类添加新的方法[Java尤其如此,其用于继承的关键字就是“扩展”(extends)],但这不是必需的。还有一种让新类产生差异化的方法更为重要,即修改基类已有方法的行为,我们称之为“重写”该方法(见图1-6)。

img

图1-6

如果想要重写一个方法,你可以在子类中对其进行重新定义。也就是说,你的预期是“我想通过相同的接口调用该方法,但是我希望它可以在新的类中实现不同的效果”。

is-a关系与is-like-a关系

继承机制存在一个有待商榷的问题:只应该重写基类中定义的方法吗?(并且不能添加基类中不存在的新方法)如果是,就意味着子类和基类的类型是完全相同的,因为它们的接口一模一样。结果就是,你可以直接用子类的对象代替基类的对象。这种纯替换关系通常叫作“替换原则”5。从某种意义上说,这是一种理想的继承方式。这种情况下基类和子类之间的关系通常叫作“is-a”关系,意思是“A是B”,比如“圆形是一个形状”。甚至有一种测试是否是继承关系的方法是,判断你的类之间是否满足这种“is-a”关系。

5也叫作“里氏替换原则”(Liskov Substitution Principle),这一理论最初由Barbara Liskov提出。

有时候,你会为子类的接口添加新的内容,从而扩展了原有的接口。在这种情况下,子类的对象依然可以代替基类的对象,但是这种代替方案并不完美,因为不能通过基类的接口获取子类的新方法。我将这种关系描述为“is-like-a”关系(这是我自创的词),意思是“A像B”,即子类在拥有基类接口的同时,也拥有一些新的接口,所以不能说两者是完全等同的。以空调为例,假设你的房间里已经安装了空调,也就是拥有能够降低温度的接口。现在发挥一下想象力,万一空调坏了,你还可以用热泵作为替代品,因为热泵既可以制冷也可以制热(见图1-7)。在这种情况下,热泵“就像是”空调,只不过热泵能做的事情更多而已。此外,由于设计房间的温度控制系统时,功能仅限于制冷,所以系统和新对象交互时也只有制冷的功能。虽然新对象的接口有所扩展,但现有系统也只能识别原有的接口。

img

图1-7

观察图1-7你就能知道,基类“制冷系统”通用性并不高,最好可以将其改名为“温度调节系统”,使其同时包含制热功能。这样一来,之前提及的替换原则就可以派上用场了。不过话说回来,这张图也反映了真实世界中的设计方式。

当你充分理解了替换原则之后,可能会认为这种纯替换方式才是唯一正确的方式。如果你的设计能够应用纯替换原则,那就太棒了。然而实际情况是,你会发现经常需要为子类的接口添加新方法。只要稍加观察,就很容易分辨出这两种情况的应用场合。

多态

在编程中涉及类型层次时,我们通常会将一个对象视为其基类的一个实例,而不是对象实际的类。这种方式可以让你在编写代码时不依赖于具体的类。在形状的例子中,方法都是作用于通用的形状,而不需要关心该形状具体是圆形、矩形、三角形,还是一个没有明确定义的形状。因为所有的形状都可以被绘制、清除、移动,所以当这些方法发送消息至对象的时候,就无须关注对象是如何处理这条消息的。

当我们添加新的类时,这些代码是不受影响的,添加新的类可以扩展面向对象程序的能力,从而能够处理一些新的情况。比如,你为基类“形状”创建了一个子类“五边形”,并且不改变那些基于通用形状的方法。这种通过派生子类就可以轻松扩展程序设计的能力,是封装变化的一种基础方式。这种方式在改善设计的同时,也降低了软件维护的成本。

当你尝试用派生的子类替代通用基类(比如,把圆形当作形状,把自行车当作交通工具,把鸬鹚当作鸟等)时会发现一个问题,即调用方法来绘制这个通用的形状、驾驶这辆通用的交通工具或者让这只鸟飞翔时,编译器并不知道在编译时具体需要执行哪一段代码。那么重点来了,当消息被发送时,程序员并不关心具体执行的是哪一段代码。也就是说,当负责绘制的方法应用于圆形、矩形或者三角形时,这些对象将能够根据其类型执行对应的正确代码。

如果你并不关心具体执行的是哪一段代码,那么当你添加新的子类时,即使不对其基类的代码做任何修改,该子类实际执行的代码可能也会有所不同。但如果编译器无法得知应该具体执行哪一段代码,它会怎么做呢?比如下图中的BirdController对象,它可以和通用的Bird对象协同工作,同时它并不知道这些对象具体是什么类型的鸟。对于BirdController来说,这种方式非常方便,因为它无须额外编写代码来确定这些对象的具体类型和行为。那么问题来了,当一个Bird对象的move()方法被调用时,如果我们并不清楚其具体的类型,该如何确保最终执行的是符合预期的正确行为呢(比如Goose对象执行的是行走、飞翔或游泳,Penguin对象则是移动或游泳,见图1-8)?

img

图1-8

答案来自继承机制的一种重要技巧:编译器并非通过传统方式来调用方法。对于非面向对象编译器而言,其生成的函数调用会触发“前期绑定”(early binding),这是一个你可能从来都没听说过的词,因为你从未考虑过使用这种方式。前期绑定意味着编译器会生成对一个具体方法名的调用,该方法名决定了被执行代码的绝对地址。但是对于继承而言,程序直到运行时才能明确代码的地址,所以就需要引入其他可行的方案以确保消息可以顺利发送至对象。

为了解决上面提及的问题,面向对象语言使用的机制是“后期绑定”(late binding)。也就是说,当你向某个对象发送消息时,直到运行时才会确定哪一段代码会被调用。编译器会确保被调用的方法是真实存在的,并对该方法的参数和返回值进行类型检查,但是它并不知道具体执行的是哪一段代码。

为了实现后期绑定,Java使用了一些极为特殊的代码以代替直接的函数调用,这段代码使用存储在对象中的信息来计算方法体的地址(第9章会详细地描述这个过程)。其结果就是,在这些特殊代码的作用下,每一个对象会有不同的表现。通俗地讲,当你向一个对象发送消息时,该对象自己会找到解决之道。

顺便一提,在某些编程语言里,你必须显式地为方法赋予这种后期绑定特性。比如,C++使用virtual关键字来达到此目的。在这些编程语言中,方法并不默认具备动态绑定特性。不过,Java默认具备动态绑定特性,所以你无须借助于其他关键字或代码来实现多态。

我们再来看一下形状的例子。之前的图中展示了一些形状的类(这些类都基于统一的接口),为了更好地描述多态,我们编写一小段只关注基类而不关注具体子类的代码。由于这段代码不关注类的细节,因此非常简单易懂。此外,如果我们通过继承添加了一个新的子类“六边形”,我们的代码仍然适用于这个新的Shape类,就像适用于其他已有子类一样。因此可以说,这段程序具备扩展性

如果你用Java编写一个方法(你马上就会学到具体应该怎么做):

void doSomething(Shape shape) {
  shape.erase();
  // ...
  shape.draw();
}

这个方法适用于任何Shape对象,所以它不关心进行绘制和清除的对象具体是什么类型。如果程序的其他地方调用了doSomething()方法,比如:

Circle circle = new Circle();
Triangle triangle = new Triangle();
Line line = new Line();
doSomething(circle);
doSomething(triangle);
doSomething(line);

不管对象具体属于哪个类,doSomething()方法都可以正常运行。

简直妙不可言。我们再看这一行代码:

doSomething(circle);

在这段代码里,原本我们需要传递一个Shape对象作为参数,而实际传递的参数却是一个Circle类的对象。因为Circle也是一个Shape,所以doSomething()也可以接受Circle。也就是说,doSomething()发送给Shape对象的任何消息也可以发送给Circle对象。这是一种非常安全且逻辑清晰的做法。

这种将子类视为基类的过程叫作“向上转型”(upcasting)。这里的“转型”指的是转变对象的类型,而“向上”沿用的是继承图的常规构图,即基类位于图的顶部,数个子类则扇形分布于下方。因此,转变为基类在继承图中的路径就是一路向上,也就叫作“向上转型”(见图1-9)。

img

图1-9

面向对象程序总会包含一些向上转型的代码,因为这样就可以让我们无须关心对象具体的类是什么。再看一下doSomething()方法中包含的代码:

shape.erase();
// ...
shape.draw();

需要注意的是,代码并没有告诉我们,“如果是一个Circle请这样做,如果是一个Square请那样做,诸如此类”。如果你真的编写了一段代码用于检查所有可能出现的形状,那么这段代码必然是一团糟,并且每当你为Shape添加一个新的子类时,都必须修改这段代码。所以,上面的代码实际上做的是:“这是一个Shape,我知道它可以进行绘制和清除,那就这么干吧,具体细节交给形状自己处理就好”。

doSomething()方法的神奇之处在于,代码运行的结果是符合预期的。如果直接通过CircleSquare或者Line对象调用draw()方法,运行的代码自然是不同的。如果调用draw()方法时并不知道Shape对象的具体类型,它也能正常工作,即执行其实际子类的代码。这一点十分了不起,因为当Java编译器编译doSomething()的代码时,它并不知道对象的类型是什么。通常来说,你可能会想当然地认为被调用的是基类Shapeerase()draw()方法,而非具体的CircleSquare或者Line子类,然而实际情况是,确实是具体的子类被调用了,这就是多态。编译器和运行时系统负责处理各种细节,你需要了解的就是多态机制的存在,更重要的是要知道如何利用多态进行设计。当你向一个对象发送消息时,哪怕需要用到向上转型,该对象也能够正确地处理该消息。

什么是面向对象编程?

面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石。

什么是面向对象编程语言?

面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性((封装、抽象、继承、多态)的编程语言。

什么是面向对象分析和设计?

前面我们讲了面向对象编程(OOP),实际上,跟面向对象编程经常放到一块儿来讲的还有另外两个概念,那就是面向对象分析(OOA)和面向对象设计(OOD)。面向对象分析英文缩写是OOA,全称是Object Oriented Analysis;面向对象设计的英文缩写是OOD,全称是Object Oriented Design。OOA、OOD、OOP三个连在一起就是面向对象分析、设计、编程(实现),正好是面向对象软件开发要经历的三个阶段。
关于什么是面向对象编程,我们前面已经讲过了。我们现在再来讲一下,什么是面向对象分析和设计。这两个概念相对来说要简单一些。面向对象分析与设计中的“分析”和“设计”这两个词,我们完全可以从字面上去理解,不需要过度解读,简单类比软件开发中的需求分析、系统设计即可。不过,你可能会说,那为啥前面还加了个修饰词“面向对象”呢?有什么特殊的意义吗?
之所以在前面加“面向对象”这几个字,是因为我们是围绕着对象或类来做需求分析和设计的。分析和设计两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法,类与类之间如何交互等等。它们比其他的分析和设计更加具体、更加落地、更加贴近编码,更能够顺利地过渡到面向对象编程环节。这也是面向对象分析和设计,与其他分析和设计最大的不同点。