拷贝控制操作(copy control)

  1. 拷贝构造函数(copy constructor)
  2. 拷贝赋值运算符(copy-assignment operator)
  3. 移动构造函数(move constructor)
  4. 移动赋值运算符(move-assignement operator)
  5. 析构函数(destructor)

拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么,拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。

拷贝、赋值和销毁

拷贝构造函数

  • 如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数

    注意:拷贝构造函数的第一个参数必须是一个引用类型,虽然可以定义接受非const引用的拷贝构造函数,但此参数几乎总是一个const引用。

    为什么要引用类型:拷贝构造函数被用来初始化非引用类类型参数(非引用形参采取值复制,复制会调用拷贝构造函数),如果其参数不是引用类型,那么调用永远不会成功——为了调用拷贝构造函数,必须拷贝其实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。

  • class Foo{ public: Foo(const Foo&); }

  • 合成的拷贝构造函数(synthesized copy constructor):会将参数的成员逐个拷贝到正在创建的对象中。
    与默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。

  • 拷贝初始化通常使用拷贝构造函数来完成,但是如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成

  • 拷贝初始化

    • 将右侧运算对象拷贝到正在创建的对象中,如果需要,还需进行类型转换。
    • 通常使用拷贝构造函数完成。
    • string book = "9-99";
    • 出现场景:
      • = 定义变量时。

      • 将一个对象作为实参传递给一个非引用类型的形参。

      • 从一个返回类型为非引用类型的函数返回一个对象。

      • 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员。

        “列表初始化”使用拷贝构造函数;注意“列表初始化”与“初始化列表”的区别。

        扩展:当初始化标准库容器或调用其 insertpush 成员时,容器会对其元素进行拷贝初始化,而用 emplace 成员创建的元素都进行直接初始化。

练习13.4

Q: 假定 Point 是一个类类型,它有一个public的拷贝构造函数,指出下面程序片段中哪些地方使用了拷贝构造函数:

1
2
3
4
5
6
7
8
Point global;
Point foo_bar(Point arg) // 1
{
Point local = arg, *heap = new Point(global); // 2: Point local = arg, 3: Point *heap = new Point(global)
*heap = local; // 拷贝运算符函数
Point pa[4] = { local, *heap }; // 4, 5
return *heap; // 6
}

A: 上面有6处地方使用了拷贝构造函数。

拷贝赋值运算符

  • 重载赋值运算符
    • 重写一个名为 operator= 的函数.
    • 通常返回一个指向其左侧运算对象的引用
    • Foo& operator=(const Foo&);
  • 合成拷贝赋值运算符
    • 将右侧运算对象的每个非 static 成员赋予左侧运算对象的对应成员。
    • 只是浅复制,对于动态分配部分很可能会导致错误。

练习13.6

Q: 拷贝赋值运算符是什么?什么时候使用它?合成拷贝赋值运算符完成什么工作?什么时候会生成合成拷贝赋值运算符?

A: 拷贝赋值运算符是一个名为 operator= 的函数。当赋值运算发生时就会用到它。合成拷贝赋值运算符可以用来禁止该类型对象的赋值(帮你隐式生成的拷贝构造和赋值有可能是 delete,比如有用户定义的移动操作,或有成员是非静态引用)。如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。

析构函数

  • 释放对象所使用的资源,并销毁对象的非static数据成员。

  • 名字由波浪号接类名构成。没有返回值,也不接受参数
    因此不能被重载,对于一个给定类,只会有唯一一个析构函数

  • ~Foo();

  • 调用时机:

    • 变量在离开其作用域时。
    • 当一个对象被销毁时,其成员被销毁。
    • 容器被销毁时,其元素被销毁。
    • 动态分配的对象,当对指向它的指针应用delete运算符时。
    • 对于临时对象,当创建它的完整表达式结束时。
  • 在析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

    不存在类似初始化列表的东西来控制成员如何销毁,析构部分是隐式的,成员销毁时发生什么完全依赖成员的类型。内置类型没有析构函数,只要释放引用或指针所指向的动态分配对象即可。

    认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段被销毁的。

  • 合成析构函数,当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数:

    • 空函数体执行完后,成员会被自动销毁
    • 对于某些类,合成析构函数被用来阻止该类型的对象被销毁。
    • 注意:析构函数体本身并不直接销毁成员。
    • 合成析构函数不会 delete一个指针数据成员,因此,此类需要定义一个析构函数来释放构造函数分配的内存。

练习13.11

Q: 为前面练习中的 HasPtr 类添加一个析构函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <string>

class HasPtr {
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) { }
HasPtr(const HasPtr &hp) : ps(new std::string(*hp.ps)), i(hp.i) { }
HasPtr& operator=(const HasPtr &hp) {
std::string *new_ps = new std::string(*hp.ps);
delete ps;
ps = new_ps;
i = hp.i;
return *this;
}
~HasPtr() {
delete ps;
}
private:
std::string *ps;
int i;
};

练习13.12

Q: 在下面的代码片段中会发生几次析构函数调用?

1
2
3
4
bool fcn(const Sales_data *trans, Sales_data accum) {
Sales_data item1(*trans), item2(accum);
return item1.isbn() != item2.isbn();
}

A: 三次,分别是 accumitem1item2

练习13.13

Q: 理解拷贝控制成员和构造函数的一个好方法的定义一个简单的类,为该类定义这些成员,每个成员都打印出自己的名字:

1
2
3
4
struct X {
X() {std::cout << "X()" << std::endl;}
X(const X&) {std::cout << "X(const X&)" << std::endl;}
}

