慕尼黑工业大学-IN2377-C---编程笔记-全-
慕尼黑工业大学 IN2377 C++ 编程笔记(全)
001:基本类型 🧱
概述
在本节课中,我们将要学习C++语言的基础——基本类型。我们将了解C++中可用的各种内置数据类型,如何声明和初始化变量,以及作用域和命名空间等核心概念。理解这些基础知识是编写正确、高效C++程序的第一步。
课程内容
欢迎与课程介绍
欢迎大家来到C++课程。我是D.B.S. Laa,我将为大家讲授这门关于现代C++编程的课程。
课程使用多种交流渠道。除了课堂提问,大家可以通过Zulip平台(zulip.it.tum.de)在课程CPP和作业CPP Homes流中提问或讨论。作业相关的GitLab仓库会通过Gila自动通知更新或修复。
上节课我们介绍了如何编译一个简单的C++程序,创建自己的共享库以及运行单元测试。大家可能已经注意到,C++有不同的标准版本,并且不同编译器对标准的支持程度不同,这是C++编程的常态。
本节课我们将深入语言本身,探讨其基础特性。虽然很多概念与其他语言类似,但其中不乏有趣和特殊的细节。
基本程序结构与控制流
最小的C++程序是main函数:
int main() {}
main函数是程序的入口点。当main函数执行完毕,程序就结束了。main函数特殊之处在于,即使声明返回int,也可以不写return语句,编译器会隐式地添加return 0;。如果声明为void main(),操作系统仍期望一个返回码,因此也会有隐式返回。
可计算算法至少需要四种基本构建块,C++都支持:
- 基本处理步骤:例如声明并初始化一个变量
int x = 0;。 - 步骤序列:用分号分隔的语句,按顺序执行。
- 条件处理:使用
if语句,根据布尔表达式决定执行路径。 - 循环:使用
while、do-while或for循环重复执行代码块。
以下是这些结构的示例:
- 条件语句:
if (x == 0) { /* 代码块 */ } else { /* 代码块 */ } - 循环:
注意,while (condition) { /* 代码 */ } do { /* 代码 */ } while (condition); for (int i = 0; i < N; ++i) { /* 代码 */ }for循环中声明的变量(如i)作用域仅限于该循环。
在C++中,代码块和作用域使用花括号 {} 来定义。与Java不同,如果if或循环体内只有一条语句,花括号可以省略,但这可能降低代码一致性。

重要提示:在C++中,如果你声明一个变量但没有初始化它(例如 int x;),它的值是未定义的。这可能导致程序行为不可预测和安全漏洞。因此,养成初始化变量的习惯至关重要。


类型安全
C++是一种强类型语言,每个对象都必须有类型。类型定义了对象能执行的操作(例如,整数可以加减乘除)。
类型安全分为两种:
- 静态类型安全:在编译时检查。编译器能发现诸如“对整数调用绘图方法”这类错误。
- 动态类型安全:在运行时检查。例如,两个整数相乘的结果超出了该整数类型能表示的范围。
然而,C++为了性能和底层编程的灵活性,并未完全强制执行这两种类型安全。未定义行为、程序崩溃或安全漏洞都可能源于类型安全违规。
因此,程序员必须主动确保类型安全:
- 使用编译器警告:编译时使用
-Wall -Werror等标志,将所有警告视为错误并修复它们。 - 使用静态分析工具:如
clang-tidy,许多现代IDE已集成此类工具。 - 使用动态分析工具:如 AddressSanitizer、UndefinedBehaviorSanitizer 等,在运行时检测问题。
基本数据类型
C++提供了多种内置数据类型。
整数类型
计算机处理数字。C++有表示自然数的无符号整数和表示正负数的有符号整数。现代计算机普遍使用二进制补码表示有符号数(C++20标准正式明确)。
整数类型有多种,但大小(位数)仅保证最小值:
short/short int:至少16位。int:至少16位。long/long int:至少32位。long long/long long int:至少64位。
实际大小取决于系统和编译器(例如,64位Windows上long是32位,而Linux上通常是64位)。
为了保证精确的位数,C++11引入了固定宽度整数类型(在头文件 <cstdint> 中):
#include <cstdint>
int32_t a; // 精确32位有符号整数
uint64_t b; // 精确64位无符号整数
这些类型位于 std 命名空间中。编译器不一定支持所有类型(例如,32位系统可能没有int64_t)。
整数字面量
- 十进制:
42 - 八进制(前缀
0):052 - 十六进制(前缀
0x或0X):0x2a - 二进制(C++14起,前缀
0b或0B):0b101010
可以使用后缀指定类型,如42u(无符号)、42l(long)、42ull(unsigned long long)。对于大数字,可以使用单引号'作为分隔符提高可读性,如1'000'000。
size_t 和 ptrdiff_t
size_t 是无符号整数类型,用于表示对象大小或数组索引,足以容纳任何类型的大小。ptrdiff_t 是有符号版本,用于表示指针差。它们的大小是实现定义的。C++23为它们引入了后缀:uz 对应 size_t,z 对应 ptrdiff_t。
布尔类型
bool 类型,取值为 true 或 false。用于条件语句和循环。其大小(占多少字节)是实现定义的。
浮点类型
float:通常为32位IEEE 754单精度浮点数(约7位十进制精度)。double:通常为64位双精度浮点数(约15位十进制精度)。long double:精度和大小取决于实现(在x86系统上常为80位)。
浮点数有特殊值:正负无穷大、负零、非数字(NaN)。
浮点数字面量
- 带小数点或指数:
3.1415,0.5,6.02e23 - 默认类型是
double。后缀f或F表示float,l或L表示long double。
C++23引入了固定宽度浮点类型(同样在 <stdfloat> 中):
std::float16_t,std::float32_t,std::float64_t,std::float128_tstd::bfloat16_t(“脑浮点”,用于机器学习)
这些是独立类型(而非别名),但目前编译器支持有限。
字符类型
char:至少8位,可能是有符号或无符号的,通常用于存储基本ASCII字符。char16_t:用于UTF-16编码(C++11引入)。char32_t:用于UTF-32编码(C++11引入)。char8_t:用于UTF-8编码(C++20引入)。wchar_t:实现定义,通常不推荐使用。
字符字面量
用单引号表示:'A'。特殊字符需要转义:'\''(单引号), '\\'(反斜杠), '\n'(换行)。指定编码前缀:u8'a' (UTF-8), u'a' (UTF-16), U'a' (UTF-32)。
void 类型
void 表示“无类型”。不能创建 void 类型的对象。主要用于指示函数没有返回值,例如 void function() {}。
变量声明与初始化
上一节我们介绍了各种基本类型,本节中我们来看看如何创建和使用这些类型的变量。
声明变量的基本形式是:类型 变量名;。在C++中,初始化是可选的,但强烈建议始终初始化变量,因为未初始化的变量包含未定义的值,是常见错误来源。
现代C++推荐使用花括号初始化(也称为列表初始化):
int x{42}; // 正确:直接初始化
int y = {42}; // 正确:拷贝列表初始化(不常用)
这种方式更安全,因为它禁止可能导致数据丢失的隐式窄化转换。例如:
double pi = 3.14159;
unsigned int a = pi; // 可能编译通过,但隐式截断为3,可能非预期
unsigned int b{pi}; // 错误或警告:禁止从double到unsigned int的窄化转换
如果确实需要转换,应使用显式类型转换。
旧的赋值式初始化 (int a = 5;) 或圆括号初始化 (int a(5);) 可能允许不安全的隐式转换,因此应优先使用花括号初始化。
auto 关键字
auto 让编译器根据初始化表达式自动推导变量类型:
auto count = 42; // count 被推导为 int
const auto pi = 3.14159f; // pi 被推导为 const float
在简单情况下优势不明显,但在处理复杂类型(如模板元编程)时非常有用。需注意,在某些特定库(如Eigen)中,auto 可能导致非预期的惰性求值。
常量
使用 const 关键字声明常量,初始化后其值不可修改:
const int max_size = 100;
类型别名
使用 using 可以为类型创建别名,提高代码可读性或便于类型替换:
using MyInt = int; // C++11 风格
typedef int MyInt; // C风格,等价但可读性稍差
作用域与存储期
变量不仅有其类型和值,还有作用域和存储期,这决定了它在何处可见以及存活多久。
作用域
作用域是程序中变量可见的区域,通常由花括号 {} 界定。
- 局部作用域:在函数或代码块内声明。
- 类作用域:在类定义内声明(后续课程介绍)。
- 命名空间作用域:在命名空间内声明。
- 语句作用域:例如在
for循环初始化语句中声明的变量。 - 全局作用域:在所有函数和类之外声明。
内部作用域的变量会隐藏外部作用域的同名变量。可以使用作用域解析运算符 :: 访问全局变量。
int x = 0; // 全局x
void func() {
int x = 1; // 局部x,隐藏全局x
{
int x = 2; // 内层块局部x,隐藏外层局部x
}
x = 3; // 修改的是局部x (值为1的)
::x = 4; // 使用::访问并修改全局x
}
存储期
存储期指对象所占用的内存何时分配、何时释放。
- 自动存储期:最常见的类型。在进入其作用域时分配,离开时自动释放。通常位于栈上。
- 静态存储期:在程序开始时分配,程序结束时释放。包括全局变量和使用
static关键字声明的局部变量。 - 动态存储期:由程序员通过
new和delete手动管理内存分配和释放。容易出错,后续课程将介绍更安全的管理方式。 - 线程局部存储期:使用
thread_local关键字,变量在每个线程中有独立的实例。
void func() {
int auto_var = 0; // 自动存储期
static int static_var = 0; // 静态存储期,只初始化一次,值在调用间保持
}
命名空间
命名空间用于将标识符(变量、函数、类等)组织到逻辑组中,防止不同库之间的名称冲突。
定义命名空间:
namespace MyLib {
int value;
void foo() { /* ... */ }
}
使用作用域解析运算符 :: 访问命名空间成员:
MyLib::value = 42;
MyLib::foo();
using 声明
可以引入特定名称或整个命名空间到当前作用域,以简化代码:
using std::cout; // 引入cout
using namespace std; // 引入整个std命名空间(谨慎使用,可能引起名称冲突)
通常不建议在全局作用域使用 using namespace std;,以免造成污染和意外冲突。最好在局部作用域(如函数内)或显式地使用 using 引入个别名称。
命名空间可以嵌套,并且是开放的,可以多次向同一个命名空间添加内容。

