为"按值传递"正名
在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
的方式传递Matcher
和Action
,不仅实现简单,天然支持左值或右值,而且成本在接受范围之内。
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
,完全满足上述必要条件。
- 产生副本:通过「拷贝构造」或「移动构造」,产生私有的
matcher
和action
副本; - 类型可拷贝:
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) : "";
};
}