【C++】C++ primer 第二章学习笔记

第二章 变量和基本类型

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

​ C++语言定义了几种基本内置类型(如字符,整型,浮点数等),同时也提供了自定义数据类型的机制。基于此,C++标准库定义了一些更加复杂的数据类型,比如可边长字符串和向量等。

2.1 基本内置类型

​ C++ 定义了一组包括 整数浮点数单个字符布尔值 的算术类型,另外还定义了一种称为 void 的空类型(空类型不对应具体的值,仅用于一些特殊场合,如:当函数不返回任何值时使用空类型作为返回类型)。

2.1.1 算术类型

算术类型分为:整型(包括字符和布尔类型)和浮点型

算数类型的尺寸依机器而定。下表列出了C++标准规定的尺寸的最小值。

类型 含义 最小尺寸
bool 布尔类型 未定义
char 字符 8位
wchar_t 宽字符 16位
char16_t Unicode字符 16位
char32_t Unicode字符 32位
short 短整型 16位
int 整型 16位
long 长整型 32位
long long 长整型 64位
float 单精度浮点数 6位有效数字
double 双精度浮点数 10位有效数字
long double 扩展精度浮点数 10位有效数字
  • 布尔类型的取值是真(true)或者假(false)
  • 一个char类型的大小和一个机器字节(byte)一样
  • 其他字符类型用于扩展字符集,如wchar_t、char16_t、char32_t。
    • wchar_t类型用于确保可以存放机器最大扩展字符集中的任意一个字符
    • char16_t、char32_t为Unicode字符集服务(Unicode是用于表示所有自然语言中字符的标准)。
  • 对于其他整型类型,C++语言规定:short ≤ int≤ long ≤ long longlong long是C++11新定义的类型)。

大多数计算机以2的整数次幂个比特作为块来处理内存,可寻址的最小内存块称为“字节(byte)”;存储的基本单元称为“字(word)”,通常由几个字节组成。

在C++语言中,一个字节要至少能容纳机器基本字符集中的字符。大多数机器的字节由8比特构成,字则由32或64比特构成,也就是4或8字节。

字 > 字节 > 比特

为了赋予内存中某个地址明确的含义,必须首先知道存储在该地址的数据的类型

  • 浮点型可表示单精度、双精度和扩展精度值。通常,float以一个字(32比特)来表示,double以两个字(64比特)来表示,long double以 三或四个字(96或128比特)来表示。

带符号类型和无符号类型

  • 除去布尔型和扩展的字符型之外,其他整型可划分为 带符号的(signed)和无符号的(unsigned) 两种。
    • 类型shortintlonglong long都是带符号的,无符号型必须在这些类型名前添加unsigned
    • 同其他整型不同,字符型被分为三种:charsigned char、和unsigned char。但是表现形式只有带符号和无符号两种。类型 charsigned char 并不一样, char的具体形式由编译器(compiler)决定。
  • 无符号类型中所有比特都用来存储值,如:8比特的unsigned char可以表示0~255区间内的值。
  • C++标准没有规定带符号类型应如何表示,但约定了在表示范围内正值和负值的量应平衡。因此,8比特的signed char理论上应该可以表示-127至127区间内的值,大多数现代计算机将实际的表示范围定为-128至127。

如何选择算数类型:

  • 当明确知晓数值不可能为负时,应该使用无符号类型。
  • 使用 int 执行整数运算,不易出错。如果数值超过了 int 的表示范围,应该使用 long long 类型。
  • 在算数表达式中不要使用 charbool 类型,只有在存放字符或布尔值时才使用它们(因为char类型在一些机器上是有符号的,而在另外一些机器上又是无符号的,所以如果使用char进行运算特别容易出问题)。如果需要使用一个不大的整数,应该明确指定它的类型是 signed char 还是unsigned char
  • 执行浮点数运算时建议使用 double 类型(因为float通常精度不够而且双精度浮点数和单精度浮点数的计算代价相差无几,甚至在某些机器上双精度运算比单精度快)。

