《深度探索C++对象模型》学习笔记(5-7章)

第5章 构造析构和拷贝

虚函数

对于抽象类而言,是否应该自己负责初始化类内的数据成员?(别忘了抽象类也可以有非纯虚函数和数据成员)

  • 一般情况下被认为子类要负责从基类继承来的数据成员的初始化
  • 或者基类必须提供一个显式的构造函数用于自己数据成员的初始化
  • 最好的方法则是将行为和数据分离,提供接口专门用于定义方法
class A_B
{
public:
    virtual ~A_B() = 0;
    virtual void Say() const = 0;
    inline char const* GetData() const { return iData; }
protected:
    A_B( char* aData ) : iData( aData ) {  }
protected:
    char* iData;
};
inline A_B::~A_B() {}
 
class C_D : public A_B // concrete derived class
{
public:
    C_D( char* aData, char* aData2 ) :
           A_B( aData ), iData2( aData2 ) { cout<<"c_d ctor"<<endl; }
    virtual ~C_D() { delete iData2; }
    virtual void Say() const { cout<<"c_d says: "<<iData<<endl; }
    void Say2() const { cout<<"c_d says2: "<<iData2<<endl; }
public:
    char* iData2;
};

纯虚函数

  • 拥有定义的纯虚函数(这还叫纯虚函数么)可以在子类中进行调用。不过就是什么用都没有罢了。
//定义
inline void A_B::Say() const { cout<<"hey!"; }
//调用,发现hey!并没有打印出来
virtual void Say() const { A_B::Say(); cout<<"c_d says: "<<iData<<endl; }
  • 实际上是否让纯虚函数拥有(没用的)定义由程序员说了算。
  • 但唯一的例外是纯虚析构函数,必须像上面那样进行显式定义 A_B::~A_B() {}
    • 否则就会出现错误,严格来说并非编译错误,而是链接错误。
    • 原因很简单,析构函数和构造函数一样,子类的都会由编译器进行扩充,调用基类的版本。
    • 不希望这么定义的话,解决方法只有生命非纯虚的析构函数

Virtual Specification

  • 不加选择地将函数设定为virtual在效率上会不升反降,不能依赖编译器的优化
  • 应付const也会令人头疼,例如在基类中不需要修改的const ref或者const pointer,到了子类就需要修改了。目前最好的方法是不用const(……无语)
  • 需要考虑到的(不全)
    • 抽象类的构造函数需要负责初始化自己的数据成员,可以声明为protected避免外部访问
    • 缺少多态需求的函数不要设定为virtual,尤其是inline函数
    • 慎用const,除非确定派生类也不会修改const修饰的对象

对象构造

无继承情况下的对象构造(限于一个class内都是public访问级别的对象)

  • class B { public: int a, b, c; }
  • C中的全局变量会被视为“临时性的定义”而只保留一个实体,位于DS的末端,称为BSS(Block Started by Symbol)的区域
  • 对于new和delete运算符的操作,不会触发trivial构造函数和析构函数
  • 而传值方式的return操作,感觉上会触发trivial copy constructor,但实际上只是位拷贝
  • 显式地设定类对象的数据成员值如何?例如B b; b.a = 1; b.b = …
    • 效率不如初始化列表高
    • 而且所设定的成员访问范围只能是public
    • 但也不是一无是处。对于大数据量、常量赋值的情况,显示设定数据的方式反而效率更高一点

多了private接口和inline的构造函数,但无virtual特性

  • class B { public: B ( int aA = 0, int aB = 0, int aC = 0 ) : a(aA), b(aB), c(aC) {} private: int a, b, c; }
  • 大小不会变化,默认的构造函数、析构函数和拷贝构造也够用了。提供初始化列表会比较高效
  • 编译器会进行优化,将inline的构造函数在对象声明处(不是构造函数内!)展开为显示设定成员的方式,利用常量赋值
  • new运算符则会有些变化 B* b = __operator_new( sizeof (B) ); if ( b != NULL ) then b->B::B(); 多了一个指针大小非零的判断
  • 而其他的,例如delete运算符、传值返回等,和上面的一样

