【问题标题】:Why is value taking setter member functions not recommended in Herb Sutter's CppCon 2014 talk (Back to Basics: Modern C++ Style)?为什么在 Herb Sutter 的 CppCon 2014 演讲(回归基础:现代 C++ 风格)中不推荐取值 setter 成员函数?
【发布时间】:2014-12-03 08:32:24
【问题描述】:

在 Herb Sutter 的 CppCon 2014 演讲中回归基础:现代 C++ 风格,他在幻灯片 28 (a web copy of the slides are here) 中提到了这种模式:

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 is problematic”让我担心,因为我在这里没有得到问题:std::string 的移动赋值运算符是 noexcept,它的析构函数也是,所以上面的 set_name() 似乎我保证没有例外。我确实看到编译器 before set_name() 在准备参数时抛出了一个潜在的异常,但我很难将其视为有问题的。

稍后在幻灯片 32 Herb 上明确指出上述是反模式。有人可以向我解释一下,为什么我一直因为懒惰而写出糟糕的代码?

【问题讨论】:

  • 如果我没记错的话,Herb 说 noexcept 在这里是一个神话,因为发生在被调用方一侧的分配可能会抛出(例如,从 原始字符串文字 或其他std::string)。所以body不会抛出,但是调用那个函数可能还是会抛出异常(std::bad_alloc
  • 函数标记为noexcept,但是调用它会抛出。在进入函数体本身之前抛出异常的事实开始进入“多少天使可以在针头上跳舞”的领域。
  • 我基本同意你的观点,但我明白 Herb 的观点。 noexcept 真的只是说“如果调用这个函数没有抛出,我保证你不会得到异常”,这可以说没有它可能有用。
  • IMO Herb 在那次演讲中所做的是从他在std::string 上运行的一些基准中获得一般性建议。这很愚蠢,使整个练习变得毫无意义。一般不要让const& 超载。除了关于noexcept 的事情之外,Herb 为一个孤独的const& 辩护的论点对于std::string 之外的其他任何事情都没有说服力。
  • @NiallDouglas:在幻灯片 23 的底部,他展示了“C++98:合理的默认建议”的表格,在幻灯片 24 的顶部,他展示了“现代 C++”的表格: 合理的默认建议”,两个表都是一样的。我不确定那里有什么不清楚的地方。

标签: c++ c++11 stl


【解决方案1】:

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

Herb 在效率方面的讨论上花费了更多时间。问题不在于分配,而在于不必要的解除分配。当您将一个std::string 复制到另一个时,如果有足够的空间来保存正在复制的数据,则复制例程将重用分配的目标字符串存储空间。在进行移动分配时,必须释放目标字符串的现有存储空间,因为它会从源字符串中接管存储空间。 “复制和移动”习语强制释放总是发生,即使你没有传递一个临时的。这是稍后在演讲中展示的可怕表现的根源。他的建议是改为使用 const ref,如果您确定需要它,则对 r 值引用进行重载。这将为您提供两全其美的优势:为非临时人员复制到现有存储中,避免解除分配,并为临时人员以一种或另一种方式支付解除分配的费用(目的地在移动之前解除分配,或者复制后源释放)。

以上不适用于构造函数,因为成员变量中没有存储空间可以释放。这很好,因为构造函数通常需要多个参数,如果您需要为每个参数执行 const ref/r-value ref 重载,最终会导致构造函数重载的组合爆炸。

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

【讨论】:

  • 这是一个很好的答案。如果您可以稍微修改它以提及此“反模式”标签仅适用于 std::string 保持容量(如 Herb 所述),并且如果我们没有使用能够重用其现有容量的复杂类型并且总是无论如何都必须解除分配,我会将其标记为已接受的答案。谢谢,顺便说一句,你真的为我击中了头。
  • 我编辑了最后一段,以使我对 Herb 建议的保留更加明确。我之前对它们有点含糊。
【解决方案2】:

考虑到为什么按值传递可能比通过 const 引用传递更好的原因有两个。

  1. 更高效
  2. 无例外

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

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

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

我确实认为仅std::string 的示例不足以推广到所有类型,但它确实质疑按值传递复制成本高但移动成本低的参数的做法,至少出于效率和例外的原因。

【讨论】:

  • 您基本上只是描述了幻灯片。我们知道这一切(从幻灯片中)。并且说取 set_name() 的值可以产生异常是不正确的,因为它不能由于 noexcept。
  • @NiallDouglas:我同意这一切都包含在幻灯片中,但它怎么不能回答你的问题?
  • 我发现效率论点非常有说服力,但您在问题中将其置之不理 :) 由于类似(无效)效率的原因,霍华德不喜欢复制和交换成语。即使在 name_.capacity() > name.length() 的情况下,它也会执行分配(因此速度较慢并且可能会抛出),因此您只需要一个 memcpy。仅此一项似乎就足以将其标记为反模式并主张提供两个重载
  • @JonathanWakely 我也发现效率论点令人信服,但不足以让我为我编写的每个设置器函数编写两个重载。如果基准测试显示它是一个问题,或者我试图让 Boost 库通过审查,那么我会打扰,否则不会。
  • 按值传递可以是noexcept,但正如 Herb 在演讲中指出的那样,“不是真的”,因为在许多情况下,必须制作一个副本才能传递,并且创建该副本可以扔。当然,异常发生在调用之前,但它仍然会发生。
