深度探索C++对象模型(二):构造函数语意学

本文详细介绍了C++中默认构造函数、拷贝构造函数的工作原理,包括何时会被合成以及其行为。内容涉及成员变量初始化、虚函数、类继承和虚继承的影响。还讨论了拷贝构造函数的位逐次拷贝语义及其安全性,并给出了何时需要自定义拷贝构造函数的情况。最后,文章提到了构造函数初始化成员列表的重要性以及全局和局部对象的内存初始化特点。
摘要由CSDN通过智能技术生成

Default Constructor的构造操作

C++编译器在以下4种情况默认构造函数被认为是nontrivial的,需要被合成出来。

含有类对象数据成员,该类对象类型有默认构造函数。

内含的类成员变量有默认构造函数,而自己没有

class A
{
public:
    A(bool _isTrue=true, int _num = 0){ isTrue = _isTrue; num = _num; }; //默认构造函数
    bool isTrue;
    int num;

};
class B
{
public:
    A a;//类A含有默认构造函数
    int b;
    //...
};
int main()
{
    B b;    //编译至此时,编译器将为B合成默认构造函数
    return 0;
}

其中,类B合成的默认构造函数类似于:

inline B::B()
{
    a.A::A();
}

但是,假如类B的成员 b 也需要初始化,就需要明确定义构造函数,那么默认构造函数就不会存在了。那怎么解决普通成员变量和对象成员变量之间的冲突呢?

方法就是扩张已存在的 constructors,在其中安插一些代码,使得user code被执行之前,先调用每个必要的类成员变量的默认构造函数:

// 程序员自己声明默认构造函数
B::B(){b=0;}

// 扩张后的默认构造函数
B::B()
{
    a.A::A();
    b=0;
}

如果有多个类成员变量需要调用其自身的默认构造函数,编译器按其声明顺序调用。

基类带有默认构造函数的派生类

这种情况和第一种情况类似

当一个类派生自一个含有默认构造函数的基类时,编译器合成的默认构造函数将根据基类声明顺序调用上层的基类默认构造函数

如果其中一个类提供了constructor,但其中没有default constructor,编译器会扩张这个constructor,调用所有必要的default construct

带有虚函数的类 

不管是自身声明的还是继承得来的虚函数,都会发生两个扩展运动:

  1. 类编译期间:编译器产生一个vtbl(虚函数表),内放虚函数的地址
  2. 对象编译期间:编译器产生一个vptr,指向vtbl

此外,虚函数的调用会被重新改写,如:

widget.flip();

// 被改写为

(*widget.vptr[1])(&widget);   // &widget其实就是this指针

虚继承下的类

其实和第三种情况类似,需要初始化虚基类指针。

对于虚继承,可以看我这篇博客

Copy Constructor的构造操作

以下三种情况,会以一个object的内容做为另一个object的初值:

  1. X obj=x;
  2. void foo(X x);
  3. X foo(){X xx; return xx;}

类成员默认初始化

拷贝构造默认的做法是逐个成员拷贝(memberwise initialization),也就是把对象中每一个内建或派生的数据成员拷贝到另一个对象中,不过对于其中的member class object,不会进行直接拷贝,而是以递归的方式实施memberwise initialization。

一个class object可通过两种方式复制得到,一种是拷贝构造,另一种就是=操作符重载(assignment)。拷贝构造函数在初始化时调用,如X obj=x; 而 assignment 在初始化以后调用,如X obj; obj=x;

如果class没有声明一个copy constructor,就会有隐式的声明或定义出现,而C++ standard只会把nontrivial的copy constructor才会被合成于程序中,也就是这个copy constructor不会展现出所谓的"bitwise copy semantics"。

Bitwise Copy Semantics(位逐次拷贝)

位逐次拷贝效率上其实挺高的,但比不上逐个成员拷贝,且存在不安全问题(也就是说,在进行拷贝时,原对象可能有需要被改变的内容)。

什么时候一个class不展现出Bitwise Copy Semantics呢?

  1. class内含(或继承)一个member object,而后者有一个copy constructor(不论是显示声明或者编译器合成)。这是由于此member object在进行copy时可能就不是按照原对象进行赋值的,如原对象的一个int变量为1,但其copy constructor将其限制为0。
  2. class声明了虚函数,或者所处的继承串链中,有一个或多个虚基类。因为在进行父类=子类时,需要重新设定虚指针。

这两种情况必须将member或base class的 copy constructor调用操作安插到被合成的copy constructor中。

程序转换语义学

首先需要明白拷贝对象的初始化操作,如X x1=x0; 会发生两个动作:定义x1(占用内存)、class X的copy constructor调用操作会被安插进去。

参数的初始化

下面的调用方式:

// 函数声明
void foo(X x0);
// 函数调用
X xx;
foo(xx);

将会要求x0以memberwise的方式,将xx当作初值。

