第1章 基础
我们要做的第一件事儿,
就是把律师统统杀光。
—— 《亨利六世》(中)
1.1 导言
本章将简略地呈现C++的文法、内存模型和计算模型,和把代码组织成程序的基本机制。 这部分语言特性所支持的编程风格常见于 C, 也被称为 过程式编程(procedural programming) 。
1.2 程序
C++是种编译型语言。要运行一个程序,其源文本需要通过编译器处理, 生成一些目标文件,再经链接器组合后给出一个可执行文件。 一个典型的C++程序要使用多个源代码文件(通常简称为 源文件(source files) )生成。

可执行文件针对特定的 硬件/操作系统 组合而生成,不可移植,比方说, 不能从 Mac系统 拿到 Windows系统 上运行。 当我们说到 C++ 程序的可移植性时,一般是在说源代码的可移植性; 就是说,这份源码可以在多个系统上被编译并运行。
ISO C++ 标准定义了两类东西:
语言核心特性(core language features), 诸如内置的类型(例如
char和int) 以及循环(例如for-语句 和while-语句)标准库组件(standard-library components), 诸如容器(例如
vector和map) 以及 I/O 操作(例如<<和getline())
标准库组件完全是普通的 C++ 代码,由具体的 C++ 实现提供。 换句话说, C++ 标准库可以且确实是用 C++ 自身 (包括少量的机器语言代码,用于线程上下文切换等功能)实现的。 这意味着,对多数需求严苛的系统编程而言,C++ 具有足够的表达能力和效率。
C++ 是静态类型语言。 就是说,任何一个东西(例如对象、值、名称和表达式)被用到的时候, 编译器都必须已经知晓其类型。对象的类型确定了可施加操作的集合。
1.2.1 Hello, World!
最小的 C++ 程序是
这定义了一个名为main的函数,它不接受参数,也不执行任何操作。
花括号{},在 C++ 中表示结组。在此处,它标示了函数体的起止点。
双斜杠,//,发起一个注释并延续至行尾。
注释是供人阅读的,编译器忽略所有注释。
每个 C++ 程序必须有且只有一个名为main()的全局函数。
整个程序由该函数开始执行。
如果main()返回了任何int整数值,该值被返回给“系统”。
如果没有返回值,系统会接收到一个值,表示执行成功。main()返回的非零值表示出错。并非所有操作系统和执行环境都会用到这个返回值。
基于 Linux/Unix 的环境用它,而基于 Windows 的环境几乎不用。
一般来说,程序会有些输出,以下程序写出Hello, World!:
#include <iostream>这行告诉编译器,
在iostream中查找标准输入输出流相关的声明,并把它们 包含(include) 进来。
如果缺少了这些声明,以下表达式
就无效了。操作符<<(“输向”)把它的第二个参数写入第一个。
在本例中,字符串文本(string literal)"Hello, World!\n"
被写入到标准输出流std::cout上。
字符串文本是由双引号包围的一连串字符。
在字符串文本中,反斜杠\后跟一字符共同表示一个“特殊字符”。
在这里,是换行符,所以写出的字符是Hello, World!紧跟一个换行。
std::指出名称cout需要在标准库(standard-library)命名空间(§3.4)中查找。
我在谈论标准功能的时候通常略掉std::;
§3.4 里展示了一个方法,无需显式指出命名空间名,就可以让其中的名称可见。
一般来说,所有的可执行代码都会被置于函数中,
再由main()函数直接或者间接地调用,例如:
如果“返回类型”为void,表示该函数不返回任何值。
1.3 函数
如果在 C++ 程序里要做一件事,主要的方式是调用某个函数去执行它。 定义函数就是指定某个操作怎样被执行。 除非事先声明过,否则函数无法被调用。
函数声明给出了该函数的名称、返回值类型(如果有的话)、 以及调用它时必须提供的参数数量和类型。例如:
在函数声明中,返回值出现在名称前面,参数类型被括在小括号里,跟在名称后面。
参数传递的语法跟初始化(§3.6.1)的语法一致。 就是说,会检查参数类型,在必要的时候会对参数进行隐式类型转换(§1.4)。例如:
编译期类型检查和类型转换的价值至关重要。
函数声明中可以包含参数名。 这对程序的读者有益,但除非该声明同时也是函数定义,编译器将忽略这些参数名。例如:
函数的类型包含返回值类型和参数类型序列。例如:
函数可以作为类(§2.3, §4.2.1)的成员。 对于 成员函数(member function) 来说,类名也是该函数类型的组成部分。例如:
我们希望代码可读性好,因为这是可维护性的前提。
而可读性的前提是,将运算任务拆分成有意义的小块(体现为函数和类),并为其命名。
如同类型(内置类型和用户定义类型)为数据提供基本的词汇表那样,函数为运算提供词汇表。
C++ 标准算法(例如 find, sort 和 iota)开了个好头。
然后,我们可以编排函数,把通用或专用的作业表示成更大型的运算任务。
代码的报错数量跟代码的量和复杂度有关。 这两个问题都可以通过更多、更简短的函数来解决。 使用函数完成一项特定任务,总是能搭救我们,避免在其它代码中再敲入一段特定的代码; 创建函数会促使我们给一个行为命名并给它单独创建文档。
如果两个函数以同样的名称定义,但使用不同的参数类型,编译器将选用最恰当的函数调用。 例如:
如果有两个备选函数可供调用,但都不优于另一个, 此次调用将被判定为具有二义性,编译器会报错。例如:
多个同名函数的定义被称为 函数重载(function overloading) ,
是泛型编程(§7.2)的重要组成部分。
当函数被重载,所有同名函数应当具有相同的语义。print()函数就是这样的示例;每个print()函数都打印其参数。
1.4 类型,变量和算术运算
所有的名称、表达式都具有类型,以确定可执行的运算。例如,声明
指出,inch的类型是int;也就是说,inch是个整数变量。
声明(declaration) 是把一个实体引入程序的语句。它规定了这个实体的类型:
类型(type) 规定了一组可能的值和一组(针对对象的)运算
对象(object) 是一块内存,其中承载某种类型的值
值(value) 是一些二进制位,其含义由某个类型规定
变量(variable) 是一个具名对象
C++ 提供了一小撮基本类型,既然我并非组织学家,也就不把它们全部逐一列出了。 如果需要完整列表,可以求助参考资料, 例如网络上的 [Stroustrup,2013] 或者 [Cppreference]。 举几个例子:
每个基本类型都直接对应于硬件结构,其容量是固定的, 该容量决定了其中存储的值的取值范围。

