为什么在Herb Sutter的CppCon 2014演讲(返回基础知识:现代C ++风格)中不建议采用赋值设置器成员函数?


70

在Herb Sutter的CppCon 2014演讲“回到基础:现代C ++风格”中,他在幻灯片28(此处是幻灯片的网络副本)上提到了这种模式:

class employee {
  std::string name_;
public:
  void set_name(std::string name) noexcept { name_ = std::move(name); }
};

他说这是有问题的,因为当用临时调用set_name()时,noexcept-ness并不强(他使用短语“ noexcept-ish”)。

现在,我在自己最近的C ++代码中已经大量使用了上述模式,主要是因为它使我每次都不必键入两个set_name()副本-是的,我知道每次强制执行一个副本构造可能会有点效率低下,但嘿,我是一个懒惰的打字员。但是,Herb的短语“ this noexcept有问题”令我担心,因为我在这里没有遇到问题:std :: string的移动赋值运算符是noexcept,它的析构函数也是如此,因此对我来说,上面的set_name()似乎可以保证noexcept。我确实在编译器准备参数时set_name()之前看到了潜在的异常抛出,但是我正在努力将其视为有问题的。

后来在幻灯片32上,赫伯清楚地指出以上内容是反模式。有人可以向我解释为什么我懒得写不好的代码吗?


5
如果我没记错的话,Herb说这noexcept是个神话,因为发生在被叫方的分配可能会抛出(例如,std::string原始字符串文字或其他构建时std::string)。因此,主体不会抛出该异常,但是调用该函数仍可能导致抛出异常(std::bad_alloc
Piotr Skotnicki 2014年

7
该函数已标记为noexcept,但调用它可能会抛出。在函数主体本身输入之前引发异常的事实开始进入“有多少天使可以在别针的头上跳舞”的领域。
杰里·科芬

7
我大体上同意你的观点,但我可以理解赫伯的观点。该noexcept真的只是说“如果调用此函数没有抛出,我答应你不会得到一个异常”,这可以说是比它可能用处不大。
Jonathan Wakely 2014年

12
海事组织赫伯在谈话中所做的事情是从他所依据的一些基准中得到的一般性建议std::string。这很愚蠢,使整个练习变得毫无意义。const&通常,不要使孤独的重载。除了关于事情的事情noexcept,赫伯关于一个人的论点const&不应该说服除std::string
R. Martinho Fernandes 2014年

5
@NiallDouglas:在幻灯片23的底部,他显示了一个表格“ C ++ 98:合理的默认建议”,在幻灯片24的顶部,他显示了表格“现代C ++:合理的默认建议”,并且两者表是相同的。我不确定那里还不清楚。
沃恩·卡托

Answers:


40

其他人已经涵盖了noexcept上面的推理。

Herb在效率方面的讨论花费了更多时间。问题不在于分配,而在于不必要的释放。当您复制一个std::string如果有足够的空间来保存要复制的数据,则复制例程将重用目标字符串的已分配存储。在进行移动分配时,目标字符串的现有存储必须被重新分配,因为它从源字符串继承了存储。“复制并移动”这个惯用语迫使总是要进行重新分配,即使您没有通过临时传递也是如此。这是在稍后的演讲中演示的可怕表现的根源。他的建议是改为使用const ref,如果您确定需要使用const ref,则对r值引用有重载。这将为您提供两全其美的优势:将非临时文件复制到现有存储中,避免了重新分配,而将临时文件移到您所在的位置

上面的内容不适用于构造函数,因为成员变量中没有要分配的存储空间。这很好,因为构造函数通常会使用多个参数,并且如果您需要为每个参数执行const ref / r-value ref重载,则最终会产生构造函数重载的组合爆炸。

现在的问题是:复制时有多少类可以重用std :: string之类的存储?我猜std :: vector确实可以,但是我不确定。我确实知道我从未写过这样的可重用存储的类,但是我写了很多包含字符串和向量的类。遵循Herb的建议,对于不会重复使用存储的类不会造成伤害,首先将使用接收器功能的复制版本进行复制,如果您确定复制对性能造成的影响太大,则您会进行r值引用重载以避免复制(就像对std :: string那样)。另一方面,使用“复制和移动”确实对std :: string和其他可重用存储的类型产生了明显的性能影响,这些类型可能在大多数人的代码中都有很多用途。我现在正在遵循Herb的建议,但是在我认为问题完全解决之前(我可能没有足够的时间来潜伏这一切的博客文章),需要多考虑一些问题。


2
这是一个很好的答案。如果您可以稍微修改一下,以指出此“反模式”标签仅适用于std :: string保持容量(如Herb所述),并且如果我们不使用能够重用其现有容量的复杂类型,并且无论如何都必须取消分配,我将其标记为已接受的答案。谢谢,顺便说一句,你真的帮了我的大忙。
尼尔·道格拉斯

我编辑了最后一段,以使我对赫伯的建议有何保留。我以前对它们有点含糊。
布雷特·霍尔

11

考虑两个原因,为什么按值传递可能比按const引用传递更好。

  1. 更高效
  2. 没有

对于类型成员的setter std::string,他证明了const引用传递通常会产生较少的分配(至少对于std::string),从而驳斥了按值传递更有效的说法。

他还noexcept通过证明noexcept声明具有误导性来驳斥它允许设置器的说法,因为在复制参数的过程中仍然可能发生异常。

因此,他得出结论,至少在这种情况下,通过const引用传递比通过值传递更可取。但是,他确实提到按值传递对于构造函数而言是一种潜在的好方法。

我确实认为std::string仅靠示例是不足以概括所有类型的,但确实引起了质疑,至少出于效率和例外的原因,通过值传递昂贵的拷贝但廉价的参数传递值的做法。


您基本上已经描述了幻灯片。我们都知道这些(从幻灯片中)。并且说采用set_name()的值可以生成异常是不正确的,因为它不能由于noexcept而导致。
Niall Douglas 2014年

@NiallDouglas:我同意幻灯片中都介绍了它,但是它怎么不能回答您的问题呢?
沃恩·卡托

3
我发现效率论点很有说服力,但是您在问题中将其抛在一边:)霍华德出于类似的(无效)原因,不喜欢复制和交换习惯。即使在某些情况下name_.capacity() > name.length(),它也只执行一次memcpy分配(因此速度较慢,可能会抛出)。这本身似乎足以标记它的反模式和主张提供两个重载
乔纳森Wakely

