返回std::vector,移动语义简介
本节阅读量:当需要将std::vector传递给函数时,通常通过(const)引用传递它,以避免制作数组的昂贵副本。
因此,您可能会惊讶地发现,std::vector却可以按值返回。
复制语义
考虑以下程序:
|
|
当arr2用arr1初始化时,会调用std::vector的拷贝构造函数,将arr1复制到arr2中。
在这种情况下,制作副本是唯一合理的做法,因为arr1和arr2需要彼此独立。
术语复制语义是指决定如何制作对象副本的规则。当我们说某个类型支持复制语义时,意思是该类型的对象是可复制的,因为已经定义了制作这种副本的规则。当我们说正在调用复制语义时,意味着正在执行某些操作来制作对象副本。
对于类类型,复制语义通常通过拷贝构造函数(和拷贝赋值运算符)实现,它们定义了如何复制该类型的对象。通常,这会复制类类型的每个数据成员。在前面的示例中,语句std::vector arr2{arr1}; 调用复制语义,也就是调用std::vector的拷贝构造函数,该构造函数随后将arr1的每个数据成员复制到arr2中。最终结果是arr1等价于(但独立于)arr2。
赋值语义可能不是最佳的行为
现在考虑这个相关的例子:
|
|
这次初始化arr2时,它使用函数generate()返回的临时对象进行初始化。在这种情况下,临时对象是右值,将在初始化表达式末尾被销毁。临时对象不能在该点之后继续使用。由于临时对象(及其数据)将在表达式末尾被销毁,因此需要某种方法将数据从临时对象中取出并放入arr2。
这里通常会采用与前面示例相同的做法:使用复制语义,并制作一个可能昂贵的副本。这样,arr2就获得了自己的数据副本,即使临时对象(及其数据)被销毁后,也可以继续使用该副本。
然而,与前面的示例不同的是,临时对象无论如何都会被销毁。初始化完成后,不再需要它的数据(这也是为什么可以销毁它)。我们不需要同时存在两组数据。在这种情况下,制作一个可能昂贵的拷贝,然后销毁原始数据并不是最优选择。
移动语义简介
相反,如果arr2有一种方法可以“窃取”临时对象的数据,而不是复制它,会怎样?这样,arr2就会成为数据的新所有者,并且不需要制作数据副本。当数据所有权从一个对象转移到另一个对象时,我们说数据已被移动。这种移动的成本通常很小(通常只有两三个指针赋值,比复制数据数组快得多!)。
额外的好处是,当临时对象随后在表达式末尾被销毁时,它将不再拥有任何需要销毁的数据,因此也不必支付这部分成本。
这就是移动语义的本质:它是决定如何将数据从一个对象移动到另一个对象的规则。当调用移动语义时,任何可以移动的数据成员都会被移动,无法移动的数据成员则会被复制。移动数据而不是复制数据的能力,可以让移动语义比复制语义更高效,特别是在可以用廉价移动替代昂贵副本时。
关键点
移动语义是一种优化,它允许我们在某些情况下以较低的成本将某些数据成员的所有权从一个对象转移到另一个对象(而不是制作更昂贵的副本),同时复制无法移动的数据成员。
如何调用移动语义
通常,当用相同类型的对象初始化对象,或将相同类型的对象赋值给另一个对象时,会使用复制语义(假设没有拷贝省略)。
然而,当以下所有条件都为真时,将调用移动语义:
- 对象的类型支持移动语义。
- 分配的初始值设定项或对象是rvalue(临时)对象。
- 移动不会被忽略。
不幸的是,并不是很多类型都支持移动语义。当然,std::vector和std::string都支持!
后面我们会更详细地研究移动语义如何工作。现在,只需要知道什么是移动语义,以及哪些类型支持移动就足够了。
可以按值返回支持移动的类型,如std::vector
由于是按值返回,如果返回类型支持移动语义,就可以移动返回值,而不是将其复制到目标对象中。这让这些类型的按值返回非常高效!
关键点
可以按值返回支持移动的类型(如std::vector和std::string)。这样的类型会以较低成本移动其值,而不是制作昂贵副本。
此类类型仍应通过常量引用传递。
等等。复制开销大的类型不应该通过值传递,但如果它们支持移动,则可以按值返回它们?
正确。
下面的讨论是选读内容,但可能有助于您理解为什么会出现这种情况。
我们在C++中最常做的事情之一,是将一个值传递给某个函数,然后返回另一个值。当传递的值是类类型时,该过程涉及4个步骤:
下面是使用std::vector演示上述过程的示例:
|
|
首先,假设std::vector不支持移动。在这种情况下,上述程序会制作4个副本:
- 构造要传递给函数的值
- 实际把该值传递给函数
- 构造要返回的值
- 实际返回该值
现在,让我们谈谈如何优化上述内容。在这里有许多可选项:通过引用或地址传递、拷贝省略、移动语义或输出参数。
我们基本无法优化副本1和3。我们需要一个std::vector来传递给函数,也需要一个std::vector来返回,因此必须构造这些对象。vector是其数据的所有者,因此它必须复制其初始值。
我们可以影响副本2和4。
副本2产生的原因是,我们通过值从调用方传递参数到被调用函数。还有其他选择吗?
- 可以通过引用或地址传递吗?可以。我们可以保证参数会在整个函数调用期间存在,调用方不必担心传递的对象意外失效。
- 这个副本可以拷贝省略吗?不。拷贝省略仅在进行冗余复制或移动时有效。这里没有多余的拷贝或移动。
- 可以在这里使用输出参数吗?不可以。这里是在将值传递给函数,而不是从函数获取值。
- 可以在这里使用移动语义吗?不可以,参数是左值。如果将数据从v1移动到v2,v1将变成空vector,随后打印v1[0]会导致未定义行为。
显然,在这里通过常量引用传递是最好的选择,因为它避免了复制,也避免了空对象问题。
副本4产生的原因是,我们按值从被调用函数返回给调用方。还有其他选择吗?
- 可以通过引用或地址返回吗?不可以。返回的对象是在函数内作为局部变量创建的,并且会在函数返回时被销毁。返回引用或指针会导致调用方接收到悬空指针或悬空引用。
- 这个副本可以被拷贝省略吗?可以,可能会。如果编译器足够智能,它会意识到我们正在被调用函数的作用域内构造一个对象并返回它。通过重写代码(在假设规则下),让v3直接在调用方作用域内构建,可以避免返回时生成副本。然而,这依赖编译器实现该优化,因此不能保证。
- 可以在这里使用输出参数吗?可以。可以在调用方作用域内构造一个空的std::vector对象,并通过非常量引用将其传递给函数,而不是将v3构造为局部变量。然后,该函数可以用数据填充该参数。函数返回时,该对象仍然存在。这避免了复制,但也有一些显著缺点和约束:调用语义难看,不能处理不支持赋值的对象,并且编写既能处理左值参数又能处理右值参数的函数会很困难。
- 可以在这里使用移动语义吗?可以。函数返回时,v3将被销毁,因此可以使用移动语义将其数据移动到调用方,而不是将v3复制回调用方,从而避免复制。
在这里,拷贝省略是最好的选择,但它是否发生并不由我们控制。对于支持移动的类型,下一个最佳选择是移动语义,它可以在编译器没有消除副本时发挥作用。对于支持移动的类型,按值返回时会自动调用移动语义。
总之,对于支持移动的类型,优先通过常量引用传递,并通过值返回。