1 基础

1.1 引言

非正式地展示C++的符号、C++的内存和计算模型,以及将代码组织成程序的基本机制。其中所涉及到的语言特性和编程风格与C语言类似,通常被称为面向过程编程。

1.2 程序

C++是一门编译型语言。用C++语言编写的源代码,首先要交由编译器编译,得到目标文件,然后借助链接器,将这些目标文件及其所依赖的库,链接成可执行程序。

源文件1
编译器
目标文件1
链接器
可执行程序
源文件2
目标文件2
库文件1
库文件2

可执行程序通常是为特定的硬件与操作系统的组合而定制的。一个能够在个人电脑上的Windows系统中运行的可执行程序,是无法在智能手机上的Android系统中运行的。通常所谓的C++程序的可移植性,指的是源代码的可移植性,即一份源代码可以在不同硬件上的不同操作系统中,被成功地编译、链接和运行。

编译、链接
运行
编译、链接
运行
面向个人电脑和Windows系统的库
面向个人电脑和Windows系统的编译器和链接器
源代码集合
可在个人电脑上的Windows系统中运行的可执行程序
个人电脑上的Windows系统
面向智能手机和Android系统的编译器和链接器
可在智能手机上的Android系统中运行的可执行程序
智能手机上的Android系统
面向智能手机和Android系统的库

ISO C++标准定义了两种实体:

C++标准库组件通常由C++语言本身编写。

C++是一门静态类型语言。这意味着,每一个实体(如对象、值、名称和表达式)在其被引用的上下文中,编译器必须明确知道其准确的类型。实体的类型决定了它所适用的操作集合,及其在内存中的布局形式。

1.2.1 Hello, World!

最小的C++程序类似下面这样:

每个C++程序必须有且仅有一个名为main的全局函数,表示从这里开始执行。main函数返回一个整型值,表示程序执行成功还是失败。如果main函数没有显式地返回任何值,系统默认收到表示成功的返回值。通常从main函数中返回零代表成功,返回非零代表失败,但并非所有操作系统及运行环境,都会用到这个返回值。比如UNIX/Linux系统会经常用到它,而Windows系统却极少使用它。

典型的程序会产生一些输出,比如下面这样:

import指令是C++20的新特性,但将所有的标准库放进一个单独的std模块还没有成为标准。截止目前,GNU C++编译器尚不支持本案例第1行中的写法。Visual C++ 2022支持这样的写法,但需做特殊的工程配置:

事实上,所有可执行代码最终都会被放进函数,并被main函数直接或间接地调用。例如:

“返回类型”为void,表示函数不返回值。

1.234
1.234
1.52276
main
print_square
square

1.3 函数

C++程序完成某件事情的方法主要是通过调用函数来实现的。程序设计者可以定义自己的函数以执行特定的操作。在调用一个函数之前,必须先声明它。

函数声明给出了函数的名称,调用该函数时必须提供的参数类型和数量,以及返回值的类型(如果有的话)。例如:

在函数声明中,返回值的类型位于函数名之前,各个参数的类型则位于函数名后面的圆括号内。

在调用一个函数时向其传递参数,其本质就是在做初始化,用实参初始化形参。这意味着,在检测参数类型的过程中,有可能会发生隐式的类型转换。例如:

不要低估编译时类型检查和隐式类型转换的价值。

函数声明可以包含参数的名称,这有助于提高程序代码的可读性,但除非在声明函数的同时给出函数的定义,否则编译器会忽略函数声明中的参数名称。例如:

函数也是有类型的。函数的类型由其返回值的类型和圆括号中的参数类型序列组成。例如:

的类型为“double(const vector<double>&,int)”。

函数可以是类的成员。对成员函数而言,类的名称也是函数类型的一部分。例如:

的类型为“char& String::(int)”。

