深入探索C++对象模型-The Semantics of Constructors

本节主要挖掘的是编译器对于“对象构造过程”的干涉

Default Constructor的构建操作

C++新手两个常见的误解

1、任何class如果没有定义default constructor,就会被合成出一个来。

2、编译器合成出来的default constructor会明确给每个data member赋值默认值

以上两点没有一个是正确的!!!!!

class Foo {
public:
    int val;
    Foo *pnext;
};
​
void foo_bar()
{
    Foo bar;
    if (bar.val || bar.pnext)
    {
        ...
    }
}

我们可以看到上面写的这个例子中,编译器并不会给Foo类生成默认构造函数,在foo_bar函数中,bar对象在声明的时候,并不会调用构造函数,其中的成员变量val 和 pnext都是未初始化的,其中的值我们并不知道,所以if分支语句的判断结果如何,我们也不知道。

编译器生成构造函数的条件

当编译器需要的时候,编译器才会帮忙合成出一个default constructor。那么什么时候编译器需要呢?

  • 类中的成员变量存在一个类的对象,且这个类存在默认构造函数

  • 一个子类继承自一个父类,这个父类存在默认构造函数

  • 带有虚函数的类

  • 带有虚基类的类

“带有Default Constructor”的Member Class Object

如果一个class没有任何构造函数,但是它内含一个object member,而这个object对应的类有默认构造函数,编译器就会为这个类合成一个默认构造函数,但是前提条件是这个类的constructor有被调用。

因此,这里可能会出现一个问题:编译器按照这个类的constructor有无被调用来为其生成默认构造函数,在不同的C++编译模块中,可能编译器会合成出多个默认构造函数,这该如何解决??

解决方法:把合成的默认构造函数、拷贝构造函数、析构函数、赋值拷贝操作都以inline的方式完成。如果函数过于复杂,并不适合做成inline形式,此时就会合成出一个explicit non-inline static实体。

为什么Member Class Object带有默认构造函数就需要合成?
class Foo{
public:
    Foo ();
    Foo (int);
};
​
Class Bar
{
public:
    Foo foo;
    char* str;
};
​
void foo_bar()
{
    Bar bar;
    if (bar.str)
    {
        ...
    }
}

Foo类中有默认构造函数,Bar类中没有人为实现的默认构造函数

但是Bar类的对象再被创造出来时,对象中的成员变量foo必须要按照默认构造函数来初始化,因此编译器会给Bar类来生成一个默认构造函数,这个默认构造函数会调用foo的默认构造函数来给进行初始化,但是我们需要注意的一点就是,编译器为Bar合成了一个默认构造函数目的是为了调用Foo的默认构造函数,因此不会对Bar中其他成员变量进行初始化赋值操作。

如果我们想要一个类的对象被创建时,所有的成员变量都能够按照预想去初始化,那么需要自行写一个默认构造函数,在这个默认构造函数中自行进行初始化赋值。

问题:如果我们自行写了一个默认构造函数,但是我们不初始化那些带有默认构造函数的member object会怎样,以Bar类来说的话,我们写的默认构造函数只初始化str成员变量,不初始化foo成员变量?

由于Foo类存在默认构造函数,foo创建时必须调用,因此编译器会对我们写的默认构造函数进行扩张,按照类中那些member object的声明顺序将这些object的默认构造函数的代码安插在explicit user code之前。

举例说明:

class A {public: A();...};
class B {public: B(); B(int);...};
class C {public: C(); A a; B b;.. private: int num;};
​
C::C() : A(1024)
{
    num = 0;
}
​
//----C()默认构造函数经过编译器扩张后
​
C::C() : B(1024)
{
    a.A::A();
    b.B::B(1024);
    // C的默认构造函数中,不初始化A,但是初始化B,经过编译器处理后,可以看到扩张的两行代码,初始化顺序还是声明的顺序,先A再B
    
    // explicit user code
    num = 0;
}

