堆疊建構與打磨精修
多年來,我逐漸傾向於兩種相輔相成、打造穩健軟體系統的方式:堆疊建構與打磨精修。
堆疊建構是指從一個極小的核心出發,逐步加入功能。打磨精修則是從一個非常粗糙的想法出發,隨著時間不斷精煉。
兩種方法本質上沒有優劣之分;這幾乎是一種風格上的選擇,取決於團隊的動態以及對問題領域的熟悉程度。此外,關於這個主題的想法並沒有什麼特別新穎之處,不過我想總結一下這些年來所學到的心得。
堆疊建構

來源:Wikimedia 公有領域
堆疊建構著重於先打造穩固的基礎。我喜歡在處理自己很熟悉的系統,或是手邊有明確規格可供依循時使用這種方式。舉例來說,我在實作通訊協定或模擬硬體時就會採用,例如我的 MOS 6502 emulator。
相較於「bottom-up」,我更偏好「堆疊建構」,因為前者讓人聯想到營建與向上生長。「bottom-up」則更為抽象、帶有方向性。而且「bottom-up」總讓人覺得像是行話,而「堆疊建構」更直觀、也更具視覺感,因此有助於向非技術背景的利害關係人溝通這個概念。
在採用堆疊建構時,我會盡量遵循以下幾項原則:
- 專注於原子化的建構單元,使其易於組合與測試。
- 從簡單、可驗證的特性出發,逐步建立強大的保證。
- 專注於正確性,而非效能。
- 同步撰寫文件與程式碼,以驗證你的推理。
- 先確立好抽象層,再進入下一層。
當我與分析能力很強的人合作時,這種方法特別有效。有形式化方法或數學背景的人,往往習慣以「建構單元」與證明的方式思考。我也發現函數式程式設計師通常較偏好這種做法。
以 Rust 這類語言為例,型別系統有助於強制維持不變量,並讓人更容易從簡單的元件堆疊出複雜的系統。此外,Rust 的 trait 系統鼓勵組合,這與這種思維方式十分契合。
「堆疊建構」方法的缺點在於,你會在基礎層上花費大量時間,遲遲無法看到具體成果。用這種方式做出 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 自行建立視窗與渲染迴圈的體驗就好得多。是的,整個程式碼都在同一個檔案裡,而且,沒有測試。也談不上什麼架構。
然而……開發遊戲的過程卻非常愉快!我知道之後隨時可以重構程式碼,但我更想專注於當下,先把遊戲玩法做好。
以下是我的遊戲迴圈,它極為指令式,不需要學習龐大的框架就能上手:
#[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 專家也能看懂這段程式碼。
在每一次迴圈迭代中,我只是:
- 取得輸入
- 更新玩家狀態
- 繪製玩家
- 等待下一幀
這是這類工作中非常典型的設計。
如果我想,現在可以進一步打磨這段程式碼,將其重構成更模組化的設計,直到達到可上線的水準。我可以引入「listener/callback」系統來分離輸入處理與玩家邏輯,或是用場景圖來管理多個遊戲物件,或是用本體系統來管理遊戲實體及其元件。但何必呢?就目前而言,我在乎的是遊戲機制,而不是架構。
找到適當的平衡
兩種變體都能帶來正確、可維護且高效的系統。沒有哪一種方法比較好或比較差。
我發現大多數人會傾向於其中一種方法。不過,同時熟悉兩種方法並知道何時該用哪一種會很有幫助。要審慎選擇,因為在兩種方法之間切換相當棘手,畢竟你處理問題的起點完全不同。
隨機一篇部落格