C++11标准中按值传递类对象参数的使用时机

严正声明:本文系作者davidhopper原创,未经许可,不得转载。

作为一名资深C++程序员,在C++98、03标准时代,一直将“不得按值传递类对象参数”的规定奉为圭臬。例如:

void SetParam(const std::string& name);   // 传入类对象的常引用作为输入参数

如果谁胆敢这样写:

void SetParam(std::string name);   // 传入类对象的值作为输入参数

则一定会被认为是个C++没有入门的菜鸟。
然而到了C++11、14、17标准时代,情况发生了变化,在特定条件下居然可以按值传递类对象参数了,具体解释见Scott Meyers所著《Effective Modern C++》一书中的Item 41: Consider pass by value for copyable parameters that are cheap to move and always copied。是否有一种颠覆三观的惊讶?

一、按值传递类对象参数的示例分析

别着急,让我们来看Apollo项目中的一个具体应用:

// 在modules/planning/common/reference_line_info.h文件中
void SetCandidatePathData(std::vector<PathData> candidate_path_data);

该函数的实现如下:

// 在modules/planning/common/reference_line_info.cpp文件中
void ReferenceLineInfo::SetCandidatePathData(
    std::vector<PathData> candidate_path_data) {
  // 传入的类对象在函数体内有被复制的需求(通过移动语义)
  candidate_path_data_ = std::move(candidate_path_data);
}

该函数首先使用std::move函数将candidate_path_data从左值(lvalue)转换为右值(rvalue),然后调用移动赋值运算符(move assignment operator),将candidate_path_data的内容移动到candidate_path_data_

左值与右值的解释
凡是能真正存储于内存而不是寄存器中的值就是左值,其余的都是右值。更简单的说法是:凡是可以进行取地址(&)操作的值都是左值,其余都是右值。

// lvalues:
int i = 42;
i = 43; // ok, i is a lvalue 
int* p = &i; // ok, i is a lvalue 
int& foo();
foo() = 42; // ok, foo() is a lvalue
int* p1 = &foo(); // ok, foo() is a lvalue
void SetPtr(std::unique_ptr<std::string>&& ptr) {
  // ptr is a lvalue. Because it can be taken the address.
  // std::move(ptr) is a rvalue. Because it can't be taken the address.
  auto p = std::move(ptr); 
}

// rvalues: 
int foobar(); 
int j = 0;
j = foobar(); // ok, foobar() is a rvalue
int k = j + 2; // ok, j+2 is a rvalue
int* p2 = &foobar(); // error, cannot take the address of a rvalue 
j = 42; // ok, 42 is a rvalue

std::move函数的解释
std::move函数的定义如下:

// in namespace std
template<typename T>
typename remove_reference<T>::type&& move(T&& param) {
  using ReturnType = typename remove_reference<T>::type&&;
  return static_cast<ReturnType>(param);
}

typename remove_reference<T>::type的意思是将类型T的引用给去除,即将T&T&&全部变为T,但不去除constvolatile属性(可通过typename remove_cv<T>::type去除)。例如std::remove_reference<int>::typestd::remove_reference<int &>::typestd::remove_reference<int &&>::type的类型均为int,但std::remove_reference<const int>::typestd::remove_reference<const int &>::typestd::remove_reference<const int &&>::type的类型均为const int
可见,std::move函数并不进行任何移动操作,它只是无条件地将传入的参数转换为右值,不需要消耗任何计算资源,其实换个形如std::rvalue_cast的名称更为恰当。

Apollo项目目前对于该函数的使用有两处,分别是:

第一种调用

// modules/planning/tasks/optimizers/piecewise_jerk_path/
// piecewise_jerk_path_optimizer.cc文件第128行
reference_line_info_->SetCandidatePathData(std::move(candidate_path_data));

第二种调用

// modules/planning/tasks/deciders/path_assessment_decider/
// path_assessment_decider.cc文件第231行
// 此处调用语法上正确,效率上错误
reference_line_info->SetCandidatePathData(new_candidate_path_data);

