UIUC-CS196-从-Bash-到-Rust-笔记-全-
UIUC CS196 从 Bash 到 Rust 笔记(全)
001:Bash 第一部分 🐚
在本节课中,我们将要学习命令行界面(CLI)的基础知识,特别是Bash shell。我们将了解什么是Bash、为什么它很重要,并学习一系列用于导航文件系统、操作文件和目录的基本命令。
什么是Bash?🤔
Bash是一个纯文本界面,用于控制类Unix操作系统。这意味着,你不再使用图形用户界面(GUI)来点击、打开文件和文件夹,而是通过一个称为命令行界面(CLI)的窗口,完全使用文本来完成所有操作。
Bash是“Bourne-Again Shell”的缩写,这是对Stephen Bourne(一个早期shell sh 的作者)名字的双关语。它被用于几乎所有的Linux机器,并且在Catalina系统版本之前的所有Mac系统上也被使用。之后,Mac系统切换到了Zsh(Z Shell),它与Bash非常相似,对于本课程的目的,两者可以互换使用。
为什么学习Bash?🎯
既然点击操作更简单,为什么还要学习这些复杂的东西呢?原因在于,Bash是操作系统的标准化操作方式。正因为标准化,它使得自动化流程和操作变得非常容易。例如,如果你想重复打开某个文件夹和文件,使用Bash,你只需使用文本指令就能控制一切,而无需训练计算机视觉去点击屏幕。这对于服务器端任务或虚拟机操作尤其重要,因为这些环境通常没有图形界面。
环境设置 💻
- Linux用户:你的设置非常简单,可能已经在使用Bash了。
- Mac用户(Catalina之前):你已经在使用Bash,无需额外设置。
- Mac用户(Catalina及之后):你使用的是Zsh,它与Bash差异很小,本课程中可视为相同。
- Windows用户(Windows 10):可以按照教程设置一个名为Vagrant的虚拟机,并在其中运行Linux shell。
- 更早版本的Windows用户:请到答疑时间寻求帮助来设置Linux shell。
为了本次演示,我们将使用一个示例文件结构。从用户目录开始,到桌面,再到一个名为“intro to bash”的文件夹,里面包含其他目录和文件。
基础命令
上一节我们介绍了Bash的基本概念,本节中我们来看看一些最核心的命令。
列出文件:ls
ls 是“list”(列表)的缩写。这个命令会列出当前目录中的所有文件和文件夹。
用法:进入一个文件夹后,直接输入 ls。
ls
你会看到当前目录下的所有项目,目录通常会以不同颜色显示。
显示当前目录:pwd
pwd 代表“print working directory”(打印工作目录)。它会显示你当前所在目录的完整路径。
用法:在终端中输入 pwd。
pwd
输出结果是从根目录到你当前位置的完整路径。
切换目录:cd
cd 代表“change directory”(更改目录)。它允许你切换到另一个目录。
用法:cd 后面需要跟一个参数,即你想要前往的目录名。
cd directory1
这会让你从当前目录进入 directory1。
以下是关于 cd 命令的一些进阶用法:
- 返回上级目录:使用
..表示上一级目录。cd .. - 使用绝对路径:以
/开头的路径是绝对路径,它从根目录开始指定完整路径。cd /Users/YourName/Desktop/intro_to_bash - 处理带空格的目录名:如果目录名包含空格,需要在空格前加上反斜杠
\,或者用引号将整个目录名括起来。使用Tab键可以自动补全并添加必要的转义符。cd some\ folder # 或 cd "some folder"
查看文件内容:cat
cat 是“concatenate”(连接)的缩写,但常被用来显示文件的内容。
用法:cat 后面跟上文件名。
cat file1.txt
终端会打印出该文件的所有内容。
创建文件:touch
touch 命令用于在当前目录创建一个新的空文件。文件的类型由扩展名决定。
用法:touch 后面跟上你想要创建的文件名。
touch newfile.txt
这将在当前目录创建一个名为 newfile.txt 的空文件。
删除文件:rm
rm 代表“remove”(移除)。这个命令会永久删除文件,不会将其移到回收站。
用法:rm 后面跟上要删除的文件名。
rm newfile.txt
警告:rm 默认不能删除非空的目录。如果需要删除目录及其所有内容,需要使用带选项的 rm 命令,我们稍后会讲到。
创建目录:mkdir
mkdir 是“make directory”(创建目录)的缩写。
用法:mkdir 后面跟上新目录的名称。
mkdir new_directory
这会在当前目录下创建一个名为 new_directory 的新文件夹。
删除空目录:rmdir
rmdir 是“remove directory”(删除目录)的缩写。它只能删除空的目录。
用法:rmdir 后面跟上要删除的空目录名。
rmdir new_directory
如果目录不为空,命令会失败并显示错误信息。
文件操作命令
掌握了创建和删除后,我们来看看如何移动和复制文件。
移动/重命名文件:mv
mv 代表“move”(移动)。它有两个主要用途:重命名文件,或将文件移动到新位置。
用法:
- 重命名:
mv 旧文件名 新文件名mv oldname.txt newname.txt - 移动文件:
mv 文件名 目标目录路径mv file1.txt directory2/
注意:如果目标位置已存在同名文件,其内容将被覆盖。
复制文件:cp
cp 代表“copy”(复制)。它的用法与 mv 类似,但原始文件会被保留。
用法:
- 复制到新位置:
cp 源文件 目标路径cp file1.txt directory1/copy_of_file1.txt - 在当前目录复制:
cp 源文件 新文件名cp original.txt duplicate.txt
执行后,你会得到两个内容相同的文件。
通配符 *
*(星号)是一个通配符,可以匹配任意数量的字符。这在操作多个文件时非常有用。
例如,要将所有 .txt 文件移动到另一个目录:
mv *.txt target_directory/
实用技巧与高级主题
为了更高效地使用Bash,这里有一些实用的技巧和命令。
终端使用技巧
以下是一些能极大提升命令行效率的技巧:
- Tab键自动补全:输入命令或文件名的一部分,然后按Tab键,Bash会尝试自动补全。如果有多项匹配,按两次Tab会显示所有选项。
- 上下箭头翻阅历史:使用上/下箭头键可以快速找到之前输入过的命令,无需重新键入。
Ctrl + C终止进程:如果某个命令运行时间过长或出现意外,按Ctrl + C可以强制终止当前正在运行的程序。!!重复上一条命令:输入!!并按回车,可以快速重新执行上一条命令。clear清屏:输入clear可以清理当前终端屏幕,让你有一个干净的界面。
查看命令手册:man
man 是“manual”(手册)的缩写。它是一个极其有用的命令,可以查看任何其他命令的详细说明、选项和用法。
用法:man 后面跟上你想了解的命令。
man ls
这会显示 ls 命令的完整手册。按 q 键可以退出手册页面。
使用命令标志(Flags)
许多命令可以通过添加“标志”(通常以 - 或 -- 开头)来改变其行为。标志的具体信息可以在 man 手册中查到。
一个常用的例子是 ls -a:
ls:列出普通文件和目录。ls -a:列出所有文件,包括以点.开头的隐藏文件。ls -a
在Unix系统中,以 . 开头的文件或目录默认是隐藏的。. 代表当前目录,.. 代表上级目录。
强制删除:rm -rf
这是一个需要谨慎使用的强大命令。
-r或-R:代表“递归地”(recursive),用于删除目录及其内部所有内容。-f:代表“强制地”(force),忽略不存在的文件或参数,不提示确认。
用法:删除一个非空目录及其所有子内容。
rm -rf directory_name
警告:此命令会永久删除指定目录下的一切,且无法恢复。请确保你确切知道要删除的是什么。
课程总结与安排 📅
本节课中我们一起学习了Bash shell的基础知识。我们了解了什么是命令行界面,为什么它在编程和系统管理中如此重要,并实践了一系列核心命令,包括:
- 导航文件系统(
pwd,cd,ls) - 操作文件和目录(
touch,mkdir,rm,rmdir,mv,cp) - 查看文件内容(
cat) - 使用实用技巧(Tab补全、历史命令、
Ctrl+C)和高级功能(man, 命令标志,以及慎用的rm -rf)
后续安排:
- 本周四的课程将继续深入讲解Bash。
- 本周五将发布关于Bash的作业,帮助大家巩固练习。
- 下周我们将开始学习版本控制系统Git。
- 之后,我们将进入Rust编程语言的学习。
请确保熟悉今天所讲的内容,并尝试在日常学习和工作中多使用终端,因为命令行工具将在你整个编程生涯中持续发挥作用。课程中使用的演示文件将会上传到课程网站,供大家下载练习。如果有任何问题,请通过Piazza(课程问答平台)提问,或留意即将公布的答疑时间。
002:Bash 第二部分 🐚



在本节课中,我们将继续学习Bash,构建于上一讲的知识之上。我们将介绍几个新的命令,然后超越单个命令,学习如何进行批处理脚本编写和在终端内编辑文本文件等更酷的操作。
概述 📋
本节课的目标是学习以下内容:
- 几个新的Bash命令:
sudo,find,grep,diff。 - 终端文本编辑器:
vim,emacs,nano。 - Bash脚本编程入门:变量、条件语句、循环和函数。
- 命令别名。

我们将逐一深入探讨这些主题。

命令学习



上一节我们学习了基本的文件操作命令,本节中我们来看看几个更强大的工具。

sudo 命令 🔑
sudo 代表“超级用户执行”。它为你提供计算机的根用户访问权限。根用户权限等同于管理员权限,你可以将根用户视为计算机的最高统治者,可以执行任何操作。

使用 sudo 需要谨慎,因为强大的能力也意味着重大的责任。你可以用它删除核心文件,因此使用时必须非常小心。

大多数日常操作(如列出目录文件)并不需要根权限,所以不必在每个命令前都使用 sudo。
以下是使用 sudo 的基本格式:
sudo [命令]
例如,要删除一个受保护的文件:
sudo rm protected_file.txt
输入命令后,系统会提示你输入密码。在终端输入密码时,屏幕上不会显示任何字符,这是为了保护你的密码安全。


find 命令 🔍


find 是一个用于定位文件的强大工具。找到文件后,你还可以对这些文件执行命令。


基本用法是输入 find,后跟一个路径。它会递归地列出该路径下所有目录中的文件。
find .
上述命令会列出当前目录(.)及其所有子目录中的文件,包括隐藏文件。

为了更精确地查找,我们可以使用一些标志来改变 find 的行为。
以下是 find 命令的一些常用标志:
-name: 按文件名查找。可以使用通配符*进行模式匹配。- 示例:
find . -name "*.txt"查找当前目录下所有.txt文件。
- 示例:
-iname: 不区分大小写地按文件名查找。- 示例:
find . -iname "README"会找到README、readme、ReadMe等文件。
- 示例:
-delete: 删除找到的文件。使用此标志需极其谨慎。- 示例:
find . -name "temp_*.log" -delete会删除所有匹配temp_*.log模式的文件。
- 示例:


重要提示:在终端中使用 rm 或 find -delete 删除的文件不会进入回收站,而是被永久删除,无法撤销。
grep 命令 📖


grep 代表“全局正则表达式打印”,用于在文件中搜索文本。
基本用法是输入 grep,后跟要搜索的术语和文件名。grep 会返回包含该术语的所有行。
grep "搜索词" 文件名.txt
例如,在一个包含多行文本的文件中搜索 “Rohan”,会输出所有包含 “Rohan” 的行,即使是作为长单词的一部分(如 “RohanAndSammy”)也会被匹配。


为了进行更精确的搜索,grep 提供了几个有用的标志。


以下是 grep 命令的一些常用标志:
-w: 仅匹配整个单词,而不是单词的一部分。-i: 使搜索不区分大小写。-n: 显示匹配行所在的行号。
这些标志可以组合使用。例如,grep -wi "rohan" file.txt 会进行不区分大小写的全词匹配搜索。

diff 命令 ⚖️
diff 命令用于比较两个文件,并逐行标识它们之间的差异。

用法是输入 diff,后跟两个文件名。
diff file1.txt file2.txt
如果文件完全相同,则没有输出。如果存在差异,diff 会输出哪些行不同。差异输出中,< 表示第一个文件的行,> 表示第二个文件的行。空行也被视为差异。
逻辑运算符 ⛓️

逻辑运算符允许你将多个 Bash 命令串联在一起执行。

使用 &&(逻辑与)可以连接命令。其含义是:只有前一个命令成功执行(返回退出状态码0),后一个命令才会执行。
命令1 && 命令2 && 命令3
例如:
mkdir new_dir && cd new_dir && touch some_file.txt && cd ..
这条命令会依次执行:创建目录、进入目录、创建文件、返回上级目录。这样做的好处是,你可以通过按上箭头键轻松地重复执行这一系列命令,而不必逐个输入。
ssh 命令 🌐


ssh(安全外壳协议)用于访问和控制远程计算机的终端。


这在连接虚拟机或服务器时非常有用。基本用法是:
ssh 用户名@主机地址
例如:ssh user@server.example.com。连接后,会提示输入密码,成功验证后,你的终端就连接到了远程机器,可以执行命令。要退出远程连接,输入 exit。
终端文本编辑器 ✏️
当你通过 ssh 连接到远程机器时,通常无法使用图形界面的文本编辑器。这时就需要在终端内直接编辑文件的工具。


vim 编辑器


vim 是一个功能强大但学习曲线较陡峭的终端编辑器。要打开文件,使用 vim 文件名。


vim 有多种模式,最常用的是:
- 命令模式:打开
vim时的默认模式,可以执行导航、删除、搜索等命令,但不能直接输入文本。 - 插入模式:可以像普通编辑器一样输入和编辑文本。


在命令模式下,可以使用以下按键导航(而非箭头键):
h(左),j(下),k(上),l(右)$跳到行尾,0跳到行首w向前移动一个单词,b向后移动一个单词/搜索词搜索文件内容


要从命令模式进入插入模式,按 i。要从插入模式返回命令模式,按 Esc。


要保存并退出 vim,在命令模式下输入:
:wq保存并退出:q!不保存强制退出
emacs 编辑器


emacs 是另一个流行的终端编辑器。与 vim 不同,它没有严格区分模式,主要通过组合键(前缀)来执行命令。

打开文件使用 emacs 文件名。打开后可以直接输入文本。



常用组合键包括:
Ctrl-a/Ctrl-e: 移动到行首/行尾Ctrl-v/Alt-v: 向下/向上翻页Alt-f/Alt-b: 向前/向后移动一个单词Ctrl-x Ctrl-s: 保存文件Ctrl-x Ctrl-c: 退出emacs





在 Mac 上,Alt 键通常对应 Option 键,但可能需要额外配置终端才能正常使用。


nano 编辑器

nano 是三者中最易上手的终端编辑器,学习曲线平缓。


