跳转至

从C到C++

有的同学可能会问:那为什么又来了个C++呢?C不是已经很好了吗?为什么还要搞个C++出来呢?

理由很简单:C语言的确简单高效,但“太弱”,缺乏现代编程语言的特性,如OOP(面向对象编程)、泛型;其标准库也极小,很多东西都得手搓,在日常编程中这是非常痛苦的。

因此,C++应运而生。C++在保留C语言高效和灵活的同时,引入了许多现代编程语言的特性,如类和对象、继承、多态、模板等,从而使得程序设计更加模块化、可维护和可扩展。此外,C++还提供了一个强大的标准库(STL),包括容器、算法和迭代器等,大大简化了日常编程任务。

C++广泛应用于多种需要兼顾性能和抽象的领域,如系统软件、游戏开发、嵌入式系统和高性能计算等,著名的Chrome、Adobe全家桶、MS Office、Visual Studio都是C++写的,古老的DirectX游戏引擎1、现代的虚幻引擎2等也都要求用C++开发;而Unity引擎虽然允许用户使用C#辅助开发,但其底层核心(乃至C#的.NET运行时)也是用C++完成;Python里相当多的高性能库(如PyTorch、TensorFlow、JAX3等)也是用C++写的。C和C++在事实上构成了当今高性能计算和软件开发的基石,在编程语言排名中常年稳居第三名和第二名,仅次于Python4

Warning

如果你仅是为了应付课程或打算法竞赛,那不必完全遵从本文中涉及到的诸如代码风格等建议,只要能快速跑通就行;但如果你想真正学好C++,建议你认真阅读并遵从本文中的建议。

一种代码有一种代码的写法,一次性代码(如考试和竞赛)和长期维护的代码(如工程项目)是两码事。前者追求速度和简洁,后者追求可读性和可维护性。本文所述的代码风格和建议,都是面向后者的。笔者个人也比较建议同学们从一开始就养成良好的代码习惯,毕竟未来你写的代码很可能会被别人阅读和维护。

所以说,虽然笔者个人非常厌恶和反对在任何代码中使用诸如 #include <bits/c++.h>using namespace std; 之类的东西,以及大量使用全局变量、开巨大的C风格静态数组、滥用宏定义、用无意义的函数和变量名、一点注释不写、通篇魔法数字等行为,但如果你仅是为了应付考试或竞赛,那么使用这些东西也无可厚非,毕竟在考试的时候这些东西确实能帮你节省不少时间——但依然建议避免这种不好的实践。

但如果要是在大作业或工程项目中这么乱写,那就太不负责任了,被解雇了也是活该。

本章所学基本聚焦于C++14及其之前的标准,后续章节会介绍更新更好的新特性。当然,本节中为了代码的简洁起见,使用了一些诸如using namespace std;之类的写法,但在工程代码中应当避免使用这些写法;关于这个东西是什么、为什么不建议使用、以及如何避免使用,后续章节会详细介绍。

从C到C++的区别

C++的基本语法和C语言几乎完全一致,因此如果你已经掌握了C语言,那么学习C++将会非常容易。

第一个C++程序

下面是一个简单的C++程序,它输出“Hello, World!”到屏幕上:

#include <iostream> // 引入输入输出流库

int main() {
    std::cout << "Hello, World!" << std::endl; // 输出Hello, World!
    return 0; // 返回0表示程序成功结束
}

我们发现,这个HelloWorld和C语言的HelloWorld长得很像,但确实存在一些区别: - 引入的头文件有区别; - 输出语句有区别。

在C++中,基本的语法结构和C语言类似,包括变量声明、数据类型、控制结构(如条件语句和循环语句)、函数定义等,统统一致。可以说,C怎么写,C++就怎么写,完全没有区别。C语言的标准库在C++中也有其移植版本,一般是从 xx.h 变成了 cxx ,例如C语言的 stdio.h 在C++中变成了 cstdio ,但函数和用法几乎完全一致。

区别在于:C++自带bool类型,不需要 #include <stdbool.h> ;另,结构体和联合体的定义上有所不同;第三,C++的字符串推荐用 std::string ,而不是C风格的字符串;最后,C++推荐使用流对象来输入输出。

接下来会把C++的新特性一一列举。对于面向对象、泛型和STL则会在后续章节中详细介绍。

花括号初始化

在C++中,推荐使用花括号初始化(brace initialization)来初始化变量。这种初始化方式可以防止窄化转换(narrowing conversion),从而提高代码的安全性。例如:

int x = 10;      // 传统初始化(赋值或者拷贝风格)
int y(20);      // 传统初始化(构造函数风格)
int z{30};      // 花括号初始化
int a{3.14};    // 保证一定不会发生窄化转换,编译错误
花括号初始化的好处在于,它可以防止隐式类型转换,从而避免一些潜在的错误。但是,由于历史原因,实际上大家依然是更喜欢使用传统初始化方式;只有在很现代的C++代码中,才会大量使用花括号初始化。

C++的输入输出及其格式化

输入输出流

在C++中,我们建议使用更安全的输入输出流 cincout 来进行输入输出操作。它们分别用于从标准输入(通常是键盘)读取数据和向标准输出(通常是屏幕)打印数据。

cincout 的基本用法如下:

#include <iostream>

int main() {
    int age;
    std::cout << "请输入你的年龄:";  // 输出提示信息
    std::cin >> age;  // 从标准输入读取数据
    std::cout << "你输入的年龄是:" << age << std::endl;  // 输出读取到的数据
    return 0;
}
以上代码的意思是:先输出提示信息“请输入你的年龄:”,然后从标准输入读取一个整数值并存储到变量age中。接着输出“你输入的年龄是:”以及读取到的年龄值。

std::endl 是一个特殊的操纵符(manipulator),它的作用是输出一个换行符并刷新输出缓冲区,等价于'\n' << std::flush

所谓“缓冲区”指的是输出流在输出数据时会先将数据存储在一个临时的内存区域(缓冲区)中,等到缓冲区满了或者遇到换行符时才会将数据真正输出到屏幕上。使用 std::endl 可以确保输出立即显示在屏幕上,而不是被缓冲起来等待下一次输出操作,这对于调试和交互式程序非常有用。

但是需要注意的是,过度使用 std::endl 可能会导致性能问题,因为每次使用它都会刷新输出缓冲区,这可能会增加程序的运行时间。对于需要频繁输出的情况,建议使用 '\n' 来代替 std::endl ,以避免不必要的性能开销。

在此类输出中,如涉及运算,必须使用括号,否则会出现优先级错误的问题。例如:

    std::cout << "两数之和为:" << (a + b) << std::endl; // 正确
    std::cout << "两数之和为:" << a + b << std::endl;   // UB

C风格的输入输出

当然,使用C风格的输入输出函数 printfscanf 也是可以的。例如:

#include <cstdio> // C风格的输入输出库

int main() {
    int age;
    std::printf("请输入你的年龄:");  // 输出提示信息
    std::scanf("%d", &age);  // 从标准输入读取数据
    std::printf("你输入的年龄是:%d\n", age);  // 输出读取到的数据
    return 0;
}

<cstdio> 是C++的C风格输入输出库,与C语言的 <stdio.h> 头文件内容完全一致,只不过使用了C++的命名空间( std )。不过大多数实现也允许不使用 std:: 前缀,也允许使用 <stdio.h> 头文件,但我们不建议使用C的头文件。

C风格的 std::printfstd::scanf 速度更快,因为不需要流操作;但是它们存在一些安全隐患,例如格式化字符串攻击和缓冲区溢出等问题。我们建议在C++工程上使用更安全的 std::cinstd::cout

我们在写代码的时候一定不要一句C一句C++,或者说不要一句printf一句cout(反过来也不行),这样会导致缓冲区冲突,从而引发一些莫名其妙的问题。要么全用C的输入输出,要么全用C++的输入输出。

Note

实际上, std::cinstd::coutprintfscanf 区别巨大。后者是一个函数,而前者是一个“流对象”(可以理解为一个“东西”而不是一个“手段”)。它们是C++标准库中的流对象,真正负责输入输出的实际上是 <istream> 头文件中的 istream::read<ostream> 头文件中的 ostream::write 方法,它们被封装进 >><< 这两个运算符(流运算符),和我们的加减乘除等运算符一样。这两个运算符必然是返回流对象的一个引用,因此可以链式调用。特别的,当输入失败的时候,会返回流对象的一个“失败”状态,因此可以通过 std::cin.fail() 来判断输入是否成功,也可以通过布尔上下文转换(例如 while(std::cin>>n) )来判断输入是否成功。

