C++之旅——类(续)
C++之旅——类(续)
上一篇中,我们聊了 C++ 中几种常见的类设计,以及虚函数、继承和资源管理等相关特性。这一篇我们继续看看类对象在初始化、赋值、拷贝和移动时的一些细节。
这些操作看起来基础,却直接决定了一个类是否好用,以及它能否正确管理自己的资源。
构造函数、析构函数、拷贝和移动操作并不是彼此独立的。如果一个类直接管理内存、锁等资源,就需要把这些操作放在一起考虑,让它们遵循一致的资源管理规则。一个类 X 可能涉及下面这些函数:
class X {
public:
X(Sometype); // “普通构造函数”:创建对象
X(); // 默认构造函数
X(const X&); // 拷贝构造函数
X(X&&); // 移动构造函数
X& operator=(const X&); // 拷贝赋值:用副本替换目标原有的值
X& operator=(X&&); // 移动赋值:接管资源,处理目标原有的资源
~X(); // 析构函数:清理
// ...
};
析构、拷贝构造、拷贝赋值、移动构造和移动赋值需要一起设计,这就是常说的五法则(Rule of Five)。这些函数不必全部手写:可以自行实现、使用 = default,或用 = delete 禁止。如果资源已经交给 vector、unique_ptr 等成员管理,通常可以直接依赖编译器生成的操作,也就是零法则(Rule of Zero)。
1. 初始化对象
1.1 构造函数
-
构造函数不仅用于初始化具名对象和动态分配的对象,也用于构造临时对象,以及实现到类类型的转换。
-
满足相应条件时,编译器会隐式声明默认构造、拷贝和移动等特殊成员函数。如果希望明确要求使用默认实现,可以写上
= default:class Y { public: Y(Sometype); Y(const Y&) = default; // 我确实想要默认拷贝构造函数 Y(Y&&) = default; // 也想要默认移动构造函数 // ... };这些函数的生成规则会相互影响。例如,声明了构造函数后,编译器就不会再隐式声明默认构造函数;声明了析构函数或拷贝操作后,不会再隐式声明移动操作;声明了移动操作后,隐式声明的拷贝操作会被定义为删除。
-
当类中有指针成员时,首先要弄清楚它是否拥有所指对象。如果拥有,默认拷贝只会复制地址,很可能导致重复释放,需要自行定义拷贝、移动语义,或者禁止相应操作;如果只是借用其他对象,复制指针也可能正是我们需要的行为。关键是把所有权表达清楚。
-
与
= default对应,= delete用来明确禁止某个操作。上一篇的自定义Vector就先禁用了拷贝,避免两个对象错误地拥有同一块数组。使用被删除的函数会导致编译错误。
= delete也能用于普通函数,并不限于特殊成员函数。
1.2 类型转换
能用单个实参调用、且没有声明为 explicit 的构造函数,可以用于从实参类型到类类型的隐式转换。这里也包括其他参数都有默认值的情况。例如,上一篇的 complex 提供了从 double 构造复数的方式。假定已经定义了接受两个 complex 的乘法运算符,就可以这样使用:
complex z1 = 3.14; // z1 变为 {3.14, 0.0}
complex z2 = z1 * 2; // z2 变为 z1 * {2.0,0} == {6.28,0.0}
这种隐式转换用在复数运算中很自然,但放到容器上就未必合适了:一个整数到底代表元素数量,还是某个元素的值?标准库 vector 的按数量构造函数使用了 explicit,避免把整数隐式转换成容器。我们也可以这样定义自己的 Vector:
class Vector {
public:
explicit Vector(int s); // 不允许从 int 到 Vector 的隐式转换
// ...
};
除非隐式转换本身就是接口的一部分,否则给这类构造函数加上 explicit,通常会让代码的意图更清楚。例如,Vector v(10); 可以正常构造对象,而 Vector v = 10; 则会被拒绝。
1.3 成员初始化器
有些成员在大多数情况下都有相同的初始值,可以直接在声明时写出来,这就是默认成员初始化器:
class complex {
double re = 0;
double im = 0; // 表示:两个 double,默认值为 0.0
public:
complex(double r, double i) : re{r}, im{i} {} // 从两个标量构造复数
complex(double r) : re{r} {} // 从一个标量构造复数
complex() {} // 默认复数:{0,0}
// ...
};
构造函数的成员初始化列表没有指定某个成员时,就会使用它的默认成员初始化器。比如,complex(double r) 只初始化 re,im 仍取默认值 0。这样既少写了重复代码,也不容易漏掉初始化。
2. 拷贝和移动对象
对象的拷贝和移动,常见于下面几种场景:
- 作为赋值操作的源
- 作为对象初始化器
- 作为按值传递的函数参数
- 作为函数返回值
- 构造异常对象或按值捕获异常
给已有对象赋值,使用的是拷贝或移动赋值运算符;创建新对象,则可能使用拷贝或移动构造函数。通过引用传参不会因此拷贝对象,而返回值等场景中的构造操作也可能被消除,后面会继续讨论。
2.1 拷贝
类对象的拷贝通常由两个成员负责:拷贝构造函数和拷贝赋值运算符。前者创建新对象,后者给已有对象赋值。
默认拷贝会依次拷贝基类部分和各个成员。对于 complex 这样的简单类型,它通常已经足够;如果成员本身是 string、vector 等类型,也会调用它们各自的拷贝操作。真正需要特别处理的,是像自定义 Vector 这样直接拥有裸指针所指资源的类。面向多态接口的类则通常不开放按值拷贝,以免发生对象切片。
假如 Vector 仍使用默认拷贝,执行 Vector v2 = v1; 时,只会复制 elem 的地址和 sz 的值。两个对象因此指向同一块数组,这就是这里所说的浅拷贝:

这显然不是我们想要的结果:不仅修改元素会相互影响,两个对象析构时还会重复释放数组。
上一篇先用 = delete 禁用了 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[0] 改为 3,v1[0] 仍然是 2:

给已经存在的 Vector 赋值,还需要拷贝赋值运算符:
Vector& Vector::operator=(const Vector& a) // 拷贝赋值
{
double* p = new double[a.sz];
for (int i = 0; i != a.sz; ++i)
p[i] = a.elem[i];
delete[] elem; // 删除旧元素
elem = p;
sz = a.sz;
return *this;
}
this是成员函数中预定义的名字,指向调用该成员函数的对象。
这里先分配、拷贝新数组,再释放旧数组,因此也能正确处理 v = v 这样的自赋值;如果分配失败,原对象的数据也不会受影响。
2.2 移动
拷贝语义解决了正确性问题,但对大型容器来说,逐个复制元素还是有些昂贵。传参时可以通过引用避免复制,可返回结果时,不能返回指向普通局部对象的引用:函数结束后,对象就已经销毁了,留下的只会是悬空引用。
比如,我们为 Vector 定义一个加法运算符,返回逐元素相加的结果:
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;
}
然后这样使用它:
void f(const Vector& x, const Vector& y, const Vector& z)
{
Vector r;
// ...
r = x + y + z;
// ...
}
x + y + z 会先计算 x + y,再把结果与 z 相加。如果结果传递都依赖深拷贝,大量元素就可能被反复复制。不过,这里编译器也可能对 return res; 进行具名返回值优化(NRVO),直接在结果位置构造对象,省掉一次拷贝或移动。
当构造无法被消除,或者需要像 r = ... 这样给已有对象赋值时,移动语义就派上用场了。对于即将结束使命的临时结果,我们更希望接管它的数组,而不是重新复制所有元素。
在 C++ 中,可以通过移动构造函数和移动赋值运算符表达这个意图。把前面的操作放在一起,Vector 的接口可以写成:
class Vector {
public:
Vector() : elem{nullptr}, sz{0} {} // 默认构造一个空 Vector
explicit Vector(int s) : elem{new double[s]{}}, sz{s} {}
~Vector() { delete[] elem; }
Vector(const Vector& a); // 拷贝构造函数
Vector& operator=(const Vector& a); // 拷贝赋值
Vector(Vector&& a) noexcept; // 移动构造函数
Vector& operator=(Vector&& a) noexcept; // 移动赋值
double& operator[](int i) { return elem[i]; }
const double& operator[](int i) const { return elem[i]; }
int size() const { return sz; }
private:
double* elem;
int sz;
};
默认构造的 Vector 是空容器;const 版本的下标运算符则让加法函数能够读取只读参数中的元素。
移动构造函数定义起来也很简单:
Vector::Vector(Vector&& a) noexcept
: elem{a.elem}, // “抓取” a 的元素
sz{a.sz}
{
a.elem = nullptr; // 现在 a 没有元素
a.sz = 0;
}
移动赋值还要先处理目标对象原有的资源:
Vector& Vector::operator=(Vector&& a) noexcept
{
if (this != &a) {
delete[] elem;
elem = a.elem;
sz = a.sz;
a.elem = nullptr;
a.sz = 0;
}
return *this;
}
Vector&&中的&&表示右值引用。右值常见于临时对象、按值返回对象的函数调用,以及std::move的结果。局部变量res的名字通常是左值表达式,但在return res;这样的返回场景中,可以触发隐式移动。- 这里的移动操作使用非
const引用,因为接管数组后还需要修改源对象,把它的指针清空。noexcept表示这些操作不抛出异常,也有利于标准库容器在迁移元素时选择移动。 - 移动后的对象仍需能正常析构,也应支持重新赋值。这个
Vector把源对象恢复为空容器状态,因此还可以正常调用size()等操作。 std::move()本身不搬运资源,它把表达式转换成右值形式,让后续操作有机会调用移动构造或移动赋值。真正接管资源的,是我们上面定义的函数。
3. 资源管理
把构造、析构、拷贝和移动配合好,就能控制对象所拥有资源的生命周期。对于 Vector 这样的资源句柄,移动只需要转交指针和大小,不必逐个复制元素,也就能低成本地把资源交给另一个对象。
内存并不是唯一的资源。凡是需要获取,并在用完后释放的东西,都可以看作资源,例如锁、套接字、文件句柄和线程句柄。资源管理不仅要避免泄漏,也要及时释放:让不再需要的文件、内存或锁一直占着位置,同样会给程序带来负担。
C++ 的常见做法,是把资源交给对象管理,让资源的释放跟随所有者的析构,这就是 RAII(资源获取即初始化)。局部对象离开作用域时会自动析构,因异常进行栈展开时也一样。需要转交资源时,可以使用移动语义或 unique_ptr;确实需要共享所有权时,则可以使用 shared_ptr。
标准库中到处都能看到 RAII:string、vector、map 等管理内存,ifstream、ofstream 管理文件,lock_guard、unique_lock 管理锁,unique_ptr 和 shared_ptr 则管理动态对象的生命周期。
线程这里要留意一个区别:std::thread 如果在 joinable() 为真时析构,会调用 std::terminate(),需要在此前调用 join() 或 detach();C++20 的 std::jthread 则会在析构时自动请求停止并等待线程结束。
参考资料
- Bjarne Stroustrup:《A Tour of C++》。
浙公网安备 33010602011771号