天天看点

Google C++每周贴士 #166: 你不是真正的复制每周贴士 #166: 你不是真正的复制

(原文链接:https://abseil.io/tips/166 译者:[email protected])

每周贴士 #166: 你不是真正的复制

  • 最初发布于:2019-08-28
  • 作者:Richard Smith
  • 更新于:2020-04-06
  • 短链接:abseil.io/tips/166

Entia non sunt multiplicanda praeter necessitatem." (“Entities should not be multiplied without necessity”) – William of Ockham

如无必要,勿增实体——奥卡姆

If you don’t know where you’re going, you’re probably going wrong.” – Terry Pratchett

如果你不知道要去哪儿,那你可能是走错了——特里·普拉切特

概述

从C++17开始,对象会尽可能在“原地”被创建。

class BigExpensiveThing {
 public:
  static BigExpensiveThing Make() {
    // ...
    return BigExpensiveThing();
  }
  // ...
 private:
  BigExpensiveThing();
  std::array<OtherThing, 12345> data_;
};

BigExpensiveThing MakeAThing() {
  return BigExpensiveThing::Make();
}

void UseTheThing() {
  BigExpensiveThing thing = MakeAThing();
  // ...
}
           

这段代码复制或移动了

BigExpensiveThing

多少次?

在C++17以前,答案是最多三次:每个

return

语句一次,还有一次是在初始化

thing

的时候。这有点道理:每个函数都有可能把

BigExpensiveThing

放到不同的地方,所以也许需要一次移动,来把值放到最终调用者需要的地方。然而在实践中,对象总是“原地”构造在变量

thing

里,没有发生任何移动,而且C++语言允许这些移动操作被“省略”,以促成此优化。

在C++17里,这段代码保证零次复制或移动。实际上,就算

BigExpensiveThing

是不可移动的,上述代码仍然是合法的。

BigExpensiveThing::Make

中的构造函数调用,直接在

UseTheThing

里构造本地变量

thing

所以发生了什么?

当编译器看到形如

BigExpensiveThing()

的表达式的时候,它并不会马上创建一个临时对象。取而代之的是,它把这个表达式当做构造某个最终对象的施工图,然后把创建(正式地说,“实现”)临时对象的操作推迟得越长越好。

一般情况下,创建对象会被一直推迟,直到对象被取名。该命名对象(上面例子中的

thing

)是直接被运行初始化器所得到的施工图所初始化的。如果该名字是一个引用,那么一个临时对象会被实现出来以承载该值。

结果就是,对象在正确的位置被直接构造,而不是在别处构造再复制过来。该行为有时被称作“保证执行的复制省略”,但那是不准确的:从来就没有过复制。

你需要知道的就是:对象在被首次被取名前不会被复制。以值返回没有额外代价。

(就算是被取名以后,函数本地变量被返回时也不会被复制,因为有命名返回值优化。详情请参考Tip 11。)

繁琐细节:匿名对象何时被复制

在两个边缘情况下,使用匿名对象还是会导致复制:

  • 构造基类:在基类构造函数初始化列表中,即使是以基类类型的未命名表达式来构造,还是会发生复制。这是因为类型作为基类时,可能有稍微不同的布局和表现(因为虚基类和虚函数指针值),所以直接初始化基类也许不会得到正确的表现。
    class DerivedThing : public BigExpensiveThing {
    public:
      DerivedThing() : BigExpensiveThing(MakeAThing()) {}  // 可能复制data_
    };
               
  • 传递或返回小而简单的对象:如果一个足够小且可以平凡复制(trivially copyable)的对象,被传递进或返回出函数,那么它可能会被传递进寄存器,因此在传递前后可能会有不同的地址。
    struct Strange {
      int n;
      int *p = &n;
    };
    void f(Strange s) {
      CHECK(s.p == &s.n);  // 可能炸锅
    }
    void g() { f(Strange{0}); }
               

繁琐细节:值类别

C++中有两种风格的表达式:

  • 得到一个值,例如

    1

    ,或

    MakeAThing()

    ——你可能会认为拥有非引用类型的表达式。
  • 得到一个指向存在的对象的位置,例如

    s

    thing.data_[5]

    ——你可能会认为拥有引用类型的表达式。

这个分割被称为“值类别”;前者是 纯右值(prvalues) 而后者是 泛左值(glvalues)。当我们前面探讨匿名对象的时候,我们实际上指的是纯右值表达式。

所有的纯右值表达式都在决定值存储位置的上下文中被求值,而纯右值表达式的执行过程则被用以在该位置初始化该值。

例如,在

中,纯右值表达式

MakeAThing()

作为变量

thing

的初始化器被求值,所以

MakeAThing()

会直接初始化

thing

。构造函数把指向

thing

的指针传递给

MakeAThing()

,然后

MakeAThing()

中的

return

语句初始化该指针指向的玩意儿。相似地,在

中,编译器有个指针指向待初始化的对象,并且直接调用

BigExpensiveThing

的构造函数来初始化该对象。

相关阅读

  • Tip #11: Return Policy
  • Tip #24: Copies, Abbrv.
  • 每周贴士 #77: 临时变量,移动,和复制
  • Tip #117: Copy Elision and Pass-by-Value

继续阅读