打开文件使用 nano 文件名。编辑器底部会显示所有常用命令的提示(如 ^O 保存,^X 退出)。


在 nano 中,你可以直接输入文本,使用箭头键导航。要执行操作,使用 Ctrl 加字母键(如 Ctrl-O 保存,Ctrl-X 退出)。退出时如果有未保存的更改,会提示你是否保存。


对于只需要进行少量快速编辑的情况,nano 是一个极佳的选择。


Bash 脚本编程入门 📜

除了手动输入命令,你还可以编写脚本来自动化执行一系列命令。Bash 脚本通常以 .sh 扩展名结尾,使用 bash 脚本名.sh 来运行。
变量


在 Bash 中声明变量很简单,直接赋值即可,无需指定类型。注意,等号 = 两边不能有空格。
变量名=值
要使用变量的值,需要在变量名前加上美元符号 $。
my_var="Hello"
echo $my_var
echo "The value is $my_var"
如果要原样输出 $ 符号,可以使用单引号 ',因为单引号内的所有字符都会按字面意义解释。

条件语句

Bash 中的条件语句使用 if、elif、else 结构,以 fi 结束。
重要:条件表达式必须放在方括号 [ ] 内,并且括号与表达式之间必须有空格。
if [ 条件1 ]
then
# 执行语句
elif [ 条件2 ]
then
# 执行语句
else
# 执行语句
fi
比较数字时,使用 -eq(等于)、-ne(不等于)等操作符。
if [ $num -eq 10 ]
then
echo "数字是10"
fi

循环

for 循环的基本格式如下:
for 变量 in 序列
do
# 循环体
done
例如,打印数字1到5:
for i in 1 2 3 4 5
do
echo "循环次数: $i"
done
也可以使用 {1..5} 这种形式生成数字序列。

函数

定义函数时,使用函数名后跟括号 () 的形式,函数体放在 { } 中。
函数名() {
# 函数体
}
向函数传递参数时,在函数内部通过 $1、$2 等来引用第一个、第二个参数。
greet() {
echo "Hello, $1"
}
greet "Alice" # 输出:Hello, Alice

命令别名 🏷️
如果你经常需要输入一个很长的命令,可以为其创建一个简短的别名。

使用 alias 命令来创建别名。
alias 简称='长命令'
例如,将 python3 别名为 python:
alias python='python3'
创建后,每次输入 python,实际上执行的是 python3。请注意,这样设置的别名仅在当前终端会话中有效。要永久生效,需要将别名定义添加到你的 shell 配置文件(如 ~/.bashrc 或 ~/.zshrc)中。
总结 🎯


本节课我们一起学习了Bash的更多高级功能。
我们首先介绍了几个关键命令:使用 sudo 获取管理员权限,用 find 精准定位文件,用 grep 在文件中搜索文本,用 diff 比较文件差异,以及用 ssh 连接远程计算机。



接着,我们探索了在终端内编辑文件的三种主要工具:功能强大但稍复杂的 vim 和 emacs,以及简单易用的 nano。你可以根据需求选择最适合你的编辑器。


然后,我们入门了Bash脚本编程,学习了如何定义和使用变量、编写条件判断和循环语句,以及创建函数来封装可重用的代码块。
最后,我们了解了如何使用 alias 为常用命令创建快捷方式,提升工作效率。
今天涵盖的内容很多,节奏较快。不必担心无法立刻掌握所有细节。Bash和终端技能的提升很大程度上依赖于实践。当你完成作业或在日常中使用终端时,你会越来越熟悉这些工具和命令。请尝试不同的文本编辑器,找到你最顺手的那一个。

下一讲,我们将开始学习 git 版本控制系统。
003:Git与GitHub入门教程 🚀

在本节课中,我们将要学习版本控制系统Git以及代码托管平台GitHub的核心概念和基本工作流程。我们将从Git是什么以及为什么使用它开始,逐步深入到其核心概念、基本命令和一个完整的实战演示。
什么是Git?🤔
Git是一个分布式版本控制系统。版本控制系统允许你跟踪计算机文件中的更改。因此,你可以用它来恢复工作的先前版本。另一个重要用途是能够与团队中的其他开发者在同一项目上进行协作。
Git是目前最常用的版本控制系统。虽然今天仍在使用一些更旧的版本控制系统,但Git是最流行的。在你的职业生涯中,你会经常遇到它。因此,确保你透彻理解它极其重要。
关于Git,它可能看起来非常可怕。但重要的是要理解,只要对Git及其良好实践有充分的理解,你就能确保永远不会遇到这些问题。即使你最终犯了这些错误,它们也都是可以修复的。
Git核心概念与工作环境 🏗️
上一节我们介绍了Git的基本定义,本节中我们来看看Git的核心工作环境。
Git有四个主要的工作环境:工作目录、暂存区、本地仓库和远程仓库。



- 工作目录:这就是普通的文件夹,你的操作系统所认识的样子。它包含所有文件,无论它们是否被Git跟踪。
- 暂存区:这是一个中间区域。Git只能跟踪它知道的文件。你需要将文件或更改放入暂存区,这样Git才能知道如何处理它们,并在你提交、推送和拉取时跟踪它们。
- 本地仓库:这是位于你计算机上的仓库。它包含你最终想要同步到远程仓库的所有更改。
- 远程仓库:这是你本地仓库的镜像,通常托管在像GitHub这样的平台上。它包含你本地仓库中所有更改的历史记录,是团队协作的中心。



Git基本命令 📟
理解了Git的环境后,我们来看看在这些环境之间移动所需的基本命令。
以下是六个核心的Git命令:
git init:此命令在你的文件夹中初始化一个新的Git仓库,创建一个隐藏的.git文件夹。git clone <repository_url>:此命令从远程源(如GitHub)复制一个仓库到你的本地计算机。git add <file_name>或git add .:此命令将文件从工作目录添加到暂存区,让Git开始跟踪它们。git commit -m "Your message here":此命令将暂存区的更改保存到本地仓库,创建一个“快照”。提交信息应简洁且描述性强。git push:此命令将你本地仓库的提交传输到远程仓库。git pull:此命令从远程仓库拉取最新的更改到你的本地仓库。
基础工作流程演示 🎬



现在,让我们将理论付诸实践,通过一个简单的例子来演示一个基础的Git工作流程。

首先,我在GitHub上创建了一个名为196-lecture的新仓库。然后,我使用git clone命令将其复制到我的本地计算机。





git clone https://github.com/your-username/196-lecture.git
cd 196-lecture
接下来,我创建一个新文件demo.txt,并添加一些内容(一个购物清单)。
touch demo.txt
# 使用文本编辑器(如VS Code、nano)打开demo.txt并添加内容:
# shopping list
# watermelon
# onions
现在,我需要将这些更改提交到仓库。第一步是将文件添加到暂存区。


git add demo.txt
# 或者使用 git add . 添加所有更改

然后,提交这些更改到本地仓库,并附上描述性的信息。


git commit -m "Create a shopping list with two items"


最后,将本地提交推送到远程仓库(GitHub)。



git push

此时,如果你刷新GitHub页面,就会看到demo.txt文件及其内容。
现在,假设我的队友在GitHub上直接编辑了demo.txt,添加了“laptop”。为了获取这个远程更改,我需要在本地运行:

git pull




这个命令会将远程仓库的最新内容(包括队友添加的“laptop”)拉取到我的本地demo.txt文件中。
总结 📚

本节课中我们一起学习了Git和GitHub的基础知识。我们了解了Git作为一个分布式版本控制系统,如何通过跟踪文件更改来帮助管理项目历史和团队协作。我们明确了Git的四个核心环境:工作目录、暂存区、本地仓库和远程仓库,并学习了在这些环境间移动的基本命令:init、clone、add、commit、push和pull。最后,我们通过一个创建文件、添加、提交和推送的完整演示,实践了基础的Git工作流程。掌握这些是进行高效、安全软件开发的第一步。
004:Git进阶 - 合并冲突、分支与回退

在本节课中,我们将深入学习Git的进阶概念,包括如何处理合并冲突、如何使用分支进行并行开发,以及如何安全地回退到代码的早期版本。掌握这些技能对于团队协作和高效管理项目至关重要。
回顾:Git基础





上一节我们介绍了Git作为分布式版本控制系统的基本概念。我们了解到Git有四个主要环境:工作目录、暂存区、本地仓库和远程仓库。通过 git add、git commit 和 git push 命令,我们可以将更改从工作目录推送到远程仓库。而 git pull 命令则用于将远程仓库的更改拉取到本地。

合并冲突



当我们尝试使用 git pull 命令将远程更改合并到本地时,如果远程和本地修改了文件的同一行,Git将无法自动决定保留哪个版本。这种情况被称为合并冲突。
以下是解决合并冲突的步骤:
- Git会在冲突文件中插入特殊标记来标识冲突区域。
- 你需要手动编辑文件,选择保留哪个版本(或进行整合)。
- 删除Git添加的冲突标记(
<<<<<<<,=======,>>>>>>>)。 - 保存文件,然后使用
git add和git commit提交解决后的版本。
示例冲突标记:
<<<<<<< HEAD
这是本地的更改
=======
这是远程的更改
>>>>>>> branch-name
在这个例子中,<<<<<<< HEAD 和 ======= 之间是你的本地更改,======= 和 >>>>>>> branch-name 之间是远程的更改。你需要决定保留哪一部分,或者编写一个新的版本,然后删除所有标记行。
分支管理

分支允许你在不干扰主开发线(通常是 master 或 main 分支)的情况下开发新功能或修复错误。master 分支应始终保持稳定和可运行的状态。
以下是分支相关的核心命令:
git branch:列出所有本地分支。git checkout -b <branch-name>:创建并切换到一个新分支。git checkout <branch-name>:切换到指定分支。git merge <branch-name>:将指定分支合并到当前分支。git push --set-upstream origin <branch-name>:将本地分支推送到远程仓库并建立追踪关系。git fetch:从远程仓库获取所有分支和提交信息,但不会自动合并到当前分支。


标准工作流程:
- 从
master分支创建一个功能分支:git checkout -b feature/new-feature - 在新分支上进行开发并提交更改。
- 将分支推送到远程仓库:
git push -u origin feature/new-feature - 在代码托管平台(如GitHub)上创建拉取请求,请求将你的分支合并回
master。 - 经过代码审查后,完成合并。


其他实用命令

除了核心操作,以下命令在日常开发中也非常有用:


git status:查看工作目录和暂存区的状态。git log --oneline:以简洁的单行格式查看提交历史。git stash:将未提交的更改临时保存起来,以便清理工作目录。git stash apply:恢复最近一次储藏(stash)的更改。


回退操作



Git提供了两种主要方式来撤销更改:


1. Git Revert(安全回退)
git revert 通过创建一个新的提交来撤销指定提交的更改。它不会删除历史记录,是一种安全的回退方式。
git revert <commit-hash>
2. Git Reset(重置)
git reset 将当前分支的指针移动到指定的提交,可以选择如何处理之后的更改。
git reset <commit-hash>:移动指针,保留工作目录的更改(即更改变为未暂存状态)。git reset --hard <commit-hash>:移动指针,并丢弃所有之后的提交和工作目录的更改。此操作需谨慎使用。

总结

本节课我们一起学习了Git的进阶功能。我们了解了如何解决合并冲突,这是团队协作中不可避免的一部分。我们深入探讨了分支策略,这是实现并行开发和保持主分支稳定的关键。最后,我们学习了如何使用 git revert 和 git reset 命令来管理项目历史和安全地回退更改。结合上一节课的基础知识,你现在已经掌握了使用Git进行高效、协作式软件开发的核心工作流程。记住,熟练使用Git需要练习,遇到问题时,善用 git status、git log 和官方文档是很好的习惯。
005:Rust入门 - 变量、类型、函数与控制流 🦀


在本节课中,我们将学习Rust编程语言的基础知识。Rust是一门注重安全与性能的系统编程语言。我们将从如何编写第一个Rust程序开始,逐步了解变量、数据类型、函数以及控制流等核心概念。
Rust简介与安装 🛠️
Rust是一门多范式系统编程语言。这意味着它不局限于单一的编程风格,并且专注于构建高性能、安全的系统软件。与C++等语言相比,Rust在保持高性能的同时,通过编译器提供了更强的内存安全和类型安全保障。
要开始使用Rust,首先需要安装它。你可以通过访问Rust官方文档获取安装指南。安装完成后,你可以通过终端命令 rustc --version 来验证安装是否成功。
Rust源文件以 .rs 扩展名结尾。要编译一个Rust文件,可以使用 rustc 命令,例如 rustc main.rs。这将生成一个可执行文件。
第一个Rust程序:Hello World 🌍
所有Rust程序的执行都始于 main 函数。这是一个特殊的函数,编译器会首先运行它。
下面是一个简单的“Hello World”程序:
fn main() {
println!("Hello, world!");
}

在这段代码中:
fn关键字用于声明一个函数。main是函数名。println!是一个Rust宏(由!标识),用于向终端输出一行文本。- 每一行代码的结尾需要加上分号
;。



将上述代码保存为 hello.rs,然后在终端中运行 rustc hello.rs 进行编译,再运行生成的可执行文件 ./hello,你将在屏幕上看到“Hello, world!”。


使用Cargo管理项目 📦


对于更复杂的项目,推荐使用Cargo,它是Rust内置的构建系统和包管理器。Cargo可以自动处理代码构建、依赖下载和项目管理。


你可以使用 cargo --version 检查Cargo是否已安装。




要创建一个新的Cargo项目,请使用 cargo new project_name 命令。这会创建一个包含以下结构的目录:
src/目录:存放源代码,其中包含一个初始的main.rs文件。Cargo.toml文件:项目的配置文件,用于管理元信息和依赖。.git目录:初始化的Git仓库。



在项目目录中,你可以使用以下常用命令:
cargo build:编译项目,在target/debug/目录下生成可调试的可执行文件。cargo run:编译并立即运行项目。cargo check:快速检查代码是否能通过编译,而不生成可执行文件,速度更快。cargo build --release:以发布模式编译,在target/release/目录下生成经过高度优化的可执行文件,但编译时间更长。


变量与可变性 📊
在Rust中,变量默认是不可变的。这意味着一旦给变量赋值,就不能再更改它的值。这有助于提高代码的安全性和清晰度,尤其是在并发编程中。


声明变量使用 let 关键字:
let x = 5; // x是不可变的
尝试修改 x 的值会导致编译错误。


如果你需要一个可以改变的变量,需要使用 mut 关键字将其声明为可变的:
let mut y = 5;
y = 10; // 这是允许的
println!("y 的值是: {}", y);

