Loading

C++Primer 第2章

第2章 变量和基本类型

数据类型是程序的基础:它告诉我们数据的意义以及我们能在数据上执行的操作。

2.1 基本内置类型

C++定义了一套包括算术类型(arithmetic type)空类型(void)在内的基本数据类型。其中算数类型包含了字符、整型数、布尔值和浮点数。空类型不对应具体的值,仅用于一些特殊的场合,例如最常见的是,当函数不返回任何值时使用空类型作为返回类型。

2.1.1 算术类型

算术类型分为两类:整型(integral type,包括字符和布尔类型在内)和浮点型

算术类型的尺寸(也就是该类型所占的比特数)在不同机器上有所差别。表2.1列出了C++标准规定的尺寸的最小值,同时允许编译器赋予这些类型更大的尺寸。某一类型所占的比特数不同,它所能表示的数据范围也不一样。

image-20221114160916908

布尔类型(bool)的取值是真(true)或者假(false)

C++提供了几种字符类型,其中多数支持国际化。基本的字符类型是char,一个char的空间应确保可以存放机器基本字符集中任意字符对应的数字值。也就是说,一个char的大小和一个机器字节一样。

其他字符类型用于扩展字符集,如wchar_t、char16_t、char32_t。wchar_t类型用于确保可以存放机器最大扩展字符集中的任意一个字符,类型char16_t和char32_t则为Unicode字符集服务(Unicode是用于表示所有自然语言中字符的标准)。

除字符和布尔类型之外,其他整型用于表示(可能)不同尺寸的整数。C++语言规定一个int至少和一个short一样大,一个long至少和一个int一样大,一个long long至少和一个long一样大。其中,long long是在C++11中新定义的。

内置类型的机器实现

计算机以比特序列存储数据,每个比特非0即1

大多数计算机以2的整数次幂个比特作为块来处理内存,可寻址的最小内存块称为“字节(byte)”,存储的基本单位称为“字(word)”,它通常由几个字节组成。在C++语言中,一个字节要至少能容纳机器基本字符集中的字符。大多数机器的字节由8比特构成,字则由32或64比特构成,也就是4或8字节。

大多数计算机将内存中的每个字节与一个数字(被称为“地址(address)”)关联起来,在一个字节为8比特、字为32比特的机器上,我们可能看到一个字的内存区域如下所示:

image-20221114163119414

其中,左侧是字节的地址,右侧是字节中8比特的具体内容。

我们能够使用某个地址来表示从这个地址开始的大小不同的比特串,例如,我们可能会说地址736424的那个字或者地址736427的那个字节。为了赋予内存中某个地址明确的含义,必须首先知道存储在该地址的数据的类型。类型决定了数据所占的比特数以及该如何解释这些比特的内容。

如果位置736424处的对象类型是float,并且该机器中float以32比特存储,那么我们就能知道这个对象的内容占满了整个字。这个float数的实际值依赖于该机器是如何存储浮点数的。或者如果位置736424处的对象类型是unsigned char,并且该机器使用ISO-Latin-1字符集,则该位置处的字节表示一个分号。

浮点型可表示单精度、双精度和扩展精度值。C++标准指定了一个浮点数有效位数的最小值,然而大多数编译器都实现了更高的精度。通常,float以1个字(32比特)来表示,double以2个字(64比特)来表示,long double以3或4个字(96或128比特)来表示。一般来说,类型float和double分别有7和16个有效位;类型long double则常常被用于有特殊浮点需求的硬件,它的具体实现不同,精度也各不相同。

带符号类型和无符号类型

除去布尔型和扩展的字符型之外,其他整型可以划分为带符号的(signed)无符号的(unsigned)两种。带符号类型可以表示正数、负数或0,无符号类型则仅能表示大于等于0的值。

类型int、short、long和long long都是带符号的,在类型名前添加unsigned就可以得到无符号类型,例如unsigned long。类型unsigned int可以缩写为unsigned。

与其他整型不同,字符型被分为了三种:char、signed char和unsigned char。特别需要注意的是:类型char和类型signed char并不一样。尽管字符型有三种,但字符的表现形式只有两种:带符号的和无符号的。类型char实际上会表现为上述两种形式中的一种,具体是哪种由编译器决定。

无符号类型中所有比特都用来存储值,例如,8比特的unsigned char可以表示0至255区间内的值。

C++标准并没有规定带符号类型应如何表示,但是约定了在表示范围内正值和负值的量应该平衡。因此,8比特的signed char理论上应该可以表示-127至127区间内的值,大多数现代计算机将实际的表示范围定为-128~127。

建议:如何选择类型

  • 当明确知晓数值不可能为负时,选用无符号类型。
  • 使用int执行整数运算。在实际应用中,short常常显得太小而long一般和int有一样的尺寸。如果你的数值超过了int的表示范围,选用long long。
  • 在算术表达式中不要使用char或bool,只有在存放字符或布尔值时才使用它们。因为类型char在一些机器上是有符号的,而在另一些机器上又是无符号的,所以如果使用char进行运算特别容易出问题。如果你需要使用一个不大的整数,那么明确指定它的类型时signed char或者unsigned char。
  • 执行浮点数运算选用double,这是因为float通常精度不够而且双精度浮点数和单精度浮点数的计算代价相差无几。事实上,对于某些机器来说,双精度运算甚至比单精度还快。long double提供的精度在一般情况下是没有必要的,况且它带来的运行时消耗也不容忽视。

2.1.2 类型转换

对象的类型定义了对象能包含的数据和能参与的运算,其中一种运算被大多数类型支持,就是将对象从一种给定的类型转换(convert)为另一种相关类型。

当在程序的某处我们使用了一种类型而其实对象应该取另一种类型时,程序会自动进行类型转换

类型所能表示的值的范围决定了转换的过程:

  • 当把一个非布尔类型的算术值赋给布尔类型时,初始值为0则结果为false,否则结果为true。
  • 当把一个布尔值赋给非布尔类型时,初始值为false则结果为0,初始值为true则结果为1。
  • 当把一个浮点数赋给整数类型时,结果将仅保留浮点数中小数点之前的部分。
  • 当把一个整数值赋给浮点类型时,小数部分记为0。如果该整数所占的空间超过了浮点类型的容量,精度可能有损失。
  • 当赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数。
  • 当赋给带符号类型一个超出它表示范围的值时,结果是未定义的(undefined)。

含有无符号类型的表达式

unsigned u = 10;
int i = -42;
std::cout << i + i << std::endl;//输出-84
std::cout << u + i << std::endl;//如果int占32位,输出4294967264

当一个算术表达式中既有无符号数又有有符号数时,有符号数会转换成无符号数,将负数转换成无符号数类似于直接给无符号数赋一个负值,结果等于这个负数加上无符号数对所能表示的最大值的模。

当从无符号数中减去一个值时,不管这个值是不是无符号数,我们都必须确保结果不能是一个负值:

unsigned u1 = 42, u2 = 10;
std::cout << u1 - u2 << std::endl;//正确:输出32
std::cout << u2 - u1 << std::endl;//正确:不过,结果是取模后的值

提示:切勿混用带符号类型和无符号类型

如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数会自动地转换成无符号数。例如,在一个形如a*b的式子中,如果a=-1,b=1,而且a和b都是int,则表达式的值显然为-1。然而,如果a是int,而b是unsigned,则结果须视在当前机器上int所占位数而定。在我们的环境里,结果是4294967295。

