现在的位置: 首页 > 综合 > 正文

Professional Programmer’s Handbook 7

2013年09月14日 ⁄ 综合 ⁄ 共 11557字 ⁄ 字号 评论关闭

7

运行期类型识别

by Danny Kalev



简介

最 初,C++不提供对运行期类型识别(RTTI)的标准支持。此外,C++的设计者至少有两个理由反对加入对RTTI的支持。第一,他们想保留对C的兼容 性。第二,他们关心效率。其他支持RTTI的语言比如Smalltalk和Lisp,他们迟缓地性能都很出名。动态类型检查的性能损失来自,在运行期对对 象类型的检索以及系统不得不存储所有类型的附加信息。C++设计者想保留C的高效。

反对将RTTI加入语言的其他主张是,在多数情况下,使用虚函数可以代替直接的运行期类型检查。然而由于在C++中增加了多继承(因此有了虚继承)给RTTI的支持者提供了压倒性的支持(多继承载第五章“面相对象的编程和设计”中讨论);在有些情况下RTTI成为了首选。

最后,C++标准化委员会确定加入RTTI。引入了两个新的运算符dynamic_cast<>typeid。另外类std::type_info加入到标准库中。

本章的结构

这一章由三部分构成,首先是虚函数的局限性,其后解释并示范了标准RTTI,最后讨论了RTTI的性能和设计问题。

不用RTTI

不要RTTI的支持,虚成员函数也能提供对动态类型的合理层次。一个良好设计的类层次可以为在基类中申明的每一个虚成员函数定义一个有含义的操作。

假定你必须开发一个文件管理程序,作为基于GUI操作系统的组件。系统中的文件以图标的形式表示,右键单击图标时可以弹出有打开、关闭、读等等的菜单。文件系统的底层实现依赖表示不同类型文件的类。在设计良好的类层次中,常用一个抽象类作为接口:


class File //抽象,所有成员都是虚的
{
public: virtual void open() =0;
public: virtual void read() =0;
public: virtual void write() =0;
public: virtual ~File () =0;
};
File::~File () //必须定义纯虚销毁器
{}

在层次的底层,你有一套实现了公共接口的类,这些公共接口都派生自File。每一个这样的子类表示文件族。为了简化问题,假设系统中只有两种类型的文件:二进制的.exe文件和文本文件。


class BinaryFile : public File
{
public:
void open () { OS_execute(this); } //实现纯虚成员函数
//...其他成员函数
};
class TextFile : public File
{
public:
void open () { Activate_word_processor (this); }
//...File的其他成员函数在这里实现
void virtual print(); //附加成员函数
};

依照文件类型,每一个派生类分别实现纯虚函数open()。因次在TextFile对象中open()激活字处理程序,而BinaryFile对象调用操作系统的API函数OS_execute()来执行二进制文件中的程序。

在 二进制文件和文本文件之间有许多差别。例如,文本文件可以在屏幕或打印机上直接打印,因为文本由可打印字符集组成。相反的,有.exe扩展名的二进制文件 包含位流;它不能在屏幕直接打印。它必须首先转换成文本文件,通常通过将二进制数据翻译成符号的工具。(例如,可执行文件中的序列0110010可以直接被相应的汇编指令替换。)换句话说,可执行文件必须转换成文本文件以便浏览和打印。因此,成员函数print()仅出现在类TextFile中。

在文件管理系统。右键单击文件图标打开对象能接受的消息(选项)的菜单。为了达到这个目的,操作系统有一个接受File引用的函数:


OnRightClick (File & file); //操作系统的API函数

显然,File没有实例,因为File是一个抽象类(参见第五章)。但是函数 OnRightClick()可以接受File的派生对象。例如,当用户右键单击文件图标并选择Open时,OnRightClick调用参数的成员函数open,正确的成员函数倍被调用。例如


OnRightClick (File & file)
{
switch (message)
{
//...
case m_open:
file.open();
break;
}
}

到现在为止,一切正常。你可以实现多态的类层次和函数,而不依赖于参数的动态类型。在这种情况下,虚函数可以满足你的要求;你不需要任何显式的RTTI。你可能注意到缺乏对文件打印的支持。再看一下类TextFile的定义:


class TextFile : public File
{
public:
void open () { Activate_word_processor (this); }
void virtual print();
};

