关于使用NVI(非虚接口)的缺点并不多,“始终将虚函数设为私有,并通过NVI来调用它们”这一准则可广泛接受。然而,在决定模板方法是否是合适的使用设计模式时,必须意识到一些需要考虑的因素,使用模板方法模式可能会导致脆弱的继承层次结构。此外,使用模板方法模式可以解决的设计问题,与使用策略模式(或在C++中使用策略模式的变体 —— 策略模式)更合适解决的设计问题之间,存在一定的重叠。我们将在本节中回顾这两个方面的考虑。
考虑前面提到的LoggingFileWriter设计。现在,假设还想拥有一个CountingFileWriter,用于统计写入文件的字符数量:
class CountingFileWriter : public FileWriter {
size_t count_ = 0;
void Preamble(const char* data) {
count_ += strlen(data);
}
};
这很容易实现。但没有理由让一个计数文件写入器不能同时进行日志记录。该如何实现一个既计数又记录日志的 CountingLoggingFileWriter 呢?将私有的虚函数改为 protected,然后在派生类中调用基类的版本:
class CountingLoggingFileWriter : public LoggingFileWriter {
size_t count_ = 0;
void Preamble(const char* data) {
count_ += strlen(data);
LoggingFileWriter::Preamble(data);
}
};
或者,是否应该让 LoggingCountingFileWriter 继承自 CountingFileWriter 呢?
无论采用哪种方式,都会导致一些代码重复 —— 例子中,计数代码同时存在于 CountingLoggingFileWriter 和 CountingFileWriter 中。随着增加更多变体,这种重复问题只会越来越严重。如果需要可组合的定制功能,模板方法模式就不是正确的选择。对于这种情况,第15章会给出答案。
脆弱基类问题并不仅限于模板方法模式,而在某种程度上,它是所有面向对象语言固有的问题。当对基类的修改导致派生类功能被破坏时,就会出现此问题。为了了解这种情况是如何发生的,特别是使用非虚接口时,让我们回到文件写入器的例子,并添加一次性写入多个字符串的功能:
class FileWriter {
public:
void Write(const char* data) {
Preamble(data);
... 将data写入文件 ...
Postscript(data);
}
void Write(std::vector<const char*> huge_data) {
Preamble(huge_data);
for (auto data: huge_data) {
... 将data写入文件 ...
}
Postscript(huge_data);
}
private:
virtual void Preamble(std::vector<const char*> data) {}
virtual void Postscript(std::vector<const char*> data) {}
virtual void Preamble(const char* data) {}
virtual void Postscript(const char* data) {}
};
计数写入器已随着这些更改保持同步:
class CountingFileWriter : public FileWriter {
size_t count_ = 0;
void Preamble(std::vector<const char*> huge_data) {
for (auto data: huge_data) count_ += strlen(data);
}
void Preamble(const char* data) {
count_ += strlen(data);
}
};
一切顺利。后来,一位好心的开发者注意到基类存在一些代码重复,于是决定对其进行重构:
class FileWriter {
public:
void Write(const char* data) { ... 这里没有改动 ... }
void Write(std::vector<const char*> huge_data) {
Preamble(huge_data);
for (auto data: huge_data) Write(data); // 代码复用!
Postscript(huge_data);
}
private:
... 这里没有改动 ...
};
现在,派生类被破坏了 —— 当写入一串字符串时,Write 的两个版本的计数自定义功能都会调用,导致数据大小被计算了两次。这里讨论的不是那种更基础的脆弱性问题,即如果基类方法的签名发生改变,派生类中的重写方法可能不再视为重写:这类问题在很大程度上可以通过正确使用 override 关键字来避免,正如第1章中所建议的那样。
只要还在使用继承,脆弱基类问题就不存在普遍的解决方案。但在使用模板方法模式时,有一条简单的准则有助于避免此问题:在更改基类以及算法或框架的结构时,应避免更改调用的定制点(即虚函数)。具体来说,不要跳过原本会调用的定制选项,也不要对已存在的定制点增加新的调用(可以添加新的定制点,只要其默认实现合理)。如果无法避免此类更改,就必须检查每一个派生类,确认是否依赖于现在已移除或替换的实现重写,并评估此类更改带来的后果。
本节简要说明的内容并非模板方法模式的缺点,而是关于C++中某个较为晦涩角落的一个警告。许多最初作为运行时行为开发的设计模式(面向对象模式)在C++的泛型编程中找到了它们的编译时对应物,是否存在一种编译时的模板方法呢?
当然,有一种显而易见的方式:可以创建一个函数模板或类模板,为固定算法中的某个步骤接受函数参数,或者接受可调用对象作为参数。标准库中有大量这样的例子,例如 std::find_if:
std::vector<int> v = ... 一些数据 ...
auto it = std::find_if(v.begin(), v.end(),
[](int i) { return i & 1; });
if (it != v.end()) { ... } // 找到偶数
std::find_if 的算法无法更改,只有检查特定值是否满足调用者提供的谓词这一步骤可以自定义。
如果想在类型层次结构中实现类似的效果,可以使用成员函数指针(尽管通过 Lambda 调用成员函数更为简便),但除了使用虚函数及其重写机制外,无法表达“调用一个同名但属于不同类的成员函数”这样的操作。在泛型编程中,并没有与此相对应的机制。
但存在一种情况:模板可能会意外地定制,通常会导致非预期的结果:
// Example 15
void f() { ... }
template <typename T> struct A {
void f() const { ... }
};
template <typename T> struct B : public A<T> {
void h() { f(); }
};
B<int> b;
b.h();
在 B<T>::h() 内部调用的是哪个 f() 函数?在符合标准的编译器上,应该调用的是独立的全局函数 ::f(),而不是基类的成员函数!
这可能会令人感到意外:如果 A 和 B 是非模板类,那么调用的将是基类方法 A::f()。这种行为源于 C++ 中模板解析的复杂性(如果了解更多,可以搜索“两阶段模板解析”或“两阶段名称查找”这两个术语,但这个问题已经远远超出了本书的讨论范围)。
如果一开始全局函数 f() 就不存在,会发生什么情况呢?那就必须调用基类中的那个函数,不是吗?
// Example 15
// 这里没有 f()!
template <typename T> struct A {
void f() const { ... }
};
template <typename T> struct B : public A<T> {
void h() { f(); } // 应该无法编译!
};
B<int> b;
b.h();
如果尝试了这段代码,而调用了 A<T>::f(),那么编译器就是有缺陷的:根据标准,这种情况根本不应该通过编译!
但是,若想调用自己基类的成员函数,该怎么办呢?答案非常简单,若没有写过大量模板代码的话,看起来会有些奇怪:
// Example 15
template <typename T> struct A {
void f() const { ... }
};
template <typename T> struct B : public A<T> {
void h() { this->f(); } // 明确调用 A::f()
};
B<int> b;
b.h();
没错,必须显式地调用 this→f(),才能确保你调用的是一个成员函数。只要这样做,无论是否声明了全局函数 f(),都会调用 A<T>::f()。顺便提一下,如果本意是调用全局函数,那么明确无误的方式是使用 ::f();如果该函数位于命名空间 NS 中,则使用 NS::f()。
编译器无法找到明显存在于基类中的成员函数而导致的编译错误,是 C++ 中最令人困惑的错误之一。更糟糕的是,有些编译器非但没有报错,反而“如你所愿”地编译了代码:如果之后有人添加了一个同名的全局函数(或者声明在包含的另一个头文件中),编译器将无声无息地切换到调用那个函数,且不会发出警告。
通用的指导原则是:在类模板中调用成员函数时,始终使用 this→ 进行限定。
总体而言,模板方法模式是 C++ 中为数不多的仍然保持纯粹面向对象特性的设计模式之一:我们前面看到的 std::find_if 所采用的模板形式(以及更多类似的模板)通常属于“基于策略的设计”的范畴,我们将在下一章中深入学习这种设计方法。