X 添加拷贝赋值运算符和析构函数,并编写一个程序以不同的方式使用 X 的对象:将它们作为非引用参数传递;动态分配它们;将它们存放于容器中;诸如此类。观察程序的输出,直到你确认理解了什么时候会使用拷贝控制成员,以及为什么会使用它们。当你观察程序输出时,记住编译器可以略过对拷贝构造函数的调用。

A:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <vector>
#include <initializer_list>

struct X {
X() { std::cout << "X()" << std::endl; }
X(const X&) { std::cout << "X(const X&)" << std::endl; }
X& operator=(const X&) { std::cout << "X& operator=(const X&)" << std::endl; return *this; }
~X() { std::cout << "~X()" << std::endl; }
};

void f(const X &rx, X x) {
std::vector<X> vec;
vec.reserve(2);
vec.push_back(rx);
vec.push_back(x);
}

int main() {
X *px = new X;
f(*px, *px);
delete px;

return 0;
}

输出为:

1
2
3
4
5
6
7
8
X()
X(const X&) // 形参复制给实参
X(const X&) // push_back(rx)
X(const X&)
~X()
~X()
~X() // 实参销毁
~X() // delete px

三/五法则

  • 如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数

    需要析构函数的原因可能是类有指针数据成员,而合成的析构函数并不会delete指针成员
    而拷贝和赋值时对于这些指针成员不能进行浅复制,否则会有多个指针指向同一内存,析构函数delete时会出问题。

  • 需要拷贝操作的类也需要赋值操作,反之亦然。
    然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

练习13.14

Q: 假定 numbered 是一个类,它有一个默认构造函数,能为每个对象生成一个唯一的序号,保存在名为 mysn 的数据成员中。假定 numbered 使用合成的拷贝控制成员,并给定如下函数:

1
void f (numbered s) { cout << s.mysn < endl; }

则下面代码输出什么内容?

1
2
numbered a, b = a, c = b;
f(a); f(b); f(c);

A: 输出3个完全一样的数。

练习13.15

Q: 假定numbered 定义了一个拷贝构造函数,能生成一个新的序列号。这会改变上一题中调用的输出结果吗?如果会改变,为什么?新的输出结果是什么?

A: 会输出3个不同的数。并且这3个数并不是a、b、c当中的数。

练习13.16

Q: 如果 f 中的参数是 const numbered&,将会怎样?这会改变输出结果吗?如果会改变,为什么?新的输出结果是什么?

A: 会改变,会输出 a、b、c的数。

使用=default

  • 可以通过将拷贝控制成员定义为 =default 来显式地要求编译器生成合成的版本。
  • 合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。
  • 只能对具有合成版本的成员函数使用 =default,即默认构造函数或拷贝构造函数。

阻止拷贝

  • 大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。
    但在有些场景下这些操作没有合理的意义,此时定义类时必须采用某种机制阻止拷贝或赋值,比如iostream类阻止复制。
  • 定义删除的函数:=delete
  • =defalut不同,=delete必须出现在函数第一次声明的时候。另一个不同之处在于可以对除析构函数外的任何函数指定=delete
  • 如果一个类有数据成员不能默认构造、拷贝、复制或者销毁,则对应的成员函数将被定义为删除的。
    • 一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的——否则会创建出无法销毁的对象。
    • 对于具有引用成员或无法默认构造的const成员的类,编译器不会为其合成默认构造函数。
    • 对于有引用成员的类,合成拷贝赋值运算符被定义为删除的——合成的拷贝赋值运算符会进行浅复制,左侧运算对象仍然指向原来的对象,而非独立的副本
  • 老版本使用private声明来阻止拷贝(会涉及成员函数和友元的控制问题——拷贝构造函数和赋值运算符应声明为private同时只声明不定义,这样链接时报错能阻止友元和成员函数进行拷贝)。
    新版本应该使用=delete

拷贝控制和资源管理

  • 类的行为可以像一个值,也可以像一个指针。
    • 行为像值:对象有自己的状态,副本和原对象是完全独立的。
    • 行为像指针:共享状态,拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。

定义行为像值的类

为了实现类值行为,HasPtr需要:

  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针;
  • 定义一个析构函数来释放string;
  • 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string
    • 如果将一个对象赋予它自身时,赋值运算符必须能正确工作:一个好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象
      最好是异常安全的,当异常发生时能将左侧运算对象置于一个有意义的状态。
    • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作

练习13.25

Q: 假定希望定义 StrBlob 的类值版本,而且希望继续使用 shared_ptr,这样我们的 StrBlobPtr 类就仍能使用指向vectorweak_ptr 了。你修改后的类将需要一个拷贝的构造函数和一个拷贝赋值运算符,但不需要析构函数。解释拷贝构造函数和拷贝赋值运算符必须要做什么。解释为什么不需要析构函数。

A: 拷贝构造函数和拷贝赋值运算符要重新动态分配内存。因为 StrBlob 使用的是智能指针,当引用计数为0时会自动释放对象,因此不需要析构函数。

定义行为像指针的类

析构函数不能单方面地释放关联的string,只有最后一个指向string的HasPtr销毁时,它才可以释放
一种解决方法是将计数器保存在动态内存中,当拷贝或赋值对象时,我们拷贝指向计数器的指针。

赋值运算符必须能处理自赋值, 当两对象相同时,检查ps和use是否释放之前,计数器就已经被递增过了。

练习13.27

Q: 定义你自己的使用引用计数版本的 HasPtr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <string>

class HasPtr {
public:
// constructor: 计数器保存在动态内存中, 初始化为1
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new size_t(1)) { }

// copy constructor: 拷贝ps和use指针, 递增计数器
HasPtr(const HasPtr &hp) : ps(hp.ps), i(hp.i), use(hp.use) { ++*use; }