2.1.3 字面值常量

一个形如42的值被称作字面值常量(literal)。每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型。

整型和浮点型字面值

我们可以将整型字面值写作十进制数、八进制数或十六进制数的形式。以0开头的整数代表八进制数,以0x或0X开头的代表十六进制数。

整型字面值具体的数据类型由它的值和符号决定。默认情况下,十进制字面值是带符号数,八进制和十六进制字面值既可能是带符号的也可能是无符号的。

十进制字面值的类型是int、long和long long中尺寸最小的那个(例如,三者当中最小的是int),当然前提是这种类型要能容纳下当前的值。八进制和十六进制字面值的类型是能容纳其数值的int、unsigned int、long、unsigned long、long long和unsigned long long中的尺寸最小者。如果一个字面值连与之关联的最大的数据类型都放不下,将产生错误。类型short没有对应的字面值。

尽管整型字面值可以存储在带符号数据类型中,但严格来说,十进制字面值不会是负数。如果我们使用了一个形如-42的负十进制字面值,那个符号并不在字面值之内,它的作用仅仅是对字面值取负值而已。

浮点型字面值表现为一个小数或以科学计数法表示的指数,其中指数部分用E或e标识:

3.14159 	3.14159E0 	0. 	0e0 	.001

默认的,浮点型字面值是一个double。

字符和字符串字面值

由单引号括起来的一个字符称为char型字面值,双引号括起来的零个或多个字符则构成字符串型字面值。

'a'	//字符字面值
"Hello world" //字符串字面值

如果两个字符串字面值位置紧邻且仅由空格、缩进和换行符分隔,则它们实际上是一个整体。当书写的字符串字面值比较长,写在一行里不太合适时,就可以采用分开书写的方式:

//分多行书写的字符串字面值
std::cout << "a really, really long string literal "
    "that spans two lines " << std::endl;

转义序列

有两类字符程序员不能直接使用:一类是不可打印(nonprintable)的字符,如退格或其他控制字符,因为它们没有可视的图符;另一类是在C++语言中有特殊含义的字符(单引号、双引号、问号、反斜线)。在这些情况下需要用到转义序列(escape sequence),转义序列均以反斜线作为开始,C++语言规定的转义序列包括:

image-20221115141252148

我们也可以使用泛化的转义序列,其形式是\x后紧跟1个或多个十六进制数字,或者\后紧跟1个、2个或3个八进制数字,其中数字部分表示的是字符对应的数值。假设使用的是Latin-1字符集,以下是一些示例:

\7 (响铃) 	\12(换行符) \40(空格)
\0 (空字符)   \115(字符M)  \x4d(字符M)

指定字面值的类型

通过添加表2.2中所列的前缀和后缀,可以改变整型、浮点型和字符型字面值的默认类型。

L'a'		// 宽字符型字面值,类型是 wchar_t
u8"hi!"		// utf-8字符串字面值(utf-8用8位编码一个Unicode字符)
42ULL		// 无符号整型字面值,类型是unsigned long long
1E-3F		// 单精度浮点型字面值,类型是 float
3.14159L	// 扩展精度浮点型字面值,类型是 long double
image-20221115142732715

对于一个整型字面值来说,我们能分别指定它是否带符号以及占用多少空间。

布尔字面值和指针字面值

true和false是布尔类型的字面值。

nullptr是指针字面值。

2.2 变量

变量提供一个具名的、可供程序操作的存储空间。C++中的每个变量都有其数据类型,数据类型决定着变量所占内存空间的大小和布局方式、该空间能存储的值的范围,以及变量能参与的运算。对C++程序员来说,“变量(variable)”和“对象(object)”一般可以互换使用。

2.2.1 变量定义

变量定义的基本形式是:首先是类型说明符(type specifier),随后紧跟由一个或多个变量名组成的列表,其中变量名以逗号分隔,最后以分号结束。列表中每个变量名的类型都由类型说明符指定,定义时还可以为一个或多个变量赋初值:

int sum = 0, value, units_sold = 0; //sum、value和units_sold都是int,sum和units_sold初值为0

初始值

当对象在创建时获得了一个特定的值,我们说这个对象被初始化(initialized)了。用于初始化变量的值可以是任意复杂的表达式。当一次定义了两个或多个变量时,对象的名字随着定义也就马上可以使用了。因此在同一条定义语句中,可以用先定义的变量值取初始化后定义的其他变量。

//正确
double price = 109.99, discount = price * 0.16;
//正确
double salePrice = applyDiscount(price, discount);

初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而以一个新值来替代。

列表初始化

C++语言定义了初始化的好几种不同形式,这也是初始化问题复杂性的一个体现。例如,要想定义一个名为units_sold的int变量并初始化为0,以下的4条语句都可以做到这一点:

int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);

作为C++11新标准的一部分,用花括号来初始化变量得到了全面应用,而在此之前,这种初始化的形式仅在某些受限的场合下才能使用。这种初始化的形式被称为列表初始化(list initialization)

当用于内置类型的变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化且初始值存在丢失信息的风险,则编译器将报错:

long double ld = 3.1415926536;
int a{ld}, b = {ld};	//错误:转换未执行,因为存在丢失信息的危险
int c(ld), d = ld;		//正确:转换执行,且确实丢失了部分值

默认初始化

如果定义变量时没有指定初值,则变量被默认初始化(default initialized),此时变量被赋予了“默认值”。默认值到底是什么由变量类型决定,同时定义变量的位置也会对此有影响。

如果是内置类型的变量未被显示初始化,它的值由定义的位置决定。定义于任何函数体之外的变量被初始化为0。定义在函数体内部的内置类型变量将不被初始化(uninitialized)。一个未被初始化的内置类型变量的值是未定义的,如果试图拷贝或以其他形式访问此类值将引发错误。

每个类各自决定其初始化对象的方式。而且,是否允许不经初始化就定义对象也由类自己决定。如果类允许这种行为,它将决定对象的初始值到底是什么。

绝大多数类都支持无须显式初始化而定义对象,这样的类提供了一个合适的默认值。例如,string类规定如果没有指定初值则生成一个空串:

std::string empty;	//empty非显示地初始化为一个空串
Sales_item item;	//被默认初始化的Sales_item对象

定义于函数体内的内置类型的对象如果没有初始化,则其值未定义。类的对象如果没有显式地初始化,则其值由类确定。

建议初始化每一个内置类型的变量。虽然并非必须这么做,但如果我们不能确保初始化后程序安全,那么这么做不失为一种简单可靠的方法

2.2.2 变量声明和定义的关系

为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译(separate compilation)机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。

为了支持分离式编译,C++语言将声明和定义区分开来。声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义(definition)负责创建与名字关联的实体。

变量声明规定了变量的类型和名字,在这一点上定义与之相同。但是除此之外,定义还申请存储空间,也可能会为变量赋一个初始值。

如果想声明一个变量而非定义它,就在变量名前添加关键字extern,而且不要显式地初始化变量:

extern int i;	//声明i而非定义i
int j;			//声明并定义j

任何包含了显式初始化的声明即成为定义。我们能给由extern关键字标记的变量赋一个初始值,但是这么做也就抵消了extern的作用。extern语句如果包含初始值就不再是声明,而变成定义:

extern double pi = 3.1416; //定义

在函数体内部,如果试图初始化一个由extern关键字标记的变量,将引发错误。

变量能且只能被定义一次,但是可以被多次声明。

关键概念:静态类型