流运算符也不是 >><< 的原本样子。它们原本是右移和左移运算符:例如 a<<b 是对a进行左移操作,将a的二进制表示整体向左边移动b位,右边补0;右移类似(只不过对于有符号整数最高位是0补0,是1补1;无符号整数默认补0)。在 <iostream> 头文件中,这两个运算符被重载了,使得它们可以用于流对象,进而辅助执行输入输出操作;也正因此,我们需要引用上述头文件才能使用它们。不过值得庆幸的是,我们可能一辈子都不会用到它们的原本样子。

头文件 <stdio.h> 是C的头文件,而 <cstdio> 是这个头文件在C++中的移植版本。两者内容完全一致,只不过 <cstdio> 使用了C++的命名空间( std )。在现代风格的C++编程中,我们通常使用 <iostream><cstdio> 来进行输入输出操作,而不是使用 <stdio.h>

格式化输出

有时候,我们需要对输入输出进行一些格式化操作,例如设置小数点位数、对齐方式等。除了使用printf的格式化字符串外,C++提供了一些操纵符(manipulator)来实现这些功能。 - std::setw(n) :设置输出宽度为n个字符。 - std::setprecision(n) :设置小数点位数为n位。 - std::fixed :固定小数位数输出浮点数。 - std::scientific :使用科学计数法输出浮点数。 - std::left :左对齐输出。 - std::right :右对齐输出。

上述不少操纵符需要引用头文件 <iomanip> 。例如,我们可以使用这些操纵符来格式化输出一个表格:

#include <iostream>
#include <iomanip>  // 引入操纵符库
int main() {
    std::cout << std::left << std::setw(10) << "Name" << std::setw(5) << "Age" << std::setw(10) << "GPA" << std::endl;
    std::cout << std::left << std::setw(10) << "Alice" << std::setw(5) << 20 << std::setw(10) << std::fixed << std::setprecision(2) << 3.5 << std::endl;
    std::cout << std::left << std::setw(10) << "Bob" << std::setw(5) << 22 << std::setw(10) << std::fixed << std::setprecision(2) << 3.8 << std::endl;
    return 0;
}
当然这个还是太冗繁了。建议使用更现代的方式,例如C++20引入的 std::format 函数(需要引用头文件 <format> ),这个东西在之后的章节中有详细介绍。

格式化输入

对于输入,则复杂得多。我们推荐同学们使用更安全的C++风格输入输出方法,也就是 cincoutgetline 等。

对于确定数量的干净5输入,可以直接使用 cin

int a, b, c;
// 假设输入格式为:1 2 3
cin >> a >> b >> c;  // 读入三个整数

对于不确定数量的干净输入,可以使用循环配合 cin

int n;
while (cin >> n) {
    // 处理输入的n
}
上述代码会一直读取输入,直到遇到文件结束符(EOF)或者输入错误为止。其能工作的原因是 cin 在读取失败时会返回流对象的一个“失败”状态,该失败状态在布尔上下文中被解释为 false ,从而终止循环。

如果遇到脏输入,则情况变得复杂许多。常见的脏输入包括逗号分割的数字、带有多余空格的字符串等。对于这些情况,推荐使用 getline 配合字符串流( stringstream )来处理。下文演示了这种方式,并将逗号分割的整数字符串转换为整数数组:

#include <iostream>
#include <sstream>
#include <string>
#include <vector>
using namespace std;

int main(){
    string line;
    string tmp;
    vector<int> results;

    // 1. 读一整行
    getline(cin, line);

    // 2. 创建字符串流
    stringstream ss(line);

    // 3. 按逗号分割并处理
    while (getline(ss, tmp, ',')) {
        results.push_back(stoi(tmp)); // 转换为整数并存储
    }
    // 如果转换为double,可以使用stod
}

工程上,脏数据非常常见,因此掌握这些输入方式是非常有必要的。

Warning

流对象处理输入输出的本质依然是操作缓冲区。因此,有一部分OIer认为他们自己维护缓冲区的输入方式更快、更好。诚然, getline 等方法处理缓冲区的性能大概比手动操作缓冲区低了约5到10%,但是实际上我并不推荐手动维护缓冲区,尤其是在工程上。原因有三: - 不安全。这是最大的一个弊病。手动维护缓冲区和不穿衣服在街上乱晃是一个道理,属于是把自己的安全完全交给了用户的善意。恶意用户攻击你的缓冲区将变得轻而易举。 - 不易懂。仅代码量一项,手动维护缓冲区就比使用流对象多出不少代码量,且难以阅读。这违背了工程代码的可读性原则。 - 难维护。我们在做题的时候,不少题目虽然算法简单但是边界条件复杂(例如日历问题),做这些题目的时候应付边界条件的时间几乎可以占到做题时间的一半。对于工程而言,手动维护缓冲区需要自己处理各种边界情况,估计也没有几个团队会有这个时间和精力去维护这些边界条件。

综上所述,虽然手动维护缓冲区在某些情况下可能会有一些性能优势,但是这种优势并不值得我们为之付出安全性、可读性和可维护性的代价。这就是工程代码为了安全、可读、可维护而牺牲性能的一个典型例子;另一方面,可以看到竞赛思维和工程思维有显著的差异,不能够混为一谈。

Tip

在C++中,除std::cout,还有std::cerrstd::clog两个流对象用于错误输出和日志输出。它们的用法和std::cinstd::cout类似,但有一些区别: - cout行缓冲的,也就是缓冲到换行符或缓冲区满才输出,可以重定向到文件或其他设备(例如./program > file),一般用于正常输出,关联的设备是标准输出设备(stdout)。 - cerr无缓冲的,也就是每次输出都会立即显示,不会缓冲,常用作错误信息的输出。其关联的设备也不是标准输出设备,而是标准错误设备(stderr)。重定向方式例如./program 2> error.log,其中2>表示重定向标准错误输出。 - clog全缓冲的,也就是缓冲区满才输出,可以重定向到文件或其他设备,一般用于日志信息的输出。虽然该流对象也关联标准错误设备(stderr),但其行为更像cout。重定向方式同cerr

利用文件流进行文件读写

在C语言的章节中,我们已经知道,可以使用FILE结构体的指针来操作文件。但是这种操作有一定弊病:需要手动管理文件指针,容易出错。虽然智能指针能够部分解决手动管理文件指针的麻烦,但C++提供了更好的文件操作方式:文件流(file stream)。

文件流和输入输出流类似,也是一个流对象。文件流有两个:std::ifstream用于从文件读取数据,std::ofstream用于向文件写入数据,分别对应标准输入输出的std::cinstd::cout。这两个流对象提供了和标准输入输出流类似的操作方法,例如使用>><<运算符进行读写操作。

下面是一个简单的例子,演示如何使用文件流读取和写入文件:

#include <iostream> 
#include <fstream>  // 引入文件流库

int main(){
    std::ifstream infile("input.txt"); // 打开输入文件
    std::ofstream outfile("output.txt"); // 打开输出文件,默认是覆盖模式
    int a, b;
    infile >> a >> b; // 从输入文件读取数据
    outfile << "Sum: " << (a + b) << std::endl; // 向输出文件写入数据
    infile.close(); // 关闭输入文件
    outfile.close(); // 关闭输出文件
    return 0;
}
在上述代码中,“读取”和“写入”两个操作被分成两个流对象而不是一个文件。这是因为C++的OOP思想,每一个对象仅负责一个职责。另,在这里也可以看出,文件流应当自行定义,而不是已经定义好的std::cinstd::cout

ofstream默认是覆盖模式打开文件的,如果想要以追加模式打开文件,可以使用以下方式:

std::ofstream outfile("output.txt", std::ios::app); // 以追加模式打开输出文件

当然,也可以使用一个统一的文件流对象来同时进行读写操作,这时需要使用std::fstream类,但这时候需要指定读写模式:

std::fstream file("data.txt", std::ios::in | std::ios::out); // 以读写模式打开文件

我们有这些读写模式可以使用: - std::ios::in :以读模式打开文件。 - std::ios::out :以写模式打开文件。 - std::ios::app :以追加模式打开文件,写入的数据会追加到文件末尾。 - std::ios::ate :打开文件后将文件指针移动到文件末尾,可以用于读写操作。 - std::ios::trunc :如果文件已存在,则在打开时将其内容截断为0长度(实际上就是覆盖模式)。 - std::ios::binary :以二进制模式打开文件,而不是文本模式。这一手段打开的文件能够避免字符转换。 这些读写模式实际上都是独热编码,也正因此,我们可以使用按位或运算符(|)来组合多个模式。

文件的其他操作

