MIT-缺失的一学期-2026-笔记-全-
MIT 缺失的一学期 2026 笔记(全)
001:课程概述与Shell介绍 🚀
概述
在本节课中,我们将学习“编程缺失主题”课程的总体介绍,并深入探讨Shell(命令行界面)的基础知识。Shell是计算机的文本接口,是程序员和系统管理员的核心工具。我们将了解它的基本概念、常用命令以及如何组合命令来执行复杂任务。
课程介绍
“编程缺失主题”这门课程最初于2019年开设,并在2020年再次运行。由于许多学生发现这门课程非常有用,并且自上次开课以来技术环境发生了变化,我们决定再次开设这门课。
我们三位讲师都是麻省理工学院的校友,在攻读博士学位期间都曾教授过许多课程。我们经常发现,在答疑时间中,我们教授的内容往往不是课程的核心主题,而是如何使用Shell、如何高效使用文本编辑器等工具和技巧。因此,我们决定将这些主题单独提炼出来,开设一门专门的课程,以便学生能在答疑时间专注于课程的核心内容。
这门课程的目标是覆盖那些构成幕后知识主体的工具、技术和概念。这些知识通常是其他课程(如编译器或算法课)所不教的,但却是高效使用计算机环境、自动化任务的关键。我们希望通过这门课,让大家了解有哪些工具可用,以及在需要时如何找到并使用它们。
课程共有九次一小时的讲座。时间有限,我们无法深入讲解所有工具或技术的细节。我们的方法更像是“接触疗法”——向大家展示什么是可能的,以及你应该了解哪些工具。我们鼓励大家通过课后练习和自行探索来深入学习。
Shell是什么?
计算机有多种交互界面。我们最熟悉的是图形用户界面(GUI),通过鼠标点击进行操作。GUI虽然好用,但通常局限于程序作者设计的功能,并且很难将不同程序的功能组合起来。
Shell是计算机的文本接口,它允许你直接输入命令,并获取命令的输出结果。它运行在终端(Terminal)的环境中。终端可以看作是一个GUI窗口,而Shell则是运行在这个窗口内的程序。
- 打开终端:
- Linux:通常按
Ctrl+Alt+T,或在应用启动器中搜索“terminal”。 - Windows:按
Win+R,输入cmd或powershell。 - macOS:按
Cmd+Space打开聚焦搜索,搜索“terminal”。
- Linux:通常按












- 常见的Shell:
- Bash (Bourne Again SHell):Linux系统上最常见的Shell,macOS过去也使用它。
- Zsh (Z Shell):现在是macOS和一些Linux发行版的默认Shell。它与Bash兼容,并提供了许多人性化改进和功能增强。
- Fish:另一个旨在提供更好命令行体验的Shell。
- Windows:有
cmd(命令提示符) 和PowerShell。它们的语法与Bash系Shell不同。在本课程中,我们将主要关注Bash及其衍生版本(如Zsh)。如果你使用Windows,建议安装WSL(Windows Subsystem for Linux)或Linux虚拟机来获得完整的Bash环境。
为什么使用Shell?
- 效率:一旦熟悉,用键盘输入命令通常比用鼠标点击更快,尤其是对于复杂任务。
- 自动化:Shell本质上是一种编程语言。你可以在命令行编写脚本,自动执行重复性任务。
- 组合性:Shell非常适合将多个程序组合起来,将一个程序的输出作为另一个程序的输入,形成强大的处理流水线。
- 开源社区与工作流:许多开源工具的安装、构建说明都基于Shell命令。在工业界,软件构建、测试和部署的流水线配置也广泛使用Shell脚本。
- 理解与安全:了解常用命令的作用,可以让你更安全、更明白地执行从网上找到的指令,而不是盲目复制粘贴。
Shell基础
当你打开终端时,会看到一个提示符(Prompt)。它通常显示用户名、主机名和当前工作目录等信息,并等待你输入命令。
运行命令:最简单的命令就是输入一个程序名。例如,输入 date 会打印当前日期。命令后可以跟参数(Arguments),参数之间用空格分隔。例如,echo hello world 会打印“hello world”。