C++是一种静态类型(statically typed)语言,其含义是在编译阶段检查类型。其中,检查类型的过程称为类型检查(type checking)

2.2.3 标识符

C++的标识符(identifier)由字母、数字和下划线组成,其中必须以字母和下划线开头。标识符的长度没有限制,但是对大小写字母敏感:

//定义4个不同的int变量
int somename, someName, SomeName, SOMENAME;

如表2.3和表2.4所示,C++语言保留了一些名字供语言本身使用,这些名字不能被用作标识符。

同时,C++也为标准库保留了一些名字。用户自定义的标识符中不能连续出现两个下划线,也不能以下划线紧连大写字母开头。此外,定义在函数体外的标识符不能以下划线开头

image-20221115163639582 image-20221115163652818

2.2.4 名字的作用域

作用域(scope)是程序的一部分,在其中名字有其特定的含义。C++语言中大多数作用域都以花括号分隔。

同一个名字在不同的作用域中可能指向不同的实体。名字的有效区域始于名字的声明语句,以声明语句所在的作用域末端为结束。

一个典型的示例:

#include <iostream>
int main()
{
    int sum = 0;
    for(int val = 1; val <= 10; ++val)
        sum += val;
    std::cout << sum << std::endl;
    return 0;
}

名字main定义于所有花括号之外,它和其他大多数定义在函数体之外的名字一样拥有全局作用域(global scope)。一旦声明之后,全局作用域内的名字在整个程序的范围内都可使用。名字sum定义于main函数所限定的作用域之内,从声明sum开始直到main函数结束为止都可以访问它,但是出了main函数所在的块就无法访问了,因此说变量sum拥有块作用域(block scope)。名字val定义于for语句内,在for语句之内可以访问val,但是在main函数的其他部分就不能访问它了。

嵌套的作用域

作用域能彼此包含,被包含(或者说被嵌套)的作用域称为内层作用域(inner scope),包含着别的作用域的作用域称为外层作用域(outer scope)

作用域中一旦声明了某个名字,它所嵌套着的所有作用域中都能访问该名字。同时,允许在内层作用域中重新定义外层作用域已有的名字:

#include <iostream>
int reused = 42;
int main()
{
    int unique = 0;
    //使用全局变量,输出42 0
    std::cout << reused << " " << unique << std::endl;
    int reused = 0;//新建局部变量,覆盖了全局变量
    //使用局部变量,输出0 0
    std::cout << reused << " " << unique << std::endl;
    //显式地访问全局变量reused;输出 42 0
    std::cout << ::reused << " " << unique << std::endl;
    return 0;
}

如果函数有可能用到某全局变量,则不宜再定义一个同名的局部变量。

2.3 符合类型

复合类型(compound type)是指基于其他类型定义的类型。C++语言有几种复合类型,本章将介绍其中的两种:引用和指针。

与我们已经掌握的变量声明相比,定义复合类型的变量要复杂很多。2.2节提到,一条简单的声明语句由一个数据类型和紧随其后的一个变量名列表组成。其实更通用的描述是,一条声明语句由一个基本数据类型(base type)和紧随其后的一个声明符(declarator)列表组成。每个声明符命名了一个变量并指定该变量为与基本数据类型有关的某种类型。

目前为止,我们所接触的声明语句中,声明符其实就是变量名,此时变量的类型也就是声明的基本数据类型。其实还可能有更复杂的声明符,它基于基本数据类型得到更复杂的类型,并把它指定给变量。

2.3.1 引用

C++11中新增了一种引用:所谓的“右值引用(rvalue reference)”,这种引用主要用于内置类。严格来说,当我们使用术语“引用(reference)”时,指的其实是“左值引用(lvalue reference)”。

引用(reference)为对象起了另外一个名字,引用类型引用(refers to)另外一种类型。通过将声明符写成&d的形式来定义引用类型,其中d是声明的变量名:

int ival = 1024;
int &refVal = ival;		//refVal指向ival(是ival的另一个名字)
int &refVal2;			//报错:引用必须被初始化

一般在初始化变量时,初始值会被拷贝到新建的对象中。然而定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另一个对象,因此引用必须初始化。

引用即别名

引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。

定义一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行的:

refVal = 2;			//把2赋给refVal指向的对象,此处即是赋给了ival
int ii = refVal;	//与ii = ival执行结果一样

为引用赋值,实际上是把值赋给了与引用绑定的对象。获得引用的值,实际上是获取了与引用绑定的对象的值。同理,以引用作为初始值,实际上是以与引用绑定的对象作为初始值:

//正确:refVal3绑定到了那个与refVal绑定的对象上,这里就是绑定到ival上
int &refVal3 = refVal;
//利用与refVal绑定的对象的值初始化变量i
int i = refVal;//正确:i被初始化为ival的值

因为引用本身不是一个对象,所以不能定义引用的引用。

引用的定义

允许在一条语句中定义多个引用,其中每个引用标识符都必须以符号&开头:

int i = 1024, i2 = 2048;
int &r = i, r2 = i2;		//r是一个引用,与i绑定在一起,r2是int
int i3 = 1024, &ri = i3;	//i3是int,ri是一个引用,与i3绑定在一起
int &r3 = i3, &r4 = i2;		//r3和r4都是引用

除了2.4.1节和15.2.3节将要介绍的两种例外情况,其他所有引用的类型都要与之绑定的对象严格匹配。而且,引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起,相关原因将在2.4.1节详述:

int &refVal4 = 10;		//错误:引用类型的初始值必须是一个对象
double dval = 3.14;  	
int &refVal5 = dval;	//错误:此处引用类型的初始值必须是int型对象

2.3.2 指针

指针(pointer)是“指向(point to)”另外一种类型的复合类型。与引用类似,指针也实现了对其他对象的间接访问。然而指针与引用相比又有很多不同点。其一,指针本身就是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象。其二,指针无须在定义时赋初值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。

定义指针类型的方法将声明符写成*d 的形式,其中d是变量名。如果在一条语句中定义了几个指针变量,每个变量前面都必须有符号*:

int *ipl,*ip2;	//ip1和ip2都是指向int型对象的指针
double dp,*dp2; //dp2是指向double型对象的指针,dp是double型对象

获取对象的地址

指针存放某个对象的地址,要想获取该地址,需要使用取地址符(操作符&)

int ival = 42;
int *p = &ival; //p存放变量ival的地址,或者说p是指向变量ival的指针

第二条语句把p定义为一个指向int的指针,随后初始化p令其指向名为ival的int对象。因为引用不是对象,没有实际地址,所以不能定义指向引用的指针。
除了2.4.2节和15.2.3节将要介绍的两种例外情况,其他所有指针的类型都要和它所指向的对象严格匹配:

double dval;
double *pd = &dval; //正确:初始值是double型对象的地址
double *pd2 = pd;	//正确:初始值是指向double对象的指针

int *pi = pd;		//错误:指针pi的类型和pd的类型不匹配
pi = &dval;			//错误:试图把double型对象的地址赋给int型指针

因为在声明语句中指针的类型实际上被用于指定它所指向对象的类型,所以二者必须匹配。如果指针指向了一个其他类型的对象,对该对象的操作将发生错误。

指针值

指针的值(即地址)应属下列4种状态之一:

  1. 指向一个对象
  2. 指向紧邻对象所占空间的下一个位置
  3. 空指针,意味着指针没有指向任何对象
  4. 无效指针,也就是上述情况之外的其他值。

