面向对象编程 ( OOP, Object Oriented Programming ) 对于一个现代的程序猿而言, 几乎是一个无法避开的话题. 无论你是想从 C++, C#, 还是 Java 开始你的学习旅程, 面向对象的一些基本的概念, 或者说是理念, 应该事先有所了解, 否则可能很快就会掉入某种编程语言的语法泥潭里, 或者陷入”不识庐山真面目, 只缘身在此山中” 的窘境. 这就如同我们要去某个地方, 首先应当明确的是目的地的方向, 再去规划具体的行进路线.

撰写本文的主要目的是为了让准备开始学习 Java 的同学先对面向对象的基本概念和理念有一个初步认识.

下面的阐述可能有一定局限性 ( 针对性 ), 也可能并不完备和严谨, 只是帮助大家理解和学习 Java, 严格的定义请参阅其它资料. 但在总体的方向上, 对于面向对象程序设计而言还是普适的.

文中使用到了 C 和 Java 做简单例子, 如果没学习过也不要紧, 当做没看见就行了.

在面向对象编程的世界里, 封装、继承和多态被称为面向对象的三个重要特征. 当然, 有些资料上也把 “抽象”归入到了面向对象特征之一.

关于 “抽象” 本文不再多言. 用几句话帮助理解: 抽象就是把客观世界的东西转化成计算机世界的数据模型. 例如: 把现实世界的一个人, 表示成计算机世界中的一个数据. 抽象的目的是”化繁为简”, 手段则是”特征提取”. 继续刚才的例子, 对于一个人而言, 关于他的信息不胜枚举, 比如: 姓名, 年龄, 性别, 身高, 体重, 家庭住址, 家庭成员, 个人爱好…… 但如果我们开发一个员工考勤系统, 显然诸如身高, 体重, 家庭成员…… 这些信息是无用的, 因此, 我们就会把那些无关的信息精简掉, 提取出我们关心的特征, 这就是抽象. 试想一下, 其实生活中很多时候都是做在抽象, 比如: 体检表就是对身体状况的抽象.

好了, 来看我们今天的主角…… 我们通过一些例子和浅显的描述来理解一下 “封装、继承、多态”.

封装


什么是封装?

封装就是把一些数据功能打包成一个独立的单元.

也许 C 语言是你学习的第一种编程语言, 那就回想一下…… 为了存储一组相同类型的数据, 我们可能把它们组合成一个数组, 以便处理. 更高级一些, 我们可能把关于某个学生的信息组合成一个结构体变量. 这就是对数据的封装. 另一方面, 我们可能把诸如求三角形面积的功能写作一个函数, 这就是对功能的封装.

针对任何一个东西 ( 对象, Object ) 的描述均可以拆分成 2 个部分: 数据功能.

封装即是把数据和功能封装成一个独立单元.

为什么要封装?

显然, 封装可以让数据和功能变得更为”整洁”, 并富有条理.

看一下墙上的电源开关, 对于使用者我们只须将 “开/关” 功能暴露给他, 而无须向其展示内部的实现.

再想一下组成计算机的各个部件…… 我们把显示功能封装在了显示器里, 而把数据存储功能封装在了硬盘里.

对于使用者而言, 封装可以让使用变得简单; 对于制造者而言, 封装可以让设计/制造变得更简单和专注, 更重要的一点, 封装有效地避免了使用者肆意地操纵(破坏)封装体的内部结构.

封装的目标?

上升到软件工程的层次, 封装需要实现软件工程中一个重要原则 “高内聚, 低耦合“.

简单来说:

把相对独立的功能和数据封装成一个单元, 这个单元的功能要尽可能地”单一”, 单元内部的数据和功能之间关系尽可能地”紧密” —- 高内聚;

另一方面, 不同的单元之间的联系要尽可能地”松散”, 当然, 更不能直接对对方的内部结构进行操纵 —- 低耦合.

应该注意的是, 封装是有层次的. 比如: 宏观上说, 我们把数据存储的功能封装在了硬盘里, 但对于硬盘而言可以进行更细层次的封装: 把供电的功能封装在了供电部件里, 把控制磁头移动的功能封装在磁头控制部件里……, 当然如果你愿意, 可以继续细分……

总之, 站在不同的角度, 封装的”粒度”是不同的. 但无论如何, 封装体内部功能和数据应是”内聚”的, 而从外部来看, 封装体应是”独立”的, 或说是”整洁”的, 对它的操纵应只能通过其对外提供的”接口”来实现.