2.1.2 类型转换

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

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

  • 把非布尔类型的算术值赋给布尔类型时,初始值为0则结果为 false,否则结果为 true
  • 把布尔值赋给非布尔类型时,初始值为 false 则结果为0,初始值为 true 则结果为1。
  • 把浮点数赋给整数类型时,进行近似处理,结果值仅保留浮点数中的整数部分。
  • 把整数值赋给浮点类型时,小数部分记为0。如果该整数所占的空间超过了浮点类型的容量,精度可能有损失。
  • 当赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数。例如:8比特大小的 unsigned char 能表示的数值总数是256(可表示0~255区间内的值),当我们把-1赋给该类型型时,所得结果是255。(见P33)
  • 赋给带符号类型一个超出它表示范围的值时,结果是 未定义的(undefined)

建议:避免无法预知和依赖于实现环境的行为。

含有无符号类型的表达式

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

如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数会自动地转换成无符号数(P34~35)。

无符号数不会小于0这一事实关系到循环的写法。

//错误:变量u永远也不会小于0,循环条件一直成立
for(unsigned u = 10; u >= 0; --u)
	std::cout << u <<std::endl;

一种解决办法是用 while 语句来代替 for 语句,因为前者可以在输出变量前先减去1。

unsigned u = 11;  //从确定要打印的 最大数+1 的数开始
while (u > 0) 
{
    --u;  		 //先减1,这样最后一次迭代将打印0
    std::cout << u << std::endl;
}

2.1.3 字面值常量

每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型。

整型和浮点型字面值

可以将整型字面值写作十进制数、八进制数或十六进制数的形式。

  • 0开头的整数代表八进制数
  • 0x0X开头的代表十六进制数

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

  • 十进制字面值的类型是能容纳下当前的值的int、long和long long中尺寸最小的那个。
  • 八进制和十六进制字面值类型是能容纳其数值的intunsigned intlongunsigned longlong longunsigned long long中尺寸最小着。

如果一个字面值连与之关联的最大的数据类型都放不下,将产生错误。

尽管整型字面值可以存储在带符号数据类型中,但严格来说,十进制字面值不会是负数(符号并不在字面值之内,它的作用仅仅是对字面值取负值而已)。

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

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

后面会介绍用后缀代表相应的字面值类型。

字符和字符串字面值

由单引号括起来的一个字符称为char型字面值,双引号括起来的零个或多个字符构成字符串型字面值(字符串字面值的类型实际上是由常量字符构成的数组(array))。

编译器在每个字符串的结尾处添加一个空字符(),因此,字符串字面值的实际长度要比它的内容多 1 。

如果两个字符串字面值位置紧邻且仅由空格、缩进和换行符分隔,则它们实际上是一个整体。

指定字面值的类型

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

字符和字符串字面值

前缀 含义 类型
u Unicode 16字符 char16_t
U Unicode 32字符 char32_t
L 宽字符 wchar_t
u8 UTF-8(仅用于字符串字面常量) char

整型字面值 和 浮点型字面值

后缀 最小匹配类型 后缀 类型
u或U unsigned f或F float
l或L long l或L long double
ll或LL long long

当使用一个长整型字面值时,最好使用大写字母L来标记,因为小写字母l和数字1太容易混淆。

我们可以将U与L或LL合在一起使用。例如:以UL为后缀的字面值的数据类型将根据具体数值情况或取 unsigned long,或者取unsigned long long

转义序列

有两类字符我们不能直接使用:一类是不可打印的字符(如退格或其它控制字符,因为他们没有可视的图符),另一类是在C++语言中有特殊含义的字符(单引号、双引号、问号、反斜线)。

在这些情况下需要用到转义序列,转义序列均以反斜线为开始,C++语言规定的转义序列包括:

换行符 横向制表符 报警(响铃)符 a
纵向制表符 v 退格符  双引号 "
反斜线 |问号 ? 单引号 '
回车符 进纸符 f

也可以使用泛化的转义序列,其形式:x后紧跟1个或多个十六进制数字 或者 后紧跟1个、2个或3个八进制数字 ,其中数字部分表示的是字符对应的数值。

假设使用的是Latin-1字符集,以下是一些示例:

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