成员函数print()不是公共接口的一部分,文件系统中的每一个文件都必须实现它。将print()放入抽象类File中是一个设计错误,因为二进制文件是不可打印的,不能为二进制文件定义这种操作。另外,处理文本文件时OnRightClick()必须支持文件打印。这时,虚成员函数形式的普通多态就不能胜任了。OnRightClick()仅知道它的参数派生自File。然而,这个信息不能反映实际对象是否可打印。显然,OnRightClick()需要它参数动态类型的进一步信息来正确处理文件打印。 这就是需要运行期类型识别的地方。在具体实现OnRightClick()之前,有必要介绍一下RTTI的要素和它的任务。

RTTI的要素

运算符typeiddynamic_cast<>为他们操作数的运行期类型识别提供了两种补充形式。操作数的动态类型信息存储在type_info对象中。这一小节演示了三种要素的用法。

RTTI只适用于多态对象

有 一点十分重要,即RTTI只适用于多态对象。类必须至少有一个虚成员函数,RTTI才支持它的对象。C++对非多态类和基本类型不提供RTTI支持。这个 限制基于一个常识——基本类型和非多态类不会在运行期改变类型。因此不需要检测他们的动态类型,肯定与静态类型一致。你将要看到,还有另外一个实际原因。

你可能知道,有至少一个虚成员函数的类都有一个编译器增加的特殊成员数据(更多信息参见第十三章“C语言的兼容性问题”)。这个成员就是虚函数表的指针。运行期类型信息存储在表中,作为一个指向std::type_info对象的指针。

类type_info

对于不同的类型,C++实例化一个相关的RTTI对象,包含必要的运行期信息。RTTI对象是标准类std::type_info的实例或派生自std::type_info的对象。(std::type_info在标准头文件<typeinfo>中定义)。这个对象是自动实现的,程序员不能以任何方式修改它。type_info的接口类似于(命名空间在第八章“命名空间”介绍):


namespace std { //类type_info在命名空间std中申明
class type_info
{
public:
virtual ~type_info(); //type_info可以作为一个基类
bool operator==(const type_info& rhs ) const; //可以比较
bool operator!=(const type_info& rhs ) const; //return !( *this == rhs)
bool before(const type_info& rhs ) const; //排序
const char* name() const; //返回包含类型能够名字的C字符串
private:
//这种类型的对象不能拷贝
type_info(const type_info& rhs );
type_info& operator=(const type_info& rhs);
}; //type_info
}

一般,同类型的所有实例共享一个type_info对象。type_info最常用的成员是name()operator==。但是在你调用这些成员函数之前,你必须自己存取type_info。怎么做呢?

运算符typeid

运算符typeid可以接受对象或类型名字作为参数,返回匹配的const type_info对象。度相的动态类型可以这样检测:


OnRightClick (File & file)
{
if ( typeid( file) == typeid( TextFile ) )
{
//接受的是TextFile对象;可以打印
}
else
{
//不是TextFile对象,不能打印
}
}

为了理解它是怎么做的,请看突出的一行:


if ( typeid( file) == typeid( TextFile ) ).

if语句检测参数file的动态类型是不是TextFile(当然file的静态类型肯定是File)。最左边的表达式typeid(file),返回一个type_info对象,这个对象保存了与对象file相关联的必要运行期类型信息。最右边的表达式 typeid(TextFile),返回与类TextFile相关联的类型信息。当对类名字而不是对象使用typeid时,它总是返回类名字相应的type_info对象。就像你在前面看到的,type_info重载了运算符==。因此,左边typeid表达式返回的type_info对象与右边typeid表达式返回的type_info对象进行比较。如果file确是TextFile的实例,if语句的结果就是true。这时,OnRightClick在菜单中显示附加选项print()。反之,就关闭print()选项。这是正确、清楚的,但是基于typeid的解决方案有一个缺点。假设你要增加一种新的文件类型,比如HTML文件。文件管理器要扩展时会遇到什么呢?他们即可读也可打印。然而他们在有些方面与纯文件又不同。发到HTML文件的open消息将打开浏览器而不是字处理程序。另外,HTML文件在打印之前必须转换成可打印格式。以最小花费来扩展系统功能是软件开发者每天都必须面对的挑战。面向对象的编程和设计可以推动这个目标。通过子类TextFile,你可以重用它已有的行为,仅仅实现HTML文件需要实现的附加功能:


class HTMLFile : public TextFile
{
void open () { Launch_Browser (); }
void virtual print(); //执行必要的到可打印格式的转换
//再打印文件
};

但是,这仅仅是一半。当接受HTMLFile类型的对象时,OnRightClick()出现严重的问题。看一看问什么:


OnRightClick (File & file) //操作系统API函数
{
if ( typeid( file) == typeid( TextFile ) )
{
//我们接受到TextFile对象;必须有打印
}
else //糟糕!类型为HTMLFile时我们到了这里
{
}
}

