右值系列之一:想要快?就传值

推荐有关右值的一系列文章,共六篇。


第一篇:想要快?就传值

原文来自: http://cpp-next.com/archive/2009/08/want-speed-pass-by-value/

实话实说,你对以下这段代码有何感觉?

std::vector<std::string> get_names();
…
std::vector<std::string> const names = get_names();

坦白的说,虽然我知道没那么糟,但是我还是感觉不妙。原则上,当 get_names() 返回时,我们必须复制一个含有多个 string 的 vector。然后,我们在初始化 names 的时候还要再一次复制它,最后我们还要销毁第一份拷贝。如果在 vector 中有N个 string,那么每次复制可能需要多至N+1次内存分配,而且 string 内容的复制会导致一系列缓存失效的数据访问。

为了消除这种顾虑,我通常会使用传引用的方法来避免无用的复制:

get_names(std::vector<std::string>& out_param );
…
std::vector<std::string> names;
get_names( names );

不幸的是,这种做法也很不理想。

  • 代码增长了150%。
  • 我们必须去掉 const,因为我们要修改 names。
  • 正如函数式编程的程序员经常提醒我们的,函数参数可被改写会使代码变得复杂,原因是它破坏了引用透明性和方程式推理。
  • 对于 names,我们失去了严格的值语义。

难道真的必须这样来写代码才可以提高效率吗?幸好,答案是不必如此(特别是当你使用的是C++0x时)。我们有一系列文章探讨右值以及它对于提高C++值语义效率的影响,本文是这个系列中的第一篇。

右值

右值是指创建匿名临时对象的表达式。右值的名字来自这样一个事实,内置类型的右值表达式只能出现在赋值操作符的右侧。这一点和左值不同,不带 const 的时候,左值是可以出现在赋值操作符的左侧的,右值表达式生成的对象没有任何持久的标识用来向它赋值。

不过,我们要讨论的是匿名临时对象的另一个重要特性,就是它们可以在表达式中只使用一次。你怎么可能再一次提及这样的一个对象呢?它没有名字(即“匿名”);而且在整个表达式求值完毕后,对象即被销毁(即“临时”)!

如果你知道你是从一个右值进行复制的话,你就有可能从源对象处将复制开销较高的资源“偷过来”,在目标对象中使用它们而不会有任何人留意它。在前面的例子中,就是将源 vector 中动态分配的字符串数组的所有权传递给目标 vector。如果我们可以在某种程度上让编译器来为我们执行这种“转移”操作,那么从以传值方式返回的 vector 来初始化 names 的代价就非常低——几乎为零。

以上是关于第二次复制的,那么第一次复制呢?原则上,当 get_names 返回时,必须将函数的返回值从函数的内部复制到外部。很好,返回值具有与匿名临时对象一样的特性:它们马上就会被销毁,以后也不会再被用到。所以,我们可以用相同的方法来消除掉第一次复制,将资源从函数内部的返回值处转移给函数调用者可见的匿名临时对象。

复制省略和RVO

前面提到复制的时候,我都写上“原则上”,其原因是,实际上编译器都允许基于我们已经讨论过的那些原则来执行一些优化。这一类优化通常被称为复制省略。例如在返回值优化(RVO)中,调用者函数在其栈上分配空间,然后将这块内存的地址传给被调用函数。被调用函数可以在这块内存上直接构造返回值,以消除从函数内部至外部的复制。该复制被编译器省略,或者说“消掉”。因此在以下代码中,将不需要进行复制:

std::vector<std::string> names = get_names();

同样,当一个函数参数以传值方式传递时,虽然编译器通常被要求建立一份拷贝(所以在函数内部修改该参数不会影响到调用者),但是当源对象是右值时,也允许省略这个复制而直接使用源对象本身。

std::vector<std::string> 
sorted(std::vector<std::string> names)
{
    std::sort(names);
    return names;
}
 
// names is an lvalue; a copy is required so we don't modify names
std::vector<std::string> sorted_names1 = sorted( names );
 
// get_names() is an rvalue expression; we can omit the copy!
std::vector<std::string> sorted_names2 = sorted( get_names() );

这真的很了不起。原则上,编译器可以消除在第12行中所有令人担心的复制,使得 sorted_names2 与 get_names() 中所创建的对象是同一个对象。但是在实践中,这一原则不会走得象我们所想的那么远,其原因我稍后解释。

