山风吹耳过, 这条路多曲折, 头顶繁星在闪烁, 你在许愿诉说, 前面有流星坠落, 怀疑当初的执着, 别退缩 , 哦哦哦哦哦, 年少轻狂过, 也曾经彷徨失措, 有你有我, 在唱这首歌, 忽然烦恼破落 落破 身旁是你是不会寂寞, 心中坚信那光明, 携手走出泥泞, 这一路你我共前行, 凝望彼此的眼睛, 坚守此刻约定, 终会在此与你同行。 再次与同行 与你同行~~~~

------------------《终会与你同行》

拷贝控制

一个类通过定义五种特殊的成员函数来控制对象的拷贝、移动、赋值和销毁操作。

  • 拷贝构造函数(copy constructor)
  • 拷贝赋值运算符(copy-assignment operator)
  • 移动构造函数(move constructor)
  • 移动赋值运算符(move-assignment operator)
  • 析构函数(destructor)

这些操作统称为拷贝控制操作(copy control)。

在定义任何类时,拷贝控制操作都是必要部分。

拷贝、赋值与销毁(Copy,Assign,and Destroy)

拷贝构造函数(The Copy Constructor)

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

1
2
3
4
5
6
7
class Foo
{
public:
Foo(); // default constructor
Foo(const Foo&); // copy constructor
// ...
};

由于拷贝构造函数在一些情况下会被隐式使用,因此通常不会声明为explicit的。

如果类未定义自己的拷贝构造函数,编译器会为类合成一个。一般情况下,合成拷贝构造函数(synthesized copy constructor)会将其参数的非static成员逐个拷贝到正在创建的对象中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Sales_data
{
public:
// other members and constructors as before
// declaration equivalent to the synthesized copy constructor
Sales_data(const Sales_data&);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
};

// equivalent to the copy constructor that would be synthesized for Sales_data
Sales_data::Sales_data(const Sales_data &orig):
bookNo(orig.bookNo), // uses the string copy constructor
units_sold(orig.units_sold), // copies orig.units_sold
revenue(orig.revenue) // copies orig.revenue
{ } // empty bod

使用直接初始化时,实际上是要求编译器按照函数匹配规则来选择与实参最匹配的构造函数。使用拷贝初始化时,是要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。

1
2
3
4
5
string dots(10, '.');   // direct initialization
string s(dots); // direct initialization
string s2 = dots; // copy initialization
string null_book = "9-999-99999-9"; // copy initialization
string nines = string(100, '9'); // copy initialization

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

  • =定义变量。
  • 将对象作为实参传递给非引用类型的形参。
  • 从返回类型为非引用类型的函数返回对象。
  • 用花括号列表初始化数组中的元素或聚合类中的成员。

当传递一个实参或者从函数返回一个值时,不能隐式使用explicit构造函数。

1
2
3
4
5
vector<int> v1(10);     // ok: direct initialization
vector<int> v2 = 10; // error: constructor that takes a size is explicit
void f(vector<int>); // f's parameter is copy initialized
f(10); // error: can't use an explicit constructor to copy an argument
f(vector<int>(10)); // ok: directly construct a temporary vector from an int

拷贝赋值运算符(The Copy-Assignment Operator)

重载运算符(overloaded operator)的参数表示运算符的运算对象。

如果一个运算符是成员函数,则其左侧运算对象会绑定到隐式的this参数上。

赋值运算符通常应该返回一个指向其左侧运算对象的引用。

1
2
3
4
5
6
class Foo
{
public:
Foo& operator=(const Foo&); // assignment operator
// ...
};

标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。

如果类未定义自己的拷贝赋值运算符,编译器会为类合成一个。一般情况下,合成拷贝赋值运算符(synthesized copy-assignment operator)会将其右侧运算对象的非static成员逐个赋值给左侧运算对象的对应成员,之后返回左侧运算对象的引用。

1
2
3
4
5
6
7
8
// equivalent to the synthesized copy-assignment operator
Sales_data& Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo; // calls the string::operator=
units_sold = rhs.units_sold; // uses the built-in int assignment
revenue = rhs.revenue; // uses the built-in double assignment
return *this; // return a reference to this object
}

