第6章 基本操作
第6章 基本操作
简介
- 基本操作
- 转换
- 成员初始化器
拷贝与移动
- 容器拷贝
- 容器移动
资源管理
运算符重载
常规操作
- 比较
- 容器操作
- 迭代器与“智能指针”
- 输入与输出
- 运算符
- swap()
- hash<>
用户定义字面量
建议
6.1 简介
一些操作,如初始化、赋值、拷贝和移动,是基础性的,因为语言规则对它们有所假设。其他操作,如 == 和 << ,具有惯例含义,忽略这些含义是危险的。
6.1.1 基本操作
一个类型的构造函数、析构函数、拷贝和移动操作在逻辑上不是独立的。我们必须将它们作为匹配的一组来定义,否则会遇到逻辑或性能问题。如果一个类 X 有一个执行非平凡任务的析构函数,比如释放自由存储空间或解锁,那么这个类很可能需要完整的一套函数:
class X {
public:
X(Sometype); // “普通构造函数”:创建一个对象
X(); // 默认构造函数
X(const X&); // 拷贝构造函数
X(X&&); // 移动构造函数(右值引用)
X& operator=(const X&); // 拷贝赋值:清理目标并拷贝
X& operator=(X&&); // 移动赋值:清理目标并移动
~X(); // 析构函数:清理
// ...
};
有五种情况,一个对象可能会被拷贝或移动:
- 作为赋值语句的源
- 作为对象初始化器
- 作为函数参数
- 作为函数返回值
- 作为异常抛出
赋值使用拷贝或移动赋值运算符。原则上,其他情况使用拷贝或移动构造函数。然而,拷贝或移动构造函数的调用通常会被编译器优化掉,通过直接在目标对象中构造用于初始化的对象。例如:
X make(Sometype);
X x = make(value);
这里,编译器通常会直接在 x 中从 make() 构造 X ,从而消除(“省略”)一次拷贝。
除了命名对象和自由存储区对象的初始化之外,构造函数还用于初始化临时对象和实现显式类型转换。
除了“普通构造函数”外,这些特殊成员函数会在需要时由编译器自动生成。如果你想明确表示生成默认实现,可以这样做:
class Y {
public:
Y(Sometype);
Y(const Y&) = default;// 我确实想要默认的拷贝构造函数
Y(Y&&) = default;// 和默认的移动构造函数
// ...
};
如果你对某些默认值明确指定,其他默认定义将不会被生成。
当一个类包含指针成员时,明确指定拷贝和移动操作通常是个好主意。原因是该指针可能指向需要 delete 的类,在这种情况下,默认的成员拷贝将是错误的。或者,它可能指向不需要 delete 的类。在这两种情况下,代码的读者都希望了解这一点。有关示例,请参阅§6.2.1。
一个很好的经验法则(有时称为 零规则 )是要么定义所有基本操作,要么都不定义(对所有使用默认)。例如:
struct Z {
Vector v;
string s;
};
Z z1;// 使用默认值初始化z1.v和z1.s
Z z2 = z1;// 使用默认拷贝z1.v和z1.s
在这里,编译器将根据需要生成成员的默认构造、拷贝、移动和析构构造函数,并且都具有正确的语义。
为了补充 =default ,我们有 =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 (§5.2.1)提供了一个从 double 类型转换的构造函数:
complex z1 = 3.14; // z1 被初始化为 {3.14, 0.0}
complex z2 = z1 * 2; // z2 被计算为 z1 * {2.0, 0} == {6.28, 0.0}
这种隐式转换有时非常理想,但并不总是如此。举个例子,假设有一个 Vector (§5.2.2),它提供了一个从 int 类型转换的构造函数:
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的隐式转换
在处理类型转换时,更多的类型类似于 Vector ,而不是像 complex 那样允许宽松的隐式转换,因此除非有充分的理由,否则对于只接受一个参数的构造函数,应该使用 explicit 关键字来避免不必要的隐式转换。
6.1.3 成员初始化器
当定义类的数据成员时,我们可以提供一个默认初始化器,称为 默认成员初始化器 。考虑对 complex (§5.2.1)的一个修订版:
class complex {
double re = 0; // 默认值0.0
double im = 0; // 默认值0.0
public:
complex(double r, double i) : re{r}, im{i} {} // 从两个标量构造complex:{r,i}
complex(double r) : re{r} {} // 从一个标量构造complex:{r,0}
complex() {} // 默认构造complex:{0,0}
// ...
};
默认值在构造函数没有为成员提供特定值时使用。这样做简化了代码,并帮助我们避免了不小心遗漏成员初始化的情况。通过在成员声明时指定默认值,可以确保即使在没有显式初始化的情况下,数据成员也能获得一个合理的初始状态。
6.2 拷贝与移动
默认情况下,对象是可以被拷贝的。这对于用户自定义类型以及内置类型都是成立的。拷贝的默认含义是逐成员拷贝:复制每个数据成员。例如,使用来自§5.2.1的 complex :
void test(complex z1) {
complex z2 {z1}; // 拷贝初始化
complex z3;
z3 = z2; // 拷贝赋值
// ...
}
现在, z1 、 z2 和 z3 具有相同的值,因为无论是赋值操作还是初始化操作,都拷贝了两个成员。
在设计一个类时,我们必须始终考虑对象可能如何被拷贝,以及是否应当支持拷贝。对于简单的具体类型来说,逐成员拷贝往往是拷贝操作的正确语义。但对于一些复杂的具体类型,比如 Vector ,逐成员拷贝可能就不是拷贝操作的正确语义了;而对于抽象类型来说,几乎从来都不是正确的拷贝方式。
6.2.1 容器拷贝
当一个类是 资源句柄 (resource handle)——也就是说,这个类负责通过指针访问对象——默认的成员拷贝通常是灾难性的。逐成员拷贝会违反资源句柄的不变性(§4.3)。例如,对于默认拷贝, Vector 的一个副本会引用与原 Vector 相同的元素:
void bad_copy(Vector v1) {
Vector v2 = v1; // 将v1的内部表示拷贝给v2
v1[0] = 2; // 导致v2[0]也变为2!
v2[1] = 3; // 同时v1[1]也变为3!
}
假设v1有四个元素,这个过程可以用图形表示如下:
幸运的是, Vector 类拥有析构函数这一事实强烈暗示了默认的(逐成员)拷贝语义是错误的,编译器至少应对这样的例子发出警告。我们需要定义更合适的拷贝语义。
一个类对象的拷贝行为由两个成员函数定义: 拷贝构造函数 和 拷贝赋值运算符 。
对于 Vector 类,可以这样定义这两个成员:
class Vector {
public:
Vector(int s); // 构造函数:建立不变量,获取资源
~Vector() { delete[] elem; } // 析构函数:释放资源
Vector(const Vector& a); // 拷贝构造函数
Vector& operator=(const Vector& a); // 拷贝赋值运算符
double& operator[](int i); // 下标访问运算符,非const版本
const double& operator[](int i) const; // 下标访问运算符,const版本
int size() const; // 返回元素数量,const成员函数
private:
double* elem; // elem 指向一个大小为 sz 的double数组
int sz;
};
一个适合 Vector 类的拷贝构造函数定义应当包括为所需数量的元素分配空间,然后逐个拷贝源 Vector 的元素,确保新 Vector 拥有独立的内存空间,代码示例如下:
Vector::Vector(const Vector& a) : sz{a.sz} {
elem = new double[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 operator+(const Vector& a, const Vector& b) {
if (a.size() != b.size())
throw Vector_size_mismatch{};
Vector res(a.size());
for (int i = 0; i != a.size(); ++i)
res[i] = a[i] + b[i];
return res;
}
在执行加法运算 + 时,涉及到将局部变量 res 的结果拷贝出来,以便调用者能够访问。我们可能这样使用这个 + 运算符:
void f(const Vector& x, const Vector& y, const Vector& z) {
Vector r;
// ...
r = x + y + z;
// ...
}
如果 Vector 很大,比如包含10,000个 double ,这种多次拷贝将非常低效。更尴尬的是, operator+() 中的 res 在拷贝后便不再使用。实际上,我们并不是真的需要拷贝,而是想直接将结果从函数中“移”出来,即进行移动而非拷贝。幸运的是,这方法可行:
class Vector {
// ...
Vector(const Vector& a); // 拷贝构造函数
Vector& operator=(const Vector& a); // 拷贝赋值
Vector(Vector&& a); // 移动构造函数
Vector& operator=(Vector&& a); // 移动赋值
// ...
};
有了这样的定义,编译器会选择移动构造函数来实现在函数返回时将返回值移出。这意味着 r = x + y + z 将不再涉及向量的拷贝,而是直接进行移动。
移动构造函数的定义通常很简单:
Vector::Vector(Vector&& a)
: elem{a.elem}, sz{a.sz} {// 从a中“夺取”元素
a.elem = nullptr;// 现在a没有元素了
a.sz = 0;
}
这里的 && 表示“右值引用”,它可以绑定到右值。右值大致意味着“不能出现在赋值语句左侧的东西”。因此,右值大致上可以理解为不能被赋值的值,比如函数调用返回的整数。所以,右值引用就是对一个无人能再对其赋值的对象的引用,我们可以安全地“窃取”其值。 Vector 中 operator+() 的局部变量 res 就是一个例子。
移动构造函数不接受 const 参数,因为它需要从其参数中移除值。 移动赋值 运算符的定义也是类似的原理。
移动操作会在使用右值引用作为初始化器或赋值语句的右侧时应用。
移动后,被移动的对象应处于允许运行析构函数的状态。通常,我们还允许向已经移动过的对象进行赋值。标准库算法(第13章)对此有所假设,我们的 Vector 类也遵循这一原则。
当程序员知道某个值不会再被使用,但编译器可能无法足够智能地识别这一点时,程序员可以明确指定:
Vector f() {
Vector x(1000);
Vector y(2000);
Vector z(3000);
z = x; // 进行拷贝(x可能在f()后续被使用)
y = std::move(x); // 进行移动(移动赋值)
// ... 在这里最好不要再使用x ...
return z; // 进行移动
}
标准库函数 std::move() 实际上并不执行移动操作,而是返回其参数的一个 右值引用 ,即允许从中进行移动的引用;它是一种类型的强制转换(§5.2.3)。
在返回之前,我们有:
当我们从函数 f() 返回时, z 的元素被 return 从 f() 移动出去之后就被销毁了。然而, y 的析构函数将会 delete[] 其元素。
根据C++标准,编译器有义务消除与初始化相关的大多数拷贝操作,这意味着移动构造函数的调用频率可能并不像你想象的那么频繁。这种 copy elision 的优化甚至消除了移动操作带来的微小开销。另一方面,通常不可能自动消除赋值操作中的拷贝或移动操作,因此在性能上,移动赋值运算符可能起到关键作用。
6.3 资源管理
通过定义构造函数、拷贝操作、移动操作和析构函数,程序员可以全面控制封装资源(如容器中的元素)的生命周期。此外,移动构造函数允许对象简单且低成本地从一个作用域转移到另一个作用域。这样一来,那些我们不能或不想从一个作用域拷贝出去的对象,就可以简单且高效地通过移动操作转移出去。考虑一个表示并发活动的 thread 和一个包含一百万个 double 的 Vector 。前者我们不能拷贝,后者则不希望拷贝。
std::vector<thread> my_threads;
Vector init(int n) {
std::thread t {heartbeat}; // 并发运行heartbeat(在一个单独的线程中)
my_threads.push_back(std::move(t)); // 移动t到my_threads中(§16.6)
// ... 更多初始化 ...
Vector vec(n);
for (auto& x : vec)
x = 666;
return vec; // 将vec从init()中移出去
}
auto v = init(1'000'000); // 启动heartbeat并初始化v
资源句柄,如 Vector 和 thread ,在很多情况下比直接使用内置指针更为优越。实际上,标准库中的“智能指针”,如 unique_ptr ,本身也是资源句柄(§15.2.1)。
我使用标准库的 vector 来保存线程,因为在§ 7.2之前,我们无法为简单的 Vector 参数化元素类型。
正如 new 和 delete 在应用程序代码中逐渐消失一样,我们也可以让指针隐入资源句柄之中。这两种方式都能带来更简洁、更易于维护的代码,且不增加额外开销。特别是,我们可以实现 强大的资源安全性 ( strong resource safety),我们可以针对一般意义上的资源,消除资源泄漏问题。例如持有内存的 vector 、持有系统线程的 thread ,以及持有文件句柄的 fstream 。
在许多语言中,资源管理主要委托给垃圾收集器处理。而在C++中,虽然可以插入垃圾收集器,但我认为,在探索更干净、更通用且定位更好的资源管理替代方案之前,垃圾收集应该是最后的选择。我的理想是不产生任何垃圾,从而消除对垃圾收集器的需求:不要乱扔垃圾!
垃圾收集本质上是一种全局内存管理策略。聪明的实现可以补偿,但随着系统越来越分布式(考虑缓存、多核和集群),局部性变得比以往任何时候都重要。
此外,内存并不是唯一的资源。资源是指任何必须在使用后被获取和(显式或隐式)释放的。例如,内存、锁、套接字、文件句柄和线程句柄。不出所料, 非内存资源 是指除内存以外的任何资源。一个好的资源管理系统应处理各种类型的资源。在长时间运行的系统中,泄漏必须避免,但过度的资源保留几乎和泄漏一样糟糕。例如,如果系统持有内存、锁、文件等资源的时间翻倍,系统可能需要配置两倍多的资源。
在诉诸垃圾收集之前,系统地使用资源句柄:让每种资源在其所在的作用域内有一个所有者,并默认在所有者作用域结束时释放资源。在C++中,这被称为 RAII (Resource Acquisition Is Initialization),并且与异常处理机制集成在一起。资源可以通过移动语义或“智能指针”从一个作用域转移到另一个作用域,而共享所有权可以通过“共享指针”(§15.2.1)来表示。
在C++标准库中,RAII无处不在:例如,内存( string , vector , map , unordered_map 等)、文件( ifstream , ofstream 等)、线程( thread )、锁( lock_guard , unique_lock 等)以及通用对象(通过 unique_ptr 和 shared_ptr )。结果是实现了在常规使用中不可见的隐式资源管理,从而导致资源保留周期较短。
6.4 运算符重载
我们可以为C++的运算符赋予自定义类型的含义(§2.4, §5.2.1)。这被称为 运算符重载 ,因为在使用时,必须从具有相同名称的一组运算符中选择正确的实现。例如,我们的复数加法 z1+z2 (§5.2.1)必须与整数加法和浮点数加法区分开来(§1.4.1)。
我们不能定义新的运算符,例如,我们不能定义像 ˆˆ 、 === 、 ∗∗ 、 $ 或一元 % 这样的运算符。允许这样做会造成与便利同样多的混淆。
强烈建议按照传统语义定义运算符。例如,一个执行减法的 + 运算符对任何人都没有好处。
我们可以为用户自定义类型(类和枚举)定义运算符:
- 二元算术运算符: + , - , ***** ,** / , 和 %**
- 二元逻辑运算符: & (按位与)、 | (按位或)、和 ˆ (按位异或)
- 二元关系运算符: == , != , < , <= , > , >= , 和 <=>
- 逻辑运算符: && 和 ||
- 一元算术和逻辑运算符: + , - , ~ (按位补)、和 ! (逻辑否定)
- 赋值运算符: = , += , * = , 等
- 自增和自减运算符: ++ 和 --
- 指针操作: -> , 一元 ***** , 和 一元 &
- 应用(调用): ()
- 下标运算: []
- 逗号: ,
- 位移运算: >> 和 <<
不幸的是,我们不能定义点运算符 . 来获取智能引用。
运算符可以作为成员函数定义:
class Matrix {
// ...
Matrix& operator=(const Matrix& m);// 将m赋值给*this; 返回*this的引用
};
这通常对运算符的第一个操作数进行修改,并且由于历史原因,对于 = , -> , () , 和 [] 是必需的。
或者,大部分运算符可以作为独立函数定义:
Matrix operator+(const Matrix& m1, const Matrix& m2);// 返回m1和m2的和
通常,对于具有对称操作数的运算符,我们将其定义为独立函数,以便对两个操作数同等对待。为了从潜在的大对象返回中获得良好的性能,如 Matrix ,我们依赖于移动语义(§6.2.2)。
6.5 常规操作
当为某种类型定义某些操作时,它们往往具有约定俗成的含义。程序员和库(尤其是标准库)常常假定这些常规含义,所以在设计对这些操作有意义的新类型时,遵循这些惯例是明智的。
- 比较: == , != , < , <= , > , >= , 和 <=> (§6.5.1)
- 容器操作: size() , begin() , 和 end() (§6.5.2)
- 迭代器和“智能指针”: -> , ***** , [] , ++ , -- , + , - , += , 和 -= (§13.3,§15.2.1)
- 函数对象: () (§7.3.2)
- 输入输出操作: >> 和 << (§6.5.4)
- swap() (§6.5.5)
- 哈希函数: hash<> (§6.5.6)
遵循这些常规操作的约定,可以确保你的类型与C++生态系统中的其他组件兼容良好,提高代码的可读性、可维护性,并降低与其他库或框架集成时的难度。
6.5.1 比较运算(关系运算符)
等价比较运算( == 和 != )的含义与拷贝紧密相关。在执行拷贝之后,拷贝的对象应当相等:
X a = something;
X b = a;
assert(a == b); // 如果a!=b,说明情况非常奇怪(§ 4.5)
在定义 == 的同时,也应该定义 != ,并确保 a!=b 等同于 !(a==b) 。
类似地,如果你定义了 < ,也应该定义 <= 、 > 、 >= ,以确保遵循通常的关系等价:
- a<=b 表示 (a<b)||(a==b) 且 !(b<a) 。
- a>b 表示 b<a 。
- a>=b 表示 (a>b)||(a==b) 且 !(a<b) 。
为了对二元运算符(如 == )的操作数给予相同处理,最佳做法是将其定义为类命名空间中的独立函数。例如:
namespace NX {
class X {
// ...
};
bool operator==(const X&, const X&);
// ...
}
“‘ 三向比较运算符 (spaceship operator)”, <=> ,它遵循自己的规则,与其他运算符的规则不同。特别是,通过定义默认的 <=> ,其他关系运算符将被隐式定义:
class R {
// ...
auto operator<=>(const R& a) const = default;
};
void user(R r1, R r2)
{
bool b1 = (r1<=>r2) == 0; // r1==r2
bool b2 = (r1<=>r2) < 0; // r1<r2
bool b3 = (r1<=>r2) > 0; // r1>r2
}
bool b4 = (r1==r2);
bool b5 = (r1<r2);
使用 <=> 时,可以实现类似于C语言中 strcmp() 的三路比较,负值表示小于,零表示等于,正值表示大于。
如果 <=> 被定义为非默认,则 == 不会被隐式定义,但是 < 和其他关系运算符会被隐式定义!例如:
struct R2 {
int m;
auto operator<=>(const R2& a) const { return a.m == m ? 0 : a.m < m ? -1 : 1; }
};
这里,我使用了 if 语句的表达式形式: p?x:y 是一个表达式,它评估条件 p ,如果为真,则 ?: 表达式的值为 x ,否则为 y 。
void user(R2 r1, R2 r2)
{
bool b4 = (r1==r2);// error: no non-default ==
bool b5 = (r1<r2);}// OK
这导致了非平凡类型定义的这种模式:
struct R3 { /* ... */ };
auto operator<=>(const R3& a, const R3& b) { /* ... */ }
bool operator==(const R3& a, const R3& b) { /* ... */ }
大多数标准库类型,如 string 和 vector ,都遵循这一模式。原因是,如果一个类型有多个参与比较的元素,那么默认的 <=> 会逐一检查它们,产生字典序比较。在这种情况下,通常值得提供一个单独优化过的 == ,因为 <=> 需要检查所有元素以确定三个可能的关系状态。
考虑字符串的比较:
string s1 = "asdfghjkl";
string s2 = "asdfghjk";
bool b1 = s1 == s2; // false
bool b2 = (s1 <=> s2) == 0; // false
使用传统的 == ,我们通过查看字符的数量就能发现字符串不相等。而使用 <=> ,我们必须读取 s2 的所有字符,以确定它小于 s1 ,因此不相等。
关于运算符 <=> 还有更多细节,但这些主要是对涉及比较和排序的高级库设施实现者感兴趣的,超出了本书的范围。旧代码不使用 <=> 。
6.5.2 容器操作
除非有非常好的理由不这么做,否则请按照标准库容器(第12章)的风格设计容器。特别是,通过实现它作为一个带有适当基本操作的句柄(§6.1.1,§6.2)来确保容器的资源安全性。
标准库中的容器都知道它们的元素数量,我们可以通过调用 size() 来获取。例如:
for (size_t i = 0; i != c.size(); ++i)// size_t 是标准库size()返回的类型名称
c[i] = 0;
然而,与使用从 0 到 size() 的索引来遍历容器相比,标准算法(第13章)依赖于由一对 迭代器 界定的 序列 (sequences)概念:
for (auto p = c.begin(); p != c.end(); ++p)
*p = 0;
在这里, c.begin() 是一个指向容器 c 第一个元素的迭代器,而 c.end() 指向 c 最后一个元素之后的位置。像指针一样,迭代器支持 ++ 来移动到下一个元素,以及 ***** 来访问指针所指向元素的值。
这些 begin() 和 end() 函数也被 for 循环语法的实现所使用,因此我们可以简化对序列的循环:
for (auto& x : c)
x = 0;
迭代器用于将序列传递给标准库算法。例如:
sort(v.begin(), v.end());
这种 迭代器模型 (§13.3)允许极大的通用性和效率。详细信息和其他容器操作,请参阅第12章和第13章。
begin() 和 end() 也可以定义为独立的函数;见§ 7.2。对于常量容器的版本,分别称为 cbegin() 和 cend() 。
6.5.3 迭代器和“智能指针”
用户定义迭代器(§13.3)和“智能指针”(§15.2.1)实现了针对其目的所需的指针操作,并经常根据需要添加语义。
- 访问:* **** ,** -> (对于类),和 []** (对于容器)
- 迭代/导航: ++ (向前), -- (向后), += , -= , + ,和 -
- 拷贝和/或移动: =
6.5.4 输入和输出操作
对于整数对, << 表示左移, >> 表示右移。然而,在 iostream 中,它们分别是输出和输入运算符(§1.8,第11章)。详细信息和其他I/O操作,请参阅第11章。
6.5.5 swap()
许多算法,特别是 sort() ,使用一个交换两个对象值的 swap() 函数。这类算法通常假设 swap() 非常快且不抛出异常。标准库提供了一个 swap(a, b) ,其实现基于三次移动操作(§16.6)。如果你设计了一种类型,它的拷贝成本高,且有可能被交换(例如,通过排序函数),那么请提供移动操作或 swap() ,或两者都提供。请注意,标准库容器(第12章)和 string (§10.2.1)都具有快速的移动操作。
6.5.6 hash<>
标准库中的 unordered_map<K, V> 是一个哈希表,其中 K 是键类型, V 是值类型(§12.6)。要使用类型 X 作为键,我们必须定义 hash<X> 。对于常见的类型,如 string ,标准库已经为我们定义了 hash<> 。
6.6 用户自定义字面量
类的一个目的就是使程序员能够设计并实现与内置类型紧密相似的自定义类型。构造函数提供了等同或超过内置类型初始化的灵活性和效率,但对于内置类型,我们有字面量:
- 123 是一个 int 。
- 0xFF00u 是一个 unsigned int 。
- 123.456 是一个 double 。
- "Surprise!" 是一个 const char[10] 。
为用户自定义类型也提供这样的字面量是很有用的。这是通过定义字面量后缀的含义来实现的,因此我们可以得到:
- "Surprise!"s 是一个 string 。
- 123s 表示秒。
- 12.7i 表示虚数,所以 12.7i+47 是一个 complex (即, {47,12.7} )。
特别是,我们可以通过使用合适的头文件和命名空间从标准库中获得这些示例:
<chrono> | std::literals::chrono_literals | h, min, s, ms, us, ns |
<string> | std::literals::string_literals | s |
<string_view> | std::literals::string_literals | sv |
<complex> | std::literals::complex_literals | i, il, if |
带用户定义后缀的字面量称为 用户自定义字面量 (User-Defined Literals,简称UDLs)。这些字面量通过 字面运算符 来定义。字面运算符将一个跟随特定后缀的参数类型的字面量转换为其返回类型。例如,虚部后缀 i 可能像这样实现:
constexpr complex<double> operator""i(long double arg) {// 虚数字面量
return {0, arg};
}
这里,
- operator"" 指示我们正在定义一个字面运算符。
- 字面指示符 "" 后的 i 是该运算符为之赋予意义的后缀。
- 参数类型 long double 表示后缀( i )被定义用于浮点数字面量。
- 返回类型 complex<double> 指定了生成字面量的类型。
有了这个定义,我们就可以写出如下代码:
complex<double> z = 2.7182818 + 6.283185i;
i 后缀的实现和 + 运算符都是 constexpr ,所以 z 值的计算在编译时完成。
6.7 建议
控制对象的构造、拷贝、移动及销毁;§6.1.1;[CG: R.1]。
设计构造函数、赋值操作和析构函数作为一组匹配的操作;§6.1.1;[CG: C.22]。
定义所有关键操作或不定义任何操作;§6.1.1;[CG: C.21]。
如果默认构造函数、赋值操作或析构函数适用,让编译器自动生成它;§6.1.1;[CG: C.20]。
如果一个类有指针成员,考虑是否需要用户自定义或删除的析构函数、拷贝和移动操作;§6.1.1;[CG: C.32] [CG: C.33]。
如果一个类有用户定义的析构函数,它可能也需要用户定义或删除拷贝和移动操作;§6.2.1。
默认情况下,声明单参数构造函数为 explicit ;§6.1.2;[CG: C.46]。
如果类成员有一个合理的默认值,作为数据成员初始化器提供;§6.1.3;[CG: C.48]。
如果默认行为不适合某个类型,重新定义或禁止拷贝;§6.1.1;[CG: C.61]。
以值返回容器(依赖于拷贝省略和移动以保证效率);§6.2.2;[CG: F.20]。
避免明确使用 std::copy() ;§16.6;[CG: ES.56]。
对于大型操作数,使用 const 引用参数类型;§6.2.2;[CG: F.16]。
提供强资源安全性;即,从不泄露你视为资源的任何东西;§6.3;[CG: R.1]。
如果一个类是资源句柄,它需要用户定义的构造函数、析构函数以及非默认的拷贝操作;§6.3;[CG: R.1]。
使用RAII管理所有资源——内存及非内存资源;§6.3;[CG: R.1]。
重载操作符以模拟常规用法;§6.5;[CG: C.160]。
如果你重载了一个操作符,定义所有常规上协同工作的操作;§6.1.1,§6.5。
如果你为一个类型定义了非默认的 <=> ,也要定义 == ;§6.5.1。
遵循标准库容器设计模式;§6.5.2;[CG: C.100]。