UNSW-COMP6771-高级-C---笔记-全-

UNSW COMP6771 高级 C++ 笔记(全)

001:课程概述与环境设置 🚀

在本节课中,我们将要学习COMP6771课程的整体框架、C++语言的核心设计理念,以及如何为后续学习设置开发环境。课程将从宏观介绍开始,逐步深入到具体的技术细节。

课程概述

大家好,欢迎来到COMP6771课程的第一周。我是Hayden,负责本课程的教学与管理工作。课程规模较小,因此大部分行政事务和答疑将由我直接处理。我们可能还会有一些客座讲师和助教团队来协助教学。

以下是本课程的核心目标:

  • 掌握使用C++20标准编写程序。
  • 学习使用C++20的相关库。
  • 了解并应用测试框架和软件工程工具。
  • 在C++语境下深入学习面向对象编程和泛型编程。

什么是C++?

C++是一种轻量级抽象编程语言。它比C语言更高级,隐藏了部分机器细节,但相比Java或Python,它提供的抽象更为轻量。C++可以包含类似Java的垃圾回收机制元素,但并非强制要求。

C++比C语言复杂得多,它拥有更庞大的库、面向对象能力以及运算符重载等特性。然而,得益于这些抽象,C++代码本身通常比C代码更简洁。需要明确的是,虽然C++向后兼容C(即任何C程序都可以用C++编译器编译),但C++是一门独立的语言。在本课程中,我们将以全新的视角学习C++,并避免编写C风格的代码。

C++的设计支柱主要有两点:

  1. 追求极致性能:C++的设计理念是,在它和汇编语言之间不应存在性能更优的语言。这意味着当你需要极高的性能时,除了直接使用汇编,C++是最佳选择。
  2. 零开销抽象:C++致力于提供高级抽象(如容器),同时保证这些抽象带来的运行时开销尽可能小,甚至为零。这与Java或Python中某些抽象带来的额外开销(如边界检查)形成对比。

C++的应用场景

C++因其兼具高性能和强大的表达能力而被广泛应用。可以将它类比为碳纤维材料:轻量且坚固,但并非所有场景都是最佳选择。

  • 高性能需求:当软件对性能有极高要求时,例如游戏引擎、高频交易系统。
  • 复杂系统:需要直接与硬件交互或构建底层复杂系统时,例如操作系统、浏览器引擎(如Chrome的Blink)、数据库。
  • 平衡点:在需要比C语言更强的表达能力和工程化支持,同时又比Python/Java等语言更高性能的场合。许多大型桌面应用(如Adobe Photoshop、微软Office套件)也使用C++开发。

学习资源与帮助渠道

以下是本课程的主要学习与求助途径:

  • 核心参考cppreference.com 是C++语言的权威在线文档,相当于语言的官方手册,课程中将频繁使用。
  • 课程论坛:这是提问的首选渠道。所有课程相关问题都应发布在论坛上,便于集中管理和快速回复。
  • 邮件联系:如需邮件沟通,请发送至课程邮箱 cs6771@cse.unsw.edu.au,而非讲师个人邮箱,这有助于高效分类和处理问题。
  • 助教:课程配备了经验丰富的助教团队,他们也会在论坛上提供帮助。

课程结构与评估

本课程主要通过GitLab进行教学和管理。评估方式如下:

  • 作业(70%):共有三次作业。作业1将于近期发布。作业不仅要求编写C++代码,还涉及编写测试用例以及使用Git进行版本控制。
  • 期末考试(30%):在线进行,无“双及格”要求。最终成绩可能会根据考试情况进行调整。
  • 学术诚信:请注意,C++代码有先进的查重工具。任何抄袭行为都将受到严肃处理。

对于不熟悉Git的同学,课程提供了“Lab 0”作为入门指南。熟悉Git的同学可以跳过。

环境设置与后续安排

课程鼓励大家在自己的电脑上设置开发环境。我们将使用现代C++(C++20)进行开发。

关于作业1的具体发布时间,请注意课程公告。我们计划尽早发布,以便大家能快速进入学习状态并获取早期反馈。

最后,希望大家共同营造一个包容、互相尊重的学习环境。计算机科学领域需要大家的共同努力来保持友好和协作的氛围。

本节课中,我们一起学习了COMP6771课程的总体介绍、C++语言的设计哲学与应用领域,并了解了课程的学习资源、评估方式以及初步的环境设置方向。从下一讲开始,我们将正式进入C++20语言特性的学习。

002:环境设置 🛠️

在本节课中,我们将学习如何为COMP6771课程设置C++开发环境。我们将从最基本的编译开始,逐步介绍如何使用Gitlab、CMake构建系统以及VS Code编辑器来编写、构建和测试C++程序。

概述

设置开发环境通常是学习新课程时最大的挑战之一。本节内容专门设计来帮助你快速上手。我们将熟悉Gitlab、学习C++编译的基础知识、构建第一个程序并进行测试。虽然C++环境设置并非通用,但本教程将针对本课程(COMP6771)的具体要求进行说明。

基础编译:从C到C++

上一节我们介绍了课程概况,本节中我们来看看如何编译一个简单的C++程序。C++的编译过程与C语言非常相似。

首先,我们创建一个简单的C++文件。以下是一个基础示例:

#include <iostream>

int main() {
    std::cout << "Hello world\n";
    std::cout << "hello";
    return 0;
}

你可以观察到它与C语言的两个主要区别:

  1. 头文件从 #include <stdio.h> 变为了 #include <iostream>iostream是C++的输入输出流库。
  2. 输出语句从 printf 变为了 std::cout <<。这种语法用于在C++中打印内容。

现在,我们来编译这个程序。编译C++程序与编译C程序类似,但使用的是 g++ 编译器。

g++ -o out file.cpp

这个命令会将 file.cpp 编译成一个名为 out 的可执行文件。运行该程序:

./out

程序将输出“Hello world”和“hello”。C++向后兼容C语言,这意味着你也可以使用 g++ 编译器来编译纯C代码。

多文件编译与构建系统的必要性

当程序变得复杂,涉及多个文件时,手动编译会变得繁琐。例如,如果你有 main.cppage.cppage.h 三个文件,编译命令会变长:

g++ -o out main.cpp age.cpp

在大型项目中,可能有成百上千个文件,管理依赖关系和编译顺序将非常困难。虽然可以使用Makefile,但它扩展性不佳。因此,现代C++项目通常使用构建系统,例如我们课程中使用的 CMake。构建系统可以自动化处理多文件编译、库依赖(如测试框架)等复杂任务。

课程环境设置实战

为了让你能顺利开始课程实践,以下是设置COMP6771开发环境的步骤。我们推荐使用UNSW的Vlab系统。

第一步:克隆教程仓库
首先,你需要将教程代码克隆到本地。在终端中执行以下命令:

git clone <教程仓库的URL>
cd <仓库目录>

第二步:运行环境设置脚本
导航到克隆的仓库目录后,运行提供的bash脚本以安装必要组件:

bash scripts/setup.sh

此过程可能需要几分钟。完成后,关闭VS Code。

第三步:在VS Code中打开项目
使用以下命令在VS Code中打开当前项目文件夹:

code .

第四步:配置CMake工具链
在VS Code中按下 Ctrl+Shift+P,打开命令面板,然后输入并选择“CMake: Select a Kit”。从列表中选择标记为“comp6771”的工具链。

第五步:配置与重新加载项目

  1. 再次按下 Ctrl+Shift+P,输入“CMake: Configure”并执行,以配置项目。
  2. 配置完成后,需要重新加载窗口以使扩展生效。按下 Ctrl+Shift+P,输入“Developer: Reload Window”并执行。

至此,你的开发环境就设置完成了。这些步骤通常每个实验或作业只需要执行一次。

构建与运行你的第一个程序

环境设置好后,我们来构建并运行一个简单的C++程序。项目代码通常位于 source 目录下。

构建程序
在VS Code中,按下 Ctrl+Shift+P,输入“CMake: Build Target”并执行。在弹出的文本框中输入要构建的目标名称,例如 hello_exe(对应 source/hello.cpp 文件),然后按回车。终端会显示构建过程,成功后会提示“Build finished with exit code 0”。

运行程序
构建生成的可执行文件位于 build 目录下,其路径结构与源文件对应。要运行刚构建的 hello_exe,可以在终端中执行:

./build/source/hello_exe

添加新文件
如果你想添加一个新文件(如 hello2.cpp)并让其可被构建,需要更新 CMakeLists.txt 文件。在该文件中添加一行,例如:

add_executable(hello_exe2 source/hello2.cpp)

保存后,需要重新加载VS Code窗口(Ctrl+Shift+P -> “Reload Window”),之后就可以选择 hello_exe2 作为构建目标了。

测试框架简介

在C++中,我们使用专业的测试框架(如Catch2)来编写测试,而不是简单的 assert。以下是一个简单的测试用例示例:

#include <catch2/catch.hpp>
TEST_CASE("Addition works") {
    int a = 5;
    int b = 4;
    CHECK(a + b == 9);
}

构建与运行测试
测试文件在 CMakeLists.txt 中被定义为测试目标(例如使用 add_test)。你可以像构建普通程序一样构建测试目标(如 hello_test)。构建成功后,在终端运行:

./build/tests/hello_test

测试框架会执行所有 CHECK 断言并报告结果。在VS Code中,你也可以使用 Ctrl+Shift+P 并执行“Run Tests”命令来编译并运行所有测试。

总结

本节课中我们一起学习了COMP6771课程的C++开发环境设置。我们从最基础的 g++ 命令行编译开始,理解了多文件编译的复杂性,从而引入了CMake构建系统的必要性。我们一步步完成了在Vlab和VS Code中的环境配置,学习了如何构建、运行程序以及如何使用Catch2测试框架。掌握这个环境是完成本课程后续学习和作业的基础。虽然涉及一些新工具,但一旦设置完成,它们将大大简化代码管理和测试过程。

003:C++基础(第一部分)🚀

在本节课中,我们将要学习C++编程语言的基础知识,包括基本类型、auto关键字、常量以及表达式。这些概念是构建更复杂C++程序的基石。

基本类型

C++提供了多种基本数据类型,与C语言类似,但也有一些不同之处。以下是C++中的一些基本类型:

  • int:用于表示整数。
  • double:用于表示双精度浮点数。
  • char:用于表示单个字符。
  • bool:用于表示布尔值(真或假)。
  • std::string:用于表示字符串。这是C++标准库中的一个类,使用起来比C语言的字符数组更方便。

与Java类似,C++中的intdouble等是原始类型,而std::string这样的类型则是对象。这意味着你可以像在Java或Python中一样,在字符串对象上调用方法,例如.front()来获取第一个字符。

C++是一种贴近硬件的语言,因此像int这样的类型大小可能因机器架构(如32位或64位)而异。你可以使用std::numeric_limits来查询你机器上特定类型的最大值和最小值。

std::是作用域解析操作符,它表示后面的名称(如string)位于标准(std)命名空间内。我们将在后续课程中更详细地讨论命名空间。

auto 关键字

上一节我们介绍了基本类型,本节中我们来看看auto关键字,它是C++11引入的一个重要特性。

auto关键字允许编译器根据初始化表达式自动推导变量的类型。这可以使代码更简洁,减少重复书写复杂类型名的需要。

使用auto时需要遵循以下规则:

  • auto只能在变量声明时使用。
  • 使用auto声明的变量必须立即进行初始化,因为编译器需要根据初始值来推导类型。

例如:

auto i = 0; // i 被推导为 int
auto j = 8.5; // j 被推导为 double
std::vector<int> f;
auto k = f; // k 被推导为 std::vector<int>

关于auto的一个重要注意事项是,默认的赋值操作是值拷贝。在上面的例子中,k = f会创建f的一个完整副本,修改k不会影响f,反之亦然。这与Java中对象赋值的引用语义不同。

使用auto是良好的编程实践,它不会带来运行时性能损失,因为类型推导发生在编译时。

常量 (const)

在C++中,const关键字用于声明一个值不可变的变量,即常量。

声明常量后,任何试图修改其值的操作都会导致编译错误。这有助于防止意外修改、向其他开发者明确意图,并且在某些情况下可能有助于编译器进行优化。

我们遵循“默认为常量”的原则:除非你确实需要更改变量的值,否则应将其声明为const。按照惯例,const通常写在类型说明符的右侧(称为“east const”风格)。

例如:

const auto meaning_of_life = 42; // 等价于 const int meaning_of_life = 42;
// meaning_of_life = 43; // 错误:无法修改常量

在多线程编程中,使用常量数据也更安全,因为它避免了共享可变状态带来的复杂性。

表达式

表达式是由值、变量、运算符和函数调用组成的代码单元,它们组合起来会产生一个新的值。

C++中的算术表达式与C语言非常相似,包括加法(+)、减法(-)、乘法(*)、除法(/)和取模(%)等操作。

对于浮点数运算,需要注意精度问题。直接比较两个浮点数(如double)是否完全相等可能不可靠,因为存在舍入误差。通常的做法是检查两个数的差值是否小于一个很小的阈值(称为epsilon或delta)。

例如,使用std::abs()函数计算绝对值来比较:

#include <cmath> // 需要包含此头文件以使用 std::abs
double a = 0.1 + 0.2;
double b = 0.3;
bool are_equal = std::abs(a - b) < 1e-10; // 检查差值是否非常小

对于字符串,你可以使用+运算符进行连接。对于布尔运算,除了传统的&&(与)、||(或)、!(非)运算符外,C++20还引入了更可读的andornot关键字,我们鼓励在课程中使用它们。

总结

本节课中我们一起学习了C++的基础知识。我们回顾了基本数据类型,认识了std::string这样的对象类型。我们深入了解了auto关键字如何简化变量声明,并理解了其默认的值拷贝语义。我们强调了使用const声明常量的重要性。最后,我们探讨了基本的表达式运算,包括整数、浮点数和字符串操作。掌握这些基础是进一步学习C++高级特性的关键。

004:C++基础(第二部分)🚀

在本节课中,我们将继续学习C++的基础知识,涵盖类型转换、函数、控制流、引用、标准库容器以及错误类型等核心概念。我们将通过简单的示例和清晰的解释,帮助你建立扎实的C++编程基础。

值语义与类型转换 🔄

上一节我们介绍了表达式,本节我们来看看C++中的值语义。值语义意味着对象在赋值和比较时,关注的是其值本身,而不是内存地址。这与Java等语言不同,在Java中,对象赋值通常传递的是引用。

例如,在C++中:

std::string s = "hello";
std::string s2 = s; // s2 是 s 的一个副本,不是同一个对象的引用
assert(s == s2); // 比较的是字符串的内容(值),而不是内存地址

接下来,我们探讨类型转换。C++提供了隐式和显式两种类型转换方式。

隐式类型转换

当从一种类型转换为另一种类型不会丢失精度时,编译器会自动进行隐式转换,也称为提升

以下是隐式类型转换的示例:

auto i = 42; // int 类型
auto d = 0.0; // double 类型
d = i; // 隐式将 int 转换为 double

显式类型转换

当转换可能丢失精度,或者你希望明确表达转换意图时,应使用显式类型转换。C++推荐使用 static_cast

以下是显式类型转换的示例:

auto i = 42;
auto d = static_cast<double>(i); // 显式将 int 转换为 double

需要注意的是,从大类型(如 int)转换为小类型(如 char)是有损转换,可能导致数据溢出或产生意外结果,使用时应格外小心。

函数 📞

函数是组织代码的基本单元。C++中的函数定义与其他语言类似。

以下是函数的基本语法示例:

// 传统风格(C风格)
int add(int a, int b) {
    return a + b;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_44.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_45.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_46.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_48.png)

// C++20 引入的新风格(返回类型后置)
auto add(int a, int b) -> int {
    return a + b;
}

在课程中,你可以选择任意一种风格,但请确保在代码中保持一致性。

默认参数

C++允许为函数参数指定默认值。调用函数时,可以省略这些具有默认值的参数。

以下是默认参数的示例:

std::string rgb(short r = 0, short g = 0, short b = 0) {
    // ... 函数体
}
rgb(); // 相当于 rgb(0, 0, 0)
rgb(255); // 相当于 rgb(255, 0, 0)

需要注意的是,一旦某个参数被赋予了默认值,它之后的所有参数也必须具有默认值。

函数重载

C++支持函数重载,即可以定义多个同名函数,只要它们的参数列表(类型、数量或顺序)不同即可。编译器会根据调用时提供的参数来决定使用哪个函数。

以下是函数重载的示例:

int square(int const x) { return x * x; }
double square(double const x) { return x * x; }
// 错误示例:仅返回值不同不能构成重载
// double square(int const x) { return x * x; }

重载函数时,应确保所有同名函数的行为语义一致,否则应使用不同的函数名。

控制流 🧭

C++提供了常见的控制流语句,如 ifswitch 和循环。

If 语句与三元运算符

if 语句用于条件判断。此外,C++也支持三元运算符 ? : 进行简化的条件赋值。

以下是控制流的示例:

// if 语句
if (condition) {
    // 代码块
} else {
    // 代码块
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_75.png)

// 三元运算符
int max = (a > b) ? a : b;

Switch 语句

switch 语句用于基于一个整型或枚举值进行多路分支。C++17引入了 [[fallthrough]] 属性,用于明确告知编译器故意不写 break 语句。

以下是 switch 语句的示例:

switch (value) {
    case 0:
        // 执行操作
        [[fallthrough]]; // 明确告知编译器继续执行下一个 case
    case 1:
        // 执行操作
        break;
    default:
        // 执行操作
}

序列容器:Vector 📦

std::vector 是C++中最常用的动态数组容器,类似于其他语言中的列表(List)或数组列表(ArrayList)。

以下是 vector 的基本操作示例:

#include <vector>
#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_83.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_85.png)

int main() {
    // 创建并初始化 vector
    std::vector<int> single_digits = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

    // 复制 vector
    std::vector<int> more_single_digits = single_digits;

    // 访问和修改元素
    more_single_digits[2] = 0;

    // 遍历 vector (使用范围for循环)
    for (auto const& digit : more_single_digits) {
        std::cout << digit << " ";
    }
    return 0;
}

引用 🎯

引用是C++中一个关键概念,它为一个已存在的对象提供了一个别名。引用主要用于函数参数传递,以实现按引用传递,避免不必要的拷贝,并允许函数修改外部变量。

引用基础

引用在声明时使用 & 符号。一旦引用被初始化指向某个对象,它就不能再指向其他对象。

以下是引用的基本示例:

int i = 5;
int& ref_i = i; // ref_i 是 i 的引用(别名)
ref_i = 10; // 修改 ref_i 实际上修改了 i
std::cout << i; // 输出 10

常量引用

如果不想通过引用修改原对象,可以使用常量引用const &)。常量引用常用于函数参数,以高效地传递大型对象,同时防止函数内部意外修改它。

以下是常量引用的示例:

void print_vector(std::vector<int> const& vec) {
    // 可以读取 vec,但不能修改它
    for (auto const& num : vec) {
        std::cout << num << " ";
    }
}

引用与函数参数

通过使用引用作为函数参数,我们可以实现经典的“交换两个数”功能,而无需使用指针。

以下是使用引用交换两个数的示例:

void swap(int& a, int& b) {
    int temp = a;
    a = b;
    b = temp;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_95.png)

int main() {
    int x = 5, y = 10;
    swap(x, y); // x 和 y 的值被交换
    return 0;
}

范围 For 循环与更多容器 🔄

范围 For 循环

C++11引入了范围for循环,可以更简洁地遍历容器(如 vector)中的所有元素。

以下是范围for循环的示例:

std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto const& element : vec) {
    std::cout << element << " ";
}
// 输出:1 2 3 4 5

无序集合与映射

C++标准库提供了基于哈希表实现的高效容器:std::unordered_set(集合)和 std::unordered_map(映射/字典)。

以下是 unordered_setunordered_map 的示例:

#include <unordered_set>
#include <unordered_map>
#include <string>

// 无序集合:存储唯一元素
std::unordered_set<std::string> names = {"Alice", "Bob"};
names.insert("Charlie");
if (names.find("Alice") != names.end()) {
    // 找到了 "Alice"
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/19b429d67b955be61d2c4d78f317efc9_109.png)

// 无序映射:键值对
std::unordered_map<std::string, int> age_map = {{"Alice", 30}, {"Bob", 25}};
age_map["Charlie"] = 28; // 插入或修改
int alice_age = age_map["Alice"]; // 访问

这些容器在平均情况下提供常数时间复杂度的查找和插入操作,非常适合需要快速查找的场景。

声明与定义 📝

在C++中,理解声明定义的区别非常重要:

  • 声明:告诉编译器某个名称(变量、函数、类)的存在及其类型。一个名称可以多次声明。
  • 定义:为声明的名称分配存储空间或提供具体实现。一个名称在同一个作用域内只能定义一次。

简单来说,定义是一种特殊的声明,它“实现”了所声明的实体。

错误类型 🚨

程序错误主要分为四类,理解它们有助于调试:

  1. 编译时错误:代码语法或类型错误,编译器无法生成目标文件。
  2. 链接时错误:各个编译好的模块(.o文件)在链接成可执行文件时出错,例如找不到某个函数的定义。
  3. 运行时错误:程序运行期间发生的错误,如除以零、文件不存在等,可能导致程序崩溃。
  4. 逻辑错误:程序可以编译和运行,但行为不符合预期。这是最隐蔽的错误,通常源于程序员的逻辑失误。

文件输入输出 📁

C++使用的概念来处理输入输出,包括文件操作。

以下是文件读写的基本示例:

#include <fstream>
#include <string>

// 写入文件
std::ofstream fout("output.txt");
if (fout) { // 检查文件是否成功打开
    fout << "Hello, File!" << std::endl;
    fout.close();
}

// 读取文件
std::ifstream fin("input.txt");
std::string line;
if (fin) {
    while (std::getline(fin, line)) { // 逐行读取
        std::cout << line << std::endl;
    }
    fin.close();
}

总结 🎉

本节课中我们一起学习了C++基础的第二部分。我们深入探讨了值语义、类型转换(隐式与显式)、函数的定义与重载、控制流语句、vector容器的使用、引用的概念及其在函数参数传递中的应用、范围for循环、以及unordered_setunordered_map等高效容器。此外,我们还了解了声明与定义的区别、不同类型的程序错误以及基本的文件I/O操作。

掌握这些基础知识是编写高效、正确C++程序的关键。在接下来的课程中,我们将基于这些概念,探索更高级的C++特性和编程技巧。

005:作业1 - 单词梯详解 🪜

在本节课中,我们将要学习COMP6771课程的第一个作业:构建一个“单词梯”(Word Ladder)程序。这是一个旨在帮助你熟悉C++标准库和基本算法的入门项目。

概述

作业1要求你使用C++构建一个单词梯查找程序。单词梯是一种文字游戏,你需要通过每次只改变一个字母的方式,将一个起始单词转换为一个目标单词,并且转换过程中的每一个中间单词都必须是有效的英文单词。例如,从 codedata 的一个可能路径是:code -> cade -> cate -> date -> data。本作业的核心是编写一个函数,在给定的词典中,找出两个单词之间所有最短的转换路径。

核心概念与算法

上一节我们介绍了单词梯的基本概念,本节中我们来看看其背后的核心算法。

从本质上讲,寻找单词梯是一个图搜索问题。我们可以将每个单词视为图中的一个节点。如果两个单词之间仅有一个字母不同,我们就在它们之间建立一条。这样,整个词典就构成了一张巨大的图。

因此,寻找从单词A到单词B的单词梯,就等价于在这张图中寻找从节点A到节点B的最短路径。由于所有边的权重相同(即每次只改变一个字母),我们可以使用广度优先搜索(BFS) 算法来高效地解决这个问题。BFS能够保证我们找到的路径是最短的。

