Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

追求拷贝语义与移动语义和谐共存的优化之道 #42

Closed
acgtyrant opened this issue Aug 25, 2015 · 11 comments
Closed

追求拷贝语义与移动语义和谐共存的优化之道 #42

acgtyrant opened this issue Aug 25, 2015 · 11 comments
Labels

Comments

@acgtyrant
Copy link

C++11 新特性「右值引用」是好东西,真正实现了通过移动语义优化传值的可行性。

最近我在做 @chenshuo 老师的复数类练习,拷贝控制实现如下:

#include <utility>

class Complex {
 public:
  Complex() = default;
  Complex(double real_part, double imaginary_part)
      : real_part_(real_part), imaginary_part_(imaginary_part) {};
  Complex(const Complex &) = default;
  Complex(Complex &&) = default;
  Complex &operator=(Complex right_hand_side) noexcept {
    using std::swap;
    swap(*this, right_hand_side);
    return *this;
  }
  ~Complex() = default;

 private:
  double real_part_ = 0;
  double imaginary_part_ = 0;
};

Complex 类不包含指针,所以很多拷贝控制函数直接显示声明为 default 了。此外,copy and swap 应该是业界公认的赋值运算符最佳实现方式了,不光能自我赋值且做到异常安全性,还都与左值右值和谐相处。

不过接下来我要定义加法运算符,犯难了:在加法多项式(即三项或以上)中,一个加法运算符的运算结果是用不到的临时对象,所以应该用同时支持复制语义和移动语义的友元函数实现加法运算符,毕竟 operator+ 函数有可能会接收一个右值:

// Complex.h
class Complex {

  ...
  double imaginary_part_ = 0;

  friend Complex operator+(
      const Complex &left_hand_side,
      const Complex &right_hand_side);
  friend Complex operator+(
      Complex &&left_hand_side,
      const Complex &right_hand_side);
  friend Complex operator+(
      const Complex &left_hand_side,
      Complex &&right_hand_side);
  friend Complex operator+(
      Complex &&left_hand_side,
      Complex &&right_hand_side);
};

// Complex.cc
Complex operator+(
    const Complex &left_hand_side,
    const Complex &right_hand_side) {
  Complex result(
      left_hand_side.real_part_ + right_hand_side.real_part_,
      left_hand_side.imaginary_part_ + right_hand_side.imaginary_part_);
  return result;
}

然而二元运算符的参数有两个,为了同时支持复制语义与移动语义,我们不得不如此同时重载四个友元函数。别忘了,把以上源代码分割到头文件 complex.hcomplex.cc 文件时,还要在 complex.h 中的 Complex 类作用域外重新声明一遍友元函数。

于是我只好放弃在移动语义上优化二元运算符。改在类内把加法运算符重载为类内成员函数,直接接收一个常量左值引用:

class Complex {

  ...

  const Complex operator+(const Complex &right_hand_side) const {
    Complex sum(
        this->real_part_ + right_hand_side.real_part_,
        this->imaginary_part_ + right_hand_side.imaginary_part_);
    return sum;
  }

}

据我所知,某函数返回值是普通的 Complex 类对象时,那么它一般会返回右值,即用函数体内返回值「复制构造」出的临时对象。于是当 operator+ 接收左值时,直接引用它;当它接收一个被调用且返回普通 Complex 类对象的函数时,后者会先生成临时对象,最后 operator+ 再把这个右值引用成常量左值。

突然,我灵光一闪,直接让 operator+ 函数体内的 sum 变成右值不就好了吗?这样在多项式运算中,不会再对函数体内返回值「复制构造」出临时对象,而是直接把它移动到临时对象上,毕竟 Complex 已经定义了移动构造函数。于是如今 operator+ 优化如下:

class Complex {

  ...

  const Complex operator+(const Complex &right_hand_side) const {
    Complex sum(
        this->real_part_ + right_hand_side.real_part_,
        this->imaginary_part_ + right_hand_side.imaginary_part_);
    return std::move(sum);
  }

}

于是在如此干净简洁、无冗余多次重载的实现下,一个小小的「把函数体内返回值移动到临时对象上」开销就无关紧要了。同理,应该可以对「在函数体内定义局部对象并返回其非引用值」的其它函数如法炮制。

好了,以上是我的随笔,欢迎大家各抒已见。有错误一定要当场斧正!

@timshen91
Copy link

你搞错了一件事,就是move其实并不需要处处都有。move是为handle object准备的。。所谓handle object就是成员占用少量空间,但是通过指针指向堆上分配的大量空间(如vector,unordered_map等)。这时候move只要复制成员(少量的指针等),而回避了复制整个堆上内存(如vector的复制)。

像你的复数类由于没有堆分配,copy和move的开销最多只是一样的。在此种情况下更清爽的做法是避免定义一切和move有关的东西。

