第二章.构造函数语意学

2.1 默认构造函数的构造操作

  1. explicit关键字能够制止单参构造函数被当作类型转换运算符.

  2. 编译器的隐式操作只是为了满足编译器本身的需求, 而不是程序本身, 一个被编译器隐式生成的默认构造函数, 多数情况下对于程序本身来说是无用的.

  3. 如果一个类没有任何构造函数, 但类中的一个非内置类型成员变量有默认构造函数, 那么这个类被隐式生成的默认构造函数则不是无用的.

  4. 只有构造函数真正被调用了, 编译器才会为类隐式生成默认构造函数.

  5. 为了避免一个没有默认构造的类被放在多个文件中实现而导致生成多个默认构造函数, 构造,析构,拷贝构造,拷贝赋值 这些函数都会被生成为inline或者explict no-inline static 的.

  6. 编译器的行为可以简单记为 编译器不会为内置类型(不包含构造函数) 生成/扩充 构造函数; 对于下方的代码, 编译器会为Bar隐式生成一个默认构造函数, 该默认构造函数中会调用Foo::Foo()来对Bar::foo做初始化, 但并不会为str做初始化操作.

    class Foo 

    public: 
       Foo();
       Foo(int);
    };

    class Bar
    {
    public:
       Foo foo;
       char *str;
    };

    //code
    void foo_bar()
    {
       Bar bar;
    }
  7. 如果一个类包含构造函数, 但构造函数中并未对非内置类型成员变量做初始化, 那么编译器会扩充已有的构造函数, 在其中调用相应成员变量的构造函数.

  8. 当编译器对构造函数进行扩充时, 扩充的构造函数会被安插在显式用户代码之前, 安插构造函数的顺序将以变量声明的顺序为基准.

  9. 编译器会为不包含任何构造函数的派生类生成一个默认构造函数, 其中会按继承先后顺序调用基类的默认构造函数.

  10. 如果一个不包含默认构造函数的类, 其中声明/继承了虚函数 或 该类派生自一个继承链,继承链中有一个或多个虚基类, 则编译器的默认构造函数生成行为如下

    • 编译器会生成一个虚函数表vtbl, 其中存放类中的虚函数地址.

    • 编译器会生成一个虚指针vptr, 该vptr存在于每一个类对象中, 指向相关的vtbl地址.

      // 示例1
      class CBase
      {
      public:
         virtual void flip() 0;  // 纯虚函数
      };

      class CDerivedA : public CBase
      {
      public:
         virtual void flip() { printf("A\n");}
      };

      class CDerivedB : public CBase
      {
      public:
      virtual void flip() { printf("B\n");}  
      };

      void do_flip(const CBase &base) { base.flip(); }

      void foo()
      {
         CDerivedA a;
         CDerivedB b;
         
         do_flip( a );
         do_flip( b );
      }


      // 对于上述情况, do_flip中的虚函数调用操作会被重新改写, 使用每一个base对象中的vptr以调用vtbl中的虚函数.
      /* 伪代码
      void do_flip(const CBase &base) { (*base.vptr[1])( &base ); }

      1.vptr[1]指向vtbl中的flip函数地址.
      2.&base为被调用的flip函数实例中的this指针.

      */
      // 示例2
      class CBase { public: int var_base; };

      class CDerivedA : public virtual CBase { public: int var_a; };

      class CDerivedB : public virtual CBase { public: int var_b; };

      class CDerivedC : public CDerivedA, public CDerivedB { public: int var_c; };

      void foo(CDerivedA *pa)
      {
         // 无法在编译期决定出pa->CBase::var_base的地址
         // 经过一系列操作, 可能被编译器转换为 pa->_vbcBase->var_base = 1024;
      pa->var_base 1024;

         /* 此处我自己进行了一个验证, 使用MSVC编译器, base类变量在derived类变量更高的地址
      printf("pa's addr is %d\n", pa);
      printf("pa->var_a's addr is %d\n", &(pa->var_a));
      printf("pa->var_base's addr is %d\n\n", &(pa->var_base));

      不考虑编译器安插其他内容的情况下, 可能的内存布局
      +-----------+-----------+
      | CDerivedA | CDerivedC | 0x000
      +-----------+-----------+
      | var_a   | var_a   | 0x004
      +-----------+-----------+
      | var_base | var_b   | 0x008
      +-----------+-----------+
      |           | var_c   | 0x00C
      +-----------+-----------+
      |           | var_base | 0x010
      +-----------+-----------+

      */

      delete pa;
      pa nullptr;
      }

      int main(int argc, char **argv)
      {
      printf("In class A :\n");
         foo( new CDerivedA() );
      printf("In class C :\n");
      foo( new CDerivedC() );
         

         system("pause");
         return 0;
      }

      /*
       编译器无法固定住foo中"经由pa而存取的CBase::var_base"的实际偏移位置, 因为pa的真正类型可以改变.
       编译器必须改变"执行存取操作"的那些代码, 使CBase::var_base可以延迟至执行期才决定下来.
       一种可能的方式是, 编译器会在类对象中安插一个_vbcBase指针, 指向虚基类CBase, 所有经由引用/指针来存取虚基类的操作都由该指针完成.
      */
  11. 编译器生成出来的默认构造函数中, 只有基类子对象,成员类对象会被初始化, 所有其他的非静态成员变量(整数,整数指针,整数数组等)都不会被初始化.

