0%

重读《C++ Primer》01:变量和基本类型

个人认为 C++ 语言最经典的入门书籍就是 《C++ Primer》了,大学自学编程的时候看的就是《C++ Primer》,只不过当时看的是第 4 版,那一版还没有介绍 C++ 11 标准。相比于 C98 标准,C 语言发生了大量变化,为了写出 Modern C++ 程序,又重新看了一遍 《C++ Primer》第 5 版。

序言

  • 学习语言的一个境界是把自己想象成编译器
  • C++ 语言正在走向完美,C++ 语言值得学习,这些知识可以成为一切编程的基础
  • 然而,在实践中不必全面地使用 C++ 语言的各种特性,而应根据工程项目的实际情况,适当取舍
  • 通常只鼓励使用 C++ 语言的一个子集就够了
  • 每当你需要走下去直接与硬件对话,C++ 成为 C 之外唯一有效率的选择
  • C++ 同时支持 4 种不同的编程风格:C 风格、基于对象、面对对象和泛型(如果把微软的 COM 也算进来的话,还可以加上一种基于组件的风格)
  • C11 对于 C98 而言,不是一种简单的升级,而是一种本质的跃升

前言

现代 C++ 语言可以看做是三部分组成:

  • 低级语言,大部分继承自 C 语言
  • 现代高级语言特性,允许我们定义自己的类型以及组织大规模程序和系统
  • 标准库:它利用高级特性来提供有用的数据结构和算法

C++ 是一种庞大的编程语言,它提供了一些为特定程序设计问题定制的功能。其中一些功能对大型项目团队有很重要的意义,但对于小型项目开发可能并无必要。

开始

学习一门新的程序设计语言的最好方法就是练习编写程序。

编写一个简单的 C++ 程序

  • 每个 C++ 程序都包含一个或多个函数(function),其中一个必须命名为 main。
  • main 函数是操作系统执行你的程序的调用入口,操作系统通过调用 main 来运行 C++ 程序,
  • 一个函数的定义包含四部分:返回类型(return type)、函数名(function name)、一个用括号包围的形参列表(parameter list,允许为空)以及函数体(function body)
  • 内置类型:即语言自身定义的类型
  • 函数体:以左花括号开始,以右花括号结速的语句块
  • 在 C++ 中,大多数 C++ 语句以分号表示结束
  • 在大多数系统中,main 的返回值用来指示状态,返回值 0 表明成功,非 0 的返回值的含义由系统定义,通常用来指出错误类型

类型是 C++ 程序设计最基本的概念之一:

  • 一种类型不仅定义了数据元素的内容,还定义了这类数据上可以进行的运算
  • 程序所处理的数据都保存在变量中,而每个变量都有自己的类型

编译、运行程序

  • 编写好 C++ 程序之后,需要编译它。如何编译程序依赖于你使用的操作系统和编译器
  • 很多 PC 机上的编译器都具备集成开发环境 IDE(Integrated Developed Environment,IDE)
  • 大部分编译器,包括集成 IDE 的编译器都会提供一个命令行界面
  • 无论使用命令行界面或者 IDE,大多数编译器都要求程序源代码存储在一个或多个文件中
  • 程序文件通常称为源文件(source file),源文件的名字以一个后缀结尾,不同的编译器使用不同的后缀命名约定,最常见的包括 .cc,.cxx、.cpp、.cp 及 .c
  • 在不同的操作和编译器系统中,运行 C++ 编译器的命令也各不相同。最常用的编译器是 GNU 编译器和微软 Visual Studio 编译器。默认情况下,运行 GNU 编译器的命令是 g++
  • 根据使用的 GNU 编译器的版本,可能需要制定 -std=c0x 参数来打开对 C11 的支持
  • 编译器通常都包含一些选项,能够对有问题的程序结构发出警告。打开这些选项通常是一个好习惯(在 GNU 编译器中使用 -Wall 选项)
  • 在 *NIX 系统中,通过如下方式编译、运行程序,并通过 echo 命令检查程序的返回值
1
2
3
4
5
6
7
8
9
10
$ cat main.cpp
int
main(void)
{
return 0;
}
$ g++ main.cpp
$ ./a.out
$ echo $?
0

初识输入输出

  • C++ 语言并未定义任何输入和输出(IO)语句,取而代之,包含了一个全面的标准库(standard library)来提供 IO 机制(以及很多其他设施)
  • iostream 库包含了两个基础类型 istream 和 ostream,分别表示输入和输出流
  • 一个流就是一个字符序列,是从 IO 设备读出或写入 IO 设备的。随着时间的推移,字符是顺序生成或消耗的

