复数类 - acgtyrant/Algorithm-and-Data-Structure GitHub Wiki

陈硕老师练习第一弹!要求:实现基本的运算,熟悉封装与数据抽象。

私有成员与拷贝控制

只打算实现实数域上的复数类,所以实部与虚部上的类型用 double 表示;数据成员不涉及指针,所以除了构造函数之外,复制构造移动构造析构均直接声明为 default, 特别地,不在构造函数提供默认参数以省略默认构造函数,直接在类内初始化数据成员,一来节约声明开销二来不改变函数签名;此外,虽然右值一般本来就不能被赋值,但可以把一些函数返回的 Complex 声明为 const, 多多益善,可以不让用户犯下 Complex a, b, c; a + b = c 的诡异错误。

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() = default;

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

有移动语义的二元运算符

我异想天开,追求拷贝语义与移动语义和谐共存的优化之道,但当时我产生了「移动构造函数」可以移动「类对象本身」的错觉,殊不知前者只移动类的成员,而且若没有显式定义专门只移动「指针成员」的移动构造函数,那么 default 的移动构造函数其实和 default 的拷贝构造函数没有区别,也就是说我这个优化只对「有指针成员和显式定义的移动构造函数移动赋值符」的类才有意义。

罢了罢了。

赋值操作符

忘记定义赋值操作符了,一般来说要同时定义复制赋值操作符和移动赋值操作符,麻烦,还要处理自我赋值这种极端情况。不过我意外学到了出色的 copy and swap 战术,不光能实现异常安全性,还能合二为一!下文的 swap 如同字面上的意思。

utility 库提供了 std::swap, 可以交换两个实参的值。特别地,如果其中某实参是右值且另一实参有移动构造函数/移动赋值运算符,那么前者就会移动到后者。如果所要交换的类对象不包含动态分配内存,可以直接对该对象使用 std::swap, 否则我们就重载 swap 以避免影响动态分配的数据成员,可以重载成友元函数或类成员函数。

言归正传,由于 Complex 不包括动态分配的数据成员,我们可以直接在「复制赋值运算符」祭出 using std::swap, 毕竟 copy and swap 技术提倡用相当一致的函数名 swap, 而不是 std::swap, 毕竟有些包含了动态成员的类不得不自定义它们的 swap, 具体可以参见 Effective C++ 条款十一。

#include <utility>

Complex operator=(const Complex &right_hand_side) {
  Complex temporary(right_hand_side);
  using std::swap;
  swap(*this, temporary);
  return *this;
}

由此可见,它能安全地自我赋值;此外假如 Complexstd::shared_ptr 成员或类似计数引用,那么交换后,由于发生了复制构造 Complex temporary(right_hand_side);, right_hand_side 内部的计数引用加一,并且和 *this 交换计数引用,最后由于 temporary 脱离作用域而被销毁,于是原本的 *this 所含计数引用,亦是现在的 temporary 所含计数引用,就减一。多么巧妙的责任交替仪式!

对移动赋值操作符如法炮制:

#include <utility>

Complex operator=(const Complex &&right_hand_side) {
  Complex temporary(right_hand_side);
  using std::swap;
  swap(*this, temporary);
  return *this;
}

毕竟 Complex 都有了移动构造函数,于是 right_hand_side 这种右值可以直接移动到 temporary 上,而且又被移动给 *this, 如今的 temporary 接收了原本的 *this, 当它脱离作用域就会顺便被销毁。综上所述,*this 接收了 temporary 内存,而后者作为短命的右值,又能帮忙销毁掉原本的 *this, 皆大欢喜。

事实上,这两个赋值操作符在 copy and swap 战术上都要构造一个临时对象并交换,既然如此,一开始干脆把形参定义为非引用参数不就好了?合体!

#include <utility>

Complex operator=(Complex right_hand_side) {
  using std::swap;
  swap(*this, right_hand_side);
  return *this;
}

超・完美无瑕之赋值操作符诞生!

类内运算符与环

我曾经得出一个心得:「有些可重载的运算符往往自成一对,即 ==!=, ><, *->, 为了贯彻直觉与一致性,优先重载前者,并让后者委托前者。」。不过我现在还发现,如果一个类需要同时重载加减乘除运算符,那么往往意味着它构成一个环,就像 Complex 构成数学上的复数环。那么它的代数就有加法逆元 AddtiveInverse 和乘法逆元 MultiplicativeInverse, 我们可以先定义这两种逆元素函数,再委托加乘运算符使用它们,最终得到逆运算符函数,复合赋值运算符也用得到。

以值传递与值语义

我曾经很困惑:函数中的形参声明为引用还是值好?Google Guide Style 没有规定,Effective C++ 条款二十指出只能以值传递内置类型和 STL 迭代器和函数对象,至于其它东西则以常量引用传递。

出乎意料,Tim Shen 指出, **引用看似节省了复制构造和祈构的开销,然而却破坏了「局部性」!**这带来的额外开销是我没料到的。于是若类本身非常小,小到和内置类型没太大的区别,就像只有两个 double 成员的 Complex 类,那么就该以值传来传去,完美贯彻「局部性」,更何况 C++ 标准本来就允许 NRVO 优化,复制开销没啥大不了。特别地,重载运算符应该统统定义为友元函数,这样左值也可以以值传递了。

不过以值传递,就要声明友元函数两次,定义一次,真是体力活。所以我目前就只定义了四大运算符,两个关系运算符,加法逆元乘法逆元,绝对值函数等等。还有共轭,距离什么的就不做了。

此外,值语义类中不是所有成员函数都能以值传递,比如 setter, 前置自增自减运算符等,这些都要用到引用,所以干脆定义为成员函数算了。

最后,我在这练习中还学到了数据抽象,基于对象,面向对象的区别。数据抽象就是塑造一个基于值语义的类,可以以值传递类对象,就像 Complex 类;基于对象则塑造基于对象语义的类,类有限,且类对象不可复制;面向对象则通过继承、多态大大拓展类了,大家公认面向对象最没必要最难用,而且陈硕老师指出所谓「面向对象」的真谛其实鲜为人知:消息传递,不过这则是另一回事了。

⚠️ **GitHub.com Fallback** ⚠️