軟體是一層層疊起來的:上層用下層提供的設施。Ousterhout 的原則是——每一層提供的抽象都應該和它上下相鄰的層不同。跟著一個操作在各層之間上下移動,每一次方法呼叫都該伴隨一次抽象的轉換。若相鄰兩層長得很像,那是紅旗:這一層多半沒有替系統賺到它引入的成本。
🧠 Why It's a Problem
- 每個新設施都是成本:多一個介面、參數、函式、類別,開發者就得多學一份東西。一個元素必須消除掉原本會存在的複雜性,才算在淨值上有貢獻;否則拿掉它反而更好。
- 相鄰層抽象雷同 = 沒有轉換:如果 C1 只是把呼叫轉手給 C2,而且簽章一模一樣,那 C1 沒有把任何複雜度藏起來,只是多了一道要維護的轉手。
- 紅旗有固定長相:穿透型方法、穿透型變數、為了一點功能而堆疊的裝飾者、介面與實作抽象雷同——它們都是「同一個抽象出現在不同層」的症狀。
- 通常代表職責沒切乾淨:當你看到一個類別對另一個類別的穿透,背後多半是兩個類別的責任範圍重疊,而不是真的需要兩層。
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);三種修法:直接暴露下層(拿掉上層對該功能的責任)、在上下類別間重新分配功能讓它們不再互相呼叫,或者在職責糾纏到拆不開時合併兩個類別。原案例最後把三個類別併成兩個,責任反而更清楚。
⚖️ 何時「重複介面」其實沒問題
簽章相同不一定是壞事。判準是:每個共用簽章的方法是否各自帶來有用且彼此不同的功能。 穿透方法糟,是因為它只搬參數、什麼都沒貢獻。
- 分派器(dispatcher):依參數挑選要呼叫哪個方法,簽章常與被呼叫者相同——但它提供了「選擇」這個真功能。例如 Web 伺服器依 URL 把請求分給「回傳磁碟檔案」或「執行程式」的處理器。
- 同介面的多種實作:作業系統裡各種磁碟驅動實作同一介面。學會一個,其餘的介面就不用重學——這降低了認知負荷。這類方法通常位於同一層、彼此不互相呼叫。
⚖️ 紅旗二:裝飾者(Decorator)
**裝飾者(decorator / wrapper)**包住既有物件、擴充功能,並提供與底層相似或相同的 API,內部再委派回底層。它天生鼓勵跨層 API 重複——BufferedInputStream 裝飾 InputStream,介面相同只多了緩衝。問題是裝飾者類別往往很淺:為了一點功能引入大量樣板與穿透方法,過度使用就會像 Java I/O 那樣爆出一堆淺類別。
TIP
動手做 decorator 之前先掂量替代方案:
- 直接加進底層類別——若功能夠通用、與底層密切相關,或多數人反正都會用到(幾乎每個
InputStream使用者都要緩衝)。 - 併入特定使用情境——若功能是為某情境特化的。
- 併進現有 decorator——讓一個 decorator 更深,而不是多開一個淺的。
- 做成獨立類別——問自己:這功能真的需要包覆既有功能嗎?捲軸也許能當獨立元件,不必包住整個
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
- 好系統裡每一層提供不同的抽象;相鄰層抽象雷同是紅旗,通常代表類別拆分或職責有問題。
- 穿透型方法只搬參數、讓類別變淺——修法是直接暴露下層、重新分配責任,或合併類別。
- 簽章相同不必然是壞事:只要每個方法各自帶來有用且不同的功能(分派器、同介面多實作)就 OK。
- 裝飾者天生製造跨層 API 重複且容易淺;建立前先考慮併入底層、情境、現有 decorator 或獨立類別。
- 介面應與實作不同——差異越大,藏起的複雜度越多,抽象越有價值。
- 穿透型變數逼中間方法認識用不到的變數;優先用 context 物件集中全域狀態,並讓它保持 immutable。
No notes yet — jot your takeaways or Q&A here.