多了virtual函数的引入

  • class B { public: B ( int aA = 0, int aB = 0 ) : a(aA), b(aB) {} virtual int C(); private: int a, b, c; }
  • 最先想到的:每一个B对象都会有一个vptr指向vtbl,这个vptr占用一个字长。这种额外消耗是否值得要看多态所带来的价值是否大于vptr所带来的空间消耗
  • 其他的代价
    • 构造函数会自动被添加代码,用于初始化vptr。这些代码位于所有的基类构造函数之后、程序员的代码之前。(展开后)类似于
B* B::B( B* this, int aA, int aB )
{
this->__vptr_point = __vtbl_point;
this->a = aA;
this->b = aB;
return this;
}
  • 不再为trivial的拷贝构造函数和拷贝运算符函数(但隐式的析构函数仍然是trivial的)
    • 类似于构造函数,需要拷贝vptr以及相关的数据成员(参看“构造函数:拷贝构造函数”),有可能是非bitwisely copy。这也是需要编译器合成不再trivial的拷贝函数(运算符)的原因之一。
  • 另外一个原因则是返回值的需要。作为引用参数之一的返回值需要拷贝构造函数获得vptr,同时销毁局部变量。
  • 由此引出的想法:有关显式拷贝构造函数的必要性——大量传值返回某类的情况下。

有继承对象构造

回顾:构造函数的工作

  • 如果有的话,调用所有虚基类构造函数
  • 如果有基类的话,调用基类的构造器。如果有多个基类,调用顺序为声明的顺序
    • 位于构造列表里面的,直接以参数进行调用
    • 并非位于列表里面的,调用默认的构造函数
    • 如果是多重继承的基类,那么需要进行this指针的调整
  • 初始化vptr指向相应的vtbl
  • 函数初始化列表会被编译器展开放置于构造函数之中(参见2.3)
  • 没有位于列表中的数据成员,如果拥有默认构造器,那么需要进行调用

拷贝构造函数中“自我拷贝”检查的必要性

  • 并非大问题,但在B b1; b2 = b1; b1 = b2;情况下,如果没有进行this指针的检查,类似的操作会导致额外的开销
  • 目前的编译器都不会对此进行检查,需要程序员进行自律

有继承情况下的对象构造,虚拟继承

  • 回顾:虚拟继承需要添加虚基类子对象(virtual base class subobject),几种解决方式(参见3.3)
  • 在虚拟继承中单纯地对每一个构造函数进行扩充是不可行的。
    • 需要增加一个是否为最底层对象的检查,如果是才负责初始化从最上层基类所继承下来的数据成员。

vptr初始化

class Base { public: virtual void Info() { cout<<"base::info"<<endl; } protected: int iBaseValue; };
class Derive1 : public virtual Base { public: virtual void Info() { cout<<"derived1::info"<<endl; } protected: int iDerive1Value; };
class Derive2 : public virtual Base { public: virtual void Info() { cout<<"derived2::info"<<endl; } protected: int iDerive2Value; };
class DD : public Derive1, public Derive2 { public: virtual void Info() { cout<<"dd::info"<<endl; } protected: int iDDValue; };
class DDD : public DD { public: virtual void Info() { cout<<"ddd::info"<<endl; } private: int iDDDValue; };
  • 基类指针指向的子类对象,调用的函数实体为子类对象的
   DDD ddd;
   Derive1 d1;
   Base* b = &amp;ddd;
   b->Info(); //"ddd::info"
   b = &amp;d1;
   b->Info(); //"derived1::info"
  • 如果在构造函数中调用成员函数会如何?构造一个子类对象时候,每次的构造函数会被认为是调用最底层子类的还是当前类本身的?
    • 当然是类本身的!因为构造的时候是按照从上到下的顺序。derive1构造的时候,ddd还尚未在内存中出现,更不要提它继承出来的Info函数了
    • 同时这也是构造函数不可为虚函数的一个原因(抽象工厂除外)