Rust还支持变量遮蔽。这意味着你可以使用 let 关键字再次声明一个同名变量,新的变量会“遮蔽”旧的变量,并且可以拥有不同的类型:
let spaces = " ";
let spaces = spaces.len(); // 遮蔽:将字符串转换为它的长度(整数)
这与使用 mut 修改变量是不同的概念。



数据类型 🧮
Rust是静态类型语言,这意味着编译器必须在编译时知道所有变量的类型。编译器通常可以根据值推断出类型,但有时也需要显式标注。





Rust的数据类型主要分为两类:标量类型和复合类型。


标量类型
标量类型代表单个值。Rust有四种基本的标量类型:
- 整数:没有小数部分的数字。分为有符号(
i8,i16,i32,i64,isize)和无符号(u8,u16,u32,u64,usize)。i32和u32是默认类型。let a: i32 = -10; let b: u64 = 100;



- 浮点数:带有小数点的数字。有
f32和f64两种,f64是默认类型,精度更高。let c = 3.14; // f64 let d: f32 = 2.5; // f32

- 布尔值:只能是
true或false。let is_rust_cool: bool = true;

- 字符:使用单引号声明,代表一个Unicode标量值,可以存储字母、数字、汉字甚至表情符号。
let emoji = '😀'; let chinese_char = '中';


复合类型



复合类型可以将多个值组合成一个类型。Rust有两个原生的复合类型:元组和数组。

- 元组:可以将多个不同类型的值组合在一起。元组有固定的长度。
let tup: (i32, f64, char) = (500, 6.4, 'J'); // 通过解构访问 let (x, y, z) = tup; println!("y 的值是: {}", y); // 输出 6.4 // 通过索引访问(从0开始) println!("第一个元素是: {}", tup.0); // 输出 500




- 数组:每个元素必须是相同类型,并且有固定的长度。
访问数组元素时,如果索引超出范围,程序会在运行时崩溃(panic),这是Rust安全机制的一部分。let arr = [1, 2, 3, 4, 5]; let first = arr[0]; // 访问第一个元素 let second = arr[1]; // 访问第二个元素 // 声明一个包含5个相同元素的数组 let same_arr = [3; 5]; // 等同于 [3, 3, 3, 3, 3]



控制流 🔀



控制流允许你根据条件决定执行哪部分代码,或者重复执行某段代码。


条件分支:if 表达式

if 表达式允许你根据条件执行不同的代码分支。在Rust中,if 是一个表达式,意味着它可以返回一个值。
let number = 6;




if number % 2 == 0 {
println!("数字是偶数");
} else {
println!("数字是奇数");
}

// if 表达式返回值
let result = if number > 5 { "大" } else { "小" };
println!("数字是{}的", result);
注意,每个条件分支返回的值必须是相同类型。



循环

Rust提供了几种循环方式:loop、while 和 for。






loop:无限循环,直到遇到break关键字。loop也可以返回值。let mut counter = 0; let result = loop { counter += 1; if counter == 10 { break counter * 2; // 跳出循环并返回值 } }; println!("结果是: {}", result); // 输出 20


while:在条件为true时循环。let mut number = 3; while number != 0 { println!("{}!", number); number -= 1; } println!("发射!");




for:最常用、最安全的循环,用于遍历集合。let arr = [10, 20, 30, 40, 50]; for element in arr.iter() { println!("值是: {}", element); } // 使用范围 for number in 1..4 { // 不包含4 println!("{}!", number); }


注释 💬




注释用于解释代码,编译器会忽略它们。Rust使用 // 来开始一行注释。
// 这是一个单行注释
let lucky_number = 7; // 这也是一个注释


/*
这是一个
多行注释块
*/
在大多数代码编辑器中,你可以使用快捷键(如 Ctrl + / 或 Cmd + /)快速注释或取消注释选中的代码行。







本节课中我们一起学习了Rust编程语言的基础核心概念。我们从Rust的简介和安装开始,编写了第一个“Hello World”程序,并学会了使用Cargo工具来管理项目。我们深入探讨了Rust中变量默认不可变的特性,以及如何使用 mut 关键字使其可变。接着,我们学习了标量类型(整数、浮点数、布尔值、字符)和复合类型(元组、数组)这两种基本数据类型。最后,我们掌握了控制程序执行流程的关键结构:条件分支 if 表达式,以及三种循环(loop、while、for)。理解这些基础知识是后续学习Rust更高级特性(如所有权、借用、结构体和枚举)的坚实基础。
006:Rust - 内存、所有权与借用

在本节课中,我们将继续学习Rust编程语言。我们将首先快速回顾一些基础语法,然后深入探讨Rust语言中核心且独特的内存管理模型:所有权和借用。理解这些概念是编写安全、高效Rust程序的关键。
索引循环与函数
上一节我们介绍了Rust的基本语法,本节中我们来看看循环和函数的写法。
索引循环
在Java或C++等语言中,你可能会使用C风格的for循环。在Rust中,语法有所不同。
以下是Java中的C风格for循环:
for (int i = 0; i < 10; i++) {
// 使用 i 做一些事情
}
在Rust中,实现相同功能的代码如下:
for i in 0..10 {
// 使用 i 做一些事情
}
这段代码从0开始,循环直到i小于10(即0到9)。虽然语法不同,但功能完全一致。多写几次就会习惯。
函数
函数用于执行特定任务。我们来看一个加法函数的例子。
在Java中,加法函数如下:
int add(int firstNumber, int secondNumber) {
return firstNumber + secondNumber;
}
在Rust中,写法如下:
fn add(first_number: i32, second_number: i32) -> i32 {
first_number + second_number
}
两者区别在于:
- 声明:Rust使用
fn关键字。 - 参数类型:Rust中类型声明更明确(例如
i32表示32位有符号整数)。 - 返回值:Rust在函数签名中使用箭头
->指定返回类型。 - 隐式返回:Rust函数中,最后一个没有分号的表达式会作为返回值。你也可以使用
return关键字和分号显式返回,这在需要提前返回时很有用。 - 无返回值:对于不返回值的函数(类似Java的
void),Rust只需省略箭头和返回类型。 - 命名约定:Rust使用蛇形命名法(
snake_case),而非Java中常见的驼峰命名法(camelCase)。
Rust内存模型
现在,让我们进入本节课的核心内容:Rust的内存模型。要理解它,我们需要先了解一些基础知识。
内存与十六进制
在高级语言如Java或Python中,内存管理是自动的。但在系统编程语言如Rust、C++中,你需要自己管理内存。
我们可以将内存想象成一系列存储信息的块,每个块都有一个地址。在计算机科学中,内存地址通常用十六进制表示。
十六进制使用0-9和A-F(代表10-15)这16个符号。例如,数字16在十六进制中表示为0x10(1*16 + 0*1)。在编程中,我们常用前缀0x来标识十六进制数。
栈数据结构
在讨论内存模型前,还需要理解栈这种数据结构。
栈就像一叠纸:你只能从顶部添加(压入)或移除(弹出)元素。它是“先进后出”的:第一个放入栈的元素将是最后一个被取出的,因为要访问它,必须先移除它上面的所有元素。
Rust中的栈与堆
Rust在运行时使用栈和堆来管理内存,它们的结构不同。
- 栈:用于存储大小固定且已知的数据(如
i32、bool)。它的操作快速,遵循后进先出的原则。 - 堆:用于存储大小未知或可能变化的数据(如
String类型)。你可以向堆请求一块空间,并获得一个指向该位置的指针。
例如,一个整数的大小在编译时是已知的(32位),所以它存储在栈上。而一个字符串,由于用户输入的长度未知,所以存储在堆上,栈上只存储指向堆内存的指针、长度和容量信息。
所有权
Rust通过一套称为所有权的规则来管理堆内存,确保内存安全。
所有权的三条核心规则是:
- Rust中的每一个值都有一个被称为其所有者的变量。
- 值在任一时刻有且只有一个所有者。
- 当所有者离开作用域,这个值将被丢弃(内存被释放)。
所有权与数据交互
对于栈数据(固定大小),赋值会创建数据的副本。
let x = 5;
let y = x; // y 是 5 的一个副本
// x 和 y 都是有效的
对于堆数据(大小可变),赋值会移动所有权,而不是复制数据。
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权移动给了 s2
// println!("{}", s1); // 错误!s1 不再有效
这样做是为了避免:
- 二次释放错误:如果
s1和s2都指向同一堆数据,离开作用域时两者都会尝试释放同一块内存。 - 性能开销:复制大量堆数据效率低下。
如果你确实需要深度复制堆数据,可以使用.clone()方法。
let s1 = String::from("hello");
let s2 = s1.clone(); // 数据被完整复制
// s1 和 s2 都有效,但指向堆上不同的数据
函数与所有权
将值传递给函数时,所有权也会发生转移。
对于堆数据:
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的所有权移动到函数里
// s 在这里不再有效
}
fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string);
} // some_string 离开作用域,`drop`被调用,内存被释放
对于栈数据(固定大小):
fn main() {
let x = 5; // x 进入作用域
makes_copy(x); // x 的值被复制到函数里
// x 在这里仍然有效
}
fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer);
} // some_integer 离开作用域,无事发生
函数也可以通过返回值转移所有权。
fn gives_ownership() -> String {
let some_string = String::from("hello");
some_string // 所有权被移动出函数
}
但是,如果函数需要操作一个值,同时还想在函数调用后继续使用原变量,每次都通过返回值交还所有权会很繁琐。这时就需要用到借用。
借用
借用允许你使用值但不获取其所有权,通过创建引用来实现。
不可变引用
我们使用&符号来创建引用。
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 传递 s1 的引用
println!("The length of '{}' is {}.", s1, len); // s1 仍然有效
}
fn calculate_length(s: &String) -> usize { // s 是对 String 的引用
s.len()
} // s 离开作用域,但因为它没有所有权,所以什么也不会发生
引用默认是不可变的,你不能通过它修改数据。
可变引用
如果你需要修改借用的数据,可以使用可变引用&mut。前提是原变量本身必须是可变的。
fn main() {
let mut s = String::from("hello");
change(&mut s); // 传递可变引用
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}

引用规则
为了保证内存安全,引用遵循两条重要规则:
- 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。
- 引用必须总是有效的。





规则1避免了数据竞争,这种竞争发生在:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针被用来写入数据。
- 没有同步数据访问的机制。

例如,以下代码会编译错误:
let mut s = String::from("hello");
let r1 = &s; // 不可变引用,没问题
let r2 = &s; // 另一个不可变引用,没问题
let r3 = &mut s; // 错误!不能在拥有不可变引用的同时拥有可变引用


为何如此设计?

你可能会觉得这些规则很繁琐。Rust这样设计是为了在安全和性能/控制力之间取得最佳平衡。





- Java/Python:使用垃圾回收器自动管理内存,安全但运行时有一定开销,控制力较低。
- C/C++:手动管理内存,性能高、控制力强,但极易出现内存错误(如段错误、二次释放)。
- Rust:通过所有权系统在编译期检查内存安全,无需垃圾回收,同时实现了高性能和强控制力。

不同的语言适用于不同的场景。Rust非常适合需要高性能和高可靠性的系统编程,例如操作系统、游戏引擎、浏览器组件等。


总结
本节课中我们一起学习了Rust内存管理的核心概念:
- 栈与堆:栈用于固定大小数据,堆用于可变大小数据。
- 所有权:三条规则确保内存安全,赋值操作对栈数据和堆数据有“复制”和“移动”的区别。
- 借用:通过引用(
&)允许访问数据而不获取所有权,分为不可变引用和可变引用,并遵循严格的规则以避免数据竞争。

理解所有权和借用是学习Rust最大的挑战之一,但也是它强大能力的源泉。掌握这些概念后,你就能编写出既安全又高效的Rust程序。
007:Rust字符串、切片与结构体 🦀
在本节课中,我们将学习Rust中的两个核心概念:切片和结构体。切片允许我们引用集合中的一部分连续元素,而结构体则是一种自定义数据类型,用于将多个相关的值组合成一个有意义的组。课程最后,我们将通过一个综合性的知识问答来复习至今为止学到的所有内容。
切片类型与字符串切片 🔪
上一节我们回顾了所有权和内存访问。本节中,我们来看看切片。切片允许我们引用一个集合中一段连续的元素序列,而不是引用整个集合。

什么是切片?



切片是对集合中一部分数据的引用,它本身不拥有数据的所有权。这在处理长字符串时非常有用,例如,我们想获取字符串中的第一个单词。
字符串切片看起来像普通的引用,但在末尾有一个方括号,里面包含两个用..分隔的数字,表示起始和结束索引(结束索引不包含在内)。
公式:&variable_name[start_index..end_index]
例如,对于一个字符串let s = “reverse me”;,切片&s[0..7]将得到”reverse”。
切片的工作原理
在内存中,一个字符串切片内部存储了一个指向起始位置的指针和切片的长度(结束索引 - 起始索引)。
以下是字符串切片的一个代码示例:
let reverse_me = String::from("reverse me");
let word_one = &reverse_me[0..7]; // 获取 “reverse”
let word_two = &reverse_me[8..10]; // 获取 “me”
println!("{} {}", word_two, word_one); // 输出 “me reverse”
切片的便捷语法
Rust提供了一些便捷的切片语法:
&s[..5]:从开头到索引5(不包含)。&s[3..]:从索引3到字符串结尾。&s[..]:获取整个字符串的切片。
字符串字面量(如let s = “hello”;)本身就是字符串切片(&str类型),这也是它们不可变的原因。
其他类型的切片
切片不仅限于字符串,也可以用于数组等其他集合。
let arr = [1, 3, 9, 6, 7];
let slice = &arr[1..3]; // 引用 [9, 6]
结构体 🏗️

现在,让我们转向结构体。结构体是一种自定义数据类型,它允许你将多个相关的值组合成一个有意义的组,这与仅仅存储同类型元素的集合(如数组)不同。


定义与实例化结构体



要定义一个结构体,使用struct关键字,后跟结构体名称和花括号。在花括号内,定义字段名及其类型。



代码:
struct UIUCStudent {
name: String,
uin: u32,
is_cs196: bool,
gpa: f32,
}
要创建结构体的一个实例(即声明一个结构体变量),使用let关键字,并按任意顺序为字段赋值。
代码:
let student1 = UIUCStudent {
name: String::from("CS 196"),
uin: 123456789,
is_cs196: true,
gpa: 4.0,
};
访问与修改结构体字段



使用点号(.)后跟字段名来访问结构体实例中的值。

代码:
println!("Student name: {}", student1.name);


要修改结构体字段的值,整个结构体实例必须是可变的。Rust不允许结构体的部分字段可变,必须是整个实例要么全部可变,要么全部不可变。

代码:
let mut student_mut = student1;
student_mut.gpa = 3.3; // 允许修改
使用函数创建结构体






