为支持移动语义,函数参数应该采用unique_ptr、按值传递还是按右值引用传递?

17

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

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

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


1
这完全取决于您希望类的用户如何与其交互。如果您希望他们知道他们正在将向量提供给您的类,则可以使用3或1。如果您希望允许他们保留向量的副本,则限制为2。 - NathanOliver
4
除了unique_ptr以外的任何东西。 - juanchopanza
1
@xaxxon 如果调用者使用std::move,那么这只会交换内部缓冲区指针到成员,这非常便宜。 - Steve Lorimer
4个回答

34
除非你有将向量存储在堆上的理由,否则不建议使用unique_ptr
向量的内部存储已经存在于堆上,因此如果使用unique_ptr,你将需要两个间接级别来解引用指向向量的指针,并且再次解引用内部存储缓冲区。
因此,我建议使用选项2或3。
如果选择选项3(需要一个rvalue引用),则要求调用someFunction时,使用者必须传递一个rvalue(直接从临时对象移动,或从lvalue中move)。
从lvalue进行移动的要求是繁琐的。
如果使用者想要保留向量的副本,则他们必须费尽周折才能实现。
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

3
如果用户想在情况3下保留一份副本,那么写t.someFunction(std::vector<string>{items})会更易读,而不是使用一个变量并移动它。我认为这种方法与选项2一样清晰,甚至更加清晰。 - HeroicKatora
1
只有当用户想要保留自己的副本时,才使用@xaxxon。如果不需要保留副本,则可以使用std::move,这样向量将被移动而不是复制。 - Steve Lorimer
1
@HeroicKatora,你认为someFunction(std::vector<string>{items})someFunction(items)更清晰明了。这是一种有趣的理解“清晰性”的方法…… - bloody

18
表面上看,选项2似乎是一个好主意,因为它可以在单个函数中处理lvalue和rvalue。但是,正如Herb Sutter在他的CppCon 2014演讲回到基础!现代C++风格的基本要素中指出的那样,对于lvalue的常见情况来说,这是一种恶化。 如果m_itemsitems“更大”,则您的原始代码将不会为向量分配内存:
// 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;
}
< p> 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所指出的那样,是添加一个右值引用重载(基本上就是你的第三个选项):
// 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 已存在时起作用,因此通过值传递参数给构造函数是完全可以的 - 无论如何都必须执行分配。
简而言之:选择添加第三个选项。即,为左值和右值分别重载一个函数。第二个选项会强制进行拷贝构造,而不是拷贝赋值,这可能更加昂贵(对于 std::stringstd::vector 来说)。
† 如果您想查看展示第二个选项可能会导致性能下降的基准测试,请参见此演讲中的 此处
‡ 如果 std::vector 的移动赋值运算符不是 noexcept,那么我们不应将其标记为 noexcept。如果您正在使用自定义分配器,请参考 文档。一般来说,类似的函数只有在类型的移动赋值是 noexcept 时才应标记为 noexcept

2
如果m_items比items“更大”,您的原始代码将不会分配内存:这是不正确的 - 它不会为向量分配内存,但很可能会为字符串分配内存。 - Steve Lorimer
1
@SteveLorimer 谢谢;我忘记考虑向量中装的是什么了。 - Justin

8
这取决于您的使用模式:
选项1
优点: - 调用者明确地表达并传递责任给被调用方。
缺点: - 除非已经使用unique_ptr包装了向量,否则不会提高可读性。 - 智能指针通常管理动态分配的对象。因此,您的vector必须成为其中之一。由于标准库容器是受管理的对象,它们使用内部分配来存储其值,这意味着每个这样的向量将有两个动态分配。一个是唯一指针的管理块+向量对象本身,另一个是存储的项目。
总结: - 如果您始终使用unique_ptr管理此向量,请继续使用它,否则不要使用。
选项2
优点:
  • This option is very flexible, since it allows the caller to decide whether he wan't to keep a copy or not:

    std::vector<std::string> vec { ... };
    Test t;
    t.someFunction(vec); // vec stays a valid copy
    t.someFunction(std::move(vec)); // vec is moved
    
  • When the caller uses std::move() the object is only moved twice (no copies), which is efficient.

缺点:

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

总结:

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

选项3

缺点:

  • This option forces the caller to give up on his copy. So if he wants to keep a copy to himself, he must write additional code:

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

摘要:

相比于选项#2,这个选项的灵活性较差,在大多数情况下我会认为它不如选项#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中描述的所有问题情况,同时享受它们的优点
  • 调用者可以决定是否保留副本
  • 可以针对任何给定情况进行优化

缺点:

总结:

只要没有这样的原型,这是一个很好的选择。


好的回答。但我认为你要找的词是“灵活”,而不是“敏捷”。无论如何,+1。 - StoryTeller - Unslander Monica
@StoryTeller,“灵活”听起来更好。 - Daniel Trugman
@Daniel Trugman 把 std::move(thevector) 传递给 t.someFunction,这意味着它会被移动两次吗?一次是在传递时,另一次是在 someFunction() 内部? - Zebrafish
@Zebrafish,我改进了我的答案,并包含了对你的问题的回答(两步走,无复制)。 - Daniel Trugman

0

目前的建议是通过值传递向量,并将其移动到成员变量中:

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

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

1
目前的建议是,我认为你对此并没有必要保持最新。 - xaxxon

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接