注意:

  • 反斜线后面跟着的八进制数字超过3个,只有前3个数字与构成转义序列
  • x要用到后面跟着的所有数字(一般来说,超过8位的十六进制字符都是与上面字符和字符串字面值表中某个前缀作为开头的扩展字符集一起使用的)

布尔字面值和指针字面值

  • truefalse是布尔类型的字面值
  • nullptr是指针字面值

2.2 变量

​ 变量提供一个具名的、可供程序操作的存储空间。C++中的每个变量都有其数据类型,数据类型决定着变量所占内存空间的大小和布局方式、该控件能存储的值的范围,以及变量能参与的运算。

2.2.1 变量定义

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

何为对象(object)?

通常情况下,对象是指一块能存储数据并具有某种类型的内存空间。

  • 一些人尽在与类有关的场景下才使用“对象”这个词。
  • 另一些人则已把命名的对象和未命名的对象区分开,把命名了的对象叫做变量
  • 还有一些人把对象和值区分开来,其中对象指能被程序修改的数据,而值指只读的数据。

初始值

当对象在创建时获得了一个特定的值,就说这个对象被 初始化

  • 对象的名字随着定义马上就可以使用了。

    //正确:price先被定义并赋值,随后被用于初始化discount
    double price = 109.99, discount = price * 0.16;
    //正确:调用函数 applyDiscount,然后用函数的返回值初始化salePrice
    double salePrice = applyDiscount(price, discount);
    

在C++语言中,初始化和赋值是两个完全不同的操作。

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

列表初始化

C++语言定义了初始化的好几种不同形式。

示例:要定义一个名为 units_sold 的 int 变量并初始化为0

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
    • 在函数体内部的变量 不被初始化。(未被初始化的内置类型变量的值是未定义,使用该类值是一种错误的编程行为且很难调试。)
  • 类的对象如果没有显式初始化,则其值由类确定。

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

2.2.2 变量声明和定义的关系

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

​ 为了支持分离式编译,C++语言将声明和定义区分开来。

  • 声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明
  • 定义(definition)负责创建与名字关联的实体。

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

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

extern int i; // 声明但不定义 i
int j;      // 声明并定义 j
  • extern语句如果包含了初始值就不再是声明了,而变成了定义。

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

    int main()
    {
        extern int i = 10; //不允许对外部变量的局部声明使用初始值设定项
        //cout << i << endl;
        return 0;
    }
    
  • 变量能且只能被定义一次,但是可以被多次声明。

声明和定义的区别看起来也许微不足道,但实际上却非常重要。

如果要在多个文件中使用同一个变量,就必须将声明和定义分离。此时,变量的定义必须出现且只能出现在一个文件中,而其他使用该变量的文件必须对其进行声明,但绝对不能重复定义。

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

程序越复杂,静态类型检查越有助于发现问题。然而,前提是编译器必须知道每一个实体对象的类型,这就要求我们在使用某个变量之前必须声明其类型。

2.2.3 标识符

C++的 标识符 由字母、数字和下画线组成。其中:

  • 必须以字母或下画线开头
  • 标识符的长度没有限制,但是对大小写字母敏感
  • 用户自定义的标识符中不能连续出现两个下画线
  • 不能以下画线紧连大写字母开头
  • 定义在函数体外的标识符不能以下画线开头

变量命名规范

变量命名有许多约定俗成的规范,下面的这些规范能有效提高程序的可读性:

  • 标识符要能体现实际含义
  • 变量名一般用小写字母;
  • 用户自定义的类名一般以大写字母开头,如Sales_item;
  • 如果标识符由多个单词组成,则单词间应有明显区分,如student_loan,studentLoan,不要使用studentloan
  • C++语言本身并没有限制变量名的长度,但考虑到将会阅读和/或修改我们的代码的其他人,变量名不应太长。

C++ 为标准库保留了一些名字。也就是关键字。关键字不能用作程序的标识符。

