❶
对象的拷贝和转移有五种情形:
- 赋值给另一个对象;
- 为一个对象提供初值;
- 作为函数参数;
- 作为函数的返回值;
- 作为一个异常。
赋值操作采用拷贝或者转移运算符。原则上,其它情形均使用拷贝或者构造函数。
不过,拷贝或转移构造函数的调用通常会被优化掉,方法是把初始值直接在该对象上进行构造。例如:
X make(SomeType);
X x = make(value);
此处,编译器通常把make()返回的X在x上构造;从而消除(“省略”)掉一次拷贝。
除了初始化具名对象和分配在自由存储区上的对象之外,构造函数也被用于构造临时对象,还被用于实现显式类型转换。
除了“常规构造函数”,以下这些成员函数也会在按需被编译器生成。 如果想显式操控默认(=default
)实现的生成,可以这样:
class Y {
public:
Y(Sometype);
Y(const Y&) = default; // 我确定想要默认的拷贝构造函数
Y(Y&&) = default; // 以及默认的转移构造函数
// ...
};
注:如果你显式生成了一部分默认实现,那其它的缺省定义就不会再生成了。
如果某个类具有指针成员变量,那么【显式定义拷贝和转移】操作通常是比较明智的。原因是指针可能指向某个资源,需要类去delete,这种情况下,默认将成员采用的逐个复制操作会导致错误。 又或者,这个指针指向的资源,要求类绝对不能delete。无论属于哪种情况,代码的读者都需要弄清楚。
一个值得力荐的规则(有时候也叫零规则(the rule of zero))是: 要么定义全部基础操作,要么全不定义(全用默认实现)。例如:
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函数的使用触发编译器报错;=delete
可用于屏蔽任何函数,不仅仅是基本成员函数。
❷
**仅接受单个参数的构造函数定义了源自参数类型的转换。
**例如,complex有个源自double的构造函数:
complex z1 = 3.14; // z1 成为 {3.14,0.0}
complex z2 = z1*2; // z2 成为 z1*{2.0,0} == {6.28,0.0}
有时候这种隐式转换很理想,但也不总那么理想。 例如,Vector有一个源自int的构造函数:
Vector v1 = 7; // OK:v1有7个元素
这通常都不是个隐患,标准库的vector
就禁止int到vector的“类型转换”。
避免这一问题的途径是仅允许显式(explicit
)“类型转换”;就是说这样定义构造函数:
class Vector {
public:
explicit Vector(int s); // 不会隐式从 int 转换到 Vector
// ...
};
Vector v1(7); // OK: v1 有 7 个元素
Vector v2 = 7; // 报错:不能从int隐式转换到Vector
涉及类型转换到时候,更多的类型像Vector,而不像complex, 因此,应该把explicit
用于单参数的构造函数,除非有好理由不用它。
❸
当某个类定义了数据成员,我们应该为其提供默认初始值,这被称为成员变量默认初始值。
考虑一下complex的这个新版本:
class complex {
double re = 0;
double im = 0; // 表征数据:两个默认值为 0.0 的 double
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}
// ...
}
只要构造函数未给定一个值,就会应用默认值。 这可以简化编码,并有助于避免粗心大意导致的成员变量未初始化。
❹
当某个类是一个资源执柄(resource handle)——就是说,当该类负责通过指针访问某个对象——默认的逐成员复制通常是个灾难。逐成员复制会违反资源执柄的不变式。例如,默认复制会导致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具有析构函数的事实强烈暗示了“默认(逐成员)的复制语意不对”, 而编译器起码应该对本例给出警告。 我们需要定义一个更优的复制语意。
某个类对象的复制由两个成员函数定义:拷贝构造函数(copy constructor)和拷贝赋值函数(copy assignment):
class Vector {
private:
double* elem; // elem指向一个数组,该数组承载sz个double
int sz;
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;
};
一个合格的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示例的结果可以这样表示:
在成员函数里,名称this
是预定义的,它指向调用该成员函数的那个对象。
❺
我们可以通过定义拷贝构造函数和拷贝赋值函数来控制复制操作,但对于庞大的容器,复制操作代价高昂。在传递一个对象给函数时,可以采用引用,从而避免复制的代价,但却不能返回一个指向局部对象的引用作为结果(在调用者查看的时候,局部变量就已经被销毁了),考虑这个:
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两次(在每次调用+时候)。 如果某个Vector很大,比方说有 10,000 个 double,可就太令人汗颜了。最汗颜的是operator+()里的res复制后再没用过。我们不是真的想复制;而是想把结果从函数里弄出来:想要转移(move)一个Vector,而非复制(copy)它。万幸的是,我们可以表明这个意图:
class Vector {
// ...
Vector(const Vector& a); // 拷贝构造函数
Vector& operator=(const Vector& a); // 拷贝赋值函数
Vector(Vector&& a); // 转移构造函数
Vector& operator=(Vector&& a); // 转移赋值函数
};
根据这个定义,编译器在把返回值传出函数时, 将使用转移构造函数(move constructor)执行。这意味着,r=x+y+z将不涉及Vector的复制。取而代之的是,Vector仅被转移了。
与之相似,Vector转移构造函数的定义也是小菜一碟:
Vector::Vector(Vector&& a)
:elem{a.elem}, sz{a.sz} // 从a“拿来元素”
{
a.elem = nullptr; // a现在没有元素了
a.sz = 0;
}
&&
的意思是“右值引用(rvalue reference)”,是个可以绑定到右值的引用。“右值(rvalue)”这词有意跟“左值(lvalue)”相对,“左值”大体上是“可以出现在赋值左侧的东西”。因此右值——大概其——是不能对其赋值的东西,比如函数调用返回的某个整数。这样,右值引用就是一个没有其他人能为其赋值的东西,所以可以安全地“偷取”其值。
转移构造函数不接受const
参数:毕竟,它本该从其参数中把值移除。转移赋值函数(move assignment)的定义相似。
每当右值引用作为初值,或作为赋值右侧的值,就应用转移操作。
转移操作之后,“移自”对象所处的状态应允许执行析构函数。一般来说,也允许向一个“移自”对象赋值。标准库算法假定如此。我们的Vector也是这样。
程序员知道某个值再也不会用到了,但编译器不见得这么聪明,程序员可以指明:
Vector f() {
Vector x(1000);
Vector y(2000);
Vector z(3000);
z = x; // 执行复制(x可能在f()后续被用到)
y = std::move(x); // 执行转移(转移复制函数)
// ... 最好别在这用x了 ...
return z; // 执行转移!因为已定义了Vector的转移构造函数
}
标准库函数move()并不真移动什么。而是返回其(我们想转移的)参数的引用——右值引用;实际上进行了类型转换。
在return执行之前,情况是:
当我们从f()返回后,z就在其元素被return移出f()后而销毁了。不过y的析构函数则将delete[]其元素。
// move example
#include <utility> // std::move
#include <iostream> // std::cout
#include <vector> // std::vector
#include <string> // std::string
int main () {
std::string foo = "foo-string";
std::string bar = "bar-string";
std::vector<std::string> myvector;
myvector.push_back (foo); // copies
myvector.push_back (std::move(bar)); // moves
std::cout << "myvector contains:";
for (std::string& x:myvector)
std::cout << ' ' << x;
return 0;
}
#Output
myvector contains: foo-string bar-string
第一次调用myvector.push_back
将foo
的值复制到vector
中(foo
在调用之前保持原有的值)。第二次调用将bar
的值移动到向量中。这将其内容转移至vector
中(同时bar
失去了它的值,现在处于有效但未指定的状态)。
编译器(由C++标准规定)对消除大多数跟初始化相关的复制操作负有义务,因此转移构造函数的调用并不如你想的那样频繁。这种拷贝消除(copy elision)甚至消除了转移操作中极微小的性能损失。 另一方面,隐式消除赋值操作中的复制和转移操作,几乎是不可能的,因此,转移赋值对性能影响巨大。
❻
定义构造函数、拷贝操作、转移操作和析构函数后,程序员能完全控制所持资源(比如容器的元素)的生命期。此外,转移构造函数可以将对象从一个作用域移到另一个,轻而易举且代价低廉。这样,对于无法或者不该通过复制方式取出作用域的对象,就能轻易低廉地转移出来。
考量标准库的thread
,它相当于一个并发行为,以及承载上百万个double的Vector。前者无法复制,后者不该复制。
std::vector<thread> my_threads;
Vector init(int n) {
thread t {heartbeat}; // 并发运行心跳(在别的线程里)
my_threads.push_back(std::move(t)); // 把 t 转移到 my_threads
// ... 其它初始化操作 ...
Vector vec(n);
for (int i=0; i!=vec.size(); ++i)
vec[i] = 777;
return vec; // 把 vec 转移出 init()
}
auto v = init(1'000'000); // 开始心跳并初始化 v
类似于Vector和thread这种资源执柄,在任何情况下,都是内置指针的优秀替代品。事实上,诸如unique_ptr这种标准库的“智能指针(smart pointer)”, 本身就是资源执柄。
就像让new和delete在应用代码中消失那样,我们可以让指针匿踪在资源执柄身后。这两种情况的结果都是更简洁、更易维护的代码,而且不增加额外负担。确切地说,可以达成强资源安全(strong resource safety);就是说,对于常规意义上的资源来说,可以消灭资源泄漏的情况。这种例子有:vector持有内存、thread持有系统线程,以及fstream持有文件执柄。
在很多语言中,资源管理主要是委派给某个资源回收器。C++也有个垃圾回收接口,以便你接入一个资源回收器。但是我认为垃圾回收器是个无奈之选,在更整洁、更通用也更接地气的资源管理器替代方案无能为力之后,才会用它。我的观点是不要制造垃圾,这样就化解了对垃圾回收器的需求:禁止乱丢垃圾!
垃圾回收大体是个全局内存管理机制。精巧的实现可以值回性能开销,但计算机系统越来越趋向于分布式(想想缓存、多核心,以及集群),局部性比以往更重要了。
还有,内存并非仅有的资源。资源是任何必须使用前(显式或隐式)申请,使用后释放的东西。 例子有内存、锁、socket、文件执柄以及线程执柄。如你所料,内存以外的资源被称为非内存资源(non-memory resource)。优秀的资源管理系统能处理所有类型的资源。在任何长时间运行的系统里,泄漏必须避免,但资源的过度占用跟泄漏一样糟糕。例如:如果一个系统把内存、锁、文件等,都持有双倍时长,那它就需要双倍的资源供给。
在采用垃圾回收器之前,请先系统化地使用资源执柄:让每个资源都有个位于某个作用域内的有所有者,并且所有者在作用域结束处释放该资源。
在C++里,这叫 RAII(资源请求即初始化 Resource Acquisition Is Initialization),它已经跟错误处理机制中的异常整合在一起。资源可以通过转移的语意或者“智能指针”,从一个作用域移到另一个作用域,还可以通过“共享指针(shared pointer)”表示共享的所有权。
在C++标准库里,RAII无处不在:例如内存(string
、vector
、map
、unordered_map
等),文件(ifstream
、ofstream
等),线程(thread
), 锁(lock_guard
,unique_lock
等), 以及常规对象(通过unique_ptr
和shared_ptr
)。其效果是隐式的资源管理,它在寻常使用中不可见,且降低了资源持有时长。
❼
想要对二元操作符——比如==——的两个操作数一视同仁, 最好在类所在的命名空间里定义一个非成员函数。例如:
namespace NX {
class X {
// ...
};
bool operator==(const X&, const X&);
// ...
};
除非违反的理由充分,否则设计容器应遵循标准库容器(第11章)的风格。 具体来说,需要达成容器资源安全,将其作为一个资源执柄来实现, 并附带适当的基础操作。
for (size_t i = 0; i<c.size(); ++i) // size_t 是标准库 size() 返回类型的名称
c[i] = 0;
for (auto& x : c)// 这里隐式利用了c.begin()和c.end()
x = 0;
for (auto p = c.begin(); p!=c.end(); ++p)
*p = 0;
除了用从0到size()的下标遍历容器外, 标准算法依赖于由一对迭代器(iterator)界定的序列(sequence)的概念。
上例中,c.begin()是个指向c第一个元素的迭代器, 而c.end()指向c最后一个元素之后的位置。 跟指针一样,迭代器支持++操作移至下一个元素,还支持*以访问其指向的元素的值。迭代器模型(iterator model)带来了极佳的通用型和性能。迭代器还被用于把序列传递给标准库算法。例如:
sort(v.begin(), v.end());
对于一对整数,<<
的意思是左移,>>
的意思是右移。 但是,对于iostream
,它们分别是输出和输入运算符。
❽
类的一个目标是让程序员设计、实现类型,并尽可能模拟内置类型。 构造函数提供了初始化操作,在灵活性和效率方面已经等同或超越了内置那些的初始化, 但对于内置类型来说,可以有文本值:
-
123
是一个int
-
0xFF00u
是一个unsigned int
-
123.456
是一个double
-
"Surprise!"
是一个const char[10]
如果用户定义类型也具备这样的文本值可就太有用了。 实现它的方法是为文本值定义适当的后缀,从而得到:
-
"Surprise!"s
是一个std::string
-
123s
是second(秒) -
12.7i
是imaginary(虚部), 因此12.7i+47
是一个complex number(复数)(即:{47, 12.7}
)
具体来说,这些来自标准库的例子,可以借由适当的头文件和命名空间得到:
不难想见,带有用户定义后缀的文本值被称为 用户定义文本值(user-difined literal)或UDL。 这些文本值通过 文本值操作符(literal operator)定义。 文本值操作符用于转换文本值,从其带有后缀的参数类型,转化到返回值类型。 例如,imaginary后缀的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;
❾
有很多算法,尤其是sort()
,会用一个swap()
函数,交换两个对象的值。 这些算法通常假定swap()快速,并且不会抛出异常。 标准库提供了一个std::swap(a,b), 它的实现用了三次转移操作:(tmp=a, a=b, b=tmp)。 假设你设计一个类型,如果复制它代价高昂又很可能被交换(比方说,被sort函数), 那么就给它定义一个转移操作,或者一个swap(),又或者干脆一起定义。 稍微提一下,标准库容器和string
具有快速转移操作。
标准库的unordered_map<K,V>
是个哈希表,其中K是键类型,V是值类型。如果想用某个类型X作为键,就必须定义hash<X>。标准库为我们给常见类型定义了它,比如std::string。
忠告
[1] 去掌控对象的构造、复制、转移以及析构;
[2] 把构造函数、赋值以及析构函数作为一套相互配合的操作进行设计;
[3] 要么定义所有基本操作,要么全都别定义;
[4] 如果缺省的构造函数、赋值、析构函数得当,让编译器去生成(别造轮子);
[5] 如果类具有指针成员,考虑一下是否需要定义一套析构函数、复制和转移的操作,或者拒绝生成它们;
[6] 如果类具有用户定义的析构函数,那它很可能需要用户定义的复制和转移操作,或拒绝生成它们;
[7] 默认情况下,请把单参数构造函数声明为explicit;
[8] 如果类具有合理的默认值,就以成员变量初值的方式给定;
[9] 如果默认的复制操作对某个类型不得当,就重定义或者禁止它;
[10] 将容器以传值的方式返回(相信转移操作的效率);
[11] 请为大容量的操作数采用const引用参数类型;
[12] 提供强类型安全;就是说,不要泄漏任何可视为资源的东西;
[13] 如果某个类是资源执柄,那它就需要用户定义的构造函数、析构函数,以及非默认的复制操作;
[14] 请重载运算符,以便模仿约定俗成的用法;
[15] 请遵循标准库容器的设计。