传递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个回答

439

Herb说这样说的原因是因为像这样的情况。

假设我有一个名为A的函数,它调用函数B,该函数又调用函数C。而A通过B将一个字符串传递到C中。对于A来说,C是未知的,也不关心;A只关心B。也就是说,CB的实现细节。

假设A的定义如下:

void A()
{
  B("value");
}

如果B和C使用const&获取字符串,那么它看起来像这样:

void B(const std::string &str)
{
  C(str);
}

void C(const std::string &str)
{
  //Do something with `str`. Does not store it.
}

一切都很好。你只是在传递指针,没有复制,没有移动,大家都很开心。C采用const&,因为它不存储字符串,只是使用它。

现在,我想做一个简单的改变:C需要在某个地方存储这个字符串。

void C(const std::string &str)
{
  //Do something with `str`.
  m_str = str;
}

你好,关于复制构造函数和潜在的内存分配(忽略短字符串优化(SSO))。C++11的移动语义应该能够消除不必要的复制构造,对吗?而且A传递了一个临时变量;没有理由让C必须复制这些数据。它只需要带走给定的内容。

但实际上它做不到。因为它使用了const&

如果我将C更改为按值接受参数,那么这只会导致B将数据复制到该参数中,我并没有得到任何好处。

所以如果我刚开始就通过所有函数通过值传递了str,依靠std::move来调度数据,我们就不会有这个问题。如果有人想保留它,他们可以这样做。如果他们不这样做,那没关系。

这是否更昂贵?是的;移动到值中更昂贵,比使用引用更昂贵。对于具有SSO的小字符串而言,它是否比复制更便宜?不是。这值得做吗?

这取决于您的用例。你有多讨厌内存分配?


3
当您说将值移到另一个地方比使用引用更昂贵时,这仍然比常量金额更昂贵(与移动的字符串长度无关),对吗? - Neil G
5
@NeilG:你明白“实现相关”的意思吗?你所说的是错误的,因为这取决于单点登录(SSO)是否被实现以及如何实现。 - ildjarn
20
如果某事物的最坏情况受到常量限制,那么它仍然是常量时间。难道没有最长的小字符串吗?那个字符串复制需要一定恒定的时间吧?所有更短的字符串都需要更少的时间来复制吧?因此,在顺序分析中,对于小字符串的复制是“常量时间”——尽管复制小字符串所需的时间不同。顺序分析关注的是渐近行为 - Neil G
8
@NeilG:当然,但你最初的问题是:“移动的字符串长度不同,费用仍然会增加相同的固定金额吗?”我想说明的是,费用可能会因为被移动字符串的长度不同而增加_不同的_固定金额,这导致答案是否定的。 - ildjarn
22
在传值的情况下,为什么要将字符串从B移动到C?如果B是"B(std::string b)"而C是"C(std::string c)",那么我们要么在B中调用"C(std::move(b))",要么'b'必须保持不变(因此“未移动”)直到退出B。即使在函数参数使用右值初始化,它在函数内部仍然是左值,需要使用"std::move"才能从该左值移动。对于"str"到"m_str"的复制也是如此。也许一个优化编译器会在调用后将字符串按照“好像规则”移动,但我认为没有强有力的保证。 - Pixelchemist
显示剩余16条评论

182
作为参数传递const std :: string&的日子结束了吗?并不是。许多人将此建议(包括Dave Abrahams在内)扩展到适用于所有std :: string参数 - 总是通过值传递std :: string并不是任意参数和应用程序的“最佳实践”,因为这些谈话/文章专注的优化仅适用于一组受限制的情况。如果您返回值、更改参数或获取值,则通过值传递可以节省昂贵的复制并提供语法上的便利性。与往常一样,当您不需要副本时,通过const引用保存了大量的复制。如果堆栈大小是一个问题(并且假设这不是内联/优化),则return_val + inval> return_val - 也就是说,在这里通过值传递可以减少峰值堆栈使用量(注意:这是ABI的过度简化)。同时,通过const引用传递可能会禁用优化。这里的主要原因不是避免堆栈增长,而是确保可以在适用的地方执行优化。通过const引用的日子并没有结束 - 规则比以前更加复杂。如果性能很重要,您应该考虑如何传递这些类型,具体取决于您在实现中使用的细节。