2.2 拷贝构造函数的构造操作

  1. 以一个对象作为另一个类对象的初值的三种情况 :

    • 对一个对象做显式初始化.

    • 当一个对象作为入参传入某函数时.

    • 当函数返回一个类对象时.

      // 显式初始化
      class CBase { ... };
      obj;
      other_obj obj;

      // 作为函数入参
      extern void foo (x);
      void bar()
      {
         obj;
         foo(obj);  //隐式初始化
      }

      // 函数返回类对象
      foo_bar()
      {
         obj;
         return obj;
      }
  2. 如果一个类没有提供显式的拷贝构造, 当对这个类的对象做拷贝初始化时, 内部会对类内每个成员施以默认的构造操作, 即将每一个内建的或派生的成员变量的值拷贝至当前对象的变量上( 浅拷贝/按位逐次拷贝 ), 这个操作是递归的.

  3. 默认构造和拷贝构造只有在类 不展现 浅拷贝 需求时才会被编译器合成出来.

  4. 不展现 浅拷贝 需求的四种情况 ( 对于前两种情况, 编译器会将拷贝构造操作加入新合成的拷贝构造函数中 )

    • 类内包含一个类成员变量且这个变量的类中声明有拷贝构造函数( 显式声明/编译器合成 ).

    • 类继承自一个基类, 基类中声明有拷贝构造函数( 显式声明/编译器合成 ).

    • 类声明了一个或多个虚函数.

    • 类派生自一个继承链, 其中有一个或多个虚基类.

  5. 为了支持多态, 对于编译器而言, 每一个新生成的类对象中的vptr和vtbl都必须被正常设初值, 这就要求, 当vptr被编译器扩张加入一个类中时, 该类就不再展现 浅拷贝 需求了.

    class CAnimal
    {
    public:
    CAnimal() : food("something") {}
    virtual ~CAnimal() {}

    virtual string sound() { return ("HelloWorld"); }
    virtual string eat() { return food; }

    private:
    string food;
    };

    class CDog : public CAnimal
    {
    public:
    CDog() : dog_food("steak") {}
    virtual ~CDog() {}

    string sound() { return ("WoofWoof"); }  //虽然没有声明virtual,但其实是virtual的
    virtual string eat() { return dog_food; }

    private:
    string dog_food;
    };

    //dogglas会调用默认构造做初始化, dogglas的vptr被设置指向CDog的vtbl, 因此将dogglas拷贝给doggy是安全的.
    CDog dogglas;
    CDog doggy dogglas;
  6. 当一个基类对象以其派生类对象的内容做初始化操作时, vptr的设置同样要保证安全

    /*
    * 由于派生类虚函数重写后, 可能会调用派生类自身的私有成员变量, 如果将派生类对象中的vptr直接
    * 浅拷贝给基类对象中的vptr, 又因为不是使用指针或引用, 必将导致运行期间内存崩溃
    */
    CDog doggy;
    CAnimal animal doggy;  //此处发生了切割(sliced)
    CAnimal *dog new CDog();

    /*
    * animal eat something
    * dog eat steak
    */
    printf("animal eat %s\n", animal.eat().c_str());
    printf("dog eat %s\n", dog->eat().c_str());

    /*
    * 这里也是个很有趣的点, 我们delete了一个基类指针, 是否会造成内存泄漏?
    * 假定两种情况: 1.派生类的构造存在额外的堆内存申请, 析构函数有正常释放这个堆内存. 2.派生类的构造中没有额外的堆内存申请
    * 在上述情况下, 如果基类析构函数为virtual, 且没有重载new和delete运算符, 则不会造成内存泄露
    * 详细原因可以查看new和delete运算符定义, 返回值和入参均为void, 也就是说, 对于new和delete而言, 能否正常申请释放内存是类型无关的.
    */
    delete dog;
  7. 当类派生自一个继承链, 其中有一个或多个虚基类时, 编译器则要保证初始化时_vbcBase(虚基类子成员对象)被正确设置, 该类则不展现浅拷贝需求.

