深度解读《深度探索C++对象模型》之C++对象的构造过程(一)

目录

对象构造过程

成员初始化列表

虚函数表指针vptr设定时机


《深度解读《深度探索C++对象模型》之默认构造函数》《深度解读《深度探索C++对象模型》之拷贝构造函数》《深入分析C++对象模型之移动构造函数》

        这三篇文章中对C++对象的构造过程有过一部分的分析,现在的这篇文章将对C++对象的构造的方方面面进行更全面的分析。

对象构造过程

        假设在代码中有一个Object的类类型,且用它定义了一个类对象,如下所示:

        Object obj;

        那么编译器将如何处理这行代码?当然这得看Object类的具体定义,以及定义它的地方在哪里。首先,编译器会为它开辟一块内存空间,大小视类的定义而定,如果是局部对象,这块内存空间不会被请0,如果是全局对象,那么这块内存空间将会被清0。然后再看它是否有定义默认构造函数,如果有默认构造函数,将会调用这个默认构造函数来构造这个对象,如果没有定义默认构造函数,编译器将会根据类的定义决定是否为它合成一个,然后再调用它来构造对象。

        构造函数构造对象的过程一般有以下的几个阶段:

  1. 如果父类中有虚基类,而此类又是最派生类的话,它就会负责构造虚基类,而且是最先构造它,如果有多个虚基类,则按照声明的顺序从左到右,从最远到最近逐个构造。如果虚基类的构造函数需要参数,则必须将它列于成员初始化列表中并指定参数,否则将调用默认构造函数。
  2. 如果类是单一继承的继承关系,则会先构造基类子对象,从继承关系的最远处开始构造,逐个构造到最近距离的基类。如果基类的构造函数需要参数,则必须将它列于成员初始化列表中并指定参数,否则将调用默认构造函数。
  3. 如果是多重继承,则按照声明顺序,从左到右依次构造基类,如果基类是继承而来的,则按照第2点规则构造,构造第二及后续的基类先要调整this指针。
  4. 如果类中有定义了虚函数或者父类中有虚基类,那么需要先设置好虚函数表指针vptr。
  5. 如果有成员初始化列表,则将它们在构造函数体内扩展开来,并于程序员所写代码之前执行。
  6. 最后执行构造函数体内程序员所写的代码。

        下面的小节将按照构造的每个阶段展开详细的讲解。

成员初始化列表

        当你写下构造函数来初始化类中的数据成员时,有两种方式可用于初始化数据成员,一种是使用成员初始化列表,一种是在构造函数中直接初始化。这两种初始化方式是否完全等同?两种写法之间是否可以互换而结果完全一致?还是说其中一种比另外一种方式更好,效率更高?接下来就来揭开这层面纱,看清它们的真实面目。

        首先,两种方式并不完全等同,某些情况下必须使用初始化列表:

  1. 当初始化一个引用类型的成员时;
  2. 当初始化一个const类型的成员时;
  3. 当调用基类的构造函数,而它的构造函数需要参数时;
  4. 当调用类类型的成员的构造函数,而它的构造函数需要参数时。

        除了以上的四种情况,其它情况下两种方式都可以互换,其实成员初始化列表最终会被编译器扩充插入到构造函数的本体内,插在程序员写的代码之前,如下面的代码:

class Object {
public:
    Object(int _x, int _y): x(_x), y(_y) { }
private:
    int x;
    int y;
 };

        构造函数会被编译器转换为:

Object(int _x, int _y) {
    x = _x;
    y = _y;
}

        如果因此你认为这两者之间是可以等同互换的,那又陷入了另外的陷阱:那就是有可能导致你的代码的效率不高。我们把来看看下面代码的定义:

#include <string>
class Object {
public:
    Object(int _x, int _y) { 
        x = _x;
        y = _y;
        str = "";
    }
private:
    int x;
    int y;
    std::string str;
 };

        它和使用成员初始化列表的方式如:

