Skip to content
Published on

有主见的工具 — 代码格式化器与 Linter 的哲学

分享
Authors

引言 — 再次升温的格式化器之争

2026 年上半年,GeekNews 和 Hacker News 上关于代码格式化器的话题再次成为热点。像 gofumpt 这类比 gofmt 更严格的「有主见(opinionated)」工具受到关注,用 Rust 写成的超高速 Python 工具 ruff 也事实上确立为标准,于是那个古老的争论重新点燃——「让工具替我们决定风格,真的好吗?」

这个争论其实并不新鲜。用 Tab 还是空格、大括号放不放在下一行、要不要加分号。这些问题和软件的历史一样久远,也在每个团队里引发过激烈的宗教战争。但过去十年间,风向变了。「格式由工具决定,人不再争论」这一哲学赢了。本文要探讨的是,这场胜利为何会发生,以及各语言生态的工具分别做出了怎样的哲学选择。

核心论点是这样的:一个好格式化器的价值,不在于它产出的风格「正确」,而在于它消除了关于风格本身的争论。有主见的工具拿走了选择权,作为交换,还给你的是认知上的自由。

格式化器解决了什么问题

要理解格式化器的价值,不妨想象一个没有它的世界。五个团队成员各自用不同的缩进、不同的换行规则、不同的括号风格写代码。Pull Request 的 diff 里,真正的改动和风格改动混在一起,审查变得困难,代码审查时间里相当一部分被浪费在「这里再加一个空格」之类毫无意义的挑刺上。

格式化器从根本上解决了这个问题。保存的那一刻,或者提交的那一刻,所有代码都被转换成同一种既定的格式。由此得到的收益有三点。

  • 争论的终结:风格不再是讨论的对象,工具说了算。
  • 一致性:整个仓库看起来像出自一人之手,阅读代码库的认知负担随之下降。
  • 干净的 diff:因为格式始终是规范化的,diff 里只剩下有意义的改动。

第三点尤其重要。当风格改动不再污染 diff,审查者就能把注意力完全集中在真正的逻辑改动上。

主见程度的光谱 — 严格与灵活

不是所有格式化器都秉持同一种哲学。工具在「给用户多少选择权」这条轴上,落在不同的位置。

一端是 gofmt。它几乎没有配置项。Go 团队的决定很明确:「争论格式是在浪费时间。定一个正确答案,让所有人遵守。」没有选项本身就是一种功能,没人会为了一份 .gofmt 配置文件而争吵。

另一端是传统工具。它们提供数百个选项,让团队可以按自己想要的方式拼出风格。灵活,但要付出代价——选项一多,围绕这些选项的争论又会卷土重来。

把这条光谱整理成表格:

工具语言哲学可配置性
gofmtGo最少选项,一个正确答案几乎没有
gofumptGo比 gofmt 更严格无(更强)
prettierJS/TS 等有主见但保留部分选项少数选项
blackPython毫不妥协的格式化极少
ruffPython超高速,格式化+lint 一体可大幅调整

有意思的是 gofumpt。它是那些觉得 gofmt「不够严格」的人做出来的工具,连 gofmt 允许的那点微小自由都被它拿走,从而产出更一致的结果。它在「有主见」这个方向上又往前迈了一步。

深入看各工具的哲学

gofmt 与 gofumpt — Go 的零容忍

Go 从一开始就把格式化变成了语言文化的一部分。go fmt 命令内置在标准工具链里,几乎所有 Go 代码看起来都一模一样。把下面这段代码丢给 gofmt,

package main
import "fmt"
func main(){
x:=1
fmt.Println(x)
}

结果总是会被规范化成这样。

package main

import "fmt"

func main() {
	x := 1
	fmt.Println(x)
}

缩进用 Tab,import 会排序,连大括号的位置都是固定的。gofumpt 在此基础上更进一步,叠加了删除多余空行、强制特定惯用写法等额外规则。

prettier — Web 生态的统一者

prettier 统一了 JavaScript、TypeScript、CSS、Markdown 等整个 Web 生态。它只提供少数几个选项(行长、分号、引号种类),其余全部交给工具决定。这种「有主见但并非完全独裁」的平衡,带来了广泛的采纳。

black — Python 的毫不妥协

black 的口号很有名:「不管什么颜色,只要是黑色就行。」用户几乎没有能调整的东西,大概只有行长可以改,其余全部固定。这种极端的简单性在 Python 社区里获得了强烈的响应。

ruff — 速度改变的格局

ruff 用 Rust 写成,比现有的 Python 工具快出几十倍。而且它把 lint 和格式化统一成了一个工具。速度不只是单纯的便利——因为它在每次保存、每次提交时都会立即执行,开发者几乎感觉不到它的存在。摩擦一旦消失,采纳就变得容易。

