《C++编程惯用法——高级程序员常用方法和技巧》——1.2 抽象模型间的关系

简介:

本节书摘来自异步社区出版社《C++编程惯用法——高级程序员常用方法和技巧》一书中的第1章,第1.2节,作者: 【美】Robert B. Murray ,更多章节内容可以访问云栖社区“异步社区”公众号查看。

1.2 抽象模型间的关系

作为初始设计过程的部分,设计人员应该仔细地考虑抽象模型与其他模型间的交互,问自己一系列相关的问题。这并不意味着我们需要一个正式的问题清单,根据应用程序的不同,可能还存在着其他的一些同样重要的问题需要设计人员去早早检测。在本小节中,我们只给出那些经常出现的问题。

问自己这些问题还有着另外一个重要的好处。设计者经常犯的错误就是:这些问题中的部分通常都具有一个看起来十分明显的答案,不过事实上这个答案却是错误的。当你在设计时问自己这些问题时,最好在那些能够很容易就得到答案的问题上停下来,并更加深入地对它们进行思索。这个(显而易见的)答案是不是对的呢?试图去设想一个可以证明它是错误的场景。只要你能够在这种思索过程中找到哪怕只是一个错误场景,你都有可能会因此避免一个将来会给你带来高昂代价的设计错误。

1.2.1 一对一,一对多,多对一,还是多对多?

现在我们来考虑电话和连接之间的关系。一个电话是否可以有多个连接呢?通常的答案都是肯定的,通话中的一方可以接通新的电话而不挂断已有连接。我们同样也要考虑相反的另外一个问题:是否可以有多于一部的电话参与到一个连接中呢?在此处的答案毫无疑问也是肯定的,因为这就是电话的核心用途!当我们关注于这个话题时,我们应该考虑的是,是否可以有多于两部的电话同时参与到一个连接中呢?虽然可能有点会令人感到惊讶,但这个问题的答案同样也是肯定的,电话会议在同一时间时的参与者可以多于三个。因此我们得到的结论是:电话和连接之间的关系是一种多对多的关系。

作为另外一个例子,我们来考虑电话号码与账单地址之间的关系。一个电话号码只能属于一个客户,由此它也只有一个相关的账单地址。然而,同样一个地址却可以有多个电话号码与之相关联。这就是一种多对一的关系(多个电话号码对应于一个账单地址)。

1.2.2 多少才算“多”?

如果连接是多对一或者多对多,那么让我们来想想:究竟这个“多”暗示着多少个对象呢?此时,我们并不需要一个精确的数字,我们想知道的只是,“多”究竟是代表着以下哪一种:

二(或者大于它的一个常数数字);
某个常量范围;例如,如果我们讨论的抽象是“一周之内的天数”,那么在一个这样的抽象中,我们最多只能有7个这样的对象;
某个变化的范围,大概有好几(百?千?万?)。
需要小心的是:即便是定义得很好的抽象模型,它最终还是会被别人以一种设计者从来都没有考虑过的方式使用。如果我们假设“多数”永远不会超过几十,并由此在实现中使用了一个二次方程式的算法,那么我们的用户在上千个对象的场景下使用我们的实现时,就将得到令人惊讶兼恶心的结果。

1.2.3 如何来保证关系会随着时间的改变而改变?

我们是否必须在对象被创建时确立下来关系的存在呢?当关系被确立后,新创建的对象是否可以参与到其中呢?

显然,电话号码可以独立于连接而存在;但连接无论在何时都必须至少涉及一个电话号码。对于连接的另一端的电话,我们对于它有什么要求?是否应该在连接建立的同时就提供它,还是在连接建立后再把它加入连接?从另一方面来考虑的是,一个连接是否可以只涉及一个电话存在?检索(而不是呼叫)电话号码的行为是否会建立一个连接?

回答这些问题需要我们对使用这些对象的应用有着相当的了解。我们需要知道足够多的电话学知识,以此来决定应用程序是否需要在连接正在建立或者中断时对它进行访问。为了讨论方便,我们假定这样的连接有着实际上的作用;例如:计费软件可能需要访问它来记录每次呼叫结束后应该花费的费用。这意味着被呼叫方应该在连接建立时就加入到连接中去。

这个模型同样也可以很好地应用到三方通话中去。此时的连接并不是在同时被建立的,而是随着时间的过去才被建立起来。每次当一个新的成员被加入到通话中时,他们只需要加入已有的连接就可以了。