C++关键字 char32_t dynamic_cast if protected struct union and
alignas class else inline public switch unsigned and_eq
alignof const enum int register template using bitand
asm constexpr explicit long reinterpret_cast this virtual bitor
auto const_cast export mutable return thread_local using compl
bool continue extern namespace short throw virtual not
break decltype false new signed true void not_eq
case default float noexcept sizeof try volatile or
catch delete for nullptr static typedef wchar_t or_eq
char do friend operator static_assert typeid while xor
char16_t double goto private static_cast typename C++操作符替代名 xor_eq

2.2.4 名字的作用域

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

  • 同一个名字在不同的作用域中可能指向不同的实体。
  • 名字的有效区域始于名字的声明语句,以声明语句所在的作用域末端为结束。
  • 定义在函数体之外的名字拥有 全局作用域(global scope)。声明之后,该名字在整个程序范围内都可使用(如main具有全局作用域)。

建议:最好在第一次使用变量时再去定义它。

这样做有助于更容易找到变量的定义位置。更重要的是,当变量的定义与它第一次被使用的地方很近时,我们可以赋给它一个比较合理的初始值。

嵌套的作用域

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

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

#include <iostream>
// 该程序仅用于说明: 函数内部不宜定义与全局变量同名的新变量
int reused = 42;  // reused拥有全局作用域
int main()
{
	int unique = 0; // unique拥有块作用域
	// 输出 #1: 使用全局变量reused; 输出 42 0
	std::cout << reused << " " << unique << std::endl;
	int reused = 0; // 新建局部变量reused,覆盖了全局变量reused
	// 输出 #2: 使用局部变量reused; 输出 0 0
	std::cout << reused << " " << unique << std::endl;
	// 输出 #3: 显式地访问全局变量reused; 输出 42 0
	std::cout << ::reused << " " << unique << std::endl;
	return 0;
}

对于输出 #3,可以用作用域操作符 :: 来覆盖默认的作用域规则。因为全局作用域本身并没有名字,所以当作用域操作符的左侧为空时,会向全局作用域发出请求获取作用域操作符右侧名字对应的变量。

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

2.3 复合类型

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

2.3.1 引用

C++11中新增了一种引用:所谓的 “右值引用” ,这种引用主要用于内置类。后面章节会详细的介绍。

严格来说,当我使用术语 “引用” 时,指的其实是 “左值引用” 。

引用为对象起了另外一个名字,通过将声明符写成 &d 的形式来定义引用类型,其中d是声明的变量名。因为引用本身不是一个对象,所以不能定义引用的引用,但可以定义任何其他类型的引用。

int ival = 1024;
int &refVal = ival; // refval指向ival
int &refVal2;       // 报错: 引用必须被初始化

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

引用的定义

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

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

除了后面章节介绍的两种例外情况,其他所有引用的类型都要满足:

  • 引用的类型要和与之绑定的对象严格匹配

  • 引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起。

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

引用即别名

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

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

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

2.3.2 指针

与引用类似,指针也实现了对其他对象的间接访问。但是指针与引用相比又有很多不同点:

  • 指针本身就是一个对象,允许对指针赋值和拷贝,而且在生命周期内它可以先后指向不同的对象。
  • 指针无须在定义时赋初值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。

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

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

获取对象的地址

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

int ival = 42;
int *p = &ival; // p存放ival的地址,或者说p是指向变量ival的指针
  • 因为引用不是对象,没有实际地址,所以不能定义指向引用的指针。

除了后面章节介绍的两种例外情况,其他所有指针的类型都要满足:

  • 指针的类型要和它所指向的对象严格匹配。

指针值

指针的值(即地址)应属于下列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
  • 解引用操作仅适用于那些确实指向了某个对象的有效指针。

某些符号有多重含义:

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

  • 在声明语句中,& 和 * 用于组成复合类型;
  • 在表达式中,& 和 * 转变成运算符

由于含义截然不同,所以我们完全可以把它当作不同的符号来看待。

空指针

空指针(null pointer) 不指向任何对象,在试图使用一个指针之前代码可以先检查它是否为空。

以下列出几个生成空指针的方法:

int *p1 = nullptr;  // 等价于 int *p1 = 0;
int *p2 = 0;        // 直接将p2初始化为字面常量0
// 需要首先 #include cstdl ib
int *p3 = NULL;     // 等价于 int *p3 = 0;
  • 得到空指针最直接的办法是用字面值 nullptr 来初始化指针(C++11标准中引入的方法)。nullptr是一种特殊类型的字面值,它可以转换成任意其他的指针类型。
  • 也可以通过将指针初始化为字面值0来生成空指针。
  • 旧版本程序通常使用 NULL(预处理变量,定义于头文件 cstdlib 中,值为0)给指针赋值。

在C++11标准下,最好使用 nullptr 初始化空指针,同时尽量避免使用NULL

建议:初始化所有指针(见P49)

同时建议初始化所有的指针,并且在可能的情况下,尽量等定义了对象之后再定义指向它的指针。如果实在不清楚指针应该指向何处,就把它初始化为nullptr或者0,这样程序就能检测并知道它没有指向任何具体的对象。

赋值和指针

指针和引用都能提供对其他对象的间接访问,然而在具体实现细节上二者有很大不同,其中最重要的一点就是 引用本身并非一个对象。一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。

指针和它存放的地址之间就没有这种限制了。和其他任何变量(只要不是引用) 一样,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象。

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

记住赋值永远改变的是等号左侧的对象。

pi = &ival ; // pi的值被改变,现在pi指向了ival
*pi = 0 ; // iva1的值被改变,指针pi并没有改变

其他指针操作

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

  • 一个指针指向某对象,同时另一个指针指向另外对象的下一个地址,此时有可能出现这两个指针值相同的情况,即指针相等。
  • 对于各种要用到指针的值的操作,不论是作为条件出现还是参与运算,都必须使用合法指针,使用非法指针作为条件或进行比较都会引发不可预计的后果。

void*指针

void* 是一种特殊的指针类型,可以存放任意对象的地址,但不能直接操作 void* 指针所指的对象(只能拿它和别的指针比较、作为函数的输入或输出,或者赋给另外一个void*指针)。

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

概况来说,以void*的视角来看内存空间也就仅仅是内存空间,没办法访问内存空间中所存的对象。后面章节会跟详细介绍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和p2都是指向int的指针
//正确理解:
int* p1 , p2; // p1是指向int的指针, p2是int型变量

涉及指针或引用的声明,一般有两种写法:

  • 第一种把修饰符和变量标识符写在一起,这种形式着重强调变量具有的复合类型:

    int *p1, *p2; // p1和p2都是指向int的指针
    
  • 第二种把修饰符和类型名写在一起,并且每条语句只定义一个变量,这种形式着重强调本次声明定义了一种复合类型:

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

指向指针的指针

通过*的个数可以区分指针的级别。也就是说,**表示指向指针的指针,***表示指向指针的指针的指针,以此类推(声明符中修饰符的个数没有限制):

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

指针之间关系(见P52)

解引用 int 型指针会得到一个 int 型的数,同样,解引用指向指针的指针会得到一个指针。

//该程序使用三种不同的方式输出了变量ival的值:第一种直接输出;第二种通过int型指针pi输出;第三种两次解引用ppi取得ival的值
cout << "The value of ival
"
    << "direct value: " << ival << "
"
    << "indirect value: " << *pi << "
"
    << "doubly indirect value: " << **ppi <<endl;

指向指针的引用

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

int i = 42;
int *p;         // p是一个int型指针
int *&r = p;    // r是一个对指针p的引用

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

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

离变量名最近的符号(此例中是 &r 的符号 &)对变量的类型有最直接的影响。因此 r 是一个引用。声明符的其余部分用以确定 r 引用的类型是什么,此例中的符号 * 说明 r 引用的是一个指针。最后,声明的基本数据类型部分指出 r 引用的是一个 int 指针。

2.4 const限定符

在变量类型前添加关键字 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对象仅在文件内有效

默认情况下,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是同一个
    

2.4.1 const的引用

把引用绑定在 const 对象上,称为 对常量的引用(reference to const)。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象

const int ci = 1024;
const int &r1 = ci;     // 正确: 引用及其对应的对象都是常量
r1 = 42;                // 错误: r1是对常量的引用(r1这个引用不能用来修改它所绑定的对象ci)
int &r2 = ci;           // 错误: 试图让一个非常量引用指向一个常量对象

