慕尼黑工业大学-C---系统编程笔记-全-
慕尼黑工业大学 C++ 系统编程笔记(全)
001:课程组织与介绍 🎯

在本节课中,我们将了解这门C++系统编程实践课程的整体目标、组织形式以及学习要求。
在深入课程内容之前,我们先花些时间梳理一下必要的组织事项。

部分内容已在启动会议上介绍过。如果你参加了启动会议,请耐心等待,我们会快速跳过这些部分。但这里也有一些关于课程运行方式的新增、更详细的信息。
课程目标 🎯
以下是本课程的核心目标。

- 学习C++编程:这自然包括掌握基本语法和惯用法。
- 实现大型系统:为此,我们将涵盖C++标准库、与Linux系统的交互,以及完成此任务所需的各种工具和技术。
- 编写高性能代码:许多人选择C++是因为它承诺能轻松编写高性能代码。因此,我们需要讨论多线程、同步以及可能影响系统性能的常见陷阱。在本课程中,你会发现这类性能陷阱出人意料地多,我们会在遇到时尽量指出。


先决条件 📋
我将跳过形式上的先决条件。既然你已选课,这部分不再那么重要。实践方面的先决条件更为相关。

以下是本课程对学生的要求。
- 无需C/C++经验:正如启动会议中指出的,我们不要求你有任何C或C++经验。甚至可以说,即使你有相关经验,我们也建议你尝试忘记,真正将本课程视为从零开始。这不是因为我们不相信你已经会写程序,而是因为这能让你更容易按照我们的期望编写代码。本课程不仅是学习如何写C++,更是学习如何写出“好”的C++代码(“好”的含义我们将在课程中阐明)。如果你有经验,这很好,会让你学得更轻松,但请尽量在完成编程作业时,仅使用课程已覆盖的知识。我保证,这将有助于你在作业中获得好成绩。
- 需有其他编程语言基础:即使没有C++经验,只要你熟悉其他通用编程语言(如Java或Python),就能完成编程作业。我相信这对所有人都适用,不会有问题。
- 推荐使用Linux系统:我们期望你在Linux系统上工作,可以是虚拟机。但我们无法保证所有内容都能在虚拟机中完美运行。理想情况下,你应使用一个纯净的Linux操作系统,最好拥有root权限。过去几年,有些学生在没有root权限的Linux系统(如图宾根大学的机房)上工作时遇到了麻烦。考虑到当前情况,本学期这应该不是问题。
- 需有基本的Shell经验:既然期望你在Linux上工作,你需要一些基本经验,特别是使用Shell,无需精通,只需能浏览系统、执行命令等。
- 其他操作系统的支持:当然,你仍可使用任何操作系统。但如果你使用非Linux系统,我们会尽力提供支持,但如果某些内容在其他系统上无法运行,你基本上需要自行解决,因为本课程是针对Linux系统设计的。特别是,我们的持续集成服务器(周五Muitz会详细介绍)运行Linux,它将对你的提交运行自动化测试。确保这些测试通过的最佳方法是在本地系统上复现它们。如果你的本地系统与CI服务器不同,可能导致本地测试通过而CI测试失败,这是可以避免的不必要麻烦。
时间安排与组织 🗓️
到目前为止有问题吗?看起来没有,很好,我们继续讨论时间安排和组织事项。
你已找到第一次讲座,所以这部分对你来说应该不新。我们在周二12:00至14:00有课。我们会准时在12:00开始,但会尽量将课程控制在90分钟内,所以通常应在13:30左右结束。

周五也是如此,课程时间为10:00至12:00。同样,我们准时在10:00开始,并尽量控制在90分钟内,通常应在11:30左右结束。这两场课程都将在我们现在所在的BigBlueButton房间进行直播。我们将这些课程大致分为两部分:约50%是讲座,我们介绍新内容和C++新知识,这些录播将公开;约50%是辅导课,我们讨论编程作业以及你在完成作业或学习中遇到的任何问题。
正如我所说,录播将上传到Moodle。既然这些幻灯片已经在线,你可以直接点击幻灯片中的链接,跳转到相应网站。
讲座和辅导课都是强制参加的。当然,如果你有正当理由(如补考),我们会例外处理。除此之外,我们期望你参加所有讲座和辅导课,这在很大程度上是因为我们会在周二和周五的课程中介绍重要且新的内容。
如果有任何重要通知,我们会通过网站和Mattermost聊天发布,请务必定期查看聊天。
课程安排 📅

关于讲座和辅导课的划分,我们有一个初步的学期安排。

这是初步安排,如果出现不可预见的情况,我们可能需要进行调整,但我们会尽可能遵循此计划。有一个假期与一次讲座时间重合,当然那次讲座不会举行,毕竟那是你们的假期。
考核方式 📝
好的。

我们已经简要提到会有编程作业,现在让我们花些时间进一步说明。

正如我提到的,我们期望你参加所有讲座和辅导课。为了鼓励出勤,我们会在随机的讲座或辅导课当天进行简短测验。
这些是非编码测验,通常可以用一句话甚至一个词来回答。

它们将在Moodle上发布,我们会在Mattermost聊天中通知。通常我们会在讲座开始时分发测验纸,但由于我们在线上进行,这有些脱节,意味着你可以在测验当天的任何时间完成。
但测验有时间限制,你可以自己选择何时进行,但一旦开始,通常有5分钟倒计时,你必须在这5分钟内完成测验。
除了测验,你可能在本学期前半部分花费最多时间的是每周的编程作业。这些作业从今天开始,在每次讲座后发布。
这些编程作业必须独立完成,不应有团队合作,我们会检查抄袭行为,所以请不要互相分享代码,独立解决这些编程作业。
它们大约在发布后9天截止。
“大约”是因为截止日期会根据学期内的日程安排而变化,但每项作业都有确切信息,上面会写明具体截止日期,以便你始终掌握最新、详细的信息。

总结 📚

本节课中,我们一起学习了这门C++系统编程课程的组织结构、学习目标、先决条件、时间安排以及考核方式。我们明确了课程旨在教授C++编程、系统实现和高性能代码编写,并强调了独立完成作业和使用Linux环境的重要性。接下来,我们将正式进入C++编程的学习。
002:C++生态系统介绍 🚀
在本节课中,我们将学习C++生态系统的基础知识。虽然不会深入探讨C++语法细节,但了解这些工具和概念对于后续的编程练习至关重要。

概述

欢迎来到今天的C++系统编程课程。今天的主题是C++生态系统介绍。这意味着我们暂时不会详细讨论C++语法,这将是下周的内容。然而,在使用C++时,你会接触到许多相关工具和概念。我们认为,了解这个生态系统不仅重要,而且在接下来几周的编程练习中,你也将大量用到这些知识。因此,我们今天将讨论这些内容。
尽管如此,为了让你在这门名为“C++系统编程”的课程中至少看到一些C++代码,我将展示一个简单的C++“Hello World”示例。
一个简单的C++示例
以下是一个基础的C++程序示例:
#include <iostream>

int main(int argc, char* argv[]) {
std::cout << "Hello " << argv[1] << std::endl;
return 0;
}

今天我不会讨论语法细节,因为这将是下周的内容。但通过这个例子,你可以了解一个C++程序的基本结构。通常,C++源文件以.cpp为扩展名。程序中包含一些语句,你可以定义main函数。其中,std::cout << ...这一行是C++中打印输出的标准方式。在这个“Hello World”示例中,程序会打印“Hello”以及你在命令行中提供的第一个参数,然后返回0。
正如周二课程所述,C++是一门编译型语言。因此,你不能像执行Python脚本那样直接运行C++源文件。对于Python,你虽然需要解释器,但无需预先编译。而C++则需要先进行编译。
编译器的作用
为了编译C++程序,你需要一个编译器。在上面的示例中,我们调用了名为c++的程序。你可以在示例终端中看到,我们运行c++程序并附带一些参数,然后生成一个名为my_program的二进制可执行文件。最后,我们执行这个文件并传递参数,就能在终端上看到预期的“Hello World”输出。
在深入讨论语法之前,我们想先谈谈第二个关键部分:C++编译器。这个程序本质上负责将一个或多个C++源文件转换成一个可执行文件,操作系统随后能够执行它。这类程序通常被称为编译器。任何将某种编程语言作为输入并输出可执行文件的程序,都可以称为编译器。
主流C++编译器
对于C++,最流行的编译器是GCC和Clang。
- GCC:全称是GNU编译器集合,来自GNU项目。它主要在Linux系统上使用,是开源的。
- Clang:来自LLVM项目。它同样非常流行,在Linux上也很常用,并且也是开源的。在macOS上开发时,如果你安装了Apple的开发工具,默认编译器通常是Clang。
我们同时介绍这两个编译器的原因是,它们在命令行参数上高度兼容。如果你看到一个使用GCC命令和参数的例子,通常只需将可执行文件名从g++换成clang++,大多数情况下也能正常工作。
编译器的使用
首先,让我们看看如何使用这样的编译器。这里有一个GCC的例子。GCC全称是GNU编译器集合,这意味着它不仅支持C++,还能编译C语言等其他编程语言。因此,它有多种调用方式。如果你想用GCC编译C++程序,必须使用g++程序。这本质上是为了告诉GCC启用C++特有的功能。


从一个源文件生成二进制可执行文件的最基本例子是:调用编译器,指定输入文件,并使用-o参数来命名输出文件。这样,编译器就会将可执行文件写入你指定的文件中。
编译过程简介

