Clean C++

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

2019-06-04  本文已影响0人  刘光聪

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

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

一个例子

存在两个使用std::function定义的「仿函数」。其中,std::function的泛型参数使用函数的原型表达。例如std::function<bool(int)>的泛型参数是一个一元的谓词。

#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,移动构造相对拷贝构造更加低廉。一般地,针对这个问题,存在3种解决方案,我们逐一分析。

重载函数

使用重载的构造函数,可以准确的根据左值或右值实现函数指派。但是,为了支持两个参数的重载,便要实现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;
};

成本分析

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

透传引用

「透传引用(Forward Reference)」是C++11实现「完美转换(Perfect Forward)」机制而引入的一个概念。使用「透传引用」可以合并上述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,完全满足上述必要条件。

在Lambda中的应用

事实上,C++98风格的函数对象,都可以使用Lambda简化实现。例如,原来的函数对象Atom实现,可以使用更为简洁的Lambda表达式实现。

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

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

但是,在Lambda表达式的「参数捕获列表」中,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) : "";
  };
}
上一篇下一篇

猜你喜欢

热点阅读