跳转到内容

设计技术与框架

本书的一个核心主题,就是采用可复用的设计技术与设计模式。作为程序员,你会反复遇到相似的问题。只要手里拥有一套丰富的思路与工具箱,就能在面对某个问题时,用合适的方法直接解决,从而省下大量时间。

本章讲的是 design technique,而下一章讲的是 design pattern。两者都代表解决特定问题的标准化方法;不过,design technique 往往更偏向 C++ 语言本身,而 design pattern 的语言依赖性则更弱。很多时候,design technique 的目标,是绕开语言中某个讨厌的特性或缺陷;而另一些时候,它则是一段可以在许多不同程序中反复使用、专门解决某类常见 C++ 问题的代码。

design technique 本质上就是一些 C++ 惯用法。它们未必是语言内建机制的一部分,但却被频繁使用。本章前半部分,会先覆盖那些在 C++ 中非常常见、但你也许总记不牢语法的语言特性。这里的内容更偏复习性质,但当你只记得“概念是什么”,却忘了具体“语法该怎么写”时,它会成为很好用的速查资料。覆盖内容包括:

  • 从零开始写一个类
  • 通过继承扩展已有类
  • 编写 Lambda 表达式
  • 实现复制并交换惯用法
  • 抛出和捕获异常
  • 定义类模板
  • 约束类模板和函数模板参数
  • 向文件写数据
  • 从文件读数据

本章后半部分,则关注建立在 C++ 语言特性之上的更高层 technique。它们会为日常编程任务提供更好的解法。话题包括:

  • Resource Acquisition Is Initialization(RAII)
  • double dispatch technique
  • mixin class

最后,本章会以对 framework 的介绍收尾。framework 作为一种 coding technique,能极大降低大型 application 的开发难度。

第 1 章“C++ 与标准库速成”中说过,C++ 标准规范本身就有 2000 多页。标准里定义了海量关键字和非常庞杂的语言特性,因此想把一切都背下来根本不现实。哪怕是 C++ 专家,也同样会偶尔去查资料。正因如此,本节会给出一组几乎存在于所有 C++ 程序中的 coding technique 示例。当你还记得概念、却把具体语法忘了时,可以回到下面这些小节快速刷新记忆。

忘了如何起步?没关系——下面是一个定义在 module interface file 中的 Simple class:

export module simple;
// A simple class that illustrates class definition syntax.
export class Simple
{
public:
Simple(); // Constructor
virtual ~Simple() = default; // Defaulted virtual destructor
// Disallow copy construction and copy assignment.
Simple(const Simple& src) = delete;
Simple& operator=(const Simple& rhs) = delete;
// Explicitly default move constructor and move assignment operator.
Simple(Simple&& src) = default;
Simple& operator=(Simple&& rhs) = default;
virtual void publicMemberFunction(); // Public member function
int m_publicInteger; // Public data member
protected:
virtual void protectedMemberFunction();// Protected member function
int m_protectedInteger { 41 }; // Protected data member
private:
virtual void privateMemberFunction(); // Private member function
int m_privateInteger { 42 }; // Private data member
static constexpr int Constant { 2 }; // Private constant
static inline int ms_staticInt { 3 }; // Private static data member
};

正如第 10 章“深入继承技巧”所解释的,如果你的 class 目的是作为其他类的 base class,那么至少其 destructor 必须是 virtual。当然,destructor 也可以不是 virtual;但若如此,我建议把整个类标记为 final,从而禁止任何类再继承它。如果你只需要让 destructor 成为 virtual,但又不需要在里面写任何代码,那么像 Simple 这样,直接显式 default 它,就是很好的做法。

这个例子还展示了:你可以显式删除或显式 default special member function。这里 copy constructor 和 copy assignment operator 被删掉,以防止无意中的复制;而 move constructor 与 move assignment operator 则被显式 default。

接下来是对应的 module implementation file:

module simple;
Simple::Simple() : m_publicInteger { 40 }
{
// Implementation of constructor
}
void Simple::publicMemberFunction() { /* Implementation */ }
void Simple::protectedMemberFunction() { /* Implementation */ }
void Simple::privateMemberFunction() { /* Implementation */ }

关于如何编写自己的 class,请参考第 8 章“熟练掌握类与对象”和第 9 章“精通类与对象”。

想从一个已有 class 派生,只需要声明一个新 class,把它写成另一个 class 的扩展即可。下面是一个名为 DerivedSimple 的类,它继承自 Simple,并定义在 derived_simple 模块中:

export module derived_simple;
export import simple;
// A class derived from the Simple class.
export class DerivedSimple : public Simple
{
public:
DerivedSimple() : Simple{} // Constructor
{ /* Implementation of constructor */ }
void publicMemberFunction() override // Overridden member function
{
// Implementation of overridden member function
Simple::publicMemberFunction(); // Access the base class implementation
}
virtual void anotherMemberFunction() // New member function
{ /* Implementation of new member function */ }
};

关于 inheritance technique 的完整细节,见第 10 章

lambda expression 允许你写出小而匿名的 inline function。它们与 C++ Standard Library algorithm 结合使用时尤其强大。下面这个例子就展示了这一点:它使用 count_if() algorithm 和一个 lambda expression,统计 vector 中偶数值的个数。除此之外,这个 lambda 还通过引用捕获了外层作用域中的 callCount 变量,以便记录自己一共被调用了多少次。

