之前已经了解了,C++中的对象具有类型和地址。从构建开始到析构结束期间,对象还占据着一块存储区域。现在,将更详细地研究这些基本属性,以理解其如何影响编写程序的方式。
C++其中一个优势(也是复杂的原因)是,开发者可对对象生命周期进行精确控制。通常,自动对象会在其作用域结束时,以明确的顺序析构。静态(全局)对象则在程序终止时,按某种相对明确的顺序析构(同一文件内的静态对象析构顺序是明确的,但不同文件间的静态对象析构顺序更为复杂)。动态分配的对象则在“显式指定时”析构(其中包含诸多细节),可以通过以下(非常)简单的程序来了解对象生命周期的特性:
#include <string>
#include <iostream>
#include <format>
struct X {
std::string s;
X(std::string_view s) : s{ s } {
std::cout << std::format("X::X({})\n", s);
}
~X() {
std::cout << std::format("~X::X() for {}\n", s);
}
};
X glob { "glob" };
void g() {
X xg{ "g()" };
}
int main() {
X *p0 = new X{ "p0" };
[[maybe_unused]] X *p1 = new X{ "p1" }; // 将会“泄露”
X xmain{ "main()" };
g();
delete p0;
// 不好,忘记删除p1了
}
执行后,程序将输出以下内容:
X::X(glob)
X::X(p0)
X::X(p1)
X::X(main())
X::X(g())
~X::X() for g()
~X::X() for p0
~X::X() for main()
~X::X() for glob
构造函数与析构函数数量不匹配,表明程序中存在错误。这个示例中,通过operator new手动创建了一个对象(由p1指向),但后续却未销毁该对象。
对于不熟悉C++的开发者来说,指针(pointer)与指向对象(pointee)的区别起来比较困难。程序中,p0和p1都会在到达作用域末尾时(即main()函数的右花括号处)自动销毁,正如xmain对象那样。但由于p0和p1指向的是动态分配的对象,这些指向对象必须显式销毁 —— 对p0做了正确处理,却(为了示例效果)故意没有处理p1。
那么p1指向的对象会怎样?手动构造却未手动析构,最终成为内存中无法访问的"幽灵对象"。这种现象就是“内存泄漏” —— 程序分配了内存却未释放。
比泄漏p1所指X对象的内存更严重:该对象的析构函数永远不会调用,这可能导致各类资源泄漏(文件未关闭、数据库连接未断开、系统句柄未释放等)。在第4章中,我们将探讨如何避免此类情况,同时编写出简而雅的代码。
由于每个对象都占用存储空间,对象所占空间大小是C++类型的一个重要(尽管属于底层)属性:
class B; // 前向声明:将来某个时刻会定义一个类 B
// 这叫做类的前向声明,告诉编译器“B 是一个类类型”
void f(B*); // 没问题,我们知道 B 是一个类类型,
// 即使现在还不知道它的具体细节。
// 因为所有对象的指针大小都是一样的,
// 所以可以声明一个指向 B 的指针参数。
// class D : B {}; // 错误!要定义类 D 继承自 B,
// 必须知道 B 的完整定义,
// 因为 D 的对象布局中包含了 B 的部分,
// 我们需要知道 B 有多大、包含哪些内容。
例子中,定义D类会导致编译失败。因为要创建一个D对象,编译器需要为其预留足够的空间,但D同时也是一个B对象。如果不知道B对象的大小,就无法确定D对象的大小。
对象(或类型)的大小可以通过sizeof操作符获取。该操作符返回一个编译期确定的非零无符号整数值,表示存储对象所需的字节数:
char c;
// 根据标准文档的表述,
// 一个 char 类型的变量恰好占用 1 字节的存储空间
static_assert(sizeof c == 1); // 对于对象,sizeof 后面不需要括号
// 这里断言 c 的大小为 1 字节
static_assert(sizeof(c) == 1); // ... 也可以加上括号使用
static_assert(sizeof(char) == 1); // 对于类型,sizeof 后面必须加括号
// 这里断言 char 类型的大小为 1 字节
struct Tiny {};
// 在 C++ 中,所有类型都必须占用 非零 的存储空间
// 即使像 Tiny 这样的空结构体(没有成员变量),也会占用一定的内存
static_assert(sizeof(Tiny) > 0);
前面的例子中,Tiny类是空的,没有数据成员。即使一个类包含成员函数,仍然可能为空。这种仅包含成员函数的空类在C++中非常常见。
C++中的对象始终占用至少1字节的存储空间,即使是像Tiny这样的空类也不例外。如果一个对象的大小为零,可能会与其相邻对象位于相同的内存地址,这会导致逻辑上的问题。
与其他语言不同,C++并未标准化基本类型的大小。例如,sizeof(int)的值可能因编译器和平台的不同而有所变化,但C++仍然对对象的大小设定了一些规则:
对于 signed char、unsigned char 和 char 类型的对象,sizeof 操作符返回的值均为 1,sizeof(std::byte) 同样如此,这些类型均可用于表示单个字节。
所有平台上,sizeof(short) >= sizeof(char) 和 sizeof(int) >= sizeof(short) 均成立。有时,sizeof(char) 和 sizeof(int) 可能都为 1。关于基本类型的位宽(即值表示所占的比特数),C++ 标准仅规定了每种类型的最小宽度,具体可参考 wg21.link/tab:basic.fundamental.width。
如前所述,对于任何类型 T,sizeof(T) > 0 恒成立。在 C++ 中,不存在零大小的对象,即使是空类的对象也至少占用 1 字节。
结构体或类对象的大小,不能小于其数据成员的大小之和(但存在例外)。
最后一条规则需要进一步解释:
class X {};
class Y {
X x;
};
int main() {
static_assert(sizeof(X) > 0);
static_assert(sizeof(Y) == sizeof(X)); // <-- here
}
标记为 <-- here 的这一行可能令人费解。为什么当每个 Y 对象都包含一个 X 对象时,sizeof(Y) 会等于 sizeof(X)?请记住,即使 X 是空类,sizeof(X) 也大于 0,每个 C++ 对象都必须占据至少一个字节的存储空间。然而,对于 Y 这个非空类而言,由于其包含 x 数据成员,每个 Y 对象本身已经占据了存储空间,就没有必要再为该类的对象增加存储空间了。
现在,考虑以下情况:
class X {
char c;
};
class Y {
X x;
};
int main() {
static_assert(sizeof(X) == sizeof(char)); // <-- here
static_assert(sizeof(Y) == sizeof(X)); // <-- here too
}
同样的逻辑在此依然适用:类型X的对象与其唯一的数据成员(char类型)占据相同的存储空间,而类型Y的对象也与其唯一的数据成员(X类型)占据相同的存储空间。
继续深入探讨:
class X { };
class Y {
X x;
char c;
};
int main() {
static_assert(sizeof(Y) >= sizeof(char) + sizeof(X));
}
这正是我们先前提到的规则,只是在此针对特定类型进行了形式化表述。假设sizeof(X)等于1的概率极高,甚至可以合理预期sizeof(Y)将会等于sizeof(char)与sizeof(X)之和。
最后,来看看这个:
class X { };
class Y : X { // <-- 私有继承
char c;
};
int main() {
static_assert(sizeof(Y) == sizeof(char)); // <-- here
}
实际应用中,可以期待编译器会在单继承关系中进行优化。由于X在Y中的存在属于实现细节而非接口,本例采用了私有继承。虽然空基类优化同样适用于公有或保护继承,但私有继承能更好地体现“X作为Y的组成部分”这一封装特性。
自C++20起,若认为组合(composition)比继承更适合描述X与Y这样的类关系,可以使用[[no_unique_address]]属性标记数据成员。这会告知编译器:若该成员是空类对象,则不必在包含对象中为其分配存储空间。需要注意的是,编译器可以选择忽略属性标注,因此在编写依赖此特性的代码前,请务必确认编译器是否支持该项优化:
class X { };
class Y {
char c;
[[no_unique_address]] X x;
};
int main() {
static_assert(sizeof(X) > 0);
static_assert(sizeof(Y) == sizeof(char)); // <-- here
}
目前,所有的示例都非常简单,使用的类包含零个、一个或两个非常小的数据成员。但实际代码很少如此简单:
class X {
char c; // sizeof(char) == 1 通过定义
short s;
int n;
};
int main() {
static_assert(sizeof(short) == 2); // 我们进行假设...
static_assert(sizeof(int) == 4); // ... 再次假设
static_assert(
sizeof(X) >= sizeof(char)+sizeof(short)+sizeof(int)
);
}
假设前两个静态断言成立(不能保证),可得知sizeof(X)至少为7(其数据成员大小之和)。实际中,可能会发现sizeof(X)等于8。这初看可能令人惊讶,这就是内存对齐(alignment)的结果。
对象(或其类型)的对齐方式决定了该对象在内存中的存放位置。char类型的对齐值为1,因此char对象可以存放在任意内存地址(只要该内存可访问)。对于对齐值为2的类型(如short),对象只能存放在地址为2的倍数的位置。更一般地说,若类型的对齐值为n,则该类型的对象必须存放在地址为n的倍数的位置。对齐值必须是严格大于0的2的幂次方,违反此规则将导致未定义行为。当然,编译器不会让开发者陷入这种境地,但如果不够小心(特别是使用本书后续会介绍的一些技巧时),可能会平添烦恼。能力越大,责任越大。
C++提供了两个与对齐相关的操作符:
alignof操作符:用于获取类型T或其对象的自然对齐值(natural alignment)。
alignas操作符:允许开发者显式指定对象的对齐方式。这在内存操作技巧(如本书后续内容)或与特殊硬件交互(此处“特殊”可广义理解)时非常有用。当然,alignas只能合理增大类型的自然对齐值,而无法减小。
对于某些基本类型T,可以预期sizeof(T)等于alignof(T)的断言成立,但该断言并不适用于复合类型。例如以下情况:
class X {
char c;
short s;
int n;
};
int main() {
static_assert(sizeof(short) == alignof(short));
static_assert(sizeof(int) == alignof(int));
static_assert(sizeof(X) == 8); // 极有可能成立
static_assert(alignof(X) == alignof(int)); // 同样的情况
}
通常,复合类型的对齐值将与其数据成员中的最大对齐值(即数值最大的对齐要求)相匹配。对于类X而言,对齐要求最高的数据成员是int类型的n,X对象将按照alignof(int)的字节边界对齐。
那么,既然sizeof(short)==2且sizeof(int)==4,为何可以预期sizeof(X)==8的断言成立。来看看X类型对象(可能)的内存布局:
图中的每个方框代表内存中的一个字节,在c和n的第一个字节之间存在一个“?”标记。这是由于对齐要求导致的。如果alignof(short)==2,且alignof(int)==4,X对象唯一正确的内存布局需要满足:
并且,类中数据成员的声明顺序会影响该类对象的大小:
class X {
short s;
int n;
char c;
};
int main() {
static_assert(sizeof(short) == alignof(short));
static_assert(sizeof(int) == alignof(int));
static_assert(alignof(X) == alignof(int));
static_assert(sizeof(X) == 12); // 极有可能成立
}
这个现象令人惊讶,但事实也确实如此,值得深思。以这个例子来说,X类对象(可能)的内存布局如下所示:
现在,s和n之间的两个“?”标记已经容易理解,但末尾的三个“?”标记可能令人困惑。为什么要在一个对象的末尾添加填充字节?
答案与数组存储有关。因为数组元素在内存中为连续存储,所以必须确保数组中每个元素都正确对齐,X类对象末尾的填充字节保证了数组中某个X元素能够正确对齐,则下一个元素也会自动对齐。
了解对齐规则后,可以看到:仅仅改变类X中成员的声明顺序,就导致该类型每个对象的内存占用增加了50%。这不仅增加了程序的内存消耗,同时也会影响运行速度。用户代码可能依赖对象地址访问成员,所以C++编译器不能擅自重排数据成员,改变成员的相对位置可能会破坏现有代码,因此开发者必须谨慎设计内存布局。需要注意的是,减小对象体积并非布局设计的唯一考量 —— 特别是在多线程代码中(有时让两个对象保持距离,反而能优化缓存利用率),所以内存布局很重要,但不能简单粗暴地处理。
在C++这种操作实际对象的语言中,关于复制和移动这两个基本概念,需要特别说明几点。
C++将以下6个成员函数视为特殊成员函数,除非显式阻止,否则编译器会自动生成这些函数:
默认构造函数:这是6个中最不特殊的一个,仅当没有定义构造函数时才会生成
析构函数:在对象生命周期结束时调用
复制构造函数:使用同类型单个对象作为参数构造时调用
复制赋值操作符:用另一个对象的内容副本替换现有对象内容时调用
移动构造函数:从可移动对象的引用构造时调用。
可移动对象包括:
无法再引用的对象(如表达式求值的匿名结果)
函数返回的对象
使用std::move()显式标记为可移动的对象
移动赋值操作符:行为类似复制赋值,但参数是可移动对象时调用
当类型不显式管理资源时,通常无需编写这些函数,编译器生成的版本也完全符合需求。例如:
struct Point2D {
float x{}, y{};
}
这里,Point2D类型表示一个没有不变量的2D坐标(其x和y数据成员可以接受任何值)。由于为x和y设置了默认初始化器(初始化为0),因此默认构造的Point2D对象将表示坐标(0,0),而6个特殊成员函数的行为也都符合预期:
复制构造函数,会调用数据成员各自的复制构造函数
复制赋值操作符,会调用数据成员的复制赋值操作符
析构函数很简单
数据成员都是基本类型,移动操作会表现得像复制操作
如果决定添加一个参数化构造函数,以允许用户代码将x和y初始化为默认值以外的其他值,可以这样做。但代价是失去隐式生成的默认构造函数:
struct Point2D {
float x{}, y{};
Point2D(float x, float y) : x{ x }, y{ y } {
}
};
void oops() {
Point2D pt; // 无法通过编译,pt 没有默认构造函数
}
这个问题当然可以解决。第一种方法是显式编写默认构造函数的实现细节:
struct Point2D {
float x, y; // 不需要默认初始化
Point2D(float x, float y) : x{ x }, y{ y } {
}
Point2D() : x{ }, y{ } { // <-- here
}
};
void oops() {
Point2D pt; // Ok
}
另一种方法是,通过默认构造函数委托给参数化构造函数:
struct Point2D {
float x, y; // 不需要默认初始化
Point2D(float x, float y) : x{ x }, y{ y } {
}
Point2D() : Point2D{ 0, 0 } { // <-- here
}
};
void oops() {
Point2D pt; // Ok
}
最佳做法是明确告知编译器:虽然编写了其他构造函数,但希望保留默认行为:
struct Point2D {
float x{}, y{};
Point2D(float x, float y) : x{ x }, y{ y } {
}
Point2D() = default; // <-- here
};
void oops() {
Point2D pt; // Ok
}
最后这种写法通常能生成最优代码,当编译器明确理解开发者意图时(通过=default声明),它们最擅长用最小代价生成最佳结果。这里的=default明确表达了这样的意图:请按照没有干预时,本应有的方式进行处理。
虽然可为Point2D添加了参数化构造函数作为示例,但实际上对于这种聚合类型(aggregate)来说并非必要。聚合类型具有特殊的初始化支持机制,这与我们当前的演示重点无关。
聚合类型是指满足以下限制条件的类型:
没有用户声明或继承的构造函数
没有private修饰的非静态数据成员
没有虚基类
不需要维护不变量
编译器可以高效地初始化这种类型。
当一个类需要显式管理资源时,编译器默认生成的这些特殊成员函数通常无法满足需求。毕竟,编译器如何能理解特定场景下开发者的真实意图呢?假设尝试实现一个简易的字符串类(以下示例不完整):
#include <cstring> // std::strlen()
#include <algorithm> // std::copy()
class naive_string { // 过于简单的字符串类(实际用途有限)
char *p {}; // 元素指针(初始化为nullptr)
std::size_t nelems {}; // 元素数量(初始化为0)
public:
std::size_t size() const {
return nelems;
}
bool empty() const {
return size() == 0;
}
naive_string() = default; // 默认构造函数(创建空字符串)
naive_string(const char *s)
: nelems{ std::strlen(s) } {
p = new char[size() + 1]; // 分配内存(额外预留一个字符空间用于存储结尾的'\0')
std::copy(s, s + size(), p);
p[size()] = '\0';
}
// 字符索引访问(提供const和非const版本):
// const版本用于const naive_string对象
// 非const版本允许修改字符串元素
// 前置条件:n < size()
char operator[](std::size_t n) const { return p[n]; }
char& operator[](std::size_t n) { return p[n]; }
// ... 其他代码将在后续进行补充
}
这个类很基础,通过分配 size()+1 字节的内存来存储从指针 p 开始的字符序列,显然涉及显式的资源管理。因此,编译器提供的特殊成员函数对这个类来说并不适用。例如:
默认生成的复制构造函数会复制指针 p,这将导致两个指针(原指针和副本指针)共享同一块内存
默认生成的析构函数会销毁指针,但还需要释放指针所指内存以避免内存泄漏
这种情况下,需要实现“三法则”(Rule of Three),即手动编写:
析构函数
复制构造函数
复制赋值操作符
在C++11引入移动语义之前,这三者已足以正确实现资源管理。虽然从技术角度来说当前的类已足够使用,但移动语义能实现更高效的类型。现代C++中,再实现两个移动操作时,就形成了“五法则”(Rule of Five):
由于naive_string类型,通过指针p管理动态分配的数组资源,该类的析构函数实现将非常简单 —— 其职责仅限于释放p指向的内存块:
// ...
~naive_string() {
delete [] p;
}
// ...
需要注意两点:
无需检查p是否为nullptr(C++中delete nullptr;安全操作)
内存是通过new[]分配的(这些操作符的区别将在第7章详细说明),必须使用delete[]
复制构造函数在以下场景调用:当使用同类型的另一个对象作为参数构造naive_string对象时:
// ...
void f(naive_string); // 按值传递
void copy_construction_examples() {
naive_string s0{ "What a fine day" };
naive_string s1 = s0; // 构造 s1,这是 复制构造
naive_string s2(s0); // ...也是复制构造
naive_string s3{ s0 }; // ...同样属于复制构造
f(s0); // 由于是传值调用,这里也会触发复制构造
s1 = s0; // 这不是复制构造,因为 s1 已经存在,这是 复制赋值
}
对于naive_string类,正确的复制构造函数可以这样实现:
// ...
naive_string(const naive_string &other)
: p{ new char[other.size() + 1] },
nelems{ other.size() } {
std::copy(other.p, other.p + other.size(), p);
p[size()] = '\0';
}
// ...
复制赋值操作符有多种实现方式,但很多都存在复杂性或安全隐患。以下为错误示范(切勿这样实现!):
// ...
// 错误的复制赋值操作符(存在问题)
naive_string& operator=(const naive_string &other) {
// 首先,释放当前对象持有的内存
delete [] p;
// 然后,分配新内存块
p = new char[other.size() + 1]; // <-- 注意这一行(若 new 抛出异常,对象将处于无效状态)
// 复制数据内容
std::copy(other.p, other.p + other.size(), p);
// 调整大小并添加结尾的零终止符
nelems = other.size();
p[size()] = '\0';
return *this;
}
// ...
这个实现看似合理(尽管略显冗长),但审视执行内存分配的那行代码时,不禁要问:如果分配失败会发生什么?确实可能失败,当进程可用内存不足,而other.size()所需内存超过剩余资源时就会失败。C++默认情况下,operator new在分配失败时会抛出异常。这将导致复制赋值函数执行中断,使*this处于错误(且危险!)的状态:p指针非空,nelems非零,p却指向无效内存(即我们不再拥有的内存区域);若继续使用这些内存,将导致未定义行为。
可以编写更多代码来修复这个错误,但仍不建议像这样编写复制赋值操作符:
// ...
// 另一个错误的复制赋值操作符
naive_string& operator=(const naive_string &other) {
// 首先,分配新的内存块
char *q = new char[other.size() + 1];
// 然后释放当前对象持有的内存,并让p指向新内存块
delete [] p; // <-- 注意这一行
p = q;
// 复制数据内容
std::copy(other.p, other.p + other.size(), p);
// 调整大小并添加结尾的零终止符
nelems = other.size();
p[size()] = '\0';
return *this;
}
// ...
表面上看这更安全,在确定内存分配成功之前,不会尝试清理*this的现有状态,可能通过大多数测试 —— 直到有人设计出以下测试用例:
void test_self_assignment() {
naive_string s0 { "This is not going to end well..." };
s0 = s0; // 错误!
}
复制赋值操作符将表现极差。在分配了由q指向的大小合适的内存块后,会删除p指向的内容。这恰好也是other.p指向的内容,从而销毁了试图复制的实际源数据。随后的操作会读取程序不再拥有的内存,所以程序得行为将变得“无法捉摸”。
仍可以尝试修补这个问题,甚至使其正常工作,但要注意:
// ...
// 这个版本可以工作,但变得复杂了,
// 这表明我们的做法可能存在问题
naive_string& operator=(const naive_string &other) {
// 避免自赋值
if(this == &other) return *this;
// 然后执行以下步骤序列
char *q = new char[other.size() + 1];
delete [] p; // <-- 注意这一行
p = q;
std::copy(other.p, other.p + other.size(), p);
nelems = other.size();
p[size()] = '\0';
return *this;
}
// ...
这种修复实际上是一种性能劣化(pessimization),每个复制赋值操作都需要永远不会执行的 if 分支的开销。暴力解决问题的方式产出了复杂但能运行的代码(正确性但不直观),并且每编写一个资源管理类时都需要重新审视这种实现。
“pessimization” 通常作为 “optimization”(优化)的反义词使用,指导致程序行为效率低于应有水平的编程策略或技术。前述案例就是这种策略的典型例子:所有人都会为那个 if 语句引入的潜在分支付出代价,而它实际上仅适用于罕见且异常的情况 —— 本不该发生的情况。
当面临 “性能劣化” 的选择时,需要重新思考,是否一开始就选错了解决问题的角度。
幸运的是,C++中有一个惯用法,称为安全赋值惯用法(safe assignment idiom),也称为复制交换法(copy-and-swap)。其关键在于认识到赋值操作由两部分组成:一个是破坏性部分,负责清理目标对象(赋值左侧)当前拥有的状态;另一个是构造性部分,负责将源对象(赋值右侧)的状态复制到目标对象。破坏性部分通常等同于该类型的析构函数中的代码,而构造性部分通常等同于该类型的复制构造函数中的代码。
这种技术之所以会(非正式地称)为“复制交换法”,是因为它通常通过组合使用类型的复制构造函数、析构函数,以及一个swap()成员函数来实现,该成员函数会逐个交换成员变量:
// ...
void swap(naive_string &other) noexcept {
using std::swap; // 使标准swap函数可用
swap(p, other.p); // 交换数据成员
swap(nelems, other.nelems);
}
// 惯用的复制赋值
naive_string& operator=(const naive_string &other) {
naive_string { other }.swap(*this); // <-- 注意本行
return *this; // 就是这样!
}
// ...
这一惯用法极其实用,其具备异常安全性、简单性,且适用于几乎所有类型。其中完成所有工作的关键代码执行了三个步骤:
首先,通过类型的复制构造函数创建other的匿名副本。此时,若抛出异常,this仍保持原状不受影响。
其次,交换该匿名临时对象(包含我们想要赋予this的内容)与目标对象的内容(将不再需要的旧状态移至匿名临时对象)。
最后,匿名临时对象在表达式结束时自动销毁(因其匿名性),使*this最终持有other状态的副本。
该惯用法甚至能安全处理自赋值情况。虽然会产生一次多余的复制,但相比让每次调用都承担几乎无用的if分支判断,这种偶尔出现的无效复制代价更为合理。
swap()成员函数的开头有noexcept声明,后续会对此进行讨论。目前,只需明确该函数(因其交换的是基础类型)绝不会抛出异常。这一特性有助于后续实现的优化。
增强版的 naive_string 通过析构函数、复制构造函数和复制赋值操作符,现在已经能正确管理资源。还可以进一步优化,甚至更安全。
下面这个代码,可能有人想添加的非成员字符串连接操作符:
// 返回s0和s1拼接后的字符串
naive_string operator+(naive_string s0, naive_string s1);
该操作可能会这样使用:
naive_string make_message(naive_string name) {
naive_string s0{ "Hello "},
s1{ "!" };
return s0 + name + s1; // <-- 注意这行
}
return 语句后的表达式首先调用 operator+(),通过连接 s0 和 name 创建一个未命名的 naive_string 临时对象。接着,这个临时对象作为第一个参数传递给另一个 operator+(),该调用又会生成另一个未命名临时对象(由第一个临时对象与 s1 连接而成)。
基于当前的实现,每个临时对象都会引发以下开销:一次内存分配;一次缓冲区数据的复制;一次析构操作;以及其他成本。更糟糕的是,每次内存分配都可能抛出异常。
不过,它能正常工作。
自 C++11 起,可以通过移动语义显著提升这类代码的效率。除了刚才讨论的传统“三法则”函数外,还可以为 naive_string 这样的类添加移动构造函数和移动赋值操作符。当编译器操作那些已知不再使用的对象时,这些函数就会被隐式调用。例如:
// ...
return s0 + name + s1;
// ...
这可以转化为:
// ...
return (s0 + name) + s1;
// ^^^^^^^^^^^ <-- 匿名对象(之后不能再引用)
// ...
随后会转换为以下操作:
// ...
((s0 + name) + s1);
// ^^^^^^^^^^^^^^^^^^^ <-- 匿名对象 (同上)
// ...
仔细思考后会发现,复制操作的根本目的是保持源对象完好,以便后续可能再次使用。而那些没有命名的临时对象则不需要保持原状 —— 后续无法被引用。可以更激进地处理这类对象,直接移动(而非复制)它们的内容。标准要求遵循的规则是:被移动的对象应处于“有效但不确定”的状态。本质上,被移动后的对象必须满足:能够安全地被析构或重新赋值,且其不变量仍然成立。实际实现中,几乎等同于将被移动的对象重置为相当于默认状态的值。
以 naive_string 类型为例,移动构造函数可以这样实现:
// ...
naive_string(naive_string &&other) noexcept
: p{ std::move(other.p) },
nelems{ std::move(other.nelems) } {
other.p = nullptr;
other.nelems = 0;
}
// ...
特定情况下,可以省略std::move()调用(基础类型的移动操作等同于复制),但为了明确表达移动意图,在代码中显式标注可能更为规范。后续章节中会讨论std::move(),但std::move()本身并不执行移动操作,它只是向编译器标记某个对象可以移动 —— 其本质上这是一种类型转换。
关于这个移动构造函数,需要注意以下几点:
参数类型为naive_string&&,表示这是一个右值引用。右值(rvalue)通俗理解就是“可以出现在赋值操作符右侧的内容”。
和swap()一样,标记为noexcept,表明执行过程不会抛出异常。
它实质上是将源对象other的状态转移给正在构造的对象*this。转移完成后,会将other保持有效状态(等同于默认构造的naive_string对象),这符合标准的要求。
若想写得更加简洁,可以使用<utility>头文件中的std::exchange()函数。例如:
a = std::exchange(b, c);
这个表达式的含义是“将b的值赋给a,同时用c的值替换掉b原来的值”。实际代码中,这类操作序列非常常见。借助这个函数,移动构造函数可以改写为:
// ...
naive_string(naive_string &&other) noexcept
: p{ std::exchange(other.p, nullptr) },
nelems{ std::exchange(other.nelems, 0) } {
}
// ...
这种写法是C++的惯用表达方式,在某些情况下还能带来有趣的优化效果。
那么,移动赋值操作符呢?可以借鉴之前讨论过的复制赋值操作符惯用法:
// 惯用的复制赋值
naive_string& operator=(naive_string &&other) noexcept {
naive_string { std::move(other) }.swap(*this);
return *this;
}
遵循复制赋值操作符的设计思路,将移动赋值操作符实现为swap()、析构函数和移动构造函数的组合。这两种惯用法背后的核心逻辑完全一致。