一、复合类型
复合类型是指基于其他类型定义的类型。C++语言有几种复合类型,包括引用和指针。
1、引用
引用并非对象,它只是为一个已存在的对象所起的另外一个名字。
除了以下2种情况,其他所有引用的类型要和与之绑定的对象严格匹配,引用只能绑定在对象上,不能与字面值或某个表达式的计算结果绑定在一起。
引用的类型和绑定的对象不严格匹配的情况:
情况1:在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。允许为一个常量引用绑定非常常量的对象、字面值、一般表达式(此时,引用其实是绑定了一个临时量对象)。
1 #include <iostream> 2 #include <memory> 3 #include <string> 4 #include <vector> 5 6 int main() 7 { 8 int i = 42; 9 const int &r1 = i; 10 const int &r2 = 42.5; 11 const int &r3 = r1 * 2; 12 return 0; 13 }
必须认识到,常量引用仅对引用可参与的操作做出了限定,对于引用本身是不是一个常量未做限定。因为对象也可能是一个非常量,所以允许通过其他途径改变它的值。
情况2:可以把基类的引用绑定到派生类对象上。
2、指针
指针是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象。指针无须在定义时赋初值。和其他内置类型一样,在块级作用域内定义的指针如果没有被初始化,值是未定义的。
如果指针指向了一个对象,则允许使用解引用符(操作符*)来访问该对象,对指针解引用会得出所指的对象,因此如果给解引用的结果赋值,实际上也就是给指针所指的对象赋值。
除了以下2种情况,其他所有指针的类型要和它所指向的对象严格匹配。
指针的类型和对象不严格匹配的情况:
情况1:允许一个指向常量的指针指向一个非常量对象,但是不能通过该指针去改变这个非常量对象的值,该对象的值只能通过其他途径修改。
1 #include <iostream> 2 #include <memory> 3 #include <string> 4 #include <vector> 5 6 int main() 7 { 8 const double pi = 3.14; 9 const double *cptr = π // cptr是一个指向常量的指针 10 double dval = 3.14; 11 cptr = &dval; 12 //*cptr = 2.33; // 错误 13 return 0; 14 }
情况2:可以将基类的指针绑定到派生类对象上。
1)其他指针操作
只要指针拥有一个合法值,就能将它用在条件表达式中。如果指针的值是0,条件取false;任何非0指针对应的条件值都是true。
1 #include <iostream> 2 #include <memory> 3 #include <string> 4 #include <vector> 5 6 int main() 7 { 8 int *p1 = nullptr; 9 if (p1) 10 std::cout << true << std::endl; 11 else 12 std::cout << false << std::endl; 13 14 int x = 0; 15 p1 = &x; 16 if (p1) 17 std::cout << true << std::endl; 18 else 19 std::cout << false << std::endl; 20 21 *p1 = -1; 22 if (p1) 23 std::cout << true << std::endl; 24 else 25 std::cout << false << std::endl; 26 return 0; 27 }
对于两个类型相同的合法指针,可以用相等操作符(==)或不相等操作符(!=)来比较它们,比较的结果是布尔值。如果两个指针存放的地址值相同,则它们相等;反之,它们不相等。这里两个指针存放的地址相同(两个指针相等)有三种可能:它们都为空、都执行同一个对象,或者都指向了同一个对象的下一地址。需要注意的是,一个指针指向某对象,同时另一个指针指向另外对象的下一地址,此时也有可能出现这两个指针相同的情况,即指针相等。
2)void*指针
void*是一种特殊的指针类型,可以存放任意对象的地址。利用void*指针能做的事儿比较有限:拿它和别的指针比较、作为函数的输入或输出,或者赋值给另外一个void*指针。不能直接操作void*指针,因为我们并不知道这个对象到底是什么类型,也就无法确定能在这个对象上做哪些操作。
3、理解复合类型的声明
1)指向指针的指针
一般来说,声明符中修饰符的个数没有限制。当有多个修饰符连写在一起时,按照其逻辑关系详加解释即可。
1 #include <iostream> 2 #include <memory> 3 #include <string> 4 #include <vector> 5 6 int main() 7 { 8 int val = 1024; 9 int *p = &val; // p指向一个int型的数 10 int **pp = &p; // pp指向一个int型的指针 11 std::cout << *p << ", " << **pp << std::endl; 12 return 0; 13 }
2)指向指针的引用
引用本身不是一个对象,因此不能定义指向引用的指针。但指针是对象,所以存在对指针的引用。
1 #include <iostream> 2 #include <memory> 3 #include <string> 4 #include <vector> 5 6 int main() 7 { 8 int i = 42; 9 int *p; // p是一个指针 10 int *&r = p; // r是一个对指针p的引用 11 12 r = &i; // r引用了一个指针,因此该语句时令p指向i 13 *r = 0; // 解引用r得到i,也就是p指向的对象,将i的值改为0 14 std::cout << i << "," << *r << std::endl; 15 return 0; 16 }
注意:面对一条比较复杂的指针或引用的声明语句时,从右向左阅读有助于弄清它的真实含义。
二、const限定符
当以编译时初始化的方式定义一个const对象时,编译器将在编译的过程中把用到该变量的地方都替换成对应的值。为了执行替换,编译器必须知道变量的初始值。如果程序包含多个文件,则每个用了const对象的文件都必须得能访问到它的初始值才行。要做到这一点,就必须在每一个用到变量的文件中都有对它的定义。为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的const变量时,其实等同于在不同文件中分别定义了独立的变量。
例子:
1 #ifndef FILE_H 2 #define FILE_H 3 void f(); 4 #endif
1 #include <header/file.h> 2 #include <iostream> 3 4 const int x = 998; 5 void f() 6 { 7 std::cout << "func:&x " << &x << std::endl; 8 }
1 #include <iostream> 2 #include <string> 3 #include <header/file.h> 4 5 const int x = 998; 6 int main() 7 { 8 f(); 9 std::cout << "main:&x: " << &x << std::endl; 10 return 0; 11 }
x的地址完全不一样,说明2个x变量时独立的,不是同一个。
如果想要在不同的文件间共享同一个const变量怎么办,方法是对于const变量不管是声明还是定义都添加extern关键字,这样只需定义一次就好了。
例子:
1 #ifndef FILE_H 2 #define FILE_H 3 extern const int x; 4 void f(); 5 #endif
1 #include <header/file.h> 2 #include <iostream> 3 4 extern const int x = 998; 5 void f() 6 { 7 std::cout << "func:&x " << &x << std::endl; 8 }
1 #include <iostream> 2 #include <string> 3 #include <header/file.h> 4 5 extern const int x; 6 int main() 7 { 8 f(); 9 std::cout << "main:&x: " << &x << std::endl; 10 return 0; 11 }
地址一样,说明是同一个变量。
1、const指针
允许把指针本身定为常量。常量指针必须初始化,一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。把*放在const关键字之前用以说明指针是一个常量。
声明语句:基本数据类型 *const 指针名 = &var;
以这种形式声明一个指针就说明这个指针是一个常量指针,即不变的是指针本身,可以通过该指针修改指针所指向的对象的值。
2、顶层const
顶层const:表示指针本身是个常量。
底层const:表示指针所指的对象是一个常量。
一般的,顶层const可以表示任意的对象是常量,这一点对任何数据类型都适用;底层const则与指针和引用等复合类型的基本类型部分有关;其中,指针类型既可以是顶层const又可以是底层const。
1 #include <iostream> 2 #include <string> 3 4 int main() 5 { 6 int ci = 100; 7 const int &r = ci; // 用于声明引用的const都是底层const 8 return 0; 9 }
当执行对象的拷贝操作时,常量是顶层const还是底层const区别明显。其中顶层const不受什么影响,底层const的限制不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层const资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之不行。
1 #include <iostream> 2 #include <string> 3 4 int main() 5 { 6 int i = 0; 7 const int ci = 42; // 顶层const 8 const int *p2 = &ci; // 底层const 9 const int *const p3 = p2; // 左边的const是底层const,右边的是顶层const 10 //int *p = p3; // 错误:p3包含底层const的含义,而p没有 11 p2 = p3; // p2和p3都是 底层const 12 p2 = &i; // int*能转换成const int* 13 //int &r = ci; // 错误:普通的int&不能绑定到int常量上 14 const int &r2 = i; // 正确:普通的int可以绑定到const int&上 15 return 0; 16 }
3、constexpr和常量表达式
常量表达式是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式。
一个对象(或表达式)是不是常量表达式由它的数据类型和初始值共同决定。
1)constexpr变量
C++11新标准规定,允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化。
1 #include <iostream> 2 #include <string> 3 4 int f() { 5 return 1024; 6 } 7 int main() 8 { 9 constexpr int mf = 1024; 10 //constexpr int x = f(); // 错误:f不是constexpr函数 11 return 0; 12 }
2)字面值类型
常量表达式的值需要在编译时就得到计算,因此对声明constexpr时用到的类型必须有所限制。因为这些类型一般比较简单、容易得到,就把它们称为“字面值类型”。
3)指针和constexpr
在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关。
1 #include <iostream> 2 #include <string> 3 4 int x = 1024; 5 int main() 6 { 7 constexpr int *p = &x; // p是一个指向整数的常量指针 8 return 0; 9 }
一个constexpr指针的初始值必须是nullptr或者0,或者是存储于某个固定地址中的对象。
三、处理类型
1、类型别名
类型别名是某种类型的同义词:
1 #include <iostream> 2 #include <string> 3 4 int main() 5 { 6 typedef char *ps; // ps是类型char*的别名 7 const ps p1 = 0; // p1是指向char的常量指针 8 const ps *p2; // p2是一个指针,它的对象是指向char的常量指针 9 return 0; 10 }
注意:遇到使用了类型别名的声明语句时,人们往往会错误地尝试把类型别名替换成它本来的样子去理解,这种理解方法是错误的;要将类型别名看成是一个基本数据类型去理解。
2、auto类型说明符
使用auto类型说明符能让编译器替我们去分析表达式所属的类型。编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。
1 #include <iostream> 2 #include <string> 3 4 int main() 5 { 6 /* 7 当引用被用作初始值时,真正参与初始化的其实是引用的对象的值, 8 此时编译器以引用对象的类型作为auto的类型。 9 */ 10 auto i = 0, &r = i; 11 auto a = r; // a是一个整数 12 /* 13 auto一般会忽略掉顶层const,底层const会保留下来 14 */ 15 const int ci = i, &cr = ci; 16 auto b = ci; // b是一个整数(ci的顶层const特效被忽略掉了) 17 auto c = cr; // c是一个整数(cr是ci的别名,ci本身是一个顶层const) 18 auto d = &i; // d是一个整型指针 19 auto e = &ci; // e是一个指向常量的指针(对常量对象取地址是一种底层const) 20 /* 21 如果希望推断出的auto类型是一个顶层const,需要明确指出 22 */ 23 const auto f = ci; // ci的推演类型是int,f是const int 24 /* 25 可以将引用的类型设为auto,此时原来的初始化规则仍然使用;设置一个类型 26 为auto的引用时,初始值中的顶层const仍然保留。如果我们给初始值绑定一个引用, 27 则此时的常量就不是顶层const了。 28 */ 29 auto &g = ci; // g是一个整型常量引用,绑定到ci 30 //auto &h = 42; // 错误:不能为非常量引用绑定字面值 31 const auto &j = 42; // 可以为常量引用绑定字面值 32 return 0; 33 }
3、decltype类型说明符
decltype的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值。
如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内)。
1 #include <iostream> 2 #include <string> 3 4 int main() 5 { 6 const int ci = 0, &cj = ci; 7 decltype(ci) x = 0; // x的类型是const int 8 decltype(cj) y = x; // y的类型是const int &,y绑定到变量x 9 //decltype(cj) z; // 错误:z是个引用,必须初始化 10 return 0; 11 }
如果decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型。有些表达式将向decltype返回一个引用类型。一般来说,当这种情况发生时,意味着该表达式的结果对象能作为一条赋值语句的左值。如果表达式的内容是解引用操作,则decltype将得到引用类型。
1 #include <iostream> 2 #include <string> 3 4 int main() 5 { 6 int i = 42, *p = &i, &r = i; 7 decltype(r + 0) b; // 加法的结果是int,因此b是一个未初始化的int 8 //decltype(*p) c; // 错误:c是int&,必须初始化 9 return 0; 10 }
对于decltype所用的表达式来说,如果变量名加了一对括号,则得到的类型和不加括号时会有不同。如果decltype使用的是一个不加括号的变量,则得到的结果就是该变量的类型;如果给变量加上了一层或多层括号,编译器就会把它当成是一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype就会得到引用类型。
1 #include <iostream> 2 #include <string> 3 4 int main() 5 { 6 int i = 42; 7 //decltype((i)) d; // 错误:d是int&,必须初始化 8 decltype(i) e; // e是一个未初始化的int 9 return 0; 10 }