模板算法中动态可定制部分的实现通常通过虚函数完成。对于通用的模板方法模式而言,这并非硬性要求,但在 C++ 中,我们很少需要其他方式。现在,我们将专门关注虚函数的使用,并对之前所学的内容进行改进。
首先从一个问题开始:虚函数应该是公有的还是私有的?传统的教科书式面向对象设计风格使用公有虚函数,因此我们常常不假思索地将其设为公有。但在模板方法模式中,这种做法需要重新评估 —— 公有函数是类接口的一部分。在我们的例子中,类接口包含整个算法以及我们在基类中建立的框架。这个函数应该是公有的,但它同时也是非虚的。算法某些部分的定制化实现,本就不打算由类型层次结构的使用端直接调用。它们仅在一个地方使用 —— 即在非虚的公有函数中,用来替换算法模板中设置的占位符。
这个想法看似简单,却让许多开发者感到意外。我曾不止一次被问到:C++ 甚至允许虚函数不是公有的吗?事实上,C++ 语言本身对虚函数的访问权限没有限制;它们可以像其他类成员函数一样,设为私有(private)、受保护(protected)或公有(public)。可能需要一些时间来适应,也许一个例子会有所帮助:
// Example 07
class Base {
public:
void method1() { method2(); method3(); }
virtual void method2() { ... }
private:
virtual void method3() { ... }
};
class Derived : public Base {
private:
void method2() override { ... }
void method3() override { ... }
};
Derived::method2() 和 Derived::method3() 都是私有的。基类甚至可以调用其派生类的私有方法吗?答案是:不需要直接调用 —— Base::method1() 只调用了 Base::method2() 和 Base::method3(),而它们是基类自身的成员函数(分别是公有和私有);调用同一类的私有成员函数是没有问题的。但如果实际的类类型是 Derived,在运行时将调用 method2() 的虚函数重写版本。这两个决定 —— “我能否调用 method2()?” 和 “调用哪个 method2()?” —— 发生在完全不同的时间点:前者发生在包含基类的模块编译时(此时派生类甚至可能尚未编写),而后者发生在程序执行时(在那时,“private”或“public”这样的访问修饰符已不再有意义)。另外如前面示例中的 method3() 所示,虚函数与其重写版本可以有不同的访问权限。同样,编译时调用的函数(在我们的例子中是 Base::method3())必须在调用点可访问;而最终在运行时执行的重写版本则不必可访问(然而,如果试图在类外部直接调用 Derived::method3(),那将是在尝试调用该类的私有方法,这是不允许的)。
// Example 07
Derived* d = new Derived;
Base* b = d;
b->method2(); // 可以,调用 Derived::method2()
d->method2(); // 无法编译 – 该函数是私有的
还有另一个更根本的原因,需要避免使用公有虚函数。一个公有方法是类接口的一部分,而一个虚函数的重写则是对实现的定制。公有虚函数本质上同时承担了这两项任务。同一个实体执行了两个截然不同且本不应耦合的功能:声明公有接口和提供替代实现。这两项功能各有不同的约束:只要类型层次结构的不变式得以维持,实现可以以方式修改;但接口本身不能被虚函数改变(除了返回协变类型外,但即便如此也并未真正改变接口)。公有虚函数所做的只是重申:是的,公有接口确实仍然如基类所声明的那样。这种将两个截然不同角色的混合,要求对关注点进行更好的分离。模板方法模式正是解决这一设计问题的答案,而在 C++ 中,体现为“非虚接口”(Non-Virtual Interface, NVI)惯用法。
公有虚函数所扮演的两种角色之间的张力,以及此类函数所创建的定制点不必要的暴露,促使我们想到将特定于实现的虚函数设为私有。Herb Sutter 在他的文章《Virtuality》(http://www.gotw.ca/publications/mill18.htm)中建议,大多数(如果不是全部)虚函数都应该私有。
对于模板方法模式而言,将虚函数从公有改为私有并不会带来后果(除了初次看到私有虚函数时可能会感到惊讶):
// Example 08 (示例 01 的 NVI 版本)
class Base {
public:
bool TheAlgorithm() {
if (!Step1()) return false; // 步骤 1 失败
Step2();
return true;
}
private:
virtual bool Step1() { return true };
virtual void Step2() = 0;
};
class Derived1 : public Base {
void Step2() override { ... 执行工作 ... }
};
class Derived2 : public Base {
bool Step1() override { ... 检查前置条件 ... }
void Step2() override { ... 执行工作 ... }
};
这种设计很好地分离了接口与实现 —— 使用端接口始终是且仅仅是调用一次来运行整个算法。更改算法部分实现的可能性并未反映在接口中。因此,仅通过公有接口访问此类型层次结构且无需扩展该层次结构(编写更多派生类)的用户,无需了解这些实现细节。要了解这在实践中如何运作,可以将本章中的每个示例从公有虚函数转换为非虚接口(NVI);我们只演示一个例子,即示例06,其余的留给读者作为练习。
// Example 09 (示例 06 的 NVI 版本)
class FileWriter {
virtual void Preamble(const char* data) {}
virtual void Postscript(const char* data) {}
public:
void Write(const char* data) {
Preamble(data);
... 将data写入文件 ...
Postscript(data);
}
};
class LoggingFileWriter : public FileWriter {
using FileWriter::FileWriter;
void Preamble(const char* data) override {
std::cout << "Writing " << data << " to the file" <<
std::endl;
}
void Postscript (const char*) override {
std::cout << "Writing done" << std::endl;
}
};
NVI(非虚接口)将接口的完全控制权交给了基类。派生类只能定制该接口的实现。基类可以确定并验证不变式,规定实现的整体结构,并明确指出哪些部分可以、必须或不能定制。NVI 还明确地将接口与实现分离开来。派生类的实现者无需担心,会无意中将其实现的某些部分暴露给调用者 —— 仅用于实现的私有方法除了基类之外,都无法调用。
像 LoggingFileWriter 这样的派生类仍然可以声明一个名为 Write 的非虚函数。这在 C++ 中被称为“遮蔽”:在派生类中引入的名称会遮蔽(或使其不可访问)从基类继承的同名函数。这会导致基类和派生类的接口产生分歧,是一种非常糟糕的做法,基类的实现者无法很好地避免有意遮蔽。当本意是作为虚函数重写的函数声明为参数略有不同的形式时,有时会发生意外的遮蔽;如果在所有重写中使用 override 关键字,则可以避免这种情况。
我们将所有用于定制实现的虚函数都设为了私有,这并非 NVI 的核心要点 —— 该惯用法以及更通用的模板方法模式,其重点在于使公有接口保持非虚。由此推论,特定于实现的重写不应是公有的,它们不属于接口的一部分(但这并不必然意味着它们必须私有)。这就留下了受保护(protected)这一选项,为算法提供定制功能的虚函数应该是私有的还是受保护的呢?模板方法模式允许两种选择 —— 类型层次结构的使用端都无法直接调用,因此算法的框架保持不受影响。答案取决于派生类是否可能需要调用基类提供的实现。以下是一个后者的例子,考虑一个可以序列化并通过套接字发送到远程机器的类型层次结构:
// Example 10
class Base {
public:
void Send() { // 此处使用了模板方法模式
... 打开连接 ...
SendData(); // 可定制的操作点
... 关闭连接 ...
}
protected:
virtual void SendData() { ... 发送基类数据 ... }
private:
... 数据 ...
};
class Derived : public Base {
protected:
void SendData() {
Base::SendData();
... 发送派生类数据 ...
}
};
框架由公有的非虚方法 Base::Send() 提供,负责处理连接协议,并在适当时机通过网络发送数据。当然,只能发送基类本身知道的数据。这就是为什么 SendData 会设计为一个定制点并声明为虚函数的原因。派生类当然必须发送其自身的数据,但基类的数据仍然需要发送,因此派生类会调用基类中的受保护虚函数。如果这个例子看起来似乎缺少了什么,那确实有原因。虽然提供了发送数据的通用模板,以及每个类处理自身数据的定制点,但还有另一个行为方面应该是用户可配置的:如何发送数据。这正是展示模板方法模式与策略模式之间(有时比较模糊的)区别的绝佳场景。
尽管本章的重点不是策略模式,但它有时会与模板方法模式相混淆,现在来澄清它们之间的区别。我们可以使用上一节的例子来说明。
我们已经使用模板方法模式为 Base::Send() 中“发送”操作的执行提供了整体模板。该操作包含三个步骤:打开连接、发送数据和关闭连接。发送数据这一步取决于对象的实际类型(即它究竟是哪个派生类),因此可明确指定为一个定制点,而模板的其余部分则是固定的。
然而,还需要另一种类型的定制:一般来说,基类(Base)并不是定义如何打开和关闭连接的合适位置。派生类也不合适:相同的对象可能通过不同类型的连接(如套接字、文件、共享内存等)进行传输。这时,可以使用策略模式来定义通信策略。该策略由一个独立的类提供:
// Example 11
class CommunicationStrategy {
public:
virtual void Open() = 0;
virtual void Close() = 0;
virtual void Send(int v) = 0;
virtual void Send(long v) = 0;
virtual void Send(double v) = 0;
... Send other types ...
};
模板函数不能是虚函数,这难道不令人沮丧吗?对于这个问题的更好解决方案,得等到第15章才能看到。无论如何,现在有了通信策略,就可以用它来参数化 Send() 操作的模板:
// Example 11
class Base {
public:
void Send(CommunicationStrategy* comm) {
comm->Open();
SendData(comm);
comm->Close();
}
protected:
virtual void SendData(CommunicationStrategy* comm) {
comm->Send(i_);
... 发送所有数据 ...
}
private:
int i_;
... 其他数据成员 ...
};
发送数据的模板基本保持不变,但已将具体步骤的实现委托给了另一个类 —— 即策略类。这正是关键区别所在:策略模式允许选择(通常在运行时)某个特定操作应使用哪种实现。公有接口是固定的,但整个实现取决于具体使用的策略。而模板方法模式则强制规定了整体的实现流程,以及公有接口,只有算法的特定步骤可以被定制。
第二个区别在于定制发生的位置:Base::Send() 的定制方式有两种。对模板的定制是在派生类中完成的;而策略的实现则是由 Base 类型层次结构之外的类提供的。
通常将所有虚成员函数默认设为私有(或受保护)是有充分理由的,这些理由并不仅限于模板方法模式的应用。然而,有一个特殊的成员函数 —— 析构函数 —— 值得单独讨论,因为析构函数的规则有些不同。
关于非虚接口(NVI)的全部讨论,都是对一条简单准则的深入阐述 —— 将虚函数设为私有(或受保护),并通过基类的非虚函数提供公有接口。这个准则听起来很好,但会直接与另一条广为人知的准则发生冲突 —— 如果一个类至少有一个虚函数,析构函数也必须为虚。由于这两条准则存在冲突,因此需要一些澄清。
将析构函数设为虚函数的原因是:如果对象是通过多态方式删除的 —— 通过指向基类的指针删除一个派生类对象 —— 析构函数必须是虚函数;否则,只有类的基类部分会析构(通常的结果是类的“切片”或部分删除,尽管标准只是简单地指出结果是未定义的)。如果对象是通过基类指针删除的,析构函数必须是虚函数,但这正是唯一的原因。如果对象总是以其正确的派生类型进行删除,那么这个原因就不适用了。这种情况并不少见,如果派生类对象存储在容器中,将以其真实类型进行删除。
容器必须知道为对象分配多少内存,因此不能混合存储基类和派生类对象,也不能将它们作为基类对象删除(存储指向基类对象指针的容器是完全不同的结构,通常正是为了能够以多态方式存储和删除对象而创建的)。
现在,若派生类必须以其自身类型进行删除,其析构函数就不需要是虚函数。然而,如果有人在实际对象是派生类类型时调用了基类的析构函数,仍然会发生严重问题。为了安全地防止这种情况发生,可以将基类的非虚析构函数声明为受保护而非公有。当然,如果基类不是抽象的,并且同时存在基类和派生类的对象,那么两个析构函数都必须是公有的,更安全的选择是将它们都设为虚函数(可以实现运行时检查,来验证基类析构函数没有用来销毁派生类对象)。
顺便提一下,如果多态删除(通过基类指针删除)是需要在基类中编写析构函数的唯一原因,那么编写 virtual ~Base() = default; 完全可以接受 —— 析构函数可以同时虚函数和默认。
我们还必须提醒读者,不要试图对类的析构函数使用模板方法模式或非虚接口(NVI)惯用法。虽然,可能有人会想这样做:
// Example 12
class Base {
public:
~Base() { // 非虚接口!
std::cout << "Deleting now" << std::endl;
clear(); // 此处运用了模板方法模式
std::cout << "Deleting done" << std::endl;
}
protected:
virtual void clear() { ... } // 可定制的部分
};
class Derived : public Base {
private:
void clear() override {
...
Base::clear();
}
};
然而,这种方法行不通(如果基类中的 Base::clear() 是纯虚函数而不是默认实现,将以一种非常引人注目的方式失败)。其原因在于,在基类的析构函数 Base::~Base() 内部,对象的实际、真实类型不再是 Derived,而是 Base。没错 —— 当 Derived::~Derived() 析构函数完成其工作,并将控制权转移给基类析构函数时,对象的动态类型会变为 Base。
唯一另一个以类似方式工作的类成员是构造函数 —— 在基类构造函数运行期间,对象的类型是 Base,当派生类构造函数开始运行后,类型才会变为 Derived。对于所有其他成员函数,对象的类型始终是其创建时的类型。如果对象是作为 Derived 创建的,就是 Derived 类型,即使调用的是基类的方法也是如此。
如果在前面的例子中 Base::clear() 是纯虚函数会发生什么?它仍然会调用!结果取决于编译器;大多数编译器会生成导致程序中止的代码,并附带诊断信息,说明调用了纯虚函数。