如何实现封装?

说了这么多, 那封装具体如何在编程过程中实现呢?

在刚才讲 “什么是封装” 的时候, 曾经举过 C 语言中的例子, 忘记了? 呵呵, 回去看一下吧~

这里我们换一种说法: 使用 C 语言编程时, 我们把”数据”封装成了变量/数组/结构体……或者其它的数据结构, 而把”功能”封装成了”函数 ( function )”. 所以, 对于使用C语言编程而言”高内聚, 低耦合”的原则同样适用.

对于 C 语言这样的编程语言, 编程的主要工作就是在定义不同的函数, 然后使用 (调用) 它. 所以, 我们把C语言叫做面向过程的程序设计语言. …… 为什么不叫面向函数呢? 呵呵, 事实上”过程”和”函数”在定义上只有细微的差别, 对于象 C 语言这样只有”函数”的程序设计语言来说, 可以把函数等同于过程. 如果你想知道过程和函数的不同点, 请问度娘…… 当然, 如果你之前学习过诸如 Pascal, Basic 这样的程序设计语言, 那…… 你懂的, 呵呵~

有人可能已经在咆哮了, 本文不是叫”面向对象起步”吗? 怎么老扯C语言…… 用C语言举例主要是因为我们很多同学学习程序设计是从C语言起步的, 如果没学习过C语言或者完全忘记了…… 那装作没看到”C语言”这三个字就行了, 呵呵~ 应该能看懂!

呵呵, 别急, 面向对象不是从石头里蹦出来的, 它也是从面向过程发展而来的…… 所以, 还从C语言说起……

在C语言中, 我们使用 “结构体类型” 可谓是把数据进行了高层次的封装, 而用 “函数” 对功能进行了封装, 这两种形式的封装是相互独立的……

而现实中, 数据和功能往往是一个有机的整体. 例如: 一个学生, 他的姓名, 性别, 年龄…… 这些是数据, 而学生也拥有吃饭, 睡觉, 打游戏这样的一些功能, 这些数据和功能其实都是学生这个整体的成员.

但是…… 在C语言中, 我们就只能通过结构体变量来存储这个学生的数据, 而定义一些函数来表达他的功能, 这两者之间在形式上是独立的, 而在关系上却又是联系紧密的.

那么…… 能不能把数据和功能都封装成一个整体单元里呢? 呵呵, 说到点子上了…… 这就是传说中的**”对象 (Object)”**

当你成为一名使用面向对象程序设计语言编程的程序猿之后, 就会发现, 每天的工作就是在定义”对象”, 并使用它. 所以叫面向对象编程(OOP) 嘛, 呵呵~

这里需要注意的是, 使用对象之前, 我们要先定义 (描述) 这个对象应该长成什么样子.

通俗来说就是描述对象应该有些什么数据 ( OOP里叫属性 ) 和 功能 ( 函数, OOP里叫方法 ), 然后…… 再把它转化成一个具体的对象去使用. 这里, 在术语中, 前者叫做”“, 后者叫”对象“, 转化过程叫”实例化“.

所以, 类和对象之间的关系其实是 “型” 与 “值” 的关系.

例如: 我们要先定义好整型是什么样子的, 然后才能声明一个具体的整型变量.

当然, 这个例子不一定恰当, 在几乎所有编程语言里整型这样的数据类型是无须程序员定义的, 但…… 总得有人定义吧…… 如果你已经开始学习Java, 那看下面的这段代码就明白了……

1
Integer x = new Integer();	// Integer是类(型), x是对象(值), 通过关键字new来实例化

对于普适的面向对象程序设计而言, 封装的典型形式就是把现实世界抽象的结果描述为对象, 当然也可能涉及到别的层面.

继承


先来看一个有趣的例子……

假如你发现门外有个如下图所示的东东, 你很兴奋地想要告诉正在赖床的室友, 下面是你们的对话:

你: 门外有一条黑狗!

猪头室友: 什么是黑狗?

你: …… 门外有一条皮毛是黑色的狗 !

猪头室友: 什么是狗?

你: 门外有一只哺乳动物, 4 条腿, 一条尾巴, 喜欢啃骨头, …… 并且它的毛是黑色的 !

猪头室友: 什么是哺乳动物?

你: &#$!$%@

