堆疊與打磨
原文由 Matthias Endler 于 發布,訂閱此部落格
多年來,我逐漸傾向於用兩種互補的方式來打造穩健的軟體系統:堆疊與打磨。
堆疊是指從一個極小的核心開始,逐步加入功能。打磨則是從一個非常粗糙的構想出發,隨著時間不斷精煉。
兩種方法本質上沒有優劣之分,幾乎可說是一種風格上的選擇,取決於團隊的互動模式以及對問題領域的熟悉程度。此外,我對這個主題的看法並沒有什麼特別新穎之處,只是想整理一下這些年來學到的心得。
堆疊

來源:Wikimedia 公有領域
堆疊這種方式著重於先打造穩固的基礎。當我在處理自己很熟悉的系統,或是有明確規格可循時,我喜歡用這種方法。舉例來說,我在實作通訊協定或模擬硬體時就會這麼做,像是我的 MOS 6502 emulator。
相較於「bottom-up」,我更偏好「building up」這個說法,因為前者讓人聯想到營造與向上堆疊的意象。「bottom-up」則比較抽象、帶有方向性。而且「bottom-up」總讓人覺得是行話,而「building up」更直觀、也更具視覺感,因此有助於向非技術背景的利害關係人傳達概念。
在採用堆疊的方式時,我會盡量遵守幾個原則:
- 專注於打造原子化的建構單元,讓它們易於組合與測試。
- 從簡單、可驗證的特性出發,逐步建構出強大的保證。
- 專注於正確性,而非效能。
- 文件與程式碼同步撰寫,用來檢驗自己的推論。
- 在進入下一層之前,先把抽象設計做到位。
當我與高度重視分析的人合作時,這種方法特別有效。有形式化方法或數學背景的人,往往習慣以「建構單元」與證明的方式思考。我也發現函數式程式設計師通常比較偏好這種做法。
在像 Rust 這樣的語言中,型別系統有助於強制維持不變量(invariants),也讓人更容易從簡單的元件堆疊出複雜的系統。此外,Rust 的 trait 系統鼓勵組合(composition),與這種思維方式非常契合。
「堆疊」這種做法的缺點是,你會在基礎層花上大量時間,遲遲看不到具體的成果。用這種方式要做出 MVP 可能會很慢。有些人也覺得這種方法過於僵硬、不夠靈活,一旦選定了某種架構,就很難轉向或改變方向。
舉例來說,假設你要打造一個網頁框架。在專案一開始就會遇到一大堆問題:
- 它要是同步還是非同步?
- 請求路由要怎麼運作?
- 會不會有中介軟體?要怎麼做?
- 回應要怎麼產生?
- 錯誤處理要怎麼做?
在堆疊的做法中,你會先回答這些問題,並優先設計核心抽象。像是 request 與 response 型別、路由器以及中介軟體系統等基礎元件,都是框架的骨幹,必須非常穩固。
只有在確定了核心資料結構及其互動方式之後,你才會著手打造公開 API。這樣可以造就出非常穩健、設計良好的系統,但也可能需要很長時間才能走到那一步。
舉例來說,這裡是熱門的 http crate 中的 Request 結構:
#[derive(Clone)]
pub struct Request<T> {
head: Parts,
body: T,
}
/// Component parts of an HTTP `Request`
///
/// The HTTP request head consists of a method, uri, version, and a set of
/// header fields.
#[derive(Clone)]
pub struct Parts {
/// The request's method
pub method: Method,
/// The request's URI
pub uri: Uri,
/// The request's version
pub version: Version,
/// The request's headers
pub headers: HeaderMap<HeaderValue>,
/// The request's extensions
pub extensions: Extensions,
_priv: (),
}在這段不長的程式碼中,有不少巧妙的設計決策:
Request結構在主體型別T上是泛型的,讓主體的表示方式(例如位元組串流、字串等)更具彈性。Parts結構與Request結構分離,讓人可以輕鬆存取請求的中繼資料,而不需要處理主體。Extensions可用來儲存從底層通訊協定衍生而來的額外資料。_priv: ()欄位是一種零大小(zero-sized)型別,用來防止外部程式碼直接建構Parts。它強制使用提供的建構子,並確保Parts結構的不變量得以維持。
除了 extensions 之外,這個設計經得起時間的考驗。自 2017 年的最初版本以來,幾乎沒有什麼變動。
打磨