总结
本节课我们一起学习了C++的基本构建块。我们了解了各种内置数据类型(整数、浮点数、布尔值、字符),学习了如何安全地声明和初始化变量,并探讨了作用域、存储期和命名空间等关键概念。记住,始终初始化变量,优先使用花括号初始化,并善用编译器警告和静态分析工具来编写更安全的代码。这些基础知识是后续学习更高级主题(如函数、类、模板)的基石。
002:函数与内存基础 🧠
在本节课中,我们将要学习C++编程中的一些核心基础概念,包括变量的初始化、数组与向量的使用、表达式与运算符、控制流语句、函数定义与调用,以及引用和类型转换。这些内容是构建更复杂C++程序的基础。
上一节我们介绍了C++的基本类型和语言特性,本节中我们来看看如何更安全地初始化变量以及使用现代C++的容器。
初始化:安全与不安全的方式
在C++中,初始化变量有两种主要方式:使用花括号 {} 的安全初始化(称为统一初始化)和使用圆括号 () 的传统初始化。推荐使用统一初始化,因为它能防止意外的窄化转换。
窄化转换是指将一种类型的值转换为另一种类型时,可能丢失信息的转换。例如,将浮点数转换为整数会丢失小数部分。
int a = 7.5; // 不安全:发生窄化转换,a 的值是 7
int b {7.5}; // 安全:编译器会报错,阻止窄化转换
int c (7.5); // 不安全:与传统赋值类似,c 的值是 7
如果你确实需要进行可能丢失信息的转换,应该使用显式的类型转换,这会让你的意图更清晰。
使用统一初始化还有一个好处:它可以用于初始化标准库容器和用户自定义类型(如 struct),功能更统一。
std::vector<int> v1 {99}; // 创建一个包含一个元素 99 的向量
std::vector<int> v2 (99); // 创建一个包含 99 个元素(值均为0)的向量
对于未显式初始化的变量,其行为取决于其存储期。具有静态存储期的变量(如全局变量)会被默认初始化为零值;而局部变量则不会被初始化,直接使用其值是未定义行为。
auto 关键字:类型推导
auto 关键字让编译器根据初始化表达式自动推导变量的类型。对于简单类型,显式写出类型可能更清晰。但在处理复杂类型(如迭代器、智能指针)时,auto 能极大简化代码并提高可读性。
auto i = 42; // i 被推导为 int
auto c = 'a'; // c 被推导为 char
// 使用 auto 简化智能指针声明
auto uptr = std::make_unique<int>(); // 无需写出 std::unique_ptr<int>
如果你不确定 auto 推导出的类型,可以使用某些IDE的功能或库(如Boost.TypeIndex)来查看。
数组与向量:固定大小与动态大小
C++提供了两种主要的序列容器:固定大小的 std::array 和动态大小的 std::vector。永远不要使用C风格数组,因为它们不安全且功能有限。
std::array 的大小在编译时必须已知,并保证内存连续。它提供了安全的元素访问方法 .at() 和不进行边界检查但更快的 [] 运算符。
#include <array>
std::array<int, 10> arr {}; // 包含10个默认初始化为0的int
arr.at(0) = 5; // 安全访问,会进行边界检查
arr[1] = 10; // 快速访问,无边界检查
std::vector 的大小可以在运行时改变。它同样保证内存连续,但在扩容时可能需要重新分配内存和拷贝元素。如果事先知道大致大小,可以使用 .reserve() 来预分配空间以提高性能。
#include <vector>
std::vector<int> vec; // 初始为空
vec.push_back(1); // 添加元素
vec.resize(5); // 调整大小为5
遍历容器时,推荐使用基于范围的for循环,它更简洁、更安全。
for (const auto& element : vec) {
std::cout << element << '\n';
}
表达式、运算符与控制流
表达式由运算符和操作数组成,求值后会产生一个结果(可能带有副作用)。每个表达式都有类型和值类别。值类别主要分为左值(有标识符的对象,可出现在赋值左侧)和右值(临时值,通常出现在赋值右侧)。
C++提供了丰富的运算符,包括算术、逻辑、比较、赋值、递增/递减和三目运算符等。需要注意运算符的优先级和结合性,不确定时使用括号来明确求值顺序。
int result = (2 + 3) * 4; // 使用括号明确优先级
控制流语句用于控制程序执行的路径,包括:
if/else:条件分支。switch:多路分支(注意每个case后通常需要break)。for/while/do-while:循环。- 现代
for循环:基于范围的循环。







使用 if 和 switch 时,可以包含一个可选的初始化语句。
if (auto it = vec.begin(); it != vec.end()) {
// 使用 it
}
函数:定义、参数与返回
函数将一组语句封装起来,并赋予一个名称,以便在程序中多次调用。函数由返回类型、函数名、参数列表和函数体组成。
参数传递主要有两种方式:
- 按值传递:函数获得参数的副本,修改副本不影响原始数据。
- 按引用传递:函数获得参数的别名,通过别名修改数据会影响原始数据。
void byValue(int x) { x = 10; } // 修改不影响外部
void byReference(int& x) { x = 10; } // 修改影响外部
int a = 5;
byValue(a); // a 仍然是 5
byReference(a); // a 变为 10
函数参数可以有默认值,但带有默认值的参数必须从参数列表的最右边开始连续出现。
void print(int a, int b = 10, int c = 20) { /* ... */ }
print(1); // 等效于 print(1, 10, 20)
print(1, 2); // 等效于 print(1, 2, 20)
引用:变量的别名
引用是一个已存在变量的别名。一旦引用被初始化为某个变量的别名,就不能再改为其他变量的别名。通过引用修改变量,实际上修改的是原始变量。
主要分为两种引用:
- 左值引用 (
T&):绑定到左值(有名字的对象)。 - 右值引用 (
T&&):绑定到右值(临时对象),主要用于实现移动语义和完美转发,这是高级优化技术。
int original = 42;
int& ref = original; // ref 是 original 的别名
ref = 100; // original 现在也是 100
引用必须在定义时初始化,并且不能为 void。引用本身不一定会占用存储空间。不能创建引用的引用,也不能创建引用数组。
需要特别注意:绝不能返回局部变量的引用,因为局部变量在函数结束后就被销毁,返回的引用将变成“悬垂引用”,导致未定义行为。
int& badFunction() {
int local = 10;
return local; // 错误!返回了即将销毁的局部变量的引用
}
类型转换:static_cast
在C++中,应使用 static_cast 进行显式的、编译时检查的类型转换。它比C风格转换 (type)value 更安全,因为功能受限,且意图更明确。其他转换操作符(如 reinterpret_cast)应尽量避免使用。
double d = 3.14;
int i = static_cast<int>(d); // 显式转换,i 的值为 3
使用 static_cast 可以消除函数重载时的歧义,或明确指示你接受可能的精度损失。

本节课中我们一起学习了C++中函数与内存的基础知识。我们了解了安全初始化的最佳实践、auto关键字的使用、现代容器array和vector的用法、表达式与控制流、函数的定义与参数传递机制,以及引用和类型转换的核心概念。掌握这些内容是编写正确、高效且现代的C++代码的关键。下一节课,我们将深入探讨面向对象编程的基石:类与对象。
003:函数、函数对象与错误处理 🧩

在本节课中,我们将要学习C++中关于函数、函数对象(如仿函数和Lambda表达式)以及错误处理的核心概念。我们将从函数的返回值、参数传递和重载开始,然后探讨如何创建和使用可调用对象,最后了解C++中处理错误的几种主要方式。
函数返回值
上一节我们介绍了函数的基本概念,本节中我们来看看函数如何返回值。

在C++中,函数可以返回一个值。返回类型写在函数名之前,例如 int myFunction()。C++11引入了尾置返回类型,允许将返回类型写在参数列表之后,使用 auto 关键字和箭头符号 -> 来指定。
// 常规返回类型
int add(int a, int b) {
return a + b;
}
// 尾置返回类型
auto add(int a, int b) -> int {
return a + b;
}

尾置返回类型的主要用途在于模板编程,当返回类型依赖于模板参数时,它非常有用。

C++函数本身不支持直接返回多个值。但我们可以使用标准库类型来模拟这一功能。

以下是返回多个值的几种方式:
std::pair:用于返回两个值。std::tuple:用于返回两个或更多值。- 结构化绑定:C++17引入的特性,可以方便地从
pair或tuple中提取值并绑定到变量。
#include <tuple>
#include <string>




// 返回一个pair
std::pair<int, std::string> getPair() {
return std::make_pair(42, "hello");
}


// 返回一个tuple
std::tuple<int, double, std::string> getTuple() {
return std::make_tuple(1, 2.0, "three");
}

int main() {
// 使用结构化绑定解包pair
auto [num, str] = getPair();
// 使用结构化绑定解包tuple
auto [a, b, c] = getTuple();
// 结构化绑定也常用于遍历map
std::map<std::string, int> myMap = {{"a", 1}, {"b", 2}};
for (const auto& [key, value] : myMap) {
// 使用key和value
}
return 0;
}
此外,如果函数体简单,编译器可以自动推导返回类型,你可以使用 auto 作为返回类型(无需尾置语法)。但需注意,这可能会降低库接口的清晰度。
// 编译器推导返回类型为int
auto add(int a, int b) {
return a + b;
}

参数传递

了解了如何返回值后,我们来看看如何将值传递给函数。
C++有三种主要的参数传递方式:
- 按值传递:创建参数的副本。修改副本不影响原始数据。
- 按引用传递:传递参数的别名。在函数内修改参数会影响原始数据。
- 按常量引用传递:传递只读的别名。无法在函数内修改参数,避免了复制开销。
void byValue(int x) {} // 复制x
void byReference(int& x) {} // x是传入变量的别名
void byConstRef(const int& x) {} // x是只读别名
如何选择传递方式?可以参考C++核心指南的建议:
- 参数只进不出:使用按值传递(如果类型复制成本低)或按常量引用传递(如果类型复制成本高,如大的数据结构)。
- 参数先进后出(即需要修改):使用按(非常量)引用传递。
- 参数只出不进:应作为返回值。
对于初学者,一个简单的原则是:对于内置类型(如 int, double)和小型结构,默认使用按值传递;对于大型对象(如 std::vector, std::string),使用按常量引用传递;当你确实需要在函数内部修改调用者的变量时,使用按引用传递。


函数重载
C++允许函数重载,即多个函数可以拥有相同的名字,只要它们的参数列表(参数的类型、数量或顺序)不同。返回类型不参与重载决议。
void print(int i) {
std::cout << "int: " << i << std::endl;
}
void print(double d) {
std::cout << "double: " << d << std::endl;
}
void print(const std::string& s) {
std::cout << "string: " << s << std::endl;
}
int main() {
print(5); // 调用 print(int)
print(3.14); // 调用 print(double)
print("hello"); // 调用 print(const std::string&)
return 0;
}
当调用重载函数时,编译器会尝试寻找最佳匹配。匹配顺序通常是:精确匹配 > 类型提升 > 标准转换 > 用户定义转换。如果找不到匹配或存在歧义,编译器会报错。
函数对象(仿函数)
普通的C++函数不是对象,不能直接存储状态或像对象一样传递。函数对象(或称仿函数)是解决了这个问题的对象。任何重载了函数调用运算符 () 的类或结构体的对象,都是函数对象。
struct Incrementor {
int value = 0;
// 重载函数调用运算符
int operator()(int x) {
value += x;
return value;
}
};
int main() {
Incrementor inc;
std::cout << inc(5) << std::endl; // 输出 5
std::cout << inc(3) << std::endl; // 输出 8,保持了状态
return 0;
}
标准库提供了 std::function 作为通用的函数包装器,它可以存储、复制和调用任何可调用对象(普通函数、Lambda表达式、仿函数等)。使用它会有轻微的性能开销(一次额外的间接调用)。
#include <functional>
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
// 包装一个普通函数
std::function<int(int, int)> func = add;
std::cout << func(2, 3) << std::endl; // 输出 5
// 包装一个Lambda表达式
std::function<int(int)> square = [](int x) { return x * x; };
std::cout << square(4) << std::endl; // 输出 16
return 0;
}
Lambda表达式

Lambda表达式是C++11引入的用于创建匿名函数对象的便捷语法。它本质上是一个编译器生成的、拥有唯一类型的匿名函数对象(闭包)。
一个Lambda表达式的基本语法如下:
[捕获列表] (参数列表) -> 返回类型 { 函数体 }
其中,参数列表和返回类型可以省略(返回类型由编译器推导)。
// 一个简单的Lambda,接受一个int,返回它的平方
auto square = [](int x) { return x * x; };
std::cout << square(5) << std::endl; // 输出 25
// 在算法中使用Lambda
std::vector<int> v = {1, 2, 3, 4, 5};
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; }); // 降序排序
捕获列表允许Lambda访问其所在作用域中的变量。
[=]:以值(复制)的方式捕获所有外部变量。[&]:以引用的方式捕获所有外部变量。[x, &y]:以值捕获x,以引用捕获y。[this]:捕获当前对象的this指针。
int base = 10;
// 以值捕获base
auto add_base_val = [base](int x) { return x + base; };
// 以引用捕获base
auto add_base_ref = [&base](int x) { base += x; return base; };
std::cout << add_base_val(5) << std::endl; // 输出 15, base仍是10
std::cout << add_base_ref(5) << std::endl; // 输出 15, base变为15
注意:当Lambda捕获了局部变量的引用,并且该Lambda的生命周期超过了被捕获变量的生命周期时,会产生悬垂引用,导致未定义行为。应谨慎使用引用捕获,尤其是默认捕获 [&]。
错误处理
程序运行时难免出错,C++提供了几种主要的错误处理机制。
1. 返回错误码
这是C语言的传统方式。函数返回一个特殊值(如-1、NULL)表示错误,或设置一个全局错误变量(如 errno)。缺点是不够直观,容易忽略错误检查。

2. 异常
C++内置的异常处理机制。使用 throw 抛出异常,使用 try 和 catch 块捕获并处理异常。
#include <stdexcept>
#include <iostream>


double divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("Division by zero!");
}
return static_cast<double>(a) / b;
}
int main() {
try {
double result = divide(10, 0);
std::cout << result << std::endl;
} catch (const std::invalid_argument& e) {
std::cerr << "Caught an error: " << e.what() << std::endl;
} catch (...) { // 捕获所有其他异常
std::cerr << "Caught an unknown error!" << std::endl;
}
return 0;
}
异常处理会进行栈展开,自动调用局部对象的析构函数,有助于资源清理。但它会带来一些运行时开销,并且在实时或嵌入式系统中有时会被禁用。函数可以用 noexcept 关键字声明不抛出异常,但这并非强制契约。
3. std::expected (C++23)
这是一种受Rust等语言启发的新型错误处理方式。函数返回一个 std::expected<T, E> 对象,其中 T 是期望的成功结果类型,E 是错误类型。调用者必须显式检查是得到了值还是错误。
#include <expected>
#include <string>
#include <iostream>
std::expected<int, std::string> parseNumber(const std::string& s) {
try {
return std::stoi(s);
} catch (...) {
return std::unexpected("Parse failed");
}
}

int main() {
auto result = parseNumber("123");
if (result.has_value()) {
std::cout << "Got value: " << result.value() << std::endl;
} else {
std::cout << "Got error: " << result.error() << std::endl;
}
return 0;
}

这种方式没有异常处理的栈展开开销,强制调用者处理错误,适用于那些“可预期的错误”(如解析失败)。异常则更适用于“不可预期的、严重的”错误(如内存耗尽)。


本节课中我们一起学习了C++函数的高级特性,包括多种返回值和参数传递方式、函数重载的规则。我们深入探讨了函数对象的概念,了解了如何使用仿函数和更现代的Lambda表达式来创建灵活的可调用单元。最后,我们对比了C++中几种主要的错误处理策略:传统的错误码、基于栈展开的异常机制,以及C++23引入的、类似于Rust的 std::expected 类型。理解这些概念和工具,将帮助你编写出更健壮、更清晰和更现代的C++代码。
004:标准库概述
在本节课中,我们将学习C++标准库的核心组成部分。标准库是C++语言的重要组成部分,它提供了大量预定义的类、函数和模板,用于处理常见任务,如字符串操作、数据存储、算法和输入/输出。掌握标准库能极大地提高编程效率和代码质量。
标准库是一个由C++标准定义的类和函数的集合。所有内容都位于std命名空间中。C++是自托管的,这意味着标准库本身也是用C++实现的。标准只定义了接口和行为规范,具体实现由编译器供应商(如GCC的libstdc++、Clang的libc++、微软的MSVC STL)提供。标准库功能庞大,包含C标准库、内存管理工具、错误处理、时间处理、字符串、容器、算法、迭代器、数值计算、输入/输出、正则表达式等众多组件。
实用库类型
上一节我们介绍了标准库的整体概念,本节中我们来看看几个非常实用的库类型,它们能帮助我们更安全、更清晰地表达程序意图。
std::optional
std::optional是一个模板类,表示一个可能包含值,也可能不包含值的对象。它常用于函数的返回值,以明确表示操作可能没有结果,避免了使用特殊值(如-1或nullptr)或额外的布尔标志。
核心概念:
std::optional<T>:可以存储一个T类型的值,或者为空。std::nullopt:表示一个空的optional。has_value()或if (opt):检查是否包含值。value()或*opt:获取存储的值(若为空则可能抛出异常或导致未定义行为)。value_or(default):获取值,若为空则返回默认值。
代码示例:
#include <optional>
#include <string>
#include <iostream>
std::optional<std::string> might_fail(bool success) {
if (success) {
return "Operation succeeded"; // 隐式构造 optional
// 等价于: return std::optional<std::string>{"Operation succeeded"};
// 或: return std::make_optional("Operation succeeded");
} else {
return std::nullopt; // 返回空值
// 等价于: return {};
}
}

int main() {
auto result = might_fail(true);
if (result) { // 或 if (result.has_value())
std::cout << result.value() << std::endl; // 安全地获取值
// 或: std::cout << *result << std::endl;
} else {
std::cout << "Operation failed" << std::endl;
}
auto failed_result = might_fail(false);
// 使用 value_or 提供默认值
std::cout << failed_result.value_or("Default message") << std::endl;
}


std::pair 和 std::tuple
std::pair和std::tuple用于将多个值组合成单个对象。pair用于两个值,tuple用于两个或更多值。它们在编译时处理,没有运行时开销。
核心概念:
std::pair<T1, T2>:存储两个值,通过first和second成员访问。std::tuple<Ts...>:存储多个值,通过std::get<I>(tuple)或结构化绑定访问。std::make_pair,std::make_tuple:辅助函数,用于创建对象。
代码示例:
#include <utility> // for pair
#include <tuple>
#include <iostream>
int main() {
// 使用 std::pair
std::pair<int, std::string> p1{42, "hello"};
std::cout << p1.first << ", " << p1.second << std::endl;
auto p2 = std::make_pair(3.14, 'a'); // 自动推导类型
// 使用 std::tuple
std::tuple<int, double, std::string> t{1, 2.2, "test"};
std::cout << std::get<0>(t) << ", " << std::get<2>(t) << std::endl;
auto t2 = std::make_tuple(10, 20.5, "world");
// 结构化绑定 (C++17)
auto [x, y, z] = t2;
std::cout << x << ", " << z << std::endl;
}
std::variant
std::variant是一个类型安全的联合体(union),可以在运行时保存多种预定义类型中的一种。它比C风格的union更安全,因为会跟踪当前存储的类型。
核心概念:
std::variant<Ts...>:可以存储Ts...中的任意一种类型。std::get<T>(var)或std::get<I>(var):获取存储的值(若类型不匹配则抛出异常)。std::holds_alternative<T>(var):检查当前是否存储了类型T。std::visit(visitor, var):使用访问者模式处理variant。
代码示例:
#include <variant>
#include <string>
#include <iostream>
int main() {
std::variant<int, float, std::string> v;
v = 42; // 存储 int
std::cout << std::get<int>(v) << std::endl; // 输出 42
// std::get<float>(v); // 错误!当前存储的是int,会抛出 std::bad_variant_access
if (std::holds_alternative<int>(v)) {
std::cout << "Holds an int" << std::endl;
}
v = 3.14f; // 改为存储 float
v = "hello"; // 改为存储 string
// 使用 std::visit 处理不同情况
auto visitor = [](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>)
std::cout << "int: " << arg << std::endl;
else if constexpr (std::is_same_v<T, float>)
std::cout << "float: " << arg << std::endl;
else if constexpr (std::is_same_v<T, std::string>)
std::cout << "string: " << arg << std::endl;
};
std::visit(visitor, v);
}
字符串:std::string 和 std::string_view
处理文本是编程中的常见任务。C++提供了std::string类来管理动态字符串,以及std::string_view类来提供字符串的只读视图,无需复制数据。
std::string
std::string是一个容器,用于存储和管理字符序列。它知道自己的长度,内存是连续的,并且提供了丰富的成员函数进行操作。
核心概念:
- 连续内存,知道自身长度。
- 支持类似容器的操作:
[],.at()(边界检查), 迭代器。 - 常用操作:
.size(),.empty(),.append(),+,.find(),.substr()。
代码示例:
#include <string>
#include <iostream>
int main() {
std::string str1; // 默认构造,空字符串
std::string str2 = "Hello";
std::string str3("World");
// 访问字符
char c = str2[0]; // 'H'
// str2.at(10); // 抛出 std::out_of_range 异常
// 修改
str2 += " "; // 追加
str2.append(str3);
// 查找
size_t pos = str2.find("World");
if (pos != std::string::npos) {
std::cout << "Found at position: " << pos << std::endl;
}
// 子串 (可能涉及内存分配和复制)
std::string sub = str2.substr(6, 5); // "World"
std::cout << sub << std::endl;
// 遍历
for (char& ch : str2) {
ch = std::toupper(ch);
}
std::cout << str2 << std::endl; // "HELLO WORLD"
}
std::string_view
std::string_view (C++17) 是一个轻量级的、非拥有的字符串视图。它只包含一个指向常量字符序列的指针和一个长度,因此创建和复制成本极低。重要:它不管理所指向内存的生命周期,使用时必须确保底层字符串有效。
核心概念:
- 非拥有,只读视图。
- 创建和子串操作开销极低(O(1))。
- 接口与
std::string相似。
代码示例:
#include <string_view>
#include <iostream>
void print_sv(std::string_view sv) { // 按值传递也很廉价
std::cout << sv << std::endl;
}
int main() {
std::string str = "A very long string that we don't want to copy";
// 从 string 创建 string_view,无复制
std::string_view sv1(str);
// 从字面量创建
std::string_view sv2 = "Hello View";
print_sv(sv1);
print_sv(sv2);
// 获取子串视图,O(1) 操作
std::string_view sub_sv = sv1.substr(2, 10); // "very long "
std::cout << sub_sv << std::endl;
// 移除前缀/后缀
sv2.remove_prefix(1); // 现在视图是 "ello View"
sv2.remove_suffix(1); // 现在视图是 "ello Vie"
std::cout << sv2 << std::endl;
// 注意:sv1 和 sub_sv 仍然依赖于 str 的生命周期
}
容器
容器是用于存储其他对象的对象。标准库提供了多种容器,每种都在不同操作上有性能权衡。它们具有类似的接口,使得学习和使用更加容易。
序列容器
序列容器按线性顺序存储元素。
std::vector
动态数组,在尾部插入/删除高效,支持随机访问。内存连续。
#include <vector>
int main() {
std::vector<int> vec = {1, 2, 3};
vec.push_back(4); // 尾部添加
int x = vec[2]; // 随机访问,不检查边界
int y = vec.at(2); // 随机访问,检查边界
for (int v : vec) { /* 遍历 */ }
// 预先分配内存以避免多次重分配
vec.reserve(100);
for(int i=0; i<100; ++i) vec.push_back(i);
}
std::deque
双端队列,在头尾插入/删除高效。内存由多块连续缓冲区组成,并非完全连续。
#include <deque>
std::deque<int> dq = {1, 2, 3};
dq.push_front(0); // 头部添加
dq.push_back(4); // 尾部添加
std::list / std::forward_list
双向链表和单向链表。在任何位置插入/删除都高效(O(1)),但不支持随机访问。
#include <list>
std::list<int> lst = {1, 2, 3};
lst.insert(std::next(lst.begin()), 99); // 在第二个位置插入
lst.erase(lst.begin()); // 删除第一个元素
关联容器
关联容器基于键来存储元素,便于快速查找。
std::set / std::multiset
有序集合,基于红黑树实现,元素自动排序。set键唯一,multiset允许重复键。查找、插入、删除复杂度为 O(log n)。
#include <set>
std::set<int> s = {5, 2, 8, 2}; // 最终 {2, 5, 8}
s.insert(3);
if (s.find(5) != s.end()) { /* 找到了 */ }
std::map / std::multimap
有序映射,存储键值对。map键唯一,multimap允许重复键。同样基于红黑树,O(log n) 复杂度。
#include <map>
std::map<std::string, int> age_map = {{"Alice", 30}, {"Bob", 25}};
age_map["Charlie"] = 28; // 插入或赋值
int alice_age = age_map.at("Alice"); // 访问,若键不存在则抛出异常
for (const auto& [name, age] : age_map) { /* 遍历 */ }
std::unordered_set / std::unordered_map
无序集合和映射,基于哈希表实现。平均情况下的插入、查找、删除为 O(1),但最坏情况为 O(n)。元素不按顺序存储。
#include <unordered_map>
std::unordered_map<std::string, int> u_map = {{"Apple", 1}, {"Banana", 2}};
u_map["Orange"] = 3;
if (u_map.contains("Apple")) { /* C++20 */ }
// 查找返回迭代器
auto it = u_map.find("Banana");
if (it != u_map.end()) {
std::cout << it->first << ": " << it->second << std::endl;
}
迭代器
迭代器提供了遍历容器元素的统一方法,它抽象了不同容器的内部结构。你可以将迭代器理解为指向容器中元素的“智能指针”。
核心概念:
begin(): 返回指向第一个元素的迭代器。end(): 返回指向末尾后一个位置的迭代器(哨兵),不可解引用。- 支持的操作:
*it(解引用),++it(前进),it->member, 以及对于某些迭代器:--it,it + n,it1 == it2。
迭代器类别:
- 输入迭代器:只读,单次遍历。
- 输出迭代器:只写,单次遍历。
- 前向迭代器:可读写,可多次遍历。
- 双向迭代器:在前向基础上可后退 (
--it)。 - 随机访问迭代器:支持所有指针算术运算 (
it + n,it1 - it2,it[n]),如vector,deque,array的迭代器。 - 连续迭代器 (C++17):随机访问迭代器,且保证元素在内存中连续,如
vector,array的迭代器。
代码示例:
#include <vector>
#include <list>
#include <iostream>
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
// 1. 使用迭代器手动遍历
for (auto it = vec.begin(); it != vec.end(); ++it) {
std::cout << *it << " ";
(*it) += 1; // 可以修改元素
}
std::cout << std::endl; // 输出: 1 2 3 4 5
// 2. 范围 for 循环 (底层使用迭代器)
for (int& val : vec) {
std::cout << val << " ";
}
std::cout << std::endl; // 输出: 2 3 4 5 6
// 3. 不同容器的迭代器
std::list<int> lst = {10, 20, 30};
for (auto it = lst.begin(); it != lst.end(); ++it) {
std::cout << *it << " ";
// --it; // 双向迭代器支持后退
}
std::cout << std::endl;
// 4. 迭代器失效示例 (在遍历时修改容器结构是危险的)
// for(auto it = vec.begin(); it != vec.end(); ++it) {
// if (*it == 3) {
// vec.erase(it); // 错误!erase后it失效,再++是未定义行为
// }
// }
// 正确做法:利用erase的返回值
for(auto it = vec.begin(); it != vec.end(); ) {
if (*it == 3) {
it = vec.erase(it); // erase返回被删除元素之后元素的迭代器
} else {
++it;
}
}
}
范围库 (Ranges) 和算法 (Algorithms)
标准算法库 (<algorithm>) 提供了大量通用算法(如排序、查找、操作),它们通常通过迭代器对容器进行操作。C++20引入的范围库 (<ranges>) 提供了更现代、更易用的函数式编程接口。
标准算法示例
以下是几个常用算法的例子:
代码示例:
#include <algorithm>
#include <vector>
#include <iostream>
#include <numeric> // for iota
int main() {
std::vector<int> vec = {5, 3, 1, 4, 2};
// 排序
std::sort(vec.begin(), vec.end()); // 升序
// std::sort(vec.begin(), vec.end(), std::greater<int>()); // 降序
// 查找
auto it = std::find(vec.begin(), vec.end(), 3);
if (it != vec.end()) {
std::cout << "Found at index: " << std::distance(vec.begin(), it) << std::endl;
}
// 计数
int count = std::count(vec.begin(), vec.end(), 2);
// 填充和生成
std::vector<int> vec2(5);
std::fill(vec2.begin(), vec2.end(), 42);
std::iota(vec2.begin(), vec2.end(), 0); // 填充为 0, 1, 2, 3, 4
// 变换 (map)
std::vector<int> vec3;
std::transform(vec.begin(), vec.end(), std::back_inserter(vec3),
[](int x) { return x * 2; });
// 删除-擦除惯用法 (移除特定元素)
vec.erase(std::remove(vec.begin(), vec.end(), 3), vec.end());
for (int v : vec) std::cout << v << " ";
std::cout << std::endl;
}
范围库 (C++20)
范围库提供了一种更简洁的方式来组合算法操作,支持惰性求值,并且语法更清晰。
核心概念:
- 范围:任何可以迭代的东西(如容器、视图)。
- 视图:对范围的轻量级转换,不复制数据,惰性求值。
- 使用管道运算符
|来组合操作。
代码示例:
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 使用视图:过滤出偶数,然后乘以2
auto even_doubled = vec
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * 2; });
// 视图是惰性的,此时还未进行计算
for (int v : even_doubled) { // 在遍历时进行计算
std::cout << v << " "; // 输出: 4 8 12 16 20
}
std::cout << std::endl;
// 取前3个元素
auto first_three = vec | std::views::take(3);
for (int v : first_three) std::cout << v << " "; // 1 2 3
// 范围算法 (C++20)
namespace rng = std::ranges;
if (rng::find(vec, 5) != vec.end()) {
std::cout << "\nFound 5" << std::endl;
}
// 排序整个范围(更简洁)
// rng::sort(vec);
}
输入/输出流
C++使用流(stream)来处理输入和输出。流是字节序列的抽象,可以关联到控制台、文件、字符串等。
标准流对象
std::cin:标准输入流(控制台输入)。std::cout:标准输出流(控制台输出)。std::cerr:标准错误流(无缓冲)。std::clog:标准日志流(有缓冲)。
文件流
std::ifstream:用于读取文件。std::ofstream:用于写入文件。std::fstream:用于读写文件。
字符串流
std::istringstream:从字符串读取数据。std::ostringstream:将数据写入字符串。std::stringstream:读写字符串。
代码示例:
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
int main() {
// 控制台 I/O
int num;
std::cout << "Enter a number: ";
std::cin >> num;
std::cout << "You entered: " << num << std::endl;
// 文件 I/O
std::ofstream out_file("output.txt");
if (out_file.is_open()) {
out_file << "Writing to a file.\n";
out_file << "Number: " << num << std::endl;
out_file.close();
}
std::ifstream in_file("output.txt");
std::string line;
if (in_file.is_open()) {
while (std::getline(in_file, line)) {
std::cout << line << std::endl;
}
in_file.close();
}
// 字符串流
std::ostringstream oss;
oss << "The answer is " << 42 << ".";
std::string result_str = oss.str();
std::cout << result_str << std::endl;
std::istringstream iss("100 200");
int a, b;
iss >> a >> b;
std::cout << a + b << std::endl; // 300
// 更现代的格式化输出 (C++20)
#include <format>
std::string formatted = std::format("Hello, {}! The value is {:.2f}.", "World", 3.14159);
std::cout << formatted << std::endl;
// C++23 的 print (部分编译器支持)
// std::print("Hello {}!\n", "C++23");
}
总结
本节课中我们一起学习了C++标准库的核心组件。我们从整体概述开始,了解了标准库的构成和重要性。接着,我们探讨了几个实用的库类型:std::optional用于可选的返回值,std::pair和std::tuple用于组合数据,std::variant用于类型安全的联合。
然后,我们深入研究了字符串处理,比较了std::string和轻量级的std::string_view。在容器部分,我们介绍了序列容器(如vector, list)和关联容器(如map, set, unordered_map),并理解了它们各自适用的场景和性能特点。
迭代器作为遍历容器的通用抽象机制被详细解释。最后,我们了解了强大的算法库以及C++20引入的、更现代易用的范围库(Ranges),并简要回顾了基于流的输入/输出系统。