虽然上面的命令可以工作,但编译器内部实际上发生了很多步骤。你需要了解的第一点是:C++编译器内部通常分为三个阶段:预处理器、编译器(狭义)和链接器。是的,“编译器”这个词本身包含了“编译器”这个步骤,这确实有点令人困惑。通常当人们谈论“编译器”时,他们指的是这三个步骤的整体。但从技术上讲,“编译器”只是将源文件转换为可执行文件的多个步骤中的一个。我们今天不会深入讨论每个步骤的细节。
尽管如此,我今天想给你一个简短的概述,介绍编译器(至少是GCC和Clang)所拥有的一些常用命令行标志和参数,你可以利用它们来影响编译过程。
编译器命令行语法与常用标志
正如你所见,通用的语法是:使用你编译器的C++版本(对于GCC是g++,对于Clang是clang++),然后是一些可选的编译器标志(通常用-o指定输出文件名),最后在命令行末尾列出所有需要的源文件作为输入。
以下是几个你可能总会用到的、最常见的标志。无论是你直接在终端中键入,还是通过某些IDE自动生成这些标志,了解它们都很有用。
-std=c++20:这可能是最重要的一个。它告诉编译器你想要使用C++20标准。正如周二所说,C++有多个标准版本,新标准通常包含更多功能。由于某些新功能在不同版本间并非完全兼容(尽管大多数时候是),你需要告诉编译器你期望使用哪个版本的C++标准,以便它相应地编译你的程序。- 优化标志:有一系列标志会影响优化级别。优化意味着,对于相同的源代码程序,编译器生成汇编代码的方式有多种。根据不同的优化标志,编译器会付出不同程度的努力来生成更快的、但功能与你所写代码等效的程序。本质上,它会更努力地生成更好的汇编代码。至少对于GCC和Clang,默认的优化级别是
-O0,即不进行优化。你可能会希望使用这个级别,因为当你想要调试程序时,完全不进行优化通常是最好的。因为如果进行了优化,编译器可能会省略一些你期望执行的指令或代码行,因为它可以推断出这些操作不影响最终结果。
总结

本节课我们一起学习了C++生态系统的基础知识。我们了解了C++程序的基本结构,认识了将源代码转换为可执行文件的编译器,并介绍了两个主流的编译器:GCC和Clang。我们还简要了解了编译过程通常包含的多个阶段,并学习了一些常用的编译器标志,例如用于指定语言标准的-std=c++20和用于控制代码优化的-O系列标志。掌握这些工具和概念,将为后续深入学习C++语法和进行实际编程练习打下坚实的基础。
003:基础C++语法 🧱
在本节课中,我们将学习C++的基础语法。这包括内置类型、变量、表达式、语句、控制流和函数等核心概念。这些内容与许多其他高级编程语言相似,但C++有其独特的细节和规则。我们将快速浏览这些内容,为后续更深入的主题打下基础。
注释 📝
上一节我们介绍了课程概述,本节中我们来看看C++中最基础的功能之一:注释。

与其他语言类似,C++有两种注释:多行注释和单行注释。它们用于在代码中添加说明性文字,编译器会忽略这些注释。
以下是两种注释的语法示例:
// 这是一个单行注释
/*
这是一个
多行注释
*/
基础类型 🔢
了解了如何为代码添加注释后,我们接下来探讨构成C++程序的基本构建块:基础类型。
C++提供了一组基础(或原始)类型,它们是构成所有其他复合类型的基础。这些类型包括 void、布尔型、整型、字符型和浮点型。
void 类型


void 类型有些特殊,它表示“无类型”。它不能拥有值,因此不允许创建 void 类型的对象。它主要用作不返回任何值的函数的返回类型。与其他语言的一个不同之处在于,C++允许指向 void 类型的指针,我们将在后续课程中详细讨论指针。

布尔类型 (bool)
布尔类型非常简单,它只有两个可能的值:true(真)和 false(假),由关键字 bool 标识。除了直接赋值,布尔值也经常通过隐式类型转换获得。

字符类型 (char)

字符类型用于表示单个字符。它通常占用1字节内存,可以表示基本的ASCII字符集。C++中还有 wchar_t、char16_t、char32_t 等用于表示更宽字符集的类型。
整型 (int)
整型可能是第一个与你已熟知的语言有显著差异的类型。所有整型都由关键字 int 标识,但我们可以通过“修饰符”来改变整型的属性。
以下是主要的修饰符类别:
- 有符号性修饰符:C++区分有符号(
signed)和无符号(unsigned)整数。有符号整数可以表示负数(C++20起强制使用二进制补码表示)。无符号整数只能表示非负数。 - 大小修饰符:
short修饰符优化空间,宽度至少16位。long和long long分别提供至少32位和64位的宽度。
默认情况下,int 是有符号的,因此 signed 关键字通常被省略。修饰符和 int 关键字的顺序可以任意排列,但这会导致代码可读性降低。

按照约定,我们建议按以下顺序排列:先有符号性修饰符(如 unsigned),然后是大小修饰符(如 long),最后是 int 关键字。这产生了以下规范的类型指定符:
| 类型指定符 | 最小宽度 | 最小值 | 最大值 |
|---|---|---|---|
short / short int |
16位 | -32768 | 32767 |
unsigned short |
16位 | 0 | 65535 |
int |
16位 | -32768 | 32767 |
unsigned / unsigned int |
16位 | 0 | 65535 |
long / long int |
32位 | -2147483648 | 2147483647 |
unsigned long |
32位 | 0 | 4294967295 |
long long / long long int |
64位 | -9223372036854775808 | 9223372036854775807 |
unsigned long long |
64位 | 0 | 18446744073709551615 |
需要注意的是,标准只规定了这些类型的最小宽度,实际宽度取决于编译器和架构。如果需要确保整数具有精确的位宽,可以使用定义在 <cstdint> 头文件中的固定宽度整数类型,例如 int32_t、uint64_t 等。
浮点类型

浮点类型用于表示实数。C++主要有三种浮点类型:
float:单精度浮点数。double:双精度浮点数。long double:扩展精度浮点数。
它们的精度和范围由实现定义,但通常遵循IEEE 754标准。

总结 📚

本节课我们一起学习了C++的基础语法。我们从最简单的注释开始,然后系统地介绍了C++的各种基础类型:特殊的 void 类型、简单的布尔类型、字符类型、具有多种修饰符的整型以及浮点类型。理解这些基础类型是编写任何C++程序的第一步。下一节课,我们将学习如何使用这些类型来声明变量和编写表达式。
004:编译C++文件与声明和定义 📚


在本节课中,我们将要学习C++程序的编译过程,以及声明和定义这两个核心概念。我们将详细探讨编译器内部的工作原理,并解释为何C++代码通常被分为头文件和实现文件。
编译C++文件 🛠️

上一节我们简要介绍了C++程序的结构。现在,我们来看一个稍长的“Hello World”程序示例,并深入理解编译过程。
正如之前提到的,C++代码通常被分离到头文件(.h或.hpp)和实现文件(.cpp)中。本节中,我们将探讨这种分离的原因以及两者的区别。目前,你只需要知道它们是两种不同的文件类型,但可以引用同一个函数(例如 sayHello)。
接下来,让我们仔细看看编译器如何处理这些文件。
编译器内部结构 🔧
从上周的学习中你应该已经知道,我们通常所说的“编译器”程序,在内部实际上分为三个部分:预处理器、编译器和链接器。
了解这个概念很有用,因为有些错误信息只会在预处理器或链接器阶段出现,而不会在编译器本身出现。因此,至少从概念上理解编译器内部如何工作是很有意义的。
预处理器
预处理器首先处理输入文件。它的输入可以是几乎任何编程语言的源文件,这意味着预处理器并不特定于C或C++,只是最常与这两种语言一起使用。