在C语言中,我们能够使用remove等函数来对文件和目录进行操作。在C++中也有类似的功能,位于<filesystem>头文件中。这个头文件在C++17中引入,提供了一些用于文件和目录操作的类和函数。 下面是一些常用的文件操作函数: - std::filesystem::remove(path) :删除指定路径的文件或目录。 - std::filesystem::rename(old_path, new_path) :重命名文件或目录。 - std::filesystem::copy(source, destination) :复制文件或目录。 - std::filesystem::create_directory(path) :创建一个新目录。 - std::filesystem::exists(path) :检查指定路径是否存在。 - std::filesystem::file_size(path) :获取指定文件的大小。 - std::filesystem::current_path() :获取当前工作目录的路径。 - std::filesystem::absolute(path) :获取指定路径的绝对路径。 - std::filesystem::directory_iterator(path) :迭代指定目录下的文件和子目录。 - std::filesystem::space(path) :获取指定路径所在文件系统的空间信息。 - std::filesystem::last_write_time(path) :获取指定文件的最后修改时间。 - std::filesystem::permissions(path, perms) :设置指定文件或目录的权限。 - std::filesystem::remove_all(path) :递归删除指定路径的文件或目录及其内容。

需要注意的是,path是一个类型,可以是字符串类型(如std::string)或者std::filesystem::path类型。使用这些函数时,需要包含头文件<filesystem>,并且在编译时需要链接文件系统库(例如使用-lstdc++fs选项)。

常变量、常量和它们的关系

常变量(也叫不可变变量、只读变量、运行时常量)、常量(也叫编译期常量)往往笼统地称为常量。它们一旦确定就不会在程序运行时改变,任何试图对它们进行运行时更改的操作都会使得编译不通过。常量的值应当在声明时确定,可以通过赋值或者计算得到。它们的名字通常使用大写字母来表示,以便于和变量区分。

声明常变量的方法和声明变量差不多,但是要在最前面加上 const 关键字,如:

const int MAX_VALUE = 100;
const int P = a + b; // 这里的a和b可以是变量
// P = 10 // 这行代码编译不通过,因此要注释掉
以上代码的意思是:我要创建一个常量MAX_VALUE,它的值是100。

如果常变量的值必须在编译时确定,可以使用常量。常量的值在编译的时候值就确定了,不过因此也需要在定义中就写明它的值。常量的声明方法和常变量类似,只是把 const 换成 constexpr

常量也可以通过计算得到,计算在编译时进行,可以节省程序运行时间,但是要求用于计算的东西也必须是常量、字面值(直接写出来的值)、constexpr函数或者立即函数6。下文是常量的几个例子。

constexpr double E = 2.71828;
constexpr double PI = 3.14159;
constexpr double EPI = E * PI;

在现代 C++ 中, const 常变量不依靠运行时初始化来确定其值(例如const int b = 1;),其表现就和 constexpr 常量一样了。因此,在大多数时候,我们也可以把 const 常变量当作 constexpr 常量来使用。但如希望严谨表达意图,仍建议使用 constexpr 来声明常量。

Tip

还是不懂?可以通过以下例子理解一下变量、运行时常量、编译期常量的区别:

    int sqr(int x) { return x * x; } // 普通函数
    const int sqr_c(const int x) { return x * x; } // const函数
    constexpr int sqr_ce(const int x) { return x * x; } // constexpr函数
    consteval int sqr_cv(const int x) { return x * x; } // consteval函数

那么对于以下声明,编译器的表现如下表所示。其中,编译失败的情形用红色标出;用 const 声明的运行时常量表现为编译期常量的特殊情形则使用蓝色标出。 | 声明 | 编译器表现 | 理由 | | --- | --- | --- | | int a0 = 5; | 变量 | 显然 | | const int a1 = 5; | 常量(原文蓝色标注) | 不依赖运行时初始化 | | constexpr int a2 = 5; | 常量 | 字面值 | | const int a3 = a0; | 常变量 | 依赖运行时值 a0 | | constexpr int a4 = a0; | 编译失败(原文红色标注) | 严格常量不能用运行时值初始化 | | int a5 = sqr(1); | 变量 | 显然 | | const int a6 = sqr(1); | 常变量 | 依赖运行时函数 | | constexpr int a7 = sqr(1); | 编译失败(原文红色标注) | 严格常量不能用运行时函数初始化 | | int a8 = sqr_c(1); | 变量 | 显然 | | const int a8 = sqr_c(1); | 常变量 | 依赖运行时函数 | | constexpr int a9 = sqr_c(1); | 编译失败(原文红色标注) | 严格常量不能用运行时函数初始化 | | int a10 = sqr_ce(1); | 变量 | 显然 | | const int a11 = sqr_ce(1); | 常量(原文蓝色标注) | 该函数接受常量则在编译期初始化 | | const int a12 = sqr_ce(a0); | 常变量 | 依赖运行时值 a0 | | constexpr int a13 = sqr_ce(1); | 常量 | 显然 | | constexpr int a14 = sqr_ce(a0); | 编译失败(原文红色标注) | 严格常量不能用运行时值初始化 | | int a15 = sqr_cv(1); | 编译失败(原文红色标注) | 立即函数不可以在运行时调用 | | const int a16 = sqr_cv(1); | 常量(原文蓝色标注) | 显然 | | const int a17 = sqr_cv(a0); | 编译失败(原文红色标注) | 立即函数不可以在运行时调用 | | constexpr int a18 = sqr_cv(1); | 常量 | 显然 | | constexpr int a19 = sqr_cv(a0); | 编译失败(原文红色标注) | 立即函数不可以在运行时调用 |

Tip

用宏定义的常量和用 constconstexpr 定义的常量有一些区别。宏定义的常量没有类型,因此在使用时需要注意类型转换的问题;而 constconstexpr 定义的常量有类型,可以更好地进行类型检查和转换。此外,宏定义的常量在预处理阶段进行替换,因此可能会导致一些意想不到的问题,例如宏展开时的优先级问题等。而 constconstexpr 定义的常量在编译阶段进行处理,更加安全可靠。

数组

C++虽然支持C风格的数组 int arr[10]; ,但是不推荐使用这种方式来声明数组。

在C++中,数组一般使用 std::arraystd::vector 来声明。它们分别表示静态数组和动态数组。

静态数组

静态数组的大小在编译时确定,不能动态改变。它的基本格式如下:

#include <array>  // 引入数组库
array<int, 10> arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 声明一个包含10个整数的数组
cout << arr[0] << endl; // 访问数组的第一个元素

这东西的实现和C风格数组相似,但一个重要的区别是:它是一个复杂类,而不是基本类型,有很多方法能够操作;另一个重要的区别是,在作为函数参数传递时,它不会退化为指针,从而避免了信息丢失的问题。

也就是说:

void foo(array<int, 10> arr) {
    cout << arr.size() << endl; // 可以获取数组的大小
}

void bar(int arr[10]) {
    cout << sizeof(arr) / sizeof(arr[0]) << endl; // 这里会输出错误的结果,因为arr退化为指针,sizeof(arr)得到的是指针大小而非数组大小
}

动态数组

动态数组的大小可以在运行时确定,可以动态改变。它的基本格式如下:

#include <vector>  // 引入向量库
vector<int> vec; // 声明一个空的动态数组
vec.push_back(1); // 向数组末尾添加一个元素1
vec.push_back(2); // 向数组末尾添加一个元素2
cout << vec[0] << endl; // 访问数组的第一个元素
cout << vec.size() << endl; // 获取数组的大小
vec.pop_back(); // 删除数组末尾的元素
vec.remove(0); // 删除数组中值为0的元素

动态数组的实现和静态数组类似,也是一个复杂类,有很多方法能够操作。它的大小可以动态改变,因此非常灵活,但性能上有较大的损失,在大型数组上比array慢一倍;但数组如果太大又不得不使用动态数组,否则栈空间不够用。

使用vector能够避免和C一样用malloc和free来直接操作内存,省心省力。

动态数组的扩容机制是一个值得研究的话题。当动态数组的容量不足以容纳新添加的元素时,通常会进行扩容操作。扩容的过程一般包括以下几个步骤: 1. 分配一块更大的内存空间,通常是当前容量的两倍。 1. 将原有元素从旧内存空间复制到新内存空间。 1. 释放旧的内存空间。 1. 更新动态数组的内部指针和容量信息。 这种扩容策略能够在保证动态数组性能的同时,减少内存分配的次数,从而提高整体效率。不过需要注意的是,扩容操作可能会导致内存碎片化,因此在频繁添加和删除元素的场景下,可能需要考虑其他数据结构(例如链表)来替代动态数组。

堆和栈

在C++中,变量的内存分配主要有两种方式:(stack)和堆(heap)。栈是一种后进先出(LIFO)的数据结构,适用于存储局部变量和函数调用信息;堆则是一种动态分配的内存区域,适用于存储需要在运行时动态创建和销毁的对象。

栈内存的分配和释放由编译器自动管理,当函数调用时,局部变量会被压入栈中;当函数返回时,这些变量会被自动弹出栈。栈内存的分配速度较快,但空间有限,通常只有几MB到几十MB。

堆内存的分配和释放需要程序员手动管理,使用 new 关键字分配内存,使用 delete 关键字释放内存。堆内存的空间较大,可以达到几GB,但分配和释放的速度较慢,且容易导致内存泄漏和碎片化问题。

