读书笔记:C++ Software Design(2)

2.访问者模式

#include <cstdlib>
#include <iostream>
#include <string>
#include <variant>
struct Print
{
 void operator()( int value ) const
 { std::cout << "int: " << value << '\n'; }
 void operator()( double value ) const
 { std::cout << "double: " << value << '\n'; }
 void operator()( std::string const& value ) const
 { std::cout << "string: " << value << '\n'; }
};
int main()
{
 // Creates a default variant that contains an 'int' initialized to 0
 std::variant<int,double,std::string> v{};
 v = 42; // Assigns the 'int' 42 to the variant
 v = 3.14; // Assigns the 'double' 3.14 to the variant
 v = 2.71F; // Assigns a 'float', which is promoted to 'double'
 v = "Bjarne"; // Assigns the string literal 'Bjarne' to the variant
 v = 43; // Assigns the 'int' 43 to the variant
 int const i = std::get<int>(v); // Direct access to the value
 int* const pi = std::get_if<int>(&v); // Direct access to the value
 std::visit( Print{}, v ); // Applying the Print visitor
 return EXIT_SUCCESS;
}

​ ​​​  既然你可能还没有幸被介绍过 C++17 的 std::variant,请允许我用最简短的方式给你介绍一下,以防万一。一个 variant代表多个备选类型中的一种。在代码示例中 main()函数开头的 variant可以包含一个 int、一个 double或一个 std::string。注意我说的是"或":一个 variant只能包含这三种备选类型中的一种。它永远不会同时包含多个,并且在通常情况下,它永远不应该什么都不包含。因此,我们称 variant为一个和类型:可能状态的集合是各备选类型可能状态的总和

​ ​​​  默认的 variant也不是空的。它被初始化为第一个备选类型的默认值。在示例中,一个默认的 variant包含值为 0 的整数。改变 variant的值很简单:你可以直接赋值。例如,我们可以赋值 42,现在意味着 variant存储了一个值为 42 的整数。如果我们随后赋值一个双精度浮点数 3.14,那么 variant将存储一个值为 3.14 的 double。如果你想要赋值一个不是可能的备选类型之一的值,将应用常规的类型转换规则。例如,如果你想要赋值一个 float,根据常规的转换规则,它会被提升为 double

​ ​​​  为了存储备选类型,variant提供了恰好足够容纳最大备选类型的内存缓冲区。在我们的例子中,最大的备选类型是 std::string,通常为 24 到 32 字节(取决于所使用的标准库实现)。因此,当你赋值字符串字面量 "Bjarne" 时,variant会首先清理先前的值,然后,由 这是唯一可行的备选类型,在原地构建 std::string到其内部缓冲区中。当你改变主意,赋值整数 43 时,variant会通过其析构函数正确销毁 std::string并将内部缓冲区重用于整数。很神奇,不是吗?variant是类型安全的,并且总是被正确初始化。我们还能要求什么呢?

​ ​​​  好吧,你当然想用 variant内部的值做点什么。如果我们只是存储这个值,那就没什么用处了。不幸的是,你不能简单地将一个 variant赋值给任何其他值(例如一个 int)来取回你的值。不,访问存储的值要稍微复杂一点。有几种访问存储值的方法,最直接的方法是 std::get()。通过 std::get(),你可以查询特定类型的值。如果 variant包含该类型的值,它返回一个该值的引用。如果不包含,它将抛出 std::bad_variant_access异常。考虑到你问得很"客气",这似乎是一个非常粗暴的回应。但我们或许应该感到高兴,因为当 variant确实不持有某个值时,它不会假装持有。至少它是诚实的。有一种更温和的方式,即 std::get_if()。与 std::get()相比,std::get_if()不返回引用,而是返回一个指针。如果你请求一个 std::variant当前不持有的类型,它不会抛出异常,而是返回一个 nullptr

​ ​​​  然而,还有第三种方式,这种方式对我们的目的特别有趣:std::visit()std::visit()允许你对存储的值执行任何操作。或者更准确地说,它允许你传递一个自定义的访问者,以对封闭类型集合中的存储值执行任何操作。听起来熟悉吗?