格式化器与 Linter 的区别

这里要厘清一个常被混淆的概念:格式化器和 linter 是不同的东西。

  • 格式化器改变代码的形态——缩进、换行、空白,绝不改变代码的含义。
  • Linter 对代码的内容发出警告——未使用的变量、潜在的 bug、反模式、违反约定的写法。

用表格看这个区别会更清楚。

区分格式化器Linter
对象代码的形态代码的内容与质量
例子缩进、换行未使用变量、空值风险
自动修复总是(只改形态)部分(仅安全的部分)
改变含义可能(修复时需留意)
失败时重新格式化警告或报错

也有边界模糊的情况。像 ruff 这样把 linter 和格式化器统一起来的工具正在增多。但从概念上讲,把二者区分开依然更安全——格式化器的改动总是安全的,而 linter 的自动修复有时会改变含义,需要审查。

CI 强制与 pre-commit — 自动化所处的位置

格式化器要真正发挥力量,就必须被强制执行。如果开发者「忘了」格式化,一致性就会被打破。强制执行的位置大致有三处。

   开发者编辑器          本地提交           CI 流水线
+---------------+     +---------------+     +---------------+
| 保存时格式化  | --> | pre-commit    | --> | fmt --check   |
| (即时反馈)    |     | (拦截提交)    |     | (拦截合并)    |
+---------------+     +---------------+     +---------------+
   摩擦最小              中间关卡             最后防线

理想情况是这三处叠加防守。编辑器保存时格式化摩擦最小,pre-commit 钩子能拦住格式错误的提交进入仓库,而 CI 是无论如何都不让它通过的最后防线。

在 CI 里,通常不是「应用」格式,而只是「检查」格式,像这样:

# 格式不对时以非零退出码失败
gofmt -l .
# 或者 Python 场景
ruff format --check .

--check 模式不会修改文件,只判定「有没有需要修的文件」。如果存在格式不对的文件,CI 就会失败,开发者在本地格式化后再重新推送。也有 CI 直接修改代码并自行提交的方式,但这会产生意料之外的提交,容易引发争议,需要谨慎对待。

pre-commit 钩子的配置示例如下。

repos:
  - repo: local
    hooks:
      - id: format-check
        name: format
        entry: ruff format
        language: system
        types: [python]

团队引入策略

把新的格式化器引入既有代码库,比想象中棘手。最大的障碍是「大重排(the big reformat)」——工具第一次应用时,仓库里几乎每一个文件都会变动,这个巨大的提交会污染 git blame 历史。

有几个能缓解这个问题的实务技巧。

  • 单一重排提交:只做一个只改格式的提交,绝不与其他改动混在一起。
  • blame 忽略配置:把这个提交登记到忽略文件中,让 git 在 blame 时跳过它。
  • 避免渐进式引入:一个文件一个文件慢慢格式化,会让 diff 一直脏下去,不如一次性全部完成。

把引入顺序整理一下:

  1. 团队就工具和最小配置达成一致。
  2. 用单一提交完成全仓重排。
  3. 把这个提交登记到 blame 忽略文件中。
  4. 启用 pre-commit 和 CI 检查。
  5. 向团队宣布启用保存时格式化。

格式化器与代码审查的关系

格式化器对代码审查的影响,比想象中更大。如前所述,格式一旦自动化,审查者就从风格挑刺中解放出来,这从根本上改变了审查的质量。

想象一下没有格式化器的团队的审查场景。每个 Pull Request 上都堆满了「这里的缩进不一样」「这个括号要放到下一行」「去掉一个空格」之类的评论。这类指正大多是对的,但没有创造任何价值,只是在消耗审查者的时间和作者的耐心。更糟的是,真正重要的问题——逻辑缺陷或设计问题——会被这些琐碎的指正淹没。

格式化器把这一层完全移除之后,审查就能聚焦在本质上。

项目没有格式化器时有格式化器时
风格评论
diff 噪音
审查焦点分散逻辑/设计
情绪消耗

「情绪消耗」这一项尤其重要。风格上的指正常常会被当成针对个人。作者会觉得「我的品味被否定了」,审查者则变成「爱唠叨的人」。当工具替代人承担这份冲突时,人与人之间的审查就会变得远比之前更具建设性、更协作。没有人会对 gofmt 生气。

这就是格式化器隐藏的价值。它不只是让代码变好看,更是在改善团队的人际关系。通过消除争论的种子,让人们把精力用在真正重要的协作上。

格式化之战的历史 — 我们是怎么走到今天的

