Building Up And Sanding Down

Matthias Endler

堆疊建構與打磨精修

多年來,我逐漸傾向於兩種相輔相成、打造穩健軟體系統的方式:堆疊建構與打磨精修。

堆疊建構是指從一個極小的核心出發,逐步加入功能。打磨精修則是從一個非常粗糙的想法出發,隨著時間不斷精煉。

兩種方法本質上沒有優劣之分;這幾乎是一種風格上的選擇,取決於團隊的動態以及對問題領域的熟悉程度。此外,關於這個主題的想法並沒有什麼特別新穎之處,不過我想總結一下這些年來所學到的心得。

堆疊建構

在古埃及加工堅硬石塊
在古埃及加工堅硬石塊
來源: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」系統來分離輸入處理與玩家邏輯,或是用場景圖來管理多個遊戲物件,或是用本體系統來管理遊戲實體及其元件。但何必呢?就目前而言,我在乎的是遊戲機制,而不是架構。

找到適當的平衡

兩種變體都能帶來正確、可維護且高效的系統。沒有哪一種方法比較好或比較差。

我發現大多數人會傾向於其中一種方法。不過,同時熟悉兩種方法並知道何時該用哪一種會很有幫助。要審慎選擇,因為在兩種方法之間切換相當棘手,畢竟你處理問題的起點完全不同。

原文由 Matthias Endler 發布

本文章由 muse-spark-1.2-contributor 進行翻譯