C++类型推断

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/xiaohu2022/article/details/69935605

C++类型推断

对于静态语言来说,你一般要明确告诉编译器变量或者表达式的类型。但是庆幸地是,现在C++已经引入了自动类型推断:编译器可以自动推断出类型。在C++11之前,类型推断只是用在模板上。而C++11通过引入两个关键字autodecltype扩展了类型推断的应用。C++14更进一步扩展了autodecltype的应用范围。明显地,类型推断可以减少很多无必要的工作。但是高兴之余,你仍然有可能会犯一些错误,如果你不能深入理解类型推断背后的规则与机理。因此,我们分别从模板类型推断、autodecltype的使用三个方面深入讲解类型推断。

模板类型推断

模板类型推断在C++98中就已经引入了,它也是理解autodecltype的基石。下面是一个函数模板的通用例子:

template <typename T>
void f(ParamType param);

f(expr);   // 对函数进行调用

编译器要根据expr来推断出TParamType的类型。特别注意的是,这两个类型有可能并不相同,因为ParamType可能会包含修饰词,比如const&。看下面的例子:

template <typename T>
void f(const T& param);

int x = 0;
f(x);   // 使用int类型调用函数

此时类型推断结果是:T的类型是int,但是ParamType的类型却是const int&。所以,两个类型并不相同。还有,你可能很自然地认为T的类型与表达式expr是一样的,比如上面的例子:两者是一样的。但是实际上这也是误区:T的类型不仅取决于expr,也与ParamType仅仅相关。这存在三种不同的情形:

情形1:ParamType是指针或者引用类型

最简单的情况ParamType是指针或者引用类型,但不是通用引用类型(&&)。此时,类型推断要点是:
1. 如果expr是引用类型,那就忽略引用部分;
2. 通过相减exprParamType的类型来决定T的类型。

比如,下面是引用类型的例子:

template <typename T>
void f(T& param);  // param是引用类型

int x = 27;      // x是int类型
const int cx = x;  // cx是const int类型
const int& rx = x;   // rx是const int&类型

f(x);   // 此时T为int,而param是int&
f(cx);  // 此时T为const int,而param是const int&
f(rx);  // 此时T为const int,而param是const int&

其中可以看到,const对象传递给接收T&参数的函数模板时,const属性是能够被T所捕获的,即const称为T的一部分。同时,引用类型对象的引用属性是可以忽略的,并没有被T所捕获。上面处理的其实是左值引用,对于右值引用,规则是相同的,但是右值引用的通配符T&&还有另外的含义,会在后面讲。

如果param是常量引用类型,推断也是相似的,尽管有些区别:

template <typename T>
void f(const T& param);  // param是常量引用类型

int x = 27;      // x是int类型
const int cx = x;  // cx是const int类型
const int& rx = x;   // rx是const int&类型

f(x);   // 此时T为int,而param是const int&
f(cx);  // 此时T为int,而param是const int&
f(rx);  // 此时T为int,而param是const int&

指针类型也同样适用:

template <typename T>
void f(T* param);      // param是指针类型

int x = 27;    // x是int
int* px = &x;  // px是int*
const int* cpx = &x;  // cpx是const int*

f(px);   // 此时T是int,而param是int*
f(cpx);  // 此时T是const int,而param是const int*

显然,这种情形类型推断很容易。

情形2:ParamType是通用引用类型(&&)

这种情形有点复杂,因为通用引用类型参数与右值引用参数的形式是一样的,但是它们是有区别的,前者允许左值传入。类型推断的规则如下:
1. 如果expr是左值,TParamType都推导为左值引用,尽管其形式上是右值引用(此时仅把&&匹配符,一旦匹配是左值引用,那么&&可以忽略了)。
2. 如果expr是右值,可以看成情形1的右值引用。

规则有点绕,还是例子说话:

template <typename T>
void f(T&& param);     // 此时param是通用引用类型

int x = 10;     // x是int
const int cx = x;   // cx是const int
const int& rx = x;   // rx是const int&

f(x);      // 左值,T是int&,param是int&
f(cx);     // 左值,T是const int&,param是const int&
f(rx);    // 左值,T是const int&,param是const int&
f(10);    // 右值,T是int,而param是int&&

所以,只要区分开左值与右值传入,上面的类型推断就清晰多了。

情形3:ParamType不是指针也不是引用类型

如果ParamType既不是引用类型,也不是指针类型,那就意味着函数的参数是传值了:

template <typename T>
void f(T param);   // 此时param是传值方式

传值方式意味着param是传入对象的一个新副本,相应地,类型推断规则为:
1. 如果expr类型是引用,那么其引用属性被忽略;
2. 如果忽略了expr的引用特性后,其是const类型,那么也忽略掉。

下面是例子:

int x = 10;         // x是int
const int cx = x;   // cx是const int
const int& rx = x;   // rx是const int&

f(x);          // T和param都是int
f(cx);         // T和param还是int
f(rx);         // T和param仍是int

其实上面的规则不难理解,因为param是一个新对象,不论其如何改变,都不会影响传入的参数,所以引用属性与const属性都被忽略了。但是有个特殊的情况,当你送入指针变量时,会有些变化:

const char* const ptr = "Hello, world";  // ptr是一个指向常量的常量指针
f(ptr);

尽管还是传值方式,但是复制是指针,当然改变指针本身的值不会影响传入的指针值,所以指针的const属性可以被忽略。但是指针指向常量的属性却不能忽略,因为你可以通过指针的副本解引用,然后就修改了指针所指向的值,原来的指针指向的内容也会跟着变化,但是原来的指针指向的是const对象。矛盾会产生,所以这个属性无法忽略。因此,ptr的类型是const char*

尽管前面三种情况已经包含了可能,但是对于特定函数参数,仍然会有特殊情况。第一情况是传入的参数是数组,我们知道如果函数参数是数组,其是当做指针来处理的,所以下面的两个函数声明是等价的:

void fun(int arr[]);   // 数组形式
void fun(int* arr);    // 指针形式
// 两者是等价的

所以,对于函数模板类型推断来说,数组参数推断的也是指针类型,比如传值方式:

template <typename T>
void f(T param);   // 传值方式

const char[] name = "Julie";   // name是char[6]数组
f(name);                  // 此时T和param是const char*类型

但是如果是引用方式,事情就发生了变化,此时数组不再被当做指针类型,而就是固定长度的数组。所以:

template <typename T>
void f(T& param);          // 引用类型

const char[] name = "Julie";   // name是char[6]数组
f(name);                  // 此时T是const char[6],而param类型是const char (&)[6]

显然与传值方式不同,很难让人理解,但是事实就是如此。但是这也暴漏了一个事实:数组的引用利用函数模板可以推导出数组的大小,下面是一个可以返回数组大小的函数实现:

template <typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
    // 由于并不实际需要数组,只用到其类型推断,所以不需要参数
    return N;
}

int arr[] = {1, 3, 7, 2, 9};
const int size = arraySize(arr);  // 5

真实很神奇的一个函数,但是一切又合情合理!

另外一个特殊情况就是传递的参数是函数,其实也是当做指针,和数组参数类似:

template <typename T>
void f1(T param);       // 传值方式

template <typename T>
void f2(T& param);       // 引用方式

void someFun(int);  // 类型为void (int)

f1(someFun);         // T和param是 void (*) (int)类型
f2(someFun);         // T是void (int)(不是指针类型),但param是void (&) (int)类型
// 尽管如此,实际使用时差别不大,用于回调函数时,一般不会去修改那个函数吧

auto类型推断

C++11引入了auto关键字,用于变量定义时的类型自动推断。从表面上看,auto与模板类型推断的作用对象是不一样的。但是两者实际上是一致的,函数模板推断的任务是:

template <typename T>
void f(ParamType param);

