为什么不要在构造函数中调用虚函数

本文探讨了C++中构造函数直接调用虚函数的行为,解释为何此时不会调用派生类的重写版本,并通过实验验证了对象的虚函数表在构造过程中的变化。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

先看一段在构造函数中直接调用虚函数的代码:

#include <iostream>
 
 class Base
 {
 public:
     Base() { Foo(); }   ///< 打印 1
 
     virtual void Foo()
     {
         std::cout << 1 << std::endl;
     }
 };
 
 class Derive : public Base
 {
 public:
     Derive() : Base(), m_pData(new int(2)) {}
     ~Derive() { delete m_pData; }
 
     virtual void Foo()
     {
         std::cout << *m_pData << std::endl;
     }
 private:
     int* m_pData;
 };
 
 int main()
 {
     Base* p = new Derive();
     delete p;
     return 0;
 }


 这里的结果将打印:1。

  这表明第6行执行的的是Base::Foo()而不是Derive::Foo(),也就是说:虚函数在构造函数中“不起作用”。为什么?

  当实例化一个派生类对象时,首先进行基类部分的构造,然后再进行派生类部分的构造。即创建Derive对象时,会先调用Base的构造函数,再调用Derive的构造函数。

  当在构造基类部分时,派生类还没被完全创建,从某种意义上讲此时它只是个基类对象。即当Base::Base()执行时Derive对象还没被完全创建,此时它被当成一个Base对象,而不是Derive对象,因此Foo绑定的是Base的Foo。

  C++之所以这样设计是为了减少错误和Bug的出现。假设在构造函数中虚函数仍然“生效”,即Base::Base()中的Foo();所调用的是Derive::Foo()。当Base::Base()被调用时派生类中的数据m_pData还未被正确初始化,这时执行Derive::Foo()将导致程序对一个未初始化的地址解引用,得到的结果是不可预料的,甚至是程序崩溃(访问非法内存)。

  总结来说:基类部分在派生类部分之前被构造,当基类构造函数执行时派生类中的数据成员还没被初始化。如果基类构造函数中的虚函数调用被解析成调用派生类的虚函数,而派生类的虚函数中又访问到未初始化的派生类数据,将导致程序出现一些未定义行为和bug。

  对于这一点,一般编译器会给予一定的支持。如果将基类中的Foo声明成纯虚函数时(看下面代码),编译器可能会:在编译时给出警告、链接时给出符号未解析错误(unresolved external symbol)。如果能生成可执行文件,运行时一定出错。因为Base::Base()中的Foo总是调用Base::Foo,而此时Base::Foo只声明没定义。大部分编译器在链接时就能识别出来。

#include <iostream>
 
 class Base
 {
 public:
     Base() { Foo(); }   ///< 可能的结果:编译警告、链接出错、运行时错误
 
     virtual void Foo() = 0;
 };
 
 class Derive : public Base
 {
 public:
     Derive() : Base(), m_pData(new int(2)) {}
     ~Derive() { delete m_pData; }
 
     virtual void Foo()
     {
         std::cout << *m_pData << std::endl;
     }
 private:
     int* m_pData;
 };
 
 int main()
 {
     Base* p = new Derive();
     delete p;
     return 0;
 }

 如果编译器都能够在编译或链接时识别出这种错误调用,那么我们犯错的机会将大大减少。只是有一些比较不直观的情况(看下面代码),编译器是无法判断出来的。这种情况下它可以生成可执行文件,但是当程序运行时会出错。


