Building Up And Sanding Down

Matthias Endler

堆疊與打磨

原文由 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)系統來管理遊戲實體及其元件。但何必呢?就現階段而言,我在乎的是遊戲機制,而不是架構。

找到適當的平衡

兩種方式都能打造出正確、可維護且有效率的系統。沒有哪一種比較好或比較差。

我發現大多數人都會偏向其中一種做法。然而,熟悉兩種方法並知道何時該用哪一種是很有幫助的。要審慎選擇,因為一旦從問題的不同端點出發,要在兩種方法之間切換是相當棘手的。

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

留言