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

Z-xuan Hong
21 min readMay 15, 2021

--

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

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

在上一篇文章中我們介紹了如何設計類別的介面,知道了根據客戶端的目標定義介面能夠減少彼此之間的耦合。相信大家或多或少有聽過繼承這個概念 (關於繼承會在後面文章中介紹)。如果說繼承是針對相似類別進行介面的設計,那麼今天要介紹的概念: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能增加系統整體的表達能力。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

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

No responses yet