一个类通过定义五种特殊的成员函数来控制此类型对象的拷贝、移动、赋值和销毁:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。而这些操作合起来称为 拷贝控制操作
拷贝和移动构造函数
定义了 当用同类型的另一个对象初始化本对象时做什么拷贝和移动赋值运算符
定义了 将一个对象赋予同类型的另一个对象时做什么析构函数
定义了 当此类型对象销毁时做什么
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作,但编译器定义的行为可能并非如我们所想,所以我们最终的目标是认识到什么时候需要定义这些操作。
拷贝构造函数
如果一个构造函数的第一个参数是自身类型的引用,且任何额外参数都有默认值,则此构造函数是 拷贝构造函数
。
class Foo{
public:
Foo(); //默认构造函数
Foo(const Foo&) //拷贝构造函数
}
之前提过,我们如果没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数
编译器从给定对象中依次将每个非 static
成员拷贝到正在创建对象中,每个成员的类型决定了它如何拷贝:
对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝
string dots(10, '.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-999" // 拷贝初始化
string nines = string(100,'9'); // 拷贝初始化
拷贝初始化,编译器会将右侧运算对象拷贝到正在创建的对象中,如果需要的话还会进行适当的类型转换
拷贝构造函数会在以下几种情况下会被使用:
- 通过
=
定义 变量时 - 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
- 初始化标准库容器或调用其 insert/push 操作,容器会对其元素进行拷贝初始化
聚合类:
- 所有成员都是
public
的- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有
virtual
函数
拷贝构造函数自己的参数必须是引用类型这里也就可以得出结论了:
将一个对象作为实参传递给一个非引用类型的形参
是会调用拷贝构造函数, 如果拷贝构造函数的参数不是引用类型,则调用永远不会成功–为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,套娃呢~
拷贝赋值运算符
class Foo{
public:
Foo& operator=(const Foo&);
};
拷贝赋值运算符本身是一个 重载的赋值运算符,定义为类的成员函数,左侧运算对象绑定到隐含的 this
参数,而右侧运算对象是所属类类型的,作为函数的参数,函数返回指向其左侧运算对象的引用
当对类对象进行赋值时,会使用拷贝赋值运算符
通常情况下,合成的拷贝赋值运算符会将右侧对象的非 static
成员逐个赋予左侧对象的对应成员,这些赋值操作是有成员类型的拷贝赋值运算符来完成的
若一个类未定义自己的拷贝赋值运算符,编译器就会为其合成拷贝赋值运算符,完成赋值操作
析构函数
析构函数完成与构造函数相反的工作: 释放对象使用的资源,销毁非静态数据成员
从语法上看,它是类的一个成员函数,名字是被波浪号接类名,没有返回值,也不接受参数
当一个类没有定义析构函数时,编译器会为它合成析构函数
析构的顺序,首先执行函数体,当空函数体执行完后,然后销毁非静态数据成员。也就是说,成员是在析构函数体之后隐含的析构阶段进行销毁
成员按初始化顺序的逆序销毁,成员销毁时发生什么完全依赖于成员的类型,销毁类类型的成员执行成员自己的析构函数,内置类型没有析构函数。
隐式销毁一个内置指针类型的成员不会
delete
它所指向的对象, 提到指针在提一下智能指针,智能指针实际上是类类型的,所以具有析构函数
什么时候调用析构函数:
- 变量在离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁
- 对于临时对象,当创建它的完整表达式结束时被销毁
举个栗子
我定义了一个简单的类,进一步理解拷贝构造函数,拷贝赋值运算符以及析构函数何时执行
#include <iostream>
#include <vector>
using namespace std;
struct X {
X() { cout << "构造函数 X()" << endl;}
X(const X&) { cout << "拷贝构造函数 X&" << endl;}
X& operator =(const X &rhs) { cout << "拷贝赋值运算符 =(const X&)" << endl;}
~X() { cout << "析构函数 ~X()" << endl;}
};
void f1(X x) {}
void f2(X &x) {}
int main()
{
X x;
cout << endl;
cout << "非引用参数传递:" << endl;
f1(x);
cout << endl;
cout << "引用参数传递:" << endl;
f2(x);
cout << endl;
cout << "动态分配:" << endl;
X *px = new X;
cout << endl;
cout << "添加到容器中:" << endl;
vector<X> vx;
vx.push_back(x);
cout << endl;
cout << "释放动态分配对象:" << endl;
delete px;
cout << endl;
cout << "间接初始化和赋值:" << endl;
X y = x;
y = x;
cout << endl;
cout << "程序结束: "<<endl;
return 0;
}
运行结果如下:
构造函数 X()
非引用参数传递:
拷贝构造函数 X&
析构函数 ~X()
引用参数传递:
动态分配:
构造函数 X()
添加到容器中:
拷贝构造函数 X&
释放动态分配对象:
析构函数 ~X()
间接初始化和赋值:
拷贝构造函数 X&
拷贝赋值运算符 =(const X&)
程序结束:
析构函数 ~X()
析构函数 ~X()
析构函数 ~X()
程序结束的析构三次分别是 x
, y
和 vx
的第一个元素
三五法则
三个基本操作可以控制类的拷贝操作: 拷贝构造函数,拷贝赋值运算符和析构函数, 其实就是三法则
五法则指的是: 在C++11新标准下,一个类还可以在定义一个移动构造函数和一个移动赋值运算符,一共五个基本操作控制类的拷贝操作
新标准新加了2个特殊函数,但是也没有将三法则直接去掉,而是合并了,称为 三五法则
这些操作 一般被看成一个整体,通常情况下,需要其中一个操作,而不需要定义所有操作的情况是很少见的
- 需要析构函数的类也需要拷贝和赋值操作
- 需要拷贝操作的类也需要赋值操作,反之亦然
解释一下为什么一般视为一个整体:
若一个类需要析构函数,则代表其编译器默认合成的析构函数不足以释放类所拥有的资源,最典型的就是指针成员。所以,我们需要自己写析构函数来释放给指针所分配的内存来防止内存泄露。
可以先看一下下面这个例子 HasPtr
, 因为成员变量中有指针类型的 ps
, 所以析构的时候需要手动 delete
class HasPtr{
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0) {}
~HasPtr() { delete ps; }
string getString() { return *ps;}
private:
std::string *ps;
int i;
};
如果对上面 HasPtr
这个类做如下的操作
HasPtr f(HasPtr hp) // HasPtr 是传值参数,所以将被拷贝
{
HasPtr ret = hp; // 拷贝指定的 HasPtr
return ret; // ret 和 hp 被销毁
}
int main()
{
HasPtr p1("test");
cout << p1.getString() << endl;
HasPtr p2 = f(p1);
cout << p2.getString() << endl;
}
如果使用编译器合成的拷贝构造函数,前面提过 对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝,也就是说指针类型只是简单拷贝指针本身,这意味着多个 HasPtr
对象可能指向相同的内存:
以上面的例子为例,hp
和 ret
因为都是调用编译器合成的拷贝构造函数,所以他们的成员变量 ps
是相同的指针值,都指向同一块地址,也就是现在是2个指针指向同一个内存了
当函数 f
返回时,两个对象上都会调用 HasPtr
的析构函数, 此析构函数会 delete
ret
和 hp
中的指针对象,此代码会导致此指针被 delete
两次,这个很明显是错误的
为了避免这种问题,所以这个时候需要定义拷贝构造函数,当然也需要定义拷贝赋值运算符
HasPtr(const HasPtr &hp) {
ps = new string(*hp.ps);
i = hp.i;
}
HasPtr& operator=(const HasPtr &rhs) {
auto newps = new string(*rhs.ps); // 拷贝指针指向的对象
delete ps; // 销毁原 string
ps = newps; // 指向新 string
i = rhs.i; // 使用内置的 int 赋值
return *this; // 返回一个次对象的引用
}
查看网上的资料,都习惯将上面这种拷贝存在的差异性,分别称为 浅拷贝
和 深拷贝
, 可以看一下知乎关于这个的讨论
https://www.zhihu.com/question/36370072
但是我觉得 C++
其实可以不用关心这个概念,其实是构造函数阶段是否存在分配动态内存,当存在分配动态内存比如指针的时候,使用编译器合成的默认拷贝构造函数得到的新对象,无法保证和原对象完全独立,互无联系,有指针悬挂等等风险,所以这时候需要自己定义默认拷贝构造函数,根据三五法则,大部分情况下,需要一个,也需要实现剩余的几个
这篇文章就先到这里,关于拷贝控制的移动构造函数、移动赋值运算符放到下一篇了,有点长了