命令调度器是计算器的中心部分。作为MVC框架中的控制器,命令调度器负责整个应用程序的业务逻辑。本章不仅讨论了计算器的命令调度器模块的具体设计,而且更广泛地讨论了松散耦合的命令基础设施的灵活设计。

4.1 命令调度器的分解

我们在分解Stack时问的第一个问题是:“Stack应该被分成多少个组件?”我们现在对命令调度器提出同样的问题。为了回答这个问题,让我们考虑一下命令调度器必须封装的功能。命令调度器的功能是:

  1. 存储已知命令的集合。
  2. 接收并解释对这些命令的请求。
  3. 派遣命令请求(包括撤销和重做的能力)。
  4. 执行实际操作(包括更新计算器的状态)。

在第二章中,我讨论了衔接原则。在类的层面上,为内聚而设计意味着每个类应该只做一件事,而且应该做得很好。在最顶层的分解层面上,命令调度器确实只做一件事:解释命令。然而,在任务层面,从我们上面的功能列表来看,它显然必须执行多个任务。因此,我们把命令调度器分解成几个不同的类,每个类对应它必须执行的主要任务。因此,我们有以下几个类。

  1. CommandRepository:存储可用命令列表
  2. CommandDispatcher:接收和解释命令请求的请求
  3. CommandManager:发送命令并管理撤销和重做
  4. Command hierarchy:执行命令

本章的其余部分将用于描述上述类和类层次的设计和突出的实现细节。

4.2 Command类

在分解的这个阶段,我发现改用自下而上的设计方法更有用。在严格的自上而下的方法中,我们可能会从CommandDispatcher(接收和解释命令请求的类)开始,然后一直到命令本身。然而,在这种自下而上的方法中,我们将从研究命令本身的设计开始。我们从被称为命令模式的抽象概念开始。

4.2.1 命令模式

命令模式是一个简单但非常强大的行为模式,它以一个对象的形式封装了一个请求。在结构上,该模式被实现为一个抽象的命令基类,它提供了一个执行请求的接口。具体的命令只是实现了这个接口。在最微不足道的情况下,抽象接口只由一个命令组成,用于执行命令所封装的请求。这个简单实现的类图如图4-1所示。
image.png

图4-1. 命令模式的最简单层次结构

基本上,该模式做了两件事。首先,它将命令的请求者与命令的调度者解耦。其次,它把对一个动作的请求封装成一个对象,这个对象可能由一个函数调用来实现。这个对象可以携带状态,并具有超出请求本身的当前生命周期的延长生命周期。

实际上,这两个特性给我们带来了什么?首先,由于请求者与调度者解耦,执行命令的逻辑不需要驻留在同一个类中,甚至不需要与负责执行命令的类在同一个模块中。这显然降低了耦合度,但也增加了内聚性,因为可以为系统必须实现的每个惟一命令创建惟一类。其次,由于请求现在被封装在命令对象中,其寿命与动作的寿命不同,所以命令既可以在时间上被延迟(例如,排队命令),也可以被撤销。撤销操作之所以成为可能,是因为已经执行的命令可以保留足够的数据来恢复到命令执行前的状态。当然,把排队能力和撤销能力结合起来,可以为所有执行命令模式的请求建立一个无限的撤销/重做。

4.2.2 关于执行撤销/重做的更多信息

pdCalc的要求之一是实现无限的撤销和重做操作。大多数书上说,撤销可以通过命令模式来实现,只需在抽象的命令接口上增加一个撤销命令即可。然而,这种简单化的处理方式掩盖了正确实现撤销功能所需的实际细节。

实现撤销和重做包括两个不同的步骤。首先(也很明显),撤销和重做必须在具体的命令类中正确实现。其次,必须实现一个数据结构来跟踪和存储被派发的命令对象。当然,这个数据结构必须保留命令的执行顺序,并且能够调度撤销、重做或执行新命令的请求。这个撤销/重做数据结构将在下面第4.4节中详细描述。撤销和重做的实现将在下面讨论。

实现撤销和重做操作本身通常是很简单的。重做操作与命令的执行函数相同。只要在第一次执行命令之前和调用undo之后,系统的状态是一样的,那么实现redo命令基本上是免费的。当然,这立即意味着执行撤销操作实际上是将系统的状态恢复到命令第一次执行之前。

撤销可以通过两种类似但又略有不同的机制来实现,每种机制都负责以不同的方式恢复系统的状态。第一种机制的作用正如其名称“撤销”所暗示的那样:它获取系统的当前状态,并从字面上逆转前进命令的过程。在数学上,也就是说,撤销被实现为执行的反向操作。例如,如果正向操作是取栈顶数字的平方根,那么撤销操作就是取栈顶数字的平方。这种方法的优点是,为了能够实现撤销,不需要存储额外的状态信息。缺点是,该方法并不适用所有可能的命令。让我们来研究一下我们前面例子的反面。也就是说,考虑取栈顶数字的平方。撤销操作将是取平方操作结果的平方根。然而,原来的数字是平方的正根还是负根?如果不保留额外的状态信息,反转方法就会失效。

将撤销操作作为逆向操作来实现的另一种方法是保留系统在第一次执行命令之前的状态,然后将撤销操作作为对之前状态的还原来实现。回到我们对一个数字进行平方运算的例子,正向操作既要计算平方,又要保存Stack中的最高数字。然后,撤销操作将通过从Stack中删除结果和推送执行前进操作之前的保存状态来实现。这个过程是由命令模式促成的,因为所有的命令都是作为允许携带状态的具体命令类的实例实现的。这种实现撤销操作的方法的一个有趣的特点是,操作本身不需要有一个数学上的逆运算。请注意,在我们的例子中,撤销操作甚至不需要知道前进操作是什么。它只需要知道如何用保存的状态替换Stack中的顶层元素。

在你的应用程序中使用哪种机制,实际上取决于你的应用程序所执行的不同操作。当操作没有反向时,存储状态是唯一的选择。当反转操作的计算成本过高时,存储状态通常是更好的实现。当存储状态的成本很高时,假设存在反转操作,通过反转实现撤销是首选。当然,由于每个命令都是作为一个单独的类来实现的,所以不需要为整个系统做一个关于如何实现撤销的全局决策。一个给定的命令的设计者可以在每个命令的基础上自由选择最适合该特定操作的方法。在某些情况下,甚至混合方法(同时存储和反转操作的不同部分)也可能是最佳的。在下一节中,我们将研究我对pdCalc的选择。

4.2.3 应用于计算器的命令模式

为了执行、撤销和重做计算器中的所有操作,我们将实现命令模式,每个计算器的操作都将由它自己的具体类来封装,这些具体类来自抽象的命令类。从上面关于命令模式的讨论中,我们可以看到,为了将该模式应用于计算器,必须做出两个决定。首先,我们必须确定每个命令必须支持哪些操作。这个操作的集合将定义命令基类的抽象接口。其次,我们必须为如何支持撤销选择一个策略。准确地说,这个决定总是被推迟到特定的具体命令的实现者那里。然而,通过预先选择状态重构或命令反转,我们可以实现一些基础设施来简化命令实现者的撤销操作。我们将连续地解决这两个问题。

4.2.3.1 命令接口

选择在抽象命令类中包含哪些公共函数,与定义计算器中所有命令的接口是相同的。因此,这个决定决不能轻易做出。虽然每个具体的命令都会执行不同的功能,但所有的具体命令都必须是可以相互替代的(回顾一下LSP)。因为我们希望接口是最小且是完整的,所以我们必须确定用最少的函数来抽象地表达所有命令所需的操作。

要包括的前两个命令是最明显和最容易定义的。它们是execute()undo(),分别是执行命令的正向和逆向操作的函数。这两个函数返回void,不需要参数。不需要参数是因为计算器的所有数据都是通过Stack类处理的,而Stack类是可以通过单例模式全局访问的。此外,命令类需要一个构造函数和一个析构函数。因为该类是一个带有虚拟函数的接口类,所以析构函数应该是虚拟的。清单4-1说明了我们对接口的首次尝试。

