劇透警告:一般來說,本文會劇透穀物練習的內容,尤其是 Bash 軌道上的穀物練習。如果你還沒自己完成它,又不想看到一些解答,那就等你完成之後再回來看吧!
這是你到新公司的第一天。文件都填完了,也見過團隊成員,終於可以坐下來,開始讀一些接下來要動手的程式碼。你開始翻過一個又一個函式、類別和模組,讀著讀著,發現自己困惑地瞇起眼睛盯著螢幕。你繼續往下讀,一個字從嘴裡溜出來,幾乎說不出口,更像是吐氣:「什麼鬼……」
你讀得越多,這種情況就越常發生,你越來越茫然,甚至有點生氣。
這段程式碼到底在幹嘛?
只要有一個人以上同時在處理同一段程式碼,想讓事情維持在好管理的狀態,所需的細心與刻意就會大幅上升。這已經不只是「概念住在你腦子裡,程式碼只要把概念做出來就好」。現在,概念必須活在程式碼裡面,讓所有協作者都能看見,需要時也能修改。
你怎麼實作某個東西,對終端使用者來說沒什麼意義,但對每一個碰過你設計的工程師來說,它應該能道盡千言萬語。要做出同樣的功能,往往有很多種做法,而且看起來好像隨便挑一種都能把工作完成。但我相信,你做的每一個決定都應該有理由(就算只是個小決定、小理由也一樣),而那個理由應該傳達出某個目標或需求。
實作細節應該幫助讀程式碼的人看出思考過程、目標與優先順序,這個想法稱為設計意圖。你怎麼為變數命名、函式接受哪些參數、東西怎麼抽象化,這些都是設計意圖可以展現的地方,可能是展現得好,也可能是展現得糟。
我深信,在進行工程設計時,設計意圖是最該考慮的事情之一。這正是軟體工程之所以有別於寫程式的原因之一。
軟體工程,就是寫程式再加上時間和其他工程師之後發生的事。
— Russ Cox
設計意圖是跨領域的
我是一名機械工程師,主要為醫療器材設計射出成型模具。我的設計完成之後,會直接送到加工廠,師傅們開始製作所有零件,再把它們組裝起來。由於他們並不知道我在設計每一個東西時腦子裡想過什麼,我得想辦法透過設計本身表現出我的意圖。
很多時候,某些特徵特別關鍵。可能是客戶說那裡需要特別嚴格的公差,也可能是模具組裝的方式因為某些原因要求極高的精度。所以,為了讓加工師傅在製作零件時能優先顧好重要部位的精度,我得留下一些特別方正、或特別容易用某種方式夾上虎鉗的位置。這樣一來,對他們來說最省事的路,就能做出對我來說最好的結果。
也有些地方的尺寸沒那麼關鍵。舉例來說,如果我在設計上開一個孔只是為了排氣,我會把它做成 6mm 這種常見又好用的尺寸。
當他們加工這個孔、量測結果時,如果看到 5.99mm 這樣的數字,就會想:「好,這應該本來就是要 6mm,我做得很接近了。」甚至不必回頭去核對 CAD 或規格圖上的尺寸。反過來說,如果我把那個孔做成 5.87mm 這種不常見的尺寸,他們看到之後,第一個反應就會是:
- 慘了,我是不是做太小了?這本來應該是要 6mm 嗎?
- (他們去查 CAD,發現孔做得沒問題,只是尺寸不常見。)
- 嗯……這個孔做成這麼不常見的尺寸,一定有原因。也許它真的很重要,或是客戶特別要求在這裡開一個孔。我得去找 Ryan 問問,看這個孔到底重要在哪裡。
- (砰!他們把那塊鋁輕輕放到我桌上。)
- (他們發現這個孔根本沒什麼重要之處,只是我挑了個奇怪的尺寸,這些額外的工作和擔心全都白費了。)
- 那個叫 Ryan 的,還真是個人才。(嘀咕、髒話、嘀咕)
這一切會發生,都是因為我設計裡的每一個決定,都會對看著它、動手做它的人傳達某些訊息,不管我是不是有意如此。他們一定會從中看出意義,因為那是他們唯一能依據的資訊!所以,如果我能花時間把有意義、刻意安排的資訊放進設計裡,那就好多了。
穀物:簡介
現在,我們用一個 Exercism 練習當例子,談談設計意圖如何能在程式碼裡傳達出來。我最近和一位學員一起看他對 Bash 軌道上穀物練習的解答。穀物這個練習處理的是小麥與棋盤問題。簡單說,西洋棋盤的第一格放一粒小麥。下一格放兩粒。再下一格放四粒。依此類推,每一格的小麥數量都是前一格的兩倍。學員的任務是找出方法,計算每一格上的數量,以及棋盤上小麥的總數。
這位學員想出了一個相當聰明的方法來計算總數。
bc <<< 'ibase=16;FFFFFFFFFFFFFFFF'
bc是一個命令列計算機。你可以把一串算式傳給它,它會算出結果,就算是很大的整數或浮點數也沒問題。在 Bash 裡不用bc也有其他方式可以做計算,不過為了簡單起見,我們就來看看使用bc時,意圖是如何被傳達出來(或傳達不出來)的。
這個解法會成立,是因為整個練習都繞著 2 的次方打轉。而有 2 的次方的地方就有二進位,有二進位的地方就有十六進位1!
這是個聰明的解法,但這段程式碼告訴了我們什麼?告訴我們十六進位在這裡很重要?告訴我們這個問題本質上繞著 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 格的棋盤2。
這 5 格的小麥數量會是:
---------------------
| 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 這個數字,也就是棋盤的格數,這是設計意圖表達清楚的好例子。假如一千年後,世界因為某個原因統一改用 7x7 的棋盤,那位未來的工程師(大概用的是 Bash 6.1)檢查腳本時,會看出你當初的用意,把 64 改成 49。完美!
朋友們,保持你的意圖
在構思實作方式時,很容易東拼西湊,抓住第一個能跑的解法就不放。在探索問題的階段這樣沒問題,但一旦你完全掌握了關鍵部分,如果你有時間好好打磨,就要確保每一個演算法、每一個變數名稱,甚至你在程式碼裡留的空白,都在描繪這個問題、關鍵需求,以及所有東西如何拼在一起。
-
如果你對二進位和十六進位的計數有點生疏,@kytrinyx 推薦 How to Count 這本書。順便打個自己的廣告,我最近也寫了幾篇關於二進位和十六進位的部落格文章。 ↩
-
我也不知道那樣要怎麼運作。也許我們就讓兵互相拿長槍對決算了。 ↩