试图拷贝或以其他方式访问无效指针的值都将引发错误。编译器并不负责检查此类错误,这一点和试图使用未经初始化的变量是一样的。访问无效指针的后果无法预计,因此程序员必须清楚任意给定的指针是否有效。
尽管第2种和第3种形式的指针是有效的,但其使用同样受到限制。显然这些指针没有指向任何具体对象,所以试图访问此类指针(假定的)对象的行为不被允许。如果这样做了,后果也无法预计。

利用指针访问对象

如果指针指向了一个对象,则允许使用解引用符(操作符*)来访问该对象:

int ival = 42;
int*p = &ival; //p存放着变量ival的地址,或者说p是指向变量ival的指针
cout << *p;   // 由符号*得到指针p所指的对象,输出42

对指针解引用会得出所指的对象,因此如果给解引用的结果赋值,实际上也就是给指针所指的对象赋值:

*p = 0;		//由符号*得到指针p所指的对象,即可经由p为变量ival赋值
cout << *p; //输出0

如上述程序所示,为*p赋值实际上是为p所指的对象赋值。

解引用操作仅适用于那些确实指向了某个对象的有效指针

关键概念:某些符号有多重含义

像&和*这样的符号,既能用作表达式里的运算符,也能作为声明的一部分出现,符号的上下文决定了符号的意义:

inti = 42;
int &r = i;		//&紧随类型名出现,因此是声明的一部分,r是一个引用
int *p;			//*紧随类型名出现,因此是声明的一部分,P是一个指针
p = &i;			//&出现在表达式中,是一个取地址符
*p = i;			//*出现在表达式中,是一个解引用符
int &r2 = *p;	//&是声明的一部分,*是一个解引用符

在声明语句中,&和*用于组成复合类型;在表达式中,它们的角色又转变成运算符。在不同场景下出现的虽然是同一个符号,但是由于含义截然不同,所以我们完全可以把它当作不同的符号来看待。

空指针

空指针(null pointer)不指向任何对象,在试图使用一个指针之前代码可以首先检查它是否为空。以下列出几个生成空指针的方法:

int *p1 = nullptr;	//等价于int *p1 = 0;
int *p2 = 0;		// 直接将p2初始化为字面常量0
// 需要首先#include cstdlib
int *p3 = NULL;		//等价于int *p3=0;

得到空指针最直接的办法就是用字面值 nullptr 来初始化指针,这也是C++11新标准刚刚引入的一种方法。nullptr是一种特殊类型的字面值,它可以被转换成(参见2.1.2节)任意其他的指针类型。另一种办法就如对p2的定义一样,也可以通过将指针初始化为字面值0来生成空指针。
过去的程序还会用到一个名为NULL的预处理变量(preprocessor variable)来给指针赋值,这个变量在头文件cstdlib中定义,它的值就是0。
2.6.3节将稍微介绍一点关于预处理器的知识,现在只要知道预处理器是运行于编译过程之前的一段程序就可以了。预处理变量不属于命名空间 std,它由预处理器负责管理,因此我们可以直接使用预处理变量而无须在前面加上std::。
当用到一个预处理变量时,预处理器会自动地将它替换为实际值,因此用NULL初始化指针和用0初始化指针是一样的。在新标准下,现在C++程序最好使用nullptr,同时尽量避免使用NULL。

把int变量直接赋给指针是错误的操作,即使int变量的值恰好等于0也不行。

int zero = 0;
pi = zero;	//错误:不能把int变量直接赋给指针

赋值和指针

指针和引用都能提供对其他对象的间接访问,然而在具体实现细节上二者有很大不同,其中最重要的一点就是引用本身并非一个对象。一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。
指针和它存放的地址之间就没有这种限制了。和其他任何变量(只要不是引用)一样,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象:

int i = 42;
int *pi = 0;	//pi被初始化,但没有指向任何对象
int *pi2 = &i;	//pi2被初始化,存有i的地址
int *pi3;		//如果pi3定义于块内,则pi3的值是无法确定的
pi3 = pi2;		//pi3和pi2指向同一个对象i
pi2 = 0;		//现在pi2不指向任何对象了

其他指针操作

只要指针拥有一个合法值,就能将它用在条件表达式中。和采用算术值作为条件(参见2.1.2节)遵循的规则类似,如果指针的值是0,条件取false:

int ival = 1024;
int *pi = 0;		//pi合法,是一个空指针
int *pi2 = &ival;	//pi2是一个合法的指针,存放着ival的地址
if (pi)				//pi的值是0,因此条件的值是false
	//...
if (pi2)			//pi2指向ival,因此它的值不是0,条件的值是true
	//...

任何非0指针对应的条件值都是true。

对于两个类型相同的合法指针,可以用相等操作符(==)或不相等操作符 (!=)来比较它们,比较的结果是布尔类型。如果两个指针存放的地址值相同,则它们相等;反之它们不相等。这里两个指针存放的地址值相同(两个指针相等)有三种可能:它们都为空、都指向同一个对象,或者都指向了同一个对象的下一地址。需要注意的是,一个指针指向某对象,同时另一个指针指向另外对象的下一地址,此时也有可能出现这两个指针值相同的情况,即指针相等。
因为上述操作要用到指针的值,所以不论是作为条件出现还是参与比较运算,都必须使用合法指针,使用非法指针作为条件或进行比较都会引发不可预计的后果。

void 指针*

void*是一种特殊的指针类型,可用于存放任意对象的地址。一个void*指针存放着一个地址,这一点和其他指针类似。不同的是,我们对该地址中到底是个什么类型的对象并不了解:

double obj = 3.14,*pd = &obj;
					//正确:void*能存放任意类型对象的地址
void *pv = &obj;	// obj可以是任意类型的对象
pv = pd;			// pv可以存放任意类型的指针

利用void*指针能做的事儿比较有限:拿它和别的指针比较、作为函数的输入或输出,或者赋给另外一个void*指针。不能直接操作void*指针所指的对象,因为我们并不知道这个对象到底是什么类型,也就无法确定能在这个对象上做哪些操作。
概括说来,以void*的视角来看内存空间也就仅仅是内存空间,没办法访问内存空间中所存的对象,关于这点将在19.1.1节有更详细的介绍,4.11.3节将讲述获取void*指针所存地址的方法。

2.3.3 理解复合类型的声明

如前所述,变量的定义包括一个基本数据类型(base type)和一组声明符。在同一条定义语句中,虽然基本数据类型只有一个,但是声明符的形式却可以不同。也就是说,一条定义语句可能定义出不同类型的变量:

//i是一个int型的数,p是一个int 型指针,r是一个int型引用
int i = 1024,*p = &i,&r = i;

定义多个变量

经常有一种观点会误以为,在定义语句中,类型修饰符(*或&)作用于本次定义的全部变量。造成这种错误看法的原因有很多,其中之一是我们可以把空格写在类型修饰符和变量名中间:

int* p;		//合法但是容易产生误导

我们说这种写法可能产生误导是因为int*放在一起好像是这条语句中所有变量共同的类型一样。其实恰恰相反,基本数据类型是int而非int*。*仅仅是修饰了p而已,对该声明语句中的其他变量,它并不产生任何作用:

int* p1, p2;	//p1是指向int的指针,p2是int

涉及指针或引用的声明,一般有两种写法。第一种把修饰符和变量标识符写在一起:

int *p1, *p2;	//p1和p2都是指向int的指针

