不同層,不同抽象 (Different Layer, Different Abstraction)

★★★★Draft

FromA Philosophy of Software Design

📚 From the Books

不同層,不同抽象 (Different Layer, Different Abstraction)Complexity

軟體是一層層疊起來的:上層用下層提供的設施。Ousterhout 的原則是——每一層提供的抽象都應該和它上下相鄰的層不同。跟著一個操作在各層之間上下移動,每一次方法呼叫都該伴隨一次抽象的轉換。若相鄰兩層長得很像,那是紅旗:這一層多半沒有替系統賺到它引入的成本。

🧠 Why It's a Problem

NOTE

抽象確實會變的好例子是檔案系統:最上層是「可變長度的位元組陣列」,中層是「固定大小磁碟區塊的快取」,最底層是「在儲存與記憶體間搬資料的裝置驅動」。同一個 read 操作往下走,三層各講一種語言。TCP 也一樣:上層是可靠的位元組串流,下層是盡力送達、可能遺失或亂序的封包。

⚖️ 紅旗一:穿透型方法(Pass-Through Method)

穿透型方法除了呼叫另一個方法之外幾乎什麼都不做,且被呼叫方法的簽章與它自己相似或相同。它讓類別變淺:增加了介面複雜度,卻沒替系統增加功能;還製造依賴——底層簽章一改,穿透方法就得跟著改。

前 — TextDocument 把呼叫原封不動轉給 TextArea
// TextDocument 幾乎每個方法都只是把參數轉送給 textArea
class TextDocument {
  constructor(textArea) {
    this.textArea = textArea;
  }
  getLastTypedCharacter() {
    return this.textArea.getLastTypedCharacter();
  }
  getCursorOffset() {
    return this.textArea.getCursorOffset();
  }
  insertString(text, offset) {
    return this.textArea.insertString(text, offset); // 插入的真正邏輯其實在 TextArea
  }
}

TextDocument 暴露了 insertString,但插入文字的真正實作在 TextArea 裡——一個功能的介面與實作被拆到兩個類別。問自己:「這兩個類別到底各自負責哪些抽象?」通常會發現責任重疊。

後 — 讓功能的介面與實作待在同一個類別
// 移除中間那層轉手:需要文字操作的呼叫端直接對 TextArea 說話
class TextArea {
  getLastTypedCharacter() {
    /* ... */
  }
  getCursorOffset() {
    /* ... */
  }
  insertString(text, offset) {
    /* 插入的介面與實作待在同一個類別 */
  }
}

// 呼叫端:不再經過一層只會轉手的 TextDocument
textArea.insertString(text, offset);

三種修法:直接暴露下層(拿掉上層對該功能的責任)、在上下類別間重新分配功能讓它們不再互相呼叫,或者在職責糾纏到拆不開時合併兩個類別。原案例最後把三個類別併成兩個,責任反而更清楚。

⚖️ 何時「重複介面」其實沒問題

簽章相同不一定是壞事。判準是:每個共用簽章的方法是否各自帶來有用且彼此不同的功能。 穿透方法糟,是因為它只搬參數、什麼都沒貢獻。

⚖️ 紅旗二:裝飾者(Decorator)

**裝飾者(decorator / wrapper)**包住既有物件、擴充功能,並提供與底層相似或相同的 API,內部再委派回底層。它天生鼓勵跨層 API 重複——BufferedInputStream 裝飾 InputStream,介面相同只多了緩衝。問題是裝飾者類別往往很淺:為了一點功能引入大量樣板與穿透方法,過度使用就會像 Java I/O 那樣爆出一堆淺類別。

TIP

動手做 decorator 之前先掂量替代方案:

  1. 直接加進底層類別——若功能夠通用、與底層密切相關,或多數人反正都會用到(幾乎每個 InputStream 使用者都要緩衝)。
  2. 併入特定使用情境——若功能是為某情境特化的。
  3. 併進現有 decorator——讓一個 decorator 更深,而不是多開一個淺的。
  4. 做成獨立類別——問自己:這功能真的需要包覆既有功能嗎?捲軸也許能當獨立元件,不必包住整個 Window

⚖️ 介面應與實作不同

同一原則的另一面:類別的介面通常該和它的實作不同;兩者抽象越接近,類別越淺。編輯器專案內部以「行」儲存文字,於是也把 API 設計成行導向(getLine / putLine)——結果 UI 得自己拆行、合行,這段邏輯散布各處。改成字元導向的 insert(position, string) / delete(start, end),內部仍以行儲存,但拆行合行的複雜度被封進文字類別:類別更深,UI 更簡單。介面與內部表示越不同,藏起來的實作複雜度就越多——那正是抽象的價值來源。

⚖️ 紅旗三:穿透型變數(Pass-Through Variable)

穿透型變數被一連串方法層層往下傳。某服務的憑證 cert 只有最底層 m3 真正用來開 socket,卻出現在 main → m1 → m2 → m3 路徑上每個方法的簽章裡。害處:強迫所有中間方法「意識到」一個它們根本不用的變數;日後要加新變數(初版沒憑證、後來才加),就得改一大票介面。

TIP

作者最常用的解法是 context 物件:把原本會變成穿透變數或全域變數的應用全域狀態(設定、共享子系統、效能計數器)全放進 context。每個系統實例擁有自己的一個 context,於是同一 process 能共存多個實例(全域變數做不到,而測試很常需要多實例)。

要讓 context 自己不變成另一個穿透變數:在主要物件裡把 context 存成 instance 變數,建立子物件時由建構式明確傳入——context 於是到處可用,卻只在建構式以明確參數現身

CAUTION

context 不是理想解:它仍帶有全域變數的多數缺點(「為什麼有這變數?在哪被用?」不顯而易見),沒紀律就會膨脹成大雜燴、製造隱性依賴,還可能有執行緒安全問題——最好的避險是讓 context 內的變數不可變(immutable)。作者只是還沒找到比它更好的解法。

🔑 Takeaways

✍️ My Notes

No notes yet — jot your takeaways or Q&A here.

📖 Further Reading