第 4 / 11 部 待錄

分層抽象與把複雜性往下拉

《A Philosophy of Software Design 軟體設計的哲學》Podcast 準備稿:不同層次不同抽象與把複雜性往下拉

書名: 軟體設計的哲學 A Philosophy of Software Design 作者: John Ousterhout 系列: 旗艦評書(恩普拉氏) 公開標題建議: 評書|A Philosophy of Software Design.穿透型方法與把複雜性往下拉 (4/11) 涵蓋範圍: 第7章 不同層次,不同抽象 + 第8章 把複雜性往下拉

背景速覽

前三集我們建立了「深模組」「資訊隱藏」「通用優於專用」這三把尺。這一集講兩個直接落地的設計動作。第7章談一個常見的壞味道——「穿透型方法」:上層方法只是把參數原封不動轉給下層,自己什麼都沒做,這代表分層分錯了(不同層卻在做同一個抽象)。第8章給一條很有態度的設計準則——「把複雜性往下拉」:與其把難題以組態參數的形式甩給使用者,不如讓模組自己多扛一點,換上層一群人通通輕鬆。這兩章合起來,是「怎麼讓每一層各司其職」的實戰守則。

一句話重點

每一層都應該提供「跟下層不一樣的抽象」——如果上層方法只是把參數原樣轉給下層(穿透型方法),那這一層根本沒存在的價值;而當你面對「這個複雜度要往上推給使用者、還是往下扛進模組」的選擇時,預設答案是往下拉:寧可讓一個模組多累一點,也別讓一群呼叫端、甚至使用者去煩惱。

值得討論的重點

1. 穿透型方法:分層失敗的紅旗

穿透型方法就是「除了呼叫另一個方法幾乎什麼都不做、簽章還跟對方幾乎一樣」的方法。Ousterhout 舉的學生案例很驚人:TextDocument 的 15 個 public 方法裡有 13 個是穿透型。它的害處有二:讓類別變淺(增加介面卻沒增加功能)、製造依賴(下層簽章一改、穿透方法跟著改)。根本病因是職責劃分混亂——TextDocument 暴露了 insertString,但真正的插入邏輯在 TextArea 裡,違反了「一個功能的介面與實作該在同一個類別」。解法三選一:直接暴露下層、在兩層間重分配職責、或乾脆合併(學生最後把三個類別併成兩個)。

2. 簽章相同不一定壞:分派器、多實作、裝飾者

不是所有「簽章一樣」都是穿透型。判準是一句話:每個共用簽章的方法,是否各自帶來有用且不同的功能?

  • 分派器(dispatcher):簽章跟被呼叫者一樣,但它提供「選擇」這個有用功能(Web 伺服器依 URL 挑處理器)。
  • 同介面多實作:磁碟驅動各自不同、介面相同——學會一個就會用全部,反而降低認知負擔。
  • 裝飾者(decorator):包一層加功能,天生鼓勵跨層 API 重複。Ousterhout 警告:裝飾者往往很淺(為一點功能引入一堆 boilerplate 和穿透方法),Java I/O 就是過度裝飾的災難。建 decorator 前先想四個替代方案:直接加進底層、與特定情境合併、與既有 decorator 合併、或做成獨立元件。

3. 介面就該跟實作不一樣——否則類別不夠深

「不同層不同抽象」還有一個版本:類別的介面通常該跟它的實作長得不一樣,兩者越像、類別越淺。編輯器案例:很多團隊內部以「行」存文字,於是 API 也做成 getLine/putLine——結果 UI 要在行中間插入、跨行刪除時,得自己拆行合行,邏輯散得到處都是。改成字元導向的 insert(position, string)/delete(start, end),內部仍以行儲存,但拆行合行的複雜度封裝在文字類別內,類別變深、UI 變簡單。金句:介面與內部表示越「不同」,類別藏起的複雜性就越多,這正是好抽象的來源。

4. 穿透型變數與 context 物件

跟穿透型方法平行的還有「穿透型變數」——一個變數沿 main,m1,m2,m3 層層往下傳,只有最底層真的用到,中間每一層都被迫「認識」它。新增一個這種變數要改一整串簽章。Ousterhout 的解法是 context 物件:把應用的全域狀態集中存進 context,主要物件持有 context 參考、只在建構式裡明確傳入。好處是集中管理、好測試(測試可直接改 context 欄位)、多實例可共存。但他誠實標註:context 不是理想解,它仍有全域變數的多數缺點(為什麼有這個變數、它在哪用到不顯而易見),沒紀律就會膨脹成大雜燴——最佳避險是讓 context 內變數不可變(immutable)

5. 把複雜性往下拉:少用組態參數

