[译]GotW #2: Temporary Objects

    不必要的和(或)临时的变量经常是罪魁祸首,它让你在程序性能方面的努力功亏一篑。如何才能识别出它们然后避免它们呢?

Problem

JG Question:

    1. 什么是临时变量?

Guru Question:

    2. 假设你正在代码审查,一个程序员写了如下的的一个函数,这个函数至少在三个地方使用了不必要的临时或者额外的对象。你能辨别出哪些?其如何修正它们?

string find_addr( list<employee> emps, string name ) {
    for( auto i = begin(emps); i != end(emps); i++ ) {
        if( *i == name ) {
            return i->addr;
        }
    }
    return "";
}

    不要改变这个函数的操作语义,即使它们可以得到改善。

 

Stop and thinking……

 

Solution

    1. 什么是临时变量?

    非正式地说,一个临时对象是一个未命名的且不能取址的对象。临时对象经常在计算一个表达式的过程当中作为中间值被创建。比如函数按值语义返回的一个对象,执行一个隐式转换或者抛出一个异常。通常称临时对象为“右值”,这么说是因为它可以出现在赋值操作的“右”手端。下面是一个例子:

widget
 f();            // f returns a temporary widget object

auto a = 0, b = 1;
auto c = 
a + b
;        // "a+b" creates a temporary int object

     与此相反,在相同的代码中我们可以有类似a和b这样有着各自名字和内存地址的对象。像这样的对象通常称为“左值”,因为它可以出现在赋值操作的“左”手端。

     这是一个简化了的真理。通常来说这是你所需要知道的全部。更精确地说,C++目前有5个种类的值,区分它们主要的用途是写语言规格文档。通常你可以忽略它们,只考虑“右值”临时对象是未被命名的且不能取址的,“左值”非临时对象是命名了的且可以取址的。

 

2. 假设你正在代码审查,一个程序员写了如下的的一个函数,这个函数至少在三个地方使用了不必要的临时或者额外的对象。你能辨别出哪些?其如何修正它们?

     不管相不相信,这个简短的函数有三个很明显的不必要的临时或额外对象的拷贝,两个微妙的,三个不相干的(three red herrings)。

按值传递的参数

最明显的额外拷贝是函数的签名:

string find_addr( list<employee> emps, string name )

     这些函数应该按引用传递,分别是const list<employee>& 和const string&,而不是按值传递。按值传递迫使编译器对所有的对象进行一个完全的拷贝,这是很昂贵的操作,且是完全没必要的。

 

    Guideline:如果对参数只是进行只读操作(不进行拷贝),那么优先选择使用const&修饰只读参数。

 

     这里要注意:如果调用方按值传递一个临时的list或string参数,它可能会被移动到函数内部而不是被拷贝。但我在这故意说“迫使编译器进行拷贝”是因为没有调用方实际上传递一个临时的list到find_addr,除了错误。

 

不重要的问题:使用“=”初始化
     接下来我们谈谈第一个不太相干的问题,在for循环的初始化中:

for( auto i = begin(emps); /*...*/ )

     你可能会说这段代码应该拼写成auto i(begin(emps))更好些,而不是 auto i = begin(emps)。理由是=语法会产生一个额外的临时对象,即使它可能会被优化掉。毕竟,我们在GotW #1中讨论了相关的问题。通常额外的=意味着两步:“转换到一个临时对象然后进行copy/move进行初始化,但是回想一下,当像这样使用auto时此规则并不适用,为什么?

切记auto总是会推导出初始化表达式的具体类型,除了顶层的const和&对转换没有影响,我们直接构造i没有必要一个转换。因此在auto i(begin(emps)) 和auto i = begin(emps). 之间是 没有区别的。选择哪个语法完全取决于你的爱好品味。没有临时上或者其他性能或语义的区别。

 

    Guideline:优选选择使用auto声明变量,这么做的其他理由是:它很自然地保证由于隐式转换的零额外临时对象。

 

在每个循环迭代后重新计算范围的上界。

     另一个潜在可避免的临时对象是在for循环的终止条件:

for( /*...*/ ; i != end(emps); /*...*/ )

     对于大多数容器,包括list,调用end()会返回一个需要构造和销毁的临时对象,即使这个值不会变。

通常当一个值不会发生变化时,我们可能会只计算这个值一次,将它存储在一个局部变量中反复使用,而不是每次循环迭代都重新计算(重新构造和重新销毁)。

 

    Guideline:优先选择预计算一个不会被改变的值,而不是重新构造不必要的对象。

 

     然而,为了谨慎:在实际中,一个简单的内联函数,比如:list<T>::end(),在循环中被使用,编译器经常会注意到这个值不会变改变,然后会为你将此值保存在循环体外而不用你自己去做。所以我其实不推荐去将end的值保存在其他地方,因为这么做会使代码变得更轻微的复杂,以效率的名义(实际上没有数据需要)定义一个过早的优化使得代码更为复杂。代码清晰是第一位:

 

    Definition:过早优化是:在没有实际所需的数据前提下,以优化的名义使得代码更复杂化。

 

    Guideline:以书写清晰和正确的代码为优先。在你得到分析后的数据能证明优化是必需之前,不要过早优化,特别是在编译器可以为你处理简单内联函数的情况下。

 

