C++ Primer 第三部分

4/27/2024 C++

# C++ primer 第三部分

# 第13章 拷贝控制

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

  • 拷贝构造函数(copy constructor)

  • 拷贝赋值运算符(copy-assignment operator)

  • 移动构造函数(move constructor)

  • 移动赋值运算符(move-assignment operator)

  • 析构函数(destructor)

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

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

# 13.1 拷贝、赋值与销毁

# 拷贝构造函数

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

class Foo
{
public:
    Foo();   // 默认构造函数
    Foo(const Foo&);   // 拷贝构造函数
    // ...
};
1
2
3
4
5
6
7

​ 拷贝构造函数的第一个参数必须是一个引用类型,拷贝构造函数通常不应该是 explicit

合成拷贝构造函数

​ 如果类未定义自己的拷贝构造函数,编译器会为类合成一个,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。一般情况下,合成拷贝构造函数会将其参数的非static成员逐个拷贝到正在创建的对象中。

class Sales_data
{
public:
    // 其他成员和构造函数的定义,如前
    // 与合成的拷贝构造函数等价的拷贝构造函数的声明
    Sales_data(const Sales_data&);
private:
    std::string bookNo;
    int units_sold = 0;
    double revenue = 0.0;
};

// 与 Sales_data 的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig):
    bookNo(orig.bookNo),    // 使用 string 的拷贝构造函数
    units_sold(orig.units_sold),    // 拷贝orig.units_sold
    revenue(orig.revenue)   // 拷贝 orig.revenue
    { } // 空函数体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

拷贝初始化

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

string dots(10, '.');   // 直接初始化
string s(dots);         // 直接初始化
string s2 = dots;       // 拷贝初始化
string null_book = "9-999-99999-9";    // 拷贝初始化
string nines = string(100, '9');       // 拷贝初始化
1
2
3
4
5

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

​ 发生拷贝初始化的情况:

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

参数和返回值

​ 在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。

​ 拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功一一为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。

拷贝初始化的限制

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

vector<int> v1(10);     // 正确:直接初始化
vector<int> v2 = 10;    // 错误:接受大小参数的构造函数是 explicit 的
void f(vector<int>);    // f的参数进行拷贝初始化
f(10);      // 错误:不能用一个 explicit 的构造函数拷贝一个实参
f(vector<int>(10));     // 正确:从一个int 直接构造一个临时 vector
1
2
3
4
5

编译器可以绕过拷贝构造函数

string null book="9-999-99999-9";// 拷贝初始化
string null book("9-999-99999-9");// 编译器略过了拷贝构造函数
1
2

# 拷贝赋值运算符

重载赋值运算符

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

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

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

class Foo
{
public:
    Foo& operator=(const Foo&);  // 赋值运算符
    // ...
};
1
2
3
4
5
6

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

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

// 等价于合成拷贝赋值运算符
Sales_data& Sales_data::operator=(const Sales_data &rhs)
{
    bookNo = rhs.bookNo;    // 调用 string::operator=
    units_sold = rhs.units_sold;    // 使用内置的 int 赋值
    revenue = rhs.revenue;  // 使用内置的 double赋值
    return *this;   // 返回一个此对象的引用
}
1
2
3
4
5
6
7
8

# 析构函数

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

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

class Foo
{
public:
    ~Foo(); // 析构函数
    // ...
};
1
2
3
4
5
6

​ 由于析构函数不接受参数,所以它不能被重载。对一个给定类,只会有唯一一个析构函数。

析构函数完成什么工作

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

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

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

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

什么时候会调用析构函数

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

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

# 三/五法则

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

​ 通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。

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

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

​ 如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然一如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

# 使用=default

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

class Sales_data
{
public:
    // 拷贝控制成员;使用 default
    Sales_data() = default;
    Sales_data(const Sales_data&) = default;
    ~Sales_data() = default;
};
1
2
3
4
5
6
7
8

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

​ 只能对具有合成版本的成员函数使用=default。(即,跌认构造函数或拷贝控制成员)。

# 阻止拷贝

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

定义删除的函数

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

struct NoCopy
{
    NoCopy() = default; // 使用合成的默认构造函数
    NoCopy(const NoCopy&) = delete; // 阻止拷贝
    NoCopy &operator=(const NoCopy&) = delete; // 阻止赋值
    ~NoCopy() = default; // 使用合成的析构函数
    // 其他成员
};
1
2
3
4
5
6
7
8

=delete=default有两点不同:

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

析构函数不能是删除的成员

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