猪头室友: 什么是动物呢?

你: 神啊, 宽恕这个无知的人吧, 阿门 !

咿呀, 要命了…… 要真有这样的室友, 那…… 搬了吧……

这个故事虽然狗血, 但它告诉我们一个道理, 我们在提到 “黑狗” 的时候是建立在对方已经知道什么是 “狗” 的前提下的. 同样, 当我们提到 “狗” 的时候也是建立在对方已经知道什么是”哺乳动物”的前提下. 如若对方真是一个 “一无所知” 的人, 那还真得以最啰嗦的方式去描述. 但不幸的是, 计算机刚好是那个最无知的 “人”……

OK, 假如再来一头猪, 你形容一下吧, “猪是一种动物, @#$*!#$%……”, 呵呵~

从这个例子我们至少可以看到下面几点:

(1) 一些事物之间是有层次关系的, 例如: 动物 → 哺乳动物 → 狗 → 黑狗; 动物 → 哺乳动物 → 猪. 如果我们把这个层次竖起来, 那从上到下是逐层递进 具体化扩张 的过程

(2) 如果先把 “上层” 的东西描述清楚, 再来描述 “下层” , 那对于 “下层” 的描述将会变得很简洁, 例如: 当已经描述清楚什么是 “狗” 之后, 那 “黑色的狗” 4个字就可以说清楚什么是 “黑狗” 了.

(3) 更关键的是, 如果我们按(2)中所说的方式去描述, 那当不同的 “层次序列” 有共同的 “上层” 时, 我们可以省很多事. 例如: 假设对动物, 哺乳动物, 狗, 黑狗均有了清晰的定义, 那要定义”猪”的时候就只需要在”哺乳动物”的基础上进一步说明就行了.

呵呵, 这其实是一个很自然的过程, 我们的认识过程也是如此的……

下面我们来定义一些术语:

(1) “动物”, “哺乳动物”, “狗” …… 这些东西称为 类. ( 注意, 不是对象! 具体的狗, “旺财”才称作”对象” )

(2) 从 “动物” 到 “哺乳动物” 的过程, 称为 继承, 相应地, 狗 → 黑狗也是继承, 其它类似……

(3) 在 “动物 → 哺乳动物” 这个继承关系中, 把”动物类”称作”哺乳动物类”的 父类(基类), 而 “哺乳动物类” 称作 “动物类” 的 子类(派生类)

(4) 子类会自然地拥有父类的所有属性和方法, 但应记住, 继承是扩张的过程, 不能收缩. 也就是说, 父类有的, 子类一定有.

大概明白什么是继承了吧……

从写程序的角度, 继承可以实现高效、合理的 “代码复用“, 更重要的是, 它更有利于我们进行整体架构.

对于代码复用, 我想通过前面的例子已经可以体会到了. 简单来说, 就是不用老是做重复劳动. 回想一下, 你写程序的过程是不是有很多 “复制 → 粘贴 → 改一改” 的动作, 当有一天你发现最初的 “原版” 有问题, 需要修改, 而你已经复制/粘贴了N个地方, 那怕就疯了吧, 呵呵……

那为什么不把共有的东西定义在父类中, 通过继承机制来复用呢?

当然, 把经常使用的功能定义在一个函数里, 也是代码复用, 只是我们现在在讲继承, 就不扯别的了……

从整体架构的角度来说, 继承机制更符合我们的认识过程, 从简单到复杂, 从抽象到具体.

站在祖先的视角来看, 它规定了其子孙后代的发展方向, 呵呵~

多态


继续刚才的例子, 假如我们定义了 “动物类”, 并通过继承机制进而定义了 “狗类”.

所有动物都会吃东西吧, 呵呵, 于是我们在 “动物类” 中定义了 “吃”这个方法, 函数原型大概是这样的:

void eat(String food, double weight)

然后…… 通过继承机制, 狗类也就自然地拥有了”吃” 这个方法 (函数).

好了, 现在问题来了, 很有可能我们在动物类中定义的 “吃法” 只是一种普适的方法. 而狗能吃骨头, 如果 food 是骨头的时候, 它的吃法要有个性些. 也就是说, 在狗这个子类中, 需要对动物这个父类中的”吃”这个方法进行重新定义. 这行不行呢? 当然可以! 这叫”覆盖

OK, 相对书面化一点说, 覆盖(Override) 就是子类中重新定义了从父类中继承而来的方法.