使用后缀递增来增加迭代器

     接下来,考虑一下for循环中变量i的增加:

for( /*...*/ ; i++ )

     这个临时变量更为微妙,但一旦记住了后缀递增和前缀递增之间的区别就很容易明白。后缀递增通常比前缀递增更低效,因为它需要保存和返回原始值。类T的后缀递增一般以一下的形式实现:

T T::operator++(int)() {
    auto old = *this; // remember our original value
    ++*this;          // always implement postincr in terms of preincr
    return old;       // return our original value
}

     现在很容易明白为什么后缀递增比前缀递增更低效了:后缀递增做了前缀递增的所有工作,除此之外,它还必须构建并返回另一个对象,其中包含了原始值。

 

    Guideline:为了一致性,总是依据前缀递增来实现后缀递增,否则你的用户可能会得到一个意外的(不愉快的)结果。

 

      在有问题的代码中,原始值从未被使用,因此没有理由使用后缀增量,相反应该使用前缀增量。尽管这差异不像是内建类型或者简单的迭代器类型,编译器通常会优化掉额外不必要的工作,但不要求超过自己所需的依旧是个好习惯。

 

    Guideline:优先选择前缀增量,只有当需要使用原始值的时候才使用后缀增量。

 

    “等等,你在打破一致性!”有人可能会说。“那是过早优化,你说过编译器会为我们保存一份end()的副本在循环之外,那么对于编译器来说优化后缀增量也是件容易的事。”这确实是可以的,但这并不意味这过早优化。在你可以证明++i不比i++更复杂之前,以优化的名义优先选择++i并不意味这在写更复杂的代码,这不是好像你需要一个分析数据来证明你使用了它。相反,倾向于++i是 避免最差化, 这意味着避免编写复杂代码相当于不必要要求额外的工作,不管怎样,忽略掉就好了。

 

   Definition:过早最差化(Premature pessimization)是当你写的代码比它所需的还慢,通常会要求一些不必要的额外工作,当同等复杂的代码应该更快且很自然地就能够编写出来。


比较可能使用了隐式转换

     接下是:

if( *i == name )

     类employee没有显示出这个问题,但是我们可以推断出一些事情。对于这段工作的代码,employee很可能要不转换到string,要不就是有个带string参数的转换构造函数。这两种情况都构造了临时对象,不管为string调用operator==还是为employee调用operator==。(Only if there does happen to be an operator== that takes one of each, or employee has a conversion to a reference, that is, string&, is a temporary not needed.)

 

    Guideline:小心通过隐式转换生成的隐藏的临时对象。一个好的办法就是声明默认构造函数和转换操作符为explicit,除非隐式转换真的需要。

 

可能不是问题的问题:return ""

return "";

     这里我们无可避免地创建了临时对象(除非我们改变返回值类型,但是我们不应该这么做。往下看),问题是:存在更好的办法吗?

     从书面上看,return ""调用string的带const char*参数的构造函数,如果string实现了你正在使用(a)足够聪明地检查传入为空的情况或(b)使用small string optimazation(SSO)将字符串直接存储到一个特定大小的string对象而不是堆上,那么将不会在堆上分配空间。

     的确,我查看了每个string的实现,在此问题上都足够聪明不会执行堆的分配,这是为了string最大限度地高效。因此,实际上这里没有什么可优化的。但是我们还有其他什么选择吗?考虑下面两种情况。

     首先,你可能会将返回值拼写成 return “”s;,这是C++14中新的特性。它本质上还是依赖了上述说的两种情况中的一种,只是以一个不同的函数,字面operator”"。

     第二,你可能会将返回值写成return { };。一个非智能和非SSO的实现,这相比于其他的可能有一个轻微的缺点,因为它调用了默认构造函数,因此大多数原生实现都很肯能不会去执行空间分配因为这里没有值需要空间。

     总的来说,在实际中,对于返回""、""s或{}都没有区别。你趋于哪种风格就使用哪个。如果你使用的string实现了智能或者SSO中的一种,(覆盖了我所知道的所有实现版本)那都是没有区别的零分配。

注意:SSO是为了避免分配开销和竞争的极好的优化,每个现代的string都应该使用。如果你使用的string实现没有使用SSO优化,写信给你使用的标准库作者,这真的很应该。

不重要的问题:多路返回

return i->addr;
return "";

     这是第二个轻微不太相干的问题。旨在吸引错误的“单入口/单出口(SE/SE)”派别门徒。

     在过去,我曾听到过有人在争论,最好是声明一个局部string对象来保存返回值,这样就只有单个返回语句返回那个值。比如这么写:

string ret; 
…
 ret = i->addr; 