这种形式着重强调变量具有的复合类型。第二种把修饰符和类型名写在一起,并且每条语句只定义一个变量:

int* p1;
int* p2;

这种形式着重强调本次声明定义了一种复合类型。

指向指针的指针

一般来说,声明符中修饰符的个数并没有限制。当有多个修饰符连写在一起时,按照其逻辑关系详加解释即可。以指针为例,指针是内存中的对象,像其他对象一样也有自己的地址,因此允许把指针的地址再存放到另一个指针当中。
通过*的个数可以区分指针的级别。也就是说,**表示指向指针的指针,***表示指向指针的指针的指针,以此类推:

int ival = 1024;
int *pi = &ival;	//pi指向一个int型的数
int **ppi = &pi;	//ppi指向一个int型的指针

指向指针的引用

引用本身不是一个对象,因此不能定义指向引用的指针。但指针是对象,所以存在对指针的引用:

int i = 42;
int *p;			//p是一个int型指针
int *&r = p;	//r是一个对指针p的引用
r = &i;			//r引用了一个指针,因此给赋值&i就是令p指向i
*r = 0;			//解引用r得到i,也就是p指向的对象,将i的值改为0

要理解r的类型到底是什么,最简单的办法是从右向左阅读的定义。离变量名最近的符号(此例中是&r的符号&)对变量的类型有最直接的影响,因此r是一个引用。声明符的其余部分用以确定r引用的类型是什么,此例中的符号*说明r引用的是一个指针。最后声明的基本数据类型部分指出r引用的是一个int指针。

面对一条比较复杂的指针或引用的声明语句时,从右向左阅读有助于弄清楚它的真实含义。

2.4 const限定符

有时我们希望定义这样一种变量,它的值不能被改变。例如,用一个变量来表示缓冲区的大小。使用变量的好处是当我们觉得缓冲区大小不再合适时,很容易对其进行调整。另一方面,也应随时警惕防止程序一不小心改变了这个值。为了满足这一要求,可以用关键字const对变量的类型加以限定:

const int bufSize = 512; //输入缓冲区大小

这样就把bufSize定义成了一个常量。任何试图为bufSize赋值的行为都将引发错误:

bufSize = 512;		//错误:试图向const对象写值

因为const对象一旦创建后其值就不能再改变,所以const对象必须初始化。一如既往,初始值可以是任意复杂的表达式:

const int i = get_size();	//正确:运行时初始化
const int j = 42;			//正确:编译时初始化
const int k;				//错误:k是一个未经初始化的常量

初始化和const

正如之前反复提到的,对象的类型决定了其上的操作。与非const类型所能参与的操作相比,const类型的对象能完成其中大部分,但也不是所有的操作都适合。主要的限制就是只能在const类型的对象上执行不改变其内容的操作。例如,const int和普通的int一样都能参与算术运算,也都能转换成一个布尔值,等等。
在不改变const对象的操作中还有一种是初始化,如果利用一个对象去初始化另外一个对象,则它们是不是const都无关紧要:

int i = 42;
const int ci = i;	//正确:i的值被拷贝给了ci
int j = ci;			//正确:ci的值被拷贝给了j

默认状态下,const对象仅在文件内有效

当以编译时初始化的方式定义一个const对象时,编译器将在编译过程中把用到该变量的地方都替换成对应的值

为了执行上述替换,编译器必须知道变量的初始值。如果程序包含多个文件,则每个用了const对象的文件都必须得能访问到它的初始值才行。要做到这一点,就必须在每一个用到变量的文件中都有对它的定义。为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的const变量时,其实等同于在不同文件中分别定义了独立的变量。

某些时候有这样一种const变量,它的初始值不是一个常量表达式,但又确实有必要在文件间共享。这种情况下,我们不希望编译器为每个文件分别生成独立的变量。相反,我们想让这类const对象像其他(非常量)对象一样工作,也就是说,只在一个文件中定义const,而在其他多个文件中声明并使用它。

解决的办法是,对于const变量不管是声明还是定义都添加extern关键字,这样只需定义一次就可以了:

//file_1.cc定义并初始化了一个常量,该常量能被其他文件访问
extern const int bufSize = fcn();
//file_1.h头文件
extern const int bufSize;	//与file_1.cc中定义的bufSize是同一个

如上述程序所示,file_1.cc定义并初始化了bufSize。因为这条语句包含了初始值,所以它(显然)是一次定义。然而,因为bufSize是一个常量,必须用extern加以限定使其被其他文件使用。

file_1.h头文件中的声明也由extern做了限定,其作用是指明bufSize并非本文件所独有,它的定义将在别处出现。

如果想在多个文件之间共享const对象,必须在变量的定义之前添加extern关键字。

2.4.1 const的引用

可以把引用绑定到const对象上,就像绑定到其他对象上一样,我们称之为对常量的引用(reference to const)。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象:

const int ci = 1024;
const int &r1 = ci;		//正确:引用及其对应的对象都是常量
r1 = 42;				//错误:r1是对常量的引用
int &r2 = ci;			//错误:试图让一个非常量引用指向一个常量对象

因为不允许直接为ci赋值,当然也就不能通过引用去改变ci。因此,对r2的初始化是错误的。假设该初始化合法,则可以通过r2来改变它引用对象的值,这显然是不正确的。

术语:常量引用是对const的引用

C++程序员们经常把词组“对const的引用”简称为“常量引用”,这一简称还是挺靠谱的,不过前提是你得时刻记得这就是个简称而已。
严格来说,并不存在常量引用。因为引用不是一个对象,所以我们没法让引用本身恒定不变。事实上,由于C++语言并不允许随意改变引用所绑定的对象,所以从这层意义上理解所有的引用又都算是常量。引用的对象是常量还是非常量可以决定其所能参与的操作,却无论如何都不会影响到引用和对象的绑定关系本身。

初始化和对const的引用

2.3.1节提到,引用的类型必须与其所引用对象的类型一致,但是有两个例外。第一种例外情况就是在初始化变量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成(参见2.1.2节)引用的类型即可。尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是个一般表达式:

int i = 42;
const int &r1 = i;		//允许将const int&绑定到一个普通int对象上
const int &r2 = 42;		//正确:r1是一个常量引用
const int &r3 = r1 * 2;	//正确:r3是一个常量引用
int &r4 = r1 * 2;		//错误:r4是一个普通的非常量引用

要想理解这种例外情况的原因,最简单的办法是弄清楚当一个常量引用被绑定到另外一种类型上时到底发生了什么:

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

此处 ri 引用了一个int型的数。对 ri 的操作应该是整数运算,但 dval 却是一个双精度浮点数而非整数。因此为了确保让 ri 绑定一个整数,编译器把上述代码变成了如下形式:

const int temp = dval;	//由双精度浮点数生成一个临时的整型变量
const int &ri = temp;	//让ri绑定这个临时量

在这种情况下,ri绑定了一个临时量(temporary)对象。所谓临时量对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。C++程序员们常常把临时量对象简称为临时量。
接下来探讨当 ri 不是常量时,如果执行了类似于上面的初始化过程将带来什么样的后果。如果 ri 不是常量,就允许对 ri 赋值,这样就会改变 ri 所引用对象的值。注意,此时绑定的对象是一个临时量而非 dval。程序员既然让 ri 引用 dval,就肯定想通过 ri 改变 dval 的值,否则干什么要给 ri 赋值呢?如此看来,既然大家基本上不会想着把引用绑定到临时量上,C++语言也就把这种行为归为非法

