【硬着头皮啃C++ Primer】第2章 变量和基本类型

第2章 变量和基本类型

2.1 基本内置类型

  基本内置类型分两类:算术类型和空类型。后者不对应具体的值,仅应用于一些特殊场合,比如,函数没有返回值时返回值类型就是空类型。
  算数类型是我们主要了解的。算数类型分两类:整型和浮点型;包括字符、整型数、布尔值和浮点数。要知道,字符和布尔型都算在整型里面。C++对不同的类型规定了最小长度,但允许编译器赋予它们更大的长度。
  对于字符类型,也就是char,有如下规定:一个char的空间应该确保可以存放机器基本字符集中任意一个字符对应的数字值。也就是说,一个char的长度和一个机器字节一样。
  这里我出现了两个疑惑:其一,“机器基本字符集”是个啥?是什么UTF-8么?其二,机器字节是啥?
  上网才发现,机器基本字符集这个定义,仅出现在本书中,英文对应“machine’s basic character set”,并没有明确的定义,一般来说,可以把ASCII当作一个机器基本字符集。那么有一个疑问,ASCII,UTF-8这些都是啥,有什么区别?
  ASCII,全称美国信息交换标准代码,是用七位或八位二进制数表达字符的方法。也就是说,对于任何七位或八位二进制数,我们都能把它翻译成对应的字符;我们要想保存任何字符,需要把它的ASCII码保存在硬盘里。UTF-8,是从Unicode衍生而来的。由于各国都有自己的编码标准,因此互相交流很成问题,就用Unicode来统一,Unicode规定,每个字符占16位,为了兼容ASCII,就让后八位对应ASCII,前八位为0。但是大家发现,相比于ASCII,Unicode太占空间,太占带宽了,就衍生出了UTF-8等编码。UTF-8等编码是对Unicode的再编码,从而节约了带宽等。另外,中国有自己的GBK2312和GBK18030编码,这是为了表达中国汉字而设计的。也被囊括在了Unicode中。
  综上所述,如果我们有一篇用UTF-8编码的中文文档,想解码,那么就是由UTF-8解码得到Unicode,再由Unicode得到GBK2312,然后转换成汉字显示出来。
  这样一看,机器基本字符集算是比较基础的字符集,UTF-8相比于它算是高级字符集了。
  第二个问题,机器字节是啥。这问题没查到答案。但按文中所说,一个char的长度一般是8位,那么一个机器字节也就是8位,八成就是内存的一个地址对应的位数吧。
  上文我们说了好几种编码方式,只有ASCII可以直接用char储存,其余的,用char根本存不下,因此C++对char类型进行了扩展,如wchar_t,char16_t等。wchar_t用于确保可以保存机器最大扩展字符集中的任意一个字符,char16_t则用于保存Unicode类型的字符。
  除了布尔型和扩展的字符型,其他整型(除了char)都可分为带符号的和无符号的。int等本身就是带符号的,前面加上unsigned就变成了无符号的。单独的unsigned表示unsigned int。
  char可分为三种:char、signed char、unsigned char。尽管它分为了三种,但是实际表示仅会是有符号的或者是无符号的,char会表现为其余两种中的一种,具体表现为哪种,由编译器决定。由于char的这个特性,一般不要让char参与运算,这会导致在不同的平台上出现兼容问题。
  这里我又想到了一个问题:平时我直接用’8’这种形式让它参与运算,这里的’8’是什么类型呢?想知道这个问题,就得知道C++用什么函数判断变量的类型。查阅得知,可以用typeid函数,这个函数属于typeinfo库:

#include 
#include 
int main()
{
	int a = 0;
	std::cout << typeid(a).name() << std::endl;
	std::cout << typeid('8').name() << std::endl;
	return 0;
}

就可以得到变量类型了。这里返回的a的类型为int,'8’的类型为char。
  接下来讨论类型转换。类型转换在我们使用一种类型的对象,但实际应该使用另外一种类型的对象时出现。类型所能表示的值的范围决定了转换的过程:
  布尔类型转换为非布尔类型时,false转换为0,true转换为1。这里我就想吐槽,return 0表示程序正常,但是0表示false。你说晦气不晦气。
  非布尔类型的算术值赋给布尔类型时,初始值为0转换为false;不为0转换为true。
  给无符号类型赋给一个超出表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数。比如,-1赋给unsigned char结果是255,因为-1对256取模得255。
  给带符号类型赋给一个超出它表示范围的值时,结果未定义。这就是溢出了,要报错。
  当一个表达式中既有带符号数又有无符号数时,这个带符号数会先转换成无符号数再参与运算,具体的转换方式就是正数不变,负数加上无符号数的模。简而言之,当一个表达式中出现一个无符号变量,那么整个表达式最后的值都被拽到了无符号这个档次。可以看一下练习2.3:

#include 
#include 
int main()
{
	unsigned u = 10, u2 = 42;
	std::cout << u2 - u << std::endl;//32
	std::cout << u - u2 << std::endl;//-32+2^32=4294967264
	int i = 10, i2 = 42;
	std::cout << i2 - i << std::endl;//32
	std::cout << i - i2 << std::endl;//-32
	std::cout << i - u << std::endl;//0
	std::cout << u - i << std::endl;//0
}