char类型变量,在给定的机器上是保存一个字符的自然大小(通常是个8比特位的字节),
其余类型的容量都是char的整数倍。
类型的容量是实现定义的(就是说,在不同的机器上可以不一样),
可使用sizeof运算符获取;例如:sizeof(char) 等于 1,sizeof(int) 则通常是4。
数字可以是浮点数或者整数。
浮点数通过小数点识别(如:
3.14)或者使用科学计数法(如:3e-2)。整数文本默认是十进制(如:
42的意思是四十二)。 前缀0b表示二进制(基数是2)整数文本(如:0b10101010)。 前缀0x表示十六进制(基数是16)整数文本(如:0xBAD1234)。 前缀0表示八进制(基数是8)整数文本(如:0334)。
为提高长文本的可读性,可以使用单引号(')作为数字分隔符。
例如: π 大概是 3.14159'26535'89793'23846'26433'83279'50288,
如果用十六进制就是 0x3.243F'6A88'85A3'08D3。
1.4.1 算术运算
算术运算符可以对基本类型进行适当的组合:
比较运算符也是这样:
除此之外,还提供了逻辑运算符:
按位的逻辑运算,其结果的类型与操作数一致,值是对每个对应的位进行运算的结果。
逻辑运算符&& 和 || 依据操作数的值仅返回 true 或 false。
在赋值和算术运算中,C++ 会为操作数在基本类型之间执行任何有意义的转换, 以便它们可以任意混合:
表达式里用到的转换被称为 常规算术转换(the usual arithmetic conversions) ,
旨在确保按操作数中最高的精度执行表达式运算。
比如说,double 和 int 的加法运算,以双精度浮点数算术执行。
请留意,= 是 赋值运算符,而 == 是进行相等性判定。
除了传统的算术和逻辑运算符,C++ 还提供了专门的运算符用于修改变量:
这些运算符简明、便利,用得很频繁。
表达式的估值顺序是从左至右,赋值除外,它是从右到左。 很不幸,函数参数的估值顺序是未指定。
1.4.2 初始化
在某个对象被使用之前,必须给定一个值。
C++ 有多种初始化方法,比如上面用到的 =,
还有一种通用形式,基于花括号内被隔开的初值列表:
= 是传统形式,可追溯至C语言,但如果你拿不准,请使用 {}-列表 这种通用形式。
最起码,在涉及信息损失的类型转换时,它不会袖手旁观。
很不幸, 缩窄转换(narrowing conversion) 这种有损信息的形式,
比如double到int以及int到char,在使用=(而非{})的时候,
会被默许并悄无声息地进行。
这种由隐式缩窄转换导致的问题,是对C语言向后兼容(§16.3)的代价。
常量(§1.6)必须初始化,变量也只该在极罕见的情况下不初始化。
在准备好合适的值以前,别引入这个名称。
对于用户定义的类型(比如string、vector、Matrix、Motor_controller以及Orc_warrior)可将其定义为隐式初始化(§4.2.1)。
定义变量时,如果可以从初始值中推导出类型,就无需明确指定:
使用auto的情况下,我们往往用=,因为不涉及类型转化的隐患,
如果你更青睐{}初始化,但用无妨。
没有特定原因去指明类型时,就可以用auto,“特定原因”包括:
如果该定义处在较大的作用域中,希望其类型对阅读源码读的人一目了然。
希望明确规定变量的取值范围或精度(比方说,想用
double,而非float)。
运用auto,可以避免冗余,也不用敲很长的类型名。
在泛型编程中这尤为重要,这种情况下,对象的确切类型难于知晓,
而且类型名可能还特别长(§12.2)。
1.5 作用域和生命期
声明会把其名称引入到某个作用域:
局部作用域(local scope) : 声明在函数(§1.3)或lambda表达式(§6.3.2)内部的名称, 被称为 局部名称(local name) 。 它的作用域从声明的位置,延续到其声明所驻的代码块末尾。代码块(block) 由一对
{}界定。函数参数的名称也被视为局部名称。类作用域(class scope) : 定义在类(§2.2、§2.3、第4章)中,且在任何函数(§1.3)、 lambda表达式(§6.3.2)和
enum类(§2.5)之外的名称, 被称为 成员名称(member name) ——也叫 类成员名称(class member name) 。 其作用域从容纳它的类声明的左花括号{开始,到这个类声明的末尾。命名空间作用域(namespace scope) : 如果名称被定义在一个命名空间(namespace)(§3.4)里,且在任何函数(§1.3)、 lambda表达式(§6.3.2)、类(§2.2、§2.3、第4章)、和
enum类(§2.5)之外, 就称之为 命名空间成员名称(namespace member name) 。 其作用域从声明所在位置开始,直至命名空间结尾。
未定义于任何其它结构内的名称,被称作 全局名称(global name) , 位于 全局命名空间(global namespace) 中。
此外,某些对象可以不具名,例如临时变量,以及通过new创建的对象。例如:
对象在使用前必须先被构造(初始化),并将在其作用域末尾被销毁。
对于命名空间中的对象,其销毁的时间点位于程序的终止。
对成员来说,其销毁的时间点,由持有它的对象的销毁时间点确定。
经由new创建的对象,将“存活”至被delete(§4.2.2)销毁为止。
1.6 常量
关于不可变更,C++有两种概念:
const:相当于“我保证不会修改这个值”。 它主要用于指定接口,对于通过指针以及引用传入函数的数据,无需担心其被修改。 编译器为const作出的“保证”担保。一个const的值可在运行期间得出。constexpr:相当于“将在编译期估值”。 它主要用于定义常量,指定该数据被置于只读内存(在这里被损坏的几率极低)中, 并且在性能方面有益。constexpr的值必须由编译器算出。
例如:
如果函数要在 常量表达式(constant expression) 中使用,就是说,
用在由编译器估值的表达式里,则必须用constexpr定义。例如:
constexpr函数可以用于非常量的参数,但此时其结果就不再是常量表达式。
对于constexpr函数,在无需常量表达式的语境里,就可以用非常量表达式参数调用它。
如此一来,就不必把本质上相同的函数定义两遍:一遍用于常量表达式,另一遍用于变量。
要成为constexpr,函数必须极其简单,且不能有副作用,且只能以传入的数据作为参数。
尤其是,它不能修改非局部变量,但里面可以有循环,以及它自己的局部变量。例如:
在某些场合下,语言规则强制要求使用常量表达式(比如:数组界限(§1.7)、
case标签(§1.8)、模板的值参数(§6.2),以及用constexpr定义的常量)。
其它情况下,编译期估值都侧重于性能方面。
抛开性能问题不谈,不变性(状态不可变更的对象)是一个重要的设计考量。
1.7 指针、数组及引用
最基本的数据集合是:一串连续分配的,相同类型元素的序列,被称为 数组(array) 。
它基本脱胎于硬件。char类型元素的数组可以这样定义:
与之相似,指针的定义是这样:
在声明里,[]的意思是“什么什么的数组”,而*的意思是“指向什么什么东西”。
所有数组都以0作为下界,所以v有六个元素,从v[0]到v[5]。
数组容量必须是常量表达式(§1.6)。指针变量可持有相应类型对象的地址:
在表达式里,一元前置运算符*的意思是“什么什么的内容”,
而一元前置运算符&的意思是“什么什么的地址”。
我们可以把前面初始化定义的结果图示如下:

思考一下,从一个数组里复制十个元素到另一个:
for-语句可读作“把i置零;在i不等于10的时候,复制第i个元素并把i增1。”
自增运算符++应用在整数或浮点数变量上时,简单地给变量加1。
C++还提供一个简化的 for-语句,名为 区间-for-语句,是遍历序列最简单的方式:
第一个 区间-for-语句 可读作“对于v的每个元素,从头至尾,复制进x并打印它。”
请留意,以列表初始化数组的时候,无需为它指定界限。
区间-for-语句 可用于任何的元素序列(§12.1)。
若不想把v中的值复制到变量x,而是仅让x引用一个元素,可以这么写:
在声明中,一元前置运算符&的意思是“引用到什么什么”。
引用和指针类似,只是在访问引用指向的值时,无需前缀*。
此外,在初始化之后,引用无法再指向另一个对象。
在定义函数参数时,引用就特别有价值。例如:
通过引用,我们确保了在调用sort(my_vec)的时候,不会复制my_vec,
并且被排序的确实是my_vec,而非其副本。
想要不修改参数,同时还避免复制的开销,可以用const引用(§1.6)。例如:
接收const引用参数的函数很常见。
运算符(例如&、*及[])用在声明中的时候,
被称为 声明运算符(declarator operator) :
1.7.1 空指针
我们尽量确保指针总是指向一个对象,以便解引用操作合法。
当没有对象可指,或我们想表达“不存在有效对象”(比如:列表的终结)的概念时,
就让指针的值为nullptr(空指针)。仅有一个nullptr,供所有指针类型共享:
检查指针参数,以确保其有所指,小心驶得万年船:
请留意,可以用++递增指针,使其指向数组的下一个元素;
以及,在用不到的时候,for-语句的初始化部分可以留空。
在count_x()的定义里,
假定了这个char*是一个 C-风格 字符串(C-style string) ,
就是说,该指针指向一个以零结尾的char数组。
字符串文本中的字符是不可变的,为了能处理count_x("Hello!"),
我给count_x()声明了const char*参数。
在老式代码里,通常用0或NULL,而非nullptr。
但是,采用nullptr,
可以消除整数(比如0或NULL)和指针(比如nullptr)之间的混淆。
在count_x()的例子中,没使用for语句的初始化部分,
因此可以用更简单的while-语句:
while-语句会一直执行到其条件变成false为止。
对数值的判定(比如count_x()里的while(*p)),
等同于将其与0比较(也就是while(*p!=0))。
对指针指的判定(比如if(p)),
等同于将其与nullptr比较(也就是if(p!=nullptr))。
不存在“空引用”。引用必须指向有效的对象(并且编译器的实现假定是这样)。 有些隐晦的小聪明可以绕过这些规则;别那么干。
1.8 判定
C++有一套传统的语句表达选择和循环,
诸如if-语句、switch-语句、while-语句、for-语句。
举个例子,这有个很简单的函数,它向用户提问,并返回一个布尔值表示用户的反馈:
跟输出运算符<<(“输至”)配对,运算符>>(“取自”)用于输入;cin是标准输入流(第10章)。>>右操作数的类型决定可接受的输入内容,这个右操作数也是输入操作的收货方。
待输出字符串末尾的字符表示另起一行(§1.2.1)。
请留意,answer定义在被需要的地方(而非更靠前的位置)。
声明能够出现在任何可以出现语句的地方。
可以改良此例,让它也接收一个n(代表“no”)作为回应:
switch-语句把一个值跟一组常量进行比较。
这些常量被称为case-标签,必须互不相同,
如果该值与其中任何一个常量都不匹配,就执行default。
如果值不匹配任何case-标签,又没有default,就什么都不做。
如果一个函数里有switch-语句,在从该函数返回的时候,可以不退出case。
我们通常要继续执行switch-语句后续的内容。这可以使用break语句实现。
举个例子,这是个电子游戏的命令解析器,略原始,还有点小聪明:
与for-语句(§1.7)类似,if-语句接收一个值并对它判定。例如:
此处的整数n的定义仅在if-语句内使用,以v.size()初始化,
并立即由分号后的条件n!=0进行判定。
在条件中声明的名称,其作用域同时囊括了if-语句的两个分支。
与for-语句的情况相同,把名称声明在if-语句的条件中,
目的是限制变量的作用域以提升可读性,并减少错误发生。
最常见的情况是针对0(或nullptr)判定变量。
这种情况下,无需明确提及判定条件。例如:
请尽可能采用这种简洁的形式。
1.9 映射至硬件
C++ 提供到硬件的直接映射。
当你用到基础运算,其操作由硬件执行,通常是单个的机器操作。
例如把两个int相加,x+y执行一条整数加法的机器指令。
C++编译器实现把机器的内存视为一连串的存储位置, 可向其中放置(带类型的)对象,并可用指针对其寻址:

指针在内存里以机器地址表示,所以图中p的数值会是3。
如果这看起来像数组(§1.7),那是因为在C++中,数组就是对“内存中一连串对象”的抽象。
这种基本语言构件向硬件的映射,对底层性能至关重要,数十年来,C和C++就是闻名于斯。 C和C++的基本机器模型基于计算机硬件,而非某种数学概念。
1.9.1 赋值
内置类型的赋值就是简单的机器复制操作。例如:
显而易见。可以图示如下:

注意,两个对象是独立的。可以修改y的值却不牵连x。
比如x=99并不会修改y的值。
这一点对所有类型都成立,不仅仅是int,
这跟Java、C#以及其它语言不同,但和C语言一样。
如果想让不同的对象指向相同(共享)的值,必须明确指出。可以用指针:
可图示如下:

用88和92作为int的地址,是随便选的。
与前例相同,可见 赋值目标 获得了 赋值源 的值,
结果是两个独立的对象(此处都是指针),具有相同的值。
就是说p=q导致p==q。执行p=q后,两个指针都指向y。
引用和指针都 指引向/指向 对象,而且在内存里都表现为机器地址。 但是在语言规则里,二者的使用形式不同。 向一个引用赋值,不会改变它引用的目标,而是会给它引用的对象赋值:
可图示如下:

想要访问指针指向的值,需要借助*;但访问 引用所指的值 却是自动(隐式)的。
对于所有内置类型,以及设计良好
——提供=(赋值)和==(相等判定)——的用户定义类型(第2章),
执行过x=y后,都会得到x==y。
1.9.2 初始化
初始化和赋值不一样。 一般来说,想要让赋值操作正确运行,被赋值对象必须已经有一个值。 另一边,初始化的任务是让一块未初始化过的内存成为一个有效的对象。 对绝大多数类型来说,针对 未初始化变量 的读取和写入都是未定义的(undefined)。 对于内置类型,这在引用身上尤其明显:
很幸运,不存在未初始化的引用;
如果能,那么r2=99就会把99赋值给某个不确定的内存位置;
其结果会导致故障或者崩溃。
=可用于初始化引用,但千万别被它搞糊涂了。例如:
这依然是初始化r,并把它绑定到x上,而不涉及任何的值复制操作。
初始化和赋值的区别,对很多用户定义的类型
——比如string和vector——而言同样极度重要,
在这些类型中,被赋值的对象拥有一份资源,该资源最终将被释放(§5.3)。
参数传递和返回值返回的基本语义是初始化(§3.6)。 举例来说,传引用(pass-by-reference)就是这么实现的。
1.10 忠告
此处的忠告是 C++ Core Guidelines [Stroustrup,2015] 的子集。 以类似 [CG: ES.23]的形式引用向核心指南, 其意思是 Expressions and Statement 章节的 第23条规则。 通常,核心指南提供更深入的理论和用例。
[1] 别慌!船到桥头自然直;§1.1; [CG: In.0]。
[2] 不要专门或单独使用内置特性。 恰恰相反,基本(内置)特性,最好借助程序库间接使用, 比方说 ISO C++ 标准库(第8-15章);[CG: P.10]。
[3] 想写出好程序,不必对C++掌握到巨细靡遗。
[4] 把力气用在编程技术上,别死磕语言特性。
[5] 有关语言定义相关问题的最终解释, 请参考 ISO C++ 标准;§16.1.3; [CG: P.2]。
[6] 把有用的操作“打包”成函数,再取个好名字;§1.3; [CG: F.1]。
[7] 函数应当仅具有单一的逻辑功能;§1.3; [CG: F.2]。
[8] 保持函数简短;§1.3; [CG: F.3]。
[9] 当函数针对不同类型执行同样概念的操作时,请采用重载;§1.3。
[10] 当函数可能在编译期估值时,用
constexpr声明它;§1.6; [CG: F.4]。[11] 去理解基本语义向硬件的映射;§1.4, §1.7, §1.9, §2.3, §4.2.2, §4.4。
[12] 用数字分隔符为大文本值提高可读性;§1.4; [CG: NL.11]
[13] 不要使用复杂表达式;[CG: ES.40]
[14] 不要使用导致范围缩小的类型转换;§1.4.2; [CG: ES.46]
[15] 尽量让变量的作用域保持最小;§1.5
[16] 不要使用“魔数”;使用符号常量;§1.6; [CG: ES.45]。
[17] 尽量用不可变更的数据;§1.6; [CG: P.10]。
[18] 每个声明里有(且仅有)一个名称;[CG: ES.10]
[19] 保持常见和局部名称简短,让不常见和非局部名称长一些;[CG: ES.7]。
[20] 不要使用形似的名称;[CG: ES.8]。
[21] 不要使用全大写(
ALL_CAPS)名称;[CG: ES.9]。[22] 在提及类型的声明里,尽量用
{}-初始化 语法;§1.4; [CG: ES.23]。[23] 使用
auto以避免重复输入类型名;§1.4.2; [CG: ES.11]。[24] 尽量别弄出来未初始化的变量;§1.4; [CG: ES.20]。
[25] 尽量缩小作用域;§1.5; [CG: ES.5]。
[26] 如果在
if-语句的条件中定义变量,尽量采用针对0的隐式判定;§1.8。[27] 仅在涉及位操作时,使用
unsigned;§1.4; [CG: ES.101] [CG: ES.106]。[28] 确保对指针的使用简单且直白;§1.7; [CG: ES.42]。
[29] 用
nullptr,而非0或NULL;§1.7; [CG: ES.47]。[30] 在有值去初始化它之前,别声明变量;§1.7, §1.8; [CG: ES.21]。
[31] 别给直观的代码写注释; [CG: NL.1]。
[32] 用注释阐释意图;[CG: NL.2]。
[33] 保持缩进风格一致;[CG: NL.4]。
最后更新于