此时,我们已经对前面的“官面总结(executive summary)”进行了一个细化。那个总结暗示着:一个连接只能存在于两部(或多部)的电话之间。现在我们认识到,那种说法并不全对:我们对于那些可以通过电话网络在一组电话(一部或多部)间相互交谈的方式更感兴趣。于是,我们又可以将总结改为如下形式:

“连接表示的是一组(一部或多部)电话间通过电话网络相互进行交流。”

基于我们对它的不断深入了解,得到这样的抽象细化是一件很正常的事情。在此例中,“连接”这个名词仍然有其意义;但它并不总是对的,我们应该随时准备,在原有的名字不能充分地阐述其意义时为抽象另取一个合适的名字。

1.2.4 is-A,has-A,还是use-A?

不同的抽象以不同的方式相关联。我们可以把三种最常见的关系归纳为:is-a、has-a以及use-a:

is-a关系
当两个类之间存在着下述关系时,我们就说它们之间存在着is-a关系:其中一个类所描述的对象属于另外一个类所描述的对象集。例如:Studebaker是一种(is-a)Car。

is-a关系的另外一种说法就是子类型化。子类型(subtype)是某些更为通用的类型[也称为超类型(supertype)]的特殊化;子类型的对象同样也是超类型的对象。这些术语源自于Smalltalk。许多C++程序员则使用C++中具有相同意义的两个词:派生类(derived class)来代替子类型,基类(base class)来代替父类型。

例如:“按键电话”和“电话”之间的关系是什么?每部按键电话同样也是一部电话。按键电话只是电话中的一个子集(图1.1)。

image

在C++中,我们使用公用继承来表示is-a关系:类Push_button_phone应该派生自类Telephone:

class Telephone{
  // Telephone stuff...
};
class Push_button_phone:public Telephone {
  // Push_button_phone stuff...
};

子类型(或者说派生类)可以对父类型(或者说基类)进行扩展;一部按键电话可以做一些其他电话做不到的事情。但子类型永远也不能对父类型有所限制(例如:在派生类将一个在基类中的公用成员函数改为私用);同样,电话能够做到的事情,按键电话都应该能做到。(如果不是这样的话,按键电话也就不应该是电话中的一个子集。)

has-a关系
has-a关系意味着包含,如果在概念上事物A包含事物B,那么A也就拥有B。电话包括一个扬声器和一个麦克风;按键电话还包括一个键盘。

与is-a不同的是,在has-a关系中,没有一个对象是另外一个对象的特例,取而代之的是,一个对象是另外一个对象的一部分。一个Push_button phone并不是一个Keypad,而是拥有一个Keypad。

C++中的has-a关系的通常实现方式是将被包含的对象作为包含它的对象的一个成员。然而,实际上我们并不一定需要这样做。虽然在概念上来讲,Keypad被包含于Push_button_phone中,但这并不意味着我们在实现这种关系时必须使用真正的包含。在实际中,总有很多好的理由让我们不必那么做(我们将在第3章讲述这一点);数据抽象的一个好处在于,对象的实现方式并不需要完全与抽象结构相匹配。我们不应该把这种方式(指用成员来实现has-a关系)来作为主要的考量标准,因为我们所处理的是抽象之间的关系,而不是它们的实现细节。

use-a关系
use-a关系应用得最普遍:在这种关系中,没有一个对象会是另外一种对象(is-a),也没有一个对象包含另外一个对象(has-a)。取而代之的是,这两个对象仅在程序中某些点进行简单的联系。在我们的电话例子中,电话和连接间具有的就是use-a关系。

相互使用的对象间通常通过调用彼此的成员函数来进行联系。当然,还存在着其他的联系方式(如:共享内存,或者是某种消息传输机制)可以做到这一点。

1.2.5 关系是单向的还是双向的

给定两个对象之间的一个关系,我们是否就可以通过其中的一个对象来得到另外一个对象的信息呢?也就是说,如果我们已经知道了电话和连接之间的关系,那么我们是否可以从连接中得到有关电话的信息,或者是从电话中得到连接的信息呢?这个问题的答案对于编写实现该抽象模型的程序的性能来说,起着本质的影响作用。

单向关系可以用C++中的指针来实现。给定一个对象,你就可以通过该指针得到另外一个对象。这种行为的反转(从一个对象得到指向它的对象)则非常困难;因为它通常都涉及从一大堆对象中进行检索,判断一个对象是否正好指向给定的那个对象。根据具体情况的变化,这种做法有时也并不完全那么不好,导致的开销也不那么大,或者是完成的困难程度并不是不可能达到的。