答案就在注释里面。
  接下来来看字面值常量。每个字面值常量都对应一种数据类型。一个常数就是一个字面值常量,常数有整数和小数,所以有整型和浮点型字面值。对于整型字面值,我们可以用十进制、八进制或者十六进制表达。正常的就是十进制,0开头的是八进制,0x或0X开头的是十六进制。因此,20、024、0x14表达的都是十进制的20,都是整型字面值常量。如果我们不指定一个整型字面值的具体类型,那么它有一个默认类型:对于十进制,默认类型是int、long、long long中能够保存字面值而不溢出的最小的那个;对于八进制和十六进制,则是int、unsigned int、long、unsigned long、long long、unsigned long long中能够保存的最小的那个。如果一个字面值太大,将产生错误。用如下的代码来检查一下:

#include 
#include 
int main()
{
	std::cout << typeid(10).name() << std::endl;
	std::cout << typeid(-10).name() << std::endl;
	std::cout << typeid(024).name() << std::endl;
	std::cout << typeid(0x14).name() << std::endl;
	std::cout << typeid(0xffffffffffffff).name() << std::endl;
}

其中,前四个的输出都是int,第五个输出是__int64,我就很好奇,这个__int64哪里来的,前面不是说应该是long long么,怎么变这个东西了。上网一查发现,vc系列用的叫__int64,而g++用的就叫long long。我用的是VS2015,因此是__int64。
  尽管整型的字面值是储存在带符号的类型中的,但是严格来说,十进制的字面值不会是负数,也就是说,如果我们有一个-42的字面值,这里的42才是字面值常量,-用于将这个常量取负罢了。
  浮点字面值的默认类型就很简单,全是double。
  字符字面值是用单引号括起来的一个字符,字符串字面值则是由双引号括起来的一组字符。后者实际上是由常量字符构成的数组。编译器在每个字符串的末尾添加一个空字符(’\0’),因此,字符串的长度要比它的内容多一。如果两个字符串常量之间仅仅用空格、缩进和换行符分隔,那实际上它们是一个整体。怎么理解呢?如果我有一个好长的字符串,可以用两对双引号把它们括起来,变成两个字符串,这俩字符串用换行分隔,那么显示起来就容易看一些,并且编译器仍然认为它们是同一个字符串。
  有一些字符在C++中有特殊含义,另外有一些没有可视字符的符号,想要在字符串中表达出来,就需要转义序列了。转义序列都是用反斜杠开始,我们常用的比如说’\n’是换行,’\t’是制表符等。还可以使用泛化的转义序列。形式是\x后面跟着一个或多个十六进制数字,或\后面跟着一个,两个或三个八进制数字。泛化的转义序列的本质就是直接用ASCII码来表示。比如说M,其ASCII码十进制是77,八进制是0115,十六进制是0x4d,那么,如下代码均输出M:

#include 
#include 
int main()
{
	std::cout << 'M' << std::endl;
	std::cout << '\x4d' << std::endl;
	std::cout << '\115' << std::endl;
}

另外我还注意到,加了endl就会换行。
  注意到上面对转义序列为八进制时有约束:如果反斜线后面跟着超过三个八进制数,那么就只有前面三个被转义,后面的都按正常的数字解释。对于十六进制则没有这个限制。那么问题就来了,假设我有一个转义序列,比如\x1234,这个转义序列由十六位二进制数唯一确定,但是这是个转义序列的同时,其类型就是char,char只有八位,出现了矛盾。该怎么办?这时就需要强制指定字面值类型。

#include 
#include 
int main()
{
	std::cout << u'\x1234' << std::endl;
}

如上面的代码,在转义序列的单引号外面加上u,就表示将这个转义序列指定为char16_t类型,这一类型就可以容纳16位的字符了。
  它前面加的这个u,名字叫做前缀。对于字符及字符串类型的字面值,使用前缀来指定字面值类型。比如u指定为char16_t,U指定为char32_t等。u8则指定为char,仅用于字符串类型字面值。整型以及浮点型字面值则是用后缀来指定:对于整型,后缀为u或U表示对应的八进制或者十六进制字面值仅从三个unsigned中选择类型;l或L表示指定为long;ll或LL表示指定为long long。对于浮点型,f或F指定为float;l或L指定为long double。
  下面来看练习2.5的第一题(a):回答’a’、L’a’、“a”、L"a"是什么类型。第一个和第二个很简单,按上面的来说,是char和wchar_t类型。但是后面两个,是字符串,是什么类型呢?按VS的解释,是char const [2]和wchar_t const [2]的类型。也就是说,是字符串数组。再看©,3.14L的类型,我以为是long long,没想到是long double。那么long long和long double有啥区别呢?前者是大整数,后者是大小数,我真是愚蠢。使用如下代码即可验证:

#include 
#include 
#include   
int main()
{
	std::cout << "long: \t\t" << "所占字节数:" << sizeof(long);
	std::cout << "\t最大值:" << (std::numeric_limits<long>::max)();
	std::cout << "\t最小值:" << (std::numeric_limits<long>::min)() << std::endl;
	std::cout << "double: \t\t" << "所占字节数:" << sizeof(double);
	std::cout << "\t最大值:" << (std::numeric_limits<double>::max)();
	std::cout << "\t最小值:" << (std::numeric_limits<double>::min)() << std::endl;
	std::cout << "long long: \t\t" << "所占字节数:" << sizeof(long long);
	std::cout << "\t最大值:" << (std::numeric_limits<long long>::max)();
	std::cout << "\t最小值:" << (std::numeric_limits<long long>::min)() << std::endl;
	std::cout << "long double: \t\t" << "所占字节数:" << sizeof(long double);
	std::cout << "\t最大值:" << (std::numeric_limits<long double>::max)();
	std::cout << "\t最小值:" << (std::numeric_limits<long double>::min)() << std::endl;
}

然而,其输出却有一定的可商榷性:long所占的字节数为4,其余三个为8;在VS2015中,double和long double的范围一样。均为2.22507e-308到1.79769e+308。

2.2 变量

  如果我们想要使用一个变量,那么首先,我们要定义一个变量。在定义变量时,如果给定这个变量一个值,那么称将该变量进行初始化;否则,称对该变量进行默认初始化。在这里,我很小心的不去使用赋值这个词,因为初始化和赋值是两种完全不同的操作:初始化是创建变量时赋予其一个初始值;赋值则是将当前值擦除,然后给它一个新值来替代。在初始化的时候,变量就没有当前值,肯定就算不上初始值了。
  定义是很简单的,使用类型说明符加上变量名就是定义了。有一个小地方需要注意:当一条语句中定义两个或多个变量时,对象的名字随着定义就马上可以使用了。因此可以用先定义的变量定义后定义的变量。
  初始化就复杂得多了。首先,如果我们明确在定义时候给了变量一个初值,那么它就被初始化。如果没给,则进行默认初始化。默认初始化有以下几种情况:对于内置类型,如果是在任何函数体之外定义的,则其初始化为0;如果在函数体内部,则不进行初始化,直接使用会导致错误。对于其他的类,对象的初值则由其自己决定。大多数类允许无需显示初始化而定义对象,这样的类提供了合适的默认值,比如,string类型的对象的默认值是"",另外一些类则规定,必须显示初始化对象。
  c++11引入了一种新的初始化方式,即列表初始化。使用花括号将初始值括起来进行初始化。当使用内置类型的变量进行列表初始化的时候,该初始化方法有一个重要特点:若初始化的值存在丢失信息的风险,则会报错。比如溢出等,就会报错。如下代码:

#include 
#include 
#include   
int main()
{
	int a = 0;
	int b = { 3.1415926 };
	int c{ 3.1415926 };
	int d(3.1415926);
	std::cout << a << " " << b << " " << c << " " << d << std::endl;
}

在上述代码中,只有a的初始化成功,b和c的初始化会报错需要收缩转换,d则会报warning,存在数据丢失的风险。
  练习2.9有两行很有趣的代码,如下:

int a=b=0;
std::cin>>int c;

这两行代码都是错的,代码是从左往右运行的,那么当用b为a初始化的时候,b还未初始化,那么就会报错。同样的,我们读入输入,存放在cin里,然后保存在c中,但是在存放之后,c还未定义,也会出现错误。
  接下来讨论一个很容易晕的问题:变量声明和定义。这个问题来源于C++的分离式编译,对于一个大型工程,无法在一个文件中完成所有功能,那么就需要多个文件协同工作。那么就出现了一个问题:文件A想要使用文件B中的变量c,这时该怎么办?用我们常用的例子:cin是在标准库中定义的,我们可以在任何文件中使用它。
  c++为了实现这个功能,将变量的声明和定义分开来:声明使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对该名字的声明。而定义则负责创建与名字关联的实体。可以把声明看作是谈恋爱,而定义看作是结婚。定义代价更大。
  声明和定义都需要规定变量的名字和类型,但定义还需要申请存储空间(买房子),也可能给变量赋一个初始值。如果我们想声明而不是定义一个变量,那么就在变量类型前面加上extern,并且不要显示的初始化变量。任何显示初始化变量的声明都会成为定义。
  在函数体内部,如果我们想初始化一个由extern标记的变量,会发生错误。使用如下代码来辅助理解:

#include 
#include 
#include   
#include
extern int i = 0;
int main()
{
	extern int j = 0;
	std::cout << i << j << std::endl;
	return 0;
}

其中,i的声明强制转换为初始化,j则直接报错。
  变量的定义必须出现且只能出现在一个文件中,其他文件中必须对该变量进行声明,却决不能重复定义。
  接下来记录一个小玩意:在c++中,给变量起名字的禁忌:必须以字母或者下划线开头;不能出现连续两个下划线;不能以下划线加大写字母开头;定义在函数体外的变量不能以下划线开头。
  作用域是一个挺蛋疼的东西。它用一对花括号标记。分全局作用域和块作用域。全局作用域就是在所有的花括号之外的区域;块作用域则是在花括号之内的部分。c++、允许作用域嵌套,即内层作用域可以直接使用外层作用域声明的变量;内层作用域也可以对外层的变量进行重新定义,但这样会极大破坏函数的可读性,不要这么做。但如果我们必须这么做怎么办?例如,全局作用域中有变量a,块作用域中也有变量a,现在我想在块作用域中调用块作用域的a,那么直接输入a即可,但是若想调用全局作用域的a,则需要使用::a。::为作用域操作符,全局作用域没有名字,因此::左侧为空,即是直接向全局作用域申请调用变量a。

