第20章 派生类(Dirived Classes)
目录
20.3.3 显式修饰(Explicit Qualification)
20.3.5 using基成员(using Base Members)
20.3.5.1 继承构造函数(Inheriting Constructors)
20.3.6 返回类型放宽(Return Type Relaxation)
20.1 引言
C++ 从 Simula 中借鉴了类和类层次结构的思想。此外,它还借鉴了类应该用来为程序员和应用程序世界中的概念建模的设计思想。C++ 提供了直接支持这些设计概念的语言结构。反之,使用语言特性来支持设计思想可以区分 C++ 的有效使用。将语言结构仅用作传统编程类型的符号支撑会忽略 C++ 的主要优势。
概念(想法、观念等)并非孤立存在。它与相关概念共存,并从与其他概念的关系中获得其大部分力量。例如,尝试解释什么是汽车。很快你就会引入车轮、发动机、司机、行人、卡车、救护车、道路、石油、超速罚单、汽车旅馆等概念。由于我们使用类来表示概念,因此问题变成了如何表示概念之间的关系。但是,我们无法直接在编程语言中表达任意关系。即使我们可以,我们也不想这样做。为了实用,我们的类应该比我们的日常概念定义得更窄——且更精确。
派生类的概念及其相关的语言机制用于表达层级关系,即表达类之间的共性。例如,圆和三角形的概念是相关的,因为它们都是形状;也就是说,它们具有共同的形状概念。因此,我们明确定义 Circle 类和 Triangle 类具有共同的 Shape 类。在这种情况下,共同的类(此处为 Shape)称为基类(base class)或超类(superclass)(译注:指的是在类结构结构的意义上,基类处于顶端),而从中派生的类(此处为 Circle 和 Triangle)称为派生类或子类。在程序中表示圆和三角形而不涉及形状概念,就会错过一些重要的东西。本章探讨了这个简单思想的含义,它是通常称为面向对象编程的基础。语言功能支持从现有类构建新类:
• 实现继承(Implementation inheritance):通过共享基类提供的功能来节省实现工作量。
• 接口继承(Interface inheritance):允许通过公共基类提供的接口互换使用不同的派生类。
接口继承通常称为运行时多态性(run-time polymorphism)(或动态多态性)(dynamic polymorphism)。相反,模板提供的类的统一用法不是继续关系(§3.4,第 23 章)通常称为编译时多态性(compile-time polymorphism)(或称静态多态性)(static polymorphism)。
关于类层次结构的讨论分为三章:
• 派生类(第 20 章):本章介绍支持面向对象编程的基本语言特性。涵盖基类和派生类、虚函数和访问控制。
• 类层次结构(第 21 章):本章重点介绍如何使用基类和派生类来围绕类层次结构的概念有效地组织代码。本章的大部分内容都用于讨论编程技术,但也涵盖了多重继承(具有多个基类的类)的技术方面。
• 运行时类型标识(第 22 章):本章描述了显式导航类层次结构的技术。特别是,介绍了类型转换操作 dynamic_cast 和 static_cast,以及根据对象的一个基类确定其类型的操作(typeid)。
第 3 章:基类和派生类(§3.2.2)和虚函数(§3.2.3)简要介绍了类型的层次结构的基本概念。现在这三章更详细地介绍了这些基本特性及其相关的编程和设计技术。
20.2 派生类
考虑构建一个处理公司雇员的程序。这样的程序可能具有如下数据结构:
struct Employee {
string first_name , family_name;
char middle_initial;
Date hiring_date;
short depar tment;
// ...
};
接下来,我们尝试定义一个管理类(经理类):
struct Manager {
Employee emp; // 经理的信息记录
list<Employee∗> group; // 管理的人员
short level;
// ...
};
经理也是员工;Employee数据存储在 Manager 对象的 emp 成员中。这对于人类读者(尤其是细心的读者)来说可能是显而易见的,但没有任何内容可以告诉编译器和其他工具,Manager也是Employee。 Manager∗ 不是 Employee∗,因此不能简单地使用其中一个,而需要另一个。特别是,如果不编写特殊代码,就不能将 Manager 放入Employee列表中。我们可以对 Manager∗ 使用显式类型转换,也可以将 emp 成员的地址放入Employee列表中。但是,这两种解决方案都不优雅,而且可能相当晦涩难懂。正确的方法是明确说明 Manager 是Employee,并添加一些信息:
struct Manager : public Employee {
list<Employee∗> group;
short level;
// ...
};
类Manager 派生于 Employee ,反之,Employee是Manager的一个基类。类Manager除了其自身的成员(group、level、等等),还具有类Employee的成员(first_name、department、等等 ) 。
派生通常以图形方式用从派生类指向其基类的指针来表示,表明派生类引用其基类(而不是相反):
派生类通常被认为从其基类继承属性,因此这种关系也称为继承(inheritance)。基类有时被称为超类(superclass)(译注:原因在于在派生关系图中,基类处于顶层),派生类被称为子类(subclass)。然而,这个术语会让那些观察到派生类对象中的数据是其基类对象数据的超集的人感到困惑。派生类通常比其基类更大(并且永远不会更小),因为它包含更多数据并提供更多功能。
派生类概念的一种流行且有效的实现是将派生类的对象表示为基类的对象,并在末尾添加具体属于派生类的信息。例如:
派生类不意味着额外的内存开销。所需的空间只是成员所需的空间。
以这种方式从 Employee 派生 Manager 会使 Manager 成为 Employee 的子类型,因此 Manager 可以在任何可以接受 Employee 的地方使用。例如,我们现在可以创建一个 Employee 列表,其中一些是 Manager:
void f(Manager m1, Employee e1)
{
list<Employee∗> elist {&m1,&e1);
// ...
}
Manager 也是 Employee,因此 Manager∗ 可以用作 Employee∗。类似地,Manager& 可以用作 Employee&。但是,Employee 不一定是 Manager,因此 Employee∗ 不能用作 Manager∗。通常,如果 Derived 类具有公共基类 (§20.5) Base,则可以将 Derived∗ 分配给 Base∗ 类型的变量,而无需使用显式类型转换。相反的转换(从 Base∗ 到 Derived∗)必须是显式的。例如:
void g(Manager mm, Employee ee)
{
Employee∗ pe = &mm; // OK: 每一个Manager都是一个Employee
Manager∗ pm = ⅇ // 错: 并非每一个Employee都是一个Manager
pm−>level = 2; // 灾难: ee 没有level成员
pm = static_cast<Manager∗>(pe); // 强制: 有效,因为pe指向Manager mm
pm−>level = 2; // 很好: pm 指向有一个level的Manager mm
}
换句话说,当通过指针和引用操作时,派生类的对象可以视为其基类的对象。反之则不然。static_cast 和 dynamic_cast 的使用在 §22.2 中讨论。
使用作为基类的类等同于定义该类的(未命名)对象。因此,必须先定义类才能将其用作基类 (§8.2.2):
class Employee; // 仅声明,未定义
class Manager : public Employee { // 错: Employee 未定义
// ...
};
20.2.1 类成员函数
简单的数据结构(例如 Employee 和 Manager)实际上并不那么有趣,而且通常也不是特别有用。我们需要提供具有一组合适操作的适当类型,并且我们需要这样做而不受限于特定表示的细节。例如:
class Employee {
public:
void print() const;
string full_name() const { return first_name + ' ' + middle_initial + ' ' + family_name; }
// ...
private:
string first_name , family_name;
char middle_initial;
// ...
};
class Manager : public Employee {
public:
void print() const;
// ...
};
派生类的成员可以使用基类的公共成员和受保护成员(参见 §20.5),就像它们是在派生类本身中声明的一样。例如:
void Manager::print() const
{
cout << "name is " << full_name() << '\n';
// ...
}
但是,派生类不能访问基类的私有成员:
void Manager::print() const
{
cout << " name is " << family_name << '\n'; // error!
// ...
}
Manager::print() 的第二个版本将无法编译,因为 Manager::print() 无法访问 family_name。
这对某些人来说可能很意外,但请考虑另一种情况:派生类的成员函数可以访问其基类的私有成员。如果允许程序员通过从类派生新类来访问类的私有部分,私有成员的概念将变得毫无意义。此外,通过查看声明为该类的成员和友成员的函数,人们再也无法找到私有名称的所有用法。人们必须检查派生类的完整程序的每个源文件,然后检查这些类的每个函数,然后找到从这些类派生的每个类,等等。这充其量是乏味的,而且往往不切实际。在可以接受的情况下,可以使用受保护的成员(而不是私有成员)(§20.5)。
通常,最干净的解决方案是让派生类仅使用其基类的公共成员。例如:
void Manager::print() const
{
Employee::print(); // 打印 Employee 信息
cout << level; // 打印具体Manager信息
// ...
}
请注意,必须使用 ::,因为 print() 已在 Manager 中重新定义。这种名称重用很常见。粗心的人可能会这样写:
void Manager::print() const
{
print(); // oops!
// 打印具体Manager信息
}
结果是一系列递归调用以某种形式的程序崩溃而告终。
20.2.2 类构造函数和析构函数
与往常一样,构造函数和析构函数也是最基本的:
• 对象从下往上构造(基类先于成员,成员先于派生类),从上往下销毁(派生类先于成员,成员先于基类);§17.2.3.
• 每个类都可以初始化其成员和基类(但不能直接初始化其基类的成员或基类);§17.4.1.
• 通常,层次结构中的析构函数需要是virtual的;§17.2.5.
• 层次结构中类的复制构造函数应谨慎使用(如果有的话),以避免分片;§17.5.1.4.
• 构造函数或析构函数中虚函数调用、dynamic_cast 或 typeid() 的解析反映了构造和销毁的阶段(而不是尚未完成的对象的类型);§22.4.
在计算机科学中,“上(up)”和“下(down)”可能会非常混乱。在源文本中,基类的定义必须出现在其派生类的定义之前。这意味着对于小示例,基类出现在屏幕上的派生类上方。此外,我们倾向于将树的根部放在顶部。但是,当我谈到从下往上构建对象时,我的意思是从最基本的(例如,基类)开始,然后再构建依赖于它的内容(例如,派生类)。我们从根(基类)向叶子(派生类)构建。
20.3 派层次结构
派生类其本身可以是基类。例如:
class Employee { /* ... */ };
class Manager : public Employee { /* ... */ };
class Director : public Manager { /* ... */ };
这种相关类的集合传统上称为类层次结构。这种层次结构通常是树状结构,但也可以是更通用的图形结构。例如:
class Temporar y { /* ... */ };
class Assistant : public Employee { /* ... */ };
class Temp : public Temporar y, public Assistant { /* ... */ };
class Consultant : public Temporar y, public Manager { /* ... */ };
或者图形结构。例如:
因此,正如§21.3 中详细解释的那样,C++ 可以表达类的有向无环图。
20.3.1 类型域(Type Fields)
要将派生类用作声明中的便捷简写,我们必须解决以下问题:给定一个 Base∗ 类型的指针,指向的对象究竟属于哪种派生类型?有四个基本解决方案:
[1] 确保只指向单一类型的对象(§3.4,第 23 章)。
[2] 在基类中放置一个类型字段,供函数检查。
[3] 使用 dynamic_cast(§22.2,§22.6)。
[4] 使用虚函数(§3.2.3,§20.3.2)。
除非您使用了 final(§20.3.4.2),否则解决方案 1 所依赖的有关所涉及类型的知识比编译器可用的知识要多。一般来说,试图比类型系统更聪明并不是一个好主意,但(尤其是与模板结合使用时)它可以用于实现同质容器(例如,标准库向量和映射),性能无与伦比。解决方案 [2]、[3] 和 [4] 可用于构建异构列表,即几种不同类型的对象(指针)的列表。解决方案 [3] 是解决方案 [2] 的语言支持变体。解决方案 [4] 是解决方案 [2] 的特殊类型安全变体。解决方案 [1] 和 [4] 的组合特别有趣且强大;在几乎所有情况下,它们产生的代码都比解决方案 [2] 和 [3] 更干净。
让我们首先研究一下简单的类型字段解决方案,看看为什么最好避免使用它。经理/员工示例可以重新定义如下:
struct Employee {
enum Empl_type { man, empl };
Empl_type type;
Employee() : type{empl} { }
string first_name , family_name;
char middle_initial;
Date hiring_date;
short depar tment;
// ...
};
struct Manager : public Employee {
Manager() { type = man; }
list<Employee∗> group; // people managed
short level;
// ...
};
鉴于此,我们现在可以编写一个打印有关每个Employee的信息的函数:
void print_employee(const Employee∗ e)
{
switch (e−>type) {
case Employee::empl:
cout << e−>family_name << '\t' << e−>department << '\n';
// ...
break;
case Employee::man:
{ cout << e−>family_name << '\t' << e−>department << '\n';
// ...
const Manager∗ p = static_cast<const Manager∗>(e);
cout << " level " << p−>level << '\n';
// ...
break;
}
}
}
并使用它来打印Employee列表,如下所示:
void print_list(const list<Employee∗>& elist)
{
for (auto x : elist)
print_employee(x);
}
这种方法运行良好,尤其是在由一个人维护的小程序中。然而,它有一个根本性的弱点,即它依赖于程序员以编译器无法检查的方式操纵类型。这个问题通常会变得更糟,因为诸如 print_employee() 之类的函数通常被组织起来以利用所涉及的类的共性:
void print_employee(const Employee∗ e)
{
cout << e−>family_name << '\t' << e−>department << '\n';
// ...
if (e−>type == Employee::man) {
const Manager∗ p = static_cast<const Manager∗>(e);
cout << " level " << p−>level << '\n';
// ...
}
}
在处理许多派生类的大型函数中查找所有此类类型字段测试可能很困难。即使找到了,也很难理解发生了什么。此外,任何新类型的Employee的添加都涉及更改系统中的所有关键功能——包含类型字段测试的功能。程序员必须考虑更改后可能需要对类型字段进行测试的每个功能。这意味着需要访问关键源代码以及测试受影响代码的必要开销。使用显式类型转换强烈暗示可以进行改进。
换句话说,使用类型字段是一种容易出错的技术,会导致维护问题。随着程序规模的增加,问题的严重性也会增加,因为使用类型字段会违反模块化和数据隐藏的理念。每个使用类型字段的函数都必须了解从包含类型字段的类派生的每个类的表示和其他实现细节。
似乎任何可从每个派生类访问的公共数据(例如类型字段)都会诱使人们添加更多此类数据。因此,公共基础类成为各种“有用信息”的存储库。这反过来又使基础类和派生类的实现以最不希望的方式交织在一起。在大型类层次结构中,公共基类中可访问(非private)的数据成为层次结构的“全局变量”。为了简洁的设计和更简单的维护,我们希望将不同的问题分开并避免相互依赖。
20.3.2 虚函数(Virtual Functions)
虚函数通过允许程序员在基类中声明函数来解决类型字段解决方案的问题,该函数可以在每个派生类中重新定义。编译器和链接器将保证对象与应用于它们的函数之间的正确对应关系。例如:
class Employee {
public:
Employee(const string& name, int dept);
virtual void print() const;
// ...
private:
string first_name , family_name;
short depar tment;
// ...
};
为了允许虚函数声明充当派生类中定义的函数的接口,为派生类中的函数指定的参数类型不能与基类中声明的参数类型不同,并且返回类型只允许有非常小的变化(§20.3.6)。虚成员函数有时称为方法(method)。
必须为首次声明虚函数的类定义虚函数(除非将其声明为纯虚函数;请参阅 §20.4)。例如:
void Employee::print() const
{
cout << family_name << '\t' << department << '\n';
// ...
}
即使没有从其类派生出任何类,也可以使用虚函数,并且不需要其自己虚函数版本的派生类无需提供该函数。派生类时,只需在需要时提供适当的函数即可。例如:
class Manager : public Employee {
public:
Manager(const string& name, int dept, int lvl);
void print() const;
// ...
private:
list<Employee∗> group;
short level;
// ...
};
void Manager::print() const
{
Employee::print();
cout << "\tlevel " << level << '\n';
// ...
}
如果派生类中的函数与基类中的虚函数具有相同的名称和相同的参数类型集,则该函数将覆盖(override)基类版本的虚函数。此外,还可以覆盖具有派生程度更高的返回类型的基类中的虚函数(§20.3.6)。
除非我们明确说明调用哪个版本的虚函数(例如调用 Employee::print()),否则将选择最适合其调用对象的重写函数。无论使用哪个基类(接口)访问对象,当我们使用虚函数调用机制时,我们总是获得相同的函数。
全局函数 print_employee() (§20.3.1) 现在不再需要,因为 print() 成员函数已经取代了它。可以像这样打印员工列表:
void print_list(const list<Employee∗>& s)
{
for (auto x : s)
x−>print();
}
每个Employee将根据其类型编写。例如:
int main()
{
Employee e {"Brown",1234};
Manager m {"Smith",1234,2};
print_list({&e,&m});
}
产生:
Smith 1234
level 2
Brown 1234
请注意,即使 print_list() 是在特定派生类 Manager 构思之前编写和编译的,它也能正常工作!这是类的一个关键方面。如果使用得当,它将成为面向对象设计的基石,并为不断发展的程序提供一定程度的稳定性。
无论实际使用哪种类型的 Employee,从 Employee 函数中获取“正确”的行为都称为多态性。具有虚函数的类型称为多态类型或(更准确地说)运行时多态类型。要在 C++ 中获得运行时多态行为,调用的成员函数必须是虚的,并且必须通过指针或引用来操作对象。当直接操作对象(而不是通过指针或引用)时,编译器知道其确切类型,因此不需要运行时多态性。
在默认情况下,覆盖虚函数的函数本身将成为virtual。我们可以在派生类中重复virtual,但不必如此。我不建议重复virtual。如果想显式,请使用覆盖(§20.3.4.1)(译注:即可在派生类中重复使用 virtual,但没必要这么做,直接使用覆盖即可)。
显然,要实现多态性,编译器必须在 Employee 类的每个对象中存储某种类型信息,并使用它来调用虚拟函数 print() 的正确版本。在典型的实现中,占用的空间刚好足以容纳一个指针(§3.2.3):通常的实现技术是让编译器将虚函数的名称转换为指向函数的指索引,并放入指针表。该表通常称为虚函数表(virtual function table)或简称为 vtbl。每个具有虚函数的类都有自己的 vtbl 来标识其虚函数。这可以用图形表示如下:
即使对象的大小和数据布局对调用者来说都是未知的,vtbl 中的函数也能让对象正确使用。调用者的实现只需要知道 Employee 中 vtbl 的位置和每个虚函数使用的索引。这种虚调用机制几乎可以与“正常函数调用”机制一样高效(相差 25% 以内),因此效率问题不应阻止任何人在普通函数调用效率可接受的情况下使用虚函数。其空间开销是具有虚函数的类的每个对象中的一个指针加上每个此类类的一个 vtbl。您只需为具有虚函数的类的对象支付此开销。只有在需要虚函数提供的附加功能时,您才选择支付此开销。如果您选择使用替代类型字段解决方案,则类型字段将需要相当大空间。
从构造函数或析构函数调用的虚函数反映出对象是部分构造或部分销毁的(§22.4)。因此,从构造函数或析构函数调用虚函数通常不是一个好主意。
20.3.3 显式修饰(Explicit Qualification)
使用范围解析运算符 :: 调用函数,就像在 Manager::print() 中所做的那样,确保不使用虚机制:
void Manager::print() const
{
Employee::print(); // not a virtual call
cout << "\tlevel " << level << '\n';
// ...
}
否则,Manager::print() 将遭受无限递归。使用限定名称还有另一个理想的效果。也就是说,如果虚函数也是inline的(这并不罕见),那么可以使用内联替换来指定使用 :: 的调用。这为程序员提供了一种有效的方法来处理一些重要的特殊情况,在这些特殊情况下,一个虚函数为同一个对象调用另一个虚函数。Manager::print() 函数就是一个例子。因为对象的类型是在 Manager::print() 的调用中确定的,所以对于结果调用 Employee::print(),不需要再次动态确定它。
20.3.4 覆盖控制(Override Control)
如果您在派生类中声明的函数与基类中的虚函数具有完全相同的名称和类型,则派生类中的函数将覆盖基类中的函数。这是一条简单而有效的规则。但是,对于较大的类层次结构,很难确保您确实覆盖了您想要覆盖的函数。请考虑:
struct B0 {
void f(int) const;
virtual void g(double);
};
struct B1 : B0 { /* ... */ };
struct B2 : B1 { /* ... */ };
struct B3 : B2 { /* ... */ };
struct B4 : B3 { /* ... */ };
struct B5 : B4 { /* ... */ };
struct D : B5 {
void f(int) const; //覆盖基类的 f()
void g(int); // 覆盖基类的 g()
virtual int h(); // 覆盖基类的 h()
};
这说明了三个错误,当它们出现在真实的类层次结构中时,它们并不明显,其中类 B0...B5 各自都有许多成员,并且分散在许多头文件中。这里:
• B0::f() 不是虚的,因此您无法覆盖它,只能隐藏它(§20.3.5)。
• D::g() 与 B0::g() 的参数类型不同,因此如果它覆盖了任何东西,它就不是虚函数 B0::g()。最有可能的是,D::g() 只是隐藏了 B0::g()。
• B0 中没有名为 h() 的函数,如果 D::h() 覆盖了任何东西,它就不是来自 B0 的函数。最有可能的是,它引入了一个全新的虚函数。
我没有向您展示 B1...B5 中的内容,所以也许由于这些类中的声明,发生了完全不同的事情。我个人不会(重复地)将虚函数用于要覆盖的函数。对于较小的程序(尤其是编译器对常见错误有适当的警告),正确完成覆盖并不难。但是,对于较大的层次结构,更具体的控制很实用:
• virtual:该函数可以重写(§20.3.2)。
• = 0:该函数必须是虚函数,并且必须重写(§20.4)。
• override:该函数旨在重写基类虚函数(§20.3.4.1)。
• final:该函数不应重写(§20.3.4.2)。
在缺乏任何这些控制的情况下,非static成员函数当且仅当它重写了基类的virtual函数(§20.3.2)才是虚函数。
编译器可以警告对显式覆盖控制的不一致使用。例如,对九个虚基类函数中的七个使用override的类声明可能会让维护者感到困惑。
20.3.4.1 override
我们可以明确地表达我们想要重写(覆盖)的期望:
struct D : B5 {
void f(int) const override; // 错: B0::f() 不是 virtual
void g(int) override; // 错: B0::f() 取double参数
virtual int h() override; // 错: 无 h() 可重写
};
给定此定义(并假设中间基类 B1...B5 不提供相关功能),所有三个声明都会出现错误。
在具有许多虚函数的大型或复杂类层次结构中,最好只使用 virtual 来引入新的虚函数,并对所有要用作覆盖函数的函数使用 override。使用 override 有点冗长,但可以明确程序员的意图。
override 指定符位于声明的最后,位于所有其他部分之后。例如:
void f(int) const noexcept override; // OK (若存在恰当的 f() 可重写)
override void f(int) const noexcept; // 语法错误
void f(int) override const noexcept; // 语法错误
是的,virtual 是前缀而 override 是后缀,这不合逻辑。这是我们几十年来为兼容性和稳定性付出的代价的一部分。
override 指定符不是函数类型的一部分,不能在类外定义中重复。例如:
class Derived : public Base {
void f() override; // OK 若 Base 有一个 virtual f()
void g() override; // OK 若 Base 有一个 virtual g()
};
void Derived::f() override // 错: override 在类外使用
{
// ...
}
void g() // OK
{
// ...
}
奇怪的是,override 不是一个关键字;它是所谓的上下文关键字。也就是说,override 在某些上下文中具有特殊含义,但在其他地方可以用作标识符。例如:
int override = 7;
struct Dx : Base {
int override;
int f() override
{
return override + ::override;
}
};
不要沉迷于这种小聪明;它会使维护变得复杂。override 是上下文关键字而不是普通关键字的唯一原因是,存在大量代码几十年来一直将 override 用作普通标识符。另一个上下文关键字是 final (§20.3.4.2)。
20.3.4.2 final
当我们声明成员函数时,我们可以在virtual和非virtual(默认)之间进行选择。对于希望派生类的编写者能够定义或重新定义的函数,我们使用virtual。我们根据类的含义(语义)进行选择:
• 我们能否想象需要进一步派生类?
• 派生类的设计者是否需要重新定义函数以实现合理的目标?
• 覆盖函数是否容易出错(即覆盖函数是否难以提供虚函数的预期语义)?
如果这三个问题的答案都是“否”,我们可以将函数保留为非virtual函数,以获得设计简单性,有时还可以获得一些性能提升(主要来自内联)。标准库中有很多这样的例子。
更罕见的是,我们有一个以虚函数开始的类层次结构,但在定义一组派生类之后,其中一个答案变成了“否”。例如,我们可以想象一种语言的抽象语法树,其中所有语言构造都被定义为从几个接口派生的具体节点类。如果我们改变语言,我们只需要派生一个新类。在这种情况下,我们可能希望阻止我们的用户覆盖虚函数,因为这种覆盖唯一能做的就是改变我们语言的语义。也就是说,我们可能希望我们的设计不让用户修改。例如:
struct Node { // 接口类
virtual Type type() = 0;
// ...
};
class If_statement : public Node {
public:
Type type() override final; // 阻止进一步重写
// ...
};
在实际的类层次结构中,通用接口(此处为 Node)和表示特定语言构造的派生类(此处为 If_statement)之间会存在多个中间类。但是,此示例的关键点在于 Node::type() 需要被覆盖(因此将其声明为virtual的),而其覆盖器 If_statement::type() 则不需要(因此将其声明为 final)。在对成员函数使用 final 后,它就不能再被其派生类覆盖,尝试覆盖将导致错误。例如:
class Modified_if_statement : public If_statement {
public:
Type type() override; // error : If_statement::type() is final
// ...
};
我们可以将类的每个virtual成员函数都设为 final;只需在类名后添加 final。例如:
class For_statement final : public Node {
public:
Type type() override;
// ...
};
class Modified_for_statement : public For_statement {
//错: For_statement 是final 的
Type type() override;
// ...
};
不管好坏,在类中添加 final 不仅可以防止覆盖,还可以防止从类中进一步派生。有些人使用 final 来尝试提高性能——毕竟,非virtual函数比virtual函数更快(在现代实现中可能快 25%),并且提供了更大的内联机会(§12.1.5)。但是,不要盲目使用 final 作为优化辅助;它会影响类层次结构设计(通常是负面的),并且性能改进很少显著。在声称效率提高之前,请进行一些认真的测量。在 final 能够清楚地反映您认为合适的类层次结构设计时使用 final。也就是说,使用 final 来反映语义需求。
final 指定符不是函数类型的一部分,并且不能在类外定义中重复使用。例如:
class Derived : public Base {
void f() final; // OK if Base has a virtual f()
void g() final; // OK if Base has a virtual g()
// ...
};
void Derived::f() final // error : final out of class
{
// ...
}
void g() final // OK
{
// ...
}
与 override(§20.3.4.1)一样,final 也是一个上下文关键字。也就是说,final 在一些上下文中具有特殊含义,但在其他情况下可用作普通标识符。例如:
int final = 7;
struct Dx : Base {
int final;
int f() final
{
return final + ::final;
}
};
同样,不要沉迷于这种小聪明;它会使维护变得复杂。 final 是上下文关键字而不是普通关键字的唯一原因是,存在大量代码几十年来一直将 final 用作普通标识符。另一个上下文关键字是 override(§20.3.4.1)。
20.3.5 using基成员(using Base Members)
函数不会跨作用域重载(§12.3.3)。例如:
struct Base {
void f(int);
};
struct Derived : Base {
void f(double);
};
void use(Derived d)
{
d.f(1); // call Derived::f(double)
Base& br = d
br.f(1); // call Base::f(int)
}
这可能会让人感到惊讶,有时我们希望通过重载来确保使用最匹配的成员函数。至于命名空间,可以使用 using 声明将函数添加到作用域。例如:
struct D2 : Base {
using Base::f; // 将Base类中所有的f带进类D2中
void f(double);
};
void use2(D2 d)
{
d.f(1); // call D2::f(int), 即, Base::f(int)
Base& br = d
br.f(1); // call Base::f(int)
}
这是类也被视为命名空间(§16.2)的简单结果。
使用多个 using 声明可以引入来自多个基类的名称。例如:
struct B1 {
void f(int);
};
struct B2 {
void f(double);
};
struct D : B1, B2 {
using B1::f;
using B2::f;
void f(char);
};
void use(D d)
{
d.f(1); // call D::f(int), that is, B1::f(int)
d.f('a'); // call D::f(char)
d.f(1.0); // call D::f(double), that is, B2::f(double)
}
我们可以将构造函数带入派生类作用域;参见 §20.3.5.1。通过使用声明带入派生类作用域的名称的访问权限由使用声明的位置决定;参见 §20.5.3。我们不能使用 using 指令将基类的所有成员带入派生类。
20.3.5.1 继承构造函数(Inheriting Constructors)
比如我想要一个与 std::vector 类似的 vector,但要保证作用域检查。我可以尝试这样做:
template<class T>
struct Vector : std::vector<T> {
T& operator[](size_type i) { check(i); return this−>elem(i); }
const T& operator[](size_type i) const { check(i); return this−>elem(i); }
void check(siz e_type i){if(this−>size()<i) throw rang e_error{"Vector::check() failed"}; }
};
不幸的是,我们很快就会发现这个定义相当不完整。例如:
Vector<int> v { 1, 2, 3, 5, 8 }; // 错: 无initializer-list 构造函数
快速检查将显示 Vector 未能从 std::vector 继承任何构造函数。
这并不是一个不合理的规则:如果一个类向基类添加数据成员或要求更严格的类不变量,那么继承构造函数将是一场灾难。然而,Vector 并没有做任何类似的事情。
我们通过简单地声明构造函数应该被继承来解决这个问题:
template<class T>
struct Vector : std::vector<T> {
using vector<T>::vector; // inherit constructors
T& operator=[](size_type i) { check(i); return this−>elem(i); }
const T& operator=(size_type i) const { check(i); return this−>elem(i); }
void check(siz e_type i) { if (this−>size()<i) throw Bad_index(i); }
};
Vector<int> v { 1, 2, 3, 5, 8 }; // OK: 使用来自std::vector 的initializer-list 构造函数
这种使用方式与普通函数的使用方式完全等同(§14.4.5,§20.3.5)。
如果您这样选择,您可以通过继承派生类中的构造函数来自找麻烦,在派生类中定义需要显式初始化的新成员变量:
struct B1 {
B1(int) { }
};
struct D1 : B1 {
using B1::B1; // 隐匿声明D1(int)
string s; // string 有一个默认构造函数
int x; // 我们“忘记”提供x的初始化
};
void test()
{
D1 d {6}; // oops: d.x 未初始化
D1 e; // 错: D1 无构造函数
}
D1::s 被初始化而 D1::x 没有被初始化的原因是继承构造函数相当于仅初始化基类的构造函数。在这种情况下,我们可能等效地写成:
struct D1 : B1 {
D1(int i) : B1(i) { }
string s; // string 有默认构造函数
int x; // 我们“忘记”提供x的初始化
};
取出脚上子弹的一种方法是添加类内成员初始化器(§17.4.4):
struct D1 : B1 {
using B1::B1; // 隐匿声明 D1(int)
int x {0}; // note: x 有初始化
};
void test()
{
D1 d {6}; // d.x 是 0
}
大多数情况下,最好避免耍小聪明,将继承构造函数的使用限制在不需要添加数据成员的简单情况下。
20.3.6 返回类型放宽(Return Type Relaxation)
有一个规则可以放宽,即重写函数的类型必须与其重写的虚函数的类型相同。也就是说,如果原返回类型是 B∗,那么重写函数的返回类型可以是 D∗,前提是 B 是 D 的公共基类。类似地,返回类型 B& 可以放宽为 D&。这有时称为协变返回规则(covariant return rule)。
这种放宽仅适用于返回类型为指针或引用的返回类型,而不适用于“智能指针”,例如unique_ptr(§5.2.1)。特别是,对于参数类型,没有类似的规则放宽,因为这会导致类型违规。
考虑一个表示不同类型表达式的类层次结构。除了用于操作表达式的操作之外,基类 Expr 还将提供用于创建各种表达式类型的新表达式对象的工具:
class Expr {
public:
Expr(); //default constructor
Expr(const Expr&); // 复制构造函数
virtual Expr∗ new_expr() =0;
virtual Expr∗ clone() =0;
// ...
};
其思想是,new_expr() 会生成表达式类型的默认对象,而 clone() 会生成该对象的副本。两者都会返回从 Expr 派生的某个特定类的对象。它们永远不能只返回“普通 Expr”,因为 Expr 被刻意且恰当地声明为抽象类。
派生类可以重写 new_expr() 和/或 clone() 来返回其自己类型的对象:
class Cond : public Expr {
public:
Cond();
Cond(const Cond&);
Cond∗ new_expr() override { return new Cond(); }
Cond∗ clone() override { return new Cond(∗this); }
// ...
};
这意味着,给定一个 Expr 类的对象,用户可以创建一个“完全相同类型”的新对象。例如:
void user(Expr∗ p)
{
Expr∗ p2 = p−>new_expr();
// ...
}
分配给 p2 的指针被声明为指向“普通 Expr”,但它将指向从 Expr 派生类型的对象,例如 Cond。
Cond::new_expr() 和 Cond::clone() 的返回类型是 Cond∗ 而不是 Expr∗。这样可以克隆 Cond 而不会丢失类型信息。类似地,派生类 Addition 会有一个 clone() 返回 Addition∗。例如:
void user2(Cond∗ pc, Addition∗ pa)
{
Cond∗ p1 = pc−>clone();
Addition∗ p2 = pa−>clone();
// ...
}
如果我们对 Expr 使用 clone(),我们只知道结果是 Expr∗:
void user3(Cond∗ pc, Expr∗ pe)
{
Cond∗ p1 = pc−>clone();
Cond∗ p2 = pe−>clone(); // 错: Expr ::clone() 返回一个Expr*
// ...
}
由于 new_expr() 和 clone() 等函数是virtual的,并且它们(间接)构造对象,因此它们通常被称为虚构造函数。每个函数都只是使用构造函数来创建合适的对象。
要创建对象,构造函数需要知道要创建的对象的确切类型。因此,构造函数不能是virtual的。此外,构造函数不完全是一个普通函数。特别是,它以普通成员函数无法实现的方式与内存管理例程交互。因此,您不能将指向构造函数的指针传递给对象创建函数。
这两个限制都可以通过定义一个调用构造函数并返回构造对象的函数来规避。这很幸运,因为在不知道其确切类型的情况下创建新对象通常很有用。Ival_box_maker(§21.2.4)就是专门为此设计的类的一个示例。
20.4 抽象类
许多类与 Employee 类相似,因为它们本身、作为派生类的接口以及作为派生类实现的一部分都很有用。对于此种类,§20.3.2 中描述的技术就足够了。但是,并非所有类都遵循该模式。某些类(例如 Shape 类)表示抽象概念,对象无法存在。Shape 类只有作为从其派生的某个类的基类才有意义。从无法为其虚函数提供合理定义这一事实可以看出这一点:
class Shape {
public:
virtual void rotate(int) { throw runtime_error{"Shape::rotate"}; } // inelegant
virtual void draw() const { throw runtime_error{"Shape::draw"}; }
// ...
};
尝试制作这种未指定类型的形状是愚蠢的,但却合法:
Shape s; // 愚蠢: “无形之形”
这很愚蠢,因为对 s 的每个操作都会导致错误。
一个更好的选择是将 Shape 类的虚函数声明为纯虚函数。虚函数通过“伪初始化器” = 0 而“变成纯函数”:
class Shape { // abstract class
public:
virtual void rotate(int) = 0; // pure virtual function
virtual void draw() const = 0; // pure virtual function
virtual bool is_closed() const = 0; // pure virtual function
// ...
virtual˜Shape(); //virtual
};
具有一个或多个纯虚函数的类是抽象类,并且不能创建该抽象类的对象:
Shape s; // 错: 抽象类Shape类对象
抽象类旨在作为通过指针和引用访问的对象的接口(以保留多态行为)。因此,抽象类通常需要具有虚析构函数(§3.2.4,§21.2.2)。由于抽象类提供的接口不能用于使用构造函数创建对象,因此抽象类通常没有构造函数。
抽象类只能用作其他类的接口。例如:
class Point { /* ... */ };
class Circle : public Shape {
public:
void rotate(int) override { }
void draw() const override;
bool is_closed() const override { return true; }
Circle(Point p, int r);
private:
Point center;
int radius;
};
未在派生类中定义的纯虚函数仍为纯虚函数,因此派生类也是抽象类。这使我们能够分阶段构建实现:
class Polygon : public Shape { // 抽象类
public:
bool is_closed() const override { return true; }
// ... draw()和rotate()未被覆盖 ...
};
Polygon b {p1,p2,p3,p4}; // 错: 抽象类Polygon 对象声明
Polygon 仍然是抽象的,因为我们没有覆盖 draw() 和 rotate()。只有完成这些之后,我们才有一个可以创建对象的类:
class Irregular_polygon : public Polygon {
list<Point> lp;
public:
Irregular_polygon(initializ er_list<Point>);
void draw() const override;
void rotate(int) override;
// ...
};
Irregular_polygon poly {p1,p2,p3,p4}; // 假设p1 .. p4 是在某处定义的点
抽象类提供接口而不公开实现细节。例如,操作系统可能会将其设备驱动程序的细节隐藏在抽象类后面:
class Character_device {
public:
virtual int open(int opt) = 0;
virtual int close(int opt) = 0;
virtual int read(char∗ p, int n) = 0;
virtual int write(const char∗ p, int n) = 0;
virtual int ioctl(int ...) = 0; //device I/O control
virtual ˜Character_device() { } // virtual析构函数
};
然后,我们可以将驱动程序指定为从 Character_device 派生的类,并通过该接口操作各种驱动程序。
抽象类支持的设计风格称为接口继承,与具有状态和/或已定义成员函数的基类支持的实现继承不同。两种方法可以组合使用。也就是说,我们可以定义和使用具有状态和纯虚函数的基类。然而,这种方法的混合可能会造成混淆,需要格外小心。
随着抽象类的引入,我们拥有了以模块化方式使用类作为构建块编写完整程序的基本功能。
20.5 访问控制
类的成员可以是private的、protected的或public的:
• 如果它是private的,则其名称只能由声明它的类的成员函数和友函数使用。
• 如果它是protected的,则其名称只能由声明它的类的成员函数和友函数以及从该类派生的类的成员函数和友函数使用(参见§19.4)。
• 如果它是public的,则其名称可由任何函数使用(包括参访问该类对象的任意函数)。
这反映了这样一种观点:访问类的函数有三种:实现类的函数(其友类和成员)、实现派生类的函数(派生类的友函数和成员函数)和其他函数。这可以用图形表示:
访问控制统一应用于名称。名称所指的内容不会影响对其使用的控制。这意味着我们可以拥有private成员函数、类型、常量等,以及private数据成员。例如,高效的非侵入式(nonintrusive)列表类通常需要数据结构来跟踪元素。如果列表不需要修改其元素(例如,通过要求元素类型具有链接字段),则列表是非侵入式的。用于组织列表的信息和数据结构可以保持private:
template<class T>
class List {
public:
void insert(T);
T get();
// ...
private:
struct Link { T val; Link∗ next; };
struct Chunk {
enum { chunk_siz e = 15 };
Link v[chunk_siz e];
Chunk∗ next;
};
Chunk∗ allocated;
Link∗ free;
Link∗ get_free();
Link∗ head;
};
公共函数的定义非常简单:
template<class T>
void List<T>::insert(T val)
{
Link∗ lnk = get_free();
lnk−>val = val;
lnk−>next = head;
head = lnk;
}
template<class T>
T List<T>::g et()
{
if (head == 0)
throw Underflow{}; // Underflow 是我的异常类
Link∗ p= head;
head = p−>next;
p−>next = free;
free = p;
return p−>val;
}
通常,支持函数(这里是私有函数)的定义有点棘手:
template<class T>
typename List<T>::Link∗ List<T>::get_free()
{
if (free == 0) {
// ... 分配一个新的块并将其链接放在空闲列表中: ...
}
Link∗ p = free;
free = free−>next;
return p;
}
通过在成员函数定义中输入 List<T>:: 来进入 List<T> 作用域。但是,由于 get_free() 的返回类型是在 List<T>::get_free() 名称之前提到的,因此必须使用全名 List<T>::Link 而不是缩写 Link。另一种方法是使用返回类型的后缀表示法(§12.1.4):
template<class T>
auto List<T>::get_free() −> Link∗
{
// ...
}
非成员函数(友函数除外)没有这样的访问权限:
template<typename T>
void would_be_meddler(List<T>∗ p)
{
List<T>::Link∗ q = 0; // 错误 : List<T>::Link 是 private的
// ...
q = p−>free; //错误 : List<T>::free 是 private的
// ...
if (List<T>::Chunk::chunk_siz e > 31) {
// 错: List<T>::Chunk::chunk_size 是private
// ...
}
}
在类中,成员默认是private的;在结构中,成员默认是public的(§16.2.4)。
使用成员类型的明显替代方法是将类型放在环绕的命名空间中。例如:
template<class T>
struct Link2 {
T val;
Link2∗ next;
};
template<class T>
class List {
private:
Link2<T>∗ free;
// ...
};
Link 使用 List<T> 的参数 T 隐式参数化。对于 Link2,我们必须明确这一点。
如果成员类型不依赖于所有模板类的参数,则非成员版本可能是更可取的;参见§23.4.6.3。
如果嵌套类本身通常没有什么用,而封闭类需要访问其表示,则将成员类声明为friend(§19.4.2)可能是一个好主意:
template<class T> class List;
template<class T>
class Link3 {
friend class List<T>; // 只有 List<T> 可访问Link<T>
T val;
Link3∗ next;
};
template<class T>
class List {
private:
Link3<T>∗ free;
// ...
};
编译器可以使用单独的访问指定符对类的各个部分进行重新排序(§8.2.6)。例如:
class S {
public:
int m1;
public:
int m2;
};
编译器可能会决定在 S 对象的布局中让 m2 位于 m1 之前。这种重新排序可能会让程序员感到意外,并且依赖于实现,因此,如果没有充分理由,请不要对数据成员使用多个访问指定符。
20.5.1 protected成员
在设计类层次结构时,我们有时会提供一些专为派生类实现者使用而非一般用户使用的函数。例如,我们可以为派生类实现者提供(高效)未经验证的访问函数,为其他实现者提供(安全)经过验证的访问函数。将未经验证的版本声明为 protected 即可实现这一点。例如:
class Buffer {
public:
char& operator[](int i); // 已验证的访问
// ...
protected:
char& access(int i); // 未验证的访问
// ...
};
class Circular_buffer : public Buffer {
public:
void reallocate(char∗ p, int s); // 更改位置和大小
// ...
};
void Circular_buffer::reallocate(char∗ p, int s)// 更改位置和大小
{
// ...
for (int i=0; i!=old_sz; ++i)
p[i] = access(i); // 无多余验证,因为在派生类的函数中
// ...
}
void f(Buffer& b)
{
b[3] = 'b'; // OK (已验证)
b.access(3) = 'c'; // 错: Buffer ::access() 是 protected的
}
对于另一个例子,参见 §21.3.5.2.中的Window_with_border 。
派生类只能针对其自身类型的对象访问基类的受保护成员:
class Buffer {
protected:
char a[128];
// ...
};
class Linked_buffer : public Buffer {
// ...
};
class Circular_buffer : public Buffer {
// ...
void f(Linked_buffer∗ p)
{
a[0] = 0; // OK: 访问Circular_buffer自己的protected 成员
p−>a[0] = 0; // 错: 访问不同类型(另一个类对象)的protected成员
}
};
这样可以防止当一个派生类破坏属于其他派生类的数据时发生的细微错误。
20.5.1.1 使用protected 成员
简单的私有/公有数据隐藏模型很好地服务于具体类型(§16.3)的概念。但是,当使用派生类时,类有两种用户:派生类和“普通公有”。实现类操作的成员和友函数代表这些用户对类对象进行操作。私有/公有模型允许程序员清楚地区分实现者和普通公众,但它没有提供专门针对派生类的方法。
声明为protected的成员比声明为private的成员更容易被滥用。特别是,声明数据成员为protected通常是一个设计错误。将大量数据放在一个公用类中供所有派生类使用,会使这些数据容易遭受破坏。更糟糕的是,受保护的数据与公有数据一样,无法轻易重组,因为没有很好的方法来找到每个用途。因此,受保护的数据成为软件维护问题。
幸运的是,您不必使用受保护的数据;private是类中的默认设置,通常是更好的选择。根据我的经验,总是有其他方法可以将大量信息放在public基类中,供派生类直接使用。
然而,对于受保护的成员函数来说,这些反对意见都不重要;protected是一种指定用于派生类的操作的很好的方式。§21.2.2 中的 Ival_slider 就是一个例子。如果在这个例子中实现类是private的,进一步的派生将是不可行的。另一方面,使用提供实现细节的基类public会导致错误和误用。
20.5.2 访问基类成员
与类成员一样,基类也可以声明为private、protected或public。例如:
class X : public B { /* ... */ };
class Y : protected B { /* ... */ };
class Z : private B { /* ... */ };
不同的访问指定符满足不同的设计需求:
• public派生使派生类成为其基类的子类型。例如,X 是 B 的一种。这是最常见的派生形式。
• private基类在定义类时最有用,通过将接口限制为基类,以便提供更强的保证。例如,B 是 Z 的实现细节。§25.3 中的 Vector 指针模板向其 Vector<void∗> 基类添加类型检查就是一个很好的例子。
• protected基类在类层次结构中很有用,其中进一步派生是常态。与private派生一样,受保护的派生用于表示实现细节。§21.2.2 中的 Ival_slider 就是一个很好的例子。
基类的访问指定符可以省略。在这种情况下,基类默认为private基类,结构体默认为public基类。例如:
class XX : B { /* ... */ }; // B is a private base
struct YY : B { /* ... */ }; // B is a public base
人们期望基类是public(即表达子类型关系),因此对于类来说,基类缺少访问指定符可能会令人惊讶,但对于结构来说则不会令人惊讶。
基类的访问指定符控制对基类成员的访问以及从派生类类型到基类类型的指针和引用的转换。考虑从基类 B 派生的类 D:
• 如果 B 是private基类,则其公共成员和受保护成员只能由 D 的成员函数和友函数使用。只有 D 的友函数和成员才能将 D∗ 转换为 B∗。
• 如果 B 是protected基类,则其公共成员和受保护成员只能由 D 的成员函数和友函数以及从 D 派生的类的成员函数和友函数使用。只有 D 的友函数和成员以及从 D 派生的类的友元和成员才能将D∗ 转换为 B∗。
• 如果 B 是public基类,则其公共成员可由任何函数使用。此外,其受保护成员可由 D 的成员和友函数以及从 D 派生的类的成员和友函数使用。任何函数都可以将 D∗ 转换为 B∗。
这基本上重述了成员访问的规则(§20.5)。在设计类时,我们选择基类的访问方式与选择访问成员的方式相同。有关示例,请参阅§21.2.2 中的 Ival_slider。
20.5.2.1 多继续和访问控制
在一个多重继承的栅格图(§21.3)中,如果一个基类可以通过多条路径到达基类的名称,则只要可以通过任何路径访问,即可访问该基类的名称。例如:
struct B {
int m;
static int sm;
// ...
};
class D1 : public virtual B { /* ... */ } ;
class D2 : public virtual B { /* ... */ } ;
class D12 : public D1, private D2 { /* ... */ };
D12∗ pd = new D12;
B∗ pb = pd; // OK: 通过D1访问
int i1 = pd−>m; // OK: 通过 D1
如果可以通过多条路径到达单个实体,我们仍然可以毫无歧义地引用它。例如:
class X1 : public B { /* ... */ } ;
class X2 : public B { /* ... */ } ;
class XX : public X1, public X2 { /* ... */ };
XX∗ pxx = new XX;
int i1 = pxx−>m; // 错, 歧义: XX::X1::B::m 还是 XX::X2::B::m?
int i2 = pxx−>sm; // OK:在一个 XX 中仅存在一个 B::sm (sm 是一个static成员)
20.5.3 using声明和访问控制
using声明(§14.2.2,§20.3.5)不能用于获取其他信息。它只是一种使可访问信息更易于使用的机制。另一方面,一旦获得访问权限,就可以将其授予其他用户。例如:
class B {
private:
int a;
protected:
int b;
public:
int c;
};
class D : public B {
public:
using B::a; // 错误 : B::a 是 private 的
using B::b; // 使B::b通过D公有可获得
};
当 using 声明与 private 或 protected 派生结合使用时,它可用于指定类通常提供的部分(但不是全部)设施的接口。例如:
class BB : private B {//获得对 B::b 和 B::c的访问权,但不能获得对 B::a的访问权
public:
using B::b;
using B::c;
};
(译注:using声明可以使用protected成员获得public访问权,但不能修改private访问权。)
另见 §20.3.5.
20.6 类成员指针(指向类成员的指针)
指向成员的指针是一种类似偏移量的结构构,允许程序员间接引用类的成员。运算符 −>∗ 和 .∗ 可以说是最专业且使用最少的 C++ 运算符。使用 −>,我们可以通过命名类 m 来访问它的成员:p−>m。使用 −>∗,我们可以访问(在概念上)将其名称存储在指向成员的指针 ptom 中的成员:p−>∗ptom。这使我们能够通过将成员名称作为参数传递来访问成员。在这两种情况下,p 必须是指向适当类的对象的指针。
指向成员的指针不能分配给 void∗ 或任何其他普通指针。空指针(例如,nullptr)可以分配给指向成员的指针,然后表示“无成员”。
20.6.1 类函数成员指针(指向类的函数成员的指针)
许多类提供了简单、非常通用的接口,旨在以几种不同的方式调用。例如,许多“面向对象”的用户接口定义了一组请求,屏幕上显示的每个对象都应准备好响应这些请求。此外,这些请求可以直接或间接地从程序中呈现。考虑这个思想的一个简单变体:
class Std_interface {
public:
virtual void start() = 0;
virtual void suspend() = 0;
virtual void resume() = 0;
virtual void quit() = 0;
virtual void full_size() = 0;
virtual void small() = 0;
virtual ˜Std_interface() {}
};
每个操作的确切含义由调用该操作的对象定义。通常,在发出请求的人或程序与接收请求的对象之间有一层软件。理想情况下,这些中间软件层不必知道有关 resume() 和 full_size() 等单个操作的任何信息。如果知道,则每次操作发生变化时都必须更新中间层。因此,这些中间层只是将表示要调用的操作的数据从请求源传输到接收者。
一种简单的方法是发送一个表示要调用的操作的string。例如,要调用 suspend(),我们可以发送字符串“suspend”。但是,必须有人创建该字符串,并且必须有人对其进行解码以确定它对应于哪个操作(如果有)。通常,这看起来是间接和乏味的。相反,我们可能只是发送一个表示操作的整数。例如,2 可能用于表示 suspend()。但是,虽然整数可能便于机器处理,但对于人来说却相当晦涩难懂。我们仍然必须编写代码来确定 2 表示 suspend() 并调用 suspend()。
但是,我们可以使用指向成员的指针间接引用类的成员。考虑一下 Std_interface。如果我想为某个对象调用 suspend() 而不直接提及 suspend(),我需要一个指向成员的指针来引用 Std_interface::suspend()。我还需要一个指向我想要挂起的对象的指针或引用。考虑一个简单的例子:
using Pstd_mem = void (Std_interface::∗)(); // 成员类型指针
void f(Std_interface∗ p)
{
Pstd_mem s = &Std_interface::suspend; // 指向suspend()的指针
p−>suspend(); //直接调用
p−>∗s(); //通过指针调用成员 (译注:VC++ 中为 (p−>∗s)())
}
通过将地址运算符 & 应用于完全限定的类成员名称(例如 &Std_interface::suspend),可以获取指向成员的指针。使用 X::∗ 形式的声明符声明类型为“指向类 X 的成员的指针”的变量。
使用别名来弥补 C 声明符语法的可读性不足是典型的做法。但是,请注意 X::∗ 声明符与传统的 ∗ 声明符完全匹配。
指向成员 m 的指针可以与对象组合使用。运算符 −>∗ 和 .∗ 允许程序员表达此类组合。例如,p−>∗m 将 m 绑定到 p 指向的对象,而 obj.∗m 将 m 绑定到对象 obj。结果可以根据 m 的类型使用。无法存储 −>∗ 或 .∗ 操作的结果以供以后使用。
当然,如果我们知道要调用哪个成员,我们会直接调用它,而不是弄乱指向成员的指针。就像普通的函数指针一样,当我们需要引用一个函数而不必知道它的名字时,就会使用指向成员函数的指针。然而,指向成员的指针并不是指向一块内存的指针,就像指向变量的指针或指向函数的指针一样。它更像是结构的偏移量或数组的索引,但显然,实现会考虑数据成员、虚函数、非虚函数等之间的差异。当指向成员的指针与指向正确类型的对象的指针组合时,它会产生一些可以识别特定对象的特定成员的东西。
p−>∗s() 调用可以用图形表示如下:
因为指向虚成员的指针(本例中为 s)是一种偏移量,所以它不依赖于对象在内存中的位置。因此,只要在两个地址空间中使用相同的对象布局,就可以在不同的地址空间之间传递指向虚成员的指针。与指向普通函数的指针一样,指向非虚成员函数的指针不能在地址空间之间交换。
请注意,通过函数指针调用的函数可以是virtual的。例如,当我们通过函数指针调用 suspend() 时,我们会获得该函数指针所应用对象的正确 suspend()。这是函数指针的一个基本方面。
编写解释器时,我们可能会使用指向成员的指针来调用以字符串形式呈现的函数:
map<string,Std_interface∗> variable;
map<string,Pstd_mem> operation;
void call_member(string var, string oper)
{
(variable[var]−>∗operation[oper])(); // var.oper()
}
static成员不与特定对象关联,因此指向static成员的指针只是一个普通指针。例如:
class Task {
// ...
static void schedule();
};
void (∗p)() = &Task::schedule; // OK
void (Task::∗ pm)() = &Task::schedule; // 错 : 普通指针赋值给类成员指针
指向数据成员的指针在§20.6.2 中描述。
20.6.2 类数据成员指针(指向类的数据成员的指针)
很自然地,指向类成员的指针的概念适用于类数据成员以及具有参数和返回类型的类函数成员。例如:
struct C {
const char∗ val;
int i;
void print(int x) { cout << val << x << '\n'; }
int f1(int);
void f2();
C(const char∗ v) { val = v; }
};
using Pmfi = void (C::∗)(int); // 指向C的函数成员的指针,带一个int参数
using Pm = const char∗ C::∗; //指向C的char* 数据成员
void f(C& z1, C& z2)
{
C∗ p = &z2;
Pmfi pf = &C::print;
Pm pm = &C::val;
z1.print(1);
(z1.∗pf)(2);
z1.∗pm = "nv1 ";
p−>∗pm = "nv2 ";
z2.print(3);
(p−>∗pf)(4);
pf = &C::f1; // 错 : 返回类型不匹配
pf = &C::f2; // 错: 参数类型不匹配
pm = &C::i; // 错: 类型不匹配
pm = pf; // 错: 类型不匹配
}
检查指向函数的指针的类型就像检查其他类型一样。
20.6.3 基类和派生类成员
派生类至少具有从其基类继承的成员。通常它有更多。这意味着我们可以安全地将指向基类成员的指针分配给指向派生类成员的指针,但不能反过来。此属性通常称为逆变(contravariance)。例如:
class Text : public Std_interface {
public:
void start();
void suspend();
// ...
virtual void print();
private:
vector s;
};
void (Std_interface::∗ pmi)() = &Text::print; // 错
void (Text::∗pmt)() = &Std_interface::start; // OK
这个逆变规则似乎与我们可以将指向派生类的指针赋值给指向其基类的指针的规则相反。事实上,这两条规则都是为了维护一个基本保证,即指针永远不会指向不具备指针所承诺的属性的对象。在这种情况下,Std_interface::∗ 可以应用于任何 Std_interface,并且大多数此类对象可能不是 Text 类型。因此,它们没有我们试图用来初始化 pmi 的成员 Text::print。通过拒绝初始化,编译器使我们免于运行时错误。
20.7 建议
[1] 避免使用类型字段;§20.3.1。
[2] 通过指针和引用访问多态对象;§20.3.2。
[3] 使用抽象类将设计重点放在提供干净的接口上;§20.4。
[4] 使用 override 在大型类层次结构中明确覆盖;§20.3.4.1。
[5] 谨慎使用 final;§20.3.4.2。
[6] 使用抽象类指定接口;§20.4。
[7] 使用抽象类将实现细节排除在接口之外;§20.4。
[8] 具有虚函数的类应该具有虚析构函数;§20.4。
[9] 抽象类通常不需要构造函数;§20.4。
[10] 优先使用私有成员来实现细节;§20.5。
[11] 优先使用公共成员来实现接口;§20.5。
[12] 仅在真正需要时谨慎使用受保护成员;§20.5.1.1。
[13] 不要将数据成员声明为受保护;§20.5.1.1。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup