你每次 git commit 都在用设计模式,但你可能一个都没认出来
Linus Torvalds 2005 年用两周写了 Git 的第一个版本。两周。你现在用的那个 git push、git merge、git rebase 的底层骨架,是一个人在两周内写完的。
一开始我觉得这是天才程序员的传说。后来仔细读了一遍 Git 的数据模型和核心代码,才意识到:不是 Linus 写得快,而是他选了一套天然正确的设计。这套设计跟设计模式里的好几个经典模式撞得死死的——不是因为他刻意去套,而是问题本身就长那样。
内容寻址存储:一个文件的"地址"就是它的哈希
Git 的数据存储方式跟文件系统完全不一样。文件系统按路径存,Git 按内容存。每个对象(文件、目录、commit)都有一个 SHA-1 哈希值作为"地址"。
.git/objects/
f4/
3b2a8c1d9e0f5678... # 一个 blob 对象
a1/
b2c3d4e5f6... # 一个 tree 对象
这个设计跟享元模式的想法出奇地一致:相同内容的文件只存一份。你的项目里有 100 个文件,但如果两个文件内容一模一样(比如重复的配置文件),Git 只存一个 blob 对象。两个文件名指向同一个哈希。
这跟享元模式的经典场景——围棋棋盘上黑白棋子的复用——本质是一样的:去重、复用、节省内存。只不过围棋复用的是对象引用,Git 复用的是文件内容。
更重要的是,内容寻址意味着 "改了什么 = 新哈希 = 新对象"。旧对象永远不会被修改,这跟不可变对象的设计理念完全一致。回滚的时候不需要"撤销操作",因为旧版本一直都在,切过去就行。
Git 的对象模型:Blob、Tree、Commit 的三层结构
Git 的世界里只有四种对象:
- Blob:文件内容,不存文件名,不存路径,只存内容
- Tree:目录结构,存的是 "文件名 + 对应的 blob/tree 哈希"
- Commit:一次提交的快照,指向一个 tree,指向上一个 commit
- Tag:一个固定的引用名,指向某个 commit
这其实就是组合模式。Tree 可以包含 Blob,也可以包含 Tree,递归下去就是整个项目的目录结构。遍历一棵 Tree 跟遍历文件系统目录树的逻辑完全一样:
Commit
└── Tree (根目录)
├── Blob "README.md"
├── Tree "src/"
│ ├── Blob "main.java"
│ └── Blob "utils.java"
└── Tree "tests/"
└── Blob "main_test.java"
组合模式在这里的价值是"统一的操作接口"。你不需要区分"这是一个文件还是一个文件夹"——遍历的时候递归就行,文件就是叶节点,目录就是中间节点。Git 的内部代码在处理 tree 和 blob 时大量用了这个递归逻辑。
暂存区:活的备忘录模式
git add 之后文件去了哪里?去了 staging area——暂存区。暂存区就是一个"还没提交的备忘录"。
备忘录模式的定义是:在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后恢复。
Git 的暂存区完美匹配这个定义:
- Originator(发起人):工作目录里的文件
- Memento(备忘录):暂存区里的文件快照(存在
.git/index) - Caretaker(负责人):Git 本身,负责保存和恢复快照
你的工作流程就是备忘录模式的标准流程:
# 创建一个备忘录(保存当前状态)
git add file.java
# 继续修改...
# 从备忘录恢复状态
git checkout -- file.java # 恢复到暂存区的版本
git stash 是这个模式的进阶用法。git stash 把工作区和暂存区的改动都保存到一个临时的 commit 里,然后把工作区恢复到 HEAD 的状态。这完全就是备忘录模式的"保存 + 恢复"流程,只是 stash 用 commit 对象而不是 index 文件来存备忘录。
# 保存现场(创建备忘录)
git stash push -m "临时保存,先去修 bug"
# 切分支修 bug...
# 恢复现场(从备忘录恢复)
git stash pop
命令模式:git 的每一个操作都是一个命令
Git 的命令行接口本身就是命令模式的标准实践。
你执行 git log --oneline --graph,底层发生的事情是:
- 解析参数:
log是命令,--oneline和--graph是选项 - 构建命令对象:
git内部有一个cmd_log函数,接收解析好的参数 - 执行命令:调用
cmd_log(argc, argv, ...) - 返回结果:输出格式化的日志
Git 源码里的 git.c 文件定义了一个命令表:
static struct cmd_struct commands[] = {
{ "add", cmd_add, RUN_SETUP | NEED_WORK_TREE },
{ "commit", cmd_commit, RUN_SETUP | NEED_WORK_TREE },
{ "log", cmd_log, RUN_SETUP },
{ "push", cmd_push, RUN_SETUP },
// ... 几十个命令
};
每个命令都是一个独立的函数,有统一的接口签名。新增一个子命令只需在表里加一行 + 写一个函数,完全不用改框架代码。这是命令模式最典型的应用场景——把"操作"封装成"对象"(这里是函数指针),让调用者(git 主程序)不需要知道命令的具体实现。
更妙的是,Git 的"撤销"操作并不是靠命令模式的 undo,而是靠它内容寻址的不可变性。你不需要"撤销一个 commit",你只需要把 HEAD 指针移到上一个 commit。因为旧 commit 从来没被删过,所以撤销就是指针移动,零成本。
分支:一个 41 字节的文件就是策略模式
Git 的分支是什么?就是一个文件,里面写着一个 40 字符的 SHA-1 哈希。
cat .git/refs/heads/main
# 输出:f43b2a8c1d9e0f5678abc123...
就这么点内容。一个 41 字节的文件。
分支切换的本质,是改变 HEAD 指向的分支引用,然后把工作目录的文件内容替换成新 commit 指向的 tree。这跟你理解的"分支 = 文件夹"完全不同。
分支合并的时候,Git 有三种策略:
- Fast-forward:目标分支是源分支的直接祖先,直接移动指针——O(1) 操作
- Three-way merge:两个分支有分歧,用共同祖先做三方合并——算法复杂度取决于文件数
- Recursive merge:当有多个共同祖先时的默认策略——递归地做三方合并
这三种策略的选择就是策略模式的体现。不同的合并场景用不同的算法,但对用户来说接口只有一个:git merge。Git 内部会根据分支的拓扑结构自动选择合并策略,你不需要关心。
这也是为什么 git merge 有时候快如闪电(fast-forward),有时候要你手动解决冲突(three-way merge),有时候甚至会在 criss-cross 历史面前还要做递归合并——同一句命令,底层跑的是完全不同的算法。
为什么 Git 的设计经得起时间考验
回到开头那个问题:Linus 为什么只用两周就写出 Git?
因为他选了正确的抽象。内容寻址存储保证了数据永不丢失;对象模型用组合模式统一了文件和目录的遍历;暂存区用备忘录模式让 "保存-恢复" 变得零成本;命令表让新增功能不需要动框架;分支策略让合并自动选择最优算法。
这些设计选择叠加在一起,让 Git 在 20 年后依然是不可替代的工具。不是因为 Git 有什么黑科技,而是它的底层模型恰好贴合了版本控制的本质需求。设计模式不是 Linus 刻意套的——他是找到了一组合适的抽象,而这些抽象恰好就是设计模式描述的那些结构。
我在做一个以卡皮巴拉为主角讲设计模式的小程序「爪爪代码冒险记」,把 23 种设计模式用漫画加答题的方式讲一遍。Git 里这些隐藏的设计模式我也会做成关卡,毕竟每个程序员每天都在用,但从来没意识到。搜一下「爪爪代码冒险记」,或者等我后面的文章同步更新。