当我们需要把 std::vector
传给某个函数时,会采用(const)引用传参,以避免对数组数据执行开销高昂的拷贝操作。
因此,当你得知“按值返回 std::vector
居然没有问题”时,大概率会感到震惊。
啥?
复制语义
当我们需要把 std::vector
传给某个函数时,会采用(const)引用传参,以避免对数组数据执行开销高昂的拷贝操作。
因此,当你得知“按值返回 std::vector
居然没有问题”时,大概率会感到震惊。
啥——?
复制语义
先看下面这段程序:
#include <iostream>
#include <vector>
int main()
{
std::vector arr1 { 1, 2, 3, 4, 5 }; // 将 {1,2,3,4,5} 拷贝进 arr1
std::vector arr2 { arr1 }; // 将 arr1 拷贝给 arr2
arr1[0] = 6; // 仍可继续使用 arr1
arr2[0] = 7; // 亦可继续使用 arr2
std::cout << arr1[0] << arr2[0] << '\n';
return 0;
}
当用 arr1
初始化 arr2
时,将调用 std::vector
的拷贝构造函数,把 arr1
的内容复制到 arr2
。
在此场景下,“复制”是唯一合理的选择,因为我们需要 arr1
与 arr2
彼此独立地存在。该示例共发生了两次复制(每次初始化各一次)。
术语 复制语义(copy semantics) 指决定如何复制对象的规则。若说某类型“支持复制语义”,即表明该类型的对象可以按既定规则被复制。若说“触发了复制语义”,则意味着执行了会导致对象被复制的操作。
对于类类型,复制语义通常通过 拷贝构造函数(以及 拷贝赋值运算符)实现,这些特殊成员函数定义了该类对象的复制方式。一般而言,这会导致类中各数据成员逐个被复制。上例中,std::vector arr2 { arr1 };
就触发了复制语义,调用 std::vector
的拷贝构造函数,把 arr1
各数据成员复制给 arr2
。最终 arr1
与 arr2
等价但彼此独立。
当复制语义不再最优
再看一个相关示例:
#include <iostream>
#include <vector>
std::vector<int> generate() // 按值返回
{
// 此处故意使用具名对象,避免强制拷贝消除(mandatory copy elision)
std::vector arr1 { 1, 2, 3, 4, 5 }; // 将 {1,2,3,4,5} 拷贝进 arr1
return arr1;
}
int main()
{
std::vector arr2 { generate() }; // generate() 的返回值在本表达式结束后即被销毁
// 后续无法再使用 generate() 的返回值
arr2[0] = 7; // 我们只能访问 arr2
std::cout << arr2[0] << '\n';
return 0;
}
此次 arr2
通过函数 generate()
返回的临时对象初始化。与上一例不同,这里的初始化器是一个右值(临时对象),它将在初始化表达式结束后立即被销毁,无法再被使用。由于该临时对象(及其数据)即将消亡,必须把数据从临时对象中“搬”到 arr2
里。
惯常做法仍是复制语义:执行一次可能昂贵的复制,使 arr2
获得一份独立数据,供后续使用。
然而,本例与前例的关键差异在于:临时对象无论如何都会被销毁。初始化完成后,它不再需要自身的数据(因此可以销毁)。我们无需让两份数据并存。此时“先昂贵复制,再销毁原数据”显然不是最优解。
移动语义简介
倘若能让 arr2
直接“窃取”临时对象的数据,而非复制,会怎样?arr2
将成为数据的新所有者,无需任何数据复制。当数据所有权从一个对象转移到另一对象时,我们称该数据被 移动(move)。移动代价通常极低(往往仅两三个指针赋值,远快于复制整段数组)。
额外收益:临时对象在表达式末尾被销毁时,已无任何数据需要释放,因此我们也不必再付出析构成本。
这就是 移动语义(move semantics) 的核心:确定如何将一个对象的数据 移动 到另一对象的规则。触发移动语义时,凡可移动的数据成员均被移动;无法移动者则被复制。把数据移动而非复制,往往使移动语义比复制语义更高效。
关键洞见
移动语义是一项优化手段:在特定条件下,能以极低开销将某些数据成员的所有权从源对象转移到目标对象(而非执行更昂贵的复制)。
无法移动的数据成员仍会被复制。
移动语义何时触发
通常,当对象以同类型对象初始化(或赋值)时,会采用复制语义(假设复制未被消除)。
相关内容
拷贝消除(copy elision)已在课程 14.15 —— 类初始化与拷贝消除中介绍。
然而,当 以下所有条件 同时成立时,将改用移动语义:
- 该类型支持移动语义;
- 被初始化(或被赋值)的对象为右值(临时对象)且类型相同;
- 移动未被消除。
遗憾的是,支持移动语义的类型并不多,但 std::vector
与 std::string
均在此列!
我们将在第 22 章深入探讨移动语义的实现细节。目前,只需了解移动语义的概念,以及哪些类型支持移动即可。
可以按值返回支持移动语义的类型,例如 std::vector
因为按值返回会产生右值,若返回类型支持移动语义,则返回值可被移动而非复制到目标对象。这使得按值返回对这些类型极其廉价!
关键洞见
我们可以按值返回支持移动语义的类型(如 std::vector
、std::string
)。这些类型会廉价地移动自身数据,而不会执行昂贵的复制。
这些类型仍应通过 const 引用传递。
等等——“复制开销大的类型不应按值传递,但若支持移动,却可按值返回?”
没错。
以下讨论为选读,但可帮助你理解个中缘由。
选读:为何可以按值返回
C++ 中一种常见模式是:向函数传入一个值,再取回另一值。当这些值为类类型时,整个过程可分为 4 步:
- 构造待传入的值;
- 实际将值传入函数;
- 构造待返回的值;
- 实际将返回值传回调用者。
下面以 std::vector
为例:
#include <iostream>
#include <vector>
std::vector<int> doSomething(std::vector<int> v2)
{
std::vector v3 { v2[0] + v2[0] }; // 3 -- 构造待返回给调用者的值
return v3; // 4 -- 实际返回值
}
int main()
{
std::vector v1 { 5 }; // 1 -- 构造待传入函数的值
std::cout << doSomething(v1)[0] << '\n'; // 2 -- 实际传入值
std::cout << v1[0] << '\n';
return 0;
}
首先假设 std::vector
不支持移动。此时上述程序将执行 4 次复制:
- 构造待传入值:将初始化列表复制进
v1
; - 实际传参:将实参
v1
复制给形参v2
; - 构造返回值:将初始化列表复制进
v3
; - 实际返回:将
v3
复制回调用者。
现在讨论如何优化。可用手段包括:按引用/地址传递、消除、移动语义、输出形参。
我们无法优化 步骤 1 与 3 的复制。必须构造一个 std::vector
用于传入,也必须构造一个用于返回——这些对象必须存在。std::vector
拥有自身数据,因此必然复制初始化数据。
我们真正能影响的是 步骤 2 与 4 的复制。
步骤 2 的复制 源于按值传参。其他可选方案?
- 能否按引用或地址传递?可以。我们保证实参在整个函数调用期间始终存在——调用者不必担心被传对象意外离开作用域。
- 能否消除复制?不能。消除仅适用于冗余复制/移动,此处无冗余。
- 能否用输出形参?不适用。我们正向函数传值,而非取回值。
- 能否用移动语义?不能。实参是左值;若把
v1
数据移至v2
,v1
将变为空 vector,后续打印v1[0]
会导致未定义行为。
显然,const 引用传参 是最佳选择:避免复制、避免空悬指针,且兼容左值/右值实参。
步骤 4 的复制 源于按值返回。其他可选方案?
- 能否按引用或地址返回?不能。返回对象在函数内作为局部变量创建,函数返回时即被销毁。返回引用或指针将导致调用者获得空悬引用或指针。
- 能否消除复制?可能。若编译器足够聪明,会意识到在函数作用域内构造对象并返回,可通过“按 as-if 规则重写代码”,令
v3
在调用者作用域内构造,从而避免返回复制,但这依赖编译器判断,无法保证。 - 能否用输出形参?可以。与其在函数内局部构造
v3
,不如在调用者作用域内先构造一个空std::vector
,再以非 const 引用传入函数,由函数填充数据。函数返回后该对象仍存在。这能避免复制,但存在明显缺点与限制:调用语法丑陋;不适用于不支持赋值的对象;难以同时兼容左值/右值实参。 - 能否用移动语义?可以。
v3
将在函数返回时被销毁,故可用移动语义把其数据移给调用者,避免复制。
消除是最佳选择,但能否发生不受我们控制。对支持移动语义的类型,次佳选择是 移动语义,当编译器未消除复制时自动启用。按值返回时,移动语义对这类类型会自动触发。
总结
对支持移动语义的类型,我们倾向于 const 引用传参,并 按值返回。