// copy-assignment operator: 必须能处理自赋值, 当两对象相同时, 检查ps和use之前
// 计数器就已经被递增过了
HasPtr& operator=(const HasPtr &rhs) {
++*rhs.use;
if (--*use == 0) {
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}

~HasPtr() {
if (--*use == 0) {
delete ps;
delete use;
}
}
private:
std::string *ps;
int i;
size_t *use;
};

交换操作

  • 管理资源的类通常还定义一个名为swap的函数,并非ni必要,但对于分配了资源的类,定义swap可能是一种很重要的优化手段。

    在自定义类上定义一个自己版本的swap来重载swap的默认行为,使其交换指针而非交换对象(交换对象需要进行一次拷贝和两次赋值)

    假设你有两个对象,分别为A和B。要将它们交换,你首先需要创建一个临时对象来保存其中一个对象的内容,这是拷贝操作。然后,你需要将A的值赋给B,以及将临时对象的值赋给A,这是两次赋值操作。

  • 经常用于重排元素顺序的算法。

  • 每个swap调用应该是未加限定的,用swap而不是std::swap。如果存在类型特定的swap版本,其匹配优先程度会优于std中定义的版本。

    1
    2
    3
    4
    void swap(Foo &lhs, Foo &rhs) {
    using std::swap;
    swap(lhs.h, rhs.h); // 使用HasPtr版本的swap, 除非未定义特定类型的swap才会用std::swap
    }
  • 拷贝并交换(copy and swap):将左侧运算对象与右侧运算对象的一个副本进行交换。

    1
    2
    3
    4
    5
    // 注意rhs是按值传递的, 意味着HasPtr的拷贝构造函数将右侧运算对象中的string拷贝到rhs
    HasPtr& HasPtr::operator=(HasPtr rhs) {
    swap(*this, rhs);
    return *this; // rhs被销毁, 从而delete了rhs中的指针
    }
    • 注意参数并非引用,否则=后左右值互换;
    • 当运算符结束时**,rhs被销毁,HasPtr析构函数执行,delete现指向的内存,即释放掉左侧运算对象原来的内存**;
    • 它自动处理了自赋值情况且天然是异常安全的——它通过在改变左侧运算对象之前靠拷贝右侧运算对象。

练习13.29

Q: 解释 swap(HasPtr&, HasPtr&)中对 swap 的调用不会导致递归循环。

1
2
3
4
5
void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针而非string对象
swap(lhs.i, rhs.i);
}

A: 在此swap函数中又调用了swap来交换HasPtr成员ps和i,但这两个成员的类型分别是指针和整型,都是内置类型,因此函数中swap被解析成std::swap,不会导致递归循环。

练习13.31

Q: 为你的 HasPtr 类定义一个 < 运算符,并定义一个 HasPtrvector。为这个 vector 添加一些元素,并对它执行 sort。注意何时会调用 swap

A: 代码略,需要注意的是它应该被声明为const的。
关于调用次数,在tdm-gcc 4.8.1中,当元素数小于等于16时sort使用插入排序算法,未使用swap,而是内存区域的整片移动。当元素数大于17时会使用快排,会调用swap,但递归到元素数小于等于16时就会使用插入排序。

练习13.32

Q: 类指针的 HasPtr 版本会从 swap 函数收益吗?如果会,得到了什么益处?如果不是,为什么?

A: 不会。默认swap版本简单交换两个对象的非静态成员,对于HasPtr来说就是交换string指针ps、引用计数指针use和整型值i。这种语义是符合期望的——两个HasPtr指向了原来对方的string,两种互换string后,各自的引用计数都是不变的(都是减1再加1)。因此,默认swap版本已经能正确处理类指针HasPtr的交换,专用swap版本不会带来更多收益。

拷贝控制示例

一定要自己写一遍!

写代码和学基础知识是一样重要的学习手段。

头文件 message.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#pragma once

#include <set>
#include <string>

class Folder;

class Message {
friend void swap(Message &, Message &);
friend class Folder;

public:
explicit Message(const std::string &str = "") : contents(str) {}
Message(const Message &);
Message &operator=(const Message &);
~Message();

void Save(Folder &);
void Remove(Folder &);
void PrintDebug();

private:
std::string contents;
std::set<Folder *> folders;

// 拷贝控制成员的工具函数
// 将本Message添加到指定参数的Folder中
void AddToFolders(const Message &);

// 从folders中的每个Folder中删除本Message
void RemoveFromFolders();

void AddFld(Folder *f) { folders.insert(f); }
void RemFld(Folder *f) { folders.erase(f); }
};

void swap(Message &, Message &);

class Folder {
friend void swap(Folder &, Folder &);
friend class Message;

public:
Folder() = default;
Folder(const Folder &);
Folder &operator=(const Folder &);
~Folder();
void PrintDebug();

private:
std::set<Message *> msgs;

// 拷贝控制成员的工具函数
// 将Folder添加到msgs中
void AddToMessage(const Folder &);
void RemoveFromMessage();

void AddMsg(Message *m) { msgs.insert(m); }
void RemMsg(Message *m) { msgs.erase(m); }
};

void swap(Folder &, Folder &);

源文件 message.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include "message.h"

void Message::Save(Folder &f) { // 用引用: 会改变Folders
folders.insert(&f);
f.msgs.insert(this);
}

void Message::Remove(Folder &f) {
folders.erase(&f);
f.msgs.erase(this);
}

void Message::AddToFolders(const Message &m) {
for (auto f : m.folders) {
f->AddMsg(this);
// f->AddMsg(&m); // Err: 不能把const元素插入非const容器
}
}

Message::Message(const Message &m) : contents(m.contents), folders(m.folders) {
AddToFolders(m); // 将本消息添加到m的Folders中
}

void Message::RemoveFromFolders() {
for (auto f : folders) {
f->RemMsg(this); // 从folders中每个指针删除本Message
}
}