Object(int _x, int _y): x(_x), y(_y), str("") { }

        是否一样?我们来看下上面那种写法的构造函数的汇编代码:

Object::Object(int, int) [base object constructor]:		# @Object::Object(int, int) [base object constructor]
        # 略...
        mov     qword ptr [rbp - 8], rdi
        mov     dword ptr [rbp - 12], esi
        mov     dword ptr [rbp - 16], edx
        mov     rdi, qword ptr [rbp - 8]
        mov     qword ptr [rbp - 48], rdi       # 8-byte Spill
        add     rdi, 8
        mov     qword ptr [rbp - 40], rdi       # 8-byte Spill
        call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string()@PLT
        mov     rax, qword ptr [rbp - 48]       # 8-byte Reload
        mov     rdi, qword ptr [rbp - 40]       # 8-byte Reload
        mov     ecx, dword ptr [rbp - 12]
        mov     dword ptr [rax], ecx
        mov     ecx, dword ptr [rbp - 16]
        mov     dword ptr [rax + 4], ecx
        lea     rsi, [rip + .L.str]
        call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator=(char const*)@PLT
        jmp     .LBB1_1
.LBB1_1:
        # 略...

        上面汇编代码中的第3到5行的rdi,esi,edx是三个传给构造函数的参数,对应的是对象的首地址(即this指针)、_x和_y的实参。第7到10行是调用std::string类的默认构造函数,接下来的几行是给数据成员x和y赋值,最后的第17、18行再次调用std::string类的构造函数,这时调用的是带参数的(存放在rsi寄存器中)构造函数。从中可以看到,成员str经过了两次的构造,首先调用的是默认构造函数,然后再次调用带实际参数的构造函数,它的效率要比使用成员初始化列表的方式要低效一些,使用后一种初始化方式只需要调用构造函数一次。

        所以因此有些人就推崇一律使用成员初始化列表的方式来初始化成员,须知使用成员初始化列表也有一些陷阱,当心一不小心就有可能掉进陷阱里去,如下面的一些情形,下面的代码,你能看出问题在哪吗?

class Object {
public:
    Object(int i): y(i), x(y) { }
private:
    int x;
    int y;
 };
 int main() {
    Object obj(5);
    return 0;
 }

        程序的原意应该是想通过初始值i先给成员y赋初值,然后再将成员y赋值给成员x,然而这将事与愿违,构造函数中初始化数据成员的顺序不是按照成员初始化列表中所写的顺序来初始化的,而是按照在类中声明它们时的顺序来初始化的,也就是说,成员x的初始化会先于成员y进行,而这时成员y的值是一个不确定的值,这将导致一个隐蔽的Bug,所幸的是编译器遇到这种情况一般会给出警告,如Clang编译器给出的警告:

warning: field 'y' is uninitialized when used here [-Wuninitialized]

        所以千万不要忽略编译器给出的警告信息,有可能就造成一个非常隐藏的Bug,导致花费大量时间排查。遇到这种情况建议你把初始化放在构造函数体内:

Object(int i): y(i) {
    x = y;
}

        如果在成员初始化列表中调用类成员函数,然后用返回值作为数据成员的初值,这样做安全吗?如下面的代码,假如geValue函数是类的成员函数:

Object(int i): x(getValue()), y(i) { }

        在成员初始化列表中调用类的成员函数是安全的,这个时候this指针已经准备妥当,所以调用没有问题,但是隐藏的问题在于,你不确定getValue函数依赖于哪些成员变量,假设它要使用成员变量y的值,因为成员变量y还未被初始化,那么这里调用这个函数引起的后果将是不确定的,它将返回一个不正确的值。

        更进一步思考,假设Object类处于继承体系下,在Object类的构造函数中调用Object类的成员函数,然后使用它的返回值当作调用基类的构造函数的参数,结果将会如何?如Object类继承自Base类,getValue是Object类中的成员函数,使用getValue函数的返回值作为Base类构造函数的参数:Object(): Base(getValue()){},此时调用getValue是安全的,因为这时this指针已经准备妥当,getValue会被决议成Object::getValue(),但是它同样存在安全隐患,它的问题跟上面提到的问题是一样的。

        如果这个函数是一个虚函数,又是怎么的表现?我们将在下节中继续分析。