base::info
derived1::info
derived2::info
dd::info
ddd::info
  • 再考虑一种情况,如果Info()又调用了一个虚函数,例如VF()会如何?按照上面第二条的说法,运行时应该调用的是子类的函数。但构造时候很明显会调用基类的
    • 实验的结果是,构造函数时候调用了各个基类自己的VF(),而运行的时候不出所料,就算用Base*指向了子类对象,调用的也是ddd::VF()。
    • 编译器的做法是对于在构造函数中的虚函数调用进行特殊处理,声明为静态方式的调用,不要用虚拟特性解决
    • 说起来简单,但实际上编译器所做的工作要复杂得多,首先要通过vptr拿到vtbl,获取虚函数的列表,这些函数都是在构造函数调用中不能表现出虚拟特性的
    • 由此而衍生出的问题是vptr的初始化时间。显然要早于任何程序员写在构造函数中的代码,但要晚于vtbl和基类构造函数的创建
    • 通过这么多学习,再次回顾构造函数步骤时候会觉得比之前要清晰很多,显然我们在构造一个基类的时候,首先要考虑虚拟继承的情况,判断自己是否为最底层的子类,如果是的话要负责构造基类;之后按照顺序构造所有基类;接下来根据基类的信息拿到vtbl中的虚函数列表;最后执行程序员自己写的代码

构造函数初始化列表中调用虚函数是否安全?

  • 形如 Derived( int aValue, int aValue2 ) : Base( F( aValue ) ), iValue ( aValue2 ) { } // F为虚函数
  • 分情况讨论。如果不需要对基类在初始化列表中同样进行初始化,那么应该是安全的。考虑一下,vptr在此时应该已经设置完毕,展开初始化列表的时候,虚函数可以正确地通过指针拿到。当然有一点需要注意的是被调用的虚函数可能会使用没有初始化的数据成员,但这不是我们讨论的问题了。所以结论是,OK,没问题,只是不推荐这么做而已。
  • 如果需要在初始化列表中对基类进行初始化,那么是绝对不安全的!基类的构造要早于子类的函数被创建,但在初始化列表展开之前,基类构造的时候就要使用子类虚函数的话,很显然是行不通的:有可能指向定义这个函数的vtbl的vptr尚未创建成功,或者可能指向了错误的类对象。(不过在gcc上貌似没有什么问题)

拷贝和赋值

依然先要回顾一下

  • 什么时候编译器会生成一个默认的拷贝构造函数?需要的时候,但并非所有情况(参见2.2)
  • 是否需要自己写拷贝构造函数需要斟酌。对于bitwise copy足够的情况没有必要自己编写

对于拷贝运算符来说以下情况是不会进行Bitwise copy的(同样参见2.2构造函数:拷贝构造函数)

  • 数据成员中有拥有拷贝运算符的类型
  • 基类中有拷贝运算符
  • 类中有虚函数(因为等号右边的对象可能是个子类)
  • 继承自虚基类

其实就是一句话……如果默认的bitwise copy(memberwise assignment)不能满足需要的时候,会出现默认的拷贝运算符 如果默认的也不能满足需要,那就得自己写一个拷贝运算符进行深拷贝 编译器在需要的时候会生成拷贝运算符

  • 生成出来的类似于:
inline D&amp; D::operator=( D* const this, D&amp; rhs )
{
this->B::operator=( rhs );
iValue = rhs.Value;
return this;
}
  • 可惜只能这么做,因为没有一个类似于构造函数一样的“拷贝初始化列表”
  • 通过this指针取到基类运算符进行赋值也可以用这个代替: ( *(B*)this) ) = rhs;,这样转化成了通过基类指针对对象进行值拷贝
  • 关键问题就在于怎么拿到基类的运算符重载函数,另外则是如果有多继承的情况,如何只(在最底层的子类)执行一次拷贝操作?
    • 嗯……通过指向成员函数的指针:
typedef B&amp; (B::*POF)(const B&amp;);
POF pOF = &amp;B::operator=;
D.*pOF( anotherD );
    • 其丑无比!
  • 其实很多编译器在虚继承的时候处理拷贝运算符比较苦手
  • 所以最简单的方式就是执行多次拷贝,只要保证当前类的调用位于所有的基类之后就行——虽然这样一来虚基类子对象会复制多次

析构

同构造函数、拷贝函数的情况类似,析构函数只有在需要的时候(基类有析构函数、数据成员有析构函数)的情况下才被生成出来 顺序和构造函数正好相反

  • 析构函数执行程序员的代码
  • 如果类的数据成员拥有析构函数,那么也会调用,不过顺序和声明相反
  • 对象内有vptr的话,那么重设vtbl
  • 如果基类拥有析构函数的话,同样相反调用
  • 如果虚基类拥有析构函数的话,且当前类是最底层的子类,那么同样相反调用

整个过程类似“脱壳”一样,从下至上地析构,子类逐渐地变为上层基类、上上层基类、上上上层……(如果有的话) 两种析构函数的版本

  • 完整的对象,设置vptrs,调用虚基类的析构函数
  • 基类子对象,除非析构函数中调用了虚函数,否则不会设置vptrs、调用虚基类的析构函数

第6章 执行期

构造和析构

局部对象的构造析构

  • 对于switch/case、goto等指令析构函数可能会被安排出现多次:每个函数的退出点都需要有一个
  • 但位于局部对象声明之前的,不需要

全局对象

  • 全局对象的生存期为main()之前构造而函数结束前析构
  • 放置的位置为DS(5.2构析拷贝:对象构造)
  • 为了支持类对象的静态初始化,需要在有虚继承的情况下进行指针调整,将子类中vbc pointer的正确位置返回

局部对象

  • 局部静态对象声明一次,只会初始化和销毁一次。初始化的地点和全局对象一样,即时对象所在的函数并未被调用
  • 闭包的概念。
void foo2() { static int i = 0; cout<<i++<<endl; }
 
    foo2(); // 0
    foo2(); // 1

数组

  • 如果数组元素没有显式的构造函数和析构函数,那么分配足够的空间就够了
  • 但如果有的话,参考cfront的解法,定义一个新的函数vec_new,参数分别为数组起始地址、类对象大小、数组元素个数、构造函数和析构函数的指针
    • 与之对应的数组析构函数vec_del只接受一个析构函数指针就足够,然后针对所有元素遍历调用
  • 例如B bases[10];就会变为 vec_new( &bases, sizeof( B ), 10, B::B, 0 );,函数会对于传入的所有对象分别调用其构造和析构函数
    • 程序员提供了初始值的情况呢?例如 B bases[10] = { B(), B( 1 ), 2 };
    • 编译器会解析出已经赋值的部分,将其变为构造函数的调用,再对剩余的对象调用vec_new,当然,数组起始地址和元素个数都会不同
  • 大概类似于B::B( &bases[0], 0 ); B::B( &bases[1], 1 ); B::B( &bases[2], 2 ); vec_new( &bases+3, sizeof( B ), 7, B::B, 0 );
    • cfront 2.0之前,数组无法应付超过一个默认参数的构造函数或者默认构造函数,回顾上面的vec_new函数,我们如何调用到拥有多个默认参数的构造函数指针?

cfront的编译器多加了一个stub的构造函数(同样也是没有参数),里面调用程序员写的带有多个默认参数的构造函数

new和delete