我们可以编写一个函数来创建并返回结构体实例。在函数内部,我们可以预设一些字段的值。



代码:
fn new_student(name: String, uin: u32) -> UIUCStudent {
UIUCStudent {
name,
uin,
is_cs196: true, // 预设值
gpa: 4.0, // 预设值
}
}
注意,当函数参数名与结构体字段名相同时,可以使用字段初始化简写语法,无需重复书写name: name。



结构体更新语法




当你想基于一个已有的结构体实例创建新实例,并只更新部分字段时,可以使用结构体更新语法..,这能避免大量重复代码。



代码:
let student2 = UIUCStudent {
name: String::from("Another Student"),
uin: 987654321,
..student1 // 其余字段使用 student1 的值
};


元组结构体





元组结构体类似于元组,但拥有一个具体的类型名。它的字段没有名称,只有类型。当你需要多次使用一种特定类型的元组时,这很有用。



代码:
struct TestScores(i32, i32, i32);
struct LectureReviews(i32, i32, i32);





let midterm = TestScores(85, 90, 78);
// midterm.0, midterm.1, midterm.2 访问值
即使TestScores和LectureReviews包含相同类型的字段,它们也是不同的类型,不能互换使用。



知识问答复习 📝




以下是本节课涵盖及之前课程的部分核心概念问答,用于巩固理解:



- 变量默认状态:Rust中的变量默认是不可变的。
- 使变量可变:使用
mut关键字可以使变量可变,例如let mut x = 5;。 - 标量类型:标量类型代表单个值,如整数、浮点数、布尔值和字符。
- 复合类型:复合类型可以包含多个值,如数组和元组。
- 数组元素类型:数组中的所有元素必须是相同类型。
- 栈与堆:栈用于存储大小固定的数据,堆用于存储大小未知或可能变化的数据。
- 编程目标:程序员通常追求更高的性能。
- 所有权规则:在Rust中,一个值在任意时刻只能有一个所有者。
- 编程范式:Rust是一种多范式编程语言。
- 定义与声明:使用
struct关键字定义结构体类型,使用let关键字声明结构体实例。 - 函数与所有权:函数通常会获取传入参数的所有权,除非特别说明(如使用引用)。
- 切片的本质:切片是引用,不拥有数据。
- 访问结构体字段:使用点表示法(
.)访问结构体字段。 - 结构体可变性:结构体的可变性是针对整个实例的,不能单独指定某个字段可变。
- 字符串切片内容:字符串切片内部存储指针和长度,但不存储容量。



总结 🎯


本节课中我们一起学习了Rust的两个重要特性:切片和结构体。切片提供了一种高效引用部分数据的方式,而结构体则帮助我们创建有组织的自定义数据类型。通过理解这些概念并进行实践,你将能更好地组织和管理Rust程序中的数据。下节课我们将暂时离开新的Rust概念,学习如何进行调试和编写更健壮的代码。
008:调试 🐛
在本节课中,我们将要学习调试(Debugging)——这是编程中一项至关重要的技能。调试是找出并修复代码中错误的过程。我们将探讨调试的心态、几种实用的调试方法,以及如何利用工具和资源来更高效地解决问题。无论你是编程新手还是有一定经验,掌握调试技巧都将使你成为一名更独立、更自信的程序员。
调试的心态
上一节我们介绍了课程概述,本节中我们来看看调试时应该保持怎样的心态。
调试可以被看作是在一个着火的房间里,依然能冷静地说:“没关系,我们能处理好。”然后从容地拿起水桶灭火。这种冷静和有条不紊的态度至关重要。
今天特别讨论调试,是因为我认为调试是许多人远离计算机科学(C.S.)的最大障碍之一。人们常常在遇到第一个棘手的程序错误(Bug)时,就认为自己不适合学习计算机科学,因为他们无法解决这个错误。
掌握调试是一项极其重要的技能,它能让你成为一名自给自足的程序员。每个人都会经历调试的过程,无论你经验多么丰富,总会在某个时候需要调试代码。有些人可能调试得更频繁,但不要因为需要调试而感到恐惧,也不要因为自己不像其他更有经验的人那样擅长调试,就认为自己不适合学习计算机科学。
为了证明这一点,我引用了几天前在Reddit上看到的一位伊利诺伊大学C.S.教授Wade Fagan的话。他是一位非常出色的教授,他说:“调试很糟糕。我讨厌它。工具、IDE、调试器等让调试变得容易一些,但它仍然很糟糕。找到一个错误可能要花上几个小时,只是为了修复一行代码。我尽我所能避免需要调试。”
即使是Wade Fagan这样的专家也需要调试。所以,不要对这个概念感到恐惧。
如何避免调试
在深入学习如何调试之前,我们先来看看如何从一开始就避免需要调试的情况。这不是一门软件设计课程,所以我不会花太多时间在这上面,但我最大的建议是:经常测试。

不要一次性写一大堆代码,然后只运行一次,希望它能神奇地工作。确保在编写代码的过程中,经常测试每一个小的功能模块。这样,当你在此基础上构建更多功能时,才不会造成灾难性的后果。
想象一下建造房子。建筑工人不会建好第一层就说“看起来差不多”,然后直接再建30层,最后才说“好了,是时候测试一下这栋房子是否稳固了”。他们会在建造过程中经常测试,确保每一层楼都非常坚固、结构良好。编程也是如此。
当然,这不是软件设计课程,你会在CS 126或CS 242等课程中更深入地学习这些内容。但今天我们的重点是调试。
调试工具箱
计算机科学中的问题和你需要调试的错误,通常可以归结为你对代码行为的许多错误假设。没有一个可以让你在脑海中运行的“调试算法”,让你按部就班地解决所有问题。
相反,我能给你的是一个包含多种不同工具的“工具箱”。你可以根据调试代码时的具体情况,从中挑选合适的工具。这些工具都是为了解决同一个核心问题:挑战你对代码的错误假设,确保你构建的代码像一栋坚固的房子。
工具一:打印语句调试(Printline Debugging)
让我们从工具箱中的第一个工具开始:打印语句调试。这是调试代码最基本的方法之一,但实际上非常强大。
你基本上就是在代码中各处放置打印语句,以确保输出符合预期,并检查是否进入了某些代码区域。
以下是你可以问自己的一些问题:
- 我是否进入了这个函数?在函数里放一个打印语句就能看到。
- 为什么这个值在这里是未知的?在那里放一个打印语句就能看到。
- 我的数组里有多少个元素?同样,放一个打印语句就能知道。

演示:打印语句调试
我将通过一个快速演示来展示如何使用基本的打印语句调试来解决一个错误。
问题描述:
这是一个来自LeetCode的简单面试题。我们需要编写一个程序,输出从1到n的数字的字符串表示。对于每个数字:
- 如果是3的倍数,输出“Fizz”。
- 如果是5的倍数,输出“Buzz”。
- 如果同时是3和5的倍数,输出“FizzBuzz”。
例如,输入为15时,输出应为:1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz。
有问题的代码:
fn fizz_buzz(n: i32) -> Vec<String> {
let mut answer = Vec::new();
for i in 1..n {
if i % 3 == 0 && i % 5 == 0 {
answer.push("FizzBuzz".to_string());
} else if i % 3 == 0 {
answer.push("Fizz".to_string());
} else if i % 5 == 0 {
answer.push("Buzz".to_string());
} else {
answer.push(i.to_string());
}
}
answer
}
这段代码看起来几乎正确,但实际运行结果不对。
调试过程:
- 首先,我用输入
1测试。输出是一个空列表[],但预期应该是["1"]。 - 我在
else分支(输出数字的分支)里添加打印语句println!("Is this working?");,但运行后控制台没有输出。这说明程序没有进入这个if语句。 - 接着,我在
for循环开始处添加打印语句println!("Entering loop");,运行后依然没有输出。这说明程序没有进入for循环。 - 最后,我在函数开头添加打印语句
println!("Entering function");,运行后看到了输出。这说明函数被调用了,但for循环没进去。 - 检查循环边界:
for i in 1..n。当n=1时,范围是1..1,这是一个空范围,所以循环体一次都不会执行。 - 修复方法:将循环范围改为
1..=n或1..n+1,使其包含n本身。
修复后,代码运行正常。

打印语句调试的优缺点
打印语句调试非常有用且强大,但它并非完美无缺。
以下是它的缺点:
- 速度慢:如果你意识到需要移动或添加打印语句,你必须重新编译代码并重新运行整个程序。
- 污染代码:打印语句成为代码的一部分。使用版本控制(如Git)时,它会显示文件被修改,即使这些修改只是调试性的。此外,过多的打印语句会使代码变得混乱。
- 可能影响性能和行为:打印语句本身需要执行时间,大量使用可能会拖慢程序速度,在极少数情况下甚至可能改变程序的行为(例如在多线程环境中)。
- 功能有限:我们将在后面介绍调试器时看到,打印语句的功能相对有限。
尽管如此,我知道很多人仅凭打印语句调试就开发了多年。它确实很强大,但有更好的方法。
工具二:小黄鸭调试法(Rubber Duck Debugging)
现在介绍工具箱里的另一个工具:小黄鸭调试法。你们有些人可能会觉得这个概念很好笑,但小黄鸭调试法实际上是很多人可能已经在无意中使用过的方法。
这个概念的名字来源于《程序员修炼之道》一书中的故事:一个程序员会随身携带一只橡皮鸭,通过向鸭子逐行解释代码来调试程序。
你可能会觉得这个人是个怪人,但这其实非常聪明,因为这种调试方式非常强大。
维基百科上是这样描述的:“许多程序员都有过这样的经历:向别人(甚至可能是一个完全不懂编程的人)解释一个问题时,在解释的过程中突然想到了解决方案。在描述代码应该做什么和观察它实际做什么的过程中,两者之间的任何不一致都会变得显而易见。更普遍地说,教授一个主题会迫使你从不同的角度评估它,从而获得更深入的理解。通过使用一个无生命的物体,程序员可以在不打扰任何人的情况下完成这个过程。”
演示:小黄鸭调试法
我没有橡皮鸭,但我有一只企鹅玩偶,我们就用它来代替。

问题描述:
这是另一个来自LeetCode的简单问题。给定一个数字数组,我们需要返回其中数字位数为偶数的数字有多少个。
例如,数组[12, 345, 2, 6, 7896],输出应为2,因为12(2位)和7896(4位)是偶数位。
有问题的代码:
fn find_numbers(nums: Vec<i32>) -> i32 {
let mut num_even = 0;
for num in nums {
if num % 2 == 0 { // 错误:检查数字本身是否为偶数,而不是其位数
num_even += 1;
}
}
num_even
}
现在,我拿起我的“小黄鸭”(企鹅),开始向它解释代码:
“好的,对于这个问题,我接收一个数字数组。我初始化一个计数器num_even为0。然后我遍历这个数组。对于每个元素num,我检查它是否是偶数……哦!我发现了问题!我在检查数字本身num是否为偶数,但题目要求的是检查数字的位数是否为偶数。所以346是偶数,但它有3位(奇数位),不应该被计数,而我的代码却会计数它。”

通过逐行解释,我很快发现了逻辑上的错误。
正确的思路:
题目限制了输入数字最大为10^5。我们可以利用这个条件:在这个范围内,只有位数为2、4或6的数字才符合条件。因此,我们可以直接检查数字是否落在这些区间内。
另一段有问题的代码:
fn find_numbers(nums: Vec<i32>) -> i32 {
let mut num_even = 0;
for num in nums {
if (num > 9 && num < 100) || (num > 999 || num < 10000) || num == 100000 {
num_even += 1;
}
}
num_even
}
这段代码意图实现上述思路,但仍有错误。通过课堂上的“小黄鸭调试”(即请同学逐行解释),我们发现了两个错误:
- 检查四位数时,条件
(num > 999 || num < 10000)中的||(或)用错了。应该是&&(与),因为我们需要数字同时大于999且小于10000。 - 同样,整个条件组合中,各个区间应该是“或”的关系,但第二个区间的内部逻辑错了。
修复后的正确代码应该是:
if (num > 9 && num < 100) || (num > 999 && num < 10000) || num == 100000 {
num_even += 1;
}
通过小黄鸭调试法,我们能够仔细审视每一行代码,从而快速发现问题所在。
工具三:调试器(Debugger)
现在我想展示另一个强大的工具:调试器。你可以在IDE或文本编辑器中可视化地使用调试器,也可以在命令行中使用。
今天,我将展示在文本编辑器(VS Code)中使用调试器的基本方法。调试器允许你在代码中设置断点,并自动跟踪代码的执行。
之前我们用打印语句模拟了断点,而调试器可以更干净地实现这一点。

演示:使用调试器
- 设置断点:在VS Code中,点击代码行号左侧的空白区域,会出现一个红点,这就是断点。断点应该设置在包含实际代码的行上。
- 启动调试:首先确保项目已编译(例如运行
cargo build)。然后点击VS Code的“启动调试”按钮。 - 控制程序流:程序会正常运行,直到遇到第一个断点后暂停。此时,你可以看到侧边栏中所有变量的当前状态。
- 单步执行:你可以使用“单步跳过”(Step Over)按钮逐行执行代码。每点击一次,程序就执行一行,变量的状态也会随之更新。你还可以使用“单步进入”(Step Into)进入被调用的函数内部,或使用“单步跳出”(Step Out)跳出当前函数。
- 多个断点:你可以设置多个断点。程序会在每个断点处暂停,让你检查状态。
使用调试器的好处是,你无需修改代码(添加打印语句),也无需反复重新编译和运行。你可以直接设置断点,让调试器来完成工作,并且能更清晰、更交互式地观察程序状态。
工具四:谷歌教授(Professor Google)

谷歌是最强大的工具。你可以通过它找到Stack Overflow页面、GitHub issues页面,以及几乎所有编程问题(乃至其他问题)的答案。


请务必使用谷歌。将你的错误信息复制到谷歌搜索框。描述你认为的问题所在并进行搜索。你日常编程中遇到的许多问题,都可以通过简单地谷歌搜索得到解决。


我说这些是因为,我相信你们许多人在遇到错误信息时,会直接拿起电脑去办公室时间,说:“老师,它不工作了。” 但你得学会成为一个自给自足的程序员。



无论你将来是在工业界还是学术界,自我解决问题的能力都至关重要。你不能指望一直有人手把手帮你。而实现这一目标的方法之一,就是善用谷歌。

活动:谷歌寻宝游戏