break; 
… 
return ret;

     他们说这么做是帮助优化器去执行“命名的返回值优化(named return value optimization)”

     事实是,不管是单返回语句是提升了还是降低了性能,这都是很依赖于你实际的代码和编译器。在这个例子中,问题是创建了一个局部string对象,然后分配给它,这意味着调用了string的默认构造函数且还可能调用了赋值操作函数,而不只是像我们代码中的单个构造函数。你可能会问“但是,调用一个普通的string构造函数能有多大的开销?”下面是执行在一个很流行的编译器上的“双返回(two-return)”版本的情况:

       · 关掉优化选项:双返回比返回一个string对象值快5%
       · 使用侵略性的优化:双返回比返回一个string对象值快40%

    注意这意味着:不仅是这个指定的编译器在特定的时间,单返回版本产生更慢的代码,而且当开启优化后,它的速度变得更慢。换句话说,单返回没有帮助优化,而是通过代码的复杂化反而干扰了优化。

    通常,SE/SE是一个废弃了的思想,且一直是错误的。“单入口”思想或者函数,应该总是在一个地方进入(开始的地方),且没有使用goto在函数内部的地方进行随意的代码跳跃,这是在计算机科学上一个非常有价值的进步。这使得库成为了可能,因为这意味着可以将函数打包然后重用它,被打包的函数总是能知道它的开始状态,也就是开始的地方,为不用关心调用方。“单出口”,在另一方面,在优化的基础上获得了不公平的流行和对称。但它是错误的,因为这理由在反向上不成立--允许调用者跳入是不好的因为它不在函数的控制之下,但允许函数在知道操作已经完成的前提下提前返回是非常好的且完全在函数的控制之下。最后一点:“单出口”在存在异常处理的任意语言中总是一个谎言,因为如果你在调用一个可能会抛出异常的函数,你可能得到一个早期异常返回。

不是问题的问题:传值返回。

第三个不相干的问题点:

string find_addr( /*...*/ )

     因为C++很自然地为返回值使用移动语义,像这string对象。当你传值返回时试图去避免临时对象通常收获甚微。例如,如果调用方写:

auto address = find_addr( mylist, “Marvin the Robot” );


     将会最多有一个便宜的移动操作(不是深拷贝)将临时对象移动到address中,且编译器允许甚至优化掉便宜的移动操作,直接将结果构造到address中。但要是你有兴致在返回的情况下避免产生临时对象用string&替代string会怎样?这有个方法你可能会试图去避免返回一个悬吊局部或者临时对象的引用的缺陷:

const string& find_addr( /* ... */ ) {
    for( /* ... */ ) {
        if( /* found */ ) {
            return i->addr;
        }
    }
    static const string empty;
    return empty;
}

     为了说明上面代码为什么是脆弱的,这有个额外的问题:

     对于上面的函数,为返回的引用在多长时间内是有效的编写文档。

 

stop and thinking…..

 

     让我们来看看,如果这对象被找到,在list中我们在employee对象中返回一个string的引用,因此引用本身只在list内部的employee的生命期间才有效,因此我们可能会试着这样:(假设对于employee来说,空的地址是无效的)

    “如果返回的字符串非空,那么这个引用是有效的直到下一次更改employee对象中的address,包括假设你从list中删除employee”。

     这些是非常脆弱的语义,不仅仅是因为第一个问题(但远非只有)直接引发的是调用者不是是哪个employee对象,不仅是没有指向正确employee对象的指针或引用,而且如果有两个有着相同address的employee对象的话,没办法简单地找出那个是正确的employee对象。第二,被调代码可以众所周知地忘记且对它返回的引用的生命期毫不关心。以下代码可能完成编译:

auto& a = find_addr( emps, "John Doe" );  // yay, avoided temporary!
emps.clear();
cout << a;                                // oops

     当被调代码像上面这么做,使用返回的超出生命期的引用,bug通常是间歇性的且很难诊断。的确,在使用标准库中,程序员经常犯的一个错误是在迭代器无效的时候使用它,这和在使用一个已经超出生命期的引用是一回事。关于意外使用无效迭代器请查看GotW #18。

总结

     这里还有一些其他优化的机会,但是先暂时忽略掉。下面是修正了临时对象的一个可能正确的find_addr版本。为了避免employee/string比较可能的转换,我们假设存在着一个和.name() == name相同语义的这样的一个函数:employee::name()

     注意倾向于使用auto声明局部变量的另一个理由是:因为list<employee>的参数现在是const,调用begin和end返回不同的类型,不是iterator而是const_iterator,而auto可以自然地推断出正确的类型,不需要在你的代码中进行修改:

string find_addr( const list<employee>& emps, const string& name ) {
    for( auto i = begin(emps);  i != end(emps); ++i ) {
        if( i->name() == name ) {
            return i->addr;
        }
    }
    return "";
}

 

原文地址:http://herbsutter.com/2013/05/13/gotw-2-solution-temporary-objects/

posted @ 2013-09-22 22:20  Navono  阅读(351)  评论(0编辑  收藏  举报