章节目录

返回std::vector,移动语义简介

本节阅读量:

当需要将std::vector传递给函数时,通过(const)引用传递它,以便不制作数组的昂贵副本。

因此,您可能会惊讶地发现,可以按值返回std::vector。


复制语义

考虑以下程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#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;
}

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

在这种情况下,制作副本是唯一合理的做法,因为需要arr1和arr2独立工作。

术语复制语义是指确定如何制作对象副本的规则。当我们说类型支持复制语义时,意思是该类型的对象是可复制的,因为已经定义了制作这种副本的规则。当我们说正在调用复制语义时,这意味着已经做了一些事情来制作对象的副本。

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


赋值语义可能不是最佳的行为

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <vector>

std::vector<int> generate() // 按值返回
{
    // 这里有意的使用一个命名对象,避免发生可能得拷贝省略
    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将是数据的新所有者,并且不需要制作数据的副本。当数据的所有权从一个对象转移到另一个对象时,我们说数据已被移动。这种移动的成本通常很小(通常只有两个或三个指针分配,这比复制数据数组快得多!)。

作为一个额外的好处,当临时对象随后在表达式末尾被销毁时,它将不再有任何数据要销毁,因此也不必支付该成本。

这是移动语义的本质,它是指确定如何将数据从一个对象移动到另一个对象的规则。当调用移动语义时,将移动任何可以移动的数据成员,并复制任何无法移动的数据成员。移动数据而不是复制数据的能力可以使移动语义比复制语义更有效率,特别是当可以用廉价的移动替换昂贵的副本时。


如何调用移动语义

通常,当用相同类型的对象初始化或为对象分配相同类型的属性时,将使用复制语义(假设没有拷贝省略)。

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

  1. 对象的类型支持移动语义。
  2. 分配的初始值设定项或对象是rvalue(临时)对象。
  3. 移动不会被忽略。

不幸的是,并不是很多类型都支持移动语义。当然,std::vector和std::string都支持!

在后面,我们将更详细地研究移动语义是如何工作的。现在,知道什么是移动语义,以及哪些类型支持移动就足够了。


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

由于按值返回,因此如果返回的类型支持移动语义,则可以移动返回的值,而不是将其复制到目标对象中。这使得这些类型的按值返回非常高效!


等等。复制开销大的类型不应该通过值传递,但如果它们支持移动,则可以按值返回它们?

正确。

下面的讨论是选读的,但可能有助于您理解为什么会出现这种情况。

我们在C++中做的最常见的事情之一是将一个值传递给某个函数,然后返回一个不同的值。当传递的值是类类型时,该过程涉及4个步骤:

下面是使用std::vector的上述过程的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#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个副本:

  1. 构造要传递给函数的值
  2. 实际把该值传递给函数
  3. 构造要返回的值
  4. 实际访该值进行返回

现在,让我们谈谈如何优化上述内容。在这里有许多可选项:通过引用或地址传递、拷贝省略、移动语义或输出参数。

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

我们可以影响副本2和4。

复制2是因为我们在通过值从调用者传递到被调用的函数。我们还有其他选择吗?

  1. 我们可以通过引用或地址传递吗?是的。我们可以保证参数将存在于整个函数调用中——调用方不必担心传递的对象意外失效。
  2. 这个副本可以拷贝省略吗?不。拷贝省略仅在进行冗余复制或移动时有效。这里没有多余的拷贝或移动。
  3. 我们可以在这里使用输出参数吗?不。正在将值传递给函数,而不是获取值。
  4. 我们可以在这里使用移动语义吗?不,参数是左值。如果将数据从v1移动到v2,v1将成为空vector,随后打印v1[0]将导致未定义的行为。

显然,在这里,通过常量引用传递是最好的选择,因为它避免了复制,避免了空指针问题。

复制4是因为我们将按值从被调用的函数传递回调用方。还有其他选择吗?

  1. 我们可以通过引用或地址返回吗?不。返回的对象是作为函数内的局部变量创建的,并且将在函数返回时被销毁。返回引用或指针将导致调用方接收悬空指针或引用。
  2. 这个副本拷贝省略吗?是的,可能吧。如果编译器是智能的,它将意识到我们正在被调用函数的范围内构造一个对象并返回它。通过重写代码(在假设规则下),以便在调用方的作用域内构建v3,可以避免在返回时生成的副本。然而,我们依赖于编译器实现它可以做到这一点,因此不能保证。
  3. 我们可以在这里使用输出参数吗?是的。可以在调用者的作用域内构造一个空的std::vector对象,并通过非常量引用将其传递给函数,而不是将v3构造为局部变量。然后,该函数可以用数据填充该参数。当函数返回时,该对象将仍然存在。这避免了复制,但也有一些显著的缺点和约束:难看的调用语义,不能处理不支持赋值的对象,并且编写既可以处理左值参数又可以处理右值参数的函数是一项挑战。
  4. 我们可以在这里使用移动语义吗?是的。当函数返回时,v3将被销毁,因此,可以使用移动语义将其数据移动到调用方,而不是将v3复制回调用方,从而避免复制。

在这里,拷贝省略是最好的选择,但它是否发生是我们无法控制的。支持移动的类型的下一个最佳选项是移动语义,它可以在编译器不删除副本的情况下使用。对于支持移动的类型,在按值返回时自动调用移动语义。

总之,对于支持移动的类型,优先通过常量引用传递,并通过值返回。


16.3 传递std::vector

上一节

16.5 数组和循环

下一节