CoffeeBeforeArch-C---入门笔记-全-

CoffeeBeforeArch C++ 入门笔记(全)

001:什么是C++? 🚀

在本节课中,我们将学习C++语言的基础知识,从最核心的概念开始。我们将了解C++是什么,如何编写一个最基本的C++程序,以及如何编译和运行它。


C++是什么? 🤔

C++是一门编译型静态类型的语言。这句话包含两个核心概念,让我们逐一拆解。

编译型语言

编译型语言意味着我们编写的源代码不能直接执行。我们必须使用一个叫做编译器的系统软件,将源代码翻译成处理器能够理解的可执行文件

例如,在Linux系统上,我们可以使用GCC编译器套件中的g++工具。它会执行预处理、编译、汇编和链接等一系列步骤,最终将main.cpp这样的C++源文件转换为可执行的main文件。

静态类型语言

在程序中,每个值都有一个与之关联的类型。类型定义了值是什么(例如,是浮点数、整数还是复杂的数据结构),以及围绕该值的使用规则(例如,可以使用哪些运算符)。

当说一门语言是静态类型时,意味着一旦为某个值(如变量或函数返回值)指定了类型,该类型在程序运行时就不能改变。编译器在编译时就必须知道所有值的类型,以便生成正确的代码。


编写第一个C++程序 ✍️

上一节我们介绍了C++的基本概念,本节中我们来看看如何编写一个最简单的C++程序。几乎所有C++程序的核心都是一个main函数。

让我们创建一个名为main.cpp的新文件。.cpp是C++源文件的常用扩展名。

在文件中,我们写入以下代码:

int main() {
    return 0;
}

这就是我们能写出的最简单的C++程序。让我们分析一下它的结构。

函数基础

一个函数是一段被命名的代码块。当我们调用这个函数时,就会执行函数体(即花括号{}内的所有代码)中的语句。

以下是函数的基本组成部分:

  • 函数名:这里是main
  • 返回类型:在函数名前指定。这里int表示该函数返回一个整数(正或负的整数)。
  • 参数列表:位于函数名后的圆括号()内。它定义了调用函数时需要传入的值。本例中为空,表示该函数不接受任何参数。
  • 函数体:花括号{}内的所有语句。本例中只有一条return 0;语句。return是C++的关键字(语言保留的具有特殊含义的单词),表示函数结束并返回一个值。在C++中,语句以分号;结尾。

main函数的特殊性

main函数在C和C++中是一个特殊的函数,因为它是每个应用程序逻辑上的执行起点。当我们运行编译后的可执行文件时,程序就从main函数开始执行。虽然程序实际启动时可能涉及全局变量初始化等步骤,但我们可以将main视为程序执行的开始。


编译与运行程序 ⚙️

现在我们已经有了一个基本的程序,接下来看看如何将它变成可以运行的程序。

由于C++是编译型语言,我们需要使用编译器将源代码转换为可执行文件。

以下是编译和运行的步骤:

  1. 编译:在命令行中,使用g++编译器。命令格式为g++ 源文件名 -o 输出可执行文件名。例如:g++ main.cpp -o main。这条命令会生成一个名为main的可执行文件。
  2. 运行:在命令行中,通过./可执行文件名来运行程序。例如:./main。运行我们当前的程序,屏幕上不会显示任何输出,因为它只是执行了return 0;

理解返回值

你可能会问,main函数返回的0去了哪里?main函数的返回值通常用作程序的退出码,用来指示程序是否成功执行。按照惯例,返回0表示程序成功执行完毕,没有错误。

我们可以在命令行中查看上一个运行程序的退出码。使用命令echo $?,它会显示我们刚刚运行的main程序的返回值。

我们可以修改main.cpp中的返回值,例如改为return 12;记住,每次修改源代码后,都必须重新编译。再次执行g++ main.cpp -o main./main,然后使用echo $?,就能看到返回值变成了12


总结与资源 📚

本节课中我们一起学习了C++的基础知识。我们了解到C++是一门编译型静态类型的语言。我们编写了第一个C++程序——一个简单的main函数,并理解了它是程序的执行起点。最后,我们学会了如何使用g++编译器将源代码编译成可执行文件,并运行和查看其返回值。

随着课程的深入,我们将探讨更多C++的特性和现代功能。如果你想深入学习,以下资源非常有用:

  • cppreference.com:这是学习C++语言和标准的权威参考网站。C++有多个标准版本(如C++98、C++11、C++17、C++20、C++23等),不同编译器对标准的支持略有不同,我们会在后续课程中讨论。
  • GitHub代码仓库:本系列及相关项目的代码可以在 github.com/coffeebeforearch 找到。

002:打印输出 📝

在本节课中,我们将学习C++中一个非常基础且重要的操作:打印输出。我们将了解如何使用C++标准库中的工具,将信息输出到屏幕上,这对于调试程序或构建日志功能至关重要。

概述

打印是程序中的一项基础操作。无论是实现日志记录器,还是为了在编程学习过程中更好地理解程序行为,我们都需要将信息输出到屏幕。幸运的是,C++标准库提供了内置的输入输出功能,我们无需从头开始实现。本节课将重点介绍如何使用 std::cout 这个字符输出流对象进行屏幕打印。

创建程序文件

首先,我们需要一个C++源文件。让我们创建一个名为 print.cpp 的新文件。

// print.cpp
int main() {
    return 0;
}

正如上一节所介绍的,main 函数是所有C++应用程序的核心,程序从这里开始执行。

包含必要的头文件

在使用 std::cout 之前,我们需要在源文件中包含其定义。std::cout 定义在 <iostream> 头文件中。

#include <iostream>

int main() {
    return 0;
}

#include 是一个预处理指令。在编译过程中,预处理器会找到指定的头文件(如 <iostream>),并将其内容复制粘贴到我们的源文件中,替换掉 #include 语句。这样,编译器在编译 main 函数时就能知道 std::cout 是什么。

我们可以使用 g++ -E 命令来观察预处理后的文件内容,它会展示被插入的头文件代码。

使用 std::cout 进行打印

包含头文件后,我们就可以使用 std::cout 进行打印了。打印操作使用双小于号 << 运算符。

#include <iostream>

int main() {
    std::cout << "Hello, World!\n";
    return 0;
}

这里的 << 是输出流对象的运算符,它的含义是“将右侧的内容送入左侧的流中”。对于 std::cout,就意味着将内容打印到屏幕。\n 是一个换行符。

编译并运行这个程序,你将在屏幕上看到 “Hello, World!”。

链式打印

我们可以将多个 << 运算符连接起来,一次性打印多个内容。

#include <iostream>

int main() {
    std::cout << "Hello, " << "World!\n";
    return 0;
}

这行代码与之前的效果相同,但将字符串分成了两部分进行打印。

打印其他数据类型

std::cout 不仅可以打印字符串,还可以打印整数、浮点数等其他基本数据类型。

#include <iostream>

int main() {
    std::cout << 1 << " " << "World!\n";
    return 0;
}

这段代码会输出 “1 World!”,然后换行。

关于 std:: 和命名空间

你可能注意到了 std:: 这个前缀。std 是一个命名空间,它包含了C++标准库的所有名称。:: 是作用域解析运算符,用于指明我们使用的是 std 命名空间中的 cout。我们将在后续课程中更详细地讨论命名空间。

总结

本节课我们一起学习了C++中的打印输出。

  • 我们了解到打印是程序的基础功能,可以通过C++标准库实现。
  • 我们学会了使用 #include <iostream> 来包含必要的头文件定义。
  • 我们掌握了使用 std::cout << 将字符串和其他数据类型输出到屏幕的基本方法。
  • 我们还看到了如何通过链式 << 运算符进行多次打印。

std::cout 是输入输出流体系的一部分,未来我们还会学习如何从用户那里获取输入(使用 std::cin),以及如何进行文件读写。掌握打印是理解这些更复杂操作的第一步。

003:变量 🧮

在本节课中,我们将要学习C++中的变量。变量是编程的基础,它允许我们为值赋予名称,从而使代码更具表达性和可读性。我们将从变量的定义和初始化开始,逐步探索如何使用它们,并介绍一个名为“自动类型推导”的便捷功能。


变量的定义与初始化

上一节我们介绍了程序由值构成。本节中我们来看看如何为这些值命名,即创建变量。

在C++中,变量是值的名称。每个值都有其类型,因此每个变量也必须有一个类型。定义变量时,我们首先指定其类型,然后为其命名。

以下是一个定义并初始化整数变量的例子:

int var1 = 10;

这行代码告诉编译器:我需要一个整数,并将它命名为 var1,同时将其初始化为 10

最佳实践:定义变量时应立即初始化。将定义和初始化分开可能导致使用未初始化的变量,从而引发难以察觉的错误。


使用变量进行计算

定义变量后,我们可以像使用原始值一样使用它们。变量名可以参与各种运算。

以下是使用变量进行计算的示例:

int var1 = 10;
int var2 = 20;
int var3 = var1 + var2; // var3 的值为 30

这里,var1var2 只是整数的名称。所有适用于整数的运算符同样适用于这些变量。


打印变量的值

我们可以使用 std::cout 来打印变量的值,就像打印直接值一样。

以下是打印变量值的代码:

#include <iostream>
int main() {
    int var3 = 30;
    std::cout << var3 << std::endl; // 输出:30
    return 0;
}

对于基本类型(如 int, double),std::cout 可以直接处理,无需额外修改。


探索其他数据类型:浮点数

除了整数,C++还支持浮点数(带小数点的数字),例如 double 类型。

以下是使用双精度浮点数的示例:

double var1 = 10.5;
double var2 = 20.7;
double var3 = var1 + var2; // var3 的值为 31.2

即使将变量类型从 int 改为 double,打印语句也无需任何更改,这非常方便。


自动类型推导:auto 关键字

有时,明确指定变量类型是多余的,因为编译器可以从初始化值中推断出类型。为此,C++提供了 auto 关键字。

以下是使用 auto 的示例:

auto var1 = 10.5;   // 编译器推断 var1 为 double
auto var2 = 20.7;   // 编译器推断 var2 为 double
auto var3 = var1 + var2; // 编译器推断 var3 为 double

使用 auto 时,必须在定义时进行初始化,因为编译器需要根据初始值来推断类型。例如,auto var; 这样的语句会导致编译错误。

auto 的优势:在处理复杂类型(如迭代器)时,auto 可以显著简化代码,避免书写冗长的类型名称,同时保持程序行为不变。


核心概念总结

本节课中我们一起学习了C++变量的核心知识:

  1. 定义与初始化:变量是具名的值,定义时需要指定类型(如 int, double),并建议立即初始化。
  2. 使用变量:变量可以像其对应的原始值一样参与运算和打印。
  3. 数据类型:C++提供了多种基本数据类型,包括整数和浮点数。
  4. 自动类型推导:使用 auto 关键字可以让编译器自动推断变量类型,简化代码,但要求定义时必须初始化。

变量是构建更复杂程序的基础。掌握如何有效地使用它们,是学习C++的重要一步。

004:条件语句 🧠

在本节课中,我们将要学习C++中的条件语句。条件语句是程序实现决策能力的关键,它允许我们根据特定条件的真假,选择性地执行不同的代码块。我们将重点学习 ifelse 关键字的基本用法。


创建程序文件

首先,我们需要创建一个新的C++源文件。我们将其命名为 condition.cpp

在文件中,我们从主函数 main 开始,这是所有C++程序的执行起点。

#include <iostream>

int main() {
    // 程序代码将写在这里
    return 0;
}

使用 if 语句

上一节我们创建了程序的基本框架,本节中我们来看看如何使用 if 语句。

if 语句允许我们检查一个条件。如果条件为真(true),则执行其后的代码块;如果为假(false),则跳过该代码块。

以下是创建变量并使用 if 语句进行比较的步骤:

  1. 我们创建两个整数变量 ab
  2. 使用 if 关键字检查 a 是否小于 b
  3. 如果条件为真,则执行花括号 {} 内的代码。
int a = 5;
int b = 10;

if (a < b) {
    std::cout << "A is less than B\n";
}

在这个例子中,因为 a (5) 确实小于 b (10),所以条件 a < b 为真,程序会打印出 “A is less than B”。

如果我们将 a 的值改为 10,使其等于 b,那么条件 a < b 将变为假。程序会跳过 if 语句块内的代码,直接执行 return 0,因此不会有任何输出。


使用 else 语句

仅仅使用 if 语句,我们只能处理条件为真的情况。为了处理条件为假的情况,我们可以使用 else 语句。

else 语句不需要指定条件,它自动捕获与之配对的 if 语句条件为假的情况。

以下是结合 ifelse 的示例:

int a = 10;
int b = 10;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/59e3ad1b5be40acd025aa8e1af42177a_12.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/59e3ad1b5be40acd025aa8e1af42177a_14.png)

if (a < b) {
    std::cout << "A is less than B\n";
} else {
    std::cout << "A is not less than B\n";
}

此时,a 等于 bif 条件 a < b 为假。因此,程序会跳过 if 块,转而执行 else 块中的代码,打印出 “A is not less than B”。

重要提示:使用 ifelse 时,最多只会执行其中一个代码块,永远不会同时执行两者。


使用 else if 链式判断

有时我们需要检查多个互斥的条件。这时,可以使用 else if 将多个条件判断链接起来。

else if 允许我们在上一个 ifelse if 条件为假时,检查另一个条件。

以下是检查变量 ab 之间所有可能关系(小于、等于、大于)的示例:

int a = 15;
int b = 10;

if (a < b) {
    std::cout << "A is less than B\n";
} else if (a == b) { // 注意:使用双等号 `==` 进行比较
    std::cout << "A is equal to B\n";
} else {
    std::cout << "A is greater than B\n";
}

程序会按顺序检查条件:

  1. 首先检查 a < b,为假。
  2. 然后检查 a == b,为假。
  3. 最后,执行 else 块,打印 “A is greater than B”。

同样,在整个 if - else if - else 链中,最多只有一个代码块会被执行。


嵌套条件语句

条件语句不仅可以链式排列,还可以相互嵌套。这意味着我们可以在一个 ifelse 代码块内部,再放置另一个完整的 if-else 语句。

这允许我们进行更复杂、更精细的条件判断。

以下是一个嵌套 if 语句的示例,它在 a 大于 b 的情况下,进一步检查 a 是否等于 15:

int a = 15;
int b = 10;

if (a < b) {
    std::cout << "A is less than B\n";
} else if (a == b) {
    std::cout << "A is equal to B\n";
} else {
    std::cout << "A is greater than B\n";
    // 嵌套的 if 语句
    if (a == 15) {
        std::cout << "A is equal to 15!\n";
    }
}

在这个例子中:

  1. 外层条件判断 a 大于 b,进入 else 块,打印 “A is greater than B”。
  2. 随后,程序执行嵌套在 else 块内的 if (a == 15) 语句。
  3. 因为 a 确实等于 15,所以继续打印 “A is equal to 15!”。

因此,运行此程序会得到两行输出。


总结

本节课中我们一起学习了C++条件语句的核心概念。我们掌握了如何使用 if 语句在条件为真时执行代码,如何使用 else 语句处理条件为假的情况,以及如何通过 else if 链和嵌套结构来处理多个复杂的条件判断。这些工具是赋予程序逻辑和决策能力的基础。

记住代码中的关键区别:单等号 = 用于赋值,而双等号 == 用于比较是否相等

005:std::array

概述

在本节课中,我们将要学习C++标准库中的一个重要容器:std::array。我们将了解它的基本概念、如何创建和初始化它,以及如何访问和修改其中的元素。


什么是 std::array? 🤔

在程序中,我们经常需要将多个值组合在一起。例如,我们可能需要收集一年中的温度数据。我们不想为每一个测量值都创建一个独立的变量。理想情况下,我们希望将它们收集到一个单一的变量中,并且能够访问其中的各个部分。

这正是我们可以使用标准库中的容器来实现的。具体来说,今天我们将要了解一个名为 std::array 的容器。这个容器封装了固定大小的数组。我们需要指定元素的数量,并且它可以存储特定类型 T 的元素。例如,我们可以存储100个整数、75个浮点数或22个双精度浮点数。我们只需要指定想要存储的类型以及在这个数组中想要存储多少个该类型的元素。然后,我们就可以通过这一个数组来访问其内容,而不需要为所有那些独立的数据片段创建75个独立的变量。


如何使用 std::array 🛠️

准备工作

在开始使用 std::array 之前,我们需要包含它的定义,就像我们需要包含 <iostream> 才能使用输入输出流一样。std::array 的定义位于 <array> 头文件中。

以下是创建和使用 std::array 的基本步骤。

1. 包含必要的头文件

首先,我们需要在源文件的顶部包含 <array> 头文件。同时,为了能够打印数组的内容,我们也需要包含 <iostream>

#include <array>
#include <iostream>

2. 创建 std::array 变量

std::array 本质上是一个模板,这意味着它不是一个具体的类型。它是一个用于创建各种数组的模板。因此,我们需要提供模板参数来指定我们想要存储的类型以及元素的数量。

我们使用尖括号 < > 来提供这些模板参数。

std::array<int, 3> my_array;

这行代码的意思是:创建一个名为 my_arraystd::array,它存储3个整数。

3. 初始化 std::array

与定义变量一样,我们通常希望对其进行初始化,以防止使用未初始化的变量。我们可以使用一种称为“聚合初始化”的方法来初始化 std::array

std::array<int, 3> my_array = {45, 23, 3};

现在,my_array 中的三个元素分别被设置为45、23和3。我们也可以省略等号,直接使用花括号。

std::array<int, 3> my_array{45, 23, 3};

4. 访问 std::array 的元素

std::array 提供了多种方法来访问其元素。以下是一些常用的方法:

使用 at() 方法

at() 方法可以访问指定位置的元素,并且会进行边界检查。如果尝试访问超出范围的元素,它会抛出异常。

std::cout << my_array.at(0) << std::endl; // 输出第一个元素:45

使用下标运算符 []

下标运算符 [] 也可以用来访问元素,但它不进行边界检查。因此,使用时要确保索引在有效范围内。

std::cout << my_array[1] << std::endl; // 输出第二个元素:23

使用 front()back() 方法

front() 方法返回数组的第一个元素,back() 方法返回数组的最后一个元素。

std::cout << my_array.front() << std::endl; // 输出第一个元素:45
std::cout << my_array.back() << std::endl;  // 输出最后一个元素:3

5. 修改 std::array 的元素

我们可以通过赋值来修改数组中的元素。

my_array[0] = 10; // 将第一个元素修改为10

如果我们想要将数组中的所有元素都设置为同一个值,可以使用 fill() 方法。

my_array.fill(54); // 将所有元素设置为54

6. 查询 std::array 的大小

std::array 提供了一个 size() 方法,用于返回数组中元素的数量。

std::cout << my_array.size() << std::endl; // 输出数组的大小:3

这种方法非常有用,因为它允许我们直接查询容器的大小,而不需要额外维护一个变量来记录元素数量。这使得代码更加简洁和表达性强。


总结

在本节课中,我们一起学习了 std::array 的基本用法。我们了解了如何创建和初始化 std::array,以及如何访问和修改其中的元素。我们还学习了如何使用 size() 方法查询数组的大小。std::array 是一个非常有用的容器,它封装了固定大小的数组,并提供了许多便捷的方法来操作数据。

在后续的课程中,当我们学习循环和算法时,还会接触到 std::array 的迭代器以及其他更高级的功能。希望这节课对你有所帮助!

006:循环结构

概述

在本节课中,我们将要学习C++中的两种循环结构:C风格for循环和基于范围的for循环。循环是编程中用于重复执行代码块的核心工具,能帮助我们高效地处理重复性任务。


C风格for循环

上一节我们介绍了循环的基本概念,本节中我们来看看最基础的C风格for循环。

C风格for循环的语法结构如下:

for (初始化; 条件; 更新) {
    // 循环体
}

以下是for循环的三个组成部分:

  1. 初始化:在循环开始前执行一次,通常用于初始化循环计数器。
  2. 条件:在每次循环迭代开始前检查。如果条件为真,则执行循环体;如果为假,则退出循环。
  3. 更新:在每次循环迭代结束后执行,通常用于更新循环计数器。

让我们通过一个简单的例子来理解。假设我们想打印数字0到4:

#include <iostream>

int main() {
    for (int i = 0; i < 5; i++) {
        std::cout << "迭代次数: " << i << std::endl;
    }
    return 0;
}

程序执行流程如下:

  1. 初始化i为0。
  2. 检查条件i < 5是否为真(0<5为真)。
  3. 执行循环体,打印“迭代次数: 0”。
  4. 执行更新语句i++i变为1。
  5. 重复步骤2-4,直到i变为5,此时条件i < 5为假,循环结束。


基于范围的for循环

了解了传统的C风格循环后,我们来看看C++11引入的、更简洁的基于范围的for循环。

基于范围的for循环主要用于遍历容器(如数组、向量等)中的所有元素。其语法更简洁,不易出错。

以下是基于范围的for循环语法:

for (元素类型 变量名 : 容器) {
    // 使用变量名操作当前元素
}

让我们用一个例子来打印数组中的所有元素:

#include <iostream>
#include <array>

int main() {
    std::array<int, 5> my_array = {42, 12, 63, 1, 3};

    for (int value : my_array) {
        std::cout << value << std::endl;
    }
    return 0;
}

这个循环会自动遍历my_array中的每个元素。在每次迭代中,变量value会被设置为数组中当前元素的值,然后执行循环体中的代码。

基于范围的for循环有两个主要优点:

  1. 代码更简洁易读:无需手动管理循环计数器和索引。
  2. 避免“差一错误”:由于循环自动处理边界,因此不会出现多循环一次或少循环一次的错误。

总结

本节课中我们一起学习了C++中的两种循环结构。

我们首先学习了C风格for循环,它通过初始化条件更新三个部分来控制循环的执行次数,适用于需要精确控制迭代过程的场景。

接着,我们学习了基于范围的for循环,这是一种更现代、更简洁的语法,特别适合遍历容器中的所有元素。它能提高代码的可读性并减少常见错误。

在实际编程中,你可以根据具体需求选择合适的循环类型。两种循环都是C++程序员工具箱中的重要组成部分。

007:While循环 🔄

在本节课中,我们将要学习C++中的while循环和do-while循环。这两种循环结构允许我们在不确定具体迭代次数的情况下,重复执行一段代码,直到某个条件不再满足。


循环回顾与引入

上一节我们介绍了for循环的基础知识,它允许我们遍历一个确定的范围或容器。然而,在编程中,我们并不总是能预先知道需要循环多少次。循环的次数可能依赖于外部输入,或者我们需要持续执行某段代码直到某个特定条件发生。在C和C++中,我们可以使用while循环来表达这种逻辑,它还有另一种形式叫做do-while循环。本节中我们来看看这两种循环的具体用法。


While循环基础

while循环会重复执行一个语句,直到其条件变为假。关键点在于,条件检查发生在每次迭代之前。如果条件为真,则执行循环体内的代码;否则,程序将跳过循环继续执行。

以下是while循环的基本语法结构:

while (condition) {
    // 循环体:当条件为真时执行的代码
}

让我们通过一个简单的例子来理解。假设我们有一些工作项需要处理,但数量不确定。

#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/c9d18556d652157eb40df13167d24ceb_3.png)

int main() {
    int work_items = 10; // 初始化工作项数量

    // 当工作项数量大于0时,持续执行循环
    while (work_items > 0) {
        // 模拟处理一个工作项
        work_items -= 1;
        // 打印当前剩余的工作项数量
        std::cout << "工作项数量: " << work_items << std::endl;
    }

    return 0;
}

在这个例子中,循环会持续执行,每次迭代减少一个work_items,并打印出剩余数量,直到work_items不再大于0。当work_items变为0时,条件work_items > 0变为假,循环终止。