对于第一种调用reference_line_info_->SetCandidatePathData(std::move(candidate_path_data));,因为std::move(candidate_path_data)candidate_path_data转换为右值,于是SetCandidatePathData(std::vector<PathData> candidate_path_data)中的std::vector<PathData> candidate_path_data调用移动构造函数(move constructor)传递参数,整个调用使用了一次移动构造函数及一次移动赋值运算符

对于第二种调用reference_line_info->SetCandidatePathData(new_candidate_path_data);,因为未将参数转换为右值,于是SetCandidatePathData(std::vector<PathData> candidate_path_data)中的std::vector<PathData> candidate_path_data调用拷贝构造函数(copy constructor)传递参数,整个调用使用了一次拷贝构造函数及一次移动赋值运算符。第二种资源消耗高,从效率方面而言是不正确的调用

第三种调用

如果使用C++98标准倡导的引用传递参数方式:

void ReferenceLineInfo::SetCandidatePathData(
    const std::vector<PathData>& candidate_path_data) {
  candidate_path_data_ = candidate_path_data;
}
// 对于上述引用传递方式,下面两种调用方式等价。
reference_line_info->SetCandidatePathData(new_candidate_path_data);
reference_line_info->SetCandidatePathData(std::move(new_candidate_path_data));

那么整个调用会使用一次拷贝赋值运算符(copy assignment operator)。

一般而言,拷贝赋值运算符拷贝构造函数的代价可视为相同,移动赋值运算符移动构造函数的代价可视为相同,并且拷贝代价远大于移动低价。于是,第一种调用耗费两次移动操作,第二种调用耗费一次拷贝操作和一次移动操作,第三种调用耗费一次拷贝操作,因此整体效率比较:

第一种调用>第三种调用>第二种调用

二、替代方案

仍以Apollo项目中的代码为例:

// 在modules/planning/common/reference_line_info.h文件中
void SetCandidatePathData(std::vector<PathData> candidate_path_data);

// 在modules/planning/common/reference_line_info.cpp文件中
void ReferenceLineInfo::SetCandidatePathData(
    std::vector<PathData> candidate_path_data) {
  // 传入的类对象在函数体内有被复制的需求(通过移动语义)
  candidate_path_data_ = std::move(candidate_path_data);
}

进行说明,下面给出两种替代方案。

2.1 重载引用方案

使用接收左值常引用、右值引用参数的两个重载函数,代码如下:

// 在modules/planning/common/reference_line_info.h文件中
// 重载函数1:左值常引用版本
void SetCandidatePathData(const std::vector<PathData>& candidate_path_data);
// 重载函数2:右值引用版本
void SetCandidatePathData(std::vector<PathData>&& candidate_path_data);

// 在modules/planning/common/reference_line_info.cpp文件中
// 重载函数1:左值常引用版本,需要一次拷贝赋值运算符
void ReferenceLineInfo::SetCandidatePathData(
    const std::vector<PathData>& candidate_path_data) {  
  // 使用拷贝赋值运算符
  candidate_path_data_ = candidate_path_data;
}

// 重载函数2:右值引用版本,仅需要一次移动赋值运算符
void ReferenceLineInfo::SetCandidatePathData(
    std::vector<PathData>&& candidate_path_data) {  
  // 使用移动赋值运算符
  candidate_path_data_ = std::move(candidate_path_data);
}
  • 优点:当传入右值引用参数时,比第一节中的按值传参版本少调用一次移动构造函数(及对应的析构函数);
  • 缺点:需要维护两个重载函数。如果传入两个、三个需要拷贝的参数,那么需要维护多少个重载函数???

2.2 通用引用方案

通用引用(universal reference)是Scott Meyers所著《Effective Modern C++》一书中提出的概念。对于包含两个地址符(&&)的参数,一般都是右值引用,但两种情形除外,属于通用引用。最常见的情形是函数模板参数(function template parameters),示例代码如下所示:

// param is a universal reference
template<typename T> void f(T&& param);

第二种情形是自动类型推断(auto type declarations),示例代码如下所示:

// var2 is a universal reference
auto&& var2 = var1;

上述两种情形的共同特点是均存在编译器类型推断。只要不存在类型推断的情形,均不属于通用引用,而是右值引用,示例代码如下:

// no type deduction; param is an rvalue reference
void f(Widget&& param); 
// no type deduction; var1 is an rvalue reference
Widget&& var1 = Widget(); 

2.2.1 通用引用方案一

对于第一节中的示例,给出第一种通用引用的实现方案:

// 在modules/planning/common/reference_line_info.h文件中
// 注意函数模板只能位于头文件中

// 1. ReferenceLineInfo类内部函数声明
// 外部接口(public属性)
template<typename T>
void SetCandidatePathData(T&& candidate_path_data);
// 内部实现函数(private属性)
template <typename T>
void SetCandidatePathDataImpl(T&& candidate_path_data, std::true_type);

// 2.ReferenceLineInfo类外部作为内联函数实现,虽然最终可能无法内联,但函数模板只能
// 放在modules/planning/common/reference_line_info.h文件中实现。
template<typename T>
inline void ReferenceLineInfo::SetCandidatePathData(
    T&& candidate_path_data) {  
    // 借助辅助函数实现
    SetCandidatePathDataImpl(
      std::forward<T>(candidate_path_data),
      std::is_same<std::vector<PathData>, typename std::decay<T>::type>());
}
// 内部实现函数
template <typename T>
inline void ReferenceLineInfo::SetCandidatePathDataImpl(T&& candidate_path_data,
                                         std::true_type) {  
  // 使用完美转发引用
  candidate_path_data_ = std::forward<T>(candidate_path_data);
}

上述实现中,std::forward<T>(candidate_path_data)表示完美转发,如果传入的实参(argument)candidate_path_data是一个左值,则std::forward<T>(candidate_path_data)是一个左值;如果candidate_path_data是一个右值,则std::forward<T>(candidate_path_data)是一个右值。typename std::decay<T>::type表示将T去除左值引用(&)、右值引用(&&)和constvolatile属性后的类型,例如:typename std::decay<const int&&>::type的类型为int。如果typename std::decay<T>::typestd::vector<PathData>类型相同,则std::is_same<std::vector<PathData>, typename std::decay<T>::type>()是继承自std::integral_constant<bool, true>(别名为std::true_type)的结构体对象,否则是继承自std::integral_constant<bool, false>(别名为std::false_type)的结构体对象。std::true_type是结构体std::integral_constant<bool, true>的别名(typedef)。

  • 优点:当传入右值引用参数时,比第一节中的按值传参版本少调用一次移动构造函数(及对应的析构函数);
  • 缺点:需要维护一个语法奇怪的完美转发实现函数。

2.2.2 通用引用方案二

对于第一节中的示例,给出第二种通用引用的实现方案:

// 在modules/planning/common/reference_line_info.h文件中
// 注意函数模板只能位于头文件中

// 1. ReferenceLineInfo类内部函数声明
template <
    typename T,
    typename Cond = typename std::enable_if<std::is_same<
        std::vector<PathData>, typename std::decay<T>::type>::value>::type>
void SetCandidatePathDataCond(T&& candidate_path_data);


// 2.ReferenceLineInfo类外部作为内联函数实现,虽然最终可能无法内联,但函数模板只能
// 放在modules/planning/common/reference_line_info.h文件中实现。
template <typename T, typename Cond>
inline void ReferenceLineInfo::SetCandidatePathDataCond(T&& candidate_path_data) {
  // 使用完美转发引用
  candidate_path_data_ = std::forward<T>(candidate_path_data);
}