Listing 4-1. 对命令接口的首次尝试

  1. class Command {
  2. public:
  3. virtual ~Command();
  4. void execute();
  5. void undo();
  6. protected:
  7. Command();
  8. private:
  9. virtual void executeImpl() = 0;
  10. virtual void undoImpl() = 0;
  11. };

在清单4-1中,读者会立即注意到构造函数是受保护的,execute()undo()都是公共的和非虚拟的,并且存在单独的executeImpl()undoImpl()虚拟函数。构造函数被保护的原因是向实现者发出信号:Command类不能被直接实例化。当然,由于该类包含纯虚函数,编译器会阻止直接实例化 Command 类。使构造函数受到保护是多余的。另一方面,使用虚拟和非虚拟函数的组合来定义公共接口,值得进行更详细的解释。

通过混合使用公共非虚拟函数和私有虚拟函数来定义一个类的公共接口,这是一个被称为非虚拟接口(NVI)模式的设计原则。NVI模式指出,多态接口应该总是使用非虚拟的公共函数来定义,这些函数会转发对私有虚拟函数的调用。这种模式背后的道理很简单。由于带有虚拟函数的基类作为一个接口类,客户应该只通过基类的接口通过多态性访问派生类的功能。通过使公共接口非虚拟化,基类实现者保留了在派发前拦截虚拟函数调用的能力,以便为所有派生类实现的执行添加前置条件或后置条件。让虚拟函数私有化,迫使消费者使用非虚拟的接口。在不需要前置条件或后置条件的微不足道情况下,非虚拟函数的实现简化为对虚拟函数的转发调用。即使是在琐碎的情况下,坚持使用NVI模式也是有必要的,因为它为未来的扩展保留了设计的灵活性,而且计算开销为零,因为转发函数的调用可以被内联。Sutter[27]中详细讨论了NVI模式背后的更深入的原理。

现在我们来考虑execute()undo()是否需要前置条件或后置条件。我们从execute()开始。通过快速浏览第二章中的用例,我们可以看到许多pdCalc必须完成的动作只有在首先满足一组前提条件的情况下才能进行。例如,要把两个数字相加,我们必须在Stack上有两个数字。很明显,加法有一个前置条件。从设计的角度来看,如果我们在执行命令之前捕获这个前置条件,我们就可以在前置条件错误导致执行问题之前处理它们。在调用executeImpl()之前,我们肯定希望检查先决条件,作为基类execute()实现的一部分。

所有命令都必须检查哪些前置条件或前置条件? 也许,就像加法一样,所有的命令必须在Stack中至少有两个数字?让我们来看看另一个用例。考虑取一个数字的正弦。这个命令只需要一个数字在Stack上。啊,前提条件是针对命令的。对于我们关于前提条件的一般处理的问题,正确的答案是要求派生类通过让execute()首先调用checkPreconditionsImpl()虚拟函数来检查自己的前提条件。

那么execute()的后置条件呢?事实证明,如果每个命令的前置条件得到满足,那么所有的命令在数学上都是定义良好的。很好,不需要后置条件的检查了。不幸的是,数学上的正确性并不足以保证浮点数的无错误计算。例如,当使用pdCalc所要求的双精度数字时,浮点加法可能会导致正向溢出,即使加法在数学上有定义。然而,幸运的是,我们在第一章的要求中指出,浮点错误可以被忽略。因此,我们在技术上不需要处理浮点错误,毕竟不需要后置条件检查。

为了保持代码的相对简单,我选择了遵守要求,忽略了pdCalc中的浮点异常。如果我想在设计中主动出击,捕获浮点错误,可以使用checkPostconditions()函数。因为浮点错误对所有的命令都是通用的,后置条件的检查可以在基类中处理。

了解了我们的前提条件和后置条件的需要,使用NVI模式,我们能够为execute()编写如下简单的实现,如清单4-2所示。

Listing 4-2. execute()的一个简单实现

  1. void Command::execute()
  2. {
  3. checkPreconditionsImpl();
  4. executeImpl();
  5. return;
  6. }

鉴于checkPreconditionsImpl()executeImpl()都必须连续调用并由派生类处理,我们难道不能把这两个操作合并到一个函数调用中吗?我们可以,但这个决定会导致一个次优的设计。首先,将这两个操作合并到一个executeImpl()函数调用中,我们会因为要求一个函数执行两个不同的操作而失去凝聚力。其次,通过使用单独的 checkPreconditionsImpl() 调用,我们可以选择强制派生类实现者检查前提条件(通过使 checkPrecodnitionsImpl() 成为纯虚函数),或者选择性地提供前提条件检查的默认实现。最后,谁能保证checkPreconditionsImpl()executeImpl()会分派到同一个派生类呢?请记住,层次结构可以是多层次的。

execute()函数类似,人们可能认为撤销命令需要检查前置条件。然而,事实证明,我们实际上从来不需要检查撤销命令的前提条件,因为它们在结构上总是为真。也就是说,由于撤销命令只有在执行命令成功完成后才能被调用,所以undo()的前提条件被保证得到满足(当然,假设execute()的实现是正确的)。与正向执行一样,undo()没有必要进行后置条件检查。

execute()undo()的前置条件和后置条件的分析,导致在虚拟接口中只增加了一个函数,即checkPreconditionImpl()。然而,为了使这个函数的实现完整,我们必须确定这个函数的正确签名。首先,这个函数的返回值应该是什么?我们可以选择将返回值设为void,并通过一个异常来处理预设条件的失败,或者将返回值设为可以表明预设条件未得到满足的类型(例如,预设条件失败时返回false的布尔值或表明发生的失败类型的枚举值)。对于pdCalc,我选择通过异常来处理前提条件的失败。这种策略能够带来更大的灵活性,因为错误不需要由直接调用者,即execute()函数来处理。此外,异常可以被设计成带有自定义的、描述性的错误信息,可以通过派生命令来扩展。这与使用枚举类型形成对比,后者必须由基类实现者完全定义。

在指定checkPreconditionImpl()的签名时,我们必须解决的第二项是选择该函数是否应该是纯虚拟的或有一个默认实现。虽然大多数命令确实需要满足一些前提条件,但并不是每个命令都是如此。例如,在Stack中输入一个新的数字不需要前提条件。因此,checkPreconditionImpl()不应该是一个纯虚函数。相反,它被赋予了一个默认的实现,即什么也不做,这相当于说明前提条件得到了满足。

因为命令中的错误是通过checkPreconditionImpl()函数检查的,所以除了checkPreconditionImpl(),任何命令的正确实现都不应该抛出异常。因此,为了增加接口保护,命令类中的每个纯虚函数都应该被标记为noexcept。为了简洁起见,我经常在文本中跳过这个关键字;然而,noexcept确实出现在实现中。这个指定符其实只在插件命令的实现中很重要,这将在第7章讨论。

接下来要添加到Command类中的一组函数是用于多态复制对象的函数。这组函数包括一个受保护的复制构造函数,一个公共的非虚拟的clone()函数,以及一个私有的cloneImpl()函数。在设计的这一点上,还不能充分说明为什么命令必须是可复制的理由。然而,当我们研究CommandRepository的实现时,这个理由将变得清晰。然而,为了保持连续性,我将在此讨论复制接口的实现。

对于为多态使用而设计的类层次结构,一个简单的复制构造函数是不够的,对象的复制必须由一个clone()虚拟函数来完成。考虑一下下面这个只显示复制构造函数的简略命令层次结构:

  1. class Command {
  2. protected:
  3. Command(const Command&);
  4. };
  5. class Add : public Command {
  6. public:
  7. Add(const Add&);
  8. };

我们的目标是复制多态使用的Command。让我们来看看下面的例子,我们通过一个命令指针持有一个添加对象:

  1. Command* p = new Add;