对const的引用可能引用一个并非const的对象

必须认识到,常量引用仅对引用可参与的操作做出了限定,对于引用的对象本身是不是一个常量未作限定。因为对象也可能是个非常量,所以允许通过其他途径改变它的值:

int i = 42;
int &r1 = i;		//引用r1绑定对象i
const int &r2 = i;	//r2也绑定对象i,但是不允许通过r2修改i的值
r1 = 0;				//r1并非常量,i的值修改为0
r2 = 0;				//错误:r2是一个常量引用

r2绑定(非常量)整数i是合法的行为。然而,不允许通过r2修改i的值。尽管如此,i的值仍然允许通过其他途径修改,既可以直接给i赋值,也可以通过像r1一样绑定到i的其他引用来修改。

2.4.2 指针和const

与引用一样,也可以令指针指向常量或非常量。类似于常量引用(参见 2.4.1 节),指向常量的指针(pointer to const)不能用于改变其所指对象的值。要想存放常量对象的地址,只能使用指向常量的指针:

const double pi = 3.14;
double *ptr = &pi;			//错误:ptr是一个普通指针
const double *cptr = &pi;	//正确:cptr可以指向一个双精度常量
*cptr = 42;					//错误:不能给*cptr赋值

2.3.2节提到,指针的类型必须与其所指对象的类型一致,但是有两个例外。第一种例外情况是允许令一个指向常量的指针指向一个非常量对象:

double dval = 3.14;		//dval是一个双精度浮点数,它的值可以改变
cptr = &dval;			//正确:但是不能通过cptr改变dval的值

和常量引用一样,指向常量的指针也没有规定其所指的对象必须是一个常量。所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定那个对象的值不能通过其他途径改变。

试试这样想吧:所谓指向常量的指针或引用,不过是指针或引用“自以为是”罢了,它们觉得自己指向了常量,所以自觉地不去改变所指对象的值。

指针是对象而引用不是,因此就像其他对象类型一样,允许把指针本身定为常量常量指针 (const pointer)必须初始化,而且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。把*放在 const 关键字之前用以说明指针是一个常量,这样的书写形式隐含着一层意味,即不变的是指针本身的值而非指向的那个值:

int errNumb = 0;
int *const curErr = &errNumb;		//curErr将一直指向errNumb
const double pi = 3.14159;
const double *const pip = &pi;		//pip是一个指向常量对象的常量指针

如同2.3.3节所讲的,要想弄清楚这些声明的含义最行之有效的办法是从右向左阅读。此例中,离curErr 最近的符号是 const,意味着 curErr 本身是一个常量对象,对象的类型由声明符的其余部分确定。声明符中的下一个符号是*,意思是 curErr是一个常量指针。最后,该声明语句的基本数据类型部分确定了常量指针指向的是一个int 对象。与之相似,我们也能推断出,pip 是一个常量指针,它指向的对象是一个双精度浮点型常量。
指针本身是一个常量并不意味着不能通过指针修改其所指对象的值,能否这样做完全依赖于所指对象的类型。例如,pip 是一个指向常量的常量指针,则不论是 pip 所指的对象值还是pip自己存储的那个地址都不能改变。相反的,curErr 指向的是一个一般的非常量整数,那么就完全可以用curErr去修改errNumb的值:

*pip = 2.72;		//错误:pip是一个指向常量的指针
					//如果curErr所指的对象(也就是errNumb)的值不为0
if (*curErr) {
	errorHandler();
    *curErr = 0;	//正确:把curErr所指的对象的值重置
}

2.4.3 顶层const

如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量以及指针所指的是不是一个常量就是两个相互独立的问题。用名词顶层const(top-level const)表示指针本身是个常量,而用名词底层const (low-level const)表示指针所指的对象是一个常量。
更一般的,顶层 const 可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层 const 则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层 const 也可以是底层 const,这一点和其他类型相比区别明显:

int i = 0;
int *const p1 = &i;		//不能改变p1的值,这是一个顶层const
const int ci = 42;		//不能改变ci的值,这是一个顶层const
const int *p2 = &ci;	//允许改变p2的值,这是一个底层const
const int *const p3 = p2;	//靠右的const是顶层const,靠左的是底层const
const int &r = ci;		//用于声明引用的const都是底层const

当执行对象的拷贝操作时,常量是顶层const还是底层const区别明显。其中,顶层const不受什么影响:

i = ci;		//正确:拷贝ci的值,ci是一个顶层const,对此操作无影响
p2 = p3;	//正确:p2和p3指向的对象类型相同,p3顶层const的部分不影响

执行拷贝操作并不会改变被拷贝对象的值,因此,拷入和拷出的对象是否是常量都没什么影响。
另一方面,底层 const 的限制却不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层 const 资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行:

int *p = p3;		//错误:p3包含底层const的定义,而p没有
p2 = p3;			//正确:p2和p3都是底层const
p2 = &i;			//正确:int*能转换成const int*
int &r = ci;		//错误:普通的int&不能绑定到int常量上
const int &r2 = i;	//正确:const int&可以绑定到一个普通int上

p3既是顶层const也是底层const,拷贝p3时可以不在乎它是一个顶层const,但是必须清楚它指向的对象得是一个常量。因此,不能用p3去初始化p,因为p指向的是一个普通的(非常量)整数。另一方面,p3的值可以赋给p2,是因为这两个指针都是底层const,尽管p3同时也是一个常量指针(顶层const),仅就这次赋值而言不会有什么影响。

2.4.4. constexpr和常量表达式

常量表达式(const expression)是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式。后面将会提到,C++语言中有几种情况下是要用到常量表达式的。

一个对象(或表达式)是不是常量表达式由它的数据类型和初始值共同决定,例如:

const int max_files = 20;			//maxfiles是常量表达式
const int limit = max_files + 1;	//limit是常量表达式
int staff_size = 27;				//staff_size不是常量表达式
const int sz = get_size();			//sz不是常量表达式

尽管 staff_size的初始值是个字面值常量,但由于它的数据类型只是一个普通 int 而非 const int,所以它不属于常量表达式。另一方面,尽管 sz 本身是一个常量,但它的具体值直到运行时才能获取到,所以也不是常量表达式。

constexpr变量

在一个复杂系统中,很难(几乎肯定不能)分辨一个初始值到底是不是常量表达式。当然可以定义一个 const 变量并把它的初始值设为我们认为的某个常量表达式,但在实际使用时,尽管要求如此却常常发现初始值并非常量表达式的情况。可以这么说,在此种情况下,对象的定义和使用根本就是两回事儿。
C++11新标准规定,允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为 constexpr 的变量一定是一个常量,而且必须用常量表达式初始化:

constexpr int mf = 20;			//20是常量表达式
constexpr int limit = mf + 1;	//mf+1是常量表达式
constexpr int sz = size();		//只有当size是一个constexpr函数时才是一条正确的声明语句

尽管不能使用普通函数作为 constexpr 变量的初始值,但是正如6.5.2节将要介绍的,新标准允许定义一种特殊的constexpr函数。这种函数应该足够简单以使得编译时就可以计算其结果,这样就能用constexpr函数去初始化constexpr变量了。

一般来说,如果你认定变量是一个常量表达式,那就把它声明成constexpr类型。

字面值类型