​ ​​​  我们作为第一个参数传递的 Print访问者必须为每一个可能的备选类型提供一个函数调用运算符。在这个例子中,这是通过提供三个 operator()来实现的:一个用于 int,一个用于 double,一个用于 std::string。特别值得注意的是,Print不必继承自任何基类,并且没有任何虚函数。因此,对任何要求都没有强耦合。如果我们愿意,我们也可以将用于 intdouble的函数调用运算符合并为一个,因为 int可以转换为 double

2.1 基于std::varaint实现的访问者模式

//---- <Circle.h> ----------------
#include <Point.h>
class Circle
{
public:
 explicit Circle( double radius )
 : radius_( radius )
 {
 /* Checking that the given radius is valid */
 }
 double radius() const { return radius_; }
 Point center() const { return center_; }
private:
 double radius_;
 Point center_{};
};
//---- <Square.h> ----------------
#include <Point.h>
class Square
{
public:
 explicit Square( double side )
 : side_( side )
 {
 /* Checking that the given side length is valid */
 }
 double side () const { return side_; }
 Point center() const { return center_; }
private:
 double side_;
 Point center_{};
};

​ ​​​  CircleSquare都显著简化了:不再需要 Shape基类,不再需要实现任何虚函数——特别是 accept()函数。因此,这种访问者方法是非侵入式的:这种形式的访问者可以轻松地添加到现有类型中!并且没有必要为任何即将到来的操作预先准备这些类。我们可以完全专注于将这两个类实现为它们本来的样子:几何基元。

​ ​​​  然而,重构中最美妙的部分是 std::variant的实际使用:

//---- <Shape.h> ----------------
#include <variant>
#include <Circle.h>
#include <Square.h>
using Shape = std::variant<Circle,Square>;
//---- <Shapes.h> ----------------
#include <vector>
#include <Shape.h>
using Shapes = std::vector<Shape>; 

​ ​​​  既然我们的封闭类型集合是一组形状,variant现在将包含 CircleSquare。那么,对于一个表示形状的类型集合的抽象,什么是好名字呢?嗯……Shape。现在由 std::variant承担了从实际形状类型中抽象出来的任务,而不是基类。如果你是第一次看到这个,你可能会非常惊讶。但是等等,还有更多:这也意味着我们现在可以不用再使用 std::unique_ptr了。记住:我们使用(智能)指针的唯一原因是使我们能够在同一个 vector中存储不同类型的形状。但是既然 std::variant使我们能够做同样的事情,我们可以简单地将 variant对象存储在单个 vector中。

​​​  有了这个功能,我们可以编写对形状的自定义操作。我们仍然对绘制形状感兴趣。为此,我们现在实现 Draw访问者:

//---- <Draw.h> ----------------
#include <Shape.h>
#include /* some graphics library */
struct Draw
{
 void operator()( Circle const& c ) const
 { /* ... Implementing the logic for drawing a circle ... */ }
 void operator()( Square const& s ) const
 { /* ... Implementing the logic for drawing a square ... */ }
};

​ ​​​  再次,我们遵循预期,为每个备选类型实现一个 operator():一个用于 Circle,一个用于 Square。但这次我们有了选择。不需要实现任何基类,因此也不需要重写任何虚函数。所以,不一定要为每个备选类型都实现恰好一个 operator()。虽然在这个例子中,实现两个函数感觉合理,但我们也可以选择将两个 operator()合并为一个函数。关于操作的返回类型,我们也有选择权。我们可以局部地决定应该返回什么,而不是由一个基类独立于具体操作做出全局决定。实现灵活性。松耦合。 太棒了!

​ ​​​  最后一块拼图是 drawAllShapes()函数:

//---- <DrawAllShapes.h> ----------------
#include <Shapes.h>
void drawAllShapes( Shapes const& shapes );
//---- <DrawAllShapes.cpp> ----------------
#include <DrawAllShapes.h>
void drawAllShapes( Shapes const& shapes )
{
 for( auto const& shape : shapes )
 {
 std::visit( Draw{}, shape );
 }
}

​ ​​​  drawAllShapes()函数被重构为使用 std::visit()。在这个函数中,我们现在将 Draw访问者应用到存储在 vector中的所有 variant上。

​​​  std::visit()的任务是为你执行必要的类型分发。如果给定的 std::variant包含一个 Circle,它将调用用于 CircleDraw::operator()。否则,它将调用用于 SquareDraw::operator()。如果你想的话,你可以用 std::get_if()手动实现相同的分发:

void drawAllShapes( Shapes const& shapes )
{
 for( auto const& shape : shapes )
 {
 if( Circle* circle = std::get_if<Circle>(&shape) ) {
 // ... Drawing a circle
 }
 else if( Square* square = std::get_if<Square>(&shape) ) {
 // ... Drawing a square
 }
 }
}

​ ​​​  我知道你在想什么:"胡说!我为什么会想这么做?那将导致和基于 enum的解决方案一样的维护噩梦。" 我完全同意你的看法:从软件设计的角度来看,这将是个糟糕的主意。尽管如此,并且我必须说,在这本书的背景下承认这一点很困难,但这样做(有时)可能有一个好的理由:性能。我知道,现在引起了你的兴趣,但既然我们马上就要讨论性能了,请允许我将这个讨论推迟几段。我保证会回到这个话题!

​ ​​​  在了解了所有这些细节之后,我们终于可以重构 main()函数了。但需要做的工作并不多:不再通过 std::make_unique()创建 CircleSquare,我们直接创建 CircleSquare,并将它们添加到 vector中。这要归功于 variant非显式构造函数,它允许任何备选类型的隐式转换:

#include <Circle.h>
#include <Square.h>
#include <Shapes.h>
#include <DrawAllShapes.h>
int main()
{
 Shapes shapes;
 shapes.emplace_back( Circle{ 2.3 } );
 shapes.emplace_back( Square{ 1.2 } );
 shapes.emplace_back( Circle{ 4.1 } );
 drawAllShapes( shapes );
 return EXIT_SUCCESS;
}

​ ​​​  这种基于值的解决方案最终结果令人着迷:没有任何基类没有任何虚函数没有任何指针没有任何手动内存分配。一切都尽可能直接,样板代码极少。此外,尽管代码看起来与之前的解决方案大相径庭,但其架构属性是相同的:任何人都能添加新操作,而无需修改现有代码。因此,在添加操作方面,我们仍然遵循了开闭原则。

2.2 适用范围

​ ​​​  访问者设计模式是GoF所描述的经典设计模式之一。它的重点在于允许你频繁地添加操作,而不是类型。请允许我使用之前的玩具示例——绘制形状——来解释访问者设计模式。

​ ​​​  Shape类仍然是许多具体形状的基类。在这个例子中,只有 CircleSquare两个类,但当然,可以有更多的形状。此外,你可能会想象有 TriangleRectangleEllipse等类。让我们假设你确信你已经拥有了将来需要的所有形状。也就是说,你认为形状集合是一个封闭集合。然而,你缺少的是额外的操作。例如,你缺少一个旋转形状的操作。此外,你想序列化形状,也就是说,你想将形状的实例转换为字节。当然,你还想绘制形状。另外,你希望允许任何人添加新的操作。因此,你期望的是一个开放的操作集合

​ ​​​  现在,每一个新操作都要求你在基类中插入一个新的虚函数。不幸的是,这可能会以不同的方式带来麻烦。最明显的是,并非每个人都能向 Shape基类添加虚函数。例如,我不能直接去修改你的代码。因此,这种方法不符合"任何人都能添加操作"的期望。虽然你已将此视为一个最终的负面定论,但我们还是更详细地分析一下虚函数的问题。