"带有Default Constructor"的Base Class

这种情况和上面讲的情况类似。

一个父类如果存在默认构造函数,一个子类被创造出来后,子类中的父类部分必须调用父类的默认构造函数来进行初始化。

因此,如果这个子类不存在默认构造函数,编译器会为其合成一个默认构造函数,其中会调用父类的默认构造函数来对父类中的成员变量进行初始化。

如果继承了多个Base Class,那么调用的顺序就是继承的顺序。

如果一个类的Base Class和member object都存在默认构造函数,那么调用顺序为Base Class再member object。

“带有Virtual Function”的Class

如果一个类中存在虚函数,那么这个类的对象必然存在虚指针,这个虚指针将指向对应的类的虚表。

这个类的对象的vptr必须被正确赋值

如果这个类没有默认构造函数,那么编译器会为这个类合成一个默认构造函数,其中会帮忙初始化vptr的值

如果这个类有默认构造函数,那么编译器会为这个类的默认构造函数进行扩张,在explicit user code前加上初始化vptr的操作

“带有Virtual Base Class”的Class

虚继承主要处理的是菱形继承的情况,避免子类中存在重复的父类对象。

class X {public: int i;...}:
class A : virtual public X {...};
class B : virtual public X {...};
class C : public A, public B {...};
​
void foo(const A* pa)
{
    // 由于是指针操作,因此在编译期间并不确定pa指针指向的地址处的对象实际类型
    // 不同的类型中i的位置不同
    pa->i = 1024;
}

编译器在编译阶段无法确定pa指针指向的i的实际地址,如同多态的特性一样,必须要到执行期才行确定下来。

cfront针对虚继承的实现方式是在那些derived class中插入一个vbptr,这个指针指向的是虚基类表,虚基类表中存放了虚基类和vbptr的偏移量,通过vbptr和虚基类表中的偏移量可以找到虚基类。

因此,同上,这个类的对象的vbptr必须被正确赋值

如果这个类没有默认构造函数,那么编译器会为这个类合成一个默认构造函数,帮忙初始化vbptr的值

如果这个类有默认构造函数,那么编译器会为这个类的默认构造函数进行扩张,在explicit user code前加上初始化vbptr的操作

Copy Constructor的构建操作

使用拷贝构造函数的常见场景:1、用一个object的值作为另个object的初始值;2、作为函数的形参;3、作为函数的返回值;

class X{...};
​
X x;
X xx = x;
​
void bar ()
{
    X xx;
    foo(xx);
}
​
X foo_bar()
{
    X xx;
    ...
    return xx;
}

编译器并不会自动给类生成拷贝构造函数,原则和默认构造函数相同,只有编译器需要时,编译器才会主动为这个类生成拷贝构造函数

Default Memberwise Initialization

一般将一个对象复制给另个类,都是memberwise的,意思就是以类中的成员变量为粒度进行拷贝,遇到object则会递归实施memberwise intialization

举例来说:

class Wrod
{
public:
    int count;
    string str;
}
​
Word w;
Word tmp = w;
// 针对Word对象的拷贝,首先会拷贝Count成员变量,后续再递归将w中的string对象拷贝给tmp中的string对象,一个递归的过程

拷贝复制的方式:1、拷贝构造函数;2、copy assignment operator等于号完成。此处讨论的是拷贝构造函数

那么这个memberwise initialization是如何完成的呢?

大多数类,编译器可以为那些类的对象的拷贝实现bitwise copies,那些类有bitwise copy semantics,当类不存在bitwise copy semantics时,编译器就会为这个类合成一个拷贝构造函数。

Bitwise Copy Semantics

如果一个类中的没有member object,或者member object符合bitwise copy semantics,且没有定义拷贝构造函数,那么这个类就是符合bitwise copy semantics。