Message::~Message() { RemoveFromFolders(); }

Message &Message::operator=(const Message &rhs) {
RemoveFromFolders();
contents = rhs.contents;
folders = rhs.folders;
AddToFolders(rhs);
return *this;
}

void swap(Message &lhs, Message &rhs) {
using std::swap;
lhs.RemoveFromFolders(); // 友元函数访问private方法(访问上并非是通过lhs去访问到)
// private方法访问友元类private成员变量msgs
// for (auto f : rhs.folders) {
// f->RemMsg(&rhs); // 友元函数没法访问友元类中的private成员变量msgs
// }
rhs.RemoveFromFolders();
swap(lhs.contents, rhs.contents);
swap(lhs.folders, rhs.folders);
lhs.AddToFolders(lhs);
rhs.AddToFolders(rhs);
}

// Folder Implementation
void swap(Folder &lhs, Folder &rhs) {
using std::swap;
lhs.RemoveFromMessage();
rhs.RemoveFromMessage();
swap(lhs.msgs, rhs.msgs);
lhs.AddToMessage(lhs);
rhs.AddToMessage(rhs);
}

void Folder::AddToMessage(const Folder &f) {
for (auto m : f.msgs) {
m->AddFld(this);
}
}

void Folder::RemoveFromMessage() {
for (auto m : msgs) {
m->RemFld(this);
}
}

Folder::Folder(const Folder &f) : msgs(f.msgs) { AddToMessage(f); }

Folder::~Folder() { RemoveFromMessage(); }

Folder &Folder::operator=(const Folder &rhs) {
RemoveFromMessage();
msgs = rhs.msgs; // 运算符函数, 并非初始化函数, 不能使用初始化列表
AddToMessage(rhs);
return *this;
}

int main() { return 0; }

练习13.37

Q: 我们并未使用拷贝交换方式来设计 Message 的赋值运算符。你认为其原因是什么?

A: 对于动态分配内存的例子来说,拷贝交换方式是一种简洁的设计。而这里的 Message 类并不需要动态分配内存,用拷贝交换方式只会增加实现的复杂度。

踩坑:访问控制问题分析

实验过程中遇到了一个有关访问控制的问题:

问题原因:

  • RemoveFromFolders 是Message的成员函数,而Message类是Folder类的友元类,因此可以通过RemoveFromFolders来访问到Folder的private成员msgs。
  • swap是Message的友元函数,通过友元函数能访问RemoveFolders(通过对象是访问不到private成员变量的)。
  • 友元不具备传递性,swap中只能访问Message类的成员,并不能访问Folder类的成员(即使Message是Folder的友元类),因此swap中不能直接访问到msgs。

访问权限

public protected private
成员是否可以访问 Yes Yes Yes
友元函数是否可以访问 Yes Yes Yes
子类是否可以访问 Yes Yes No
类的实例化对象是否可以访问 Yes No No

三种继承方式导致的权限变化

public protected private
public继承 public protected private
protected继承 protected protected private
private继承 private private private

动态内存管理类

  • 当使用allocator分配内存时,内存是未构造的,为了使用了此原始内存,必须调用construct在内存中构造一个对象。
  • uninitialized_copy: Constructs copies of the elements in the range [first,last) into a range beginning at result and returns an iterator to the last element in the destination range.
  • destroy函数会运行string的析构函数,string的析构函数会释放掉string自己的内存。
  • 拷贝赋值运算符在释放已有元素之前调用alloc_n_copy并保存结果,这样就可以处理自赋值情况。
  • alloc.construct(dest++, std::move(*elem++));调用move返回的结果会令construct使用string的移动构造函数,构造出的每个string会从旧string那接管内存的所有权。
  • string移动构造函数的细节并未公开,但标准库保证“移后源(moved-from)string仍然保持一个有效的、可析构的状态。
  • 通常不为move提供一个using声明(原因在P706),而是直接使用std::move

StrVec

头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#pragma once

#include <memory>
#include <string>

class StrVec {
public:
StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr){};
StrVec(std::initializer_list<std::string>);
StrVec(const StrVec &);
StrVec &operator=(const StrVec &);
~StrVec();
void push_back(const std::string &);
size_t size() const { return first_free - elements; }
size_t capacity() const { return cap - elements; }
std::string *begin() const { return elements; }
std::string *end() const { return first_free; }

private:
static std::allocator<std::string> alloc; // static声明
void chk_n_alloc() {
if (size() == capacity()) {
reallocate();
}
}
std::pair<std::string *, std::string *> alloc_n_copy(const std::string *,
const std::string *);
void free();
void reallocate();
std::string *elements;
std::string *first_free;
std::string *cap;
};

源文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include "StrVec.h"

#include <algorithm>

std::allocator<std::string> StrVec::alloc; // 类static成员的定义(不带static)

void StrVec::push_back(const std::string &s) {
chk_n_alloc();
alloc.construct(first_free++, s);
}

std::pair<std::string *, std::string *> StrVec::alloc_n_copy(
const std::string *b, const std::string *e) {
auto data = alloc.allocate(e - b);
// Unlike algorithm copy, uninitialized_copy constructs the objects in-place,
// instead of just copying them. This allows to obtain fully constructed
// copies of the elements into a range of uninitialized memory
return {data, std::uninitialized_copy(b, e, data)};
}

void StrVec::free() {
// for_each(elements, first_free, [](std::string &str) { alloc.destroy(&str);
// });
if (elements) {
for (auto p = first_free; p != elements;) {
alloc.destroy(--p);
}
alloc.deallocate(elements, cap - elements);
}
}

// 拷贝控制成员
StrVec::StrVec(const StrVec &s) {
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}

StrVec::~StrVec() { free(); }