以下是该问题的一个抽象描述:

// 核心函数签名
std::vector<std::vector<std::string>> generate(
    const std::string& start,
    const std::string& end,
    const std::unordered_set<std::string>& lexicon
);

该函数接收起始单词、结束单词和一个包含所有有效单词的词典(lexicon),返回所有最短的单词梯路径。

作业要求详解

理解了算法基础后,我们来看看完成作业需要满足的具体要求。

功能要求

  • 必须实现 generate 函数,进行广度优先搜索。
  • 必须进行环路检查,避免搜索陷入无限循环(例如 dog -> cog -> dog ...)。
  • 如果存在多条最短路径,必须返回所有解决方案。
  • 返回的解决方案必须按字典序排序。例如,所有路径应首先按第一个不同的单词排序,以此类推。

评分构成

作业占总成绩的15%,评分细则如下:

  • 50% - 正确性: 通过自动化测试评估你的代码输出是否正确。
  • 25% - 测试: 你需要使用 Catch2 框架编写测试用例。评分依据包括:
    • 测试正确性: 测试是否有效验证了代码功能。
    • 测试覆盖率: 是否涵盖了关键和边缘情况。
    • 黑盒测试: 测试应只通过 generate 等公共接口进行,不应依赖内部实现细节。
    • 清晰度: 代码注释和逻辑布局是否清晰。
  • 20% - C++最佳实践: 评估代码质量,例如正确使用 constauto、引用、避免C风格数组和裸指针、使用范围for循环等。
  • 5% - 代码风格: 使用 clang-format-11 工具自动格式化代码。只要代码能通过 clang-format 的检查,即可获得这部分的分数。

性能与提交

  • 时间限制: 每个 generate 函数调用必须在 15秒 内完成(在CSE机器上运行)。单词梯搜索是一个NP完全问题,对于某些困难的单词对,你需要优化算法(例如使用更高效的数据结构)来满足时间要求。
  • 内存: 虽然没有严格的硬性限制,但你的程序应合理使用内存(通常应远低于1GB)。
  • 截止日期: 第3周星期五晚上8点。迟交每小时扣总分的2%。
  • 学术诚信: 严禁抄袭,我们将使用查重软件进行检查。

项目结构与起步

现在我们对任务有了清晰的认识,接下来看看如何开始动手编码。

项目已经提供了基础结构,你需要关注以下关键文件:

  • src/word_ladder.cpp: 这是你需要主要实现 generate 函数的地方。
  • src/lexicon.cpp: 已提供辅助函数 read_lexicon,它负责从文件(如 english.txt)中读取词典并返回一个 std::unordered_set<std::string>,你可以直接使用。
  • src/debugging_main.cpp: 提供了一个简单的 main 函数,方便你在不运行完整测试套件的情况下快速测试和调试你的代码。
  • test/: 目录下存放你的 Catch2 测试文件。我们已经提供了一个示例测试 sample_test.cpp 和一个高难度的性能测试 benchmark.cpp
  • include/comp6771/word_ladder.hpp: 头文件,包含了函数的声明。

以下是起步建议:

  1. 仔细阅读 word_ladder.hpp 中的函数声明。
  2. word_ladder.cpp 中实现 generate 函数。
  3. 利用 debugging_main.cpp 进行初步的功能验证。
  4. test/ 目录下编写全面的测试用例,覆盖正常情况、边界情况和可能的错误。

总结

本节课中我们一起学习了COMP6771的第一次作业。我们了解了单词梯的概念,其本质是一个图的最短路径搜索问题,可以通过广度优先搜索(BFS) 算法解决。作业要求我们实现核心的 generate 函数,并关注正确性、测试覆盖、代码风格和性能。项目提供了良好的起始框架,包括词典读取和调试入口。请务必尽早开始,利用好提供的测试工具,并注意遵守C++的最佳实践。祝你编程愉快!

006:STL容器 🗃️

在本节课中,我们将要学习C++标准模板库(STL)中的容器。STL是C++编程的核心部分,它提供了一系列预构建的数据结构和算法,使我们无需从零开始编写。我们将了解不同类型的容器、它们的设计哲学以及如何有效地使用它们。

概述

STL是一个库集合,其设计哲学是:容器应存储数据,但无需理解算法如何操作它们;算法应能操作数据,但无需知道容器如何实现。这种抽象通过“迭代器”这一概念连接起来,它充当了容器和算法之间的通用接口。

上一节我们介绍了STL的基本概念,本节中我们来看看具体的容器类型及其使用方法。

迭代器简介

在深入容器之前,我们先简要了解迭代器。迭代器是抽象指针,指向容器中的某个位置。它允许我们遍历容器中的元素,而无需关心容器的底层实现。

以下是遍历数组的三种方法:

#include <array>
#include <iostream>

int main() {
    std::array<int, 3> ages = {18, 19, 20};

    // 方法1:C风格循环
    for (unsigned int i = 0; i < ages.size(); ++i) {
        std::cout << ages[i];
    }

    // 方法2:基于范围的for循环(推荐)
    for (int age : ages) {
        std::cout << age;
    }

    // 方法3:使用迭代器
    for (auto it = ages.begin(); it != ages.end(); ++it) {
        std::cout << *it;
    }
}

在上面的代码中,ages.begin() 返回指向第一个元素的迭代器,ages.end() 返回指向最后一个元素之后位置的迭代器。循环持续进行,直到迭代器 it 等于 end()

顺序容器

顺序容器以严格的线性排列方式组织对象。以下是STL中五种主要的顺序容器:

  1. std::vector:动态大小的数组。元素在内存中连续存储,支持快速随机访问。当空间不足时,它会自动重新分配更大的内存块。
    • 公式/代码std::vector<T> v;
  2. std::array:固定大小的数组。它是C风格数组的轻量级封装,大小在编译时已知。
    • 公式/代码std::array<T, N> arr;
  3. std::deque:双端队列。支持在头部和尾部进行高效的插入和删除。其实现通常使用一系列固定大小的数组,并非严格连续存储。
    • 公式/代码std::deque<T> dq;
  4. std::list:双向链表。支持在任何位置进行高效的插入和删除,但不支持快速随机访问。
    • 公式/代码std::list<T> lst;
  5. std::forward_list:单向链表。比 list 更节省空间,但只支持单向遍历。
    • 公式/代码std::forward_list<T> flst;

向量 (std::vector) 深入

向量是最常用的顺序容器。让我们看看它的一些特性:

#include <vector>
#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/290c3a372105c2e0532361e8593f92eb_35.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/290c3a372105c2e0532361e8593f92eb_37.png)

int main() {
    std::vector<int> v = {1, 2, 3};
    std::cout << "大小: " << v.size() << "\n";
    std::cout << "容量: " << v.capacity() << "\n"; // 已分配的内存空间

    v.push_back(5); // 添加元素,可能导致容量增加
    std::cout << "添加后容量: " << v.capacity() << "\n";

    // 访问元素:两种方式
    std::cout << "v[0]: " << v[0] << "\n";          // 无边界检查,访问越界导致未定义行为
    std::cout << "v.at(0): " << v.at(0) << "\n";    // 有边界检查,越界抛出 std::out_of_range 异常

    // 预分配空间以提高效率
    v.reserve(100);
    std::cout << "预留后容量: " << v.capacity() << "\n";
}

关键点:

  • size():当前元素数量。
  • capacity():已分配的内存可容纳的元素数量,通常 >= size()
  • push_back():在末尾添加元素,摊还常数时间复杂度
  • operator[]:快速访问,无检查。
  • at():安全访问,有检查。
  • reserve(n):预分配空间,避免多次重新分配。

有序关联容器

关联容器基于键来存储元素。有序关联容器中的元素按键排序。

以下是主要的有序关联容器:

  1. std::set:唯一键的集合,即每个键只出现一次。
    • 公式/代码std::set<Key> s;
  2. std::map:键值对集合,键唯一。
    • 公式/代码std::map<Key, T> m;
  3. std::multiset:键集合,允许重复键。
  4. std::multimap:键值对集合,允许重复键。

映射 (std::map) 示例

#include <map>
#include <string>
#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/290c3a372105c2e0532361e8593f92eb_56.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/290c3a372105c2e0532361e8593f92eb_57.png)

int main() {
    // 现代C++风格声明
    auto m = std::map<std::string, int>{};

    // 插入元素的几种方法
    // 1. 使用 std::pair
    m.insert(std::pair<std::string, int>{"bat", 1});

    // 2. 使用初始化列表(编译器自动转换为 pair)
    m.insert({"cat", 2});

    // 3. 使用 emplace(推荐,直接在容器内构造,避免拷贝)
    m.emplace("dot", 3);

    // 危险:使用 operator[] 访问不存在的键会插入该键(值为0)
    // std::cout << m["elephant"] << "\n";

    // 更安全:使用 find 或 C++20 的 contains
    auto it = m.find("cat");
    if (it != m.end()) {
        std::cout << "找到 cat: " << it->second << "\n";
    }

    // 遍历(按键排序输出)
    for (const auto& [key, value] : m) { // C++17 结构化绑定
        std::cout << key << ": " << value << "\n";
    }
}

注意:std::map 通常基于平衡二叉搜索树(如红黑树)实现,因此查找、插入和删除操作的时间复杂度为 O(log n)

无序关联容器

无序关联容器使用哈希表实现,不维护元素的顺序,但提供平均常数时间复杂度的查找。

以下是主要的无序关联容器:

  1. std::unordered_set:唯一键的哈希集合。
    • 公式/代码std::unordered_set<Key> us;
  2. std::unordered_map:键值对的哈希映射。
    • 公式/代码std::unordered_map<Key, T> um;
  3. std::unordered_multiset:允许重复键的哈希集合。
  4. std::unordered_multimap:允许重复键的哈希映射。

无序映射 (std::unordered_map) 的优势

当不需要元素顺序,但需要极快的查找速度时,应使用无序容器。

#include <unordered_map>
#include <string>
#include <iostream>

int main() {
    std::unordered_map<std::string, int> um = {{"apple", 5}, {"banana", 3}};

    // C++20 前,检查键是否存在
    if (um.find("apple") != um.end()) {
        std::cout << "苹果数量: " << um.at("apple") << "\n";
    }

    // C++20 起,可以使用 contains
    if (um.contains("banana")) {
        std::cout << "香蕉存在\n";
    }

    // 平均 O(1) 的查找速度
    um.emplace("orange", 10);
    std::cout << "橘子数量: " << um["orange"] << "\n"; // 注意:operator[] 若键不存在则会插入
}

容器性能考量

选择容器时,需要根据操作频率(如插入、删除、查找、随机访问)权衡性能。以下是一些通用指南:

  • 需要快速随机访问和尾部操作:使用 std::vector
  • 需要频繁在头部和尾部插入/删除:使用 std::deque
  • 需要频繁在任意位置插入/删除,且不需要随机访问:使用 std::list
  • 需要维护唯一键的排序集合:使用 std::setstd::map
  • 需要极快的查找速度,且不关心顺序:使用 std::unordered_setstd::unordered_map

实践中,std::vectorstd::unordered_map 可以解决大部分问题。

总结

本节课中我们一起学习了C++ STL的核心组成部分——容器。我们介绍了顺序容器(如 vector, list)、有序关联容器(如 map, set)和无序关联容器(如 unordered_map)。我们了解了它们的基本用法、性能特性以及如何根据具体需求选择合适的容器。记住,理解这些容器的底层原理和复杂度对于编写高效的C++程序至关重要。在接下来的课程中,我们将更深入地探讨连接容器和算法的关键——迭代器。

007:STL迭代器

在本节课中,我们将要学习C++标准模板库(STL)中的一个核心概念:迭代器。迭代器是连接容器与算法的桥梁,它提供了一种统一的方式来访问和遍历各种容器中的元素,无论这些容器在内存中是如何组织的。

上一节我们介绍了STL容器,本节中我们来看看如何以一种通用的方式访问这些容器中的元素。

迭代器的基本概念

迭代器是一个抽象的概念,它模拟了指针的行为。你可以将迭代器想象成一个“智能指针”,它知道如何在特定的容器中移动到下一个(或上一个)元素。

一个迭代器类型通常由容器提供。当你调用容器的 .begin() 方法时,你会得到一个指向容器第一个元素的迭代器。当你调用 .end() 方法时,你会得到一个指向容器“末尾之后”位置的迭代器(注意,它不指向最后一个元素,而是指向最后一个元素之后的位置)。

以下是一个使用迭代器遍历 std::vector 的基本示例:

std::vector<std::string> words = {"Hi", "how", "are", "you"};
for (auto it = words.begin(); it != words.end(); ++it) {
    std::cout << *it << std::endl; // 使用 * 操作符解引用迭代器,获取元素值
}

在这段代码中,it 是一个迭代器。++it 操作将其移动到下一个元素,*it 解引用它以获取当前元素的值。这个循环的逻辑与使用指针遍历数组完全相同。

迭代器的类型与操作

STL提供了多种类型的迭代器以适应不同的遍历需求。

以下是几种常见的迭代器获取方法:

  • .begin() / .end():获取指向容器起始和末尾之后的正向迭代器。
  • .rbegin() / .rend():获取反向迭代器。.rbegin() 指向最后一个元素,.rend() 指向第一个元素之前的位置。递增反向迭代器(++)会向容器的前端移动。
  • .cbegin() / .cend():获取常量迭代器。通过常量迭代器解引用得到的是 const 引用,不能用于修改容器中的元素。这有助于表达代码的意图并防止误操作。

尝试通过常量迭代器修改元素会导致编译错误:

std::vector<std::string> words = {"a", "b"};
for (auto it = words.cbegin(); it != words.cend(); ++it) {
    *it = "hello"; // 编译错误!不能给常量赋值
}

迭代器的优势与抽象

迭代器的核心优势在于其提供的抽象层。对于算法而言,它不需要知道底层是数组、链表还是树;它只需要一个能线性移动并访问元素的迭代器。

例如,无论是对 std::vector(连续内存)、std::list(链表)还是 std::map(平衡树),一个通用的查找算法都可以通过它们的迭代器工作。容器负责实现自己的迭代器,使其能够以线性的方式“走遍”所有元素(例如,std::map 的迭代器可能实现了中序遍历)。

迭代器在查找中的实际应用

迭代器的一个常见用途是与容器的 .find() 方法结合,进行高效的查找。

以下是几种在 std::map 中检查元素是否存在并获取其值的方法比较:

std::map<std::string, int> m = {{"cat", 10}, {"dog", 20}};
std::string key = "cat";

// 方法1:使用迭代器和 .find()
auto it = m.find(key); // 一次查找
if (it != m.end()) {
    // it 是一个指向 std::pair<const std::string, int> 的迭代器
    std::cout << "Value found via iterator: " << it->second << std::endl;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/6ddd54b810e188f793bbd6c0bf09a40e_49.png)

// 方法2:使用 .contains() (C++20) 和 .at()
if (m.contains(key)) { // 第一次查找
    std::cout << "Value found via contains/at: " << m.at(key) << std::endl; // 第二次查找
}

// 方法3(旧式):直接使用 .at() 并捕获异常
try {
    std::cout << "Value found via at (with exception): " << m.at(key) << std::endl;
} catch (const std::out_of_range& e) {
    std::cout << "Key not found." << std::endl;
}

使用 .find() 并返回迭代器通常是高效且灵活的做法,因为它只进行一次查找。如果找到了元素,迭代器可以直接用来访问或修改它,也可以用来进行后续的遍历(例如,找到该元素后面的所有元素)。而 .contains() 配合 .at() 需要进行两次独立的查找,在性能敏感的场合可能效率较低。直接使用 .at() 则依赖于异常处理,可能会影响程序的控制流。

流迭代器

迭代器的概念甚至扩展到了输入/输出流。你可以创建 std::istream_iterator 来从输入流(如文件或标准输入)中读取数据,就像遍历容器一样。

#include <iterator>
#include <fstream>

std::ifstream file("data.txt");
std::istream_iterator<int> input_begin(file);
std::istream_iterator<int> input_end; // 默认构造的迭代器代表“流结束”

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/6ddd54b810e188f793bbd6c0bf09a40e_55.png)

for (auto it = input_begin; it != input_end; ++it) {
    std::cout << *it << " "; // 从文件中读取并打印整数
}

本节课中我们一起学习了STL迭代器。我们了解了迭代器如何作为抽象指针,为各种容器提供统一的遍历接口。我们探讨了正向、反向和常量迭代器的区别,并通过实例看到了迭代器在查找操作中的实际应用和性能考量。最后,我们简要了解了迭代器概念如何扩展到I/O流。理解迭代器是有效使用STL算法(我们将在下一节讨论)的关键。

008:STL算法 🧮

概述

在本节课中,我们将要学习C++标准模板库(STL)中的算法。STL算法是一组通用的函数,它们基于迭代器的抽象概念来操作容器中的数据。我们将了解如何使用这些算法来简化代码、提高可读性,并避免编写重复的循环逻辑。


STL算法简介

上一节我们介绍了STL容器和迭代器,本节中我们来看看STL算法。STL算法本质上是一些函数,它们基于抽象的“指针”(即迭代器)概念来执行特定操作。例如,二分查找算法可以作用于任何提供了适当迭代器的数据结构上。

一个简单的例子是求和。假设我们有一个整数向量:

std::vector<int> nums = {1, 2, 3, 4, 5};

我们可以使用传统的循环来求和,但STL提供了 std::accumulate 算法:

int sum = std::accumulate(nums.begin(), nums.end(), 0);

std::accumulate 函数接受容器的起始和结束迭代器,以及一个初始值,然后对所有元素进行累加。


常用STL算法示例

以下是几个核心的STL算法及其用法。

1. 累加与归约

std::accumulate 是典型的归约算法。它默认使用加法操作符,但也可以接受自定义的二元操作符。

// 使用默认加法
int sum = std::accumulate(nums.begin(), nums.end(), 0);
// 使用乘法操作符
int product = std::accumulate(nums.begin(), nums.end(), 1, std::multiplies<int>());

2. 查找元素

std::find 算法用于在容器中查找特定值。

auto it = std::find(nums.begin(), nums.end(), 4);
if (it != nums.end()) {
    std::cout << "Found: " << *it << std::endl;
}

需要注意的是,对于像 std::map 这样的关联容器,使用其自身的 find 成员函数(如 map.find(key))通常比 std::find 更高效,因为成员函数可以利用容器的内部结构(如二叉搜索树)。

3. 计算距离与移动迭代器

std::distance 用于计算两个迭代器之间的元素数量。

auto dist = std::distance(nums.begin(), nums.end()); // 返回容器大小

std::nextstd::advance 用于移动迭代器。std::next 返回移动后的新迭代器而不修改原迭代器,而 std::advance 直接修改传入的迭代器。

auto mid = std::next(nums.begin(), std::distance(nums.begin(), nums.end()) / 2);

4. 变换与映射

std::transform 算法将一个容器的元素转换后存入另一个容器,类似于函数式编程中的 map 操作。

std::string s = "hello";
std::string upper;
std::transform(s.begin(), s.end(), std::back_inserter(upper), ::toupper);

5. 遍历与修改

std::for_each 算法对容器中的每个元素应用一个函数。

std::for_each(s.begin(), s.end(), [](char &c) { c = ::toupper(c); });


Lambda表达式与STL算法

Lambda表达式允许我们内联定义匿名函数,这在配合STL算法使用时非常方便。

Lambda的基本语法如下:

[capture](parameters) -> return_type { body }
  • 捕获列表 [capture]:指定哪些外部变量可以在Lambda体内使用(按值或按引用捕获)。
  • 参数列表 (parameters):与普通函数参数列表类似。
  • 返回类型 -> return_type:可选的返回类型声明。
  • 函数体 { body }:Lambda的执行代码。

以下是一个使用Lambda为向量每个元素加上一个值的例子:

void add_n(std::vector<int> &v, int n) {
    std::for_each(v.begin(), v.end(), [n](int &val) { val += n; });
}

在这个例子中,[n] 表示按值捕获外部变量 n。如果使用 [&n],则是按引用捕获,Lambda内部对 n 的修改会影响外部变量。


迭代器类别与算法约束

并非所有算法都适用于所有容器。算法对迭代器有不同的要求,而容器提供的迭代器能力也不同。主要迭代器类别包括:

  • 输入迭代器:只能向前读取。
  • 输出迭代器:只能向前写入。
  • 前向迭代器:可以多次向前读取/写入。
  • 双向迭代器:可以向前和向后移动。
  • 随机访问迭代器:可以任意跳转(如 vector 的迭代器)。

例如,std::advance 只需要输入迭代器,而 std::binary_search 在随机访问迭代器上具有对数复杂度,但在双向或前向迭代器上会退化为线性复杂度。


总结

本节课中我们一起学习了STL算法的核心概念。我们了解了如何使用 std::accumulatestd::findstd::transform 等通用算法来操作容器,从而编写出更简洁、更易读的代码。我们还探讨了Lambda表达式如何与这些算法结合,以提供灵活的操作逻辑。最后,我们简要介绍了迭代器类别对算法性能的影响。掌握这些工具将帮助你更高效地使用C++标准库。

009:类类型

概述

在本节课中,我们将要学习C++中的类类型。我们将探讨作用域规则、对象的生命周期、类的定义与使用、构造函数与析构函数、常量成员函数、静态成员以及如何控制编译器自动生成的成员函数。理解这些概念对于掌握C++面向对象编程至关重要。

作用域

上一节我们介绍了课程安排,本节中我们来看看作用域。

在C++中,当你声明一个变量或任何命名类型(如lambda、字符串对象或基本类型int)时,该变量的作用域通常从其定义点开始,直到下一个闭合花括号}结束。这个规则适用于函数、if语句、while语句、for语句以及匿名代码块。

与C语言风格不同,我们鼓励你在首次使用变量的地方附近定义它,而不是在代码块顶部声明所有变量。这样做可以避免变量拥有超出其所需范围的作用域,使程序更紧凑。

以下是关于作用域的几个关键点:

  • 变量名在其作用域内有效。
  • 相同的变量名可以在不同的作用域中重复使用。
  • 每次遇到一个开括号{,通常意味着一个新作用域的开始,该作用域在对应的闭括号}处结束。

对象与生命周期

上一节我们介绍了作用域,本节中我们来看看对象及其生命周期。

在C++中,几乎所有东西都可以被视为对象。从形式上讲,对象是一块具有特定类型的内存,该类型可以是intstringunordered_set等。对象生命周期指的是对象从进入作用域(被创建)到离开作用域(被销毁)的整个过程。

所有C++对象都有构造和析构的概念。构造函数在对象创建时被调用以进行初始化,析构函数在对象销毁时被调用以进行清理。即使是基本类型(如int),在某些上下文中也会被默认构造(例如设置为0)。

对象生命周期的价值在于资源管理。当对象离开其作用域时,其析构函数会被自动调用。对于管理资源的类(如动态分配内存的vector或打开文件的ifstream),析构函数可以确保资源被正确释放,从而避免内存泄漏或资源泄漏。这并非传统意义上的垃圾回收,而是通过对象的生命周期自动管理其拥有的资源。

命名空间

上一节我们讨论了对象生命周期,本节中我们简要了解一下命名空间。

命名空间提供了一种将全局作用域细分为不同命名范围的方法,用于组织代码并避免名称冲突。std就是一个标准的命名空间。

你可以使用namespace关键字定义命名空间,并使用范围解析运算符::来访问其中的成员。

关于using关键字:

  • using namespace xxx; 会将指定命名空间中的所有名称引入当前作用域,这可能导致名称污染和歧义,通常不推荐使用。
  • using xxx::yyy; 只引入特定名称,相对好一些,但在本课程中仍建议显式使用xxx::yyy的形式以保持清晰。