标准库定义了 4 个 IO 对象:

  • cin:istream 类型的对象,用于处理输入,也被称为标准输入
  • cout:ostream 类型的对象,用于处理输出,也被称为标准输出
  • cerr:ostream 类型的对象,用于输出警告和错误消息,也被称为标准错误
  • clog:osteram 类型的对象,用于输出程序运行时的一般性信息

系统通常将程序所运行的窗口与这些对象关联起来。如下是一个使用标准 IO 库的程序:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int main(void)
{
std::cout << "Enter two numbers:" << std::endl;
int v1 = 0, v2 = 0;
std::cin >> v1 >> v2;
std::cout << "The sum of" << v1 << " and " << v2
<< " is " << v1 + v2 << std::endl;
return 0;
}

在该程序中:

  • #include <iostream> 告诉编译器我们想使用 iostream 库,尖括号中的名字指出了一个头文件
  • 每个使用标准库相关设施的设施都必须包含相关的头文件
  • #include 指令和头文件名字必须在同一行,通常情况下,我们将一个程序的所有 #include 指令都放在源文件的开始位置
  • main 函数体的第一条语句执行了一个表达式。一个表达式产生一个计算结果,它由一个或多个运算对象和一个运算符(通常情况)组成,这里使用了输出运算符 << 在标准输出上打印消息
  • << 运算符接受两个运算对象,左侧的运算对象必须是一个 ostream 对象,右侧的运算对象是要输出的对象。该运算符将给定值写到给定的 ostream 对象中。输出运算符的计算结果就是其左侧运算对象,这也是这条语句能连续使用两次 << 的原因
  • 用双引号包围的字符序列,被称为字符串字面值常量
  • endl 是一个被称为操纵符的特殊值,写入 endl 的效果是结束当前行,并将设备关联的缓冲区中的内容刷到设备中
  • 操纵符对象,在读写流的时候用来操纵流本身
  • 缓冲刷新操作可以保证到目前为止程序所产生的所有输出都真正写入到输出流中,而不仅仅是停留在内存中等待写入流。程序员在调试时添加打印语句,这类语句应该保证一直刷新流。否则如果程序崩溃,输出可能还留在缓冲区中,从而导致关于程序崩溃位置的错误推断。
  • 默认情况下,读 cin 会刷新 cout;程序终止时也会刷新 cout
  • 默认情况下,写到 cerr 的数据是不缓冲的,写到 clog 的数据是被缓冲的。
  • 前缀 std:: 指出名字是定义在名为 std 的命名空间(namespace)中的。命名空间可以帮助我们避免不经意的名字定义冲突,以及使用库中相同名字导致的冲突。标准库中定义的所有名字都在命名空间 std 中。通过命名空间使用标准库有一个副作用:当使用标准库中的一个名字时,必须显式说明我们想使用来自命名空间 std 中的名字。通过使用作用域运算符 :: 来指出我们想使用的名字来自于哪个命名空间。
  • 初始化一个变量,就是在变量创建的同时为它赋予一个值
  • 输入运算符 >> 与输出运算符类似,它接收一个 istream 作为其左侧运算对象,接收一个对象作为其右侧运算对象,它从给定的 istream 读入数据,并存入给定的对象中,输入运算符也返回其左侧运算对象作为其计算结果,因此可以将一系列输入请求合并到单一语句中