预处理器只处理预处理指令,即那些以井号(#)开头的行,以及宏。它的输出是同一个文件,但其中所有的预处理指令和宏都已被替换或展开,不再包含任何预处理指令。只有经过这个处理后的文件才会被交给编译器。
以下是几个最重要的预处理指令:
1. #include 指令
#include 指令的作用非常简单:它只是将指定文件的内容复制到当前文件中。其语法如下:
#include <header_name> // 使用尖括号
#include "header_name" // 使用双引号
两者的主要区别在于搜索路径:
- 使用双引号时,编译器会先在用户指定的自定义包含路径中查找头文件,然后再搜索系统目录。
- 使用尖括号时,编译器通常只在系统目录中查找头文件。
因此,包含标准库头文件时通常使用尖括号,而包含项目自身的头文件时使用双引号。需要注意的是,通常不应该直接包含 .cpp 文件。
2. #define 指令
#define 指令用于定义宏。例如:
#define FOO // 定义一个名为FOO的宏,无内容
#define BAR 1 // 定义一个名为BAR的宏,内容为1
预处理器会将代码中所有出现 FOO 的地方直接移除,将所有出现 BAR 的地方替换为 1。
重要提示:在C++中,不应该使用 #define 来定义常量。C语言中常用这种做法,但在C++中有更好的方式,例如使用 const 或 constexpr 变量,我们将在后续课程中讨论。
3. 条件编译指令 (#ifdef, #ifndef, #endif)
这些指令用于在代码被交给编译器之前,有条件地包含或排除部分代码。例如:
#ifdef FOO
// 如果宏FOO已定义,则保留此部分代码
#else
// 如果宏FOO未定义,则保留此部分代码
#endif
这种做法的一个主要用途是实现头文件保护,我们稍后会讨论。
编译器(狭义)

经过预处理器处理的源文件(在C++中称为翻译单元,通常是一个 .cpp 文件)会被交给编译器。
编译器的工作是检查语法、进行优化,并将翻译单元转换为目标文件(在Linux上通常是 .o 文件)。目标文件本质上是该翻译单元机器码的二进制表示。
需要注意的是,一个 .cpp 文件可能会使用在其他文件中定义的函数。在编译阶段,这些对外部符号的引用还没有被解析,它们只是被记录下来。
链接器

一个项目通常由多个 .cpp 文件组成,因此编译后会生成多个目标文件。链接器的任务就是将这些目标文件,以及可能用到的外部库,组合成一个完整的可执行程序或库。
链接器会查找所有目标文件中引用的外部符号,并将它们正确地关联起来,最终生成可执行文件(如 a.out)或库文件(如 .so 或 .a)。

总结 📝

本节课中我们一起学习了C++程序的编译流程。我们了解到“编译”实际上包含预处理、编译(狭义)和链接三个阶段。预处理器处理 #include、#define 等指令;编译器将单个翻译单元转为目标文件;链接器则将多个目标文件及库合并为最终程序。我们还强调了在C++中应避免使用 #define 定义常量。理解这些步骤对于诊断构建错误和编写模块化代码至关重要。
005:引用、数组与指针 📚


在本节课中,我们将学习C++中的三个核心概念:引用、数组和指针。这些是理解C++作为一门系统编程语言如何直接操作内存和资源的关键。我们将从引用开始,逐步深入到数组和指针,并解释它们之间的关系与区别。

引用 🔗

上一节我们介绍了复合类型的概念。本节中,我们来看看引用。引用为已存在的对象或函数提供了一个别名。
引用的语法与特性
声明引用的基本语法是在类型后添加 & 符号。例如:
int& ref = variable;
以下是关于引用需要了解的关键点:
- 引用必须初始化,且只能指向已存在的对象或函数。
- 引用本身是不可变的(即不能让其指向另一个对象),但它所引用的对象的值可以被修改。
- 引用本身不是对象,因此不能创建指向引用的指针,也不能创建引用的数组。
引用的初始化
由于引用必须初始化,定义时必须为其指定一个目标。以下是一些合法的初始化场景:
- 指向一个类型匹配的对象。
- 指向一个函数。
- 指向一个可转换为目标类型的对象。
在函数参数、返回类型或类成员声明中,可以只声明引用而不立即初始化。
左值引用示例
让我们通过一个例子来理解引用的行为:
unsigned i = 5;
unsigned j = 10;
unsigned& r = i; // r 是 i 的引用
r = 21; // 现在 i 的值变为 21
r = j; // 将 j 的值(10)赋给 r(即 i),i 变为 10。r 仍然引用 i,而不是 j。
数组 📦

了解了如何为单个对象创建别名后,我们来看看如何组织多个同类型对象——数组。
数组的声明与初始化
数组用于在连续内存中存储多个同类型对象。声明数组需要指定元素类型和大小。
int arr[10]; // 声明一个包含10个整数的数组
以下是数组初始化的几种方式:
- 在声明时使用初始化列表:
int arr[3] = {1, 2, 3}; - 初始化列表可以省略大小,编译器会自动推导:
int arr[] = {1, 2, 3}; - 可以部分初始化,未指定的元素会被默认初始化(如设为0)。
数组的访问与遍历
数组元素通过从0开始的索引进行访问。
arr[0] = 42; // 访问第一个元素
可以使用循环来遍历数组中的所有元素。


指针 🎯

数组在内存中是连续存储的。要直接操作内存地址,我们需要指针。指针是存储另一个变量内存地址的变量。
指针的声明与使用
声明指针需在类型后使用 * 符号。
int* ptr; // 声明一个指向整数的指针
以下是使用指针的基本操作:
- 获取地址:使用取址运算符
&获取变量的地址。ptr = &variable; - 解引用:使用解引用运算符
*访问指针所指向地址的值。int value = *ptr; - 指针算术:可以对指针进行加减运算,这在遍历数组时非常有用。
指针与数组的关系
数组名在大多数情况下可以视为指向其首元素的指针。
int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr; // ptr 指向 arr[0]
通过指针算术可以访问数组的其他元素,例如 *(ptr + 2) 访问 arr[2]。
指针的注意事项
指针功能强大,但也容易导致错误,例如:
- 空指针:未指向任何有效地址的指针。
- 野指针:指向已释放或无效内存的指针。
- 内存泄漏:忘记释放动态分配的内存。
总结 🎉

本节课中我们一起学习了C++中引用、数组和指针这三个核心概念。
- 引用是一个对象的别名,必须初始化且不可更改其指向,用于提供便捷的访问方式。
- 数组是相同类型对象的集合,在内存中连续存储,通过索引访问。
- 指针存储内存地址,能够直接操作内存,与数组关系密切,但需要谨慎使用以避免错误。

理解这些概念是掌握C++系统编程的基础,它们让你能够更精细地控制程序的内存和资源。在接下来的课程中,我们将基于这些知识,探索更复杂的类型和功能。
006:类与对象 🏗️

在本节课中,我们将学习C++中一个核心概念:类。C++是一门支持多种编程范式的语言,而类正是面向对象编程范式的基石。我们将从基本语法开始,逐步深入到类的成员、内存布局等细节,确保初学者也能轻松理解。

类的定义与基本语法 📝
上一节我们介绍了课程概述,本节中我们来看看如何定义一个类。在C++中,类是一种用户自定义类型,其定义语法如下:

class ClassName {
// 成员声明...
};
或者使用 struct 关键字(源自C语言,在C++中与 class 的主要区别在于默认访问权限):
struct ClassName {
// 成员声明...
};
以下是定义类时需要注意的几个关键点:
- 类名:可以是任何有效的标识符。
- 类体:花括号
{}内是成员声明的区域,可以包含变量(数据成员)、函数(成员函数)或类型(嵌套类型)。 - 分号:类定义必须以分号
;结束,忘记分号会导致编译错误。
类的成员:数据成员 📊
了解了基本语法后,我们来详细看看构成类的元素。首先介绍数据成员,它们本质上是声明在类内部的变量。

数据成员主要分为两类:
- 非静态数据成员:每个该类的对象都拥有自己独立的一份副本。
- 静态数据成员:使用
static关键字声明,在整个程序中只有唯一一份存储,被该类的所有对象共享。
关于数据成员的声明,有以下规则:
- 数据成员是声明,而非定义。
- 声明时不能使用
extern关键字。 - 可以使用
thread_local修饰静态数据成员,使其具有线程局部存储期。 - 所有数据成员必须具有完整类型(我们稍后会讨论)。
- 每个数据成员的名称在类内必须是唯一的。
- 非静态数据成员可以指定默认成员初始化值(例如
int a = 123;),这会影响对象的初始化过程。
类的内存布局 🧠
C++作为一门贴近硬件的语言,其用户自定义类型(如类)的性能可以达到与内置类型相当的水平。一个关键原因在于,类对象在内存中的布局有严格且明确的规则。
每个类型都有其大小和对齐要求。对于类类型:
- 成员顺序:非静态数据成员在内存中按照其声明顺序依次存放。
- 对齐与填充:每个成员必须存储在其对齐要求整数倍的地址上。编译器可能会在成员之间插入填充字节以满足对齐要求,这会导致对象的总大小可能大于各成员大小之和。
- 类本身的对齐:类类型的对齐要求等于其所有非静态数据成员中最大的对齐要求。
- 类本身的大小:类对象的大小至少是各非静态数据成员大小之和(加上可能的填充),并且任何对象的大小至少为1字节。
- 静态成员:静态数据成员不存储在类对象内部,它们在程序的数据区有独立的存储位置。
让我们通过一个例子来理解内存布局。假设有一个类 C:
class C {
int i; // 大小4, 对齐4
char* p; // 大小8, 对齐8
char c; // 大小1, 对齐1
short s; // 大小2, 对齐2
};

其对象在内存中的一种可能布局如下(假设特定系统架构):
- 从偏移量0开始,存储4字节的
int i。 - 下一个成员
char* p需要8字节对齐。当前地址偏移量是4,不是8的倍数,因此编译器插入4字节的填充。 - 在偏移量8(8的倍数)开始,存储8字节的
char* p。 - 在偏移量16开始,存储1字节的
char c。 - 下一个成员
short s需要2字节对齐。当前地址偏移量是17,是奇数,不是2的倍数。因此编译器插入1字节的填充。 - 在偏移量18(2的倍数)开始,存储2字节的
short s。 - 最终,对象
C的大小为20字节(0-19),其对齐要求为8(所有成员中最大的)。
总结 🎯
本节课中我们一起学习了C++中类的基础知识。我们首先介绍了类的定义语法,然后详细讲解了数据成员的类型(静态与非静态)及其声明规则。最后,我们深入探讨了类对象在内存中的布局规则,包括成员顺序、对齐要求和填充字节,这是理解C++高效内存管理的关键。

掌握类的这些基础概念,是后续学习构造函数、成员函数、访问控制等更高级主题的坚实基础。
007:动态内存管理与所有权 🧠
在本节课中,我们将要学习动态内存管理及其相关的重要概念。这些概念对于编写现代C++程序至关重要,但同时也是初学者最容易感到困惑的部分之一。因此,请务必积极提问,我们将详细探讨这些内容。

进程内存布局
首先,我们需要了解Linux(及其他操作系统)上典型的进程内存布局。每个Linux进程都在其自己的虚拟地址空间中运行。这意味着内核会让每个进程认为自己可以访问一个巨大的连续地址范围。
在64位系统上,这个范围可以大到2^64字节。当然,这远大于系统中实际的物理内存。因此,内核内部会通过页表、内存管理单元等数据结构,将这些虚拟地址映射到物理地址。
这种机制为我们的进程提供了便利:它们可以假装拥有这个巨大的地址范围,而内核则负责将这个范围映射到系统中实际的物理内存。这不仅简化了内核中的内存管理代码,还提高了安全性,因为每个进程都有自己的地址空间,不会意外访问或更改属于其他进程的内存。此外,它还支持一些有用的技巧,例如内存映射文件。
内核与用户空间

我们必须记住,内核本身也需要内存,并且也使用虚拟地址范围的一部分。因此,地址范围通常被划分为保留给内核内存的部分和用户空间程序可访问的部分。
在一个典型的Linux系统上,通常将最高的248个地址保留给内核,然后有一个非常大的未使用地址空间部分,接着较低的248字节可供用户空间寻址。目前大多数x86平台上的内存管理单元仅支持48位指针,因此我们无法寻址更多内存,但这在未来可能会改变。
即使有48位指针的限制,我们仍然可以寻址巨大的内存量:用户空间程序有128TB的可寻址内存,内核空间也有相同大小。对于目前编写的程序来说,这已经足够了。
用户空间内存段
由于我们主要关注用户空间程序,让我们放大用户空间地址空间的这一部分。我们可以看到,它本身又被组织成不同的段。

- 栈段:包含程序栈和所有局部变量。
- 内存映射段:本课程中不特别相关。
- 堆段:用于动态内存分配。
- 其他段:主要包含静态数据。
这些段可以增长。例如,当我们调用一个函数时,可能需要为该函数的局部变量、参数和返回值分配更多栈空间。通常,栈内存段向下增长,即分配更多栈内存时地址减小。相反,堆段通常向上增长,即分配更多堆内存时地址增加。
这些背景信息在某些用例中可能很有用,也使得读取指针更容易。如果我们查看可寻址用户空间内存中的最小和最大地址,会发现它们相当受限。查看程序中的指针时,通常会看到它们以0x7f...开头,这是因为指针通常位于该段的顶部区域。
栈段与自动存储期

到目前为止,您主要在与栈段交互,因为该段通常用于具有自动存储期的对象,也就是您几乎一直在使用的局部变量。
这种内存非常方便,因为编译器可以静态决定何时必须进行分配和释放,并且内存布局在编译时是已知的。这允许高度优化的代码,因为在栈段上的任何分配和释放都只是简单地增加和减少指针(汇编中的基址和栈指针寄存器)。
因此,这个段非常快,我们不需要复杂的代码来分配内存。但它也非常不灵活:例如,数组大小必须在编译时已知,并且我们无法在栈上实现任何动态数据结构,如树、图等。当然,这些数据结构非常有用。在本节课中,我们将看到如何实现它们。

堆段与动态存储期
栈段相当直接。您只需编写局部变量就可以透明地在代码中使用栈内存,无需做更多工作。堆段则不同,它通常用于具有动态存储期的对象,程序员(也就是您)必须显式管理分配和释放。

到目前为止,您只间接使用过堆内存,例如通过std::vector。std::vector内部会根据向量的大小执行动态内存的分配和释放。这已经是一个例子,说明使用堆内存如何让您编写更灵活的程序,因为您不再受限于编译时已知的常量大小。
当然,使用堆内存也有一些主要缺点,最显著的是性能影响。堆上的分配需要高度复杂的实现来跟踪堆内存的哪些部分已分配、哪些是空闲并可重用等。
一个相关的问题是,使用堆内存时,如果进行大量小分配,总是存在内存碎片化的风险,尽管这不太可能导致堆内存耗尽。最后,也是本节课剩余部分将重点关注的,动态内存分配非常容易出错,因为您必须管理分配和释放,这意味着您可能会忘记释放不再需要的内存,从而导致内存泄漏。

本节课中我们一起学习了进程内存布局、栈与堆的区别、以及动态内存管理的基本概念和挑战。理解这些是掌握C++内存管理的基础。
008:继承

在本节课中,我们将要学习C++面向对象编程的第二个核心支柱——继承。我们将探讨如何定义派生类、理解对象在继承层次结构中的构造与析构顺序,并初步了解继承模式的概念。
面向对象编程的三大支柱
上一节我们介绍了数据抽象,它通过类来实现。本节中我们来看看继承。

面向对象编程基于三大支柱:
- 数据抽象:通过类实现。
- 继承:通过类派生实现,派生类继承其基类的成员和成员函数。
- 动态绑定(多态):通过虚函数实现,允许程序在运行时根据对象的实际类型调用相应函数。
定义派生类

在C++中,任何类类型都可以从一个或多个基类派生。这适用于 class 和 struct。基类本身也可以有自己的基类,从而构建出任意复杂的继承层次结构。
定义派生类的高级语法如下:
class DerivedClassName : BaseSpecifierList {
// 类成员定义
};
对于 struct,语法本质相同。
基类指定符列表

基类指定符列表位于冒号之后,包含一个或多个由逗号分隔的基类指定符。
一个基类指定符的构成如下:
- 访问说明符:控制继承模式,可以是
private、protected或public。大多数情况下使用public。 virtual关键字(可选):用于处理多重继承的特定情况,本课程不深入讨论。- 基类名称:指定要继承的类。
以下是定义派生类的几个示例:
class Base {};
// 示例1:隐式私有继承
class Derived0 : Base {};
// 示例2:显式私有继承
class Derived1 : private Base {};

// 示例3:公有继承(最常见)
class Derived2 : public Base {};
// 复杂示例(实践中罕见):公有虚拟继承与私有继承的组合
class DerivedComplex : public virtual Base, private Derived1 {};
在实践中,你几乎只会看到从单个基类进行的公有继承,如 Derived2 所示。
继承层次结构中的构造与析构
定义了继承层次结构后,我们需要了解其中对象的初始化和销毁过程,这通过构造函数和析构函数完成。
构造函数调用顺序

当调用派生类的构造函数时,初始化按以下严格顺序进行:
- 初始化所有基类。如果存在多个基类,则按照它们在基类指定符列表中出现的顺序从左到右进行初始化。
- 初始化派生类的数据成员。初始化顺序完全取决于数据成员在类定义中声明的顺序,与它们在构造函数初始化列表中的顺序无关。
- 执行派生类构造函数的函数体。
关于基类初始化的重要说明:
- 默认情况下,基类使用其默认构造函数进行初始化。
- 可以通过委托构造函数语法显式调用基类的其他构造函数来覆盖此默认行为。
示例说明
假设有以下类定义:
class Base {
public:
Base() { /* 打印信息:调用Base默认构造 */ }
Base(int) { /* 打印信息:调用Base(int)构造 */ }
};
class Derived : public Base {
public:
Derived() { /* 打印信息:调用Derived默认构造 */ }
Derived(int a, int b) : Base(a) { /* 打印信息:调用Derived(int, int)构造 */ }
};
执行以下代码:
Derived d1; // 调用Derived默认构造函数
Derived d2(1, 2); // 调用Derived(int, int)构造函数
输出结果将是:
调用Base默认构造
调用Derived默认构造
调用Base(int)构造
调用Derived(int, int)构造
对于 d1,虽然 Derived() 构造函数没有显式调用基类构造函数,但根据规则,基类 Base 仍会先被其默认构造函数初始化。
对于 d2,Derived(int, int) 构造函数通过 : Base(a) 显式调用了 Base(int) 构造函数,因此基类使用该构造函数初始化。



总结



本节课中我们一起学习了C++继承的基础知识。我们了解了如何通过派生类构建继承层次结构,掌握了派生类定义中基类指定符列表的语法。更重要的是,我们深入探讨了派生类对象的构造过程,明确了基类初始化、成员初始化和构造函数体执行的严格顺序。理解这些顺序对于编写正确且可预测的面向对象程序至关重要。下一节,我们将继续探讨继承的另一个关键方面——继承模式与成员访问控制。
009:模板
在本节课中,我们将要学习C++中一个强大的特性——模板。模板允许我们编写与类型无关的通用代码,从而避免代码重复,并创建高度可复用的组件,如标准库中的std::vector。
为什么需要模板?🤔
上一节我们介绍了系统编程的基础,本节中我们来看看模板。模板是一个比较抽象的概念。其主要动机在于,当我们开始实现某些功能时,常常会遇到一些本质上与特定类型T无关的功能。
以下是几个典型的例子:
- 交换函数:一个交换函数本质上不关心它交换的是什么类型的元素,只要这些元素满足某些特定要求(如可移动或可复制)即可。
std::vector容器:std::vector同样不关心它存储的是什么类型的元素,只要这些元素支持移动或复制等操作即可。
在你的练习中,你可能已经实现过一些容器。由于当时不了解模板,这些容器被限制为只能处理特定类型。但实际上,我们可以用更通用的方式实现它们,使其适用于几乎任意类型。

这里的问题是,我们希望这种功能对所有合适的类型T都可用。但我们不希望出现代码膨胀——如果必须为每一种可能的类型手动实现,代码量将非常庞大。而且,我们甚至无法为那些在实现时(例如实现std::vector数据结构时)还不知道的用户自定义类型编写代码。
这就是模板要解决的问题。
模板是什么?🔧
模板允许我们定义一个类、函数、类型别名或变量的家族。这个家族可以通过一个或多个模板参数进行参数化。
这些模板参数可以是:
- 类型:例如
int或float。 - 非类型参数(常量值):例如,
std::array的大小就是一个非类型模板参数,它允许我们在编译时指定数组的固定大小。 - 模板模板参数:参数本身也是一个模板。
为了使用这样的类家族,我们必须提供一个模板实参,该实参将替换模板参数。这个过程产生的结果称为模板的特化。例如,我们可以使用std::vector<int>或std::vector<float>,其中int和float就是模板实参。
编译时与运行时的关键区别⚙️
模板的语法看起来可能与其他语言(如Java)中的泛型相似,但关键区别在于:模板纯粹是一种编译时构造。
这意味着,当你使用一个模板时(例如std::vector<int>),编译器会进行模板实例化。这个过程会为你的模板的特定特化(如vector<int>)生成实际的汇编代码。如果你再使用std::vector<float>,编译器会再次为vector<float>生成另一份汇编代码。
这种机制有几个重要后果,特别是如果你习惯了Java的运行时泛型,可能会感到惊讶:
- 所有模板参数都必须在编译时确定。你只能将编译时常量或编译时已知的类型名称作为模板实参。
- 在运行时,一个模板实例化(如
vector<int>)就像一个普通的类,你无法再改变它。

什么是编译时和运行时?
- 编译时是编译器处理代码的阶段。编译器可以计算常量表达式,例如
4 * 3,这个结果可以作为std::array的大小。 - 运行时是程序实际执行的阶段。两个非常量变量的乘积结果无法在编译时确定为一个固定值,因此不能用作模板实参。

一个简单的模板示例📝
让我们看一个如何自己定义std::vector模板的简化示例。
template <class T>
class vector {
private:
T* data_;
size_t size_;
size_t capacity_;
public:
void push_back(const T& value) {
// ... 实现逻辑,使用 T 类型
}
// ... 其他成员函数
};
这个定义与普通类相似,关键区别在于class关键字前多了一行:
template <class T>

这行代码告诉编译器,我们的vector类由一个名为T的类型模板参数进行模板化。然后,我们可以在vector的实现中像使用其他类型别名一样使用T。
例如,在push_back函数中,我们使用const T&作为参数类型。当我们用特定类型(如int)实例化vector时,此处的T将成为int的别名。当我们用其他类型(如类A)实例化时,T则被替换为类A。
通过这种方式,我们可以轻松实现非常通用的容器,而无需关心T具体是什么类型,只需专注于容器本身的功能逻辑。
模板的正式语法📚
现在,让我们更正式地看看C++标准是如何定义模板的。

我们可以将多种实体声明为模板,语法总是相同的:
template < 参数列表 > 声明
- 参数列表:一个逗号分隔的模板参数列表。参数可以是类型、非类型或模板模板参数。
- 声明:跟在
template关键字和参数列表后面的部分。它可以是:- 类声明或定义
- 成员类或枚举类型
- 函数或类的成员函数
- 命名空间作用域的静态数据成员或类作用域的数据成员
- 类型别名
其中,类模板声明是在C++代码中最常见的用法。
类型模板参数详解🎯

正如我们刚才在幻灯片上看到的,可以放入模板参数列表的第一种东西是类型模板参数。它们是任意类型的占位符。
其语法是:typename 或 class,后跟一个任意名称(可省略)。
以下是关于类型模板参数的要点:
- 使用
typename或class作为关键字完全由你决定,两者没有区别,可以互换使用。 - 一旦在模板声明中有了这样的模板参数,在声明体内,你指定的名称就简单地成为一个类型别名。
总结✨

本节课中我们一起学习了C++模板的核心概念。我们了解到模板是一种强大的元编程工具,用于创建通用、类型安全的代码,从而避免重复。关键在于理解模板是编译时实例化的,这与某些其他语言的运行时泛型有本质区别。我们通过一个简化的vector示例看到了类模板的基本语法,并正式介绍了模板声明及其类型参数的规则。掌握模板是深入理解C++标准库和进行高效系统编程的基础。
010:标准库 I 📚



在本节课中,我们将要学习C++标准库的基础知识。标准库是C++编程的核心组成部分,它提供了大量预定义的函数和类,用于处理常见任务,如字符串操作、数据存储和算法。我们将分两次讲座来深入探讨标准库,今天主要介绍其概览和字符串库。
概述
上一节我们介绍了C++的基础语法和内存管理。本节中,我们来看看C++标准库是什么,以及它如何帮助我们更高效地编写程序。
标准库是C++语言规范的一部分。它定义了一系列类和函数的接口,所有符合标准的编译器都必须支持这些接口。标准库的实现本身通常是用C++编写的,但规范只定义了接口和行为,并未规定具体的实现方式。这意味着不同的编译器可以使用不同的实现,例如在Linux上常见的GCC的libstdc++和Clang的libc++。
标准库不仅定义了函数名和参数,还规定了某些操作的时间复杂度。例如,对于一个容器,访问其元素的时间复杂度应为常数时间 O(1)。
标准库包含多个子库,每个子库都有自己的头文件。许多头文件以字母c开头,例如 <cstring> 或 <cmath>。这是因为它们源自C语言的标准库。在C++中,通常建议使用C++特有的等效功能(如 std::string 而非C风格字符串),因为它们通常更安全、更易用,并且支持函数重载等特性。只有在进行底层操作或某些功能不存在C++版本时,才应考虑使用C库函数。
以下是标准库主要组成部分的概览:

- 工具库:包含内存管理(如
placement new、shared_ptr)、工具函数(如std::optional、std::variant)和时间测量等功能。 - 字符串库:提供
std::string类,用于处理文本。 - 容器库:提供诸如
std::vector、std::map等数据结构。 - 算法库:提供排序、查找、最大值/最小值等通用算法。
- 迭代器库:定义了遍历容器元素的标准方法。
- 数值库:包含数学函数(部分来自C的
<cmath>)、复数运算和随机数生成器。 - 输入/输出库:除了
std::cout,还提供文件流和字符串流。 - 多线程库:提供线程创建、同步原语(如互斥锁)等支持。


随着C++标准的更新,库的功能也在不断扩展,例如增加了正则表达式、文件系统操作等。未来版本还可能加入网络库。

字符串库详解
在之前的练习中,我们已经简单使用过字符串。本节中,我们将详细探讨std::string。

std::string 定义在 <string> 头文件中。它本质上是一个字符序列。虽然std::string本身是一个模板类,可以支持不同的字符类型(如wchar_t),但最常用的是基于char的类型。
std::string 的一个关键特性是它自动管理动态内存。当你创建一个字符串或修改其内容时,类内部会处理内存的分配和释放,这极大地简化了编程,避免了手动使用new和delete。
与C风格字符串(以空字符\0结尾的字符数组)不同,std::string 会存储自己的长度信息。这意味着:
std::string可以包含空字符(\0),而C风格字符串则不能。- 获取字符串长度是一个
O(1)的常数时间操作。
std::string 提供了丰富的成员函数,用于字符串操作,例如查找子串、比较、连接和修改内容。


有时,我们仍然需要与只接受C风格字符串的API(例如一些旧的C库函数)进行交互。为此,std::string 提供了 c_str() 成员函数,它可以返回一个指向内部字符数组的指针(以\0结尾)。调用此函数后,只要不修改原std::string对象,返回的指针就是有效的。
一般来说,在C++程序中应优先使用 std::string。只有在与需要C风格字符串的接口交互时,才使用 c_str() 进行转换。我们将在后续课程中介绍另一个有用的工具:std::string_view。


以下是 std::string 的一些常用成员函数示例:

#include <string>
#include <iostream>

int main() {
std::string str = "Hello, Systems Programming!";
// 获取长度
std::cout << "Length: " << str.length() << std::endl; // 或 str.size()
// 查找子串
std::size_t pos = str.find("Systems");
if (pos != std::string::npos) {
std::cout << "'Systems' found at position: " << pos << std::endl;
}
// 获取C风格字符串
const char* c_str = str.c_str();
// 连接字符串
str += " Welcome!";
std::cout << str << std::endl;
return 0;
}
总结

本节课中我们一起学习了C++标准库的基础知识。我们了解了标准库的定义、常见实现以及主要组成部分。我们重点深入探讨了字符串库,学习了std::string如何自动管理内存、存储长度信息,以及如何通过c_str()成员函数与C风格字符串进行互操作。记住,在C++中应优先使用std::string以获得更好的安全性和便利性。在下一讲中,我们将继续探索标准库的其他强大组件。
011:标准库 I 📚
在本节课中,我们将学习C++标准库的基础知识。标准库提供了大量预构建的数据结构和算法,能够帮助我们更高效、更安全地编写程序。我们将首先了解标准库的整体结构,然后深入探讨字符串库的用法。

概述
C++标准库是一个庞大且功能丰富的工具集合,它本身是C++标准的一部分。标准定义了库的接口、语义和实现必须满足的契约(例如某些数据结构的运行时复杂度),但具体实现由编译器供应商提供。例如,GCC使用libstdc++,而Clang使用libc++。
标准库中的所有功能都声明在 std 命名空间中,并分为多个子库,每个子库包含多个头文件。此外,为了保持向后兼容性,C++标准库也包含了C标准库的部分内容,但通常建议优先使用C++的等效功能。
以下是标准库包含的主要部分:
- 实用工具库:包含智能指针(如
std::unique_ptr)、错误处理、时间操作以及std::optional、std::variant、std::tuple等有用类型。 - 字符串库:包含
std::string类和轻量级的std::string_view。 - 容器库:包含
std::array、std::vector、std::list、std::map等数据结构。 - 算法库:提供排序、搜索等作用于容器的算法。
- 迭代器:作为连接容器和算法的通用抽象层。
- 数值库:包含数学函数、复数、随机数生成等。
- 输入/输出流库。
- 多线程库。
由于内容繁多,我们无法在两次课程中覆盖所有细节。本课程旨在提供一个概览和使用指南,具体细节请查阅官方参考文档。
字符串库详解
上一节我们介绍了标准库的整体架构,本节中我们来看看其中一个最常用的部分:字符串库。虽然大家已经在编程作业中接触过 std::string,但我们尚未深入探讨其工作原理。

什么是 std::string?
本质上,std::string 是一个专门用于存储字符的 std::vector。与 std::vector 一样,它管理自己的内存,并且大小可变。不同之处在于,std::string 提供了大量针对字符串操作的成员函数,使得字符串处理更加方便。
与C风格字符串(字符数组)相比,std::string 的一个显著优点是它知道自己的长度。我们无需像在C语言中那样手动添加空终止符(\0),也无需调用 strlen 这类函数来实时计算长度,因为 std::string 内部维护着一个记录字符数量的成员变量。这使其在某些场景下效率更高。
std::string 的内容在内存中仍然是连续存储的,并且内部以空字符终止。因此,我们可以通过 c_str() 成员函数获取一个指向内部字符数组的C风格字符串指针,并将其传递给期望 const char* 参数的C函数。

std::string 定义在 <string> 头文件中。它实际上是更通用的模板类 std::basic_string<char> 的类型别名。C++也支持更宽的字符类型(如 wchar_t),并有对应的别名(如 std::wstring)用于处理Unicode字符。
核心建议:在C++编程中,应优先使用 std::string 而非原生的字符指针(char*)。但这并不意味着在所有情况下都必须使用 std::string,我们稍后会看到其他选择。
std::string 的基本用法
以下是 std::string 的一些基本操作示例:
#include <iostream>
#include <string>
int main() {
// 1. 默认构造:创建一个空字符串
std::string empty_str;
// 2. 通过字符串字面量构造
std::string hello = "Hello, World!";
std::cout << hello << std::endl; // 输出: Hello, World!
// 3. 注意:如果字符串字面量包含空字节,构造时会在此处截断
std::string truncated = "Hello\0World";
// 使用 cout 打印只会输出 "Hello",因为流在遇到空字符时停止。
// 要验证长度,可以使用 .size() 成员函数。
std::cout << "Truncated string: " << truncated << std::endl;
std::cout << "Its size is: " << truncated.size() << std::endl; // 输出: 5
// 4. 获取C风格字符串指针
const char* c_style_ptr = hello.c_str();
// 现在 c_style_ptr 可以用于需要 const char* 的场合
return 0;
}
代码说明:
- 示例3展示了从包含空字节(
\0)的字面量构造std::string时的行为:构造函数会在第一个空字节处停止,因此生成的字符串不包含“World”部分。 c_str()方法返回一个指向以空字符结尾的字符数组的指针,该数组包含了与std::string相同的内容。

总结

本节课中我们一起学习了C++标准库的基础知识和 std::string 类。我们了解到标准库是一个由编译器实现的、功能强大的工具箱,涵盖了从数据结构、算法到系统功能的各个方面。对于字符串处理,std::string 相比C风格字符串更安全、更方便,因为它自动管理内存并记录长度。在接下来的课程中,我们将继续探索标准库中的其他容器和工具。
012:标准库 II - 函数对象与Lambda表达式

在本节课中,我们将继续探讨C++标准库,重点学习函数对象(Functors)和Lambda表达式。这些概念对于编写灵活、高效的代码至关重要。
上一节我们介绍了标准库的基础容器和算法,本节中我们来看看如何通过函数对象和Lambda表达式来定制算法的行为。
函数对象(Functors)
在C++中,普通函数本身不是对象。这带来了一些限制:例如,函数不能直接作为参数传递,也不能拥有自己的状态(除非是类的成员函数)。为了解决这个问题,C++定义了函数对象的概念。

一个类型T要成为函数对象,必须满足以下要求:
T必须是一个对象。- 类型
T必须重载了函数调用运算符operator()。
满足这些要求的对象通常被称为仿函数(Functors)。有了仿函数,我们就可以像操作普通对象一样操作它们,例如将其作为参数传递给其他函数。
C++中已经存在多种有效的函数对象,无需我们自行定义:
- 函数指针
- Lambda表达式
- 自定义的类(通过重载
operator()实现)

虽然普通函数本身不是对象,但当我们使用函数名时,它会隐式转换为指向该函数的指针,而函数指针是有效的函数对象。
函数指针
函数虽然不是对象,但构成函数的汇编指令在内存中占据一段空间,拥有一个起始地址。因此,我们可以声明指向函数的指针。
以下是声明一个指向自由函数(非成员函数)的指针的语法:
return_type (*pointer_name)(parameter_list);
return_type:函数返回类型。pointer_name:函数指针变量的名称。parameter_list:函数的参数列表(参数可以有名称,也可以没有)。


声明后,我们可以初始化这个指针,使其指向一个具有匹配签名的函数。这使得我们可以将函数作为参数传递给其他函数、构造函数或成员函数。
常见的应用场景包括:
- 向
std::sort算法传递自定义的比较函数。 - 向某个方法传递一个回调函数(callback),用于执行特定逻辑。
初始化后,可以通过解引用指针来调用函数。
以下是一个使用函数指针的示例:
// 声明一个函数,它接受一个函数指针和两个整数作为参数
int call_function(int (*func)(int, int), int a, int b) {
return (*func)(a, b); // 通过指针调用函数
// 也可以简写为:return func(a, b); // 编译器会自动解引用
}

// 定义两个匹配签名的函数
int add(int x, int y) { return x + y; }
int add_four(int x, int y) { return x + y + 4; }
int main() {
// 调用call_function,传递函数名(隐式转换为指针)
int result1 = call_function(add, 2, 3);
// 也可以显式取地址
int result2 = call_function(&add_four, 2, 3);
}
然而,函数指针的语法繁琐且可读性差。此外,它还有一些更微妙的缺点:
- 难以捕获环境:所有影响函数语义的变量都必须作为参数显式传递。
- 无法定义局部函数:例如,如果只想在某个高阶函数内部使用一个自定义比较函数,使用函数指针则必须将其定义为全局或命名空间作用域的函数,破坏了封装性。
Lambda表达式
为了解决函数指针的局限性,C++引入了Lambda表达式。从高层次看,Lambda表达式构造了一个闭包(closure),它将一个函数与其定义时所在的环境(即能影响其行为的变量)捆绑在一起。
Lambda表达式的核心特性是能够捕获(capture) 其定义作用域内的变量,并在函数体内引用这些变量。这极大地增强了代码的灵活性和表现力。
一个Lambda表达式的基本语法如下:
[capture_list] (parameter_list) -> return_type { function_body }
capture_list:捕获列表,指定哪些外部变量被捕获以及如何捕获(值捕获[=]、引用捕获[&]或指定变量[x, &y])。parameter_list:参数列表(可选)。return_type:返回类型(通常可省略,由编译器推导)。function_body:函数体。

Lambda表达式生成一个未命名的函数对象(闭包),可以将其赋值给auto变量或直接使用。
以下是使用Lambda表达式重写之前排序示例的简单示意(具体std::sort用法将在后续课程详述):
std::vector<int> vec = {5, 2, 8, 1};
int offset = 2;
// 使用Lambda表达式定义局部比较逻辑,并捕获外部变量offset
std::sort(vec.begin(), vec.end(),
[offset](int a, int b) { return (a + offset) < (b + offset); });
在这个例子中,Lambda表达式捕获了变量offset,使得比较逻辑可以动态地基于外部变量进行调整,而无需修改函数签名或定义全局函数。

本节课中我们一起学习了C++中函数对象的核心概念。我们了解到,虽然普通函数不是对象,但通过函数指针、自定义类(仿函数)以及更强大的Lambda表达式,我们可以创建可传递、可存储、并带有状态的可调用实体。特别是Lambda表达式,它通过捕获列表将代码与其上下文环境绑定,提供了比函数指针更简洁、更灵活的解决方案,是现代C++编程中不可或缺的工具。在接下来的课程中,我们将看到这些概念在标准库算法中的广泛应用。
013:现代硬件中的并发与并行编程
在本节课中,我们将要学习现代硬件中的并发概念以及并行编程的基础知识。在深入探讨C++语言和标准库提供的具体并发工具之前,理解底层的硬件原理和核心概念至关重要。本节课将主要使用伪代码和概念图进行讲解,为后续学习C++并发编程打下坚实基础。

什么是并发?
当我们谈论并发时,首先需要明确其定义。从概念上讲,并发非常简单。假设你有一种编程语言,可以定义函数 foo 和 bar。
void foo() { /* ... */ }
void bar() { /* ... */ }
如果该语言提供了启动线程(Thread)的功能,那么你就可以让多个函数并发执行。线程是操作系统中的一个概念,它允许程序同时运行多个任务。
// 伪代码示例
Thread t1 = start_thread(foo);
Thread t2 = start_thread(bar);
wait_for(t1);
wait_for(t2);
在这个例子中,我们启动了两个线程 T1 和 T2,每个线程执行一个函数。主函数等待这两个线程执行完毕。这里的“并发”意味着函数 foo 和 bar 在同一时间段内被执行,它们并非交替执行,而是真正地同时进行。
随之而来的问题是:CPU 如何实现这一点?尤其是当这些函数属于同一个程序时。更广泛的问题是:如何利用并发来加速程序,同时避免引入新的、难以捉摸的错误?

CPU如何实现并发?
要回答第一个问题,我们需要更深入地了解CPU的设计。现代CPU即使只有一个核心,也能进行乱序执行(Out-of-Order Execution),这本质上提供了指令级并行。但这并非我们本节课的重点。
我们关注的是CPU如何同时执行多个不同的指令流。指令流可以理解为函数的执行过程,因为不同的函数包含不同的指令。即使从不同线程调用同一个函数,它们在某一时刻也可能执行不同的指令,因为执行速度可能略有差异。
我们主要关注CPU能够同时执行多个指令流的两种情况:
- 同时多线程:单个CPU核心能够执行多个线程。这个概念你可能更熟悉英特尔公司为其命名的“超线程”技术,但通用概念称为同时多线程。
- 多核心:拥有多个物理上独立的CPU核心,每个核心可以独立运行线程。
例如,一个四核CPU,如果每个核心支持两个超线程,那么理论上它可以同时执行八个线程。
我们之所以需要并发,是因为如果能让程序在多个线程和核心上同时运行,通常能获得更快的执行速度。C++作为一种相对底层的语言,在系统编程中,我们需要了解CPU实现并发的细节,这样才能理解潜在的隐患和错误,并学会如何正确实现以避免这些问题。
本节课内容主要参考CPU厂商的开发手册,例如《Intel架构软件开发人员手册》和ARM的相关文档。这些是理解底层硬件行为的重要资料。

深入同时多线程
首先,我们来探讨同时多线程在单个CPU核心内部是如何工作的。
一个不支持多线程的CPU核心,已经通过乱序执行支持了指令级并行。这意味着它可以并行执行单个指令流中的多条指令,前提是这些指令相互独立。例如,一条指令向一个内存地址写入数据,另一条指令向另一个地址写入数据,它们互不影响,CPU就可以同时执行它们。
而同时多线程在此基础上,增加了线程级并行。这意味着在单个CPU核心内,可以同时执行多个线程(现代CPU通常是两个)。
同时多线程的特殊之处在于,不同的线程共享许多硬件组件。例如:
- 算术逻辑单元:负责整数运算。
- SIMD单元:执行单指令多数据操作,能同时处理多个浮点数或整数,拥有更大的寄存器(如x86架构上的512位寄存器)。
当然,也有一些组件是每个线程独占的,无法共享。主要包括:
- 控制单元:负责获取指令、决定下一条指令的位置(如处理分支指令)。
- 寄存器文件:每个线程需要自己独立的寄存器组来保存当前执行状态。
让我们看一个指令流的例子。假设有两个线程在执行:
- 线程1 执行指令:
store(存储到内存)、add(加法)、sub(减法)。 - 线程2 执行指令:
padd(SIMD打包加法)、pmul(SIMD打包乘法)。
在支持同时多线程的CPU核心中,调度器会尝试将这些来自不同线程的指令,填充到共享的ALU、SIMD单元等执行部件中,只要资源可用且指令之间没有依赖关系,它们就可以真正地同时被执行。这提高了硬件资源的利用率。
总结

本节课我们一起学习了并发编程的基础硬件知识。我们明确了并发的概念是指多个任务在同一时间段内同时执行。为了实现这一点,现代CPU主要通过同时多线程和多核心两种技术来支持线程级并行。同时多线程允许单个物理核心同时执行多个线程,它们共享部分硬件资源(如ALU、SIMD单元),但也拥有各自独立的资源(如控制单元、寄存器文件)。理解这些底层机制,对于后续在C++中正确、高效地使用并发编程,并规避潜在的数据竞争和同步问题至关重要。下一节课,我们将开始学习C++标准库中具体的并发编程工具。
014:C++中的多线程
在本节课中,我们将学习如何在C++中使用多线程。我们将从如何创建和启动线程开始,然后探讨多线程编程中至关重要的同步问题,以确保程序行为正确且高效。
上一节我们介绍了多线程的基本概念和潜在问题,本节中我们来看看如何在C++中实际创建和使用线程。
线程的创建与使用
C++标准库在 <thread> 头文件中提供了 std::thread 类,这是我们创建和管理线程的主要工具。这个类是平台无关的,其行为由C++标准定义,底层实现会适配不同的操作系统。
为了启用线程功能,编译时可能需要特定的标志。例如,在Linux上使用GCC或Clang时,通常需要 -pthread 标志。为了保持项目的可移植性,建议使用CMake来管理这些依赖。
以下是如何在CMake项目中启用线程支持的代码片段:
find_package(Threads REQUIRED)
target_link_libraries(your_target_name PRIVATE Threads::Threads)
现在,让我们看看如何使用 std::thread 类。
启动线程
std::thread 的构造函数用于启动一个新线程。其语法是传递一个可调用对象(如函数或lambda表达式)以及该对象所需的参数。
构造函数的基本形式如下:
std::thread thread_object(callable_function, arg1, arg2, ...);
传递给构造函数的参数会被转发给可调用对象。一旦函数执行完毕并返回,该线程就会终止。

也可以使用默认构造函数创建一个空的、未关联任何执行线程的 std::thread 对象。
等待线程结束
线程启动后,会在后台执行。在主程序结束前,必须对每个已启动的线程对象调用 join() 成员函数。join() 会阻塞当前线程(通常是主线程),直到被调用的线程执行完毕。即使你知道线程已经结束,也必须调用 join()。
每个通过 std::thread 构造函数启动的线程,其对应的 std::thread 对象在销毁前必须被 join() 恰好一次。如果在对象析构时还未调用 join(),程序将被终止。
代码示例
以下是创建和运行线程的一个简单示例:
#include <thread>

void foo(int a, int b) {
// 执行一些工作
}
int main() {
// 方式一:传递函数和参数
std::thread t1(foo, 1, 2);
// 方式二:传递lambda表达式
std::thread t2([]() {
foo(3, 4); // 在lambda内部调用函数
});
// 主线程也可以执行工作
foo(5, 6);
// 等待所有线程完成
t1.join();
t2.join();
return 0;
}
在这个例子中,我们创建了两个线程 t1 和 t2 来并发执行 foo 函数,同时主线程也执行了一次 foo。最后,通过调用 join() 确保所有线程在程序退出前完成。
线程同步与数据竞争
上一节我们学习了如何创建线程,本节中我们来看看多线程编程中最核心的挑战:同步与数据竞争。
当多个线程共享同一内存空间时,如果它们同时访问(读取或写入)同一内存位置,就可能引发问题。在C++中,这被称为 冲突 或 数据竞争。
具体来说,以下情况构成冲突:
- 一个或多个线程读取,同时另一个线程写入。
- 一个线程写入,同时另一个线程读取。
- 多个线程同时写入。

任何存在冲突的未同步操作,在C++中都属于未定义行为。 这意味着程序可能崩溃、产生错误结果或出现其他不可预测的行为。
唯一安全的情况是:所有线程都只从同一内存位置读取数据。
为了使存在冲突的操作具有明确定义的行为,线程之间必须进行 同步。主要的同步机制有两种:
- 互斥:确保一次只有一个线程能访问共享资源。
- 原子操作:保证对单个内存位置的读写操作是不可分割的。
在后续的课程中,我们将详细探讨如何在C++中实现这些同步机制。

本节课中我们一起学习了C++多线程编程的基础。我们了解了如何使用 std::thread 创建和管理线程,并认识了多线程环境中至关重要的数据竞争与同步问题。记住,任何非纯读取的共享内存访问都必须进行同步,否则会导致未定义行为。在接下来的课程中,我们将深入探讨互斥锁、原子操作等具体的同步工具。
015:组织大型项目 🏗️

在本节课中,我们将要学习如何管理和组织大型C++项目,以及可以使用的工具和设施。我们将探讨项目布局、第三方库集成以及高级调试技巧。
项目布局与结构 📁
上一节我们介绍了组织大型项目的动机,本节中我们来看看如何具体地构建项目结构。
项目在文件系统上的结构虽然听起来简单,但实际上对整个项目的生命周期有深远影响。项目布局会影响C++代码的多个方面,例如源文件树结构、命名空间结构、头文件包含关系,以及库和可执行文件的组织方式。一个糟糕的项目结构会阻碍项目进展,增加引入错误的可能性,并使维护和扩展变得困难。

因此,遵循一些最佳实践来组织项目至关重要。以下是一些关于目录结构的高级建议:
- 分离库和可执行文件:属于不同库或可执行文件的文件应放在不同的目录中,不应将它们混合在同一个目录树下。
- 分离组件:即使在同一库或可执行文件中,属于不同组件或模块的文件也应放在不同的目录中。这通常对应着系统中的不同模块,并且这些模块应属于不同的顶级命名空间。
- 分离测试代码:测试代码应放在与实际实现代码分离的目录树中。这样可以方便地在发布或构建时剥离测试代码。
- 优先使用外部构建:始终推荐使用外部构建。在CMake中,这意味着创建一个单独的
build目录来存放所有生成的文件,从而避免源文件目录被生成的文件污染,便于查找和修改实际源代码。
项目规模考量 ⚖️

项目布局的具体规则会根据项目规模而变化。对于小型项目,一些不完美的解决方案或许可以接受,但在大型项目中会成为主要问题。反之,一些仅适用于大型项目的实践在小型项目中可能显得过度设计。
关键在于,如果你预见到项目未来会增长,提前规划是值得的。可以引入一些在项目初期并非必需,但在项目发展到一定规模后绝对必要的实践。项目规模是主观的,也取决于参与项目的人数。
无论项目大小,我们都建议遵循一些通用准则:始终以清晰的模块化为目标来组织文件、目录和命名空间。这包括实现高内聚、低耦合的代码。一个清晰的目录结构可以反映项目中的不同模块,从而使这一点更容易实现。

大多数情况下,除非你明确知道要启动一个非常庞大的新项目,否则我们建议从一个单一的结构开始(例如一个库或一个可执行文件)。然后随着项目的发展,逐步转向更独立和模块化的内部结构。
总结


本节课中我们一起学习了组织大型C++项目的核心原则。我们了解了项目结构的重要性,探讨了关于目录布局、组件分离和测试代码管理的最佳实践。我们还讨论了如何根据项目规模调整组织策略,并强调了为未来增长进行规划的价值。记住,一个清晰、模块化的项目结构是提高代码可维护性、可扩展性和团队协作效率的基础。
016:Linux上的C++系统编程 🐧

在本节课中,我们将学习如何在Linux操作系统上进行C++系统编程。我们将探讨如何超越标准C++库,直接与操作系统交互,以实现更高效、更底层的控制。

概述
到目前为止,我们主要讨论了C++语言本身,这是进行C++系统编程的重要基础。今天,我们将重点介绍如何实际与操作系统通信,并以Linux为例进行讲解。我们已经在所有练习中使用了Linux,因此今天将主要聚焦于Linux平台。
标准C++与系统交互
我们之前讨论的是标准C++,即C++标准。它确实包含了许多可以与系统交互的函数,例如写入文件、读取文件以及启动线程等。然而,标准库的设计目标是跨架构和跨操作系统兼容性。例如,流(streams)通常速度较慢,并且你无法精确控制标准库将使用的具体功能或系统调用。
对于系统编程,我们通常需要直接访问我们正在使用的操作系统。系统编程的示例包括处理文件和目录。虽然标准库中已有一些相关功能,但操作系统通常提供了更丰富的功能,我们也希望能够使用它们。此外,高效地读写文件也很重要,因为正如之前所说,流操作很慢。不同的文件读写场景需要不同的方法,我们今天将看到一些示例。为了实现这些,你需要直接使用系统调用或系统提供的函数。
系统编程的核心领域
以下是系统编程的几个核心领域:
- 手动内存管理:到目前为止,我们使用的是C++的标准分配器,它们主要在后台使用
malloc。但问题是,如果你认为可以实现更高效的malloc,该如何实现呢?当然,你需要能够与操作系统通信,以便在你的进程中实际分配新内存。 - 网络编程:我们今天不会讨论网络编程。主要原因是信息学专业有一门完整的课程专门讲解这个主题,其中重点介绍了Linux上的C网络API。因此,我们今天不涵盖这部分内容,但它无疑是系统编程中非常重要的一部分。
- 进程和线程管理:你可以使用C++的
std::thread类,但你可能还想执行诸如启动新程序、自定义新线程或新进程等操作。
这些都是系统编程的典型示例。
Linux API与POSIX标准
在Linux中,所有这些功能都通过用户空间C API提供。用户空间意味着许多功能尽可能在用户空间(而非内核空间)实现。当然,创建新进程甚至打开文件最终都需要与内核通信。但Linux的标准库(或者说,其实现)试图在用户空间实现尽可能多的功能。
这个API以C API的形式实现,这意味着有为C编写的头文件。这些头文件声明了函数,然后你通常只需要链接系统上的 libc 库即可使用这些函数。C++的优势在于它能轻松与C互操作。因此,在C++程序中,你通常可以直接包含这些C头文件,它们会正常工作。这使得用C++进行系统编程变得非常容易。

POSIX标准
Linux API 很大程度上遵循一个名为 POSIX 的标准。POSIX本质上是一组函数和行为的集合,定义了操作系统应如何表现以及应提供哪些类型的函数。Linux提供的大多数函数实际上都在POSIX标准中有所规定。这意味着它们不仅特定于Linux,也可以在所有其他符合POSIX标准的系统中使用。例如,大多数类Unix系统(不仅是Linux,还有macOS以及各种BSD Unix)通常都支持POSIX API。因此,如果你只使用Linux实现的纯POSIX函数,那么你的程序在很大程度上也可以移植到其他使用POSIX API的操作系统上。
正如所说,Linux API主要由C头文件(C API)组成。我们今天将讨论的函数涉及的最重要的头文件包括 unistd.h、fcntl.h 以及 sys/ 目录下的几个头文件。当然,在介绍具体函数时,我也会简要说明需要使用哪些头文件。
Linux特有功能
当然,由于POSIX API试图兼容,或者说它试图成为一个可以在多个操作系统中实现的标准,这通常意味着每个操作系统都会有POSIX未涵盖的额外功能。Linux也不例外。Linux是一个庞大的软件项目,拥有许多非常特定且仅在Linux上工作的功能,它们无法在macOS或任何BSD系统上运行。
这些Linux特有的功能也以C函数的形式定义。但使用较新的功能也意味着,当你执行程序时,需要足够新的Linux内核来实现这些功能。
文档查询
对于POSIX API和Linux API,你都可以在手册页(man pages)中找到所有文档。通常,纯POSIX文档位于第3p节(或3 pos节)。如果你只是输入 man 后跟函数名,系统通常会自动找到正确的文档。对于Linux特有的函数也是如此,你也可以输入 man 后跟函数名,Linux会提供非常详细的函数文档。
以上是对Linux支持的API类型以及我们今天将要讨论的API的概述。
文件描述符:核心概念

POSIX以及Linux中一个非常核心的概念是所谓的 文件描述符。它们通常缩写为FD(单数)或FDs(复数)。
文件描述符这个名字暗示它当然用于处理文件,但这并非文件描述符的唯一用途。它们通常用作处理许多不同类型资源的句柄。文件是一种资源,为其分配文件描述符是合理的,这也是名称的由来。但你也可以为目录分配文件描述符(“文件”描述符这个说法可能仍然可以接受)。此外,你还可以为网络套接字分配文件描述符(根据Unix哲学“一切皆文件”,或许网络套接字也可以被视为文件)。特别是在Linux上,你还有许多行为完全不像文件的对象,它们仍然由文件描述符处理。
本质上,你可以将文件描述符视为一个任意的整数(int 类型)。它只是你当前正在处理的任何对象或资源的某种句柄。
因为它们处理的是资源,所以这个资源最终必须被释放。例如,如果你打开了一个文件...
总结

本节课中,我们一起学习了Linux上C++系统编程的基础。我们了解了标准C++库在系统交互方面的局限性,并引出了直接使用操作系统API的必要性。我们介绍了系统编程的几个关键领域,如内存管理、进程线程控制等。重点讲解了Linux提供的用户空间C API及其与POSIX标准的关系,包括可移植的POSIX函数和Linux特有的扩展功能。最后,我们引入了文件描述符这一核心概念,它是Linux/POSIX系统中处理各种I/O资源的统一抽象。在接下来的课程中,我们将基于这些概念,深入探讨具体的系统调用和编程实践。
017:杂项主题 🧩
在本节课中,我们将探讨一些在前面的课程中没有机会深入讨论,但依然足够有趣的主题。请注意,今天涉及的大部分内容都远比本讲座所展示的要庞大,我们只是浅尝辄止,目的是让大家知道这些概念的存在。如果你对某个主题感兴趣,我们鼓励你自行深入研究。有了这个概览,当你遇到可能用到这些知识的问题时,或许能想起来:“哦,之前有提到过这个主题。”

带着这个目标,让我们开始了解一些在X86架构上可以做的有趣事情。
指针标记(Pointer Tagging) 🏷️
首先,我们要简要讨论一个名为“指针标记”的主题。它利用了我们在动态内存管理课程中学到的知识:程序使用的是虚拟地址空间,这些虚拟地址由内存管理单元(MMU)转换为主存中的实际物理地址。
在64位系统(如今已是事实标准)上,虚拟地址是64位整数。但在X86-64架构上,内存管理单元实际上只使用指针的低48位来进行物理地址转换。这意味着我们的指针实际上只包含48位信息,高16位未被系统使用。于是,我们自然想到可以尝试在这高16位中存储一些有用的信息,这就被称为“指针标记”——我们给指针添加了“标记”。
当然,这样做必须非常小心。如果我们意外地设置了高16位中的某一位,然后在将值用作指针之前忘记清除它,就会引发各种难以调试的内存错误,导致程序崩溃。此外,如果我们希望程序具有真正的可移植性,还必须提供一个不使用指针标记的实现,因为这是X86架构特有的,在其他可能使用指针高位的架构上可能无效。
指针标记在特定情况下极其有用的一点是:你可以通过一条原子指令同时修改两个值——即指针本身和你想存储在那16位中的任何信息。根据你构建的数据结构类型,利用这一特性可以极大地提高数据结构的同步效率。

有了这个能力,我们基本上可以在这16位里做任何想做的事情。例如,我们可以存储最多16个二进制标志位来告诉我们关于指针目标的某些信息,或者存储一个16位整数,等等。
为了给你一些启发,我可以分享一些实际应用场景。例如,我曾经需要实现一个本质上类似树的数据结构。节点之间存在关系,但节点本身只是原始内存块,你无法预先知道这些内存是如何组织的。这意味着可能存在不同类型的节点,它们都只是原始内存块。因此,我必须在指向这些原始内存块的指针中,编码该内存块的实际类型。在这种情况下,指针标记就非常有用,因为我可以将指针和目标信息一起存储在一个64位整数中,并且能够通过一条原子操作来修改这个64位整数。
另一个例子来自我们的数据库项目。你可以在指针的高16位中存储一个小的布隆过滤器,用于哈希表查找。考虑链式哈希表桶中存储的指针,你只需要使用48位作为指针,剩下的16位可以用来存放一个小型布隆过滤器。在某些情况下,你可以根据这个布隆过滤器推断出某个值肯定不在这个桶中,从而跳过相对昂贵的链式查找。当然,这只是众多例子中的两个,指针标记在很多场景下都相当有用。
但请注意,大多数情况下,指针标记是一种优化手段。此外,由于它很容易引入内存错误,建议始终将带标记的指针包装在一个合适的数据结构中。这个数据结构应提供一个接口,让你能以比直接操作原始64位整数更类型安全的方式提取实际指针和设置标记。不要以原始形式暴露这些带标记的指针,只通过包装数据结构来访问底层数据。
为了实际进行这些花哨的修改,我们首先需要将指针转换为整数类型。合适的类型是 uintptr_t。然后,我们可以使用常规的位操作来访问这个64位值的标记部分和指针部分。
以下是一个代码示例,展示了如何使用指针的高16位来存储信息:
// 辅助常量
constexpr int kPointerBits = 48;
constexpr uintptr_t kPointerMask = (static_cast<uintptr_t>(1) << kPointerBits) - 1;
// 将指针与标记组合
uintptr_t AddTag(void* ptr, uint16_t tag) {
uintptr_t int_ptr = reinterpret_cast<uintptr_t>(ptr);
// 清除指针的高16位,然后将标记左移到高16位,最后组合
return (int_ptr & kPointerMask) | (static_cast<uintptr_t>(tag) << kPointerBits);
}

// 从带标记的指针中提取原始指针
void* GetPointer(uintptr_t tagged_ptr) {
return reinterpret_cast<void*>(tagged_ptr & kPointerMask);
}
// 从带标记的指针中提取标记
uint16_t GetTag(uintptr_t tagged_ptr) {
return static_cast<uint16_t>(tagged_ptr >> kPointerBits);
}
当然,你也可以对低16位进行类似操作,只是移位和掩码操作的方向相反,但原理完全相同。
关于严格别名规则的说明:这里并不违反严格别名规则,因为我们没有使用不同类型的指针来引用不同类型的数据结构。这里没有别名问题。我们只是将指针的值复制到一个新的 uintptr_t 类型变量中,这总是合法的。然后,当我们提取时,将64位整数的字节复制到一个新的指针类型值中,同样没有任何问题。
关于 reinterpret_cast 的说明:此处的 reinterpret_cast 之所以有效,是因为C++标准特别允许将指针类型重新解释为 uintptr_t。这是“不能随意重新解释类型”规则的一个特例,因为有时将指针视为整数值是有用的。
关于实用性的问题:这听起来可能有点小题大做,确实比较复杂。保持“我可能不需要这个”的心态是件好事。但根据经验,特别是在我们教研室开发的数据库中,我们确实使用了这项技术。

本节课中,我们一起学习了指针标记这一高级主题。我们了解到,在X86-64架构上,可以利用指针中未使用的高位来存储额外信息,从而将数据和元数据紧凑地结合在一起,并通过原子操作进行修改。虽然这是一个强大的优化技巧,但我们也强调了其平台依赖性和潜在的风险,建议通过包装数据结构来安全地使用它。希望这个概览能为你未来的编程工作提供一个新的思路。

浙公网安备 33010602011771号