typeid返回它参数的准确类型信息。因此,当参数是HTMLFile时,OnRightClick()if语句的结果是false。但false值意味着二进制文件!因此,打印关闭了。这个麻烦的bug可能在你每一次添加新文件类型时就出现。当然你可以改变OnRightClick()以使它可以执行其他测试:


OnRightClick (File & file) //操作系统API函数
{
if ( (typeid( file) == typeid( TextFile ))
|| (typeid( file) == typeid( HTMLFile)) ) //也检测HTMLFile
{
//我们接受TextFile和HTMLFile;打印都必须打开
}
else //这是二进制文件,没有打印选项
{
}
}

但是这个方法是笨重并且容易出错的。此外,它给维护它的程序员强加了不想要的负担。他们不仅在OnRightClick()中为File每一个派生类增加代码,而且还必须保证每一个File的派生类都被加查到。幸运的是,C++提供了更好的方法来处理这种情况。



注意:你可以使用typeid从新得到非多态对象和基本类型的类型信息。但是,结果引用了表示操作数静态类型的type_info对象。例如


#include<typeinfo>
#include <iostream>
#include <string>
using namespace std;
typedef int I;
void fundamental()
{
cout<<typeid(I).name()<<endl; //显示'int'
}
void non_polymorphic()
{
cout<<typeid(string).name()<<endl;
}



注意:然而请注意,对基本类型和非多态对象使用dynamic_cast将导致编译期错误。

运算符dynamic_cast<>

OnRightClick()关心每一个可能的类型是一种错误。这样做的结果就是,每一次派生新类或修改以存在类都迫使你修改OnRightClick()。在软件设计和特定的面向对象设计中,你希望这样的依赖性最小。如果你进一步检查OnRightClick(),你会发现OnRightClick()并不能真真的知道参数是不是类TextFile的实例。相反,所有OnRightClick()需要知道的只是参数是不是TextFile。在这两者之间是有很大区别的——如果对象是TextFile的实例和是TextFile任何一个派生类的实例,对象就是TextFile。可是,typeid是无力检查对象的派生层次的。为了达到这目的,你必须使用运算符dynamic_cast<>dynamic_cast<>接受两个参数:第一个是类型名字,第二个是dynamic_cast<>试图要进行运行期类型转换的对象。例如


dynamic_cast <TextFile &> (file); //试图将file转换成TextFile对象的
//引用

如 果想转换成功,第二个参数是第一个参数所指示类的实例或其派生类的实例。(If the attempted cast succeeds, either the second argument is an instance of the class name that appears as the second argument or it is an object derived from it.原文似乎有错。)如果file是一个TextFile,前面的dynamic_cast<>表达式成功。 这是OnRightClick正确运行所需的信息。但是你怎么知道dynamic_cast<>是否成功呢?

指针转换和引用转换

dynamic_cast<>有两个特殊,一是指针,二是引用。当它成功时,dynamic_cast<>返回所需类型的指针和引用。当dynamic_cast<>不能执行转换时,返回一个NULL指针,对于引用则抛出一个std::bad_cast异常。看下面的指针转换的例子:


TextFile * pTest = dynamic_cast < TextFile *> (&file); //试图将file
//的地址转换成指向TextFile的指针
if (pTest) //dynamic_cast成功,file 是一个 TextFile
{
//使用pTest
}
else // file不是一个TextFile;pTest是NULL
{
}

C++没有NULL引用。因此当引用dynamic_cast<>失败时,抛出std::bad_cast异常。这就是为什么呢总是需要将引用dynamic_cast<>表达式放在 try块的原因,当然还必须有匹配的catch语句来处理std::bad_cast异常(参见第六章“异常处理”)。例如


try
{
TextFile tf = dynamic_cast < TextFile &> (file);
//安全的使用tf,
}
catch (std::bad_cast)
{
//dynamic_cast<>失败
}

现在你能使OnRightClick()正确的处理HTMLFile对象:


OnRightClick (File & file)
{
try
{
TextFile temp = dynamic_cast<TextFile&> (file);
//显示选项“打印”
switch (message)
{
case m_open:
temp.open(); //不管是TextFile::open还是HTMLFile::open
break;
case m_print:
temp.print();//不管是TextFile::print还是HTMLFile::print
break;
}//switch
}//try
catch (std::bad_cast& noTextFile)
{
//对于BinaryFile之类的file;不显示“打印”
}
}// OnRightClick

