🎯 什麼情境該想到我

當你「在 C++ 裡想覆寫建構函式中被呼叫的方法,卻發現覆寫根本沒生效」的時候。

書上的起點:「建構函式中的物件建立可能會帶來依賴問題,尤其是當測試很難依賴這些物件的時候。大多數情況下我們可以使用提取並重寫工廠方法(276 頁)手法來對付這個問題。但對於那些不支援在建構函式中呼叫虛擬函式的語言,則必須另覓他法。而辦法之一便是本節所要講的替換實例變數。」(p.317)

為什麼子類化並重寫方法在這裡會失效

書中用 Pager 的例子示範這個陷阱:建構函式裡呼叫了虛擬函式 formConnection,「由於 formConnection 是個虛擬函式,所以人們可能會想當然地以為只要對它進行子類化並重寫方法(314 頁)就夠了」(p.317)——但驗證下來不是。

「在 C++ 中重寫虛擬函式時,基底類別相應的行為會被派生類別中的行為替換,這一點跟我們預期的一樣,然而有一點例外,就是當你在建構函式中呼叫一個虛擬函式時,呼叫並不會被分發到派生類別中的相應虛擬函式上去。」(p.318)結果就是那次 formConnection 的呼叫被決議到基底類別 Pager::formConnection 上。

C++ 為什麼要這條規則?書中的解釋是:若基底類別 A 的建構函式呼叫了被 B 覆寫的方法,而 B 的覆寫版本用到 B 自己的成員——「既然還沒輪到 B 的建構函式,那麼就是說該成員根本就還沒被初始化,結果可想而知」(p.318)。

「一些其他語言在這個問題上則要放鬆一些,比如 Java 中允許這麼做,但我不建議你在產品程式碼中這麼幹。」(p.318)

什麼時候用它、什麼時候用別的

書中給了明確的分岔(p.319):

  • 如果你想要替換的物件並沒有在建構函式中被使用(而只是建立的話)→ 採用提取並重寫獲取方法。
  • 如果建構函式中使用了該物件,並且你需要確保在另一個方法被呼叫之前將該物件替換掉 → 採用替換實例變數。

⚙️ 怎麼用(步驟)

書中列出的步驟(p.320):

  1. 找出你想要替換的實例變數。
  2. 建立一個名為 supersedeXXX 的方法,其中 XXX 是你想要替換的變數的名字。
  3. 在該方法中銷毀原先被建立出來的那個物件,換入你新建出來的物件。如果持有該物件的實例成員是一個引用,則需要確保該類別中沒有其他成員引用了原先建立出來的那個物件。如果有的話,你可能就需要在 supersedeXXX 方法裡面多做一點工作,來確保能夠安全地換入你的新物件並且確保達到正確的效果。

使用方式(書中原話是針對 BlendingPen 的例子):在測試時,可以根據需要建立該物件,並在需要放入感知物件的時候呼叫它的 supersede... 方法。(p.319)

為什麼名字要叫 supersede: 「使用 supersede 作為這類方法的方法名前綴的一個好處就是這個單詞比較奇異且不常見,於是如果你擔心別人會在產品程式碼中使用這個方法的話,只需搜尋一下 supersede 就能知道結果了。」(p.319)

★★ 在 Go 裡:它要解決的那個問題不存在

★ 書中的問題(p.317–318):★★ C++ 不支援「在建構函式裡呼叫虛擬函式並分派到子類」
  → ★ 所以 [[子類化並重寫方法]] 對「建構函式裡建立的東西」無效
  → ★ 解法:★★ 加一個 setter,★ 讓測試在建構「之後」把實例變數換掉

★★ Go 裡這整條推理不成立:
① ★ Go 沒有繼承 ⇒ ★★ 沒有「建構函式裡的虛擬分派」這個概念
② ★★ Go 的建構函式就是一個普通函式 ⇒ ★ 它的參數本來就可以是任何東西
→ ★ 所以 Go 的答案是 [[參數化建構函式]],★★ 而不是「事後 setter」
// ❌★★ Go 裡不需要這樣
type Service struct{ conn Conn }
func New() *Service { return &Service{conn: mustConnect()} }
func (s *Service) SetConnForTest(c Conn) { s.conn = c }    // ★★ 不必要
 
// ✅ ★ 直接參數化
func New(conn Conn) *Service { return &Service{conn: conn} }

★★ 但「事後 setter」在 Go 裡有兩個真實的壞處

① ★★ 它讓型別變成「可變的」⇒ ★ 併發不安全
   ★ s.SetConn(c) 與另一個 goroutine 的 s.conn 讀取 ⇒ ★★ 資料競爭(-race 會報)
   → ★ 對照:★★ 建構時注入 ⇒ 欄位建立後不再寫入 ⇒ ★ 天然併發安全