我们通过一个Kahoot小游戏来实践一下。游戏包含6个问题,有些你可能知道答案,有些则肯定需要谷歌。前5名将获得额外的学分奖励。
问题示例:
- Git报错:
error: unable to read tree object HEAD。这是什么意思?我该怎么办? - 我被困在Vim编辑器里了,关不掉!怎么办?(提示:在Bash讲座中学过)
- 在Bash中,我想用
echo输出美元符号$,但输入echo $它却试图解释变量。我该怎么只输出$这个字符? - 在Bash中,我输入
cd new_dir || touch new_file,进入了新目录,但没看到新文件。touch命令没执行吗?这是怎么回事? - 在Python中,我的代码
result = int(“123”)报错了,编译器在抱怨。我该怎么办? - 我尝试SSH连接到我的UIUC虚拟机,但系统说我的NetID找不到。怎么办?


通过这个游戏,我们可以看到,即使你对某个问题或语言一无所知,只要善于使用谷歌搜索,也能找到解决方案。
总结


本节课中我们一起学习了调试的艺术与科学。我们探讨了调试时应有的冷静心态,并介绍了四种强大的调试工具:


- 打印语句调试:通过插入打印输出来观察程序状态和流程,简单直接但稍显笨拙。
- 小黄鸭调试法:通过向他人或物体逐行解释代码,来梳理逻辑、发现隐含的错误假设。
- 调试器:利用IDE或命令行工具设置断点、单步执行、实时查看变量状态,提供了强大且非侵入式的调试手段。
- 谷歌搜索:将错误信息或问题描述作为搜索词,利用互联网上的海量资源(如Stack Overflow)寻找解决方案,是程序员最重要的自学技能之一。
作为一名程序员,你越能自给自足,就越优秀。这项技能不仅限于软件工程。无论你将来成为理论研究者,还是其他领域的工程师,调试能力都贯穿于许多不同的学科和职业中,是一项非常有用且重要的技能。

希望本节课对你有所帮助。祝你调试顺利!
009:API调用、请求与JSON 🚀
在本节课中,我们将要学习API(应用程序编程接口)的核心概念。我们将了解什么是API、为何使用API、如何使用API,并深入探讨HTTP请求和JSON数据格式。这些知识将帮助你理解现代软件如何通过互联网进行通信和数据交换。
什么是API?🤔
API是Application Programming Interface(应用程序编程接口)的缩写。为了理解它,我们可以用一个餐厅的例子来比喻。
想象你在一家餐厅。你有一份菜单,上面列出了你可以点的食物。厨房负责处理你的订单并制作食物。然而,从发送订单到厨房,再到获得你想要的食物,中间缺少了一个关键环节——服务员。
你浏览菜单,找到想要的食物,向服务员提出请求。服务员接收你的请求,传递给厨房。厨房完成工作后,将食物交给服务员,服务员再送给你。整个过程顺利且简单。
类似地,在一个计算机系统中,你有客户端和服务器。虽然图中它们看起来很近,但客户端和服务器之间的距离并不重要,因为我们使用API。我们的服务器可能在国家的另一端,甚至世界的另一端。这都没关系,因为API的使用降低了复杂性,并确保无论我们在哪里,每次都能正常工作。
回到我们的例子。我们有一个请求(绿色球)。我们将请求发送到服务器。服务器处理我们的请求,获取我们想要的响应(数据或任何我们需要的东西),然后将其传回客户端。现在我们就有了响应。
但是,这个响应是如何传递的?谁在处理它?我们如何精确地获得所需?直接传输数百万比特的数据并获取回来是相当困难的。这里的“服务员”就是我们的API。通过使用应用程序编程接口,我们可以降低数据传输的复杂性,并将大量工作外包出去,因为API使这一切变得非常容易。
现实世界中的API示例 🌍
让我们举一个现实世界的例子。你在日常生活中哪里见过API?
Uber就是一个很好的例子。这是我Uber应用的一个截图。注意右下角,那里写着“Google”。据我所知,Google并不拥有Uber。那么,为什么Uber会使用Google的代码呢?
Uber使用的是Google Maps API。Google拥有世界上最好的地图软件。它使用大约32颗卫星来提供更新的信息和实时数据。你可以获取路线。Uber司机使用这些路线,结合地图和交通数据,以及不断更新的地图,来规划最短路径。
其次,使用API非常容易。一旦你有了API,就无需更新地图或处理运行GPS软件的复杂性。因此,即使是Uber这样的大公司,也会将工作外包给API。
为何要使用API?💡
我们为什么要关心API?我们正在学习编程,难道不能自己写所有代码吗?这是因为一个非常重要的事实:优秀的程序员是“懒惰”的。
这里的“懒惰”不是指抄袭代码,而是指高效。优秀的程序员不会浪费时间一遍又一遍地编写相同的代码,不会手动输入数据,而是会使用脚本等工具来处理。
Perl编程语言的作者Larry Wall提出了程序员的三大美德:懒惰、急躁和傲慢。这三者听起来像是缺点,但实际上它们能让你更高效,更快地达成目标。
以下是使用API的优势:
- 自动化:API允许我们自动化任务,而无需自己编写自动化代码。将工作外包给已经存在的代码是非常好的。
- 效率:API允许开发者访问强大的工具,并非常快速地创建新功能并发布。例如,添加一个搜索栏或搜索引擎本身非常困难。通过使用API,我们可以更高效地利用时间。
- 适应性:API为公司节省了大量时间和金钱,因为更改可以非常快速地发布和实施。例如,如果客户突然要求添加一个新功能,并且必须在两天内完成,从头开始编写、实现和测试将花费很长时间。而调用一个API,你就有了可用的代码。它是一个黑盒,能给你所需的东西,你无需测试和浪费更多时间。
这又回到了“优秀程序员是懒惰的”这个理念。我们没有必要每次想做某事时都重新发明轮子。使用API可以让你的生活更轻松。对于你们的项目也是如此,你们完全可以使用API,因为很多你们可能需要的东西要么在一个学期内制作太复杂,要么已经存在,无需浪费时间重写。
HTTP请求简介 🌐
在深入API之前,我们先简单了解一下HTTP请求。
首先,什么是HTTP?HTTP代表超文本传输协议。这是一种为客户端和服务器之间的通信而设计的协议。
让我们看看HTTP请求的样子。你不需要理解大部分内容,这里只是给你一个直观的印象。在顶部,你有请求行。这具体说明了你在做什么。注意我们有一个关键词GET,后面是URL和HTTP版本。
在它下面,我们有请求头。这些是关于请求的信息。对于我们的目的,可以完全忽略通用部分。然后有一个空行,分隔头部和主体。接着是请求消息主体。这就是你(客户端)发送给服务器的内容。
然后你会得到一个HTTP响应消息。同样,你不需要记住大部分内容。但在顶部,我们有状态行和响应头。我们真正想要的是响应消息主体,因为它包含了我们试图获取的数据信息。
四种主要的HTTP方法 📡
HTTP方法有很多,但这里我们重点介绍四种,因为它们易于解释,并能给你一个很好的入门。
- GET方法:使用GET从指定源请求数据。顾名思义,GET获取你想要的东西。查询字符串位于GET请求的URL中。例如,
GET URL?参数1=值1&参数2=值2。GET请求不应在处理敏感信息时使用,因为这些数据可能会被缓存在浏览器历史记录和Web服务器日志中。GET请求仅用于请求数据,不能用于修改数据。 - POST方法:使用POST向服务器发送数据以创建或更新记录。GET用于获取东西,POST用于在服务器端更新东西。发送到服务器的数据存储在HTTP请求的主体中,这与GET请求(数据在URL中)不同。POST比GET稍微安全一些,因为你无法缓存POST请求。因此,你用POST所做的任何事情通常不会被存储和保存。
- PUT方法:使用PUT来更改服务器端的数据。那么为什么我们既有PUT又有POST呢?PUT请求是幂等的。这意味着多次调用相同的PUT请求总会产生相同的结果。而如果你多次调用POST,可能会产生重复的值。例如,如果你有一个循环,不断重复PUT操作来设置某人的名字为“Sammy”,即使代码有bug,你也只会看到一次结果。但如果你使用POST,你可能会得到一个全是“Sammy”的巨大列表,没有新信息。因此,PUT用于幂等请求。
- HEAD方法:这与GET方法几乎相同,只是没有响应主体。当你只想在下载整个文件之前查看你将得到什么时,HEAD很有用。例如,如果你要下载一个巨大的JSON文件,可以先使用HEAD检查,然后在确切知道它是什么之后再下载,或者拒绝下载。
数据交换格式:JSON与XML 📄
API如何实际发送我们想要的数据?有两种非常流行的数据交换格式。
- JSON:代表JavaScript对象表示法。它是目前非常流行的格式。JSON是一种以人类可读格式序列化数据对象的标准。由于其格式,JSON很容易被人类和计算机读取。它作为浏览器-服务器数据交换的通用格式非常流行,并被JavaScript之外的许多不同语言和系统使用。JSON文档具有嵌套结构,基本思想是由键值对组成。
- XML:代表可扩展标记语言。这是一种较旧但仍然非常强大且广泛使用的数据格式。我们不会重点讨论它,因为你们将看到的很多API不会使用XML。XML文档也具有嵌套结构,基本结构是你有节点,一个大的节点里面包含子节点。
JSON结构详解 🧱
JSON由数组和对象组成。
- 数组:数组就是我们见过的列表,一个由方括号包围的、逗号分隔的列表。
- 对象:对象由花括号包围,并包含一个由逗号分隔的键值对列表。
- 键值对:键值对的格式是:引号中的键/字段名,然后是值。
JSON中可以有不同的数据类型。主要类型包括:
- 整数、浮点数(数字)
- 字符串(必须用双引号包围)
- 布尔值(关键字
true或false,注意小写) - 嵌套数组
- 嵌套对象
null值
最后两种(嵌套数组和对象)非常强大,因为这意味着你可以在一个JSON对象中包含多个JSON块和多个数组,这种嵌套结构允许你快速发送大量数据,并且易于计算机和人类阅读。
让我们看一个UIUC学生的JSON例子。正如你所见,它很容易阅读。我们可以看到顶部有姓名(字符串)、UIN(整数)、电子邮件(字符串),底部的“foreign_language_completed”是布尔值true。


现在看看“classes”。注意“classes”外面有一个方括号,所以“classes”是一个数组。它包含一个由逗号分隔的列表。但“classes”里面是什么?在“classes”里面是对象。注意花括号。所以“classes”是一个包含多个对象(即课程)的数组。我们可以看到对象内部有“name”和“grade”等。这展示了JSON的强大之处,因为你可以使用这样的嵌套结构将大量数据存储在一个关于一个人的文档中。
整个东西是一个对象,由花括号包围。这些是键值对。数组作为一个整体,黄色表示数组的开始和结束,里面有彩色的对象。


如何整合所有内容?🔗
要将所有这些整合起来,请遵循以下步骤:

- 阅读API文档以获取访问要求:不同的API在使用方式上差异很大,但大多数都涉及获取URL和API密钥。
- 获取API密钥:API通常会要求你唯一地标识自己,以便他们知道你是谁。这是为了确保如果你付费使用服务,你能得到你付费的东西;如果你是匿名用户,你不会滥用他们的系统或使其崩溃。
- 根据API文档生成URL:类似于GET请求,我们需要一个可以发送请求的URL。你可以在文档中找到这个。
- 测试你的请求:这一步是可选的,但强烈建议。你可以在浏览器中使用像Postman这样的工具测试你的请求。我们稍后将使用Postman。你使用HTTP请求进行测试,并检查通常是JSON格式的响应。
- 将其集成到你的程序中:虽然这只是一步,但实际上是非常大的一步,因为你必须首先测试所有内容,然后尝试将你制作的东西放入你的应用程序中,同时不破坏其他所有东西。

API调用演示 🖥️


现在让我们进入一个使用API调用的演示。





首先,我们来看一个叫“Reqres”的网站。它提供了一些简单的API,虽然不做什么重要的事情,但可以用来测试你自己的API调用。

我们找到“single user”这个API。我们可以看到它是一个GET请求。如果我们想看看URL是什么,可以点击这里。如果我们发出GET请求,我们将得到这个JSON字符串。
让我们在Postman中做这个。我复制这个链接,在Postman中打开一个新请求,粘贴URL,保持请求类型为GET,然后发送。我们得到了JSON数据,并且可以看到它被自动美化了,所以我们可以清楚地看到这个人的JSON包含什么。



但这并不是很有用。让我们做一些更实用的东西。我将讨论Google自定义搜索。Google自定义搜索是一项功能,允许你创建自己的搜索引擎,可以用来搜索整个网络或仅搜索几个网站。
例如,如果我访问GeeksforGeeks,顶部的搜索栏就是一个Google自定义搜索栏。它基于Google自己的搜索引擎构建,但只搜索GeeksforGeeks的网页。





我们如何在自己的程序中使用这个呢?为此,Google有自己的URL路径,我们必须遵循。如果我访问Google自定义搜索API,我们会看到他们为我们指定了一种格式。我浏览文档,看到他们希望我们这样传递参数。这是一个GET请求。


我们可以看到他们希望我们这样做:我们有URL,提供我们的API密钥,放入我们的搜索引擎密钥,最后在末尾放入我们的查询。

我已经在Postman中做了一个查询。注意我模糊了我的API和搜索引擎密钥。然后我做的查询是“CS 196”。让我们看看JSON输出。




如果我们查看这个JSON,我们会得到大量的数据。首先,让我们简化一下。使用VS Code或其他IDE,你可以将JSON查询简化为主要部分。这再次展示了JSON的嵌套特性。我们看到这里有URL,还有“kind”。“kind”是“customsearch”。在URL中,我们得到了我们进行的查询。在“queries”中,我们看到我们进行了这个搜索。我们得到了结果总数,以及关于我们刚刚进行的查询的其他信息。



现在我们可以查看实际信息:“searchTime”、“formattedSearchTime”,最后是“items”。“items”占用了很多空间。这是Google为我们找到的所有结果。我们可以看到顶部结果是这个链接。如果我们将这个链接粘贴到Google中,它就是CS 196的网站。


所以,Google自定义搜索引擎允许你使用Google自己的软件动态进行查询。




总结 📝



本节课中,我们一起学习了API的核心概念。我们了解到API是应用程序编程接口,它像餐厅的服务员一样,在客户端和服务器之间传递请求和响应,降低了通信的复杂性。我们探讨了为何要使用API——为了提高效率、实现自动化和增强适应性,并理解了“优秀程序员是懒惰的”这一高效编程理念。


我们介绍了HTTP协议及其四种主要方法:GET(获取数据)、POST(发送数据)、PUT(幂等地更新数据)和HEAD(检查响应头)。我们还学习了两种重要的数据交换格式,重点深入了解了JSON的结构,包括对象、数组、键值对以及各种数据类型。

