现代 C++ 设计模式(四)
二十一、状态
我必须承认:我的行为受我的状态支配。如果我睡眠不足,我会有点累。如果我喝了酒,我就不会开车了。所有这些都是状态,它们支配着我的行为:我的感觉,我能做什么和不能做什么。
当然,我可以从一种状态转换到另一种状态。我可以去喝杯咖啡,这会让我从困倦中清醒过来(我希望!).所以我们可以把咖啡看作是一个触发器,让你真正从困倦状态转变为清醒状态。在这里,让我笨拙地为你举例说明:
1 coffee
2 sleepy --------> alert
所以,状态设计模式是一个非常简单的想法:状态控制行为;状态可以改变;唯一没有定论的是谁触发了状态变化。
从根本上说,有两种方法:
- 状态是具有行为的实际类,这些行为将实际状态从一个状态切换到另一个状态。
- 状态和转换只是枚举。我们有一个称为状态机的特殊组件来执行实际的转换。
这两种方法都是可行的,但第二种方法才是最常见的。我们将看一看他们两个,但是我必须承认我将浏览第一个,因为这不是人们通常做事情的方式。
状态驱动的状态转换
我们从最简单的例子开始:电灯开关。它只能处于开和关状态。我们将构建一个模型,其中任何状态都能够切换到其他状态:虽然这反映了状态设计模式的“经典”实现(按照 GoF 的书),但我不推荐这样做。
首先,让我们为电灯开关建模:它只有一种状态和一些从一种状态切换到另一种状态的方法:
1 class LightSwitch
2 {
3 State *state;
4 public:
5 LightSwitch()
6 {
7 state = new OffState();
8 }
9 void set_state(State* state)
10 {
11 this->state = state;
12 }
13 };
这看起来完全合理。我们现在可以定义State,,在这个特殊的例子中,它将是一个实际的类:
1 struct State
2 {
3 virtual void on(LightSwitch *ls)
4 {
5 cout << "Light is already on\n";
6 }
7 virtual void off(LightSwitch *ls)
8 {
9 cout << "Light is already off\n";
10 }
11 };
这个实现远非直观,以至于我们需要慢慢地、小心地讨论它,因为从一开始,关于State类的任何东西都没有意义。
首先,State不抽象!你会认为一个国家你没有办法(或理由!)将是抽象的。但事实并非如此。
其次,State允许从一种状态切换到另一种状态。这……对一个通情达理的人来说,毫无意义。想象一下电灯开关:它是改变状态的开关。人们并不指望国家本身会改变自己,但它似乎确实在改变。
第三,也许最令人困惑的是,State::on/off的默认行为声称我们已经处于这种状态!当我们实现示例的其余部分时,这将在某种程度上结合在一起。
我们现在实现开和关状态:
1 struct OnState : State
2 {
3 OnState() { cout << "Light turned on\n"; }
4 void off(LightSwitch* ls) override;
5 };
6
7 struct OffState : State
8 {
9 OffState() { cout << "Light turned off\n"; }
10 void on(LightSwitch* ls) override;
11 };
OnState::off和OffState::on的实现允许状态本身切换到另一个状态!它看起来是这样的:
1 void OnState::off(LightSwitch* ls)
2 {
3 cout << "Switching light off...\n";
4 ls->set_state(new OffState());
5 delete this;
6 } // same for OffState::on
这就是转换发生的地方。这个实现包含了奇怪的对delete this的调用,这在现实世界的 C++ 中并不常见。delete this对状态的初始分配做了一个非常危险的假设。这个例子可以用智能指针重写,但是使用指针和堆分配清楚地表明状态在这里被主动破坏了。如果状态有一个析构函数,它将被触发,你将在这里执行额外的清理。
当然,我们确实希望开关本身也能切换状态,如下所示:
1 class LightSwitch
2 {
3 ...
4 void on() { state->on(this); }
5 void off() { state->off(this); }
6 };
因此,将所有这些放在一起,我们可以运行以下场景:
1 LightSwitch ls; // Light turned off
2 ls.on(); // Switching light on...
3 // Light turned on
4 ls.off(); // Switching light off...
5 // Light turned off
6 ls.off(); // Light is already off
我必须承认:我不喜欢这种做法,因为它不直观。当然,状态可以被告知(观察者模式)我们正在进入它。但是状态切换到另一个状态的想法——按照 GoF 书的说法,这是状态模式的“经典”实现——似乎并不特别令人愉快。
如果我们要笨拙地说明从OffState到OnState的过渡,它需要被说明为:
1 LightSwitch::on() -> OffState::on()
2 OffState -------------------------------------> OnState
另一方面,从OnState到OnState的转换使用基本的State类,它告诉你你已经处于那个状态了:
1 LightSwitch::on() -> State::on()
2 OnState ----------------------------------> OnState
这里给出的例子可能看起来特别人工,所以我们现在要看看另一个手工设置,其中状态和转换被简化为枚举成员。
手工状态机
让我们试着为一个典型的电话对话定义一个状态机。首先,我们将描述电话的状态:
1 enum class State
2 {
3 off_hook,
4 connecting,
5 connected,
6 on_hold,
7 on_hook
8 };
我们现在还可以定义状态之间的转换,也称为enum class:
1 enum class Trigger
2 {
3 call_dialed,
4 hung_up,
5 call_connected,
6 placed_on_hold,
7 taken_off_hold,
8 left_message,
9 stop_using_phone
10 };
现在,这个状态机的确切规则,也就是哪些转换是可能的,需要存储在某个地方。
1 map<State, vector<pair<Trigger, State>>> rules;
所以这有点笨拙,但本质上映射的关键是我们正在移动的State,值是一组Trigger- State对,代表在这个状态下可能的触发器和当你使用触发器时进入的状态。
让我们初始化这个数据结构:
1 rules[State::off_hook] = {
2 {Trigger::call_dialed, State::connecting},
3 {Trigger::stop_using_phone, State::on_hook}
4 };
5
6 rules[State::connecting] = {
7 {Trigger::hung_up, State::off_hook},
8 {Trigger::call_connected, State::connected}
9 };
10 // more rules here
我们还需要一个开始状态,如果我们希望状态机在到达该状态时停止执行,我们还可以添加一个退出(终端)状态:
1 State currentState{ State::off_hook },
2 exitState{ State::on_hook };
这样,我们就不必为实际运行(我们使用编排这个术语)状态机构建单独的组件。例如,如果我们想建立一个交互式的电话模型,我们可以这样做:
1 while (true)
2 {
3 cout << "The phone is currently " << currentState << endl;
4 select_trigger:
5 cout << "Select a trigger:" << "\n";
6
7 int i = 0;
8 for (auto item : rules[currentState])
9 {
10 cout << i++ << ". " << item.first << "\n";
11 }
12
13 int input;
14 cin >> input;
15 if (input < 0 || (input+1) > rules[currentState].size())
16 {
17 cout << "Incorrect option. Please try again." << "\n";
18 goto select_trigger;
19 }
20
21 currentState = rules[currentState][input].second;
22 if (currentState == exitState) break;
23 }
首先:是的,我确实使用了goto,这很好地说明了它的恰当之处。至于算法本身,这是相当明显的:我们让用户在当前状态下选择一个可用的触发器(operator <<已经在幕后为State和Trigger实现了),如果触发器有效,我们通过使用之前创建的rules映射转换到它。
如果我们到达的状态是退出状态,我们就跳出循环。这是一个与程序交互的例子:
1 The phone is currently off the hook
2 Select a trigger:
3 0\. call dialed
4 1\. putting phone on hook
5 0
6 The phone is currently connecting
7 Select a trigger:
8 0\. hung up
9 1\. call connected
10 1
11 The phone is currently connected
12 Select a trigger:
13 0\. left message
14 1\. hung up
15 2\. placed on hold
16 2
17 The phone is currently on hold
18 Select a trigger:
19 0\. taken off hold
20 1\. hung up
21 1
22 The phone is currently off the hook
23 Select a trigger:
24 0\. call dialed
25 1\. putting phone on hook
26 1
27 We are done using the phone
这个手卷状态机的主要好处是非常容易理解:状态和变迁是普通的枚举,变迁的集合定义在一个简单的std::map中,起始和结束状态是简单的变量。
带 Boost 的状态机。主流媒体
在现实世界中,状态机更加复杂。有时,您希望在达到某个状态时发生一些动作。在其他时候,您希望转换是有条件的,也就是说,您希望转换仅在某些条件成立时发生。
使用 Boost 时。MSM(元状态机),一个属于 Boost 的状态机库,你的状态机是一个通过 CRTP 从state_machine_def继承的类:
1 struct PhoneStateMachine : state_machine_def<PhoneStateMachine>
2 {
3 bool angry{ false };
我添加了一个bool来表示呼叫者是否生气(例如,被挂起);我们稍后会用到它。现在,每个状态也可以驻留在状态机中,并且应该从state类继承:
1 struct OffHook : state<> {};
2 struct Connecting : state<>
3 {
4 template <class Event, class FSM>
5 void on_entry(Event const& evt, FSM&)
6 {
7 cout << "We are connecting..." << endl;
8 }
9 // also on_exit
10 };
11 // other states omitted
如您所见,状态还可以定义当您进入或退出特定状态时发生的行为。
你也可以定义在转换时(而不是当你到达一个状态时)执行的行为:这些也是类,但是它们不需要继承任何东西;相反,他们需要向operator()提供一个特定的签名:
1 struct PhoneBeingDestroyed
2 {
3 template <class EVT, class FSM, class SourceState, class TargetState>
4 void operator()(EVT const&, FSM&, SourceState&, TargetState&)
5 {
6 cout << "Phone breaks into a million pieces" << endl;
7 }
8 };
正如您可能已经猜到的那样,参数为您提供了对状态机以及您要去的和要去的状态的引用。
最后,我们有保护条件:这些条件决定了我们是否可以首先使用一个转换。现在,我们的布尔变量angry不是 MSM 可用的形式,所以我们需要包装它:
1 struct CanDestroyPhone
2 {
3 template <class EVT, class FSM, class SourceState, class TargetState>
4 bool operator()(EVT const&, FSM& fsm, SourceState&, TargetState&)
5 {
6 return fsm.angry;
7 }
8 };
前面的代码生成了一个名为CanDestroyPhone的保护条件,我们可以在稍后定义状态机时使用它。
对于定义状态机规则,Boost。MSM 使用 MPL(元编程库)。具体来说,转换表被定义为一个mpl::vector,每行依次包含:
- 源州
- 转变
- 目标州
- 要执行的可选操作
- 可选的保护条件
有了这些,我们可以定义一些电话呼叫规则如下:
1 struct transition_table : mpl::vector <
2 Row<OffHook, CallDialed, Connecting>,
3 Row<Connecting, CallConnected, Connected>,
4 Row<Connected, PlacedOnHold, OnHold>,
5 Row<OnHold, PhoneThrownIntoWall, PhoneDestroyed,
6 PhoneBeingDestroyed, CanDestroyPhone>
7 > {};
在前面,与状态不同,像CallDialed这样的转换是可以在状态机类之外定义的类。它们不必从任何基类继承,并且可以很容易地为空,但是它们必须是类型。
我们的transition_ table的最后一行是最有趣的:它规定我们只能在受到CanDestroyPhone保护条件的情况下尝试销毁手机,当手机实际被销毁时,应该执行PhoneBeingDestroyed动作。
现在,我们还可以添加一些东西。首先,我们添加起始条件:因为我们使用了 Boost。MSM,起始条件是一个typedef,不是一个变量:
1 typedef OffHook initial_state;
最后,如果没有可能的转换,我们可以定义要发生的动作。这是有可能的。例如,在你打碎手机后,你就不能再用它了,对吗?
1 template <class FSM, class Event>
2 void no_transition(Event const& e, FSM&, int state)
3 {
4 cout << "No transition from state " << state_names[state]
5 << " on event " << typeid(e).name() << endl;
6 }
Boost MSM 将状态机分为前端(我们刚才写的就是这个)和后端(运行它的部分)。使用后端 API,我们可以从前面的状态机定义中构造状态机:
1 msm::back::state_machine<PhoneStateMachine> phone;
现在,假设存在只打印我们所处状态的info()函数,我们可以尝试编排以下场景:
1 info(); // The phone is currently off hook
2 phone.process_event(CallDialed{}); // We are connecting...
3 info(); // The phone is currently connecting
4 phone.process_event(CallConnected{});
5 info(); // The phone is currently connected
6 phone.process_event(PlacedOnHold{});
7 info(); // The phone is currently on hold
8
9 phone.process_event(PhoneThrownIntoWall{});
10 // Phone breaks into a million pieces
11
12 info(); // The phone is currently destroyed
13
14 phone.process_event(CallDialed{});
15 // No transition from state destroyed on event struct CallDialed
这就是你如何定义一个更复杂的、行业优势的状态机。
摘要
首先,有必要强调一下这一点。MSM 是 Boost 中两个可选的状态机实现之一,另一个是 Boost.Statechart。我很确定还有很多其他的状态机实现。
第二,状态机远不止这些。例如,许多库支持分级状态机的思想:例如,Sick状态可以包含许多不同的子状态,如Flu或Chickenpox。如果你在状态Flu,你也被假定在状态Sick。
最后,值得再次强调的是,现代状态机离最初形式的状态设计模式有多远。重复 API 的存在(例如LightSwitch::on/off vs. State::on/off)以及自删除的存在,在我的书中是明确的代码味道。不要误解我的意思——这种方法是可行的,但是不直观而且麻烦。
二十二、策略
假设您决定接受一个包含几个字符串的数组或向量,并将它们作为一个列表输出,
- 仅仅
- 喜欢
- 这
如果您考虑不同的输出格式,您可能知道您需要获取每个元素并使用一些附加标记输出它。但是对于 HTML 或 LaTeX 这样的语言,列表也需要开始和结束标签或标记。这两种格式的列表处理既相似(需要输出每一项),又不同(输出项的方式)。每一个都可以用单独的策略来处理。
我们可以制定一个呈现列表的策略:
- 呈现开始标记/元素。
- 对于每个列表项,呈现该项。
- 呈现结束标记/元素。
可以为不同的输出格式制定不同的策略,然后可以将这些策略输入到一个通用的、不变的算法中来生成文本。
这是另一种存在于动态(运行时可替换)和静态(模板合成、固定)实例中的模式。让我们来看看他们两个。
动态策略
因此,我们的目标是以下列格式打印一个简单的文本项列表:
1 enum class OutputFormat
2 {
3 markdown,
4 html
5 };
我们的策略框架将在下面的基类中定义:
1 struct ListStrategy
2 {
3 virtual void start(ostringstream& oss) {};
4 virtual void add_list_item(ostringstream& oss, const string& item) {};
5 virtual void end(ostringstream& oss) {};
6 };
现在让我们跳到我们的文本处理组件。这个组件有一个特定于列表的成员函数,比如说,append_list()。
1 struct TextProcessor
2 {
3 void append_list(const vector<string> items)
4 {
5 list_strategy->start(oss);
6 for (auto& item : items)
7 list_strategy->add_list_item(oss, item);
8 list_strategy->end(oss);
9 }
10 private:
11 ostringstream oss;
12 unique_ptr<ListStrategy> list_strategy;
13 };
所以我们有一个名为oss的缓冲区,所有的输出都放在这里,这是我们用来呈现列表的策略,当然还有append_list(),它指定了使用给定策略来实际呈现一个列表所需要采取的一组步骤。
现在,注意这里。如前所述,组合是骨架算法的具体实现的两个可能选项之一。
相反,我们可以添加像add_list_item()这样的函数作为虚拟成员,由派生类重写:这就是模板方法模式所做的。
不管怎样,回到我们的讨论。我们现在可以继续为列表实现不同的策略,比如一个HtmlListStrategy:
1 struct HtmlListStrategy : ListStrategy
2 {
3 void start(ostringstream& oss) override
4 {
5 oss << "<ul>\n";
6 }
7 void end(ostringstream& oss) override
8 {
9 oss << "</ul>\n";
10 }
11 void add_list_item(ostringstream& oss, const string& item) override
12 {
13 oss << "<li>" << item << "</li>\n";
14 }
15 };
通过实现覆盖,我们填补了指定如何处理列表的空白。我们将以类似的方式实现一个MarkdownListStrategy,但是因为 Markdown 不需要开始/结束标签,我们将只使用override``add_list_item()函数:
1 struct MarkdownListStrategy : ListStrategy
2 {
3 void add_list_item(ostringstream& oss,
4 const string& item) override
5 {
6 oss << " * " << item << endl;
7 }
8 };
我们现在可以开始使用TextProcessor,给它输入不同的策略,得到不同的结果。例如:
1 TextProcessor tp;
2 tp.set_output_format(OutputFormat::markdown);
3 tp.append_list({"foo", "bar", "baz"});
4 cout << tp.str() << endl;
5
6 // Output:
7 // * foo
8 // * bar
9 // * baz
我们可以规定策略在运行时是可切换的——这就是为什么我们称这个实现为动态策略。这是在set_output_format()函数中完成的,它的实现很简单:
1 void set_output_format(const OutputFormat format)
2 {
3 switch(format)
4 {
5 case OutputFormat::markdown:
6 list_strategy = make_unique<MarkdownListStrategy>();
7 break;
8 case OutputFormat::html:
9 list_strategy = make_unique<HtmlListStrategy>();
10 break;
11 }
12 }
现在,从一种策略切换到另一种策略是很简单的,您可以立即看到结果:
1 tp.clear(); // clears the buffer
2 tp.set_output_format(OutputFormat::Html);
3 tp.append_list({"foo", "bar", "baz"});
4 cout << tp.str() << endl;
5
6 // Output:
7 // <ul>
8 // <li>foo</li>
9 // <li>bar</li>
10 // <li>baz</li>
11 // </ul>
静态策略
多亏了模板的魔力,你可以将任何策略嵌入到类型中。仅需要对TextStrategy类进行最小的修改:
1 template <typename LS>
2 struct TextProcessor
3 {
4 void append_list(const vector<string> items)
5 {
6 list_strategy.start(oss);
7 for (auto& item : items)
8 list_strategy.add_list_item(oss, item);
9 list_strategy.end(oss);
10 }
11 // other functions unchanged
12 private:
13 ostringstream oss;
14 LS list_strategy;
15 };
前面所发生的一切就是我们添加了LS模板参数,用这种类型创建了一个成员策略,并开始使用它来代替之前的指针。append_list()的结果是相同的:
1 // markdown
2 TextProcessor<MarkdownListStrategy> tpm;
3 tpm.append_list({"foo", "bar", "baz"});
4 cout << tpm.str() << endl;
5
6 // html
7 TextProcessor<HtmlListStrategy> tph;
8 tph.append_list({"foo", "bar", "baz"});
9 cout << tph.str() << endl;
前面示例的输出与动态策略的输出相同。请注意,我们已经制作了两个TextProcessor实例,每个都有不同的列表处理策略。
摘要
策略设计模式允许您定义算法的框架,然后使用组合来提供与特定策略相关的缺失的实现细节。这种方法有两种表现形式:
- 动态策略只是保存一个指向所用策略的指针/引用。想换个不同的策略吗?换个参照物就行了。放轻松!
- 静态策略要求您在编译时选择策略并坚持下去——以后没有改变主意的余地。
应该使用动态策略还是静态策略?嗯,动态的允许你在对象被构造后重新配置它们。想象一个控制文本输出形式的 UI 设置:你更愿意拥有一个可切换的TextProcessor还是两个类型为TextProcessor<MarkdownStrategy>和TextProcessor<HtmlStrategy>的变量?这真的取决于你。
最后,您可以约束一个类型所采用的策略集:不允许使用一般的ListStrategy参数,而是可以使用一个std::variant来列出唯一允许传入的类型。
二十三、模板方法
策略和模板方法设计模式非常相似,以至于就像工厂一样,我很想将这些模式合并成一个单一的框架方法设计模式。我会忍住冲动。
策略和模板方法的区别在于,策略使用组合(不管是静态的还是动态的),而模板方法使用继承。但是,在一个地方定义算法的框架,在其他地方定义其实现细节的核心原则仍然存在,再次观察 OCP(我们只是扩展系统)。
游戏模拟
大多数棋盘游戏都非常相似:游戏开始(发生某种设置),玩家轮流玩,直到决定一个赢家,然后可以宣布赢家。不管是什么游戏——国际象棋、西洋跳棋还是其他游戏,我们都可以将算法定义如下:
1 class Game
2 {
3 void run()
4 {
5 start();
6 while (!have_winner())
7 take_turn();
8 cout << "Player " << get_winner() << " wins.\n";
9 }
如你所见,运行游戏的run()方法简单地调用了一组其他方法。这些被定义为纯虚拟的,并且还具有protected可见性,因此它们不会自己被调用:
1 protected:
2 virtual void start() = 0;
3 virtual bool have_winner() = 0;
4 virtual void take_turn() = 0;
5 virtual int get_winner() = 0;
平心而论,前面的一些方法,尤其是void-return 的,不一定非得是纯虚拟的。例如,如果一些游戏没有明确的start()程序,将start()作为纯虚拟违反了 ISP,因为不需要它的成员仍然必须实现它。在策略一章中我们特意用虚拟无操作方法做了一个策略,但是用模板方法,情况就不那么明朗了。
现在,除了前面的例子,我们可以拥有与所有游戏相关的某些public成员:玩家的数量和当前玩家的索引:
1 class Game
2 {
3 public:
4 explicit Game(int number_of_players)
5 : number_of_players(number_of_players){}
6 protected:
7 int current_player{ 0 };
8 int number_of_players;
9 }; // other members omitted
从现在开始,Game类可以被扩展来实现一个国际象棋游戏:
1 class Chess : public Game
2 {
3 public:
4 explicit Chess() : Game{ 2 } {}
5 protected:
6 void start() override {}
7 bool have_winner() override { return turns == max_turns; }
8 void take_turn() override
9 {
10 turns++;
11 current_player = (current_player + 1) % number_of_players;
12 }
13 int get_winner() override { return current_player;}
14 private:
15 int turns{ 0 }, max_turns{ 10 };
16 };
一局国际象棋涉及两个玩家,所以它被输入到构造器中。然后,我们继续覆盖所有必要的功能,实现一些非常简单的模拟逻辑,以便在十回合后结束游戏。以下是输出:
1 Starting a game of chess with 2 players
2 Turn 0 taken by player 0
3 Turn 1 taken by player 1
4 ...
5 Turn 8 taken by player 0
6 Turn 9 taken by player 1
7 Player 0 wins.
这差不多就是全部了!
摘要
与使用组合并因此分为静态和动态变化的策略不同,模板方法使用继承,因此,它只能是静态的,因为一旦对象被构造,就没有办法篡改它的继承特征。
模板方法中唯一的设计决策是,您希望模板方法使用的方法是纯虚拟的,还是实际上有一个主体,即使这个主体是空的。如果你预见到一些方法对所有的继承者来说都是不必要的,那就让它们成为无操作的方法。
二十四、访问者
一旦你有了一个类型的层次结构,除非你有访问源代码的权限,否则不可能给这个层次结构的每个成员添加一个函数。这是一个需要预先计划的问题,并产生了访问者模式。
这里有一个简单的例子:假设您已经解析了一个数学表达式(当然,使用了解释器模式!)由double值和加法运算符组成:
1 (1.0 + (2.0 + 3.0))
该表达式可以表示为类似如下的层次结构:
1 struct Expression
2 {
3 // nothing here (yet)
4 };
5
6 struct DoubleExpression : Expression
7 {
8 double value;
9 explicit DoubleExpression(const double value)
10 : value{value} {}
11 };
12
13 struct AdditionExpression : Expression
14 {
15 Expression *left, *right;
16
17 AdditionExpression(Expression* const left, Expression* const right)
18 : left{left}, right{right} {}
19
20 ~AdditionExpression()
21 {
22 delete left; delete right;
23 }
24 };
因此,给定对象的这种层次结构,假设您想要向各种Expression继承者添加一些行为(嗯,我们现在只有两个,但是这个数字可以增加)。你会怎么做?
不速之客
我们将从最直接的方法开始,一种打破开闭原则的方法。本质上,我们将跳转到已经编写好的代码中,修改Expression接口(通过关联,修改每个派生类):
1 struct Expression
2 {
3 virtual void print(ostringstream& oss) = 0;
4 };
除了破坏 OCP 之外,这种修改还依赖于这样一个假设,即您实际上可以访问该层次结构的源代码——这并不总是有保证的。但我们总得从某个地方开始,对吧?因此,随着这种变化,我们需要在DoubleExpression中实现print()(这很简单,所以我在这里省略了)以及在AdditionExpression中实现print():
1 struct AdditionExpression : Expression
2 {
3 Expression *left, *right;
4 ...
5 void print(ostringstream& oss) override
6 {
7 oss << "(";
8 left->print(oss);
9 oss << "+";
10 right->print(oss);
11 oss << ")";
12 }
13 };
哦,这太有趣了!我们在子表达式上多态地递归调用print()。精彩;让我们来测试一下:
1 auto e = new AdditionExpression{
2 new DoubleExpression{1},
3 new AdditionExpression{
4 new DoubleExpression{2},
5 new DoubleExpression{3}
6 }
7 };
8 ostringstream oss;
9 e->print(oss);
10 cout << oss.str() << endl; // prints (1+(2+3))
嗯,这很简单。但是现在假设您在层次结构中有 10 个继承者(顺便说一下,在现实世界的场景中并不少见),您需要添加一些新的eval()操作。那是需要在十个不同的类中完成的十个修改。但是 OCP 不是真正的问题。
真正的问题是 SRP。你知道,像印刷这样的问题是需要特别关注的。与其说每个表达式都应该打印自己,为什么不引入一个知道如何打印表达式的ExpessionPrinter?稍后,您可以引入一个知道如何执行实际计算的ExpressionEvaluator,而不会以任何方式影响Expression层次结构。
反射式打印机
既然我们已经决定制作一个单独的打印机组件,让我们去掉print()成员函数(当然,要保留基类)。这里有一个警告:不能让Expression类为空。为什么呢?因为只有当你真的有virtual在里面的时候,你才会得到多态行为。所以,现在,让我们在里面放一个虚拟析构函数;那就行了!
1 struct Expression
2 {
3 virtual ~Expression() = default;
4 };
现在让我们试着实现一个ExpressionPrinter。我的第一反应会是这样写:
1 struct ExpressionPrinter
2 {
3 void print(DoubleExpression *de, ostringstream& oss) const
4 {
5 oss << de->value;
6 }
7 void print(AdditionExpression *ae, ostringstream& oss) const
8 {
9 oss << "(";
10 print(ae->left, oss);
11 oss << "+";
12 print(ae->right, oss);
13 oss << ")";
14 }
15 };
前面代码编译的几率:零。C++ 知道,ae->left是一个Expression,但是由于它不在运行时检查类型(不像各种动态类型的语言),它不知道调用哪个重载。太糟糕了!
这里能做什么?嗯,只有一件事——移除重载并在运行时检查类型:
1 struct ExpressionPrinter
2 {
3 void print(Expression *e)
4 {
5 if (auto de = dynamic_cast<DoubleExpression*>(e))
6 {
7 oss << de->value;
8 }
9 else if (auto ae = dynamic_cast<AdditionExpression*>(e))
10 {
11 oss << "(";
12 print(ae->left, oss);
13 oss << "+";
14 print(ae->right, oss);
15 oss << ")";
16 }
17 }
18
19 string str() const { return oss.str(); }
20 private:
21 ostringstream oss;
22 };
上述内容实际上是一个可用的解决方案:
1 auto e = new AdditionExpression{
2 new DoubleExpression{ 1 },
3 new AdditionExpression{
4 new DoubleExpression{ 2 },
5 new DoubleExpression{ 3 }
6 }
7 };
8 ExpressionPrinter ep;
9 ep.print(e);
10 cout << ep.str() << endl; // prints "(1+(2+3))"
这种方法有一个相当大的缺点:没有编译器检查,事实上,您已经为层次结构中的每个元素实现了打印。
当添加新元素时,您可以继续使用ExpressionPrinter而无需修改,它将跳过任何新类型的元素。
但这是一个可行的解决方案。说真的,很有可能就此打住,不再继续使用访问者模式:dynamic_cast这并不昂贵,我认为许多开发人员会记得在if语句中包含每一种类型的对象。
WTH 是调度吗?
每当人们谈到来访者,就会提到派遣这个词。这是什么?简而言之,“分派”是一个计算调用哪个函数的问题,具体来说,就是需要多少条信息才能进行调用。
这里有一个简单的例子:
1 struct Stuff {}
2 struct Foo : Stuff {}
3 struct Bar : Stuff {}
4
5 void func(Foo* foo) {}
6 void func(Bar* bar) {}
现在,如果我创建一个普通的Foo对象,我可以用它调用func():
1 Foo *foo = new Foo;
2 func(foo); // ok
但是如果我决定将它转换为基类指针,编译器将不知道调用哪个重载:
1 Stuff *stuff = new Foo;
2 func(stuff); // oops!
现在,让我们从多方面来考虑这个问题:有没有什么方法可以强迫系统调用正确的重载,而不需要任何运行时(dynamic_cast和类似的)检查?原来是有的。
看,当你在一个Stuff上调用某个东西时,这个调用可以是多态的(多亏了一个 Vtable ),它可以被直接分派给必要的组件。这又可以调用必要的重载。这被称为双重分派,因为:
- 首先对实际对象进行多态调用。
- 在多态调用中,调用重载。因为在对象内部,
this有一个精确的类型(例如,Foo*或Bar*),所以正确的重载被触发。
我的意思是:
1 struct Stuff {
2 virtual void call() = 0;
3 }
4 struct Foo : Stuff {
5 void call() override { func(this); }
6 }
7 struct Bar : Stuff {
8 void call() override { func(this); }
9 }
10
11 void func(Foo* foo) {}
12 void func(Bar* bar) {}
你能看到这里发生了什么吗?我们不能把一个通用的call()实现粘在Stuff中:不同的实现必须在它们各自的类中,这样this指针才能被正确地类型化。
这个实现允许您编写以下内容:
1 Stuff *stuff = new Foo;
2 stuff->call(); // effectively calls func(stuff);
经典访客
访问者设计模式的“经典”实现使用双重分派。访问者成员函数的调用有一些约定:
- 访问者的成员函数通常被称为
visit()。 - 在整个层次结构中实现的成员函数通常被称为
accept()。
我们现在可以从我们的Expression基类中扔掉那个虚拟析构函数,因为我们实际上已经有东西放在那里了:函数accept():
1 struct Expression
2 {
3 virtual void accept(ExpressionVisitor *visitor) = 0;
4 };
正如你所看到的,前面提到了一个名为ExpressionVisitor的(抽象)类,它可以作为各种访问者的基类,比如ExpressionPrinter、ExpressionEvaluator等等。我在这里选择了一个指针,但是你可以用一个引用来代替。
现在,Expression的每一个继承者都需要以相同的方式实现accept(),即:
1 void accept(ExpressionVisitor* visitor) override
2 {
3 visitor->visit(this);
4 }
另一方面,我们可以将ExpressionVisitor定义如下:
1 struct ExpressionVisitor
2 {
3 virtual void visit(DoubleExpression* de) = 0;
4 virtual void visit(AdditionExpression* ae) = 0;
5 };
注意,我们绝对必须为所有对象定义重载;否则,在实现相应的accept()时,我们会得到一个编译错误。我们现在可以继承这个类来定义我们的ExpressionPrinter:
1 struct ExpressionPrinter : ExpressionVisitor
2 {
3 ostringstream oss;
4 string str() const { return oss.str(); }
5 void visit(DoubleExpression* de) override;
6 void visit(AdditionExpression* ae) override;
7 };
visit()函数的实现应该是相当明显的,因为我们已经不止一次地看到过它,但是我将再次展示它:
1 void ExpressionPrinter::visit(AdditionExpression* ae)
2 {
3 oss << "(";
4 ae->left->accept(this);
5 oss << "+";
6 ae->right->accept(this);
7 oss << ")";
8 }
注意现在调用是如何发生在子表达式本身上的,再次利用了双重分派。至于新的双派遣访问者的用法,这里是:
1 void main()
2 {
3 auto e = new AdditionExpression{
4 // as before
5 };
6 ostringstream oss;
7 ExpressionPrinter ep;
8 ep.visit(e);
9 cout << ep.str() << endl; // (1+(2+3))
10 }
实现附加访问者
那么,这种方式的优势是什么呢?这样做的好处是您只需通过层次结构实现一次accept()成员。你再也不用碰任何一个成员了。例如:假设你现在想有一种方法来评估表达式的结果?这很简单:
1 struct ExpressionEvaluator : ExpressionVisitor
2 {
3 double result;
4 void visit(DoubleExpression* de) override;
5 void visit(AdditionExpression* ae) override;
6 };
但是需要记住的是,visit()目前被声明为一个void方法,所以实现可能看起来有点奇怪:
1 void ExpressionEvaluator::visit(DoubleExpression* de)
2 {
3 result = de->value;
4 }
5
6 void ExpressionEvaluator::visit(AdditionExpression* ae)
7 {
8 ae->left->accept(this);
9 auto temp = result;
10 ae->right->accept(this);
11 result += temp;
12 }
前面是无法从accept()到return的副产品,有点棘手。本质上,我们评估左边的部分并缓存值。然后我们评估正确的部分(因此result被设置),然后用我们缓存的值增加它,从而产生总和。不完全是直观的代码!
尽管如此,它工作得很好:
1 auto e = new AdditionExpression{ /* as before */ };
2 ExpressionPrinter printer;
3 ExpressionEvaluator evaluator;
4 printer.visit(e);
5 evaluator.visit(e);
6 cout << printer.str() << " = " << evaluator.result << endl;
7 // prints "(1+(2+3)) = 6"
同样,你可以添加许多其他不同的游客,向 OCP 致敬,并在这个过程中享受乐趣。
非循环访问者
现在是一个很好的时机来提及访问者设计模式实际上有两种类型。他们是
- 循环访问者,这是基于函数重载。由于层次结构(必须知道访问者的类型)和访问者(必须知道层次结构中的每个类)之间的循环依赖,该方法的使用仅限于不经常更新的稳定层次结构。
- 非循环访问者,这是基于 RTTI。这里的优点是对被访问的层次结构没有限制,但是,正如您可能已经猜到的,存在性能问题。
非循环访问者实现的第一步是实际的访问者接口。我们没有为层次结构中的每一个类型定义一个visit()重载,而是尽可能地使事情通用化:
1 template <typename Visitable>
2 struct Visitor
3 {
4 virtual void visit(Visitable& obj) = 0;
5 };
我们需要我们的域模型中的每个元素都能够接受这样的访问者,但是由于每个专门化都是唯一的,我们所做的就是引入一个标记接口——一个空类,除了一个虚拟析构函数之外什么也不是:
1 struct VisitorBase // marker interface
2 {
3 virtual ~VisitorBase() = default;
4 };
前面的类没有行为,但是我们将把它作为一个accept()方法的参数,用于我们实际想要访问的任何对象。现在,我们能做的是重新定义我们之前的Expression类,如下所示:
1 struct Expression
2 {
3 virtual ~Expression() = default;
4
5 virtual void accept(VisitorBase& obj)
6 {
7 using EV = Visitor<Expression>;
8 if (auto ev = dynamic_cast<EV*>(&obj))
9 ev->visit(*this);
10 }
11 };
下面是新的accept()方法的工作原理:我们获取一个VisitorBase,然后尝试将其转换为一个Visitor<T>,其中T是我们当前所在的类型。如果转换成功,这个访问者知道如何访问我们的类型,所以我们调用它的visit()方法。如果它失败了,那就没用了。理解为什么obj本身没有我们可以调用的visit()是很重要的。如果是这样的话,就需要为每一个有兴趣调用它的类型重载,这正是引入循环依赖的原因。
在我们模型的其他部分实现了accept()之后,我们可以通过再次定义一个ExpressionPrinter来把所有的东西放在一起,但是这一次,它看起来如下:
1 struct ExpressionPrinter : VisitorBase,
2 Visitor<DoubleExpression>,
3 Visitor<AdditionExpression>
4 {
5 void visit(DoubleExpression &obj) override;
6 void visit(AdditionExpression &obj) override;
7
8 string str() const { return oss.str(); }
9 private:
10 ostringstream oss;
11 };
如你所见,我们实现了VisitorBase标记接口以及一个Visitor<T>用于我们想要访问的每个T。如果我们省略了一个特定的类型T(例如,假设我注释掉了Visitor<DoubleExpression>),程序仍然会编译,并且相应的accept()调用,如果它来了,将简单地作为空操作执行
在前面的内容中,visit()方法的实现实际上与我们在传统的 visitor 实现中所拥有的完全相同,结果也是如此。
std::和visit的变体
虽然与传统的访问者模式没有直接关系,但是值得一提的是std::visit,仅仅是因为它的名字暗示了与访问者模式有关。本质上,std::visit是一种访问变体类型的正确部分的方法。
这里有一个例子:假设您有一个地址,该地址的一部分是一个house字段。现在,一所房子可以只是一个数字(如“伦敦路 123 号”),也可以有一个名字,如“蒙特菲奥里城堡”因此,您可以按如下方式定义变量:
1 variant<string, int> house;
2 // house = "Montefiore Castle";
3 house = 221;
这两个赋值都有效。现在,假设您决定打印房屋名称或门牌号。为此,您可以首先定义一个类型,该类型为变体中不同类型的成员提供函数调用重载:
1 struct AddressPrinter
2 {
3 void operator()(const string& house_name) const {
4 cout << "A house called " << house_name << "\n";
5 }
6
7 void operator()(const int house_number) const {
8 cout << "House number " << house_number << "\n";
9 }
10 };
现在,这种类型可以与std::visit()结合使用,这是一个将访问者应用到 variant 类型的库函数:
1 AddressPrinter ap;
2 std::visit(ap, house); // House number 221
由于一些现代 C++ 的魔力,也可以适当地定义一组访问者函数。我们需要做的是构造一个类型为auto&的 lambda,获取底层类型,使用if constexpr进行比较,并进行相应的处理:
1 std::visit([](auto& arg) {
2 using T = decay_t<decltype(arg)>;
3
4 if constexpr (is_same_v<T, string>)
5 {
6 cout << "A house called " << arg.c_str() << "\n";
7 }
8 else
9 {
10 cout << "House number " << arg << "\n";
11 }
12 }, house);
摘要
访问者设计模式允许我们向对象层次结构中的每个元素添加一些行为。我们看到的方法包括
- 介入式:向层次结构中的每个对象添加一个虚方法。有可能(假设你有源代码),但打破 OCP。
- 反射式:添加一个不需要改变对象的独立访问者;每当需要运行时调度时使用
dynamic_cast。 - 经典(双重分派):整个层次结构确实被修改了,但只是一次,而且是以一种非常普通的方式。这个层级的每一个成员都学会了如何接待访客。然后,我们对 visitor 进行子类化,以在各个方向增强层次结构的功能。
访问者经常与解释器模式一起出现:在解释了一些文本输入并将其转换成面向对象的结构之后,我们需要,例如,以特定的方式呈现抽象语法树。Visitor 帮助在整个层次结构中传播一个ostringstream(或类似的对象)并将数据整理在一起。
二十五、也许是单子
在 C++ 中,像在许多其他语言中一样,我们有不同的方式来表达一个值的存在或不存在。特别是在 C++ 中,我们可以使用以下任何一种:
- 使用
nullptr对缺勤进行编码。 - 使用智能指针(例如,
shared_ptr),同样可以测试其是否存在。 std::optional<T>是库解决方案;如果缺少值,它可以存储类型为T或std::nullopt的值。
假设我们决定采用nullptr方法。在这种情况下,让我们假设我们的域模型定义了一个Person,它可能有也可能没有一个Address,反过来,它可以有一个可选的house_name 1 :
1 struct Address {
2 string* house_name = nullptr;
3 };
4
5 struct Person {
6 Address* address = nullptr;
7 };
我们感兴趣的是写一个函数,给定一个人,安全地打印这个人的房屋名称,当然如果它存在的话。在“传统的”C++ 中,我们会这样实现它:
1 void print_house_name(Person* p)
2 {
3 if (p != nullptr &&
4 p->address != nullptr &&
5 p->address->house_name != nullptr) // ugh!
6 cout << *p->address->house_name << endl;
7 }
前面的代码代表了深入对象结构的过程,注意不要访问nullptr值。相反,这种向下钻取的过程可以通过使用可能单子以函数的方式来表示。
为了构造单子,我们将定义一个新的类型Maybe<T>。此类型将用作参与下钻过程的临时对象:
1 template <typename T> struct Maybe {
2 T* context;
3 Maybe(T *context) : context(context) { }
4 };
到目前为止,Maybe看起来像一个指针容器,没什么令人兴奋的。它也不是很有用,因为给定一个Person* p,我们不能产生一个Maybe(p),因为我们不能从构造器中传递的参数推导出类模板参数。在这种情况下,我们还创建了一个 helper 全局函数,因为函数实际上可以推导出模板参数:
1 template <typename T> Maybe<T> maybe(T* context)
2 {
3 return Maybe<T>(context);
4 }
现在,我们想要做的是给Maybe一个成员函数
- 如果
context != nullptr,则更深地钻入对象;或者 - 如果上下文实际上是
nullptr,则什么也不做
“向下钻取”的过程被封装到模板参数Func中,如下所示:
1 template <typename Func>
2 auto With(Func evaluator)
3 {
4 return context != nullptr ? maybe(evaluator(context)) : nullptr;
5 }
前面是高阶函数的一个例子,也就是取函数的函数。 2 我们创建的这个函数采用了另一个名为evaluator的函数,假设当前上下文为非空,可以在上下文中调用这个函数,并返回一个可以包装在另一个Maybe中的指针。这个技巧允许链接With()呼叫。
现在,以类似的方式,我们可以创建另一个成员函数,这次只需调用context上的给定函数,而不改变上下文本身:
1 template <typename TFunc>
2 auto Do(TFunc action)
3 {
4 if (context != nullptr) action(context);
5 return *this;
6 }
我们完事了。我们现在可以做的是重新定义我们的print_house_name()函数如下:
1 void print_house_name(Person* p)
2 {
3 auto z = maybe(p)
4 .With([](auto x) { return x->address; })
5 .With([](auto x) { return x->house_name; })
6 .Do([](auto x) { cout << *x << endl; });
7 }
这里有几点需要注意。首先,我们设法创建了一个流畅的接口,即一个可以将函数调用一个接一个链接起来的设置。这种说法是有道理的,因为每个操作符(With、Do等)。)返回*this或者一个新的Maybe<T>。同样值得注意的是,下钻过程是如何在每一个转折点被 lambda 函数封装的。
正如您可能猜到的,前面的方法确实有性能成本,尽管这些成本很难预测,并且取决于编译器优化代码的能力。它也远非完美,因为我很乐意省略[](auto x)部分,以支持一些速记符号。理想情况下,类似于maybe(p).With{it->address}的东西会很好。
Footnotes 1
房子的名字是真实存在的(至少在英国是这样的):当你买了一座城堡,它的地址不是“伦敦路 123 号”,而只是“蒙特菲奥里城堡”,这就是它的地址。你可以猜到,并不是所有的房子都有名字,这就解释了为什么这个字段是可选的。
2
严格地说,高阶函数要么接受一个函数作为一个或多个参数,要么返回一个函数(或两者都有)。
3
例如,Kotlin 和 Swift 编程语言支持这种方法。如果没有必要,这两种语言都允许程序员避免额外的 lambda 函数仪式。这包括省略参数、捕获列表和返回值,以及使用花括号,而不是圆括号,这让您可以简单地打开一个事实上的作用域,并放置所有要由 lambda 执行的语句。
第一部分:创建模式
即使没有创造模式,用 C++ 创造一个对象的行为也充满了危险。应该在栈上创建还是在堆上创建?那应该是一个原始指针,一个唯一的或共享的指针,还是其他什么?最后,手动创建对象是否仍然合适,或者我们是否应该将基础设施的所有关键方面的创建推迟到专门的构造,如工厂(稍后将详细介绍它们!)还是控制容器的倒置?
无论您选择哪一个选项,创建对象仍然是一件苦差事,尤其是如果构建过程很复杂或者需要遵守特殊的规则。这就是创造模式的来源:它们是与创建对象相关的常见方法。
如果你对基本的 C++ 或者智能指针不太熟悉,这里有一个简单的 C++ 对象创建方法的回顾:
- 栈分配创建一个将在栈上分配的对象。该对象将在作用域结束时被自动清理(您可以用一对花括号在任何地方创建一个人工作用域)。如果你把这个对象赋给一个变量,这个对象将在作用域的最末端调用析构函数;如果不这样做,析构函数将被立即调用。(这可能会破坏 Memento 设计模式的一些实现,我们稍后会发现。)
- 使用原始指针的堆分配将对象放在堆上(也称为自由存储)。
Foo* foo = new Foo;创建了一个Foo的新实例,并留下了谁负责清理对象的问题。GSL 1owner<T>试图引入一些原始指针“所有权”的概念,但不涉及任何清理代码——你仍然必须自己编写。 - 一个唯一的指针(
unique_ptr)可以获取一个堆分配的指针并管理它,这样当不再有对它的引用时,它会被自动清除。唯一指针确实是唯一的:你不能复制它,也不能把它传递给另一个函数而不失去对原指针的控制。 - 共享指针(
shared_ptr)接受一个堆分配的指针并管理它,但是允许在代码中共享这个指针。只有当指针上没有组件时,拥有的指针才会被清除。 - 弱指针(
weak_ptr)是一个智能但无所有权的指针,它保存对由shared_ptr管理的对象的弱引用。您需要将它转换成一个shared_ptr,以便能够实际访问被引用的对象。它的用途之一是打破shared_ptrs 的循环引用。
从函数返回对象
如果你要返回大于一个字大小的值,有几种方法可以从函数中返回一些东西。首先,也是最明显的是:
1 Foo make_foo(int n)
2 {
3 return Foo{n};
4 }
您可能会觉得,使用前面的方法,正在制作Foo的完整副本,从而浪费了宝贵的资源。但并不总是如此。假设您将Foo定义为:
1 struct Foo
2 {
3 Foo(int n) {}
4 Foo(const Foo&) { cout << "COPY CONSTRUCTOR!!!\n"; }
5 };
您会发现复制构造器可能被调用零到两次:调用的确切次数取决于编译器。返回值优化(RVO)是一个编译器特性,它专门防止产生额外的副本(因为它们不会真正影响代码的行为)。然而,在复杂的场景中,你真的不能指望 RVO 会发生,但是在选择是否优化返回值的时候,我更倾向于选择 Knuth。 2
当然,另一种方法是简单地返回一个智能指针,比如一个unique_ptr:
1 unique_ptr<Foo> make_foo(int n)
2 {
3 return make_unique<Foo>(n);
4 }
这是非常安全的,但也是固执己见的:你已经为用户选择了智能指针。他们不喜欢智能指针怎么办?如果他们更喜欢shared_ptr呢?
第三个也是最后一个选择是使用原始指针,可能与 GSL 的owner<T>一起使用。这样,您不是在强制清理分配的对象,而是在传递一个非常明确的信息,即这是调用者的责任:
1 owner<Foo*> make_foo(int n)
2 {
3 return new Foo(n);
4 }
你可以把这种方法看作是给用户一个提示:我正在返回一个指针,从现在开始由你来负责这个指针。当然,现在make_foo()的调用者需要处理指针:要么正确调用delete,要么将其包装在unique_ptr或shared_ptr中。请记住,owner<T>没有提到复制。
所有这些选项都同样有效,很难说哪个选项更好。
Footnotes 1
指南支持库( https://github.com/Microsoft/GSL )是 C++ 核心指南建议的一组函数和类型。这个库包括许多类型,其中的owner<T>类型用于指示指针的所有权。
2
以《计算机编程的艺术》系列丛书而闻名的唐纳德·克努特(Donald Knuth)曾写过一篇论文,声称“过早优化是万恶之源”。C++ 让过早的优化变得非常诱人,但是你应该抵制这种诱惑,直到 A)你完全明白你在做什么;B)您实际体验到需要优化的性能效果。
第二部分:结构模式
顾名思义,结构模式就是建立应用程序的结构,以提高代码的一致性、可用性和可重构性。
当涉及到确定一个物体的结构时,我们可以采用两种相当众所周知的方法:
- 继承:对象自动获取其基类的非私有字段和函数。为了允许实例化,对象必须实现来自其父对象的每个纯虚拟成员;如果没有,那它就是抽象的,不能被创造(但是你可以从中继承)。
- 复合:通常意味着孩子不能离开父母而存在。想象一个拥有
owner<T>类型成员的对象:当对象被销毁时,它们也随之被销毁。 - 聚合:一个对象可以包含另一个对象,但是那个对象也可以独立存在。想象一个拥有类型为
T*或shared_ptr<T>的成员的对象。
如今,组合和聚合都以相同的方式处理。如果你有一个字段类型为Address的Person类,你可以选择Address是外部类型还是嵌套类型。在这两种情况下,只要它是public,就可以将其实例化为Address或Person::Address。
我认为,当我们真正指的是聚合时,使用组合这个词已经变得如此普遍,以至于我们也可以以可互换的方式使用它们。这里有一些证据:当我们谈到 IoC 容器时,我们说的是复合根。但是等等,IoC 容器不是单独控制每个对象的生存期吗?确实如此,所以我们在这里使用“组合”这个词,实际上是指“聚合”。
第三部分:行为模式
当大多数人听说行为模式时,主要是关于动物以及如何让它们做你想做的事情。嗯,在某种程度上,所有的编码都是关于程序做你想做的事情,所以行为软件设计模式涵盖了非常广泛的行为,尽管如此,这些行为在编程中还是很常见的。
作为一个例子,考虑软件工程领域。我们有经过编译的语言,其中包括词法分析、语法分析和无数其他事情(解释器模式),并且,在为程序构建了抽象语法树(AST)之后,您可能想要分析程序中可能存在的错误(访问者模式)。所有这些行为都很常见,可以用模式来表达,这就是我们今天在这里的原因。
与创造模式(专门关注对象的创建)或结构模式(关注对象的组合/聚合/继承)不同,行为设计模式不遵循一个中心主题。虽然不同的模式之间有某些相似之处(例如,策略和模板方法以不同的方式做同样的事情),但是大多数模式都提供了解决特定问题的独特方法。
第四部分:附录 A:函数式设计模式
Appendix A: Functional Design Patterns
虽然 C++ 主要是一种面向对象的编程语言,但是对函数对象(例如,std::function)和 lambda 函数的支持使得它对单子的支持有限,单子是函数编程世界的设计模式。不过,不得不说,由于对函数对象以及有用的辅助结构(例如,代数数据类型、模式匹配等)的更好处理,单子在函数式编程语言中更有用。).
我不打算在本书中展示单子的目录,但是我想展示至少一个单子的例子,它可以被 C++ 开发人员使用。