用类进行设计
既然你已经通过第 4 章“设计专业级 C++ 程序”体会到了良好软件设计的重要性,现在就该把“类”的概念和“好设计”的理念结合起来了。那些只是会在代码里使用类的程序员,与真正理解面向对象编程的人之间,差别往往就在于:他们如何组织类之间的关系,以及如何看待程序的整体设计。
本章会先简要介绍过程式编程(也就是 C 风格的思考方式),然后再深入讨论面向对象编程(OOP)。即使你已经使用类很多年了,也值得把这一章读完,因为它会提供一些重新思考“类”这一概念的新角度。我会讨论类之间的多种关系,也会指出构建面向对象程序时程序员经常掉进去的陷阱。
在思考过程式编程或面向对象编程时,最重要的一点是:它们只是两种理解程序运作方式的不同视角。很多程序员在还没有真正弄清“类是什么”“对象是什么”之前,就已经被 OOP 的语法和术语绊住了。这一章代码不多,重点放在概念和想法上。第 8 章“深入掌握类与对象”、第 9 章“精通类与对象”,以及第 10 章“深入继承技巧”,会进一步深入 C++ 的类语法。
我是在按过程式方式思考吗?
Section titled “我是在按过程式方式思考吗?”像 C 这样的过程式语言,会把代码拆成一个个小块,而每个小块(理想情况下)只完成一项任务。如果 C 里没有这些过程,那么所有代码就都会挤在 main() 里。那样的代码可读性会非常糟糕,你的同事大概也不会太高兴。
计算机并不在乎你的代码是全堆在 main() 里,还是被拆成了一块块带有清晰名字和注释的小模块。过程本身是一种抽象,它存在的意义是帮助你——也帮助那些需要阅读和维护你代码的人。它围绕着一个最基本的问题展开:*这个程序做什么?*当你试图用自然语言回答这个问题时,你其实就是在以过程式的方式思考。比如,你在设计一个选股程序时,可能会这样回答:程序先从互联网上获取股票报价;然后按照某些指标对这些数据排序;接着对排序后的数据做分析;最后输出一份买入和卖出建议清单。等到真正开始编码时,你往往会直接把这个思维模型翻译成一组 C 风格函数:retrieveQuotes()、sortQuotes()、analyzeQuotes() 和 outputRecommendations()。
当你的程序严格遵循一串固定步骤时,过程式方法通常很有效。不过在大型现代应用中,事件几乎从来都不是线性的。用户往往可以在任意时刻执行任意命令。过程式思维还有一个问题:它根本不会触及数据表示。在刚才的例子里,我们完全没有讨论“股票报价”本身究竟是什么。
如果这种过程式思维方式听起来很像你平时处理程序的方式,也不用担心。一旦你意识到 OOP 只不过是另一种、更灵活的软件思考方式,它就会逐渐变得自然起来。
面向对象的哲学
Section titled “面向对象的哲学”过程式方法问的是“这个程序做什么?”,而面向对象方法问的是另一个问题:“我正在建模什么现实世界中的对象?”OOP 的核心观念是:你不应把程序拆成一项项任务,而应拆成对现实世界对象的模型。乍看上去这似乎有些抽象,但只要你开始从类(class)、组件(component)、属性(property) 和行为(behavior) 的角度去理解现实中的对象,这件事就会清晰得多。
类帮助你区分“对象本身”和“对象的定义”。以橙子为例。谈论“橙子”这种长在树上的美味水果,和谈论“某一个具体橙子”——比如此刻正在我键盘旁边滴汁的那一个——显然不是一回事。
当你回答“橙子是什么?”这个问题时,你谈论的是一种叫做“橙子”的类。所有橙子都是水果;所有橙子都长在树上;所有橙子都有某种橙色;所有橙子都有某种特定风味。类本质上就是对“什么定义了这一类对象”的一种封装。
而当你在描述某一个具体橙子时,你谈论的就是一个对象(object)。所有对象都属于某个特定的类。因为我桌上的这个东西是个橙子,所以我知道它属于橙子类;也因此,我知道它是一种长在树上的水果。我还可以进一步说:它是中等深浅的橙色,味道“非常不错”。对象是类的一个实例(instance)——也就是一个具体条目,它具有能让它和同类其他实例区分开的特征。
换一个更具体的例子:回到前面的选股应用。在 OOP 中,“股票报价”就是一个类,因为它定义了“什么构成一个报价”这一抽象概念。而某一个具体报价,例如“当前的 Microsoft 股票报价”,则是一个对象,因为它是这个类的一个具体实例。
如果你有 C 语言背景,可以把类和对象类比成类型和变量。事实上,第 1 章“C++ 与标准库速成导论”已经展示过,类的语法和 C 的结构体语法很相似。
如果你去思考一个复杂的现实对象,比如一架飞机,就很容易看出它是由更小的组件(components) 组成的:机身、操控装置、起落架、发动机,以及许多其他部件。从更小组件的角度来理解对象,是 OOP 的关键能力;这就像在过程式编程里,把复杂任务拆成更小的过程一样基础。
组件本质上和类没有区别,只是它更小,也更具体。一个好的面向对象程序可能会有一个 Airplane 类,但如果这个类要完整描述一架飞机,它会大得难以管理。更现实的设计是:Airplane 类由许多更小、更容易掌控的组件组成。每个组件本身又可能继续包含更小的子组件。例如,起落架是飞机的一个组件,而机轮又是起落架的一个组件。
属性(properties) 是把一个对象与另一个对象区分开的东西。还是以 Orange 类为例,前面提到所有橙子都有某种橙色和某种风味。这两个特征就是属性。所有橙子都拥有同样的属性,只不过属性值不同。我的橙子可能味道“非常好”,而你的橙子也许味道“糟糕透顶”。
你也可以从类的层面来理解属性。正如前面说过的,所有橙子都是水果,都长在树上。这些可以看作水果类的属性;而“橙色的具体深浅”则是由某个具体水果对象决定的。类属性由该类的所有对象共享,而对象属性则存在于该类的每个对象中,但值各不相同。
在选股示例中,股票报价就有很多对象属性,例如公司名称、股票代码、当前价格,以及其他统计信息。
属性描述的是对象的特征。它回答的问题是:“这个对象有什么不同?”
行为(behaviors) 回答的是下面两个问题之一:“这个对象会做什么?”或者“我可以对这个对象做什么?”对于橙子来说,它自己不会做太多事,但我们可以对它做很多事。其中一种行为是:它可以被吃掉。和属性一样,你也可以在类层面和对象层面来思考行为。大多数橙子都可以用相似的方式被吃掉;但在某些行为上,它们也可能不同——例如从斜坡上滚下去时,一个非常圆的橙子和一个更扁的橙子,表现显然就不一样。
选股示例提供了一个更实用的行为例子。还记得在过程式思考里,我曾经认定程序需要“分析股票报价”这一功能吗?如果改用 OOP 的思路,你可能会决定:股票报价对象可以分析它自己。这样一来,“分析”就成了股票报价对象的一种行为。
在面向对象编程中,大部分功能性代码会从过程里搬出来,转而进入类中。通过构建拥有特定行为的类,并定义这些类如何交互,OOP 提供了一种更丰富的机制,把代码附着到它所操作的数据上。类的行为通常通过类成员函数(class member functions) 实现。
正如第 4 章所解释的,C++ 是一门多范式语言,同时支持面向对象编程和过程式编程。因此,C++ 并不像 Java 那样强迫你把所有东西都塞进类里。在 C++ 中,如果 OOP 合适,你完全可以自由使用类;如果把它和过程式风格混用,把某些功能保留在独立函数中,也完全没有问题。事实上,C++ 标准库中有很多功能就是以独立函数的形式提供的,例如全部算法。
把这些概念串起来
Section titled “把这些概念串起来”有了上面的概念,你就可以重新审视选股程序,并用面向对象的方式重新设计它。
如前所述,“股票报价”本身就是一个很好的类。为了获取一组报价,程序还需要“若干股票报价组成的整体”这一概念,也就是通常所说的集合(collection)。因此,更好的设计可能是:有一个表示“股票报价集合”的类,而它又由许多表示单个“股票报价”的更小组件构成。
继续看属性。这个集合类至少会有一个属性——收到的实际报价列表。它还可能有其他属性,例如最近一次获取报价的确切日期和时间。至于行为,这个“股票报价集合”应当能够与服务器通信以获取报价,并提供一个已经排序好的报价列表。这些就是“获取报价”和“排序报价”行为。
股票报价类本身则会拥有前面提到的那些属性——名称、代码、当前价格等等。同时,它还会有一个“分析”行为。你也许还会考虑其他行为,例如买入或卖出该股票。
把组件之间的关系画成图,通常会很有帮助。图 5.1 使用 UML 类图语法(见附录 D“UML 简介”)表示:一个 StockQuoteCollection 包含零个或多个(0..*)StockQuote 对象,而一个 StockQuote 对象只属于一个(1)StockQuoteCollection。