字符串

C++风格的字符串类型是 std::string ,它可以存储一串字符。字符串的基本格式如下:

#include <string>   // 引入字符串库
string str = "Hello, World!";
引用字符串库是必要的,否则编译器可能会报错;这个库还提供了一些对字符串进行操作的方法,非常方便。

字符串的本质是一个数组,存储了一串字符(C风格的字符串正是char[])。我们可以通过索引来访问字符串中的字符,例如str[0]表示第一个字符,str[1]表示第二个字符,以此类推。

字符串的长度可以通过 str.length() 方法来获取。除此以外,还有很多字符串操作方法,例如 str.substr() (获取子串)、 str.find() (查找子串)等。

字符串是一个复杂类,和以上提到的所有数据类型都有区别。具体为什么是“复杂类”,这涉及到C++的面向对象编程(OOP)特性。我们会在后续章节中详细介绍。

结构体、联合体

C++的结构体和联合体与C中的略有区别。

例如,我们可以声明一个表示学生的结构体:

struct Student {
    string name;  // 学生姓名
    int age;      // 学生年龄
    double gpa;   // 学生绩点
};

Student student1;  // 声明一个学生变量
student1.name = "Alice";  // 设置学生姓名
student1.age = 20;  // 设置学生年龄
student1.gpa = 3.5;  // 设置学生绩点
cout << "Name: " << student1.name << ", Age: "
     << student1.age << ", GPA: " << student1.gpa << endl;

以上内容很好地展示了怎么定义、声明、使用一个结构体。结构体的成员可以通过点(.)运算符来访问,例如 student1.name 表示学生1的姓名。可以看到,不需要再像C语言一样,定义起来那么复杂。联合体也类似,这里不再赘述了。

C++结构体、联合体的内存结构和C语言中的是一样的,都是按照成员的顺序依次存储在内存中的。

另一个非常重要的不同是:C++结构体和联合体可以包含方法(函数),而C语言中的结构体和联合体只能包含数据成员。这使得C++的结构体和联合体更加强大和灵活,可以实现朴素的面向对象编程(OOP)特性,如:

struct Student {
    string name;  // 学生姓名
    int age;      // 学生年龄
    double gpa;   // 学生绩点
    void printInfo() {  // 成员方法,打印学生信息
        cout << "Name: " << name << ", Age: "
             << age << ", GPA: " << gpa << endl;
    }
};

Student student1;  // 声明一个学生变量
student1.name = "Alice";  // 设置学生姓名
student1.age = 20;  // 设置学生年龄
student1.gpa = 3.5;  // 设置学生绩点
student1.printInfo();  // 调用成员方法,打印学生信息

枚举

枚举是一个可以存储一组命名常量的变量。

枚举有两种类型,一种是传统无作用域枚举 enum ,另一种是C++11引入的有作用域枚举 enum class (也叫强枚举)。它们的区别在于,传统无作用域枚举的常量可以直接访问,枚举名和枚举值都泄漏到所在的作用域;而有作用域枚举的常量需要通过枚举名来访问。

传统无作用域枚举的基本格式如下:

    enum Color { RED, GREEN=5, BLUE };
一般情况下,枚举的底层类型由编译器自选,只要能够容纳所有的值就行了,一般是 int 。常量从0开始依次递增,例如上面的RED的值为0。也可以设定枚举的值,上文中我们将GREEN的值设定为5,那么BLUE的值就是6。调用这种枚举非常简单:
Color color = RED; // 正统调用,color的值为0
int n = (int)RED; // 正统调用,n的值为0
int n = RED; // 不报错,n的值为0
Color c = 7; // 不报错,但是c的值不是RED、GREEN、BLUE中的任何一个,属于有效但未命名的值
可以看出,这种枚举没有类型安全性,也没有作用域隔离。

有作用域枚举的基本格式如下:

    enum class Color : std::uint8_t { RED, GREEN=5, BLUE };
上述强枚举的枚举名被限定在作用域内,因此只能通过类似 Color::RED 来访问。强枚举的底层类型可以显式指定,例如上面的 std::uint8_t 。如果不指定,默认是 int 。调用这种枚举只能使用上述的正统调用:
Color color = Color::RED; // 正确,color的值为Color::RED
int n = Color::RED; // 报错,不能将Color类型赋值给int类型
Color c = 6; // 报错,不能将int类型赋值给Color类型
int n = static_cast<int>(Color::RED); // 正确,n的值为0
可以看出,这种枚举有类型安全性,也有作用域隔离。

枚举作为一个数据类型很笨,不仅没有任何方法,也不能进行运算,唯一的作用是定义一组常量,便于阅读;在 switch 语句中的使用较为多见。而剩下的很多方法,都不得不手动定义。

例如下列代码中,我们定义了一个枚举的遍历方法:

enum class Color : std::uint8_t {
    FIRST=RED,
    RED=0,
    GREEN=1,
    BLUE=2,
    LAST=BLUE
};

for (Color c = Color::FIRST; c <= Color::LAST; c = static_cast<Color>(static_cast<int>(c) + 1)) {
    // 遍历 Color 枚举的所有值
}
这段代码中,我们定义了一个Color枚举,并且手动定义了FIRST和LAST两个常量,分别表示枚举的第一个值和最后一个值。

智能指针(穿了衣服版)

我们已经知道C风格的指针是什么了。C++中虽然也能和C一样使用“裸指针”,但是不推荐这么做。C++引入了智能指针的概念,来帮助我们更好地管理内存,不需要再手动malloc/free了。

智能指针有三个7主要类型。这三个主要类型都定义在<memory> 头文件中,分别是: - std::unique_ptr :表示独占所有权的强引用智能指针,一个对象只能有一个 unique_ptr 指向它。当这个 unique_ptr 被销毁时,所指向的对象也会被自动释放。 - std::shared_ptr :表示共享所有权的强引用智能指针,一个对象可以有多个 shared_ptr 指向它。对象会在最后一个指向它的 shared_ptr 被销毁时自动释放。 - std::weak_ptr :表示弱引用的智能指针,不拥有对象的所有权。它通常与 shared_ptr 一起使用,用于解决循环引用的问题。

Note

上述文字中,提到了一个新的名词:所有权。这是编程中的一个非常重要的概念。

所有权指的是:若某个对象A拥有另一个对象B的所有权,那么A就负责B的生命周期管理,包括B的创建、使用和销毁。换句话说,A有权决定B什么时候被创建,什么时候被销毁。例如在传统的C中,以下代码是常见的:

void foo() {    
    int a = 42; // 创建一个整数对象,a拥有它的所有权
    int* ptr = new int(42); // 创建一个整数对象,ptr拥有它的所有权
    delete ptr; // 释放ptr指向的整数对象
}
上述代码中,函数 foo 拥有指针 ptr 指向的整数对象的所有权,因此它负责这个对象的创建和销毁;而在该函数结束时,也要负责销毁所有的对象,也就是释放内存。如果上述代码中没有delete ptr; 这一行,那么就会导致内存泄漏,因为这个整数对象的内存没有被释放(但上述int a = 42; 这一行不会导致内存泄漏,因为它是栈上的对象,会在函数结束时自动销毁)。这个实际上就是C的一个大问题:如果程序员忘记释放内存,那就会导致程序内存泄漏。

而有了智能指针,这个问题就迎刃而解了,见下文。

例如:

void foo(){
    auto ptr = std::make_unique<int>(42); // 创建一个unique_ptr,指向一个整数42
    std::cout << *ptr << std::endl; // 输出42
} // ptr超出作用域,所指向的整数自动释放

void bar(){
    auto ptr1 = std::make_shared<int>(42); // 创建一个shared_ptr,指向一个整数42
    {
        auto ptr2 = ptr1; // 共享所有权
        std::cout << *ptr2 << std::endl; // 输出42
    } // ptr2超出作用域,但整数不会释放,因为ptr1仍然指向它
    std::cout << *ptr1 << std::endl; // 输出42
} // ptr1超出作用域,所指向的整数自动释放
除非直接定义,否则不建议直接使用智能指针的构造函数来创建智能指针对象,而是推荐使用 make_uniquemake_shared 这两个工厂函数来创建智能指针对象。例如:
auto ptr1 = std::make_unique<int>(42); // 创建一个unique_ptr,指向一个整数42
auto ptr2 = std::make_shared<int>(42); // 创建一个shared_ptr,指向一个整数42

struct Node {
    int value;
    std::shared_ptr<Node> next; // 直接定义的shared_ptr,也是好的用法
    Node(int val) : value(val), next(nullptr) {}
};
make_uniquemake_shared 是创建智能指针的推荐方式。我们要创建什么类型、指向什么对象,就在尖括号和圆括号中指定即可。这样可以避免许多的潜在错误,例如内存泄漏、悬空指针等。

weak_ptr 的使用则更为复杂一些,通常用于解决循环引用的问题。例如:

#include <iostream>
#include <memory>
#include <string>
using namespace std;
struct Node {
    string name;
    shared_ptr<Node> next; // 强引用,可能导致循环引用
    weak_ptr<Node> prev;   // 弱引用,避免循环引用

    Node(const string& n) : name(n), next(nullptr), prev() {}
};
class List{
public:
    shared_ptr<Node> head;
    shared_ptr<Node> tail;
    ... // 其他方法
}

Note

那两个shared_ptr为什么会导致循环引用?这是因为智能指针对内存进行管理的方式是通过引用计数来实现的。每有一个unique_ptr或者shared_ptr指向一个对象,这个对象的引用计数就会增加1;每当一个unique_ptr或者shared_ptr被销毁或者重新指向其他对象时,引用计数就会减少1。当引用计数变为0时,表示没有任何智能指针指向这个对象了,这时对象的内存就会被自动释放。而weak_ptr不会影响引用计数。

这就可以看出问题。如果我们写了这样的代码:

struct Node {
    string name;
    shared_ptr<Node> next; // 强引用,可能导致循环引用
    shared_ptr<Node> prev; // 强引用,可能导致循环引用
}
那么如果我们创建了两个节点A和B,并且让A的next指向B,B的prev指向A,那么A和B就形成了一个循环引用。即使我们不再使用A和B,它们的引用计数也不会变为0,因为它们互相引用着对方。于是,A和B的内存永远不会被自动释放,因而就会导致内存泄漏。

为了解决这个问题,我们可以使用weak_ptr来打破循环引用。例如,我们可以让prev成为一个weak_ptr,这样就不会增加引用计数,从而避免循环引用的问题。

而为什么在上述List类中,head和tail使用shared_ptr呢?这是因为head和tail是链表的入口和出口,它们需要拥有节点的所有权,以确保链表中的节点在链表存在期间不会被销毁。因此,使用shared_ptr是合适的选择。我们也可以模拟以下销毁整个链表的过程: 1. 在链表的生命周期内:链表头的引用计数是1,链表尾节点的引用计数是2(“链表尾”8指向尾节点,而前一个节点也指向尾节点)。 1. 先销毁整个List。这时候,链表头的引用计数会减少1(变成0),而尾节点的引用计数也会减少1(变成1)。 1. 由于链表头的引用计数变成0,因此头节点会被自动释放。头节点的释放会导致它的next指针指向的下一个节点的引用计数减少1,也就是第二个节点的引用计数变成0。 1. 于是产生链式反应,整个链表的节点都会被依次释放,直到尾节点。

这是写了一个双向链表节点的例子。注意其中的 next 是强引用,而 prev 是弱引用。这样可以避免循环引用的问题,从而防止内存泄漏。这个指针在多线程编程中也很有用,可以安全地访问共享资源。当然,我们一般不去主动构造一个没有配合的弱引用,弱引用大多都是依附于共享强引用而存在的。

而数组中也不再建议使用指针来访问了,改为统一使用STL容器,例如 std::vectorstd::array迭代器。于是,裸指针在C++中基本光荣退休。至于迭代器是啥,我们会在后续章节中详细介绍。

基本上,我们可把使用哪种指针的决策流程总结如下: - 不共享所有权,使用 std::unique_ptr 。这是99%的情况。 - 共享所有权且肯定没有循环引用,使用 std::shared_ptr 。 - 共享所有权且可能有循环引用,使用 std::shared_ptrstd::weak_ptr ,后者用于打破循环引用。 - 数组?使用 std::vectorstd::array 等STL容器,而非指针。

C++的智能指针极大地简化了内存管理,减少了内存泄漏和悬空指针等问题的发生。推荐大家在C++编程中尽量使用智能指针,而不是裸指针。

C++独占的特性

上述内容中,就算是 std::array 和智能指针,实际上都能在C中找到影子,在C中也可以较为容易地实现这些功能。但是,C++中也有一些独占的特性,是C中很难或无法实现的。下面介绍几个重要的C++独占特性。

命名空间

我们知道,一个软件还是程序,可能由很多人来完成。为了方便,每一个人都有可能定义自己的东西,例如功能(函数)、数据(变量)等。那么,如果两个人给自己不同的东西起了同样的名字怎么办?这时,电脑就无法区分它们了。

一个简单的方法是加强沟通,减少重名的可能性。但是,这样做并不现实。有的项目可能有数百人参与,沟通成本过高;有的项目是给下游使用的,这时又不可能沟通。这个问题非常棘手。

为了解决这个问题,C++引入了命名空间的概念。命名空间可以参照我们说过的虚拟环境概念来理解:每一个人都有自己的一个沙盒,在自己的沙盒里可以随便起名字,互不干扰。这样一来,即使两个人起了同样的名字,也不会冲突,因为它们属于不同的命名空间。

    namespace Alice {
        int value = 42; // 数据(变量)
        void show() {   // 功能(方法)
            std::cout << "Alice's value: " << value << std::endl;
        }
    }
    namespace Bob {
        int value = 100; // 数据(变量)
        void show() {    // 功能(方法)
            std::cout << "Bob's value: " << value << std::endl;
        }
    }
这样,两者并不冲突。

但是新的问题又来了:有时候,别人在他们的命名空间里写了一些东西,而这些东西又是我们想要的。为了方便起见,肯定不能写第二遍。那么,我们该怎么办呢?可以这样写:

    Alice::show(); // 调用Alice命名空间中的show函数
    Bob::show();   // 调用Bob命名空间中的show函数
于是困扰我们的重名问题就彻底解决了。

为了帮助我们更好的开发,C++提供了一些东西减少我们的重复劳动。这些东西被C++放在了“标准”命名空间中,也就是 std 。诸如 coutcinendl 等都在这个命名空间中。因此,我们在使用这些东西的时候,必须加上 std:: 前缀,例如 std::coutstd::cinstd::endl 等。

为了方便起见,可以使用 using namespace std; 来引入整个 std 命名空间,这样在这个文件以及其下游文件中,就可以直接使用标准空间中的东西,而不需要加上 std:: 前缀了。但是这样做也是有风险的:这会把整个标准命名空间都引进来,容易导致重名冲突等问题。

举例:假设你自己写了一个swap函数,然后在你的头文件中使用了 using namespace std; ,那么当别人引入你的头文件时,标准命名空间中的 std::swap 函数也会被引入,从而导致重名冲突,最终引发编译错误。

命名空间的编译原理

命名空间符号在编译时是在符号前加上命名空间前缀来实现的。例如, std::cout 在编译时会被转换为类似 _ZSt4cout 这样的符号名(具体名称可能因编译器而异)。这样做的好处是可以避免不同命名空间中的符号冲突。

Warning

严格禁止在工程头文件中使用 using namespace std; !这会污染全局的命名空间,从而导致重名冲突等问题。头文件是给别人用的,绝对不应污染别人的命名空间。

如果确实需要,我们有以下手段来解决污染命名空间问题:

  1. 每一次使用标准命名空间中的东西时,都加上 std:: 前缀。
        std::cout << "Hello, World!" << std::endl;
    
  2. 只引入需要的东西,例如:
        using std::cout;
        using std::endl;
    
    这样就只引入了 coutendl ,而不会污染其他的东西。而一般人也不会去定义诸如 coutendl 这样的名字,所以这样基本上可以认为是安全的。

Warning

在工程上,源文件也不推荐使用 using namespace std; 。但是这样做是可以容忍的,因为源文件是给自己用的,一般不至于污染命名空间,但是风险也是相当大的。对此,这需要大家自己权衡利弊了。如果项目周期非常短(例如做题),那么这么做没有毛病。但是如果是写工程这种长周期开发,则推荐老老实实用上面提到的两种方法来避免污染命名空间。

Note

为了简便,本书中大部分代码都使用了 using namespace std; ,但是请大家务必牢记上述警告和注意事项。

引用

引用是C++中的一个重要特性,其核心任务是让变量有第二个名字,但不管理生命周期。引用不能为空、不能重新指向、不拥有对象,但在函数参数传递、返回值等场景中极为有用。

左值引用

默认提到引用,指的就是左值引用。

引用的基本格式如下:

类型& 引用名 = 原变量名;
以上代码的意思是:声明一个名为引用名的引用,它是原变量名的别名。引用的作用是可以通过别名来访问原变量。例如,我们可以声明一个整数的引用:
int a = 10;  // 声明一个整数变量
int& ref = a;  // 声明一个整数的引用
cout << "a: " << a << ", ref: " << ref << endl;
ref = 20;  // 修改引用的值
cout << "a: " << a << ", ref: " << ref << endl;

以上代码的意思是:声明一个名为ref的引用,它是变量a的别名。我们可以通过ref来访问a。当我们修改ref的值时,实际上也修改了a的值。

而在函数形参中,引用是较大形参传递的首选。

