阅读说明:对于前面代码看不懂的朋友,可以先跳到最后看总结,然后再回头看上文内容,或者会有豁然开朗的感觉。
开发运行环境:visual studio 2013
源代码
#include <iostream>
#include <cstring>
using namespace std;
class ClassTest
{
public:
ClassTest()
{
c[0] = '\0';
cout << "ClassTest()" << endl;
}
ClassTest& operator=(const ClassTest &ct)
{
strcpy(c, ct.c);
cout << "ClassTest& operator=(const ClassTest &ct)" << endl;
return *this;
}
ClassTest(ClassTest&& ct)
{
cout << "ClassTest(ClassTest&& ct)" << endl;
}
ClassTest & operator=(ClassTest&& ct)
{
strcpy(c, ct.c);
cout << "ClassTest & operator=(ClassTest&& ct)" << endl;
return *this;
}
ClassTest(const char *pc)
{
strcpy(c, pc);
cout << "ClassTest (const char *pc)" << endl;
}
//private:
ClassTest(const ClassTest& ct)
{
strcpy(c, ct.c);
cout << "ClassTest(const ClassTest& ct)" << endl;
}
virtual int ff()
{
return 1;
}
private:
char c[256];
};
ClassTest f1()
{
ClassTest c;
return c;
}
void f2(ClassTest ct)
{
;
}
int main()
{
ClassTest ct1("ab");//直接初始化
ClassTest ct2 = "ab";//复制初始化
ClassTest ct3 = ct1;//复制初始化
ClassTest ct4(ct1);//直接初始化
ClassTest ct5 = ClassTest("ab");//复制初始化
ClassTest ct6 = f1();
f1();
f2(ct1);
return 0;
}
初始化1:ClassTest ct1("ab")
ClassTest ct1("ab");//直接初始化
00B09518 push 0B0DCB8h //"ab"字符串地址
00B0951D lea ecx,[ct1]
00B09523 call ClassTest::ClassTest (0DC101Eh)
上面初始化汇编代码中,首先将“ab”字符串的地址压栈,并且取得ct1对象的地址存入寄存器ecx,即通过栈和寄存器传入两个参数,调用了ClassTest(const char *pc)构造函数。在ClassTest(const char *pc)函数中利用ct1对象的地址(即this指针)初始化ct1对象。
初始化2:ClassTest ct2 = "ab"
ClassTest ct2 = "ab";//复制初始化
00B09528 push 0B0DCB8h //"ab"字符串地址
00B0952D lea ecx,[ct2]
00B09533 call ClassTest::ClassTest (0DC101Eh)
这是一个拷贝初始化式,底层的汇编有点出乎意料。本来赋值表达式右边会利用形参为const char*的构造函数生成一个临时对象,然后再利用这个临时对象拷贝或移动到ct2,但是经过visual studio编译器的处理,使得赋值表达式右边的字符串作为构造函数的实参直接对ct2进行初始化,和初始化1一样,这样可以省略了一步,加快运行速度,并且达到同样的效果。注意:在上面的汇编中,已经关闭了visual studio编译器优化,说明这种方法已经作为了visual studio的普遍方法,而不是作为一种vs所认为的优化手段了。
初始化3:ClassTest ct3 = ct1
ClassTest ct3 = ct1;//复制初始化
00B09538 lea eax,[ct1]
00B0953E push eax
00B0953F lea ecx,[ct3]
00B09545 call ClassTest::ClassTest (0DC14C4h)
初始化3中通过栈和寄存器ecx传入了赋值表达式左右两边的对象地址,然后调用了类的拷贝构造函数(注意:函数只有一个形参,但其实也传入了ct3对象的地址,this指针),假如用户没有定义拷贝构造函数,编译器会生成合成的拷贝构造函数。如下:
010B3EE0 push ebp
010B3EE1 mov ebp,esp
010B3EE3 sub esp,0CCh
010B3EE9 push ebx
010B3EEA push esi
010B3EEB push edi
010B3EEC push ecx
010B3EED lea edi,[ebp-0CCh]
010B3EF3 mov ecx,33h
010B3EF8 mov eax,0CCCCCCCCh
010B3EFD rep stos dword ptr es:[edi]
010B3EFF pop ecx
010B3F00 mov dword ptr [this],ecx
010B3F03 mov eax,dword ptr [this] //eax指向ct3对象地址
010B3F06 mov dword ptr [eax],10BDC70h //虚表指针存储在对象偏移量为0的地方
010B3F0C mov esi,dword ptr [__that] //esi存储ct1对象地址
010B3F0F add esi,4 //将esi加4,跳过4个字节的虚表指针,指向ct1后面的成员变量c
010B3F12 mov edi,dword ptr [this]
010B3F15 add edi,4 //edi指向ct2后面成员变量c
010B3F18 mov ecx,40h
010B3F1D rep movs dword ptr es:[edi],dword ptr [esi] //将ct1中字符数组元素拷贝到ct3字符数组
010B3F1F mov eax,dword ptr [this] //通过eax返回ct3对象地址
010B3F22 pop edi
010B3F23 pop esi
010B3F24 pop ebx
010B3F25 mov esp,ebp
010B3F27 pop ebp
初始化4:ClassTest ct4(ct1)
ClassTest ct4(ct1);//直接初始化
010B954A lea eax,[ct1]
010B9550 push eax
010B9551 lea ecx,[ct4]
010B9557 call ClassTest::ClassTest (0DC14C4h)
初始化4和初始化3汇编指令一样,底层都是传入了两个对象的地址,然后再调用拷贝构造函数。
初始化5:ClassTest ct5 = ClassTest()
ClassTest ct5 = ClassTest();//复制初始化
010B955C lea ecx,[ct5]
010B9562 call ClassTest::ClassTest (0DC12ADh)
跟踪下去,发现它跳到了类的默认构造函数那里;
ClassTest()
010B4C70 push ebp
010B4C71 mov ebp,esp
010B4C73 sub esp,0CCh
010B4C79 push ebx
010B4C7A push esi
010B4C7B push edi
010B4C7C push ecx
010B4C7D lea edi,[ebp-0CCh]
010B4C83 mov ecx,33h
010B4C88 mov eax,0CCCCCCCCh
010B4C8D rep stos dword ptr es:[edi]
010B4C8F pop ecx
010B4C90 mov dword ptr [this],ecx
010B4C93 mov eax,dword ptr [this]
010B4C96 mov dword ptr [eax],10BDC70h
{
c[0] = '\0';
010B4C9C mov eax,1
010B4CA1 imul ecx,eax,0
010B4CA4 mov edx,dword ptr [this]
010B4CA7 mov byte ptr [edx+ecx+4],0
cout << "ClassTest()" << endl;
说好的生成一个临时对象,再将这个临时对象拷贝或移动到ct5中,其实不然。而是将ct5对象地址作为实参去调用默认构造函数,进而对ct5进行初始化。
初始化6:ClassTest ct6 = f1()
ClassTest ct6 = f1();
010B9567 lea eax,[ct6]
010B956D push eax
010B956E call f1 (0DC14BFh)
010B9573 add esp,4
这个初始化的底层实现也是比较出乎意料的一个。首先将已存在在main函数栈中的ct6对象地址压栈,此时根据函数调用规则,可以知道ct6对象地址其实作为了f1的实参。
ClassTest f1()
{
00DC5830 push ebp //栈帧开始
00DC5831 mov ebp,esp
00DC5833 sub esp,1D0h
00DC5839 push ebx
00DC583A push esi
00DC583B push edi
00DC583C lea edi,[ebp-1D0h]
00DC5842 mov ecx,74h
00DC5847 mov eax,0CCCCCCCCh
00DC584C rep stos dword ptr es:[edi]
00DC584E mov eax,dword ptr ds:[00DD0000h] //初始化栈
00DC5853 xor eax,ebp
00DC5855 mov dword ptr [ebp-4],eax
ClassTest c;
00DC5858 lea ecx,[c] //c的值ebp+FFFFFEF4h即ebp-12,说明c是一个栈内局部变量
00DC585E call ClassTest::ClassTest (0DC12ADh) //调用默认构造函数初始化c
return c;
00DC5863 lea eax,[c]
00DC5869 push eax //c对象地址
00DC586A mov ecx,dword ptr [ebp+8] //ct6对象地址
00DC586D call ClassTest::ClassTest (0DC14BAh) //调用拷贝构造函数,初始化ct6
00DC5872 mov eax,dword ptr [ebp+8] //返回ct6对象地址
}
00DC5875 push edx
00DC5876 mov ecx,ebp
00DC5878 push eax
00DC5879 lea edx,ds:[0DC58A4h]
00DC587F call @_RTC_CheckStackVars@8 (0DC1136h)
00DC5884 pop eax
//省略余下代码
从上面的汇编代码中可以看出,c是栈内的局部变量,并且调用了默认构造函数对c进行了初始化。但f1代码中return c语句,它就是返回一个和c一样的临时对象了吗?其实不然。在调用f1的时候,也传进了ct6对象的地址,在f1内部对c进行初始化后,直接通过c对象地址和ct6地址调用移动构造函数,对ct6进行了初始化,最后返回的是ct6对象地址。可以看出vs将ct6的初始化工作放在了函数内部进行!
临时对象:f1()
f1();
00DC9576 lea eax,[ebp-814h]
00DC957C push eax
00DC957D call f1 (0DC14BFh)
00DC9582 add esp,4
临时对象可以看成是无名的变量,在内部也是存在于栈中的一个对象。所以和初始化6一样,只不过这个时候传入的是临时对象的地址而已,最后返回的也是临时对象的地址,返回前也调用了移动构造函数
临时对象:f2(ct1)
f2(ct1);
010F9392 sub esp,104h //开辟栈空间,生成一个临时对象,刚好是260个字节(256+4,即虚表指针和私有的char型数组的总大小)
010F9398 mov ecx,esp //将esp栈顶指针作为临时对象的起始地址
010F939A lea eax,[ct1] //传入ct1对象地址
010F93A0 push eax
010F93A1 call ClassTest::ClassTest (010F1078h)
010F93A6 call f2 (010F14BFh)
010F93AB add esp,104h
从上面的汇编代码中可以看出,编译器对于一个形参为类型的函数,不是直接传入ct1对象地址,而是在栈上生成一个临时对象并且用拷贝构造函数进行初始化,最后再传入临时对象的地址调用f2函数。
总结
这么零散复杂的汇编,大部分人看了都有点头疼,最后再来个总结:
(1)什么是拷贝初始化(也称为复制初始化):将一个已有的对象拷贝到正在创建的对象,如果需要的话还需要进行类型转换。拷贝初始化发生在下列情况:
使用赋值运算符定义变量
将对象作为实参传递给一个非引用类型的形参
将一个返回类型为非引用类型的函数返回一个对象
用花括号列表初始化一个数组中的元素或一个聚合类中的成员
(2)什么是直接初始化:在对象初始化时,通过括号给对象提供一定的参数,并且要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数
(3)在底层实现中,可以看出编译器的思想是能不用临时对象就不用临时对象。因此对于下面这些拷贝初始化,都不会生成临时对象再进行拷贝或移动到目标对象,而是直接通过函数匹配调用相应的构造函数。
1 ClassTest ct2 ="ab"; //相当于ClassTest ct2("ab");
2 ClassTest ct5 =ClassTest("ab"); //相当于ClassTest ct5("ab")
下面的语句,visual studio才会生成一个无名的临时对象(位于main函数的栈中),注意:f1的返回值类型是非引用的,f2的形参类型是非引用的。
1 f1(); //临时对象用于存储f1的返回值
2 f2(ct1); //临时对象用于拷贝实参,并传入函数
而下面则是直接传入赋值表达式左边对象地址,然后再对该对象进行移动拷贝,注意f1返回值类型是非引用的,如果是引用的,则会调用拷贝构造函数。
1 ClassTest ct6 = f1();
(4)直接初始化和拷贝初始化效率基本一样,因为在底层的实现基本一样,所以将拷贝初始化改为直接初始化效率提高不大。
(5)拷贝初始化什么时候使用了移动构造函数:当你定义了移动构造函数,下列情况将调用移动构造函数
将一个返回类型为非引用类型的函数返回一个对象
(6)拷贝初始化什么时候使用拷贝构造函数:
赋值表达式右边是一个对象
直接初始化时,括号内的参数是一个对象
用花括号列表初始化一个数组中的元素或一个聚合类中的成员
将一个返回类型为引用类型的函数返回一个对象
形参为非引用类型的函数,其中是将实参拷贝到临时对象
深入解释直接初始化与复制初始化的区别
跟大家详细分享一下我对直接初始化和复制初始化的理解。
一、Primer中的说法
首先我们来看看经典是怎么说的:
“当用于类类型对象时,初始化的复制形式和直接形式有所不同:直接初始化直接调用与实参匹配的构造函数,复制初始化总是调用复制构造函数。复制初始化首先使用指定构造函数创建一个临时对象,然后用复制构造函数将那个临时对象复制到正在创建的对象”
还有一段这样说,
“通常直接初始化和复制初始化仅在低级别优化上存在差异,然而,对于不支持复制的类型,或者使用非explicit构造函数的时候,它们有本质区别:
ifstream file1("filename")://ok:direct initialization
ifstream file2 = "filename";//error:copy constructor is private
”
二、通常的误解
从上面的说法中,我们可以知道,直接初始化不一定要调用复制构造函数,而复制初始化一定要调用复制构造函数。然而大多数人却认为,直接初始化是构造对象时要调用复制构造函数,而复制初始化是构造对象时要调用赋值操作函数(operator=),其实这是一大误解。因为只有对象被创建才会出现初始化,而赋值操作并不应用于对象的创建过程中,且primer也没有这样的说法。至于为什么会出现这个误解,可能是因为复制初始化的写法中存在等号(=)吧。
为了把问题说清楚,还是从代码上来解释比较容易让人明白,请看下面的代码:
#include <iostream>
#include <cstring>
using namespace std;
class ClassTest
{
public:
ClassTest()
{
c[0] = '\0';
cout<<"ClassTest()"<<endl;
}
ClassTest& operator=(const ClassTest &ct)
{
strcpy(c, ct.c);
cout<<"ClassTest& operator=(const ClassTest &ct)"<<endl;
return *this;
}
ClassTest(const char *pc)
{
strcpy(c, pc);
cout<<"ClassTest (const char *pc)"<<endl;
}
// private:
ClassTest(const ClassTest& ct)
{
strcpy(c, ct.c);
cout<<"ClassTest(const ClassTest& ct)"<<endl;
}
private:
char c[256];
};
int main()
{
cout<<"ct1: ";
ClassTest ct1("ab");//直接初始化
cout<<"ct2: ";
ClassTest ct2 = "ab";//复制初始化
cout<<"ct3: ";
ClassTest ct3 = ct1;//复制初始化
cout<<"ct4: ";
ClassTest ct4(ct1);//直接初始化
cout<<"ct5: ";
ClassTest ct5 = ClassTest();//复制初始化
return 0;
}
输出结果为:
从输出的结果,我们可以知道对象的构造到底调用了哪些函数,从ct1与ct2、ct3与ct4的比较中可以看出,ct1与ct2对象的构建调用的都是同一个函数——ClassTest(const char *pc),同样道理,ct3与ct4调用的也是同一个函数——ClassTest(const ClassTest& ct),而ct5则直接调用了默认构造函数。
于是,很多人就认为ClassTest ct1("ab");等价于ClassTest ct2 = "ab";,而ClassTest ct3 = ct1;也等价于ClassTest ct4(ct1);而且他们都没有调用赋值操作函数,所以它们都是直接初始化,然而事实是否真的如你所想的那样呢?答案显然不是。
三、层层推进,到底谁欺骗了我们
很多时候,自己的眼睛往往会欺骗你自己,这里就是一个例子,正是你的眼睛欺骗了你。为什么会这样?其中的原因在谈优化时的补充中也有说明,就是因为编译会帮你做很多你看不到,你也不知道的优化,你看到的结果,正是编译器做了优化后的代码的运行结果,并不是你的代码的真正运行结果。
你也许不相信我所说的,那么你可以把类中的复制函数函数中面注释起来的那行取消注释,让复制构造函数成为私有函数再编译运行这个程序,看看有什么结果发生。
很明显,发生了编译错误,从上面的运行结果,你可能会认为是因为ct3和ct4在构建过程中用到了复制构造函数——ClassTest(const ClassTest& ct),而现在它变成了私有函数,不能在类的外面使用,所以出现了编译错误,但是你也可以把ct3和ct4的函数语句注释起来,如下所示:
int main()
{
cout<<"ct1: ";
ClassTest ct1("ab");
cout<<"ct2: ";
ClassTest ct2 = "ab";
// cout<<"ct3: ";
// ClassTest ct3 = ct1;
// cout<<"ct4: ";
// ClassTest ct4(ct1);
cout<<"ct5: ";
ClassTest ct5 = ClassTest();
return 0;
}
然而你还是非常遗憾地发现,还是没有编译通过。这是为什么呢?从上面的语句和之前的运行结果来看,的确是已经没有调用复制构造函数了,为什么还是编译错误呢?
经过实验,main函数只有这样才能通过编译:
int main()
{
cout<<"ct1: ";
ClassTest ct1("ab");
return 0;
}
在这里我们可以看到,原来是复制构造函数欺骗了我们。
四、揭开真相
看到这里,你可能已经大惊失色,下面就让我来揭开这个真相吧!
还是那一句,什么是直接初始化,而什么又是复制初始化呢?
简单点来说,就是定义对象时的写法不一样,一个用括号,如ClassTest ct1("ab"),而一个用等号,如ClassTest ct2 = "ab"。
但是从本质来说,它们却有本质的不同:直接初始化直接调用与实参匹配的构造函数,复制初始化总是调用复制构造函数。复制初始化首先使用指定构造函数创建一个临时对象,然后用复制构造函数将那个临时对象复制到正在创建的对象。所以当复制构造函数被声明为私有时,所有的复制初始化都不能使用。
现在我们再来看回main函数中的语句,
1、ClassTest ct1("ab");这条语句属于直接初始化,它不需要调用复制构造函数,直接调用构造函数ClassTest(const char *pc),所以当复制构造函数变为私有时,它还是能直接执行的。
2、ClassTest ct2 = "ab";这条语句为复制初始化,它首先调用构造函数ClassTest(const char *pc)函数创建一个临时对象,然后调用复制构造函数,把这个临时对象作为参数,构造对象ct2;所以当复制构造函数变为私有时,该语句不能编译通过。
3、ClassTest ct3 = ct1;这条语句为复制初始化,因为ct1本来已经存在,所以不需要调用相关的构造函数,而直接调用复制构造函数,把它值复制给对象ct3;所以当复制构造函数变为私有时,该语句不能编译通过。
4、ClassTest ct4(ct1);这条语句为直接初始化,因为ct1本来已经存在,直接调用复制构造函数,生成对象ct3的副本对象ct4。所以当复制构造函数变为私有时,该语句不能编译通过。
注:第4个对象ct4与第3个对象ct3的创建所调用的函数是一样的,但是本人却认为,调用复制函数的原因却有所不同。因为直接初始化是根据参数来调用构造函数的,如ClassTest ct4(ct1),它是根据括号中的参数(一个本类的对象),来直接确定为调用复制构造函数ClassTest(const ClassTest& ct),这跟函数重载时,会根据函数调用时的参数来调用相应的函数是一个道理;而对于ct3则不同,它的调用并不是像ct4时那样,是根据参数来确定要调用复制构造函数的,它只是因为初始化必然要调用复制构造函数而已。它理应要创建一个临时对象,但只是这个对象却已经存在,所以就省去了这一步,然后直接调用复制构造函数,因为复制初始化必然要调用复制构造函数,所以ct3的创建仍是复制初始化。
5、ClassTest ct5 = ClassTest();这条语句为复制初始化,首先调用默认构造函数产生一个临时对象,然后调用复制构造函数,把这个临时对象作为参数,构造对象ct5。所以当复制构造函数变为私有时,该语句不能编译通过。
五、假象产生的原因
产生上面的运行结果的主要原因在于编译器的优化,而为什么把复制构造函数声明为私有(private)就能把这个假象去掉呢?主要是因为复制构造函数是可以由编译默认合成的,而且是公有的(public),编译器就是根据这个特性来对代码进行优化的。然而如里你自己定义这个复制构造函数,编译则不会自动生成,虽然编译不会自动生成,但是如果你自己定义的复制构造函数仍是公有的话,编译还是会为你做同样的优化。然而当它是私有成员时,编译器就会有很不同的举动,因为你明确地告诉了编译器,你明确地拒绝了对象之间的复制操作,所以它也就不会帮你做之前所做的优化,你的代码的本来面目就出来了。
举个例子来说,就像下面的语句:
ClassTest ct2 = "ab";
它本来是要这样来构造对象的:首先调用构造函数ClassTest(const char *pc)函数创建一个临时对象,然后调用复制构造函数,把这个临时对象作为参数,构造对象ct2。然而编译也发现,复制构造函数是公有的,即你明确地告诉了编译器,你允许对象之间的复制,而且此时它发现可以通过直接调用重载的构造函数ClassTest(const char *pc)来直接初始化对象,而达到相同的效果,所以就把这条语句优化为ClassTest ct2("ab")。
而如果把复制构造函数声明为私有的,则对象之前的复制不能进行,即不能把临时对像作为参数,调用复制构造函数,所以编译就认为ClassTest ct2 = "ab"与ClassTest ct2("ab")是不等价的,也就不会帮你做这个优化,所以编译出错了。
注:根据上面的代码,有些人可能会运行出与本人测试不一样的结果,这是为什么呢?就像前面所说的那样,编译器会为代码做一定的优化,但是不同的编译器所作的优化的方案却可能有所不同,所以当你使用不同的编译器时,由于这些优化的方案不一样,可能会产生不同的结果,我这里用的是g++4.7。