常量表达式的值需要在编译时就得到计算,因此对声明constexpr时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见、容易得到,就把它们称为“字面值类型”(literaltype)
到目前为止接触过的数据类型中,算术类型、引用和指针都属于字面值类型。自定义类 Sales item、IO 库、string 类型则不属于字面值类型,也就不能被定义成constexpr。其他一些字面值类型将在7.5.6节和19.3节介绍。

尽管指针和引用都能定义成constexpr,但它们的初始值却受到严格限制。一个constexpr指针的初始值必须是nullptr或者0,或者是存储于某个固定地址中的对象。

6.1.1节将要提到,函数体内定义的变量一般来说并非存放在固定地址中,因此constexpr 指针不能指向这样的变量。相反的,定义于所有函数体之外的对象其地址固定不变,能用来初始化 constexpr 指针。同样是在6.1.1 节中还将提到,允许函数定义一类有效范围超出函数本身的变量,这类变量和定义在函数体之外的变量一样也有固定地址。因此,constexpr 引用能绑定到这样的变量上,constexpr 指针也能指向这样的变量。

指针和constexpr

必须明确一点,在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关:

const int *p = nullptr;			//p是一个指向整型常量的指针
constexpr int *q = nullptr;		//q是一个指向整数的常量指针

p和q的类型相差甚远,p是一个指向常量的指针,而q是一个常量指针,其中的关键在于constexpr把它所定义的对象置为了顶层const (参见2.4.3节)。
与其他常量指针类似,constexpr 指针既可以指向常量也可以指向一个非常量:

constexpr int *np = nullptr;	//np是一个指向整数的常量指针,其值为空
int j = 0;
constexpr int i = 42;			//i的类型是整型常量
//i和j都必须定义在函数体之外
constexpr const int *p = &i;	//p是常量指针,指向整型常量i
constexpr int *p1 = &j;			//p1是常量指针,指向整数j

2.5 处理类型

2.5.1 类型别名

类型别名(type alias)是一个名字,它是某种类型的同义词。使用类型别名有很多好处,它让复杂的类型名字变得简单明了、易于理解和使用,还有助于程序员清楚地知道使用该类型的真实目的。

声明类型别名有两种方法,传统的方式是使用关键字typedef:

typedef double wages;		//wages是double的同义词
typedef wages base, *p;		//base是double的同义词,p是double*的同义词

其中,关键字 typedef 作为声明语句中的基本数据类型(参见2.3节)的一部分出现。含有 typedef 的声明语句定义的不再是变量而是类型别名。和以前的声明语句样,这里的声明符也可以包含类型修饰,从而也能由基本数据类型构造出复合类型来。

C++11新标准规定了一种新的方法,使用别名声明(alias declaration)来定义类型的别名:

using SI = Sales_item;	//SI是Sales_item的同义词

这种方法用关键词using作为别名声明的开始,其后紧跟别名和等号,其作用是把等号左侧的名字规定成等号右侧类型的别名。

类型别名和类型的名字等价,只要是类型的名字能出现的地方,就能使用类型别名。

指针、常量和类型别名

如果某个类型别名指代的是复合类型或常量,那么把它用到声明语句里就会产生意想不到的后果。例如下面的声明语句用到了类型pstring,它实际上是类型char*的别名:

typedef char *pstring;
const pstring cstr = 0;		//cstr是指向char的常量指针
const pstring *ps;			//ps是一个指针,它的对象是指向char的常量指针

上述两条声明语句的基本数据类型都是const pstring,和过去一样,const 是对给定类型的修饰。pstring 实际上是指向 char 的指针,因此,const pstring 就是指向char的常量指针,而非指向常量字符的指针。

遇到一条使用了类型别名的声明语句时,人们往往会错误地尝试把类型别名替换成它本来的样子,以理解该语句的含义:

const char *cstr = 0;	//是对const pstring cstr的错误理解

再强调一遍:这种理解是错误的。声明语句中用到pstring时,其基本数据类型是指针。可是用char*重写了声明语句后,数据类型就变成了 char,*成为了声明符的一部分。这样改写的结果是,const char 成了基本数据类型。前后两种声明含义截然不同,前者声明了一个指向 char的常量指针,改写后的形式则声明了一个指向const char的指针。

2.5.2 auto类型说明符

编程时常常需要把表达式的值赋给变量,这就要求在声明变量的时候清楚地知道表达式的类型。然而要做到这一点并非那么容易,有时甚至根本做不到。为了解决这个问题,C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。和原来那些只对应一种特定类型的说明符 (比如 double)不同,auto让编译器通过初始值来推算变量的类型。显然,auto定义的变量必须有初始值。

使用auto也能在一条语句中声明多个变量。因为一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始基本数据类型都必须一样:

auto i = 0, *p = &i;		//正确:i是整数、p是整型指针
auto sz = 0, pi = 3.14;		//错误:sz和pi的类型不一致

复合类型、常量和auto

编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。
首先,正如我们所熟知的,使用引用其实是使用引用的对象,特别是当引用被用作初始值时,真正参与初始化的其实是引用对象的值。此时编译器以引用对象的类型作为auto的类型:

int i = 0, &r = i;
auto a = r;		//a是一个整数(r是i的别名,而i是一个整数)

其次,auto一般会忽略掉顶层const(参见2.4.3节),同时底层const则会保留下来,比如当初始值是一个指向常量的指针时:

const int ci = i, &cr = ci;
auto b = ci;	//b是一个整数(ci的顶层const特性被忽略掉了)
auto c = cr;	//c是一个整数(cr是ci的别名,ci本身是一个顶层const)
auto d = &i;	//d是一个整型指针(整数的地址就是指向整数的指针)
auto e = &ci;	//e是一个指向整数常量的指针(对常量对象取地址是一种底层const)

如果希望推断出的auto类型是一个顶层const,需要明确指出

const auto f = ci;	//ci的推演类型是int,f是const int

还可以将引用的类型设为auto,此时原来的初始化规则仍然适用:

auto &g = ci;		//g是一个整型常量引用,绑定到ci
auto &h = 42;		//错误:不能为非常量引用绑定字面值
const auto &j = 42;	//正确:可以为常量引用绑定字面值

设置一个类型为auto的引用时,初始值中的顶层常量属性仍然保留。和往常一样,如果我们给初始值绑定一个引用,则此时的常量就不是顶层常量了。

要在一条语句中定义多个变量,切记,符号&和*只从属于某个声明符,而非基本数据类型的一部分,因此初始值必须是同一种类型:

auto k = ci, &l = i;		//k是整数,l是整型引用
auto &m = ci, *p = &ci;		//m是对整型常量的引用,p是指向整型常量的指针
//错误:i的类型是int而&ci的类型是const int
auto &n = i, *p2 = &ci;

2.5.3 decltype类型指示符

有时会遇到这种情况:希望从表达式的类型推断出要定义的变量的类型,但是不想用该表达式的值初始化变量。为了满足这一要求,C++11 新标准引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值

decltype(f()) sum = x;	//sum的类型就是函数f的返回类型

编译器并不实际调用函数 f,而是使用当调用发生时的返回值类型作为 sum 的类型。
decltype 处理顶层 const 和引用的方式与auto有些许不同。如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层 const 和引用在内):

const int ci = 0, &cj = ci;
decltype(ci) x = 0;		//x的类型是const int
decltype(cj) y = x;		//y的类型是const int&,y绑定到变量x
decltype(cj) z;			//错误:z是一个引用,必须初始化