编写易于理解的代码,是提高程序可维护性的第一步。为此需要将计算任务分解为一系列有意义的“块”——函数和类——并为其命名。函数为计算提供了基本词汇,就象(内置和用户定义的)类型为数据提供了基本词汇一样。

C++标准库中的算法函数,如find、sort、iota等,提供了一个良好的起点。接下来的工作就是将完成通用和特殊任务的各种函数组合成更大的任务。

代码中的错误数量与代码量和代码复杂性密切正相关。降低代码量和代码复杂性的有效方法之一,就是编写更多且更短的函数。为特定的任务定义函数,既能避免将相同或类似的代码散布于程序的各个角落,又能强制程序设计者对任务进行命名并跟踪其依赖。如果无法为函数确定贴切的名称,则往往意味着任务划分存在问题,设计缺陷就此暴露无遗。

如果两个或更多函数具有相同的名称,但带有不同的参数,则编译器会选择最合适的版本进行调用。例如:

如果有两个或更多版本均可选择,参数匹配的程度难分伯仲,此即模棱两可的调用,编译器会报告错误。例如:

在编程过程中,可以使用相同的函数名定义多个函数,谓之函数重载。函数重载也是泛型编程的基本特性之一。当一个函数被重载时,同名的每个版本都应该实现相同的语义。就象上面的print函数,无论它的哪个版本,都会打印出它所接收到的参数。而对于下面的版本:

虽然也能构成重载,但并不是好的设计,因为它不接受任何参数,在语义上与该函数的其它重载版本并不一致。

1.4 类型、变量与运算

每个名字和每个表达式都有自己的类型,类型决定了它们可以执行的操作。例如:

上述声明指定了变量inch是int类型的。这意味着inch是一个整型变量。

声明(declaration)是一条语句,它为程序引入了一个实体,并指定了它的类型:

C++提供了数量众多的基础类型,包括但不限于:

每种基础类型都与硬件设施直接对应,硬件决定了基础类型数据在内存中占用的字节数,内存中的字节数决定了它们的取值范围。

char类型的变量在内存中通常占用一个字节,即八个二进制位。其它类型变量的字节数则是char类型变量字节数的整数倍。一种类型的实际字节数可能因不同的机器而异。通过sizeof操作符可以获得特定类型的实际字节数。例如sizeof(char)通常是1,而sizeof(int)则通常是4。如果需要明确指定类型的字节数,则可以使用标准库提供的类型别名,如int32_t等。

数字可以是浮点数或整数:

为了改善长字面量的可读性,可以使用单引号(')作为数字分隔符,如圆周率π的值大约是3.14159'26535'89793'23846'26433'83279'50288,或用十六进制表示为0x3.243F'6A88'85A3'08D3。

C++14:二进制字面量

C++14:数字分隔符

C++17:十六进制浮点数字面量

1.4.1 算术运算

基础类型的数据可以通过下列算术操作符组合为算术表达式:

还可以使用这些比较操作符:

此外,还可以使用逻辑或位操作符:

按位操作表达式的值与操作数的类型相同。逻辑操作表达式的值为布尔类型的true或false。

在赋值及算术操作表达式中,C++会在基本类型之间进行各种有意义的隐式类型转换,使它们可以自由地混合使用。例如:

发生在这些表达式中的隐式类型转换,称为常用算术类型转换,旨在确保表达式以最高精度的操作数类型完成运算。例如double和int类型的加法和乘法计算,会先将int类型的操作数转换为double类型,再对两个double类型的操作数执行加法和乘法计算。

注意,“=”是赋值操作符,而“==”是相等性判断操作符。

除了常见的算术运算与逻辑运算,C++还提供了一组用于修改变量的操作符:

绝大多数操作符的结合顺序是从左向右的。例如:

但包含赋值语义的操作符的结合顺序却是从右向左的。例如:

由于某些历史原因,以函数的返回值作为操作数的表达式,或函数的参数,函数调用的顺序是未定义的。例如:

其中f函数和g函数的调用顺序并不确定。

C++17:严格指定运算顺序

1.4.2 初始化

在对象被使用之前,必须为其指定一个初始值,这个操作称为初始化。C++提供了多种表达初始化操作的语法和符号,比如单个的等号(=)。此外,C++还提供了一种更为通用的形式,即用花括号括起来,并用逗号分隔多个值的,所谓初始化列表。

等号(=)形式的初始化源于C语言的传统风格,如果拿不定主意该用什么形式初始化,使用花括号列表初始化总是正确的,而且还能防止因隐式类型转换而导致的信息损失。

当采用等号(=)而非花括号({})的形式执行初始化操作时,编译器会接受因类型窄化(如从double到int)而导致的信息损失。这是为与C语言兼容而不得不付出的代价。

常量必须在声明的同时初始化。普通变量虽无此硬性要求,但也仅在极其有限的特定情况下,会处于未初始化状态,毕竟待到有合适初值时再定义变量也是没有问题的。用户自定义类型(如string、vector、Matrix、MotorController、OrcWarrior等)的变量可在定义时被隐式初始化。

在定义变量时,如果其类型可以从初始值设定项中推导出来,则无需显式指定其类型,代之以auto关键字即可。

借助auto关键字声明变量,倾向于使用等号初始化,因为这里不存在因隐式类型转换而导致信息损失的风险。当然,使用列表初始化也没有任何问题。

当没有明显理由需要显式指定类型时,强烈建议使用auto关键字。这里的“明显理由”包括:

借助auto关键字,可以避免书写冗长的类型名称及重复代码。这在泛型编程中显得尤其重要。因为在泛型编程中,程序员往往很难知道对象的确切类型,而且类型名称可能会相当长。例如:

可以简化书写成:

C++11:通过花括号列表,执行统一且通用的初始化

C++11:借助auto关键字,从初始值设定项中推导出类型

C++11:避免类型窄化

1.5 作用域和生命周期

声明语句的作用就是将一个名字引入当前作用域。这个“当前作用域”可以是:

某些对象也可能没有名字,比如临时对象或者用new操作符创建的对象。

对象必须先被构造(初始化)才能被使用,并在其离开作用域时被销毁:

1.6 常量

C++支持两种不变性:

被声明为constexpr或consteval的函数称为纯函数,即数学意义上的函数。纯函数不能有任何副作用,比如只能带有输入参数,不能带有输出参数,也不能修改任何非局部变量的值,等等,但它可以有自己的循环和自己的局部变量。例如下面计算xn的函数:

某些场合,语言规则强制要求使用常量表达式,比如数组的大小、switch-case语句中的标签、模板的值参数,以及通过constexpr定义的常量等。另一些情况下,如果希望借助编译时求值改善程序的运行性能,也可以使用常量表达式。即使不考虑性能因素,不变性概念(对象的状态恒久不变)也是程序设计中需要考虑的问题。

C++11:更加通用且有保证的常量表达式constexpr

C++14:改进constexpr函数,允许使用循环

C++20:保证编译时求值的consteval函数

C++20:保证静态(非运行时)初始化的constinit变量

1.7 指针、数组和引用

最基本的数据集合是相同类型元素的连续序列,谓之数组。这种序列的逻辑结构与其在硬件上的物理结构一致。比如可以象下面这样声明一个字符类型的数组:

类似地,下面是一个对字符指针的声明:

在上述声明语句中,方括号([])表示数组类型,星号(*)则表示指针类型。所有数组的下标都从0开始,因此数组v中的6个元素,可通过下标0、1、2、······、5引用,即v[0]、v[1]、v[2]、······、v[5]。数组的大小必须是一个常量表达式。指针变量可以存放特定类型对象的地址:

在表达式中,前置一元操作符“&”表示获取操作数的地址,而“*”则表示获取操作数的目标。如下图所示:

下面的代码打印输出了一个数组中的全部元素:

上述代码中的for循环可以这样解读:将i的值置为0,只要i的值小于10,就执行循环体,打印输出数组v中下标为i的元素的值,并对i做自增,再次判断i的值是否小于10,若i的值小于10则继续执行循环体,打印输出数组v中下一个元素的值,重复以上过程,直到i的值不小于10,退出for循环,执行循环结构之后的语句。

除了这种继承自C语言的for循环以外,C++还提供了另一种更简单的基于范围的for循环,专门针对这种遍历容器中所有元素的操作:

上述代码中的第一个for循环可以这样解读:对于数组v中的每一个元素,从第一个到最后一个,依次复制一份到变量x中,在循环体中打印输出x的值。注意,当给基于范围的for循环指定了序列时,就不需要再强调序列的边界了,C++会自动处理从哪里开始到哪里结束的问题。基于范围的for循环可用于遍历任何类型的对象序列。

如果不希望将数组v中的每个元素依次复制给变量x,而是希望通过x引用数组v中的每个元素,可象下面这样编写代码:

在上述代码中,后置一元操作符“&”表示某种类型的引用。引用于指针类似,区别在于无须使用前置一元操作符“*”即可访问其所引用的目标对象本身,而非其副本。引用一旦获得目标就不能再引用其它对象了。

引用在指定函数的参数类型时特别有用。例如:

借助引用,可以确保类似sort(values)这样的函数调用,不会复制整个values向量给函数的参数v,进而保证是values向量本身被排序,而不仅仅是排序了它的一份拷贝。

有时可能既希望避免参数复制的开销,又不希望实参在函数中被意外修改。为此可以使用const引用。例如:

事实上,函数接受const引用作为输入参数的情况非常普遍。

声明语句中的“[]”、“*”、“&”和“()”,称为声明操作符:

而在非声明语句中,这些操作符的语义则有所不同:

注意其中的微妙差别与内在联系。

C++11:基于范围的for循环

1.7.1 空指针

通过前置一元操作符“*”获取一个指针所指向的目标,这种操作称为解引用操作。要想使解引用操作有效,就必须保证指针中存放的是一段有效内存的地址,即有对象可指,但并不是所有指针在任何时候都有对象可指,比如双向链表中头结点的前指针和尾节点的后指针。为了表达这种无所指向的语义,C++提供了空指针的概念。任何类型的空指针都可以用关键字nullptr表示。例如:

在使用指针之前检查其是否为空,在任何时候都不失为一种明智之举:

在旧式代码中,常用0或NULL表示空指针,其实它们都是整数,与指针在类型上存在潜在的歧义,使用指针类型的nullptr可以消除这种因歧义而导致的负面影响。

上述代码中的for循环也可以用while循环代替:

指针可以为空,但引用不能,一个引用必须引用有效的对象,编译器亦假定如此。当然,通过一些奇技淫巧确实可以突破这个限制,但最好还是不要这么做。

C++11:用关键字nullptr表示空指针

1.8 检验

C++提供了一套用于表示分支和循环的常规语句,比如if-else条件分支语句、switch-case开关分支语句、while循环语句和for循环语句等。例如下面的accept函数,它向用户提问,并根据用户的回答,返回相应的布尔值:

与插入流操作符(<<)相对应,提取流操作符(>>)用于从流对象中读取数据。cin代表标准输入流,通常是键盘。“>>”操作符右操作数决定输入数据的类型,同时也是输入操作的目标。将流控制符flush插入输出流,意在强制将输出缓冲区中的数据刷到输出设备上。

注意,局部变量answer的定义出现在确实需要它的地方,不必提前。声明语句可以出现在任何需要声明的地方。

下面的改进版,将用户的输入范围明确限定在字符'y'和字符'n'之间,输入其它字符视同'n',但给出必要提示:

switch-case开关分支语句检查一个值是否在一组常量中。这些常量即case标签。case标签不能重复,如果检验值不与任何case标签匹配,则执行default分支。如果没有提供default分支,则当检验值不与任何case标签匹配时什么也不做。

在使用switch-case开关分支语句时,如果想要退出一个case分支,不一定非要从当前函数中返回。更常见的情况是从switch-case结构后面继续执行,这可以通过break语句实现。例如:

与for语句类似,if语句也可以引入一个变量并检验它。例如:

在if条件中定义了整数n,通过v.size()初始化,并在分号后立即检验n的值是否非零。在if条件中定义的名字,其作用域和生命期被限制在if-else结构的各个分支内部。与for语句一样,在if语句中定义名字的目的,在于限制变量的作用域,提高可读性,减少发生错误的机会。

对于“n != 0”或“p != nullptr”这样的条件检验,通常可以省略不写。例如下面的写法与上述代码完全等价:

如果可以的话,建议采用这种更简单的形式。

C++17:带有初始值设定项的选择语句

1.9 映射到硬件

C++提供直接到硬件的映射。很多基本操作都是通过直接调用硬件功能实现的。典型的情况是执行单条机器指令。例如对两个int型变量x和y的加法计算x+y,就会直接执行处理整数加法的机器指令。

C++的实现可以直接将机器的一段内存区域,视作一个地址连续的容器,并将一个或多个(有类型的)对象置于其中,同时通过指针保存它们的地址,并访问之。

指针类型在内存中直接被表述为地址。上面图中的101和106都是内存地址。内存看起来非常象数组,地址就象数组元素的下标。事实上,数组正是C++对“连续内存中的对象序列”的基本抽象。

基本语言结构能够直接映射到硬件,这使得一门语言能获得系统原生的底层性能。C和C++语言数十年间正是以此著称于世。C和C++语言的基本机器模型是直接基于计算机硬件的,而不是某种高层形态的数学抽象。

1.9.1 赋值

对于内置类型而言,赋值语句的本质就是简单的内存复制。例如:

y
x
赋值
(3)2
2

两个对象在赋值前后都是独立的,不存在一个引用另一个的情况。赋值以后,修改x的值不会影响y,修改y的值也不会影响x。无论是int、double、bool等基本类型,还是结构体、类等复合类型,以上关于赋值语义的描述都适用。这与Java、C#等语言不同,但与C语言一致。

如果希望不同的对象,指向或引用相同的值,即共享同一块内存,必须显式地表达出来。例如:

147
258
y
x
赋值
(258)147
3
147
2

存放整型数值2和3的内存地址分别是147和258,保存在指针变量x和y中。经过“y = x”赋值以后,将x中的147复制到y中,覆盖其原来的258。最后x和y都指向存放整型数值2的内存。这时,修改x的目标也就是修改y的目标,反之亦然。

但如果换做是引用,情况会与指针不同。例如:

147
258
y
x
赋值
258
(3)2
147
2

访问指针的目标需要借助解引用操作符(*),而对引用解引用则是隐式操作。

1.9.2 初始化

初始化与赋值不同。通常在赋值操作中,被赋值的对象必须先拥有一个有效的值,该值会在赋值后被新值取代(覆盖),而初始化的任务则是令一段未定义的内存区域化身为一个有效的对象。对几乎所有数据类型而言,读写任何未初始化对象的结果都是未定义的。例如:

幸运的是,不会出现未初始化的引用,编译器会纠正这一点,但未初始化的指针,必然可能成为潜在缺陷的根源。

对引用的初始化,可以写成这样:

也可以写成这样:

这里的等号(=)并非赋值,依然是对引用r的初始化,令其引用已定义的整型变量x。

对很多用户自定义类型而言,严格区分初始化和赋值同样非常关键,比如string和vector,因为赋值通常伴随着对已有资源的释放,而初始化则不需要这么做。

向函数传递参数,和从函数中返回值,也属于初始化语义,特别是在接受引用型参数,和返回引用型返回值的情况下。

1.10 建议