new运算符

  • 简单的一个new运算符语句其实包括多个步骤。例如int* pi = new int( 5 );
    1. 首先调用__new函数进行内存分配int* pi = __new ( sizeof(int ) );
    2. 赋值 *pi = 5。
    3. 实际上第一步中应该多判断一下pi是否不为0再赋值
  • 对于类对象来说。例如Base* pb = new Base( pb, 5 );
    1. 类似于上面的做法,分配内存先 Base* pb; pb = __new( sizeof( B ) );
    2. 赋值 *pb = Base::Base( 5 );
    3. 实际情况需要考虑的还要更多,例如在拷贝构造的时候,函数有可能抛出异常。如果catch到的话需要__delete掉已经分配的内存空间

delete运算符

  • 也是交给内置函数去做的。例如delete pi;
    1. 变为if ( pi != 0 ) __delete( pi );
    2. 不过由于不会重置内存,所以之后再判断pi是否为null甚至*p去取值都有可能成功。
  • 对于类对象的析构来说。例如delete pb;
    1. 变为if ( pb != 0 ) { Base::~Base( pb ); __delete( pb ); }

new和delete运算符的实现

  • 首先是size为0的情况会自动调整为1,这是因为每一次new出来的指针都应该是不同的,所以就算size为0,也要指向不同的1-byte内存区域。
  • 声明void*指针,用malloc拿到相应size的空间并且返回指针
  • delete采用free()实现

数组的new运算符

  • 例如int* pi = new int[5]; 或者MyStruct* ps = new MyStruct[5]; //MyStruct是结构体
    • 内置类型时vec_new并不会调用,因为它作用就是遍历调用构造函数,但上面两种类型都没有
    • 只有__new用来分配空间
  • 对于拥有默认构造器的类来说,数组初始化时候也需要析构函数指针,因为可能发生异常,以便清理已经分配的空间(6.1执行期:构造和析构第4节)
  • C++ 2.0之前在delete掉数组时候,需要程序员手动指定元素个数,例如delete [5] pi;
    • 但之后就不用了,就像我们现在所使用的那样delete[] pi;
    • 实现这个功能需要知道数组首地址和元素的个数。但这样做需要更多性能上的开销。所以如果没带[]调用delete,只会删掉一个元素
    • 具体来说,即将vec_new返回的内存地址多加一个word长度的信息,保存数组长度(所谓cookie)
    • 但Sun编译器的做法是维护一个保存数组指针和大小的额外数组,其中还放置析构函数的地址
  • 继承情况下的问题
    • D继承于B,都实现了虚析构函数。那么B* pb = new D[ 5 ];如何?
    • 语法上OK,但delete [] pb时候问题来了……vec_del只会调用5次B类的析构函数,D的却没有。因为它总是执行被删除类型的析构函数。
      • vec_del只会调用5次B类的析构函数,D的却没有。因为它总是执行被删除类型的析构函数。
      • 更可怕的是,由于传入的元素大小是sizeof( B ),显然在pb++的时候移动的距离并非预期的D的长度,也就是说析构函数指针所作用的内存区域必然会出现错误
    • 如果非得这么干的话,需要程序员自己解决问题:遍历数组每一个元素,然后 D* d = &((D*)pb)[i]; delete d;

new运算符的重载

  • 例如B* b = new (arena) B();,这里的arena为指定的的内存区域
  • 实际上编译器做的事情就是将传入的arena参数返回而已
  • 在编译器其实就相当于B* p = (B*) arena; 不过要做的事情除了决定arena的类型,还得 p->B::B();
  • 重复利用一块区域进行构造如何?例如上面操作后再 b = new (arena) B();
    • 那么之前new出来的对象不会被析构。
    • 直接delete b的话,是有问题的,虽然可以调用到析构函数,但b占用的内存区域同样也回收了
    • 所以需要b->~b();
    • 不过标准C++中不需要这么做,编译器的__delete();会进行析构但不释放内存区域
  • C++标准里面规定,利用同一块内存构造新对象的时候,要么是同一类型,要么就是全新的区域,并且大小足够放下这个object
  • 这么一来,继承的类就不好说了,所以这种new运算符的重载基本是不支持多态的