4
在堆栈使用方面,典型的ABI将通过寄存器传递单个引用,而不使用堆栈。 - ahcox

71

简短回答:不行! 详细回答:

  • 如果您不打算修改字符串(将其视为只读),请将其作为const ref&传递。
    (显然,const ref&需要在使用它的函数执行期间保持范围内)
  • 如果您计划修改它或知道它会超出范围(线程),请将其作为value传递,不要在函数体内复制const ref&

cpp-next.com上有一篇名为“Want speed, pass by value!”的帖子。 简而言之:

 

指南:不要复制函数参数,而是通过值进行传递,让编译器进行复制。

^的翻译

不要复制函数参数 --- 意思是:如果您计划通过将其复制到内部变量来修改参数值,则只需使用值参数即可

所以不要这样做

std::string function(const std::string& aString){
    auto vString(aString);
    vString.clear();
    return vString;
}

执行此操作:

std::string function(std::string aString){
    aString.clear();
    return aString;
}

当你需要在函数体内修改参数值时。

你只需要注意在函数体内如何使用该参数。它是只读还是非只读...如果仅限于作用域内。


3
您建议在某些情况下使用引用传递,但您指向一个指南,建议始终通过值传递。 - Keith Thompson
5
@KeithThompson所说的“Don’t copy your function arguments.”意思是不要复制const ref&到一个内部变量中以修改它。如果你需要修改它...就将参数变成值传递。对于我这个非英语母语的人来说,这非常清楚易懂。 - CodeAngry
9
@KeithThompson 提到的指南引用(“不要复制函数参数。相反,通过值传递它们,让编译器进行复制。”)是从该页面中复制的。如果这还不够清楚,我无能为力。我不完全信任编译器做出最佳选择。我宁愿在定义函数参数时非常清楚地表达我的意图。#1 如果只读,则为const ref&。#2 如果我需要编写它或者我知道它会超出范围……我使用值。#3 如果我需要修改原始值,则通过ref&传递。#4 如果参数是可选的,则使用pointers *,这样就可以将其设置为nullptr - CodeAngry
11
我在这个问题上并不偏袒传值还是传引用。我的观点是你主张在某些情况下传递引用,但是却引用了一条指南(看起来是支持传值的),似乎是为了支持你的立场。如果你不同意这个指南,你可能需要表态并解释原因。 (链接到cpp-next.com对我来说无法使用。) - Keith Thompson
8
您误解了指南的概括。它并不是说要“始终”按值传递。总结一下,它的意思是“如果您本来会制作一个本地副本,请使用按值传递让编译器为您执行该副本。”它并没有说当您不想制作副本时就使用按值传递。 - Ben Voigt
显示剩余3条评论

67

这高度取决于编译器的实现。

但是,它也取决于你使用的内容。

让我们考虑下一个函数:

bool foo1( const std::string v )
{
  return v.empty();
}
bool foo2( const std::string & v )
{
  return v.empty();
}

为了避免内联,这些函数被实现在独立的编译单元中。然后:
1. 如果将字面量传递给这两个函数,则性能差异不大。在两种情况下,都需要创建一个字符串对象。
2. 如果传递另一个std::string对象,则foo2将优于foo1,因为foo1将执行深度复制。

在我的PC上,使用g ++ 4.6.1,我得到了以下结果:

  • 按引用传递变量:1000000000次迭代->经过的时间:2.25912秒
  • 按值传递变量:1000000000次迭代->经过的时间:27.2259秒
  • 按引用传递字面量:100000000次迭代->经过的时间:9.10319秒
  • 按值传递字面量:100000000次迭代->经过的时间:8.62659秒

6
更相关的是函数内部发生了什么:如果使用引用调用,它是否需要在内部进行复制以便在按值传递时可以省略? - leftaroundabout
1
@leftaroundabout 当然。我假设这两个函数完全执行相同的操作。 - BЈовић
8
这不是我的观点。无论是按值传递还是按引用传递,哪种更好取决于函数内部的操作。在您的示例中,实际上并没有使用很多字符串对象,因此引用显然更好。但是,如果函数的任务是将字符串放入某个结构中或执行涉及多次字符串分割的递归算法等操作,则按值传递可能比按引用传递节省一些复制。Nicol Bolas 解释得很好。 - leftaroundabout
7
在我看来,“这取决于函数内部的操作”的做法是不好的设计,因为你是基于实现细节来定义函数的参数。 - Hans Olsson
2
可能是打字错误,但最后两个文字计时循环次数少了10倍。 - TankorSmash
显示剩余6条评论