标准库是C++编程的利器,熟练使用它可以避免重复造轮子,写出更高效、更安全、更易维护的代码。建议在实践中多查阅标准库文档,不断探索其丰富的功能。
005:资源管理


在本节课中,我们将学习C++中的类与对象,这是面向对象编程的基础。我们将重点探讨构造函数、析构函数以及运算符重载,这些概念对于理解下一周的内存和资源管理至关重要。


类与结构体

在C++中,我们使用class或struct关键字来定义用户自定义类型,它们本质上都是类。两者的主要区别在于默认的成员访问控制权限。
class的默认访问权限是private。struct的默认访问权限是public。

定义一个类的基本语法如下:
class ClassName {
// 成员声明(变量、函数、类型)
}; // 注意:类定义末尾需要分号



类成员

类可以包含数据成员和成员函数。



数据成员

数据成员是类中声明的变量。它们可以是任何完整类型,并且可以使用修饰符如static、const或thread_local。



示例:
struct Foo {
int i = 42; // 普通数据成员,带初始化
std::string s{"hello"};
static int magic_value; // 静态数据成员
const int c = 100; // 常量数据成员
};
静态数据成员属于类本身,而不是类的某个对象,因此需要在类外单独定义以分配存储空间:
int Foo::magic_value = 0; // 静态成员的定义

成员函数
成员函数是定义在类作用域内的函数。它们可以访问类的数据成员和其他成员函数。
成员函数可以在类内直接定义(内联),也可以在类外定义(外联)。通常,我们将类声明放在头文件(.hpp)中,而将成员函数的定义放在源文件(.cpp)中,以提高编译效率。


示例:
// 头文件 foo.hpp
class Foo {
public:
void do_something(); // 声明
int get_value() const { return value_; } // 内联定义
private:
int value_;
};

// 源文件 foo.cpp
#include "foo.hpp"
void Foo::do_something() { // 外联定义
// 函数实现
}

访问控制与this指针
类成员可以具有三种访问权限:
public:可以被任何代码访问。private:只能被类自身的成员函数和友元访问。protected:类似于private,但允许派生类访问。

非静态成员函数内部有一个隐式的this指针,它指向调用该成员函数的对象实例。通过this指针,可以访问当前对象的成员。
示例:
class MyClass {
int x;
public:
void set_x(int val) {
this->x = val; // 使用 this 指针访问成员
// 等价于 x = val;
}
};

特殊成员函数
构造函数
构造函数是一种特殊的成员函数,在创建类对象时自动调用,用于初始化对象。它的名称与类名相同,没有返回类型。

- 默认构造函数:没有参数或所有参数都有默认值的构造函数。
- 初始化列表:构造函数可以使用初始化列表来初始化成员变量,这发生在构造函数体执行之前。
- 转换构造函数:只接受一个参数的构造函数,可以用于隐式类型转换。使用
explicit关键字可以禁止隐式转换。 - 拷贝构造函数:接受一个
const引用到同类对象的构造函数,用于用一个已存在的对象初始化新对象。


示例:
class Example {
int a, b;
public:
Example() : a(0), b(0) {} // 默认构造函数
Example(int x, int y) : a(x), b(y) {} // 带参构造函数
explicit Example(int x) : a(x), b(0) {} // explicit 转换构造函数
Example(const Example& other) : a(other.a), b(other.b) {} // 拷贝构造函数
};

析构函数
析构函数在对象生命周期结束时自动调用,用于清理资源(如关闭文件、释放内存等)。它的名称是类名前加~,没有参数和返回类型。

对象的构造和析构顺序相反:成员变量按声明顺序构造,按逆序析构;基类先于派生类构造,晚于派生类析构。



示例:
class FileHandler {
FILE* file_;
public:
FileHandler(const char* filename) { file_ = fopen(filename, "r"); }
~FileHandler() { if (file_) fclose(file_); } // 析构时关闭文件
};




运算符重载



C++允许重载大部分运算符,使其能用于用户自定义类型,这可以让代码更直观。运算符重载本质上是一个特殊命名的函数。
语法:
ReturnType operator Op (ArgumentList) {
// 实现
}

示例:重载+和==运算符
class Complex {
double real, imag;
public:
Complex operator+(const Complex& other) const {
return Complex(real + other.real, imag + other.imag);
}
bool operator==(const Complex& other) const {
return real == other.real && imag == other.imag;
}
};


可以重载的运算符包括算术运算符、关系运算符、赋值运算符、下标运算符[]、函数调用运算符()等。不能重载的运算符包括作用域解析符::、成员访问符.、sizeof等。

注意:重载运算符时应遵循其直观含义,避免造成混淆。





枚举类


enum class(有作用域枚举)是C++11引入的类型安全的枚举。与传统的C风格enum相比,它的枚举值位于枚举类的作用域内,不会隐式转换为整数,避免了命名冲突。


示例:
enum class TrafficLight { Red, Yellow, Green };
TrafficLight light = TrafficLight::Red; // 必须使用作用域
// int val = light; // 错误:不能隐式转换
int val = static_cast<int>(light); // 正确:显式转换




建议在新代码中使用enum class代替普通的enum。



总结


本节课我们一起学习了C++中面向对象编程的核心概念。我们了解了如何使用class和struct定义类型,以及如何通过构造函数和析构函数管理对象的生命周期。我们还探讨了运算符重载,它允许我们为用户自定义类型定义直观的操作。最后,我们介绍了更安全的enum class枚举类型。掌握这些知识是理解下一周内存与资源管理内容的重要基础。
006:移动语义与资源管理