StrVec &StrVec::operator=(const StrVec &rhs) {
auto data = alloc_n_copy(rhs.begin(), rhs.end());
elements = data.first;
first_free = cap = data.second;
return *this;
}

void StrVec::reallocate() {
auto newcapacity = size() ? 2 * size() : 1;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++i) {
alloc.construct(dest++, std::move(*elem++)); // 移动而非拷贝
// 不知道移动后旧StrVec中string包含什么, 但保证对其析构是安全的
}
free();
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}

StrVec::StrVec(std::initializer_list<std::string> il) {
auto newdata = alloc_n_copy(il.begin(), il.end());
elements = newdata.first;
first_free = cap = newdata.second;
}

int main() { return 0; }
static成员变量声明和定义的问题
  1. 类内static std::allocator<std::string> alloc;是声明,不是定义。
    注意变量可以声明多次但只能定义一次,该变量在多个类的对象中共享(多个对象公有),所以是声明,只能是声明多次,但不能是定义多次。

  2. 类外需要定义,否则会报链接时错误:

    类外使用默认初始化定义:std::allocator<std::string> StrVec::alloc;

    • 需要在变量前写类名

    • 定义中不需要再使用static

  3. 为什么要定义为static,不定义成static直接就是在类内定义也能跑?

  • 这里应该主要是为了节约空间,让多个对象共享此变量。
  • 但也有声音反对这种做法:静态函数不等同于静态成员变量,前者会大量使用,而后者在现代C++实践中会尽量避免,因为其生命周期不可控。就以 StrVecalloc 为例,声明为 static 看似节省了空间,但破坏了对象的"粒度",这个成员成了一个"全局共享池",你甚至不清楚它会什么时候回收内存。其次,这一点点空间,压根没有必要节省,每个对象保证自己的 alloc 受自己控制,而不会和别的对象搅和到一起,这一点对于代码的清晰度来讲,是十分必要的。
  • C++11提供了allocator_traits, 提供了allocate的静态方法,也许是种更好的方法(TODO:本人还不会用,就先不介绍了)。

推荐阅读:

String

头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#pragma once

#include <memory> // allocator

class String {
public:
String() : String("") {} // 成员方法定义后不用加';'
String(const char*);
String(const String&);
String& operator=(const String&);
~String();

size_t length() const { return cap - elements - 1; }
size_t size() const { return cap - elements - 1; }
char* begin() const { return elements; }
char* end() const { return cap; }

const char* c_str() const { return elements; }

private:
char* elements;
char* cap; // 指向'\n'尾后元素
static std::allocator<char> alloc; // static声明

std::pair<char*, char*> alloc_n_copy(const char*, const char*);
void free();
};

源文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include "String.h"

#include <algorithm>
#include <iostream>

std::allocator<char> String::alloc; // static类成员定义

std::pair<char*, char*> String::alloc_n_copy(const char* b, const char* e) {
auto data = alloc.allocate(e - b);
return {data, std::uninitialized_copy(
b, e, data)}; // uninitialized_copy拷贝时前闭后开,
// 返回最后一个元素之后的位置
}

String::String(const char* s) {
char* e = const_cast<char*>(s); // "const char *" 类型的值不能用于初始化 "char *" 类型的实体
while (*e) {
++e;
}
auto newdata = alloc_n_copy(s, ++e); // 第二个参数为最后一个元素'\n'尾后元素
elements = newdata.first;
cap = newdata.second;
}

String::String(const String& rhs) {
// printf("%c %zu\n", *rhs.elements, rhs.length());
auto newdata = alloc_n_copy(rhs.elements, rhs.cap);
elements = newdata.first;
cap = newdata.second;
std::cout << "copy constructor" << std::endl;
}

String& String::operator=(const String& rhs) {
auto newdata = alloc_n_copy(rhs.elements, rhs.cap);
free();
elements = newdata.first;
cap = newdata.second;
std::cout << "copy-assignment" << std::endl;
return *this;
}

void String::free() {
if (elements) {
// for (auto p = cap; p != elements;) {
// alloc.destroy(--p);
// }
std::for_each(elements, cap, [](char& c) { alloc.destroy(&c); });
alloc.deallocate(elements, cap - elements);
}
}

String::~String() { free(); }

测试文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
#include <vector>

#include "String.h"

// Test reference to http://coolshell.cn/articles/10478.html

void foo(String x) { std::cout << x.c_str() << std::endl; }

void bar(const String& x) { std::cout << x.c_str() << std::endl; }

String baz() {
String ret("world");
return ret;
}

int main() {
char text[] = "world";

String s0;
String s1("hello");
printf("s1 = %s s1.length() = %zu s1.begin() = %c\n", s1.c_str(), s1.length(),
*s1.begin());
String s2(s1);
printf("s2 = %s s2.length() = %zu s2.begin() = %c\n", s2.c_str(), s2.length(),
*s2.begin());
String s3 = s1;
printf("s3 = %s s3.length() = %zu\n", s3.c_str(), s3.length());
String s4(text);
s2 = s1;

foo(s1);
bar(s1);
foo("temporary");
bar("temporary");
String s5 = baz();

std::vector<String> svec;
svec.reserve(8);
svec.push_back(s0);
svec.push_back(s1);
svec.push_back(s2);
svec.push_back(s3);
svec.push_back(s4);
svec.push_back(s5);
svec.push_back(baz());
svec.push_back("good job");

for (const auto& s : svec) {
std::cout << s.c_str() << std::endl;
}
}

对象移动

  • 很多拷贝操作后,原对象会被销毁,因此引入移动操作可以大幅度提升性能。
  • 在新标准中,我们可以用容器保存不可拷贝的类型,只要它们可以被移动即可。
  • 标准库容器、stringshared_ptr类既可以支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

