五、命令行界面
这是非常激动人心的一章。虽然命令行界面(CLI)可能不具备现代图形用户界面(GUI)的魅力,尤其是那些手机、平板电脑或 Web 界面,但 CLI 仍然是非常有用和有效的用户界面。本章详细介绍了 pdCalc 命令行界面的设计和实现。到本章结束时,我们将第一次拥有一个功能正常的计算器,尽管功能不完整,这是我们开发过程中的一个重要里程碑。
5.1 用户界面抽象
虽然我们可以单独设计一个功能完整的 CLI,但我们从需求中知道,功能完整的计算器必须同时具有 CLI 和 GUI。因此,通过首先考虑这两个接口之间的共性并将这种功能分解到一个公共抽象中,我们的整体设计将会得到更好的服务。让我们考虑构造用户界面抽象的两种设计方案:自顶向下的方法和自底向上的方法。
在考虑具体类型之前设计抽象接口类似于自顶向下的设计。就用户界面而言,你首先要考虑任何用户界面都必须符合的最基本的要素,并基于这个极简主义的概念创建一个抽象的界面。当抽象概念缺少实现具体类型所需的东西时,对接口的细化就变得必要了。在考虑具体类型之后设计抽象接口类似于自底向上的设计。同样,就用户界面而言,您首先要考虑所有具体类型的需求(在本例中是 CLI 和 GUI),寻找所有类型之间的共性,然后将这些共性提取出来。当您添加一个新的具体类型,该类型需要最初提取抽象时没有考虑到的额外特性时,对接口的细化就变得必要了。
一般来说,自顶向下和自底向上哪种策略更适合创建抽象界面?通常,答案取决于具体的情况、个人舒适度和风格。在这个特定的场景中,我们更好地从抽象开始,向下工作到具体的类型(自顶向下的方法)。
为什么呢?在这种情况下,自顶向下的方法基本上是免费的。用户界面是 pdCalc 的高级模块之一,当我们执行初始分解时,我们已经在第二章中为 UI 模块定义了抽象接口。现在让我们把抽象的模块接口变成实际的面向对象的设计。
抽象接口
为用户界面提供一个抽象界面的目的是使程序的其余部分能够与用户界面进行交互,而不用考虑当前界面是图形界面、命令行界面还是其他什么界面。理想情况下,我们将能够将抽象接口分解为使用每个具体接口所需的最少数量的函数。任何共享实现的函数都可以在基类中定义,而任何需要基于具体类型的唯一实现的函数都可以在抽象基类中声明为虚拟的,并在派生类中定义。这个概念相当简单,但是,像往常一样,细节决定成败。
图 5-1
最小接口层次结构
考虑图 5-1 中描述的层级。我们的目标是为 pdCalc 的UserInterface
类设计一个最小但完整的接口,与 Liskov 替换原则一致,该接口既适用于 CLI,也适用于 GUI。如前所述,我们已经在第二章中为这个 UI 定义了一个高级接口。让我们从这个预定义的接口开始,并根据需要进行重构。
参照表 2-2 ,我们看到UserInterface
类的完整接口由两个事件处理函数postMessage()
和stackChanged()
以及一个UserInterface
引发的事件commandEntered()
组成。有趣的是,UserInterface
类是发布者、观察者、抽象用户接口类,也是模块接口的主要组件。
两个事件处理函数postMessage()
和stackChanged()
在接口级是简单明了的。正如我们对以前的观察者所做的那样,我们将简单地将这两个函数添加到UserInterface
类的公共接口中,并创建代理观察者类来代理发布者和实际观察者之间的通信。这些代理将在“用户界面观察者”一节中详细讨论。具体的用户界面必须根据单个用户界面与用户的交互方式,唯一地处理事件处理的实现。因此,postMessage()
和stackChanged()
必须都是纯虚拟的。因为在事件处理过程中不需要UserInterface
类的介入,为了简单起见,我选择放弃 NVI 模式。然而,正如在第四章中所讨论的,我们可以使用 NVI 模式来处理琐碎的转发非虚拟接口功能。
类作为发布者的角色比作为观察者的角色稍微复杂一些。正如我们在第三章中看到的,在设计Stack
类时,Stack
实现了发布者接口,而不是作为发布者。因此我们得出结论,从Publisher
类继承应该是私有的。对于UserInterface
类,除了UserInterface
类本身不是发布者之外,它与Publisher
类的关系是相似的。UserInterface
类是系统中用户界面的抽象接口,继承自Publisher
类只是为了强调用户界面必须自己实现发布者接口的概念。CLI 和 GUI 类都需要从Publisher
访问公共函数(例如,引发事件)。因此,在这种情况下,受保护的继承模式是合适的。
此外,回想一下第三章,为了让Stack
类实现发布者接口,一旦我们使用私有继承,我们需要将Publisher
类的attach()
和detach()
函数提升到Stack
的公共接口中。使用受保护的继承也是如此。然而,问题是提升应该发生在UserInterface
类中还是它的派生类中?要回答这个问题,我们需要知道 pdCalc 将如何使用特定的用户界面。显然,CLI 或 GUI 都是-a UserInterface
。因此,具体的用户界面将公开地从UserInterface
继承,并被期望遵守 LSP。因此,将事件附加到特定用户界面或从特定用户界面分离事件必须能够在不知道底层 UI 类型的情况下完成。因此,attach()
和detach()
函数必须作为UserInterface
公共接口的一部分可见。有趣的是,在 observer 模式的一个相当独特的实现中,发布者接口的一部分是在UserInterface
级别实现的,而发布者接口的另一部分是在派生类级别实现的。
结合前面的所有要点,我们最终可以定义UserInterface
类:
export module pdCalc.userInterface;
export class UserInterface : protected Publisher
{
public:
UserInterface();
virtual ~UserInterface();
virtual void postMessage(string_view m) = 0;
virtual void stackChanged() = 0;
using Publisher::attach;
using Publisher::detach;
static string CommandEntered();
};
CommandEntered()
函数返回一个字符串,它是命令输入事件的名称。它是附加或分离该事件所必需的,可以被赋予任何对UserInterface
类中的事件唯一的名称。
为了完整起见,我们在图 5-2 中展示了最终的用户界面层次。类图说明了 CLI、GUI、抽象的UserInterface
类和发布者接口之间的关系。记住UserInterface
类和Publisher
类之间的继承是受保护的,所以UserInterface
(或后续的派生类)不能用作Publisher
。然而,如前所述,具体 CLI 和 GUI 类与抽象UserInterface
类之间的继承意图是公共的,允许任一具体类型的实例化被替换为UserInterface
。
图 5-2
用户界面层次结构
用户界面事件
定义UserInterface
类并没有完成 UI 的接口。因为UserInterface
类是一个事件发布者,我们还必须定义对应于commandEntered()
事件的事件数据类。此外,定义UserInterface
类最终完成了发布者/观察者对,因此我们最终准备好设计和实现事件代理类,以便在用户界面、命令调度器和栈之间代理事件。
在第四章中,我们看到所有的命令都是通过事件传递给命令调度器的。具体来说,UI 引发一个包含编码为字符串参数的特定命令的事件,CommandInterpreter
接收该事件,字符串参数被传递给CommandFactory
,在那里检索具体的命令进行处理。就命令调度器而言,处理commandEntered()
事件是一样的,不管编码的命令字符串是来自 CLI 还是 GUI。同样,当Stack
类引发一个stackChanged()
事件时,Stack
对处理该事件的特定UserInterface
无关紧要。因此,我们被鼓励在用户界面层次结构的UserInterface
类级别上统一处理commandEntered()
事件的发布和stackChanged()
事件的处理。
我们从检查引发commandEntered()
事件的公共基础设施开始。在UserInterface
类的构造器中为所有用户界面注册了commandEntered()
事件。因此,任何派生的用户界面类都可以简单地通过调用由Publisher
接口定义的raise()
函数来引发这个事件,由于受保护的继承,它是任何具体 UI 实现的一部分。raise()
函数的签名需要事件的名称和事件的数据。因为事件的名称是在UserInterface
的构造器中预定义的,所以引发命令输入事件所需的唯一附加功能是处理事件数据的统一对象。现在让我们来看看它的设计。
命令数据
在第三章中,我们设计了我们的事件系统,使用推送语义来传递事件数据。回想一下,推送语义仅仅意味着发布者创建一个包含处理事件所需信息的对象,并在事件发生时将该对象推送给观察者。我们还研究了两种处理事件数据的技术。在多态技术中,事件数据对象必须公开地从抽象的EventData
类继承。当事件被引发时,观察器通过抽象接口接收事件数据,并通过将事件数据向下转换到适当的派生类来检索数据。在类型擦除技术中,如果具体的观察者知道如何将数据any_cast
为适当的类型,则事件数据不需要从公共基类派生。pdCalc 的实现使用类型擦除方法来实现事件。由于这两种技术都在第三章中进行了描述,下面只讨论 pdCalc 中实际使用的类型擦除技术。
对于命令输入的事件,事件数据通常是一个字符串,包含要输入到栈中的数字或要发出的命令的名称。虽然我们可以创建一个独特的CommandEnteredData
类,在其构造器中接受这个字符串,但类型擦除方法实际上允许一个简单得多的解决方案:事件数据可以只是字符串本身。当事件被观察者捕获时,事件数据d
由any_cast<string>(d)
而不是any_cast<CommandEnteredData>(d)
恢复。
对于commandEntered()
事件数据的任何一种设计都不能被认为优于另一种——它们只是做出相反的权衡。使用一个CommandEnteredData
类通过抽象提供了额外的类型特异性,代价是额外的代码和额外的函数调用来检索抽象的字符串。使用普通的string
作为事件的数据简单、轻量、高效,但是缺乏类抽象所带来的清晰性。对于复杂的代码库,引入一个新的类来抽象事件数据可能是更好的选择。然而,由于我们已经在第三章中描述了类抽象事件数据策略,为了便于说明,commandEntered()
事件的数据是使用普通的string
实现的。
虽然 CLI 和 GUI 确定如何以及何时引发commandEntered()
事件的机制有所不同,但两者都是通过最终调用Publisher
的raise()
函数来引发事件,其中的string
对发出的特定命令进行编码。也就是说,对于一些命令字符串cmd
,下面的代码在 CLI、GUI 或任何其他可能从UserInterface
继承的用户界面中引发一个commandEntered()
事件:
raise(UserInterface::CommandEntered(), cmd);
现在我们可以引发 UI 事件了,让我们看看它们是如何被处理的。
用户界面观察者
这一小节的目标是构建使类能够监听事件的机制。因为抽象用户界面既是事件的源,也是事件的接收器,所以 UI 是演示发布者和观察者如何相互交互的理想选择。
在第三章中,我们看到观察者是注册并监听发布者发起的事件的类。到目前为止,我们已经遇到了都需要观察事件的CommandInterpreter
和UserInterface
类。虽然可以直接让CommandInterpreter
或UserInterface
成为观察者,但我更喜欢在发布者和需要观察事件的类之间构建一个专用的观察者中介。我经常含糊地把这个中介称为代理。我们现在准备给这个术语一个更具体的含义。
代理模式[11]是一种设计模式,它使用一个类,代理,作为其他东西的接口。其他的东西,姑且称之为目标,并没有严格的定义。它可以是一个网络连接、一个文件、一个内存中的对象,或者就像我们的例子一样,只是另一个类。通常,当底层目标无法复制、不方便复制或复制成本很高时,会使用代理模式。代理模式使用一个类缓冲区来允许系统将目标视为一个独立于其底层组成的对象。在我们的上下文中,我们使用代理模式只是为了缓冲发布者和观察者之间的通信。
我们为什么要在这里为代理模式费心呢?这种策略有几个明显的优点。首先,它通过用描述性命名的事件处理函数替换一般命名的notify()
函数,增加了目标类的公共接口的清晰度。其次,从Observer
类中移除了一个不必要的继承。消除这种依赖性减少了耦合,增加了内聚力,并有助于在目标不是观察者的环境中重用目标。第三,使用代理类消除了需要监听多个事件的目标类所产生的模糊性。如果不使用代理类,观察者将需要在其单一的notify()
函数中消除事件的歧义。为每个事件使用单独的代理使每个事件能够调用目标对象中唯一的处理函数。使用代理实现观察者模式的主要缺点是处理每个事件的额外间接成本很小。然而,在适合使用观察者模式的情况下,额外的间接成本可以忽略不计。
使用代理模式实现观察者模式导致了下面两个处理commandEntered()
和stackChanged()
事件的类:分别是CommandIssuedObserver
和StackUpdatedObserver
。CommandIssuedObserver
在 UI 引发的commandEntered()
事件和 command dispatcher 中的观察之间进行协调。StackUpdatedObserver
在由栈引发的stackChanged()
事件和 UI 中的观察之间起中介作用。这两个类的实现相对简单并且非常相似。举例来说,让我们检查一下CommandIssuedObserver
的实现。
CommandIssuedObserver
的声明如下所示:
class CommandIssuedObserver : public Observer
{
public:
explicit CommandIssuedObserver(CommandInterpreter& ci);
private:
void notifyImpl(const any&) override;
CommandInterpreter& ci_;
};
因为它在作为发布者的 UI 和作为观察者的目标的CommandInterpreter
之间传递事件,所以CommandIssuedObserver
的构造器引用一个CommandInterpreter
实例,当 UI 引发一个commandEntered()
事件时,它保留这个实例以回调命令调度器。回想一下,当观察者连接到事件时,CommandIssuedObserver
将由 UI 存储在Publisher
的事件符号表中。notifyImpl()
的实现只是将函数的参数任意转换为string
,然后调用CommandInterpreter
的commandEntered()
函数。
当然,在事件被触发之前,CommandIssuedObserver
必须向 UI 注册。为了完整起见,以下代码说明了如何完成此任务:
ui.attach( UserInterface::CommandEntered(),
make_unique<CommandIssuedObserver>(ci) );
其中ui
是一个UserInterface
引用,ci
是一个CommandInterpreter
实例。注意,由于attach()
函数被有意提升到抽象的UserInterface
作用域中,通过引用附加允许我们对 CLI 和 GUI 重用同一个调用。也就是说,注册事件是通过抽象 UI 接口完成的,这大大简化了 pdCalc 的main()
例程中的用户界面设置。StackUpdatedObserver
的声明和注册是类似的。
观察者代理类的完整实现可以在AppObservers.m.cpp
中找到。虽然观察者代理的使用与事件观察类交织在一起,但是代理不是目标类接口的一部分。
因此,它们包含在自己的文件中。在main.cpp
中执行代理与事件的连接。这种代码结构保留了发布者和观察者之间的松散绑定。具体来说,发布者知道他们可以引发哪些事件,但不知道谁会观察它们,而观察者知道他们会观看哪些事件,但不知道谁会引发它们。发布者及其观察者外部的代码将两者绑定在一起。
5.2 具体的 CLI 类
本章的其余部分将专门详细介绍 CLI 具体类,它是用户界面模块的一个成员。让我们从重新检查 CLI 的要求开始。
要求
对 pdCalc 的要求表明计算器必须有一个命令行界面,但是准确地说,什么是 CLI 呢?我对命令行界面的定义是任何通过文本交互响应用户命令的程序用户界面。即使您对命令行界面的定义有些不同,我相信我们肯定会同意,仅仅简单地指出一个程序应该有一个 CLI 是远远不够的。
在产品开发的情况下,当你遇到一个太模糊的需求而不能设计一个组件时,你应该立即向你的客户寻求澄清。注意,我说的是当时*,而不是如果时。无论您在尝试细化需求之前付出了多少努力,您总是会有不完整的、不一致的或者变化的需求。这通常有几个原因。有时,这是由于有意识地努力不要在前期花费时间提炼需求。有时,这是由于缺乏经验的团队成员不理解如何正确地收集需求。然而,通常情况下,它只是因为最终用户不知道他们真正想要或需要什么,直到产品开始成型。我发现这是真的,即使对于我自己的客户的小开发项目也是如此!虽然作为实现者,您总是保留在没有客户参与的情况下提炼需求的权宜之计,但我的经验表明,这条道路总是导致重复重写代码:一次是为了您认为用户想要的,一次是为了用户认为他们想要的,一次是为了用户实际想要的。*
显然,对于我们的案例研究,我们只有一个假设的最终用户,所以我们将简单地自己进行细化。我们规定如下:
-
CLI 应该接受为计算器定义的任何命令的文本命令(存在于命令工厂中的命令以及撤销、重做、帮助和退出命令)。
-
help 命令应该显示所有可用命令的列表和简短的解释消息。
-
CLI 应该按照处理命令的顺序接受空格分隔的命令。回想一下,这个顺序对应于反向波兰符号。按下 return 键后,将处理一行中的所有命令。
-
处理完命令后,界面应该最多显示栈的前四个元素加上栈的当前大小。
令人惊讶的是,前面列出的最低要求足以构建一个简单的 CLI。虽然这些需求有些随意,但为了描述设计和实现,需要选择一些特定的东西。如果您不喜欢由此产生的 CLI,我强烈建议您指定自己的需求,并相应地修改设计和实现。
CLI 设计
CLI 的设计非常简单。因为我们的总体架构设计将计算器的整个“业务逻辑”放在了后端,所以前端只是一个薄层,它只不过接受和标记来自用户的输入,将输入顺序传递给控制器,并显示结果。让我们从描述接口开始。
界面
从本章前面的分析中,我们知道具体的 CLI 类将继承抽象的UserInterface
类。这个继承是公共的,因为 CLI 是-a UserInterface
并且必须替换为一个。因此,CLI 必须实现UserInterface
的两个抽象纯虚函数:postMessage()
和stackChanged()
。这两个方法只通过一个UserInterface
引用进行多态调用;因此,这两种方法都成为 CLI 私有接口的一部分。除了构造和销毁,CLI 需要公开的唯一功能是启动其执行的命令。这个函数驱动整个 CLI,并且只在用户请求退出程序时返回(正常情况下)。结合上述内容,CLI 的整个界面可由以下内容给出:
export module pdCalc.userInterface;
export class Cli : public UserInterface
{
public:
Cli(istream& in, ostream& out);
~Cli();
void execute(bool suppressStartupMessage = false, bool echo = false);
private:
void postMessage(string_view m) override;
void stackChanged() override;
};
虽然接口基本上是自解释的,但是构造器和execute()
函数的参数都值得解释一下。为了满足前面描述的需求,可以编写不带参数的execute()
函数。接口中包含的两个参数只是可以打开的可选特性。第一个参数指示 CLI 启动时是否显示横幅。第二个参数控制命令回显。如果echo
被设置为true
,那么在显示结果之前每个命令都会重复。这两个特性都可以在 CLI 中硬编码,但是为了增加灵活性,我选择将它们作为参数添加到execute()
方法中。
构造器的参数比execute()
命令的参数稍微不那么明显。根据定义,CLI 从cin
获取输入,并将结果输出到cout
或者cerr
。然而,对这些标准 I/O 流进行硬编码会任意地将该类的使用限制在传统的 CLI 中。通常,我主张将功能限制在您真正需要的范围内,而不是预期更广泛的用途。然而,使用 C++ 流 I/O 是我的经验法则的少数例外之一。
让我们讨论一下为什么使用对基类 C++ I/O 流的引用通常是一个好的设计实践。首先,使用不同 I/O 模式的愿望很普遍。具体来说,重定向到文件或从文件重定向是经常请求对 CLI 进行的修改。事实上,我们会在第八章看到这个请求!第二,实现通用与专用接口实际上并没有增加复杂性。例如,不是直接写入cout
,而是简单地保存对输出流的引用并写入。在基本情况下,这个引用简单地指向cout
。最后,使用任意的流输入和输出大大简化了测试。虽然程序可以使用cin
和cout
实例化Cli
类,但是测试可以使用文件流或字符串流实例化Cli
类。以这种方式,可以使用字符串或文件来模拟交互式流输入和输出。这种策略简化了对Cli
类的测试,因为输入可以很容易地传入,输出可以很容易地以字符串的形式捕获,而不是通过标准的输入和输出。
最后,请注意,Cli
类被声明为UserInterface.m.cpp
文件中userInterface
模块的一部分,而它是在Cli.m.cpp
文件中的分区userInterface:Cli
中定义的。由于在UserInterface.m.cpp
和Cli.m.cpp
之间会产生一个循环参考,所以需要这种有点奇怪的结构。另一种选择是简单地在userInterface
模块中定义Cli
类,而不是一个分区,很可能将Cli.m.cpp
重命名为UserInterface.cpp
。这个实现细节与 pdCalc 的设计没有任何关系——它只是对文件组织的好奇。
实施
Cli
类的实现值得研究,以观察 pdCalc 设计的模块化所带来的简单性。Cli
类的整个实现有效地包含在execute()
和postMessage()
成员函数中。execute()
函数驱动命令行界面。它向最终用户显示启动消息,等待输入命令,标记这些命令,并引发事件以通知命令调度器输入了新命令。stackChanged()
函数是一个观察者代理回调目标,它在stackChanged()
事件引发后将栈的顶部写入命令行。本质上,CLI 简化为两个 I/O 例程,其中execute()
处理输入,stackChanged()
处理输出。让我们从execute()
函数开始,看看这两个函数的实现:
void Cli::execute(bool suppressStartupMessage, bool echo)
{
if(!suppressStartupMessage) startupMessage();
for(string line; std::getline(in_, line, '\n'); )
{
istringstream iss{line};
// Tokenizer must be one of LazyTokenizer or GreedyTokenizer.
// See discussion below.
Tokenizer tokenizer{iss};
for(auto i : tokenizer)
{
if(echo) out_ << i << endl;
if(i == "exit" || i == "quit")
return;
else
raise(UserInterface::CommandEntered(), i);
}
}
return;
}
CLI 的主要算法相当简单。首先,CLI 等待用户输入一行。其次,这个输入行被Tokenizer
类标记化。然后,CLI 对输入行中的每个令牌进行循环,并以令牌字符串作为事件数据引发事件。CLI 在遇到quit
或exit
令牌时终止。
之前没有解释的execute()
函数的唯一部分是Tokenizer
类。简单地说,Tokenizer
类负责获取一个文本字符串,并将这个字符串拆分成单独的空格分隔的标记。CLI 和Tokenizer
都不能确定令牌的有效性。令牌只是作为事件引发,供命令调度器模块处理。注意,作为编写自己代码的替代方法,许多库(例如 boost)都提供了简单的记号赋予器。
记号化算法相对简单;我们马上会看到两个独立的实现。然而,首先,为什么要为Tokenizer
选择一个类设计,而不是,比方说,选择一个使用返回string
s 的vector
的函数的设计?实际上,两种设计在功能上都是可行的,并且两种设计都同样易于测试和维护。然而,我更喜欢类设计,因为它为Tokenizer
提供了一个独特的类型。让我们来看看为标记化创建不同类型的优势。
假设我们想在函数foo()
中对输入进行令牌化,但在单独的函数bar()
中处理令牌。考虑以下两对可能的函数来实现这一目标:
// use a Tokenizer class
Tokenizer foo(string_view);
void bar(const Tokenizer&);
// use a vector of strings
vector<string> foo(string_view);
void bar(const vector<string>&);
首先,使用一个Tokenizer
类,foo()
和bar()
的签名立即通知程序员函数的意图。我们知道这些函数涉及到标记化。使用string
s 的vector
会产生歧义,不需要更多的文档(我故意没有为参数提供名称)。然而,更重要的是,键入标记器使编译器能够确保只能使用Tokenizer
类作为参数调用bar()
,从而防止程序员意外地使用不相关的string
集合调用bar()
,类设计的另一个好处是Tokenizer
类封装了表示标记集合的数据结构。这种封装保护了与bar()
的接口,使其不必决定将底层数据结构从string
的vector
更改为string
的list
(或者,一个生成器,我们很快就会看到)。最后,如果需要的话,Tokenizer
类可以封装关于标记化的附加状态信息(例如,原始的、预标记的输入)。一个string
的集合显然仅限于携带令牌本身。
我们现在转向Tokenizer
类的实现。我选择呈现两个独立的实现:贪婪标记化和懒惰标记化。第一种方法,贪婪标记化,是我在本书第一版中采用的方法。第二种方法,惰性标记化,只是随着 C++20 中协程的引入才变得微不足道。
为了演示这两种算法的可交换性,我在一个相同的概念接口后面实现了这两种算法。但是,因为这两个类不打算以多种形式使用,所以接口不是通过继承实现的。让我们检查一下这个假设的接口:
class TokenizerInterface
{
public:
explicit TokenizerInterface(istream&);
~TokenizerInterface();
size_t nTokens() const;
// not a "real" interface class - just using auto conceptually
auto begin();
auto end();
};
记号赋予器可以从输入流中构造,它可以声明已经解析了多少个记号,并且可以返回一个迭代器到记号流的开始和结尾。当然,还可以添加更多的功能,但是这些成员函数构成了在 pdCalc 中解析标记所需的最小集合。现在让我们来看看各个实现。
是什么让一个符号化算法变得贪婪,又是什么让一个人变得懒惰?两种实现都在构造器中初始化标记化。但是,贪婪算法会立即解析流中的所有令牌,并将它们存储在一个容器中以备将来使用。TokenizerInterface
中的迭代器接口只是将请求转发给底层容器,例如GreedyTokenizer
中的vector<string>
。当用户迭代令牌时,不会发生令牌化;符号化在构造过程中已经贪婪地发生了。下面是贪婪标记化算法的一个简单实现(它也将所有条目弹出为小写):
void GreedyTokenizer::tokenize(istream& is)
{
for(istream_iterator<string> i{is}; i != istream_iterator<string>{};
++i)
{
string t;
ranges::transform(*i, back_inserter<string>(t), ::tolower);
tokens_.push_back( std::move(t) );
}
return;
}
相比之下,我们来看看懒惰标记化算法。惰性令牌化只在请求下一个令牌时解析每个令牌。C++20 协程使这种懒惰算法变得简单可行,这将在侧栏中讨论。然而,首先让我们检查一下LazyTokenizer
的实现:
cppcoro::generator<string> LazyTokenizer::tokenize(std::istream& is)
{
for(istream_iterator<string> i{is}; i != istream_iterator<string>{};
++i)
{
string t;
ranges::transform(*i, back_inserter<string>(t), ::tolower);
++nTokens_;
co_yield t;
}
co_return;
}
Listing 5-1Lazy tokenizer
贪婪和懒惰实现之间存在两个差异。首先,简单地说,惰性例程需要计算和存储被解析的标记(nTokens_
)的数量。对于贪婪算法来说,这一步是不必要的,因为它将所有的令牌保存在一个vector
中,而这个 ?? 知道自己的大小。第二个区别是,惰性算法使用了co_yield
操作符,并返回一个生成器(cppcoro 库[7]的一部分),这将在侧栏中详细讨论。实际上,co_yield
操作符向编译器发出信号,表明这个函数是一个协程,它可以被抢占,然后在它产生的地方重新启动,在这种情况下,恢复 for 循环来解析下一个令牌。co_yield
允许返回值,在本例中,是我们的延迟求值的令牌。
应该注意的是,虽然这两个记号赋予器有相同的接口,但是它们的行为略有不同。首先,贪婪的令牌化器解析流一次,但是令牌可以根据需要迭代多次。nTokens()
函数总是返回流中令牌的总数,因为在调用nTokens()
之前流已经被完全解析。相比之下,惰性标记化器只能迭代一次,因为迭代会导致标记化。因此,nTokens()
函数返回到那时为止解析的令牌数,这个数可能小于输入流中的令牌总数。当然,如果需要在LazyTokenizer
上进行多次迭代,令牌可以总是存储在一个容器中,因为流是被延迟解析的。一旦解析完成,这两个记号赋予器将表现相同。
贪婪标记器和懒惰标记器的实现都在Tokenizer.m.cpp
源文件中提供。默认情况下,pdCalc 被配置为只使用惰性标记器。然而,这两个记号赋予器是完全可以互换的。如果您希望尝试贪婪的记号赋予器,只需更改在CLI
的execute()
函数中实例化的记号赋予器。显然,两个标记化器之间的切换可以通过静态多态性在编译时进行配置。
Modern C++ Design Note: Coroutines and Generators
协程是一个古老的概念,最终在 C++20 中成为标准。在我看来,就可用性而言,协程是一个混合体。如果您不需要编写用于管理协程生命周期的支持代码,那么编写协程本身相当简单。具体来说,相对于列出 5-1 ,实现tokenize()
容易,实现generator<>
难。
不幸的是,C++20 标准(可能是 C++23)没有采用一个用于协同程序使用的公共库。).然而,幸运的是,高质量的 cppcoro 协程库已经存在[7],您可以放心地知道,它的作者已经为您实现了使用协程的困难部分。在我最初实现清单 5-1 时,我编写了自己的生成器类。我的实现不是通用的,也很粗糙,但是它确实像预期的那样工作。然而,理解实现对理解 pdCalc 的设计没有指导意义。最终,我认为描述协程的详细实现超出了本书的范围。而是决定直接用 cppcoro 的生成器类。许可的 MITgenerator.hpp
包含在 pdCalc 中,位于3rdparty/cppcoro
中。那些有兴趣了解协程细节的读者可以参考 cppcoro 的创建者刘易斯·贝克的精彩博文。相反,我们的讨论将集中于更高层次的设计目标,即概念上的协程是什么,以及我们如何能够使用它们来改进 pdCalc 的设计。
协程是子程序的推广,它允许通过程序从内部放弃执行来暂停控制。协程保持它们的状态,并且以后可以从它们放弃控制的点恢复。实际上,协程提供了一种语言机制来支持协作式多任务处理(相对于线程的抢占式多任务处理)。
协程支持的类型之一是生成器。生成器是在迭代时生成序列的对象。当数列是无穷大时,它们特别有用。生成器的规范实现似乎是斐波那契数的生成。斐波那契数列, F n ,由以下递归定义:
F 0 = 0 ,F 1 = 1 ,Fn=Fn1+Fn2,n > 1
在不使用协程的情况下,可以用下面的函数很容易地生成第 n 个序列:
auto fibLoop(unsigned int n)
{
vector<long long> v(n+1);
v[0] = 0;
if(n > 0) v[1] = 1;
for(auto i = 2u; i < v.size(); ++i)
v[i] = v[i-1] + v[i-2];
return v;
}
不幸的是,我们必须对所有数字进行贪婪的评估,直到第 n 个数字。fibLoop()
不允许通过重复调用函数来对斐波那契数列进行缓慢的顺序计算。
让我们试着创建一个函数,它可以在每次被调用时生成斐波纳契数列中的下一个数字。下面的代码是我们的第一次尝试:
long long fibStatic()
{
static long long cur = 0;
static long long prev = 1;
auto t = cur;
cur += prev;
prev = t;
return prev;
}
只要我们只想在单线程环境中每次程序执行时生成一次斐波那契数列,代码就能工作。为了解决代码只能被调用一次的问题,我们可以添加一个重置标志作为参数。然而,现在我们只是添加了黑客,我们仍然没有解决无法在多线程环境中运行的问题。让我们再试一次。
我们的下一个尝试是一个相当复杂的解决方案,包括一个 Fibonacci 类和一个迭代器:
class Fibonacci
{
class fibonacci_iterator
{
public:
using iterator_category = std::input_iterator_tag;
using difference_type = std::ptrdiff_t;
using value_type = long long;
using reference = long long&;
using pointer = long long*;
fibonacci_iterator(){}
fibonacci_iterator(Fibonacci* f): f_{f}{}
long long operator*() const { return f_->cur_; }
fibonacci_iterator& operator++(){f_->next(); return *this;}
void operator++(int){ operator++(); }
auto operator<=>(const fibonacci_iterator&) const = default;
private:
Fibonacci* f_ = nullptr;
};
public:
using iterator = fibonacci_iterator;
iterator begin() { return fibonacci_iterator{this}; }
iterator end() { return fibonacci_iterator{}; }
private:
void next()
{
long long t = cur_;
cur_ += prev_;
prev_ = t;
}
long long cur_ = 0l;
long long prev_ = 1l;
};
哎哟!前面的代码是草率的、不完整的,执行看似简单的任务真的很糟糕。然而,利用范围库(见下一个边栏),它确实使我们能够以一种非常简洁的方式生成和使用斐波那契数:
Fibonacci fib;
// grab the first 10 Fibonacci numbers and use them one at a time
ranges::for_each(fib | views::take(10), [](auto fn){useFib(fn);});
如果您不熟悉前面的语法,尤其是将范围传递给视图的函数式风格,不要担心。ranges 库是 C++20 的另一个新特性,我将在下一个边栏中简要介绍它。
简单的事情应该很容易,这就是协程和生成器的亮点。如果我们能提供fibLoop()
的简单性、fibStatic()
的一个接一个的语义,以及Fibonacci
类的可表达性和安全性,那会怎么样。如果有一种语言机制,允许我们编写一个可以被中断和恢复的函数,并在每次函数暂停时产生一个值,那么这将是可能的。当然,这个特性正是协程所提供的。我们现在可以简单地写
cppcoro::generator<long long> fibonacci()
{
long long prev = 1;
long long cur = 0;
while(true)
{
co_yield cur;
long long t = cur;
cur += prev;
prev = t;
}
}
前面的协程是一个无限但可中断的循环。co_yield
表达式使协程暂停并产生当前的斐波那契数。cppcoro 的generator
类将暂停和恢复fibonacci()
的复杂机制隐藏在简单易用的迭代器接口后面。当生成器迭代器被解引用时,返回当前斐波那契数的值。当向前迭代器前进时,fibonacci()
被恢复,继续无限循环,直到再次遇到co_yield
。通过这种方式,我们可以以有限的方式使用和访问无限循环,这为计算有限但不是预定深度的斐波那契数提供了一个非常简洁的实现。如前所述,协程可以简单地使用:
auto fib = fibonacci();
// grab the first 10 Fibonacci numbers and use them one at a time
ranges::for_each(fib | views::take(10), [](auto fn){useFib(fn);});
在这一点上,希望列出 5-1 是有意义的。我们的惰性令牌化器只是一个字符串令牌生成器,它通过循环流、提取每个空格分隔的字符串并暂停执行直到请求下一个令牌来生成。这是一个漂亮的设计,通过新的 C++20 语言特性变得很容易。
在离开这个侧栏之前,我想分享一个我从惨痛的教训中学到的快速技巧:仔细观察通过引用传递给协程的任何值的生命周期。事实上,我甚至认为应该避免通过引用协程来传递值。考虑我第一次为LazyTokenizer
编写字符串构造器的失败尝试:
LazyTokenizer::LazyTokenizer(const std::string& s)
: nTokens_{0}
{
istringstream t{s};
generator_ = tokenize(t);
}
前面看似正确的代码将会编译,但是标记器在使用时会导致分段错误。原因是虽然tokenize()
函数看起来是一个简单返回的常规函数调用,但它不是。tokenize()
是一个可恢复的协程,通过其返回的生成器来访问,在这种情况下,生成器存储在generator_
中。这里,generator_
,一个成员变量,比局部变量t
有更长的生存期。当第一次调用tokenize()
时,一切正常。发电机已初始化并处于就绪状态。然而,由于tokenize()
通过引用捕获它的流参数,当协程通过迭代generator_
前进时,t
已经超出范围并被销毁。在tokenize()
内部,我们被留下来推进一个istream_iterator<string>
,它正在迭代一个被销毁的istringstream
。显然,这段代码会失败。一旦你推理出这个失败,它就完全有意义了。然而,对我来说,我第一次遇到这个错误时并不明显,因为我的经验已经训练我将tokenize()
解释为一个单通函数,它只在控制权返回给调用者之前存在。当然,协程调用语义是不同的,因为协程在销毁之前一直存在。将控制权返回给调用者不会破坏协程内部的本地上下文,其中可能包含对函数参数的引用。相反,该状态被存储以供以后恢复。此后超出范围的任何被引用对象都变得无效。程序员当心。
作为 CLI 实现的最后一部分,我们研究一个简化版本的stackChanged()
函数:
void Cli::stackChanged()
{
unsigned int nElements{4};
auto v = Stack::Instance().getElements(nElements);
string s{"\n"};
auto bi = std::back_inserter(s);
for( auto j = v.size(); auto i : views::reverse(v) )
std::format_to(bi, "{}:\t{:.12g}\n", j--, i);
postMessage(s);
}
Cli.m.cpp
中的实现仅在印刷的花哨程度上有所不同。注意,每当栈改变时,CLI 简单地挑选栈的前四个(如我们的需求中所指定的)条目(getElements()
返回nElements
和栈大小的最小值),在string
中格式化它们,并将该字符串传递给postMessage()
函数。对于 CLI,postMessage()
只是将字符串写入输出流。
在我们继续之前,让我们停下来思考一下 CLI 的实现有多简洁明了。这种简单性是 pdCalc 整体设计的直接结果。尽管许多用户界面将“业务逻辑”与显示代码混杂在一起,但我们精心设计了这两个独立的层。命令的解释和处理,即“业务逻辑”,完全驻留在命令调度器中。因此,CLI 只负责接受命令、标记命令和报告结果。此外,基于我们的事件系统的设计,CLI 没有直接耦合到命令调度器,这与我们的 MVC 架构是一致的。命令调度器确实有到用户界面的直接链接,但是由于我们的抽象,命令调度器绑定到一个抽象的UserInterface
而不是一个具体的用户界面实现。通过这种方式,Cli
完美地替代了UserInterface
(LSP 的应用),并且可以作为计算器的许多独特视图中的任何一个来轻松地换入或换出。虽然对于计算器的设计来说,这种灵活性似乎有些过头了,但是从测试和关注点分离的角度来看,所有组件的模块化都是有益的,即使计算器没有设计另一个用户界面。
Modern C++ Design Note: The Ranges Library
ranges 库是 C++20 的四大特性之一,一个简单的侧边栏并不能代表这个库。然而,我们将绕一个很快的弯路来得到一个粗略的介绍。
范围库引入了三个主要的新构造:范围概念、范围算法和视图。忽略用 C++ 概念实现范围的详细 C++ 机制,从逻辑上讲,范围是一个由开始和结束划分的可迭代集合。虽然你可以选择实现自己的范围,但从 C++20 开始,STL 容器就是范围,所以每个人都可以直接访问范围。
很好,vector
s 是范围,那么现在怎么办?您将从范围中看到的第一个直接好处是它们所伴随的算法的语法得到了改进。假设您需要对string
s、v
中的vector
进行排序(我们假设使用默认的排序标准)。在 ranges 之前,使用标准库,您将编写以下代码:
std::sort( begin(v), end(v) );
前面的语法并不可怕,但是假设我们想要对整个vector
进行排序,为什么我们不能只调用sort(v)
?现在你可以了。因为vector
是一个范围,我们可以称之为基于范围的等价算法:
std::ranges::sort(v);
那就干净多了。如果不是全部的话,大多数标准算法现在都有基于范围的等价算法。
如果基于范围的算法语法是我们从 ranges 库中得到的全部,我会感到很无趣。然而,我们得到的要多得多,因为范围是有视图的。不严格地说,视图是一个延迟求值的范围适配器。让我们考虑一个例子。假设您有一个由 20 个整数组成的vector
、v
,并且想要存储您在vector
、t
中遇到的最后五个偶数的平方(是的,这是一个不可否认的人为例子)。下面一行可执行代码将完成这一壮举:
// note, code assumes
// namespace ranges = std::ranges;
// namespace views = std::ranges::views;
ranges::copy( v | views::filter([](int i){return i % 2 == 0;})
| views::reverse
| views::take(5)
| views::transform([](int i){return i * i;})
, back_inserter(t) );
在前面显示的一行代码中发生了三件重要的事情。首先,也是最明显的,所有的视图都被链接在一起。其次,视图的链接使我们能够在一个循环中通过v
执行所有这些操作。第三,因为视图是延迟评估的,所以只采取必要的操作。例如,我们不反转所有的v
,只反转偶数。此外,我们没有平方所有的偶数,只有最后五个(或者更少,如果在v
中偶数少于五个)。
虽然前面的代码既紧凑又高效,但我愿意承认它可能不是最易读的。当然,视图reverse
和take
清楚地陈述了它们在做什么,但是理解filter
和transform
做什么也需要理解它们嵌入的 lambda 表达式。幸运的是,我们可以将视图存储在变量中,并按如下方式应用它们:
auto takeEven = views::filter([](int i){ return i % 2 == 0; });
auto square = views::transform([](int i){return i * i;});
ranges::copy(v | takeEven | views::reverse | views::take(5) | square, back_inserter(t));
这就更清楚了。从现有视图中轻松创建新的命名视图的能力既增加了可读性,又支持可重用性。
我将以我开始时的方式结束这个侧边栏,声明这个小侧边栏绝不公正。然而,如果这篇边栏激起了你的兴趣,我鼓励你去读读 Eric Niebler 的文章《介绍 Range[26]和 Range-v3 用户手册[25]。Eric Niebler 的 Range-v3 库构成了构建 C++ 标准范围库的基础。
5.3 将它结合在一起:一个工作程序
在我们结束关于 CLI 的章节之前,有必要编写一个简单的主程序,将所有组件连接在一起,演示一个可以工作的计算器。pdCalc 在main.cpp
中的实际实现要复杂得多,因为它处理多个用户界面和插件。最终,我们将逐步理解main.cpp
中的完整实现,但是现在,下面的代码将使我们能够使用命令行界面执行一个有效的计算器(当然,包括适当的头文件和模块导入):
int main()
{
Cli cli{std::cin, std::cout};
CommandInterpreter ci{cli};
RegisterCoreCommands(cli);
cli.attach(UserInterface::CommandEntered(), make_unique<CommandIssuedObserver>(ci) );
Stack::Instance().attach(Stack::StackChanged(), make_unique<StackUpdatedObserver>(cli) );
cli.execute();
return 0;
}
由于设计的模块化,整个计算器只需六个可执行语句就可以设置、组装和执行!main()
函数中的逻辑很容易理解。从维护的角度来看,任何项目的新程序员都可以很容易地跟踪计算器的逻辑,并看到每个模块的功能都被清晰地划分为不同的抽象。正如我们将在以后的章节中看到的,随着更多模块的加入,抽象变得更加强大。
为了让您快速入门,在构建可执行文件pdCalc-simple-cli
的存储库源代码中包含了一个项目,使用前面的main()
函数作为应用程序的驱动程序。该可执行文件是一个独立的 CLI,包括本书到目前为止讨论的所有功能。
在下一章,我们将考虑计算器的图形用户界面的设计。一旦 GUI 完成,许多用户会很快认为 CLI 只是一个练习或以前时代的遗物。在此之前,我想鼓励读者不要这么快就对这个不起眼的 CLI 做出判断。CLI 是非常高效的界面,通常更容易为需要大型部署或自动化的任务编写脚本。至于 pdCalc,就个人而言,我更喜欢 CLI 而不是 GUI,因为它易于使用。当然,也许这只是表明我也是上一个时代的遗物。
六、图形用户界面
在本章中,我们将探索 pdCalc 的图形用户界面(GUI)的设计。任何时候设计一个 GUI,都需要选择一个窗口小部件平台。如前所述,我选择使用 Qt 来创建 GUI。也就是说,这不是一个如何使用 Qt 设计界面的章节。相反,我假设读者有 Qt 的工作知识,并且这一章本身集中在 GUI 的设计方面。事实上,我将尽可能地让读者阅读源代码来了解小部件实现的细节。任何关于 Qt 实现的讨论要么只是附带的,要么值得特别强调。如果你对图形用户界面设计不感兴趣,这一章可以完全跳过,几乎不会影响连贯性。
6.1 要求
在第五章中,我们开始了对命令行界面(CLI)的分析,导出了一个可供 CLI 和 GUI 使用的接口抽象。显然,我们将在这里重用这个接口,因此我们已经知道了我们的整个用户界面必须符合的抽象接口。因此,我们从定义 GUI 专门化的要求开始这一章。
和 CLI 一样,我们很快发现第一章的要求对于指定一个图形用户界面来说是远远不够的。给定的要求只是功能性的。也就是说,我们知道计算器应该支持哪些按钮和操作,但我们对预期的外观一无所知。
在一个商业项目中,人们会(希望)让客户、图形艺术家和用户体验专家来协助设计 GUI。对于我们的案例研究,充分说明我们自己的要求就足够了:
-
GUI 应该有一个显示输入和输出的窗口。输出是当前栈的前六个条目。
-
GUI 应该有可点击的按钮来输入数字和所有支持的命令。
-
GUI 应该有一个显示错误消息的状态显示区域。
前面的要求仍然没有解释计算器实际上应该是什么样子。为此,我们需要一张照片。图 6-1 显示了在我的 Windows 桌面上出现的工作计算器(使用 Qt 5.15.2 的 Windows 10)。将完成的图形用户界面作为设计图形用户界面的原型展示无疑是“欺骗”希望这条捷径不会太偏离案例研究的真实性。显然,在开发的这个阶段不会有成品。在生产环境中,人们可能会用手或用 Microsoft PowerPoint、Adobe Illustrator 或 Inkscape 等程序绘制模型。或者,也许 GUI 是从一个物理对象建模的,设计者要么有照片,要么直接访问那个对象。例如,一个人可能正在设计一个 GUI 来代替一个物理控制系统,并且需求指定界面必须显示相同的刻度盘和仪表(以减少运算符再培训的成本)。
图 6-1
没有插件的 Windows 上的 GUI
pdCalc 的 GUI 灵感来自我的 HP48S 计算器。对于那些熟悉本系列中任何一款惠普计算器的人来说,这个界面有些熟悉。对于那些不熟悉这一系列计算器的人(可能是大多数读者),下面的描述解释了 GUI 的基本行为。
GUI 的顶部三分之一是专用的输入/输出(I/O)窗口。I/O 窗口在左侧显示前六个栈级别的标签,栈的顶部在窗口的底部。栈上的值出现在窗口右侧与数字在栈上的位置相对应的行上。当用户输入一个数字时,栈减少到只显示顶部的五个栈元素,而输入的数字在底部行左对齐显示。一个数字被终止,并通过按 enter 按钮输入到栈中。
假设有足够的输入,一按下按钮就开始操作。如果输入不足,I/O 窗口上方会显示一条错误消息。对于命令,输入区域中的有效数字被视为栈中的顶部数字。也就是说,在输入数字的同时应用操作相当于按下 enter 然后应用操作。
为了节省空间,一些按钮的操作被移到了按钮本身的上方和左侧。首先按下 shift 按钮,然后按下移位文本下方的按钮,可以激活这些移位操作。按下 shift 按钮会将计算器置于移位模式,直到按下具有移位操作的按钮或再次按下 shift 按钮。为了清楚起见,移位操作通常是按钮操作的逆操作。
为了方便输入,许多按钮被绑定到键盘快捷键。也就是说,除了按压 GUI 按钮之外,还可以替代地按压键盘按键。比如按相应的数字键可以点击数字按钮,按 Enter 键可以点击 Enter 键,按 S 键可以点击 Shift 键,按 Backspace 键可以点击 Bksp 键,按 E 键可以点击取幂运算(eex),按相应的键盘键可以点击四则基本算术运算(+、-、*、/)。
最后,一些操作是半隐藏的。当不输入数字时,退格键从栈中删除顶部条目,而回车键复制栈中的顶部条目。这些组合中的一些并不直观,因此可能并不代表很好的 GUI 设计。但是,它们确实模拟了 HP48S 上使用的输入。如果您以前从未使用过 HP48 系列计算器,我强烈建议您在继续之前,从 GitHub 资源库构建并熟悉 GUI。
如果你想知道proc
键是做什么的,它会执行存储过程。这是我们将在第八章中遇到的“新”需求之一。
人们对 GUI 的第一个批评可能是它不太漂亮。我同意。本章中 GUI 的目的不是演示高级 Qt 特性。相反,目的是说明如何设计模块化、健壮、可靠和可扩展的代码库。添加代码使 GUI 更有吸引力而不是功能性会分散对这个信息的注意力。当然,这个设计允许一个更漂亮的 GUI,所以你可以在提供的基础设施上随意制作你自己的漂亮 GUI。
我们现在有足够的细节来设计和实现计算器的 GUI。然而,在我们开始之前,有必要对构建 GUI 的替代方法进行一个简短的讨论。
6.2 构建 GUI
本质上,构建 GUI 有两种不同的途径:在集成开发环境(IDE)中构建 GUI,或者在代码中构建 GUI。在这里,我不严格地使用术语代码来表示通过文本构建 GUI,无论是使用传统的编程语言(如 C++)还是声明性的标记语法(如 XML)。当然,介于两个极端之间的是混合方法,它利用来自 ide 和代码的元素。
6.2.1 在 ide 中构建 GUI
如果您需要的只是一个简单的 GUI,那么,在 IDE 中设计和构建您的 GUI 无疑是更容易的途径。大多数 ide 都有一个图形界面,用于在画布上展示可视元素,例如,画布可能代表一个对话框或小部件。一旦建立了新的画布,用户就可以通过将现有的小部件拖放到画布上来可视化地构建 GUI。现有的窗口小部件包括 GUI 工具包的内置图形元素(例如,按钮)以及在 IDE 框架中支持拖放的自定义窗口小部件。一旦布局完成,就可以用图形或一点点代码将动作捆绑在一起。最后,IDE 会创建与图形化 GUI 相对应的代码,IDE 创建的代码会与其余的源代码一起编译。
使用 IDE 构建 GUI 既有优点也有缺点。一些优点如下。首先,因为这个过程是可视化的,所以在执行布局时,您可以很容易地看到 GUI 的外观。这与为 GUI 编写代码形成了鲜明的对比,在编写代码时,您只能看到编译和执行代码后的 GUI 外观。这种区别非常类似于使用微软 Word 这样的 WYSIWYG 文本编辑器和 LaTeX 这样的标记语言来写论文的区别。其次,IDE 通过在后台自动生成代码来工作,因此图形化方法可以显著减少编写 GUI 所需的编码量。第三,ide 通常在属性表中列出 GUI 元素的属性,这使得在不经常查阅 API 文档的情况下对 GUI 进行风格化变得很简单。这对于很少使用的功能尤其有用。
使用 IDE 构建 GUI 的一些缺点如下。首先,您受限于 IDE 选择公开的 API 子集。有时候,完整的 API 是公开的,有时候,不是。如果您需要 IDE 作者没有授予您的功能,您将被迫编写自己的代码。也就是说,IDE 可能会限制对 GUI 元素的微调控制。其次,对于重复的 GUI 元素,您可能需要多次执行相同的操作(例如,单击以使所有按钮中的文本变为红色),而在代码中,很容易将任何重复的任务封装在类或函数调用中。第三,使用 IDE 设计 GUI 将 GUI 限制在可以在编译时做出的决定。如果你需要动态地改变一个 GUI 的结构,你需要为此写代码。第四,在 IDE 中设计 GUI 会将您的代码与特定的供应商产品联系起来。在公司环境中,这可能不是一个重要的问题,因为整个公司的开发环境可能是统一的。然而,对于一个开放源代码的分布式项目,并不是每个想为您的代码库做贡献的开发人员都希望被限制在您选择的 IDE 中。
6.2.2 用代码构建 GUI
顾名思义,用代码构建 GUI。您可以编写代码与 GUI 工具包进行交互,而不是以图形方式将小部件放在画布上。对于如何编写代码,有几种不同的选择,通常,对于任何给定的 GUI 工具包,都有不止一种选择。首先,您几乎总是可以用工具包的语言编写源代码。例如,在 Qt 中,您可以完全通过以命令式风格编写 C++ 来构建您的 GUI(即,您显式地指导 GUI 的行为)。其次,一些 GUI 工具包允许声明性风格(即,您编写描述 GUI 元素风格的标记代码,但是工具包定义元素的行为)。最后,一些工具包使用基于脚本的接口来构造 GUI(通常是 JavaScript 或 JavaScript 派生语法),可能与声明性标记结合使用。在本章的上下文中,用代码构建 GUI 专指用 C++ 针对 Qt 的桌面小部件集进行编码。
正如您所料,用代码构建 GUI 与用 IDE 构建 GUI 的权衡几乎相反。优点如下。首先,小部件的完整 API 是完全公开的。因此,程序员有尽可能多的微调控制。如果小部件库设计者希望用户能够做一些事情,你可以用代码来做。第二,通过使用抽象,重复的 GUI 元素很容易管理。例如,在设计计算器时,我们可以创建一个按钮类并简单地实例化它,而不必手动定制每个按钮。第三,在运行时动态添加小部件很容易。对于 pdCalc 来说,这个优势对于满足支持动态插件的需求非常重要。第四,如果构建系统独立于 IDE,那么用代码设计 GUI 就完全独立于 IDE。
虽然用代码构建 GUI 有很多优点,但也存在缺点。首先,布局不直观。为了看到 GUI 成形,您必须编译并执行代码。如果它看起来是错误的,你必须调整代码,再试一次,并重复这个过程,直到你得到它的权利。这可能是非常乏味和耗时的。其次,您必须自己编写所有代码。尽管 IDE 会自动生成 GUI 代码的很大一部分,尤其是与布局相关的部分,但是当您编写代码时,您必须手动完成所有工作。最后,当用代码编写 GUI 时,您将无法在属性表上简洁地访问小部件的所有属性。通常,您需要更频繁地查阅文档。也就是说,良好的 IDE 代码完成对这项任务有很大的帮助。有人可能会对我的最后一句话大呼冤枉,声称“使用 IDE 可以减轻不使用 IDE 的缺点是不公平的。”请记住,除非您是在纯文本编辑器中编写源代码(不太可能),否则代码编辑器仍然可能是一个复杂的 IDE。我比较了使用 IDE 的图形 GUI 布局工具构建 GUI 和使用现代代码编辑器(可能本身就是 IDE)手动编写代码。
6.2.3 哪种 GUI 构建方法比较好?
对于标题中过于笼统的问题,答案当然是否定的。哪种技术更适合构建 GUI 完全取决于上下文。当您在自己的编码追求中遇到这个问题时,请参考前面的权衡,并根据您的情况做出最明智的选择。通常,最好的解决方案是一种混合策略,其中 GUI 的某些部分将以图形方式进行布局,而 GUI 的其他部分将完全由代码构建。
在我们的上下文中,一个更具体的问题是,“哪种 GUI 构建方法更适合 pdCalc?”对于这个应用程序,权衡的结果是更倾向于基于代码的方法。首先,计算器的可视化布局相当简单(一个状态窗口、一个显示部件和一个按钮网格),很容易用代码实现。这一事实立即消除了 IDE 方法最显著的优势,即可视化地处理复杂的布局。第二,按钮的创建和布局是重复的,但是很容易封装,这是基于代码的方法的优点之一。最后,因为计算器必须支持运行时插件,所以代码方法更适合动态添加小部件元素(运行时发现的按钮)。
在本章的剩余部分,我们将探索 PD calc GUI 的代码设计。特别是,重点将放在组件及其接口的设计上。因为我们的重点不是小部件的构造,所以许多实现细节将被忽略。然而,不要害怕。如果您对细节感兴趣,所有代码都可以在 GitHub 资源库中找到。
6.3 模块化
从本书开始,我们已经讨论了计算器的分解策略。使用 MVC 架构模式,我们将设计分成一个模型、一个视图和一个控制器。在第四章中,我们看到其中一个主要模块,命令调度器,被分成了几个子组件。CLI 模块足够简单,可以用一个类来实现,而 GUI 模块足够复杂,因此分解非常有用。回想一下第二章中的内容,当我们提到 GUI 模块时,我们只是将模块作为一个逻辑结构,因为在撰写本文时,Qt 还不支持 C++20 模块。
在第五章中,我们决定我们系统的任何用户界面都必须继承UserInterface
抽象类。本质上,UserInterface
类定义了 MVC 模式中视图的抽象接口。虽然 GUI 模块必须从UserInterface
继承,因此向控制器呈现相同的抽象接口,但是我们可以自由地分解 GUI 的内部,只要我们认为合适。我们将再次使用松耦合和强内聚的指导原则来模块化 GUI。
当我分解一个模块时,我首先考虑的是强内聚性。也就是说,我试图将模块分成小的组件,每个组件做一件事(并且做得很好)。让我们用 GUI 来尝试一下。首先,任何 Qt GUI 都必须有一个主窗口,通过继承QMainWindow
来定义。主窗口也是 MVC 视图的入口点,所以我们的主窗口也必须从UserInterface
继承。MainWindow
是我们的第一堂课。接下来目测图 6-1 ,计算器明显分为用于输入的组件(按钮集合)和用于显示的组件。因此,我们增加了两个等级:?? 和 ??。我们已经讨论过使用代码方法构建 GUI 的一个优点是抽象了按钮的重复创建,所以我们也将创建一个CommandButton
类。最后,我添加了一个负责管理计算器外观的组件(例如,字体、边距、间距等)。)我恰当地将其命名为LookAndFeel
类。还存在一个用于存储过程条目的组件,但是我们将把对该组件的讨论推迟到第八章。现在让我们看看每个类的设计,从CommandButton
开始。我们将讨论对这个初始分解的任何必要的改进,如果它们出现的话。
6.3.1 命令按钮抽象
我们从描述按钮是如何被抽象的开始讨论。这是一个合理的起点,因为按钮是数字和命令输入到计算器的基础。
Qt 提供了一个按钮小部件类,它显示一个可点击的按钮,当按钮被点击时会发出一个信号。这个QPushButton
类提供了数字和命令输入所需功能的基础。我们可以采用的一种预期设计是按原样使用QPushButton
s。这种设计需要明确地编写代码,将每个QPushButton
手动连接到它自己定制的插槽。然而,这种方法是重复的、乏味的,并且非常容易出错。此外,一些按钮需要QPushButton
API 没有提供的附加功能(例如,移位输入)。因此,我们为我们的程序寻找一个按钮抽象,它建立在QPushButton
之上,用额外的功能补充这个 Qt 类,但同时也限制QPushButton
的接口以完全满足我们的需求。我们称这个类为CommandButton
。
用模式的话来说,我们提出了既作为适配器又作为门面的东西。我们在第三章中看到了适配器模式。门面模式是近亲。适配器模式负责将一个接口转换成另一个接口(可能经过一些调整),而外观模式负责为子系统中的一组接口提供统一的接口(通常是一种简化)。我们的CommandButton
肩负着这两项任务。我们都在将QPushButton
接口简化为 pdCalc 需要的受限子集,同时调整QPushButton
的功能以满足我们问题的需求。那么,CommandButton
到底是门面还是适配器?区别是无关紧要的;它共享每一个的特征。请记住,理解不同模式的目标并根据您的需求调整它们是非常重要的。为了模式的纯粹性,不要迷失在四人帮[11]的机械实现中。
CommandButton 设计
除了介绍性的评论,我们仍然必须确定我们的CommandButton
到底需要做什么,以及它将如何与 GUI 的其余部分交互。在许多方面,CommandButton
的外表和行为都与QPushButton
相似。例如,CommandButton
必须呈现一个可以点击的可视按钮,在按钮被点击后,它应该发出某种信号,让其他 GUI 组件知道发生了点击动作。然而,与标准的QPushButton
不同,我们的CommandButton
必须支持标准状态和移动状态(例如,一个支持 sin 和 arcsin 的按钮)。这种支持应该是可视化的(两种状态都应该由我们的CommandButton
小部件显示)和功能性的(点击信号必须描述标准点击和移位点击)。因此,我们需要回答两个设计问题。首先,我们如何设计和实现小部件以正确地出现在屏幕上?第二,一般来说,计算器如何处理移位运算?
让我们首先解决CommandButton
外观问题。当然,我们可以从头开始实现我们的按钮,手动绘制屏幕,并使用鼠标事件来捕获按钮点击,但这对CommandButton
来说太过了。相反,我们寻求一个重用 Qt 的QPushButton
类的解决方案。我们基本上有两种复用选择:继承和封装。
首先,让我们考虑通过继承在CommandButton
类的设计中重用QPushButton
类。这种方法是合理的,因为人们可以在逻辑上采用 a CommandButton
是-a QPushButton
的观点。然而,这种方法有一个直接的缺陷。一个 is-a 关系意味着公共继承,这意味着QPushButton
的整个公共接口将成为CommandButton
公共接口的一部分。然而,我们已经确定,为了简化 pdCalc,我们希望CommandButton
有一个受限的接口(facade 模式)。好,让我们尝试私有继承,并将我们的观点修改为一个实现——一个CommandButton
和QPushButton
之间的关系。现在我们遇到了第二个缺陷。没有来自QPushButton
的公共继承,CommandButton
失去了对QWidget
类的间接继承,这是 Qt 中一个类成为用户界面对象的先决条件。因此,任何私有继承QPushButton
的实现也需要从QWidget
进行公共继承。然而,因为QPushButton
也继承自QWidget
,所以CommandButton
对这两个类的多重继承会导致歧义,因此是不允许的。我们必须寻找另一种设计。
现在,考虑将一个QPushButton
封装在一个CommandButton
中(即CommandButton
有一个 QPushButton
)。我们可能应该从这个选项开始,因为一般的实践表明我们应该尽可能地选择封装而不是继承。然而,许多开发人员倾向于从继承开始,我想讨论这种方法的缺点,而不仅仅求助于 C++ 最佳实践标准。除了打破强继承关系之外,选择封装方法还克服了前面讨论的使用继承的两个缺点。首先,由于QPushButton
将被封装在CommandButton
中,我们可以自由地只暴露QPushButton
接口中对我们的应用程序有意义的那些部分(或者根本没有)。其次,通过使用封装,我们将避免同时从QWidget
和QPushButton
类继承的多重继承混乱。注意,原则上我不反对使用多重继承的设计。在这种情况下,多重继承是不明确的。
封装关系可以采用组合或聚合的形式。CommandButton
类哪个合适?考虑两个类,A
和B
,其中A
封装了B
。在复合关系中,B
是A
的组成部分。在代码中,这种关系表示如下:
class A
{
// ...
private:
B b_;
};
相反,聚合意味着A
只是在内部使用一个B
对象。在代码中,聚合表示如下:
class A
{
// ...
private:
B* b_; // or some suitable smart pointer or reference
};
对于我们的应用程序,我认为聚合更有意义。也就是说,我们的CommandButton
使用了一个QPushButton
,而不是由一个QPushButton
组成。这种差别是微妙的,同样合乎逻辑的论点可以用来声明这种关系是复合的。也就是说,两种设计都在 Qt 中机械地工作,所以你的编译器不会在乎你选择如何表达这种关系。
既然我们已经决定在CommandButton
中聚合QPushButton
,我们可以继续进行CommandButton
类的总体设计。我们的CommandButton
必须同时支持主命令和辅助命令。视觉上,我选择在按钮上显示主要命令,在按钮的左上方用蓝色显示辅助命令(我们将讨论如何暂时改变状态)。因此,CommandButton
仅仅实例化了一个QPushButton
和一个QLabel
,并将它们都放在一个QVBoxLayout
中。QPushButton
显示主命令的文本,QLabel
显示移位命令的文本。布局如图 6-2 所示。如前所述,为了完成设计,为了与 GUI 的其余部分进行图形交互,CommandButton
必须公开继承QWidget
类。该设计产生了一个可重用的CommandButton
小部件类,用于声明主要和次要命令的通用按钮。因为按钮动作是通过使用QPushButton
实现的,所以CommandButton
类的整体实现非常简单。
重用QPushButton
还有最后一个小细节。显然,由于QPushButton
被私有地封装在CommandButton
中,客户端无法从外部连接到QPushButton
的clicked()
信号,这使得客户端代码无法知道何时点击了CommandButton
。这个设计其实是有意的。CommandButton
将在内部捕获QPushButton
的clicked()
信号,随后重新发射自己的信号。这个公共CommandButton
信号的设计与移位状态的处理有着错综复杂的联系。
图 6-2
CommandButton
的布局
我们现在返回到对计算器内的移位状态进行建模。我们有两个实际的选择。第一个选项是让CommandButton
s 了解计算器何时处于移位状态,并且只发出正确的移位或未移位命令。或者,第二个选项是让CommandButton
的信号同时包含移位和未移位命令,并让信号接收器整理出计算器的当前状态。让我们检查这两个选项。
第一个选项,让CommandButton
s 知道计算器是处于移位状态还是非移位状态,实现起来相当容易。在一个实现中,当按下换档按钮时,换档按钮通知每个按钮(通过 Qt 信号和槽),并且按钮在换档和非换档状态之间切换。如果需要,甚至可以在每次切换转换状态时,将转换位置的文本与按钮上的文本进行交换。或者,切换按钮可以连接到一个设置全局切换状态标志的插槽,当按钮发出发生点击的信号时,按钮可以查询该标志。在任一实现场景中,当单击按钮时,只发出当前状态的命令,该命令的接收者最终通过一个commandEntered()
事件将该命令从 GUI 中转发出去。
在第二个选项中,CommandButton
不需要知道计算器的任何状态。相反,当一个按钮被点击时,它会以移动和非移动两种状态发出信号。本质上,按钮只是在被单击时通知它的侦听器,并提供两种可能的命令。然后,接收器负责确定在commandEntered()
事件中发出哪些可能的命令。接收方大概必须负责跟踪移位的状态(或者能够轮询持有该状态的另一个类或变量)。
对于CommandButton
,处理计算器状态的两种设计都相当不错。然而,就个人而言,我更喜欢不需要CommandButton
s 知道任何关于转移状态的设计。在我看来,这种设计促进了更好的内聚性和更松散的耦合。这个设计更有凝聚力,因为CommandButton
应该负责显示一个可点击的小部件,并在按钮被点击时通知系统。要求CommandButton
理解计算器状态侵犯了它们抽象的独立性。这些按钮不再是带有两个命令的普通可点击按钮,而是与计算器的全局状态概念紧密联系在一起。此外,通过迫使CommandButton
s 理解计算器的状态,通过迫使CommandButton
s 不必要地互连到移位按钮或它们必须轮询的类,增加了系统中的耦合。当 shift 按钮被按下时,通知每一个CommandButton
的唯一好处是能够交换主要和次要命令的标签。当然,标签交换可以独立于CommandButton
的信号参数来实现。
CommandButton 界面
获得正确的设计是困难的部分。有了设计,界面实际上就可以自己写了。让我们检查一下CommandButton
类定义的简化版本:
class CommandButton : public QWidget
{
Q_OBJECT // needed by all Qt objects with signals and slots
public:
CommandButton(const string& dispPrimaryCmd, const string& primaryCmd,
const string& dispShftCmd, const string& shftCmd,
QWidget* parent = nullptr);
CommandButton(const string& dispPrimaryCmd, const string& primaryCmd,
QWidget* parent = nullptr);
private slots:
void onClicked();
signals:
void clicked(string primCmd, string shftCmd);
};
CommandButton
类有两个构造器:四参数重载和两参数重载。四参数重载允许指定主要命令和辅助命令,而两参数重载只允许指定主要命令。每个命令都需要两个字符串来提供完整的说明。第一个字符串相当于标签将在 GUI 中显示的文本,可以在按钮上显示,也可以在移动后的命令位置显示。第二个字符串相当于由commandEntered()
事件引发的文本命令。可以通过要求这两个字符串相同来简化接口。但是,我选择增加显示不同于命令调度器所需的文本的灵活性。注意,由于尾部的parent
指针,我们需要重载而不是默认参数。
该接口唯一的另一个公共部分是clicked()
信号,它与按钮的主命令和移位命令一起发出。双变元对一变元信号背后的基本原理之前已经讨论过了。尽管是私有的,我还是在CommandButton
的界面中列出了onClicked()
插槽,以突出显示为了捕捉内部QPushButton
的clicked()
信号而必须创建的私有插槽。onClicked()
函数的唯一目的是捕获QPushButton
的clicked()
信号,并发出带有两个函数参数的CommandButton
的clicked()
信号。
如果你看看CommandButton.h
中CommandButton
类的实际声明,你会看到一些额外的函数作为CommandButton
公共接口的一部分。这些只是简单的转发功能,要么改变外观(例如,文本颜色),要么向底层QPushButton
添加可视元素(例如,工具提示)。虽然这些功能是CommandButton
界面的一部分,但它们在功能上是可选的,并且独立于CommandButton
的底层设计。
获取输入
GUI 需要接受两种不同类型的用户输入:数字和命令。这两种输入类型都是用户通过排列在网格中的CommandButton
(或映射到这些按钮的键盘快捷键)输入的。这些CommandButton
的集合、它们的布局以及它们给 GUI 其余部分的相关信号组成了InputWidget
类。
命令输入在概念上很简单。点击一个CommandButton
,然后发出一个信号,反映该特定按钮的命令。最终,GUI 的另一部分将接收这个信号,并引发一个由命令调度器处理的commandEntered()
事件。
输入数字比输入命令要复杂一些。在 CLI 中,我们可以简单地允许用户键入数字,并在输入完成后按 enter 键。然而,在 GUI 中,我们没有这样的内置机制(假设我们想要一个比 Qt 窗口中的 CLI 更复杂的 GUI)。虽然计算器确实有一个用于输入数字的Command
,但请记住,它假设的是完整的数字,而不是单个数字。因此,GUI 必须有一个构造数字的机制。
构建数字包括输入数字和特殊符号,如小数点、加/减运算符或取幂运算符。此外,当用户输入时,他们可能会出错,所以我们也希望启用基本编辑(例如退格)。数字的组合是一个两步过程。InputWidget
只负责发出编写和编辑数字所需的按钮点击。GUI 的另一部分将接收这些信号并汇编完整的数字输入。
输入输出集的设计
从概念上讲,InputWidget
类的设计很简单。小部件必须显示生成和编辑输入所需的按钮,将这些按钮绑定到按键(如果需要),并在这些按钮被单击时发出信号。如前所述,InputWidget
包含数字输入和命令输入按钮。因此,它负责数字 0 –
9、加号/减号按钮、小数点按钮、求幂按钮、回车按钮、退格按钮、shift 按钮以及每个命令的按钮。回想一下,作为一种节约,CommandButton
类允许每个可视按钮有两个不同的命令。
为了整个 GUI 的一致性,我们将使用CommandButton
专门作为所有输入按钮的表示,即使是既没有发布命令也没有辅助操作的按钮(例如 0 按钮)。我们对CommandButton
的设计如此灵活,真是太方便了!然而,这个决定仍然给我们留下了两个突出的设计问题,即我们如何在视觉上布局按钮,以及当按钮被点击时我们该做什么。
在InputWidget
中放置按钮有两种选择。首先,InputWidget
本身拥有一个布局,它将所有的按钮放在这个内部布局中,然后InputWidget
本身可以放在主窗口的某个地方。备选方案是InputWidget
在建造期间接受外部拥有的布局,并将其CommandButton
放置在该布局上。总的来说,让InputWidget
拥有自己的布局是最好的设计。与替代方法相比,它提高了内聚力,减少了耦合。让InputWidget
接受外部布局的唯一例外是,如果设计要求其他类共享相同的布局来放置额外的小部件。在这种特殊情况下,使用两个类外部拥有的共享布局会更简洁。
现在让我们把注意力转向点击InputWidget
中的按钮会发生什么。因为InputWidget
封装了CommandButton
s,每个CommandButton
的clicked()
信号不能被InputWidget
类的消费者直接访问。因此,InputWidget
必须捕捉所有的CommandButton
点击并重新发出。对于正弦或正切等计算器命令,重新发出单击是一个微不足道的转发命令。事实上,Qt 支持将CommandButton
的clicked()
信号直接连接到InputWidget commandEntered()
信号的简写符号,无需通过InputWidget
中的私有插槽。数字、数字编辑按钮(如加/减、退格)和计算器状态按钮(如 shift)通过在InputWidget
的专用槽中捕捉来自CommandButton
的特定clicked()
信号并随后为这些动作中的每一个发出InputWidget
信号来更好地处理。
如上所述,当按下每个输入按钮时,InputWidget
必须发出自己的信号。在一个极端情况下,InputWidget
可以为每个内部CommandButton
提供单独的信号。在另一个极端,不管按钮按下与否,InputWidget
只能发出一个信号,并通过一个参数来区分动作。正如所料,对于我们的设计,我们将寻求一些中间地带,分享来自每一个极端的元素。
本质上,InputWidget
接受三种不同类型的输入:修饰符(例如,回车、退格、加/减、shift)、科学符号字符(例如,0 –
9、十进制、取幂)或命令(例如,正弦、余弦等)。).每个修改器需要一个唯一的响应;因此,每个修饰物结合到它自己单独的信号上。另一方面,科学符号字符可以简单地通过在屏幕上显示输入字符来统一处理(Display
类的作用)。因此,科学符号字符都是通过发出一个信号来处理的,该信号将特定字符编码为一个参数。最后,通过发出单个信号来处理命令,该信号只是将主要和次要命令作为信号的函数参数一字不差地转发。
在构建信号处理时,重要的是将InputWidget
作为一个类来维护,以便向 GUI 的其余部分发送原始用户输入信号。让InputWidget
解释按钮按下会导致问题。例如,假设我们设计了InputWidget
来聚合字符,并且只发出完整有效的数字。由于这种策略意味着每个字符输入都不会发出信号,所以在数字完成之前,字符既不能显示也不能编辑。这种情况显然是不可接受的,因为用户肯定希望在输入时看到屏幕上的每个字符。
现在让我们将注意力转向将我们的设计转化为InputWidget
的最小界面。
InputWidget 的接口
我们通过展示类声明开始讨论InputWidget
的接口。正如所料,我们清晰的设计带来了简单明了的界面。
class InputWidget : public QWidget
{
Q_OBJECT
public:
explicit InputWidget(QWidget* parent = nullptr);
signals:
void characterEntered(char c);
void enterPressed();
void backspacePressed();
void plusMinusPressed();
void shiftPressed();
void commandEntered(string, string);
};
本质上,整个类接口是由对应于用户输入事件的信号定义的。具体来说,我们有一个信号指示任何科学记数法字符的输入,一个信号指示前进命令按钮的点击,以及单独的信号分别指示退格、回车、加/减或 shift 按钮的点击。
如果您查看 GitHub 资源库源代码中的InputWidget.cpp
文件,您会发现一些额外的公共函数和信号。这些额外的函数是实现后续章节中介绍的两个特性所必需的。首先,需要一个addCommandButton()
函数和一个setupFinalButtons()
函数来适应插件按钮的动态添加,这是在第七章中介绍的一个特性。其次,需要一个procedurePressed()
信号来指示用户请求使用存储过程。存储过程在第八章中介绍。
显示器
从概念上讲,计算器有两个显示器:一个用于输入,一个用于输出。这种抽象可以在视觉上实现为两个单独的显示或者一个合并的输入/输出显示。两种设计都完全有效;每个都在图 6-3 中进行了说明。
图 6-3
输入和输出显示选项
选择一种 I/O 风格还是另一种,最终取决于客户的偏好。我对这两种风格都没有特别的偏好,所以选择了合并显示,因为它看起来更像我的 HP48S 计算器。选择了显示风格后,现在让我们来关注这个选择所暗示的设计含义。
如图 6-3a 所示,使用一个独立的屏幕小部件进行输入和输出,选择独立的输入和输出显示类是显而易见的。输入显示将有接收InputWidget
信号的插槽,输出显示将有接收完整数字(来自输入显示)和栈更新的插槽。内聚力会很强,组件的分离会很合适。
然而,我们的设计要求混合输入/输出显示器,如图 6-3b 所示。混合设计极大地改变了使用独立输入和输出显示类的敏感性。虽然将输入和输出显示问题集中到一个类中确实会降低显示的内聚性,但是试图维护两个独立的类都指向同一个屏幕上的小部件会导致笨拙的实现。例如,选择哪个类应该拥有底层的 Qt 小部件是任意的,很可能导致一个共享的小部件设计(也许使用一个shared_ptr
?).然而,在这种情况下,输入或输出显示类应该初始化屏幕上的小部件吗?如果输入显示共享指向单个显示小部件的指针,那么输入显示向输出显示发送信号是否有意义?答案很简单,两个类的设计对于一个合并的 I/O 显示小部件是不成立的,即使我们可能更喜欢将输入和输出显示分开。
上述讨论确定了几个有趣的点。首先,设计在屏幕上的视觉呈现可以合理地改变底层组件的设计和实现。虽然这在一个具体的 GUI 示例中似乎是显而易见的,但间接的含义是,如果屏幕上的小部件只是稍微改变一下,GUI 类的设计可能需要显著改变。第二,当设计与第二章中假设的良好设计要素直接矛盾时,结果会更清晰。显然,第二章中的指导方针是为了帮助设计过程,而不是作为不可违反的规则。也就是说,我的总的建议是保持遵循指导方针的清晰性,但只是明智地违反最佳实践。
既然我们已经决定用一个底层的Display
类来实现一个单一的 I/O 显示,让我们来看看它的设计。
显示类的设计
我承认。我最初对Display
类的设计和实现是无能的。我没有使用适当的分析技术和前期设计,而是有机地发展设计(即,与实现并行)。然而,当我的设计迫使Display
类发出commandEntered()
信号让 GUI 正常工作时,我就知道这个设计有一股“臭味”。负责在屏幕上绘制数字的类可能不应该解释命令。也就是说,实现工作正常,所以我让代码保持原样,并完成了计算器。然而,当我最终开始写这个设计时,我很难为我的设计制定一个基本原理,我最终不得不承认自己的设计有致命的缺陷,迫切需要重写。
显然,在重新设计展示后,我可以简单地选择只描述改进的产品。然而,我认为研究我的第一次被误导的尝试,讨论设计有一些严重问题的迹象,并最终看到经过一夜的重构最终出现的设计是有启发性的。可能,这里最有趣的教训是,糟糕的设计肯定会导致工作代码,所以永远不要认为工作代码是好设计的指标。此外,糟糕的设计,如果本地化,可以重构,有时,重构应该仅仅是为了增加清晰度。当然,重构假设您的项目时间表包含足够的应急时间,可以定期暂停以偿还技术债务。在回到更好的设计之前,我们现在开始简要研究我的错误。
拙劣的设计
根据前面的分析,我们确定计算器应该有一个统一的Display
类来处理输入和输出。我的显示设计中的基本错误源于错误地解释了一个Display
类意味着没有额外的正交关注类。因此,我继续将所有没有被InputWidget
类处理的功能合并到一个单独的Display
类中。让我们沿着这条路开始。然而,我们不是像我以前那样完成设计和实现,而是一看到第一个致命缺陷出现,就停下来重新设计这个类(这是我本来应该做的)。
使用单一的Display
类设计,Display
负责显示来自用户的输入和来自计算引擎的输出。显示输出是微不足道的。Display
类观察stackChanged()
事件(间接的,因为它不是 GUI 外部接口的一部分)并用新的栈值更新屏幕显示小部件(在本例中是一个QLabel
)。从概念上讲,显示输入也是微不足道的。Display
直接接收InputWidget
类(如characterEntered()
)发出的信号,并用当前输入更新屏幕显示小工具。这种交互的简单性掩盖了这种设计的根本问题,即输入不是为了显示而自动输入的。相反,它是通过独立输入几个字符,并按下 enter 按钮来完成输入,从而将多个信号组合在一起。输入的这种顺序结构意味着计算器必须保持一个活动的输入状态,而输入状态与显示小部件无关。
此时,您可能会问,除了意识形态上的厌恶之外,Display
类保持输入状态还有什么问题。难道我们不能把状态简单地看作一个显示输入缓冲区吗?让我们继续这个设计,看看它为什么有缺陷。例如,考虑退格按钮,它的操作基于输入状态被重载。如果当前输入缓冲区不为空,退格键将从该缓冲区中删除一个字符。但是,如果当前输入缓冲区为空,按 backspace 按钮会导致发出从栈中删除顶部数字的命令。因为在这种设计下,Display
拥有输入状态并且是backspacePressed()
信号的接收器,所以Display
必须是来自栈命令的丢弃号的源。一旦Display
开始发布命令,我们已经完全放弃了内聚力,是时候去寻找意大利面酱了,因为意大利面条代码随之而来。从这里开始,我不再只是放弃设计,而是加倍努力,我原来的设计实际上变得更差了。然而,与其在这条错误的道路上走得更远,不如让我们继续研究一种更好的方法。
改进的展示设计
在讨论糟糕的显示设计的早期,我指出致命的错误来自于假设统一的显示需要单一的类设计。然而,正如我们已经看到的,这个假设是无效的。计算器中状态的出现意味着至少需要两个类:一个用于可视显示,一个用于状态。
这是否让你想起了我们已经见过的模式?GUI 需要维护一个内部状态(一个模型)。我们目前正在设计一个展示(一个视图)。我们已经设计了一个类InputWidget
,用于接受输入和发布命令(一个控制器)。显然,GUI 本身只不过是一种熟悉的模式——模型-视图-控制器(MVC)的体现。注意,相对于图 2-2 中的 MVC 原型,GUI 可以用间接通信来代替控制器和模型之间的直接通信。Qt 的信号和插槽机制促进了这一微小的变化,从而降低了耦合度。
我们现在将注意力集中在新引入的模型类的设计上。模型完成后,我们将返回到Display
类来完成它现在更简单的设计和界面。
6.3.4 模型
我恰当地称之为GuiModel
的模型类负责 GUI 的状态。为了正确地实现这一目标,模型必须是导致系统状态改变的所有信号的接收器,并且它必须是指示系统状态已经改变的所有信号的源。自然,模型也是系统状态的存储库,它应该为 GUI 的其他组件提供查询模型状态的工具。我们来看看GuiModel
的界面:
class GuiModel : public QObject
{
Q_OBJECT
public:
enum class ShiftState { Unshifted, Shifted };
struct State { /* discussed below */ };
GuiModel(QObject* parent = nullptr);
~GuiModel();
void stackChanged(const vector<double>& v);
const State& getState() const;
public slots:
// called to toggle the calculator's shift state
void onShift();
// paired to InputWidget's signals
void onCharacterEntered(char c);
void onEnter();
void onBackspace();
void onPlusMinus();
void onCommandEntered(string primaryCmd, string secondaryCmd);
signals:
void modelChanged();
void commandEntered(string s);
void errorDetected(string s);
};
GuiModel
类的六个槽都对应于InputWidget
类发出的信号。GuiModel
解释这些请求,适当地改变内部状态,并发出一个或多个自己的信号。特别值得注意的是commandEntered()
信号。鉴于GuiModel
的onCommandEntered()
插槽接受两个参数,即对应于被按下的CommandButton
的原始主命令和辅助命令,而GuiModel
负责解释 GUI 的转换状态,并且仅重新发射带有活动命令的commandEntered()
信号。
GuiModel
界面的其余部分涉及 GUI 的状态。我们首先讨论嵌套的State
结构背后的基本原理。比起在GuiModel
中将模型状态的每一部分声明为一个单独的成员,我发现将所有的状态参数放在一个结构中要干净得多。这种设计通过允许使用一个函数调用通过常量引用返回整个系统状态,而不是要求逐段访问各个状态成员,从而方便了模型状态的查询。我选择嵌套State
结构,因为它是GuiModel
的固有部分,没有独立的用途。因此,State
结构自然属于GuiModel
的范围,但是它的声明必须公开声明,以便 GUI 的其他组件能够查询状态。
结构的组成部分定义了 GUI 的整个状态。特别地,这个State
结构包括一个数据结构,该数据结构保存栈上最大数量的可见数字的副本、当前输入缓冲区、定义系统移位状态的枚举以及定义输入缓冲区有效性的 Qt 枚举。声明如下:
struct State
{
vector<double> curStack;
string curInput;
ShiftState shiftState;
QValidator::State curInputValidity;
};
一个有趣的问题是,为什么GuiModel
的State
缓冲来自栈顶的可见数字?鉴于Stack
类是单例的,Display
可以直接访问Stack
。然而,Display
仅观察到GuiModel
中的变化(通过modelChanged()
插槽)。因为与栈变化无关的状态变化频繁出现在 GUI 中(例如,字符输入),由于Display
不是stackChanged()
事件的直接观察者,因此Display
将被迫在每个modelChanged()
事件上浪费地查询Stack
。另一方面,GuiModel
是stackChanged()
事件的观察者(间接通过MainWindow
的函数调用)。因此,有效的解决方案是让GuiModel
仅在计算器的栈实际改变时更新栈缓冲区,并让Display
类访问该缓冲区,这通过构造保证是当前的,用于更新屏幕。
显示冗余
我们现在准备将注意力返回到Display
类。将所有的状态和状态交互放在GuiModel
类中后,Display
类可以简化为一个对象,它监视模型的变化并在屏幕上显示计算器的当前状态。除了构造器之外,Display
类的接口只包含两个函数:模型改变时调用的 slot 和在状态区域显示消息时调用的成员函数。后一个函数调用用于显示在 GUI 中检测到的错误(例如无效输入)以及在命令调度器中检测到的错误(通过UserInterface
的postMessage()
发送)。下面给出了Display
类的整个接口:
class Display : public QWidget
{
Q_OBJECT
public:
explicit Display(const GuiModel& g, QWidget* parent = nullptr,
int nLinesStack = 6, int minCharWide = 25);
void showMessage(const string& m);
public slots:
void onModelChanged();
};
Display
类的构造器的可选参数只是指示栈在屏幕上的可视外观。具体来说,Display
类的客户端可以灵活控制要显示的栈行数和屏幕显示的最小宽度(以固定宽度字体字符为单位)。
6.3.6 结合在一起:主窗口
主窗口是一个相当小的类,服务于一个大的目的。准确地说,它在我们的应用中有三个目的。首先,像在大多数基于 Qt 的 GUI 中一样,我们需要提供一个从QMainWindow
中公开继承的类,它自然地充当应用程序的主 GUI 窗口。特别是,这是在启动 GUI 的函数中实例化和显示的类。遵循我典型的创造性命名风格,我将这个类称为MainWindow
。其次,MainWindow
作为计算器视图模块的接口类。也就是说,MainWindow
也必须公开继承我们的抽象UserInterface
类。最后,MainWindow
类拥有前面讨论的所有 GUI 组件,并在必要时将这些组件粘合在一起。实际上,将组件粘合在一起只需要将信号连接到相应的插槽。这些简单的实现细节可以在MainWindow.cpp
源代码文件中找到。我们将在本节的剩余部分讨论MainWindow
的设计和界面。
我们已经编写了一个 Qt 应用程序;很明显我们会在某个地方有一个QMainWindow
的后代。这本身并不十分有趣。然而,有趣的是决定使用多重继承来使同一个类也作为 pdCalc 的其余部分的UserInterface
。也就是说,这真的是一个有趣的决定吗,或者只是因为一些开发人员对多重继承有强烈的厌恶而显得有些挑衅?
事实上,我本可以将QMainWindow
和UserInterface
分成两个独立的类。在主窗口装饰有菜单、工具栏和多个底层小部件的 GUI 中,我可能会将两者分开。然而,在我们的 GUI 中,QMainWindow
除了为我们的 Qt 应用程序提供一个入口点之外,没有其他用途。在QMainWindow
的角色中,MainWindow
实际上什么也不做。因此,创建一个单独的MainWindow
类,唯一的目的是包含一个UserInterface
类的具体专门化,除了避免多重继承之外,没有任何其他目的。虽然有些人可能不同意,但我认为在这种情况下,缺乏多重继承实际上会使设计复杂化。
前面描述的情况实际上是多重继承是最佳选择的一个典型例子。特别是,多重继承在派生类中表现出色,这些派生类的多个基类表现出正交功能。在我们的例子中,一个基类充当 Qt 的 GUI 入口点,而另一个基类充当 pdCalc 的 GUI 视图的UserInterface
专门化。请注意,两个基类都不共享功能、状态、方法或祖先。在至少有一个基类是纯抽象的(没有状态,只有纯虚函数的类)的情况下,多重继承特别有意义。使用纯抽象基类的多重继承的场景非常有用,以至于在不允许多重继承的编程语言(例如,C#和 Java 中的接口)中允许使用它。
MainWindow
的接口简单地由一个构造器、UserInterface
类中两个纯虚函数的覆盖和一些用于动态添加命令的函数组成(当我们设计插件时,我们将在第七章中遇到这些函数)。为了完整起见,MainWindow
的界面如下:
class MainWindow : public QMainWindow, public UserInterface
{
class MainWindowImpl;
public:
MainWindow(int argc, char* argv[], QWidget* parent = nullptr);
void postMessage(string_view m) override;
void stackChanged() override;
// plugin functions...
};
外观和感觉
在我们用一些执行 GUI 的示例代码来结束本章之前,我们必须简单地回到 GUI 的最后一个组件,即LookAndFeel
类。LookAndFeel
类只是管理 GUI 的可动态定制的外观,比如字体大小和文本颜色。界面简单。对于每个定制点,都有一个函数返回请求的设置。例如,为了获得显示的字体,我们提供了一个函数:
class LookAndFeel
{
public:
// one function per customizable setting, e.g.,
const QFont& getDisplayFont() const;
// ...
}
因为我们在计算器中只需要一个LookAndFeel
对象,所以这个类是作为单例实现的。
一个很好的问题是,“我们到底为什么需要这个类?”答案是,它让我们有机会根据当前环境动态修改计算器的外观,并且它集中了对 pdCalc 外观的内存访问。例如,假设我们想要让我们的 GUI DPI 知道并相应地选择字体大小(我在源代码中没有,但是您可能想要)。对于静态配置文件(或概念上的等效物,注册表设置),我们必须在安装过程中为每个平台定制设置。要么我们必须在安装程序中为每个平台构建定制,要么我们必须编写在安装期间执行的代码,以动态创建适当的静态配置文件。如果我们必须写代码,为什么不把它放在它应该在的地方呢?作为一个实现决策,LookAndFeel
类可以简单地设计为读取一个配置文件并在内存中缓冲外观属性(一个外观代理对象)。这才是LookAndFeel
级的真正威力。它集中了外观属性的位置,因此只需要更改一个类就可以实现全局外观更改。也许更重要的是,LookAndFeel
类将单个 GUI 组件与定义 GUI 如何发现(并可能适应)特定平台上的设置的实现细节隔离开来。
在LookAndFeel.cpp
文件中可以找到LookAndFeel
类的完整实现。当前的实现非常简单。LookAndFeel
类提供了一种标准化 GUI 外观的机制,但是没有实现允许用户定制应用程序。第八章简要地建议了一些可能的扩展,你可以对LookAndFeel
类进行扩展,使 pdCalc 用户可定制。
6.4 工作计划
我们以一个用于启动 GUI 的功能main()
来结束这一章。由于我们将在第七章中遇到的额外要求,pdCalc 的实际main()
函数比下面列出的更复杂。但是,简化版本值得列出来,以说明如何将 pdCalc 的组件与 GUI 结合在一起,以创建一个正常运行的独立可执行文件。
int main(int argc, char* argv[])
{
QApplication app{argc, argv};
MainWindow gui{argc, argv};
CommandInterpreter ci{gui};
RegisterCoreCommands(gui);
gui.attach(UserInterface::CommandEntered(),
make_unique<CommandIssuedObserver>(ci) );
Stack::Instance().attach(Stack::StackChanged(),
make_unique<StackUpdatedObserver>( gui ) );
gui.execute();
return app.exec();
}
注意前面提到的用于执行 GUI 的main()
函数与第五章结尾列出的用于执行 CLI 的main()
函数之间的相似之处。这些相似之处并非偶然,而是 pdCalc 模块化设计的结果。
与 CLI 一样,为了让您快速入门,在构建可执行文件pdCalc-simple-gui
的存储库源代码中包含了一个项目,使用前面的main()
函数作为应用程序的驱动程序。该可执行文件是一个独立的 GUI,包括本书到目前为止讨论的所有特性。
6.5 Microsoft Windows 内部版本说明
pdCalc 被设计为既是 GUI 又是 CLI。在 Linux 中,控制台应用程序(CLI)和窗口应用程序(GUI)在编译时没有区别。对于这两种风格,可以用相同的构建标志来编译统一的应用程序。然而,在 Microsoft Windows 中,创建一个既作为 CLI 又作为 GUI 的应用程序并不容易,因为操作系统要求应用程序在编译期间声明控制台或 Windows 子系统的使用。
为什么子系统的声明在 Windows 上很重要?如果一个应用程序被声明为窗口应用程序,如果从命令提示符启动,该应用程序将简单地返回而没有输出(即,该应用程序将看起来好像从未执行过)。但是,当双击应用程序的图标时,应用程序会在没有后台控制台的情况下启动。另一方面,如果一个应用程序被声明为控制台应用程序,则当从命令提示符启动时,GUI 将会出现,但是如果通过双击该应用程序的图标来打开,GUI 将与后台控制台一起启动。
通常,Microsoft Windows 应用程序是为一个子系统或另一个子系统设计的。在少数同时使用 GUI 和 CLI 开发应用程序的情况下,开发人员已经创建了避免上述问题的技术。一种这样的技术创建了两个应用程序:一个. com 和一个. exe,操作系统可以根据通过命令行参数选择的选项适当地调用它们。
为了保持 pdCalc 的代码简单和跨平台,我忽略了这个问题,简单地使用控制台子系统构建了 GUI(然而,pdCalc-simple-gui
没有 CLI,是在窗口模式下构建的)。事实上,这意味着如果通过双击 pdCalc 的图标来启动应用程序,将会在后台出现一个额外的控制台窗口。如果您打算将应用程序专门用作 GUI,可以通过使用 windows 子系统构建程序来解决这个问题。如果您进行此更改,请记住,pdCalc 的 CLI 实际上将被禁用。构建窗口应用程序可以通过在负责构建 pdCalc 可执行文件的CMakeLists.txt
文件的add_executable()
命令中添加WIN32
选项来完成(参见pdCalc-simple-gui
的CMakeLists.txt
文件)。如果您需要访问 CLI 和 GUI,而外部控制台让您抓狂,您有两个现实的选择。首先,在互联网上搜索前面讨论过的技术之一,并尝试一下。就我个人而言,我从未走过那条路。其次,构建两个独立的可执行文件(可能称为 pdCalc 和 pdCalc-cli ),而不是一个能够基于命令行参数切换模式的可执行文件。应用程序灵活的架构支持这两种选择。
七、插件
你可能已经读过这一章的标题,所以你已经知道这一章是关于插件的,特别是它们的设计和实现。此外,插件将为我们提供探索设计技术的机会,以隔离平台特定的功能。然而,在我们深入细节之前,让我们先来定义什么是插件。
7.1 什么是插件?
插件是一种软件组件,在程序初次编译后,它可以将新功能添加到程序中。在这一章中,我们将专门关注运行时插件,即作为共享库构建的插件(例如,POSIX。所以还是 Windows。dll 文件),它们在运行时是可发现和可加载的。
插件在应用程序中有用的原因有很多。这里只是几个例子。首先,插件对于允许最终用户在不需要重新编译的情况下向现有程序添加特性是有用的。通常,这些新特性是最初的应用程序开发人员完全没有预料到的。第二,从架构上来说,插件可以将一个程序分成多个可选的部分,这些部分可以单独与程序一起发布。例如,考虑一个程序(例如 web 浏览器),它附带一些基本功能,但允许用户添加专业功能(例如广告拦截器)。第三,插件可以用于设计一个可以为特定客户定制的应用程序。例如,考虑一个电子健康记录系统,它需要不同的功能,这取决于软件是部署在医院还是医生的个人诊所。插入核心系统的不同模块可以捕获必要的定制。当然,人们可以为插件想出许多额外的应用。
在 pdCalc 的上下文中,插件是提供新的计算器命令和可选的新 GUI 按钮的共享库。这项任务会有多难?在第四章中,我们创建了许多命令,并看到添加新的命令是相当简单的。我们简单地继承了Command
类(或它的一个派生类,如UnaryCommand
或BinaryCommand
),实例化了命令,并用CommandFactory
注册了它。例如,以正弦命令为例,该命令在CoreCommands.m.cpp
中声明如下:
class Sine : public UnaryCommand
{
// implement Command virtual members
};
并由线路登记在CommandFactory.m.cpp
cf.registerCommand( "sin", MakeCommandPtr<Sine>() );
其中cf
是一个CommandFactory
参考。事实证明,除了一个关键步骤之外,插件命令几乎完全可以遵循这个配方。由于 pdCalc 在编译时不知道插件命令的类名,所以我们不能使用插件类名进行分配。
不知道插件命令的类名这个看似简单的困境导致了我们需要为插件解决的第一个问题。具体来说,我们需要建立一个抽象接口,通过它插件命令可以被发现并在 pdCalc 中注册。一旦我们就插件接口达成一致,我们将很快遇到第二个基本的插件问题,那就是如何动态加载一个插件,甚至使共享库中的名称对 pdCalc 可用。让我们的生活变得更复杂的是,第二个问题的解决方案依赖于平台,所以我们将寻求一种设计策略来最小化平台依赖性的痛苦。我们将遇到的最后一个问题是更新我们现有的代码来动态添加新的命令和按钮。也许令人惊讶的是,这最后一个问题是最容易解决的。然而,在我们开始解决这三个问题之前,我们需要考虑一些 C++ 插件的规则。
7 . 1 . 1 c++ 插件的规则
插件在概念上不是 C++ 语言的一部分。更确切地说,插件是操作系统如何动态加载和链接共享库的一种表现形式(因此插件具有平台特定的性质)。对于任何规模不小的项目,应用程序通常分为一个可执行文件和几个共享库(传统上。所以 Unix 中的文件。麦克 OS X 的 dylib 文件。MS Windows 中的 dll 文件)。
通常,作为 C++ 程序员,我们很高兴地没有意识到这种结构的微妙之处,因为可执行文件和库是在同构的构建环境中构建的(即,相同的编译器和标准库)。然而,对于一个实用的插件接口,我们没有这样的保证。相反,我们必须防御性地编程,并假设最坏的情况,即插件构建在与主应用程序不同但兼容的环境中。这里,我们将做一个相对较弱的假设,即这两个环境至少共享相同的对象模型。具体来说,我们要求两个环境使用相同的布局来处理虚函数指针(vptr
)。如果你不熟悉虚函数指针的概念,所有血淋淋的细节都可以在[18]中找到。虽然原则上,C++ 编译器作者可以选择不同的vptr
布局,但实际上,编译器通常使用兼容的布局,尤其是同一编译器的不同版本。如果没有这个共享对象模型的假设,我们将被迫开发一个 C 语言的–
纯插件结构。注意,我们还必须假设sizeof(T)
对于主应用程序和插件中的所有类型T
都是相同的大小。例如,这消除了 32 位应用程序和 64 位插件,因为这两个平台具有不同的指针大小。
异构环境中的编程如何影响我们可以使用的编程技术?在最坏的情况下,主应用程序可能是用不同的编译器和不同的标准库构建的。这一事实有几个严重的影响。首先,我们不能假设插件和应用程序之间的内存分配和释放是兼容的。这意味着在一个插件中的任何内存必须在同一个插件中。其次,我们不能假设来自标准库的代码在任何插件和主应用程序之间都是兼容的。因此,我们的插件接口不能包含任何标准容器。虽然标准库的不兼容性看起来很奇怪(这是标准的库,对吗?),记住标准指定的是接口,而不是实现(受一些限制,比如vector
s 占用连续内存)。例如,不同的标准库实现经常有不同的string
实现。一些人喜欢小字符串优化,而另一些人喜欢使用写时复制。第三,虽然我们已经为对象中的vptr
假设了一个兼容的布局,但是我们不能假设完全相同的对齐。因此,如果主应用程序中使用了基类中定义的成员变量,插件类就不应该从主应用程序类继承。这是因为如果每个编译器使用不同的对齐方式,主应用程序的编译器可能会对成员变量使用不同于插件编译器定义的内存偏移量。第四,由于不同编译器之间的名称混淆差异,导出的接口必须指定extern "C"
链接。链接要求是双向的。插件不应调用没有extern "C"
链接的应用程序函数,应用程序也不应调用没有extern "C"
链接的插件函数。请注意,因为非非线性、非虚拟成员函数需要跨编译单元的链接(与虚函数相反,虚函数是通过虚函数表中的偏移量通过vptr
调用的),所以应用程序应该只通过虚函数调用插件代码,插件代码不应该调用在主应用程序中编译的基类非非线性、非虚拟函数。第五,异常很少能在主程序和插件之间的二进制接口上移植,所以我们不能在插件中抛出异常并试图在主应用程序中捕捉它们。最后,C++20 模块可以跨插件边界移植,但是它们的编译模块接口(CMI)却不能。插件所需的由 C++20 模块封装的任何主要应用程序代码必须向该插件提供其模块接口文件。这本质上与给插件提供一个头文件没有什么不同,除了,根据构建系统,模块接口文件可能需要单独编译或预编译,而不是简单地包含。
那是一口。让我们通过列举 C++ 插件的规则来回顾一下:
-
在一个插件中分配的内存必须在同一个插件中释放。
-
标准库组件不能在插件接口中使用。
-
假设不兼容对齐。如果主应用程序中使用了成员变量,避免插件从主应用程序类继承。
-
从插件导出的函数(将由主应用程序调用)必须指定
extern "C"
链接。从主应用程序导出的函数(将被插件调用)必须指定extern "C"
链接。 -
主应用程序应该只通过虚函数与插件派生类通信。插件派生类不应该调用非非线性、非虚拟的主应用程序基类函数。
-
不要让插件中抛出的异常传播到主应用程序。
-
模块 CMI 不是可分发的工件;分发模块接口文件。
记住这些规则,让我们回到设计插件必须解决的三个基本问题。
7.2 问题 1:插件接口
插件接口负责几个项目。首先,它必须能够发现新的命令和新的 GUI 按钮。我们将看到,通过类接口可以最有效地实现这一功能。第二,插件必须支持一个 C 链接接口来分配和释放前面提到的插件类。第三,pdCalc 应该提供一个从Command
派生的PluginCommand
类来帮助正确编写插件命令。从技术上讲,PluginCommand
类是可选的,但是提供这样一个接口有助于用户遵守插件规则第三和第六条。第四,值得插件接口提供查询一个插件支持的 API 版本的功能。最后,pdCalc 必须为插件调用的任何函数提供 C 链接。具体来说,插件命令必须能够访问栈。我们将从发现命令的界面开始依次解决这些问题。
7.2.1 发现命令的界面
我们面临的第一个问题是如何从插件中分配命令,我们既不知道插件提供什么命令,也不知道我们需要实例化的类的名称。我们将通过创建一个抽象接口来解决这个问题,所有插件都必须遵循这个接口来导出命令及其名称。首先,让我们解决我们将需要什么功能。
回想一下第四章,为了将新命令加载到计算器中,我们必须用CommandFactory
注册它。按照设计,CommandFactory
是专门为允许命令的动态分配而构建的,这正是我们插件命令所需要的功能。现在,我们假设插件管理系统可以访问 register 命令(我们将在 7.4 节解决这个缺陷)。CommandFactory
的注册功能需要一个string
命令名和一个unique_ptr
作为命令的原型。由于 pdCalc 对插件中的命令名一无所知,插件接口必须首先使名称可被发现。第二,由于 C++ 缺乏反射这一语言特性,插件接口必须提供一种方法来创建一个与每个发现的名字相关联的原型命令。同样,通过设计,抽象的Command
接口通过clone()
虚拟成员函数支持原型模式。让我们看看这两个先前的设计决策是如何有效地启用插件的。
基于前面提到的 C++ 插件规则,我们实现命令发现的唯一方法是将其封装成一个所有插件都必须遵守的纯虚拟接口。理想情况下,我们的虚函数将返回一个由string
s 键入的unique_ptr<CommandPtr>
值的关联容器。然而,我们的 C++ 插件规则也规定我们不能使用标准容器,因此排除了string
、map
、unordered_map
和unique_ptr
。与其(糟糕地)重新实现这些容器的定制版本,我们将只使用一个通常被避免的、低级的可用工具,指针数组。
前面的设计是通过创建一个所有插件都必须符合的Plugin
类来实现的。这个抽象类的目的是标准化插件命令发现。类声明由以下内容给出:
// module code will be omitted from additional plugin listings
export class pdCalc.plugins;
export class Plugin
{
public:
Plugin();
virtual ~Plugin();
struct PluginDescriptor
{
int nCommands;
char** commandNames;
Command** commands;
};
virtual const PluginDescriptor& getPluginDescriptor() const = 0;
};
我们现在有了一个抽象插件接口,当它被专门化时,需要一个派生类来返回一个描述符,该描述符提供了可用命令的数量、这些命令的名称以及命令本身的原型。显然,命令名的顺序必须与命令原型的顺序相匹配。另一种设计是将CommandDescriptor
定义如下:
struct CommandDescriptor
{
char* commandName;
Command* command;
};
并让PluginDescriptor
保存一个CommandDescriptor
的数组,而不是单独的名称和命令数组。这种选择是典型的数组结构或数组结构难题。在这种情况下,两种选择都是有效的,我选择前者作为 pdCalc 的实现,这有点武断。
不幸的是,对于原始指针和原始数组,谁拥有命令名和命令原型的内存会产生歧义。我们无法使用标准容器,这迫使我们进入了一个不幸的设计:通过注释签约。因为我们的规则规定在一个插件中分配的内存必须由同一个插件释放,所以最好的策略是规定插件负责释放PluginDescriptor
及其组成部分。如前所述,内存契约是通过注释“执行”的。
太好了,我们的问题解决了。我们创建一个插件;姑且称之为MyPlugin
,继承自Plugin
。我们将在 7.3 节看到如何分配和释放插件。在MyPlugin
内部,我们像往常一样通过继承Command
来创建新的命令。因为插件知道它自己的命令名,不像主程序,插件可以用new
操作符分配它的命令原型。然后,为了注册所有插件的命令,我们简单地分配一个带有命令名和命令原型的插件描述符,通过覆盖getPluginDescriptor()
函数返回描述符,并让 pdCalc 注册命令。由于Command
必须每个都实现一个clone()
函数,pdCalc 可以通过这个虚拟函数复制插件命令原型,并向CommandFactory
注册它们。很简单,用于注册的字符串名称可以从commandNames
数组中创建。对于已经分配的Plugin* p
,pdCalc 中的以下代码可以实现注册:
const auto& d = p->getPluginDesciptor();
for(int i = 0; i < d.nCommands; ++i)
CommandFactory::Instance().registerCommand( d.commandNames[i],
MakeCommandPtr(d.commands[i]->clone()) );
在这一点上,你可能会意识到我们的插件所面临的困境。命令在插件中分配,通过插件的clone()
函数分配,在CommandRegistry
注册时复制到主程序中,然后当CommandRegistry
的析构函数执行时,最终被主程序删除。更糟糕的是,每次执行命令时,CommandRegistry
都会克隆它的原型,通过Command
的clone()
函数触发插件中的new
语句。这个已执行命令的生命周期由CommandManager
通过其撤销和重做栈来管理。具体来说,当一个命令从其中一个栈中被清除时,当保存该命令的unique_ptr
被销毁时,在主程序中调用delete
。至少,它是这样工作的,不需要任何调整。正如第四章提到的,CommandPtr
不仅仅是unique_ptr<Command>
的简单别名。现在让我们最后描述一下CommandPtr
别名和MakeCommandPtr()
函数背后的机制,它们允许正确的插件命令内存管理。
基本上,我们首先需要一个函数在适当的编译单元中调用delete
。这个问题最简单的解决方案是在Command
类中添加一个deallocate()
虚函数。这个函数的职责是当Command
被销毁时,在正确的编译单元中调用delete
。对于所有核心命令,正确的行为是简单地delete
主程序中的类。因此,我们没有使deallocate()
函数成为纯虚拟的,我们给它以下默认实现:
void Command::deallocate()
{
delete this;
}
对于插件命令,deallocate()
的覆盖具有相同的定义,只有定义出现在插件的编译代码中(比如,在特定插件中命令使用的基类中)。因此,当在主应用程序的Command
指针上调用deallocate()
时,虚拟函数调度确保从正确的编译单元调用delete
。现在,我们只需要一个机制来确保当Command
s 被回收时,我们调用deallocate()
而不是直接调用delete
。幸运的是,似乎标准委员会在设计unique_ptr
时就完美地预见到了我们的需求。让我们回到CommandPtr
别名,看看如何使用unique_ptr
来解决我们的问题。
定义一个CommandPtr
别名和实现一个能够调用deallocate()
而不是delete
的MakeCommandPtr()
函数只需要非常少的几行代码。这段代码利用了unique_ptr
的 deleter 对象(见侧栏),当调用unique_ptr
的析构函数时,它允许调用一个定制例程来回收unique_ptr
持有的资源。让我们看看代码:
inline void CommandDeleter(Command* p)
{
if(p) p->deallocate();
return;
}
using CommandPtr = unique_ptr<Command, decltype(&CommandDeleter)>;
inline auto MakeCommandPtr(Command* p)
{
return CommandPtr{p, &CommandDeleter};
}
对前面的密集代码的简要解释是有保证的。一个CommandPtr
只是一个unique_ptr
的别名,它包含一个Command
指针,通过在析构时调用CommandDeleter()
函数来回收这个指针。由unique_ptr
调用的CommandDeleter()
函数是一个简单的内联函数,它调用先前定义的虚拟deallocate()
函数。为了减轻创建CommandPtr
的语法负担,我们引入了一个内联的MakeCommandPtr()
助手函数,它从一个Command
指针构造一个CommandPtr
。就这样。现在,就像以前一样,unique_ptr
s 自动为Command
s 管理内存。但是,unique_ptr
的析构函数调用CommandDeleter
函数,该函数调用deallocate()
,该函数在正确的编译单元中对底层Command
发出delete
,而不是直接调用底层Command
上的delete
。
如果您查看MakeCommandPtr()
的源代码,除了之前看到的采用Command
指针参数的函数版本之外,您会看到一个非常不同的重载,它使用可变模板和完美转发。由于在存储过程的构造中MakeCommandPtr()
的不同语义用法,这个重载函数一定存在。我们将在第八章中重温这两种函数形式背后的推理。如果悬念太多,可以直接跳到第 8.1.2 节。
Modern C++ Design Note: UNIQUE_PTR Destruction Semantics
unique_ptr<T,D>
类模板是一个智能指针,它对资源的唯一所有权进行建模。最常见的用法是只指定第一个模板参数T
,它声明了所拥有的指针的类型。第二个参数D
指定了一个定制的删除可调用对象,该对象在unique_ptr
的销毁过程中被调用。让我们来看看unique_ptr
的析构函数的概念模型:
template<typename T, typename D = default_delete<T>>
class unique_ptr
{
T* p_;
D d_;
public:
~unique_ptr()
{
d_(p_);
}
};
unique_ptr
的析构函数没有直接调用delete
,而是使用函数调用语义将所拥有的指针传递给删除器。从概念上来说,default_delete
实现如下:
template<typename T>
struct default_delete
{
void operator()(T* p)
{
delete p;
}
};
也就是说,default_delete
仅仅是由unique_ptr
包含的底层指针。然而,通过在构造期间指定一个定制的删除器可调用对象(D
模板参数),可以使用unique_ptr
来释放需要定制的解除分配语义的资源。举个简单的例子,unique_ptr
的删除语义允许我们创建一个简单的 RAII(资源获取是初始化)容器类,MyObj
,由malloc()
分配:
MyObj* m = static_cast<MyObj*>( malloc(sizeof(MyObj) ) );
auto p = unique_ptr<MyObj, decltype(&free)>{m, &free};
当然,我们对 pdCalc 的设计展示了自定义删除语义unique_ptr
有用性的另一个实例。应该注意的是,shared_ptr
也以类似的方式接受自定义删除器。
7.2.2 添加新 GUI 按钮的界面
从概念上讲,动态添加按钮与动态添加命令没有太大区别。主应用程序不知道需要从插件中导入什么按钮,所以Plugin
接口必须提供一个提供按钮描述符的虚函数。然而,与命令不同,插件实际上不需要分配按钮本身。回想一下第六章中的 GUI CommandButton
小部件只需要文本来构造。特别是,它需要按钮的显示文本(可选地,转换状态文本)和与clicked()
信号一起发出的命令文本。因此,即使对于插件命令,相应的 GUI 按钮本身也完全驻留在主应用程序中;插件必须只提供文本。这导致了Plugin
类中的如下简单接口:
class Plugin
{
public:
struct PluginButtonDescriptor
{
int nButtons;
char** dispPrimaryCmd; // primary command label
char** primaryCmd; // primary command
char** dispShftCmd; // shifted command label
char** shftCmd; // shifted command
};
virtual const PluginButtonDescriptor* getPluginButtonDescriptor() const = 0;
};
同样,由于插件必须遵循的规则,接口必须由低级的字符数组组成,而不是高级的 STL 结构。同样,我们也可以提供一个使用结构数组而不是数组的结构的接口。
getPluginButtonDescriptor()
函数相对于getPluginDescriptor()
的一个有趣的方面是决定返回一个指针而不是一个引用。这种选择背后的基本原理是,插件作者可能希望编写一个插件,该插件导出没有相应 GUI 按钮的命令(即,仅 CLI 命令)。当然,相反的情况是荒谬的。也就是说,我无法想象为什么有人会编写一个插件,为不存在的命令输出按钮。这种实用性体现在两个描述符函数的返回类型中。由于这两个函数都是纯虚拟的,Plugin
专门化必须实现它们。因为getPluginDescriptor()
返回一个引用,所以它必须导出一个非空描述符。然而,通过返回一个指向描述符的指针,getPluginButtonDescriptor()
被允许返回一个nullptr
,表明插件没有导出任何按钮。有人可能会争辩说,getPluginButtonDescriptor()
函数不应该是纯虚拟的,而应该提供一个返回nullptr
的默认实现。这个决定在技术上是可行的。然而,通过坚持插件作者手动实现getPluginButtonDescriptor()
,界面强制明确做出决定。
7.2.3 插件分配和释放
我们最初的问题是主程序不知道插件命令的类名,因此不能通过调用new
来分配它们。我们通过创建一个抽象的Plugin
接口来解决这个问题,该接口负责导出命令原型、命令名和 GUI 创建按钮的足够信息。当然,要实现这个接口,插件必须从Plugin
类派生,从而创建一个专门化,其名称主应用程序不能提前知道。表面上,我们没有取得任何进展,又回到了原来的问题。
我们的新问题虽然可能与原来的问题相似,但实际上要容易解决得多。这个问题是通过在每个插件中创建一个单独的extern "C"
分配/解除分配函数对来解决的,这个函数对具有预先指定的名字,通过基类指针来分配/解除分配Plugin
专门化类。为了满足这些需求,我们向插件接口添加了以下两个函数:
extern "C" void* AllocPlugin();
extern "C" void DeallocPlugin(void*);
显然,AllocPlugin()
函数分配了Plugin
特殊化并将其返回给主应用程序,而一旦主应用程序使用完插件,DeallocPlugin()
函数就会解除插件的分配。奇怪的是,AllocPlugin()
和DeallocPlugin()
函数使用void
指针而不是Plugin
指针。这个接口对于保持 C 链接是必要的,因为extern "C"
接口必须符合 C 类型。保持 C 连接的一个不幸后果是必须进行强制转换。主应用程序在使用void*
之前必须将它强制转换为Plugin*
,共享库在调用delete
之前必须将void*
强制转换回Plugin*
。然而,请注意,我们不需要具体的Plugin
的类名。因此,AllocPlugin()/DeallocPlugin()
函数对解决了我们的问题。
插件命令界面
从技术上讲,不需要特殊的插件命令接口。然而,提供这样的接口有助于编写遵循 C++ 插件规则的插件命令。具体来说,通过创建一个PluginCommand
接口,我们向插件开发者保证了两个关键特性。首先,我们提供了一个接口,保证插件命令不会从具有任何状态的命令类继承(以避免对齐问题)。从结构上看,这一特性是显而易见的。其次,我们修改了checkPreconditionsImpl()
函数来创建一个跨越插件边界的无异常接口。考虑到这一点,我们给出了PluginCommand
界面:
class PluginCommand : public Command
{
public:
virtual ~PluginCommand();
private:
virtual const char* checkPluginPreconditions() const noexcept = 0;
virtual PluginCommand* clonePluginImpl() const noexcept = 0;
void checkPreconditionsImpl() const override final;
PluginCommand* cloneImpl() const override final;
};
虽然在第四章中只简单提到过,但是除了checkPreconditionsImpl()
和cloneImpl()
之外,Command
类中所有的纯虚函数都被标记为noexcept
(参见关键字noexcept
的侧栏)。因此,为了确保插件命令不会引发异常,我们只需在层次结构的PluginCommand
级别实现checkPreconditionsImpl()
和cloneImpl()
函数,并为其派生类创建新的、无异常的纯虚函数来实现。checkPreconditionsImpl()
和cloneImpl()
在PluginCommand
类中都被标记为final
,以防止专门化无意中覆盖这些函数中的任何一个。checkPreconditionsImpl()
的实现可以简单地写成如下:
void PluginCommand::checkPreconditionsImpl() const
{
if( const char* p = checkPluginPreconditions() )
throw Exception(p);
return;
}
注意,上述实现背后的关键思想是,PluginCommand
类的实现驻留在主应用程序的编译单元中,而该类的任何专门化驻留在插件的编译单元中。因此,通过虚拟调度,对checkPreconditionsImpl()
的调用在主应用程序的编译单元中执行,这个函数又调用驻留在插件的编译单元中的无异常的checkPluginPreconditions()
函数。如果出现错误,checkPreconditionsImpl()
函数通过指针返回值接收错误,随后从主应用程序的编译单元而不是插件的编译单元产生异常。如果没有前提条件失败,checkPluginPreconditions()
返回一个nullptr
,不抛出异常。
在Command.cpp
中可以找到类似的cloneImpl()
的简单实现。继承自PluginCommand
而不是Command
、UnaryCommand
或BinaryCommand
的插件命令更有可能避免违反任何 C++ 插件规则,因此不太可能产生难以诊断的特定于插件的运行时错误。
Modern C++ Design Note: Noexcept
C++98 标准允许使用异常规范。例如,下面的规范表明函数foo()
不抛出任何异常(规范throw
为空):
void foo() throw();
不幸的是,C++98 异常规范存在许多问题。虽然它们在指定函数可能抛出的异常方面是一种高尚的尝试,但它们通常不会像预期的那样运行。例如,编译器在编译时从不保证异常规范,而是通过运行时检查来强制执行该约束。更糟糕的是,声明不抛出异常规范可能会影响代码性能。出于这些原因以及更多原因,许多编码标准被编写成声明应该简单地避免异常规范(例如,参见[34]中的标准 75)。
虽然指定一个函数可以抛出哪些规范被证明不是非常有用,但是指定一个函数不能抛出任何异常可能是一个重要的接口考虑事项。幸运的是,C++11 标准通过引入noexcept
关键字弥补了异常规范的混乱。关于noexcept
说明符用法的深入讨论,参见[24]中的第 14 项。对于我们的讨论,我们将集中在关键字在设计中的有用性。
撇开性能优化不谈,在函数规范中选择使用noexcept
很大程度上是一个偏好问题。对于大多数函数来说,没有异常规范是正常的。即使函数代码本身不发出异常,也很难静态地确保函数中的嵌套函数调用不发出任何异常。因此,noexcept
是在运行时强制执行的,而不是在编译时保证。因此,我个人的建议是将noexcept
说明符的用法保留到那些需要对函数意图做出强有力声明的特殊情况中。pdCalc 的Command
层次结构说明了不抛出异常对于正确操作非常重要的几种情况。这一要求在接口中进行了编码,以通知开发人员抛出异常将导致运行时错误。
API 版本控制
毫无疑问,在一个长期应用程序的生命周期中,插件的规范可能会改变。这意味着在某个时间点编写的插件可能不再适用于更新的 API 版本。对于作为单个单元交付的应用程序,组成整体的组件(即多个共享库)由开发计划同步。对于一个完整的应用程序,版本控制用于向外部世界表达整个应用程序已经发生了变化。然而,因为插件被设计成独立于主应用程序的开发,所以将插件版本与应用程序版本同步可能是不可能的。此外,插件 API 可能会也可能不会随着每个应用程序版本而改变。因此,为了确保兼容性,我们必须将插件 API 的版本与主应用程序分开。虽然您可能不希望将来改变插件 API,但是如果您没有预先将查询插件支持的 API 版本的能力作为 API 本身的一部分,您将不得不引入一个突破性的改变来在以后添加这个特性。根据您的需求,这样的突破性改变可能是不可行的,并且您将永远无法添加 API 版本控制。因此,即使最初没有使用,在插件接口中添加一个函数来查询插件支持的 API 版本也应该被认为是一个隐式需求。很明显,API 版本不同于应用程序版本。
实际的 API 版本编号方案可以简单,也可以复杂,视情况而定。简单来说,可以是单个整数。在更复杂的方面,它可以是一个包含几个整数的结构,用于主版本、次版本等。对于 pdCalc,我选择了一个简单的结构,只使用了一个主版本号和一个次版本号。接口代码如下所示:
class Plugin
{
public:
struct ApiVersion
{
int major;
int minor;
};
virtual ApiVersion apiVersion() const = 0;
};
主应用程序在调用插件函数之前简单地调用apiVersion()
函数,以确保插件是兼容的。如果检测到不兼容,会显示一条错误消息,并且可以忽略或拒绝不兼容的插件。或者,主应用程序可以支持多个插件版本,并且ApiVersion
信息通知主应用程序插件支持什么功能。
7.2.6 提供栈
插件接口的一部分包括使插件及其命令可被 pdCalc 发现。pdCalc 插件接口的另一部分包括使 pdCalc 功能的必要部分对插件可用。具体来说,新命令的实现需要访问 pdCalc 的栈。
正如我们在开发核心命令时所看到的,命令只需要对栈进行非常基本的访问。具体来说,他们需要能够将元素推到栈上,从栈中弹出元素,并可能检查栈中的元素(以实现前提条件)。我们让这个功能对核心命令可用的策略是将Stack
类实现为一个单独的类,带有一个公共接口,包括 push、pop 和 inspection 成员函数。然而,这种设计无法扩展到插件命令,因为它违反了 C++ 插件的两条规则。也就是说,我们当前的接口不符合 C 链接(栈提供了一个 C++ 类接口),当前的检查函数通过 STL vector
返回栈元素。
这个问题的解决方法很简单。我们只需在栈中添加一个新的接口(最好是在一个特别指定的头文件中),该接口由一组全局(在pdCalc
名称空间之外)extern "C"
函数组成,这些函数在 C 链接和 C++ 类链接(又是适配器模式)之间进行转换。回想一下,由于Stack
类是作为单例实现的,插件和全局帮助函数都不需要拥有Stack
引用或指针。助手函数通过Instance()
函数直接访问Stack
。我选择在单独的StackPluginInterface.m.cpp
模块接口文件中实现以下五个功能:
export module pdCalc.stackinterface;
extern "C" void StackPush(double d, bool suppressChangeEvent);
extern "C" double StackPop(bool suppressChangeEvent);
extern "C" size_t StackSize();
extern "C" double StackFirstElement();
extern "C" double StackSecondElement();
为了简单起见,由于我的示例插件不需要比顶部两个元素更深入地访问栈,所以我只创建了两个检查函数,StackFirstElement()
和StackSecondElement()
,用于获取栈的顶部两个元素。如果需要,可以实现将栈元素返回到任意深度的函数。为了维护extern "C"
链接,这样一个函数的实现者需要记住使用一个double
的原始数组,而不是 STL vector
。
前面五个函数的完整、简单的实现出现在StackPluginInterface.cpp
文件中。例如,StackSize()
函数的实现如下所示:
size_t StackSize()
{
return pdCalc::Stack::Instance().size();
}
7.3 问题 2:加载插件
如前所述,插件是特定于平台的,插件的加载本质上需要特定于平台的代码。在本节中,我们将考虑两个主题。首先,我们将讨论加载库和它们各自的符号所必需的特定于平台的代码。这里,我们将关注两个平台接口:POSIX (Linux、UNIX、Mac OS X)和 win32 (MS Windows)。其次,我们将探索一种设计策略,以减轻由于使用特定于平台的代码而导致的源代码混乱。
7.3.1 特定平台插件加载
为了使用插件,我们只需要三个特定于平台的函数:一个打开共享库的函数,一个关闭共享库的函数,一个从打开的共享库中提取符号的函数。表 7-1 按平台列出了这些函数及其相关的头文件。让我们看看这些函数是如何使用的。
表 7-1
不同平台的插件功能
| |可移植性操作系统接口
|
win32
|
| — | — | — |
| 页眉 | dl fcn . h .- | windows.h |
| 加载库 | 运行中() | LoadLibrary() |
| 关闭库 | dlclose() | 免费库() |
| 获取库符号 | dlsym() | GetProcAddress() |
7.3.2 加载、使用和关闭共享库
使用插件的第一步是要求运行时系统打开库,并使其可导出的符号对当前的工作程序可用。每个平台上的 open 命令都需要打开共享库的名称(POSIX 还需要一个标志来指定所需的符号绑定,可以是惰性的,也可以是立即的),它返回一个不透明的库句柄,用于在后续的函数调用中引用该库。在 POSIX 系统上,句柄类型是一个void*
,而在 win32 系统上,句柄类型是一个HINSTANCE
(经过一些分解后,它是一个void*
的 typedef)。例如,下面的代码在 POSIX 系统上打开一个插件库libPlugin.so
:
void* handle = dlopen("libPlugin.so", RTLD_LAZY);
其中,RTLD_LAZY
选项只是告诉运行时系统执行惰性绑定,在执行引用符号的代码时解析符号。替代选项是RTLD_NOW
,在dlopen()
返回之前解析库中所有未定义的符号。如果打开失败,则返回空指针。一个简单的错误处理方案跳过从空插件加载任何功能,警告用户打开插件失败。
除了不同的函数名之外,打开插件的主要平台特定的差异是不同平台采用的规范命名约定。例如,在 Linux 上,共享库以lib
开头,并有一个.so
文件扩展名。在 Windows 上,共享库(通常称为动态链接库,简称 dll)没有特定的前缀和一个.dll
文件扩展名。在 Mac OS X 上,共享库通常以lib
开头,扩展名为.dylib
。本质上,这种命名约定只在两个地方有关系。首先,构建系统应该为各自的平台创建具有适当名称的插件。其次,打开插件的调用应该使用正确的格式指定名称。由于插件名称是在运行时指定的,我们需要确保插件名称是由提供插件的用户正确指定的。
一旦插件被打开,我们需要从共享库中导出符号来调用插件中包含的函数。这个导出是通过调用dlopen()
或LoadLibrary()
(取决于平台)来完成的,这两者都使用插件函数的字符串名称将插件函数绑定到函数指针。然后,通过这个获得的函数指针,在主应用程序中间接调用绑定的插件函数。
为了绑定到共享库中的一个符号,我们需要有一个插件句柄(打开一个插件的返回值),要知道我们要调用的插件中的函数名,要知道我们要调用的函数的签名。对于 pdCalc,我们需要调用的第一个插件函数是AllocPlugin()
来分配嵌入的Plugin
类(见 7.2.3 节)。因为这个函数被声明为插件接口的一部分,所以我们知道它的名字和签名。举个例子,在 Windows 上,对于一个已经加载的由HINSTANCE handle
指向的插件,我们用下面的代码将插件的AllocPlugin()
函数绑定到一个函数指针:
// function pointer of AllocPlugin's type:
extern "C" { typedef void* (*PluginAllocator)(void); }
// bind the symbol from the plugin
auto alloc = GetProcAddress(handle, "AllocPlugin");
// cast the symbol from void* (return of GetProcAddress)
// to the function pointer type of AllocPlugin
PluginAllocator allocator{ reinterpret_cast<PluginAllocator>(alloc) };
随后,插件的Plugin
特殊化由以下内容分配:
// only dereference if the function was bound properly
if(allocator)
{
// dereference the allocator, call the function,
// cast the void* return to a Plugin*
auto p = static_cast<Plugin*>((*allocator)());
}
具体的Plugin
现在可以通过抽象的Plugin
接口使用(例如,加载插件命令,查询支持的插件 API)。
在插件解除分配时,需要一个类似的代码序列来绑定和执行插件的DeallocPlugin()
函数。感兴趣的读者可以参考 GitHub 资源库中特定于平台的代码来了解详细信息。记住,在释放插件之前,由于插件分配的命令驻留在主应用程序的内存中(但是必须在插件中回收),所以在释放所有命令之前,插件不能关闭。驻留在主应用程序内存空间的插件命令的例子有CommandFactory
中的命令原型和CommandManager
中撤销/重做栈上的命令。
因为一个插件是一个获得的资源,我们应该在用完它的时候释放它。这个动作在 POSIX 平台上通过调用dlclose()
来执行,在 win32 平台上通过调用FreeLibrary()
来执行。例如,POSIX 系统的以下代码关闭了用dlopen()
打开的共享库(handle
):
// only try to close a non-null library
if(handle) dlclose(handle);
既然我们已经讨论了特定于平台的打开、使用和关闭插件的机制,我们将注意力转向一种设计策略,这种策略减轻了使用多平台源代码所固有的复杂性。
7.3.3 多平台代码的设计
对于任何软件项目来说,跨平台的可移植性都是一个值得称赞的目标。然而,在维护可读代码库的同时实现这一目标需要大量的预先考虑。在这一节中,我们将研究一些在保持可读性的同时实现平台可移植性的设计技术。
显而易见的解决方案:库
对于可移植性问题,显而易见的(也是首选的)解决方案是使用一个为您抽象平台依赖性的库。在任何开发场景中使用高质量的库总是可以节省您设计、实现、测试和维护项目所需功能的精力。使用库进行跨平台开发还有一个额外的好处,就是将特定于平台的代码隐藏在独立于平台的 API 后面。当然,这样的 API 允许您维护一个可以跨多个平台无缝工作的单一代码库,而不用在源代码中添加预处理指令。尽管我们在第六章中没有明确讨论这些优点,但是 Qt 的工具包抽象为构建 GUI 提供了一个平台无关的 API,否则这将是一个平台相关的任务。在 pdCalc 中,我们使用 Qt 构建了一个可以在 Windows 和 Linux 上编译和执行的 GUI(可能还有 OS X,尽管我还没有验证这个事实),而不需要在平台之间修改任何一行源代码。
唉,显而易见的解决方案并不总是可用的。不将库合并到项目中有许多原因。首先,许多库不是免费的,一个库的成本可能高得令人望而却步,尤其是如果许可证除了开发费之外还有使用费的话。第二,库的许可可能与项目的许可不兼容。例如,也许您正在构建一个封闭的源代码,但是唯一可用的库有一个不兼容的开源许可证(反之亦然)。第三,库经常没有源代码。缺少源代码使得扩展库的功能变得不可能。第四,您可能需要对库的支持,但是供应商可能不提供任何支持。第五,库可能会附带与您的升级周期不兼容的升级周期。第六,一个库可能与你的工具链不兼容。最后,对于您正在寻找的功能,库可能根本不存在。因此,虽然使用库通常是实现可移植性的第一选择,但是有足够多的使用库的反例值得讨论如何在没有库的情况下实现可移植性。
原始预处理器指令
使用原始预处理器指令无疑是尝试实现跨平台代码的第一种方法。几乎所有编写过可移植代码的人都是这样开始的。简单地说,平台相关代码出现的任何地方,特定于平台的部分都被预处理器#ifdef
指令所包围。让我们以 Linux 和 Windows 中共享库的运行时加载为例:
#ifdef POSIX
void* handle = dlopen("libPlugin.so", RTLD_LAZY);
#elif WIN32
HINSTANCE handle = LoadLibrary("Plugin.dll");
#endif
不要忘记头文件周围的预处理器指令:
#ifdef POSIX
#include <dlfcn.h>
#elif WIN32
#include <windows.h>
#endif
对于少数平台或极少数实例,使用原始预处理器指令是可以接受的。然而,这种技术扩展性很差。一旦平台数量或需要平台相关代码的代码位置数量增加,使用原始预处理器指令很快就会变得一团糟。代码变得难以阅读,并且在添加新平台时找到所有依赖于平台的位置变成了一场噩梦。即使在一个中等规模的项目中,在代码中散布#ifdef
很快就变得站不住脚了。
(稍微)更聪明的预处理器指令
在平台 API 名称不同但函数调用参数相同的情况下(比您想象的更常见,因为相似的功能需要相似的定制,这不足为奇),我们可以更聪明地使用预处理器。我们可以创建平台相关的宏名,并在一个集中的位置定义它们,而不是将预处理指令放在每个平台相关的函数调用和类型声明的位置。这个想法用一个例子更好解释。让我们来看看关闭 Linux 和 Windows 上的共享库:
// some common header defining all platform dependent analogous symbols
#ifdef POSIX
#define HANDLE void*
#define CLOSE_LIBRARY dlclose
#elif WIN32
#define CLOSE_LIBRARY FreeLibrary
#define HANDLE HINSTANCE
#endif
// in the code, for some shared library HANDLE handle
CLOSE_LIBRARY(handle);
这种技术比在每次函数调用时都洒上#ifdef
的幼稚方法要干净得多。然而,它受到严格的限制,只能处理具有相同参数的函数调用。显然,我们在调用点仍然需要一个#ifdef
来打开一个共享库,因为 POSIX 调用需要两个参数,而 Windows 调用只需要一个。当然,有了 C++ 的抽象能力,我们可以做得更好。
构建系统解决方案
一个有趣的想法是将特定于平台的代码分成特定于平台的源文件,然后使用构建系统根据平台选择正确的文件。让我们考虑一个例子。将所有特定于 Unix 的代码放在名为UnixImpl.cpp
的文件中,将所有特定于 Windows 的代码放在名为WindowsImpl.cpp
的文件中。在每个相应的平台上,编写构建脚本,只编译适当的特定于平台的文件。使用这种技术,不需要平台预处理器指令,因为任何给定的源文件只包含一个平台的源代码。
前面的方案有两个明显的缺点。首先,只有在所有平台上的所有特定于平台的文件之间保持与您自己的源代码相同的接口(例如,函数名、类名、参数列表)时,该方法才有效。这个壮举说起来容易做起来难,特别是如果你有独立的团队在每个平台上工作和测试。使问题复杂化的是,因为编译器在任何给定时间仅看到单个平台的代码,所以没有语言机制(例如,类型系统)来实施这些跨平台接口约束。其次,实现跨平台兼容性的机制对于任何在单一平台上检查源代码的开发人员来说都是完全不透明的。在任何一个平台上,许多依赖于平台的源文件中只有一个有效地存在,并且这个源文件不提供其他文件存在的任何提示。当然,后一个问题加剧了前一个问题,因为缺乏跨平台的源代码透明性,加上缺乏对该技术的语言支持,使得维护接口一致性几乎是不可能的。由于这些原因,一个纯粹的构建系统解决方案是难以处理的。
注意到这种技术的缺点,我们必须小心不要把婴儿和洗澡水一起倒掉,因为我们最终解决方案的核心在于预处理程序和构建系统解决方案并列的语言支持机制。这种设计技术将在下一节中讨论。
平台工厂功能
将预处理器宏分散到代码中需要特定于平台的功能的地方,类似于使用整数标志和switch
语句来执行特定于类型的代码。并非巧合的是,这两个问题有相同的解决方案,即构建一个抽象的类层次结构,并通过多态性执行特定的功能。
我们将分两步构建设计通用跨平台架构的解决方案。首先,我们将设计一个处理动态加载的平台层次结构。其次,我们将把这个特定的解决方案扩展成一个框架,将平台依赖性抽象成一个独立于平台的接口。在这两个步骤中,我们将采用一种混合解决方案,通过最少使用特定于平台的预处理器指令,以类型安全的方式利用构建系统。在这个过程中,我们会遇到一个重要的设计模式,抽象工厂。让我们从检查插件的平台无关的动态加载开始。
为了解决我们的具体问题,我们首先为一个DynamicLoader
基类定义一个独立于平台的抽象接口。我们的DynamicLoader
只需要做两件事:分配和解除分配插件。因此,基类被简单地定义如下:
class DynamicLoader
{
public:
virtual ~DynamicLoader();
virtual Plugin* allocatePlugin(const string& pluginName) = 0;
virtual void deallocatePlugin(Plugin*) = 0;
};
前面的基类的设计意图是层次结构将由平台特殊化。
注意,接口本身是独立于平台的。平台相关的分配和取消分配是一个实现细节,由该接口的平台特定的派生类通过虚函数来处理。此外,因为每个特定于平台的实现都完全包含在派生类中,所以通过将每个派生类放在一个单独的文件中,我们可以使用构建系统来有选择地只编译与每个平台相关的文件,从而避免在层次结构中的任何地方需要平台预处理器指令。更好的是,一旦分配了一个DynamicLoader
,接口就抽象出插件加载的特定于平台的细节,插件的消费者不需要关心插件加载的细节。装就行了。对于DynamicLoader
的派生类的实现者,编译器可以使用类型信息来加强跨平台的接口一致性,因为每个派生类必须符合抽象基类指定的接口,这对于所有平台都是通用的。该设计在图 7-1 中进行了图示总结。pdCalc 包含的源代码为 POSIX 兼容系统和 Windows 实现了特定于平台的加载器。
前面的设计将特定于平台的细节隐藏在抽象接口之后,减轻了插件消费者理解插件是如何加载的需要。当然,这是假设插件消费者实例化了正确的特定于平台的派生类,这是不能由DynamicLoader
层次结构自动处理的。这里,我们可以使用熟悉的设计模式,即工厂函数,来解决实例化正确的派生类的问题。回想一下,工厂函数是一种将类型创建与实例化的逻辑点分开的模式。
图 7-1
用于独立于平台的插件分配和释放的动态加载器层次结构
在第四章中,我们定义了一个工厂函数,它基于一个string
参数返回一个特定类型的Command
。在这里,工厂函数更加简单。由于我们的层次结构是由平台特殊化的,而不是传入一个string
来选择适当的派生类,我们只是通过使用预处理器指令来进行选择:
unique_ptr<DynamicLoader> dynamicLoaderFactory()
{
#ifdef POSIX
return make_unique<PosixDynamicLoader>();
#elif WIN32
return make_unique<WindowsDynamicLoader>();
#else
return nullptr;
#endif
}
Listing 7-1A dynamic loader factory function
通过将dynamicLoaderFactory()
函数编译成它自己的源文件,我们可以通过在一个源文件中隔离一组预处理器指令来实现独立于平台的插件创建。然后调用工厂函数在需要插件分配或释放的站点返回正确类型的DynamicLoader
。通过让工厂返回一个unique_ptr
,我们不必担心内存泄漏。下面的代码片段说明了DynamicLoader
的独立于平台的用法:
// Question: What platform?
auto loader = dynamicLoaderFactory();
// Answer: Who cares?
auto plugin = (loader ? loader->allocatePlugin(pluginName) : nullptr);
出于 pdCalc 的目的,我们可以停止使用DynamicLoader
层次结构和简单的工厂函数。我们只需要抽象一个平台相关的特性(插件的分配和释放),前面的代码就足够了。然而,我们已经走了这么远,值得再走一步来看看平台独立性的通用实现,它适用于需要许多不同的平台相关特性的情况,即使它不是我们的案例研究特别需要的。
通用平台无关代码的抽象工厂
作为软件开发人员,我们经常面临平台依赖性带来的设计挑战。下面是一个 C++ 开发人员的常见平台特定编程任务的不完整列表:插件加载、进程间通信、文件系统导航(在 C++17 中标准化)、图形、线程(在 C++11 中标准化)、持久设置、二进制序列化、sizeof()
内置数据类型、定时器(在 C++11 中标准化)和网络通信。这个列表中的大部分功能(如果不是全部的话)都可以通过 boost 或 Qt 之类的库中独立于平台的 API 获得。对我个人来说,引起最大麻烦的平台特定特性是不起眼的目录分隔符(POSIX 系统上的'/'
和 Windows 系统上的'\'
)。
假设我们的计算器需要读取、写入和保存持久自定义设置的能力(参见第八章了解为什么这对计算器是必要的)。通常,Linux 系统将设置保存在文本文件中(例如,在 Ubuntu 上,用户设置保存在。在 Windows 系统上,持久设置保存在系统注册表中。实际上,最好的解决方案是使用已经实现了这个抽象的现有库(例如,Qt 的QSettings
类)。出于教学目的,我们将假设没有可用的外部库,我们将检查一个在现有动态加载器旁边添加持久设置(或任何数量的平台相关功能)的设计。我们的重点将放在抽象上,而不是每个平台上设置实现的细节。
简单的解决方案是搭载我们的动态加载器,简单地将必要的设置接口直接添加到DynamicLoader
类中。当然,我们需要将这个类重命名为更通用的名字,比如OsSpecificFunctionality
,并使用LinuxFuntionality
和WindowsFunctionality
这样的派生类。这种方法简单、快速,并且很快难以处理;它与凝聚力是对立的。对于任何相当大的代码,这种技术最终会导致不可控制的膨胀,因此,完全缺乏界面的可维护性。尽管项目有时间压力,但我建议总是避免这种快速解决方案,因为它只会增加您的技术债务,并在未来导致比现在使用适当的解决方案更长的延迟。
我们没有膨胀现有的DynamicLoader
类,而是从它的设计中获得灵感,创建了一个独立的、类似的设置层次,如图 7-2 所示。
同样,我们有在每个独特的平台上实例化特定于平台的派生类的问题。然而,我们没有添加一个额外的settingsLoaderFactory()
函数来镜像现有的dynamicLoaderFactory()
函数,而是寻求一个通用的解决方案,在为平台选择保留单个代码点的同时实现无限的功能扩展。正如所料,我们不是第一个遇到这个特殊问题的程序员,并且已经有了一个解决方案,抽象工厂模式。
图 7-2
独立于平台的持久设置的设置层次结构
根据 Gamma 等人[11]的说法,抽象工厂“提供了一个创建相关或依赖对象系列的接口,而无需指定它们的具体类。”本质上,该模式可以分两步构建:
-
为每个相关对象创建独立的层次结构(族)(例如,动态加载器层次结构和设置层次结构,通过它们的平台依赖性相关)。
-
创建一个专门针对依赖关系(例如平台)的层次结构,为每个系列提供工厂功能。
我发现如果没有具体的例子,前面的抽象很难理解;因此,让我们考虑一下我们在 pdCalc 中试图解决的问题。当我们浏览这个例子时,我们将参考图 7-3 中的(过于复杂的)类图。回想一下,这种抽象的总体目标是创建一个单一的源位置,它能够为创建任意数量的特定于平台的专门化提供一种独立于平台的机制。
正如我们已经看到的,依赖于平台的功能可以抽象成并行、独立的层次结构。这些层次结构使得依赖于平台的实现可以通过多态性通过独立于平台的基类接口来访问。对于 pdCalc,这种模式转化为提供平台不可知的Settings
和DynamicLoader
层次结构来分别抽象持久设置和动态加载。例如,我们可以通过抽象的DynamicLoader
接口多态地分配和释放一个插件,只要系统基于平台实例化正确的底层派生类(PosixDynamicLoader
或WindowsDynamicLoader
)。抽象工厂的这一部分由图 7-3 中的DynamicLoader
层次表示。
图 7-3
应用于 pdCalc 的抽象工厂模式
问题现在简化为基于当前平台实例化正确的派生类。我们不是提供单独的工厂函数来实例化DynamicLoader
和Settings
对象(一种分散的方法,要求每个工厂中有单独的平台#ifdef
,而是创建一个层次结构,提供一个抽象接口来提供创建DynamicLoader
和Settings
对象所必需的工厂函数。然后,这个抽象工厂层次结构(图 7-3 中的PlatformFactory
层次结构)在平台上专门化,这样我们就有了工厂层次结构的平台特定的派生类,它们创建了功能层次结构的平台特定的派生类。该方案将平台依赖性集中到一个工厂函数中,该工厂函数实例化正确的PlatformFactory
专门化。在 pdCalc 的实现中,我选择将PlatformFactory
设为单例,从而将PlatformFactory
的工厂函数“隐藏”在Instance()
函数中。
抽象工厂模式可能仍然没有太多意义,所以让我们来看一些示例代码,以自顶向下的方式查看抽象。最终,抽象工厂模式使我们能够在 pdCalc 中编写以下独立于平台的高级代码:
// PlatformFactory Instance returns either a PosixFactory or a
// WindowsFactory instance (based on the platform), which in turn
// creates the correct derived DynamicLoader
auto loader = PlatformFactory::Instance().createDynamicLoader();
// The correctly instantiated loader provides platform specific
// dynamic loading functionality polymorphically through a platform
// independent interface
auto plugin = loader->allocatePlugin(pName);
// ...
loader->deallocatePlugin(plugin);
// Same principle for settings...
auto settings = PlatformFactory::Instance().createSettings();
settings->readSettingsFromDisk();
// ...
settings->commitSettingsToDisk();
向下钻取,我们将检查的第一个函数是PlatformFactory
的Instance()
函数,它根据平台返回一个PosixFactory
或一个WindowsFactory
。
PlatformFactory& PlatformFactory::Instance()
{
#ifdef POSIX
static PosixFactory instance;
#elif WIN32
static WindowsFactory instance;
#endif
return instance;
}
前面的函数做了一些微妙但聪明的事情,这是一个值得了解的技巧。从客户端的角度来看,PlatformFactory
看起来像一个普通的单例类。一个调用Instance()
函数,返回一个PlatformFactory
引用。然后,客户端使用PlatformFactory
的公共接口,就像使用任何其他单例类一样。然而,因为Instance()
成员函数返回一个引用,我们可以自由地多态使用实例。因为PosixFactory
和WindowsFactory
都是从PlatformFactory
派生的,所以被实例化的instance
变量是与实现中的#ifdef
所定义的平台相匹配的专门化。我们巧妙地对类的用户隐藏了一个实现细节,抽象工厂模式的机制。除非客户注意到PlatformFactory
中的工厂函数是纯虚拟的,否则他们可能不会意识到他们正在使用面向对象的层次结构。当然,我们的目标并不是在一个邪恶的阴谋中向用户隐藏任何东西来掩盖实现。更确切地说,信息隐藏的这种使用被用来减少PlatformFactory
的客户端的认知负担。
我们接下来检查PosixFactory
和WindowsFactory
类中createDynamicLoader()
函数的简单实现(注意函数的协变返回类型):
unique_ptr<DynamicLoader> PosixFactory::createDynamicLoader()
{
return make_unique<PosixDynamicLoader>();
}
unique_ptr<DynamicLoader> WindowsFactory::createDynamicLoader()
{
return make_unique<WindowsDynamicLoader>();
}
之前,我们简单地用类层次结构替换了动态加载器工厂函数(见清单 7-1 ),用多态替换了平台#ifdef
。由于只有一部分功能依赖于平台,用抽象工厂替换工厂功能无疑是大材小用。然而,对于我们的例子,我们有独立的DynamicLoader
和Settings
家族,它们都依赖于相同的平台标准(原则上,我们可以有任意数量的这种层次),抽象工厂模式允许我们将平台依赖性集中在一个位置(这里,在PlatformFactory
的Instance()
函数中),而不是分散在多个独立的工厂函数中。从维护的角度来看,价值主张类似于偏好多态来切换语句。
拼图的最后一块是DynamicLoader
和Settings
层次结构的实现。幸运的是,这些实现与 7.3.3 节中概述的思想是相同的,我们不需要在这里重复它们的实现。使用抽象工厂模式确实不会给平台相关功能的实现增加任何固有的复杂性。该模式只是通过单个工厂层次结构而不是一系列工厂函数来增加这些类实例化的机制。
在 pdCalc 的存储库中的源代码中,不存在Settings
层次结构(或其关联的readSettingsFromDisk()
和PlatformFactory
中的commitSettingsToDisk()
函数)实现,因为 pdCalc 不需要持久设置抽象。Settings
层次结构仅仅是作为一个看似合理的例子制造出来的,用来具体展示抽象工厂模式的机制和相关性。也就是说,我确实选择在 pdCalc 的代码中单独包含一个完整的抽象平台工厂实现,只是为了说明抽象工厂模式的实际实现,即使更简单的单个工厂函数对于生产代码来说已经足够了,也是更好的选择。
7.4 问题 3:改装 pdCalc
我们现在转向最后一个插件问题,即改造已经开发的类和接口,以适应动态添加计算器功能。这个问题不是关于插件管理的。相反,我们在这里解决的问题是扩展 pdCalc 的模块接口以接受插件特性。本质上,第 7.2 节定义了如何发现插件中的命令和按钮,本节描述了如何将这些新发现的命令合并到 pdCalc 中。
注入命令
让我们首先创建一个接口来实现新发现的插件命令的注入。回想一下第四章,当应用程序启动时,核心命令被加载到CommandFactory
中。首先,主应用程序调用RegisterCoreCommands()
函数。其次,在这个函数中,为每个核心命令调用CommandFactory
类的registerCommand()
函数,用CommandFactory
注册命令的名称和命令原型。在 7.2 节中,我们开发了一个从插件导出命令名和命令原型的接口。显然,要注册这些插件命令,我们只需扩展命令调度器模块的接口,使其包含现有的registerCommand()
函数,这样负责加载插件的代码也可以注册它们的命令。因为已经导出了commandDispatcher
模块的CommandFactory
分区(为了导出RegisterCoreCommands()
函数),所以只需从CommandFactory
分区导出CommandFactory
类,就可以实现对命令分派器模块的所需接口更改。真的就这么简单。
事后看来,导出CommandFactory
类和RegisterCoreCommands()
函数的需求解释了为什么将RegisterCoreCommands()
放在了CommandFactory
分区中。最初,我在CoreCommands
分区中实现了RegisterCoreCommands()
,但是当我意识到CoreCommands
分区不需要从commandDispatcher
模块中导出时,我移动了它。
7.4.2 向 GUI 添加插件按钮
回想一下,在本节开始时,我们概述了在为插件改装 pdCalc 时需要解决的两个问题。我们刚刚解决的第一个问题是,如何在插件加载后将插件命令添加到CommandFactory
中。由于我们已经编写了必要的函数,只需要扩展模块定义的公共接口,所以解决方案变得非常简单。第二个问题涉及改装 pdCalc,以便能够在 GUI 中添加对应于插件命令的按钮。
根据我们的命令调度器的设计,一旦命令被注册,它就可以被任何引发一个commandEntered()
事件的用户界面执行,事件的参数是命令的名称。因此,对于 CLI,用户可以通过键入插件名称来执行插件命令。也就是说,插件命令一经注册,CLI 就可以立即访问它们。当然,让一个插件命令在 GUI 中可访问要稍微复杂一些,因为必须为每个发现的插件命令创建一个可以引发commandEntered()
事件的按钮。
在第 7.2 节中,我们定义了一个用于标记CommandButton
的接口。每个插件都提供了一个PluginButtonDescriptor
来定义主要命令标签、次要命令标签以及与这些标签相关联的底层命令。因此,为了添加一个与插件命令相对应的新 GUI 按钮,我们必须简单地扩展 GUI 的MainWindow
类的接口,以包括一个基于按钮标签添加按钮的功能:
class MainWindow : public QMainWindow, public UserInterface
{
public:
// Existing interface plus the following:
void addCommandButton(const string& dispPrimaryCmd,
const string& primaryCmd, const string& dispShftCmd,
const string& shftCmd);
};
当然,这个功能也需要基于一些合适的算法来布局按钮。我的简单算法只是将四个按钮从左到右排成一行。
与CommandRegistry
的registerCommand()
函数不同,addCommandButton()
不是MainWindow
类预先存在的公共函数。因此,我们必须添加并实现这个新功能。十有八九,GUI 的模块化实现在 GUI 模块的某个地方已经有了类似的功能,因为已经需要这个功能来创建核心命令的按钮。因此,addCommandButton()
函数的实现可能只是将这个调用从MainWindow
转发到适当的内部 GUI 类,而这个函数可能已经存在了。由于 Qt 目前的局限性,我们没有使用 C++ 模块来封装 GUI。将addCommandButton()
添加到MainWindow
类的公共部分足以扩展逻辑模块。
7.5 合并插件
到目前为止,我们已经讨论了 C++ 插件的指南、插件接口、插件命令内存管理、加载和卸载插件、接口后抽象平台相关代码的设计模式,以及改进 pdCalc 以支持插件命令和 GUI 注入。然而,我们还没有讨论任何发现插件的机制,实际上从磁盘加载和卸载插件,管理插件的生命周期,或者将插件功能注入 pdCalc。这些操作是由应用程序的PluginLoader
类和main()
函数执行的,现在将对这两者进行描述。
加载插件
加载插件是由一个PluginLoader
类完成的。PluginLoader
负责查找插件动态库文件,将插件加载到内存中,并按需为 pdCalc 提供具体的Plugin
专门化。PluginLoader
还负责在适当的时候释放插件资源。正如我们之前看到的,一个好的设计会通过 RAII 实现自动释放。
加载插件的第一步是确定应该加载哪些插件以及何时加载。事实上,只有两个可行的选择来回答这个问题。当程序启动时,插件由 pdCalc 自动加载(例如,配置文件中指定的文件或特定目录中的所有 dll),或者插件由用户直接请求按需加载。当然,这些选项并不相互排斥,并且可以设计一个包含这两个选项的PluginLoader
类,可能具有让用户指示将来应该自动加载哪些手动加载的插件的能力。插件如何加载没有对错之分。这个决定必须由计划的需求来解决。
为了简单起见,我选择实现一个插件加载器,在 pdCalc 启动时自动加载插件。PluginLoader
通过读取由多行文本组成的 ASCII 配置文件来找到这些插件,每一行都单独列出了插件的文件名。配置文件被任意命名为plugins.pdp
,这个文件必须位于当前的可执行路径中。在plugins.pdp
中列出的插件文件可以使用相对或绝对路径来指定。更复杂的插件加载器实现可能会将插件文件的位置存储在操作系统–
特定的配置位置(例如,Windows 注册表)中,并使用更好的文件格式,例如 XML 或 JSON。一个好的库,比如 Qt,可以帮助您解析文件,并使用独立于平台的抽象找到特定于系统的配置文件。
正如前面在 7.1.1 节中提到的,插件传统上在不同的平台上有不同的命名约定。对于 pdCalc,我选择要求用户在plugins.pdp
文件中指定每个插件的依赖于平台的文件名。另一种方法是要求用户为每个插件指定一个独立于平台的根名称,并让 pdCalc 在每个平台上重新构建依赖于平台的名称。如果我们选择了后一种方法,我们将能够通过用插件名称构建器层次结构扩展抽象平台工厂来干净地实现这个特性。
考虑到前面的插件加载器设计约束,PluginLoader
接口相当简单:
export module pdCalc.pluginManagement;
export class PluginLoader
{
public:
void loadPlugins(UserInterface& ui, const string& pluginFileName);
const vector<const Plugin*> getPlugins();
};
loadPlugins()
函数将配置文件的名称作为输入,将每个库加载到内存中,并分配每个库的Plugin
类的一个实例。UserInterface
参考仅用于错误报告。当main()
函数准备好注入插件的命令时,getPlugins()
函数被调用来返回加载的Plugin
的集合。当然,loadPlugins()
和getPlugins()
函数可以合并,但是我更喜欢这样的设计,它能让程序员保留对插件加载时间和插件使用的更好的控制。我的PluginLoader
实现使用了一些巧妙的技术,通过 RAII 来管理插件的自动释放。由于这里的实现与设计是正交的,感兴趣的读者可以参考PluginLoader.m.cpp
源文件了解详细信息。
注入功能
已经决定插件应该从配置文件中自动加载,插件加载最合理的位置是在main()
函数中的某个地方(或者由main()
调用的帮助函数)。本质上,这个loadPlugins()
函数只是把我们之前讨论过的所有部分放在一起:加载插件库,加载插件,从插件描述符中提取命令和按钮,并将这些命令和按钮注入到 pdCalc 中。当然,正确的实现也会对插件进行错误检查。例如,错误检查可能包括检查插件 API 版本,确保命令尚未注册,以及确保 GUI 按钮对应于命令工厂中的命令。下面的代码片段是加载插件的函数的框架。它的输入是一个用于报告错误的UserInterface
参考和一个PluginLoader
参考。
void setupPlugins(UserInterface& ui, PluginLoader& loader)
{
loader.loadPlugins(ui, "plugins.pdp");
auto plugins = loader.getPlugins();
for(auto p : plugins)
{
auto apiVersion = p->apiVersion();
// verify plugin API at correct level
// inject plugin commands into CommandFactory - recall
// the cloned command will auto release in the plugin
auto descriptor = p->getPluginDescriptor();
for( auto i : views::iota(0, descriptor.nCommands) )
{
registerCommand(ui, descriptor.commandNames[i],
MakeCommandPtr(descriptor.commands[i]->clone()) );
}
// if gui, setup buttons
auto mw = dynamic_cast<MainWindow*>(&ui);
if(mw)
{
auto buttonDescriptor = p->getPluginButtonDescriptor();
if(buttonDescriptor)
{
for( auto i : views::iota(0, buttonDescriptor->nButtons) )
{
auto b = *buttonDescriptor;
// check validity of button commands
mw->addCommandButton(b.dispPrimaryCmd[i], b.primaryCmd[i],
b.dispShftCmd[i], b.shftCmd[i]);
}
}
}
}
return;
}
在花了很长一章描述如何实现 C++ 插件之后,结局有点虎头蛇尾,因为大多数机制都是在更深的抽象层处理的。当然,这种“乏味”,正如我们在本书中了解到的,只有通过精心的设计才能实现。简单性总是比代码本身所表明的更难实现。在这个高层次的抽象中,如果有任何复杂的东西泄露出来,这肯定意味着一个低劣的设计。
7.6 具体插件
在解释如何将原生 C++ 插件整合到 pdCalc 中的长时间讨论之后,我们终于到了可以实现一个具体插件的时候了。基于我们在第一章中的需求,我们需要编写一个插件,为自然对数、其反指数算法和双曲三角函数添加命令。当然,你可以随意添加包含任何你喜欢的功能的插件。例如,两个有趣的插件可能是概率插件和统计插件。概率插件可以计算排列、组合、阶乘和随机数,而统计插件可以计算平均值、中位数、众数和标准差。然而现在,我们将简单地考虑我们的双曲线自然对数插件的设计和实现。
插件接口
HyperbolicLnPlugin
的实现实际上非常简单。我们将从该类的接口开始,然后一反常态地研究一些实现细节。选择用于进一步检查的代码突出了与原生 C++ 插件相关的特定细节。
HyperbolicLnPlugin
的接口由专门化Plugin
类的类定义和所需的插件分配和解除分配函数给出:
class HyperbolicLnPlugin : public pdCalc::Plugin
{
public:
HyperbolicLnPlugin();
~HyperbolicLnPlugin();
private:
const PluginDescriptor& getPluginDescriptor() const override;
const PluginButtonDescriptor* getPluginButtonDescriptor()
const override;
pdCalc::Plugin::ApiVersion apiVersion() const;
};
extern "C" void* AllocPlugin();
extern "C" void DeallocPlugin(void*);
正如所料,该类实现了Plugin
类中的三个纯虚函数。AllocPlugin()
和DeallocPlugin()
函数有它们明显的实现。AllocPlugin()
简单地返回一个新的HyperbolicLnPlugin
实例,而DeallocPlugin()
函数将其void*
参数转换为Plugin*
,随后在这个指针上调用delete
。注意,根据定义,插件不是主程序的一部分,因此也不应该是pdCalc
名称空间的一部分。因此,在一些位置有明确的名称空间限定。
HyperbolicLnPlugin
类的职责只是按需提供插件描述符,并管理描述符所需对象的生命周期。PluginDescriptor
提供命令名和插件实现的相应的Command
。第 7.6.3 节对这些Command
进行了描述。插件的PluginButtonDescriptor
简单地列出了由PluginDescriptor
定义的Command
的名称以及出现在 GUI 按钮上的相应标签。因为HyperbolicLnPlugin
中的命令都有自然的反转,我们简单地给每个按钮贴上一个前进命令的标签,并把第二个(移位的)命令附加到反转上。我对插件提供的命令使用了明显的标签:sinh
、asinh
、cosh
、acosh
、tanh
、atanh
、ln
和exp
。您是选择ln
作为主服务器,选择exp
作为辅助服务器,还是相反,这只是一个偏好问题。
由于已经讨论过的原因,插件描述符不使用 STL 容器来传输内容。我们通常更喜欢在界面中使用vector
和unique_ptr
来管理资源,但是我们被迫使用原始数组。当然,我们的类提供的封装使我们能够在实现中使用我们想要的任何内存管理方案。对于HyperbolicLnPlugin
,我选择了一个使用string
、unique_ptr
和vector
的复杂的自动内存管理方案。使用 RAII 内存管理方案的优点是,我们可以确保插件在出现异常时不会泄漏内存(即,在构建期间抛出的内存不足异常)。实际上,我不希望计算器在低内存环境中执行,即使是这样,插件分配期间的内存泄漏也不会有多大影响,因为用户在这种情况下的下一个动作可能是重启计算机。因此,回想起来,在构造器中使用裸的new
而在析构函数中使用delete
的更简单的内存管理方案可能会更实用。
7.6.2 源代码依赖倒置
令人惊讶的是,HyperbolicLnPlugin
前面的类声明确实是插件的完整接口。我说令人惊讶是因为,乍一看,人们可能会惊讶于插件的界面与插件提供的功能没有任何关系。当然,这种情况正是应该的。插件提供的计算器功能实际上只是一个实现细节,可以完全包含在插件的实现文件中。
前面的微妙之处,即 pdCalc 只知道插件的接口,而不知道功能本身,不应该被忽略。事实上,这种源代码依赖倒置是插件设计的全部要点。到底什么是源代码依赖倒置,为什么它很重要?要回答这个问题,我们必须先上一堂简短的历史课。
传统上(想想 20 世纪 70 年代的 Fortran),通过简单地编写新的函数和子程序来扩展代码。这种方法的主要设计问题是,要求主程序调用新函数会将主程序绑定到任何扩展的具体接口上。因此,主程序变得依赖于扩展作者突发奇想定义的接口变化。也就是说,每一个新的扩展都定义了一个新的接口,主程序必须遵循这个接口。这种设置非常脆弱,因为主程序需要不断修改才能跟上扩展接口的变化。因为每个新的扩展都需要对主程序的源代码进行独特的修改,所以主程序处理扩展的代码的复杂性随着扩展的数量而线性增长。如果这还不够糟糕的话,添加新功能总是需要重新编译和重新链接主程序。具体来说,想象一个 pdCalc 的设计,每次添加新的插件命令时,都需要修改、重新编译和重新链接 pdCalc 的源代码。
前面的问题可以在没有面向对象编程的情况下通过函数指针和回调来解决,尽管这种方式有些不雅和麻烦。然而,随着面向对象编程的兴起,特别是继承和多态,依赖问题的解决方案在语言支持下以类型安全的方式得到了解决。这些技术使得源代码依赖倒置的普及成为可能。具体来说,源代码依赖倒置声明主程序定义了一个接口(例如,我们在本章中学习的插件接口),所有扩展都必须符合这个接口。在这种策略下,扩展从属于主程序的接口,而不是相反。因此,主程序可以通过插件进行扩展,而无需修改、重新编译或重新链接主程序的源代码。然而,更重要的是,接口的可扩展性是由应用程序而不是其插件决定的。具体来说,pdCalc 提供了Plugin
接口类来定义新功能的添加,但是 pdCalc 从来不知道其扩展的实现细节。一个不符合 pdCalc 接口的插件根本无法注入新的Command
7.6.3 实现双曲线插件的功能
到游戏的这个阶段,我们知道HyperbolicLnPlugin
将通过为每个操作实现一个命令类来提供它的功能。在实现了其中的一些类之后,你会很快注意到插件中的所有命令都是一元命令。不幸的是,基于我们 C++ 插件的第三条规则(假设不兼容对齐),我们不能从UnaryCommand
类继承,而是必须从PluginCommand
类继承。注意,我们的对齐假设甚至排除了通过多重继承使用UnaryCommand
类,我们必须在HyperbolicLnPluginCommand
基类中重新实现一元命令功能。虽然这确实感觉有点重复,但 C++ 插件的规则让我们别无选择(虽然我们可以为一个UnaryPluginCommand
和一个UnaryBinaryCommand
提供源代码,但这些必须用每个插件单独编译)。
因此,我们最终得到了接口类,所有的命令都来自这个接口类:
class HyperbolicLnPluginCommand : public pdCalc::PluginCommand
{
public:
HyperbolicLnPluginCommand() = default; // ???? see sidebar
explicit HyperbolicLnPluginCommand(const HyperbolicLnPluginCommand&
rhs);
virtual ~HyperbolicLnPluginCommand() = default;
void deallocate() override;
protected:
const char* checkPluginPreconditions() const noexcept override;
private:
void executeImpl() noexcept override;
void undoImpl() noexcept override;
HyperbolicLnPluginCommand* clonePluginImpl() const noexcept override;
virtual HyperbolicLnPluginCommand* doClone() const = 0;
virtual double unaryOperation(double top) const = 0;
double top_;
};
与UnaryCommand
类一样,HyperbolicLnPluginCommand
类实现了纯虚拟的executeImpl()
和undoImpl()
命令,将命令操作委托给纯虚拟的unaryOperation()
函数。此外,HyperbolicLnPluginCommand
类实现了checkPluginPreconditions()
函数,以确保在调用命令之前,栈中至少有一个数字。该函数是protected
,因此如果子类必须实现任何额外的前提条件,但仍然调用基类的checkPluginPreconditions()
来进行一元命令前提条件检查,它可以直接覆盖前提条件函数。
deallocate()
和clonePluginImpl()
函数有明显的实现,但在插件中扮演着关键的角色。deallocate()
函数简单地实现为
void HyperbolicLnPluginCommand::deallocate()
{
delete this;
}
回想一下,deallocate()
函数的作用是在插件的编译单元中强制释放插件命令的内存。当持有命令的unique_ptr
被销毁时,通过CommandDeleter()
函数调用。
clonePluginImpl()
函数由下式给出
HyperbolicLnPluginCommand*
HyperbolicLnPluginCommand::clonePluginImpl() const noexcept
{
HyperbolicLnPluginCommand* p;
try
{
p = doClone();
}
catch(...)
{
return nullptr;
}
return p;
}
该函数的唯一目的是调整插件命令的克隆,以确保异常不会跨越插件和主应用程序之间的内存边界。
完成HyperbolicLnPlugin
剩下的工作就是为插件中需要的每个数学运算子类化HyperbolicLnPluginCommand
,并实现剩下的几个纯虚函数(unaryOperation()
、doClone()
和helpMessageImpl()
)。一旦我们达到这一点,这些函数的实现与第四章的一元函数的实现没有什么不同。感兴趣的读者可以参考HyperbolicLnPlugin.cpp
中的源代码了解详情。
Modern C++ Design Note: Defaulting Special Functions
有些成员函数是特殊的,如果你不提供实现,编译器可以为你提供一个。唯一允许这样做的成员函数是默认构造器、复制构造器、移动构造器、默认析构函数、复制赋值、移动赋值和比较操作。编译器可以自动提供这些操作,为什么我们还需要= default
语法?实际上,有两个主要原因。首先,有时默认实现会被抑制。例如,只有在没有提供其他构造器的情况下,才会自动生成默认构造器。因此,如果您提供了替代构造器,但仍希望编译器自动为您生成默认构造器,您必须手动指示它这样做。default
关键字有用的第二个原因是为了清晰。以前,在编译器会为您实现一个特殊成员函数的情况下,您可以让它安静地实现(可能会让新手感到困惑),或者手动实现它,不必编写编译器会为您编写的相同代码。新语法通过声明特殊函数的存在为您提供了明确的能力,但通过= default
实现它也是高效的。
7.7 后续步骤
在对 C++ 插件进行了相当长时间的讨论之后,随着双曲三角函数和自然对数插件的实现,我们已经完成了第一章中提出的 pdCalc 的要求。正如最初描述的那样,计算器已经完成,我们准备发布了!然而,作为有经验的软件开发人员,我们知道任何“成品”都只是客户要求新特性之前的一个暂时的里程碑。下一章处理这种确切的情况,我们将修改我们的设计,以纳入计划外的扩展。
八、新需求
这是一个美丽的周一早晨,你刚刚在轻松的周末步入工作岗位。毕竟你周五刚做完 pdCalc,现在准备出货了。在你坐下来喝咖啡之前,你的项目经理走进你的办公室说,“我们还没完。客户要求一些新功能。”
前面的场景在软件开发中太常见了。虽然新功能可能不会在上线时被请求,但是在您完成大部分设计和实现之后,新功能几乎不可避免地会被请求。因此,我们应该尽可能实际地进行防御性开发,以预测可扩展性。我说尽可能实际地防御,而不是尽可能地防御,因为过于抽象的代码和过于具体的代码一样对开发有害。通常,如果需要的话,简单地重写一个不灵活的代码比毫无理由地维护一个高度灵活的代码要容易得多。在实践中,我们寻求在代码的简单性、可维护性和一定程度的可扩展性之间取得平衡。
在这一章中,我们将探索修改我们的代码来实现超出原始需求设计的特性。本章中介绍的新功能的讨论范围从完整的设计和实现到仅为自我探索而提出的建议。让我们从两个扩展开始,从需求一直到实现。
8.1 完全设计的新功能
在这一节中,我们将研究两个新特性:计算器的批处理操作和存储过程的执行。我们将从批量操作开始。
批量操作
对于那些不熟悉这个术语的人,让我们来定义批处理操作。任何程序的批处理操作都只是程序的执行,从开始到结束,一旦程序启动,没有用户的交互。大多数桌面程序不在批处理模式下运行。然而,批处理操作在编程的许多分支中仍然非常重要,例如科学计算。对于那些受雇于大公司的人来说,可能更感兴趣的是,你的工资单可能是由一个程序以批处理方式运行的。
实话实说吧。pdCalc 的批处理操作,除了测试之外,并不是一个非常有用的扩展。我之所以包括它,主要是因为它演示了如何简单地扩展一个设计良好的 CLI 来添加批处理模式。
回想一下第五章,pdCalc 的 CLI 具有以下公共接口:
class Cli : public UserInterface
{
public:
Cli(istream& in, ostream& out);
~Cli();
void execute(bool suppressStartupMessage = false, bool echo = false);
};
要使用 CLI,该类是用cin
和cout
作为参数构造的,而execute()
是用空参数调用的:
Cli cli{cin, cout};
// setup other parts of the calculator
cli.execute();
我们如何修改Cli
类来启用批处理操作?令人惊讶的是,我们根本不需要修改这个类的代码!按照设计,CLI 本质上是一个解析器,它只是从输入流中提取空格分隔的字符输入,通过计算器处理数据,并生成字符输出到输出流。因为我们预先考虑过不要将这些输入和输出流硬编码为cin
和cout
,所以我们可以通过将输入和输出流转换为文件流来将 CLI 转换为批处理处理器,如下所示:
ifstream fin{inputFile};
ofstream fout{outputFile};
Cli cli{fin, fout};
// setup other parts of the calculator
cli.execute(true, true);
其中inputFile
和outputFile
是可以通过 pdCalc 的命令行参数获得的文件名。回想一下,execute()
函数的参数只是抑制了启动横幅,并将命令回显到输出中。
是的,确实是这样(但是参见main.cpp
中的一些实现技巧)。我们的 CLI 最初是这样构建的,只需更改其构造器参数,就可以将其转换为批处理程序。当然,你可以争辩说,作为作者,我是故意这样设计Cli
类的,因为我知道计算器会以这种方式扩展。然而,事实是,我只是用流输入而不是硬编码输入来构造我的所有 CLI 接口,因为这种设计使 CLI 更加灵活,几乎没有额外的认知负担。
在离开这一部分之前,我将很快注意到,实际情况是,在操作系统的帮助下,pdCalc 的 CLI 已经有了批处理模式。通过在命令行重定向输入和输出,我们可以获得相同的结果:
my_prompt> cat inputFile | pdCalc --cli > outputFile
对于 Windows,只需用 Windows type
命令替换 Linux cat
命令。
存储过程
无可否认,向 pdCalc 添加批处理模式是一个有点做作的例子。增加的功能并不十分有用,代码变化也很小。在这一节中,我们将研究一个更有趣的特性扩展——存储过程。
什么是存储过程?在 pdCalc 中,存储过程是在当前栈上操作的存储的、可重复的操作序列。存储过程提供了一种技术,通过从现有的计算器原语创建用户定义的函数来扩展计算器的功能。您可以将执行存储过程视为类似于为计算器运行一个非常简单的程序。理解这个概念最简单的方法是考虑一个例子。
假设你需要经常计算三角形的斜边。对于图 8-1 中描绘的直角三角形,我们可以用毕达哥拉斯公式)计算出斜边的长度 c 。
图 8-1
直角三角形
假设我们有一个边长为 a = 4、 b = 3 的三角形,这些值被输入到 pdCalc 的栈中。在 CLI 中,您会看到以下内容:
Top 2 elements of stack (size = 2):
2: 3
1: 4
为了计算这个三角形的 c ,我们将执行下面的指令序列:dup * swap dup * + 2 root
。按 enter 键后,最终结果将是
Top element of stack (size = 1):
1: 5
如果一次输入一个命令,那么每次按 enter 键时,我们都会看到中间结果栈。如果我们在一行中输入所有命令,然后按 enter 键,pdCalc 将在显示最终结果之前显示每个中间栈。当然,请注意,这个命令序列不是唯一的。使用例如命令序列2 pow swap 2 pow + 2 root
可以获得相同的结果。
如果你和我一样,如果你不得不用 pdCalc 反复计算斜边,你可能会想在第一次手工计算后自动操作。这正是存储过程所允许的。自动化不仅节省时间,而且更不容易出错,因为封装了许多连续命令的存储过程可以被编写、测试和随后重用。如果操作可以由 pdCalc 原语(包括插件函数)组装,存储过程可以扩展计算器的功能,以计算简单的公式,而无需编写任何 C++ 代码。现在我们只需要设计和实现这个新特性。
用户界面
pdCalc 既有 GUI 又有 CLI,因此添加任何面向用户的功能都需要对这两个用户界面组件进行一些修改。对于存储过程,对用户界面的修改非常小。首先,存储过程只是一个包含有序的 pdCalc 指令序列的文本文件。因此,用户可以使用任何纯文本编辑器创建存储过程。因此,除非您想为存储过程文本编辑器提供语法突出显示,否则存储过程的用户界面只能从 CLI 和 GUI 执行。
让我们首先解决在 CLI 中合并存储过程的问题。如前所述,存储过程只是文件系统中的文本文件。回想一下,CLI 的工作方式是将空格分隔的输入标记化,然后通过引发事件将每个标记单独传递给命令调度器。因此,访问存储过程的一个简单方法就是将存储过程文件的名称传递给 CLI。然后,这个文件名将像任何其他命令或数字一样进行标记化,并传递给命令调度器进行处理。为了确保文件名被命令调度器解释为存储过程而不是命令,我们只需在文件名前面加上符号proc:
,并更改命令调度器的解析器。例如,对于一个名为hypotenuse.psp
的存储过程,我们可以向 CLI 发出命令proc:hypotenuse.psp
。我采用文件扩展名psp
作为 pdCalc 存储过程的简写。自然,文件本身是一个普通的 ASCII 文本文件,包含一系列用于计算直角三角形斜边的命令,如果您愿意,可以使用txt
扩展名。
回想一下,GUI 被设计为像 CLI 一样将命令传递给命令调度器。因此,为了使用存储过程,我们添加一个按钮,打开一个对话框来导航文件系统以找到存储过程。一旦选择了存储过程,我们就在文件名前面加上proc:
并引发一个CommandEntered
事件。显然,您可以让您的存储过程选择对话框尽可能的漂亮。我选择了一个简单的设计,允许在一个可编辑的组合框中输入文件名。为了便于使用,组合框中预先填充了当前目录中扩展名为.psp
的任何文件。
对命令调度器模块的更改
下面是CommandInterpreter
的executeCommand()
函数的简短列表,包括解析存储过程所需的逻辑。代码中省略的部分出现在第 4.5.2 节。
void CommandInterpreter::CommandInterpreterImpl::executeCommand(const
string& command)
{
string_view sv{command};
// handle numbers, undo, redo, help in nested if
// ...
else if( command.size() > 6 && sv.starts_with("proc:") )
{
string filename{sv.substr(5, command.size() - 5)};
handleCommand( MakeCommandPtr<StoredProcedure>(ui_, filename) );
}
// else statement to handle Commands from CommandFactory
// ...
return;
}
从前面的代码清单中,我们看到实现只是从 string 命令参数中剥离出proc:
来创建存储过程文件名,创建一个新的StoredProcedure
类,并执行这个类。现在,我们假设让StoredProcedure
类成为Command
类的子类是最佳设计。我们将讨论为什么这种策略是首选,并在接下来的小节中检查它的实现。然而,在我们到达那里之前,让我们讨论一下这个MakeCommandPtr()
函数的新重载。
在第 7.2.1 节中,我们首先看到了由以下实现给出的MakeCommandPtr
的版本:
inline void CommandDeleter(Command* p)
{
if(p) p->deallocate();
return;
}
using CommandPtr = std::unique_ptr<Command, decltype(&CommandDeleter)>;
inline auto MakeCommandPtr(Command* p)
{
return CommandPtr{p, &CommandDeleter};
}
前面的函数是一个帮助函数,用于从原始的Command
指针创建CommandPtr
。这种形式的函数用于从现有Command
的克隆中创建一个CommandPtr
(例如,在CommandFactory::allocateCommand()
):
auto p = MakeCommandPtr( command->clone() );
然而语义上,在CommandInterpreterImpl::executeCommand()
中,我们看到了完全不同的用法,那就是构造一个从Command
派生的类的命名实例。当然,我们可以用现有的MakeCommandPtr
原型来满足这个用例。例如,我们可以如下创建一个StoredProcedure
:
auto c = MakeCommandPtr(new StoredProcedure{ui, filename});
然而,只要有可能,我们不希望用裸露的new
来污染高级代码。因此,我们寻求实现一个重载的 helper 函数来为我们执行这个构造。其实现如下所示:
template<typename T, typename... Args>
auto MakeCommandPtr(Args&&... args)
requires std::derived_from<T, Command>
{
return CommandPtr{new T{std::forward<Args>(args)...}, &CommandDeleter};
}
Listing 8-1Generic perfect forwarding constructor
在 C++11 之前,不存在简单有效的技术来构造具有可变数目构造器参数的泛型类型,这对于创建从Command
类派生的任何一个可能的类都是必要的,每个类都具有不同的构造器参数。然而,现代 C++ 使用可变模板和完美转发为这个问题提供了一个优雅的解决方案。这个构造是下面侧栏的主题。
Modern C++ Design Note: Variadic Templates and Perfect Forwarding
可变模板和完美转发各自解决了 C++ 中不同的问题。可变模板支持使用未知数量的类型化参数进行类型安全的泛型函数调用。完美转发支持将参数正确类型地转发到模板函数内部的底层函数。这些技术的机制可以在你最喜欢的 C++11 参考文本中学习(例如,[30])。在侧栏中,我们将研究一种类型安全的通用设计技术,用于构造需要不同数量构造器参数的具体对象。这种技术是通过可变模板和完美转发的组合来实现的。由于缺乏命名创意,我将这种模式命名为通用完美转发构造器(GPFC)。让我们从介绍 GPFC 解决的根本问题开始。
让我们考虑一下每个作者最喜欢的过度简化的面向对象编程的例子,形状层次结构:
class Shape
{
public:
virtual double area() const = 0;
};
class Circle : public Shape
{
public:
Circle(double r) : r_{r} {}
double area() const override { return 3.14159 * r_ * r_; }
private:
double r_;
};
class Rectangle : public Shape
{
public:
Rectangle(double l, double w) : l_{l}, w_{w} {}
double area() const override { return l_ * w_; }
private:
double l_, w_;
};
在 C++ 中,实现为虚拟分派的可替换性解决了需要使用基类保证的接口通过基类指针调用派生类型的特定实现的问题。在形状示例中,可替换性意味着能够按如下方式计算面积:
double area(const Shape& s)
{
return s.area();
}
对于任何从Shape
派生的类。虚拟函数的确切接口是完全指定的,包括任何函数参数的数量和类型(即使在空的情况下,如本例中的area()
函数)。然而,问题是对象构造永远不能以这种方式“虚拟化”,即使可以,也不会起作用,因为构造一个对象所必需的信息(它的参数)在不同的派生类之间经常是不同的。
进入一般的完美转发构造器模式。在这个模式中,我们使用可变模板来提供一个类型安全的接口,该接口可以接受任意数量的不同类型的构造器参数。第一个模板参数总是我们想要构造的类型。然后,使用完全转发来保证参数以正确的类型传递给构造器。准确地说,为什么在这种情况下完美转发是必要的,这源于模板中类型是如何推导出来的,超出了本讨论的范围(详见[24])。对于我们的形状示例,应用 GPFC 模式会导致以下实现:
template<typename T, typename... Args>
auto MakeShape(Args&&... args)
{
return make_unique<T>(forward<Args>(args)...);
}
下面的代码说明了如何使用MakeShape()
函数创建具有不同数量构造器参数的不同类型:
auto c = MakeShape<Circle>(4.0);
auto r = MakeShape<Rectangle>(3.0, 5.0);
注意,GPFC 模式也适用于在继承层次结构中创建彼此不相关的类。事实上,GPFC 模式被标准库中的make_unique()
函数用来以一种高效、通用的方式生成unique_ptr
,而不需要裸的new
。虽然严格地说,它们是不同的,但我喜欢把 GPFC 模式看作工厂方法的一般模拟。
敏锐的读者会注意到清单 8-1 中函数声明后面的特殊的requires
子句。对于 C++20 来说,requires
子句引入了一个约束,指定了对模板参数的编译时要求。在清单 8-1 的情况下,我们要求类型T
必须从Command
派生。约束是概念的组成部分,是 C++20 的一个新的语言特性。在下面的边栏中简要介绍了一些概念。
Modern C++ Design Note: Concepts
概念是 C++20 的一个非常突出的新特性,用于向模板类型添加需求。概念在本书中并不突出,因为 pdCalc 在其实现中并没有包含很多模板。然而,由于新标准中概念的重要性,我选择在侧栏中简要提及它们,以说明为什么您可能会使用它们。
模板在 C++ 语言中已经存在了几十年,在它们存在的整个过程中,程序员都有同样的抱怨:模板错误会导致冗长的、难以理解的编译器错误消息。为什么不同编译器的错误信息都是一样的?答案基本上是因为类型错误通常是在调用栈深处发现的,而不是在使用该类型的第一行。在错误发生的地方,失去了太多的上下文来给出简洁的信息。
概念通过在使用时约束可接受的模板类型来解决前面的问题。这些需求既可以通过一个requires
子句作为约束添加,也可以通过使用一个requires
子句(或其连接词)来构建一个称为概念的受限模板类型。如果您选择在模板代码中使用概念(您可能应该这样做),不要从头开始。C++20 概念库预定义了许多概念,比如清单 8-1 中使用的derived_from
概念。
让我们从概念和requires
子句的上下文中重新检查清单 8-1 中的代码。在这本书的第一版中,因为概念还不是一种语言特征,所以完全相同的功能被无限制地呈现出来。也就是说,清单 8-1 是相同的,只是没有出现requires
子句。对于两个版本的源代码,当一个从Command
派生的类被传递给MakeCommandPtr()
时,会生成相同的编译代码。概念的真正好处是在犯错误的时候。
考虑一个不是从Command
派生的默认可构造哑类A
,并进一步考虑以下错误的函数调用:
auto p = MakeCommandPtr<A>();
使用 gcc,调用受约束版本的MakeCommandPtr()
会导致以下“友好的”错误:
note: 'pdCalc::Command' is not a base of 'A'
我在引号中写了 friendly,因为 gcc 给出了一个巨大的模板扩展错误,但至少包含了有用的注释,即Command
不是A
的基,作为错误末尾的注释,这正是这段代码无法编译的原因。现在让我们对比一下同样的呼叫,但是是无约束版本的MakeCommandPtr()
。这一次,最后出现的错误是
note: candidate expects 0 arguments, 2 provided
return CommandPtr{new T{std::forward<Args>(args)...}, &CommandDeleter};
这实际上并没有解释这个函数调用失败的原因。在向语言引入概念之前,有经验的程序员只是习惯了糟糕的模板错误消息。我个人诊断模板错误的策略是使用编译器找到令人不快的源代码行,忽略编译器产生的所有消息,并认真思考该行为什么会导致错误。这样的技术对新手来说是不可能的;概念是一个很大的进步。
在结束这篇边栏之前,值得注意的是,在清单 8-1 中,我们没有使用requires
子句,而是创建了一个概念并将模板参数约束为MakeCommandPtr()
。这种策略会产生以下替代代码:
template<typename T>
concept PdCalcCommand = std::derived_from<T, Command>;
template<PdCalcCommand T, typename... Args>
auto MakeCommandPtr(Args&&... args)
{
return CommandPtr{new T{std::forward<Args>(args)...}, &CommandDeleter};
}
我选择直接使用requires
子句,而不是创建一个概念,因为这个概念没有在代码中的其他地方使用过。也就是说,代码没有从概念上受益于PdCalcCommand
;它只需要对MakeCommandPtr
有一个约束。因此,requires
子句的使用看起来更简单,再次遵从了奥卡姆剃刀的设计。
设计 StoredProcedure 类
我们现在回到设计StoredProcedure
类的棘手问题。我们问的第一个问题是,我们到底需要一个类吗?我们已经有了解析单个命令、执行它们并将其放入撤销/重做栈的设计。也许正确的答案是以类似于处理批量输入的方式来处理存储过程。也就是说,在交互式会话(GUI 或 CLI)期间,通过读取存储过程文件、解析它并批量执行命令(就像我们在 CLI 中使用多个命令的一长行命令一样)来处理存储过程,而不引入新的StoredProcedure
类。
在考虑了下面这个非常简单的例子后,几乎可以立即放弃上述设计。假设您实现了一个计算三角形面积的存储过程。存储过程的输入将是栈上三角形的底和高。triangleArea.psp
由下式给出:
*
0.5
*
如果我们没有一个StoredProcedure
类,那么triangleArea.psp
中的每个命令都将被执行并按顺序进入撤销/重做栈。对于 I/O 栈上的值4
和5
,存储过程的正向执行将产生正确的结果10
,以及如图 8-2 所示的撤销栈。基于这个撤销栈,如果用户试图撤销,而不是撤销三角形区域存储过程,用户将只撤销栈上的顶层操作,即最后的乘法。I/O 栈将读取
4
5
0.5
图 8-2
没有StoredProcedure
类的撤销栈
(撤销栈在5
和0.5
之间会有一个*
,而不是
4
5
要完全撤销一个存储过程,用户需要按撤销 n 次,其中 n 等于存储过程中的命令数。重做操作也存在同样的缺陷。在我看来,撤销存储过程的预期行为应该是撤销整个过程,并让 I/O 栈保持在执行存储过程之前的状态。因此,不使用StoredProcedure
类来处理存储过程的设计无法正确地实现撤销和重做,因此必须放弃。
复合模式
本质上,为了解决存储过程的撤销/重做问题,我们需要一个特殊的命令,它封装了多个命令,但表现为单个命令。幸运的是,复合模式解决了这个难题。根据 Gamma 等人[11]的说法,复合模式“让客户可以一致地对待单个对象和对象的组合。”通常,复合模式指的是树形数据结构。我更喜欢一个更宽松的定义,这种模式可以应用于任何允许对复合对象进行统一处理的数据结构。
图 8-3 显示了复合模式的一般形式。Component
类是一个抽象类,需要执行一些动作。这个动作可以由一个Leaf
节点单独执行,也可以由一组被称为Composite
的Component
来执行。客户端通过Component
接口与组件层次结构中的对象进行多形态的交互。从客户端的角度来看,Leaf
节点和Composite
节点都不加区分地处理doSomething()
请求。通常,Composite
s 通过简单地调用它持有的Component
s(或者Leaf
s 或者嵌套Composite
s)的doSomething()
命令来实现doSomething()
。
图 8-3
复合模式的一般形式
在我们特定的具体例子中,Command
类扮演Component
的角色,具体的命令如Add
或Sine
扮演Leaf
节点的角色,而StoredProcedure
类是复合的。doSomething()
命令由一对纯虚函数executeImpl()
和undoImpl()
代替。我怀疑以这种方式组合命令和复合模式是相当普遍的。
以前,我们了解到为了正确地实现存储过程的撤销/重做策略,类设计是必要的。如前所述,复合模式的应用促使从Command
类派生出StoredProcedure
类。现在让我们设计一个StoredProcedure
类,并作为复合模式的一个具体应用来检查它的实现。
第一次尝试
实现复合模式的一种常见方法是通过递归。Composite
类保存一个Component
的集合,通常通过简单的vector
或者更复杂的方式,比如二叉树中的节点。Composite
的doSomething()
函数简单地迭代这个集合,为集合中的每个Component
调用doSomething()
。Leaf
节点的doSomething()
函数实际上做了一些事情并终止递归。虽然不是必需的,但是Component
类中的doSomething()
函数通常是纯虚拟的。
让我们考虑前面的方法,在 pdCalc 中为StoredProcedure
s 实现复合模式。我们已经确定,pdCalc 的Command
类是Component
,具体的命令类,比如Add
,是Leaf
类。因此,我们只需要考虑StoredProcedure
类本身的实现。注意,由于当前的Component
和Leaf
类的实现可以按原样使用,复合模式可以很容易地应用于扩展现有代码库的功能。
考虑以下StoredProcedure
级的骨架设计:
class StoredProcedure : public Command
{
private:
void executeImpl() noexcept override;
void undoImpl() noexcept override;
vector<unique_ptr<CommandPtr>> components_;
};
executeImpl()
命令将按如下方式执行:
void StoredProcedure::executeImpl()
{
for(auto& i : components_)
i->execute();
return;
}
undoImpl()
将以类似的方式实现,但是对component_
集合进行反向迭代。
前面的设计是否解决了以前在没有StoredProcedure
类的情况下将存储过程命令直接输入撤销/重做栈时遇到的撤销/重做问题?考虑图 8-4 中所示的撤销栈,这是我们之前检查过的triangleArea.psp
示例。如图中的SP
所示,存储过程在撤销栈中显示为单个对象,而不是代表其组成命令的单个对象。因此,当用户发出撤销命令时,CommandManager
将通过调用存储过程的undoImpl()
函数来撤销存储过程。这个存储过程的undoImpl()
函数反过来通过对其容器Command
s 的迭代来撤销单个命令,这种行为正是我们所期望的,复合模式的应用确实解决了眼前的问题。
图 8-4
使用StoredProcedure
类的撤销栈
为了完成StoredProcedure
类的实现,我们需要解析存储过程文件的字符串命令(带有错误检查),并使用它们来填充StoredProcedure
的components_ vector
。这个操作可以写在StoredProcedure
的构造器中,并且实现将是有效和完整的。我们现在将拥有一个StoredProcedure
类,它可以将字符串命令转换成Command
命令,并将它们存储在一个容器中,并且能够根据需要执行和撤销这些存储的Command
命令。换句话说,我们基本上重写了CommandInterpreter
!相反,让我们考虑一种替代设计,它通过重用CommandInterpreter
类来实现StoredProcedure
类。
StoredProcedure
级的最终设计
这个设计的目标是按原样重用CommandInterpreter
类。放松这个约束并修改CommandInterpreter
的代码可以稍微清理一下实现,但是设计的本质是一样的。考虑下面的StoredProcedure
级改进骨架设计:
class StoredProcedure : public Command
{
private:
void executeImpl() noexcept override;
void undoImpl() noexcept override;
std::unique_ptr<Tokenizer> tokenizer_;
std::unique_ptr<CommandInterpreter> ci_;
bool first_ = true;
};
目前的设计与我们之前的设计几乎相同,除了components_ vector
已经被CommandInterpreter
所取代,并且已经明确了对记号赋予器的需求。好在我们在第五章中编写了可重用的记号赋予器!
我们现在准备好看到executeImpl()
和undoImpl()
的完整实现。注意,虽然下面的实现没有使用前面看到的模式的规范版本,但是这个StoredProcedure
类的实现仍然只是复合模式的一个应用。首先,我们来看看executeImpl()
:
void StoredProcedure::executeImpl() noexcept
{
if(first_)
{
ranges::for_each( *tokenizer_,
this{ci_->commandEntered(c);} );
first_ = false;
}
else
{
for(auto i = 0u; i < tokenizer_->nTokens(); ++i)
ci_->commandEntered("redo");
}
return;
}
第一次调用executeImpl()
时,令牌必须从令牌化器中提取出来,并由StoredProcedure
自己的CommandInterpreter
执行。对executeImpl()
的后续调用仅仅是请求StoredProcedure
的CommandInterpreter
重做每个StoredProcedure
命令的向前执行。记住,StoredProcedure
的executeImpl()
函数本身会被 pdCalc 的CommandInterpreter
调用;因此,我们的设计要求嵌套CommandInterpreter
的。图 8-5 显示了三角形区域存储过程示例的设计,其中CI
代表CommandInterpreter
。
图 8-5
使用嵌套CommandInterpreter
的撤销栈
StoredProcedure
的undoImpl()
的实现很简单:
void StoredProcedure::undoImpl() noexcept
{
for(auto i = 0u; i < tokenizer_->nTokens(); ++i)
ci_->commandEntered("undo");
return;
}
撤销是通过请求底层的CommandInterpreter
撤销存储过程中的一些命令来实现的。
在结束对最后一个StoredProcedure
类的讨论之前,我们应该考虑一下StoredProcedure
类中命令的标记化。StoredProcedure
的标记化过程包括两个步骤。必须打开并读取存储过程文件,然后对文本流进行实际的标记化。这个过程只需要在初始化时,每个StoredProcedure
实例化执行一次。因此,标记化的自然位置是在StoredProcedure
的构造器中。然而,在StoredProcedure
的构造器中放置标记化会与 pdCalc 的命令错误处理程序产生不一致。特别是,pdCalc 假设命令可以被构造,但不一定被执行,而不会失败。如果一个命令不能被执行,期望通过检查命令的前提条件来处理这个错误。标记化会失败吗?当然可以。例如,如果存储过程文件无法打开,标记化就会失败。因此,为了保持错误处理的一致性,我们在StoredProcedure
的checkPreconditionsImpl()
函数中实现了标记化,当 pdCalc 的CommandInterpreter
第一次尝试执行存储过程时会调用这个函数。由于标记化需要执行一次,所以我们只在第一次执行checkPreconditionsImpl()
函数时执行操作。完整的实现可以在StoredProcedure.cpp
文件中找到。
8.2 设计更有用的计算器
到目前为止,所有关于 pdCalc 的讨论都集中在可从 GitHub 下载的完整代码的设计和实现上。然而,本章的其余部分标志着对这种风格的背离。此后,我们将只讨论扩展的想法和如何修改 pdCalc 以适应新特性的建议。不仅没有提供工作代码,而且在写这些部分之前也没有创建工作代码。因此,我们将要讨论的设计还没有经过测试,选择完成这些扩展的有冒险精神的读者可能会发现将要讨论的想法是次优的,或者,我敢说,是错误的。欢迎来到从空白开始设计功能的狂野西部!将需要试验和反复。
8.2.1 复数
计算器的原始设计规范要求双精度数字,我们设计和实现的计算器明确地只处理双精度数字。然而,需求是变化的。假设您的同事是一名电气工程师,他路过您的办公室,爱上了您的计算器,但需要一台处理复数(虚数)的计算器。这是一个合理的要求,所以让我们看看如何重构我们的计算器来满足这个新特性。
添加复数需要对 pdCalc 进行四项主要修改:在内部使用复数表示,而不是将数字表示为double
s,更改输入和输出(以及,通过扩展,解析)以适应复数,修改 pdCalc 的栈以存储复数而不是double
s,以及修改命令以对复数而不是实值输入执行计算。第一个变化,寻找一个复数的 C++ 表示,是微不足道的;我们将使用std::complex<double>
。一个只有实部的数将简单地存储为一个虚部设置为0
的complex<double>
。其他三个变化不那么微不足道。现在让我们更深入地看看一些能够适应这些变化的设计选项。
修改输入和输出
在所有需要的改变中,修改 I/O 例程实际上是最容易的。要解决的第一个问题是如何解释和表示复数。例如,我们是否希望一个复数c
被表示为c = re + im * i
(也许虚数应该是j
,因为特性请求来自一个电气工程师)?也许我们更喜欢使用c = (re, im)
或使用尖括号或方括号的变体。这个问题没有正确答案。尽管有些选择可能比其他选择更容易实现,但由于这种选择只是一种约定,在实践中,我们会将解决方案委托给客户。对于我们的案例研究,我们将简单地采用约定c = (re, im)
。
我们将只讨论修改 I/O 的命令行版本。一旦为 CLI 准备好了处理复数的基础设施,修改 GUI 应该相当简单。我们遇到的第一个问题是Tokenizer
类。这个类的最初设计只是通过在空白上分割输入来标记。然而,对于复数,这种方案是不够的。例如,根据逗号后是否插入空格,复数的标记方式会有所不同。
在某种程度上,输入变得足够复杂,以至于您需要使用一种语言语法,并将简单的输入例程迁移到“真正的”扫描器和解析器(可能使用 lex 和 yacc 之类的库)。有些人可能会说,通过增加复数,我们已经达到了这种复杂程度。然而,我认为,如果我们修改tokenize()
例程来扫描’(
'标记,并为左括号和右括号之间(包括左括号和右括号)的任何内容创建一个“number”标记,我们可能可以勉强使用现有的简单输入标记器。显然,我们需要执行一些基本的错误检查来确保正确的格式。另一种方法是基于正则表达式匹配分解输入流。这就是 lex 的基本操作方式,在从头开始编写复杂的扫描器之前,我会使用 lex 或类似的库进行研究。
我们遇到的下一个输入问题是解析CommandInterpreterImpl
的executeCommand()
函数中的数字。目前,一个字符串参数(令牌)被传递给这个函数,该字符串被解析以确定它是一个数字还是一个命令。通过检查,我们可以看到,如果我们修改isNum()
来识别和返回复数而不是浮点数,executeCommand()
将适用于复数。最后,需要更新EnterNumber
命令来接受和存储一个complex<double>
。
这就负责修改输入例程,但是我们如何修改输出例程呢?回想一下,Cli
类是Stack
的stackChanged()
事件的(间接)观察者。每当Stack
引发该事件时,就会调用Cli
的stackChanged()
函数将当前栈输出到命令行。让我们来看看Cli::stackChanged()
是如何实现的。本质上,CLI 使用以下函数调用回调栈,用顶部的nElements
填充容器:
auto v = Stack::Instance().getElements(nElements);
然后创建一个string
、s
和相应的back_inserter
、bi
,首先用一些栈元数据填充,然后用以下代码填充栈元素:
for( auto j = v.size(); auto i : views::reverse(v) )
{
std::format_to(bi, "{}:\t{:.12g}\n", j--, i);
}
最后,s
被发送到 CLI 的输出例程,该例程将其输出到 CLI 的输出流。一旦Stack
的getElements()
函数被修改为返回一个vector<complex<double>>
,并且输出格式被相应地改变,Cli
的stackChanged()
函数将按预期工作。实际上只需要很少的改变——这就是设计良好和实现良好的代码的美妙之处。
修改栈
在第三章中,我们最初设计计算器的栈只对双精度变量进行操作。显然,这个限制意味着现在必须重构Stack
类来处理复数。当时,我们质疑为栈硬编码目标数据类型的逻辑,我建议不要设计通用的Stack
类。我的建议是,一般来说,在第一个重用案例明确建立之前,不要设计通用接口。设计好的通用接口通常比设计特定类型更难,从我的个人经验来看,我发现代码的意外重用很少会有结果。然而,对于我们的Stack
类来说,将这种数据结构重新用于另一种数据类型的时候到了,在这一点上,谨慎的做法是将Stack
的接口转换为通用接口,而不仅仅是重构该类以针对复数进行硬编码。
使Stack
类通用化几乎和你想象的一样简单。第一步是通过用我们的通用类型T
替换double
的显式使用,使接口本身通用化。界面变成了
template<typename T>
class Stack : private Publisher
{
public:
static Stack& Instance();
void push(T, bool suppressChangeEvent = false);
T pop(bool suppressChangeEvent = false);
void swapTop();
std::vector<T> getElements(size_t n) const;
void getElements(size_t n, std::vector<T>&) const;
using Publisher::attach;
using Publisher::detach;
};
一般来说,所需的实现更改很简单。double
的使用被替换为T
,显然,pdCalc 中的Stack
类的使用必须被重构,以使用泛型而不是非模板化接口。
需要修改的接口的最后一部分是第七章中添加的五个全局extern "C"
帮助函数,用于将栈命令导出到插件。因为这些函数必须有 C 链接,我们不能让它们成为模板,它们也不能返回 C++ complex
类型来代替double
。第一个问题并不像乍看上去那么可怕。虽然我们的目标是使Stack
类通用和可重用,但是栈的插件接口不需要通用。对于任何特定版本的 pdCalc,无论是对实数进行操作的版本还是对复数进行操作的版本,系统中只存在一个特定的Stack<T>
实例,并且这个实例将有一个特定的T
实现。因此,用于 pdCalc 的栈的 C 链接接口只需要反映计算器中使用的T
的选择。也就是说,容器被设计成通用的和可重用的,但是插件的接口不需要这种灵活性,因为一旦为计算器选择了数据格式,它就不再被重用。
将 C 链接接口中的complex<double>
表示替换到栈中很简单。我们有几个选择。首先,我们可以用两个doubles
的序列替换每个double
:一个代表实数部分,一个代表复数部分。当然,由于 C 函数本身不能返回两个doubles
,我们必须修改返回栈值的函数。一种选择是在参数列表中使用指针参数,通过这些参数“返回”复杂的值。第二种选择是通过数组返回复数。另一个解决方案,也是我的首选,是简单地定义一个struct
:
struct Complex
{
double re;
double im;
};
为了补充接口功能,将当前使用的double
替换为Complex
。虽然这个新的Complex struct
复制了标准complex
类的存储,但是我们不能在纯 C 接口中使用标准的complex
类。
修改命令
修改命令来处理复数真的很容易,因为 C++ 库为我们的计算器所需的所有数学运算提供了重载。除了用Stack<complex<double>>
替换Stack
(希望我们在某处给它起了别名)和在BinaryCommand
和UnaryCommand
中用complex<double>
替换double
的语法变化之外,大多数命令保持不变。例如,清除一堆实数和清除一堆复数是一样的。给定运算符重载,将两个复数相加与将两个实数相加是相同的。当然,我们可能想要添加额外的命令,比如 complex conjugate,但是即使是这个功能也是由 C++ complex
类提供的。如果您创建的命令使用了complex
类本身不支持的算法,那么在修改命令以支持复数时,您可能会遇到比编程更多的数学困难。
变量
在本章的前面,我们实现了存储过程作为一种存储简单指令序列的方法。虽然存储过程适用于只使用每个输入一次的琐碎操作(例如,毕达哥拉斯定理),但是在尝试实现多次使用每个输入的更复杂的公式(例如,二次公式)时,很快就会遇到问题。为了克服这个困难,您需要实现在命名变量中存储参数的能力。
在 pdCalc 中实现变量需要对现有组件进行一些修改,包括添加一个重要的新组件,即符号表。为了简单起见,在示例代码中,我恢复使用实数表示 pdCalc。然而,使用复数不会增加额外的设计复杂性。现在让我们探索一些实现变量的可能的设计思想。
输入和新命令
显然,使用变量需要一些提供符号名的方法。目前,我们的计算器只接受数字和命令作为输入。输入任何在CommandFactory
中找不到的字符串都会导致错误。然而,回想一下,这个错误是在CommandInterpreter
中产生的,而不是在标记器中产生的。因此,我们需要修改CommandInterpreter
,使其不拒绝字符串,而是以某种方式将它们放入栈。现在,我们假设栈除了接受数字还可以接受字符串。我们将在接下来的章节中讨论对Stack
类的必要修改。同样,我们将讨论限制在命令行界面。图形用户界面带来的唯一额外的复杂性是提供了一种机制来输入除数字之外的字符串(可能是虚拟数字小键盘附带的虚拟键盘)。
从技术上讲,我们可以允许任何字符串代表一个变量。然而,将允许的语法限制在字符串的某个子集可能会更好,可能用符号分隔以区分变量名和命令。因为这种选择仅仅是惯例,所以您可以自由选择适合您或您的用户口味的任何规则。就个人而言,我可能会选择像变量名必须以字母开头,可以包含字母、数字的任意组合,还可能包含一些特殊符号,如下划线。为了消除变量名和命令之间的混淆,我将变量用单引号或双引号括起来。
既然我们已经建立了变量的语法,我们仍然需要一种机制来从栈中取出一个数字并将其存储到变量中。完成这项任务最简单的方法是提供一个新的二进制命令store
,从栈中删除一个数字和一个字符串,并创建一个符号表条目,将这个变量名链接到这个数字。例如,考虑栈
4.5
2.9
"x"
发出store
命令应导致进入 x → 2 *。*符号表中的 9 和的一个结果栈
4.5
隐式地,变量应该被转换成在计算中使用的数字,但在栈中显示为名称。我们还应该提供一个明确的命令eval
,将一个符号名称转换成一个数字。例如,给定栈
"x"
发出eval
命令应该会导致栈
2.9
这样的命令应该有一个相当明显的实现:用符号表中的值替换栈顶的变量。显然,请求对不在符号表中的变量求值会导致错误。计算一个数字可能会导致错误,或者更好的是,只返回数字。您可能会想到许多处理变量的奇特命令(例如,列出符号表)。然而,store
和eval
命令包含使用变量所需的最小命令集。
数字表示和栈
到目前为止,我们的栈只需要表示一个单一的、唯一的类型,可以是实数,也可以是复数。然而,由于变量和数字都可以存储在栈中,我们需要栈能够同时存储这两种类型。我们立即摒弃了可以同时处理两种不同类型的栈的概念,因为这将很快导致混乱。相反,我们寻求一种能够通过单一接口处理数字和变量类型的统一表示。很自然,我们转向了等级制度。
考虑图 8-6 中类图表达的设计。这种层次结构使得Variable
和Number
可以作为Value
互换使用。这种多态设计解决了我们已经遇到的三个问题。首先,Variable
s 和Number
s 可以统一存储在Stack<Value*>
中(可能使用更合适的智能指针存储方案)。第二,当Add
或Sine
等命令需要一个数来执行一个操作时,可以从栈中弹出Value
s,并通过虚拟的evaluate()
函数请求double
s。显然,a Number
直接存储它所代表的double
,而 a Variable
存储变量的名称,可以通过在变量符号表中查找转换成数值。最后,Value
的子类可以返回其底层值的string
表示(或者是Number
的数值,或者是Variable
的名称)。这种字符串转换对于在 I/O 栈上显示是必要的。
图 8-6
一种能够统一表示数字和变量的层次结构
符号表
从本质上讲,符号表只是一种数据结构,它允许通过将一个键与一个值(关联数组)配对来进行符号查找。在这种情况下,变量名作为键,数值作为值。C++ 标准库直接通过map
或unordered_map
提供这种服务,这取决于所需的底层数据结构。然而,正如在第三章中,我强烈建议不要在程序中直接使用标准库容器作为外部接口。相反,应该使用适配器模式将库容器封装在由应用程序本身定义的接口之后。这种模式没有给类的用户增加任何限制,但是它允许设计者独立于底层库容器的接口来限制、扩展或修改组件的接口。
因此,符号表的推荐设计是创建一个SymbolTable
类来包装一个unordered_map<string, double>
。这个底层哈希表提供了一种存储类型,用于在作为string
的变量名和底层数值之间进行映射。SymbolTable
类的公共接口提供了从符号表中添加和删除变量的成员函数,可选地(我们没有指定清除变量的命令)。由于我们在计算器中只需要一个符号表,所以SymbolTable
可能应该作为单例来实现。
一个简单的扩展:数字常量
一旦我们建立了存储用户定义变量的机制,我们就可以进行简单的扩展来提供用户定义的常量。常量是简单的变量,一旦设定就不能改变。常量可以在 pdCalc 中硬编码,在程序启动时通过读取常量文件添加,或者在计算器执行期间动态添加。
显然,为了存储一个常量,我们将需要添加一个新的命令;姑且称之为cstore
。cstore
的工作方式与store
相同,除了该命令通知符号表正在存储的变量不能被改变。我们有两个显而易见的实施方案。首先,在SymbolTable
类中,我们添加了第二个映射,指示给定的名称是代表变量还是常量。这种方法的优点是,添加一个额外的映射只需要对现有代码进行最少的实现更改。缺点是这种方法需要对符号表的每次调用进行两次独立的查找。更好的方法是修改原始映射,将值类型存储为Entry
而不是double
,其中Entry
定义为
struct Entry
{
double val;
bool isConst;
};
当然,为了避免硬编码double
类型,我们当然可以模板化SymbolTable
和Entry
。
由变量启用的功能
让我们来看看哪些变量使我们能够做到这一点。考虑根由
)
给定的二次方程ax2+bx+c= 0
以前我们不能编写存储过程来计算两个根,现在我们可以编写存储过程:
"c" store "b" store "a" store "b" 2 pow 4 "a" "c" * * - sqrt "root" store
"b" neg "root" + 2 a * / "b" neg "root" - 2 a * /
它将从栈中取出代表系数 a,b,c 的三个条目,并返回代表二次方程的根的两个条目。现在,我们的计算器有所进展了!
8.3 一些有趣的自我探索扩展
本章最后一节列出了一些有趣的 pdCalc 扩展,您可以考虑自己尝试一下。与上一节不同,我没有提供任何设计思路来帮助您开始。我只提供了每个挑战的简要描述。
8.3.1 高 DPI 缩放
像素分辨率极高的显示器越来越成为标准配置。考虑如何修改 pdCalc 的 GUI 来正确处理此类显示的缩放。这个特性是独立于操作系统的,还是我们对第七章中的PlatformFactory
有另外的用途?从版本 5.6 开始,Qt 通过一个高 DPI 缩放的接口来帮助你完成这个任务。
动态蒙皮
在第六章中,引入了一个类来管理 GUI 的外观。然而,所提供的实现仅仅集中了外观和感觉。它不允许用户定制。
用户经常想要定制他们的应用程序的外观和感觉。允许这种变化的应用程序被认为是“可换肤的”,每个不同的外观和感觉被称为一个皮肤。考虑对LookAndFeel
类进行必要的接口和适当的实现更改,以启用 pdCalc 的皮肤。一些可能的选项包括用于定制单个小部件的对话框或从皮肤配置文件中选择皮肤的机制。用一个集中的类来处理应用程序的外观应该会使这种改变变得简单明了。不要忘了给LookAndFeel
添加一个信号,这样其他 GUI 元素就知道什么时候需要用新的外观重新绘制它们自己了!
流量控制
有了变量,我们大大增强了存储过程的灵活性。对于计算大多数公式,这个框架应该是足够的。然而,如果我们想实现一个递归公式,比如计算一个数的阶乘,该怎么办呢?虽然我们有能力通过插件来执行如此复杂的计算,但如果能将这种功能扩展到那些没有 C++ 编程经验的计算器用户,那就更好了。为了完成这项任务,我们需要为流控制设计一个语法。最简单的设计至少能够处理循环和条件操作。就增加的功能和实现工作而言,将流控制添加到 pdCalc 将是一个相当显著的增强。可能是时候转向真正的扫描器和解析器了!
8.3.4 另一种图形用户界面布局
受 HP48S 计算器的启发,pdCalc GUI 目前采用垂直方向。然而,现代屏幕分辨率往往宽于高,使得垂直方向不是最佳选择。对水平方向进行硬编码并不比最初的垂直方向更具挑战性。相反,考虑如何重新设计 pdCalc,以便能够在运行时切换方向。也许垂直方向只是一个不同的皮肤选项?
一个图形计算器
HP48 系列计算器不仅仅是科学计算器;他们在绘制计算器。尽管在复杂的独立绘图程序存在的情况下,为计算机实现绘图计算器可能不太实际,但这种练习可能会被证明是非常有趣的。从 5.7 版本开始,Qt 现在包含了一个绘图模块,使得这个任务比以前简单多了。考虑到这个图形化小部件集,最大的挑战可能只是设计一个图形化输入的方法。如果你想回到 20 世纪 70 年代,考虑为 CLI 实现一个 ASCII 图形计算器!
8.3.6 插件管理系统
目前,插件是在 pdCalc 启动时加载的,加载哪些插件是通过从文本文件中读取共享库名称来确定的。插件一旦加载,就不能卸载。考虑实现一个动态插件管理系统,以便插件可以在运行时被选择、加载和卸载。您甚至可以扩展插件接口来支持插件描述的动态查询。我认为真正的问题在于如何处理一个插件的卸载,这个插件的一个命令当前在撤销/重做栈上。
移动设备接口
在我创作这本书的最初构思中,我设想了一章来描述如何将 pdCalc 扩展到 iOS 或 Android 平板电脑。Qt 库可以再次帮助您完成这项任务。我没有在本书中包括这一章的原因是我没有任何平板电脑编程的实践经验。从我第一次涉足设计领域开始,我就觉得试图教别人如何设计平板电脑界面是不真诚的。嗯,这可能是一个糟糕设计的绝佳例子!尽管如此,将 pdCalc 扩展到平板电脑或智能手机界面仍是一个有价值的挑战。
8.3.8 云中的 pdCalc
假设您想扩展 pdCalc 以在云中执行。本书中介绍的 pdCalc 的设计可能会被归类为模块化整体结构。大多数大规模云程序都被设计成分布式服务。因此,我建议的第一个设计变更是将 pdCalc 重组为微服务架构。我们不是将模块构建为共享库,而是将模块实现为独立的服务,每个模块运行在自己的容器中。模块将通过 RESTful APIs 而不是函数调用进行通信。从现有的 c++ API 设计 RESTful APIs 应该是一项简单的任务。根据程序的预期负载,您可以通过一个容器编排服务(如 Kubernetes)添加动态伸缩。然而,我怀疑我们的低级计算器不太可能看到每个服务需要一个以上的容器。一个合适的构建系统甚至应该包括一个自动化测试套件和一个用于持续集成和持续部署的管道。
微服务架构的一个方便的特性是,由于不同的组件通过 API 进行通信,所以用不同的编程语言编写不同的模块是很简单的。基于云的 pdCalc 的 GUI 可能是一个网页,我们可能希望用 JavaScript(或 TypeScript)编写该组件,可能使用 Angular 之类的库。后端可以用 C++ 设计,但 Python 可能是更合适的语言。根据会话状态和栈的设计,pdCalc 甚至可能需要一个数据库。
将 pdCalc 移植到云听起来不仅仅是一个小的设计修改。事实上,它的设计可能足够独特,足以成为它自己的一本书——如果你想合写,请随时给我发电子邮件。既然你已经看完了这本书,你和我都需要一个新的项目!