美文网首页cpp“菜鸟”程序员学习笔记我爱编程
深入理解C++11 核心编程(四)--新手易学,老兵易用

深入理解C++11 核心编程(四)--新手易学,老兵易用

作者: 认真学计算机 | 来源:发表于2016-10-14 17:01 被阅读178次

    对于连续两个右尖括号>, 那么它们之间需要一个空格来进行分隔,以避免发生编译时的错误。

    #include<iostream> 
    using namespace std;
    template<int i>class X{};
    template<class T>class Y{};
    Y<X<1>>x1;
    Y<X<2>>x2;
    
    int main(){
        return 0;
    
    }
    

    C++98编译器会把>>优先解析为右移符号。除了嵌套的模板标识,在使用形如static_cast、dynamic_cast、reinterpret_cast, 或者const_cast表达式进行转换的时候,我们也常会遇到相同的情况。

    const vector<int> v=static_cast<vector<int>>(v) 
    

    C++11标准要求编译器智能地去判断在哪些情况下>>不是右移符号。

    auto类型推导
    在C/C++程序员的眼中,每个变量使用前必须定义几乎是天经地义的事,这样通常被视为编程语言中的"静态类型"的体现。而对于如Python、Perl、JavaScript等语言中变量不需要声明,而几乎"拿来就用"的变量使用方式,则被视为是编程语言中"动态类型"的体现。静态类型和动态类型的主要区别在于对变量进行类型检查的时间点。对于所谓的静态类型,类型检查主要发生在编译阶段;而对于动态类型,类型检查主要发生在运行阶段。形如Python等语言中变量"拿来就用"的特性,则需要归功于一个技术,即类型推导。

    C++11中类型推导的实现的方式之一就是重定义了auto关键字。另外一个现实是decltype。

    #include<iostream>
    using namespace std;
    int main(){
    auto name="world.\n";
    cout<<"hello,"<<name;
    }
    

    这里我们使用了auto关键字来要求编译器对变量name的类型进行自动推导。这里编译器根据它的初始化表达式的类型,推导出name的类型为char*。

    auto声明的变量必须被初始化,以使编译器能够从其初始化表达式中推导出其类型。从这个意义上来讲,auto并非一种"类型"声明,而是一个类型声明时的"占位符",编译器在编译时间会将auto替代为变量实际的类型。

    auto的优势

    直观地,auto推导的一个最大优势就是在拥有初始化表达式的复杂类型变量声明时简化代码。由于C++的发展,声明变量类型也变得越来越复杂,很多时候,名字空间,模板成为了类型的一部分,导致程序员在使用库的时候如履薄冰。

    #include<string>
    #include<vector>
    
    voidvoid loopover(std:: vector<std:: string>&vs){
        std:: vector<std:: string>:: iterator i =vs.begin(); //想要使用iterator,往往需要书写大量代码
        for(; i<vs.end();i++){
            //一些代码 
        } 
    }
    

    在不使用using namespace std的情况下,想对一个vector数组进行循环。可以看到,当想定义个迭代器i的时候,我们必须写出std:: vector<std:: string>:: iterator 这样长的类型声明。而使用auto的话,代码会的可读性可以成倍增长。

    #include<string>
    #include<vector>
    
    voidvoid loopover(std:: vector<std:: string>&vs){
        //std:: vector<std:: string>:: iterator i =vs.begin(); //想要使用iterator,往往需要书写大量代码
        for(auto i=vs.begin(); i<vs.end() ; i++){
            //一些代码 
        } 
    }
    

    使用了auto,程序员甚至可以将i的声明放入for循环中,i的类型将由表达式vs.begin() 推导出。事实上,在C++11中,由于auto的存在,使得STL将会变得更加容易,写出的代码也会更加清晰可读。

    auto的第二个优势则在于可以免除程序员在一些类型声明时的麻烦,或者避免一些在类型声明时的错误。事实上,在C/C++中,存在着很多隐式或者用户自定义的类型转换规则(比如整型与字符型进行加法运算后,表达式返回的是整型,这是一条隐式规则)。这个时候,auto就有用武之地了。

    #include<string>
    #include<vector>
    using namespace std;
    
    class PI{
        public:
            double operator*(float v){
            return(double) val*v;//这里精度被扩展了
            }
            const float val=3.1415927f;
    };
    int main(){
        float radius=1.7e10;
        PI pi;
        auto circumference=2*(pi*radius);
    }
    

    定义了float型的变量radius(半径) 以及一个自定义类型PI变量pi(π值), 在计算圆周长的时候,使用了auto类型来定义变量circumference。这里,PI在于float类型数据相乘时,其返回值为double。而PI的定义可能是在其他的地方(头文件里),main函数的程序员可能不知道PI的作者为了避免数据上溢或者精度降低而返回了double类型的浮点数。因此main函数程序员如果使用float类型声明circumference, 就可能享受不了PI作者细心设计带来的好处。反之,将circumference声明为auto,就没有问题。因为编译器已经自动地做出了最好的选择。

    但是,auto并不能解决所有的精度问题:(这跟一些动态类型语言中数据会自动进行扩展的特性还是不一样的)

    #include <iostream>
    using namespace std;
    int main(){
        unsigned int a=4294967295;//最大的unsigned Int值 
        unsigned int b=1;
        auto c=a+b; //c的类型依然是unsigned int 
        cout<<"a="<<a<<endl;//a=4294967295
        cout<<"b="<<b<<endl;//b=1
        cout<<"a+b="<<c<<endl;//a+b=0 
        return 0;
    }
    

    auto的第三个优点就是其"自适应"性能够在一定程度上支持泛型的编程。

    在PI那个代码中,如果将operator*返回值变成了long double,此时,main函数并不需要修改,因为auto会"自适应"新的类型。对于不同的平台上的代码维护,auto也会带来一些"泛型"的好处。以strlen函数为例,在32位的编译环境下,strlen返回的为一个4字节的整型,而在64位的编译环境下,strlen会返回一个8字节的整型。虽然系统库<cstring>为其提供了size_t类型来支持多平台间的代码共享支持,但是使用auto关键字我们同样可以达到代码跨平台的效果。

    auto var=strlen("hello world!").
    

    由于size_t的适用范围往往局限于<cstring>中定义的函数,auto的适用范围明显更加广泛。
    当auto应用于模板的定义中,其"自适应"行会得到更加充分的体现。

    #include <iostream>
    using namespace std;
    template<typename T1,typename T2>
    double Sum(T1&t1, T2&t2){
        auto s=t1+t2;//s的类型会在模板实例化时被推导出来
        return s; 
    }
    int main(){
        int a=3;
        long b=5;
        float c=1.0f,d=2.3f;
        auto e=Sum<int,long>(a,b);//s的类型被推导为long
        auto f=Sum<float,float>(c,d);//s的类型被推导为float 
    }
    

    Sum模板函数接受两个参数。由于类型T1、T2要在模板实例化时才能确定,所以在Sum中将变量s的类型声明为auto的。在函数main中我们将模板实例化时,Sum<int,long>中的s变量会被推导为long类型,而Sum<float,float>中的s变量则会被推导为float。可以看到,auto与模板一起使用时,其"自适应"特性能够加强C++中"泛型"的能力。不过在这个例子中,由于总是返回double类型的数据,所以Sum模板函数的适用范围还是受到了一定的限制。另外,应用auto还会在一些情况下取得意想不到的好效果。

    #include <iostream>
    using namespace std;
    #define Max1(a,b)((a)>(b))?(a):(b)
    #define Max2(a,b)({\
    auto _a=(a);\
    auto _b=(b);\
    (_a>_b)? _a:_b;})
    int main(){
        int m1=Max1(1*2*3*4,5+6+7+8);
        int m2=Max2(1*2*3*4,5+6+7+8); 
    }
    

    (#define 里的"" 意味着就是可以把多行一起处理。)
    我们定义了两种类型的宏Max1和Max2。两者作用相同,都是求a和b中较大者并返回。前者采用传统的三元运算符表达式,这可能会带来一定的性能问题。因为a或者b在三元运算符中都出现了两次,那么无论是取a还是取b,其中之一都会被运算两次。而在Max2中,我们将a和b都先算出来,再使用三元运算符进行比较。

    在传统的C++98标准中,由于a和b的类型无法获得,所以我们无法定义Max2这样的高性能宏。而新的标准中的auto则提供了这种可行性。

    auto的使用细则

    auto类型指示符与指针和引用之间的关系。在C++11中,auto可以与指针和引用结合起来使用,使用的效果基本上会符合C/C++程序员的想象。

    #include <iostream>
    using namespace std;
    
    int x;
    int *y=&x; //把x的地址给y
    double foo();
    int &bar();
    auto *a=&x;//int* 
    auto &b=x;//int&
    auto c=y;//int*
    auto *d=y;//int*
    auto *e=&foo();//编译失败,指针不能指向临时变量
    auto &f=foo();//编译失败,nonconst的左值引用不能和一个临时变量绑定                       
    auto g=bar(); //int
    auto &h=bar(); //int&
    

    (问题:指针和引用的区别)
    指针:指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元;而引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。

    int a=1; int *p=&a;
    int a=1; int &b=a;
    

    上面定义了一个整型变量和一个指针变量p, 该指针变量指向a的存储单元,即p的值是a存储单元的地址。而下面两句定义了一个整型变量a和这个整型a的引用b,事实上,a和b是同一个东西,在内存占有同一个存储单元。

    • 可以有const指针,但是没有const引用;
    • 指针可以有多级,但是引用只能是一级(int **p, 合法而 int &&a是不合法的)
    • 指针的值可以为空,但是引用的值不能为NULL, 并且引用在定义的时候必须初始化;
    • 指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。
    • "sizeof引用"得到的是所指向的变量(对象)的大小,而"sizeof指针"得到的是指针本身的大小;
    • 指针和引用的自增(++)运算意义不一样;

    变量a、c、d的类型指针类型,且都指向变量x。实际上对于a、c、d三个变量而言,声明其为auto* 或 auto 并没有区别。而如果要使得auto声明的变量是另一个变量的引用,则必须使用auto&,如图本例中的变量b和h一样。

    其次,auto与volatile和const之间也存在着一些项目的联系。volatile和const代表了变量的两种不同的属性:易失的和常量的。在C++标准中,它们常常被一起叫做cv限制符。鉴于cv限制符的特殊性,C++11标准规定auto可以与cv限制符一起使用,不过声明为auto的变量并不能从其初始化表达式中"带走" cv限制符。

    #include <iostream>
    using namespace std;
    
    int main(){
        double foo();
        float *bar();
        const auto a=foo();//a:const double
        const auto &b=foo(); //b:const double&
        volatile auto*c=bar(); //c:volatile float*
        auto d=a;//d:double
        auto &e = a;//e:const double& 
        auto f=c;//f:float*
        volatile auto&g=c;//g:volatile float*&
        return 0;
    }
    

    我们可以通过非cv限制的类型初始化一个cv限制的类型,如变量a、b、c所示。不过通过auto声明的变量d、f却无法带走a和f的常量性或者易失性。而引用,比如,声明为引用的变量e、g都保持了其引用的对象相同的属性(事实上,指针也是一样的)。

    此外,跟其他的变量指示符一样,同一个赋值语句中,auto可以用来声明多个变量的类型,不过这些变量的类型必须相同。如果这些变量的类型不相同,编译器则会报错。事实上,用auto来声明多个变量类型时,只有第一个变量用于auto的类型推导,然后推导出来的数据类型被作用于其他的变量。所以,不允许这些变量的类型不相同。

    auto x=1,y=2;//x和y的类型均为int
    //m是一个指向const int类型变量的指针,n是一个int类型的变量
    const auto*m=&x,n=1;
    auto i=1,j=3.14f;//编译失败
    auto o=1,&p=o,*q=&p;//从左向右推导
    

    对于变量m和n,这里似乎是auto被替换成了int,所以m是一个int*指针类型,而n只是一个int类型。同样的情况也发生在变量o、p、q上,这里o是一个类型为int的变量,p是o的引用,而q是p的指针。auto的类型推导按照从左往右,且类似于字面替换的方式进行。事实上,标准里称auto是一个将要推导出的类型的"占位符"。这样的规则无疑是直观而让人略感意外的。包括C++11新引入的初始化列表,以及new,都可以使用auto关键字。

    #include<initializer_list>
    auto x=1;
    auto x1(1);
    auto y{1};//使用初始化列表的auto
    auto z=new auto(1);//可以用于new 
    

    不过auto也不是万能的,受限于语法的二义性,或者是实现的困难性,auto往往也会有上的限制。例外都写在了下面代码中:

    #include<vector>
    using namespace std;
    void fun(auto x=1){} //1:auto函数参数,无法通过编译
    struct str{
    auto var=10;//2:auto非静态成员变量,无法通过编译
    };
    int main(){
    char x[3];
    auto y=x;
    auto z[3]=x;//3:auto数组,无法通过编译
    //4:auto模板参数(实例化时),无法通过编译
    vector<auto> v={1};
    }
    

    (1) 对于函数fun来说,auto不能是其形参类型。可能感觉对于fun来说,由于其有默认参数,所以应该推导fun形参x的类型为int型。但事实却无法符合大家的想象。因为auto是不能做形参的类型的。如果程序员需要泛型的参数,还是需要求助于模板。

    (2) 对于结构体来说,非静态成员变量的类型不能是auto的。同样的,由于var定义了初始值,读者可能认为auto可以推导str成员var的类型为int的。但编译器阻止了auto对结构体中的非静态成员进行推导,即使成员拥有初始值。

    (3) 声明auto数组。我们可以看到,main中的x是一个数组,y的类型是可以推导的,而声明auto z[3]这样的数组同样会被编译器禁止。

    (4) 在实例化模板的时候使用auto作为模板参数,如果main中我们声明的vector<auto> v。虽然读者可能认为这里一眼而知是int类型,但编译器却阻止了编译。

    为了避免和C++98中auto的含义发生混淆,C++11只保留auto作为类型指示符的用法,以下的语句在C++98和C语言中都是合法的。但是在C++11中,编译器会报错。

    auto int i=1;
    

    auto只是C++11中类型推导体现的一部分。其余的,则会在decltype中得到体现。

    decltype

    typeid 与decltype

    C++98对动态类型支持就是C++中的运行时类型识别(RTTI)
    RTTI的机制是为每个类型产生一个type_info类型的数据,程序员可以在程序中使用typeid随时查询一个变量的类型,typeid就会返回变量相应的type_info数据。而type_info的name成员函数可以返回类型的名字。而在C++11中,又增加了hash_code这个成员函数,返回该类型唯一的哈希值,以供程序员对变量的类型随时进行比较。

    #include<iostream>
    #include<typeinfo>
    using namespace std;
    class White{};
    class Black{};
    int main(){
        White a;
        Black b;
        cout<<typeid(a).name()<<endl;//5White
        cout<<typeid(b).name()<<endl;//5Black
        White c;
        bool a_b_sametype=(typeid(a).hash_code()==typeid(b).hash_code());
        bool a_c_sametype=(typeid(a).hash_code()==typeid(c).hash_code());
        cout<<"Same type?" <<endl;
        cout<<"A and B?"<<(int)a_b_sametype<<endl;
        cout<<"A and C?"<<(int)a_c_sametype<<endl; 
    }
    

    这里我们定义了两个不同的类型White和Black,以及其类型的变量a和b。此外我们使用typeid返回类型的type_info,并分别引用name打印类型的名字(5这样的前缀是g++这类编译器输出的名字,其他编译器可能会打印出其他的名字,这个标准并没有明确规定),应用hash_code进行类型的比较。在RTTI的支持下,程序员可以在一定程度上了解程序中类型的信息(相比于is_same模板函数的成员类型value在编译时得到信息,hash_code是运行时得到的信息)。

    除了typeid外,RTTI还包括C++中的dynamic_cast等特性。但是,RTTI会带来一些运行时的开销,所以编译器会让用户选择性地关闭该特性(比如XL C/C++编译器得-qnortti, GCC的选项-fno-rttion, 或者微软编译器选项/GR-)。而且很多时候,运行时才确定出类型对于程序员来说为时过晚,程序员更多需要的是在编译时期确定出类型()标准库中非常常见。而通常程序员是要使用这样的类型而不是识别该类型,因此RTTI无法满足需求。

    在C++的发展中,类型推导是随着模板和泛型编程的广泛使用而引入的。因为在泛型编程中,类型成了未知数。例如:

    #include <iostream>
    using namespace std;
    template<typename T1,typename T2>
    double Sum(T1&t1, T2&t2){
        auto s=t1+t2;//s的类型会在模板实例化时被推导出来
        return s; 
    }
    

    其中,模板函数Sum的参数的t1和t2类型都是不确定的,因此t1+t2这个表达式将返回的类型也就不可由Sum的编写者确定。无疑,这样的状况会限制模板的使用范围和编写方式。 最好的解决办法就是让编译器辅助进行类型推导。

    与auto类似地,decltype也能进行类型推导,不过两者的使用方式却又一定的区别。

    #include<iostream>
    #include<typeinfo>
    using namespace std;
    int main(){
        int i;
        decltype(i)j=0;
        cout<<typeid(j).name() <<endl;//打印出"i",g++表示int
        float a;
        double b;
        decltype(a+b)c;
        cout<<typeid(c).name() <<endl;//打印出"d",g++表示double 
    }
    

    变量j的类型由decltype(i)进行声明,表示j的类型跟i相同(或者准确地说,跟i这个表达式返回得类型相同)。而c的类型则跟(a+b)这个表达式返回的类型相同。而由于a+b加法表达式返回的类型为double(a会被扩展为double类型与b相加),所以c的类型被decltype推导为double。

    decltype的类型推导并不是像auto一样是从 变量声明的初始化表达式 获得变量的类型,decltype总是以一个普通的表达式为参数,返回该表达式的类型。而与auto相同的是,作为
    一个类型指示符,decltype可以将获得的类型来定义另外一个变量。与auto相同的是,作为一个类型指示符,decltype可以将获得的类型来定义另外一个变量。与auto相同,decltype类型推导也是在编译时进行的。

    使用decltype推导类型是非常常见的事情。比较典型的就是decltype与typdef/using的合用。在C++11的头文件中,我们常常能看以下这样的代码:

    using size_t=decltype(sizeof(0));
    using ptrdiff_t=decltype((int*)0-(int*)0);
    using nullptr_t=decltype(nullptr);
    

    这里的size_t以及ptrdiff_t还有nullptr_t都是由decltype推导出的类型。在一些常量、基本类型、运算符、操作符都已经被定义好的情况下,类型可以按照规则被推导出来。而使用using, 都可以为这些类型取名。这就颠覆了之前类型拓展需要将扩展类型"映射"到基本类型的常规做法。

    此外,decltype在某些场景下,可以极大地增加代码的可读性。

    #include<iostream>
    #include<vector>
    using namespace std;
    int main(){
        vector<int> vec;
        typedef decltype(vec.begin()) vectype;
        for(vectype i=vec.begin();i<vec.end();i++){
            //做一些事情 
        }
        for(decltype(vec)::iterator i=vec.begin();i<vec.end();i++){
            //做一些事情 
        } 
    }
    //需要默写
    

    (C++的 vector容器 和typedef
    1、vector容器基本操作
    (1) 头文件#include<vector>,
    (2) 创建vector对象, vector<int> vec;
    (3) 尾部插入数字:vec.push_back(a);
    (4) 使用下标访问元素,cout<<vec[0]<<endl;下标是从0开始的。
    (5) 使用迭代器访问元素.

    vector<int>:: iterator it;
    for(it=vec.begin();it!=vec.end();it++)
         cout<<*it<<endl;
    

    (6) 插入元素: vec.insert(vec.begin()+i,a);在第i+1个元素前面插入a; (7) 删除元素: vec.erase(vec.begin()+2);删除第3个元素
    vec.erase(vec.begin()+i,vec.end()+j);删除区间[i,j-1];区间从0开始
    (8) 向量大小:vec.size();
    (9) 清空:vec.clear();

    2、typedef用法小结
    A :定义一种类型的别名,而不只是简单的宏替换。可以用作同时声明指针型的多个对象。比如:
    char* pa,pb 这多数不符合我们的意图,它只声明了一个指向字符变量的指针,和一个字符变量。
    但用如下方法可以:

    typedef char* PCHAR; //一般用大写
    PCHAR pa,pb;//可行,同时声明了两个指向字符变量的指针。
    //虽然:char *pa,*pb;也可行,但相对来说没有用typedef的形式直观,尤其在需要大量指针的地方,typedef的方式更省事。
    

    B :用typedef来定义与平台无关的类型。
    比如定义一个叫 REAL 的浮点类型,在目标平台一上,让它表示最高精度的类型为:
    typedef long double REAL;
    在不支持long double 的平台二上,改为:
    typedef double REAL;
    在连double都不支持的平台三上,改为:typedef float REAL;
    也就是说,当跨平台时,只要改下typedef本身就行,不用对其他源码做任何修改。
    另外,因为typedef是定义了一种类型的新别名,不是简单的字符串替换,所以它3比宏来得更加稳健(虽然用宏有时可以完成以上的用途)。


    我们定义了vector的iterator的类型。这个类型还可以再main函数中重用。当我们遇到一些具有复杂类型的变量或表达式时,就可以利用decltype和typedef/using的组合来将其转化为一个简单的表达式,这样在以后的代码写作中可以提高可读性和可维护性。此外我们可以看到decltype(vec)::iterator这样的灵活用法,这看起来和auto非常类似,也类似于是一种"占位符"式的替代。在C++11中,我们有时会遇到匿名的类型,而拥有了decltype这个利器之后,重用匿名类型也并非难事。

    #include<iostream> 
    using namespace std;
    
    enum class{K1,K2,K3} anon_e;//匿名的强类型枚举
    
    union{
        decltype(anon_e) key;
        char*name;
    }anon_u; // 匿名的Union联合体
    
    struct{
        int d;
        decltype(anon_u) id;
    }anon_s[100];//匿名的struct数组
    
    int main() {
        decltype(anon_s) as;
        as[0].id.key=decltype(anon_e)::K1;//引用匿名强类型枚举中的值 
    }
    

    (匿名枚举的功能等价于静态常成员变量)
    (问题:什么是匿名的强类型枚举)
    匿名的强类型枚举anon_e、匿名的联合体anon_u,以及匿名的结构体数组anon_s。可以看到,只要通过匿名类型的变量名anon_e、anon_u,以及anon_s, decltype可以推导其类型并且进行重用。

    有了decltype,我们可以适当扩大模板泛型的能力。如果稍微改变下函数模板的接口,就可以将该目标适用于更大的范围。

    #include<iostream> 
    using namespace std;
    
    template<typename T1, typename T2> 
    void Sum(T1& t1, T2&t2, decltype(t1+t2)&s){
        s=t1+t2;
    }
    int main(){
        int a=3;
        long b=5;
        float c=1.0f, d=2.3f;
        long e;
        float f;
        Sum(a,b,e); //s的类型被推导为long 
        Sum(c,d,f); //s的类型被推导为float 
    }
    

    代码Sum函数模板增加了类型为decltype(t1+t2)的s作为参数,而函数本身不返回任何值。这样一来,Sum的适用性增加,返回的类型是根据t1+t2推导而来的类型。

    这里最大的问题,在于返回值得类型必须一开始就被指定,程序员必须清楚Sum运算的结果使用什么样的类型来存储是合适的(这里指的是long e 和float f)对于一些泛型编程中依然不能满足需求。解决的方法是结合decltype和auto关键字,使用追踪返回类型的函数定义来使得编译器对函数返回值进行推导。

    某些情况下,模板库的使用人员可能认为一些自然而简单的数据结构,比如数组,也是可以被模板类所包含的。不过很明显,如果t1+t2是两个数组,t1+t2不会是合法的表达式。为了避免不必要的误解,模板库的开发人员应该为这些特殊情况考虑其他的版本。

    #include<iostream> 
    using namespace std;
    
    template<typename T1, typename T2> 
    void Sum(T1& t1, T2&t2, decltype(t1+t2)&s){
        s=t1+t2;
    }
    
    void Sum(int a[], int b[], int c[]){
        //数组版本 
    }
    
    int main(){
        int a[5], b[5], c[5];
        Sum(a,b,c);//选择数组版本
        int d,e,f;
        Sum(d,e,f);//选择模板的实例化版本 
    }
    //默写
    

    由于声明了数组版本Sum,编译器在编译Sum(a,b,c)的时候,会优先选择数组版本,而编译Sum(d,e,f)的时候,依然会对应到模板的实例化版本。这就能够保证Sum模板函数最大的可能性。

    #include<map>
    using namespace std;
    int hash(char*);
    map<char*, decltype(hash)>dict_key;//无法通过编译
    map<char*, decltype(hash(nullptr))> dict_key1;
    

    标准库中的map模板。因为该map是为了存储字符串以及与其对应的哈希值的,因此我们可以decltype(hash(nullptr)) 来确定哈希值得类型。这样的定义非常直观。但是需要注意的是,decltype只能接受表达式做参数,像函数名做参数的表达式decltype(hash)是无法通过编译的。

    (问题:标准库的map函数介绍)
    事实上,decltype在C++11的标准库中也有一些应用,一些标准库的实现也会依赖于decltype的类型推导。一个典型的例子是基于decltype的模板类result_of,其作用是推导函数的返回类型。我们可以看一下应用的实例。

    #include<type_traits>
    using namespace std;
    typedef double(*func)(); //double型的函数指针。
    int main(){
    result_of<func()>:: type f;//由func()推导其结果类型
    }
    

    (typedef 用途四:为复杂的声明定义一个新的简单的别名。方法是:在原来的声明里逐步用别名替换一部分复杂声明,如此循环,把带变量名的部分留到最后替换,得到的就是原声明的最简化版)
    从变量名看起,先往右,再往左,碰到一个圆括号就调转阅读的方向;括号内分析完就跳出括号,还是按先右后左的顺序,如此循环,直到整个声明分析完。举例:
    int (func)(int p);
    首先找到变量名func,外面有一对圆括号,而且左边是一个
    号,这说明func是一个指针;然后跳出这个圆括号,先看右边,又遇到圆括号,这说明(
    func)是一个函数,所以func是一个指向这类函数的指针,即函数指针,这类函数具有int类型的形参,返回值类型是int。
    int (
    func[5])(int );
    func右边是一个[]运算符,说明func是具有5个元素的数组;func的左边有一个
    ,说明func的元素是指针(注意这里的不是修饰func,而是修饰func[5]的,原因是[]运算符优先级比高,func先跟[]结合)。跳出这个括号,看右边,又遇到圆括号,说明func数组的元素是函数类型的指针,它指向的函数具有int*类型的形参,返回值类型为int。

    也可以记住2个模式:
    type ()(....)函数指针
    type (
    )[]数组指针


    这里的f 的类型最终被推导为double,而result_of并没有真正调用func() 这个函数,这一切都是因为底层的实现使用了decltype。result_of的一个可能的实现方式如下:

    #include<iostream> 
    using namespace std;
    
    template<class> 
    struct result_of;
    template<class F,class... ArgTypes>
    struct result_of<F(ArgTypes...)>
    {
        typedef decltype(
        std:: declval<F>()(std:: declval<ArgTypes>()...)    
        )type;
    };
    

    这里标准库将decltype作用于函数调用上(使用了变长函数模板),并将函数调用表达式返回的类型typedef为一个名为type的类型。这样一来,result_of<func()>:: type就会被decltype推导为double.

    decltype推导四规则

    作为auto的伙伴,decltype在C++11中非常重要,不过和auto一样,由于应用广泛,所以使用decltype也有很多的细则条款需要注意。很多时候,用户会发现decltype的行为不如预期,那么下面的这些规则将解释为什么这些行为不如预期。

    int i;
    decltype(i) a;//a:int
    decltype((i)) b;//b:int& ,无法编译通过
    

    我们在编译的时候,发现decltype((i)) b; 这样的语句编译不过。编译器会提示b是一个引用,但没有被赋初值。

    当程序员用decltype(e)来获取类型时,编译器将依序判断以下四规则:

    1、如果e是一个没有带括号的标记符表达式或者类成员访问表达式,那么decltype(e)就是e所命名的实体的类型。此外,如果e是一个被重载的函数,则会导致编译时错误。

    2、否则,假设e的类型是T,如果e是一个将亡值,那么decltype(e)为T&&。

    3、否则,假设e的类型是T,如果a是一个左值,则decltype(e)为T&。

    4、否则,假设e的类型是T,则decltype(e)为T。

    标记符表达式:基本上,所有除去关键字、字面量等编译器需要使用的标记之外的程序员自定义的标记都可以是标记符。而单个标记符对应的表达式就是标记符表达式。

    int arr[4];
    

    那么arr是一个标记符表达式,而arr[3]+0, arr[3]等,则都不是标记符表达式。

    decltype(i)a,使用了推导规则1--因为i是一个标记符表达式,所以类型被推导为int。而decltype((i)) b;中,由于(i)不是一个标记符表达式,但却是一个左值表达式(可以有具体的地址),因此,按照decltype推导规则3,其类型应该是一个int的引用。
    {回顾下:左、右值以及左值引用和右值引用:}
    不过C++中,有一个被广泛认同的说法,那就是可以取值的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。
    【在C++11中,右值引用就是对一个右值进行引用的类型。事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。
    右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。(也就是说需要找一个寄主)】

    
    
    #include<vector>
    #include<iostream>
    using namespace std;
    
    int i=4;
    int arr[5]={0};
    int *ptr=arr;
    struct S{
        double d;
    }s;
    void Overloaded(int);
    void Overloaded(char);//重载的函数
    int&& RvalRef();
    const bool Func(int);
    
    //规则1:单个标记符表达式以及访问类成员,推导为本类型
    decltype(arr) var1;//int[5], 标记符表达式 
    decltype(ptr) var2;//int*, 标记符表达式
    decltype(s.d) var4;//double, 成员访问表达式
    //decltype(Overloaded) var5;//无法通过编译,是个重载的函数
    
    //规则2:将亡值,推导为类型的右值引用
    decltype(RvalRef()) var6=1; //int&& 而对于右值引用而言,一定要赋初始化。
    
    //规则3:左值,推导为类型的引用
    decltype(true?i:i)  var7=i;//int&,三元运算符,这里返回一个i的左值
    decltype((i)) var8=i;//int&,带圆括号的左值
    decltype(++i) var9=i;//int&,++i返回i的左值
    decltype(arr[3]) var10=i;//int&[] 操作返回左值
    decltype(*ptr) var11=i;//int&* 操作返回左值
    decltype("lval") var12="lval";//const char(&)[9], 字符串字面常量为左值
    
    //规则4:以上都不是,推导为本类型
    decltype(1) var13;//int,除字符串外字面常量为右值
    decltype(i++) var14;//int,i++返回右值
    decltype((Func(1))) var15;//const bool,圆括号可以忽略。 
    
    int main(){
        return 0;
    }
    

    通过decltype(++i)和decltype(i++)可以看出编译器所识别的不同的类型。
    可以看到,规则1不但适用于基本数据类型,还适用于指针、数组、结构体,甚至函数类型的推导,事实上,规则1在decltype类型推导中运用的最为广泛。

    规则3其实是一个左值规则。decltype的参数不是标志表达式或者类成员访问表达式,且参数都为左值,推导出的类型均为左值引用。规则4则是适用于以上都不适用者。

    引起麻烦的只能是规则3带来的左值引用的推导。一个简单的能够让编译器提示的方法是:如果使用decltype定义变量,那么先声明这个变量,再在其他语句里对其进行初始化。这样一来,由于左值引用总是需要初始化的,编译器会报错提示。另外一些时候,C++11标准库中添加的模板类is_lvalue_reference 或者 is_rvalue_reference,可以帮助程序员进行一些推导结果的识别。

    #include<type_traits>
    #include<iostream>
    using namespace std;
    
    int i=4;
    int arr[5]={0};
    int *ptr=arr;
    int *ptr=arr;
    int&&RvalRef();
    int main(){
        cout<<is_rvalue_reference<decltype(RvalRef())>:: value<<endl;//1
        cout<<is_lvalue_reference<decltype(true?i:i)>:: value<<endl;//1
        cout<<is_lvalue_reference<decltype((i))>:: value<<endl;//1
        cout<<is_lvalue_reference<decltype(++i)>:: value<<endl;//1
        cout<<is_lvalue_reference<decltype(arr[3])>:: value<<endl;//1
        cout<<is_lvalue_reference<decltype(*ptr)>:: value<<endl;//1
        cout<<is_lvalue_reference<decltype("lval")>:: value<<endl;//1
        cout<<is_lvalue_reference<decltype(i++)>:: value<<endl;//0
        cout<<is_rvalue_reference<decltype(i++)>:: value<<endl;//0
    }
    

    如果程序员在书写中不是非常确定decltype是否将类型推导为左值引用,也可以通过这样的小实验来辅助确定。

    cv限制符的继承与冗余的符合

    与auto类型推导时不能"带走" cv限制符不同,decltype是能够"带走"表达式的cv限制符的。不过,如果对象的定义中有const或volatile限制符,使用decltype进行推导时,其成员不会继承const或volatile限制符。

    #include <type_traits>
    #include <iostream>
    using namespace std;
    const int ic=0;
    volatile int iv;
    struct S{int i;};
    
    const S a={0};//结构体变量a
    volatile S b;//结构体变量b
    volatile S *p=&b;//结构体指针p
    
    int main(){
    cout<< is_const<decltype(ic)>:: value<<endl;//1
    cout<< is_volatile<decltype(iv)>:: value<<endl;//1
    cout<< is_const<decltype(a)>:: value<<endl;//1
    cout<< is_volatile<decltype(b)>:: value<<endl;//1
    cout<< is_const<decltype(a.i)>:: value<<endl;//0,成员不是const
    cout<< is_volatile<decltype(p->i)>:: value<<endl;//0,成员不是volatile 
    }
    

    运用C++库<type_traits>函数里的is_const和is_volatile来查看类型是否是常量或者易失的。可以看到,结构体变量a、b和结构体指针p和cv限制符并没有出现在其成员的decltype类型推导结果中。

    而与auto相同的,decltype从表达式推导出类型后,进行类型定义时,也会允许一些冗余的符号。比如cv限制符以及引用符号&,通常情况下,如果推导出的类型以及有了这些属性,冗余的符号则会被忽略:

    #include <type_traits>
    #include <iostream>
    using namespace std;
    
    int i=1;
    int &j=i;
    int *p=&i;
    const int k=1;
    
    int main(){
    decltype(i) &var1=i;
    decltype(j) &var2=i;//冗余的&,被忽略。 这里依然是 int& 左值引用
    cout<<is_lvalue_reference<decltype(var1)>:: value<<endl;//1,是左值引用
    cout<<is_rvalue_reference<decltype(var2)>:: value<<endl;//0,不是右值引用
    cout<<is_lvalue_reference<decltype(var2)>:: value<<endl;//1,是左值引用
    decltype(p) *var3=&i;//无法通过编译
    decltype(p) *var3=&p;//var3的类型是Int** 
    auto *v3=p;//v3的类型是Int*
    v3=&i;
    const decltype (k) var4=1;//冗余的const,被忽略 
    }
    

    我们定义了类型为decltype(i)& 的变量var1, 以及类型为decltype(j)&的变量var2。由于i的类型为int,所以这里的引用符号保证var1成为一个int&引用类型。而由于j本身就是一个int&的引用类型,所以decltype之后的&成为了冗余符号,会被编译器忽略,因此j的类型依然是int&.

    这里特别需要注意的是decltype(p)*的情况。可以看到,在定义var3变量的时候,由于p的类型是int*,因此var3被定义为了int**类型,这跟auto声明中,*也可以是冗余的不同。在decltype后的*号,并不会被编译器忽略。

    var4中const可以被冗余的声明,但会被编译器忽略,同样的情况也会发生在volatile限制符上。下面的追踪返回类型的函数定义,则将融合auto、decltype,将C++11中的泛型能力提升到更高的水平。

    使用追踪放回类型的函数

    追踪返回类型的函数和普通函数的声明最大的区别在于返回类型的后置。在一般情况下,普通函数的声明方式会明显简单于最终返回类型。比如:

    int func(char* a,int b);
    //比 auto func(char* a,int b) ->int; 好很多
    

    有的时候,追踪返回类型声明的函数也会带来大家一些意外:

    class OuterType{
       struct InnerType{int i;};
       InnerType GetInner(); //返回值是结构体:InnerType。
       InnerType it; //变量是it结构体。
    };
    //可以不写OuterType::InnerType
    auto OuterType::GetInner() ->InnerType{
      return it;
    }
    // 这里不太明白,为什么这里需要这样写;
    

    InnerType 不必写明其作用域。

    因为返回类型后置,使模板中的一些类型推导就成为了可能。

    #include <type_traits>
    #include <iostream>
    using namespace std;
    
    template<typename T1,typename T2> 
    auto Sum(const T1&t1,const T2&t2)->decltype(t1+t2){
        return t1+t2;
    }
    
    template<typename T1,typename T2> 
    auto Mul(const T1&t1,const T2&t2)->decltype(t1*t2){
        return t1*t2;
    }
    
    int main(){
        auto a=3;
        auto b=4L;
        auto pi=3.14;
        auto c=Mul(Sum(a,b),pi);
        cout<<c<<endl;//21.98
    }
    

    我们定义了两个模板函数Sum和Mul,它们的参数的类型和返回值都是实例化时决定。而由于main函数中还使用了auto,整个例子中没有看到一个"具体"的类型声明。这一切都要归功于类型推导帮助下的泛型编程。程序员在编写时无需关心任何时段的类型选择,编译器会合理地进行推导,而简单程序的书写也由此得到了极大的简化。

    追踪返回类型的另一个优势是简化函数的定义,提高代码的可读性。

    #include <type_traits>
    #include <iostream>
    using namespace std;
    //有的时候,你会发现这是面试题 
    int (*(*pf())())(){
        return nullptr; 
    }
    
    //auto(*)() ->int(*)() 一个返回函数指针的函数(假设为a函数) 
    //auto pf1() ->auto(*)() ->int(*)() 一个返回a函数的指针的函数
    auto pf1() ->auto(*)() ->int(*)(){
        return nullptr;
    }
    
    int main(){
        cout<<is_same<decltype(pf),decltype(pf1)>:: value<<endl;//1
    }
    

    我们定义了两个类型完全一样的函数pf和pf1。其返回的都是一个函数指针。而该函数指针又指向一个返回函数指针的函数。这一点通过is_same的成员value已经能够确定了。而仔细看一看函数类型的声明,可以发现老式的声明法可读性非常差。而追踪返回类型只需要依照从右向左的方式,就可以将嵌套的声明解析出来。大大提高了嵌套函数这类代码的可读性。

    除此之外,追踪返回类型也被广泛地应用在转发函数中:

    #include <type_traits>
    #include <iostream>
    using namespace std;
    
    double foo(int a){
        return(double)a+0.1;
    }
    int foo(double b){
        return(int)b;
    }
    
    template<class T>
    auto Forward(T t)->decltype(foo(t)){
        return foo(t);
    }
    int main(){
        cout<<Forward(2)<<endl;//2.1
        cout<<Forward(0.5)<<endl;//0
    }
    

    由于使用了追踪返回类型,可以实现参数和返回类型不同时的转发。
    追踪返回类型还可以用于函数指针中,声明方式与追踪返回类型的函数比起来是一样的。

    auto(*fp)()->int; 和 int(*fp)();
    

    函数引用也是一样适用的:

    auto(&fr)()->int; 和 int(&fr)(); 也是等价的
    

    另外,没有返回值得函数也可以被声明为追踪返回类型,程序员只需要将返回类型声明为void即可。

    基于范围的for循环。

    在C++98标准中,如果要遍历一个数组,通常会需要如下代码:

    #include<iostream>
    using namespace std;
    int main(){
    int arr[5]={1,2,3,4,5};
    int *p;
    for(p=arr;p<arr+sizeof(arr)/sizeof(arr[0]); ++p){
    *p*=2;
    }
    for(p=arr;p<arr+sizeof(arr)/sizeof(arr[0]); ++p){
    cout<<*p<<'\t';
    }
    }
    

    用指针p来遍历数组arr中的内容,两个循环分别完成了每个元素自乘以2和打印工作。而C++的标准模板库中,我们还可以找到形如for_each的模板函数。如果我们使用for_each来完成代码的工作,代码将会是:

    #include<algorithm>
    #include<iostream>
    using namespace std;
    int action1(int&e){
        e*=2;
    }
    int action2(int&e){
        cout<<e<<'\t';
    }
    int main(){
        int arr[5]={1,2,3,4,5};
        for_each(arr,arr+sizeof(arr)/sizeof(arr[0]),action1);
        for_each(arr,arr+sizeof(arr)/sizeof(arr[0]),action2);
    }
    

    for_each使用了迭代器的概念,其迭代器就是指针。由于迭代器内含了自增操作的概念,所以,++p操作则可以不写在for_each循环中了。不过都需要告诉循环体其界限的范围,即arr到arr+sizeof(arr)/sizeof(arr[0])之间,才能按元素执行操作。

    很多时候,对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,也是容易犯错误的。而C++11也引入了基于范围的for循环,就很好地解决了这个问题。

    #include<algorithm>
    #include<iostream>
    using namespace std;
    int main(){
        int arr[5]={1,2,3,4,5};
        for(int&e:arr) e*=2;
        for(int&e:arr) cout<<e<<'\t';
    } 
    

    这是个基于范围的for循环的实例。for循环后的括号由冒号":"分为两部分,第一部分是范围内用于迭代的变量,第二部分则表示将被迭代的范围。在上面的例子中,表示的是在数组arr中用迭代器e进行遍历。这样一来,遍历数组和STL容器就非常容易了。

    基于范围的for循环中迭代的变量采用了引用的形式,如果迭代变量的值在循环中不会被改变,那我们完全可以不用引用的方式来做迭代变量。

    for(int e:arr)
    cout<<e<<'\t';
    

    或者用auto类型指示符,循环会更加简练。

    for(auto e:arr)
    cout<<e<<'\t';
    

    基于范围的for循环跟普通循环是一样的,可以用continue语句跳出循环的本次迭代,而用break语句来跳出整个循环。

    能否使用基于范围的for循环,必须依赖于一些条件。首先,就是for循环迭代的范围是可确定的。对于类来说,如果该类有begin和end函数,那么begin和end之间就是for循环迭代的范围。对于数组而言,就是数组的第一个和最后一个元素间的范围。其次,基于范围的for循环还要求迭代的对象实现++和==等操作符。对于标准库中的容器,如string,array,vector,deque,list,queue,map,set等,不会有问题,因为标准库总是保证其容器定义了相关的操作。

    如:

    #include<iostream>
    using namespace std;
    int func(int a[]){
    for(auto e:a)
    cout<<e;
    }
    int main(){
    int arr[] = {1,2,3,4,5};
    func(arr);
    }
    

    上述代码会报错,因为作为参数传递而来的数组a的范围不能确定,因此也就不能使用基于范围循环for循环对其进行迭代的操作。

    如果使用auto来声明迭代的对象的话,那么这个对象不会是迭代器对象。

    #include<vector>
    #include<iostream>
    using namespace std;
    
    int main(){
        vector<int> v={1,2,3,4,5};
        for(auto i=v.begin();i!=v.end();++i)
        cout<<*i<<endl;//i是迭代器对象
        for(auto e:v)
        cout<<e<<endl;//e是解引用后的对象 
    }
    

    (补充内容)C/C++中volatile关键字详解

    用volatile的原因:
    C/C++中的volatile关键字和const对应,用来修饰变量,通常用于建立语言级别的 memory barrier。

    volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法:int volatile vInt; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。

    volatile 指出 i 是随时可能发生变化的,每次使用它的时候必须从i的地址中读取,因而编译器生成的汇编代码会重新从i的地址读取数据放在 b 中。编译器对访问该变量的代码就不再进行优化,而优化做法是,由于编译器发现两次从i读数据的代码之间的代码没有对 i 进行过操作,它会自动把上次读的数据放在 b 中。而不是重新从 i 里面读。这样以来,如果 i是一个寄存器变量或者表示一个端口数据就容易出错,所以说 volatile 可以保证对特殊地址的稳定访问。

    其实不只是“内嵌汇编操纵栈”这种方式属于编译无法识别的变量改变,另外更多的可能是多线程并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程 visible。一般说来,volatile用在如下的几个地方:

    1. 中断服务程序中修改的供其它程序检测的变量需要加volatile;
    2. 多任务环境下各任务间共享的标志应该加volatile;
    3. 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;

    多线程下的volatile

    有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值,如下: 
    

    volatile BOOL bStop = FALSE;
    (1) 在一个线程中:
    while( !bStop ) { ... }
    bStop = FALSE;
    return;
    (2) 在另外一个线程中,要终止上面的线程循环:
    bStop = TRUE;
    while( bStop ); //等待上面的线程终止,如果bStop不使用volatile申明,那么这个循环将是一个死循环,因为bStop已经读取到了寄存器中,寄存器中bStop的值永远不会变成FALSE,加上volatile,程序在执行时,每次均从内存中读出bStop的值,就不会死循环了。
    这个关键字是用来设定某个对象的存储位置在内存中,而不是寄存器中。因为一般的对象编译器可能会将其的拷贝放在寄存器中用以加快指令的执行速度,例如下段代码中:
    ...
    int nMyCounter = 0;
    for(; nMyCounter<100;nMyCounter++)
    {
    ...
    }
    ...
    在此段代码中,nMyCounter的拷贝可能存放到某个寄存器中(循环中,对nMyCounter的测试及操作总是对此寄存器中的值进行),但是另外又有段代码执行了这样的操作:nMyCounter -= 1;这个操作中,对nMyCounter的改变是对内存中的nMyCounter进行操作,于是出现了这样一个现象:nMyCounter的改变不同步。

    相关文章

      网友评论

        本文标题:深入理解C++11 核心编程(四)--新手易学,老兵易用

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