本篇文章讲解C++的拷贝构造函数和赋值运算符
如果一个构造函数的第一个参数是自身类类型的引用,并且任何额外的参数都有默认值。则此构造函数就是拷贝构造函数
int main(int argc, const char* argv[])
{
A a(1);
A a3=a;
A a4 = {a};
std::cout << "拷贝后:" << a3.a << std::endl;
}
如果A的拷贝构造函数声明为explicit的,上述代码会报错。下面这些情况会调用拷贝构造函数:
下面这个例子综合了上述所有情况
#include <iostream>
#include <vector>
class A
{
public:
int a;
A() :a(0) { std::cout << "A的默认构造函数执行了" << std::endl; };
A(int pA) :a(pA) { std::cout << "A的构造函数执行了" << std::endl; }
A(const A& va) :a(va.a) { std::cout << "A的拷贝构造函数执行了"<<std::endl; };
};
class B
{
public:
A a;
};
A test(A a);
int main(int argc, const char* argv[])
{
std::cout << "创建A" << std::endl;
A a(1);
std::cout << "传递A非引用实参" << std::endl;
A a1 = test(a);
std::cout << "构建A的数组" << std::endl;
A a2[3]{ a1 };
std::cout << "初始化聚合类B" << std::endl;
B b{a1};
std::cout << "拷贝A" << std::endl;
A a3 = a1;
std::cout << "使用A初始化vector" << std::endl;
std::vector<A> a4{a3};
std::cout << "通过push_back向vector添加A" << std::endl;
a4.push_back(a3);
std::cout << "通过emplace向vector添加A" << std::endl;
a4.emplace(a4.end(),1);
}
A test(A a)
{
std::cout << "返回A非引用对象" << std::endl;
return A(a);
}
运行结果如下:
创建A
A的构造函数执行了
传递A非引用实参
A的拷贝构造函数执行了
返回A非引用对象
A的拷贝构造函数执行了
构建A的数组
A的拷贝构造函数执行了
A的默认构造函数执行了
A的默认构造函数执行了
初始化聚合类B
A的拷贝构造函数执行了
拷贝A
A的拷贝构造函数执行了
使用A初始化vector
A的拷贝构造函数执行了
A的拷贝构造函数执行了
通过push_back向vector添加A
A的拷贝构造函数执行了
A的拷贝构造函数执行了
通过emplace向vector添加A
A的构造函数执行了
A的拷贝构造函数执行了
A的拷贝构造函数执行了
有个地方需要注意,就是使用A初始化vector的时候,这个时候执行了两次拷贝构造函数:
使用emplace方法创建元素的时候使用的直接初始化
如果类自己定义了拷贝构造函数,就需要自己负责所有成员常量的赋值,编译器不再进行任何赋值操作
我们对于拷贝构造函数,有三种分类:
没有拷贝构造函数
,编译器也没有合成默认拷贝构造函数,这种情况下,对象也可以使用拷贝构造函数,底层会直接复制内存数据,这种情况相当于进行了全拷贝。编译器合成了拷贝构造函数
,这个时候,拷贝构造函数会依次拷贝类的非静态成员变量,如果成员是基本类型,直接复制,如果是类类型,调用成员的拷贝构造函数。对于数组,队列,栈或者容器类成员,会依次拷贝他们的成员到新的对象中,如果类存在虚函数表,需要拷贝虚函数表指针。如果类存在虚基类表指针,需要拷贝虚基类指针。也就是说,编译器的合成方法会帮我们做所有我们想做的事情。这个时候的拷贝相当于全自动的,从用户的角度来看起始和第一类是相似的,但是本质不同。当然性能也不一样。自己定义拷贝构造函数
,这个时候,所有成员变量的拷贝工作由我们自己负责,但是,虚函数指针和虚基类指针的拷贝我们不用管,就像构造函数中我们也没有管这两个指针的赋值一样,编译器会为我们插入相关代码。所以,这种相当于一个半自动的拷贝。一般情况下,我们会对一个类的拷贝进行手动控制,尤其是类中存在手动分配内存的情况下
。第一种情况好理解,我们就不介绍了,我们通过一个例子看一下2、3的区别:
#include <iostream>
#include <vector>
class A
{
public:
int a;
A() :a(0) { std::cout << "A的默认构造函数执行了" << std::endl; };
A(int pA) :a(pA) { std::cout << "A的构造函数执行了" << std::endl; }
A(const A& va) { std::cout << "A的拷贝构造函数执行了"<<std::endl; };
virtual void print() {};
virtual ~A() { std::cout << "A的析构函数执行了" << std::endl; };
};
class B
{
public:
int a;
B() :a(0) { std::cout << "B的默认构造函数执行了" << std::endl; };
B(int pA) :a(pA) { std::cout << "B的构造函数执行了" << std::endl; }
virtual void print() {};
virtual ~B() { std::cout << "B的析构函数执行了" << std::endl; };
};
A test(A a);
int main(int argc, const char* argv[])
{
A a(1);
B b(1);
A a1 = a;
B b1 = b;
std::cout << "a1.a::" <<a1.a<< std::endl;
std::cout << "b1.a::" << b1.a << std::endl;
}
上面的例子中类A和类B基本一致,唯一的不同就是A定义了自己的拷贝构造函数,类B使用合成的拷贝构造函数,什么时候合成后面会介绍。
看一下打印结果:
A的构造函数执行了
B的构造函数执行了
A的拷贝构造函数执行了
a1.a::-858993460
b1.a::1
B的析构函数执行了
A的析构函数执行了
B的析构函数执行了
A的析构函数执行了
就像我们上面介绍的,A使用自己写的拷贝构造函数,所以不对a的值进行拷贝,需要自己写代码,如果没写,a的值就是为定义的。但是B使用合成的拷贝构造函数,a的值进行了拷贝。
然后我们看一下具体的汇编代码:
这是类A执行拷贝的汇编代码
00007FF667F61E94 mov rax,qword ptr [this]
00007FF667F61E9B lea rcx,[A::`vftable' (07FF667F6BCF8h)]
这是类B执行拷贝的汇编代码
00007FF667F66AD6 mov rax,qword ptr [this]
00007FF667F66ADD lea rcx,[B::`vftable' (07FF667F6BC30h)]
00007FF667F66AE4 mov qword ptr [rax],rcx
00007FF667F66AE7 mov rax,qword ptr [this]
00007FF667F66AEE mov rcx,qword ptr [__that]
00007FF667F66AF5 mov ecx,dword ptr [rcx+8]
00007FF667F66AF8 mov dword ptr [rax+8],ecx
我们可以很清楚的看到:
总结:如果类的设计者手动添加了拷贝构造函数和拷贝赋值运算符,那么必须为类的成员变量和基类的拷贝赋值负责
编译器生成拷贝构造函数的情况:
拷贝构造函数
。如果代码中有涉及类的复制构造时,编译器会为当前类生成一个拷贝构造函数,来调用成员变量的拷贝构造函数。拷贝构造函数
。如果代码中有涉及类的复制构造时,编译器会为当前类生成一个拷贝构造函数,来调用父类的拷贝构造函数。总结:编译器生成拷贝构造函数的前提是类一定没有定义任何拷贝构造函数,并且代码中存在类的复制构造
我们通过将拷贝构造函数设置成delete,可以避免当前类发生拷贝工作。对于单例使用的类我们一般都会这样设值。
我们可以通过在拷贝构造函数的结尾添加default关键字来显式让编译器合成一个拷贝构造函数。这里需要注意两点:
我们通过使用
当前对象的引用 operator = (const 拷贝对象的引用)
来定义一个赋值运算符。注意:
再次重申一下赋值和初始化的区别:
初始化和赋值的区别就看操作的是不是一个新对象,如果一个新对象被定义,那就是初始化,如果没有新对象定义,那就是赋值。
看下面的代码:
int main(int argc, const char* argv[])
{
A a(1);
A a1 = a;
A a2;
a2 = a;
}
a1执行的是拷贝构造函数
a2执行的是拷贝赋值运算符
编译器合成赋值运算符的条件和拷贝构造函数是一致的。
也就是说:
前两个我们好理解,因为要用,第三四条,构造函数的时候我们也好理解,毕竟需要赋值啊,现在拷贝的时候还有用吗?
看下面的例子:
#include <iostream>
#include <vector>
class A
{
public:
int a;
A() :a(0) { std::cout << "A的默认构造函数执行了" << std::endl; };
A(int pA) :a(pA) { std::cout << "A的构造函数执行了" << std::endl; }
A(const A& va) { std::cout << "A的拷贝构造函数执行了"<<std::endl; };
virtual void print() {};
virtual ~A() { std::cout << "A的析构函数执行了" << std::endl; };
};
int main(int argc, const char* argv[])
{
std::cout << "A:::" << sizeof(A) << std::endl;
A a(1);
A a2;
// 此时执行拷贝赋值运算符
a2 = a;
}
执行拷贝赋值运算符的时候的汇编代码如下:
// 我们只截取了关键部分
00007FF67A6D24A6 mov rax,qword ptr [this]
00007FF67A6D24AD mov rcx,qword ptr [__that]
00007FF67A6D24B4 mov ecx,dword ptr [rcx+8]
00007FF67A6D24B7 mov dword ptr [rax+8],ecx
00007FF67A6D24BA mov rax,qword ptr [this]
我们发现只把that的从第8个字节开始的4字节内容赋值给了this从第8字节开始处,我们知道,前8个字节正是虚函数指针。所以说:
拷贝赋值运算符并没有对虚函数指针进行复制。
如果对于一个需要在堆上分配空间的类来说,通常我们要实现拷贝赋值运算符,那我们应该怎么写呢?
看下面例子:
class A
{
public:
int* a;
A& operator=(const A& va)
{
if (a)
{
delete a;
}
a = new int();
*a = *va.a;
return *this;
}
virtual ~A()
{
if (a)
{
delete a;
a = nullptr;
}
};
};
这样写有问题吗?
看着不错,先删除自己这块内存,使用new int() 新开辟一块内存,然后把va的值复制过来,这样就实现了深拷贝。
但是,想象一下,如果我自己拷贝自己呢?
也就是va就是*this,这时候上面代码还能用吗?
很显然,这个时候删除自己这块内存的时候已经把va的a删除了,后面肯定会出现异常,下面是一个优化的写法:
A& operator=(const A& va)
{
int* temp = a;
a = new int();
*a = *va.a;
if (temp)
{
delete temp;
}
return *this;
}
想想我们为什么需要自定义拷贝构造函数和拷贝赋值运算符,一般情况是下是因为类中存在需要我们深拷贝的成员,比如在堆上手动申请的内存。这种情况下,基本上也需要定义析构函数,因为要释放内存。所以总的来说:
收放自如,张弛有度!!!