2.3 复合类型

  复合类型是指基于其他类型定义的类型,这里介绍两种,引用和指针。我们之前提到,一条简单的声明语句由一个数据类型(如int)和紧随其后的一个变量名列表(a,b,c)组成。但是更通用的描述是由一个基本数据类型和紧随其后的声明符列表组成。
  我们必须牢记,声明符就是变量名。然后来看第一种复合类型:引用。引用就是为变量起了另外一个名字,引用类型引用另外一种类型。这句话有点难断句,引用类型/引用/另外一种/类型。也就是说,引用类型可能有许多类型,具体类型由他引用的类型决定。通过将声明符,也就是变量名,写成&+声明符的形式来定义引用类型。
  引用必须被初始化,这是由引用的自身性质决定的。引用就是给变量起了一个别名,也就是说,必须现有一个变量,然后起一个别名。比如说,萧炎是一个男生。我给他起个别名叫土豆儿子。这样是合理的。我不能先起一个土豆儿子,然后十年以后萧炎出生,我把名字给他。这样是不行的。从程序实现角度来讲,在定义引用时,程序会把引用和它的初始值绑定在一起,而不是把初始值拷贝给引用。一旦初始化完成,引用就和初始值锁死了,钥匙被扔了,因此无法令引用重新绑定给另外一个对象。
  引用不是对象!
  一切对引用的操作实际上都是对它的初始值的操作。为引用赋值,等价于给初始值赋值;从引用获得值,等价于获取和引用绑定的对象的值。引用的引用是不被允许的,因为引用必须绑定到对象上,而引用本身不是对象。而且,引用也不能绑定在字面值或者表达式的计算结果上,这个原因后面再说。
  我们允许一条语句中定义多个引用,要求每个声明符前面都有&,并且引用的类型必须和绑定的对象类型一致。
  妈了个鸡,引用这个名字实在是不好,因为它本身既是动词又是名词,很容易理解错误。
  然后来看第二种复合类型:指针。指针和引用类似,都可以实现对其他对象的间接访问。但是与引用不同,指针本身就是一个对象,允许对指针进行赋值和拷贝,在指针的生命周期里,可以允许它指向不同的对象。也就是说,指针和对象仅仅是指向的关系,而不是拷贝。也可以这么说,指针是恋爱,引用是结婚。指针无需在定义时赋初值,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。
  指针的内容是某个对象的地址,也就是说,给指针赋值,需要使用地址来赋值。为了得到一个对象的地址,需要使用取地址符&。那么,可不可以定义一个指向引用的指针呢?不可以,因为指针的内容是地址,引用不是对象,它没有地址,因此不可以定义指向引用的指针。
  指针的值是一个地址,具体来说,会有四种情况:指向一个对象;指向紧邻对象所占空间的下一个位置;空指针;无效指针。试图拷贝或者访问无效指针都会引发错误。但是别以为访问第二种或者第三种情况的指针没事,只是不会报错,实际出现什么也无法估计。
  要想访问一个指针指向的对象,要使用解引用符*。这里有一点要注意,指针的定义需要*,解引用也需要*,这两种情况是不一样的,见如下代码:

int i=0;
int *j=&i;//表明j是一个指针,其初始值是i的地址
std::cout<<*j<<std::endl;//输出j指向的对象,*是解引用符

注意,解引用操作仅适用于那些已经指向有效对象的指针。
  在访问一个指针前,先检查该指针是否为空是一个好主意。但是如何得到一个空指针呢?有以下三种方法:

int *p1=nullptr;
int *p2=0;
//需要首先include
int *p3=NULL;

最简单且最直观的是使用nullptr来为指针赋初值,nullptr是一种特殊类型的字面值,可以被转换成任意其他的指针类型。为什么这么说呢?因为所有类型的指针都可以用它赋初值,因此它必须是万用类型才可以。也可以用0作为初值,但是没有nullptr直观。古老的程序也会使用NULL这个预处理变量,它的值就是0,在cstdlib这个头文件中定义。
  注意,虽然我们可以用0给指针赋初值,但是并不能把一个变量赋给指针,即使这个变量的值为0。这本质上是不能把int类型的变量转换成int*类型的变量。变量和常量还是有本质区别的。另外,指向long的指针也不能用int的地址赋值,这里不存在int*到long*的转换。
  两个指针可以使用==判断是否相等,但要注意的是,如果指针a指向变量A,指针b指向变量B的下一个地址,这时a和b也可能相等。因为变量A和变量B在内存中可能是相邻存放的。
  void*类型的指针是一种特殊的指针,从字面意义上将,这个指针指向的对象是void,也就是空。因此它可用于存放任意类型的变量的地址。同时由于这一点,我们无法得知这个指针实际指向的到底是什么类型,那么就无法具体操作这个指针指向的对象。实际使用中,void*一般用于和其他指针的比较,或者作为函数的输入或者输出。
  练习2.23提出了一个有趣的问题:给定一个指针p,你能否判断它指向了一个合法的对象?答案是不能。因为你如果想判断,就需要访问,而无论指针指向的对象是否合法,它都是有内容的,这个内容你有可能不懂,但不一定是非法的。一般在使用指针时,最好先初始化为NULL,释放后赋值为NULL,判断是否是空指针,来避免这些问题。在过程中是否非法就需要程序员自己把握了。
  接下来是一个很令人疑惑的部分:理解复合类型的声明。如前所述,变量的定义包括一个基本数据类型和一组声明符,虽然基本数据类型只有一个,但声明符可以有很多:

int i=1024,*p=&i,&r=i;

这里,一句话声明了整型变量i,整型指针p和整型引用r。
  多个符号可以一起使用:比如**是指向指针的指针,对这种指针,访问最原始的对象需要两次解引用。因为指针本身是一个对象,那么就存在对指针的引用:

int i=0;
int *p=&i;
int *&r=p;

这里的r就是对指针的引用。但是阅读起来有一些困难,最简单的就是从右向左阅读r的定义,离r最近的符号(这里是&)对r的类型有最直接的影响。这里就说明r是一个引用,其余部分可以确定r所引用的类型是什么,这里的*表示r引用的是一个指针。最后,最左边的int表明r引用的是一个int指针。

2.4 const限定符

  有时候我们需要定义这样一种变量,它的值不能被改变。例如,用一个变量来表示缓冲区的大小。使用变量的好处在于当我们觉得缓冲区的大小不再合适是,很容易对其进行调整。另一方面,也应随时警惕防止程序一不小心改变了这个值。为了满足这个要求,可以用关键字const对变量的类型加以限定。
  由于const对象一旦创建以后,它的值就不能改变,所以它必须初始化。可以是运行时初始化,也可以是编译时初始化:

const int i=get_size();//运行时初始化
const int j=0;//编译时初始化

  初始化可以用常量,也可以用函数返回值,还可以使用变量。无论如何,在初始化之后它的值就会被确定了。与非const类型的变量相比,const类型的变量可以完成大多数操作,也可以转换成布尔值,只是不能执行改变它内容的操作。
  默认状况下,const仅在文件内有效,在编译的时候,编译器会把该文件中所有用到这个const变量的地方全部替换成对应的常量。为了执行这一替换,编译器必须知道const变量的初始值。我们知道,初始值是在变量定义的时候确定的。那么如果有若干个文件,每个文件中都有const变量,那么就必须保证每个文件中都有const变量的定义。为了支持这个用法,同时防止重复定义,规定默认情况下const仅在文件内有效。
  那么现在我有一个需求,一个文件中需要使用另外一个文件中的const变量,该如何实现呢?如果是常量表达式,很简单,再定义一个就可以了。但如果不是常量表达式,是需要运行时初始化的,那么如果再定义一个,可能会很麻烦,引入一大堆额外的类什么的。这时就迫切需要可共享的const变量了。解决方法是对于一个需要在其他文件中使用的const变量,在定义和声明的时候,都是用extern进行限定。也就是说,和普通的变量的区别就在于,const变量在定义的时候也需要extern进行限定。
  那么既然const变量也是一个对象,那么就可以对其进行引用,我们称之为常量引用。与普通引用不同的是,常量引用不能修改被引用的变量的值——这是废话,能修改还叫个屁常量。如何声明一个常量引用呢?如下

const int i=0;
const int &j=i;

这里的j就是一个常量引用。同时注意,非常量的引用是无法绑定在常量对象上的。
  现在有一个烧脑筋的问题:如上面所说,一个非常量的引用,无法绑定在常量对象上。那么,对于一个常量的引用,只能绑定在与之匹配的常量对象上么?举例来说,上面的j,是一个常量的引用,那么它只能绑定i这种常量对象么?
  不是的。这是一种特例。对常量的引用可以绑定非常量的对象,字面值,甚至是一个一般表达式:

int i=40;
const int &r0=i;//绑定在非常量的对象
const int &r1=42;//绑定在字面值
const int &r2=i*2;//绑定在一般表达式

这里r0、r1、r2都没有遵守引用和绑定的对象类型必须完全一致的规则。要想理解这种规则上的例外,就必须弄清楚当一个常量引用被绑定到另外一种类型上时到底发生了什么:

double dval=3.14;
const int &ri=dval;

这里的ri,最正统的应该是绑定到一个整型常量对象,但是却绑定到一个浮点变量对象,不会报错。为了运算不出错,就必须保证ri绑定一个整数,那么编译器就会加一句代码:

double dval=3.14;
const int tmp=dval;
const int &ri=tmp;

