面向对象编程 ( 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 而言, 覆盖与重载可算是最典型的形式, 对于初学者而言我想已经够了……

​

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

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

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

​

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