44

除非你确实需要一个副本,否则仍然可以使用const&。例如:

bool isprint(std::string const &s) {
    return all_of(begin(s),end(s),(bool(*)(char))isprint);
}
如果你改为按值传递字符串,则最终会移动或复制该参数,而这是没有必要的。不仅复制/移动可能更昂贵,而且还引入了新的潜在故障;复制/移动可能会抛出异常(例如,在复制期间分配可能会失败),而对现有值进行引用则不能。
如果您确实需要副本,则通过值传递和返回通常是(总是?)最佳选择。实际上,除非您发现额外的副本实际上会导致性能问题,否则我通常不会在C++03中担心它。在现代编译器上,复制省略似乎相当可靠。我认为人们的怀疑和坚持认为您必须检查支持RVO的编译器的表格大多已经过时了。
简而言之,C++11在这方面并没有真正改变任何事情,除了那些不信任复制省略的人。

2
移动构造函数通常使用 noexcept 实现,但显然复制构造函数不是。 - leftaroundabout

38
几乎是的。
在C++17中,我们有basic_string_view<?>,这基本上使得std::string const&参数只有一种狭窄用途。
移动语义的存在消除了std::string const&的一个用例--如果您打算存储参数,则通过值取std::string更加优化,因为您可以从参数中move
如果有人使用原始C "字符串"调用您的函数,这意味着仅分配了一个std::string缓冲区,而不是std::string const&情况下的两个缓冲区。
但是,如果您不打算复制,请在C++14中采用std::string const&
通过std::string_view,只要您没有将该字符串传递给期望C样式'\0'终止字符缓冲区的API,您就可以更有效地获得类似std::string的功能,而无需冒任何风险进行分配。甚至可以将原始C字符串转换为std::string_view,而无需进行任何分配或字符复制。
在这一点上,std::string const&的用途是当您不会整体复制数据,并且将其传递给期望空终止缓冲区的C样式API,并且您需要std::string提供的更高级字符串功能。实际上,这是一组罕见的要求。

3
我很感激这个答案,但我想指出它(像许多好的答案一样)存在一些特定领域的偏见。具体而言:“在实践中,这是一组罕见的要求”……在我的开发经验中,这些限制 - 在作者看来似乎异常狭窄 - 实际上经常被满足。值得指出这一点。 - fish2000
1
@fish2000 为了明确,要让 std::string 占据主导地位,你不仅需要满足其中的 一些 要求,而是需要满足所有要求。其中任何一个或两个要求,我承认是很常见的。也许你通常需要这三个要求(比如,你正在解析字符串参数以选择要完整传递给哪个 C API?) - Yakk - Adam Nevraumont
@Yakk-AdamNevraumont 这是一种因人而异的事情 - 但如果你正在编写针对 POSIX 或其他 API 的程序,其中 C 字符串语义是最低公共分母,那么这就是一个频繁的用例。我应该真的说我喜欢 std::string_view - 正如你所指出的,“原始的 C 字符串甚至可以被转换为 std::string_view,而无需进行任何分配或字符复制”,这是值得记住的事情,对于那些在此类 API 使用上下文中使用 C++ 的人来说,确实如此。 - fish2000
3
“一个原始的 C 字符串甚至可以直接转换成 std::string_view,而无需进行任何分配或字符复制”,这是值得记住的事情。确实如此,但它遗漏了最好的部分——如果原始字符串是一个字符串字面量,在这种情况下,*甚至不需要运行时 strlen()*! - Don Hatch
虽然我还想问另一个问题。@Yakk-AdamNevraumont那使用完美转发怎么样呢?例如,void f(std::string&& s) { std::string copy = std::forward(s)} - Bob
显示剩余2条评论

18

