Practical usage of cpp reference and move semantic

Practical usage of cpp reference and move semantic

在优化重构一部分老代码时,实际使用 c++ 的 reference 与 move semantic 遇到了若干问题,在此记录。

Aggregation

首先,数据的设计并不复杂,只有一个类,成员变量为一个 std function 并需要在初始化时赋值。最初设计如下,
我希望尽一切可能避免保存 function 对象的副本,所以将函数参数与成员变量全部用 reference 表示。

class UniformValueWrapper {
  public:
    explicit UniformValueWrapper(const std::function<UniformValue(const JsonishValue *)> &parse_func) :
      parse_func_(parse_func) {}
    const std::function<UniformValue(const JsonishValue *)> &parse_func_;
};

// using
UniformValueWrapper wrapper([](const JsonishValue *jsonish) {
   return UniformValue{jsonish->toJsonBool()->getBool()};
   }
   );

这样的写法编译链接无误,实际运行时,卡在实际真正调用这个函数的地方了。当然各个平台由于编译器和操作系统不
同可能有不同的表现,我相信在某些平台我的代码应该是直接 crash 的。那么问题出在哪里?我的预想是在构造函数中
传递 const ref 使其扩展局部变量(即提供的这个函数)的生命周期,使其可以保存在 UniformValueWrapper 这个
类的生命周期中。但是实际情况是,局部变量的生命周期只能维持到构造函数调用完成,所以在UniformValueWrapper
中的 ref member 虽然保存了原有的 reference 但是对应的实例在构造函数调用完成后就已经释放。

实际上在 class member variable 使用 reference 的做法是 OOP 中的 aggregation.

In UML it is called aggregation. It differs from composition in that the member object is not owned by the referring class.
The main reason for aggregation is that the contained object is not owned by the containing object and thus their lifetimes are not bound. In particular the referenced object lifetime must outlive the referring one. It might have been created much earlier and might live beyond the end of the lifetime of the container.

所以生命周期问题是所有使用 aggregation 方式时需要时刻记在心里的关键点。

针对我的使用场景,修改方法其实很简单,就是放弃使用 aggregation 把 class member 的 reference 去掉。

modernize-pass-by-value

据上一章,我作如下修改:

class UniformValueWrapper {
   public:
     explicit UniformValueWrapper(const std::function<UniformValue(const JsonishValue *)> &parse_func) :
       parse_func_(parse_func) {}
     std::function<UniformValue(const JsonishValue *)> parse_func_;
 };

这样写法没有问题,但是 clang 编译器提示 "Clang-Tidy: Pass by value and use std::move" 这里很有意思了,
为什么我所认为的使用 const reference 的写法明明是高效传递变量,避免不必要的 copy 操作,为何让我改用低效的
pass by value? 查了一下 Clang Tidy 的文档,初看起来,两种方式的区别在于 copy 发生的时机。

  1. 使用 const ref 的方式,在构造函数参数传递没有 copy 但是在 parse_func_(parse_func) 这里进行了 copy
  2. 使用 pass by value 方式,在构造函数参数传递时进行 copy 但是后面的 member initialize 改成了 parse_func_(std::move(parse_func))

那么,实际上一次 copy 是无法避免的,只是在哪个时机发生而已,为什么要求我们使用后一种方式呢?

这里现代 C++ 编译器有一个牛逼的优化,叫做 copy elision. 在一个声明了 value 参数的函数中,当函数实际参数
是 rvalue 时,编译器能判断出多余的 copy 操作,并主动忽略之,则在函数内直接使用了实参对象。这样的做法和
const ref 似乎是一致的。其实 Return Value Optimization 也是 copy elision 的一个体现。那么回到上面的问题,
当时机合适的时候,其实一次 copy 都没有发生,直接就把函数参数 move 到了最终的地方。如果时机不合适,那么
最差也就是和原来一样,进行了一次 copy 操作。这就是为什么推荐这样写的原因,编码者不用费心去思考这个函数
实际调用时的情况,让 Clang 来帮我们做判断,我们这样写能保证最高效情况能出现,且最差情况也不会使其差过
原本的写法。

摘录一下黄金准则:

Guideline: Don’t copy your function arguments. Instead, pass them by value and let the compiler do the copying.

最终的代码

class UniformValueWrapper {
 public:
  explicit UniformValueWrapper(std::function<UniformValue(const JsonishValue *)> parse_func) :
  parse_func_(std::move(parse_func)) {}
  std::function<UniformValue(const JsonishValue *)> parse_func_;
};

Ref:

  1. https://clang.llvm.org/extra/clang-tidy/checks/modernize-pass-by-value.html
  2. Want Speed? Pass by Value.
posted @ 2022-06-14 17:15  皮斯卡略夫  阅读(217)  评论(0编辑  收藏  举报