类的基础

上一节我们介绍了命名空间,本节中我们深入探讨C++类的核心概念。

类允许我们抽象复杂的代码,并提供清晰的接口(如std::vectorpush_backsize等方法),从而分离接口与实现。

类是一种新的类型,使用关键字classstruct定义。类由成员函数(方法)和数据成员(字段)组成。类定义中必须包含至少一个public:protected:private:访问说明符区域。

类定义示例

class MyClass {
 public:
  // 公有成员函数
  int get_val() const { return i_; }
  MyClass(int i) : i_{i} {} // 构造函数

 private:
  // 私有数据成员
  int i_;
};

类使用示例

auto mc = MyClass{1}; // 创建对象,调用构造函数
std::cout << mc.get_val(); // 调用公有成员函数
// mc.i_ = 5; // 错误:i_ 是私有成员

类定义与实现分离
通常,类声明(包含成员函数原型)放在头文件(.h)中,而成员函数的定义则放在源文件(.cpp)中。在类外定义成员函数时,需要使用类名和作用域解析运算符::

// person.h
class Person {
 public:
  Person(const std::string& name, int age);
  const std::string& get_name() const;
  const int& get_age() const;
 private:
  std::string name_;
  int age_;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/d918ba9e763f55bad3f71ae57e77662b_52.png)

// person.cpp
#include "person.h"
Person::Person(const std::string& name, int age) : name_{name}, age_{age} {}
const std::string& Person::get_name() const { return name_; }
const int& Person::get_age() const { return age_; }

构造函数详解

上一节我们介绍了类的基本结构,本节中我们详细学习构造函数。

构造函数的职责是初始化对象。其执行流程如下:

  1. 按类中声明的顺序,初始化初始化列表中的数据成员。
  2. 对于未在初始化列表中出现的、非内置类型的数据成员,进行默认构造。
  3. 执行构造函数体。

初始化列表
使用初始化列表(构造函数参数后的:开始的部分)直接初始化成员变量,效率更高。它可以避免不必要的默认构造和随后的赋值操作。

class MyClass {
 public:
  // 使用初始化列表
  MyClass(int i, int j) : i_{i}, j_{j} {}
  // 对比:在构造函数体内赋值(可能先默认构造,再赋值)
  // MyClass(int i, int j) { i_ = i; j_ = j; }
 private:
  int i_;
  int j_;
};

合成的默认构造函数
如果你没有为类定义任何构造函数,编译器会为你合成一个默认构造函数(无参)。但是,一旦你定义了任何构造函数(即使是有参的),编译器就不会再自动生成默认构造函数。此时,如果你需要默认构造函数,必须显式定义它。

委托构造函数
一个构造函数可以在其初始化列表中调用同一个类的另一个构造函数。

class B {
 public:
  B() : B{5} {} // 委托给 B(int) 构造函数
  explicit B(int val) : val_{val} {}
 private:
  int val_;
};

explicit 构造函数
如果构造函数只有一个参数,编译器允许进行从参数类型到该类类型的隐式转换。使用explicit关键字可以禁止这种隐式转换,要求必须显式调用构造函数。

class Age {
 public:
  explicit Age(int a) : age_{a} {} // 禁止隐式转换
 private:
  int age_;
};
// Age a1 = 20; // 错误:不能隐式转换
auto a2 = Age{20}; // 正确:显式构造

常量成员函数

上一节我们学习了构造函数,本节中我们来看看常量成员函数。

常量成员函数是指在函数参数列表后加上const关键字的成员函数。它们承诺不会修改对象的任何数据成员。

规则

  • 非常量对象可以调用常量成员函数和非常量成员函数。
  • 常量对象只能调用常量成员函数。

因此,在设计类时,所有不修改对象状态的成员函数都应该声明为const,以确保常量对象也能使用它们。

class Person {
 public:
  void set_name(const std::string& name) { name_ = name; } // 非常量函数
  const std::string& get_name() const { return name_; } // 常量函数
 private:
  std::string name_;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/d918ba9e763f55bad3f71ae57e77662b_76.png)

int main() {
  auto p1 = Person{"Hayden"};
  p1.set_name("Chris"); // 正确:非常量对象调用非常量函数
  std::cout << p1.get_name(); // 正确:非常量对象调用常量函数

  const auto p2 = Person{"Hayden"};
  // p2.set_name("Chris"); // 错误:常量对象不能调用非常量函数
  std::cout << p2.get_name(); // 正确:常量对象调用常量函数
}

静态成员

上一节我们介绍了常量成员函数,本节中我们来看看静态成员。

静态成员(包括静态数据成员和静态成员函数)属于类本身,而不是类的某个特定对象。它们具有全局生命周期,在程序开始时创建,程序结束时销毁。

静态成员函数不依赖于任何对象的数据成员,因此可以通过类名直接调用,而无需创建对象。

class User {
 public:
  static bool valid_name(const std::string& name) {
    // ... 验证逻辑,不访问非静态成员
    return true;
  }
};

// 调用静态成员函数
if (User::valid_name("Santa Claus")) {
  // ...
}

静态数据成员通常在类内声明,在类外定义和初始化(除非是constexpr)。

控制默认成员函数

上一节我们学习了静态成员,本节中我们来看看如何控制编译器自动生成的成员函数。

编译器会自动为类合成一些特殊的成员函数,如默认构造函数、拷贝构造函数等。你可以使用= default= delete来显式控制这些行为。

  • = default:显式请求编译器生成该函数的默认版本。
  • = delete:禁止编译器生成该函数,使得相应的操作(如拷贝)不可用。
class MyVector {
 public:
  // 显式请求编译器生成默认构造函数
  MyVector() = default;
  // 禁止拷贝构造函数
  MyVector(const MyVector& other) = delete;

  explicit MyVector(std::vector<int>::size_type length)
      : data_(length, 0) {}

 private:
  std::vector<int> data_;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/d918ba9e763f55bad3f71ae57e77662b_93.png)

MyVector v1; // 正确:使用默认构造函数
MyVector v2{v1}; // 错误:拷贝构造函数被删除

总结

本节课中我们一起学习了C++类类型的核心知识。我们探讨了作用域规则和对象生命周期,理解了如何通过构造函数和析构函数管理资源。我们学习了如何定义和使用类,包括分离声明与实现、使用初始化列表高效初始化成员。我们还了解了常量成员函数的用途、静态成员的特性,以及如何使用= default= delete来控制编译器自动生成的成员函数。掌握这些概念是进行有效C++面向对象编程的基础。

010:运算符重载 🧮

在本节课中,我们将要学习C++中一个非常强大且核心的特性:运算符重载。运算符重载允许我们为自定义类型(如类)定义运算符(如 +-<<)的行为,从而复用已有的、语义清晰的运算符,使代码更简洁、更易读。

为什么需要运算符重载?

上一节我们介绍了运算符重载的基本概念,本节中我们来看看它如何简化代码。考虑一个简单的 Point 类,它表示一个二维点,包含 xy 坐标。

如果没有运算符重载,我们可能需要定义一个静态函数来执行加法,并定义一个独立的函数来打印点:

class Point {
public:
    Point(int x, int y) : x_{x}, y_{y} {}
    int get_x() const { return x_; }
    int get_y() const { return y_; }
    static Point add(const Point& p1, const Point& p2) {
        return Point{p1.get_x() + p2.get_x(), p1.get_y() + p2.get_y()};
    }
private:
    int x_;
    int y_;
};

void print(std::ostream& os, const Point& p) {
    os << "(" << p.get_x() << ", " << p.get_y() << ")";
}

int main() {
    Point p1{1, 2};
    Point p2{3, 4};
    print(std::cout, Point::add(p1, p2)); // 输出 (4, 6)
}

这种方式虽然可行,但语法繁琐且不直观。用户需要知道 add 这个特定函数名,并且打印操作也显得冗长。

通过运算符重载,我们可以将代码简化为更自然的形式:

std::cout << p1 + p2 << std::endl;

这行代码的含义一目了然:打印 p1p2 的和。接下来,我们将学习如何实现这种简洁的语法。

运算符重载的基本原理

运算符重载本质上就是定义了一个特殊命名的函数。当编译器遇到像 p1 + p2 这样的表达式时,它会去寻找一个名为 operator+ 的函数,该函数接受两个 Point 类型的参数。

以下是实现上述简洁语法的 Point 类:

class Point {
public:
    Point(int x, int y) : x_{x}, y_{y} {}
    // 声明运算符重载函数
    friend Point operator+(const Point& lhs, const Point& rhs);
    friend std::ostream& operator<<(std::ostream& os, const Point& p);
private:
    int x_;
    int y_;
};

// 定义运算符重载函数
Point operator+(const Point& lhs, const Point& rhs) {
    return Point{lhs.x_ + rhs.x_, lhs.y_ + rhs.y_};
}

std::ostream& operator<<(std::ostream& os, const Point& p) {
    os << "(" << p.x_ << ", " << p.y_ << ")";
    return os; // 返回输出流以支持链式调用
}

int main() {
    Point p1{1, 2};
    Point p2{3, 4};
    std::cout << p1 + p2 << std::endl; // 输出 (4, 6)
}

现在,p1 + p2 会调用我们定义的 operator+ 函数,而 std::cout << ... 会调用我们定义的 operator<< 函数。代码的语义变得非常清晰。

friend 关键字与访问控制

在上面的例子中,我们使用了 friend 关键字。这是因为 operator+operator<<全局函数,而不是类的成员函数。它们需要访问 Point 类的私有成员 x_y_

friend 关键字的作用是:它允许一个全局函数(或另一个类)访问当前类的私有和保护成员。你可以将其理解为“授予朋友访问私人部分的权限”。

  • 何时需要 friend 当运算符重载函数是全局函数,并且需要直接访问类的私有数据成员时(例如,当类没有提供相应的公共接口时)。
  • 何时不需要 friend 当运算符重载函数是类的成员函数时(例如 +=),或者当它可以通过类的公共接口(如 get_x())完成工作时。

成员运算符 vs. 友元运算符

并非所有运算符重载都必须是友元函数。根据运算符的性质,它们可以分为成员运算符和友元(全局)运算符。

以下是常见的分类:

  • 成员运算符:这些运算符通常作用于对象自身,并可能修改对象的状态。

    • 赋值运算符:=, +=, -=, *=, /=
    • 下标运算符:[]
    • 递增/递减:++, --
    • 成员访问运算符:->, *(解引用)
    • 函数调用运算符:()
  • 友元(全局)运算符:这些运算符通常不修改参数,而是基于参数产生一个新值。它们通常需要访问私有成员。

    • 算术运算符:+, -, *, /
    • 关系与相等运算符:==, !=, <, >, <=, >=
    • 输入/输出流运算符:<<, >>

成员运算符示例:+=

+= 运算符修改左侧操作数,因此它非常适合作为成员函数。

class Point {
public:
    // ... 其他成员 ...
    Point& operator+=(const Point& rhs) { // 返回引用以支持链式赋值
        x_ += rhs.x_;
        y_ += rhs.y_;
        return *this; // 返回当前对象的引用
    }
private:
    int x_;
    int y_;
};

int main() {
    Point p1{1, 2};
    Point p2{3, 4};
    p1 += p2; // p1 现在变为 (4, 6)
    (p1 += p2) += p2; // 链式调用,p1 最终为 (7, 8)
}

注意 operator+= 返回 Point&(引用),并返回 *this。这模仿了内置类型的行为,允许链式赋值(如 a += b += c)。

利用已有运算符实现新运算符

我们可以利用已经定义好的成员运算符(如 +=)来实现对应的友元运算符(如 +),避免代码重复。

class Point {
public:
    // ... 其他成员和 operator+= ...
    friend Point operator+(const Point& lhs, const Point& rhs);
};

Point operator+(const Point& lhs, const Point& rhs) {
    Point result = lhs; // 拷贝构造 lhs
    result += rhs;      // 使用已定义的 operator+=
    return result;      // 返回新对象(不是引用)
}

这样,operator+ 的实现就简洁地复用了 operator+= 的逻辑。

关系与相等运算符

关系运算符(<, >, <=, >=)和相等运算符(==, !=)也通常是友元函数。一个常见的技巧是只定义最基本的运算符(如 ==<),然后基于它们定义其他运算符。

class Point {
public:
    // ... 其他成员 ...
    friend bool operator==(const Point& lhs, const Point& rhs);
    friend bool operator<(const Point& lhs, const Point& rhs);
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/52d03b547472f794a9e3ad7b22d66390_35.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/52d03b547472f794a9e3ad7b22d66390_37.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/52d03b547472f794a9e3ad7b22d66390_39.png)

bool operator==(const Point& lhs, const Point& rhs) {
    return lhs.x_ == rhs.x_ && lhs.y_ == rhs.y_;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/52d03b547472f794a9e3ad7b22d66390_41.png)

bool operator!=(const Point& lhs, const Point& rhs) {
    return !(lhs == rhs); // 基于 operator==
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/52d03b547472f794a9e3ad7b22d66390_43.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/52d03b547472f794a9e3ad7b22d66390_45.png)

bool operator<(const Point& lhs, const Point& rhs) {
    // 定义一种排序规则,例如先比较 x,再比较 y
    if (lhs.x_ != rhs.x_) return lhs.x_ < rhs.x_;
    return lhs.y_ < rhs.y_;
}

bool operator>(const Point& lhs, const Point& rhs) {
    return rhs < lhs; // 基于 operator<
}

bool operator<=(const Point& lhs, const Point& rhs) {
    return !(rhs < lhs); // 基于 operator<
}

bool operator>=(const Point& lhs, const Point& rhs) {
    return !(lhs < rhs); // 基于 operator<
}

需要特别注意的运算符

下标运算符 []

下标运算符必须是成员函数。一个关键点是,为了同时支持常量对象和非常量对象,我们通常需要提供两个版本。

class Point {
public:
    // ... 其他成员 ...
    int& operator[](int index) { // 非常量版本,返回引用,允许修改
        if (index == 0) return x_;
        if (index == 1) return y_;
        // 错误处理(简单示例)
        throw std::out_of_range("Index out of range");
    }
    const int& operator[](int index) const { // 常量版本,返回常量引用
        if (index == 0) return x_;
        if (index == 1) return y_;
        throw std::out_of_range("Index out of range");
    }
private:
    int x_;
    int y_;
};

int main() {
    Point p{1, 2};
    p[0] = 10; // 调用非常量版本,可以修改
    std::cout << p[0]; // 调用非常量版本

    const Point cp{3, 4};
    // cp[0] = 5; // 错误!调用常量版本,不能修改
    std::cout << cp[0]; // 正确,调用常量版本
}

递增和递减运算符 ++--

递增和递减运算符有前缀(++i)和后缀(i++)之分,它们必须被重载为成员函数。

  • 前缀版本operator++(),先递增,然后返回对象的引用。
  • 后缀版本operator++(int),接受一个哑元 int 参数以区分,先保存原值,然后递增,最后返回保存的原值(副本)。
class Counter {
public:
    Counter(int v = 0) : value_{v} {}
    // 前缀 ++
    Counter& operator++() {
        ++value_;
        return *this;
    }
    // 后缀 ++
    Counter operator++(int) {
        Counter old = *this; // 保存原值
        ++(*this);           // 使用前缀 ++ 递增
        return old;          // 返回原值(副本)
    }
    int get_value() const { return value_; }
private:
    int value_;
};

int main() {
    Counter c{5};
    Counter d = ++c; // d = 6, c = 6
    Counter e = c++; // e = 6, c = 7
}

后缀版本返回的是值(副本),而不是引用,因为返回的是局部对象 old 的副本。

类型转换运算符

类型转换运算符允许将类的对象隐式或显式地转换为其他类型。它被声明为成员函数,没有返回类型(返回类型就是转换的目标类型)。

class Point {
public:
    // ... 其他成员 ...
    // 将 Point 转换为 double(例如,计算到原点的距离)
    explicit operator double() const {
        return std::sqrt(x_ * x_ + y_ * y_);
    }
};

int main() {
    Point p{3, 4};
    // double dist = p; // 错误!因为转换运算符是 explicit 的
    double dist = static_cast<double>(p); // 正确,显式转换,dist = 5.0
}

使用 explicit 关键字可以防止隐式转换,避免意外的类型转换,使代码更安全。

运算符重载的设计准则

在结束之前,让我们回顾一些重要的设计准则:

  1. 保持直观性:只重载那些对自定义类型有明确、直观意义的运算符。如果 + 对你的类型意味着某种“连接”或“组合”,并且用户能轻易理解,那就重载它。否则,使用一个命名清晰的普通函数。
  2. 遵循惯例:尽量让重载的运算符行为与内置类型和标准库类型的行为一致。例如,operator+ 不应该修改其操作数,而应返回一个新值。
  3. 成对重载:某些运算符通常是成对出现的。如果你重载了 ==,通常也应该重载 !=。如果你重载了 <,可能也需要重载 ><=>=。利用已有运算符来实现它们可以减少错误。
  4. 注意返回值:赋值类运算符(=, +=)通常返回左侧操作数的引用(*this)以支持链式调用。算术运算符(+, -)通常返回一个新对象(值)。

总结

本节课中我们一起学习了C++运算符重载的核心概念。我们了解到:

  • 运算符重载通过定义名为 operator@ 的函数来实现,它能让自定义类型使用内置运算符,极大提升代码的可读性和简洁性。
  • 运算符重载函数可以是类的成员函数(如 +=, []),也可以是友元全局函数(如 +, <<, ==)。选择取决于运算符是否需要访问私有成员以及是否修改左侧操作数。
  • 我们探讨了多种运算符的重载方法,包括算术运算符、复合赋值运算符、关系运算符、下标运算符、递增/递减运算符以及类型转换运算符,并学习了如何利用已有运算符来简化新运算符的实现(如用 += 实现 +)。
  • 最后,我们强调了运算符重载的设计准则:保持语义直观、遵循语言惯例、注意返回值类型,并且只重载那些对类型有明确意义的运算符。

运算符重载是C++中实现抽象和编写优雅接口的强大工具,正确使用它能让你设计的类像内置类型一样自然易用。

011:异常处理 🚨

在本节课中,我们将要学习C++中的异常处理机制。异常是处理程序运行时意外错误的一种强大方式,它允许我们优雅地处理问题,而不是让程序直接崩溃。我们将涵盖抛出异常、捕获异常、重新抛出、noexcept说明符以及异常安全等级等核心概念。

异常的基本概念

上一节我们介绍了课程概述,本节中我们来看看异常的基本概念。异常用于处理程序运行时的意外情况,即所谓的“运行时异常”。当程序执行过程中发生某些未预料到的问题时,异常机制允许我们中断正常的控制流,并跳转到专门的错误处理代码。

在C++中,异常是对象。所有异常类型都继承自标准库中的 std::exception 基类。当发生错误时,代码会“抛出”一个异常对象。如果这个异常没有被捕获,程序将终止。

抛出与捕获异常

理解了异常是什么之后,我们来看看如何抛出和捕获它们。这是异常处理的核心操作。

以下是抛出和捕获异常的基本语法结构:

try {
    // 可能抛出异常的代码
    if (something_went_wrong) {
        throw std::out_of_range("Index out of bounds");
    }
} catch (const std::out_of_range& e) {
    // 处理特定类型的异常
    std::cout << "Caught an out_of_range exception: " << e.what() << std::endl;
} catch (...) {
    // 捕获所有其他类型的异常
    std::cout << "Something else happened." << std::endl;
}

  • try:包含可能抛出异常的代码。
  • throw 语句:用于抛出一个异常对象。可以抛出任何类型的对象,但最佳实践是抛出继承自 std::exception 的类。
  • catch:用于捕获并处理特定类型的异常。一个 try 块后面可以跟多个 catch 块,但程序只会进入第一个匹配异常类型的 catch 块。
  • catch (...):这是一个特殊的捕获子句,可以捕获任何类型的异常。应谨慎使用。

当异常在 try 块中被抛出时,程序会立即停止 try 块内后续代码的执行,并开始依次检查后面的 catch 块,寻找第一个能匹配该异常类型的处理程序。

异常传播与重新抛出

有时,我们捕获异常后,可能只是进行部分处理(如记录日志),然后希望将异常传递给上一层的调用者来处理。这时就需要重新抛出异常。

重新抛出使用不带操作数的 throw 语句。它会将当前捕获的异常原封不动地向上传递。

void some_function() {
    try {
        // 调用可能抛出异常的函数
        risky_operation();
    } catch (const MyException& e) {
        std::cerr << "Logging error: " << e.what() << std::endl;
        throw; // 重新抛出当前捕获的异常
    }
}

异常会沿着调用栈向上“冒泡”,直到被某个 catch 块捕获。如果一直传递到 main 函数都未被捕获,则程序会调用 std::terminate() 终止。

捕获异常的最佳实践

在编写捕获异常的代码时,有几个重要的最佳实践需要遵循,这关系到代码的正确性和效率。

以下是关于如何正确捕获异常的几个要点:

  1. 始终通过 const 引用捕获:这避免了不必要的对象拷贝,并且防止了对象切片问题(当捕获派生类异常时)。例如:catch (const std::exception& e)
  2. 避免捕获指针:虽然可以抛出和捕获指针,但这会导致内存管理复杂化,不是推荐的做法。
  3. 谨慎使用 catch (...):这会捕获所有异常,可能掩盖你未预料到的错误。通常只在需要确保资源被释放(如关闭文件、释放锁)的最终清理代码中使用。
  4. 按从具体到一般的顺序排列 catch:因为 catch 块是按顺序检查的,所以应该先捕获更具体的异常类型(如 std::out_of_range),再捕获更一般的类型(如 std::exception)。

异常安全等级

编写可能抛出异常的代码时,需要考虑其“异常安全性”,即当异常发生时,程序状态会如何。这通常分为四个等级。

以下是四种异常安全等级,从最安全到最不安全:

  1. 不抛出保证:函数保证永远不会抛出异常。所有错误都在内部处理。这是最理想的等级。在函数声明后使用 noexcept 关键字来标识。
  2. 强异常安全:如果函数因异常而失败,程序状态会回滚到函数调用之前的样子,就像什么都没发生过一样。这通常通过“先做可能失败的工作,再做不可逆的改变”来实现。
  3. 基本异常安全:如果函数因异常而失败,程序状态可能发生改变,但保证不会发生资源泄漏(如内存泄漏)。这是最低的合理保证。
  4. 无异常安全:函数抛出异常后,程序可能处于损坏状态,甚至发生资源泄漏。这是不可接受的设计。

在设计和实现类(如你的作业中的 euclidean_vector)时,应力争为关键操作提供强异常安全或不抛出保证。

noexcept 说明符

noexcept 是一个函数说明符,用于向编译器和其他程序员表明该函数不会抛出异常。

在函数声明或定义中添加 noexcept,意味着你承诺该函数提供“不抛出保证”。

int get_size() const noexcept; // 此函数承诺不抛出异常
void resize(size_t n);         // 此函数可能抛出异常

使用 noexcept 的好处包括:

  • 代码清晰:明确表达了设计意图。
  • 潜在的性能优化:编译器可能生成更高效的代码,因为它知道不需要为异常处理做准备。
  • 与标准库的兼容性:某些标准库算法对 noexcept 移动构造函数有优化。

重要提示noexcept 只是一个承诺。如果标记为 noexcept 的函数内部抛出了异常,程序会直接调用 std::terminate() 终止,而不是正常传播异常。因此,只应对真正不会抛出异常的函数使用它。

在测试中验证异常

在编写单元测试时,我们需要验证代码是否按预期抛出异常。Catch2 测试框架提供了方便的宏来完成这个任务。