析构函数(The Destructor)

析构函数负责释放对象使用的资源,并销毁对象的非static数据成员。

析构函数的名字由波浪号~接类名构成,它没有返回值,也不接受参数。

1
2
3
4
5
6
class Foo
{
public:
~Foo(); // destructor
// ...
};

由于析构函数不接受参数,所以它不能被重载。

如果类未定义自己的析构函数,编译器会为类合成一个。合成析构函数(synthesized destructor)的函数体为空。

析构函数首先执行函数体,然后再销毁数据成员。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。成员按照初始化顺序的逆序销毁。

隐式销毁一个内置指针类型的成员不会delete它所指向的对象。

无论何时一个对象被销毁,都会自动调用其析构函数。

当指向一个对象的引用或指针离开作用域时,该对象的析构函数不会执行。

三/五法则(The Rule of Three/Five)

需要析构函数的类一般也需要拷贝和赋值操作。

需要拷贝操作的类一般也需要赋值操作,反之亦然。

使用=default(Using =default

可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成版本。

1
2
3
4
5
6
7
8
9
class Sales_data
{
public:
// copy control; use defaults
Sales_data() = default;
Sales_data(const Sales_data&) = default;
~Sales_data() = default;
// other members as before
};

在类内使用=default修饰成员声明时,合成的函数是隐式内联的。如果不希望合成的是内联函数,应该只对成员的类外定义使用=default

只能对具有合成版本的成员函数使用=default

阻止拷贝(Preventing Copies)

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是显式地还是隐式地。

在C++11新标准中,将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)可以阻止类对象的拷贝。删除的函数是一种虽然进行了声明,但是却不能以任何方式使用的函数。定义删除函数的方式是在函数的形参列表后面添加=delete

1
2
3
4
5
6
7
8
struct NoCopy
{
NoCopy() = default; // use the synthesized default constructor
NoCopy(const NoCopy&) = delete; // no copy
NoCopy &operator=(const NoCopy&) = delete; // no assignment
~NoCopy() = default; // use the synthesized destructor
// other members
};
  1. NoCopy() = default;
    • 这行代码声明了默认构造函数,并使用 = default 表示使用编译器合成的默认实现。
    • 默认构造函数会被合成为一个无参构造函数,用于创建 NoCopy 类型的对象。
  2. NoCopy(const NoCopy&) = delete;
    • 这行代码声明了拷贝构造函数,并使用 = delete 表示禁用了该函数。
    • 禁用拷贝构造函数意味着不能通过拷贝构造函数来创建新对象,即不能以拷贝的方式初始化一个 NoCopy 类型的对象。
  3. NoCopy &operator=(const NoCopy&) = delete;
    • 这行代码声明了拷贝赋值运算符,并使用 = delete 表示禁用了该运算符。
    • 禁用拷贝赋值运算符意味着不能通过拷贝赋值操作来将一个 NoCopy 类型的对象赋值给另一个对象。
  4. ~NoCopy() = default;
    • 这行代码声明了析构函数,并使用 = default 表示使用编译器合成的默认实现。
    • 默认析构函数会被合成为一个析构函数,用于销毁 NoCopy 类型的对象。
  5. 其他成员:
    • NoCopy 结构体中还可以定义其他成员,这里没有给出具体的实现。

=delete=default有两点不同:

  • =delete可以对任何函数使用;=default只能对具有合成版本的函数使用。
  • =delete必须出现在函数第一次声明的地方;=default既能出现在类内,也能出现在类外。

析构函数不能是删除的函数。对于析构函数被删除的类型,不能定义该类型的变量或者释放指向该类型动态分配对象的指针。

如果一个类中有数据成员不能默认构造、拷贝或销毁,则对应的合成拷贝控制成员将被定义为删除的。

在旧版本的C++标准中,类通过将拷贝构造函数和拷贝赋值运算符声明为private成员来阻止类对象的拷贝。在新标准中建议使用=delete而非private

