传递const std::string&作为参数的日子结束了吗?

690

我听了Herb Sutter最近的一次演讲,他建议传递std::vectorstd::string的原因大多数已经消失。他建议编写以下函数现在更可取:

std::string do_something ( std::string inval )
{
   std::string return_val;
   // ... do stuff ...
   return return_val;
}
我明白在函数返回时,return_val将成为一个右值,因此可以使用移动语义来返回,而移动语义非常廉价。但是,inval仍然比引用的大小要大得多(引用通常实现为指针)。这是因为具有各种组件,包括堆中的指针和成员char[]以进行短字符串优化。因此,按引用传递仍然是一个好主意。
有人能解释一下Herb为什么会这样说吗?

118
我认为这个问题的最佳答案可能是阅读Dave Abrahams在C++ Next上的文章,(https://web.archive.org/web/20140113221447/http://cpp-next.com/archive/2009/08/want-speed-pass-by-value/)。我想补充一下的是,我没有看到任何关于这个问题不适合或不具有建设性的地方。这是一个明确的关于编程的问题,有事实依据可以回答。 - Jerry Coffin
3
有趣的是,如果你必须要复制一份数据,按值传递比按引用传递更快。 - Benj
10
我敏感于问题被错误地归类为重复并关闭。我不记得这个案例的细节,也没有重新审查过它们。相反,我打算删除我的评论,假设我犯了错误。感谢您把这件事告诉我。 - Howard Hinnant
5
@HowardHinnant,非常感谢你的关注和敏锐度。当一个人遇到这样高水平的关注和敏感时,总是很珍贵的时刻,令人耳目一新!(我当然会删除我的留言。) - Sz.
1
自从引入了std::string_view,就再也没有一个好的理由传递字符串的引用了。但是考虑到你提出这个问题的时间,这可能不是你想要的答案! - undefined
13个回答

8

在IT技术方面,对于std::string的使用,采用C++参考文献是一种快速简短的本地优化方法,而通过传值可能会(或不会)获得更好的全局优化。

因此,答案是:这取决于情况:

  1. 如果您从外部编写所有代码到内部函数,则知道代码执行的操作,可以使用引用const std::string &
  2. 如果您编写库代码或大量使用传递字符串的库代码,则可能通过信任std::string复制构造函数行为在全局意义上获得更多收益。

3
如评论中@JDługosz指出的那样,Herb在另一场(晚一些的?)演讲中给出了其他建议,大约从这里开始看:https://youtu.be/xnqTKD8uD64?t=54m50s
他的建议是:只为带有所谓“sink参数”的函数 f 使用值参数,假设您将从这些sink参数中进行移动构造。
与针对lvalue和rvalue参数分别量身定制的f的最佳实现相比,这种通用方法仅增加了一个移动构造函数的开销。要了解为什么会这样,请假设f采用值参数,其中T是某个可复制和可移动构造类型:
void f(T x) {
  T y{std::move(x)};
}

使用lvalue参数调用f将导致调用复制构造函数来构造x,并调用移动构造函数来构造y。另一方面,使用rvalue参数调用f将导致调用移动构造函数来构造x,并再次调用移动构造函数来构造y

通常,针对lvalue参数的f的最佳实现如下:

void f(const T& x) {
  T y{x};
}

在这种情况下,只调用一个复制构造函数来构造y。对于右值参数,f的最佳实现通常如下:
void f(T&& x) {
  T y{std::move(x)};
}

在这种情况下,只调用一个移动构造函数来构造 y
因此,一个明智的折衷方案是采用值参数,并针对与最优实现相比的lvalue或rvalue参数进行一次额外的移动构造函数调用,这也是Herb在演讲中给出的建议。
正如@JDługosz在评论中指出的那样,仅通过值传递对于将从sink参数构建某个对象的函数才有意义。当您拥有一个复制其参数的函数 f 时,与通用的传递-const引用方法相比,传递-值方法将具有更多的开销。对于保留其参数副本的函数 f,传递值的方法将具有以下形式:
void f(T x) {
  T y{...};
  ...
  y = std::move(x);
}

在这种情况下,对于左值参数,存在一个复制构造和移动赋值,对于右值参数,则存在一个移动构造和移动赋值。最优秀的左值参数情况为:
void f(const T& x) {
  T y{...};
  ...
  y = x;
}

这仅涉及一个赋值操作,与通过值传递所需的复制构造函数和移动赋值相比,成本可能要低得多。原因是赋值操作可能会重用y中已经分配的内存,从而避免(解)分配内存,而复制构造函数通常会分配内存。

对于rvalue参数,保留副本的最优实现的形式为:f

void f(T&& x) {
  T y{...};
  ...
  y = std::move(x);
}

因此,在这种情况下只需要移动分配。将一个rvalue传递给接受const引用的f版本只需要进行赋值而不是移动分配。因此,相对而言,在这种情况下,采用接受const引用的f版本作为通用实现更为可取。

因此,为了获得最优实现,通常需要进行重载或某种完美转发,正如演讲中所示。缺点是需要过载参数数量与f的参数数目相关的组合爆炸方式。完美转发的缺点是f将成为模板函数,这将阻止它成为虚函数,并且如果想要100%正确的结果,会导致显著更复杂的代码(请参见演讲的详细信息)。


请查看Herb Sutter在我的新答案中的研究结果:只有在移动构造时才执行此操作,而不是移动分配。 - JDługosz
1
@JDługosz,感谢您指向Herb的讲话,我刚刚看了它并完全修改了我的答案。我之前不知道(移动)分配建议。 - Ton van den Heuvel
那个数字和建议现在在标准指南文档中。 - JDługosz

0
问题在于“const”是一个非粒度限定符。通常所说的“const string ref”的意思是“不要修改这个字符串”,而不是“不要修改引用计数”。在C++中,根本没有办法表达哪些成员是“const”。它们要么全部是,要么全部不是。
为了解决这个语言问题,STL可以允许在你的例子中使用“C()”来进行移动语义复制,并忠实地忽略与引用计数(可变)相关的“const”。只要规范得当,这就没问题。
由于STL没有这样做,我有一个版本的字符串,它通过const_casts<>去掉了引用计数器(在类层次结构中无法追溯地使某些东西可变),并且-惊人的是-您可以自由地将cmstring作为const引用传递,并在深层函数中对它们进行复制,整天都没有泄漏或问题。
由于C++在这里没有“派生类const粒度”,编写一个良好的规范并制作一个闪亮的“const可移动字符串”(cmstring)对象是我见过的最佳解决方案。

@BenVoigt 是的...与其强制转换,它应该是可变的...但你不能在派生类中将STL成员更改为可变的。 - Erik Aronesty
3
听起来你有一个很酷的解决方案,但我不确定这个问题是什么。也许你可以添加一些引言句子,以明确问题(“我听说Herb Sutter最近发表了一篇演讲,他建议通过const&传递std :: vector和std :: string的原因基本上已经消失了。...有人能解释一下为什么Herb会这样说吗?”)与你的答案开头之间的联系(“问题在于“const”是一个非粒度定限符。...”)。 - Don Hatch

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