while循环非常适用于处理基于外部输入(如从队列中获取任务)或需要迭代至达到某个目标(如求解问题)的场景,因为我们无法预先知道确切的迭代次数。


Do-While循环

do-while循环是while循环的一个变体。它与while循环的主要区别在于:条件检查发生在每次迭代之后。这意味着do-while循环至少会执行一次循环体,然后再判断是否继续。

以下是do-while循环的基本语法结构:

do {
    // 循环体:至少执行一次的代码
} while (condition);

让我们看一个例子,即使初始条件为假,循环体也会执行一次。

#include <iostream>

int main() {
    int work_items = 0; // 初始工作项数量为0

    // 先执行一次循环体,再检查条件
    do {
        std::cout << "工作项数量: " << work_items << std::endl;
        work_items -= 1; // 即使没有工作项,也执行一次“处理”
    } while (work_items > 0); // 执行后检查条件

    return 0;
}

运行这段代码,你会看到即使work_items初始值为0,不满足work_items > 0的条件,程序仍然会输出一次“工作项数量: 0”。这是因为代码先执行了do块内的语句,然后才进行条件判断。

do-while循环的典型应用场景是需要至少执行一次操作的情况。例如,在编写命令行菜单或用户交互界面时,程序启动后总是需要先获取一次用户输入,然后再根据输入判断下一步行动,这时do-while循环就非常合适。


总结

本节课中我们一起学习了C++中两种重要的循环结构:

  1. while循环:在每次迭代之前检查条件。只有当条件为真时,才会执行循环体。它适用于迭代次数未知,但需要先判断再执行的场景。
  2. do-while循环:在每次迭代之后检查条件。它保证循环体至少执行一次,然后再根据条件决定是否继续。它适用于那些必须至少执行一次操作的场景,例如用户输入处理。

理解这两种循环的区别和适用场景,对于编写灵活、健壮的程序至关重要。你可以根据程序逻辑的具体需求,选择合适的循环结构。

008:函数 🧩

在本节课中,我们将要学习C++中的函数。函数是编程中一种重要的控制流工具,它允许我们将一系列语句命名并打包,以便在程序中重复使用,从而避免代码重复,并使代码结构更清晰、更易于维护。

在之前的课程中,我们学习了条件语句和循环,它们分别用于选择性执行和重复执行代码。本节我们将探讨另一种控制流结构——函数。

什么是函数?🤔

函数允许我们为一系列想要执行的语句赋予一个名称。这样,每当我们需要执行这些操作时,只需调用该函数名即可,而无需重复编写相同的代码。

为什么需要函数?

我们经常需要在程序的不同位置执行相同的操作,但这些操作可能不是连续进行的(例如,在程序开始、中间和结束时各执行一次)。使用循环无法很好地解决这种非连续重复的问题。此外,直接复制粘贴代码会导致代码重复,使得程序冗长且难以维护。通过函数,我们可以将公共代码提取出来,赋予其一个名称,实现代码的复用。

函数的基本结构 🏗️

我们已经接触过一个特殊的函数——main函数,它是所有C++程序的入口点。实际上,main函数的结构与我们自定义的函数结构是相同的。

一个函数的核心组成部分包括:

  1. 返回类型:函数执行完毕后返回给调用者的值的类型。如果函数不返回任何值,则使用 void
  2. 函数名:用于标识和调用函数的名称。
  3. 参数列表:位于函数名后的圆括号 () 内,定义了函数可以接收的输入(参数)。可以为空。
  4. 函数体:由一对花括号 {} 包裹,包含了函数要执行的所有语句。

以下是 main 函数的结构示例:

int main() { // 返回类型为 int,函数名为 main,参数列表为空
    // 函数体
    return 0; // 返回一个整数值
}

编写一个无返回值的函数 📝

让我们通过一个例子来学习如何编写自己的函数。假设我们有一个常见操作:打印数组的内容。

首先,我们创建一个源文件(例如 functions.cpp),并包含必要的头文件,然后定义一个数组。

#include <array>
#include <iostream>

int main() {
    // 创建两个数组
    std::array<int, 3> my_array1 = {1, 2, 3};
    std::array<int, 3> my_array2 = {4, 5, 6};

    // 不使用函数时,需要重复编写打印代码
    for (int value : my_array1) {
        std::cout << value << ' ';
    }
    std::cout << '\n';

    for (int value : my_array2) {
        std::cout << value << ' ';
    }
    std::cout << '\n';

    return 0;
}

如你所见,打印两个数组需要重复几乎相同的代码块。为了解决这个问题,我们可以编写一个函数。

创建 print_array 函数

我们将创建一个名为 print_array 的函数,它接收一个 std::array<int, 3> 类型的参数,并负责打印其内容。由于它只执行打印操作而不返回任何值,其返回类型为 void