(尽量用delete)

拷贝控制和资源管理(Copy Control and Resource Management)

通常,管理类外资源的类必须定义拷贝控制成员。

行为像值的类(Classes That Act Like Values)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class HasPtr
{
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0) { }
// each HasPtr has its own copy of the string to which ps points
HasPtr(const HasPtr &p):
ps(new std::string(*p.ps)), i(p.i) { }
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps; }

private:
std::string *ps;
int i;
};

编写赋值运算符时有两点需要注意:

  • 即使将一个对象赋予它自身,赋值运算符也能正确工作。
1
2
3
4
5
6
7
8
9
// WRONG way to write an assignment operator!
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
delete ps; // frees the string to which this object points
// if rhs and *this are the same object, we're copying from deleted memory!
ps = new string(*(rhs.ps));
i = rhs.i;
return *this;
}

赋值运算符通常结合了拷贝构造函数和析构函数的工作。

编写赋值运算符时,一个好的方法是先将右侧运算对象拷贝到一个局部临时对象中。拷贝完成后,就可以安全地销毁左侧运算对象的现有成员了。

1
2
3
4
5
6
7
8
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); // copy the underlying string
delete ps; // free the old memory
ps = newp; // copy data from rhs into this object
i = rhs.i;
return *this; // return this object
}

定义行为像指针的类(Defining Classes That Act Like Pointers)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class HasPtr
{
public:
// constructor allocates a new string and a new counter, which it sets to 1
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
// copy constructor copies all three data members and increments the counter
HasPtr(const HasPtr &p):
ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr&);
~HasPtr();

private:
std::string *ps;
int i;
std::size_t *use; // member to keep track of how many objects share *ps
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
HasPtr::~HasPtr()
{
if (--*use == 0)
{ // if the reference count goes to 0
delete ps; // delete the string
delete use; // and the counter
}
}

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; // increment the use count of the right-hand operand
if (--*use == 0)
{ // then decrement this object's counter
delete ps; // if no other users
delete use; // free this object's allocated members
}
ps = rhs.ps; // copy data from rhs into this object
i = rhs.i;
use = rhs.use;
return *this; // return this object
}
  1. 默认构造函数:
    • HasPtr(const std::string &s = std::string()) 是默认构造函数,它接受一个 std::string 类型的引用参数 s,默认参数为一个空字符串。
    • 在构造函数中,使用 new 运算符在堆上分配了一个新的 std::string 对象,并将 s 的拷贝作为其初始值。
    • 同时,使用 new 运算符在堆上分配了一个新的 std::size_t 对象,用于计数对象与其他对象共享同一字符串的数量。这个计数器的初始值被设置为 1。
  2. 拷贝构造函数:
    • HasPtr(const HasPtr &p) 是拷贝构造函数,它接受一个 HasPtr 类型的引用参数 p
    • 在构造函数中,直接将成员变量 psiuse 分别指向参数对象 p 对应的成员变量。
    • 同时,通过对 use 所指向的计数器递增 1,来表示新创建的对象与原对象共享同一字符串。
  3. 拷贝赋值运算符:
    • HasPtr& operator=(const HasPtr&) 是拷贝赋值运算符。
    • 在函数体内,首先判断了自赋值情况,然后递增了参数对象 p 的计数器。
    • 接着递减了当前对象的计数器,如果计数器减为 0,则释放了当前对象所拥有的资源(即删除了当前对象的 psuse 指针所指向的内存)。
    • 最后,将当前对象的 psiuse 分别指向参数对象 p 对应的成员变量,并返回了当前对象的引用。
  4. 析构函数:
    • ~HasPtr() 是析构函数,它负责释放 HasPtr 对象所分配的动态内存。
    • 在析构函数中,通过递减计数器的值,来决定是否释放动态分配的字符串资源。如果计数器减为 0,则表示没有其他对象与当前对象共享该字符串资源,因此释放该资源。

交换操作(Swap)

通常,管理类外资源的类会定义swap函数。如果一个类定义了自己的swap函数,算法将使用自定义版本,否则将使用标准库定义的swap