启示

虽然复制省略从未被标准要求实现,但是我已测试过的每一个编译器的最新版本都已实现此种优化。即使你对于以传值方式返回那些重量级对象感到不舒服,复制省略还是会改变你编写代码的方式。

我们来看一下前面那个组 sorted(...) 函数的以下写法,它接受以 const 引用方式传入的 names 并进行一次显式的复制:

std::vector<std::string> 
sorted2(std::vector<std::string> const& names) // names passed by reference
{
    std::vector<std::string> r(names);        // and explicitly copied
    std::sort(r);
    return r;
}

虽然乍看起来 sorted 和 sorted2 是一样的,但是如果编译器实现了复制省略,它们会有巨大的性能差异。即便传给 sorted2 的实参是右值,进行复制的源对象 names 也是一个左值,因此复制不能被优化掉。从某种意义上说,复制省略是分离编译模式的牺牲品:在 sorted2 函数体内部,没有任何关于传给函数的实参是否为右值的信息;而在外部的调用点,也没有迹象显示该实参最终会被复制。

这一事实直接将我们引至出以下指引:

指引:不要复制你的函数参数。而应该以传值的方式来传递它,让编译器来做复制。

最坏的情况下,如果你的编译器不支持复制省略,性能也不会更坏。而最好的情况下,你会看到性能的极大提升。

你可以立即应用该指引的一个地方就是赋值操作符。规范的、易写的、保证正确的、强异常保证的、复制并交换的赋值操作符通常会这样写:

T& T::operator=(T const& x) // x is a reference to the source
{ 
    T tmp(x);          // copy construction of tmp does the hard work
    swap(*this, tmp);  // trade our resources for tmp's
    return *this;      // our (old) resources get destroyed with tmp 
}

但是通过以上对复制省略的讨论,可以知道这种写法显然是低效的!显而易见,现在正确编写一个复制并交换的赋值操作应该是:

T& operator=(T x)    // x is a copy of the source; hard work already done
{
    swap(*this, x);  // trade our resources for x's
    return *this;    // our (old) resources get destroyed with x
}

真的假不了

当然,天下没有免费的午餐,所以我还有以下说明。

首先,当你以引用方式传递一个参数并在函数体内对其进行复制时,复制构造函数是从一个集中的地方被调用的。但是,当你以传值方式传递一个参数时,编译器为其生成的对复制构造函数的调用是位于每一次对左值进行传递的调用点。如果该函数在多个地方被调用,且代码大小或局部性是你的应用程序的关键重点,这的确会是一个问题。

另一方面,也可以很容易地建立一个包装函数,将复制局部化:

std::vector<std::string> 
sorted3(std::vector<std::string> const& names)
{
    // copy is generated once, at the site of this call
    return sorted(names);
}

由于反之并不成立——你不能通过包装来取回已失去的复制省略的机会——所以我建议你还是要从前面的指引开始,然后仅在发现必须要做的时候才改变它。

其次,我还没有发现有哪个编译器可以在函数返回其参数时进行复制省略,正如我们的 sorted 实现。你可以想象一下如何进行这些复制省略:没有某种形式的跨函数优化,sorted 的调用者无从知晓其参数(而不是其它对象)最终会被返回,所以编译器必须在栈上分别为参数和返回值分配不同的空间。

如果你要返回一个函数的参数,你还是可以获得近似最优的性能,方法是与一个缺省构造的返回值进行交换(所提供的缺省构造函数和交换函数必须该是低开销的,通常也是如此):

std::vector<std::string> 
sorted(std::vector<std::string> names)
{
    std::sort(names);
    std::vector<std::string> ret;
    swap(ret, names);
    return ret;
}

后续内容

我希望你现在已经不会再为以值方式传递或返回一个非平凡对象而感到焦虑。不过我们还没结束:目前为止我们已经讨论了右值、复制省略,以及RVO,我们已具备了所需的背景知识,可以继续讨论转移语义、右值引用、完美转发,以及在本系列文章中将继续的其它内容。回头见!

鸣谢

Howard Hinnant is responsible for key insights that make this article series possible. Andrei Alexandrescu was posting on comp.lang.c++.moderated about how to leverage copy elision years before I took it seriously. Most of all, though, thanks in general to all readers and reviewers!


  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值