最后,我们通过演示了解了如何阅读API文档、获取API密钥、生成请求URL、使用工具(如Postman)测试请求,并将API调用集成到自己的程序中。这些知识将帮助你在未来的项目中有效地利用现有的网络服务和数据,避免重复造轮子,从而更高效地完成开发工作。
010:使用库和框架 📚
在本节课中,我们将要学习库与框架的区别,以及如何在项目中实际使用它们。我们将探讨为什么开发者普遍使用这些工具,并学习如何评估和选择适合自己项目的库。
概述
上一节我们介绍了API(应用程序编程接口),它是一种通过网络与不同服务(如谷歌地图)交互以获取数据和使用功能的好方法。本节中,我们来看看另外两种避免“重复造轮子”的重要工具:库和框架。
库与框架的区别
首先,我们来明确库和框架的核心区别。
- 库 提供一组辅助函数、对象或模块,你的应用程序代码可以调用它们来实现特定功能。
- 示例:一个提供统计功能的库,它可能包含计算列表平均值的函数。
- 代码示例:
mean = statistics.mean(data_list)
- 框架 定义了一些开放或未实现的函数或对象,用户通过编写代码来填充这些“骨架”,从而创建自定义应用程序。
- 示例:Angular 是一个用于构建Web应用的JavaScript框架。它提供了一个基础结构,你只需在其中实现具体功能。
简而言之,库是你调用它的工具,而框架是它调用你的代码的结构。
如何在项目中使用库和框架
了解了基本概念后,我们来看看如何在个人项目中开始使用库和框架。以下是研究和引入新工具的一般步骤。
第一步:研究
首先,你需要进行研究。如果你需要一个实现特定功能的库,可以打开搜索引擎。
- 方法:搜索你需要的功能和你计划使用的编程语言。
- 目标:找到几个看起来有潜力的候选库或框架。

第二步:评估

找到几个候选后,下一步是评估哪个最适合你的项目。以下是一些有用的评估指标:
以下是评估库质量的几个关键指标:
- GitHub星标数:通常,星标越多,代表项目越受欢迎、越可靠。公式上可以简单理解为:
受欢迎度 ∝ 星标数。 - README文件:README是项目的“门面”。一个清晰、美观、包含示例代码的README通常意味着项目本身质量较高。
- 文档:快速浏览库的文档。检查它是否有示例代码、是否易于理解、语法你是否喜欢。
- 支持与维护:查看GitHub仓库的“Issues”标签。观察已关闭和未解决问题的比例。一个健康维护的项目会及时响应和解决问题。
- 个人经验与偏好:如果你之前用过类似的工具,可以优先考虑。有时选择也取决于个人编码风格偏好。
第三步:尝试


评估之后,就可以动手尝试了。


- 方法:将库引入你的项目(如果使用版本控制,建议新建分支),尝试实现一个简单的“Hello World”功能。
- 目标:感受一下使用体验。如果喜欢,就将其集成到你的主要项目中。

实践示例:在Rust项目中添加库


上一节我们介绍了评估库的通用方法,本节中我们来看看在Rust项目中具体如何操作。我们将以添加一个统计库为例。


假设我们需要一个Rust的统计库。我们可以访问 crates.io(Rust的包仓库网站)进行搜索。
搜索“statistics”后,我们可能找到几个候选,例如:
stats-rs(156 stars)rustats(54 stars)statistic(19 stars)
根据星标、README质量和Issues处理情况,我们决定尝试 stats-rs。
1. 添加依赖
在Rust中,我们使用Cargo管理依赖。所有依赖项在 Cargo.toml 文件的 [dependencies] 部分声明。



[dependencies]
stats-rs = "0.12.0"
版本号 0.12.0 通常可以在库的README“Getting Started”部分找到。


2. 下载依赖
在终端中运行 cargo build 命令。Cargo会自动下载 stats-rs 及其所有依赖项(依赖的依赖),形成一个依赖关系图并全部安装。
3. 在代码中使用


依赖安装好后,需要在代码中引入才能使用其功能。

// 引入整个crate(库)
extern crate stats_rs;


// 从stats_rs的statistics模块中,引入mean和median这两个特定函数
use stats_rs::statistics::{mean, median};

fn main() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let m = mean(&data);
println!("The mean is: {}", m);
}
注意:使用 use 语句精确导入所需功能,而不是导入整个库,这可以避免命名空间污染,并使代码更清晰。



4. 查阅文档与测试
你可以随时查阅库的在线文档,复制示例代码到你的项目中进行测试和修改。同时,确保你选择的库有良好的社区支持(即Issues能被及时响应和关闭),这对长期项目至关重要。





包管理器简介




需要指出的是,不仅仅是Rust,大多数现代编程语言都有类似的包管理工具:
- Rust: Cargo (
cargo build) - JavaScript/Node.js: npm (
npm install) - Python: pip (
pip install) - Java: Maven, Gradle
它们的功能类似:帮助你声明、下载和管理项目所依赖的库。
总结与展望 🧑🍳
本节课中我们一起学习了库与框架的核心区别,掌握了从研究、评估到实际集成一个库的完整流程,并具体实践了如何在Rust项目中使用Cargo添加外部依赖。
可以将软件开发想象成烹饪。一位优秀的厨师(开发者)不会从种小麦、养奶牛开始做一顿饭(项目)。他会利用市场上优质的食材(库)和高效的厨具(框架),将它们组合起来,创造出美味佳肴(软件)。现代软件开发很大程度上正是如此:识别需求,寻找并集成现有的优秀工具,快速构建出功能强大的应用。

在你们未来的项目和职业生涯中,请积极运用这种思维。从你的课程项目开始,尝试研究有哪些库可以让你的开发过程更高效、让你的应用功能更出色。
011:正则表达式 🧩

在本节课中,我们将要学习一种处理“混乱”数据的强大工具——正则表达式。现实世界中的数据往往格式不一,例如电话号码和日期就有多种书写方式。正则表达式能帮助我们定义文本模式,从而精确地查找、匹配和提取所需信息。
数据为何混乱?
上一节我们介绍了学习正则表达式的必要性,本节中我们来看看数据混乱的具体表现。在自由格式的数据中,人类可以按多种方式输入相同的信息。
以下是几种常见的数据混乱示例:
- 电话号码:
(734) 930-3030、734-930-3030、734.930.3030 - 日期:
05/09/2017、2017-05-09、May 9th, 2017
什么是正则表达式?
正则表达式(Regular Expression,常缩写为 regex 或 re)是一个由字符组成的序列,它定义了一个搜索模式。这个模式可用于字符串搜索、查找替换或输入验证。
简单来说,正则表达式允许你定义一个可以匹配文本的模式。然而,这些模式初看可能非常晦涩,例如:^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$。但别担心,我们将从基础开始构建理解。
基础匹配:字符与数字
让我们从一个简单的文本开始练习:“Hello CS196 at Illinois... CS125... CS173... CS225...”。


如果我们想匹配“CS196”,最简单的正则表达式就是 CS196。它表示按顺序匹配字符 C、S、1、9、6。


以下是两个最基本的规则:
- 匹配特定字母:直接书写该字母。例如
C匹配字母 C。 - 匹配特定数字:直接书写该数字。例如
1匹配数字 1。
匹配任意数字与“任何”字符
如果我们想匹配文本中所有类似“CSxxx”的课程编号(如CS196, CS125),而不是固定匹配“CS196”,该怎么办?我们需要匹配“CS”后面的任意三位数字。
这时可以使用元字符 \d,它匹配任何单个数字(0-9)。因此,模式 CS\d\d\d 就能匹配所有三位数的课程编号。
注意:元字符
\D(大写D)是\d的反义,匹配任何非数字字符。
如果我们想匹配一个逗号前的所有内容(例如在“hello, world”中匹配“hello”),需要匹配“任何”字符,直到遇到逗号。
元字符 .(点号)作为通配符,可以匹配任何单个字符(换行符通常除外)。因此,模式 ....., 可以匹配逗号前的任意五个字符。
注意:由于点号
.已被用作通配符,如果你想匹配文本中实际的点号字符,需要使用反斜杠转义:\.。
匹配特定字符集合
有时通配符 \. 或 \d 过于宽泛,我们想限定只匹配几个特定字符。例如,在电话号码 xxx-xxx-xxxx 或 xxx.xxx.xxxx 中,分隔符只能是短横线 - 或点号 .,不能是其他字符。
我们可以使用字符集 [...] 来定义某个位置允许出现的字符。例如,模式 \d\d\d[-.]\d\d\d[-.]\d\d\d\d 可以匹配用 - 或 . 分隔的电话号码。
我们还可以在字符集中使用范围表示法来简化。例如:
[a-z]匹配任何小写字母。[0-9]匹配任何数字(等价于\d)。[2-4]匹配数字2、3或4。
如果想匹配不在某个集合内的字符,可以在字符集开头使用脱字符 ^。例如,[^aeiou] 匹配任何非元音字母。
控制重复次数
书写多个重复的元字符(如 \d\d\d)很繁琐。我们可以用花括号 {} 来指定前面元素重复的次数。
以下是控制重复的几种方式:




- 精确重复:
{n}重复恰好 n 次。例如\d{3}等价于\d\d\d。 - 范围重复:
{m,n}重复 m 到 n 次(包含)。例如E{2,5}匹配连续出现2到5次的大写字母 E。 - 至少一次:
+(加号)匹配前面的元素一次或多次。例如\d+匹配一个或多个数字。 - 零次或多次:
*(星号)匹配前面的元素零次或多次。例如A*匹配零个或多个字母 A。 - 零次或一次:
?(问号)匹配前面的元素零次或一次,即可选。例如-?匹配一个可选的短横线。
分组与选择




我们如何将量词(+, *, ?, {})应用到一组字符上,而不是单个字符?例如,匹配重复的“ABC”。

使用圆括号 () 可以创建一个捕获组,将多个元素视为一个整体。例如,(ABC)+ 可以匹配“ABC”、“ABCABC”等。


圆括号也用于实现逻辑“或” 操作。使用竖线 | 在组内分隔不同选项。例如,(cat|dog) 可以匹配“cat”或“dog”。


正则表达式应用与总结
正则表达式是语言无关的工具,几乎所有编程语言(如 Python, JavaScript, Java, Rust)都支持它。它主要用于:
- 数据验证:检查输入格式(如邮箱、电话)。
- 数据提取:从文本(如日志、网页)中抓取特定信息。
- 搜索与替换:在文本编辑器或代码中执行复杂的模式替换。

本节课中我们一起学习了正则表达式的核心概念:从基础的字符、数字匹配,到使用元字符(\d, .),再到字符集 []、量词(+, *, ?, {})以及分组 () 和选择 |。虽然正则表达式模式初看复杂,但通过分解和练习,你将能掌握这项处理文本数据的强大技能。建议使用在线工具(如 regex101.com)多加练习,并随时查阅速查表以巩固记忆。
012:Rust智能指针与链表 🧠

在本节课中,我们将要学习Rust中的智能指针,并探索如何利用它们来实现链表数据结构。我们将从理解指针的基本概念开始,逐步深入到Box、Rc和RefCell这三种核心智能指针,并了解它们如何解决在Rust中实现链表时遇到的所有权与可变性等独特挑战。
内存与数据结构回顾
上一节我们介绍了课程目标,本节中我们来看看计算机内存的基本概念,为理解链表和指针打下基础。
计算机使用内存,内存可以看作是由十六进制数字寻址的块。数组在内存中的表示就是一系列连续的块。所谓连续,意味着这些内存块彼此相邻。这种特性使得我们可以通过索引来访问数组中的每个元素,因为我们确切地知道下一个元素就在它旁边。

链表简介
与数组的连续存储不同,链表元素可以分散在内存各处。以下是链表在内存中的抽象表示:

链表的一个重要特性是你不能通过索引来访问元素,因为它们分散在内存中。连接这些内存块的方式是通过指针。通过让一个内存块指向下一个内存块,我们可以构建一条链。因此,链表依赖于指针来建立元素间的线性关系。
在Java等语言中,链表的节点可以简单地用一个对象表示,该对象不仅包含数据,还包含一个指向下一个节点的指针。
Rust中实现链表的挑战
那么,如何在Rust中实现同样的功能呢?当我们尝试在Rust中创建一个包含数据和下一个节点指针的结构体时,会遇到一个根本性问题。
在编译时,Rust需要知道一个类型占用多少空间。问题在于,如果一个类型(如链表节点)递归地包含自身,Rust就无法在编译时确定其大小。这就像陷入了一个无限循环:要计算List的大小,需要知道其内部List的大小,而内部的List又包含另一个List,如此往复。



Rust无法在编译时确定这种递归类型的大小,因此代码无法编译。
智能指针概述
为了解决上述问题,我们需要引入智能指针的概念。首先,让我们定义什么是指针。
在抽象定义中,指针是指向存储在其他地方值的箭头。在像C++这样的语言中,指针被定义为存储值的内存地址的东西。在Rust中,这个概念对我们有所抽象,但核心思想相同。
智能指针是指针,但带有额外的功能和元数据。它们更“智能”。在Rust中,我们将学习三种主要的智能指针。
Box 智能指针