void draw(const Widget& w) {
    // 使用w进行绘制操作
}
上述一股Qt味的代码中,我们使用了一个常量引用作为函数参数。这样做有两个好处: - 避免了拷贝开销。如果Widget是一个较大的类,那么传递它的引用可以避免拷贝整个对象,从而提高性能。 - 保持了对象的不可变性。使用常量引用可以确保函数不会修改传入的对象,从而提高代码的安全性。 而使用非常量引用作为函数参数,则表示函数可能会修改传入的对象:
void update(Widget& w) {
    // 修改w的状态
}

为什么不用智能指针?因为智能指针管理生命周期,而引用不管理生命周期。引用更轻量级,适合用于函数参数传递和返回值等场景。除非你要转移或重置指针本身,否则不需要使用 unique_ptr ;除非你要延长对象寿命(如缓存、异步任务等),否则不需要使用 shared_ptr

但引用也不是万能的:容器里就不要存引用了,这是值(具体对象)和多态基类(智能指针)的天下,引用请靠边站(不是对象,存不了)。

右值引用

右值引用是C++11引入的一种新特性,绑定的是“马上要死”的临时变量,这一点和左值引用绑定的“长期活着”的变量不同。

一般,有自己名字的变量,要引用全都是左值引用;而没有自己名字的临时变量,则要用右值引用。另,std::move 可以把左值强制转换为右值引用,从而实现移动语义。那什么是“移动语义”呢?后面会讲。

举例:

int x = 1; // x 是一个左值,1 是一个右值
int& lref = x; // 左值引用,绑定到左值 x
int&& rref = 2; // 右值引用,绑定到右值 2
int&& rref2 = x // 错误,不能将左值绑定到右值引用
int&& rref3 = x + 3; // 合法,x + 3 是一个右值
rref = x + 3; // 合法,x + 3 是一个右值

那么这玩意有什么用呢?

第一个用途:把拷贝变成搬运,即“移动语义”。举个例子:

    string s1 = "hello";
    string s2 = s1 + "world";
我们发现,“s1 + "world"”是一个临时对象,生命周期就这一行。但是要把这玩意搬进s2,就不得不做一次深拷贝,挺浪费的。

为了物尽其用,干脆把这个临时对象的资源直接搬进s2好了,不就省事了吗?这就是移动语义的核心思想。

实现移动语义需要用到右值引用,也就是说,重载:

    string(const string& other); // 拷贝构造函数
    string(string&& other); // 移动构造函数
这就是“搬家”实现的关键。右值引用的“马上要死”特性,保证了我们可以放心地把资源搬走,而不会影响原来的对象。这有着典型的写法:
    string(string&& other) noexcept
        : data(other.data) {    // 直接抢夺资源指针
        other.data = nullptr; // 搬家后,把原对象的指针置空,防止析构时重复释放
    }
因为临时对象马上就销毁,所以我们“偷窃”其资源没有人会察觉,从而大大提高了性能。而自己实现的类,往往也需要实现移动构造函数和移动赋值运算符,从而支持移动语义。

而第二个用途就是“完美转发”。这在模板编程中非常有用,可以把参数原封不动地传递给另一个函数,而不会丢失其左值/右值属性。

template<class T>
void foo(T&& arg) {
    bar(std::forward<T>(arg)); // 完美转发
}
上述代码中, T&& 是一个通用引用(也叫转发引用),它可以绑定到左值或右值。实际调用时,实参如果是左值, T 会被推导为左值引用类型;如果实参是右值, T 会被推导为右值类型。这样, std::forward<T>(arg) 就能根据 T 的类型正确地转发参数,保持其左值/右值属性。

unique_ptr只能移动、不能拷贝的特性就是由右值引用实现的:

std::unique_ptr<int> p1 = std::make_unique<int>(5);
std::unique_ptr<int> p2 = p1;      // 拷贝构造被删除
std::unique_ptr<int> p3 = std::move(p1); // OK,移动构造
上述移动完后,p1变成了空指针,p3拥有了原来p1的资源。

C++函数的高级特性

C++下定义函数的方法和C完全一致。但是,C++对函数进行了扩展,增加了一些高级特性,例如函数重载、函数默认参数值等。

C++函数的编译原理

C++函数在编译时会进行名称修饰(Name Mangling),这是为了支持函数重载等特性。名称修饰的过程会将函数名、参数类型、返回类型等信息编码到符号名中,从而生成一个唯一的符号名。例如,假设我们有以下两个函数:

int add(int a, int b);
double add(double a, double b);
这个函数在C中是不允许的,因为函数名称相同,编译器不能区分它们,会报错“冲突声明”。但是在C++中是允许的,因为编译器会对函数名称进行修饰,生成不同的符号名,例如:
call _Z3addii  # 对应 int add(int, int)
call _Z3adddd  # 对应 double add(double, double)
上述代码中, _Z3addii_Z3adddd 分别是两个 add 函数的修饰名称,包含了参数类型的信息,从而使得编译器能够区分它们。但是我们发现一个问题:函数的返回值是不参与名称修饰的,这意味着如果两个函数只有返回值类型不同,而参数列表相同,那么它们仍然会冲突,编译器会报错。

但这就会导致一个问题:如果我们需要在C++中调用C语言的函数怎么办?因为C语言不支持名称修饰,而C++支持名称修饰,这就会导致链接错误。为了解决这个问题,C++提供了 extern "C" 关键字,可以告诉编译器按照C语言的方式来处理函数名称,从而避免名称修饰。例如:

extern "C" {
    void c_function(int a);
}
上述代码中, c_function 函数会按照C语言的方式来处理名称,不会进行名称修饰,从而可以正确地链接C语言的函数。当然,这也会有所牺牲,在该块中的函数和变量不能使用C++的特性,例如函数重载、命名空间等。

函数重载

函数重载是C++中的一个重要特性,它允许我们定义多个同名的函数或运算符,但它们的参数列表或返回类型不同。写一个例子就好了:

struct Tensor2D{
    int x_dim, y_dim;
}
Tensor2D operator+(const Tensor2D& other) {
    // 实现加法运算
    Tensor2D result;
    result.x_dim = this->x_dim + other.x_dim;
    result.y_dim = this->y_dim + other.y_dim;
    return result;
}
以上代码就是重载的一个鲜活实例。我们重载了加法运算符,这使得我们能够对Tensor2D对象进行加法运算。合适的重载可以使代码更简洁、更易读。

除了重载运算符,还可以重载流运算符来实现自定义输入输出,重载函数实现对不同参数的处理等。重载的关键是参数列表的不同,返回类型可以相同或不同。刚才在名称修饰中,我们已经提到过,返回类型不参与名称修饰,因此不能仅通过返回类型来区分重载函数。

函数默认参数值

函数默认参数值是C++中的一个特性,它允许我们在函数声明时为某些参数指定默认值。如果调用函数时没有提供这些参数的值,编译器会使用默认值。

例如:

void greet(const std::string& name = "Guest") {
    std::cout << "Hello, " << name << "!" << std::endl;
}

greet(); // 输出: Hello, Guest!
greet("Alice"); // 输出: Hello, Alice!
在上述代码中,函数 greet 有一个默认参数 name ,如果调用时没有提供该参数的值,默认值 "Guest" 会被使用,这个“没有提供参数”的规范名称是参数缺省

需要注意的是,函数缺省的参数必须从右向左依次定义,不能在中间或左侧定义缺省参数。这一点和C#、Python等语言不同,必须牢记。例如,下面的代码是错误的:

void foo(int a = 10, int b); // 错误,b没有默认值
正确的写法是:
void foo(int a, int b = 20); // 正确

而在函数声明和定义分离的情况下,默认参数值只能在函数声明中指定,而不能在函数定义中指定。例如:

void greet(const std::string& name = "Guest"); // 函数声明
void greet(const std::string& name) { // 函数定义
    std::cout << "Hello, " << name << "!" << std::endl;
}

类型推断

类型推断是C++11引入的一个特性,它允许编译器根据变量的初始值自动推断变量的类型。使用类型推断可以使代码更简洁、更易读。 类型推断的基本语法是使用 auto 关键字:

auto x = 5;  // 编译器推断x的类型为int
auto y = 3.14;  // 编译器推断y的类型为double
auto str = "Hello, World!";  // 编译器推断str的类型为const char*
以上代码中,编译器会根据初始值自动推断变量的类型。

必须注意,auto 不是一个类型,而是一个占位符,编译器会根据变量的初始值来推断出一个具体的类型。因此,使用 auto 时必须确保变量有一个明确的初始值,否则编译器无法推断出类型,会导致编译错误。我们也绝对不能把 auto 当成一个万能的类型来使用,例如:

auto x; // 错误,缺少初始值,编译器无法推断出类型

