C++学习-构造函数、析构函数与虚函数的关系

news/2024/5/18 21:41:23 标签: 构造函数, 析构函数, 虚函数, virtual, C++

文章主要探讨两个问题:

那么我们现在先来回答第一个问题:

构造函数和析构函数能否是虚函数">1 构造函数析构函数能否是虚函数

C++:构造函数析构函数能否为虚函数

简单回答是:构造函数不能为虚函数,而析构函数可以且常常是虚函数

(1) 构造函数不能为虚函数

让我们来看看大牛C++之父 Bjarne Stroustrup 在《The C++ Programming Language》里是怎么说的:

To construct an object, a constructor needs the exact type of the object it is to create. Consequently, a constructor cannot be virtual. Furthermore, a constructor is not quite an ordinary function, In particular, it interacts with memory management in ways ordinary member functions don’t. Consequently, you cannot have a ponter to a constructor.

From 《The C++ Progamming Language》15.6.2

然而大牛就是大牛,这段话对一般人来说太难理解了。那下面就试着解释一下为什么:

这就要涉及到C++对象的构造问题了,C++对象在三个地方构建:(1)函数堆栈;(2)自由存储区,或称之为堆;(3)静态存储区。无论在那里构建,其过程都是两步:首先,分配一块内存;其次,调用构造函数。好,问题来了,如果构造函数虚函数,那么就需要通过vtable 来调用,但此时面对一块 raw memeory,到哪里去找 vtable 呢?毕竟,vtable 是在构造函数中才初始化的啊,而不是在其之前。因此构造函数不能为虚函数

(2)析构函数可以是虚函数,且常常如此

这个就好理解了,因为此时 vtable 已经初始化了;况且我们通常通过基类的指针来销毁对象,如果析构函数不为虚的话,就不能正确识别对象类型,从而不能正确销毁对象。

困惑我们的是我们却经常看到“虚构造函数”这样的说法,这就要归咎于不负责任或者说误人子弟的媒体了(包括书、技术文章等等)。因为他们说的是类似下面这样的做法:

class Expr 
{
public:
     Expr();
     Expr(const Expr&);
     virtual Expr* new_expr() { return new Expr(); }
     virtual Expr* clone() { return new Expr(*this); }
};

—转自Linux Tour

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

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

#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: 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

最终结论:

  • 不要在构造函数析构函数中调用虚函数,因为这种情况下的虚函数调用不会调用到外层派生类的虚函数
  • 对象的虚函数表地址在对象的构造和析构过程中会随着部分类的构造和析构而发生变化,这一点应该是编译器实现相关的。

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


http://www.niftyadmin.cn/n/1636802.html

相关文章

(kinetis K60)UART寄存器 串口收发数据

串口初始化…… main.c 文件#include "common.h" #include "uart.h" #include "isr.h"void UART4_Init(U32); void delay(long count); void Uart4_SendByte(U8 Char);void main (void) {char str[]"hello cortex-m4 …

ubuntu16.04下twisted安装

1.第一种方法是 Ubuntu上下载twisted压缩包&#xff0c;twisted-twisted-17.9.0.tar.gz, cd 到下载目录&#xff0c;用tar –zvxf twisted-twisted-17.9.0.tar.gz解压缩&#xff0c;解压完成后进入目录twisted-twisted-17.9.0,然后用 python setup.py install命令安装 这时&…

机器学习工程师指南

描述&#xff1a;机器学习相关知识的权威资源 机器学习是非常困难的&#xff0c;因为要掌握多方面的知识。在本月的早些时候进行的一个项目中&#xff0c;我在处理一些简单的数据时&#xff0c;我无论如何也回想不起一个双变量探索技术的名称&#xff0c;尽管我在几个月前刚刚…

提升Entityframework效率的几种方法

1、了解EF的三种数据加载模式 a)Lazy Loading i.默认ef是使用此模式&#xff0c;显示开启在DbContext的构造器中使用Configuration.LazyLoadingEnabledtrue; ii.此模式中&#xff08;codefirst&#xff09;&#xff0c;领域模型不能被声明为sealed&#xff0c;导航属性必须声明…

Python基础知识-getopt()

sys 模块通过 sys.argv 属性提供了对命令行参数的访问。 命令行参数是调用某个程序时除程序名以外的其它参数。这样命名是有历史原因的&#xff0c;在一个基于文本的环境里(比如 UNIX 操作系统的 shell 环境或者 DOS-shell )&#xff0c;这些参数和程序的文件名一同被输入的。但…

DIOCP开源项目-测试数据库性能

今天群里有个朋友说他们医院项目采用直连数据库&#xff0c;高峰时期sqlserver的连接数达到7000多,于是我准备做个用diocp做个demo&#xff0c;服务端用连接池。白天的时候我在想&#xff0c;并发如果7000个。如果用diocp做三层服务器&#xff0c;连接池应该在100个左右。今天晚…

git-重命名文件和文件夹

git mv -f oldfolder newfolder git add -u newfolder -u 选项会更新已经追踪的文件和文件夹。 git commit -m "changed the foldername whaddup" git mv foldername tempname && git mv tempname folderName 在大小写不敏感的系统中&#xff0c;如win…

即时通讯软件openfire+spark+smack

http://blog.sina.com.cn/s/blog_56c9b55c0100zxc7.html 开发你自己的XMPP IM - [J2EE]这几天查国内外的资料&#xff0c;发现国内关于这方面间的软件资料太少了&#xff0c;就想在这里写几篇关于此类IM 软件开发的文章。不过别看东西小&#xff0c;涉及的模块可不少。 所以我基…