16-5 返回 std::vector 及移动语义介绍

当我们需要将一个 std::vector 传递给函数时,会采用 (const) 引用传递的方式,这样就不会产生昂贵的数组数据副本。

因此,你可能会惊讶地发现,按值返回 std::vector 其实是可行的。

啥?


复制语义

考虑以下程序:

#include <iostream>
#include <vector>

int main()
{
    std::vector arr1 { 1, 2, 3, 4, 5 }; // copies { 1, 2, 3, 4, 5 } into arr1
    std::vector arr2 { arr1 };          // copies arr1 into arr2

    arr1[0] = 6; // We can continue to use arr1
    arr2[0] = 7; // and we can continue to use arr2

    std::cout << arr1[0] << arr2[0] << '\n';

    return 0;
}

image

当 arr2 用 arr1 初始化时,会调用 std::vector 的复制构造函数,将 arr1 复制到 arr2 中。

在此情况下,复制是唯一合理的操作,因为我们需要 arr1 和 arr2 独立存在。该示例最终会生成两个副本,每次初始化各生成一个。

复制语义copy semantics指的是确定对象副本生成方式的规则。当我们说某类型支持复制语义时,意味着该类型的对象可被复制,因为复制规则已被明确定义。当我们说复制语义被调用时,则表示我们执行了会生成对象副本的操作。

对于类类型,复制语义通常通过复制构造函数(及复制赋值运算符)实现,这些构造函数定义了该类型对象的复制方式。这通常会导致类型的每个数据成员都被复制。在前例中,语句 std::vector arr2 { arr1 }; 调用了复制语义,从而调用 std::vector 的复制构造函数,该函数将 arr1 的每个数据成员复制到 arr2 中。最终结果是 arr1arr2 等价(但相互独立)。


当复制语义不理想时

现在考虑这个相关的例子:

#include <iostream>
#include <vector>

std::vector<int> generate() // return by value
{
    // We're intentionally using a named object here so mandatory copy elision doesn't apply
    std::vector arr1 { 1, 2, 3, 4, 5 }; // copies { 1, 2, 3, 4, 5 } into arr1
    return arr1;
}

int main()
{
    std::vector arr2 { generate() }; // the return value of generate() dies at the end of the expression

    // There is no way to use the return value of generate() here
    arr2[0] = 7; // we only have access to arr2

    std::cout << arr2[0] << '\n';

    return 0;
}

image

本次初始化 arr2 时,使用的是函数 generate() 返回的临时对象。与先前初始化器是可供后续语句使用的左值不同,此处的临时对象是右值,将在初始化表达式结束时被销毁。该临时对象在此之后无法再被使用。由于临时对象(及其数据)将在表达式结束时被销毁,我们需要某种方式将数据从临时对象提取并赋值给 arr2。

通常的做法与前例相同:采用复制语义进行可能耗时的复制操作。这样 arr2 便获得了数据的独立副本,即使临时对象(及其数据)被销毁后仍可使用。

然而本例与前例的关键差异在于:临时对象注定会被销毁。初始化完成后,临时对象的数据已无保留必要(这正是可销毁的原因)。此时无需两套数据并存。在这种情况下,执行可能耗费资源的复制操作后再销毁原始数据,显然并非最优解。


移动语义简介

那么,如果有一种方法能让 arr2 直接“窃取”临时变量的数据而非复制它呢?这样 arr2 就会成为数据的新所有者,无需创建数据副本。当数据所有权从一个对象转移到另一个对象时,我们称之为数据被移动been moved。这种移动的成本通常微不足道(通常只需两到三次指针赋值操作,远比复制数组数据快得多!)。

额外的好处是,当表达式结束时临时对象被销毁时,它将不再有任何数据需要销毁,因此我们也不必承担该成本。

这正是移动语义move semantics的核心本质——它规定了数据从一个对象转移至另一个对象的规则。当移动语义被调用时,所有可移动的数据成员将被移动,而不可移动的数据成员则被复制。通过移动而非复制数据,移动语义往往比复制语义更高效,尤其当昂贵的复制操作能被低成本的移动操作替代时。

核心要义
移动语义是一种优化机制,允许我们在特定条件下以低成本将某些数据成员的所有权从一个对象转移至另一个对象(而非执行更耗费资源的复制操作)。

无法移动的数据成员则通过复制实现转移。


移动语义的触发机制

通常,当对象使用同类型对象进行初始化(或赋值)时,将采用复制语义(假设复制操作未被省略)。

相关内容:
我们在第14.15节 类初始化与复制省略 中讨论过复制省略机制。

但当同时满足以下条件时,将调用移动语义:

  • 对象类型支持移动语义
  • 对象通过同类型右值(临时)对象进行初始化(或赋值)
  • 移动操作未被省略