[^FIGURE 5.1]
再看第二个例子。正如前面所说,一个橙子有颜色、风味等属性,也有“被吃掉”“被滚动”等行为。你当然还可以继续想出更多行为,例如被扔、被剥皮、被榨汁。橙子的另一个属性,也可以是“它的一组种子”。图 5.2 展示了 Orange 与 Seed 类的一种可能 UML 类图,其中包括这样一种关系:一个 Orange 包含零个或多个(0..*)Seed,而一个 Seed 只属于一个(1)Orange。

[^FIGURE 5.2]
生活在类的世界里
Section titled “生活在类的世界里”程序员从过程式思维切换到面向对象范式时,常常会对“属性和行为如何组合成类”产生一种顿悟。有些程序员会重新审视自己正在开发的程序设计,并把其中某些部分重写成类;另一些程序员则可能会忍不住把全部旧代码扔掉,从头把项目改造成一个彻头彻尾的面向对象应用。
使用类开发软件,大致有两种主要思路。对一部分人来说,类只是“封装数据和功能的一种很好的方式”。这类程序员会在程序里点缀地使用类,让代码更可读、更容易维护。采取这种方式的人,会像外科医生植入起搏器一样,把一些孤立的代码块剥离出来,再用类来替换它们。这种做法本身并没有什么错。他们把类看作一种在很多场景下都很有价值的工具。程序中有些部分就是“天然像一个类”,例如股票报价。这些部分很容易被隔离出来,并用现实世界的术语来描述。
另一部分程序员则会彻底拥抱 OOP 范式,把几乎所有东西都变成类。在他们眼中,有些类对应现实世界里的事物,例如橙子或股票报价;另一些类则封装更加抽象的概念,例如排序器或者撤销类。
理想做法很可能介于这两个极端之间。你的第一个面向对象程序,也许本质上仍然是一个传统的过程式程序,只不过零星地掺进了一些类;也可能你曾经彻底走极端,把所有东西都做成类,从一个表示 int 的类到一个表示主应用程序的类。随着经验增长,你最终会找到一个更舒服的平衡点。
在“设计一个富有创造力的面向对象系统”和“把每一丁点东西都变成类,从而惹恼团队里其他人”之间,往往只有一线之隔。正如弗洛伊德似乎会说的那样:有时候,一个变量真的就只是一个变量。好吧,这当然是对他说法的转述。
假设你正在设计下一款爆款井字棋游戏。这次你打算彻底走 OOP 路线,于是你端着咖啡、拿着记事本坐下来,开始勾勒类和对象。在这种游戏里,通常会有一个监督游戏流程并能判断输赢的类。为了表示棋盘,你可能会设想一个 Grid 类,用来记录棋子及其位置。进一步说,网格中的一个组件还可以是 Piece 类,用来表示 X 或 O。
等等,先别急!这个设计提出要有一个专门表示 X 或 O 的类,这也许就有点“类过度”了。毕竟,char 难道不能同样胜任表示 X 和 O 吗?再进一步,为什么 Grid 不能直接使用一个枚举类型的二维数组?Piece 类是不是只会让代码变得更复杂?看看下面这张表示 Piece 类的表格:
| 类 | 相关组件 | 属性 | 行为 |
|---|---|---|---|
| Piece | 无 | X 或 O | 无 |
这张表实在太稀薄了,它几乎在大声提示:这里的粒度也许细得不适合成为一个完整的类。
当然,具有前瞻性的程序员也可能会辩称:虽然 Piece 目前看来确实是个非常单薄的类,但把它做成类并不会造成什么真实损失,却为未来扩展留出了空间。也许今后会加上更多属性,比如 Piece 的颜色,或者某个 Piece 是否是最近刚刚移动过的那一个。
另一个解决思路,是别去建模“棋子”,而是改为建模格子的状态(state)。一个格子的状态可以是 Empty、X 或 O。为了让设计更具前瞻性,你甚至可以设计一个抽象基类 State,再派生出具体类 StateEmpty、StateX 和 StateO。这样一来,将来无论要给基类还是单个状态类增加属性,都变得更自然。
显然,这里并没有唯一正确答案。真正重要的是:这些正是你在设计应用程序时应该认真思考的问题。请始终记住,类存在的意义,是帮助程序员管理代码。如果类的唯一用途只是让代码“看起来更面向对象”,那设计就已经出问题了。
过于泛化的类
Section titled “过于泛化的类”也许比“不该成为类的类”更让人头疼的,是那些“过于泛化的类”。所有 OOP 学生一开始都会从“橙子”这类毫无疑问就是类的例子学起。但在真实开发中,类可能会变得非常抽象。很多 OOP 程序都会有一个“应用程序类”,尽管“应用程序”本身并不是什么你能在物理世界里摸到的实体。然而,把应用程序本身表示成一个类有时仍然很有用,因为应用程序自己也有某些属性和行为。
所谓过于泛化的类,是指它根本不真正表示某个具体东西。程序员可能原本是想做一个灵活的、可复用的类,结果却造出了一个令人困惑的怪物。比如,想象你要做一个整理和展示媒体内容的程序:它可以整理照片、组织数字音乐和电影收藏,还可以充当私人日记。过于泛化的做法,就是把这些统统都视为“媒体对象”,然后构建一个单一的大类,试图容纳所有支持格式。这个类可能有一个叫做 data 的属性,用来保存图像、歌曲、电影或日记条目的原始比特;它还可能有一个叫做 perform 的行为,用来显示图像、播放歌曲、播放电影,或者把日记条目调出来供编辑。
这个类过于泛化的线索,其实就藏在这些属性和行为的命名里。data 这个词本身几乎没有信息量——你只能用这种空泛术语,因为这个类已经被硬生生扩展到好几种完全不同的用途上了。同样,perform 在不同类型媒体上会做完全不同的事情。很明显,这个类试图做得太多了。
不过,在设计一个媒体管理程序时,你的应用中当然还是很可能会有一个 Media 类。这个 Media 类应当包含各种媒体都共有的那些属性,例如名称、预览、对应媒体文件的链接等等。但这个 Media 类不应当包含处理特定媒体的细节。它不应该负责显示图片,也不应该负责播放歌曲或电影。相反,你的设计里应当还有其他类,例如 Picture 类和 Movie 类。这些更具体的类,才负责真正的媒体专属功能,例如显示图片或播放电影。显然,这些媒体专属类与 Media 类之间存在某种关系,而“如何表达类之间的关系”,正是下一节的主题。
作为程序员,你一定会遇到这样的情况:不同的类看起来拥有一些共同特征,或者彼此之间似乎存在某种联系。面向对象语言为处理这类关系提供了多种机制。困难之处在于,先搞清楚这种关系到底是什么。类关系大致可以分成两种主要类型:has-a 关系和 is-a 关系。
has-a 关系
Section titled “has-a 关系”参与 has-a 关系的类,遵循的是“A 拥有 B”或“A 包含 B”(也就是 “A has a B” / “A contains a B”)这样的模式。在这种关系里,你可以把一个类想象成另一个类的一部分。前面提到的组件,一般就代表一种 has-a 关系,因为它描述的是“一个类由其他类构成”。
现实世界中的一个例子,可以是动物园和猴子的关系。你可以说:动物园里有一只猴子,或者动物园包含一只猴子。于是,如果你在代码中模拟动物园,就会有一个 Zoo 类,而它拥有一个猴子组件。
用户界面的例子通常也很能帮助人理解类关系。原因是:虽然并非所有 UI 都用 OOP 实现(尽管如今大多数都是),但屏幕上的可视元素天然就很适合映射成类。一个典型的 has-a UI 类比,就是“包含一个按钮的窗口”。按钮和窗口显然是两个独立的类,但它们之间又确实存在联系。因为按钮位于窗口内部,所以我们会说“窗口有一个按钮”。
图 5.3 展示了一个现实世界和一个用户界面中的 has-a 关系示例。