虚函数表指针vptr设定时机

        假如有以下的代码:

#include <cstdio>

class Base1 {
public:
    Base1() { printLog(); }
    virtual ~Base1() = default;
    virtual void printLog() { printf("%s\n", __PRETTY_FUNCTION__); }
};
class Base2 {
public:
    Base2() { printLog(); }
    virtual ~Base2() = default;
    virtual void printLog() { printf("%s\n", __PRETTY_FUNCTION__); }
};
class Derived: public Base1, public Base2 {
public:
    Derived() { printLog(); }
    virtual ~Derived() = default;
    void printLog() override { printf("%s\n", __PRETTY_FUNCTION__); }
};
 int main() {
    Derived d;
    Base2* pb2 = &d;
    pb2->printLog();
    return 0;
 }

        上面代码中第24行的虚函数的调用,我们已经知道它将会调用的是Derived类中的printLog函数,这是多态的行为。但是在类中的构造函数中调用一个虚函数,调用的是谁的虚函数呢?如代码中的第22行,在构造Derived类的对象d时,它将会依次调用Base1类的构造函数和Base2类的构造,最后才执行Derived类的构造函数。那么在各个类的构造函数中调用虚函数printLog,到底调用的哪个虚函数实例?是Base1和Base2类的?还是Derived类的?

        C++语言规则告诉我们,在基类构造函数中调用的虚函数,应当调用到基类中的虚函数,而不是调用到派生类中的虚函数,这是因为这时候在构造的是基类子对象,派生类的对象还未构造出来。那么如何来保证做到这一点?有一种策略就是当在构造函数中调用虚函数时,不采用虚拟调用的机制,而是采用静态调用的方式,如转换成:Base1::printLog,但是如果在虚函数中又再调用一个虚函数又当如何?这时又该如何决议调用的是哪个类的虚函数?这个调用可不是在构造函数中调用的,理应使用虚拟机制来决定其调用的函数,其实最好的策略就是在此时限定可供调用的一组虚函数的候选列表,这也就是每个类所独有的虚函数表,然后再加一个虚函数表指针vptr指向它,所以这个vptr应当在构造这个基类的子对象时先设定好它,让它指向基类的虚函数表,这样就可以保证通过虚拟机制调用到的是基类中的虚函数。构造完基类子对象后再接着构造派生类部分,这时又将vptr设置回指向派生类的虚函数表,这样就可以保证虚函数的调用不会出现错乱。

        那么在成员初始化列表中调用一个虚函数是否安全?经过前面的分析可以知道,在这里调用虚函数是安全的,因为这时类的vptr已经设置好正确的值,但是虽然调用是安全的,但却不是一个好行为,因为虚函数里可能依赖于其它还未被初始化的数据成员,所以不推荐这种做法。

        最后来回答上小节中提到的问题,在成员初始化列表中调用一个虚函数,用它的返回值作为调用基类构造函数的参数,这样做的结果是什么?下面是示例代码:

class Base {
public:
    Base(int i): b(i) { }
    virtual ~Base() = default;
    virtual int getValue() { return 0; }
private:
    int b;
};
class Object: public Base {
public:
    Object(int i): Base(getValue()), x(i) { }
    virtual ~Object() = default;
    int getValue() override { return x; }
private:
    int x;
 };
 int main() {
    Object obj(5);
    return 0;
 }

        在上面的代码中,在Object类的构造函数的成员初始化列表中调用了虚函数getValue,首先调用是安全的吗?其次调用的又是哪个类的虚函数?根据我们之前的分析,Object类的vptr要等到构造完Base类子对象才会被设置,而这时Base类子对象还未开始构造,它的vptr也还没有被设置好。我们先来看看这个构造函数对应的汇编代码:

Object::Object(int) [base object constructor]:	# @Object::Object(int) [base object constructor]
    # 略...
    lea     rax, [rip + vtable for Object]
    add     rax, 16
    mov     qword ptr [rdi], rax
    mov     rax, qword ptr [rdi]
    call    qword ptr [rax + 16]
    mov     rdi, qword ptr [rbp - 24]       # 8-byte Reload
    mov     esi, eax
    call    Base::Base(int) [base object constructor]
    mov     rax, qword ptr [rbp - 24]       # 8-byte Reload
    lea     rcx, [rip + vtable for Object]
    add     rcx, 16
    mov     qword ptr [rax], rcx
    mov     ecx, dword ptr [rbp - 12]
    mov     dword ptr [rax + 12], ecx
    # 略...

        从汇编代码看到,这时调用虚函数是安全的,且调用的是Object类的虚函数,上面汇编代码中的第3到5行就是去设置虚函数表指针,它指向Object类的虚函数表,第6、7行代码就是通过虚函数表去掉用getValue虚函数(表格中第三行的位置)。接下来的第8到10行就是调用Base类的构造函数了,第8行是加载this指针到rdi寄存器,第9行是将返回值(保存在rax寄存器)加载到esi寄存器,这两个寄存器将作为调用Base构造函数的参数。等到构造完Base类子对象之后,在之后的第11到14行代码里又重新设置vptr的值,让其重新指向Object类的虚函数表(因为在Base类的构造函数里会将它设置为指向Base类的虚函数表)。看到这里,我们发现它的行为跟之前的分析有点不同,在这种情况下,编译器是先设置了vptr让其指向派生类Object类的虚函数表,我猜测是因为这时候Base类还未开始构造,所以设置成Object类的更合理一些。

        调用是安全的,但是调用结果是安全的吗?其实这样的调用存在着安全隐患,前面就已经说过了,getValue函数有可能对Object类中的成员变量有依赖,而这时这些成员变量可能还被初始化。如上面的例子中,它使用了成员变量x,而成员变量x直到最后才被初始化,对应上面汇编代码的第15、16行,[rbp - 12]是保存的参数i的值,[rax + 12]对应的位置是成员变量x,rax保存着this,偏移12字节是因为前面8字节是vptr,接着4字节是从Base类继承的成员变量b。

最近更新

  1. MP公共字段填充

    2024-04-27 20:54:04       0 阅读
  2. 配置 VSCode C++ 调试时, 常见错误教程

    2024-04-27 20:54:04       1 阅读
  3. HarmonyOS应用开发前景及使用工具

    2024-04-27 20:54:04       1 阅读
  4. JVM的基础介绍(1)

    2024-04-27 20:54:04       1 阅读

热门阅读

  1. 函数为什么要防抖

    2024-04-27 20:54:04       14 阅读
  2. c++中的__declspec(dllexport) 和 __declspec(dllimport)

    2024-04-27 20:54:04       50 阅读
  3. 笔记:oracle报错ORA-55941

    2024-04-27 20:54:04       13 阅读
  4. 小红书笔记的规则权重算法7个要点

    2024-04-27 20:54:04       14 阅读
  5. c++ shared_ptr和weak_ptr结合应用实验

    2024-04-27 20:54:04       15 阅读
  6. c/c++的关键字 inline 介绍

    2024-04-27 20:54:04       11 阅读
  7. 学习笔记-数据结构-树与二叉树(2024-4-22)

    2024-04-27 20:54:04       34 阅读
  8. 【spring6】Spring IoC注解式开发

    2024-04-27 20:54:04       28 阅读
  9. Spring

    Spring

    2024-04-27 20:54:04      12 阅读
  10. CSS体验

    CSS体验

    2024-04-27 20:54:04      10 阅读
  11. 手写一个民用Tomcat (07)

    2024-04-27 20:54:04       13 阅读