Stack是我们将设计和实现的计算器的第一个模块。 虽然我在第 2 章中定义了模块的公共接口,但我很少谈到它的实现。 在 C++ 中,模块不是定义的语言概念。 因此,我们基本上需要将Stack分解为函数和类的逻辑分组,并将其称为我们的模块。 因此,这就是我们开始的地方。如果您对Stack数据结构的机制有点生疏,那么现在是查阅您最喜欢的数据结构和算法书籍的好时机。 我个人最喜欢的是 Cormen 等人 [5] 的作品。
3.1 Stack模块的分解
分解Stack模块时要问的第一个问题是:“Stack应该分成多少块?”用面向对象的说法,我们问:“我们需要多少个对象,它们是什么?”在这种情况下,答案是相当明显的:一个,Stack本身。从本质上讲,整个Stack模块是一个单一的数据结构的表现,它可以很容易地被一个类所封装。这个类的公共接口已经在第二章中描述过了。
人们可能会问的第二个问题是,“我到底需不需要建立一个类,或者我可以直接使用标准模板库(STL)的Stack类?”这其实是一个非常好的问题。所有的设计书都宣扬,当你可以使用一个库中的数据结构时,你不应该写你自己的数据结构,特别是当数据结构可以在STL中找到时,STL保证是符合标准的C++发行版的一部分。的确,这是一个明智的建议,我们不应该重写Stack数据结构的机制。然而,我们也不应该直接使用STL栈作为我们系统中的栈。相反,我们将编写我们自己的Stack类,将STL容器封装为一个私有成员。
假设我们选择使用STL的Stack来实现我们的Stack模块。有几个原因使我们更倾向于封装STL容器(或任何供应商的数据结构)而不是直接利用。首先,通过封装STLStack,我们为计算器的其他部分设置了一个接口保护。也就是说,我们通过分离Stack的接口和实现,使其他计算器模块与底层Stack实现的潜在变化隔离开来(还记得封装吗)。当使用供应商的软件时,这种预防措施特别重要,因为这种设计决定将变化定位在包装器的实现上而不是Stack模块的接口上。如果供应商修改了其产品的接口(供应商就是这样偷偷摸摸的),或者你决定用一个供应商的产品换另一个供应商的产品,这些变化只会局部影响你的Stack模块的实现,而不会影响Stack模块的调用者。即使底层实现是标准化的,比如ISO标准化的STLStack,接口保护也能使你改变底层实现而不影响依赖的模块。例如,如果你改变了主意,后来决定重新实现你的Stack模块,例如使用一个向量而不是Stack。
包裹STL容器而不是直接使用它的第二个原因是,这个决定允许我们限制接口,使其完全符合我们的要求。在第二章中,我们花费了大量的精力为Stack模块设计了一个有限的、最小的接口,能够满足pdCalc的所有用例。通常情况下,一个底层实现所提供的功能可能比你实际想要暴露的功能要多。如果我们直接选择 STL 的Stack作为我们的Stack模块,这个问题就不会很严重,因为 STL 的Stack的接口与我们为计算器的Stack定义的接口非常相似,这并不奇怪。然而,假设我们选择了Acme公司的RichStack类及其67个公共成员函数作为我们的Stack模块来使用。一个忽略了阅读设计规范的初级开发者可能会在不知不觉中违反我们的Stack模块的一些隐含的设计契约,调用一个不应该在应用程序的上下文中公开暴露的RichStack函数。虽然这种滥用可能与模块的文档接口不一致,但人们永远不应该依赖其他开发者真正阅读或遵守文档(可悲但真实)。如果你能通过编译器可以强制执行的语言结构(如访问限制)来强行阻止滥用的发生,那就这么做。
封装STL容器的第三个原因是为了扩展或修改底层数据结构的功能。例如,对于pdCalc,我们需要添加STLStack类中没有的两个函数(getElements()
和swapTop()
),并且将错误处理从标准的异常转化为我们自定义的错误事件。因此,包装类使我们能够修改STL的标准容器接口,这样我们就可以符合我们自己内部设计的接口,而不是被STL提供给我们的功能所约束。
正如人们所期望的那样,上述的封装情况经常发生,因此被编成了一种设计模式,即适配器(包装器)模式[6]。正如Gamma等人所描述的,适配器模式被用来将一个类的接口转换为客户所期望的另一个接口。通常情况下,适配器提供了某种形式的转换能力,从而也成为了其他不兼容的类之间的中介。
在该模式的原始描述中,对适配器进行了抽象,以允许单个消息使用适配器类层次结构通过多态性包装多个不同的适配器。对于pdCalc的Stack模块的需要,一个简单的具体的适配器类就足够了。
记住,设计模式的存在是为了帮助设计和交流。尽量不要陷入完全按照文本中规定的方式实现模式的陷阱。使用文献作为指导来帮助澄清您的设计,但是,最终,最好实现适合您的应用程序的最简单的解决方案,而不是最接近学术理想的解决方案。
我们应该问的最后一个问题是,“我的Stack应该是通用的(即模板化)吗?”答案是:“也许”。在理论上,设计一个抽象的数据结构来封装任何数据类型是合理的做法。如果数据结构的最终目标是出现在一个库中或被多个项目共享,那么数据结构应该是通用的。然而,在单个项目的背景下,我不建议使数据结构通用化,至少在开始时不建议。通用的代码更难写,更难维护,也更难测试。除非前期存在多种类型的使用场景,否则我认为编写泛型代码是不值得的。我已经完成了太多的项目,在这些项目中,我花费了额外的时间来设计、实现和测试一个通用的数据结构,只是为了将其用于一种类型。现实地说,如果你有一个非通用的数据结构,但突然发现你确实需要把它用于不同的类型,那么必要的重构通常不会比一开始就把类设计成通用的更困难。此外,现有的测试将很容易适应通用接口,提供一个由单一类型建立的正确性基线。因此,我们将设计我们的Stack,使其具有双重特性。
3.2 Stack类
现在我们已经确定我们的模块将由一个类组成,即一个底层Stack数据结构的适配器,让我们来设计它。在设计一个类时,首先要问的一个问题是:“这个类将如何被使用?”例如,你是否在设计一个抽象的基类,以便被继承,从而被多态地使用?你是在设计一个主要作为普通数据(POD)库的类吗?这个类的许多不同的实例会在任何时候存在吗?任何给定实例的寿命是多少?通常谁会拥有这个类的实例?实例是否会被共享?这个类会被同时使用吗?通过询问这些问题和其他类似的问题,我们发现了我们的Stack的功能需求清单如下。
- 系统中只应该有一个Stack。
- Stack的寿命就是应用程序的寿命。
- UI和命令调度器都需要访问Stack,两者都不应该拥有Stack。
- 栈的访问不是并发的。
只要满足上述前三个条件,该类就是单例模式的最佳候选类[6]。
3.2.1 单例模式
单例模式被用来创建一个在系统中只应该存在一个实例的类。单例类不为任何消费者所拥有,但该类的单一实例也不是全局变量(然而,有些人认为,单身模式是变相的全局数据)。为了不依赖荣誉系统,语言机制被用来确保只有一个实例可以存在。此外,在单例模式中,实例的生命周期通常是从第一次实例化开始,直到程序终止。根据不同的实现,单例可以被创建为线程安全的,也可以只适用于单线程的应用。在Alexandrescu [2]中可以找到关于不同C++单例实现的精彩讨论。对于我们的计算器,我们将使用最简单的实现来满足我们的目标。
为了推导出一个简单的单例实现,我们参考了我们对C++语言的知识。首先,正如之前所讨论的,没有其他类拥有一个单例实例,单例的实例也不是一个全局对象。这意味着单例类需要拥有它的单一实例,而且所有权访问应该是私有的。为了防止其他类实例化我们的单例,我们还需要使其构造函数和赋值运算符为私有或删除。其次,知道系统中只应该存在一个单例,这立即意味着我们的类应该静态地持有它的实例。最后,其他类需要访问这个单例,我们可以通过一个公共静态函数来提供。结合以上几点,我们构建了清单3-1中所示的单例类的外壳。
清单3-1. Singleton类的Shell
class Singleton {
public:
static Singleton& Instance()
{
static Singleton instance;
return instance;
}
void foo()
{
/* does foo things */
}
private:
// prevent public instantiation, copying, assignment, movement, destruction
Singleton()
{
/* constructor */
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton&& operator=(Singleton&&) = delete;
~Singleton()
{
/* destructor */
}
};
单例类的静态实例被保留在函数范围而不是类范围内,以防止在一个单例类的构造函数依赖于另一个单例的情况下发生不可控制的实例化顺序冲突。C++的实例化顺序规则的细节超出了本书的范围,但在单例的背景下的详细讨论可以在Alexandrescu [2]中找到。
请注意,由于对一个实例的访问缺乏锁定,我们的单例模型目前只适合于单线程环境。在这个多核处理器的时代,这样的限制是明智的吗?对于pdCalc来说,没问题!我们的简单的计算器没有必要使用多线程。编程是很难的,多线程编程则更难。除非是绝对必要,否则不要把一个简单的设计问题变成一个更难的问题。
现在我们有了Singleton类的外壳,让我们看看如何使用它。为了访问实例并调用foo()函数,我们只需使用以下代码:
Singleton::Instance().foo();
在第一次调用Instance()函数时,实例变量被静态地实例化,并返回对该对象的一个引用。由于在函数范围内静态分配的对象在程序终止前一直保留在内存中,因此实例对象在Instance()函数的范围结束后不会被销毁。在以后调用Instance()时,实例变量的实例化将被跳过(它已经被构造出来,并且在前一次函数调用时就在内存中),并且简单地返回一个对实例变量的引用。请注意,虽然底层的单例实例是静态持有的,但foo()函数本身并不是静态的。
好奇的读者现在可能会问:“为什么还要保留一个类的实例?为什么不简单地让Singleton类的所有数据和所有功能都是静态的呢?”原因是单例模式允许我们在需要实例语义的地方使用单例类。这些语义的一个特别重要的用法是在回调的实现中。例如,以Qt的信号和槽机制为例(你将在第6章遇到信号和槽),它可以被松散地解释为一个强大的回调系统。为了将一个类中的信号连接到另一个类中的槽,我们必须提供指向这两个类实例的指针。如果我们在实现Singleton时没有对Singleton类进行私有实例化(也就是只利用静态数据和静态函数),那么用Qt的信号和槽来使用我们的Singleton类将是不可能的。
3.2.2 Stack模块作为一个单例类
我们现在拥有了我们的Stack模块的基本设计。我们已经决定将整个模块封装在一个类中,这个类基本上是作为STL容器的适配器。我们已经决定,我们的一个类符合单例的模型标准,这个单例类将有第二章中设计的公共接口。结合这些设计元素,我们得到了类的初始声明,如清单3-2所示。
class Stack {
class StackImpl;
public:
static Stack& Instance();
void push(double);
double pop();
void getElements(int, vector<double>&) const;
void swapTop();
private:
Stack();
~Stack();
// appropriate blocking of copying, assigning, moving...
unique_ptr<StackImpl> pimpl_;
};
因为本书的重点是设计,所以文中没有提供每个成员函数的实现,除非这些细节特别具有指导意义或突出了设计的关键因素。作为提醒,pdCalc的完整实现可以从GitHub资源库中下载。偶尔,仓库的源代码会是文中出现的理想化界面的一个更复杂的变体。这将是本书余下部分的一般格式。
对于那些不熟悉pimpl习惯法(将一个类的实现放在一个单独的私有实现类中)的读者来说,pimpl成员变量会显得非常神秘。不要着急。你将在下面一节中回顾这一原则。
在暂时离开对Stack类设计的讨论之前,让我们简单地绕一下,讨论一个相关的实现细节。我们花了很多时间来回顾在Stack的设计中使用适配器模式来隐藏底层数据结构的重要性。这个决定的理由之一是,它提供了无缝改变底层实现的能力,而不会影响到依赖于Stack接口的类。问题是:“为什么Stack的底层实现会改变?”
在我的第一个版本的Stack的实现中,我为底层数据结构选择了明显的选择,即STL栈。然而,我很快就遇到了一个使用STL栈的效率问题。我们的Stack类的接口提供了一个getElements()函数,使用户界面可以查看计算器Stack的内容。不幸的是,STL的Stack的接口没有提供类似的功能。要看到STLStack顶层元素以外的元素,唯一的方法是连续弹出Stack,直到到达感兴趣的元素。很明显,因为我们只想看到Stack中的元素,而不是改变Stack本身,所以我们需要立即将所有的条目推回Stack中。有趣的是,对于我们的目的来说,STL的Stack被证明是一个不适合实现Stack的数据结构。一定有一个更好的解决方案。
幸运的是,STL提供了另一种适合我们任务的数据结构,即双端队列,或称deque。deque是一种STL数据结构,其行为类似于向量,只是deque允许将元素推到其前面和后面。矢量被优化为在增长的同时仍然提供连续性的保证,而deque被优化为通过牺牲连续性来快速增长和收缩。这个特点正是有效实现Stack所需的设计权衡。事实上,实现STL的Stack最常见的方法就是简单地包裹STL的deque(是的,就像我们的Stack一样,STL的Stack也是适配器模式的一个例子)。幸运的是,STL deque也承认非破坏性迭代,这是STLStack额外缺少的要求,我们需要实现我们的Stack的getElements()方法。好在我使用了封装,将Stack的实现从其接口中隐藏起来。在意识到可视化STL的Stack的局限性后,我能够改变Stack类的实现,使用STL deque,而对pdCalc的其他模块没有任何影响。
3.2.2.1 Pimpl风格
如果你选择查看GitHub仓库中pdCalc的实现版本,你会发现许多实际的类的实现都被pimpl风格(桥接模式的C++专用)所隐藏。对于那些不熟悉这个风格的人来说,它是指向实现的指针的速记符号。在实践中,与其在头文件中声明一个类的所有实现,不如向前声明一个指向“隐藏”实现类的指针,并在实现文件中完全声明和定义这个“隐藏”的类。包含并使用一个不完整的类型(pimpl变量)是允许的,只要pimpl变量只在包含其完整声明的源文件中被解读。例如,考虑下面的类A,它有一个由函数f()
和g()
组成的公共接口;一个由函数u()
、v()
和w()
组成的私有实现;以及私有数据v_
和m_
。
class A {
public:
void f();
void g();
private:
void u();
void v();
void w();
vector<double> v_;
map<string, int> m_;
};
使用pimpl习惯法,我们不把A的私有接口直观地暴露给这个类的消费者,而是写成
class A {
class AImpl;
public:
void f();
void g();
private:
unique_ptr<AImpl> pimpl_;
};
其中u
、v
、w
、v_
和m_
现在都是AImpl类的一部分,它只在与类A相关的实现文件中被声明和定义。为了确保AImpl不能被任何其他类访问,我们声明这个实现类是一个完全在A中定义的私有类。其中一个主要的优点是,通过将A类的私有接口从A.h移到A.cpp,当只有A的私有接口发生变化时,我们不再需要重新编译任何消耗A类的代码。对于大规模的软件项目来说,在编译过程中所节省的时间可能是非常可观的。
就我个人而言,我在我写的大部分代码中都使用了pimpl风格。我的一般规则的例外情况是:对于那些有非常有限的私有接口的代码或计算密集型的代码(例如pimpl的间接开销很大的代码)。除了在只有类AImpl发生变化时不必重新编译包括A.h在内的文件的编译好处之外,我发现pimpl风格给代码增加了很大的清晰度。这种清晰性来自于在实现文件中隐藏辅助函数和类的能力,而不是在头文件中列出它们。通过这种方式,头文件真正反映了接口的基本内容,从而防止了类的膨胀,至少在可见的接口层面上是如此。对于其他简单地使用你的类的程序员来说,实现细节在视觉上是隐藏的,因此不会分散他们对你那希望有良好记录的、有限的、公共的接口的注意力。
在继续完成Stack的接口之前,请注意这里使用的pimpl风格完全隐藏了对Stack类用户的底层Stack数据结构的选择。这个选择是一个实现细节,应该对用户隐藏起来。pimpl风格使我们能够完全隐藏它,甚至从视觉上检查。pimpl风格确实是封装的缩影。
3.3 添加事件
构建符合第二章堆栈接口的堆栈的最后一个必要元素是事件的实现。事件是一种弱耦合形式,允许一个对象(通知器或发布者)向任意数量的其他对象(监听器或订阅者)发出发生了有趣事情的信号。这种耦合是弱的,因为通知者和倾听者都不需要直接知道对方的接口。事件的实现方式取决于语言和库,甚至在一种特定的语言中,也可能存在多种选择。例如,在C#中,事件是核心语言的一部分,事件处理也相对容易。在C++中,我们就没那么幸运了,必须实现我们自己的事件系统,或者依赖一个提供这种功能的库。
C++程序员有几个处理事件的发布库选项,其中突出的是boost和Qt。boost库支持信号和槽,这是一种静态类型的机制,发布者可以通过回调向订阅者发出事件信号。另一方面,Qt同时提供了一个完整的事件系统和一个动态类型的事件回调机制,巧合的是,这也被称为信号和槽。这两个库都有很好的文档,经过了很好的测试,受到了很好的尊重,并可用于开放源代码和商业用途。在我们的计算器中,任何一个库都是实现事件的可行选择。然而,为了指导性的目的和尽量减少我们的计算器后端对外部库的依赖,我们将实现我们自己的事件系统。在设计你自己的软件时,做出适当的决定是非常有必要的,你应该研究使用一个库和为你的个人应用建立自定义事件处理的利弊。也就是说,除非你有一个令人信服的理由,否则默认的立场应该是使用一个库。
3.3.1 观察者模式
因为事件是这样一个通用实现的c++特性,所以您可以放心,描述事件的设计模式是存在的—这个模式就是观察者模式。观察者模式是一种抽象实现发布者和监听器的标准方法。正如该模式的名字所暗示的,在这里,监听器被称为观察者。
在Gamma等人[6]所描述的模式中,具体的发布者实现了抽象的发布者接口,而具体的观察者则实现了抽象的观察者接口。从概念上讲,实现是通过公共继承的。每个发布者拥有一个观察者的容器,发布者的接口允许附加和分离观察者。当一个事件发生(被提出)时,发布者迭代其观察者集合,并通知每个观察者事件已经发生。通过虚拟调度,每个具体的观察者根据自己的实现来处理这个通知消息。
观察者可以通过两种方式之一从发布者那里接收状态信息。首先,一个具体的观察者可以有一个指向它所观察的具体发布者的指针。通过这个指针,观察者可以查询事件发生时发布者的状态。这种机制被称为pull语义。另外,也可以实现push语义,即发布者将状态信息与事件通知一起push给观察者。 图3-1是了一个简化的观察者模式的类图。
图3-1. 观察者模式的简化版类图,它是在pdCalc中实现的,该图说明了事件数据的push语义。
3.3.1.1 增强观察者模式的实现
在我们的计算器的实际实现中,除了图3-1中描述的抽象外,还增加了几个额外的功能。首先,在图中,每个发布者拥有一个观察者列表,当事件发生时,这些观察者都得到了通知。然而,这种实现意味着发布者要么只有一个事件,要么有多个事件,但没有办法区分每个事件应该调用哪个观察者。一个更好的发布者实现方式是用一个关联数组来保存观察者的列表。通过这种方式,每个发布者可以有多个不同的事件,每个事件只通知对该特定事件感兴趣的观察者。虽 虽然从技术上讲,关联数组中的键可以是设计人员选择的任何合适的数据类型,但我选择为计算器使用字符串。也就是说,发布者通过名称来区分各个事件。这种选择增强了可读性,并能在运行时灵活地添加事件(而不是选择枚举值作为键)。
一旦发布者类可以包含多个事件,程序员就需要能够在调用attach()
或detach()
时通过名称来指定事件。因此,这些方法签名必须根据它们在图 3-1 中的显示方式进行适当修改,以包含事件名称。对于附件,方法签名通过添加事件的名称来完成。调用者只需用具体的观察者实例和该观察者所附加的事件名称来调用 attach()
方法。然而,将观察者从发布者中分离出来需要稍微复杂的机制。因为一个发布者中的每个事件都可以包含多个观察者,因此程序员需要能够区分观察者以进行分离。自然地,这个要求也导致命名观察者,并且必须修改 detach()
函数签名以适应观察者和事件的名称。
为了便于分离观察者,每个事件的观察者都应该间接存储并通过他们的名字引用。 因此,我们不是存储观察者列表的关联数组,而是选择使用观察者的关联数组。
在现代C++中,程序员可以选择使用map或unordered_map来实现标准库中的关联数组。这两种数据结构的典型实现分别是红黑树和哈希表。由于关联数组中元素的排序并不重要,我为pdCalc的Publisher类选择了unordered_map。然而,对于每个事件可能有少量的观察者订阅,任何一种数据结构都会是同样有效的选择。
到目前为止,我们还没有准确地说明观察者是如何存储在发布者中的,只是说它们是以某种方式存储在关联数组中。因为观察者是使用多态的,语言规则要求它们以指针或引用的方式持有。那么问题来了,发布者应该拥有观察者还是简单地引用其他类所拥有的观察者?如果我们选择引用路线(通过引用或原始指针),那么除了发布者之外,还需要一个类来拥有观察者的内存。这种情况是有问题的,因为并不清楚在任何特定的实例中谁应该拥有观察者。因此,每个开发者都可能会选择不同的方案,而观察者的长期维护将陷入混乱。更糟糕的是,如果观察者的所有者释放了观察者的内存,却没有将观察者从发布者那里分离出来,那么触发发布者的事件就会导致崩溃,因为发布者会持有对观察者的无效引用。基于这些原因,我更倾向于让发布者拥有其观察者的内存。
在放弃了引用之后,我们必须使用拥有语义,而且由于C++的多态机制,我们必须通过指针来实现所有权。在现代C++中,指针类型的唯一所有权是通过unique_ptr实现的(参见“现代C++设计说明”中关于拥有语义的侧栏,以了解设计含义)。把上述所有的建议放在一起,我们就能设计出Publisher类的最终公共接口,如清单3-3所示。
清单3-3. 发布者类的最终公共接口
// Publisher.h
class Observer;
class Publisher {
class PublisherImpl;
public:
void attach(const string& eventName, unique_ptr<Observer> observer);
unique_ptr<Observer> detach(const string& eventName, const string& observerName);
// ...
private:
unique_ptr<PublisherImpl> publisherImpl_;
};
事件存储的实现细节为
// Publisher.cpp
class Publisher::PublisherImpl {
// ...
private:
using ObserversList = unordered_map<string, unique_ptr<Observer>>;
using Events = unordered_map<string, ObserversList>;
Events events_;
};
观察者类的接口比发布者类的接口要简单得多。然而,由于我还没有描述如何处理事件数据,我们还没有准备好设计观察者的接口。我将在下面的3.3.1.2节中讨论事件数据和观察者类的接口。
现代C++设计说明:拥有语义和unique_ptr
在C++中,拥有一个对象的概念意味着在不再需要该对象时有责任删除其内存。在C++11之前,尽管任何人都可以实现自己的智能指针(很多人都这么做了),但语言本身并没有表达出指针所有权的标准语义(除了auto_ptr,它已经被废弃)。通过本地指针传递内存更像是一个信任问题。也就是说,如果你“newed”了一个指针,并通过原始指针将其传递给一个库,你希望库在使用完后删除该内存。或者,库的文档可能会告诉你在执行了某些操作后删除内存。如果没有一个标准的智能指针,在最坏的情况下,你的程序会泄露内存。在最好的情况下,你不得不使用一个非标准的智能指针来连接一个库。
C++11通过标准化一套主要从boost库中借用的智能指针,纠正了未知指针所有权的问题。unique_ptr最终允许程序员正确地实现唯一所有权(因此废除了auto_ptr)。本质上,unique_ptr 确保在任何时候都只存在一个指针实例。为了使语言能够执行这些规则,唯一指针的复制和非移动赋值没有被实现。相反,移动语义被用来确保所有权的转移(显式函数调用也可以被用来手动管理内存)。Josuttis [8]对使用unique_ptr的机制提供了一个很好的详细描述。要记住的重要一点是不要在 unique_ptrs 和原始指针之间混合指针类型。
从设计的角度来看,unique_ptr意味着我们可以使用标准的C++编写接口,明确地表达唯一所有权语义。正如在讨论观察者模式时看到的那样,唯一所有权语义在任何设计中都是很重要的,其中一个类创建的内存将被另一个类所拥有。例如,在计算器的事件系统中,虽然事件的发布者应该拥有它的观察者,但发布者很少有足够的信息来创建它的观察者。因此,重要的是能够在一个地方为观察者创建内存,但能够将该内存的所有权传递给另一个地方,即发布者。unique_ptr 提供了这种服务。因为观察者是通过 unique_ptr 传递给发布者的,所有权被转移给发布者,当发布者不再需要观察者时,观察者的内存被智能指针删除。另外,任何类都可以从发布者那里回收一个观察者。由于 detach() 方法以唯一的 ptr 形式返回观察者,发布者通过将观察者的内存转回给调用者,明确地放弃了对观察者内存的所有权。
上述观察者模式的实现明确地执行了发布者拥有其观察者的设计。使用这种实现的最自然的方式是创建小型的、专门的、中间的观察者类,这些观察者类本身持有指向应该响应事件的实际类的指针或引用。例如,从第二章开始,我们知道pdCalc的用户界面是Stack类的一个观察者。然而,我们真的希望用户界面是一个(公开继承自)Observer,而这个Observer是由图3-2a中描述的Stack拥有的吗?图3-2c中描述了一个更好的解决方案。在这里,堆栈拥有一个堆栈ChangeEvent观察者,当堆栈发生变化时,它反过来通知UserInterface。这种模式使堆栈和UserInterface保持真正的独立。当你在第五章学习我们的第一个用户界面时,会有更多关于这个主题的内容。
图 3-2. 观察者模式的不同所有权策略
现代C++确实承认观察者模式的所有权语义有另一种合理的选择:共享所有权。如上所述,让Stack拥有用户界面是不合理的。然而,有些人可能认为创建一个额外的ChangeEvent中间类而不是直接让用户界面成为观察者也是不合理的。唯一可用的中间选项似乎是让Stack引用用户界面。不过之前我说过,让一个发布者引用它的观察者是不安全的,因为观察者可能从发布者下面消失,留下一个悬空的引用。这个悬空引用的问题能得到解决吗?
幸运的是,现代C++再一次用共享语义来拯救我们(如图3-2b所描述)。在这种情况下,观察者用shared_ptr(见下面关于shared_ptrs的侧边栏)来共享,而发布者用weak_ptr(shared_ptr的相对物)保留对观察者的引用。weak_ptrs是专门用来缓解对共享对象的悬空引用问题的。Meyers[19]在第20项中描述了这种由发布者共享观察者所有权的设计。就个人而言,我更喜欢使用拥有语义和轻量级、专用观察者类的设计。
3.3.1.2 处理事件数据
在描述观察者模式时,我提到有两种处理事件数据的不同范式:pull和push语义。在pull语义中,只需通知观察者事件已经发生。然后,观察员有额外的责任获取可能需要的任何额外数据。这个实现非常简单,观察者维护对任何对象的引用,它可能需要从中获取状态信息,并且观察者调用成员函数来获取该状态以响应事件。
pull语义有几个优点。首先,观察者可以在处理事件的时候准确地选择它想获取的状态。第二,在向观察者传递可能不使用的参数时,不会消耗不必要的资源。第三,pull语义很容易实现,因为事件不需要携带数据。然而,pull语义也有缺点。首先,pull语义增加了耦合性,因为观察者需要持有对发布者的状态获取接口的引用并理解它们。其次,观察者只能访问发布者的公共接口。这种访问限制使观察者无法从发布者那里获得私有数据。
与pull语义相反,push语义是通过让发布者在事件发生时发送与该事件相关的状态数据来实现的。然后,观察者接收这些状态数据作为通知回调的参数。该接口通过在抽象的观察者基类中使通知函数成为纯虚函数来执行push语义。
事件处理的push语义也有优点和缺点。第一个优点是,push语义减少了耦合性。发布者和观察者都不需要知道彼此的接口。他们只需要服从抽象的事件处理接口。第二,发布者在push状态时可以向观察者发送私有信息。第三,作为引发事件的对象,发布者可以精确地发送处理事件所需的数据。push语义的主要缺点是,在观察者不需要发布者所push的状态数据的情况下,它们的实现难度稍大,并可能带来不必要的开销。最后,请注意,对于特殊情况,使用push语义的设计总是可以通过添加对push数据添加回调引用来添加拉语义。反之则不然,因为push语义需要事件处理机制中的专用基础设施。
基于上述push和pull语义之间的权衡,我选择为pdCalc的事件处理实现push语义。push语义的主要缺点是实现时可能会有计算上的开销。然而,由于我们的应用程序并不是性能密集型的,这种模式所表现出的耦合度降低以及发布者所保持的参数控制超过了轻微的性能开销。我们现在的任务是设计一个通过push语义传递事件数据的实现。
为了实现事件处理的push语义,必须对事件发生时从发布者到观察者传递参数的接口进行标准化。理想情况下,每个发布者/观察者对将被传递的参数类型达成一致,并且发布者将在事件发生时调用观察者的适当成员函数。然而,这种理想的情况在我们的发布者/观察者类的层次结构中实际上是不可能的,因为具体的发布者并不知道具体观察者的接口。具体的发布者只能通过调用发布者基类中的 raise() 函数来通用地引发事件。raise() 函数又通过Observer基类的虚拟notify()函数多态地通知具体观察者。因此,我们寻求一种通用的技术来通过抽象的 raise/notify 接口传递自定义数据。
解决我们的困境的方法相当简单。我们为事件数据创建一个并行的对象层次结构,并通过这个抽象的状态接口将事件数据从发布者传递给观察者。这个层次结构中的基类EventData是一个空类,只包含一个虚拟的析构器。每个需要参数的事件都会子类化这个基类,并实现任何被认为合适的数据处理方案。当一个事件被引发时,发布者通过EventData基类的指针将数据传递给观察者。收到数据后,具体的观察者将状态数据下传到具体的数据类,随后通过派生类的具体接口提取必要的数据。虽然具体发布者和具体观察者必须在数据对象的接口上达成一致,但无论是具体发布者还是具体观察者都不需要知道对方的接口。因此,我们保持了松散的耦合。
为了巩固上面的观点,让我们看看计算器的Stack是如何实现状态数据的。回顾一下第 2 章,Stack 实现了两个事件,即 stackChanged() 事件和 error(string) 事件。stackChanged()
事件在这里并不有趣,因为该事件没有携带数据。然而,错误事件确实携带数据。考虑清单 3-4 中所示的堆栈错误条件的类层次结构。
清单3-4 事件数据层次结构
// Publisher.h
class EventData {
public:
virtual ~EventData();
};
// Stack.h
class StackEventData : public EventData {
public:
enum class ErrorConditions { Empty,
TooFewArguments };
StackEventData(ErrorConditions e)
: err_(e)
{
}
static const char* Message(ErrorConditions ec);
const char* message() const;
ErrorConditions error() const { return err_; }
private:
ErrorConditions err_;
};
StackEventData类定义了Stack的事件数据如何被打包并发送给观察Stack的类。当堆栈模块内发生错误时,Stack类会引发一个事件,并将该事件的信息推送给它的观察者。在这个例子中,Stack创建一个StackEventData的实例,在构造函数中指定错误的类型。这个由有限的错误条件集合组成的枚举类型可以用message()
函数转换为字符串。然后观察者在收到事件发生的通知时,可以自由地使用或忽略这些信息。如果你注意到了,是的,我刚刚巧妙地将error()
接口的签名从字符串改为EventData。
作为一个具体的例子,假设一个错误是由于空栈被弹出而触发的。为了引发这个事件,Stack调用下面的代码(实际的实现略有不同,因为Stack被包裹在一个pimpl中):
parent_.raise(Stack::StackError,
make_shared<StackEventData>(
StackEventData::ErrorConditions::Empty)
);
raise()
函数的第一个参数是一个静态字符串,解析为“error”。回顾一下,为了处理多个事件,发布者为每个事件命名。这里,Stack::StackError
静态变量持有这个事件的名称。使用变量而不是直接使用字符串,是为了防止因输入错误的事件名称而导致运行时错误。raise()
函数的第二个参数创建了StackEventData实例,并用空的Stack错误条件初始化了它。注意,该实现使用shared_ptr传递事件数据。这个决定将在下面关于共享语义的侧边栏中讨论。尽管StackObserver类还没有被引入,但为了完整起见,我注意到一个事件可以用下面的代码来解释,其类型为:
StackObserver::notify(shared_ptr<EventData> d)
{
shared_ptr<StackEventData> p = dynamic_pointer_cast<StackEventData>(d);
if (p) {
// do something with the data
} else {
// uh oh, what event did we just catch?!
}
}
现代C++设计说明:共享语义和sharedptr
unique_ptr使程序员能够安全地表达唯一所有权,shared ptr使程序员能够安全地表达共享所有权。在 C++11 标准之前,C++ 允许通过原始指针或引用来共享数据。因为类数据的引用只能在构造过程中被初始化,对于后期绑定的数据,只能使用原始指针。因此,经常有两个类通过各自包含一个指向共同对象的原始指针来共享一块数据。当然,这种情况的问题是,不清楚哪个对象拥有这个共享对象。特别是,这种模糊性意味着不确定何时可以安全地删除这样的共享对象,以及哪个拥有对象最终应该释放内存。 shared_ptrs在标准库层面上纠正了这种困境。
sharedptr 通过引用计数实现共享语义。当新的对象指向一个 shared_ptr 时,内部引用计数会增加(通过构造器和赋值强制执行)。当一个 shared_ptr 走出范围时,它的析构器被调用,这将减少内部引用计数。当计数为零时,最终的 shared ptr 的销毁会触发底层内存的回收。与unique_ptr一样,显式成员函数调用也可以用来手动管理内存。Josuttis [8] 对使用 shared_ptr 的机制提供了很好的详细描述。与 unique_ptr 一样,我们必须小心不要混合指针类型。当然,这一规则的例外是与 weak_ptr 的混合使用。此外,引用计数会带来时间和空间的开销,所以读者在部署共享指针之前应该熟悉这些权衡。
从设计上考虑,shared_ptr结构使程序员可以共享堆内存而不直接跟踪对象的所有权。对于计算器来说,按值传递事件数据并不是一个选项。因为事件数据对象存在于一个层次结构中,按值传递事件数据对象会导致分片。然而,使用原始指针(或引用)来传递事件数据也会有问题,因为这些数据对象的寿命在共享它们的类中无法得知。自然地,发布者在引发事件时分配内存。由于观察者可能希望在完成事件处理后保留内存,所以发布者不能在事件处理后简单地取消分配内存。此外,由于可以为任何给定的事件调用多个观察者,发布者也不能将数据的唯一所有权转移给任何给定的观察者。因此,C++11中标准化的shared_ptr为处理这种情况提供了理想的语义。
现在你理解了事件数据,让我们最后写一下抽象的观察者接口。不出所料,这正是你可能期待的。见清单3-5。
3.3.2 Stack作为事件发布者
构建Stack的最后一步是简单地把所有的碎片放在一起。清单3-1显示了作为单例的Stack。为了实现事件,我们只需修改这段代码,从Publisher基类中继承。我们现在必须问自己,这种继承应该是公开的还是私有的?
通常,在面向对象的编程中,人们使用公共继承来表示 is-a 关系。也就是说,公有继承表达了派生类是基类的一个类型或特殊化的关系。更确切地说,is-a 关系遵守Liskov可替代性原则(LSP)[29],该原则指出(通过多态性)一个以基类指针(引用)为参数的函数必须能够接受派生类的指针(引用)而不需要知道它。简而言之,一个派生类必须可以在基类可以使用的地方使用,可以互换。当人们提到继承的时候,他们一般指的是公共继承。
私有继承是用来表达 implements-a 关系的。简单地说,私有继承是用来将一个类的实现嵌入另一个类的私有实现中。它不遵守LSP,事实上,如果继承关系是私有的,C++语言不允许用派生类来代替基类。为了完整起见,密切相关的受保护的继承在语义上与私有继承相同。唯一的区别是,在私有继承中,基类的实现在派生类中成为私有,而在保护继承中,基类的实现在派生类中成为保护。
我们的问题现在已经被细化为,Stack是一个发布者,还是Stack实现了一个发布者?答案是都是。那是没有用的,那么我们如何选择呢?
为了分清在这个例子中我们应该使用公有继承还是私有继承,我们必须深入研究 Stack 类的用法。公有继承,或is-a关系,将表明我们的意图是将堆栈多态地作为发布者使用。然而,情况并非如此。虽然Stack类是一个发布者,但在LSP意义上,它并不是一个可以替代发布者的发布者。因此,我们应该使用私有继承来表示在 Stack 中使用 Publisher 的实现的意图。等价地,我们可以说明,堆栈提供了Publisher服务。如果你一直在关注版本库的源代码,你可能已经注意到一个很大的提示,即私有继承就是答案。Publisher类是用一个非虚拟的、受保护的析构器实现的,这使得它不能用于公共继承。
熟悉面向对象设计的读者可能会问,为什么我没有问无处不在的has-a问题,它表示所有权或聚合关系。也就是说,为什么Stack不应该简单地拥有一个Publisher并重用它的实现,而不是私下继承它呢?许多设计者几乎只喜欢用聚合来代替私有继承,他们认为当在这两者之间有同等的选择时,应该总是喜欢导致更松散耦合的语言特性(继承是一种比聚合更强的关系)。这个观点是有道理的。但就个人而言,我更愿意接受以更强的耦合性换取更清晰的技术。我相信,与聚合相比,私有继承更清楚地说明了实现发布者服务的设计意图。这个决定没有正确或错误的答案。在你的代码中,你应该选择适合你口味的风格。
3.3.3 完整的Stack界面
我们终于准备好编写完整的Stack公共接口了。见清单3-6。
清单3-6. 完整的Stack公共接口
class Stack : private Publisher {
public:
static Stack& Instance();
void push(double, bool suppressChangeEvent = false);
double pop(bool suppressChangeEvent = false);
void swapTop();
vector<double> getElements(size_t n) const;
using Publisher::attach;
using Publisher::detach;
};
如本章所述,Stack是一个单例类(注意Instance()
方法),它实现了Publisher服务(注意Publisher类的私有继承,并将attach()
和detach()
方法吊在公共接口中)。Stack类的公共部分与清单3-2中描述的EventData类一起,包含了第二章中表2-2介绍的Stack模块的完整接口。虽然我还没有为Stack描述任何具体的观察者,但我们已经为pdCalc完整定义了我们的事件系统,它是基于久经考验的观察者模式。在这一点上,我们已经准备好设计pdCalc的下一个组件,即命令调度器模块。
3.4 关于测试的简要说明
在结束这第一章介绍pdCalc的源代码之前,我应该停顿一下,说几句关于测试的话。尽管测试绝不是本书的核心探索性话题,但它是任何高质量实现的一个组成部分。除了在GitHub上找到的计算器的源代码外,我还包括了我所有的自动单元测试代码。因为我选择使用Qt作为pdCalc的图形用户界面框架(见第6章),Qt测试框架是建立pdCalc单元测试套件的自然选择。主要是,这种选择不会给项目增加任何额外的库依赖,而且测试框架可以保证在所有Qt移植的平台上工作。也就是说,许多高质量的C++单元测试框架中的任何一个都会有同样的效果。
就我个人而言,我发现即使是小项目的编程,单元测试也是不可缺少的。首先,单元测试提供了一种手段,以确保你的代码能达到预期的功能。其次,单元测试使你在开发用户界面之前就能看到一个模块工作正常。早期的测试能够在早期发现错误,而软件工程的一个众所周知的事实是,早期发现错误会导致错误修复成本成倍降低。我还发现,在开发周期的早期看到模块完全工作是一种奇怪的激励。最后,单元测试还能让你知道,你的代码在代码修改前后的功能是一样的。由于迭代是设计和实现的一个基本要素,你的代码会改变无数次,甚至在你认为你已经完成了它。在每次构建时自动运行全面的单元测试将确保新的变化没有不可预测地破坏任何现有的功能单元。
因为我非常重视测试(这是我试图教给新的专业开发人员的第一课),我努力确保pdCalc的代码测试的完整性。测试代码不仅为读者提供了一个典范(我希望),而且还保证了我在写这本书的整个代码开发部分是正确的。然而,尽管我有特别想写无错误的代码,甚至在对源代码进行了非理性的审查之后,我确信最终产品中仍然存在错误。请随时将你发现的任何和所有的错误发电子邮件给我。我将尽我最大的努力,在GitHub仓库的代码中实时加入修正,并对第一个向我报告任何错误的读者给予适当的署名。