class Word1
{
public:
    Word1();
private:
    int cnt;
    char* str;
};
​
class Word2
{
public:
    Word2();
private:
    int cnt;
    String str;
};
​
// 编译器会为Word2生成拷贝构造函数的伪代码
​
inline Word2::Word2(const Word& wd)
{
    str.String::String(wd);
    // 还会完成其余成员变量的拷贝赋值
    cnt = wd.cnt;
}

Word1类拷贝可以直接按位拷贝即可,因此Word1类对象之间的拷贝是浅拷贝,浅拷贝可能会带来很多问题。

Word2类拷贝不可以按位拷贝,因为Word2类对象中存在String类型的member object,而String类型存在拷贝构造函数,因此针对str成员变量的拷贝必须调用String类型的拷贝构造函数才行。

因此,编译器会为Word2类生成拷贝构造函数,这个拷贝构造函数中会调用String的拷贝构造函数,同时还会完成其余成员变量的拷贝。

不要Bitwise Copy Semantics!

什么时候一个类不会展现出”bitwise copy semantics"呢?

  1. class内含有一个member object,这个object的类中有设计者声明的或者是合成的拷贝构造函数;

  2. class继承的父类中存在被设计者声明或者是合成的拷贝构造函数;

  3. class中含有虚函数;

  4. class中含有虚基类

可以看到这四种情况和是否合成默认构造函数的四种情况完全一致。

前两种情况十分易懂,针对父类或者是member object的拷贝需要调用对应class中的拷贝构造函数,因此编译器需要合成拷贝构造函数来完成调用。后续讨论主要针对后两种情况。

class中含有虚函数

此时这个类的对象会存在一个vptr指向vtbl。

直接举例来说明

class ZooAnimal
{
public:
    virtual void draw();
    ...
};
​
class Bear : public ZooAnimal
{
public:
    virtual void draw();
    ...
};
​
int main()
{
    Bear b;
    ZooAnimal z = b; // 发生sliced切割行为
    ZooAnimal* ptr = &z;
    ptr->draw();
    return 1;
}

在C++中时允许将子类对象赋值给父类对象,如果按照bitwise的拷贝方式,那么b对象中的vptr的值也会拷贝给z,那么z中的vptr指向的就是Bear类的虚表而非ZooAnimal类的虚表,如果真的发生了,我们可以预想到的一个严重情况就是,ptr->draw调用的时Bear类中定义的draw方法,draw方法中如果使用到了Bear类中才有的成员变量,而ptr指向的是z,z是ZooAnimal类型,根本没有Bear类中的那些成员变量,此时就会“炸毁”blow up。

class中含有虚基类

此时,对象中存在vbptr,以及虚基类,这个vbptr指向一个虚基类表,不同的类之间的虚基类表不同,因此将一个子类赋值给父类,如果按照bitwise的拷贝方式,那么父类对象中的vbptr将会指向子类的虚基类表,这就出错了。

Program Transformation Semantics

Explicit Initialization

明确地进行类对象初始化,编译器会进行如下转化

X x1(x0);
X x2 = x0;
X x3 = X(x0);
​
// 经过编译器上述代码发生转化,先创建对象,后续调用拷贝构造函数
// 注意这里是伪代码,X x1仅仅只创建对象,不调用默认构造函数进行初始化
X x1;
X x2;
X x3;
​
x1.X::X(x0);
x2.X::X(x0);
x3.X::X(x0);

Argument Initialization

函数中的形参是如何传递的,编译器会进行如下转化

void foo(X x0);
// 经过编译器转化,函数的参数类型变为引用类型
void foo(x& x0);
​
X x0;
foo(x0);
// 经过编译器转化,这个函数的调用方法变为创建一个临时变量,将这个临时变量作为引用传入函数
X __tmp;
__tmp.X::X(x0);
foo(__tmp);
// foo函数调用完返回后就会调用析构函数将临时变量__tmp析构掉