右值引用

  • 新标准引入右值引用以支持移动操作。

  • 右值引用就是必须绑定到右值的引用,通过&&获得。右值引用只能绑定到一个将要销毁的对象上,因此可以自由地移动其资源。
    类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。

    右值的概念其实很微妙,一旦某个右值,有了名字,也就在内存中有了位置,它就变成了一个左值。但它又是一个很有用的概念,允许程序员更加细粒度的处理对象拷贝时的内存分配问题,提高了对临时对象和不需要的对象的利用率,极大提高程序的效率。当然,也会引入更多的bug。不过,这就是C++的哲学,什么都允许你做,但出了问题,可别赖C++这门语言。

  • 左值引用,即“常规引用”,不能绑定到要转换的表达式、字面值常量或返回右值的表达式。
    而右值表达式相反,可以绑定到这类表达式,但不能绑定到一个左值上。

    1
    2
    3
    int &r2 = i * 42;  // 错误, 左值引用不能绑定到i*42这个右值上
    const int &r3 = i * 42; // 正确, 可以将一个const的引用保定到一个右值上
    // 等价于绑定一个到一个临时对象上
  • 返回左值的表达式包括返回左值引用的函数及赋值、下标、解引用和前置递增/递减运算符
    返回右值的表达式包括非引用类型的函数及算术、关系、位和后置递增/递减运算符

    1
    2
    3
    4
    5
    int &r2 = i * 42;  // 错误: i*42是一个右值
    const int &r3 = i * 42; // 正确: 可以将一个const的引用绑定到一个右值上(产生的temp)

    int f();
    int &&r1 = f();
  • 左值有持久状态,右值要么是字面常量,要么是在表达式求值过程中创建的临时变量。
    由于右值只能绑定到临时对象,可知:

    • 所引用的对象即将被销毁
    • 该对象没有其他用户

    因此,使用右值引用的代码可以自由地接管所引用对象的资源

  • 不能将一个右值引用绑定到一个右值引用类型的变量上。

    1
    2
    3
    int &&rr1 = 42;
    int &&rr2 = rr1; // 错误: 表达式rr1是左值
    // 变量可以看作只有一个运算对象而没有运算符的表达式

    变量是左值,是持久的,直到离开作用域才被销毁,因此不能将一个右值引用绑定到一个变量上,即使这个变量是右值引用也不行。

move函数

  • int &&rr2 = std::move(rr1);
  • move告诉编译器,我们有一个左值,但我希望像右值一样处理它。
  • 调用move意味着:除了对rr1赋值或者销毁它外,我们将不再使用它。
    调用move后就不能对移后源对象的值做任何假设。
  • 使用move的代码应该使用std::move而不是move,这样可以避免潜在的名字冲突(原因见于P707)

练习13.46

Q: 什么类型的引用可以绑定到下面的初始化器上?

1
2
3
4
5
6
int f();
vector<int> vi(100);
int? r1 = f();
int? r2 = vi[0];
int? r3 = r1;
int? r4 = vi[0] * f();

A:

1
2
3
4
int&& r1 = f();
int& r2 = vi[0];
int& r3 = r1;
int&& r4 = vi[0] * f();

练习13.48

Q:定义一个vector<String> 并在其上多次调用 push_back。运行你的程序,并观察 String 被拷贝了多少次。

A:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <algorithm>
#include <iostream>
#include <vector>

#include "String.h"

int main() {
String s1("One"), s2("Two");
std::cout << s1 << " " << s2 << std::endl;
String s3(s2);
std::cout << s1 << " " << s2 << " " << s3 << std::endl;
s3 = s1;
std::cout << s1 << " " << s2 << " " << s3 << std::endl << std::endl;

std::vector<String> vs;
vs.push_back(s1);
vs.push_back(std::move(s2));
vs.push_back(String("Three"));
vs.push_back("Four");
std::for_each(vs.begin(), vs.end(),
[](const String &s) { std::cout << s << " "; });
std::cout << std::endl;
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(base) ➜  ch13 clang++ -Wall String.cpp testString.cpp -o String && ./String
One Two
copy constructor
One Two Two
copy-assignment
One Two One

copy constructor
copy constructor
copy constructor
copy constructor
copy constructor
copy constructor
copy constructor
One Two Three Four

TODO:这里调用7次,但答案说只应该有2次。

移动构造函数和移动赋值运算符

移动构造函数

  • 第一个参数是该类类型的一个引用,关键是,这个引用参数是一个右值引用与拷贝构造函数一样,任何额外的参数都必须有默认实参
  • 除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的
  • StrVec::StrVec(StrVec &&s) noexcept{} 构造函数中**noexcept通常出现在参数列表和初始化列表开始的冒号之间**。
  • 必须在类头文件的声明和定义中都指定noexcept
  • 不分配任何新内存,只是接管给定的内存,因此移动操作通常不会抛出任何异常
  • 除非标准库知道我们的移动构造函数不会抛出异常,否则它认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外工作。

为什么需要except:

eg:vector需要保证如果我们调用push_back时发生异常,vector自身不会改变。

  • 移动一个对象通常会改变其值,如果重新分配过程中使用了移动构造函数,且在移动了部分元素后抛出了一个异常,就会出现问题——旧空间中移动源元素已经改变,而新空间中未构造的元素尚不存在。
  • 如果使用拷贝构造函数发生了异常,可以很容易地满足要求——旧元素保持不变,如果发生了异常,释放新分配内存并返回就好了。

为了避免这种潜在问题,除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中必须使用拷贝构造函数而不是移动构造函数

移动赋值运算符

  • StrVec& StrVec::operator=(StrVec && rhs) noexcept{}
  • noexcept 出现在参数列表和初始化列表开始的冒号之间。

移动后源对象必须可析构

  • 从一个对象移动数据并不会销毁此对象,但有时会在移动操作完成后,源对象会被销毁,必须确保移后源对象进入一个可析构的状态,这是通过将移后源对象的指针成员置为nullptr实现的。
  • 除了确保可析构外,还要确保对象有效:可以安全地为其赋予新值活可以安全地使用而不依赖其当前值。

合成的移动操作

  • 与拷贝操作不同,编译器不会为某些类合成移动操作:如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。

  • 只有一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动构造或移动赋值时,编译器才会为它合成移动构造函数和移动赋值运算符。

  • 与拷贝操作不同,移动操作永远也不会隐式定义为删除的函数(如果有引用成员或const成员,拷贝操作会被隐式定义为删除的……)。但如果显示要求编译器生成 =default 的移动操作,而编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。

  • 如果一个类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符被定义为删除的。

移动右值,拷贝左值

  • StrVec拷贝构造函数接受一个const StrVec&,移动构造函数接受一个StrVec&&

    1
    2
    3
    4
    StrVec v1, v2;
    v1 = v2; // v2是左值, 只能使用拷贝赋值
    StrVec getVec(istream &); // 返回的是右值(不是左值就都是右值)
    v2 = getVec(cin); // 使用移动赋值

    在第二个赋值中,调用拷贝赋值运算符需要进行一次到const的转换,而StrVec&&是精准匹配,因此会使移动赋值运算符。

如果没有移动构造函数,右值也被拷贝

  • 如果一个类有一个可用的拷贝构造函数而没有移动构造函数,编译器不会合成移动构造函数,对其对象是通过拷贝构造函数来“移动”的。
    拷贝赋值运算符和移动赋值运算符情况类似。
  • 用拷贝构造函数来代替移动构造函数几乎肯定是安全的,: 可以将一个Foo &&转换为一个const Foo &

拷贝并交换赋值运算符和移动操作

1
2
3
4
5
6
7
8
9
10
11
class HasPtr {
public:
// 新添加的移动构造函数
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i), { p.ps = 0;}
// 赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
HasPtr &operator=(HasPtr rhs) {
swap(*this, rhs);
return *this;
}
// 其他成员的定义
}
  • 观察赋值运算符,此运算符有一个非引用参数,这意味着该参数要进行拷贝初始化。
    依赖实参类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。
  • 当rhs离开作用域时,这个string被销毁。