std::string不是Plain Old Data(POD),它的原始大小并不是最重要的事情。例如,如果你传入一个超过SSO长度并在堆上分配的字符串,我会期望复制构造函数不会复制SSO存储。

之所以建议这样做是因为inval是从参数表达式构造的,因此始终会根据需要移动或复制-假设您需要拥有参数。如果您不需要,则使用const引用仍然可能是更好的选择。


2
关于复制构造函数足够聪明,如果不使用SSO就不必担心它的有趣观点。可能是正确的,我将不得不检查是否属实;-) - Benj
4
@Benj:我知道这是个旧评论,但如果SSO足够小,则无条件地复制它比进行条件分支更快。例如,64字节是一个缓存行,可以在非常短的时间内轻松复制。在x86_64上可能只需要8个时钟周期或更少。 Translated: @Benj: 我知道这是一条旧评论,但如果SSO足够小,则无条件地复制它比进行条件分支更快。例如,64字节就是一个缓存行,可以在非常短的时间内轻松复制。在x86_64上可能只需要8个或更少的时钟周期。 - Zan Lynx
即使SSO没有被复制构造函数复制,一个std::string<>也会分配32个字节从堆栈中,其中16个需要初始化。相比之下,只有8个字节被分配和初始化为引用:这是两倍的CPU工作量,并且占用四倍的缓存空间,这些空间将不可用于其他数据。 - cmaster - reinstate monica
哦,我忘了讲述如何在寄存器中传递函数参数;这将使最后一位被调用者的堆栈使用降至零... - cmaster - reinstate monica

17

我复制/粘贴了来自这个问题的答案,并更改了名称和拼写以适应此问题。

下面是测量所需内容的代码:

#include <iostream>

struct string
{
    string() {}
    string(const string&) {std::cout << "string(const string&)\n";}
    string& operator=(const string&) {std::cout << "string& operator=(const string&)\n";return *this;}
#if (__has_feature(cxx_rvalue_references))
    string(string&&) {std::cout << "string(string&&)\n";}
    string& operator=(string&&) {std::cout << "string& operator=(string&&)\n";return *this;}
#endif

};

#if PROCESS == 1

string
do_something(string inval)
{
    // do stuff
    return inval;
}

#elif PROCESS == 2

string
do_something(const string& inval)
{
    string return_val = inval;
    // do stuff
    return return_val; 
}

#if (__has_feature(cxx_rvalue_references))

string
do_something(string&& inval)
{
    // do stuff
    return std::move(inval);
}

#endif

#endif

string source() {return string();}

int main()
{
    std::cout << "do_something with lvalue:\n\n";
    string x;
    string t = do_something(x);
#if (__has_feature(cxx_rvalue_references))
    std::cout << "\ndo_something with xvalue:\n\n";
    string u = do_something(std::move(x));
#endif
    std::cout << "\ndo_something with prvalue:\n\n";
    string v = do_something(source());
}

对我来说,这会输出:
$ clang++ -std=c++11 -stdlib=libc++ -DPROCESS=1 test.cpp
$ a.out
do_something with lvalue:

string(const string&)
string(string&&)

do_something with xvalue:

string(string&&)
string(string&&)

do_something with prvalue:

string(string&&)
$ clang++ -std=c++11 -stdlib=libc++ -DPROCESS=2 test.cpp
$ a.out
do_something with lvalue:

string(const string&)

do_something with xvalue:

string(string&&)

do_something with prvalue:

string(string&&)

下表总结了我的结果(使用clang -std=c++11)。第一个数字是复制构造函数的数量,第二个数字是移动构造函数的数量:
+----+--------+--------+---------+
|    | lvalue | xvalue | prvalue |
+----+--------+--------+---------+
| p1 |  1/1   |  0/2   |   0/1   |
+----+--------+--------+---------+
| p2 |  1/0   |  0/1   |   0/1   |
+----+--------+--------+---------+

按值传递的解决方案只需要一个重载,但在传递左值和右值时会多出一个移动构造函数。这在任何情况下都可能是可接受的或不可接受的。两种解决方案都有优缺点。