來源:Wikimedia 公有領域
另一種我覺得同樣有效的方法是「打磨」。在這種做法中,你從一個粗糙的原型(或垂直切片)開始,隨著時間不斷精煉。你一遍又一遍地「磨掉」粗糙的邊角,直到對成果感到滿意。這有點像木工,你從一塊粗糙的木料開始,慢慢將它琢磨成一件藝術品。(雖然我對木工其實一竅不通,但我想大概就是那種感覺。)
關鍵在於,這雖然類似原型製作,但並不完全相同。差別在於,你並不打算丟棄自己寫下的程式碼。相反地,你是想利用問題本身的迭代特性,刻意在「草稿」上反覆加工,直到完成最終版本。在任何時間點,如果有需要,你都可以停下來,直接交付當前的版本。
我發現這種方法在需要不斷嘗試、快速迭代的創意專案上特別好用。有遊戲開發或腳本語言背景的人往往比較偏好這種方式,因為他們習慣以更探索性的方式工作。
在使用這種方法時,我會盡量遵守以下幾個原則:
- 關掉內心的完美主義者。
- 寫第一版草稿時不要邊寫邊改。
- 程式碼重複是完全被允許的。
- 重構、重構、再重構。
- 等第一版草稿完成後再補測試。
- 先專注於最外層的 API;把它做到位,再來打磨內部實作。
這種方法讓你能輕鬆丟棄程式碼、嘗試新東西。我發現,對於喜歡事先規劃、非常有條理與方法的人來說,這可能會讓人感到挫折。那種「混亂」感似乎會讓一些人卻步。
舉個例子,假設你正在用 Rust 寫一款遊戲。你可能會想調整遊戲的各個面向,並針對玩法機制快速迭代,直到感覺「恰到好處」。
為了做到這一點,你可能會先從一個只有遊戲迴圈骨架的版本開始,其他什麼都沒有。接著加入一個可以在畫面上移動的玩家角色。你不斷調整跳躍高度和移動速度,直到手感對了。在這個階段,你與遊戲邏輯之間幾乎沒有什麼抽象層。你可能會有大量重複的程式碼和寫死的數值,但現階段這樣也沒關係。一旦核心玩法機制確定下來,你就可以開始重構程式碼。
我覺得如果在遊戲設計初期就使用 Bevy 或其他框架,Rust 反而可能會礙事。實體元件系統(entity component system)會讓人覺得相當笨重,阻礙快速迭代。(至少上次我嘗試 Bevy 時是這種感覺。)
我用 macroquad 自己打造視窗與渲染迴圈的體驗就好得多。沒錯,整個程式碼都在同一個檔案裡,而且,沒有任何測試。也談不上有什麼架構。
然而……開發遊戲的過程卻非常過癮!我知道之後隨時可以重構程式碼,但我更想專注於當下,先把玩法做好。
以下是我的遊戲迴圈,寫得非常指令式(imperative),不需要為了入門而去學一套龐大的框架:
#[macroquad::main("Game")]
async fn main() {
let mut player = Player::new();
let input_handler = InputHandler::new();
clear_background(BLACK);
loop {
// Get inputs - only once per frame
let movement = input_handler.get_movement();
let action = input_handler.get_action();
// Update player with both movement and action inputs
player.update(&movement, &action, get_frame_time());
// Draw
player.draw();
next_frame().await
}
}你不需要是 Rust 專家也能看懂這段程式碼。
在每一次迴圈迭代中,我只需要:
- 取得輸入
- 更新玩家狀態
- 繪製玩家
- 等待下一幀
這是這類工作中非常典型的設計。
如果我想,我現在就可以打磨這段程式碼,將它重構成更模組化的設計,直到達到可上線的水準。我可以引入「監聽器/回呼」系統來分離輸入處理與玩家邏輯,或是用場景圖(scene graph)來管理多個遊戲物件,或是用本體(ontology)系統來管理遊戲實體及其元件。但何必呢?就現階段而言,我在乎的是遊戲機制,而不是架構。
找到適當的平衡
兩種方式都能打造出正確、可維護且有效率的系統。沒有哪一種比較好或比較差。
我發現大多數人都會偏向其中一種做法。然而,熟悉兩種方法並知道何時該用哪一種是很有幫助的。要審慎選擇,因為一旦從問題的不同端點出發,要在兩種方法之間切換是相當棘手的。
隨機一篇部落格
留言
登入後參與討論