根据定义,一个复制构造函数需要一个对其自身类类型的引用作为其参数。因为在多义性设置中,我们不知道底层类型,我们必须尝试以如下方式调用复制构造函数:

  1. auto p2 = new Command { *p };

上述结构是非法的,不会被编译。因为Command类是抽象的(它的复制构造函数是受保护的),编译器将不允许创建Command对象。然而,并不是所有的层次结构都有抽象基类,所以人们可能会想在那些合法的情况下尝试这种结构。请注意。这种结构会将层次结构分割开来。也就是说,p2将被构造成一个Command实例,而不是一个Add实例,而且p的任何Add状态都会在拷贝中丢失。

鉴于我们不能直接使用复制构造函数,我们如何在多态环境下复制类呢?解决办法是提供一个虚拟的克隆操作,可以按如下方式使用:

  1. Command* p2 = p->clone();

在这里,非虚拟的clone()函数将克隆操作分派给派生类的cloneImpl()函数,该函数的实现只是调用它自己的复制构造函数,并以一个被解除引用的this指针作为参数。对于上面的例子,扩展后的接口和实现将如下:

  1. class Command {
  2. public:
  3. Command* clone() const { return cloneImpl(); }
  4. protected:
  5. Command(const Command&) { }
  6. private:
  7. virtual Command* cloneImpl() const = 0;
  8. };
  9. class Add : public Command {
  10. public:
  11. Add(const Add& rhs)
  12. : Command { rhs }
  13. {
  14. }
  15. private:
  16. Add* cloneImpl() const { return new Add { *this }; }
  17. };

这里唯一有趣的实现特征是cloneImpl()函数的返回类型。请注意,基类将返回类型指定为Command*,而派生类将返回类型指定为Add*。这种结构被称为返回类型的共变性,这一规则指出,派生类中的覆盖函数可以返回比虚拟接口中的返回类型更特殊的类型。共变性允许克隆函数总是返回适合于克隆所调用的层次结构的特定类型。这个特性对于拥有公共克隆函数并允许从层次结构中的所有级别进行克隆调用的实现是很重要的。

我选择用一个帮助信息函数和一个相应的虚拟实现函数来完善命令界面。这个帮助功能的意图是强制要求各个命令实现者为命令提供简短的文档,可以通过用户界面上的帮助命令进行查询。帮助功能对于命令的功能来说并不重要,它作为设计的一部分是可选的。然而,为命令的使用提供一些内部文档总是好的,即使是在一个像计算器这样简单的程序中。

结合以上所有的信息,我们终于可以为我们的命令类写出完整的抽象接口了,见清单4-3。

Listing 4-3. 命令类的完整抽象接口

  1. class Command {
  2. public:
  3. virtual ~Command();
  4. void execute();
  5. void undo();
  6. Command* clone() const;
  7. const char* helpMessage() const;
  8. protected:
  9. Command();
  10. Command(const Command&);
  11. private:
  12. virtual void checkPreconditionsImpl() const;
  13. virtual void executeImpl() noexcept = 0;
  14. virtual void undoImpl() noexcept = 0;
  15. virtual Command* cloneImpl() const = 0;
  16. virtual const char* helpMessageImpl() const noexcept = 0;
  17. };

如果你看一下Command.h的源代码,你还会看到一个虚拟的deallocate()函数。这个函数是专门用于插件的,它在接口中的加入将在第七章中讨论。


现代C++设计说明:override关键字

  1. class Base {
  2. public:
  3. virtual void foo(int);
  4. };
  5. class Derived : public Base {
  6. public:
  7. void foo(double);
  8. };
  1. Base* p = new Derived;
  2. p->foo(2.1);

哪个函数被调用?大多数C++新手都认为Derived::foo()被调用了,因为他们认为Derivedfoo()是覆盖了Base的实现。然而,由于基类和派生类之间foo()函数的签名不同,Basefoo()实际上隐藏了Derived的实现,因为重载不能跨越范围边界。因此,不管参数的类型如何,调用p->foo()将调用Basefoo()。有趣的是,出于同样的原因

  1. Derived d;
  2. d.foo(2);

除了Derivedfoo()之外,永远不能调用任何东西。

在C++03和C++11中,上述代码的行为方式完全一样,令人困惑,但技术上是正确的。然而,从C++11开始,派生类可以选择性地用override关键字来标记重写函数:

  1. class Derived : public Base {
  2. public:
  3. void foo(double) override;
  4. };

现在,编译器会将这个声明标记为错误,因为程序员明确地声明派生函数应该覆盖。因此,override关键字的加入,通过允许程序员明确其意图,防止了一个令人困惑的错误发生。

从设计的角度来看,override关键字明确地将函数标记为重写。虽然这看起来并不重要,但当在一个大的代码库中工作时,它是相当有用的。当实现一个基类在代码的另一个不同部分的派生类时,知道哪些函数覆盖基类函数,哪些不覆盖基类函数是很方便的,而不必看基类的声明。


4.2.3.2 撤销策略

在为我们的命令定义了抽象的接口之后,我们接下来要设计撤销策略。从技术上讲,由于我们接口中的undo()命令是一个纯虚拟的,我们可以简单地挥挥手,声称undo的实现是每个具体命令的问题。然而,这样做既不雅观又没有效率。相反,我们为所有的命令(或者至少是命令的分组)寻求一些功能上的共性,这可能会使我们在更高的层次上实现撤销,而不是在命令层次结构的每个叶子节点上。

如前所述,撤销可以通过命令反转或状态重建(或两者的某种组合)来实现。命令反转已经被证明是有问题的,因为对于某些命令来说,反转问题是不成立的(具体来说,它有多个解)。因此,让我们研究一下状态重建作为pdCalc的通用撤销策略。

我们开始分析,考虑一个用例,即加法操作。加法运算从Stack中取出两个元素,把它们加在一起,然后返回结果。一个简单的撤销可以通过从Stack中删除结果并恢复原来的操作数来实现,前提是这些操作数是由execute()命令存储的。现在,考虑减法,或乘法,或除法。这些命令也可以通过丢弃其结果并恢复其操作数来撤销。对所有的命令实现撤销是不是很简单,我们只需要在execute()过程中存储堆栈中的前两个值,并通过丢弃命令的结果和恢复存储的操作数来实现撤销?不,考虑一下正弦、余弦和正切。它们各自从Stack中取出一个操作数并返回一个结果。考虑一下交换。它从Stack中取出两个操作数并返回两个结果(操作数的顺序相反)。一个完全统一的撤销策略不可能在所有的命令中实现。也就是说,我们不应该放弃希望,回到为每个命令单独实现撤销。

仅仅因为我们计算器中的所有命令都必须从Command类派生出来,没有规则要求这种继承必须是图4-1中描述的 直接继承。相反,请考虑图4-2中描述的命令层次结构。虽然有些命令仍然直接继承自Command基类,但我们已经创建了两个新的子类,可以从中继承更多的专业命令。事实上,正如我们不久将看到的,这两个新的基类本身就是抽象的。
image.png
图4-2. 计算器的命令模式的多级层次结构

我们前面的用例分析确定了两个重要的操作子类,它们对各自的成员统一实现撤销:二进制命令(采取两个操作数并返回一个结果的命令)和单进制命令(采取一个操作数并返回一个结果的命令)。因此,我们可以通过通用地处理这两类命令的撤销来大大简化我们的实现。虽然不属于单项或二项命令系列的命令仍然需要单独实现undo(),但这两个子类别占了计算器核心命令的75%左右。创建这两个抽象概念将节省大量的工作。

让我们来看看UnaryCommand类。根据定义,所有的单项命令都需要一个参数并返回一个值。例如:f(x) = sin(x) 从Stack中获取一个数字x,并将结果f(x)返回到Stack中。如前所述,之所以将所有的单项函数视为一个家族,是因为无论哪种函数,所有的单项命令都以相同的方式实现向前执行和撤销,区别只在于f的功能形式。也就是,Stack上必须至少有一个元素。