​ ​​​  如果你决定使用纯虚函数,你将不得不在每个派生类中实现该函数。对于你自己的派生类型,你可能只会把这当作一点点额外的工作。但你可能会给其他从 Shape基类继承并创建了形状的人带来额外的工作。而这非常有可能发生,因为这是面向对象编程的优势:任何人都可以轻松添加新类型。既然这是可以预期的,这可能是不使用纯虚函数的一个理由。

​ ​​​  作为替代方案,你可以引入一个常规的虚函数,即带有默认实现的虚函数。虽然 rotate()函数的默认行为听起来是个非常合理的想法,但 serialize()函数的默认实现听起来一点都不容易。我承认我得仔细想想如何实现这样的函数。你现在可能建议只是抛出一个异常作为默认实现。然而,这意味着派生类必须再次实现缺失的行为,这将是伪装的纯虚函数,或者明显违反了里氏替换原则

​ ​​​  无论哪种方式,向 Shape基类中添加一个新操作都是困难的,甚至根本不可能。根本原因是添加虚函数违反了开闭原则。如果你确实需要频繁添加新操作,那么你应该进行设计,使操作的扩展变得容易。这正是访问者设计模式试图实现的目标。

3.策略模式

3.1策略模式的特点

//---- <Shape.h> ----------------
class Shape
{
public:
 virtual ~Shape() = default;
 virtual void draw( /*some arguments*/ ) const = 0;
};
//---- <Circle.h> ----------------
#include <Point.h>
#include <Shape.h>
class Circle : public Shape
{
public:
 explicit Circle( double radius )
 : radius_( radius )
 {
 /* Checking that the given radius is valid */
 }
 double radius() const { return radius_; }
 Point center() const { return center_; }
 void draw( /*some arguments*/ ) const override;
private:
 double radius_;
 Point center_{};
};
//---- <Circle.cpp> ----------------
#include <Circle.h>
#include /* some graphics library */
void Circle::draw( /*some arguments*/ ) const
{
 // ... Implementing the logic for drawing a circle
}
//---- <Square.h> ----------------
#include <Point.h>
#include <Shape.h>
class Square : public Shape
{
public:
 explicit Square( double side )
 : side_( side )
 {
 /* Checking that the given side length is valid */
 }
 double side () const { return side_; }
 Point center() const { return center_; }
 void draw( /*some arguments*/ ) const override;
private:
 double side_;
 Point center_{};
};
//---- <Square.cpp> ----------------
#include <Square.h>
#include /* some graphics library */
void Square::draw( /*some arguments*/ ) const
{
 // ... Implementing the logic for drawing a square
}

​ ​​​  上面的代码中描述了几何图元类的层次结构,Square和Circle类拥有自己独有数据,又继承了Shape的抽象draw函数。如此一来,Square和Circle类都会依赖于与绘制相关的OpenGL库。如果draw函数内部有变化,使用了Vulcan库来绘制,那么整体改动就很大。如果使用继承的方式来完善代码,很可能导致类的层次结构错综复杂。

image-20251231150907892

​ ​​​  而组合是一种更合适的方案,策略模式使用组合代替继承,用以解决上诉问题。根据单一职责原则,我们将draw函数提取出来,添加DrawStrategy基类,设计类结构关系图如下。

image-20251231151349036

​ ​​​  绘图方面的隔离现在使我们能够更改绘图的实现,而无需修改形状类。这满足了单一职责原则(SRP)的思想。你现在还可以引入新的 draw()实现,而无需修改任何其他代码。这满足了开闭原则(OCP)。再次说明,在这种面向对象的环境下,SRP 是 OCP 的推动者。改进后的代码如下,DrawStrategy在外部定义并注入到Shape类中。

//---- <DrawStrategy.h> ----------------
template< typename T >
class DrawStrategy
{
public:
 virtual ~DrawStrategy() = default;
 virtual void draw( T const& ) const = 0;
};