这样,ri就绑定了tmp而不是dval,就实现了确保ri绑定的是一个整数。这里的tmp称为临时量,就是当编译器需要一个空间来暂存表达式的求职结果时临时创建的。
  一定要记住一点,常量引用仅仅对引用本身做了限制,对所绑定的对象是没有限制的。也就是说,不能通过常量引用改变所绑定的对象,但是可以通过其他方法对对象进行改变,这是允许的。本质上,常量引用是对引用的约束,这样看来,“对常量的引用”这个说法至少是不完全的。实际上应该是“常量是一个引用”。
  好了,既然常量是一个对象,那么指针就可以指向它。类似于常量引用,也存在指向常量的指针,指向常量的指针也不能用于改变所指对象的值。要想存放常量对象的地址,只能使用指向常量的指针。令人啼笑皆非的是,虽然只有指向常量的指针能存放常量的地址,但是指向常量的指针本身是可以指向非常量的对象的。也就是说,无论是指向常量的指针还是常量引用,它们所关联的对象是否是常量根本无关紧要,只不过是指针或者引用自以为是指向了常量,然后按指向常量的规范要求自己罢了。这就好像一个舔狗,自诩为女神的男朋友,一言一行都已男朋友自居,但女神自己是否承认和舔狗没有任何关系。
  大家可能会好奇,为啥不管“指向常量的指针”直接叫“常量指针”呢?因为真的存在“常量指针”这个玩意啊。它和指向常量的指针是不同的东西。因为指针是对象,所以指针本身可以定义为常量。注意,一个是“本身”是常量,一个是“指向”常量。这就是它俩的区别。本身是常量的叫常量指针,它不能更改绑定的对象;指向的是常量的叫指向常量的指针,它可以更改绑定的对象,但不能更改对象的值。
  常量指针必须初始化,一旦初始化完成,则它的值就不能再改变了。下面咱们来分辨三种量:常量指针,指向常量的指针,指向常量的常量指针:

int errNumb=0;
const double pi=3.14159;
int *const curErr=&errNumb;//常量指针,离curErr最近的是const,表明它是一个常量
const double *pip=&pi;//指向常量的指针,离pip最近的是*,表明它是一个指针
const double *const pipi=&pi;//指向常量的常量指针,离pipi最近的是const,表明它是一个常量

指针本身是一个常量并不意味着不能通过指针修改器所指对象的值,能否修改完全依赖于它指向的是否是一个常量。比如说按上面的代码,curErr可以修改errNumb的值,虽然它是一个常量指针。pip虽然是一个指针,但它不能修改pi的值,因为它指向的是一个浮点常量。
  来看两道题巩固一下吧:

int i=-1,&r=0;//r是引用,引用绑定的必须是对象,0不是对象,错误
int *const p2=&i2;//p2是常量指针,正确
const int i=-1,&r=0;//r是常量引用,可以绑定字面值0,正确
const int *const p3=&i2;//p3是指向常量的常量指针,可以绑定i2,正确
const int *p1=&i2;//p1是指向常量的指针,可以绑定i2,正确 
const int &const r2;//r2本身是常量,它是一个常量的引用,是引用却没有初始化,错误
const int i2=i,&r=i;//r是常量引用,可以绑定i,正确

唯一一个疑惑应该是const int &const r2,r2是一个常量,它还是一个引用,这里我将其改为如下代码就不再报错:

const int &const r2=i2;

从这个角度来看,尽管r2是一个常量,但它还是一个引用,和一个常量对象绑定。那么,r2自己到底是一个引用还是一个对象呢?虽然我用VS2015,上述代码跑的通,但是用gcc就会报错:
Main.cpp:13: 错误: ‘const’限定符不能应用到‘int&’上
也就是说,后一个const是无效的,它就是一个引用。

int i, *const cp;//cp是常量指针,需要初始化,没初始化,错误
int *p1, *const p2;//p2也是常量指针,也没初始化,错误
const int ic, &r = ic;//ic是常量,没初始化,错误,r是常量引用,用ic初始化,错误
const int *const p3;//p3是指向常量的常量指针,没初始化,错误
const int *p;//p是指向常量的指针,正确

  现在我们可以看到,const有两种情况,第一种,变量本身是const,第二种,变量指向的对象是const。这两种情况有本质的区别,因此我们称第一种为顶层const(血统纯正,上层人士),第二种为底层const。注意,指针类型既可以是顶层const也可以是底层const。用于声明引用的const都是底层const,这也佐证了上文中第二个const无效的观点。这也可以看出,只有复合类型才会涉及底层const。const int v2 = 0;
int v1 = v2;
int *p1 = &v1, &r1 = v1;
const int *p2 = &v2, *const p3 = &i, &r2 = v2;
  在执行对象的拷贝操作时,常量是顶层const还是底层const区别明显。如果对象本身是const,即顶层const,那么可以随便拷贝——又不更改常量的值,随便来。但如果对象指向的是const,那么拷贝的时候就要注意:拷入和拷出的对象必须具有相同的底层const资格,或者两个对象的数据类型能够转换,一般是指非常量能够转换成常量。这有点难以理解,我们用下面的代码来说明:

const int *const p3 = p2;
int *p = p3;
//错误,因为p3指向一个常量,而p指向一个非常量,那么在p看来,它所指向的是一个变量,可以修改,但实际不可以,因此出现的矛盾。

  来看两个练习巩固一下:

const int v2 = 0;//v2是顶层const
int v1 = v2;//v1不是const
int *p1 = &v1, &r1 = v1;//p1不是const,r1不是const
const int *p2 = &v2, *const p3 = &i, &r2 = v2;//p2是底层const,p3既是顶层const又是底层const,r2是底层const
r1 = v2;//r1是一个int引用,指向v1,该句等价于v1=v2,正确
p1 = p2;//p1是一个指针,指向v1;p2是底层const,指向v2,执行这句之后,p1也指向v2,即const int转换为int,错误
p2 = p1;//不考虑上面那句p2=p1,这句是让p2指向v1,即int转换为const int,正确
p1 = p3;//p3指向const int i,让p1指向i,即const int转换为int,错误
p2 = p3;//让p2指向i,即const int 转换为const int,正确

  然后转向介绍常量表达式。常量表达式定义为值不会改变并且在编译时就能获得结果的表达式。一个对象能称之为常量表达式需要满足两个条件,其一,数据类型是常量;其二,初始值是常量表达式或字面值或者在编译时就能得到的值。因此,不能令常量表达式的初始值是一个函数的返回值。
  在一个复杂系统中,几乎肯定不能分辨一个初始值是不是常量表达式,那么我们对于常量表达式的初始值的约束几乎是完全无效的。这时,我们可以将变量声明为constexpr类型,来让编译器帮助我们验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化。这样就不用我们自己费尽心思去判断了。一般来说,如果你认定一个变量是常量表达式,那么就把它声明成constexpr就好了。
  由于常量表达式的值需要在编译时就得到计算,那么其类型就必须较为简单,称这些类型为字面值类型。算数类型、指针、引用都是字面值类型。尽管指针和引用可以定义为字面值类型,但它们的初始值却受到限制:一个constexpr指针的初始值必须是0或者null,或者储存于某个固定地址的对象。这意味着什么呢?意味着函数体内部的变量的地址都不能作为constexpr指针的初始值,因为它们的地址可变。由此可以推断出,定义于所有函数体之外的对象的地址可以用来初始化constexpr指针。
  必须说明一点,用constexpr限制的指针,仅仅限制指针本身,而对于指针指向的对象是否是常量没有限制。也就是说,constexpr指针一定是一个常量指针。

int null=0*p=null;

以上的代码是错误的,因为p是一个普通指针,null是一个int对象,不能把int转换为int*。不要认为null的值为0就误认为p是一个固定的地址,除非null是一个const int。当 null是一个const int,那么p就可以初始化为null,这种理解对么?也不对,因为指针的值不能是随便一个常量。他只能是0。这里不能理解为int转化为int*,也不能理解为用一个常量初始化int*,只能理解为用一种迂回的方式,将指针初始化为0。

2.5 处理类型

  现在我们都有点晕了,类型太多太复杂了,有的类型好难拼又好难记,这怎么办?
  第一种方法是使用类型别名。类型别名是一个名字,是某种类型的同义词。传统上使用关键字typedef,typedef作为声明语句的基本数据类型的一部分出现,如下:

typedef double wages;
typedef wages base,*p;

含有typedef的语句定义的不再是变量而是别名,这里,wages是double的别名,base是wages的别名,p是wages*的别名。
  新标准规定了一种新的方法,使用别名声明定义类型的别名:

using SI=Sale_item;

这种方法使用using开始,其后紧跟别名和等号,作用是把等号左侧的名字规定成右侧类型的别名。
  注意,在理解别名出现困难时,将别名的原意带回去是不正确的。这常常出现在别名是复合数据类型的情况中,如下:

typedef char *pstring;
const pstring cstr0=0;
const char *cstr1=0;

如果按照代入来理解,这两种情况是等价的,但实际并不是。cstr0是常量指针,而cstr1则是指向常量的指针。本质上是因为前者const修饰pstring,后者修饰char。
  然后来说一个很方便的东西,auto。我们知道,如果想把一个表达式的值赋给变量,就必须知道这个表达式的值的类型。如果表达式过于复杂,那么就很难知道它的类型,容易弄错。为了防止这种情况的发生,我们使用auto来让编译器自己确定变量的类型。为了让其能够确定,aoto定义的变量必须有初始值。使用auto也可以同时确定多个变量,注意所有变量的初始类型必须一样。
  当初始值的类型是复合类型时,auto推断出的类型可能和初始值的类型不同:比如说,初始值的类型是引用,则推断出的类型是引用所绑定的类型。其次,auto会忽略顶层const,却不会忽略底层const,想要让auto称为顶层const则需要特殊说明。下面来看比较复杂的练习2.33:
  在做这个练习的时候,我遇到了一点疑惑,记录如下:

const int *aaa = 0;
int const *bbb = 0;
std::cout << typeid(aaa).name() << std::endl;
std::cout << typeid(bbb).name() << std::endl;

aaa和bbb分别是指向常量的指针和常量指针,但是typeid没法分辨出这个。这要注意。接下来可以看题目了:

int i = 0, &r = i;//r是int的引用
auto a = r;//a是int类型
const int ci = i, &cr = ci;//cr是常量引用
auto b = ci;//b是int
auto c = cr;//c是int
auto d = &i;//d是指向int的指针
auto e = &ci;//e是指向常量的指针,底层const,const不丢掉
const auto f = ci;//f是常量,顶层const
auto &g = ci;//g是一个整型常量引用,原因在于,ci的类型是整形常量,编译器推断出g的类型是整型常量引用,注意这个const不能被丢掉,因为const修饰的是int而不是&
auto &h = 42;//h是一个错误,原因在于,42的类型是字面值,编译器推断出h的类型是字面值引用,但不存在字面值引用
const auto &j = 42;//j是一个整型常量引用,因为有了const的限制,使得编译器知道了j一定是一个常量引用,那么,初始值可以是42的常量引用就是整型常量引用
a=42;//正确
b=42;//正确
c=42;//正确
d=42;//错误,不能给指针赋值除了0以外的字面值
e=42;//错误,同上
f=42;//错误
g=42;//错误,g绑定整型常量,不能赋值

