十四、命令
想一个琐碎的变量赋值,比如meaning_of_life = 42
。变量被赋值了,但是没有任何记录表明赋值发生了。没有人能给我们以前的值。我们不能将赋值的事实序列化到某个地方。这是有问题的,因为没有变更的记录,我们就不能回滚到以前的值,执行审计,或者进行基于历史的调试。
命令设计模式提出,我们向对象发送命令:关于如何做某事的指令,而不是通过 API 操纵它们来直接处理对象。命令只不过是一个数据类,其成员描述做什么和如何做。让我们来看一个典型的场景。
方案
让我们试着为一个有余额和透支额度的典型银行账户建模。我们将在上面实现deposit()
和withdraw()
函数:
1 struct BankAccount
2 {
3 int balance = 0;
4 int overdraft_limit = -500;
5
6 void deposit(int amount)
7 {
8 balance += amount;
9 cout << "deposited " << amount << ", balance is now " <<
10 balance << "\n";
11 }
12
13 void withdraw(int amount)
14 {
15 if (balance - amount >= overdraft_limit)
16 {
17 balance -= amount;
18 cout << "withdrew " << amount << ", balance is now " <<
19 balance << "\n";
20 }
21 }
22 };
当然,现在我们可以直接调用成员函数,但是让我们假设,为了审计的目的,我们需要记录每一笔存款和取款,但是我们不能在BankAccount
中直接这样做,因为——你猜怎么着——我们已经设计、实现并测试了那个类。
实现命令模式
我们将从定义一个命令的接口开始:
1 struct Command
2 {
3 virtual void call() const = 0;
4 };
有了这个接口,我们现在可以用它来定义一个BankAccountCommand
,它将封装关于如何处理银行账户的信息:
1 struct BankAccountCommand : Command
2 {
3 BankAccount& account;
4 enum Action { deposit, withdraw } action;
5 int amount;
6
7 BankAccountCommand(BankAccount& account, const Action
8 action, const int amount)
9 : account(account), action(action), amount(amount) {}
该命令中包含的信息包括:
- 要操作的帐户
- 要采取的操作;选项集和存储这些选项的变量都在一个声明中定义
- 存款或取款的金额
一旦客户提供了这些信息,我们就可以利用这些信息进行存款或取款:
1 void call() const override
2 {
3 switch (action)
4 {
5 case deposit:
6 account.deposit(amount);
7 break;
8 case withdraw:
9 account.withdraw(amount);
10 break;
11 }
12 }
使用这种方法,我们可以创建命令,然后在命令上执行帐户权限的修改:
1 BankAccount ba;
2 Command cmd{ba, BankAccountCommand::deposit, 100};
3 cmd.call();
这会在我们的账户上存 100 美元。放轻松!如果您担心我们仍然向客户端公开原始的deposit()
和withdraw()
成员函数,您可以将它们设为private
,并简单地将BankAccountCommand
指定为友元类。
撤消操作
因为一个命令封装了关于对一个BankAccount
的修改的所有信息,它同样可以回滚这个修改,并将其目标对象返回到其先前的状态。
首先,我们需要决定是否将撤销相关的操作放入我们的Command
接口。出于简洁的目的,我将在这里这样做,但一般来说,这是一个需要尊重我们在本书开始时讨论过的接口分离原则的设计决策(第一章)。例如,如果您设想一些命令是最终的,并且不受撤销机制的影响,那么将Command
拆分成Callable
和Undoable
可能是有意义的。
无论如何,这里是更新的Command
;注意,我特意从函数中删除了const
:
1 struct Command
2 {
3 virtual void call() = 0;
4 virtual void undo() = 0;
5 };
这里是BankAccountCommand::undo()
的一个天真的实现,其动机是(不正确的)账户存款和取款是对称操作的假设:
1 void undo() override
2 {
3 switch (action)
4 {
5 case withdraw:
6 account.deposit(amount);
7 break;
8 case deposit:
9 account.withdraw(amount);
10 break;
11 }
12 }
为什么这个实现被打破了?因为如果你试图提取相当于一个发达国家国内生产总值的金额,你不会成功,但当回滚交易时,我们没有办法知道它失败了!
为了获得这个信息,我们修改withdraw()
来返回一个成功标志:
1 bool withdraw(int amount)
2 {
3 if (balance - amount >= overdraft_limit)
4 {
5 balance -= amount;
6 cout << "withdrew " << amount << ", balance now " <<
7 balance << "\n";
8 return true;
9 }
10 return false;
11 }
那就好多了!我们现在可以修改整个BankAccountCommand
来做两件事:
- 取款时,在内部存储一个
success
标志。 - 调用
undo()
时使用该标志。
我们开始吧:
1 struct BankAccountCommand : Command
2 {
3 ...
4 bool withdrawal_succeeded;
5
6 BankAccountCommand(BankAccount& account, const Action action,
7 const int amount)
8 : ..., withdrawal_succeeded{false} {}
9
10 void call() override
11 {
12 switch (action)
13 {
14 ...
15 case withdraw:
16 withdrawal_succeeded = account.withdraw(amount);
17 break;
18 }
19 }
你现在明白为什么我从Command?
的成员中移除了const
了吗?现在我们正在分配一个成员变量withdrawal_succeeded
,我们不能再声称call()
是const
。我想我本可以把它留在undo()
的,但这没什么好处。
好了,现在我们有了标志,我们可以改进我们的undo()
实现了:
1 void undo() override
2 {
3 switch (action)
4 {
5 case withdraw:
6 if (withdrawal_succeeded)
7 account.deposit(amount);
8 break;
9 ...
10 }
11 }
Tada!我们最终可以用一致的方式撤销撤销命令。
当然,这个练习的目的是为了说明,除了存储关于要执行的操作的信息之外,命令还可以存储一些中间信息,这些信息对于审计之类的事情也是有用的:如果您检测到一系列 100 次失败的撤销尝试,您就可以调查潜在的黑客攻击。
复合命令
可以用两个命令模拟从账户 A 到账户 B 的资金转移:
- 从 A 中提取 X 美元
- 将$X 存入 B
如果我们不创建和调用这两个命令,而是只创建和调用一个封装了这两个命令的命令,那就太好了。这是我们在第八章中讨论的复合设计模式的本质。
让我们定义一个框架复合命令。我将从vector <BankAccountCommand>
继承——这可能会有问题,因为std::vector
没有虚拟析构函数,但在我们的例子中这不是问题。这里有一个非常简单的定义:
1 struct CompositeBankAccountCommand : vector<BankAccountCommand>, Command
2 {
3 CompositeBankAccountCommand(const initializer_list<value_type>& items)
4 : vector<BankAccountCommand>(items) {}
5
6 void call() override
7 {
8 for (auto& cmd : *this)
9 cmd.call();
10 }
11
12 void undo() override
13 {
14 for (auto it = rbegin(); it != rend(); ++it)
15 it->undo();
16 }
17 };
如您所见,CompositeBankAccountCommand
既是一个向量,也是一个Command
,这符合复合设计模式的定义。我添加了一个接受初始化列表的构造器(非常有用!)并实施了undo()
和redo()
操作。注意,undo()
进程以相反的顺序执行命令;希望我不必解释为什么您希望这是默认行为。
那么现在,专门用于转账的复合命令怎么样?我将它定义如下:
1 struct MoneyTransferCommand : CompositeBankAccountCommand
2 {
3 MoneyTransferCommand(BankAccount& from,
4 BankAccount& to, int amount) :
5 CompositeBankAccountCommand
6 {
7 BankAccountCommand{from, BankAccountCommand::withdraw, amount},
8 BankAccountCommand{to, BankAccountCommand::deposit, amount}
9 } {}
10 };
如你所见,我们所做的就是重用基类构造器用两个命令初始化对象,然后重用基类的call()/undo()
实现。
但是等等,这不对吧?基类实现并不完全符合它,因为它们没有包含失败的思想。如果我不能从 A 处取钱,我就不应该把钱存到 B 处:整个链条会自动取消。
为了支持这一想法,需要进行更剧烈的变革。我们需要
- 给
Command
添加一个success
标志。 - 记录下每一次操作的成败。
- 确保该命令只有在最初成功时才能撤消。
- 引入一个名为
DependentCompositeCommand
的新中间类,它非常小心地回滚命令。
当调用每个命令时,我们只有在前一个命令成功的情况下才这样做;否则,我们简单地将success
标志设置为false
。
1 void call() override
2 {
3 bool ok = true;
4 for (auto& cmd : *this)
5 {
6 if (ok)
7 {
8 cmd.call();
9 ok = cmd.succeeded;
10 }
11 else
12 {
13 cmd.succeeded = false;
14 }
15 }
16 }
没有必要覆盖undo()
,因为我们的每个命令都检查它自己的success
标志,并且只有当它被设置为true
时才撤销操作。
人们可以想象出一种更强的形式,其中复合命令只有在其所有部分都成功的情况下才成功(想象一下取款成功但存款失败的转帐——您希望它通过吗?)—这有点难以实现,我再次将它作为一个练习留给读者。
本节的全部目的是说明当考虑到现实世界的业务需求时,一个简单的基于命令的方法是如何变得非常复杂的。你是否真的需要这种复杂性…嗯,这取决于你。
命令查询分离
命令查询分离(CQS)的概念是指系统中的操作大致分为以下两类:
- 命令,是系统执行某些操作的指令,这些操作涉及状态突变,但不产生任何值
- 查询是对产生值但不改变状态的信息的请求
任何当前直接公开其状态以进行读写的对象都可以隐藏其状态(使其成为private
),然后,不是提供 getter 和 setter 对,而是提供一个单一的接口。我的意思是:假设我们有一个Creature
,它有两个属性叫做strength
和agility
。我们可以这样定义这种生物:
1 class Creature
2 {
3 int strength, agility;
4 public:
5 Creature(int strength, int agility)
6 : strength{strength}, agility{agility} {}
7
8 void process_command(const CreatureCommand& cc);
9 int process_query(const CreatureQuery& q) const;
10 };
如您所见,没有 getters 和 setters,但是我们有两个(只有两个!)名为process_command()
和process_query()
的 API 成员,用于与Creature
对象的所有交互。这两个都是专用类,与CreatureAbility
枚举一起定义如下:
1 enum class CreatureAbility { strength, agility };
2
3 struct CreatureCommand
4 {
5 enum Action { set, increaseBy, decreaseBy } action;
6 CreatureAbility ability;
7 int amount;
8 };
9
10 struct CreatureQuery
11 {
12 CreatureAbility ability;
13 };
如您所见,该命令描述了您想要更改哪个成员、如何更改以及更改多少。query 对象只指定要查询的内容,我们假设查询的结果是从函数返回的,而不是在 query 对象本身中设置的(如果其他对象影响了这个对象,正如我们已经看到的,这就是您应该做的)。
下面是process_command()
的定义:
1 void Creature::process_command(const CreatureCommand &cc)
2 {
3 int* ability;
4 switch (cc.ability)
5 {
6 case CreatureAbility::strength:
7 ability = &strength;
8 break;
9 case CreatureAbility::agility:
10 ability = &agility;
11 break;
12 }
13 switch (cc.action)
14 {
15 case CreatureCommand::set:
16 *ability = cc.amount;
17 break;
18 case CreatureCommand::increaseBy:
19 *ability += cc.amount;
20 break;
21 case CreatureCommand::decreaseBy:
22 *ability -= cc.amount;
23 break;
24 }
25 }
下面是更简单的定义:
1 int Creature::process_query(const CreatureQuery &q) const
2 {
3 switch (q.ability)
4 {
5 case CreatureAbility::strength: return strength;
6 case CreatureAbility::agility: return agility;
7 }
8 return 0;
9 }
如果您想要记录或持久保存这些命令和查询,现在只有两个地方需要这样做。所有这些的唯一真正问题是,对于只想以熟悉的方式操作对象的人来说,使用 API 有多困难。
幸运的是,如果我们愿意,我们总是可以制造 getter/setter 对;这些只会调用带有适当参数的process_
方法:
1 void Creature::set_strength(int value)
2 {
3 process_command(CreatureCommand{
4 CreatureCommand::set, CreatureAbility::strength, value
5 });
6 }
7
8 int Creature::get_strength() const
9 {
10 return process_query(CreatureQuery{CreatureAbility::strength});
11 }
不可否认,前面是一个非常简单的例子,说明了 CQS 系统内部实际发生的事情,但是它有望提供一个思路,说明如何将所有的对象接口分成命令和查询部分。
摘要
命令设计模式很简单:它基本上意味着对象可以使用封装了指令的特殊对象相互通信,而不是将相同的指令指定为方法的参数。
有时候,你不希望这样的对象使目标发生变异,或者导致它做一些特定的事情;相反,您希望使用这样的对象从目标查询一个值,在这种情况下,我们通常将这样的对象称为查询。虽然在大多数情况下,查询是依赖于方法的返回类型的不可变对象,但是也有一些情况(例如,参见责任链代理链示例;当你希望返回的结果被其他组件修改时。但是组件本身仍然没有修改,只有结果。
UI 系统中大量使用命令来封装典型的动作(例如,复制或粘贴),然后允许通过几种不同的方式调用单个命令。例如,您可以使用顶级应用程序菜单、工具栏上的按钮或键盘快捷键进行复制。最后,这些动作可以被组合成宏——可以被记录然后随意重放的动作序列。
Footnotes 1
我们有专门的历史调试工具,如 Visual Studio 的 IntelliTrace 或 UndoDB。
十五、解释器
解释器设计模式的目标是,你猜对了,解释输入,尤其是文本输入,尽管公平地说这真的无关紧要。解释器的概念与大学教授的编译理论和类似课程有很大联系。因为我们在这里没有足够的空间来深入研究不同类型的解析器的复杂性,所以本章的目的是简单地展示一些你可能想要解释的事情的例子。
这里有几个相当明显的例子:
- 像
42
或1.234e12
这样的数字文字需要被解释为有效地存储在二进制中。在 C++ 中,这些操作通过 C APIs 如atof()
以及更复杂的库如 Boost.LexicalCast 来实现 - 正则表达式帮助我们找到文本中的模式,但你需要认识到的是,正则表达式本质上是一种独立的、嵌入式的特定领域语言(DSL)。当然,在使用它们之前,必须对它们进行正确的解释。
- 任何结构化数据,无论是 CSV、XML、JSON 还是更复杂的数据,在使用之前都需要解释。
- 在解释器应用的顶峰,我们有完全成熟的编程语言。毕竟,像 C 或 Python 这样的语言的编译器或解释器在使某些东西可执行之前必须真正理解这种语言。
鉴于与口译有关的挑战的扩散和多样性,我们将简单地看一些例子。这些用来说明如何构建一个解释器:要么从零开始,要么利用一个在工业规模上帮助做这些事情的库。
数值表达式计算器
假设我们决定解析非常简单的数学表达式,比如 3+(5–4),也就是说,我们将限制自己使用加法、减法和括号。我们想要一个程序,可以读取这样的表达式,当然,计算表达式的最终值。
我们将手工构建计算器,而不求助于任何解析框架。这应该有望突出解析文本输入所涉及的一些复杂性。
乐星
解释表达式的第一步称为词法分析,它涉及到将一系列字符转换成一系列标记。一个标记通常是一个基本的语法元素,我们应该以这样一个简单的序列结束。在我们的例子中,令牌可以是
- 整数
- 运算符(加号或减号)
- 左括号或右括号
因此,我们可以定义以下结构:
1 struct Token
2 {
3 enum Type { integer, plus, minus, lparen, rparen } type;
4 string text;
5
6 explicit Token(Type type, const string& text)
7 : type{type}, text{text} {}
8
9 friend ostream& operator<<(ostream& os, const Token& obj)
10 {
11 return os << "`" << obj.text << "`";
12 }
13 };
你会注意到Token
不是一个enum
,因为除了类型之外,我们还想存储与这个令牌相关的文本,因为它并不总是预定义的。
现在,给定一个包含表达式的std::string
,我们可以定义一个词法分析过程,将文本转换成vector<Token>
:
1 vector<Token> lex(const string& input)
2 {
3 vector<Token> result;
4
5 for (int i = 0; i < input.size(); ++i)
6 {
7 switch (input[i])
8 {
9 case '+':
10 result.push_back(Token{ Token::plus, "+" });
11 break;
12 case '-':
13 result.push_back(Token{ Token::minus, "-" });
14 break;
15 case '(':
16 result.push_back(Token{ Token::lparen, "(" });
17 break;
18 case ')':
19 result.push_back(Token{ Token::rparen, ")" });
20 break;
21 default:
22 // number ???
23 }
24 }
25 }
解析预定义的令牌很容易。事实上,我们本可以把它们作为map<BinaryOperation, char>
来添加,以简化事情。但是解析一个数字并不容易。如果我们击中了 1,我们应该等待,看看下一个字符是什么。为此,我们定义了一个单独的例程:
1 ostringstream buffer;
2 buffer << input[i];
3 for (int j = i + 1; j < input.size(); ++j)
4 {
5 if (isdigit(input[j]))
6 {
7 buffer << input[j];
8 ++i;
9 }
10 else
11 {
12 result.push_back(Token{ Token::integer, buffer.str() });
13 break;
14 }
15 }
本质上,当我们不断读取(抽取)数字时,我们将它们添加到缓冲区中。完成后,我们从整个缓冲区中创建一个Token
,并将其添加到结果vector
中。
从语法上分析
解析过程将一系列标记转换成有意义的、通常面向对象的结构。在顶部,拥有一个树的所有元素都实现的抽象父类型通常很有用:
1 struct Element
2 {
3 virtual int eval() const = 0;
4 };
类型的eval()
函数计算这个元素的数值。接下来,我们可以创建一个元素来存储整数值(如 1、5 或 42):
1 struct Integer : Element
2 {
3 int value;
4
5 explicit Integer(const int value)
6 : value(value) {}
7
8 int eval() const override { return value; }
9 };
如果我们没有一个Integer
,就必须有一个加法或者减法之类的运算。在我们的例子中,所有的操作都是二进制的,这意味着它们有两个部分。例如,我们模型中的2+3
可以用伪代码表示为BinaryOperation{Literal{2}, Literal{3}, addition}
:
1 struct BinaryOperation : Element
2 {
3 enum Type { addition, subtraction } type;
4 shared_ptr<Element> lhs, rhs;
5
6 int eval() const override
7 {
8 if (type == addition)
9 return lhs->eval() + rhs->eval();
10 return lhs->eval() - rhs->eval();
11 }
12 };
注意,在前面,我使用了一个enum
而不是一个enum class
,这样我可以在后面写BinaryOperation::addition
。
但是不管怎样,继续解析过程。我们需要做的就是将一系列的Token
转换成一棵Expression
的二叉树。从一开始,它看起来就像这样:
1 shared_ptr<Element> parse(const vector<Token>& tokens)
2 {
3 auto result = make_unique<BinaryOperation>();
4 bool have_lhs = false; // this will need some explaining :)
5 for (size_t i = 0; i < tokens.size(); i++)
6 {
7 auto token = tokens[i];
8 switch(token.type)
9 {
10 // process each of the tokens in turn
11 }
12 }
13 return result;
14 }
从前面的代码中我们唯一需要讨论的是have_lhs
变量。记住,你试图得到的是一棵树,在这棵树的根部,我们期待一个BinaryExpression
,根据定义,它有左右两边。但是当我们在一个数字上时,我们怎么知道它是表达式的左边还是右边呢?是的,我们不知道,这就是为什么我们要追踪这个。
现在让我们一个案例一个案例地检查一下。首先,整数——它们直接映射到我们的Integer
结构,所以我们所要做的就是将文本转换成数字。(顺便说一句,如果我们愿意,我们也可以在 lexing 阶段这样做。)
1 case Token::integer:
2 {
3 int value = boost::lexical_cast<int>(token.text);
4 auto integer = make_shared<Integer>(value);
5 if (!have_lhs) {
6 result->lhs = integer;
7 have_lhs = true;
8 }
9 else result->rhs = integer;
10 }
plus
和minus
标记简单地决定了我们当前正在处理的操作的类型,所以它们很简单:
1 case Token::plus:
2 result->type = BinaryOperation::addition;
3 break;
4 case Token::minus:
5 result->type = BinaryOperation::subtraction;
6 break;
然后是左括号。是的,只有左边,我们在这个层次上没有明确地检测到右边的。基本上,这里的想法很简单:找到右括号(我现在忽略嵌套的括号),取出整个子表达式,parse()
递归地将它设置为我们当前正在处理的表达式的左边或右边:
1 case Token::lparen:
2 {
3 int j = i;
4 for (; j < tokens.size(); ++j)
5 if (tokens[j].type == Token::rparen)
6 break; // found it!
7
8 vector<Token> subexpression(&tokens[i + 1], &tokens[j]);
9 auto element = parse(subexpression); // recursive call
10 if (!have_lhs)
11 {
12 result->lhs = element;
13 have_lhs = true;
14 }
15 else result->rhs = element;
16 i = j; // advance
17 }
在真实的场景中,您会希望这里有更多的安全特性:不仅处理嵌套括号(我认为这是必须的),还处理缺少右括号的不正确表达式。如果真的不见了,你会怎么处理?抛出异常?尝试解析剩下的内容,并假设结束在最后?还有别的吗?所有这些问题都留给读者作为练习。
根据 C++ 本身的经验,我们知道为解析错误生成有意义的错误消息是非常困难的。事实上,您会发现一种叫做“跳过”的现象,在这种情况下,如果有疑问,词法分析器或语法分析器将试图跳过不正确的代码,直到遇到有意义的东西:这种方法正是静态分析工具所采用的,当用户键入不完整的代码时,静态分析工具有望对其正确工作。
使用词法分析器和语法分析器
实现了lex()
和parse()
之后,我们最终可以解析表达式并计算其值:
1 string input{ "(13-4)-(12+1)" };
2 auto tokens = lex(input);
3 auto parsed = parse(tokens);
4 cout << input << " = " << parsed->eval() << endl;
5 // prints "(13-4)-(12+1) = -4"
用 Boost 解析。精神
在现实世界中,很少有人用手动解析器来处理复杂的事情。当然,如果您正在解析一种“琐碎的”数据存储格式,比如 XML 或 JSON,那么手工滚动解析器是很容易的。但是如果您正在实现自己的 DSL 或编程语言,这不是一个选项。
助推。Spirit 是一个库,它通过为解析器的构造提供 succint(虽然不是特别直观)API 来帮助创建解析器。该库并不试图明确地将词法分析和解析阶段分开(除非您真的想这样做),而是允许您定义如何将文本结构映射到您定义的类型的对象上。
让我给你看一些使用 Boost 的例子。tln 编程语言的精神。 1
抽象语法树
首先,您需要 AST(抽象语法树)。在这方面,我简单地创建了一个支持访问者设计模式的基类,因为遍历这些结构非常重要:
1 struct ast_element
2 {
3 virtual ~ast_element() = default;
4 virtual void accept(ast_element_visitor& visitor) = 0;
5 };
然后,该接口用于在我的语言中定义不同的代码结构,例如:
1 struct property : ast_element
2 {
3 vector<wstring> names;
4 type_specification type;
5 bool is_constant{ false };
6 wstring default_value;
7
8 void accept(ast_element_visitor& visitor) override
9 {
10 visitor.visit(*this);
11 }
12 };
前面的属性定义有四个不同的部分,每个部分都存储在一个公共可访问的字段中。注意,它使用了一个type_specification
,它本身就是另一个ast_element
。
AST 的每一个类都需要适应 Boost。fusion——另一个 Boost 库,支持编译时(元编程)和运行时算法的融合(因此得名)。改编代码非常简单:
1 BOOST_FUSION_ADAPT_STRUCT(
2 tlön::property,
3 (std::vector<std::wstring>, names),
4 (tlön::type_specification, type),
5 (bool, is_constant),
6 (std::wstring, default_value)
7 )
Spirit 解析成常见的数据类型没有问题,比如一个std::vector
或std::optional
。它在多态性方面确实有一些问题:Spirit 更喜欢使用variant
,而不是让您的 AST 类型相互继承,也就是说:
1 typedef variant<function_body, property, function_signature> class_member;
句法分析程序
助推。Spirit 让我们将解析器定义为一组规则。使用的语法非常类似于正则表达式或 BNF (Bachus-Naur 形式)符号,除了运算符放在符号之前,而不是之后。以下是一个规则示例:
1 class_declaration_rule %=
2 lit(L"class ") >> +(alnum) % '.'
3 >> -(lit(L"(") >> -parameter_declaration_rule % ',' >> lit(")"))
4 >> "{"
5 >> *(function_body_rule | property_rule | function_signature_rule)
6 >> "}";
前面期望类声明以单词class
开始。然后它期望一个或多个单词(每个单词是一个或多个字母数字字符,因此是+(alnum)
),用句点'.'
分隔——这就是%
操作符的用途。您可能已经猜到,结果会映射到一个vector
上。随后,在花括号之后,我们期望零个或多个函数、属性或函数签名的定义——使用variant
将这些字段映射到我们之前的定义。
自然,有些元素是 AST 元素整个层次结构的“根”。在我们的例子中,这个根叫做file
(惊喜!),这里有一个函数既解析文件又漂亮地打印它:
1 template<typename TLanguagePrinter, typename Iterator>
2 wstring parse(Iterator first, Iterator last)
3 {
4 using spirit::qi::phrase_parse;
5
6 file f;
7 file_parser<wstring::const_iterator> fp{};
8 auto b = phrase_parse(first, last, fp, space, f);
9 if (b)
10 {
11 return TLanguagePrinter{}.pretty_print(f);
12 }
13 return wstring(L"FAIL");
14 }
前面的类型TLanguagePrinter
本质上是一个访问者,它知道如何用不同的语言(比如 C++)来呈现我们的 AST。
打印机
在解析了语言之后,我们可能想要编译它,或者在我的例子中,将它转换成其他语言。考虑到我们之前已经在整个 AST 层次结构中实现了一个accept()
方法,这相当容易。
唯一的挑战是如何对待variant
类型的人,因为他们需要特殊的访客。在std::variant
的情况下,你要找的是std::visit()
,但是因为我们用的是boost::variant
,所以要调用的函数是boost::accept_visitor()
。这个函数要求你给它一个从static_visitor
继承的类的实例,并为每一个可能的类型提供函数调用重载。这里有一个例子:
1 struct default_value_visitor : static_visitor<>
2 {
3 cpp_printer& printer;
4
5 explicit default_value_visitor(cpp_printer& printer)
6 : printer{printer}
7 {
8 }
9
10 void operator()(const basic_type& bt) const
11 {
12 // for a scalar value, we just dump its default
13 printer.buffer << printer.default_value_for(bt.name);
14 }
15
16 void operator()(const tuple_signature& ts) const
17 {
18 for (auto& e : ts.elements)
19 {
20 this->operator()(e.type);
21 printer.buffer << ", ";
22 }
23 printer.backtrack(2);
24 }
25 };
然后调用accept_visitor(foo, default_value_visitor{...})
,正确的重载将被调用,这取决于实际存储在variant
中的对象的类型。
摘要
首先,需要说明的是,相对而言,解释器设计模式有点不常见——构建解析器的挑战现在被认为是无关紧要的,这就是为什么我看到它在许多英国大学(包括我自己的大学)的计算机科学课程中被删除。此外,除非你打算从事语言设计,或者制作静态代码分析工具,否则你不太可能找到需求量很大的构建解析器的技能。
也就是说,解释的挑战是计算机科学的一个完全独立的领域,一本设计模式书的一章无法合理地公正对待它。如果您对这个主题感兴趣,我建议您查看诸如 Lex/Yacc、ANTLR 等专门针对 lexer/parser 构造的框架。我还可以推荐为流行的 ide 编写静态分析插件——这是一个很好的方式来感受真实的 ASTs 是什么样子的,是如何遍历的,甚至是如何修改的。
Footnotes 1
tln 是一种玩具语言,我构建它是为了演示“如果你不喜欢现有的语言,就构建一种新的语言”的想法。它使用 Boost。Spirit 并交叉编译(transpiles)成 C++。它是开源的,可以在 https://github.com/nesteruk/tlon
找到
十六、迭代器
每当您开始处理复杂的数据结构时,都会遇到遍历的问题。这可以用不同的方式处理,但是最常见的遍历方式,比如说,一个vector
是使用一个叫做迭代器的东西。
简单地说,迭代器是一个对象,它可以指向集合中的一个元素,并且知道如何移动到集合中的下一个元素。因此,只需要实现++
操作符和!=
操作符(这样你就可以比较两个迭代器并检查它们是否指向同一个东西)。就这样。
C++ 标准库大量使用迭代器,所以我们将讨论迭代器的使用方式,然后我们将看一看如何创建我们自己的迭代器以及迭代器的局限性。
标准库中的迭代器
假设您有一个名称列表,例如:
1 vector<string> names{ "john", "jane", "jill", "jack" };
如果您想获得names
集合中的第一个名字,您可以调用一个名为begin()
的函数。这个函数不会通过值或引用给出名字;相反,它给你一个迭代器:
1 vector<string>::iterator it = names.begin(); // begin(names)
函数begin()
作为vector
的成员函数和全局函数存在。全局变量对数组(C 风格的数组,而不是 ??)特别有用,因为它们不能有成员函数。
所以begin()
返回一个迭代器,你可以把它看作一个指针:在vector
的情况下,它有相似的机制。例如,您可以解引用迭代器来打印实际名称:
1 cout << "first name is " << *it << "\n";
2 // first name is john
给我们的迭代器知道如何前进,也就是移动到指向下一个元素。重要的是要认识到++
指的是向前移动的概念,也就是说,它不同于在内存中向前移动(即增加内存地址)的指针的++
:
1 ++it; // now points to jane
我们也可以使用迭代器(和指针一样)来修改它所指向的元素:
1 it->append(" goodall"s);
2 cout << "full name is " << *it << "\n";
3 // full name is jane goodall
现在,begin()
的对应词当然是end()
,但是它并不指向最后一个元素,而是指向最后一个元素之后的元素。下面是一个笨拙的例子:
1 1 2 3 4
2 begin() ^ ^ end()
可以使用end()
作为终止条件。例如,让我们使用我们的it
迭代器变量打印其余的名字:
1 while (++it != names.end())
2 {
3 cout << "another name: " << *it << "\n";
4 }
5 // another name: jill
6 // another name: jack
除了begin()
和end()
,我们还有rbegin()
和rend()
,它们允许我们在集合中向后移动。在这种情况下,您可能已经猜到了,rbegin()
指向最后一个元素,rend()
指向第一个元素之前的一个元素:
1 for (auto ri = rbegin(names); ri != rend(names); ++ri)
2 {
3 cout << *ri;
4 if (ri + 1 != rend(names)) // iterator arithmetic
5 cout << ", ";
6 }
7 cout << endl;
8 // jack, jill, jane goodall, john
前面有两件事值得指出。首先,即使我们正在向后遍历向量,我们仍然在迭代器上使用了++
操作符。第二,我们被允许做算术:同样,当我写ri + 1
时,这指的是ri
之前的元素,而不是之后的元素。
我们也可以有不允许修改对象的常量迭代器:它们通过cbegin()/cend()
返回,当然,也有反向变量crbegin()/crend()
:
1 vector<string>::const_reverse_iterator jack = crbegin(names);
2 // won't work
3 *jack += "reacher";
最后,值得一提的是现代 C++ 结构,这是一个基于范围的 for 循环,它是从容器的begin()
一直迭代到到达容器的end()
的一种速记方式:
1 for (auto& name : names)
2 cout << "name = " << name << "\n";
注意迭代器在这里是自动解引用的:变量name
是一个引用,但是你同样可以通过值进行迭代。
遍历二叉树
让我们来完成遍历二叉树的经典“comp sci”练习。首先,我们将这个 tere 的一个节点定义如下:
1 template <typename T> struct Node
2 {
3 T value;
4 Node<T> *left = nullptr;
5 Node<T> *right = nullptr;
6 Node<T> *parent = nullptr;
7 BinaryTree<T>* tree = nullptr;
8 };
每个节点都有对其left
和right
分支、其父节点(如果有)以及整个树的引用。节点可以独立构建,也可以根据其子节点的规范来构建:
1 explicit Node(const T& value)
2 : value(value)
3 {
4 }
5
6 Node(const T& value, Node<T>* const left, Node<T>* const right)
7 : value(value),
8 left(left),
9 right(right)
10 {
11 this->left->tree = this->right->tree = tree;
12 this->left->parent = this->right->parent = this;
13 }
最后,我们引入一个实用的成员函数来设置tree
指针。这是在Node
的所有子节点上递归完成的:
1 void set_tree(BinaryTree<T>* t)
2 {
3 tree = t;
4 if (left) left->set_tree(t);
5 if (right) right->set_tree(t);
6 }
有了这个,我们现在可以定义一个叫做BinaryTree
的结构——正是这个结构允许迭代:
1 template <typename T> struct BinaryTree
2 {
3 Node<T>* root = nullptr;
4
5 explicit BinaryTree(Node<T>* const root)
6 : root{ root }
7 {
8 root->set_tree(this);
9 }
10 };
现在我们可以为树定义一个迭代器。迭代二叉树有三种常见的方法,我们首先实现的是 preorder:
- 我们一遇到这个元素就返回它。
- 我们递归地遍历左边的子树。
- 我们递归地遍历右边的子树。
所以让我们从构造器开始:
1 template <typename U>
2 struct PreOrderIterator
3 {
4 Node<U>* current;
5
6 explicit PreOrderIterator(Node<U>* current)
7 : current(current)
8 {
9 }
10
11 // other members here
12 };
我们需要定义operator !=
来和其他迭代器比较。因为我们的迭代器充当指针,所以这是微不足道的:
1 bool operator!=(const PreOrderIterator<U>& other)
2 {
3 return current != other.current;
4 }
我们还需要*
操作符来取消引用:
1 Node<U>& operator*() { return *current; }
现在,困难的部分来了:遍历树。这里的挑战是我们不能使算法递归——记住,遍历发生在++
操作符中,所以我们最终实现如下:
1 PreOrderIterator<U>& operator++()
2 {
3 if (current->right)
4 {
5 current = current->right;
6 while (current->left)
7 current = current->left;
8 }
9 else
10 {
11 Node<T>* p = current->parent;
12 while (p && current == p->right)
13 {
14 current = p;
15 p = p->parent;
16 }
17 current = p;
18 }
19 return *this;
20 }
这还挺乱的!此外,它看起来一点也不像树遍历的经典实现,因为我们没有递归。我们一会儿会回到这个话题。
现在,最后一个问题是我们如何从我们的BinaryTree
中暴露迭代器。如果我们将它定义为树的默认迭代器,我们可以如下填充它的成员:
1 typedef PreOrderIterator<T> iterator;
2
3 iterator begin()
4 {
5 Node<T>* n = root;
6
7 if (n)
8 while (n->left)
9 n = n->left;
10 return iterator{ n };
11 }
12
13 iterator end()
14 {
15 return iterator{ nullptr };
16 }
值得注意的是,在begin()
中迭代并不从整棵树的根开始;相反,它从最左边的可用节点开始。
现在所有的部分都就位了,下面是我们如何进行遍历:
1 BinaryTree<string> family{
2 new Node<string>{"me",
3 new Node<string>{"mother",
4 new Node<string>{"mother's mother"},
5 new Node<string>{"mother's father"}
6 },
7 new Node<string>{"father"}
8 }
9 };
10
11 for (auto it = family.begin(); it != family.end(); ++it)
12 {
13 cout << (*it).value << "\n";
14 }
您也可以将这种形式的遍历公开为一个单独的对象,即:
1 class pre_order_traversal
2 {
3 BinaryTree<T>& tree;
4 public:
5 pre_order_traversal(BinaryTree<T>& tree) : tree{tree} {}
6 iterator begin() { return tree.begin(); }
7 iterator end() { return tree.end(); }
8 } pre_order;
用作:
1 for (const auto& it: family.pre_order)
2 {
3 cout << it.value << "\n";
4 }
类似地,可以定义in_order
和post_order
遍历算法来公开适当的迭代器。
协程迭代
我们有一个严重的问题:在我们的遍历代码中,operator++
是一个不可读的混乱,与你在维基百科上读到的任何关于树遍历的内容都不匹配。这是可行的,但这仅仅是因为我们预先初始化了迭代器,从最左边的节点开始,而不是根节点,这也是有问题的和令人困惑的。
为什么我们会有这个问题?因为++
操作符的函数是不可恢复的:它不能在调用之间保持栈,因此递归是不可能的。现在,如果我们有一种机制可以鱼和熊掌兼得:可以执行适当递归的可恢复函数,会怎么样?这正是协程的作用。
使用协程,我们可以如下实现后序树遍历:
1 generator<Node<T>*> post_order_impl(Node<T>* node)
2 {
3 if (node)
4 {
5 for (auto x : post_order_impl(node->left))
6 co_yield x;
7 for (auto y : post_order_impl(node->right))
8 co_yield y;
9 co_yield node;
10 }
11 }
12
13 generator<Node<T>*> post_order()
14 {
15 return post_order_impl(root);
16 }
这不是很棒吗?算法终于又可读了!此外,看不到任何begin()/end()
:我们只是返回一个generator
,这是一个专门设计用于逐步返回通过co_yield
输入的值的类型。在产生每个值之后,我们可以暂停执行并做其他事情(比如打印值),然后在不丢失上下文的情况下继续迭代。这使得递归成为可能,并允许我们这样写:
1 for (auto it: family.post_order())
2 {
3 cout << it->value << endl;
4 }
协程是 C++ 的未来,它解决了许多传统迭代器不适合的问题。
摘要
迭代器设计模式在 C++ 中以显式和隐式(例如,基于范围的)形式无处不在。不同类型的迭代器用于迭代不同的对象:例如,反向迭代器可能适用于vector
,但不适用于单链表。
实现您自己的迭代器就像提供++
和!=
操作符一样简单。大多数迭代器只是简单的指针外观,用于在集合被丢弃之前遍历一次。
协程修复了迭代器中存在的一些问题:它们允许在调用之间保留状态,这是其他语言(例如 C#)很久以前就已经实现的。因此,协程允许我们编写递归算法。
十七、中介
我们编写的大部分代码都有不同的组件(类)通过直接引用或指针相互通信。但是,也有不希望对象一定意识到对方存在的情况。或者,也许你确实希望他们知道彼此,但你仍然不希望他们通过指针或引用来交流,因为,嗯,那些可能会变得陈旧,你也不想去引用一个nullptr
,不是吗?
因此,中介是一种促进组件间通信的机制。自然地,中介本身需要能够被参与的每个组件访问,这意味着它要么是一个全局static
变量,要么只是一个被注入每个组件的引用。
聊天室
典型的互联网聊天室是中介设计模式的经典例子,所以在进入更复杂的内容之前,让我们先实现它。
聊天室中最简单的参与者实现可以是:
1 struct Person
2 {
3 string name;
4 ChatRoom* room = nullptr;
5 vector<string> chat_log;
6
7 Person(const string& name);
8
9 void receive(const string& origin, const string& message);
10 void say(const string& message) const;
11 void pm(const string& who, const string& message) const;
12 };
所以我们有了一个拥有name
(用户 id)、聊天日志和指向实际ChatRoom
的指针的人。我们有一个构造器和三个成员函数:
- 允许我们接收信息。这个功能通常会在用户的屏幕上显示消息,并将其添加到聊天日志中。
say()
允许此人向房间里的每个人广播消息。pm()
是私人信息传递功能。您需要指定邮件收件人的姓名。
say()
和pm()
只是将操作转发到聊天室。说到这里,我们来实际实现一下ChatRoom
—并不是特别复杂:
1 struct ChatRoom
2 {
3 vector<Person*> people; // assume append-only
4
5 void join(Person* p);
6 void broadcast(const string& origin, const string& message);
7 void message(const string& origin, const string& who,
8 const string& message);
9 };
是否使用指针、引用或shared_ptr
来实际存储聊天室参与者的列表最终取决于您:唯一的限制是std::vector
不能存储引用。所以,我决定在这里用指针。ChatRoom
API 非常简单:
- 让一个人加入房间。我们不打算实现
leave()
,而是将这个想法推迟到本章的后续例子中。 - 将消息发送给每个人…嗯,不完全是每个人:我们不需要将消息发送回发送它的人。
message()
发送私人信息。
join()
的实现如下:
1 void ChatRoom::join(Person* p)
2 {
3 string join_msg = p->name + " joins the chat";
4 broadcast("room", join_msg);
5 p->room = this;
6 people.push_back(p);
7 }
就像经典的 IRC 聊天室一样,我们向房间里的每个人广播某人已经加入的消息。在这种情况下,origin
被指定为"room"
,而不是被加入的人。然后我们设置这个人的room
指针,并将他们添加到房间里的人的列表中。
现在,让我们看看broadcast()
:这是向每个房间参与者发送消息的地方。请记住,每个参与者都有自己的Person::receive()
函数来处理消息,因此实现有些琐碎:
1 void ChatRoom::broadcast(const string& origin, const string& message)
2 {
3 for (auto p : people)
4 if (p->name != origin)
5 p->receive(origin, message);
6 }
我们是否想要阻止广播信息被转发给我们自己是一个争论点,但我在这里积极地避免它。不过,其他人都明白这一点。
最后,这里是用message()
实现的私有消息:
1 void ChatRoom::message(const string& origin,
2 const string& who, const string& message)
3 {
4 auto target = find_if(begin(people), end(people),
5 & { return p->name == who; });
6 if (target != end(people))
7 {
8 (*target)->receive(origin, message);
9 }
10 }
这将在列表people
中搜索收件人,如果找到了收件人(因为谁知道呢,他们可能已经离开房间了),就将消息发送给那个人。
回到say()
和pm()
的Person's
实现,它们是:
1 void Person::say(const string& message) const
2 {
3 room->broadcast(name, message);
4 }
5
6 void Person::pm(const string& who, const string& message) const
7 {
8 room->message(name, who, message);
9 }
至于receive()
,嗯,这是一个在屏幕上显示消息并将其添加到聊天日志的好地方。
1 void Person::receive(const string& origin, const string& message)
2 {
3 string s{ origin + ": \"" + message + "\"" };
4 cout << "[" << name << "'s chat session] " << s << "\n";
5 chat_log.emplace_back(s);
6 }
我们在这里做了额外的工作,不仅显示消息来自谁,还显示我们当前在谁的聊天会话中——这将有助于诊断谁在何时说了什么。
这是我们将要经历的场景:
1 ChatRoom room;
2
3 Person john{ "john" };
4 Person jane{ "jane" };
5 room.join(&john);
6 room.join(&jane);
7 john.say("hi room");
8 jane.say("oh, hey john");
9
10 Person simon("simon");
11 room.join(&simon);
12 simon.say("hi everyone!");
13
14 jane.pm("simon", "glad you could join us, simon");
这是输出结果:
1 [john's chat session] room: "jane joins the chat"
2 [jane's chat session] john: "hi room"
3 [john's chat session] jane: "oh, hey john"
4 [john's chat session] room: "simon joins the chat"
5 [jane's chat session] room: "simon joins the chat"
6 [john's chat session] simon: "hi everyone!"
7 [jane's chat session] simon: "hi everyone!"
8 [simon's chat
session] jane: "glad you could join us, simon"
事件中介
在聊天室的例子中,我们遇到了一个一致的主题:每当有人发布消息时,参与者都需要通知。对于观察者模式来说,这似乎是一个完美的场景,这将在第二十章中讨论:中介者拥有一个所有参与者共享的事件的想法;然后,参与者可以订阅该事件以接收通知,他们还可以引发该事件,从而触发所述通知。
C++ 中没有内置事件(不像 C#),所以我们将使用一个库解决方案来演示。助推。Signals2 为我们提供了必要的功能,尽管术语略有不同:我们通常称之为信号(生成通知的对象)和槽(处理通知的函数)。
让我们来看一个更简单的例子,而不是再次重做聊天室:想象一场足球比赛,有球员和足球教练。教练看到自己的球队得分,自然要恭喜球员。当然,他们需要一些关于这个事件的信息,比如谁进了球,到目前为止他们进了多少球。
我们可以为任何类型的事件数据引入一个基类:
1 struct EventData
2 {
3 virtual ~EventData() = default;
4 virtual void print() const = 0;
5 };
我添加了print()
函数,这样每个事件都可以被打印到命令行,还添加了一个虚拟析构函数,让 ReSharper 闭嘴。现在,我们可以从这个类派生出一些与目标相关的数据:
1 struct PlayerScoredData : EventData
2 {
3 string player_name;
4 int goals_scored_so_far;
5
6 PlayerScoredData(const string& player_name, const int goals_scored_so_far)
7 : player_name(player_name),
8 goals_scored_so_far(goals_scored_so_far) {}
9
10 void print() const override
11 {
12 cout << player_name << " has scored! (their "
13 << goals_scored_so_far << " goal)" << "\n";
14 }
15 };
我们将再次构建一个中介器,但是它没有行为!说真的,有了事件驱动的基础设施,就不再需要它们了:
1 struct Game
2 {
3 signal<void(EventData*)> events; // observer
4 };
事实上,你可以只拥有一个全局signal
而不创建一个Game
类,但是我们在这里使用最小惊奇原则,如果一个Game&
被注入一个组件,我们知道那里有一个明显的依赖关系。
不管怎样,我们现在可以构造Player
类了。一名球员有一个名字,他们在比赛中的进球数,当然还有一个仲裁人Game
的参考:
1 struct Player
2 {
3 string name;
4 int goals_scored = 0;
5 Game& game;
6
7 Player(const string& name, Game& game)
8 : name(name), game(game) {}
9
10 void score()
11 {
12 goals_scored++;
13 PlayerScoredData ps{name, goals_scored};
14 game.events(&ps);
15 }
16 };
这里的Player::score()
是一个有趣的函数:它使用events
信号创建一个PlayerScoredData
,并将其发布给所有订阅者。谁得到这个事件?为什么,当然是一只Coach
:
1 struct Coach
2 {
3 Game& game;
4 explicit Coach(Game& game) : game(game)
5 {
6 // celebrate if player has scored <3 goals
7 game.events.connect([](EventData* e)
8 {
9 PlayerScoredData* ps = dynamic_cast<PlayerScoredData*>(e);
10 if (ps && ps->goals_scored_so_far < 3)
11 {
12 cout << "coach says: well done, " << ps->player_name << "\n";
13 }
14 });
15 }
16 };
Coach
类的实现很简单;我们的教练连名字都没有。但是我们确实给了他一个构造器,在那里创建了一个对game.events
的订阅,这样,无论什么时候发生了什么,教练都可以在提供的 lambda (slot)中处理事件数据。
注意,lambda 的参数类型是EventData*
——我们不知道一个球员是得分了还是被罚下了,所以我们需要dynamic_cast
(或类似的机制)来确定我们得到了正确的类型。
有趣的是,所有的魔法都发生在设置阶段:没有必要为特定的信号明确地登记插槽。客户端可以使用它们的构造器自由创建对象,然后,当玩家得分时,会发送通知:
1 Game game;
2 Player player{ "Sam", game };
3 Coach coach{ game };
4
5 player.score();
6 player.score();
7 player.score(); // ignored by coach
这会产生以下输出:
1 coach says: well done, Sam
2 coach says: well done, Sam
输出只有两行长,因为在第三个目标上,教练不再感兴趣了。
摘要
中介设计模式本质上提出了引入一个中间组件,系统中的每个人都可以引用该组件,并可以使用该组件相互通信。代替直接的内存地址,通信可以通过标识符(用户名、唯一的 id 等)进行。
中介器最简单的实现是一个成员列表和一个函数,它遍历列表并做它想要做的事情——无论是对列表的每个元素还是有选择地。
更复杂的 Mediator 实现可以使用事件来允许参与者订阅(和取消订阅)系统中发生的事情。这样,从一个组件发送到另一个组件的消息可以被视为事件。在这种设置中,如果参与者对某些事件不再感兴趣或者他们将要完全离开该系统,他们也很容易取消订阅这些事件。
十八、备忘录
当我们查看命令设计模式时,我们注意到理论上记录每一个单独的更改列表允许您将系统回滚到任何时间点——毕竟,您已经保留了所有修改的记录。
但是,有时您并不真正关心回放系统的状态,但是如果需要的话,您确实关心能够将系统回滚到特定的状态。
这正是 Memento 模式所做的:它存储系统的状态,并将其作为一个专用的、只读的对象返回,没有自己的行为。如果你愿意的话,这个“令牌”只能用于将它反馈到系统中,以将它恢复到它所代表的状态。
让我们看一个例子。
银行存款
让我们以之前创建的银行账户为例:
1 class BankAccount
2 {
3 int balance = 0;
4 public:
5 explicit BankAccount(const int balance)
6 : balance(balance) {}
但是现在我们决定做一个只有deposit()
功能的银行账户。与前面示例中的void
不同,deposit()
现在将返回一个Memento
:
1 Memento deposit(int amount)
2 {
3 balance += amount;
4 return { balance };
5 }
并且该备忘录然后将可用于将账户回滚到先前的状态:
1 void restore(const Memento& m)
2 {
3 balance = m.balance;
4 }
5 };
至于备忘录本身,我们可以做一个简单的实现:
1 class Memento
2 {
3 int balance;
4 public:
5 Memento(int balance)
6 : balance(balance)
7 {
8 }
9 friend class BankAccount;
10 };
这里有两点需要指出:
Memento
类是不可变的。想象一下,如果你真的可以改变平衡:你可以将账户回滚到一个从未有过的状态!- memento 将
BankAccount
声明为朋友类。这允许帐户实际使用balance
字段。另一个可行的方法是让Memento
成为BankAccount
的内部类。
下面是如何使用这样的设置:
1 void memento()
2 {
3 BankAccount ba{ 100 };
4 auto m1 = ba.deposit(50);
5 auto m2 = ba.deposit(25);
6 cout << ba << "\n"; // Balance: 175
7
8 // undo to m1
9 ba.restore(m1);
10 cout << ba << "\n"; // Balance: 150
11
12 // redo
13 ba.restore(m2);
14 cout << ba << "\n"; // Balance: 175
15 }
这个实现足够好了,尽管还缺少一些东西。例如,你永远不会得到代表期初余额的备忘录,因为构造器不能返回值。你可以在里面放一个指针,但是看起来有点难看。
撤消和重做
如果你要保存由BankAccount
生成的每一个备忘录会怎么样?在这种情况下,您会遇到类似于我们的Command
模式实现的情况,撤销和重做操作是这个记录的副产品。让我们看看如何用备忘录获得撤销/重做功能。
我们将引入一个新的银行账户类BankAccount2
,它将保存它所生成的每一个备忘录:
1 class BankAccount2 // supports undo/redo
2 {
3 int balance = 0;
4 vector<shared_ptr<Memento>> changes;
5 int current;
6 public:
7 explicit BankAccount2(const int balance) : balance(balance)
8 {
9 changes.emplace_back(make_shared<Memento>(balance));
10 current = 0;
11 }
我们现在已经解决了返回初始平衡的问题:初始变化的备忘录也被存储。当然,这个备忘录实际上并没有被返回,所以为了回滚到它,我想你可以实现一些reset()
函数之类的东西——这完全取决于你。
在前面的例子中,我们使用shared_ptr
来存储备忘录,我们也使用shared_ptr
来返回它们。此外,我们使用current
字段作为变更列表的“指针”,这样,如果我们决定撤销并后退一步,我们总是可以重做并恢复到我们刚刚做过的事情。
现在,这里是deposit()
函数的实现:
1 shared_ptr<Memento> deposit(int amount)
2 {
3 balance += amount;
4 auto m = make_shared<Memento>(balance);
5 changes.push_back(m);
6 ++current;
7 return m;
8 }
现在有趣的事情来了(顺便说一下,我们仍然在列出BankAccount2
的成员)。我们添加了一个基于备忘录恢复帐户状态的方法:
1 void restore(const shared_ptr<Memento>& m)
2 {
3 if (m)
4 {
5 balance = m->balance;
6 changes.push_back(m);
7 current = changes.size() - 1;
8 }
9 }
恢复过程与我们之前看到的过程有很大不同。首先,我们实际上检查了shared_ptr
是否被初始化——这是相关的,因为我们现在有了一种发送无操作信号的方式:只返回一个默认值。此外,当我们恢复一个备忘录时,我们实际上将该备忘录推入更改列表中,以便撤销操作可以正确地对其进行操作。
现在,这里是undo()
的(相当棘手的)实现:
1 shared_ptr<Memento> undo()
2 {
3 if (current > 0)
4 {
5 --current;
6 auto m = changes[current];
7 balance = m->balance;
8 return m;
9 }
10 return{};
11 }
如果current
指向大于零变化,我们只能undo()
。如果是这样的话,我们把指针移回来,在那个位置抓取变化,应用它,然后返回那个变化。如果我们不能回滚到前一个备忘录,我们返回一个默认构造的shared_ptr
,为此我们签入restore()
。
redo()
的实现非常相似:
1 shared_ptr<Memento> redo()
2 {
3 if (current + 1 < changes.size())
4 {
5 ++current;
6 auto m = changes[current];
7 balance = m->balance;
8 return m;
9 }
10 return{};
11 }
同样,我们需要能够重做一些事情:如果可以,我们安全地做,如果不行,我们什么也不做,返回一个空指针。综上所述,我们现在可以开始使用撤销/重做功能了:
1 BankAccount2 ba{ 100 };
2 ba.deposit(50);
3 ba.deposit(25); // 125
4 cout << ba << "\n";
5
6 ba.undo();
7 cout << "Undo 1: " << ba << "\n"; // Undo 1: 150
8 ba.undo();
9 cout << "Undo 2: " << ba << "\n"; // Undo 2: 100
10 ba.redo();
11 cout << "Redo 2: " << ba << "\n"; // Redo 2: 150
12 ba.undo(); // back to 100 again
摘要
Memento 模式就是分发令牌,这些令牌可以用来将系统恢复到以前的状态。通常,令牌包含将系统移动到特定状态所需的所有信息,如果它足够小,您还可以使用它来记录系统的所有状态,以便不仅允许将系统任意重置到先前的状态,还允许对系统所处的所有状态进行受控的向后(撤消)和向前(重做)导航。
十九、空对象
我们并不总是选择我们工作的界面。例如,我宁愿让我的车自己把我送到目的地,而不用我把 100%的注意力放在路上和旁边开车的危险的疯子身上。软件也是如此:有时你并不真的想要某项功能,但它已经内置在界面中了。那你是做什么的?你创建了一个空对象。
方案
假设您继承了一个使用以下接口的库:
1 struct Logger
2 {
3 virtual ~Logger() = default;
4 virtual void info(const string& s) = 0;
5 virtual void warn(const string& s) = 0;
6 };
本库使用该接口对银行账户进行操作,例如:
1 struct BankAccount
2 {
3 std::shared_ptr<Logger> log;
4 string name;
5 int balance = 0;
6
7 BankAccount(const std::shared_ptr<Logger>& logger,
8 const string& name, int balance)
9 : log{logger}, name{name}, balance{balance} { }
10
11 // more members here
12 };
事实上,BankAccount
可以有类似于:
1 void BankAccount::deposit(int amount)
2 {
3 balance += amount;
4 log->info("Deposited $" + lexical_cast<string>(amount)
5 + " to " + name + ", balance is now $" + lexical_cast<string>(balance));
6 }
那么,这里的问题是什么?嗯,如果你确实需要日志,没有问题,你只需要实现你自己的日志类…
1 struct ConsoleLogger : Logger
2 {
3 void info(const string& s) override
4 {
5 cout << "INFO: " << s << endl;
6 }
7
8 void warn(const string& s) override
9 {
10 cout << "WARNING!!! " << s << endl;
11 }
12 };
你可以直接使用它。但是如果您根本不想要日志呢?
空对象
再次查看BankAccount
的构造器:
1 BankAccount(const shared_ptr<Logger>& logger,
2 const string& name, int balance)
因为构造器接受一个日志记录器,所以假设你可以通过传递一个未初始化的shared_ptr<BankAccount>(). BankAccount
就可以得到它是不安全的。在对它进行调度之前,可以在内部检查指针,但是你不知道它会这样做,没有额外的文档也不可能知道。
因此,唯一合理的传递给BankAccount
的是null object
——一个符合接口但不包含任何功能的类:
1 struct NullLogger : Logger
2 {
3 void info(const string& s) override {}
4 void warn(const string& s) override {}
5 };
shared_ptr
不是空对象
需要注意的是shared_ptr
和其他智能指针类不是空对象。空对象是保持正确操作(不执行任何操作)的东西。但是对未初始化的智能指针的调用会崩溃和烧毁:
1 shared_ptr<int> n;
2 int x = *n + 1; // yikes!
有趣的是,从调用的角度来看,没有办法让智能指针变得“安全”。换句话说,你不能写这样一个智能指针,如果foo
未初始化,foo->bar()
会神奇地变成空操作。原因是前缀*
和后缀->
操作符只是代理底层(原始)指针。没有办法在指针上做无操作。
设计改进
停下来想一想:如果BankAccount
在你的控制之下,你能改进界面使它更容易使用吗?这里有一些想法:
- 到处放指针检查。这整理出了
BankAccount
端的正确性,但并没有停止让库用户感到困惑。记住,你仍然没有意识到指针可以为空。 - 添加一个默认参数值,类似于
const shared_ptr<Logger>& logger = no_logging
,其中no_logging
是BankAccount
类的某个成员。即使是这种情况,你仍然需要在每个你想使用对象的地方检查指针值。 - 使用
optional
类型。这在习惯用法上是正确的,并且传达了意图,但是导致了传入一个optional<shared_ptr<T>>
和随后检查一个可选的是否为空的恐惧。
隐式空对象
还有另一个激进的想法,它包括绕过Logger
弯道两次跳跃。它包括将日志记录过程细分为调用(我们想要一个漂亮的Logger
接口)和操作(日志记录器实际做的事情)。因此,请考虑以下情况:
1 struct OptionalLogger : Logger {
2 shared_ptr<Logger> impl;
3 static shared_ptr<Logger> no_logging;
4 Logger(const shared_ptr<Logger>& logger) : impl{logger} {}
5 virtual void info(const string& s) override {
6 if (impl) impl->info(s); // null check here
7 }
8 // and similar checks for other members
9 };
10
11 // a static instance of a null object
12 shared_ptr<Logger> BankAccount::no_logging{};
所以现在我们已经从实现中抽象出了调用。我们现在做的是如下重新定义BankAccount
构造器:
1 shared_ptr<OptionalLogger> logger;
2 BankAccount(const string& name, int balance,
3 const shared_ptr<Logger>& logger = no_logging)
4 : log{make_shared<OptionalLogger>(logger)},
5 name{name},
6 balance{balance} { }
如您所见,这里有一个巧妙的借口:我们接受一个Logger
,但是存储一个OptionalLogger
(这是代理设计模式)。然后,对这个可选记录器的所有调用都是安全的——它们只有在底层对象可用时才“发生”:
1 BankAccount account{ "primary account", 1000 };
2 account.deposit(2000); // no crash
在前面的例子中实现的代理对象本质上是 Pimpl 习惯用法的定制版本。
摘要
空对象模式提出了一个 API 设计的问题:我们可以对我们依赖的对象做什么样的假设?如果我们使用一个指针(原始的或智能的),那么我们有义务在每次使用时检查这个指针吗?
如果您觉得没有这样的义务,那么客户端实现空对象的唯一方法就是构造所需接口的无操作实现,并传入该实例。也就是说,这只适用于函数:例如,如果对象的字段也被使用,那么您就真的有麻烦了。
如果您想主动支持将空对象作为参数传递的想法,您需要明确这一点:要么将参数类型指定为std::optional
,给参数一个暗示内置空对象的缺省值(例如= no_logging
),要么只编写文档解释在这个位置应该有什么样的值。
二十、观察者
observer 模式是一种流行且必要的模式,因此令人惊讶的是,与其他语言(例如 C#)不同,C++ 和标准库都没有现成的实现。尽管如此,一个安全的、正确实现的观察器(如果有这种东西的话)在技术上是一个复杂的构造,所以在这一章中我们将研究它所有的血淋淋的细节。
财产观察员
人会变老。这是生活的现实。但是当一个人长大一岁时,我们可能会祝贺他的生日。但是怎么做呢?给定一个定义,例如:
1 struct Person
2 {
3 int age;
4 Person(int age) : age{age} {}
5 };
我们怎么知道一个人的age
什么时候变了?我们没有。要看到变化,我们可以尝试轮询:每 100 毫秒读取一个人的年龄,并将新值与以前的值进行比较。这种方法可以工作,但是很繁琐,而且不可扩展。我们需要在这方面变得更聪明。
我们知道,我们希望在对一个人的age
字段的每一次写操作中得到通知。好吧,抓住这一点的唯一办法就是做一个二传手,那就是:
1 struct Person
2 {
3 int get_age() const { return age; }
4 void set_age(const int value) { age = value; }
5 private:
6 int age;
7 };
设置器set_age()
是我们可以通知任何关心age
实际上已经改变的人的地方。但是怎么做呢?
观察者
好吧,一种方法是定义某种基类,它需要被任何对获得Person
的改变感兴趣的人继承:
1 struct PersonListener
2 {
3 virtual void person_changed(Person& p,
4 const string& property_name, const any new_value) = 0;
5 };
然而,这种方法相当令人窒息,因为属性的改变可能发生在除了Person
之外的类型上,我们不希望为这些类型产生额外的类。这里有一些更普通的东西:
1 template<typename T> struct Observer
2 {
3 virtual void field_changed(T& source, const string& field_name) = 0;
4 };
希望field_changed()
中的两个参数是不言自明的。第一个是对实际更改了字段的对象的引用,第二个是字段的名称。是的,名字是作为string
传递的,这确实伤害了我们代码的可重构性(如果字段名改变了呢?). 1
这个实现将允许我们观察对一个Person
类的更改,例如,将它们写到命令行:
1 struct ConsolePersonObserver : Observer<Person>
2 {
3 void field_changed(Person& source, const string& field_name) override
4 {
5 cout << "Person's " << field_name << " has changed to "
6 << source.get_age() << ".\n";
7 }
8 };
例如,我们在场景中引入的灵活性将允许我们观察多个类的属性变化。例如,如果我们将类Creature
添加到组合中,您现在可以观察到:
1 struct ConsolePersonObserver : Observer<Person>, Observer<Creature>
2 {
3 void field_changed(Person& source, ...) { ... }
4 void field_changed(Creature& source, ...) { ... }
5 };
另一种选择是使用std::any
并去掉一个通用的实现。试试看!
可观察的
不管怎样,让我们回到Person
吧。由于这将成为一个可观察的类别,它必须承担新的责任,即:
- 保留一份对
Person
的变化感兴趣的所有观察者的私人名单 - 让观察者
subscribe()/unsubscribe()
注意到Person
的变化 - 用
notify()
通知所有观察者实际发生了变化
所有这些功能都可以很愉快地转移到一个单独的基类中,以避免为每个潜在的可观察对象复制它:
1 template <typename T> struct Observable
2 {
3 void notify(T& source, const string& name) { ... }
4 void subscribe(Observer<T>* f) { observers.push_back(f); }
5 void unsubscribe(Observer<T>* f) { ... }
6 private:
7 vector<Observer<T>*> observers;
8 };
我们已经实现了subscribe()
——它只是在观察者的私有列表中添加了一个新的观察者。观察者列表对任何人都不可用——甚至对派生类也不可用。我们不希望人们随意操纵这些收藏品。
接下来,我们需要实现notify()
。这个想法很简单:遍历每个观察者并一个接一个地调用它的field_changed()
:
1 void notify(T& source, const string& name)
2 {
3 for (auto obs : observers)
4 obs->field_changed(source, name);
5 }
然而,仅仅从Observable<T>
继承是不够的:每当一个字段被改变时,我们的类还需要在调用notify()
中尽自己的一份力量。
例如,考虑设置器set_age()
。它现在有三项责任:
- 检查名称是否已经更改。如果
age
是 20,我们给它分配 20,那么执行任何分配或通知都没有意义。 - 为该字段分配适当的值。
- 使用正确的参数调用
notify()
。
因此,set_age()
的新实现如下所示:
1 struct Person : Observable<Person>
2 {
3 void set_age(const int age)
4 {
5 if (this->age == age) return;
6 this->age = age;
7 notify(*this, "age");
8 }
9 private:
10 int age;
11 };
连接观察者和可观察物
我们现在准备开始使用我们创建的基础设施,以便获得关于Person
的字段更改的通知(好吧,我们可以称它们为属性,真的)。这里提醒一下我们的观察者长什么样:
1 struct ConsolePersonObserver : Observer<Person>
2 {
3 void field_changed(Person& source,
4 const string& field_name) override
5 {
6 cout << "Person's " << field_name << " has changed to "
7 << source.get_age() << ".\n";
8 }
9 };
我们是这样使用它的:
1 Person p{ 20 };
2 ConsolePersonObserver cpo;
3 p.subscribe(&cpo);
4 p.set_age(21); // Person's age has changed to 21.
5 p.set_age(22); // Person's age has changed to 22.
只要您不关心属性依赖和线程安全/可重入性的问题,您就可以在这里停下来,采用这个实现,并开始使用它。如果你想看到更复杂方法的讨论,请继续阅读。
依赖性问题
16 岁或 16 岁以上的人(在你的国家可能不同)可以投票。因此,假设我们希望在一个人的投票权发生变化时得到通知。首先,让我们假设我们的Person
类型有以下 getter:
1 bool get_can_vote() const { return age >= 16; }
注意,get_can_vote()
没有支持字段和 setter(我们可以引入这样一个字段,但这显然是多余的),然而我们也觉得有必要对它使用notify()
。但是怎么做呢?嗯,我们可以试着找出是什么导致了can_vote
的改变……没错,set_age()
的确如此!因此,如果我们想要投票状态变化的通知,这些需要在set_age()
完成。准备好,你会大吃一惊的!
1 void set_age(int value) const
2 {
3 if (age == value) return;
4
5 auto old_can_vote = can_vote(); // store old value
6 age = value;
7 notify(*this, "age");
8
9 if (old_can_vote != can_vote()) // check value has changed
10 notify(*this, "can_vote");
11 }
前面的函数太多了。我们不仅检查age
是否已经改变,我们还检查can_vote
是否已经改变并通知它!您可能会猜测这种方法的伸缩性不好,对吗?想象一下can_vote
依赖于两个字段,比如说age
和citizenship
——这意味着它们的设置器都必须处理can_vote
通知。如果age
也以这种方式影响其他十个属性呢?这是一个不可行的解决方案,会导致脆弱的代码无法维护,因为变量之间的关系需要手动跟踪。
简单地说,在前面的场景中,can_vote
是age
的依赖属性。依赖属性的挑战本质上是 Excel 等工具的挑战:给定不同单元格之间的大量依赖关系,当其中一个单元格发生变化时,您如何知道要重新计算哪些单元格。
当然,属性依赖可以被形式化为某种类型的map<string, vector<string>>
,它将保存一个受属性影响的属性列表(或者,相反,所有影响属性的属性)。可悲的是,这个映射必须手工定义,并且保持它与实际代码同步是相当棘手的。
取消订阅和线程安全
我忽略了讨论的一件事是一个观察者如何从一个可观察的事物中。通常,您希望将自己从观察者列表中删除,这在单线程场景中非常简单:
1 void unsubscribe(Observer<T>* observer)
2 {
3 observers.erase(
4 remove(observers.begin(), observers.end(), observer),
5 observers.end());
6 };
虽然使用擦除-删除习惯用法在技术上是正确的,但它只在单线程场景中是正确的。std::vector
不是线程安全的,所以同时调用subscribe()
和unsubscribe()
可能会导致意想不到的后果,因为这两个函数都会修改向量。
这很容易解决:只需锁定 observable 的所有操作。这可能看起来很简单:
1 template <typename T>
2 struct Observable
3 {
4 void notify(T& source, const string& name)
5 {
6 scoped_lock<mutex> lock{ mtx };
7 ...
8 }
9 void subscribe(Observer<T>* f)
10 {
11 scoped_lock<mutex> lock{ mtx };
12 ...
13 }
14 void unsubscribe(Observer<T>* o)
15 {
16 scoped_lock<mutex> lock{ mtx };
17 ...
18 }
19 private:
20 vector<Observer<T>*> observers;
21 mutex mtx;
22 };
另一个非常可行的替代方法是使用类似 TPL/PPL 中的concurrent_vector
的东西。很自然地,您会失去排序保证(换句话说,一个接一个地添加两个对象并不能保证它们以那个顺序被通知),但是它确实让您不必自己管理锁。
再融合
最后一个实现通过在有人需要时锁定三个关键方法中的任何一个来提供一些线程安全。但是现在让我们想象一下下面的场景:您有一个TrafficAdministration
组件,它一直监视一个人,直到他到了可以开车的年龄。当他们 17 岁时,组件退订:
1 struct TrafficAdministration : Observer<Person>
2 {
3 void TrafficAdministration::field_changed(
4 Person& source, const string& field_name) override
5 {
6 if (field_name == "age")
7 {
8 if (source.get_age() < 17)
9 cout << "Whoa there, you are not old enough to drive!\n";
10 else
11 {
12 // oh, ok, they are old enough, let's not monitor them anymore
13 cout << "We no longer care!\n";
14 source.unsubscribe(this);
15 }
16 }
17 }
18 };
这是一个问题,因为当年龄达到 17 岁时,整个呼叫链将是:
1 notify() --> field_changed() --> unsubscribe()
这是一个问题,因为在unsubscribe()
中,我们最终试图获取一个已经被占用的锁。这是一个可重入的问题。有不同的方法来处理这个问题。
- 一种方法是简单地禁止这种情况。毕竟,至少在这个特殊的例子中,很明显这里发生了重入。
- 另一种方法是放弃从集合中删除元素的想法。相反,我们可以这样做:
1 void unsubscribe(Observer<T>* o)
2 {
3 auto it = find(observers.begin(), observers.end(), o);
4 if (it != observers.end())
5 *it = nullptr; // cannot do this for a set
6 }
随后,当您notify()
时,您只需要进行额外的检查:
1 void notify(T& source, const string& name)
2 {
3 for (auto obs : observers)
4 if (obs)
5 obs->field_changed(source, name);
6 }
当然,以上只是解决了notify()
和subscribe()
之间可能的竞争。例如,如果您同时对subscribe()
和unsubscribe()
进行修改,这仍然是对集合的并发修改——并且仍然可能失败。所以,至少,你可能想在那里留一把锁。
还有一种可能是在notify()
中复制整个集合。你仍然需要锁,你只是没有把它应用到任何东西上。我的意思是:
1 void notify(T& source, const string& name)
2 {
3 vector<Observer<T>*> observers_copy;
4 {
5 lock_guard<mutex_t> lock{ mtx };
6 observers_copy = observers;
7 }
8 for (auto obs : observers_copy)
9 if (obs)
10 obs->field_changed(source, name);
11 }
在前面的实现中,我们确实获得了一个锁,但是当我们调用field_changed
时,这个锁已经被释放了,因为它只是在用于复制向量的人工作用域中创建的。我不会担心这里的效率,因为指针向量不会占用太多内存。
最后,总是可以用一个recursive_mutex
替换一个mutex
。一般来说,大多数开发人员都讨厌递归互斥体(SO 上的证明),不仅仅是因为性能问题,更重要的是因为在大多数情况下(就像 Observer 示例一样),如果您的代码设计得好一点,就可以使用普通的非递归变体。
这里有一些有趣的实际问题我们还没有真正讨论。它们包括以下内容:
- 同一个观察者加两次会怎么样?
- 如果我允许重复的观察者,那么
ubsubscribe()
会删除每一个实例吗? - 如果我们使用不同的容器,行为会受到什么影响?例如,我们决定通过使用
std::set
或boost::unordered_set
来防止重复,这对普通操作意味着什么? - 如果我想要按优先级排序的观察者,该怎么办?
一旦你的基础稳固,这些和其他实际问题都是可以解决的。我们不会在这里花更多的时间讨论它们。
通过升压观察器。信号 2
观察者模式有许多预打包的实现,可能最广为人知的是 Boost。信号 2 库。本质上,这个库提供了一个名为signal
的类型,用 C++ 术语表示一个信号(在别处称为事件)。该信号可以通过提供函数或λ来订阅。它也可以被取消订阅,当你想就此发出通知时,它可以被触发。
使用 Boost。信号 2,我们可以定义Observer<T>
如下:
1 template <typename T>
2 struct Observable
3 {
4 signal<void(T&, const string&)> property_changed;
5 };
它的调用如下所示:
1 struct Person : Observable<Person>
2 {
3 ...
4 void set_age(const int age)
5 {
6 if (this->age == age) return;
7
8 this->age = age;
9 property_changed(*this, "age");
10 }
11 };
API 的实际使用将直接使用signal
,当然,除非您决定添加更多的 API 陷阱以使其更容易:
1 Person p{123};
2 auto conn = p.property_changed.connect([](Person&, const string& prop_name)
3 {
4 cout << prop_name << " has been changed" << endl;
5 });
6 p.set_age(20); // name has been changed
7
8 // later, optionally
9 conn.disconnect();
一个connect()
调用的结果是一个connection
对象,当您不再需要信号通知时,它也可以用来取消订阅。
摘要
毫无疑问,本章给出的代码是一个过度思考和过度设计问题的明显例子,远远超出了大多数人想要实现的目标。
让我们回顾一下实现 Observer 时的主要设计决策:
- 决定你希望你的观察对象传达什么信息。例如,如果您正在处理字段/属性更改,您可以包括属性的名称。您也可以指定旧的/新的值,但是传递类型可能会有问题。
- 您希望您的观察器是一个疲惫的类,还是仅仅拥有一个虚函数列表就可以了?
- 你想如何处理观察员退订?
- 如果您不打算支持取消订阅——恭喜您,因为在可重入场景中不存在移除问题,所以实现
Observer,
将会节省很多精力。 - 如果你计划支持一个显式的
unsubscribe()
函数,你可能不希望在函数中直接删除,而是将你的元素标记为删除,以后再删除。 - 如果您不喜欢在(可能为空)原始指针上调度的想法,可以考虑使用
weak_ptr
来代替。
- 如果您不打算支持取消订阅——恭喜您,因为在可重入场景中不存在移除问题,所以实现
- 有可能从几个不同的线程调用一个
Observer<T>
的函数吗?如果是,您需要保护您的订阅列表:- 您可以在所有相关功能上放置一个
scoped_lock
;或者 - 你可以使用线程安全的集合,比如 TBB/PPL
concurrent_vector
。你失去了订购保证。
- 您可以在所有相关功能上放置一个
- 是否允许来自同一来源的多个订阅?如果是,就不能使用
std::set
。
遗憾的是,没有一个理想的 Observer 实现可以满足所有的要求。无论您选择哪种实现,都会有一些妥协。
Footnotes 1
C#已经在后续版本中两次明确解决了这个问题。首先,它引入了一个名为[CallerMemberName]
的属性,该属性将调用函数/属性的名称作为参数的字符串值插入。第二个版本简单地引入了nameof(Foo)
,它将一个符号的名称转换成一个字符串。