Practical Object-Oriented Design Book 心得&整理 (4)

Z-xuan Hong
18 min readMay 1, 2021

--

不管DDD、TDD、Design Pattern、Refactoring都是大師們淬鍊出來的結晶,如果我們連原料都沒有的話要如何淬鍊出跟大師們一樣的結晶呢?話不多說就讓本篇文章承接上一回繼續介紹物件導向設計,讓大家對於原料 (物件)的使用有更深入的認知。這樣一來不管是DDD、TDD、Design Pattern、Refactoring都能更容易上手。

未來文章根據下方依序介紹:

在先前的文章中,我們分別提到了如何設計遵守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的行為。

UML Class Diagram

接著我們針對修改前和修改後畫出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。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

熱愛軟體開發、物件導向、自動化測試、框架設計,擅長透過軟工幫助企業達到成本最小化、價值最大化。單元測試企業內訓、導入請私訊信箱:t107598042@ntut.org.tw。

No responses yet