编译器使用引用绑定的类型作为auto推断的类型!!!也就是说,编译器并不是通过找能够满足初始值类型的类型往回找auto推断的类型,如果是这样,那么ci推断出g是整型常量引用,因为只有整型常量引用可以绑定整型常量;这样一来,也可以通过42推断出h是整型常量引用,因为只有整型常量引用可以绑定字面值。这种思路是错误的。必须牢牢把握“编译器使用引用绑定的类型作为auto推断的类型”这句话。并且,const修饰auto会做出一定的限制。
  类型实在是太容易让人迷惑了。如果我们想要得到一个表达式的值的变量类型,但又不想直接用这个值给变量赋值,比如说这个值要经过复杂的运算才能得到等,那该怎么办?使用decltype类型指示符。decltype和auto都是类型说明符,decltype的作用是选择并返回操作数的数据类型,但并不进行计算。举例如下:

decltype(f()) sum=x;

这里就使用f函数的返回值的类型作为sum的类型。这里,编译器并不实际执行函数f,而只是获得了它的返回值的类型。注意,如果decltype使用的表达式是一个变量,则将这个变量的类型全盘转移,无论是const还是引用,都会全部复制过去。只有在这里,引用才不是其所指对象的同义词。那我又有问题,如果我想要引用所绑定的类型怎么办?(这事儿可真多)这就要用到decltype的一个特点了,如果使用的是表达式而不是变量,则使用表达式结果的类型作为类型——表达式结果的类型总不能是引用了吧。如下:

int i=42,*p=&i,&r=i;
decltype(r) a;//错误,a的类型是引用,必须初始化
decltype(r+0) b;//正确,b的类型是r+0的结果的类型,是int
decltype(*p) c;//错误,c的类型是int&

我在这里卡住了,为什么c的类型是int &呢???p是一个指针,解指针操作得到的应该是int啊,为什么是int&呢?查阅得到了这样一个规定:
如果 decltype 的对象是一个表达式且表达式结果是一个左值,则结果是它的引用类型。
具体讨论可以参考该网址。
  接着说,如果我们使用一对括号括住了一个变量,那么这个变量也就变成了一个表达式,但和+0的效果是不一样的。如果是用括号括住,那么表达式的结果是一个左值,+0,结果是一个右值。左值进行decltype得到的一定是引用,右值得到的是本身类型。上面的r+0是一个表达式,大家都知道,但是*p也是一个表达式。前者得到的结果是一个右值,而后者得到的结果是一个左值,因此前者类型是int,后者类型是int&。以以下三行代码为例:

int i = 42, *p = &i;
decltype(*p) a;//a的类型是int&
decltype(*p + 0) b;//b的类型是int

这可以总结为一个如下规律:用双括号括住一个变量,其类型一定是一个引用;用单括号括住一个变量,仅在这个变量本身是引用的时候才是引用。赋值a=b也是一类表达式,表达式的结果的类型是引用类型,引用的类型是左值类型。

2.6 自定义数据结构

  简单的,我们可以使用结构体来定义类。以关键字struct开始,紧跟着类名和类体。在类体中定义类的成员。现在我们只涉及数据成员。可以为数据成员提供一个类内初始值,创建对象时,初始值用于初始化数据成员。没有初始值的成员将被默认初始化。在之后,我们也可以使用class来定义类。
  如果在不同的文件中想使用同一个类,类的定义必须保持一致,为了这个需求,类一般定义在头文件中。头文件通常包含那些只定义一次的实体,如类,const和constexpr变量。这样就有一个问题,在头文件A中可能包含头文件B,使用头文件A的文件中可能也需要头文件B,那么就可能出现多次包含的问题。解决这个问题的常用技术是预处理器。我们使用的#include就是一项预处理功能,当预处理器看到#include时就会用指定的头文件的内容代替#include。
  我们还常用另外一项预处理功能:头文件保护符,它依赖于预处理变量。预处理变量有两种状态:已定义和未定义。#define指令把一个名字设定为预处理变量;#ifdef当且仅当变量已定义时候为真;ifndef当且仅当变量未定义时候为真。一旦检查结果为真,会执行后续操作直到遇到#endif为止。
  例如,我们自己定义一个头文件如下:

#ifndef SALES_DATA_H
#define SALE_DATA_H
#include
struct Sales_data{
	std::string bookNo;
	unsigned units_sold=0;
	double revenue=0.0;
};
#endif

我们在第一次include这个头文件的时候,会先判断预处理变量SALES_DATA_H,如果它不存在,说明该头文件之前没有include,那么就include string,定义类,结束定义。若第二次include这个头文件,由于预处理变量SALES_DATA_H已定义,那么直接跳转到endif,不会产生多次include的错误。可能有人会问,string也可能多次包含啊?string里面也会检查名为_STRING_的预处理变量啊。由于各个文件都会检查预处理变量,因此预处理变量将会无视C++中有关作用域的规则。同时由于这一点,预处理变量的名字一定不能重复,最好全部大写,名字基于头文件给出。

你可能感兴趣的:(杂谈)