概述

在本节课中,我们将要学习C++中一个核心且强大的概念:资源管理。我们将重点探讨RAII(资源获取即初始化)原则,并深入理解移动语义如何帮助我们高效、安全地管理内存、文件句柄等资源。课程将涵盖拷贝操作、移动操作、智能指针,并解释为何在现代C++中应避免使用原始指针。
课程公告与安排
在开始今天的核心内容之前,有一些课程安排需要告知大家。
首先,下周课程将暂停一次。因为12月7日是学校的学术活动日,没有练习课。为了保持课程与练习的同步,12月5日的讲座也将取消。因此,下周大家可以稍作休息。
其次,关于家庭作业。作业7将于本周末发布,其提交期限为两周,所以大家有更充足的时间来完成。请放心,作业量不会因此增加,只是多了一周的完成时间。下周之后,我们将于12月12日恢复正常课程安排。
最后,我想向大家介绍一个有趣的编程活动。本周五是12月1日,标志着“编程降临节”(Advent of Code)的开始。这是一个从12月1日到25日,每天发布一道编程挑战的在线活动。题目从易到难,非常适合用来练习C++编程。我创建了一个本课程的私有排行榜,相关信息会发布在Moodle上,欢迎大家参与。
资源管理(RAII)的核心概念
上一节我们了解了课程安排,现在让我们进入今天的核心主题:资源管理。这是C++区别于C语言的关键领域之一,主要解决了C语言在资源管理上容易出错、不安全的问题。
C++的核心概念是RAII,即“资源获取即初始化”。这个缩写在你未来的C++开发生涯中会经常遇到。其主要思想是:将任何类型的资源(如内存、文件句柄、网络套接字、互斥锁、数据库连接等)的生命周期与一个对象的生命周期绑定。
其工作原理如下:
- 在对象的构造函数中获取或初始化资源。
- 在对象的析构函数中释放或清理资源。
由于编译器会自动管理具有自动存储期对象的构造和析构,这就确保了资源总能被正确清理,即使程序因异常而提前退出。你无法忘记清理,也不会重复清理。
RAII实践示例
以下是一个简单的RAII实践示例。假设我们有一个向文件写入消息的函数,并且需要在多线程环境中进行同步。
#include <fstream>
#include <mutex>
#include <stdexcept>
void write_message(const std::string& msg) {
static std::mutex mtx; // 静态互斥锁,所有调用共享
std::lock_guard<std::mutex> lock(mtx); // RAII包装器:构造时加锁,析构时解锁
std::ofstream file("message.txt");
if (!file.is_open()) {
throw std::runtime_error("Could not open file");
}
file << msg << std::endl;
// 无需手动关闭文件或解锁互斥锁!
}
在这个例子中:
std::lock_guard和std::ofstream都是RAII类。lock_guard在构造时锁定互斥锁,在析构时(函数结束时)自动解锁。ofstream在析构时会自动关闭文件。- 即使
throw语句引发了异常,所有已完全构造的RAII对象也会被正确销毁,资源得到释放。
这种编程方式确保了线程安全和异常安全,使你几乎不可能犯资源泄漏的错误。
拷贝操作与“三法则”
上一节我们介绍了RAII的基本思想,本节中我们来看看实现RAII类时的一个关键细节:拷贝操作。
编译器会为自定义类型生成默认的拷贝构造函数和拷贝赋值运算符,执行浅拷贝(即逐成员拷贝)。对于管理资源的类(例如持有一个指向动态内存的指针),浅拷贝是危险的,因为它会复制资源句柄本身,而不是资源内容。这会导致两个对象指向同一资源,在析构时该资源会被释放两次,引发未定义行为(通常是程序崩溃)。
因此,对于管理资源的类,你必须自定义拷贝操作。以下是自定义拷贝操作的示例:
#include <memory>
#include <algorithm>
class ManagedArray {
std::unique_ptr<int[]> data; // RAII包装的指针
size_t capacity;
public:
// 构造函数
explicit ManagedArray(size_t cap) : capacity(cap), data(std::make_unique<int[]>(cap)) {}
// 拷贝构造函数
ManagedArray(const ManagedArray& other) : ManagedArray(other.capacity) {
std::copy(other.data.get(), other.data.get() + capacity, data.get());
}
// 拷贝赋值运算符
ManagedArray& operator=(const ManagedArray& other) {
if (this != &other) { // 自赋值检查
if (capacity != other.capacity) {
data = std::make_unique<int[]>(other.capacity); // 旧内存通过unique_ptr赋值自动释放
capacity = other.capacity;
}
std::copy(other.data.get(), other.data.get() + capacity, data.get());
}
return *this;
}
// 析构函数由 std::unique_ptr 自动处理
};
重要规则:三法则
如果你需要为类显式定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么你很可能需要同时定义这三个。因为它们通常共同处理资源的生命周期管理。编译器生成的默认版本在存在资源管理时往往是错误的。
移动语义与“五法则”
上一节我们处理了拷贝,本节中我们来看看一种更高效的操作:移动。
有时我们不需要拷贝整个对象,特别是对于临时对象或即将销毁的对象。移动语义允许我们将资源从一个对象“转移”到另一个对象,避免昂贵的拷贝开销。这类似于现实中将物品递给别人,而不是复制一份。
移动操作通过右值引用(T&&)实现。你可以定义移动构造函数和移动赋值运算符。
class MyClass {
public:
// 移动构造函数
MyClass(MyClass&& other) noexcept
: data(std::move(other.data)), capacity(other.capacity) {
other.capacity = 0; // 使被移动对象处于有效但未指定状态
}
// 移动赋值运算符
MyClass& operator=(MyClass&& other) noexcept {
if (this != &other) {
data = std::move(other.data); // 转移资源所有权
capacity = other.capacity;
other.capacity = 0;
}
return *this;
}
private:
std::unique_ptr<int[]> data;
size_t capacity;
};
要调用移动操作而非拷贝操作,可以使用 std::move 将左值转换为右值引用。
MyClass a;
MyClass b = std::move(a); // 调用移动构造函数,a的资源被转移给b
移动语义的主要用途:
- 提升效率:例如从函数返回大型对象时,避免拷贝。
- 实现“只移动”类型:对于某些资源(如唯一文件句柄),拷贝无意义,但移动(转移所有权)是合理的。
重要规则:五法则
随着移动语义的引入,规则扩展为“五法则”:如果你定义了移动操作,通常也需要同时定义拷贝操作和析构函数(即总共五个特殊成员函数)。如果你希望禁止拷贝,可以显式地将拷贝操作标记为 = delete。
智能指针
上一节我们讨论了移动语义如何帮助管理所有权,本节中我们来看看C++标准库提供的、直接应用这些概念的工具:智能指针。
在传统C/C++中,使用 new/malloc 分配内存,必须手动使用 delete/free 释放,极易导致内存泄漏、重复释放等问题。现代C++使用智能指针作为RAII包装器来管理动态内存。
std::unique_ptr
std::unique_ptr 表示对动态分配对象的独占所有权。它不可拷贝,但可以移动。当 unique_ptr 被销毁时,它会自动删除其管理的对象。
#include <memory>
struct Widget { int x, y; };
void takeOwnership(std::unique_ptr<Widget> ptr); // 通过传值接受所有权
int main() {
auto ptr = std::make_unique<Widget>(3, 4); // 使用 make_unique 创建
// auto ptr2 = ptr; // 错误!不可拷贝
auto ptr2 = std::move(ptr); // 正确:移动,ptr 现在为空
takeOwnership(std::move(ptr2)); // 转移所有权给函数
// 此后不能再使用 ptr 和 ptr2
}
std::shared_ptr
std::shared_ptr 表示共享所有权。多个 shared_ptr 可以指向同一对象,内部通过引用计数管理生命周期。当最后一个 shared_ptr 被销毁时,对象才会被删除。它比 unique_ptr 开销大,应仅在需要共享所有权时使用。
#include <memory>
int main() {
auto sp1 = std::make_shared<int>(42);
auto sp2 = sp1; // 拷贝,引用计数增加
// sp1 和 sp2 指向同一个整数
} // sp1 和 sp2 析构,引用计数降为0,内存被释放
指南
- 默认使用
std::unique_ptr:用于明确的独占所有权,零开销。 - 谨慎使用
std::shared_ptr:仅在需要共享所有权时使用,注意循环引用问题。 - 避免使用原始指针管理所有权:几乎在所有情况下,智能指针都是更安全的选择。原始指针应仅用于不涉及所有权的观察和访问(这时可以使用引用或
std::optional)。
总结
本节课中我们一起学习了C++资源管理和移动语义的核心内容。
我们首先理解了RAII原则,它是C++管理资源(内存、文件、锁等)的基石,通过将资源生命周期绑定到对象生命周期,确保了异常安全和代码简洁性。
接着,我们探讨了拷贝操作和“三法则”,认识到对于管理资源的类,需要自定义拷贝构造函数和拷贝赋值运算符以避免浅拷贝带来的问题。
然后,我们深入学习了移动语义,它通过移动构造函数和移动赋值运算符,允许高效地转移资源所有权,避免了不必要的拷贝,并引入了“五法则”作为相关实践指南。
最后,我们介绍了现代C++中管理动态内存的工具:智能指针。std::unique_ptr 用于独占所有权,std::shared_ptr 用于共享所有权。我们强调了应优先使用智能指针,并避免使用原始指针进行资源管理。

掌握这些概念对于编写安全、高效、现代的C++代码至关重要。它们帮助你构建资源泄漏更少、性能更优、更易于维护的程序。
007:继承与多态



在本节课中,我们将完成面向对象编程章节的学习,重点探讨继承、多态、虚函数以及相关的注意事项。



概述


上一节我们讨论了资源管理、拷贝、移动和所有权。本节中,我们将探讨面向对象编程的核心概念:继承和多态。我们将学习如何从一个类派生出新类,如何通过虚函数实现多态行为,以及在使用这些特性时需要注意的细节。



继承基础
在C++中,可以使用struct或class关键字定义类,并可以从其他类派生新类。我们称被派生的类为基类。


以下是继承的基本语法:
class Base {
// 基类成员
};
class Derived : public Base {
// 派生类成员
};
C++允许从多个基类继承,这称为多重继承。然而,多重继承可能导致复杂问题,因此除非有明确需求,否则应避免使用。
构造函数与析构函数

基类在派生类中类似于高优先级的成员。在构造派生类对象时,基类构造函数首先被调用(按基类指定列表中的顺序),然后初始化派生类的数据成员,最后执行派生类构造函数的函数体。


基类默认调用其默认构造函数,除非在派生类的构造函数初始化列表中显式指定。

以下是如何在派生类构造函数中委托调用基类构造函数的示例:
class Base {
public:
Base(int a) : value(a) {}
int value;
};



class Derived : public Base {
public:
Derived(int a, int b) : Base(a), derived_value(b) {}
int derived_value;
};
析构函数的调用顺序与构造函数相反:首先调用派生类的析构函数,然后按与构造相反的顺序调用基类的析构函数。

访问修饰符与继承模式


在继承时,可以指定访问修饰符(public、protected、private)来控制基类成员在派生类中的可见性。

public继承:基类的public成员在派生类中保持public,protected成员保持protected。这是最常用的模式,用于建模“是一个”的关系。protected继承:基类的public和protected成员在派生类中都变为protected。private继承:基类的public和protected成员在派生类中都变为private。

protected和private继承属于特殊情况,通常很少使用。

多态与虚函数
继承的一个主要目的是实现多态,即通过基类指针或引用调用派生类的特定方法。在C++中,多态不是默认行为,需要通过虚函数实现。


默认情况下,如果在派生类中重新定义基类的函数,它会隐藏基类版本,而不是覆盖。要实现多态,必须在基类中将函数声明为virtual,并在派生类中使用override关键字。
以下是一个示例:
class Base {
public:
virtual void foo() { std::cout << "Base::foo\n"; }
};
class Derived : public Base {
public:
void foo() override { std::cout << "Derived::foo\n"; }
};
现在,通过基类引用调用foo将根据实际对象类型调用正确的函数。
覆盖的规则
要成功覆盖虚函数,派生类中的函数必须与基类虚函数具有相同的名称、参数列表和限定符(如const)。返回类型必须相同或是协变的。
使用override关键字至关重要,因为它能让编译器检查你是否正确覆盖了基类的虚函数,避免因拼写错误或参数不匹配而意外创建新函数。