1
std::string是一个标准库类。它已经可以移动和复制。我不明白这与问题有什么关系。OP更关心移动与引用的性能,而不是移动与复制的性能。 - Nicol Bolas
3
本答案会计算按传值方式设计的std::string所需进行的移动和复制次数,与使用一对重载函数按引用传递进行比较。我将在示例中使用OP提供的代码,除了替换一个虚拟字符串以提示何时发生了复制/移动。 - Howard Hinnant
在进行测试之前,您应该优化代码。 - The Paramagnetic Croissant
3
你得到了不同的结果吗?如果是这样,你是使用什么编译器和命令行参数的? - Howard Hinnant

16

Herb Sutter和Bjarne Stroustrup仍然建议使用const std::string&作为参数类型;请参见https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#Rf-in

这里没有提到其他回答中未提到的一个陷阱:如果将字符串字面值传递给const std::string&参数,则会传递一个引用到临时字符串,即即时创建的字符串来保存文字的字符。 如果然后保存该引用,则在临时字符串被解除分配时,它将无效。为确保安全,必须保存副本而不是引用。问题源于字符串字面值是const char[N]类型,需要提升为std::string

下面的代码说明了陷阱和解决方法,以及一种小的效率选项——重载具有const char*方法的函数,并参见Is there a way to pass a string literal as reference in C++

(注意:Sutter和Stroustrup建议如果您保留字符串的副本,还应提供具有&&参数和std::move()的重载函数。)

#include <string>
#include <iostream>
class WidgetBadRef {
public:
    WidgetBadRef(const std::string& s) : myStrRef(s)  // copy the reference...
    {}

    const std::string& myStrRef;    // might be a reference to a temporary (oops!)
};

class WidgetSafeCopy {
public:
    WidgetSafeCopy(const std::string& s) : myStrCopy(s)
            // constructor for string references; copy the string
    {std::cout << "const std::string& constructor\n";}

    WidgetSafeCopy(const char* cs) : myStrCopy(cs)
            // constructor for string literals (and char arrays);
            // for minor efficiency only;
            // create the std::string directly from the chars
    {std::cout << "const char * constructor\n";}

    const std::string myStrCopy;    // save a copy, not a reference!
};

int main() {
    WidgetBadRef w1("First string");
    WidgetSafeCopy w2("Second string"); // uses the const char* constructor, no temp string
    WidgetSafeCopy w3(w2.myStrCopy);    // uses the String reference constructor
    std::cout << w1.myStrRef << "\n";   // garbage out
    std::cout << w2.myStrCopy << "\n";  // OK
    std::cout << w3.myStrCopy << "\n";  // OK
}
输出:
const char * constructor
const std::string& constructor

Second string
Second string

这是一个不同的问题,而 WidgetBadRef 不需要有 const& 参数才会出错。问题是,如果 WidgetSafeCopy 只接受一个字符串参数,它是否会变慢?(我认为将副本临时成员复制肯定更容易发现) - Superfly Jon

16

请参阅“Herb Sutter "Back to the Basics! Essentials of Modern C++ Style”。除其他主题外,他还回顾了过去给出的参数传递建议以及C++11带来的新思想,并特别关注按值传递字符串的想法。

slide 24

基准测试显示,在函数将在任何情况下复制字符串时,通过值传递std::string可能会明显地变慢!
这是因为您强制它始终进行完全复制(然后移动到位置),而const&版本将更新旧字符串,这可能会重用已分配的缓冲区。
请参见他的幻灯片27:对于“set”函数,选项1与往常一样。 选项2添加了rvalue引用的重载,但如果有多个参数,则会导致组合爆炸。
仅当必须创建字符串(而不是更改其现有值)时才可以使用按值传递技巧。也就是说,构造函数中,参数直接初始化匹配类型的成员。
如果您想看看自己在这方面有多深入,请观看Nicolai Josuttis的演讲,并祝您好运(在找到上一个版本的错误后,“完美-完成!” n次。曾经到过那里吗?)
这也被总结为标准指南中的⧺F.15

更新

通常,您应该将“string”参数声明为std::string_view(按值)。这样可以像使用const std::string&一样高效地传递现有的std::string对象,并且可以传递词法字符串文字(例如"hello!")而不需要复制它,并且可以传递类型为string_view的对象,因为这些现在也在生态系统中。

例外情况是当函数需要一个实际的std::string实例,以便传递给另一个声明为const std::string&的函数时。


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