//---- <Circle.h> ----------------
#include <Shape.h>
#include <DrawCircleStrategy.h>
#include <memory>
#include <utility>
class Circle : public Shape
{
public:
 explicit Circle( double radius, std::unique_ptr<DrawStrategy<Circle>> drawer )
 : radius_( radius )
 , drawer_( std::move(drawer) )
 {
 /* Checking that the given radius is valid and that
 the given 'std::unique_ptr' is not a nullptr */
 }
 void draw( /*some arguments*/ ) const override
 {
 drawer_->draw( *this, /*some arguments*/ );
 }
 double radius() const { return radius_; }
private:
 double radius_;
 std::unique_ptr<DrawStrategy<Circle>> drawer_;
};

//---- <Square.h> ----------------
#include <Shape.h>
#include <DrawSquareStrategy.h>
#include <memory>
#include <utility>
class Square : public Shape
{
public:
 explicit Square( double side, std::unique_ptr<DrawStrategy<Square>> drawer )
 : side_( side )
 , drawer_( std::move(drawer) )
 {
 /* Checking that the given side length is valid and that
 the given 'std::unique_ptr' is not a nullptr */
 }
 void draw( /*some arguments*/ ) const override
 {
 drawer_->draw( *this, /*some arguments*/ );
 }
 double side() const { return side_; }
private:
 double side_;
 std::unique_ptr<DrawStrategy<Square>> drawer_;
};


​ ​​​  策略模式的缺点:

​ ​​​  首先,虽然某个操作的实现细节已被提取和隔离,但操作本身仍是具体类型的一部分。这一事实证明了前述的限制,即我们仍然无法轻松地添加操作。与访问者模式相反,策略模式保留了面向对象编程的优势,并能使你轻松地添加新类型。

​ ​​​  其次,尽早识别出这类变化点是值得的。否则,将需要进行大量的重构。当然,这并不意味着你应该提前将所有东西都通过策略模式实现,以防万一,从而避免重构。这可能会迅速导致过度设计。但是,在首次有迹象表明某个实现细节可能发生变化,或者希望拥有多种实现时,你应该尽快实施必要的修改。最佳但有些抽象的建议是,尽可能保持简单(KISS原则:保持简单、直接)。

​ ​​​  第三,如果你通过基类实现策略模式,性能肯定会因额外的运行时间接调用而受到影响。性能还受许多手动内存分配(std::make_unique()调用)、由此导致的内存碎片化以及众多指针带来的各种间接寻址的影响。这是可以预见的,但实现的灵活性以及每个人都能添加新实现的机会,可能超过了这种性能损失。当然,这取决于具体情况,你需要逐个案例做决定。如果使用模板实现策略模式(参见关于“基于策略的设计”的讨论),这个缺点就不存在了。

​ ​​​  最后但同样重要的是,策略设计模式的一个主要缺点是,单一的策略应该只处理单一操作或一小组高内聚的函数。否则,你将再次违反单一职责原则。如果需要提取多个操作的实现细节,则必须存在多个策略基类和多个数据成员,它们可以通过依赖注入来设置。

3.2 使用std::fuction实现的策略模式

​ ​​​  std::function为我们重构图形绘制示例提供了所需的一切:它代表了一个单一可调用对象的抽象,这几乎正是我们用来替换 DrawCircleStrategy和 DrawSquareStrategy这两个类层次结构所需的东西,而这两个类层次结构各自只包含一个虚函数。因此,我们依靠 std::function的抽象能力:

//---- <Shape.h> ----------------
class Shape
{
public:
 virtual ~Shape() = default;
 virtual void draw( /*some arguments*/ ) const = 0;
};
//---- <Circle.h> ----------------
#include <Shape.h>
#include <functional>
#include <utility>
class Circle : public Shape
{
public:
 using DrawStrategy = std::function<void(Circle const&, /*...*/)>;
 explicit Circle( double radius, DrawStrategy drawer )
 : radius_( radius )
 , drawer_( std::move(drawer) )
 {
 /* Checking that the given radius is valid and that
 the given 'std::function' instance is not empty */
 }
 void draw( /*some arguments*/ ) const override
 {
 drawer_( *this, /*some arguments*/ );
 }
 double radius() const { return radius_; }
private:
 double radius_;
 DrawStrategy drawer_;
};
//---- <Square.h> ----------------
#include <Shape.h>
#include <functional>
#include <utility>
class Square : public Shape
{
public:
 using DrawStrategy = std::function<void(Square const&, /*...*/)>;
 explicit Square( double side, DrawStrategy drawer )
 : side_( side )
 , drawer_( std::move(drawer) )
 {
 /* Checking that the given side length is valid and that
 the given 'std::function' instance is not empty */
 }
 void draw( /*some arguments*/ ) const override
 {
 drawer_( *this, /*some arguments*/ );
 }
 double side() const { return side_; }
private:
 double side_;
 DrawStrategy drawer_;
};

​ ​​​  首先,在 Circle类中,我们为期望的 std::function类型添加了一个类型别名(DrawStrategy)。这个 std::function类型表示任何可调用对象,它能够接收一个 Circle对象,可能还有几个与绘图相关的参数,并且不返回任何内容。当然,我们也在 Square类中添加了相应的类型别名。在 CircleSquare的构造函数中,我们现在接收一个 std::function类型的实例,以替代指向策略基类(DrawCircleStrategyDrawSquareStrategy)的指针。这个实例会立即被移动到这个同样属于 DrawStrategy类型的数据成员 drawer_中。

​ ​​​  "嘿,为什么你要按值传递这个 std::function实例?这难道不是非常低效吗?我们不是更应该优先使用常量引用传递吗?" 简而言之:不,按值传递并非低效,而是对其他方案的一个优雅折中。不过,我承认这可能令人意外。既然这绝对是一个值得注意的实现细节,那就让我们仔细看看。

​ ​​​  如果我们使用常量引用,会遇到的缺点是右值会被不必要地复制。如果我们被传递了一个右值,这个右值会绑定到(左值)常量引用上。然而,当将这个常量引用传递给数据成员时,它会被复制。这并非我们的本意:我们自然希望它被移动。简单的原因是我们不能从常量对象移动(即使使用 std::move)。所以,为了高效处理右值,我们必须为 CircleSquare的构造函数提供重载版本,这些重载将通过右值引用(DrawStrategy&&)来接收 DrawStrategy。出于性能考虑,我们将为 CircleSquare分别提供两个构造函数。

​ ​​​  提供两个构造函数(一个用于左值,一个用于右值)的方法确实有效且高效,但我未必会称之为优雅。另外,我们或许应该让我们的同事省去处理此事的麻烦。出于这个原因,我们利用了 std::function的实现。std::function同时提供了复制构造函数和移动构造函数,因此我们知道它可以被高效地移动。当我们按值传递 std::function时,复制构造函数或移动构造函数将被调用。如果传递给我们的是一个左值,则调用复制构造函数,复制该左值。然后,我们将那个副本移动到数据成员中。总共,我们会执行一次复制和一次移动来初始化数据成员 drawer_。如果传递给我们的是一个右值,则调用移动构造函数,移动该右值。接着,生成的参数 strategy被移动到数据成员 drawer_中。总共,我们会执行两次移动操作来初始化数据成员 drawer_。因此,这种形式代表了一种很好的折中:它很优雅,并且在效率上几乎没有差异。

​ ​​​  策略设计模式的std::function实现带来了诸多益处。首先,你的代码变得更简洁、更易读,因为你不必处理指针及相关的生命周期管理(例如使用std::unique_ptr),也避免了引用语义通常会带来的问题(参见“指南22:优先使用值语义而非引用语义”)。其次,你促进了松耦合。实际上是非常松散的耦合。在这种情况下,std::function就像一个编译防火墙,它保护你免受不同策略实现细节的影响,同时为开发者如何实现不同的策略解决方案提供了极大的灵活性。

4.命令模式

4.1命令模式的特点

​ ​​​  命令设计模式专注于(最常见的)一次执行且(通常)立即执行的工作包的抽象与隔离。为此,它识别出不同类型的工作包作为变化点,并引入相应的抽象,以方便实现新型的工作包。