【解决方案3】:

Herb 有一点,当您已经分配了存储空间时,按值取值可能效率低下并导致不必要的分配。但是通过const& 获取几乎一样糟糕,就像你获取一个原始的 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;
  }
};

这样的对象可以直接从std::string"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 想象一个基于push_back 的实现,它清除目标,然后推送每个字符,在停止之前检查'\0'。这只读取一次。无法预分配目标缓冲区是有代价的:但如果您不断分配相似长度的字符串,这样的实现可能会更快。 string_view&lt;char&gt; 不能复制它:template 实现可以。事实上,char const*std::basic_string&lt;char&gt;::operator= 重载可能会遍历输入字符以寻找 \0 复制直到空间不足,然后搜索大小并调整大小。
  • 确实可以使用蹦床类型来节省为每个采用容量能力参数的成员函数编写两个重载。确实开始感觉有点像在这个阶段问有多少天使可以在针头上跳舞……
  • @NiallDouglas 然而,range_viewarray_viewstring_view 类在这个问题之外还有很多用途:获取范围、连续数组或字符串的非复制子部分的能力是很强大。这恰好只是它们的另一种用途。
【解决方案4】:

您有两种调用这些方法的方法。

  • rvalue参数,只要参数类型的move constructor是noexcept就没有问题(std::string的情况下很可能是noexcept),无论如何最好使用有条件的noexcept(确保参数是 noexcept)
  • 使用lvalue 参数,在这种情况下,将调用参数类型的copy constructor,并且几乎可以肯定它需要一些分配(可能会抛出)。

在这种情况下可能会错过使用,最好避免使用。 class 的客户端假定没有按指定抛出异常,但在有效的、可编译的、不可疑的 C++11 中可能会抛出异常。

【讨论】:

  • std::string 的移动赋值运算符和析构函数保证为 noexcept。还有它的移动构造函数,但奇怪的是,如果移动构造函数引用分配器。
  • 这是一个缺陷:cplusplus.github.io/LWG/lwg-active.html#2063 字符串移动分配不应该是无条件的 noexcept,因为分配器可能不相等并且不会传播,需要重新分配。对于移动构造,分配器总是传播,所以存储可以被窃取。对于分配器扩展的移动构造函数,如果提供的分配器与右值字符串中的分配器不匹配,则必须重新分配。
  • 唉,又是该死的分配器。是的,我看到了与我提出的 boost::concurrent_unordered_map 完全相同的问题,这很有意义。谢谢乔纳森。
  • 确实是该死的分配器!虽然我看到你说std::string,并且因为它使用std::allocator,其中propagate_on_container_move_assignment 为真,但确实std::string 的移动分配永远不会在实践中抛出,即使noexcept 保证在一个未来的标准。不过,这通常不适用于std::basic_string
猜你喜欢
  • 1970-01-01
  • 2019-03-25
  • 1970-01-01
  • 1970-01-01
  • 2012-08-14
  • 2011-01-02
  • 2019-10-25
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多