在代码中,通过在UnaryCommand基类中覆盖executeImpl()undoImpl()checkPreconditionsImpl(),并创建一个新的unaryOperation()纯虚拟,将每个命令的精确实现委托给另一个派生类,来强制执行上述单项命令的共同特征。结果就是一个UnaryCommand类,其声明如下,见清单4-4。

清单4 UnaryCommand类

  1. class UnaryCommand : public Command {
  2. public:
  3. virtual ~UnaryCommand();
  4. protected:
  5. void checkPreconditionsImpl() const override;
  6. UnaryCommand() { }
  7. UnaryCommand(const UnaryCommand&);
  8. private:
  9. void executeImpl() override;
  10. void undoImpl() override;
  11. virtual double unaryOperation(double top) const = 0;
  12. double top_;
  13. };

为了完整起见,让我们检查一下Command的三个重写函数的实现。检查前提条件是微不足道的;我们确保至少有一个元素在栈上。如果没有,就会抛出一个异常。

  1. void UnaryCommand::checkPreconditionsImpl() const
  2. {
  3. if (Stack::Instance().size() < 1)
  4. throw Exception { "Stack must have one element" };
  5. }

executeImpl()命令也是非常直接的:

  1. void UnaryCommand::executeImpl()
  2. {
  3. top_ = Stack::Instance().pop(true);
  4. Stack::Instance().push(unaryOperation(top_));
  5. }

最上面的元素被从Stack中弹出并存储在UnaryCommand的状态中,以便撤销。记住,因为我们已经检查了前提条件,所以我们可以保证unaryOperation()会在没有错误的情况下完成。有特殊前提条件的命令仍然需要实现checkPreconditionsImpl(),但它们至少可以将单数前提条件的检查向上委托给UnaryCommand的checkPreconditionImpl()函数。然后,我们一举将单选函数操作分派给另一个派生类,并将其结果推回堆栈中。

UnaryCommand的executeImpl()函数中唯一的特殊之处在于Stack的pop命令的布尔参数。这个布尔参数可以选择抑制Stack改变事件的发射。因为我们知道接下来对Stack的push命令将立即再次改变Stack,所以没有必要发出两个后续的Stack改变事件。对这个事件的抑制允许命令实现者将命令的动作归结为一个用户可见的事件。尽管Stack的pop()的这个布尔参数不是原始设计的一部分,但现在可以作为一种方便的方式将这个功能添加到Stack类中。记住,设计是迭代的。

最后要检查的成员函数是undoImpl():

  1. void UnaryCommand::undoImpl()
  2. {
  3. Stack::Instance().pop(true);
  4. Stack::Instance().push(top_);
  5. }

这个函数也有预期的明显实现。单元操作的结果被从Stack中删除,之前的顶层元素,即在执行executeImpl()时存储在类的top_成员中的元素,被恢复到Stack中。

作为使用UnaryCommand类的一个例子,我们介绍了正弦命令的部分实现。

  1. class Sine : public UnaryCommand {
  2. private:
  3. double unaryOperation(double t) const override { return std::sin(t); }
  4. };

显然,使用UnaryCommand作为基类而不是最高级别的Command类的好处是,我们不再需要实现undoImpl()checkPreconditionsImpl(),而且我们用稍微简单的unaryOperation()替换了executeImpl()的实现。我们不仅在整体上需要更少的代码,而且由于undoImpl()checkPreconditionsImpl()的实现在所有单数命令中都是相同的,我们也减少了代码的重复,这是一个积极的方面。

二进制命令的实现方式与单进制命令类似。唯一的区别是,执行操作的函数需要两个命令作为操作数,并且相应地必须存储这两个值以便撤销。BinaryCommand类的完整定义可以在GitHub资源库中的Command.h头文件中找到,与Command和UnaryCommand类一起。

4.2.3.3 具体命令

定义上面的Command、UnaryCommand和BinaryCommand类,就完成了在计算器中使用命令模式的抽象接口。获得这些正确的接口包含了命令设计的大部分内容。然而,在这一点上,我们的计算器还没有一个具体的命令(除了部分正弦类的实现外)。本节将最终纠正这个问题,我们的计算器的核心功能将开始形成。

计算器的核心命令都是在CoreCommands.h文件中定义的,其实现可以在相应的CoreCommands.cpp文件中找到。什么是核心命令?我把核心命令定义为包含了从第1章中列出的需求中提炼出来的功能的命令集。计算器必须执行的每个不同的动作都有一个独特的核心命令。为什么我把这些命令称为核心命令?它们是核心命令,因为它们与计算器一起被编译和链接,因此在计算器加载时立即可用。事实上,它们是计算器内在的一部分。这与插件命令相反,它们可以在运行时由计算器动态加载。插件命令将在第7章详细讨论。

虽然人们可能怀疑我们现在需要进行分析以确定核心命令,但事实证明,这种分析已经完成了。具体来说,核心命令是由第二章用例中描述的动作定义的。精明的读者甚至会记得,用例中的异常列表定义了每个命令的前提条件。因此,在必要时参考用例,我们可以很容易地推导出核心命令。为了方便起见,它们都被列在表4-1中。

Table 4-1. 按其直属抽象基类列出的核心命令
image.png
在比较上面的核心命令列表和第二章的用例时,我们注意到明显没有撤销和重做的命令,尽管它们都是用户可以要求计算器执行的操作。这两个“命令”很特别,因为它们在系统中对其他命令起作用。由于这个原因,它们没有被作为命令模式意义上的命令来实现。相反,它们是由尚未讨论的CommandManager内在地处理的,它是负责请求命令、调度命令、请求撤销和重做操作的类。撤销和重做动作(相对于每个命令所定义的撤销和重做操作而言)将在下面的第4.4节中详细讨论。

每个核心命令的实现,包括前提条件的检查、前进操作和撤销的实现,都是比较直接的。大多数命令类可以用20行左右的代码实现。有兴趣的读者如果想研究细节,可以参考资源库的源代码。

4.2.3.4 深层调度层次的替代方案

为每个操作创建一个单独的命令类是实现命令模式的一种非常经典的方式。然而,现代C++给了我们一个非常有说服力的替代方案,使我们能够将层次结构扁平化。具体来说,我们可以使用lambda表达式(见边栏)来封装操作,而不是创建额外的派生类,然后使用标准函数类(见边栏)来将这些操作存储在UnaryCommand或BinaryCommand级别的类中。为了使讨论具体化,让我们考虑BinaryCommand类的另一种局部设计,如清单4-5所示。

Listing 4-5. 一个替代性的部分设计

  1. class BinaryCommandAlternative final : public Command {
  2. using BinaryCommandOp = double(double, double);
  3. public:
  4. BinaryCommandAlternative(const string& help,
  5. function<BinaryCommandOp> f);
  6. private:
  7. void checkPreconditionsImpl() const override;
  8. const char* helpMessageImpl() const override;
  9. void executeImpl() override;
  10. void undoImpl() override;
  11. double top_;
  12. double next_;
  13. string helpMsg_;
  14. function<BinaryCommandOp> command_;
  15. };

现在,我们不是用一个抽象的BinaryCommand来实现executeImpl(),而是声明一个具体的、最终的(见边栏)类,它接受一个可调用的目标,通过调用这个目标来实现executeImpl()。事实上,BinaryCommand和BinaryCommandAlternative之间唯一的实质性区别是executeImpl()命令的实现上的细微差别;见清单4-6。
Listing 4-6. 微妙的差异

  1. void BinaryCommandAlternative::executeImpl()
  2. {
  3. top_ = Stack::Instance().pop(true);
  4. next_ = Stack::Instance().pop(true);
  5. // invoke callable target instead of virtual dispatch:
  6. Stack::Instance().push(command_(next_, top_));
  7. }

