Practical Object-Oriented Design Book 心得&整理 (5)
不管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
在上一篇文章中我們介紹了如何設計類別的介面,知道了根據客戶端的目標定義介面能夠減少彼此之間的耦合。相信大家或多或少有聽過繼承這個概念 (關於繼承會在後面文章中介紹)。如果說繼承是針對相似類別進行介面的設計,那麼今天要介紹的概念:Duck Type,則是針對彼此無關聯的類別進行介面的設計。
What is Duck Type
在書中的定義如下:
Duck types are public interfaces that are not tied to any specific class. These across-class interfaces add enormous flexibility to your application by replacing costly dependencies on class with more forgiving dependencies on messages.
翻譯成白話文就是:Duck Type的介面不屬於特定的類別,其特性讓客戶端耦合到message,而不是特定的類別,有助於彈性的提升。
之前文章中多次提到,message是物件導向設計很重要的概念,透過message的角度設計物件,能有效降低耦合,而Duck Type就是message概念發揮到極致的展現。
當物件能接收Duck Type定義的message時,此時無論什麼類別都不重要了,也因為如此,讓類別對於客戶端的影響大幅度降低。當我們改變系統行為時,只需要替換不同類別的物件,並確保他能接受Duck Type定義的message即可修改系統行為。
可能有人會說Duck Type只能應用在動態語言,但物件導向的概念與語言是無關的,本篇文章的範例就是用Java來撰寫。透過學習OO概念,而不是把思考邏輯限縮在特定語言,能讓我們寫出品質更優良的程式碼。也能在學習中獲得更多的樂趣。
Why Duck Type
- 彈性:當客戶端使用Duck Type時,不會耦合到特定的類別。替換行為只需要給予接收同樣message的物件即可。
- 提升抽象化:當系統過度針對特定類別操作時,系統的Context會限縮到特定類別,使用Duck Type能提升系統抽象化,達到Context Indepedent的效果。
- 降低重複:當針對不同類別進行特殊處理時,這些類別的資訊會在多個客戶端重複,進而增加修改的成本,Duck Type讓客戶端不再針對特定類別進行特殊處理,因此大幅降低類別資訊的重複。
- 修改影響範圍降低:當系統對於特定類別的耦合降低時,能夠達到Isolate Change、降低修改成本的效果。
實際範例:
相信大家都有玩過英雄打怪的遊戲,當英雄吃到補品時,根據不同種類的補品會有不同效果,像是讓英雄的血量、魔力、戰鬥力提升等等。
因此我們設計一個類別為Hero,並且有血量、魔力、戰鬥力。
private static class Hero {
private int healthPoint;
private int magicPoint;
private int combatEffectiveness;
public Hero() {
this.healthPoint = 100;
this.magicPoint = 100;
this.combatEffectiveness = 10;
}
}
為了完成英雄吃到補品的功能,我們要先思考幾件事情:
- 客戶端期望期望達到什麼目標?
- 客戶端想要傳送什麼message?
- 客戶端需要什麼參數?
上述問題的答案為:
- 客戶端希望英雄能達到吃補品的目標。
- 客戶端會傳送eatGoods message。
- 客戶端需要提供補品給英雄。
經過上面簡單的問題,我們幫Hero物件設計出以下API:
private static class Hero {
private int healthPoint;
private int magicPoint;
private int combatEffectiveness;
public Hero() {
this.healthPoint = 100;
this.magicPoint = 100;
this.combatEffectiveness = 10;
}
public void eatGoods(Goods goods) {
// do somethings
}
}
private static class Goods {
}
接著我們要完成以下功能:
- 當英雄吃到血量補品時,血量加上10
- 當英雄吃到超級血量補品時,血量加上20
- 當英雄吃到魔力補品時,魔力加上10
- 當英雄吃到超級魔力補品時,魔力加上20
- 當英雄吃到戰鬥力補品時,戰鬥力加上100
如果不使用OO的角度思考,容易寫出以下程式碼:
private static class Hero {
private int healthPoint;
private int magicPoint;
private int combatEffectiveness;
public Hero() {
this.healthPoint = 100;
this.magicPoint = 100;
this.combatEffectiveness = 10;
}
public void eatGoods(Goods goods) {
if (goods instanceof HealthGoods) {
HealthGoods healthGoods = (HealthGoods)goods;
this.healthPoint += healthGoods.getHealthPoint();
} else if (goods instanceof SuperHealthGoods) {
SuperHealthGoods superHealthGoods = (SuperHealthGoods)goods;
this.healthPoint += superHealthGoods.getHealthPoint();
} else if (goods instanceof MagicGoods) {
MagicGoods magicGoods = (MagicGoods)goods;
this.magicPoint += magicGoods.getMagicGoods();
} else if (goods instanceof SuperMagicGoods) {
SuperMagicGoods superMagicGoods = (SuperMagicGoods)goods;
this.magicPoint += superMagicGoods.getMagicGoods();
} else if (goods instanceof CombatEffectivenessGoods) {
CombatEffectivenessGoods combatEffectivenessGoods = (CombatEffectivenessGoods)goods;
this.combatEffectiveness += combatEffectivenessGoods.getCombatEffectivenessPoint();
}
}
}
private static class Goods {
}
private static class HealthGoods extends Goods {
public HealthGoods() {
}
public int getHealthPoint() {
return 10;
}
}
private static class SuperHealthGoods extends Goods {
public SuperHealthGoods() {
}
public int getHealthPoint() {
return 20;
}
}
private static class MagicGoods extends Goods {
public MagicGoods() {
}
public int getMagicGoods() {
return 10;
}
}
private static class SuperMagicGoods extends Goods {
SuperMagicGoods() {
}
public int getMagicGoods() {
return 20;
}
}
private static class CombatEffectivenessGoods extends Goods {
CombatEffectivenessGoods() {
}
public int getCombatEffectivenessPoint() {
return 100;
}
}
上述程式碼有以下問題:
- Hero耦合到全部種類的補品。
- Hero控制全部的流程,沒有分散給各個補品。
- HealthGoods與SuperHealthGoods物件,只有runtime時data值的不同,沒有行為上的差異,造成類別數量上升。
為了解決上述問題,我們需要達到以下目標:
- Hero需要與全部種類的補品解耦合。
- Hero需要放棄控制權,相信補品能夠完成它自身的責任。
- HealthGoods與SuperHealthGoods、MagicGoods與SuperMagicGoods都需要簡化。
上述兩點正是Duck Type所要解決的問題,之前介紹的定義中說明了一切:
… interfaces that are not tied to any specific class. … by replacing costly dependencies on class with more forgiving dependencies on messages.
定義Duck Type
不管是繼承、Duck Type、單一類別,介面皆是透過What goal the client want的角度去定義。
今天客戶端為Hero物件,接收者為Goods物件。Hero期望Goods幫他達成什麼目標呢?他期望Goods的效果能作用在他身上!!!不管是血量、魔力、戰鬥力。
所以Hero物件想要傳送applyEffect訊息給Goods,並且期望與相信Goods能達成applyEffect的責任。
修改後程式碼如下:
private static class Hero {
private int healthPoint;
private int magicPoint;
private int combatEffectiveness;
public Hero() {
this.healthPoint = 100;
this.magicPoint = 100;
this.combatEffectiveness = 10;
}
public void eatGoods(Goods goods) {
goods.applyEffect(this);
}
public void addHealthPoint(int healthPoint) {
this.healthPoint += healthPoint;
}
public void addMagicPoint(int magicGoods) {
this.magicPoint = magicGoods;
}
public void addCombatEffectiveness(int combatEffectiveness) {
this.combatEffectiveness += combatEffectiveness;
}
}
private interface Goods {
void applyEffect(Hero hero);
}
private static class HealthGoods implements Goods {
public int getHealthPoint() {
return 10;
}
@Override
public void applyEffect(Hero hero) {
hero.addHealthPoint(getHealthPoint());
}
}
private static class SuperHealthGoods implements Goods {
public int getHealthPoint() {
return 20;
}
@Override
public void applyEffect(Hero hero) {
hero.addHealthPoint(getHealthPoint());
}
}
private static class MagicGoods implements Goods {
public int getMagicPoint() {
return 10;
}
@Override
public void applyEffect(Hero hero) {
hero.addMagicPoint(getMagicPoint());
}
}
private static class SuperMagicGoods implements Goods {
public int getMagicPoint() {
return 20;
}
@Override
public void applyEffect(Hero hero) {
hero.addMagicPoint(getMagicPoint());
}
}
private static class CombatEffectivenessGoods implements Goods {
public int getCombatEffectivenessPoint() {
return 100;
}
@Override
public void applyEffect(Hero hero) {
hero.addCombatEffectiveness(getCombatEffectivenessPoint());
}
}
修改後程式碼,解決了上述兩個問題:
- Hero需要與全部種類的補品解耦合:Hero只專注applyEffect訊息,至於是哪個類別的物件一律不知道,完全沒有畫面。
- Hero需要放棄控制權,相信補品能夠完成它自身的責任:Hero從集中控制權到下放控制權給能接收applyEffect的物件,並且相信他能把事情做好做滿。
上述就是Duck Type的威力!!!不管是什麼類別,只要能接收applyEffect訊息都是好類別 (黑貓白貓,能抓老鼠的就是好貓)。
簡化類別數量
我們剩下最後一個問題,此問題跟Duck Type無關:
- HealthGoods與SuperHealthGoods、MagicGoods與SuperMagicGoods都需要簡化。
上述問題體現了初學者 (包含我自身)在剛剛學習物件時,時常會犯的錯誤:看到黑影就開槍,看到名詞就建立類別!!!
這種反射性的習慣,會導致系統無緣無故增加一堆類別,導致維護成本上升。
為了避免上述問題,我們需要了解一件事情:物件的出現是為了提供客戶端行為,類別用來定義物件為了提供此行為的實作與資料結構。不同的物件如果需要提供不同的行為,才需要不同的類別。當行為一樣但資料的值不一樣時,代表我們需要另一個物件,而不是新的類別。
因此我們應該做的是,產生HealthGoods物件與SuperHealthGoods物件、產生MagicGoods物件與SuperMagicGoods物件。
修改後程式碼如下:
public static void main(String[] args) {
HealthGoods healthGoods = new HealthGoods(10);
HealthGoods superHealthGoods = new HealthGoods(20);
MagicGoods magicGoods = new MagicGoods(10);
MagicGoods superMagicGoods = new MagicGoods(20);
CombatEffectivenessGoods combatGoods = new CombatEffectivenessGoods(100);
Hero hero = new Hero();
hero.eatGoods(healthGoods);
hero.eatGoods(superHealthGoods);
hero.eatGoods(magicGoods);
hero.eatGoods(superMagicGoods);
hero.eatGoods(combatGoods);
}
private static class Hero {
private int healthPoint;
private int magicPoint;
private int combatEffectiveness;
public Hero() {
this.healthPoint = 100;
this.magicPoint = 100;
this.combatEffectiveness = 10;
}
public void eatGoods(Goods goods) {
goods.applyEffect(this);
}
public void addHealthPoint(int healthPoint) {
this.healthPoint += healthPoint;
}
public void addMagicPoint(int magicGoods) {
this.magicPoint = magicGoods;
}
public void addCombatEffectiveness(int combatEffectiveness) {
this.combatEffectiveness += combatEffectiveness;
}
}
private interface Goods {
void applyEffect(Hero hero);
}
private static class HealthGoods implements Goods {
private int healthPoint;
public HealthGoods(int healthPoint) {
this.healthPoint = healthPoint;
}
@Override
public void applyEffect(Hero hero) {
hero.addHealthPoint(this.healthPoint);
}
}
private static class MagicGoods implements Goods {
private int magicPoint;
public MagicGoods(int magicPoint) {
this.magicPoint = magicPoint;
}
@Override
public void applyEffect(Hero hero) {
hero.addMagicPoint(this.magicPoint);
}
}
private static class CombatEffectivenessGoods implements Goods {
private int combatEffectivenessPoint;
CombatEffectivenessGoods(int combatEffectivenessPoint) {
this.combatEffectivenessPoint = combatEffectivenessPoint;
}
@Override
public void applyEffect(Hero hero) {
hero.addCombatEffectiveness(this.combatEffectivenessPoint);
}
}
從中我們可以看到,超級補品類別已經消失,類別數量從原先的五個降低為三個。解決了上述類別數量過多的問題。
上述程式碼還有些不容易被察覺的缺陷,在範例中10與20分別代表一般補品與超級補品,這樣隱性的概念是很難被察覺的。也會增加客戶端使用困難度,客戶端只想知道What (一般補品、超級補品),而不是How (10、20)。
讓客戶端知道10與20會過度增加客戶端對於系統的耦合,當10與20需要轉變成40與80時,所有客戶端都會受到影響。
DDD的Factory中提到過,透過Class Static Factory能增加系統對於Domain的描述能力。並且降低重複程式碼、降低客戶端對於物件內部的耦合。因此這邊套用Class Static Factory到補品類別上面。
修改程式碼如下:
public static void main(String[] args) {
Hero hero = new Hero();
hero.eatGoods(HealthGoods.healthGoods());
hero.eatGoods(HealthGoods.superHealthGoods());
hero.eatGoods(MagicGoods.magicGoods());
hero.eatGoods(MagicGoods.superMagicGoods());
}private static class HealthGoods implements Goods {
private int healthPoint;
public static HealthGoods healthGoods() {
return new HealthGoods(10);
}
public static HealthGoods superHealthGoods() {
return new HealthGoods(20);
}
public HealthGoods(int healthPoint) {
this.healthPoint = healthPoint;
}
@Override
public void applyEffect(Hero hero) {
hero.addHealthPoint(this.healthPoint);
}
}
private static class MagicGoods implements Goods {
private int magicPoint;
public static MagicGoods magicGoods() {
return new MagicGoods(10);
}
public static MagicGoods superMagicGoods() {
return new MagicGoods(20);
}
public MagicGoods(int magicPoint) {
this.magicPoint = magicPoint;
}
@Override
public void applyEffect(Hero hero) {
hero.addMagicPoint(this.magicPoint);
}
}
使用Factory後的程式碼,解決的上述表達力不足、容易重複、過度耦合實作細節的問題。
結論:
- 介面的設計應該專注在What而不是How
- Duck Type專注在message,讓客戶端與特定類別解耦合,達到一定彈性
- 使用Duck Type的客戶端,應該相信其能完成所負責的任務,下放權力能降低修改的影響。
- 類別的增加,應該是為了提供不同行為的物件。
- Factory能增加系統整體的表達能力。