② ★★ 它讓「這個物件是否已經初始化完成」變得不確定
   ★ New() 之後、SetConn() 之前的那個物件是「半成品」
   → ★ 而任何人都可能在那個時刻用它 ⇒ ★★ nil panic
   → ★ 這是 [[暫時欄位]] 在建構期的樣貌

★★ 所以在 Go 裡「事後 setter」是一個明確要避免的形態,
★ 而不只是「一個不得已時的手法」。

★★ 那 Go 裡什麼時候真的需要「建構後替換」?

★ 只有兩種情況:
① ★★ 你改不動那個建構函式(★ 第三方套件)
   → ★ 那時它通常已經提供了 setter 或 option(★ 而如果沒有,你只能包一層)
② ★★ 那個依賴的建立必須延後(★ 建構時還不知道它)
   → ★ 但那時正確做法是「注入一個工廠函式」而不是 setter:
     type Service struct{ newConn func(context.Context) (Conn, error) }
   → ★★ 差別:★ 工廠函式在建構時就確定了,★★ 而 setter 讓物件有兩個狀態

★★ 書中這一頁真正值得帶走的一句話

★★ 「在不支援建構函式中分發虛擬函式的語言裡,子類化並重寫方法會失效」
→ ★ 這句話的一般化版本(★ 跨語言都成立):
  ★★ 「建構期發生的事,很難在事後被替換」
→ ★ 所以:★★ 建構函式應該「只組裝,不做事」
  ✅ ★ 收依賴、設欄位、回傳
  ❌ ★★ 連線、讀檔、發請求、啟動 goroutine、註冊 callback
  → ★ 若真的需要那些,★★ 把它們放在一個明確的 Start(ctx) 方法裡

★★ 「建構函式只組裝、Start() 才做事」是這一頁在 Go 最有價值的推論:
★ 它讓「建立一個物件」永遠是安全、快速、可測的,
★★ 而「啟動它」是一個可以被控制、可以回傳 error、可以被取消的動作。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • Feathers 自己都承認這招看起來很糟。 「從表面上來說,替換實例變數看起來是個挺糟糕的放置感知變數的手法,但在 C++ 中,當參數化建構函式(297 頁)手法由於建構函式中糾纏的邏輯而變得難以使用時,替換實例變數就成了最好的選擇。不過,在允許建構函式中呼叫虛擬函式的語言中,提取並重寫工廠方法(276 頁)通常是更好的選擇。」(p.319)——它是被語言限制逼出來的選項,不是首選。

  • ⚠️ 它在產品程式碼上留下一個設置方法,而這是不良實踐。 書中特別開了一個側欄講這個代價(p.319):

    「一般而言,提供設置方法來允許外界修改被用到的子物件屬於不良實踐。這些設置方法允許客戶程式碼徹底改變一個物件在其生命週期當中的行為。在旁人可以呼叫這些設置方法時,你就必須得了解目標物件的歷史狀態,方能知道對它的方法呼叫會帶來什麼後果。而當沒有設置方法時,程式碼便更易於理解。」

    也就是說:你為了能測試,主動在設計上加了一個會讓程式碼變得更難理解的口子。supersede 這個罕見字首正是用來監控這個口子有沒有被濫用。

  • 替換時要處理好舊物件的所有權。 步驟 3 明講:如果是引用,得確認類別中沒有其他成員還引用著舊物件(p.320)。

  • 不要為了控制工廠而動用引入靜態設置方法。 書中在 BlendingPen 的例子裡評估過:可以用引入靜態設置方法來控制工廠產出的物件,「但這麼改動的話就太具侵入性了」(p.319)。

  • ★★ 它要解決的問題在 Go 不存在(Go 沒有繼承,沒有「建構函式裡的虛擬分派」)。Go 的答案是 參數化建構函式。

  • ★★ 「事後 setter」在 Go 是明確要避免的形態:它讓型別可變(併發不安全),而且讓「是否初始化完成」變得不確定(半成品狀態)。

  • ★★ 真正值得帶走的推論:建構函式應該「只組裝,不做事」。 連線、讀檔、發請求、啟動 goroutine 要放在明確的 Start(ctx) 方法裡。

  • ★ 這讓「建立物件」永遠安全、快速、可測,而「啟動它」是可控制、可回傳 error、可取消的動作。

  • ★ 需要延後建立時注入「工廠函式」而非 setter:工廠函式在建構時就確定了,setter 讓物件有兩個狀態。

  • ★ 建構後替換只在「改不動第三方建構函式」時才需要,而那時通常已有 option。

🔗 相關工具