以下是使用 Catch2 测试异常的几种方法:

  • REQUIRE_NOTHROW(expression):验证表达式 抛出异常。
  • REQUIRE_THROWS(expression):验证表达式 抛出某种异常。
  • REQUIRE_THROWS_AS(expression, exception_type):验证表达式会抛出 特定类型 的异常。

例如,测试向量越界访问:

TEST_CASE("Access out of bounds throws") {
    euclidean_vector v(10);
    REQUIRE_THROWS_AS(v.at(20), std::out_of_range);
    REQUIRE_NOTHROW(v.at(5)); // 有效访问不应抛出
}

在你的作业中,你需要为 euclidean_vector 类的某些操作(如越界访问)实现异常抛出,并使用这些 Catch2 宏来编写相应的测试用例。

总结

本节课中我们一起学习了C++异常处理的核心知识。我们了解了异常是用于处理运行时错误的对象,掌握了使用 trycatchthrow 来抛出和捕获异常的基本语法。我们探讨了异常传播的机制和重新抛出的用法,并强调了通过 const 引用捕获异常的最佳实践。

此外,我们学习了异常安全性的四个等级(不抛出、强、基本、无),理解了编写健壮代码时考虑异常安全的重要性。我们还介绍了 noexcept 说明符的用途,它用于标记不会抛出异常的函数。最后,我们看到了如何使用 Catch2 测试框架来验证代码是否正确抛出了异常。

正确处理异常是编写可靠、健壮C++程序的关键技能,希望你能在作业和未来的项目中应用这些知识。

012:Assignment 2 概述与核心概念解析 🧮

在本节课中,我们将详细解析COMP6771课程Assignment 2的核心要求与实现思路。本次作业的核心任务是实现一个名为 EuclideanVector 的类,它模拟了一个数学中的欧几里得向量。我们将学习如何编写各种构造函数、操作符重载以及成员函数,并理解底层如何使用智能指针管理动态数组。

作业概览与类定义

上一节我们介绍了课程背景,本节中我们来看看Assignment 2的具体内容。你需要花费大量时间编写一个名为 EuclideanVector 的类。

这个类被定义在一个命名空间中,其基本结构如下:

namespace comp6771 {
    class EuclideanVectorError : public std::runtime_error {
        // 自定义异常类
    };
    class EuclideanVector {
        // 你需要实现的主类
    private:
        std::unique_ptr<double[]> magnitudes_; // 核心数据成员
    };
}

整个类框架已经提供给你。其中包含一个自定义的异常类 EuclideanVectorError,它继承自 std::runtime_error。这个异常类主要目的是为你的异常提供一个具体的名称。主类 EuclideanVector 的私有部分目前只包含一个 std::unique_ptr<double[]> 类型的数据成员 magnitudes_。虽然智能指针是下一周的主题,但它不会阻碍你开始本次作业。

需要实现的构造函数

以下是本次作业中你需要实现的一系列构造函数。

  1. 默认构造函数:无参数,构造一个空的欧几里得向量。
  2. 维度构造函数:接受一个 int 参数,构造一个指定维度且所有元素为0的向量。例如 EuclideanVector(10) 生成一个包含10个0的向量。
  3. 维度与初值构造函数:接受一个 int(维度)和一个 double(初始值),构造一个所有元素均为该初始值的向量。例如 EuclideanVector(10, 1.1) 生成一个包含10个1.1的向量。
  4. 迭代器范围构造函数:接受两个迭代器(起始和结束),根据该范围的内容构造向量。这允许你从其他容器(如 std::vector, std::list 等)构造 EuclideanVector
  5. 初始化列表构造函数:接受一个 std::initializer_list<double> 来初始化向量,类似于 std::vector 的用法。
  6. 拷贝构造函数与移动构造函数:这两个是下一周会深入讨论的主题,但你现在就可以实现它们。

操作符重载与成员函数

除了构造函数,你还需要实现大量的操作符重载和成员函数。

  • 赋值操作符:包括拷贝赋值和移动赋值操作符。
  • 下标操作符operator[],用于访问向量元素。
  • 算术操作符:包括一元正号、一元负号、复合加法等。
  • 类型转换操作符:实现到 std::vector<double>std::list<double> 的转换。
  • 成员函数:包括获取向量维度的 dimensions(),以及用于常量对象和非常量对象的下标访问成员函数。
  • 友元函数:包括向量的加法、减法、数乘以及输出流操作符 operator<<

工具函数与异常处理

我们还有一些工具函数需要实现,它们位于相同的命名空间内,但不属于 EuclideanVector 类本身。

这些函数包括计算欧几里得范数(Euclidean norm)、单位向量(unit vector)和点积(dot product)。如果你不熟悉这些数学概念,可以通过快速搜索来回顾。

关于异常处理,作业要求你在特定情况下抛出提供的 EuclideanVectorError 异常。在作业说明的表格中,“Exception”一栏描述了何时需要抛出异常以及异常信息的具体格式。

核心要求是:异常信息中的占位符(如 X, Y)必须替换为实际的具体数值。
例如,当两个维度不同的向量相加时,应抛出的异常信息格式为:"Dimensions of LHS X and RHS Y do not match",其中 XY 应替换为实际的维度值(如3和4)。

关于 std::unique_ptr 和数据成员的说明

现在我们来快速了解一下底层数据成员 magnitudes_。它是一个 std::unique_ptr<double[]>,你可以将其视为一个动态分配的、原始的 double 数组。

虽然我们在Assignment 1中禁止使用原始的C风格数组,但这次不同。因为你现在是库的编写者,就像 std::vectorstd::string 在底层也使用原始数组以获得最佳性能一样。EuclideanVector 类就是这个“原始数组”的一个安全、高级的封装。

关键点在于:你可以像使用普通 double 数组一样使用 magnitudes_。你可以通过下标索引访问元素、赋值、用循环遍历它。关于拷贝和移动语义的特殊处理,我们会在下周详细讨论,但这并不妨碍你开始实现其他大部分功能。

其他重要规则与答疑

以下是作业中其他一些重要的规则和常见问题解答。

  • const 正确性:所有不应修改对象状态的成员函数都应声明为 const
  • noexcept:所有提供不抛出异常保证的函数都应使用 noexcept 修饰。
  • 性能要求:虽然没有严格的算法复杂度要求,但所有测试应在合理时间内(如1秒内)完成。避免低效的实现(如不必要的内存分配或嵌套循环)。
  • 禁止使用的工具你不能在实现中使用任何STL容器(如 std::vector, std::array, std::list。但是,你可以使用STL算法(如 std::for_each, std::distance)和其他工具。
  • 关于循环:使用传统的 for 循环是可以接受的,不会因此扣分。当然,如果能有更优雅的写法(例如使用基于范围的for循环或STL算法)会更好。
  • 问题反馈:如有关于作业要求的澄清性问题,请在课程论坛的指定主题下提问。

总结

本节课中我们一起学习了COMP6771 Assignment 2的核心内容。我们了解了需要实现的 EuclideanVector 类的整体结构,包括多种构造函数、操作符重载、成员函数和工具函数。我们明确了异常处理的具体要求,特别是异常信息的格式。最重要的是,我们理解了底层使用 std::unique_ptr<double[]> 来管理动态数组,并且可以像使用普通数组一样操作它,同时我们也知道了本次作业中禁止使用STL容器的关键限制。现在,你可以基于这些理解开始着手实现你的向量类了。

013:资源管理 🧠

在本节课中,我们将要学习C++中的资源管理,特别是堆内存的管理。我们将探讨如何创建和销毁对象,理解拷贝与移动语义,并学习“三五法则”来确保资源被正确管理。


对象与内存区域

在C++中,对象是与特定类型相关联的一块内存区域。与Java等语言不同,C++将intbool等基本类型也视为对象。对象可以被创建、销毁、拷贝或移动。

栈内存与堆内存

到目前为止,我们主要使用栈内存。栈资源(如局部变量)在其作用域结束时会被自动销毁。然而,栈内存大小有限,且对象的生命周期受限于其作用域。

为了创建生命周期更长或大小动态变化的对象,我们需要使用堆内存。堆内存由程序员显式管理,使用newdelete关键字(类似于C中的mallocfree)。

示例:创建堆对象

int* a = new int(4); // 在堆上创建一个未命名的int对象,值为4
std::cout << *a << std::endl; // 输出 4
delete a; // 必须显式释放内存

在上面的例子中:

  • a是一个命名资源(栈上的指针变量)。
  • new int(4)创建了一个未命名资源(堆上的int对象)。
  • a离开作用域时,指针a本身(栈资源)会被销毁,但它指向的堆内存不会被自动释放,必须使用delete

如果不使用delete,就会导致内存泄漏


管理类中的堆资源

上一节我们介绍了基本的堆内存操作。本节中我们来看看当类自己管理堆资源(例如,实现一个简单的动态数组)时会发生什么。

考虑一个简化的MyVec类,它内部使用C风格数组在堆上存储数据:

class MyVec {
private:
    int* data_;      // 指向堆上数组的指针
    std::size_t size_;
    std::size_t capacity_;
public:
    // 构造函数:分配堆内存
    MyVec(std::size_t size) : size_(size), capacity_(size) {
        data_ = new int[size];
    }
    // 析构函数:目前为空
    ~MyVec() {}
};

问题:默认析构函数不足

如果使用默认的析构函数,当MyVec对象离开作用域时,只会销毁data_size_capacity_这三个成员变量(它们都在栈上)。而data_指向的堆内存数组不会被释放,导致内存泄漏。

解决方案:我们需要在析构函数中显式释放堆内存。

~MyVec() {
    delete[] data_; // 释放整个数组
}


三五法则 (Rule of Five)

上一节我们解决了析构问题。然而,对于管理资源的类,仅仅处理析构是不够的。编译器会为类自动生成(合成)一些特殊的成员函数。如果我们自定义了其中任何一个,通常需要仔细考虑所有五个,这就是“三五法则”。

以下是需要关注的五个特殊成员函数:

  1. 析构函数 (Destructor)
  2. 拷贝构造函数 (Copy Constructor)
  3. 拷贝赋值运算符 (Copy Assignment Operator)
  4. 移动构造函数 (Move Constructor)
  5. 移动赋值运算符 (Move Assignment Operator)

1. 拷贝语义的问题

编译器合成的拷贝构造函数会进行“浅拷贝”——它只是简单地复制每个成员变量的值。对于指针data_,这意味着新旧两个MyVec对象的data_会指向同一块堆内存

这会导致两个严重问题:

  • 双重释放:当两个对象都被销毁时,它们的析构函数会对同一块内存调用delete[],引发未定义行为(通常是程序崩溃)。
  • 意外修改:通过一个对象修改数组内容,会影响到另一个对象。

解决方案:自定义拷贝构造函数,进行“深拷贝”。

// 拷贝构造函数
MyVec(const MyVec& other) : size_(other.size_), capacity_(other.capacity_) {
    data_ = new int[other.size_]; // 1. 分配新的堆内存
    // 2. 复制数据
    std::copy(other.data_, other.data_ + other.size_, data_);
}

拷贝赋值运算符也需要类似的深拷贝逻辑。一种常见的实现是“拷贝并交换”惯用法:

// 拷贝赋值运算符
MyVec& operator=(const MyVec& other) {
    MyVec temp(other); // 利用拷贝构造函数创建临时副本
    swap(*this, temp); // 交换当前对象和副本的内容
    return *this;      // 临时对象离开作用域,自动清理旧资源
}

左值、右值与移动语义

上一节我们处理了拷贝的问题。但有时我们不需要拷贝,而是想“转移”资源的所有权,这时就需要移动语义。要理解移动,首先需要了解左值(Lvalue)和右值(Rvalue)。

左值 vs. 右值

  • 左值 (Lvalue):指代一个具有存储空间(地址)的持久对象。通常是有名字的变量。例如:int x = 5;中的x
  • 右值 (Rvalue):指代一个临时的、没有持久存储空间的表达式。通常是字面量、临时对象或表达式结果。例如:5x + 1

简单区分:能取地址的是左值,不能取地址的是右值

移动语义与 std::move

移动语义允许我们将资源(如堆内存)从一个对象“转移”到另一个对象,避免昂贵的深拷贝。它对于像std::vector这样持有大量数据的对象尤其高效。

移动构造函数和移动赋值运算符的参数类型是右值引用 (T&&)。

示例:移动构造函数

// 移动构造函数
MyVec(MyVec&& other) noexcept
    : data_(std::exchange(other.data_, nullptr)) // 接管资源,并将原指针置空
    , size_(std::exchange(other.size_, 0))
    , capacity_(std::exchange(other.capacity_, 0)) {}

std::exchange(a, b)b的值赋给a,并返回a的旧值。这完美地实现了所有权的转移。

如何触发移动?
我们不能直接“移动”一个左值。std::move()的作用就是将左值转换为右值引用,从而允许调用移动构造函数或移动赋值运算符。

MyVec v1(10);
MyVec v2 = std::move(v1); // 调用移动构造函数,v1的资源被转移到v2

移动后,源对象v1处于有效但未指定状态。它仍然是一个合法对象(不会崩溃),但其内容是不确定的,不应再被使用。通常其内部指针会被置为nullptr

移动赋值运算符的实现与移动构造函数类似,但需要处理自赋值并释放当前对象持有的旧资源。


RAII:资源获取即初始化

最后,我们来总结一个核心的C++最佳实践:RAII。

RAII(Resource Acquisition Is Initialization)是一种编程惯用法,其核心思想是:

  • 将资源的生命周期与对象的生命周期绑定
  • 在构造函数中获取资源(如分配内存、打开文件)。
  • 在析构函数中释放资源(如释放内存、关闭文件)。

我们之前实现的MyVec类,以及标准库中的std::vectorstd::fstream都是RAII的典型例子。通过将堆内存封装在栈对象内部,我们利用了栈对象自动析构的特性,从而自动、安全地管理了资源,避免了内存泄漏。


本节课中我们一起学习了C++资源管理的基础。我们区分了栈内存和堆内存,理解了使用new/delete进行手动管理的必要性及其风险。我们深入探讨了“三五法则”,学习了当类管理堆资源时,为何以及如何自定义拷贝构造函数、移动构造函数、赋值运算符和析构函数。最后,我们介绍了RAII这一关键设计模式,它是编写安全、无泄漏C++代码的基石。在接下来的课程中,我们将看到如何用“智能指针”这一工具来简化资源管理。

014:智能指针 🧠

概述

在本节课中,我们将学习C++中的智能指针。智能指针是管理动态分配内存(堆内存)的强大工具,它们遵循RAII(资源获取即初始化)原则,能自动管理对象的生命周期,从而避免内存泄漏和其他资源管理错误。我们将重点介绍两种主要的智能指针:std::unique_ptrstd::shared_ptr,并探讨为何使用智能指针是编写健壮C++代码的最佳实践。


智能指针简介

上一节我们介绍了RAII原则和手动管理资源的挑战。本节中,我们来看看如何利用C++标准库提供的智能指针来简化资源管理。

智能指针是封装了原始(“哑”)指针的类模板,其核心职责是拥有(own)所指向的堆对象,并确保在适当时机自动释放该对象。它们位于 <memory> 头文件中。

主要有两种智能指针:

  • std::unique_ptr:独占所有权。一个资源在同一时刻只能被一个 unique_ptr 拥有。
  • std::shared_ptr:共享所有权。多个 shared_ptr 可以共享同一个资源的所有权。

此外,还有与 shared_ptr 配套使用的 std::weak_ptr,它是一种不增加引用计数的观察者指针。


独占指针:std::unique_ptr

std::unique_ptr 是最常用、最简单的智能指针。它独占所指向对象的所有权,意味着当 unique_ptr 被销毁(例如离开作用域)时,它所管理的堆对象也会被自动删除。

基本用法

你可以像使用原始指针一样使用 unique_ptr,例如解引用、访问成员等。

#include <memory>
#include <iostream>

int main() {
    // 创建一个 unique_ptr,管理一个在堆上分配的 int
    std::unique_ptr<int> up1(new int(10));
    std::cout << *up1 << std::endl; // 输出: 10

    // 访问成员(如果指向的是类对象)
    // auto up2 = std::make_unique<std::string>("Hello");
    // std::cout << up2->size() << std::endl;

    // up1 离开作用域时,其管理的 int 会被自动删除
}

关键特性

  • 不可拷贝unique_ptr 删除了拷贝构造函数和拷贝赋值运算符,这是为了防止多个 unique_ptr 拥有同一资源而导致重复释放。
  • 可移动:所有权可以通过移动语义进行转移。移动后,源 unique_ptr 变为空(指向 nullptr)。
    std::unique_ptr<int> up1(new int(5));
    std::unique_ptr<int> up2 = std::move(up1); // 所有权转移
    // 此时 up1 为空,*up2 为 5
    
  • 释放资源reset() 方法会释放当前管理的对象(并可选地接管一个新对象)。release() 方法会放弃所有权,返回原始指针而不删除对象。
  • 获取原始指针get() 方法返回其管理的原始指针,但不放弃所有权。这常用于需要传递原始指针的API。

最佳实践:使用 std::make_unique

为了避免直接使用 new 关键字,并确保异常安全,C++14引入了 std::make_unique。它是创建 unique_ptr 的推荐方式。

// 更安全、更简洁的创建方式
auto up3 = std::make_unique<int>(42);
auto up4 = std::make_unique<std::string>("Hello World");
auto up5 = std::make_unique<std::vector<int>>(5, 1); // 向量包含5个1

使用 make_unique 几乎可以完全避免在代码中显式使用 newdelete


共享指针:std::shared_ptr

上一节我们学习了独占所有权的 unique_ptr。本节中我们来看看允许共享所有权的 std::shared_ptr

std::shared_ptr 通过引用计数机制实现共享所有权。多个 shared_ptr 可以指向同一个对象。每当一个新的 shared_ptr 被创建来指向该对象(通过拷贝构造、赋值等),引用计数就会增加。当一个 shared_ptr 被销毁时,引用计数减少。只有当引用计数变为零时,所管理的对象才会被删除。

基本用法

#include <memory>
#include <iostream>

int main() {
    // 创建 shared_ptr (推荐使用 make_shared)
    auto sp1 = std::make_shared<int>(100);
    {
        auto sp2 = sp1; // 拷贝构造,引用计数+1,现在为2
        std::cout << “引用计数: ” << sp1.use_count() << std::endl; // 输出: 2
    } // sp2 离开作用域,引用计数-1,现在为1
    // sp1 离开作用域,引用计数变为0,对象被删除
}

弱指针:std::weak_ptr

std::weak_ptr 是一种不控制对象生命周期的智能指针,它指向一个由 shared_ptr 管理的对象。weak_ptr 不会增加引用计数。

它的主要用途是打破 shared_ptr 的循环引用(例如在双向链表或父-子结构中),以及作为观察者,安全地访问可能已被释放的资源。

auto shared = std::make_shared<int>(10);
std::weak_ptr<int> weak = shared; // 创建弱指针,引用计数仍为1

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/2234d19c866af44cea776f9097826c88_62.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/2234d19c866af44cea776f9097826c88_64.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/2234d19c866af44cea776f9097826c88_66.png)

// 要使用 weak_ptr,必须先将其“锁定”为一个 shared_ptr
if (auto temp = weak.lock()) { // 如果对象还存在,lock() 返回一个有效的 shared_ptr
    std::cout << *temp << std::endl;
} else {
    std::cout << “对象已被释放” << std::endl;
}

何时使用 shared_ptr

基本原则:优先使用 unique_ptr
仅在以下情况考虑使用 shared_ptr

  1. 一个对象需要有多个所有者。
  2. 你无法预知哪个所有者会存活得最久(例如,在复杂的图结构或某些并发场景中)。

滥用 shared_ptr 会导致代码复杂度增加和潜在的循环引用问题。


智能指针与异常安全

智能指针的一个巨大优势是提供强大的异常安全保证。考虑以下手动管理资源的危险情况:

void riskyFunction() {
    int* raw_ptr = new int(5);
    someFunctionThatMightThrow(); // 如果这里抛出异常...
    delete raw_ptr; // ...这行永远不会执行,导致内存泄漏!
}

使用 unique_ptr,即使发生异常,资源也能被正确释放:

void safeFunction() {
    auto smart_ptr = std::make_unique<int>(5);
    someFunctionThatMightThrow(); // 即使抛出异常...
    // ...当 stack unwinding 发生时,smart_ptr 的析构函数会被调用,从而释放内存。
}

部分构造问题

智能指针还能解决构造函数中的资源泄漏问题。如果一个类的构造函数在初始化多个成员时抛出异常,那么对于已经成功构造的成员(尤其是原始指针成员),其指向的堆内存可能无法被正确释放,因为该类的析构函数不会被调用。

使用 unique_ptr 作为成员变量可以完美解决这个问题。因为即使外部类的构造函数失败,对于已完全构造的 unique_ptr 成员,其析构函数仍会被调用,从而确保其管理的资源被释放。

class SafeClass {
    std::unique_ptr<MyType> a_;
    std::unique_ptr<MyType> b_;
public:
    SafeClass(int a, int b) : a_(std::make_unique<MyType>(a)), // 如果这里成功
                              b_(std::make_unique<MyType>(b)) { // 但这里构造失败抛出异常
        // 构造函数体
    }
    // 无需显式定义析构函数
};
// 即使 b_ 构造失败,a_ 的析构函数也会被调用,释放其资源。

总结

本节课我们一起学习了C++智能指针的核心概念:

  1. std::unique_ptr:用于独占所有权场景。使用 std::make_unique 创建。不可拷贝,但可移动。是默认的首选智能指针。
  2. std::shared_ptrstd::weak_ptr:用于共享所有权场景。使用 std::make_shared 创建。通过引用计数管理生命周期。weak_ptr 用于观察和打破循环引用。
  3. 核心优势:智能指针自动管理资源生命周期,极大地减少了内存泄漏的风险,并提供了强大的异常安全保证。
  4. 设计哲学:优先使用栈对象和值语义,必要时使用 unique_ptr,仅在确需共享所有权时才使用 shared_ptr

掌握智能指针是编写现代、安全、高效C++代码的关键一步。在后续的作业和项目中,请积极应用这些知识。

015:模板入门 🧩

在本节课中,我们将要学习C++模板的基础知识。模板是实现静态多态(编译时多态)的核心工具,它允许我们编写与类型无关的通用代码。我们将从函数模板开始,逐步深入到类模板,并理解其背后的编译模型。

什么是多态性?

多态性是指为不同类型的实体提供单一接口。在面向对象编程中,这通常通过继承和虚函数实现。然而,C++提供了两种主要的多态形式:动态多态静态多态

  • 动态多态:在程序运行时解析调用哪个函数。这是Java等语言中常见的多态形式。
  • 静态多态:在编译时解析调用哪个函数。C++模板是实现静态多态的主要方式。

静态多态本质上是一种函数重载,它属于泛型编程的范畴,旨在创建通用的软件组件。实际上,从本课程开始,你一直在使用模板,例如标准模板库(STL)中的容器:std::vector<int>std::list<std::string> 等。

从函数重载到函数模板

假设我们需要一个求两个数最小值的函数,它需要能处理 intdouble 类型。使用函数重载,我们需要编写两个函数:

auto min(int a, int b) -> int {
    return a < b ? a : b;
}

auto min(double a, double b) -> double {
    return a < b ? a : b;
}

虽然这可行,但代码存在重复。函数模板提供了一种更优雅的解决方案。

函数模板

函数模板是编译器根据不同类型生成具体函数实例的指令。以下是 min 函数的模板版本:

template <typename T>
auto min(T a, T b) -> T {
    return a < b ? a : b;
}

  • template <typename T>:这告诉编译器接下来的内容是一个模板,T 是一个占位符,代表某种类型。
  • T 被称为模板类型参数

当我们调用 min(1, 2)min(1.0, 2.0) 时,编译器会生成两个独立的函数:min<int>min<double>,并将它们编译进二进制文件中。这就是静态多态:在编译时根据类型生成不同的函数,运行时直接调用,无需类型检查,因此性能更高。

模板非类型参数

模板参数不仅可以代表类型,还可以代表已知类型的值,这称为非类型参数

考虑一个查找数组最小值的函数:

template <typename T, std::size_t size>
auto find_min(const std::array<T, size>& a) -> T {
    T min_val = a[0];
    for (std::size_t i = 1; i < size; ++i) {
        if (a[i] < min_val) {
            min_val = a[i];
        }
    }
    return min_val;
}

  • typename T:类型参数,代表数组元素的类型。
  • std::size_t size:非类型参数,代表数组的大小,它是一个编译时常量。

使用非类型参数的好处是,编译器可以将数组大小硬编码到生成的函数中,有时能带来性能优化。但请注意,对于每种不同的类型和大小组合,编译器都会生成一个独立的函数实例,这可能会增加最终二进制文件的大小。

类模板

函数模板很有用,但模板更常见和强大的应用场景是创建类模板。例如,STL中的 std::vectorstd::stack 都是类模板。

创建一个类模板的语法与函数模板类似。以下是一个简易栈的类模板声明:

// stack.h
template <typename T>
class Stack {
public:
    Stack();
    void push(const T& item);
    void pop();
    auto top() -> T&;
    auto top() const -> const T&;
    friend auto operator<<(std::ostream& os, const Stack& s) -> std::ostream&;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/4593240dd453c0786024802f0f9d9ea0_67.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/4593240dd453c0786024802f0f9d9ea0_69.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/4593240dd453c0786024802f0f9d9ea0_70.png)

private:
    std::vector<T> data_;
};

在类外部定义成员函数时,需要在每个函数定义前加上模板声明,并使用类作用域:

// 在 stack.h 或单独的 .tpp 文件中
template <typename T>
void Stack<T>::push(const T& item) {
    data_.push_back(item);
}

template <typename T>
auto Stack<T>::top() -> T& {
    return data_.back();
}

关键点:当在代码中引用模板类类型时(如函数参数、返回类型、变量声明),需要使用 Stack<T>。当引用类名本身(如构造函数名、析构函数名)或在类内部引用成员时,使用 Stack

包含编译模型

上一节我们介绍了如何定义类模板及其成员函数。本节中我们来看看模板代码的组织方式,这涉及到一个重要的概念:包含编译模型

对于普通函数和类,我们通常将声明放在头文件(.h),定义放在源文件(.cpp),然后分别编译,最后链接。但对于模板,这种方法行不通。

模板的定义必须在编译时对编译器可见。编译器需要在看到模板被使用的代码(如 main.cpp)时,能够根据模板定义生成具体的类型实例(如 Stack<int>)。如果定义在单独的 .cpp 文件中,编译器在编译 main.cpp 时就无法生成代码,会导致链接错误。

因此,模板的定义必须放在头文件中。通常有两种做法:

  1. 直接将成员函数的定义写在类声明内部。
  2. 将成员函数的定义写在类声明之后,但在同一个头文件内。有时为了清晰,会使用一个 .tpp(或 .ipp)文件来存放定义,然后在头文件末尾 #include 它。

// stack.h
template <typename T>
class Stack {
    // ... 声明
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/4593240dd453c0786024802f0f9d9ea0_108.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/4593240dd453c0786024802f0f9d9ea0_110.png)

#include "stack.tpp" // 包含定义

// stack.tpp
template <typename T>
void Stack<T>::push(const T& item) {
    // ... 实现
}
// ... 其他成员函数定义

这种模型的缺点是可能增加编译时间,因为同一个模板定义会在多个翻译单元中被实例化多次。但它保证了程序的正确性。

惰性实例化

编译器对于类模板采用惰性实例化策略。这意味着,编译器只会为那些真正被代码使用到的成员函数生成实例。

例如,如果你创建了一个 Stack<int> 但只调用了 pushtop 函数,那么 pop 函数的代码就不会被生成到最终的二进制文件中。这有助于减少不必要的代码膨胀。但需要注意,这也意味着模板代码中的语法错误可能直到该成员函数被使用时才会暴露。

模板与静态成员、友元

模板与类的其他特性交互时,规则是直观的:每个模板实例都是一个独立的类型

静态成员

如果类模板中有静态成员,那么每个模板实例(如 Stack<int>Stack<double>)都拥有自己独立的静态成员副本Stack<int> 的静态成员与 Stack<double> 的静态成员毫无关系。

友元函数

友元函数的规则类似。如果类模板声明了一个友元函数(如输出操作符 <<),那么每个模板实例都会有一个独立的友元函数operator<<(std::ostream&, const Stack<int>&)operator<<(std::ostream&, const Stack<double>&) 是两个完全不同的函数。

常量表达式(constexpr)简介

最后,我们简要介绍一个与编译时计算相关的概念:常量表达式constexpr 关键字用于指示编译器,某个变量或函数的值可以在编译时计算。

  • constexpr 变量:其值必须在编译时已知。
  • constexpr 函数:如果其所有参数都是编译时常量,则该函数可以在编译时被求值。
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

int main() {
    constexpr int result = factorial(10); // 在编译时计算
    std::cout << result << std::endl; // 直接输出 362880
    return 0;
}

使用 constexpr 可以将运行时的计算转移到编译时,从而提升程序运行时的性能。编译器在优化模式下,即使没有 constexpr 也可能进行类似的优化,但使用 constexpr 给予了编译器明确的指示,并在某些情况下是必需的。

总结 🎯

本节课中我们一起学习了C++模板的核心概念:

  1. 模板是实现静态多态的工具,在编译时根据类型生成代码,提升运行时效率。
  2. 函数模板用于创建通用函数,类模板用于创建通用数据结构,是更常见的用法。
  3. 模板参数分为类型参数typename T)和非类型参数(如 std::size_t N)。
  4. 必须遵循包含编译模型,将模板的定义放在头文件中,以便编译器在编译时进行实例化。
  5. 编译器对类模板使用惰性实例化,只生成被用到的成员函数。
  6. 每个模板实例都是独立类型,拥有自己独立的静态成员友元函数副本。
  7. constexpr 关键字允许在编译时计算变量和函数的值,是一种编译时优化手段。

理解模板是掌握现代C++泛型编程的关键。在接下来的课程中,我们将继续探索迭代器和其他高级模板主题。

016:自定义迭代器 🧑‍💻

概述

在本节课中,我们将要学习如何为自定义容器类型创建自定义迭代器。我们将探讨迭代器的基本概念、迭代器失效问题,并通过一个具体的代码示例来理解如何实现一个功能完整的迭代器。


迭代器回顾与迭代器失效

上一节我们介绍了迭代器的基本概念,本节中我们来看看迭代器失效这个重要问题。

迭代器是C++中连接容器和算法的桥梁。它是一个抽象概念,类似于指针,用于线性地访问容器中的元素。然而,当我们在迭代过程中修改容器时,迭代器可能会失效,导致未定义行为。

迭代器失效示例

以下是一个迭代器失效的典型例子。在遍历std::vector时向其中添加元素,可能会导致程序崩溃或进入无限循环。

std::vector<int> v = {1, 2, 3, 4, 5};
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 2) {
        v.push_back(2); // 修改容器,可能导致迭代器失效
    }
}

核心原因:当push_back导致容器重新分配内存(容量改变)时,所有现有的迭代器、指针和引用都会失效。即使没有重新分配,end()迭代器也会失效。

不同操作的失效规则

以下是std::vector部分操作的迭代器失效规则:

  • push_back:如果操作后size() > capacity()(即发生重分配),则所有迭代器失效。否则,仅end()迭代器失效。
  • erase被删除元素及其之后所有元素的迭代器失效(包括end())。

重要原则:在修改容器结构(增删元素)后,应假设所有现有的迭代器都可能失效,最安全的做法是停止使用它们或重新获取。


实现自定义迭代器

理解了迭代器的基本规则后,本节我们来看看如何为一个自定义容器实现迭代器。

我们将以一个简单的整数栈IntStack为例,它使用单向链表实现。我们的目标是让IntStack支持begin()end()方法以及基于范围的for循环。

容器结构定义

首先,我们定义容器本身及其内部节点。

class IntStack {
private:
    struct Node {
        int value;
        std::unique_ptr<Node> next;
        Node(int v, std::unique_ptr<Node> n) : value(v), next(std::move(n)) {}
    };
    std::unique_ptr<Node> head_;
public:
    // 栈的基本操作:push, pop, top 等
    void push(int value) {
        head_ = std::make_unique<Node>(value, std::move(head_));
    }
    // ... 其他成员函数
};

定义迭代器类

迭代器通常作为容器的一个嵌套类(publicprivate)来实现。它需要提供一组特定的操作符重载和类型定义。

以下是IntStack::iterator的核心实现:

class IntStack {
public:
    class iterator; // 前向声明
    using const_iterator = iterator; // 简化示例,实际const迭代器可能不同

    class iterator {
    private:
        Node* current_;
        // 构造函数设为私有,只允许IntStack创建迭代器
        explicit iterator(Node* n) : current_(n) {}
        friend class IntStack; // 声明友元

    public:
        // 1. 必需的迭代器类型定义 (Iterator Traits)
        using iterator_category = std::forward_iterator_tag;
        using value_type = int;
        using reference = int&;
        using pointer = int*;
        using difference_type = int;

        // 2. 核心操作符重载
        // 解引用,获取当前元素值
        reference operator*() const { return current_->value; }
        // 成员访问操作符
        pointer operator->() const { return &(current_->value); }

        // 前缀递增 (++it)
        iterator& operator++() {
            current_ = current_->next.get();
            return *this;
        }
        // 后缀递增 (it++)
        iterator operator++(int) {
            iterator old = *this;
            ++(*this);
            return old;
        }

        // 相等/不等比较
        friend bool operator==(const iterator& a, const iterator& b) {
            return a.current_ == b.current_;
        }
        friend bool operator!=(const iterator& a, const iterator& b) {
            return !(a == b);
        }
    };

    // 3. 容器提供获取迭代器的方法
    iterator begin() { return iterator(head_.get()); }
    iterator end() { return iterator(nullptr); } // 用nullptr表示末尾
    const_iterator begin() const { return iterator(head_.get()); }
    const_iterator end() const { return iterator(nullptr); }
    const_iterator cbegin() const { return begin(); }
    const_iterator cend() const { return end(); }
};

代码解析

  1. 迭代器状态:迭代器对象内部通常只保存一个指向容器内部元素的指针或引用(本例中是Node* current_)。
  2. 迭代器类别:通过iterator_category等类型定义,告诉标准库算法这个迭代器的能力(如前向、双向、随机访问)。我们的链表迭代器是forward_iterator_tag
  3. 必需的操作
    • operator*operator->:用于访问元素。
    • operator++:用于移动到下一个元素。
    • operator==operator!=:用于比较迭代器(通常比较其内部指针)。
  4. 构造控制:迭代器的构造函数通常是private的,并通过将容器类声明为friend,确保只有容器能创建有效的迭代器实例(通过begin()end())。
  5. end()迭代器:通常用一个特殊值(如nullptr)表示序列的“尾后”位置。

使用自定义迭代器

实现完成后,我们的IntStack就可以像标准库容器一样使用了:

IntStack s;
s.push(5);
s.push(4);
s.push(3);
s.push(2);
s.push(1);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/21104176e331387dea78d85347debcac_78.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/21104176e331387dea78d85347debcac_79.png)

// 使用迭代器遍历
for (auto it = s.begin(); it != s.end(); ++it) {
    std::cout << *it << ' ';
}
// 输出: 1 2 3 4 5

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/21104176e331387dea78d85347debcac_81.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/21104176e331387dea78d85347debcac_83.png)

// 使用基于范围的for循环
for (int val : s) {
    std::cout << val << ' ';
}
// 输出: 1 2 3 4 5


迭代器类别与能力

不同的迭代器提供不同的移动和访问能力,标准库算法会根据迭代器类别选择最高效的实现。

以下是主要的迭代器类别及其所需支持的操作:

  • 输入迭代器:只读,且只能单次遍历。需要:==, !=, ++, *, ->
  • 输出迭代器:只写,且只能单次遍历。需要:++, *(仅用于赋值)。
  • 前向迭代器:可读写,可多次遍历。需要:包含输入/输出迭代器的所有操作。
  • 双向迭代器:在前向迭代器基础上,增加反向移动能力。需要:额外支持 ----(int)
  • 随机访问迭代器:提供像指针一样的随机访问能力。需要:额外支持 +, -, +=, -=, <, >, <=, >=, []

我们的IntStack::iterator是一个前向迭代器。如果要升级为双向迭代器,需要修改数据结构为双向链表,并为迭代器添加递减操作符。


总结

本节课中我们一起学习了C++自定义迭代器的核心知识。

我们首先回顾了迭代器失效的概念,明白了在修改容器结构时使用迭代器的危险性。接着,我们深入探讨了如何为一个自定义的链表栈容器实现迭代器,包括定义迭代器嵌套类、实现必需的操作符重载(解引用、递增、比较)、声明迭代器类别特征,以及让容器提供begin()end()方法。

实现自定义迭代器的关键点在于:

  1. 迭代器是一个独立的类,封装了对容器内部元素的访问逻辑。
  2. 必须提供一组标准的操作符重载和类型定义(Iterator Traits)。
  3. 通过控制构造函数和友元关系,管理迭代器的创建权限。
  4. 迭代器的能力(类别)决定了它能与哪些标准库算法兼容。

掌握自定义迭代器是理解C++标准库设计哲学和编写泛型代码的重要一步。

017:高级模板 🧩

概述

在本节课中,我们将深入学习C++模板的高级特性。我们将从模板的基础知识出发,探索如何通过默认模板参数、特化、可变参数模板等技术,更灵活、更强大地使用模板进行泛型编程。


默认模板参数

上一节我们介绍了模板的基础知识,本节中我们来看看如何为模板参数设置默认值,这类似于为函数参数设置默认值。

核心概念

在类模板中,可以为模板参数指定默认类型。语法如下:

template <typename T, typename Container = std::vector<T>>
class Stack {
    // ...
};

这里,Container 的默认类型是 std::vector<T>。如果用户创建 Stack<int>,编译器会将其视为 Stack<int, std::vector<int>>

示例解析

考虑一个栈类,它使用一个容器来存储元素。我们可以让容器类型成为一个模板参数,并为其设置默认值。

#include <vector>
#include <list>
#include <iostream>

template <typename T, typename Container = std::vector<T>>
class Stack {
private:
    Container elements;
public:
    void push(const T& item) {
        elements.push_back(item); // 假设容器有 push_back
    }
    // ... 其他成员函数
};

int main() {
    Stack<int> s1; // 使用默认容器 std::vector<int>
    Stack<float, std::list<float>> s2; // 显式指定容器为 std::list<float>
    return 0;
}

重要说明

  • 模板的“有效性”是惰性实例化的。编译器只会在实际使用某个成员函数时,才检查其对于给定的模板参数是否有效。
  • 例如,如果你为 Stack 指定了一个没有 push_back 方法的容器类型,但只要你不调用 push 函数,代码依然可以编译。只有在调用 push 时,编译器才会尝试实例化该函数并报错。


模板特化

模板特化允许我们为特定的类型或类型模式提供定制化的实现,同时保持接口的一致性。其核心目的是保持语义,而不是改变含义。

部分特化

部分特化是指为模板参数的一个子集(而非全部具体类型)提供特殊实现。最常见的例子是为指针类型提供特化。

以下是部分特化的一个典型场景:

假设我们有一个通用的 Stack 模板,它有一个 sum 函数用于计算栈中所有元素的和。对于指针类型的栈,直接对指针求和没有意义,我们希望对指针所指向的值求和。

// 主模板
template <typename T>
class Stack {
private:
    std::vector<T> elements;
public:
    // ... push, pop, top 等函数
    T sum() const {
        return std::accumulate(elements.begin(), elements.end(), T{});
    }
};

// 针对 T* 的部分特化
template <typename T>
class Stack<T*> {
private:
    std::vector<T*> elements;
public:
    // ... push, pop, top 等函数(实现可能与主模板相同)
    T sum() const {
        // 特化的 sum:对指针解引用后求和
        return std::accumulate(elements.begin(), elements.end(), T{},
                               [](T acc, T* ptr) { return acc + *ptr; });
    }
};

关键点:部分特化 Stack<T*> 仍然是一个模板,它适用于任何指针类型,而不仅仅是某个具体类型的指针。

显式特化

显式特化是为模板参数指定一个完全具体的类型。此时,模板参数列表为空。

// 主模板
template <typename T>
class Stack {
    // ... 通用实现
};

// 针对 std::string 的显式特化
template <>
class Stack<std::string> {
private:
    std::vector<std::string> elements;
public:
    // ... 可以为 std::string 提供特殊优化的实现
    // 例如,sum 函数对字符串可能没有意义,可以移除或改变其行为
    size_t total_length() const { // 提供一个新语义的函数
        return std::accumulate(elements.begin(), elements.end(), size_t{0},
                               [](size_t acc, const std::string& s) { return acc + s.size(); });
    }
};

何时使用特化?

  • 保持语义:当通用模板对某种类型无法编译或行为不正确时,通过特化使其行为与其他类型一致。
  • 性能优化:当对特定类型存在更高效的实现方式,且不改变其对外接口和语义时。例如,std::vector<bool> 进行了空间优化(位存储),这就是一个显式特化的经典案例。
  • 注意:应避免使用特化来为一个类型提供完全不同的行为,这会让使用者感到困惑。如果行为不同,它应该是一个不同的类。

类型特征

类型特征是一种在编译时查询或修改类型信息的模板技术。标准库 <type_traits> 中提供了大量类型特征。

基本原理

类型特征通常通过特化来实现。它们本质上是包含静态常量的类模板。

// 主模板:默认不是 void
template <typename T>
struct is_void {
    static const bool value = false;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_36.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_38.png)

// 显式特化:针对 void 类型
template <>
struct is_void<void> {
    static const bool value = true;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_40.png)

// 使用
std::cout << is_void<int>::value; // 输出 0 (false)
std::cout << is_void<void>::value; // 输出 1 (true)

实际应用

类型特征使得基于类型的条件编译和代码生成成为可能。

#include <iostream>
#include <type_traits>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_46.png)

template <typename T>
void test_if_number_type(const T& value) {
    if constexpr (std::is_integral_v<T> || std::is_floating_point_v<T>) {
        std::cout << value << " is a number.\n";
    } else {
        std::cout << value << " is not a number.\n";
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_48.png)

int main() {
    test_if_number_type(42);      // 输出: 42 is a number.
    test_if_number_type(3.14);    // 输出: 3.14 is a number.
    test_if_number_type("hello"); // 输出: hello is not a number.
    return 0;
}

另一个例子是 std::numeric_limits,它通过特化为各种算术类型提供了最大值、最小值等信息。


可变参数模板

可变参数模板允许模板接受任意数量的模板参数,是实现像 printf 这种可变参数函数的类型安全方式。

递归展开模式

可变参数模板通常通过递归进行实例化。

#include <iostream>

// 基础情况:处理一个参数
template<typename T>
void print(const T& t) {
    std::cout << t << '\n';
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_52.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_54.png)

// 递归情况:处理一个参数和一包参数
template<typename T, typename... Args>
void print(const T& t, const Args&... args) {
    std::cout << t << ' ';
    print(args...); // 递归调用,处理剩余参数包
}

int main() {
    print(1, 2.0, "three"); // 输出: 1 2 three
    return 0;
}

工作原理

调用 print(1, 2.0, “three”) 时:

  1. 匹配可变参数版本 print<int, double, const char*>,输出 1,然后递归调用 print(2.0, “three”)
  2. 匹配可变参数版本 print<double, const char*>,输出 2.0,然后递归调用 print(“three”)
  3. 匹配基础版本 print<const char*>,输出 three

编译器在编译时会为每一次调用生成具体的函数实例,这虽然可能导致代码膨胀,但换来了运行时的高效。


成员模板

成员模板是指类(包括模板类)内部的模板。常见的用途是创建接受同类但模板参数不同的对象的构造函数或赋值运算符。

示例:异构栈的构造

我们希望一个 Stack<double> 可以从一个 Stack<int> 构造。

template <typename T>
class Stack {
private:
    std::vector<T> elements;
public:
    // 普通的拷贝构造函数 (由编译器生成或自己定义)
    Stack(const Stack&) = default;

    // 成员模板构造函数:允许从另一种类型的 Stack 构造
    template <typename U>
    Stack(const Stack<U>& other) {
        // 遍历 other 的元素,转换类型后压入当前栈
        for (const auto& elem : other.elements_) { // 假设能访问 elements_
            elements.push_back(static_cast<T>(elem));
        }
    }
    // ... 其他成员
};

注意:在类外定义成员模板时,需要提供两套模板参数。

template <typename T> // 类的模板参数
template <typename U> // 成员模板自己的模板参数
Stack<T>::Stack(const Stack<U>& other) {
    // ... 实现
}

模板模板参数

模板模板参数是指一个模板参数本身也是一个模板。这用于让用户只传递容器模板,而不需要重复指定元素类型。

对比:非模板模板参数 vs 模板模板参数

// 方式1:普通模板参数,需要指定容器具体类型
template <typename T, typename Container> // Container 是具体类型,如 std::vector<int>
class Stack1 {
    Container elements; // Container 必须已经是完整类型
};

Stack1<int, std::vector<int>> s1; // 需要写两次 int

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_70.png)

// 方式2:模板模板参数,只需传递容器模板
template <typename T,
          template <typename...> class Container = std::vector> // Container 是一个模板
class Stack2 {
    Container<T> elements; // 用 T 实例化容器模板
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_72.png)

Stack2<int, std::vector> s2; // 更简洁:Stack2<int>
Stack2<double, std::list> s3;

语法细节

template <typename...> class Container 中的 typename... 表示容器模板可以接受任意数量的模板参数(例如 std::vector 有元素类型和分配器两个参数),这提高了通用性。


模板参数推导

在调用函数模板时,编译器通常可以根据函数实参自动推导出模板参数的类型,无需显式指定。

推导规则

template <typename T>
void f(T* ptr) {}

int arr[10];
f(arr); // 推导出 T = int

编译器通过匹配实参 int[10](会退化为 int*)与形参 T*,推导出 Tint

需要显式指定的情况

有时自动推导可能不准确或不是我们想要的,这时可以显式指定模板参数。

template <typename T>
T min(T a, T b) { return (a < b) ? a : b; }

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_86.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/015aec1d2084e65be62a50b75655afbc_88.png)

int i = 5;
double d = 3.14;

// auto result = min(i, d); // 错误:无法推导出唯一的 T (int 还是 double?)
auto result1 = min<double>(i, d); // 正确:显式指定 T 为 double,i 被转换为 double
auto result2 = min<int>(i, d);    // 正确:显式指定 T 为 int,d 被转换为 int(可能丢失精度)

C++17 的类模板参数推导(CTAD)进一步简化了操作,例如 std::vector v{1, 2, 3}; 可以直接推导出 v 的类型为 std::vector<int>


总结

本节课我们一起深入探讨了C++模板的高级主题。我们学习了如何为模板参数设置默认值,如何通过部分特化和显式特化为特定类型提供定制实现,以及如何利用类型特征在编译时获取类型信息。我们还了解了可变参数模板如何处理任意数量的参数,成员模板如何增强类的灵活性,以及模板模板参数如何简化代码。最后,我们回顾了模板参数推导的规则。掌握这些技术将帮助你编写出更通用、更高效且类型安全的C++代码。

