现代C++:类型推导 All in one
前言
第一次尝试着写文章记录一些所见所学,目的主要是确认自己懂了,并且是正确的懂了,希望有不正确的地方,大家能在评论区指正。虽然CSDN口碑不太好,不过只是记载文字而已,只要在编辑文章的地方没有广告就行。
表达式的值类型
所有的C++都由类型与值类别组成。类型就是我们常说的int、int*、int&、int&&等。而值类别就相对复杂,我们以C++标准为准,同时在后文中提供了函数来判断值类型。
值类别
C与C++98中关于值类别的定义就不再回溯,但还是建议大家看一看参考链接。
随着移动语义引入到 C++11 之中,值类别被重新进行了定义,以区别表达式的两种独立的性质:
- 拥有身份 (identity):可以确定表达式是否与另一表达式指代同一实体,例如通过比较它们所标识的对象或函数的(直接或间接获得的)地址;
- 可被移动:移动构造函数、移动赋值运算符或实现了移动语义的其他函数重载能够绑定于这个表达式。
为了更好的表达移动语义,C++11对值类别进行了更系统的分类。需要注意拥有身份是对表达式而言的,如果两个不同的表达式可以表示同一个实体,才能称为拥有身份;而可被移动并不是通过可否
std::move()
来判断(后面会解释),而是通过该表达式能否被移动构造函数、移动赋值运算符或实现了移动语义的其他函数绑定来确认的。
通过以上属性可以将值类别分类(还描述了其性质):
- 拥有身份且不可被移动的表达式被称作左值 (lvalue)表达式;
- 可以由内建的取址运算符取左值的地址:
&++i[1]
及&std::endl
是合法表达式。 - 可修改的左值可用作内建赋值和内建复合赋值运算符的左操作数。
- 左值可用于初始化左值引用;这会将一个新名字关联给该表达式所标识的对象。
- 可以由内建的取址运算符取左值的地址:
- 拥有身份且可被移动的表达式被称作亡值 (xvalue)表达式;
- 特别是,与所有的右值类似,亡值可以绑定到右值引用上,而且与所有的泛左值类似,亡值可以是多态的,而且非类的亡值可以有 cv 限定。
- 不拥有身份且可被移动的表达式被称作纯右值 (prvalue)表达式;
- 纯右值不具有多态:它所标识的对象的动态类型始终为该表达式的类型。
- 非类非数组的纯右值不能有 cv 限定,除非它被实质化以绑定到 cv 限定类型的引用 (C++17 起)。(注意:函数调用或转型表达式可能生成非类的 cv 限定类型的纯右值,但其 cv 限定符通常被立即剥除。)
- 纯右值不能具有不完整类型(除了类型 void(见下文),或在 decltype 说明符中使用之外)
- 纯右值不能具有抽象类类型或其数组类型。
- 拥有身份的表达式被称作“泛左值 (glvalue) 表达式”。左值和亡值都是泛左值表达式。
- 泛左值可以通过左值到右值、数组到指针或函数到指针隐式转换转换成纯右值。
- 泛左值可以是多态的:其所标识的对象的动态类型不必是该表达式的静态类型。
- 泛左值可以具有不完整类型,只要表达式中容许。
- 可被移动的表达式被称作“右值 (rvalue) 表达式”。纯右值和亡值都是右值表达式。
- 右值不能由内建的取址运算符取地址:
&int()、&i++[3]、&42 及 &std::move(x)
是非法的。 - 右值不能用作内建赋值运算符及内建复合赋值运算符的左操作数。
- 右值可以用于初始化 const 左值引用,这种情况下该右值所标识的对象的生存期被延长到该引用的作用域结尾。
- 右值可以用于初始化右值引用,这种情况下该右值所标识的对象的生存期被延长到该引用的作用域结尾。
- 当被用作函数实参且该函数有两种重载可用,其中之一接受右值引用的形参而另一个接受 const 的左值引用的形参时,右值将被绑定到右值引用的重载之上(从而,当复制与移动构造函数均可用时,以右值实参将调用其移动构造函数,复制和移动赋值运算符与此类似)。
- 右值不能由内建的取址运算符取地址:
上面所说容易看晕,下面是总结出的一些样例,大家可以对照着看:
class Base {
public:
static int c_static_a;
int c_a{ 0 };
void c_f() {};
static void c_static_f() {};
Base& operator+(const Base& rhs) {
c_a += rhs.c_a;
return *this;
}
enum MyColor
{
Blue,
};
};
void f(int) {}
Base& g(Base& b) {
return b;
}
Base&& make_base() {
Base base{};
return std::move(base);
}
decltype(auto) h()
{
auto fp = static_cast<void(&&)(int)>(f);
return fp;
}
int main()
{
int a{ 0 };
int&& b{ 12 };
Base base{}, base2{};
int* p{ &a };
int arr[]{1,2,3,4};
// 左值
// 1. 变量、函数、数据成员名构成的表达式
&a;
&b; // 注意该规则是部分类型的,如b是int&&类型
&f;
&(base.c_a);
&(base.c_static_a); // 静态变量名也是左值
// 2. 返回类型为左值引用的函数调用或重载运算符表达
&(g(base));
&(base + base2);
// 3. a = b,a += b,a %= b,以及所有其他内建的赋值及复合赋值表达式
&(a = b); // TODO 通过赋值语句将右值引用类型的左值表达式绑定到int类型的左值表达式,这个赋值语句的具体函数是怎么样的
// 4. ++a 和 --a,内建的前置自增与前置自减表达式;
&(++a);
// 5. *p,内建的间接寻址表达式;
&(*p);
// 6. a[n] 和 n[a],内建的下标表达式,当 a[n] 中的一个操作数为数组左值时(C++11 起);
// TODO 没太看懂描述
&(arr[4]);
// 7. a.m,对象成员表达式,除了 m 为成员枚举项或非静态成员函数,或者 a 为右值而 m 为对象类型的非静态数据成员的情况;
// &(base.Blue) erro 成员枚举项
// &(base.c_f); error 非静态成员函数
&(base.c_static_f);
// &make_base().c_a; // TODO a 为右值而 m 为对象类型的非静态数据成员的情况 不知道怎么创造,这个make_base()为什么拿到的不是右值呢?
// 8. p->m,内建的指针成员表达式,除了 m 为成员枚举项或非静态成员函数的情况;同1和7相似
// 9. a.*mp,对象的成员指针表达式,其中 a 是左值且 mp 是数据成员指针;同1和7相似
// 10. p->*mp,内建的指针的成员指针表达式,其中 mp 是数据成员指针同1和7相似
// 11. a, b,内建的逗号表达式,其中 b 是左值;
&(12, a);
// 12. a ? b : c,对某些 b 和 c 的三元条件表达式(例如,当它们都是同类型左值时,但细节见其定义);TODO 先搁置不管
// 13. 字符串字面量,例如 "Hello, world!";
&("HELLO WORLD");
// 14. 转换为左值引用类型的转型表达式
&(static_cast<int&>(a));
// 15. 返回类型是 函数的右值引用 的函数调用表达式或重载的运算符表达式;
//&(h()); // TODO 不知道怎么声明返回值
// 16. 转换为函数的右值引用类型的转型表达式,如 static_cast<void(&&)(int)>(x)。
&(static_cast<void(&&)(int)>(f));
// 纯右值
// 1. (除了字符串字面量之外的)字面量,例如 42、true 或 nullptr;
nullptr;
// 2. 返回类型是非引用的函数调用或重载运算符表达式,例如 str.substr(1, 2)、str1 + str2 或 it++;
f(1);
// 3. a++ 和 a--,内建的后置自增与后置自减表达式;
a++;
// 4. a + b、a % b、a & b、a << b,以及其他所有内建的算术表达式;
a + b;
// 5. a && b、a || b、!a,内建的逻辑表达式;
a && b;
// 6. a < b、a == b、a >= b 以及其他所有内建的比较表达式;
a < b;
// 7. & a,内建的取地址表达式;
&(a);
// 8. a.m,对象成员表达式,其中 m 是成员枚举项或非静态成员函数[2];
base.c_static_f;
base.Blue;
// 9. p->m,内建的指针成员表达式,其中 m 为成员枚举项或非静态成员函数[2];同8差不多
// 10. a.*mp,对象的成员指针表达式,其中 mp 是成员函数指针;同8差不多
// 11. p->*mp,内建的指针的成员指针表达式,其中 mp 是成员函数指针[2];同8差不多
// 12. a, b,内建的逗号表达式,其中 b 是右值;
a, 12;
// 13. a ? b : c,对某些 b 和 c 的三元条件表达式(细节见其定义);
// 14. 转换为非引用类型的转型表达式,例如 static_cast<double>(x)、std::string{} 或(int)42;
static_cast<double>(a);
// 15. this 指针;
// 16. 枚举项;
Base::MyColor::Blue;
// 17. lambda 表达式,例如[](int x) { return x * x; };
[](int x) {return x * x; };
// 亡值
// 1. 返回类型为对象的右值引用的函数调用或重载运算符表达式,例如 std::move(x);
std::move(a);
make_base();
// 2. a[n],内建的下标表达式,其操作数之一是数组右值;// TODO ?
arr[4];
// 3. a.m,对象成员表达式,其中 a 是右值且 m 是非引用类型的非静态数据成员;
make_base().c_a; // TODO
// 4. a.*mp,对象的成员指针表达式,其中 a 为右值且 mp 为数据成员指针;同3类似
// 5. a ? b : c,对某些 b 和 c 的三元条件表达式(细节见其定义);
// 6. 转换为对象的右值引用类型的转型表达式,例如 static_cast<char&&>(x);
static_cast<char&&>(a); // move就是进行了差不多的cast
// 7. 在临时量实质化后,任何指代该临时对象的表达式。(C++17 起)
Base().c_a;
}
模板推导
我们以下面的模型来研究模板推导:
template<typename T>
void f(ParamType param);
f(expr);
在编译期间,编译器使⽤expr进⾏两个类型推导:⼀个是针对T的,另⼀个是针对ParamType的。
在研究之前我们先看看要如何获得编译器真正推导出来的结果,现代C++11编译器都提供一个宏,可以获得函数签名,这里以MSCV
为例:
template<typename T>
void f(const T& param) {
std::cout << __FUNCSIG__ << std::endl;
};
int main() {
int i{};
f(i); // out: void __cdecl f<int>(int &)
}
上述例子我们可以很明显的看出来,T被推导成int,ParamType被推导成int&。
如果我们有boost库,还可以使用boost::type_index::type_id_with_cvr来查看推导类型:
template<typename T>
void f(const T& param) {
using boost::typeindex::type_id_with_cvr;
std::cout << "T = " << type_id_with_cvr<T>().pretty_name()
<< "; param = " << type_id_with_cvr<decltype(param)>().pretty_name()
<< std::endl;
}
int main{
int i{};
f(i); // out: T = int; param = int const & __ptr64
}
就像上面一样,T和ParamType两个类型通常是不同的,因为ParamType包括了const和引⽤的修饰。T的推导不仅取决于expr的类型,也取决于ParamType的类型。这⾥有三种情况:
- ParamType是⼀个指针或引⽤,但不是通⽤引⽤(关于通⽤引⽤请参⻅Item24。在这⾥你只需要知道它存在,而且不同于左值引⽤和右值引⽤)
template<typename T> void f(T& param);
- ParamType⼀个通⽤引⽤
template<typename T> void f(T&& param);
- ParamType既不是指针也不是引⽤
template<typename T> void f(T param);
ParamType是⼀个指针或引⽤,但不是通⽤引⽤
- 如果expr的类型是⼀个引⽤,忽略引⽤部分
- 然后剩下的部分决定T,然后T与形参匹配得出最终ParamType
template<typename T> void f(T& param);
// f(expr);
int x=27;
const int cx=x;
const int & rx=cx;
f(x); //T是int,param的类型是int&
// 根据法则二,因为形参中没有const,要保留const,得出T->const int。
// 这也说明了向T&类型的参数传递const对象是安全的:对象T的常量性会被保留为T的⼀部分
f(cx); //T是const int,param的类型是const int &
// 根据法则一忽略引用,根据法则二,得出T->const int。
f(rx); //T是const int,param的类型是const int &
ParamType⼀个通⽤引⽤
我们定义在函数模板中假设有⼀个模板参数T,那么通⽤引⽤就是T&&。通用引用的推导规则比较复杂,在移动语义中相当重要。
- 如果expr是左值,T和ParamType都会被推导为左值引⽤。这⾮常不寻常,第⼀,这是模板类型推导中唯⼀⼀种T和ParamType都被推导为引⽤的情况。第⼆,虽然ParamType被声明为右值引⽤类型,但是最后推导的结果它是左值引⽤。
- 如果expr是右值,就使⽤情景⼀的推导规则
template<typename T> void f(T&& param);
// f(expr);
int x=27;
const int cx=x;
const int & rx=cx;
f(x); //T是int&,param的类型是int&
f(cx); //T是const int&,param的类型是const int &
f(rx); //T是const int&,param的类型是const int &
f(12); //T是int,param的类型是**int&&**,在移动语义中相当重要
很帅的一点是当通⽤引⽤被使⽤时,类型推导会区分左值实参和右值实参。我们可以利用这一点来在代码中判断表达式的值类型。
template<typename T>
bool is_lvalue(T&& param) {
return std::is_lvalue_reference_v<T&&>;
}
template<typename T>
bool is_rvalue(T&& param) {
return std::is_rvalue_reference_v<T&&>;
}
is_lvalue(i); // out: true
is_rvalue(12); // out: true
// make_base()可以参考《表达式的值类型》
is_rvalue(make_base()); // out: true
is_lvalue(make_base()); // out: false
ParamType既不是指针也不是引⽤
这种方式是通过pass-by-value进行的,因为是通过传值拷贝出的形参,所以不能保留cv限定。
- 和之前⼀样,如果expr的类型是⼀个引⽤,忽略这个引⽤部分
- 如果忽略引⽤之后expr是⼀个cv,那就再忽略c-v。
template<typename T> void f(T param);
// f(expr);
int x=27;
const int cx=x;
const int & rx=cx;
const char* const ptr = ; //
f(x); //T是int,param的类型是int
f(cx); //T是int,param的类型是int
f(rx); //T是int,param的类型是int
// 因为拷贝的是指针变量,所以抹去了ptr的cv限定,但是ptr指向的对象的cv属性被保留下来了。
f(ptr); // T是const char*, param的类型是const char*
数组和函数作为实参时,如果是通过pass-by-value传递会退化成指针,这里不做研究,很少遇见。
库解析
remove_reference
:
template <class _Ty>
struct remove_reference {
using type = _Ty;
};
template <class _Ty>
struct remove_reference<_Ty&> {
using type = _Ty;
};
template <class _Ty>
struct remove_reference<_Ty&&> {
using type = _Ty;
};
int x = 1;
int& y = x;
int&& z = 2;
type_id_with_cvr<decltype(x)>().pretty_name(); // out: int
type_id_with_cvr<remove_reference_new<decltype(x)>::type>().pretty_name(); // out: int
type_id_with_cvr<decltype(y)>().pretty_name(); // out: int&
type_id_with_cvr<remove_reference_new<decltype(y)>::type>().pretty_name(); // out: int
type_id_with_cvr<decltype(z)>().pretty_name(); // out: int&&
type_id_with_cvr<remove_reference_new<decltype(z)>::type>().pretty_name(); // out: int
- 类模板不会根据变量进行推导
- 类模板的主模板不能含有模板参数列表
- T&优先匹配T&,再考虑T
- T&&优先匹配T&&,再考虑T
std::forward
:
template <class _Ty>
_NODISCARD constexpr _Ty&& forward(
remove_reference_t<_Ty>& _Arg) noexcept { // forward an lvalue as either an lvalue or an rvalue
return static_cast<_Ty&&>(_Arg);
}
template <class _Ty>
_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept { // forward an rvalue as an rvalue
static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
return static_cast<_Ty&&>(_Arg);
}
通过在forward的形参中使用remove_reference_t<T>
阻止形如std::forward()
的调用,这是语法规则。
std::forward()
的调用即使能进行类型推导也是没有意义的。
auto推导
当⼀个变量使⽤auto进⾏声明时,auto扮演了模板的⻆⾊,变量的类型说明符扮演了ParamType的⻆⾊。我们直接看案例,它的推导规则跟模板推导不能说一摸一样,只能说完全一致。
auto x = 27; // int
const auto cx = x; // const int
const auto& rx = cx; // const int&
const char name[] = "R. N. Briggs"; // const char[13]
auto arr1 = name; // const char*
auto& arr2 = name; // const char(&)[13]
auto func1 = someFunc; // void(*)(int,double)
auto& func2 = someFunc; // void(&)(int,double)
auto&& rrx = x; // int&
auto&& rrx2 = 12; // int&&
auto tmp1{ 1 }; // int,**这里推出来是正确的**
auto tmp2 = { 1 }; // std::initializer_list<int>,**需要注意这一点**
// auto 常用于无名类型,例如 lambda 表达式的类型
auto lambda = [](int x) { return x + 3; };
[](...){}(c0, c1, v, w, d, n, m, lambda); // 阻止“变量未使用”警告,这个帅气
decltype推导
decltype
是最老实本分的类型推导了,甚至它根本没有推导,但是它有很多用法值得我们学习。
int x = 27; // int
const int cx = x; // const int
const int & rx = cx; // const int&
int&& r = 12; // int&&
在C++11中,decltype最主要的⽤途就是⽤于函数模板返回类型,而这个返回类型依赖形参,如下:
template<typename Container,typename Index>
auto authAndAccess(Container& c,Index i)
->decltype(c[i])
{
authenticateUser();
return c[i];
}
函数名称前⾯的auto不会做任何的类型推导⼯作。相反的,他只是暗⽰使⽤了C++11的尾置返回类型语法,即在函数形参列表后⾯使⽤⼀个->
符号指出函数的返回类型。
尾置返回类型的好处是我们可以在函数返回类型中使⽤函数参数相关的信息。在authAndAccess函数中,我们指定返回类型使⽤c和i
。如果我们按照传统语法把函数返回类型放在函数名称之前, c和i
就未被声明所以不能使⽤。
而在C++14标准下,只需要一个auto就可以告诉编译器从函数的返回类型进行auto推导,如下:
template<typename Container,typename Index>
auto authAndAccess(Container& c,Index i) // 类型推导成T
{
authenticateUser();
return c[i];
}
我们知道auto的推到规则是按照函数模板的推导规则来的,会导致例如使用auto接受一个引用时将引用去掉了,这样的意外情况。这是我们可以使用decltype(auto)
告诉编译器使用decltype
推导规则。
template<typename Container,typename Index>
decletype(auto) authAndAccess(Container& c,Index i) // 类型推导成T&
{
authenticateUser();
return c[i];
}
// `decltype(auto)`还可以用于初始化表达式的推导:
int i = 1;
const int& j = i;
auto k = j; // int
decltype(auto) l = j; // const int &
事实上decltype还有一种decltype( (expr) )
的形式,其定义如下:
- 如果 表达式 的值类别是亡值,将会 decltype 产生 T&&;
- 如果 表达式 的值类别是左值,将会 decltype 产生 T&;
- 如果 表达式 的值类别是纯右值,将会 decltype 产生 T。
通过以上特性,我们可以更改之前写的判断左右值的模板,如下:
template <typename T>
constexpr bool is_lvalue = std::is_lvalue_reference_v<T>;
template <typename T>
constexpr bool is_xvalue = std::is_rvalue_reference_v<T>;
template <typename T>
constexpr bool is_prvalue = !(is_lvalue<T> || is_xvalue<T>);
const int cx = x;
is_lvalue_b<decltype((x))>; // T&
is_xvalue_b<decltype((make_base()))>; // T&&
is_prvalue_b<decltype((12))>; // T
通过目前所讲,可以做到完美转发,具体的以后再研究:
完美转发就是保持函数实参的值类别
// 在其所调用的函数返回引用的情况下
// 函数调用的完美转发必须用 decltype(auto)
template<class F, class... Args>
decltype(auto) PerfectForward(F fun, Args&&... args)
{
return fun(std::forward<Args>(args)...);
}