另一种实现方法“copy construct”拷贝构建,直接将临时变量构建在函数参数所对用的栈上的位置,此时这个临时变量的活动范围就仅限在foo函数内。编译器自行选择这两种转化方式。

Return Value Initialization

函数的返回值是一个对象,编译器会进行如下转化

X bar()
{
    X xx;
    return xx;
}
​
X xx = bar();
bar().memfunc();
X (*pf) ();
pf = bar;
​
// 经过转化后的伪代码,返回值将转换为一个引用类型的形参来进行传递,整个函数会进行调整,与该函数相关的代码也会进行相应的调整
void bar(X& res)
{
    X xx;
    xx.X::X();
    res.X::X(xx);
    return;
}
​
X xx;
bar(xx);
X temp;
(bar(temp), temp).memfunc();
void (*pf)(X&);
pf = bar;

Optimization at the User Level

函数的返回值是一个对象的情况下,我们往往会在函数体内先创建一个对象,对这个对象处理后,后再利用这个对象拷贝构造返回值,这个过程中函数体中创建的对象仅用于计算,还占据了构造和析构的开销,为了优化这个点,很多程序员都会改用另一种写法。直接函数举例说明

X bar(const T &y, const T &z)
{
    X xx;
    // ... 根据y和z来对xx进行处理
    return xx;
}
​
// 我们可以看到函数体中的xx定义出来仅仅只是用来计算,后续xx用于返回值的拷贝构造,那么为何不直接利用y和z来构造返回值?因此有了以下的优化
X bar(const T &y, const T &z)
{
    return X(y, z);
}
​
// 后续进一步转化
void bar(X &res, const T &y, const T &z)
{
    res.X::X(y, z);
    return;
}

我们可以看到返回值res,直接通过y和z来构造出来,而非通过创建临时对象xx,再进行拷贝构造得到,这避免了临时对象xx的开销。这个编程技巧是很多程序员都会使用的。

Optimization at the Compiler Level

针对上面的那个编程技巧,编译器也将这个技巧加到了程序优化的措施里,这个措施称为Named Return Value(NRV)优化。

X bar()
{
    X xx;
    // 处理xx
    return xx;
}
​
// 编译器会自动进行NRV优化,也就是按照Optimization at the User Level中的那个编程技巧进行改写函数
void bar(X &res)
{
    res.X::X();
    // 直接处理res
    return;
}

这个NRV优化如今被视为是标准C++编译器的一个义不容辞的优化操作。在cfront中要求类中具有copy constructor才能触发NRV,但是现在的编译器并没有这个要求也能触发NRV优化。

NRV优化的问题:

1、NRV优化完全是由编译器自动优化的,我们并不知道这个优化是否完成;

2、一旦函数变得比较复杂,NRV优化就会难以施行,在cfront中只有当所有的named return指令句写在函数的top level时,优化才会执行,否则就会关闭这个优化;

3、NRV优化会破坏原程序的执行流程,原本应该会有一个临时对象触发构造函数以及析构函数,同时会触发拷贝构造函数,但是经过NRV优化后,仅会发生一次res的构造函数;

因此NRV优化很多需要注意的点,最主要的点还是在于第三个问题,针对是否剔除拷贝构造的调用,需要考虑很多情况,同样的优化措施也可以用于以下的情况

X xx1 = X(1024);
X xx2 = (X)1024;
// 以上的的写法中,都会先创建一个临时变量,后续将这个临时变量拷贝构造给xx1和xx2
// 如果进行优化的话,那么就不会发生拷贝构造
​
Thing outer;
{
    // 如果要进行优化的话,这种情况是否调用拷贝构造,抑或是直接简单忽略inner
    Thing inner(outer);
}

面对“以一个class object作为另一个class object的初值"的情况,编译器有很大的发挥空间,可以让你的程序性能得到很大的提升,但是另一方面也要求程序员能够安全地规划copy constructor的副作用。

要不要Copy Constructor?