018:高级类型 - 第一部分

概述

在本节课中,我们将要学习C++中的一些高级类型特性。这些特性包括decltype、类型转换、绑定以及转发。虽然这些概念在日常编程中可能不常直接使用,但理解它们有助于我们更深入地理解C++语言的设计和编译时行为。


decltype:从值到类型

上一节我们介绍了课程背景,本节中我们来看看decltypedecltype是C++内置的一个功能,它允许你获取一个变量或命名项的类型。本质上,它是一种将值转换为类型的方式。

例如,如果你有一个变量i,你可以使用decltype(i)来获取i的类型,而不是直接写出intauto。这样,你可以声明一个变量x,使其类型与i相同。

代码示例:

int i = 5;
decltype(i) x; // x 的类型是 int

decltype与某些动态类型语言中的typeof不同。在Python中,type(a)返回一个可以用于if语句比较的值。而在C++中,decltype返回的是一个类型,而不是一个值。因此,你不能写if (decltype(i) == int)这样的代码,因为==操作符作用于值,而非类型。

decltype的规则较为复杂,其结果取决于传入表达式的种类。以下是核心规则摘要:

  • 如果表达式E是一个变量、静态成员或函数参数,则decltype(E)的结果是T(即该变量本身的类型)。
  • 如果表达式E是一个左值引用,则decltype(E)的结果是T&
  • 对于右值引用等其他情况,规则更为细致,但以上两条覆盖了大多数常见场景。

代码示例:

int i = 1;
int& j = i;
int&& k = std::move(i);

decltype(i) a; // a 是 int
decltype(j) b = i; // b 是 int&,必须初始化
// decltype(k) c; // k 是 int&&,规则类似

那么,decltype的实际用途是什么?一个典型的场景是在编写泛型函数时确定返回类型。

代码示例:

// 旧的写法(在某些上下文中无效)
template <typename Iterator>
decltype(*begin) find(Iterator begin, Iterator end, int index) {
    // ... 查找逻辑
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/e82bc31b31dc2045370c217ef829b1b7_22.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/e82bc31b31dc2045370c217ef829b1b7_24.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/e82bc31b31dc2045370c217ef829b1b7_25.png)

// 使用尾置返回类型的现代写法(有效)
template <typename Iterator>
auto find(Iterator begin, Iterator end, int index) -> decltype(*begin) {
    // ... 查找逻辑
    // 返回类型是迭代器解引用后的类型
}

在上面的例子中,我们无法预先知道解引用迭代器*begin的具体类型,但decltype(*begin)可以在编译时为我们推导出这个类型,从而指定函数的返回类型。


类型转换

上一节我们介绍了decltype,本节中我们来看看类型转换。类型转换与昨天学习的类型特征(type traits)原理相似,但侧重点不同。类型特征用于查询类型的属性(例如is_void, is_pointer),而类型转换则专注于将一个类型转换为另一个相关的类型

例如,标准库提供了std::remove_reference。给定一个类型Tstd::remove_reference<T>::type会得到移除引用修饰后的类型T

代码示例:

#include <type_traits>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/e82bc31b31dc2045370c217ef829b1b7_33.png)

int main() {
    using std::is_same_v;
    // 类型特征:检查两个类型是否相同
    static_assert(is_same_v<int, int> == true);
    static_assert(is_same_v<int, int&> == false);

    // 类型转换:移除引用
    static_assert(is_same_v<int, std::remove_reference<int>::type> == true);
    static_assert(is_same_v<int, std::remove_reference<int&>::type> == true); // 移除左值引用
    static_assert(is_same_v<int, std::remove_reference<int&&>::type> == true); // 移除右值引用
}

除了remove_reference,还有add_rvalue_referenceremove_constadd_const等一系列类型转换工具。它们都是通过模板特化(包括全特化和偏特化)在编译时实现的。

从C++14开始,访问这些类型的语法得到了简化,你可以直接使用std::remove_reference_t<T>来代替std::remove_reference<T>::type,代码更简洁。


绑定

上一节我们介绍了类型转换,本节中我们来看看绑定。绑定讨论的是函数调用时,实参(arguments)如何与形参(parameters)匹配的规则。你已经对此有了一些直观理解,例如可以将int类型变量传递给接受double类型参数的函数。

绑定规则明确规定了不同类型的值(左值、常量左值、右值)可以绑定到哪些类型的引用参数上。

以下是核心绑定规则:

  • 左值引用 (T&):只能绑定到左值。
  • 常量左值引用 (const T&):可以绑定到左值、常量左值和右值。这是它非常实用的原因。
  • 右值引用 (T&&):只能绑定到右值。

代码示例:

#include <iostream>

void print(int& a) { std::cout << "lvalue ref: " << a << std::endl; }
void print(const int& a) { std::cout << "const lvalue ref: " << a << std::endl; }
void print(int&& a) { std::cout << "rvalue ref: " << a << std::endl; }

int main() {
    int i = 1;
    const int ci = 2;

    print(i);   // OK, 绑定到 print(int&)
    print(ci);  // OK, 绑定到 print(const int&)
    print(3);   // OK, 绑定到 print(int&&) 或 print(const int&)
    // print(ci); // 错误:不能将常量左值绑定到非常量左值引用 print(int&)
}

理解这些规则有助于编写更通用和高效的函数。例如,使用const T&作为参数可以使函数接受更广泛的输入。

一个特殊的规则是:模板化的右值引用(也称为转发引用或万能引用),即template <typename T> void foo(T&& arg),它几乎可以绑定任何类型的实参(左值、右值、常量等)。这为接下来的“转发”话题奠定了基础。


引用折叠

在深入讨论转发之前,我们需要了解引用折叠的规则。当我们通过模板或类型别名创建引用的引用时,编译器会使用这些规则来确定最终的类型。

引用折叠规则只有四条:

  • T& & 折叠为 T&
  • T& && 折叠为 T&
  • T&& & 折叠为 T&
  • T&& && 折叠为 T&&

简单来说:只要其中有一个是左值引用 (&),结果就是左值引用 (&);只有两者都是右值引用 (&&) 时,结果才是右值引用 (&&)。

代码示例:

using int_lref = int&;
using int_rref = int&&;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/e82bc31b31dc2045370c217ef829b1b7_82.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/e82bc31b31dc2045370c217ef829b1b7_84.png)

// 根据引用折叠规则:
int_lref&  a = ...; // 类型为 int&  (规则1)
int_lref&& b = ...; // 类型为 int&  (规则2)
int_rref&  c = ...; // 类型为 int&  (规则3)
int_rref&& d = ...; // 类型为 int&& (规则4)

这些规则在理解std::forward的机制时至关重要。


标准转发 (std::forward)

上一节我们介绍了引用折叠,本节中我们来看看标准转发。std::forward被称为完美转发,它的核心作用是:在泛型函数(尤其是模板函数)中,保持传入参数的原始值类别(左值或右值属性),并将其无损地传递给另一个函数。

考虑以下场景:你有一个模板函数,它接受一个万能引用参数,然后需要将这个参数原封不动地传给另一个函数。如果不使用std::forward,参数的值类别可能会在传递过程中丢失(例如,一个右值传入后,在函数内部有了名字,就变成了左值)。

代码示例:

#include <utility>
#include <iostream>

void process(int& x) { std::cout << "处理左值: " << x << std::endl; }
void process(int&& x) { std::cout << "处理右值: " << x << std::endl; }

// 转发函数
template <typename T>
void relay(T&& arg) {
    // 如果不使用 forward,arg 在函数内部总是左值
    // process(arg); // 总是调用 process(int&)

    // 使用 forward 保持 arg 的原始值类别
    process(std::forward<T>(arg));
}

int main() {
    int a = 1;
    relay(a);            // 传递左值, relay 中调用 process(int&)
    relay(std::move(a)); // 传递右值, relay 中调用 process(int&&)
    relay(2);            // 传递右值, relay 中调用 process(int&&)
}

std::forward<T>(arg)的实现利用了引用折叠规则。当T被推导为左值引用类型(如int&)时,forward返回左值引用;当T被推导为非引用类型(如int)或右值引用类型(如int&&)时,forward返回右值引用。这样就实现了参数的完美转发。


总结

本节课中我们一起学习了C++中几个高级的类型相关特性:

  1. decltype:用于在编译时获取表达式或变量的类型,常用于泛型编程中声明类型。
  2. 类型转换:通过模板特化在编译时对类型进行变换(如添加/移除引用、常量性等)。
  3. 绑定规则:明确了函数调用时不同值类别(左值、右值)与不同引用类型形参之间的匹配规则。
  4. 引用折叠:定义了当出现“引用的引用”时,编译器如何确定最终类型的规则。
  5. 完美转发 (std::forward):结合万能引用和引用折叠,在泛型函数中保持参数原始值类别并传递给其他函数的关键技术。

这些概念是理解现代C++模板和泛型编程深层机制的重要组成部分。虽然它们可能不会出现在每天的代码中,但掌握它们能让你更好地理解库的实现、编写更灵活的模板代码,并深入体会C++语言的设计哲学。

019:高级类型 - 第二部分 🧩

在本节课中,我们将要学习一个高级主题:完美转发。我们将探讨为什么需要它,它是如何工作的,以及它在实际编程中的具体应用。虽然这个概念可能有些“学术性”,但理解它有助于我们更深入地掌握C++的类型系统和模板机制。

上一节我们介绍了引用折叠和模板参数推导,本节中我们来看看如何利用这些知识来实现一个能“完美”传递参数类型的函数。


概述:为什么需要完美转发? 🤔

在编写通用函数(如包装器或工厂函数)时,我们常常会遇到一个问题:当我们把一个参数传递给另一个函数时,该参数的原始“值类别”(是左值还是右值)和“常量性”可能会丢失。

考虑以下简单的包装函数:

template <typename T>
void wrapper(T value) {
    function(value); // 调用另一个函数
}

如果我们传入一个右值引用,在 wrapper 函数内部,value 会被当作一个普通的左值来处理,其“右值性”就丢失了。这可能导致无法调用那些只接受右值引用的函数(例如移动构造函数)。

我们的目标是创建一个能保留传入参数所有类型属性的转发机制。


回顾:参数传递的几种方式 🔄

在深入 std::forward 之前,我们先快速回顾几种传递参数的方式及其局限性。

以下是几种常见的函数签名及其问题:

  1. 按值传递

    template <typename T>
    void wrapper(T value) { ... }
    
    • 问题:总是会发生拷贝(或移动),无法保留原始引用类型。对于不可拷贝的类型会失败。
  2. 常量左值引用

    template <typename T>
    void wrapper(const T& value) { ... }
    
    • 优点:可以绑定到几乎所有类型(左值、右值、常量)。
    • 缺点:参数在函数内部始终是 const 的,我们无法修改它。这不符合“转发”的初衷。
  3. 非常量左值引用

    template <typename T>
    void wrapper(T& value) { ... }
    
    • 优点:参数可修改。
    • 缺点:无法绑定到右值或常量对象,适用范围太窄。

显然,我们需要一种更强大的方法。


核心机制:转发引用与引用折叠 🧠

解决方案的关键在于使用一种特殊的引用——转发引用(也称为万能引用),并结合我们之前学过的引用折叠规则。

转发引用的形式是 T&&,其中 T 是一个模板类型参数。它的神奇之处在于,根据传入实参的类型,编译器会进行不同的模板实例化和引用折叠。

让我们看一个例子:

template <typename T>
void wrapper(T&& arg) { // arg 是一个转发引用
    function(arg);
}
  • 当我们传入一个左值(例如 int x; wrapper(x);)时:
    • T 被推导为 int&
    • 参数 arg 的类型变为 int& &&
    • 根据引用折叠规则(& && 折叠为 &),arg 最终是一个 int&(左值引用)。
  • 当我们传入一个右值(例如 wrapper(42);wrapper(std::move(x));)时:
    • T 被推导为 int
    • 参数 arg 的类型是 int&&(右值引用)。

这样,wrapper 函数内部就“知道”了 arg 原本是左值还是右值。但是,这里还有一个陷阱:在函数体内,无论 arg 是左值引用还是右值引用,它本身都是一个有名字的变量,因此是一个左值表达式。如果我们直接把它传给 function,它仍然会被当作左值处理。

所以,我们需要一个方法,在传递 arg 时,根据其被推导出的类型,将其“还原”为正确的值类别。这就是 std::forward 的使命。


解决方案:std::forward 的魔法 ✨

std::forward 是一个条件性的转换工具,它通常与转发引用一起使用。其核心作用是:如果传入的参数是一个右值引用,那么 std::forward 会将其转换为右值(即 static_cast<T&&>);否则,它什么也不做,保持其为左值

它的典型用法如下:

template <typename T>
void wrapper(T&& arg) {
    // 使用 std::forward 来“完美转发” arg 的值类别
    function(std::forward<T>(arg));
}

现在,整个转发链条就完整了:

  • 如果 wrapper 被一个左值调用,arg 是左值引用,std::forward<T>(arg) 返回一个左值引用。
  • 如果 wrapper 被一个右值调用,arg 是右值引用,std::forward<T>(arg) 返回一个右值引用(具体来说是 static_cast<T&&>(arg),这会触发移动语义)。

这样,function 接收到的参数就完全保留了它在 wrapper 调用点的原始值类别和类型。


实际应用:make_unique 案例分析 🛠️

std::forward 的一个经典应用是在工厂函数中,例如 std::make_unique。让我们看一个简化版的实现思路:

template <typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

以下是关键点解析:

  1. Args&&... args:这是一个可变参数模板和转发引用的组合。它可以接受任意数量、任意类型(左值/右值)的参数包。
  2. std::forward<Args>(args)...:这里对参数包中的每一个参数应用 std::forward。这确保了在构造 T 类型的对象时,每个参数都能以其原始的值类别被传递。
    • 如果用户传入了一个右值(例如临时对象),它会被移动给 T 的构造函数。
    • 如果用户传入了一个左值,它会被以引用的方式传递(避免拷贝)。

这种模式使得 make_unique 成为一个高效且通用的工厂函数。


总结与建议 📝

本节课中我们一起学习了C++中一个高级但重要的概念——完美转发。

  • 核心问题:在通用代码中传递参数时,容易丢失其原始的值类别(左值/右值)和常量性。
  • 核心工具std::forward
  • 配合机制:必须与转发引用 (T&&) 和引用折叠规则一起使用。
  • 主要用途:编写通用的包装函数、工厂函数(如 make_unique)或任何需要将参数透明地传递给其他函数的场景。

最后,给初学者的一个实用建议:如果你不确定是否需要使用 std::forward,那么很可能你暂时不需要它。完美转发是库作者和高级框架开发者更常接触的工具。对于日常应用开发,你可能会更频繁地使用像 std::move 这样更直观的工具。然而,理解其原理能让你更好地理解标准库的行为,并在需要时能够自己构建同样强大的抽象。

020:Assignment 3 作业详解 🧠

在本节课中,我们将详细解析COMP6771课程的第三次作业。本次作业的核心目标是创建一个通用的、有向的、带权重的图数据结构。我们将重点学习如何运用模板和自定义迭代器,这是对前两次作业知识的深化和综合应用。

作业概述 📋

本次作业要求你实现一个名为 gdwg::graph 的模板类,它代表一个有向加权图。与Assignment 2类似,你需要自己实现一个类库,但这次的数据结构更为复杂。图是通用的,这意味着节点类型 N 和边权重类型 E 可以是任何可比较的类型。

核心结构与要求

图的基本特性

  • 有向性:边具有方向。从节点 A 到节点 B 的边,并不意味着存在从 BA 的边。
  • 带权重:每条边都有一个关联的权重值。
  • 允许自环:边的源节点和目的节点可以是同一个节点。
  • 允许多重边:图中可以存在多条从同一源节点指向同一目的节点的边(即使权重不同)。

类的定义与实现

由于 graph 是一个模板类,其所有定义(包括成员函数)都必须放在头文件 graph.hpp 中。你可以选择将定义直接写在类体内,或写在类体下方。

建议:虽然有些同学可能倾向于先实现一个特定类型(如 int)的图,然后再将其模板化,但这可能会带来大量繁琐的修改工作。我们更推荐从一开始就使用模板进行开发。

迭代器设计 🔄

迭代器是本次作业的关键挑战之一。你需要实现一个双向迭代器,用于遍历图中的所有边。

上一节我们介绍了图的基本特性,本节中我们来看看迭代器的具体设计。

  • 迭代含义:对图进行迭代,意味着按特定顺序遍历其所有边。
  • 排序规则:边的顺序首先按源节点排序,然后按目的节点排序,最后按权重排序。
  • 迭代值:每次解引用迭代器,应返回一个 value_type 结构体。该结构体在类中已预先定义:
    struct value_type {
        N from;
        N to;
        E weight;
    };
    

为了降低难度,作业说明中已经提供了迭代器类的大致框架。你可以参考课程中 IntStackRope 的例子来理解如何构建自定义迭代器,但请注意你的迭代器需要是双向的并且是模板化的

内部表示与内存管理 💾

作业对图的内部存储结构没有硬性规定,你可以自由选择 std::vectorstd::set 等STL容器。但有一个关键约束:节点必须在堆内存上存储

以下是需要遵循的核心原则:

  1. 当外部数据(如一个局部字符串)被插入图中时,图必须在堆上创建该数据的一份拷贝,并自行管理其生命周期。这意味着即使原始数据离开作用域被销毁,图中的数据依然有效。
  2. 对于图中的任何一个节点,在内存中只应存在一份底层资源。所有对该节点的引用(例如在不同边的记录中)都应指向这同一份资源。
  3. 为了实现上述目标,你必须使用智能指针(如 std::unique_ptrstd::shared_ptr)来管理节点的内存。对于边,则没有此严格限制,但也不应无意义地创建多个副本。

建议:先设计好你的数据存储结构(例如,是用一个节点列表加一个边列表,还是使用邻接表等),再根据结构决定使用哪种智能指针更为合适。

需要实现的主要功能

以下是作业要求你实现的主要成员函数类别:

  • 构造函数与赋值操作:包括默认构造、初始化列表构造、迭代器范围构造、拷贝/移动构造与赋值等。
  • 修改器
    • insert_node:插入节点。
    • insert_edge:插入边。
    • replace_node:替换节点。
    • merge_replace_node:合并并替换节点(具体行为参见作业说明中的图示)。
    • erase_node / erase_edge:删除节点或边。
    • clear:清空图。
  • 访问器与迭代器
    • is_node / empty / is_connected 等查询函数。
    • begin() / end() 及其常量版本,用于获取迭代器。
  • 其他:比较操作符、输出操作符等。

开发与测试建议 🛠️

  1. 循序渐进:从一个简单的、非模板的版本开始实现核心图结构(如插入/删除节点、边),确保逻辑正确后再引入模板和迭代器。
  2. 优先编写测试:强烈建议在深入编码前或编码早期就开始编写测试用例。这能帮助你更好地理解需求,并在后续重构时快速验证正确性。测试应覆盖各种边界情况,如空图、自环、多重边等。
  3. 善用资源:对于作业说明不清晰的地方,请查阅课程论坛的置顶帖“Assignment 3 Spec Questions”。助教Nathaniel已经解答了大量疑问。
  4. 关于复杂度:在实现时,请参考作业说明中的“复杂度要求”。但评分时不会进行极其严苛的算法复杂度分析,这更多是为你提供一个实现指南。请将主要精力放在功能的正确实现上。
  5. 迭代器的重要性:迭代器部分占有相当比例的分数。如果完全无法实现迭代器,可能会损失约15%的总分。因此,在完成基础功能后,务必投入时间攻克迭代器。

总结 📝

本节课中我们一起学习了COMP6771 Assignment 3的核心要求。本次作业是对C++高级特性的综合实践,重点在于:

  • 设计并实现一个通用的、有向加权图模板类。
  • 深入理解并实现一个自定义的双向迭代器
  • 运用智能指针进行安全的堆内存生命周期管理。
  • 遵循良好的C++工程实践,包括编写全面的测试用例。

请合理安排时间,从基础功能做起,逐步构建复杂的迭代器和模板逻辑。遇到问题时,积极查阅课程资料和论坛。相信通过完成这个作业,你对C++模板和自定义类型的理解将迈上一个新台阶。祝你好运!

021:动态多态性(第一部分)🚀

概述

在本节课中,我们将要学习C++中的动态多态性。这是面向对象编程的核心概念之一,允许我们编写能够处理多种类型的通用代码。我们将重点探讨继承、虚函数以及它们在运行时如何工作。


课程背景与安排

大家好,欢迎来到第九周。根据出勤率,我能感觉到大家既对学期感到疲惫,也正全力投入第三个作业。我知道这对每个人来说都是一个压力很大的时期,希望大家一切顺利。

关于作业二的成绩,我们计划在本周五发布。目前我们已经完成了大约三分之二的批改工作。

关于作业三的截止日期,目前没有延期的计划。虽然这个问题让我听起来有点不近人情,但延长期限有时对那些合理安排时间的同学并不公平,并且可能会打乱大家其他作业的计划。

本周我们将完成课程的最后一个主要主题:运行时多态性。这是我第一次讲授这部分内容,所以对我而言也是新的尝试。


核心概念回顾

在深入动态多态性之前,我们先快速回顾几个核心的面向对象编程概念。

继承

继承是指基于现有类创建新类的思想。新类(称为派生类或子类)继承了基类(父类)的成员,并可以添加自己的新成员。例如,标准库中的 std::runtime_error 类就继承自 std::exception 类。

代码示例:

class BaseClass {
    // ... 基类成员
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/b31fc10609875d7fa0dbda4825da5b81_25.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/b31fc10609875d7fa0dbda4825da5b81_27.png)

class DerivedClass : public BaseClass {
    // ... 派生类成员,继承了 BaseClass 的成员
};

多态性

多态性允许我们将子类对象当作其基类类型来使用。这是一种运行时泛化类型的方式。例如,你可以有一个 Car 类型的指针或引用,它实际上可以指向 ToyotaHonda 对象。

动态绑定

动态绑定是描述多态行为实际发生机制的术语。在运行时(动态),我们将一个对象绑定到另一个类型(如基类引用或指针)。我们之前讨论过绑定,例如将非常量实参绑定到常量形参。


继承在C++中的语法

在C++中,继承的语法与Java等语言类似,但有关键区别。我们使用冒号 : 来表示派生关系。

代码示例:

class Base {
    // 基类定义
};

class Derived : public Base { // 使用 'public' 和冒号
    // 派生类定义
};

关键字 public 表示基类的公有成员在派生类中保持公有。在几乎所有合理的情况下,你都会使用 public 继承。

关于访问控制:

  • public: 可从类外部访问。
  • private: 只能在类自身的范围内访问。
  • protected: 只能在类自身及其派生类的范围内访问。

虽然 protected 在某些情况下有用,但许多软件设计观点认为应谨慎使用,因为需要被子类访问的成员通常也应该对公有接口开放。


对象的内存布局与切片问题

理解对象在内存中的布局对于掌握多态性至关重要。

基类与派生类的内存布局

考虑以下类:

代码示例:

class Base {
private:
    int member_;
    std::string name_;
public:
    int get_member() const { return member_; }
    std::string get_class_name() const { return "Base"; }
};