因为cj是一个引用,decltype(cj)的结果就是引用类型,因此作为引用的z必须被初始化。
需要指出的是,引用从来都作为其所指对象的同义词出现,只有用在 decltype 处是一个例外。

decltype和引用

如果decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型。如4.1.1节将要介绍的,有些表达式将向 decltype 返回一个引用类型。一般来说当这种情况发生时,意味着该表达式的结果对象能作为一条赋值语句的左值:

//decltype的结果可以是引用类型
int i = 42, *p = &i, &r = i;
decltype(r + 0) b;		//正确:加法的结果是int,因此b是一个(未经初始化的)int
decltype(*p) c;			//错误:c是int&,必须初始化

因为r是一个引用,因此decltype(r)的结果是引用类型。如果想让结果类型是r所指的类型,可以把r作为表达式的一部分,如r+0,显然这个表达式的结果将是一个具体值而非一个引用。

另一方面,如果表达式的内容是解引用操作,则decltype将得到引用类型。

decltype和auto的另一处重要区别是,decltype 的结果类型与表达式形式密切相关。有一种情况需要特别注意:对于 decltype 所用的表达式来说,如果变量名加上了一对括号,则得到的类型与不加括号时会有不同。如果 decltype 使用的是一个不加括号的变量,则得到的结果就是该变量的类型;如果给变量加上了一层或多层括号,编译器就会把它当成是一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype就会得到引用类型:

//decltype的表达式如果是加上了括号的变量,结果将是引用
decltype((i)) d;	//错误:d是int&,必须初始化
decltype(i) e;		//正确:e是一个()int

切记:decltype((variable))(注意是双层括号)的结果永远是引用,而decltype(variable)结果只有当variable本身就是一个引用时才是引用。

2.6 自定义数据结构

从最基本的层面理解,数据结构是把一组相关的数据元素组织起来然后使用它们的策略和方法。举一个例子,我们的 Sales_item 类把书本的 ISBN 编号、售出量及销售收入等数据组织在了一起,并且提供诸如isbn函数、>>、<<、+、+=等运算在内的一系列操作,Salesitem类就是一个数据结构。

C++语言允许用户以类的形式自定义数据类型,而库类型 string、istream、ostream等也都是以类的形式定义的,就像第1章的Sales item类型一样。尽管Sales_item类非常简单,但是要想给出它的完整定义可在第14章介绍自定义运算符之后。

2.6.1 定义Sales_data类型

struct Sales_data{
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

我们的类以关键字struct开始,紧跟着类名和类体(其中类体部分可以为空)。类名由花括号包围形成了一个新的作用域(参见2.2.4节)。类内部定义的名字必须唯一,但是可以与类外部定义的名字重复。

类体右侧的表示结束的花括号后必须写一个分号,这是因为类体后面可以紧跟变量名以示对该类型对象的定义,所以分号必不可少:

struct Sales_data {/* ... */} accum, trans, *salesptr;
//与上一条语句等价,但可能更好一些
struct Sales_data {/* ... */};
Sales_data accum, trans, *salesptr;

分号表示声明符(通常为空)的结束。一般来说,最好不要把对象的定义和类的定义放在一起。这么做无异于把两种不同实体的定义混在了一条语句里,一会儿定义类,一会儿又定义变量,显然这是一种不被建议的行为。

很多新手程序员经常忘了在类定义的最后加上分号。

类数据成员

类体定义类的成员,我们的类只有数据成员(data member)。类的数据成员定义了类的对象的具体内容,每个对象有自己的一份数据成员拷贝。修改一个对象的数据成员,不会影响其他Sales_data的对象。
定义数据成员的方法和定义普通变量一样:首先说明一个基本类型,随后紧跟一个或多个声明符。我们的类有3个数据成员:一个名为 bookNo的 string 成员一个名为units_sold的unsigned成员和一个名为 revenue 的 double 成员。每个Sales_data的对象都将包括这3个数据成员。
C++11新标准规定,可以为数据成员提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化(参见2.2.1节)。因此当定义Sales_data的对象时,units_sold和revenue都将初始化为0,bookNo将初始化为空字符串。

对类内初始值的限制与之前(参见2.2.1节)介绍的类似:或者放在花括号里,或者放在等号右边,记住不能使用圆括号。
7.2节将要介绍,用户可以使用C++语言提供的另外一个关键字class来定义自己的数据结构,到时也将说明现在我们使用struct的原因。

2.6.2 使用Sales_data类

和 Sales_item 类不同的是,我们自定义的 Sales_data 类没有提供任何操作,Sales_data 类的使用者如果想执行什么操作就必须自己动手实现。

内容过于简单,跳过了。

2.6.3 编写自己的头文件

尽管如19.7节所讲可以在函数体内定义类,但是这样的类毕竟受到了一些限制。所以,类一般都不定义在函数体内。当在函数体外部定义类时,在各个指定的源文件中可能只有一处该类的定义。而且,如果要在不同文件中使用同一个类,类的定义就必须保持一致。
为了确保各个文件中类的定义一致,类通常被定义在头文件中,而且类所在头文件的名字应与类的名字一样。例如,库类型string 在名为string 的头文件中定义。又如,我们应该把Sales_data类定义在名为 Sales_data.h的头文件中。
头文件通常包含那些只能被定义一次的实体,如类、const 和 constexpr 变量(参见2.4节)等。头文件也经常用到其他头文件的功能。例如,我们的Sales_data类包含有一个string成员,所以 Sales_data.h 必须包含 string.h头文件。同时使用Sales_data类的程序为了能操作bookNo成员需要再一次包含string.h头文件。

这样,事实上使用 Sales_data类的程序就先后两次包含了 string.h 头文件:一次是直接包含的,另有一次是随着包含 Sales_data.h 被隐式地包含进来的。有必要在书写头文件时做适当处理,使其遇到多次包含的情况也能安全和正常地工作。

头文件一旦改变,相关的源文件必须重新编译以获取更新过的声明。

预处理器概述

确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocessor),它由C++语言从C语言继承而来。预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。之前已经用到了一项预处理功能#include,当预处理器看到#include标记时就会用指定的头文件的内容代替#include。
C++程序还会用到的一项预处理功能是头文件保护符 (header guard),头文件保护符依赖于预处理变量(参见2.3.2节)。预处理变量有两种状态:已定义和未定义。#define指令把一个名字设定为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已经定义:#ifdef当且仅当变量已定义时为真,#ifndef当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif指令为止。

使用这些功能就能有效地防止重复包含的发生:

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

第一次包含 Sales_data.h 时,#ifndef的检查结果为真,预处理器将顺序执行后面的操作直至遇到#endif为止。此时,预处理变量 SALES_DATA_H的值将变为已定义,而且sales_data.h也会被拷贝到我们的程序中来。后面如果再一次包含Sales_data.h,则#ifndef的检查结果将为假,编译器将忽略#ifndef到#endif之间的部分。

WARNING:预处理变量无视C++语言中关于作用域的规则。

整个程序中的预处理变量包括头文件保护符必须唯一,通常的做法是基于头文件中类的名字来构建保护符的名字,以确保其唯一性。为了避免与程序中的其他实体发生名字冲突,一般把预处理变量的名字全部大写。

头文件即使(目前还)没有被包含在任何其他头文件中,也应该设置保护符。头文件保护符很简单,程序员只要习惯性地加上就可以了,没必要太在乎你的程序到底需不需要。

posted @ 2022-11-16 22:36  泠枫Jun  阅读(77)  评论(0编辑  收藏  举报