[^FIGURE 5.3]
has-a 关系还可以分为两种:
- 聚合(aggregation): 在聚合关系中,即使聚合者对象被销毁,被聚合的对象(组件)仍然可以继续存在。例如,一个动物园对象里包含许多动物对象;当动物园因为破产而被销毁时,那些动物对象理想情况下不会一起消失,而是会被转移到另一个动物园。
- 组合(composition): 在组合关系中,如果一个由其他对象组成的对象被销毁,那么组成它的那些对象也会随之销毁。例如,一个包含按钮的窗口对象被销毁时,那些按钮对象也会一起被销毁。
is-a 关系(继承)
Section titled “is-a 关系(继承)”is-a 关系是面向对象编程中极其基础的概念,以至于它有很多名称,例如派生(deriving)、子类化(subclassing)、扩展(extending) 和继承(inheriting)。类建模的是现实世界中“对象拥有属性和行为”这一事实;而继承建模的是“这些对象通常组织成层次结构”这一事实。这些层次结构体现的,就是 is-a 关系。
从根本上说,继承遵循的模式是“A 是一种 B”(也就是 “A is a B”),或者“A 是一种‘具有某种额外特征的 B’”(也就是后面会看到的 “A is a B that is …”)——当然,事情很快就会变得微妙。先坚持简单场景,继续看动物园,但这次假设里面不只有猴子,还有别的动物。光这句话本身就已经建立起了关系:猴子是一种动物。类似地,长颈鹿是一种动物,袋鼠是一种动物,企鹅也是一种动物。那又如何呢?继承的魔力,就来自于你意识到:猴子、长颈鹿、袋鼠和企鹅之间有某些共通点,而这些共通点正是“动物”这个一般概念的特征。
这对于程序员意味着:你可以定义一个 Animal 类,把所有动物共有的属性(大小、位置、饮食等等)和行为(移动、进食、睡觉)封装进去。然后,像猴子这样的具体动物,就成为 Animal 的派生类,因为猴子包含了动物的一切共有特征。别忘了:猴子是一种动物,再加上一些让它区别于其他动物的附加特征。图 5.4 展示了一个动物继承关系图,其中箭头表示 is-a 关系的方向。