B b;
    b.ToString();
    b.~B();
    new ( &amp;b ) D;
    b.ToString();
    • 哪个类的ToString被调用了?实际上是B的……

临时对象

不太严谨但清晰的定义是:没有别名的局部对象 T a, b; T c = a + b;

  • 不能肯定是否调用了c的拷贝构造函数,并且利用a+b产生的临时对象作为参数
  • 因为可能产生的NRV优化以及不同的加号运算符重载,可能会直接将a+b的值放入c内

对于T c = a + b; 和T c; c = a + b;来说可能导致完全不同的结果

  • 前者一般都会调用重载的等号运算符,直接传入局部对象引用作为第一个参数
  • 而后者总是会产生临时对象,调用其等号运算符、再析构
  • 另外后者需要保证“c是全新的”,而等号运算符又不会调用析构,所以必须保证在赋值前进行析构。
  • 一般而言前者效率更好

关键问题在于临时对象的生存期,即何时调用它的析构函数才合适

  • C++标准规定:对于临时对象的析构应该是完整表达式求值的最后一个步骤
  • 换句话说在表达式被完全求值之前,所有为此产生的临时对象都不能销毁
  • 也有例外
    • 利用表达式初始化对象 String s = flag ? null : s1 + s2;,也就是说在?后s1+s2产生的临时对象就要析构了。但如果String需要用拷贝构造函数进行初始化的话,必然会位于析构之后,所得到的值也会是错误的。
    • 利用对象初始化引用时。如果String& s = s1 + s2; 如果引用的临时对象析构了,那引用就没意义了。所以这种情况下,临时对象会在引用失效或者自身失效后进行析构,哪个先到算哪个。

第7章 终章

Template

Template是个非常强大的语法,毋庸置疑。它提供了C++的泛型特性。但由于使用不便,很多人不愿意用。相较于更现代的语言,例如C#和Java,C++利用Template实现的泛型确实不太好理解。 Template的“绑定”会直接替换实际的类型到形参上 对于在Template类里面生命的各种成员而言,编译器在处理Template时候,它们还没有被生成。只有绑定了类型之后才可以使用。例如一个Class B拥有一个enum对象TStatus。但不能直接用B::Status,而只能B::Status。

  • 所以对于常量之类的,尽量从Template类中提取出来,避免多次拷贝

对于指针和引用的区别对待

  • B* b = 0;这样做不会产生一个B的实体出来,实际上现在C++标准也禁止编译器这么做
  • B& b = 0;这样则会产生一个实体。因为引用不能为空,展开后类似于B tmp( int( 0 ) ); const B& b = tmp;

Template的实例化(instantiation)

  • 指针和引用的例子参见上一条
  • 并非一次性都实例化完成,用到的才实例化。例如B* b = new B(0); 只会实例化Template本身、构造、析构函数。这是出于效率的考虑。另外new运算符虽然是static的,但它也间接依赖于Template的参数:对象的大小。

Template的错误处理

  • 一些潜在的错误(非语法错误):例如给指针赋了错误的值(int* = 1024)、使用类没有实现的运算符。不过编译器都会在实例化Template时候判断出来

名称问题

  • Template类的定义范围内有一个extern函数,和使用处定义的一个extern函数重名了,那么在Template类成员函数中调用的到底是那个函数?
//template定义范围内
extern double foo( double aValue );
template<class T>
class C1
{
public:
    bar() { _v1 = foo( _v2 ); }
    bar2( T aValue ) { _v1 = foo( aValue ); }
private:
    T _v1;
    int _v2;
//template使用范围内
extern int foo( int aValue );
C1<int> c;
c.bar();
  • 上例中是定义范围内的那个,而不是适用范围内,符合参数类型的那个。
  • 原则:调用的函数是否和使用的类型相关,不相关就直接使用定义范围的。本例中_v2是很明显的int,不会随着模板参数类型变化而变化,所以直接使用定义范围的。所以如果是调用了bar2()的话,那就会选择使用范围的那个foo()。

成员函数