#include <iostream>
 
 class Base
 {
 public:
     Base() { Subtle(); }   ///< 运行时错误(pure virtual function call)
 
     virtual void Foo() = 0;
     void Subtle() { Foo(); }
 };
 
 class Derive : public Base
 {
 public:
     Derive() : Base(), m_pData(new int(2)) {}
     ~Derive() { delete m_pData; }
 
     virtual void Foo()
     {
         std::cout << *m_pData << std::endl;
     }
 private:
     int* m_pData;
 };
 
 int main()
 {
     Base* p = new Derive();
     delete p;
     return 0;
 }

 从编译器开发人员的角度上看,如何实现上述的“特性”呢?

  我的猜测是在虚函数表地址的绑定上做文章:在“当前类”(正在被构造的类)的构造函数被调用时,将“当前类”的虚函数表地址绑定到对象上。当基类部分被构造时,“当前类”是基类,这里是Base,即当Base::Base()的函数体被调用时,Base的虚函数表地址会被绑定到对象上。而当Derive::Derive()的函数体被调用时,Derive的虚函数表地址被绑定到对象上,因此最终对象上绑定的是Derive的虚函数表。

  这样编译器在处理的时候就会变得很自然。因为每个类在被构造时不用去关心是否有其他类从自己派生,而不需要关心自己是否从其他类派生,而只要按照一个统一的流程,在自身的构造函数执行之前把自身的虚函数表地址绑定到当前对象上(一般是保存在对象内存空间中的前4个字节)。因为对象的构造是从最基类部分(比如A<-B<-C,A是最基类,C是最派生类)开始构造,一层一层往外构造中间类(B),最后构造的是最派生类(C),所以最终对象上绑定的就自然而然就是最派生类的虚函数表。

  也就是说对象的虚函数表在对象被构造的过程中是在不断变化的,构造基类部分(Base)时被绑定一次,构造派生类部分(Derive)时,又重新绑定一次。基类构造函数中的虚函数调用,按正常的虚函数调用规则去调用函数,自然而然地就调用到了基类版本的虚函数,因为此时对象绑定的是基类的虚函数表。

  下面要给出在WIN7下的Visual Studio2010写的一段程序,用以验证“对象的虚函数表在对象被构造的过程中是在不断变化的”这个观点。

  这个程序在类的构造函数里做了三件事:1.打印出this指针的地址;2.打印虚函数表的地址;3.直接通过虚函数表来调用虚函数。

  打印this指针,是为了表明创建Derive对象是,不管是执行Base::Base()还是执行Derive::Derive(),它们构造的是同一个对象,因此两次打印出来的this指针必定相等。

  打印虚函数表的地址,是为了表明在创建Derive对象的过程中,虚函数表的地址是有变化的,因此两次打印出来的虚函数表地址必定不相等。

  直接通过函数表来调用虚函数,只是为了表明前面所打印的确实是正确的虚函数表地址,因此Base::Base()的第19行将打印Base,而Derive::Derive()的第43行将打印Derive。

  注意:这段代码是编译器相关的,因为虚函数表的地址在对象中存储的位置不一定是前4个字节,这是由编译器的实现细节来决定的,因此这段代码在不同的编译器未必能正常工作,这里所使用的是Visual Studio2010。


#include <iostream>
 
 class Base
 {
 public:
     Base() { PrintBase(); }
 
     void PrintBase()
     {
         std::cout << "Address of Base: " << this << std::endl;
 
         // 虚表的地址存在对象内存空间里的头4个字节
         int* vt = (int*)*((int*)this);
         std::cout << "Address of Base Vtable: " << vt << std::endl;
 
         // 通过vt来调用Foo函数,以证明vt指向的确实是虚函数表
         std::cout << "Call Foo by vt -> ";
         void (*pFoo)(Base* const) = (void (*)(Base* const))vt[0];
         (*pFoo)(this);
 
         std::cout << std::endl;
     }
 
     virtual void  Foo() { std::cout << "Base" << std::endl; }
 };
 
 class Derive : public Base
 {
 public:
     Derive() : Base() { PrintDerive(); }
 
     void PrintDerive()
     {
         std::cout << "Address of Derive: " << this << std::endl;
 
         // 虚表的地址存在对象内存空间里的头4个字节
         int* vt = (int*)*((int*)this);
         std::cout << "Address of Derive Vtable: " << vt << std::endl;
 
         // 通过vt来调用Foo函数,以证明vt指向的确实是虚函数表
         std::cout << "Call Foo by vt -> ";
         void (*pFoo)(Base* const) = (void (*)(Base* const))vt[0];
         (*pFoo)(this);
 
         std::cout << std::endl;
     }
 
     virtual void Foo() { std::cout << "Derive" << std::endl; }
 };
 
 int main()
 {
     Base* p = new Derive();
     delete p;
     return 0;
 }


Address of Base: 001E7F98
 Address of Base Vtable: 01297844
 Call Foo by vt -> Base
 
 Address of Derive: 001E7F98
 Address of Derive Vtable: 01297834
 Call Foo by vt -> Derive
 
 Address of Derive: 001E7F98
 Address of Derive Vtable: 01297834
 Call Foo by vt -> Derive
 
 Address of Base: 001E7F98
 Address of Base Vtable: 01297844
 Call Foo by vt -> Base


http://www.cnblogs.com/carter2000/archive/2012/04/28/2474960.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值