在编译器实现技术上,有一种策略是导入所谓的临时性object,并调用copy constructor将它初始化,然后将此临时性object交给函数。如上一段程序代码可能会被这样转换:

// 编译器产生临时对象
X _temp0;

// 编译器调用copy constructor
__temp0.X::X(xx);

// 重写函数调用操作 x0=__temp0;
foo(__temp0);

// class X应定义destructor,以释放__temp0

本人有个小疑问:直接用xx拷贝构造x0不就行了吗?

这种引用临时变量的策略太麻烦了,Borland C++编译器直接将实际参数建构在其应该的位置上。

返回值的初始化

 已知函数定义:

X bar(){
    X xx;
    // 处理 xx...
    return xx;
}

那么,函数的返回值如何从局部对象xx中拷贝过来呢?cfront编译器中采用的是双阶段转化:

  1. 首先加上一个额外参数,类型是 class object的一个reference。
  2. return指令之前,安插一个copy constructor调用操作,以便将欲传回的object内容当作上述新增参数的初值。编译器会改写函数,使它不传回任何值。

因此,bar()转换如下:

void bar(X& __result){  // 额外参数,无返回值
    X xx;
    xx.X::X();   // 编译器调用默认的构造函数
    // 处理 xx 中 ......
   __result.X::X(xx);
   return;
}

bar()的调用操作X xx=bar();被改写为:

X xx;
bar(xx);

同理,如果声明一个函数指针:

X(*pf)();
pf=bar;

会被这样转换:

void(*pf)(X&);
pf=bar;

使用者层面优化

有以下代码:

X bar(const T &y,const T &z){
    X xx;
    // 以y和z处理 xx
    return xx;
}

这个函数的运算开销有两部分

  1. 利用y和z处理xx的过程。
  2. 编译器新增参数__result调用copy constructor部分。

程序员可以在x类中写一个参数为y和z的构造函数,然后bar()函数内不调用拷贝构造,而是调用构造函数(调用构造函数的开销就是第一部分开销),这样,就不会产生拷贝构造的开销了:

X bar(const T &y,const T &z){
   return X(y,z);
}

编译器会将这个函数转换为:

void bar(X& __result,const T &y,const T &z){
    __result.X::X(y,z);
    return;
}

但这种方法其实不太被接收,因为编程难度会加大,如果我现在有一个a,b处理xx的过程,是不是也得写成构造函数。。。这样会使得构造函数大量扩散

编译器层面的优化

对于return指令传回的具名值,编译器会进行NRV(Named Return Value)优化,如:

X bar(){
    X xx;
    // 处理 xx...
    return xx;
}

编译器可以进行优化,少一次xx到 __result的拷贝构造:

void bar(X& __result){  // 额外参数,无返回值
    X xx;
    __result.X::X();   // 编译器调用默认的构造函数
    // 直接处理 __result ......
   return;
}

除此之外,还有添加临时值、设置内联等方法,比较复杂就不讲了。

什么时候需要提供Copy Constructor

有以下代码:

根据上面的只是可知:这个class的默认拷贝构造函数视为trivial,所以根据另一个值进行初始化时,会进行bitwise copy,这样效率很高,但安全吗?

答案是:安全。所以,这个class并不需要显示定义拷贝构造函数。

但是,如果这个类有大量的成员变量,可以定义copy constructor进行memberwise初始化操作,这样效率更高。

在此基础上,使用

C++ library的memcpy()会更有效率:

 

如果Point3d类中还有vptr,那么编译器会扩张成:

所以memset()和memcpy()要慎用 。

构造函数初始化成员列表

以下4中情况,需要通过初始化成员列表进行参数初始化:

  1. 初始化一个reference member时
  2. 初始化一个const member时
  3. 调用一个base class的constructor,而它拥有一组参数时
  4. 调用一个member class的constructor,而它拥有一组参数时

如以下例子:

这种写法效率不高,因为在编译器内部,多了一个临时变量的相关操作:

 较佳的方式:

 看到这里,大家应该都会有一个疑问:为啥初始化成员列表list和构造函数体内初始化,编译器的扩张结果不一样?

首先需要明白一点:list中初始化的顺序是由class中members声明顺序决定的,编译器会按照这种顺序对list重排序。

所以,下面的程序会出现错误: 

 在初始化 i 的时候,j 还没初始化

然后,还需要明白一点:list中的成员初始化放在explicit user code之前。

所以下面的写法是正确的:

最后,还可以明白一点:list中可以用成员函数初始化成员变量

如xfoo()是类X的一个成员函数,以下程序是正确的:

C++编译器会将它扩展为:

 由于在调用构造函数的时候,this指针已知了,所以xfoo()函数能够被编译器正确找到。

补充一个小tip:
Global obeject的内存在程序启动时被清为0

Local object的内存配置在堆栈中,heap object 配置于自由空间(...)中,都不会被清0,它们的内容是上次被使用过的痕迹。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值