面向对象编程 (OOP) 简介






4.92/5 (53投票s)
这是一篇初学者文章,通过我个人的观点和经验来解释面向对象编程(也称为 OOP 或 OO 编程)。
开始之前
我写这篇文章的初衷是向那些对该主题一无所知的人解释面向对象编程,我希望我的解释足够简单易懂。
不过,我确实希望本文的读者了解编程基础,比如什么是变量、什么是数组、循环以及函数调用(也称为过程和方法)的概念。
背景
我 11 岁时(也许是 10 岁半,我不太确定)开始使用 AMOS Basic 编程。那种语言专注于游戏开发,非常简单(或者如某些人所说,非常有限)。这种简单性帮助我更好地学习了编程结构,例如 if
、else
、用于 if
的逻辑运算符、goto
、函数调用等。此外,由于它在“对象分配”方面的局限性(因为它并不真正支持对象分配),我学会了如何使用多个保持同步的数组来处理信息分组,以及如何重用数组中的空位来存放新项目。
这种语言的一些局限性让我思考更好的解决方案……当我发现 C 语言时,我找到了许多更好的方案。之后我花了一些时间学习汇编(我的第一次尝试并不成功),后来我学习了 C++(初看起来很糟糕,但这是 C++ 库的缺陷,而不是语言本身的问题),当我理解了 C++ 后,我更加喜欢它了。它为我仍在 C 语言中遇到的那些问题提供了更多的解决方案。实际上,我所寻找的一切都与面向对象编程有关。而且,因为我仍然看到人们误用和误解 OOP,并且我也收到许多向新开发人员解释 OOP 的请求,所以我决定写这篇文章。
注意: 这是我对 OOP 的个人看法和我学习过程的个人解释。我不会专注于“正确的定义”,因为根据最基本的定义,汇编也可以是一种 OOP 语言,因为我们可以在汇编中创建对象(并且,最终,任何程序都可以看作是一个汇编程序)。
第一步
当我开始编程时,我使用的语言完全不支持对象。它只知道 3 种数据类型:整数(也称为 whole numbers [1, 2, 3 是整数的例子]),实数 [如 3.14] 和字符串 ["可以呈现给用户的文本"],以及这 3 种类型中任意一种的数组。
变量的类型通过其名称来编码(一个 string
变量的名称以 $ 结尾,所以 name$
表示一个 string
),整数没有任何特殊字符,而实数使用 # 后缀(所以,Price#
将是一个实数变量)。
我不想讨论命名规则。唯一重要的是,只有 3 种数据类型帮助我理解并专注于编程关键字(如 if
、for
、goto
、gosub
[或函数调用] 等),也帮助我理解了如何构建算法,将内存中一个已经“死亡”的特定项目(实际上是游戏中的一个死亡敌人)重用,以放置另一个项目,因为它不允许我“释放”内存。
然而,我只能使用这 3 种数据类型中的一种或它们的数组,这一点让我很困扰。我实际上创建了许多函数来尝试“自动化”这种分配、释放和“对象构造”的重复性工作。举个例子,考虑到这种语言是用来创建游戏的,并且屏幕上可以轻易出现许多“敌人”,每个敌人都有自己的 X/Y 坐标,所以拥有像下面这样的数组是很常见的:
- EnemiesX(someSize);
- EnemiesY(someSize);
- EnemiesKind(someSize);
- 等等。
因此,如果我想对所有敌人执行相同的操作,我实际上需要:
- 知道 "someSize" 的值。我可以从任何一个数组中提取它,但我有责任保持它们同步,使它们有足够的大小来支持所有可能出现在屏幕上的敌人,甚至要知道一个敌人何时死亡(可能需要另一个数组来告知);
- 我被迫在相同的索引下访问所有这些数组,以获取单个敌人的所有信息。
这可能看起来很傻,但我会尝试用矩形来解释。想象一下,你只想在内存中存储不定数量的矩形及其颜色,并在某个时刻将它们绘制到屏幕上。
你实际上需要 5 个数组(left, top, width, height 和 color [我们也可以存储 right 和 bottom 而不是 width 和 height,但现在这不是重点])。
想象一下绘制所有矩形的函数。它可能看起来像这样:
For I=0 To ArrayLength(RectanglesLeft)-1 DrawRectangle RectanglesLeft(I), RectanglesTop(I), RectanglesWidth(I), RectanglesHeight(I), RectanglesColors(I) Next // This is a pseudo-basic code.
如果我们能创建一个包含所有这些特性的新“数据类型”,会不会更好呢?
例如,一个包含 Left
、Top
、Width
、Height
和 Color
的 Rectangle
类型,这样我们就可以简单地拥有一个 Rectangle
数组了?
这可以将代码简化为类似这样:
For I=0 To ArrayLength(Rectangles)-1 r = Rectangles(I); DrawRectangle r.Left, r.Top, r.Width, r.Height, r.Color Next // This is a pseudo-basic code.
或者,更好的是,DrawRectangle
实际上可以接收一个 Rectangle
,所以:
For I=0 To ArrayLength(Rectangles)-1 DrawRectangle Rectangles(I) Next // This is a pseudo-basic code.
这里的好处是,我们只有一个数组,所以访问它们不会有麻烦,而且它们也不可能变得不同步(如果你调整了第一个和第二个数组的大小,但在调整第三个数组大小时出现内存不足,就可能发生这种情况)。
使用 Rectangle
类型可以保证所有的特性都在那里,并且在调用另一个函数时,我们不需要传递 5 个参数,我们可以传递一个正确类型的单个参数。在直接处理数组时,我们只需要处理一个数组。调整它的大小也是一个单一的操作,所以它要么完全成功,要么完全失败,不会有中间状态。
所以,能够创建这个 Rectangle
类型是我们迈向 OOP 的第一步(实际上也是我迈向 OOP 的第一步)。
C 结构体
C 语言允许我将所有我想要的特性都放在一个 struct
数组中。这样的 struct
可以被赋予一个名称(在这种情况下是 Rectangle
),并且它可以拥有我需要的所有特性(left
、top
、width
、height
和 color
)。
在游戏中(记住,我是从游戏开始的),我需要像敌人在屏幕上的 X/Y 位置、他面向的方向、他移动的速度、敌人的种类等特性。而 C 语言允许我创建一个正确类型的单个数组(在新的例子中是 Enemy
)。
但是,即使能够创建这些 struct
,然后能够创建许多该 struct
的 object
,C 语言并不被认为是一种面向对象编程语言。
这是因为,即使我们可以说那些敌人是对象,该语言并不支持所有构成 OOP 基础的特性(或者至少不方便使用它们)。事实上,OOP 有一些令人困惑的定义,根据最基本的定义,C 语言是面向对象的。然而,OOP 最重要的特性并没有被语言强制执行,这些特性是封装和多态。
注意: struct
、class
和 interface
被称为(对象的)“类型”。它们实际上说明了其类型的对象中存在哪些特性。一个对象是给定类型的实际“数据”。例如,Enemy
类型定义了它有 X
和 Y
位置、Direction
等特性。那么,每个敌人就是一个拥有这些特性的对象。
封装
封装实际上意味着两件事。一件是“成员”的分组,另一件是“成员”的隐藏。
例如,在谈论矩形时,我谈到了 left
、top
、width
、height
和 color
,我将所有这些数据分组到一个单一的 Rectangle
类型中,这可以通过使用 C 语言的 struct
来实现。
但是,如何将我们可以用于矩形的函数分组呢?
例如,DrawRectangle
不必是一个接收 Rectangle
的“全局”函数。它可以是 Rectangle
“内部”的一个函数。换句话说,我们可以有一个 Rectangle
类型,它包含特性(left
、top
、width
、height
和 color
)和函数(在 OOP 中称为方法,比如 Draw
)。所以,我们可以通过执行 rectangle.Draw
来绘制一个矩形,而不是 DrawRectangle rectangle
。
这实际上使事情的组织变得更简单,因为我们不再需要有 DrawCircle
、DrawRectangle
、DrawImage
等,我们只需要有 Draw
方法,这些方法当然会与实际类型相关联(所以,我们期望 rectangle.Draw
会绘制一个矩形,而 circle.Draw
会绘制一个圆形)。
所以,能够将函数与对象定义一起分组是区分 OOP 语言和非 OOP 语言的一个特点,而 C 语言不能将函数放在新类型的“内部”(有些人可能会争辩说函数指针也能做到同样的事情,但它们并不完全相同,因为它们“每个对象”都占用空间……它们实际上是在 C 语言中实现 OOP 的一种方式,尽管比它们的 C++ 对应物更难使用)。
隐藏
关于封装的另一个特性,它用于隐藏一个类的实现细节,这既非常重要,又非常有问题。它有问题是因为很多人回避它,因为他们说“如果我可以通过将字段设为公有来直接获取或设置变量,那么创建方法来做这件事就是无用的”。
为了解释整个情况,我说过我们可以在 Rectangle
中使用 Width
或 Right
特性。实际上,要同时拥有 Left
、Right
和 Width
,我们只需要在对象的内存中存储 2 个特性,另一个可以计算出来。同样的情况也适用于 Top
、Bottom
和 Height
。
我们应该在对象中存储所有特性吗?我们应该总是重新计算其中一些以节省内存吗?
如果我们在对象中拥有所有特性,我们如何保证在设置 Left
和 Right
时,用户不会忘记设置 Width
特性?
如果后来我们决定只在内存中存储 Left
和 Width
,并计算出 Right
,我们又如何保证用户不会尝试设置 Right
,毕竟它实际上并不存在?
答案是:类的使用者不应该知道数据是如何在内存中存储的。只有类的编写者应该知道哪些数据真正存储在内存中。
这种思维方式实际上创建了一个原则,即我们从不暴露字段(每个对象消耗内存的实际特性),因此我们应该总是创建函数(或者更好的是,方法,因为这是类型内部函数的名称)或属性来获取或设置所需的值。
也就是说,如果用户可以选择调用 getLeft
、getRight
和 getWidth
方法,那么哪一个是在读取时计算出来的(如果其中有的话)并不重要。同样,当改变值时,用户将能够调用像 setLeft
、setWidth
或 setRight
这样的方法,这些方法实际上可以验证新值并在需要时重新计算其他字段(所以,如果 Width
是计算出来的,调用 setWidth
可以验证新值不小于零,然后将 right
字段设置为 left + new width value
,但类的使用者不需要知道这个计算过程)。
而且,为了完成这项工作,类中存在的字段必须对其他类完全不可访问。其他类应该只能看到那些方法,这样它们可以通过这些方法获取所有需要的信息,而无需知道该类的内部细节。
C++ 和 JavaScript
在不关心他人编写的类的内部细节方面,C++ 大体上遵循 OOP。它有可见性修饰符(像 public
和 private
这样的东西),用于告知哪些特性可以被其他类看到和操作,哪些特性只能由类本身访问。然而,当我们处理编译时,C++ 在“内部细节”的大小方面存在问题,因为即使其他类无法访问标记为私有的特性,在创建新对象实例时,那些“不可访问”的特性的实际大小也很重要。如果正在实例化的类与创建该类新实例的代码一起重新编译,那就不是问题,但如果类(如 Rectangle
)存在于一个 DLL 中,而创建 Rectangle
实例/对象的代码存在于另一个应用程序中,就会导致严重问题。如果由于某种原因,DLL 中对象的大小发生了变化,使用它的应用程序必须重新编译,否则将在运行时发生崩溃。从这个意义上说,即使有隐藏成员的能力,C++ 并没有真正允许实现细节的改变。C# 和 Java 则允许这样做。
至于 JavaScript,嗯,它实际上被认为是面向对象的。它不使用像 public
和 private
这样众所周知的术语来进行封装,并且考虑到它从未被编译成 DLL 供以后使用,它避免了在处理可能被重新编译的 DLL 时可能发生在 C++ 中的“对象大小调整”问题。但我认为,它没有明确的可见性修饰符,这意味着我们可以在 JavaScript 中实现 OOP,就像我们可以在 C 中实现 OOP 一样,但它作为一种语言,本身并不是面向对象的。不过,有些人仍然会不同意我的观点,毕竟 JavaScript 被认为是一种面向对象的语言。我怎敢说相反的话?
特性、属性、字段和成员 (Traits, properties, fields and members)
我使用了特性 (traits)、属性 (properties)、字段 (fields) 和成员 (members) 这些术语。
- Trait (特性) 不是编程中使用的术语。我决定使用这个词正是为了避免与现有术语混淆,我只是在谈论存在于对象中的一种特征,而不管它是如何工作的;
- Field (字段) 是用于那些真正构成对象一部分并消耗内存的特性的名称。获取和设置一个字段会执行直接的内存操作,不能执行验证代码或更改其他字段;
- Property (属性) 用于谈论那些通常由
get
/set
方法组成的特性。例如,在不支持真正属性的语言中,Left
属性可以由getLeft
和setLeft
方法组成。在那些支持属性的语言中,我们会像使用字段一样使用它们,但它们可以由方法实现,因此它们有可能在 get 时计算值,并在 set 时进行验证和更改多个字段; - Member (成员) 实际上是类型中的任何字段、方法甚至属性。但一个成员只是一个真正存在于类型中的东西,所以,对于不支持真正属性的语言,
getLeft
和setLeft
将是成员,但Left
属性则不是,因为它并不真正存在,它只是开发人员为了指代 get 和 set 方法而给出的一个名称。
多态
我“喜欢”这个术语及其基本解释(澄清一下,我其实讨厌这个术语和它的基本解释):“多态是一个对象呈现不同形态的能力”。
“所以,如果我有一个 Shape
对象,它有一个 IsEllipse
属性,用于决定它是被绘制成一个 Ellipse
还是一个 Rectangle
,并且我可以随时更改它,这就是多态,对吗?毕竟,这个形状可以在任何时候从 Rectangle
变成 Ellipse
,再变回 Rectangle
。”
最糟糕的是,我真的对高中老师这么说过,他还说我说的对。但多态与对象改变形态毫无关系。事实上,对象不需要有任何视觉表现也可以是多态的。
多态是能够进行“相同的调用”但得到不同结果(执行不同的代码)的能力,这要归功于某种上下文。
例如,你还记得我谈到将一个 Draw
方法放入 Rectangle
类型中吗?
现在想象有另一个类型,叫做 Line
。它有要在屏幕上呈现的线的初始和最终坐标,并且它也会有一个 Draw
方法。
所以,如果我们有类似这样的东西:
Function DrawAll(Array) For Each Object in the Array Object.Draw Next End Function // The For Each will simply execute the same inner block to every object in the Array.
我们将能够将 Rectangle
绘制为矩形,并将 Line
绘制为线条。我们不需要调用不同的方法(如 DrawLine
和 DrawRectangle
),也不需要在调用前检查对象类型,所以 Draw
调用实际上是多态的。事实上,这样的例子是运行时和动态多态。它是动态的,因为 DrawAll
函数不知道数组中将存在哪些类型的项目。它只知道它会是一个数组,并且它相信它可以在该数组的项目上调用 Draw
。
这种动态行为很多时候被认为是有问题的,因为它可能导致运行时错误。如果我传入一个数字数组,而数字没有 Draw 函数,会发生什么?它会编译成功,因为没有对输入参数进行验证,但它会在执行期间失败。
C++、C#、Java(不包括 JavaScript)和许多其他语言大多是静态类型语言。也就是说,类型必须在编译时已知。所以,我可以(实际上我应该)以不同的方式编写代码。其中一种可能性(还不是最好的)是这样的:
Function DrawAll(Array of Rectangles) For Each Rectangle in the Array Rectangle.Draw() Next End Function Function DrawAll(Array of Lines) WriteText "I should be drawing lines but, instead, I decided to write this text" End Function
在这种情况下,我不能简单地传入“任何”数组来调用 DrawAll
。我必须传入一个 Rectangle
数组或一个 Line
数组来调用 DrawAll
。
在编译时,通过数组的类型,编译器会选择调用哪个 DrawAll
函数。这是一种只为帮助开发者而存在的多态。它与拥有 DrawAllRectangles
和 DrawAllLines
函数并无本质区别,但我们(开发者)在进行调用时不必使用不同的名称。数组的类型将为我们决定正确的方法。
然后,还有存在于 C++、C#、Java 和其他语言中的运行时多态,它不像第一个那样动态。我们将编写一个单一的 DrawAll
函数,期望一个包含某种抽象类或接口的数组。
也就是说,我们的 DrawAll
函数可能看起来像这样:
Function DrawAll(Array of Shapes) For Each Shape in the Array Shape.Draw() Next End Function
并且 Rectangle
和 Ellipse
(以及任何其他具有 Draw
方法的形状)都应该继承/实现 Shape
类/接口。
在这样的类或接口中,会有一个 Draw
方法的声明,但实际上没有任何代码。这对于像这样的情况很有用,这样 DrawAll
方法可以接收任何 Shape
,并且知道它可以调用一个 Draw
方法,因为这个方法应该存在。在编译时,就可以验证一个 Shape 将会有一个 Draw
方法,所以可以为 Rectangle
和 Line
调用这个方法,但甚至不可能尝试将一个 int
数组传递给 DrawAll
函数,因为 int
不是 Shape
,这会导致一个编译时错误(这将禁止开发者发布其程序,也避免了客户实际运行代码时发生失败)。然而,在编译时,我们不需要知道哪个代码会被实际调用(会是 Rectangle.Draw
还是 Line.Draw
或者其他某个 shape 子类.Draw
?)。
非视觉多态
我知道 Rectangle
是一个视觉形状,所以,即使我说多态与“形态”无关,我仍然在给出一个“视觉”的例子。
所以,让我们看(或者更好说,只读)另一个例子。在编程世界里,我们经常处理 Stream
(流)。但为了让它更简单,我们来谈谈 Writeable
(可写对象)。
作为一名开发者,你可以创建一个函数,“写入一些文本”(比如日期和时间)到……一个 Writeable
对象。
这样的 Writeable
不需要有视觉表现。然而,它可以是多态的
,通过以下方式完成其工作:
- 写入文本文件;
- 通过 TCP/IP 连接发送数据(可能发送一封电子邮件);
- 将文本存储在内存中;
- 将文本发送到屏幕(好吧,这个是视觉的);
- 什么也不做。
创建可写对象很奇怪吗?尤其是一个什么都不做的?
嗯,这在日志记录算法中非常常用。你编写的代码会做一些工作并生成一些日志,但实际的日志记录会进入某个 Writeable
,它可能会保存到文件,可能会将内容写入屏幕,可能会在“内存流”中注册内容以便另一个任务相应地显示它……或者可能什么也不做,这样生成日志的代码就可以每次都调用 Write
方法,即使我们不想要日志记录。
一种不同的呈现方式
我试图在简明扼要的同时,解释多态和封装的所有特性。我知道这些概念如果没有好的例子是很难理解的,所以我会尝试提供一个更好的例子,即使它不能涵盖这些主题的所有细节。
我之前谈到了游戏,并提到了敌人。让我们想象这是一个太空射击游戏,我们实际上使用列表来存储屏幕上显示的敌人(列表就像“可调整大小”的数组),并且我们有 3 种敌人。
- 基本敌人只是向下移动屏幕。他们可以斜向移动,但他们不会改变方向,并且他们最终会移出屏幕;
- 中级敌人以 Z 字形向下移动,所以他们也会移出屏幕;
- 而困难的敌人会一直在屏幕上移动,直到他们死亡或杀死你,所以他们不会通过移出屏幕而消失。
我们都知道,动画是由许多相似(但略有不同)的帧连续呈现而成的,因此,在游戏中,我们可能会使用基于逐帧的计算。
所以,在检查是否有任何碰撞(这可能导致敌人或玩家角色死亡)之前,我们将首先移动每个敌人。
为所有敌人制作动画的算法可以是这样的:
For Each enemy in enemiesList MoveSingleFrame(enemy) Next
也可以是这样的:
For Each enemy in enemiesList enemy.MoveSingleFrame Next
第一种情况更类似于它在 C 语言中的样子。第二种情况实际上有更好的封装,但它可能使用也可能不使用多态。
尽管阅读这两段代码非常相似,但在合适的编辑器中编写第二段代码可能要容易得多。使用现代编辑器,我们可以在给定上下文中看到可用函数/方法的列表,在第一种情况下,我们最终可能会看到许多与我们想做的事情无关的函数。在第二种情况下,通过编写 enemy.
,我们将能够只看到属于 Enemy
类的函数。所以,使用第二种版本编写代码可能实际上更容易。
这也是更好的封装,因为 MoveSingleFrame
不会是一个“全局”函数,它将被包含在一个 Enemy
类中。
不管封装部分如何,这两种解决方案都可能不使用多态(而且它们在编译时实际上可以生成完全相同的代码)。那么,MoveSingleFrame
是如何实现的呢?
在没有多态的情况下,我们可以将敌人实例的种类(Kind)作为一个数字(int
)。
所以:
- 基本敌人;
- 中级敌人;
- 困难敌人。
我们可以这样实现 MoveSingleFrame
:
Function MoveSingleFrame(Enemy) If Enemy.Kind = 1 BasicEnemyFrame(Enemy) ElseIf Enemy.Kind = 2 IntermediateEnemyFrame(Enemy) ElseIf Enemy.Kind = 3 HardEnemyFrame(Enemy) Else GenerateErrorAsThisKindOfEnemyShouldNotExist End If End Function
你看到这种方法的问题了吗?
实际上有很多问题。例如,如果我们创建新的敌人种类,我们就需要重新审视这个函数来添加新的测试。
如果我们的游戏非常大,有超过 1000 种敌人,我们就需要在一个函数中放入 1000 个条件。此外,考虑到还会有其他可能与敌人种类相关的函数(比如伤害计算),我们将不得不保持所有函数“同步”,都包含这 1000 个条件,并且我们必须记住每种敌人的编号。更糟糕的是,如果我们需要为一个种类为 1000 的敌人制作动画,我们的算法会非常慢,因为在真正到达合适的条件之前会进行 999 次测试。
所以,寻找可能的改进,我们可以有:
- 枚举 (Enums)。枚举实际上是为值命名的一种方式。因此,我们不再测试
Kind
是否等于 1、2、3……或 1000,而是测试Kind
是否等于BasicEnemy
、IntermediateEnemy
、HardEnemy
……或LastBoss
; Switch
语句。我们可以使用switch
而不是If
和许多许多的ElseIf
。Switch
实际上经过优化,可以对单个值进行多重条件检查,能够进行位测试(因此,对于 1000 个可能的值,只需 10 次测试)或使用跳转表(它会检查最小值和最大值,如果一切正常,将使用一个内部的跳转数组……这是编译器本身的实现细节,所以只需记住 switch 旨在快速执行);- 使用某种虚拟/动态分派。这就是多态,这意味着我们不是在
MoveSingleFrame
中测试Kind
,然后调用相应的EnemyFrame
,而是MoveSingleFrame
本身就是针对正在操作的对象的正确方法。
利用多态
要使用多态,我们不是将一个 Kind
放在一个单一的 Enemy
类中,而是将 Enemy
作为一个基类,只说明它会有一个 MoveSingleFrame
方法,然后每种敌人的 Kind
都是一个子类,以不同的方式实现 MoveSingleFrame
,使用实际的 EnemyFrame
代码。
所以,是类代表了 Kind
,而不是一个数值,对 MoveSingleFrame
的调用将根据我们正在处理的实际 Enemy
子类实例跳转到正确的代码。
在源代码中,我们获得了以下优势:
- 每个类都可以存在于自己的文件中;
- 每个类只需要知道与其自身
Kind
敌人相关的内容,所以MoveSingleFrame
会非常小; - 其他可能需要的方法(如
DetectCollisionAndCauseDamage
)会按敌人种类彼此靠近。也就是说,在BasicEnemy
类中,你将能够看到相关的MoveSingleFrame
和DetectCollisionAndCauseDamage
。这与在一个有 1000 个条件的函数中看到一个MoveSingleFrame
,然后不得不在DetectCollisionAndCauseDamage
中搜索相应的敌人种类是不同的; - 可以通过创建
Enemy
的新子类来添加新的敌人种类,而无需更改任何现有类。当我们有那些按Kind
进行的if
或switch
时,添加一个新的敌人种类意味着我们需要更改所有按Kind
进行switch
或if
的方法,以检查新的种类; - 实际上,在动态加载库的应用程序中,可以在不同的库中创建新的
Enemy
种类并毫无问题地加载它们(想象一下,关卡定义在一个文本文件中,然后文本说明要加载哪些敌人)。这对于需要知道所有现有种类以选择正确方法来调用的代码来说是根本不可能的。
OOP 的程序员方面
当我第一次谈到封装时,我说过对 private
和 public
成员的支持是封装中与隐藏实现细节相关的部分。
嗯,对某些特性(如封装和多态)的支持通常被用来判断一种语言是否是面向对象的。但这种简化可能会导致问题,因为如果程序员知道如何使用它,C 实际上是能够隐藏实现细节的。
另一种看待它的方式是:如果一种语言支持 OOP 特性并使其易于使用(通常通过拥有使其使用非常简单的特定关键字),那么它就是一种 OOP 语言。这实际上并不意味着在 OOP 语言中创建的每个应用程序都将以面向对象的方式编写。我们期望使用它会很简单,但这不会仅仅通过使用该语言就自动发生。
例如,我们仍然可以在 C# 中使用 switch
和基本数据类型,实际上像在结构化语言中一样编程。反过来也是可能的,即使在不被认为是 OOP 的语言中也可以用 OOP 的方式编程。例如,每个代码最终都会被编译成机器码,并且很容易将二进制表示(汇编)转换为该机器码的文本表示(汇编器)。汇编器不是 OOP,但它当然允许编写 OOP 代码。
所以,无论我们的语言是否被官方认为是 OOP 语言,我们都有责任确保我们的程序真正使用了 OOP。这实际上创造了许多创建优秀 OOP 程序的“原则”。
这些原则包括:
- 永远,永远不要创建
public
字段。总是通过方法或属性来暴露特性; - * 如果使用 C、C++ 或任何可能受限于内存中对象大小的语言,这可能会破坏客户端代码,要么在创建类型时预留一些字段以备将来使用,要么为你的类型创建像
Create
/Destroy
这样的函数。使用Create
/Delete
方法的客户端代码在未来你的对象大小发生变化时不会被破坏,因为Create
代码将在你的库中,并且肯定会与大小发生变化的对象一起重新编译; - 永远不要创建全局函数。如果需要,在一个类中创建
static
方法,这样你至少可以有某种分组(如果你实际上使用的语言不支持类或命名空间,你就需要总是用它们相关的类型来为所有函数加前缀); - 如果你有一个
Type
或Kind
特性,并且你使用if
或switch
来根据它采取不同的行动,考虑创建一个abstract class
或interface
来表示这个种类,并将每个条件的代码放在一个不同的子类中; - * 如果
switch
是基于某个外部值(比如一个文本字符或另一个库提供的枚举值),仍然可以用一个输入值和实现了接口/抽象类的实例之间的映射/字典来替换switch
。
嗯,还有许多其他建立在 OOP 之上的原则。其中一些变得如此流行,以至于实际上有些人称它们为 OOP 原则。这包括:
- 面向接口编程,而不是面向实现编程;
- 优先使用组合而非继承;
- SOLID(这是 5 个原则的首字母缩写);
- 许多设计模式,比如(抽象)工厂模式,它说我们不应该直接创建我们的对象实例,我们应该依赖一个“服务”来为我们完成这项工作。
接口和抽象类
我已经介绍过,一个抽象类可能只有一个方法的定义而没有实现,这很有用,这样你就可以创建另一个能够调用它的方法,比如一个日志记录方法调用 writeable.Write
,而实际的 Write
可以在许多不同的子类中实现。
在很多情况下,我也说过“抽象类或接口”。那么,它们之间有什么区别呢?
嗯,这个区别并非在所有语言中都存在,而且它更多是概念上的,而不是一种需要。抽象类实际上是至少有一个抽象方法的类,但它们可以有其他非抽象方法和字段。
接口就像抽象类,但它们所有的方法都必须是抽象的,并且它们不允许任何字段;
例如,C++ 没有真正的 interface
,但许多开发者会创建完全抽象的类,并称之为接口。
C#(通常是 .NET)和 Java 允许我们将类标记为 abstract
,即使它们一个 abstract
方法都没有……这实际上是强迫用户继承该类的一种方式,一些开发者会说这些类使用了 abstract
关键字,但并非真正的 abstract
。
此外,.NET 和 Java 不支持多重继承,因此使用一个完全抽象的类作为基类,不允许一个类再从另一个基类继承,但它们允许一个类实现多个接口。在一个类中实现多个不相关的接口通常是一种不好的做法,但一些开发者能够为这个特性找到用途。
我个人的观点是,好的代码(好的类/对象)应该做它需要做的事情,而不关心子类化或接口实现。它只应该在它的目的就是这样做的情况下才继承或实现一个接口。所以,对于那些没有实现接口或基类的代码,如果它们必须被需要接口或基类的代码“看到”,我们会创建另一个类,继承那个基类或实现所需的接口,并重定向到那些不需要实现这些的代码(实际上,这种实现了接口并重定向调用的类被称为适配器)。只有在性能关键的代码中,或者为了使用那些无法处理适配器的糟糕框架时,我们才可能创建我们“自己工作”的代码,并仍然让它继承一个基类或接口,以便它执行得更快或直接与另一个框架兼容。
使用封装
之前我解释了什么是封装。现在我想展示如何使用它。
大多数语言使用 3 个关键字来实现封装:public
、protected
和 private
。这些也是 UML 类图中使用的可见性修饰符。
public
和 private
是最容易理解的。一个 public
成员对所有人都是可访问的。一个 private
成员只对类本身是可访问的。
protected
是一个特殊情况。它可以被类本身及其任何子类访问,但对其他类是不可访问的。在大多数框架中,具有多态性的方法通常是受保护的 (protected)。它们的存在是为了被(重新)实现,但只有框架本身期望调用它们,所以它们不是 public
的。
实际上,这 3 个关键字漏掉了两种情况。正如我刚才所说,大多数框架使用 protected
方法作为多态方法,但是:
- 它们应该只被(重新)实现,而不被调用。
protected
关键字允许子类(重新)实现方法(实现或重新实现一个方法称为重写),同时也允许调用protected
方法,即使这不是框架想要允许的; - 通常,调用这些
protected
方法的框架类是一个管理器类,它既不是声明这些protected
方法的类,也不是它的子类。所以protected
是不够的。
实际上,我不知道有任何语言能解决第一个问题。这只是一个规则,即旨在被重写的 protected
方法不应该被子类调用。
对于第二个问题,每种语言都创造了不同的方式来解决。UML 的纯粹主义者认为这破坏了封装,但我实际上认为这是对可见性修饰符中缺失功能的一种修正。
C++ 允许一个类声明哪些类是“它的朋友”。那些朋友能够访问前一个类的所有成员,无论可见性修饰符如何(好吧,C++ 确实在破坏封装)。
C# 有两个额外的可见性修饰符,名为 internal
和 internal protected
(或 protected internal
,顺序不影响结果)。internal
成员对于在同一个可执行文件或 DLL 中编译的其他类被认为是 public
的,但对于在其他可执行文件或 DLL 中编译的代码则被认为是 private
的。
internal protected
可从子类访问,无论它们来自哪个程序集,并且也可从声明该成员的同一可执行文件或 DLL 中编译的其他类访问,但不能从其他可执行文件或 DLL 中声明的非子类访问。
我个人认为 C++ 的 friend class
比 internal
更好,即使它破坏了封装,因为在许多项目中,人们将所有类都放在同一个可执行文件中,所以 internal
最终的作用就像 public
。但我更希望语言能使用 friend class
和 internal
的组合,其中只有 internal
成员(而不是所有成员)可以暴露给友元类,而不是暴露给可执行文件或 DLL 的所有类。但我不知道有任何语言是这样工作的。
JavaScript 没有可见性修饰符。放入对象的所有成员都是 public
的(我说放入对象是因为我们不声明一个类中有哪些成员,我们可以在任何时候向对象添加成员)。我们可以实现一种 private
成员,因为一个函数可以声明另一个函数并将其放入一个对象中,而这个新函数可以访问第一个函数的局部变量。这些局部变量对外部代码是不可访问的,因此,它们就像 private
成员一样工作,即使它们并不真正在对象内部。如果你觉得这很复杂,嗯,这就是为什么我不认为 JavaScript 是真正的面向对象的。它有对象,它有封装,但使用起来并不容易。
最后,JavaScript 没有 protected
成员,所以任何旨在多态的成员都必须是 public
的。我应该补充一点,根据 JavaScript 的工作方式,所有成员实际上都是多态的(你可以随时替换它们……在任何时候,被任何类),但这并不意味着这些成员被期望是多态的,重写那些不被期望是多态的成员实际上可能是错误的来源。
C 语言没有可见性修饰符,但实际上 C 的代码被分成了声明(通常在 .h 文件中)和实现(通常在 .c 文件中)。因此,通过不为你不想让用户访问的结构体提供头文件,你就可以让那个 struct
的作用类似于 internal
(与 C# 相比)。
事实上,考虑到我们不应该暴露类的内部细节,而 C 中的 struct
只能包含内部细节,这意味着我们永远不应该提供 struct
的头文件。然后,就由我们来创建函数来创建对象、销毁它们并操作它们(作为属性或方法),并将这些函数放入一个公共头文件中。所以,那些不在公共 .h 文件中的函数是 private
的,而在公共 .h 文件中的是 public
的。这意味着我们没有 protected
成员。
实际上创建它们是可能的,但变得非常困难,以至于我不会尝试去解释它,因为我的目的不是教如何在 C 中实现 OOP。
命名空间
命名空间是封装的一部分。我们可以说它们属于分组类别,但是,嗯,所有的分组也有助于隐藏不应该被看到的信息。
例如,在 .NET 中使用 Visual Studio,当我们输入 System.Collections.
时,我们会看到在 System.Collections
命名空间中找到的所有类型和子命名空间。也就是说,它不会显示来自其他命名空间的类(一种“隐藏”,即使我们仍然可以访问使用其他命名空间),并且它确实显示了与集合(数组、列表等都是“对象的集合”)相关的类的分组。
它们对于避免歧义情况也很有用。例如,一个 Rectangle
可能只是坐标的内存表示(所以,Color
不会是它的一部分),也可能是一个表示视觉 Rectangle
的类,它可能包括边框颜色、边框大小、边框样式(虚线、实线等),当然还有它的内部颜色或填充。只要它们被分组在不同的命名空间中,这两个类就可以以相同的名称存在。
使用多态
使用多态的方式数不胜数。正如我已经说过的,JavaScript 总是多态的(而且你没有选择禁用这种多态)。
在 C 语言中,函数指针允许多态,即使没有类的支持(如果你想在 C 中使用 OOP,它们是模拟类的基础)。在 C# 中,我们可以使用委托(与函数指针相似,但功能更完整一些)和虚方法。
嗯,当我们谈论面向对象编程和多态时,大多数人想到的是虚方法,所以我会专注于它们。
要在 C++ 和 C# 中使用虚方法,我们必须用 virtual
修饰符标记方法。在 Java 中,所有方法默认都是虚方法,我们可以将它们标记为 final
来移除这种多态性。抽象方法总是虚的,但它们没有默认实现。
为了尝试解释多态(或虚拟)方法与非虚拟方法之间的区别,我认为举例说明会很好。
所以,想象我有一个 Rectangle
类和一个 Line
类。两者都有一个非虚的 Draw
方法。
我们已经可以做这样的事情了:
Rectangle rectangle = new Rectangle(0, 0, 100, 100, 0xFF0000)
rectangle.Draw();
// This sample code is in C#.
并且
Line line = new Line(0, 0, 100, 100, 0xFF0000);
line.Draw();
这些调用不是多态的,因为编译器知道 rectangle
和 line
变量的真实类型,所以这些调用和下面这些非常等价:
DrawRectangle(rectangle);
DrawLine(line);
这与那些接收数组(无论是无类型数组还是形状数组)的 DrawAll
方法不同。在那些情况下,代码知道它会接收一些对象并对它们调用 Draw
,但它不知道实际上正在执行哪个代码(是 Rectangle.Draw
?Line.Draw
?)并且每个对象都可以有不同的 Draw(我们可以在同一个数组中放入 Rectangle
、Line
等)。
但是 C++、C#、Java(再说一遍,不包括 JavaScript)和其他静态类型语言要求在编译时知道一个类型,并且这个类型必须已经有我们想要调用的方法(这样编译器可以在编译时验证这样的调用有适当的参数)。这就是为什么在那些语言中,我们需要使用一个基类或接口。我们必须有一个带有 Draw
方法的类型,Line
和 Rectangle
都可以使用它,而让 Line
成为 Rectangle
的子类或反之都是不合逻辑的,因为它们根本就是不同的类。
所以,开发者被迫创建某种基类型(类或接口),并将所有要多态的方法都放在里面。
就我而言,我会使用这个类:
public abstract class Shape
{
public abstract void Draw();
}
然后我们让 Rectangle
和 Line
都继承 Shape
类。在 C# 中,我们还需要明确虚方法的实现,所以我们应该使用 override
关键字来说明我们正在实现首次在 Shape
中声明的 Draw
方法(如果一个方法有相同的名称、结果和参数类型,C++ 和 Java 会自动重写)。
现在我们就可以这样做了:
rectangle.Draw()
line.Draw()
甚至可以这样:
someUnknownShape.Draw();
如果编译器在编译时知道真实类型,那么将虚方法的调用当作非虚方法来调用是一种可能的优化。
这适用于以下情况:
Rectangle rectangle = new Rectangle(0, 0, 100, 100, 0xFF0000)
rectangle.Draw();
在这种情况下,编译器可能能够知道 rectangle
变量确实是一个 Rectangle
,而不是 Rectangle
的子类,从而避免虚调用。
无论调用是虚拟的还是非虚拟的,结果都是一样的,但虚拟调用比非虚拟调用慢,所以进行这种优化可能会很好。当然,如果我们有一个接收形状数组的 DrawAll
方法,我们就不能做这种优化。我们不知道那些形状是 Rectangle
、Line
还是其他。
有了虚调用,多亏了基类或接口,事情就能正常工作了。它不像 JavaScript 或其他动态
语言那样动态,但实际上它更快。
底层细节
我想让这篇文章保持简单,但我相信通过理解底层细节,更容易理解虚方法(以及为什么许多语言不默认将所有方法都设为虚方法)。这些是编译器为我们做的细节,以使代码更简单。
你可能知道,计算机能执行的一切都必须加载到内存中,而内存中的每个位置都可以用一个数字来表示。
当我们进行像下面这样的非虚调用时:
rectangle.Draw (... parameters here...)
编译器将确定 Rectangle.Draw
方法相对于文件开头的位置,并且不会保留一个按“Draw”名称的调用,而是会做类似这样的事情:
- 将参数填充到处理器寄存器和/或堆栈中。矩形本身是一个隐藏的第一个字段;
- 调用
Rectangle.Draw
方法的地址。
这意味着,如果 Rectangle.Draw
从文件开头的第 500 个字节开始,方法调用将是 CALL 500
。
如果 500 是 Rectangle.Draw
的地址,我们将如何调用 line.Draw
?
嗯,如果 Line
类型在编译时已知,编译器可以确定 Line.Draw
的地址(或者 DrawLine
……如果调用不是虚的,这实际上是一样的)并进行适当的调用。所以,假设它在地址 700,它将是 CALL 700
。
如果类型在编译时无法确定,那么它应该使用一种替代的调用方式。这就是像 shape.Draw()
这样的虚
调用发挥作用的地方,因为编译器不知道它应该 CALL 500
、CALL 700
还是其他。相同的编译代码可以根据对象使用地址 500
、700
或另一个地址。
在大多数 OOP 语言中,虚方法被放在一个 VTable(虚表)中。一个 VTable 就像一个函数指针数组。然后,每个对象都有一个指向这个 VTable 的第一个“隐藏字段”。
所有 Rectangle
都会指向 Rectangle
VTable,所有 Line
都会指向 Line
VTable,并且考虑到它们继承自 Shape
,VTable 将以 Shape
VTable 的内容开始。子类可以自由地替换它们 VTable 中的项目,通过替换那些来自基类型的项目,它们实际上“重新实现”了它。这就是我们如何在子类上实现 Draw
方法的方式。
当然,编译器需要改变调用的方式。所以,当我们调用 shape.Draw
时,编译器需要做的事情大概是这样的:
- 像往常一样,将参数填充到寄存器或堆栈中;
- 然后,使用 shape,读取引用 VTable 的隐藏字段;
- 有了 VTable,它会在我们调用的适当位置读取函数指针数组;
- 最后,它调用它刚刚读取的那个地址。
如果我们尝试将这样的代码用 C 语言实现,它会是这样的:
shape->vTable->Draw(shape, ... all other parameters ...);
我们应该记住,这个 Draw
不是一个方法,它是一个函数指针。
我们为什么需要一个基类?为什么不把所有具有相同签名(签名是名称、输入参数类型和结果类型)的方法都自然地看作是同一个方法的实现呢?
因为 VTable 的大小是固定的,它考虑了实际类型及其基类型中的所有虚方法。如果我们真的想把所有兼容的方法都看作是虚的,而不管它们的类型如何(并且考虑到其他 DLL 可能会在以后加载),我们就需要使用一些不同的索引技术,这肯定会比通过索引直接查找 VTable 慢。这就是在动态语言(如 JavaScript)中发生的情况。
基类是可接受的,但为什么不是所有的调用都是虚的呢?
为了性能,为了减小那些 VTables 的大小,并且因为一个不打算被子类化的类可能没有为此做好准备。大多数程序员在相信一个方法不会被重新实现时,会对方法结果做较少的验证,所以重新实现它可能会导致严重的错误(许多开发者实际上认为 Java 方法默认是虚的是一个设计缺陷)。
我们能在 C 语言中进行那些虚调用吗?
当然可以。我在 C 中给出的例子是可行的,但创建 VTable 将是我们的责任。此外,对于“类”的用户来说,必须使用两次 shape
变量可能会非常烦人,一次是用来读取要调用的 VTable
和函数指针,然后是将其作为第一个参数传递,因为这是函数指针所必需的参数(而且我们不能简单地将 shape 存储在 VTable 指针中,因为对于给定类型的所有对象,它都是一个单一的 VTable)。
面向组件的语言
有一段时间,我看到许多文档和网页说 C# 是一种面向组件的语言,但没有解释什么是面向组件的语言。这个表达似乎与面向对象的语言完全相同,而且许多人确实说它们是同一回事。
维基百科的解释让事情变得更糟,因为它似乎表明面向组件的语言提供的特性比面向对象的语言要少。那么,为什么会有人自豪地说一种语言“不仅是面向对象的,它还是面向组件的”,如果这会提供更少的特性呢?
嗯,我研究这个话题有一段时间了,我可以说,对于一种语言被认为是面向组件的,有两件不同的事情被看作是重要的。
第一项,在我看来,是对面向对象语言应该始终具备的良好封装的一种精确化,但由于实现的失败(如在 C++ 中),人们决定创造一个新术语,而不是试图说 C++ 不是面向对象的(我认为 C++ 作为 OOP 的声誉非常强,所以“没有人”讨论它)。第二项更多地关系到由这些语言创建的组件如何在可视化编辑器中被操作。
修改类的内部细节不应导致破坏性变更
private
可见性的唯一目的就是向类的客户隐藏实现细节。所以,我应该问:我们为什么要向客户隐藏细节?
在我看来,主要有两个原因:
- 避免用户将不一致的状态置于我们类的实例中;
- 可以自由地更改内部细节而不破坏现有客户端。
但在 C++ 中,如果我们简单地向一个类添加新字段,我们就会导致存在于不同库/可执行文件中的客户端代码崩溃,因为 new
操作符使用那些“内部细节”来计算分配的大小,当使用新版本的组件时,这可能无效。
因此,一种面向组件的语言应该允许存在于不同库中的组件(它们仍然是类)被单独更新,而不会对依赖的已编译代码造成问题。
当然,不可能禁止开发者对组件进行破坏性更改,但至少封装会真正起作用。只要我们不改变 public
和 protected
成员的签名,新版本的组件就能工作。我们可以自由地完全替换 private
字段(使组件在内存中变大或变小),并向我们的组件添加新的方法和属性,而那些不会被重新编译的客户端代码将继续正常工作。
从这个意义上说,Java 和 C# 都是面向组件的语言,而 C++ 不是。C++ 当然可以创建组件,但组件创建者有责任提供正确的创建/销毁方法,而用户有责任正确使用它们,而不是使用 new
和 delete
操作符。
可发现的已编译代码
对于一种面向组件的语言来说,重要的第二项是允许这些组件被可视化编辑器创建和操作。对属性、事件以及这两者上的自定义信息的支持被认为是基础,同样重要的还有在已编译代码中存储关于现有类型、属性和事件的信息。
C# 和 Java 都在编译后的代码中包含了足够的信息,使我们能够列出现有的类型及其成员,并根据这些信息实例化对象并操作它们。有一些适用于 Java 的可视化编辑器能够将 get/set 方法用作属性,但对某些人来说这是一种变通方法,因为 Java 没有真正的属性。由于 C# 对属性和事件有真正的支持,并且这些可以用 [Attribute]
来修饰,因此 C# 被认为是面向组件的。
在我看来,对真实属性和事件的支持不应该被用来定义什么是面向组件的语言,而是被用来试图将 Java 排除在比较之外。事实上,如果我可以自由地重新定义这些术语,我会说,改变私有成员而不破坏客户端代码的能力应该是 OOP 的一部分(毕竟,这是封装的目的),而一种面向组件的语言是既是面向对象的,又允许分析已编译代码以发现现有的类型和成员,并在发现后创建和操作它们,以便它们可以很容易地被放入可视化编辑器。
根据这个个人定义,C++ 不是面向对象的。它几乎是,但 new
操作符的问题足以将它排除在面向对象的语言之外。相比之下,C# 和 Java 都是面向组件的语言。C# 可以通过拥有真正的属性和事件来使编辑器的工作更容易,但这并不意味着 Java 就不是面向组件的。
那么 C 和 C++ 呢?
我们总能在 C 和 C++ 中创造变通方案。没有什么能阻止开发者创建返回现有类和成员定义的方法,但要保持同步可能会非常有问题,因为语言中没有任何东西会阻止开发者说一个类有一个它实际上没有的方法,或者只是在定义中写错名字。所以,在 C 和 C++ 中创建组件是可能的,但我不认为这样做值得(至少不是手动……总是可以创建代码生成器来保持同步)。
结论
我没有任何真正的结论。我只希望这篇文章对任何想学习一点关于面向对象编程的人有用。
你有什么问题或者不同意某些观点吗?请通过论坛问我。我会尽力回答你的问题,并且根据情况,我会更新文章以帮助其他可能有同样问题的人。
版本历史
- 2014年3月31日:添加了面向组件的语言主题;
- 2014年3月30日:添加了使用封装和使用多态主题;
- 2014年3月29日:初始版本。