vector values { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
int callCount { 0 };
auto evenCount { ranges::count_if(values,
[&callCount](int value) {
++callCount;
return value % 2 == 0;
})
};
println("There are {} even elements in the vector.", evenCount);
println("Lambda was called {} times.", callCount);

关于 lambda expression 的详细内容,可参考第 19 章“函数指针、函数对象与 Lambda 表达式”。

copy-and-swap idiom 已在第 9 章中详细讨论过。它是一种用于实现“可能抛异常”的对象操作,同时仍提供 strong exception-safety guarantee(也就是 all-or-nothing 语义)的 idiom。做法很简单:先创建当前对象的一个副本,然后在这个副本上完成所有修改(这些修改过程可能复杂,也可能抛异常);只有当整个过程没有抛出异常时,才通过一个 non-throwing swap() 把这个副本与原对象交换。assignment operator 就是一个非常典型的使用 copy-and-swap idiom 的场景。你的 assignment operator 会先构造 source object 的局部副本,再通过只依赖 non-throwing swap() 的方式,把副本内容提交给当前对象。

下面是一个简洁示例:它把 copy-and-swap idiom 用在 copy assignment operator 上。这个类定义了 copy constructor、copy assignment operator,以及一个标记为 noexceptswap() 成员函数。

export module copy_and_swap;
export class CopyAndSwap final
{
public:
CopyAndSwap() = default;
~CopyAndSwap(); // Destructor
CopyAndSwap(const CopyAndSwap& src); // Copy constructor
CopyAndSwap& operator=(const CopyAndSwap& rhs); // Copy assignment operator
void swap(CopyAndSwap& other) noexcept; // noexcept swap() member function
private:
// Private data members...
};
// Standalone noexcept swap() function
export void swap(CopyAndSwap& first, CopyAndSwap& second) noexcept;

对应实现如下:

CopyAndSwap::~CopyAndSwap() { /* Implementation of destructor. */ }
CopyAndSwap::CopyAndSwap(const CopyAndSwap& src)
{
// This copy constructor can first delegate to a non-copy constructor
// if any resource allocations have to be done. See the Spreadsheet
// implementation in Chapter 9 for an example.
// Make a copy of all data members...
}
void swap(CopyAndSwap& first, CopyAndSwap& second) noexcept
{
first.swap(second);
}
void CopyAndSwap::swap(CopyAndSwap& other) noexcept
{
using std::swap;
// Swap each data member, for example:
// swap(m_data, other.m_data);
}
CopyAndSwap& CopyAndSwap::operator=(const CopyAndSwap& rhs)
{
// Copy-and-swap idiom.
auto copy { rhs }; // Do all the work in a temporary instance.
swap(copy); // Commit the work with only non-throwing operations.
return *this;
}

更详细讨论,见第 9 章

如果你所在团队不使用 exception(真是可耻!),或者你已经习惯了 Java 风格的 exception,那么 C++ 的语法也许偶尔会卡壳。下面给出一个速查版本,使用的是内建 exception class std::runtime_error。在多数较大的程序中,你通常还是会定义自己的 exception class。

import std;
using namespace std;
void throwIf(bool should)
{
if (should) {
throw runtime_error { "Here's my exception" };
}
}
int main()
{
try {
throwIf(false); // Doesn't throw.
throwIf(true); // Throws.
} catch (const runtime_error& e) {
println(cerr, "Caught exception: {}", e.what());
return 1;
}
}

关于 exception 的更多内容,见第 14 章“错误处理”。

template 语法很容易让人犯迷糊。最容易忘记的一点是:凡是使用 class template 的代码,必须同时“看得到” class template 本身的定义,以及其 member function 的实现。function template 也一样。达成这一点的一种常见做法,就是把 class member function 的实现直接写在包含 class template 定义的 interface file 中。下面这个例子就演示了这种方式:它实现了一个 class template,用来包装一个对象引用,并提供一个 getter。先看 module interface file:

export module simple_wrapper;
export template <typename T>
class SimpleWrapper
{
public:
explicit SimpleWrapper(T& object) : m_object { object } { }
T& get() const { return m_object; }
private:
T& m_object;
};

测试代码如下:

import simple_wrapper;
import std;
using namespace std;
int main()
{
// Try wrapping an integer.
int i { 7 };
SimpleWrapper intWrapper { i }; // Using CTAD.
// Or without class template argument deduction (CTAD).
SimpleWrapper<int> intWrapper2 { i };
i = 2;
println("wrapped value is {}", intWrapper.get());
println("wrapped value is {}", intWrapper2.get());
// Try wrapping a string.
string str { "test" };
SimpleWrapper stringWrapper { str };
str += "!";
println("wrapped value is {}", stringWrapper.get());
}

关于 template 的细节,请参考第 12 章“使用模板编写泛型代码”和第 26 章“高级模板”。

有了 concept,你就可以对 class template 和 function template 的模板参数施加约束。例如,下面这段代码给上一节的 SimpleWrapper 增加了约束:其模板类型参数 T 必须是 floating-point type 或 integral type 之一。如果指定了不满足这些约束的类型,编译就会直接失败。

import std;
export template <typename T> requires (std::floating_point<T> || std::integral<T>)
class SimpleWrapper
{
public:
explicit SimpleWrapper(T& object) : m_object { object } { }
T& get() const { return m_object; }
private:
T& m_object;
};

关于 concept 的完整讨论,见第 12 章

下面这个程序会先向文件写入一条消息,然后重新打开文件,再追加另一条消息。更多细节可参考第 13 章“揭开 C++ I/O 的面纱”。

import std;
using namespace std;
int main()
{
ofstream outputFile { "FileWrite.out" };
if (outputFile.fail()) {
println(cerr, "Unable to open file for writing.");
return 1;
}
outputFile << "Hello!" << endl;
outputFile.close();
ofstream appendFile { "FileWrite.out", ios_base::app };
if (appendFile.fail()) {
println(cerr, "Unable to open file for appending.");
return 2;
}
appendFile << "World!" << endl;
}

文件输入的细节见第 13 章。下面先给一个速查版示例,展示文件读取的基础做法。它会读取上一节程序写出的文件,并按空白分隔 token,一个一个输出:

import std;
using namespace std;
int main()
{
ifstream inputFile { "FileWrite.out" };
if (inputFile.fail()) {
println(cerr, "Unable to open file for reading.");
return 1;
}
string nextToken;
while (inputFile >> nextToken) {
println("Token: {}", nextToken);
}
}

如果你想一次性读入整个文本文件,也可以直接调用一次 getline()

string fileContents;
getline(inputFile, fileContents, '\0');
println("{}", fileContents);

不过,这种方式对 binary file 并不适用,因为其中可能包含 \0 字符。

另一种做法,是使用 istreambuf_iterator(见第 17 章“理解迭代器与 Ranges 库”):

string fileContents {
istreambuf_iterator<char> { inputFile },
istreambuf_iterator<char> { }
};
println("{}", fileContents);

就在你读这段话的时候,世界各地有成千上万的 C++ 程序员正在反复解决那些早就已经被解决过的问题。圣何塞某个格子间里,有人正从零开始实现一个基于引用计数的 smart pointer;地中海某座小岛上的年轻程序员,正在苦苦设计一套其实会从 mixin class 中受益巨大的 class hierarchy。

作为专业的 C++ 程序员,你理应把更少的时间花在重复造轮子上,而把更多时间花在“用新方式去适配已有可复用概念”上。本节就会给出几个通用做法,它们可以直接被应用到你的程序里,或者按你的需要进一步定制。

Resource Acquisition Is Initialization(RAII)是一个简单却极其强大的概念。它用于获取某些资源的所有权,并在某个 RAII 实例离开作用域时,自动释放这些已获得的资源。无论初始化还是销毁,都会发生在一个确定的时刻。更直白地说:一个新的 RAII 实例的 constructor 会获取某个资源的所有权,并用该资源来初始化自己——这正是 “resource acquisition is initialization” 这个名字的来源。而它的 destructor 则会在 RAII 实例被销毁时,自动释放这份资源。

下面是一个 File RAII class 的示例。它安全地包装了一个 C 风格文件句柄(std::FILE),并在 RAII 实例离开作用域时自动关闭该文件。这个 RAII class 还提供了 get()release()reset() 成员函数,它们的行为和某些 Standard Library class(例如 std::unique_ptr)上的同名成员函数类似。RAII class 通常都会禁止 copy construction 和 copy assignment;因此,这个实现也把它们删掉了。

import std;
class File final
{
public:
explicit File(std::FILE* file) : m_file { file } { }
~File() { reset(); }
// Prevent copy construction and copy assignment.
File(const File& src) = delete;
File& operator=(const File& rhs) = delete;
// Allow move construction.
File(File&& src) noexcept : m_file { std::exchange(src.m_file, nullptr) }
{
}
// Allow move assignment.
File& operator=(File&& rhs) noexcept
{
if (this != &rhs) {
reset();
m_file = std::exchange(rhs.m_file, nullptr);
}
return *this;
}
// get(), release(), and reset()
std::FILE* get() const noexcept { return m_file; }
[[nodiscard]] std::FILE* release() noexcept
{
return std::exchange(m_file, nullptr);
}
void reset(std::FILE* file = nullptr) noexcept
{
if (m_file) { std::fclose(m_file); }
m_file = file;
}
private:
std::FILE* m_file { nullptr };
};

它可以这样使用:

File myFile { std::fopen("input.txt", "r") };

一旦 myFile 离开作用域,它的 destructor 就会被调用,而文件也会被自动关闭。

不过,使用 RAII class 时有一个很重要的陷阱必须注意。你可能会不小心写出一条语句,以为自己已经在某个作用域里正确创建了一个 RAII 实例,结果实际上创建出来的是一个临时对象,而这个临时对象会在那条语句执行结束后立刻销毁。例如,下面这条语句是正确使用 File RAII class 的方式:

File myFile { std::fopen("input.txt", "r") };

但你也可能会不小心忘记给这个 RAII 实例起名字,写成这样:

File { std::fopen("input.txt", "r") };

这条语句创建的是一个临时 File 实例,而它会在该语句结束时立刻析构。compiler 不会因为这件事自动报错或给 warning。为了避免这种问题,你应当把 RAII class 的 constructor 标记为 [[nodiscard]]。例如:

[[nodiscard]] explicit File(std::FILE* file) : m_file{ file } { }

这样一来,如果你创建了 File 实例却没有给它起名字,compiler 就会给出类似下面这样的 warning:

warning C4834: discarding return value of function with 'nodiscard' attribute

当然,对 File 这种 RAII class 来说,你大概本来也不会忘记给它起名字,因为你多半还想继续对打开的文件做点什么。不过,有时你确实会在某个作用域里创建一个 RAII instance,却并不需要在后面显式操作这个对象本身,例如 mutex lock。下面就来看一个来自 Standard Library 的 RAII class 示例:std::unique_lock(见第 27 章“使用 C++ 进行多线程编程”)。下面这段代码展示了正确使用 unique_lock 的方式。这里我先故意不用统一初始化语法,后面马上会解释原因。

class Foo
{
public:
void setData()
{
unique_lock<mutex> lock(m_mutex);
// …
}
private:
mutex m_mutex;
};

setData() 成员函数使用 unique_lock 这个 RAII class,在局部作用域中构造了一个名为 lock 的对象。它会锁住数据成员 m_mutex,并在函数结束时自动解锁。

然而,由于定义完之后你并不会再直接使用 lock 变量,因此这里很容易犯下面这个错误:

unique_lock<mutex>(m_mutex);

这段代码虽然能编译,但它做的事和你原本想做的完全不是一回事!它实际上声明了一个名为 m_mutex 的局部变量(从而遮蔽了类成员 m_mutex),并且用 unique_lock 的默认构造函数来初始化它。结果就是:类成员 m_mutex 根本没有被锁住!如果 warning level 设得足够高,compiler 通常会给出如下 warning:

warning C4458: declaration of 'm_mutex' hides class member

如果你改用统一初始化语法,像下面这样写,compiler 虽然不会再给出“遮蔽类成员”的 warning,但这段代码仍然不是你想要的行为。它只是对 m_mutex 创建了一个临时 lock,而由于这个 lock 是临时对象,它会在语句结束时立刻被释放。

unique_lock<mutex> { m_mutex };

最近,一些 compiler 确实像我在前面 File 示例中建议的那样,把 unique_lock 的 constructor 也标记成了 [[nodiscard]]。Visual C++ 2022 就是一个例子。如果你使用的是这样的 compiler,那么前面这条语句就会触发一条 warning,指出 constructor 的返回值被丢弃了。

另外,你也可能在传入的名字上打错字,例如写成:

unique_lock<mutex>(m);

这里你既忘了给 lock 起名字,又把参数名误写成了 m。结果仍然只是声明了一个名为 m 的局部变量,并用 unique_lock 的默认构造函数初始化它。compiler 甚至可能连 warning 都不给,除非它愿意提醒你:m 是一个未被引用的局部变量。但在这种场景下,如果改用统一初始化语法:

unique_lock<mutex> { m };

那么 compiler 就会直接报错,指出 m 这个标识符未声明。

一定要给你的 RAII instance 起名字!另外,我也建议不要为 RAII class 提供默认构造函数,这样可以避开这里讨论的一部分问题。

Double dispatch 是一种在多态概念上再增加一个维度的 technique。正如第 5 章“使用类进行设计”中所说,多态让程序可以根据对象在运行期的真实类型来决定行为。例如,你可以有一个 Animal 类,其中定义了 move() 成员函数。所有 Animal 都会移动,但它们“如何移动”并不相同。因此,move() 会在每个 Animal 派生类中分别实现,以便程序在运行期无需知道具体动物类型,也能自动调用到正确的成员函数。第 10 章解释了如何使用 virtual member function 来实现这种运行期多态。

不过,有时你需要某个成员函数的行为同时取决于两个对象的运行期类型,而不仅仅是一个。例如,假设你要给 Animal 类加入一个成员函数,用来返回“这个动物是否会吃掉另一个动物”。这个判断取决于两件事:哪种动物在吃,以及哪种动物被吃。遗憾的是,C++ 并没有内建语言机制,能直接根据两个以上对象的运行期类型来选择行为。单靠 virtual member function 并不足以表达这种场景,因为它只能根据“接收消息的那个对象”的运行期类型来决定调用哪个成员函数。

有些 object-oriented language 提供了 multimethods 这样的能力,允许程序在运行期根据两个或更多对象的运行期类型来选择成员函数。在 C++ 中并没有直接支持 multimethods 的核心语言特性,但你可以使用 double dispatch technique,它提供了一种让函数对多个对象都表现为“virtual”的思路。

如果一个成员函数的行为取决于两个不同对象的运行期类型,那么最直接的实现方式,就是从其中一个对象的视角出发,使用一连串 if / else 去检查另一个对象的类型。例如,你可以在每个 Animal 派生类中实现一个名为 eats() 的成员函数,让它接收另一个动物作为参数。这个成员函数可以在 base class 中声明为 pure virtual,如下所示:

class Animal
{
public:
virtual bool eats(const Animal& prey) const = 0;
};

每个派生类都实现 eats(),并根据参数对象的类型返回正确结果。下面是几个派生类中的 eats() 实现。注意,TRex 完全不需要 if,因为——按照作者的说法——T-rex 这种 carnivorous dinosaur 什么都吃。

bool Bear::eats(const Animal& prey) const
{
if (typeid(prey) == typeid(Fish)) { return true; }
return false;
}
bool Fish::eats(const Animal& prey) const
{
if (typeid(prey) == typeid(Fish)) { return true; }
return false;
}
bool TRex::eats(const Animal& prey) const
{
return true;
}

这种 brute-force 做法当然能工作,对于只有少量类的场景,它也许还是最直接的方案。不过,你通常还是会希望避免这种做法,原因有几个:

  • OOP 纯粹主义者往往会对“显式询问对象类型”这种写法皱眉,因为它通常意味着 design 没有形成更自然的 object-oriented 结构。
  • 一旦类型数量增加,这种代码很快就会变得混乱而重复。
  • 这种写法不会强迫派生类去认真处理新加入的类型。例如,如果你新增一个 DonkeyBear 类仍然可以编译,但面对 Donkey 时它会错误地返回 false——尽管大家都知道熊会吃驴。熊之所以“不吃驴”,只是因为代码里没有显式写一条针对 Donkeyelse if

尝试 #2:结合重载的单分派多态

Section titled “尝试 #2:结合重载的单分派多态”

你可以尝试把 polymorphism 和 overloading 结合起来,绕开那一大串 if / else。既然每个类里都有一个 eats() 成员函数,为什么不为每个 Animal 派生类分别重载一个版本,而不是只保留一个接收 Animal reference 的版本?这样,base class 的定义可以变成:

class Animal
{
public:
virtual bool eats(const Bear&) const = 0;
virtual bool eats(const Fish&) const = 0;
virtual bool eats(const TRex&) const = 0;
};

由于这些成员函数在 base class 中都是 pure virtual,每个派生类都会被强制要求:为每一种 Animal 派生类型提供自己的行为定义。例如,Bear 类中会有如下成员函数:

class Bear : public Animal
{
public:
bool eats(const Bear&) const override { return false; }
bool eats(const Fish&) const override { return true; }
bool eats(const TRex&) const override { return false; }
};

这一招乍看很不错,但它其实只解决了一半问题。为了调用正确的 eats() 重载,compiler 必须在编译期就知道“被吃的那个动物”的静态类型。下面这样的调用之所以没有问题,是因为两个对象的编译期类型都已知:

Bear myBear;
Fish myFish;
println("Bear eats fish? {}", myBear.eats(myFish));

缺失的那一半在于:这个方案只在一个方向上是多态的。你可以通过 Animal reference 去访问 myBear,此时依然会调用正确的成员函数:

Animal& animalRef { myBear };
println("Bear eats fish? {}", animalRef.eats(myFish));

但反过来却不成立。如果你把 Animal reference 传给 eats(),就会得到编译错误,因为根本不存在一个接收 Animaleats()。compiler 无法在编译期判断到底该调哪个重载。下面这个例子就无法编译:

Animal& animalRef { myFish };
println("Bear eats fish? {}",
myBear.eats(animalRef)); // BUG! No member function Bear::eats(Animal&)

由于 compiler 必须在编译期就决定调用哪一个 eats() 重载,这个方案并不是真正意义上的多态。如果你遍历的是一个 Animal reference 数组,并希望把数组中的每个元素都传给 eats(),它就完全不适用了。

Double dispatch 才是一个真正多态的解法,用来处理这种“行为取决于两个类型”的问题。在 C++ 中,多态是通过派生类重写成员函数来实现的,程序会根据对象在运行期的真实类型调用正确版本。前面那个“单层多态”的尝试失败,是因为它试图用多态去决定“选哪个重载版本”,而不是去决定“在谁身上调用成员函数”。

先聚焦一个派生类,例如 Bear。它需要一个下面这样的成员函数:

bool eats(const Animal& prey) const override;

double dispatch 的关键就在于:最终结果通过“在参数对象上再做一次成员函数调用”来决定。设想 Animal 类中还存在一个名为 eatenBy() 的成员函数,它接收一个 Animal reference 参数。如果当前这个 Animal 会被传进来的那个动物吃掉,就返回 true。有了这样的成员函数之后,eats() 的实现就会变得非常简单:

bool Bear::eats(const Animal& prey) const
{
return prey.eatenBy(*this);
}

乍看之下,这好像只是在前一个“单层多态”方案上多包了一层成员函数调用。毕竟,每个派生类还是得为每个 Animal 派生类型都实现对应的 eatenBy()。但这里有一个关键不同点:多态发生了两次!第一次,是你在某个 Animal 上调用 eats() 时,运行期多态会决定究竟调用 Bear::eats()Fish::eats(),还是其他版本。第二次,是你调用 eatenBy() 时,多态又会根据 prey 的运行期类型,决定究竟调用哪个类中的 eatenBy()。需要注意的是,*this 的运行期类型总与其编译期类型一致,因此 compiler 依然能够在编译期为参数选择正确的 eatenBy() 重载(这里就是 Bear 版本)。

下面给出使用 double dispatch 的 Animal hierarchy 定义。由于 base class 要引用派生类,因此必须先 forward declare 它们。还要注意:每个 Animal 派生类中的 eats() 实现都长得一模一样,但它们却不能被提到 base class 中统一实现。原因在于:如果你那样做,compiler 在 base class 中看到的 *this 类型只会是 Animal,而不是具体的派生类;于是它就无法解析出正确的 eatenBy() 重载。成员函数重载解析依据的是对象的编译期类型,而不是运行期类型。

// Forward declarations.
class Fish;
class Bear;
class TRex;
class Animal
{
public:
virtual bool eats(const Animal& prey) const = 0;
virtual bool eatenBy(const Bear&) const = 0;
virtual bool eatenBy(const Fish&) const = 0;
virtual bool eatenBy(const TRex&) const = 0;
};
class Bear : public Animal
{
public:
bool eats(const Animal& prey) const override{ return prey.eatenBy(*this); }
bool eatenBy(const Bear&) const override { return false; }
bool eatenBy(const Fish&) const override { return false; }
bool eatenBy(const TRex&) const override { return true; }
};
class Fish : public Animal
{
public:
bool eats(const Animal& prey) const override{ return prey.eatenBy(*this); }
bool eatenBy(const Bear&) const override { return true; }
bool eatenBy(const Fish&) const override { return true; }
bool eatenBy(const TRex&) const override { return true; }
};
class TRex : public Animal
{
public:
bool eats(const Animal& prey) const override{ return prey.eatenBy(*this); }
bool eatenBy(const Bear&) const override { return false; }
bool eatenBy(const Fish&) const override { return false; }
bool eatenBy(const TRex&) const override { return true; }
};

double dispatch 这个概念确实需要一点时间才能顺手。我建议你亲自玩一玩这段代码,熟悉其概念与实现方式。

第 5 章第 6 章“面向复用设计”都已经介绍过 mixin class。它回答的问题是:“这个类还能做什么?”而答案通常都以 “-able” 结尾,例如 ClickableDrawablePrintableLovable 等等。mixin class 提供了一种办法:在不承诺完整 is-a 关系的前提下,为某个类增加功能。在 C++ 中,实现 mixin class 有几种常见 technique。本节会依次讨论:

  • Using multiple inheritance
  • Using class templates
  • Using CRTP
  • Using CRTP and “deducing this

这一节会展示:如何通过 multiple inheritance 来设计、实现并使用一个 mixin class。

mixin class 包含的是可以被其他类复用的真实代码。一个 mixin class 通常只实现一块定义清晰的功能。例如,你也许会有一个叫 Playable 的 mixin class,把它 mix 到某些 media object 里。这个 mixin class 中可以包含大部分与声卡驱动通信的代码。只要把这个类混入,media object 就能“免费”获得相应功能。

在设计 mixin class 时,你必须先想清楚:你到底在增加什么行为,以及这份行为到底应该属于 object hierarchy 本身,还是更适合作为一个独立类存在。沿用刚才的例子,如果所有 media class 都天生可播放,那么 base class 本身就应该直接继承 Playable,而不是把 Playable 分散地 mix 到每个派生类中。反过来,如果只有某些零散分布在 hierarchy 不同位置的 media class 是 playable 的,那么 mixin class 就很有意义。

一种特别适合使用 mixin class 的情况,是你的类在一个维度上已经形成 hierarchy,但在另一个维度上又共享某些共同特征。例如,设想一个在网格上进行的战争模拟游戏。每个 grid location 都可以包含一个 Item,而这个 Item 可能有攻击、防御以及其他各种能力。有些 item(例如 Castle)是静止的;另一些(例如 KnightFloatingCastle)则可以在 grid 上移动。如果你一开始按攻击和防御能力来组织 hierarchy,那么最后得到的结构很可能像图 32.1 一样。

而这个 hierarchy 完全忽略了某些类“还能移动”这件事。如果反过来按移动能力来组织 hierarchy,得到的结构就会更像图 32.2

当然,图 32.2 这样一来,就把图 32.1 原本在攻击/防御维度上的组织结构彻底打散了。面对这种情况,一个称职的 object-oriented programmer 该怎么办?

如果你最终决定沿用第一种以 attacker 和 defender 为主轴的 hierarchy,那么就需要想办法把 movement 功能补进来。一种选择是:虽然只有部分派生类支持移动,但你仍然可以move() 成员函数加进 Item base class。默认实现什么都不做,而某些派生类则通过 override move() 来真正改变其在 grid 上的位置。

一张层级图,顶部是 Item。其下分成两个分支:Defender 和 Attacker。Defender 分支下面有 Barrier 与 Castle,其中 Castle 还继续分出 FloatingCastle。

[^FIGURE 32.1]

一张流程图,顶部是 Item。其下分为 NonMover 与 Mover 两类。NonMover 下有 Turret、Castle 与 Barrier;Mover 下则包括 Knight、FloatingCastle 和 SuperKnight。

[^FIGURE 32.2]

另一种办法,是单独写一个 Movable mixin class。这样,图 32.1 中那种优雅 hierarchy 仍然可以保留,但其中某些类除了继承自己的 parent 之外,还会额外继承 Movable。这种设计如图 32.3 所示。

一张流程图,顶部是 Item。其下分成三个方向:Defender、Movable 和 Attacker。Defender 分支中包含 Barrier、Castle 和 FloatingCastle;Attacker 分支中包含 Knight、SuperKnight 和 Turret。

[^FIGURE 32.3]

当然,还有另一种思路:把 hierarchy 整体压平,完全不使用运行时多态,而是在需要对某个类型子集做多态处理时,改用 static polymorphism 和/或 type erasure。本书不再继续展开这些方案。

编写一个 mixin class,与写一个普通 class 本质上没有区别。事实上,它通常还会更简单。沿用前面战争模拟的例子,Movable mixin class 可以写成下面这样:

class Movable
{
public:
virtual void move() { /* Implementation to move an item… */ }
};

这个 Movable mixin class 实现了“在 grid 上移动一个 item”的真实逻辑。同时,它本身也提供了一个类型,用于表示“所有可移动的 Item”。这意味着:即便你不知道、也不关心某个对象具体属于 Item 的哪个派生类,你仍然可以创建一个只保存全部可移动 item 的数组。

使用 mixin class 的语法,与使用 multiple inheritance 完全一致:除了从主 hierarchy 中的 parent 继承之外,你还额外继承这个 mixin class。示例如下:

class FloatingCastle : public Castle, public Movable { /* … */ };

这样一来,Movable mixin class 所提供的功能就被 mixed in 到了 FloatingCastle 中。于是,这个类既位于 hierarchy 中最合适的位置,又仍然能与 hierarchy 中其他地方的对象共享同一份可移动能力。

在 C++ 中实现 mixin class 的第二种办法,是把 mixin class 自身写成一个 class template:它接收一个模板类型参数,然后自己从这个类型派生。

第 6 章里曾解释过如何实现一个 SelfDrivable mixin class template,用它来创建 self-drivable car 和 truck。既然你现在已经熟悉 class template(见第 12 章第 26 章),那么第 6 章里那个 SelfDrivable mixin 例子应该也不会再令你惊讶。该 mixin class 的定义如下:

template <typename T>
class SelfDrivable : public T { /* … */ };

如果你已经有 CarTruck 两个类,那么定义 self-drivable car 和 truck 就变得很容易:

SelfDrivable<Car> selfDrivingCar;
SelfDrivable<Truck> selfDrivingTruck;

这样,就可以在完全不修改 CarTruck 原有定义的前提下,为它们增加新功能。

下面给出一个完整示例:

template <typename T>
class SelfDrivable : public T
{
public:
void drive() { this->setSpeed(1.2); }
};
class Car
{
public:
void setSpeed(double speed) { println("Car speed set to {}.", speed); }
};
class Truck
{
public:
void setSpeed(double speed) { println("Truck speed set to {}.", speed); }
};
int main()
{
SelfDrivable<Car> car;
SelfDrivable<Truck> truck;
car.drive();
truck.drive();
}

在 C++ 中实现 mixin class 的另一种 technique,是使用 curiously recurring template pattern(CRTP)。

这时,mixin class 本身仍然是一个 class template,不过它这次接收的模板类型参数表示的是“某个派生类的类型”,而它自己并不继承自任何其他类。在实现中,则需要通过 static_cast()this 转换成派生类类型:

template <typename Derived>
class SelfDrivable
{
public:
void drive()
{
auto& self { static_cast<Derived&>(*this) };
self.setSpeed(1.2);
}
};

接下来,像 CarTruck 这样的具体类,只需要继承自 SelfDrivable,并把自己的类型作为模板类型参数传进去即可:

class Car : public SelfDrivable<Car>
{
public:
void setSpeed(double speed) { println("Car speed set to {}.", speed); }
};
class Truck : public SelfDrivable<Truck>
{
public:
void setSpeed(double speed) { println("Truck speed set to {}.", speed); }
};

它们的使用方式如下:

Car car;
Truck truck;
car.drive();
truck.drive();

在前面的 CRTP 示例中,SelfDrivable::drive() 的实现需要一个 static_cast(),以便拿到正确的派生类类型。借助 C++23 的 “deducing this” 特性,SelfDrivable 可以写得更优雅:这次使用的是 explicit object parameter。于是,这个版本里的 SelfDrivable mixin class 不再是 class template,但 SelfDrivable::drive() 本身则变成了 member function template。那个带有 this 标记的参数,叫作 explicit object parameter(见第 8 章“熟练掌握类与对象”)。

class SelfDrivable
{
public:
void drive(this auto& self) { self.setSpeed(1.2); }
};

此时,CarTruck 就只需要简单继承自 SelfDrivable

class Car : public SelfDrivable { /* Same as before */ };
class Truck : public SelfDrivable { /* Same as before */ };

当 graphical operating system 在 1980 年代刚出现时,procedural programming 仍是主流。当时编写 GUI application,通常意味着你要手动操作复杂的数据结构,再把它们交给 OS 提供的函数。例如,为了在窗口里画一个矩形,你可能得先填好一个 Window struct,再把它传给某个 drawRect() 函数。

随着 object-oriented programming(OOP)越来越流行,程序员开始寻找一种方式,把 OOP 范式应用到 GUI 开发中。结果就诞生了所谓 object-oriented framework。从一般意义上说,framework 就是一组被协同使用的类,它们为某种底层功能提供一套 object-oriented interface。提到 framework 时,程序员通常会想到那些用于通用 application 开发的大型 class library。不过实际上,framework 可以代表任意规模的功能集合。如果你为应用编写了一整组提供数据库能力的类,那么它们其实也完全可以被视为一个 framework。

framework 最典型的特征,是它会自带一整套 technique 和 pattern。framework 往往需要一些学习成本,因为它们通常都有自己独特的 mental model。在你真正开始使用某个大型 application framework(例如 Microsoft Foundation Classes,MFC)之前,你必须先理解它“看待世界的方式”。

framework 彼此在抽象理念和具体实现上差异都很大。许多 framework 建立在 legacy procedural API 之上,而这会影响它们设计的很多方面。另一些 framework 则是从零开始,完全围绕 object-oriented design 构建起来的。还有些 framework 在理念上可能刻意排斥 C++ 某些语言特性。例如,一个 framework 就可能明确拒绝 multiple inheritance。

因此,当你开始接触一个新的 framework 时,第一项任务就是弄清它到底靠什么运转。它遵循哪些 design principle?它的开发者试图传递什么 mental model?它大量依赖语言的哪些方面?这些都是至关重要的问题,即便它们听起来像是“用着用着自然会明白”的东西。如果你没有真正理解 framework 的 design、model 或语言层特征,那么你很快就会陷入“越界使用 framework”的局面。

而一旦你真正理解了 framework 的设计,也就更容易扩展它。例如,如果 framework 本身缺了某个能力——比如 printing 支持——你就可以按照 framework 既有的模型,写出自己的 printing class。这样既能保持整个 application 的设计一致性,也能产出以后可被其他 application 复用的代码。

另外,framework 还可能要求你使用某些特定数据类型。例如,MFC 使用的是 CString 来表示字符串,而不是 Standard Library 的 std::string。但这并不意味着你必须把整个代码库都切换到 framework 提供的类型上。更合理的方式,是只在 framework 代码与其余业务代码的边界上做类型转换。

正如前面提到的,不同 framework 在 object-oriented design 上会采取不同思路。其中一种非常常见的范式,就是 model-view-controller(MVC)。这一范式试图刻画这样一个事实:许多 application 都在处理一组数据、对这组数据的一种或多种 view,以及对数据的操作。

在 MVC 中,这组数据叫作 model。例如,在一个赛车模拟器里,model 会保存诸如当前车速、车辆受损程度等统计信息。在实践中,model 往往体现为一个带有大量 getter 和 setter 的类。赛车的 model 类定义可能会长成这样:

class RaceCar
{
public:
RaceCar();
virtual ~RaceCar() = default;
virtual double getSpeed() const;
virtual void setSpeed(double speed);
virtual double getDamageLevel() const;
virtual void setDamageLevel(double damage);
private:
double m_speed { 0.0 };
double m_damageLevel { 0.0 };
};

View 则是 model 的某种具体可视化形式。例如,RaceCar 完全可以有两个 view:第一个是汽车本身的图形视图;第二个则可能是一张展示受损程度随时间变化的图表。关键点在于:这两个 view 面向的是同一份数据——它们只是“看同一件事的两种不同方式”。这正是 MVC 范式的一大优势:通过把数据本身与数据展示分离开来,你既能让代码结构更清晰,也更容易为同一份数据增加新的 view。

MVC 的最后一个组成部分,是 controller。controller 负责在某些事件发生时修改 model。例如,当赛车模拟器中的驾驶员撞上混凝土护栏时,controller 会指示 model:提高受损程度,并降低车速。controller 也可以直接操作 view。例如,当用户拖动 user interface 中的 scrollbar 时,controller 会通知 view 去滚动其内容。

MVC 的这三个组成部分通过一个 feedback loop 相互作用:用户动作由 controller 处理,controller 再去调整 model 和/或 view;而当 model 变化后,它又会通知 view 更新自己。图 32.4 展示了这种交互关系。

一张流程图展示四个主要组成部分:user、view、controller 和 model。user 与 view、controller 交互;view 向 user 显示数据,并更新 model;controller 则同时操作 view 与 model。

[^FIGURE 32.4]

model-view-controller 范式已经在许多流行 framework 中得到了广泛支持。即便是 web application 这类看起来不那么“传统”的应用,也正在越来越多地走向 MVC,因为它强制把数据、数据操作以及数据展示清晰分开。

MVC pattern 之后又继续演化出了多个变体,例如 model-view-presenter(MVP)、model-view-adapter(MVA)、model-view-viewmodel(MVVM)等等。

本章介绍了一些专业 C++ 程序员会在项目中反复使用的常见 technique。随着你继续成长为软件开发者,你也一定会逐渐形成自己的一套可复用类库与工具箱。理解 design technique,会进一步打开你通往 pattern 的大门——也就是那些更高层、可复用的构造方式。下一章第 33 章“应用设计模式”中,你将看到 pattern 的各种实际用法。

通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某道题上卡住了,建议先回过头重读本章相关部分,尽量自己找到答案,再去看网站上的解答。

  1. 练习 32-1: 编写一个 RAII class template,名为 Pointer<T>,它能够保存一个指向 T 的 pointer,并在该 RAII instance 离开作用域时自动删除那块内存。请为它提供 reset()release() 成员函数,以及重载的 operator*
  2. 练习 32-2: 修改你在练习 32-1 中的 class template,使其在 constructor 收到 nullptr 时抛出 exception。
  3. 练习 32-3: 基于你在练习 32-2 中的解答,增加一个名为 assign() 的 member function template,其模板类型参数为 E。该函数应接收一个 E 类型参数,并把它赋给 wrapped pointer 所指向的数据。同时,为这个 member function template 添加约束,确保类型 E 的确能够赋值给一个 T 类型的 lvalue。
  4. 练习 32-4: 写一个 lambda expression,返回两个参数之和。两个参数必须属于相同类型。这个 lambda expression 应当能处理各种数据类型,例如 integral type、floating-point type,甚至 std::string。请用它分别计算 11 和 22、1.1 和 2.2,以及 “Hello ” 和 “world!” 的和。