《软件设计哲学》 vs 《代码整洁之道》—— 一场关于软件设计的公开辩论
公开辩论 · 软件设计 · 2024.9 – 2025.2
《软件设计哲学》VS《代码整洁之道》
一场关于软件设计的公开辩论 —— 中文整理翻译版
JOHN
John Ousterhout《软件设计哲学》
(A Philosophy of Software Design)作者
UB
Robert C. Martin“Uncle Bob”
《代码整洁之道》(Clean Code)作者
原文:github.com/johnousterhout/aposd-vs-clean-code
目录
相关链接
《软件设计哲学》第 2 版,作者 John Ousterhout
《代码整洁之道》,作者 Robert C. Martin
引言:如何评判一个软件设计思想
JOHN:(Uncle)Bob,你好!你我各写了一本软件设计方面的书。我们在一些问题上有共识,但在我最近的著作《软件设计哲学》(以下简称 APOSD)与你的经典之作《代码整洁之道》之间,存在相当大的意见分歧。感谢你同意在这里讨论这些分歧。
UB: 乐意之至,John。开始之前我想说,我仔细通读了你的书,读得非常愉快,其中充满了有价值的洞见。有些地方我不同意,比如 TDD 和 “抽象优先的增量式开发”,但整体上我非常喜欢。
JOHN: 我想和你讨论三个话题:方法长度、注释、测试驱动开发。不过在深入之前,先让我们比较一下总体的设计哲学。当你听到一个与软件设计相关的新想法时,你如何决定是否认可它?
我先说。对我来说,软件设计的根本目标是让系统易于理解和修改。我用 “复杂度”(complexity)一词指那些让系统难以理解和修改的东西。对复杂度贡献最大的因素与信息有关:
开发者为了完成一项任务,需要在脑中装下多少信息?
开发者所需的信息有多容易获得、多显而易见?
开发者需要掌握的信息越多,在这个系统上工作就越难。如果所需的信息不够明显,情况会更糟。最坏的情况是:某条关键信息藏在某段遥远的代码里,而开发者对此闻所未闻。
当我评估一个与软件设计相关的想法时,我会问:它能否降低复杂度?这通常意味着要么减少开发者必须知道的信息量,要么让所需信息变得更加显而易见。
轮到你了:你决定认可哪些想法时,有通用的原则吗?
UB: 我同意你的思路。一种纪律或技术应该让程序员的工作更轻松。我要补充的是,我们最想帮助的程序员不是代码的作者,而是必须阅读并理解他人代码(或一周后的自己)的程序员。程序员花在 “读代码” 上的时间远超 “写代码”,所以我们要减轻的是 “读” 这一活动。
一、方法长度
JOHN: 我们的第一个分歧点是方法长度。
在《代码整洁之道》第 34 页你说:” 函数的第一条规则是要短小。第二条规则是还要更短小。“之后你说” 函数几乎不应该有 20 行长 “,并建议函数应该” 只有两三行或四行长 “。第 35 页你说:”if 语句、else 语句、while 语句等内部的代码块应该只有一行长。很可能那一行应该是一次函数调用。“我在整本书里找不到任何地方暗示函数可能” 过短 “。
我同意:把代码划分为相对较小的单元(“模块化设计”)是减少程序员脑中同时所需信息量最重要的方式之一。其思想当然是:把一块复杂功能封装进一个带简单接口的独立方法,开发者就可以使用这个方法(或阅读调用它的代码)而不必了解其实现细节,只需了解其接口。最好的方法是提供大量功能但接口非常简单的方法:它把很大的认知负担(阅读详细实现)替换成小得多的认知负担(学习接口)。我把这类方法称为 “深层”(deep)方法 。
然而,与软件设计中的大多数想法一样,分解也可能过头。方法越拆越小,进一步细分的收益就越低:每个接口背后隐藏的功能量在下降,而接口往往变得更复杂。我把这类接口称为 “浅层”(shallow):它们在减少程序员所需知识方面帮助不大。最终会达到这样的点:使用该方法的人必须理解其实现的每一个方面,这样的方法通常毫无意义。
过度分解的另一个问题是容易产生纠缠(entanglement)。如果为了理解某个方法内部如何工作,你还必须去读另一个方法的代码,那么这两个方法就是纠缠的(APOSD 中称为 “连体”conjoined)。如果你在读代码时发现自己不得不在两个方法的实现之间来回翻页,那是一个危险信号,说明这两个方法可能纠缠了。纠缠的方法难以阅读,因为你需要在脑中同时装下的信息并不在同一处。纠缠的方法通常可以通过合并来改进,让所有代码集中在同一处。
《代码整洁之道》关于方法长度的建议如此极端,以至于鼓励程序员创造出同时患有 “浅层接口” 和 “纠缠” 两种毛病的小到不能再小的方法。设定诸如” 方法 2-4 行、if/while 语句体一行” 之类的武断数字上限,更是加剧了这个问题。
UB: 虽然我确实强烈推荐非常短的函数,但我不认为书中设定了武断的数字上限。你提到的第 34 页那个 2-4 行的函数,来自我和 Kent Beck 1999 年为了练习 TDD 而写的 Sparkle 小程序。我觉得了不起的是那个小程序里大部分函数都只有 2-4 行,因为它是一个 Swing 程序 —— 而 Swing 程序往往有非常长的方法。
至于设定限制,第 13 页我讲得很清楚:书中的建议对我和其他作者有效,但未必对所有人有效。我并不宣称拥有最终权威,也不宣称某种绝对的 “正确性”。它们只是供人参考。
JOHN: 我认为这些问题的实质最好通过具体的代码例子来说明。但在那之前,Bob,我想问你:你认为代码存在 “过度分解” 的可能吗,还是说 “越小一定越好”?如果你认为过度分解是可能的,你如何识别它已经发生?
UB: 过度分解当然可能。举个例子:
void doSomething() {doTheThing()} // 过度分解了。我决定分解到何种程度的策略,是那条老规矩:一个方法应该 “只做一件事”(One Thing)。如果我能有意义地从一个方法中提取出另一个方法,那就说明原方法做了不止一件事。“有意义” 意味着:被提取出的功能可以起一个描述性的名字;并且它做的事比原方法少。
JOHN: 不幸的是,“只做一件事” 的方法会导致过度分解:
“一件事” 这个词很模糊,容易被滥用。比如一个方法只有两行代码,它难道不是在干两件事吗?
你没有提供任何有用的 “护栏” 来防止过度分解。你举的例子极端得没有用处;“能否起名” 这一限定也帮不上忙 —— 任何东西都能起名。
“只做一件事” 在很多情况下干脆就是错的。如果两件事密切相关,完全可以把它们实现在同一个方法里。例如,任何线程安全的方法都要先获取锁,再执行其功能。这是两件 “事”,但它们应该属于同一个方法。
UB: 让我先回应最后一点。你建议加锁和执行临界区应该放在同一个方法里。但我会倾向于把加锁与临界区分开:
void concurrentOperation() {
lock()
criticalSection();
unlock()
}这把临界区与锁解耦,允许它在不需要加锁的时候被调用(例如单线程模式),或者在锁已由他人设置的情况下被调用。
至于 “容易被滥用” 的论点,我认为这不是什么大问题。If 语句容易被滥用,Switch 语句容易被滥用,赋值语句也容易被滥用。一件事容易被滥用,并不意味着它就该被回避或压制,只意味着人们应该适当小心。始终存在一个叫判断力(judgment)的东西。
所以,当在一个大方法里看到这样一段代码:
...
amountOwed=0;
totalPoints=0;
...把它们提取成下面这样就是判断力差的表现,因为这种提取没有意义 —— 实现并不比接口更深入:
void clearAmountOwed() {
amountOwed=0;
}
void clearTotalPoints() {
totalPoints=0;
}然而,下面这样提取可能是好的判断,因为接口是抽象的,实现有更深的细节:
void clearTotals() {
amountOwed=0;
totalPoints=0;
}后者有一个漂亮的描述性名字,抽象程度刚好足以有意义而不冗余;而且这两行代码紧密相关,足以算作 “只做一件事”:初始化。
JOHN: 当然任何东西都可以被滥用。但最好的设计方法会鼓励人们用正确的方式做事、抑制滥用。不幸的是,“只做一件事” 规则出于我上面给出的原因鼓励滥用。
软件设计者当然需要运用判断力:不可能为软件设计提供精确的配方。但好的判断力需要原则和指导。《代码整洁之道》关于分解的论证,包括 “只做一件事” 规则,是片面的。它给出了何时 “切分” 的强烈、具体、量化的建议,却几乎没有给出任何 “如何判断已经切过头了” 的指导。我能找到的全部,只有第 36 页关于清单 3-3 的两句话例子(还相当琐碎),淹没在一堆 “切、切、切” 的 exhortations 之中。
我使用 “深 / 浅” 这种刻画的原因之一,是它能同时覆盖取舍的两面:它既能告诉你何时分解是好的,也能告诉你何时分解会让事情变糟。
UB: 你说得对,我在书中确实没有多谈如何进行这种判断。2008 年时我关心的是打破那个年代(web 早期)常见的那种超大函数的习惯。在第二版里我已经更平衡了。
不过,如果我必须犯错,我宁愿偏向分解。考虑和可视化各种分解是有价值的 —— 如果我们判断它们过头了,随时可以内联回去。
JOHN: 回到你的 clearTotals 例子:
clearTotals方法似乎与 “只做一件事” 规则相矛盾:amountOwed和totalPoints这两个变量看不出特别相关,同时初始化它们难道不是在做两件事吗?你说两条语句都是在执行 “初始化”,所以它只是一件事。那是不是说,用一个方法同时初始化两个毫无共同点的完全独立对象也是可以的?我怀疑不是。感觉你是在努力为 “只做一件事” 规则拼凑一个干净的框架 —— 这让我觉得它并不是一条好规则。在没有更多上下文的情况下,我怀疑
clearTotals方法是否有意义。
UB: 我希望你同意,下面这两个例子中前者稍微好一点:
public String makeStatement() {
clearTotals();
return makeHeader() + makeRentalDetails() + makeFooter();
}public String makeStatement() {
amountOwed=0;
totalPoints=0;
return makeHeader() + makeRentalDetails() + makeFooter();
}JOHN: 呃,实际上不。第二个例子完全清晰直白:我看不出拆分它有任何好处。
SPOCK(也就是 UB): 有意思。
JOHN: 我认为,如果看一个非平凡的代码例子,我们的分歧会更容易厘清。来看看《代码整洁之道》中的 PrimeGenerator 类,即第 145-146 页的清单 10-8。这个 Java 类生成前 N 个质数:
package literatePrimes;
import java.util.ArrayList;
public class PrimeGenerator {
private static int[] primes;
private static ArrayList<Integer> multiplesOfPrimeFactors;
protected static int[] generate(int n) {
primes = new int[n];
multiplesOfPrimeFactors = new ArrayList<Integer>();
set2AsFirstPrime();
checkOddNumbersForSubsequentPrimes();
return primes;
}
private static void set2AsFirstPrime() {
primes[0] = 2;
multiplesOfPrimeFactors.add(2);
}
private static void checkOddNumbersForSubsequentPrimes() {
int primeIndex = 1;
for (int candidate = 3; primeIndex < primes.length; candidate += 2) {
if (isPrime(candidate))
primes[primeIndex++] = candidate;
}
}
private static boolean isPrime(int candidate) {
if (isLeastRelevantMultipleOfLargerPrimeFactor(candidate)) {
multiplesOfPrimeFactors.add(candidate);
return false;
}
return isNotMultipleOfAnyPreviousPrimeFactor(candidate);
}
private static boolean isLeastRelevantMultipleOfLargerPrimeFactor(int candidate) {
int nextLargerPrimeFactor = primes[multiplesOfPrimeFactors.size()];
int leastRelevantMultiple = nextLargerPrimeFactor * nextLargerPrimeFactor;
return candidate == leastRelevantMultiple;
}
private static boolean isNotMultipleOfAnyPreviousPrimeFactor(int candidate) {
for (int n = 1; n < multiplesOfPrimeFactors.size(); n++) {
if (isMultipleOfNthPrimeFactor(candidate, n))
return false;
}
return true;
}
private static boolean isMultipleOfNthPrimeFactor(int candidate, int n) {
return candidate == smallestOddNthMultipleNotLessThanCandidate(candidate, n);
}
private static int smallestOddNthMultipleNotLessThanCandidate(int candidate, int n) {
int multiple = multiplesOfPrimeFactors.get(n);
while (multiple < candidate)
multiple += 2 * primes[n];
multiplesOfPrimeFactors.set(n, multiple);
return multiple;
}
}在深入这段代码之前,我鼓励每一位读者先花时间读一遍代码,得出自己的结论。你觉得这段代码容易理解吗?如果容易,为什么?如果不容易,是什么让它变复杂的?
另外,Bob,你能确认你仍然认可这段代码吗(也就是说,这段代码恰当地体现了《代码整洁之道》的设计哲学,这就是你认为它在生产中应有的样子)?
UB: 啊,是的,PrimeGenerator。这段代码出自 Donald Knuth 1982 年那篇关于文学化编程的论文。程序原本用 Pascal 写成,由 Knuth 的 WEB 系统自动生成,是一个非常大的单方法;我把它翻译成了 Java。
当然,这段代码从来不是为生产准备的。Knuth 和我都把它当作教学例子。在《代码整洁之道》中它出现在名为 Classes(类)的章节。那一章的教训是:一个非常大的方法往往包含许多不同的代码段,这些代码段更适合分解为独立的类。
在那个章节里,我从那个函数中提取了三个类:PrimePrinter、RowColumnPagePrinter 和 PrimeGenerator。
其中提取出的类之一是 PrimeGenerator。它原本是这样的(这段我没有在书中发表)。变量名和整体结构都是 Knuth 的:
public class PrimeGenerator {
protected static int[] generate(int n) {
int[] p = new int[n];
ArrayList<Integer> mult = new ArrayList<Integer>();
p[0] = 2;
mult.add(2);
int k = 1;
for (int j = 3; k < p.length; j += 2) {
boolean jprime = false;
int ord = mult.size();
int square = p[ord] * p[ord];
if (j == square) {
mult.add(j);
} else {
jprime=true;
for (int mi = 1; mi < ord; mi++) {
int m = mult.get(mi);
while (m < j)
m += 2 * p[mi];
mult.set(mi, m);
if (j == m) {
jprime = false;
break;
}
}
}
if (jprime)
p[k++] = j;
}
return p;
}
}虽然那一章的教训已经讲完了,但我不想让那个方法看起来那么过时,所以作为事后补记,我把它清理了一下。我的目标不是讲解如何生成质数,而是想让读者看到:违反单一职责原则的大方法,如何被拆分成几个更小、命名良好的类,类里包含几个更小、命名良好的方法。
JOHN: 谢谢你的背景介绍。虽然那段代码的细节不是那一章的重点,但可以想见,这段代码代表了你认为在给定算法下 “正确” 且 “最干净” 的写法 —— 而这正是我不同意的地方。
PrimeGenerator 有许多设计问题,但现在我只聚焦方法长度。这段代码被切得这么碎(8 个小到不能再小的方法),读起来很困难。首先看 isNotMultipleOfAnyPreviousPrimeFactor 方法:它调用 isMultipleOfNthPrimeFactor,后者又调用 smallestOddNthMultipleNotLessThanCandidate。这些方法既浅又纠缠:要理解 isNot...,你必须读另外两个方法并把那些代码同时装进脑子。例如,isNot... 有副作用(它修改了 multiplesOfPrimeFactors),但除非你读完三个方法,否则你看不出来。
UB: 我觉得你说得有道理。十八年前,当我正沉浸在这次重构中时,这些名字和结构对我来说完全合理。现在它们对我来说仍然合理 —— 但那是因为我又一次理解了算法。几天前我第一次重新面对这个算法时,我在这些名字和结构上挣扎过。一旦我理解了算法,名字和结构就完全合理了。
JOHN: 即使对一个理解算法的人,这些名字也是有问题的;我们稍后讨论注释时再谈。而且,如果作者日后回来时连作者自己都觉得代码不再有意义,那就说明代码有问题。“代码最终能被理解(哪怕要经历巨大的痛苦)” 并不能为它的纠缠开脱。
UB: 要是我们有这样的水晶球,能帮未来的自己免于这样的 “巨大痛苦” 就好了。;-)
JOHN: 不需要水晶球。PrimeGenerator 的问题相当明显,比如纠缠和接口复杂度。也许你惊讶于它难以理解,但我不。换句话说,如果你无法预判自己的代码是否容易理解,那说明你的设计方法论有问题。
UB: 有道理。不过我要说,我在解读你的重写(见下文)时也经历了同等的 “痛苦与折磨”。所以,显然我们两人的方法论都不足以把读者从这种挣扎中解救出来。
JOHN: 回到我开场关于复杂度的论述:把 isNot... 拆成三个方法,并没有减少你需要在脑中记住的信息量,它只是把信息摊开了,以至于 “你需要把三个方法一起读” 这件事不那么明显。而且由于代码被拆开,更难看清整体结构:读者不得不在方法之间来回翻页,实际上是在脑中重建一个整体版本。因为这些片段都是相关的,这段代码放在同一处才最容易理解。
UB: 我不同意。看这个 isNotMultipleOfAnyPreviousPrimeFactor:
private static boolean isNotMultipleOfAnyPreviousPrimeFactor(int candidate) {
for (int n = 1; n < multiplesOfPrimeFactors.size(); n++) {
if (isMultipleOfNthPrimeFactor(candidate, n))
return false;
}
return true;
}如果你信任 isMultipleOfNthPrimeFactor 方法,那么这个方法完全可以独立阅读。我的意思是:我们遍历所有之前的 n 个质数,看候选数是不是它们的倍数。非常直白。当然,你可以合理地问:如何判断候选数是不是倍数?那你就需要去看 isMultiple... 方法。
JOHN: 这段代码看起来确实简单直白。可惜,这种表象具有欺骗性。
如果读者信任 isMultipleOfNthPrimeFactor 这个名字(它暗示一个无副作用的谓词)而不去读它的代码,他们就不会意识到它有副作用,也不会意识到这些副作用对传给 isNot... 的 candidate 参数构成了约束(它必须在每次调用之间单调不减)。要理解这些行为,你必须同时读 isMultiple... 和 smallestOdd...。当前的分解把这一重要信息对读者隐藏了。
如果说有什么比 “不理解代码” 更容易导致 bug 的事,那就是 “你以为你理解了,其实没有”。
UB: 这是一个合理的担忧。不过,函数是按照它们被调用的顺序呈现的,这缓解了问题。因此可以预期读者已经看过主循环,知道 candidate 每次迭代增加 2。
埋在 smallestOddNth... 里的那个副作用确实更成问题。既然你指出来了,我也不太喜欢它。但它不应该妨碍对 isNot... 的基本理解。
一般来说,如果你信任被调用方法的名字,那么理解调用方并不需要理解被调用方。例如:
for (Employee e : employees)
if (e.shouldPayToday())
e.pay();如果我们把这两个方法调用替换成它们的实现,代码并不会变得更容易理解 —— 那样的替换只会模糊意图。
JOHN: 这个例子之所以成立,是因为被调用的方法与父代码相对独立。可惜 isNot... 不是这样。事实上,isNot... 不仅与它调用的方法纠缠,还与它的调用方纠缠:isNot... 只有在 ” 于一个 candidate 单调递增的循环中被调用” 时才成立。要说服自己它是对的,你必须找到调用它的代码,并确认 candidate 在调用之间从不递减。把 isNot... 与调用它的循环拆开,让读者更难相信它是对的。
UB: 正如我前面说的,这正是方法按那个顺序排列的原因。我预期等你读到 isNot... 时,你已经读过 checkOddNumbersForSubsequentPrimes,知道 candidate 是按 2 递增的。
JOHN: 我们简单谈谈这个,因为这是我又一个不同意《代码整洁之道》的地方。如果方法纠缠了,任何巧妙的方法定义排序都无法解决问题。在这种具体情形下,有两个别的方法插在 checkOdd... 的循环和 isNot... 之间,所以读者还没读到 isNot... 就已经忘了循环上下文。而且,真正制造对循环依赖的代码并不在 isNot... 里,而在距离 checkOdd... 更远的 smallestOdd... 里。
UB: 我真诚地怀疑会有人忘记 candidate 是按 2 递增的。这是相当明显的避免浪费的方式。
JOHN: 开场时我讲过,减少人们需要在脑中同时记住的信息量很重要。在这种情形下,读者必须一边读四个与循环基本无关的中间方法,一边记住那个循环。你显然认为这很容易、很自然(我不同意)。但情况比这更糟:没有任何迹象表明 checkOdd... 的哪些部分以后会变得重要,所以唯一安全的做法是记住每一个方法里的一切,直到你遇到所有可能由它衍生的其他方法。而且,为了把这些片段联系起来,读者还必须重建调用图,才能注意到:即使隔了 4 层方法调用,smallestOdd... 里的代码也对 checkOdd... 里的循环施加了约束。这是施加在读者身上不合理的认知负担。
如果两段代码紧密相关,解决办法是把它们放在一起。把它们分开 —— 哪怕只是物理上相邻的方法 —— 都会让代码更难理解。
在我看来,PrimeGenerator 里的所有方法都纠缠在一起:为了理解这个类,我必须把所有方法同时装进脑子。读代码时我不断在方法之间来回翻页。这是一个危险信号,说明代码被过度分解了。
Bob,你能帮我理解你为什么把代码拆成这么小的方法吗?方法这么多,是不是有什么我漏掉的好处?
UB: 我想你我只能就此各执己见。总的来说,我相信 “小而命名良好” 的方法原则以及关注点分离。一般来说,如果你能把一个大方法拆成几个命名良好、关注点各异的小方法,从而暴露它们的接口和高层功能分解,那就是好事。
遍历奇数是一个关注点。
判断素性(primality)是另一个。
划掉质数的倍数又是另一个。
在我看来,把这些关注点分离并命名,有助于暴露算法的工作方式 —— 哪怕以一些纠缠为代价。
而在你的方案(我们马上就会看到)中,你也以类似的方式拆分了算法。只不过你不是把关注点拆成函数,而是把它们拆成带注释的区块。
你说在我的方案里,读者读其他函数时需要把循环上下文记在脑中。我建议:在你的方案里,读者读你的解释性注释时也需要把循环上下文记在脑中。他们也许要在各个区块之间 “来回翻页” 才能建立理解。
也许你担心在我的方案里,“翻页” 的距离(按行数算)比你的长。我不确定这是否是个重要论点 —— 毕竟它们都装在同一屏里(至少在我的屏幕上),而且地标相当明显。
方法长度小结
JOHN: 看来是时候收尾这一节了。下面这样总结我们的一致与分歧,合理吗?
我们都同意模块化设计是好事。
我们都同意过度分解是可能的,并且《代码整洁之道》第一版没有提供多少识别过度分解的指导。
我们不同意 “分解到什么程度”:你建议把代码拆成比我小得多的单元。你认为你推荐的额外分解让代码更容易理解;我认为它走得太远,反而让代码更难理解。
你认为 “只做一件事” 规则在判断力辅助下会带来恰当的分解;我认为它缺乏护栏,会导致过度分解。
我们都同意
PrimeGenerator内部按方法分解是有问题的。你指出你写PrimeGenerator的主要目的是展示如何分解成类,而不是如何把一个类内部进一步分解成方法。类内方法之间的纠缠,对你的困扰不如对我大。你认为方法分解带来的好处可以弥补纠缠造成的问题;我认为不能:分解后的方法一旦纠缠,就比不分解更难读,这违背了分解的全部目的。
你认为在类内排列方法的顺序有助于弥补方法间的纠缠;我不认为。
UB: 我认为这是对我们异同的公正评估。我们都看重分解,也都避免纠缠;但我们不同意这两个价值的相对权重。
二、注释
JOHN: 进入第二个分歧点:注释。在我看来,《代码整洁之道》对待注释的方式会导致文档不足的代码,从而抬高软件开发成本。我确信你不同意,那我们就来讨论。
《代码整洁之道》第 54 页这样谈注释:
注释的恰当用法,是弥补我们用代码表达自己时的失败。注意我用了 “失败” 这个词,我是认真的。注释永远是失败。我们必须有注释,因为我们并不总能想出如何不用注释来表达自己,但它们的出现并不值得庆祝…… 每次你写注释,你都该皱起眉头,感受自己表达能力上的失败。
说实话,我第一次读到这段文字时感到震惊,现在它仍让我皱眉。这是在给写注释贴污名。初级开发者会想:“如果我写注释,人们可能觉得我失败了,所以最安全的做法是一个注释也不写。”
UB: 那一章以这些话开头:
没有什么比一处位置得当的注释更有帮助的了。
然后它继续说,注释是一种必要的恶。
读者唯一可能推断出 “应该不写注释” 的途径,就是根本没读那一章。那一章通篇讨论了一系列注释,有坏的,也有好的。
JOHN: 《代码整洁之道》在注释的 “恶” 方面着墨远多于 “必要” 方面。你引用的那句话后面跟着两句批评注释的话。第 4 章用了 4 页谈好注释,接着用 15 页谈坏注释。还有像 “唯一真正好的注释,是你找到办法不写的那条” 这样的讥讽。而 “注释永远是失败” 实在太朗朗上口,成了读者最可能从那一章记住的唯一一句话。
UB: 页数差异是因为:写好注释的方式只有寥寥几种,而写坏注释的方式却多得多。
JOHN: 我不同意;这恰恰说明你对注释有偏见。如果你看 APOSD 的第 13 章,会发现它找到了比《代码整洁之道》多得多的注释的创造性用法。而且如果比较 APOSD 第 13 章的基调与《代码整洁之道》第 4 章的基调,《代码整洁之道》对注释的敌意就相当清楚了。
UB: 这最后一句话,你且用《注释》一章开头的那句话和结尾的那个例子去平衡一下。它们传达的可不是 “敌意”。
我对注释总体上没有敌意,我对无意义的注释(gratuitous comments)很有敌意。
你我大概都经历过注释绝对必要的时代。七十、八十年代我是汇编语言程序员,也写过一点 FORTRAN。那些语言里没有注释的程序根本无法读懂。因此,默认写注释成了约定俗成的智慧;计算机系学生被教导不加批判地写注释,注释变成了纯粹的善。
在《代码整洁之道》里,我决定对抗那种心态。注释可能非常糟糕,也可能好。
JOHN: 我不认为注释在今天比 40 年前更没有必要性。
注释至关重要,为软件增加巨大价值。问题在于,有很多重要信息根本无法用代码表达。通过添加注释补上这些缺失的信息,开发者可以让代码大大易于阅读。这不是你所说的 “表达能力上的失败”。
UB: 确实有不在代码中、或无法用代码表达的重要信息。那就是一种失败 —— 我们语言的失败,或者我们用语言表达自己的能力的失败。在每种情况下,注释都是我们 “用语言表达意图的能力” 的失败。
而且我们在这方面经常失败,所以注释是一种必要的恶 —— 或者如果你愿意,一种不幸的必需品。如果我们拥有完美编程语言(TM),我们永远不会再写一条注释。
JOHN: 我不认为完美编程语言能消除注释的需要。注释和代码服务于非常不同的目的,所以我看不出两者应该用同一种语言。以我的经验,英语作为注释语言就很好。
你为什么觉得关于程序的信息应该完全用代码表达,而不是代码与英语的组合?
UB: 我痛惜的是,有时我们必须使用人类语言而不是编程语言。人类语言不精确、充满歧义。用人类语言描述程序这种精确的东西非常困难,充满出错和无心误导的机会。
JOHN: 我同意英语并非总能像代码那样精确,但它仍然可以被精确地使用,而且注释通常不需要代码那种程度的精确。
注释往往包含定性信息,比如做某件事的原因,或者某个东西的整体思路。在这些方面英语比代码更胜任,因为它是更具表现力的语言。
UB: 这句话我没有异议。
JOHN: 你担心注释会错误或误导、从而拖慢软件开发吗?我常听人抱怨过时注释(通常把它当作不写注释的借口),但我的职业生涯中并没有发现它们是多大的问题。错误注释确实会发生,但我不常遇到;即便遇到,它们也很少浪费我多少时间。相比之下,我因为文档不足而浪费了巨量时间;我经常把开发时间的 50-80% 花在翻代码、弄清那些 “如果代码注释得当就一目了然” 的事情上。
UB: 你我的经历差异很大。我当然被位置得当的注释帮助过;但我也同样确定(就在本文档里)被一条不正确、错位、多余或以其他方式糟糕透顶的注释分心、迷惑过。
JOHN: 我邀请每一位读者问自己两个问题:
因为错误注释,你的开发速度损失了多少?
因为缺失注释,你的开发速度损失了多少?
对我来说,缺失注释的代价轻松是错误注释的 10-100 倍。这就是为什么看到《代码整洁之道》里那些劝阻人们写注释的内容,我会皱眉。
来看 PrimeGenerator 类:那段代码里一条注释也没有。你觉得这合适吗?
UB: 我认为就我写它的目的而言是合适的。它是那一课(超大方法可以拆成包含更小方法的更小类)的附属品。加很多解释性注释会冲淡那个要点。
不过总的来说,清单 4-8 里我用的注释风格更合适。那个清单位于《注释》一章的末尾,描述了另一个算法略有不同、注释更好的 PrimeGenerator。
JOHN: 我不同意 “加注释会冲淡你的要点”,而且我认为清单 4-8 也远远注释不足。
但我们别争这两点了。我们来讨论:如果 PrimeGenerator 要用于生产,它应该有哪些注释。我提一些建议,你可以同意或反对。
首先,讨论一下你使用 isLeastRelevantMultipleOfLargerPrimeFactor 这类超长名字的做法。我的理解是:你主张用这种名字,而不是用短名字加描述性注释 —— 你实际上是把注释搬进了代码。在我看来,这种做法有问题:
长名字很笨拙。开发者每次调用一个方法,几乎都得把它的 “文档” 重新敲一遍;长名字浪费横向空间,触发代码折行。长名字读起来也别扭:我每读一次,脑子都想去解析每一个音节,这拖慢了我。注意,本次讨论中你我都不得不缩写这些名字 —— 这正说明长名字笨拙且无益。
长名字难以解析,传达信息的效率不如注释。学生读
PrimeGenerator时,最先抱怨的事情之一就是长名字(学生们根本搞不懂它们)。比如上面那个名字就含混晦涩:“least relevant”(最不相关 / 最迟相关)是什么意思?“larger prime factor” 又是什么?即使完全理解方法内的代码,我也很难弄懂这个名字。如果这个名字要替代注释,它还得更长才行。
在我看来,短名字加描述性注释的传统做法更方便,传达所需信息的效率也更高。你主张的做法有什么优势?
UB: “超长音节”(Megasyllabic):好词!
我喜欢让方法名成为能配合关键字和赋值语句的句子片段。这让代码读起来更自然:
if (isTooHot)
cooler.turnOn();关于名字长度,我还遵循一条简单规则:方法的 “作用域” 越大,名字越短;作用域越小,名字越长。我这次提取的私有方法处于很小的作用域中,所以名字偏长。这类方法通常只从一个地方被调用,所以程序员不需要为了另一个调用而记住长名字。
JOHN: isTooHot 这类名字我完全没意见。我担心的是 isLeastRelevantMultipleOfLargerPrimeFactor 这类名字。
有意思的是,方法越短越小,你反而推荐越长名字。这在我看来意味着:这些函数的接口更复杂,所以要更多词来描述它们。这为我不久前的论断提供了佐证:你把方法拆得越碎,得到的方法就越 “浅”。
UB: 变小的不是函数,是作用域。私有函数的作用域比调用它的公开函数小;被那个私有函数调用的函数作用域更小。随作用域下降的,还有情境性细节(situational detail)。描述这样的细节往往需要一个长名字,或一条长注释。我更喜欢用名字。
至于长名字难解析,那是练习(practice)的问题。代码里满是需要练习才能适应的东西。
JOHN: 我不接受这个说法。代码里也许满是需要练习才能适应的东西,但那不能成为借口。需要更多练习的方法比需要更少练习的方法更差。如果适应长名字需要大量功夫,那必须有相应的好处来补偿;目前我没看到任何好处。我也不认为有任何理由相信练习会让这些名字更容易消化。
另外,你上面的话违反了我的一条基本规则,即 “复杂度存在于读者的眼中”。如果你写了别人认为复杂的东西,你就必须接受它大概真的很复杂(除非你认为读者完全无能)。拿” 你只是练习不够” 当借口、暗示这其实是读者的问题,是不行的。稍后我在我们的讨论中也得遵守这条规则。
UB: 有道理。至于 “leastRelevant” 的含义,那是我们很快会撞上的一个更大的问题:它关系到作者与解决方案之间的亲密感,以及读者缺乏这种亲密感。
JOHN: 你还没有回答我的问题:为什么超长名字优于短名字加描述性注释?
UB: 对我来说这是个偏好问题。我偏爱长名字胜过注释。我不信任注释会被维护,也不信任它们会被阅读。你有没有注意到许多 IDE 把注释涂成浅灰色,让人容易忽略?名字比注释更难被忽略。
(顺便说一句,我的 IDE 把注释涂成亮红色消防车红。)
JOHN: 我不明白为什么一个怪兽名字就比注释更可能 “被维护”,我也不同意 IDE 鼓励人们忽略注释(你的偏见又冒出来了)。我当前的 IDE(VSCode)并没有用更浅的颜色显示注释。我之前的(NetBeans)用了,但那种配色并没有隐藏注释 —— 它把注释与代码区分开,让代码和注释都更易读。
我们已经讨论了 “注释 vs 长方法名” 这一具体问题,现在谈谈注释的总体。我认为需要注释有两大原因。第一个原因是抽象。简单说:没有注释,就不可能拥有抽象或模块化。抽象是优秀软件设计最重要的组成部分之一。我把抽象定义为 “一种简化地思考某事的方式,它省略不重要的细节”。方法是最明显的抽象例子 —— 应该可以在不读方法代码的情况下使用它。我们做到这一点的方式,就是写一条描述方法接口(调用该方法所需的全部信息)的头部注释。如果方法设计得好,接口会比方法代码简单得多(它省略了实现细节),因此注释减少了人们必须装在脑中的信息量。
UB: 很久以前,在 1995 年的一本书里,我把抽象定义为:
本质的放大,无关紧要之物的消除。
我当然同意抽象对优秀软件设计很重要。我也同意位置得当的注释能增强读者理解我们所用抽象的能力。但我不同意注释是理解这些抽象的唯一方式,甚至也不是最佳方式。不过有时候它们确实是唯一的选择。
但请看:
addSongToLibrary(String title, String[] authors, int durationInSeconds);在我看来这是一个非常好的抽象,我想象不出注释还能怎么改进它。
JOHN: 我们对抽象的定义非常接近,这很好。
然而 addSongToLibrary 这个声明(还)不是一个好的抽象,因为它省略了本质信息。要使用 addSongToLibrary,开发者需要以下问题的答案:
作者字符串是否有预期格式,比如 “姓氏,名字”?
作者是否应该按字母序排列?如果不是,顺序是否以其他方式有意义?
如果库中已有一首同名的歌但作者不同,会发生什么?是用新歌替换,还是库中保留多首同名歌曲?
库是如何存储的(例如完全在内存中?还是保存在磁盘上?)?如果这些信息已在别处(如整个类的文档)说明,就不必在此重复。
所以 addSongToLibrary 需要相当多注释。有时方法签名(方法名与类型、参数、返回值)已包含使用它所需的全部信息,但这相当罕见。只要随便浏览一下你最喜欢的库的文档:有多少情形你能仅凭签名就知道怎么用?
UB: 是的,有时方法签名是不完整的抽象,需要注释。当接口属于公共 API、或供独立开发团队使用的 API 时尤其如此。然而在单个开发团队内部,接口上长篇的描述性注释往往更像是阻碍而非帮助。团队对系统内部了如指掌,通常仅凭签名就能理解接口。
JOHN: 在我们在某次线下讨论中,你主张接口注释没有必要,因为当一组开发者在同一份代码上工作时,他们可以集体让整个代码 “常驻” 在脑中,所以注释是不必要的:有疑问就问熟悉那段代码的人。这会带来巨大的认知负担 —— 要让所有代码在脑中保持加载状态,我很难想象这真的能行。也许你的记性比我好,但我发现自己几周前写的代码很快就会忘。在任何有一定规模的项目里,我认为你的做法会导致开发者花大量时间重读代码、重新推导接口,并且很可能在这个过程中犯错。花几分钟把接口文档写下来,能省时间、减认知负担、减 bug。
UB: 我认为某些接口需要注释,即使它们对团队是私有的。但更常见的情况是,团队对系统足够熟悉,命名良好的方法和参数就足够了。
JOHN: 看一个 PrimeGenerator 里的具体例子:isMultipleOfNthPrimeFactor 方法。当读者在 isNot... 中遇到对 isMultiple... 的调用时,他们需要充分理解 isMultiple... 如何工作,才能明白它如何嵌进 isNot... 的代码。方法名并没有完全记录接口,所以如果没有头部注释,读者将不得不去读 isMultiple 的代码。这迫使读者把更多信息装进脑子,让在此代码上的工作更难。
这是我为 isMultiple 起草的第一版头部注释:
/**
* Returns true if candidate is a multiple of primes[n], false otherwise.
* May modify multiplesOfPrimeFactors[n].
* @param candidate
* Number being tested for primality; must be at least as
* large as any value passed to this method in the past.
* @param n
* Selects a prime number to test against; must be
* <= multiplesOfPrimeFactors.size().
*/你觉得如何?
UB: 我认为它很准确。如果遇到它,我不会删掉。但我不认为它应该写成 javadoc。
第一句话与名字 isMultipleOfNthPrimeFactor 冗余,可以删掉。关于副作用的警告很有用。
JOHN: 我同意第一句话与名字大体冗余,我确实犹豫过是否保留它。我决定保留,是因为我认为它比名字更精确一点,也更容易读。你提议通过删注释来消除注释与方法名之间的冗余;我则会通过缩短方法名来消除冗余。
顺便说一句,你之前抱怨注释不如代码精确,但在这个例子里注释更精确(方法名无法包含 primes[n] 这样的文本)。
UB: 有道理。有时候精确性更适合放在注释里。
继续批评你的注释:candidate 这个名字与 “Number being tested for primality” 同义。最终,注释里的所有词都只能在我脑中待着,直到我明白它们为何在此;我还得担心它们是否准确。所以我必须读代码来理解和验证注释。
JOHN: 哇哦。你刚才听到的那声巨响,是我的下巴掉到地上的声音。
帮我理解一下:实践中你遇到的大约多大比例的注释,你愿意不读代码验证就信任?
UB: 我把每条注释都当作潜在的错误信息。它们充其量是一种 “把作者意图与代码交叉核对” 的方式。我给注释多少信任,很大程度上取决于它们让交叉核对变得多容易。当我读到一条不引起我交叉核对的注释时,我认为它毫无价值。当我看到一条引起我交叉核对、且交叉核对确实有价值的注释时,那才是真正的好注释。
换句话说:最好的注释告诉我关于代码的一些 “令人惊讶且可验证” 的东西;最差的注释浪费我的时间,告诉我一些显而易见或错误的东西。
JOHN: 听起来你的答案是 0%:你不信任任何未经代码验证的注释。这在我看来毫无道理。如前所述,绝大多数注释是正确的。写注释并不难;我软件设计课上的学生几周内就写得很好了。让注释随着代码演进而保持更新也不难。你拒绝信任注释,是你对注释的非理性偏见的又一表现。
拒绝信任注释要付出极高的代价。为了弄懂如何调用一个方法,你必须读完该方法的全部代码;如果方法还调用别的方法,你还得读那些方法,以及它们调用的方法,如此递归下去。与读(并信任)一条像我上面写的简单接口注释相比,这是巨大的工作量。
如果你选择不为方法写接口注释,就等于让该方法的接口处于未定义状态。即使有人读了方法的代码,他们也看不出实现的哪些部分预期保持不变、哪些部分可能变化(代码里无法指定这种 “契约”)。这会导致误解和更多 bug。
UB: 好吧,大概我只是比你被坑得更惨。我走过太多由错误注释引发的兔子洞,在无数毫无价值的文字沙拉上浪费过时间。
当然,我对注释的信任不是二元的。如果注释在那里,我会读;但我不隐式信任它。作者越让我觉得随意,或者作者英语越差,我对注释的信任就越低。
如前所述,我们的 IDE 往往把注释涂成可忽略的颜色。我的 IDE 把注释涂成亮消防车红,因为当我写注释时,我期望它被阅读。
同理,我用长名字作为注释的替代品,因为我期望这些长名字被阅读;程序员很难忽略名字。
JOHN: 我前面提到需要注释有两大原因。到目前为止我们讨论的是第一个(抽象)。
第二个原因是:代码中不明显的重要信息。 PrimeGenerator 的算法非常不明显,所以需要相当多注释来帮助读者理解发生了什么、为什么。算法的大部分复杂度源于它被设计为高效计算质数:
该算法刻意避免除法 —— 在 Knuth 写原始版本时除法相当昂贵(现在没那么贵了)。
每个新质数的第一个倍数,是用 “质数的平方” 而不是 “质数 ×3” 计算的。这很神秘:为什么跳过中间的那些奇数倍数是安全的?而且,这个优化看似只带来微小的性能影响,实际上却带来巨大差异(数量级)。使用平方还带来一个副作用:测试某个候选数时,只用测试到候选数平方根为止的质数。如果以 3× 作为初始倍数,就要测试与候选数相差 3 倍范围内的质数 —— 那是多得多的测试。
“使用平方” 的这个含义如此不明显,以至于我是为了准备本次讨论的材料才意识到的;以前我和学生讨论这段代码很多次,都从未想到过。
这两个问题都不是从代码里能看出来的;没有注释,读者只能靠自己琢磨。我班上的学生在限时 30 分钟的情况下通常一个都琢磨不出来,但我认为注释能让他们几分钟内就理解。回到我开场的话:这是一个 “信息很重要,所以需要让它可及” 的例子。
你同意这两个问题都该有注释解释吗?
UB: 我同意这个算法很微妙。把第一个质数倍数设为质数的平方,起初极其神秘。我花了整整一小时骑自行车才弄懂它。
注释会有帮助吗?也许。不过我的猜测是,读我们对话的读者没有一个因此受益,因为你我如今对这个方案太过亲密了。你我能用融入那种亲密感的词语谈论它;但我们的读者多半还没有那种契合。
一个办法是画一幅图 —— 一图胜千言。这是我的尝试:
X
1111111111111111111111111
1111122222333334444455555666667777788888999990000011111222223333344444
35791357913579135791357913579135791357913579135791357913579135791357913579
!!! !! !! ! !! ! !! ! ! !! ! !! ! ! ! ! !! !! !
3 |||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-
5 |||||||||||-||||-||||-||||-||||-||||-||||-||||-||||-||||-||||-
7 |||||||||||||||||||||||-||||||-||||||-||||||-||||||-||||||-||||||-
11 |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-||||||||||-
13 ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
...
113||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||我预期读者需要盯着它看一会儿,同时也要看代码。然后他们脑中会 “咔哒” 一声,说:“哦 ——!原来如此!”
JOHN: 我发现这张图非常难懂。它急需补充英文文字来解释所表达的思想。连语法都不明显:1111111111111111111111111 是什么意思?
也许这里有根本性的哲学差异。我感觉你乐于给读者几个线索,然后让他们自己去拼。也许你不介意人们盯着某物看一会儿才弄懂?我不同意这种做法:它导致浪费时间、误解和 bug。
我认为软件应该完全显而易见,读者不需要聪明,也不需要 “盯着它看一会儿” 才能弄懂。受苦然后宣泄,适合希腊悲剧,不适合读代码。读者可能有的每一个问题,都应在代码或注释中得到自然回答。关键思想和重要结论应该明确陈述,而不是留给读者去推断。理想情况下,即使读者很匆忙、没有仔细读代码,他们对事物如何工作(以及为什么)的第一次猜测也应该是对的。在我看来,那才是整洁代码。
UB: 我不反对你的感受。好而整洁的代码应该尽可能容易理解。我想给读者尽可能多的线索,让代码读起来凭直觉就能懂。那是目标。正如我们即将看到的,那可能是个很难达成的目标。
JOHN: 既然如此,你还坚持你上面画的那幅 “图” 吗?它似乎与你刚说的话不一致。而且如果你真想给读者尽可能多的线索,你会加更多注释。
UB: 就图的准确性而言,我坚持它。而且我认为它是个好的交叉核对工具。我对此图容易理解不抱幻想。这个算法很有挑战性,需要下功夫才能领会。我是在骑车时于脑中画出这幅图才最终理解的;回家后我把它真正画出来,希望它能帮助某个愿意下功夫理解它的人。
注释小结
JOHN: 让我们收尾这一节。以下是我对我们一致与分歧的总结:
我们对注释的总体看法根本不同。我比你看重注释的价值,并认为注释在系统设计中扮演根本且不可替代的角色。你同意有些地方必须用注释,但认为注释并不总是让代码更容易理解,所以你认为需要注释的地方要少得多。
对同一段代码,我大概会写比你多 5-10 倍的注释行数。
我认为缺失注释比错误或无益的注释更严重地造成生产力损失;你认为按普遍实践来看注释是净负面:坏注释消耗的时间比好注释节省的更多。
你认为 “注释用英语而非编程语言写” 是个问题;我不觉得这有多大问题,而且在许多情况下英语效果更好。
你建议开发者尽可能把我会用注释表达的信息改写成代码。超长方法名就是一例。我相信超长名字笨拙难懂,用短名字配注释更好。
我认为没有大量注释就无法定义接口、创建抽象。你同意公共 API 如此,但认为团队内部的接口几乎不需要注释。
你不愿信任任何注释,除非读过代码验证过;我一般信任注释,因此我不必像你那样读那么多代码。你认为这让我暴露于过多风险。
我们都同意实现代码只有在不明显时才需要注释。虽然我们都不主张大量实现注释,但我比你更容易看到它们的价值。
总体而言,在这个话题上我们很难找到共识。
UB: 这是对我们各自立场的公正评估;我想它们源于我们各自不同的经历。多年以来,我发现行业中普遍实践的大多数注释都无益。而你似乎在你遇到的注释中找到了更多帮助。
三、John 对 PrimeGenerator 的重写
JOHN: 我提到过,我会让我软件设计课上的学生重写 PrimeGenerator,以修复它的所有设计问题。这是我的重写(注意:这是在讨论开始前写的;鉴于讨论中我学到的东西,我现在会修改其中几条注释,但我保留了原始形式):
package literatePrimes;
import java.util.ArrayList;
public class PrimeGenerator2 {
/**
* Computes the first prime numbers; the return value contains the
* computed primes, in increasing order of size.
* @param n
* How many prime numbers to compute.
*/
public static int[] generate(int n) {
int[] primes = new int[n];
// Used to test efficiently (without division) whether a candidate
// is a multiple of a previously-encountered prime number. Each entry
// here contains an odd multiple of the corresponding entry in
// primes. Entries increase monotonically.
int[] multiples = new int[n];
// Index of the last value in multiples that we need to consider
// when testing candidates (all elements after this are greater
// than our current candidate, so they don't need to be considered).
int lastMultiple = 0;
// Number of valid entries in primes.
int primesFound = 1;
primes[0] = 2;
multiples[0] = 4;
// Each iteration through this loop considers one candidate; skip
// the even numbers, since they can't be prime.
candidates: for (int candidate = 3; primesFound < n; candidate += 2) {
if (candidate >= multiples[lastMultiple]) {
lastMultiple++;
}
// Each iteration of this loop tests the candidate against one
// potential prime factor. Skip the first factor (2) since we
// only consider odd candidates.
for (int i = 1; i <= lastMultiple; i++) {
while (multiples[i] < candidate) {
multiples[i] += 2*primes[i];
}
if (multiples[i] == candidate) {
continue candidates;
}
}
primes[primesFound] = candidate;
// Start with the prime's square here, rather than 3x the prime.
// This saves time and is safe because all of the intervening
// multiples will be detected by smaller prime numbers. As an
// example, consider the prime 7: the value in multiples will
// start at 49; 21 will be ruled out as a multiple of 3, and
// 35 will be ruled out as a multiple of 5, so 49 is the first
// multiple that won't be ruled out by a smaller prime.
multiples[primesFound] = candidate*candidate;
primesFound++;
}
return primes;
}
}每个人都可以自己读一读,判断它是否比原版更容易理解。我想提几点整体上的事情:
只有一个方法。我没有再细分,因为我觉得这个方法已经天然分成几个清晰可辨、各自可理解的片段。把片段提成方法似乎不会显著提高可读性。学生重写时通常会有 2 或 3 个方法,那些也通常没问题。
注释非常多。我极少写出这种注释密度的代码。我写的大多数方法体内没有注释,只有一条描述接口的头部注释。但这代码微妙而棘手,所以需要大量注释让读者看清微妙之处。其中一些注释很长,这是一个危险信号,说明我在为这段代码寻找清晰简洁的解释时颇为挣扎。即便加了这么多解释材料,这个版本还是比原版略短(65 行 vs 70 行)。
UB: 我猜这是完全重写。我猜你是先努力弄懂《代码整洁之道》里的算法,然后从头写起。如果是这样,那没问题。
在《代码整洁之道》里,我重构了 Knuth 的算法以给它一点结构。那和完全重写不是一回事。
话虽如此,你的版本比 Knuth 的和我都好得多。
我写那一章是 18 年前了,所以我很久没看过、没理解这个算法了。第一次看到你的挑战时我想:“哦,我肯定能弄懂我自己的代码!”—— 不,不能。我能看到所有活动的部件,但我无法弄明白这些部件是如何生成一份质数列表的。
于是我看你的代码。我有同样的困难。我能看到所有活动部件,全都带着注释,但我仍然弄不明白这些部件为何能生成质数列表。
弄懂它需要大量 “盯着天花板、闭眼、可视化、骑车”。
我遇到的困难之一就是你写的注释。我们一条一条来。
/**
* Computes the first prime numbers; the return value contains the
* computed primes, in increasing order of size.
* @param n
* How many prime numbers to compute.
*/
public static int[] generate(int n) {在我看来,写成下面这样更好:
public static int[] generateNPrimeNumbers(int n) {或者如果你坚持:
//Return the first n prime numbers
public static int[] generate(int n) {我并不是原则上反对 Javadoc;我只是只在绝对必要时才写。我也厌恶那些从方法签名就能一眼看出的描述和 @param 语句。
下一条注释花了我足足 20 分钟来琢磨:
// Used to test efficiently (without division) whether a candidate
// is a multiple of a previously-encountered prime number. Each entry
// here contains an odd multiple of the corresponding entry in
// primes. Entries increase monotonically.首先,我不确定那句 “division(除法)” 是否必要。我是老派,我预期人人都知道内层循环里要尽量避免除法。但也许我错了……
而且,埃拉托斯特尼筛法根本不做除法,还比这个算法更容易理解和解释。那为什么偏偏用这个算法?我想 Knuth 是想省内存—— 在 1982 年,省内存很重要。这个算法比筛法省得多。
接着是那句:“Each entry here contains an odd multiple…(每条目包含一个奇数倍数)”。我看看注释,再看代码,看到:multiples[0] = 4;
“那不是奇数,” 我对自己说,“所以他可能是指偶数。”
然后我往下看到:multiples[i] += 2*primes[i];
“这是加一个偶数!” 我对自己说,“我很肯定他想说’ 偶数’ 而不是’ 奇数 ’。”
我当时还没弄明白 multiples 数组是什么,所以我觉得它里面全是偶数也完全合理,你的注释只是可以理解的用词对调。毕竟注释没有编译器,所以它们会犯人类用词常犯的那种错误。
直到我看到 multiples[primesFound] = candidate*candidate; 才开始起疑。如果 candidate 是质数,prime*prime 在 2 之外的所有情况下不都应该是奇数吗?我不得不在心里算一遍来证明:(2n+1)(2n+1) = 4n²+4n+1…… 是的,是奇数。
好吧,所以 multiples 数组里全是奇数倍数,除了第一个元素 —— 因为它是 2 的倍数。
所以那条注释也许应该写成:
// multiples of corresponding prime.或者我们该把数组改名为 primeMultiples 之类,干脆删掉注释。
接着下一条注释:
// Each iteration of this loop tests the candidate against one
// potential prime factor. Skip the first factor (2) since we
// only consider odd candidates.这讲不太通。它所说的代码是:
for (int i = 1; i <= lastMultiple; i++) {
while (multiples[i] < candidate) {multiples 数组,我们现在已经知道,是质数倍数的数组。这个循环不是把候选数与质数因子(prime factors)比较,而是与当前的质数倍数(prime multiples)比较。
幸运的是,在我第三四次读到这条注释时,我意识到你真正想用的词是 “multiples(倍数)”。但我要知道这一点,唯一途径就是理解算法。而当我理解算法之后,我还要注释干什么?
那给我留下最后一个问题:multiples[primesFound] = candidate*candidate; 到底为什么要用平方?这说不通。于是我把它改成:
multiples[primesFound] = candidate;它照样工作得很好。所以这一定是什么优化。
你解释这条的注释是:
// Start with the prime's square here, rather than 3x the prime.
// This saves time and is safe because all of the intervening
// multiples will be detected by smaller prime numbers. As an
// example, consider the prime 7: the value in multiples will
// start at 49; 21 will be ruled out as a multiple of 3, and
// 35 will be ruled out as a multiple of 5, so 49 is the first
// multiple that won't be ruled out by a smaller prime.头几次读它时,我完全不懂。它只是一堆数字。
我盯着天花板,闭上眼睛可视化。我看不见。于是我去骑了一趟沉思的自行车,途中我意识到:2 的质数倍数在某个时刻会包含 2×3,然后是 2×5。所以 multiples 数组在某个时刻会包含比它们所代表的质数更大的质数的倍数。然后我恍然大悟!
突然间一切都有了意义。我意识到 multiples 数组等价于我们在埃拉托斯特尼筛法中使用的布尔数组 —— 只是有一个非常有趣的转折。如果你在白板上做筛法,你可以擦掉所有小于候选数的数字,只划掉 “所有先前质数的下一个倍数”。
这个解释现在对我来说完全合理 —— 但我敢打赌正在读它的人还在疑惑。这个思想就是很难解释。
最后我回到你的注释,能看明白你在说什么了。
两个程序员的故事
这里的关键是:你我都掉进了同一个陷阱。 我 18 年前重构那个老算法时,以为那些方法和变量名能表达我的意图 ——因为我理解那个算法。你前段时间写那段代码,并装饰上你以为能解释你意图的注释 ——因为你也理解那个算法。
但我的名字没有在 18 年后帮到我。它们也没帮到你或你的学生。你的注释也没帮到我。
我们都站在盒子里面,试图与站在盒子外、看不见我们所看见之物的人沟通。
根本问题在于:向一个与你试图解释的细节并不亲密的人解释事情,非常困难。 我们的解释往往只有在读者自己把细节琢磨出来之后才显得合理。
JOHN: 你上面讨论的东西很多,但我想它们归结为一点:你不喜欢我写的注释。如我之前所说,复杂度存在于读者眼中:如果你说我的注释令人困惑、没帮你理解代码,那我就必须认真对待。
与此同时,你已经明确表示总体上不看重注释的价值。你倾向于让这段代码(或任何代码)基本没有注释。你上面论证说:注释根本无法让代码更容易理解;理解代码的唯一方式是读代码。那是逃避(cop-out)。
UB: 抱歉打断你;但我想你夸大了我的立场。我当然从没说注释永远没有帮助。有时候它们当然有帮助。我说的是:只有当代码验证了它们时我才信任它们。有时一条注释会让这种验证容易得多。
JOHN: 你一直说你有时能用到注释,但现实是 “有时” 在你的代码里几乎从未发生。我们看看你对我的代码的修订版就知道了。
回到我的论点。为了写出我们的各种版本,你和我不得不积累大量关于这个算法的知识,比如为什么一个质数的第一个倍数可以是它的平方。不幸的是,并非所有这些知识都能在代码中表达。我们的职业责任是尽最大努力用注释传达这些知识,这样读者就不必一遍又一遍地重新构建它。 即使最终注释并不完美,它们也会让代码更容易理解。
如果这种情况发生在现实中,我会和你及其他人一起改进我的注释。比如,我会问你一些问题,以更好地理解为什么 “质数平方” 的注释似乎没帮到你:
注释里有没有误导或令人困惑的内容?
有没有你在骑车时获得的关键信息,突然让一切变清楚了?
我还会把注释拿给几个人看,听取他们的看法。然后我会重写注释以改进它。
鉴于你从根本上不相信注释,我觉得就算我重写之后,你大概仍看不到它的价值。这种情况下我会把注释拿给别人看,尤其是那些对注释总体持更积极看法的人,听取他们的意见。只要注释不误导,且至少有几个人觉得它有帮助,我就会保留它。
现在谈两条你反对的具体注释。第一条是 multiples 变量的注释:
// Used to test efficiently (without division) whether a candidate
// is a multiple of a previously-encountered prime number. Each entry
// here contains an odd multiple of the corresponding entry in
// primes. Entries increase monotonically.这条注释里有一个被你揭露的 bug(第一条目不是奇数);抓得好!你随后论证说注释里大部分信息是不必要的,并提出这样替代:
// multiples of corresponding prime.你这里删掉了太多有用的信息。比如,我不认为可以安全地假设读者会自己想到动机是避免除法。把这些假设和动机明确讲出来总是更好,以免产生困惑。而且我认为让读者知道这些条目永不递减是有帮助的。
我会直接修掉 bug,把所有信息保留下来:
// Used to test efficiently (without division) whether a candidate
// is a multiple of a previously-encountered prime number. Each entry
// (except the first, which is never used) contains an odd multiple of
// the corresponding entry in primes. Entries increase monotonically.第二条是 for 循环的注释:
// Each iteration of this loop tests the candidate against one
// potential prime factor. Skip the first factor (2) since we
// only consider odd candidates.你反对它,是因为循环代码实际上不是拿候选数与质数因子比较,而是与倍数比较。我写这种实现注释时,目标不是复述代码;那种注释通常没什么价值。这里的目标是陈述代码在逻辑上做什么(what),而不是如何做(how)。从这个意义上说,这条注释是对的。
然而,如果一条注释在读者心中引起困惑,它就不是好注释。因此我会重写它,明确它描述的是代码的抽象功能,而非精确行为:
// Each iteration of this loop considers one existing prime, ruling
// out the candidate if it is a multiple of that prime. Skip the
// first prime (2) since we only consider odd candidates.总之,我同意你的论断:“向一个与你试图解释的细节并不亲密的人解释事情,非常困难。” 然而,作为程序员,我们的责任恰恰就是要做这件事。
UB: 很高兴我们达成一致。我们也一致认为应该让其他人评审代码,并就代码和注释提出建议。
四、Bob 对 PrimeGenerator2 的重写
UB: 我看到你的方案,并且在充分理解它之后,稍微重构了一下。我把它加载进 IDE,写了一些简单测试,提取了几个简单方法。
我还去掉了那个可怕的带标签的 continue 语句。我把 3 加进了质数列表,这样就能把第一个元素标记为无关,并给它一个值 -1。(我想我还沉浸在偶 / 奇混淆的余波中。)
我喜欢这样,因为 generateFirstNPrimes 方法的实现以某种暗示机理的方式描述了各个活动部件。读这段实现很容易窥见机制。我完全不觉得注释有帮助。
我想这个算法就是这么回事:恰当地解释它所需的努力,加上别人阅读并理解那个解释所需的努力,大致等于读代码外加骑一趟自行车所需的努力。
package literatePrimes;
public class PrimeGenerator3 {
private static int[] primes;
private static int[] primeMultiples;
private static int lastRelevantMultiple;
private static int primesFound;
private static int candidate;
// Lovely little algorithm that finds primes by predicting
// the next composite number and skipping over it. That prediction
// consists of a set of prime multiples that are continuously
// increased to keep pace with the candidate.
public static int[] generateFirstNPrimes(int n) {
initializeTheGenerator(n);
for (candidate = 5; primesFound < n; candidate += 2) {
increaseEachPrimeMultipleToOrBeyondCandidate();
if (candidateIsNotOneOfThePrimeMultiples()) {
registerTheCandidateAsPrime();
}
}
return primes;
}
private static void initializeTheGenerator(int n) {
primes = new int[n];
primeMultiples = new int[n];
lastRelevantMultiple = 1;
// prime the pump. (Sorry, couldn't resist.)
primesFound = 2;
primes[0] = 2;
primes[1] = 3;
primeMultiples[0] = -1;// irrelevant
primeMultiples[1] = 9;
}
private static void increaseEachPrimeMultipleToOrBeyondCandidate() {
if (candidate >= primeMultiples[lastRelevantMultiple])
lastRelevantMultiple++;
for (int i = 1; i <= lastRelevantMultiple; i++)
while (primeMultiples[i] < candidate)
primeMultiples[i] += 2 * primes[i];
}
private static boolean candidateIsNotOneOfThePrimeMultiples() {
for (int i = 1; i <= lastRelevantMultiple; i++)
if (primeMultiples[i] == candidate)
return false;
return true;
}
private static void registerTheCandidateAsPrime() {
primes[primesFound] = candidate;
primeMultiples[primesFound] = candidate * candidate;
primesFound++;
}
}JOHN: 这个版本比《代码整洁之道》里的版本有相当大的改进。减少方法数量让代码更易读,接口也更干净。如果注释得当,我认为这个版本的易读性会与我的版本相当(你创建的那些额外方法没有特别帮上忙,但也没有碍事)。我猜想如果给读者投票,会有人更喜欢你的版本,也有人更喜欢我的。
不幸的是,这个修订版造成了严重的性能回退:我测出它比之前任何修订版慢 3-4 倍。问题在于,你把对某个特定候选数的处理从单个循环改成了两个循环(increaseEach... 和 candidateIsNot... 两个方法)。在之前版本(以及 candidateIsNot 方法)的循环里,一旦候选数被判定不合格就中止循环(大多数候选数很快就被排除了)。但 increaseEach... 必须检查 primeMultiples 里的每一个条目。这导致循环迭代次数多出 5-10 倍,整体慢 3-4 倍。
鉴于当前算法(及其复杂度)存在的全部理由就是最大化性能,这种变慢是不可接受的。这两个方法必须合并。
我认为这里发生的事情是:你如此专注于一件其实没那么重要的事(造出尽可能小的方法),以至于在其他真正重要的问题上掉了链子。我们已经第二次看到这种情况了。在原版 PrimeGenerator 里,你一心想造小方法,以至于没注意到代码正在变得不可理解;在这个版本里,你一心想把我那个单一方法切碎,以至于没注意到你正在把性能炸掉。
我不认为这只是一次不幸的疏忽组合。软件设计中最重要的事情之一是识别什么重要、并聚焦于它;如果你聚焦于不重要的事,你很可能搞砸重要的事。
你修订版的代码依然注释不足。你相信注释没有任何有意义的方式能帮助读者理解代码。我认为这源于你总体上对注释价值的怀疑;你太快放弃(throw in the towel)了。
这个算法异常难解释,但我仍然相信注释有帮助。比如,我相信你必须做出一些努力,帮助读者理解为什么一个质数的第一个倍数会是该质数的平方。你花了大量时间才建立对这个的理解;难道就没有办法把它传达给别人吗?如果你当初把这些信息写进原版代码,就能省下那趟漫长的自行车之旅。
就此放弃,是对职业责任的放弃(abdication of professional responsibility)。
你修订版里仅有的几条注释也几乎没价值。第一条注释太隐晦,帮助有限:我完全理解它要解释的代码,却仍然无法理解 “predicting the next composite number and skipping over it(预测下一个合数并跳过它)” 这句话。有一条注释只是玩笑;考虑到你反对多余注释,看到它我很意外。显然,在注释问题上你和我活在两个不同的宇宙里。
最后,我不明白你为什么反感我代码里的带标签 continue 语句。这是 “跳出嵌套循环” 问题的一个干净而优雅的解决方案。我希望更多语言有这个特性;替代方案是笨拙的代码 —— 你设置一个变量,跳出一层循环,然后检查变量,再跳出下一层。
UB: 抓得好!如果我想到去 profile 这个方案,我也会抓到的。你说得对,分开两个循环增加了一些不必要的迭代。我找到了一个不用那个可怕的 continue 来解决此问题的好办法。我更新的版本现在比你的还快!一百万个质数 440 毫秒,而你的要 561 毫秒。;-)下面只给出改动部分。
public static int[] generateFirstNPrimes(int n) {
initializeTheGenerator(n);
for (candidate = 5; primesFound < n; candidate += 2)
if (candidateIsPrime())
registerTheCandidateAsPrime();
return primes;
}
private static boolean candidateIsPrime() {
if (candidate >= primeMultiples[lastRelevantMultiple])
lastRelevantMultiple++;
for (int i = 1; i <= lastRelevantMultiple; i++) {
while (primeMultiples[i] < candidate)
primeMultiples[i] += 2 * primes[i];
if (primeMultiples[i] == candidate)
return false;
}
return true;
}JOHN: 是的,这修复了问题。我注意到你现在从《代码整洁之道》版本的 8 个方法降到 4 个方法了。
五、测试驱动开发(TDD)
JOHN: 进入第三个分歧点:测试驱动开发。我是单元测试的超级粉丝。我相信单元测试是软件开发过程中不可或缺的一部分,会反复带来回报。我想我们在这一点上是一致的。
然而,我不是测试驱动开发(TDD)的粉丝。TDD 规定测试必须先于代码编写,代码必须以极小的增量编写和测试。这种方法有严重问题,而我找不到任何足以补偿的优势。
UB: 如我开始所说,我仔细读过《软件设计哲学》。我发现它充满有价值的洞见,并且强烈同意你的大部分观点。
所以我很惊讶地发现,在第 157 页,你写了一段非常简短、轻蔑、贬损且不准确的关于测试驱动开发的章节。抱歉用了这么多形容词,但我觉得这是公平的刻画。所以我的目标,是纠正那些让你写出下面这段话的错误观念:
“测试驱动开发是一种软件开发方法,程序员在写代码之前先写单元测试。创建新类时,开发者首先根据其预期行为为该类编写单元测试。这些测试没有一个会通过,因为类还没有代码。然后开发者一个接一个地处理这些测试,写出足以让该测试通过的代码。当所有测试都通过时,这个类就完成了。”
这是不对的。TDD 与你描述的大相径庭。我用三条定律来描述它:
在写出一个 “因为该代码不存在而失败” 的单元测试之前,你不允许写任何生产代码。
你不允许写出超出 “足以失败” 的单元测试;编译失败也算失败。
你不允许写出超出 “足以让当前失败的测试通过” 的生产代码。
稍加思考你就会确信,这三条定律会把你的开发周期锁定在只有几秒长。你写一两行会失败的测试,再写一两行会通过的生产代码,如此每几秒循环往复。
TDD 的第二层是红 - 绿 - 重构(Red-Green-Refactor)循环。这个循环有几分钟长。它由几个 “三定律” 小周期组成,随后是一段反思和重构的时期。在那段反思里,我们从快速周期的亲密感中抽身出来,审视刚写出的代码的设计:它干净吗?结构好吗?有更好的方案吗?它符合我们正在追求的设计吗?如果不符合,该不该改?
JOHN: 哎呀!对于不准确地描述 TDD,我 “认罪伏法”。我将在 APOSD 的下一次修订中修正这一点。话虽如此,你对 TDD 的定义并没有改变我的担忧。
让我们讨论一下 TDD 潜在的优势和劣势;然后读者可以自行判断 TDD 总体上是不是个好主意。
开始讨论之前,让我澄清我更喜欢的一种替代 TDD 的方法。在你的在线视频里,你把 TDD 的替代方案描述成:开发者写出代码、让它完全跑通(大概靠手动测试),然后回头写单元测试。你论证说这种方法会很糟糕:一旦开发者觉得代码能跑了,就会失去兴趣,所以不会真的去写测试。我完全同意你。不过,这不是 TDD 唯一的替代方案。
我更喜欢的方法是:开发者以比 TDD 稍大的单元工作,也许几个方法或一个类。开发者先写一些代码(几十行到几百行不等),然后为这些代码写单元测试。和 TDD 一样,在拥有全面的单元测试之前,代码不被视为 “可用”。
UB: 在这份文档里,我们把这个技术称为 “打包”(bundling)如何?这是我在《代码整洁之道》第二版中使用的术语。
JOHN: 我没意见。
以较大单元工作的原因,是为了鼓励设计思考:让开发者能够思考一组相关的任务,做一些规划,从而得到一个整体上各部分契合良好的好设计。当然,初始设计想法会有缺陷,重构仍然必要,但目标是让开发过程围绕设计而不是测试。
开始讨论前,你能列出你认为 TDD 相对我刚描述的方法所能提供的优势吗?
UB: 我通常归功于 TDD 的优势是:
很少需要调试。毕竟,如果你一两分钟前刚看着一切正常运转,那没什么好调试的。
源源不断的可靠底层文档,以非常小且隔离的单元测试形式存在。那些测试描述了系统每个方面的底层结构和运作。如果你想知道系统中怎么做某件事,有测试会示范给你看。
更少耦合的设计:系统的每个小部分都必须被设计成可测试的,而可测试性要求解耦。
一套你可以托付身家性命、从而支持无惧重构的测试套件。
不过,你问我的是这些优势中哪些是 TDD 相对你偏爱的方法的优势。那取决于你描述的 “较大单元” 有多大。对我来说重要的是保持周期时间短,并防止阻碍可测试性的纠缠。
在我看来,以较小单元工作、然后立即补写事后测试,可以带来上述所有优势 —— 只要你非常小心地测试刚写的代码的每个方面。我认为一个自律的程序员可以有效地这样工作。事实上,我认为这样的程序员产出的代码,与另一位遵循 TDD 的程序员写出的代码无法区分。
你上面说打包是为了鼓励设计。我认为鼓励设计是非常好的事。我要问你的问题是:你为什么认为 TDD 不鼓励设计? 我自己的经验是,设计来自战略性思考,它独立于 TDD 或打包的战术性行为。设计是后退一步看代码,构想出能应对更大一组约束和需求的结构。一旦你脑中有了那个愿景,在我看来打包和 TDD 会产生类似的结果。
JOHN: 首先,让我回应你列出的 TDD 的四条优势:
很少需要调试? 我认为任何形式的单元测试都能减少调试工作,但不是因为你说的原因。好处在于单元测试更早地暴露 bug,而且是在更容易追查的环境里。一个开发期相对容易修的 bug,在生产里可能非常痛苦。我不太被你的论证说服 ——“因为一分钟前刚看到一切正常,所以调试更少”:很容易做一个微小的改动,却暴露一个长期存在、尚未被触发的刁钻 bug。难以调试的问题源于系统累积的复杂度,而不是代码增量的大小。
UB:
说得对。不过,当周期非常短时,即使最刁钻的 bug 的成因也有最大机会被追查到。周期越短,机会越好。 JOHN:
这只在一定限度内成立。我认为你相信把单元做得越来越小会持续带来好处,几乎无限地小下去。我认为存在一个收益递减的点,过了它,做得更小不再有帮助,反而开始有害。我们在方法长度上见过这个分歧,我想我们在这里又看到了。
底层文档? 我不同意:单元测试是糟糕的文档形式。注释是有效得多的文档形式,而且你可以把它放在相关代码旁边。想通过读一堆单元测试来学习方法接口,似乎远比读几句英语文字困难。
UB:
如今用 IDE 的 “哪里用到了” 功能很容易找到某个函数的测试。至于注释更好,如果那是真的,就不会有人发布示例代码了。
更少耦合的设计? 可能吧,但我自己没经历过。我不清楚 “为可测试性而设计” 会产出最佳设计。
UB:
解耦通常源于测试需要某种 mock。Mock 倾向于强加一些原本可能不存在的抽象。 JOHN:
以我的经验,mock 几乎从不改变接口;它只是为现有(通常不可移动的)接口提供替代实现。 UB:
我们的经验不同。
支持无惧重构? 正中靶心!单元测试几乎所有的收益都来自这里,而且这是一个非常非常重大的好处。
UB:
同意。
我同意你的结论:就提供这些好处而言,TDD 和打包大致相同。
现在让我解释为什么我认为 TDD 很可能导致糟糕的设计。
TDD 的根本问题是:它迫使开发者过于战术化地工作,开发单元过小,这抑制了设计思考。 在 TDD 中,开发的基本单元是一个测试:先写测试,再写让测试通过的代码。然而,设计的自然单元比这更大:比如一个类或一个方法。这些单元对应多个测试用例。如果开发者只想着下一个测试,那么他在任何时刻都只考虑设计问题的一部分。如果不同时思考整个设计问题,就很难设计出好东西。TDD 明确禁止开发者写出超过 “让当前测试通过” 所需的生产代码;这抑制了优秀设计所需的战略性思考。
TDD 没有提供足够的指导来鼓励设计。你提到了红 - 绿 - 重构循环,它建议每步之后重构,但几乎没有重构的指导。开发者该如何决定何时重构、重构什么?这似乎完全交给他们自己的判断。比如,如果我写一个方法需要多次 TDD 循环迭代,我应该在每次迭代后都重构(听起来很繁琐),还是等几次迭代之后再看更大的代码块、从而重构时更具战略性?没有指导,开发者很容易一直拖延重构。
TDD 与我们之前讨论的 “只做一件事” 规则相似:它有偏向性。它给出非常强烈而清晰的指令,把开发者推向一个方向(此处是战术化行动),而对另一个方向(更战略性地设计)只有含糊的指导。结果,开发者很可能会过度偏向战术化。
TDD 保证开发者最初会写出坏代码。 如果你不先思考整个设计问题就动手写代码,你写出的第一批代码几乎必然是错的。设计只会在积累了一大堆坏代码之后才发生。我看了你的 TDD 视频,你反复先写错代码、之后再修正。如果开发者认真重构(就像你做的那样),他们最终仍能得到好代码,但这是在和人性作对。在 TDD 下,那些坏代码实际上能跑(有测试为证!),而人性就是不愿意改动能跑的东西。如果我开发的东西不平凡,我很可能得在 TDD 下积累大量坏代码,然后才有足够的代码摆在面前、让我明白设计本该是什么。要强迫自己扔掉那些工作,对我来说非常困难。
开发者很容易一边完全战术化地工作、一层层堆 hack、偶尔做点小重构、从不思考整体设计,一边还相信自己是在正确地做 TDD。
我相信打包方法优于 TDD,因为它让开发过程围绕设计展开:先设计,再代码,后写单元测试。 当然,重构仍然必要:第一次就把设计做对几乎不可能。但从设计开始会减少你写出的坏代码量,让你更快到达好设计。用 TDD 同样可以产出同样好的设计;只是更难,需要多得多的纪律。
UB: 我逐点回应你的观点。
我没发现 TDD 的规模如此战术化以至于抑制思考。每个程序员,无论其测试纪律如何,都是一行一行写代码的。那极其战术化,却并不抑制设计。那么,一次一个测试又怎么会抑制设计呢?
TDD 的文献强烈反对拖延重构,同时强烈鼓励思考设计。两者都是该纪律的组成部分。
我们所有人开始时都会写坏代码。TDD 的纪律给了我们机会和安全,去持续清理它。设计洞见正是从这类清理活动中产生的。重构的纪律允许坏设计一步一步被改造成好设计。
我不明白为什么 “晚点写测试” 是更好的设计选择。TDD 里没有任何东西阻止我在写第一个被测代码之前就预先想透设计。
JOHN: 你说 TDD 没有任何东西阻止开发者预先思考设计。这只有部分正确。在 TDD 下,我可以预先思考,但我不能把我的想法以代码形式写下来,因为那会违反 TDD 定律一。这是一个显著的抑制。
你声称 TDD”强烈鼓励思考设计”,但我在你的 TDD 讨论中没有看到这一点。我看了你用 TDD 计算保龄球得分的视频示例,前一两分钟之后就再也没提过设计(讽刺的是,这个示例的结论之一是:那个简短的最初设计后来被证明毫无用处)。视频里完全没有 “提前思考” 的暗示;全都是在事后清理烂摊子。
在你给我看的所有 TDD 材料中,我没有看到任何关于 “TDD 变得如此战术化以至于设计从未发生” 这一危险的警告(也许你根本不把这当作严重风险?)。
UB: 我通常用简化的 UML 形式来记录我早期的设计决策。我不反对用伪代码、甚至真实代码记录它们。不过,我不会提交任何这类预写代码。我大概会把它放在一个文本文件里,在遵循 TDD 循环时参考它。为了让一个失败的测试通过,我可能会觉得足够安全,可以从文本文件复制粘贴进 IDE。
保龄球游戏正是一个例子,说明我们最初的设计决策可能与最终方案偏离得多远。确实,入门视频往往不会展示一门纪律的深度。
JOHN: 第二次看你的 TDD 视频时,有一句话跳了出来:
“人类认为先出现的东西重要,后出现的东西不那么重要、有点像是可选的;正因为它们排在最后,所以必要的时候我们可以略过它们。”
这完美地抓住了我对 TDD 的担忧。TDD 坚持测试必须放在第一位,而设计 —— 如果真的发生的话 —— 排在最后,在代码跑通之后。我相信好的设计是最重要的东西,所以它必须是最高优先级。我并不认为测试是可选的,但推迟测试比推迟设计安全。写测试并不特别难;最重要的是有纪律去写。而得到好设计真的很难,即使你非常自律;所以它需要成为注意力的中心。
UB: TDD 是一种编码纪律。设计当然先于编码 —— 我不认识任何不这么想的人。即使是保龄球视频也讲了这一点。但是,正如保龄球视频所示,有时代码会把你带向非常不同的方向。
那种偏离并不意味着设计本不该做。它只意味着设计是有猜测性的,未必总能经得起现实考验。正如艾森豪威尔所说:
“在准备战斗时,我总觉得计划毫无用处,但规划(planning)不可或缺。”
JOHN: 你问为什么 “晚写测试” 是更好的设计选择。它不是。打包方法的好处并不来自晚写测试;它来自更早做设计。(稍)晚写测试是这个选择的结果。在打包方法下,测试仍然写得相当早,所以我不认为这种延迟会造成重大问题。
UB: 我想我们只是对 “TDD 抑制设计” 意见不一。TDD 的实践并不抑制我做设计,因为我重视设计。我建议:那些不重视设计的人,无论遵循什么纪律都不会设计。
JOHN: 你声称我担心的 TDD 问题在实践中根本不会发生。不幸的是,我听到过我信任的高级开发者的相反说法。他们抱怨 TDD 团队产出的糟糕代码,并且认为问题正是 TDD 造成的。当然,任何设计方法都可能产出糟糕代码。也许那些团队没有正确实施 TDD,也许那些案例是离群值。
但反馈给我的问题,与我基于 TDD 的战术性本质所预期发生的完全吻合。
UB: 我的经验不同。我在许多有效且有益地使用 TDD 的项目上工作过。我相信你信任的那些高级开发者对他们经验的描述是真实的。但既然我从未见过 TDD 导致如此糟糕的后果,我真心怀疑责任能归到 TDD 头上。
JOHN: 你要我信任你关于 TDD 的丰富经验,我承认我个人没有 TDD 经验。另一方面,我有大量战术式编程的经验,而且我知道它很少有好结局。
TDD 是我见过的最极端的战术式编程形式之一。 一般来说,如果 “让它跑起来” 是第一优先级,而不是 “设计出干净的结构”,代码就会变成意大利面条。我在你的 TDD 方法中看不到足够的保障来防止灾难场景;我甚至看不到对风险的清晰认识。
总体而言,TDD 处于风险 - 回报谱系上一个糟糕的位置。与打包方法相比,TDD 在代码质量上的下行风险巨大,而我看不到足够的上行回报(如果有的话)来补偿。
UB: 对此我只能说,你的观点建立在一堆错误的印象和推测之上,而不是直接经验。
JOHN: 现在让我问你几个问题。
第一,在微观层面,TDD 究竟为什么要禁止开发者写出超出 “让当前测试通过” 所需的代码?强制 “近视” 如何让系统变得更好?
UB:
这一纪律的目标是确保一切都经过测试。一个很好的方式就是拒绝写任何代码,除非是为了让失败的测试通过。此外,在如此短的周期里工作能带来对代码工作方式的洞见。那些洞见往往带来更好的设计决策。 JOHN:
我同意看到代码(部分)跑起来能带来洞见。但这样的好处当然可以在不对开发者思维施加如此苛刻限制的情况下获得?
第二,在更宏观层面,你认为 TDD 是否可能比更以设计为中心的方法(比如我描述的打包方法)产出更好的设计?如果是,你能解释为什么吗?
UB:
我的猜测是:一个精通打包的人和一个精通 TDD 的人会产出非常相似的设计、非常相似的测试覆盖率。我还斗胆猜测,TDD 者会比打包者稍微更有生产力,不为别的,就因为 TDD 者更早发现并修复问题。 JOHN:
我认为打包方法会带来更好的设计,因为它真正聚焦于设计,而不是聚焦于测试、然后希望好设计神奇地冒出来。我认为很难论证 “达成某件事的最佳方式是把注意力集中在别的东西上”。而且打包方法会进展更快,因为早期的设计思考会减少你在 TDD 下最终不得不扔掉的坏代码量。总体而言,我认为这两种方法的最佳情形大致相同,但平均情形、(尤其是)最坏情形,TDD 会差得多。
JOHN: 我认为我们在 TDD 上的分歧无法化解。要化解,我们需要关于 TDD 好坏结果频率的实证数据。不幸的是,我不知道有这样的数据。因此,读者必须自行判断 TDD 的潜在收益是否大于风险。
对于任何选择使用 TDD 的人,我敦促你极其谨慎。你的首要目标绝不能只是可用的代码,而是一个干净的设计,让你未来能够快速开发。 TDD 不会自然而然地把你引向最佳设计,所以你需要大量、持续的重构来避免意大利面条代码。反复问自己:“假设我在这个项目一开始就知道我现在知道的一切,我还会选择当前这个代码结构吗?” 当答案是 “否” 时(这会经常发生),停下来重构。要认识到 TDD 会让你写出比你习惯的更多的坏代码,所以你必须准备好比以往扔掉和重写更多。花时间提前规划、思考整体设计,而不是只想着让下一个测试通过。
如果你认真做所有这些事情,我认为就有可能缓解 TDD 的风险并产出设计良好的代码。
UB: 我只想说:我同意所有这些建议,但不同意你 “TDD 可能是坏代码成因” 的断言。
TDD 小结
JOHN: 以下是我对我们关于测试驱动开发想法的总结尝试:
我们都同意单元测试是软件开发中的必要元素。它们让开发者能够在不担心破坏东西的情况下对系统做重大改动。
我们都同意可以用 TDD 产出设计良好的系统。
我认为 TDD 抑制好的设计、且很容易导致非常糟糕的代码。你不认为 TDD 抑制好的设计,也不认为有多大坏代码风险。
我认为有比 TDD 更好的方法来产出优秀的单元测试套件,比如前面讨论的 “打包” 方法。你同意打包能产出与 TDD 一样好的结果,但认为它可能导致测试覆盖率略低。
我认为 TDD 和打包的最佳情形结果相似,但平均和最坏情形下 TDD 会差得多。你不同意,并认为 TDD 充其量可能产出略优于打包的结果;你还认为在选择两者时,偏好和个性是更大的因素。
UB: 这是对我们讨论的公正总结。我们似乎对 “纪律的最佳应用方式” 有分歧。我倾向于一种纪律化的方式:以非常短的周期、先写测试,让代码保持被测试覆盖。你倾向于另一种纪律化的方式:写相对较长的代码包,然后为这些包写测试。我们对这两种纪律的风险与回报看法不一。
结语
JOHN: 首先,我想感谢你容忍(并回应)我就《代码整洁之道》中一些关键思想提出的论证。我希望这次讨论能为读者提供思考的食粮。
我们讨论了很多话题和子话题,但我认为我的大部分担忧源于《代码整洁之道》的两个总体错误:未能聚焦于重要的事,以及未能平衡设计取舍。
在软件设计(可能在任何设计环境中)中,识别真正重要的事物并聚焦于它们至关重要。如果你把注意力放在不重要的事物上,你就不太可能达成真正重要的事物。不幸的是,《代码整洁之道》反复聚焦于并不真正重要的事物,比如:
把十行方法拆成五行方法,再把五行方法拆成两三行方法。
取消用英语写的注释。
先写测试再写代码,把开发的基本单元从抽象变成测试。
这些都没有提供显著价值,而且我们已经看到它们如何分散对 “产出最佳设计” 的注意力。
相反,《代码整洁之道》从根本上低估了注释的价值,而注释是必需且不可替代的。这代价巨大:没有接口注释,接口的规格就不完整,这必然导致困惑和 bug;没有实现注释,读者被迫重新推导原作者脑中的知识和意图,这浪费时间并导致更多 bug。
我在开场白中说,当重要信息对开发者不可及、不明显时,系统就变得复杂。拒绝写注释,就是在隐藏你拥有而他人需要的重要信息。
《代码整洁之道》的第二个总体错误与平衡有关。设计是相互竞争的关切之间的平衡。几乎任何设计思想走到极端都会变成坏事。 然而,《代码整洁之道》反复给出非常强的单向建议,却没有相应强度的反向建议,也没有任何有意义的指导说明如何识别自己已经走得太远。比如,让方法更短通常是好事,但《代码整洁之道》的立场如此片面和极端,读者很可能会切得过头。我们在 PrimeGenerator 例子中看到,这如何导致了几乎不可理解的代码。同样,《代码整洁之道》关于 TDD 的立场也是片面的:它不承认任何可能的弱点,还鼓励读者把 TDD 推向战术化的极端,让设计完全被挤出开发过程。
UB: John,我想感谢你参与这个项目。对我来说这非常有趣。我喜欢与聪明人争论和辩论。我也认为我们共享的价值观远远多于将我们分开的东西。
就我而言,我只想说:我已经认真考虑了你提出的观点,虽然我不同意你上面的结论,但我已经把你的一些更好的想法、以及这整份文档,整合进了《代码整洁之道》第二版。
再次感谢,并代我向你的学生们问好。
附:共识与分歧一览表(整理者总结)
(整理者注)下表是译者对原文三场主题辩论的提炼,便于快速回顾;详细论证请以上文对话原文为准。
两书的总体分歧(John 结语): 《代码整洁之道》一是不聚焦重要的事(过度关注方法长短、禁注释、先测试),二是给出一边倒的建议而缺乏平衡与 “何时过头” 的指导。UB 的回应:已把本次讨论及若干建议整合进《代码整洁之道》第二版。
本文档根据 John Ousterhout 与 Robert C. Martin 的公开讨论整理翻译,版权归原作者所有;中文整理仅为学习交流用途。
豆包 AI 生成

评论