auto x = 5; // 正确,编译器推断x的类型为int
x = "Hello"; // 错误,x的类型已经被推断为int,不能赋值为字符串
不能把它当Python那样的动态类型来使用。如确实需要动态类型,可以使用 std::variantstd::any 来实现,或使用泛型编程(模板)来实现类型的灵活性。

但是依然需要注意:std::variantstd::any 以及模板虽然可以实现类型的灵活性,但它们也不是动态类型,他们仅仅是一个盒子,里面可以装不同类型的东西,但这个盒子本身的类型是确定的。具体使用见C++高级特性那一章。

因此,我们需要根据实际需求来选择合适的工具,而不是滥用 auto 或者其他类型灵活性的工具。

Warning

不要滥用 auto ,因为这会使得代码的可读性降低,尤其是当变量的类型不明显时。我们只推荐在变量的类型是确定的类型的名称非常冗长你知道这个变量的类型是什么或者大概是什么的情况下使用类型推断,例如auto it = std::max_element(v.begin(), v.end());中, it 的类型是 std::vector<int>::iterator 是确定的,这个类型名称很长,你也知道它是个迭代器类型,这时候使用 auto 是非常合适的。但是如果你真写了auto x = 5;这种代码,那就属于滥用了,是不提倡的。

如果变量的类型确实不确定(例如变量类型会随着初始化方式的不同而改变),这时候可以使用模板函数或模板类,而不是使用 auto 。如果你知道变量的类型是一个确定的类型,但是你不知道具体是什么类型,建议你先搞清楚这个变量的类型再写代码,否则几乎必然会出错。

类型别名

类型别名是C就有的一个特性,但是C++11对它进行了扩展。类型别名允许我们为现有的类型创建一个新的名称,使得代码更易读。

C++中可以使用 using 关键字来定义类型别名。

using ll = long long;  // 定义一个长整型的别名
using IntVector = std::vector<int>;  // 定义一个整型向量的别名
IntVector v = {1, 2, 3};  // 使用别名创建一个整型向量

如果使用C风格的语法,则是:

typedef long long ll;  // 定义一个长整型的别名
typedef std::vector<int> IntVector;  // 定义一个整型向量的别名
IntVector v = {1, 2, 3};  // 使用别名创建一个整型向量

using和typedef几乎没有什么区别,只不过using的语法更加符合直觉(用这个作为这个的别名),类似于声明变量;而typedef则更像是定义一个宏(虽然实际上不是),阅读方向是反直觉的。

using的另一个独特之处是可以用于模板类型的别名:

template <typename T>
using Matrix = std::vector<std::vector<T>>;  // 定义一个二维向量的别名
Matrix<int> m = {{1, 2}, {3, 4}};  // 使用别名创建一个二维向量
Matrix<double> dm = {{1.1, 2.2}, {3.3, 4.4}};  // 使用别名创建一个二维向量
typedef就无法应用于模板类型别名。因此,在C++中,我们推荐使用using来定义类型别名。

类型强转

有时候,在编程中我们需要将一个类型转化成另一个类型,以满足特定的需求。类型强转包括两类:隐式转换和显式转换。隐式转换是编译器自动进行的,而显式转换则需要程序员手动指定。

在一般情况下,隐式转换是安全的,不会导致数据丢失或错误。然而,有些情况下隐式转换可能会引发诸如精度等问题。默认能够进行的隐式转换包括以下几步: 1. 标准整形提升:所有比 int 小的整型(如 charshort )会被提升为 intunsigned int 。 1. 整形等级转换:提升之后,如果类型仍不匹配,编译器会尝试将较小的整型转换为较大的整型(如 int 转为 long )。 1. 浮点等级转换:如果涉及浮点数,编译器会尝试将较小的浮点类型转换为较大的浮点类型(如 float 转为 double )。 1. 混合类别转换:如果操作数类型不同,编译器会尝试将整型转换为浮点型,以避免精度丢失。转换后的类型为与浮点数的类型相同的浮点类型。 1. 其他转换:包括数组到指针、函数到指针、空指针常量、枚举到整型9、类类型的转换10等。

在C++中,类型强转被拆成了四个方式(四大金刚): - static_cast编译期安全的强转,包括数值提升/截断,枚举/整型,子类指针转父类指针、void转型等。它是最常用的类型转换方式,适用于大多数情况。 - dynamic_cast运行时安全的强转,几乎仅用于父类指针转子类指针。它会在运行时检查类型安全,如果转换不安全,则返回nullptr。它只能用于有虚函数的类。同时,它是唯一一个在运行时检查强转安全性的转换方式。 - const_cast常变转换,其他啥都不干。它是唯一一个能去const的转换方式。 - reinterpret_cast按位重解释*,用于int指针互转、void指针互转、无关类指针互转等。它是危险的转换方式,仅在编译期做极弱的检查。它也可以用于转引用,但是如果转不了不会返回空引用11而是报错。除非我们知道在干什么,否则不要使用它。

举例说明:

#include <iostream>
using namespace std;

double d = 3.14;
int a = static_cast<int>(d);  // 使用static_cast进行数值转换
int a = (int)d;  // C风格的强转,也行

const int c = 42;
int* p = const_cast<int*>(&c);  // 使用const_cast去掉const属性
// *p = 100; // 但是修改原变量的值是个UB

class Base;
class Derived : public Base;
// 注意:以上两行代码仅用于说明继承关系,实际过不了编译
Base* b = new Derived();
Derived* d = dynamic_cast<Derived*>(b);  // 使用dynamic_cast将父类指针转为子类
Derived* d = static_cast<Derived*>(b);  // 使用static_cast转型(不安全,但是能过编译)

uintptr_t ptr = reinterpret_cast<uintptr_t>(b);  // 使用reinterpret_cast将指针转换为整数

那么有些同学可能会问:为什么C++要提供这么多种类型强转?难道C风格的强转不行吗?没错,两种代码实际上都可以用。不过,C风格的强转像个大锤,一口气把任何东西都能砸成目标东西,但是它可不带管安全性的;而C++强转四大金刚分别是四把精确的手术刀,功能单一、语义明确,编译器会帮助你把关;要是危险或者出错了,编译器给你兜底。这样就可以避免很多潜在的错误。

C语言的强转实际上会先尝试常变转换,再尝试数值转换,要是不行就常变数值一起转,还不行就按位重解释。所以说这玩意实际上是四合一,不过也导致它隐形语义极为复杂、易于出错,出错了也不容易搜索定位。

const volatile void* v = ...;
int* bad = (int*)v;  // C风格的强转,实际上一口气把const和volatile都去掉了,顺便做了个按位重解释

所以说,我们非常建议优先使用C++四大强转做显式强转。我们非常不建议在C++中使用旧式风格的强转,除非要做向下兼容等不这么做不行的事情。

Note

volatile 是C/C++中的一个关键字,表示变量可能会被外部因素改变,因此编译器不会对它进行优化。它通常用于多线程编程或硬件寄存器的访问等。这个东西和移位运算符一样,绝大多数人一辈子都不会用到。

Lambda表达式

Lambda表达式是C++11引入的一个特性,它允许我们在代码中定义匿名函数。Lambda表达式可以捕获外部变量,并且可以作为参数传递给其他函数。它的基本语法如下:

[捕获列表](参数列表) -> 返回类型 {
    // 函数体
}
捕获列表用于指定哪些外部变量可以在Lambda表达式中使用,参数列表和返回类型与普通函数类似。Lambda表达式可以直接在代码中定义,不需要单独声明。

例如:

#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> nums = {1, 2, 3, 4, 5};
    int sum = 0;
    for_each(nums.begin(), nums.end(), [&sum](int n) { sum += n; });  // 使用Lambda表达式计算总和
    cout << "Sum: " << sum << endl;  // 输出结果
    return 0;
}
以上代码中,我们定义了一个Lambda表达式,该表达式能够捕获外部变量 sum ,并对 nums 向量中的每个元素进行求和操作。Lambda表达式可以使代码更简洁。

Lambda表达式的类型是特殊的。它是一个独一无二的、不可命名的、由编译器生成闭包类型。该类是匿名的,每一个Lambda表达式都会生成一个独一无二的类。这个类会重载 operator() ,使得Lambda表达式可以像函数一样被调用。其类型是一个确定的类型,但是该类型既不能命名也不能写出。Lambda表达式也不是一个函数指针,但是可以隐式转换为函数指针(前提是没有捕获任何外部变量)。

例如下列代码:

auto lambda = [](int x) { return x * x; };
其类型实际上类似于:
class __lambda_unique_name {
public:
    int operator()(int x) const { return x * x; }
};
但是你永远不能直接写出这个类名,它是不可以访问的。

有些时候,我们需要对其进行存储或传递,此时需要声明其类型。此时, auto 是好的实践之一。但是如果要传递给函数怎么办呢?为了这个目的,C++11引入了 std::function 模板类,它可以存储任何可调用对象,包括Lambda表达式、函数指针、函数对象等。使用 std::function 可以方便地传递和存储Lambda表达式。