回顾一下格式化器走向胜利之前的历史,能帮我们更好地理解今天的处境。从软件的早期开始,开发者就在为代码风格争吵。这种争吵看似琐碎,实际上激烈得惊人。

最有名的争论是缩进——用 Tab 还是空格,如果是空格,用两格还是四格。这个问题让开发者分裂了几十年,也成了梗图和玩笑的固定题材。大括号开在哪里、一行的最大长度、要不要分号,情况也是一样。

这类争论的问题在于,它们没有任何客观的正确答案。大多数风格选择纯粹是品味问题,没有哪一方明显更优。可正因为没有正确答案,争论反而没完没了——每个人都想为自己的品味辩护,团队在无法达成共识的情况下不断消耗精力。

转折点来自两个认知。第一,风格的一致性远比风格的具体内容重要。无论是哪种风格,只要整个团队一致地使用,就能获得大部分好处。第二,靠人手动维持这种一致性是不现实的——人会犯错,会遗忘,只有工具才能强制实现完美的一致性。

这两个认知相遇,催生了有主见的格式化器。当 gofmt 宣布「不要争论,定一个就好」时,那不只是一个工具,而是一种文化宣言。而这份宣言在过去十年间蔓延到了整个软件世界。我们今天能从风格争论中解脱出来,正是这段历史的结果。

确定性与幂等性 — 好格式化器的条件

一个好的格式化器应当具备两个技术性质:确定性(determinism)与幂等性(idempotency)。

确定性是指相同的输入永远产生相同的输出。不管在哪个开发者的机器上跑、什么时候跑,结果都必须一致。如果格式化器因环境或时间不同而产出不同结果,那就是一场灾难——如果每个团队成员得到的格式都不一样,使用格式化器的理由本身就消失了。

幂等性是指对已经格式化过的代码再格式化一次,结果不会改变。格式化一次之后的结果,再格式化一遍,也应当保持原样。设想一下:

   原始代码 --格式化--> 结果 A --格式化--> 结果 A (相同)
                                  |
                          幂等性被打破
                                  |
   原始代码 --格式化--> 结果 A --格式化--> 结果 B (不同!)

幂等性被打破的格式化器,会在 CI 检查中引发无穷无尽的问题。开发者在本地格式化后推送,但 CI 上的格式化器版本有微妙差异,又报告说需要再格式化一次,于是陷入死循环。这就是为什么整个团队固定使用完全相同版本的格式化器很重要。

正因为有这两个性质,格式化器的版本管理比想象中更重要。许多团队会在依赖文件里显式锁定格式化器版本,并强制 CI 与本地使用完全相同的版本。

大规模代码库中的性能

格式化器一旦变慢,开发体验就会变差。每次保存都要卡顿几秒,开发者就会想把它关掉。这正是 ruff 这类工具为什么能改变格局的原因。

传统的 Python 工具用 Python 本身写成,速度较慢。相比之下,ruff 用 Rust 写成,快出几十倍。这种速度差距不只是单纯的便利——一个在每次保存、每次提交时都会执行的工具,如果反应即时,开发者几乎不会意识到它的存在;摩擦一旦消失,采纳就会毫无阻力地发生。

把性能左右引入成败的场景整理成表格:

场景慢的格式化器快的格式化器
保存时自动格式化感受到延迟,想关掉即时,无感
pre-commit 钩子每次提交都要等瞬间完成
大规模 CI 检查流水线瓶颈可忽略不计
编辑器集成打断打字节奏顺畅

这就是「速度也是一种功能」这句话的由来。无论规则多好的工具,只要慢,最终都会被关掉,而被关掉的工具毫无价值。

格式忽略注释 — 逃生口的困境

几乎每个格式化器都提供一种忽略注释,用来把特定区域排除在格式化之外。这是为对齐好的数据表格、或刻意排布过的代码这类自动格式化反而会损害可读性的场景准备的逃生口。

但这个逃生口带来了一种困境。没有忽略注释时,格式化器偶尔会强加不好的结果;忽略注释一旦泛滥,格式化器一致性这一核心价值就会崩塌。当忽略注释散落在仓库各处,最终会变成「哪里格式化了、哪里没格式化」这样无法预测的状态。

实务原则是这样的:忽略注释要真正罕见地使用,只在有明确理由时才用,并附上一句简短说明为什么要忽略。如果忽略注释变得越来越多,那可能不是格式化器不好的信号,而是代码结构本身出了问题的信号。

各语言生态的文化差异

有意思的是,对格式化器的态度因语言生态而异。这种差异反映了各语言的设计哲学和社区文化。