1
2
3
4
5
6
7
8
9
10
11
12
class HasPtr
{
friend void swap(HasPtr&, HasPtr&);
// other members as in § 13.2.1 (p. 511)
};

inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, rhs.ps); // swap the pointers, not the string data
swap(lhs.i, rhs.i); // swap the int members
}

一些算法在交换两个元素时会调用swap函数,其中每个swap调用都应该是未加限定的。如果存在类型特定的swap版本,其匹配程度会优于std中定义的版本(假定作用域中有using声明)。

1
2
3
4
5
6
7
8
9
10
11
12
13
void swap(Foo &lhs, Foo &rhs)
{
// WRONG: this function uses the library version of swap, not the HasPtr version
std::swap(lhs.h, rhs.h);
// swap other members of type Foo
}

void swap(Foo &lhs, Foo &rhs)
{
using std::swap;
swap(lhs.h, rhs.h); // uses the HasPtr version of swap
// swap other members of type Foo
}

与拷贝控制成员不同,swap函数并不是必要的。但是对于分配了资源的类,定义swap可能是一种重要的优化手段。

由于swap函数的存在就是为了优化代码,所以一般将其声明为内联函数。

定义了swap的类通常用swap来实现赋值运算符。在这种版本的赋值运算符中,右侧运算对象以值方式传递,然后将左侧运算对象与右侧运算对象的副本进行交换(拷贝并交换,copy and swap)。这种方式可以正确处理自赋值情况。

1
2
3
4
5
6
7
8
// note rhs is passed by value, which means the HasPtr copy constructor
// copies the string in the right-hand operand into rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
// swap the contents of the left-hand operand with the local variable rhs
swap(*this, rhs); // rhs now points to the memory this object had used
return *this; // rhs is destroyed, which deletes the pointer in rhs
}
  1. 参数传递:
    • 拷贝赋值运算符的参数 rhs 是按值传递的,这意味着在调用拷贝赋值运算符时会发生参数对象的拷贝构造。
    • 当调用拷贝赋值运算符时,参数对象 rhs 会被拷贝构造出一个临时副本,其中包括 rhs 所指向的字符串的拷贝。
  2. 函数体内:
    • 首先,使用 swap() 函数交换了当前对象和临时副本 rhs 的成员。
    • 这个 swap() 调用会交换两个对象的成员指针 psiuse,从而使得当前对象指向了 rhs 原来所指向的字符串,而 rhs 则指向了当前对象原来所指向的字符串。这样就完成了资源的转移。
    • 最后,返回了当前对象的引用,并且由于 rhs 是按值传递的,函数结束后会自动销毁 rhs,从而释放了 rhs 所指向的字符串资源。

拷贝赋值运算符通常结合了拷贝构造函数和析构函数的工作。在这种情况下,公共部分应该放在private的工具函数中完成。

对象移动(Moving Objects)

某些情况下,一个对象拷贝后就立即被销毁了,此时移动而非拷贝对象会大幅度提高性能。

在旧版本的标准库中,容器所能保存的类型必须是可拷贝的。但在新标准中,可以用容器保存不可拷贝,但可移动的类型。

标准库容器、stringshared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

右值引用(Rvalue Reference)

为了支持移动操作,C++11引入了右值引用类型。右值引用就是必须绑定到右值的引用。可以通过&&来获得右值引用。

