非正式地展示C++的符号、C++的内存和计算模型,以及将代码组织成程序的基本机制。其中所涉及到的语言特性和编程风格与C语言类似,通常被称为面向过程编程。
C++是一门编译型语言。用C++语言编写的源代码,首先要交由编译器编译,得到目标文件,然后借助链接器,将这些目标文件及其所依赖的库,链接成可执行程序。
可执行程序通常是为特定的硬件与操作系统的组合而定制的。一个能够在个人电脑上的Windows系统中运行的可执行程序,是无法在智能手机上的Android系统中运行的。通常所谓的C++程序的可移植性,指的是源代码的可移植性,即一份源代码可以在不同硬件上的不同操作系统中,被成功地编译、链接和运行。
ISO C++标准定义了两种实体:
核心语言特性:如内置类型(char、int、······)、流程控制(if-else分支、for循环、······)等
标准库组件:如容器(vector、map、······)、输入输出操作(<<、getline、······)等
C++标准库组件通常由C++语言本身编写。
C++是一门静态类型语言。这意味着,每一个实体(如对象、值、名称和表达式)在其被引用的上下文中,编译器必须明确知道其准确的类型。实体的类型决定了它所适用的操作集合,及其在内存中的布局形式。
最小的C++程序类似下面这样:
1int main() {}
这里定义了一个名为main的函数,它不接受任何参数,也不执行任何操作
一对花括号“{}”表示C++中的一个编组,这里用于标识函数体的开始和结束
双斜杠“//”表示一段注释的开始,这段注释到行尾结束。注释是供人阅读的文本,编译器会忽略代码中的注释
每个C++程序必须有且仅有一个名为main的全局函数,表示从这里开始执行。main函数返回一个整型值,表示程序执行成功还是失败。如果main函数没有显式地返回任何值,系统默认收到表示成功的返回值。通常从main函数中返回零代表成功,返回非零代表失败,但并非所有操作系统及运行环境,都会用到这个返回值。比如UNIX/Linux系统会经常用到它,而Windows系统却极少使用它。
典型的程序会产生一些输出,比如下面这样:
x1import std;
2
3int main() {
4 std::cout << "Hello, World!\n";
5}
程序代码的第1行,指示编译器去声明标准库变量的存在。如果没有这个声明,std::cout将变得毫无意义
import指令用于导入指定的模块,这里导入的是表示标准库的模块std
操作符“<<”表示将其右操作数的值插入其左操作数所表示的对象中,这里是将字符串“Hello, World!”插入标准输出流std::cout中,即打印该字符串
“std::”表示其后的标识符cout来自标准库命名空间
字符串字面量是一系列被双引号包裹起来的字符
在字符串字面量中,反斜杠“\”与它后面的一个字符相结合,表示一个“特殊字符”,谓之转义字符。此处的“\n”即表示换行符
import指令是C++20的新特性,但将所有的标准库放进一个单独的std模块还没有成为标准。截止目前,GNU C++编译器尚不支持本案例第1行中的写法。Visual C++ 2022支持这样的写法,但需做特殊的工程配置:
事实上,所有可执行代码最终都会被放进函数,并被main函数直接或间接地调用。例如:
xxxxxxxxxx
171// 求双精度浮点数的平方
2
3double square(double x) {
4 return x * x;
5}
6
7// 打印双精度浮点数的平方
8
9void print_square(double x) {
10 cout << x << "的平方是" << square(x) << "\n";
11}
12
13// 主函数
14
15int main() {
16 print_square(1.234); // 1.234的平方是1.52276
17}
“返回类型”为void,表示函数不返回值。
C++程序完成某件事情的方法主要是通过调用函数来实现的。程序设计者可以定义自己的函数以执行特定的操作。在调用一个函数之前,必须先声明它。
函数声明给出了函数的名称,调用该函数时必须提供的参数类型和数量,以及返回值的类型(如果有的话)。例如:
xxxxxxxxxx
31Elem* next_elem(); // next_elem函数,没有参数,返回一个指向Elem对象的指针
2void exit(int); // exit函数,接受一个整型参数,没有返回值
3double sqrt(double); // sqrt函数,接受一个双精度浮点型参数,并返回一个双精度浮点型返回值
在函数声明中,返回值的类型位于函数名之前,各个参数的类型则位于函数名后面的圆括号内。
在调用一个函数时向其传递参数,其本质就是在做初始化,用实参初始化形参。这意味着,在检测参数类型的过程中,有可能会发生隐式的类型转换。例如:
xxxxxxxxxx
21double s2 = sqrt(2); // 调用sqrt函数,参数为double{2}
2double s3 = sqrt("three"); // 编译错误,无法将一个字符串隐式转换为double
不要低估编译时类型检查和隐式类型转换的价值。
函数声明可以包含参数的名称,这有助于提高程序代码的可读性,但除非在声明函数的同时给出函数的定义,否则编译器会忽略函数声明中的参数名称。例如:
xxxxxxxxxx
21double sqrt(double d); // 返回d的平方根
2double square(double); // 返回参数的平方
函数也是有类型的。函数的类型由其返回值的类型和圆括号中的参数类型序列组成。例如:
xxxxxxxxxx
11double get(const vector<double>& vec, int index);
的类型为“double(const vector<double>&,int)”。
函数可以是类的成员。对成员函数而言,类的名称也是函数类型的一部分。例如:
xxxxxxxxxx
11char& String::operator[](int index);
的类型为“char& String::(int)”。
编写易于理解的代码,是提高程序可维护性的第一步。为此需要将计算任务分解为一系列有意义的“块”——函数和类——并为其命名。函数为计算提供了基本词汇,就象(内置和用户定义的)类型为数据提供了基本词汇一样。
C++标准库中的算法函数,如find、sort、iota等,提供了一个良好的起点。接下来的工作就是将完成通用和特殊任务的各种函数组合成更大的任务。
代码中的错误数量与代码量和代码复杂性密切正相关。降低代码量和代码复杂性的有效方法之一,就是编写更多且更短的函数。为特定的任务定义函数,既能避免将相同或类似的代码散布于程序的各个角落,又能强制程序设计者对任务进行命名并跟踪其依赖。如果无法为函数确定贴切的名称,则往往意味着任务划分存在问题,设计缺陷就此暴露无遗。
如果两个或更多函数具有相同的名称,但带有不同的参数,则编译器会选择最合适的版本进行调用。例如:
xxxxxxxxxx
31void print(int); // 接受一个整型参数的版本
2void print(double); // 接受一个双精度浮点型参数的版本
3void print(string); // 接受一个字符串参数的版本
xxxxxxxxxx
31print(42); // 调用print(int)
2print(9.65); // 调用print(double)
3print("Hello, World!"); // 调用print(string)
如果有两个或更多版本均可选择,参数匹配的程度难分伯仲,此即模棱两可的调用,编译器会报告错误。例如:
xxxxxxxxxx
21void print(int, double);
2void print(double, int);
xxxxxxxxxx
11print(0, 0); // 编译错误,模棱两可(ambiguous)
在编程过程中,可以使用相同的函数名定义多个函数,谓之函数重载。函数重载也是泛型编程的基本特性之一。当一个函数被重载时,同名的每个版本都应该实现相同的语义。就象上面的print函数,无论它的哪个版本,都会打印出它所接收到的参数。而对于下面的版本:
xxxxxxxxxx
11void print(); // 不接受任何参数的版本
虽然也能构成重载,但并不是好的设计,因为它不接受任何参数,在语义上与该函数的其它重载版本并不一致。
每个名字和每个表达式都有自己的类型,类型决定了它们可以执行的操作。例如:
xxxxxxxxxx
11int inch;
上述声明指定了变量inch是int类型的。这意味着inch是一个整型变量。
声明(declaration)是一条语句,它为程序引入了一个实体,并指定了它的类型:
类型(type)定义了对象的取值范围及可执行的操作
对象(object)是用于存放特定类型值的内存空间
值(value)是一系列二进制位,具体含义由其类型定义
变量(variable)是一个有名字的对象
C++提供了数量众多的基础类型,包括但不限于:
bool:布尔值,可取值true或false,表示逻辑真或逻辑假
char:字符,如'a'、'z'、'9'等
int:整数,如-273、42、1066等
double:双精度浮点数,如-273.15、3.14、6.626e-34等
unsigned:非负整数,如0、1、999等,常用于位操作
每种基础类型都与硬件设施直接对应,硬件决定了基础类型数据在内存中占用的字节数,内存中的字节数决定了它们的取值范围。
xxxxxxxxxx
711Byte=8bits
2________
3bool: |________|
4char: |________|__________________________
5int: |________|________|________|________|___________________________________
6double: |________|________|________|________|________|________|________|________|
7unsigned: |________|________|________|________|
char类型的变量在内存中通常占用一个字节,即八个二进制位。其它类型变量的字节数则是char类型变量字节数的整数倍。一种类型的实际字节数可能因不同的机器而异。通过sizeof操作符可以获得特定类型的实际字节数。例如sizeof(char)通常是1,而sizeof(int)则通常是4。如果需要明确指定类型的字节数,则可以使用标准库提供的类型别名,如int32_t等。
数字可以是浮点数或整数:
浮点数字面量含有小数点,如3.14,或者指数符号,如314e-2,表示
整数字面量默认采用十进制,如42,表示四十二,添加不同的前缀,以采用其它进制:
前缀0b表示二进制,如0b10101010
前缀0表示八进制,如0334
前缀0x表示十六进制,如0xBAD12CE3
为了改善长字面量的可读性,可以使用单引号(')作为数字分隔符,如圆周率
C++14:二进制字面量
C++14:数字分隔符
C++17:十六进制浮点数字面量
基础类型的数据可以通过下列算术操作符组合为算术表达式:
x+y,加法
+x,取正(一元加法)
x-y,减法
-y,取负(一元减法)
x*y,乘法
x/y,除法
x%y,整数求余(取模)
还可以使用这些比较操作符:
x==y,相等
x!=y,不等
x<y,小于
x>y,大于
x<=y,小于等于
x>=y,大于等于
此外,还可以使用逻辑或位操作符:
x&y,按位与
x|y,按位或
x^y,按位异或
~x,按位取反
x&&y,逻辑与
x||y,逻辑或
!x,逻辑非
按位操作表达式的值与操作数的类型相同。逻辑操作表达式的值为布尔类型的true或false。
在赋值及算术操作表达式中,C++会在基本类型之间进行各种有意义的隐式类型转换,使它们可以自由地混合使用。例如:
xxxxxxxxxx
61void some_functioin() { // 不返回值的函数
2 double d = 2.2; // 初始化浮点数
3 int i = 7; // 初始化整数
4 d = d + i; // 先将i的值转换为浮点数,与浮点数d求和,再将浮点数的求和结果赋值给浮点数d
5 i = d * i; // 先将i的值转换为浮点数,与浮点数d求积,再将浮点数的乘积转换为整数赋值给i
6}
发生在这些表达式中的隐式类型转换,称为常用算术类型转换,旨在确保表达式以最高精度的操作数类型完成运算。例如double和int类型的加法和乘法计算,会先将int类型的操作数转换为double类型,再对两个double类型的操作数执行加法和乘法计算。
注意,“=”是赋值操作符,而“==”是相等性判断操作符。
除了常见的算术运算与逻辑运算,C++还提供了一组用于修改变量的操作符:
x+=y,先将x与y的值相加,再将计算结果赋值给x,相当于x=x+y
++x,x的值增加1,相当于x=x+1
x-=y,先将x与y的值相减,再将计算结果赋值给x,相当于x=x-y
--x,x的值减少1,相当于x=x-1
x*=y,先将x与y的值相乘,再将计算结果赋值给x,相当于x=x*y
x/=y,先将x与y的值相除,再将计算结果赋值给x,相当于x=x/y
x%=y,先将x与y的值求余,再将计算结果赋值给x,相当于x=x%y
绝大多数操作符的结合顺序是从左向右的。例如:
xxxxxxxxxx
51x + y + z
2|___| |
3|_____|
4|
5表达式的值
但包含赋值语义的操作符的结合顺序却是从右向左的。例如:
xxxxxxxxxx
51x += y += z
2| |____|
3|____|
4|
5表达式的值
由于某些历史原因,以函数的返回值作为操作数的表达式,或函数的参数,函数调用的顺序是未定义的。例如:
xxxxxxxxxx
21f(x) + g(y)
2h(f(x), g(y))
其中f函数和g函数的调用顺序并不确定。
C++17:严格指定运算顺序
xxxxxxxxxx
181double d1 = 2.3; // 初始化d1为2.3
2double d2{ 2.3 }; // 初始化d2为2.3
3double d3 = { 2.3 }; // 初始化d3为2.3
4complex<double> z1 = 1; // 实部虚部均为双精度浮点数的复数
5complex<double> z2{ d1, d2 }; // 实部为d1虚部为d2的复数
6complex<double> z3 = { d1, d2 }; // 这里的等号(=)可以省略
7vector<int> v{ 1, 2, 3, 4, 5, 6 }; // int类型的动态数组
8
9int i1 = 7.8; // i1的值为7,从double到int的隐式类型转换,导致精度丢失
10// int i2{ 7.8 }; // 编译错误,编译器禁止在花括号列表初始化中发生类型窄化
11
12cout << d1 << ' ' << d2 << ' ' << d3 << endl;
13cout << z1 << ' ' << z2 << ' ' << z3 << endl;
14
15for (int n : v) cout << n << ' ';
16cout << endl;
17
18cout << i1 /*<< ' ' << i2 */<< endl;
在对象被使用之前,必须为其指定一个初始值,这个操作称为初始化。C++提供了多种表达初始化操作的语法和符号,比如单个的等号(=)。此外,C++还提供了一种更为通用的形式,即用花括号括起来,并用逗号分隔多个值的,所谓初始化列表。
xxxxxxxxxx
71double d1 = 2.3; // 初始化d1为2.3
2double d2{ 2.3 }; // 初始化d2为2.3
3double d3 = { 2.3 }; // 初始化d3为2.3
4complex<double> z1 = 1; // 实部虚部均为双精度浮点数的复数
5complex<double> z2{ d1, d2 }; // 实部为d1虚部为d2的复数
6complex<double> z3 = { d1, d2 }; // 这里的等号(=)可以省略
7vector<int> v{ 1, 2, 3, 4, 5, 6 }; // int类型的动态数组
等号(=)形式的初始化源于C语言的传统风格,如果拿不定主意该用什么形式初始化,使用花括号列表初始化总是正确的,而且还能防止因隐式类型转换而导致的信息损失。
xxxxxxxxxx
21int i1 = 7.8; // i1的值为7,从double到int的隐式类型转换,导致精度丢失
2int i2{ 7.8 }; // 编译错误,编译器禁止在花括号列表初始化中发生类型窄化
当采用等号(=)而非花括号({})的形式执行初始化操作时,编译器会接受因类型窄化(如从double到int)而导致的信息损失。这是为与C语言兼容而不得不付出的代价。
常量必须在声明的同时初始化。普通变量虽无此硬性要求,但也仅在极其有限的特定情况下,会处于未初始化状态,毕竟待到有合适初值时再定义变量也是没有问题的。用户自定义类型(如string、vector、Matrix、MotorController、OrcWarrior等)的变量可在定义时被隐式初始化。
在定义变量时,如果其类型可以从初始值设定项中推导出来,则无需显式指定其类型,代之以auto关键字即可。
xxxxxxxxxx
131auto b = true; // bool
2auto c = 'x'; // char
3auto n = 123; // int
4auto l = 456L; // long
5auto u = 789U; // unsigned int
6auto f = 1.2F; // float
7auto d = 3.4; // double
8auto r{ sqrt(5) }; // double
9
10cout << typeid(b).name() << ' ' << typeid(c).name() << ' ' <<
11 typeid(n).name() << ' ' << typeid(l).name() << ' ' <<
12 typeid(u).name() << ' ' << typeid(f).name() << ' ' <<
13 typeid(d).name() << ' ' << typeid(r).name() << endl;
借助auto关键字声明变量,倾向于使用等号初始化,因为这里不存在因隐式类型转换而导致信息损失的风险。当然,使用列表初始化也没有任何问题。
当没有明显理由需要显式指定类型时,强烈建议使用auto关键字。这里的“明显理由”包括:
该定义的作用域较大,希望代码的阅读者明确知道变量的确切类型
初始化设定项的类型,不是显而易见的
希望明确限定变量的范围和精度,例如希望使用long而非int,或者希望使用double而非float
借助auto关键字,可以避免书写冗长的类型名称及重复代码。这在泛型编程中显得尤其重要。因为在泛型编程中,程序员往往很难知道对象的确切类型,而且类型名称可能会相当长。例如:
xxxxxxxxxx
11map<string, list<storage_info_t>>::const_iterator it = groups.begin();
可以简化书写成:
xxxxxxxxxx
11auto it = groups.begin();
C++11:通过花括号列表,执行统一且通用的初始化
C++11:借助auto关键字,从初始值设定项中推导出类型
C++11:避免类型窄化
声明语句的作用就是将一个名字引入当前作用域。这个“当前作用域”可以是:
局部作用域:在函数和匿名函数中声明的名字叫局部名字。它的作用域从声明它的地方开始,到声明语句所在语句块的结尾为止。语句块的边界由一对花括号表示。函数形参的名字也属于局部名字
类作用域:如果一个名字被声明于类的内部,并且不在任何函数、匿名函数和enum class中,那么它就是该类的一个成员名字。它的作用域从包括其声明语句的左花括号开始,到相应的右花括号为止
命名空间作用域:如果一个名字被声明于命名空间的内部,并且不在任何函数、匿名函数和enum class中,那么它就是该命名空间的一个成员名字。它的作用域从声明它的地方开始,到命名空间结束为止
全局(命名空间)作用域:在所有结构之外声明的名字称为全局名字。全局名字隶属于全局命名空间,其作用域为全局作用域
某些对象也可能没有名字,比如临时对象或者用new操作符创建的对象。
xxxxxxxxxx
171vector<int> vec; // vec是一个全局作用域中的全局名字
2
3namespace ns {
4 int var; // var是一个命名空间作用域中的成员名字
5 // ...
6}
7
8void fct(int arg) { // fct是一个全局作用域中的全局名字,arg是一个局部作用域中的局部名字
9 string motto{ "Who dares wins" }; // motto是一个局部作用域中的局部名字
10 auto p = new Record{ "Hume" }; // p指向一个无名Record对象
11 // ...
12}
13
14struct Record {
15 string name; // name是一个类作用域中的成员名字
16 // ...
17};
对象必须先被构造(初始化)才能被使用,并在其离开作用域时被销毁:
局部作用域中的对象,在函数返回或其所在语句块结束时被销毁
类作用域中的对象,与其所隶属的对象一起被销毁
命名空间(包括全局命名空间)作用域中的对象,在程序结束时被销毁
通过new操作符创建的对象,在对其执行delete操作时被销毁
C++支持两种不变性:
const:大致意味着“我承诺不修改其值”。主要用于声明函数的指针或引用型输入参数。对于这样的参数,函数调用者不必担心其被函数意外地修改,编译器将负责履行const承诺。被const声明的值可以在运行时获得
constexpr:大致意味着“请在编译时计算其值”。主要用于声明常量,其目的是把数据置于进程内存空间的只读区域中(更小概率被破坏),以提高性能。被constexpr声明的值必须在编译时获得
为了使一个函数可在常量表达式中使用,必须将其声明为constexpr或consteval。这样的函数可在编译时被调用执行
一个constexpr函数既可以在编译时被执行,也可以在运行时被执行,具体取决于调用该函数的上下文
如果希望一个函数仅在编译时被执行,应将其声明为consteval而不是constexpr
xxxxxxxxxx
421import std;
2
3using namespace std;
4
5double sum(const vector<double>& vec) { // sum不会修改传递给它的参数
6 double res = 0;
7 for (double val : vec)
8 res += val;
9 return res;
10}
11
12constexpr double square(double x) { // square可在编译时被执行
13 return x * x;
14}
15
16consteval double cube(double x) { // cube只能在编译时被执行
17 return x * x * x;
18}
19
20int main() {
21 constexpr int dmv = 17; // dmv是一个命名常量
22 int var = 17; // var不是常量
23 const double sqr = sqrt(var); // sqv是一个命名常量,其值在运行时获得
24
25 vector<double> vec{ 1.2, 3.4, 5.6 }; // vec不是常量
26 const double sum1 = sum(vec); // sum1是一个命名常量,其值在运行时获得
27 // constexpr double sum2 = sum(vec); // 编译错误,sum2是一个命名常量,其值只能在编译时获得
28
29 cout << dmv << ' ' << var << ' ' << sqr << ' ' << sum1 << /*' ' << sum2 << */endl;
30
31 constexpr double squ1 = 1.4 * square(17); // squ1是一个命名常量,其值在编译时获得
32 // constexpr double squ2 = 1.4 * square(var); // 编译错误,squ2是一个命名常量,其值只能在编译时获得
33 const double squ3 = 1.4 * square(var); // squ3是一个命名常量,其值在运行时获得
34
35 cout << squ1 << /*' ' << squ2 << */' ' << squ3 << endl;
36
37 constexpr double cb1 = 2.5 * cube(17); // cb1是一个命名常量,其值在编译时获得
38 // constexpr double cb2 = 2.5 * cube(var); // 编译错误,cb2是一个命名常量,其值只能在编译时获得
39 // const double cb3 = 2.5 * cube(var); // 编译错误,cube只能在编译时被执行
40
41 cout << cb1 << /*' ' << cb2 << ' ' << cb3 << */endl;
42}
被声明为constexpr或consteval的函数称为纯函数,即数学意义上的函数。纯函数不能有任何副作用,比如只能带有输入参数,不能带有输出参数,也不能修改任何非局部变量的值,等等,但它可以有自己的循环和自己的局部变量。例如下面计算
xxxxxxxxxx
91constexpr double nth(double x, int n) {
2 double res = 1;
3 int i = 0;
4 while (i < n) {
5 res *= x;
6 ++i;
7 }
8 return res;
9}
某些场合,语言规则强制要求使用常量表达式,比如数组的大小、switch-case语句中的标签、模板的值参数,以及通过constexpr定义的常量等。另一些情况下,如果希望借助编译时求值改善程序的运行性能,也可以使用常量表达式。即使不考虑性能因素,不变性概念(对象的状态恒久不变)也是程序设计中需要考虑的问题。
C++11:更加通用且有保证的常量表达式constexpr
C++14:改进constexpr函数,允许使用循环
C++20:保证编译时求值的consteval函数
C++20:保证静态(非运行时)初始化的constinit变量
最基本的数据集合是相同类型元素的连续序列,谓之数组。这种序列的逻辑结构与其在硬件上的物理结构一致。比如可以象下面这样声明一个字符类型的数组:
xxxxxxxxxx
11char v[6]; // 6个字符组成的数组
类似地,下面是一个对字符指针的声明:
xxxxxxxxxx
11char* p; // 指向字符的指针
在上述声明语句中,方括号([])表示数组类型,星号(*)则表示指针类型。所有数组的下标都从0开始,因此数组v中的6个元素,可通过下标0、1、2、······、5引用,即v[0]、v[1]、v[2]、······、v[5]。数组的大小必须是一个常量表达式。指针变量可以存放特定类型对象的地址:
xxxxxxxxxx
21char* p = &v[3]; // 指针p指向数组v中的第4个元素
2char x = *p; // *p表示指针p所指向的对象
在表达式中,前置一元操作符“&”表示获取操作数的地址,而“*”则表示获取操作数的目标。如下图所示:
xxxxxxxxxx
61_______________________
2v|___|___|___|___|___|___|
30 1 2 3 4 5
4^
5_|_
6p|___|
下面的代码打印输出了一个数组中的全部元素:
xxxxxxxxxx
51int v[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
2
3for (auto i = 0; i < 10; ++i)
4 cout << v[i] << ' ';
5cout << endl;
上述代码中的for循环可以这样解读:将i的值置为0,只要i的值小于10,就执行循环体,打印输出数组v中下标为i的元素的值,并对i做自增,再次判断i的值是否小于10,若i的值小于10则继续执行循环体,打印输出数组v中下一个元素的值,重复以上过程,直到i的值不小于10,退出for循环,执行循环结构之后的语句。
除了这种继承自C语言的for循环以外,C++还提供了另一种更简单的基于范围的for循环,专门针对这种遍历容器中所有元素的操作:
xxxxxxxxxx
91int v[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
2
3for (auto x : v) // 借助变量x遍历数组v中的每个元素
4 cout << x << ' ';
5cout << endl;
6
7for (auto x : { 10, 21, 32, 43, 54, 65 }) // 借助变量x遍历列表中的每个元素
8 cout << x << ' ';
9cout << endl;
上述代码中的第一个for循环可以这样解读:对于数组v中的每一个元素,从第一个到最后一个,依次复制一份到变量x中,在循环体中打印输出x的值。注意,当给基于范围的for循环指定了序列时,就不需要再强调序列的边界了,C++会自动处理从哪里开始到哪里结束的问题。基于范围的for循环可用于遍历任何类型的对象序列。
如果不希望将数组v中的每个元素依次复制给变量x,而是希望通过x引用数组v中的每个元素,可象下面这样编写代码:
xxxxxxxxxx
81int v[10]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
2
3for (auto& x : v) // 将数组v中的每个元素置换为该元素的平方
4 x *= x;
5
6for (auto const& x : v) // 打印输出数组v中的每个元素
7 cout << x << ' ';
8cout << endl;
在上述代码中,后置一元操作符“&”表示某种类型的引用。引用于指针类似,区别在于无须使用前置一元操作符“*”即可访问其所引用的目标对象本身,而非其副本。引用一旦获得目标就不能再引用其它对象了。
引用在指定函数的参数类型时特别有用。例如:
xxxxxxxxxx
11void sort(vector<double>& v); // 对double型向量v中的元素排序
借助引用,可以确保类似sort(values)这样的函数调用,不会复制整个values向量给函数的参数v,进而保证是values向量本身被排序,而不仅仅是排序了它的一份拷贝。
有时可能既希望避免参数复制的开销,又不希望实参在函数中被意外修改。为此可以使用const引用。例如:
xxxxxxxxxx
11double sum(const vector<double>& v);
事实上,函数接受const引用作为输入参数的情况非常普遍。
声明语句中的“[]”、“*”、“&”和“()”,称为声明操作符:
“[]”表示数组,“T a[n]”表示a是一个包含n个T类型元素的数组
“*”表示指针,“T* p”表示p是一个保存T类型对象的地址,即指向该T类型对象的指针
“&”表示引用,“T& r”表示r是一个T类型对象的别名,即引用该T类型对象的引用
“()”表示函数,“T f(X)”表示f是一个接受X类型参数并返回T类型结果的函数
而在非声明语句中,这些操作符的语义则有所不同:
“[]”表示下标操作,“a[0]”表示数组a中下标为0的元素
“*”表示获取目标,“*p”表示获取指针p所指向的目标
“&”表示获取地址,“&x”表示获取变量x的内存地址
“()”表示函数调用,“f(x)”表示调用名为f的函数,同时传入x作为参数
注意其中的微妙差别与内在联系。
C++11:基于范围的for循环
通过前置一元操作符“*”获取一个指针所指向的目标,这种操作称为解引用操作。要想使解引用操作有效,就必须保证指针中存放的是一段有效内存的地址,即有对象可指,但并不是所有指针在任何时候都有对象可指,比如双向链表中头结点的前指针和尾节点的后指针。为了表达这种无所指向的语义,C++提供了空指针的概念。任何类型的空指针都可以用关键字nullptr表示。例如:
xxxxxxxxxx
31double* pd = nullptr; // 不指向任何double型变量的double型指针
2Link<Record>* lst = nullptr; // 不指向任何链表对象的链表型指针
3int x = nullptr; // 编译错误,nullptr只能用于指针,不能用于整数
在使用指针之前检查其是否为空,在任何时候都不失为一种明智之举:
xxxxxxxxxx
121int count(const char* p, char c) {
2 if (p == nullptr)
3 return 0;
4
5 int cn = 0;
6
7 for (; *p != '\0'; ++p)
8 if (*p == c)
9 ++cn;
10
11 return cn;
12}
参数p指向的有可能是一个字符串字面量,其只读属性决定了使用const char*作为其类型更为安全
后续所有关于指针p的操作都是建立在p不是空指针的前提下,因此先就p是否等于nullptr进行判断
for循环头中,被两个分号分隔的三个表达式,如无必要可以省略不写,但分号必须保留
++p即令指针p指向字符串中的下一个字符
在旧式代码中,常用0或NULL表示空指针,其实它们都是整数,与指针在类型上存在潜在的歧义,使用指针类型的nullptr可以消除这种因歧义而导致的负面影响。
上述代码中的for循环也可以用while循环代替:
xxxxxxxxxx
121int count(const char* p, char c) {
2 if (!p)
3 return 0;
4
5 int cn = 0;
6
7 while (*p)
8 if (*p++ == c)
9 ++cn;
10
11 return cn;
12}
while循环的循环体重复执行,直到while后面表达式的值为false为止
“*”和“++”操作符的优先级相同,但按从右至左的顺序执行,因此“*p++”等价于“*(p++)”
将一个数值直接当作条件来判断,如“while (*p)”,等价于将这个值与0进行比较,如“while (*p != 0)”
将一个指针直接当作条件来判断,如“if (!p)”,等价于将这个指针与nullptr进行比较,如“if (p == nullptr)”
指针可以为空,但引用不能,一个引用必须引用有效的对象,编译器亦假定如此。当然,通过一些奇技淫巧确实可以突破这个限制,但最好还是不要这么做。
C++11:用关键字nullptr表示空指针
C++提供了一套用于表示分支和循环的常规语句,比如if-else条件分支语句、switch-case开关分支语句、while循环语句和for循环语句等。例如下面的accept函数,它向用户提问,并根据用户的回答,返回相应的布尔值:
xxxxxxxxxx
101bool accept() {
2 cout << "您想继续吗(y/n)?" << flush; // 打印问题
3 char answer = 0; // 初始化为用户不可能输入的值
4 cin >> answer; // 读取用户的输入
5
6 if (answer == 'y')
7 return true; // 输入y则返回true
8 else
9 return false; // 否则返回false
10}
与插入流操作符(<<)相对应,提取流操作符(>>)用于从流对象中读取数据。cin代表标准输入流,通常是键盘。“>>”操作符右操作数决定输入数据的类型,同时也是输入操作的目标。将流控制符flush插入输出流,意在强制将输出缓冲区中的数据刷到输出设备上。
注意,局部变量answer的定义出现在确实需要它的地方,不必提前。声明语句可以出现在任何需要声明的地方。
下面的改进版,将用户的输入范围明确限定在字符'y'和字符'n'之间,输入其它字符视同'n',但给出必要提示:
xxxxxxxxxx
171bool accept() {
2 cout << "您想继续吗(y/n)?" << flush; // 打印问题
3 char answer = 0; // 初始化为用户不可能输入的值
4 cin >> answer; // 读取用户的输入
5
6 switch (answer) {
7 case 'y':
8 return true; // 输入y则返回true
9
10 case 'n':
11 return false; // 输入n则返回false
12
13 default:
14 cout << "输入" << answer << "与输入n等价" << endl;
15 return false; // 输入其它字符则返回false
16 }
17}
switch-case开关分支语句检查一个值是否在一组常量中。这些常量即case标签。case标签不能重复,如果检验值不与任何case标签匹配,则执行default分支。如果没有提供default分支,则当检验值不与任何case标签匹配时什么也不做。
在使用switch-case开关分支语句时,如果想要退出一个case分支,不一定非要从当前函数中返回。更常见的情况是从switch-case结构后面继续执行,这可以通过break语句实现。例如:
xxxxxxxxxx
231bool accept() {
2 cout << "您想继续吗(y/n)?" << flush; // 打印问题
3 char answer = 0; // 初始化为用户不可能输入的值
4 cin >> answer; // 读取用户的输入
5
6 bool retval;
7
8 switch (answer) {
9 case 'y':
10 retval = true; // 输入y则返回true
11 break;
12
13 case 'n':
14 retval = false; // 输入n则返回false
15 break;
16
17 default:
18 cout << "输入" << answer << "与输入n等价" << endl;
19 retval = false; // 输入其它字符则返回false
20 }
21
22 return retval;
23}
与for语句类似,if语句也可以引入一个变量并检验它。例如:
xxxxxxxxxx
121void doSomething(vector<int>& v) {
2 ...
3
4 if (auto n = v.size(); n != 0) {
5 ... // 向量v非空则执行
6 }
7 else {
8 ... // 向量v为空则执行
9 }
10
11 ...
12}
在if条件中定义了整数n,通过v.size()初始化,并在分号后立即检验n的值是否非零。在if条件中定义的名字,其作用域和生命期被限制在if-else结构的各个分支内部。与for语句一样,在if语句中定义名字的目的,在于限制变量的作用域,提高可读性,减少发生错误的机会。
对于“n != 0”或“p != nullptr”这样的条件检验,通常可以省略不写。例如下面的写法与上述代码完全等价:
xxxxxxxxxx
121void doSomething(vector<int>& v) {
2 ...
3
4 if (auto n = v.size()) {
5 ... // 向量v非空则执行
6 }
7 else {
8 ... // 向量v为空则执行
9 }
10
11 ...
12}
如果可以的话,建议采用这种更简单的形式。
C++17:带有初始值设定项的选择语句
C++提供直接到硬件的映射。很多基本操作都是通过直接调用硬件功能实现的。典型的情况是执行单条机器指令。例如对两个int型变量x和y的加法计算x+y,就会直接执行处理整数加法的机器指令。
C++的实现可以直接将机器的一段内存区域,视作一个地址连续的容器,并将一个或多个(有类型的)对象置于其中,同时通过指针保存它们的地址,并访问之。
xxxxxxxxxx
71_______A_______ _______B_______ 对象
2| | |
3_______________________________________ 内存
4___|___|___|___|___|___|___|___|___|___
5100 101 102 103 104 105 106 107 108 109 地址
6^ ^
7[101] [105] 指针
指针类型在内存中直接被表述为地址。上面图中的101和106都是内存地址。内存看起来非常象数组,地址就象数组元素的下标。事实上,数组正是C++对“连续内存中的对象序列”的基本抽象。
基本语言结构能够直接映射到硬件,这使得一门语言能获得系统原生的底层性能。C和C++语言数十年间正是以此著称于世。C和C++语言的基本机器模型是直接基于计算机硬件的,而不是某种高层形态的数学抽象。
对于内置类型而言,赋值语句的本质就是简单的内存复制。例如:
xxxxxxxxxx
21int x = 2, y = 3;
2y = x; // 将x的值2赋给y,覆盖y原来的值3,y的值变成2
两个对象在赋值前后都是独立的,不存在一个引用另一个的情况。赋值以后,修改x的值不会影响y,修改y的值也不会影响x。无论是int、double、bool等基本类型,还是结构体、类等复合类型,以上关于赋值语义的描述都适用。这与Java、C#等语言不同,但与C语言一致。
如果希望不同的对象,指向或引用相同的值,即共享同一块内存,必须显式地表达出来。例如:
xxxxxxxxxx
21int *x = new int{ 2 }, *y = new int{ 3 };
2y = x; // y和x指向同一个值2
存放整型数值2和3的内存地址分别是147和258,保存在指针变量x和y中。经过“y = x”赋值以后,将x中的147复制到y中,覆盖其原来的258。最后x和y都指向存放整型数值2的内存。这时,修改x的目标也就是修改y的目标,反之亦然。
但如果换做是引用,情况会与指针不同。例如:
xxxxxxxxxx
21int &x = *new int{ 2 }, &y = *new int{ 3 };
2y = x; // 将x的目标值2赋给y的目标,覆盖y原来的目标值3,y的目标值变成2
访问指针的目标需要借助解引用操作符(*),而对引用解引用则是隐式操作。
初始化与赋值不同。通常在赋值操作中,被赋值的对象必须先拥有一个有效的值,该值会在赋值后被新值取代(覆盖),而初始化的任务则是令一段未定义的内存区域化身为一个有效的对象。对几乎所有数据类型而言,读写任何未初始化对象的结果都是未定义的。例如:
xxxxxxxxxx
31int &x, *y;
2x = 2; // 编译错误,引用必须被初始化
3y = 3; // 运行错误,不知道会发生什么
幸运的是,不会出现未初始化的引用,编译器会纠正这一点,但未初始化的指针,必然可能成为潜在缺陷的根源。
对引用的初始化,可以写成这样:
xxxxxxxxxx
11int& r{ x }; // r引用x
也可以写成这样:
xxxxxxxxxx
11int& r = x; // r引用x
这里的等号(=)并非赋值,依然是对引用r的初始化,令其引用已定义的整型变量x。
对很多用户自定义类型而言,严格区分初始化和赋值同样非常关键,比如string和vector,因为赋值通常伴随着对已有资源的释放,而初始化则不需要这么做。
向函数传递参数,和从函数中返回值,也属于初始化语义,特别是在接受引用型参数,和返回引用型返回值的情况下。
不必慌张!随着时间的推移,一切都会渐渐清晰起来
不要排它地、孤立地使用内置特性。通过库,特别是标准库,间接地使用内置特性,不失为一种最佳方式
借助“#include”或“import”导入库,可以简化编程
想要写出好的程序,不必了解C++的所有细节
关注编程技术,而非语言特性
关于语言定义问题的最终结论,尽在ISO C++标准中
将有意义的操作“打包”成函数,并给它起个好名字
一个函数最好只执行单一逻辑操作
保持函数简短
函数重载的适用情形是,几个函数的任务相同,而处理的参数类型或数量不同
如果一个函数可能在编译时执行,应将其声明为constexpr
如果一个函数必须在编译时执行,应将其声明为consteval
如果一个函数不允许有副作用,应将其声明为constexpr或consteval
理解语言原语是如何映射到硬件的
使用数字分隔符,可令大的字面量更可读
避免使用复杂表达式
避免窄化类型转换
最小化变量的作用域
保持作用域尽量小
避免使用魔法常量,尽量使用符号化常量
优先采用不可变数据
一条语句(只)声明一个名字
一般的、局部化的名字尽量简短,特殊的、非局部的名字可以长一些
避免使用形似的名字
避免出现全部由大写字母组成的名字
在声明语句中,如果显式指定了类型而非使用auto关键字,则优先使用列表初始化语法
借助auto关键字可以避免重复输入类型名称
避免不初始化变量
不要在有可用初值之前声明变量
在if语句的条件中声明变量时,优先采用隐式检验,而不是与0和nullptr比较
优先使用基于范围的for循环,而非传统的for循环
只在有位运算的场合使用unsigned类型
指针的使用尽量简单、直接
使用nullptr而非NULL或0表示空指针,即什么也不指向的指针
能通过代码表达清楚的,就不要在注释中啰里啰嗦
通过注释描述意图
缩进风格始终保持一致