2
@JonathanWakely我也发现效率参数引人注目,但不足以让我烦恼我为每个编写的setter函数编写两个重载。如果基准测试显示这是一个问题,或者我试图使Boost库无法通过审查,那么我会打扰,否则不会。
Niall Douglas

3
价值传递可以是noexcept,但是正如赫伯在讲话中指出的那样,“不是真的”,因为在许多情况下,必须制作一个副本以进行传递,并且创建该副本可能会被抛出。当然,该异常发生在调用之前,但是仍然发生。
马歇尔(Marshall)Clow,2014年

6

Herb的观点是,在已经分配了存储空间的情况下,按价值计算可能会效率低下并导致不必要的分配。但是采用byconst&几乎是很糟糕的,就像采用原始C字符串并将其传递给函数一样,会发生不必要的分配。

您应该采取的是从字符串读取的抽象,而不是字符串本身,因为这是您所需要的。

现在,您可以执行以下操作template

class employee {
  std::string name_;
public:
  template<class T>
  void set_name(T&& name) noexcept { name_ = std::forward<T>(name); }
};

这是相当有效的。然后添加一些SFINAE也许:

class employee {
  std::string name_;
public:
  template<class T>
  std::enable_if_t<std::is_convertible<T,std::string>::value>
  set_name(T&& name) noexcept { name_ = std::forward<T>(name); }
};

因此我们在接口而不是实现上会遇到错误。

这并不总是可行的,因为它需要公开公开实现。

这是string_view类型类可以进入的地方:

template<class C>
struct string_view {
  // could be private:
  C const* b=nullptr;
  C const* e=nullptr;

  // key component:
  C const* begin() const { return b; }
  C const* end() const { return e; }

  // extra bonus utility:
  C const& front() const { return *b; }
  C const& back() const { return *std::prev(e); }

  std::size_t size() const { return e-b; }
  bool empty() const { return b==e; }

  C const& operator[](std::size_t i){return b[i];}

  // these just work:
  string_view() = default;
  string_view(string_view const&)=default;
  string_view&operator=(string_view const&)=default;

  // myriad of constructors:
  string_view(C const* s, C const* f):b(s),e(f) {}