术语:“对const的引用”简称为“常量引用”(引用一个 常量const对象)

常量引用指的是我们定义的引用,而不是引用的对象

初始化和对const的引用

一般情况下,引用的类型要和与之绑定的对象严格匹配。但是有两个例外:

  • 第一个例外就是在初始化常量引用时,允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可(尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至一般表达式)。

    int i = 42;
    // 允许将const int&绑定到一个普通int对象上
    const int &r1 = i;      // 正确: r1是一个常量引用
    const int &r2 = 42;    
    const int &r3 = r1 * 2; // 正确: r3是一个常量引用
    int &r4 = r1 * 2;       // 错误: r4是一个普通的非常量引用
    
  • 第二个例外在第十五章。

下面再次详细的解释一遍刚刚的内容:

对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是一个常量引用

做一个不太标准的总结, 说到底:常量引用就是不能通过它自身修改所引用的对象,而所引用的对象是什么并没有限定,并且所引用的对象的值还是可以任意修改的(前提不是const对象)。

2.4.2 指针和const

指向常量的指针(pointer to const) 不能用于修改其所指向的对象。

const double pi = 3.14;     // pi是个常量,它的值不能改变
double *ptr = &pi;          // 错误: ptr是一个普通指针
const double *cptr = &pi;   // 正确: cptr可以指向一个双精度常量
*cptr = 42;         	    // 错误: 不能给*cptr赋值(即上述定义)

一般情况下,指针的类型要和与之指向的对象严格匹配。但是有两个例外:

  • 第一个例外是允许令一个指向常量的指针指向一个非常量对象

    double dval = 3.14; 		// dval是一个双精度浮点纹,它的值可以改变
    cptr = &dval;       		// 正确: 但是不能通过cptr改变dval的值
    
  • 第二个例外还不清楚。

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

const指针

定义语句中把 * 放在 const 之前用来说明指针本身是一个常量,俗称常量指针常量指针(const pointer) 必须初始化,而且一旦初始化完成,指针本身的值(即存放在指针中的那个地址)就不能再改变了:

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

指针本身是常量并不代表不能通过指针修改其所指向的对象的值,能否这样做完全依赖于其指向对象的类型。

2.4.3 顶层const

顶层 const 表示指针本身是个常量,底层 const表示指针所指的对象是一个常量。指针类型既可以是顶层 const 也可以是底层 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上
    

2.4.4 constexpr 和常量表达式

常量表达式 指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式。

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

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

constexpr变量

C++11标准中规定,允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化:

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

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

字面值类型

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

函数体内定义的变量一般并非存放在固定地址中,因此 constexpr 指针不能指向这样的变量。相反,函数体外定义的变量地址固定不变,可以用来初始化 constexpr 指针。

指针和constexpr

constexpr 声明中如果定义了一个指针,限定符 constexpr 仅对指针本身有效,与指针所指的对象无关:

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

constexpr 把它所定义的对象置为了顶层 const

与其他常量指针类似, 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

constconstexpr 限定的值都是常量。但 constexpr 对象的值必须在编译期间确定,而 const 对象的值可以延迟到运行期间确定。建议使用 constexpr 修饰表示数组大小的对象,因为数组的大小必须在编译期间确定且不能改变。

2.5 处理类型

2.5.1 类型别名

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

有两利方法可用于定义类型别名:

  • 传统方法是使用关键字 typedef 定义类型别名:

    typedef double wages;   // wages是double的同义词
    typedef wages base, *p; // base是double的同义词,p是double*的同义词
    
  • C++11标准规定了一种新方法,使用关键字 using 进行别名声明(alias declaration),作用是把等号左侧的名字规定成等号右侧类型的别名。

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

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

指针、常量和类型别名

如果某个类型别名指代的是复合类型或常量,那么把它用到声明语句中会产生意想不到的后果:

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

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

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

2.5.2 auto类型说明符

C++11新增 auto 类型说明符,能让编译器自动分析表达式所属的类型。auto 让编译器通过初始值来推算变量的类型,因此auto 定义的变量必须有初始值。