注释简介

  • 注释可以帮助人类读者理解程序,注释通常用于概述算法,确定变量的用途,或者解释晦涩难懂的代码段
  • 编译器会忽略注释,因此注释对程序的行为或性能不会有任何影响
  • 错误的注释比完全没有注释更糟糕,因为它会误导读者
  • C++ 有两种注释:单行注释(以 // 开始,以换行符结速,双斜线右侧的所有内容都会被编译器忽略,这种注释可以包含任何文本包括额外的双斜线),界定符对注释(继承自 C 语言,以 /* 开始,以 */ 结束,可以包含除 */ 以外的任意内容,包括换行符,在 /**/ 之间的所有内容都当作注释)
  • 当注释界定符跨越多行时,最好能显式指出其内部的程序行都属于多行注释的一部分,所采用的风格是,注释内的每行都以一个 * 开头,从而指出整个范围都属于多行注释的一部分
  • 注释界定符对通常用于多行解释,而双斜线注释通常用于半行和单行附注
  • 注释界定符不能嵌套,编译器对这类问题所给出的错误可能是难以理解、令人困惑的
  • 在调试期间如果需要注释掉一些代码,由于这些代码可能包含界定符对形式的注释,因此可能导致嵌套错误,因此最好的方式是用单行注释方式注释掉代码段的每一行

控制流

  • 语句一般是顺序执行的,但程序设计语言提供了多种不同的控制流语句,允许我们写出更为复杂的执行路径
  • while 语句反复执行一段代码,直到给定条件为假为止,while 语句的形式:
1
2
while (condition)
statement
  • while 语句持续地交替检测 condition 和执行 statement,直至 condition 为假为止
  • 语句块:用花括号包围的零条或多条语句的序列。语句块也是语句的一种,在任何要求使用语句的地方都可以使用语句块
  • for 语句:在循环条件中检测变量,在循环体中递增变量的模式使用非常频繁。因此 C++ 专门定义了第二种循环语句,即 for 语句
  • 每个 for 语句包含两部分:循环头和循环体。循环头控制循环体的执行次数,它由三部分组成:一个初始化语句,一个循环条件,以及一个表达式。初始化语句中定义的变量仅在 for 循环内部存在,在循环结束之后是不能使用的,初始化语句只在 for 循环入口处执行依次。循环体每次执行前都会先检查循环条件,只要循环条件为真,就会执行循环体。表达式在 for 循环体之后执行,执行完表达式之后,for 语句重新检测循环条件,从而决定是否继续执行 for 循环体。这一过程将持续进行,直到循环条件为假

当使用一个 istream 对象作为条件时,其结果是检测流的状态。如果流是有效的,即流未遇到错误,那么检测成功。当遇到文件结束符或遇到一个无效输入时,istream 对象的状态会变为无效。

  • 从键盘向程序输入数据时,对于如何指出文件结束,不同操作系统有不同的约定。在 Windows 中,使用 Ctrl+Z,然后按 Enter。在 Unix 系统中,文件结束输入的是 Ctrl+D

编译器关于编译错误通常会包含一个行号和一条简短描述,描述了编译器认为我们所犯的错误。按照报告的顺序来逐个修正错误,是一种好习惯。因为单一错误常常会具有传递效应,导致编译器在其后报告比实际数量多得多的错误信息。另一个好习惯是在每修正一个错误后,就立即重新编译代码,或者最多是修正了一小部分明显的错误后就重新编译。这就是所谓的 编译-编译-调试 周期。

  • C++ 提供 if 语句来支持条件执行
  • C++ 用 = 进行赋值,用 == 作为相等运算符。两个运算符都可以出现在条件中,一个常见的错误是想在条件中使用 ==,却误用了 =

C++ 程序很大程度上是格式自由的,虽然很大程度上可以按照自己的意愿自由地设定程序的格式,但所做出的选择会影响的可读性。不存在唯一正确的风格,但保持一致性是非常重要的。

类简介

在 C++ 中,通过定义一个类(class)来定义自己的数据结构。一个类定义了一个类型,以及与其关联的一组操作。类机制是 C++ 最重要的特性之一。

  • 为了使用标准设施,必须包含相关的头文件。类似的,也需要使用头文件来访问为自己的应用程序所定义的类
  • 习惯上,头文件根据其中定义的类的名字来命名
  • 通常使用 .h 作为头文件的后缀(也有程序使用 .H、.hpp、.hxx)
  • 标准库头文件通常不带后缀
  • 编译器一般不关心头文件名的形式,但有的 IDE 对此有特定的要求

为了使用一个类,我们不关心它是如何实现的,只需要知道类对象可以执行什么操作。每个类实际上都定义了一个新的类型,其类型名就是类名。与内置类型一样,我们可以定义类类型的变量。类的作者决定了类类型对象上可以使用的所有操作。

  • 包含来自标准库的头文件时,应该使用 <> 来包含头文件名;对于不属于标准库的头文件,则用双引号包围
  • 成员函数是定义为类的一部分的函数,有时也被称为方法,通常以一个类对象的名义调用成员函数
  • 点运算符只能用于类类型对象。其左侧运算对象必须是一个类类型对象,右侧运算对象必须是该类型的一个成员名,运算结果为右侧运算对象指定的成员
  • 当点运算符访问一个成员函数时,通常是想调用该函数。使用调用运算符 () 来调用一个函数,里面放置实参列表(可能为空)
  • 大多数操作系统支持文件重定向,这种机制允许我们将标准输入和标准输出与命名文件关联起来

任何常用的编程语言都具备一组公共的语法特征,不同语言仅在特征的细节上有所区别。最基本的特征包括:

  • 整型、字符型等内置类型
  • 变量,用来为对象命名
  • 表达式和语句,用来操纵上述数据类型的具体值
  • if 或 while 等控制结构,这些结构允许我们有选择地执行一些语句或者重复地执行一些语句
  • 函数,用于定义可供随时调用的计算单元

大多数编程语言通过两种方式来进一步补充其基本特征:

  • 赋予程序员自定义数据类型的权利,从而实现对语言的扩展
  • 将一些有用的功能封装成库函数提供给程序员

在 C++ 中,对象类型决定了能对该对象执行的操作。C++ 是静态数据类型语言,它的类型检查发生在编译时。因此编译器必须知道程序中每一个变量对应的数据类型

C++ 提供了一组内置数据类型、相应的运算符以及为数不多的几种程序流控制语句,这些元素共同构成了 C++ 语言的基本形态。而 C++ 中最重要的语法特征就是类,通过它,程序员可以定义自己的数据类型。为了与 C++ 的内置类型区别开来,它们通常被成为 类类型

C++ 的类类型既可以包含数据成员,也可以包含函数成员。C++ 语言主要的一个设计目标就是让程序员自定义的数据类型像内置类型一样好用。

变量和基本类型

  • 数据类型是程序的基础:它告诉我们数据的意义以及我们能够在数据上执行的操作
  • C++ 语言支持广泛的数据类型,它定义了几种基本内置类型,同时也为程序员提供了自定义数据类型的机制。基于此,C++ 标准库定义了一些更加复杂的数据类型(比如可变长字符串和向量等)

基本内置类型

C++ 定义了一套包括算术类型和空类型在内的基本数据类型。其中算术类型包含了:字符、整数型、布尔类型和浮点数。而空类型不对应具体的值,仅用于一些特殊的场合。

  • 算术类型的尺寸(即该类型的数据所占的比特数)在不同机器上有所差别
  • C++ 标准规定了各个类型的尺寸最小值,同时允许编译器赋予这些类型更大的尺寸
  • 算术类型可以分为两类:整型(字符、整数型、布尔类型)和浮点型(单精度、双精度、扩展精度)。
  • 布尔类型(bool)的取值是 true 或 false
  • C++ 提供几种字符类型,基本的字符类型为 char(char 的空间需要确保可以存放机器基本字符集中任意字符对应的数字值,即和一个机器字节一样),其他字符类型用于扩展字符集(wchar_t、char16_t、char32_t)
  • 整数型用于表示不同尺寸的整数,包括 short、int、long、long long
  • 对于浮点型,C++ 标准制定了一个浮点数有效位数的最小值,大多数编译器都实现了更高的精度(通常 float 占 4 字节、double 用 8 字节表示,而 long double 用 12 或 16 字节来表示)
  • 某一类型所占的比特数不同,它所能表示的数据范围也不一样
  • 为了赋予内存中某个地址明确的含义,必须首先知道存储在该地址的数据的类型。类型决定了数据所占的比特数以及该如何解释这些比特的内容。
  • 除去布尔类型和扩展的字符型之外,其他整型可以划分为带符号和无符号的两种,通过在 int、short、long、long long 前添加 unsigned 就可以得到无符号类型
  • 与其他整型不同,字符型被分为了 3 种:char、signed char 和 unsigned char。类型 char 实际上会表现为带符号或者无符号,具体是哪种由编译器决定

以下是选择类型的一些基本准则:

  • 当明确知道数值不可能为负时,选择无符号类型
  • 使用 int 执行整数运算。如果你的数值超过了 int 的表示范围,选用 long long
  • 在算术表达式中不要使用 char 和 bool,只有在存放字符或布尔值时才使用它们
  • 如果你需要使用一个不大的整数,那么明确指定它的类型是 signed char 或 unsigned char
  • 执行浮点数运算选用 double

类型转换

当在程序的某处我们使用了一种类型,而其实对象应该取另一种类型时,程序会自动进行类型转换。当把一种算术类型的值赋给另外一种类型时,类型所能表示的值得范围决定了转换的过程

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

在含有无符号类型的表达式中,需要注意:

  • 当一个算术表达式中既有无符号数又有 int 时,那么这个 int 就会转换成无符号数
  • 当从无符号数中减去一个数时,不管这个数是不是无符号数,我们都需要确保结果不可能是一个负值
  • 无符号数不会小于 0 这一事实也关系到循环的写法
  • 切勿混用带符号类型和无符号类型

我们要避免无法预知和依赖于实现环境的行为:

  • 即使代码编译通过,如果程序执行了一条未定义的表达式,仍有可能产生错误,且每次执行结果可能不一样
  • 如果程序依赖于实现环境,那么这样的程序就称作不可移植的

字面值常量

  • 每一个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型
  • 整型字面值可以写作十进制数、八进制数(以 0 开头)或十六进制数的形式(以 0x 或 0X)开头
  • 整型字面值的具体数据类型由它的值和符号决定:默认情况下,十进制字面值是带符号数,八进制和十六进制字面值可能是带符号、也可能是无符号
  • 十进制字面值类型是 int、long、long long 中尺寸最小的那个(前提是该类型能容纳当前值)
  • 八进制和十六进制的类型是能容纳当前值 int、unsigned int、long、unsigned long、long long、unsinged long long 中尺寸最小者
  • 严格来说,十进制字面值不会是负数,负数十进制字面值可以认为是正数字面值取负
  • 浮点型字面值表现为一个小数或以科学记数法表示的指数,其中指数以 E 或 e 标识
  • 默认浮点型字面值是一个 double
  • 单引号括起来的一个字符称为 char 型字面值
  • 双引号括起来的零个或多个字符则构成字符串型字面值
  • 字符串字面值的类型实际上由常量字符构成的数组
  • 编译器在每一个字符串的结尾处添加一个空字符,因此字符串字面值的实际长度要比它的内容多 1
  • 当两个字符串字面值位置紧邻,且仅有空格、缩进和换行符分隔,则它们实际上是一个整体(当书写的字符串字面值比较长,写在一行里不太合适时,就可以采取分开书写的方式)

转移序列

有两类字符程序员不能直接使用:

  • 不可打印字符,因为他们没有可视的图符
  • 在 C++ 语言中有特殊含义的字符(如单引号、双引号)

在这些情况下需要用到转义序列,转义序列均以反斜线作为开始。还可以使用泛化的转义序列,其形式是 ‘\x’ 后紧跟 1 个或多个十六进制数字,或者 \ 后紧跟 1 个、2 个或者 3 个八进制数字,其中数字部分表示的是字符对应的数值。注意,如果 \ 后跟着的八进制数字超过 3 个,只有前 3 个构成转义序列。而 \x 要用到后面跟着的所有数字。

指定字面值类型

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

前缀:

  • u:Unicdoe 16 字符
  • U:Unicode 32 字符
  • L:宽字符
  • u8:UTF-8(仅用于字符串字面常量)

后缀:

  • u 或 U:unsigned
  • f 或 F:float
  • l 或 L:long
  • ll 或 LL:long long
  • l 或 L:long double

布尔字面值和指针字面值

  • true 和 false 是布尔类型的字面值
  • nullptr 是指针字面值

变量

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

  • 变量定义的基本形式:首先是类型说明符,之后紧跟一个或多个变量名组成的列表,其中变量名以逗号分隔,最后以分号结束
  • 定义时还可以为一个或多个变量赋予初值
  • 当对象在创建时获得了一个特定值,我们说这个对象被初始化了
  • 当一次定义了两个或多个变量时,对象的名字随着定义也就马上可以使用了,因此在同一条定义语句中,可以用先定义的变量值去初始化后定义的其他变量
  • 在 C++ 语言中,初始化和赋值是两个完全不同的操作。初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而以一个新值来替代

列表初始化

C++ 语言定义了初始化的好几种不同形式,这也是初始化问题复杂性的一个体现。以下 4 条语句都可以对 int 变量作初始化:

1
2
3
4
int i = 0;
int i = {0};
int i{0};
int i(0);

作为 C++11 新标准的一部分,用花括号来初始化变量得到了全面应用,这种初始化的形式被称为列表初始化。无论是初始化对象,还是某些时候为对象赋新值,都可以使用这样一组由花括号括起来的初始值。

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

默认初始化

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

如果是内置类型未被显式初始化,它的值由定义的位置决定。定义于任何函数体之外的变量被初始化为 0,定义在函数体内部的内置类型变量将不被初始化(uninitialized)。一个未被初始化的内置类型变量的值是未定义的。

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

绝大多数类都支持无须显式初始化而定义对象,这样的类提供了一个合适的默认值。而一些类要求每个对象都显式初始化,此时如果创建了一个该类的对象而未对其做明确的初始化操作,将引发错误。

未初始化变量引发运行时故障:未初始化的变量含有一个不确定的值,使用未初始化变量的值是一种错误的编程行为并且很难调试。建议初始化每一个内置类型的变量。

变量声明和定义的关系

为了允许把程序拆分成多个逻辑部分来编写,C++ 语言支持分离式编译机制,该机制允许将程序分隔为若干个文件,每个文件可以独立编译。为了支持分离式编译,C++ 语言将声明和定义区分开来。

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

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

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

任何包含了显式初始化的声明即成为了定义(即使使用了关键字 extern)。extern 语句如果包含了初始值就不再是声明,而变成定义了。

  • 在函数体内部,如果试图初始化一个由 extern 关键字标记的变量,将引发错误。
  • 变量只能被定义一次,但是可以被多次声明。
  • 如果要在多个文件中使用同一个变量,那么就必须将声明和定义分离。此时变量的定义必须出现在且只能出现在一个文件中,而其他用到该变量的文件必须对其进行声明,却绝对不能重复定义

C++ 是一种静态类型语言,其含义是在编译阶段检查类型。其中检查类型的过程称为类型检查。对象的类型决定了对象所能参与的运算,C++ 编译器负责检查数据类型是否支持要执行的运算,如果视图执行类型不支持的运算,编译器将报错。

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

标识符

C++ 标识符由字母、数字和下划线组成,其中必须以字母或下划线开头。标识符的长度没有限制,但是对大小写敏感。C++ 语言保留了一些名字供语言本身使用,这些名字不能被用作标识符。

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

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

  • 标识符要能体现实际含义
  • 变量名一般用小写字母
  • 用户自定义的类名一般以大写字母开头
  • 如果标识符由多个单词组成,则单词间应有明显区分

对于命名规范来说,若能坚持,必将有效。

名字的作用域

无论是在程序的什么位置,使用到的每个名字都会指向一个特定的实体:变量、函数、类型等等。然而,同一个名字如果出现在程序的不同位置,也可能指向的是不同的实体。

作用域是程序的一部分,在其中名字有其特定的含义。C++ 语言中大多数作用域都以花括号分隔。同一个名字在不同作用域中可能指向不同的实体。名字的有效作用域始于名字的声明语句,以声明语句所在的作用域末端为结束。

  • 定义在函数体之外的名字拥有全局作用域,一旦声明之后,全局作用域内的名字在整个程序范围内都可使用
  • 定义在语句块的名字具有块作用域
  • 作用域能彼此包含,被包含的作用域称为内层作用域,包含着别的作用域的作用域称为外层作用域
  • 作用域中一旦声明了某个名字,它所嵌套的所有作用域都能访问该名字,同时允许在内层作用域中重新定义外层作用域中已有的名字
  • 使用域操作符 :: 可以覆盖默认的作用域规则,当域操作符左侧为空时,将访问全局作用域中的名字
  • 如果函数有可能用到全局变量,则不宜再定义一个同名的局部变量

一般来说,在对象第一次被使用的地方附近定义它是一种好的选择,因为这样做更容易找到变量的定义,另外,当变量的定义与它第一次被使用的地方很近时,我们也会赋给它一个比较合理的初始值。

复合类型

  • 复合类型是基于其他类型定义的类型。C++ 语言有几种复合类型。
  • 一条声明语句由一个基本数据类型和紧跟其后的一个声明符列表组成。每个声明符命名了一个变量并指定该变量为与基本数据类型有关的某种类型

引用

  • 引用为对象起了另外一个名字,引用类型引用另外一种类型,通过将声明符写成 &d 的形式来定义引用类型,其中 d 是声明的变量名
  • 在定义引用时,程序把引用和它的初始值绑定在一起,而不是将初始值拷贝给引用
  • 一旦初始化完成,引用将和它的初始值对象一直绑定在一起
  • 因为无法令引用重新绑定到另外一个对象,引用必须初始化
  • 引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字
  • 定义一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行的
  • 由于引用本身不是一个对象,所以不能定义引用的引用
  • 在一条语句中定义多个引用时,每个引用标识符都必须以 & 开头
  • 所有引用的类型必须和与之绑定的对象严格匹配(有两个例外)
  • 引用只能绑定到对象上,而不能与字面值或某个表达式的计算结果绑定在一起

指针

  • 指针是指向另外一种类型的复合类型
  • 与引用类似,指针也实现了对其他对象的间接访问
  • 指针本身就是一个对象,允许对指针赋值和拷贝
  • 指针无须在定义时赋予初值(其初始化行为和其他内置类型一样)
  • 定义指针类型的方法是:将声明符写成 *d 的形式,其中 d 是变量名
  • 如果在一条语句中定义了几个指针变量,每个变量前面都必须有符号 *
  • 指针存放某个对象的地址,要想获取该地址,需要使用取地址符 &
  • 由于引用不是对象,所以不能定义指向引用的指针
  • 所有指针的类型都要和它所指向的对象严格匹配(指针的类型就说明了它所指向对象的类型,因此两者必须匹配,如果指针指向了一个其它类型的对象,对该对象的操作将发生错误)
  • 访问无效指针的值将引发错误,编译器并不负责检查此类错误
  • 如果指针指向了一个对象,允许使用解引用符(*)来访问该对象,对指针解引用会得到指针所指向的对象
  • 空指针不指向任何对象,空指针可以使用 nullptr(C++11 引入,是一种特殊类型的字面值)、0、NULL(预处理变量,它的值就是 0)
  • 使用未初始化的指针是引发运行时错误的一大原因(未经初始化的指针,其内容可能恰好是个有效地址,则会给程序造成难以预计的后果)
  • 给指针赋值就是让它存放一个新的地址,从而指向一个新的对象
  • 指针也能用在条件表达式中,如果指针值为 0,则为 false,任何非 0 指针对应的布尔值都是 true
  • 如果两个指针存放的地址值相同,则它们相等,反之它们不想等
  • void* 是一种特殊的指针类型,可以存放任意对象的地址
  • 不能直接操作 void* 所指的对象,因为并不知道这个对象到底是什么类型,也就无法确定能在这个对象上做哪些操作

理解复合类型的声明

  • 变量的定义包含一个基本数据类型和一组声明符。在同一条定义语句中,虽然基本数据类型只有一个,但是声明的形式却可以不同。也就是说,同一条定义语句可以定义出不同类型的变量
  • 类型修饰符是声明符的一部分,用于修饰变量标识符
  • 修饰符的个数没有限制,** 表示指向指针的指针,*** 表示指向指针的指针的指针,以此类推
  • 引用本身不是对象,因此不能定义指向引用的指针。但是指针是对象,所以存在对指针的引用
  • 离变量名最近的符号对变量的类型有最直接的影响,因此 int *&r 用于表示对指针的引用,该指针指向 int 型对象

const 限定符

  • 可以使用 const 关键字对变量的类型加以限定,这样该类型的变量就不能被修改
  • const 对象一旦创建后其值就不能再改变,所以 const 对象必须初始化
  • 只能在 const 对象上执行不改变其内容的操作

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

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

解决办法是,对于 const 变量不管是声明还是定义,都添加关键字 extern。所以如果想在多个文件之间共享 const 对象,必须在变量的定义前面添加 extern 关键字。

  • 可以把引用绑定到 const 对象上,就像绑定到其他对象上一样,我们称之为对常量的引用
  • 对常量的引用不能被用作修改它所绑定的对象
  • 引用的对象是常量还是非常量可以决定其所能参与的操作,却无论如何都不会影响到引用和对象的绑定关系本身
  • 初始化常量引用时(对常量的引用)允许使用任意表达式作为初始值,只要该表达式的值结果能转换成引用的类型即可
  • 因此允许为一个常量引用绑定非常量的对象、字面值、甚至是个一般表达式。此时常量引用其实是绑定到一个临时量对象(临时量对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名对象)
  • 对 const 的引用可能引用一个非 const 对象。此时不能通过该引用修改所绑定的对象,但仍可以通过其他途径修改该对象(毕竟该对象是非 const 对象)
  • 可以让指针指向常量或非常量。指向常量的指针不能用于改变其所指对象的值
  • 要想存放常量对象的地址,只能使用指向常量的指针
  • 允许指向常量的指针指向一个非常量对象,此时不能通过该指针修改所指向的对象,但仍可以通过其他途径修改该对象(毕竟该对象是非 const 对象)
  • 指向常量的指针或引用,不过是指针或引用自以为是罢了,它们觉得自己指向了常量,所以自觉地不去修改所指向的对象。
  • 指针是对象而引用不是,因此允许把指针本身定义为常量,常量指针必须初始化,而且一旦初始化完成,则它的值也就不能再改变了
  • 把 * 放在 const 关键字之前用以说明指针是一个常量,即不变的是指针本身,而非指针所指向的对象
  • 指针本身是不是常量、指针所指向的对象是不是常量,这是两个独立的问题
  • 顶层 const 表示指针本身是个常量,底层 const 表示指针所指的对象是一个常量
  • 更一般地,顶层 const 可以表示任意的对象是常量,这一点对任何数据类型都适用;底层 const 则与指针和引用等复合类型的基本类型部分有关
  • 当执行对象拷贝时,常量是顶层 const 还是底层 const 区别明显。执行拷贝操作时,并不会修改被拷贝对象的值,因此被拷贝对象的顶层 const 基本不会对拷贝操作有什么影响,但是底层 const 的限制不能忽视。当对象的拷贝操作时,拷入和拷出的对象必须具备相同的底层 const 资格,或者两个对象的数据类型必须能够转换(一般来说非常量可以转化成常量,反之则不行)

常量表达式

常量表达式是指值不会改变并且在编译过程就能得到计算结果的表达式。一个对象(或表达式)是不是常量表达式由它的数据类型和初始值共同决定。字面值属于常量表达式,用常量表达式初始化的 const 对象也是常量表达式。

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

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

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

常量表达式的值需要在编译时就得到计算,因此对声明 constexpr 时用到的类型必须有所限制,这些类型称为字面值类型。算术类型、引用、指针都属于字面值类型。IO 库、string 类型则不属于字面值类型。

尽管指针和引用都能定义成 constexpr,但他们的初始值却受到严格限制。一个 constexpr 指针的初始值必须是 nullptr 和 0,或者存储于某个固定地址中的对象。定义在所有函数体之外的对象,其地址固定不变,函数体内定义的变量一般来说并非在固定地址中(但在函数体内定义的静态变量也是存放在固定地址中)。

如果使用 constexpr 定义了一个指针,constexpr 仅对指针有效,与指针所指的对象无关。

处理类型

  • 类型别名是一个名字,它是某种类型的同义词。
  • 使用类型别名可以让复杂的类型名字变得简单明了、易于了解和使用
  • 关键字 typedef 作为声明语句中的基本类型的一部分出现,其定义的不再是变量而是类型别名
  • typedef 的声明符也可以包含类型修饰,从而也能由基本数据类型构造出复合类型
  • 新标准还引入了一种新的方法:使用别名声明来定义类型别名,该方法使用 using 作为别名声明的开始,后面紧跟别名和等号,其作用是把等号左侧的名字规定成等号右侧类型的别名
  • 类型别名和类型的名字等价,只要是类型的名字能出现的地方,就能使用类型别名
  • 如果某个类型别名指代的是复合类型或常量,那么把它用到声明语句里就会产生意向不到的后果
1
2
3
typedef char *pstring;
const pstring cstr = 0;
const pstring *ps
  • 此时需要牢记 const 是对给定类型的修饰,而 pstring 的类型是一个指向 char 的指针,因此 const 修饰的是指针,也就说 const pstring 表示指向 char 的常量指针。此时不能错误地尝试把类型别名替换成它本来的样子,这种理解会造成错误。

auto 类型说明符

C++11 标准引入了 auto 类型说明符,用它就能让编译器替代我们去分析表达式所属的类型。和原来那些只对应一种特定类型的说明符(例如 int)不同,auto 让编译器通过表达式的值来推算变量的类型。因此 auto 定义的变量必须有初始值。

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

编译器推断出来的 auto 类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则:

  • 当引用被用作初始值时,真正参与初始化的其实是引用对象的值,此时编译器以引用对象的类型作为 auto 的类型
  • auto 一般会忽略掉顶层 const,但是底层 const 会被保留下来

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

1
const auto f
  • 设置一个类型为 auto 的引用时,初始值中的顶层常量属性仍然保留

decltype 类型指示符

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

decltype 处理顶层 const 和引用的方式与 auto 有些不同。如果 decltype 使用的表达式是一个变量,则 decltype 返回该变量的类型(包括顶层 const 和 引用在内)。

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

如果 decltype 使用的表达式不是一个变量,则 decltype 返回表达式结果对应的类型。需要注意,如果表达式的结果是解引用操作,则 decltype 将得到引用类型。因为解引用指针可以得到指针所指的对象,而且还能给这个对象赋值,因此 decltye(指针)的结果类型就是引用类型。

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

自定义数据结构

从最基本的层面理解,数据结构就是把一组相关的数据元素组织起来然后使用它们的策略和方法。C++ 语言允许用户以类的形式自定义数据类型。

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

类体右侧的表示结束的花括号后必须写一个分号,因为类体后面可以紧跟变量名以表示对该类型对象的定义,所以分号必不可少。分号表示声明符(通常为空)的结束。

  • 一般来说,最好不要把对象的定义和类的定义放在一起。
  • 很多新手程序员经常忘了在类定义的最后加上分号

类体定义类的成员,类的数据成员定义了类的对象的具体内容,每个对象有自己的一份数据成员拷贝。修改一个对象的数据成员,不会影响其他对象。定义数据成员的方法和定义普通变量一样。C++ 新标准规定,可以为每个数据成员提供一个类内初始值。创建对象时,类内初始值用于初始化数据成员。没有初始值的成员将被默认初始化。类内初始值或者放在花括号里,或者放在等号右边,记住不能使用圆括号。

虽然可以在函数体内定义类,但是这样的类毕竟受到一些限制。所以,类一般都不定义在函数体内。当在函数体外部定义类时,在各个指定的源文件中可能只有一处该类型的定义。而且,如果在不同的文件中使用同一个类,类的定义就必须保持一致。因此,为了确保各个文件中类的定义一致,类通常被定义在头文件中,而且类所在头文件的名字应该与类名一致。

头文件通常包含那些只能被定义一次的实体,如类、const 和 constexpr 变量。头文件也经常用到其他头文件的功能。为了防止头文件被重复包含(防止多个重复定义),有必要在书写头文件时做适当的处理,使其遇到多次包含的情况下也能安全和正常地工作。

确保头文件多次包含仍能安全工作的常用技术是预处理器。预处理是在编译器之前执行的一段程序,可以部分地改变我们所写的程序:

  • #include:当预处理器看到该标记时,就会用指定的头文件内容替代 #include
  • 头文件保护符,依赖于预处理变量。预处理变量有两种状态:已定义和未定义。
  • #define 指令把一个名字设置为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已经定义
  • #ifdef 当且仅当变量已定义时为真,#ifndef 当且仅当变量未定义时为真。一旦结果为真,则执行后续操作直至遇到 #endif 指令为止

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

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

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

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