f(expr);   // 根据expr类型推导出T和ParamType的类型

编译器要根据expr类型推导出T和ParamType的类型。移植到auto上是那么容易:把auto看成函数模板中的T,而把变量的实际类型看成ParamType。这样我们可以把auto类型推断转换成函数模板类型推断,还是例子说话:

// auto推断例子
auto x = 10;
const auto cx = x;
const auto& rx = x;

// 传化为模板类型推断
template <typename T>
void f1(T param);
f1(10);          

template <typename T>
void f2(const T param);
f2(x);  

template <typename T>
void f3(const T& param);
f3(x);  

显然,很容易推断出各个变量的类型。前面说到,函数模板类型推断有三种情况,那么对于auto来说,仍然有三种情形:
1. 类型修饰符是一个指针或者引用,但是不是通用引用;
2. 类型修饰符是一个通用引用;
3. 类型修饰符不是指针,也不是引用。

下面是具体例子:

const int N = 2;
auto x = 10;   // 情形3: int
const auto cx = x; // 情形3: const int
const auto& rx = x;  // 情形1:const int&
auto y = N;         // 情形3: int

// 情形2
auto&& y1 = x;   // 左值:int&
auto&& y2 = cx;  // 左值: const int&
auto&& y3 = 10;  // 右值:int&&

可以看到,auto与函数模板类型推断本质上是一致的。但是有一个特殊情况,那就是C++11支持统一初始化方式:

// 等价的初始化方式
int x1 = 9;
int x2(9);
// 统一初始化
int x3 = {9};
int x4{9};

上面的4种方式都可以用来初始化一个值为9的int变量,那么你可能会想下面的代码是同样的效果:

auto x1 = 9;
auto x2(9);
auto x3 = {9};
auto x4{9};

但是实际上不是这样:对于前两个,确实是初始化了值为9的int类型变量,但是后两者确是得到了包含元素9的std::initialzer_list<int>对象(初始化列表),这算是auto的一个特例吧。但是这对函数模板类型推断并不适用:

auto x = {1, 3, 5}  // 合法:std::initializer_list<int>类型

template<typename T>
void f(T param);

f({1, 3, 5});  // 非法,无法编译:不能推断出T的类型

// 可以修改成下面
template <typename T>
void f2(std::initializer_list<T> param);

f2({1, 3, 5});  // 合法:T是int,param是std::initializer_list<int>

上面讲的都是关于auto用于变量定义时的类型推断。但是C++14auto还可以用于函数返回类型的推断以及泛型lambda表达式(其参数支持自动推断类型)。如下面的例子:

// C++14功能
// 定义一个判断是否大于10的泛型lambda表达式
auto isGreaterThan10 = [] (auto i) { return i > 10;};

bool r = isisGreaterThan10(20);  // false

// auto用于函数返回类型自动推断
auto multiplyBy2Lambda(int x)
{
    return [x] {return 2 * x;};
}

auto f = multiplyBy2Lambda(4);
cout << f() << endl;   // 8

这些例子是auto用于模板类型推断,不同于前面的定义变量时的类型推断,不能使用初始化列表来推断:

// 以下都是无法编译的
auto createList()
{
    return {1, 3, 5};
}

auto f = [](auto v) {};
f({1, 3, 5});

总之,auto与模板类型推断是一致的,除了要注意初始化列表这种特殊情况。

decltype关键字

decltype用于返回某一实体(变量名与表达式)的类型。我们从最简单的例子开始:

const int x = 0;   // decltype(x)是const int

struct Point {int x; int y;};
Point p{2, 5};
// decltype(Point::x)是int; decltype(p.x)是int

bool f(int x);

// decltype(f)是bool(int)
// decltype(f(2.0))是bool

vector<int> v{2, 5};
// decltype(v)是vector<int>
// decltype(v[0])是int&

大部分情况,decltype按照你所预料的方式工作:decltype用于一个变量名时,返回的正是该变量所对应的类型;用于函数返回值也正是函数返回值类型。但是当用于左值表达式时,decltype推断出的类型却一定是一个引用类型,看下面的例子:

