C++ std::vector:返回 std::vector 与移动语义深度解析

当我们需要把 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

在此场景下,“复制”是唯一合理的选择,因为我们需要 arr1arr2 彼此独立地存在。该示例共发生了两次复制(每次初始化各一次)。

术语 复制语义(copy semantics) 指决定如何复制对象的规则。若说某类型“支持复制语义”,即表明该类型的对象可以按既定规则被复制。若说“触发了复制语义”,则意味着执行了会导致对象被复制的操作。

对于类类型,复制语义通常通过 拷贝构造函数(以及 拷贝赋值运算符)实现,这些特殊成员函数定义了该类对象的复制方式。一般而言,这会导致类中各数据成员逐个被复制。上例中,std::vector arr2 { arr1 }; 就触发了复制语义,调用 std::vector 的拷贝构造函数,把 arr1 各数据成员复制给 arr2。最终 arr1arr2 等价但彼此独立。

当复制语义不再最优

再看一个相关示例:

#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 —— 类初始化与拷贝消除中介绍。

然而,当 以下所有条件 同时成立时,将改用移动语义:

  1. 该类型支持移动语义;
  2. 被初始化(或被赋值)的对象为右值(临时对象)且类型相同;
  3. 移动未被消除。

遗憾的是,支持移动语义的类型并不多,但 std::vectorstd::string 均在此列!

我们将在第 22 章深入探讨移动语义的实现细节。目前,只需了解移动语义的概念,以及哪些类型支持移动即可。

可以按值返回支持移动语义的类型,例如 std::vector

因为按值返回会产生右值,若返回类型支持移动语义,则返回值可被移动而非复制到目标对象。这使得按值返回对这些类型极其廉价!

关键洞见
我们可以按值返回支持移动语义的类型(如 std::vectorstd::string)。这些类型会廉价地移动自身数据,而不会执行昂贵的复制。
这些类型仍应通过 const 引用传递。

等等——“复制开销大的类型不应按值传递,但若支持移动,却可按值返回?”

没错。

以下讨论为选读,但可帮助你理解个中缘由。

选读:为何可以按值返回

C++ 中一种常见模式是:向函数传入一个值,再取回另一值。当这些值为类类型时,整个过程可分为 4 步:

  1. 构造待传入的值;
  2. 实际将值传入函数;
  3. 构造待返回的值;
  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 数据移至 v2v1 将变为空 vector,后续打印 v1[0] 会导致未定义行为。
      显然,const 引用传参 是最佳选择:避免复制、避免空悬指针,且兼容左值/右值实参。
  • 步骤 4 的复制 源于按值返回。其他可选方案?

    • 能否按引用或地址返回?不能。返回对象在函数内作为局部变量创建,函数返回时即被销毁。返回引用或指针将导致调用者获得空悬引用或指针。
    • 能否消除复制?可能。若编译器足够聪明,会意识到在函数作用域内构造对象并返回,可通过“按 as-if 规则重写代码”,令 v3 在调用者作用域内构造,从而避免返回复制,但这依赖编译器判断,无法保证。
    • 能否用输出形参?可以。与其在函数内局部构造 v3,不如在调用者作用域内先构造一个空 std::vector,再以非 const 引用传入函数,由函数填充数据。函数返回后该对象仍存在。这能避免复制,但存在明显缺点与限制:调用语法丑陋;不适用于不支持赋值的对象;难以同时兼容左值/右值实参。
    • 能否用移动语义?可以。v3 将在函数返回时被销毁,故可用移动语义把其数据移给调用者,避免复制。

消除是最佳选择,但能否发生不受我们控制。对支持移动语义的类型,次佳选择是 移动语义,当编译器未消除复制时自动启用。按值返回时,移动语义对这类类型会自动触发。

总结
对支持移动语义的类型,我们倾向于 const 引用传参,并 按值返回

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"