现在,作为一个例子,与其声明一个乘法类并实例化一个乘法对象,如

  1. auto mult = new Multiply;

我们创建一个能够进行乘法运算的二进制命令替代方案,如

  1. auto mult = new BinaryCommandAlternative { "help msg",
  2. [](double d, double f) { return d * f; } };

为了完整起见,我提到,由于没有类进一步派生自BinaryCommandAlternative,我们必须在构造函数中直接处理帮助信息,而不是在派生类中。此外,正如所实现的,BinaryCommandAlternative只处理二进制的前提条件。然而,可以用类似于处理二进制操作的方式来处理其他前提条件。也就是说,构造函数可以接受并存储一个lambda,在checkPreconditionImpl()中对两个Stack参数进行测试后执行前提条件测试。

很明显,通过创建UnaryCommandAlternative类,单进制命令可以与二进制命令类似地处理。如果有足够的模板,我很肯定你甚至可以把二进制和单进制命令统一到一个类中。但要注意的是。太多的聪明才智,虽然在茶水间闲聊时令人印象深刻,但通常不会带来可维护的代码。在这个扁平化的命令层次结构中,为二进制命令和单进制命令保留单独的类,可能在简洁和易懂之间取得了适当的平衡。

BinaryCommand的executeImpl()和BinaryCommandAlternative的executeImpl()之间的实现差异相当小。然而,你不应该低估这一变化的规模。最终的结果是,在命令模式的实现上存在着明显的设计差异。在一般情况下,一个比另一个好吗?我不认为可以明确地做出这样的陈述;每一种设计都有取舍。BinaryCommand策略是命令模式的经典实现,大多数有经验的开发者都会认识到这一点。源代码非常容易阅读、维护和测试。对于每一个命令,都会创建一个确切的类来执行一个操作。另一方面,BinaryCommandAlternative是非常简洁的。与其为n个操作建立n个类,不如只存在一个类,每个操作都由构造函数中的lambda定义。如果节省代码是你的目标,那么这种替代风格是很难被打败的。然而,由于lambdas的定义是匿名对象,不对系统中的每个二进制操作进行命名就会失去一些清晰性。

对于pdCalc来说,哪种策略更好,深层命令层次结构还是浅层命令层次结构?就我个人而言,我更喜欢深层次的命令结构,因为命名每个对象带来的清晰性。然而,对于像加法和减法这样的简单操作,我认为可以提出一个很好的论据,即减少行数所带来的清晰度比匿名所带来的损失要大。由于我的个人偏好,我使用深层次的层次结构和二进制命令类来实现大部分的命令。尽管如此,我还是通过BinaryCommandAlternative实现了乘法,以说明实践中的实现。在一个生产系统中,我强烈建议选择其中一种策略。在同一个系统中实现这两种模式肯定比采用一种更让人困惑,即使选择的那一种被认为是次优的。


现代C++设计说明:lambdas、function和final的关键字
Lambdas、function和final关键字实际上是三个独立的现代C++概念。因此,我将分别讨论它们。
Lambdas:
Lambdas(更正式的说法是lambda表达式)可以被认为是匿名的函数对象。推理lambdas的最简单方法是考虑其函数对象的等价物。定义lambda的语法如下:
capture-list{function-body}

上述lambda语法完全等同于一个函数对象,它通过构造函数将capture-list存储为成员变量,并提供一个operator() const成员函数,参数由argument-list提供,函数体由function-body提供。operator()的返回类型通常是从函数体中推导出来的,但如果需要的话,也可以使用替代的函数返回类型语法(即在参数列表和函数体之间使用 ->ret)手动指定。

鉴于lambda表达式和函数对象之间的等价性,lambdas实际上并没有为C++提供新的功能。任何可以在C++11中用lambda完成的事情都可以在C++03中用不同的语法完成。然而,lambdas所提供的是一种引人注目的、简洁的语法,用于声明内联的匿名函数。lambdas的两个非常常见的用例是作为STL算法的谓词和C++11异步任务的目标。有些人甚至认为,lambda语法是如此引人注目,以至于不再需要在高级代码中写for循环,因为它们可以被lambda和算法取代。我个人认为这种观点过于极端。

在二进制命令的替代设计中,你看到了lambdas的另一个用途。它们可以被存储在对象中,然后按需调用,为实现算法提供不同的选择。在某些方面,这种范式编码了策略模式的一个微观应用。为了避免与命令模式相混淆,我特别没有在正文中介绍策略模式。有兴趣的读者可以参考Gamma等人[6]的详细资料。

Standard function:
function类是C++标准库的一部分。这个类为任何可调用目标提供了一个通用的包装,将这个可调用目标转换为一个函数对象。基本上,任何可以像函数一样被调用的C++结构体都是一个可调用的目标。这包括函数、lambdas和成员函数。

标准函数提供了两个非常有用的功能。首先,它为与任何可调用目标的接口提供了一个通用设施。也就是说,在模板编程中,将一个可调用的目标存储在一个函数对象中,统一了目标上的调用语义,与底层类型无关。其次,函数能够存储其他难以存储的类型,如lambda表达式。在BinaryCommandAlternative的设计中,我们利用函数类来存储lambdas,以实现小型算法,将策略模式覆盖到命令模式上。虽然在pdCalc中没有实际使用,但函数类的通用性实际上使得BinaryCommandAlternative构造函数可以接受除lambdas之外的可调用目标。

The final Keyword:
在C++11中引入的final关键字使类的设计者能够声明一个类不能被继承或一个虚拟函数不能被进一步重写。对于那些来自C#或Java的程序员来说,你会知道C++在final(双关语)加入这一功能方面是迟到的。

在C++11之前,需要使用讨厌的hacks来阻止一个类的进一步派生。从C++11开始,final关键字使编译器能够强制执行这一约束。在C++11之前,许多C++设计师认为final关键字是不必要的。希望一个类不能被继承的设计者只需将析构器变成非虚拟的,从而暗示从这个类派生出的东西不在设计者的意图之内。任何看过继承自STL容器的代码的人都会知道,开发者是多么倾向于遵循编译器不执行的意图。你有多少次听到同行的开发者说:”当然,这是个坏主意,在一般情况下,但是,别担心,在我的特殊情况下,这很好。” 这句经常说的话语几乎不可避免地会在接下来的一周时间里进行调试,以追踪晦涩难懂的bug。

为什么你想阻止继承一个类或覆盖一个先前声明的虚拟函数?可能是因为你遇到了这样的情况:继承虽然被语言很好地定义了,但在逻辑上却毫无意义。这方面的一个具体例子是pdCalc的BinaryCommandAlternative类。虽然你可以尝试从它派生并覆盖executeImpl()成员函数(没有final关键字),但该类的意图是终止层次结构并通过可调用的目标提供二进制操作。继承自BinaryCommandAlternative是在其设计范围之外的。因此,防止派生有可能防止微妙的语义错误。


4.3 命令库

我们的计算器现在拥有满足其要求的所有命令。然而,我们还没有定义存储命令和随后按需访问它们所需的基础设施。在这一节中,我们将探讨几种存储和检索命令的设计策略。

4.3.1 CommandRepository类

乍一看,实例化一个新的命令似乎是一个微不足道的问题,需要解决。例如,如果用户要求将两个数字相加,下面的代码将执行这一功能。

  1. Command* cmd = new Add;
  2. cmd->execute();

太好了! 问题解决了,对吗?并非如此。这段代码是如何调用的?这段代码出现在哪里?如果增加了新的核心命令(即要求改变)会怎样?如果新的命令是动态添加的(如在插件中),会怎样?看起来很容易解决的问题,实际上比最初预期的要复杂。让我们通过回答上述问题来探索可能的设计方案。