[^FIGURE 5.4]
就像猴子和长颈鹿是不同类型的动物一样,用户界面里通常也会有不同类型的按钮。例如,复选框就是一种按钮。假设按钮只是一个“可以被点击从而执行某个动作的 UI 元素”,那么 Checkbox 就是在 Button 类基础上扩展出“选中或未选中”状态的子类。
在建立 is-a 关系时,一个重要目标是把公共功能提炼进基类(base class) 里,也就是其他类从中扩展出来的那个类。如果你发现所有派生类都有很相似、甚至完全相同的代码,那就应该思考:是否可以把其中一部分或全部提到基类中。这样一来,需要修改时就只改一处,而将来的派生类也能“免费”继承到这些共享功能。
继承中的几种技术
Section titled “继承中的几种技术”前面的例子已经隐含展示了一些继承中常见的技术,只是还没有正式归纳。当你设计派生类时,程序员有多种方式让一个类区别于它的父类(parent class,也叫基类或超类)。派生类可能会使用其中一种或多种技术,而识别这些技术的一个办法,是补全这句话:“A 是一种 B,而且它……”(也就是 “A is a B that is …”)。
派生类可以通过增加额外功能来增强父类。例如,猴子是一种“会在树上荡来荡去”的动物。除了拥有 Animal 的所有成员函数之外,Monkey 类还可以额外拥有一个 swingFromTrees() 成员函数,而这正是 Monkey 自己特有的能力。
派生类也可以彻底替换,或者说重写(override) 父类的某个成员函数。例如,大多数动物是通过行走来移动的,所以你也许会给 Animal 类一个模拟行走的 move() 成员函数。如果是这样,那么袋鼠就是一种“通过跳跃而不是行走来移动”的动物。Animal 基类的其他属性和成员函数依旧适用,只是 Kangaroo 派生类改变了 move() 的实现方式。
当然,如果你发现自己几乎把基类的所有功能都替换掉了,那往往说明“继承”本身可能就不是正确做法——除非这个基类是一个抽象基类(abstract base class)。抽象基类要求每个派生类都去实现那些在基类中没有实现的成员函数,并且你无法创建抽象基类本身的实例。抽象基类会在第 10 章“深入继承技巧”中详细讨论。
派生类还可以在继承自基类属性的基础上,再引入新的属性。例如,企鹅拥有动物的一切属性,同时还可以额外拥有“喙的大小”这一属性。
C++ 的确提供了某种类似于“重写成员函数”的机制,允许你在派生类中隐藏属性。不过,这么做很少真的合适,因为它会把基类中的属性隐藏起来——也就是说,基类和派生类可能会同时拥有同名但彼此独立的两个属性。第 10 章 会更详细地解释这种“隐藏”。更重要的是,不要把“替换属性”与“派生类具有不同属性值”这两件事混淆。例如,所有动物都可以有一个表示食性的属性。猴子吃香蕉,企鹅吃鱼,但这并不是“替换了食性属性”;它们只是给同一个属性赋了不同的值。
多态(polymorphism) 指的是:那些遵守同一组属性和成员函数约定的对象,可以被互换使用。类定义就像对象与外部代码之间的一份契约。根据定义,任何 Monkey 对象都必须支持 Monkey 类规定的属性和成员函数。
这个观念同样延伸到基类。因为所有猴子都是动物,所以所有 Monkey 对象也都支持 Animal 类的属性和成员函数。
多态是 OOP 里极其美妙的一部分,因为它真正发挥了继承的力量。在动物园模拟里,你可以写一段代码,遍历动物园中的所有动物,并让每只动物都移动一次。因为它们全都属于 Animal 类,所以它们都“知道怎么移动”。某些动物也许重写了 move(),但最妙的地方就在这里:你的代码只需要告诉每个动物“去移动”,完全不必知道,也不必关心,它到底是哪一种动物。每种动物都会按照它自己的方式去移动。
has-a 与 is-a 之间的细线
Section titled “has-a 与 is-a 之间的细线”在现实世界中,对对象之间的 has-a 与 is-a 关系进行分类通常很容易。没人会说“橙子有一个水果”——橙子本身就是水果。但在代码里,事情有时就没这么清楚了。
考虑一个假想类,它表示一种关联数组(associative array)——一种能够高效地把键映射到值的数据结构。例如,保险公司可以用 AssociativeArray 把会员 ID 映射到姓名,这样只要给出一个 ID,就能轻松找到对应会员名。会员 ID 是键(key),而会员名是值(value)。
在标准关联数组实现中,一个键只关联一个值。如果 ID 14534 映射到会员名 “Kleper, Scott”,那它就不能同时映射到 “Kleper, Marni”。在大多数实现中,如果你试图为一个已经有值的键再插入第二个值,第一个值就会消失。换句话说,如果 ID 14534 先映射到 “Kleper, Scott”,然后你又把 ID 14534 设成 “Kleper, Marni”,那么 Scott 实际上就失去了保险。下面这段序列演示了这一点:它展示了对假想 insert() 成员函数的两次调用,以及关联数组中的结果内容。
myArray.insert(14534, "Kleper, Scott");| 键 | 值 |
|---|---|
| 14534 | “Kleper, Scott” [string] |
myArray.insert(14534, "Kleper, Marni");| 键 | 值 |
|---|---|
| 14534 | “Kleper, Marni” [string] |
要想象一种“类似关联数组,但允许同一个键对应多个值”的数据结构,并不困难。在保险这个例子里,一个家庭可能有多个成员姓名对应同一个 ID。既然这种数据结构和关联数组如此相似,自然会希望尽量复用已有功能。关联数组虽然每个键只能有一个值,但这个值本身可以是任意东西。因此,这个值不必是单个字符串;它也可以是一个包含多个字符串的集合(例如 vector)。每次你为已有 ID 添加新成员时,就把姓名追加进这个集合中。这样一来,它的工作流程可能会像下面这样:
Collection collection; // 创建一个新集合。collection.insert("Kleper, Scott"); // 向集合中加入一个新元素。myArray.insert(14534, collection); // 把集合插入数组。| 键 | 值 |
|---|---|
| 14534 | {“Kleper, Scott”} [Collection] |
Collection collection { myArray.get(14534) }; // 取出现有集合。collection.insert("Kleper, Marni"); // 向集合中追加一个元素。myArray.insert(14534, collection); // 用更新后的集合替换旧集合。| 键 | 值 |
|---|---|
| 14534 | {“Kleper, Scott”, “Kleper, Marni”} [Collection] |
直接围着集合和字符串打转既麻烦,又需要大量重复代码。更好的办法,是把这种“多值”能力封装进一个单独的类里,比如 MultiAssociativeArray。这个类的对外行为可能和 AssociativeArray 非常像,只不过在幕后,它把每个值都存成一个字符串集合,而不是单个字符串。显然,MultiAssociativeArray 与 AssociativeArray 有某种关系,因为它依然借助关联数组来存储数据。真正不那么清楚的,是:这到底应该建模成 is-a 关系,还是 has-a 关系?
先从 is-a 关系开始,假设 MultiAssociativeArray 是 AssociativeArray 的派生类。事实证明这会是个坏主意,但正适合拿来当“糟糕设计”的例子。MultiAssociativeArray 必须重写“向数组中加入新条目”的成员函数,以便在内部创建集合并添加新元素,或者取出现有集合并把新元素加进去。它还必须重写负责取值的成员函数。不过,这里会出现一个复杂点:重写后的 get() 成员函数应当返回的是“单个值”,而不是“集合”。那么 MultiAssociativeArray 到底该返回哪个值呢?一种办法,是返回与给定键关联的第一个值;再另外增加一个 getAll() 成员函数,用来取回与该键相关联的全部值。乍看之下,这似乎像是个挺合理的设计。尽管它重写了基类的所有成员函数,但它依旧在派生类内部复用了基类的某些成员函数。图 5.5 以 UML 类图展示了这种方案。

