简洁代码与软件设计哲学
(本文件是罗伯特-“鲍勃叔叔”-马丁(Robert “Uncle Bob” Martin)和约翰-奥斯特豪特(John Ousterhout)在 2024 年 9 月至 2025 年 2 月期间进行的一系列讨论的结果,其中有些是在线讨论,有些是当面讨论。如果您想对本讨论中的任何内容发表评论,我们建议您在与 APOSD 相关的谷歌群组中发表评论。)
介绍
JOHN:
你好,鲍勃(叔叔)!你和我都写过关于软件设计的书。我们在某些问题上意见一致,但我最近出版的《软件设计哲学》(以下简称 “APOSD”)和你的经典著作《清洁代码》(Clean Code)之间存在一些很大的分歧。谢谢你同意在这里讨论这些分歧。
UB:
我很荣幸,约翰。在我们开始之前,请允许我说,我仔细阅读了你的书,我发现它非常令人愉快,而且充满了有价值的见解。有些地方我不同意你的观点,比如 TDD 和抽象优先增量主义,但总的来说,我非常喜欢这本书。
JOHN:
我想和你讨论三个话题:方法长度、注释和测试驱动开发。但在讨论这些问题之前,我们先来比较一下整体的理念。当你听到一个与软件设计相关的新想法时,你如何决定是否支持这个想法?
我先说。对我来说,软件设计的基本目标是使系统易于理解和修改。我用 “复杂性 ”这个词来指那些让人难以理解和修改系统的东西。造成复杂性的最重要因素与信息有关:
- 开发人员必须在头脑中掌握多少信息才能完成任务?
- 开发人员所需的信息有多容易获取、有多明显?
开发人员需要掌握的信息越多,他们就越难开发系统。如果所需的信息不明显,情况就会更糟。最糟糕的情况是,开发人员从未听说过的某个遥远的代码中隐藏着关键信息。
当我评估一个与软件设计相关的想法时,我会问它是否能降低复杂性。这通常意味着要么减少开发人员必须知道的信息量,要么使所需信息更加明显。
现在请问您:在决定支持哪些想法时,您是否会使用一些一般原则?
UB:
我同意你的方法。一门学科或技术应该让程序员的工作更轻松。我还想补充一点,我们最想帮助的程序员不是作者。我们希望让工作更轻松的程序员是必须阅读和理解他人(或一周后自己)编写的代码的程序员。程序员阅读代码的时间远远多于编写代码的时间,因此我们要减轻的工作就是阅读。
方法长度
JOHN:
我们的第一个分歧点是方法长度。在《简洁代码》第 34 页,你说:”函数的第一条规则是它们应该很小。函数的第二条规则是它们应该比这更小”。后来,你又说:“函数几乎不应该长达 20 行”,并建议函数应该 “只有两行、三行或四行长”。在第 35 页,你说:”if
语句、else
语句、while
语句等内的块应该只有一行长。也许这一行应该是函数调用”。我在《简洁代码》中找不到任何关于函数可能太短的内容。
我同意,将代码分割成相对较小的单元(“模块化设计”)是减少程序员必须同时在脑海中记住的信息量的最重要方法之一。当然,我们的想法是将复杂的功能块封装到一个具有简单界面的独立方法中。这样,开发人员就可以利用该方法的功能(或阅读调用该方法的代码),而无需了解该方法的实现细节;他们只需了解该方法的接口。最好的方法是那些提供大量功能但接口非常简单的方法:它们用小得多的认知负荷(学习接口)取代了大的认知负荷(阅读详细的实现)。我称这些方法为 “深度 ”方法。
然而,与软件设计中的大多数想法一样,分解也可能走得太远。随着方法越来越小,进一步细分的好处也越来越少。隐藏在每个界面背后的功能数量会减少,而界面往往会变得更加复杂。我称这些接口为 “肤浅 ”接口:它们在减少程序员需要了解的内容方面并无多大帮助。最终,使用该方法的人需要了解其实现的方方面面。这样的方法通常毫无意义。
分解过多的另一个问题是容易导致纠缠。如果为了理解其中一个方法的内部工作原理,你还需要阅读另一个方法的代码,那么这两个方法就是纠缠在一起的(或者用 APOSD 术语来说是 “连体的”)。如果你曾在阅读代码时发现自己在两个方法的实现之间来回切换,那就说明这两个方法可能是纠缠在一起的。纠缠在一起的方法很难阅读,因为你需要同时掌握的信息并不都在同一个地方。通常,可以通过合并纠缠的方法来改进,使所有代码都在一处。
清洁代码》中关于方法长度的建议过于极端,以至于鼓励程序员创建极小的方法,这些方法既存在接口浅薄的问题,也存在纠缠不清的问题。设置任意的数字限制,如一个方法中 2-4 行,if
或 while
语句的正文中只有一行,会加剧这一问题。
UB:
虽然我强烈建议使用非常简短的函数,但我认为说这本书设置了任意的数值限制并不公平。你在第 34 页提到的 2-4 行函数,是 Kent Beck 和我在 1999 年一起编写的 Sparkle applet 的一部分,作为学习 TDD 的练习。我认为该 applet 中的大多数函数都是 2-4 行,这一点很了不起,因为它是一个 Swing 程序;而 Swing 程序往往有很长的方法。
至于设置限制,我在第 13 页明确指出,虽然书中的建议对我和其他作者都很有效,但未必对每个人都有效。我并不声称自己是最终权威,甚至也没有任何绝对的 “正确性”。这些建议仅供参考。
JOHN:
我认为,如果我们看看具体的代码示例,这些问题就最容易理解了。但在此之前,请允许我问你,鲍勃:你是否认为代码有可能过度分解,还是越小越好?如果你认为过度分解是可能的,那么你如何识别这种情况?
UB:
代码当然有可能过度分解。下面是一个例子:
void doSomething() {doTheThing()} // 过度分解。
我用来决定在多大程度上进行分解的策略是一条古老的规则,即一个方法应该做 “一件事”。如果我能有意义地从另一个方法中提取出一个方法,那么原来的方法就做了不止一件事。“有意义 “的意思是,抽取出来的功能可以被赋予一个描述性的名称;而且它所做的事情比原来的方法要少。
JOHN:
不幸的是,“一件事 ”方法会导致过度分解:
- 一件事 “这个词很模糊,很容易被滥用。例如,如果一个方法有两行代码,它不就是在做两件事吗?
- 你没有提供任何有用的防护措施来防止过度分解。你举的例子太极端了,没有任何用处,而 “能否命名 ”的限制条件也无济于事:任何东西都可以被命名。
- 在很多情况下,“一件事 ”的方法是错误的。如果两件事情密切相关,那么用一个方法来实现它们可能很有意义。例如,任何线程安全方法都必须首先获取一个锁,然后执行其功能。这是两件 “事”,但它们属于同一个方法。
UB:
让我先解决最后一件事。你建议锁定线程和执行关键部分应放在同一个方法中。不过,我很想把锁定和关键部分分开。
void concurrentOperation() { lock() criticalSection(); unlock() }
这就将临界部分与锁分离开来,允许在不需要加锁(例如在单线程模式下)或锁已被他人设置时调用临界部分。
现在来谈谈 “易于滥用 ”的论点。我认为这不是一个重要的问题。If
语句很容易被滥用。Switch
语句容易被滥用。赋值语句容易被滥用。容易被滥用的事实并不意味着应该避免或抑制它。它只是意味着人们应该适当注意。总有一种东西叫做:判断。
因此,当面对一个更大的方法中的这段代码时:
... amountOwed=0; totalPoints=0; ...
如果将它们提取如下,那将是一个糟糕的判断,因为提取并没有意义。实现并不比接口更详细。
void clearAmountOwed() { amountOwed=0; } void clearTotalPoints() { totalPoints=0; }
然而,由于接口是抽象的,而实现具有更深层次的细节,因此将它们提取如下可能是一个很好的判断。
void clearTotals() { amountOwed=0; totalPoints=0; }
后者有一个很好的描述性名称,足够抽象,既有意义又不多余。而且这两行的关联性很强,可以用来做一件事:初始化。
JOHN:
当然,任何东西都可能被滥用。但最好的设计方法是鼓励人们以正确的方式做事,阻止滥用。不幸的是,“一事一议 ”规则鼓励了滥用,原因如上所述。
当然,软件设计师也需要做出判断:我们不可能为软件设计提供精确的配方。但良好的判断需要原则和指导。《The Clean Code 》 关于分解的论点,包括 “一件事规则”,都是片面的。它们就何时进行分解给出了强有力的、具体的、量化的建议,却几乎没有指导如何判断自己是否走得太远。我能找到的只是第 36 页关于清单 3-3 的一个两句话的例子(这很琐碎),被埋没在 “切、切、切 ”的劝告之中。
我使用 “深/浅 ”特征描述的原因之一是,它能捕捉到权衡的两面性;它能告诉你什么时候分解是好的,什么时候分解会让事情变得更糟。
UB:
你说得很好,我在书中没有多谈如何做出判断。早在 2008 年,我关注的是如何打破网络早期常见的超大型函数的习惯。在第二版中,我的观点更加平衡了。
尽管如此,如果我必须犯错,我还是宁愿在分解方面犯错。考虑和可视化分解是有价值的。如果我们认为它们走得太远,可以随时对它们进行内联。
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 (a.k.a 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; } }
在我们深入研究这段代码之前,我鼓励阅读这篇文章的每个人花点时间仔细阅读这段代码,并从中得出自己的结论。你觉得代码容易理解吗?如果是,为什么?如果不容易,是什么让它变得复杂?
另外,鲍勃,你能确认你支持这段代码吗(也就是说,这段代码恰当地体现了 “纯净代码 ”的设计理念,而且你认为这段代码在生产中使用时应该是这样的)?
UB:
啊,是的。PrimeGenerator
。这段代码来自 1982 年唐纳德-克努特(Donald Knuth)撰写的《语言编程》(Literate Programming)一文。该程序最初是用 Pascal 编写的,由 Knuth 的 WEB 系统自动生成了一个非常大的方法,我将其翻译成了 Java。
当然,这段代码从未用于生产。我和 Knuth 都将其作为教学范例。在《清洁代码》中,它出现在名为 “类 ”的章节中。这一章的教训是,一个非常庞大的方法往往包含许多不同的代码部分,这些代码最好分解成独立的类。
在这一章中,我从该函数中提取了三个类: 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:
我觉得你说得有道理。18 年前,当我进行重构时,这些名称和结构对我来说非常合理。现在我也觉得有道理–但那是因为我再次理解了算法。几天前,当我第一次回到算法中时,我还在为名称和结构纠结。一旦我理解了算法,名称和结构就完全说得通了。
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....
当前的分解向读者隐藏了这一重要信息。
如果说有什么比不理解代码更容易导致错误的话,那就是在不理解代码的情况下还以为自己理解了代码。
UB:
这种担心是有道理的。不过,由于这些函数是按照调用的顺序排列的,所以这种担心也是有道理的。因此,我们可以预期读者已经看过主循环,并理解candidate
每次迭代增加 2。
埋藏在 smallestOddNth...
中的副作用就比较麻烦了。既然你已经指出来了,我就不太喜欢了。不过,这个副作用不应该混淆对 isNot....
的基本理解。
一般来说,如果你相信被调用方法的名称,那么理解调用者并不需要理解被调用者。例如
for (Employee e : employees) if (e.shouldPayToday()) e.pay();
如果我们将这两个方法的调用替换为它们的实现,就不会更容易理解了。这种替换只会掩盖本意。
JOHN:
这个例子之所以有效,是因为被调用的方法相对独立于父方法。遗憾的是,isNot....
并非如此。
事实上,isNot...
不仅与它调用的方法纠缠在一起,还与它的调用者纠缠在一起。isNot...
只有在循环中调用时才会起作用,而在循环中,candidate
是单调递增的。为了让自己相信它能起作用,你必须找到调用 isNot...
的代码,并确保 candidate
从一个调用到下一个调用都不会减少。将 isNot… 与调用它的循环分开,会让读者更难相信它是有效的。
UB:
正如我之前所说,这就是为什么这些方法的顺序是这样的。我估计当你读到 isNot...
的时候,你已经读过 checkOddNumbersForSubsequentPrimes
并知道candidate
是以 2 为单位递增的。
JOHN:
让我们简单讨论一下这个问题,因为这是我不同意 “清洁代码 ”的另一个地方。如果方法纠缠在一起,方法定义的巧妙排序并不能解决问题。
在这种特殊情况下,checkOdd...
和 isNot...
的循环之间还有两个方法,因此读者在读到 isNot....
之前就已经忘记了循环的上下文。此外,对循环产生依赖的实际代码并不在 isNot...
中:而是在 smallestOdd...
中,这与 checkOdd....
的距离更远。
UB:
我很怀疑是否有人会忘记candidate
是以 2 为单位增加的。这是一个非常明显的避免浪费的方法。
JOHN:
在我的开场白中,我谈到了减少人们必须同时在脑海中记住的信息量是多么重要。在这种情况下,读者在阅读四种与循环无关的方法的同时,还必须记住循环。你显然认为这很容易,也很自然(我不同意)。但情况比这更糟。没有任何迹象表明 checkOdd...
的哪些部分在后面会很重要,因此唯一安全的方法就是记住每一个方法的所有内容,直到你遇到过可能从它派生出来的所有其他方法。而且,为了建立各部分之间的联系,读者还必须重构调用图,以注意到即使通过 4 层方法调用,smallestOdd...
中的代码也对 checkOdd....
中的循环施加了限制。这对读者来说是不合理的认知负担。
如果两段代码紧密相关,解决办法就是将它们整合在一起。将代码片段分开,即使是在物理上相邻的方法中,也会使代码更难理解。
对我来说,PrimeGenerator
中的所有方法都是纠缠在一起的:为了理解这个类,我必须同时将所有方法加载到我的脑海中。在阅读代码时,我不断地在这些方法之间来回切换。这表明代码已经过度分解。
鲍勃,你能帮我理解一下你为什么要把代码分成这么小的方法吗?是不是我忽略了这么多方法的好处?
UB:
我想我们在这个问题上会有分歧。一般来说,我信奉小方法命名原则和关注点分离原则。一般来说,如果你能把一个大方法分解成几个具有不同关注点的命名良好的小方法,并通过这样做暴露它们的接口和高层功能分解,那么这就是一件好事。
- 循环处理奇数是一个关注点。
- 确定原始性是另一个问题。
- 标记素数的倍数是另一个问题。
在我看来,将这些问题分开并命名,有助于揭示算法的工作方式–即使要付出一些纠缠的代价。
在你的解决方案中,我们很快就会看到,你以类似的方式分解了算法。不过,你并没有将关注点分隔成函数,而是将它们分隔成若干部分,并在这些部分上方添加了注释。
你提到,在我的解决方案中,读者在阅读其他函数时必须牢记循环上下文。我建议,在你的解决方案中,读者在阅读你的解释性注释时,必须牢记循环的上下文。他们可能不得不在各部分之间 “来回翻阅”,以加深理解。
现在,也许你担心在我的解决方案中,“翻转 ”的距离(以行为单位)比你的长。我不认为这是个重要的问题,因为它们都在同一个屏幕上(至少在我的屏幕上是这样),而且地标非常明显。
方法长度总结
JOHN:
听起来是时候结束这一部分了。这是对我们的共识和分歧的合理总结吗?
- 我们同意模块化设计是件好事。
- 我们同意过度分解是有可能的,而《清洁代码》第 1 版在如何识别过度分解方面没有提供太多指导。
- 我们在分解到什么程度的问题上存在分歧:你建议将代码分解成比我小得多的单元。你认为你建议的额外分解能让代码更容易理解;而我认为这太过分了,实际上会让代码更难理解。
- 你认为 “一事一议 ”原则在判断的基础上应用,会带来适当的分解。而我认为它缺乏防护措施,会导致过度分解。
- 我们同意,将
PrimeGenerator
内部分解为方法是有问题的。你指出,你编写PrimeGenerator
的主要目的是展示如何分解为类,而不是如何将类内部分解为方法。 - 类中方法之间的纠缠不会像我一样困扰你。你认为分解方法的好处可以弥补纠缠带来的问题。我认为不能:当分解的方法纠缠在一起时,它们比不分解的方法更难阅读,这就违背了分解的初衷。
- 你认为给类中的方法排序有助于弥补方法之间的纠缠;我不这么认为。
UB:
我认为这是对我们的共识和分歧的公平评价。我们都重视分解,也都避免纠缠;但我们对这两种价值的相对权重存在分歧。
注释
JOHN:
让我们继续讨论第二个分歧点:注释。在我看来,“清洁代码”(Clean Code)的注释方法会导致代码文档不足,从而增加软件开发的成本。我相信你不会同意,所以我们来讨论一下。
下面是《清洁代码》中关于注释的内容(第 54 页):
正确使用注释是为了弥补我们在代码表达方面的不足。请注意,我用了 “失败 ”这个词。我是认真的。注释总是失败的。我们必须有注释,因为没有注释,我们总是不知道如何表达自己,但使用注释并不值得庆祝…… 每次写注释时,你都应该面无表情,感受到自己表达能力的失败。
老实说,当我第一次读到这段文字时,我感到非常震惊,现在读来仍让我心惊肉跳。这是对写注释的污名化。初级开发者会想:”如果我写了注释,别人可能会认为我失败了,所以最安全的做法就是不写注释。”
UB:
这一章的开头有这样几句话
没有什么能比一个恰当的注释更有用了。
它接着说,注释是必要之恶。
读者要想推断出他们不应该写注释,唯一的办法就是他们没有真正读过这一章。这一章讲述了一系列注释,有坏的,也有好的。
JOHN:
Clean Code 更多关注的是注释的 “邪恶 ”方面,而不是 “必要 ”方面。你上面引用的那句话后面有两句话是批评注释的。第 4 章用了 4 页的篇幅谈论好的注释,接着用了 15 页的篇幅谈论坏的注释。其中有 “唯一真正好的评论是你想办法不写的注释 ”这样的冷嘲热讽。而 “注释永远是失败的 ”则朗朗上口,是读者最有可能从这一章中记住的一句话。
UB:
页数上的差异是因为写好注释的方法不多,而写坏注释的方法却很多。
JOHN:
我不同意;这说明你对注释有偏见。如果你看看《APOSD》第 13 章,就会发现使用注释的建设性方法比《Clean Code》多得多。如果你比较一下《APOSD》第 13 章和《Clean Code》第 4 章的语气,《Clean Code》对注释的敌意就非常明显了。
UB:
我想让你把最后一条评论与 “注释 ”一章中最初的陈述和最后的示例平衡一下。它们并没有传达 “敌意”。
我对一般的注释没有敌意。我非常敌视无端的注释。
你我可能都经历过一个注释绝对必要的时代。在七八十年代,我是一名汇编语言程序员。我也写过一些 FORTRAN 语言。在这些语言中,没有注释的程序是难以理解的。
因此,默认编写注释就成了一种传统智慧。事实上,计算机科学专业的学生也被教导不加批判地编写注释。注释成了纯粹的好东西。
在《清洁代码》中,我决定与这种思想作斗争。注释既可以是好的,也可以是坏的。
JOHN:
我不同意今天的注释比 40 年前更不需要的说法。
注释至关重要,能为软件带来巨大价值。问题在于,有很多重要信息根本无法用代码来表达。通过添加注释来填补这些缺失的信息,开发人员可以大大简化代码的阅读。这并不是你所说的 “他们表达能力的失败”。
UB:
确实有一些重要信息没有或无法用代码表达出来。这就是失败。这是我们语言的失败,也是我们用语言表达自己的能力的失败。在任何情况下,注释都是我们使用语言表达意图能力的失败。
而我们在这方面经常失败,因此注释是一种必要之恶–如果你愿意,也可以说是一种不幸的必然。如果我们有完美的编程语言(TM),我们就不会再写注释了。
JOHN:
我不同意完美的编程语言会消除注释的必要性。注释和代码的作用截然不同,所以我不认为我们应该用同一种语言来处理这两种情况。根据我的经验,英语作为注释语言的效果很好。你为什么认为有关程序的信息应该完全用代码来表达,而不是将代码和英语结合起来使用?
UB:
我对我们有时必须使用人类语言而不是编程语言感到遗憾。人类语言不精确,充满歧义。用人类语言来描述像程序这样精确的东西非常困难,而且充满了出错和无意误导的机会。
JOHN:
我同意英语并不总是像代码那样精确,但它仍然可以被精确地使用,而且注释通常不需要像代码那样的精确度。注释通常包含定性信息,如为什么要做某事,或某事的总体思路。与代码相比,英语更能表达这些信息,因为它是一种更具表现力的语言。
UB:
我不反对这种说法。
JOHN:
你是否担心注释会出现错误或误导,从而导致软件开发速度减慢?我经常听到有人抱怨陈旧的注释(通常是作为不写注释的借口),但在我的职业生涯中,我并没有发现它们是一个严重的问题。不正确的注释确实会发生,但我并不经常遇到,即使遇到了,也很少花费我太多时间。与此相反,我却因为文档不完善而浪费了大量时间;我经常要花费 50-80% 的开发时间去琢磨代码,而如果代码有正确的注释,这些问题是显而易见的。
UB:
你和我的经历截然不同。
我当然曾得到过恰当注释的帮助。当然,我也曾被不正确的、错位的、无端的或其他糟糕的注释所干扰和困惑(就在本文档中)。
JOHN:
我请阅读这篇文章的每个人问自己以下问题:
- 错误的注释对软件开发速度的影响有多大?
- 遗漏注释对软件开发速度的影响有多大?
对我来说,遗漏注释的代价是错误注释代价的 10-100 倍。因此,当我在《清洁代码》(Clean Code)中看到一些不鼓励人们写注释的内容时,我感到非常痛心。
让我们来看看 PrimeGenerator
类。这段代码中没有一条注释,你觉得这样合适吗?
UB:
我认为就我编写它的目的而言,它是合适的。这是对 “大方法可以分解成包含小方法的小类 ”这一课程的补充。如果添加大量解释性注释,就会偏离这一点。
不过,一般来说,我在清单 4-8 中使用的注释风格更为合适。该清单位于注释一章的最后,描述了另一个算法略有不同的 PrimeGenertor
,以及一组更好的注释。
JOHN:
我不同意添加注释会分散你的注意力,而且我认为清单 4-8 的注释也少得可怜。但我们不要争论这两个问题。相反,让我们来讨论一下 PrimeGenerator
代码如果用于生产,应该有哪些注释。我将提出一些建议,你可以同意,也可以不同意。
首先,让我们来讨论一下你对 isLeastRelevantMultipleOfLargerPrimeFactor
等超音节名称的使用。我的理解是,你主张使用这样的名称,而不是使用较短的名称,并加上描述性注释:你实际上是把注释移到了代码中。在我看来,这种做法是有问题的:
- 长名称很别扭。开发人员每次调用一个方法时,实际上都要重新键入该方法的文档,而且长名称会浪费水平空间,并引发代码换行。名字读起来也很别扭:每次读的时候,我的大脑都想解析每一个音节,这让我的阅读速度减慢。请注意,在这次讨论中,你和我都采用了缩写名称的方法:这表明长名称既笨拙又不合乎情理。
- 这些名字很难解析,不能像注释那样有效地传递信息。当学生阅读《PrimeGenerator》时,他们首先抱怨的就是名字太长(学生无法理解)。例如,上面的名称含糊不清:“最不相关 ”是什么意思?“较大质因数 ”又是什么?即使完全理解了方法中的代码,我也很难理解这个名称。如果这个名称不需要注释,那就需要更长的注释。
在我看来,使用较短名称并带有描述性注释的传统方法更方便,也能更有效地传达所需的信息。你主张的方法有什么优势?
UB:
“大音节”: 好词!
我喜欢我的方法名是句子片段,与关键字和赋值语句搭配得很好。这让代码读起来更自然。
if (isTooHot) cooler.turnOn();
我还遵循一个关于名称长度的简单规则。方法的作用域越大,名称就应该越短,反之亦然 — 作用域越短,名称就越长。我在本例中提取的私有方法的作用域非常小,因此名称也偏长。像这样的方法通常只在一个地方被调用,所以程序员没有负担为另一次调用记住一个很长的名称。
JOHN:
像 isTooHot
这样的名称我完全没意见。我担心的是 isLeastRelevantMultipleOfLargerPrimeFactor
这样的名称。
有趣的是,当方法变得越来越小、越来越窄时,你却推荐使用更长的名称。这说明这些函数的接口更加复杂,因此需要更多的词语来描述它们。这为我不久前的论断提供了佐证,即方法拆分得越多,产生的方法就越浅。
UB:
不是函数变小了,而是范围变小了。一个私有函数的作用域要小于调用它的公有函数。被私有函数调用的函数的作用域更小。随着作用域的缩小,情况细节也在缩小。描述这样的细节往往需要一个很长的名字或很长的注释。我更喜欢使用名称。
至于长名称难以解析,那是实践的问题。代码中有很多需要练习才能习惯的东西。
JOHN:
我不接受这种说法。代码中可能有很多需要练习才能习惯的东西,但这并不能成为它的借口。需要更多练习的方法比需要更少练习的方法更糟糕。如果要花很多功夫才能适应长名称,那么最好有一些补偿性的好处;但到目前为止,我还没有看到任何补偿性的好处。而且,我也没有理由相信练习会让这些名称更容易消化。
此外,您的上述注释还违反了我的一条基本原则,即 “复杂性取决于读者的眼光”。如果你写的代码别人认为很复杂,那么你就必须承认代码可能很复杂(除非你认为读者完全不称职)。不能找借口或暗示这确实是读者的问题(“你只是没有足够的练习”)。在我们稍后的讨论中,我也要遵守这条规则。
UB:
有道理。至于 “最不相关 ”的含义,这是一个更大的问题,你我很快就会遇到。它与作者与解决方案之间的亲密关系有关,也与读者缺乏这种亲密关系有关。
JOHN:
你还没有回答我的问题:为什么使用超长的名称比使用带有描述性注释的短名称更好?
UB:
这是我的喜好问题。比起注释,我更喜欢长名称。我不相信注释会被维护,也不相信它们会被阅读。你有没有注意到,很多集成开发环境都把注释涂成浅灰色,这样就很容易被忽略?忽略名字比忽略注释更难。
(顺便说一句,我让我的集成开发环境把注释涂成鲜艳的火红色)
JOHN:
我不明白为什么怪物名称比注释更容易被 “维护”,而且我也不同意集成开发环境会鼓励人们忽略注释(这又是你的偏见在作祟)。我现在使用的集成开发环境(VSCode)并没有为注释使用浅色。我以前的集成开发环境(NetBeans)使用了这种颜色,但这种配色方案并没有隐藏注释,而是将注释与代码区分开来,使代码和注释都更容易阅读。
既然我们已经讨论了注释与长方法名的具体问题,那么让我们来谈谈一般的注释。我认为需要注释有两个主要原因。需要注释的第一个原因是抽象。简而言之,没有注释就不可能有抽象性或模块化。
抽象是优秀软件设计最重要的组成部分之一。我将抽象定义为 “一种省略不重要细节的简化思维方式”。抽象最明显的例子就是方法。使用一个方法,不需要阅读它的代码。实现这一点的方法是编写头注释,描述方法的接口(调用方法所需的所有信息)。如果方法设计得很好,其接口就会比方法的代码简单得多(省略了实现细节),因此注释可以减少人们头脑中必须掌握的信息量。
UB:
很久以前,在 1995 年出版的一本书中,我将抽象定义为
放大本质,剔除无关。
我当然同意,抽象对于良好的软件设计非常重要。我也同意,恰当的注释可以提高读者理解我们试图使用的抽象概念的能力。我不同意评论是理解这些抽象概念的唯一甚至是最好的方法。但有时它们是唯一的选择。
请看
addSongToLibrary(String title, String[] authors, int durationInSeconds);
在我看来,这是一个非常好的抽象,我无法想象注释会如何改进它。
JOHN:
我们对抽象的定义非常相似,很高兴看到这一点。不过,addSongToLibrary
声明(目前)还不是一个好的抽象,因为它遗漏了一些必要的信息。为了使用 addSongToLibrary
,开发人员需要回答以下问题:
- 作者字符串是否有任何预期格式,如 “姓,名”?
- 是否希望作者按字母顺序排列?如果不是,顺序是否有其他意义?
- 如果曲库中已经有一首标题相同但作者不同的歌曲,会发生什么情况?是用新的歌名替换,还是保留多首相同歌名的歌曲?
- 曲库是如何存储的(例如,是完全存储在内存中还是保存在磁盘上?) 如果这些信息已在其他地方(如整个类的文档)记录,则无需在此重复。
因此,addSongToLibrary
需要大量注释。有时,方法的签名(方法的名称和类型、参数和返回值)包含了使用该方法所需的全部信息,但这种情况非常罕见。只要浏览一下你最喜欢的库包的文档:有多少情况下你能仅凭签名就明白如何使用一个方法?
UB:
是的,有时方法的签名是不完整的抽象,需要注释。当接口是公共应用程序接口的一部分,或者是供不同开发团队使用的应用程序接口时,情况尤其如此。然而,在一个开发团队中,对接口进行冗长的描述性注释往往是一种阻碍而非帮助。开发团队对系统的内部结构了如指掌,通常只需通过接口的签名就能理解接口。
JOHN:
在我们的一次面对面讨论中,你认为界面注释是不必要的,因为当一群开发人员在开发一段代码时,他们可以共同将整段代码 “加载 ”到他们的大脑中,所以注释是不必要的:如果你有问题,只需询问熟悉这段代码的人。这将造成巨大的认知负担,要让所有代码都在头脑中 “加载”,我很难想象这是否真的可行。也许你的记忆力比我好,但我发现我很快就会忘记几周前写的代码。在任何规模的项目中,我认为你的方法都会导致开发人员花费大量时间阅读代码来重新获取接口,而且很可能在途中犯错。而花几分钟时间记录接口,则可以节省时间,减轻认知负担,减少错误。
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
“这个名称与 ”检验原始性的数 “同义。
不过,到头来,评论中的所有词句都只能在我的大脑中停留,直到我明白它们为什么会出现在这里。我还得担心它们是否准确。因此,我必须通过阅读代码来理解和验证注释。
JOHN:
Whoah. 你刚才听到的那声巨响是我下巴落地的声音。请帮助我更好地理解这个问题:你在实践中遇到的注释中,大约有多少是你愿意相信而无需阅读代码来验证的?
UB:
我把每一条注释都视为潜在的错误信息。充其量,它们只是一种对照代码检查作者意图的方法。我对注释的信任程度在很大程度上取决于它们是否容易进行交叉检查。当我读到一条不会让我进行交叉检查的注释时,我认为它没有任何价值。如果我看到的注释能让我进行交叉检查,而且交叉检查的结果是有价值的,那么这就是一条非常好的注释。
另一种说法是,最好的注释能告诉我一些关于代码的令人惊讶且可验证的东西。而最差的注释则是浪费我的时间,告诉我一些显而易见或不正确的东西。
JOHN:
听起来你的答案是 0%:除非经过代码验证,否则你不相信任何注释。这对我来说毫无意义。正如我上面所说,绝大多数注释都是正确的。写注释并不难;在我的软件设计课上,学生们几周内就能写得很好。随着代码的发展,不断更新注释也并不难。你拒绝相信注释是你对注释的非理性偏见的另一个表现。
拒绝信任注释的代价非常高昂。为了理解如何调用一个方法,你必须阅读该方法的所有代码;如果该方法调用其他方法,你还必须递归阅读这些方法及其调用的方法。与阅读(并信任)像我上面写的那样简单的接口注释相比,这是一项巨大的工作量。
如果你选择不为方法写接口注释,那么你就会使该方法的接口未定义。即使有人阅读了该方法的代码,他们也无法分辨出实现的哪些部分应该保持不变,哪些部分可能会发生变化(代码中无法指定这种 “契约”)。这将导致误解和更多的错误。
UB:
好吧,我想我只是比你受到的伤害更大。我走过了太多错误评论的兔子洞,在毫无价值的文字沙拉上浪费了太多时间。
当然,我对注释的信任并不是二元对立的。如果有注释,我就会读;但我并不会因此就默认信任。我觉得作者越是无理取闹,或者作者越不擅长英语,我就越不信任注释。
如上所述,我们的集成开发环境倾向于将注释涂成可忽略的颜色。我让我的集成开发环境将注释涂成鲜艳的红色,因为当我写注释时,我希望它能被阅读。
同样,我使用长名称来代替注释,因为我希望这些长名称能被阅读;程序员很难忽略名称。
JOHN:
我刚才提到,需要注释有两个一般原因。到目前为止,我们讨论的是第一个原因(抽象)。注释的第二个一般原因是为了显示代码中不明显的重要信息。PrimeGenerator
中的算法非常不明显,因此需要大量注释来帮助读者理解发生了什么以及为什么。该算法的大部分复杂性都是为了高效计算素数而设计的:
- 该算法不遗余力地避免除法,因为在克努特写最初版本时,除法是相当昂贵的(现在已经不那么昂贵了)。
- 每个新质数的第一个倍数都是通过平方质数来计算的,而不是乘以 3。这很神秘:为什么跳过中间的奇数倍数是安全的?此外,这一优化看似对性能影响不大,但实际上却产生了巨大的差异(数量级)。使用平方有一个副作用,即在测试候选数时,只测试候选数平方根以下的素数。如果使用 3x 作为初始倍数,那么候选数的 3 倍以内的素数都会被测试;这就意味着要测试更多的素数。使用平方的这一含义并不明显,我只是在准备本次讨论的材料时才意识到这一点;而在我与学生多次讨论代码时,却从未想到过这一点。
这两个问题在代码中都不是显而易见的;如果没有注释,读者只能自己去弄明白。我班上的学生一般都无法在我给他们的 30 分钟内弄明白这两个问题,但我认为注释可以让他们在几分钟内明白。回到我的开场白,这是一个信息很重要的例子,因此需要提供信息。
你是否同意应该有评论来解释这两个问题?
UB:
我同意这个算法很微妙。把第一个质数的倍数设为质数的平方,起初让人深感神秘。我骑了一个小时的自行车才弄明白。
注释会有帮助吗?也许吧。不过,我猜阅读过我们对话的人都不会从中得到帮助,因为你和我现在都对这个解法太熟悉了。你和我可以用符合这种亲密关系的词语来谈论这个解决方案;但我们的读者可能还不喜欢这种亲密关系。
一种解决方案是描绘一幅图画–胜过千言万语。以下是我的尝试。
X
1111111111111111111111111
1111122222333334444455555666667777788888999990000011111222223333344444
35791357913579135791357913579135791357913579135791357913579135791357913579
!!! !! !! ! !! ! !! ! ! !! ! !! ! ! ! ! !! !! !
3 |||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-||-
5 |||||||||||-||||-||||-||||-||||-||||-||||-||||-||||-||||-||||-
7 |||||||||||||||||||||||-||||||-||||||-||||||-||||||-||||||-||||||-
11 |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-||||||||||-
13 ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
...
113||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
我估计我们的读者会盯着这个看一段时间,还要看看代码。但是,他们的大脑会 “咔嗒 ”一声,然后说:”哦!是的,我现在明白了!我现在明白了!”
JOHN:
我觉得这个图很难理解。它需要补充英文文本来解释其中的观点。甚至语法也不明显:1111111111111111111111111 是什么意思?
也许我们在哲学上存在根本分歧。我感觉你乐于给读者提供一些线索,然后让他们把这些线索拼凑起来。也许你不介意人们盯着某样东西看一会儿才能想明白?我不同意这种做法:它会造成时间浪费、误解和错误。我认为软件应该是一目了然的,读者不需要耍小聪明,也不需要 “盯着这个看一会儿 ”就能弄明白。苦难之后的宣泄对于希腊悲剧来说很好,但对于阅读代码来说并不好。读者可能提出的每一个问题都应该在代码或注释中得到自然的解答。关键的观点和重要的结论应该明确指出,而不是留给读者去推断。在理想情况下,即使读者因为匆忙而没有仔细阅读代码,他们对事物工作原理(以及原因)的第一猜测也应该是正确的。对我来说,这就是简洁的代码。
UB:
我不反对你的观点。好的简洁代码应该尽可能易于理解。我希望给我的读者提供尽可能多的线索,让他们能直观地阅读代码。
这就是我的目标。正如我们即将看到的,这可能是一个很难实现的目标。
JOHN:
在这种情况下,你还坚持你上面描绘的 “画面 ”吗?这似乎与你刚才所说的不一致。而且,如果你真的想给读者提供尽可能多的线索,你就会写更多的注释。
UB:
我坚持这幅画的准确性。我认为这是一个很好的对照。我并不幻想它很容易理解。
这种算法很有挑战性,需要下功夫才能理解。骑车途中,我在脑海中画了这幅图,终于理解了它。回到家后,我把它画了出来,并把它展示出来,希望它能帮助那些愿意下功夫理解它的人。
注释总结
JOHN:
让我们结束这部分的讨论。以下是我对我们的共识和分歧的总结。
- 我们对注释的总体看法有本质区别。我比你更看重注释,我认为注释在系统设计中发挥着不可替代的基本作用。你同意有些地方需要注释,但注释并不总能让人更容易理解代码,所以你认为需要注释的地方要少得多。
- 我可能会为某段代码多写 5-10 行注释。
- 我认为,缺少注释比错误或无用的注释更容易造成工作效率的损失;而你认为,注释是一种净负面,就像通常的做法一样:糟糕的注释比好的注释节省更多的时间。
- 你认为用英语而不是编程语言写评论是有问题的。我不认为这有什么特别的问题,而且我认为在很多情况下,用英语写评论效果更好。
- 你建议开发人员尽可能将我认为是注释的信息转换成代码。超长方法名就是一个例子。我认为超长的名称既笨拙又难以理解,最好使用较短的名称并辅以注释。
- 我认为,如果没有大量注释,就不可能定义接口和创建抽象。您同意对公共应用程序接口进行注释,但认为没有必要对团队内部的接口进行注释。
- 在阅读代码验证注释之前,您不愿意相信注释。我通常信任注释;这样做,我就不需要像你一样阅读那么多代码。您认为这会让我承担太大的风险。
- 我们一致认为,只有当代码不明显时,执行代码才需要注释。虽然我们都不赞成大量的实现注释,但我比你更有可能看到它们的价值。
总的来说,我们在这个问题上很难找到一致的地方。
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 行对 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) {
我一般不反对 Javadocs,但只有在绝对必要的情况下我才会写 Javadocs。我也很反感那些从方法签名就能看出来的描述和 @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.
首先,我不知道为什么要使用 “除法 ”语句。我是个老派的人,所以我想每个人都知道,如果可以避免的话,要避免在内循环中进行除法运算。但也许我错了……
另外,埃拉托塞尼斯之筛也不进行除法运算,而且比这种算法更容易理解和解释。那么,为什么要采用这种算法呢?我认为Knuth是为了节省内存–在1982年,节省内存是很重要的。这种算法使用的内存比筛子少得多。
然后是这句话: Each entry here contains an odd multiple...
我看了看这句话,又看了看代码,发现:multiples[0] = 4;
。
“这不是奇数”,我对自己说。“所以他的意思可能是偶数”。
然后我往下看,看到:multiples[i] += 2*primes[i]
;
“这是在加一个偶数!” 我对自己说。我敢肯定他说的是 “偶数 ”而不是 “奇数”。
我当时还不知道倍数数组是什么。因此,我认为其中包含偶数是完全合理的,而你的评论只是一种可以理解的词语转换。毕竟,注释是没有编译器的,所以会出现人类在使用单词时经常犯的错误。
当我读到 multiples[primesFound] = candidate*candidate;
时,我才开始质疑。如果候选数是质数,那么质数*质数不应该在超过 2 的所有情况下都是奇数吗?为了证明这一点,我不得不在脑子里计算了一下。(2n+1)(2n+1) = 4n^2+4n+1...
是的,这很奇怪。
好吧,那么倍数数组中除了第一个元素是 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
数组是质数的倍数数组。这个循环不是根据质因数测试候选数,而是根据当前的质倍数测试候选数。
幸运的是,当我第三次或第四次阅读这条评论时,我意识到你的真正意思是使用 “倍数 ”一词。但我知道这一点的唯一办法就是理解算法。而当我理解了算法之后,我为什么还需要这条评论呢?
这就给我留下了最后一个问题。这到底是为什么?
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:
你在上面的讨论中有很多东西,但我认为归根结底只有一点:你不喜欢我写的评论。正如我在前面提到的,复杂性取决于读者的眼睛:如果你说我的注释让你感到困惑,或者没有帮助你理解代码,那么我必须认真对待。
同时,你已经明确表示,你认为注释总体上没有什么价值。你的偏好是这段代码(或任何代码)基本上没有注释。你在上面辩称,注释根本无法让代码更容易理解;理解代码的唯一方法就是阅读代码。这是一种逃避。
UB:
很抱歉打断你,但我认为你夸大了我的立场。我当然从未说过注释永远不会有帮助。当然,有时是有帮助的。我说的是,我只相信代码验证过的注释。有时,注释会让验证变得容易得多。
JOHN:
你一直说你有时会发现注释的用处,但实际上 “有时 ”在你的代码中几乎从未出现过。我们看看你对我代码的修改就知道了。
现在回到我的话题。为了写出不同版本的代码,你和我必须积累大量有关算法的知识,比如为什么质数的第一个倍数是它的平方就没问题。遗憾的是,这些知识并不能全部体现在代码中。我们的职业责任就是尽我们所能在注释中传达这些知识,这样读者就不必一遍又一遍地重新构建这些知识。即使由此产生的注释并不完美,它们也会使代码更容易理解。
如果这种情况发生在现实生活中,我会与您和其他人一起改进我的注释。例如,我会问你一些问题,以便更好地理解为什么 “平方质数 ”注释似乎对你没有帮助:
- 注释中是否存在误导或混淆之处?
- 你在骑车过程中是否获得了一些重要信息,让事情突然变得清晰起来?
我还会把注释给其他几个人看,听听他们的看法。然后,我会重新修改注释,使其更加完善。
鉴于你从根本上不相信注释,我认为即使经过我的修改,你很可能仍然认为注释没有价值。在这种情况下,我会把这条注释给其他人看,尤其是那些对注释总体持积极态度的人,征求他们的意见。只要注释没有误导性,而且至少有几个人认为它有帮助,我就会保留它。
现在让我来谈谈你反对的两条具体注释。第一条注释是关于倍数变量的:
// 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.
你暴露了这个注释中的一个错误(第一个条目不是奇数);接得好!然后,你认为注释中的大部分信息都是不必要的,并提出了这样一个替代方案:
// multiples of corresponding prime.
你在这里遗漏了太多有用的信息。例如,我不认为假定读者会明白避免除法的动机是安全的。最好能清楚地说明这些假设和动机,这样就不会产生混淆了。而且我认为,让读者知道这些条目从未减少过,也是很有帮助的。我会简单地修复这个错误,保留所有信息:
// 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.
你反对这条注释,因为循环的代码实际上并没有根据质因数来测试候选数,而是根据倍数来测试。当我写这样的实现注释时,我的目的不是重述代码;这样的注释通常不会提供太多价值。这里的目的是在逻辑意义上说明代码在做什么,而不是它是如何做的。从这个意义上说,注释是正确的。
但是,如果一条注释会给读者带来困惑,那么它就不是一条好的注释。因此,我会重写这条注释,以明确它描述的是代码的抽象功能,而不是它的精确行为:
// 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:
很高兴我们意见一致。我们还同意让其他人审阅代码,并就代码和注释提出建议。
鲍勃重写的 PrimeGenerator2
UB:
当我看到你的解决方案,并对它有了很好的理解之后。我对它进行了一些重构。我把它加载到我的集成开发环境中,写了一些简单的测试,并提取了几个简单的方法。
我还去掉了那个可怕的带标签的 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
的最初版本中,你一心只想创建最微小的方法,以至于没有注意到代码变得难以理解。而在这个版本中,你又如此热衷于砍掉我的单个方法,以至于没有注意到你正在破坏性能。
我不认为这只是一个不幸的疏忽组合。软件设计中最重要的事情之一就是确定什么是重要的,并专注于此;如果你专注于不重要的事情,你很可能会搞砸重要的事情。
你修订版中的代码仍然注释不足。你认为注释无法帮助读者理解代码。我认为这源于你普遍不相信注释的价值;你很快就放弃了注释。这种算法异常难以解释,但我仍然相信注释可以有所帮助。例如,我认为你必须尝试帮助读者理解为什么质数的第一个倍数是质数的平方。你花了很多时间去理解这一点,肯定有办法把你的理解传达给其他人吧?如果你在最初版本的代码中包含了这些信息,你就可以省去骑自行车的时间了。就此放弃是对专业责任的推卸。
你在修订版中提出的几条意见没有什么价值。第一条评论太含糊不清,帮不上什么忙: 我无法理解 “预测下一个合数并跳过它 ”这句话的含义,尽管我完全理解这句话想要解释的代码。其中一条注释只是一个笑话;鉴于你反对无关注释,我很惊讶看到这一点。
显然,在注释问题上,你我生活在不同的世界里。
最后,我不明白你为什么对我代码中标注的 continue
语句感到不快。这是一个简洁而优雅的解决方案,可以解决从嵌套循环中逃脱的问题。我希望有更多的语言具有这种功能;否则,代码就会很笨拙,先设置一个变量,然后退出一级循环,然后检查变量并退出下一级循环。
UB:
说得好!如果我想过对解决方案进行剖析,我也会发现这一点。你说得对,把两个循环分开会增加一些不必要的迭代。我找到了一个很好的方法来解决这个问题,而不用可怕的 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:
是的,问题解决了。我注意到你现在只用了 4 种方法,而在 Clean Code 版本中是 8 种。
测试驱动开发
JOHN:
让我们继续讨论第三个分歧点,即测试驱动开发。我是单元测试的忠实粉丝。我认为单元测试是软件开发过程中不可或缺的一部分,它的价值会一次又一次地体现出来。我想我们在这一点上是一致的。
然而,我并不喜欢测试驱动开发(TDD),因为它规定必须在编写代码之前编写测试,而且必须以极小的增量编写和测试代码。这种方法有严重的问题,但我却没有发现任何可以弥补的优势。
UB:
正如我在开头所说,我仔细阅读了《软件设计哲学》。我发现它充满了有价值的见解,而且我非常同意你提出的大多数观点。
因此,我惊讶地发现,在第 157 页,你写了一个关于测试驱动开发的非常简短、轻蔑、贬低和不准确的章节。很抱歉用了这么多形容词,但我认为这是很公平的描述。因此,我在这里的目的是纠正导致你写下以下内容的误解:
“测试驱动开发是一种软件开发方法,程序员在编写代码之前先编写单元测试。在创建一个新类时,开发人员首先根据该类的预期行为编写单元测试。这些测试都不会通过,因为该类没有代码。然后,开发人员一次通过一个测试,编写足够的代码使测试通过。当所有测试都通过后,类就完成了”。
这完全是错误的。TDD 与你所描述的大相径庭。我用三条法则来描述它。
- 在编写单元测试之前,不允许编写任何生产代码。
- 不允许编写超过足以导致失败的单元测试,编译失败就是失败。
- 不允许编写超过足以使当前失败测试通过的生产代码。
只要稍加思考,你就会相信这三条法则会将你锁定在一个只有几秒钟长的循环中。你写的一两行测试会失败,你写的一两行生产代码会通过,如此循环往复,每隔几秒钟就会发生一次。
TDD 的第二层是红-绿-重构循环。这个循环长达几分钟。它包括三个法则的几个循环,然后是一段时间的反思和重构。在反思过程中,我们会从快速循环的亲密关系中抽离出来,审视我们刚刚编写的代码的设计。它是否简洁?结构合理吗?有没有更好的方法?是否与我们追求的设计相匹配?如果不匹配,是否应该匹配?
JOHN:
哎呀!我承认 “有罪”,因为我对 TDD 的描述不准确。我会在下一次修订 APOSD 时修正这一点。尽管如此,你对 TDD 的定义并没有改变我的担忧。
让我们来讨论一下 TDD 的潜在优缺点;然后,读者可以自行决定他们是否认为 TDD 总体上是个好主意。
在我们开始讨论之前,让我先澄清一下我更喜欢的替代 TDD 的方法。在你的在线视频中,你将 TDD 的替代方法描述为:开发人员编写代码,使其完全正常工作(大概是通过手动测试),然后返回并编写单元测试。你认为这种方法很糟糕:开发人员一旦认为代码可以工作了,就会失去兴趣,因此他们不会真正编写测试。我完全同意你的观点。然而,这并不是 TDD 的唯一选择。
我更喜欢的方法是,开发人员以比 TDD 更大的单元为单位工作,也许是几个方法或一个类。开发人员首先编写一些代码(从几十行到几百行不等),然后为这些代码编写单元测试。与 TDD 一样,代码在经过全面的单元测试后才算 “正常工作”。
UB:
在本文中,我们把这种技术称为 “捆绑 ”如何?这是我在 Clean Code 2d ed 中使用的术语。
JOHN:
我没意见。
之所以要以更大的单元来工作,是为了鼓励设计思维,这样开发人员就可以考虑一系列相关的任务,并进行一些规划,从而提出一个很好的整体设计,使各个部分很好地结合在一起。当然,最初的设计想法会有缺陷,重构仍然是必要的,但我们的目标是让开发过程以设计而不是测试为中心。
为了开始我们的讨论,你能列举一下你认为 TDD 与我刚才描述的方法相比有哪些优势吗?
UB: 我通常认为 TDD 有以下优点:
- 几乎不需要调试。毕竟,如果你一两分钟前才看到一切正常,那就没什么好调试的了。
- 可靠的底层文档流,以非常小的、孤立的单元测试的形式出现。这些测试描述了系统各方面的底层结构和操作。如果你想知道如何在系统中做某件事,这些测试会告诉你怎么做。
- 耦合度较低的设计是因为系统的每一个小部分都必须设计成可测试的,而可测试性要求解耦。
- 一套你可以信赖的测试套件,因此支持无畏的重构。
不过,你问我 TDD 与你的首选方法相比有哪些优势。这取决于你所描述的大型单元有多大。对我来说,最重要的是缩短周期,防止出现阻碍可测试性的纠葛。
在我看来,在小单元中工作,然后立即编写事后测试,可以给你带来上述所有优势,只要你非常小心地测试你刚刚编写的代码的每一个方面。我认为一个严谨的程序员可以有效地采用这种工作方式。事实上,我认为这样的程序员编写出的代码,我无法将其与另一位遵循 TDD 的程序员编写的代码区分开来。
你在上面提到,捆绑是为了鼓励设计。我认为鼓励设计是件好事。我的问题是 为什么你认为 TDD 不鼓励设计?我自己的经验是,设计来自战略思维,而战略思维与 TDD 或捆绑的战术行为无关。设计是从代码中后退一步,设想出能解决更多限制和需求的结构。
一旦你有了这样的设想,在我看来,捆绑和 TDD 会产生类似的结果。
JOHN:
首先,请允许我谈谈你列出的 TDD 的四个优势:
很少需要调试?我认为任何形式的单元测试都能减少调试工作,但不是你所说的那种原因。单元测试的好处在于能更早地暴露错误,并在更容易追踪的环境中进行。在开发过程中修复一个相对简单的错误,在生产过程中追踪起来可能会非常痛苦。我不太相信你所说的因为 “你一分钟前才看到一切正常 ”所以调试工作就会减少的说法:做一个微小的改动很容易就会暴露出一个存在已久但尚未被触发的非常棘手的 bug。难以调试的问题来自系统累积的复杂性,而不是代码增量的大小。
UB: 没错。不过,如果周期很短,那么即使是最棘手的错误,也有最大的机会被追踪到。周期越短,机会就越大。
JOHN:这只在一定程度上是对的。我认为你认为把单元越做越小会带来好处,而且几乎没有限制。我认为存在一个收益递减点,在这个点上,把东西做得更小不再有帮助,实际上开始有害。我们曾在方法长度问题上看到过这种分歧,我想我们在这里又看到了。
低级文档?我不同意:单元测试是一种糟糕的文档形式。注释是一种更有效的文档形式,你可以把它们放在相关代码旁边。试图通过阅读一堆单元测试来了解一个方法的接口,似乎比阅读几句英文文本要困难得多。
UB:现在使用集成开发环境的 “where-used ”功能就能很容易地找到函数的测试。至于说注释更好,如果真是这样,就不会有人发布示例代码了。
耦合度更低的设计?有可能,但我还没有亲身经历过。我不清楚为可测试性而设计是否会产生最好的设计。
UB: 一般来说,解耦是因为测试需要某种模拟。模拟往往会强制抽象,否则就不会存在。
JOHN:根据我的经验,模拟几乎从未改变过接口;它只是为现有的(通常是不可移动的)接口提供了替代品。
UB: 我们的经验不同。
实现无畏重构?BINGO!单元测试的几乎所有好处都来自于此,而且这真的是一件大事。
UB: 同意。
我同意你的结论,即在提供这些好处方面,TDD 和捆绑差不多。
现在让我来解释一下为什么我认为 TDD 可能会导致糟糕的设计。TDD 的根本问题在于,它迫使开发人员过于战术性地工作,开发单元太小;它阻碍了设计思维。使用 TDD,开发的基本单位是一个测试:首先编写测试,然后编写代码使测试通过。然而,设计的自然单位要比这个大:例如,一个类或一个方法。这些单元对应多个测试用例。如果开发人员只考虑下一个测试,那么他们在任何时候都只考虑了设计问题的一部分。如果不同时考虑整个设计问题,就很难设计出好的东西。TDD 明确禁止开发人员编写比通过当前测试所需更多的代码;这阻碍了良好设计所需的战略思维。
TDD 没有提供足够的指导来鼓励设计。你提到了 “红-绿-重构 ”循环,该循环建议在每个步骤后进行重构,但几乎没有关于重构的指导。开发人员应该如何决定何时重构以及重构什么?这似乎完全取决于开发人员自己的判断。例如,如果我正在编写一个需要多次迭代 TDD 循环的方法,我应该在每次迭代后进行重构(这听起来很乏味),还是等到多次迭代后再进行重构,这样我就可以在重构时查看更大的代码块,从而更具战略性?如果没有指导,开发人员很容易继续推迟重构。
TDD 类似于我们之前讨论过的 “一件事规则”,它是有偏向性的:它提供了非常有力和明确的指导,推动开发人员朝一个方向(在本例中,即战术性行动)前进,而对另一个方向(更具战略性的设计)的指导却很模糊。因此,开发人员很可能会偏向于过于战术性。
TDD 保证了开发人员最初会写出糟糕的代码。如果你不考虑整个设计问题就开始写代码,那么你写的第一段代码几乎肯定是错的。只有在积累了大量错误代码之后,设计才会发生。我看了你关于 TDD 的视频,你多次写了错误的代码,后来又修复了它。如果开发人员认真地重构代码(就像你做的那样),他们最终还是能写出好代码,但这违背了人的本性。有了 TDD,那些糟糕的代码实际上就能正常工作了(有测试可以证明!),而人类的天性就是不想改变那些正常工作的代码。如果我正在开发的代码并不复杂,那么在使用 TDD 之前,我可能需要积累大量糟糕的代码,然后才能有足够多的代码让我明白设计应该是怎样的。我很难强迫自己放弃所有这些工作。
开发人员很容易认为自己在正确地执行 TDD,但却完全按部就班地工作,一个黑客接着一个黑客,偶尔进行一些小的重构,而从不考虑整体设计。
我认为捆绑方法优于 TDD,因为它将开发过程聚焦于设计:先设计,后代码,再写单元测试。当然,重构仍然是必要的:几乎不可能第一次就把设计做好。但是,从设计开始会减少你编写的糟糕代码的数量,让你更快地完成一个好的设计。用 TDD 也能做出同样好的设计,只是难度更大,需要更多的纪律。
UB:
我将逐一回答你的问题。
- 我并不觉得 TDD 的规模过于战术性,以至于阻碍了思考。每个程序员,无论其测试纪律如何,都是一行一行地编写代码。这是非常战术性的,但并不妨碍设计。那么,为什么一次测试会阻碍设计呢?
- 关于 TDD 的文献强烈反对延迟重构。而对设计的思考则受到了大力鼓励。两者都是这门学科不可或缺的组成部分。
- 我们一开始都会写出糟糕的代码。TDD 纪律为我们提供了持续清理代码的机会和安全性。设计灵感就来源于这些清理活动。重构纪律让糟糕的设计一步步转变为更好的设计。
- 我并不清楚为什么迟写测试是更好的设计选择。在 TDD 中,没有任何东西能阻止我在编写第一段测试代码之前,就对设计进行深思熟虑。
JOHN:
你说 TDD 并没有阻止开发人员提前考虑设计。这只是部分正确。在 TDD 下,我可以提前思考,但我不能以代码的形式写下我的想法,因为这违反了 TDD 规则 1。这是一个很大的阻碍。
你声称在 TDD 中 “强烈鼓励思考设计”,但我在你关于 TDD 的讨论中并没有看到这一点。我观看了你使用 TDD 计算保龄球得分的视频示例,在前一两分钟之后,你甚至从未提到过设计(讽刺的是,这个示例的结论之一是,简短的初始设计毫无用处)。视频中没有任何关于提前思考的建议;所有内容都是在事后收拾残局。在你给我看的所有 TDD 资料中,我都没有看到任何关于 TDD 过于战术化以至于设计从未发生过的危险的警告(也许你根本不认为这是一个严重的风险?)
UB:
我通常使用 UML 的简化形式来捕捉我的早期设计决策。我不反对用伪代码,甚至是真实代码来捕获它们。不过,我不会提交任何此类预先写好的代码。我可能会将其保存在文本文件中,并在遵循 TDD 循环时查阅。为了让失败的测试通过,我可能会从文本文件中复制并粘贴到集成开发环境中,这让我感到足够安全。
保龄球游戏就是一个例子,它说明了我们最初的设计决定与最终的解决方案会有多么大的偏差。的确,入门视频往往无法揭示一门学科的深度。
JOHN:
当我第二次观看你的 TDD 视频时,你说的一句话让我印象深刻:
人类认为排在前面的东西是重要的,而排在最后的东西则不那么重要,在某种程度上是可有可无的;这就是它们排在最后的原因,如果有必要,我们可以把它们省略掉。
这完美地抓住了我对 TDD 的担忧。TDD 坚持测试必须放在第一位,而设计(如果有的话)则放在最后,在代码工作之后。我认为好的设计才是最重要的,所以它必须是重中之重。我并不认为测试是可有可无的,但推迟测试比推迟设计更安全。编写测试并不特别困难,最重要的是要有这样做的纪律。即使你非常严谨,也很难做出一个好的设计;这就是为什么它需要成为关注的焦点。
UB:
TDD 是一门编码学科。当然,设计先于编码–我不知道有谁不这么认为。就连保龄球游戏视频也说明了这一点。但是,正如我们在保龄球游戏视频中看到的,有时代码会把你带向一个截然不同的方向。
这种不同并不意味着不应该进行设计。它只是意味着设计是推测性的,不一定能在现实中存活下来。
艾森豪威尔曾经说过
“在准备战斗的过程中,我总是发现 计划是无用的,但规划是不可或缺的”
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 的人会产生非常相似的设计,测试覆盖率也非常相似。我还大胆猜测,如果不是因为 TDDer 比捆绑者更早发现和解决问题,TDDer 的工作效率会比捆绑者高一些。
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:
首先,我要感谢你们容忍(并回应)我对《简洁代码》中一些关键观点的争论。我希望这次讨论能为读者提供思考的食粮。
在这次讨论中,我们涉及了很多主题和子主题,但我认为我的大部分担忧都来自于《简洁代码》所犯的两个普遍错误:未能关注重要的东西,以及未能平衡设计上的取舍。
在软件设计中(也许在任何设计环境中),确定真正重要的事情并将注意力集中在这些事情上是至关重要的。如果把注意力集中在不重要的事情上,就不可能实现真正重要的事情。不幸的是,《简洁代码》 总是把注意力集中在那些并不重要的事情上,例如
- 将十行方法分为五行方法,将五行方法分为两行或三行方法。
- 杜绝使用英文注释。
- 在代码之前编写测试,将开发的基本单元变成测试而不是抽象。
这些都没有提供重要的价值,而且我们已经看到了它们是如何分散注意力,无法产生最佳设计的。
相反,《简洁代码》从根本上低估了注释的价值,而注释是不可或缺和不可替代的。这样做的代价是巨大的。没有接口注释,接口规范就不完整。这肯定会导致混乱和错误。如果没有实现注释,读者就不得不重新了解原始开发者的知识和意图。这不仅浪费时间,还会导致更多的错误。
我在开场白中说过,如果开发人员无法获得重要信息,系统就会变得复杂。如果拒绝写注释,就等于隐藏了自己掌握的、别人也需要的重要信息。
《简洁代码》中的第二个普遍错误与平衡有关。设计代表了相互竞争的关注点之间的平衡。几乎所有的设计理念如果走极端都会变成坏事。然而,《简洁代码》反复在一个方向上给出了非常强烈的建议,却没有在另一个方向上给出相应的强烈建议,也没有提供任何有意义的指导,告诉我们如何识别自己是否走得太远。例如,缩短方法通常是件好事,但《简洁代码》的立场是如此片面和极端,以至于读者很可能把东西砍得太碎。我们在 PrimeGenerator
的示例中看到,这导致代码几乎无法理解。同样,《简洁代码》 对 TDD 的立场也是片面的,没有认识到任何可能的弱点,鼓励读者将其推向战术的极端,将设计完全挤出开发过程。
UB:
John, 感谢你参与这个项目。这对我来说非常有趣。我喜欢与聪明人的分歧和辩论。我还认为,我们之间的共同价值观远远多于分歧。
我只想说,我已经充分考虑了您提出的观点,虽然我不同意您的上述结论,但我已经将您的几个更好的想法以及整个文档纳入了《简洁代码》第二版。
再次感谢您,并向您的学生问好。
你也许感兴趣的:
- 【外评】15 年前我给自己的一系列编程建议
- 【外评】软件复杂性的三大法则(或:为什么软件工程师总是脾气暴躁)
- 【外评】我对 The Clean Coder 的看法
- 【外评】我为什么编程
- 【外评】我们应该将编程法则视作谚语
- 【译文】40 亿条 if 语句
- 现在开始,把代码里的 else 丢掉!
- 程序员提交 PR 的理想长度是多少?有人答:50 行代码!
- 别再说 “技术债” 了!
- 经历多次重写,苹果平台最强科学计算器PCalc背后的故事
有些人对这样的事情如此教条,这仍然让我感到震惊。我不明白为什么有人会把这些事情当作福音。
还有谁不得不面对那些一超过 80 行的字符余量就口吐白沫的白痴?
不仅仅是编程风格、模式和习语。在技术堆栈和解决方案架构方面,情况可以说更加糟糕。
当我在专业环境中与人打交道时,他们很快就会指出他们在书中读到的东西,或者更糟糕的是在博客中读到的东西,而几乎没有其他补充,这让我感到非常沮丧。
这种情况在 NoSQL 和微服务大行其道时尤为严重。在PAAS/SAAS和容器化方面也是如此。我们有很多非常非常基本的东西以函数应用或 lambdas 的形式运行,或者在 ADF 或 Talend 中运行简单的转换,但这些东西的价值为零,只会增加支持和维护开销。
始终牢记,有时你与写书/写博客/写文章的人之间的唯一区别就是他们真的写了这本书/博客/文章。他们写下的观点并不代表事实。运用你自己的思想和经验。
一想到我在职业生涯早期留下的公关评论,我就感到后怕。
“阿克苏尔这应该尝试遵循更多的稳固原则”。
但是,我是正规工程专业出身,我以为这就是专业软件工程师的含义。殊不知,这些 “原则 ”只是一个顾问的胡思乱想。事实证明,大多数人的出发点都是好的,都希望用标准化的方式来编写代码,但出于某些原因,这样做的结果总是让代码看起来像企业 FizzBuzzmeme repo。
出于某种原因,在软件领域似乎存在着巨大的非实证思维和信仰体系空间。
我在想,这是不是因为在很多情况下(取决于不同的领域),可能有效/可行的解决方案的空间几乎是无限的,而如果你没有通过测量来支持的硬性要求,你就可以自由地设想任何有效的系统结构,并证明它 “更好”,而这并不是可以观察和测量的东西。
> 由于某种原因,在软件领域,非证据思维和信仰体系的空间似乎大得惊人。
第二个问题是,书籍作者已经变得非常擅长编造伪证来支持他们的主张。最常见的形式是 “我与 X 家公司的 Y 名员工交谈了 Z 年,因此我知道什么最有效”。
如果去掉所有的哗众取宠,这不过是 “相信我 ”而已,但在一个社会证明的世界里,这听起来似乎是不可否认的。
> 社会证明的世界
这就产生了 “好做法”、“最佳做法 ”和 “坏做法 ”的概念,没有人希望自己做的事情被认为是坏做法,因为这意味着你是一个糟糕的开发者。
我几乎总是在犹豫是否使用 “工程师 ”这个词,因为据我所知,工程被认为是一种使用测量和结果来推动决策的实践/流程,这与软件的各个领域不同。你能想象如果土木工程也采用同样的思维方式吗?“这份新材料在 GitHub 上有很多星星,每个人都在说旧材料是糟糕的做法。
* 当然,这也是一件事(见:石棉),但仅限于产出可测量的地方。
一个优秀工程师的标志是知道何时这种敷衍才是真正有意义和有帮助的。为形式而形式是反模式的,但我能告诉谁呢?
在我看来,这是两本书的主要区别之一。CC 有一种你必须遵守的基于观点的硬性规定的氛围,而 APoSD 感觉更像是根据经验得出的原则或指南。
APoSD 的作者是一位备受尊敬的计算机科学家,他不仅在技术上取得了巨大成就,而且还曾担任过教授,教学经验丰富;而 CC 的作者的履历主要与撰写软件相关,而非撰写软件。
CC 的作者曾与一位德高望重的人共事……让这一点沉淀一下吧:)
我认为,这源于对编写代码的真正目的的根本误解。
编码是为存在的某些方面建立一个可计算的模型,通常是为了某些业务。在建立模型时,理解和交流是最重要的。性能和其他方面的考虑也很重要,但这些可以说是机器的偶然特征,在理想世界中,它们不会对我们的模型产生实际影响。
同样,在理想世界中,我们甚至不需要编程语言。我们可以用某种完美的抽象语言来设计和解释计算系统,而不必担心它们会以程序的形式实现。
我认为,这些空泛的理念没有充分强调活动的高层次方面,从而混淆了人们的视听。相反,人们总是纠结于特定范式/语言中的特定模式,而忘记了真正的目标是构建一个能让需要使用它的维护者群体理解的系统。
似乎每一种软件设计/开发系统、意识形态和实践都有其存在的理由,都有其固有的优势。每一种都可以解决(或至少帮助解决)一些常见问题。
例如,抽象是好的,简短的方法在某种程度上也是好的(谁会愿意阅读一个 2000 行的函数呢?
这似乎是一个来回摆动的钟摆。我们从大型前端设计到极限编程,再到无处不在的面向对象设计文化,最后又回到了其他范式。
重述一下我上个月在这里说过的话:
我喜欢说,任何在编译过程中无法存活的东西都不是设计,而是代码组织。设计是指:使用哪些数据结构(列表、映射、数组等),哪些数据要保留在内存中,哪些数据要加载/保存以及何时加载/保存,使用哪些算法,如何处理并发等。保持代码有条理是有用的,也是基本卫生的一部分,但这远不是这门手艺的决定性特征。
我完全不同意。从根本上说,设计是一门以人为本的学科,而人类在编译代码之前几乎只与代码打交道。无论我们在做什么,一个强大的共享心智模型和任何在计算机上运行的代码一样,都是软件开发的一部分。
编程语言可以(应该!)成为令人惊叹的思维工具,而不仅仅是让计算机做事的工具;使用这些工具来弄清楚我们在做什么,是有效开发的关键部分。我所见过的效率最高的软件工程工作就是找出更好的思考方式:开发更好的工具和抽象。工具和抽象是复杂的,因为它们会从根本上影响构建在其上的一切。一个好的高层次设计是一个团队在一天内就能添加某些特定功能、一个团队需要六个月时间以及一个团队说这不可能完成之间的区别。
我同意,我最近一直在想,编程最难的部分是代码组织。无论是文件放在哪里,还是如何将常用的成语(如验证或数据访问)包装成易于使用的抽象。
这很容易出大错,最终导致一团糟。
而且一开始就显得毫无意义。一个全新的项目很容易就能运转起来,但却会把组织工作搞得一团糟。但很快,对代码进行修改就会变得非常昂贵。
我加入过不少项目,都是在别人做了半年之后。很多时候,这些代码就像一团强加于人的垃圾,根本起不到任何作用。糟糕的架构师不明白为什么要使用他们所使用的模式,或者是中低级别的程序员在做项目,这些都会导致项目在看起来快要结束的时候戛然而止。
当 1000 行的代码被轻松重构为 100 行时,你就会开始思考,这些人怎么可能真的相信自己有能力领导一个项目?他们显然完全不在状态,令人沮丧。
作为一个行业,我们的管理层似乎完全无法区分真正的高级开发人员和永远不会成为高级开发人员的人。
> 保持代码条理清晰是有用的,也是基本卫生的一部分,但这远不是这门手艺的决定性特征。
我同意你的观点,但我认为将一个绝对高于另一个作为 “决定性特征 ”是没有意义的。任何一个都可能阻碍软件的开发,使其无法以有用的方式出现。
关于软件的哪些方面比其他方面更重要的争论,通常发生在那些亲身经历过项目失败的人之间。如果软件开发的任何一个方面有可能扼杀你的项目,你就会觉得它是 “决定性特征”。
> 软件开发的任何方面都会让人感觉是 “决定性特征”,如果它威胁到你的项目。
我觉得这说不通。可能有成千上万的事情会扼杀项目。我们必须考虑它们的几率有多大。
您如何定义整个行业的几率?几率取决于项目、团队、技术堆栈和组织环境。
无论我工作了多久(迄今已有 25 年),我认为我的个人经验只足以让我知道,如果我见过的事情,它可能会经常发生。如果我从未见过的事情,就我所知,它可能还是很常见的。
我的看法是,这本书也是有抱负的 SSR 和 SR 开发人员的权威来源。
对代码风格的评论通常是主观的,而且很容易被当作个人喜好,或者在小开发人员的情况下,被当作缺乏技能。
直到他们提到 “鲍勃叔叔的书”。现在,小开发人员的主观意见突然看起来就像来自扎实知识的有根据的建议。其他人现在也有理由倾听了。
当然,所有这些都是无中生有。但这就像金钱的概念一样。它之所以有效,只是因为其他人认为它有效。
什么是 “SSR 和 SR 开发人员”?
半高级和高级开发人员
半高级?
我通常不太认真对待那些在职务名称前加上高级等前缀的人。我见过比所谓高级开发人员代码写得好的年轻开发人员。
这种用法常见吗?
我从来没听说过,还以为是 gacha 术语,SSR 开发人员占前 1-2%,SR 开发人员占前 20%。
当然,虽然我经常编程,但我本身并不是程序员。在我的印象中,编程简单而有趣,但软件开发却辛苦而费力。这两者之间的区别就在于卫生等方面。
100%. 处理遗留问题要费力得多,也会使卫生问题复杂化。
系统需要能够应对在其使用寿命内所承受的各种压力。运行时字节码/机器代码/配置涉及系统的实际运行。代码则与工程师未来对系统进行修改有关。监控系统负责让操作人员确保系统正常运行。所有这些都会影响所部署系统在其生命周期内的可靠性和性能。所有这些都是系统设计的一部分。
> 代码组织
这也是代码文档。
文档清晰易读是好事,对吗?因此,审阅者有理由说 “这很难读”,因为它的主要目的失败了。
是的,鲍勃大叔当然会迂腐。我的一位朋友是Smalltalk顾问,曾与他合作过一段时间。鲍勃大叔 “不按常理出牌”。
他的清洁代码工作无疑是相当教条的。我记得,他说 Java 不是面向对象的。
不过,如果我没记错的话,他那本关于 C++ 的书(Designing Object-Oriented C++ Applications Using the Booch Method)中有一些精彩的部分。他对类和实例之间区别的描述是其中比较精彩的部分。
还有一个著名的 sudouko 谜题事件,在这个事件中,一个尝试测试驱动开发的学生无法得到答案。这是一个非常有启发性的事件,它说明了 TDD 不可能帮助你解决超出增量变化范围的问题。Peter Norvig 的解决方案非常清楚地说明了这一点。鲍勃大叔似乎没有意识到这一点。
> 还有谁遇到过那些一超过 80 行的字符边距就口吐白沫的白痴?
但我承认,在我年轻的时候,我对语言和开发实践相当教条,所以我也曾是那样的人。
>> … TDD 不可能帮助你解决增量变化之外的问题。
谢谢你表达了 TDD 这个令人头疼的问题。就我个人而言,我无法将其用于 “新东西”,我需要探索并直接使用 “真实 ”代码来创建任何非显而易见的东西。
我自己更倾向于 DTT: 先开发,后测试。
在我的职业生涯中,DSTYBYTD 流程使我的事业蒸蒸日上。
开发、出货、告诉老板你进行了测试并记录在案。
+1
我认为,“代码经过精心设计,并有大量像样的测试 ”是代码库领先于许多人的地方,即使是现在,也不管它是如何产生的。
鲍勃一生经历了太多的成功。他非常相信自己。但是,我不得不说,另一个人咄咄逼人,态度恶劣,尽管我更倾向于同意他的观点。他故意歪曲鲍勃的观点。我认为他比鲍勃提出了更多错误的确定性。不买账。
另一个人 “是 Tcl 脚本语言的作者约翰-奥斯特豪特(John Ousterhout)。
虽然我明白你为什么会认为他更 “咄咄逼人”,但我个人认为更重要的是,总的来说,他更善于描述自己的推理。
仅仅有自己的观点是很容易的,而能够清楚地阐述自己持有某种观点的原因则要重要得多,尤其是在这种对话中,而我一再发现 UB 在这方面有所欠缺。
> Java 不是面向对象的。
从技术上讲,Java 并不是严格意义上的面向对象(Smalltalk、Ruby)。它是现代意义上的面向对象(现代 >= 20 世纪 80 年代,C++)。虽然我不确定这是否就是鲍勃所说的–我并不尊重这个人或他的想法,所以我偏颇地猜测他对 OO 的定义只是他和他的粉丝们之间的共识。
不是罗恩-杰弗里斯没能解决这个问题吗?
我认为这更多地说明了键盘上的人及其对解决方案领域的不熟悉,而不是 TDD 本身。使用 TDD 时,你仍然需要洞察力和设计,盲目的渐进主义从来不是个好主意。
UB 对如何进行 TDD 的描述并没有表明,有些问题需要不同层次的思考,而他所描述的 TDD 并没有考虑到这一点。
我同意。
我曾在几个开发团队中专业地使用过 TDD。它在合适的团队中很有用。如果你不是太教条,TDD 的效果会很好。就像做任何事情一样,你需要团队中有经验丰富的人知道何时何地该做什么。我认为任何工具、编码标准、最佳实践或其他东西都是如此。你必须知道何时偏离。
我还在大学开设过入门课程,教授编程入门。我认为,TDD 是一种很好的编程教学工具。学生们往往会坐下来写一个完整的程序,然后开始调试。而 TDD 则教会他们每次编写一小段程序,并在编写过程中进行测试。
>但如果我没记错的话,他写的关于 C++ 的书《使用 Booch 方法设计面向对象的 C++ 应用程序》(Designing Object-Oriented C++ Applications Using the Booch Method)中有一些非常精彩的部分。
如果我没记错的话,Grady Booch 本人也有一本书名大致相同的书,只不过他的名字当然不会出现在书名中,而是作为作者出现在书中。我想我很久以前读过这本书,而且很喜欢。
编辑:我上网查了一下,这本书在这里的 “Booch method ”部分有提到:
https://en.m.wikipedia.org/wiki/Grady_Booch
> 永远记住,有时你和写这本书/博客/文章的人之间唯一的区别就是他们真的写了这本书/博客/文章。他们写下的观点并不代表事实。要运用自己的思想和经验。
但这种差异实际上是巨大的。我认为你贬低了写作过程的价值。假设写作者是出于善意,并真正努力提供最好的信息。
但当你开始写作时,你会发现这个想法可能并没有那么好。也许我需要读更多的相关资料?你都注意到了吗?这个想法与我刚刚写的另一个主题有冲突吗?什么才是正确的?诸如此类的问题不胜枚举。当你把所有想法都写成书面文字时,就更容易发现所有相互冲突的想法和错误。并不是所有的作家都那么优秀,但你应该明白我的意思。
写作是确定自己观点的绝佳方式。想法与写作时形成的想法之间存在很大差距。
请记住,当大多数人介绍一项技术时,他们正处于蜜月期。
因为教条主义很容易,你不需要思考,不需要考虑后果或弊端,最高领袖让你做什么你就做什么。
有时候我真希望自己也能这样生活,这样我就不用经常和那些人吵架了。
这只是初级和中级开发人员的表现。这就像哥特阶段或其他什么。
它与 BJJ、国际象棋、vim、keto、linters 和 “统治等级制度 ”相伴而生。
这很烦人,但大多数人都经历过。如果你不了解情况,他们又怎么会了解呢?
在我的 Macbook Pro M2 上,屏幕的一半是浏览器窗口,另一半是我的集成开发环境,还有一个文件树查看器窗格和另一个 LLM 工具窗格,底部还有一个终端窗格…… 我的实际代码编辑窗格从来没有像现在这么紧张过。即使是 80 个字符,我也要横向滚动。辅助显示器能帮上忙,但当你经常离开办公桌工作时,辅助显示器就无能为力了。
在笔记本电脑上编码,即使是名列前茅的地位,也是你的主要问题所在。出于物理/位置原因,你必须在 15 英寸的屏幕上写代码。你不应该选择这样做,也不应该围绕这种限制来设计你的工作流程。
一台 42 英寸 4k 电视机(大流行之初,我花了两三百美元买到的)能让我在一台中级 Chromebook 上使用四个 80-90 列的文本窗口。你给我的钱还不够我在笔记本电脑上做同样的工作,哪怕是一台 4000 美元的 MBP。
(没错,即使有大量的空间,80 列仍然是净胜)。
我家里有一台 120 英寸 4K 显示器和一台 40 英寸 2K 显示器。不过,这完全忽略了我评论的重点,那就是我在工作时经常不在办公桌前。我不知道你想表达什么意思。
投资 Vision Pro。它将改变你的生活。
我也在考虑 XREAL Air 2 Ultra,它似乎更适合旅行使用,但超宽屏的概念听起来真的很不错。我可能会采纳你的建议。
在文字渲染分辨率方面没有可比性。Vision Pro 是目前市场上唯一一款接近 20:20 视觉分辨率的产品。
> 还有谁遇到过超过 80 行字元边距就口吐白沫的白痴?
在我 11 年的职业生涯中,一次也没有。但是,我工作过的几乎所有代码库都存在令人崩溃的可维护性问题,因为其他工程师似乎唯一遵循的原则就是 DRY,而不惜牺牲 SOLID 的所有原则。
我清理过的大多数代码在变得更加 DRY 之后,维护起来就容易多了。
但问题的关键并不在于 DRY 本身。关键在于代码有更好的抽象,易于推理。
UB 似乎把抽象看得太远了,例如,用一些清晰的抽象取代了两行非常清晰的代码。
DRY 的目的是确保同一代码不必在两个地方修改,因为在一个地方修改代码的工程师可能不知道这一点。但 DRY 的许多应用都无意识地违反了单一责任原则,在不应该有耦合的地方产生了耦合。
clearTotals()可以说比其他 “抽象 ”方法更有意义,因为如果你有不止一个状态需要重置/初始化,你就想集中知道哪些变量必须一起设置–否则就很容易添加另一个状态,却忘记在该设置的地方设置它。
当然,方法只是捕获这些信息的几种方法之一,而且不一定是最好的方法。
>> 始终牢记,有时你与写书/写博客/写文章的人之间的唯一区别就是他们真的写了这本书/博客/文章。
说得好!
对于许多 C#、Java 和 C++ 工程师来说,鲍勃大叔是他们的救世主,而 GoF 则是他们的使徒。
一切都应遵循 SOLID 和简洁原则,并使用设计模式来实现。
我能为自己做的最好的事情之一就是回到过去,告诉年轻时的自己不要太在意 “正确的 ”设计模式,或者用完美的 DRY 方式来表示一段代码。在很长一段时间里,我绝对是自己最大的敌人,因为我认为 SOLID 和 GoF 设计模式比编写易于理解的代码更重要,因为在未来的某一天,你的系统需要使用新的数据库或文件系统等做完全不同的事情时,这些代码不需要在多个文件之间跳转。我开始寻找可以添加设计模式的地方,而不是让它们自然发展。我构建的大多数软件都不需要如此繁重的抽象和复杂性,20 年来,我只需要切换过两次数据库系统,而抽象最终并没有帮助减少多少时间或复杂性。与重写直接处理数据库的部分相比,这绝对不值得进行前期规划。
也许这是一种权利,在过度架构的解决方案严重烧伤自己的时候,你终于开始明白你并不需要所有的复杂性。为人类编写代码,越简单越好。牢记大型性能问题,但只有当它们成为问题或非常明显时,才围绕它们编写代码。如果说有什么帮助的话,那就是引导初级开发人员远离复杂的代码,同时鼓励他们利用自己的时间进行尝试。你可以自己想办法,但请不要在共享代码库中这样做,好吗?
游戏开发人员在外面抓流浪狗。
很好。我从游戏开发人员那里看到的代码,不良做法有理有据,自负过头了。
这很不幸,因为没有(合理的)理由像这样编写 C# 这种多范式语言。
与我共事过的最糟糕的工程师就是这样一个人,他认为只要是在书上看到的,他的观点就能压倒一切。有一次,他气急败坏地嚷道:”等你像我一样读了 13 本关于这个话题的书,再来讨论吧!” 而且还是一些超级平凡的事情,比如如何组织配置文件什么的。
搞得每次工程规划会议都很麻烦。
> 有些人对这种事情如此教条,至今仍让我匪夷所思。我不明白为什么有人把这些东西当作福音。
我喜欢阅读不同观点的书籍。
不过,我很鄙视那些读了书就想把自己的书本知识凌驾于他人之上的人。这些人认为自己读过一些书,所以在任何情况下都能占上风。他们几乎总是认为你没有读过这些书。如果你指出你也读过这些书,他们就会把话题转移到他们读过的另一套书上,因为他们不喜欢有人试图削弱他们的书本知识优势。
更糟糕的是,这个人读了自己领域以外的书,还试图把书本知识引入工作场所。我遇到的最糟糕的经理就是这样一个人,他读了很多流行心理学的书,然后试图根据这些书对我们每个人进行心理分析。
> 口沫横飞的白痴
这似乎是对人的一种不必要的苛责。
清洁代码的狂热分子一直是我共事过的人中最不讨人喜欢、最没有生产力、最不务实的。我有过多个客户,整个团队都在威胁要辞职,除非解雇清洁代码狂热分子。而当他们被解雇后,你猜怎么着–错误减少了,已交付的功能增加了,会议变得高效了。在我看来,“口吐白沫的白痴 ”是一种轻描淡写的说法。
这似乎也是一种相当强硬的立场。我认为,你很难与那些被你称为不像话、没有生产力、不务实、过于热心、口吐白沫的白痴的人合作,这并不奇怪。
这让我想起了一个关于素食主义者的标准笑话:”你怎么知道某人是素食主义者?别担心,他们会告诉你的”。
这是一个非常具有讽刺意味的笑话,因为我听得最多的关于素食主义的人就是抱怨素食主义的非素食主义者。在这个主题中也是如此。我只看到有人抱怨清洁代码的人是多么教条,而我在这个主题中却没有看到这样的例子。我看到的唯一强烈而绝对的语言来自那些抱怨 CC 人士的人。
请记住,我与这场斗争无关。我不是素食主义者,我的方法有时比四行还长,我偶尔也会写评论。不过,如果这个话题能说明什么的话,清洁代码的人似乎要比反动分子好相处得多。
我并不是要憎恨这些人,但当我遇到他们时,他们周围的社会和代码环境总是表现出同样的功能障碍。我已经见过很多次了。当你的团队因为无法忍受与你共事而威胁辞职时,问题很可能出在你身上,而不是他们。而当你被解雇后,错误减少了,速度提高了,士气问题消失了,很明显,问题出在你身上。这并不是手忙脚乱的理论推断,而是不同公司数十个实例的一致模式。
我是素食主义者,让我告诉你,有些素食主义者完全令人难以忍受。虽然对素食主义者喋喋不休的人确实比难以忍受的素食主义者要多,但这也有一定的道理。
我没有读完这个主题中的所有 416 条评论,但我确实与一些虔诚地遵循 “清洁守则 ”的人共事过。我遇到过的最奇怪的例子可能是这样一个人,他坚持认为自己是一名后端开发人员,不应该打开用户界面测试任何东西,因为测试应该总是足够的,并且会继续将错误推送到生产中,因为早期的 “启动期 ”产生了一些测试有限(或没有!)的非常糟糕的代码。这完全行不通。他走后,团队开了个派对。当时有两个人辞职,很大程度上就是因为这个人。
我也曾与一些人共事过,他们总体上喜欢 “清洁代码”,但并不严格遵守它,尽管它适用于特定的项目。这没什么:这只是一种普通的分歧,比如 Python 和 Ruby 之间的分歧,或者大括号应该放在哪里之类的:你们互相讨论,然后达成一种对每个人都合理的解决方案。
而 “反动派 ”大多只是想把事情做好的普通人。
除此之外,他们的论点是 “我讨厌干净代码的人,因为他们使用了糟糕的推理(在博客上读到的东西)”。这些观点相互排斥。OC 的基本论点是,清洁代码没有好的理由。
我更喜欢可读的代码,但我不会称自己为 “简洁代码 ”者。
我在这里对简洁代码持强烈反对态度,但我很乐意同意你和钢人的观点:我认为简洁代码在学术和单个开发人员的软件项目中是完全可行的。
我认为大部分的反对意见(当然也包括我所有的反对意见)都是关于它悄然进入生产环境,并加剧了在团队环境中容易出现问题的个性特征,但这并不能使抽象概念失效,而应将其范围扩大到实践中。
我见过有人看了一眼代码库,说它太复杂了,并建议阅读《清洁代码》。
这种复杂性完全是由一个用于全国互操作性的外部数据模型决定的。
根据我的经验和 OP 中的对话,鲍勃大叔是一个虚荣心驱使的自恋狂,是一个具有传染性的骗子。
嗯,“清洁代码 ”倡导者除外。
也许他们把 “代码 ”错当成了 “牙齿”?
天哪,我花了 5 个小时才拿到这个。很高兴我终于看到了!
但是……它们确实会起泡!
其他行业的专业人士并不 “只是 ”写书。从某种意义上说,通常该领域都有几位广受赞誉的作者,他们为确保自己的书有意义付出了一些扎实的工作。虽然在其他领域会有不同意见,或一些废话连篇的惯例,但传统智慧通常至少足以让你成为一名优秀的专业人士。
在编程领域,这是一个狂野的西部。很多说法都是凭空捏造的。当涉及到 CS 的科学部分时,很少能看到任何合理的研究。但遵守规则会让生活更轻松。即使规则很糟糕。这也是保守主义作为一种政治理念存在的原因。
> 当涉及到 CS 的科学部分时,很少能看到任何明智的研究。
你发现维克多-巴西利的作品了吗?
我曾经工作过的地方有一位 “建筑师”,他对自己做出的决定有任何疑问,都会把它说成是 “最佳实践”。
他经常错得离谱,总是让人气愤。
> 还有谁曾遇到过超过 80 行字符边距就口吐白沫的白痴?
老实说,这是一个非常平庸的开发人员的最好写照。
我的经验是,对代码格式的严格要求与一个人作为开发人员的能力无关,也就是说,两者都不是一个好的指标。
我继承了许多格式草率的代码库,它们都包含一些错误,而这些错误又与混乱的代码库融为一体。风格一致的代码有一个很好的特性,那就是某些类型的错误会立即显现出来。
例如,在 if 语句后面有一大段代码,但它的缩进与 if 主体相同。开发人员忽略了收尾括号,将逻辑放在了错误的位置。此外,还有一个嵌套的 if 语句,其主体的缩进小于周围的代码。这很难阅读,而且容易出错。
我无法想象一个 “优秀 ”的开发人员会容忍这种情况,尽管我承认你不必 “一丝不苟 ”地防止这类事情发生。
与我共事过的最好的开发人员是个 “有谱儿的人”。
他的代码真的很简单。
我逐渐认识到,没有 “规则”,只有启发式方法。
我都不记得上一次在一个团队工作时,没有在签到时自动执行inting的情况是什么时候了。为什么人们还要浪费时间去争论可以自动完成的工作呢?
我注意到,一个人的语言能力越差,他的代码格式就越差。
然后,人们就会制定严格的代码格式化规则,因为他们意识到格式化良好的代码更易于阅读和扩展。
然后,人们意识到这是代码组织的问题,而不是规则本身的问题。他们有自己的偏好,但不会将这些偏好视为 “唯一正确的方式”。
因此,规则严谨的人处于中间地带,变得不那么糟糕,而这是一种广泛的能力。
不同意。非常不同意。
更聪明的人写的代码更烂。
干净的代码是给更笨的人写的。
仔细想想吧。因为聪明人不需要干净的代码。对他们来说,代码是如此琐碎,如此易读,以至于他们真的不需要超简洁和格式化。
因此,聪明人对编写简洁代码的强迫症倾向是随机的。他们要么有,要么根本不在乎。
但在愚蠢的人中,这就不是随机的了。他们需要简洁的代码,因为他们不够聪明,无法理解不简洁的代码。
或者说,最聪明的人明白,比他们更不聪明/经验更少的人也能使用代码,这一点同样重要。
不,这就是自以为聪明的蠢人的想法。
聪明人往往不会意识到自己有多聪明,他们不会承认代码不干净。对他们来说,这只是小事一桩。
在 HN 上,很多人,我是说很多人都认为自己很聪明,但实际上他们都很普通或愚蠢。在这种水平上工作的聪明人很少。真的很少。我所在的整个公司,没有一个人真正 “聪明 ”到我所说的程度。大多数人都很普通。
好吧,你对 “聪明 ”的定义听起来与这个主题中其他人使用的定义不同。在我看来,你更像是在谈论 “天才”,特别是那种无法与普通人相处的天才。
不,我说的是天才。而不是野蛮人。野蛮人很可能无法与你的情感产生共鸣。天才可以。区别仅仅在于,如果他们花 1 秒钟就能分析出你花 5 分钟才能完成的事情,除非你告诉他们,否则他们往往意识不到这一点。
话虽如此,但我所说的东西是有梯度的。你越聪明,写出低劣代码的可能性就越大。
你的代码越干净,你越有可能写得愚蠢。这并不是一个明确的信号,但确实存在相关性。
这是我听过的最愚蠢的事情
可能是因为你认为自己很聪明,而且你写的代码过于简洁,并对其过于苛刻。至于你是否真的聪明,那就另当别论了。
你在脑海中构建了一个我的形象,而这个形象与现实完全不符。祝你好运。
这只是一个幸运的猜测。
多年来,我一直在努力寻找我遗失的一句话,听起来你可能知道,因为我想它会引起你的共鸣。
这句话出自查尔斯-西蒙尼(Charles Simonyi)之手,说的是随着年龄的增长,他脑子里处理大量信息的惊人能力下降了,因此,他开始写出更好的代码。你知道这个故事吗?
另外,我对你的观点半信半疑,但我看到它以两种不同的方式发生。在为研究目的编写临时代码时,我看到一些很有天赋的人写出了看似马虎、直奔主题的代码,因为这是达到目的的最快方法。我之所以说看似马虎,是因为另一个程序员会看到一个错综复杂的机制,在很多地方都差之毫厘,谬以千里。而写代码的人却会说,这已经是 100% 的显而易见了,怎么可能再改得更明显呢?
在软件开发方面,我有时会看到一些很有天赋的人写出复杂得令人难以置信的代码,因为他们喜欢发挥自己的智力,喜欢看到自己能创造出华丽的高塔。但我也会看到普通程序员和笨程序员做同样的事情,唯一的区别是,有天赋的人可以逍遥法外,然后才开始对他们造成伤害。更有甚者,我看到一些非常优秀的工程师,虽然天赋异禀,但也试图效仿天才,结果却事倍功半。有天赋的程序员通常会厌倦这种做法,并逐渐摆脱它,但其中有些人却乐此不疲,他们致力于欺骗自己和其他人,让他们相信这是编写软件的正确方法。
> 因此,在聪明人中,写出整洁代码的强迫症倾向是随机的。
如果他们完全独自工作,而且他们的工作完全不依赖于其他人使用他们的代码是否成功,那么这种倾向就是随机的。不过,在大型软件项目中,我的经验是,这并不是随机的:最聪明的人最终都能写出好代码,除非他们别有用心或有严重的社交盲点。
是的,他们会学习。但这种趋势依然存在。他们会努力降低代码的难度,但往往会优先考虑速度和效果。
这在定义上是不正确的。
低劣的代码不会运行,或者不会做作者认为它应该做的事情。你不可能写出真正低劣的代码,但却很聪明。
我见过聪明人为了写出 “聪明 ”的代码而陷入困境。滥用一种语言的特性,让代码 “看起来 ”很聪明。我从未见过我认为聪明的人在重要的地方写出完全没有格式化的代码。
我可能不同意他们的所有选择,但与我共事过的最聪明的人往往会让代码的结构反映出他们头脑中的问题结构。没错,在这种情况下,你会开始把代码本身当作自己思考的助手。你不去想东西在哪里,因为它们就应该在哪里。
为了编写软件而强迫自己记住一堆毫无意义的细枝末节,这不是聪明的表现,而是想让别人觉得自己聪明的表现。
>低劣的代码无法运行,或者无法完成作者认为它应该做的事情。你不可能写出真正低劣的代码却很聪明。
我说的低劣代码是指美学上的低劣。而不是本质上的低劣。所以定义上是对的,取决于你的定义。显然,我是在说话,而且我确定了上下文,所以大多数读者都能明白我是在使用我的定义,而不是你自己的定义。
>我见过聪明人为了写出 “聪明 ”的代码而陷入困境。滥用一种语言的特性,让代码 “看起来 ”很聪明。但我从未见过我认为聪明的人在重要的地方写出完全没有格式化的代码。
那你就没接触过最聪明的人。你身边可能有比普通人更聪明的人。
>我可能不同意他们的所有选择,但我共事过的最聪明的人往往会让代码的结构反映出他们头脑中的问题结构。没错,在这种情况下,你会开始把代码本身当作自己思考的助手。你不去想事情在哪里,因为它们就在应该在的地方。
是的,最聪明的人在头脑中构建的问题结构是常人难以理解的。他们可以在脑子里容纳更多的东西,所以结构会很复杂。
>为了写软件而强迫自己记住一堆毫无意义的细枝末节,这不是聪明的表现,而是想让别人觉得自己聪明的表现。
我说的不就是这个意思吗?对于聪明人来说,格式化规则就是一堆毫无意义的细枝末节。这对他们的可读性没有任何帮助,因为他们的智慧可以让他们轻松地解析最糟糕的代码。我指的是美观上的低劣,而不是本质上的低劣。
> 那你就没接触过最聪明的人。你身边可能有比普通人更聪明的人。
这完全无法证实。我说周围最聪明的人总是穿着小丑鞋上班。如果你不同意,那只是因为你没见过我说的那些人。问题解决
那就说丑。正如你所见,“低劣 ”可以是模棱两可的。大多数人都会把漏洞百出的代码归类为低劣代码。
> 那你就没接触过最聪明的人。你身边可能有比普通人更聪明的人。
这实质上是用你自己的信念来证明你的信念是正确的。你说我没有接触过最聪明的人,是因为我说最聪明的人不会像你说的那样。你是在说 “我是对的,所以你是错的”。也许你身边没有最聪明的人。
> 是啊,最聪明的人在脑子里构建的问题结构是常人难以理解的。他们可以在脑子里容纳更多的东西,所以结构会很复杂。
复杂就是简单 简单则难。没错,有些事情本来就比其他事情复杂。但我们的目标是把重要的东西记在脑子里。尽可能多地卸载,这样才能专注于重要的事情。
> 这不正是我要说的吗?对于聪明人来说,格式规则就是一堆毫无意义的琐事。这对他们的可读性没有任何帮助,因为他们的智慧可以让他们轻松地解析最糟糕的代码。我指的是美观上的低劣,而不是本质上的低劣。
不,这不是你要说的重点。
另外,请看我说的每一句话。严格遵守任何一种风格都不是智力的标志。我明确说过,严格遵守基本上适用于各种技能的人。但高手也有偏好,但要意识到它们更多的是指导原则,可读性比规则更重要。
而规则应该是合乎逻辑的,基本上是第二天性的。比如缩进在大多数语言中都是完全可有可无的。但适当的缩进可以让你更好地直观了解代码的流程。没有人会阅读/编写精简过的 JavaScript。
>这实质上是用你自己的信念来证明你的信念是正确的。你说我没有接触过最聪明的人,是因为我说最聪明的人不会做你所说的事情。你是在说 “我是对的,所以你是错的”。也许你身边没有最聪明的人。
我有量化的证据。他们的智商都在150以上。
>复杂很容易。简单就是难。是的,有些事情本来就比其他事情复杂。但我们的目标是把重要的事情记在脑子里。尽可能多地卸载,这样才能专注于重要的事情。
复杂并不容易。简单不一定总是困难的。故事显然比这更复杂。
>不,这不是你要说的重点。
就是这样 这是个反问句
我看了你说的每一句话。首先,我从没说过要严格遵守某种风格。聪明人都有自己的喜好。
>规则应该是合乎逻辑的,而且基本上是第二天性的。就像缩进在大多数语言中是完全可有可无的。但适当的缩进可以让你更好地直观了解代码的流程。没有人会阅读/编写精简过的 JavaScript。
我见过一些聪明人能做到这一点。他们根本不在乎。
你只是在做更多的宣称。你说他们的智商超过 150 并不意味着他们真的超过了 150。或者说,你并不是为了证明自己的观点而胡编乱造。
另外,你怎么会知道他们的智商?我就读的是本州最好的高中,也是全美最好的高中之一,它成为了今天整个高中阶层的模板。我这么说不是为了吹牛。我这么说是为了证明我的下一句话。我无法告诉你那里任何人的智商是多少。我从来没说过这个问题。
> 首先,我从未说过要严格遵守某种风格。聪明人都有自己的偏好。
这直接反驳了你的其他说法。你说聪明人只会写难看的代码,而不在乎格式。你甚至在这篇文章的后面也是这么说的。这几乎就像你在说任何你认为在当下会让你正确的事情。
你还声称看了我说的每一句话,但你在这里的发言却自相矛盾。因为我说过,高手都有自己的喜好和风格,但也知道有些时候规则与目标背道而驰。
而你可能真的相信你见过聪明人能做到这一点。然而,在这一点上,我怀疑你是否有能力准确判断任何人的技能或智力。或者判断他们说法真实性的能力。
这是因为我们谈到了门萨,而他是其中一员。我问他的智商,他告诉了我。他说是,我相信他。然后另一个人也说有人邀请他加入门萨,但他没有。你怎么看都行。
当你编写代码时,你有编译器和其他各种工具的帮助,再加上你头脑中已经完全形成了代码的运行模型,而且你最近还记住了其他各种方法,以及它们是如何失败的,为什么会失败。而在阅读代码时,你却没有这些东西。
因此,阅读代码比编写代码更难。
所以,如果你写的代码是用你的全部智力写出来的,那么你就太笨了,无法读懂它。
我从没说过聪明人会用全部智力写代码。
他们很可能是在用自己智力的一小部分编写代码,而这些代码对于正常人来说仍然过于复杂,难以理解。
现实情况是,你必须与不同智力水平的人合作。你的堆栈必须让 “普通 ”程序员也能理解。否则,祝你好运能找到人。
没错。但我说的仍然是事实。这种趋势是存在的。
聪明人往往不知道自己有多聪明,也不会意识到自己的代码有多难读,直到代码审查时,愚蠢的人才会指出聪明人认为 “显而易见 ”的问题。
> 然后,人们就会意识到这是代码的组织问题,而不是规则本身的问题。他们有偏好,但不会把这些偏好当作 “唯一正确的方法”。
是的,我同意这一点
比方说,大括号的位置并不重要,重要的是保持一致。
不过在某些情况下,我认为像非常严格的 80 个字符限制实际上会导致更糟糕的代码(或者至少是更难读的代码)。
我的意思是,他们写了很多关于这个问题的书,有一个人还厚颜无耻地把他的观点称为 “哲学”,尽管这只是一种武断的观点。
大部分软件都是在给概念分配大词和过于复杂的术语,这些东西伪装成具有深层含义的东西,而实际上只是一些编造出来的观点。
软件设计是一门艺术。它不是工程,也不是科学。这就是为什么有那么多胡编乱造的东西。讽刺的是,我们用 “艺术 ”来解决编程中的工程问题。这就好比,我们实际上并不知道编程的最佳解决方案,所以我们编造了一些狗屁模式和哲学。不过,我们可以给这种模式起个过于复杂的名字,比如山达基或反转控制,现在大家都认为这是一个正式、合法的科学概念了。
好吧,科学论派的猫已经出笼了。很多软件中的骗局还没有。哲学 “是我见过的最扯淡的词了。
但有一些我们都同意是好的典型做法:使用 VCS、编写测试、在需要时编写注释、分离不同的抽象层次,等等。对吗?这源于多年来在软件方面的共同经验。
随着时间的推移,我们会发现一些模式、常见问题和解决方法,等等。这不一定是严格的模式,而是整体策略。
如果我们不这样做,那就只能是凭感觉,对吗?工程部分在哪里?
没有什么是一成不变的。如果你拥有强类型,并使用纯函数和不变性编程,同时充分利用联合类型和匹配,那么你的代码通常只需要很少的单元测试就能正常工作。你只需要集成测试和 e2e 测试。
我很少写单元测试,因为我的编码风格是尽可能严格地使用静态检查,因此不需要单元测试。
我认为只有 30% 的模式是好的,是可以共享的。其他的只是艺术和观点。比如方法名长度、注释或 OOP。
我不太同意,单元测试应该测试行为,有无类型不应成为覆盖率的决定性因素。
我不认为模式整体上是好的,但存在已知的问题和现有问题的结构,因此将其归结为艺术似乎是还原论。
你能举例说明你在哪些地方使用了 “静态检查”,而其他人可能更倾向于使用单元测试吗?我很好奇。
我不太同意,单元测试应该测试行为,类型不应该成为覆盖率的决定性因素。
我不认为模式整体上是好的,但存在已知的问题和现有问题的结构,因此将其归结为艺术似乎是还原论。
你不同意是因为你可能没有像我一样使用静态检查。比如我的代码中没有字符串或数字。一切都在严格的联合类型边界上运行。只有在 IO 或状态接口上才会出现字符串等无界类型。
我可以编写代码而不测试行为,也可以让行为在没有测试的情况下可靠运行。关键词是单元测试。通常情况下,IO 和这些边界之外的东西需要集成测试。
如今,大多数网络编程实际上并不需要太多的单元测试。你所做的处理并不多。网络层的功能是路由器,而网络层是一个元层,它编写的代码会在其他地方执行。
一半以上的代码以 sql 的形式执行。集成测试要重要得多。
将其归结为一门艺术并不是还原论。这是事实。编程中的科学方法在哪里?一个模式是如何用科学方法推导出来的?如果不是用科学方法推导出来的,那么它是像数学那样从公理和逻辑中创造出来的吗?它是定理吗?
不是,都是编造出来的。我们没有量化的方法来验证为什么一种设计比另一种设计更好。这就是软件技术经常横向发展的原因。我们无法验证当前的设计比上一个设计更好。
甚至你我都有分歧,陷入僵局。你能证明你的单元测试优于我的静态测试吗?并不能。实际上,如果不考虑使用依赖类型所需的工作量,静态检查是可以证明更好的。
这是一种有趣的方法,我想看看你所说的实现。你使用的是哪种语言的类型系统?
我同意编程通常没有科学的方法。不过我觉得还是可以的。当然,并不是所有东西都有科学方法,有些东西总是取决于个人品味和理解,但只要对现有的大规模代码库进行分析、开展调查、在大公司内部测试不同的方法,也许就能移动光标。这种方法更类似于社会科学,尽管在某些圈子里这可能是个不好的词!
我们可能不会达成一致,但我确实很喜欢听别人讲述如何编码。
Typescript 可以实现依赖类型、联合类型、穷举匹配以及实现这种编程风格所需的一切功能。它只是不严格而已。
另一种语言是 rust。虽然它的类型系统不如 typescript 那么富有表现力,但它是严格的,这意味着没有人能真正通过作弊摆脱困境。因此,一般来说,Rust 代码比 typescript 需要的单元测试更少。
另一种语言是 Idris 和 Haskell。但这些语言很少被使用。
这篇文章可以让你对我所说的有所了解:https://wiki.haskell.org/Why_Haskell_just_works#:~:text=The%…
谢谢,我已经有一段时间没有接触 Typescript 了,所以现在可能正是时候。当你觉得自己已经掌握了相关行为时,你会进行复杂的集成测试吗?
我使用相当面向类型的语言编写大型网络应用程序,但我仍然编写了大量单元测试。主要与解析有关。
是啊。就应该这样。通常情况下,你不需要太多的解析,因为它是以 json 或 protobuf 的形式进入的。但 IO 与你的代码程序之间的接口是可能出现异常和错误的地方。在这个边界之外,你的代码应该是纯粹和确定的。
既然你是自己进行解析,而不是使用模式验证器和现有格式(如 json),那么你的代码就在进行大量的数据处理,因此需要大量的单元测试。大多数情况下,开发人员可以信任解析库。
你描述的情况对我来说并不陌生。也许你是在为房间写作,而不是立即为我着想。
解析有点……无处不在。路径片段实例?解析。表单?解析。智能构造函数的全部意义就在于解析。从持久层反序列化?解析。当然,JSON 和 Protobuf 也是如此,但即使依赖于像 aeson 这样强大的库,我们仍然要编写测试。为什么不呢?你定义的类型可以用不同的方式序列化,而你反序列化的方式需要与你(或外部系统)序列化的方式往返,这也需要更多的测试。
我不会这样做,因为反序列化库的维护者已经对该库进行了严格测试。
问题在于编程就是交流。交流的确是一种艺术。编程不仅仅是向机器下达指令,如果是这样的话,我们就会乐此不疲地使用二进制代码了。因此,我们有两个维度,第一个维度是给出二进制指令,另一个维度是如何让人类(包括我们自己)理解这些指令。
不,编码还有其他维度。通信只是一个维度。
还有优化和模块化。所有这三个维度都是紧密联系、相互关联的。
方的人永远不会同意酷的人的观点。你可以很酷,编写一些复杂的代码;你也可以很方正,每当看到一个很长的方法时就说 “我们必须从头开始重建整个项目”。
你只需要做一个在书出版时盲目执行鲍勃叔叔建议的项目,就能知道这些建议的价值有多大。当时,在软件工程方面有一些低垂的果实可供采摘,他写了一些关于这些果实的文章。
在敏捷时代之初,他和其他许多著名作家一样,在担任软件工程师期间从未写过任何有意义的作品(在范围和知名度上)。他的成功只是初级开发人员寻求某种指导的结果,而这种需求是永无止境的。
可怕的建议产生了大量的代码,这些代码具有大量的间接性,工作起来非常痛苦。真的很痛苦,伙计们。
> 成功只是一大波初级开发人员寻求某种指导的结果,而这种需求是永无止境的。
问题是,有些人从未从中成长起来。我曾面试过一些公司,他们会把这本书发给任何新来的实习生/初级开发人员。然后,在招聘过程中,他们甚至不问你是否读过这本书,而是直接问你对这本书的了解程度。比如 “鲍勃叔叔在他的《清洁代码》一书中是怎么说 X 的?他们会不断提到这本书。有些人甚至会在公关中引用这本书。
最糟糕的是,一旦他们离开自己的公司,由于他们不懂其他东西,他们就会在其他地方应用同样的东西,并将其转换到新公司。
阅读 FitNesse 框架的源代码很有帮助。
https://github.com/unclebob/fitnesse
你可以看到他的所有想法是如何汇聚成一个由数百个几乎空无一物的类组成的球,以及诸如 “catch Throwable ”之类的瑰宝。
(英语小贴士:advice 不是可数名词,所以不能用复数表示)
我完全同意。我和鲍勃叔叔的接触是作为初级开发人员接受其他初级开发人员的建议[没有 “s”]。
是的,我也觉得 “敏捷时代 ”的许多大师从未真正成功开发过任何产品,这一点很可疑。
值得注意的是,肯特-贝克(Kent Beck)并不是这样的人,因为他发布了第一个单元测试库,后来又发布了很多其他语言的单元测试库。
比如,我个人更喜欢裸断言式的测试(如 pytest),但 junit 风格现在基本上已经无处不在了。
Kent Beck 和 Uncle Bob 一样糟糕!他喝了自己的 “酷爱之水”(Kool-Aid),全心投入了他掀起的疯狂的 XP 编程热潮(……其中包含要求每写一行代码都要结对编程这样的高明之处)。
听着,两位作者都是非常聪明的人,他们对开发都有很好的见解,我们都可以从中学习……但他们也都有一个缺点,那就是太爱自己的想法了。
这让他们看不到这些想法中的缺陷,也让你在阅读他们的作品时不得不持怀疑态度,对每个想法进行独立评估。
谢谢你的纠正,我对缺乏证书的看法是,在早期,你不需要证书就能流行起来,所以内容很少,没有人去检查。我有点像现在的情况,动漫简介图片的微博账号就像他们发明了人工智能一样,没有显示任何代码或成就。
干净的代码、设计模式等也被教师、教授和课程讲师们所接受。
我认为这些范式和模式往往在错误的抽象层上运行,而大多忽略了最重要的东西,比如效率、错误处理和调试。
但要做好这些事情,需要付出更多的血汗和泪水,所以没有什么简单的教学方法。
清洁代码试图在比效率更重要的层面上运行:代码维护。在绝大多数情况下,计算机的运行速度已经足够快,你无需担心效率问题。(部分原因是任何现代语言提供的所有常用算法都已高度优化,使用起来比手工编写更简单,因此你需要担心的地方都已经很高效了)。
当然,错误处理和调试也是维护的一部分。然而,除了这两方面,还有很多其他方面需要考虑。
我们有理由憎恨 “清洁代码”,但那些最不遵守规则的人所编写的代码仍然比以前那些不可能的东西要好得多。“认为有害的后退 “是解决程序员曾经做过的所有坏事(有些人现在还在做)的早期步骤之一,但你可以遵循认为有害的后退的 ”规则”,但仍然可以产生非常糟糕的代码,所以我们需要更多。
根据个人经验,最耗时的维护问题是由于以下原因造成的:
– 第三方依赖、兼容性问题、破坏性更改
– 臃肿的抽象和间接代码
– 性能问题,尤其是通过缓存等方式解决的问题
– 错误处理不善
– 数据不一致
如果代码比较简单,可以直接遵循和逐步完成,则可以从一开始就避免上述 3/5 种问题。随着时间推移而出现的模式和抽象有时是有益的。过度使用抽象的遗留代码或第三方代码实际上是一种障碍,会大大降低我理解、拥有和修复问题的速度。
听起来你很幸运,避免了一些历史上最糟糕的代码实践。真为你高兴。
我希望你能找到一个好的答案来解决你提到的问题。我还没有看到任何我有信心的方案。
另一方面,《软件设计哲学》简明扼要,非常出色,是基于数十年的教学经验编写而成的。
如果我中了彩票,我不想要一栋以我的名字命名的大楼,我想给每个大学计算机科学[1]专业的学生捐一本这本书,并把它作为必读书目。
1: 我知道这是一本软件工程方面的书,但由于目前很少有软件工程学士学位课程,所以你懂我的意思&trade;
更重要的是,要有编写优秀软件的经验。
世界不是二进制的
你想说什么?
我把这句话理解为讽刺。
他们都没有提到评论的一个重要案例。有时,你要处理的是超出你控制范围的错误或反直觉过程。
例如,我现在正在为一个 USB 设备编写驱动软件。即使是在文档规定的协议范围内,设备也很容易进入不良状态。每当我实现了一个变通方法,或者弄清了设备期望的信息显示方式,我都会在注释中记录下来。否则,当(不可避免地)代码需要添加功能或重构时,我就会完全忘记为什么要这么写。
质数示例是一个自足的确定性算法。虽然我发现有注释的代码更容易解析,但如果没有注释,我仍然可以花时间去理解它。在我的 USB 设备驱动程序中,如果没有注释,再多的审查也无法告诉其他人我为什么要以某种方式编写命令序列,或者哪些时序是重要的。
唯一的办法就是使用一些愚蠢的方法名称,如 “requestSerialNumberButDontCallThisAfterSettingDisplayData ”或 “sendDisplayDataButDontCallTwiceWithin100Ms”。
> 唯一的办法就是使用愚蠢的方法名
没错。方法名是很糟糕的注释。没有空格,难以直观解析,这还不算缩写和歧义。
我经常写一些长达几近论文长度的注释来解释一些特别诡异的行为,或者在处理一段反直觉/敏感/可怕的代码时需要注意的事项,因此我写巨型注释的原因就更简单了:我放在注释中的所有内容都应该放在相邻的文档(或 ADR、故障排除日志、运行手册等)中。当我把它们放在这些地方时,人们不会去读它们,然后他们就会对代码做错误的事情。当我把它放在注释中时,人们就会阅读它,“用错误的方式更新可怕的代码导致的错误再次发生 ”这类事件的发生率降为零就是证明。修复注释块比修复工程师更容易。
为提到 ADR 而点赞!;)
我非常喜欢这种方法,正如其他评论已经提到的那样,这是一种捕捉特定决策 “为什么 ”的好方法,它超越了代码中的 “如何”。
对于不熟悉 ADR 的人来说,这里是一个很好的起点: – https://cognitect.com/blog/2011/11/15/documenting-architectu… – https://adr.github.io/
> 是的。方法名称是很糟糕的注释。没有空格,难以直观解析,这还不算首字母缩略词和歧义。
这就是为什么 snake_case 或 kebab-case(如果语言允许的话)要比 PascalCase 或 camelCase 好得多的原因。
更糟糕的是,当 camelCase 进入 JSON 时,因为人们想让 serde 自动化,却懒得让实际界面(JSON 模式)易于阅读和调试。
> 例如,我正在为一个 USB 设备编写驱动软件。即使是在文档规定的协议范围内,设备也很容易进入不良状态。每当我实现了一个变通方法,或者弄清了设备到底希望如何显示一条信息时,我都会在注释中记录下来。否则,当(不可避免地)代码需要添加功能或重构时,我就会完全忘记为什么要这么写。
我相信一般情况下都有这样做的理由(您的情况听起来就是一个完美的候选)。Dtrace 的实现是另一个例子[0],其中有很好的描述,包括 ASCII 图表(旁白:了解一些 Emacs 的例子(尽管我确信 vim 也有图表功能,如果我从 nvi 中抽身出来足够长的时间去了解,我就会知道))。
[0] https://github.com/opendtrace/opendtrace/blob/master/lib/lib…
> 我完全忘了为什么要这么写。
这就是写注释的主要原因。代码永远无法告诉你 “为什么”。
代码本来就是关于 “是什么 ”和 “怎么做 ”的。为什么 “必须用散文来表达。
所描述的用例–有非常特殊例外情况的 USB 程序–有力地证明了文言文编程,即散文多于代码。
难道所有事情都必须被推到一套结构规定的规则中吗?
难道我们就不能说 “评论在这里是有用的”,而不试图把它变成$方法论的案例吗?
字化编程并不是一套规定结构的规则或方法论。它只是以散文为主,代码为辅。
为什么不把散文放在函数名称中?
函数名称是有限制的。例如,不能在函数名中提供控制内容的电路图。但您可以在注释中提供(ASCII 图像或图片链接)。
除此以外,如果原因发生了变化(可能是外部依赖中的问题最终得到了修补),你就必须更新名称,否则就会导致名称不正确。如果只是在一个代码库中,这只是轻微的恼人,但如果该函数被导出,就会造成不必要的破坏性更改。
同意。那么,为什么不在使用注释之前尽可能多地在名称中添加内容呢?Prose 作为名称看起来很丑,但实用性并没有降低。
这就把 “为什么 ”嵌入了你的应用程序接口。如果它发生了变化,函数就不再是对底层原因的抽象,更改函数名就会破坏你的应用程序接口。
这并不是说在名称中什么都不嵌入。我很喜欢 “长名字就是长篇大论 ”这篇博文[1]:名字需要明确指代被命名的事物所做的事情,并且要足够精确,以排除它不做的事情。名字当然可以太短,例如 C 语言的 “快速冲刺 ”函数 `sprintf` 可能就太短了,不容易理解。
[1] https://journal.stuffwithstuff.com/2016/06/16/long-names-are…
虽然我不是鲍勃叔叔式的 “无注释 ”者,但我确实喜欢可笑的方法名。我会密切关注该方法以及调用该方法的上下文,因为它一定在做什么非常奇怪的事情,才配得上这么长的名字。
正因为如此,你应该只为那些确实在做奇怪事情的方法保留这样的长度。如果每个方法都很长,代码库就会变成噪音。(我同意)
现实中不会出现这种情况。你的程序会做很多事情,实际上,程序中的很多函数都可以使用简短的名称。这就像英语。有大词,也有小词,通常情况下,大词和小词结合使用才能进行交流。
实际上,没有人会用大词交流。只有在需要时,才会出现长长的函数名。
这发生在我们正在讨论的文章中,似乎也是罗伯特所提倡的。
出现在一个人为的例子中。
我以前也是这样工作的,但我发现每个非繁琐的方法都涉及到边缘情况和变通方法,用方法名来记录它们会破坏可读性。
我唯一想说的是,短名称表示没有异常,而长名称表示你必须格外注意。我从不建议用长名称代替注释。以下是我留下评论的 4 个理由: https://max.engineer/reasons-to-leave-comment 评论对于 “为什么 ”和补充背景至关重要。名称不应超出 “是什么 ”的范围。更多内容请点击:https://max.engineer/maintainable-code(查看 “什么 ”部分,重点是命名)。
有一些 Haskell 函数的名称类似于 reallyUnsafePtrEquality# 或 accursedUnutterablePerformIO,你就知道发生了什么有趣的事情 😛
听起来你应该通过在类型中编码和/或添加断言来使这些无效状态无法表示。尤其是当你将它们作为接口公开时,就像你的示例函数名所暗示的那样。
它们是 USB 设备内部的无效状态,而不是驱动程序代码内部的无效状态。因此,你对驱动程序代码所做的任何事情都无法使它们成为无效状态。你所能做的就是避免以有问题的方式 “蛙跳 ”设备。
GP 正在通过编写设备驱动程序使这些无效状态变得不可访问。
我觉得这些名字没什么问题。虽然有点难解析,但名称会随着函数调用移动,而注释不会。
虽然看起来很烦人,但当你真正阅读函数时,就会知道它是做什么的。名称更优雅的函数读起来不那么烦人,但信息量较少,不能提供关键信息。
名字看起来很丑。但就像人们有强迫症一样,非要把东西弄得优雅,而实际上优雅对用户是有害的。除了 “难以解析 ”之外,你能给出一个合理的理由来解释为什么这样的方法名是愚蠢的吗。就像另一位用户说的那样……如果你想让事情更简单,就使用蛇形外壳。
编码方法之间的时间依赖关系(或排除关系)很难。你可以通过使用类似 typestate 模式(在 rust 中很常见)来部分实现。
你有没有想过将你辛苦获得的设备行为信息提炼成一个设备模拟器,以便测试你的代码?
多年来,我曾与一些人共事过,他们在决定 “重构 ”代码时,并没有将函数拆分开来,而是将一些有意义的函数作为一个单元进行重用,或者将一些有逻辑意义的函数作为一个单元进行重用。
早在大学时,我就读过《清洁代码》(Clean Code)这本指定读物,这绝对是鲍勃大叔给我的普遍印象。看到同一缩进级别的任意几行,选中它们,提取方法,模糊地命名它的部分功能,然后重复。
老实说,我认为这就是这种思想流派的产物,即一个函数应该是 X 行,而不是一个函数实现一个函数。现在想想,这有点像 “子程序 ”和 “函数 ”的区别。
在处理他们的代码时,我非常感谢现代集成开发环境的内联功能。我经常对代码进行检查和重组,以便全面了解代码的作用范围,然后再尽可能地还原原始代码,使我的改动尽可能小。
当方法被拆分过多时,我看到的警示信号是方法边界开始变得混乱:方法需要太多参数,或者状态被保存到名称混乱的类成员中,或者你最终返回的结构体包含了一大堆不相关的值。
一直都是这样
我已经很久没有读过这本书了,但我认为它的意思并不是 “在 X 行处剪切函数”,而是 “长函数往往会同时做太多事情”。我认为,如果你能为函数的某些子部分取一个好名字,就说明可以将其提取出来。在这一点上,你应该不需要查看函数的实现,除非是你想修改的特定函数,因为它的名称和参数应该足以让你知道它是做什么的,你不需要去碰它。
我们说的是同一件事,你还觉得难以理解吗?
我强烈推荐《软件设计哲学》。它基本上可以归结为通过抽象所包含的复杂性与接口的复杂性之比来衡量抽象的质量。至少,这是我总结出来的经验法则,而这一启发式方法能让你走得如此之远,实在令人难以置信。我现在经常用这种方法来思考我的软件设计,这对我帮助很大。
在阅读了包括《简洁代码》(Clean Code)在内的其他编程建议书籍后,我并不觉得我的代码变得更好或更易于维护。
第二值得推荐的是《编程珍珠》(Programming Pearls),里面有一些精华。
隐含地说,最佳比例是 5-20:1。你的接口必须涵盖 5-20 种情况,这样才有价值。少了,额外的抽象就是不需要的复杂性。再多的话,你的抽象就可能过于宽泛,难以有用/易懂。他举的例子特别考虑了层次结构中子类的数量。
这就像是领域建模的解锁密码。或者决定函数的长度(5-20 行,有例外情况)。
我同意,这是很平常的原则。
这是一个很好的经验法则,但如果因为 “万一将来出现新的情况怎么办 ”而设置接口,该如何应对呢?
这种情况绝不会像最初预期的那样在未来出现。你最终不得不删除和重构大量代码。抽象只有在少用和不用时才有用,因为它们不需要处理根本不存在的东西。
在进行初始设计时,应从复杂性与抽象预算的中间部分开始。如果你有 100 个 “复杂性单元”(代码行、条件、状态、类、用例等),请尝试找到 10 个细分单元,每个单元 10 个。很少有一次性的。有时,一组中会有 20 多个单元。大多数情况下,你应该有 5-20 组,每组 5-20 个单位。
如果一开始就这样做,那么在抽象变得太脆而需要重构之前,你的抽象还有弯曲的余地。
一个接口几乎不值得只实现 1 个单元,有时实现 3 个单元,通常实现 5-20 个单元,有时实现大于 20 个单元。
诀窍在于识别 “复杂性单位 ”和特定抽象所涵盖的 “单位 ”数量。当然,不同的单位之间可能存在矛盾,你必须做出判断。这不是灵丹妙药。它只是一个有用的(至少对我来说)框架,用于思考如何管理复杂性。
如果你拥有代码库,那就重构。诚然,如果你要为用户提供稳定的界面,而你又无法编辑他们的代码,那么你就需要仔细规划向后兼容性。
“我们会在需要的时候提取接口–当我们知道需求是什么的时候,我们就更有能力设计出符合需求的接口。现在提取接口还为时过早,除非我们真的没有其他功能工作要做?
也许一些例子可以澄清你的意图,因为我能想到的所有候选解释都是荒谬的。
C 标准库中的 sin() 函数涵盖了 2⁶⁴ 的情况,因为它只需要一个参数,而在大多数平台上,这个参数都是 64 位的。你的意思是要把它分成 2⁶⁰ 个独立的函数吗?
如果你是说应该通过布尔和枚举参数来告诉子程序或类,调用者需要 5-20 种用例中的哪一种?我不敢苟同。让它们成为独立的子程序或类。
如果一个子程序中有 5-20 行代码,但没有条件或可能的零迭代循环,那么这些代码都是相同的情况。子程序在某些情况下不运行其中的某些行,而在其他情况下运行其他行。
该函数涉及 2⁶⁴ 输入,而不是情况。它只处理一种情况:将角度值转换为(半个)笛卡尔坐标。
听起来你还没有尝试过实现它。但如果你所想的 “情况 ”就是 narnarpapadaddy 所指的 “情况”,那我们就会想到他们的那句话:“任何更少的 [情况],额外的抽象都是不需要的复杂性”。当我们谈论 sin() 函数时,这显然是荒谬的。因此,这不可能是他们的本意。
当然,另一种更宽容的解释是,像 sin() 这样的单个函数并不是 GP 使用 “接口 ”一词的本意。不过,别让我打断你的 “稻草人 ”游戏,你做得很好。
把它想象成 “复杂性分布 ”更合适。
只有一行代码的函数、只有一个元素的接口、只有一个父类和子类的类层次结构,都是非常有用的。大多数情况下,这种抽象都是开销。
通常情况下,一个有 5-20 行的函数,或一个有 5-20 个成员的接口,或一个有 5-20 个子类的类层次结构,都是有用的抽象。这就是太宽泛(函数 “doStuff”)和太狭窄(函数 “callMomOnTheLandLine”)之间的最佳平衡点。
有时,上述任何复杂度比大于 20:1 的抽象都是有用的。
这并不是一条硬性规定。如果你的复杂度比不在这个范围内,请三思你的抽象。
关于函数行为,我会从循环复杂性的角度来看待。
我是否需要 5-20 个非繁琐的测试用例来覆盖该函数接受的输入范围?
如果需要,那么函数的行为复杂度可能已经达到了增加价值而非开销的适当水平。
如果我只需要 1 个测试,或者需要 200 个测试,那么它可能做得太多或太少了。
循环复杂度并不是这样的,如果你认为 5-20 个测试用例就足以满足 sin()、open() 或 Lisp EVAL 的要求,那你就需要检查一下你的脑袋了。
你说得对,我提出了复杂性的两个不同维度,以此来透视一个函数包含多少复杂性。但我认为这个原则对任何一个维度都适用。
我不认为 open() 只需要 20 个测试用例。有时,多于 20 个测试用例也是有效的,因为你需要跨越其他复杂性维度。这种情况时有发生,我对此没有异议。
但事实上,你需要大于 20 个测试用例,这就提出了一个问题:open() 是一个好的应用程序接口吗?
我并没有对 open() 做任何特别的评判,但什么才是好的文件 API 却存在很大争议。因此,对我来说,这个例子验证了我的原则:这是一个行为复杂且存在争议的应用程序接口。这正是我所建议的。
这样说有帮助吗?
是的,open() 是一个很好的应用程序接口。真不敢相信你会问这个问题!它已接近柏拉图理想中的优秀 API;并不是说它不能设计得更好,但软件世界中几乎没有任何接口能以如此低的接口复杂度提供如此多的功能,或为如此多不同的调用者或如此多不同的调用者提供服务。也许 TCP/IP、HTTP、JSON 和 SQL 可以在其中的某些方面与之抗衡,但在其他方面却不尽人意。
不,20 个测试用例对 open() 来说是不够的。还差得远呢。在 Linux man 页面中,open() 列出了 36 个错误案例。
什么是好的文件应用程序接口并无争议。例如,CP/M 和 MS-DOS 1.0 中基于 FCB 的记录 I/O、TOPS-20 基于 JFN 的接口、OS/370 对数据集的各种访问方法,都与 open() 截然不同,也互不相同。大约从 35 年前开始,每个新系统都只是复制 Unix API,并略有不同。例如,有时它们不使用位标志,或者它们的 open() 通过额外的返回值或异常而不是无效文件描述符报告错误。有时,它们使用不透明的文件描述符对象,而不是使用整数。有时,文件名语法允许使用驱动器字母、流标识符或变量。但是,没有什么看起来像 Guardian、CP/M、Multics 或 VAX/VMS RMS 的 I/O API,这是有道理的。
我在 “简洁代码运动 ”之前就在这一行了,和所有软件运动一样,“简洁代码运动 ”是对软件行业实际问题的一种反应。大量的过程函数,深层嵌套的条件,没有结构,全局变量,根本没有测试。这些都是当时的常态。
清洁代码》将事情推向了一个更好的方向,但它矫枉过正了。在很多方面,《APOSD》(2018 年出版)都是对《Clean Code》(2008 年出版)过度的修正。
人们是否会过度向后摇摆,转向巨型方法、深度嵌套条件等?我不知道。但很有可能。
我相信,有一种真正的生理效应,让你需要思考的代码区域完全在一个屏幕上,而无需滚动,这是个好主意。屏幕高度可能会有一个上限,而这个上限是有用的: 我认为一个 100 行的函数会在它之上,而一个 24 行的函数会安全地在它之下,但我不想冒险猜测中间的高度。
这完全取决于你的大脑如何处理所看到的内容,以及在获取下一个所需信息时所涉及的规划过程。如果该信息不在屏幕上,那么大脑就会启动存储当前状态的机制,并计划以任何必要的方式移动你的双手,以便将其呈现在屏幕上,这就是一种不流畅。
同样,如果令牌离你当前关注的东西太远,也会出现同样的情况。在你当前焦点的周围可能会有一个区域(也可能是若干个代币),在这个区域内,你的大脑可以准确地让你的眼睛进行扫描,而在这个区域之外,就会出现寻找不流畅的现象。
我想这就是为什么会出现 k 和 j 这样奇怪的边缘情况,他们以在一个 80×24 的缓冲区中拥有所有代码而自豪,尽管他们违反了所有关于代码可读性的规则,但这对他们来说确实有效。
你要找的术语是认知负荷。这是一个定性的术语,用来表示一个人在完成一项任务时,需要保存在工作记忆中的信息量。
我认为极端的学习方法是有用的,只是不要把任何一种范式当作福音。实践 “简洁代码 ”会迫使你以一种特殊的方式思考问题,当你尝试过之后,你就会开始感觉到应该在哪里划清界限。实践 “清洁代码 ”会让你成为更好的程序员,但你必须自己找出其中的利弊得失。
其他例子还有 TDD。在一段时间内强迫自己为所有事情都写测试,这让我后来的代码都变得更好了,尽管我现在并没有实践 TDD。
我也有同感,测试的好处在于它迫使你编写可以被测试的代码,这往往会让代码变得更好。
如果嵌套不多,大函数也不是那么糟糕。
你只需向下滚动,就能看到以线性方式发生了什么。
这是 Linux 内核采用的方法,也是内核使用 8 空格制表符的主要原因。一般来说,这种方法对了解正在发生的事情非常有效。我对 200 行的带错误处理的直行函数很满意,而由 10 个 20 行的函数组成的 “if-else ”就很难读了。后者才是 “干净的代码”。
正如本-富兰克林(Ben Franklin)所写:“最好的医生知道大多数药物的价值”。
鲍勃对……评论……的评论是如此怪异,以至于我不禁认为他只是拒绝承认这一点,而不是承认他可能错了。就像,对错误/过时的注释的偏执是相当荒谬的,我已经在许多代码库中编码了 20 年,我甚至想不起来有哪次我被注释严重误导,从而浪费了大量时间。然而,我在没有注释的不清晰代码上浪费的时间绝对是惊人的。然而,真正让我觉得奇怪的是,他认为这是一条好注释:
这是逐字记录,我并没有不公平地删减上下文。比如,这到底要告诉别人什么?给个算法链接,或者简单解释一下算法,或者直接给出算法名称,让别人去查,不是更简单吗?相反,他只是说要骑自行车去理解它,还画了一幅奇怪的图。他还有一些奇怪的论点:如果某种东西无法用编程语言表达,那就是编程语言的错(什么?我真的很难相信他认为这些都是很好的论据,我只是觉得他不想承认他在这方面是错的。
> 太离奇了……这到底是要告诉别人什么?
我喜欢这个奇怪的评论。就像看到了三角函数的物理几何证明。或者就像在骑一个小时自行车的过程中思考素数的问题。
在 10 – 15 秒钟的时间里,这条评论让我对素数有了一瞬间的应用直觉,这是我以前所没有领会到的。
当然,这其中的大部分时间都是我首先发现最上面的三行数字是一个侧向的数列(旋转打印就能立刻发现)。明文的乐趣。
但后来,这个模式突然出现了,代码,包括优化,都变得有意义了,但现在是从 “摸索 ”开始的,用的是 MTOWTDI。
无论是他们的注释还是函数名和注释,都没有引起我的 “摸索”。我可以 “接受 ”这些断言,但对我来说,无论是命名还是注释,都不像图表那样直观不言自明。
他们两人都评论说,要考虑重构,就必须深入了解代码在做什么。一旦发生了这种闪烁,就根本不需要参考代码了,它只是素数的另一种属性。
> 我并没有不公平地将上下文割裂开来
是的,你没有附上这个注释的周围代码。即使只有函数名,这条注释也会更有意义。
如果你需要代码来理解注释,那么注释就是失败的。我想我的意思是,“注释 ”没有任何额外的内容,你必须阅读代码才能知道注释的意思。
代码是用来解释注释的。
在这一点上我和你的观点基本一致,但我始终意识到注释有可能是 bug。如果代码被移动了,那么注释就很有可能被附加到错误的方法或代码行上。
我现在会在方法或函数的基础上进行注释,描述方法的作用。至于 “如何”,应该在正文中就能看出来。
他似乎并不经常承认自己错了。
笑话,这让我想起了我们在航天器上的那些雕刻。就像,如果我们必须把算法传达给外星文明,那么当然,这可能是最好的方法!
https://en.wikipedia.org/wiki/Pioneer_plaque
从最初的评论开始,到逐字逐句的 “好评论 ”出现,再到航天器上的雕刻,这样的并置让我笑中带泪。
尤其是从对评论 UB 的理解有多么离奇,到真正看到 “野生 ”的评论 UB 的过程。
一旦你注意到三进制在最上面几排,它就变成了一个相当不错的评论。
刚刚知道 9、15、21……都是素数。非常棒的评论 ^^.
好吧,从 “技术 ”上讲,最上面一排里是有素数的_______________。
只是不仅仅是素数 ^^
这两本书我都很喜欢,但鲍勃叔叔是个让人难以忘怀的人。他当时是个有点邪教的人物。尝试真正遵循《清洁代码》中的指导原则让我了解了很多关于 “过度分解 ”的知识,并最终学会了如何不写代码。它提醒我,美学有可能走得太远,结果变得丑陋不堪。对大量只做一件事的小函数大惊小怪是一种疯狂。每个单独的函数最终只会做零件事。你只能在程序的灰烬中寻找 “我哪里做错了”。
在元层面上,这些交流虽然略显有趣,但却有一种争论针尖上能有几个天使跳舞的气氛。我想起了一句老话: “写音乐就像跳建筑舞” 如果你想写好代码,那就读好代码。培养自己的品味。我想我再也不会读关于代码组成的书了。
> 你只能在程序的灰烬中寻找 “我哪里做错了?”
比喻用词精妙,谢谢。
> 如果你想写出好代码,那就读好代码吧。
作为一名在小公司工作的初级开发人员,我经常依靠这个社区的指导,而这似乎是本主题中最合理的建议。
你需要知道什么是好代码。不同的程序员,甚至是资深程序员,可能会有很多不同的看法。Clean Code 邪教会告诉你在那里找到好代码,但那是我读过的最毒的编程书籍。
忘掉代码本身,关注结果。
我的意思是 好代码是在一个长期项目中默默存活下来的代码,而这个项目在新工程师(无论经验丰富与否)加入的许多周期中都发生了很大变化。你越少听到人们抱怨它,但你发现越多的人在使用或依赖它,代码就越好。如果人们大肆宣扬他们有多喜欢它,那么它要么是新的,要么是他们说服自己喜欢但心里知道很糟糕的东西。能用的东西才是好东西–好到人们根本不会注意到。
不,因为你读到的旧 C 代码都是 IFDEF 迷宫,并认为那就是好代码。不,要想看到好代码,你通常得看看有经验的人在开发新东西时写的代码。
所以你认为难看的代码就是坏代码?还是说它使用的功能很低级/过时?如果不是它的生命周期价值,那什么才是好代码?
你肯定不会只根据写代码的人推测代码的质量吧?
好的代码是在它解决问题的程度与它的易维护性之间权衡的结果。随着经验的积累,人们会学习如何写出更好的代码,但很少有人会利用学到的知识重写旧项目–从头开始往往更容易。
你必须阅读大量不同的代码。每个人在写代码时都认为自己的代码很好。丑陋的旧代码背后往往隐藏着与原始设计不符的许多需求变更。其他代码看起来漂亮,但却经不起需求变更的考验,甚至比其他代码更糟糕。
> 忘记代码本身,关注结果。
这让我想起了 Dijkstra 说过的话(意译):计算才是最重要的,而不是代码。
并非如此,长期存在的项目并不会随着经验的积累而调整其完整的代码库,就像 Linux 内核可能永远不会用 Rust 重写,C++ 项目永远不会转变为 C++14+ 等等。
这里值得注意的是,代码库中哪些部分不需要根据经验进行调整。这是关键所在。如果人们不修改代码,说明他们不需要修改,这是一个有用的信号。
反之,如果你询问代码库的所有开发人员,他们会立即告诉你代码库中流失率最高的部分是什么。这在我的许多项目中都非常奏效。
这也可能意味着 “我们没有修改这个,因为我们不敢那么做,或者工作量太大,我们只能忍受 25 年前的错误决定”。这是你最不想复制的代码。
有趣的是,我们只是认为新语言能产生 “更好的代码”。
有很多各种语言的强大程序可供学习。孤立地了解这些程序是很难的,但相比之下,了解好代码的样子则更容易。有些代码流畅易读。而另一些代码则很难理解。先从不涉及大量底层调用的简单项目开始。然后再学习更复杂的实现。有 LLM 作为导师,阅读代码的最佳时机莫过于现在。如果您使用 AI 集成编辑器或代码打包器,您可以为 LLM 提供大量上下文。向 LLM 询问重要的模块,并逐步完成它们。向 LLM 询问函数的其他实现方法或翻译成其他语言的方法。在运行环境中安装一个程序,并使用调试器走一遍。查看代码在文件和模块中的组织方式。你将不可避免地遇到一些杂乱无章的 “坏代码”。有时这也是有原因的。如果你喜欢看书,《开源应用程序架构》(Architectures of Open Source Applications,AOSA)一书也很有趣,但确实没有办法避免下载 repo 并阅读代码。很快,你就会形成自己的品味,知道什么对你有意义,并能独立思考开发者做出的选择。
虽然有点遗憾,但我认为随着法律硕士的出现,过去程序员的一些风格怪癖将变得有点不合时宜。不过,它们仍将为代码考古提供机会。
同意。
其他一些启发式方法
* 每个 if 语句都有可能出现 bug,因为代码有两条或两条以上的路径可循。将选择保留在代码的业务/需求层,而不是隐藏在较低层次的分解中。
* 开关语句如果不是详尽无遗的(即涵盖所有可能的值),就有可能出现错误,尤其是在没有默认情况下。
拥有更好类型系统的现代语言与第二点关系不大,因为它们要求穷举模式匹配。
每个 if 语句都有可能出现错误,因为代码有两条或两条以上的路径可循。
这就是程序的循环复杂度:https://en.wikipedia.org/wiki/Cyclomatic_complexity
由此推论,尽快收敛不同的路径(例如使用非空类型和默认值)或将它们全部收敛到同一位置(例如非本地异常处理)也是有益的。
我经常将其缩写为 “心身复杂性”,因为更复杂的代码可能会让程序员感到头疼。
>每个单独的函数最终做的事情都是零。
基本上就是 Lambda 微积分)
我已经很多年没读过这本书了,但我很惊讶这里有这么多对它的憎恨。在我的记忆中,它似乎是相当无害的东西,比如给东西起个好名字,尽量让代码可读,不要注释代码做了什么,而是注释为什么,使用一致的格式,避免重复。
除了人们过多地使用空类和继承外,我还真没见过人们过多地分解函数的问题。
哪些部分是需要发展的重要部分?
https://qntm.org/clean 网站对 “简洁代码 ”的缺点有很好的概述。
我对清洁代码的不满之一在于它的名字。
代码清洁度并没有一个客观的衡量标准。因此,如果 “代码整洁 ”是你的目标,那么你就没有有意义的标准来评估替代方案。(包括 Bob Martin 提出的那些标准)。
不过,情况还会更糟。还有一种潜意识因素会带来更大的麻烦。编写 “干净的代码 ”显然是件好事,对吗?(谁会反驳呢?)否则就是道德败坏。
鲍勃大叔 “试图建立的基础从一开始就腐朽不堪。但它却是教条主义的完美配方。
老实说,这让我觉得 “干净 ”这个词很合适。我不能说衡量我家的清洁程度是客观的。
我是有偏见的(我以前的一个同事是鲍勃大叔的粉丝,他一心只想按部就班地做每一件事,层层抽象、模式化、六边形架构、大量单元测试、不走弯路,即使我们不知道我们到底要构建什么,并且需要尽快获得 MVP),但我只想说这一点: 奥斯特豪特是斯坦福大学的教授,还在软件方面取得了其他成就,除此之外,他还编写了 TCL(被公认为最好的 C 代码库之一),而罗伯特-马丁则更像是一位软件技术布道者。前者擅长实际交付,后者擅长推销。
另外,Ousterhout 关于设计的书非常容易读,我想我喜欢这本书是因为我在阅读时大多只是点头赞同,很少有让我停下来的地方。
你对你前同事的方法有偏见,因此采用了 “清洁代码 ”方式?我想这并没有取得很好的效果,因为在尝试正确的方法之前,你需要快速建立一个 MVP?
在项目的任何阶段,遵循 “清洁代码 ”都不是开发软件的正确方法。这是一个没有真正编写过任何实质性代码的人的一些观点。除了 Clean Code 是一种糟糕的方法之外,它还可能是一个非常缓慢的过程。
是的,有偏见,反对知道 “编写软件的最佳方法 ”并不考虑当前的需求和限制而加以应用。他们通过向人们发送鲍勃大叔的视频链接来 “启迪 ”人们,从而为自己的立场辩护。
别忘了,鲍勃大叔在撰写 “清洁代码 ”时已经拥有 40 年的编码经验。
不要犯工匠的错误,他自称有 20 年的经验,但实际上只有重复 20 次的 1 年经验。
肯尼斯-科普兰(Kenneth Copeland)已经做了 50 年的牧师,但他的神学和教牧实践仍然很糟糕。经验年限并不是一个有用的衡量标准,而应该看结果。
我的中学英语老师有四十年的写作经验。她写的是教案。这并不能说明她就是斯蒂芬-金。
软件是以结果为导向的,仅仅为 X YOE 暖座、谈论代码而不是实际执行是没有价值的。
他都运了些什么?
他是否编写了我们所知道的大型代码库?
我发现在这类讨论和书籍中,缺乏对类型系统的讨论确实令人惊讶。对我来说,有效使用类型系统是创建简洁、安全、可读性和可维护性软件设计的杀手锏。
如果使用得当,强大的静态类型检查功能可以使某些类型的错误变得不可能,使你不必再编写许多乏味的测试(这些测试往往会妨碍重构),还可以作为文档使用,并使重构/维护的速度和安全性提高一个数量级。即使在没有类型检查器的情况下,避免动态行为通常也是更安全的做法,因此学习如何以这种方式思考仍然是有益的。
像函数应该有多大、给变量取什么名字,甚至在编码之前/之后是否要写测试……这些次要的话题就像试图为如何写作文、创作平面设计或如何烹饪提出一般规则一样。“这取决于 “具体情况,而且每次都要兼顾不同的优先事项。这种事情只有通过实践才能正确掌握 (https://en.wikipedia.org/wiki/Tacit_knowledge),因此,在确定了要始终做和始终避免的简单事情之后,再去阅读或讨论它,就只会有很多收获。
因为在《软件设计哲学》问世时,类型学的钟摆还没有摆回到静态流行的时候。当时,Scala 和 Haskell 的大多数人都站在角落里大喊大叫,直到他们(好吧,我们,因为我也是其中一员)的脸都青了,他们还在讨论如何减少 “某些类型的 bug”,以及如何让不可能的状态变得不可能。
从那以后,每个人和他们的兄弟都坐上了静态类型的列车。从这个角度看,你是对的。这似乎是个疏漏。再过 10 年,人们可能会有相反的想法。
这正是 Ousterhout 教授在开设这门课时所采取的方法,而这门课也正是这本书的诞生地–不是让学生仅仅为了分数而上交工作代码,而是与学生一起审查代码,然后学生再努力使代码变得更好–反过来,这本书的第 2 版也借鉴了这门课的教学经验,作者实际上也根据所获得的经验改变了自己的立场。
这有什么说服力?学生不是经验丰富的程序员,不是在大型团队中工作,学生的作业也不像长期的大型商业项目。
如果你指的是这里的补充 https://web.stanford.edu/~ouster/cgi-bin/book.php,我读过这些内容,但听起来仍像是经验之谈,你只有通过大量实践才能真正学习和理解,例如:“根据我的经验,好的地方是以某种通用方式实现新模块”“拥有良好的品味是成为优秀软件设计师的重要组成部分”。
与大多数其他编程书籍相比,它具有更好的证书和经验基础。
此外,正是由于学生缺乏经验,这本书才具有可信度–因为学生会犯错误,会做出错误的架构/设计选择,这就为他们提供了改正的机会。
我认为,作者从 “模块应该是专用的 ”转为 “模块应该是通用的”(粗略转述,我的那本已邮寄到巴西,正等着买新的),这一点很了不起。
如果您知道有其他值得推荐的书籍,而且其作者和论述的背景相似或更好,我将很高兴听到它们。
> 与其他大多数编程书籍相比,它具有更好的资历和经验基础。
在考虑编码建议时,我觉得证书或编码实验的结果并不重要,尤其是在涉及学生的情况下。我在脑海中反复推敲各种情况,然后根据上下文和相互竞争的优先级选择合理的方案。
> 我认为,作者从 “模块应专业化 ”转为 “模块应通用化”(粗略转述,我的书寄到巴西了,正等着买新书),这很了不起。
我发布的链接上有 PDF 格式的内容。我想我不觉得涉及几个函数签名的有限示例(学生的作业是编写一个基本的文本编辑器)有说服力,也不理解为什么我需要选择模块是趋向于专用还是通用……你是根据具体情况处理,根据上下文选择好处最多的一种,并在以后有意义时愿意改变?除了几条规则之外,“视情况而定 ”确实是大多数争论的答案,但这也很无趣。类似的还有函数的长度、注释的书写以及变量的命名。
> 如果你还知道其他值得推荐的书籍,而且它们的作者和论述背景相似或更好,我很乐意听听你的意见。
谷歌的软件工程》可能与大型项目相关: https://abseil.io/resources/swe-book
其余的可能最好从实践中来,我在这里学到的大部分知识都是这样得来的。例如,我并不是因为基于证书或实验的建议而成为强静态类型的拥护者,而是从使用和不使用类型的编码经验中获得的。
谢谢。
一眼望去,这本书似乎与 APoSD 非常吻合–你认为哪些方面与 APoSD 相悖?
我发现这两本书并不那么相似。谷歌的那本书更多的是关于在团队中扩展和维护软件的开发过程的实用技巧,而不是专注于代码。
好的。
你会推荐哪本书的内容与《APoSD》相似?
类型系统和基于类型的编码模式现在非常流行,但 6 年前还不是这样。部分原因是 6 年前使用的主要语言中的类型系统都是黑客之作(不客气地说)。
我预计钟摆很快就会摆到反对类型系统的位置,原因与反对 OOP 的原因相同:太多繁重的工作由程序员不知道的东西来完成,鼓励人们 “太聪明”,等等。与 OOP 一样,代数类型也是一种必须善加利用的工具,目前的用户都是非常喜欢类型系统并善加利用的人。这种工具迟早会落入普通程序员的手中,到那时我们就会看到,一个优秀的类型系统会给你带来多么可怕的伤害。
这本书读起来很有趣。几个月前,我第一次阅读 APoSD,读着读着,我发现自己不禁踊跃点头。当然,我也有一些疑问,但总的来说,它符合我在如何编写正确、可维护、可扩展和可理解的软件方面的经验。
我从未读过 CC,但我读过一些摘录[1]。我曾担心这些反驳是在攻击一个稻草人,但没有,鲍勃叔叔相信这些东西,包括评论是邪恶的,你只需要阅读所有代码并将其记在脑子里。
即使这是真的,我写的代码也会因为写了注释(尤其是接口注释)而变得更好,因为这些注释有助于我的思考。此外,它还能帮助我的代码审查员–在没有编写接口的情况下。如果你只有代码,而没有关于代码应该做什么的说明,你怎么能知道代码是否正确呢?我认为大多数代码审查员都是根据他们推断的接口来验证代码的。明确说明对我们双方都有帮助。
[1]: https://qntm.org/clean
鲍勃大叔坚持认为函数应该只有 2-4 行,这让我很费解。我不明白他怎么会当真。全世界有哪一个具有实质性功能的应用程序符合这一规则?
我曾在所谓的 “千层面代码 ”中看到过这种情况–多个薄层看似什么也没做(或几乎什么也没做),但每一层都是对原始开发者头脑中某些深奥接口的实现。
最终,你的代码必须有所作为。把这些东西放在一个地方,你就能看到它的全貌。
是的,这个方法可行,但前提是函数是纯粹的,使用的是纯粹的函数组合。
鲍勃叔叔没有提到这一点。
看到了吗?在纯函数式构成中,点代表函数构成,这在大多数情况下是可行的。我经常这样编程。任何人都不可能在改变状态和实例化对象时做到这一点。
请原谅我用手机打字时格式和命名的不一致。
抱怨这种风格的人往往不熟悉这种风格。如果你了解过程式编码风格和这样的函数组成方法,那么通常这种风格会更容易,因为高级函数读起来就像英语。你甚至不需要看定义,就已经知道这个复杂的字符串格式化函数是做什么的了。
无需注释。两位作者都没有告诉你这种超级模块化的方法。他们都没有提到最关键的一点,即这种风格要求函数是纯粹的。
因此,要让你的大部分代码都遵循这种极具模块化和可读性的方法……你的大部分代码都必须尽量减少 IO 和状态变化,并尽可能将其隔离开来。
Haskell 类型系统、IO monad 正推动程序员朝这个方向努力。
同样,两位作者都没有谈到这一点。
从马丁的博客来看,他近年来一直在研究 Clojure。我有点希望使用函数式 lisp 的经历会改变他之前在《Clean Code》中坚持的一些观点,但根据这次讨论,似乎并没有。
createspecialString “长达七行。
那就拆分它。所有纯函数都很容易分解成最原始的单元,所以即使是最教条的屁眼也不能胡说八道。
或者将其全部放在一行中。
一旦进入这种风格,行的数量就会偏离主题。这完全是一个矫枉过正的概念,因为它与可读性和模块性完全无关。
对于纯粹的非命令式函数来说,行数毫无意义。行只对命令式函数有意义,因为每一行代表一条指令。
那么,纯函数式组合的最有趣之处在于能够解除(去)组合,内联函数体,直到生成的函数足够大,值得花精力去阅读它。
我经常看到一些空壳函数对参数进行重组,然后传递给另一个函数,后者又对参数进行重组,然后再传递给另一个函数,如此循环往复。其中一个就有 11 层之深。
我也见过这样的代码–只有 S、K 和 I 组合器。无法阅读。
很多人都不知道洗参数有多危险,尤其是在没有命名参数的语言中…
Powershell 是一种糟糕的语言,但它让我爱上了命名参数。命名所有的东西。
如果你有一个 2-4 行的函数,并发现了这一点,你可以很容易地删除它。
有。很多 Java 代码库都是这样的。
它和你想象的一样糟糕。功能分散在各个地方,因此很难推理出它们是如何组合在一起的。
有一次,我为了将一些代码转换为 golang 代码,对 Java 的调用堆栈进行了全面的深入研究。这太神奇了,在一些代码上有差不多 5 层的间接,而这些代码实际上做了我想要做的事情,但我必须完全跟踪它,并牢记整个调用栈的参数,才能弄明白这一点,因为其中几层间接有可能做更多的工作,依赖关系图也要复杂得多。最后,我得到了一个包含两个函数的 go 文件(一个重现了实际有趣的 Java 代码,另一个则按照所有间接层的调用方式进行调用。它不到 100 行,而且更容易理解。
在 412 个堆栈框架中穿行,找出你感兴趣的事情究竟发生在哪里,而这些堆栈框架都是两行长的方法,这总是很有趣的。
再加上 Go 和 C++ 代码库。
是的,我曾在几个这样的代码库中工作过。这非常棒,你可以把所有事情一点一点分解,每一步都很合理,而且可以单独测试。这是我做过的最好的工作。
但这些步骤是否真的在做任何可以测试的事情呢?我在这类代码库中的经验是,大多数函数除了调用其他函数外,并没有做什么其他事情,因此测试这些函数的结果要么是在多个地方测试完全相同的行为,要么是进行大量模拟,使测试变得毫无意义。
更糟糕的是,我见过有人将函数拆分开来,以至于你现在需要在函数调用之间维护某种类级状态,才能获得正确的行为。这几乎不可能进行有意义的测试,因为可能的状态和状态之间的顺序非常复杂–你可能会正确地测试个别情况,但这种系统永远不可能涵盖所有可能的行为。
> 在多个地方测试完全相同的行为
我认为这其实很好。特别是如果你采用金字塔式的测试方法,对某些低级逻辑进行大量测试,然后对使用低级逻辑的高级逻辑进行少量测试,我认为高级测试覆盖低级测试中也使用的代码路径没有任何问题。如果说有什么问题的话,我觉得这样更容易理解和调试故障–你知道低级部分的行为没有改变,否则低级测试就会失败,所以错误只能出现在高级组件本身。
这些大型复合语句如果完美无缺,看起来会很不错,但当你制作没有中间变量的巨型表达式时,测试就会变得更加困难。
当你使用在变量中存储了增量结果的小型表达式时,你可以在调试器中看到结果,从而可以看到每个阶段。
这有点奇怪。
不过,我有一位朋友是专业的 Smalltalk 程序员。他声称,在他17年的职业生涯中,方法的行数中位数是4。
这在其他语言中更难做到–C 语言似乎要达到 10 行左右。
显然,这条规则可能会导致过多方法的复杂性,从而影响较小方法所带来的收益。
> 方法行数中位数
为每个实例变量自动生成获取器和设置器,会拖累平均值。(也许很多 getter 和 setter 本来就不应该存在)。
不属于 smalltalk。
约翰-卡马克(John Carmack)不同意鲍勃叔叔的观点,而且约翰-卡马克实际上也会编程。
我自己的经验是,如果集成开发环境能够在函数中间折叠一个新的作用域,那么只需编写一个注释并启动一个新的作用域,就能制作出功能强大且非常清晰的大型函数。
如果一个函数会被多次调用,那么新建一个函数是有意义的,但这种认为任何最终只返回一个值的东西都必须是一个独立函数的想法是一种巨大的痛苦,它带来的问题比它解决的问题还多。
只要在函数中间新建一个作用域,就可以使用外层作用域中的所有变量,在不引入新变量的情况下进行转换,并最终将一个新变量 “返回 ”到外层作用域。
我一直不明白,为什么用几十个或几百个名称(其中大部分可能都没有很好的描述性,因为命名一百个小东西就已经很混乱了)来污染命名空间会被认为是一个好主意。你看着一个列表,根本不知道什么是重要的,什么是为了满足某些无所事事的公众演讲者的任意规则而被塞进去的。
折叠的问题在于,您需要事先知道哪些子作用域是独立的,因此可以折叠,哪些不可以。也就是说,您必须先分析不熟悉的代码,才能知道哪些子作用域可能需要折叠。而且,对于已经折叠的子作用域,由于它是折叠的,所以你无法看到它是否读取或更改了某个局部变量。抽取函数的好处是,你可以知道它们与调用者的实现细节无关(也就是说,除了作为参数传递的变量外,它们不可能依赖或修改调用者的局部变量)。
过多的参数会造成问题。嵌套函数在这方面可以提供帮助,因为它们允许你在访问共享变量的同时 “避开 ”其实现。有时,参数集合可以合理地成为自己的类。
集成开发环境的功能固然很好,但我反对要求在阅读和理解代码时依赖它们,而不是在编写代码时。
你需要先验地知道哪些子作用域是独立的,因此是可折叠的,哪些不是。
独立是什么意思?我会将它们全部折叠起来,因为它们本来就应该折叠起来。
你必须先分析不熟悉的代码,才能知道哪些子作用域可能需要折叠
不熟悉?我并没有重构,而是全部折叠了。
提取函数的好处在于,你知道它们与调用者的实现细节无关(也就是说,除了作为参数传递的变量外,它们不可能依赖或修改调用者的局部变量)。
这在某种程度上是对的,但这更像是一种让单体函数变得简单的方法,而单体函数又会让程序变得更简单,因为你可以避免使用大量微小的函数。这样一来,程序就能做一些非琐碎的事情,但又不会有大量的函数来混淆视听。
实际上,这并不是一个真正的问题。它并不是 1:1 的完全替代 “并不是问题的关键。如果你真的想要,你仍然可以在注释和常量引用中加入注释和常量引用。
集成开发环境的功能很好,但我反对要求在阅读和理解代码时依赖它们,而不是在编写代码时。
为什么要这样做?另一种选择是,你仍然有这些注释部分,它们有自己的作用域,但不会被折叠。你可以随时在没有集成开发环境的情况下工作,而当你回到集成开发环境时,它仍然可以工作。
现实情况是,你可以看到一个函数的大致概览,然后逐个部分查看细节,甚至不必跳转到文件的其他部分或其他文件中去查看。
代码行数/方法(Digitalk V/286 图像,1989 年)= 7
https://dl.acm.org/doi/10.1145/74878.74904
就像他的很多方法一样,在教育环境中,这种方法非常适合教授人们更好的编码技能,但在现实世界中却没有太大意义。
这一点读起来比理解起来容易。很多语言都强迫你在一个函数中做很多事情,而盲目臃肿是很容易做到的(C++/Go/Java)。(C++/Go/Java/etc 是的)。
他在文章中举了一个例子
void concurrentOperation() { lock() criticalSection(); unlock() }
因此,如果将 criticalSection 替换为大量操作,如打开文件、读取行、查找某些内容、关闭文件。我想你就能更好地表达一个过于臃肿的函数了。
Scala 有语言支持来展示这种情况。
在该方法中,你所做的是开始一个关键部分,做一些事情,然后结束一个关键部分。建议用以下方法来打破这种状态:
def criticalSection(f: () => Unit) { lock() f() unlock() }
这样你就有了一个只做一件事的方法,而且很容易理解。而且还可以重复使用。
原始代码可用作
criticalSection { _ => doSomething() }
现在,这种替换不再依赖于锁定。锁定是分层的。
我很惊讶 Ousterhout 没有指出 PrimeGenerator3 重构带来的巨大问题: 它将状态存储在静态(!)字段中,因此除非添加全局锁,否则在有线程的情况下完全无法使用。
即使鲍勃大叔认为微小方法很好,但如果他需要状态,为什么还要引入伪构造函数 “initializeTheGenerator”,并把所有东西都变成静态的呢?如果辅助方法是实例方法,静态 “generateFirstNPrimes ”方法就可以简单地构造一个新实例来存储状态。
我目前正在处理一个代码库,它代表了盲目遵循 “清洁代码 ”等的后果。
我的经验告诉我,你永远不要成为第一个建议重写的人。因为我只是这个项目的承包商,所以我强烈倾向于让它自己解决。那些毫无意义的数据访问层封装似乎蕴含着很多自我。我现在可不想跟别人过不去。这个市场相当稀缺。
重写只有在你得到其他东西时才有用。在一个项目中混合使用 Rust 和 C++ 是很难的,但也是可行的–如果你尝试一下,你会发现有足够多的 “摩擦”,最终值得重写以摆脱其中一个。
在这段文字中,马丁似乎与软件工程的现实情况格格不入,这一点让我很吃惊。风格化的重构会导致性能倒退,三行方法的方法名又长又拗口,对注释几乎完全敌视……不管谁对谁错,这些都像是最糟糕的诡辩极端主义,而不是可以应用于大型软件开发的理性实用主义。
当我遇到鲍勃大叔的信徒时,我几乎总是对他们纯粹僵化的方法感到失望:一切都必须这样简化,变成这样的片段,因为这是客观上更好的软件设计,仅此而已。当然,标准的默认方法和最佳实践是需要牢记的重要事项,但喜欢马丁的人所表现出的教条程度实在令人震惊和担忧。
我担心他的方法会让某类程序员专注于……美学的、教条式的统一性(以及相关的非生产性,即主要出于美学动机的、教条式的修改,而不是增强功能、修复错误,或者项目中其他编码员一致认为可以提高可维护性的修改),而不是提高他们的技能和对工艺的熟悉程度。
可维护性/适当的因子是主观的品质,在很大程度上取决于项目、项目中的其他程序员,以及在该环境中对软件工程的期望。
如果假装事实并非如此,认为统一的 “一种简洁代码风格统治一切 ”是一种可行的方法,那就会对每个相关人员造成伤害。经验丰富的工程师在努力控制复杂性,新工程师在寻找方向和严谨性,客户在等待工程人员交付功能,业务利益相关者在困惑为什么三个冲刺都过去了,而 “重构为更小的方法 ”是唯一的交付成果–所有人都是如此。
鲍勃叔叔的事情是我现在正在经历的。
我雇了一个朋友,他是鲍伯叔叔的忠实粉丝,在与公司其他人面试时,他一直试图展示自己的知识。我并没有多想,告诉其他面试官这只是他的个人怪癖,不用太担心。
我让他和一些初级开发人员一起做一个项目,而我则处理一些更紧急的事情。项目完成后,我过去看了看他那边的进展情况。我对不必要的间接使用感到震惊;为了做一些简单的事情(如数据库调用),要使用 4 或 5 个层级。更糟糕的是,他让后进生把整个类作为一个接口与一个 “错误 ”的数据库类连接起来。
没有完成任何实际工作,我花了 4 周时间来构建真正的项目,同时扔掉了不必要的垃圾。
当我读到《清洁代码》这本书时,我很喜欢,但我总认为它的很多内容都是针对特定时期的特定语言的。如果你把它逐字逐句地用于 2025 年的 Python 项目,为什么?
我不认为你的朋友误解了鲍勃叔叔的书是他的错。
从这个主题来看,似乎很多人对 UB 的作品都有类似的问题。
也许,“完成任务的开发者 ”和 “臃肿的开发者 ”的分野并非由鲍勃大叔造成,而只是与之相关。也就是说,优秀的开发人员也同意鲍勃大叔的观点,不过显然对他的话有不同的理解。
聪明人读一本书,会认真思考。其他人则认为这本书是超人写的,把一切都变成了宗教信仰。
我的意思是,不同的人对这本书有不同的理解。(没有人把它当作宗教,但有些人认为它建议 “创造一座不必要的抽象山”,而另一些人则认为 “增加必要的抽象”)。
不是说 UB 本人,但开发人员被告知某本书或某个人在某个问题上具有权威性/有定论,这并不健康。
> 我担心他的方法会让某类程序员专注于……美学的、教条式的统一性(以及相关的非生产性,即主要出于美学动机的、教条式的修改,而不是增强、错误修正,或者项目中其他编码员一致认为可以提高可维护性的东西),而不是提高他们的技能和对自己技术的熟悉程度。
有趣的是,我发现情况恰恰相反。根据我的经验,那些愿意对代码风格采取 “教条式 ”立场的人,都是那些能够真正开始实施功能和错误修复的人。那些认为凡事都有时间和地点,每次公关都需要重新讨论相同问题的人,才会把自己绑得死死的,一事无成。
我完全同意马丁所写的一切吗?原则上不同意。但我更愿意在一个同意遵循他的标准(或任何类似的同样严格的标准,只要它们不是疯狂的)的代码库和团队上工作,而不是在一个不同意遵循他的标准的代码库和团队上工作。
我对《清洁代码》一书等并不熟悉;我的介绍是这篇文章。UB 似乎一直在倡导我不喜欢的模式!举个例子: 函数有时只需 2-3 行。通常是 5-20 行。多于 2-3 行的情况比较少见,但也不少见!
我还喜欢对每个模块和函数以及许多字段/变量进行详细的文档注释。还有,任何需要特别注意的、不直观的、表示单位或来源的内容,等等。
函数长度也取决于语言。一种语言的每一行需要另一种语言的三行,如果前者有隐式错误处理,而后者有显式错误处理的话。但我认为这两种语言的认知负荷是相似的。
在适当的情况下,我也能接受 1000 行的函数。让我在代码中跳来跳去,而不是一行一行地直线阅读?不,谢谢!
函数长度的问题无关紧要。请继续关注 UB 在说什么。
我读了之后并没有得到这种印象。我还发现所讨论的 TDD 方法惯性很大。
> 在这段文字中,马丁似乎与软件工程的现实情况格格不入,这让我很吃惊。
一直都是这样。Fowler 对贫血领域模型的批评也是如此。但软件工程也不例外,很多人不经过自己的思考就相信了某个人。
> 一直都是这样。福勒对贫血领域模型的批评也是如此。
你为什么不同意 “贫血的领域模型是一种反模式 ”这一事实?
https://martinfowler.com/bliki/AnemicDomainModel.html
我认为,如果你真的花点时间去学习和理解他所说的话以及他的观点,那么他的批评显然是有道理的。花点时间理解他提出的理由:这不是面向对象编程。就是这样。
你看,在一个贫血的领域模型中,你拥有的不是对象,而是被输入到函数中的 DTO。这违反了面向对象编程的基本原则。这要么是直接的过程式编程,要么就是函数式编程,如果你足够努力的话。如果把 OO 作为目标,这显然是一种反模式。
下面这句话概括了他的主要论点:
> 从本质上讲,贫血领域模型的问题在于,它们付出了领域模型的所有代价,却得不到任何好处。
你真的反对它吗?
听着,像福勒(Fowler)和鲍勃大叔(Uncle Bob)这样的人主张采用特定的风格。这意味着他们必须采用一种修辞风格,着重强调某种风格的优点,并强调该风格所解决的问题和不遵循该风格所造成的问题。这完全没有问题。如果你不以宗教般的狂热追随某种事物,这也没有问题。如果你有不同的品味,是否意味着任何不同意你的人都是错的?
不冷静的是,因为无知和懒惰而批评别人,因为觉得自己的个人品味受到重视而对某人或某事说三道四。
“这不是面向对象编程 “只有在你认为面向对象编程等同于好的情况下才能成立。我不这么认为。它有时好,有时不好。
为什么要以面向对象为目标?我们的目标是编写易于维护的优秀软件。除了写书的人,没有人在使用 UML 图表。
举例来说,你为什么不专注于用 OO 语言编写 OO 代码呢?你会开始用函数式语言编写 OO 代码吗?不会的,因为这样做毫无意义。编程范式的存在是有原因的
> 例如,你为什么不专注于用 OO 语言编写 OO 代码?你会开始用函数式语言编写 OO 代码吗?不会的,因为这毫无意义。编程范式的存在是有原因的
我的报酬是用软件有效地解决业务问题,而不是使用特定的范式。如果 FP 解决方案更合适,团队也能支持它,那我就会使用它。
> 例如,你为什么不专注于用 OO 语言编写 OO 代码?
通常人们这样做是为了交付更高质量的软件。大多数语言仍然有一些 OO 特性,人们不使用它们是因为他们知道这些特性会导致糟糕的代码。我想到了继承(一种核心的 OO 特性)。现在大多数专业人士都认为不应该使用它。
OO 设计通常过于抽象,这使得它们难以理解和改变。它们缺乏 “行为的局部性”。简单的算法看起来很复杂,因为其中的部分内容分散在多个类中。这就是为什么越来越多的现代语言倾向于放弃 OOP 的原因。
我认为,从长远来看,我们将从 OO 中保留的是将方法与结构体关联起来的可能性。
> 例如,你为什么不专注于用 OO 语言编写 OO 代码呢?
这是循环逻辑。我不会专注于编写 OO 代码,因为根据我的经验,结果通常会更糟。如果我不得不使用一种以编写 OO 代码为导向的语言,我还是会尽量减少损失。
> 编程范式的存在是有原因的
没有。很多范式只是历史的偶然。
> 为什么不专注于用 OO 语言编写 OO 代码?
这应该是解决问题的最佳方案,直接决定是否使用 OO 是最好的,而不是语言。
> “这不是面向对象编程 ”只有在你认为面向对象编程等同于好的情况下才能成立。我不这么认为。它有时好,有时不好。
你看,这种懒惰的无知对讨论毫无益处,只会让人误以为是恶意谩骂。
领域模型从根本上说是一个面向对象编程的概念。您可以用类为业务领域建模,也就是说,您可以在类中指定反映业务领域的行为。您的 Order 类有一个 Product 项目集合,但您可以更新订单、取消订单、重复订单等。这些行为应该是成员函数。在以 OO 为基础的领域驱动设计中,您可以在类级别实现这些操作,因为您的类可以模拟业务领域并实现业务规则。
反对贫血的领域模型的理由是,没有行为的领域模型无法满足领域模型的最基本要求。你的领域模型只是把 DTO 当作值类型来传递,根本没有行为。没有行为的对象有意义吗?不,在 OO 和其他领域都是如此。为什么?因为一个没有行为的领域模型意味着你在浪费所有的开发精力去建立一个什么都不做也没有任何好处的结构,因此是一种浪费。你最好做一些完全不同的事情,这肯定不是领域驱动设计。
事实上,你所提出的混合论点的整个问题在于,你试图把一个流行词强加给一个与它毫无相似之处的东西。这就好比你想从玩流行语宾果游戏中获益,却根本懒得去学习它的绝对基础知识或任何东西。你根本不知道自己在做什么,却莫名其妙地称之为领域驱动设计。
> 为什么要以 OO 为目标?
你采用的是 OO 概念,其最基本的特征就是用对象来模拟业务领域。你明白这种说法的荒谬性吗?
我真的不明白这种对领域建模的固执。它看起来就像大量 UML 与 “*DD”(生活小贴士:几乎任何 X 驱动开发都是经验丰富的程序员很少关心的东西。你可以从几乎任何方法论中借鉴好的想法,而不必痴迷于其主要课题。痴迷于 “唯一正确的方法 ”会浪费大量脑细胞)。此外,正常人都不会碰 UML。或者制作大的类及其关系的官方图表。这简直是浪费时间。你可能会想出一些核心概念和关系,比如 B-REP,但你并不需要一些术语繁多的官方方法来做这些。
> 反对贫血领域模型的论点是,没有行为的领域模型不符合领域模型的最基本要求。你的领域模型只是把 DTO 当作值类型来传递,根本没有行为。没有行为的对象有意义吗?不,在 OO 和其他领域都是如此。为什么?因为一个没有行为的领域模型意味着你要浪费所有的开发精力来建立一个什么都不做也没有任何好处的结构,因此是一种浪费。你最好做一些完全不同的事情,这肯定不是领域驱动设计。
我几乎不知道你在说什么,但我同意,如果没有领域驱动设计,我可能会过得更好。
> 你采用的是 OO 概念,其最基本的特征是用对象来模拟业务领域。你明白这种说法的荒谬性吗?
除非我不明白,因为我不在乎 DDD?我的论点很简单:关心你的代码在多大程度上遵循了某种第三方方法论并不重要,重要的是你是否写出了好代码。
> 领域模型从根本上说是一种面向对象编程概念。
它们不是。
> 领域模型从根本上说是一种面向对象的编程概念,而不是。
我有更好的工具来实现这一点。
> 在以 OO 为基础的 “领域驱动设计 ”中,你可以在类的层次上实现这些操作,因为你的类可以为业务领域建模并实现业务规则。
你还是没有解释 “为什么”。你只是在重复一堆教条。
> 一个没有行为的领域模型意味着你在浪费所有的开发精力来建立一个什么都不做也没有任何好处的结构,因此是一种浪费。
根据我的经验,这完全是错误的。
> 你不知道自己在做什么,却莫名其妙地称之为领域驱动设计。
我不称之为领域驱动。你想叫领域驱动就叫,不想叫就不叫。我不在乎它叫什么,我在乎的是它是否能带来有效、可维护、低缺陷率的软件。
> 我关心的是它是否能开发出有效、可维护、低缺陷率的软件。
这就是问题所在。所有其他的发明都必须服务于这一目标。
> 您的订单类有一个产品项目集合,但您可以更新订单、取消订单、重复订单等。这些行为应该是成员函数。
这就是把 OO 搞得一团糟并给它带来坏名声的方法:
订单还能知道什么?也许可以给它一个 JSON 渲染器 .toJson()、一个定价机制 .getCost()、折扣规则 .applyDiscount()、访问客户银行账户的 .directDebit()、日志和备份。如果一个类有 10 多个行为,你很可能会忘记另外 5 个。
订单是一张寄到你邮箱的纸。你不能用锋利的笔去涂抹它,也不能让它自己走进文件柜。它是一张纸,你通过读取()来把它装进盒子里,然后送到邮局。你有行为,邮局也有行为。订单和盒子没有。它们充其量只有几个 getters() 或一些用于返回集合数据的静态方法,但即便如此,我也会避而远之。例如:如果订单给了我一个不错的 totalPrice() 方法,那么以后的事情就简单多了,对吗?当然不是,因为在 TaxCalculator(而不是 order.calculateTax())中,我需要深入研究细节,而不是汇总数据。DiscountApplier 也是如此。
> 没有行为的对象有意义吗?不,在 OO 和其他地方都是如此。
它是有意义的,就像在领域(现实世界的订单)中一样。顺便提一句,我认为无行为对象是 Clojure 的核心原则之一。
既然这是 HN 每个月抨击 UB 的主题,我应该指出,我从他那里学到了大部分这些东西。(不过更多的是从 SOLID 学来的,我想我在清洁方面没什么可说的)。
上述例子违反了 SRP 和 DI。
“单一更改理由”: 如果 order.cancel(..) 知道电子邮件,那么如果取消规则发生变化或电子邮件系统发生变化,我就必须修改这段代码。如果我们不再通过电子邮件通知怎么办?订单必须知道 SMS 或其他技术,这将导致更多的更改原因。
“依赖倒置”: 无论技术能力如何,人们都知道订单是什么。它们可以在没有计算机或任何特定实现方式的情况下存在。因此(相对于其他关注点而言),订单是高层次的、抽象的。订单是通过数据库、Kafka 和/或一系列其他技术(或实现细节)来处理的。DI 指出,抽象事物不应依赖于具体事物。
我们对 OOP 的核心存在分歧。在英语中,“猫吃老鼠 ”这样一个简单的句子可以分解如下:
– 猫是主语名词
– Eats(吃)是动词
– 老鼠是宾语名词
在面向对象编程中,主语通常是程序员、程序、计算机、用户代理或用户。宾语是……对象。动词是方法。
因此,想象一下 “客户取消了订单 ”这个句子。
– 顾客是主语名词
– 取消是动词
– Order 是宾语名词
在 OOP 风格中,您不会将这句话表达为 customer.cancel(order),尽管从左到右的朗读方式与英语类似。取而代之的是围绕宾语进行表达。订单是对象名词,是要取消的。因此,order.cancel()。主语名词是隐含的,因为它是多余的。在给定的方法(甚至系统)中,几乎每个主名词都是同一个程序员、程序、计算机、用户代理或用户。
如需了解更多信息,我建议阅读 Grady Booch 等人编著的《面向对象分析与应用设计》(第 3 版)第一部分,以及 David West 编著的《对象思维》。
—
尽管如此,我认为你在这个例子中的单一责任原则是正确的。行为过多的类通常应分解为多个类,并适当分配责任。但是,不应该让对象没有行为。它仍然必须是一个拟人化的对象,用行为封装它所拥有的任何数据。
> 所以,想象一下 “顾客取消了订单 ”这个句子。
> 顾客是主语名词
这是错的。因为顾客并没有取消订单。实际上是顾客要求取消订单。然后订单被 “系统 ”取消了。不管这个系统是什么。
这就是为什么不是用 customer.cancel(order) 而是 system.cancel(order, reason = “customer asked for it”) 来表达的原因。
> 因此,order.cancel()。主语名词是隐含的,因为它是多余的。
啊,是这样吗?那么,我想请您告诉我:如果有两个系统(例如一个旧系统和一个新系统,甚至更多系统),而订单有时需要在这两个系统或其中一个系统中取消,会发生什么情况?在你们的世界里,现在是如何工作的?
mrkeen 提到了依赖倒置(DI)。我认为在 oop 中,订单有一个取消方法是合理的,但选择该方法时最好使用 DI 配置。这是因为调用者可能并不清楚所涉及的一切。
如果系统是新的,而且只有一种方法,那就不值得为它操心。但如果出现了新的要求,选择一种方法来处理是有意义的。
例如,订单可能由销售人员输入,也可能由客户在网上输入。取消流程(也许是一种策略)可能会有所不同。不同的用户可能有不同的权限取消订单。网站的设计者可能不需要编写所有这些代码,也许他们只需要为订单设置一个取消功能,然后让业务逻辑来处理。每个订单对象都可以配置正确的策略。
如果你不想使用 OO,那也没关系,但你仍然必须处理这些情况。网页设计师调用的函数放在哪个模块?如何选择正确的流程?这些模式在其他范式中可能会有其他名称,但模式实际上是一样的。不同之处在于你把复杂性塞进了哪里。
> 客户实际上要求取消订单。
这就是为什么许多面向对象程序员喜欢谈论消息传递而不是方法调用。这确实是请求取消订单,而订单可以决定是否满足该请求。
> 订单可以决定是否满足该请求。
在我的思维世界里,订单不做决定。如果我去找业务团队,说 “订单决定”,他们会用异样的眼光看我。而且理由充分。
与英语语法的比较没有必要。我在论证中没有使用它,而你也说过它不是这样的,所以当你得出
> 订单是宾语名词,是被取消的内容。因此,order.cancel()
你只是重述了我反对的立场,却没有提出论据。
哦,你描述得比我好听多了。我现在感觉很糟。
> 领域模型从根本上说是一个面向对象编程的概念
绝对不是。事实上,它们甚至不是编程所特有的,更不用说 OOP 了。
> 这不是面向对象编程。就是这样。
没错,就是这样。这种 “经典 ”的面向对象编程本身就是一种反模式。
(尽管如此,OOP 的定义并不完善。举例来说,我并不反对将相关的数据结构和功能放在同一个命名空间中。但对他来说,OOP 并不是这个意思)
我在这里用一个非常简短的例子来回答为什么贫血领域模型总的来说更优越,无论你使用的是 OOP 还是其他方法。
你自己用了 “订单 ”的例子,我就在此基础上再举例说明。
我绝不会把更新订单的功能与订单的数据和结构结合起来。原因很简单:业务约束并不总是存在于订单中。
下面是一个例子,说明为什么这种方法必然会失败:如果业务规定,只有在一个月内订购 10000 件商品后才能下订单,那么就不能在订单类中对该约束建模。您必须将其移至外部–移至了解系统中所有订单的实体。这就是订单存储库(OrderRepository),或者你想如何称呼它都可以。
请记住,您在另一篇文章中是这么说的:
> 您的订单类有一个产品项目集合,但您可以更新订单、取消订单、重复订单等。这些行为应该是成员函数。
所以您的订单应该有一个重复功能?但订单如何知道它是否可以重复呢?它可能会违反最大月项目约束。订单要做到这一点的唯一方法就是持有对 OrderRepository 的引用。
这是个大问题。现在,订单存储库和订单的概念已经纠缠在一起。事实上,订单完全可以在没有 OrderRepository 的情况下生存,例如,当您构建一个订单模拟(OrderSimulation)时,实际上并没有订单被执行/存在。但是,即使不需要,现在也有了订单存储库。
经验法则是:如果业务部门说 “我们不再需要功能 A,请将其删除”,那么您就应该能够从代码中删除该功能,而不会触及任何不相关的功能。如果您现在删除了 OrderRepository,并且由于您的代码更改而导致 Order 类出现错误,业务部门可能会问怎么会这样,因为虽然 OrderRepository 不能没有 Orders,但 Orders 可以没有 OrderRepository。
如果这看起来有点不现实,那么想想用户: 没有 UserRepository,用户很容易存在,但反过来就不一样了。
这就清楚地表明,丰富的领域模型并不适合用于业务领域的建模,一般来说是次优的解决方案。而贫血领域模型则与之完美匹配。
还有一点:即使是自然语言也不同意丰富领域模型。订单会重复吗?不会!订单是重复的,也就是说,它是由某物或某人重复的。仅这一点就清楚地表明,在命令之外还有一个实体对这种行为负责。再说一遍,贫血领域模型是用代码表达这一点的绝佳解决方案。
但如果你不同意,我希望你能解释一下你认为贫血领域模型的缺点是什么。
你在这里举了一个很好的例子,我完全同意你的观点。
事实上,我发现这种意外/不必要的耦合是造成问题、错误和限制重用的首要原因,因此也是任何软件产品开发速度的首要原因。单向依赖变成循环依赖的概念真的很难发展、维护、测试和理解。
事实上,我甚至可以说,作为一般的经验法则,如果你的类 A 依赖于类 B,而类 B 又依赖于类 A,那么你就犯了一个大错,你真的应该认真地重新考虑你的设计。
(与此规则相邻的是,存在于软件层次结构中同一层次的同级类也不应该互相知道对方)。
事实上,当你在设计代码时只考虑单向依赖关系时,你就会得到一个整洁的千层面代码库,所有东西都可以很容易地放入其中。(此外,它还有一个次要功能,即消除堆栈中的所有向上跳转,即回调)。
如果能提供有关此主题的任何现有文章的链接,我们将不胜感激。
如果您不介意视频格式,我强烈推荐您观看视频:https://youtu.be/zHiWqnTWsn4?t=3134。
52:14 处的幻灯片是关于 SOLID 原则的,第一张是关于 SRP 的,它就 Order 是否应该有行为给出了相当易懂的建议。
这是 Fowler 的原文:https://martinfowler.com/bliki/AnemicDomainModel.html
通过搜索这个词,你会很容易找到很多关于这个问题的其他观点。
> 我将用一个非常简单的例子来回答为什么贫血的领域模型总的来说更优越,无论你使用的是 OOP 还是其他方法。
当然,我可以搜索一下,但不是关于 OOP 纯粹主义的结果似乎很少见。
我还没有看到很多证据表明马丁真的有编码方面的能力,可以像他那样发表权威性的言论。我认为,当你因提供建议或成为 “专家 ”而出名时,就很难谦虚到足以学习新东西。就我个人而言,我过去说过很多关于编码的蠢话;幸运的是,这些蠢话都没有被编入 “经典 ”书籍。
清洁代码》一书中的建议让我印象深刻的是,这些想法充其量只是未经证实的观点(IE 只是马丁的观点),而在最坏的情况下,这些想法是在为坏习惯辩护。说 “我不需要注释我的代码,我的代码自己会说话 ”很诱人,但很少是真的(再好的函数名也无法告诉你函数/模块为什么是这样的)。拆分函数和移动东西看起来像工作,感觉也像工作,但却什么也没做成,坦白说,我经常觉得自己在编码时就像在玩小玩意儿(不过至少小玩意儿不会破坏你的历史记录)。每当马丁在这些问题上受到质疑时,他只会说要使用 “良好的判断力”,但代码和建议本应体现良好的判断力,而大多数情况下却没有。
我个人希望人们忘掉 “清洁代码”。你最好避而远之,或者把它当作不应该做的事情的例子。
我看过他 15 年前发表的一些演讲,让我印象深刻的是,他会用物理学等客观上并不正确的事物进行类比。他自信满满地谈论着一门他显然连本科水平都不懂的学科。
在接下来的演讲中,他又同样自信地谈论编码。他的自信显然与他对材料的理解程度无关,我为什么要相信他说的话呢?
> 我还没有看到很多证据表明马丁真的有编码方面的能力,可以像他那样权威地发言。
据我推断,他的主要编码工作是很久以前的事了,而且可能是用 C++ 编写的。
我不知道人们为什么把 UB 当回事。他从未提供过任何工作经验的证明–他声称只为一家公司工作过,而这家公司……从未将任何代码交付生产。甚至他在 GitHub 上的代码示例也只是片段,甚至连一个待办事项应用程序都没有(好吧,我认为他 “每个函数只做一件事 ”的风格是一种自我实现的预言)。
也许正是因为有了他这样的人,我们才不得不做 leet 代码测试(我不相信他有能力解决哪怕是一个简单的问题)。
鲍勃叔叔是 Fitnesse 的核心贡献者之一,在当年 Java 流行的时代,Fitnesse 取得了中等程度的成功。
另外,你应该明白,在 Github 流行之前,人们就已经开始从事软件工程师的工作了,或者说,在 Github 开始开放源代码之前,人们就已经开始从事软件工程师的工作了,对吗?所以,如果一个人已经 60 多岁了,他的大部分工作很可能从未开源过,而且他的工作所针对的用例、平台和服务在这个时代已经毫无用处。
这些都与软件工程师的水平无关。
最后,你有证据证明他从未将任何代码投入生产吗?
> 所以,如果一个人已经 60 多岁了,他的大部分工作很可能从未开源过、
John Ousterhout 今年 70 岁,是开源先驱之一。我们不知道鲍勃大叔运过或没运过什么,但他在这次讨论中的友好对手肯定运过知名度很高的项目。
你是指像这样的垃圾项目提交吗:https://github.com/unclebob/fitnesse/commit/d6034080a04c740c…
在任何一个正常的开发团队中,这种毫无意义的混淆都不可能通过代码审查。
这就是那种想让自己看起来很有成果,却只是在集成开发环境中重命名变量的人提交的代码。
批评的理由是 UB 曾在一家据称不向生产交付代码的公司工作,而不是他在 GitHub 上没有大量的开源项目。
> 所以,如果一个人已经 60 多岁了,他的大部分工作很可能从未开源过。
有点年龄歧视?我今年 72 岁,制作了许多自由和开放源码软件工具。
真的。我认识很多六七十岁的人都在使用 Git,他们仍然是非常优秀的程序员。
使用 Git 与你编写的软件是专有还是开源无关。
另一个不太务实的建议是 “尖叫架构”。如果你花点时间想一想,这其实并不是个好主意。我正在撰写的一篇博文就是对它的反驳。
我希望你能进一步阐述!
简而言之:在设计新软件时,一开始你并没有它的架构图。因此,从零开始时,架构不应该是尖叫式的,而是必须是非承诺性/非指定性的,以便为未来留出回旋余地。(如何实现非承诺性架构是我最感兴趣的话题,我每隔几年就会发现一个好方法)。具体来说,架构应强调入口点和输出。这正是 Rails 等框架所提供的。在某种自定义架构从中间开始出现之前,你可以通过入口点来实现,这样随着时间的推移,它就可以慢慢开始 “尖叫 ”了。
我在刚开始工作的时候读过《清洁代码》(Clean Code)一书,当时我在一个小团队里工作,我们并没有什么标准,也不关心可维护性,但到了后来,可维护性开始变得重要起来,我觉得这本书对我很有帮助。
当然,教条主义永远不会是完美的,但当你一无所有时,教条主义的老师可以让你有一个好的起点。我很钦佩他坚持自己的原则,并证明了他在简洁代码中制定的规则在很多情况下都能让代码更易读。
我对他这个人一无所知。我从未读过他的其他书,但我从那本书中得到了很多东西。你可以从某些东西中获得很多东西,而不必成为它的信徒。
编辑:我想即使是 UB 也会同意我的观点,他的教条主义是一种态度,是对缺乏严谨性或不关心代码可读性的强烈反击,而不是必须遵循的字面规定。请看他在这里的评论:
> 在 2008 年的时候,我关心的是如何打破早期网络上常见的大型函数的习惯。在第二版中,我的想法更加平衡了。
也许是我运气好,我的编码生活与我阅读《简洁代码》的时间非常吻合。这对我和许多人来说都是一个 “啊哈 ”时刻。对于那些已经读过关于编写可读代码的书的人来说,我相信这本书对他们的帮助并不大。
我不得不承认我从未读过《清洁代码》。它从来就没有吸引过我。几年前,我确实读过 UBs 的一些文章。它们的确让我思考–我认为这是一个积极的方面,而且与你所提出的观点不谋而合。
我同意,软件开发中的僵化和 “宗教式 ”狂热是没有帮助的。
不过,我喜欢代码库的一致性,这一点在 “软件设计哲学 ”中有过讨论,我总是把它归结为,即使我做错了什么,或者做得不够好,只要我坚持做下去,一旦我意识到,或者它很重要,我只需要改变一件事就能得到好处。
在证据面前不能不顾一切地改变,这就是一致性和僵化的区别(我希望如此)!
> 在这份报告中,马丁似乎与软件工程的现实脱节了。风格化的重构会导致性能倒退,三行方法的方法名又长又拗口,对注释近乎完全敌视……不管谁对谁错,这些观点看起来都是最糟糕的诡辩极端主义,而不是可以应用于大型软件开发的理性实用主义。
我认为你是无知者无畏。让我们花点时间来真正思考一下鲍勃大叔在他的《清洁代码》一书中提出的论点。
他主张优化代码以提高清晰度和可读性。代码的主要目的是帮助程序员理解代码,并轻松高效地修改代码。至于机器如何处理代码,则是次要的。为什么?因为程序员的时间比任何基础设施成本都要昂贵得多。
如何让代码清晰易读?鲍勃叔叔给出了他的建议。在方法名称中说明它们的作用,这样程序员就可以轻松地推理代码,甚至无需查看函数的作用。将低级代码提取到高级方法中,这样函数调用就能以相同的细节描述它的作用。注释是一种自我承认,即你没有写出可读的代码,你可以通过将代码重构为自描述的成员函数来弥补你的失败。
总的来说,这是一个优化问题,其唯一目标就是可读性。因此,性能下降显然是可以接受的。
你真的对此有任何抱怨吗?如果你读了你写的东西,你会发现你什么也没说,也没有具体的内容:你只是空洞地说了一些听起来很恶毒但没有任何实质内容的广告词。
这有什么意义?
> 可维护性/适当的因子都是主观品质,在很大程度上取决于项目、项目中的其他程序员以及在该环境中软件工程的预期。
你的混合论证的问题在于,像你这样的人非常热衷于抱怨和批评他人表达的观点,但当你被轻描淡写地问及这个问题时,你就会显示出你实际上没有任何可供选择的方法、指导或任何东西。你的论点归根结底就是 “你们有自己一贯遵循的风格,但我认为我也有自己的风格,而且不知何故,我认为应该以我的品味为准,而我的品味连我自己都说不清楚”。你有自己的观点没问题,但为什么要批评别人有自己的观点呢?
> 评论是你对自己未能写出可读代码的自我承认,你可以通过将代码重构为自描述的成员函数来弥补你的失败。
在某些情况下,这可能是对的,但我不认为有什么非约定俗成的方式可以让代码描述为什么要以这种方式编写,或者为什么要以这种方式实现功能。如果所有的注释都是糟糕的,那么这种文档就需要在其他地方编写,在那里它将与实现脱节,而且很可能会被遗忘。
> 在某些情况下,这可能是对的,但我看不到有什么非设计的方法可以让代码描述为什么要以这种方式编写,或者为什么要以这种方式实现功能。
我不得不说你的论点是胡说八道。要么你根本就没看,因为你不幸只看到了无能的开发人员编写的糟糕代码;要么你根本就不知道代码是什么样的,所以无法分辨。
核心原则非常简单,而且无处不在。例如,用自我描述的名称代替注释。这难道不是显而易见的吗?我的意思是,一个名为 foobinator 的成员函数需要结合注释和深入的定义才能知道它是做什么的。你需要通过注释来了解名为 postOrderMessageToEventBroker 的成员函数是做什么的吗?
另一个非常基本的例子:谓词。很难理解 isMessageAnOrderRequest(message) 的作用吗?如果 message.type == “command” && type.ToUpperCase() == “request” && message.class == RequestClass.Order 呢?哪个更简洁易读?你说这些例子都是臆造的,但在某些领域,它们不仅仅是惯用的。以用户定义的类型断言为例。TypeScript 甚至有专门的语言结构,以用户定义类型保护的形式来实现它们。而你却声称这些例子是人为设计的?
我开始相信,所有这些批评鲍勃叔叔、埃里克-埃文斯(Eric Evans)或其他作者的人,其实都是出于对自己一无所知的事物的无知。他们在某个博客上读到了一些评论,然后突然就认为自己是一个他们一无所知的主题的权威。
噪音真多。
> 是否需要注释来说明名为 postOrderMessageToEventBroker 的成员函数的作用?
哪个事件代理?我会通过回调得到响应吗?如果发送失败会发生什么情况,有重试机制吗?如果有,重试多少次?超过重试次数后会发生什么?该方法是否会设置订单 ID?该方法是否线程安全?会出现哪些错误?“在事件代理预热/连接之前不要调用此方法”。等等等等
> 你需要注释来说明名为 postOrderMessageToEventBroker 的成员函数是做什么的吗?
显然不需要,你回复的注释也没有断言不需要。他们说得很清楚,注释应该解释_why_,而 postOrderMessageToEventBroker 只解释了_what_(这反映在您问题的措辞中)。幸运的是,注释实际上是免费的,而且我们并不局限于让读者每次只看一句话,我们可以用注释(在不明显的时候)解释为什么,也可以用清晰的函数名解释是什么。
> 我开始相信,所有这些批评鲍勃叔叔、埃里克-埃文斯或其他作者的人,其实都是出于对自己一无所知的事物的无知。
我已经在许多不同领域从事编程工作 60 年了。我比他更早开始编程。我可能比他用更多的语言写过更多的代码(据统计有 36 种),所以我对他的批评是基于真实世界的经验。
我指的是 “为什么”,而不是 “是什么 ”或 “怎么做”。在我看来,这不是一个好的函数名,但我也有自己的看法:get-station-id-working-around-vendor-limitation-that-forces-us-to-route-the-call-through-an-intermediary-entity。
相反,评论可以简明扼要地告诉我为什么这个实现看起来比需要的还要复杂,并链接到相关文档或问题等。
> 代码的主要目的是帮助程序员理解代码,并轻松高效地修改代码。机器如何使用代码则是次要的。
这种心态听起来像是在冯-诺依曼体系结构的固有特征上建立漏洞百出的抽象的秘诀,而最近的大规模 CPU 并行性也是如此。这带来了数据竞赛、死锁和性能低下。这种心态的另一个表现是,考虑到硬件性能的惊人提升,现代软件的速度其实并没有达到应有的水平。
> 评论是你对自己未能写出可读代码的自我承认
我不信这个邪。将函数的行为和契约压缩到函数名中是不可能的。如果可以,那么编译器就会自动从方法名中生成代码。你可以使用约定和触发词来编纂行为(例如 bubbleSort、makeSHA256),但这只适用于众所周知的概念。在模块边界,我感兴趣的不是模块的内部运作,而是它的契约。任何足够复杂的模块都有一个复杂到绝对需要注释的契约。
> 这种心态听起来像是在冯-诺依曼体系结构的固有特征上建立漏洞百出的抽象的秘诀,而最近的大规模 CPU 并行性也是如此。这将带来数据竞赛、死锁和低性能。
不,并非如此。你考虑如何命名函数,以及如果将代码中的哪些部分提取到函数中会更容易阅读,并不意味着你在创建抽象或制造问题。
你关于冯-诺依曼体系结构的其他评论纯属无稽之谈。你的代码易于阅读并不意味着你在写与代码执行方式毫无关联的诗歌。想想你在说什么:编写可读代码的意义何在?是为了美观而牺牲错误,还是为了帮助开发人员理解代码的作用?如果是后者,你认为你在表达什么观点?
> 注释是你对自己未能写出可读代码的自我承认,你可以通过将代码重构为自描述的成员函数来弥补你的失败
# 这不是我想要的方式,但由于依赖关系 [URL to github ticket] 中的 #12345 错误,我们不得不这样做。
# TODO FIXME 当上述工作完成后。
哦不,我的自述代码太失败了。对不起,我完全应该把这个方法命名为 DoThisAndTHatButAlsoIncludeAnUglyHackBecauseSomeDubfuckUpstreamShippedWithABug。
鲍勃大叔建议的代码并不是更容易阅读和理解,而是更难阅读和理解。既然分歧由此而起,就不可能有进一步的讨论。
[删除]
我写 IMO 是有原因的。你这句话的问题在于,你在暗示我不同意清洁代码的改进不是改进。在大多数情况下,它们是改进了,但它们是对那些特殊例子的改进,并不能一概而论。特别是函数大小的问题绝对是愚蠢的。我们还可以做不同的改进。
在现实世界中,针对程序文本进行优化是错误的。如果行为是错误的,那么文本读起来是否漂亮并不重要。你需要的是为调试而优化的代码,而为调试而优化的代码希望避免跳来跳去,因为在单个堆栈帧中看到的信息越多越好。
一般的 OOP 风格代码也有类似的问题。隔离状态很好,分布式隔离状态则是一场噩梦。将分布式计算中的这一难题移植过来,会使程序调试变得更加困难,而不是更加容易,因为你现在必须了解对象之间的通信历史,才能理解某个全局状态是如何汇总达到的。
相比之下,直接在更大的状态下运行的步骤序列更容易遵循逻辑,因为它明确地写在一个地方。
注释也比复杂的方法名称要好得多。为什么要注释,我想连鲍勃都会同意注释的重要性,但如何注释却非常有用。考虑一下在文档字符串中写出示例代码和输出结果的 python 库(这些输出结果会被转化为自动测试)。方法名称有那么重要吗?其实并不重要。
再看看 APL 及其爱好者。虽然我不是 APL 的拥趸,但其支持者提出了他们喜欢 APL 的一个很好的理由:你可以一次性看到程序中更多的内容,而且符号序列可以形成具有精确含义的单词。
基本上,数学符号和汉字合二为一。这与鲍勃的 “简洁代码 ”方法有何不同?
下面是一个例子: https://qntm.org/clean
> 一边摆手一边说废话,对讨论毫无益处。
这并不像他的准则要求我们做的那样,是在假定善意。
> 机器如何使用它是次要的。为什么?因为程序员的时间比任何基础设施成本都要昂贵得多。
这假定代码运行在企业基础设施上。如果运行在终端用户设备上呢?作为用户,我当然关心我手机的电池寿命。我们甚至还没有讨论环境问题。最后,在很多应用中,速度其实很重要。
> 注释是对你未能写出可读代码的自我承认,你可以通过将代码重构为自说明的成员函数来弥补你的失败。
自解释代码是一个崇高的目标,但在实践中,除了最琐碎的应用,你总是至少有一些代码需要附加注释。世界不是二进制的。
我不会花很长时间来回应你的评论,因为它看起来是指责性的,而且很粗鲁;如果你能修改得更有实质性,我会很乐意参与更多的讨论。
我的一个具体回应是:不是我
> 我]只说了一些空洞的广告词,听起来非常恶毒
……相反,我批评的是马丁的教学方法,而不是他的编程方法。我在相邻的评论中进一步阐述了这一批评,请点击此处:https://news.ycombinator.com/item?id=43171470
> 纯粹的僵化
这看起来更像是一种沟通风格上的差异。鲍勃大叔的演讲和写作都是规范性的–这是我上小学时就耳濡目染的一种风格,因为从你说话的事实中就可以看出,你只是在描述你的观点,任何额外的对冲语言都会进一步削弱你的立场,而不是你的本意。
如果你听他在访谈或其他场合被明确问及整体教条主义或关于这个或那个概念的问题,他会对实用主义持非常开放的态度,即使面对半途而废的例子,他也很少需要太多的说服。
> 对评论的敌意
作为一个乐于在代码的棘手部分加入微型小说的人,我认为这种敌意是方向正确的建议(只要采用该建议的工程师对实用主义持开放态度)。
在最近的一个 $WORK 例子中,我正在编写一些解析代码,其中有一个 `populate` 方法用于生成一个对象/结构/POCO/POJO/dataclass/不管它是什么–用你的语言来说是什么,随着它的长度增加,我开始写一些注释来描述这些部分,为了简单起见,我们姑且称它们为 “只在这一层填充 ”和 “递归”。
如果你从字面上理解对注释的敌意,你就会简单地看待这些注释,并说它们必须被删除。我努力做到实事求是,并借此机会检查是否有办法让代码更加不言自明。幸运的是,只需将最初的部分拆分成一个 `populate_no_recurse` 方法,就能创建我想要的文档,而且还能为我在一些地方实际想要执行的操作提供一个有意义的名称。
这种特殊的模式(将一个长方法分解为一系列命名的中间部分)有其失败的模式,尤其是在优化较差的运行时(C#、Java、…、Python…)的热路径中,如果不加区分地使用,肯定会影响未来的可读性,但我有足够多的经验来确信它在这里是一个不错的选择。鲍勃大叔提出的一些方向性正确的建议让我对自己的部分解决方案有了新的认识,并使之更加完善。
> 其他动机
– 风格上的重构会导致性能下降,但这是值得的。作为人类,我们天生就有规避风险的倾向,所以让我们来看看一个具有相反效果的相反行为:你有多少时候愿意为了挤出一些性能(举个具体的例子,假设有一些操作需要在空间/时间/带宽上进行权衡,这就意味着你应该在数据库中使用一个讨厌的递归代码来计算像十亿位掩码上的 popcount 这样的操作,或者干脆重写存储层的这一部分)而放慢功能开发速度并增加代码维护难度?我的工作 80% 是让程序运行得更快,10% 是教别人如何让程序运行得更快,但一天只有那么几个小时。我时不时还是会用性能来换取代码的速度和稳定性,而对于那些QPS小于100万的初创公司来说,他们可能比我更应该做这样的交易(假设这是真正的交易,而不仅仅是把垃圾代码部署到prod的借口)。
– 我对 “折磨方法名 ”这个问题最有意见。当然,如果一个长名称不够合适,不能给你带来长名称的好处(从名称就能知道它是做什么的、可搜索性),你就不应该折磨它,但如果长名称确实合适呢?对于足够大的代码库,我认为长名称仍然值得付出其他代价。尤其是在经历了几次招聘/解聘之后,没有人知道子系统是如何工作的情况下,能够从某些特定安卓设备上出现错误的 HTML 直接找到十亿行中产生错误的一行,是非常宝贵的。不过,我认为切换点还是很高的。在 10 万至 100 万行的范围内,没有足够多的相似概念可供搜索,因此真正独特的名称不会带来太多好处,唯一真正的好处就是仅仅通过名称就能知道一个东西是做什么的。长名称的代价是信息密度,当上下文(可能还有三条注释)很清楚时,我可以用单字母变量名来编写数字例程,否则就有可能掩盖真正的逻辑,使大脑中的模式识别部分无法帮助处理问题。不过,能正确告诉你一件事是做什么的名称仍然是有帮助的(调用 `.resetRetainingCapacity()` 和 `.reset()` 之间的区别–后者你仍然需要查看源代码以确定它是否是你想要的方法,如果你对该数据结构不熟悉,就会减慢开发速度)。我仍然会根据具体情况来处理这条建议,我不一定会同意昨天的自己。
> “鲍勃大叔的信徒 ”与 “鲍勃大叔”
这也许就是你抱怨的核心?我见过很多人喜欢他的建议,但却不太务实。大多数 IME 都是初入职场的人,他们只是想知道如何从 “我会写代码 ”变成 “我能写好代码”,因此,如果你能提供有理有据的反例,他们是可以接受指导的。剩下的大多数人,我认为他们喜欢鲍勃大叔的建议,但不怎么会写代码,所以他们的意见和其他不明真相的意见一样没有价值,我不确定我会不会浪费太多时间去感叹这种错误信息。至于其他人?我没有足够多的样本来帮助我,但无情的教条主义是非常糟糕的,这样的人肯定是存在的。
谢谢你深思熟虑的回答。我一般不想深入探讨马丁主张的具体内容。是喜欢还是回避注释,是给方法起一个特定的名字,还是接受重构带来的性能损失–这些在上下文中都有好坏之分。
我认为,很多工程师听到 “有时间、有地点 ”或 “根据上下文”,就会认为我是在说编码方法可以或应该因人而异。其实不然!在注释、方法长度、耦合、命名等方面采用默认方法是非常重要的。然而,最合理的默认方法是受上下文约束的,而不是著名作者的唯一真理(或者在很多情况下,是厌恶变化的高级项目架构师的唯一真理)。一套惯例/最佳实践的 “上下文边界 ”通常是一个代码库/团队。有时是代码库中的一个子区域。更罕见的情况是,它是一种正在处理的代码类型(例如,支付处理代码就需要采用不同的方法,而不是单个脚本)。在这种情况下,当贡献者偏离了一套约定俗成的最佳实践时,提出质疑是绝对合适的–只是这些最佳实践可能不是马丁的最佳实践。
相反,我批评的核心是马丁的方法缺乏远见。观点/实用主义–而不是什么抽象的 “根据一套规则创建完备代码的技能水平”–是中级高级工程师中的稀缺品,而马丁的作品主要面向中级高级工程师,并受到他们的重视。
从这里,我看到马丁在 Osterhout 记录中的立场有两处错误:
“不合群 “并不是一个任意选择的贬义词。当奥斯特豪特催促马丁改进和编写一些代码时,马丁的产出和他的辩护实在是质量太低。我之所以说他的代码质量很低,是因为尽管大家对方法长度/命名/SRP 等问题的具体看法不尽相同,但几乎每个人都认为马丁的版本存在严重问题,而我看到的对奥斯特豪特代码最严格的批评也只是 “嗯,还行,可以更好”。这一点,再加上马丁围绕重构的 “原因 ”所做的陈述,表明他的建议是否适用于 2025 年实质性的代码质量改进(而不是,比如说,对 2005 年 PHP 5000 行的神对象怪胎(god-object monstrosities)进行去魅化)是值得怀疑的。就其本身而言,不适用并不是一个大问题,这让我想到……
其次,马丁是一位老师。当你提到“‘鲍勃叔叔的信徒’与‘鲍勃叔叔’”时,当我谈到我在喜欢马丁的人中看到的僵化现象时,我说的是作为老师的他。这不是托瓦尔兹(Torvalds)、安蒂雷兹(Antirez)或法布里斯-贝拉德(Fabrice Bellard)式的传奇撰稿人在讨论他们制作重要软件的方法论。马丁首先(也许仅仅)是一位教师:这是他推销自己的方式,也是人们看重他的原因。这没有问题!教师不一定要成为贡献者/构建者才能成为优秀的教师。不过,这确实意味着我们可以根据马丁教学方法的质量来评价他,而不是仅仅根据他所传授的理念本身的优点来评价他。换句话说,教师总是会说一些半对半错的话,以此把学生从他们还没准备好的事情中解救出来,而我们并不会因此而责备他们–只要他们能坚持让学生理解教材的总体目标(即使后来需要卸载一些入门捷径)。
我认为马丁作为一名教师的表现实在太差了。对他的作品产生最强烈共鸣的人,正是那些将其推向最僵化、最不健康极端的人。他的教导语气是绝对的,夹杂着一些“……但当然只是务实地这样做 ”的插话,而他自己似乎并不真的相信这些话。在成绩优秀的工程系,他的教材往往被认为是领导者必须回避的东西,以防走得太远,而不是他们乐于让后辈学习的东西。这些都说明了作为一名教师的失败。
当然,软件工程师往往是二元思维者,容易把事情想得太极端–这就意味着,一个广受这类人群认可的教师有义务将这些倾向考虑在内。马丁并没有很好地做到这一点:他提出了过时的、在很多情况下都不合适的做法,同时在他的教学和对批评的回应中表现出一种固执的、绝对主义的语气。即使我对他的具体技术建议抱有最大可能的怀疑,这仍然是糟糕的教学法。
“鲍勃大叔 “不是软件工程师(他自称为软件工程师),他在这个问题上所说的一切充其量只是理论上的,最糟糕的是蛇油。有谁能指出他写过什么实质性的代码才能让人认真对待他。他在 GitHub 存储库中的代码,除了样式等,都是些简单的东西。
如果不是该领域的实践者,思考与该领域(如软件工程)相关的问题也许是可以的,但制造当下流行的理论,并将其作为坚实的理论(双关语)来推销,并期望得到认真对待,这在我看来实在可笑。
很多人都在抨击(有理由抨击)清洁代码,但相比之下,我真的很钦佩 Ousterhout 对平衡原则的承诺,尤其是从非琐碎的例子中学习。软件设计哲学》是一本发人深省的好书。
软件设计哲学》对我来说似乎更务实。
作为一个最近才开始阅读软件设计哲学的人,我不得不说,作者提出的很多观点都是我通过经验总结出来的,感觉相当不错。相比之下,我在刚开始工作的时候读过《简洁代码》一书,虽然当时觉得有一些指导原则很好,但我仍然认为有一些指导原则总比什么都没有要好。
我认为你很快就会不接受这种建议,因为它不太实用,让人感觉不着边际。这样做的结果并不是代码更容易阅读,恰恰相反。我认为 Java 世界受他的影响越来越大。
但我并不反对他,正如其他评论所说,问题在于教条主义和盲目追随这些作者,而不是去思考。
坚定的清洁代码狂热分子的争论在 PR 上浪费了我太多的时间,我已经数不清了。公关人员在一些底层机器和最终用户都不关心的问题上进行意识形态的讨论,时间长达数小时,有时甚至数周。
将这一数字乘以整个行业,浪费的生产力可能高达数亿美元。
注 我也不主张使用牛仔编码或意大利面条代码。
john ousterhout 的书是关于如何编写软件的唯一一本有实证依据的书。我强烈推荐这本书,它是关于如何编写代码的唯一一本书。
这本书真的会毒害人的心灵。即使里面有一些值得学习的好东西,它也被藏在了大量的建议中,这些建议要么很糟糕,要么需要打星号。但其实并没有什么星号,相反,书中提出了一些看起来像规则的东西,如果你想成为一名优秀的程序员,就不应该违反这些规则。
当我第一次读这本书时,我已经有 10 年的编程经验了,但我当时还是大学毕业后的第一份工作。我听说过很多关于这本书的事情,所以我相信它所说的话。我让它凌驾于我的代码编写方式之上,因为我认为专业的代码编写方式与我认为的最佳代码编写方式大相径庭。
有趣的是,我花了 5 年时间才开始相信自己的判断。我认为这不仅是因为我对自己更有信心,还因为我做的项目越来越大,我经常放下一个项目,几个月后再回来。这样,一旦我的心智模型消失,我就能看到代码有多糟糕。
现在,我对代码的要求不那么严格了,而且我发现以后的工作会好很多。
> 我经常看到这种情况,尤其是在程序员中。
我经常看到这种情况,尤其是在初级程序员中。我认为这很可能源于对做出错误决定的不安全感。这是有道理的,但我不禁觉得这是对工程工作中的决策不负责任。工程学归根结底是要根据具体情况选择适当的折衷方案。如果有一个放之四海而皆准的 “最佳 ”解决方案或简单易行的规则,他们就不需要工程师了。
我一直认为这是程序员版的 “没有人会因为选择了 IBM 而被解雇”,这也是当年关于高管的一句常用语。做一件事,你可以直接指向 “专家”,然后指责他们。
这种比较很有帮助。我想这就是风险规避的核心。在某些时候,规避风险就成了把决策权推卸给他人,这在专门为决策而聘用的职位上似乎是不可能的,但在高管身上更是如此。
我完全同意你的观点,但我对最初评论中提到的那些人也有同感。他们拿着高薪,决定如何花钱优化公司。
1. 新手遵守规则是因为有人告诉他要这样做
2. 大师遵守规则,因为他理解规则
3. 大师破坏规则,因为规则不适用
在 1 和 2 之间还有一步,那就是有人因为相信自己理解规则而制定规则。
有一个时代,每个 PHP 新手都开始写自己的权威博文和框架,我强烈地感觉到这对 PHP 生态系统的分裂和不安全的声誉产生了很大的影响(因为很多权威博文都演示了如何引入 SQL 注入漏洞)。
您指的是哪本书?上面的评论提到了两本书。
清洁代码
“john ousterhout 的书是唯一一本关于如何编写软件的书,它背后有任何实际证据”。
这是错误的,希望读到这句话的人不会把你的话当真。例如,有一些关于软件工程实证方法的书籍,实际上就是在为软件工程技术寻找真凭实据。例如,请参阅格雷格-威尔逊(Greg Wilson)的著作。
还有很多其他架构/设计类书籍都使用真实世界的系统作为例子。在我们这个领域,“证据 ”肯定是缺乏的,但只要你努力,还是可以找到的。
格雷格-威尔逊(Greg Wilson)在促进 “业界 ”思考我们的工艺方面确实有很大帮助:
https://github.com/gvwilson
编辑:哇,在他的项目 “理论上永远行不通 ”中,他对 “业界 ”反思 “工艺 ”的能力有相当清醒的认识
https://neverworkintheory.org/
> 关于该项目:
> 六十多年来,人们一直在构建复杂的软件,但直到最近,只有少数研究人员对软件的实际构建过程进行了研究。很多人都有自己的观点,而且往往非常强烈,但大多数观点都是基于个人轶事或 “显而易见 ”的推理,就像亚里士多德得出重物比轻物下落更快的结论一样。
在 2024 年的回顾中:
> 结论
> 喜剧演员 W.C. 菲尔兹曾经说过:”如果一开始不成功,那就再试一次。然后放弃。没有必要再做傻事了”。在第一篇文章发表 13 年后的今天,我们试图弥合研究与实践之间鸿沟的努力显然没有奏效。我们期待听到其他人有什么可行的计划,能得到这两个群体的真正支持。
IEEE 和 ACM 的年鉴会反对这种观点:
>只有少数研究人员研究过实际操作方法
我敢肯定,《APoSD》中的论文参考文献不止 5 篇。
> 没有链接
5 年差不多。
当我在书店找到一本《clean code》时,我只花了几分钟就把它放了回去。我之前读过 John Ousterhout 的书。
典型的 HN 评论员自以为是。我只花了不到几分钟就意识到这是胡说八道。它并没有让事情变得清晰,而是让事情变得更加抽象,更加难以改变。DDD 也是如此。只需构建你需要的东西,之后再处理不可避免的变化所带来的后果。没有人会在意你是否奇迹般地从第一天起就完美地模拟出了你的领域的 “最终形式”。
哦,还有 TDD?对了,你为实现细节编写的那些完美定义的单元案例。我最近读到的最好的评论(很抱歉我找不到了)类似于 “我写的第一个单元是通过正确使用相关的模拟来验证预期的副作用”。
做任何事情都没有 “最佳方法”,但在软件工程中……糟糕的 “最佳方法 ”远远多于最好的 “最佳方法”。
DDD 是一种从实现中提取业务逻辑的好方法。
通过对业务建模,可以将业务逻辑提升为一级元素。
通过实现业务对象,可以在业务中封装其功能。
账户 “或 ”未清偿余额 “都有业务含义。对它们进行建模可以明确地表达业务逻辑。
它还能让你创建与业务逻辑而不是实现相关的测试。
您仍然可以 “构建您需要的东西,并在以后处理不可避免的变化所带来的后果”。
建立所需的模型,业务部门将不得不对该模型进行更改,以实现他们的变更,而 IT 系统只是一个细节。
通过扩展和改变 DDD 模型来实现变革。
反过来问,在不了解领域的情况下,如何编写出 “能实现所需功能 ”的代码?
我发现人们对 “简洁代码 ”的痴迷很有趣。在我看来,罗伯特-马丁(Robert Martin)的 “简洁架构”(Clean Architecture)比那些疯狂的三行函数、无注释、只做一件事等想法更有价值、更现实。我愿意接受遵循 “简洁架构 ”的最丑陋的代码,而不愿意接受任何不理智地分离业务逻辑和 I/O 的 “简洁代码”。
我不太喜欢这个人,但对于网络开发来说,即使只是基本遵循 “简洁架构”,也能让事情不至于长期陷入混乱。
有些软件大师让我很头疼,罗伯特-马丁(Robert Martin)就是其中之一。当面对他提出的糟糕建议时,他很快就会说这并不意味着要按字面意思来理解。然后,像肯特-贝克(Kent Beck)这样的大师又说,如果你不完全按照他们说的去做,就不能批评他们的方法。因此,虽然这并不完全是一个悖论(不同的人有不同的观点),但我觉得大师们在塑造软件工程世界的同时,还靠着不可信的说法谋生。
因此,我对罗伯特接受批评并进行讨论表示赞赏,但对他在面对批评时淡化自己的建议表示遗憾–我还记得在另一场讨论中,有人质疑他的说法 “你不实践 tdd,就不是专业人士”,而他的回答是 “这并不意味着要认真对待”。
这些人的想法很好,但他们应该更多地对自己进行批评,比如 “这里是什么时候不应用这个”,“这里是什么地方应该弯曲”,而不是 “你做错了 ”或 “不要从字面上理解”。
我越是尝试执行 “清洁代码”,就越能体会到 “越糟糕越好 ”的方法,这种方法来自于一种观察,而不是教条:虽然所有程序员都在努力实现和界面的简洁性,但当它们发生冲突时,简洁的实现通常会胜过简洁的界面,因为它们更容易修改。
https://dreamsongs.com/RiseOfWorseIsBetter.html
我不明白这对人有什么作用。狗吹口哨,“嘿,嘿,别管我的诡计”。
对我来说最有效的一种评论方法是插入链接:
1. 被调用函数的在线文档
2. 正在生成的指令的说明文档,插入
3. 代码解决的问题
4. 函数试图实现的功能的说明
然后我修复了文本编辑器,使其能够点击这些链接。
我还修复了反汇编器,为每条指令添加了指向指令说明页面的可点击链接。
在我们还不能点击任何东西的时候,我曾做过这样的工作,以了解编译器在生成代码时的想法。那很有趣。
我觉得鲍勃大叔的风格有些奇怪,他更喜欢读取和修改共享状态,而不是接受参数的纯函数。当我读到 registerTheCandidateAsPrime() 方法(取自 UB 的重写)不带候选参数时,我不禁两眼放光。
你如何对这些方法进行单元测试?你必须直接设置字段值,然后调用方法,再对字段进行断言。如果答案是 “你不会对私有方法进行单元测试”,那完全没问题,因为我同意这一点(也许私有关键字隐含了这一点,我不懂 Java)。但我很难想象,如果你像 Bob 推荐的那样严格遵守 TDD,你将如何使用这些私有方法。像 increaseEachPrimeMultipleToOrBeyondCandidate() 这样的方法相当复杂,如果不能直接使用这些方法,那么使用 TDD 构建起来就会很棘手。
如果不出意外,鲍勃的方法肯定不是线程安全的。如果同时调用 PrimeGenerator3.generateFirstNPrimes(),它们就会互相践踏。约翰-奥斯特豪特的无状态版本就没有这个问题。
这个主题中有很多关于 Bob 大叔的负面评论。我个人并不喜欢《清洁代码》(Clean Code),而非常喜欢《软件设计哲学》(A Philosophy of Software Design)。
我承认任何非小说类书籍都会夸大自己的价值,因此我尽量选择比较温和的方式。从这个角度来看,《简洁代码》给我的启发不大,但《简洁架构》给了我启发。Clean Coder》也是一本关于软件专业性的有趣读物,而《Clean Agile》则是一本关于敏捷根基的有趣读物。我不认识任何实践 “真正的 ”敏捷的人(我自己也不愿意这样做),但其中有一些非常可靠的观点。
我知道 “清洁代码 ”有一种类似于邪教的追随者,人们盲目地追随它,但该死的是,有些评论对鲍勃大叔实在是太无礼了。我还是觉得他是个不错的作者,他的其他书也给了我一些建议,对我这个刚入门的开发人员帮助很大。
鲍勃叔叔对我们的行业产生了可怕的影响,我们正在表达这一点。他实际上什么都没做过,却能靠贩卖自己缺乏经验的观点赚到钱。
有人像我一样,一开始跳过了这篇文章,因为从标题上看,似乎有人只是比较了两种方法:
不,这是真正的约翰和鲍勃之间的辩论。他们互相辩论。读来令人惊叹。
我只知道约翰-奥斯特豪特与平均水平比 UB 高得多的开发人员共事,这可能会影响他们的偏见。
他还与更多的学生一起工作,处理学生规模的项目、问题和代码寿命。他用自己的书上课,我认为这本书的水平适合大一学生。
两本书都很糟糕,但《APOSD》是我最不喜欢的技术书籍。CC》至少让我看到了批评者过于不近人情的一面。Kernighan和Pike的《编程实践》比这两本书都好得多。https://antirez.com/news/124 是目前为数不多的关于评论的优秀论著之一,作为一个行业,当 “做错 ”的代价通常很低时,我们对评论的关注就太多了。
在阅读《APoSD》时,我的一个想法是,这本书一直在倡导 “有阅读能力的编程”,但却从未达到这一点。
显然,斯坦福大学存在着某种紧张关系,因为新生被教导要保持方法/函数的简短,而软件设计课程的先修课程是CS140,而CS140又需要CS107或EE108B,CS107需要CS106B,所以这门课程可能要到四年学位快过半时才能选修(课程页面上有说明,近期毕业的学生将优先选修)。
尽管如此,阐述基本原则和前提以及反过来支持这些原则和前提的经验还是有价值的。通过阅读您的链接,这似乎与我对 APoSD 评论建议的理解非常吻合,这不禁让人好奇,如何才能让它成为初学者可以使用的某种语言的入门课程课文。
APOSD》有什么好不喜欢的?
这本书给我的印象大多是合理的建议,没有一条是过于规范的。我不同意的地方也不多。
这主要是我读完后写下的内容。的确,“合理性 ”是问题的一部分。
赞同的东西之所以赞同,主要是因为它是如此直白的陈词滥调。“不重要的东西应该隐藏起来,而且越多越好。“但重要的东西就必须暴露出来” 好吗?有人想反驳吗?这不是在教或学什么新东西或有价值的东西,它甚至不像 CC 说得那样容易引人争论。我还希望这本书这么短,能简明扼要,但可惜,书中充斥着这种东西。最后一页的设计原则总结也是类似的。你可以对其中的一些原则提出质疑,但争论可能只是在充分理解术语的含义以及假设的背景情况。很多建议都取决于背景!在这本书中,语境并没有真正被提及。举个例子,书中只有一个非常细微的提示,即作者意识到为代码读者写作意味着读者来自特定的受众,通常是你的同事,这就给了你某些实惠,而这些实惠对于随机的博客作者来说是没有的。
在书中没有提到的其他地方,作者曾经写道:”面向对象语言的强类型化鼓励了难以重复使用的狭义包。每个包都需要特定类型的对象;如果两个包要协同工作,就必须编写转换代码,在包所需的类型之间进行转换。这实际上是一个细微的问题,值得讨论。是静态类型还是动态类型,是半生不熟的 OOP 系统还是完全成熟的 OOP 系统,这些都是非常重要的上下文。但在他的 “哲学 ”中,这似乎是一个完全没有考虑到的问题,甚至在最后一章中,当他强调 OOP 是一种 “趋势 ”时,你也会认为这应该是一个合适的问题。
作者的许多咆哮似乎都是对 Java 的抨击。好吧,随你怎么说,不过 Java 对这些抱怨也有回应(尤其是现代 Java)。(尤其是现代 Java)。
最后,也是我最先抱怨的地方,因为这也是本书的开头(包括封面设计),他将简单/复杂与主观的容易/困难混为一谈,从而使简单/复杂的定义根基不稳。我本希望在希基(Hickey,Clojure/”Simple Made Easy “演讲的成名人物,不知者请留意)之后,人们能理解复杂性是客观存在的,但可惜的是。虽然这种认识并不是 Hickey 首创的,但在当下,我认为不同意这种认识是很值得商榷的。所以,这本书 “就本书而言……复杂性是指与软件系统结构有关的、使人难以理解和修改系统的任何东西”。对不起,这不是一个有用的复杂性定义,现在整本书都因为这个自定义定义而变得更难阅读/更容易误读。好吧,至少它明确指出了是自定义。
除了 “复杂性”,他还可以选择另一个词,但我认为这不会有太大影响。主观的 “易/难 “正是 Ousterhout 想说的。
归根结底,他(以及我们所有人)所面临的问题是,”好的软件设计 “并不能通过正确的凌婷规则集或静态分析来衡量。因此,如果你试图将概念逐级分解,同时又要保持一个应该包括所有软件的范围,这可能意味着在某些地方不可能不显得软弱和不具体。我仍然认为他在总体上取得了很好的平衡。
我同意可以围绕背景和受众展开更多讨论。Ousterhout 说:”如果你写的代码在你看来很简单,但其他人却认为它很复杂,那么它就是复杂的”,但如果我团队中的每个人都换成了几乎没有写代码经验的新员工,又该怎么办呢?难道同一个代码库从简单变成了复杂?
如果你认为 APOSD 中你同意的部分都是简单琐碎的,那么显然只有你不同意的部分才值得考虑。APOSD 不是一篇学术论文,它并没有宣称自己是完全真正的原创。你大概是个程序员专家,所以《APOSD》中讨论的大部分内容似乎都是 “直截了当的陈词滥调”,这完全说得通。对你来说,这些内容微不足道,显而易见。但对于只有一年工作经验的刚毕业的学生来说,这些内容却是新颖且信息量大的。也许你应该采纳自己的建议,考虑一下背景。
在讨论软件开发和架构时,你会提出怎样的复杂性定义?
复杂性:扭曲在一起的事物。
你可以计算事物,也可以计算扭曲。当一组事物的扭曲(甚至结)少于另一组事物时,它就比较简单。当你拉扯某样东西时,如果它与其他东西通过缠绕连接在一起,那么你面对的就是复杂性。当你有意将事物缠绕在一起时,你就是在创造复杂性。你可以说你是在把事物 “组合 “在一起,一旦完成,它们就 “组合 “在一起了。
从编程的最小细节,如状态(与简单的永恒不变的值相比,值和时间的缠绕更为复杂),到最大的模块化问题(由更小的事物组成的系统的属性;当你可以断开这些事物,而无需将它们彼此解开时,你就实现了更简单的设计),都与此相关。
这与简单或困难是两码事,尽管我们可以断言,一个更简单的系统往往更容易改变,因为你不一定需要同时处理那么多缠绕在一起的事情。但这并不是必然的,因为我们程序员在复杂的事情上会越学越好,以至于他们会感觉非常容易,而且我们也喜欢制作工具来试图解决复杂性的来源,无论是问题领域固有的复杂性,还是我们不必要地强加给自己的复杂性,一旦你学会了其中的一些工具,对真正复杂的系统进行更改就会变得非常容易。复杂的东西有时也会非常有用,尤其是当它们声称能解决问题,而你只想昨天就解决问题,而不关心如何解决的时候。但无论如何,简单还是复杂是一个属性,无论谁看它都是一样的。根据 APOSD 的定义,程序中不可变集合这种基本的东西会让人更难理解,因为大多数人在基础教育中都没有学过这些知识,而且很多语言也没有把它们作为标准库的一部分。从本质上说,它们是陌生的。即使你已经习惯了它们,根据你要做的事情,它们仍然会有点难用。但是,不可变集合比可变集合更复杂吗?不会。
推荐观看:https://www.youtube.com/watch?v=SxdOUGdseq4
与其读 “简洁代码”,我更建议大家读以下两本书中的一本
https://en.wikipedia.org/wiki/Code_Complete
https://en.wikipedia.org/wiki/The_Pragmatic_Programmer
今天,我不会推荐《代码大全》;我认为《编程实践》涵盖了大部分相同的内容,篇幅更短,写得更好,而且没有受到麦康奈尔后来接受的蛇蝎心肠的方法论的玷污,其中一些方法论被写入了《代码大全》第二版。当《CC》改变我的世界时,TPOP还不存在。
我很久没有读过《代码大全》了。我手头没有这本书,但我很确定它是第二版,因为它的封面是灰色的。而且我敢肯定,我拿到第二版的时间比现在更接近它出版的时间。
我记得当时这本书很不错。我不记得里面有什么太火的片段。老实说,我只记得对这本书的总体满意度(?如果你问我究竟从《代码大全》中学到了什么并应用到了现在的工作中,我也说不清楚。
你认为书中的哪些内容是 “蛇油”?他们推荐匈牙利符号还是什么奇怪的东西?
我刚刚读了第二版中一些比较有问题的部分,虽然我认为其中有很多值得批评的地方,但我怀疑这只是玫瑰色的后见之明,或者说是无知,让我没有反对第一版中的这些内容;我很确定这些问题中的大部分已经存在了:
– 所有的时间都浪费在了愚蠢的 “建筑 “类比上。
– 完全不关注开源软件,可能是因为他被自己的 “建筑 “类比误导了。根本不存在什么可自由再发布的橱柜,因为你的房子与当地的核反应堆共用它,所以它就格外可靠;也不存在什么腐烂的地板托梁,你不通过源代码许可谈判就无法修复。(他确实讨论了购买图书馆的问题,就像你可以购买橱柜而不是建造橱柜一样)。虽然这在第一版中肯定也有所欠缺,但在 01994 版中这是一个更容易被原谅的疏忽。
– 对自动测试的关注非常少;直到第 22 章我们才接触到 “开发人员测试”,而即使是 “开发人员测试 “也主要是关于手动测试的,尽管在第 4.4 节和第 9.4 节中有一些关于单元测试和测试优先编程的不经意的论述,但没有解释其含义。即使他在第 22.2 节试图解释 “测试优先编程 “时,也没有暗示我们谈论的是自动测试。最后,我们在第 22.4 节中看到了 “测试代码 “和 “JUnit”,然后在第 22.5 节和第 22.6 节中有关于实际自动化测试的信息,尽管没有任何实际的测试代码。关于测试用例设计的建议仍然非常好。
– 此外,关于源控制的内容也很少。 我知道这是第一版的不足之处,因为我还记得在读了几年之后才了解到 RCS 的启示。 我认为第二版的情况实际上有所改善;在第 30.2 节中有一个 “版本控制 “小节,它将你引向第 28.2 节 “配置管理”,其中谈及了一些问题,但没有提到 Subversion(02000 年 10 月首次发布)、CVS(01990)、RCS(01982)、SCCS(01973),甚至 Visual SourceSafe(01994)。 因为这是 “管理建设 “一章,所以它主要描述的是通过官僚机构手动实施类似流程。 但是,在第 668 页,有半页的篇幅对版本控制软件大加赞赏,称其为 “团队项目中不可或缺的软件”,但却没有提到一个可以使用的程序。 这在 01994 中是可以理解的疏忽,而在 02004 中则是不可原谅的。
– 虽然他在书的开头口口声声说项目阶段和项目活动是独立的,但后来却经常把它们混为一谈,经常假定一种准瀑布模型(虽然他并没有直接提倡这种模型),即需求分析阶段之后是架构阶段,然后是详细设计阶段,然后是 “施工 “阶段,然后是测试阶段,最后是维护阶段。 这显然与微软 Windows、Emacs、Linux、GCC 和 Facebook 等项目的实际情况完全不同。 Facebook 是什么时候开始从详细设计转向构建的? 如果它在开始 “建设 “之前花一两年时间进行架构设计,会不会成为一个更好的社交网站? 不过,他在这个问题上确实经常反反复复,有时主张采用循序渐进的方法,有时又在一页之后自相矛盾。
– 与此相关的是,他提倡一种分工,即 “架构师负责需求;设计人员负责架构;编码人员负责设计”。 (传统上,虽然他没有这么说,但质量保证测试人员随后会使用代码)。 这种分工方式已经尝试过很多次,但尝试过的公司大多被分工不那么混乱的公司所淘汰;它们大多只能在合法垄断的利基市场中生存,比如国防部的成本加成主承包商。 它们都无法生产出与 Linux、GCC 和 Facebook 等产品相媲美的优质产品。 我认为这是本书中最蛇蝎的部分。
– Code Complete 的表 3-1 列出了 “根据引入和检测缺陷的时间来确定修复缺陷的平均成本”,该表令人信服、令人信服、脚注详尽,有几十年的文献资料,而且完全是编造出来的。 见 https://softwareengineering.stackexchange.com/questions/1637… https://web.archive.org/web/20121101231451/http://blog.secur… https://www.lesswrong.com/posts/4ACmfJkXQxkYacdLt/diseased-d… https://gist.github.com/Morendil/258a523726f187334168f11fc83…. 这些捏造的数据是麦康奈尔主张类似瀑布模型的主要理由。 与此相反,通过实证而非道听途说对这一问题进行调查的最新研究发现,”我们没有发现延迟问题效应的证据;也就是说,在后期阶段解决问题的努力并没有持续或显著地大于在问题提出后不久解决的努力。” https://arxiv.org/pdf/1609.04886 https://agilemodeling.com/essays/costofchange.htm https://buttondown.com/hillelwayne/archive/i-ing-hate-scienc….
– 关于 “用户界面设计 “的部分糟糕得令人生厌。 他认为你可以在没有工作软件的情况下设计出一个好的用户界面(”用户界面通常在需求时就已经指定了。 如果没有,就应该在软件架构中加以规定”),而不是循序渐进地对使用工作系统的人的可用性反馈做出回应。 这部分很短,而这本身就令人瞠目结舌;可用性是大多数类型软件的核心问题,也是软件最具挑战性的方面之一。 实际上,大多数软件中的几乎所有内容最终都应由用户体验驱动,并以可用性测试为基础。 游戏、网站、浏览器,甚至编译器,都因可用性而生,也因可用性而死。 但麦康奈尔却将其视为众多细节中的一个小细节。
– 关于 “架构先决条件 “的部分听起来像是 IBM 大型机程序员在 01978 年编写的,然后用一些 OO 和 WWW 术语进行了装饰。 是的,架构显然应该 “描述要使用的主要文件和表格设计”。 这是有道理的。 是的,”输入/输出(……)是架构中值得关注的另一个领域。 架构应指定先读、跟读或及时读取方案”。 我是说,真的吗? 请注意,”客户端”、”服务器”、”层”、”缓存”、”协议”、”网络”、”消息”、”队列”,甚至 “进程”(指程序的运行实例)等词在这里都完全不见踪影。 这并不是说他使用了不同的术语,而是他根本没有使用任何词语来谈论它们。
– 他试图用 “一个数字的平方根 “这个完全无厘头的例子来讨论 “容错”,结果必然是把这个话题弄得一团糟。 他只字未提任何能真正实现容错的技术,如无状态、幂等、端到端原则、事务、日志、故障停止、校验和、磁盘镜像、硬件三重冗余、看门狗定时器、ECC、异常检测、网络超时、监控、警报等。 唯一的例外是,他提到了粒度重启。 在此,我仅限于他撰写本书第一版时众所周知的技术,不包括 Paxos、最终一致性和梅克尔图等内容。
– 在很多情况下,他都在重复一些他听过但显然并不理解的东西。 例如,他在第 4.1 节中试图描述 Fortran 程序员用 C++ 编写 Fortran 的情况,却完全忽略了实际的主要困难(主要是将数据结构化为数组,而不是控制流);他还说:”汇编程序被视为第二代语言”,但却没有任何历史背景来说明这句话的含义。
– 我认为第二版中的一个新问题是,它假定所有软件都是面向对象的(尽管口口声声说Visual Basic [6]是专业程序员中最流行的语言,但许多人仍在使用Ada、汇编、Cobol、C和Fortran等语言编程)。 我认为 OO 是一种有用的软件设计方法,但如果我在写一本关于如何设计程序的通用教程,我就不会像 McConnell 在第 5.2 节中所做的那样,把其中的一个步骤称为 “第 3 级:划分为类”,因为这会让我的书完全不适用于 C、Go、Fortran、Rust、Racket、VB6 或 Clojure 中的编程,也不适用于人们在 Python、PHP、JS、Octave 和 R 中所做的大部分工作。 这里的 “蛇油 “并不是面向对象,而是一种全面化的意识形态,即所有东西都必须是面向对象的;第 6 章的引言说:”在 21 世纪,程序员用类来思考编程”。 在我的印象中,第一版没有这个问题。
– 由于他根本不了解面向对象,所以他给出了很多糟糕的建议,比如 “面向对象程序中很大一部分例程都是访问例程,这些例程将非常简短”,§7.4。 他的第 6 章整章都在讲类的设计,但他从未提及面向对象的实际核心概念,即多态消息发送,这大概是因为尽管他知道多态消息发送的存在,但他并不太习惯使用多态消息发送,也不了解多态消息发送在 OO 世界观中的核心地位。 相反,他把类看作是 CLU 的 “簇 “或 Ada 的 “包 “的新式同义词。 这一章的大部分篇幅都在讨论如何解决 C++ 的缺点。 这不是真正的 “蛇油”,只是无能而已。
– 另一个我确信在第一版中没有出现的问题是,几乎每一章都推荐了毫无价值的 IEEE 管理过程标准,结果适得其反。 这有点像 “蛇油”;这些标准的文学质量令人憎恶,而且不包含任何有用的信息,根本无法帮助任何人改进他们编写的软件。 作为一个代表性的催吐例子,请查看第 20 章和第 21 章推荐的 IEEE 1028。http://profs.etsmtl.ca/claporte/english/enseignement/cmu_sqa….。 与我不幸读过的其他一些 IEEE 管理过程标准不同,这本书至少看起来没有包含任何错误信息,但这是因为它设法用了 47 页的篇幅对软件问题只字未提。
书中仍有许多可靠的材料,也有许多好信息的参考文献,但其中夹杂着许多严重的错误信息、误导性类比和令人尴尬的无能。 要看完这么多的文字,实在是一件很费劲的事情。 但它肯定比《简洁代码》要好。 不过,既然《编程实践》、《务实的程序员》和《软件设计哲学》都已出版,我想就没有理由再推荐《代码大全》了。
我读过《清洁代码》,但一点印象都没有。公平地说,那是很久以前的事了。
但 SOLID 和 Clean Architecture 原则几乎每天都在影响着我。
这显然是一种平衡。我在这两种环境中工作过,我倾向于欣赏那些至少读过这些书的人的代码,但他们把这些书当作建议而不是福音。相比之下,有些人从来不看书,不知道什么是 “好”,什么都黑。
一方面,这些书之所以受欢迎,是因为很多人读了这些书后认为书中的观点很有道理,并赞同这种观点。另一方面,流行并不代表正确!我认为这就是人工智能错误百出的地方。GIGO!如果你的所有代码都基于最常见的模式,那么你真的确定这种常见模式真的是最好的吗?人工智能,还有这些书的布道者,往往毫无头绪。只是鹦鹉学舌而已。
相对于 “规则”,我更愿意每次都遵循 “原则”。从书本中汲取原则,至少试着写出简洁的代码!
对于编码 “最佳实践”,我又回到了初级工程师的态度: 避免任何类似教条的东西。
他们使用的例子无关紧要。一个已经解决的问题可以随心所欲地编写。
真正的挑战在于,代码会发生变化,或者永远不能被视为最终代码。
将代码过度切割成方法会使代码变得僵化。 我想这可能是问题的关键所在,但如果你需要更改方法名称才能反映方法的意图,那你就写出了经典的无益注释:
// check a is not null
if (a != 0) { … }
过度使用注释与过度使用方法有着同样的问题。
没有严谨性,注释和方法名称就会开始说谎。
因为它们的内容/名称并非理解代码所必需。它们本来就不应该存在。
质数代码读起来让人心疼。我觉得鲍勃和我们大多数人生活在不同的现实中。
是的,不,我认为他的思维能力与大多数人(至少是这里的大多数评论者)不同,因此实际上是生活在不同的现实中。人类大脑的功能大相径庭。有两个例子让我印象深刻。
1) UB 说他以 if(isTooHot) 为例,从左到右完整地阅读了代码。我只有在实在搞不清楚代码在做什么的情况下,才会采取这种阅读方式。我的意思是,我会把代码块或行看成一个整体。
2) UB 说注释很烦人,因为他在阅读注释时必须把整个注释文本记在脑子里。这又说明他是从左到右阅读所有内容的,而且他很可能可以把阅读过的所有内容存储到一定量。
我的大脑可不是这样工作的。我的工作记忆只能存储很少的单词,但可以存储概念/想法。为了让工作记忆更好地发挥作用,我需要尽可能多地查看相关代码,如果我必须在开始的地方浏览太远,我的思维图像就会消失。
> 对我来说,软件设计的基本目标是使系统易于理解和修改。我用 “复杂性 “一词来指那些让人难以理解和修改系统的东西。
这就解释了现代软件的所有问题。
当你设计一级方程式赛车引擎时,引擎设计的目的不是 “让引擎更容易修改”。而是为了赢得比赛。而这取决于比赛–滑稽赛车引擎、一级方程式赛车引擎、勒芒大赛引擎、纳斯卡赛车引擎等,都是不同的,因为比赛是不同的。
再举个例子:当你设计一栋建筑时,目标并不是让人们更容易理解这栋建筑。目标是满足建筑物的要求、用途、要求、环境等。有时,更好的建筑只是更复杂而已,让建筑师或建筑工人的工作更轻松,虽然很好,但并不是目的。
有些东西本来就不应该简单易懂,因为简单易懂并不是事物的目的。专注于事物的真正目标,并实现这一目标;不要被附属目标所干扰。
软件的特殊性在于它永远不会完成。这就使得软件的关键品质之一是易于修改,而一级方程式赛车引擎或大多数建筑则不具备这种品质。
易于改装是 T 型福特车设计的首要任务之一,因为汽车坏了必须修理,而一辆难以修理或无法修理的汽车会让车主损失一大笔钱。软件不会坏(尽管在线服务会坏),但由于其他原因,软件的修改也是重中之重。
也许寿命较短的建筑不需要很容易改动,特别是如果建筑师对用户在使用期内的需求有很好的了解。但事实往往并非如此,克里斯托弗-亚历山大之所以出名,很大程度上是因为他的大部分职业生涯都致力于研究如何让建筑物的居民更容易地对建筑物进行改造,这样即使建筑师在几十年前猜错了,他们的需求最终也会得到满足。拥有数百年历史的石头农舍也是如此,易于改建对它们来说至关重要;如果无法改建,最多一两个世纪后它们就会失去功能。
无论软件的目标是什么,对于长期修改软件以实现目标的人来说,能够理解软件是至关重要的。
一个天才的建筑师/公式 1 工程师可以设计出一个惊人完美地符合所有要求的建筑/发动机,但如果建筑工人难以理解,或者后来的承包商不知道如何维护或修复任何东西,那么这只是一个理论上完美的设计,而在现实中却是一个糟糕的设计。这不是一个让人们的生活更轻松一点的美好愿望,它决定了项目的成败。天才建筑师/工程师可以坚持认为复杂的设计反映了底层领域,但在某些时候,他们必须向其他不是天才的人证明这一点。
显然,编写代码时让一个计算机科学一年级的学生就能理解发生了什么并立即开始贡献自己的力量是荒谬的,但与此同时,没有人会在真空中构建任何东西。在任何情况下,设计都需要一定的可读性。
大多数人并不是在制造一级方程式赛车。建筑物是一个更好的比喻:建筑物的设计是为了维护。你可以在不更换门或墙的情况下更换门把手,你可以关闭不同区域的电源来进行维修。危险或复杂的部件都会贴上标签,搬进各自的房间或橱柜,并上锁。
软件设计的首要目标应该是便于未来的开发人员理解和修改,强调代码可读性的重要性。
在我看来,Clean Code 中的 “PrimeGenerator “示例非常糟糕,完全无法阅读! 如果用一个方法/函数来解释算法,再穿插一些注释,效果会好得多。 我的意思是,看看这个可恶的东西:
这种方法不仅本身毫无意义,而且还有副作用!谁会从方法名称上想到这一点呢?自我文档化代码也不过如此…… Ousterhout 正确地指出了他的错误。
事实上,Ousterhout 的观点非常精彩,以至于我很想读读他的书。相反,我现在更不想读《清洁代码》了。
> 这篇文章作为一个单独的方法/函数,再穿插一些解释算法的注释,效果会好得多。
写这篇评论时,我还没读完整篇文章。事实证明,Ousterhout 提供的重写版 “PrimeGenerator “正是如此。至少 UB 承认这个版本确实要好得多。
说到这个话题,我非常喜欢的一本书是《美丽的代码》[1]。这本书没有说教或规范,而是由不同的程序员展示他们喜欢编写的代码。
[1] https://www.goodreads.com/book/show/405790.Beautiful_Code
我只是在思考人工智能辅助编码给设计讨论带来了什么,尤其是当人工智能变得越来越强大,我们越来越依赖它的时候。你仍然希望把东西做成模块化并易于理解,这样人工智能就能很容易地理解它,修改模块所需的信息也能在一个相对较小的上下文窗口中找到,但不同的是,我们可以很容易地对哪种代码风格更容易被 LLM 理解进行大规模的测量,所以其中的一些争论可能会相对客观地做出决定!
至于话题:讨论的话题都是相对琐碎的表层内容,大多数情况下我都同意 POSD 的观点,但无论如何,这些都将由人工智能来处理。我想,人类将利用剩余的脑容量来处理真正的深度设计问题(暂时如此)。
Clran 代码只是优秀软件工程师工具箱中的工具之一。
回想起来,我对此类书籍的态度表明了我对编程的理解程度。在瞬息万变的行业中,这些书曾经是一个人在职业生涯早期死死抓住不放的救命稻草。后来,它们变成了一个有趣的旁注,提醒人们他们所坚持的是什么,因为那些对他们有用的好戒律在其他书籍中脱颖而出。最后,当一个人已经游刃有余时,它们就变得没有必要,而且似乎是教条式的。换句话说,这本书是必读书,就看你在什么地方找到了自己:) 免责声明:在这两本书中,我只读了《清洁代码》。
虽然我很喜欢这次讨论,因为这是将立场剥离到基本原则的一次练习。我发现一个极大的讽刺,那就是他们在什么是 “良好实践 “的问题上产生分歧的根本原因并没有得到讨论。
约翰听起来像是要开始构建一种新型数据库,而鲍勃听起来像是在为一家物流公司处理一个已有 20 年历史的代码库。他们的立场都很合理,都针对具体情况进行了优化。
我觉得鲍勃的回答更有分寸(我非常看重这一点),而约翰的回答有时更有说服力。我同意过度合成是一个真正的问题,鲍勃在这个问题上站错了队。但公平地说,鲍勃和 “简洁代码 “所处的时代恰恰相反,他在这方面的立场给人的感觉就像是一种哲学,其核心是过度修正(尽管不一定是缺陷)。
他们说的基本都对,但细节决定成败,尽量不要过于教条。例如,函数长度就是一个让人纠结和争论不休的问题。
提取一个只使用一两次的函数有什么价值?可能非常有限。是否应该将其作为一个公共函数,是否应该鼓励更多的使用,这些都值得商榷。我们还可以看看函数的声明。它是否有很多参数?它的实现是否复杂?是否有测试?函数是否会被大量使用?如果所有这些问题的答案都是否定的,那么你或许可以内联它而不会有太大损失。但反过来说,这样做也不会有什么好处。一个经常被使用的小函数可能是有价值的。
还有第三点需要考虑:函数是否会增加模块的 API 面。拥有大量私有函数会使模块难以理解。拥有大量公有函数会降低 API 的凝聚力。
因此,这里存在一个灰色地带。像 Kotlin 这样的语言为你提供了更多选择:将其作为嵌套函数、作为扩展函数、放在 Companion 对象中,等等。你可以把函数放在函数中,这样有助于提高可读性。这样做的目的是防止在外部函数的上下文之外使用函数。嵌套函数应该非常简短。而且它们的唯一目的应该是使外部函数的逻辑更可读/可理解。我并不经常使用嵌套函数,但我发现它有一些用途。除了可读性,使用嵌套函数没有其他意义。
说到 Kotlin,它的标准库充满了非常小的扩展函数。它们大多只有一两行。它们显然很有价值,因为人们一直在使用它们。你会得到诸如 fun List.isNullOrEmpty(): Boolean 函数,它能让你的 if 语句更易读、更不容易出错。它还适用于 Java 列表。我之所以喜欢 Kotlin,很大一部分原因就在于此。
我倾向于将很多建议简化为内聚性和耦合性,就像双方在这里争论的一样。在函数中,耦合是通过参数和副作用(例如通过参数修改状态)而不是返回值来实现的。如果一个函数开始做太多不相关的事情,就会失去内聚性。高耦合度和低内聚性通常意味着可测试性差。你会发现自己模拟参数只是为了测试一个函数。提高可测试性是提取更小、更易测试的函数的合理理由。
鲍勃大叔可能是软件业最大的骗子。真是一堆垃圾。这么多精力都浪费在这些设计模式、SOLID 和其他 OOP 扯蛋上了。
原来你只需将不可变的数据传入,就能得到不可变的数据。谁能想到呢?整个 90 – 00 年代的 Java OOP 垃圾仍然让我噩梦连连。
这其实是一篇很棒的文章。
我正在为客户设计一门课程,教授数据科学家(以及那些整天生活在 Jupyter 中的人)软件工程最佳实践。
对于这些不是 “程序员”、不使用 “集成开发环境”、基本上整天都在写代码的人来说,似乎非常缺乏教材。
UB 有一次说
> 我们要是有水晶球就好了
然后,他似乎真的找到了他的水晶球,因为在下一个问题中,他提到了对话中尚未发生的事情:
> 解读你的改写(下)
然后是
> 在你的解决方案中,我们很快就会看到下面的内容
这让人读起来有些困惑,因为答案是基于未来才会出现的反驳观点。(我想,这与 Ousterhout 在 UB 的 PrimeGenerator 例子中遇到的问题类似)。
埋在最后:《清洁代码》计划推出第二版!鉴于鲍勃在这次对话中的不妥协态度,我想知道他会改些什么。
相关链接:https://news.ycombinator.com/item?id=27276706
对我来说,”只做一件事 “在 “单层抽象原则”(Single Layer Of Abstraction Principal)的背景下可能是最好的理解–它曾无数次地帮助我在复杂代码中有意识地遵循 SLAP,而 “只做一件事 “似乎是它的自然延伸。
这真是一本引人入胜的文学读物,我喜欢它,爱不释手,就像小说中的人物一样!
在软件工程领域还有其他类似的读物吗?
克努斯?
他过去曾提出过 “文学编程 “的概念。
http://literateprogramming.com/
我强烈推荐他的各种演讲稿/论文集,包括
https://www.goodreads.com/book/show/112245.Literate_Programm…
编程纪律》,Dijkstra
并发程序的架构》,Per Brinch Hansen
书面编程–参见 http://www.literateprogramming.com/
我不推荐 “设计模式”,因为如果你的编程语言不够完善,就需要这些元素。
还有更多。
现在还有人向鲍勃-马丁道歉吗?
我对首要例子的看法:
有些人可能会觉得文档和注释太多了–我觉得注释有助于将代码与文档联系起来。欢迎提出建议!
清洁度和设计与输入输出、副作用和状态高度相关。
大多数程序员在 2025 年就知道这一点了,但那时他们还不知道。看起来作者甚至都没有提到这一点。
> 我抱怨我们有时必须使用人类语言而不是编程语言。人类语言不精确,充满歧义。使用人类语言来描述像程序这样精确的东西是非常困难的,而且充满了许多出错和无意误导的机会。
鲍勃大叔的这段话令人汗颜,因为他的职业生涯百分之百是在写英语,而不是写代码。
与之形成有趣对比的是 Ousterhout 的观点:
>如果你能将一个系统可视化,那么你就有可能在一个
>计算机程序…. 这意味着,编写
>软件的最大限制在于我们理解所创建系统的能力。
有趣的是,这与 “软件设计书 “谷歌邮件列表中的另一种说法形成了鲜明对比:
>约翰-奥斯特豪特(John Ousterhout),2018 年 8 月 21 日,下午 12:30:15
>我从来不觉得图是一种特别有用的描述软件结构的方式。
>类之间的交互最终会变得如此复杂,以至于图变得一团糟,无法阅读。
>此外,我不确定软件图形表示的复杂性是否与其
>实际复杂性相关(图形表示法可能看起来非常复杂,但软件可能
>还是很容易维护的)。
如果有人知道解决这个问题的文本/视频/访谈,或者有人提倡/推荐哪种可视化方式,我会很感兴趣。
结构图对我来说很少有用,但数据流的可视化是我思考代码的一般方式。有时它类似于图表,但更多的是踌躇不决,我脑子里只有手头任务的相关部分,而不是全部。
我们把这一领域从工程学降级为哲学,这让人很难过。但它就是它。
下一步–时尚和信仰。
比起蛊惑人心和盲从规则,这是一种进步。
此外,书中还论证了工程学原理(在几乎所有可能的意义上)。
当然!
我不是说哲学不好。也许制作软件从来就不是一门工程学科。我是说做衣服、法律和音乐就不是。这很好。
但工程学确实意味着要遵守一些规则。
哲学是理性、数学和科学的基础。”工程师 “不理解哲学或哲学的重要性,这太可悲了。
工程师相信定义。根据定义,哲学不是一门科学学科,因为一旦一门学科变得科学化,它……就不再是哲学了。
正如亚历山大-皮亚季戈尔斯基(Alexander Pyatigorsky)的名言:”哲学的价值在于没有人需要它”。
我的意思是–不理解哲学概念(如伦理、逻辑)以及这些概念如何导致科学方法的工程师确实很可怜。
没有哲学的科学只是科学主义,它会导致工程师创造出以前无法想象的恐怖!
看到谈论这个话题总是令我着迷,因为我在一个利基领域(音频插件 DSP 开发)编程多年,也曾与清洁代码程序员交流过,但我似乎完全无法理解他们的工作。
现在的情况是,为了编程和做我想做的事情,我几乎要把所有东西都手写出来,以重复的方式展开,并以代码块的形式组织东西,每个代码块之间用注释隔开,注释说明每个代码块要做什么。我可以如此有预见性和规律性地完成这些工作,以至于我的代码被其他人的更清洁代码(Clean Code)所解析,并被收录为可用于其他软件的程序行为块。
我不得不学习一些有用的东西,了解我的方法在哪些地方没有发挥其假设的优势:漫不经心地展开一切并不能提高速度,我不得不学习在更靠近变量使用的地方声明变量。但我也不得不认识到,为了提高性能,我可以采取与 “清洁代码 “相反的做法。在过去,你可以为计算分配变量,以避免 “自我重复”(Repeating Yourself),但在现代处理器上……除了在包含不同数据的宽数据字上并行运行计算等技术外,你甚至可以利用 CPU 急于做数学运算的特性,避免创建额外的变量。只做几次数学运算比创建一个全新的变量来跳过数学运算更有效率。
这个世界对我来说是有意义的。它就像汇编语言,只不过它是 C 语言(甚至不是 C++)。我不知道其他人在多大程度上也是这么想的,或者说他们甚至连简单的抽象概念都难以掌握。
这只是我看到鲍勃信徒的语境,而不是仅仅声明一个变量不做两次运算,出于看似纯粹的语义原因将其分成大约十二种不同的方法,并坚持认为其他任何方法都是愚蠢的。而我就在这里,编写并重复使用着大量令人震惊的原始代码,这些代码似乎都能正常工作,即使几十年后我再回到这里,也能毫不费力地找出我做了什么。
愚蠢到让你的作品屡试不爽,也是有道理的。
这里的背景至关重要: Ousterhout是伟大的程序员之一,是他建立了我们今天生活的自由软件世界,而鲍勃叔叔是个骗子。Ousterhout 并不是没有问题(Stallman 曾称他为自由软件社区的 “寄生虫”,并坚决反对他的技术品味),但他写出了真正改变世界的软件。相比之下,据我所知,鲍勃大叔只是个写书的风流作家,从未写出过任何值得一用的软件。
奥斯特豪特致鲍勃大叔
> 也许你会惊讶于它的难以理解,但我并不惊讶。换一种说法,如果你无法预测你的代码是否容易理解,那么你的设计方法就有问题。
这场辩论充满了这样的宝藏。这种揭露骗子的方式真是既清晰又低调!
我说的 Ousterhout 推出的 “改变世界的软件 “是什么?Tcl。(等一下,先别急着降票。)Tcl 从 19 世纪 80 年代起就是 EDA 和自动回归测试的关键技术。你正在阅读这篇文章的电脑中的每一块 VLSI 芯片,可能都是通过大量使用 Tcl 的工作流程设计、验证和测试出来的。GCC 的测试套件也是 Tcl。尽管如此
01980 年代的自动测试?是的。诚然,在本世纪初敏捷时代(鲍勃大叔和他那些不那么无能的同胞们)之前,自动化测试在软件界并不十分盛行,但 EE 和编译器工程师们普遍采用自动化测试的时间要比这长得多,信不信由你,Tcl 在很长一段时间内都是最糟糕的选择。这都是 John Ousterhout 的功劳。
你知道在 Tcl 出现之前,SPICE 的开发者是如何让 SPICE 可以编写脚本的吗?他们把 csh 连接到了 SPICE 中。该死的 csh。如果你没试过用 csh 维护一个大型脚本,那你就不知道什么叫痛苦。
好的程序员能写出好的软件;坏的程序员能写出坏的软件,或者什么软件都写不出来。Ousterhout 写出了为数不多的可称得上伟大的软件之一。(鲍勃大叔写了什么软件?
听鲍勃叔叔的编程建议而不听奥斯特豪特的建议,就像听中学英语老师的写作建议而不听斯蒂芬-金的建议一样。并不是说金能给你更糟糕的建议,但如果你需要英语老师的建议,一般来说,你的判断力不足以分辨金在极少数情况下的错误。
我是 Tcl 的忠实粉丝,但 Ousterhout 还创造了许多其他重要的东西–请参见 https://en.wikipedia.org/wiki/John_Ousterhout 。
我认为 Magic 和 Raft 的重要性远不及 Tcl,尽管我可能在某些时候用过用 Magic 设计的芯片。而且,虽然我喜欢 Tk 并觉得它很有启发性,但我记得我使用过的 Tk 应用程序(不是我自己写的)大概只有三个,而且它们对我来说并不重要。
至于Sprite-LFS,我非常喜欢Sprite LFS论文,并认为它很有启发性,但我的结论是,Seltzer的后续BSD-LFS论文篡改了其中一些更令人惊讶的说法,最终关于内存大小和磁盘大小相对趋势的基本预测被证明是错误的,削弱了LFS方法的整体关键优势。隐约类似于LFS的方法对于固态硬盘和SMR磁盘来说非常重要,但WAFL在01995年就已经类似于LFS了(诚然,这是在Sprite-LFS之后),而且固态硬盘FTL也做了一些并不非常类似于LFS的事情。因此,我认为雪碧-LFS最终并没有那么重要。
Sprite 作为一个整体,我不太能够评价。我从来没有研究过操作系统,但我花了不少时间阅读SOSP和HotOS的论文以及系统论文,我不记得除了Sprite-LFS之外,我还看到过任何出自Sprite的东西。我还以为是 Solaris 中的门,但不是,那是 Sun 的 Spring,而不是 Sprite。在海湾的另一边,Ousterhout 最终把 Tcl 带到了那里。所以,雪碧有可能是一项伟大的成就,但我没有注意到。但我觉得更有可能的是,我们尝试了 “显而易见 “的东西(在一堆工作站上使用 SSI),然后发现它为什么不好,这影响了后来的努力,比如 PVM、MOSIX、Beowulf、distcc、MapReduce、Ceph 等,因为 Sprite 踩到了地雷,所以他们不必踩。https://web.archive.org/web/20150225073211/http://www.eecs.b…. 上有一篇不错的回顾文章(作者是 Ousterhout)。
因此,我不认为 Tk、Magic、Raft 和 Sprite-LFS 真的具有与 Tcl 相同的重要性。Sprite也许可以。
我认为,花大量的时间和精力去做那些结果并不重要的事情并不是坏事,原因有两个。一个原因是,经过足够长的时间后,仍然具有重大意义的东西确实很少。(今天,谁还能回忆起米诺斯女王们的失望?)另一个原因是,你能做的有意义的事情–哪怕只是暂时的–通常都很可能会失败。因此,如果你花大量时间去做那些可能意义重大的事情,你将会失败其中的大部分。
但在奥斯特豪特的案例中,有一件事确实取得了辉煌的成功,那就是 Tcl。
很好奇 Stallman 对 Robert C. Martin 有何评价–找了一下,但没找到….。
不是关于马丁的。关于 Ousterhout。27-30 年前。
是的,我很好奇,除了那段著名的评论之外,是否还有 Stallman 对 Bob Martin 的评价。
不太可能。他主要局限于评论政治问题: https://www.stallman.org/archives/2024-nov-feb.html
他到底是谁的叔叔?
所有这些都很棒,但在大多数采用离岸外包的大公司项目中却毫无用处,因为在这些项目中,我们已经很高兴,因为我们首先交付的是真正能用的东西。
我从上世纪 80 年代初就开始编程,从未见过以所谓 “简洁代码 “风格编写的真实生产代码。
在哪里可以找到马丁的代码?我想看看并编译一下。
https://github.com/unclebob/fitnesse
“这种担心是有道理的。不过,由于这些函数是按照调用的顺序排列的,因此这种担心也就不那么强烈了。因此,我们可以预期读者已经看过主循环,并理解候选函数每次迭代都会增加两个”。
我认为这完全没有抓住重点。如果我必须阅读整个代码才能理解该方法的行为,那么它真的更简洁吗?副作用是邪恶的
PDSD 关于方法长度的观点是正确的。CC 示例中给出的方法短得离谱。CC 在注释方面比 PDSD 更正确。特别是在某些地方强制使用注释会导致质量非常低,而且坦率地说,完全令人厌恶的注释指出 “get_height “方法 “获取高度”,这很有帮助。CC 在 TDD 方面比 PDSD 更正确。我们注意到,只关注实现细节而忽视应用程序接口结构的危险始终存在,但 TDD 有一个重构步骤来解决这个问题。在每一小步之间都有一个安全状态,这种小步工作的总体思路是非常有价值的。
软件设计领域两位杰出人物之间的讨论真是精彩纷呈。感谢您的发布!
你们真的讨厌我觉得讨论很有趣吗?
这似乎是一篇没有内容的评论。读完之后,我并没有比读之前获得更多的信息,也没有任何艺术或文化价值。它没有促使我质疑我的任何假设或调查任何事情。它表达了你的经历,但你的经历并没有任何不同寻常或令人惊讶之处,也许除了你不知道鲍勃-马丁 “叔叔 “是个无能的江湖骗子。也许这些就是人们给这篇文章打低分的原因之一。
有道理!谢谢。
降票是不同意见的合法表达,而不是仇恨。
“仇恨 “这个词用得太重了。
顺便说一下,”我也是 “的评论通常会被降权。
现在明白了,他们并没有增加什么,或者说没有增加任何东西。我们已经有了向上投票按钮。
我认为,大多数人之所以对鲍勃大叔有意见,是因为他们知道自己的做法与他的建议相去甚远,他们把他的规范性和毫不妥协的建议视为人身攻击。
我还想知道有多少人把他的建议理解为一个无脑、迂腐的独裁者的建议。
我对 UB 的认识来自于他在 YouTube 上随意发布的一段关于编程语言的视频,因此我对他的第一印象包括他的幽默和他能够看到问题的正反两面,同时不惧怕有强烈的观点。我非常喜欢与那些观点鲜明、酝酿已久的人交谈,也喜欢倾听他们的意见,无论我是否同意。至少这意味着他们经过了深思熟虑,这有助于更好地讨论和学习。
我也没有长期的代码提交历史,更多的是在这里和那里涉猎,所以也许我对 UB 的攻击所能触及的范围较小。尽管如此,我还是承认,清洁代码纳粹分子可能会因为我所遵循的一些做法和我继续遵循的一些做法而把我撕成碎片。但改进是比完美更容易实现的目标,收集有价值的信息总比教条主义要好。
最后,我喜欢听 UB 的演讲。我并不遵循他的所有做法,但我会把它们记在脑海里。如果不值得严格遵守,它们总是值得考虑的,尤其是它们背后的意图。
因此,当我看到他关于评论的观点或关于抽象和变量命名的观点时,我的第一反应不是哀叹他如何毒害我们的年轻人或侮辱我的代码,而是问自己如何利用他的观点。我鼓励其他人也这样做;这样会更有趣,不仅对编程,对任何事情都是如此。
至于那些被困在 “清洁守则 “地狱里的人,他们的上司要求他们严格遵守……这听起来像是个人的失败,或者是个人的不适应,或者两者兼而有之。我会责怪信使,而不是信息。
我对 UB 的看法是,他向初级开发人员大肆宣扬自己的品牌和风格(既有隐晦的方式,也有相当公开的方式),扭曲了很多人的思想,对他们和他们周围的人造成了长期影响。我个人并不反对 UB,但处理他的追随者所创建的代码却让人深感沮丧,而且他们经常声称这是 “最佳实践”,尽管他的大多数主张基本上都没有经过验证,而且如果你阅读了他的批评,就会发现这些都是常识。