当定义一个类时,我们可以显式或隐式地指定此类型的对象拷贝、移动、赋值和销毁时做什么。
一个类可以通过定义五种特殊的成员函数来控制这些操作,包括:++拷贝构造函数++、++拷贝赋值函数++、++移动构造函数++、++移动复制函数++和++析构函数++。我们称这些操作为拷贝控制操作。
class Foo {
Foo()
Foo(const Foo&); // 拷贝构造函数
Foo(const Foo&&); // 移动构造函数
Foo& operator=(const Foo&); // 拷贝赋值运算符
Foo& operator=(const Foo&&); // 移动赋值运算符
~Foo(); // 析构函数
...
}
如果一个类没有定义所有这个拷贝控制成员,编译器会自动为它定义缺失的操作。
本篇主要介绍最基本的《拷贝构造函数》和《拷贝赋值运算符》及其使用过程中需要注意的地方。
==定义:如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。==
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&) // 拷贝构造函数
}
拷贝构造函数用同类型的另一个对象初始化本对象,完成从对象之间的 复制过程;
如果我们没有为一个类定义拷贝构造函数,编译器会为我们默认定义一个。
通常称缺省的拷贝构造函数称为:合成拷贝构造函数。也可以按我们的习惯称之为默认拷贝构造函数。
合成的拷贝构造函数会从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:
1. 对类类型的成员,会使用其拷贝构造函数来拷贝
2. 其他内置类型成员直接拷贝
以一个例子进行说明:
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&) // 拷贝构造函数
private:
std::string str;
int num;
}
// 与 Foo 的合成拷贝函数等价:
Foo::Foo(const Foo &foo) {
this->str = foo.str; // 使用 string 的拷贝构造函数
this->num = foo.num; // 直接拷贝 foo.num
}
成员类型的拷贝需要特别强调一点:默认的合成构造函数对于指针类型,使用的是位拷贝!
位拷贝拷贝地址,值拷贝拷贝内容
试想一下,当成员的类型包含指针的情况:
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&) // 拷贝构造函数
char *data;
}
定义 Foo 对象 A 与 B,此时 A.data 与 B.data 分别指向一段内存区域,进行如下赋值操作:
Foo A;
A.data = "hello";
Foo B(A); // 拷贝构造函数
B.data = "world"; // B.data = A.data = "world"
Foo B(A) 调用默认拷贝构造函数,对于指针类型,编译器会默认进行位拷贝(也就是浅拷贝),拷贝指针的地址 —— B.data = A.data,这样 A.data 与 B.data 就指向了同一块内存区域,因此 A.data 的内容也变成 world。
这样可能导致的问题:
1. A.data 和 B.data 指向同一块区域,任何一方改变都会影响另一方。
2. 当对象析构时,B.data 被释放两次
因此,当类成员包含指针类型,一定要重写拷贝构造函数或者拷贝赋值函数,对指针类型自定义实现值拷贝:
Foo::Foo(const Foo &foo) {
data = new char(sizeof(foo.data));
memcpy(data, B.data, sizeof(B.data));
}
class Foo
{
public:
Foo(int a);
Foo(const Foo&);
~Foo();
int a;
};
// 构造函数
Foo::Foo(int a)
{
this->a = a;
std::cout << "-> create" << std::endl;
}
// 析构函数
Foo::~Foo()
{
std::cout << "-> delete" << std::endl;
}
// 拷贝构造函数
Foo::Foo(const Foo &foo)
{
this->a = foo.a;
std::cout << "-> copy" << std::endl;
}
void g_fun(Foo foo)
{
}
int main()
{
Foo foo(1);
g_fun(foo);
return 0;
}
输出如下:
-> create
-> copy
-> delete
当调用 g_fun 函数,编译器会偷偷做比较重要的几个步骤:
1. foo 对象传入函数时,产生一个临时变量 C
2. 调用拷贝构造函数使用 foo 对象初始化 C
3. 等 g_fun 方法执行完成后,析构掉 C
class Foo
{
public:
Foo(int a);
Foo(const Foo&);
~Foo();
int a;
};
// 构造函数
Foo::Foo(int a)
{
this->a = a;
std::cout << "-> create" << std::endl;
}
// 析构函数
Foo::~Foo()
{
std::cout << "-> delete" << std::endl;
}
// 拷贝构造函数
Foo::Foo(const Foo &foo)
{
this->a = foo.a;
std::cout << "-> copy" << std::endl;
}
foo g_fun()
{
Foo foo(1);
return foo;
}
int main()
{
g_fun();
return 0;
}
当 g_Fun() 函数执行到 return 时,会产生以下几个重要步骤:
1. 生成临时变量 C
2. 调用拷贝构造函数使用 foo 对象初始化 C
3. 函数执行到最后析构局部变量 foo
4. 函数调用结束析构临时变量 C
Foo foo(1); // 直接初始化
Foo foo1(foo); // 拷贝构造函数
Foo foo2 = foo; // 拷贝构造函数
特别的,c++ 标准库容器会对它们所分配的对象进行拷贝初始化,如:对容器类调用其 insert 或 push 成员时,对其元素进行拷贝初始化。而用 emplace 成员创建的函数都是进行直接初始化。
容器类调用 insert 或 push 成员插入元素,会涉及到两次构造函数的调用,一是初始化对象时,二是插入时触发拷贝构造。这样会造成不必要的资源浪费。
c++11 标准中引入了 emplace,如 vector 容器的 emplace、emplace_back,类似于 insert,但是由于直接初始化,只需构造一次就可以了。
大多数类应该定义拷贝构造函数合拷贝赋值运算符,但对于某些类,这些操作没有合理的意义。在此情况下必须采取某种机制阻止拷贝或赋值。如,iostream 类阻止了拷贝,以避免多个对象写入或读取相同的 IO 缓冲。
在 c++11 新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为 delete 来阻止拷贝。如:
class Foo
{
public:
Foo(int a);
Foo(const Foo&) = delete;
Foo& operator=(const Foo&) = delete;
};
= delete 通知编译器该控制成员是删除的成员,我们不希望定义这些成员。
析构函数不能是删除的成员,我们不能删除析构函数
拷贝构造函数和赋值运算符的行为比较相似,都是将一个对象的值复制给另一个对象;但是其结果却有些不同,拷贝构造函数使用传入对象的值生成一个新的对象的实例,而赋值运算符是将对象的值复制给一个已经存在的实例。
这种区别从两者的名字也可以很轻易的分辨出来,拷贝构造函数也是一种构造函数,那么它的功能就是创建一个新的对象实例;赋值运算符是执行某种运算,将一个对象的值复制给另一个对象(已经存在的)。
调用的是拷贝构造函数还是赋值运算符,主要是看是否有新的对象实例产生。==如果产生了新的对象实例,那调用的就是拷贝构造函数;如果没有,那就是对已有的对象赋值,调用的是赋值运算符。==
Foo foo(10);
Foo foo1(20);
foo = foo1; // 拷贝赋值运算符
Foo foo2(foo); // 拷贝构造函数
拷贝赋值运算符基于重载运算符,本质上也是函数,其名字由 operator 关键字后接要定义的运算符的符号组成。
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
Foo& operator=(const Foo&);
与默认的 合成构造函数 一样,如果一个类未定义自己的拷贝赋值运算符,编译器会默认生成一个 合成拷贝赋值运算符。
合成拷贝赋值运算符会将右侧对象的每个非 static 成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符完成的。对于数组类型成员,逐个赋值数组元素。
// 与 Foo 的拷贝赋值运算符等价:
Foo& Foo::operator=(const Foo &foo) {
this->str = foo.str; // 使用 string::operator=
this->num = foo.num; // 直接内置的 int 赋值
}