[^FIGURE 5.5]
现在再把它看成 has-a 关系。MultiAssociativeArray 是一个独立的类,但它内部包含了一个 AssociativeArray 对象。它的接口大概会和 AssociativeArray 相似,但不必完全相同。在幕后,当用户往 MultiAssociativeArray 里添加内容时,内容实际上会先被包装进一个集合,再存入 AssociativeArray 对象中。这看起来就相当合理,如图 5.6 所示。

[^FIGURE 5.6]
那么,到底哪种方案才是正确的呢?看起来似乎没有绝对标准答案。但几十年的经验告诉我们:在这两者之间,has-a 往往才是更好的选择。最主要的原因,是它允许你调整公开接口,而不必担心还得维持“关联数组”原本的全部语义。例如,在图 5.6 里,get() 被改成了 getAll(),这一下就清楚表明:它获取的是 MultiAssociativeArray 中某个键关联的所有值。此外,使用 has-a 关系时,你也不必担心 AssociativeArray 的那些功能“渗漏”到 MultiAssociativeArray 上。举例来说,如果 AssociativeArray 支持一个“获取总值数量”的成员函数,那么在 is-a 关系下,它返回的就会是“集合的个数”,除非 MultiAssociativeArray 还记得把它也重写掉。
当然,你仍然可以尝试论证:MultiAssociativeArray 本质上就是“带有额外功能的 AssociativeArray”,所以它应该被建模成 is-a 关系。这里真正想说明的是:这两种关系之间有时的确存在一条很细的分界线,而你必须根据类将来如何被使用,来判断你构建的这个东西,究竟只是“借用了另一个类的一些功能”,还是它真的就是“那个类的一个修改版或增强版”。
下表总结了针对 MultiAssociativeArray 采用两种不同设计方案时,各自的支持理由与反对理由:
| IS-A | HAS-A | |
|---|---|---|
| 支持理由 | 从根本上说,它还是同一类抽象,只是特征不同。它提供了(几乎)与 AssociativeArray 相同的成员函数。 | MultiAssociativeArray 可以自由拥有任何有用的成员函数,而不需要受 AssociativeArray 现有成员函数的牵制。其内部实现将来甚至可以从 AssociativeArray 改成别的东西,而无需改变对外接口。 |
| 反对理由 | 关联数组按定义就是“每个键只有一个值”。把 MultiAssociativeArray 说成关联数组,几乎是在亵渎这个概念!另外,MultiAssociativeArray 把 AssociativeArray 的两个成员函数都重写掉,本身就是设计有问题的强烈信号。更糟的是,AssociativeArray 某些未知的或不合适的属性、成员函数,还可能“渗漏”进 MultiAssociativeArray。 | 从某种意义上说,MultiAssociativeArray 通过设计新成员函数,像是在重新发明轮子。AssociativeArray 里某些额外属性或成员函数,原本也许是有用的。 |
在这个场景下,反对使用 is-a 关系的理由非常充分。另外,里氏替换原则(LSP, Liskov substitution principle) 也能帮助你在 is-a 和 has-a 之间做决定。这个原则说的是:在不改变程序行为的前提下,派生类应该能够替换基类。套用到这里,就意味着这必须是 has-a 关系,因为你不能简单地把原本使用 AssociativeArray 的地方,全都替换成 MultiAssociativeArray。如果这么做,行为就变了。比如,AssociativeArray 的 insert() 会删除数组中已存在的同键旧值,而 MultiAssociativeArray 则不会删除这类值。
本节详细解释的这两种方案,其实也并不是唯一可能的方案。其他做法也可能成立,例如让 AssociativeArray 反过来包含一个 MultiAssociativeArray,或者让它们两者都从某个共同基类继承,等等。面对一个具体设计问题,你通常都能想出不止一种解决方案。
如果你确实可以在这两种关系之间做选择,那么基于多年的经验,我建议优先选择 has-a,而不是 is-a。
请注意,这里之所以使用 AssociativeArray 和 MultiAssociativeArray,只是为了演示 is-a 与 has-a 之间的差异。在你自己的代码中,更推荐直接使用标准库中现成的关联容器,而不是自己重写。C++ 标准库提供了 std::map(适合作为 AssociativeArray 的替代品)和 std::multimap(适合作为 MultiAssociativeArray 的替代品)。这两个标准类会在第 18 章“标准库容器”中讨论。
not-a 关系
Section titled “not-a 关系”当你思考类之间究竟存在什么关系时,也别忘了先问一句:它们之间真的有关系吗?不要让你对面向对象设计的热情,演变成一大堆根本没必要的类/派生类关系。
一种常见陷阱是:两个东西在现实世界里显然彼此相关,但在代码里却并没有真正有意义的关系。面向对象层次结构需要建模的是功能性的关系,而不是人为拼凑出来的关系。图 5.7 展示的就是这样一类关系:它们作为本体论或分类层次也许很有意义,但在代码中却未必对应真正有用的关系。

