Reading 29: 团队版本控制(Team Version Control)
目录 · ← l28 · appendix →
Reading 29: 团队版本控制(Team Version Control)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。本讲以 Git 命令行为主,因此代码对比大量使用
bash 与text 代码块;凡涉及”合并后代码会变成什么样”的地方,仍然给出 Java 源码,这样才能看清”自动合并成功但语义坏掉”的过程。
概述
前面几周你做问题集时,虽然一直在用 Git,但很少需要和同时间往同一个仓库 push/pull 的其他人协调;进入小组项目之后,这件事变成日常。本讲的目标只有两条:复习 Git 基础与提交图(commit graph),以及练习多用户的 Git 场景。它给出的核心结论是:团队版本控制的成败不取决于你记住多少命令,而取决于一套协作纪律——沟通、写规格、写测试、跑测试、自动化、提交前审查、开工前先拉取、收工前同步。本讲与三大目标的关系非常直接:Safe from bugs 靠”提交前跑测试 + 自动化构建”来保证(”it worked on my machine”从此不再成立);Easy to understand 靠可读的提交图与有意义的提交信息来保证(历史是写给未来的人看的沟通记录);Ready for change 靠小的原子提交、频繁同步与可回滚的历史来保证(任何一次改动都能被定位、被理解、被撤销)。课程还明确给出了一条反直觉的建议:在 6.031 这种 1–2 周、3 人规模的项目里,不要使用分支或变基(branching / rebasing),把精力放在清晰沟通与频繁同步上;分支的价值在项目更大、周期更长、人更多时才显现。
核心概念与设计原则详解
提交图(Commit Graph / DAG)
- 定义与目的:Git 仓库中记录的历史是一个有向无环图(directed acyclic graph, DAG)。每个节点是一个提交(commit,也叫 version / revision),即项目所有文件在那一刻的完整快照;每个提交有唯一的十六进制 ID。它解决的是”我们如何知道自己从哪儿来、两条改动线在哪里分开又在哪里合上”。
- 直观解释(”它是什么?”):任何分支(例如默认的
main)的历史都从某个初始提交开始,然后可能分叉(多个开发者并行改动,或同一个人在两台机器上工作而中间没有 commit-push-pull),再合回来。课程要求你能指着ex05-hello-git的输出,说清main的历史在哪里分开、在哪里合上: ```text- b0b54b3 (HEAD, origin/main, origin/HEAD, main) Greeting in Java
- 3e62e60 Merge |
| * 6400936 Greeting in Scheme - | 82e049e Greeting in Ruby |/
- 1255f4e Change the greeting
- 41c4b8f Initial commit ``` 这里
3e62e60是合并提交(merge commit),它有两个父提交;\|\与\|/之间就是分叉与回合。
- 关键规则与最佳实践:
- 图形输出来自
git log --graph --oneline --decorate --all;课程使用的git lol就是它的别名(可用git config --global alias.lol "log --graph --oneline --decorate --all"配置)。 - 括号里的
HEAD、main、origin/main是引用(refs):HEAD是你当前所在的位置,main是本地分支,origin/main是上次与远程通信时远程分支的位置。 - 不需要背下
Pro Git2.3 节列出的所有选项,只要知道”什么是可能的”,需要时再去查。 - 能把”分叉—合并”讲清楚,是理解后面所有冲突问题的前提。
- 图形输出来自
合并、合并冲突与”先拉取再开工”(Merging, Merge Conflicts, Pull Before You Start Working)
- 定义与目的:合并(merge)把两条历史线的改动结合起来。Git 能自动合并不同位置的改动;当两边改了同一处时,它无法替你决定,于是报告合并冲突(merge conflict)。它解决的是”并行工作如何安全地汇合”。
- 直观解释(”它是什么?”):本讲用三组练习把这件事拆开(下面思考题会给出答案):
- 不同方法的改动:Alice 改
greet(..),Bob 改greeting(),Git 自动合并,结果正确。 - 同一行被两边改动:Alice 把
return "Hello";改成return "Ciao";,Bob 把它改成return "Hello, ";——同一行、两个不同结果,Git 无法自动合并。 - 接口约定悄悄变了:Alice 把
greet(..)从”打印”改成”返回字符串”,Bob 新写了一个调用它的Main.java。Git 自动合并成功,编译也通过,但运行起来什么都不打印——合并成功不等于语义正确。
- 不同方法的改动:Alice 改
- 关键规则与最佳实践:
- 开工前先 pull:否则你的起点是旧版本,你必然要在以后合并,而且很可能要花时间解冲突。
- 合并后必须重新运行测试——Git 保证的是文本层面不重叠,不是语义层面不打脸。
- 接口变更(签名、返回值、语义)要事先沟通,这是本讲第一条团队准则”Communicate”的具体含义。
- 冲突不是灾难,而是一次”需要人来做的决定”;解冲突时必须理解双方各自的意图,而不是随便删掉一边。
- 永远不要为了图快用
push --force覆盖别人的提交——那是在用别人的工作换自己的方便。
八条团队准则(Team Guidelines for Version Control)
- 定义与目的:每个团队都会形成自己的版本控制标准,规模与周期是主要影响因素。课程给出了适用于 6.031 这种小规模团队项目的八条准则,它们解决的是”如何用纪律替代运气”。
- 直观解释(”它是什么?”):逐条是:Communicate(沟通)——告诉队友你要做什么、正在做什么、做完了什么,这是避免别人花时间清理坏代码的最佳方式;Write specs(写规格)——这是 6.031 在意的东西,也是沟通的一部分;Write tests(写测试)——不要等代码堆成山才开始测,也不要一个人写测试另一个人写实现(除非那份实现是准备丢掉的原型),先写测试以确保大家对规格达成一致,每个人都要为自己代码的正确性负责;Run the tests(跑测试)——测试不跑就没用,开工前跑一遍,提交前再跑一遍;Automate(自动化)——6.031 用 Didit 在你 push 到 github.mit.edu 时自动跑测试,这也消除了”在我机器上是好的”这种说法:要么自动化构建通过,要么就得修;Review what you commit(审查你要提交的东西)——用
git diff --staged或图形工具看一眼,跑测试,不要用git commit -a;Pull before you start working;Sync up(同步)——一天或一次工作结束时,确认所有人都 push/pull 完毕、处于同一个提交、并且对项目状态满意。 - 关键规则与最佳实践:
- 提交的粒度应当是”对项目的一次改动”,而不是”改过的每个文件各来一次”,也不是”包括还不能编译的中间状态”。
- 不要提交不能编译的代码、不要提交调试输出、不要提交实际上用不到的东西——”don’t break the build”。
- 不要对你编辑过的每个文件做整体重排版(reformat),那会让 diff 变成一团噪声,掩盖真正的改动。
- 团队项目要求每个成员在每次课堂 check-in 时,能展示自己的工作副本已 commit、已 push、且与远程仓库同步。
- 迭代 #0 时,每位成员把工作提交到
iter0/<用户名>/目录下(因为大家可能产生同名文件),迭代 #1 之后才统一使用src/与test/——这就是用目录结构规避冲突的典型手法。
提交粒度与提交信息(Commit Granularity and Commit Messages)(补充说明:课程原文强调”每次提交要有描述你改了什么的有用信息”,并在团队项目规范里明确要求 useful commit message;以下关于提交粒度与信息格式的九条细则属于工程实践补充,用于把这条要求变得可执行。)
- 定义与目的:提交是历史的最小单位,也是未来阅读这份历史的人的唯一路标。它解决的是”三个月后(或三小时后)我能不能弄明白这里为什么变成了这样”。
- 直观解释(”它是什么?”):好的提交回答三个问题:改了什么、为什么改、影响谁。它是一个自包含的、可单独审查(review)、可单独回滚(revert)的改动单元。
- 关键规则与最佳实践:
- 原子性:一次提交只做一件事;”修 bug + 顺手重命名 + 调整格式”应当分成三次提交。
- 可编译:每个提交都不应破坏构建,因为任何人都可能从任意提交开始工作。
- 主题行简洁:一句话说明做了什么,通常控制在一行以内;需要的话用
模块: 动作的形式。 - 正文讲清楚”为什么”:代码本身说明了”是什么”,提交信息的价值在于动机与取舍。
- 不要用
update、fix、stuff、asdf这类信息——它们等于告诉后来的人”这段历史你不用读了”。 - 多人共同完成的一次提交,在信息里写上合作者(课程的项目规范明确要求这样做,助教也会通过 Git 日志查看个人贡献)。
灾难恢复(Disaster Recovery)
- 定义与目的:本讲把”恢复”分成两层:预防与补救。预防是定期 add/commit/push,这样工作安全地存在远程仓库里,随时可以用
git clone拉到一个全新目录;补救则针对已经发生的问题。它解决的是”我搞坏了仓库怎么办”。 - 直观解释(”它是什么?”):三个关键命令各司其职:
git revert <revision>:撤销一整个提交。它不是把仓库倒回旧版本,而是在 HEAD 处新建一个提交,抵消旧提交的效果;旧提交仍然留在历史里。git show <revision>:查看某个提交的 diff(删除是红色、新增是绿色);git show <revision>:<path>直接输出某个文件在那个版本的内容,找到可用版本后复制粘贴是最简单的恢复策略。git checkout <revision> -- <path>:把工作目录里的某个文件替换成旧版本。--(后面有空格)和路径都不能少。它不产生提交,但会把改动放入暂存区。
- 关键规则与最佳实践:
- 永远不要运行不带
-- <path>的git checkout <revision>:那会让HEAD指向旧提交,你就不再处于main分支上——这就是”detached HEAD(分离头指针)”,课程的原话是”nobody wants that”。 - 如果某个命令警告你 HEAD 已分离,先停下来求助,不要继续操作,以免丢工作;已经发生的分离状态几乎总能恢复,但必须小心处理。
- 只想撤销一个提交里某一处有问题的改动时,用
git show找到旧版本内容,而不是 revert 整个提交。 - 用图形工具或 VS Code 的 TIMELINE 视图来浏览历史、定位要恢复的数据,但执行命令仍推荐命令行。
- 恢复之后要跑测试并提交,否则你的”恢复”只存在于本地工作目录里。
- 永远不要运行不带
分支、拉取请求与代码评审(Feature Branches, Pull Requests, Code Review)(补充说明:6.031 明确不推荐在课程规模的项目中使用分支与变基,本小节介绍的是业界在更大团队中的标准做法,用于理解为什么课程做出这个取舍。)
- 定义与目的:功能分支(feature branch)把一个功能的开发隔离在独立的历史线上,拉取请求(pull request, PR)是”请求把你的分支合并进主干”的正式提案,代码评审(code review)是合并前必须通过的审查。它解决的是”如何在不阻塞他人的前提下,让改动在进入主干之前被多人看过”。
- 直观解释(”它是什么?”):主干(
main)始终是可发布的状态;每个人从主干拉出分支、在自己的分支上做小步提交、推送、开 PR,CI 跑测试、同事读代码,通过后再合并回主干。整个过程把 Reading 4(代码评审)从”事后传阅”变成了”合并的前置条件”。 - 关键规则与最佳实践:
- 分支要短命:活几天、而不是几周,否则合并冲突会指数级增长。
- PR 要小:几百行以内的 PR 才能被认真评审。
- 保护主干:要求 CI 通过 + 至少一人 approve 才允许合并。
- 合并前先把自己分支上的主干最新版本合进来(或 rebase),在自己的分支上解冲突,而不是把冲突留给主干。
- 课程对照:6.031 的 1–2 周、3 人项目里,”所有人都在
main上频繁提交 + 高频同步 + Didit 自动跑测试”比引入分支更简单可靠;课程原话是分支”extremely important when the size of the project, the length of the time, or the number of people is much larger”(sp22 新增的这句解释比 sp21 更明确地给出了判据)。
合并 vs 变基(merge vs rebase)(补充说明)
- 定义与目的:两者都把两条历史线结合起来,但历史形状不同。它解决的是”我希望历史看起来是什么样”。
- 直观解释(”它是什么?”):
git merge保留两条线的真实形状,产生一个合并提交,历史是”忠实的”但会有分叉。git rebase把你的提交逐个”搬到”目标分支的最新提交之上,历史变成一条直线,读起来干净,但等于重写了提交 ID。
- 关键规则与最佳实践:
- 只对尚未推送、别人看不到的本地提交做 rebase。
- 绝不对已经推送并被别人基于其工作的分支做 rebase(或 force push)——别人的历史会因此断裂。
- 团队达成统一约定:要么”合并优先”,要么”变基优先”,不要各自为政。
- 变基过程中每个提交都可能产生冲突,需要逐个解决;这也是它在小项目里不划算的原因。
- 记住课程的立场:在 6.031 的项目里用不到它;理解概念是为了在更大团队里能接得上。
不该进仓库的东西:.gitignore、编译产物与密钥(补充说明:课程原文要求”不要提交没用的东西”“审查你要提交的内容”,但没有展开 .gitignore 的写法;以下内容是这条要求的具体化。)
- 定义与目的:版本库应当只包含人写的源码与配置。编译产物可以从源码重建,编辑器设置因人而异,密钥则根本不应该存在于任何被共享的地方。
.gitignore让 Git 忽略这些路径。 - 直观解释(”它是什么?”):判断标准很简单——“这能不能从仓库里的其他东西重新生成?” 能,就不该提交;“这是不是秘密?” 是,就绝对不能提交(提交过的密钥即使后来删除,它仍然留在历史里,必须视为已泄漏并立即轮换)。
- 关键规则与最佳实践:
- 常见的忽略项:
*.class、build/、target/、out/、node_modules/、.vscode/、.idea/、*.log、.env、*.pem。 .gitignore只对未跟踪文件生效;已经提交过的文件必须先git rm --cached <file>,再依赖.gitignore。- 密钥、令牌、口令一律通过环境变量或密钥管理服务注入,绝不硬编码、绝不提交。
- 提交前用
git status与git diff --staged双重确认;.gitignore不是万能的,它只挡住你想到过的东西。 - 一旦密钥进过远程仓库,视为已经泄漏:立刻轮换密钥,并检查历史中是否还有残留。
- 常见的忽略项:
代码示例与对比分析
Git 是一个命令行工具,所以下面多数对比以仓库状态与命令序列为单位(bash /text)。但”合并”这件事的后果最终会体现在Java 源码上,因此凡是涉及合并语义的场景,都给出 Java 代码,让你看清”Git 说合并成功”与”代码还能正确工作”之间的距离。
场景 1:提交前不审查,用 git commit -a 把所有改动一并塞进历史
❌ 错误代码
#改了三个文件(其中一个是调试用的笔记),不想多想,直接一把梭
$ git status
On branch main
Changes not staged for commit:
modified: Hello.java
modified: HelloTest.java
modified: notes-todo.txt
$ git commit -a -m "fix"
[main 9f31c02] fix
3 files changed, 412 insertions(+), 8 deletions(-)
// 被顺手提交进仓库的调试输出:History 里永远留着它
public class Hello {
public static String greeting() {
System.out.println("DEBUG greeting() called"); // TODO 删掉
return "Hello";
}
}
【错误代码的问题】
- 塞进了与提交目的无关的文件:
notes-todo.txt让你的队友在 review 时看到一堆噪声,也增加了仓库体积。 - 调试输出进入历史:
println会污染所有使用者的标准输出(在 6.031 的测试里甚至会让自动化测试的输出无法阅读),而删除它需要额外一次提交——历史里永远留着”那段错误存在过”的记录。 -a绕过了暂存区:你失去了”提交前看一眼将要进入历史的确切内容”这道唯一的人工闸门,正是这道闸门让 6.031 反复强调”Review what you commit”。- 提交粒度被破坏:一次提交里混着修复、笔记与调试代码,将来要 revert 时无法只撤销坏的那部分。
✅ 正确代码
#只暂存本次真正想要的改动,并逐个 hunk 确认
$ git status
On branch main
Changes not staged for commit:
modified: Hello.java
modified: HelloTest.java
Untracked files:
notes-todo.txt # 本地笔记:不提交,写进 .gitignore 或干脆留在本地
$ git add -p Hello.java HelloTest.java
$ git diff --staged # 提交前审查:即将进入历史的确切内容
$ ./gradlew test # 测试通过才提交(6.031 项目里由 Didit 在 push 后自动跑)
$ git commit -m "hello: greeting() 支持自定义问候语"
[main 4c81de0] hello: greeting() 支持自定义问候语
2 files changed, 37 insertions(+), 6 deletions(-)
$ git push
// 提交进仓库的是干净的实现:没有调试输出,没有 TODO 残留
public class Hello {
/**
* @param language 问候语所使用的语言,例如 "en" 或 "it"
* @return 对应的问候语
*/
public static String greeting(String language) {
return "it".equals(language) ? "Ciao" : "Hello";
}
}
【为什么这样更好】 三步闸门各挡住一类事故:git add -p 让你逐块选择改动(把调试输出留在工作区),git diff --staged 让你看清将要被记录的内容(而不是你以为的内容),跑测试让你确认这份内容不会破坏构建。提交信息从 fix 变成 hello: greeting() 支持自定义问候语,队友在 git log 里一眼就知道这次提交动了什么、动了哪个模块。最后 git push 让工作进入远程仓库——这正是”预防灾难”的核心动作:工作只要在远程,就几乎不会丢。
【代码对比解说】 这里的对比不是两个版本的 Java 代码,而是两种仓库状态:左版的历史里多了一个 fix 提交,它同时包含调试代码、无关笔记与真正的修复;右版的历史里只有一次干净、可审查、可单独回滚的改动。请注意 git commit -a 并不是”危险命令”,它只是把”人类审查”这一步删掉了——在本讲的价值体系里,被删掉的这一步才是关键。另外注意 git add -p 与 git diff --staged 是成对使用的:前者决定进入暂存区的内容,后者让你复核这个决定;只做前者不做后者,仍然可能把调试输出提交上去。
【设计原则透视】 这直接对应 Reading 3(测试)的”测试不跑就没用”:本讲把”跑测试”从个人习惯升级为提交的前置条件,并由 Didit 这类自动化工具兜底。它也对应 Reading 9(避免调试)中”不要留下调试输出”的纪律——System.out.println 的残留既是 ETU 问题(噪声掩盖真正输出),也是 SFB 问题(在某次测试中它会成为误报的来源)。从抽象边界的角度看,git diff --staged 就是”提交”这一操作的规格:它让你在副作用发生前确认输入是否符合预期,与 Reading 6 中”实现前先写清后置条件”是同一套思维。
场景 2:提交粒度与提交信息——二十次 update 与一次”什么都有”的巨型提交
❌ 错误代码
$ git log --oneline
f10a2b1 update
9c4e77a update
77bb3de asdf
31a90cc stuff
0d5e114 fix
c2b8f70 WIP
a40031e update
... # 还有十几个一模一样的
另一种同样糟糕的形态:一次提交做完了整个迭代
$ git show --stat 3e62e60
Hello.java | 210 ++++++++++++++++--------
HelloTest.java | 96 +++++++-----
Main.java | 44 ++++++
build.gradle | 3 +-
team-contract.pdf | Bin 0 -> 184320 bytes
notes-todo.txt | 18 +++
6 files changed, 371 insertions(+), 118 deletions(-)
【错误代码的问题】
- 历史失去检索价值:
update、fix、stuff、asdf让你的队友(以及三个月后的你)无法用git log找到”上次改greeting()是哪次提交”。 - 无法精确回滚:想撤销的只是
Hello.java里那处坏改动,但git revert 3e62e60会把整轮的 371 行新增、118 行删除全部撤销,包括你想保留的部分。 - 无法有效评审:一个包含六个文件、数百行改动的提交无法被认真 review(对应 Reading 4 的评审实践),队友只能”扫一眼然后 approve”。
- 把可编译性与不可编译性混在一起:巨型提交往往包含”中途还不编译”的状态,破坏”每个提交都不破坏构建”的承诺。
✅ 正确代码
$ git log --oneline
b0b54b3 hello: greeting(String) 支持语言参数
4c81de0 hello: 把 greet() 从打印改为返回值
3e62e60 main: 新增 Main.java 并打印问候语
1255f4e hello: 修改问候语文本
41c4b8f Initial commit
$ git show -s --format=%B 4c81de0
hello: 把 greet() 从打印改为返回值
Main 需要把问候语写进日志文件而不是标准输出,所以 greet() 不能再
自己 println。这次改动只调整返回类型与调用点,不改问候语内容。
- Hello.java: greet(String) 返回 String,不再打印
- HelloTest.java: 断言返回值而不是捕获标准输出
由 Alice 与 Bob 结对完成(同一键盘)。
【为什么这样更好】 每次提交都以一句 模块: 做了什么 开头,于是 git log --oneline 本身就是一份可读的项目年表;需要细节时,git show -s --format=%B 或 git show 给出为什么这么改(Main 需要写日志文件,所以不能再用 println),以及影响面(改了两个文件、调用点同步更新)。原子化的提交让 git revert 变成一把手术刀而不是一把锤子。最后一行记录合作者,既满足课程项目”多人共同完成的提交要注明合作者”的要求,也让助教能从 Git 日志中看到每个人的实际贡献。
【代码对比解说】 提交信息的质量不是审美问题,而是信息检索问题:git log --oneline 是你寻找”这个 bug 是从哪次改动引入的”的第一入口,如果入口全是 update,你只能退回到逐行 git blame。同理,提交粒度直接决定 git revert、git bisect、代码评审这三件事是否可用。注意右版的正文里写的是动机(为什么改)而不仅仅是动作(改了什么)——动作在 diff 里已经写着,动机只能由人写下来。补充说明:业界常见的约定是主题行不超过约 50 个字符、正文每行约 72 个字符,并用 模块: 动作 或 type(scope): subject 之类的前缀;这些约定属于补充实践,核心仍是”一次一件事 + 说清为什么”。
【设计原则透视】 这与 Reading 6/7(规格说明)同构:提交信息就是这次改动的规格——它说明意图(前置条件)、说明效果(后置条件),并让审查者可以判断实现是否符合意图。它也与 Reading 11(AF/RI)呼应:一次提交应当有清晰的抽象边界,即”这次改动引入了什么新的不变式”,把不相关的改动混进来,等于同时修改多条不变式而无法分别验证。最后,它与 Reading 29 自身的第一条团队准则”Communicate”直接相连:提交信息是异步沟通的主要载体,你不可能每次都当面告诉队友你改了什么。
场景 3:不先 pull 就开工,于是”自动合并成功但语义坏了”
❌ 错误代码
// 合并结果:Hello.java(Alice 的改动)
public class Hello {
/** 现在返回问候语,而不是打印它。 */
public static String greet(String name) {
return greeting() + ", " + name;
}
public static String greeting() {
return "Hello";
}
}
// 合并结果:Main.java(Bob 新加的文件,基于旧版 hello 写的调用)
public class Main {
public static void main(String[] args) {
Hello.greet("Eve"); // 返回值被丢弃:编译通过,运行却什么都不输出
}
}
【错误代码的问题】
- Git 无法替你发现语义冲突:Alice 改了
Hello.java的方法体,Bob 新增了Main.java,两处改动不重叠,所以 Git 自动合并成功;但greet的语义已经从”打印”变成”返回”,Bob 的调用点因此失效。 - 不先 pull 就是基于旧版本开发:Bob 写
Main.java时的起点里,greet还是返回void的,他的代码在那个版本上是正确的——错误来自”起点过时”,这正是本讲把”Pull before you start working”单列一条的原因。 - 没有测试覆盖调用点:如果有一个断言
greet行为的测试(或一个端到端测试检查输出),这次语义破坏会在 CI 上立刻暴露。 - “编译通过”给人虚假的安全感:合并后构建是绿的,于是没有人怀疑它——这是最危险的一类失败。
✅ 正确代码
$ git pull #开工前先拉取,确认自己的起点是最新的
Already up to date.
$ git log --oneline -5 #看到接口变更的提交,先与队友确认调用点是否都已同步
4c81de0 hello: 把 greet() 从打印改为返回值
// 与新的规约一致的调用点:使用返回值,而不是假设它会打印
public class Main {
public static void main(String[] args) {
System.out.println(Hello.greet("Eve")); // 按新规格显式输出
}
}
import static org.junit.Assert.assertEquals;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import org.junit.Test;
/** 覆盖 greet(..) 的返回值语义,让接口变更无处可藏。 */
public class HelloTest {
@Test
public void testGreetReturnsGreeting() {
assertEquals("Hello, Eve", Hello.greet("Eve"));
}
@Test
public void testGreetDoesNotPrint() {
PrintStream original = System.out;
ByteArrayOutputStream captured = new ByteArrayOutputStream();
System.setOut(new PrintStream(captured));
try {
Hello.greet("Eve"); // 新规格:只返回,不打印
} finally {
System.setOut(original);
}
assertEquals("", captured.toString());
}
}
【为什么这样更好】 三个动作把这类事故挡住:先 pull 保证起点是最新的;看 log 并与队友沟通让接口变更成为共同知晓的事实(本讲第一条准则);测试覆盖返回值语义让”语义坏了”变成一次红的构建,而不是一个上线后才发现的问题。注意右版的 Main 只是显式地把返回值打印出来——它并没有试图恢复旧行为,而是接受新规格,这正是”合并后要重新理解代码含义”的体现。
【代码对比解说】 本讲的第三组练习问的正是这里:合并结果会怎样?答案是”我们能自动合并,但合并后的代码是坏的(无错误、结果错误)“——在 Java 里 Hello.greet("Eve"); 作为表达式语句完全合法(返回值被丢弃),所以既没有静态错误也没有运行时异常,只是什么都不会输出。这组对比的教学价值在于告诉你:Git 的冲突检测是基于文本位置的,而软件的正确性依赖语义契约。任何”接口/语义变更”都必须由人来传播,工具帮不上忙。补充一点 TypeScript 对照(sp22 原版):greet("Eve"); 在 TS 中同样合法,结果也一样是静默无输出。
【设计原则透视】 这是 Reading 6(规格说明)最直接的团队版本:greet 的后置条件从”打印一行问候”变成”返回问候字符串”,规格变了,所有调用点都必须重新验证。它也是 Reading 3(测试)的用例:好的测试把规格钉住,使得”别人静默地改变了语义”这件事无法悄悄发生。从抽象边界的角度看,Hello.greet 是一个模块边界,跨边界的约定必须显式且共享;本讲那句”pull before you start working”的技术含义其实是:你的局部心智模型必须与仓库的当前状态同步,否则你写出的代码是针对一个已经不存在的世界的。
场景 4:把冲突标记直接提交上去,或者靠删掉一边来”解决”冲突
❌ 错误代码
$ git merge bob
Auto-merging Hello.java
CONFLICT (content): Merge conflict in Hello.java
Automatic merge failed; fix conflicts and then commit the result.
$ git add Hello.java
$ git commit -m "merge" # 冲突标记还在文件里!
// 被提交上去的 Hello.java:Git 的冲突标记原样留在源码里
public class Hello {
public static void greet(String name) {
System.out.println(greeting() + name);
}
public static String greeting() {
<<<<<<< HEAD
return "Ciao";
=======
return "Hello, ";
>>>>>>> bob
}
}
【错误代码的问题】
- 代码根本不能编译:
<<<<<<<、=======、>>>>>>>都不是 Java 语法,构建立刻失败——直接违反团队准则”don’t break the build”。 - 把决策责任推给别人:冲突标记的含义是”这里需要一个人来决定”,把它提交上去等于告诉队友”你来替我决定”。
- 没有运行测试就提交:如果提交前跑过测试,这次合并根本不可能通过。
- 丢掉了对方的工作:另一种同样糟糕的”解决”方式是随手删掉一边——无论删掉哪一边,都有人白做了。
✅ 正确代码
$ git merge bob
Auto-merging Hello.java
CONFLICT (content): Merge conflict in Hello.java
Automatic merge failed; fix conflicts and then commit the result.
$ git diff #1) 看清楚两边各自想做什么(冲突区域的上下文)
$ git log --oneline --left-right HEAD...bob
#2) 与队友确认:Bob 想把逗号移到 greeting() 里,Alice 想把问候语改成 Ciao;两者的意图可以同时满足,于是手工写出合并后的正确版本
$ git add Hello.java
$ ./gradlew test #3) 合并后必须重新跑测试
$ git commit -m "hello: 合并 Ciao 问候语与逗号位置调整
Bob 把逗号移进 greeting(),Alice 把问候语改为 Ciao;两者互不冲突,
合并后 greeting() 返回 \"Ciao, \",greet() 只拼接姓名。"
// 手工合并的结果:同时保留双方的意图,并且可以编译、可以被测试覆盖
public class Hello {
public static void greet(String name) {
System.out.println(greeting() + name);
}
/** @return 含尾随逗号与空格的问候语,例如 "Ciao, " */
public static String greeting() {
return "Ciao, ";
}
}
【为什么这样更好】 冲突解决被拆成三个必须显式完成的步骤:读懂两边意图(用 git diff 看冲突区域,用 git log --left-right 看两边各自带来了什么提交)、做决定并写下来(在提交信息里记录这个决定,让未来的人知道为什么是 "Ciao, " 而不是别的写法)、重新验证(跑测试并确认构建是绿的)。合并提交的信息本身成了一份微型设计文档。
【代码对比解说】 左版的失败不是”命令用错了”,而是把一个必须由人完成的判断(”这里应该是什么”)交给了提交按钮。右版则把它当成一次真正的设计活动:Alice 改问候语文本、Bob 改逗号位置,这两个意图并不矛盾,正确结果是把两者都保留下来("Ciao, "),而不是二选一。顺便回答本讲的第二组练习:如果两边都改了 greeting() 的 return 那一行(一个改成 "Ciao",一个改成 "Hello, "),Git 无法自动合并,必须由人决定。请注意这里有一个容易忽略的原则:冲突标记从来不是噪声,它是一份待办事项。
【设计原则透视】 这与 Reading 6(规格说明)再次同构:冲突区域的两个版本代表两份互不兼容的规格,解决冲突就是重新确定唯一的规格,并把它写进代码与提交信息。它也体现 Reading 11(AF/RI)的思维:合并后的 greeting() 有一个新的表示契约(”含尾随逗号与空格”),必须写进 Javadoc,否则下一个调用者还会踩同样的坑。最后,它印证本讲的结论:工具只能发现文本重叠,语义一致性必须由人和测试来保证——这也解释了为什么 6.031 宁可不推荐分支,也要强调”频繁同步 + 频繁跑测试”。
场景 5:灾难恢复——手工复制、detached HEAD 与正确的 revert
❌ 错误代码
#想撤销上午那次坏提交,于是……
$ git checkout 82e049e # 忘了 -- <path>!
Note: switching to '82e049e'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches ...
HEAD is now at 82e049e Greeting in Ruby
#继续在这里改代码、commit……
$ git commit -am "继续修"
[detached HEAD 7b1c9aa] 继续修
另一种"手工恢复":把旧版本内容从聊天记录/邮件里复制粘回文件,
既没有提交信息说明发生了什么,也没有测试证明恢复是正确的。
【错误代码的问题】
- detached HEAD 意味着你的提交不属于任何分支:
7b1c9aa不在main的历史上,一旦切换分支,它就很难被找回。 - 恢复过程不可追溯:手工复制粘贴不留下任何记录,未来没人知道”这里为什么被改回去了”。
- 可能连带撤销不该撤销的东西:本讲明确说过,一个提交往往同时改进了三个函数,其中只有一个改动是错的。
- 忘记重新测试:恢复后的代码没有经过验证就继续开发,等于把不确定性叠加在不确定性上。
✅ 正确代码
#情况 A:想撤销一整个提交 → 用 revert,它在 HEAD 处新建一个反向提交
$ git lol
* b0b54b3 (HEAD, origin/main, origin/HEAD, main) Greeting in Java
...
$ git revert 82e049e
[main abcd123] Revert "Greeting in Ruby"
1 file changed, 1 deletion(-)
delete mode 100644 hello.rb
$ git lol
* abcd123 (HEAD, main) Revert "Greeting in Ruby"
* b0b54b3 (origin/main, origin/HEAD) Greeting in Java
...
$ ls
Hello.java hello.scm hello.txt # hello.rb 已消失,历史仍然完整
#情况 B:只想恢复某一个文件 → 用 show 找到好版本,用 checkout -- <path> 取回
$ git show 41c4b8f:hello.txt
Hello, version control!
$ git checkout 41c4b8f -- hello.txt # 注意 -- 与路径都不能省
$ git diff --staged # 确认暂存的正是这次恢复
$ ./gradlew test # 恢复后重新验证
$ git commit -m "hello.txt: 恢复 41c4b8f 版本的问候语(撤销 again 的改动)"
【为什么这样更好】 git revert 把”撤销”变成历史里一个可读、可审查、可再撤销的事件:它不重写历史,不改变别人的起点,而且在 git lol 的图上能直接看到发生了什么。git checkout <rev> -- <path> 则把恢复精确到单个文件,配合 git diff --staged 让恢复本身也经过一次人工审查。两者都保留了完整的因果链——这正是 Reading 13(调试)所说的”让问题可被追溯”。
【代码对比解说】 左版的核心风险是用一个不可追溯的动作换取一时的方便;右版则把恢复也纳入版本控制纪律:恢复是一次提交、恢复要跑测试、恢复要写清楚依据(引用了哪个 SHA)。这里最容易踩的坑就是 git checkout <revision> 少了 -- <path>:命令本身不会报错,它只是”帮你”切到了一个旧的提交上,然后你所有的后续提交都会悬空。课程的原话非常直接:如果你看到”HEAD is detached”的警告,先停下来求助;已经发生的分离几乎总能恢复,但必须小心处理。
【设计原则透视】 这与 Reading 5(版本控制)里”对象图不可变”的思想一致:Git 的历史只能被追加,不能被悄悄改写——revert 之所以优于”手工改回去”,正是因为前者保持了历史的可审计性(auditability),这与 Reading 28(伦理)中的伦理结构、以及 Reading 6 中”让行为可被检验”的原则相通。它也提醒你:恢复是一种开发活动,不是一种文件操作——它需要规格(我要回到哪个状态)、需要测试(回到的状态是否正确)、需要记录(为什么这样恢复)。
场景 6:把编译产物与密钥提交进仓库
❌ 错误代码
$ git add .
$ git commit -m "commit everything"
$ git status --short
A build/Hello.class
A build/HelloTest.class
A .idea/workspace.xml
A notes-todo.txt
A secrets.properties
// 硬编码在源码里的 API 密钥:一旦提交,它就永久留在 Git 历史里
public class AnalyticsClient {
/** 分析服务的 API 密钥。 */
private static final String API_KEY = "sk-live-3f9a1c7b2e5d4806";
/**
* 上报一个事件。
*
* @param event 事件名
*/
public void send(String event) {
// HTTP 请求略:Authorization: Bearer <API_KEY>
System.out.println("sending " + event + " with key " + API_KEY);
}
}
【错误代码的问题】
git add .把一切都收进来:.class文件可以从源码重建,提交它们会导致每次重新编译都产生无意义的 diff;.idea/是个人编辑器设置,会与队友的设置反复冲突。- 密钥进入远程仓库等于已经泄漏:即使下一个提交把它删掉,
git log -p仍然能翻出明文;克隆过仓库的每个人都有一份副本。 - 调试语句把密钥打印到日志:
System.out.println(... + API_KEY)让密钥出现在构建日志与终端回滚缓冲区里,泄漏面进一步扩大。 - 清理成本极高:从历史中彻底移除一个文件需要重写历史(filter-repo 之类工具),在共享仓库里几乎不可能安全地做——所以唯一现实的对策是轮换密钥。
✅ 正确代码
$ cat .gitignore
#编译产物(以 # 开头的行是注释)
*.class
build/
target/
out/
#编辑器与 IDE 设置
.idea/
.vscode/
*.swp
#依赖与日志、本机笔记
node_modules/
*.log
notes-todo.txt
#绝不提交的密钥与本机配置
.env
*.pem
secrets.properties
$ git status --short # 只看到真正属于源码的改动
M Hello.java
M HelloTest.java
$ git add Hello.java HelloTest.java
$ git diff --staged
$ git commit -m "hello: 密钥改由环境变量注入,移除硬编码的 API_KEY"
$ git push
// 密钥从环境变量注入:源码里不出现任何秘密
public class AnalyticsClient {
private final String apiKey;
/**
* @param apiKey 从环境变量或密钥管理服务取得的 API 密钥
* @throws IllegalStateException 如果密钥缺失
*/
public AnalyticsClient(String apiKey) {
if (apiKey == null || apiKey.isEmpty()) {
throw new IllegalStateException(
"缺少 API 密钥:请设置环境变量 ANALYTICS_API_KEY");
}
this.apiKey = apiKey;
}
/**
* 从环境变量构造客户端。
*
* @return 使用 ANALYTICS_API_KEY 的客户端
*/
public static AnalyticsClient fromEnvironment() {
return new AnalyticsClient(System.getenv("ANALYTICS_API_KEY"));
}
/**
* 上报一个事件。
*
* @param event 事件名
*/
public void send(String event) {
// HTTP 请求略:Authorization: Bearer <apiKey>
System.out.println("sending " + event); // 日志里绝不出现密钥
}
}
【为什么这样更好】 三条防线同时生效:.gitignore 让生成物与本机配置从一开始就进不了暂存区(git status 因此变得可读——这本身就是 ETU 的收益);密钥通过构造函数注入、由环境变量提供,于是源码可以公开、可以分享、可以在任何机器上克隆而不会泄漏秘密;构造函数对缺失密钥立即失败,把”部署时忘了配环境变量”变成一个启动即崩的明确错误,而不是一个运行时静默的认证失败。日志里也不再打印密钥。
【代码对比解说】 左版的提交名单本身就是一份”我们没想清楚什么该进仓库”的自白;右版把 .gitignore 当成一份显式的政策文档——它写下了团队关于”什么属于版本库”的共识。Java 侧的关键差异是依赖注入:密钥不再是编译期常量,而是构造对象时传入的运行时值,这既解决了泄漏问题,也让 AnalyticsClient 变得可测试(测试可以注入一个假密钥)。注意 .gitignore 的两条重要限制:它只对未跟踪文件生效,已被提交的文件必须先 git rm --cached;它也只挡住你想到过的路径,所以 git add . 这种习惯仍然危险——用 git add <file> 明确指定要提交的内容,是最省心的做法。
【设计原则透视】 这与 Reading 11(AF/RI)的表示不变量同构:apiKey 的 RI 是”非空且来自受信任的注入点”,构造函数在入口处强制它成立(这正是”在对象创建时建立不变式”的经典写法)。它也与 Reading 8(不可变性)一致:private final String apiKey 一旦设定就不再变化,避免”某个方法偷偷换了密钥”这类事故。放到 Reading 28(伦理)的框架里看,硬编码密钥违反了 ACM 准则 1.6(尊重隐私)与 1.7(保密)的精神:它把系统与用户的秘密暴露给每一个能读到仓库的人,而这些人里有相当一部分你并不认识——这正是”扩展的影响圈”在版本控制层面的一次具体呈现。
与其他设计原则的关联
- 与 Reading 5(版本控制 Version Control):本讲是 Reading 5 的多用户版本。Reading 5 讲过对象图、分支、
clone/add/commit/push/log/merge与main这一默认分支名(旧教程里的master请直接替换为main),本讲则把”多人同时推拉同一个仓库”的协调问题补齐。建议把两讲一起读:前者解释”Git 是什么数据结构”,后者解释”团队怎么用它”。 - 与 Reading 4(代码评审 Code Review):本讲的要求”Review what you commit”(
git diff --staged)是自我评审,而 Reading 4 的代码评审是同行评审;在更大的团队里,这两件事由功能分支与拉取请求串成一条流水线(补充说明:6.031 的小项目不要求分支)。提交的原子性直接决定评审质量——一个 400 行的提交无法被认真评审。 - 与 Reading 3(测试 Testing):本讲把”写测试、跑测试、自动化”作为团队准则的前三条,并把 Didit 的自动构建当作”it worked on my machine”的解药。测试在这里获得了一个新角色:它是合并后语义检查的唯一手段(见场景 3)。
- 与 Reading 6/7(规格说明与设计规格 Specifications / Designing Specifications):提交信息是一次改动的规格;接口变更(方法签名与后置条件)必须被传播到所有调用点;冲突解决的本质是重新确定唯一规格。本讲与 Reading 6 是”团队层面”与”代码层面”的同一件事。
- 与 Reading 9(避免调试 Avoiding Debugging):
commit -a最经典的危害就是把println调试输出带进仓库(sp21 原文的原话是”a great way to fill your repo with printlns”)。避免调试输出、避免依赖打印来观察行为,正是本讲”不要提交调试输出”的技术前提。 - 与 Reading 11(抽象函数与表示不变量 AF/RI):合并后的每个模块都必须重新确认自己的 RI 是否仍然成立(场景 4 中
greeting()的”含尾随逗号与空格”就是一条新的 RI);密钥注入的构造检查是”在创建时建立不变式”的教科书用法。 - 与 Reading 13(调试 Debugging):
git log、git show、git blame、git bisect是调试时间维度上 bug 的主要工具;本讲强调的历史可读性(有意义的提交信息、原子提交)直接决定这些工具是否好用。git revert也是一次”可追溯的修复”。 - 与 Reading 28(软件工程中的伦理 Ethical Software Engineering):本讲练习中”Charlie 写了一个能工作的方法,一次都没测试就提交到团队仓库”用的正是 Process 透镜——他违反的是团队约定的过程;而把密钥或用户数据提交进仓库,则同时违反 ACM 准则的 1.6(隐私)与 1.7(保密)。团队版本控制纪律是伦理要求最日常的落地形式。
关键要点
- 开工前
git pull,收工前git push,并且确认所有人处于同一个提交:起点过时是所有”自动合并成功但代码坏了”事故的根源。 - 提交前必须过三道闸门:
git diff --staged看一眼、跑一遍测试、写一句有用的提交信息;并且一次提交只做一件事、保证它能编译:不要用git commit -a跳过审查,原子提交才是git revert、代码评审与git log检索能力的前提。 - 合并成功 ≠ 语义正确:Git 只能发现文本重叠;接口变更靠沟通传播,正确性靠合并后重新跑测试来确认。
- 恢复用工具、不要用手工:撤销整个提交用
git revert,恢复单个文件用git checkout <rev> -- <path>(--与路径都不能省);看到 detached HEAD 警告就停下来求助。 .gitignore从第一天就写,密钥绝不进仓库:判断标准是”能不能从仓库重建”(不能重建才提交)与”是不是秘密”(是秘密就永远不提交)。
常见陷阱与注意事项
- 不先 pull 就开始改代码 → 后果:你编辑的是旧版本,之后必然要合并,而且很可能要花时间解冲突;更糟的是像场景 3 那样”合并成功但语义坏了”,构建是绿的而行为是错的。
- 用
git commit -a或git add .一把梭,或者把生成物写进.gitignore却忘记git rm --cached→ 后果:调试println、本地笔记、.class文件、IDE 设置乃至密钥被一起提交,历史里永久留下噪声与秘密;而被跟踪过的文件不会因为.gitignore自动消失,它继续产生无意义的 diff,.gitignore形同虚设。 - 把冲突标记直接提交,或者随手删掉一边 → 后果:代码无法编译、违反”don’t break the build”,或者无声地删掉了队友的工作。
- 提交信息写成
update、fix、stuff→ 后果:历史失去检索价值,git log/git blame/git revert全部失效;三个月后连你自己都不知道那次改动的动机。 - 在引用与历史上冒险:
git checkout <revision>忘了-- <path>,或者对已推送的分支做 rebase /push --force→ 后果:前者进入 detached HEAD,之后的提交不属于任何分支、极易丢工作;后者改写同事的基点,让他们的提交悬空并制造难以理解的冲突(补充说明:6.031 本身不推荐使用 rebase)。看到 detached HEAD 警告必须停下来求助。 - 以为”团队里只要有人跑过测试就行” → 后果:本讲明确要求每个人为自己的代码正确性负责,并且要跑测试后再提交;依赖别人兜底,等于把不确定性留给整个团队。
思考题(带答案)
问题 1:Alice 与 Bob 从同一版本出发,分别改动了同一份 Hello 程序。请分别回答下面两种情形,并解释 Git 为什么给出了不同的结果。(a) Alice 修改了 greet(..)(在问候语后加上 "!"),Bob 修改了 greeting()(把返回值从 "Hello" 改为 "Ciao"):Git 合并后 Hello.greet("Eve") 的结果是什么?(b) Alice 把 greeting() 改成 return "Ciao";,而 Bob 把逗号移进 greeting()(greet(..) 改成 System.out.println(greeting() + name);、greeting() 改成 return "Hello, ";):Git 能自动合并吗?
答案(a):结果是 Ciao, Eve!。两人改的是文件中不同的位置:Alice 动的是 greet(..) 方法体里的字符串拼接,Bob 动的是 greeting() 的 return 语句。Git 的合并是基于共同祖先的三方合并(three-way merge):把”共同祖先→Alice”与”共同祖先→Bob”两组差异分别计算出来,由于两组差异涉及的行不重叠,它就能同时应用,因此自动合并成功,而且结果在语义上也是正确的。合并后的关键代码是:
public static void greet(String name) {
System.out.println(greeting() + ", " + name + "!"); // 来自 Alice
}
public static String greeting() {
return "Ciao"; // 来自 Bob
}
教学要点是:自动合并成功且结果正确,这是最常见的情形,也正是”Git 帮你省掉了大量协调工作”的地方。但 (b) 会告诉你,自动合并成功并不保证正确。
答案(b):不能自动合并,会产生合并冲突。原因在于 greeting() 方法体里那一行 return "Hello"; 被两个人用不同方式修改了:Alice 改成 "Ciao",Bob 改成 "Hello, "。三方合并的规则是”只有一方改动的行取改动后的版本,双方都改了同一行的就交给人工”,因此 Git 会报出 CONFLICT (content): Merge conflict in Hello.java,并把两个版本用 <<<<<<< / ======= / >>>>>>> 标记在文件里:
public static String greeting() {
<<<<<<< HEAD
return "Ciao";
=======
return "Hello, ";
>>>>>>> bob
}
需要人来判断”正确的意图是什么”:如果两个人分别想改问候语语言与标点位置,这两件事并不矛盾,正确结果是 return "Ciao, ";(并把它写进 Javadoc,作为新的表示契约)。这个例子还说明一件事:冲突是文本层面的,解决冲突是设计层面的——解完必须跑测试。(a) 与 (b) 的对照正是本讲的核心:Git 只比较行的重叠情况,它既不知道也不关心代码的语义。
问题 2:Alice 把 greet(..) 从”打印问候语”改成”返回问候字符串”,Bob 新增了一个 Main.java,内容是 Hello.greet("Eve");。Git 会怎样?运行 Main 会打印什么?
答案:Git 会自动合并成功,因为两处改动不重叠(Alice 改 Hello.java 的方法体与签名,Bob 新增 Main.java)。但在 Java 里,Hello.greet("Eve"); 作为表达式语句是合法的——返回值被丢弃,不会产生静态错误,也不会抛出运行时异常。因此运行 Main 的结果是什么都不打印:没有错误,但结果与 Bob 的意图不符。这正是本讲练习的选项里”we can automatically merge, but the resulting code is broken (no error, wrong answer)”所描述的情形。修复方式是让调用点与新规格一致(System.out.println(Hello.greet("Eve"));),并且为 greet(..) 的返回值语义补上测试,让这类语义破坏在下一次 CI 运行时立刻暴露。这题的核心教训:Git 的冲突检测只覆盖文本位置,不覆盖语义契约;所以”Pull before you start working”、接口变更要沟通、合并后必须重新跑测试,这三条团队准则缺一不可。(补充说明:sp22 的 TypeScript 版本结论完全相同。)
问题 3:你是三人小组的一员。队友 Charlie 刚刚把一个功能提交到共享仓库,提交历史是这样的:git log --oneline 显示 a1b2c3d update,而这次提交同时包含三个文件、四百多行改动,其中还带着两个 System.out.println("DEBUG ...")。请指出至少三个具体问题,并给出你会怎样与 Charlie 沟通(用本讲的准则说话)。
答案:具体问题:(1) 提交信息 update 无法被检索,将来没人能从 git log 找到这次改动的动机,git revert a1b2c3d 也会把四百多行全部撤销,无法只撤销坏的那部分;(2) 一次提交包含三个文件、四百多行,无法被认真 review(违反 Reading 4 的评审实践),而且很可能包含”中途不编译”的状态,违反”don’t break the build”;(3) 两个 DEBUG 输出会污染标准输出,可能让自动化测试的输出无法阅读,甚至让某些测试产生误判(对应 Reading 9 的”避免调试输出”);(4) 从 Reading 28 的 Process 透镜看,问题不在于代码能不能跑,而在于他跳过了团队约定的过程,把自己的不确定性转嫁给了队友。沟通方式(用本讲的准则,而不是用”你写得真烂”):先引用共同约定——”我们约好提交前跑一遍测试、用 git diff --staged 看一眼、并且每个提交都要能描述自己改了什么”;然后提出可操作的补救:如果这次提交还没被别人基于它工作,可以本地整理后重新提交(补充说明:重写已推送的历史会影响他人,必须先与全组确认;更安全的方式是追加一个清理提交,把 println 删掉);最后把它变成流程改进而不只是个人批评——在提交信息里写清动机、以后用 git add -p 逐块暂存、push 后让 Didit 自动跑测试,让”高质量提交”成为结构而不是靠每个人自觉(这正是 Reading 28 的”伦理结构而非伦理个体”在版本控制里的翻版)。
第三部分:软件构造核心原则速查表
本速查表按类别汇总 MIT 6.031 全部关键概念、规则与代码模板,可作为写代码时的检查清单,也可作为考前复习的”一页纸”。 术语以 sp22 原版为准,代码为 Java。
3.1 总纲:三大目标(The Big Three)
| 缩写 | 目标 | 判据 | 主要手段 |
|---|---|---|---|
| SFB | Safe from bugs 免于 bug | 今天正确,未来也正确 | 静态类型、规格说明、不变量、不可变性、测试、断言 |
| ETU | Easy to understand 易于理解 | 与未来的程序员清晰沟通 | 好命名、注释、抽象、封装、避免魔法数字、DRY |
| RFC | Ready for change 易于修改 | 能适应变化而不重写 | 表示独立性、接口、解耦、规格说明、模块化 |
黄金法则:任何一个设计决策都应当能回答”它如何改善 SFB / ETU / RFC 中的至少一项,代价是什么”。
3.2 静态检查与类型(Reading 1–2)
| 概念 | 要点 |
|---|---|
| 静态检查 | 编译期检查;错误在运行前被抓住 |
| 动态检查 | 运行期检查;错误在发生时被抓住 |
| 无检查 | 错误可能无声地产生错误结果 |
| 类型安全 | 类型系统保证变量永远持有该类型的合法值 |
优先级:静态检查 > 动态检查 > 无检查。因为越早发现错误,定位与修复成本越低。
Java 关键规则
// 优先使用 final:让"不可重新赋值"成为编译期保证
final int n = 5; // 不可重新赋值
final List<String> names = new ArrayList<>(); // 引用不可变,但内容仍可变!
陷阱:
final只保证引用不可重新赋值,不保证对象不可变。这是 ETU 上最容易误解的一点。
3.3 规格说明(Reading 6–7)★核心
规格说明 = 契约:规定方法”应该做什么”,不规定”如何做”。
/**
* Find the first occurrence of a value in a list.
*
* @param lst list to search (must not be null)
* @param val value to search for
* @return the smallest index i such that lst.get(i).equals(val),
* or -1 if val does not occur in lst
* @throws NullPointerException if lst is null
*/
public static int find(List<Integer> lst, int val) { ... }
| 组成 | 含义 | 谁负责 |
|---|---|---|
| 前置条件 precondition | 调用者必须满足的条件 | 调用者的义务;违反时实现可以做任何事 |
| 后置条件 postcondition | 实现必须保证的结果 | 实现者的义务;调用者可以依赖 |
| 副作用 side-effect | 方法对输入之外状态的修改 | 必须显式写明(尤其是 mutating methods) |
| 异常 exception | 前置条件被违反时的信号 | 只应表示”调用者的错误”,不应表示”实现的失败” |
规格强弱规则
| 规则 | 说明 |
|---|---|
| 减少前置条件 = 更强 | 能接受更多输入 |
| 增加后置条件 = 更强 | 保证更多输出 |
| 前置条件更弱 + 后置条件更强 = 更强 | 更强 = 更容易被调用者依赖 |
| 不可比较 | 一个前置更弱但后置也更弱时,两者 incomparable |
写好规格的八条准则
- 声明式优于操作式:说”返回最小的索引”,而不是”从 0 开始循环比较”。
- 允许实现自由度(underdetermined / nondeterministic):除非调用者真的依赖唯一答案,否则不要规定过死。
- 前置条件不要过强:能接受的输入越多越好,但代价是实现要处理更多情况——找平衡点。
- 不要用
null:把null从接口中彻底排除;需要”可能没有”时用Optional或明确的返回约定(如-1)。 - 写明空输入与边界(emptiness / boundary)。
- 说明可变性:方法是否修改输入对象、是否返回内部对象的别名。
- 异常用于前置条件违反,不要用于控制流。
- 规格说明不能被实现”顺手加强”——实现必须满足规格,但不能依赖超出规格的行为。
代码模板:前置条件检查
public static int find(List<Integer> lst, int val) {
// fail fast:尽早、显式地检查前置条件
if (lst == null) throw new NullPointerException("lst must not be null");
...
}
3.4 测试(Reading 3)★核心
测试优先编程(test-first programming):先写测试,再写实现。理由:强迫你在写代码前想清楚规格与边界。
| 概念 | 要点 |
|---|---|
| 验证 verification | 确信产品此刻正确 |
| 确认 validation | 确信产品满足用户真实需求 |
| 系统性测试 | 按输入空间划分选取用例,而不是随机或穷举 |
| 输入空间划分 partitioning | 把输入分成若干 subdomain,每个子域至少取一个代表 |
| 边界值 boundary value | 子域的边界(0、空、最大、最小、临界点)是最易出 bug 之处 |
| 黑盒测试 | 只看规格,不看实现 |
| 白盒测试(glass box) | 参考实现选取用例;用于补充黑盒测试 |
| 覆盖率 coverage | 语句覆盖是最弱的标准;分支/路径覆盖更强 |
| 单元测试 vs 集成测试 | 单个模块 vs 模块组合;集成测试前可用 stub 替代未完成模块 |
| 回归测试 | 每次修改后自动重跑全部测试 |
| 迭代开发 | 小步实现、小步测试 |
测试用例设计模板
// 1. 先写测试策略注释,说明如何划分输入空间
// Testing strategy:
// partition on lst: empty list, singleton list, list with duplicates, list without target
// partition on val: present in lst, absent from lst
@Test public void testFindEmptyList() {
assertEquals(-1, Find.find(List.of(), 3));
}
@Test public void testFindAbsent() {
assertEquals(-1, Find.find(List.of(1, 2, 3), 9));
}
黄金法则:测试必须能暴露 bug——一个永远通过的测试没有价值。写测试时问自己:”我要怎么写才能让这个测试失败?”
3.5 不可变性(Reading 8)★核心
可变性的危险来自别名(aliasing):当两个引用指向同一个可变对象,任何一方都能在另一方不知情时改变其状态。
不可变性的三大好处
| 好处 | 机制 |
|---|---|
| SFB | 不可变对象的状态不可能被意外改变,天然免于一类 bug,也天然线程安全 |
| ETU | 读代码时不必追踪”谁还持有这个对象的引用” |
| RFC | 不可变对象可以安全共享、安全缓存、安全作为 Map 的键 |
不可变类的设计规则(四条)
- 所有字段声明为
private final。 - 不提供任何 mutator 方法(包括
setX)。 - 不暴露任何可变内部对象的引用:getter 必须返回防御性拷贝。
- 不把可变外部对象的引用存进字段:构造函数必须对可变参数做防御性拷贝。
代码模板:防御性拷贝
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
if (start.after(end)) throw new IllegalArgumentException("start after end");
// 入向防御性拷贝
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
}
public Date start() {
// 出向防御性拷贝
return new Date(start.getTime());
}
}
Collections.unmodifiableList 的局限
List<String> inner = new ArrayList<>(List.of("a"));
List<String> view = Collections.unmodifiableList(inner); // 只是只读视图
inner.add("b"); // 视图跟着变!因为它是 view,不是 copy
// view.add("c"); // 会抛 UnsupportedOperationException
unmodifiableList提供的是不可修改的视图(底层仍可变),不是不可变对象。要真正不可变,必须做拷贝并切断对底层集合的引用:List.copyOf(inner)。
3.6 抽象数据类型 ADT(Reading 10–12)★核心
ADT 的定义:由一组操作刻画的数据类型,其内部表示对使用者隐藏。
| 概念 | 含义 |
|---|---|
| 抽象 abstraction | 忽略底层细节,只保留高层概念 |
| 模块化 modularity | 系统由可独立理解、替换的单元组成 |
| 封装 encapsulation | 通过访问控制(private)保护内部状态 |
| 信息隐藏 information hiding | 使用者不需要、也不能知道内部表示 |
| 表示独立性 representation independence | 内部表示的改变不影响使用者 |
操作分类(必须掌握)
| 类别 | 定义 | 示例(List<E>) |
|---|---|---|
| Creator | 创建新对象(构造函数或工厂方法) | new ArrayList<>()、List.of() |
| Producer | 由旧对象产生新对象(不修改旧的) | List.of(...)、concat、subList |
| Observer | 观察对象状态,返回其他类型的值 | size()、get(i)、isEmpty() |
| Mutator | 修改对象状态 | add()、remove()、clear() |
设计 ADT 的准则
- 操作应当少而正交:每个操作做一件事,组合覆盖所有需要的行为。
- 不要暴露内部表示(representation exposure)。
- 优先选择不可变 ADT;可变 ADT 只在必要时使用。
- 用
private字段 +public方法实现封装。 - 在规格说明中写明所有操作的前置/后置条件,让使用者不需要看实现。
Java 中的 ADT 实现方式
| 方式 | 适用场景 |
|---|---|
类 + private 字段 | 通用;具体类型 |
| 接口 + 实现类 | 需要多种实现或多态时(List / ArrayList) |
泛型 class Bag<E> | 需要容纳任意元素类型 |
enum | 值域小而有限的类型(enum Suit { HEARTS, SPADES }) |
| 抽象类 | 多个实现共享部分代码时 |
// 接口与实现分离
public interface Shape { double area(); }
public final class Circle implements Shape {
private final double r;
public Circle(double r) { this.r = r; }
@Override public double area() { return Math.PI * r * r; }
}
// 使用者只依赖 Shape 接口,可以自由更换实现(RFC)
3.7 抽象函数与表示不变量(Reading 11)★★最核心
两个必须写在代码里的注释
public class Tweet {
private final String author;
private final String text;
private final List<String> hashtags;
// Abstraction function:
// AF(t) = a tweet posted by t.author, with text t.text,
// and hashtags t.hashtags
// Representation invariant:
// author != null && text != null && hashtags != null
// && no element of hashtags is null
// && no element of hashtags is the empty string
// Safety from rep exposure:
// All fields are private and final.
// String is immutable, so returning it directly is safe.
// hashtags is copied in the constructor and its getter returns
// an unmodifiable copy, so no internal alias escapes.
}
| 概念 | 定义 | 要点 |
|---|---|---|
| 抽象函数 AF | 从内部表示 R 到抽象值 A 的映射:AF: R → A | 回答”内部状态代表什么”;可以是多对一(非单射) |
| 表示不变量 RI | 内部表示必须始终满足的条件:RI: R → boolean | 回答”哪些内部状态是合法的” |
| 表示暴露 | 内部表示逃逸到 ADT 之外 | 破坏封装,使 RI 无法被保证;用防御性拷贝防止 |
| 有益的可变性 | 对使用者不可见、且保持 AF 不变的内部修改 | 如缓存(memoization);对观察者而言对象仍是”不可变的” |
AF 与 RI 的三条铁律
- 所有构造函数必须建立 RI,所有 mutator 必须保持 RI。
- 所有 observer 和 producer 可以假设 RI 成立(只要前置条件满足)。
- AF 和 RI 必须写在代码注释里,否则它们只存在于实现者脑中,ETU 直接受损。
checkRep() 模板
private void checkRep() {
assert author != null;
assert text != null;
assert hashtags != null;
for (String h : hashtags) {
assert h != null && !h.isEmpty();
}
}
// 在每个构造函数的末尾、每个 mutator 的末尾调用 checkRep()
assert默认在 JVM 中关闭,需要在运行参数中显式启用-ea。这正是 6.031 提倡的”fail fast”。
AF/RI 的其他用途
- 定义
equals():两个对象相等 ⟺ 它们的抽象值相等(而非内部表示相同)。 - 定义
toString():应当输出抽象值,而不是内部字段的机械拼接。 - 证明方法正确:写代码前先问”这个操作会破坏 RI 吗?”
配方(Recipes for programming)
- 写规格说明(前置/后置条件)。
- 选择内部表示,写下 AF 和 RI。
- 实现构造函数、观察者、生产者、修改者。
- 在每个方法结尾调用
checkRep()。 - 写测试。
3.8 相等性(Reading 15)★核心
等价关系三性质:自反(reflexive)、对称(symmetric)、传递(transitive)。任何 equals() 实现都必须满足这三条,否则 Set/Map 的行为会出错。
| 概念 | 含义 | 适用 |
|---|---|---|
| 引用相等 | a == b:指向同一个对象 | 始终可用 |
| 值相等 / 观察相等(observational equality) | 两个对象的所有观察结果都相同 | 不可变类型 |
| 行为相等(behavioral equality) | 两个对象在未来的所有操作下表现一致 | 可变类型(几乎不可能实现,一般不用) |
规则
- 不可变类型:应当实现
equals(),用抽象值定义相等。 - 可变类型:应当使用引用相等(不覆写
equals()),因为观察相等会随时间失效。
equals() 实现模板
@Override
public boolean equals(Object obj) {
if (this == obj) return true; // 快速路径 + 自反
if (!(obj instanceof Duration)) return false; // 类型检查(null 会返回 false)
Duration other = (Duration) obj;
return this.minutes == other.minutes; // 比较所有决定抽象值的字段
}
@Override
public int hashCode() {
// 契约:equals 相等 ⇒ hashCode 必须相等
return Objects.hash(minutes);
}
hashCode() 契约
a.equals(b)⟹a.hashCode() == b.hashCode()(必须)a.hashCode() == b.hashCode()不要求a.equals(b)(哈希碰撞是允许的)- 同一次程序运行中,只要对象状态未变,
hashCode()必须稳定不变。
最重要的陷阱:hashCode() 绝不能基于可变字段。如果对象被放进 HashSet 后字段被修改,hashCode 变化,对象就”丢失”在错误的哈希桶里,再也查不到了。
3.9 递归与递归数据类型(Reading 14、16、17)
递归实现的结构
// 1. 基础情形 base case
// 2. 递归步骤 recursive step:把问题分解为更小的同类问题
/** @return the subsequences of s, each as a String, in an unspecified order */
public static List<String> subsequences(String s) {
List<String> result = new ArrayList<>();
if (s.isEmpty()) { // base case
result.add("");
return result;
}
char first = s.charAt(0);
String rest = s.substring(1);
for (String sub : subsequences(rest)) { // recursive step
result.add(sub);
result.add(first + sub);
}
return result;
}
递归 vs 迭代:递归在递归定义的数据结构(链表、树、表达式)上更自然、更易证明正确;迭代在需要极致性能、或深度可能很大(栈溢出)时更好用。
递归数据类型 ImList<E>(不可变列表)
public interface ImList<E> {
static <E> ImList<E> empty() { ... } // creator
ImList<E> cons(E e); // producer
E first(); // observer(前置条件:非空)
ImList<E> rest(); // producer(前置条件:非空)
boolean isEmpty(); // observer
int size(); // observer
}
关键点:
cons/rest是 producer(返回新列表,不修改原列表),因此ImList是不可变的。递归数据类型的 AF 天然是”递归的”:AF(empty) = 空序列;AF(cons(e, l)) = [e] + AF(l);RI(cons(e, l)) = e != null && RI(l)。
函数式三件套(Reading 16)
| 操作 | 签名 | 含义 |
|---|---|---|
map | (A → B) × List<A> → List<B> | 对每个元素应用函数,长度不变 |
filter | (A → boolean) × List<A> → List<A> | 保留满足谓词的元素 |
reduce | (B × A → B) × B × List<A> → B | 从左到右累积,把列表折叠成单个值 |
List<String> names = people.stream()
.filter(p -> p.age() >= 18) // 过滤
.map(Person::name) // 变换
.collect(Collectors.toList()); // 收集
int total = numbers.stream().reduce(0, Integer::sum); // 归约
3.10 文法、解析与小语言(Reading 18–19、26–27)
文法(grammar)四要素
| 要素 | 含义 | 示例 |
|---|---|---|
| 终结符 terminal | 不能再展开的符号 | "(", "+", "3" |
| 非终结符 nonterminal | 可继续展开的符号 | <expr>, <term>, <number> |
| 产生式 production | 展开规则 | <expr> ::= <term> "+" <expr> |
| 根非终结符 root | 起始符号 | <expr> |
正则表达式运算符:连接、重复 * + ?、选择 \|、字符类 [...]、分组 (...)。
正则表达式无法描述递归嵌套结构(如配对的括号),需要文法。
解析器(parser)流水线
字符序列 --(文法/解析器生成器)--> 解析树 parse tree --(遍历)--> 抽象语法树 AST --> 解释器求值
Java 中用 Pattern/Matcher
Pattern p = Pattern.compile("(\\d+)-(\\d+)");
Matcher m = p.matcher("2024-05");
if (m.matches()) {
int year = Integer.parseInt(m.group(1));
}
小语言(little language / DSL)的价值:为一个特定领域设计专门的、小而精确的语言,比用通用语言硬编码更 ETU(表达意图更清晰)也更 RFC(新增能力只需扩文法)。
访问者模式(visitor pattern,Reading 27)
interface Visitor<R> { R visitPlus(Plus e); R visitInt(Int e); }
interface Expr { <R> R accept(Visitor<R> v); } // 双重分派 double dispatch
| 优点 | 代价 | |—|—| | 新增操作只需加一个 Visitor 类,不必改数据类型 | 新增数据变体必须修改所有 Visitor(expression problem) |
3.11 并发(Reading 21–24)★核心
两大模型
| 模型 | 机制 | 优点 | 风险 |
|---|---|---|---|
| 共享内存 shared memory | 多个线程读写同一块内存 | 直接、高效 | 竞态条件、死锁 |
| 消息传递 message passing | 并发单元之间通过队列传消息,不共享内存 | 天然避免竞态 | 需要设计协议;可能死锁、可能阻塞 |
基本概念:进程 process(独立地址空间)、线程 thread(共享地址空间)、时间分片 time slicing、交错 interleaving、竞态条件 race condition。
竞态条件的本质
// ❌ 错误:counter++ 不是原子操作
public void increment() { counter++; } // 实际是 read-modify-write 三步
两个线程可能都读到 counter = 5,都写回 6,结果丢失一次自增。
并发安全的四层策略(按优先级)
- 不要共享可变状态:优先使用不可变对象(R8、R11)。不可变对象天然线程安全。
- 限制可变状态的作用域:把可变状态封装在单个线程内。
- 用同步机制保护共享可变状态:
- 互斥锁
mutex/ 临界区critical section(R23); - 消息传递 + 阻塞队列(R24)。
- 互斥锁
- 用不可变快照通信:线程之间传递不可变消息,而不是共享可变对象。
Java 同步写法(Reading 23)
public class BankAccount {
private int balance; // guarded by this
public synchronized void deposit(int amount) {
balance += amount;
checkRep();
}
public synchronized int getBalance() { return balance; }
private void checkRep() { assert balance >= 0; }
}
规则:每个被锁保护的字段都必须在注释中声明它由哪把锁保护(”guarded by”)。
死锁(deadlock)的成因与预防 死锁发生的四个必要条件:互斥、持有并等待、不可抢占、循环等待。 最实用的预防手段是打破”循环等待”——全局统一的锁获取顺序。
// ❌ 错误:两个线程以不同顺序获取同一对锁 → 死锁
// 线程 A:lock(x); lock(y);
// 线程 B:lock(y); lock(x);
// ✅ 正确:用唯一的全局顺序(例如按对象身份哈希)决定加锁顺序
private static void lockInOrder(Object a, Object b) { ... } // 先锁"小"的
消息传递写法(Reading 24)
// 阻塞队列作为同步机制:生产者-消费者模式
private final BlockingQueue<Message> queue = new LinkedBlockingQueue<>();
queue.put(msg); // 队列满时阻塞
Message m = queue.take(); // 队列空时阻塞
消息传递天然避免了共享内存的竞态,是”用通信代替共享”(Do not communicate by sharing memory; share memory by communicating)的体现。
并发黄金法则
- 能不用并发就不用;能用不可变就不用锁。
- 每个共享可变字段都必须有明确的保护策略。
- 不要依赖
sleep()来”修”竞态——那是掩盖而非修复。 - 并发 bug 无法靠测试可靠发现(调度具有偶然性):应靠设计与推理。
3.12 网络(Reading 25)
| 概念 | 要点 |
|---|---|
| 客户端/服务器模式 | 服务器监听端口、等待连接;客户端主动发起连接 |
| 地址与端口 | IP 定位主机,端口(0–65535)定位主机上的服务 |
| TCP | 可靠的、双向的字节流;不保留消息边界 |
| Socket | 通信端点;ServerSocket 负责 accept(),Socket 负责读写 |
| 协议 | 客户端与服务器交换的字节序列的格式约定(如 HTTP) |
| 阻塞式 I/O | read() 在无数据时阻塞;accept() 在无连接时阻塞 |
// 服务器骨架
try (ServerSocket server = new ServerSocket(PORT)) {
while (true) {
Socket sock = server.accept(); // 阻塞直到有连接
new Thread(() -> handle(sock)).start(); // 每个连接一个线程
}
}
关键设计原则:把”网络读写”抽象成 ADT 的接口(如
MessageQueue),业务逻辑就不必关心字节流细节——这正是 R10 表示独立性在网络层的应用,也让程序在测试时可以用假实现替换真实网络(stub)。
3.13 代码质量与调试(Reading 4、9、13)
代码审查清单
| 类别 | 检查点 |
|---|---|
| Bug | 潜在 bug、off-by-one、边界条件、异常的吞掉 |
| 重复 | DRY:重复代码意味着修改时会漏改 |
| 一致性 | 代码与规格说明是否一致 |
| 防御性 | 是否 fail fast、是否检查前置条件 |
| 作用域 | 全局变量、过大的变量作用域、一变量多用 |
| 常量 | 魔法数字应命名为具名常量 |
| 命名 | 名字要表达意图;避免 data、temp、flag |
| 排版 | 一致的缩进、空白帮助阅读、不要一行塞太多 |
| 注释 | 解释”为什么”,不复述”是什么” |
| 设计 | 是否误用/未用 ADT、规格说明、不变量等概念 |
避免调试的四道防线(Reading 9,按优先级)
| 防线 | 手段 |
|---|---|
| 1. 让 bug 不可能发生 | 静态类型、不可变对象、final、把非法状态设计成不可表示 |
| 2. 让 bug 显而易见(localize) | 模块化、封装、缩小变量作用域、fail fast |
| 3. 让 bug 尽早失败 | assert、checkRep()、前置条件检查 |
| 4. 彻底测试 | 系统性测试、回归测试 |
系统化调试:科学方法(Reading 13)
- 重现 bug(找到最小可重现输入,可用 delta debugging / slicing 缩小范围)
- 观察数据:实际值是什么?
- 提出假设:哪个环节出错了?为什么?
- 做实验验证假设(用
assert、打印、断点) - 重复,直到定位
- 修复,并添加回归测试
反模式:靠猜、随机改代码、”试试加上这个看看行不行”。调试的目标是理解,不是让程序暂时不报错。
3.14 版本控制与团队协作(Reading 5、29)
| 概念 | 要点 |
|---|---|
| 仓库 / 工作副本 | repository(对象图)vs working copy(你的文件系统) |
| 提交 commit | 一次快照;含作者、时间、信息、指向父提交的指针 |
| HEAD | 当前所在提交 |
| 暂存区 staging area | git add 后、git commit 前的中间区域 |
| 分支 / 合并 | 提交图上的分支引用;merge 产生合并提交 |
| 合并冲突 | 同一处被两边修改;需手工解决后 git add + git commit |
团队工作流(推荐)
git switch -c feature/parser # 1. 从主干切出功能分支
# ... 小步提交,提交信息写清"为什么" ...
git push -u origin feature/parser # 2. 推送到远端
# 3. 发起 Pull Request,请同伴代码审查(Reading 4)
# 4. 审查通过后合并回主干,删除分支
git switch master && git pull && git merge feature/parser
反模式清单
- 直接在
master上开发并提交半成品。 - 一次提交改动上千行(无法审查)。
- 提交信息写 “fix”、”update”、”asdf”。
- 提交编译产物、IDE 配置、密钥(应写入
.gitignore)。 - 用
git push --force覆盖他人提交。
3.15 软件工程伦理(Reading 28)
四种道德透镜
| 透镜 | 核心问题 |
|---|---|
| 后果主义 consequentialism | 这个系统的结果对最大多数人是好是坏? |
| 义务论 deontology | 是否违反了不可逾越的责任与规则(如不欺骗、不伤害)? |
| 美德伦理 virtue ethics | 一个正直、诚实的工程师会怎么做? |
| 社会契约 social contract | 是否违背了公众对专业人士的信任? |
实践检查点
- 隐私:是否收集了超出必要范围的数据?是否默认安全?
- 偏见:训练数据/规则是否对某些群体不公?
- 透明度:用户是否知道系统在做什么?
- 安全关键:失效模式是否会伤害人?是否有降级方案?
- 举报(whistleblowing):内部渠道失败时,工程师的责任边界在哪?
MIT 6.031 把伦理放在最后一讲,意在说明:SFB / ETU / RFC 是技术标准,而”对谁负责”是工程标准。二者缺一不可。