1
2
3
4
5
6
int i = 42;
int &r = i; // ok: r refers to i
int &&rr = i; // error: cannot bind an rvalue reference to an
int &r2 = i * 42; // error: i * 42 is an rvalue
const int &r3 = i * 42; // ok: we can bind a reference to const to an rvalue
int &&rr2 = i * 42; // ok: bind rr2 to the result of the multiplication
  1. int i = 42;
    • 定义了一个整型变量 i,并初始化为 42。
  2. int &r = i;
    • 创建了一个整型的引用 r,它绑定到变量 i 上。
    • 这是合法的,因为 r 是一个左值引用,可以绑定到左值 i 上。
  3. int &&rr = i;
    • 尝试将一个右值引用 rr 绑定到左值 i 上。
    • 这是不合法的,因为右值引用只能绑定到右值上,而 i 是一个左值。
  4. int &r2 = i * 42;
    • 尝试将一个左值引用 r2 绑定到一个表达式 i * 42 上。
    • 表达式 i * 42 的结果是一个右值,因此不能被左值引用绑定。
  5. const int &r3 = i * 42;
    • 将一个常量左值引用 r3 绑定到表达式 i * 42 上。
    • 这是合法的,因为常量左值引用可以绑定到右值上,此处 r3 绑定到了表达式的临时结果上。
  6. int &&rr2 = i * 42;
    • 将一个右值引用 rr2 绑定到表达式 i * 42 上。
    • 这是合法的,因为右值引用可以绑定到右值上,表达式 i * 42 的结果是一个右值。

右值引用只能绑定到即将被销毁,并且没有其他用户的临时对象上。使用右值引用的代码可以自由地接管所引用对象的资源。

变量表达式都是左值,所以不能将一个右值引用直接绑定到一个变量上,即使这个变量的类型是右值引用也不行。

1
2
int &&rr1 = 42;     // ok: literals are rvalues
int &&rr2 = rr1; // error: the expression rr1 is an lvalue!

调用move函数可以获得绑定在左值上的右值引用,此函数定义在头文件utility中。

1
int &&rr3 = std::move(rr1);

调用move函数的代码应该使用std::move而非move,这样做可以避免潜在的名字冲突。

移动构造函数和移动赋值运算符(Move Constructor and Move Assignment)

移动构造函数的第一个参数是该类类型的右值引用,其他任何额外参数都必须有默认值。

除了完成资源移动,移动构造函数还必须确保移后源对象是可以安全销毁的。

在函数的形参列表后面添加关键字noexcept可以指明该函数不会抛出任何异常。

对于构造函数,noexcept位于形参列表和初始化列表开头的冒号之间。在类的头文件声明和定义中(如果定义在类外)都应该指定noexcept

1
2
3
4
5
6
7
8
9
class StrVec
{
public:
StrVec(StrVec&&) noexcept; // move constructor
// other members as before
};

StrVec::StrVec(StrVec &&s) noexcept : /* member initializers */
{ /* constructor body */ }

这段代码定义了一个 StrVec 类,并实现了一个移动构造函数。

  1. StrVec(StrVec&&) noexcept;
    • 这是一个移动构造函数的声明。它接受一个右值引用作为参数,表示可以接受一个临时对象或者被移动的对象。
    • noexcept 关键字表示这个移动构造函数不会抛出异常,这是为了允许编译器进行优化。
  2. StrVec::StrVec(StrVec &&s) noexcept : /* member initializers */ { /* constructor body */ }
    • 这是移动构造函数的定义。
    • 在函数体内,通常会执行成员变量的移动操作,以将资源从被移动的对象 s 转移给当前对象。
    • noexcept 关键字用于声明这个移动构造函数不会抛出异常,这样的声明有助于优化代码。

移动构造函数的作用是将资源从一个对象转移到另一个对象,通常是从一个临时对象或者一个将要销毁的对象到一个新创建的对象。这样可以避免不必要的资源复制和额外的内存分配,提高程序的性能和效率。

标准库容器能对异常发生时其自身的行为提供保障。虽然移动操作通常不抛出异常,但抛出异常也是允许的。为了安全起见,除非容器确定元素类型的移动操作不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝而非移动操作。

不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept

在移动操作之后,移后源对象必须保持有效的、可销毁的状态,但是用户不能使用它的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
// direct test for self-assignment
if (this != &rhs)
{
free(); // free existing elements
elements = rhs.elements; // take over resources from rhs
first_free = rhs.first_free;
cap = rhs.cap;
// leave rhs in a destructible state
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
  1. StrVec &StrVec::operator=(StrVec &&rhs) noexcept
    • 这是移动赋值运算符的定义。它接受一个右值引用 rhs 作为参数,表示可以接受一个临时对象或者被移动的对象。
    • noexcept 关键字表示这个移动赋值运算符不会抛出异常,这是为了允许编译器进行优化。
  2. if (this != &rhs)
    • 在函数体内首先进行了自赋值检测,确保不会对当前对象自己进行移动赋值操作。
  3. free();
    • 调用了 free() 函数,用于释放当前对象的资源,包括释放动态分配的内存。
  4. elements = rhs.elements;, first_free = rhs.first_free;, cap = rhs.cap;
    • rhs 对象中的成员变量 elementsfirst_freecap 的值分别赋给了当前对象的相应成员变量。
    • 这样就将 rhs 对象所持有的资源移动给了当前对象。
  5. rhs.elements = rhs.first_free = rhs.cap = nullptr;
    • rhs 对象的成员变量 elementsfirst_freecap 分别置为 nullptr,将 rhs 置于可析构状态,避免其被重复释放。
  6. return *this;
    • 返回对当前对象的引用。

只有当一个类没有定义任何拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为类合成移动构造函数和移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,则编译器也能移动该成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// the compiler will synthesize the move operations for X and hasX
struct X
{
int i; // built-in types can be moved
std::string s; // string defines its own move operations
};

struct hasX
{
X mem; // X has synthesized move operations
};

X x, x2 = std::move(x); // uses the synthesized move constructor
hasX hx, hx2 = std::move(hx); // uses the synthesized move constructor
  1. struct X
    • X 结构体包含了一个整型成员 i 和一个字符串成员 s
    • 对于整型成员 i,因为是内置类型,可以直接进行移动操作。
    • 对于字符串成员 sstd::string 类型已经为其定义了移动构造函数和移动赋值运算符,因此可以安全地进行移动操作。
  2. struct hasX
    • hasX 结构体包含了一个 X 类型的成员 mem
    • 因为 X 类型已经为其定义了移动构造函数和移动赋值运算符,所以 hasX 结构体也会自动拥有对应的移动操作。
  3. X x, x2 = std::move(x);
    • 定义了两个 X 类型的对象 xx2
    • 使用 std::move()x 转换为右值引用,然后将其移动给 x2
    • 这里的移动操作会调用 X 结构体的合成移动构造函数,将 x 的成员移动给 x2
  4. hasX hx, hx2 = std::move(hx);
    • 定义了两个 hasX 类型的对象 hxhx2
    • 使用 std::move()hx 转换为右值引用,然后将其移动给 hx2
    • 这里的移动操作会调用 hasX 结构体的合成移动构造函数,将 hx 的成员 mem 移动给 hx2 的对应成员。

与拷贝操作不同,移动操作永远不会被隐式定义为删除的函数。但如果显式地要求编译器生成=default的移动操作,且编译器不能移动全部成员,则移动操作会被定义为删除的函数。

定义了移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则这些成员会被默认地定义为删除的函数。

如果一个类有可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的,即使调用move函数时也是如此。拷贝赋值运算符和移动赋值运算符的情况类似。

1
2
3
4
5
6
7
8
9
10
11
class Foo
{
public:
Foo() = default;
Foo(const Foo&); // copy constructor
// other members, but Foo does not define a move constructor
};

Foo x;
Foo y(x); // copy constructor; x is an lvalue
Foo z(std::move(x)); // copy constructor, because there is no move constructor
  1. Foo x;
    • 定义了一个名为 xFoo 类型的对象。
    • 这里使用了默认构造函数来初始化对象 x
  2. Foo y(x);
    • 定义了一个名为 yFoo 类型的对象,并将 x 作为参数传递给拷贝构造函数。
    • 因为 x 是一个左值,所以会调用拷贝构造函数来初始化 y
    • 这里的拷贝构造函数会将 x 的成员逐个拷贝给 y
  3. Foo z(std::move(x));
    • 定义了一个名为 zFoo 类型的对象,并使用 std::move()x 转换为右值引用作为参数传递。
    • 由于 Foo 类型没有定义移动构造函数,因此编译器会使用合成的移动构造函数。
    • 由于没有自定义的移动构造函数,编译器会将其等同于拷贝构造函数,因此会调用拷贝构造函数来初始化 z
    • 这里的拷贝构造函数会将 x 的成员逐个拷贝给 z

使用非引用参数的单一赋值运算符可以实现拷贝赋值和移动赋值两种功能。依赖于实参的类型,左值被拷贝,右值被移动。

1
2
3
4
5
6
7
8
9
// assignment operator is both the move- and copy-assignment operator
HasPtr& operator=(HasPtr rhs)
{
swap(*this, rhs);
return *this;
}

hp = hp2; // hp2 is an lvalue; copy constructor used to copy hp2
hp = std::move(hp2); // move constructor moves hp2

这段代码定义了一个赋值运算符重载函数,该函数同时充当了移动赋值运算符和拷贝赋值运算符的角色。

  1. HasPtr& operator=(HasPtr rhs)
    • 这是赋值运算符重载函数的定义。它接受一个 HasPtr 类型的参数 rhs,这里是通过值传递的方式,因此会调用拷贝构造函数来创建 rhs 的副本。
    • 在函数体内部,首先调用了 swap(*this, rhs),将当前对象和传入的参数 rhs 进行交换。这样可以实现资源的转移,而不需要额外的内存分配和释放。
    • 最后返回对当前对象的引用。
  2. hp = hp2;
    • 将一个 HasPtr 类型的对象 hp2 赋值给另一个 HasPtr 类型的对象 hp
    • 因为 hp2 是一个左值,所以会调用拷贝赋值运算符来实现赋值操作。
    • 在赋值运算符内部,会创建 hp2 的副本,然后将其与 hp 进行交换,完成赋值操作。
  3. hp = std::move(hp2);
    • 将一个 HasPtr 类型的对象 hp2 转换为右值引用,然后赋值给另一个 HasPtr 类型的对象 hp
    • 因为 std::move() 函数将 hp2 转换为右值引用,所以会调用移动赋值运算符来实现赋值操作。
    • 在赋值运算符内部,会将 hp2 的资源直接转移给 hp,而不需要进行额外的资源复制,提高了效率。

建议将五个拷贝控制成员当成一个整体来对待。如果一个类需要任何一个拷贝操作,它就应该定义所有五个操作。

移动赋值运算符可以直接检查自赋值情况。

C++11标准库定义了移动迭代器(move iterator)适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。移动迭代器的解引用运算符返回一个右值引用。

调用make_move_iterator函数能将一个普通迭代器转换成移动迭代器。原迭代器的所有其他操作在移动迭代器中都照常工作。

最好不要在移动构造函数和移动赋值运算符这些类实现代码之外的地方随意使用move操作。

右值引用和成员函数(Rvalue References and Member Functions)

区分移动和拷贝的重载函数通常有一个版本接受一个const T&参数,另一个版本接受一个T&&参数(T为类型)。

1
2
void push_back(const X&);   // copy: binds to any kind of X
void push_back(X&&); // move: binds only to modifiable rvalues of type X

有时可以对右值赋值:

1
2
string s1, s2;
s1 + s2 = "wow!";

在旧标准中,没有办法阻止这种使用方式。为了维持向下兼容性,新标准库仍然允许向右值赋值。但是可以在自己的类中阻止这种行为,规定左侧运算对象(即this指向的对象)必须是一个左值。

在非static成员函数的形参列表后面添加引用限定符(reference qualifier)可以指定this的左值/右值属性。引用限定符可以是&或者&&,分别表示this可以指向一个左值或右值对象。引用限定符必须同时出现在函数的声明和定义中。

1
2
3
4
5
6
7
8
9
10
11
12
class Foo
{
public:
Foo &operator=(const Foo&) &; // may assign only to modifiable lvalues
// other members of Foo
};

Foo &Foo::operator=(const Foo &rhs) &
{
// do whatever is needed to assign rhs to this object
return *this;
}
  1. Foo &operator=(const Foo&) &;
    • 在赋值运算符的声明中添加了引用限定符 &,表示此赋值运算符只能被左值对象调用。
    • 这意味着该赋值运算符只能在非常量左值对象上执行赋值操作,对于右值对象或者常量左值对象是不可用的。
  2. Foo &Foo::operator=(const Foo &rhs) &
    • 在赋值运算符的定义中同样添加了引用限定符 &,与声明中的匹配。
    • 这样的赋值运算符只能被非常量左值对象调用。
    • 在函数体内部,可以执行任何与赋值相关的操作。

引用限定符的使用可以有效地限制特定成员函数的调用方式,增强了代码的可读性和安全性。在这个例子中,引用限定符确保了赋值运算符只能在非常量左值对象上执行,从而避免了意外的赋值行为。

一个非static成员函数可以同时使用const和引用限定符,此时引用限定符跟在const限定符之后。

1
2
3
4
5
6
class Foo
{
public:
Foo someMem() & const; // error: const qualifier must come first
Foo anotherMem() const &; // ok: const qualifier comes first
};

引用限定符也可以区分成员函数的重载版本。

1
2
3
4
5
6
7
8
9
class Foo
{
public:
Foo sorted() &&; // may run on modifiable rvalues
Foo sorted() const &; // may run on any kind of Foo
};

retVal().sorted(); // retVal() is an rvalue, calls Foo::sorted() &&
retFoo().sorted(); // retFoo() is an lvalue, calls Foo::sorted() const &

在这个示例中,Foo 类定义了两个 sorted() 成员函数,它们使用了不同的引用限定符,分别针对右值引用(&&)和左值引用(const &)。

  1. Foo sorted() &&;
    • 此版本的 sorted() 函数使用了右值引用限定符 &&
    • 这意味着它只能在可修改的右值对象上调用,通常表示临时对象,如函数的返回值或者显式地使用 std::move() 转移的对象。
    • 该版本的 sorted() 函数可能会就地修改调用对象,并返回修改后的对象。
  2. Foo sorted() const &;
    • 此版本的 sorted() 函数使用了左值引用限定符 const &
    • 这意味着它可以在任何类型的 Foo 对象上调用,包括可修改的左值对象和常量对象。
    • 该版本的 sorted() 函数保证不会修改调用对象,并返回一个新的排序后的对象。

在调用时,根据调用对象的类型(右值引用还是左值引用),编译器将决定调用哪个版本的 sorted() 函数。

  • retVal().sorted();
    • retVal() 是一个右值,因此调用的是 Foo sorted() &&; 版本的函数。
    • 这里假设 retVal() 返回的是一个临时对象或者通过 std::move() 转移的对象。
  • retFoo().sorted();
    • retFoo() 是一个左值,因此调用的是 Foo sorted() const &; 版本的函数。
    • 这里假设 retFoo() 返回的是一个左值对象。

如果一个成员函数有引用限定符,则具有相同参数列表的所有重载版本都必须有引用限定符。

1
2
3
4
5
6
7
8
9
10
class Foo
{
public:
Foo sorted() &&;
Foo sorted() const; // error: must have reference qualifier
// Comp is type alias for the function type
// that can be used to compare int values
using Comp = bool(const int&, const int&);
Foo sorted(Comp*); // ok: different parameter list
};
  1. Foo sorted() &&;
    • 这是一个具有右值引用限定符 && 的成员函数版本。
    • 它表示该函数只能在可修改的右值对象上调用。
  2. Foo sorted() const;
    • 这是一个错误的声明。因为它与前一个版本具有相同的参数列表,但没有引用限定符。
    • 根据规则,如果存在具有引用限定符的版本,那么相同参数列表的所有重载版本都必须有引用限定符。
  3. Foo sorted(Comp*);
    • 这是一个不同参数列表的重载版本,因此可以没有引用限定符。
    • 它声明了一个以指向函数的指针作为参数的成员函数。

在设计类的成员函数时,需要特别注意遵循引用限定符的规则,以确保代码的一致性和可读性。

彩蛋

我不再迷茫,思念是唯一的行囊 漫天的星光,有一颗是你的愿望