Practical Object-Oriented Design Book 心得&整理 (4)
不管DDD、TDD、Design Pattern、Refactoring都是大師們淬鍊出來的結晶,如果我們連原料都沒有的話要如何淬鍊出跟大師們一樣的結晶呢?話不多說就讓本篇文章承接上一回繼續介紹物件導向設計,讓大家對於原料 (物件)的使用有更深入的認知。這樣一來不管是DDD、TDD、Design Pattern、Refactoring都能更容易上手。
未來文章根據下方依序介紹:
- What is Object-Oriented Design
- Designing Classes with a Single Responsibility
- Managing Dependencies
- Creating Flexible Interfaces (*)
- Reducing Costs with Duck Typing
- Acquiring Behavior through Inheritance
- Sharing Role Behavior with Module
- Combining Object with Composition
- Designing Cost-Effective Tests
在先前的文章中,我們分別提到了如何設計遵守Single Responsibility的類別、如何管理模組之間的Dependency,而這些設計都屬於物件導向靜態的表現,但物件導向系統在runtime運行時,是由物件之間彼此透過message溝通來達成特定的目標,因此今天要跟大家介紹如何設計出有彈性的物件介面 (物件能夠接受的message)。
Class vs Object
剛學習物件導向時,我們常會聽到Class與Object這兩個專有名詞,把這兩個名詞混淆的人也很多,因此想把物件導向學好,清楚掌握這兩個名詞的定義是不可或缺的。
- Class (類別):用來定義物件的內容,包含資料結構、函數等實作細節,屬於靜態的表現,程式碼中我們看到的都屬於Class。
- Object (物件):從Class中實例化出來的動態元件,專注提供客戶端一系列的服務 (Capabilities),屬於動態的表現。
看完上述的定義可能覺得有講跟沒講一樣,因此這邊舉個生活化的例子,讓大家對於Class與Object之間的關係能有更近一步的了解。
這個人手一機時代,相信大家都有手機。而手機就像是Class,手機中的愛瘋、Note、S系列就像是物件,手機這個概念為靜態的表現 (無法使用),日常生活中我們使用的手機為動態的表現 (接收我們的點擊、滑動、聲音控制等等)。
Single Responsibility、Dependency Management分別定義了應該Class負責哪些責任 (What)、應該與誰合作 (Who),Message則是定義了物件如何與其他物件溝通 (How)。即便是What、Who設計良好,當How出問題時,一樣會造成過度耦合,導致Cascade Change的發生。
想要與物件溝通,我們需要定義其介面,介面包含物件能夠接收的message。
如下面程式碼範例:當物件被實例化 (new Person)出來時,能夠接收兩個message,分別是talkTo與sleep。
public static class Person {
public void talkTo(Person personToTalk) {
// do something
}
public void sleep() {
// do something
}
}
Message Communication
上圖有兩個範例:
- 左圖:物件與物件溝通的方式非常複雜,當其中一個物件修改時,容易造成Cascade Change,而這些物件也難以在不同的Context底下被reuse。
- 右圖:物件與物件溝通的方式直覺、簡單,我們可以輕易劃分出多個Cluster,Cluster彼此之間不會互相影響,修改時影響的幅度較小。
當責任、依賴關係都設計好時,接著需要考慮物件與物件之間如何溝通,物件溝通的方式是透過message,物件的介面定義了其能接收哪些message,因此接下來將介紹介面的基本定義。
Interface
介面有很多種類,大致上的分類為:
- Class Interface:特定類別的物件能夠接收哪些message,通常以Concrete Class的方式呈現。
範例:Person Class定義兩種message。
public static class Person {
public void talkTo(Person personToTalk) {
// do something
}
public void sleep() {
// do something
}
}
- Related Classes Interface:相關類別的物件能夠接受哪些message,通常以繼承的方式呈現。
範例:Abstract Class定義了talk message,Women與Men都能接收talk message,但實作不同。
public static abstract class Person {
abstract void talk();
}
public static class Women extends Person {
@Override
void talk() {
// talk
}
}
public static class Men extends Person {
@Override
void talk() {
// talk
}
}
- Cross Unrelated Classes Interface:不同類別、彼此毫無關係的物件能夠接受哪些訊息,通常以role interface的方式呈現 (只關注物件能夠接收哪些訊息,至於物件屬於哪個類別不在乎)。
範例:Men Class與Cabinet Class皆實作了Displayable介面,即便兩者毫無關係 (語意、繼承架構上),都能夠接收display message。
interface Displayable {
void display(Graphics graphics);
}
public static class Men implements Displayable {
@Override
public void display(Graphics graphics) {
}
}
public static class Cabinet implements Displayable {
@Override
public void display(Graphics graphics) {
}
}
Define Interface
上述介紹了介面的基本種類,然而不是所有類別上的message都能夠給客戶端所使用,上述Message Communcation中我們知道,如何溝通是非常重要的一環,設計不好會導致Cascade Change。
定義介面的守則就是:透露物件內部的資訊越少越好,只根據客戶的Goal設計,不能透露任何實作細節 (Data、Library、Implementation Details)。
為了控制介面透露的資訊,常見的visibility有兩種:
- Public Interface:關注於物件提供客戶端哪些功能,客戶端通常使用物件的public interface與其溝通。
- Private Interface:當定義客戶期望的功能後,為了實作其功能,物件需要private interface輔助達到其目標,客戶端通常禁止使用private interface與其溝通。
範例:透過政治帶入日常讓大家更能了解Public與Private的差別,韓導是前高雄市長,所以需要接受質詢,議員會要求韓導提供接受質詢的服務,而我們都知道韓導也稱之為韓總機,因此為了達成接受質詢這個服務,需要多個private method來幫助,分別是:請衛生處長回答、請警局局長回答、跳針高雄發大財。
對於議員來說韓導怎麼接受質詢不重要,只要能提供接受質詢這個服務。假設哪天韓導不想當總機,只要把private method換成親自回答即可。透過Goal Driven的方式 (接受質詢)設計介面能夠增加系統的彈性與穩定度。
private static class 韓國瑜 {
public void 接受質詢() {
請衛生處長回答();
請警局局長回答();
高雄發大財();
}
private void 請衛生處長回答() {
}
private void 請警察局長回答() {
}
private void 高雄發大財() {
}
}
看完了上述介紹我們更清楚如何有效定義介面:
- 關注What而不是How
- Public Interface用來為客戶端提供目標
- Private Interface用來輔助物件達到其目標
- 客戶端只能使用Public Interface、客戶端耦合Private Interface容易導致Cascade Change。 (這也是為什麼物件導向中,強制物件的Field都應該設定為private,因為Field也屬於Private Interface的一種,如果耦合到Field會導致Cascade Change,沒辦法Isolate Change)
Find Stable Public Interface
書中提出以下方法,能幫助我們找出穩定的Public介面
- Focus on What, not How
範例:下面程式碼定義了兩個Class,分別是Formatter與ReportGenerator,從其互動可以發現幾件事情:
- 從Formatter的介面中,我們無法知道header、body、footer的呼叫順序,代表其介面沒有妥善封裝,導致使用者容易出錯。 (良好介面的設計必須要讓客戶端出錯的機率越低越好)
- 分配Format責任給Formatter是希望當Format實作細節變動時,不會影響其餘客戶端,但因為其介面曝露實作細節 (How)的緣故,假設format的步驟更改 (加入appendXXX),所有客戶端皆會受到影響,喪失了Decomposition的目的,而客戶端與Formatter強烈耦合,像是Single Large Entity。
public static class Formatter {
public String appendHeader(String result) {
// append header to the result and return
}
public String appendBody(String result) {
// append body to the result and return
}
public String appendFooter(String result) {
// append footer to the result and return
}
}
public static class ReportGenerator {
private Formatter formatter;
public ReportGenerator(Formatter formatter) {
this.formatter = formatter;
}
public String generateReport() {
String result = // data of report;
result = this.formatter.appendHeader(result);
result = this.formatter.appendBody(result);
result = this.formatter.appendFooter(result);
return result;
}
}
修改程式碼如下:專注What (formatCSV)而不是How (header、body、footer),能夠提供穩定的介面,降低客戶端與物件的耦合。
public static class Formatter {
public String formatCSV(String result) {
result = this.appendHeader(result);
result = this.appendBody(result);
result = this.appendFooter(result);
return result;
}
String appendHeader(String result) {
// append header to the result and return
}
String appendBody(String result) {
// append body to the result and return
}
String appendFooter(String result) {
// append footer to the result and return
}
}
public static class ReportGenerator {
private Formatter formatter;
public ReportGenerator(Formatter formatter) {
this.formatter = formatter;
}
public String generateReport() {
String result = // data of report;
return this.formatter.formatCSV(result);
}
}
- Trust your collaborator
上述範例中,ReportGenerator集中控制Formatter如何進行format,代表ReportGenerator不信任Formatter能夠把事情做好,這種模式偏向於程序導向,即便使用物件Decomposition,ReportGenerator仍然逃不過Central Global Control,最後變成God Object Anti-Pattern。
物件導向需要物件與物件之間彼此信任,從Central Global Control轉變成Decentralized Control,修改後的範例可以看到,ReportGenerator pass control給Formatter,並且相信他能做好formatCSV的工作。進而降低彼此之間耦合。
- Reduce object’s context
當使用者操作物件時,需要提供物件所需的Context,物件才能順利運行,所謂的Context指的是物件的dependency、runtime所需要的環境等。
從上述修改後,雖然降低了ReportGenerator與Formatter彼此之間的耦合,但有以下缺點:
- 為了使用ReportGenerator物件,我們需提供他一個能夠接收formatCSV message的物件,換句話說,ReportGenerator無法在不同Context底下被reuse (JSON、XML等等)。
因此我們需要降低ReportGenerator物件所需的Context,說白了就是抽象化,來增加其reusability。因此我們將formatCSV抽象化成format,就字面上來說,formatCSV相較於format更接近how,format更接近what,因此也符合focus on what的建議。
修改後如下:我們定義了Formatter,並且讓CSVFormatter實作Formatter,如此一來我們降低了ReportGenerator所需的Context,ReportGenerator只需要物件能夠接收format訊息,即可與其互動。
降低Context結果也讓我們得到了OCP的彈性,不管是JSON、XML等,只要實作Formatter即可為ReportGenerator替換不同格式。
public interface Formatter {
public String format(String result);
}
public static class CSVFormatter implements Formatter {
public String format(String result) {
result = this.appendHeader(result);
result = this.appendBody(result);
result = this.appendFooter(result);
return result;
}
String appendHeader(String result) {
// append header to the result and return
}
String appendBody(String result) {
// append body to the result and return
}
String appendFooter(String result) {
// append footer to the result and return
}
}// 以下省略
從上述例子證明了即便類別的責任、依賴關係設計良好,如果物件溝通的方式不對,還是會造成Cascade Change。
如下圖:修改前和修改後的Class Diagram都是一樣的,我們無法透過Class Diagram看出物件在runtime的行為。
接著我們針對修改前和修改後畫出Interaction Diagram:
修改前:
修改後:
前後最大的差異在於:
- Message:修改前有三個,修改後變為一個。
- Atomic Goal Driven Message:好的物件讓客戶端傳送的message越少越好,修改前明顯沒有達到此目標。
- Pass Control:修改前由於ReportGenerator的集中控制,導致兩個Class嚴重耦合。 (從UML Class Diagram看不出來)
- Encapsulation:修改前由於Formatter介面沒有妥善封裝,客戶端需要知道呼叫的步驟,當有多個客戶端時,我們不能確保所有客戶端的正確性。修改後把步驟封裝在format函數實作內部,讓客戶端不會有出錯的可能,進而保護了Formatter物件的功能完整性。
Law of Demeter
此原則告訴我們物件應該只有以下幾種溝通方式:
- 與參數傳進來的物件溝通
- 與物件的Collaborator溝通
- 與物件直接實例化出來的物件溝通
為什麼?
我們從上述知道Context對於物件的維護性非常重要,藉由遵守Law of Demeter能夠限制物件的Context,降低Cascade Change的影響。
違反時?
範例:當物件A呼叫物件B取得物件C,再透過物件C取得物件D時,會導致高度耦合,並且產生以下缺點:
- 物件A耦合了B、C、D,當B、C、D修改時,都會影響A,導致Cascade Change。
- 為了使用物件A需要提供B、C、D物件才能滿足物件A的Context,如此限制導致物件A難以在不同Context底下被reuse (不是所有Context都能夠提供B、C、D等物件)。
private static class A {
public void foo() {
getB().getC().getD().doSomething();
}
}
如何改善?
- 專注Goal Driven Message,物件能夠提供客戶端什麼服務?
- Delegate Goal Driven Message給Collaborator。
範例:上述修改後,我們讓物件A Delegate功能給物件B,物件B提供物件A所需要的功能。
修改後有以下好處:
- 降低耦合:現在物件A只知道物件B的存在,C、D修改不會影響物件A。
- 增加可用性:在不同Context,只要我們能為物件A提供一個物件能夠接收doSomething message即可達到reuse的效果。
private static class A {
public void foo() {
getB().doSomething();
}
}
結論:
- Class為靜態表現、Object為動態表現。
- 盡量讓客戶端相依於Public Interface,而不是Private Interface。
- 根據Goal來定義介面能增加介面的彈性。
- 信任物件、Pass Control是程序導向與物件導向最大的不同。
- 違反Law of Demeter容易導致Cascade Change。