[^FIGURE 5.7]
避免不必要继承的最佳办法,是先把设计草图画出来。对每个类和派生类,都写下你打算放进这个类里的属性与成员函数。如果你发现某个类没有任何属于它自己的特定属性或成员函数,或者它的这些属性和成员函数全都被派生类彻底覆盖了,那么你就应该重新思考这个设计——当然,前面提到的抽象基类属于例外。
正如类 A 可以成为 B 的基类,B 也完全可以成为 C 的基类。面向对象层次结构可以用来建模这种多级关系。一个包含更多动物的动物园模拟,完全可以设计成:每种动物都是共同基类 Animal 的某个派生类,如图 5.8 所示。

[^FIGURE 5.8]
当你真正开始编码这些派生类时,可能会发现其中很多其实非常相似。一旦如此,就应该考虑再插入一个共同父类。比如,如果你意识到 Lion 和 Panther 不仅移动方式相同,而且饮食也相同,那么这也许表明你需要一个 BigCat 类。你还可以继续细分 Animal,例如增加 WaterAnimal 和 Marsupial。图 5.9 展示了一种利用这种共性的、更具层次感的设计。

[^FIGURE 5.9]
生物学家看到这个层次结构,也许会很失望——企鹅和海豚在生物分类上并不属于同一家族。但这正好强调了一个很重要的点:在代码中,你必须平衡“现实世界关系”和“共享功能关系”。即使两种东西在现实世界里很接近,它们在代码中也可能根本不存在关系,因为它们实际上并不共享功能。你完全可以把动物分成哺乳动物和鱼类,但那未必能帮助你把共性提炼到基类中去。
另一个重要点是:层次结构的组织方式通常不止一种。前面的设计主要是按“动物如何移动”来组织的;如果改成按“动物吃什么”或“动物身高”来组织,层次结构可能就会完全不同。最终起决定作用的,还是这些类将如何被使用。需求会决定类层次结构该怎么设计。
一个好的面向对象层次结构应当做到:
- 以有意义的功能关系来组织类
- 通过把共通功能提炼到基类中来支持代码复用
- 避免派生类重写父类的大量功能,除非父类是抽象基类
到目前为止,前面的每个例子都只有一条继承链。换句话说,一个给定类至多只有一个直接父类。但事情并不一定非得如此。通过多重继承(multiple inheritance),一个类可以拥有不止一个基类。
图 5.10 展示了一种多重继承设计。这里仍然有一个叫做 Animal 的基类,但它会进一步按大小划分;另有一条层次结构按饮食分类,第三条层次结构则按移动方式分类。于是,每一种动物都同时派生自这三条层次中的某个类,如图中的不同连线所示。