Go 社区从一开始就把「一种格式」当作文化接受了下来。没有人抱怨 gofmt 没有选项,反而以此为傲。相比之下,JavaScript 生态长期以来风格多样性很大,在 prettier 出现之前,每个团队各不相同。Python 曾经处于两者之间,后来靠 black 和 ruff 迅速实现了标准化。

这种文化差异对实务有启发意义。引入一门新语言时,弄清楚那个生态的标准格式化器是什么、社区抱持怎样的态度,就能避开不必要的争论。在标准稳固的生态里,跟随标准是最优选择;在标准薄弱的生态里,团队尽早定下一个标准才是关键。

自动化的陷阱与批判视角

格式化器总体上是一种祝福,但也有陷阱。

过度严格的反作用。 工具太严格时,有时会强制产出损害可读性的结果。比如把刻意对齐成表格的代码打乱。大多数工具为这类情况提供了格式忽略注释,但滥用会让工具的价值消失。

Linter 规则的无限膨胀。 格式化器消除了争论,linter 却可能反而增加争论——团队会为「这条规则要不要打开」没完没了地争论。规则集一旦膨胀,开发者就会对警告变得麻木,连真正重要的警告也会被忽视。

工具能替代思考的错觉。 格式漂亮不代表代码好。格式化器只是整理形态,不会改善设计。如果依赖工具、认为「通过了就没问题」,就会错过真正的问题。

配置文件管理的负担。 工具越灵活,配置文件就越大,而这份配置本身也会变成需要维护的对象。这正是 gofmt 这类无配置工具的吸引力所在——没有配置可管理,也就没有什么可以为之争吵的了。

多语言仓库中的格式化

现代很多仓库并不只用一种语言——后端用 Go,前端用 TypeScript,基础设施脚本用 Python,配置用 YAML 和 JSON。在这样的多语言仓库里,就会出现需要为每种语言协调不同格式化器的问题。

因为各语言的标准格式化器不同,无法用一个工具格式化整个仓库。取而代之的做法是,把各语言的格式化器汇聚到一个统一的入口之后。

   仓库格式化命令 (一个)
        |
        +--> Go 文件      --> gofmt
        +--> TS 文件      --> prettier
        +--> Python       --> ruff
        +--> YAML/JSON    --> prettier

这个结构里重要的是,开发者不需要为每种语言记住不同的命令。像 pre-commit 框架这样的工具,会根据文件类型自动调用对应的格式化器。开发者只需要提交,背后每个文件都会被正确的工具格式化。

多语言仓库的陷阱在于工具版本管理会变得复杂——要分别锁定多种语言的格式化器版本,并让 CI 与本地保持一致,管理的节点因此增多。为此,最好设一份在一处统一声明工具版本的清单文件,让所有环境都引用它。

Linter 规则的三个层级

要用好 linter,不应该不假思索地把所有规则都打开,而应该分层来考虑。在实务中,linter 规则大致分为三个层级。

  • 错误层:几乎可以确定是 bug 的东西。未使用的变量、不可达代码、错误的比较。这些应当强制执行。
  • 警告层:通常不好但有例外的东西。复杂度过高的函数、过长的参数列表。保留为警告,但不强制。
  • 品味层:纯粹是偏好问题的东西。这些大多不打开更好,打开只会带来争论。

把这些层级整理成表格:

层级例子处理方式
错误未使用变量、空值风险在 CI 中强制失败
警告复杂度过高、函数过长显示但放行
品味特定语法偏好大多禁用

关键是只强制执行错误层,其余谨慎对待。如果把所有规则都当作错误强制执行,开发者会被琐事绊住脚,很快就会想办法绕过 linter 本身。规则集越小越清晰,就越受尊重。

引入到遗留代码库

到目前为止的讨论都以新项目为前提。但现实中,许多团队面对的是已经混杂了数年不同风格的遗留代码库。在这里引入格式化器是一个更难的问题。

最大的担忧是,大重排会不会破坏什么。格式化器只改变形态,原则上不会改变行为,但极少数情况下会有例外——比如字符串内部的空白,或某些宏处理,可能出现细微差异。所以引入之后跑一遍完整测试、确认没有回归,是必不可少的一步。

遗留代码库引入的安全顺序如下:

  1. 安装格式化器,但暂时不改动任何文件。
  2. 以检查模式运行,掌握会有多少文件受影响的规模。
  3. 确认测试套件是否足够。如果不够,先补强测试。
  4. 用单一提交完成全仓重排。
  5. 跑一遍完整测试,确认没有回归。
  6. 把这个提交登记到 blame 忽略文件中。