// 由val1和val2相加的结果可以推断出item的类型
auto item = val1 + val2;    // item初始化为val1和val2相加的结果

使用 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,同时底层 const 则会保留下来:

    const int ci = i, &cr = ci; //////顶层const
    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

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

    设置一个类型为 auto 的引用时,原来的初始化规则仍然适用,并且初始值中的顶层常量属性仍然保留。

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

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

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

2.5.3 decltype类型指示符

有时会遇到这种情况:希望从表达式的类型推断出要定义的变量的类型,但不想用该表达式的值初始化变量。

为了满足这一需求,C++11新增第二种类型说明符 decltype ,作用是选择并返回操作数的数据类型。此过程中编译器分析表达式并得到它的类型,但不实际计算表达式的值。

decltype(f()) sum = x;  // sum的类型就是函数f的返回类型
  • 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是一个引用,必须初始化
    

    注意:引用从来都作为其所指对象的同义词出现,只有用在decltype处是个例外。

decltype和引用

  • 如果 decltype 使用的表达式不是一个变量,则 decltype 返回表达式结果对应的类型。如果表达式的内容是解引用操作,则 decltype 将得到引用类型:

    // decltype的结果可以是引用类型
    int i = 42, *p = &i , &r = i;
    decltype (r) b;     // 错误:因为r是一个引用,所以b的类型是int&,必须初始化
    decltype (r + 0) b; // 正确:加法的结果是int,因此b是一个(未初始化的)int
    decltype (*p) c; 	// 错误:c是int&,必须初始化(如果表达式的内容是解引用操作,则decltype将得到引用类型)
    
  • decltypeauto 的另一处重要区别是,decltype 的结果类型与表达式形式密切相关。注意:如果 decltype 使用的是一个不加括号的变量,则得到的结果就是该变量的类型;如果给变量加上了一层或多层括号,则 decltype 会得到引用类型,因为编译器会把它当作一个表达式,而变量是一种可以作为赋值语句左值的特殊表达式:

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

切记decltype((variable)) 的结果永远是引用,而 decltype(variable) 的结果只有当 variable本身是一个引用时才会是引用。

2.6 自定义数据结构

2.6.1 定义Sales_data类型

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

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

    struct Sales_data { /* ... */ } accum, trans, *salesptr;
    //与上一条语句等价,但可能更好一些
    struct Sales_data { /* ... */ };
    Sales_data accum, trans, *salesptr;
    
  • 一般来说,最好不要把对象的定义和类的定义放在一起。这样做无异于把两种不同实体的定义混在一条语句里,一会儿定义类,一会儿定义变量,显然这是一种不被建议的行为。

类数据成员

类的数据成员定义了类的对象的具体内容,每个对象都有自己的一份数据成员拷贝。修改一个对象的数据成员,不会影响其他Sales_item(类)的对象。

C++11 标准规定,可以为类的数据成员提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员,没有初始值的成员将被默认初始化(初始化为0 / 空字符串)。

  • 注意:类内初始值不能使用圆括号。

2.6.2 使用Sales_data类

2.6.3 编写自己的头文件

  • 头文件(header file)通常包含那些只能被定义一次的实体,如类、constconstexpr变量。
  • 头文件一旦改变,相关的源文件必须重新编译以获取更新之后的声明。

预处理器概述

确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocessor),由C++语言从C语言继承而来。预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。

C++程序还会用到一项预处理功能:头文件保护符(header guard),头文件保护符依赖于预处理变量(preprocessor variable)。

预处理变量有两种状态:已定义和未定义。#define指令把一个名字设定为预处理变量。另外两个指令分别检查某个指定的预处理变量是否已经定义:#ifdef指令当且仅当变量已定义时为真,#ifndef指令当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif指令为止。

使用这些功能就能有效地防止重复包含的发生(在高级版本的IDE环境中,可以直接使用#pragma once命令来防止头文件的重复包含):

#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

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

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

头文件即使目前还没有被包含在任何其他头文件中,也应该设置保护符。

原文地址:https://www.cnblogs.com/LFVH-Coding/p/14289760.html