首先,我们问的是如何调用代码的问题。计算器的部分要求是要有一个命令行界面(CLI)和一个图形用户界面(GUI)。显然,初始化一个命令的请求将在用户界面的某个地方产生,以响应用户的操作。让我们考虑一下用户界面将如何处理减法。假设GUI有一个减法按钮,当这个按钮被点击时,一个函数被调用来初始化和执行减法命令(我们暂时忽略撤销)。现在考虑CLI。当减法标记被识别时,一个类似的函数被调用。起初,人们可能期望我们可以调用相同的函数,只要它存在于业务逻辑层而不是用户界面层中。然而,GUI回调的机制使得这是不可能的,因为这将迫使业务逻辑层对GUI的部件库产生不必要的依赖(例如,在Qt中,按钮回调是一个类中的槽,这要求回调的类是一个Q_OBJECT)。或者,GUI可以部署双重定向来调度每个命令(每个按钮的点击会调用一个函数,而这个函数会调用业务逻辑层的一个函数)。这种情况似乎既不优雅又没有效率。

虽然上述策略看起来相当麻烦,但这种初始化方案在结构上的缺陷远比不便要深。在我们为pdCalc采用的模型-视图-控制器架构中,不允许视图直接访问控制器。由于命令是属于控制器的,由用户界面直接初始化命令违反了我们的基础架构。

我们如何解决这个新问题? 回想一下表2-2,命令调度程序的唯一公共接口是事件处理函数commandEntered(const string&)。 这个实现实际上回答了我们最初提出的两个问题:初始化和执行代码是如何调用的,它驻留在哪里? 此代码必须通过从UI到命令分派器的事件间接触发,该事件使用通过字符串编码的特定命令。 代码本身必须驻留在命令调度程序中。 注意,这个接口还有一个额外的好处,就是在创建新命令时,可以消除CLI和GUI之间的重复。 现在,两个用户界面都可以简单地通过引发commandEntered事件并通过字符串指定命令来创建命令。 您将分别在第5章和第6章中看到每个用户界面如何实现引发该事件。

从上面的分析来看,我们有动力为命令调度器添加一个新的类,负责拥有和分配命令。我们将这个类称为CommandRepository。目前,我们假设命令调度器的另一部分(CommandDispatcher类)接收commandEntered()事件并从CommandRepository(通过commandEntered()的字符串参数)请求适当的命令,而命令调度器的另一个组件(CommandManager类)随后执行该命令(并处理撤销和重做)。也就是说,我们已经将命令的初始化和存储与它们的调度和执行解耦。CommandManager和CommandDispatcher类是接下来章节的主题。现在,我们将专注于命令的存储、初始化和检索。

我们从以下CommandManager类的骨架接口开始:

  1. class CommandRepository {
  2. public:
  3. unique_ptr<Command> allocateCommand(const string&) const;
  4. };

从上面的接口,我们可以看到,给定一个字符串参数,存储库会分配相应的命令。该接口采用了一个智能指针返回类型,明确指出调用者拥有新构建的命令的内存。

现在让我们考虑allocateCommand()的实现会是什么样子。这个练习将帮助我们修改设计,使其更加灵活。

  1. unique_ptr<Command> CommandRepository::allocateCommand(const string& c) const
  2. {
  3. if (c == "+")
  4. return make_unique<Add>();
  5. else if (c == "-")
  6. return make_unique<Subtract>();
  7. // ... all known commands ...
  8. else
  9. return nullptr;
  10. }

上述界面简单而有效,但由于需要对系统中的每一条命令有先验的了解,所以它有局限性。一般来说,这样的设计是非常不可取和不方便的,原因有几个。首先,在系统中添加一个新的核心命令需要修改版本库的初始化功能。第二,部署运行时插件命令将需要一个完全不同的实现。第三,这种策略在特定命令的实例化和其存储之间产生了不必要的耦合。相反,我们更倾向于这样的设计:CommandRepository只依赖于Command基类所定义的抽象接口。

上述问题是通过应用一个简单的模式来解决的,这个模式被称为原型模式[6]。原型模式是一种创建模式,原型对象被存储起来,这种类型的新对象只需复制原型就可以创建。现在,考虑一种设计,将我们的CommandRepository仅仅作为一个命令原型的容器。此外,让原型全部由命令指针存储,例如,在一个哈希表中,使用一个字符串作为键(也许是commandEntered()事件中提出的同一个字符串)。然后,新的命令可以通过添加(或删除)一个新的原型命令来动态地添加(或删除)。为了实现这个策略,我们对CommandRepository类做了如下补充:

  1. class CommandRepository {
  2. public:
  3. unique_ptr<Command> allocateCommand(const string&) const;
  4. void registerCommand(const string& name, unique_ptr<Command> c);
  5. private:
  6. using Repository = unordered_map<string, unique_ptr<Command>>;
  7. Repository repository_;
  8. };
  1. 注册一个命令的实现是非常简单的:
  1. void CommandRepository::registerCommand(const string& name, unique_ptr<Command> c)
  2. {
  3. if (repository_.find(name) != repository_.end())
  4. // handle duplicate command error
  5. else repository_.emplace(name, std::move(c));
  6. }

在这里,我们检查该命令是否已经在版本库中。如果是,那么我们就处理这个错误。如果不是,那么我们就把命令参数移到资源库中,在那里,命令成为命令名的原型。注意,unique_ptr的使用表明,注册一个命令会将这个原型的所有权转移到命令库中。在实际使用中,核心命令都是通过CoreCommands.cpp文件中的一个函数注册的,每个插件内部也有一个类似的函数来注册插件命令(我们将在第七章研究插件的构造时看到这个接口)。这些函数分别在计算器的初始化和插件的初始化过程中被调用。可选的是,命令库可以用明显的实现方式增加一个取消注册的命令。
使用我们的新设计,我们可以重写allocateCommand()函数,如下所示。

  1. unique_ptr<Command> CommandRepository::allocateCommand(const string& name) const
  2. {
  3. auto it = repository_.find(name);
  4. if (it != repository_.end()) {
  5. return unique_ptr<Command>(it->second->clone());
  6. } else
  7. return nullptr;
  8. }
  1. 现在,如果命令在版本库中被找到,就会返回一个原型的副本。如果没有找到该命令,将返回一个nullptr(或者可以抛出一个异常)。原型的拷贝会以unique_ptr的形式返回,表明调用者现在拥有这个命令的拷贝。注意使用命令类中的`clone()`函数。clone函数最初被添加到Command类中,并承诺将来会有相应的理由。正如现在所看到的,我们需要`clone()`函数,以便为我们的原型模式的实现多态地复制Commands。当然,如果我们在设计Command类时没有预见性地实现所有命令的克隆功能,那么现在就可以很容易地加入这个功能。记住,你不可能在第一遍就把设计做得很完美,所以要习惯于迭代设计的想法。

基本上,registerCommand()allocateCommand()体现了CommandRepository类的最小完整接口。然而,如果你检查这个类的源代码,你会看到一些不同之处。首先,额外的函数已经被添加到接口中。这些额外的功能主要是方便和语法糖。第二,整个接口被隐藏在一个pimpl后面。第三,我使用了一个别名 CommandPtr,而不是直接使用 unique_ptr。在本章中,只要认为 CommandPtr 是由以下 using 语句定义的:

  1. using CommandPtr = std::unique_ptr<Command>;

真正的别名,可以在Command.h中找到,稍微复杂一些。它将在第7章中详细解释。相应地,我使用一个函数MakeCommandPtr()而不是make_unique()来创建CommandPtrs。

最后,版本库代码中唯一没有讨论过的影响设计的接口部分是选择让CommandRepository成为单例。这个决定的原因很简单。无论系统中存在多少个不同的命令调度器(有趣的是,我们最终会看到有多个命令调度器的情况),函数的原型永远不会改变。因此,让CommandRepository成为一个单子,可以集中存储、分配和检索计算器的所有命令。