​ 对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象:

struct NoDtor{
    NoDtor()= default; // 使用合成默认构造函数
    ~NoDtor() =delete;
}// 我们不能销毁 NoDtor 类型的对象
NoDtor nd;// 错误:NoDtor 的析构函数是删除的
NoDtor *p= new NoDtor();// 正确:但我们不能 delete p
delete p;// 错误:NoDtor 的析构函数是删除的
1
2
3
4
5
6
7
8

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

合成的拷贝控制成员可能是删除的

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

​ 本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。

private 拷贝控制

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

# 13.2 拷贝控制和资源管理

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

# 行为像值的类

行为向值的类需要:

  • 定义一个拷贝构造函数,完成 string 的拷贝,而不是拷贝指针
  • 定义一个析构函数来释放 string
  • 定义一个拷贝赋值运算符来释放对象当前的 string,并从右侧运算对象拷贝string
class HasPtr
{
public:
    HasPtr(const std::string &s = std::string()):
        ps(new std::string(s)), i(0) { }
    // 对ps 指向的 string,每个 HasPtr 对象都有自己的拷贝
    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
10
11
12
13
14
15

类值拷贝赋值运算符

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

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

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

    HasPtr& HasPtr::operator=(const HasPtr &rhs)
    {
        auto newp = new string(*rhs.ps);    // 拷贝底层 string
        delete ps;   // 释放旧内存
        ps = newp;   // 从右侧运算对象拷贝数据到本对象
        i = rhs.i;
        return *this;   // 返回本对象
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
  • 即使将一个对象赋予它自身,赋值运算符也能正确工作。

    // 这样编写赋值运算符是错误的!
    HasPtr& HasPtr::operator=(const HasPtr &rhs)
    {
        delete ps;   // 释放对象指向的 string
        // 如果 rhs 和*this 是同一个对象,我们就将从已释放的内存中拷贝数据!
        ps = new string(*(rhs.ps));
        i = rhs.i;
        return *this;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

    ​ 对于一个赋值运算符来说,正确工作是非常重要的,即使是将一个对象赋予它自身,也要能正确工作。一个好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象。

# 定义行为像指针的类

​ 有时我们希望直接管理资源。这种情况下,使用引用计数就要很用用了。

引用计数

工作方式如下

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外) 还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有-个对象共享状态,因此将计数器初始化为 1。
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
  • 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为 0,则析构函数释放状态。
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。

定义一个使用引用计数的类

class HasPtr
{
public:
    // 构造函数分配新的 string 和新的计数器,将计数器置为 1
    HasPtr(const std::string &s = std::string()):
        ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
    // 拷贝构造函数拷贝所有三个数据成员,并递增计数器
    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; // 用来记录有多少个对象共享*ps 的成员
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

类指针的拷贝成员“篡改”引用计数

​ 析构函数释放内存前应该判断是否还有其他对象指向这块内存。

HasPtr::~HasPtr()
{
    if (--*use == 0)
    {   // 如果引用计数变为 0
        delete ps;   // 释放 string 内存
        delete use;  // 释放计数器内存
    }
}

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
    ++*rhs.use;    // 递增右侧运算对象的引用计数
    if (--*use == 0)
    {   // 然后递减本对象的引用计数
        delete ps; // 如果没有其他用户
        delete use; // 释放本对象分配的成员
    }
    ps = rhs.ps;    // 将数据从 rhs 拷贝到本对象
    i = rhs.i;
    use = rhs.use;
    return *this;   // 返回本对象
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 13.3 交换操作

编写我们自己的 swap函数

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

class HasPtr
{
    friend void swap(HasPtr&, HasPtr&);
    // 其他函数定义
};

inline void swap(HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap(lhs.ps, rhs.ps);   // 交换指针,而不是 string 数据
    swap(lhs.i, rhs.i);     // 交换int 成员
}
1
2
3
4
5
6
7
8
9
10
11
12

swap 函数应该调用 swap,而不是std::swap

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

void swap(Foo &lhs, Foo &rhs)
{
    // 错误:这个函数使用了标准库版本的 swap,而不是 HasPtr 版本
    std::swap(lhs.h, rhs.h);
    // 交换类型 Foo的其他成员
}

void swap(Foo &lhs, Foo &rhs)
{
    using std::swap;
    swap(lhs.h, rhs.h);  // 使用 HasPtr 版本的 swap
    // 交换类型 Foo 的其他成员
}
1
2
3
4
5
6
7
8
9
10
11
12
13

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

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

在赋值运算符中使用swap

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

// 注意 rhs 是按值传递的,意味着 HasPtr 的拷贝构造函数
// 将右侧运算对象中的 string 拷贝到 rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
    // 交换左侧运算对象和局部变量 rhs 的内容
    swap(*this, rhs);   // rhs 现在指向本对象曾经使用的内存
    return *this;       // rhs 被销毁,从而 delete了rhs 中的指针
}
1
2
3
4
5
6
7
8

​ 使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

# 13.4 拷贝控制示例

​ 虽然通常来说分配资源的类更需要拷贝控制,但资源管理并不是一个类需要定义自己的拷贝控制成员的唯一原因。一些类也需要拷贝控制成员的帮助来进行簿记工作或其他操作。

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

# 13.5 动态内存管理类

​ 某些类需要在运行时分配可变大小的内存空间。这种类通常可以(并且如果它们确实可以的话,一般应该)使用标准库容器来保存它们的数据。但是,这一策略并不是对每个类都适用;某些类需要自己进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。

​ 移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象中。

# 13.6 对象移动

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

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

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

# 右值引用

​ 为了支持移动操作,C++11引入了右值引用类型。右值引用就是必须绑定到右值的引用。可以通过&&来获得右值引用。右值引用有一个重要的性质一只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

​ 为了与右值引用区分开来,我们可以称常规引用为左值引用 ,我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。

int i = 42;
int &r = i;         // 正确:r引用i
int &&rr = i;       // 错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42;   // 错误:i*42是一个右值
const int &r3 = i * 42;    // 正确:我们可以将一个 const 的引用绑定到一个右值上
int &&rr2 = i * 42;        // 正确:将rr2绑定到乘法结果上
1
2
3
4
5
6

左值持久;右值短暂

​ 考察左值和右值表达式的列表,两者相互区别之处就很明显了:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

​ 由于右值引用只能绑定到临时对象,我们得知

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

​ 右值引用指向将要被销毁的对象。因此我们可以从绑定到右值引用的对象“窃取”状态。

变量是左值

​ 变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。

int &&rr1 = 42;     // 正确:字面常量是右值
int &&rr2 = rr1;    // 错误:表达式 rr1是左值!
1
2

标准库move函数

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

int &&rr3 = std::move(rr1); //右值引用 rr3 绑定到左值 rr1
1

​ 我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。

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

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

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

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

移动操作、标准库容器和异常

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

class StrVec
{
public:
    StrVec(StrVec&&) noexcept;  // 移动构造函数
    // 其他成员的定义,如前
};

StrVec::StrVec(StrVec &&s) noexcept : /* 成员初始化器 */
{ /* 构造函数体 */ }
1
2
3
4
5
6
7
8
9

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

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

移动赋值运算符

​ 移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为 noexcept。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值:

StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
    // 直接检测自赋值
    if (this != &rhs)
    {
        free();     // 释放已有元素
        elements = rhs.elements;    // 从 rhs 接管资源
        first_free = rhs.first_free;
        cap = rhs.cap;
        // 将 rhs 置于可析构状态
        rhs.elements = rhs.first_free = rhs.cap = nullptr;
    }
    return *this;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

移后源对象必须可析构

​ 在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。

合成的移动操作

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

// 编译器会为X和hasx 合成移动操作
struct X
{
    int i;   // 内置类型可以移动
    std::string s;   // string 定义了自己的移动操作
};

struct hasX
{
    X mem; // X 有合成的移动操作
};

X x, x2 = std::move(x);         // 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx);   // 使用合成的移动构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

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

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

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

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

class Foo
{
public:
    Foo() = default;
    Foo(const Foo&);    // 拷贝构造系教
    // 其他成员定义,但 Foo 未定义移动构造函数
};

Foo x;
Foo y(x);   // 拷贝构造函数;x是一个左值
Foo z(std::move(x));    // 拷贝构造函数,因为未定义移动构造函数
1
2
3
4
5
6
7
8
9
10
11

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

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

// 赋值运算符既是移动赋值运算符,也是拷贝赋值运算
HasPtr& operator=(HasPtr rhs)
{
    swap(*this, rhs);
    return *this;
}

hp = hp2;   // hp2 是一个左值;hp2 通过拷贝构造函数来拷贝
hp = std::move(hp2);    // 移动构造函数移动 hp2
1
2
3
4
5
6
7
8
9

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

Message类的移动操作

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

Message& Message::operator=(Message &&rhs)
{
    if (this != &rhs) {// 直接检查自赋值情况
        remove from Folders();
        contents = std::move(rhs.contents); // 移动赋值运算符
        move Folders(&rhs); // 重置 Folders 指向本 Message
    }
	return *this;
}
    
1
2
3
4
5
6
7
8
9
10

移动迭代器

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

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

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

uninitialized copy:
void StrVec::reallocate()
{
    // 分配大小两倍于当前规模的内存空间
    auto newcapacity = size() ? 2 *size() :1;
    auto first = alloc.allocate(newcapacity);
    // 移动元素
    auto last = uninitialized copy(make move iterator(begin()),
    							make move iterator(end()),
    							first);
    free();//释放旧空间
    elements = first;// 更新指针
    first free = last;
    cap = elements +newcapacity;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 右值引用和成员函数

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

void push_back(const X&);   // 拷贝:绑定到任意类型的 X
void push_back(X&&);        // 移动:只能绑定到类型X的可修改的右值
1
2

右值和左值引用成员函数

​ 有时可以对右值赋值:

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

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

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

class Foo
{
public:
    Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值
    // Foo 的其他参数
};

Foo &Foo::operator=(const Foo &rhs) &
{
    // 执行将 rhs 赋予本对象所需的工作
    return *this;
}
1
2
3
4
5
6
7
8
9
10
11
12

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

class Foo
{
public:
    Foo someMem() & const;      // 错误:const 限定符必须在前
    Foo anotherMem() const &;   // 正确:const 限定符在前
};
1
2
3
4
5
6

重载和引用函数

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

class Foo
{
public:
    Foo sorted() &&;        // 可用于可改变的右值
    Foo sorted() const &;   // 可用于任何类型的 Foo
};

retVal().sorted();   // retVal()是一个右值,调用 Foo::sorted() &&
retFoo().sorted();   // retFoo()是一个左值,调用 Foo::sorted() const &
1
2
3
4
5
6
7
8
9

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

class Foo
{
public:
    Foo sorted() &&;
    Foo sorted() const;    // 错误:必须加上引用限定符
    // Comp 是函数类型的类型别名
    // 此函数类型可以用来比较 int 值
    using Comp = bool(const int&, const int&);
    Foo sorted(Comp*);  // 正确:不同的参数列表
};
1
2
3
4
5
6
7
8
9
10

# 第14章 重载运算与类型转换

# 14.1 基本概念

​ 重载的运算符是具有特殊名字的函数,它们的名字由关键字operator和其后要定义的运算符号组成。

​ 重载运算符函数的参数数量和该运算符作用的运算对象数量一样多。对于二元运算符来说,左侧运算对象传递给第一个参数,右侧运算对象传递给第二个参数。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参。

​ 如果一个运算符函数是类的成员函数,则它的第一个运算对象会绑定到隐式的this指针上。因此成员运算符函数的显式参数数量比运算对象的数量少一个。

​ 当运算符作用于内置类型的运算对象时,无法改变该运算符的含义。

​ 只能重载大多数已有的运算符,无权声明新的运算符号。

  • 可以被重载的运算符:

    +     -       *       /       %       ^
    &     |       ~       !       ,       =
    <     >       <=      >=      ++      --
    <<    >>      ==      !=      &&      ||
    +=    -=      /=      %=      ^=      &=
    |=    *=      <<=     >>=     []      ()
    ->    ->*     new     new []  delete  delete []
    
    1
    2
    3
    4
    5
    6
    7
  • 不能被重载的运算符:

    ::    .*      .       ?:
    
    1

直接调用一个重载的运算符函数

​ 重载运算符的优先级和结合律与对应的内置运算符一致。

​ 可以像调用普通函数一样直接调用运算符函数。

// 一个非成员运算符函数的等价调用
data1 + data2;              // 普通的表达式
operator+(data1, data2);    // 等价的函数调用
data1 += data2;             // 基于“调用”的表达式
data1.operator+=(data2);    // 对成员运算符函数的等价调用
1
2
3
4
5

某些运算符不应该被重载

​ 通常情况下,不应该重载逗号,、取地址&、逻辑与&&和逻辑或||运算符。

使用与内置类型一致的含义

​ 建议只有当操作的含义对于用户来说清晰明了时才使用重载运算符,重载运算符的返回类型也应该与其内置版本的返回类型兼容。

赋值和复合赋值运算符

​ 如果类中含有算术运算符或位运算符,则最好也提供对应的复合赋值运算符。

选择作为成员或者非成员

​ 把运算符定义为成员函数时,它的左侧运算对象必须是运算符所属类型的对象。

string s = "world";
string t = s + "!";     // 正确:我们能把一个const char*加到一个 string 对象中
string u = "hi" + s;    // 如果+是 string 的成员,则产生错误
1
2
3

​ 如何选择将运算符定义为成员函数还是普通函数:

  • 赋值=、下标[]、调用()和成员访问箭头->运算符必须是成员函数。
  • 复合赋值运算符一般是成员函数,但并非必须。
  • 改变对象状态或者与给定类型密切相关的运算符,如递增、递减、解引用运算符,通常是成员函数。
  • 具有对称性的运算符可能转换任意一端的运算对象,如算术、相等性、关系和位运算符,通常是普通函数。

# 14.2 输入和输出运算符

# 重载输出运算符<<

​ 通常情况下,输出运算符的第一个形参是ostream类型的普通引用,第二个形参是要打印类型的常量引用,返回值是它的ostream形参。

Sales_data 的输出运算符

ostream &operator<<(ostream &os, const Sales_data &item)
{
    os << item.isbn() << " " << item.units_sold << " "
        << item.revenue << " " << item.avg_price();
    return os;
}
1
2
3
4
5
6

输出运算符应该尽量减少格式化操作

​ 通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。

输入输出运算符必须是非成员函数

​ 输入输出运算符必须是非成员函数。而由于IO操作通常需要读写类的非公有数据,所以输入输出运算符一般被声明为友元。

# 重载输人运算符>>

​ 通常情况下,输入运算符的第一个形参是要读取的流的普通引用,第二个形参是要读入的目的对象的普通引用,返回值是它的第一个形参。

Sales_data 的输入运算符

istream &operator>>(istream &is, Sales_data &item)
{
    double price;   // 不需要初始化,因为我们将先读入数据到 price,之后才使用它
    is >> item.bookNo >> item.units_sold >> price;
    if (is)    // 检查输入是否成功
        item.revenue = item.units_sold * price;
    else
        item = Sales_data();    // 输入失败:对象被赋予默认的状态
    return is;
}
1
2
3
4
5
6
7
8
9
10

​ 输入运算符必须处理输入失败的情况,而输出运算符不需要。

输入时的错误

​ 以下情况可能导致读取操作失败:

  • 读取了错误类型的数据。
  • 读取操作到达文件末尾。
  • 遇到输入流的其他错误。

​ 当读取操作发生错误时,输入操作符应该负责从错误状态中恢复。

​ 如果输入的数据不符合规定的格式,即使从技术上看IO操作是成功的,输入运算符也应该设置流的条件状态以标示出失败信息。通常情况下,输入运算符只设置failbit状态。eofbitbadbit等错误最好由IO标准库自己标示。

# 14.3 算术和关系运算符

​ 通常情况下,算术和关系运算符应该定义为非成员函数,以便两侧的运算对象进行转换。其次,由于这些运算符一般不会改变运算对象的状态,所以形参都是常量引用。

​ 算术运算符通常会计算它的两个运算对象并得到一个新值,这个值通常存储在一个局部变量内,操作完成后返回该局部变量的副本作为结果(返回类型建议设置为原对象的const类型)。

// 假设两个对象指向同一本书
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
{
    Sales_data sum = lhs;   // 把lhs的数据成员拷贝给 sum
    sum += rhs;     // 将 rhs 加到 sum中
    return sum;
}
1
2
3
4
5
6
7

​ 如果类定义了算术运算符,则通常也会定义对应的复合赋值运算符,此时最有效的方式是使用复合赋值来实现算术运算符。

# 相等运算符

相等运算符设计准则:

  • 如果类在逻辑上有相等性的含义,则应该定义operator==而非一个普通的命名函数。这样做便于使用标准库容器和算法,也更容易记忆。

  • 通常情况下,operator==应该具有传递性。

  • 如果类定义了operator==,则也应该定义operator!=

  • operator==operator!=中的一个应该把具体工作委托给另一个。

    bool operator==(const Sales_data &lhs, const Sales_data &rhs)
    {
        return lhs.isbn() == rhs.isbn() &&
            lhs.units_sold == rhs.units_sold &&
            lhs.revenue == rhs.revenue;
    }
    
    bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
    {
        return !(lhs == rhs);
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

# 关系运算符

​ 定义了相等运算符的类通常也会定义关系运算符。因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较实用。

​ 关系运算符设计准则:

  • 定义顺序关系,令其与关联容器中对关键字的要求保持一致。
  • 如果类定义了operator==,则关系运算符的定义应该与operator==保持一致。特别是,如果两个对象是不相等的,那么其中一个对象应该小于另一个对象。
  • 只有存在唯一一种逻辑可靠的小于关系时,才应该考虑为类定义operator<

# 14.4 赋值运算符

​ 为了与内置类型的赋值运算符保持一致(也与我们已经定义的拷贝赋值和移动赋值运算一致),这个新的赋值运算符将返回其左侧运算对象的引用:

StrVec &StrVec::operator=(initializer_list<string> il)
{
    // alloc n copy 分配内存空间并从给定范围内拷贝元素
    auto data = alloc_n_copy(il.begin(), il.end());
    free();     // 销毁对象中的元素并释放内存空间
    elements = data.first;      // 更新数据成员使其指向新空间
    space
    first_free = cap = data.second;
    return *this;
}

1
2
3
4
5
6
7
8
9
10
11

​ 我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数。

复合赋值运算符

// 作为成员的二元运算符:左侧运算对象绑定到隐式的 this 指针
// 假定两个对象表示的是同一本书
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
    units_sold += rhs.units_sold;
    revenue += rhs.revenue;
    return *this;
}
1
2
3
4
5
6
7
8

​ 赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做这两类运算符都应该返回左侧运算对象的引用。

# 14.5 下标运算符

​ 下标运算符必须定义为成员函数。

​ 类通常会定义两个版本的下标运算符:一个返回普通引用,另一个是类的常量成员并返回常量引用。

class StrVec
{
public:
    std::string& operator[](std::size_t n)
    { return elements[n]; }
    const std::string& operator[](std::size_t n) const
    { return elements[n]; }

private:
    std::string *elements;  // 指向数组首元素的指针
}
1
2
3
4
5
6
7
8
9
10
11

# 14.6 递增递减运算符

​ 定义递增和递减运算符的类应该同时定义前置和后置版本,这些运算符通常定义为成员函数。

定义前置递增/递减运算符

​ 为了与内置操作保持一致,前置递增或递减运算符应该返回运算后对象的引用。

// 前置版本:返回递增/递减对象的引用
StrBlobPtr& StrBlobPtr::operator++()
{
    ++curr;     // 将 curr 在当前状态下向前移动一个元素
    return *this;
}
1
2
3
4
5
6

区分前置和后置运算符

​ 后置递增或递减运算符接受一个额外的(不被使用)int类型形参,该形参的唯一作用就是区分运算符的前置和后置版本。

class StrBlobPtr
{
public:
    // 递增和递减运算符
    StrBlobPtr& operator++();    // 前置运算符
    StrBlobPtr& operator--();
    StrBlobPtr operator++(int);  // 后置运算符
    StrBlobPtr operator--(int);
};
1
2
3
4
5
6
7
8
9

​ 为了与内置操作保持一致,后置递增或递减运算符应该返回运算前对象的原值(返回类型建议设置为原对象的const类型)。

StrBlobPtr StrBlobPtr::operator++(int)
{
    StrBlobPtr ret = *this;    // 记录当前的值
    ++*this;      // 向前移动一个元素,前置++需要检查递增的有效性
    return ret;   // 返回之前记录的状态
}
1
2
3
4
5
6

显式地调用后置运算符

​ 如果想通过函数调用的方式使用后置递增或递减运算符,则必须为它的整型参数传递一个值。

StrBlobPtr p(a1);   // p指向al中的vector
p.operator++(0);    // 调用后置版本的 operator++
p.operator++();     // 调用前置版本的 operator++
1
2
3

# 14.7 成员访问运算符

​ 箭头运算符必须定义为成员函数,解引用运算符通常也是如此。

​ 重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的类的对象。

class StrBlobPtr
{
public:
    std::string& operator*() const
    {
        return (*p)[curr];   // (*p)是对象所指的 vector
    }
    std::string* operator->() const
    {   // 将实际工作委托给解引用运算符
        return & this->operator*();
    }
};
1
2
3
4
5
6
7
8
9
10
11
12

对箭头运算符返回值的限定

​ 对于形如point->mem的表达式来说,point必须是指向类对象的指针或者是一个重载了operator->的类的对象。point类型不同,point->mem的含义也不同。

  • 如果point是指针,则调用内置箭头运算符,表达式等价于(*point).mem
  • 如果point是重载了operator->的类的对象,则使用point.operator->()的结果来获取mem,表达式等价于(point.operator->())->mem。其中,如果该结果是一个指针,则执行内置操作,否则重复调用当前操作。