参数解析:Shell会将你输入的文本在空格处分割成多个部分,第一部分作为要执行的程序名,其余部分作为参数传递给该程序。如果参数本身包含空格,需要使用引号(单引号 ' 或双引号 ")将其括起来,或者使用反斜杠 \ 进行转义。





获取帮助:
man <command>:查看命令的详细手册页。例如man echo。<command> --help或-h:通常可以获取命令的简要帮助信息。






在文件系统中导航
pwd:打印当前工作目录。cd <path>:更改当前工作目录到指定路径。ls <path>:列出指定目录的内容。如果不指定路径,则列出当前目录。

路径类型:
- 绝对路径:从根目录
/开始,如/home/user/docs。 - 相对路径:相对于当前目录。
.表示当前目录。..表示父目录。~表示当前用户的家目录。
- 路径组合:可以使用
/分隔路径组件,例如cd ../project/src。
高效导航技巧:
- Tab键自动补全:输入路径或命令的前几个字母,按
Tab键可以自动补全。如果存在多个可能选项,按两次Tab会显示所有选项。 - 工具:可以使用像
zoxide这样的工具来快速跳转到常用目录。



程序与PATH
当你输入一个命令(如 date)时,Shell如何知道该执行哪个程序?它通过一个叫做 PATH 的环境变量来查找。
echo $PATH:查看PATH变量的内容,它是一系列用冒号:分隔的目录路径。which <command>:显示Shell将执行哪个路径下的命令。例如which date。





Shell会按照 PATH 中列出的目录顺序,查找与命令名同名的可执行文件,并执行找到的第一个。



常用命令示例
以下是一些在Shell中非常有用的命令:







cat <file>:连接文件并打印其内容到标准输出。sort <file>:对文件的行进行排序。uniq <file>:报告或省略重复的行(通常与sort结合使用,因为uniq只处理相邻的重复行)。head/tail <file>:显示文件的开头或结尾部分(默认10行)。可以使用-n指定行数,如head -n 5 file.txt。grep <pattern> <file>:在文件中搜索匹配指定模式的行。模式支持正则表达式,功能强大。-r:递归搜索目录。
sed:流编辑器,用于对输入流(或文件)执行基本的文本转换。常用于搜索和替换。- 例如:
sed -i 's/old/new/g' file.txt将文件中所有的“old”替换为“new”。
- 例如:
find <path> <expression>:在目录树中查找文件。功能非常强大,可以根据名称、类型、大小、修改时间等条件进行搜索。- 例如:
find . -name "*.txt" -type f查找当前目录下所有.txt文件。 - 可以与
-exec结合,对找到的每个文件执行命令。例如:find . -name "*.tmp" -exec rm {} \;删除所有.tmp文件。
- 例如:
awk:一种用于文本处理的编程语言和工具。它特别适合处理按列(字段)组织的数据。- 默认按空白字符(空格、制表符)分割行。例如
awk '{print $2}' file.txt会打印每行的第二个字段。 - 可以指定字段分隔符,例如
awk -F',' '{print $1}' data.csv处理CSV文件。
- 默认按空白字符(空格、制表符)分割行。例如


组合命令与重定向
Shell的强大之处在于能够将简单的命令组合起来。

- 管道(Pipe)
|:将一个命令的输出作为另一个命令的输入。- 例如:
history | grep git | tail -n 5先获取命令历史,然后过滤出包含“git”的行,最后显示最后5行。
- 例如:
- 输出重定向
>和>>:将命令的输出写入文件,而不是显示在终端。command > file.txt:将输出写入文件(覆盖原有内容)。command >> file.txt:将输出追加到文件末尾。
- 输入重定向
<:从文件读取数据作为命令的输入,而不是从键盘。- 例如:
sort < data.txt。
- 例如:



Shell脚本编程
当命令变得复杂时,可以将它们写入一个文件,形成一个Shell脚本。







创建脚本:
- 创建一个新文件,例如
myscript.sh。 - 在文件第一行添加 shebang 行,指定解释器:
#!/bin/bash。 - 在下面编写你的Shell命令。
- 保存文件。


使脚本可执行:
chmod +x myscript.sh
运行脚本:
./myscript.sh
注意:运行当前目录下的脚本需要使用 ./ 前缀,否则Shell会在 PATH 中查找,而通常当前目录不在 PATH 中(这是出于安全考虑)。


脚本中的控制结构:
Shell脚本支持 if、while、for 等控制结构。



- 条件判断:通常使用
test命令或其别名[。- 例如:
if [ "$name" = "Alice" ]; then echo "Hi Alice!"; fi - 更多条件测试(如检查文件是否存在
-f,检查目录是否存在-d)请参考man test。
- 例如:
- 循环:
for循环:for i in 1 2 3; do echo $i; done- 可以使用命令生成序列:
for i in $(seq 1 5); do echo $i; done while循环:while true; do echo "Looping..."; sleep 1; done





总结
本节课我们一起学习了Shell的基础知识。我们了解了什么是Shell以及为什么它如此重要。我们学习了如何在文件系统中导航、如何运行程序、如何使用 PATH 变量。我们介绍了一系列强大的命令行工具,如 grep、find、sed 和 awk,并学习了如何通过管道和重定向将它们组合起来。最后,我们了解了如何将一系列命令保存为Shell脚本,并使其可执行,从而实现任务的自动化。



Shell是一个极其强大的环境,掌握它将极大地提升你的工作效率和对计算机的控制能力。请务必完成课程提供的练习,这是巩固知识的最佳方式。下一节课,我们将深入探讨命令行环境本身,包括终端配置、作业控制、远程连接等内容。
002:命令行环境核心概念与远程操作


在本节课中,我们将深入学习命令行环境的核心概念,包括程序输入输出的多种方式、环境变量、信号处理,以及如何高效地在远程服务器上工作。我们还将探讨如何定制你的Shell环境,使其更符合你的使用习惯。
上一讲我们介绍了Shell的基本用法和一些常用程序。本节中,我们来看看Shell环境中更深层的概念和约定。

程序输入与输出
在编程中,程序的输入和输出通常很明确。但在Shell脚本中,输入和输出的来源可能变得复杂。Shell程序遵循一些约定,以便不同程序之间能够通信。
参数与标志
最简单的输入形式是参数。当我们运行 ls -l project1 时,-l 和 project1 就是传递给 ls 的参数。
代码示例:
ls -l project1
在Python中,可以通过 sys.argv 获取这些参数:
import sys
print(sys.argv)
参数中的 - 或 -- 前缀表示“标志”,用于改变程序的默认行为。例如,-a 标志让 ls 显示隐藏文件(以 . 开头的文件)。
代码示例:
touch .a_file
ls # .a_file 不会显示
ls -a # .a_file 会显示
单字符标志通常可以组合使用,顺序无关紧要:
ls -la project1
许多程序接受任意数量的参数,并对每个参数执行相同操作:
mkdir project3 project4
通配符
通配符(Globbing)是一种简单的模式匹配语言。当使用 *.py 时,Shell会将其扩展为匹配该模式的所有文件。
代码示例:
touch {foo,bar,baz}.{py,sh}
ls *.py *.sh
一些Shell支持更复杂的通配模式,例如 ** 可以匹配嵌套目录:
ls **/*.py
标准流与管道
程序通过标准输入(stdin)、标准输出(stdout)和标准错误(stderr)进行通信。管道 | 可以将一个程序的输出连接到另一个程序的输入。
代码示例:
# 生成数字并过滤奇数
python -c "import time; [print(i) for i in range(10)]" | grep "[13579]"
管道中的程序是并行运行的,数据流实时传递。标准错误流用于输出错误信息,与标准输出分开。
代码示例:
ls non_existent_dir # 错误信息输出到 stderr
ls non_existent_dir 2> error.txt # 将 stderr 重定向到文件
ls . > output.txt 2>&1 # 将 stdout 和 stderr 都重定向到文件
ls . &> all_output.txt # 另一种合并重定向的语法
/dev/null 是一个特殊文件,写入它的任何内容都会被丢弃:
ls . > /dev/null # 丢弃输出
环境变量
环境变量是存储在Shell会话中的键值对,可以影响程序的行为。
代码示例:
FOO=bar # 定义变量
echo $FOO # 使用变量
变量赋值时不能有空格。你可以将命令的输出捕获到变量中:
files=$(ls)
echo "$files"
默认情况下,变量只在当前Shell会话中有效。使用 export 命令可以使变量被其子进程继承。
代码示例:
DEBUG=1
bash -c 'echo $DEBUG' # 输出为空
export DEBUG=1
bash -c 'echo $DEBUG' # 输出 1
一些环境变量是约定俗成的,例如 HOME 表示用户主目录,TZ 可以设置时区:
TZ=Asia/Tokyo date
退出码与信号
程序执行后会返回一个退出码(Return Code),用于表示执行状态。按照惯例,0 表示成功,非零值表示错误。


代码示例:
ls .
echo $? # 输出 0
ls non_existent_dir
echo $? # 输出非零值(例如 2)


Shell的控制流结构(如 && 和 ||)会检查退出码:
# 仅当前一个命令成功时,才运行下一个命令
ls . && echo "Success"
# 仅当第一个命令失败时,才运行第二个命令
ls non_existent_dir || echo "Failed"
grep -q 命令在找到匹配时返回0,未找到时返回非零值,常用于条件判断。
信号(Signals)是发送给进程的软件中断。例如,按下 Ctrl+C 会发送 SIGINT 信号,通常导致程序终止。
代码示例:
# Python 程序处理 SIGINT 信号
import signal, time
def handler(signum, frame):
print("Ignoring signal!")
signal.signal(signal.SIGINT, handler)
while True:
print("Running...")
time.sleep(1)
Ctrl+Z 会发送 SIGTSTP 信号,挂起进程。可以使用 fg 命令恢复进程,或使用 kill 命令发送其他信号。
代码示例:
# 启动一个睡眠进程
sleep 100
# 按下 Ctrl+Z 挂起它
# 查看作业
jobs
# 恢复进程(在前台运行)
fg
# 或者发送 SIGCONT 信号使其在后台继续
kill -CONT %1
远程服务器操作
SSH(Secure Shell)是连接和操作远程服务器的标准工具。
基本语法:
ssh user@hostname
SSH密钥认证
为了避免每次连接都输入密码,可以使用SSH密钥对进行认证。永远不要分享你的私钥。
生成密钥对:
ssh-keygen -t ed25519
将公钥复制到远程服务器:
ssh-copy-id user@hostname
远程执行命令与文件传输
SSH不仅可以用于交互式会话,还可以直接执行命令。
代码示例:
# 在远程服务器上执行 ls 命令
ssh user@hostname ls
# 将远程命令的输出通过管道在本地处理
ssh user@hostname ls | wc -l
# 整个管道在远程服务器执行
ssh user@hostname "ls | wc -l"
使用 scp 命令在本地和远程服务器之间复制文件:
# 复制本地文件到远程服务器
scp local_file.txt user@hostname:/remote/path/
# 从远程服务器复制文件到本地
scp user@hostname:/remote/path/remote_file.txt .
终端复用器
当与远程服务器的连接断开时,其中运行的程序通常会终止。终端复用器(如 tmux)可以解决这个问题,它允许你在一个会话中运行多个程序,并且会话与连接无关。
基本 tmux 命令:
tmux: 启动新会话。Ctrl+b c: 创建新窗口。Ctrl+b 0: 切换到0号窗口。Ctrl+b d: 分离当前会话。tmux attach: 重新连接到之前的会话。
使用 tmux,即使SSH连接断开,你的程序也会继续在服务器上运行。
定制Shell环境
你可以通过修改配置文件和安装插件来定制你的Shell环境,使其更高效、更美观。
修改PATH环境变量
PATH 变量定义了Shell查找可执行程序的目录列表。你可以添加自定义目录。
代码示例:
# 临时添加目录到 PATH
export PATH="$PATH:/my/custom/bin"
# 永久生效,需要将上述行添加到 Shell 配置文件中(如 ~/.bashrc)
Shell配置文件
不同的Shell读取不同的配置文件。例如,Bash在交互式登录时会读取 ~/.bash_profile 或 ~/.profile,而交互式非登录Shell会读取 ~/.bashrc。
你可以修改提示符 PS1 环境变量:
PS1="\u@\h \w\$ " # 设置提示符格式
插件与框架
有许多插件可以增强Shell的功能,例如:
- fzf: 模糊查找命令历史。
- zsh-autosuggestions: 根据历史自动建议命令。
- powerlevel10k: 高度可定制的提示符主题。
建议一次只安装一个插件,确认有用后再保留,避免Shell启动过慢。



点文件管理


配置文件通常以 . 开头(如 .bashrc),称为“点文件”。许多人将他们的点文件存储在Git仓库中,并使用符号链接来管理。

代码示例:
# 创建符号链接
ln -s ~/dotfiles/.bashrc ~/.bashrc
AI工具集成
你可以在命令行中使用AI工具来辅助完成复杂任务。
使用 llm 工具:
# 询问如何查找最近一天修改的Python文件
llm "find Python files modified in the last day"
使用 claude 工具执行复杂操作:
# 让AI生成并执行命令
claude "find the most recently modified shell script and change its shebang to use zsh"
终端模拟器
终端模拟器(如 Alacritty, iTerm2, GNOME Terminal)是运行Shell的GUI程序。你也可以配置它们的字体、颜色方案等选项。
总结



本节课我们一起深入学习了命令行环境的核心机制。我们了解了程序如何通过参数、标志、标准流和环境变量进行输入输出通信,掌握了退出码和信号的控制流程。我们还学习了如何使用SSH安全地连接和操作远程服务器,并利用 tmux 来维持会话。最后,我们探讨了如何通过配置文件、插件和AI工具来定制和增强你的Shell环境,使其成为一个更加强大和个性化的工具。掌握这些概念将帮助你更高效、更自信地使用命令行。
003:开发环境与工具
在本节课中,我们将要学习开发环境与工具。我们将涵盖文本编辑、代码智能、AI辅助开发等核心主题,帮助你构建高效的工作流。

开发环境概述
开发环境是一套用于开发软件的工具集合。它有许多不同的形式和配置。
上一节我们介绍了开发环境的概念,本节中我们来看看两种主要的开发环境类型。
终端基础开发环境:这种配置通常在终端中运行,例如使用Tmux进行窗格分割,结合Shell和文本编辑器(如Vim)。它适用于远程服务器或需要轻量级、可脚本化环境的场景。
图形化集成开发环境:例如Visual Studio Code,它将文本编辑、代码提示、自动格式化等功能集成在一个应用程序中。它通常能更好地集成AI功能,适合初学者或需要一体化工具的场景。

文本编辑与Vim

文本编辑是IDE的核心功能。在编写程序时,你需要编辑代码库中的文件。Vim是一个强大的文本编辑器,其交互方式针对代码编辑进行了优化。

Vim的核心思想
Vim的核心思想是将其界面视为一种编程语言。它包含一系列基本操作(原语),你可以组合这些原语来完成强大的编辑任务。其目标是让你无需频繁在键盘和鼠标之间切换,从而保持高效。
Vim是一个模态编辑器,这意味着它有不同的操作模式来执行不同类型的任务。
以下是Vim的主要模式:
- 正常模式:默认模式,用于移动光标和执行命令。
- 插入模式:用于插入和添加文本。
- 替换模式:用于覆盖现有文本。
- 可视模式:用于选择文本块、行或矩形区域。
- 命令模式:用于运行保存、退出等命令。
Vim的基本操作
在正常模式下,你可以使用各种键绑定来移动和编辑文本。这些绑定通常具有助记性,便于记忆。
以下是Vim中一些基本的移动命令:
h、j、k、l:分别向左、下、上、右移动光标。w:向前移动一个单词。b:向后移动一个单词。0:移动到行首。$:移动到行尾。gg:移动到文件顶部。G:移动到文件底部。:123:跳转到第123行。
编辑命令可以与移动命令组合使用,形成强大的编辑操作。
以下是Vim中一些基本的编辑命令:
i:进入插入模式。a:在光标后进入插入模式。o:在下方新建一行并进入插入模式。O:在上方新建一行并进入插入模式。d{motion}:删除移动命令覆盖的文本(例如,dw删除一个单词)。c{motion}:删除移动命令覆盖的文本并进入插入模式(例如,ci(删除括号内的内容并进入插入模式)。u:撤销操作。
你还可以使用计数来重复操作,例如 5j 向下移动5行,3dw 删除3个单词。
Vim的理念是,通过练习将这些移动和编辑命令组合成肌肉记忆,你可以达到“思考即编辑”的速度。许多其他软件(如VS Code、Zsh)也支持Vim键绑定,因此学习Vim的思维方式具有广泛的适用性。

代码智能与语言服务器


现代IDE提供了许多编程语言特定的支持,如自动格式化、错误检查和代码补全。这些功能通常通过语言服务器协议实现。



语言服务器是一个独立的进程,它理解特定编程语言的语义。IDE通过LSP与语言服务器通信,从而获得代码智能功能。这种架构将M个IDE支持N种语言的问题,简化为实现M个LSP客户端和N个语言服务器的问题。




语言服务器支持的功能极大地提升了开发体验。
以下是语言服务器提供的一些关键功能:
- 代码补全:输入时自动提示变量、函数或方法名。
- 跳转到定义:快速导航到变量、函数或类型的定义处。
- 查找引用:查找代码库中所有使用某个符号的地方。
- 错误检查:在输入时实时标记语法或类型错误。
- 自动导入:自动添加缺失的模块或包导入语句。
为你的编程语言设置好语言服务器,可以显著提高代码理解和导航的效率。
AI辅助开发
自GitHub Copilot等工具出现以来,大型语言模型在辅助代码编写方面变得越来越强大。目前主要有三种使用AI模型编写代码的交互形式。


AI自动补全
这是最基本的交互形式。当你开始输入代码时,AI模型会预测并建议接下来的代码片段。你可以按Tab键接受建议。通过编写描述性的注释或文档字符串,可以更好地引导AI生成符合你意图的代码。

内联聊天

这种模式允许你选择一段代码或指定一个光标位置,然后直接向AI发出指令,让它修改选中的代码。例如,你可以选中一个使用第三方库的函数,然后要求AI“不要使用第三方库”来重写它。AI会生成修改建议(通常用红绿颜色标出增删),你可以审查并接受这些更改。

编码代理
编码代理是更高级的、对话式的工具。你可以与它进行多轮对话,要求它解释代码、询问问题,或者委托它完成更复杂的任务。它就像一个集成在你代码环境中的ChatGPT。需要注意的是,LLM是基于概率的模型,有时会产生看似合理但实际错误的输出,因此需要仔细审查。
关于AI工具的隐私和资源消耗:大多数工具需要联网,因为模型运行在云端。一些工具提供隐私设置选项,但完全隐私通常需要本地运行模型,这可能会消耗大量电力和计算资源。
总结
本节课中我们一起学习了开发环境与工具的核心内容。我们首先了解了终端环境和图形化IDE的区别。然后深入探讨了Vim的模态编辑哲学及其高效的键绑定组合。接着,我们介绍了语言服务器如何为IDE提供代码智能功能。最后,我们概述了AI辅助开发的三种主要形式:自动补全、内联聊天和编码代理,并讨论了它们的使用场景和注意事项。
掌握这些工具和概念,将帮助你构建个性化、高效的工作流,从而更专注于解决实际的编程问题。
004:调试与性能分析 🐛⚡
在本节课中,我们将学习调试与性能分析的核心概念和实用工具。调试帮助我们找出程序行为与预期不符的原因,而性能分析则帮助我们理解程序如何运行以及如何使其运行得更快。
调试基础:从打印到日志
上一节我们介绍了课程概述,本节中我们来看看调试的基础方法。计算机只会严格地执行我们告诉它的指令,但这不一定是我们的本意。调试就是探索我们想让计算机做什么和它实际做了什么之间的差异。
C语言先驱Brian Kernighan曾说过:“最有效的调试工具仍然是仔细的思考,加上明智放置的打印语句。” 这至今仍然适用。很多调试工作最终都归结为“打印调试”,即在代码中插入一系列打印语句,输出有助于你思考程序正在做什么的信息,并与你认为它应该做什么进行比较。
打印调试有时被视为一种原始的工具,但在很多情况下它确实非常有效。其最大的问题是,每次程序运行时你几乎都从零开始,需要逐步缩小问题可能出现的范围。这就是日志记录发挥作用的地方。日志记录本质上是打印语句的一种更有原则的使用方式,这些语句会长期保留在你的代码中。它们不是仅为某次调试会话而插入的,而是更持久的记录,在你需要调试软件的任何时候(无论是程序崩溃、理解性能还是实现新功能)都可能有用。
在大型代码库,尤其是生产代码库中,你会看到大量的日志记录。日志可以是非结构化的(例如纯文本),也可以是结构化的(例如指标或JSON对象)。结构化的日志更容易在事后使用Shell工具进行解析和提取信息。

以下是关于日志记录的一些常见约定:
- 日志级别:通常包括
TRACE、DEBUG、INFO、WARN、ERROR、CRITICAL,用于指示日志的重要性。应用程序或库通常允许你设置一个阈值,只输出该级别及以上的日志。 - 按模块配置:更复杂的日志框架允许你按模块或文件指定日志级别,以便对特定部分进行更详细的记录。
- 主动记录:日志语句通常是你在编写代码时主动添加的,认为这些信息在调试时会有用。打印调试可以被看作是在说“这里本应该被记录下来”。
许多第三方程序也内置了日志功能。对于命令行工具,通常可以传递 -v 或 --verbose 标志来获取更多输出。对于系统服务(例如Linux上的 sshd),可以使用像 journalctl 这样的工具来查看其日志。
使用调试器:GDB与RR
当打印调试不够用时,例如重新编译和部署程序变得繁琐,或者你需要观察程序执行过程来决定提取哪些信息时,就需要使用调试器。
最常见的通用调试器是 GDB 和 LLDB。它们可以附加到一个正在运行的进程,并允许你控制其执行,例如在特定代码行暂停、在程序暂停时检查内存、继续执行直到当前函数结束等。它们主要适用于编译型语言(如C、C++、Rust)生成的二进制文件。对于Python、JavaScript、Java等运行时语言,通常有语言特定的调试器(如Python的 pdb)。
调试器的一个常见痛点是:你让程序运行,发现了一个错误,但你想回溯到导致这个错误发生的操作序列。此时程序已经执行过了错误点。反向调试 或 记录回放调试 可以解决这个问题。一个主要的工具是 RR。RR会记录程序的所有操作(与操作系统的交互、内存访问等),然后允许你反向执行程序。当你在一个断点处停止时,可以执行“反向步进”,撤销当前行的执行,回到上一行代码的状态。这对于追踪棘手的错误非常强大。
然而,调试也存在“海森堡bug”(以不确定性原理命名),即当你试图观察一个bug时,它却消失了。这在打印调试中很常见,因为打印会减慢程序速度,可能改变并发程序的行为顺序。RR使用确定性调度器来记录线程执行顺序,这也可能导致某些bug在记录模式下无法复现。
让我们通过一个示例程序 corruption.c 来演示RR的使用。该程序有一个结构体数组,在更新某个账户的名字后,意外地改变了另一个账户的ID。通过RR记录、设置断点、设置观察点(watch)并使用 reverse-continue 命令,我们可以回溯到ID被修改的确切时刻和函数调用栈,最终发现是 strcpy 操作导致了缓冲区溢出,覆盖了相邻的 id 字段。
RR功能强大,尤其适用于难以复现的崩溃或“不稳定”的测试。但它需要硬件支持,且目前仅适用于Linux。如果修改了程序代码,则需要重新编译并重新记录。
系统调用追踪:strace与eBPF
有时,你不需要关心代码本身,只想了解程序在做什么,例如程序卡住时它在等待什么。这时可以使用追踪工具。
一个常见的工具是 strace,它可以追踪一个进程进行的系统调用(程序与操作系统内核的交互,如打开文件、读写网络等)。你可以用 strace 启动一个程序,或者用 strace -p PID 附加到一个已运行的进程。strace 的输出可能非常冗长,但你可以使用过滤器,例如 -e trace=%file 只显示文件相关操作,或者 -e trace=openat 只显示特定的系统调用。使用 -f 标志可以追踪由原始进程派生的子进程。
strace 功能有限,例如它无法统计系统调用的延迟分布。更强大的基础设施是 eBPF,它允许你将小程序注入到Linux内核中,以内核权限检查和测量各种内部状态。
例如,bpftrace 工具使用一种特殊的过滤语言。一个简单的命令 bpftrace -e ‘tracepoint:syscalls:sys_enter_* { @[probe] = count(); }’ 可以统计所有系统调用的调用次数。另一个命令 bpftrace -e ‘tracepoint:syscalls:sys_enter_openat { printf(“%s %s\n”, comm, str(args->filename)); }’ 可以打印出每个打开文件的进程名和文件名。你还可以通过条件过滤,只追踪特定进程。
基于eBPF,人们构建了许多有用的工具,例如:
- biolatency:测量磁盘I/O操作的延迟分布。
- opensnoop:实时显示哪些进程打开了哪些文件。
对于网络调试,tcpdump 可以抓取和分析网络接口上的数据包。更高级的工具如 Wireshark 提供了GUI界面,并能解析多种高层协议(如HTTP、MySQL)。对于加密流量(如HTTPS),可以使用 mitmproxy 这类中间人代理进行解密和检查。
自动化检查:消毒器与Valgrind
并非所有调试都需要手动进行。消毒器 是编译器的扩展,它会在编译时向程序中插入额外的指令,用于在运行时检查程序是否在做“坏事”。
例如,AddressSanitizer 可以检测内存错误。对于一个存在堆缓冲区溢出但未立即崩溃的程序,使用 -fsanitize=address 标志重新编译后运行,它会清晰地报告出堆缓冲区溢出错误、发生位置、调用栈以及内存被覆盖的详细信息。
还有其他消毒器,如用于检测数据竞争的 ThreadSanitizer。消毒器要求你重新编译程序。如果你无法重新编译(例如程序是别人构建的),可以使用 Valgrind。Valgrind是一个程序解释器,它模拟CPU来执行你的程序,虽然速度很慢,但可以检查每一次内存操作,给出深入的分析报告。它也可用于提供精确到指令级别的性能分析。
人工智能(如大语言模型)在某些调试场景中也极为有用,特别是在解释晦涩的错误信息、或追踪跨语言(如Python调用Rust再调用C)的复杂调用链中的问题时,LLM可以帮助定位问题根源,尽管它们可能不擅长直接修复。
性能分析入门:计时与监控
性能分析与调试密切相关,因为理解代码在做什么通常也能帮助你理解它的性能。我们首先需要讨论如何测量时间。
最基本的工具是Shell内置的 time 命令。它运行一个命令并报告三个时间:
- real:实际流逝的“墙钟时间”。
- user:程序在用户态消耗的CPU时间。
- sys:程序在内核态消耗的CPU时间。
由于可能存在多核并行,CPU时间可能大于实际时间。time 命令简单,但多次运行结果会有波动。hyperfine 工具通过多次运行命令并进行统计分析,可以更可靠地比较两个程序的性能,并给出置信区间。
有时,仅仅监控系统资源使用情况就能发现瓶颈。htop 是一个交互式进程查看器,可以显示CPU、内存使用率、负载平均值以及每个进程的详细信息。还有更专业的工具,如 iotop(磁盘I/O)、iftop(网络流量)、btop(功能更丰富的系统监控)等。
代码级性能剖析:perf与火焰图
当你需要深入代码内部,了解时间具体花在哪里时,需要使用剖析器。perf 是一个强大的Linux采样剖析器。它周期性地中断程序,记录当时CPU正在执行的函数,从而统计出各函数的“热度”。
使用 perf record -g ./slow_program 记录性能数据,然后使用 perf report 查看报告。报告会显示调用关系,以及每个函数自身(不包括其调用的子函数)消耗的时间百分比。你可以展开函数,甚至注解汇编代码,查看具体哪条指令耗时最多。
对于复杂的性能数据,人类难以解析。火焰图 提供了出色的可视化。通过 perf script 等命令生成数据,并用工具(如 inferno)处理,可以得到一个SVG格式的火焰图。图中,每个函数表示为一个横向的条,宽度代表消耗的时间,纵向表示调用栈。宽的“火焰”就是性能热点。火焰图是交互式的,可以在浏览器中点击缩放,非常适合定位性能瓶颈。


perf 是采样剖析器,信息可能不完整。如果需要极其精确的指令级计数,可以使用 Valgrind 的 callgrind 工具,但它会显著降低程序运行速度。


可视化与总结
性能分析不仅仅是测量运行时间,还包括理解程序输出或行为如何随输入变化。例如,你可能想了解一个字符串搜索程序的耗时如何随输入文件大小和模式长度变化。
将原始数据表格可视化至关重要。在终端中,gnuplot 可以快速绘制简单的图表。对于更复杂的分析,可以使用Python的 matplotlib 或R语言的 ggplot2。ggplot2 的“分面”功能尤其强大,可以轻松地根据数据的多个维度生成并排的图表网格,帮助可视化高维数据集。
最后要强调的是,性能分析的需求常常会回溯到日志记录。如果你在编写代码时就有远见地进行结构化日志记录,并记录下诸如输入大小、内部数据结构规模等关键信息,那么当你后续需要分析性能时,这些现成的日志就是宝贵的数据源,可以方便地提取并绘制成各种图表,从而揭示性能问题或扩展性瓶颈。
本节课中我们一起学习了调试与性能分析的核心思想与工具链:从基础的打印和日志,到交互式调试器(GDB/RR),再到系统级追踪(strace/eBPF)和自动化检查(消毒器/Valgrind);从简单的计时和资源监控,到深入的代码剖析(perf)和结果可视化。掌握这些技能将帮助你更高效地发现并解决程序中的问题,并构建出性能更优的软件。
005:核心概念与基础操作
在本节课中,我们将要学习版本控制系统,特别是Git。我们将从底层的数据模型开始,理解其核心思想,然后学习如何使用基本的Git命令来管理代码历史。这将帮助你摆脱对Git命令的盲目记忆,真正理解其工作原理。
什么是版本控制系统?
版本控制系统是一种用于跟踪源代码或其他任何文件及文件夹变更的软件。顾名思义,这些工具帮助你维护对软件所做更改的历史记录。此外,它们还促进了协作。例如,如果你使用像GitHub这样的开发平台,你将使用Git版本控制系统与该服务以及其他在同一代码库上工作的开发者进行交互。
从逻辑上讲,版本控制系统所做的是跟踪一个包含代码等文件的文件夹的变更。它将文件夹中的所有内容记录在一系列快照中。每个快照都封装了你正在跟踪的目录内文件或文件夹的完整状态。版本控制系统还维护元数据,例如谁创建了每个快照,以及你可能希望与代码更改相关联的任何消息,或许是为了解释你为何进行某些更改。

为什么版本控制有用?
即使你不与其他开发者协作,即使你只是在处理一个个人项目或实验作业,版本控制也能让你查看项目的旧快照,基本上是在你随时间更改代码时跟踪它。它还能启用其他功能,例如让你在并行的开发分支上工作。因此,如果你想拥有底层代码库,并先处理功能一,然后在功能一完成之前切换到处理功能二,也许注意到代码中的一个错误并想修复它,你可以在版本控制系统的帮助下完成所有这些,而无需在你完成之前让所有更改相互干扰。
这些工具提供了很多价值,即使你只是独自工作。如果你与其他开发者一起工作,版本控制系统基本上是必须使用的,除非你想通过来回发送压缩文件等方式,但这很快就会变得非常混乱。它是软件开发中与他人协作的标准工具。
现代版本控制系统让你可以做各种方便的事情,并回答那些原本难以回答的问题。例如,你可以查看特定的代码片段或模块,并询问“谁写了这个?”如果你想和他们谈谈。你可以查看特定的代码行,并询问“谁是最后一个修改这段代码的人?”“它是什么时候被更改的?”“为什么被更改?”你甚至可以用版本控制系统做更高级的事情。假设你一直在开发某个特定的软件数月之久,有一天你注意到软件有一个特定的错误,而且看起来这个错误最初并不存在,比如一年前你的软件能很好地完成这个特定的事情,然后在过去一年的某个时刻,你在代码中引入了一个错误。你会怎么做来试图找出这个错误是何时引入的,以及是哪个更改引入了它?版本控制系统可以为你自动化这个过程。你可以做一件非常酷的事情:编写一个测试,如果错误存在则测试失败,然后你的版本控制系统可以自动对你的整个版本历史进行二分搜索,以精确定位错误被引入的确切位置。
这些都是非常强大的工具,值得学习。在本讲座中,我们将教你版本控制系统的基本概念,特别是Git版本控制系统,因为它是当今事实上的版本控制系统。
Git的数据模型
现在,让我们深入探讨Git的数据模型。Git的巧妙之处在于它设计了一个经过深思熟虑的数据模型,该模型支持版本历史的所有这些优良特性,包括我们之前谈到的一些基础功能,如查看谁进行了特定更改、跟踪你的更改,以及我提到的一些高级功能。你可以维护历史、支持不同的开发分支、与其他开发者协作。
作为数据模型的起点,让我们谈谈快照。我使用这个术语来指代一个目录及其内部所有内容的状态,因此它是你计算机上文件和文件夹的层次结构。Git将历史中的特定时间点建模为文件和文件夹的集合,我称之为快照。在Git术语中,文件被称为“blob”,文件夹被称为“tree”。
一个快照的示例状态可能看起来像这样:你可能有一个快照的根目录,它是一个“tree”。在这个文件夹内部,我们可能有另一个“tree”,比如一个名为“foo”的“tree”。这个内部的“tree”可能包含一个文件,比如这里有一个名为“bar.txt”的文件。这是一个“blob”。这是一个“tree”,这是一个“tree”,然后也许这个顶层的“tree”内部有另一个文件,所以这个文件在顶层,而不是在内部。这大致封装了一个文件夹及其内部所有内容的状态,这就是我们在Git中建模的方式。
现在,在我们的版本控制系统中,我们如何将这些快照相互关联起来?这是一种在某个时间点的状态,现在我们想要维护历史。你可能会想到的一种方法是存储一系列快照,比如你可能拥有版本1、版本2、版本3等等,每个都是一个快照,你可以通过查看不同的快照来了解事物如何随时间变化。
出于一些原因,Git并不这样建模历史。相反,在Git中,历史是快照的有向无环图。如果你没有上过数据结构和算法课,别担心,我们会给你足够好的直觉,这并不复杂。这意味着在Git中,每个快照都引用一组父节点(即它之前的事物),我们将通过一个例子来讨论为什么是一组父节点而不是单个父节点。

假设我们在某个时间点拥有仓库状态的单个快照。我将画一个图,用圆圈表示快照。这就像一个包含一堆文件的文件夹及其状态,包括文件内容、文件夹名称等,在某个特定时间点。
假设我从那个状态开始,然后也许我对我的代码做了一些更改,我可能添加了一个功能,所以我有了一个新的快照,我的代码库的一个新状态,然后这个新快照实际上会指回先前的快照。也许我实现了一个错误修复,所以我创建了一个新的快照,它也会指回先前的快照。
然后假设在这一点上,我确定我想实现一个功能,但还有一个我想修复的错误,你可以想象基于同一个快照独立地做这两件事。所以也许我从这里分支出一个分支来修复一个错误并创建一个新的快照。但我从同一个起点开始,也许我在实现一个功能方面取得了一些进展,但还没有完全完成。也许我取得了更多进展,所以我拍摄了另一个快照,创建了另一个历史元素,它指回它来自的父节点。
到目前为止,我们已经展示了分支,但我们也可以讨论一个场景,即我们可能有一个具有多个父节点的快照。我们可能有这个错误修复分支,我在那里做了一个错误修复;还有这个功能分支,我最终完成了功能的实现;然后我想将这两组更改合并到一个统一的代码库中。所以我最终可能会创建一个单独的提交,它包含了来自这两个分支的更改,并引用它们两者作为父节点,因为它是从两者派生而来的。然后我在这里创建了一个所谓的“合并提交”。
因此,从逻辑上讲,这就是Git表示历史的方式:只是一堆通过这种父节点关系相关联的快照。
关于这一点,可能还需要知道的一个细节是,在Git的历史模型中,快照以及这些提交和它们之间的关系,所有这些都是一个不可变的数据结构。你可以添加内容,你可以创建一个提交并让它指向更早的东西,但你实际上不能修改这里面的东西。比如我切换到这里的这个提交,你实际上不能做那样的更改。也许思考这个的一个好方法是就像在现实生活中,你不能回到过去改变事物的历史。同样地,Git希望按原样记录历史,你实际上不能回去修改这个图中的东西,你只能添加内容。有一些方法可以处理各种类型的错误,比如如果你不小心在这里的代码中引入了一个错误,你想修复它,或者你做了一个后来实际上想撤销的提交,有一些方法可以实现相同的效果,而不会真正干扰这个不可变的或类似追加的历史模型。
深入数据模型:对象与引用
让我们再深入一层。看看当我们用更接近代码的形式写下这些概念时是什么样子,可能会很有启发性。让我们写下一些伪代码。让我们写下Git中的一些类型定义。
在Git中,文件(或Git术语中的“blob”)是什么?它只是一堆数据,是一个字节序列。我会写成类似“array of bytes”的形式。在你的计算机上,文件就是这样的,只是一些二进制数据。
然后我们有“tree”或目录。目录是什么?它们是将其内容名称映射到实际内容的数据结构。这里我说目录映射字符串。例如,在上面的顶层“tree”中,它将“foo”映射到包含在其中的“tree”,也将“bar.txt”映射到包含在其中的“blob”。因此,“tree”内部可以包含“tree”或“blob”作为元素,由这里的文件名标识。所以这是文件名。或者也许“name”更好,因为它也可以是“tree”。
现在我们有文件和文件夹,现在我们想要为我们的历史建模。为了更精确一点,当我说“blob”或“tree”时,我指的是这个递归数据结构中的元素类型。当我说“snapshot”(快照)时,我认为这不是标准的Git术语,我指的是对应于整个目录的顶层“tree”,我们想要跟踪该目录的历史。当我说“commit”(提交)时,我指的是该历史图中的节点。因此,图(即历史)由一堆通过这种父节点关系相关联的提交组成。
那么,什么是提交?它是一个包含几个不同元素的数据结构。继续我的伪代码,提交内部包含快照。这个的类型是什么?这是一个“tree”。然后提交还有父节点。这是一个列表……呃……提交的列表。所以这也是递归数据结构,指回其他提交。然后这里还有一些其他东西,比如你可能想要有一个作者姓名,你可能想要一个提交消息,你可以在其中放入诸如你在历史图中引入的特定提交中更改了什么内容。
到目前为止有问题吗?继续思考Git历史和所有内容,在伪代码层面的抽象中,Git引入了一个称为“object”(对象)的概念,它统一了“blob”、“tree”和“commit”。因此,“object”是“blob”、“tree”或“commit”。现在我们可以用一种统一的语言来指代所有这些不同类型的元素。然后,在Git中,Git将你的数据存储在一个对象存储中,其中数据通过其SHA-1哈希进行内容寻址。现在我们将使用我们刚刚在这里定义的类型,并说Git在一个从SHA-1哈希到对象实际内容的映射中跟踪你的对象。
那么,在Git中,东西实际上是如何从这个对象存储中存储和加载的呢?在伪代码中,如果我们想要存储一个对象,我们将首先将其ID计算为对象的SHA-1哈希。然后,将其存储到这个逻辑映射中。然后,如果我们想通过ID从这个对象存储中加载某些东西,我们只需查看对象存储并通过其ID获取它。
这里还有一个细节:“blob”、“tree”和“commit”以这种方式统一。它们都存储在这个内容寻址的对象存储中。在内存表示中,这些东西实际上并不包含其他东西。并不是说提交内部包含所有先前的提交,或者“tree”内部包含所有内部的“tree”。这里通过对象存储有一层间接性,所以你可以将这些视为指针。在这个图中,我会画一个星号,所以这是一个指向提交的指针,这是一个指向“tree”的指针,这是一个指向“tree”的指针,这是一个指向“blob”的指针。但从逻辑上讲,你不需要太担心这个。
现在我们已经介绍了这些概念,包括对象存储和Git中所有内容(从提交到“tree”到“blob”)都可以通过其SHA-1哈希来标识的想法,我们可以稍微充实一下这里的图片。我不会到处画哈希,但例如,这里的这个提交可能由一个提交对象标识,其哈希可能类似于十六进制……是的,通常在Git中,你将哈希写为40个字符长的十六进制字符串。所以这可能像“0fc2……37”这样。这是这个对象的哈希,然后它内部有内容,包括一个父指针。所以这里的这个提交可能将其内容的哈希作为……再次强调,哈希是对这些内容的哈希,对象的内容。所以也许这个以“2”开头,以“7”结尾。因此,这里的这个父指针实际上将是这个哈希值,所以这将是那个值。然后这个将有自己的数据。
现在我们有了我们的历史模型,包括文件系统内容的模型,并且我们有一种通过这些非常不方便的40个字符长的字符串来命名事物的方法。因此,Git引入的下一个概念帮助我们命名事物。40个字符长的十六进制字符串不是非常人类可读的,你也不想记住它们。所以Git引入了一个称为“references”(引用)的概念,它将人类可读的名称映射到SHA-1哈希。
问题是,什么是SHA-1?SHA-1是一种哈希函数,我今天无法详细讲解哈希函数是什么,但你可以将其视为……输入随机数据,但它是……坚持这一点。是的,所以SHA-1哈希接收一些数据(一个字节数组),并返回类似160位的数据。你可以将其视为一种确定性地将任意长度的数据映射到固定长度表示的方法,有点像随机但确定性的映射。也许稍微深入一点密码学理论,如果这有任何帮助的话,你可以将其视为存在一个称为随机预言机模型的东西,你可以将其视为假设有一个全局的对象及其哈希注册表,每当你计算SHA-1哈希时,你就在这个全局注册表中查找是否有人之前尝试过注册这个东西。如果没有人注册过它,那么它会被添加到注册表中,并与某个随机的160位值相关联。然后将来,任何人在这个全局共享的注册表中查找同一个对象时,都会得到相同的值。但在高层次上,它是一种以确定性的方式将任意大小的东西压缩成一个小表示的方法,使其有点随机性,并且碰撞有限。因此,如果你哈希两个不同的东西,你不太可能从哈希函数得到相同的输出。
回到引用,回想一下Git历史是不可变的,我们不能改变那个东西的任何内容,我们只能添加新东西。但与引用相关的是,你可以将对象存储视为你应该放入新东西,但你不去删除东西。另外,如果你考虑我对SHA-1的解释,即它如何确定性地将这些数据映射到短字符串或短字节数组,如果你考虑如果你要修改那个提交图中间的东西会发生什么,那么所有这些后面的节点都通过引用其内容的哈希来指向更早的节点。所以你改变了图中更早的东西,它的哈希也会改变,然后这些东西都不会起作用。
无论如何,这些东西都是不可变的,并且是追加的,或者这是一种很好的思考方式。而引用是你引入可变性的地方。因此,你可能想要有人类可读的名称,比如你可能想用“main”或“master”这样的名称来引用你正在处理的代码库的最新版本,这在Git术语中可能是一个标签或分支,它指向你的提交图中的特定提交。因为Git中的这个引用映射是可变的,你可以在继续开发软件时保持其更新。
在这个图中,我正在引入一种新类型的东西。这些是提交,这些是父节点关系,我在这里矩形中写的是一个指向这个提交的引用。这个想法是,当你开发软件时,也许你会添加一个新功能,现在你引入了一个新的提交。那个提交指回这个先前的提交,你可以更新“master”以指向你刚刚创建的这个最新提交。
当你使用Git时,有更高级的命令会为你处理很多这些事情,你不是像我在这里黑板上画的那样手动执行各个步骤。所以提交列表是不可变的,但指向这些提交的引用是可变的。是的,没错。将历史(即提交和父节点关系)视为不可变的,而你用来指代特定事物的名称(存储在引用数据结构中)是可变的。
暂存区:控制快照内容
在进入实际演示之前,我想解释另一个与这个核心数据模型正交但属于Git创建提交接口一部分的概念。到目前为止,我们还没有讨论如何实际创建这些东西,如何描述快照中包含什么。
因此,你可能会想象设计版本控制系统的一种方式是,你正在跟踪计算机上特定文件夹的更改,然后版本控制系统可能有一个命令来提交最新的更改,它只是获取当前状态的所有内容,然后说:“好的,我要拍一张照片,那就是我的新快照。”有一些版本控制系统是这样工作的,但Git不是。原因是,你可能有一个存放代码的文件夹,也许你进去添加了一个功能,然后在提交更改之前,你又进去添加了另一个功能,然后也许你修复了一个错误,然后也许你开始了一个新功能,然后你想:“哦,等等,我还没有提交任何更改。”如果你只是将事物的当前状态作为快照,那会有点混乱。所以Git让你对制作这些快照有更多的控制,因此它有一个称为“staging area”(暂存区)的概念,用于帮助向Git描述你想要包含在你创建的下一个提交中的内容。
我们将通过演示结合其他Git命令来查看暂存区的实际作用。
基础命令实战
现在,让我们开始在终端中输入一些代码。不出所料,如果你输入git,那就是Git命令行程序的接口。一个可能有用的命令是git help。Git的所有功能都是作为Git下的子命令实现的,比如git status、git init、git commit等等,我们稍后会详细讨论这些。还有一个git help,你可以通过这个界面获取Git子命令的帮助,它只是打开相应命令的手册页(man pages),我们在第一讲中介绍过。所以,如果你做git help help,例如,你会得到帮助命令的帮助页面;如果你做git help commit,你会得到提交命令的帮助页面。所以,如果你不想去谷歌或ChatGPT之类的地方,这真的很有用,是内联帮助。
好的,这里我有一个空目录,我称之为git-demo。空目录,里面什么都没有。如果我输入git init,我会看到它在这个目录中创建了一个空的Git仓库。所以如果我做另一个目录列表,我会看到有一个新的隐藏文件,一个以点开头的文件,叫做.git。因此,与此Git仓库对应的所有数据都存储在那里。如果我们稍微查看一下那里,如果我们做ls .git,我们会看到objects和refs文件夹,所以这实际上就是磁盘上的对象存储数据结构和引用。
一个有用的命令是git status,它告诉你发生了什么。我们在这里看到的是我们在master分支上,还没有任何提交。所以,就这个历史的图形视图而言,什么都没有,是一个空的世界。也没有什么可以提交的,我们还没有在这个目录中添加任何东西,什么都没有被暂存。然后我们看到“On branch master”。这是我的Git设置为将默认分支称为master。所以你可以大致认为有一个名称还没有指向任何东西,因为还没有任何历史。
让我们在这里创建一些内容,然后展示我们如何实际开始创建一些Git历史。我们没有任何内容,我们想创建一些内容,以便我们可以在历史中跟踪它。我不会在你面前现场编写一些随机代码,我只是要写一个带有某种填充文本的文本文件。我们将输入北约音标字母。
我有一个名为nato.txt的文件,里面有一些文本。如果我做git status,我会看到有东西改变了。现在我看到了未跟踪的文件。Git在说:“嘿,这个文件没有包含在上一个快照中。”在这种情况下,只是没有快照,但这个文件没有包含在上一个快照中,但嘿,我只是让你知道它是未跟踪的,所以你可以做点什么。我可以做git add nato.txt。这个命令的作用是暂存文件以准备提交。所以它说“Changes to be committed”。我的Git仓库历史(如图形视图)仍然是空的。有零个提交,但现在我已经说了:“好的,无论我什么时候进行下一次提交,我都想包含这个。”然后Git有一个叫做git commit的命令,它创建一个新的提交,它会弹出这个编辑器,并包含一些与提交一起的元数据,包括提交消息。所以你想输入好的提交消息。在这种情况下,我将其称为“initial commit”。我保存这个文件,然后Git查看该文件的内容并创建一个名为“initial commit”的新提交。
现在,我想我不会在今天的讲座中讨论如何编写好的提交消息,但我认为这是一个非常重要的话题,所以我们在今天的讲义中包括了一些资源的链接,也许约翰下周会谈到它。好的,我已经创建了我的第一个提交,现在我想看看这里发生了什么。我可以做git status,它会说一些稍微不同的东西:“nothing to commit, working directory clean”。但我们注意到“No commits yet”消息消失了。
有几个命令可以用来检查你的历史状态,这些命令非常方便。有一个叫做git log的命令,它给你一个历史的扁平化视图,大致上是将其线性化并按线性顺序打印出来。这个命令有几个选项,比如--graph选项,它显示一个图,当我只有一个提交时看起来不那么有趣,但我们稍后再看这个,会看到一个稍微高级一点的图。
我们甚至可以稍微查看一下数据模型的底层,如果我们想将其与数据模型联系起来,查看git cat-file命令可能会有所帮助。我认为在常规使用中,你不太可能使用这个命令,但这让你可以稍微深入Git的底层。这个命令让你可以按名称查看对象存储中的对象(它会为你进行引用查找),或者你可以直接输入SHA-1哈希。它有几个标志,-t和-p,用于查看类型或漂亮地打印文件。所以我可以问master引用的对象类型是什么,我说好的,这是一个提交,它指的是上面这个相同的提交。如果我做git cat-file -p,这次我直接输入提交哈希(再次回想,Git中的所有对象都由其对象哈希标识),我可以看到与提交对应的数据。所以我在那里高亮显示的内容对应于这里的数据结构。所以我们可以在这里看到它有一个指向“tree”的指针,那是这个快照的内容,以及一些元数据和我的提交消息。这里没有父节点,因为这是第一个提交。但如果我对这个东西做git cat-file -t,我会看到一个“tree”。git cat-file -p这个东西,我会看到有一个包含单个条目的“tree”,那个从字符串到东西的映射,那个条目是这个名称和这个哈希。而且方便的是,Git告诉我它所指的东西是一个“blob”。如果我做git cat-file -p这个哈希,我会看到那个快照中文件的内容,在这种情况下,实际上与我工作目录中nato.txt的内容相同。
那么,人们看到我如何将这些Git命令与底层数据模型联系起来了吗?是的,问题?你能定义一下什么是分支吗?是的,所以分支是一种特殊类型的引用,你可以附加到它上面,并且当你进行提交时,它会随着你移动。所以在这里,如果我们查看git log,我们有一个单独的提交,我们有指向这个提交的master分支。如果我对我的文件做一些更改……不要写像这样的糟糕提交消息。这里我添加了我的更改内容,-m让你在命令行上提供提交消息。现在如果我查看git log,我有我原始的提交,那个初始提交,然后是我刚刚添加了一行文本的新提交。我看到master更新了。所以我附加到了这个master分支,我在git status中看到它说“On branch master”,然后当我做git commit时,它会为我更新那个引用。所以回想一下我之前在谈论如何从底层数据模型的操作角度思考事物,但通常我们不会对这个东西进行原子更改。Git会为我们组合这些东西。你可以使用git commit命令在这个图中创建一个新节点并移动这个引用。
是的,直观上,感觉分支应该指的是……嗯……历史中的一系列节点,就像你可以放置的线性窗口。但是,是的,所以你指出,直观上你可能认为分支指的是整个谱系之类的,但在Git中,分支实际上只是一个指向提交的指针,这是对的。有一些其他版本控制系统具有分支的概念,其工作方式略有不同,更接近你对实际跟踪谱系的直觉,但Git不是这样工作的。所以分支只是指向提交的指针,你可以随意移动它们。如果你这里有一个分支,你只是想把它移到这里,你完全可以在Git中做到。所以它们并不真正特殊,除了指向提交之外,它们不跟踪谱系,而提交跟踪谱系。
目前还有其他问题吗?master总是自动更新指向最新的提交吗?不,不是的。master是或曾经是当你创建新的Git仓库时默认分支的默认名称。基本上所有分支都以这种方式工作:如果你附加到一个分支并进行提交,无论你在哪个分支上,那个分支都会被更新。所以如果我在这里创建一个新的分支,如果我做git branch anish,那会在我所在的位置创建一个新的分支。然后我做git switch anish,我做git status,它会说“On branch anish”。然后假设我修改这个文件以添加一个新行。我查看我的Git历史。在这个--graph视图中,它也显示了一些分支的位置。master仍然在它之前的位置,因为我切换到了anish分支,然后进行了最新的提交。所以这个分支更新到了新的提交,但master停留在原地,因为我在进行提交时不在master上。
如果你在master上……是的,所以问题是如果你在master分支上,那么master会……让我git switch master。顺便说一下,这会改变我的工作目录内容,所以如果我输入nato.txt,“hotel”现在不见了,因为我移回了这里。所以快照是它自己的东西,但这个git switch命令将工作目录更改为匹配我刚切换到的这个分支。所以绝对正确,如果我修改这个东西,让我修改这个以在这里添加“alpha”。然后做git commit。-a标志提交所有东西。我将开始给出越来越差的提交消息以加快速度。现在如果我查看Git历史,我也可以添加--all标志。如果我们不添加--all,它只显示你所在分支的递归历史,所以它不显示当前不可达的节点。但如果你做--all,它会显示所有分支可达的所有东西。是的,所以现在你看到我在这里。你问如果我切换到master并进行提交会发生什么。所以我做了这个新的提交,master更新了,但这个提交指回这个作为它的父节点。所以这是没有“hotel”或“alpha”的那个,这是我添加了“hotel”的那个,这是我添加了“alpha”的那个。所以现在如果我查看我的文件,在这个中我有“alpha”到“golf”。如果我做git switch anish,我查看这个,我有“bravo”到“hotel”。
现在,既然我们已经进入了一个分叉历史的情况,我们有两个不同的分支或两个不同的提交,其中任何一个都不是另一个的父节点,我们可以讨论合并。所以如果我们,假设我们切换到master分支,我们可以做git merge anish。它会进入一个状态,创建一个新的提交,弹出这个东西,要求我写提交消息。在这种情况下,我会保存并退出。在这种情况下,我们看到它说“Auto-merging nato.txt”。所以在这种情况下,因为我做了一个更改,在顶部添加了“alpha”,另一个更改在底部添加了“hotel”,并且中间的内容相同,差异和合并算法足够聪明,能够弄清楚我们实际上想要合并这些东西,我们没有遇到合并冲突。所以现在如果我查看历史,我应该看到我的前三个提交1、2、3,然后是这个提交。或者抱歉,前两个提交1、2,然后是这个提交,这个有更早的那个作为父节点的另一个提交,最后是我刚刚创建的这个新的合并提交,它将anish分支合并到master中。现在如果我查看nato.txt的内容,我有在一个分支中添加的“alpha”和在另一个分支中添加的“hotel”。
然后,既然……哎呀……既然master包含了所有历史,包括这个东西,这个东西已经被合并进来了,我不再需要这个名字了。所以我可以继续做git branch -d anish。让我做git branch,它会显示列表……哎呀,git branch。好的,第三次了。我会看到我有两个分支。我在master分支上,这就是为什么它有这个星号和绿色。我可以做git branch -d anish,它说删除了这个分支。所以现在如果我查看版本历史,图看起来是一样的,因为所有这些都是从这个master东西可达的。但是与这个提交相关联的这个名称消失了,因为我不再需要这个名字了。
所以再次强调,这传达了我可以删除一个分支,而历史仍然在那里。分支和跟踪谱系在Git中是不同的概念。是的,问题?一个提交就像我们花了很多时间讨论的那个数据结构,而一个分支是一个引用。它是一个提交的名称或SHA-1哈希的名称。提交是不可变的,分支是可变的。
问题?与其git switch master然后git merge anish,是否可能……是的,所以问题是,与其将anish合并到master,我们是否可以将master合并到anish?是的,当然可以。master并不特殊,它只是一个名称与另一个名称的区别。
问题?让我们试试。所以master分支正在使用中,那是我当前签出的那个,这就是这个“HEAD pointing to master”的意思,是的,这就是我在当前目录中签出的内容。但如果我做一些像git branch main然后git switch main的事情,我可以做git branch -d master,现在我没有master了。
目前还有其他问题吗?是的,问题是:我能恢复它吗?好的,这就像相当高级的Git,如果你一切都做对了,你永远不需要这个工具。但有一个叫做git reflog的工具。基本上,Git维护了一堆额外的状态,包括你的引用历史。所以引用是可变的,因此维护它们发生了什么的历史通常很有帮助。比如,如果你将一个分支从指向一个东西移动到指向另一个东西,然后你想:“哎呀,我不想那样做。”你怎么回去?你只需更改那个东西。这就是为什么Git维护一个历史。所以reflog就像引用日志。然后Git中的另一个东西……再次强调,Git只有两样东西:引用和对象。对象是不可变的。那么,当你创建越来越多的历史并移动分支时,Git如何决定保留什么?将活动历史视为从所有引用的并集可达的图。所以如果我创建,比如说,实际上已经在这个图中,这个节点不可达任何引用。所以当你最终在对象存储中有类似孤儿的东西时,Git会自动垃圾回收它。如果你说,比如我创建了一个提交,我删除了分支或什么,我可以查看我的reflog并找到那个提交,那个提交仍然会在我的对象存储中。但如果我使用Git很长时间,然后一年后我想:“哎呀,我实际上想要那个很久以前删除的旧东西”,它可能已经消失了。
问题:当你分支main时,你如何指定main应该指向什么?这是一个好问题。当我做git branch main时,我如何指定main应该指向什么?所以没有任何额外参数,main指向我当前所在的位置,也就是HEAD,在这种情况下也与master相同。你也可以为分支指定一个引用或SHA-1哈希。所以,如果我再看看我的图……这里是我最初添加更多东西的地方,所以如果我做git branch old这个……这会做什么?这将创建一个名为old的新分支,最初指向这里。所以如果我查看这个图,我看到这个old已经被创建了。

有一个main或master分支是约定吗?是的,所以问题是,有一个main或master分支是约定吗?是的,答案是肯定的。比如,通常你希望人们拥有他们代码的最新版本是main或master。在旧版本的Git中,它曾经是master,现在我认为默认是un或main,我不完全确定,我认为GitHub现在使用main作为默认值。但是,是的,比如你希望main在某种程度上是特殊的,所以你可能有那个作为你的代码的主要版本。然后当你实现功能时,你可能会创建所谓的功能分支,在这些功能分支中进行更改,然后将它们合并回main。还有其他更复杂的工作流程,比如你可能在维护一个网站,你有网站的生产版本(真实用户使用的主要实时版本),然后你可能有一个用于测试的暂存版本。所以你可能有用于生产的main分支,用于暂存的staging分支,然后你可能有一堆用于其他正在开发的功能和错误修复的功能分支和错误修复分支。也许你会先将这些东西合并到staging,测试它们,如果staging看起来状态良好,你可能会将其合并到main。所以有一些相当复杂的分支和合并工作流程,我认为我们在讲义中链接了其中一些。我们对待main的方式只是约定。是的,没错,我们对待main的方式只是约定,如果我们愿意,我们可以称它为issue或two。
Git远程仓库与协作

如果你想再待五分钟左右,我可以向你展示如何使用Git远程仓库,然后我们可以在那里结束。如果你必须离开,请随意离开,但我认为这可能是一个很好的结束话题。到目前为止,我们已经讨论了如何自己使用Git进行软件开发,介绍了一些基本概念,如创建版本历史和来回切换。我认为我们没有展示的一件事是git checkout。所以,如果你想将工作目录的内容更改为较旧的提交,你可以做类似git checkout old或git switch old的事情。在这种情况下,git switch old,它会切换到那个分支并检出那里的内容。所以我cat nato.txt,它是在我添加了所有后来添加的新东西之前。由于这里有这个main,我仍然有后来的历史。我甚至可以检出特定的提交,比如我可以做git checkout,然后输入提交哈希,甚至是提交哈希的前缀,只要它是明确的。现在我有我的非常短的nato.txt内容,我回到了这里。如果我查看图,我看到这个大写的HEAD(Git用蓝色渲染的特殊引用)指回这里。然后我有所有更新的历史在上面。如果我做git status,我会看到Git处于这种分离的HEAD状态。这意味着当前没有活动分支。所以如果我进行新的提交,我不再有这个很好的属性,即我有一个命名的分支随着我更改代码而前进。


因此,长时间保持在这种分离的HEAD状态相对不常见。通常你在某个分支上进行……哎呀……对你的代码进行更改。好的,所以Git远程仓库。到目前为止,我们讨论了如何自己使用Git。你可以用Git做的一件非常强大的事情是与其他人员一起工作。是的,问题?main直接指向一个提交而不是一个分支?是的,这是一种很好的思考方式。分离的HEAD意味着HEAD直接指向一个提交而不是一个分支。如果你处于分离的HEAD状态,所以我会做git……我不知道git switch是否适用于这个……是的,git checkout这个。所以我处于分离的HEAD状态,我可以修改这个nato.txt,创建一个提交。它实际上会创建提交,但这个提交有点像这个孤儿的东西,只有HEAD指向这个提交,没有分支。所以如果我切换回main,例如,看到它说警告你留下了一个提交,它没有连接到任何分支。所以这实际上此刻仍然在我的对象存储中,但它最终会垃圾回收它,因为没有分支指向它或它的任何后继。
好的,所以Git远程仓库,我们讨论过……是的,本地使用Git,但你也可以使用Git仓库与其他人员协作。一种常见的方法是使用GitHub。所以现在我在展示……登录到我的GitHub账户,你可以将GitHub仓库视为……GitHub仓库并不真正特殊。Git是一个分布式版本控制系统,你拥有Git仓库的副本,它可以连接到任意数量的其他Git仓库(称为远程仓库),然后你可以在它们之间双向交换信息。所以你可以本地向历史添加内容并将其推送到某个远程,其他人可以对远程进行更改,你可以将其拉入你自己的本地仓库。所以我可以……在GitHub上创建一个新的仓库。让它空白,等等,也许我应该将其设为私有。
所以我在GitHub上创建了一个空仓库。这有点像其他人……GitHub刚刚做了一个git init,他们没有历史,但我有我的仓库并在上面工作,我有一堆历史,现在我可以将它们连接起来。有一种方法可以告诉Git你想添加一个新的远程。你可以从GitHub复制粘贴这个,或者你可以学习如何使用这个git remote命令。所以有一个git remote子命令,它有一个add子命令,你可以命名每个远程,因为你可以有多个。然后我认为我们没有时间深入了解与GitHub身份验证的具体工作原理,但如果你感兴趣,可以阅读他们的文档或讲义。但一旦我们这样做,我们就可以开始向这个远程发送和接收数据。有一个git push命令,可以将我的本地更改发送到远程。所以我可以做git push -u main。我会解释-u的含义。这个命令的作用是推送到远程origin,我的main分支的内容。如果远程上不存在main分支,它会创建一个,并设置它使其匹配我的本地main。-u使其成为一个跟踪分支,所以当我在本地进行提交时,我可以只做另一个git push而不需要任何额外参数,它会知道我在的当前分支main对应于另一端的origin/main。在另一端,这里的origin/main是什么?是的。


所以现在我已经做了这个git push,如果我转到GitHub的Web界面,我可以看到我的nato.txt在这里,带有所有最新的内容。
然后,为了演示其他几件事,假设我是其他人,我将在同一台计算机上演示,当然,比如转到我的桌面文件夹,其他人可以获取Git仓库并克隆它。git clone命令获取一个现有的远程并给你一个本地副本。我可以进入这个并在这里做一些更改。所以我只是非常快速地添加一些内容,做一些虚拟提交。现在如果我做git status,我会看到“On branch main, your branch is ahead of origin/main by 1 commit”。记住我现在就像玩家二,在我的桌面上。如果我在这里做一个git push而不带任何额外参数,它会将我的本地更改发送到远程。
如果我回到我之前打开的窗口,我在这里做git status,我cat nato.txt,什么都没有改变,因为我还没有从这台计算机上的远程拉取任何内容。但如果我在这里做git pull,或者实际上也许我可以先做git fetch。有一些密切相关的命令。git fetch从远程接收数据,但实际上不更改你的任何本地引用。如果你做git pull,它实际上会更新你的本地引用。由于这个分支正在跟踪origin/main,并且origin/main已经更新,git pull会执行所谓的……在这种情况下是快进。现在如果我查看nato.txt,我看到我刚添加的“ASDF”。所以我已经展示了端到端:好的,我是一个人,我对代码做了一些更改;我是另一个人,我可以拉入那些更改。这些只是基础,但你可以在脑海中想象这些东西如何映射到底层数据模型的更改。基本上每个人都有不同的视图,这个历史的不同切片,你正在共享更改或对这个历史的更新,这以一种非常清晰的方式实现了协作。
然后,也许展示提交历史也在GitHub上会很有用。哦,是的,约翰指出展示提交历史也在GitHub上可能会很有帮助。是的,这是一个相当高级、相当复杂的产品。它拥有的一个不错的功能是让你可视化提交历史。所以我们也可以去……不同的仓库,比如Missing Semester仓库。是的,顺便说一下,所有讲义和本课程网站的所有内容都在一个开源脚本仓库中,所以如果你感兴趣,你可以去那里看看。如果我们查看那里的提交历史,比如我们可以看到约翰今天在讲座期间刚刚做了一些更改,我今天早些时候做了一些更改,等等。所以,当你与他人协作时,这里有一个非常强大的Web界面,肯定会派上用场。

是的,问题?当你将main推送到origin时,main是只包括它指向的提交还是包括所有东西?这是一个很好的问题。所以就像当你将main推送到远程origin时,它是只包括main指向的提交,比如只包括这个,还是包括整个历史?答案是后者。当你共享东西时,它总是包括导致该点的所有历史。Git确实有一些特殊的命令,如果你想要截断这些东西并只
006:代码打包与分发 🚢
在本节课中,我们将要学习如何将代码打包并分发给他人使用。这不仅仅是复制文件那么简单,因为代码的运行依赖于编程语言运行时、操作系统特性以及其他库。我们将探讨如何管理这些依赖、创建可复制的环境、构建可分发的软件包,以及使用容器技术来封装整个应用。


理解依赖关系

上一节我们介绍了打包的基本概念,本节中我们来看看代码运行的基础:依赖关系。你的代码通常不仅依赖于你编写的文件,还隐式地依赖于许多外部库和系统组件。
例如,考虑以下简单的 Python 程序:
import requests
response = requests.get("https://missing.csail.mit.edu")
print(response.status_code)
如果你尝试运行它,可能会遇到错误,因为 requests 库不是 Python 默认安装的一部分。你需要先安装它。
在 Python 中,默认的包管理工具是 pip。你可以使用以下命令安装 requests:
pip install requests
这个过程会从 Python 包索引(PyPI)下载 requests 及其所有依赖项,并将它们安装到你的文件系统中。之后,程序就能成功运行了。
不同的编程语言有不同的包管理工具和哲学。例如,Rust 使用 cargo,它是一个统一的工具链,负责编译、打包和发布。而 LaTeX 则通常将所有包与语言运行时捆绑在一起分发。
管理依赖冲突与环境隔离
随着项目依赖的增加,你可能会遇到依赖冲突。例如,两个库可能要求同一个依赖的不同、不兼容的版本。
以下是解决依赖冲突的方法:
- 创建虚拟环境:虚拟环境为每个项目提供了一个独立的 Python 运行时副本,隔离了依赖。
在 Python 中,你可以这样创建和激活虚拟环境:
python -m venv myenv
source myenv/bin/activate # 在 Linux/macOS 上
# 或
myenv\Scripts\activate # 在 Windows 上
激活后,pip 安装的包将只存在于这个独立环境中,不会影响系统全局或其他项目。这是一种避免“依赖地狱”的好实践。

除了 pip,还有更现代、更快的工具如 uv。uv 不仅能更快地安装包和解析依赖,还能方便地创建和管理虚拟环境:
uv venv myenv
source myenv/bin/activate
uv pip install requests

另一个常见需求是隔离编程语言本身的版本。例如,你的项目可能需要 Python 3.12,而系统默认是 3.11。使用 uv 可以轻松指定版本:
uv venv --python 3.12 myenv-3.12

打包自己的代码


上一节我们学习了如何安装他人的包,本节中我们来看看如何将自己的代码打包,让他人能够安装。



假设你有一个简单的 Python 文件 greeting.py:
def hello():
print("Hello, world!")
在同一个目录下,你可以通过 from greeting import hello 来使用它。但如果你切换到其他目录,Python 就找不到这个模块了。为了让代码可安装,你需要遵循 Python 的打包规则。


以下是创建一个可安装包所需的核心文件:
1. 项目代码 (greeting.py):
import typer
app = typer.Typer()
@app.command()
def hello():
print("Hello, world!")
if __name__ == "__main__":
app()
2. 项目配置文件 (pyproject.toml):
[project]
name = "greeting"
version = "2.1.0"
dependencies = ["typer>=0.9.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project.scripts]
greet = "greeting:app"
这个文件定义了包的元数据、依赖项、构建系统,并声明了一个名为 greet 的命令行工具入口点。


配置完成后,你可以在项目目录下使用 uv pip install . 进行本地安装。安装后,不仅可以在任何地方通过 import greeting 使用该库,还可以直接在命令行运行 greet 命令。

构建可分发的制品
直接从源代码安装要求用户拥有完整的构建工具链。更常见的分发方式是提供预构建的制品,例如二进制文件或打包好的归档。


对于 Python,标准的预构建格式是 wheel 文件(.whl)。你可以使用构建工具来生成它:
uv build
这个命令会生成一个 .whl 文件。这个文件本质上是一个包含你的代码和元数据的 ZIP 归档。其他人只需这一个文件,就可以用 uv pip install greeting-2.1.0-py3-none-any.whl 来安装你的包,无需访问源代码。
对于其他语言(如 Go),你通常会为不同操作系统(Windows、macOS、Linux)和 CPU 架构(x86-64、ARM)编译多个独立的二进制文件,用户直接下载对应版本即可运行。
版本管理与语义化版本
管理依赖时,你需要指定它们兼容的版本范围。常见的做法是遵循语义化版本规范。
语义化版本号格式为:主版本号.次版本号.修订号
- 主版本号:做了不兼容的 API 修改。
- 次版本号:做了向下兼容的功能性新增。
- 修订号:做了向下兼容的问题修正。
在 pyproject.toml 中,你可以这样指定依赖版本:
dependencies = [
"typer>=0.9.0, <2.0.0", # 大于等于0.9.0,但小于2.0.0
"requests==2.31.0", # 严格等于2.31.0
]
对于库项目,通常指定较宽的版本范围(如 typer>=0.9.0),以避免给下游用户带来不必要的依赖冲突。对于应用项目,则倾向于锁定所有依赖的确切版本(使用 uv pip compile 生成 requirements.txt 或 uv.lock 文件),以确保部署环境完全一致、可重现。
使用容器封装应用
当你的应用依赖扩展到编程语言环境之外(如特定的系统库、编译器、GPU驱动)时,环境管理变得异常复杂。容器技术(如 Docker)通过封装整个应用及其运行环境(文件系统、库、配置)来解决这个问题。
容器与虚拟机的区别在于,容器共享主机操作系统的内核,只隔离用户空间,因此更加轻量高效。
创建 Docker 镜像
你需要编写一个 Dockerfile 来定义如何构建镜像:
# 使用官方 Python 镜像作为基础
FROM python:3.14
# 安装构建工具和包管理器
RUN apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/*
RUN pip install uv
# 将应用代码复制到容器中
COPY . /app
WORKDIR /app
# 安装应用依赖
RUN uv pip install .
# 定义容器启动时执行的命令
CMD ["greet"]
然后,使用以下命令构建并运行镜像:
docker build -t greeting:latest .
docker run greeting:latest
优化后的 Dockerfile 会合并命令、清理缓存,以生成更小的镜像。
管理多服务应用
现代应用通常由多个服务组成(如 Web 应用和数据库)。Docker Compose 允许你使用一个 docker-compose.yml 文件来定义和运行多个容器:
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgres://db:5432
depends_on:
- db
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=secret
运行 docker compose up 即可启动所有定义的服务。
为了让容器化应用随系统启动,你可以将其配置为系统服务(例如,在 Linux 上使用 systemd)。




发布你的制品
最后,你需要将打包好的制品发布到公共或私有的仓库,以便他人获取。
- Python 包:可以使用
uv publish将 wheel 文件发布到 PyPI(或其测试环境 TestPyPI)。 - Docker 镜像:需要先使用你的用户名标记镜像,然后推送到 Docker Hub 或其他容器仓库:
之后,任何人都可以通过docker build -t yourusername/greeting:latest . docker push yourusername/greeting:latestdocker pull yourusername/greeting来获取并运行你的应用。



本节课中我们一起学习了软件打包与分发的核心概念。我们从理解依赖关系开始,探讨了如何通过虚拟环境隔离依赖、如何将自己的代码打包成可安装的库或应用、如何使用语义化版本管理依赖兼容性,最后深入了解了如何使用 Docker 容器技术来封装复杂的应用环境以实现一致的分发和部署。掌握这些技能,将使你能够更可靠地构建、分享和运行软件。
007:智能体编码 🧠
在本节课中,我们将学习一种新兴且强大的AI辅助编程形式——编码智能体。我们将了解它们是什么、如何工作,并通过实际演示探索其核心用例和高级功能。
上一节我们介绍了AI辅助编码的基础形式,如自动补全和内联聊天。本节中,我们将深入探讨一种更复杂、更强大的新范式。
智能体编码概述
编码智能体本质上是一个具备工具调用能力的对话式AI模型。与ChatGPT等纯聊天模型不同,智能体可以执行诸如读写文件、运行Shell命令等操作,从而在您的计算机上实际编写和修改代码。它们通常经过专门调优,以更有效地完成软件开发任务。
智能体如何工作

要有效使用编码智能体,理解其底层工作原理很有帮助。它们主要由两部分构成:
-
底层语言模型:可以将其视为一个对完成字符串进行建模的概率分布。给定一个提示字符串
x,模型会输出一个可能的完成字符串y的概率分布。我们用公式表示为:y ~ π_θ(y | x)其中
π_θ是参数化的条件概率分布。在实际使用中,我们从这个分布中采样得到输出ŷ。 -
智能体框架:这是包裹在语言模型外层的代码,负责管理交互循环。其工作流程如下:
- 接收用户输入。
- 循环调用语言模型。
- 如果模型输出工具调用请求(如运行命令、编辑文件),则执行该工具。
- 将工具执行的结果作为新的输入反馈给语言模型。
- 重复此过程,直到模型输出面向用户的最终文本。
这种设计使得智能体能够通过“思考-行动-观察”的循环来完成任务。
核心用例演示

让我们通过一些具体场景来看看编码智能体的实际应用。
1. 实现新功能
假设我们有一个简单的Python脚本 dl.py,用于下载网页并提取链接。我们想将其改进为一个更正式的命令行程序。
我们可以直接向智能体发出自然语言指令:
将我编写的脚本 dl.py 改造成一个合适的命令行程序。使用 argparse 进行参数解析,确保它有正确的类型注解,并用 mypy 检查类型。
智能体会自动读取文件、修改代码、运行类型检查,并最终完成整个任务。

2. 修复代码问题
当代码存在编译错误、类型检查问题或测试失败时,编码智能体是非常有效的调试工具。
以下是使用智能体修复Bug的典型流程:
- 为Bug编写一个重现该问题的单元测试。
- 启动智能体并告知:“这里有一个Bug,我为此编写了测试。你可以用
[运行测试的命令]来复现问题。请修复这个Bug。” - 智能体会运行测试、阅读相关代码、定位问题、实施修复,并重新运行测试以确保通过。
3. 代码审查与重构
智能体擅长理解代码逻辑和差异对比,可用于代码审查。
- 你可以将代码片段或Pull Request链接提供给智能体,让其进行审查。
- 在进行性能优化或重构时,可以要求智能体检查修改前后的代码是否在语义上等价,这有助于发现细微的Bug。
4. 探索与理解代码库
面对一个新的或大型代码库时,智能体可以帮助你快速导航和理解。
- 你可以直接询问:“请向我解释这个代码库的结构。”
- 或者更具体地:“我需要实现XX功能,应该修改哪些文件?”


5. 作为智能Shell使用



你甚至可以将编码智能体当作一个能理解自然语言的Shell来使用。
例如,无需记忆复杂的命令标志,你可以直接说:
使用 ag 命令找出本目录下所有进行了重命名导入(`import ... as ...`)的Python文件。
智能体会生成并执行正确的命令,然后为你总结结果。
高级功能与技巧
为了更高效地使用编码智能体,以下是一些高级概念和功能。
上下文管理
语言模型有固定的上下文窗口限制。智能体框架提供了多种工具来管理上下文,以保持模型的高效运行。
- 清除上下文:开始一个不相关的新任务时,可以清除之前的对话历史。
- 回滚对话:如果智能体执行了错误操作,你可以回退到之前的某个时间点,从那里重新开始,避免错误信息污染上下文。
- 上下文压缩:当对话历史过长时,可以调用压缩功能。智能体会使用LLM将之前的对话摘要成一个更短的版本,从而腾出上下文空间。
- 代理文件:在项目根目录创建
agents.md(或claude.md)文件。智能体启动时会自动读取此文件内容作为初始上下文,非常适合存放项目特定的开发指南、常用命令等。 - 技能:作为
agents.md的进阶,你可以将详细的指导信息放在独立的文件中,只在智能体需要时通过工具调用来加载,避免一次性占用过多上下文。
提升效率
- 可复用提示:对于经常需要执行的特定类型任务(如按特定规范进行代码审查),可以将其保存为可复用的提示模板。
- 并行智能体:复杂的任务可能耗时数分钟甚至更久。你可以利用
git worktree创建代码库的多个独立副本,然后在每个副本中运行不同的智能体并行处理多个任务,最后再合并结果。 - 子智能体:主智能体可以将子任务委托给专门的子智能体执行。子智能体完成任务后,仅将关键结果返回给主智能体,这有助于保持主上下文的简洁。一些工具(如网页抓取)本身就是以子智能体形式实现的。
外部集成与最新信息
- 模型上下文协议:这是一个连接智能体框架与外部工具(如Notion、数据库)的标准化协议,极大地扩展了智能体的能力边界。
- 获取最新信息:语言模型的知识存在截止日期。要让智能体使用最新的库或信息,可以命令它通过网页抓取工具获取相关文档(如
LM.text格式的文件),并将其纳入上下文。


注意事项与局限性
尽管编码智能体非常强大,但必须清醒认识其局限性:
- AI并非魔法:底层语言模型是概率模型,会犯错误。它们生成的代码可能看起来合理,但存在逻辑或安全漏洞。
- 需要监督:务必审查智能体生成的所有代码。验证错误代码有时比自己编写正确代码更耗时。
- 可能“失控”:智能体可能陷入调试循环、固执己见或执行无意义的操作,需要用户及时干预。
- 隐私与成本:使用云端模型服务意味着代码数据会被发送到提供商。此外,根据使用量会产生相应费用,需注意成本控制。
- 辅助而非替代:编程和计算机科学技能仍然至关重要。智能体是强大的辅助工具,但目前远未达到使程序员过时的程度。

总结
本节课我们一起深入探讨了编码智能体。我们了解了其核心架构(语言模型 + 智能体框架),演示了它在功能实现、Debug、代码审查、项目探索等多个场景下的强大应用,并学习了一系列提升使用效率的高级技巧和上下文管理方法。请记住,这是一个需要实践的工具,我们鼓励你通过练习来掌握它,但同时务必保持批判性思维,仔细审查其输出。
编码智能体代表了AI辅助编程的前沿,熟练运用它将能显著提升你的开发效率与探索能力。
008:代码之外
在本节课中,我们将要学习软件工程中那些不直接涉及编码,但对职业发展至关重要的“软技能”。这些技能包括如何与他人协作、如何与大型代码库互动、如何有效学习以及如何在开源或团队环境中工作。我们将探讨单向沟通(如代码注释、README、提交信息)和双向沟通(如提交Bug报告、代码审查)的最佳实践,并讨论在AI时代下工程师应有的职业素养。
单向沟通:为未来的工程师留下信息
上一节我们介绍了工程师沟通的两个主要类别。本节中,我们来看看第一种:单向沟通。这是指你为其他工程师(包括未来的自己)编写信息,但并非实时对话。代码注释、README文件和Git提交信息是其主要载体。
代码注释:超越“是什么”,解释“为什么”
代码注释是向未来查看代码的工程师传递信息的主要方式。然而,许多注释只是重复代码本身的功能,这是最无用的注释,因为代码就在那里。真正有价值的注释应该解释那些无法从代码中轻易推断出的信息。
以下是几种有价值的注释类型:
- 待办事项(TODO):在代码中直接标记需要完成或修改的地方。例如:
// TODO: 优化此处的数据库查询性能。 - 引用来源(References):当你实现来自论文、博客或教科书的算法时,应注明出处。这有助于他人理解算法原理和你选择它的原因。
- 解释“为何不”(Why Nots):解释为什么没有采用看似更直接的方法。例如,你可能没有使用标准库的哈希映射,而是用了自定义实现。注释应说明原因(例如,标准库实现在多线程环境下有性能问题),以防止他人重蹈覆辙。
README文件:项目的门面
大多数项目都有一个README文件,通常使用Markdown格式。它是项目的“门面”,应简洁明了地传达关键信息。
编写README通常遵循一个三步流程:
- 这是什么? 清晰说明项目的目的和功能。
- 我为何要关心? 解释项目的价值、独特性或有趣之处。
- 如何使用? 提供快速上手指南,包括安装和基本用法。
保持README简洁至关重要,使其成为可快速浏览的有效参考。其他详细信息(如贡献指南、行为准则)应放在独立的文件中(如CONTRIBUTING.md)。
Git提交信息:记录代码的演变历史
在版本控制讲座中,我们学习了Git的用法,但未深入探讨如何撰写好的提交信息。提交信息不应只描述“更改了什么”(这从git diff中显而易见),而应解释“为何进行此更改”以及“为何以这种方式进行”。
一份好的提交信息应回答以下问题:
- 是什么问题促使了这次更改?
- 你考虑了哪些替代方案?为什么选择了当前方案?
- 这次更改带来了哪些权衡或影响?(例如,优化了运行时性能但增加了构建时间)
随着更改复杂度的增加,应将大的改动拆分为多个逻辑独立的小提交。可以使用 git add -p 命令进行交互式暂存。将格式化更改与语义更改分开提交是良好的实践。
注意:在追求良好技术写作的同时,应避免走向另一个极端——写得过多。必须尊重读者的时间。使用LLM生成内容时,务必要求其产出简洁的摘要,而非冗长的散文。
双向沟通:与他人协作的艺术
上一节我们探讨了如何为他人留下信息。本节中,我们来看看双向沟通,即协作。工程师职业生涯中最常见的协作方式包括提交贡献(如Bug报告、拉取请求)和进行代码审查。
提交有效的Bug报告
提交高质量的Bug报告是一项需要练习的技能。一个有效的Bug报告能节省维护者大量时间,从而增加问题被修复的可能性。
一份好的Bug报告应包含以下要素:
- 足够的上下文:包括操作系统、软件版本、配置文件等。
- 期望与实际结果:清晰说明你期望发生什么,实际发生了什么。这有助于判断是软件错误还是文档问题。
- 复现步骤:提供一个有序的、编号的步骤列表,让他人能可靠地复现该问题。
- 已尝试的解决方法:说明你已经尝试过哪些排查或解决方法,表明你已付出努力。
- 最小化复现示例:尽可能提供一个能触发该问题的最简代码片段或配置。这是你能为维护者做的最有价值的工作之一。
在提交Bug报告前,务必搜索是否已有相同问题的报告,避免提交重复内容。对于已有的问题,使用“点赞”(👍)功能表示你也遇到此问题,而非发表“+1”或“我也遇到了”这类无意义的评论。
提交拉取请求(Pull Request)
当你决定向一个项目贡献代码时,提交拉取请求是主要方式。
提交拉取请求前,请注意以下几点:
- 阅读贡献指南:查看项目的
CONTRIBUTING.md文件,了解其流程和规范。 - 注意项目许可证:了解你贡献的代码将遵循何种许可证。
- 保持提交的整洁:遵循之前提到的Git提交规范,将改动拆分为逻辑独立的提交。
- 清晰说明“为何”:在拉取请求描述中,不仅要说明更改内容,更要解释为什么这个项目应该接受这个功能、修复或文档更新。维护者接受代码意味着他们将承担长期的维护责任。
- 提供审查指引:如果更改包含多个逻辑步骤,可以建议审查者按提交顺序进行审查。
如果维护者拒绝了你的拉取请求(例如,认为功能超出项目范围),你可以选择“分叉”(Fork)项目。但分叉意味着你将独立维护一个代码库的分支,这是一个重大的责任,通常应作为最后的手段。
进行建设性的代码审查
代码审查不是上级对下级的审判,而是关于代码本身的异步讨论,旨在共同改进代码库。
进行代码审查时,请遵循以下原则:
- 针对代码,而非个人:评论应关于代码逻辑和设计,而非程序员的能力。
- 提供可操作的反馈:与其说“不要用全局变量”,不如说“能否将全局变量替换为配置数据类?这样我们可以并行运行测试。”后者提供了具体的改进方向。
- 多提问,少命令:用“如果这里传入
null会发生什么?”代替“处理null情况”。前者更具协作性。 - 解释原因:说明你为什么认为某种做法更好或存在问题。
- 避免评论过载:过多的评论会让人望而生畏。如果发现一个重复出现的模式(如命名规范),只需在第一次出现时指出即可。
- 区分阻塞性与非阻塞性评论:明确哪些更改是必须的(阻塞性),哪些只是建议(非阻塞性)。对于非阻塞性建议,可以使用
nit:前缀。 - 不吝赞美:当发现代码中的巧妙之处或优秀设计时,请明确指出。积极的反馈能营造更好的协作氛围。
虽然AI工具可以辅助代码审查,但它们通常缺乏对项目整体方向、产品决策或版本兼容性等深层上下文的理解。因此,AI审查可以作为有用工具,但不能完全替代人工审查。
提问与回答:高效学习的技巧
工程师的大量非编码时间都花在向他人提问或回答他人问题上。无论是向导师请教,还是帮助同学,提升提问和回答的技巧都大有裨益。
如何提出好问题
提出好问题能帮助你更快地获得有用答案。
以下是提升提问技巧的几个要点:
- 陈述你的理解:在提问前,先说明你已经知道什么、尝试过什么。这能避免回答者重复已知信息。
- 多问是非题:通过一系列具体的是非问题,可以快速定位你的理解偏差,避免陷入无关的冗长解释。
- 承认不理解之处:明确说出你不懂的地方,让回答者能校准你的知识水平。
- 不要接受不完整的答案:如果没有理解,请继续追问。说“谢谢”然后离开并不能让你学到东西。
- 先自行研究:在提问前,先尝试自己搜索(Google、LLM等)。提出一个已有明显答案的问题会浪费他人时间。
这些原则同样适用于向LLM提问。在Stack Overflow等公共论坛提问时,遵循这些指南不仅能让你获得更好答案,也能惠及后来遇到相同问题的人。
AI时代的职业素养
随着AI在软件工程中的广泛应用,围绕其使用的社会和职业规范仍在演变。遵守良好的AI使用礼仪至关重要。
使用AI时,请注意以下几点:
- 披露使用情况:当你使用AI辅助完成工作时,应予以披露。这不是出于羞耻感,而是为了设定对产出质量的预期,并确保工作得到适当级别的审查。说明具体在哪些部分使用了AI(如生成测试用例、编写前端代码)非常有帮助。
- 遵守团队规范:不同公司、团队或开源项目对AI的使用可能有不同规定,有时出于法律(如数据隐私)、安全或项目理念的考虑。务必熟悉并遵守你所在环境的规范。
- 权衡学习与效率:使用AI编码可能会减少你从亲手编码中学到的经验。这是一个需要有意做出的权衡:你愿意在哪些地方投入时间深入学习,在哪些地方优先考虑效率。
- 注意评估场景:在面试、考试等评估场景中使用AI前,务必明确了解相关方的规定和期望。如果不确定,最好事先询问或主动披露。
总结
本节课中,我们一起学习了软件工程师在“代码之外”必备的核心软技能。我们探讨了如何通过清晰的代码注释、README和提交信息进行有效的单向沟通。我们深入研究了如何通过撰写高质量的Bug报告、提交结构良好的拉取请求以及进行建设性的代码审查来与他人协作。我们还介绍了如何通过更好的提问和回答来促进学习。最后,我们讨论了在AI工具日益普及的背景下,工程师应具备的职业素养和礼仪。掌握这些技能,将帮助你在技术能力之外,成为一名更高效、更受尊敬的团队成员和开源贡献者。
009:代码质量 🛠️
在本节课中,我们将学习如何提升代码质量。我们将涵盖一系列主题,包括代码格式化、代码检查、软件测试、预提交钩子、持续集成和命令运行器。如果时间允许,我们还将作为一个额外主题,介绍正则表达式。正则表达式是一个跨领域主题,在包括代码质量在内的多个领域都有应用。
我们将以一个小型 Python 库 MinlibB 作为案例进行研究。这个库包含一些随机但并非特别有用的工具函数,非常适合用来演示我们今天要讨论的各种概念。
代码格式化 ✨
上一节我们介绍了课程概述,本节中我们来看看代码格式化。
代码自动格式化工具可以自动美化程序中的表面语法。使用这些工具的原因在于,你可以将编写代码和保持代码整洁这些繁琐的细节工作自动化,从而将脑力集中在思考更深层、更具挑战性的问题上。
例如,观察左侧的代码,你会发现一些格式不一致的地方。在 Python 中,字符串可以用双引号或单引号界定,但这里的使用并不一致。此外,二元运算符之间的空格使用也不统一。
自动格式化工具可以检查代码格式是否符合标准,或者直接修复代码使其符合标准。这样,你就不需要再手动完成这项工作。
有些工具支持多种编程语言,并且高度可配置。另一些工具则配置选项有限,目的是减少无谓的争论,让程序员遵循统一标准,专注于更重要的任务。
你还可以将格式化工具与 IDE 集成,例如在保存代码时自动格式化。
以下是使用格式化工具的示例:
# 检查代码格式是否符合标准
hatch fmt --check
# 自动修复代码格式
hatch fmt
运行格式化工具后,它会显示一个差异对比,指出需要修改的地方,例如调整函数定义之间的间距、统一字符串引号、修正二元运算符的空格等。

另一个与格式化相关的工具是 EditorConfig。它是一个文件格式和一类 IDE 插件,帮助开发者在不同 IDE 间保持一致的编码标准。例如,它可以统一缩进大小、文件末尾换行符等设置。
代码检查 🔍

上一节我们介绍了代码格式化,本节中我们来看看代码检查。
代码检查工具会对你的代码进行静态分析。这里的“静态”指的是在不实际运行代码的情况下进行分析,但它比格式化工具更深入,会进行语义分析。代码检查旨在发现代码中的反模式和潜在问题。


不同工具的检查深度不同,但它们通常能在问题演变成实际 bug 之前发现很多问题。
代码检查工具通常配备一系列检查规则,这些规则可以在项目级别进行配置。有时,某些规则可能会产生误报,因此许多检查工具支持在特定文件或代码行上禁用某些规则。
好的检查工具会在发现问题时,告诉你它寻找的模式是什么、为什么这种模式不好,并可能建议更好的替代方案。有些检查工具甚至可以自动修复一些问题。然而,由于静态分析并不完美,有些修复是安全的,有些则可能需要程序员手动修复。
以下是运行代码检查工具的示例:
# 运行代码检查,仅标记问题
hatch lint --check
# 运行代码检查并自动修复可修复的问题
hatch lint
运行后,工具会列出发现的问题。例如,它可能指出:
- 使用了
import *,这会使其他静态分析变得困难。 - 导入模块的顺序未排序。
- 导入了未使用的模块。
- 存在可以简化的
if-return-else结构。
对于无法自动修复的问题,或者你决定保留的特定代码模式,你可以使用注释来禁用特定行的检查,例如 # noqa: F403。
代码检查工具也是一个很好的学习工具。例如,它可能指出在 raise 语句中直接使用字符串字面量会导致错误信息在回溯中重复显示,建议先将字符串赋值给变量。
有时,你可能需要为特定项目编写自定义的检查规则。这时,可以使用 semgrep 这样的工具。与基于文本搜索的 grep 不同,semgrep 能解析编程语言的语法树,进行更深层次的搜索,因此更健壮。
软件测试 🧪

上一节我们介绍了代码检查,本节中我们来看看软件测试。

软件测试是提高代码信心的标准技术。其核心思想是,在编写代码后,再编写一些代码来“练习”你写的代码,并评估其行为是否符合预期。测试在长期内非常有用,可以在代码演进过程中持续运行,确保没有引入破坏性更改。


测试可以在不同的粒度级别进行:
- 单元测试:测试单个函数。
- 集成测试:测试不同模块或服务之间的交互。
- 功能测试:更端到端的测试,评估软件是否满足用户需求。
还有一种流行的开发方法叫测试驱动开发,即先根据规格编写测试,然后再编写实现代码。
让我们以为 fizzBuzz 函数编写测试为例。该函数的规格是:对于给定的整数,返回 “fizz”(3的倍数)、”buzz”(5的倍数)、”fizzbuzz”(3和5的公倍数),否则返回数字本身。
在 Python 中,使用 pytest 框架,测试文件通常以 test_ 开头,测试函数也以 test_ 开头。
# test_fizzbuzz.py
from minilib import fizzbuzz
def test_fizzbuzz():
assert fizzbuzz(3) == "fizz"
assert fizzbuzz(30) == "fizzbuzz"
运行测试:
hatch test
如果测试失败,你可以根据具体的输入输出去分析和修复代码。
一个衡量测试好坏的重要指标是代码覆盖率。它衡量的是你的测试执行了实现代码中多少行的代码。虽然高覆盖率不能保证测试完美,但低覆盖率通常意味着很多代码路径未经测试。
你可以使用工具生成覆盖率报告:
# 运行测试并计算覆盖率
hatch test --cov
# 生成 HTML 格式的覆盖率报告
hatch run coverage html
报告会高亮显示哪些代码行已被覆盖,哪些未被覆盖,从而指导你编写更有针对性的测试。
另一种强大的测试方法是基于属性的测试。与编写具体的测试用例不同,你可以描述代码应该满足的属性,测试库会自动生成大量随机输入来验证这些属性。
例如,对于 left_pad 函数(在字符串左侧填充空格至指定长度),其属性可能包括:
- 输出字符串的长度至少等于指定的长度
i。 - 原始字符串
s应包含在输出字符串中。
在 Python 中,可以使用 hypothesis 库进行基于属性的测试。


from hypothesis import given, strategies as st
from minilib import left_pad
@given(st.text(max_size=20), st.integers(min_value=0, max_value=50))
def test_left_pad_length(s, i):
assert len(left_pad(s, i)) >= i
@given(st.text(max_size=20), st.integers(min_value=0, max_value=50))
def test_left_pad_contains(s, i):
assert s in left_pad(s, i)


当测试失败时,hypothesis 会提供一个具体的反例,帮助你调试。
预提交钩子与持续集成 🤖
上一节我们介绍了软件测试,本节中我们来看看如何自动化执行这些质量检查。
预提交钩子是一种 Git 机制,允许你在每次提交前自动运行脚本。这可以确保所有提交到仓库的代码都符合质量标准。
你可以使用 pre-commit 等工具来管理这些钩子。在配置文件(如 .pre-commit-config.yaml)中定义要运行的检查,例如代码格式化和代码检查。
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: format-check
name: Format Check
entry: hatch fmt --check
language: system
- id: lint-check
name: Lint Check
entry: hatch lint --check
language: system
开发者只需运行一次 pre-commit install 来安装钩子。之后,每次尝试 git commit 时,这些检查都会自动运行。如果任何检查失败,提交将被阻止,直到问题被修复。
持续集成服务(如 GitHub Actions)则是在远程服务器上自动化运行工作流。你可以配置 CI,使其在特定事件发生时触发,例如推送代码、创建拉取请求或按计划定时运行。
CI 可以运行所有本地能运行的检查(格式化、检查、测试、类型检查等),并且因为它运行在远程环境,你还可以轻松地运行矩阵测试。例如,在不同的操作系统(Windows、Linux、macOS)和不同的 Python 版本(3.7, 3.8, …, 3.12)上并行运行你的测试套件。

CI 的配置通常以 YAML 文件形式定义(例如 .github/workflows/ci.yml),其中指定了触发事件、运行环境、安装步骤和要执行的命令。
# .github/workflows/ci.yml 示例片段
jobs:
test:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install hatch
- run: hatch test
CI 的结果会显示在拉取请求页面上,方便团队审查。与 CI 紧密相关的是持续部署,即利用 CI 基础设施在代码通过所有检查后,自动构建和部署应用程序。
命令运行器与正则表达式 ⚙️
上一节我们介绍了预提交钩子和持续集成,本节中我们来看看两个能提升效率的实用工具:命令运行器和正则表达式。
命令运行器(如 just)用于为项目中常用的长命令创建简短的别名。如果你没有使用像 hatch 这样内置命令管理的项目管理器,这将非常有用。

例如,你可以创建一个 justfile:
# justfile
format:
black .
isort .
lint:
flake8 .
test:
pytest
然后,你只需运行 just format、just lint 或 just test 即可。
正则表达式是一种用于描述字符串集合(模式)的微型语言。它在代码质量工具(如搜索特定代码模式)、IDE 搜索替换、文本处理等场景中广泛应用。
正则表达式的基本语法包括:
- 字面量:
abc匹配字符串 “abc”。 - 字符类:
[abc]匹配 a、b 或 c 中的任意一个字符。[^abc]匹配不在 a、b、c 中的任意字符。[a-z]匹配一个范围。 - 特殊字符:
.:匹配任意字符(除换行符外)。\d:匹配数字。\w:匹配单词字符(字母、数字、下划线)。\s:匹配空白字符(空格、制表符等)。
- 量词:
*:零次或多次。+:一次或多次。?:零次或一次。{n}:恰好 n 次。{n,}:至少 n 次。{n,m}:n 到 m 次。
- 定位符:
^:匹配字符串开始。$:匹配字符串结束。\b:匹配单词边界。
- 分组与捕获:使用括号
()进行分组,并可以捕获匹配的子串,用于后续引用或替换。 - 选择:
|表示“或”,例如cat|dog。

在搜索替换中,你可以使用捕获组。例如,在 Vim 中,将所有的数字重复一遍:
:%s/\(\d\+\)/\1\1/g
这里 \(\d\+\) 捕获一个或多个数字,\1 在替换部分引用第一个捕获组。
在编程中,正则表达式库(如 Python 的 re 模块)也广泛用于解析和验证文本。
需要注意的是,不同工具和编程语言的正则表达式实现(“风味”)略有差异。对于复杂的语言解析(如 HTML),正则表达式能力不足,需要使用更专业的解析器。
总结 📚
本节课中我们一起学习了提升代码质量的一系列核心概念和工具。
我们从代码格式化开始,了解了如何利用工具自动保持代码风格一致。接着,我们探讨了代码检查,它通过静态分析发现更深层的反模式和潜在问题。然后,我们深入研究了软件测试,包括单元测试、基于属性的测试以及衡量测试效果的代码覆盖率。
为了将这些检查流程自动化并标准化,我们介绍了预提交钩子,它能在代码提交前强制执行质量门禁;以及持续集成服务,它能在云端为每次变更运行全面的测试矩阵。
最后,我们了解了提升效率的命令运行器,并学习了强大的文本处理工具正则表达式的基礎。
记住,具体的工具(如 black、ruff、pytest、GitHub Actions)会因编程语言和项目而异,但这里介绍的概念是普适的。掌握这些理念并应用合适的工具,将帮助你编写出更健壮、更可维护的高质量代码。

浙公网安备 33010602011771号