C++之旅(学习笔记)第6章 基本操作
6.1 基本操作
class X{
public:
X(Sometype); // "普通的构造函数": 创建一个对象
X(); // 默认构造函数
X(const X&); // 拷贝构造函数
X(X&&); // 移动构造函数
X& operator=(const X&); // 拷贝赋值操作符:清空目标对象并拷贝
X& operator=(X&&); // 移动赋值操作符:清空目标对象并移动
~X(); // 析构函数:清理资源
//...
};
在下面5种情况下,对象会被移动或拷贝:
- 赋值给其他对象。
- 作为对象初始化。
- 作为函数的实参。
- 作为函数的返回值。
- 作为异常。
如果希望显示地使用函数的默认实现:在函数后面加上 =default
class Y{
public:
Y(Sometype);
Y(const Y&) = default; // 我确实需要默认的拷贝构造函数
Y(Y&&) = default; // 也确实需要默认的移动构造函数
};
- 一旦显示地指定了某些函数地默认形式,编译器就不会再为函数生成其他默认定义了。
- 当类中含有指针成员时,最好显示地指定拷贝操作和移动操作。如果不这样做,当编译器生成地默认函数试图delete指针对象时,系统将发生错误。
如果不想生成目标操作函数:在函数后面加上=delete
class Shape {
public:
Shape(const Shape&) =delete; // 禁止拷贝
Shape& operator=(const Shape&) =delete;
//...
};
void copy(Shape& s1, const Shape& s2)
{
s1 = s2; // 错误:Shape禁止拷贝
}
- 试图使用=delete的函数会在编译时报错;=delete可以用于禁用任意函数,并非仅仅用于禁用基础成员函数。
6.1.2 转换
接受单个参数的构造函数同时定义了从参数类型到类类型的转换。
例如:complex提供了一个接受double类型的参数的构造函数:
class complex {
double re,im; // 成员变量:两个双精度浮点数
public:
complex(double r,double i):re{r},im{i}{} // 用两个标量构建该复数
complex(double r):re{r},im{0}{} // 用一个标量构建该复数
complex():re{0},im{0}{} // 默认的复数是:{0,0}
//...
};
complex z1 = 3.14; // z1变成 {3.14,0.0}
complex z2 = z1*2; // z2变成 z1*{2.0,0} == {6.28,0.0}
- 这种转换有时似乎合情理,有时则不然。
例如:Vector提供了一个接受int类型的参数构造函数:
class Vector{
private:
double* elem;
int sz;
public:
Vector(int s):elem{new double[s]},sz{s}
{
for(int i = 0; i != s; ++i)
elem[i] = 0;
}
~Vector(){delete[] elem;}
// ...
};
Vector v1 = 7; // 可行:v1有7个元素
- 通常情况下,该语句的执行结果并非如我们的预期,标准库vector禁止这种int到vector的转换。
解决该问题的办法是只允许显示进行类型转换:
class Vector{
public:
explicit Vector(int s); // 不能隐式地将int转化为Vector
// ...
};
Vector v1(7); // 可行:v1有7个元素
Vector v2 = 7; // 错误:不能隐式地将int转化为Vector
- 关于类型转换地问题,complex只是一小部分,大多数类型地情况与Vector类似。
所以除非你有充分地理由,否则最好把接受单个参数的构造函数声明成explicit的。
6.1.3 成员初始值设定项
定义类的数据成员时,可以提供默认的初始值,称其为默认成员初始值设定项。
如下:修订版本的complex
class complex {
double re = 0;
double im = 0; // 表示两个默认值为0.0的double类型的成员
public:
complex(double r, double i) : re{r}, im{i} {} // 从两个标量{r,i}构造complex
complex(double r) : re{r} {} // 从一个标量{r,0}构造complex
complex() {} // 默认值为{0,0}的complex
// ...
};
对于所有构造函数没有提供初始值的成员,默认初始值都会起作用。
6.2 拷贝和移动
默认情况下,我们可以拷贝对象,不论是用户自定义类型的对象还是内置类型的对象。
拷贝的默认含义是逐成员地复制,即依次复制每个成员。
6.2.1 拷贝容器
当一个类被作为资源句柄时,换句话说,当这个类负责通过指针访问一个对象时,采用默认的逐成员复制方式通常意味着会产生灾难性的错误。
逐成员复制的方式会违反资源句柄的约束条件。
例如:默认拷贝将产生Vector的一份拷贝,而这个拷贝所指向的元素与原来的元素是同一个:
void bad_copy(Vector v1)
{
Vector v2 = v1; // 将v1的表层拷贝到v2
v1[0] = 2; // v2[0]也变成了2
v2[1] = 3; // v1[1]也变成了3
}
假设v1包含4个元素,则结果如下图所示:
类对象的拷贝操作可以通过两个成员来定义:拷贝函数与拷贝赋值操作符:
class Vector {
public:
Vector(int s);
~Vector() { delete[] elem; }
Vector(const Vector& a); // 拷贝构造函数
Vector& operator=(const Vector& a); // 拷贝赋值操作符
double& operator[](int i);
const double& operator[](int i) const;
int size() const;
private:
double* elem;
int sz;
};
对Vector来说,拷贝构造函数的正确定义应该首先为指定数量的元素分配空间,然后把元素复制到空间中。这样复制完成后,每个Vector就拥有自己的元素拷贝了:
Vector::Vector(const Vector& a) : elem {new double[a.sz]}, sz{a.sz} //拷贝构造函数,分配元素所需要的空间
{
for(int i = 0; i != sz; ++i)
elem[i] = a.elem[i];
}
在这个示例中,v2=v1的结果现在可以表示成:
除了拷贝构造函数,我们还需要一个拷贝赋值操作符:
Vector& Vector::operator=(const Vector& a) // 拷贝赋值操作
{
double* p = new double[a.sz];
for(int i = 0; i != a.sz; ++i)
p[i] = a.elem[i];
delete[] elem; // 删除旧元素
elem = p;
sz = a.sz;
return *this;
}
其中,名字this被预定义在成员函数中,它指向调用该成员函数的那个对象。
元素拷贝发生在旧元素被删除之前,所以如果在拷贝的过程中抛出异常,Vector的旧值可得以保留。
6.2.2 移动容器
对于大容量的容器,拷贝过程有可能消耗巨大。
当给函数传递对象时,可通过使用引用类型来减少拷贝对象的代价,但是无法返回局部对象的引用(函数的调用者都没机会和返回结果碰面,局部对象就被销毁了)。
我们相比于拷贝一个Vector对象,更希望移动它。
class Vector {
// ...
Vector(const Vector& a); // 拷贝构造函数(复制构造)
Vector& operator=(const Vector& a); // 拷贝赋值操作符(复制赋值)
Vector(Vector&& a); // 移动构造函数
Vector& operator=(Vector&& a); // 移动赋值操作符
};
基于上述定义,编译器将选择移动构造函数来执行从函数中移出返回值的任务。
定义Vector移动构造函数的过程非常简单:
Vector::Vector(Vector&& a) : elem {a.elem}, sz{a.sz}
{
a.elem = nullptr; //现在a中没有任何元素
a.sz = 0;
}
符号&&的意思是”右值引用“,右值的含义与左值正好相反。
左值的大致含义是 ”能出现在赋值操作符左侧的内容“,因此右值大致上就是无法为其赋值的值,比如函数调用返回的一个整数就是右值。进一步地,右值引用地含义就是引用了一个别人无法赋值地内容,所以我们可以安全地”窃取“它的值。
移动构造函数不接受const实参:毕竟移动构造函数最终要删除它实参中的值。
6.3 资源管理
通过定义构造函数、拷贝操作、移动操作和析构函数,程序员就能对受控资源(比如容器中元素)的生命周期进行完全控制。
内存也不是唯一的一种资源。资源是指任何在使用前需要获取与(显示或隐式)释放的东西,除了内存,还有锁、套接字、文件句柄和线程句柄等非内存资源。
在C++标准库中,RAII无处不在:例如,内存(string、vector、map、unordered_map等)、文件(ifstream、ofstream等)、线程(thread)、锁(lock_guard、unique_lock等)和通用对象(通过unique_ptr和shared_ptr访问)。
6.4 建议
-
尽量让对象的构造、拷贝(复制)、移动和销毁、在掌控之中;
-
同时定义所有的基本操作,或者什么都不定义;
-
如果默认的构造函数、赋值操作符和析构函数符合要求、那么让编译器负责生成它们;
-
如果类含有指针成员,考虑这个类是否需要用户自定义或者删除析构函数、拷贝函数及移动函数;
-
默认情况下,把单参数的构造函数声明成explicit的;
-
如果默认拷贝函数不适合当前类型,则重新定义或禁止拷贝函数;
-
用传值的方式返回容器(依赖拷贝消除和移动以提高效率);
-
避免显示使用std::copy();
-
对于容量较大的操作数,使用const引用作为参数类型;
-
使用RAII管理所有资源——内存和非内存资源;
-
如果默认的构造函数、赋值操作符和析构函数符合要求、那么让编译器负责生成它们;
-
如果类含有指针成员,考虑这个类是否需要用户自定义或者删除析构函数、拷贝函数及移动函数;
-
默认情况下,把单参数的构造函数声明成explicit的;
-
如果默认拷贝函数不适合当前类型,则重新定义或禁止拷贝函数;
-
用传值的方式返回容器(依赖拷贝消除和移动以提高效率);
-
避免显示使用std::copy();
-
对于容量较大的操作数,使用const引用作为参数类型;
-
使用RAII管理所有资源——内存和非内存资源;