要注意的是, 子类中定义的这个方法的原型必须与父类中的方法原型完全一致, 也就是说, 方法名和参数表都相同 (方法名后面括号中的参数个数和类型都必须一致), 否则不可称作覆盖.

那…… 如果只是方法名和参数表相同, 返回值不同, 这样能不能叫覆盖呢? 呵呵, 这叫”出错”! 哈哈, 语法上根本不允许……

通常情况下, 使用 Java 编程时, 会在子类中有 “覆盖” 情形的方法前面写上 @Override 这个注解.

在C++中除了覆盖之外, 还有一个概念很相似, 叫 “重写(Overwrite)”, 它与覆盖(Override) 是有区别的, 但因为本文以 Java 为例, 并不存在重写的情况, 因此就不再赘述了, 感兴趣的问度娘……

说完了覆盖, 我们来看另一种情况:

假设在”动物”这个类中定义了一个 “喝水” 的方法, 原型是这样的:

void drink(double weight)

现在我们还想继续定义另一个 “喝水” 的方法, 但多一个参数表示这水是热的, 还是凉的.

也许你会想, 那我们另外定义一个不同名字的方法不就行了嘛…… 呵呵, 或许我们就喜欢 “drink” 这个名字呢?

其实, 我们可以定义另外一个方法, 原型如下:

void drink(double weight, boolean isHot)

也就是说, 我们在同一个类中定义了两个同名的方法, 但它们的参数表是可区分的, 这叫做 “重载(Overload)

这里解释一下什么叫”参数表是可区分的”, 为什么不说”参数表不同”呢?
一般而言, 我们说 “参数表不同” 表达的是 “参数个数不同 或 参数类型不同, 或者两者均不同”. 但注意以下的情形:
(1) void doSomeThing (Number param)
(2) void doSomeThing (Integer param)
(3) void doSomeThing (Double param)
(4) void doSomeThing (int param)
按上述的说法这 4 个方法显然是”参数表不同”的

但 (1) 与 (2) 是不能同时出现在一个类的定义里面的, 因为 Number类是 Integer的祖先类, 当我们以 doSomeThing(3) 的形式来调用doSomeThing方法的时候, 计算机不知道该调用(1) 还是(2).

同样的, (1) 与 (3) 也不能同时出现在一个类中. 但是, (2) 和 (3) 是可以的……
那 (2) 和 (4) 可不可以在同一个类中出现呢? — 不行! 术语里, 把 Integer 称作 int 的”装箱类”. 如果它们同时出现在同一个类中, 调用时也是有歧意的.
因此, 这里我们使用 “可区分” 这个词……

那么……

同一个类中定义两个原型完全相同的方法(类似覆盖), 这样行不行呢? — NO!!! 语法错误!

那么……

同一个类中定义两个名称相同, 参数表相同, 只是返回值不同的方法, 行不行? — NO!!!

那么……

同一个类中定义两个名称不同, 其它都相同的方法行不行? — YES! 当然可以, 只是这就是两个普通方法定义了, 它们之间不构成重载关系.

好了, 最后对比一下覆盖(Override) 和 重载(Overload):

(1) 覆盖是发生成父类与子类之间的, 而重载是发生成同一个类的内部的.

(2) 构成覆盖关系的函数要求名称相同, 参数表相同, 返回值也相同; 重载则要求名称相同, 返回值相同, 但参数表是可区分的.

STOP !!! 不是要讲什么是多态吗? 怎么扯半天的覆盖和重载?

呵呵, 其实覆盖与重载就是面向对象中多态性的两种表现形式. 当然, 多态性在不同的语言里可能还有更多的表现. 但对于 Java 而言, 覆盖与重载可算是最典型的形式, 对于初学者而言我想已经够了……

现在不用再解释什么是多态了吧, 呵呵, 自己领悟吧~

一个不太严谨的说法是, 所谓”多态”, 就是同一个东东, 在收到不同的输入时, 有不同的行为 ( 同名多状态 ). 相当不严谨, 只是帮助理解, 勿喷~

其实严格的定义很拗口, 也不容易理解, 这里就不再转载了…… 很多网上的文章或帖子, 对于多态的定义并不正确, 甚至有误导的嫌疑! 建议看点权威的资料~

好了, 到这里吧, 就到这里吧, 希望对学习面向对象程序设计有所帮助……