[^FIGURE 5.10]
在用户界面语境下,想象一张用户可以点击的图片。这个类看起来既像按钮,又像图片,因此它的实现就可能同时继承 Image 类和 Button 类,如图 5.11 所示。

[^FIGURE 5.11]
多重继承在某些场景下确实有用,但它也有不少缺点,必须始终牢记。许多程序员都不喜欢多重继承。C++ 明确支持这类关系,而 Java 则基本完全废除了它,除了“从多个接口(抽象基类)继承”之外。批评者通常会给出几个理由。
首先,多重继承在可视化层面就很复杂。正如你从图 5.10 中看到的那样,一旦存在多条层次结构和相互交叉的连线,即便是一个简单的类图,也会迅速变得复杂。类层次结构本来应该帮助程序员更容易理解代码之间的关系;可在多重继承下,一个类可能拥有多个彼此完全不相关的父类。当有这么多类同时向一个对象“贡献代码”时,你真的还能轻松跟踪发生了什么吗?
其次,多重继承可能会破坏原本干净的层次结构。在动物的例子里,一旦切换到多重继承,Animal 这个基类本身就会变得没那么有意义,因为描述动物的代码已经被拆散到三条独立层次结构里了。虽然图 5.10 看起来展示的是三条干净的层次,但不难想象它们会如何逐渐混乱起来。例如,如果你后来意识到所有 Jumper 不仅跳跃方式相同,而且吃的东西也相同,该怎么办?因为“移动方式”和“饮食”属于不同层次结构,你没法在不继续增加额外派生类的情况下,把这两个概念优雅地合并起来。
第三,多重继承在实现上也更加复杂。如果两个基类以不同方式实现了同一个成员函数,该怎么办?如果两个基类本身又都派生自一个共同基类,又怎么办?这些可能性都会让实现变得复杂,因为在代码中组织如此精细的关系,不管是对作者还是读者来说,都很不友好。
其他语言之所以能彻底拿掉多重继承,是因为它通常都可以避免。只要重新思考你的层次结构,当你能够掌控项目设计时,往往都能避免把多重继承引入系统中。
Mixin 类
Section titled “Mixin 类”Mixin 类代表的是类之间另一种关系。在 C++ 中,实现 mixin 类的一种方式,在语法上看起来与多重继承相似,但其语义却完全不同。mixin 类回答的问题是:“这个类还能做什么?”而答案通常以 “-able” 结尾。Mixin 类是一种“在不承诺完整 is-a 关系的前提下,为类增加功能”的方式。你可以把它理解为一种“共享能力”的关系。
回到动物园的例子,你也许想引入这样一个概念:有些动物是“可抚摸的”(pettable)。也就是说,游客可以摸这些动物,而且大概率不会被咬或被抓伤。你可能希望所有可抚摸动物都支持“被抚摸”这一行为。由于这些可抚摸动物除此之外并没有别的明显共性,而你又不想破坏已经设计好的现有层次结构,那么 Pettable 就是一个非常适合的 mixin 类。
Mixin 类在用户界面里也很常见。与其说某个 PictureButton 类既是 Image 又是 Button,不如说它是一个 Image,同时又是 Clickable。桌面上的文件夹图标也可以是一个 Image,同时还是 Draggable 和 Clickable。软件开发者总是很喜欢发明各种有趣的形容词。
Mixin 类与基类之间的区别,更多体现在你如何理解这个类,而不是某种具体代码差异。一般来说,mixin 类比多重继承更容易消化,因为它们的作用域通常非常有限。Pettable mixin 类只是给任意现有类增加一个行为;Clickable mixin 类可能也只是增加“鼠标按下”和“鼠标抬起”行为。此外,mixin 类很少形成庞大层次结构,因此不会出现功能的交叉污染。第 32 章“结合设计技术与框架”会更详细地讨论 mixin 类。
在这一章中,你在不被大量代码细节淹没的前提下,理解了面向对象程序设计的核心思路。你学到的这些概念,几乎适用于任何面向对象语言。对你来说,其中一些内容也许只是复习,也许是一种把熟悉概念重新形式化的新方式。你也许学到了一些解决老问题的新角度,或者得到了新的论据,去支持你一直在团队里倡导的那些设计原则。即使你过去从未在代码中使用过类,或者只是偶尔使用,现在你在“如何设计面向对象程序”这件事上,也已经比很多经验丰富的 C++ 程序员理解得更透彻了。
研究类之间的关系很重要,不只是因为相互联系良好的类有助于代码复用、减少混乱,还因为你往往是与团队协作开发。以有意义方式组织起来的类,会更容易阅读,也更容易维护。在你设计程序时,完全可以把“类关系”这一节当作参考资料反复回看。
下一章会继续围绕设计主题展开,讨论如何在设计代码时把“复用”纳入考虑。
通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的解答都可以在本书网站 www.wiley.com/go/proc++6e 的代码下载中找到。不过,如果你在做题时卡住了,我仍然建议先回头重读本章的相关部分,尽量自己找到思路,再去查看网站上的答案。
本章练习并不存在唯一正确答案。正如你在本章中看到的,同一个问题通常可以有多种设计方案,而每种方案都有不同权衡。附带解答只是解释了一种可能设计,并不意味着你的方案必须和它完全一致。
-
练习 5-1: 假设你想编写一个赛车游戏。你需要为“汽车本身”建立某种模型。假设在这个练习里只有一种汽车类型。每个汽车实例都需要追踪若干属性,例如发动机当前输出功率、当前燃油使用量、轮胎压力、行车灯是否打开、雨刷器是否处于工作状态等等。游戏还应允许玩家为汽车配置不同的发动机、不同的轮胎、自定义行车灯和雨刷器等等。你会如何为这样一辆汽车建模?为什么?
-
练习 5-2: 继续练习 5-1 的赛车游戏。你当然会希望同时支持人类驾驶的汽车,以及由人工智能(AI)驾驶的汽车。你会如何在游戏中建模这种差异?
-
练习 5-3: 假设某个人力资源(HR)应用的一部分里有下面三个类:
Employee: 追踪员工 ID、工资、入职日期等信息Person: 追踪姓名和地址Manager: 追踪其团队中有哪些员工
你如何看待图 5.12中的高层类图?你会对它做什么修改吗?图中没有展示各个类的属性与行为,因为那正是练习 5-4 的主题。

-
练习 5-4: 从练习 5-3 最终得到的类图出发,给类图增加一些行为和属性。最后,再把“经理管理员工团队”这一事实建模出来。