// 函数定义
void print_array(std::array<int, 3> array) { // 返回类型 void,函数名 print_array,参数为 array
    for (int value : array) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

现在,我们可以在 main 函数中调用这个函数,从而消除代码重复:

int main() {
    std::array<int, 3> my_array1 = {1, 2, 3};
    std::array<int, 3> my_array2 = {4, 5, 6};

    // 调用函数来打印数组
    print_array(my_array1); // 将 my_array1 作为参数传递给函数
    print_array(my_array2); // 将 my_array2 作为参数传递给函数

    return 0;
}

程序执行时,控制流会跳转到 print_array 函数内部执行其语句,执行完毕后返回到 main 函数继续执行下一行。这样,代码变得更简洁、更具表达力。

编写一个有返回值的函数 🔄

上一节我们介绍了无返回值(void)的函数。本节中我们来看看如何编写一个会返回计算结果的函数。

假设我们需要一个函数来计算数组中所有元素的总和。

我们创建一个新的源文件(例如 returnvalue.cpp)。首先,定义一个数组,然后编写求和函数。

创建 sum 函数

这个函数需要接收一个数组作为输入,遍历并累加所有元素,最后将总和返回。因此,它的返回类型应该是 int

#include <array>
#include <iostream>

// 函数定义:返回数组元素之和
int sum(std::array<int, 3> array) { // 返回类型为 int
    int result = 0; // 初始化累加器
    for (int value : array) {
        result += value; // 将每个元素加到 result 上
    }
    return result; // 返回最终的计算结果
}

int main() {
    std::array<int, 3> my_array = {1, 2, 3};

    // 调用函数并将返回值存储在一个变量中
    int total = sum(my_array);

    // 打印结果
    std::cout << "The sum of the array is: " << total << '\n';

    return 0;
}

sum 函数被调用时,它执行计算并通过 return 语句将结果(一个整数值)传递回调用处。在 main 函数中,我们将这个返回值存储在变量 total 中,然后将其打印出来。运行程序,输出应为 The sum of the array is: 6

总结 📚

本节课中我们一起学习了C++函数的基础知识。

我们首先了解了函数的概念及其在避免代码重复、提高代码可读性和可维护性方面的重要性。接着,我们剖析了函数的基本结构,包括返回类型、函数名、参数列表和函数体。

通过两个具体的例子,我们实践了如何编写函数:

  1. 我们编写了一个无返回值(void)的 print_array 函数,用于打印数组内容,从而消除了重复的打印代码。
  2. 我们编写了一个有返回值(int)的 sum 函数,用于计算数组元素的总和,并学会了如何接收和使用函数的返回值。

函数是构建模块化、高效C++程序的核心工具。掌握函数的定义和使用,是迈向更复杂编程的重要一步。

009:函数重载与重载决议

在本节课中,我们将要学习C++中的函数重载与重载决议。通过这两个特性,我们可以让多个函数共享同一个名字,而编译器会根据我们调用时提供的参数,自动选择正确的函数版本。

上一节我们介绍了函数的基本形式、编写和使用方法。本节中我们来看看如何处理那些功能相似,但操作不同类型或参数数量不同的函数。

概述

函数重载允许我们为多个功能相似的函数使用相同的名称。重载决议是编译器根据函数调用时提供的参数,从所有重载函数中选择最匹配的那个版本的过程。这避免了为每个微小变体创建不同函数名的麻烦。

创建示例程序

首先,我们创建一个新的源文件 overloading.cpp,并包含必要的头文件。

#include <array>
#include <iostream>

接着,我们在 main 函数中定义两个数组,一个整型数组和一个浮点型数组。

int main() {
    std::array<int, 3> my_int_array = {1, 2, 3};
    std::array<float, 3> my_float_array = {1.1f, 2.2f, 3.3f};
    // ... 后续代码
}

不使用重载的函数

假设我们需要打印这两个数组。一种方法是创建两个名称不同的函数。

以下是打印整型数组的函数:

void print_int_array(std::array<int, 3> my_array) {
    for (auto value : my_array) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

以下是打印浮点型数组的函数:

void print_float_array(std::array<float, 3> my_array) {
    for (auto value : my_array) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

然后在 main 函数中分别调用它们:

print_int_array(my_int_array);
print_float_array(my_float_array);

这种方法可以工作,但要求使用者记住两个不同的函数名,接口设计不够优雅。

引入函数重载

利用函数重载,我们可以为这两个函数赋予相同的名字。编译器通过函数签名(函数名 + 参数的数量、类型及顺序)来区分它们。

以下是重载后的 print_array 函数:

// 重载版本1:处理整型数组
void print_array(std::array<int, 3> my_array) {
    for (auto value : my_array) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

// 重载版本2:处理浮点型数组
void print_array(std::array<float, 3> my_array) {
    for (auto value : my_array) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

现在,在 main 函数中,我们可以统一使用 print_array 这个名称:

print_array(my_int_array);   // 调用整型版本
print_array(my_float_array); // 调用浮点型版本

编译器会根据传入参数 my_int_arraymy_float_array 的类型,自动选择对应的函数版本。这简化了接口,使用者只需记住一个函数名。

重载决议失败的情况

编译器必须能够明确区分不同的重载函数。如果两个函数的签名完全相同,即使函数体不同,也会导致编译错误。

例如,以下代码会导致“重定义”错误:

void print_array(std::array<int, 3> my_array) {
    for (auto value : my_array) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

// 错误:函数签名与上一个函数完全相同
void print_array(std::array<int, 3> my_array) {
    std::cout << "Hello World\n";
}

编译器无法判断在调用 print_array(my_int_array) 时应该使用哪个函数,因此会报错。重载决议的核心是消除歧义。

当前方案的局限性

虽然函数重载解决了命名问题,但我们仍然存在代码重复。两个 print_array 函数的函数体几乎完全一样,唯一的区别是参数类型。

在下一节中,我们将学习使用模板来解决这种代码重复的问题,编写出更通用、更简洁的代码。

总结

本节课中我们一起学习了C++的函数重载与重载决议。

  • 函数重载允许我们为多个功能相似的函数使用相同的名称。
  • 重载决议是编译器根据函数调用时的实际参数,从所有重载函数中选择最匹配版本的过程。
  • 区分重载函数的依据是函数签名(函数名 + 参数类型、数量及顺序)。
  • 如果多个重载函数的签名完全相同,将导致编译错误,因为编译器无法消除歧义。
  • 函数重载提供了清晰的接口,但可能带来代码重复,这可以通过后续学习的模板技术来优化。

010:函数模板 🧩

在本节课中,我们将要学习C++中的函数模板。函数模板是解决代码重复问题的强大工具,它允许我们编写一个通用的函数“蓝图”,让编译器根据我们使用的具体类型自动生成对应的函数版本。

上一节我们介绍了函数重载和重载解析,本节中我们来看看如何利用函数模板来避免因重载相似函数而导致的代码重复。

概述:为何需要函数模板?

在之前的例子中,我们有两个打印数组的函数:一个处理整数数组,另一个处理浮点数数组。这两个函数的函数体完全相同,唯一的区别是参数类型。手动为每种类型编写一个函数会导致代码冗余和维护困难。

函数模板通过允许我们编写一个“模板”来解决这个问题。我们只需定义一次函数逻辑,并指定一个或多个类型参数(如 T)。当我们在代码中使用这个模板时,编译器会根据我们提供的具体类型(如 intfloat)自动生成对应的函数代码。

编写第一个函数模板

让我们从一个简单的例子开始,创建一个可以打印任何类型数组的函数模板。

首先,我们需要包含必要的头文件并设置主函数。

#include <array>
#include <iostream>

int main() {
    // 创建示例数组
    std::array<int, 3> my_int_array = {1, 2, 3};
    std::array<float, 3> my_float_array = {1.1f, 2.2f, 3.3f};

    // 后续将在这里调用我们的模板函数
    return 0;
}

接下来,我们编写函数模板 print_array。模板声明使用 template 关键字,后跟类型参数列表。

// 函数模板声明
template <typename T>
void print_array(const T& arr) {
    for (auto value : arr) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

代码解析

  • template <typename T>:这声明了一个模板,T 是一个占位符,代表某种类型。
  • void print_array(const T& arr):函数接受一个类型为 T 的常量引用参数 arrT 的具体类型将在调用时确定。
  • 函数体使用范围 for 循环遍历数组并打印每个元素。

使用函数模板

有多种方式可以调用函数模板。

方法一:显式指定模板参数

我们可以在函数名后的尖括号 <> 中明确告诉编译器我们想要哪个版本。

int main() {
    std::array<int, 3> my_int_array = {1, 2, 3};
    std::array<float, 3> my_float_array = {1.1f, 2.2f, 3.3f};

    // 显式指定模板参数类型
    print_array<std::array<int, 3>>(my_int_array);
    print_array<std::array<float, 3>>(my_float_array);

    return 0;
}

方法二:让编译器自动推导类型

在大多数情况下,编译器可以根据传入函数的参数自动推导出模板参数 T 的类型。这使得调用模板函数和调用普通函数一样简单。

int main() {
    std::array<int, 3> my_int_array = {1, 2, 3};
    std::array<float, 3> my_float_array = {1.1f, 2.2f, 3.3f};

    // 编译器自动推导类型
    print_array(my_int_array);   // 编译器推导出 T 是 std::array<int, 3>
    print_array(my_float_array); // 编译器推导出 T 是 std::array<float, 3>

    return 0;
}

这种方式更简洁,也是实践中更常用的方法。

C++20 中的简写函数模板语法 ✨

C++20 引入了一种更简洁的模板函数定义方式,称为“简写函数模板”(Abbreviated Function Template)。它允许我们使用 auto 关键字作为函数参数的类型,编译器会自动将其视为一个模板。

以下是使用简写语法的例子:

// C++20 简写函数模板
void print_array_abbreviated(const auto& arr) {
    for (auto value : arr) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/4c78d75e0fd918a83304b78368d585be_13.png)

int main() {
    std::array<int, 3> my_int_array = {1, 2, 3};
    std::array<float, 3> my_float_array = {1.1f, 2.2f, 3.3f};

    print_array_abbreviated(my_int_array);
    print_array_abbreviated(my_float_array);

    return 0;
}

注意:此功能需要编译器支持 C++20 标准。在编译时,你可能需要添加 -std=c++20 标志(例如:g++ -std=c++20 your_file.cpp)。

编译与运行

无论使用哪种定义和调用方式,编译并运行程序后,你都将看到如下输出:

1 2 3
1.1 2.2 3.3

这证明编译器成功为我们生成了处理 int 数组和 float 数组的两个不同函数版本。

总结

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

  1. 目的:函数模板主要用于消除处理不同数据类型的、功能相同的函数之间的代码重复。
  2. 定义:使用 template <typename T> 声明模板,T 是类型占位符。
  3. 使用
    • 可以显式指定模板参数:function_name<Type>(arguments)
    • 更常见的是让编译器根据函数参数自动推导类型。
  4. 现代语法:C++20 的简写函数模板允许使用 auto 作为参数类型来隐式创建模板,使代码更加简洁。

通过将生成具体函数代码的工作交给编译器,函数模板极大地提高了代码的复用性和可维护性。它是C++泛型编程的基石,也是学习标准模板库(STL)的重要前导知识。

011:模板特化

概述

在本节课中,我们将要学习C++中的模板特化。模板特化允许我们为特定的类型或值提供模板的特殊版本,从而覆盖通用模板的行为。这对于处理某些需要特殊逻辑的类型非常有用。

在上一节中,我们介绍了函数模板,它帮助我们解决了为不同类型编写重复代码的问题。本节中我们来看看,当通用模板的逻辑不适用于所有类型时,我们该如何处理。

从函数模板到模板特化

函数模板通过提供一个代码“蓝图”,让编译器为我们生成针对不同数据类型的函数版本,从而避免了代码重复。

然而,有时我们并不希望所有类型都使用完全相同的函数体。例如,我们可能希望对std::array<int, 3>类型的处理方式与其他类型不同。这时,我们就需要使用模板特化

模板特化示例

以下是一个基于打印数组概念的模板特化示例。我们有一个通用的print_array函数模板,但我们将为std::array<int, 3>类型提供一个特化版本。

#include <iostream>
#include <array>

// 通用的函数模板(使用C++20的简写函数模板语法)
void print_array(const auto& input_array) {
    for(const auto& element : input_array) {
        std::cout << element << ' ';
    }
    std::cout << '\n';
}

// 为 std::array<int, 3> 类型提供的模板特化
template<>
void print_array(const std::array<int, 3>& input_array) {
    std::cout << "Printing from our specialization.\n";
}

int main() {
    std::array<int, 3> int_array = {1, 2, 3};
    std::array<float, 3> float_array = {1.1f, 2.2f, 3.3f};

    // 调用特化版本
    print_array(int_array);
    // 调用通用模板生成的版本
    print_array(float_array);

    return 0;
}

代码解析与关键点

以下是上述代码的关键组成部分及其作用:

  1. 通用模板void print_array(const auto& input_array) 是一个通用模板,适用于任何可以通过范围for循环遍历的类型。
  2. 特化声明template<> 这行代码告诉编译器,接下来的函数是之前声明的print_array模板的一个特化版本。尖括号<>为空,表示这是一个完全特化(针对一个具体的类型)。
  3. 特化函数签名void print_array(const std::array<int, 3>& input_array) 明确指定了此特化版本仅适用于std::array<int, 3>类型。
  4. 特化函数体:在特化版本中,我们并没有打印数组元素,而是输出了一条特定的信息,这演示了如何为特定类型提供完全不同的实现。

编译与运行结果

使用支持C++20的编译器(如g++)编译上述代码:

g++ -std=c++20 template_specialization.cpp -o template_specialization

运行生成的可执行文件,输出结果如下:

Printing from our specialization.
1.1 2.2 3.3

可以看到,对于int_array,程序调用了特化版本,打印了特定字符串。而对于float_array,程序则使用了通用模板生成的代码,正常打印了数组元素。

总结

本节课中我们一起学习了C++模板特化的基本概念和用法。我们了解到,当通用模板的逻辑不能满足所有类型的需求时,可以通过编写模板特化来为特定类型提供定制化的实现。其核心步骤是:先声明通用模板,然后使用template<>语法声明一个特化版本,并在函数签名中明确指出特化的具体类型。模板特化是C++泛型编程中实现灵活性和特定优化的强大工具。

注:本示例使用了C++20的简写函数模板语法(auto参数),但模板特化的概念同样适用于传统的template <typename T>语法。更多详细信息可参考 cppreference.com

012:迭代器 🚀

在本节课中,我们将要学习C++中一个非常重要的概念——迭代器。迭代器为我们提供了一种通用的方式来遍历标准模板库(STL)中的容器。它们是连接容器与STL算法的桥梁,理解迭代器是掌握现代C++编程的关键一步。

迭代器是什么?

上一节我们介绍了STL容器的基本概念,本节中我们来看看如何遍历它们。迭代器是一种对象,它能够遍历容器中的元素,并访问这些元素。你可以把迭代器想象成一个智能指针,它指向容器内的某个位置。

迭代器的一个主要用途是与STL算法配合使用。我们可以用迭代器定义一个值的范围,例如从容器的开始到结束,然后将这个范围传递给STL算法,算法会对该范围内的值执行特定操作。

迭代器还定义了一系列要求。并非所有类型的迭代器都能用于每一个STL算法。例如,我们可以对 std::array 使用 std::sort 排序算法,但不能对 std::unordered_map 这样的无序容器使用 std::sort。因此,迭代器为我们在不同场景下使用容器和算法奠定了基础。

基础迭代操作 🛠️

我们将以老朋友 std::array 为例,学习如何使用迭代器遍历容器。之前我们跳过了容器中 beginendrbeginrend 这些方法,现在我们将详细探讨它们。

首先,创建一个源文件 iterator.cpp,并包含必要的头文件。

#include <array>
#include <iostream>

int main() {
    // 创建一个包含5个整数的数组
    std::array<int, 5> my_array = {1, 2, 3, 4, 5};
    // ... 后续代码
}

使用迭代器遍历数组

我们可以使用 for 循环和迭代器来手动遍历容器,这比传统的基于下标的循环更具表达性。

以下是使用迭代器的 for 循环结构:

for (auto itr = my_array.begin(); itr < my_array.end(); itr += 1) {
    std::cout << *itr << " ";
}
std::cout << std::endl;

让我们分解这段代码:

  • 初始化auto itr = my_array.begin(); 将迭代器 itr 设置为指向数组的起始位置。这比使用 int i = 0 更清晰地表达了意图。
  • 条件itr < my_array.end(); 检查迭代器是否尚未到达数组的“末尾之后”的位置。end() 返回的迭代器指向最后一个元素之后的位置。
  • 递增itr += 1; 在每次循环后将迭代器向前移动一个位置,指向下一个元素。
  • 访问值:在循环体内,我们使用解引用运算符 * 来获取迭代器当前指向的值:*itr

编译并运行此程序,将按顺序输出 1 2 3 4 5

反向迭代器 🔄

有时我们需要反向遍历容器。如果手动使用整数下标实现,需要调整起始值、条件和递减逻辑,比较繁琐。而使用反向迭代器则非常简单。

只需将 begin()end() 替换为 rbegin()rend() 即可:

for (auto itr = my_array.rbegin(); itr < my_array.rend(); itr += 1) {
    std::cout << *itr << " ";
}
std::cout << std::endl;
  • rbegin() 返回一个指向容器最后一个元素的反向迭代器。
  • rend() 返回一个指向容器第一个元素之前的反向迭代器。
  • 递增操作 itr += 1 在反向迭代器中意味着向容器的前端移动。

运行修改后的代码,将输出 5 4 3 2 1。这展示了使用迭代器如何让代码更简洁、更具表达力。

使用迭代器偏移

我们不一定总是需要遍历整个容器。迭代器支持偏移操作,允许我们处理容器的子集。

以下是使用偏移的示例:

// 从正向第二个元素开始遍历(偏移1)
for (auto itr = my_array.begin() + 1; itr < my_array.end(); itr += 1) {
    std::cout << *itr << " ";
}
// 输出: 2 3 4 5

// 从反向第三个元素开始遍历(偏移2)
for (auto itr = my_array.rbegin() + 2; itr < my_array.rend(); itr += 1) {
    std::cout << *itr << " ";
}
// 输出: 3 2 1

这种能力在需要将容器的一部分传递给STL算法时非常有用。

总结 📚

本节课中我们一起学习了C++迭代器的核心概念和基本用法。

我们了解到:

  1. 迭代器是遍历STL容器的通用工具,也是使用STL算法的关键。
  2. 使用 begin()end() 可以获取定义范围的正向迭代器。
  3. 使用 rbegin()rend() 可以轻松实现反向遍历。
  4. 通过解引用运算符 * 可以访问迭代器指向的值。
  5. 迭代器支持偏移操作(如 begin() + 1),用于处理容器的子范围。

虽然对于遍历整个容器,基于范围的 for 循环(for (auto val : container))通常更简洁,但理解并掌握迭代器对于进行更复杂的操作、使用STL算法以及理解C++标准库的设计至关重要。


扩展阅读

(注:本教程示例代码可在 Github.com/CoffeeBeforeArch 找到)

013:std::sort 🧮

在本节课中,我们将要学习标准模板库(STL)中的第一个算法:std::sort。我们将了解如何使用它来对容器(如 std::array)中的元素进行排序,并探索C++20引入的更简洁的“范围”语法。

概述

STL算法与标准库容器紧密配合。容器(如 std::array)为我们提供了方便的数据表示方式,而算法则允许我们对这些容器执行常见操作,例如排序。使用这些算法的主要好处之一是避免重复造轮子,编译器供应商提供的实现通常已经非常高效。

准备工作

首先,我们需要包含必要的头文件,并创建一个待排序的数组。

#include <algorithm> // 用于 std::sort
#include <array>     // 用于 std::array
#include <iostream>  // 用于输出

int main() {
    // 创建一个包含5个整数的数组,元素未排序
    std::array<int, 5> my_array = {53, 21, 2, 85, 2};
    // ... 后续代码
}

为了在排序前后查看数组内容,我们创建一个辅助打印函数。

// 使用C++20的缩写函数模板语法定义打印函数
void print(auto array) {
    for (auto value : array) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

使用 std::sort

std::sort 算法的核心是接收一个元素范围(由两个迭代器指定)并对该范围内的元素进行排序。它要求容器提供随机访问迭代器std::array 满足此要求。

以下是使用迭代器调用 std::sort 的基本方法:

// 对 my_array 的全部元素进行排序
std::sort(my_array.begin(), my_array.end());

// 打印排序后的数组
print(my_array);

my_array.begin() 返回指向数组第一个元素的迭代器,my_array.end() 返回指向最后一个元素之后的迭代器。这个范围 [begin, end) 包含了所有需要排序的元素。你也可以通过调整迭代器来排序数组的子集。

C++20 范围语法

C++20引入了约束算法和范围库,提供了更简洁的语法。使用 std::ranges::sort,你可以直接传递整个容器,而无需显式指定开始和结束迭代器。

// 使用C++20的范围语法排序,更简洁
std::ranges::sort(my_array);

// 打印排序后的数组
print(my_array);

这种方法让代码意图更加清晰:你只是想排序这个数组。

完整代码示例

将以上部分组合起来,得到完整的示例程序:

#include <algorithm>
#include <array>
#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/aab151b8dda55764fb73fb2fa7f72b2e_5.png)

// 打印数组的函数
void print(auto array) {
    for (auto value : array) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

int main() {
    // 1. 创建并初始化数组
    std::array<int, 5> my_array = {53, 21, 2, 85, 2};

    // 2. 打印原始数组
    std::cout << "原始数组: ";
    print(my_array);

    // 3. 使用传统迭代器方式排序
    // std::sort(my_array.begin(), my_array.end());

    // 4. 使用C++20范围语法排序(二选一)
    std::ranges::sort(my_array);

    // 5. 打印排序后的数组
    std::cout << "排序后数组: ";
    print(my_array);

    return 0;
}

编译和运行此程序(需支持C++20),你将看到数组从 53 21 2 85 2 被排序为 2 2 21 53 85

总结

本节课中我们一起学习了STL算法库的基础知识,重点掌握了 std::sort 的使用方法。我们了解到:

  1. std::sort 用于对容器内指定范围的元素进行排序。
  2. 它需要接收两个迭代器来定义排序范围,并且要求容器支持随机访问迭代器。
  3. 从C++20开始,可以使用 std::ranges::sort 和范围语法,直接传递容器对象,使代码更加简洁易读。

通过使用这些标准库算法,我们可以更高效、更安全地处理数据,专注于解决更高级别的问题,而非底层实现的细节。

014:std::vector 🧮

在本节课中,我们将要学习C++标准库中的另一个重要容器:std::vector(标准向量)。我们将了解它与之前学过的std::array有何不同,以及如何利用其动态增长的特性。

概述

在编程中,我们常常无法预先知道需要向容器中放入多少个元素。数据可能来自外部源,其总大小在程序开始时并不明确。这给我们之前学过的std::array带来了挑战,因为std::array的模板参数之一就是元素数量,且必须在编译时确定。因此,我们需要一个能够根据需要动态增长的容器,这就是std::vectorstd::vector在很多方面与std::array相似,但它是一个动态大小的数组。

创建与初始化

上一节我们介绍了std::vector的基本概念,本节中我们来看看如何创建和使用它。

首先,我们需要包含必要的头文件。

#include <iostream>
#include <vector>

std::array不同,创建std::vector时不需要指定元素数量。我们只需指定要存储的数据类型。

std::vector<int> my_vector;

我们也可以在定义时使用初始化列表来初始化向量。

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

我们可以像处理std::array一样,使用一个简单的函数来打印向量的内容。

void print(const std::vector<int>& vector) {
    for (auto value : vector) {
        std::cout << value << " ";
    }
    std::cout << "\n";
}

编译并运行程序,会输出向量的内容:1 2 3 4 5

修改向量:添加与删除元素

std::vectorstd::array的一个主要区别在于其修改器方法。因为向量的大小可以改变,所以它提供了添加和删除元素的方法。

以下是std::vector的一些核心修改器方法:

  • push_back:在向量末尾添加一个元素。
  • pop_back:移除向量末尾的元素。
  • insert / emplace:在指定位置插入元素。

让我们尝试使用push_backpop_back

my_vector.push_back(6); // 在末尾添加元素 6
print(my_vector); // 输出: 1 2 3 4 5 6

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/cad0a9ade18f865e6611f9736143f59f_11.png)

my_vector.pop_back(); // 移除最后一个元素
print(my_vector); // 输出: 1 2 3 4 5

运行程序,可以看到向量成功地动态添加和移除了元素。

理解容量与大小

当我们动态修改向量时,一个关键问题是内存是如何管理的。这里需要理解两个概念:大小容量

  • 大小:向量中当前存储的元素数量,通过size()方法获取。
  • 容量:向量底层已分配内存空间所能容纳的元素数量,通过capacity()方法获取。

容量可能大于大小。当我们添加新元素时,如果大小即将超过容量,向量就需要在底层执行一次重新分配,以找到更大的内存块来存放所有元素。这个过程可能影响性能。

让我们通过一个循环观察大小和容量的变化。

std::vector<int> my_vector;
for (int i = 0; i < 10; ++i) {
    std::cout << "Size: " << my_vector.size() << "\n";
    std::cout << "Capacity: " << my_vector.capacity() << "\n";
    my_vector.push_back(i);
}

运行结果会显示,容量并非每次push_back都增加,而是以近似翻倍的方式增长(具体策略因编译器实现而异)。这是为了减少频繁内存分配的开销。

性能优化:预分配内存

频繁的重新分配会影响性能。如果我们能预先知道或将存储的元素数量,可以使用reserve()方法提前分配足够的容量,避免运行时的多次分配。

例如,如果我们知道要存储10个元素:

std::vector<int> my_vector;
my_vector.reserve(10); // 预先分配容量为10

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/cad0a9ade18f865e6611f9736143f59f_17.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/cad0a9ade18f865e6611f9736143f59f_19.png)

for (int i = 0; i < 10; ++i) {
    std::cout << "Size: " << my_vector.size() << "\n";
    std::cout << "Capacity: " << my_vector.capacity() << "\n";
    my_vector.push_back(i);
}

运行后可以看到,容量一开始就是10,之后添加元素时不再需要重新分配内存。

此外,如果向量的容量远大于其当前大小,可以使用shrink_to_fit()方法请求释放未使用的内存,使容量减小到与大小匹配。但这只是一个请求,具体实现可能不保证立即执行。

总结

本节课中我们一起学习了std::vector。我们了解到它是一个可以动态增长和缩小的序列容器,与std::array的固定大小不同。我们学习了如何创建、初始化向量,以及使用push_backpop_back来修改它。更重要的是,我们探讨了向量底层的内存管理机制,理解了大小容量的区别,以及如何通过reserve()方法进行性能优化。std::vector是C++中最常用、最灵活的容器之一,掌握其基本原理对编写高效程序至关重要。在后续课程中,我们将继续探讨emplace_back等其他方法。

015:引用(References)📚

在本节课中,我们将要学习C++中的一个重要概念——引用。引用是一种为已存在的变量创建别名的方法,它可以避免不必要的数据拷贝,从而提高程序性能或满足特定功能需求。


理解变量与拷贝

首先,我们来看一个简单的例子,理解普通变量赋值时发生了什么。

#include <iostream>

int main() {
    int a = 5;
    int b = a; // 将a的值拷贝给b
    b += 1;    // 修改b的值

    std::cout << "a is equal to " << a << std::endl;
    std::cout << "b is equal to " << b << std::endl;

    return 0;
}

在这段代码中:

  • 我们创建了一个名为 a 的整数变量,并将其值设为 5
  • 接着,我们创建了另一个整数变量 b,并将其初始化为 a 的值。这实际上是将 a 的值(5拷贝到为 b 新分配的内存中。
  • 然后,我们将 b 的值增加 1
  • 最后打印结果,a 的值仍然是 5,而 b 的值变成了 6

这是因为 ab 是两块独立的内存空间,对 b 的修改不会影响 a


引入引用

上一节我们介绍了变量的拷贝,本节中我们来看看如何避免拷贝。在C++中,我们可以使用引用来为已存在的变量创建一个别名,而不是创建其副本。

声明引用的语法是使用 & 符号。以下是修改后的代码:

#include <iostream>

int main() {
    int a = 5;
    int &b = a; // b是a的引用(别名)
    b += 1;     // 通过b修改,实际上修改的是a

    std::cout << "a is equal to " << a << std::endl;
    std::cout << "b is equal to " << b << std::endl;

    return 0;
}

在这段代码中:

  • int &b = a; 声明了 b 是整数 a 的一个引用。b 不是新变量,它只是 a 的另一个名字。
  • 当我们执行 b += 1 时,由于 b 指向 a 的内存,所以实际上是将 a 的值从 5 增加到了 6
  • 因此,打印出的 ab 的值都是 6

验证内存地址

为了更直观地理解引用是别名这一概念,我们可以打印出变量和其引用的内存地址。在C++中,使用 & 运算符(取地址运算符)可以获取变量的内存地址。

以下是验证代码:

#include <iostream>

int main() {
    int a = 5;
    int &b = a;

    std::cout << "Address of a: " << &a << std::endl;
    std::cout << "Address of b: " << &b << std::endl;

    return 0;
}

运行这段代码,你会发现 ab 的地址是完全相同的。这证明了 b 并没有自己独立的内存空间,它和 a 共享同一块内存。

作为对比,如果我们回到最初的例子(没有引用,只是两个独立变量),并打印它们的地址:

#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/9e4484ae265fee9f208ccb033037c47b_13.png)

int main() {
    int a = 5;
    int b = a; // 拷贝,不是引用

    std::cout << "Address of a: " << &a << std::endl;
    std::cout << "Address of b: " << &b << std::endl;

    return 0;
}

你会看到 ab 的地址是不同的,这表明它们是存储在不同内存位置的两个独立变量。


总结

本节课中我们一起学习了C++中引用的基础知识:

  1. 引用的定义:引用是为已存在的变量创建的别名,使用 & 符号声明,例如 int &ref = var;
  2. 引用的作用:通过引用操作数据,实际上是在操作原始变量,避免了数据的拷贝。这在处理大型数据(如包含上万个元素的向量)时能显著提升性能。
  3. 引用的本质:引用本身不占用额外的存储空间(从程序员视角),它和其引用的变量共享同一内存地址。

引用是C++中实现高效编程的关键工具之一。在下一节中,我们将探讨引用最常用的场景之一:在函数参数传递中使用按引用传递

更多关于引用声明的详细信息,可以参考 cppreference.com。本节所有示例代码均可在 github.com/coffeebeforearch 找到。

016:按引用传递

在本节课中,我们将学习C++中一个非常实用的概念:按引用传递。我们将探讨它与默认的“按值传递”有何不同,以及为何在某些情况下使用按引用传递是必要且高效的。

概述

默认情况下,C++函数使用按值传递。这意味着当我们调用一个函数时,会将实参的值复制到函数的形参中。然而,有时我们可能不希望进行复制,原因可能是功能性的(例如操作一个锁)或性能上的(例如避免复制一个大型数据结构)。本节课,我们将学习如何使用按引用传递来直接操作原始数据,而非其副本。

准备工作

首先,我们创建一个新的C++源文件,并包含必要的头文件。

#include <iostream>
#include <vector>

我们包含了 <iostream> 用于输入输出,以及 <vector> 用于使用向量容器。

按值传递的示例

上一节我们提到了按值传递的概念,本节中我们通过一个具体例子来看看它的效果。

假设我们有一个函数,其功能是向一个整数向量中添加一系列元素。以下是该函数的初始实现:

void addElements(std::vector<int> vec, int n) {
    for (int i = 0; i < n; ++i) {
        vec.push_back(i);
    }
}

这个函数接收一个向量 vec 和一个整数 n,然后将 0n-1 的数字添加到向量中。

现在,我们在 main 函数中调用它:

int main() {
    std::vector<int> myVector;
    addElements(myVector, 10);

    for (auto value : myVector) {
        std::cout << value << " ";
    }
    std::cout << std::endl;
    return 0;
}

由于C++默认使用按值传递addElements 函数中的 vecmyVector 的一个副本。因此,在函数内部对 vec 所做的任何修改(如添加元素)都不会影响 main 函数中原始的 myVector。运行此程序,输出将为空,因为 myVector 始终是空的。

引入按引用传递

为了直接修改原始的 myVector,我们需要使用按引用传递。这意味着函数参数将成为原始变量的一个别名,而不是副本。

修改函数签名,将向量参数改为引用类型:

void addElements(std::vector<int>& vec, int n) {
    for (int i = 0; i < n; ++i) {
        vec.push_back(i);
    }
}

注意,我们只是在参数类型 std::vector<int> 后面添加了一个 & 符号,使其变为 std::vector<int>&。现在,vec 是传入向量的一个引用

再次运行相同的 main 函数,输出将是 0 1 2 3 4 5 6 7 8 9。这是因为 addElements 函数现在直接操作 main 函数中的 myVector,而不是它的副本。

核心概念对比

以下是两种传递方式的核心区别:

  • 按值传递void func(Type param)

    • 创建实参的完整副本。
    • 函数内修改 param 不会影响原始数据。
    • 适用于不需要修改原始数据,或数据很小、复制成本低的情况。
  • 按引用传递void func(Type& param)

    • 创建实参的一个别名(引用)。
    • 函数内修改 param 直接影响原始数据。
    • 适用于需要修改原始数据,或数据很大、希望避免复制开销的情况。

使用场景

以下是按引用传递的两个主要使用场景:

  1. 功能性需求:当函数需要修改其调用者提供的变量时,必须使用按引用传递(或指针)。例如,交换两个变量的值,或像我们的例子一样向容器中添加元素。
  2. 性能优化:当传递大型对象(如包含成千上万个元素的向量、字符串或自定义数据结构)时,复制整个对象的成本很高。使用按引用传递可以避免复制,显著提升程序性能。

总结

本节课中我们一起学习了C++中按引用传递的用法。我们了解到,通过在函数参数类型后添加 & 符号,可以使其成为引用类型,从而实现直接操作原始数据而非其副本。这与默认的按值传递有本质区别。按引用传递常用于需要修改实参或避免大型数据复制开销的场景,是编写高效C++程序的重要工具之一。

017:指针基础

在本节课中,我们将要学习C++中一个核心概念——指针。指针是一种存储内存地址的变量类型,它允许我们直接访问和操作内存中的数据。理解指针是掌握C++内存管理和高级编程技巧的关键一步。

概述

在之前的课程中,我们介绍了引用和取地址运算符 &。一个自然的问题是:我们能否将某个变量的地址存储起来?答案是肯定的,这种存储地址的类型就是指针。本节课我们将探讨指针的基础知识,包括如何声明、初始化和使用指针来访问内存中的数据。

指针的声明与初始化

上一节我们提到了取地址运算符,本节中我们来看看如何将地址存储在一个变量中。

指针的声明使用星号 *。例如,一个指向整数的指针类型写作 int*。要获取一个变量的地址并将其赋给指针,我们使用取地址运算符 &

以下是声明和初始化指针的步骤:

  1. 声明一个普通变量,例如 int a = 5;
  2. 声明一个指针变量,例如 int* b;。这里的 int* 表示 b 是一个指向 int 类型内存地址的指针。
  3. 使用取地址运算符 & 获取变量 a 的地址,并将其赋值给指针 bb = &a;

此时,指针 b 存储的就是变量 a 在内存中的地址。

int a = 5;       // 定义一个整数变量 a
int* b = &a;     // 定义一个指向整数的指针 b,并让它存储 a 的地址

访问指针的值与解引用

指针本身存储的是地址。如果我们想访问或修改该地址所指向的实际数据,就需要使用解引用运算符 *

解引用操作 *b 表示“获取指针 b 所指向地址处的值”。通过这种方式,我们可以像操作原始变量一样操作指针指向的数据。

以下是一个使用指针修改数据的例子:

int a = 5;
int* b = &a;     // b 指向 a
*b += 1;         // 解引用 b,并将其指向的值(即 a 的值)加 1
// 现在 a 的值变成了 6

通过指针 b,我们间接地修改了变量 a 的值。

指针与引用的对比

理解指针时,将其与之前学过的引用进行对比会很有帮助。引用是变量的别名,而指针是存储地址的变量。引用在初始化后不能更改其绑定对象,而指针可以重新指向不同的地址。

核心区别在于:

  • 引用int& ref = a; ( refa 的别名)
  • 指针int* ptr = &a; ( ptr 存储着 a 的地址)

关于原始指针的说明

在C++现代编程中,直接使用“原始指针”(即本节课介绍的 int* 这类指针)的情况正在减少,因为它们在管理不当(如忘记释放内存)时容易导致错误。然而,理解原始指针的工作原理至关重要,它是学习智能指针(如 std::unique_ptr, std::shared_ptr)等更安全内存管理工具的基础。在后续课程中,我们将探讨这些更优的替代方案。

总结

本节课中我们一起学习了C++指针的基础知识。我们了解了指针是一种存储内存地址的变量类型,学会了如何使用 * 声明指针,使用 & 获取地址,以及使用 * 解引用来访问或修改指针所指向的数据。虽然现代C++推荐使用更安全的工具,但深入理解指针是成为一名熟练C++程序员的必经之路。

018:动态内存分配

在本节课中,我们将要学习C++中的动态内存分配。我们将了解如何使用 newdelete 表达式来手动管理内存,包括为单个对象和数组分配与释放内存。理解这些基础知识对于深入理解标准库容器(如 std::vector)的内部工作原理至关重要。

动态内存分配基础

上一节我们介绍了指针的基本概念。本节中我们来看看如何使用 new 表达式来动态分配内存。new 表达式用于在程序运行时请求内存,并返回一个指向该内存地址的指针。

例如,为一个整数分配内存的代码如下:

int* intPtr = new int;

这段代码执行以下操作:

  1. 请求一个整数所需的内存空间。
  2. 分配该内存。
  3. 返回该内存的地址,并将其存储在指针 intPtr 中。

分配内存后,我们可以像操作普通变量一样操作它。例如,我们可以通过解引用指针来设置其值:

*intPtr = 242;

然后,我们可以打印这个整数的值和地址:

std::cout << "Value: " << *intPtr << std::endl;
std::cout << "Address: " << intPtr << std::endl;

当我们手动管理动态分配的内存时,必须在使用完毕后释放它,否则会导致内存泄漏。我们使用 delete 表达式来释放内存:

delete intPtr;

释放内存非常重要,因为计算机的内存是有限的。如果程序不断分配内存而不释放,最终将耗尽所有可用内存,导致程序崩溃。

动态分配数组

我们不仅可以分配单个对象,还可以分配一个对象数组,这类似于 std::vector 的功能。

要为多个整数(例如10个)分配内存,我们使用带有方括号的 new 表达式:

int* intPtr = new int[10];

现在,intPtr 指向一个包含10个整数的数组的首元素地址。

操作数组元素的方法如下:

  • 设置第一个元素的值:*intPtr = 242;intPtr[0] = 242;
  • 设置其他元素的值,例如第三个元素:intPtr[2] = 241;

同样,我们可以打印特定元素的值和地址:

std::cout << "Value at index 2: " << intPtr[2] << std::endl;
std::cout << "Address of index 2: " << &intPtr[2] << std::endl;

释放数组内存

释放数组内存的语法与释放单个对象不同,这一点必须注意。

以下是释放内存的正确方法:

  • 释放单个对象:delete intPtr;
  • 释放数组对象:delete[] intPtr;

如果对数组使用 delete 而非 delete[],则只会释放数组的第一个元素,造成内存泄漏。因此,必须确保分配和释放的方式匹配。

总结

本节课中我们一起学习了C++动态内存分配的核心知识。我们掌握了如何使用 new 表达式为单个对象和数组分配内存,以及如何使用 deletedelete[] 表达式正确释放内存。虽然在实际开发中,我们更推荐使用 std::vector 等标准库容器来自动管理内存,但理解底层的手动内存管理机制对于成为一名优秀的C++程序员至关重要。

019:智能指针之std::unique_ptr 🧠

在本节课中,我们将要学习C++中的智能指针之一:std::unique_ptr。我们将了解为何要避免使用原始指针,以及std::unique_ptr如何帮助我们自动管理动态分配的内存,从而防止内存泄漏。

上一节我们介绍了动态分配的基础知识,并使用了原始指针。本节中我们来看看如何用更现代、更安全的方式管理内存。

为何避免使用原始指针?

在C++中,我们通常不鼓励使用原始指针,主要有两个原因。

  1. 类型表达能力不足:一个原始指针(例如 int*)无法表达它是指向单个元素,还是指向一个包含10个或100个元素的数组。这个信息没有体现在类型本身。
  2. 内存释放不安全:使用原始指针时,我们无法保证动态分配的内存最终会被释放。除非我们手动在代码中某处写入 delete,否则程序可能会不断分配内存而从不释放,最终导致内存耗尽错误。

C++提供了几种方法来解决这个问题。一种方法是使用STL容器(如 std::vector),它会自动处理内存的分配和释放。另一种方法就是使用智能指针,例如我们今天要讲的 std::unique_ptr

什么是 std::unique_ptr?

std::unique_ptr 是一个智能指针,它通过一个指针拥有并管理另一个对象,并在 unique_ptr 离开作用域时处置该对象。这意味着我们可以让 unique_ptr 来管理我们动态分配的内存,当指针不再需要时(即离开作用域),它会自动释放内存,我们无需手动调用 delete

创建和使用 std::unique_ptr

以下是创建和使用 std::unique_ptr 的基本步骤。

首先,我们需要包含必要的头文件。

#include <iostream>
#include <memory>

方法一:直接管理 new 分配的指针

我们可以创建一个 std::unique_ptr 来接管一个由 new 分配的指针。

std::unique_ptr<int[]> pointer(new int[10]);

在这行代码中:

  • std::unique_ptr<int[]> 声明了一个管理 int 数组的 unique_ptr
  • pointer 是变量名。
  • new int[10] 动态分配了一个包含10个整数的数组,并将其管理权交给了 pointer

现在,pointer 就可以像普通指针一样使用,例如通过索引访问数组元素。

for (int i = 0; i < 10; i++) {
    pointer[i] = i * i; // 填充平方数
}
std::cout << pointer[4] << std::endl; // 输出 16
std::cout << pointer[7] << std::endl; // 输出 49

pointer 离开其作用域(例如 main 函数结束)时,它会自动释放其管理的数组内存。

方法二:使用 std::make_unique(推荐)

更现代、更安全的方式是使用 std::make_unique 函数,它可以完全避免直接使用 new

auto pointer = std::make_unique<int[]>(10);

这行代码使用 auto 进行自动类型推导,std::make_unique<int[]>(10) 会创建一个管理10个整数数组的 unique_ptr。代码的其余部分与之前完全相同。

使用 std::make_unique 是更推荐的做法,因为它更安全,能防止某些异常情况下的内存泄漏。

std::unique_ptr 的其他功能

std::unique_ptr 比原始指针功能更丰富。根据C++参考文档,它还有一些有用的成员函数:

  • release():返回被管理对象的指针并释放所有权。调用后,unique_ptr 不再管理该内存,你需要负责手动释放。
  • reset():替换 unique_ptr 当前管理的对象(会先释放原对象)。
  • swap():交换两个 unique_ptr 所管理的对象。
  • get():获取指向被管理对象的原始指针(但不释放所有权)。

你可以查阅C++参考文档以获取更详细的信息。

总结

本节课中我们一起学习了 std::unique_ptr 智能指针。我们了解了原始指针的局限性,以及 std::unique_ptr 如何通过所有权模型自动管理动态内存的生命周期,从而有效防止内存泄漏。我们学习了两种创建 unique_ptr 的方法,并推荐使用 std::make_unique。最后,我们简要介绍了 unique_ptr 提供的一些额外成员函数。通过使用智能指针,我们可以编写出更安全、更易于维护的现代C++代码。

020:std::shared_ptr 🧠

在本节课中,我们将学习C++中的另一个智能指针:std::shared_ptr。我们将了解它如何实现资源的共享所有权,以及它与std::unique_ptr的区别。

上一节我们介绍了用于独占所有权的std::unique_ptr。本节中我们来看看用于共享所有权的std::shared_ptr。在程序中,我们经常需要多个指针指向同一块内存,即共享某个对象的所有权,并确保在所有引用都消失之前该对象不会被释放。std::shared_ptr正是为此设计。

创建与使用 std::shared_ptr

std::unique_ptr类似,std::shared_ptr也定义在 <memory> 头文件中。以下是创建一个std::shared_ptr的基本方法。

#include <iostream>
#include <memory>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/5ab198e28e2e0c6dc97b9a54353f94c6_1.png)

int main() {
    // 创建一个管理10个整数数组的shared_ptr
    std::shared_ptr<int[]> pointer1(new int[10]);
}

unique_ptr不同,shared_ptr允许多个指针共同管理同一资源。

    // 创建第二个shared_ptr,指向同一块内存
    auto pointer2 = pointer1;

现在,pointer1pointer2共同拥有这个动态分配的10个整数数组。这块内存只有在最后一个引用它的shared_ptr离开作用域或被释放时,才会被自动删除。

查看引用计数

std::shared_ptr提供了一个成员函数use_count(),用于查看当前有多少个shared_ptr对象共享同一个资源。

以下是查看引用计数的方法。

    std::cout << "Reference count: " << pointer1.use_count() << std::endl;

当有两个shared_ptr共享资源时,use_count()会返回2。如果只有一个,则返回1。

使用 std::make_shared

类似于std::make_unique,C++也提供了std::make_shared来更安全、更高效地创建shared_ptr。对于数组,此功能需要C++20或更高标准支持。

以下是使用std::make_shared创建数组的方法。

    // 使用C++20的make_shared创建动态数组(需要支持C++20的编译器)
    auto pointer1 = std::make_shared<int[]>(10);
    auto pointer2 = pointer1;
    std::cout << "Reference count: " << pointer1.use_count() << std::endl;

使用make_shared可以避免直接使用new表达式,使代码更安全、更现代。编译时需指定C++20标准(例如 -std=c++20)。

其他常用操作

std::shared_ptr支持与std::unique_ptr类似的操作,例如通过下标访问数组元素、使用reset()释放资源、使用get()获取原始指针等。

以下是std::shared_ptr的一些常用操作。

  • 索引访问pointer1[0] = 42;
  • 重置指针pointer1.reset(); // 释放所有权,如果它是最后一个所有者,则删除内存
  • 获取原始指针int* raw_ptr = pointer1.get(); // 谨慎使用

本节课中我们一起学习了std::shared_ptr的基本概念和用法。我们了解到:

  1. std::shared_ptr用于实现资源的共享所有权。
  2. 多个shared_ptr可以指向同一对象,并通过引用计数管理对象的生命周期。
  3. 可以使用std::make_shared(C++20起支持数组)来更安全地创建shared_ptr
  4. 通过use_count()可以查询当前的共享所有者数量。

使用std::shared_ptr可以帮助我们更安全地管理动态内存,避免内存泄漏,是编写现代C++程序的重要工具。

021:std::span 🧩

在本节课中,我们将要学习C++标准库中的一个容器——std::span。我们将了解它是什么,为什么它很有用,以及如何使用它来查看数据序列而无需拥有其底层内存。

概述

到目前为止,我们主要学习了管理底层内存的容器。例如,我们看过像 std::vector 这样的容器,它会为我们进行动态内存分配,并在使用完毕后释放内存。我们也学习了几种智能指针,如 std::unique_ptrstd::shared_ptr,它们负责管理我们分配的某块内存的所有权,并在使用完毕后释放内存。

然而,有时我们想要容器的良好抽象,但又不一定想拥有底层内存的所有权。例如,我们可能只想查看一个 vector 的子集,而不想在一个新容器中实际拥有那块内存。我们只是想“窥视”一下我们的 vector。在现代C++中(至少从C++20开始),我们通过 std::span 来实现这一点。

std::span 描述了一个可以引用连续对象序列的对象,序列的第一个元素从索引0开始。它可以具有静态范围或动态范围。本质上,std::span 为我们提供了一种查看或引用某些对象序列的方式,而无需实际拥有底层内存。因此,我们可以查看 vector 的一个子集,而无需拥有我们正在查看的内存。

接下来,让我们看看如何使用 std::span 的基础知识,以及它为何有帮助。

开始使用

让我们开始创建一个新文件 span.cpp。我们需要包含一些头文件:<iostream> 用于打印输出,以及 <span>。不出所料,std::span 定义在 <span> 头文件中。我们还可以包含之前见过的 <vector>

#include <iostream>
#include <span>
#include <vector>

我们将创建一个 main 函数,这是C++程序的核心。假设我们想实现一个简单的函数来打印 vector 的一个子集。

定义一个使用 std::span 的函数

让我们先写出这个函数的框架。它的返回类型是 void,因为它只是一个打印函数,不需要返回任何内容。我们可以称这个函数为 print_sub_vector

void print_sub_vector(std::span<int> span) {
    for (auto value : span) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

这个函数接受一个 std::span<int> 类型的参数。我们可以像使用任何其他容器一样使用 span,例如使用基于范围的 for 循环来遍历并打印其中的每个整数值,最后输出一个换行符。

std::span 的好处在于它不拥有底层内存。它只是表达“我想向这个函数传递一个整数范围”的一种方式。对于这个函数来说,它只是一个要打印的整数范围,不关心这些整数来自哪里,也不拥有任何底层内存。这更好地表达了我们的意图。

调用使用 std::span 的函数

现在,让我们看看如何调用这个接受 std::span 参数的函数。

首先,我们创建一个 vector,作为我们想要获取子集的对象。

int main() {
    std::vector<int> my_vector = {1, 2, 3, 4, 5};
}

现代C++和范围概念的一个优点是,我们可以将 vector 视为一个值范围,而这个值范围可以转换为 span。这意味着我们不会在函数内部复制 vector,而只是通过 span 查看其内容。

我们可以直接将整个 vector 传递给函数,以打印其全部内容。

    // 打印整个vector
    print_sub_vector(my_vector);

保存并编译程序。我们需要使用C++20标准进行编译,因为这是一个较新的特性。

g++ -std=c++20 -o span span.cpp
./span

运行后,你会看到打印出了整个 vector 的内容:1 2 3 4 5。我们没有复制 vector,只是通过 span 查看了它。

查看 vector 的子集

如果我们只想查看 vector 的一部分呢?我们可以创建一个 std::span 来指定我们感兴趣的范围。

例如,我们可以创建一个从 vector 开头开始、包含两个元素的 span

    // 打印前两个元素
    print_sub_vector(std::span(my_vector.begin(), 2));

std::span 的构造函数可以接受一个起始迭代器和一个元素数量。这里,我们从 my_vector.begin() 开始,取2个元素。运行程序,会打印出 1 2

我们也可以不从开头开始。例如,打印中间三个元素(索引1到3)。

    // 打印中间三个元素(索引1, 2, 3)
    print_sub_vector(std::span(my_vector.begin() + 1, 3));

运行程序,会打印出 2 3 4

通过使用 std::span,我们的 print_sub_vector 函数本身不需要知道范围的细节。它只是接收一个抽象的 span 并打印它。而在调用函数的地方,我们可以灵活地决定要传递哪一部分元素。

总结

本节课中,我们一起学习了 std::span 的基本用法及其优势。

  • std::span 是一个轻量级的、非拥有的视图,用于表示连续对象序列。
  • 它提供了容器级别的抽象(如迭代、获取大小),但不管理底层内存的所有权
  • 这在我们需要查看或处理数据的一部分(如 vector 的子集),而又不想复制数据或传递复杂的起始/结束索引对时非常有用。
  • 使用 std::span 可以使函数接口更清晰,表达“我需要一个数据范围”的意图,而不绑定到特定的容器类型。

通过将 std::span 集成到你的代码中,你可以编写出更灵活、更高效的函数,特别是在处理数据切片和范围操作时。

022:结构体(Structs)🧱

在本节课中,我们将学习C++中的结构体(struct)。结构体是一种强大的工具,它允许我们创建自定义的数据类型,将相关的数据成员和操作这些数据的成员函数组合在一起。

概述

在编程中,有时标准库提供的类型无法满足我们的需求。我们可能需要实现具有特定优化的自定义类型,或者创建语言中原本不存在的类型。在C++中,我们通过定义结构体(struct)和类(class)来实现这一点。本节课,我们将专注于结构体的基础知识。

定义结构体

我们通过 struct 关键字来定义一个新的类型。以下是一个创建名为 Point 的结构体的基本语法:

struct Point {
    // 数据成员
    int x;
    int y;

    // 成员函数
    void print() {
        std::cout << "x is equal to " << x << '\n';
        std::cout << "y is equal to " << y << '\n';
    }
};

在上面的代码中:

  • Point 是我们定义的新类型名称。
  • xy 是它的数据成员,用于存储坐标值。
  • print() 是一个成员函数,用于打印该点的坐标。

使用结构体

定义好结构体后,我们就可以像使用其他内置类型(如 int)一样使用它来创建变量,这些变量被称为对象

创建对象与访问成员

以下是创建 Point 对象并访问其成员的示例:

int main() {
    // 创建一个Point对象p1
    Point p1;

    // 使用成员访问运算符(.)为数据成员赋值
    p1.x = 10;
    p1.y = 20;

    // 使用成员访问运算符调用成员函数
    p1.print(); // 输出: x is equal to 10
                //        y is equal to 20

    return 0;
}

多个独立的对象

每个结构体对象都是独立的,拥有自己的数据成员副本。

int main() {
    Point p1;
    p1.x = 10;
    p1.y = 20;

    Point p2; // p2是另一个独立的Point对象
    p2.x = 5;
    p2.y = -5;

    p1.print(); // 输出p1的数据: 10, 20
    p2.print(); // 输出p2的数据: 5, -5

    return 0;
}

在上面的例子中,p1p2 是两个完全独立的对象,修改其中一个不会影响另一个。

总结

本节课我们一起学习了C++中结构体的基本概念和用法。我们了解到:

  1. 结构体 允许我们定义自定义类型,将数据(成员变量)和操作(成员函数)封装在一起。
  2. 使用 struct 关键字定义结构体,并在其中声明数据成员和成员函数。
  3. 可以像使用普通变量一样创建结构体的对象
  4. 使用成员访问运算符(. 来访问或修改对象的数据成员,以及调用其成员函数。
  5. 每个对象都是其结构体类型的一个独立实例,拥有自己的数据。

结构体是面向对象编程的基石之一。在后续课程中,我们将深入探讨类的概念、构造函数、析构函数等更高级的主题。

023:类与访问控制符

在本节课中,我们将要学习C++中的基本概念,并重点理解访问控制符(如 publicprivate)如何控制对类成员的访问。我们将通过创建一个简单的 Point 类来演示这些概念。

概述

在上一节中,我们介绍了结构体,它允许我们定义自己的复合数据类型。本节中,我们来看看C++中的。类和结构体非常相似,但它们在成员的默认访问权限上存在关键区别。理解这一点是掌握C++面向对象编程的基础。

创建类

与定义结构体类似,我们使用 class 关键字来定义一个类。我们将创建一个表示二维坐标点的 Point 类。

#include <iostream>

class Point {
    // 默认情况下,类成员是私有的(private)
    int x;
    int y;
};

访问控制符:public 与 private

类与结构体的核心区别在于默认的访问控制符。在结构体中,所有成员默认是 public(公开的),意味着可以从结构体外部直接访问。在类中,所有成员默认是 private(私有的),意味着只能从类的内部访问。

以下是如何在类中显式指定访问控制符:

class Point {
public: // 从这里开始,成员是公开的
    // 成员函数(方法)
    void print() {
        std::cout << "x is " << x << '\n';
        std::cout << "y is " << y << '\n';
    }
    void setXY(int newX, int newY) {
        x = newX;
        y = newY;
    }

private: // 从这里开始,成员是私有的
    // 数据成员
    int x;
    int y;
};

使用类

定义了类之后,我们可以创建该类的对象(实例)并使用其公开的成员函数。

int main() {
    Point p1; // 创建一个Point对象

    // p1.x = 10; // 错误!x是私有成员,不能从外部访问
    // p1.y = 20; // 错误!y是私有成员,不能从外部访问

    p1.setXY(10, 20); // 正确!通过公开的setter函数设置值
    p1.print();       // 正确!调用公开的成员函数

    return 0;
}

为何使用访问控制符?

将数据成员设为私有,并通过公开的成员函数(如setter和getter)来访问它们,这是一种良好的封装实践。这样做有两大好处:

  1. 数据验证:在setter函数中,你可以添加逻辑来检查输入值的有效性。
    void setXY(int newX, int newY) {
        if(newX >= 0 && newY >= 0) { // 示例:确保坐标非负
            x = newX;
            y = newY;
        }
    }
    
  2. 实现灵活性:类的内部实现(如数据如何存储)可以改变,但只要公开的接口(函数)保持不变,使用该类的代码就无需修改。

以下是使用封装后的 Point 类的完整示例:

#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/886b84566332d59cc5f9f14903564fe9_13.png)

class Point {
public:
    // 设置坐标的接口
    void setXY(int newX, int newY) {
        // 这里可以添加数据验证逻辑
        x = newX;
        y = newY;
    }
    // 打印坐标的接口
    void print() {
        std::cout << "x is " << x << '\n';
        std::cout << "y is " << y << '\n';
    }

private:
    // 私有的数据成员,外部无法直接访问
    int x;
    int y;
};

int main() {
    Point p1;
    p1.setXY(10, 20); // 通过公共接口设置值
    p1.print();       // 通过公共接口打印值
    return 0;
}

总结

本节课中我们一起学习了C++中的基本定义和使用。我们理解了访问控制符 publicprivate 的核心作用:public 成员可以从任何地方访问,而 private 成员只能从类内部访问。类的默认私有访问权限鼓励了封装的设计理念,即通过公开的成员函数(如setter和getter)来控制和保护内部数据,这提高了代码的安全性、可维护性和灵活性。在后续关于继承的课程中,我们还将接触到另一个访问控制符 protected

024:构造函数 🏗️

在本节课中,我们将要学习C++中的构造函数。构造函数是一种特殊的成员函数,用于在创建对象时初始化其数据成员。通过使用构造函数,我们可以避免在对象创建后手动设置每个成员变量,从而使代码更简洁、更安全。

在之前的视频中,我们学习了结构体和类,以及如何使用它们来创建对象。然而,我们还没有讨论如何在不通过成员访问运算符(点运算符)手动访问数据成员的情况下初始化它们。在C++中,我们通过构造函数来实现这一点。构造函数允许我们指定对象应如何被构造。

默认成员初始化

在深入构造函数之前,我们先看一种简单的初始化方法:默认成员初始化。

以下是一个简单的结构体示例,它有两个整数数据成员 xy,以及一个打印它们的方法。

struct Point {
    int x = 10; // 默认成员初始化
    int y = 20; // 默认成员初始化
    void print() {
        std::cout << "x = " << x << ", y = " << y << std::endl;
    }
};

main 函数中,我们创建一个 Point 对象并调用 print 方法。

int main() {
    Point p;
    p.print(); // 输出: x = 10, y = 20
    return 0;
}

使用默认成员初始化,每当创建 Point 的实例时,xy 会自动初始化为10和20,除非我们之后手动修改它们。

构造函数基础

构造函数是一种与结构体或类同名的特殊成员函数。它没有返回类型,并且在创建对象时自动调用。

无参构造函数

我们可以定义一个无参构造函数来初始化对象。

struct Point {
    int x;
    int y;

    // 无参构造函数
    Point() {
        x = 20;
        y = 10;
        std::cout << "Constructor called!" << std::endl;
    }

    void print() {
        std::cout << "x = " << x << ", y = " << y << std::endl;
    }
};

当我们在 main 函数中创建 Point 对象时,这个构造函数会被调用。

int main() {
    Point p; // 输出: Constructor called!
    p.print(); // 输出: x = 20, y = 10
    return 0;
}

成员初始化列表

另一种初始化数据成员的方法是使用成员初始化列表。它在构造函数体执行之前进行初始化。

struct Point {
    int x;
    int y;

    // 使用成员初始化列表的构造函数
    Point() : x(10), y(20) {
        std::cout << "Constructor called!" << std::endl;
    }

    void print() {
        std::cout << "x = " << x << ", y = " << y << std::endl;
    }
};

使用成员初始化列表通常更高效,特别是对于常量成员或对象成员,因为它避免了先默认初始化再赋值的过程。

带参数的构造函数

很多时候,我们希望在创建对象时传入特定的值。为此,我们可以定义带参数的构造函数。

struct Point {
    int x;
    int y;

    // 带两个整数参数的构造函数
    Point(int newX, int newY) {
        x = newX;
        y = newY;
    }

    void print() {
        std::cout << "x = " << x << ", y = " << y << std::endl;
    }
};

在创建对象时,我们可以直接传递初始值。

int main() {
    Point p(5, 7); // 使用带参数的构造函数
    p.print(); // 输出: x = 5, y = 7
    return 0;
}

我们同样可以使用成员初始化列表来实现带参数的构造函数。

Point(int newX, int newY) : x(newX), y(newY) {}

默认构造函数与用户定义构造函数

一旦我们定义了任何构造函数,编译器将不再自动生成默认(无参)构造函数。这可能导致错误。

例如,如果我们只定义了带两个参数的构造函数,那么尝试使用无参构造函数创建对象就会出错。

int main() {
    Point p; // 错误:没有匹配的构造函数
    return 0;
}

为了解决这个问题,我们可以显式地要求编译器生成默认构造函数。

struct Point {
    int x;
    int y;

    Point() = default; // 显式默认构造函数
    Point(int newX, int newY) : x(newX), y(newY) {}

    void print() {
        std::cout << "x = " << x << ", y = " << y << std::endl;
    }
};

现在,无参构造函数和带参构造函数都可以使用了。

总结

本节课中我们一起学习了C++构造函数的核心概念。我们了解了构造函数的作用是在对象创建时初始化其成员。我们探讨了三种初始化方式:默认成员初始化、无参构造函数体初始化以及更高效的成员初始化列表。我们还学习了如何创建带参数的构造函数,以便在创建对象时传入特定值。最后,我们明白了用户定义构造函数会抑制编译器生成默认构造函数,但可以通过 = default 来显式请求生成。

构造函数是面向对象编程中控制对象初始化的强大工具,掌握它们对于编写健壮、清晰的C++代码至关重要。

025:析构函数 🧹

在本节课中,我们将要学习C++中的析构函数。析构函数是一种特殊的成员函数,它在对象生命周期结束时自动调用,主要用于清理对象占用的资源,例如释放动态分配的内存。

概述

上一节我们介绍了构造函数,它用于在创建对象时初始化数据成员。本节中我们来看看析构函数,它用于在销毁对象时执行清理工作。

析构函数的基本用法

析构函数在对象被销毁时自动调用。例如,当对象离开其作用域,或者我们使用 delete 运算符删除动态分配的对象时,析构函数就会运行。

以下是一个简单的析构函数示例:

#include <iostream>

struct IntArray {
    int* array;

    // 构造函数
    IntArray(int size) {
        array = new int[size];
    }

    // 析构函数
    ~IntArray() {
        delete[] array;
        std::cout << "运行析构函数!" << std::endl;
    }
};

int main() {
    IntArray a(10);
    a.array[0] = 10;
    std::cout << a.array[0] << std::endl;
    return 0;
}

在这个例子中,IntArray 结构体管理一个动态分配的整数数组。构造函数负责分配内存,而析构函数负责释放内存。当 main 函数返回,对象 a 离开作用域时,析构函数会自动调用,释放 array 指向的内存。

RAII 设计模式

这种在构造函数中获取资源、在析构函数中释放资源的设计模式,通常被称为 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。

RAII的核心思想是让对象的生命周期与资源绑定。当对象创建时获取资源,当对象销毁时自动释放资源,用户无需手动管理。C++标准库中的许多容器(如 std::vector)都采用了这种模式。

现代C++中的资源管理

虽然手动编写析构函数是有效的,但在现代C++中,我们通常可以避免直接管理原始指针,从而减少甚至消除编写析构函数的需要。

例如,我们可以使用智能指针(如 std::unique_ptr)来管理动态内存:

#include <iostream>
#include <memory>

struct IntArray {
    std::unique_ptr<int[]> array;

    // 构造函数
    IntArray(int size) : array(new int[size]) {}
};

int main() {
    IntArray a(10);
    a.array[0] = 10;
    std::cout << a.array[0] << std::endl;
    return 0;
}

在这个改进的版本中,std::unique_ptr 拥有动态数组的所有权。当 IntArray 对象销毁时,std::unique_ptr 的析构函数会自动释放其管理的内存,因此我们无需为 IntArray 手动编写析构函数。

何时需要编写析构函数

尽管智能指针和标准库容器能处理大多数情况,但在某些场景下,我们仍然需要编写自己的析构函数:

  • 管理非内存资源(如文件句柄、网络连接、锁)。
  • 实现自定义的、复杂的资源管理逻辑。
  • 维护与C语言库或遗留代码的接口。

总结

本节课中我们一起学习了C++析构函数。我们了解了析构函数的基本语法和作用,即在对象销毁时执行清理工作。我们探讨了RAII设计模式,它通过将资源生命周期与对象绑定来简化资源管理。最后,我们看到了在现代C++中,利用智能指针等工具可以避免许多手动资源管理的工作,使代码更安全、更简洁。

026:拷贝构造函数 📝

在本节课中,我们将要学习C++中的拷贝构造函数。拷贝构造函数是一种特殊的成员函数,用于创建一个新对象,并将其初始化为一个已存在对象的副本。我们将探讨如何定义和使用它,以及编译器在何时会为我们自动生成一个。


回顾构造函数

上一节我们介绍了构造函数的基础知识,它是用于创建和初始化类或结构体对象的成员函数。

现在,我们经常需要在编程中基于一个已存在的对象来创建一个新对象。指定如何实现这一过程的方法就是通过拷贝构造函数。这就是我们今天要学习的内容。


一个简单的起点

让我们从一个简单的结构体 Point 开始,它代表一个二维坐标点。

struct Point {
    int x;
    int y;

    // 普通构造函数
    Point(int newX, int newY) : x(newX), y(newY) {}

    void print() {
        std::cout << "x is equal to " << x << " y is equal to " << y << std::endl;
    }
};

main 函数中,我们可以这样使用它:

int main() {
    Point p1(10, 20);
    p1.print();
    return 0;
}

运行程序会输出:x is equal to 10 y is equal to 20


引入拷贝构造函数

现在,假设我们想基于已存在的 p1 对象创建一个新的 Point 对象 p2。我们不希望再次手动传递 xy 的值,而是希望直接复制 p1。这时就需要拷贝构造函数。

拷贝构造函数的定义如下:

struct Point {
    // ... 其他成员 ...

    // 拷贝构造函数
    Point(const Point& p) {
        x = p.x;
        y = p.y;
        std::cout << "Running our copy constructor!" << std::endl;
    }
};

以下是关于拷贝构造函数定义的关键点:

  • 它接受一个常量引用const Point&)作为参数。这确保了原始对象在拷贝过程中不会被意外修改。
  • 在函数体内,我们将参数对象 p 的成员值复制到新创建的对象中。
  • 我们添加了一行打印语句,以便观察拷贝构造函数何时被调用。

现在,我们可以在 main 函数中使用它:

int main() {
    Point p1(10, 20);
    p1.print();

    // 使用拷贝构造函数创建 p2
    Point p2 = p1;
    p2.print();

    return 0;
}

运行程序,输出如下:

  1. x is equal to 10 y is equal to 20 (来自 p1.print()
  2. Running our copy constructor! (拷贝构造函数被调用)
  3. x is equal to 10 y is equal to 20 (来自 p2.print()

这表明我们成功地基于 p1 创建了一个内容相同的新对象 p2


编译器的默认拷贝构造函数

对于像我们 Point 这样简单的结构体(仅包含基本数据类型),编译器会自动为我们生成一个默认的拷贝构造函数。这个默认的拷贝构造函数会执行“浅拷贝”,即逐个复制每个成员变量的值。

因此,即使我们删除自定义的拷贝构造函数,代码依然可以正常工作:

struct Point {
    int x;
    int y;
    Point(int newX, int newY) : x(newX), y(newY) {}
    void print() { ... }
    // 没有定义拷贝构造函数
};

int main() {
    Point p1(10, 20);
    Point p2 = p1; // 使用编译器生成的默认拷贝构造函数
    p2.print(); // 输出: x is equal to 10 y is equal to 20
    return 0;
}

输出结果与之前相同,只是没有了“Running our copy constructor!”的提示。在许多简单情况下,依赖编译器的默认实现是完全可行的。


禁用拷贝构造函数

有时,我们可能希望禁止对象的拷贝行为。例如,std::unique_ptr 拥有对内存的独占所有权,因此不允许被拷贝。

我们可以通过将拷贝构造函数标记为 = delete 来显式禁止它:

struct Point {
    int x;
    int y;
    Point(int newX, int newY) : x(newX), y(newY) {}

    // 删除拷贝构造函数
    Point(const Point& p) = delete;

    void print() { ... }
};

int main() {
    Point p1(10, 20);
    Point p2 = p1; // 错误:尝试使用已删除的函数
    return 0;
}

尝试编译这段代码会导致错误,因为拷贝操作不再被允许。这在需要控制对象复制行为的场景中非常有用。


总结

本节课中我们一起学习了C++中的拷贝构造函数。

  • 我们了解到拷贝构造函数用于基于现有对象创建新对象,其标准形式为 ClassName(const ClassName&)
  • 我们学习了如何自定义拷贝构造函数以实现特定的复制逻辑。
  • 我们认识到对于简单的类,编译器会自动生成一个默认的拷贝构造函数来执行成员间的值复制。
  • 最后,我们知道了可以通过 = delete显式禁用拷贝构造函数,以防止对象被复制。

理解拷贝构造函数是掌握C++对象生命周期和资源管理的重要一步。

027:运算符重载 🧮

在本节课中,我们将要学习C++中一个非常强大的特性——运算符重载。通过运算符重载,我们可以让自定义的类型(如结构体和类)像内置类型(如intfloat)一样,使用+-+=等运算符,从而使代码更直观、更易读。

在之前的课程中,我们学习了如何使用structclass来定义自己的类型。然而,默认情况下,编译器并不知道如何对这些新类型的对象使用运算符。例如,它不知道如何将两个Point对象相加。运算符重载就是教编译器如何处理这些运算符的方法,通过将它们实现为我们类型内部的成员函数。

一个简单的例子:Point结构体

让我们从一个简单的Point结构体开始,它代表一个二维坐标点。

struct Point {
    int x;
    int y;

    Point(int x_val, int y_val) : x(x_val), y(y_val) {}

    void print() {
        std::cout << "x: " << x << ", y: " << y << std::endl;
    }
};

这个结构体包含两个数据成员xy,一个构造函数用于初始化,以及一个打印坐标的成员函数print

重载加法运算符 (+)

假设我们想实现两个Point对象的加法,即新点的x坐标是两个点x坐标之和,y坐标也是两个点y坐标之和。

我们希望这样使用:

Point p1(10, 20);
Point p2(30, 40);
Point p3 = p1 + p2; // 期望 p3 为 (40, 60)

但默认情况下,编译器会报错,因为它不理解Point对象之间的+操作。我们需要在Point结构体中重载+运算符。

实现 operator+

以下是operator+成员函数的实现:

struct Point {
    // ... 其他成员(x, y, 构造函数, print)

    // 重载 + 运算符
    Point operator+(const Point& rhs) const {
        return Point(x + rhs.x, y + rhs.y);
    }
};

让我们分解一下这个实现:

  • 返回类型Point。因为相加的结果是一个新的点。
  • 函数名operator+。这是一个特殊的函数名,用于重载+运算符。
  • 参数const Point& rhsrhs代表“右手边”(right-hand side),即+运算符右边的对象(例如p2)。我们通过常量引用传递它,以避免不必要的复制,并且承诺不修改它。
  • 函数体:创建并返回一个新的Point对象,其xy分别是当前对象(p1)和参数对象(p2)对应坐标的和。
  • const修饰符:在函数声明的末尾,const表示这个成员函数不会修改调用它的对象(即p1)。

关键理解:表达式p1 + p2实际上等价于p1.operator+(p2)p1是调用该成员函数的对象,p2作为参数传入。

现在,我们可以成功编译并运行代码,p3.print()将输出x: 40, y: 60

重载复合赋值运算符 (+=)

接下来,我们看看如何重载+=运算符,它用于修改一个已存在的对象,而不是创建新对象。

我们希望这样使用:

Point p1(10, 20);
Point p2(30, 40);
p1 += p2; // 期望 p1 变为 (40, 60)

表达式p1 += p2可以理解为p1 = p1 + p2

实现 operator+=

以下是operator+=成员函数的实现:

struct Point {
    // ... 其他成员

    // 重载 += 运算符
    Point& operator+=(const Point& rhs) {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }
};

分解这个实现:

  • 返回类型Point&(引用)。我们返回当前对象(p1)的引用,以支持链式调用(如a += b += c)。
  • 函数名operator+=
  • 参数:同样是const Point& rhs,代表+=右边的对象。
  • 函数体:直接修改当前对象的xy成员,将它们分别与rhs的对应坐标相加。
  • 返回值*thisthis是一个指向当前对象的指针,*this就是对当前对象(p1)的解引用。我们返回它的引用。

现在,执行p1 += p2;后,p1.print()将输出x: 40, y: 60

总结

本节课中我们一起学习了C++运算符重载的基础知识:

  1. 目的:让自定义类型支持C++内置运算符,提升代码的直观性和表达能力。
  2. 实现方式:在类或结构体内部,以operator关键字加上要重载的运算符(如++=)作为函数名,定义成员函数。
  3. 关键区别
    • operator+ 通常返回一个新对象,不修改原对象,函数常声明为const
    • operator+= 通常修改当前对象并返回其引用,以支持链式操作。
  4. 参数传递:通常使用常量引用(const T&)来传递运算符右侧的操作数,以提高效率。

运算符重载是一个功能丰富的主题,C++允许重载大部分运算符(除了少数如::.*等)。理解其基本原理后,你可以根据需要为你自定义的类型重载其他运算符,如-*==[]等,使它们用起来就像内置类型一样自然。

028:std::move与移动语义基础 🚀

在本节课中,我们将学习C++中std::move和移动语义的基础知识。移动语义是一种避免不必要数据拷贝、提升程序性能的重要机制,尤其适用于管理大型对象或独占资源(如std::unique_ptr)的场景。


为何需要移动语义?

在之前的课程中,我们讨论过有时需要避免程序内的拷贝操作。这可能是出于功能原因,例如std::unique_ptr不能被拷贝;也可能是出于性能原因,例如拷贝一个非常大的std::vector代价高昂。

C++中绕过拷贝的一种方法是使用移动和移动语义。其核心思想是:与其将资源复制到一个新对象,不如“窃取”底层资源的所有权。这就是我们今天要通过简单示例来探讨的内容。


std::unique_ptr看拷贝的限制

让我们通过一个具体例子开始。首先,创建一个名为move.cpp的文件,并包含必要的头文件。

#include <iostream>
#include <memory> // 用于使用std::unique_ptr
#include <utility> // 用于使用std::move

int main() {
    // 创建一个管理10个整数数组的unique_ptr
    auto pointer1 = std::make_unique<int[]>(10);
    // 尝试拷贝pointer1到pointer2 —— 这是不允许的!
    // auto pointer2 = pointer1; // 错误:使用了已删除的函数‘std::unique_ptr<...>的拷贝构造函数’
    return 0;
}

如代码所示,std::unique_ptr的拷贝构造函数被显式删除,因此尝试拷贝会导致编译错误。这确保了资源的独占所有权。


理解移动构造函数与移动语义

既然不能拷贝,我们如何转移unique_ptr管理的资源呢?答案是通过移动语义。

移动构造函数在形式上与拷贝构造函数相似,但参数类型是右值引用T&&)。其核心行为是“窃取”参数对象持有的资源,而非复制。

根据cppreference的说明:

  • 行为:移动构造函数“窃取”参数持有的资源。例如,pointer1管理的数组会被“偷走”并交给pointer2
  • 移动后状态:被移动的对象(源对象)会处于“有效但未指定”的状态。这意味着你仍然可以安全地析构它或为其赋予新值,但不应依赖其移动前的数据内容。对于std::unique_ptr,其移动后的状态是完全指定的——它会变为nullptr

值类别:左值(Lvalue)与右值(Rvalue)

要理解如何触发移动,需要了解C++的值类别。我们主要关注两类:

  • 左值 (Lvalue):通常有名称,可以取地址,常出现在赋值表达式左侧。例如变量pointer1
  • 右值 (Rvalue):通常是临时对象或字面量,没有名称,常出现在赋值表达式右侧。例如函数返回值std::make_unique<int[]>(10)或字面量8

默认情况下,左值倾向于被拷贝,右值倾向于被移动。但有时我们希望将左值“当作”右值来处理,以允许移动发生。


使用std::move进行移动

std::move的作用就是将左值转换为右值引用类型,从而“允许”编译器对其进行移动操作。它本身并不执行任何移动,只是改变了值的类别。

让我们修改之前的代码,使用std::move来转移pointer1的资源:

int main() {
    // 创建unique_ptr
    auto pointer1 = std::make_unique<int[]>(10);

    // 打印移动前的指针
    std::cout << "pointer1 before move: " << pointer1.get() << ‘\n’;

    // 使用std::move将pointer1转换为右值,从而调用移动构造函数
    auto pointer2 = std::move(pointer1);

    // 打印移动后的指针状态
    std::cout << "pointer1 after move: " << pointer1.get() << ‘\n’;
    std::cout << "pointer2 after move: " << pointer2.get() << ‘\n’;

    return 0;
}

编译并运行此程序,输出将类似于:

pointer1 before move: 0x55a0ba5e6e70
pointer1 after move: 0
pointer2 after move: 0x55a0ba5e6e70

输出结果清晰地展示了移动语义的效果:

  1. 移动前,pointer1持有一个有效的内存地址。
  2. 移动后,pointer1内部的指针变为nullptr(即0),资源已被“窃取”。
  3. pointer2现在持有原本属于pointer1的那个内存地址。


移动语义的应用场景

移动语义在多种场景下非常有用,以下是一些常见例子:

  • 向容器添加元素:在循环中调用函数获取结果并存入std::vector时,使用std::move可以避免拷贝,直接转移资源。
  • 函数返回值优化:编译器经常使用移动语义来优化函数返回局部对象时的效率。
  • 交换两个对象std::swap的实现通常依赖于移动语义,使其变得高效。

总结

本节课我们一起学习了C++中移动语义的基础知识:

  1. 移动语义的目的:通过“窃取”资源而非拷贝,来提升性能并支持不可拷贝类型(如std::unique_ptr)的所有权转移。
  2. 核心机制:移动构造函数和移动赋值运算符负责实现资源的转移。
  3. 值类别:理解了左值(倾向于拷贝)和右值(倾向于移动)的基本概念。
  4. std::move的作用:它是一个简单的类型转换工具,将左值转换为右值引用,从而“允许”移动操作发生。它本身不执行任何移动。
  5. 移动后状态:被移动的对象处于有效但未指定的状态,不应再依赖其原有值。某些标准库类型(如std::unique_ptr)有明确指定的移动后状态。

掌握移动语义是编写现代高效C++代码的关键一步。你可以尝试为自定义类实现移动构造函数,或在算法中主动使用std::move来优化性能。

029:移动构造函数 🚀

在本节课中,我们将学习移动构造函数的基础知识,以及如何在自定义的结构体和类中定义它们。

上一节我们讨论了移动语义的概念,即不复制值,而是将底层资源从一个对象转移到另一个对象。本节中我们来看看如何为我们自己定义的类或结构体实现这一功能。

概述

我们将通过一个简单的例子来学习。很多时候,我们从多个地方获取对象并将它们放入容器(如 std::vector)中。但并非所有对象都能被复制(例如 std::unique_ptr),并且复制操作可能非常昂贵。因此,我们更希望将底层内容“移动”到容器中,而不是复制。

让我们开始编写代码。

代码示例:定义结构体 S

首先,我们创建一个简单的结构体 S,它包含一个普通构造函数和一个复制构造函数,以便观察它们何时被调用。

#include <iostream>
#include <vector>

struct S {
    // 普通构造函数
    S() {
        std::cout << "constructor!" << std::endl;
    }

    // 复制构造函数
    S(const S&) {
        std::cout << "copy constructor!" << std::endl;
    }
};

int main() {
    std::vector<S> my_vector;
    S s; // 调用普通构造函数
    my_vector.push_back(s); // 尝试将 s 推入向量
    return 0;
}

编译并运行此代码,输出如下:

constructor!
copy constructor!

如我们所见,创建对象 s 时调用了普通构造函数,而将其推入 vector 时调用了复制构造函数。这是因为 s 是一个左值(lvalue),push_back 的签名会接受一个常量引用并执行复制。

实现移动构造函数

为了避免昂贵的复制,我们需要实现移动构造函数。由于我们已定义了复制构造函数,编译器不会自动生成移动构造函数。

以下是移动构造函数的定义方法:

struct S {
    S() {
        std::cout << "constructor!" << std::endl;
    }

    S(const S&) {
        std::cout << "copy constructor!" << std::endl;
    }

    // 移动构造函数
    S(S&&) {
        std::cout << "move constructor!" << std::endl;
    }
};

现在,我们可以使用 std::move 将左值 s 转换为右值引用,从而触发移动操作:

int main() {
    std::vector<S> my_vector;
    S s;
    my_vector.push_back(std::move(s)); // 使用 std::move 触发移动
    return 0;
}

编译并运行,输出变为:

constructor!
move constructor!

现在,对象被移动到了 vector 中,而不是被复制。

右值的自动移动

在某些情况下,即使不使用 std::move,也会发生移动。例如,当我们直接创建一个临时对象并将其传递给 push_back 时:

int main() {
    std::vector<S> my_vector;
    my_vector.push_back(S()); // 传递一个临时对象(右值)
    return 0;
}

编译并运行,输出为:

constructor!
move constructor!

这里,S() 创建了一个无名临时对象,它是一个右值。因此,push_back 会自动调用移动构造函数,无需显式使用 std::move

关键点总结

以下是关于移动构造函数和 std::vector::push_back 行为的几个关键点:

  1. 左值与复制:当传递一个具名对象(左值)给 push_back 时,默认会调用复制构造函数。
  2. 移动构造函数:需要手动定义移动构造函数来启用移动语义。其签名形式为 ClassName(ClassName&&)
  3. std::move 的作用std::move 本身不执行移动,它只是将左值转换为右值引用,表明该对象可以被移动。
  4. 右值与自动移动:临时对象(右值)在传递给 push_back 时会自动触发移动操作,无需 std::move
  5. 隐式声明规则:如果用户定义了复制构造函数、复制赋值运算符或析构函数,编译器将不会自动生成移动构造函数。

总结

本节课中我们一起学习了移动构造函数的基础知识。我们了解到,通过定义移动构造函数,可以高效地将资源从一个对象转移到另一个对象,避免不必要的复制。我们还探讨了 std::vector::push_back 在面对左值和右值时的不同行为,以及如何使用 std::move 来强制移动左值。

掌握移动语义对于编写高效的现代 C++ 代码至关重要,尤其是在处理大型对象或不可复制的资源(如文件句柄、网络连接)时。

030:push_back 与 emplace_back 的区别

概述

在本节课中,我们将学习C++标准模板库(STL)中 std::vector 容器的两个重要方法:push_backemplace_back。我们将探讨它们的工作原理、区别以及各自的适用场景,特别是结合我们之前学过的构造函数、拷贝构造函数和移动构造函数的知识。

std::vector 是最常用的STL容器之一,它是一个动态大小的数组。理解如何高效地向其中添加元素至关重要。push_backemplace_back 就是用于此目的的两个方法,但它们在底层实现和性能上存在关键差异。


准备工作

在深入比较之前,我们先设置一个简单的示例结构体 S,以便观察构造函数、拷贝构造函数和移动构造函数的调用情况。

#include <iostream>
#include <utility>
#include <vector>

struct S {
    int x;
    // 构造函数
    S(int n) : x(n) { std::cout << "构造函数被调用\n"; }
    // 拷贝构造函数
    S(const S& other) : x(other.x) { std::cout << "拷贝构造函数被调用\n"; }
    // 移动构造函数
    S(S&& other) noexcept : x(std::move(other.x)) { std::cout << "移动构造函数被调用\n"; }
};

int main() {
    std::vector<S> my_vec;
    // 后续实验将在此进行
    return 0;
}

这个结构体 S 包含一个整数成员 x,并定义了三种构造函数,每个被调用时都会打印相应的信息。


深入理解 push_back

上一节我们介绍了实验环境,本节中我们来看看 push_back 方法。push_back 用于在 vector 的末尾添加一个新元素。它有两种重载形式,分别处理左值(lvalue)和右值(rvalue)。

使用左值调用 push_back

当我们传递一个已命名的对象(左值)给 push_back 时,它会调用拷贝构造函数,在 vector 内部创建该对象的一个副本。

以下是具体步骤:

  1. 首先,创建一个 S 类型的对象 s
  2. 然后,调用 my_vec.push_back(s)
  3. 此时,push_back 会为 vector 分配内存(如果需要),并调用 S 的拷贝构造函数,将 s 的内容复制到新分配的内存中。

运行代码后,输出将显示“构造函数被调用”(创建s)和“拷贝构造函数被调用”(复制到vector)。

使用 std::move 调用 push_back

如果我们想避免拷贝,可以使用 std::move 将左值转换为右值引用,从而触发移动构造函数。

以下是具体步骤:

  1. 同样先创建对象 s
  2. 调用 my_vec.push_back(std::move(s))
  3. 这次,push_back 会调用移动构造函数,将 s 的资源“移动”到 vector 中,这通常比拷贝更高效。

输出将显示“构造函数被调用”和“移动构造函数被调用”。

使用临时对象(右值)调用 push_back

我们也可以直接传递一个临时对象(右值)给 push_back,这会自动触发移动语义。

以下是具体步骤:

  1. 直接调用 my_vec.push_back(S(10))
  2. 首先,S(10) 调用构造函数创建临时对象。
  3. 接着,push_back 会调用移动构造函数,将这个临时对象移动到 vector 中。

输出同样显示“构造函数被调用”和“移动构造函数被调用”,且无需显式使用 std::move

总结 push_back 的特点:无论哪种方式,push_back 总是涉及两处内存:一处是原始对象所在的位置,另一处是 vector 内部为新元素分配的位置。操作过程要么是拷贝,要么是移动。


探索 emplace_back

上一节我们看到了 push_back 总是需要构造然后拷贝或移动,本节中我们来看看 emplace_back 如何提供一种更高效的替代方案。emplace_back 的核心思想是“就地构造”。

push_back 接收一个对象不同,emplace_back 接收的是构造该对象所需的参数。它会在 vector 尾部直接分配的内存中,使用这些参数调用构造函数来创建对象,从而完全避免了额外的拷贝或移动操作。

使用 emplace_back

以下是使用 emplace_back 的示例:

my_vec.emplace_back(10);

这行代码的含义是:在 my_vec 的末尾,直接使用参数 10 调用 S 的构造函数 S(int n) 来创建一个 S 对象。

运行代码,输出将只显示一次“构造函数被调用”。没有拷贝构造函数,也没有移动构造函数。对象被直接构造在了 vector 为自己管理的内存中。

emplace_back 的优势:对于构造成本较高的对象(例如包含动态内存或复杂资源管理的类),使用 emplace_back 可以避免不必要的拷贝或移动,从而带来显著的性能提升。


总结

本节课中我们一起学习了 std::vectorpush_backemplace_back 方法。

  • push_back:接受一个已构造好的对象(或临时对象),然后将其拷贝移动vector 中。它总是涉及原始对象和容器内目标位置两处内存。
  • emplace_back:接受构造对象所需的参数,并在 vector 内部管理的内存中直接构造对象。它避免了额外的拷贝或移动步骤,通常更高效。

核心选择建议

  • 当你已经有一个现成的对象需要添加到容器时,使用 push_back(配合 std::move 来启用移动语义)。
  • 当你希望直接使用参数在容器内构造一个新对象时,使用 emplace_back 以获得更好的性能。

理解这两个方法的区别,有助于你在编写C++程序时做出更优的选择,尤其是在处理复杂或昂贵的对象时。

031:继承与派生类

在本节课中,我们将要学习C++中的继承与派生类。继承是面向对象编程的核心概念之一,它允许我们创建一个新类(派生类)来继承另一个类(基类)的属性和方法,从而减少代码重复并建立类之间的层次关系。

概述

到目前为止,我们一直在C++中定义独立的structclass。然而,很多时候我们的结构体或类之间可能存在重叠,无论是在实现的方法上还是在包含的数据上。处理这种代码重复的一种方式就是通过继承和派生类。

我们可以定义一个基类,它实现一组公共的数据成员和方法,然后让其他类从这个基类继承。这些其他类被称为派生类。本节课我们将学习C++中继承的基础知识。

定义基类

首先,我们创建一个简单的基类。基类的定义方式与普通类相同,关键在于我们如何使用它。

#include <iostream>

struct Base {
    int x;
    int y;

    // 构造函数
    Base(int new_x, int new_y) : x(new_x), y(new_y) {}

    // 成员函数
    void printXY() {
        std::cout << "x is equal to " << x << '\n';
        std::cout << "y is equal to " << y << '\n';
    }
};

在上面的代码中,我们定义了一个名为Base的结构体,它包含两个整数数据成员xy,一个用于初始化这些成员的构造函数,以及一个打印xy值的成员函数printXY

创建派生类

接下来,我们基于Base类创建一个派生类。派生类可以继承基类的数据和方法,并在此基础上添加自己的特有成员。

struct Derived1 : public Base {
    int z;

    // 派生类的构造函数
    Derived1(int new_x, int new_y, int new_z) : Base(new_x, new_y), z(new_z) {}

    // 派生类特有的成员函数
    void printZ() {
        std::cout << "z is equal to " << z << '\n';
    }
};

让我们来解析一下这段代码:

  • struct Derived1 : public Base:这表示Derived1类公开继承自Base类。这意味着Derived1对象将包含Base的所有成员。
  • int z;:我们为派生类添加了一个新的数据成员z
  • Derived1(...) : Base(new_x, new_y), z(new_z) {}:这是派生类的构造函数。在成员初始化列表中,我们首先调用基类Base的构造函数来初始化继承来的xy,然后初始化派生类自己的成员z
  • void printZ():这是一个派生类特有的新成员函数。

通过继承,Derived1类现在拥有了xyprintXY()(来自基类)以及zprintZ()(自己添加的)。

使用派生类

现在,让我们看看如何使用这个派生类。

int main() {
    // 创建派生类对象
    Derived1 d1(1, 2, 3);

    // 调用从基类继承的方法
    d1.printXY();
    // 调用派生类自己的方法
    d1.printZ();

    return 0;
}

运行这段代码,输出结果将是:

x is equal to 1
y is equal to 2
z is equal to 3

正如所见,派生类对象d1可以无缝地使用来自基类的方法printXY和自己定义的方法printZ

继承的灵活性

继承的强大之处在于,我们可以基于同一个基类创建多个不同的派生类,每个派生类可以进行不同的特化和扩展。

例如,我们可以创建另一个派生类Derived2,它可能使用不同的数据类型或初始化方式:

struct Derived2 : public Base {
    float value;

    Derived2() : Base(10, 20), value(3.14f) {} // 使用常量初始化基类部分
    // ... 可以添加其他成员
};

访问说明符

在上面的例子中,我们使用了public继承(: public Base)。C++中还有privateprotected继承,它们控制着基类成员在派生类中的访问权限。此外,struct默认的成员访问权限是public,而class默认是private,这在定义继承关系时也会产生影响。这些更深入的主题我们将在后续课程中讨论。

总结

本节课中我们一起学习了C++继承与派生类的基础知识。我们了解到:

  1. 继承 允许派生类获取基类的数据成员和成员函数,是代码复用的重要机制。
  2. 使用 : public Base 语法可以定义一个派生类。
  3. 派生类可以在构造函数中通过 成员初始化列表 调用基类的构造函数。
  4. 派生类可以 添加新的数据成员和成员函数,对基类功能进行扩展。
  5. 派生类对象可以 同时使用基类和自身的方法
  6. 继承的访问控制(publicprivateprotected)决定了基类成员在派生类中的可见性,这是一个重要的设计考量点。

通过继承,我们可以构建出层次化的类结构,使代码更加模块化、可维护和可扩展。

032:多态性基础 🧬

在本节课中,我们将要学习C++中多态性的基本概念。多态性允许我们将不同类型的对象视为同一类型,这在处理继承体系时尤其有用,例如将不同种类的动物对象统一管理。


概述

上一节我们介绍了继承的概念,即派生类可以继承基类的成员。本节中我们来看看多态性,它允许我们将派生类对象当作其基类类型来处理。这使得我们可以将具有相同基类的不同派生类对象组合在一起,例如放入同一个容器中。

多态性(Polymorphism)一词意为“多种形态”。在继承的上下文中,它通常指我们可以将不同类型的对象(特别是派生类对象)视为其共同的基类类型。这样做的好处是,我们可以将具有相同基类的对象放入标准模板库(STL)容器中。

基础示例:动物类继承体系

我们将使用一个经典的动物继承示例。首先,创建一个基类 Animal,然后创建两个派生类 DogCat

#include <iostream>

// 基类 Animal
struct Animal {
    void speak() {
        std::cout << "Default speak function!" << std::endl;
    }
};

// 派生类 Dog
struct Dog : public Animal {
    void speak() {
        std::cout << "Woof!" << std::endl;
    }
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/976b807a40418d570e9e186612673c24_3.png)

// 派生类 Cat
struct Cat : public Animal {
    void speak() {
        std::cout << "Meow!" << std::endl;
    }
};

在上面的代码中:

  • Animal 是基类,有一个 speak 成员函数。
  • DogCat 是派生类,它们重载了基类的 speak 函数,提供了各自特定的实现。

创建对象并调用函数

以下是创建派生类对象并调用其成员函数的基本操作。

int main() {
    Dog d;
    Cat c;

    d.speak(); // 输出: Woof!
    c.speak(); // 输出: Meow!

    return 0;
}

运行此程序,Dog 对象输出 “Woof!”,Cat 对象输出 “Meow!”,符合预期。

将派生类对象视为基类类型

有时,我们希望将 DogCat 这些不同类型的对象统一视为 Animal 类型。一个简单的方法是使用基类类型的引用指向派生类对象。

int main() {
    Dog d;
    Cat c;

    // 创建 Animal 类型的引用,指向 Dog 对象
    Animal& a1 = d;
    // 创建 Animal 类型的引用,指向 Cat 对象
    Animal& a2 = c;

    a1.speak(); // 输出: Default speak function!
    a2.speak(); // 输出: Default speak function!

    return 0;
}

代码解析

  • Animal& a1 = d;:这行代码创建了一个 Animal 类型的引用 a1,并将其绑定到 Dog 对象 d 上。这是合法的,因为 Dog 继承自 Animal
  • 通过引用 a1a2 调用 speak 函数时,调用的是基类 Animalspeak 函数,而不是派生类中重载的版本。

这种行为被称为向上转型,即将派生类类型转换为其基类类型。目前我们看到的是一种静态多态,因为编译器在编译时就已经决定了调用哪个函数。

静态多态与动态多态

  • 静态多态:函数调用在编译时确定。上面的例子就是静态多态,编译器看到 a1Animal& 类型,因此直接绑定到 Animal::speak
  • 动态多态:函数调用在运行时确定。如果我们希望即使通过基类引用调用 speak,也能执行派生类特定的版本(例如,通过 Animal& 引用调用 Dog::speak),就需要使用虚函数。这将是下一节视频讨论的内容。

总结

本节课中我们一起学习了C++多态性的基础。我们了解到:

  1. 多态性允许我们将派生类对象当作基类类型处理。
  2. 通过创建基类类型的引用指向派生类对象,可以实现向上转型
  3. 在当前的静态多态示例中,通过基类引用调用函数会执行基类的版本。
  4. 若要在运行时根据实际对象类型调用函数,需要使用虚函数实现动态多态,这将是后续课程的主题。

通过掌握多态性,我们可以更灵活地组织和管理具有继承关系的对象,为构建复杂的程序结构打下基础。

033:虚函数 🧬

在本节课中,我们将要学习C++中的虚函数。虚函数是实现多态性的关键机制,它允许我们在运行时根据对象的实际类型来调用正确的成员函数,即使我们使用的是指向基类的指针或引用。

概述

在上一节中,我们介绍了多态性的基础知识,并看到了如何使用引用来将派生类对象重新解释为基类对象,这被称为静态多态。然而,编译器在编译时并不总是知道所有对象的类型信息。有时,我们可能有一个指向基类类型的指针或引用,但并不知道其底层实际的派生类类型是什么。尽管如此,我们仍然希望在这些情况下调用底层派生类类型的行为。这正是虚函数发挥作用的地方。

虚函数的作用

虚函数是可以在派生类中被重写的成员函数。与非虚函数不同,即使没有关于类实际类型的编译时信息,其重写行为也会被保留。这意味着,如果我们有一个被向上转型的派生类对象,并且我们有一个指向基类的指针或引用,当我们通过该指针或引用调用某个被重写的虚函数时,我们仍然会调用派生类的行为。这被称为虚函数调用。

代码示例

以下是演示虚函数基本用法的代码示例:

#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/19dc0b4493dea711aee6b93b49b2bf3f_5.png)

struct Animal {
    // 使用 virtual 关键字声明虚函数
    virtual void speak() {
        std::cout << "Default speak function\n";
    }
};

struct Dog : public Animal {
    // 使用 override 关键字明确表示重写基类虚函数
    void speak() override {
        std::cout << "Woof\n";
    }
};

struct Cat : public Animal {
    void speak() override {
        std::cout << "Meow\n";
    }
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/19dc0b4493dea711aee6b93b49b2bf3f_7.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/19dc0b4493dea711aee6b93b49b2bf3f_8.png)

int main() {
    Dog d;
    Cat c;

    // 直接调用派生类对象的函数
    d.speak(); // 输出: Woof
    c.speak(); // 输出: Meow

    // 通过基类引用调用(向上转型)
    Animal& a1 = d;
    Animal& a2 = c;
    a1.speak(); // 输出: Woof (因为 speak 是虚函数)
    a2.speak(); // 输出: Meow (因为 speak 是虚函数)

    return 0;
}

使用 override 关键字

override 关键字是C++11引入的一个说明符,用于明确指出某个函数旨在重写基类中的虚函数。这是一个良好的编程实践,因为它可以帮助编译器捕获错误。

以下是使用 override 关键字的示例:

struct Dog : public Animal {
    // 正确:明确表示重写基类的 speak 函数
    void speak() override {
        std::cout << "Woof\n";
    }
};

如果不小心拼错了函数名,而没有使用 override,编译器会将其视为一个新的成员函数,而不会报错,这可能导致难以察觉的错误。使用 override 后,编译器会检查该函数是否确实重写了基类的虚函数,如果没有,则会报错。

虚函数的底层机制

虚函数调用通常涉及一些运行时开销。当通过基类指针或引用调用虚函数时,程序需要在运行时查找并决定应该调用哪个函数(基类的还是某个派生类的)。这个查找过程通常通过一个称为“虚函数表”的机制来实现。我们将在后续更高级的课程中详细讨论这个话题。

总结

本节课中我们一起学习了C++中虚函数的核心概念。我们了解到,虚函数通过在基类中使用 virtual 关键字声明,允许派生类重写其行为,并且即使通过基类指针或引用调用,也能执行派生类的实现。我们还介绍了 override 关键字的重要性,它有助于在编译时捕获因拼写错误等导致的潜在错误,是提高代码健壮性的良好实践。通过掌握虚函数,我们可以更灵活地设计和实现具有多态行为的面向对象程序。

034:抽象类 🧩

在本节课中,我们将要学习C++中的抽象类。抽象类是一种特殊的类,它不能被实例化,只能作为其他类的基类使用。通过使用抽象类,我们可以定义一些通用的接口,同时强制要求派生类实现特定的功能。

在之前的几节课程中,我们介绍了继承和多态的基础知识。我们学习了如何创建基类和从基类继承的多个派生类。本节中,我们来看看一种特殊类型的基类——抽象类。

什么是抽象类?

抽象类定义了一种抽象类型,这种类型不能被实例化,但可以作为基类使用。这意味着程序员不能(无论是故意还是意外地)创建抽象类的对象,但它仍然可以作为其他类继承和构建的基础。

实现抽象类的关键机制是纯虚函数。当一个类或结构体包含至少一个纯虚函数时,它就成为抽象类。纯虚函数的声明方式是在虚函数声明的末尾加上 = 0,这表示基类不提供该函数的实现,而要求派生类必须实现它。

以下是纯虚函数的语法示例:

virtual void functionName() = 0;

为何需要抽象类?

考虑一个代表“动物”的基类。动物本身是一个抽象概念,我们只关心具体的动物类型,如“狗”或“猫”。我们不应该能够创建一个通用的“动物”对象。抽象类正是用来防止这种情况发生的工具。

上一节我们介绍了虚函数和覆盖,本节中我们来看看如何通过纯虚函数将基类定义为抽象类。

实践示例:从具体类到抽象类

让我们通过一个具体的代码示例来理解抽象类的创建和使用。

以下是一个未使用抽象类的初始代码结构:

struct Animal {
    virtual void speak() { std::cout << “Some sound\n”; }
};
struct Dog : public Animal {
    void speak() override { std::cout << “Woof\n”; }
};
struct Cat : public Animal {
    void speak() override { std::cout << “Meow\n”; }
};

在这个结构中,可以创建 Animal 类型的对象,但这在逻辑上并不合理。

为了将 Animal 定义为抽象类,我们需要将其中的 speak 函数声明为纯虚函数。

修改后的 Animal 结构体如下:

struct Animal {
    virtual void speak() = 0; // 纯虚函数
};

现在,Animal 成为了一个抽象类。尝试创建 Animal 的对象会导致编译错误,这正是我们期望的结果。

然而,我们仍然可以:

  1. 创建 DogCat 的对象。
  2. 使用基类 Animal 的指针或引用来指向派生类对象,并实现多态。

以下是演示这些操作的代码:

int main() {
    Dog d;
    Cat c;

    d.speak(); // 输出:Woof
    c.speak(); // 输出:Meow

    Animal& a1 = d;
    Animal& a2 = c;

    a1.speak(); // 输出:Woof (多态)
    a2.speak(); // 输出:Meow (多态)

    // Animal a; // 错误!不能实例化抽象类
    return 0;
}

核心要点总结

本节课中我们一起学习了C++抽象类的核心概念:

  • 定义:包含至少一个纯虚函数的类称为抽象类。
  • 目的:抽象类用于定义接口和抽象概念,防止其被直接实例化。
  • 语法:通过 virtual ReturnType FunctionName() = 0; 声明纯虚函数。
  • 作用:强制要求所有派生类(非抽象类)必须覆盖并实现所有的纯虚函数。
  • 多态:抽象类虽然不能实例化,但其指针或引用可以用于指向派生类对象,是实现运行时多态的重要基础。

通过使用抽象类,我们可以设计出更清晰、更健壮的类层次结构,确保代码遵循特定的设计契约。

035:类模板 🧩

在本节课中,我们将要学习C++中的类模板。类模板允许我们创建通用的类和容器,就像标准模板库(STL)中的std::vector一样,它可以容纳任何类型的元素。通过使用类模板,我们可以让编译器为我们生成针对不同数据类型的类版本,从而减少重复代码。

上一节我们介绍了函数模板的基础知识,本节中我们来看看如何将类似的模板概念应用到类上。

定义类模板

要定义一个类模板,我们使用template关键字,后跟模板参数列表。以下是一个简单的动态数组类模板示例。

#include <iostream>
#include <memory>

template <typename T>
struct DynamicArray {
    int size;
    std::unique_ptr<T[]> ptr;

    // 构造函数
    DynamicArray(int new_size) : size(new_size), ptr(new T[new_size]) {}

    // 填充数组的方法
    void fill(const T& value) {
        for (int i = 0; i < size; ++i) {
            ptr[i] = value;
        }
    }

    // 打印数组内容的方法
    void print() const {
        for (int i = 0; i < size; ++i) {
            std::cout << ptr[i] << " ";
        }
        std::cout << std::endl;
    }
};

在这个DynamicArray类模板中:

  • template <typename T> 声明了一个类型模板参数 T
  • 类内部使用 T 来定义指针管理的数组类型 std::unique_ptr<T[]>
  • 构造函数接收一个大小参数,并动态分配一个 T 类型的数组。
  • fill 方法接收一个 T 类型的值,并用它填充整个数组。
  • print 方法将数组的所有元素打印到控制台。

使用类模板

定义了类模板后,我们可以通过指定具体的类型来实例化它。实例化时,需要在类名后使用尖括号 <> 提供模板参数。

以下是使用上述DynamicArray类模板的示例:

int main() {
    // 实例化一个存储整数的动态数组
    DynamicArray<int> int_array(10);
    int_array.fill(5);
    int_array.print();

    // 实例化一个存储双精度浮点数的动态数组
    DynamicArray<double> double_array(10);
    double_array.fill(1.23);
    double_array.print();

    return 0;
}

在这段代码中:

  • DynamicArray<int> 告诉编译器生成一个 T 被替换为 intDynamicArray 类版本。
  • DynamicArray<double> 则生成一个 T 被替换为 double 的版本。
  • 之后,我们就可以像使用普通类一样,调用其成员方法(如 fillprint)。

编译与运行

当你编译并运行上述程序时,输出结果将如下所示:

5 5 5 5 5 5 5 5 5 5
1.23 1.23 1.23 1.23 1.23 1.23 1.23 1.23 1.23 1.23

这证明了编译器成功为我们生成了两个不同版本的 DynamicArray 类:一个用于 int,另一个用于 double

总结

本节课中我们一起学习了C++类模板的核心概念。我们了解到,类模板通过template <typename T>语法定义,允许我们创建可处理多种数据类型的通用类。使用时,通过ClassName<Type>的形式进行实例化。这极大地提高了代码的复用性和灵活性,是C++标准库中众多容器(如vector, list, map)的实现基础。虽然类模板还有更多高级特性(如特化),但掌握其基本用法是有效使用现代C++的关键一步。

036:默认比较与三路比较运算符 🚀

在本节课中,我们将学习C++20引入的默认比较功能以及三路比较运算符(又称“飞船运算符”)。这些特性可以帮助我们简化自定义类型的比较操作,让编译器自动生成比较运算符,从而减少重复代码。

在之前的课程中,我们学习了如何为自定义的结构体或类重载运算符。然而,如果需要实现多个简单的比较运算符(如等于、不等于、大于、小于等),手动编写每个成员函数会显得繁琐。

幸运的是,C++20通过默认比较和三路比较运算符提供了解决方案。我们可以将这些工作委托给编译器,就像使用模板一样,让编译器为我们生成这些比较运算符。

接下来,我们通过一个简单的例子来看看具体如何操作。

定义结构体与问题引入

首先,我们创建一个名为 default_compare.cpp 的文件,并包含必要的头文件和主函数。

#include <iostream>

struct S {
    int a;
    int b;
};

int main() {
    S s1 = {1, 2};
    S s2 = {1, 3};
    std::cout << (s1 == s2) << std::endl;
    return 0;
}

如果我们尝试编译上述代码,编译器会报错,提示没有为类型 S 找到匹配的 operator==。这是因为我们尚未为结构体 S 实现相等比较运算符。

我们可以手动实现这个运算符,逐个比较所有数据成员。但如果数据成员很多,这会是一项繁重的工作。更理想的方式是让编译器自动生成这些代码,这正是默认比较功能的用武之地。

使用默认相等比较运算符

我们可以通过将运算符设置为 default 来让编译器生成它,类似于默认复制构造函数或移动构造函数。

以下是修改后的代码:

#include <iostream>

struct S {
    int a;
    int b;
    // 让编译器生成默认的相等比较运算符
    bool operator==(const S&) const = default;
};

int main() {
    S s1 = {1, 2};
    S s2 = {1, 3};
    std::cout << (s1 == s2) << std::endl; // 输出 0 (false)
    return 0;
}

编译器生成的 operator== 会执行成员级别的逐一比较。它会比较两个对象的所有数据成员(ab)。如果发现任何一对成员不相等,则返回 false;如果全部相等,则返回 true

注意:要使用C++20的此功能,需要使用支持C++20的编译器(如GCC 10+)并添加编译标志 -std=c++20

引入三路比较运算符

默认 operator== 只解决了相等比较。如果我们还需要其他比较操作(如 ><>=<=),仍然需要手动实现它们。

为了避免逐个实现所有比较运算符,C++20引入了三路比较运算符 operator<=>,它看起来像一艘飞船或飞碟。😊

使用这个运算符,我们可以指示编译器生成所有标准的比较运算符。

以下是使用三路比较运算符的示例:

#include <iostream>

struct S {
    int a;
    int b;
    // 使用三路比较运算符,让编译器生成所有比较运算符
    auto operator<=>(const S&) const = default;
};

int main() {
    S s1 = {1, 2};
    S s2 = {1, 3};

    std::cout << (s1 == s2) << std::endl;  // 相等比较,输出 0
    std::cout << (s1 > s2) << std::endl;   // 大于比较,输出 0
    std::cout << (s1 < s2) << std::endl;   // 小于比较,输出 1

    return 0;
}

现在,s1 < s2s1 > s2s1 <= s2s1 >= s2 等所有比较操作都可以正常使用,因为编译器已经通过 operator<=> 为它们生成了代码。

编译器生成的比较逻辑也是成员级别的,并且遵循字典序。它会首先比较第一个成员 a,如果 a 能决定大小关系(例如 s1.a > s2.a),则立即返回结果;如果 a 相等,则继续比较下一个成员 b

编译与运行

使用以下命令编译代码(确保使用C++20标准):

g++ -std=c++20 default_compare.cpp -o default_compare
./default_compare

运行程序,你将看到基于成员值比较的正确布尔结果。

总结

本节课中我们一起学习了C++20中两个强大的特性:

  1. 默认比较运算符:通过 bool operator==(const T&) const = default; 让编译器自动生成相等比较运算符。
  2. 三路比较运算符(飞船运算符):通过 auto operator<=>(const T&) const = default; 让编译器自动生成全套比较运算符(==, !=, <, >, <=, >=)。

这些特性极大地简化了为自定义类型实现比较逻辑的过程,特别是在只需要简单的成员级比较时。对于更复杂的比较规则(如部分排序、弱排序),你可以选择手动实现 operator<=> 来定义更精确的比较语义,但这超出了本入门教程的范围。

通过将这些繁琐的工作交给编译器,我们可以更专注于程序的核心逻辑。

037:虚拟继承

概述

在本节课中,我们将要学习C++中的虚拟继承。我们将探讨多重继承中可能出现的“菱形继承”问题,并学习如何使用虚拟基类来解决这个问题。


菱形继承问题

上一节我们介绍了继承和派生类的基础知识,了解了如何从基类继承数据成员和成员函数。本节中我们来看看在复杂的继承层次结构中可能出现的一个特定问题。

在编写代码时,我们可能会遇到具有多层继承关系的复杂层次结构,甚至会有派生类同时继承多个基类的情况。在这个过程中可能出现的一个问题是所谓的“菱形继承”问题。这种情况是指我们无意中从某个基类继承了多次。

以下是菱形继承问题的一个简单示例:

struct A {
    A() { std::cout << "Constructing A\n"; }
};

struct B : public A {
    B() { std::cout << "Constructing B\n"; }
};

struct C : public A {
    C() { std::cout << "Constructing C\n"; }
};

struct D : public B, public C {
    D() { std::cout << "Constructing D\n"; }
};

在这个例子中,我们定义了四个进行继承的struct。基类A有一个简单的构造函数。BCA的派生类。最底层的派生类D同时继承了BC。这样就形成了一个菱形继承模式:顶层的基类A,向下分支出两个派生类BC,最后是同时继承BC的最底层派生类D

这里的问题在于,D无意中从A继承了两次:一次通过B,一次通过C。我们需要理解继承的含义。当B继承自A时,意味着我们在基类A的基础上构建新对象BA将成为B的一个子对象。对于C也是如此,A将成为C的一个子对象。当D同时继承BC时,BC都将成为D的子对象。由于BC各自包含一个A子对象,这意味着在D对象内部,我们得到了两个A类型的子对象。这就是菱形继承问题:我们在最底层的派生类中无意中多次继承了同一个基类。

让我们通过跟踪构造函数调用来观察这种情况。我们只需创建一个D类型的对象:

int main() {
    D d;
    return 0;
}

编译并运行程序后,我们可以看到构造D类型对象时的输出。首先会调用D的构造函数。由于D继承了CB,我们也会看到这些构造函数的调用。C继承自AB也继承自A,因此在构造子对象C时,我们也在构造一个A对象;同样,在构造子对象B时,我们也在构造一个A对象。最终,我们构造了A子对象两次,在D对象内部有两个A子对象。

这个问题不仅仅是占用了更多内存来存储A的数据成员。它还影响了向上转型和多态性。例如,我们无法将D向上转型为A类型:

int main() {
    D d;
    A &a_ref = d; // 编译错误:A是D的模糊基类
    return 0;
}

编译器会报错,指出AD的模糊基类。这是因为D内部有两个A子对象,当进行向上转型时,编译器不知道我们实际想要使用哪一个。在模糊的情况下,编译器通常会拒绝编译代码。


虚拟继承解决方案

那么,我们如何解决这个菱形继承问题呢?一种方法是通过虚拟基类的概念。

根据CPP参考中关于虚拟基类的说明:对于每个被指定为虚拟的基类,最底层的派生对象只包含一个该类型的基类子对象。这意味着,如果我们将A声明为虚拟基类,那么在最底层的派生类D中,即使通过BC继承,我们也只会得到一个A子对象。

让我们看看如何实现:

struct A {
    A() { std::cout << "Constructing A\n"; }
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/c96b49e5bb975cb09fbb506c5f46b7d5_11.png)

struct B : virtual public A {  // 将A声明为虚拟基类
    B() { std::cout << "Constructing B\n"; }
};

struct C : virtual public A {  // 将A声明为虚拟基类
    C() { std::cout << "Constructing C\n"; }
};

struct D : public B, public C {
    D() { std::cout << "Constructing D\n"; }
};

我们只需在继承时使用virtual关键字将A声明为虚拟基类。现在,当我们尝试向上转型时,不再会出现错误,只会得到一个关于未使用变量的警告,但不再提示模糊性,代码可以正常编译。

重新编译并运行程序后,我们可以看到构造函数调用的变化。当构造继承自BCD类型对象时,会创建CB类型的对象,但只构造了一个A类型的子对象。尽管BC都继承自A,但由于我们将A声明为BC中的虚拟基类,根据虚拟基类的定义,我们只得到了一个实例。

虚拟继承在实际的标准库中也有应用。例如,IO流库就是一个使用虚拟基类的继承层次结构实例。std::istreamstd::ostream都通过虚拟继承从基类std::ios派生。而std::iostream则同时继承std::istreamstd::ostream。这里使用虚拟继承就是为了防止出现两个std::ios基类实例。


总结

本节课中我们一起学习了C++中的虚拟继承。我们探讨了多重继承中出现的菱形继承问题,即派生类无意中多次继承同一个基类的情况。我们学习了如何使用virtual关键字将基类声明为虚拟基类,从而确保在最底层的派生类中只存在一个基类子对象。我们还了解了虚拟继承在实际应用中的例子,如C++标准库中的IO流层次结构。掌握虚拟继承有助于我们设计更清晰、更安全的类层次结构。

038:函数对象 🎯

在本节课中,我们将要学习C++中的函数对象。函数对象是任何定义了函数调用运算符的对象,这意味着我们可以像调用函数一样调用这些对象。函数对象的一个优点是它们可以携带状态,这些状态就是与对象关联的数据成员。我们将学习如何定义和使用函数对象,并了解如何在标准库算法(如 std::ranges::find_if)的上下文中应用它们。


定义函数对象

上一节我们介绍了函数对象的基本概念,本节中我们来看看如何定义一个简单的函数对象。

我们将创建一个名为 is_divisible 的结构体,用于检查一个数字是否能被另一个数字整除。这个结构体包含一个数据成员 divisor(除数)和一个重载的函数调用运算符。

#include <iostream>

struct is_divisible {
    int divisor;
    // 构造函数,用于初始化除数
    is_divisible(int new_divisor) : divisor(new_divisor) {}
    // 重载函数调用运算符
    bool operator()(int dividend) {
        return (dividend % divisor) == 0;
    }
};

在上述代码中:

  • divisor 是数据成员,存储了我们要检查的除数。
  • 构造函数 is_divisible(int new_divisor) 用于初始化 divisor
  • bool operator()(int dividend) 是重载的函数调用运算符。它接受一个 dividend(被除数)作为参数,并返回一个布尔值,表示 dividend 是否能被 divisor 整除。其核心逻辑是检查余数是否为0,用公式表示为:dividend % divisor == 0

使用函数对象

定义了函数对象后,我们可以像使用函数一样使用它。以下是创建对象并调用它的示例。

int main() {
    // 创建一个检查是否能被10整除的函数对象
    is_divisible is_divisible_by_10(10);

    // 像调用函数一样调用该对象
    std::cout << is_divisible_by_10(55) << '\n'; // 输出 0 (false)
    std::cout << is_divisible_by_10(50) << '\n'; // 输出 1 (true)

    return 0;
}

运行此代码,对于55会输出0(假),对于50会输出1(真)。这证明了我们可以像调用函数一样调用 is_divisible_by_10 这个对象。


在STL算法中应用函数对象

函数对象的一个强大用途是作为谓词传递给标准模板库(STL)中的算法。接下来,我们看看如何将函数对象与 std::ranges::find_if 算法结合使用。

首先,我们需要包含必要的头文件并创建一个整数向量。

#include <vector>
#include <algorithm>

int main() {
    // 创建一个整数向量
    std::vector<int> my_vector = {41, 20, 84, 94, 23};

    // 创建一个检查是否能被10整除的函数对象
    is_divisible is_divisible_by_10(10);

    // 使用 std::ranges::find_if 查找第一个能被10整除的元素
    auto iterator = std::ranges::find_if(my_vector, is_divisible_by_10);

    // 输出找到的元素(注意:实际使用中应检查迭代器是否有效)
    std::cout << *iterator << '\n'; // 输出 20

    return 0;
}

以下是关键步骤的说明:

  1. 包含头文件<vector> 用于使用 std::vector<algorithm> 用于使用 std::ranges::find_if
  2. 创建向量my_vector 包含一些整数。
  3. 使用算法std::ranges::find_if 接受一个范围(my_vector)和一个谓词(is_divisible_by_10)。它会返回指向范围内第一个使谓词返回 true 的元素的迭代器。
  4. 输出结果:解引用迭代器 *iterator 得到值 20,它是向量中第一个能被10整除的数。

注意std::ranges::find_if 是C++20引入的约束算法。编译时需要指定C++20标准,例如使用 g++ -std=c++20 进行编译。


总结

本节课中我们一起学习了C++函数对象的核心概念和应用。

  • 函数对象是定义了函数调用运算符 operator() 的对象,可以像函数一样被调用。
  • 定义方法:在结构体或类中重载 operator(),并实现所需的逻辑(例如,检查整除性:dividend % divisor == 0)。
  • 基本使用:创建函数对象实例后,可以直接用 对象(参数) 的语法调用它。
  • 高级应用:函数对象可以作为谓词传递给STL算法(如 std::ranges::find_if),使代码更简洁、更通用。

函数对象为C++编程提供了将状态和行为封装在一起的灵活方式。在下一节课中,我们将探讨Lambda表达式,它是一种创建匿名函数对象的更简洁的语法,可以进一步简化代码。

039:Lambda表达式 🚀

在本节课中,我们将要学习C++中的Lambda表达式。Lambda表达式是C++11引入的一项强大功能,它允许我们创建匿名的函数对象,从而简化代码并提高可读性。

上一节我们介绍了函数对象的基础知识,了解了如何通过重载结构体或类的函数调用运算符来创建可调用对象。然而,我们也注意到,为了创建一个函数对象,需要编写相当多的样板代码,例如定义结构体、添加数据成员、构造函数以及重载运算符。

从函数对象到Lambda表达式

本节中,我们来看看如何使用Lambda表达式来简化这个过程。Lambda表达式允许我们创建一个能够捕获作用域内变量的无名函数对象,而无需显式定义一个结构体或类。

以下是使用Lambda表达式替换之前函数对象的具体步骤:

  1. 移除结构体定义:我们不再需要定义一个类似 is_divisible 的结构体。
  2. 使用 auto 关键字:我们将依赖编译器自动推导Lambda表达式的类型。
  3. 编写Lambda表达式:Lambda表达式主要由三部分组成:
    • 捕获列表 []:指定要从外部作用域捕获哪些变量(按值或按引用),甚至可以在此处创建新变量。
    • 参数列表 ():与普通函数一样,指定该函数对象接受的参数。
    • 函数体 {}:定义函数对象要执行的操作。

让我们通过一个具体例子来理解。假设我们有一个函数对象,用于检查一个数是否能被某个除数整除。

原始函数对象代码:

struct is_divisible {
    int divisor;
    is_divisible(int new_divisor) : divisor(new_divisor) {}
    bool operator()(int dividend) const {
        return dividend % divisor == 0;
    }
};

使用Lambda表达式替换后:

auto is_divisible_by_10 = [divisor = 10](int dividend) {
    return dividend % divisor == 0;
};

在这段Lambda表达式中:

  • [divisor = 10] 是捕获列表,它创建并初始化了一个名为 divisor 的变量,其值为10。
  • (int dividend) 是参数列表,表示这个Lambda接受一个整型参数 dividend
  • { return dividend % divisor == 0; } 是函数体,其逻辑与之前结构体中的 operator() 完全一致。

可以看到,我们用一行简洁的Lambda表达式完全替代了之前需要多行代码定义的结构体。

在算法中使用Lambda表达式

Lambda表达式的一个常见用途是作为参数传递给标准库算法,例如 std::ranges::find_if

以下是一个完整的示例,演示了如何使用Lambda表达式在向量中查找第一个能被10整除的数:

#include <iostream>
#include <vector>
#include <algorithm>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/7400bc6a63bfddd3a88e7d6df9eded3a_5.png)

int main() {
    std::vector<int> my_vector = {1, 25, 3, 20, 5};

    // 定义Lambda表达式
    auto is_divisible_by_10 = [divisor = 10](int dividend) {
        return dividend % divisor == 0;
    };

    // 使用Lambda表达式作为谓词
    auto it = std::ranges::find_if(my_vector, is_divisible_by_10);

    if (it != my_vector.end()) {
        std::cout << "Found: " << *it << std::endl; // 输出: Found: 20
    }

    return 0;
}

编译此代码需要使用支持C++20的编译器,并指定 -std=c++20 标志。运行程序后,将成功找到并输出数字20。

总结

本节课中我们一起学习了C++ Lambda表达式的核心概念和基本用法。我们了解到,Lambda表达式通过 [捕获列表](参数列表){函数体} 的形式,提供了一种创建匿名函数对象的简洁方式。它极大地减少了定义小型、一次性使用的函数对象所需的样板代码,使代码更加清晰和易于维护。通过将其与标准库算法结合使用,我们可以编写出既强大又优雅的C++代码。

040:并行STL算法 🚀

在本节课中,我们将要学习如何使用C++标准模板库(STL)中的并行算法来提升程序性能。我们将通过一个具体的例子,演示如何将一个串行求和操作转换为并行操作,从而显著减少运行时间。


概述

到目前为止,我们主要关注的是用C++编写功能正确的代码。然而,编程的另一个重要方面是性能。我们不仅关心程序能生成正确的结果,还关心它能在可接受的时间内完成这些计算。性能在编程的各个层面都至关重要。

本节我们将探讨如何通过并行性为程序获取额外性能的基础知识。现代CPU拥有多核并支持SIMD指令,我们希望充分利用这些硬件特性。实现这一目标的方法之一就是使用并行算法。幸运的是,在现代C++版本和一些额外库的帮助下,我们可以获得我们所熟悉的标准STL算法的并行版本。

创建示例程序

我们将从一个简单的例子开始:对一个包含大量整数的向量进行求和。我们将使用STL算法中的 std::reduce 函数。

首先,创建一个名为 parallel_algo.cpp 的新文件。

以下是实现串行求和的代码:

#include <numeric>
#include <vector>

int main() {
    // 创建一个包含 2^30 个整数的向量
    std::vector<int> my_vector(1 << 30); // 相当于 2^30,约10亿个整数

    // 使用 std::reduce 对向量中的所有元素求和
    auto result = std::reduce(my_vector.begin(), my_vector.end(), 0);

    return result; // 向量初始化为0,所以结果为0
}

在这段代码中,我们创建了一个非常大的向量(约4GB数据),并使用 std::reduce 对其所有元素进行求和。向量默认初始化为0,因此最终结果也是0,但程序仍需执行大量的加法运算。

测量串行性能

我们可以使用 time 命令来测量这个程序的运行时间。在终端中编译并运行:

g++ -o parallel_algo parallel_algo.cpp
time ./parallel_algo

在我的测试中,这个串行版本的程序大约需要 11秒 才能完成。这是一个我们希望进行优化的耗时操作。

引入并行执行策略

C++17引入了执行策略(Execution Policies),允许我们指示算法使用并行或向量化技术。主要的执行策略有:

  • std::execution::seq: 顺序执行(默认,不允许并行化)。
  • std::execution::par: 允许并行执行(使用多个线程)。
  • std::execution::par_unseq: 允许并行和向量化执行(使用多个线程和SIMD指令)。

为了加速我们的求和操作,我们将使用 std::execution::par_unseq 策略。

我们需要包含 <execution> 头文件,并将执行策略作为第一个参数传递给 std::reduce

修改后的代码如下:

#include <numeric>
#include <vector>
#include <execution> // 引入执行策略头文件

int main() {
    std::vector<int> my_vector(1 << 30);

    // 使用并行且向量化的执行策略
    auto result = std::reduce(std::execution::par_unseq,
                              my_vector.begin(),
                              my_vector.end(),
                              0);

    return result;
}

链接并行库并测量性能

对于GCC等编译器,并行算法的实现依赖于外部线程库,例如Intel的Threading Building Blocks(TBB)。你需要先安装这个库(例如,在Ubuntu上使用 sudo apt-get install libtbb-dev),然后在编译时链接它。

使用以下命令编译并行版本的程序:

g++ -o parallel_algo_par parallel_algo.cpp -ltbb
time ./parallel_algo_par

再次运行程序,你会发现执行时间从大约 11秒 减少到了大约 4秒,性能提升了接近 3倍。我们所做的仅仅是在 std::reduce 调用中添加了一个执行策略参数。

可用算法与注意事项

并非所有STL算法都支持并行执行策略。CPPReference网站有一个专门的页面列出了所有支持执行策略的算法,例如 std::sortstd::for_eachstd::transform 等。在决定并行化之前,请务必查阅文档。

使用并行算法时也需注意:

  • 线程安全性: 确保传递给算法的函数和操作是线程安全的。
  • 开销: 对于非常小的数据量,创建和管理线程的开销可能超过并行计算带来的收益。
  • 数据竞争: 并行算法本身会处理数据划分,但如果你在算法外部操作共享数据,则需要自行管理同步。

总结

本节课中我们一起学习了C++并行STL算法的基础知识。我们了解到:

  1. 通过使用C++17引入的执行策略(如 std::execution::par_unseq),可以轻松地将许多标准算法并行化。
  2. 这通常需要链接额外的库(如Intel TBB)。
  3. 在我们的求和示例中,仅添加一个参数就获得了近3倍的性能提升,这展示了并行化在处理大规模数据时的强大能力。

并行化是优化程序性能的重要手段,而C++标准库提供的并行算法使其实现变得异常简单。对于计算密集型的任务,考虑使用并行算法是一个很好的起点。

041:线程基础

概述

在本节课中,我们将要学习C++中线程的基础知识。我们将了解如何创建线程、如何等待线程完成,以及如何使用更现代的std::jthread来简化线程管理。

在上一节中,我们探讨了并行STL算法的基础,这些算法非常方便,因为我们无需关心底层的并行化细节。然而,有时我们需要执行的工作并不能完美地匹配某个STL算法。因此,我们可能需要亲自动手实现并行化。在C++中,实现并行化的一种方式就是通过线程。我们可以创建执行线程来获得并行性。今天,我们将学习如何创建和连接这些线程,并初步了解如何使用它们。

准备工作

首先,我们需要包含必要的头文件。我们将使用<iostream>进行打印输出,并使用<thread>头文件来使用线程类。

#include <iostream>
#include <thread>

创建并运行一个线程

std::thread类代表一个单独的执行线程。线程允许多个函数并发执行。我们可以创建一个线程,并指定它要执行的函数,该线程将与主线程并行运行。

以下是创建线程的步骤。首先,我们需要定义一个希望线程执行的函数。

void print_thread_id(int id) {
    std::cout << "Printing from thread " << id << '\n';
}

接下来,在main函数中,我们可以创建一个std::thread对象来运行这个函数。构造函数接受要执行的函数以及该函数所需的参数。

int main() {
    std::thread t1(print_thread_id, 0);
    // ... 其他代码
}

连接线程

创建线程后,我们必须确保主线程在适当的位置等待该线程完成执行。这是通过调用线程对象的.join()方法实现的。

int main() {
    std::thread t1(print_thread_id, 0);
    t1.join(); // 主线程在此等待t1完成
    return 0;
}

如果不调用join(),主线程可能会在子线程完成工作之前就结束并销毁线程对象,这将导致运行时错误。

使用 std::jthread 简化管理

从C++20开始,引入了std::jthread(joining thread)。它与std::thread行为相似,但关键区别在于,std::jthread在析构时会自动调用join(),从而避免因忘记连接而导致的错误。

以下是使用std::jthread的示例:

int main() {
    std::jthread t1(print_thread_id, 0);
    // 无需手动调用 t1.join(),析构时会自动连接
    return 0;
}

使用std::jthread可以使代码更简洁、更安全。

创建多个线程

在实际应用中,我们经常需要创建多个线程。我们可以将线程对象存储在容器(如std::vector)中,并通过循环来创建它们。

以下是创建多个线程的示例:

int main() {
    std::vector<std::jthread> my_threads;

    for (int i = 0; i < 3; ++i) {
        my_threads.emplace_back(print_thread_id, i);
    }
    // 所有jthread在离开作用域时会自动join
    return 0;
}

运行此程序时,多个线程会并发执行,打印语句的顺序可能每次运行都不同,甚至可能出现输出内容交错的情况。这是因为线程是异步执行的,没有特定的协调顺序。

总结

本节课中我们一起学习了C++线程编程的基础。

我们首先介绍了std::thread类,学习了如何创建线程并指定其要执行的函数。我们强调了连接线程(.join())的重要性,以避免主线程提前结束导致的错误。

接着,我们探讨了C++20引入的std::jthread,它通过在析构时自动连接,简化了线程的生命周期管理,使代码更健壮。

最后,我们演示了如何创建和管理多个线程,并观察到并发执行时输出顺序的不确定性,这引出了线程间协调与同步的需求,这将是后续课程的主题。

通过掌握这些基础知识,你已经迈出了编写并行C++程序的第一步。在接下来的课程中,我们将学习如何使用互斥锁、条件变量等工具来协调线程间的操作。

042:线程同步与std::mutex 🧵

在本节课中,我们将学习如何使用C++标准库中的std::mutex(互斥锁)来实现线程同步。我们将解决多个线程同时访问共享资源(如std::cout)时可能出现的输出混乱问题,并介绍更安全的资源管理方式。


问题背景:并发访问的混乱

在上一节中,我们介绍了使用std::threadstd::jthread创建与连接线程的基础知识。在那个例子中,我们遇到了一个典型问题:多个线程并发访问某个共享资源。

在我们的案例中,共享资源是std::cout流对象。多个线程试图同时使用std::cout打印信息,导致输出结果中不同字符串交织在同一行,显得混乱不堪。

我们当时就在寻找一种方法,能够同步这些不同的线程,防止这种并发访问。而std::mutex正是我们需要的工具。

根据C++参考文档,std::mutex类是一个同步原语,可用于保护共享数据,防止被多个线程同时访问。这听起来完全符合我们的需求。


基础示例:未使用互斥锁的问题

让我们先看一个没有使用互斥锁的示例程序,以重现问题。

#include <iostream>
#include <vector>
#include <thread>

void print_thread_id(int id) {
    std::cout << "Printing from thread " << id << '\n';
}

int main() {
    std::vector<std::jthread> my_threads;
    for (int i = 0; i < 3; ++i) {
        my_threads.emplace_back(print_thread_id, i);
    }
    return 0;
}

编译并运行此程序,可能会得到如下混乱的输出:

Printing from thread Printing from thread 0
1
Printing from thread 2

可以看到,线程0和线程1的输出部分内容被“挤”在了同一行。这是因为多个线程在几乎同一时刻访问了std::cout,导致其内部状态被打乱。


引入 std::mutex

为了解决这个问题,我们需要在访问std::cout之前“锁定”一个资源,确保同一时间只有一个线程能执行打印操作。std::mutex提供了lock()unlock()两个核心方法来实现这一点。

以下是使用std::mutex的基本步骤:

  1. 在访问共享资源前调用mutex.lock()。如果锁已被其他线程持有,当前线程将等待(阻塞)。
  2. 执行对共享资源的操作(例如打印)。
  3. 操作完成后调用mutex.unlock(),释放锁,允许其他线程获取。

让我们修改之前的代码,使用std::mutex来保护std::cout

#include <iostream>
#include <vector>
#include <thread>
#include <mutex> // 引入mutex头文件

int main() {
    std::mutex m; // 创建一个互斥锁
    std::vector<std::jthread> my_threads;

    auto print_task = [&m](int id) {
        m.lock(); // 进入临界区前加锁
        std::cout << "Printing from thread " << id << '\n';
        m.unlock(); // 离开临界区后解锁
    };

    for (int i = 0; i < 3; ++i) {
        my_threads.emplace_back(print_task, i);
    }
    return 0;
}

现在,无论运行程序多少次,输出都不会再出现行间交织的情况。虽然线程的执行顺序可能每次不同(例如 0, 1, 22, 0, 1),但每个线程的完整输出都会独占一行。std::mutex确保了互斥访问,即同一时刻只有一个线程能通过锁的保护区域(临界区)。


潜在风险:死锁

直接使用lock()unlock()管理互斥锁,类似于用newdelete管理内存,存在忘记释放资源的风险。如果我们忘记调用unlock(),就会导致死锁

考虑以下错误代码:

auto faulty_task = [&m](int id) {
    m.lock(); // 加锁
    std::cout << "Printing from thread " << id << '\n';
    // 忘记调用 m.unlock()!
};

第一个线程加锁后,如果没有解锁就结束,那么后续所有线程在调用m.lock()时都会永远等待,因为锁永远不会被释放。程序会卡住,无法正常结束。


更安全的方案:std::lock_guard 🛡️

为了避免手动管理锁带来的遗忘风险,C++提供了std::lock_guard。它是一个基于RAII(资源获取即初始化)机制的互斥锁包装器。

RAII的核心思想是:在对象的构造函数中获取资源(例如加锁),在析构函数中自动释放资源(例如解锁)。这样,只要lock_guard对象离开其作用域,锁就会被自动释放,无需手动调用unlock

以下是使用std::lock_guard的代码:

#include <iostream>
#include <vector>
#include <thread>
#include <mutex>

int main() {
    std::mutex m;
    std::vector<std::thread> my_threads;

    auto safe_task = [&m](int id) {
        std::lock_guard<std::mutex> lg(m); // 构造时自动锁定m
        std::cout << "Printing from thread " << id << '\n';
        // lg析构时自动解锁m
    };

    for (int i = 0; i < 3; ++i) {
        my_threads.emplace_back(safe_task, i);
    }

    for (auto& t : my_threads) {
        t.join();
    }
    return 0;
}

在这段代码中,std::lock_guard<std::mutex> lg(m);这一行在创建lg对象时,会自动调用m.lock()。当lg在函数末尾离开作用域被销毁时,其析构函数会自动调用m.unlock()。这种方式更安全、更简洁,彻底避免了因忘记解锁而导致的死锁问题。


总结

本节课中我们一起学习了线程同步的基础知识:

  1. 问题:多个线程并发访问共享资源(如std::cout)会导致数据竞争和输出混乱。
  2. 工具:引入std::mutex(互斥锁)作为同步原语,通过对临界区加锁来实现互斥访问。
  3. 基础用法:使用lock()unlock()方法手动控制锁的获取与释放。
  4. 风险:手动管理锁可能因忘记解锁而导致程序死锁。
  5. 最佳实践:使用std::lock_guard,利用RAII机制自动管理锁的生命周期,这是更安全、更推荐的写法。

通过使用std::mutexstd::lock_guard,我们可以有效地协调多个线程,确保它们有序地访问共享资源,从而编写出正确、健壮的多线程程序。

043:std::atomic基础

概述

在本节课中,我们将要学习C++标准库中的std::atomic以及原子操作的基础知识。我们将从一个多线程程序中的常见问题——数据竞争(Data Race)入手,理解其产生的原因,并学习如何使用std::atomic来确保操作的原子性,从而避免数据竞争,保证程序行为的正确性。


多线程共享资源的问题

上一节我们介绍了多线程编程的基本概念。本节中我们来看看当多个线程同时访问和修改同一个共享资源时可能遇到的问题。

一个典型的问题是多个线程同时尝试使用std::cout进行打印,这可能导致输出内容混乱交错。解决此类共享资源竞争问题的一种方法是使用std::mutex(互斥锁)。然而,C++工具箱中还有另一种强大的工具——std::atomic

一个简单的示例:计数器递增

让我们通过一个具体的例子来理解数据竞争。我们将创建两个线程,每个线程都会对一个共享的整数计数器进行多次递增操作。

以下是示例代码的基本结构:

#include <iostream>
#include <thread>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/c4acf4acd3d46c2be2e9f941331822f2_3.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/c4acf4acd3d46c2be2e9f941331822f2_5.png)

int main() {
    int counter = 0; // 共享计数器

    // 定义一个lambda表达式作为线程的工作函数
    auto work = [&counter]() {
        for (int i = 0; i < 100; ++i) {
            counter += 1; // 递增计数器
        }
    };

    // 创建并启动两个线程
    std::thread t1(work);
    std::thread t2(work);

    // 等待线程结束
    t1.join();
    t2.join();

    // 打印最终结果
    std::cout << counter << std::endl;

    return 0;
}

当每个线程只循环100次时,程序很可能输出预期的结果200。这是因为工作量小,一个线程可能在另一个线程开始前就完成了所有工作。

数据竞争的出现

现在,让我们将循环次数增加到10000次,看看会发生什么。

修改循环部分:

for (int i = 0; i < 10000; ++i) {
    counter += 1;
}

重新编译并多次运行程序,你会发现输出结果不再是稳定的20000,而是在1000020000之间的一个随机值,例如187001960010200

为什么会出现这种情况?这源于数据竞争

理解数据竞争

根据C++内存模型的定义,当对一个内存位置的写操作与另一个线程对同一内存位置的读或写操作同时发生时,就产生了冲突。包含两个冲突操作的程序即存在数据竞争

一旦发生数据竞争,程序的行为是未定义的。这意味着任何输出结果都是“可接受”的,程序不再具有确定性。

以下是避免数据竞争的几种主要方法:

  • 单线程执行:所有操作都在同一个线程中完成。
  • 使用原子操作:确保特定操作(如读-改-写)作为一个不可分割的单元执行。这正是std::atomic提供的功能。
  • 使用同步原语:如std::mutex,通过建立“发生在前”(happens-before)的关系来强制顺序执行。

本节课我们将重点探讨第二种方法:原子操作。

原子操作的核心概念

要理解为什么counter += 1会导致数据竞争,我们需要剖析这个操作在底层做了什么。

counter += 1并非一个单一操作,它实际上包含三个步骤:

  1. :从内存中读取counter的当前值。
  2. :将读取到的值加1。
  3. :将新的值写回counter所在的内存位置。

当两个线程同时执行这个“读-改-写”序列时,它们的步骤可能会交错执行。例如:

  1. 线程A和线程B都读取到counter的值为0
  2. 线程A和线程B都在本地将值改为1
  3. 线程A和线程B都将1写回内存。

最终,尽管两个线程都执行了递增操作,但counter的值只增加了1,而不是2。这就是数据竞争导致结果错误的根本原因。

原子操作的意义在于,它将“读-改-写”这样的复合操作打包成一个不可分割的单一操作。在执行原子操作期间,不会有其他线程能够介入并修改同一数据。这就从根本上防止了操作步骤的交错,从而消除了数据竞争。

使用 std::atomic 解决问题

现在,让我们使用std::atomic来修复我们的程序。

首先,需要包含原子操作的头文件:

#include <atomic>

然后,将普通的int类型计数器改为std::atomic<int>类型:

std::atomic<int> counter(0);

std::atomic<int>类型重载了+=++等运算符。修改后,counter += 1就变成了一个原子性的读-改-写操作

以下是修改后的完整代码:

#include <iostream>
#include <thread>
#include <atomic> // 包含原子操作头文件

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/c4acf4acd3d46c2be2e9f941331822f2_13.png)

int main() {
    std::atomic<int> counter(0); // 声明为原子整数

    auto work = [&counter]() {
        for (int i = 0; i < 10000; ++i) {
            counter += 1; // 现在这是一个原子操作
        }
    };

    std::thread t1(work);
    std::thread t2(work);

    t1.join();
    t2.join();

    std::cout << counter << std::endl; // 现在总是输出 20000

    return 0;
}

重新编译并运行程序,你会发现无论运行多少次,输出结果总是正确的20000。我们成功消除了数据竞争,程序行为变得确定且正确。

总结

本节课中我们一起学习了std::atomic的基础知识:

  1. 数据竞争:当多个线程无同步地访问同一内存位置,且至少有一个是写操作时,就会发生数据竞争,导致未定义行为
  2. 问题根源:像counter += 1这样的操作不是原子的,它由读、改、写多个步骤组成,在多线程环境下这些步骤可能交错,导致结果错误。
  3. 原子操作:原子操作是不可分割的最小操作单元。std::atomic将特定类型(如int)的操作(如递增)转换为原子操作,确保其执行过程不会被其他线程打断。
  4. 使用方法:通过包含<atomic>头文件,并将变量声明为std::atomic<T>类型(如std::atomic<int>),即可使用其提供的原子运算符和成员函数。

std::atomic是编写高效、正确并发程序的重要工具之一,它通常比使用互斥锁(mutex)的性能开销更小,适用于保护简单的数据操作。对于更复杂的临界区操作,std::mutex仍然是必要的选择。

044:命名空间 🧭

在本节课中,我们将要学习C++中的命名空间。命名空间是解决大型项目中命名冲突问题的重要工具。通过将代码(如函数、类、变量)封装在不同的命名空间中,我们可以避免因名称相同而导致的编译错误。

命名冲突问题

上一节我们介绍了函数的基本概念,本节中我们来看看当多个函数拥有相同名称时会发生什么。在大型项目中,确保所有类、结构体、函数都有唯一的名字或函数签名可能很困难。

以下是一个简单的例子,展示了命名冲突:

#include <iostream>

void print() {
    std::cout << "Printing from function 1\n";
}

void print() {
    std::cout << "Printing from function 2\n";
}

int main() {
    print();
    return 0;
}

在这个例子中,我们定义了两个同名的 print 函数。尽管它们的函数体不同,但它们的函数签名完全相同。编译器无法区分这两个函数,因此会报“重定义”错误。

使用命名空间解决问题

为了解决上述问题,我们可以使用命名空间。命名空间为代码元素提供了一个有作用域的容器,从而将它们与其他作用域中同名的符号区分开来。

以下是使用命名空间修改后的代码:

#include <iostream>

namespace A {
    void print() {
        std::cout << "Printing from function 1\n";
    }
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/06da2b0b87f76a6b1e667cd7e50aeca2_5.png)

void print() {
    std::cout << "Printing from function 2\n";
}

int main() {
    // 调用全局命名空间中的 print 函数
    print();
    return 0;
}

现在,第一个 print 函数被封装在名为 A 的命名空间中。当我们在 main 函数中调用 print() 时,编译器会查找全局命名空间中的 print 函数,因此会执行第二个函数。

如何访问命名空间中的元素

要访问特定命名空间中的函数或类,我们需要使用作用域解析运算符 ::。这与我们使用 std::cout 的方式类似,cout 就是定义在 std 命名空间中的。

以下是访问命名空间 Aprint 函数的方法:

int main() {
    // 调用命名空间 A 中的 print 函数
    A::print();
    return 0;
}

通过使用 A::print(),我们明确告诉编译器要使用定义在命名空间 A 中的 print 函数,而不是全局命名空间中的那个。

命名空间的其他用途

命名空间不仅可以用于函数,还可以用于封装类、结构体和变量。这为组织代码和避免命名冲突提供了极大的灵活性。

以下是使用命名空间的一些关键点:

  • 命名空间通过 namespace 名称 { ... } 语法定义。
  • 使用 命名空间名称::元素名称 来访问其中的元素。
  • 它可以有效隔离不同模块或库中的代码。

总结

本节课中我们一起学习了C++中的命名空间。我们首先了解了在大型项目中可能出现的命名冲突问题。接着,我们学习了如何使用命名空间将代码封装起来以避免这些冲突。最后,我们掌握了通过作用域解析运算符 :: 来访问特定命名空间中元素的方法。命名空间是C++中管理代码作用域和避免名称污染的核心机制之一。

045:伪随机数生成

在本节课中,我们将学习如何在C++程序中生成随机数。随机数在模拟随机过程或测试函数时非常有用。C++标准库的 <random> 头文件提供了多种生成随机数的方法。我们将从基础开始,学习如何生成随机整数,并了解不同的随机数引擎和分布。

生成非确定性随机数

上一节我们介绍了课程概述,本节中我们来看看生成随机数最基本的方法:使用 std::random_device。这是一个生成非确定性随机数的均匀分布整数随机数生成器。

以下是使用 std::random_device 生成10个随机数的步骤:

  1. 包含必要的头文件 <iostream><random>
  2. main 函数中创建一个 std::random_device 对象。
  3. 使用循环调用该对象的函数调用运算符 () 来生成随机数。

#include <iostream>
#include <random>

int main() {
    // 创建随机设备
    std::random_device rd;
    
    // 生成并打印10个随机数
    for (int i = 0; i < 10; ++i) {
        std::cout << rd() << " ";
    }
    std::cout << "\n";
    
    return 0;
}

每次运行此程序,你都会得到一组不同的无符号整数。

使用伪随机数引擎

上一节我们使用了非确定性随机源,本节中我们来看看伪随机数引擎。C++提供了多种具有不同特性的引擎,例如 std::mt19937(梅森旋转算法引擎)。这些引擎在随机数质量、内存占用和性能之间有不同的权衡。

以下是使用 std::mt19937 引擎的示例:

#include <iostream>
#include <random>

int main() {
    // 创建梅森旋转引擎
    std::mt19937 mt;
    
    // 生成并打印10个随机数
    for (int i = 0; i < 10; ++i) {
        std::cout << mt() << " ";
    }
    std::cout << "\n";
    
    return 0;
}

直接使用引擎时,每次程序运行可能会产生相同的随机数序列,因为它需要一个种子来初始化其内部状态。

控制随机性:种子

为了控制随机数的可重复性或确保其随机性,我们需要管理引擎的种子。给构造函数传递一个固定值(如 std::mt19937 mt(42);)会产生确定性的、可重复的序列。为了获得每次运行都不同的随机序列,一个常见的模式是使用 std::random_device 来生成一个随机种子。

以下是结合使用 std::random_devicestd::mt19937 的示例:

#include <iostream>
#include <random>

int main() {
    // 用随机设备生成种子
    std::random_device rd;
    // 用随机种子初始化梅森旋转引擎
    std::mt19937 mt(rd());
    
    // 生成并打印10个随机数
    for (int i = 0; i < 10; ++i) {
        std::cout << mt() << " ";
    }
    std::cout << "\n";
    
    return 0;
}

这样,每次程序启动时,引擎都会获得一个不同的随机种子,从而产生不同的随机数序列。

应用随机数分布

上一节我们生成了原始随机数,本节中我们来看看如何让这些数字符合特定的统计分布。<random> 库提供了多种分布,如均匀分布、正态分布等。我们可以将随机数引擎与分布组合使用。

以下是使用 std::uniform_int_distribution 生成指定范围内均匀分布随机数的示例,模拟掷骰子(生成1到6之间的整数):

#include <iostream>
#include <random>

int main() {
    // 设置随机数生成器
    std::random_device rd;
    std::mt19937 mt(rd());
    
    // 定义均匀整数分布,范围[1, 6]
    std::uniform_int_distribution<int> dist(1, 6);
    
    // 模拟掷10次骰子
    for (int i = 0; i < 10; ++i) {
        std::cout << dist(mt) << " ";
    }
    std::cout << "\n";
    
    return 0;
}

在这个例子中,dist(mt) 会使用 mt 引擎生成一个在1到6之间均匀分布的随机整数。

本节课中我们一起学习了C++中生成随机数的基础知识。我们了解了如何使用 std::random_device 获取非确定性随机数,如何使用如 std::mt19937 这样的伪随机数引擎,以及如何通过设置种子来控制随机序列。最后,我们学习了如何将引擎与 std::uniform_int_distribution 这样的分布结合,以生成符合特定范围和统计规律的随机数。掌握这些工具,你就能在程序中有效地模拟随机性了。

046:文件输入输出 📁

在本节课中,我们将学习C++中文件输入输出(File I/O)的基础知识,特别是如何使用标准库中的 fstream 来读取和写入文件。我们将通过两个简单的例子来演示如何将数据写入文件,以及如何从文件中读取数据。

概述

在编程中,经常需要从外部源读取数据,或者将程序生成的数据(如日志)写入文件。fstream 是C++标准输入输出库的一部分,它提供了进行文件操作的高级接口。本节将介绍其基本用法。

写入文件:使用 ofstream

首先,我们来看如何将数据写入文件。我们将创建一个程序,计算0到9的平方数,并将结果保存到一个文本文件中。

以下是实现此功能的基本步骤:

  1. 包含必要的头文件 fstream
  2. 创建一个 ofstream(输出文件流)对象,并指定要写入的文件名。
  3. 像使用 cout 一样,使用 << 操作符将数据写入文件流。
  4. 完成写入后,程序会自动关闭文件。

以下是具体的代码示例:

#include <fstream>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/4344606b15a7a548bf075bf746b0594f_5.png)

int main() {
    // 创建一个输出文件流对象,并打开名为 "data.txt" 的文件
    std::ofstream output("data.txt");

    // 循环写入0到9的平方数
    for (int i = 0; i < 10; i++) {
        output << i * i << " "; // 将平方数和一个空格写入文件
    }
    output << std::endl; // 在文件末尾写入一个换行符

    return 0;
}

运行此程序后,会在当前目录生成一个名为 data.txt 的文件,其内容为 0 1 4 9 16 25 36 49 64 81

读取文件:使用 ifstream

上一节我们介绍了如何将数据写入文件,本节中我们来看看如何从文件中读取数据。我们将使用 ifstream(输入文件流)来读取刚才创建的 data.txt 文件。

以下是实现此功能的基本步骤:

  1. 包含头文件 fstreamiostream(用于在控制台打印)。
  2. 创建一个 ifstream 对象,并指定要读取的文件路径。
  3. 使用 >> 操作符从文件流中读取数据到变量中。
  4. 在一个循环中持续读取,直到文件末尾。

以下是具体的代码示例:

#include <fstream>
#include <iostream>

int main() {
    // 创建一个输入文件流对象,并打开名为 "data.txt" 的文件
    std::ifstream input("data.txt");

    int data; // 用于存储从文件中读取的整数

    // 当还能从文件流中成功读取一个整数到 data 变量时,循环继续
    while (input >> data) {
        std::cout << data << " "; // 将读取的数据打印到控制台
    }
    std::cout << std::endl; // 打印一个换行符

    return 0;
}

运行此程序,控制台将输出 0 1 4 9 16 25 36 49 64 81,这与我们写入文件的内容完全一致。ifstream 会自动处理文件中的空格和换行符,使得读取格式化的数据(如整数)变得非常简单。

总结

本节课中我们一起学习了C++文件I/O的基础知识。我们掌握了两个核心类:

  • std::ofstream:用于向文件写入数据,使用方式类似于 std::cout
  • std::ifstream:用于从文件读取数据,使用方式类似于 std::cin

通过 << 操作符进行写入,通过 >> 操作符进行读取,是操作文件流的基本方法。这只是文件操作的起点,fstream 还支持更多高级功能,如以追加模式打开文件、在文件中定位等,但掌握这些基础是进一步学习的关键。

047:Constexpr

概述

在本节课中,我们将要学习C++中的编译时编程,特别是如何使用constexpr说明符。我们将探讨如何将计算从运行时移动到编译时,以提升程序的运行效率。

编译时编程与Constexpr简介

我们通常关心程序的运行时性能。提升性能的工具之一,是将运行时计算转移到编译时完成。其核心思想是:程序通常编译一次,但会运行多次。因此,我们宁愿在编译时支付一次计算成本,而不是在每次运行程序时都重复支付。

在C++中,实现这一目标的方法之一就是使用constexpr说明符。根据CPP参考页面的定义,constexpr说明符声明可以在编译时求值函数或变量的值。这正是在进行一种权衡:将开销从运行时转移到编译时。

一个简单的Constexpr示例

上一节我们介绍了constexpr的基本概念,本节中我们来看看一个具体的例子。

我们将创建一个计算整数n的阶乘的函数。阶乘是所有从n1的正整数的乘积。例如,5的阶乘是 5 * 4 * 3 * 2 * 1 = 120

以下是实现递归阶乘函数的代码:

#include <iostream>

int factorial(int n) {
    if (n <= 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/5313160f6226255c68315776206b90c1_5.png)

int main() {
    int result = factorial(5);
    std::cout << result << std::endl;
    return 0;
}

编译并运行此代码,会得到正确的结果120。这是标准的C++代码,使用了函数、变量和std::cout进行打印。

查看运行时行为

由于我们的目标是将计算从运行时转移到编译时,这种变化在高层次上不易察觉,但可以通过测量时间或查看底层汇编代码来观察。我们真正改变的是运行时执行的操作。

我们可以使用objdump工具来查看程序生成的汇编代码,了解程序在底层做了什么。在汇编代码的main函数中,我们可以看到对factorial函数的调用。这意味着每次运行程序,都会在运行时调用这个函数。

应用Constexpr

现在,我们希望将这个计算从运行时转移到编译时。我们不希望在运行时看到对factorial的调用,而只希望得到结果。

以下是修改后的代码,将函数和变量标记为constexpr

#include <iostream>

constexpr int factorial(int n) {
    if (n <= 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/5313160f6226255c68315776206b90c1_9.png)

int main() {
    constexpr int result = factorial(5);
    std::cout << result << std::endl;
    return 0;
}

constexpr标记告诉编译器,可以在编译时求值这个函数或变量的值。我们允许编译器在编译时计算出result的值,而不是等到运行时再调用factorial函数。

重新编译并运行代码,我们仍然得到相同的结果120。然而,查看新的汇编代码会发现显著不同:main函数中不再有对factorial的调用。相反,我们看到代码中直接使用了值0x78(即十进制的120)。这意味着编译器在编译时已经计算出了正确答案,并将其硬编码到最终的可执行文件中。

例如,如果将factorial(5)改为factorial(3),重新编译后,汇编代码中会直接出现值6。这证明了原本在运行时进行的计算(调用阶乘函数)现在转移到了编译时。

Constexpr的灵活性

如前所述,constexpr声明的是可能在编译时求值,但并非强制。对于constexpr函数,我们仍然可以在运行时使用它。因为我们不可能总是预先知道所有传递给函数的参数值,所以仍然需要能够处理运行时数据。

以下是一个同时使用编译时和运行时计算的例子:

#include <iostream>
#include <random>

constexpr int factorial(int n) {
    if (n <= 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/5313160f6226255c68315776206b90c1_17.png)

int main() {
    // 编译时计算
    constexpr int compile_time_result = factorial(5);
    std::cout << "Compile-time result: " << compile_time_result << std::endl;

    // 运行时计算
    std::random_device rd;
    int random_number = rd() % 6; // 生成一个0到5的随机数
    int runtime_result = factorial(random_number);
    std::cout << "Runtime result for " << random_number << "!: " << runtime_result << std::endl;

    return 0;
}

在这个例子中,compile_time_result是编译时常量。而runtime_result则依赖于运行时生成的随机数,因此它本身不能是constexpr变量,但factorial函数仍然可以被调用。constexpr函数是通用的,既可用于编译时上下文,也可用于运行时上下文。

总结

本节课中我们一起学习了C++中的编译时编程,重点掌握了constexpr说明符的用法。

  • 核心概念constexpr用于声明可以在编译时求值的函数或变量,旨在将计算开销从运行时转移到编译时,从而提升程序运行效率。
  • 关键特性
    • constexpr函数和变量为编译器提供了在编译期进行求值的可能性。
    • constexpr函数具有灵活性,既可以在编译时使用(当参数是常量表达式时),也可以在运行时使用(当参数是运行时值)。
    • 通过查看汇编代码,可以直观验证计算是否被移到了编译时。
  • 应用价值:对于一次编译、多次运行的程序,使用constexpr进行编译时计算是优化性能的有效工具之一。

048:概念(Concepts)🚀

在本节课中,我们将要学习C++20中引入的“概念(Concepts)”功能。概念是一种强大的工具,用于在编译时对模板参数进行约束和验证,确保模板只被用于设计者预期的类型,从而避免误用并产生更清晰的错误信息。

概述

上一节我们介绍了模板的基础知识。本节中我们来看看如何为模板添加约束。当我们定义一个函数模板时,通常并不希望它能用于所有可能的数据类型。例如,我们可能只设计一个模板来处理各种整数类型,而不希望它被用于浮点数或自定义结构体。概念提供了一种表达这种约束的方式。

一个未受约束的模板示例

首先,让我们创建一个简单的函数模板,它本意是用于打印整数值。

#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/86b51393ad5a540c7368cbb278edfebd_3.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/coffeebeforearch-cpp-intro/img/86b51393ad5a540c7368cbb278edfebd_5.png)

// 一个简单的函数模板,用于打印值
void print(auto value) {
    std::cout << "打印整数值: " << value << '\n';
}

int main() {
    print(42); // 正确使用:整数
    print(10.352); // 意外使用:双精度浮点数
    return 0;
}

编译并运行上述代码(使用 g++ -std=c++20 concepts.cpp -o concepts),你会发现即使我们只打算处理整数,模板也能为 double 类型实例化并工作。这可能导致运行时出现意外行为,或者产生难以理解的编译错误。

使用概念约束模板

为了避免上述问题,我们可以使用C++标准库中预定义的概念来约束模板参数。以下是使用 std::integral 概念来约束 print 函数的方法。

#include <iostream>
#include <concepts> // 引入概念库

// 使用概念约束模板参数:value必须是整数类型
void print(std::integral auto value) {
    std::cout << "打印整数值: " << value << '\n';
}

int main() {
    print(42); // 正确:42是整数类型
    print(10u); // 正确:10u是无符号整数类型
    // print(10.352); // 错误:double类型不满足std::integral概念
    return 0;
}

当我们尝试用 double 类型调用 print 函数时,编译器会给出明确的错误信息,指出 double 类型不满足 std::integral 概念的要求,从而阻止了模板的误用。

核心概念库简介

C++标准库的 <concepts> 头文件定义了许多核心语言概念,用于常见的类型约束。以下是一些常用的概念:

  • std::integral:要求类型是整数类型(如 int, char, unsigned long)。
  • std::floating_point:要求类型是浮点类型(如 float, double)。
  • std::same_as:要求类型与指定类型完全相同。
  • std::derived_from:要求类型派生自指定基类。
  • std::convertible_to:要求类型可以转换为指定类型。

总结

本节课中我们一起学习了C++20的概念(Concepts)。我们了解到,概念是一种为模板参数添加编译时约束的机制,它能确保模板只被用于设计者预期的类型,从而提升代码的安全性和可读性,并产生更清晰的编译器错误信息。我们通过一个示例演示了如何使用 std::integral 概念来约束一个函数模板,使其仅接受整数类型参数。


附注与资源

  • 本系列教程的所有代码示例可在 GitHub 仓库 coffeebeforearch/cpp-from-scratch 中找到。
  • 关于概念的更多详细信息,请参阅 C++参考 - 概念库
  • 这是本入门系列的最后一课。后续将开启关于更高级的C++用法、调试、编译器、构建系统以及并行编程的新系列教程。
posted @ 2026-03-29 09:12  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报