  // known continuous memory containers:
  template<std::size_t N>
  string_view(const C(&arr)[N]):string_view(arr, arr+N){}
  template<std::size_t N>
  string_view(std::array<C, N> const& arr):string_view(arr.data(), arr.data()+N){}
  template<std::size_t N>
  string_view(std::array<C const, N> const& arr):string_view(arr.data(), arr.data()+N){}
  template<class... Ts>
  string_view(std::basic_string<C, Ts...> const& str):string_view(str.data(), str.data()+str.size()){}
  template<class... Ts>
  string_view(std::vector<C, Ts...> const& vec):string_view(vec.data(), vec.data()+vec.size()){}
  string_view(C const* str):string_view(str, str+len(str)) {}
private:
  // helper method:
  static std::size_t len(C const* str) {
    std::size_t r = 0;
    if (!str) return r;
    while (*str++) {
      ++r;
    }
    return r;
  }
};

可以直接从astd::string或a构造这样的对象,"raw C string"并且几乎无成本地存储您需要知道的内容,以便从中生成新的对象std::string

class employee {
  std::string name_;
public:
  void set_name(string_view<char> name) noexcept { name_.assign(name.begin(),name.end()); }
};

并且由于现在我们set_name有一个固定的接口(不是完美的转发接口),因此它的实现可能不可见。

唯一的低效率是,如果您传递C风格的字符串指针,则在某种程度上不必要地遍历了它的大小两次(第一次寻找'\0',第二次复制它们)。另一方面,这为您的目标信息提供了必须达到的大小,因此可以预先分配而不是重新分配。


关于您通过以下方式传递以无效值结尾的字符串指针时的效率低下的评论string_view:即使您将指针直接传递给std::string::assign,该实现几乎肯定也需要两次传递才能扫描长度并进行复制。无论如何,对于大于SSO大小的字符串。
Casey 2014年

@Casey想象一个push_back基于目标的实现,该实现将清除目标,然后推送每个字符,并'\0'在停止之前进行检查。这只会读取一次。无法预分配目标缓冲区是有代价的:但是,如果您不断分配相似的长度字符串,则这种实现可能会更快。一个string_view<char>不能复制的是:一个template实现可能。实际上,的std::basic_string<char>::operator=重载char const*可能会使输入字符走到\0外面寻找副本,直到离开房间,然后搜索大小并调整大小。
Yakk-Adam Nevraumont 2014年

实际上,可以使用蹦床类型来节省为每个具有功能能力参数的成员函数编写两个重载。确实开始有点像问这个阶段有多少天使可以在一根针上跳舞……
Niall Douglas

1
@NiallDouglas但是range_viewarray_viewstring_view班级有很多这个问题的效用之外:取范围的非复制小节的能力,连续的数组或字符串是非常强大的。这恰好是它们的另一种用法。
Yakk-Adam Nevraumont 2014年

2

您有两种方法来调用该方法。

  • 使用形rvalue参时,只要形move constructor参类型为noexcept,就没有问题(std::string大多数情况下为noexcept),在任何情况下都最好使用条件式noexcept(以确保形参为noexcept)
  • 对于lvalue参数,在这种情况下,copy constructor将调用参数类型的,几乎可以确定它需要一些分配(可能会抛出)。

在这种情况下,使用可能会被误用,因此最好避免使用。class假定没有抛出异常的客户端,但是可以以有效,可编译,没有可疑的C++11方式抛出异常。


std :: string的移动分配运算符和析构函数保证为noexcept。及其移动构造函数,但奇怪的是,如果该移动构造函数采用了对分配器的引用,则不会。
Niall Douglas 2014年

这是一个缺陷:cplusplus.github.io/LWG/lwg-active.html#2063字符串移动分配不应该无条件限制,因为分配器可能不相等且不会传播,需要重新分配。对于移动构造,分配器始终传播,因此可以窃取存储。对于分配器扩展的move构造函数,如果提供的分配器与rvalue字符串中的分配器不匹配,则必须重新分配。
Jonathan Wakely 2014年

再次,该死的分配器。是的,我在建议的boost :: concurrent_unordered_map中看到了完全相同的问题,这很有意义。谢谢乔纳森。
Niall Douglas 2014年

2
真是该死的分配器!虽然我看到您说过的话std::stringstd::allocatorpropagate_on_container_move_assignment是正确的,但即使noexcept在将来的标准中取消了保证,std :: string的move分配也不会在实践中抛出。但是,这通常并不适用std::basic_string
Jonathan Wakely 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.