再探值与引用:函数模板的类型推导规则辨析

初探值与引用:学会正确地使用右值引用 一文中,讲解了右值引用的正确用法。本节继续在前文基础上,讲解值与引用在模板函数中的关系。

在C++11中,有三种引用类型:

  • const T&:const 左值引用
  • T&:非const的左值引用
  • T&&:右值引用

在非模板函数中,常常使用引用类型来作为参数类型,以避免不必要的赋值。但是在模板函数中,却并不总是建议使用引用类型作为模板参数类型,应优先选择值传递,除非遇到以下情况:

  • 对象不允许复制:即拷贝构造函数加上=delete或设置为private
  • 对象用于返回数据(T&);
  • 转发引用(T&&);
  • 引用传递可以获得明显的性能提升。

值传递

引用传递是为了避免复制,但是值传递也不一定就会发生copy。

基于【初探右值引用】中的Foo类,有如下按值传递的模板函数pass_by_value

1
2
3
4
5
6
7
8
9
10
template<typename T>
void pass_by_value(Foo arg) {
// ...
}

int main(int argc, char const *argv[]) {

pass_by_value(Foo{100});
return 0;
}

也许你认为pass_by_value(Foo{100});会调用一次默认构造函数 + 一次ctor,但实际上在C++11之后,编译器发生了优化只是会调用一次默认构造函数,禁止了优化则会调用一次构造函数 + 一次mtor。输出如下:

1
2
3
4
5
$ g++ reference.cc -o ref && ./ref							# 默认情况
default
$ g++ -fno-elide-constructors reference.cc -o ref && ./ref # 禁止编译器优化
default
mtor

说明,当模板参数T具有移动构造函数时,即便是按照值传递也会优先调用移动构造函数,那么这种情况下值传递性能也并没有发生损失。

即使对于比较复杂的类型std::string,由于std::string也实现了移动构造函数,因此使用std::string来实例化pass_by_value函数也是一样的情况:

1
2
3
4
5
std::string str{"hello Cpp"};			

pass_by_value(std::move(str)); // 默认优化;禁止优化则触发移动构造函数
pass_by_value(std::string{"pass_by_move"}); // 默认优化;禁止优化则触发移动构造函数
std::cout<<str.length()<<std::endl; // 输出 0
  • 对于纯右值std::string{"pass_by_move"}:传入pass_value_by函数时,无论是否开启优化,都不会产生性能损失。甚至在C++17中,强制实现优化,省去中间的移动构造函数,即不加 -fno-elide-constructors 编译标志都不会有移动构造的过程,仅一次默认构造函数,就类似上面pass_by_value(Foo{100});输出。

  • 对于已经存在的std::string对象str,如果确认不再使用str,则以std::move(obj)形式传入,告知编译器调用移动构造函数,因此也不会有性能损失。

by the way

  • std::move(str)之后str的值就处于未定义状态,不可再使用,除非再次初始化。

  • 如果 strconst修饰,经过std::move转换也无法触发移动构造函数。为什么?

    因为 pass_by_value(std::string arg)本质上就是如下一个初始化过程:

    1
    2
    const std::string str_const {"hello Cpp"};
    std::string arg{std::move(str_const)};

    没有加上const修饰时,static_cast是可以将左值str转化为右值,触发移动构造函数。

    当加上const修饰时,static_cast就不管用了,因为这中间设计到了两个步骤:1)先将const修饰符去掉,这是const_cast的任务,不是static_cast的;2)才是将左值转换为右值。第一步就失败了。因此,加上const修饰时无法调用移动构造函数。

    进一步,尝试如下类型转换,直接查看编译器错误,会报错:*error: binding reference of type ‘std::string&&’ to ‘const std::string’*。

    1
    std::string&& rv = std::move(str_const);

    但是如果你将鼠标放在pass_by_value(std::move(str_const));函数上,可以发现IDE显示推断出来的类型是std::string。这又是为什么呢???

    答案是类型退化,这是接下来类型退化知识。

上述总结,在模板函数以值传递时,只有一种情况会产生复制:若对象obj已经存在,再调用pass_by_value(obj)才会产生复制,那么有没有可以解决的办法,由调用者决定不复制呢?答案是 pass_by_value(std::ref(obj))

因此,在值传递时,调用者可以自主选择是否复制、移动,具有很大便利性。

类型退化