OnRightClick()的修订版本可以正确的处理HTMLFile类型的对象,因为HTMLFile类型的对象是一个TextFile。当用户点击文件管理器显示的打开选项时,函数OnRightClick()调用它参数的成员函数open()open()执行相应的动作,因为在类HTMLFile中已提供了正确的版本。同样的当OnRightClick()检测到参数是一个TextFile时,就显示打印选项。如果用户点击,OnRightClick()向它的参数发送print消息,引发相应的动作。

dynamic_cast<>的其他用途

对象的动态类型——不是静态类型——必须执行适当地的类型转换时,就需要动态类型转换。注意,在这些情况下任何使用静态类型转换的企图,都将被编译器标志为错误,或更糟——可能导致运行期的未定义行为。

交叉转换

交叉转换(cross cast)将多继承对象转换成它第二基类中的一个。为了示范交叉转换作了解些什么,考虑下面的类层次:


struct A
{
int i;
virtual ~A () {} //强制成为多态;dynamic_cast的需要
};
struct B
{
bool b;
};
struct D: public A, public B
{
int k;
D() { b = true; i = k = 0; }
};
A *pa = new D;
B *pb = dynamic_cast<B*> pa; //交叉转换;访问多派生对象的
//第二基类

pa的静态类型是“指向A的指针”,而它的动态类型是“指向D的指针”。简单的static_cast<>不能将指向A的指针转换成指向B的指针,因为AB是无关的( 这时你的编译器将报告一个错误)。强力类型转换(例如reinterpret_cast<>或C风格的转换),在运行期将产生严重后果,因为编译器简单的将pa赋值给pb。但是,在D中子对象B与子对象A的地址是不同的。为了正确执行交叉转换,pb的值必须在运行期计算。毕竟在交叉转换执行的时候甚至无法知道类D是否存在!下面的程序示范了为什么要动态类型转换,而不能是编译期转换:


int main()
{
A *pa = new D;
B *pb = (B*) pa; //糟糕的;pb指针指向D中的子对象A
bool bb = pb->b; //bb有一个未定义的值
cout<< "pa: " << pa << " pb: "<<pb <<endl; //pb没有调
//整正确;pa和pb是一样的
pb = dynamic_cast<B*> (pa); //交叉转换;正确调整pb
bb= pb->b; //OK, bb is true
cout<< "pa: "<< pa << " pb: " << pb <<endl; //OK,pb调整正确了;
//pa和pb是不同的值
return 0;
}

程序有两行输出;第一行显示papb的内存地址是一样的。第二行显示在执行了需要的动态类型转换之后papb的内存地址是不一样的。

虚基类向下转换

向下转换(downcast)是从派生类到基类的转换。在将RTTI引入语言之前,向下转换被认为是一种不好的程序设计习惯。他们是不安全的,有些观点甚至认为动态类型转换破坏了面向对象的原则(参见第二章“标准简报:ANSI/ISO C++的最新附加部分”)。dynamic_cast<>使你能够安全、标准、简单的使用虚基类到派生类对象向下转换。看下面的例子:


struct V
{
virtual ~V (){} //保证多态性
};
struct A: virtual V {};
struct B: virtual V {};
struct D: A, B {};
#include <iostream>
using namespace std;
int main()
{
V *pv = new D;
A* pa = dynamic_cast<A*> (pv); //向下转换
cout<< "pv: "<< pv << " pa: " << pa <<endl; //OK,pv和pa
//有不同的地址
return 0;
}

V是类AB的虚基类。DAB多继承来。在main()中,pv申明为“指向V的指针”而它的动态类型是“指向D的指针”。象在交叉转换的例子中一样,需要pv的动态类型来正确的进行到A的向下转换。static_cast<>将被编译器拒绝。如你在第五章读到的,虚子对象的内存布局可能与非虚子对象不同。因此,不可能在编译期计算pv指向对象中子对象A地址。就像程序输出显示的,pvpa指向不同的内存地址。

运行期类型识别的开销

RTTI不是免费的。为了估计它的性能开销到底有多大,了解RTTI的后台实现机制是十分重要的。有些技术细节是依赖平台的。然而,这里展示的基本模式可以让你对RTTI的内存开销以及对执行速度的影响有一个公正的印象。

内存开销

需要为每一个基本类型和用户定义类型存储一个type_info对象。理想情况,可以对每一个不同的类型只实现一个的type_info对象。但是,这不是一个要求,而且在许多情况下——例如,动态链接库——不可能为每一个存在的类只保存一个type_info对象。因此可能为一个类型创建了多个type_info

先前有一个实际的原因,dynamic_cast<>只适用于多态对象:对象不能直接存储它的运行期类型信息(例如,作为一个数据成员)。

多态对象的RTTI