​ ​​​  在这种基于面向对象的形式中,命令模式以命令基类的形式引入了抽象。这使得任何人都能够实现一种新的具体命令。这个具体命令可以做任何事情,甚至可以对某种接收者执行操作。命令的效果通过这种抽象基类由特定类型的调用者触发。

​​​  作为命令设计模式的一个具体示例,我们来看下面的计算器实现。第一个代码片段展示了 CalculatorCommand基类的实现,它表示对给定整数进行数学运算的抽象:

class CalculatorCommand {
public:
    virtual ~CalculatorCommand() = default;
    virtual int execute(int value) const = 0; // 执行操作
    virtual int undo(int value) const = 0;    // 撤销操作
};

​​​  CalculatorCommand类期望派生类同时实现纯虚函数 execute()和执行撤销操作的纯虚函数 undo()。对 undo()的期望是,它能实现撤销 execute()函数效果所需的必要操作。

​​​  AddSubtract类都代表了计算器可能的命令,因此实现了 CalculatorCommand基类:

class Add : public CalculatorCommand {
    int value_to_add_;
public:
    explicit Add(int value) : value_to_add_(value) {}
    
    int execute(int value) const override {
        return value + value_to_add_; // 执行加法
    }
    
    int undo(int value) const override {
        return value - value_to_add_; // 通过减法撤销
    }
};

class Subtract : public CalculatorCommand {
    int value_to_subtract_;
public:
    explicit Subtract(int value) : value_to_subtract_(value) {}
    
    int execute(int value) const override {
        return value - value_to_subtract_; // 执行减法
    }
    
    int undo(int value) const override {
        return value + value_to_subtract_; // 通过加法撤销
    }
};

​ ​​​  Add类使用加法运算实现 execute()函数,并使用减法运算实现 undo()函数。Subtract类则实现相反的操作。

//---- <Calculator.h> ----------------
#include <CalculatorCommand.h>
#include <stack>
class Calculator
{
public:
 void compute( std::unique_ptr<CalculatorCommand> command );
 void undoLast();
 int result() const;
 void clear();
private:
 using CommandStack = std::stack<std::unique_ptr<CalculatorCommand>>;
 int current_{};
 CommandStack stack_;
};
//---- <Calculator.cpp> ----------------
#include <Calculator.h>
void Calculator::compute( std::unique_ptr<CalculatorCommand> command )
{
 current_ = command->execute( current_ );
 stack_.push( std::move(command) );
}
void Calculator::undoLast()
{
 if( stack_.empty() ) return;
 auto command = std::move(stack_.top());
 stack_.pop();
 current_ = command->undo(current_);
}
int Calculator::result() const
{
 return current_;
}
void Calculator::clear()
{
 current_ = 0;
 CommandStack{}.swap( stack_ ); // Clearing the stack
}

​ ​​​  借助 CalculatorCommand继承体系,Calculator类本身可以保持相当简单。我们为计算活动所需的函数仅有 compute()undoLast()compute()函数接收一个 CalculatorCommand实例,立即执行它以更新当前值,并将其存储在栈中。undoLast()函数则从栈中弹出最后执行的命令并调用其 undo()方法来撤销该操作。

​ ​​​  命令设计模式的优点与策略设计模式的优点相似:命令通过引入某种形式的抽象(例如基类或概念)帮助你与具体任务的实现细节解耦。这种抽象允许你轻松添加新任务。因此,命令满足了单一职责原则和开闭原则。

​ ​​​  然而,命令设计模式也有其缺点。但与策略设计模式相比,其缺点列表相当短。唯一的真正缺点是,如果你通过基类(经典的GoF风格)实现命令,由于额外的间接性会导致运行时性能开销增加。同样,这需要由你来决定增加的灵活性是否超过运行时性能的损失。

posted @ 2026-01-04 16:27  王小于的啦  阅读(3)  评论(0)    收藏  举报