在cfront编译器中,开启NRV优化的前提是类具备拷贝构造函数,如果一个类具备bitwise copy semantics,那么编译器就不会自动合成拷贝构造函数,那么NRV优化也就不会开启。因此,如果这个类的对象在后续使用的过程中会频繁的进行值传递,那么建议手动实现一个bitwise的拷贝构造函数来触发NRV。

// 最简单的拷贝构造函数和构造函数
class A
{
    A ()
    {
        memset(this, 0, sizeof(A));
    }
    A (A& tmp)
    {
        memcpy(this, &A, sizeof(A));
    }
}

我们需要注意的是,在构造和拷贝函数中使用memset和memcpy的操作,必须确保编译器产生任何内部members才行!!

如果类中带有vptr,那么编译器会对构造函数进行扩张,在explicit user code前加上对vptr的初始化语句,后续再执行memset,这样会导致vptr的值也被置0。

拷贝构造函数也同理,利用子类对象来拷贝构造一个父类对象,如果直接使用memcpy那么就出问题了。

因此使用memset和memcpy必须掌握C++ Object Model。

Member Initialization List

Initialization List一般用于以下情况:

1、初始化const member;

2、初始化reference member;

3、调用base class的带参构造函数;

4、调用member class的带参构造函数。

以下是initialization list的常见的使用举例:

class A
{
public:
    A (): n(100), str(""){}
    
    // 被扩张成
    A ()
    {
        str.string::string(0);
        n = 100;
    }
    // 上下的差别在于,下面的写法中,经过编译器转化后,首先会先调用string的默认构造函数对str进行初始化,还会产生一个临时对象temp表示"",后续再调用string的operator = assignment运算符将temp拷贝给str,后续再摧毁temp
    A()
    {
        n = 100;
        str = "";
    }
    
    // 被扩张成
    A()
    {
        str.string::string(0);
        n = 100;
        string _tmp;
        _tmp.string::string(0);
        str.string::operator=(_tmp);
        temp.string::~string();
    }
    
private:
    int n;
    string str;
};

我们可以看到使用初始化列表来初始化那些member object可以带来很大的性能优化。因此这也引导程序员们坚持将所有的member初始化操作都放在member initialization list中完成。

initialization list发生了什么?

编译器会根据initialization list,以适当的次序在constructor中安插初始化操作,这些操作都会安插在explicit user code 之前。

这个初始化顺序是由这些member在类中的声明顺序来决定的。

这里就会出现一个问题,member的初始化顺序可能和变量之间的赋值顺序冲突!举例来说

class X
{
    int i;
    int j;
public:
    X(int val) :
        j(val), i(j)
    {}
    // 解决办法:把存在依赖成员变量的值进行初始化的成员变量放到constructor内,如下所示,此时初始化顺序就会发生变化,编译器会插入j = val的初始化代码在explicit user code之前,后续再执行i = j的代码。初始化顺序变为先j后i。
    X(int val) : j(val)
    {
        i = j;
    }
    ...
};

intialization list中的顺序和类中成员的声明顺序不一致,这个类中各个成员的初始化顺序和预想的不一致,导致这个bug出现。

还有几种特殊的情况也不建议使用initialization list,就是一个成员变量调用成员函数来进行初始化,成员函数的执行时对象中的各个成员变量的初值都和初始化顺序息息相关,我们不知道这个成员函数和成员变量初值的依赖程度。因此,建议将这个过程放到constructor中进行,并在函数调用前赋值好各个成员变量的初值。

举例来说发生错误的情况

class A : public X
{
    int val;
public:
    int getVal()
    {
        return val;
    }
    
    A(int val) :
        val(val),
        X(getVal())
    {}
    
    // 经过编译器扩张后,显然初始化顺序发生了错误,首先将未初始化的val的值拿来初始化Base Class去了。
    A (int val)
    {
        X::X(this, this->getVal());
        val = val;
    }
};

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值