2.3 程序转化语义学

  1. 显式初始化 在程序转化时有两个必要的阶段: 重写每一个变量定义, 剥离初始化操作; 调用类的拷贝构造.

    class CBase
    {
    public:
       CBase();
       CBase(CBase &base);
       virtual ~CBase();
       
    private:
      ...
    };

    CBase g_base;

    //原始函数
    void foo_bar()
    {
       CBase local_base1(g_base);
       CBase local_base2 g_base;
       CBase local_base3 CBase(g_base);
    }

    //编译器转化后 伪代码
    void foo_bar_tran()
    {
       //重写变量定义,剥离初始化操作
       CBase local_base1;
       CBase local_base2;
       CBase local_base3;
       
       //调用拷贝构造
       local_base1.CBase::CBase(g_base);
       local_base2.CBase::CBase(g_base);
       local_base3.CBase::CBase(g_base);
    }
  2. 参数初始化 把一个类对象当做参数传给一个函数/作为函数返回值, 相当于以参数值为对象对形参/返回值做初始化操作

    void foo (CBase base) { ... }

    //相当于 CBase base = g_base
    foo(g_base);
  3. 返回值初始化 分两个阶段: 首先加上一个额外参数, 类型是类对象的一个引用, 该参数用来放置拷贝构造的返回值; 其次在 return 指令前安插一个拷贝构造调用操作, 将返回值对象赋给添加的额外参数.

    //原始函数
    CBase bar()
    {
       CBase base;
       //...
       return base;
    }

    //转换后 伪代码
    void bar_tran(CBase &__result)
    {
       CBase base;
       base.CBase::CBase();
       __result.CBase::CBase(base);
       return;
    }

    /*
    * 上述的这种转换方式被称为NRV(Named Return Value)优化
    * 该转换方式被视为C++编译器的一个义不容辞的优化标准
    * 当然,该优化方式要求类内存在拷贝构造函数(无论是显式编写的还是编译器合成的)
    */

2.4 列表初始化

  1. 必须使用列表初始化的几种情况

    • 初始化一个引用成员变量

    • 初始化一个const成员变量

    • 调用基类的构造且该构造函数拥有一组参数

    • 调用一个类成员变量的构造且该构造拥有一组参数时

    class CWord
    {
     String _name;
     int    _cnt;
     
    public:
     CWord()
    {
       _name 0;
       _cnt  0;
    }
    };

    //上方构造可能会被编译器转化成如下形式
    //伪代码
    CWord::CWord(this)
    {
     _name.String::String();
     
     String temp String(0);
     
     _name.String::operator=(temp);
     
     //临时对象被销毁了,如果String的 ‘=’ 运算符内部是一个指针的浅拷贝,那么这里就会有问题.
     temp.String::~String();
    }

    //如果CWord类的构造使用了列表初始化
    CWord::CWord
    _name(0)
    {
     _cnt 0;
    }
    //构造可能会被编译器转化成如下形式
    //伪代码
    CWord::CWord(this)
    {
     //拷贝构造,没有临时对象
     _name.String::String(0);
     _cnt 0;
    }
  2. 当我们使用列表初始化时, 编译器会一一操作初始化列表中的成员, 以适当的顺序在构造函数中的编写者显式代码之前安插初始化操作, 而这个安插的顺序并不是按照我们列表初始化中编写的顺序来排列的, 而是这些变量在类中的声明顺序决定的.

    //形如下列代码,由于误以为初始化顺序会按照列表初始化编写的顺序操作而产生了错误
    class CWord
    {
     int i;
     int j;
    public:
     //由于声明顺序导致i(j)会比j(val)更早被执行
     CWord(int val)
    j(val), i(j)
    {}
    }

    //伪代码
    CWord::CWord(this, int val)
    {
     j;
     val;
    }