现代C++设计说明:统一初始化
你可能已经注意到,我经常使用大括号进行初始化。对于那些长期使用C++编程的开发者来说,使用大括号来初始化一个类(即调用其构造函数)可能会显得很奇怪。虽然我们习惯于用列表语法来初始化数组,如在

  1. int a[] = { 1, 2, 3 };

使用大括号来初始化类是C++11的一个新特性。 虽然小括号仍可用于调用构造函数,但使用大括号的新语法,称为统一初始化,是现代C++的首选语法。虽然这两种初始化机制在功能上执行相同的任务,但新语法有三个优点:

  1. 统一的初始化是非狭义的

    1. class A { A(int a); };
    2. A a(7.8); // ok, truncates
    3. A a{7.8}; // error, narrows
  2. 统一初始化(与初始化器列表相结合)允许用列表初始化用户定义的类型:

    1. vector <double> v{ 1.1, 1.2, 1.3 }; // valid since C++11; initializes vector with 3 doubles
  3. 统一初始化绝不会被错误地解析为一个函数

    1. struct B { B(); void foo(); };
    2. B b(); // Are you declaring a function that returns a B?
    3. b.foo(); // error, requesting foo() in non-class type b
    4. B b2{}; // ok, default construction
    5. b2.foo(); // ok, call B::foo()

    在使用统一初始化时,只有一个大的注意事项:列表构造函数总是在任何其他构造函数之前被调用。典型的例子来自STL向量类,它有一个初始化列表构造函数和一个单独的构造函数,接受一个整数来定义向量的大小。因为如果使用了大括号,初始化列表构造函数会在任何其他构造函数之前被调用,所以有以下不同的行为。

    1. vector<int> v(3); // vector, size 3, all elements initialized to 0
    2. vector<int> v{3}; // vector with 1 element initialized to 3

    幸运的是,上述情况并不经常出现。然而,当它发生时,你必须了解统一初始化和函数式初始化之间的区别。

从设计的角度来看,统一初始化的主要优点是,用户定义的类型可以被设计成接受相同类型的值的列表来构建。因此,容器,如向量,可以用一个值的列表进行静态初始化,而不是默认初始化后再进行连续赋值。这个现代C++的特性使得派生类型的初始化可以使用与内置数组类型相同的初始化语法,这是C++03中缺少的语法特性。


4.3.2 注册核心命令

我们现在已经定义了计算器的核心命令和一个用于按需加载和提供命令的类。然而,我们还没有讨论将核心命令加载到CommandRepository中的方法。为了正常运行,所有核心命令的加载必须只进行一次,而且必须在计算器使用前进行。本质上,这为命令调度器模块定义了一个初始化要求。由于在退出程序时取消对核心命令的注册是不必要的,所以不需要一个最终化功能。

为命令调度器调用初始化操作的最佳位置是在计算器的main()函数中。因此,我们简单地创建一个全局的RegisterCoreCommands()函数,在CoreCommands.h头文件中声明它,在CoreCommands.cpp文件中实现它,并在main()中调用它。创建一个全局函数而不是在CommandRepository的构造函数中注册核心命令的原因是为了避免CommandRepository与命令层次结构的派生类耦合。当然,这个注册函数可以被称为InitCommandDispatcher(),但我更喜欢一个能更具体描述功能的名字。

隐含地,我们刚刚扩展了命令调度器的接口(最初在表2-2中定义),尽管是相当琐碎的。我们是否应该提前预见到接口的这一部分呢?也许不能。这个接口的更新是由于一个设计决定而必须的,这个设计决定的层次要比第二章的高层分解要详细得多。我认为在开发过程中稍微修改一个关键的接口是一种可以接受的设计方式。要求不变性的设计策略实在是太死板了,不实用。然而,请注意,在开发过程中轻易接受对关键接口的修改与在发布后接受对关键接口的修改是截然不同的,只有在充分考虑到这种修改会对已经在使用你的代码的客户产生什么影响之后,才能做出决定。

4.4 命令管理

在设计了命令基础结构并为系统中的命令的存储、初始化和检索创建了一个存储库后,我们现在准备设计一个负责按需执行命令并管理撤销和重做的类。这个类被称为CommandManager。从本质上讲,它通过对每个命令调用execute()函数来管理命令的生命周期,随后以适合实现无限撤销和重做的方式保留每个命令。我们将从定义CommandManager的接口开始,最后讨论实现无限撤销和重做的策略来结束本节。

4.4.1 接口

CommandManager的接口是非常简单和直接的。CommandManager需要一个接口来接受要执行的命令、撤销命令和重做命令。另外,还可以包括一个查询可用的撤销和重做操作数量的接口,这对于实现GUI可能很重要(例如,对于重做大小等于零的情况,重做按钮置灰)。一旦一个命令被传递给CommandManager,CommandManager就拥有该命令的生命周期。因此,CommandManager的接口应该强制执行拥有的语义。结合起来,我们有以下完整的CommandManager的接口。

  1. class CommandManager {
  2. public:
  3. size_t getUndoSize() const;
  4. size_t getRedoSize() const;
  5. void executeCommand(unique_ptr<Command> c);
  6. void undo();
  7. void redo();
  8. };

在CommandManager.h中列出的实际代码中,该接口额外定义了一个枚举类,用于在构造过程中选择撤销/重做策略。我包括这个选项只是为了说明问题。生产代码将简单地实现一个撤销/重做策略,而不是在构造时使底层数据结构可定制。

为了实现无限制的撤销和重做,我们必须有一个可动态增长的数据结构,能够按照命令的执行顺序存储和重访。尽管我们可以设计许多不同的数据结构来满足这一要求,但我们将研究两种同样好的策略。这两种策略都已在计算器中实现,可以在CommandManager.cpp文件中看到。

考虑图4-3中的数据结构,我把它称为列表策略。在一个命令被执行后,它被添加到一个列表中(实现可以是一个列表、向量或其他合适的有序容器),一个指针(或索引)被更新,指向最后执行的命令。每当撤销被调用时,当前指向的命令被撤销,指针向左移动(早期命令的方向)。当重做被调用时,命令指针会向右移动(后面命令的方向),新指向的命令被执行。当当前命令指针到达最左边(没有更多的命令可以撤销)或最右边(没有更多的命令可以重做)时,就存在边界条件。这些边界条件可以通过禁用使用户调用命令的机制来处理(例如,分别将撤销或重做按钮涂成灰色),或者简单地忽略会导致指针超出边界的撤销或重做命令。当然,每次执行一个新的命令时,在新的命令被添加到撤销/重做列表之前,当前命令指针右边的整个列表必须被刷新。为了防止撤销/重做列表变成一棵有多个重做分支的树,这种刷新列表是必要的。
image.png
Figure 4-3. 撤销/重做列表策略

作为一种选择,考虑图4-4中的数据结构,我称之为Stack策略。我们不是按照命令的执行顺序来维护它们的列表,而是维护两个Stack,一个用于撤销命令,一个用于重做命令。在一个新的命令被执行后,它被推到撤销Stack中。命令的撤销是通过从撤销Stack中弹出最上面的条目,撤销命令,并将命令推到重做Stack中。命令的重做是通过从重做Stack中弹出顶部条目,执行命令,并将命令推入撤销Stack。边界条件是存在的,可以通过Stack的大小来识别。执行一个新的命令需要刷新重做Stack。
image.png
figure 4-4. 撤销/重做堆栈策略

实际上,在通过Stack或列表策略实现撤销和重做之间的选择,主要是个人的偏好。列表策略只需要一个数据容器和较少的数据移动。然而,Stack策略稍微容易实现,因为它不需要索引或指针移动。也就是说,这两种策略都相当容易实现,只需要很少的代码。一旦你实现并测试了这两种策略,CommandManager就可以很容易地在未来需要撤销和重做功能的项目中重复使用,只要命令是通过命令模式实现的。为了获得更多的通用性,CommandManager可以在抽象的Command类上进行模板化。为了简单起见,我选择专门为前面讨论的抽象Command类实现所包含的CommandManager。