这时测试套件的重要性就体现出来了。测试足够扎实的话,大重排一旦破坏了什么,会立刻被抓到;测试薄弱的话,大重排就变成了一场赌博。所以颇为吊诡的是,引入格式化器的前提条件往往正是测试覆盖率。

另一个现实的考量是正在进行中的分支。大重排提交几乎会触及每一个文件,因此会与尚未合并的分支产生大规模冲突。所以最好选在正在进行的工作较少的时点(比如刚发布之后)做大重排,并提前通知所有团队成员,让他们先整理好自己的分支。

格式化器做不到的事 — 命名与结构

不要高估格式化器的力量,这一点很重要。格式化器只是整理代码的形态,并不会提升代码的质量。看看下面这两个函数。

def f(x, y, z):
    a = x + y
    b = a * z
    return b

这段代码格式完美——缩进和空白都无可挑剔。但函数名 f 和变量名 ab 什么都没说明。格式化器永远修不好这个问题。命名、函数拆分、抽象层级这些真正的设计问题,属于人的判断范畴。

这就是不能盲目迷信格式化器和 linter 的原因。如果通过了工具检查就让人产生「这已经足够好了」的错觉,关于命名和结构这类真正重要的思考就会消失。工具只能保证形态的下限,却抬不高设计的上限。好代码依然要靠人来创造。

实务建议

把前面的内容压缩成实务指南:

  • 语言若有标准格式化器,直接用,不要重新发明轮子。
  • 配置越少越好,选项越多,争论就越多。
  • 在概念上把格式化器和 linter 区分开,让各自忠于自己的角色。
  • CI 中只做检查,不做应用。
  • 引入时用单一重排提交,并配合 blame 忽略。
  • linter 规则保持精简,防止警报疲劳。
  • 记住工具只整理形态,不会替代设计。

编辑器集成 — 消除摩擦的最后一块拼图

格式化器要真正在团队里扎根,就必须顺畅地集成进开发者的日常工具——编辑器。理想的体验是,开发者几乎意识不到格式化器的存在:写代码、保存,就自动整理好了,不需要额外敲命令,也不需要记规则。

编辑器集成的核心方式是保存时格式化(format on save)。保存文件的瞬间,格式化器就会运行,把代码规范化。这样一来,开发者就能只专注于逻辑,不必操心风格。

   编写代码 (不用管风格)
        |
        v
   保存 (Ctrl+S / Cmd+S)
        |
        v
   格式化器自动运行
        |
        v
   规范化后的代码显示在屏幕上

这里重要的是,整个团队要共享同一套编辑器设置。如果把编辑器设置文件提交进仓库,新成员一打开仓库,正确的格式设置就会立即生效,从而避免「在我的编辑器里格式化结果不一样」这类困惑。

不过保存时格式化也有需要留意的地方。在非常大的文件上,保存可能会变慢;也会出现打开别人的代码只改了一行,结果整个文件被重新格式化、导致 diff 变大的情况。后者大多可以靠前面提到的单一重排提交先把仓库整体规范化来解决。

格式化器的未来 — 集成与智能化

格式化工具的演进方向可以归纳为两个词:集成与智能化。

集成这股趋势,ruff 展现得很清楚。过去,格式化器、linter、import 排序器各自是独立的工具,要分别安装、配置、接入 CI。ruff 把它们合而为一,简化了配置和执行。工具越少,管理负担越小,一致性越高。这股集成趋势很可能会蔓延到其他语言生态。

智能化这股趋势还处于早期。传统的格式化器纯粹基于规则。但也有人在讨论一个方向:让 AI 学习代码风格,自动把握团队的惯例,连规则难以明确规定的细微风格也能跟上。不过,格式化器的根本要求——确定性与幂等性——在这里成了障碍,因为 AI 的概率性本质,与「相同输入产生相同输出」这一原则相冲突。

所以短期内,格式化器的核心仍然会是确定性的、基于规则的。AI 很可能停留在辅助角色上,提出规则建议或处理例外情况。因为对格式化器来说,可预测性是一个远比灵活性更重要的价值。

结语

有主见的格式化器的兴起,展现了软件文化的成熟。我们认识到风格争论不会创造任何价值,把这些争论托付给工具之后,才能真正专注于要紧的问题。gofmt 开启的「没有选项」这一哲学,如今已经蔓延到了众多语言生态中。

但工具消除了争论,不代表它也消除了判断。格式化器定下形态,linter 警示陷阱,但好的设计和清晰的代码,依然是人的工作。把工具擅长的事交给工具,把人的精力用在工具触及不到的地方——这才是有主见的工具给我们的真正礼物。

把原本用来争论风格的时间,拿去打造更好的软件吧。

参考资料