class Derived : public Base {
private:
    std::vector<int> vec_member_;
    std::unique_ptr<int> ptr_member_;
public:
    std::string get_class_name() const { return "Derived"; } // 重写函数
};

  • Base 对象在内存中包含两个数据成员:int member_std::string name_
  • Derived 对象则包含四个数据成员:从 Base 继承的两个,以及它自己的 vec_member_ptr_member_
  • 成员函数(如 get_member)的代码并不存储在每个对象中,而是存储在程序的代码段。

对象切片问题

当我们尝试通过值传递来处理多态时,会遇到“对象切片”问题。

问题示例:

void print_name(Base b) { // 按值传递
    std::cout << b.get_class_name() << std::endl;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/b31fc10609875d7fa0dbda4825da5b81_68.png)

int main() {
    Base b;
    Derived d;
    print_name(b); // 输出 "Base"
    print_name(d); // 输出 "Base" (但我们期望 "Derived")
}

原因分析:
Base 对象在内存中有一个固定的大小。当 Derived 对象通过值传递给期望 Base 类型的函数时,编译器会创建一个新的 Base 对象并进行复制。由于 Base 类型的内存空间不足以容纳 Derived 的所有额外数据成员,这些额外的数据就被“切掉”了。因此,函数内部处理的是一个纯粹的 Base 对象,调用它的 get_class_name 自然返回 "Base"

解决方案:
要避免切片并实现真正的多态行为,应始终通过指针引用来传递对象。

void print_name(const Base& b) { // 按常量引用传递
    std::cout << b.get_class_name() << std::endl;
}
// 或者
void print_name(const Base* b) { // 按指针传递
    if (b) std::cout << b->get_class_name() << std::endl;
}

引用和指针本身的大小是固定的(通常是一个机器字长),它们只是指向实际对象,因此不会发生切片。


虚函数与覆盖

即使我们通过引用传递,上面的例子仍然可能输出两个 "Base"。这是因为编译器在编译时需要决定调用哪个版本的 get_class_name。默认情况下,它根据变量的静态类型(即声明时的类型)来决定,而不是根据它实际指向的对象的动态类型

为了让编译器在运行时根据对象的实际类型来调用正确的函数,我们需要使用虚函数

使用 virtualoverride

  • 在基类中,将可能被派生类重新定义的函数声明为 virtual
  • 在派生类中,使用 override 关键字来显式表明你正在覆盖基类的虚函数。

修正后的代码示例:

class Base {
public:
    virtual ~Base() = default; // 虚析构函数,稍后解释
    virtual std::string get_class_name() const { return "Base"; }
    int get_member() const { return member_; } // 非虚函数
private:
    int member_;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/b31fc10609875d7fa0dbda4825da5b81_84.png)

class Derived : public Base {
public:
    std::string get_class_name() const override { return "Derived"; }
};

现在,当我们通过基类引用调用 get_class_name 时,程序会输出:

  • Base -> "Base"
  • Derived -> "Derived"

为什么需要虚函数?

C++的设计哲学是“不为未使用的功能付出代价”。如果不将函数标记为 virtual,编译器可以进行优化,直接进行静态绑定(在编译时确定调用哪个函数),速度更快,占用内存更少。而标记为 virtual 则意味着需要支持动态绑定,这会引入一些运行时开销(我们接下来会看到),但换来了多态的灵活性。

override 关键字不是编译器必需的,但强烈建议使用。它可以防止因拼写错误、参数列表不匹配或 const 限定符不同而意外创建新函数,而不是覆盖虚函数。


虚函数表(vtable)机制

虚函数是如何在运行时实现动态绑定的呢?答案是虚函数表

什么是虚函数表?

  • 每个包含虚函数的类都有一个对应的虚函数表(vtable)。
  • vtable 是一个函数指针数组,存储在程序的数据段中(编译时确定)。
  • 表中的每一项指向该类的一个虚函数的具体实现。
  • 如果一个类继承了带有虚函数的基类,它会有自己的 vtable。对于它覆盖的虚函数,表中指向其自己的实现;对于未覆盖的虚函数,表中指向基类的实现。

对象与vtable的关联

  • 当一个类拥有虚函数时,这个类的每个对象在内存中都会包含一个额外的隐藏成员——一个指向该类 vtable 的指针(通常称为 vptr)。
  • 因此,带有虚函数的对象会比没有虚函数的同类对象稍大一些(多一个指针的大小)。

动态调用的过程

当我们通过基类指针或引用调用一个虚函数时(例如 basePtr->get_class_name()),程序会执行以下步骤:

  1. 通过对象的 vptr 找到该对象所属类的 vtable。
  2. 在 vtable 中查找要调用的虚函数对应的条目(偏移量在编译时已知)。
  3. 跟随该条目中的函数指针,调用正确的函数实现。

这个过程就是动态绑定,它发生在运行时。


静态绑定 vs. 动态绑定

  • 静态绑定(早期绑定): 在编译时就能确定调用哪个函数。非虚函数的调用、通过对象本身(而不是指针/引用)进行的调用都是静态绑定。它关联的是名字的静态类型(变量声明时的类型)。
  • 动态绑定(晚期绑定): 在运行时才能确定调用哪个函数。通过基类指针或引用调用虚函数时发生。它关联的是指针/引用所指向对象的动态类型(实际对象的类型)。

示例:

Derived d;
Base& b_ref = d; // b_ref 是 Base 的引用,但指向 Derived 对象

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/b31fc10609875d7fa0dbda4825da5b81_132.png)

// 静态类型:Base&
// 动态类型:Derived
std::cout << b_ref.get_class_name(); // 动态绑定,输出 "Derived"


纯虚函数与抽象类

纯虚函数

有时,基类中的某个虚函数无法提供有意义的默认实现,它只是为派生类定义一个必须实现的接口。这时我们可以将其声明为纯虚函数

语法: 在函数声明的末尾加上 = 0

class Shape { // 形状基类
public:
    virtual ~Shape() = default;
    virtual double area() const = 0; // 纯虚函数,计算面积
    virtual int sides() const = 0;   // 纯虚函数,获取边数
};

抽象类

包含至少一个纯虚函数的类称为抽象类

  • 抽象类不能被实例化(不能创建该类的对象)。
  • 它的作用是为派生类定义一个公共接口和可能的部分实现。
  • 派生类必须覆盖(实现)所有的纯虚函数,否则派生类也会成为抽象类。

抽象类代表了一种过于抽象、无法独立存在的概念(如“形状”),而它的派生类(如“圆形”、“矩形”)才是具体可用的。

使用纯虚函数和抽象类是一种编译时强制接口约定的好方法。


final 关键字

final 关键字可以用于类或虚函数。

  • 用于类:表示该类不能被继承。
    class NoFurtherDerivation final : public Base { ... };
    
  • 用于虚函数:表示该虚函数在派生类中不能再被覆盖。
    class Base {
    public:
        virtual void do_something() final; // 这是最终的实现
    };
    

使用 final 可以让编译器进行更多优化,因为它知道该函数的实现不会再改变。


总结

本节课我们一起学习了C++动态多态性的基础知识:

  1. 继承是构建类层次结构的基础。
  2. 必须通过指针或引用来传递多态对象,以避免对象切片问题。
  3. 虚函数virtual)是实现运行时多态的关键,它允许根据对象的实际类型来调用函数。
  4. 使用 override 关键字可以明确意图并让编译器检查错误。
  5. 虚函数通过虚函数表(vtable) 机制实现,每个对象有一个指向vtable的指针,用于在运行时查找正确的函数地址。
  6. 静态绑定发生在编译时,关联静态类型;动态绑定发生在运行时,关联动态类型。
  7. 纯虚函数= 0)定义了接口而没有实现,包含纯虚函数的类是抽象类,不能实例化。
  8. final 关键字可以阻止进一步的继承或覆盖。

理解这些概念对于编写灵活、可扩展的面向对象C++程序至关重要。在下一部分,我们将继续探讨更多关于多态性的高级主题。

022:动态多态性(第二部分)🚀

在本节课中,我们将继续学习C++中的动态多态性,深入探讨对象存储、类型转换、构造函数与析构函数的行为,以及一些高级概念如协变与逆变。我们将通过具体的代码示例和概念解释,帮助你理解这些复杂但核心的主题。


对象存储与切片问题

上一节我们介绍了虚函数和动态绑定的基础。本节中我们来看看如何存储多态对象,以及需要注意的“对象切片”问题。

在像Java这样的语言中,你可以轻松地将子类对象放入父类类型的容器中。但在C++中,如果你尝试通过值来存储多态对象,就会遇到问题。

以下是存储多态对象时需要注意的关键点:

  • 向量存储问题std::vector<BaseClass> 存储的是对象的值。当你向其中推入一个 SubClass 对象时,会发生对象切片,SubClass 特有的部分会被“切掉”,容器中只剩下一个 BaseClass 对象。
  • 引用限制:你不能创建 std::vector<BaseClass&>,因为C++标准库容器不能直接存储引用。
  • 解决方案:因此,存储多态对象集合的唯一安全方式是使用指针,最好是智能指针。例如:std::vector<std::unique_ptr<BaseClass>>
// 正确做法:使用智能指针存储多态对象
std::vector<std::unique_ptr<BaseClass>> vec;
vec.push_back(std::make_unique<BaseClass>());
vec.push_back(std::make_unique<SubClass1>());
vec.push_back(std::make_unique<SubClass2>());

构造函数、析构函数与继承链

现在,让我们探讨在继承体系中,构造函数和析构函数的调用顺序及其影响。

构造函数调用顺序

当创建一个派生类对象时,构造过程是从最顶层的基类开始,逐层向下到最具体的派生类。

规则:每个派生类的构造函数必须首先调用其直接基类的构造函数。这是为了确保基类的私有成员能被正确初始化。

class Base {
private:
    int member;
public:
    Base(int m) : member{m} {}
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6771-adv-cpp/img/4b7813cdcebb076c3b3883056527f58c_12.png)

class Derived : public Base {
private:
    std::unique_ptr<int> ptr;
public:
    // 必须首先调用基类构造函数
    Derived(int m, std::unique_ptr<int> p)
        : Base{m}, // 调用基类构造函数
          ptr{std::move(p)} // 然后初始化自身成员
    {}
};

析构函数调用顺序

析构函数的调用顺序与构造函数相反:先调用派生类的析构函数,然后沿着继承链向上调用基类的析构函数。

重要建议:如果一个类可能被继承(即用作多态基类),应将其析构函数声明为 virtual。这确保了通过基类指针删除派生类对象时,派生类的析构函数能被正确调用,避免资源泄漏。

class PolymorphicBase {
public:
    virtual ~PolymorphicBase() = default; // 虚析构函数
};


静态类型 vs. 动态类型

理解静态类型和动态类型的区别对于掌握多态至关重要。

  • 静态类型:变量在编译时被声明的类型。看赋值语句的左侧即可知。
  • 动态类型:变量在运行时实际指向或引用的对象的类型。

以下是不同类型变量组合的示例:

Base base; // 静态类型:Base, 动态类型:Base
SubClass sub; // 静态类型:SubClass, 动态类型:SubClass

Base& baseRefToSub = sub; // 静态类型:Base&, 动态类型:SubClass
// SubClass& subRefToBase = base; // 错误!不能将基类引用绑定到派生类对象

关键点

  • 对于值类型,由于对象切片,静态类型总是等于动态类型。
  • 对于指针或引用,静态类型和动态类型可能不同,这正是多态工作的基础。
  • auto 关键字会让变量的静态类型等于其初始化表达式的类型,从而避免隐式的向上转型。

向上转型与向下转型

在处理类层次结构时,类型转换是常见的操作。主要有两种转换方向。

向上转型

向上转型是从派生类转换到基类(在继承树上向上移动)。这总是安全的,并且经常隐式发生。

Dog dog;
Animal& animalRef = dog; // 向上转型,安全
Animal* animalPtr = &dog; // 向上转型,安全

向下转型

向下转型是从基类转换到派生类(在继承树上向下移动)。这不安全,因为基类对象可能不是那个派生类的实例。C++提供了两种方式进行向下转型。

以下是进行向下转型的方法:

  • static_cast:在编译时进行转换,不进行运行时类型检查。仅当你100%确定对象的动态类型时使用,否则是未定义行为。
  • dynamic_cast:在运行时进行转换,会检查对象的实际类型。对于指针,失败时返回 nullptr;对于引用,失败时抛出 std::bad_cast 异常。

Animal* animalPtr = new Dog;

// 向下转型
Dog* dogPtrStatic = static_cast<Dog*>(animalPtr); // 危险,假设你知道它是Dog
Dog* dogPtrDynamic = dynamic_cast<Dog*>(animalPtr); // 安全,会检查
if (dogPtrDynamic) {
    // 转换成功
}


协变与逆变

当覆盖虚函数时,返回类型和参数类型需要遵循特定的规则,分别是协变和逆变。

协变

协变适用于虚函数的返回类型。覆盖函数可以返回基类函数返回类型的派生类型

class Animal {};
class LandAnimal : public Animal {};
class Dog : public LandAnimal {};

class Base {
public:
    virtual LandAnimal& getFavoriteAnimal();
};
class Derived : public Base {
public:
    Dog& getFavoriteAnimal() override; // 允许:Dog 是 LandAnimal 的派生类
    // Animal& getFavoriteAnimal() override; // 错误:Animal 是 LandAnimal 的基类
};

逆变

逆变适用于虚函数的参数类型。覆盖函数可以使用基类函数参数类型的基类型作为参数。

class Base {
public:
    virtual void useAnimal(LandAnimal& a);
};
class Derived : public Base {
public:
    void useAnimal(Animal& a) override; // 允许:Animal 是 LandAnimal 的基类
    // void useAnimal(Dog& a) override; // 错误:Dog 是 LandAnimal 的派生类
};

虚函数与默认参数

这是一个需要特别注意的细节:虚函数的默认参数是静态绑定的。

这意味着默认参数的值在编译时根据调用该函数的静态类型确定,而不是运行时根据动态类型确定。这可能导致违反直觉的结果。

核心建议:避免在虚函数中使用默认参数。如果需要默认行为,可以考虑使用重载或其他设计模式。

class Base {
public:
    virtual void printNum(int x = 1) { cout << “Base: “ << x; }
};
class Derived : public Base {
public:
    void printNum(int x = 2) override { cout << “Derived: “ << x; }
};

Derived d;
Base& b = d;
d.printNum(); // 输出:Derived: 2 (静态类型Derived,使用默认参数2)
b.printNum(); // 输出:Derived: 1 (静态类型Base,使用默认参数1,但调用Derived的函数体)

构造与析构期间的动态绑定

在对象的构造和析构过程中,动态多态性(即通过虚函数表查找)的行为是特殊的。

重要规则

  1. 在构造函数中,对象正在构建中,其动态类型被视为当前正在构造的类类型。因此,在基类构造函数中调用虚函数,不会下降到派生类的覆盖版本。
  2. 在析构函数中,对象正在销毁中,其动态类型也被视为当前正在析构的类类型。在基类析构函数中调用虚函数,同样不会调用派生类的覆盖版本。

最佳实践:避免在构造函数和析构函数中调用虚函数。如果需要,可以考虑使用非虚函数或传递参数来初始化。


本节课中我们一起学习了C++动态多态性的高级主题。我们探讨了如何安全地存储多态对象,理解了静态与动态类型的区别,掌握了向上转型和向下转型的安全用法,认识了协变与逆变的规则,并注意到了虚函数中默认参数以及构造/析构期间动态绑定的特殊行为。这些知识对于编写正确、高效且安全的面向对象C++程序至关重要。

023:低延迟开发实战

概述

在本节课中,我们将跟随Optiver的嘉宾Greg Saunders,学习在金融交易系统等高性能场景下进行低延迟C++开发的核心原则与实战技巧。我们将从一个具体的“订单簿”案例出发,逐步应用性能分析工具和数据结构优化策略,显著降低关键函数的执行时间。

什么是Optiver?

Optiver由两个荷兰单词组合而成:“Optie”(金融期权)和“Verhandelaar”(批发贸易商),意为期权交易商。Optiver是一家做市商,其核心业务是为市场提供流动性,帮助交易者以合理的价格在需要时进行交易。

做市的关键在于估算金融产品(如期权)的当前公允价值,并以此为基础,向买家提供比任何其他对手方更低的价格,向卖家提供比任何其他对手方更高的价格。通过这种方式,Optiver使市场更具流动性,并为市场参与者提供了更优的交易条件。

其工作流程简述如下:交易所(如ASX)将市场信息和交易者行为数据发送给Optiver。随后,Optiver的自动交易系统利用这些信息估算价格,并决定是否执行订单操作(如下单、修改或取消订单)。如果需要执行,则将操作指令发送回交易所,然后等待交易所的下一轮信息,并重复整个过程。

Optiver是UNSW高级C++编程课程的赞助商,并为该课程每年的最佳学员提供500澳元奖金。这是因为在Optiver,他们使用C++构建世界级的交易平台,开发者需要设计、开发和维护低延迟的C++系统,而这门课程所培养的技能与此高度契合。

低延迟开发实战

现在,让我们深入探讨低延迟开发。

交易所发送给我们的一个重要信息是“订单簿”。屏幕上显示了一个订单簿的示例。订单簿包含了交易者愿意交易的价格,以及在每个价格水平上交易者愿意交易的量(手数)。例如,在屏幕上的订单簿中,我们可以看到在122.56的价格上,有卖出10手该产品的意向。

当我们从交易所收到这个订单簿时,我们希望尽可能快地做出响应。也就是说,我们希望以最小的延迟来响应这个信息或事件。

在本讲座中,我们将以“订单簿”这个概念为基础,进行降低延迟的实战演练。那么,我们该如何做呢?

不幸的是,降低延迟没有“银弹”,没有快速简单的捷径,但有一些黄金法则:

  1. 先测量,后修复:如果你在修复前不进行测量,很可能会改错地方,真正的问题反而被忽略。
  2. 避免过早优化:不要在对系统的实际性能特征有深入了解之前就过早地进行优化。
  3. 深入理解你要解决的问题:对问题的浅层理解无法产生最优解,必须深入理解才能找到最快、最好的解决方案。
  4. 了解可用的工具:我们将在本讲座中介绍其中一些。
  5. 了解硬件的特性:这样才能理解系统为何表现出特定的性能。

让我们尝试将这些规则应用到“以最小延迟响应交易所订单簿信息”这个问题上。

响应事件时,首先要做的是识别所谓的“热路径”,即事件发生时实际执行的代码。事件发生时未执行的代码无关紧要,只有热路径上的代码才是关键。

以下是一个可能用来表示订单簿的简单类。为了本次练习,我们假设 get_volume 方法是响应订单簿事件的热路径的一部分。因此,我们将尝试通过使其尽可能快,来降低 get_volume 方法的延迟。

以下是我实现 get_volume 方法的第一次尝试。可以看到,我使用了一个字典(map)来映射价格和该价格上的可用量。get_volume 方法接收一个价格,在该映射中搜索,如果找到则返回关联的量,否则返回零。这看起来是一个不错的初步解决方案。

你可能会想,为什么用字符串表示价格?原因是,目前仍有交易所在其协议中使用ASCII字段来表示价格,这是因为涉及小数位数精度等问题。使用浮点数表示可能无法保证所需的精度,因此我们使用字符串表示价格,并使用映射来查找任意给定价格水平上的量。

记住第一条黄金法则:修复前先测量。让我们开始测量,我将使用 Google Benchmark 进行测量。它可以在GitHub上获取,如果你用过 Google Test 进行单元测试,会发现它很类似。实际上它在底层使用了 Google Test。

屏幕上显示了一个使用 Google Benchmark 框架编写的基准测试示例。它反复调用 get_volume 方法(针对一个已知存在于映射中的价格),并测量其运行时间。benchmark::DoNotOptimize 这行代码是告诉编译器不要因为未使用返回值而优化掉对 book->get_volume 的调用。这样,基准测试会多次调用 get_volume,测量其耗时,并告诉我们结果。我们也可以编写一个类似的基准测试,来测试不存在于映射中的价格,以观察当价格不在订单簿中时的耗时。

让我们看看输出结果。Google Benchmark 的输出显示,针对“存在价格”的基准测试耗时约958纳秒,其中953纳秒是CPU时间,大约运行了70万次迭代。针对“不存在价格”的基准测试耗时略低。

很好,现在我们知道了 get_volume 方法的耗时,我们有了一个基线,可以用来判断后续的改进是否真的使其更快。

现在,我要介绍的第一个工具是 Valgrind(如果你还没用过的话)。Valgrind 最初是一个用于调试内存分配的工具,后来逐渐发展成为一个分析工具框架。其中一个工具叫 Callgrind,它能生成调用图的可视化。在屏幕右侧可以看到这个可视化。

Callgrind 工具会生成一个文件,然后可以用一个叫 KCachegrind 的图形用户界面工具加载这个文件,并提供这种直观的图形表示。

从右侧的调用图中我们能看出什么?我们可以看到,get_volume 85% 的运行时间都花在了 map 类的 find 方法上。如果我们进一步深入,会发现几乎所有这些时间都花在了一个名为 _Rb_tree 类的 find 方法上。这可能对你来说有点陌生,但你可能猜到了,它是一棵红黑树,这是 std::map 底层使用的自平衡树数据结构。

因此,我们的 get_volume 方法将其绝大部分时间都花在了遍历红黑树上。

接下来我们要做的是,问问自己:我们为当前问题选择了正确的数据结构吗?

这是红黑树的一个高层视图,以及它可能为我们的订单簿呈现的样子。你可以看到各个节点中的不同价格。

当你搜索红黑树时,它当然会从树的根节点开始,然后沿着树向左或向右移动,直到找到目标节点。正如你所知,这将花费对数时间。

这可能不是你所期望的,你可能期望 std::map 具有平均常数(O(1))查找时间。但事实上,std::map 并没有平均常数查找时间,它是用树实现的,而不是哈希表。在这个用例中,我们真正需要的是哈希表,我们想要 O(1) 的查找时间。因此,我们应该使用的是 std::unordered_map

在这里,我修改了 get_volume 方法……实际上我根本没有修改 get_volume 方法,我只是将 OrderBook 类从使用 std::map 改为使用 std::unordered_map。现在,我们来看看这是否会对 get_volume 方法的性能产生影响,以及会产生多大影响。

让我们再次运行基准测试。看,现在“存在价格”的测试耗时415纳秒,“不存在价格”的测试耗时约290纳秒。性能有了显著提升!这尤其是个好消息。让我们用图表来展示一下,仅仅通过将 map 改为 unordered_map,我们实际上将 get_volume 方法的运行时间减少了一半以上。这真是太好了,我们取得了实质性的改进。

那么,我们能否做得更好呢?

让我们再次使用 Valgrind 的 Callgrind 工具,为使用 unordered_map 的新版 get_volume 生成调用图。

这是新版本的调用图。现在我们可以看到,大约一半多的时间仍然花在搜索 unordered_map 上,我们稍后会优化这部分。但如果我们看调用图的左侧,实际上它大约花费了11%的时间在字符串析构函数上,大约15%的时间在字符串构造函数上。它在创建和销毁字符串上花费了大量时间。

为什么会这样?答案是,每次创建一个字符串,基本上都会进行一次内存分配。不幸的是,屏幕尺寸不够大,但如果你在调用图上向下滚动,实际上会看到它最终调用了 mallocfree。因此,花费在字符串构造函数和析构函数上的大量时间实际上只是在做内存管理,总计约占25%的时间。

我们能对此做些什么呢?我们想要避免这些内存分配。我们可以通过再次修改数据结构来实现。现在,你可以看到屏幕底部,我将 unordered_map 从映射 stringlong,改为映射 string_viewlong

这意味着当我们搜索映射时,不需要创建一个字符串,只需要给它一个 string_view(这正是我们已经拥有的),它就能够搜索这个 string_view,而无需构造一个全新的字符串。

