为了支持移动语义,函数参数应该由unique_ptr、值还是右值获取?

To support move semantics, should function parameters be taken by unique_ptr, by value, or by rvalue?

我的一个函数将向量作为参数并将其存储为成员变量。我正在使用对矢量的常量引用,如下所述。

class Test {
 public:
  void someFunction(const std::vector<string>& items) {
   m_items = items;
  }

 private:
  std::vector<string> m_items;
};

但是,有时items包含大量字符串,所以我想添加一个支持移动语义的函数(或用一个新函数替换该函数)。

我正在考虑几种方法,但我不确定该选择哪一种。

1) unique_ptr

void someFunction(std::unique_ptr<std::vector<string>> items) {
   // Also, make `m_itmes` std::unique_ptr<std::vector<string>>
   m_items = std::move(items);
}

2) 按值传递并移动

void someFunction(std::vector<string> items) {
   m_items = std::move(items);
}

3) 右值

void someFunction(std::vector<string>&& items) {
   m_items = std::move(items);
}

我应该避免哪种方法,为什么?

除非你有理由让向量驻留在堆上,否则我建议不要使用 unique_ptr

vector 的内部存储无论如何都在堆上,所以如果您使用 unique_ptr,您将需要 2 个间接度,一个是取消引用指向 vector 的指针,另一个是取消引用内部存储缓冲区。

因此,我建议使用 2 或 3。

如果你选择选项 3(需要右值引用),你就是在向你的 class 的用户强加一个要求,即他们传递一个右值(直接来自临时值,或者从左值移动) ), 当调用 someFunction.

从左值移动的要求很繁重。

如果您的用户想要保留矢量的副本,他们必须克服重重困难才能做到这一点。

std::vector<string> items = { "1", "2", "3" };
Test t;
std::vector<string> copy = items; // have to copy first
t.someFunction(std::move(items));

但是,如果您选择选项 2,用户可以决定是否要保留副本 - 选择权在他们手中

保留一份:

std::vector<string> items = { "1", "2", "3" };
Test t;
t.someFunction(items); // pass items directly - we keep a copy

不要保留副本:

std::vector<string> items = { "1", "2", "3" };
Test t;
t.someFunction(std::move(items)); // move items - we don't keep a copy

这取决于您的使用模式:

选项 1

优点:

  • 责任明确表达并从调用者传递给被调用者

缺点:

  • 除非矢量已经使用 unique_ptr 包装,否则这不会提高可读性
  • 智能指针通常管理动态分配的对象。因此,您的 vector 必须成为一个。由于标准库容器是使用内部分配来存储其值的托管对象,这意味着将为每个此类向量进行两次动态分配。一个用于唯一 ptr 的管理块 + vector 对象本身,另一个用于存储的项目。

总结:

如果您一直使用 unique_ptr 管理此向量,请继续使用它,否则不要使用它。

选项 2

优点:

  • 这个选项非常灵活,因为它允许调用者决定是否要保留副本:

    std::vector<std::string> vec { ... };
    Test t;
    t.someFunction(vec); // vec stays a valid copy
    t.someFunction(std::move(vec)); // vec is moved
    
  • 当调用者使用std::move()时对象只被移动两次(没有复制),这是高效的。

缺点:

  • 当调用者不使用std::move()时,总是调用复制构造函数来创建临时对象。如果我们要使用 void someFunction(const std::vector<std::string> & items) 并且我们的 m_items 已经足够大(就容量而言)以容纳 items,那么赋值 m_items = items 将只是一个复制操作,没有额外的分配。

总结:

如果你事先知道这个对象会在运行时被重新-设置很多次,而且调用者并不总是使用std::move(),我本来可以避免的。否则,这是一个很好的选择,因为它非常灵活,尽管存在问题,但仍可根据需要提供用户友好性和更高的性能。

选项 3

缺点:

  • 此选项强制调用者放弃他的副本。所以如果他想自己保留一份副本,他必须编写额外的代码:

    std::vector<std::string> vec { ... };
    Test t;
    t.someFunction(std::vector<std::string>{vec});
    

总结:

这不如选项 #2 灵活,因此我认为在大多数情况下都较差。

选项 4

考虑到选项 2 和 3 的缺点,我认为建议一个额外的选项:

void someFunction(const std::vector<int>& items) {
    m_items = items;
}

// AND

void someFunction(std::vector<int>&& items) {
    m_items = std::move(items);
}

优点:

  • 它解决了选项 2 和 3 中描述的所有问题场景,同时也享有它们的优势
  • 来电者决定是否自己保留副本
  • 可以针对任何给定场景进行优化

缺点:

  • 如果该方法接受许多参数都作为 const 引用 and/or 右值引用
  • 的数量

总结:

只要您没有这样的原型,这是一个不错的选择。

目前对此的建议是按值获取向量并将其移动到成员变量中:

void fn(std::vector<std::string> val)
{
  m_val = std::move(val);
}

我刚刚检查过,std::vector 确实提供了移动赋值运算符。如果调用者不想保留副本,他们可以将其移动到调用站点的函数中:fn(std::move(vec));.

从表面上看,选项 2 似乎是个好主意,因为它在单个函数中同时处理左值和右值。然而,正如 Herb Sutter 在他的 CppCon 2014 演讲中指出的那样 Back to the Basics! Essentials of Modern C++ Style这是对左值常见情况的悲观看法。

如果 m_itemsitems "bigger",您的原始代码将不会为向量分配内存:

// Original code:
void someFunction(const std::vector<string>& items) {
   // If m_items.capacity() >= items.capacity(),
   // there is no allocation.
   // Copying the strings may still require
   // allocations
   m_items = items;
}

std::vector 上的复制赋值运算符足够聪明,可以重用现有分配。另一方面,按值取参数总是要进行另一次分配:

// Option 2:
// When passing in an lvalue, we always need to allocate memory and copy over
void someFunction(std::vector<string> items) {
   m_items = std::move(items);
}

简单来说:复制构造和复制赋值的代价不一定相同。复制赋值比复制构造更有效率——它对 std::vectorstd::string .

更有效

正如 Herb 所说,最简单的解决方案是添加右值重载(基本上是您的选项 3):

// You can add `noexcept` here because there will be no allocation‡
void someFunction(std::vector<string>&& items) noexcept {
   m_items = std::move(items);
}

请注意,复制分配优化仅在 m_items 已经存在时才有效,因此按值将参数传递给 constructors 完全没问题 - 分配必须以任何一种方式执行。

TL;DR: 选择 添加 选项 3。即,对左值进行重载,对右值进行一次重载。选项 2 强制复制 construction 而不是复制 assignment,这可能更昂贵(并且适用于 std::stringstd::vector )

† 如果你想看到基准显示选项 2 可能是一个悲观化,at this point in the talk,Herb 显示了一些基准

‡ 如果 std::vector 的移动赋值运算符不是 noexcept,我们不应该将其标记为 noexcept。如果您使用的是自定义分配器,请咨询 the documentation
根据经验,如果类型的移动赋值是 noexcept

,类似的函数应该只标记为 noexcept