另外:

  // Free friend function of Complex
  Complex operator+(Complex left_hand_side, Complex right_hand_side) {
    return Complex(
        left_hand_side.real_part_ + right_hand_side.real_part_,
        left_hand_side.imaginary_part_ + right_hand_side.imaginary_part_);
  }

这样写更简单一些。像Complex这么小的东西,直接传值较为妥当。另外我个人偏好使用friend function,因为这样left hand side也是传值了。传值的好处可参见这里( http://www.zhihu.com/question/32250671/answer/60064571 ),不过这里可能体现得不是很明显。

@acgtyrant
Copy link
Author

@innocentim 咦你想的移动语义怎么和我想的不一样?

复制构造要开辟一个新空间,把源对象的值复制过去,如果源对象是右值,后者还会被销毁。

移动构造则是不开辟新空间,直接让新对象接收源对象(即右值) 的内存空间。如果有必要时可以 swap 它们,原本的左值移动到临时对象上并随之析构掉。

此外 handle object 的优化和复制语义移动语义没有关系吧,直接 swap 它们的成员即可。

@fbq
Copy link

fbq commented Aug 25, 2015

移动构造则是不开辟新空间,直接让新对象接收源对象(即右值) 的内存空间。如果有必要时可> 以 swap 它们,原本的左值移动到临时对象上并随之析构掉。

构造函数为什么会swap?为什么会有"原本的左值"?

@acgtyrant
Copy link
Author

@fbq 不好意思忘记说了,「如果有必要时可以 swap 它们,原本的左值移动到临时对象上并随之析构掉。」针对的是(移动)赋值操作符。

@acgtyrant
Copy link
Author

我用 GCC 5.2.0 实验了下:

complex.h:

#include <iostream>
#include <utility>

class Complex {
 public:
  Complex() = default;
  Complex(double real_part, double imaginary_part)
      : real_part_(real_part), imaginary_part_(imaginary_part) {}
  // Complex(const Complex &) = default;
  // Complex(Complex &&) = default;
  Complex(const Complex &right_hand_side)
      : real_part_(right_hand_side.real_part_),
      imaginary_part_(right_hand_side.imaginary_part_) {
    std::cout << "I use a copy constructor!" << std::endl;
  }
  Complex(Complex &&right_hand_side)
      : real_part_(right_hand_side.real_part_),
      imaginary_part_(right_hand_side.imaginary_part_) {
    std::cout << "I use a move constructor!" << std::endl;
  }
  Complex &operator=(Complex right_hand_side) {
    using std::swap;
    swap(*this, right_hand_side);
    return *this;
  }
  ~Complex() = default;

  const Complex operator+(const Complex &right_hand_side) const {
    Complex sum(
        this->real_part_ + right_hand_side.real_part_,
        this->imaginary_part_ + right_hand_side.imaginary_part_);
    return std::move(sum);
  }

 private:
  double real_part_ = 0;
  double imaginary_part_ = 0;
};

complex.cc:

#include "complex.h"

int main(void) {
  Complex a(1, 2);
  Complex b(3, 4);
  Complex c(5, 6);
  Complex d = a + b;
  Complex e = a + b + c;
  return 0;
}

编译并输出:

I use a move constructor! 
I use a move constructor! 
I use a move constructor! 

去掉加法运算符里的 std::move() 并重新编译, 没有任何输出,大概被 (N)RVO 掉了吧……

@fbq
Copy link

fbq commented Aug 25, 2015

@timshen91
Copy link

我好奇你对move的理解是哪里看来的。

如果你去翻标准,你会发现lvalue ref和rvalue ref在编译器看来是一模一样的,除了他们是不同的类型之外。也就是说,primitive type的copy和move的行为是一模一样的,即复制内容。

lvalue ref和rvalue ref的区别全体现在已有的函数对它们重载后的行为的区别,和不同表达式转化为不同类型的引用(如函数调用返回的是返回类型的右值引用)。

@acgtyrant
Copy link
Author

@innocentim C++ Primer. 我先看 @fbq 的视频了。

@Mooophy
Copy link
Contributor

Mooophy commented Aug 25, 2015

同意@innocentim

建议Po主多从CS基础知识的角度来理解移动语意。到了这个层面,c++ primer 的解说深度不太够了。Tim说的handle object 很有启发性,瞬间想通很多东西,谢〜

@acgtyrant
Copy link
Author

温习了移动拷贝构造函数,突然才明白我这种优化只对「有指针成员和显式定义的移动构造函数移动赋值符」的类才有意义。换言之,Complex 定义了 default 的移动构造函数移动赋值符,其实它们和同样 default 的复制构造函数复制赋值符没有区别。贻笑大方了。

@pezy pezy added the *value label Aug 25, 2015
@Mooophy
Copy link
Contributor

Mooophy commented Aug 25, 2015

@acgtyrant
share观点才会更有助于自己的提高,何况是有Tim这种水准的指点。

另,欢迎 @innocentim 经常来玩。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

5 participants