遗憾的是:支持移动语义的类型并不多。但std::vector和std::string都支持!

第22章将深入解析移动语义的工作原理。目前只需了解移动语义的概念及其适用类型即可。


我们可以按值返回支持移动语义的类型(如 std::vector)

由于按值返回会生成右值,若返回类型支持移动语义,则返回值可被移动而非复制到目标对象中。这使得按值返回对这类类型极为高效!

关键要点:
我们可以按值返回支持移动的类型(如 std::vector 和 std::string)。此类类型将以低成本移动其值,而非进行高成本的复制。

此类类型仍应通过 const 引用传递。


等等,等等,等等。复制成本高的类型不应该按值传递,但如果它们支持移动操作,就可以按值返回?

正确。

以下讨论可选,但可能有助于理解原因。

在C++中最常见的操作之一,就是向函数传递某个值,并返回不同的值。当传递的值是类类型时,这个过程涉及四个步骤:

  • 构造待传递的值。
  • 实际将值传递给函数。
  • 构造待返回的值。
  • 实际将返回值传递回调用方。

以下是使用std::vector实现上述过程的示例:

#include <iostream>
#include <vector>

std::vector<int> doSomething(std::vector<int> v2)
{
    std::vector v3 { v2[0] + v2[0] }; // 3 -- construct value to be returned to caller
    return v3; // 4 -- actually return value
}

int main()
{
    std::vector v1 { 5 }; // 1 -- construct value to be passed to function
    std::cout << doSomething(v1)[0] << '\n'; // 2 -- actually pass value

    std::cout << v1[0] << '\n';

    return 0;
}

image

首先,假设 std::vector 不支持移动操作。在此情况下,上述程序会生成 4 个副本:

  1. 构造待传递的值时,将初始化列表复制到 v1
  2. 实际将值传递给函数时,将参数 v1 复制到函数参数 v2
  3. 构造待返回的值时,将初始化列表复制到 v3
  4. 实际将值返回给调用方时,将 v3 复制回调用方。

现在探讨优化方案。我们可利用多种手段:引用传递/地址传递、省略语法、移动语义及输出参数。

复制操作1和3无法优化。函数必须接收std::vector参数,也需返回std::vector对象——这些对象必须被构造。std::vector 是其数据的所有者,因此必然会复制初始化参数。

我们可以影响的是复制 2 和 4。

复制 2 的产生是因为我们从调用方到被调用函数采用值传递。还有其他选择吗?

  • 能否采用引用或地址传递?可以。我们能确保参数在整个函数调用期间存在——调用方无需担心传入对象意外超出作用域。
  • 能否省略此复制?不能。省略仅适用于冗余复制或移动操作,而此处不存在冗余复制或移动。
  • 能否使用输出参数?不能。我们向函数传递的是值,而非获取返回值。
  • 能否在此使用移动语义?否。参数是左值。若将数据从 v1 移动到 v2,v1 将变为空向量,随后打印 v1[0] 将导致未定义行为。

显然,常量引用传递是最佳选择,它既避免了复制,又规避了空指针问题,且同时适用于左值和右值参数。

副本4的产生源于被调函数按值返回给调用方。还有哪些替代方案?

  • 能否按引用或地址返回?不行。返回对象作为函数内局部变量创建,函数返回时将被销毁。返回引用或指针将导致调用方获得悬空引用/指针。
  • 能否省略此副本?可能可以。若编译器足够智能,它会意识到我们在被调用函数作用域内构造对象并返回。通过重写代码(遵循假设规则),将 v3 的构造移至调用者作用域,即可避免返回时的复制操作。但这依赖于编译器能识别此优化机会,因此无法保证实现。
  • 能否在此使用输出参数?可以。无需将v3构造为局部变量,我们可在调用者作用域内构造空的std::vector对象,并通过非const引用传递给函数。函数可将数据填入该参数。函数返回后该对象仍存在。此法虽避免了复制,但存在显著缺点与限制:调用语义复杂、不支持不支持赋值的对象,且难以编写同时兼容左值和右值参数的函数。
  • 能否在此使用移动语义?可以。v3在函数返回时将被销毁,因此无需将其复制回调用方,而是通过移动语义将数据移至调用方,从而避免复制。

省略是最佳选择,但能否实现取决于编译器。对于支持移动的类型,次优方案是移动语义——当编译器未省略复制操作时可启用该机制。值得注意的是,对支持移动的类型,值返回时会自动调用移动语义。

综上所述,对于支持移动的类型,我们优先采用常量引用传递参数,并通过值返回参数。

posted @ 2026-01-05 16:02  游翔  阅读(20)  评论(0)    收藏  举报