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;
}

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

在这种情况下,进行复制是唯一合理的操作,因为我们需要 arr1arr2 独立存在。这个例子最终进行了两次复制,每次初始化一次。

术语复制语义指的是决定对象如何复制的规则。当我们说一个类型支持复制语义时,我们指的是该类型的对象是可复制的,因为已经定义了进行此类复制的规则。当我们说复制语义被调用时,这意味着我们做了某些操作会导致对象的复制。

对于类类型,复制语义通常通过复制构造函数(和复制赋值运算符)实现,它定义了该类型对象如何复制。通常这会导致类类型的每个数据成员都被复制。在前面的例子中,语句 std::vector arr2 { arr1 }; 调用了复制语义,导致调用 std::vector 的复制构造函数,然后它将 arr1 的每个数据成员复制到 arr2 中。最终结果是 arr1 等同于(但独立于)arr2

复制语义何时不佳

现在考虑这个相关的例子

#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;
}

这次初始化 arr2 时,它是使用从函数 generate() 返回的临时对象初始化的。与前一种情况不同,前一种情况初始化器是一个左值,可以在将来的语句中使用,而在此情况下,临时对象是一个右值,将在初始化表达式结束时被销毁。临时对象不能在那个点之后使用。由于临时对象(及其数据)将在表达式结束时被销毁,我们需要某种方式将数据从临时对象中取出并放入 arr2 中。

这里通常的做法与前面的例子相同:使用复制语义并进行潜在的昂贵复制。这样,arr2 获得其自身的数据副本,即使在临时对象(及其数据)被销毁后也可以使用。

然而,这个案例与前一个例子的不同之处在于,临时对象无论如何都会被销毁。初始化完成后,临时对象不再需要其数据(这就是我们可以销毁它的原因)。我们不需要两组数据同时存在。在这种情况下,进行潜在的昂贵复制然后销毁原始数据是次优的。

移动语义简介

相反,如果有一种方法让 arr2 “窃取”临时对象的数据而不是复制它呢?那么 arr2 将成为数据的新所有者,并且不需要复制数据。当数据的所有权从一个对象转移到另一个对象时,我们称数据被移动了。这种移动的成本通常微不足道(通常只是两到三个指针赋值,这比复制数组数据快得多!)。

作为一个额外的好处,当临时对象在表达式结束时被销毁时,它将不再有任何数据要销毁,因此我们也不必支付这笔成本。

这就是移动语义的精髓,它指的是决定如何将数据从一个对象移动到另一个对象的规则。当调用移动语义时,任何可以移动的数据成员都会被移动,而任何不能移动的数据成员都会被复制。能够移动数据而不是复制数据可以使移动语义比复制语义更高效,尤其是当我们可以用廉价的移动替换昂贵的复制时。

关键见解

移动语义是一种优化,它允许我们在某些情况下以低成本将某些数据成员的所有权从一个对象转移到另一个对象(而不是进行更昂贵的复制)。

不能移动的数据成员会被复制。

移动语义如何调用

通常,当一个对象用相同类型的对象初始化(或赋值)时,将使用复制语义(假设复制未被省略)。

相关内容

我们在课程 14.15 -- 类初始化和复制省略 中介绍了复制省略。

但是,当以下所有条件都为真时,将调用移动语义:

  • 对象的类型支持移动语义。
  • 对象正在用相同类型的右值(临时)对象初始化(或赋值)。
  • 移动未被省略。

这是个坏消息:支持移动语义的类型并不多。然而,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 -- 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;
}

首先,假设 std::vector 不支持移动。在这种情况下,上述程序会进行 4 次复制:

  1. 构造要传递的值会将初始化列表复制到 v1 中。
  2. 实际将值传递给函数会将参数 v1 复制到函数参数 v2 中。
  3. 构造要返回的值会将初始化器复制到 v3 中。
  4. 实际将值返回给调用者会将 v3 复制回调用者。

现在让我们讨论如何优化上述过程。我们手头有许多工具:按引用或地址传递、省略、移动语义和输出参数。

我们根本无法优化复制 1 和 3。我们需要一个 std::vector 来传递给函数,并且我们需要一个 std::vector 来返回——这些对象必须被构造。std::vector 是其数据的所有者,因此它必然会复制其初始化器。

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

进行复制 2 是因为我们正在从调用者按值传递给被调用的函数。我们还有哪些其他选择?

  • 我们可以按引用或地址传递吗?是的。我们保证参数将在整个函数调用期间存在——调用者不必担心传递的对象意外超出作用域。
  • 可以省略此复制吗?不能。省略只在我们进行冗余复制或移动时有效。这里没有冗余复制或移动。
  • 我们可以在这里使用输出参数吗?不能。我们正在向函数传递一个值,而不是获取一个值。
  • 我们可以在这里使用移动语义吗?不能。参数是一个左值。如果我们将数据从 v1 移动到 v2v1 将成为一个空向量,随后打印 v1[0] 将导致未定义行为。

显然,按 const 引用传递是这里的最佳选择,因为它避免了复制,避免了空指针问题,并且适用于左值和右值参数。

复制 4 的产生是因为我们正在从被调用的函数按值传递回调用者。我们在这里还有哪些其他选择?

  • 我们可以按引用或地址返回吗?不能。我们返回的对象是作为函数内部的局部变量创建的,并将在函数返回时销毁。返回引用或指针将导致调用者收到悬空指针或引用。
  • 可以省略此复制吗?是的,有可能。如果编译器足够智能,它会意识到我们正在被调用函数的范围内构造一个对象并返回它。通过重写代码(在 as-if 规则下),以便在调用者的范围内构造 v3,我们可以避免在返回时本来会进行的复制。但是,我们依赖于编译器意识到它可以这样做,因此无法保证。
  • 我们可以在这里使用输出参数吗?是的。我们可以不在函数局部变量中构造 v3,而是在调用者的作用域内构造一个空的 std::vector 对象,并通过非 const 引用将其传递给函数。然后函数可以用数据填充此参数。当函数返回时,此对象将仍然存在。这避免了复制,但也有一些显著的缺点和限制:调用语义丑陋,不适用于不支持赋值的对象,并且编写能够同时处理左值和右值参数的此类函数具有挑战性。
  • 我们可以在这里使用移动语义吗?是的。v3 将在函数返回时被销毁,所以我们不必将 v3 复制回调用者,我们可以使用移动语义将其数据移动到调用者,从而避免复制。

省略是这里的最佳选择,但它是否发生超出了我们的控制。对于支持移动的类型,次优选择是移动语义,它可以在编译器不省略复制的情况下使用。对于支持移动的类型,当按值返回时,移动语义会自动调用。

总而言之,对于支持移动的类型,我们倾向于按 const 引用传递,并按值返回。

guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
69 条评论
最新
最早 最多投票
内联反馈
查看所有评论