抽象类与纯虚函数
可以将虚函数声明为纯虚函数,从而使类成为抽象类。抽象类不能实例化,但可以用作基类来定义接口。
声明纯虚函数的语法如下:
class AbstractBase {
public:
virtual void pureVirtual() = 0; // 纯虚函数
};

派生类必须覆盖所有纯虚函数才能成为具体类。



多态类的注意事项



使用多态类时需要注意以下几点:




- 构造函数和析构函数中的虚函数:在构造函数和析构函数中调用虚函数时,不会进行动态绑定,而是调用当前类(正在构造或析构的类)的版本。
- 虚析构函数:如果一个类打算作为多态基类使用(即通过基类指针删除派生类对象),其析构函数必须是虚的。否则,通过基类指针删除派生类对象会导致未定义行为。
- 对象切片:将派生类对象赋值给基类对象(而非指针或引用)时,派生类特有的部分会被“切掉”,只保留基类部分。这通常不是期望的行为。因此,对于多态类型,通常应禁用拷贝和移动操作(使用
= delete)。 - 克隆模式:如果需要复制多态对象,可以实现一个虚的
clone方法,它在派生类中返回一个指向新副本的指针(通常包装在std::unique_ptr中)。

动态类型转换
dynamic_cast用于在继承层次结构中进行安全的向下转型。它需要运行时类型信息(RTTI),因此只适用于具有虚函数的类。

对于指针类型,如果转型失败,dynamic_cast返回nullptr。对于引用类型,如果转型失败,则抛出std::bad_cast异常。


虚函数的实现机制



大多数编译器使用虚函数表(vtable)来实现动态绑定。每个多态类都有一个vtable,其中存储了指向其虚函数的指针。每个多态类的对象都包含一个指向其vtable的指针(vptr)。调用虚函数时,通过vptr找到vtable,再通过vtable中的函数指针调用正确的函数。




这种机制带来了少量开销:
- 内存开销:每个对象需要一个额外的vptr(通常8字节),每个类需要一个vtable。
- 运行时开销:每次虚函数调用需要两次间接寻址(通过vptr和函数指针)。
其他要点
final关键字:可以用于类(防止进一步派生)或虚函数(防止在派生类中被覆盖)。std::enable_shared_from_this:如果一个类的方法需要返回指向自身的std::shared_ptr,该类应继承自std::enable_shared_from_this,并使用shared_from_this()方法。- 组合优于继承:在设计中,应优先考虑组合(“有一个”关系),而不是继承(“是一个”关系)。继承应仅用于建立真正的子类型关系。

总结