#include <iostream>
#include <functional>  // 包含std::function的头文件
using namespace std;

void applyFunction(const function<int(int)>& func, int value) {
    cout << "Result: " << func(value) << endl;  // 调用传入的函数并输出结果
}

int main() {
    auto lambda = [](int x) { return x * x; };  // 定义一个Lambda表达式
    applyFunction(lambda, 5);  // 将Lambda表达式传递给函数
    return 0;
}
然而值得注意的是,即使是用 std::function ,它也仅仅是一个传递Lambda表达式的手段,而不是Lambda表达式本身的类型。且该类的性能开销比较大,有类型擦除的开销,可能会通过内联或堆分配来优化,因此在性能敏感的场景下应谨慎使用该类。实际上如果Lambda表达式不捕获任何外部变量,我们完全可以直接转成函数指针传递。
    using FuncPtr = int(*)(int);  // 定义一个函数指针类型
    FuncPtr func = [](int x) { return x * x; };  // 将Lambda表达式转换为函数指针
    cout << "Result: " << func(5) << endl;  // 调用函数指针并输出结果

多文件编程

头文件和源文件

头文件是一些预先写好的代码的集合。通过包含头文件,我们可以使用这些预先写好的代码,而不需要重新编写它们。头文件的扩展名通常是 .h.hpp 。引入头文件只需要在文件的开头使用 #include 指令即可。

头文件有两种类型:标准库头文件和自定义头文件。标准库头文件是C++标准库提供的头文件,通常使用尖括号括起来,例如 <iostream><vector> 等,这样会先在系统路径中查找,再去当前路径中查找;自定义头文件是用户自己编写的头文件,通常使用双引号括起来,例如 "myheader.h" ,这样会先在当前路径中查找,再去系统路径中查找。

与“头文件”相对应的是源文件,它们通常包含程序的主要逻辑和实现代码。源文件的扩展名通常是 .cpp.cxx.cc 。源文件可以包含头文件,并且可以定义函数、类和变量等。

// 这是一个源文件
#include <iostream>  // 引入标准库头文件
#include "myheader.h"  // 引入自定义头文件

int main() {
    // 使用头文件中的代码
    return 0;
}

Warning

严格禁止使用所谓的“万能头文件” #include <bits/stdc++.h>尤其是在工程中!该头文件有三个严重的问题: - 该头文件不是C++标准的一部分,而是GCC编译器提供的一个非标准头文件。使用该头文件会导致代码在不同编译器下表现不同,严重地影响代码的可移植性。 - 该头文件会引入整个标准库,从而显著地降低代码的可维护性,具体表现为: - 显著地增加编译时间,尤其是在大型项目中。 - 引入大量实际上并不需要的库,严重地增加了命名冲突的风险。 - 引入大量宏定义,可能会导致意想不到的行为。 综上所述,严格禁止使用该头文件!多背几个常用的头文件名称并不难,且对代码质量有显著提升。

自己写个头文件

有时候,我们自己需要写一个项目,这个项目代码量较大,可能有数千行。此时,我们需要多文件编程,以便于代码的组织和管理。

C++的多文件编程,文件结构通常是这样的:

project/
    main.cpp // 主要的程序入口
    module1.cpp // 模块1的实现
    module1.h // 模块1的头文件
    module2.cpp // 模块2的实现
    module2.h // 模块2的头文件
    ... 这里可能还有其他文件
由此可见,除了主要程序入口( main 函数所在的文件)之外,其他的头文件和源文件通常是成对出现的。在头文件中,一般包括类、函数、变量等的声明;在源文件中,一般包括类、函数、变量等的定义和实现。

自己写头文件时,通常包括以下内容: - 声明函数、类、变量等的接口。 - 使用预处理指令防止重复包含。 例如,我们可以编写一个简单的头文件 myheader.h ,包含一个函数的声明和定义:

#ifndef MYHEADER_H  // 编译守卫,防止重复包含
#define MYHEADER_H
int add(int, int);  // 函数声明
#endif
然后在源文件 myheader.cpp 中实现这个函数:
#include "myheader.h"  // 引入头文件
int add(int a, int b) {  // 函数定义
    return a + b;
}
然后在主程序中使用这个函数:
#include <iostream>
#include "myheader.h"  // 引入头文件

int main() {
    int x = 5;
    int y = 10;
    int sum = add(x, y);  // 调用add函数
    std::cout << "Sum: " << sum << std::endl;  // 输出结果
    return 0;
}
最终对其进行编译:
g++ main.cpp myheader.cpp -o myprogram
这样,我们就完成了一个简单的多文件编程。在上述编译命令中,两个源文件的顺序无关紧要。

如果源文件数量过多,那么就不应该使用诸如gcc等工具手动地编译。此时,应该使用 MakefileCMakeXMakeConan 等构建工具来管理编译过程。关于后三个构建工具的使用,将在下一章中介绍。

再强调一遍:在多文件编程中,不建议使用 using namespace std; 。尤其是头文件,严格禁止在头文件中使用该语句!

练习

重做

试着把上述C语言中的所有练习题,都用C++的方式重写一遍。要求: - 使用C++的输入输出流( iostream )替代C的标准输入输出( stdio.h )。 - 使用C++的标准库容器(如 std::arraystd::vector )替代C的数组。 - 不得出现裸指针和直接的内存管理(如 mallocfree )。

比较C和C++的代码风格和编程习惯,体会两者的异同。

练习

改错

以下几段代码存在一些错误、不良习惯或潜在问题,请找出并改正它们。

#include <iostream>
int main() {
    auto x;              
    x = 42;
    std::cout << x << '\n';
}

#include <iostream>
int main() {
    int a[5] = {1,2,3,4,5};
    for (auto v : a)          
        v += 10;
    for (auto v : a)
        std::cout << v << ' '; // 输出不符合预期的原因是?
}
// a.cpp
int add(int a, int b = 0) { return a + b; }

// main.cpp
int add(int a, int b = 0);   // 声明
int main() {
    std::cout << add(5) << '\n';
}
#include <iostream>
constexpr int sq(int x) { return x * x; }
int main() {
    int n;
    std::cin >> n;
    constexpr int val = sq(n);
    std::cout << val << '\n';
}
#include <cstdio>
#include <iostream>
int main() {
    std::cout << "C++ ";
    printf("C\n");          // 可能先打印 C
}
#include <iostream>
int f(int x) { return x + 1; }
int f(void* p) { return 2; }
int main() {
    std::cout << f(nullptr) << '\n'; // 输出不确定
}

答案

以下是上述练习题的错误,改正方法留给读者自行思考。 1. auto 用作声明变量时,必须有初始值,否则编译器无法推断类型。改正方法: auto x = 42; 。 1. 范围for循环中, auto v 是按值传递的,修改 v 不会影响原数组。改正方法:使用引用传递, for (auto& v : a) 。 1. 函数默认参数值只能在函数声明中指定,不能在函数定义中指定。改正方法:在 main.cpp 中添加函数声明时指定默认参数值:

int add(int a, int b = 0);
1. constexpr 函数的参数必须是编译时常量, n 是运行时输入的变量,不能作为 constexpr 变量的初始化值。 1. C++的标准输出流和C的标准输出使用不同的缓冲区,可能导致输出顺序不确定。改正方法:使用同一种输出方式,或者在 printf 之后调用 std::cout.flush() 。 1. nullptr 可以转换为任何指针类型,因此调用 f(nullptr) 时,编译器无法确定调用哪个重载版本。改正方法:显式指定调用的版本,例如 f(static_cast<int*>(nullptr))


  1. 该游戏引擎最著名的作品莫过于《红色警戒2》了。 

  2. 这个写出来的东西就太多了,目前大多数3A大作都是用这个引擎写的。 

  3. JAX是Numpy的GPU加速版本,Google出品。 

  4. Python最通行的解释器CPython甚至也是C写的! 

  5. 这里的干净指的是简单的空格分割或换行符分割,没有诸如逗号等其他符号。与之相对应的脏数据则是指包含了各种符号、格式不统一等复杂情况的数据。 

  6. 立即函数指的是声明为 consteval 的函数,在 C++20 中被引入,这样的函数只能在编译时期调用 

  7. 实际上还有第四种 std::auto_ptr,但是因为这个类型存在一些设计缺陷,已经在C++11中被弃用,因此这里不做介绍。 

  8. “链表尾”和“链表尾节点”不是一个东西。以一个长度为10的链表为例,链表尾节点是第10个节点,而链表尾是一个指向第10个节点的指针。 

  9. C++11 起的强枚举不能隐式转换 

  10. 指的是诸如代码: struct A{ A(int);}; void foo(A); foo(42); 中, foo(42) 把整数隐式转换为类 

  11. 没有“空引用”这种东西。