双向关系的使用要简单得多了:给定任意一个对象,我们都可以直接地找到另外的那个对象。然而,建立一个双向关系的开销也要比建立单向关系的开销大得多。我们需要对这两个对象同时进行一定程度的修改,双向关系可能会导致要求更多的空间,更多的运行时间;这一点在关系随着时间同时变化时更为明显。改变一个单向关系可能只涉及一个指针的改变。由于往两个对象中增添新的关系可能会导致已有关系的失衡,改变一个双向关系的操作可能涉及三个(甚至更多的)指针变化。在两个对象间,单向关系要显得更快、更小,也更容易被编程实现,但它却限制了用户使用抽象模型的能力。这也是实现细节可以(也是必要)影响设计思路的一个常见的地方。

再回到我们关于电话的那个例子中来,电话和连接之间的关系在大多数情况下都应该是双向的:只要给定一部电话,我们就可以找到连接端的另外一部电话。如果经验不足,我们则可能会采取另外一种方法,那就是:查找每一部已有的电话,判断它们是不是处在同一个连接中。

通常我们的选择都不是那么简单明了。例如,考虑一个编译器,在其中表达式被作为一个树状的结构来处理,我们可以将a = b + c表示成图1.2中所示的树。那么在树中,节点和它们的子节点之间的关系是单向的还是双向的呢?在实际中,为了提高效率,这个关系通常都是单向的,程序也可以以此来编写。例如,如果我们都是自顶而下地来处理每棵树,那么在树中保留一个指向父节点的指针则显得有点多余。
image

1.2.6 两个对象中是否可以有着多重同样的关系

给定两个对象间的关系,我们是否可以在这两个对象中保留多份该关系呢?例如:“父亲”这个关系只能在两个对象间存在一份,一个对象只能是或者不是另外那个对象的父亲。诸如“他是我父亲父亲”这样的表述显得毫无意义。而对于关系“文章引用的书籍”来说,当文章中引用同一本书中的内容超过两次后则可以存在多份这样的关系。

1.2.7 关系是否是必需的

对于每个对象来说,关系中是否就一定要至少包含一个对象呢?例如,如果有一个用来表示职工的类Employee,那么关系“向……汇报”是否就一定要包括所有的职工呢?对于部门的主管来说,它应该如何处理?我们的类的结构是否支持主管向整个部门进行汇报的功能?把主管的汇报对象改为空(或者是他自己)会不会更容易一些?

相关文章
|
30天前
|
安全 算法 C++
【C/C++ 泛型编程 应用篇】C++ 如何通过Type traits处理弱枚举和强枚举
【C/C++ 泛型编程 应用篇】C++ 如何通过Type traits处理弱枚举和强枚举
48 3
|
1月前
|
安全 算法 编译器
【C++ 泛型编程 进阶篇】深入探究C++模板参数推导:从基础到高级
【C++ 泛型编程 进阶篇】深入探究C++模板参数推导:从基础到高级
248 3
|
1月前
|
存储 算法 编译器
【C++ TypeName用法 】掌握C++中的TypeName:模板编程的瑞士军刀
【C++ TypeName用法 】掌握C++中的TypeName:模板编程的瑞士军刀
238 0
|
1月前
|
安全 算法 C++
【C++泛型编程 进阶篇】模板返回值的优雅处理(二)
【C++泛型编程 进阶篇】模板返回值的优雅处理
33 0
|
1月前
|
算法 编译器 数据库
【C++ 泛型编程 高级篇】使用SFINAE和if constexpr灵活处理类型进行条件编译
【C++ 泛型编程 高级篇】使用SFINAE和if constexpr灵活处理类型进行条件编译
246 0
|
1月前
|
JSON JavaScript 前端开发
C++ 智能指针与 JSON 处理:高级编程技巧与常见问题解析
C++ 智能指针与 JSON 处理:高级编程技巧与常见问题解析
269 0
|
1月前
|
设计模式 程序员 C++
【C++ 泛型编程 高级篇】C++模板元编程:使用模板特化 灵活提取嵌套类型与多容器兼容性
【C++ 泛型编程 高级篇】C++模板元编程:使用模板特化 灵活提取嵌套类型与多容器兼容性
259 2
|
1天前
|
算法 编译器 C语言
探索C++编程的奥秘与魅力
探索C++编程的奥秘与魅力
|
3天前
|
存储 搜索推荐 C++
【C++高阶(二)】熟悉STL中的map和set --了解KV模型和pair结构
【C++高阶(二)】熟悉STL中的map和set --了解KV模型和pair结构
|
9天前
|
编译器 C++
C++编程之美:探索初始化之源、静态之恒、友情之桥与匿名之韵
C++编程之美:探索初始化之源、静态之恒、友情之桥与匿名之韵
21 0