值传递还有一个重要特性,会导致参数类型T发生退化:

  • C-style的裸数组、字符串常量会退化成指针
  • 变量的constvolatile修饰符会自动消失,指针变量除外。
  • 引用会退化成普通类型

第二条也解释了为什么类型是const std::stringstr_const传入pass_by_value函数后推断为std::string类型。

现有如下的demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   const int  a = 10;
const char b = 'c';
const int array[] = {1,2,3,4};
const char* ch = "Hello world";
const int* pa = &a;
const std::string& str_const_ref = str_const;

// const 退化
pass_by_value(a); // 退化成:int
pass_by_value(b); // 退化成: char
// 数组退化成指针
pass_by_value(array); // const int*
pass_by_value(ch); // const char*
// 指针的const不退化
pass_by_value(pa); // const int*
// 引用退化
pass_by_value(str_const_ref);// !!! 退化成:const std::string& --> std::string
  • const除了修饰指针,其余都会退化掉

    但是可以发现特列:如果const修饰裸数组是不会退化的,这是为啥?因为裸数组会退化成指针,const修饰指针不会退化,使得const修饰数组时就推导出const T*

  • 引用会退化成普通类型。

    即使是const T&也会退化成T

思考下参数退化会带来什么问题?

引用传递

引用传递,与值传递则相反:

  • 任何情况下都不会造成copy
  • 不会造成模板参数类型退化

下面对三种引用类型进行说明。

const T&

const T& 引用类型:既可以接受左值,也可以接受右值。

假定存在pass_by_const_ref函数,参数argconst T&引用类型,在函数内部打印传入的arg对象地址:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void pass_by_const_ref(const T& arg) {
std::cout<<&arg<<std::endl;
}

int main(int argc, char const *argv[]) {
Foo foo{10}; // 调用构造函数
pass_by_const_ref(foo);
pass_by_const_ref(std::move(foo));
pass_by_const_ref(Foo{100}); // 调用构造函数
return 0;
}

最终的输出打印如下:

1
2
3
4
5
6
$ g++ reference.cc -o ref && ./ref
default
0x7fffd68902c0
0x7fffd68902c0
default
0x7fffd68902c4

一共进行了两次构造函数,没有发生copy,符合预期。而且前两次调用pass_by_const_ref函数打印的地址都一致,是因为arg都是指向了foo,但是第三次调用打印的地址增加了4个字节,是因为临时对象Foo{100}foo在同一个连续的栈空间中,而Foo类大小只有四个字节(只有一个int类型成员变量num_)。

因此,const T&引用类型,无论传入左值还是右值,都不会发生copy。

类型不退化

同样以值传递中退化,传入pass_bt_const_ref函数:

1
2
3
4
5
6
7
  pass_by_const_ref(str_const); // const std::string&
pass_by_const_ref(a); // const int&
pass_by_const_ref(b); // const char&
pass_by_const_ref(array); // const int(&)[4]
pass_by_const_ref(ch); // const char* const &
pass_by_const_ref(pa); // const int*
pass_by_const_ref(str_ref_const); // const std::string&

分析如下:

  • pass_by_const_ref函数,传入const std::string类型的对象str_const时,arg推理为const std::string&,没有发生类型退化。其中,T被推理为std::stringconst由于出现在pass_by_const_ref函数的模板参数中,因此不会出现在T中;
  • array也是被推断为固定长度类型const int[&][4],而不是单纯的指针const int*
  • ch被推断为 const char* const&ch原本类型是const char*,表示无法修改ch指向的字符值,但是可以修改ch值,即可以执行++ch这类操作,而pass_by_const_ref函数给ch加上了一个const修饰,使得ch本身的值也无法修改,即const char* const

因此,const T&,不会退化模板参数T的类型,反而会加强没有const修饰的输入参数的类型。

by the way

即使pass_by_const_ref函数,可以接受常量,比如pass_by_const_ref(Foo{100}),但是如果pass_by_const_ref内部如下,最终触发的也是复制构造函数。

或者,arg被用于其他函数xxxx_func(arg)的参数,也是触发const Foo&版本,而不是Foo&&函数版本。

1
2
3
4
5
template<typename T>
void pass_by_const_ref(const T& arg) {
Foo foo(arg);
// xxx_func(arg);
}

T&

假定有pass_by_lv_ref函数,以T&为模板参数类型:

1
2
3
4
template<typename T>
void pass_by_lv_ref(T& arg) {
//...
}

T&const T&区别有两点:

  1. pass_by_lv_ref函数可以修改arg参数,并且修改后的arg值可以传递给pass_by_lv_ref函数的调用者,避免了return返回。

  2. T& 不能接受右值,但如果先以const T类型的变量var指向一个T类型的常量,再把var传递给pass_by_ref函数是允许的。

    如果直接给pass_by_ref函数传递右值,则报错:error: cannot bind non-const lvalue reference of type ‘std::string&’ to an rvalue of type ‘std::string’

    1
    2
    3
    4
    int main(int argc, char const *argv[]) {
    pass_by_lv_ref(std::string{"temp value"});
    return 0;
    }

    如果传入 pass_by_lv_ref函数的参数就带有const修饰,那么将会把arg推导为const类型引用,且这个const修饰符是出现在T中。

    1
    2
    pass_by_lv_ref(str_const);			// 推导为:const std::string&
    pass_by_lv_ref(std::move(str_const)); // 推导为:const std::string&

    但是!!! pass_by_lv_ref函数内部默认arg是可以修改的。当pass_by_lv_ref函数接受了const类型变量,arg被推导为const T&,变成只能读,那么就会导致一个问题:如果在pass_by_lv_ref函数内部修改str_constch等const修饰的传入参数,将会编译报错。

    怎么办?

    在C++20之前,使用std::enable_if_t,直接禁止给pass_by_lv_ref函数传入const类型变量。此时,给 pass_by_lv_ref 函数传递const类型变量,在IDE中这个函数下方出现红色波浪线,提示不存在这个函数,就不需要等到编译期才发现。

    1
    2
    3
    4
    template<typename T, typename = std::enable_if_t< !std::is_const<T>::value>>
    void pass_by_lv_ref(T& arg) {
    //...
    }

T&&

T&&,即右值引用模板化,此时的类型推导原则是:引用折叠

假定有pass_by_perfect_ref函数,如下:

1
2
3
4
template<typename T>
void pass_by_perfect_ref(T&& arg) {
// ... std::forward<T&&>(arg)
}

依赖于引用折叠,pass_by_perfect_ref函数能推导出任意类型的输入参数。此时在pass_by_perfect_ref函数内部就能知道传入的参数是什么类型:

1
2
3
4
5
pass_by_perfect_ref(str);           // 左值推导为:string&,
pass_by_perfect_ref(str_const); // const 左值:const string&
pass_by_perfect_ref(str_ref_const); // const 左值:const string&
pass_by_perfect_ref(std::string{"hello perfect_ref"}); // 右值:std::string&&
pass_by_perfect_ref(std::move(str)); // 右值:std::string&&

一般地,当模板函数使用T&&作为参数类型时,函数内部需使用std::forward<T&&>(arg)来转发输入参数,目的就是为了保持住输入参数的原来类型。

下面先讲解下forward函数:

1
2
3
4
5
6
7
8
9
10
11
12
// 用于接收 const T&、T&
template<typename T>
constexpr T&& forward(typename std::remove_reference<T>::type& __t) noexcept {
return static_cast<T&&>(__t);
}
// 用于接收 T&&
template<typename T>
constexpr T&& forward(typename std::remove_reference<T>::type&& __t) noexcept {
static_assert(!std::is_lvalue_reference<T>::value,
"template argument substituting _Tp is an lvalue reference type");
return static_cast<_Tp&&>(__t);
}
  1. forward函数的输入参数是 const T&类型时,

    • std::remove_reference函数会消去T的引用,变成const T,那么forward函数的__t类型即 const T&
    • 再经过static_cast<const T&&&>(_t),其中目的类型是const T&&&,经引用折叠变成const T&

    因此,当输入类型是const T&时,输出还是这个类型

  2. forward函数的输入是T&类型时,分析同上,输出还是T&

  3. forward函数的输入是T或者T&&类型时,经过std::remove_reference函数都会变成T类型,再经过static_cast<T&&>转换,都变成T&&

    右值传递到pass_by_perfect_ref函数时,arg指向的是右值,但arg本身是左值,如果不经过std::forward转换,arg将以左值角色参与后续操作,违背了调用者意图。

