Practical Object-Oriented Design Book 心得&整理 (2)
不管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
社會中,政府負責國家運作、企業家負責公司營運、員工負責認真加班?、老師負責教導學生、學生認真唸書,運作良好的社會中充斥著各式各樣的角色,每個角色只專注一項責任、並把他做到最好。
你知道嗎?其實在物件導向中也是一樣的道理,每個物件負責一項屬於自身的責任,並把他做到最好 (Do one thing, and do it well)。如何設計物件並讓他將自身的責任做到最好,就是今天文章要探討的議題。
物件導向設計中有兩個重要的概念:
- Message thinking:專注在物件與物件之間傳遞的message,專注於物件能提供什麼樣的行為給客戶端 (What),而不是實作細節 (How),這是物件導向動態的表象。
- Class organize:專注在如何organize不同邏輯到各自的類別身上,專注於責任、而不是條列式的步驟,這是物件導向靜態的表象。
還記得碩士研修物件導向分析與設計時,書中提到:物件導向分析在於先針對問題設計出Domain Model (靜態表象),接著透過Grasp Pattern的方式轉換成Sequence Diagram (動態表象),兩者缺一不可,甚至Sequence Diagram的重要性遠遠比Domain Model還要來的重要。為什麼?因為物件導向系統的核心基礎為message,而Domain Model只是物件之間互動的靜態結果,即使Domain Model設計的非常漂亮,只要物件互動的方式造成耦合、Context的上升,都會增加改變的成本。
關於Dependencies的介紹我在上篇文章中有提到,這邊就不在重述。
Design Class with Single Responsibility:將要解決的問題切個成多個責任,並且有系統性的分配給各個類別,讓每個類別do one thing, and do it will。Design Class with Single Responsibility屬於Class organize的範疇,因為以靜態的表象切入思考、設計。
You have an application in mind. You know what it should do. You may even have thought about how to implement the most interesting bits of behavior. The problem is not one of technical knowledge but of organization; you know how to write the code but not where to put it.
開發軟體時,問題通常不會是技術、實作細節部分,而是這段程式碼應該要放在哪裡、怎麼放修改成本最小。Design Class with Single Responsibility給了我們很好的提示…
那麼,什麼是Single Responsibility?Responsibility的定義為何?為什麼要Single Responsibility?違反Single Responsibility會怎麼樣?如何設計遵守Single Responsibility的類別?接下來將會針對上述問題一一解答,並且最後透過實際案例,示範如何逐步重構違反Single Responsibility的類別,讓每個類別遵守Single Responsibility?
What is Single Responsibility?
什麼是Single Responsibility,Uncle bob的paper中有給出詳細的定義:
A class should have one, and only one, reason to change.
在設計類別的時候,我們要讓每個類別都只有一種改變的原因。
這邊也間接回答了什麼是Responsibility:Responsibility等同於reason to change。每種責任都會有一種改變的原因,因此當類別有多種改變原因時,代表包含過多責任,代表違反Single Responsibility。
Why Single Responsibility?
上一篇文章中提到,我們希望透過物件導向設計來降低修改軟體的成本,這也回答了為什麼要Single Responsibility。為什麼遵守Single Responsibility能讓修改成本變小呢?
很簡單,當系統中每個類別遵守Single Responsibility時,每當有不同的原因導致改變的產生 (reason to change),基本上只會影響一個或兩個類別,我們只需要修改少數的類別即可,同時也避免了Cascade Change的發生。
如下圖所示:類別A負責R1、類別B負責R2、類別C負責R3、類別D負責R4,R1、R2、R3、R4皆代表不同改變的原因 (different reason to change),只要系統因為上述R1~R4而變化時,只需修改一個類別即可完成修改,同時也滿足Local Consequence,在複雜系統中,能夠narrow down要修改的部分是提高生產力很重要的關鍵。
Violation of Single Responsibility
當我們違反Single Responsibility時,會伴隨著以下副作用:
- Hard to reuse
如下圖所示:當一個類別責任過多時,即便客戶端只需要reuse一小部分功能,客戶端也會被迫耦合到其他功能,造成此類別難以使用。當一個介面責任過多時,會造成implementor難以實作,丟出RuntimeException也會破壞客戶端的期望,進而導致違反LSP。
- Hard to test
如下圖所示:為了要測試負責過多責任的類別,測試檔案會變得越來越大,可能要寫數3~40個Test case才有辦法cover其功能,測試的過度肥大也讓測試類別違反Single Responsibility,可說是雪上加霜。
- Duplicate code everywhere
當類別不好reuse時,就會乾脆複製貼上最省事,複製貼上會造成專案的品質越來越差。
- Coupling multiple client
如圖所示:當類別負責越多責任時,不同種類的客戶端也就越多 (需要不同行為的客戶端),即便客戶端只使用一小部分行為,也會因為其他客戶端的行為受到影響,造成Cascade Change。
How to Check Wether a Class Obey Single Responsibility?
這邊提出以下方法可以幫助我們設計出遵守Single Responsibility的類別:
- Tell a Story
透過句子來描述此類別負責的功能,能幫助我們發現隱藏的責任。
舉個例子:Order麻煩告訴我Total Price、Order麻煩幫助我負責紀錄消費者加入哪些LineItem、Order麻煩負責從資料庫載入自己,前面兩句都符合Order類別應該要做的事情,但是第三句話就有點不相干了,從簡單的描述中,Order包含了負責LineItem紀錄與運算、從資料庫載入自己這兩種責任。
- Rate of Change
儘管敘述能夠幫助我們發現責任,但對於找出更深層的類別幫助是有限的,此時可以觀察資料與邏輯 (Data & Logic Together)變化的頻率。
範例:
public static class User {
private String firstName;
private String lastName;
private String age;
private String email;
public User(String firstName, String lastName, String age, String email) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.email = email;
}
public String getName() {
return this.firstName + " " + this.lastName;
}
public String getEmail() {
return email;
}
public String getAge() {
return age;
}
}
範例中User類別包含四個Field,分別是firstName、lastName、age、email。
getName函數告訴我們幾件事情:
- User需要負責把firstName與lastName串接起來
- firstName、lastName、getName變化的頻率一致
- 元素間變化頻率一致代表這些元素會因為同樣的原因受到改變,也就是負責一樣的責任,因此這些元素應該被放在同一個類別,進而產生了UserName類別。
修改後程式碼如下:
public static class User {
private UserName userName;
private String age;
private String email;
public User(UserName userName, String age, String email) {
this.userName = userName;
this.age = age;
this.email = email;
}
public UserName getName() {
return this.userName;
}
public String getEmail() {
return email;
}
public String getAge() {
return age;
}
}
private static class UserName {
private final String firstName;
private final String secondName;
public UserName(String firstName, String secondName) {
this.firstName = firstName;
this.secondName = secondName;
}
public String getName() {
return this.firstName + " " + this.secondName;
}
@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null || getClass() != object.getClass()) return false;
UserName userName = (UserName) object;
return Objects.equals(firstName, userName.firstName) &&
Objects.equals(secondName, userName.secondName);
}
@Override
public int hashCode() {
return Objects.hash(firstName, secondName);
}
}
- Same Level of Abstraction
當類別擁有抽象化程度不一的Field、Method時,High Level概念與Low Level概念夾雜在一起,此時類別包含多種改變的原因 (multiple reason to change),因此違反Single Responsibility。
範例:
public static class User {
private UserName userName;
private Integer age;
private String email;
public User(UserName userName, Integer age, String email) {
this.userName = userName;
if (age < 0 || age > 100)
throw new RuntimeException("Invalid age");
this.age = age;
if (!email.contains("@"))
throw new RuntimeException("Invalid email");
this.email = email;
}
public UserName getName() {
return this.userName;
}
public String getEmail() {
return email;
}
public Integer getAge() {
return age;
}
}
我們沿用上面User類別的範例,在User的建構子中,User負責判斷age、email的正確性,這類型的程式碼相信大家都不陌生,但這恰恰違反了Same Level of Abstraction。原因如下:
- User類別負責的責任為紀錄age、email、userName等資訊,也就是Tracking Responsibility。
- User類別為了記錄age,需要先針對age進行驗證,對於User來說,如何驗證age正確性這件事是次要的,User關心的是紀錄age、修改age,Tracking與Validation責任的抽象化程度不同。email也是一樣的道理。
上述原因都是在提示我們應該針對age、email建立適當的類別,如此一來User只需要專注其責任 (紀錄、修改),驗證的邏輯將會被封裝在Age、Email等類別。
修改後程式碼如下:
public static class User {
private UserName userName;
private Age age;
private Email email;
public User(UserName userName, Age age, Email email) {
this.userName = userName;
this.age = age;
this.email = email;
}
public UserName getName() {
return this.userName;
}
public Email getEmail() {
return email;
}
public Age getAge() {
return age;
}
}
public static class Email {
private final String value;
public Email(String value) {
if (!value.contains("@"))
throw new RuntimeException("Invalid email");
this.value = value;
}
@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null || getClass() != object.getClass()) return false;
Email email = (Email) object;
return Objects.equals(value, email.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
public static class Age {
private final Integer value;
public Age(Integer value) {
if (value < 0 || value > 100)
throw new RuntimeException("Invalid age");
this.value = value;
}
@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null || getClass() != object.getClass()) return false;
Age age = (Age) object;
return Objects.equals(value, age.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
不知道大家有沒有發現,重構的過程當中,很像是一個蘿蔔一個坑的概念,每個責任都像是蘿蔔,而適當的類別就是像一個坑,每條蘿蔔 (責任)都剛剛好塞到一個坑 (類別),硬是要把兩條蘿蔔塞到一個坑反而造成兩條蘿蔔都長不大 (不好修改)。
結論:
- Design a Class with Single Responsibility是一種Class organize的技巧,屬於物件導向靜態的表象。
- Design an Object with Message Passing是物件導向中的核心概念,屬於物件導向動態的表象。
- 遵守Single Responsibility能讓變動發生時,只需修改一個類別,並且降低責任之間的耦合,進而避免Cascade Change,降低改變的成本。
- 當Tell a Story說不出來時,可以使用Rate of Change、Same Level of Abstraction來幫助我們找出隱藏的抽象化 (類別)。
- 遵守一個蘿蔔一個坑,不要亂塞,等等兩條蘿蔔都長不大。