C++类型推断

作者: 小白将 | 来源:发表于2017-04-09 21:32 被阅读360次

    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++》第一章的学习笔记,当然加入了自己的理解,有任何问题可以参考原书。

    相关文章

      网友评论

        本文标题:C++类型推断

        本文链接:https://www.haomeiwen.com/subject/xdrjattx.html