因此std::forward函数,搭配模板使用时,比如上面的 pass_by_perfect_ref 函数,arg无论传递给std::forward什么类型,std::forward都能输出相同的类型。因此,std::forward<T&&>(arg)可以触发以arg对象为参数的最合适、正确的的函数,或者构造函数:

  • 如果arg是作为其他函数的参数,那么std::forward<T&&>(arg)则是为了调用该函数的正确模板
  • 如果arg是作为同类型的构造函数的参数,那么std::forward<T&&>(arg)就是为了调用正确的构造函数版本。

因此,对于下面的demo,pass_by_perfect_ref能正确推断出输入参数arg的类型,将arg传给std::forward时,仍然能保持原类型,这就保证了合理的调用后续函数。

1
2
3
pass_by_perfect_ref(str_const); // const string&    
pass_by_perfect_ref(str); // string&
pass_by_perfect_ref(std::string{"hello perfeect ref"}); // std::string&&

by the way

提一下,std::forwardstd::move的区别,再理解下std:forward的完美转发的含义。

pass_by_perfect_refpass_by_move_ref两个函数,内部分别是调用std::forwardstd::move进行转发后,再用于构造Foo对象。

1
2
3
4
5
6
7
8
9
template<typename T>
void pass_by_perfect_ref(T&& arg) {
Foo foo(std::forward<T&&>(arg));
}

template<typename T>
void pass_by_move_ref(T&& arg) {
Foo foo(std::move(arg));
}

对于下面的函数调用,都是将左值foo作为传入参数:

1
2
3
4
5
6
int main(int argc, char const *argv[]) {
Foo foo{10};

pass_by_perfect_ref(foo);
pass_by_move_ref(foo);
}

输出却不同:

1
2
3
4
$ g++ reference.cc -o ref && ./ref
default
ctor
mtor

std::forward<T&&>(arg) 输出后arg仍然是左值角色,最后调用的是Foo的复制构造函数,这也符合调用pass_by_perfect_ref函数调用者的预期,因为自己传入的就是foo,就是想要触发ctor

std::move(arg)输出后arg变成右值角色,最后调用的是Foo的移动构造函数,这会导致pass_by_move_ref函数的调用者传入的foo对象处于未定义状态,这符合调用者的预期吗?明显不是。

对于调用者而言,是否要触发移动构造函数,应该由于调用调用者自己掌控:

  • 想触发复制构造函数时,就如下(1)调用
  • 想触发移动构造函数时,就如下(2)调用
1
2
pass_by_perfect_ref(foo);	// (1)
pass_by_perfect_ref(std::move(foo)); // (2)

这也就是经常各个书籍上看到将std::move称为无条件转发,而std::forward是完美转发的理由。

by the way, Again !!!

还记得在本文开篇说,模板函数不选择值传递的四种情况之一是:转发引用(T&&)。现在理解了一点没有?

因为在T&&下,pass_by_perfect_ref可以推导出任意类型的输入参数,调用合适的函数版本。这一过程像值传递一样,完全可以由调用者掌握。

1
2
3
4
// 调用者自己可以选择
pass_by_perfect_ref(foo);
pass_by_perfect_ref(std::move(foo));
pass_by_perfect_ref(FOO{10});

T&&也不会退化

自然,T&&引用也不会发生类型退化,传入的裸数组参数arg是什么类型就会变成相应类型的引用,不会加强也不会退化,类似于T&

1
2
pass_by_perfect_ref("hello perfect_ref");  // const char (&rhs)[18]
pass_by_perfect_ref(array); // const int (&rhs)[4]

T&&的缺点

T&&就当真那么perfect?

如果你在 pass_by_perfect_ref 函数内部使用T定义一个对象,那么当 pass_by_perfect_ref函数传入一个左值时,T此时会被推断为const T&或者T&,由于引用未初始化而报错:error: ‘obj’ declared as reference but not initialized

1
2
3
4
template<typename T>
void pass_by_perfect_ref(T&& arg) {
T obj;
}

本文,主要讲解了值传递和引用传递的优缺点,但还有一个遗留问题:当模板函数的传入参数类型是字符串常量和裸数组时,该如何更好地处理?

  • 值传递:字符串常量/数组会退化成指针,那如果想判断数组是否相等怎么办?
  • 引用传递:字符串/数组会被推导出固定长度类型,比如上面的 const int (&rhs)[4],模板的灵活性不就丧失了?

敬请期待下一次更新,奥里给!!!