  • 编译器寻找函数定义:遵循一定的规则,例如一定在头文件对应的同名cpp文件内
  • 寻找成员函数:Borland生成所有函数;cfront的方法是模拟链接(simulate linkage),只生成需要的
  • 成员函数实体的唯一性:生成一份,然后在.o中链接

异常处理

中心思想:发生异常的时候查找catch,将局部变量析构掉后让当前函数unwind

  • 所以看起来很像的代码可能由于异常处理机制的存在而拥有差别很大的执行期语义

对于类似互斥锁之类的操作,如果异常发生在lock之后,如何进行unlock?

  • 提供多个unlock操作,分别放在可能的路径上
  • 对于更现代的语言来说,都拥有类似finally这样子句来支持异常发生后的对象清理操作

再例如一个子类指针指向的数组,分配到某一个元素的基类区域时候发生了一场,那么之前的元素都需要分别调用子类和基类的析构,而发生错误的那个元素只要调用基类的即可,因为子类尚未构造出来。 代码会被划分为多个区段,因为在异常发生前的对象都需要调用析构进行清除,之后的则不用

  • try以外的,没有局部对象的代码段、try以外的,有局部对象的代码段,try代码段
  • 实现方法:program counter-rage table。记录各个代码段的起始/终止位置
  • 发生异常的时候,如果当前位置位于try代码段之中,那么就寻找try字句处理

异常发生的时候会产生异常对象(exception object),放置在异常栈中

  • throw出去的是地址、type identifier以及可能的异常对象TI
  • 如果跑出了异常子类而catch的是基类,exObj会被slice off掉非基类的部分
  • catch字句结束时,当前的局部exObj就会被销毁。所以如果再一次抛出异常的话,捕获的又是原来的对象了(这节翻译的也太烂了!)

RTTI

cfront2.0之前,转型运算符*是不能够被重载的

  • 不过之后就可以了

downcast:有用、常用甚至是必要的——但不一定安全的操作 类型安全的downcast操作

  • 为了支持安全的downcast需要额外开销:类型信息(TI)和运行时的类型判断(runtime type)
  • 所以TI的信息就放在vtbl第一个slot位置了

类型安全的动态cast操作

  • 编译器做了额外的事情帮助程序员处理downcast带来的问题——如果转换失败,返回null而不是指向错误内存的指针
  • 顺便总结一下三种cast操作的特点(const不用说了)
    • reinterpret_cast。仅指针。编译器解释。不修改格式。不安全。
    • static_cast。继承。基本不用于指针,效率不高。不安全。
    • dynamic_cast。继承。运行时。指针或引用。安全。
  • dynamic_cast的额外开销:执行期通过需要转换的指针获得类型描述
    • (( type_info* )( p->__vptr[0] ))->type_descriptor
    • 拿到之后要交给一个内置的函数判断p指向的类型和这个类型描述是否相符
    • 本来贝尔实验室的人准备就用type()类似这样的运算符实现dynamic_cast,但C++标委会的人认为这样做和static_cast就没区别了,体现不出来dynamic_cast需要的额外开销

重提:引用不是指针

  • 和指针不同,引用不能指向到0,所以dynamic_cast失败的时候,只能引发一个异常

Typeid运算符

  • typeid返回的是一个对于type_info的const reference
  • 它的==运算符也是被重载的
  • type_info的实际结构包括的信息:类名、和其他type_info对象的位置关系、类原名、子类型信息等

2 Responses to “《深度探索C++对象模型》学习笔记(5-7章)”

  1. Adoo 说道:

    不错的笔记,受教。

  2. Adoo 说道:

    另外,想与哥们交换个链接,不知可否。

Leave a Reply