邏輯被分割成方法(method),而不是全部擠在一大坨裡。為什麼?
從概念上說,你可以把任何程式組織成一個巨大的程式,控制流程到處跳。但這種「一大坨邏輯」有嚴重問題:
| 問題 | 說明 |
|---|---|
| 難以閱讀 | 難以區分重要部分和不重要部分 |
| 難以分層理解 | 無法先理解一部分、把細節留到後面 |
| 難以分離關注點 | 無法區分呼叫者需要知道的和修改者需要知道的 |
| 難以重用 | 巨大程式沒有方便的方式來引用部分邏輯以供後續重用 |
將程式邏輯分割成方法,給了你一種方式說:「這些邏輯片段彼此沒有緊密關聯。」進一步將方法分到類別、類別分到套件,延伸了這種溝通。方法的命名也給你機會向讀者傳達這段計算的目的,而非實作。讀者通常只需閱讀方法名稱就能獲取所需的資訊。
將大型計算分割成方法在概念上很簡單:把屬於一起的放在一起,把該分開的分開。實務上,你會花費時間和創造力來判斷什麼屬於一起、什麼不屬於,以及如何最好地進行分割。
方法分割的常見議題:
| 議題 | 說明 |
|---|---|
| 大小 | 太多太小的方法,讀者難以跟隨碎片化的表達;太少的方法導致重複和彈性損失 |
| 目的 | 解決常見問題的方法通常容易命名;解決獨特問題的方法命名更難但對讀者更重要 |
| 命名 | 方法名稱是溝通的核心工具 |
本章涵蓋的模式:
| 模式 | 說明 |
|---|---|
| Composed Method | 以對其他方法的呼叫來組合方法 |
| Intention-Revealing Name | 以方法的意圖來命名 |
| Method Visibility | 讓方法盡可能私有 |
| Method Object | 將複雜方法轉為獨立物件 |
| Overridden Method | 覆寫方法以表達特化 |
| Overloaded Method | 提供同一計算的替代介面 |
| Method Return Type | 宣告最通用的回傳型別 |
| Method Comment | 以註解傳達無法從程式碼輕易讀出的資訊 |
| Helper Method | 建立小型私有方法,使主要計算更簡潔 |
| Debug Print Method | 使用 toString() 印出有用的除錯資訊 |
| Conversion | 清楚地表達一種物件到另一種的轉換 |
| Conversion Method | 在來源物件上提供回傳轉換後物件的方法 |
| Conversion Constructor | 在目標物件的類別上提供接受來源物件為參數的方法 |
| Creation | 清楚地表達物件建立 |
| Complete Constructor | 撰寫回傳完整形成物件的建構式 |
| Factory Method | 以類別的靜態方法表達更複雜的建立 |
| Internal Factory | 將可能需要解釋或日後精煉的物件建立封裝在 helper method 中 |
| Collection Accessor Method | 提供允許有限存取集合的方法 |
| Boolean Setting Method | 若有助於溝通,為每個狀態各提供一個設定 boolean 值的方法 |
| Query Method | 以 isXXX 命名的方法回傳 boolean 值 |
| Equality Method | 一起定義 equals() 和 hashCode() |
| Getting Method | 偶爾以方法提供對欄位的存取 |
| Setting Method | 更少見地以方法提供設定欄位的能力 |
| Safe Copy | 透過複製傳入或傳出存取方法的物件,避免 aliasing 錯誤 |
Composed Method#
以對其他方法的呼叫來組合方法,每個被呼叫的方法大致在相同的抽象層次。
組合不良的方法的一個徵兆是混合抽象層次:
void compute() {
input();
flags |= 0x0080;
output();
}這種程式碼讓讀者感到突兀。程式碼在抽象層次平順流動時最容易理解,突然切換抽象層次會打斷流暢感。
flowchart TD
A["compute()"] --> B["input()"]
A --> C["process()"]
A --> D["output()"]
B --> B1["readFile()"]
B --> B2["parseData()"]
C --> C1["validate()"]
C --> C2["transform()"]
D --> D1["formatResult()"]
D --> D2["writeOutput()"]
style A fill:#ffd,stroke:#333,stroke-width:2px
style B fill:#ddf,stroke:#333
style C fill:#ddf,stroke:#333
style D fill:#ddf,stroke:#333
style B1 fill:#dfd,stroke:#333
style B2 fill:#dfd,stroke:#333
style C1 fill:#dfd,stroke:#333
style C2 fill:#dfd,stroke:#333
style D1 fill:#dfd,stroke:#333
style D2 fill:#dfd,stroke:#333方法長度#
- 有人建議數值限制(如不超過一頁或 5-15 行),但限制引發了為什麼的問題
- 讀者在閱讀整體結構時,一次看到大量程式碼是有價值的——空白提供了整體結構和複雜度的線索
- 同一個大方法在你要理解細節時卻成了障礙——你一次只能有效地容納一個「腦容量」的細節
效能考量#
方法調用的效能開銷(百萬次迭代 vs 百萬次訊息)平均約 20-30%,不足以影響大多數程式的效能。更快的 CPU 和效能瓶頸的高度局部性特質,使得效能問題最好留到能從真實資料集收集統計數據時再處理。
實務建議#
- 基於事實而非推測來組合方法——先讓程式碼運作,再決定結構
- 如果預先花大量時間結構化程式碼,當你在實作過程中學到新東西時,就得重做
- 當所有邏輯細節都攤在面前時,更容易合理地組合方法
- 如果分割結果難以閱讀,inline 所有方法直到有一個巨大方法,然後基於最近的經驗重新分割
與特化的關係#
大小適當的方法可以被整體覆寫(override),而不必把程式碼複製到子類別再編輯,也不必為一個概念性的變更覆寫兩個方法。
Intention-Revealing Name#
方法應以潛在呼叫者可能有的目的來命名,而非實作策略。
不好的例子:
Customer.linearCustomerSearch(String id)較好的例子:
Customer.find(String id)身為程式碼作者的目標不是盡快把你知道的一切都說出來——有時候需要克制。除非實作策略與使用者相關,否則不要放在名稱裡。好奇的人可以查看方法本體。
即使提供兩種不同效能的查找:
Customer.find(String id)
Customer.fastFind(String id)也應從呼叫者的角度來溝通差異,而非實作細節(hash table 或 tree)。
命名建議:
- 基於方法在呼叫端程式碼中的樣貌來思考名稱——讀者最可能在那裡首次遇到這個名稱
- 呼叫端方法應該在講述故事——命名方法使它們幫助說故事
- 如果你在實作與既有介面類似的方法,使用相同的名稱(如
hasNext()和next()) - 如果只是有點相似,先思考是否使用了正確的隱喻,然後以前綴表達差異
Method Visibility#
四種可見性層級——public、package、protected、private——各自表達不同的意圖。
兩個最大的衝突限制是:
- 需要向外部使用者揭露某些功能
- 需要維持未來的彈性
揭露越多方法,未來需要變更物件介面時就越困難。
各層級的意義#
public:宣告方法 public 表示你相信它在宣告它的套件之外有用。你接受了維護它的責任——保持不變、修正所有呼叫者、或至少通知呼叫它的程式設計師
public Object next();這個宣告說現在和可預見的未來,
next()將對客戶端可用。package:表示這個方法對此套件中的物件有用,但你不願意承諾讓它對外部物件可用。這是一種奇怪的聲明——視為一個信號,表示功能可能應該被移動使方法更不可見,或者該方法比你懷疑的更廣泛有用
protected:只在透過子類別提供程式碼重用時有用。雖然看似比 package 更嚴格,但兩者實際上是正交的——套件外的子類別可以看到並調用 protected 方法
private:在未來彈性上是極致——你可以保證找到並變更所有呼叫者。宣告方法 private 表示這個方法對外部的價值不值得使其更廣泛可用的成本
策略#
- 逐步揭露——從最嚴格的可見性開始,必要時再揭露
- 如果方法不再需要那麼可見,就降低其可見性(僅在你能存取所有呼叫者時才行)
- 最初認為是 private 的方法,以新方式使用物件後,常常成為介面的有價值成員
final 和 static#
- final:宣告方法 final 表示你不介意別人使用這個方法,但不允許任何人改變它。你用排除有人可以有效覆寫它的可能性,來交換沒人會意外破壞你物件的保證
- static:即使呼叫者沒有該類別的實例也能呼叫。Static 方法不能依賴任何實例狀態,因此不適合放置複雜邏輯。一個好的 static 方法用途是替代建構式
Method Object#
這是作者最喜愛的模式之一。Method Object 可以幫助你將塞在一個不可能方法裡的糾結程式碼,轉變為可讀、清晰、逐步揭示細節的程式碼。
何時使用#
尋找具有以下特徵的長方法:
- 大量參數和暫時變數
- 嘗試提取任何部分都會導致長參數列表和難以命名的子方法
建立步驟#
- 建立一個以方法命名的類別(例如
complexCalculation()變成ComplexCalculator) - 為方法中使用的每個參數、區域變數和欄位,在新類別中建立一個同名欄位
- 建立一個接受原方法參數和原物件使用欄位的建構式
- 將方法複製到新類別的
calculate()方法中——原方法的參數、區域變數和欄位變成新物件的欄位參照 - 將原方法的本體替換為建立新類別實例並調用
calculate()的程式碼:complexCalculation() { new ComplexCalculator().calculate(); } - 如果原方法中有設定欄位,在
calculate()回傳後設定它們:complexCalculation() { ComplexCalculator calculator = new ComplexCalculator(); calculator.calculate(); mean = calculator.mean; variance = calculator.variance; }
classDiagram
class OriginalClass {
-field1
-field2
+complexCalculation()
}
class ComplexCalculator {
-field1
-field2
-param1
-param2
-localVar1
-localVar2
+calculate()
}
OriginalClass ..> ComplexCalculator : 建立並委派
note for ComplexCalculator "原方法的參數、區域變數、\n欄位都變成新物件的欄位"重構的樂趣#
確認重構後的程式碼和舊程式碼行為一致後,樂趣就開始了:
- 新類別中的程式碼很容易重構——你可以提取方法而不需要傳遞任何參數,因為所有資料都存在欄位中
- 開始提取方法後,你常會發現有些變數可以從欄位降級為區域變數
- 之前難以隔離的共同子表達式可能變成有意義名稱的 helper method
如果懷疑需要 method object 時,原方法已經被切成碎片了,先 inline 所有子方法,讓一切回到同一處再開始。需要呼叫原物件方法的情況是需要更多 inline 的明確指標。
Overridden Method#
物件導向程式設計之美在於它提供多種方式來表達相似計算之間的差異。Overridden method 是清楚表達變化的方式。
- 在超類別中宣告為 abstract 的方法是明確的特化邀請
- 任何未宣告為 final 的方法都是表達既有計算變化的候選者
- 超類別中良好組合的方法提供大量潛在的掛鉤
覆寫不是非此即彼的——你可以透過調用 super.method() 同時執行子類別和超類別的程式碼。但需注意:
- 只對同名方法這樣做
- 如果子類別明確選擇有時調用自己的程式碼、有時為多種方法調用超類別程式碼,類別會難以跟隨且容易意外破壞
- 如果覺得需要調用不同的超類別方法,應重構控制流程直到不再需要在子類別和超類別之間來回跳躍
超類別中太大的方法會造成兩難:是把程式碼複製到子類別然後編輯,還是找其他方式表達變化?複製的問題是別人之後可能更改你複製的超類別程式碼,在你(或他們)不知情的情況下破壞你的程式碼。
Overloaded Method#
當你以不同的參數型別宣告相同方法時,你在表達:「這些是這個方法參數的替代格式。」
例如,一個方法可以接受代表檔案名稱的 String 或 OutputStream——為想用檔案名稱的使用者提供簡單介面,同時為想傳入已建好的 stream 的使用者保留彈性。Overloaded method 免除了呼叫者轉換參數的責任。
使用不同數量參數的同名方法是 overloading 的一種變體,但有缺點:
- 讀者不只需要讀方法名稱,還需要讀參數列表才知道調用了哪個方法
- 如果 overloading 複雜,讀者需要理解微妙的 overload resolution 規則
重要原則:
- Overloaded method 應該服務相同目的,變化僅在參數型別
- 不同的 overloaded method 有不同的回傳型別會使程式碼太難閱讀
- 不同的計算應該有不同的名稱
Method Return Type#
方法的回傳型別首先表明該方法是透過副作用運作的 procedure,還是回傳特定型別物件的 function。void 讓 Java 避免了區分兩者的關鍵字。
選擇回傳型別的原則:
- 有時意圖是特定的——具體類別或基本型別
- 但你希望方法盡可能廣泛適用,所以選擇表達你意圖的最抽象回傳型別
- 這保留了你未來更換具體回傳型別的彈性
泛化回傳型別也可以隱藏實作細節:
- 例如回傳
Collection而非List,可以鼓勵使用者不假設元素有固定順序
回傳型別是程式演進中常見的變更區域:
- 你可能一開始回傳具體類別
- 後來發現數個相關方法回傳不同的具體類別,每個都共享(或應該共享)一個共同介面
- 宣告共同介面(如有需要)並從所有方法回傳新介面,有助於讀者理解相似性
Method Comment#
盡可能透過程式碼的名稱和結構表達資訊。加入註解以表達無法從程式碼明顯看出的資訊。在預期之處加入 javadoc 註解來解釋方法和類別的目的。
- 在為溝通而撰寫的程式碼中,許多註解是完全多餘的——撰寫和維護它們與程式碼一致性的成本不值得
- Method comment 處於尷尬的抽象層次——如果兩個方法之間有限制(例如必須先呼叫一個再呼叫另一個),註解該放哪裡?
- 註解必須與程式碼分別維護,且當註解不再有效時沒有即時回饋
自動化測試可以傳達不自然適合放在 method comment 中的資訊:
- 你可以撰寫測試確保以錯誤順序調用方法時拋出適當的 exception
- 自動化測試有許多優勢:撰寫測試是有價值的設計練習(尤其在實作之前)、如果測試能執行就與程式碼一致、自動化重構工具可以低成本地保持測試更新
溝通仍然是這些實作模式中的至高價值。如果 method comment 是最佳的溝通媒介,就寫一個好的註解。
Helper Method#
Helper method 是 composed method 的結果。如果你要把大方法分割成數個小方法,你就需要這些小方法——它們就是 helper。
Helper method 的目的:
- 隱藏暫時不相關的細節,使大規模計算更可讀
- 透過方法名稱表達你的意圖
- 通常宣告為 private,如果類別打算被子類別精煉則移至 protected
你可能建立一個 private helper,後來發現外部使用者想調用它。即使 helper 永遠不「畢業」,它作為溝通的據點仍然有價值。
注意事項#
Helper 傾向簡短,但可能太短。例如,一個只回傳類別建構式的 helper:
// 以下兩者溝通效果相當: return testClass.getConstructor().newInstance(); return getTestConstructor().newInstance();不過,如果子類別會覆寫建構式的計算方式,那個 helper method 仍然合理
當方法邏輯變得不清楚時,消除 helper(至少暫時)——inline 所有 helper method,重新審視邏輯,再重新提取有意義的方法
Helper method 的最後一個用途是消除共同子表達式——如果你在類別中每個需要某個小計算的地方都呼叫一個 helper method,變更那個表達式就很容易
Debug Print Method#
將物件渲染為字串有許多潛在原因:呈現給使用者、儲存以供後續檢索、或向程式設計師呈現物件內部。
Object 介面有十一個方法,其中 toString() 將接收者渲染為字串——但為了什麼目的?
- 一個誘惑是一次滿足多個目的——但這些折衷很少奏效
- 債券交易員、程式設計師和資料庫想知道的關於物件的事情是不同的
投資於高品質的 debug printing 是有槓桿效應的:
- 在開發環境中發現物件的重要內部細節可能需要半分鐘的滑鼠點擊
- 將同樣的細節渲染在
toString()中,同樣的資訊只需一秒鐘一次點擊 - 在密集的除錯過程中,保持專注可以節省數分鐘甚至數小時
由於
toString()是 public,它容易被濫用——人們會解析 print string 來取得有用資訊。這種程式碼很脆弱,因為變更toString()很常見。防止濫用的最佳策略是確保物件有客戶端需要的所有 protocol。
因此,當你需要提供對程式設計師友善的物件渲染時覆寫 toString()。其他字串渲染作為物件上的其他方法或在獨立類別中撰寫。
Conversion#
有時你有物件 A,但需要物件 B 來傳遞給後續計算。如何表達從來源物件到目標物件的轉換?
影響轉換表達方式的技術因素:
- 轉換數量:如果物件只需要轉換成一個其他物件,可以用簡單的方式;潛在無限數量的轉換則需要不同方式
- 類別之間的依賴:不值得為了方便的轉換表達而引入新的依賴
轉換的實作是另一個議題:
- 有時建立新型別的真實物件,從來源複製資訊
- 有時可以實作目標物件的介面而不從來源複製資訊
- 有時可以為兩個物件找到共同介面,直接對該介面編程
Conversion Method#
如果你需要在相似型別的物件之間表達轉換,且轉換數量有限,就在來源物件上提供一個方法來表達轉換。
例如,實作笛卡兒和極座標的轉換:
class Polar {
Cartesian asCartesian() {
...
}
}反之亦然。注意轉換方法的回傳型別是目標物件特定的類別。
Conversion method 的優缺點:
- 優點:讀起來很好(Eclipse 中有超過一百個範例)
- 缺點:
- 需要能夠修改來源物件的 protocol
- 從來源物件引入對目標物件的依賴——如果這種依賴尚不存在,不值得為了轉換方法的便利而引入
- 當潛在轉換數量無限時變得笨重——一個有二十個不同
asThis()和asThat()的類別難以閱讀
因此,謹慎使用 conversion method,且僅在轉換到相似型別物件的情況。否則使用 conversion constructor。
Conversion Constructor#
Conversion constructor 接受來源物件作為參數,回傳目標物件。當需要將一個來源物件轉換成多個目標時,conversion constructor 很有用——因為轉換不會全部堆積在來源物件上。
例如,File 支援一個將代表檔案名稱的 String 轉換成適合讀寫刪除的物件的 conversion constructor。雖然 String.asFile() 很方便,但 String 的這類轉換沒有盡頭——因此用以下方式更好:
File(String name)
URL(String spec)
StringReadStream(String contents)否則 String 會有無限數量的 conversion method。
如果你需要回傳具體類別以外的東西的自由度,conversion constructor 可以表達為回傳更通用型別的 factory method(或放在被方法建立的類別以外的類別上)。
Creation#
在早期(半個世紀前),程式是程式碼和資料的巨大未分化塊。然後人們發現一個尷尬事實:程式被撰寫的目的既是為了被執行,也是為了被修改。所有那些跳來跳去的控制和自我修改的程式碼,對執行很好,但對之後的修改很糟糕。
物件將大程式分割成小電腦運行小程式,藉由提供事件視界(event horizon)來服務未來的變更——超過這個視界,對程式的變更成本很低。
這種分割是為了人類的目的——適應我們易錯的、變化的、有創造力的心智,不是為了電腦的好處。對人類讀者來說,建立物件是一個陳述:某些狀態歸在一起以支援某些計算,其細節目前無關。
表達性地使用物件建立需要在清楚直接的表達和彈性需求之間取得平衡。
Complete Constructor#
物件在能夠計算之前需要某些資訊。透過提供回傳準備好計算的物件的建構式,向潛在使用者傳達先決條件。如果有多種設定物件的方式,提供多個建構式,每個都回傳良好形成的物件。
new Rectangle(0, 0, 50, 200);另一種方式——用零參數建構式後接一系列 setting method——不能傳達哪些參數組合是正確運作所必需的:
Rectangle box = new Rectangle();
box.setLeft(0);
box.setWidth(50);
box.setHeight(200);
box.setTop(0);看到四參數建構式,你就知道四個參數都是必要的。
重要注意事項:
- 建構式將客戶端綁定到具體類別——如果你想讓程式碼更抽象,引入 factory method
- 即使有 factory method,也在其下提供 complete constructor,讓好奇的讀者快速理解建立物件需要什麼參數
- 實作時,將所有建構式匯集到單一主建構式進行所有初始化——確保所有變體建構式建立的物件滿足所有不變式,並向未來修改者傳達這些不變式
Factory Method#
表達物件建立的另一種方式是類別上的靜態方法。Factory method 比建構式有幾個優勢:
- 可以回傳更抽象的型別(子類別或介面的實作)
- 可以以意圖命名,而不只是類別名稱
Rectangle.create(0, 0, 50, 200);但 factory method 增加了複雜性,應在其優勢有價值時使用,而非作為常規。
何時使用 factory method:
- 意圖比單純建立物件更複雜,如在快取中記錄物件或在執行時期決定建立哪個子類別
- 如果只是普通的物件建立,用建構式表達就好——使用 factory method 會浪費讀者時間去猜測還有什麼在發生
Factory method 的變體是將相關的 factory method 聚集為特殊工廠物件的實例方法。當你有數個同時變化的具體類別時很有用——例如,每個作業系統可以有不同的工廠物件來建立進行作業系統呼叫的物件。
flowchart LR
A["Complete Constructor\n直接 new"] --> B["Factory Method\n靜態方法建立"]
B --> C["Internal Factory\n封裝在 helper 中"]
A -.- A1["✅ 簡單直接\n❌ 綁定具體類別"]
B -.- B1["✅ 可回傳抽象型別\n✅ 意圖命名"]
C -.- C1["✅ 支援 lazy init\n✅ 邀請子類別精煉"]Internal Factory#
當 helper 物件的建立是 private 但複雜或可能被子類別變更時,建立一個建立並回傳新物件的方法。
Internal factory 常見於 lazy initialization:
getX() {
if (x == null)
x = ...;
return x;
}getter method 的重點是聲明變數正在被延遲初始化。如果 x 的計算有任何複雜性,可以有效地延遲到 internal factory:
getX() {
if (x == null)
x = computeX();
return x;
}Internal factory 也是對子類別精煉的邀請。使用相同演算法但不同資料結構的計算可以透過 internal factory 表達。或者,你也可以將資料結構作為參數傳遞給 helper 物件。
Collection Accessor Method#
假設你有一個包含集合的物件。如何提供對該集合的存取?
最簡單的方案是提供集合的 getting method:
List<Book> getBooks() {
return books;
}這給客戶端最大彈性,但有問題:
- 依賴集合內容的內部狀態可能在你背後被無效化
- 提供這種全功能存取也錯過了為物件建立豐富、有意義 protocol 的機會
替代方案#
用不可修改集合包裝:
List<Book> getBooks() {
return Collections.unmodifiableList(books);
}但包裝器只在編譯器層面假裝是集合——任何修改嘗試都會在執行時期拋出 exception。除錯這類錯誤(尤其在生產環境中)是昂貴的。
更好的方式是提供有限且有意義的存取方法:
void addBook(Book arrival) {
books.add(arrival);
}
int bookCount() {
return books.size();
}如果客戶端需要遍歷集合元素,提供回傳 iterator 的方法:
Iterator getBooks() {
return books.iterator();
}如果要確保客戶端不能變更集合內容,回傳一個移除操作拋出 exception 的 iterator:
Iterator<Book> getBooks() {
final Iterator<Book> reader = books.iterator();
return new Iterator<Book>() {
public boolean hasNext() {
return reader.hasNext();
}
public Book next() {
return reader.next();
}
public void remove() {
throw new UnsupportedOperationException();
}
};
}如果你發現自己在重複大部分的集合 protocol,很可能有設計問題。如果你的物件為客戶端做更多工作,它就不必提供這麼多對其內部的存取。
Boolean Setting Method#
如何最好地提供設定 boolean 狀態的 protocol?最簡單的方案是裸 setting method:
void setValid(boolean newState) {
...
}如果客戶端需要這種彈性,這個風格沒問題。然而,當所有對 setting method 的呼叫都是常數 true 或 false 時,可以提供更具表達力的介面——為每個 boolean 值各一個方法:
void valid() { ... }
void invalid() { ... }使用此介面的程式碼讀起來更好,且更容易靜態發現狀態在哪裡被設定。
但如果你看到這樣的程式碼:
if (...boolean expression...)
cache.valid();
else
cache.invalid();那就直接提供 setValidity(boolean) 吧。
Query Method#
有時物件需要基於另一個物件的狀態做決定。這不是理想的——另一個物件通常應該自己做決定。但當物件需要將決策標準作為 protocol 的一部分提供時,使用名稱以 “be” 的某種形式(如 is、was)或 “have” 為前綴的方法。
如果一個物件有大量依賴另一個物件狀態的邏輯,這是邏輯擺錯位置的線索:
if (widget.isVisible())
widget.doSomething();
else
widget.doSomethingElse();那麼 widget 很可能缺少一個方法。
嘗試移動邏輯,看是否讀起來更清楚。有時這些移動會違反你對哪個物件負責計算哪部分的先入之見。但相信並按照你眼睛看到的證據行動,通常會改善設計。
Equality Method#
當兩個物件需要比較相等性(例如作為 hash table 的 key),但它們的身份不重要時,實作 equals() 和 hashCode()。因為兩個相等的物件必須有相同的 hash value,只使用計算相等性時使用的資料來計算 hash。
// Instrument
public boolean equals(Object other) {
if (!(other instanceof Instrument))
return false;
Instrument instrument = (Instrument) other;
return getSerialNumber().equals(instrument.getSerialNumber());
}注意方法開頭的 guard clause。如果你知道跨類別比較是程式設計錯誤,可以消除 guard clause 讓 ClassCastException 被拋出,或在 guard clause 內拋出 IllegalArgumentException。
由於序號是比較相等性時唯一使用的資訊,它也是計算 hash value 時唯一應使用的資料:
// Instrument
public int hashCode() {
return getSerialNumber().hashCode();
}對小資料集,
0作為 hash code 完全可以運作。
另一種避免處理 equality 的方式是確保兩個相等的不可變物件就是同一個物件。例如,在 factory method 中分配 Instrument:
// Instrument
static Instrument create(String serialNumber) {
if (cache.containsKey(serialNumber))
return cache.get(serialNumber);
Instrument result = new Instrument(serialNumber);
cache.put(serialNumber, result);
return result;
}Getting Method#
提供物件狀態存取的一種方式是提供回傳該狀態的方法。按 Java 慣例,這些方法以 get 為前綴:
int getX() {
return x;
}是否撰寫 getting method(或至少使其可見)比如何撰寫更重要、更有趣:
- 遵循邏輯和資料放在一起的原則,需要 public 或 package 可見的 getting method 是一個線索——邏輯應該搬到別處
- 與其撰寫 getting method,不如嘗試移動使用資料的邏輯
Getting method 的例外情況:
- 有一組演算法位於自己的物件中——演算法需要存取資料,需要 getting method 來接收
- 你想要一個 public 方法,而它恰好透過回傳欄位值來實作
- 將被工具調用的 getting method 通常必須是 public
內部 getting method(private 或 protected)對實作 lazy initialization 或 caching 很有用。但如同所有額外的抽象,這些精煉最好在需要時才引入。
Setting Method#
如果需要設定欄位值的方法,以欄位名稱加 set 前綴命名:
void setX(int newX) {
x = newX;
}Setting method 比 getting method 更不應該被公開。Setting method 以實作命名而非意圖命名。如果介面的某個有用部分最好透過設定欄位來實作,那沒問題——但方法名稱應從客戶端程式碼的角度撰寫。
讓實作洩漏的 setting method:
paragraph.setJustification(Paragraph.CENTERED);以方法目的命名,讓程式碼說話:
paragraph.centered();即使 centered() 的實作就是一個 setting method:
Paragraph.centered() {
setJustification(CENTERED);
}內部 setting method(private 或 protected)可以有價值——例如更新依賴資訊:
private void setJustification(...) {
...
redisplay();
}這種 setting method 的用法像是簡單的約束引擎——確保當資料變更時,依賴的資料也跟著變更。
Setting method 使程式碼脆弱。避免遠距作用(action at a distance)——如果物件 A 依賴物件 B 內部表示的細節,B 的程式碼變更也會要求 A 的程式碼變更。更好的做法是將邏輯和資料放在一起。
Safe Copy#
使用 getting 或 setting method 時,你可能遇到 aliasing 問題——兩個物件各自假設它們對第三個物件有獨佔存取。Aliasing 問題是更深層設計問題的症狀(如缺乏對哪個物件負責哪些資料的清晰度),但你可以透過在回傳或儲存之前複製物件來避免一些缺陷:
List<Book> getBooks() {
List<Book> result = new ArrayList<Book>();
result.addAll(books);
return result;
}Setting method 也可以用 safe copy 撰寫:
void setBooks(List<Book> newBooks) {
books = new ArrayList<Book>();
books.addAll(newBooks);
}作者曾審查過一個銀行系統,其中 safe copy 被過度使用。每個存取方法都有「安全」和「不安全」兩個版本。為了消除 aliasing 缺陷,每次調用 safe copy 方法時都複製巨大的物件結構。系統太慢,客戶端傾向使用不安全版本,結果產生了大量 aliasing 缺陷。根本的設計問題——物件沒有提供足夠有意義的 protocol——從未被解決。
Safe copy 嚴格來說是治標的手段,用於保護程式碼免受不受控的外部存取。它很少應該是實作核心語義的一部分。不可變物件和 composed method 提供更簡單、更具溝通力且更不易出錯的介面。
結語#
本章描述了建立方法的模式。這結束了與 Java 語言相關的模式。下一章將描述使用集合類別的模式。