第22章 运行时信息(Run-Time Type Information)
Premature optimization
is the root of all evil.
(过早优化是万恶之源。)
--------------------------------------------------- Donald Knuth
On the other hand,
we cannot ignore efficiency.
(在另一方面,我们也不能忽视效率。)
–-------------------------------------------------- Jon Bentley
目录
22.2.1.1 dynamic_cast到引用(dynamic_cast to Reference)
22.2.2 多继承(Multiple Inheritance)
22.2.3 static_cast 和 dynamic_cast
22.2.4 覆盖接口(Recovering an Interface)
22.3 双重派发和访问者(Double Dispatch and Visitors)
22.4 构造和析构(Construction and Destruction)
22.5 类型识别(Type Identification)
22.5.1 扩展类型信息(Extended Type Information)
22.6 RTTI的使用和误用(Uses and Misuses of RTTI)
22.1 引言(Introduction)
一般来说,类是由基类网格(lattice)类组成的。这样的类网格通常称为类层级结构。我们尝试设计类,以便用户不必过分担心类是由其他类组成的这种方式。特别是,虚调用机制确保当我们在对象上调用函数 f() 时,无论层级结构中的哪个类提供了用于调用的 f() 声明,还是哪个类定义了它,都会调用同一个函数。本章介绍如何在仅已知基类提供的接口的情况下获取有关整个对象的信息。
22.2 类层级结构导航(Class Hierarchy Navigation)
§21.2 中定义的 Ival_box 的一个合理用途是将它们交给控制屏幕的系统,并让该系统在发生某些活动时将对象交还给应用程序。我们将控制屏幕的 GUI 库和操作系统设施的组合称为系统。在系统和应用程序之间来回传递的对象通常称为小部件或控件。这就是许多用户界面的工作方式。从语言的角度来看,重要的是系统不知道我们的 Ival_box。系统的接口是根据系统自己的类和对象而不是我们应用程序的类来指定的。这是必要且适当的。然而,它确实有一个不愉快的影响,即我们丢失了有关传递给系统并随后返回给我们的对象类型的信息。
恢复“丢失”的对象类型需要我们以某种方式要求对象显示其类型。对对象的任何操作都需要我们拥有适合该对象的指针或引用。因此,在运行时检查对象类型的最明显和最有用的操作是类型转换操作,如果对象是预期类型,则返回有效指针,如果不是,则返回空指针。 dynamic_cast 运算符正是这样做的。例如,假设“系统”使用指向 BBwindow 的指针调用 my_event_handler(),其中发生了活动。然后我可能会使用 Ival_box 的 do_something() 调用我的应用程序代码:
void my_event_handler(BBwindow∗ pw)
{
if (auto pb = dynamic_cast<Ival_box∗>(pw)) { // pw 指向 Ival_box吗?
// ...
int x = pb−>get_value(); // 使用Ival_box
// ...
}
else {
// ... oops! 处理不可预料事件 ...
}
}
解释这里发生的事情的一种方法是 dynamic_cast 从面向实现的用户界面系统语言转换为应用程序语言。重要的是要注意此示例中未提及的内容:对象的实际类型。对象将是一种特定类型的 Ival_box,例如 Ival_slider,由一种特定类型的 BBwindow(例如 BBslider)实现。在“系统”和应用程序之间的这种交互中,没有必要也不希望明确说明对象的实际类型。存在一个接口来表示交互的基本要素。特别是,设计良好的界面会隐藏不必要的细节。
从图形上看,pb = dynamic_cast<Ival_box∗>(pw) 的动作可以表示如下:
来自 pw 和 pb 的箭头表示传递到对象的指针,而其余的箭头表示传递的对象的不同部分之间的继承关系。
使用运行时类型信息通常称为“运行时类型信息”,通常缩写为 RTTI。
从基类到派生类的转换通常称为向下转换,因为继承树的绘制惯例是从根向下生长的。类似地,从派生类到基类的转换称为向上转换。从基类到兄弟类的转换,例如从 BBwindow 到 Ival_box 的转换,称为交叉转换。
22.2.1 动态类型转换符:dynamic_cast
dynamic_cast 运算符接受两个操作数:一个用 < 和 > 括起来的类型,以及一个用 ( 和 ) 括起来的指针或引用。首先考虑指针的情况:
dynamic_cast<T∗>(p)
若 p 是类型 T∗ 或者类型 D∗ (其中,T 是 D 的一个基类),则结果就恰好像我们简单地将 p 赋值给 T∗ 一样。 例如:
class BB_ival_slider : public Ival_slider, protected BBslider {
// ...
};
void f(BB_ival_slider∗ p)
{
Ival_slider∗ pi1 = p; // OK
Ival_slider∗ pi2 = dynamic_cast<Ival_slider∗>(p); // OK
BBslider∗ pbb1 = p; // 错: BBslider 是一个保护基类(译注:不能在类外直接使用)
BBslider∗ pbb2 = dynamic_cast<BBslider∗>(p); // OK: pbb2 成为 nullptr
//译注:上面这行语句在我的编译环撞报错,上面句子应该指转换报错的情况:
// warning C4540: dynamic_cast 用于转换为不可访问或不明确的基;运行时测试将失败//(“BB_ival_slider *”到“BBslider *”)
//: error C2683: “dynamic_cast”:“BB_ival_slider”不是多态类型
}
译注:下列演示向下(基类向派生类)转换时才能演示返回 nullptr的情况:
// dynamic_cast_3.cpp
// compile with: /c /GR
class B { virtual void f();};
class D : public B { virtual void f();};
void f() {
B* pb = new D; // unclear but ok
B* pb2 = new B;
D* pd = dynamic_cast<D*>(pb); // ok: pb actually points to a D
D* pd2 = dynamic_cast<D*>(pb2); // pb2 points to a B not a D
}
其中,D* pd2 = dynamic_cast<D*>(pb2); 执行后返回 nullptr. 因为 pb2 实指指向的是 B 的对象。
这个(向上转型)是无趣的案例。但是,令人放心的是,dynamic_cast 不允许意外违反私有和受保护基类的保护(译注:这是与C语言风格的强制转换的主要区别,因此涉及强制类对象指针转换时推荐这种方法)。由于用作向上转型的 dynamic_cast 与简单赋值完全相同,因此它不涉及任何开销,并且对其词法上下文很敏感。
dynamic_cast 的目的是处理编译器无法确定转换正确性的情况。在这种情况下,dynamic_cast<T∗>(p) 查看 p 指向的对象(如果有)。如果该对象属于 T 类或具有唯一的 T 类型基类,则 dynamic_cast 返回指向该对象的 T∗ 类型指针;否则,返回 nullptr。如果 p 的值为 nullptr,则 dynamic_cast<T∗>(p) 返回 nullptr。请注意,转换必须是到唯一标识的对象的要求。可以构造转换失败并返回 nullptr 的示例,因为 p 指向的对象具有多个表示 T 类型基数的子对象(§22.2)。
dynamic_cast 需要一个指向多态类型的指针或引用,以便进行向下转换或交叉转换。例如:
class My_slider: public Ival_slider { // 多态基类(Ival_slider 具有虚函数)
// ...
};
class My_date : public Date { // 基类无多态 (Date 没有虚函数)
// ...
};
void g(Ival_box∗ pb, Date∗ pd)
{
My_slider∗ pd1 = dynamic_cast<My_slider∗>(pb); // OK
My_date∗ pd2 = dynamic_cast<My_date∗>(pd); // 错: Date 没有多态
}
要求指针的类型具有多态性简化了 dynamic_cast 的实现,因为它可以轻松找到保存有关对象类型的必要信息的位置。典型的实现将通过将指向类型信息的指针放置在对象类的虚函数表中(§3.2.3),将“类型信息对象”(§22.5)附加到对象。例如:
虚线箭头表示偏移量,仅给出指向多态子对象的指针即可找到完整对象的起始位置。很明显,dynamic_cast 可以高效实现。所涉及的只是对表示基类的 type_info 对象进行一些比较;无需昂贵的查找或字符串比较。
从逻辑角度来看,将 dynamic_cast 限制为多态类型也是有意义的。也就是说,如果一个对象没有虚函数,那么在不知道其确切类型的情况下就无法安全地对其进行操作。因此,应注意不要将这样的对象置于其类型未知的上下文中。如果知道其类型,我们就不需要使用 dynamic_cast。
dynamic_cast 的目标类型不需要是多态的。这允许我们将具体类型包装在多态类型中,例如,通过目标 I/O 系统进行传输(§22.2.4),然后稍后“解开(unwrap)”具体类型。例如:
class Io_obj { //目标 I/O 系统基类
virtual Io_obj∗ clone() = 0;
};
class Io_date : public Date, public Io_obj { };
void f(Io_obj∗ pio)
{
Date∗ pd = dynamic_cast<Date∗>(pio);
// ...
}
一个 dynamic_cast 到 void∗ 的转换可用于确定多态类型对象的起始地址。例如:
void g(Ival_box∗ pb, Date∗ pd)
{
void∗ pb2 = dynamic_cast<void∗>(pb); // OK
void∗ pd2 = dynamic_cast<void∗>(pd); // 错: Date 不是 polymorphic
}
派生类对象中表示基类的对象(例如 Ival_box)不一定是该对象中最底层派生类的第一个子对象。因此,pb 不一定与 pb2 拥有相同的地址。
此类强制类型转换仅用于与非常低级的函数交互(只有此类函数处理 void∗)。没有来自 void∗ 的 dynamic_cast(因为无法知道在哪里找到 vptr;§22.2.3)。
22.2.1.1 dynamic_cast到引用(dynamic_cast to Reference)
要获得多态行为,必须通过指针或引用来操作对象。当 dynamic_cast 用于指针类型时,nullptr 表示失败。这对于引用来说既不可行也不理想。
已经一个指针结果,我们必须考虑结果为 nullptr 的可能性,即指针不指向对象。因此,应始终明确测试指针的 dynamic_cast 的结果。对于指针 p,dynamic_cast<T∗>(p) 可以看作是问题“p 指向的对象(如果有)是否为 T 类型?”例如:
void fp(Ival_box∗ p)
{
if (Ival_slider∗ is = dynamic_cast<Ival_slider∗>(p)) { // p 指向Ival_slider吗 ?
// ... use is ...
}
else {
// ... *p 不是 slider指针; 处理替代方案 ...
}
}
在另一方面,我们可以合理地假设引用指向对象(§7.7.4)。因此,引用 r 的dynamic_cast<T&>(r) 不是一个问题,而是一个断言:“r 引用的对象属于 T 类型。”引用的 dynamic_cast 的结果由 dynamic_cast 本身的实现隐式测试。如果对引用的dynamic_cast 的操作数不是预期的类型,则会引发 bad_cast 异常。例如:
void fr(Ival_box& r)
{
Ival_slider& is = dynamic_cast<Ival_slider&>(r); // r 引用 Ival_slider!
// ... use is ...
}
动态指针转换失败和动态引用转换失败的结果差异反映了引用和指针之间的根本区别。如果用户想要防止对引用的错误转换,则必须提供合适的处理程序。例如:
void g(BB_ival_slider& slider, BB_ival_dial& dial)
{
try {
fp(&slider); // 指向 BB_ival_slider,作为 Ival_box* 传递
fr(slider); // 指向 BB_ival_slider,作为Ival_box& 传递
fp(&dial); // 指向 BB_ival_dial,作为 Ival_box* 传递
fr(dial); // dial 作为 Ival_box 对象传递
}
catch (bad_cast) { // §30.4.1.1
// ...
}
}
fp() 的调用和第一次 fr() 的调用将正常返回(假设 fp() 确实可以处理BB_ival_dial),但第二次 fr() 的调用将导致 bad_cast 异常,该异常将被 g() 捕获。
针对 nullptr 的显式测试很容易被意外忽略。如果您担心这一点,您可以编写一个转换函数,在失败时抛出异常而不是返回 nullptr。
22.2.2 多继承(Multiple Inheritance)
当仅使用单继承时,类及其基类构成一棵以单个基类为根的树。这很简单,但往往有限制。当使用多重继承时,没有单根。这本身并不会使事情变得太复杂。但是,如果一个类在层级结构中出现不止一次,我们在引用代表该类的对象时必须小心一点。
当然,我们会尝试让层级结构尽可能简单(而不是更简单)。但是,一旦构建了一个非平凡的层级结构,我们有时需要浏览它以找到要使用的特定类。这种需求发生在两种情况下:
• 有时,我们希望显式命名一个基类以用作接口,例如,为了解决歧义或调用特定函数而不依赖于虚函数机制(明确限定调用;§21.3.3)。
• 有时,我们希望在给定一个指向另一个的指针的情况下获取指向层级结构子对象的指针,例如,从指向基的指针获取指向完整派生类对象的指针(向下转换;§22.2.1)或从指向另一个基的指针获取指向基类对象的指针(交叉转换;§22.2.4)。
在这里,我们考虑如何使用类型转换(强制类型转换)来导航类层级结构以获取所需类型的指针。为了说明可用的机制及其指导规则,请考虑一个包含复制基数和虚基数的格子:
class Component
: public virtual Storable { /* ... */ };
class Receiver
: public Component { /* ... */ };
class Transmitter
: public Component { /* ... */ };
class Radio
: public Receiver, public Transmitter { /* ... */ };
或者用图表表示为:
这里,Radio 对象有两个 Component 类的子对象。因此,Radio 中从 Storable 到 Component 的 dynamic_cast 会产生歧义并返回 0。根本无法知道程序员想要哪个 Component:
void h1(Radio& r)
{
Storable∗ ps = &r; // 一个 Radio有一个唯一的 Storable
// ...
Component∗ pc = dynamic_cast<Component∗>(ps); // pc = 0; 一个 Radio有两个Components
// ...
}
一般来说,程序员(以及查看单个翻译单元的编译器)并不知道完整的类网格。相反,代码是根据某些子网格的知识编写的。例如,程序员可能只知道无线电的发射器部分,并写出:
void h2(Storable∗ ps) //ps 指不指向 Component 都可能
{
if (Component∗ pc = dynamic_cast<Component∗>(ps)) {
// 我们有一个 component!
}
else {
// 不是一个 Component
}
}
指向 Radio 对象的指针的歧义通常在编译时无法检测到。
只有虚基类才需要这种运行时歧义检测。对于普通基类,向下转型(即,朝向派生类;§22.2)时始终存在给定转型的唯一子对象(或无)。虚基类的等效歧义在向上转型(即,朝向基类)时发生,但此类歧义在编译时被捕获。
22.2.3 static_cast 和 dynamic_cast
dynamic_cast 可以从多态虚基类强制转换为派生类或兄弟类(§22.2.1)。static_cast (§11.5.2) 不会检查其强制转换的对象,因此它不能:
void g(Radio& r)
{
Receiver∗ prec = &r; // Receiver 是 Radio 的一个普通基类
Radio∗ pr = static_cast<Radio∗>(prec); // OK, 未检查
pr = dynamic_cast<Radio∗>(prec); // OK, 运行时检查
Storable∗ ps = &r; // Storable 是 Radio 的一个虚基类
pr = static_cast<Radio∗>(ps); //error : 不能从虚基类转换
pr = dynamic_cast<Radio∗>(ps); // OK, 运行时检查
}
dynamic_cast 需要多态操作数,因为非多态对象中没有存储可用于查找其代表基类的对象的信息。具体而言,由其他语言(如 Fortran 或 C)确定布局约束类型的对象可用作虚基类。对于此类对象,只有静态类型信息可用。但是,提供运行时类型标识所需的信息包括实现 dynamic_cast 所需的信息。
为什么有人想使用 static_cast 进行类级次结构导航?使用 dynamic_cast (§22.2.1) 会产生运行时成本。更重要的是,在 dynamic_cast 可用之前,已经编写了数百万行代码。此代码依赖于确保强制转换有效的其他方法,因此 dynamic_cast 所做的检查被视为多余的。但是,此类代码通常使用 C 风格强制转换 (§11.5.3) 编写;通常会保留一些难以察觉的错误。尽可能使用更安全的 dynamic_cast。
编译器无法对 void∗ 指向的内存做出任何假设。这意味着 dynamic_cast(必须查看对象以确定其类型)无法从 void∗ 进行转换。为此,需要 static_cast。例如:
Radio∗ f1(void∗ p)
{
Storable∗ ps = static_cast<Storable∗>(p); // 只相信程序员
return dynamic_cast<Radio∗>(ps);
}
dynamic_cast 和 static_cast 都遵守 const 和访问控制。例如:
class Users : private set<Person> { /* ... */ };
void f2(Users∗ pu, const Receiver∗ pcr)
{
static_cast<set<Person>∗>(pu); // error : 非法访问
dynamic_cast<set<Person>∗>(pu); // error : 非法访问
static_cast<Receiver∗>(pcr); //error : 不能转换 const
dynamic_cast<Receiver∗>(pcr); //error : 不能转换 const
Receiver∗ pr = const_cast<Receiver∗>(pcr); // OK
// ...
}
无法使用 static_cast 或 reinterpret_cast 强制转换为私有基类,并且“强制转换const”(或 volatile)需要 const_cast(§11.5.2)。即便如此,只有在对象最初未被声明为 const(或 volatile)的情况下,使用结果才是安全的(§16.2.9)。
22.2.4 覆盖接口(Recovering an Interface)
从设计角度来看,dynamic_cast(§22.2.1)可以看作是一种询问对象是否提供给定接口的机制。
举个例子,考虑一个简单的对象 I/O 系统。用户想要从流中读取对象,确定它们是否属于预期类型,然后使用它们。例如:
void user()
{
// ... 打开假定包含形状的文件,并将 ss 作为该文件的 istream 附加 ...
unique_ptr<Io_obj> p {get_obj(ss)}; // 从流读取对象
if (auto sp = dynamic_cast<Shape∗>(p.get())) {
sp−>draw(); // 读取 Shape
// ...
}
else {
// oops: non-shape in Shape file
}
}
函数 user() 通过抽象类 Shape 专门处理形状,因此可以使用各种形状。 dynamic_cast 的使用至关重要,因为对象 I/O 系统可以处理许多其他类型的对象,并且用户可能意外打开了一个文件,其中包含用户从未听说过的类的完好对象。
我使用了 unique_ptr<Io_obj> (§5.2.1,§34.3.1),这样我就不会忘记删除 get_obj() 分配的对象。
此对象 I/O 系统假定读取或写入的每个对象都是从 Io_obj 派生的类。Io_obj 类必须是多态类型,以允许 get_obj() 的用户使用 dynamic_cast 恢复返回对象的“真实类型”。例如:
class Io_obj {
public:
virtual Io_obj∗ clone() const =0; // polymor phic
virtual ˜Io_obj() {}
};
对象 I/O 系统中的关键函数是 get_obj(),它从 istream 读取数据并根据该数据创建类对象。假设表示输入流上对象的数据以标识对象类的字符串为前缀。get_obj() 的工作是读取该字符串并调用能够读取和创建正确类的对象的函数。例如:
using Pf = Io_obj∗(istream&); // 指向返回一个 Io_obj* 的函数
map<string,Pf> io_map; // 映射字符串到创建函数
string get_word(istream& is); // 从is读一个词; 若读取失败抛出 Read_error
Io_obj∗ get_obj(istream& is)
{
string str = get_word(is); // 读初始词
if (auto f = io_map[str]) // 查找 str 以获得函数
return f(is); // 调用函数
throw Unknown_class{}; //没有 str 的匹配项
}
称为 io_map 的 map 包含名称字符串和可以构造具有该名称的类的对象的函数对。
我们可以按照 user() 的需要从 Io_obj 派生出 Shape 类:
class Shape : public Io_obj {
// ...
};
但是,使用已经定义好的 Shape (§3.2.4) 而不改变会更有趣(在许多情况下也更现实):
struct Io_circle : Circle, Io_obj {
Io_circle(istream&); //从输入流初始化
Io_circle∗ clone() const { return new Io_circle{ ∗this}; } // 使用复制构造函数
static Io_obj∗ new_circle(istream& is) { return new Io_circle{is}; } // for io_map
};
这是一个例子,说明如何使用抽象类将一个类放入层级结构中,而且所需的预见性比一开始将其构建为节点类所需的预见性要少(§21.2.2)。
Io_circle(istream&) 构造函数使用来自其 istream 参数的数据初始化对象。new_circle() 函数被放入 io_map 中,以使对象 I/O 系统知道该类。例如:
io_map["Io_circle"]=&Io_circle::new_circle; // 在某处
其他形状的构造方式相同:
class Io_triangle : public Triangle , public Io_obj {
// ...
};
io_map["Io_triangle"]=&Io_circle::new_triangle; // 在某处
如果提供对象 I/O 支架变得繁琐,则模板可能会有所帮助:
template<class T>
struct Io : T, Io_obj {
public:
Io(istream&); //initialize from input stream
Io∗ clone() const override { return new Io{ ∗this}; }
static Io∗ new_io(istream& is) { return new Io{is}; } // for io_map
};
鉴于此,我们可以定义 Io_circle:
using Io_circle = Io<Circle>;
不过,我们仍然需要明确定义 Io<Circle>::Io(istream&),因为它需要了解 Circle 的细节。请注意,Io<Circle>::Io(istream&) 无法访问 T 的私有或受保护数据。这个想法是,类型 X 的传输格式是使用 X 的构造函数之一构造 X 所需要的。流的信息不一定是 X 的成员值的序列。
Io 模板是一种通过提供句柄(该句柄是该层级结构中的节点)将具体类型放入类层级结构的方法的示例。它从其模板参数派生,以允许从 Io_obj 进行转换。例如:
void f(io<Shape>& ios)
{
Shape∗ ps = &ios;
// ...
}
遗憾的是,从模板参数派生不允许使用 Io 作为内置类型:
using Io_date = Io<Date>; // 包裹具体类
using Io_int = Io<int>; // 错: 不能从内置类型派生
可以通过使用户的对象成为 Io_obj 的成员来解决此问题:
template<class T>
struct Io :Io_obj {
T val;
Io(istream&); //initialize from input stream
Io∗ clone() const override { return new Io{ ∗this}; }
static Io∗ new_io(istream& is) { return new Io{is}; } // for io_map
};
现在我们可以处理
using Io_int = Io<int>; // 包裹内置类型
由于将值设为成员而不是基数,我们不能再直接将 Io_obj<X> 转换为 X,因此我们提供一个函数来执行此操作:
template<typename T>
T∗ get_val<T>(Io_obj∗ p)
{
if (auto pp = dynamic_cast<Io<T>∗>(p))
return &pp−>val;
return nullptr;
}
user() 函数现在变成:
void user()
{
// ... open file assumed to hold shapes, and attach ss as an istream for that file ...
unique_ptr<Io_obj> p {get_obj(ss)}; // read object from stream
if (auto sp = get_val<Shape>(p.g et())) {
sp−>draw(); // use the Shape
// ...
}
else {
// ... oops: cope with non-shape in Shape file ...
}
}
这个简单的对象 I/O 系统并不能满足人们的所有需求,但它几乎可以放在一页纸上,而且关键机制有很多用途。它是系统“接收端”的蓝图,用于以类型安全的方式通过通信通道传输任意对象。更一般地说,这些技术可用于根据用户提供的字符串调用函数,并通过运行时类型识别发现的接口来操作未知类型的对象。
一般来说,这种对象 I/O 系统的发送方部分也将使用 RTTI。考虑:
class Face : public Shape {
public:
Shape∗ outline;
array<Shape∗> eyes;
Shape∗ mouth;
// ...
};
为了正确写出 outline 指向的 Shape,我们需要弄清楚它是哪种 Shape。这是 typeid()(§22.5)的工作。一般来说,我们还必须保留一个(指针,唯一标识符)对的表,以便能够传输链接的数据结构,并避免重复指向多个指针(或引用)的对象。
22.3 双重派发和访问者(Double Dispatch and Visitors)
传统的面向对象编程基于在仅给出指针或对接口(基类)的引用的情况下根据对象的动态类型(最深派生类的类型)选择虚函数。特别是,C++ 可以一次对一种类型执行这种运行时查找(也称为动态分发)。在这方面,C++ 类似于 Simula 和 Smalltalk 以及较新的语言,例如 Java 和 C#。无法根据两种动态类型选择函数可能是一个严重的限制。此外,虚函数必须是成员函数。这意味着我们不能在不修改提供接口的基类和所有应受影响的派生类的情况下将虚函数添加到类层级结构中。这也可能是一个严重的问题。本节介绍了这些问题的基本解决方法:
§22.3.1 双重分发展示了如何根据两种类型选择一个虚函数。
§22.3.2 访问者展示了如何使用双重派发将多个函数添加到类层级结构中,而层级结构中只有一个额外的虚函数。
这些技术最实际的例子发生在我们处理数据结构时,例如向量或图形或指向多态类型对象的指针。在这种情况下,只能通过(隐式或显式)检查基类提供的接口来动态了解对象的实际类型(例如,向量元素或图形节点)。
22.3.1 双重派发(Double Dispatch)
考虑如何根据两个参数选择一个函数。例如:
void do_someting(Shape& s1, Shape& s2)
{
if (s1.intersect(s2)) {
// 两个形状重叠
}
// ...
}
我们希望它适用于以 Shape 为根的类层级结构中的任意两个类,例如 Circle 和 Triangle。
基本策略是进行虚函数调用来为 s1 选择正确的函数,然后进行第二次调用来为 s2 选择正确的函数。为了简化,我将省略计算两个形状是否实际相交,只编写选择正确函数的代码框架。首先,我们用一个用于相交的函数定义 Shape:
class Circle;
class Triangle;
class Shape {
public:
virtual bool intersect(const Shape&) const =0;
virtual bool intersect(const Circle&) const =0;
virtual bool intersect(const Triangle&) const =0;
};
接下来我们需要定义 Circle 和 Triangle 来重写这些虚函数:
class Circle : public Shape {
public:
bool intersect(const Shape&) const override;
virtual bool intersect(const Circle&) const override;
virtual bool intersect(const Triangle&) const override
};
class Triangle : public Shape {
public:
bool intersect(const Shape&) const override;
virtual bool intersect(const Circle&) const override;
virtual bool intersect(const Triangle&) const override;
};
现在每个类都可以处理 Shape 层级结构中的所有可能的类,因此我们只需要决定对每个组合应该做什么:
bool Circle::intersect(const Shape& s) const { return s.intersect(∗this); }
bool Circle::intersect(const Circle&) const { cout <<"intersect(circle,circle)\n"; return true; }
bool Circle::intersect(const Triangle&) const { cout <<"intersect(circle,triangle)\n"; return true; }
bool Triangle::intersect(const Shape& s) const { return s.intersect(∗this); }
bool Triangle::intersect(const Circle&) const { cout <<"intersect(triangle ,circle)\n"; return true; }
bool Triangle::intersect(const Triangle&) const { cout <<"intersect(triangle ,triangle)\n"; return true; }
这里有趣的函数是 Circle::intersect(const Shape&) 和 Triangle::intersect(const Shape&)。它们需要处理 Shape& 参数,因为该参数必须引用派生类。技巧/技术是简单地以相反的顺序对参数进行虚调用。完成后,我们进入了四个可以实际进行交集计算的函数之一。
我们可以通过创建一个包含所有 Shape∗ 值对的向量并对这些值调用 intersect() 来测试这一点:
void test(Triangle& t, Circle& c)
{
vector<pair<Shape∗,Shape∗>> vs { {&t,&t}, {&t,&c}, {&c,&t}, {&c,&c} };
for (auto p : vs)
p.first−>intersect(∗p.second);
}
使用 Shape∗s 确保我们依赖于类型的运行时解析。我们得到:
intersect(triangle ,triangle)
intersect(triangle ,circle)
intersect(circle,triangle)
intersect(circle,circle)
如果您认为这很优雅,则需要提高标准,但它可以完成任务。随着类层级结构的增长,对虚函数的需求呈指数级增长。在大多数情况下,这是不可接受的。将其扩展到三个或更多参数很简单,但很乏味。最糟糕的是,每个新操作和每个新派生类都需要对层级结构中的每个类进行修改:这种双重派发技术具有高度侵入性。理想情况下,我更喜欢一个简单的intercept(Shape&,Shape&)函数,并为特定形状的所需组合指定覆盖器。这是可能的[Pirkelbauer,2009],但在C ++ 11中不行。
双重派发的尴尬并不会使它试图解决的问题变得不那么重要。想要一个依赖于两个(或更多)操作数类型的操作(例如 intersect(x,y))并不罕见。解决方法有很多。例如,找到矩形的交点既简单又有效。因此,对于许多应用程序,人们发现为每个形状定义一个“边界框”,然后计算边界框上的交点就足够了。例如:
class Shape {
public:
virtual Rectangle box() const = 0; // 矩形封装形状
// ...
};
class Circle : public Shape {
public:
Rectangle box() const override;
// ...
};
class Triangle : public Shape {
public:
Rectangle box() const override;
// ...
};
bool intersect(const Rectangle&, const Rectangle&); // simple to calculate
bool intersect(const Shape& s1, const Shape& s2)
{
return intersect(s1.box(),s2.box());
}
另一种技术是预先计算类型组合的查找表[Stroustrup,1994]:
bool intersect(const Shape& s1, const Shape& s2)
{
auto i = index(type_id(s1),type_id(s2));
return intersect_tbl[i](s1,s2);
}
这一思想的变体被广泛使用。许多变体使用存储在对象中的预计算值来加快类型识别速度(§27.4.2)。
22.3.2 访问者(Visitors)
访问者模式 [Gamma,1994] 可以部分解决虚函数和覆盖函数的指数增长以及(过于)简单的双重派发技术带来的令人不快的侵入性问题。
考虑如何将两个(或更多)操作应用于类层级结构中的每个类。基本上,我们将对节点层级结构和操作层级结构进行双重派发,以选择正确节点的正确操作。这些操作称为访问者;在这里,它们在从类 Visitor 派生的类中定义。节点是类的层级结构,具有接受 Visitor&s 的虚函数 accept()。对于此示例,我使用描述语言构造的 Nodes 层级结构,这在基于抽象语法树 (AST) 的工具中很常见:
class Visitor;
class Node {
public:
virtual void accept(Visitor&) = 0;
};
class Expr : public Node {
public:
void accept(Visitor&) override;
};
class Stmt : public Node {
public:
void accept(Visitor&) override;
};
到目前为止一切顺利:Node 层级结构仅提供了一个虚函数 accept(),该函数接受一个 Visitor& 参数,该参数表示应该对给定类型的 Node 执行什么操作。
我在这里没有使用 const,因为一般来说,Visitor 的操作可能会更新“访问的”Node 或 Visitor本身。
现在 Node 的 accept() 执行双重派发技巧,并将 Node 本身传递给 Visitor 的 accept():
void Expr::accept(Visitor& v) { v.accept(∗this); }
void Stmt::accept(Visitor& v) { v.accept(∗this); }
Visitor声明了一组操作:
class Visitor {
public:
virtual void accept(Expr&) = 0;
virtual void accept(Stmt&) = 0;
};
然后,我们可以通过从 Visitor 派生并重写其 accept() 函数来定义操作集。例如:
class Do1_visitor : public Visitor {
void accept(Expr&) { cout << "do1 to Expr\n"; }
void accept(Stmt&) { cout << "do1 to Stmt\n"; }
};
class Do2_visitor : public Visitor {
void accept(Expr&) { cout << "do2 to Expr\n"; }
void accept(Stmt&) { cout << "do2 to Stmt\n"; }
};
我们可以通过创建 pair 指针的 vector 进行测试,以确保使用运行时类型解析:
void test(Expr& e, Stmt& s)
{
vector<pair<Node∗,Visitor∗>> vn {&e,&do1}, {&s,&do1}, {&e,&do2}, {&s,&do2}};
for (auto p : vn)
p.first−>accept(∗p.second);
}
我们得到:
do1 to Expr
do1 to Stmt
do2 to Expr
do2 to Stmt
与简单的双重派发相反,访问者模式在实际编程中被广泛使用。它只是轻微的侵入(accept() 函数),并且使用了许多基本思想的变体。但是,类层级结构上的许多操作很难用访问者来表达。例如,需要访问图中不同类型的多个节点的操作不能简单地实现为访问者。所以,我认为访问者模式是一种不优雅的解决方法。存在替代方案,例如[Solodkyy,2012],但不是在纯 C++11 中。
C++ 中大多数访问者的替代方案都是基于对同质数据结构(例如,包含指向多态类型的指针的向量或节点图)进行显式迭代的思想。在每个元素或节点上,调用虚函数可以执行所需的操作,或者可以应用一些基于存储数据的优化(例如,参见 §27.4.2)。
22.4 构造和析构(Construction and Destruction)
类对象不仅仅是内存的一个区域(§6.4)。类对象由其构造函数从“原内存(raw memory)”构建,并在执行其析构函数时恢复为“原内存”。构造是自下而上的,析构是自上而下的,类对象是其构造或析构过程的对象。此顺序是必要的,以确保在初始化之前不会访问对象。试图通过“聪明”的指针操作(§17.2.3)提前或无序访问基对象和成员对象是不明智的。构造和析构的顺序反映在 RTTI、异常处理(§13.3)和虚函数(§20.3.2)的规则中。
依赖构造和析构顺序的细节是不明智的,但你可以在对象未完成时通过调用虚函数 dynamic_cast (§22.2) 或 typeid (§22.5) 来观察该顺序。在构造函数中的这一点,对象的 (动态) 类型仅反映迄今为止构造的内容。例如,如果 §22.2.2 中层级结构中的 Component 的构造函数调用虚函数,它将调用为 Storable 或 Component 定义的版本,但不会调用 Receiver、Transmitter 或 Radio 的版本。在构造的那个点,该对象还不是 Radio。同样,从析构函数调用虚函数将仅反映尚未销毁的内容。最好避免在构造和析构期间调用虚函数。
22.5 类型识别(Type Identification)
dynamic_cast 运算符可满足运行时获取对象类型信息的大多数需求。重要的是,它可确保使用它编写的代码能够与程序员明确提到的类派生的类正确配合使用。因此,dynamic_cast 以类似于虚函数的方式保留了灵活性和可扩展性。
但是,有时了解对象的确切类型是必要的。例如,我们可能想知道对象的类名或布局。typeid 运算符通过生成表示其操作数类型的对象来实现此目的。如果 typeid() 是一个函数,它的声明看起来会像这样:
class type_info;
const type_info& typeid(expression); // 伪声明
也就是说,typeid() 返回对 <typeinfo> 中定义的名为 type_info 的标准库类型的引用:
• 给定一个类型的名称作为其操作数,typeid(type_name) 返回对表示 type_name 的 type_info 的引用;type_name 必须是完全定义的类型 (§8.2.2)。
• 给定一个表达式作为其操作数,typeid(expr) 返回对表示 expr 所表示的对象类型的 type_info 的引用;expr 必须引用完全定义的类型 (§8.2.2)。如果 expr 的值为 nullptr,typeid(expr) 将抛出 std::bad_typeid。
typeid() 可以找到引用或指针所指对象的类型:
void f(Shape& r, Shape∗ p)
{
typeid(r); // r 所引用对象的类型
typeid(∗p); // p 指向的对象的类型
typeid(p); // 指针类型, 即, Shape* (uncommon, except as a mistake)
}
如果 typeid() 的操作数是指针或值为 nullptr 的多态类型的引用,则 typeid() 会抛出 std::bad_typeid。如果 typeid() 的操作数具有非多态类型或不是左值,则结果在编译时确定,而无需评估操作数表达式。
如果对象由解引用的指针或对多态类型的引用表示,则返回的 type_info 是该对象最外层派生类的 type_info,即定义该对象时使用的类型。例如:
struct Poly { // polymor phic base class
virtual void f();
// ...
};
struct Non_poly { /* ... */ }; // no virtual functions
struct D1
: Poly { /* ... */ };
struct D2
: Non_poly { /* ... */ };
void f(Non_poly& npr, Poly& pr)
{
cout << typeid(npr).name() << '\n'; // writes something like "Non_poly"
cout << typeid(pr).name() << '\n'; // name of Poly or a class derived from Poly
}
void g()
{
D1 d1;
D2 d2;
f(d2,d1); // writes "Non_poly D1"
f(∗static_cast<Poly∗>(nullptr),∗static_cast<Null_poly∗>(nullptr)); // oops!
}
最后一次调用将只打印 Non_poly(因为 typeid(npr) 未被评估),然后抛出 bad_typeid。
type_info 的定义如下:
class type_info {
// data
public:
virtual ˜type_info(); //is polymorphic
bool operator==(const type_info&) const noexcept; // can be compared
bool operator!=(const type_info&) const noexcept;
bool before(const type_info&) const noexcept; // 排序
size_t hash_code() const noexcept; // 由 unordered_map 和相关类使用
const char∗ name() const noexcept; // 类型名
type_info(const type_info&) = delete; // 阻止复制
type_info& operator=(const type_info&) = delete; // 阻止复制
};
before() 函数允许对众 type_info 进行排序。具体来说,它允许将众 type_id 用作有序容器(例如 map)的键。before 定义的关系与继承关系之间没有任何关系。hash_code() 函数允许将众 type_id 用作哈希表(例如 unordered_map)的键。
不能保证系统中每种类型只有一个 type_info 对象。事实上,在使用动态链接库的情况下,实现很难避免重复的 type_info 对象。因此,我们应该在 type_info 对象上使用 == 来测试相等性,而不是在指向此类对象的指针上使用 ==。
我们有时想知道某个对象的确切类型,以便对整个对象(而不仅仅是其某个基类)执行某些服务。理想情况下,此类服务以虚函数的形式呈现,因此无需知道确切类型。在某些情况下,无法为每个操作的对象假设通用接口,因此必须绕道而行(§22.5.1)。另一个更简单的用途是获取类的名称以进行诊断输出:
#include<typeinfo>
void g(Component∗ p)
{
cout << typeid(∗p).name();
}
类名的字符表示由实现定义。此 C 风格字符串驻留在系统拥有的内存中,因此程序员不应尝试 delete[] 它。
22.5.1 扩展类型信息(Extended Type Information)
type_info 对象仅包含最少的信息。因此,找到某个对象的确切类型通常只是获取和使用该类型的更详细信息的第一步。
考虑一下实现或工具如何在运行时向用户提供有关类型的信息。假设我有一个工具,可以为每个使用的类生成对象布局的描述。我可以将这些描述符放入 map 中,以允许用户代码找到布局信息:
#include <typeinfo>
map<string, Layout> layout_table;
void f(B∗ p)
{
Layout& x = layout_table[typeid(∗p).name()]; // 求得基于 *p 名的布局
// ... use x ...
}
最终的数据结构如下:
其他人可能会提供完全不同类型的信息:
unordered_map<type_index,Icon> icon_table; // §31.4.3.2
void g(B∗ p)
{
Icon& i = icon_table[type_index{typeid(∗p)}];
// ... use i ...
}
type_index 是用于比较和散列 type_info 对象(§35.5.4)的标准库类型。
最终的数据结构如下:
将 typeid 与信息关联起来而无需修改系统头文件,可以让多个人或工具将不同的信息与彼此独立的类型关联起来。这一点很重要,因为有人能想出一套让每个用户都满意的信息的可能性接近于零。
22.6 RTTI的使用和误用(Uses and Misuses of RTTI)
我们应该只在必要时使用显式运行时类型信息。静态(编译时)检查更安全,开销更小,并且在适用的情况下可以生成结构更好的程序。基于虚函数的接口将静态类型检查与运行时查找相结合,从而提供类型安全性和灵活性。然而,程序员有时会忽略这些替代方案,并在不合适的地方使用 RTTI。例如,RTTI 可用于编写伪装得很浅的 switch 语句:
// 运行时类型信息的滥用:
void rotate(const Shape& r)
{
if (typeid(r) == typeid(Circle)) {
// do nothing
}
else if (typeid(r) == typeid(Triangle)) {
// ... rotate triangle ...
}
else if (typeid(r) == typeid(Square)) {
// ... rotate square ...
}
// ...
}
使用 dynamic_cast 而不是 typeid 只会稍微改善此代码。无论如何,此代码在语法上很丑陋,而且效率低下,因为它会重复执行昂贵的操作。
遗憾的是,这不是一个稻草人例子;这样的代码确实有人写过。对于许多没有类层级结构和虚函数等价对象的语言训练的人来说,有一种几乎无法抗拒的冲动,想把软件组织成一组 switch 语句。这种冲动通常应该抵制。使用虚函数(§3.2.3,§20.3.2)而不是 RTTI 来处理大多数需要基于类型的运行时区分的情况。
当某些服务代码用一个类来表达,而用户想要通过派生来添加功能时,就会出现许多正确使用 RTTI 的例子。§22.2 中对 Ival_box 的使用就是一个例子。如果用户愿意并且能够修改库类(比如 BBwindow)的定义,那么就可以避免使用 RTTI;否则,就需要使用 RTTI。即使用户愿意修改基类(例如,添加虚函数),这种修改也可能会导致自身的问题。例如,可能需要在不需要或没有意义的类中引入虚函数的虚实现。在 §22.2.4 中可以找到使用 RTTI 实现简单对象 I/O 系统的示例。
对于具有严重依赖动态类型检查的语言背景的人来说,例如 Smalltalk、前泛型 Java 或 Lisp,将 RTTI 与过于通用的类型结合使用很有吸引力。请考虑:
// 运行时类型信息的滥用:
class Object { // 多态
// ...
};
class Container : public Object {
public:
void put(Object∗);
Object∗ get();
// ...
};
class Ship : public Object { /* ... */ };
Ship∗ f(Ship∗ ps, Container∗ c)
{
c−>put(ps); //将 Ship 放入容器
// ...
Object∗ p = c−>g et(); // 从容器取回一个对象
if (Ship∗ q = dynamic_cast<Ship∗>(p)) { // 运行时检查: Object 是一个 Ship?
return q;
}
else {
// ... do something else (typically, error handling) ...
}
}
这里,Object 类是一个不必要的实现工件。它过于通用,因为它不对应于应用程序域中的抽象,并迫使应用程序程序员使用实现级抽象(Object)。这类问题通常可以通过使用仅包含一种指针的容器模板更好地解决:
Ship∗ f(Ship∗ ps, vector<Ship∗>& c)
{
c.push_back(ps); // put the Ship into the container
// ...
return c.pop_back(); // retrieve a Ship from the container
}
这种代码风格不容易出错(静态类型检查效果更好),而且比纯基于 Object 的替代方案更简洁。结合使用虚函数,这种技术可以处理大多数情况。在模板中,模板参数 T 代替 Object 并启用静态类型检查(§27.2)。
22.7 建议(Advice)
[1] 使用虚函数确保无论对象使用哪个接口,都可以执行相同的操作;§22.1。
[2] 在无法避免类层级结构导航时使用 dynamic_cast;§22.2。
[3] 使用 dynamic_cast 进行类层级结构的类型安全显式导航;§22.2.1。
[4] 当无法找到所需的类被视为失败时,使用 dynamic_cast 转换为引用类型;§22.2.1.1。
[5] 当无法找到所需的类被视为有效的替代方案时,使用 dynamic_cast 转换为指针类型;§22.2.1.1。
[6] 使用双派发或访问者模式来表达对两个动态类型的操作(除非您需要优化查找);§22.3.1。
[7] 在构造或析构期间不要调用虚函数;§22.4。
[8] 使用 typeid 实现扩展类型信息;§22.5.1。
[9] 使用 typeid 查找对象的类型(而不是查找对象的接口);§22.5。
[10] 优先使用虚函数,而不是基于 typeid 或 dynamic_cast 的重复 switch 语句;§22.6。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup