【问题标题】:Advantages of pass-by-value and std::move over pass-by-reference值传递和 std::move 优于传递引用的优点
【发布时间】:2019-01-13 07:34:00
【问题描述】:

我目前正在学习 C++,并尽量避免养成坏习惯。 据我了解,clang-tidy 包含许多“最佳实践”,我尽量坚持使用它们(尽管我还不一定了解 为什么 它们被认为是好的),但是我不确定我是否理解这里的建议。

我使用了教程中的这个类:

class Creature
{
private:
    std::string m_name;

public:
    Creature(const std::string &name)
            :  m_name{name}
    {
    }
};

这导致 clang-tidy 建议我应该通过值而不是引用传递并使用std::move。 如果我这样做了,我会建议将 name 设为引用(以确保它不会每次都被复制)并警告 std::move 不会有任何效果,因为 nameconst 所以我应该删除它。

我没有收到警告的唯一方法是完全删除 const

Creature(std::string name)
        :  m_name{std::move(name)}
{
}

这似乎是合乎逻辑的,因为const 的唯一好处是防止弄乱原始字符串(这不会发生,因为我按值传递)。 但我在CPlusPlus.com 上阅读:

尽管注意-在标准库中-移动意味着被移动的对象处于有效但未指定的状态。这意味着,在这样的操作之后,被移动对象的值应该只被销毁或分配一个新值;否则访问它会产生一个未指定的值。

现在想象一下这段代码:

std::string nameString("Alex");
Creature c(nameString);

因为nameString 是按值传递的,所以std::move 只会使构造函数内部的name 无效,而不会触及原始字符串。但是这样做有什么好处呢?无论如何,内容似乎只被复制一次 - 如果我在调用 m_name{name} 时通过引用传递,如果我在传递它时通过值传递(然后它被移动)。我知道这比通过值传递而不使用std::move 更好(因为它被复制了两次)。

那么两个问题:

  1. 我是否正确理解了这里发生的事情?
  2. 使用std::move 与通过引用传递并仅调用m_name{name} 相比有什么好处吗?

【问题讨论】:

  • 通过引用传递,Creature c("John"); 制作一个额外的副本
  • This link 可能是一本有价值的读物,它也涵盖了通过 std::string_view 和 SSO。
  • 我发现clang-tidy 是一种让自己沉迷于不必要的微优化而牺牲可读性的好方法。这里首先要问的问题是,我们实际上调用了多少次Creature 构造函数。

标签: c++


【解决方案1】:
/* (0) */ 
Creature(const std::string &name) : m_name{name} { }
  • 传递的左值绑定到name,然后复制m_name

  • 传递的右值绑定到name,然后复制m_name


/* (1) */ 
Creature(std::string name) : m_name{std::move(name)} { }
  • 传递的左值复制name,然后被移动m_name

  • 传递的右值移动name,然后被移动m_name


/* (2) */ 
Creature(const std::string &name) : m_name{name} { }
Creature(std::string &&rname) : m_name{std::move(rname)} { }
  • 传递的左值绑定到name,然后复制m_name

  • 传递的右值绑定到rname,然后移动m_name


由于移动操作通常比复制操作更快,因此如果您通过大量临时操作,(1) 优于 (0)(2) 在复制/移动方面是最佳的,但需要重复代码。

完美转发可以避免代码重复

/* (3) */
template <typename T,
          std::enable_if_t<
              std::is_convertible_v<std::remove_cvref_t<T>, std::string>, 
          int> = 0
         >
Creature(T&& name) : m_name{std::forward<T>(name)} { }

您可能希望限制T 以限制可以实例化此构造函数的类型域(如上所示)。 C++20 旨在通过Concepts 简化这一点。


在 C++17 中,prvaluesguaranteed copy elision 的影响,当适用时,这将减少向函数传递参数时的复制/移动次数。

【讨论】:

  • 对于 (1) 自 c++17 以来 pr-value 和 xvalue 大小写不一样?
  • 请注意,在这种情况下,您不需要 SFINAE 来完善前进。它只需要消除歧义。在传递错误参数时,它似乎有助于潜在的错误消息
  • 我们可以在 (2) 中写:Creature(const std::string &amp;name) : m_name{std::move(name)} { } 吗?
  • @skytree:你不能从一个 const 对象移动,因为移动会改变源。这将编译,但它会复制。
  • @skytree: 一个非const 左值引用可以被移出,但它有潜在的危险和误导性,因为移出的对象不是临时的,你的函数的调用者可能期望重用它。不幸的是,我不知道有什么简单的方法可以检测“不动的动作”。小心:)
【解决方案2】:
  1. 我是否正确理解这里发生的事情?

是的。

  1. 使用std::move 是否比通过引用传递并仅调用m_name{name} 有任何好处?

一个易于掌握的函数签名,没有任何额外的重载。签名立即显示该参数将被复制——这使调用者不必怀疑const std::string&amp; 引用是否可能被存储为数据成员,以后可能会成为悬空引用。并且不需要重载 std::string&amp;&amp; nameconst std::string&amp; 参数以避免在将右值传递给函数时出现不必要的副本。传递左值

std::string nameString("Alex");
Creature c(nameString);

对于按值获取其参数的函数会导致一个副本和一个移动构造。将右值传递给同一个函数

std::string nameString("Alex");
Creature c(std::move(nameString));

导致两个移动结构。相反,当函数参数为const std::string&amp; 时,将始终存在副本,即使传递右值参数也是如此。只要参数类型易于移动构造(std::string 就是这种情况),这显然是一个优势。

但有一个缺点需要考虑:推理不适用于将函数参数分配给另一个变量(而不是初始化它)的函数:

void setName(std::string name)
{
    m_name = std::move(name);
}

将导致m_name 在重新分配之前引用的资源重新分配。我建议阅读 Effective Modern C++ 中的第 41 条以及 this question

【讨论】:

  • 这是有道理的,尤其是这使得声明更易于阅读。我不确定我是否完全掌握了您答案的解除分配部分(并理解了链接的线程),因此只需检查如果我使用move,空间就会被解除分配。如果我不使用move,它只会在分配的空间太小而无法容纳新字符串时才会被释放,从而提高性能。对吗?
  • 是的,就是这样。当从const std::string&amp; 参数分配给m_name 时,只要m_name 适合,内部内存就会被重新使用。当移动分配m_name 时,内存必须预先释放。否则,不可能从作业右侧“窃取”资源。
  • 什么时候变成悬空引用?我认为初始化列表使用深拷贝。
  • “签名立即表明参数将被复制”一针见血。很高兴知道其他答案中描述的微优化,但除非您真的需要这些微秒,否则不应成为令人信服的理由。
【解决方案3】:

如何传递并不是这里唯一的变量,传递的内容会在两者之间产生很大的不同。

在 C++ 中,我们有 all kinds of value categories,并且这种“惯用语”适用于传入 rvalue(例如 "Alex-string-literal-that-constructs-temporary-std::string"std::move(nameString))的情况,这会导致 制作了 0 个 std::string 的副本(对于右值参数,该类型甚至不必是可复制构造的),并且只使用 std::string 的移动构造函数。

Somewhat related Q&A.

【讨论】:

    【解决方案4】:

    与 pass-by-(rv)reference 相比,pass-by-value-and-move 方法有几个缺点:

    • 它会生成 3 个对象而不是 2 个;
    • 按值传递对象可能会导致额外的堆栈开销,因为即使是常规字符串类通常也至少比指针大 3 或 4 倍;
    • 参数对象构造将在调用方完成,导致代码膨胀;

    【讨论】:

    • 你能解释一下为什么它会导致 3 个对象产生吗?据我了解,我可以将“Peter”作为字符串传递。这会被生成、复制然后移动,不是吗?无论如何,堆栈不会在某个时候使用吗?不是在构造函数调用时,而是在 m_name{name} 被复制的部分?
    • @Blackbot 我指的是您的示例std::string nameString("Alex"); Creature c(nameString); 一个对象是nameString,另一个是函数参数,第三个是类字段。
    【解决方案5】:

    在我的例子中,切换到按值传递然后执行 std:move 会导致 Address Sanitizer 中出现 heap-use-after-free 错误。

    https://travis-ci.org/github/acgetchell/CDT-plusplus/jobs/679520360#L3165

    所以,我把它关掉了,还有clang-tidy中的建议。

    https://github.com/acgetchell/CDT-plusplus/compare/80c96789f0a2...0d78fd63b332

    【讨论】:

      猜你喜欢
      • 2013-08-20
      • 1970-01-01
      • 1970-01-01
      • 2011-04-07
      • 2011-02-02
      • 1970-01-01
      • 2019-09-26
      • 1970-01-01
      相关资源
      最近更新 更多