每一个多态对象都有指向它虚函数表的指针。这个指针,传统命名为vptr,指向包含类的每一个虚函数内存地址的调度表。诀窍是在这个表中增加其他条目。这个条目表明了类的type_info对象。换句话说,多态对象的数据成员vptr指向指针的列表,在表中type_info的地址保存在固定的位置。这个模型在内存使用上是很节约的;它只需要一个type_info对象,每个多态类只要一个指针。注意这是固定成本的,不管程序中实际存在多少个类的实例。因此检索对象运行期类型信息的花费只是一个间接指针,这可能不如直接访问数据成员有效;但是这相当于虚函数调用。

额外的开销

间接指针、type_info对象、每一个类一个指针听起来RTTI的“价格”很合理。但这是不全面的。type_info对象象其他对象一样,必须构造。包含上百个不同多态类的大程序也必须构造同等数量的type_info对象。

RTTI支持通常可以被关闭

即 使你在程序中从不使用RTTI,这个开销也是强加的。由于这个原因,大多数编译器允许你关闭RTTI支持(检查用户手册,查找编译器RTTI的默认设置和 如何改变它)。如果你存在程序中从不使用RTTI,你可以关闭编译器对RTTI的支持。这能提高速度,减小可执行文件的大小。

typeid VS. dynamic_cast<>

到现在为止,本章讨论了RTTI的间接花费。现在讨论它的直接花费——适用typeiddynamic_cast<>

typeid调用消耗常数时间。用typeid检索每一个多态对象的运行期类型信息消耗同样的时间,不管对象的继承层次如何复杂。本质上,调用typeid与调用虚成员函数类似。例如,表达式 typeid(obj)按类似下面代码的方式求值:


return *(obj->__vptr[0]); //返回type_info对象
//其地址存储在obj对象虚表偏移0处

注意,类type_info的指针存储在虚表的固定偏移处(通常是0,但这是依赖于具体编译器的)。

typeid不同,dynamic_cast<>消耗的时间不是常数。在表达式dynamic_cast<T&> (obj)中,T是目标类型,obj是操作数,转换操作数到目标类型消耗的时间依赖与obj继承层次的复杂性。dynamic_cast<>必须穿越obj的继承树,直到找到目标对象。当目标是虚基类时,动态类型转换变得更加复杂(这显然是不可避免得);因此它要执行更长时间。更糟的情况是当操作数是一个派生多次的对象而目标是无关类型。这种情况下,在dynamic_cast<>确认obj不能转换成T之前,它不得不搜索obj 的整个继承树。换句话说,失败的dynamic_cast<>是一个O(n)的运算,n是操作数基类的数量。

你可能从前面的设计角度的观点中得到结论:dynamic_cast<>优于typeid,因为dynamic_cast<>能使你的设计更灵活更有扩展性。尽管如此,typeid的运行期开销小于dynamic_cast<>,后者依赖于参数继承层次的复杂性。

总结

C++的RTTI机制由三个部分组成:运算符typeid、运算符dynamic_cast<>和类 std::type_info。在C++中RTTI相对较新。许多现有编译器还不支持它。此外,支持它的编译器一般默认关闭RTTI支持。即使在程序中没有显式的使用RTTI,编译器自动的在可执行文件中增加RTTI的“骨架”。为了避免这一点,一般关闭编译器的RTTI支持。

从面向对象程序设计的角度来说,运算符dynamic_cast<>优于typeid,因为它使得设计更灵活更强壮。但是,dynamic_cast<>可能比typeid慢的多,因为前者的执行依赖于目标和操作数的接近程度,也就是后者继承层次的复杂程度。当使用复杂继承层次的时,带来的性能损失可能是显著的。因此不推荐使用RTTI。在多数情况下,虚成员函数就足够达到必要的多态行为。只有虚成员函数不够用时,才考虑RTTI。

下面是几个使用RTTI时要时刻注意的问题:

  • 为了开启RTTI支持,对象必须有至少一个虚成员函数。另外,记得打开编译器的RTTI支持(请参考你的用户手册获取更多信息)。

  • 只要你使用dynamic_cast<>处理引用,就要保证你的程序有一个catch语句来处理可能抛出的std::bad_cast异常。也要注意,在typeid表达式中使用一个NULL指针的试图,比如typeid(*p)p是一个NULL,将导致抛出一个std::bad_typeid异常。

  • 当对指针使用dynamic_cast<> 时,一定要检查返回值。




Contents

© Copyright 1999, Macmillan Computer Publishing. All rights reserved.

翻译:Sttony,2001.11.22

  

抱歉!评论已关闭.