我们遇到的第一个智能指针是Box智能指针。Box允许我们将数据存储在堆上,而不是栈上。
在Rust中,栈用于存储大小固定且在编译时已知的数据,而堆用于存储大小未知或可能变化的数据。Box<T>是一个智能指针,它通过在堆上分配类型为T的值,并在栈上保留一个指向堆数据的指针来实现这一点。
let b = Box::new(5); // 5存储在堆上,b(一个指针)存储在栈上
Box是一个智能指针,因为它实现了Deref和Drop trait,允许它像引用一样被使用,并在离开作用域时自动清理堆数据。
使用Box可以解决链表节点大小未知的问题。通过将递归类型包装在Box中,我们告诉Rust:这个字段只是一个指针(Box),而指针的大小是固定的。因此,整个结构体的大小在编译时就可知了。
以下是改进后的链表节点定义:
struct Node {
value: i32,
next: Option<Box<Node>>, // 使用Box包装下一个节点
}
Option枚举用于表示“有值”或“无值”(类似其他语言中的null),结合Box,这便是一个可编译的单向链表节点。
实现单向链表
基于Box和Option,我们可以实现一个简单的单向链表。
以下是链表节点的结构定义和使用示例:
struct Node {
value: i32,
next: Option<Box<Node>>,
}
impl Node {
fn set_next(&mut self, next_node: Box<Node>) {
self.next = Some(next_node);
}
}
fn main() {
// 创建头节点
let mut head = Node { value: 1, next: None };
// 创建下一个节点
let next_node = Box::new(Node { value: 2, next: None });
// 将头节点指向下一个节点
head.set_next(next_node);
}
在这个例子中,head节点指向next_node,形成了一个简单的两节点链表。
双向链表的挑战
单向链表相对简单,但双向链表带来了新的挑战。双向链表的每个节点不仅有一个指向下一个节点的指针(next),还有一个指向前一个节点的指针(prev)。
在Java中,这很简单:节点类包含prev和next两个字段。在Rust中,如果我们尝试用Box来实现:
struct Node {
value: i32,
prev: Option<Box<Node>>, // 指向前一个节点
next: Option<Box<Node>>, // 指向下一个节点
}
这段代码会引发所有权问题。根据Rust的所有权规则,每个值只能有一个所有者。在双向链表中,一个节点可能同时被其前一个节点的next字段和后一个节点的prev字段“拥有”。这违反了单一所有权原则。
Rc 智能指针
为了解决多所有权问题,我们引入第二个智能指针:Rc,即引用计数智能指针。
Rc<T>允许一个值拥有多个所有者。它通过引用计数来跟踪指向同一数据的引用数量。当计数变为零时,数据会被自动清理。
Rc的直观比喻是一个客厅里的电视:第一个人进来打开电视,其他人可以进来观看。当最后一个人离开房间时,电视被关掉。如果在还有人看的时候关掉电视,就会引起混乱。Rc确保只有当所有“观看者”(引用)都离开时,才清理数据。
在双向链表中,我们可以用Rc来包装节点,允许多个指针(prev和next)共享对同一个节点的所有权。
use std::rc::Rc;
struct Node {
value: i32,
prev: Option<Rc<Node>>,
next: Option<Rc<Node>>,
}
内部可变性与 RefCell 智能指针
然而,仅仅使用Rc还不够。回忆一下Rust的借用规则:在任何给定时间,你可以拥有一个可变引用,或者任意数量的不可变引用,但不能同时拥有两者。
在双向链表中,我们不仅需要共享所有权(Rc),有时还需要能够修改节点,即使它被多个地方引用。例如,设置一个节点的prev或next字段就是修改操作。
这引出了我们的第三个智能指针:RefCell。RefCell<T>允许你在运行时执行借用检查,而不是在编译时。它遵循与编译时相同的规则(多个不可变借用或一个可变借用),但如果违反规则,程序会在运行时panic,而不是无法编译。
对于双向链表,我们需要结合Rc和RefCell:Rc允许多个所有者共享数据,RefCell允许在需要时对共享数据进行可变访问。
最终,一个可行的双向链表节点定义可能如下:
use std::rc::Rc;
use std::cell::RefCell;


struct Node {
value: i32,
prev: Option<Rc<RefCell<Node>>>,
next: Option<Rc<RefCell<Node>>>,
}
这种嵌套(Rc<RefCell<Node>>)表示:“这是一个可以被多个所有者共享的节点,并且这些所有者可以在运行时获得可变引用来修改它。” 对于这个结构,Rust会说没问题。

总结
本节课中我们一起学习了Rust中实现链表所需的核心智能指针。
Box<T>:用于在堆上分配数据,解决递归类型大小未知的问题。它是实现单向链表的基础。Rc<T>:启用多重所有权,通过引用计数管理内存。它是实现需要共享节点的数据结构(如双向链表)的关键。RefCell<T>:提供内部可变性,允许在运行时检查借用规则。它使得在多重所有权的上下文中修改数据成为可能。
通过结合使用这些智能指针,我们能够克服Rust严格的所有权和借用规则带来的挑战,成功实现链表这类复杂的数据结构。理解这些工具不仅能帮助你构建链表,更是掌握Rust安全且高效地管理内存和并发能力的基石。

内容来源:根据伊利诺伊大学计算机编程课程《从Bash到Rust》第12讲“Rust:智能指针、链表”(BV188rDBFEUY_p12)内容翻译整理。
013:Rust函数式编程与闭包 🦀

在本节课中,我们将要学习函数式编程的核心概念,特别是闭包和高阶函数在Rust中的应用。我们将从基本定义开始,逐步深入到具体的代码示例,帮助你理解如何利用这些强大的工具来编写更简洁、更可预测的代码。
什么是函数式编程?
上一节我们介绍了课程目标,本节中我们来看看函数式编程的具体定义。
函数式编程是一种通过组合纯函数来构建软件的过程,它避免共享状态、可变数据和副作用。这与面向对象编程形成对比,后者的应用状态通常是共享的,并与对象的方法共存。
以下是几个核心概念的定义:
- 纯函数:每个输入都精确对应一个输出。例如,数学函数
f(x) = 2x是纯函数,因为输入2总是得到输出4。 - 副作用:指在被调用函数返回值之外,任何可观察到的应用程序状态变化。例如,修改全局变量或在函数内部进行控制台日志输出。

函数式代码的优势在于它往往更简洁、更可预测且更容易测试。尽管相关的术语和模式对新手来说可能显得深奥,但其背后的思想实际上非常强大且实用。
一等函数与闭包

理解了函数式编程的基本理念后,本节中我们来看看实现它的第一个关键特性:一等函数。
一等函数是指函数可以像其他任何变量一样被对待。这意味着:
- 函数可以作为参数传递给其他函数。
- 函数可以作为另一个函数的返回值。
- 函数可以被赋值给一个变量。
闭包是一等函数的一个例子,它是匿名函数。你可以将其保存到变量或作为参数传递。闭包与普通函数的一个关键区别在于,它们可以捕获其定义所在作用域中的值。
以下是如何在Rust中定义一个闭包:
// 普通函数
fn add_two(x: i32) -> i32 {
x + 2
}
// 等效的闭包(完整形式)
let add_two_closure = |x: i32| -> i32 { x + 2 };



// 更简洁的闭包(Rust可以推断类型)
let add_two_closure = |x| { x + 2 };



// 最简洁的形式(单表达式可省略花括号)
let add_two_closure = |x| x + 2;


以下是定义闭包时的注意事项:
- 使用一对竖线
|来指定参数。 - 参数后面可以放置包含闭包主体的花括号
{}。 - 如果闭包主体是单个表达式,花括号是可选的。


调用闭包的方式与调用命名函数相同,使用存储它的变量名:
let result = add_two_closure(5); // result 为 7
闭包可以访问其定义作用域中的变量,这是普通函数做不到的:


let y = 10;
let capture_closure = || y; // 闭包捕获了变量 y
println!("{}", capture_closure()); // 输出 10
高阶函数:Map, Filter, Fold
掌握了闭包之后,我们就可以利用它们来使用高阶函数了。高阶函数是指那些以函数为参数、或返回一个函数、或两者皆是的函数。
Map 函数
map 函数接受一个函数(或闭包)和一个可迭代对象,并将该函数应用到可迭代对象的每个元素上,从而产生一个新的可迭代对象。
let numbers = vec![1, 2, 3];
let doubled: Vec<_> = numbers.iter().map(|x| x * 2).collect();
// doubled 现在是 [2, 4, 6]
在上面的代码中,|x| x * 2 这个闭包被 map 应用到了 numbers 列表的每一个元素上。iter() 方法创建一个迭代器,collect() 方法将结果迭代器收集回一个向量(Vec)。


Filter 函数
filter 函数根据一个返回布尔值的函数(或闭包)来筛选可迭代对象中的元素。只有使该函数返回 true 的元素会被保留在新的迭代器中。
struct Shoe {
size: u32,
style: String,
}
fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}
在这个例子中,闭包 |s| s.size == shoe_size 作为筛选条件。它捕获了外部变量 shoe_size,并检查每一只鞋的尺寸是否与之匹配。
Fold 函数
fold 函数(在其他语言中常称为 reduce)将一个函数应用到一个可迭代对象上,并将其“折叠”或“缩减”为单个最终值。它接受一个初始值和一个带有两个参数(累加器和当前元素)的闭包。



let numbers = vec![1, 2, 3];
let sum = numbers.iter().fold(0, |acc, x| acc + x);
// sum 现在是 6
执行过程如下:
- 初始累加器
acc为0。 - 第一个元素
1:acc (0) + x (1) = 1,成为新的acc。 - 第二个元素
2:acc (1) + x (2) = 3,成为新的acc。 - 第三个元素
3:acc (3) + x (3) = 6,作为最终结果返回。
组合使用高阶函数
Map、Filter 和 Fold 可以链式组合在一起,形成非常强大的数据处理模式。这种模式如此强大,以至于谷歌基于它构建了整个分布式数据处理系统(MapReduce)。
假设我们有一个篮球运动员列表,我们想计算所有全明星球员在本赛季为球队贡献的总分数(得分 + 助攻得分):
struct Player {
name: String,
points: u32,
assisted_points: u32,
all_star: bool,
}



fn total_points_contributed_by_all_stars(players: Vec<Player>) -> u32 {
players
.into_iter() // 创建迭代器
.filter(|p| p.all_star) // 1. 过滤出全明星球员
.map(|p| p.points + p.assisted_points) // 2. 映射为每个球员的总贡献分
.fold(0, |acc, points| acc + points) // 3. 将所有贡献分累加
}
这段代码清晰地展示了数据处理流程:
- Filter:从所有球员中筛选出全明星。
- Map:将每个全明星球员转换为其贡献的总分数。
- Fold:将所有总分数求和,得到最终结果。
这种声明式的风格让代码的意图非常明确,易于理解和维护。
总结
本节课中我们一起学习了Rust中的函数式编程核心概念。
我们首先了解了函数式编程是通过纯函数组合来避免副作用和共享状态的范式。
接着,我们学习了一等函数和闭包,闭包是匿名的、可以捕获其环境变量的函数,是函数式编程的基石。
然后,我们深入探讨了三个关键的高阶函数:map(转换每个元素)、filter(根据条件筛选元素)和fold/reduce(将序列缩减为单个值)。
最后,我们看到了如何将这些高阶函数链式组合,以清晰、强大的方式处理数据。

掌握这些概念将帮助你编写出更简洁、更模块化且更易于推理的Rust代码。
014:无惧递归 🧠

在本节课中,我们将学习递归。递归是计算机科学中一个看似神奇但实则非常基础的概念。我们将通过可视化的方式,特别是递归树和调用栈,来揭开递归的神秘面纱,帮助你直观地理解递归在代码底层是如何工作的。
递归简介
上一节我们介绍了函数的基本概念,本节中我们来看看递归。递归在数学中其实很常见,一个经典的例子是阶乘函数。
阶乘的数学定义如下:
n! = n × (n-1) × (n-2) × ... × 1
例如,6! = 6 × 5 × 4 × 3 × 2 × 1 = 720。
我们可以注意到,6! 也可以写成 6 × 5!。这种将问题分解为更小的、相同形式的子问题的过程,就是递归的核心思想。
递归的代码表示
递归函数通常包含两个部分:基准情形和递归情形。基准情形是问题的最小版本,可以直接给出答案,从而终止递归。


以下是阶乘函数的递归定义代码:
def factorial(n):
if n == 1: # 基准情形
return 1
else: # 递归情形
return n * factorial(n-1)
当 n 为 1 时,函数直接返回 1。否则,函数会调用自身来计算 n-1 的阶乘,并将结果与 n 相乘。
可视化递归:递归树 🌳
为了理解递归的执行过程,我们可以使用递归树。递归树以图形化的方式展示了函数调用自身的过程。
让我们以计算 factorial(6) 为例来构建递归树。





- 为了计算
f(6),我们需要知道f(5)的结果。 - 为了计算
f(5),我们需要知道f(4)的结果。 - 这个过程一直持续下去,直到我们到达基准情形
f(1),它直接返回 1。


此时,递归树向下延伸的部分(控制流)就完成了。然后,我们开始向上回溯(返回结果):


f(1)返回 1。f(2)计算2 * f(1) = 2,并返回 2。f(3)计算3 * f(2) = 6,并返回 6。- 依次类推,最终
f(6)计算6 * f(5) = 6 * 120 = 720,得到最终结果。


对于阶乘函数,其递归树是一条直线,因为每次递归调用只产生一个子问题。
另一个例子:斐波那契数列
斐波那契数列是另一个经典的递归例子。数列的定义是:第0项是0,第1项是1,从第2项开始,每一项都等于前两项之和。
其递归定义如下:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) (当 n > 1 时)
对应的代码实现是:
def fibonacci(n):
if n == 0: # 基准情形 1
return 0
elif n == 1: # 基准情形 2
return 1
else: # 递归情形
return fibonacci(n-1) + fibonacci(n-2)
斐波那契数列的递归树与阶乘不同。因为每次递归调用需要计算 F(n-1) 和 F(n-2) 两个子问题,所以递归树会分叉,形成一个树状结构,而不是一条直线。你可以使用像 visualgo.net 这样的网站来可视化这个分支过程。
可视化递归:调用栈 📚
除了递归树,调用栈是理解递归底层机制的另一个关键工具。调用栈是计算机内存中的一块区域,用于跟踪函数调用。
当一个函数被调用时,系统会为其创建一个栈帧,并将其压入调用栈。栈帧中包含了函数的参数、局部变量和返回地址等信息。当函数执行完毕返回时,其对应的栈帧会被弹出。
让我们用阶乘函数 factorial(5) 来看调用栈的工作流程:
main函数调用factorial(5),将factorial(5)的栈帧压入栈。factorial(5)需要调用factorial(4),将factorial(4)的栈帧压入栈顶。- 此过程持续到
factorial(1)被调用并压栈。 factorial(1)是基准情形,它返回值1,然后其栈帧被弹出。- 控制权回到
factorial(2),它现在可以计算2 * 1 = 2,返回值后弹出栈帧。 - 此过程逐级向上回溯,直到
factorial(5)计算出结果120并返回给main函数,所有栈帧被清空。
对于斐波那契数列 fibonacci(5),调用栈的变化会更复杂,因为涉及多个分支的函数调用和返回,但其基本原理相同:每次函数调用产生一个栈帧,返回时弹出。
递归的陷阱与权衡
通过调用栈模型,我们可以理解递归的两个重要陷阱:
- 栈溢出:如果递归函数缺少正确的基准情形,或者递归深度过大,会导致函数无限调用自身。这将不断创建新的栈帧,直到耗尽系统内存,引发“栈溢出”错误。
- 空间效率:递归调用需要为每一层调用保存栈帧,这可能会消耗大量内存。对于深度很大的递归,其空间复杂度可能比等效的迭代解法更高。
因此,递归并非总是最佳选择。递归代码通常更简洁、更直观,尤其对于树状结构或分治算法。但迭代解法在空间效率上往往更有优势。在选择时,需要在代码的清晰度和运行效率之间进行权衡。
总结


本节课中我们一起学习了递归的核心概念。我们通过递归树可视化了递归的分解与合并过程,并通过调用栈模型理解了递归在计算机内存中的执行机制。我们还探讨了递归的潜在问题,如栈溢出和空间消耗,并提醒大家递归和迭代各有优劣,应根据实际情况选择。

记住,理解递归的关键在于识别基准情形和信任递归步骤能正确解决更小的子问题。多加练习,你就能无惧递归。
015:Rust并发、线程与通道 🧵

