《C++ Primer》3. 类设计者的工具

拷贝控制

拷贝控制操作:显式或隐式地指定类的对象拷贝、移动、赋值和销毁时做什么

具体有:

  • 拷贝构造函数、拷贝赋值函数
  • 移动构造函数、移动赋值函数
  • 析构函数

拷贝、赋值与销毁

拷贝构造函数通常不应该是 explicit

与默认构造函数不同,即使定义了其他构造函数,编译器依然会为我们提供一个合成拷贝构造函数

编译器可以跳过拷贝或移动构造函数,直接创建对象,如:

string null_book = "9-999-99999-9";

可能被编译器优化为:

string null_book("9-999-99999-9");

即使编译器可以这么干,但是我们仍然必须保证拷贝或者移动构造函数必须可以访问

合成拷贝赋值运算符、合成析构函数(为空)

  • 需要析构函数的类也需要拷贝和赋值操作
  • 需要拷贝操作的类也需要赋值操作,反之亦然

= default 可以放在 cpp 文件里面,但是 = delete 不行,它只能放在类声明里面(因为编译器用它来报错)

= delete 也可以用来引导函数匹配过程

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

  • 如果类中有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被删除
  • 如果类中有数据成员的析构函数被删除,那么就不提供合成默认和拷贝构造函数
  • 如果含有引用,那么不提供合成默认构造函数
  • 如果含有 const 成员或者含有引用,那么不提供合成拷贝赋值运算符

拷贝控制和资源管理

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

首先必须确定拷贝语义,一般有两种选择:

  • 类的行为像一个值:对象有自己的状态,且不同对象状态独立,例如 string
  • 类的行为像一个指针:对象之间共享状态,例如 shared_ptr

通常需要实现一个 swap 函数

对象移动

旧版本 C++ 中,容器保存的类必须可拷贝,然而在新标准中,容器可以保存不可拷贝的类型,只要它们能被移动就行

右值引用只能绑定到一个将要销毁的对象

int i = 42;

int & r = i;                 // 正确
int && rr = i;               // 错误:右值引用不能引用左值

int & r2 = i * 42;           // 错误:左值引用不能引用右值(表达式的结果)
const int & r3 = i * 42;     // 正确:const 引用可以引用右值
int && rr2 = i * 42;         // 正确

注意,我们可以让一个 const 左值引用来引用右值表达式,这样是没问题的,因为我们不会修改这个 const

但是我们可以使用 const_cast 去掉这个 const

左值和右值区别:

  • 左值持久:左值有持久的状态
  • 右值短暂:一般都是临时对象,并且没有其他用户,我们就能接管它

单个变量的表达式是左值

vector 容器、移动与 noexcept

最好使用 noexcept 来通知标准库,让它不要做额外的工作,例如,对于 vector 来说,如果移动构造函数可能抛出异常,那么它在扩容时会使用拷贝构造函数,原因是:

  • 如果 vector 使用移动构造函数,且在移动了一部分之后抛出了异常,会出现问题,旧空间中的移动源元素已经被改变了,而新空间未构造的元素可能尚不存在,这种情况下 vector 并不能满足自身保持不变的要求
  • 而如果使用拷贝构造,那么完全可以停止拷贝,释放新空间并抛出异常

移动赋值运算符也是同样

移动赋值运算符必须正确处理自赋值!

移动后源对象必须可析构

如果一个类定义了拷贝构造、拷贝赋值或者析构函数,那么编译器就不会为它生成合成移动构造函数。只有当一个类并没有定义任何自己版本的拷贝控制成员,并且类的每个非 static 数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符

与拷贝操作不同,编译器不会隐式定义移动操作为 delete,只有当我们手动定义其 = delete 或者 = default 并且不能生成合成的移动操作时才会是 delete

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

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

移动迭代器:解引用运算符生成右值引用

引用限定符

用来限定赋值对象

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

Foo & Foo::operator=(const Foo & rhs) & {
    ...
}

引用限定符可以是 &&&,分别表示 this 可以指向一个左值或右值

可以同时使用 const 和引用限定,但是引用限定必须在 const 之后

注意:const & 和复合类型一样,可以匹配所有类型 的 Foo

如果有多个函数重载,且其中一个加上了引用限定,每个函数重载都必须加上引用限定:

class Foo {
public:
    Foo sorted() &&;
    Foo sorted() const;   // 错误:必须加上引用限定符
}

重载运算与类型转换

某些运算符不应该被重载:

  • &&||,如果重载它们,会丧失短路属性
  • 逗号运算符和取地址运算符,C++ 已经赋予了它们的特殊含义

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

输出运算符尽量减少格式化操作,输入运算符应该处理读取错误的情况

赋值运算符、下标运算符、函数调用运算符、类型转换运算符必须是成员函数

递增递减运算符:

  • 前置:C & operator++()C & operator--(),它们应该返回左值引用
  • 后置:C operator++(int)C operator--(int),它们应该返回右值(递增递减之前的值)

前置没传参,后置会传参

成员访问运算符

解引用运算符:C & operator*() const

箭头运算符:C * operator->() const,箭头运算符一般直接调用解引用运算符,然后取地址返回

对于形如 point->mem 这样的语句来说:

  • 如果 point 是指针,那么直接使用 (*point).mem
  • 否则,如果它重载了 operator->(),那么先调用这个,接着递归判断

函数调用运算符

标准库中提供的函数对象:

image-20250323154233049

function 类型

image-20250323154822517

类型转换运算符

使用 explicit 来强制显式转换

但是有例外:

  • ifwhiledo 语句的条件部分
  • for 语句的条件表达式
  • !||&& 运算符
  • 三元条件运算符 ?: 的条件表达式

避免过度使用类型转换运算符

避免有二义性的类型转换:

  • 不要为类定义相同的类型转换
  • 不要在类中定义两个及以上转换源或转换目标是算术类型的转换,让标准类型转换(指内置类型之间的转换)完成剩余的工作

在函数重载时,如果两个用户定义的类型转换都提供了可行的匹配,那么我们认为这些转换一样好,而不考虑需要的标准类型转换:

struct E {
    E(double);
};

struct C {
    C(int);
};

void manip2(const C &);
void manip2(const E &);

manip2(10);  // C 和 E 两个用户定义的类型转换都可以用在这里,所以它们一样好

上面代码会产生二义性

函数重载与运算符重载

运算符重载会有更多的候选函数,如:

a + b;

可能调用两种东西:

a.operator+(b);
operator+(a, b);

当我们对同一个类既提供了转换目标是算术类型的类型转换,又提供了重载的运算符,则会遇到重载运算符与内置运算符的二义性问题

面向对象程序设计

基类、派生类、虚函数、纯虚函数、抽象基类、虚析构

override 关键字标识派生类中的虚函数

如果声明了析构函数(无论它是否是默认的、= default、虚的)编译器就不会合成移动构造函数和移动赋值运算符

合成拷贝控制与继承

继承的构造函数(使用 using):

  • 和普通成员的 using 不一样,构造函数的 using 不会改变该构造函数的访问级别
  • 不会继承默认实参,而是会根据默认实参的个数来生成多个构造函数,例如 Type(int, int, int v = 1, int v2 = 2) 会生成三个构造函数
  • 默认、拷贝和移动构造函数不会被继承

容器与继承:继承体系的对象必须采用间接存储的方式

模板与泛型编程

参考 C++ Templates 的部分和 Effective Modern C++