剧透警告:本文包含 Grains 练习的剧透,尤其是 Bash 赛道上的 Grains 练习。如果你还没自己完成它,又不想提前看到一些提交的解答,那就等完成之后再回来吧!
这是你到一家新公司上班的第一天。手续都办完了,团队也见过了,终于到了坐下来读一读你以后要打交道的代码的时候。你开始翻阅一个个函数、类、模块,读着读着,不自觉地眯起眼睛,一脸困惑地盯着屏幕。你继续往下读,一个字从嘴里溜了出来,轻得几乎没出声,更像是叹出来的:“什……么……”1 你越读下去,这种情况出现得越频繁,你越来越困惑,甚至有点生气。
这段代码到底在干什么?
只要一段代码有不止一个人参与,想让它维持可控,所需的用心和审慎就会大幅攀升。事情不再只是概念装在你脑子里、代码负责把概念实现出来。现在,概念必须活在代码里面,让所有协作者都能看见,并在需要时修改它。
你怎么实现某个东西,对最终用户来说意义不大,但对每一位在任一环节接触到你的设计的工程师来说,它传递大量信息。实现同一种功能往往有很多种方式,看起来随便选哪种都能把活干完。但我相信,你做出的每一个决定都应该有理由(哪怕只是一个小决定配一个小理由),而这个理由应该传达出某个目标或需求。
实现细节应当帮助代码的读者看出思路、目标和优先级,这个理念就叫设计意图。你怎么给变量命名、函数接收哪些形参、东西怎么抽象,都是可以体现设计意图的地方,体现得好还是不好,就在这些地方。
我坚信,设计意图是进行工程设计时最需要考虑的事情之一。它正是把软件工程与编程区分开来的要素之一。
软件工程,就是给编程加上时间和别的程序员之后发生的事。
设计意图跨越学科
我是一名机械工程师,做的是注塑模具设计,主要面向医疗器械。我的设计一旦完成,就会直接送出门进入机加工车间,师傅们在那里把各个零件做出来,再组装到一起。他们并不知道我设计每一件东西时脑子里想的是什么,所以我必须想办法通过设计本身把我的意图展示出来。
很多时候,某些特征特别关键。要么是客户说那里需要特别严格的公差,要么是模具的配合方式出于某种原因要求极高的精度。所以,为了帮机加工师傅们以优先保证关键部位精度的方式加工零件,我得留出一些特意做成方形、或者方便以特定方式夹到台钳上的位置。这样一来,对他们来说最省事的做法,恰恰能给我带来最好的结果。
也有一些地方的尺寸没那么关键。比如,如果我在设计里开一个只用来排气的孔,我会把它做成 6mm 这样一个好用又常见的尺寸。
他们加工这个孔时,量一下做出来是多少,如果看到 5.99mm 这样的数字,就会想“行,这大概本来就是要做到 6mm,我差不多到位了”,甚至不用回头去核对 CAD 或规格图纸上的尺寸。反过来,如果我把它做成一个不常见的尺寸,比如 5.87mm,他们看到之后,第一反应会是这样:
- 哎呀,我是不是加工得太小了?本来应该是 6mm 吧?
- (去查一下 CAD,发现自己的孔没问题,只是尺寸不常见。)
- 嗯……这个孔做成不常见的尺寸,肯定有原因。也许它特别重要,或者客户特意要求这里做个特殊的孔。我得去找 Ryan 问问,这个孔到底重要在哪。
- (砰!他们把这块铝料轻轻地放到我桌上。)
- (他们发现这个孔根本没什么要紧的,我就是随便挑了个奇怪尺寸,这些多出来的功夫和担心全是白费。)
- 好家伙,那个 Ryan,真是个奇葩。(咕哝,骂了句脏话,继续咕哝)
这一切之所以会发生,是因为我设计中的每一个决定,不管我有没有这个意思,都会向看它、加工它的人传达某种信息。他们只能从中读出意义,因为这是他们仅有的信息!所以,如果我能花点时间,把有意义的、有意图的信息放进设计里,那就好得多。
Grains:简介
现在,我们借 Exercism 上一个练习的例子,聊聊怎么在代码里传达设计意图。最近,我和一位学员一起看了他为 Bash 赛道上的 Grains 练习提交的解答。Grains 这个练习讲的是麦子与棋盘问题。简单说,就是在棋盘的第一格放一粒麦子。下一格放两粒。再下一格放四粒。依此类推,每一格的麦子数都是前一格的两倍。学员需要想办法算出每一格上的数值,以及棋盘上麦子的总数。
这位学员想出了一个相当巧妙的办法来算总数。
bc <<< 'ibase=16;FFFFFFFFFFFFFFFF'
bc是一个命令行计算器。你可以把算术表达式以字符串形式传给它,它会求值,哪怕是非常大的整数和浮点数也没问题。在 Bash 里,不用bc也有别的算法,不过为了简单起见,我们就用bc来看看意图如何传达,或者传达不了。
这个提交的解答之所以行得通,是因为整个练习都围绕 2 的幂展开,而有 2 的幂的地方就有二进制,有二进制的地方就有十六进制2!
这个提交的解答挺巧妙,但代码在告诉我们什么?告诉我们十六进制在这里很重要?还是问题本质上围绕 16 展开?重读一遍题目描述后,很明显这两种说法都不成立。我和这位学员一起头脑风暴了一些能更清楚地传达意图的做法。下面是我们想到的几个:
方案一:二进制
因为我们有一堆东西在不断翻倍(因而也就有一堆 2 的幂),我们来看看用二进制表示会是什么样,看它能不能帮上忙。
第一格上有 1 粒麦子。用二进制表示,这也是0b1(0b只是表示“这是一个二进制数”,真正的数字是1)。
第二格上有 2 粒麦子。用二进制是0b10。目前总数是 3(也就是0b11)。
第三格上有 4(0b100)粒麦子。目前总数:7(0b111)。
第四格上有 8 粒麦子(0b1000)。目前总数:15(0b1111)。
你看出规律了吗?
每一格都代表一个二进制位,把它们全加起来,就是一堆 1。
在这位学员提交的解答里,我们可以把那些 F 换成 64 个 1(每格一个)!
bc <<< "ibase=2;1111111111111111111111111111111111111111111111111111111111111111"
更有意图,因为它更贴近题目给出的东西。可是,我们不说机器人话。一长串基本数不清的 1,也许算不上什么改进。
方案二:暴力计算
好吧,那也许我们干脆放弃非十进制计数法。我们何不让代码贴合手工数棋盘上麦子总数的做法,也就是一格一格地数?
total=0
current_grains=1
for square in {1..64}; do
total=$( bc <<< "$total + $current_grains" )
current_grains=$( bc <<< "$current_grains * 2" )
done
echo "$total"
这样可读性和可理解性都强多了。代码清楚地显示出棋盘上的格数是一个关键因素,每一格翻倍的效果也是。我觉得这比最初的那份提交的解答要好。
然而。
它很慢。循环、累加,还反复调用外部命令?这些加在一起,运行时间就有点慢。那这算大问题吗?不算。如果你用 Bash 写脚本,多半早就接受了没有速度上的硬性要求。但它还能更好吗?能。
方案三:直接计算
那么,怎么在不迭代的情况下把它们加起来?
我们来看同一个问题的缩小版:一个只有 5 格的棋盘3。
这五格上的麦子数量分别如下:
---------------------
| 1 | 2 | 4 | 8 |16 |
---------------------
这里的总数是:1 + 2 + 4 + 8 + 16 = 31。嗯。31 还没让我一眼看出什么明显的东西。我们再看大一点。
好吧,那一个 6 格的棋盘呢?这次,我会在每一格下面显示累计总数,方便我们加总。
-------------------------
| 1 | 2 | 4 | 8 |16 |32 |
| | 3 | 7 |15 |31 |63 |
-------------------------
总和是:1 + 2 + 4 + 8 + 16 + 32 = 63。嗯……其实我开始隐约看到一点规律的影子了,不过为了保险,我们再来一个。
7 格:
-----------------------------
| 1 | 2 | 4 | 8 |16 |32 |64 |
| | 3 | 7 |15 |31 |63 |127|
-----------------------------
1 + 2 + 4 + 8 + 16 + 32 + 64 = 127。看出来了吗?31、63、127 这几个数有没有让你想到什么?
它们几乎就是 2 的幂。事实上,它们比下一个 2 的幂少 1。
再举一个例子,把它讲透。想象一个 12 格的棋盘。最后一格是 1 翻了 11 次倍(数学圈里写作 2^11):2048。再翻一倍,得到 4096(2^12)。那么……如果我们总结的规律没错,累计总数应该比 4096 少 1,也就是 4095。把它加一遍,结果确实正好是这样:1 + 2 + 4 + 8 + 16 + 32 + 64 + 128 + 256 + 512 + 1024 + 2048 = 4095。
换句话说,要求出全部
n格的总数,你需要升到更高的一个 2 的幂,再从这个结果里减去 1。
第 64 格上的麦子数是 2^63(还记得是从 0 开始编号吧?)。所以嘛,如果我们要算出一直到第 64 格为止所有格子上的麦子总数,就需要算出 2^64 再减 1。
成了。
用 Bash 写出来就是这样:
bc <<< "2^64 - 1"
用二进制验证一下,就能明白为什么。用二进制表示,全部 64 格的总数是多少?
0b1111... # 64 ones
理论上第 65 格上的麦子数是多少?
0b10000... # 1 and 64 zeros
怎么从 1 后面 64 个 0 变成 64 个 1?减去 1 就行了。
这又额外带来了什么好处?现在,我们有了一个漂亮、可读的总数表达式。它不迭代,所以性能很好。而且它里面含有 64 这个数字,也就是棋盘上的格数,这是一个设计意图传达得好的好例子。如果出于某种原因,1000 年后全世界统一改用 7x7 的棋盘,那位未来的工程师(多半用着 Bash 6.1)会查看脚本,明白你当初想做什么,然后把 64 改成 49。皆大欢喜!
保持用心,朋友们
在琢磨实现方式时,东拼西凑、抓住第一个能跑通的方案不放手,这很常见。在探索问题的阶段这样没问题,但一旦你完全弄懂了关键部分,如果你有时间好好打磨,就要确保每一个算法、每一个变量名,甚至你的空白排版,都能勾勒出问题、关键需求以及各部分如何组合在一起的图景。
-
如果你觉得自己的二进制和十六进制计数有点生疏,@kytrinyx 推荐 How to Count 这本书。顺便打个不害臊的广告,我最近也写了两篇关于二进制和十六进制的博客文章。 ↩
-
我也不知道那该怎么玩。也许可以让双方的兵互相单挑得了。 ↩