Clean C++: 为"按值传递"正名

为"按值传递"正名

C++98中,按值传递(pass-by-value)意味着低效的、无畏的拷贝,被广大程序员嗤之以鼻。据此推之,对于自定义类型,如果用于入参则应使用pass-by-const-reference;如果用于出参则应pass-by-reference

在缺失移动语义(move semantic)下的C++98标准,这样的结论无可厚非。但是,在增加移动语义(move semantic)下的C++11标准,按值传递在某些特殊场景具有优异的表现力,是时候为"按值传递"正名了。

一个例子

存在两个使用std::function定义的「仿函数」。

#include <string>
#include <functional>

using Matcher = std::function<bool(int)>;
using Action = std::function<std::string(int)>;

C++98风格

另外存在一个Atom的函数对象。按照既有的C++98的习惯,使用pass-by-const-reference传递参数,则必然调用std::function的「拷贝构造」。

struct Atom {
  Atom(const Matcher& matcher, const Action& action)
    : matcher(matcher), action(action) {
  }
  
  std::string operator()(int m) const {
    return matcher(m) ? action(m) : "";
  }
  
private:
  Matcher matcher;
  Action action;
};

可是,当传递给Atom构造函数的是std::function类型的「右值」时,我们期望调用更为低廉的「移动构造」,而非「拷贝构造」。存在如下几种解决方案,逐一分析。

重载函数

使用重载的构造函数,可以准确的根据左值或右值实现函数指派,但为了支持两个参数的重载,便要实现4个重载的构造函数。组合爆炸是一种典型的设计缺陷,为了提升性能,这个解决方案所付出的成本相当昂贵。

struct Atom {
  Atom(const Matcher& matcher, const Action& action)
    : matcher(matcher), action(action) {
  }
  
  Atom(const Matcher& matcher, Action&& action)
    : matcher(matcher), action(std::move(action)) {
  }
  
  Atom(Matcher&& matcher, const Action& action)
    : matcher(std::move(matcher)), action(action) {
  }

  Atom(Matcher&& matcher, Action&& action)
    : matcher(std::move(matcher)), action(std::move(action)) {
  }
  
  std::string operator()(int m) const {
    return matcher(m) ? action(m) : "";
  }
  
private:
  Matcher matcher;
  Action action;
};

成本分析

当传递左值,存在一次「拷贝构造」;当传递右值,存在一次「移动构造」,但存在不可接受的组合爆炸的问题。

移动引用

使用「移动引用」可以合并上述4个构造函数,它使用「完美转换」的机制,实现左值和右值的透明传递。但是,使用「移动引用」引入了泛型设计,其实现必须放到头文件,增加了编译时依赖的成本,而且增加了实现的复杂度。此外,鉴于「完美转换」并非100%的完美,当客户传递不正确的类型时,编译器的错误信息相当冗长。

struct Atom {
  template <typename Matcher, typename Action>
  Atom(Matcher&& matcher, Action&& action)
    : matcher(std::forward<Matcher>(matcher))
    , action(std::forward<Action>(action)) {
  }
  
  std::string operator()(int m) const {
    return matcher(m) ? action(m) : "";
  }
  
private:
  Matcher matcher;
  Action action;
};

成本分析

当传递左值,存在一次「拷贝构造」;当传递右值,存在一次「移动构造」。避免了组合爆炸的问题,但引入了模板的复杂度,及其模板膨胀的问题;但相对重载方法,代码更加简洁。

按值传递

使用pass-by-value的方式传递MatcherAction,不仅实现简单,天然支持左值或右值,而且成本在接受范围之内。

struct Atom {
  Atom(Matcher matcher, Action action)
    : matcher(std::move(matcher))
    , action(std::move(action)) {
  }
  
  std::string operator()(int m) const {
    return matcher(m) ? action(m) : "";
  }
  
private:
  Matcher matcher;
  Action action;
};

成本分析

当传递左值,存在一次「拷贝构造」,及其一次「移动构造」;当传递右值,存在两次次「移动构造」。相对上两个方案,其成本多了一次低廉的「移动构造」,但完全避免了组合爆炸的问题,及其模板的复杂度,代码更加简洁。

何时按值传递

综上述,可以归纳如下条件,可以考虑使用「按值传递」的方法。

  • 产生副本;
  • 类型可拷贝;
  • 移动成本低廉。

例如,上述的函数对象Atom,完全满足上述必要条件。

  • 产生副本:通过「拷贝构造」或「移动构造」,产生私有的matcheraction副本;
  • 类型可拷贝:std::function类型可拷贝。
  • 移动成本低廉:std::function的移动构造的成本非常低廉。

在Lambda中的应用

可以使用Lambda简化Atom类的实现。

using Rule = std::function<std::string(int)>;

Rule atom(Matcher matcher, Action action) {
  return [matcher, action](int m) {
    return matcher(m) : action(m) : "";
  };
}

但是,在「参数捕获列表」中,matcher, action是按值传递给闭包对象的,此时必然调用std::function的「拷贝构造函数」。幸运的是,C++14支持以初始化的方式将对象移动至闭包对象。

using Rule = std::function<std::string(int)>;

Rule atom(Matcher matcher, Action action) {
  return [matcher = std::move(matcher), action = std::move(action)](int m) {
    return matcher(m) : action(m) : "";
  };
}

猜你喜欢

转载自blog.csdn.net/weixin_34061482/article/details/90959641