更多 C++ 惯用法/非虚拟接口
- 将整个类层次结构的通用前后代码片段(例如,不变式检查、获取/释放锁)模块化/重构到一个位置。
- 模板方法 - 来自四人帮的设计模式书籍的更通用的模式。
前置条件和后置条件检查被认为是一种有用的面向对象编程技术,尤其是在开发阶段。前置条件和后置条件确保类层次结构(以及通常的抽象)的不变式在程序执行期间的指定点不被破坏。在开发阶段或调试构建期间使用它们有助于尽早捕获违规。为了保持一致性和易于维护此类前置条件和后置条件,它们应该理想地模块化到一个位置。在一个类层次结构中,其不变式必须在子类中的每个方法之前和之后都保持,模块化变得很重要。
类似地,在类层次结构中获取和释放对公共数据结构的锁可以被认为是前置条件和后置条件,即使在生产阶段也必须确保。将锁获取和释放的责任从子类中分离出来,并将它们放在一个地方 - 可能是基类中,这很有用。
非虚拟接口 (NVI) 惯用法允许我们将前后代码片段重构到一个方便的位置 - 基类。NVI 惯用法基于 Herb Sutter 在他名为“虚函数”的文章 [2] 中概述的 4 个准则。引用 Herb 的话
- 准则 #1:优先使用模板方法设计模式使接口非虚拟。
- 准则 #2:优先使虚函数私有。
- 准则 #3:只有当派生类需要调用虚函数的基类实现时,才将虚函数设为保护的。
- 准则 #4:基类析构函数应该要么是公共的并且是虚拟的,要么是受保护的并且是非虚拟的。- 引用结束。
这里有一些代码实现了遵循上述 4 个准则的 NVI 惯用法。
class Base {
private:
ReaderWriterLock lock_;
SomeComplexDataType data_;
public:
void read_from( std::istream & i) { // Note non-virtual
lock_.acquire();
assert(data_.check_invariants()); // must be true
read_from_impl(i);
assert(data_.check_invariants()); // must be true
lock_.release();
}
void write_to( std::ostream & o) const { // Note non-virtual
lock_.acquire();
write_to_impl(o);
lock_.release();
}
virtual ~Base() {} // Virtual because Base is a polymorphic base class.
private:
virtual void read_from_impl( std::istream & ) = 0;
virtual void write_to_impl( std::ostream & ) const = 0;
};
class XMLReaderWriter : public Base {
private:
virtual void read_from_impl (std::istream &) {
// Read XML.
}
virtual void write_to_impl (std::ostream &) const {
// Write XML.
}
};
class TextReaderWriter : public Base {
private:
virtual void read_from_impl (std::istream &) {}
virtual void write_to_impl (std::ostream &) const {}
};
基类的上述实现捕捉了几个设计意图,这些意图对于实现 NVI 惯用法的优势至关重要。此类意在用作基类,因此它具有一个虚析构函数和一些纯虚函数(read_from_impl、write_to_impl),所有具体的派生类都必须实现这些函数。客户端的接口(即 read_from 和 write_to)与子类的接口(即 read_from_impl 和 write_to_impl)是分开的。虽然 read_from_impl 和 write_to_impl 是两个私有函数,但基类可以使用动态调度调用相应的派生类函数。这两个函数为派生类族提供了必要的扩展点。但是,它们被禁止扩展客户端接口(read_from 和 write_to)。请注意,可以从派生类调用客户端的接口,但是,这会导致递归。最后,NVI 惯用法建议每个公共非虚拟函数使用一个私有虚拟扩展点。
客户端只调用公共接口,该接口反过来调用虚 *_impl 函数,就像在模板方法设计模式中一样。在调用 *_impl 函数之前和之后,基类会执行锁操作和不变式检查操作。通过这种方式,层次结构范围内的前后代码片段可以集中在一个地方,简化维护。即使客户端没有直接调用虚函数,Base 层次结构的客户端仍然可以获得多态行为。派生类应确保通过在派生类中也将它们设为私有来禁止客户端直接访问实现函数 (*_impl)。
如果不小心,使用 NVI 惯用法可能会导致脆弱的类层次结构。如 [1] 中所述,在脆弱基类 (FBC) 接口问题中,当基类实现发生更改而没有通知时,子类的虚函数可能会意外地被调用。例如,以下代码片段(受 [1] 启发)使用 NVI 惯用法来实现 CountingSet,它以 Set 作为基类。
class Set {
std::set<int> s_;
public:
void add (int i) {
s_.insert (i);
add_impl (i); // Note virtual call.
}
void addAll (int * begin, int * end) {
s_.insert (begin, end); // --------- (1)
addAll_impl (begin, end); // Note virtual call.
}
private:
virtual void add_impl (int i) = 0;
virtual void addAll_impl (int * begin, int * end) = 0;
};
class CountingSet : public Set {
private:
int count_;
virtual void add_impl (int i) {
count_++;
}
virtual void addAll_impl (int * begin, int * end) {
count_ += std::distance(begin,end);
}
};
上面的类层次结构很脆弱,因为在维护期间,如果 addAll 函数(由 (1) 指示)的实现被更改为对从 begin 到 end 的每个整数调用公共非虚拟 add 函数,那么派生类 CountingSet 就会失效。由于 addAll 调用 add,因此派生类的扩展点 add_impl 被调用以处理每个整数,最后 addAll_impl 也被调用,导致对整数范围的计数两次,这在派生类中默默地引入了错误!解决方法是严格遵循编码纪律,在基类的任何公共非虚拟接口中调用恰好一个私有虚拟扩展点。但是,解决方案取决于程序员的纪律,因此在实践中很难遵循。
请注意,NVI 惯用法如何将每个类层次结构视为一个微小的(有些人可能喜欢称之为微不足道)面向对象框架,其中通常观察到控制反转 (IoC) 流。框架控制程序的流程,而不是客户端编写的函数和类,这就是为什么它被称为控制反转。在 NVI 中,基类控制程序流程。在上面的示例中,Set 类执行必要的公共插入工作,然后调用 *_impl 虚函数(扩展点)。Set 类不得调用其任何自己的公共接口,以防止 FBC 问题。
最后,NVI 惯用法会导致类层次结构中一定程度的代码膨胀,因为当应用 NVI 时,函数数量会翻倍。基类中重构代码的大小应该足够大,以证明使用 NVI 的合理性。
- 接口类
- 线程安全接口,来自 Douglas Schmidt 等人的模式导向软件体系结构(第 2 卷)。
- 公共重载非虚函数调用受保护的非重载虚函数 [1]
[1] 选择性开放递归:关于组件和继承的模块化推理 - Jonathan Aldrich、Kevin Donnelly。
[2] 虚函数! -- Herb Sutter
[3] 对话:虚函数的你 -- Jim Hyslop 和 Herb Sutter
[4] 我应该使用受保护的虚函数而不是公共虚函数吗? -- Marshall Cline