4.5 命令调度器

命令调度器模块的最后一个组件是 CommandDispatcher 类本身。尽管这个类被命名为CommandInterpreter更为恰当,但我还是保留了CommandDispatcher这个名字,以强调这个类作为命令调度器模块与计算器其他部分的接口。也就是说,就其他模块而言,CommandDispatcher类是命令调度器模块的全部。

如前所述,CommandDispatcher类有两个主要作用。第一个角色是作为命令调度器模块的接口。第二个角色是解释每个命令,从CommandRepository中请求适当的命令,并将每个命令传递给CommandManager执行。我们依次处理这两个角色。

4.5.1 接口

尽管命令调度器模块的实现很复杂,但CommandDispatcher类的接口却非常简单(正如大多数好的接口一样)。正如第二章所讨论的,命令调度器的接口完全由一个用于执行命令的单一函数组成;命令本身由一个字符串参数指定。这个函数自然就是之前讨论过的executeCommand()事件处理程序。因此,CommandDispatcher类的接口是由以下内容组成的:

  1. class CommandDispatcher {
  2. public:
  3. CommandDispatcher(UserInterface& ui);
  4. void executeCommand(const string& command);
  5. private:
  6. unique_ptr<CommandDispatcherImpl> pimpl_;
  7. };

回顾一下,计算器的基本结构是基于模型-视图-控制器模式,CommandDispatcher被允许直接访问模型(Stack)和视图(用户界面)。因此,CommandDispatcher的构造函数需要一个对抽象的UserInterface类的引用,其细节将在第五章讨论。对堆栈的直接引用是不需要的,因为Stack被实现为一个单例。按照我的惯例,CommandDispatcher的实际实现被推迟到一个私有的实现类,CommandDispatcherImpl。

与上面的设计相比,另一种设计是将CommandDispatcher类直接作为观察者。正如在第3章中所讨论的,我更喜欢使用中介事件观察者的设计。在第5章中,我将讨论一个CommandIssuedObserver代理类的设计和实现,在用户界面和CommandDispatcher类之间进行事件中介。

4.5.2 实现细节

通常在本书中,我不会讨论pimpl类中包含的实现细节。但在这种情况下,CommandDispatcherImpl类的实现特别具有指导意义。CommandDispatcherImpl类的主要功能是实现executeCommand()函数。这个函数必须接收命令请求,解释这些请求,检索命令,请求执行命令,并优雅地处理未知命令。如果我们自上而下地开始分解命令调度器模块,想要干净利落地实现这个函数将是非常困难的。然而,由于我们采用了自下而上的方法,executeCommand()的实现在很大程度上是将现有的组件粘合在一起的练习。考虑下面的实现,其中manager_对象是CommandManager类的一个实例,如清单4-7中所示。

  1. void CommandDispatcher::CommandDispatcherImpl::executeCommand(const string& command)
  2. {
  3. // entry of a number simply goes onto the the stack
  4. double d;
  5. if( isNum(command, d) )
  6. manager_.executeCommand(MakeCommandPtr<EnterNumber>(d));
  7. else if(command == "undo")
  8. manager_.undo();
  9. else if(command == "redo")
  10. manager_.redo();
  11. else if(command == "help")
  12. printHelp();
  13. else if(command.size() > 6 && command.substr(0, 5) == "proc:")
  14. {
  15. auto filename = command.substr(5, command.size() - 5);
  16. handleCommand( MakeCommandPtr<StoredProcedure>(ui_, filename) );
  17. }
  18. else
  19. {
  20. auto c = CommandRepository::Instance().allocateCommand(command);
  21. if(!c)
  22. {
  23. ostringstream oss;
  24. oss << "Command " << command << " is not a known command";
  25. ui_.postMessage( oss.str() );
  26. }
  27. else handleCommand( std::move(c) );
  28. }
  29. return;
  30. }

第5-10行处理特殊命令。特殊命令是任何不在命令库中输入的命令。在上面的代码中,这包括输入一个新号码、撤销和重做。如果没有遇到特殊命令,那么就假定可以在命令库中找到该命令。这个请求是在第13行提出的。如果从命令库中返回nullptr,则在第16-18行中处理这个错误。否则,该命令将由命令管理器执行。注意,命令的执行是在一个try/catch块中处理的。通过这种方式,我们能够捕获由命令前提条件失败引起的错误,并在用户界面上报告这些错误。命令管理器的实现确保了在前提条件下失败的命令不会被输入到撤销堆栈中(很简单,按执行顺序)。

实现包括两个额外的特殊命令。这些额外的特殊命令中的第一个是help。发出help命令可以为当前在命令库中的所有命令打印一个简短的解释信息。虽然该实现一般会将帮助信息打印到用户界面上,但我只在CLI中暴露了帮助命令(也就是说,我的GUI的实现没有帮助按钮)。第二个特殊命令是关于存储过程的处理。存储过程在第8章有解释。此外,我把try/catch块放在自己的函数中。这样做只是为了缩短executeCommand()函数,并将命令解释的逻辑与命令执行分开。

4.6 回顾之前的决定

在这一点上,我们已经完成了计算器的两个主要模块:Stack和命令调度器。让我们重新审视一下我们的原始设计,讨论一下出现的一个重要的微妙的偏差。

回顾一下第2章,我们最初的设计是通过在Stack和命令调度器中引发事件来处理错误,而这些事件将由用户界面来处理。这个决定的原因是为了一致性。虽然命令调度器有一个对用户界面的引用,但Stack却没有。因此,我们决定简单地让这两个模块通过事件来通知用户界面的错误。然而,精明的读者会注意到,按照上面的设计,命令调度器从未引发过异常。相反,当错误发生时,它直接调用用户界面。难道我们没有破坏系统中有意设计的一致性吗?事实上,在设计命令调度器时,我们隐含地重新设计了系统的错误处理机制,这样,无论是Stack还是命令调度器都不会引发错误事件。让我们研究一下原因。

正如我刚才所说,从它的实现来看,很明显,命令调度器不会引发错误事件,但Stack事件发生了什么?我们并没有改变Stack类的源代码,那么错误事件是如何被消除的呢?在最初的设计中,当错误发生时,Stack通过引发事件间接地通知用户界面。两个可能的Stack错误情况是弹出一个空的Stack和交换一个不够大的Stack的前两个元素。在设计命令时,我意识到如果一个命令触发了这两种错误情况,用户界面会被通知,但命令调度器不会被通知(它不是Stack事件的观察者)。在这两种错误情况下,一条命令已经完成,尽管没有成功,但却被错误地放在了撤销Stack中。然后我意识到,要么命令调度器必须捕获堆栈错误并防止错误地放置在撤销Stack中,要么命令不应该被允许产生Stack错误。正如最终的设计所展示的,我选择了更简单、更干净的实现方式,即在执行命令之前使用预设条件来防止Stack错误的发生,从而隐含地抑制了Stack错误。

最大的问题是,为什么我不改变描述原始设计的文字和相应的代码来反映错误报告的变化呢?简单地说,我想让读者看到,错误确实会发生。设计是一个迭代的过程,一本试图通过实例来教授设计的书应该接受这个事实,而不是隐藏它。设计应该具有一定的流动性(但可能具有较高的粘性)。尽早改变一个糟糕的设计决定,要比在遇到证据表明原始设计存在缺陷的情况下仍然坚持下去要好得多。一个糟糕的设计改得越晚,修复它的成本就越高,而开发人员在试图实现一个错误的过程中也会产生更多的痛苦。至于改变代码本身,当我进行重构时,我会从生产系统中的Stack类中删除多余的代码,除非Stack类被设计成在另一个通过事件处理错误的程序中重复使用。毕竟,作为一个通用的设计,通过引发事件来报告错误的机制是没有缺陷的。事后看来,这种机制对pdCalc来说根本不合适。