第8章的核心態度:面對「這個複雜度該往上推還往下拉」,預設往下拉。最常見的「往上推」壞習慣就是組態參數——cache 大小、重試次數、上百個 knob 全甩給使用者。Ousterhout 直言這常是「逃避責任」:很多時候使用者根本不知道該填什麼。經典案例是網路協定的重試間隔——與其開一個 timeout 參數讓人猜,不如內部根據實際 RTT 動態計算,既不必使用者懂、又能自適應環境。準則:理想上每個模組應完整解決一個問題;組態參數代表「不完整的解」。 暴露參數前先問:「使用者真的能比我這裡決定出更好的值嗎?」

注意事項

⚠️ 「把複雜性往下拉」最容易做過頭——極端版就是把整個應用塞進一個類別。Ousterhout 給了三個成立條件:被下拉的複雜度與類別現有功能密切相關、下拉後能在他處大量簡化、下拉後讓類別介面更簡單。反例就是把 UI 邏輯下拉進文字類別——UI 知識跟文字核心無關,那不是下拉,是資訊洩漏。判準永遠回到「整體複雜度有沒有降」。

⚠️ 組態參數不是一律邪惡。有些情況底層真的不知道最佳策略、使用者更懂自己的領域(例如哪些請求對時間敏感、該給高優先權)。Ousterhout 反對的是「把能自己算的東西也甩出去」,不是反對所有可調參數。能自動算合理預設值、讓使用者只在例外時才覆寫,是黃金折衷。

⚠️ context 物件講的時候要平衡——它常被新手當成「合法的全域變數」濫用。強調 Ousterhout 自己都說它「不是理想解、只是目前他找到最好的」,並把「immutable」當成護身符一起講,免得聽眾拿回去就開一個塞滿可變狀態的 god context。

專家補充

💡 穿透型方法這條紅旗,跟《Clean Code》其實不衝突、甚至互補——Uncle Bob 也討厭沒意義的轉手。但對照點在於:Uncle Bob 傾向「多分一層、職責更單一」,而 Ousterhout 會反問「你這多出來的一層,是不是只是在穿透?」這正是兩派最微妙的交界——抽象層該不該存在,取決於它有沒有提供『不一樣的抽象』,而不是它有沒有讓單一類別變小。可呼應藍圖 §5。

💡 「介面要跟實作不一樣」這句,其實是判斷抽象品質最快的試紙。如果你的 class 介面長得就像它的內部資料結構(getLine 配「內部用 line 陣列」),那它幾乎沒藏東西。好抽象一定有「翻譯」——對外講字元、對內存行,中間那層翻譯就是你的價值。

💡 「把複雜性往下拉」其實是分散式系統與基礎設施設計的核心哲學。自適應重試(依 RTT 動態算 timeout)就是 TCP 的真實做法(RTO 估計);「別讓使用者調 knob,讓系統自己學」也是現代 autoscaling、自適應限流的方向。Ousterhout 講的是寫 class 的尺度,但同一條原則一路放大到整個基礎設施都成立。

討論問題

🎙️ 開場鉤子:「如果一個類別有 15 個方法,其中 13 個都只是『把參數轉給另一個類別』——你會不會覺得這個類別其實根本不該存在?Ousterhout 給這種方法取了個名字,叫穿透型方法,而它幾乎總是在告訴你:你的分層分錯了。」

🎙️ 自問自答:「面對一個你拿不準該設多少的參數(cache 大小、retry 次數),你是會把它做成 config 丟給使用者,還是讓系統自己算?」——用網路重試間隔的例子說明:丟出去看似貼心,其實是把你不會的難題,甩給一個更不會的人。

🎙️ 帶走的一題:「翻一下你最近加的一個組態參數。誠實問自己:使用者真的有辦法比你更會填這個值嗎?如果不行——你能不能讓程式自己算出一個夠好的預設?」

更大範圍關聯

  • 與《Clean Code》對照:穿透型方法、「介面該不該多一層」的取捨,正面對應 [軟體工程領域切分藍圖] §5「Clean 到底乾不乾淨」——「多分一層」何時是清晰、何時只是穿透。
  • 裝飾者模式的再評價:本集對 decorator 的批評,是對 GoF《Design Patterns》的一次「實務修正」——模式不是越多越好,裝飾者用過頭就是 Java I/O。可接到「設計模式該不該照搬」的更大討論。
  • 基礎設施哲學:「把複雜性往下拉、自適應而非調參」是分散式系統、SRE/autoscaling 的共同信念,與《人月神話》的「本質複雜性要被吸收、不該轉嫁」一脈相承。

錄製建議

  • 建議時長:約 22–24 分鐘。配比:穿透型方法+簽章重複+介面vs實作約 9 分、穿透型變數與 context 約 6 分、把複雜性往下拉+組態參數約 9 分。
  • 討論策略:用「13/15 都是穿透方法」這個誇張數字開場最抓人;中段把 context 物件講成「合法全域變數的紀律版」,務必把 immutable 的警語帶到;後段把「把複雜性往下拉」立成一個有態度的口號,用組態參數=逃避責任做反面,網路重試間隔做正面收尾。
  • 這兩章偏「實戰守則」,例子都很具體,可以多用「指著螢幕喊 red flag」的口吻,讓聽眾學會當場辨識。