前言:
之前,在编写C++的一些代码的时候,在类的构造、析构、赋值函数部分,偶尔会出现编译器做出和我初想不一样的决策。后来,看了<深度探究C++对象模型 >这本书后,有的疑问或多或少得到了解决。所以在此,想写一些笔记来记录这些疑问的地方,可能后面关于对象模型还会关于其他方面的文章。不过这个系列文章都主要是以探讨为核心,以后在编程中遇到的相关问题也会不定期在这系列文章继续补充。有的疑问的解答可能只是经过本人的探究得到的猜测,如果有错误,还望指出,谢谢! |
引言:
在看这篇文章之前,先看如下几个问题: |
1.任何class如果没有用户定义的default constructor或者copy constructor,编译器就会合成一个出来? 2.编译器合成出来的default constructor会显式设定 “class 内每一个datamember” 的默认值? 3.编译器合成出来的copy constructor的拷贝是memberwise copy 还是 bitwise copy? |
又或者是如下几段代码: |
//Test类,下面的样例代码都会使用该类。
#include <iostream>
using namespace std;
class Test
{
public:
Test() { cout << "默认" << endl; }
Test(int a) { cout << "带参" << endl; }
~Test() { cout << "析构" << endl; }
Test(const Test&) { cout << "拷贝构造" << endl; }
Test& operator=(const Test&) {
cout << "赋值" << endl;
return *this;
}
};
1.如下三句代码分别会构造几个object? |
int main()
{
Test test1(1); //第一句
Test test2 = 1; //第二句
Test test3 = Test(1); //第三句
return 0;
}
2.如下代码中fun1和fun2的区别? |
Test fun1(){
Test a(1);
return a;
}
Test fun2(){
return Test(1);
}
int main(){
{ Test test1 = fun1(); }
cout << "--------------分隔符---------------" << endl;
{ Test test2 = fun2(); }
return 0;
}
3.如下代码两行代码的区别? |
int main(){
Test test1;
Test test2();
return 0;
}
4.如下代码两段代码分别的区别? |
Test fun(){
return Test();
}
Test func(int){
return Test(1);
}
int main(){
Test test1(fun());
Test test2(Test());
Test test3(fun(1));
Test test4(Test(1));
return 0;
}
在看本篇文章之前,你可以尝试在编译器中编译运行以上代码,看看编译器对以上代码做出的解释、决策跟你所预期的是否一样。如果你对以上问题留有困惑,那么你的问题应该在本篇文章的后续内容都能得到解答。 |
一 . Constructor (Default or Copy)的合成
首先,我先要对第一个问题进行探讨: “任何class如果没有用户定义的default constructor或者copy constructor,编译器就会合成一个出来?” 在之前对C++的学习中,可能有些朋友(包括本人)会有以上这个概念的认识。然而事实上,这个结论是错误的。 要真正地理解编译器会在何时自主合成一个Constructor,我们首先需要了解Constructor的相关功能: |
1. 初始化member
2. 初始化base obj
3. 初始化vptr(如果存在)
4. 初始化virtual base obj相关信息(如果存在)
什么时候编译器会自主合成Constructor呢?我们就要分清楚什么是程序需要,什么是编译器需要。 很显然,对于编译器需要的,一定是被合成出来的constructor函数要担负起编译器需要执行的相关功能。 在<深度探究C++对象模型 >一书中说到,对于以下四种情况,编译器会默认合成contructor: |
1. 带有 Constructor 的 Member Class Object (无论是用户提供还是编译器合成的),该类对应合成相应的Constructor。
2. 带有 Constructor 的 Base Class (无论是用户提供还是编译器合成的),该类对应合成相应的Constructor。
3. 带有一个Virtual Function (包括父类中存在 Virtual Function )
4. 带有一个Virtual Base Class ( 包括父类中存在Virtual Base Class)
对于1.如果一个类不含有用户定义的 Default Constructor 且不含合成的 Constructor 函数,那么对其 member 是不进行初始化的,而如果member中含有其他 class object 递归同样操作。如果存在1所属情况,那么就必须由编译器合成一个默认构造函数来调用其 member class object 的 constructor。那么 copy constructor 同理。 对于2.其实和上一个的原因差不多,都是因为需要调用base object 的 constructor ,所以编译器合成了对应的 constructor 函数 。 对于3.在这里不过多着墨于vptr(虚表指针)的介绍,如果对此不清楚的可以在这里先理解为一个拥有虚函数class 所特有的指针。而这个指针需要在构造对象之初就要完成对该指针的初始化,所以编译器合成了 default and copy constructor 。 对于4.虚基类,我们知道,对于虚基类,从多个途径派生出来的虚基类在子类有且只有一份,那么在其派生链中间类对于虚基类需要固定其实际偏移量。这句话可能不容易理解,嗯,你可以这样想,对于一个对象取其成员,其实实际上是获取该成员的偏移量,再根据自身的地址得到,然而如果对于一个中间类A,其reference或者pointer可能代表的是其任何子类。既然A*或者A&可以代表的实际类型有很多可能,如果使得在这些类中对于虚基类拥有同一偏移量呢?所以,在这里,引入了跟vptr一样的实现方法,用指针指向虚基类。那么所以编译器需要合成 default and copy constructor 用于初始化该指针。 其实以上只是对原因的简单阐述,如果有想深入了解编译器的小动作,可以自行阅读<深度探究C++对象模型 >一书 第二章。 同理我们可以知道,对于问题二: “编译器合成出来的default constructor会显式设定 “class 内每一个datamember” 的默认值?” 答案肯定是No,编译器合成出来的default constructor只会对上述4中情况中所涉及到的内容进行初始化。 |
既然以上的问题解决了,那么我们试图在这个问题的基础上继续衍生出新的问题: 如果在需要合成constructor的情况下,用户提供了相应的constructor呢?编译器会做出如何的行为呢? 既然我们知道,在上述四种情况中,编译器需要constructor函数来对其某些属性进行必不可少的初始化。所以当用户提供了constructor之后,编译器会在user code之前插入这些初始化代码 ,所以在如下代码中: |
class A
{
public:
A() {
num = 0;
}
string str;
int num;
};
实际上编译器加入了如下代码: |
//c++伪码
class A
{
public:
A() {
str.string::string(); //对member class object constructor的调用
num = 0;
}
string str;
int num;
};
所以如果要对str进行非默认构造,请使用构造列表,否则如果在构造函数内部初始化就出现如下情况: |
//c++伪码
class A
{
public:
A() {
//这里实际上是先使用默认构造函数构造一个str,然后在使用"abc"构造一个临时string对象,然后调用拷贝构造给str。
str = "abc";
num = 0;
}
string str;
int num;
};
二 . Memberwise Copy and Bitwise Copy
接着第三个问题: “编译器合成出来的copy constructor的拷贝是memberwise copy 还是 bitwise copy?” 我们先来了解一下什么是Memberwise Copy , Bitwise Copy。 |
1. Bitwise Copy(位逐次拷贝):对于一个对象在其所在内存按照一位一位的依次拷贝。特点:速度块。
2. Memberwise Copy(成员逐次拷贝):对一个对象所有成员进行拷贝(拷贝方式要视成员和copy constructor实现而定),特点:控制力强。
了解了两个copy方式的区别之后,我们再来回顾一下,copy constructor 合成出现的原因。 1.需要对member class object 的 copy constructor进行调用。 2.需要对base class 的 copy constructor 进行调用。 3.需要对vptr进行初始化。 4.需要对指向 virtual base class 的指针进行初始化。 我们可以看出来,对于1,2,我们肯定不能把合成的copy constructor 视位Bitwise Copy。对于vptr呢?我们知道,派生类对象可以赋值给基类对象,虽然这是会发生sliced(截断),但是从语法和用法上是可取的。如果在这个情况下,合成的copy constructor按照Bitwise Copy。那么很明显,派生类的vptr被赋值给了基类的vptr。一旦使用该对象的指针进行对虚函数的调用就会Boom!!!!。同理,对于第4点也一一样。 所以我们知道对于编译器合成的copy constructor 采用的是Memberwise Copy。这里值得注意的是:对于基本类型:int,char,double..等等还是使用的Bitwise Copy。而其他member class object 要参照其类内部copy constructor实现而定。 |
三 . NRV 优化
在谈NRV优化的之前,我觉得还是应该先从代码入手,先看到了问题所在再去谈论优化问题也就变得更加理所应当。 |
int main()
{
Test test1(1); //第一句
Test test2 = 1; //第二句
Test test3 = Test(1); //第三句
return 0;
}
有c++基础的朋友,应该对第一句很不陌生,而且可以很轻松的知道test1(1)调用了带参构造函数,初始化了一个object。 然而对于第二句,第三句,我想如果在单独出现的情况下,多数人应该还是能够得到正确答案;而在出现对比的时候,可能有许多人就会对答案变得似是而非。其实归根究底还是对构造函数没有深入剖析。 在<深度探究C++对象模型 >P39中说到: 单一参数的constructor可以被当作一个conversion运算符;当加入explicit关键字后可以关闭这一方便但却危险的特性。 所以,在第二句中,test2 = 1 实质也是调用了带参构造函数,初始化了一个object。 那么对于第三句呢?或许应该理解为Test(1)初始化了一个临时的对象然后赋值给了test3。然而事实上经过仔细推敲,之前的理解是有问题的,我们知道在定义Test test3的时候 ‘=’绝不应该理解为赋值而是初始化,这是毋庸置疑的。既然是初始化,那么肯定对于test3调用的肯定不是赋值运算符而是某个constructor。那么我们可以理解为,Test(1)初始化了一个临时对象,并且用该对象调用拷贝构造去初始化test3。事实上编译器会对此做出优化,因为临时变量在此处完全没有其他作用,仅仅是deliver data,所以编译器会直接把第三句认同为Test test3(1)。 所以说,以上三句话实质上其实都是只调用了一次带参构造函数。 当然以上的问题还没涉及到NRV优化,只是通过第三个语句来引出优化这一概念,下面来看这段代码: |
Test fun1(){
Test a(1);
return a;
}
Test fun2(){
return Test(1);
}
int main(){
{ Test test1 = fun1(); }
cout << "--------------分隔符---------------" << endl;
{ Test test2 = fun2(); }
return 0;
}
我们先谈谈第二个语句把 Test test2 = fun2(); 在编译器中,对fun2()的函数会进行如下改写: |
void fun2(Test& _result){
_result.Test::Test(Test(1));
return;
}
所以说,test2的初始化是直接拿到了fun2中,而不是在fun2中构造一个临时对象,再用临时对象初始化test2。这个时候你可以把test2这个对象的整个初始化过程分为两步:1.main函数内进行分配内存,2.fun2进行对象构造初始化。 对于第一个语句呢?或许有的朋友编译器得到的结果是先使用带参构造函数调用拷贝构造,有的朋友编译器得到的结果是只调用了一次带参构造。既然同一份代码,可能得出不同的结果,那只有可能是编译器在私底下 ‘搞了小动作’ 我们先还是按照刚才的方法改写fun1函数: |
void fun1(Test& _result){
Test a(1);
_result.Test::Test(a);
return;
}
我们可以看到对象a的作用如同上一段提到的deliver data。从a的内存分配到构造初始化,以及最后的copy to _result。所以,编译器就做了如下NRV(Named Return Value)优化: |
void fun1(Test& _result){
_result.Test::Test(1);
return;
}
编译器并不构造临时对象而是直接将外部的test1拿到函数内部构造,而函数中对a所做的操作,也直接对test1进行。这样就免去了最后的一次copy constructor 消耗。不过值得注意的事,有的朋友的编译器给的结果的确是构造了临时对象。 嗯,是的,NRV优化开启是有条件的,这并不是C++编译器的必须工作。在<深度探究C++对象模型 >一书中曾提到开启NRV优化的必要条件是其类必须定义copy constructor。然而,在现在的g++编译器中,这个条件已经不是必要条件了。我尝试着去找过解释,大概下面这段话应该是符合情理的解释: 早期的 cfront需要一个开关来决定是否应该对代码实行NRV优化,这就是是否有客户(程序员)显式提供的拷贝构造函数:如 果客户没有显示提供拷贝构造函数,那么cfront认为客户对默认的逐位拷贝语义很满意,由于逐位拷贝本身就是很高效的,没必要再对其实施NRV优化;但 如果客户显式提供了拷贝构造函数,这说明客户由于某些原因(例如需要深拷贝等)摆脱了高效的逐位拷贝语义,其拷贝动作开销将增大,所以将应对其实施NRV 优化,其结果就是去掉并不必要的拷贝函数调用。” 所以说,导致结果出现差异并不是这个问题,而是,对于有的编译器优化并不是默认开启的。例如在VS2015下,你需要在项目中修改优化参数O1或者Ox开启优化。这样编译器才会启动NRV优化。 再者,NRV优化开启的还有一个必要条件就是只有当所有的named return 指令发生于函数的top level,优化才施行。只有当是所有的named return是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时。下面给出了两种对比的: |
//Test 类中加入 Test& operator +(int);
Test CanOpen() //可以开启NRV优化
{
Test xx;
if (....) return xx;
else{
...
return xx;
}
}
Test CannotOpen() //无法开启NRV优化
{
Test xx;
if (...) return xx;
else {
...
return xx + 1; //不是所有named return 都是函数的最后一个语句并且不属于表达式的一部分。
}
}
四 . 定义对象?函数声明?
之所以提出这一部分,实则最开始源于一道c++笔试题,后来在这个题目的基础上延伸出相关的几个问题,于是就拿出来探讨一下,首先先看这段代码: |
int main(){
Test test1;
Test test2();
return 0;
}
第一句很显然是定义一个Test对象;至于第二句,嗯,我可以理解为作者视图显式调用默认构造函数去初始化一个test2对象。然而事实上,编译器会把第二个语句解释为一个函数声明,这个函数的返回值为Test类型,函数名为test2,参数列表为空。的确这样解释也是说得通的,并且事实上编译器也就是如此做的。 最开始我对这个问题也就没有进行更多探究了,直到后来,自己写的时候遇到这段代码: |
Test fun(){
return Test();
}
Test func(int){
return Test(1);
}
int main(){
Test test1(fun());
Test test2(Test());
Test test3(fun(1));
Test test4(Test(1));
return 0;
}
我们先看前面两个语句,第一个语句之前我们讲过,就是直接test1调用默认构造函数初始化。第二个语句,从字面上仿佛把我们的理解思路向第一个语句上引导 — 用Test()构造的临时对象构造test2。我们可能就开始关心到底直接构造的test2还是拷贝的临时对象。然而事实上,编译器把第二句仍旧当作是一个函数声明。嗯很奇怪,并且这个函数返回类型为Test,函数名字是test2,参数是一个返回Test,参数列表为空的函数对象。 是的,编译器把Test()当作类型来理解。 刚开始,我并不理解缘由,直到后来无意间想起function这个模板。function模板在定义对象时要先提供函数类型:function<int()> p;这个p可以 ‘指向’ 所有返回值为int,参数列表为空的函数对象。是的这里int()就被视作了类型。 所以说,在上述情况中,既然编译器把Test()视作类型,那么对于Test test2(Test());就只能被翻译为函数声明了,因为声明过程中,对于参数可以只提供类型而不需要参数名(定义的时候也可以不需要参数名)。 至于后面两句,因为参数列表中出现了具体的值1,那么Test(1)就只能被翻译为值了,那么Test test4(Test(1));就只能被翻译为对象的构造,并且这里时直接构造的test4,并不会触发拷贝函数。 |