本节课中我们一起学习了C++中继承与多态的核心概念。我们了解了如何通过继承建立类之间的关系,如何使用虚函数和override关键字实现多态行为。我们还探讨了抽象类、对象切片、虚析构函数的重要性以及克隆模式。最后,我们简要了解了虚函数的实现机制和一些设计原则,如优先使用组合。掌握这些概念对于设计和实现灵活、可扩展的面向对象程序至关重要。
008:模板与泛型编程 🧩
在本节课中,我们将要学习C++中一个非常强大的特性:模板与泛型编程。模板允许我们编写与类型无关的代码,从而实现代码的复用和抽象。我们将从模板的基本概念开始,逐步深入到其工作原理、参数类型、实例化过程以及一些高级用法。
概述 📋
泛型编程的核心思想是编写不依赖于特定数据类型的代码。例如,一个用于交换两个值的函数,无论这两个值是整数、浮点数还是自定义类对象,其逻辑都是相同的。模板机制使得我们可以只编写一次这样的逻辑,然后让编译器为不同的类型生成具体的代码。
上一节我们介绍了运算符重载等面向对象特性,本节中我们来看看如何利用模板实现更通用的编程。
模板基础
模板是C++支持泛型编程的基础。通过模板,我们可以定义函数或类,其中的某些类型或值是参数化的。
函数模板
一个函数模板就像一个蓝图,编译器根据这个蓝图和实际使用的类型来生成具体的函数。
以下是一个简单的函数模板示例,它交换两个值:
template <typename T>
void swap(T& a, T& b) {
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
在这个例子中,typename T(或等价的 class T)声明了一个类型模板参数。当调用 swap(x, y) 时,编译器会推断 T 的具体类型(例如 int、double 或 MyClass),并生成一个处理该类型的 swap 函数。
类模板
类模板允许我们定义可以处理多种数据类型的类。标准库中的 std::vector 就是一个经典的类模板。
以下是一个简化版固定大小数组的类模板示例:
template <typename T, std::size_t N>
class FixedArray {
private:
T data[N];
public:
T& operator[](std::size_t index) {
// 边界检查(此处省略)
return data[index];
}
// ... 其他成员函数
};
这个 FixedArray 类模板有两个参数:一个类型参数 T 和一个非类型参数 N(数组大小)。
模板参数类型
模板参数主要有三种类型。
类型参数
这是最常见的模板参数,使用 typename 或 class 关键字引入。它代表一个尚未指定的数据类型。
template <typename T> // T 是一个类型参数
非类型参数
非类型参数是值,而不是类型。它们必须是编译时常量表达式,例如整数、枚举、指针或引用。
template <int N> // N 是一个非类型整数参数
template <typename T, T Value> // Value 是一个类型为T的非类型参数
一个常见的用例是指定容器的大小,如上面的 FixedArray 示例。
模板模板参数
这种参数本身是一个模板。它相对少见,但在构建高级的泛型库时可能用到。
template <template <typename> class Container>
class Adapter {
Container<int> storage; // 使用传入的模板容器来存储int
};
模板实例化
模板本身不是完整的代码。只有当代码中使用了模板,并为所有模板参数提供了具体参数(或编译器能推导出参数)时,编译器才会生成具体的代码,这个过程称为实例化。
隐式实例化
当代码中使用模板时,编译器会自动进行实例化。
FixedArray<int, 10> intArray; // 实例化 FixedArray<int, 10>
swap(a, b); // 实例化 swap<int> 或 swap<double> 等
显式实例化
我们可以强制编译器为特定的模板参数组合生成代码,这称为显式实例化。这在分离编译和构建库时很有用。
// 在头文件中声明模板
template <typename T> void myFunc(T t);
// 在源文件中进行显式实例化
template void myFunc<int>(int); // 显式实例化 int 版本
template void myFunc<double>(double); // 显式实例化 double 版本
重要特性:模板的实例化是惰性的。编译器只生成那些真正被用到的成员函数的代码。如果一个类模板的某个成员函数从未被调用,那么该函数就不会被实例化,即使其中包含语法错误也可能不会被发现。
模板代码的组织
由于模板需要在编译时看到完整的定义才能进行实例化,传统的将声明放在 .h 文件、定义放在 .cpp 文件的方法通常不适用于模板。
常见的做法是将模板的声明和定义都放在头文件中。这样,在任何使用该模板的编译单元中,编译器都能看到完整的定义并进行实例化。
另一种方法是使用显式实例化(如上所述),将定义放在 .cpp 文件中,并列出所有需要支持的类型。这种方法适用于已知且有限的类型集合,常用于库的发布。
模板实参推导
对于函数模板,编译器通常能够根据函数调用时传入的实参自动推导出模板参数的类型,这大大方便了使用。
template <typename T>
T max(T a, T b) {
return (a > b) ? a : b;
}
int x = 5, y = 10;
auto m = max(x, y); // 编译器推导出 T 为 int
然而,推导要求类型必须精确匹配或能进行标准转换。例如,max(5, 10.0) 会导致推导歧义(T 是 int 还是 double?),从而引发编译错误。解决方法是显式指定类型:max<double>(5, 10.0)。
对于类模板,在C++17之前,构造对象时必须显式指定模板参数。从C++17开始,引入了类模板实参推导,编译器可以根据构造函数参数来推导类模板参数。
std::pair p(1, 3.14); // C++17: 推导为 std::pair<int, double>
std::vector v{1, 2, 3}; // C++17: 推导为 std::vector<int>



特化与偏特化


有时,对于特定的类型或参数值,通用的模板实现可能不是最优的,甚至无法工作。这时,我们可以提供特化版本。
全特化
为模板的所有参数都指定具体的类型或值。
// 通用模板
template <typename T>
class MyContainer { /* 通用实现 */ };
// 对 T = bool 的全特化
template <>
class MyContainer<bool> { /* 针对bool的优化实现 */ };
偏特化
只为一部分模板参数指定具体类型,或者对参数施加一些限制(如指针类型)。
// 通用模板
template <typename T, typename Allocator>
class Vector { /* ... */ };
// 偏特化:当第二个参数是 SpecialAlloc 时的版本
template <typename T>
class Vector<T, SpecialAlloc> { /* ... */ };
// 偏特化:针对指针类型的版本
template <typename T>
class MyContainer<T*> { /* ... */ };
注意:函数模板不支持偏特化,但可以通过重载实现类似效果。
Lambda表达式与泛型
C++11引入的Lambda表达式也可以写成模板形式(从C++14开始支持泛型Lambda)。
// 泛型Lambda,参数类型为 auto
auto generic_adder = [](auto a, auto b) {
return a + b;
};
int sum_i = generic_adder(1, 2); // 调用 int 版本
double sum_d = generic_adder(1.5, 2.5); // 调用 double 版本
编译器会为每个不同的参数类型组合生成一个独立的闭包类型。这为编写简洁的泛型回调函数提供了极大便利。
总结 🎯
本节课中我们一起学习了C++模板与泛型编程的核心概念:
- 模板基础:使用
template关键字定义函数或类模板,实现类型无关的代码。 - 参数类型:模板参数可以是类型参数、非类型参数或模板模板参数。
- 实例化:模板在使用时由编译器生成具体代码,可以是隐式或显式实例化。
- 代码组织:模板定义通常需放在头文件中以确保编译器可见。
- 实参推导:编译器能自动推导函数模板参数,C++17后也支持类模板实参推导。
- 特化:可以为特定的模板参数提供定制化的实现,包括全特化和偏特化。
- 泛型Lambda:使用
auto参数的Lambda表达式,方便编写匿名泛型函数。

模板是C++强大抽象能力的基石,也是理解标准库(STL)工作原理的关键。掌握模板能帮助你编写出更灵活、高效和可复用的代码。
009:STL与迭代器 🚀
概述
在本节课中,我们将学习C++中的可变参数模板和编译时编程。这些高级特性允许我们编写更灵活、更高效的代码,特别是在处理未知数量的参数或在编译时进行计算时。
可变参数模板 📦
上一节我们介绍了模板的基本概念,本节中我们来看看可变参数模板。可变参数模板允许我们定义接受任意数量参数的模板。
定义可变参数模板
可变参数模板使用参数包,参数包可以接受零个或多个参数,用三个点(...)表示。
类模板示例:
template <typename... Ts>
class Tuple {};
函数模板示例:
template <typename... Ts>
void printTuple(const Tuple<Ts...>& tuple) {
// 实现细节
}
实现可变参数函数
实现可变参数函数通常使用尾递归或折叠表达式。




以下是使用尾递归的实现方法:
template <typename T>
void printElements(const T& head) {
std::cout << head << std::endl;
}
template <typename T, typename... Ts>
void printElements(const T& head, const Ts&... tail) {
std::cout << head;
if constexpr (sizeof...(tail) > 0) {
std::cout << ", ";
}
printElements(tail...);
}
折叠表达式
C++17引入了折叠表达式,使处理参数包变得更加简单。
求和函数示例:
template <typename R, typename... Args>
R reduceSum(const Args&... args) {
return (args + ...);
}
打印函数示例:
template <typename... Args>
void print(const Args&... args) {
(std::cout << ... << args) << std::endl;
}
编译时编程 ⚙️
上一节我们介绍了可变参数模板,本节中我们来看看编译时编程。编译时编程允许我们将计算从运行时转移到编译时,从而提高运行时性能。
常量表达式
常量表达式是在编译时可以求值的表达式,使用 constexpr 关键字标记。
常量表达式函数示例:
constexpr int square(int x) {
return x * x;
}
常量表达式变量示例:
constexpr int x = 7; // 常量表达式
const int y = square(5); // 常量表达式
编译时 if 语句
编译时 if 语句(if constexpr)允许在编译时根据条件选择代码路径。
示例:
template <typename T>
auto getValue(const T& value) {
if constexpr (std::is_pointer_v<T>) {
return *value;
} else {
return value;
}
}
模板元编程
模板元编程利用模板在编译时进行计算,是编译时编程的重要组成部分。
计算阶乘的示例:
template <int N>
struct Factorial {
static constexpr int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static constexpr int value = 1;
};
类型特征
类型特征允许我们在编译时查询类型的属性,是模板元编程的重要工具。
标准库类型特征示例:
std::is_integral_v<int>; // true
std::is_pointer_v<int*>; // true
SFINAE
SFINAE(替换失败不是错误)允许模板在替换失败时被忽略,而不是导致编译错误。
示例:
template <typename T>
typename T::value_type negate(const T& value) {
return -value.value();
}
int negate(int x) {
return -x;
}
概念(C++20)
C++20引入了概念,使模板约束更加清晰和易读。
示例:
template <std::integral T>
T add(T a, T b) {
return a + b;
}

总结
本节课中我们一起学习了可变参数模板和编译时编程。可变参数模板允许我们处理任意数量的参数,而编译时编程则通过将计算转移到编译时来提高运行时性能。这些高级特性是C++强大功能的重要组成部分,掌握它们将帮助我们编写更高效、更灵活的代码。
010:表达式模板与CRTP模式 🚀


在本节课中,我们将要学习两种强大的C++编译时编程技术:表达式模板 和 奇异递归模板模式。我们将探讨如何利用这些技术编写出既优雅又高效的代码,特别是在数值线性代数等高性能计算领域。

概述 📋


上一讲我们探讨了编译时编程的基础。本节中,我们来看看两个具体的、强大的应用实例:表达式模板 和 奇异递归模板模式。表达式模板允许我们以数学公式般优雅的语法编写代码,同时获得与底层C风格循环相媲美甚至更优的性能。CRTP模式则是一种实现静态多态性的技术,可以在没有虚函数开销的情况下实现多态行为。

表达式模板:优雅与性能兼得 ✨




数值线性代数(例如向量和矩阵运算)是科学计算、深度学习等领域的核心。一个典型的操作是计算两个缩放后向量的和:
z = c1 * x + c2 * y




传统C++方式的性能问题




在C++中,我们可以通过运算符重载让代码像数学公式一样优雅:



Vector z = c1 * x + c2 * y;

然而,这种直观写法的朴素实现(每个运算符都返回一个新的临时Vector对象)会导致严重的性能问题:
- 额外的内存分配:
c1*x和c2*y会创建两个临时向量,+操作可能再创建一个。对于大型向量(例如数十GB),这会耗尽GPU内存或显著增加内存压力。 - 缓存不友好:多次遍历数据,效率低下。
- 性能损失:与手写的、融合了所有操作的C风格循环相比,性能可能相差一个数量级(例如10倍)。





解决方案:惰性求值与表达式模板

核心思想是惰性求值。我们不立即执行每个运算符,而是将它们组合成一个表达式树,仅在最终需要结果(例如赋值给z)时,才一次性遍历数据并执行所有计算。


表达式模板是一种在编译时构建此表达式树的技术。它通过模板元编程,将用户写的c1 * x + c2 * y这样的表达式,在编译期转换为一个代表整个计算过程的类型。





以下是实现表达式模板的关键组件:


-
通用的
Expression类模板:它存储一个代表元素级运算的可调用对象(如lambda)和对其操作数的引用(打包在std::tuple中)。template <typename Callable, typename... Operands> class Expression { private: Callable callable_; std::tuple<const Operands&...> operands_; public: Expression(Callable callable, const Operands&... ops) : callable_(std::move(callable)), operands_(ops...) {} // ... 下标运算符稍后定义 };



-
重载运算符,返回表达式对象:运算符(如
+,*)不再进行计算,而是返回一个包装了相应运算和操作数的Expression对象。// 向量加法运算符 template <typename L, typename R> auto operator+(const L& lhs, const R& rhs) { auto callable = [](auto a, auto b) { return a + b; }; return Expression<decltype(callable), L, R>(callable, lhs, rhs); } // 标量乘法运算符 template <typename L, typename R> auto operator*(const L& lhs, const R& rhs) { auto callable = [](auto a, auto b) { return a * b; }; return Expression<decltype(callable), L, R>(callable, lhs, rhs); }当用户写下
c1 * x时,编译器会实例化并返回一个Expression<Lambda, double, Vector>类型的对象。

-
Vector类的赋值运算符:这是触发实际计算的“求值”点。它接受任何Expression类型,并遍历向量索引,对每个索引i调用表达式的下标运算符来获取结果。class Vector { std::vector<double> data_; public: template <typename Expr> Vector& operator=(const Expr& expr) { for (size_t i = 0; i < data_.size(); ++i) { data_[i] = expr[i]; // 触发表达式求值 } return *this; } };



-
Expression的下标运算符:这是魔法发生的地方。当expr[i]被调用时,它需要解包操作数元组,对每个操作数获取其第i个元素(如果是向量或表达式)或直接返回值(如果是标量),然后将这些值传递给存储的callable_(如乘法lambda)进行计算。这里需要一个辅助函数
subscript_at,利用if constexpr和类型特征在编译时决定如何获取操作数的值:// 类型特征,判断是否为向量或表达式 template <typename T> struct is_vector_or_expr : std::false_type {}; template <> struct is_vector_or_expr<Vector> : std::true_type {}; template <typename Callable, typename... Ops> struct is_vector_or_expr<Expression<Callable, Ops...>> : std::true_type {}; // 辅助函数:获取操作数在索引i处的值 template <typename Operand> auto subscript_at(const Operand& op, size_t i) { if constexpr (is_vector_or_expr<Operand>::value) { return op[i]; // 向量或表达式:使用下标 } else { return op; // 标量:直接返回值 } } // Expression类的下标运算符 auto operator[](size_t i) const { auto call_at_index = [this, i](const auto&... ops) { // 对每个操作数调用subscript_at,然后传递给callable_ return callable_(subscript_at(ops, i)...); }; return std::apply(call_at_index, operands_); }




优势与总结


通过表达式模板,Vector z = c1 * x + c2 * y; 这行代码在编译期被转换为一个高效的、融合的循环,等价于:
for (size_t i = 0; i < n; ++i) {
z[i] = c1 * x[i] + c2 * y[i];
}
优势:
- 零额外临时对象:只存储标量和引用,内存效率与C风格代码相同。
- 单次遍历:数据只遍历一次,缓存友好。
- 性能提升点:在
Vector::operator=的循环中,可以方便地引入SIMD向量化、多线程并行甚至GPU内核调用,而用户代码无需任何更改。 - 语法优雅:保持了数学公式般的可读性。

注意事项:使用auto关键字接收表达式模板结果时需要小心,因为auto expr = c1 * x + c2 * y;只会存储表达式树,而不会进行计算。只有赋值给具体的Vector对象时才会求值。




Eigen和Boost.uBLAS等库都使用了表达式模板技术。C++26标准也将引入基础的线性代数支持。









奇异递归模板模式:静态多态 🔄




上一节我们介绍了如何利用编译时技术优化数值运算。本节中,我们来看看另一种模式——CRTP,它用于实现静态多态,以规避虚函数调用的运行时开销。





动态多态的开销


传统的多态通过虚函数和虚表实现。虽然灵活,但每次虚函数调用都涉及间接寻址,阻止了内联优化,在极端性能敏感的场景下可能成为瓶颈。




CRTP模式简介

奇异递归模板模式的核心形式是:一个类Derived公开继承自一个以Derived自身作为模板参数的基类模板Base<Derived>。




template <typename Derived>
class Base {
// ... 基类可以利用`Derived`类型
};


class Derived : public Base<Derived> {
// ...
};



这种“循环”引用让基类在编译时就知道派生类的类型。
实现静态多态

基类可以定义非虚方法,这些方法通过static_cast将this指针转换为派生类类型,然后调用派生类的方法。



template <typename Derived>
class Amount {
public:
double getValue() const {
// 在编译时向下转型为Derived并调用其getValue
return static_cast<const Derived&>(*this).getValue();
}
};




class Constant42 : public Amount<Constant42> {
public:
double getValue() const { return 42.0; }
};

class Variable : public Amount<Variable> {
double value_;
public:
Variable(double v) : value_(v) {}
double getValue() const { return value_; }
};




// 使用模板函数实现“多态”调用
template <typename T> // T 被约束为继承自Amount<T>
void printValue(const Amount<T>& amount) {
std::cout << amount.getValue() << std::endl;
}
// 调用 printValue(Constant42{}) 或 printValue(Variable{43})

当amount.getValue()被调用时,它内联了Derived::getValue()的调用,没有虚函数开销。


C++20概念的简化
使用C++20的概念,可以更清晰地表达约束,而无需使用继承链:


template <typename T>
concept HasValue = requires(const T& t) {
{ t.getValue() } -> std::convertible_to<double>;
};






template <HasValue T>
void printValue(const T& obj) {
std::cout << obj.getValue() << std::endl;
}
// Constant42和Variable只需实现getValue,无需继承自特定基类。



应用:多态克隆


CRTP一个经典应用是实现“多态克隆”。在多态继承体系中,由于对象可能通过基类指针引用,直接拷贝会导致切片问题。常见的做法是禁止拷贝,并提供一个虚的clone方法。但使用std::unique_ptr等智能指针时,协变返回类型不再适用。

以下是利用CRTP实现多态克隆的框架:



- 定义可克隆接口:
class Cloneable { public: virtual ~Cloneable() = default; virtual std::unique_ptr<Cloneable> cloneImpl() const = 0; std::unique_ptr<Cloneable> clone() const { return cloneImpl(); } };

- 使用CRTP的辅助类:
template <typename Derived, typename Base = Cloneable> class CloneInherit : public Base { public: std::unique_ptr<Derived> clone() const { // 调用派生类的cloneImpl(来自基类接口) return std::unique_ptr<Derived>( static_cast<Derived*>(this->cloneImpl().release()) ); } private: // 实现基类的纯虚函数,利用协变返回类型(返回原始指针) Cloneable* cloneImpl() const override { return new Derived(static_cast<const Derived&>(*this)); } };



- 派生类的定义:
class Concrete : public CloneInherit<Concrete> { // ... 私有拷贝构造函数 public: // 现在Concrete对象拥有一个返回unique_ptr<Concrete>的clone方法 };


这样,我们通过CRTP在编译时为每个派生类注入了正确的克隆实现,避免了手动重复编写克隆逻辑,同时安全地使用了智能指针管理内存。







总结 🎯



本节课中我们一起学习了两种高级的C++编译时编程范式:



- 表达式模板:通过编译期表达式树和惰性求值,将优雅的运算符重载语法转换为高效、融合的循环。这是高性能线性代数库的基石,允许无缝集成向量化、并行化和GPU计算。

- 奇异递归模板模式:通过让基类以派生类为模板参数,实现编译时多态,消除虚函数开销。我们看到了它在静态多态接口和多态克隆等场景下的应用。


这些技术体现了C++“零开销抽象”哲学的强大之处:你可以构建高级、易用的接口,同时不牺牲底层性能。虽然实现细节可能复杂,但它们通常被封装在库中,最终用户能享受到简洁和高效的双重好处。理解这些模式有助于你阅读高级库的源码,并在需要时自己构建高性能的抽象。
011:现代C++并行编程 🚀
概述
在本节课中,我们将学习C++中的并行编程基础。我们将探讨如何利用现代C++特性来创建和管理多个线程,并学习如何安全地协调它们之间的操作,以避免数据冲突和死锁。
并行编程简介
现代计算机硬件,从智能手机到数据中心服务器,都拥有多个处理核心。为了充分利用这些硬件的性能,我们需要编写能够同时执行多个任务的程序。C++本身并非专为并行编程设计,但自C++11标准以来,它引入了一系列特性来支持这一领域。
并行编程的核心挑战在于管理多个线程对共享内存的访问。当多个线程同时写入同一内存位置时,就会发生冲突。因此,我们需要同步这些访问,确保数据的一致性。
线程:并行执行的基本单元
上一节我们介绍了并行编程的背景,本节中我们来看看C++中并行执行的基本单元——线程。
自C++11起,标准库提供了 std::thread 类,它允许我们以平台无关的方式创建和管理线程。你可以通过传递一个可调用对象(如函数、Lambda表达式或函数对象)来创建一个新线程。
代码示例:创建线程
#include <thread>
#include <iostream>
void my_function() {
std::cout << "Hello from thread!\n";
}
int main() {
std::thread t(my_function); // 创建并启动线程
t.join(); // 等待线程结束
return 0;
}
线程对象本身不提供返回值机制,线程的计算结果需要通过共享变量来传递。此外,每个 std::thread 对象在其生命周期结束前必须被 join()(等待其完成)或 detach()(分离,让其独立运行)一次,否则程序将异常终止。
C++20引入了 std::jthread,它在析构时会自动调用 join(),使用起来更安全、更直观。
互斥锁:协调共享访问
我们知道了如何创建线程,但如何安全地让它们协作呢?本节中我们来看看最基本的同步原语——互斥锁。
互斥锁(Mutex)用于实现“互斥访问”。线程在访问共享资源前先锁定(lock)互斥锁,访问完成后解锁(unlock)。在此期间,其他试图锁定同一互斥锁的线程会被阻塞,直到锁被释放。
C++标准库提供了几种互斥锁:
std::mutex:基本的互斥锁。std::recursive_mutex:允许同一线程多次加锁,用于递归函数等场景。std::shared_mutex(C++17):支持共享读锁和独占写锁,适用于读多写少的场景。
代码示例:使用互斥锁保护控制台输出
#include <thread>
#include <mutex>
#include <iostream>
std::mutex print_mutex;
void safe_print(const std::string& msg) {
std::lock_guard<std::mutex> lock(print_mutex); // 自动加锁,离开作用域自动解锁
std::cout << msg << std::endl;
}
int main() {
std::thread t1([](){ safe_print("Hello from T1"); });
std::thread t2([](){ safe_print("Hello from T2"); });
t1.join();
t2.join();
return 0;
}
为了避免手动调用 lock() 和 unlock() 可能导致的错误(如忘记解锁),应优先使用RAII包装器:
std::lock_guard:简单的作用域锁。std::unique_lock:功能更丰富的锁,可延迟加锁、转移所有权。std::scoped_lock(C++17):可同时锁定多个互斥锁,避免死锁。



条件变量:基于事件的线程协调




互斥锁解决了数据访问的冲突,但线程间有时需要基于特定条件进行协调。本节中我们来看看条件变量。
条件变量允许一个或多个线程等待某个条件成立,而其他线程可以在条件成立时通知等待的线程。它通常与互斥锁一起使用。
核心工作流程:
- 等待线程:获取互斥锁 -> 检查条件 -> 若条件不满足,则在条件变量上等待(
wait),并原子地释放互斥锁 -> 被唤醒后重新获取锁并再次检查条件。 - 通知线程:修改共享状态 -> 通知(
notify_one或notify_all)条件变量。
代码示例:简单的任务队列
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <iostream>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> tasks;
bool finished = false;
void worker() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
// 等待条件:有任务或工作结束
cv.wait(lock, []{ return !tasks.empty() || finished; });
if (finished && tasks.empty()) break;
// 处理任务
int task = tasks.front();
tasks.pop();
lock.unlock(); // 提前解锁,让其他线程可以操作队列
std::cout << "Processing task: " << task << std::endl;
}
}
int main() {
std::thread t(worker);
// 主线程添加任务
{
std::lock_guard<std::mutex> lock(mtx);
tasks.push(1);
tasks.push(2);
}
cv.notify_one(); // 通知一个等待的线程
// ... 添加更多任务
{
std::lock_guard<std::mutex> lock(mtx);
finished = true;
}
cv.notify_all(); // 通知所有线程结束
t.join();
return 0;
}
原子操作:无锁同步
对于简单的共享变量(如计数器),使用互斥锁可能开销较大。本节中我们来看看更轻量级的同步工具——原子操作。
原子操作保证了对某个内存位置的读写是不可分割的,即在该操作执行期间,其他线程无法观察到中间状态。对于基本数据类型(如 int),原子操作通常由硬件直接支持,效率很高。
C++标准库提供了 std::atomic<T> 模板类。
代码示例:原子计数器
#include <thread>
#include <atomic>
#include <iostream>
#include <vector>
std::atomic<int> counter{0};
void increment(int n) {
for (int i = 0; i < n; ++i) {
counter.fetch_add(1, std::memory_order_relaxed); // 原子加1
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment, 1000);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter value: " << counter << std::endl; // 输出 10000
return 0;
}
std::atomic 为整数类型提供了 fetch_add, fetch_sub, ++, -- 等操作。对于泛型类型 T,主要支持 load(), store(), exchange(), compare_exchange_strong/weak() 等操作。
并行算法:标准库的并行化
手动管理线程和同步是复杂的。幸运的是,对于许多通用算法,C++17及以后的标准提供了并行执行策略。
许多STL算法(如 std::sort, std::for_each, std::reduce)现在接受一个额外的“执行策略”参数,指示算法可以并行或向量化执行。



可用的执行策略标签:
std::execution::seq:顺序执行(默认)。std::execution::par:允许并行执行。std::execution::par_unseq(C++17):允许并行和向量化执行。std::execution::unseq(C++20):允许向量化执行。
代码示例:并行排序
#include <algorithm>
#include <vector>
#include <execution>
int main() {
std::vector<int> data = {5, 3, 1, 4, 2};
// 使用并行策略进行排序
std::sort(std::execution::par, data.begin(), data.end());
// data 现在为 {1, 2, 3, 4, 5}
return 0;
}
重要注意事项:
- 异常安全:使用并行策略时,如果算法内部抛出异常,程序会调用
std::terminate()。 - 同步责任:算法本身不负责同步你传入的数据容器。如果容器可能被其他线程访问,你需要自行加锁。
- 编译器支持:此特性对编译器实现要求较高,支持程度不一,使用时需检查你的工具链。
其他并行工具与展望
除了上述核心机制,现代C++还提供了其他工具:
- 信号量 (
std::counting_semaphore):控制对有限数量资源的并发访问。 - 闩锁 (
std::latch) 和屏障 (std::barrier):用于协调多个线程在某个点同步。 - 协程 (C++20):支持可挂起和恢复的函数,为异步编程提供了新模型,但目前生态和支持尚在完善中。
在实际项目中,对于复杂的并行任务,我们通常会依赖更高级的库(如线程池、OpenMP、MPI或用于GPU计算的CUDA/OpenCL库),它们封装了底层的线程和同步细节。
总结
本节课中我们一起学习了现代C++并行编程的基础知识。我们从线程的创建和管理开始,探讨了如何使用互斥锁和条件变量来同步线程和协调它们的工作。接着,我们了解了轻量级的原子操作用于无锁同步,以及如何利用STL的并行算法来简化通用计算的并行化。最后,我们简要浏览了信号量、屏障等其他工具,并认识到在实际开发中,高级并行库的重要性。

掌握这些基础概念是理解和构建高效、正确并发C++程序的关键第一步。
012:多线程基础与语言演进 🧵


在本节课中,我们将学习C++20中的概念(Concepts)特性,并回顾C++语言的发展历程与未来展望。概念是一种强大的模板元编程工具,用于对模板参数施加约束,从而生成更清晰、更健壮的代码和更友好的错误信息。
概念基础 📚


上一节我们介绍了课程概述,本节中我们来看看C++20概念的基本语法和用途。概念本质上是对模板参数施加要求的机制,它取代了过去的std::enable_if等技术,使代码意图更明确。



以下是使用概念的几种基本语法形式:




- 在模板声明后使用
requires子句template <typename T> requires std::integral<T> auto add(T a, T b) { return a + b; }

- 使用尾随
requires子句template <typename T> auto add(T a, T b) requires std::integral<T> { return a + b; }





- 将概念直接作为模板参数
template <std::integral T> auto add(T a, T b) { return a + b; }




- 使用受限的
auto(缩写函数模板)
注意:这种形式允许auto add(std::integral auto a, std::integral auto b) { return a + b; }a和b是不同的类型,只要它们都满足std::integral概念。

概念的高级用法 ⚙️

上一节我们介绍了概念的基本语法,本节中我们来看看概念更强大的功能,如重载、特化和组合。



概念可以用于函数重载和模板特化,使得编译器能根据类型满足的不同概念来选择不同的实现。


以下是使用概念进行函数重载的示例:



// 为前向迭代器定义
template <std::forward_iterator I>
void advance(I& iter, int n) { /* 前向移动逻辑 */ }
// 为双向迭代器定义
template <std::bidirectional_iterator I>
void advance(I& iter, int n) { /* 双向移动逻辑 */ }


// 为随机访问迭代器定义
template <std::random_access_iterator I>
void advance(I& iter, int n) { iter += n; } // 直接跳转

你还可以组合多个概念,并使用逻辑运算符。



以下是组合概念的几种方式:




- 在
requires子句中使用逻辑与template <typename I, typename V> requires std::input_iterator<I> && std::equality_comparable_with<std::iter_value_t<I>, V> bool find(I begin, I end, V value) { ... } - 使用嵌套的
requires子句添加额外约束template <std::input_iterator I> requires requires (I iter) { { *iter } -> std::equality_comparable; } bool find(I begin, I end, auto value) { ... }




定义自定义概念 🛠️
上一节我们学习了如何使用现有概念,本节中我们来看看如何定义自己的概念。你可以基于类型特征(type traits)或要求表达式(requires expressions)来创建概念。
使用concept关键字可以定义新概念。
以下是基于类型特征定义概念的示例:
// 基于类型特征定义“整型”概念
template <typename T>
concept Integral = std::is_integral_v<T>;


// 基于现有概念定义“有符号整型”概念
template <typename T>
concept SignedIntegral = Integral<T> && std::is_signed_v<T>;
更强大的是使用要求表达式,它可以检查类型是否支持特定的语法操作。

要求表达式有几种形式:

- 简单要求:检查表达式是否有效。
template <typename T> concept Addable = requires (T a, T b) { a + b; // 要求类型T的对象可以使用+运算符 }; - 类型要求:检查嵌套类型是否存在。
template <typename T> concept HasValueType = requires { typename T::value_type; // 要求类型T有名为value_type的嵌套类型 }; - 复合要求:检查表达式并对其返回类型施加约束。
template <typename T> concept EqualityComparable = requires (T a, T b) { { a == b } -> std::convertible_to<bool>; // 要求==操作的结果可转换为bool { a != b } -> std::convertible_to<bool>; };

C++标准演进与未来 🚀
上一节我们深入探讨了概念,本节中我们将视角放宽,回顾C++标准的演进历程并展望未来。了解语言的发展方向有助于我们把握现代C++编程的最佳实践。


C++大约每三年发布一个新标准。C++11是一次重大革新,C++14和C++17是增量更新,而C++20则引入了模块、概念、协程等重大特性。C++23是一个中型修订版,包含了一些改进和新工具。

对于C++26,委员会正在规划一些重要特性。
以下是预计可能进入C++26的特性:

- 线性代数库:提供标准的矩阵和向量运算支持。
- 反射:允许代码在编译时检查自身结构,简化绑定生成等任务。
- 执行器(Executors)和网络库:为异步和网络编程提供标准化支持。
- 安全剖面:响应行业对内存安全等问题的关注,定义更安全的C++子集。
总结与建议 📝
本节课我们一起学习了C++20的核心特性之一——概念,并回顾了C++语言的演进。概念通过为模板参数提供清晰的约束,极大地改善了泛型编程的体验,带来了更好的代码可读性和更友好的编译器错误信息。
对于想要继续提升C++技能的同学,最好的建议是动手实践。可以通过参与开源项目、完成个人玩具项目、实习或毕业设计来应用所学知识。同时,保持对C++新标准(如C++23/26)特性的关注,并利用Compiler Explorer等工具进行实验。

最后,在职业发展上,保持开放心态,选择让自己有热情和成就感的方向。无论是继续深造还是进入业界,持续学习和实践都是关键。祝大家在未来的学习和职业生涯中一切顺利!

浙公网安备 33010602011771号