在本节课中,我们将学习Rust语言中关于并发编程的核心概念。我们将探讨什么是并发、为什么它很重要但同时又充满挑战,并学习如何在Rust中使用线程和通道来实现并发。
概述:什么是并发?
上一节我们介绍了课程的整体目标,本节中我们来看看并发的定义。
并发被定义为程序能够被分解为可以独立运行的部分的能力。
当我们谈论并发时,通常也会提到并行这个概念。以下是两者的定义对比:
- 并发:程序的不同部分可以独立执行。
- 并行:程序的不同部分在同一时刻执行。
为了帮助理解,可以考虑一些现实世界的例子。例如,人类处理日常任务时,通常不会等一件事彻底完成再开始另一件,而是以并发的方式工作:你可能在做作业,然后快速查看邮件,再回去做作业,接着回复短信。这就是并发。相比之下,并行就像是同时将四盘饼干放入烤箱并同时准备它们。
对于本讲座而言,我们将两者都归类为并发,不做过于精确的区分。
为什么使用并发?
上一节我们了解了并发的概念,本节中我们来看看为什么需要并发。
我们需要关注过去40年的微处理器数据趋势。处理器的速度增长开始趋于平缓,但我们拥有的核心数量却在急剧增加。这意味着我们无法单纯让处理器变得更快,但我们可以更好地利用已有的多个处理器。
并发允许我们利用计算机中的多个处理器,从而在许多情况下提升性能。
并发的挑战
尽管并发听起来很棒,但它也非常难以实现。一个挑战的例子是:Mozilla(开发Rust并创建Firefox的公司)曾有一个在Firefox网页渲染引擎中使用并发的议题。这个议题在C++代码库中开放了整整七年,因为在一个拥有数百万行代码的并发程序中添加功能极其复杂。
然而,当他们将代码迁移到Rust后,利用并发修复了那个问题,显著减少了渲染网页所需的时间。这清楚地展示了并发的益处。
那么,为什么并发如此困难?让我们通过一个简单的例子来了解并发程序中常见的挑战。
假设Jeff要从ATM取款2000美元,他的银行账户余额是5000美元。ATM的操作步骤是:
- 从服务器读取余额(5000美元)。
- 本地计算:5000 - 2000 = 3000美元。
与此同时,Jeff的公司向他的账户直接存款4000美元。银行的操作步骤是:
- 从服务器读取余额(5000美元)。
- 本地计算:5000 + 4000 = 9000美元。
如果这两个操作并发执行且没有正确处理,可能导致数据竞争。ATM可能将服务器余额更新为3000美元,而直接存款操作随后将余额更新为9000美元。最终,Jeff的账户余额错误地变成了9000美元,而不是正确的7000美元(5000 - 2000 + 4000)。
好消息是,Rust的所有权机制在很大程度上缓解了这类问题。并发中的常见问题,如内存安全问题和数据竞争,在Rust中通过所有权得到了解决。
Rust中的线程
上一节我们探讨了并发的意义与挑战,本节中我们来看看Rust中实现并发的基础:线程。
在大多数现代操作系统中,执行的程序代码运行在进程中,操作系统同时管理多个进程。在你的程序内部,你也可以有同时运行的独立部分,运行这些独立部分的特性被称为线程。
将程序中的计算拆分为多个线程可以提高性能,因为程序可以同时执行多个任务,但这也增加了复杂性。
由于线程可以同时运行,无法保证不同线程上代码部分的执行顺序。
Rust试图减轻使用线程的负面影响,但在多线程上下文中编程仍然需要仔细思考,并且代码结构不同于单线程程序。Rust希望你在编写并发程序时更加无畏,但并发本身仍然具有挑战性。
创建线程
以下是如何在Rust中创建线程:
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
我们使用 thread::spawn 函数并传入一个闭包,该闭包包含我们想要在新线程中运行的代码。
在这个程序中,有两个线程:主线程和新创建的派生线程。派生线程打印数字1到9,主线程打印数字1到4。由于线程调度顺序无法保证,输出顺序可能每次运行都不同。

此外,注意派生线程可能无法打印到9,因为一旦主线程结束,程序就会终止,派生线程也可能被强制结束。

使用 Join 句柄等待线程



为了解决派生线程可能无法完成的问题,我们可以保存 spawn 的返回值。



spawn 返回一个 JoinHandle。当我们调用其 join 方法时,它会阻塞当前线程(这里是主线程),直到对应的派生线程执行完毕。



use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
现在,即使主线程的循环先结束,它也会在最后被 handle.join() 阻塞,等待派生线程完成所有9次迭代。
join 方法放置的位置很重要。如果将它放在主线程工作之前,那么主线程会先等待派生线程完成,然后才开始自己的工作。
使用 Move 关键字实现线程安全
上一节我们学习了如何创建和控制线程,本节中我们来看看Rust如何通过所有权确保线程安全。
闭包可以捕获其所在环境中的值。但在多线程环境中,如果闭包捕获了值的引用,而该值在线程使用过程中可能被修改或丢弃,就会引发问题。
Rust通过强制闭包使用 move 关键字来获取其所用值的所有权来解决这个问题。这会将值的所有权转移到新线程中,原线程则无法再使用该值,从而防止了数据竞争。
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
// 此处不能再使用 `v`,因为所有权已移入闭包。
// drop(v); // 如果取消注释,将会编译错误。
handle.join().unwrap();
}
使用通道在线程间传递消息
线程间需要通信。Rust标准库提供了通道来实现基于消息传递的并发。
可以将通道想象成一条河流。通道有两端:发送者和接收者。发送者(上游)将数据放入“小船”并送入河流,接收者(下游)则在终点接收这些“小船”。
创建通道
Rust的标准通道是 MPSC(多生产者,单消费者)通道,意味着可以有多个发送者,但只有一个接收者。
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
// 此处不能再使用 `val`,因为所有权已随 `send` 转移。
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
}
mpsc::channel()创建一个通道,返回一个元组(发送者, 接收者)。- 在新线程中,我们使用
tx.send(val)发送一个值。 - 在主线程中,我们使用
rx.recv()阻塞等待并接收发送过来的值。recv()会返回一个Result<T, E>,成功时包含接收到的值。
通道与所有权
发送操作 send 会获取参数的所有权,并将所有权转移给接收端。这意味着发送后,原始发送者不能再使用该值。这是Rust防止数据竞争的又一机制。
多个发送者



通过克隆发送者,可以实现多个生产者向同一个消费者发送消息。



use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone(); // 克隆发送者
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx1.send(val).unwrap();
thread::sleep(Duration::from_millis(1));
}
});
thread::spawn(move || {
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_millis(1));
}
});
// 将接收者当作迭代器使用,无需显式调用 recv
for received in rx {
println!("Got: {}", received);
}
}

两个派生线程分别使用原始的 tx 和克隆的 tx1 向同一个接收者 rx 发送消息。主线程通过迭代 rx 来接收所有消息。由于线程执行顺序不确定,输出消息的顺序也可能是交错的。
总结

本节课中我们一起学习了Rust并发编程的基础知识。



我们首先了解了并发的概念及其与并行的区别,并探讨了使用并发的原因(利用多核)和挑战(数据竞争、复杂性)。
接着,我们深入学习了Rust中实现并发的两个核心工具:
- 线程:使用
thread::spawn创建新线程,通过JoinHandle的join方法等待线程完成。Rust利用所有权和move关键字确保线程间数据访问的安全。 - 通道:使用
mpsc::channel创建用于线程间通信的通道。发送者 (tx) 和接收者 (rx) 通过消息传递进行通信,send操作会转移数据的所有权,从而在编译期防止数据竞争。MPSC通道支持多个发送者。

Rust的所有权系统为编写安全、高效的并发程序提供了强大的编译时保障,使得开发者能够更自信地处理多线程任务。
016:共享状态并发与互斥锁
在本节课中,我们将要学习共享状态并发的基本概念,理解其潜在问题,并探索如何使用互斥锁来解决这些问题。我们还将了解Rust语言如何独特地处理互斥锁,以帮助开发者避免常见的并发陷阱,如死锁。
共享状态并发及其问题
上一节我们介绍了使用通道进行线程间通信。本节中我们来看看另一种并发模型:共享状态并发。
共享内存并发类似于多重所有权,即多个线程可以同时访问同一内存位置。这听起来可能带来问题,让我们通过一个例子来理解原因。
以下是线程访问数据时可能出现的问题的一个典型示例。这是一段伪代码,类似于Rust代码:
static mut GLOBAL_VAR: i32 = 0;
for i in 0..10 {
GLOBAL_VAR += 1;
}
实现变量递增时会发生三个重要步骤:
- 线程首先读取变量的当前值。
- 线程递增该值。
- 线程将新值写回变量。
在单线程程序中,这个过程是顺序的:0, 1, 2, 3, 4, 5, 6, 7, 8, 9。最终 GLOBAL_VAR 的值是10。
然而,在两个线程同时运行的情况下,情况就变得复杂了。假设每个线程循环5次(总计10次递增)。我们的目标仍然是最终得到10,并且因为有两个线程同时工作,速度应该更快。
但实际执行过程取决于CPU调度器如何分配时间给这些进程,无法保证按我们期望的顺序执行。可能出现以下交错执行的情况:
- 线程1读取0,递增为1并写入。
- 线程2读取1,递增为2并写入。
- 线程1读取2,递增为3(但在写入前)...
- 线程2也读取了2,递增为3并写入。
- 此时,两个线程都认为它们将值从2递增到了3,但实际只增加了一次,值变成了3而不是4。
由于线程可能在同一时刻读取相同的值,然后分别递增并写回,导致实际递增次数少于预期。多次运行此代码可能会得到不同的最终结果,这是不可接受的。


互斥锁:解决方案

互斥锁是“相互排斥”的缩写。互斥锁通过确保在任何给定时间只有一个线程可以访问数据,来解决我们之前遇到的问题。
要访问互斥锁中的数据,线程必须首先请求互斥锁的锁。锁是一种数据结构,用于跟踪当前谁拥有数据的独占访问权。拥有锁的线程可以自由访问和读取数据。其他试图获取锁的线程将被阻塞,必须等待锁被释放。




可以用一个会议室的比喻来理解:
- 线程是演讲者。
- 互斥锁是唯一的麦克风。
- 数据是听众。
- 一个演讲者拿到麦克风(锁定互斥锁),只有他能对听众讲话(访问数据)。
- 演讲结束后,他放下麦克风(解锁互斥锁)。
- 然后下一个演讲者可以来拿麦克风。


通过这种方式,任何时刻都只有一个人可以访问数据。



互斥锁的规则与死锁



互斥锁有两个重要规则:
- 在使用数据之前,必须尝试获取锁。
- 当使用完互斥锁保护的数据后,必须解锁互斥锁,以便其他线程可以获取锁。
然而,在多线程编程的复杂性中,遵循这些规则可能很困难。一个常见的问题是死锁。

死锁发生在当一个线程忘记解锁互斥锁时。其他线程在尝试访问该互斥锁时会被阻塞,等待锁被释放。由于锁永远不会被释放,代码无法继续执行,程序“冻结”在此状态。调试死锁非常困难。

Rust中的互斥锁
Rust通过其所有权系统帮助我们避免死锁。让我们看看Rust中互斥锁的基本用法。
以下是一个简单的单线程程序中使用互斥锁的例子:
use std::sync::Mutex;
fn main() {
// 使用 new 函数创建一个新的 Mutex,初始值为 5
let m = Mutex::new(5);
{
// 使用 lock 方法获取锁,返回一个 Result,我们使用 unwrap
let mut num = m.lock().unwrap();
// num 可以视为指向互斥锁内部数据的可变引用
*num = 6;
// 当 num 离开作用域时,Drop trait 会自动调用,释放锁
}
println!("m = {:?}", m);
}
关键点在于:
m.lock()会返回一个MutexGuard智能指针。- 我们可以通过解引用来访问或修改内部数据(如
*num = 6)。 - 当
MutexGuard离开作用域时,Rust会自动调用其Drop实现来释放锁。这意味着在Rust中,你几乎不需要手动解锁,从而极大地减少了死锁的可能性。
多线程与 Arc<T>
然而,要在多个线程间共享互斥锁,我们需要更多的步骤。Rust的所有权规则要求我们使用 Arc<T>(原子引用计数)智能指针类型。
Arc<T> 允许数据拥有多个所有者,并且是线程安全的(但会带来一些性能开销)。我们只在多线程程序中需要它。

以下是一个在多线程中使用 Mutex 和 Arc 的示例:





use std::sync::{Arc, Mutex};
use std::thread;


fn main() {
// 使用 Arc 包装 Mutex,使其可以安全地在多个线程间共享
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
// 克隆 Arc 指针,增加引用计数
let counter = Arc::clone(&counter);
// 创建新线程
let handle = thread::spawn(move || {
// 在线程内获取锁
let mut num = counter.lock().unwrap();
// 修改数据
*num += 1;
// 锁在 num 离开作用域时自动释放
});
handles.push(handle);
}
// 等待所有线程结束
for handle in handles {
handle.join().unwrap();
}
// 打印最终结果
println!("Result: {}", *counter.lock().unwrap());
}



这段代码创建了10个线程,每个线程都将计数器增加1。通过使用 Arc<Mutex<T>> 模式,我们安全地实现了多线程间的共享状态修改。最终结果将是10。




这个简单的代码模板可以用于构建更复杂的并发程序。
并发的用途
最后,我们来谈谈并发的实际用途。并发并不仅限于特定任务,它可以优化许多任务。任何可以并发完成的事情也可以线性完成,但并发能在以下方面帮助我们:
- 提高程序吞吐量:它允许在给定时间内完成的任务数量随着处理器数量的增加而增加,从而更充分地利用多核硬件。
- 改善程序结构:有些任务本质上是并发的,用并发方式建模更自然(例如,Web服务器同时处理多个请求)。
- 更好地利用硬件:非并发程序可能只使用一个CPU核心,而并发程序可以利用所有可用的核心,最大化硬件性能。
总结

本节课中我们一起学习了共享状态并发的核心概念。我们了解到多个线程直接访问共享内存会导致数据竞争和不一致的结果。互斥锁通过提供独占访问机制来解决这个问题。我们探讨了互斥锁的基本规则以及忘记解锁导致的死锁问题。重要的是,我们看到了Rust如何通过其所有权系统和 MutexGuard 的自动 Drop 机制,极大地简化了互斥锁的使用并帮助开发者避免死锁。最后,我们通过 Arc<Mutex<T>> 模式实现了多线程间的安全数据共享,并讨论了并发编程在提升吞吐量和硬件利用率方面的价值。

浙公网安备 33010602011771号