int x = 10;
// decltype(x)是int,但是decltype((x))确是int&

struct A {double x;};
const A* a = new A{2.0};
// decltype(a->x)是double,但是decltype((a->x))确是const double&

让人感觉非常奇怪。其实广泛的C++表达式(字面值,变量名,表达式等等)包含两个独立的属性:类型(type)和值种类(value category)。这里的类型指的是非引用类型,而值种类有三个基本类型:xvalue,lvalueprvalue。当decltype作用于不同值种类的表达式上,其效果不一样。具体可以参考这里(反正有点复杂)。

上面的简单了解就好,因为用的并不是太多。而decltype的一个很重要的应用是在函数模板中的返回值类型推断。这里举个例子:你想写一个函数,这个函数接收两个参数,一个支持索引操作符的容器对象,一个是索引参数;函数验证用户身份,然后返回值这个容器对象在该索引值处的元素,要求其返回类型与容器对象索引操作返回值类型一样。此时就可以使用decltype,先看一下下面的实现:

// C++11
template <typename Container, typename Index>
auto authAndAccesss(Container& c, Index i)
    ->decltype(c[i])
{
    // 验证用户
    // ...
    return c[i];
}

这种实现使用了C++11中的“拖尾返回类型”:函数返回类型要在参数列表之后声明(使用->分割),使用“拖尾返回类型”,我们可以利用函数的参数来推断返回类型:上面就用了c[i]来推断返回值类型。还有注意的是上面的auto没有推断功能,仅仅是指明使用了“拖尾返回类型”。大家可能会想,为什么不把decltype(c[i])直接替换auto的位置?这样是不行的,因为此时函数参数还没有被创建!

但是C++14允许你省略掉拖尾部分:

// C++14
template <typename Container, typename Index>
auto authAndAccesss(Container& c, Index i)
{
    // 验证用户
    // ...
    return c[i];
}

此时仅留下auto,此时auto真正用于返回值类型推断:即根据返回值表达式c[i]来推断返回类型。此时,问题来了。我们知道容器的索引操作返回的大部分是引用类型,但是auto推导类型时,会忽略c[i]的引用属性,那么函数返回值是一个右值(尽管我们希望它仍然是左值),下面的代码就存在问题:

vector<int> v{1, 2, 3, 4, 5};

authAndAccess(v, 2) = 10;   // 无法编译:无法对右值赋值

我们知道decltype(c[i])是可以正常推断的,所以,为了解决上面的问题,C++14引入了decltype(auto)标识符:auto说明类型需要推断,decltype说明类型推断要使用decltype规则。所以,再次修改代码:

template <typename Container, typename Index>
decltype(auto) authAndAccesss(Container& c, Index i)
{
    // 验证用户
    // ...
    return c[i];
}

此时,如果c[i]的返回类型是引用类型,那么函数的返回类型也是引用类型。其实decltype(auto)还可以用于声明变量:

int x = 10;
const int& cx = x;

auto y = cw;   // 类型是int
decltype(auto) z = cw;  // 类型是const int&

对于修改版本的authAndAccesss,一个问题你只能传递左值引用的容器对象,并且该对象不能是常量左值引用。但是我们想既可以传递左值又可以传递右值,这个时候你需要使用&&通用引用:

template <typename Container, typename Index>
decltype(auto) authAndAccesss(Container&& c, Index i)
{
    // 验证用户
    // ...
    return std::forward(c)[i];
}

其中std::forward函数是专门处理通用引用类型参数的,基本上就是传入的参数是右值,转化的还是右值引用,如果是左值,那么转化的是左值引用,具体可以参考这里


终于完了,本教程算是《Effective Modern C++》第一章的学习笔记,当然加入了自己的理解,有任何问题可以参考原书。

猜你喜欢

转载自blog.csdn.net/xiaohu2022/article/details/69935605