1
2
hp = hp2; // hp2是一个左值; hp2通过拷贝构造函数来拷贝
hp = std::move(hp2); // 移动构造函数移动hp2

传参使用值传递,需要构造函数。

  • 更新三/五法则:如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。

Message类的移动操作

工具函数: 窃取m的folders

1
2
3
4
5
6
7
8
void Message::MoveFolders(Message *m) {
folders = std::move(m->folders); // 使用set的移动赋值运算符,移动到当前对象中
for (auto f : folders) {
f->RemMsg(m);
f->AddMsg(this);
}
m->folders.clear(); // 确保销毁m是无害的
}
  • 通过调用move,使用后set的移动赋值运算符而非拷贝赋值运算符。
  • 向set插入一个元素可能会抛出一个异常,因此不能将其标记为noexcept
  • 在执行了move之后,我们知道m.folders是有效的,但不知道其内容。
    由于Message析构函数遍历folders,因此通过 m->folders.clear() 来确保set是空的。

移动构造函数

1
2
3
Message::Message(Message &&m) : contents(std::move(m.contents)) {
MoveFolders(&m);
}
  • 移动构造函数使用move来移动contents,并默认初始化自己的folders成员(然后在移动构造函数中移动folders并更新Folder指针)。

移动赋值运算符

1
2
3
4
5
6
7
8
Message &Message::operator=(Message &&rhs) {
if (this != &rhs) {
RemoveFromFolders();
contents = std::move(rhs.contents); // 使用移动赋值函数
MoveFolders(&rhs);
}
return *this;
}
  • 直接检查自赋值。
  • 与任何赋值运算符一样,移动赋值运算符必须销毁左侧运算对象的旧状态。

移动迭代器:

  • C++11中定义了一种移动迭代器适配器,一个移动迭代器通过改变给定迭代器的解引用运算符行为来适配此迭代器。

  • 与其他迭代器不同,移动迭代器节的解引用运算符生成一个右值引用

  • make_move_iterator函数讲一个普通迭代器转换为一个移动迭代器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    void StrVec::reallocate() {
    auto newcapacity = size() ? 2 * size() : 1;
    auto newdata = alloc.allocate(newcapacity);
    // auto dest = newdata;
    // auto elem = elements;
    // for (size_t i = 0; i != size(); ++i) {
    // alloc.construct(dest++, std::move(*elem++)); // 移动而非拷贝
    // // 不知道移动后旧StrVec中string包含什么, 但保证对其析构是安全的
    // }
    auto dest = std::uninitialized_copy(std::make_move_iterator(begin()),
    std::make_move_iterator(end()), newdata);
    free();
    elements = newdata;
    first_free = dest;
    cap = elements + newcapacity;
    }
  • 标准库并不保证哪些算法适合移动迭代器,只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能讲移动迭代器传给算法。
    建议:小心地使用移动操作,以获得性能提升。

练习13.49 && 13.50

为你的 StrVecStringMessage 类添加一个移动构造函数和一个移动赋值运算符。

在你的 String 类的移动操作中添加打印语句,并重新运行13.6.1节的练习13.48中的程序,它使用了一个vector<String>,观察什么时候会避免拷贝。

错误示范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 类中声明
String(String&&);
String& operator=(String&&);

// 类外定义
String::String(String&& s) : elements(s.elements), cap(s.cap) {
s.elements = s.cap = nullptr;
std::cout << "move constructor" << std::endl;
}

