`

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

    博客分类:
  • C++
 
阅读更多

转自<http://www.cnblogs.com/carter2000/archive/2012/04/28/2474960.html>

 

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

 

#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指针必定相等。

#include <iostream>

class Base
{
public:
    Base() { PrintBase(); }
    virtual ~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[1];   ///< 注意这里索引变成 1 了,因为析构函数定义在Foo之前
        (*pFoo)(this);

        std::cout << std::endl;
    }

    virtual void  Foo() { std::cout << "Base" << std::endl; }
};

class Derive : public Base
{
public:
    Derive() : Base() { PrintDerive(); }
    virtual ~Derive() { 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[1];   ///< 注意这里索引变成 1 了,因为析构函数定义在Foo之前
        (*pFoo)(this);

        std::cout << std::endl;
    }

    virtual void Foo() { std::cout << "Derive" << std::endl; }
};

int main()
{
    Base* p = new Derive();
    delete p;
    return 0;
}

   打印虚函数表的地址,是为了表明在创建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: 002E7F98
Address of Base Vtable: 01387840
Call Foo by vt -> Base

Address of Derive: 002E7F98
Address of Derive Vtable: 01387834
Call Foo by vt -> Derive

 在析构函数中调用虚函数,和在构造函数中调用虚函数一样。

 

  析构函数的调用跟构造函数的调用顺序是相反的,它从最派生类的析构函数开始的。也就是说当基类的析构函数执行时,派生类的析构函数已经执行过, 派生类中的成员数据被认为已经无效。假设基类中虚函数调用能调用得到派生类的虚函数,那么派生类的虚函数将访问一些已经“无效”的数据,所带来的问题和访 问一些未初始化的数据一样。而同样,我们可以认为在析构的过程中,虚函数表也是在不断变化的。

  将上面的代码增加析构函数的调用,并稍微修改一下,就能验证这一点:

#include <iostream>

class Base
{
public:
    Base() { PrintBase(); }
    virtual ~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[1];   ///< 注意这里索引变成 1 了,因为析构函数定义在Foo之前
        (*pFoo)(this);

        std::cout << std::endl;
    }

    virtual void  Foo() { std::cout << "Base" << std::endl; }
};

class Derive : public Base
{
public:
    Derive() : Base() { PrintDerive(); }
    virtual ~Derive() { 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[1];   ///< 注意这里索引变成 1 了,因为析构函数定义在Foo之前
        (*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

 最终结论:

    1. 不要在构造函数和析构函数中调用虚函数,因为这种情况下的虚函数调用不会调用到外层派生类的虚函数(参考:http://www.artima.com/cppsource/nevercall.htmlhttp://www.parashift.com/c%2B%2B-faq-lite/strange-inheritance.html#faq-23.5)。

    2. 对象的虚函数表地址在对象的构造和析构过程中会随着部分类的构造和析构而发生变化,这一点应该是编译器实现相关的。

注:以上的讨论是基于简单的单继承,对于多重继承或虚继承会有一些细节上的差别。

 

分享到:
评论

相关推荐

    为什么构造函数不能是虚函数

    - 这是因为构造函数调用的顺序是从基类到派生类,而在构造函数中调用虚函数时,虚拟表(VTable)中的指针还未被更新为最终派生类的版本,导致调用的总是当前类的函数。 4. **潜在的安全问题** - 如果允许在构造函数...

    构造函数不能声明为虚函数的原因及分析

    1. 从存储空间角度,虚函数对应一个指向vtable虚函数表的指针,这大家都知道,可是这个指向vtable的指针其实是存储在对象的内存空间的。问题出来了,如果构造函数是虚...而构造函数是在创建对象时自动调用的,不可能通

    c++ 构造函数中调用虚函数的实现方法

    然而,当我们尝试在构造函数中调用虚函数时,情况变得稍微复杂一些。下面我们将详细讨论这个问题。 首先,理解C++对象的构造过程至关重要。在创建一个对象时,构造函数会按照继承层次自底向上顺序执行。这意味着先...

    构造函数不能为虚函数的理由

    这就是为什么构造函数调用是从基类到更加派生类顺序的另一个理由。 但是,当这一系列构造函数调用正发生时,每个构造函数都已经设置 V PTR 指向它自己的 V TABLE。如果函数调用使用虚机制,它将只产生通过它自己的 ...

    构造函数不能声明为虚函数,析构函数可以声明为虚函数

    构造函数不能声明为虚函数主要是因为构造过程中对象的动态类型尚未确定,而析构函数可以声明为虚函数以确保正确释放资源并避免内存泄漏等问题。理解这些基本原则对于编写高质量、健壮的C++程序至关重要。

    基类子类虚函数的调用顺序问题

    3. 如果需要在子类构造函数中调用重写后的虚函数,可以考虑使用构造函数初始化列表或者在子类构造体内部完成,以确保对象完全初始化后再调用虚函数。 了解这个原理对于编写复杂继承结构的程序非常重要,因为它可以...

    C++箴言:避免析构函数调用虚函数

    ### C++箴言:避免析构函数调用虚函数 #### 概述 在C++编程中,理解和遵循良好的设计模式对于确保程序的稳定性和可维护性至关重要。其中一个经常被提及的原则是“避免在析构函数中调用虚函数”。这一原则在C++语言...

    winform 构造方法中调用虚方法源码

    本资源提供的"winform 构造方法中调用虚方法源码"是一个关于面向对象编程的实例,探讨了如何在WinForm控件的构造函数中调用虚方法。 在C#或.NET编程中,构造函数是类的一个特殊方法,用于初始化新创建的对象。而虚...

    C++ 虚继承对基类构造函数调用顺序的影响

    而继承包含了虚拟继承和普通继承,在可见性上分为public、protected、private。可见性继承比较简单,而虚拟继承对学习c++的难度较大。  首先,虚拟继承与普通继承的区别有:  假设derived 继承自base类,那么...

    C# 构造函数如何调用虚方法

    因此,在构造函数中调用虚方法可能会导致一些问题,因为虚方法可能会被子类重写,而在构造函数中调用虚方法可能会导致一些意外的结果。 例如,以下代码可能会导致警告“virtual member call in constructor”: ...

    C++中的类中函数调用

    这里,通过基类指针调用虚函数,根据指向的对象类型动态决定调用哪个版本的函数,体现了多态性的特点。 ### 总结 C++中类中函数的调用是面向对象编程的重要组成部分,它不仅提供了封装和抽象的能力,还支持继承和...

    C++箴言:避免析构函数调用虚函数[归类].pdf

    在C++编程中,有一条重要的原则是避免在构造函数或析构函数中调用虚函数。这条箴言尤其对那些从C#或Java转向C++的开发者来说可能显得有些反直觉,因为在这些语言中,这样的调用通常是允许的。然而,在C++中,这样做...

    派生类构造函数和析构函数的执行顺序

    这是因为,构造函数是在对象创建时被调用的,而虚函数是在对象已经创建后被调用的。 派生类构造函数和析构函数的执行顺序是非常重要的。正确地理解和使用这两个函数,可以实现代码的重复利用和提高程序的效率。

    构造函数Demo

    在C#编程语言中,构造函数(Constructor)是类的一个特殊方法,它的主要作用是在创建对象时初始化新实例。构造函数的名称必须与类名完全相同,并且没有返回类型,即使是void也不行。本篇文章将深入探讨C#中的构造...

    C++构造函数,复制构造函数和析构函数专题[1].pdf

    在C++编程语言中,构造函数、复制构造函数和析构函数是面向对象特性的重要组成部分,它们在对象的生命周期管理中扮演着至关重要的角色。同时,继承、虚函数和多态性是C++实现面向对象设计的核心概念。下面将详细讨论...

    一起学C#:构造函数和析构函数.rar

    2. **访问修饰符**:构造函数通常为`public`,以便在类的外部创建对象时调用。当然,也可以根据需求定义`private`、`internal`或`protected`的构造函数。 3. **无返回值**:构造函数不同于普通方法,它不能有任何...

    C++有子对象的派生类的构造函数

    2. 显式构造函数调用:可以使用成员初始化列表(member initialization list)来指定子对象的构造函数,并传递参数。这种方式可以提高效率,因为避免了默认构造后再赋值的额外开销。 三、派生类构造函数 1. 继承...

    完全掌握C++编程中构造函数使用的超级学习教程

    这里,`m_width`, `m_length`, 和 `m_height` 在构造函数调用时直接初始化。 6. 显式构造函数: 显式构造函数通过使用`explicit`关键字来防止隐式类型转换,防止可能导致意外行为的自动类型转换。例如,如果`Box`...

    c#析构构造函数c#析构构造函数c#析构构造函数

    C#中的析构构造函数(Destructor)是一种特殊的方法,它在对象即将被垃圾回收器清除时执行,用于释放非托管资源。在C#中,析构函数以`~`符号开头,后面跟着类名,例如`~MyClass()`. 它们不同于常规的构造函数,构造...

Global site tag (gtag.js) - Google Analytics