当然,为了拥有从 string_viewlong 的映射,我们需要将字符串存储在某个地方。string_view 本身不存储底层字符串数据,它是一个非常轻量级的数据结构,底层数据必须存储在其他地方。因此,我在数据结构中添加了一个 vector<string> 来存储底层字符串。但映射现在是从 string_viewlong。所以当我们调用 find 方法时,不需要构造字符串,从而避免了那些昂贵的字符串构造函数和析构函数调用。

让我们看看效果。通过这个更改,我们将时间减少到了243纳秒(针对存在价格)和191纳秒(针对不存在价格)。非常好!

用图表形式展示,我们现在大约只有原始运行时间的四分之一。这相当不错,我们甚至没有对 get_volume 方法本身做太多修改,只是改变了底层数据结构,并确保在查找数据结构时不做像创建字符串这样的傻事。

这是现在 get_volume 方法的调用图。如你所见,它消除了所有那些字符串构造和析构的麻烦。现在,它大约花费80%的时间搜索 unordered_map,大约8%的时间计算 unordered_map 的迭代器。

如果我们深入查看搜索映射的代码,可以看到它做了三件事:首先,对字符串进行哈希(这大约占搜索 unordered_map 时间的25%);然后,在哈希表中找到目标条目所在的桶;最后,在该桶内搜索,以确定我们寻找的节点是否确实存在,并返回与该节点关联的值。这就是它做的三件事。

接下来我想讨论的是利用缓存。

以防你不知道(不过作为三年级高级C++程序员,你们可能知道),现代CPU使用缓存来缓解数据在内存和CPU之间传输的成本,这在CPU术语中是一个相对昂贵的操作。

当数据在计算机内存和缓存之间传输时,是以称为“缓存行”的块为单位传输的。通常,这些块的大小约为64字节。这意味着,如果你查找一个4字节的值,它实际上会从内存中获取64字节到缓存中,包括你真正感兴趣的4字节之前或之后(或两者)的许多字节。

这意味着,如果你有一个数据结构,其中所有信息都紧密地存储在内存中相邻的位置,那么你将更多地受益于缓存。反之,如果数据结构中的元素在内存中相距较远,则受益较少。

vector 这样的数据结构为例,vector 只是一个连续的字节流,所以其中的所有内容在内存中都是紧密相邻的。因此,如果你想利用缓存,vector 是一个很好的选择。不幸的是,如果你想利用缓存,map 就不是一个很好的选择。哈希表本身存储在连续内存中,所以哈希表能很好地利用缓存。但是,每个桶内的哈希表节点存储在内存中的不同位置,因此它们不能像我们希望的那样充分利用缓存。

所以,我们需要一个能很好利用缓存的数据结构,像 vector 这样的结构就很好。在这种情况下,我们可以使用一种叫做“循环缓冲区”的东西。循环缓冲区就像一个 vector 或数组,我们使用数组中的条目,如果需要“绕到”末尾,就回到循环缓冲区的开头。

如果我们使循环缓冲区的大小远大于我们需要的价格数量,那么我们就可以将这些价格存储在循环缓冲区中。它们将在内存中是连续的(或至少是紧密相邻的),这样我们就能利用缓存优势。

这在代码中是什么样子呢?现在,我不再使用 map 了,如屏幕底部所示,我有一个 long 类型的 vector,这些 long 值代表每个价格水平上的量。我使用这个缓冲区的方式是:首先将价格从字符串转换为整数;然后,用该价格除以缓冲区大小取模(余数),这告诉我该价格在缓冲区中的位置;然后,查找就会快得多。

然而,这种方式中,将字符串转换为整数的成本大约与哈希表中的哈希操作一样昂贵。在数量级上,它与哈希的成本差不多。所以它并没有节省哈希的成本,但它确实更好地利用了缓存。因此,我们应该能看到运行时间的减少,因为它更有效地利用了缓存。

让我们看看运行基准测试会发生什么。看,我们再次将运行时间减少到了136纳秒(存在价格)和132纳秒(不存在价格)。很好,利用缓存再次给了我们性能优势。

用图表展示,我们现在大约只有原始运行时间的15%。我们确实取得了显著的改进。

现在,我想看的最后一件事是优化CPU指令。这真的是在尝试优化算法时可能达到的最后一个层面。如果你到了这个层面,你真的是在谈论从运行时间中“刮”下微小的部分,但在像Optiver这样的公司,这可以带来巨大的差异。

让我们看看如何做到这一点。

为了理解像 get_volume 这样的方法的CPU指令运行时间,我们需要一个性能分析器。在Linux上,有一个叫做 perf 的工具,它是一个性能分析器,允许我们测量特定指令的运行时间。它通过使用现代CPU的一个叫做“硬件性能计数器”的功能来实现。CPU可以计数特定类型事件发生的次数,例如缓存未命中、缓存命中、CPU周期等。perf 工具利用这些性能计数器来帮助我们理解 get_volume 方法的机器码指令执行了多长时间。

屏幕上展示了一个如何使用 perf 工具的示例:首先在基准测试代码上运行 perf record,这会生成一个文件;然后可以使用像 perf annotate 这样的工具,它基本上会显示 get_volume 方法的每条指令以及执行该指令大约花费的时间。

让我们看看它的输出。我已经从输出中排除了关于调用 strtod(将字符串转换为浮点数)和计算字符串到整数转换的代码部分。所以我们在这里只看 get_volume 方法的第二行:return volumes_[price % volumes_.size()]

我们可以看到,有一条特定的指令(地址 b903)占用了 get_volume 函数内CPU时间的39.48%。这排除了 get_volume 调用的其他函数。实际上,get_volume 的大部分时间花在了将字符串转换为整数上。但就其在 get_volume 函数内部花费的时间而言,大约40%是在那条 mov 指令(b903)上。

如果你像我一样,可能会挠头想:一条 mov 指令怎么可能花费这么长时间?答案是,这些性能分析工具的工作方式是,它们试图将函数内花费的时间归因于某条特定的指令。但由于现代CPU的工作方式,它们可以同时执行多条指令,并且不一定按照指令发出的顺序执行。因此,有时它实际上可能将性能问题错误地归因于某条指令。事实上,这里正是发生了这种情况。真正的问题在于地址 b900div 指令。那才是真正导致问题的指令。当然,div 指令对于计算取模操作(price % volumes_.size())是必需的,而 get_volume 函数内部的大部分时间就花在了这里。

如果我们需要的优化程度比现在已经达到的还要高,我们能对此做些什么呢?

如果我们对代码进行微调,确保向量(循环缓冲区)的大小始终是2的幂(例如16384、32768、65536等),那么我们就可以用简单的二进制“与”操作来替换那个取模表达式,正如屏幕上现在显示的那样:price & (volumes_.size() - 1)。这将给出除以缓冲区大小后的整数余数。但这仅在缓冲区大小是2的幂时才有效。

如果我们这样做,然后重新运行 perf 工具,你可以看到注释后的输出。现在重要的指令是地址 b8f8and 指令,它现在只占 get_volume 函数内部时间的1%。因此,我们显著加快了 get_volume 代码本身。但不幸的是,在这个案例中,我们并没有加快将字符串转换为整数的代码,而这部分代码占据了该函数运行时间的大部分。所以,如果我们进行这个更改,实际上不会对 get_volume 的运行时间产生太大影响。这是一个“避免过早优化”的例子,因为我们会修复错误的地方。

以上就是我关于如何使用 Valgrind、Callgrind、Perf 和 Google Benchmark 等工具,以及选择正确的数据结构等方法来降低函数延迟的示例。

现在,在我结束并给你们提问机会之前,我想简要谈谈Optiver为像你们这样的软件开发者提供的一些机会。

基本上,我们招聘毕业生和实习生。对于毕业生,你可以是刚从大学毕业或拥有最多四年行业经验的人,来申请我们的毕业生职位。

对于2022年开始的毕业生,薪酬方案是每年20万澳元外加福利。目前可以申请的职位包括交易员毕业生、市场风险分析师和软件开发员。

对于实习生,职位主要面向倒数第二年的学生,但如果你是倒数第三年且简历出色,仍然可以申请。实习生的薪酬方案是每年10万澳元外加养老金,但这是一个为期12周的项目,所以大约能拿到其中的四分之一,当然还有福利。

关于资格,你必须是澳大利亚或新西兰公民、澳大利亚永久居民,或者能够通过临时毕业生或技术独立签证计划获得工作权利的人。如果你是符合条件的人,我鼓励你访问我们的网站并申请。

看看我们现有的职位,业务大致分为两部分:交易员,他们实际控制我们的交易系统并促使它们进行交易;以及技术部门。

在交易部门,我们有实际使用交易平台进行交易的交易员;有研究人员,他们的工作是改进我们的系统,识别市场机会;还有风险经理,帮助我们管理风险,这对像Optiver这样的做市商至关重要。

对于这些职位的要求:交易员需要量化技能、数学、科学、工程背景,需要能够跳出框框思考,有成功的动力,并且通常对交易和金融市场有兴趣。编码经验不那么重要,但有的话肯定有益。

研究人员需要积极主动,善于沟通,能够解决问题,量化技能很重要,不需要编码技能但有的话是加分项。

风险经理需要良好的沟通能力,一些编程经验(但不一定是C++),以及对金融市场的兴趣。

对于技术职位(可能更适合本课程的听众),当然有软件开发员角色,他们设计、开发和维护我们的交易系统,主要使用C++,但也用一些C#和Python。

我们还有生产工程师角色,他们的工作是优化和维护交易平台,确保我们能够安全部署软件,进行适当的监控和控制。

我们还有FPGA开发员角色。FPGA是现场可编程门阵列,它有点像计算机中的CPU,但你不是用指令序列来编程它,而是用逻辑电路(与、或、非门的组合)来编程。使用FPGA,它们能够以比计算机快得多的速度执行非常简单的程序。我们在工作中也使用FPGA。

对于这些职位的要求:显然,对低延迟开发和高性能系统的兴趣对软件开发员角色至关重要;能够展示你做过工作的个人项目总是有帮助的;与他人协作的能力;当然,有C++、C#或Java经验(本课程的听众已经具备C++经验)。对于生产工程师角色,他们确实进行编程,所以编程技能很重要,但他们需要能够与我们的开发员和交易员合作,因此良好的沟通技巧、协作能力、使用不同操作系统的能力等都很重要。对于FPGA开发员,有VHDL或Verilog经验非常重要,了解网络协议和网络底层工作原理也很有用。

我之前提到过会给你们网站。如果你对我们的任何职位感兴趣,我强烈鼓励你访问这个网站。从那里你可以了解更多关于Optiver的信息,底部有链接可以直接跳转到我们目前可用的职位,你可以在网站上直接申请这些职位。

总结

本节课中,我们一起学习了低延迟C++开发的核心思想与实战方法。我们从识别热路径和测量性能基线开始,逐步应用了更换数据结构(从 std::mapstd::unordered_map)、避免不必要的内存分配(使用 std::string_view)、利用CPU缓存(使用循环缓冲区)等优化策略,并介绍了 Valgrind、Google Benchmark 和 perf 等强大的性能分析工具。最后,我们还了解了像Optiver这样的高性能交易公司对人才的需求和提供的职业机会。希望这些知识能帮助你在构建高性能系统时做出更明智的决策。

024:考试说明与复习指南

概述

在本节课中,我们将详细介绍COMP6771课程的期末考试安排、结构、备考策略以及考试期间的重要注意事项。我们将涵盖考试形式、评分方式、技术准备以及如何应对可能出现的突发情况。

考试基本信息

考试将于8月23日(星期一)下午2点至5点进行。这是一场开卷考试,总分为30分,占课程总成绩的30%。

考试将包含两道小型“作业风格”的编程题,每道题15分。题目设计旨在评估你对C++核心概念的理解和应用能力,而非测试记忆或理论细节。

考试结构与题目类型

上一节我们介绍了考试的基本信息,本节中我们来看看考试的具体结构和题目类型。

考试包含两种主要类型的题目:

  1. 第一题:问题解决型(类似Assignment 1风格)

    • 重点在于算法和逻辑实现。
    • 不要求深入掌握复杂的C++语法细节(如智能指针、运算符重载)。
    • 示例:实现一个基于栈的计算器,处理输入命令并输出结果。
    • 目标是让擅长解决问题但觉得C++细节复杂的学生能够展示能力。
  2. 第二题:C++接口实现型(介于Assignment 2和3风格之间)

    • 重点在于实现一个类或接口,展示对C++特性的运用。
    • 可能涉及类设计、模板、运算符重载、迭代器等。
    • 允许使用STL容器,因此实现可能比Assignment 2更直接。
    • 目标是评估你对C++语言特性的实际应用能力。

核心设计理念:题目不能太小(易导致抄袭),也不能是单一的“全有或全无”式大题(评分困难)。两道中等规模的题目能在有限时间内提供合理的区分度。

考试环境与技术准备

了解了题目类型后,我们需要确保有一个稳定的考试环境。以下是关于技术准备的重要事项。

网络与连接要求

考试期间,你必须拥有稳定的网络连接,用于向GitLab推送代码和接收邮件。

  • 网络不稳定怎么办?
    • 尝试优化现有环境:将电脑靠近路由器、调整手机热点位置。
    • 如果因疫情限制无法前往他处,且网络问题确实无法解决,请尽早通过邮件联系讲师,商讨可能的解决方案(如延迟参加补考)。
  • VLAB或CSE系统问题?
    • 如果是CSE系统大规模故障,学校会酌情处理(如延长考试时间)。
    • 个人连接VLAB的问题通常与本地网络有关。

开发环境选择

你可以选择在CSE的VLAB上或本地进行开发。

  • 在VLAB上使用VS Code SSH:推荐方式。相关设置指南已在课程论坛置顶。
  • 在本地开发:允许。但必须注意:最终自动评分将在CSE机器上进行。
    • 关键步骤:在提交前,务必在CSE机器上测试你的代码是否能正确编译和运行。
    • 风险:在本地开发三小时,最后五分钟提交时发现代码在CSE上不工作,将无法获得特殊考虑。

代码提交与验证

考试结束时,你需要将代码提交到指定的GitLab仓库。

  • 提交依据:我们将以你GitLab仓库master分支在截止时间点的内容为准进行评分。
  • 时间戳注意:GitLab显示的是提交(commit)的时间,而非推送(push)的时间。只要在截止时间前完成推送即可。
  • 自动检查脚本:考试期间,我们会提供一个在CSE机器上运行的脚本命令(例如 6771-exam-q1)。
    • 作用:该脚本会克隆你的仓库,尝试编译你的代码,并运行一个简单的测试用例。
    • 目的:这不是为了验证代码完全正确,而是提供一个“完整性检查”,确保你的代码能够编译,没有致命的语法错误。最终评分将基于更全面的自动化测试集

考试规则与特殊情况处理

准备好了环境,我们还需要清楚考试的规则以及遇到问题该如何应对。

健康状况与特殊考虑

  • 开始考试即视为状态良好:根据学校政策,一旦你开始考试,即表示你自认身体健康,可以完成考试。
  • 考试前不适:如果考试当天早上感到不适(如头痛、生病),请不要开始考试。应立即申请特殊考虑(Special Consideration)以获得补考资格。
  • 考试中突发状况:如果在考试期间突发严重健康问题或其他极端情况(如紧急医疗事件),请:
    1. 立即停止考试。
    2. 尽快给讲师发送邮件说明情况。
    3. 在当天或次日提交特殊考虑申请及相关证明。
      学校通常会批准此类情况下的补考。

学术诚信与沟通

  • 允许使用的内容:你可以使用自己的笔记、讲义、以及自己完成的作业代码。禁止使用他人的代码或进行任何形式的协作。
  • 沟通至关重要:考试期间遇到任何技术或突发问题,请立即在课程论坛(Ed)上发帖说明(除非是涉及需要停止考试的严重健康问题,则应直接邮件联系讲师)。过度沟通好过沟通不足。
    • 反面案例:曾有学生在考试结束后8小时才邮件告知未能成功提交,因“考试压力大先去睡觉了”。由于无法核实期间发生什么,很难提供帮助。

评分标准与备考建议

最后,我们来明确一下考试的评分标准,并给出备考方向。

评分与审查

  • 自动评分:考试将主要采用自动化评分。
  • 无特定格式或风格要求:在时间压力下,不要求代码格式(clang-format)、linting或完美的编程风格。重点是让代码正确运行。
  • 无测试要求:不要求你编写Catch2测试。当然,你可以自己写测试来验证代码,但这并非评分项。
  • 代码编译失败:如果代码无法编译,通常该题得分会很低或为零。在特定情况下(如课程总分在及格线边缘),讲师会人工复查试卷,判断是否因小错误导致,但这不是常规流程。
  • 分数分配明确:考试题目会明确标出每部分的分数,帮助你合理分配时间。

复习范围与策略

考试内容涵盖整个学期所学的核心C++主题。

  • 明确会涉及的主题
    • STL容器、迭代器、算法
    • 类类型(构造、析构、静态成员等)
    • 运算符重载
    • 异常处理
    • 模板(第二题很可能是一个模板类)
    • 动态多态与继承的基本概念
  • 可能涉及或作为拔高的主题
    • 自定义迭代器(如果出现,可能只占少量分数,用于区分高分学生)
    • 高级模板特性(类型特征、特化等)
  • 资源管理(如智能指针):可能需要了解,但题目设计可能允许你避免使用它们。
  • 最佳备考资料你完成的三个作业。如果你有任何作业没有完成或理解不透彻,现在是回顾和完成它们的最佳时机。通过作业,你已经为考试进行了最直接的准备。

总结

本节课中我们一起学习了COMP6771期末考试的完整指南。我们明确了考试时间是8月23日14:00-17:00,形式是两道编程题。我们讨论了应选择稳定的考试环境,并提前熟悉在CSE机器上的操作流程。我们强调了考试规则,特别是健康状况的申报和遇到问题要及时沟通的重要性。最后,我们回顾了评分方式,并指出以本学期的作业为核心进行复习是最有效的备考策略。

请关注课程页面,本周内将发布模拟考试(Sample Exam),其形式和环境设置与真实考试完全相同,是熟悉流程的最佳工具。祝大家复习顺利,考试成功!

025:考试复习指南 🎯

在本节课中,我们将一起回顾COMP6771课程的考试要点、结构以及备考策略。本次复习讲座旨在帮助你理解考试形式、明确复习重点,并提供实用的应试建议。

考试结构与形式 📝

上一节我们介绍了课程的整体情况,本节中我们来看看考试的具体安排。

考试包含两个主要问题,它们的设计思路与样卷非常相似。

  • 问题一:深度问题

    • 这类问题类似于“单词阶梯”或“栈计算器”风格的编程任务。
    • 核心是实现一个函数,该函数接收输入并执行特定操作。
    • 你不需要使用过于复杂的C++特性,主要依靠前几周学到的知识即可解决。当然,熟练使用STL容器和算法会让编码更轻松。
    • 评分方式类似于作业一:我们会用一系列难度递增的测试用例来评估你的实现。通过更多测试意味着获得更高分数。
  • 问题二:广度问题

    • 这类问题类似于作业二的风格,侧重于正确使用C++语言特性
    • 你可能很快就能理解题目要求,但挑战在于如何用C++正确地实现,例如处理模板、异常、继承、运算符重载、构造/析构函数等。
    • 我们会提供一个基础的测试用例,帮助你理解类的预期行为。

两个问题在分数上权重相等。考试旨在让擅长深度算法问题或广度语言特性的学生都能有机会展示自己的能力。

考试环境与流程 💻

了解了考试内容后,我们来看看具体的考试环境和操作流程。

考试将在与样卷完全相同的环境中进行。如果你能成功设置并运行样卷,那么正式考试的环境配置将不会有任何问题。

以下是关于考试流程的一些关键信息:

  • 代码仓库:考试开始前(不少于15分钟),你将获得访问考试代码仓库的权限。该仓库基于样卷仓库创建,结构一致。
  • 测试命令:你可以使用 COMP6771 exam check 命令在CSE机器上编译和运行测试。建议在考试开始约一小时后再使用此命令进行全面检查。
  • 提交时间:最终评分将以你在GitLab上的提交(commit)时间为准,而非推送(push)时间。请务必在考试结束前完成提交。
  • 遇到问题:如果遇到技术问题(如无法推送),请立即在考试论坛上发帖说明情况,并附上截图等证据。我们会根据实际情况进行合理处理。

重要提醒:考试为开卷,你可以参考自己的作业、讲义和实验解决方案代码。但是,严禁从互联网复制代码。抄袭检测系统非常灵敏,一旦发现将导致严重后果。

课程内容复习重点 📚

现在我们已经清楚了考试的形式,接下来梳理一下各周讲义内容的复习重点。

以下是各主题在考试中的相关性评估:

  • 第1-2周(C++基础、STL、迭代器):这些知识不是解决考试问题的强制要求,但掌握它们会非常有帮助,能让你更高效地解决问题。
  • 第3周(类类型):理解类的基本工作原理是重要的基础。
  • 第4周(运算符重载)与第5周(异常):这两个主题非常重要,你需要非常熟悉。
  • 第6周(资源管理与智能指针):理解资源管理是关键。虽然使用智能指针是良好实践,但在考试中并非强制。
  • 第7周(模板)与第8周(自定义迭代器):你需要对模板感到舒适。对于自定义迭代器,应理解其概念,但不需要从头实现一个完整的迭代器。
  • 第9周(高级模板)与第10周(高级类型):这部分内容可能只有少量知识点对考试有用。
  • 第11周(动态多态):你应该对此感到熟悉。

核心建议:对于标为“非常重要”和“应该熟悉”的主题,需要确保理论理解清晰。对于其他主题,在时间允许的情况下尽可能掌握,它们可能成为解决问题的有用工具。

备考策略与常见问题 ❓

在明确了复习重点后,我们来看看一些具体的备考策略和常见问题的解答。

备考策略建议:

  1. 起点选择:可以考虑从问题二开始,因为这类问题可能需要更多时间编译和调试。在编译间隙,可以思考问题一的解法。
  2. 聚焦重点:优先解决容易的部分,不要在某一个难点上耗费过多时间。考试评分不鼓励“英雄主义”的复杂解法。
  3. 练习巩固:如果你对“单词阶梯”这类算法问题感到生疏,可以在一些编程练习网站上做些简单题目热身。

常见问题解答:

  • 需要自己编写测试吗? 不需要。我们不会根据你编写的测试评分。
  • 代码风格会被评分吗? 不会。我们主要通过自动化测试来评分。
  • 如果考试中代码编译不通过怎么办? 对于非琐碎错误导致的编译失败,通常无法获得部分分数。请务必确保代码能通过 exam check 命令。
  • 遇到系统崩溃等极端情况怎么办? 密切关注课程邮箱,我们会通过邮件发布紧急通知和解决方案。

总结与祝福 ✨

本节课中我们一起学习了COMP6771期末考试的复习要点。

我们回顾了考试的两个核心问题类型:侧重于算法实现的深度问题和侧重于语言特性应用的广度问题。我们明确了考试的环境、流程以及重要的行为准则(特别是严禁抄袭)。接着,我们梳理了各周课程内容的复习优先级,帮助你高效分配时间。最后,我们分享了一些实用的备考策略并解答了常见疑问。

如果你已经较好地完成了前三项作业,那么你已经为这次考试做好了相当充分的准备。请保持自信,合理安排最后的复习时间。

祝大家在考试中一切顺利,取得理想的成绩!也请大家在疫情期间保重身体。期待未来在LinkedIn或其他场合与大家保持联系。再见!👋

posted @ 2026-03-29 09:33  布客飞龙II  阅读(40)  评论(0)    收藏  举报