上述实现中,typename Cond = typename std::enable_if<std::is_same<std::vector<PathData>, typename std::decay<T>::type>::value>::type>是一个默认值为typename std::enable_if<std::is_same<std::vector<PathData>, typename std::decay<T>::type>::value>::type>的条件模板参数。typename std::enable_if<std::is_same<std::vector<PathData>, typename std::decay<T>::type>::value>::type>表示如果typename std::decay<T>::typestd::vector<PathData>类型相同,则函数void SetCandidatePathDataCond(T&& candidate_path_data)可以编译通过,否则会编译失败,即该函数只接受类型为std::vector<PathData>std::vector<PathData>&std::vector<PathData>&&const std::vector<PathData>const std::vector<PathData>&const std::vector<PathData>&&volatile std::vector<PathData>volatile std::vector<PathData>&volatile std::vector<PathData>&&的实参。

  • 优点:当传入右值引用参数时,比第一节中的按值传参版本少调用一次移动构造函数(及对应的析构函数),且只需维护一个实现函数;
  • 缺点:需要维护一个语法更奇怪的完美转发实现函数。

三、结论

何时使用按值传递类对象参数?

  • 传入的类对象必须要在函数体内被复制(通过移动语义或拷贝方式),说得更直白一点,就是要接收外部传入的类对象参数(构造函数或Set函数)。如果没有该需求,一律使用C++98标准倡导的引用传递参数方式
  • 该类的移动操作必须比拷贝操作的效率要高得多,如果不满足该需求,一律使用C++98标准倡导的引用传递参数方式
  • 该类必须已经实现了移动构造函数和移动赋值运算符;
  • 必须使用std::move()函数将被传入的参数转换为右值,以便使用移动语义(即调用移动构造函数或移动赋值运算符);
  • 不要将基类对象通过传值方式传递;
  • 对于只可移动而不可拷贝的类对象,例如:std::unique_ptr<std::string>std::futurestd::thread,只能使用按右值引用方式传递参数(整个调用只需耗费一次移动操作),示例代码如下:
class Widget {
public:
  ...
  void SetPtr(std::unique_ptr<std::string>&& ptr) {
    p = std::move(ptr); 
  }
private:
  std::unique_ptr<std::string> p;
};

调用代码必须类似如下操作:

Widget w;
...
w.SetPtr(std::make_unique<std::string>("Modern C++"));
// or
auto title = std::make_unique<std::string>("Modern C++");
w.SetPtr(std::move(title));
  • 在按值传递类对象参数的前提下,千万不要在类对象前加const修饰符,否则将变为彻头彻底的低效率拷贝传递,示例代码如下:
// 注意在类对象前加const修饰符是完全错误的用法,会变为彻头彻底的低效率拷贝传递
// 下面是错误代码,千万不要效仿!!!
void ReferenceLineInfo::SetCandidatePathData(
    const std::vector<PathData> candidate_path_data) {
  candidate_path_data_ = std::move(candidate_path_data);
}

上述做法完全错误,因为const对象不能被移动,会导致传递参数时无法使用移动赋值运算符,转而使用低效的拷贝赋值运算符!

最后,请记住Scott Meyers的忠告,谨慎使用按值传递类对象参数
C++11 doesn’t fundamentally change the C++98 wisdom regarding pass by value. In general, pass by value still entails a performance hit you’d prefer to avoid, and pass by value can still lead to the slicing problem. What’s new in C++11 is the distinction between lvalue and rvalue arguments. Implementing functions that take advantage of move semantics for rvalues of copyable types requires either overloading or using universal references, both of which have drawbacks. For the special case of copyable, cheap-to-move types passed to functions that always copy them and where slicing is not a concern, pass by value can offer an easy-to-implement alternative that’s nearly as efficient as its pass-by-reference competitors, but avoids their disadvantages.

C ++ 11并没有从根本上改变C ++ 98关于传值方式的考量。 通常,应尽可能避免按值传递以免造成性能损失,并且按值传递还可能导致基类与派生类对象之间的所谓截断问题(slicing problem)。和C++ 98相比, C ++ 11的新情况是区分左值和右值。 利用可拷贝类型的右值实现移动语义,通常需要重载两个函数或使用通用引用,二者均有缺陷。 对于可拷贝且移动代价低的类对象,如不涉及基类与派生类之间的截断问题,按值传递参数通常可以做到与引用传递参数方式的效率相当,但无需考虑代码膨胀的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值