String& String::operator=(String&& rhs) {
if (&rhs != this) {
free();
rhs.elements = rhs.cap = nullptr;
}
std::cout << "move-assignment" << std::endl;
return *this;
}


// 测试部分
std::vector<String> vs;
vs.push_back(s1);
std::move(s1);
vs.push_back(std::move(s2));


// 测试结果
(base) ➜ ch13 clang++ -Wall String.cpp testString.cpp -o String && ./String
copy constructor
move constructor
copy constructor

可以看到vs.push_back(std::move(s2))使用的还是移动赋值构造函数,但vector扩容时使用的就是拷贝赋值运算符,问题在于没有使用noexcept

正确示范

需要同时在声明和定义中都使用noexcept

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 类中声明
String(String&&) noexcept;
String& operator=(String&&) noexcept;

// 类外定义
String::String(String&& s) noexcept : elements(s.elements), cap(s.cap) {
s.elements = s.cap = nullptr;
std::cout << "move constructor" << std::endl;
}

String& String::operator=(String&& rhs) noexcept {
if (&rhs != this) {
free();
rhs.elements = rhs.cap = nullptr;
}
std::cout << "move-assignment" << std::endl;
return *this;
}

// 测试部分
std::vector<String> vs;
vs.push_back(s1);
std::move(s1);
vs.push_back(std::move(s2));
vs.push_back(String("Three"));
vs.push_back("Four");
std::for_each(vs.begin(), vs.end(),
[](const String &s) { std::cout << s << " "; });


// 测试结果
(base) ➜ ch13 clang++ -Wall String.cpp testString.cpp -o String && ./String
copy constructor
move constructor
move constructor
move constructor
move constructor
move constructor
move constructor
One Two Three Four

除了第一个是拷贝构造,其他都是移动构造,符合预期。

练习13.51

Q: 虽然 unique_ptr 不能拷贝,但我们在12.1.5节中编写了一个 clone 函数,它以值的方式返回一个 unique_ptr。解释为什么函数是合法的,以及为什么它能正确工作。

1
2
3
4
// 不能拷贝unique_ptr的规则有一个例外: 我们可以拷贝或赋值一个将要被销毁的unique_ptr
unique_ptr<int> clone(int p) {
return unique_ptr<int>(new int(p));
}

A: 在这里是移动的操作而不是拷贝操作,因此是合法的。

练习13.53

Q: 从底层效率的角度看,HasPtr 的赋值运算符并不理想,解释为什么?为 HasPtr 实现一个拷贝赋值运算符和一个移动赋值运算符,并比较你的新的移动赋值运算符中执行的操作和拷贝并交换版本中的执行的操作。

A:

  • hp = hp2; 在进行拷贝赋值时,先通过拷贝构造创建了hp2的副本rhs,然后再交换hp和rhs,rhs作为一个中间媒介,只是起到了将值从hp2传递给hp的作用,是一个冗余的操作。
  • hp = std::move(hp2); 在进行移动赋值时,先从hp2转移到了rhs,然后再交换到hp,也是冗余的。
  • 也就是说,这种实现方式唯一的好处是统一了拷贝和移动赋值运算,但在性能上多了一次从rhs的间接传递,性能不好。
练习13.54

Q: 如果我们为 HasPtr 定义了移动赋值运算符,但未改变拷贝并交换运算符,会发生什么?编写代码验证你的答案。

A: 会产生编译错误。因为对于hp = std::move(hp2)这样的赋值语句来说,两个运算符匹配得一样好,从而产生了二义性。

1
2
3
error: ambiguous overload for 'operator=' (operand types are 'HasPtr' and 'std::remove_reference<HasPtr&>::type { aka HasPtr }')
hp1 = std::move(*pH);
^

右值引用和成员函数

右值和左值引用成员函数

1
2
3
4
5
6
7
8
9
void StrVec::push_back(const string &s) {
chk_n_alloc();
alloc.construct(first_free++, s);
}

void StrVec::push_back(string &&s) {
chk_n_alloc();
alloc.construct(first_free++, std::move(s));
}
  • 区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&
  • 引用限定符:
    • 在参数列表后面防止一个&,限定只能向可修改的左值赋值而不能向右值赋值。
    • 引用限定符可以是&&&,分别指出this可以指向一个左值或一个右值。
      类似const限定符,引用限定符只能出现在(非static)成员函数,且必须同时出现在函数的声明和定义中。
    • 一个函数可以同时用const和引用限定,引用限定符必须放在const限定符之后。

重载和和引用函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Foo {
public:
Foo sorted() &&; // 用于可改变的右值
Foo sorted() const &; // 用于任何类型的Foo

private:
std::vector<int> data;
};

// 对象为右值, 可以原地排序
Foo Foo::sorted() && {
std::sort(data.begin(), data.end());
return *this;
}

// 对象是一个const或一个左值, 哪种情况都不能对其原址排序
Foo Foo::sorted() const & {
Foo ret(*this);
std::sort(ret.data.begin(), ret.data.end());
return ret;
}
  • 当对一个右值执行sorted时,可以安全地直接对其data成员进行排序。
    对象是一个右值意味着没有其他用户,可以改变对象。
  • 当对一个const右值或一个左值执行sorted时,不能改变对象,需要在排序前拷贝data。
  • 如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
1
2
retVal().sorted(); // retVal()是一个右值, 调用Foo::sorted() &&
retFoo().sorted(); // retFoo()是一个左值, 调用Foo::sorted() const &
练习13.56

Q: 如果 sorted定义如下,会发生什么:

1
Foo Foo::sorted() const & { return Foo(*this).sorted(); }

A: 可以正确利用右值引用版本来完成排序。
编译器会认为Foo(*this)是一个“无主”的右值,对它调用sorted会匹配右值版本。