如何做好抽象化?就靠三隻穿雲箭!
最近看了一本相見恨晚的書,書名叫做:Refactoring for Software Design Smells (Managing Technical Debt),書中列舉出數個違反Abstraction原則的壞味道 (Code Smell),並且詳細描述這些壞味道對於系統架構所產生的不良影響。
看完後發覺,自己學習物件導向這麼多年,雖然逐漸能夠設計出良好抽象化,但對於”如何系統性歸納抽象化知識”這件事情,好像沒有完整的總結。
因此,本篇文章主要目標是想藉由分享書中提到的抽象原則壞味道,來歸納這些年來學習抽象化的經驗,這次要介紹的壞味道有三個,分別是:
- Multifaceted Abstraction
- Imperative Abstraction
- Missing Abstraction
希望大家看完後,能達到以下目標:
- 了解什麼是Abstraction。
- 了解為什麼需要Abstraction。
- 了解如何使用Abstraction原則降低系統複雜度。
- 了解什麼樣的Abstraction (Anti Pattern of Abstraction)會給系統帶來嚴重的損害。
What is Abstraction?
臉書上的Emoji相信大家都有使用過,像是常見的哭笑不得😂就是我最喜歡用的Emoji。
你知道嗎?Emoji事實上,就是一種抽象化,它針對不同人哭笑不得的表情,找出相似的地方,像是:眼淚噴出、嘴角上揚、眼睛微笑,等等。;它同時也針對不同人所擁有的不同細節,實施刪去法,像是:頭髮、眉毛、膚色,等等。
書中對於Abstraction原則的定義如下:
The principle of abstraction advocates the simplification of entities through reduction and generalization: reduction is by elimination of unnecessary details and generalization is by identification and specification of common and important characteristics
翻成白話文就是:Abstraction = Reduction + Generalization。
抽象化透過刪去法隱藏實作細節;抽象化透過泛化找出相似的特性並且記錄起來。
這就是抽象化的定義,很簡單明瞭不是嗎?:)
接下來我們要介紹為什麼要使用抽象化,以及它的目的…
Why We Need Abstraction?
再解釋為什麼之前,我想探討為什麼抽象化有用。
原因是:大腦處理資訊的方式很單一,沒辦法一次處理過多資訊,當我們讓大腦瘋狂Context Switch時,大腦的運作會變的非常沒有效率。而抽象化能降低大腦需要處理的資訊 (刪減或找出相似特性),自然而然能提升大腦運作的效率,也同時增加我們的工作效率。
在了解抽象化為什麼有用後,我們來解釋為什麼需要抽象化:
- 降低大腦認知複雜度。
- 提升溝通效率。
- 控制複雜度。
還記得上面提到過Abstraction = Reduction + Generalization的概念嗎?接下來用Reduction與Generalization來解釋抽象化如何達到上述作用。
Reduction
- 刪除不必要的實作細節,降低大腦需要處理的資訊,因而降低大腦認知複雜度。
- 刪除不必要的實作細節,降低溝通時需要提到的資訊,因而提升溝通效率。
- 刪除不必要的實作細節,降低模組API的複雜度,因而控制複雜度。
Generalization
- 找出相似且重要的特性,讓大腦只專注在處理重要的資訊,因而降低大腦認知複雜度。
- 找出相似且重要的特性,增加溝通時的意圖與準確度,因而提升溝通效率。
- 找出相似且重要的特性,降低變異性 (variation)對於系統的影響,因而控制複雜度。
在介紹了What與Why之後,接下來將介紹如何 (How)在現實專案中使用Abstraction原則…
How to Use Abstraction Principle?
關於如何應用抽象化原則,書中提到了五種手段 (Enabling techniques):
- 提供明確的邊界與身份 (Provide a crisp conceptual boundary and a unique identity)。
- 從Problem Domain找出Entities (Map domain entities)。
- 確保聚合性與完整性 (Ensure coherence and completeness)。
- 指派單一且有意義的責任 (Assign single and meaningful responsibility)。
- 刪除重複程式碼 (Avoid duplication)。
接下來將用程式碼一一介紹上述手段。
提供明確的邊界與身份
以下為違反提供明確的邊界與身份的範例程式碼:
private static class Hero {
private int x;
private int y;
void moveRight() {
this.x++;
}
void moveLeft() {
this.x--;
}
void moveUp() {
this.y++;
}
void moveDown() {
this.y--;
}
}
上述程式碼我們看到:Hero類別擁有兩個fields,分別是:x與y,這恰恰違反提供明確邊界與身份。
為什麼?因為x與y改變的頻率一樣,x與y彼此為conceptual whole (兩者缺一不可的意思)。
所以我們應該要建立一個類別Point,Point類別為x與y的邊界,修改後程式碼如下:
private static class Hero {
private Point point; // crisp boundary and unique identity
void moveRight() {
this.point = point.right();
}
void moveLeft() {
this.point = point.left();
}
void moveUp() {
this.point = point.up();
}
void moveDown() {
this.point = point.down();
}
}
private static class Point { // Value Object
private final int x;
private final int y;
private Point(int x, int y) {
this.x = x;
this.y = y;
}
Point right() {
return new Point(x + 1, y);
}
Point left() {
return new Point(x - 1, y);
}
Point up() {
return new Point(x, y + 1);
}
Point down() {
return new Point(x, y - 1);
}
}
從Problem Domain找出Entities
以下為違反從Problem Domain找出Entities的範例程式碼:
private static class Main {
public static void main(String[] args) {
Object[] object = new Object[] { "mars", 12 };
String name = getName(object);
Integer age = getAge(object);
}
private static String getName(Object[] object) {
return (String) object[0];
}
private static Integer getAge(Object[] object) {
return (Integer) object[1];
}
}
上述程式碼我們看到:使用物件陣列來封裝名稱與年齡,並且用index順序記錄不同資料,暴露過多實作細節增加耦合,我們應該從Problem Domain中找出對應的entity,而不是使用過度general的資料結構。
假設我們的Problem Domain為員工帳務系統,那麼那代表名稱與年齡的entity應該為Employee,因此使用Employee封裝名稱與年齡,修改後程式碼如下:
private static class Main {
public static void main(String[] args) {
Employee employee = new Employee("mars", 12);
String name = employee.getName();
Integer age = employee.getAge();
}
}
private static class Employee {
private String name;
private Integer age;
public Employee(String name, Integer age) {
this.name = name;
this.age = age;
}
public Integer getAge() {
return age;
}
public String getName() {
return name;
}
}
確保聚合性與完整性
以下為違反確保聚合性與完整性的範例程式碼:
private static class CheckBox {
private boolean isChecked;
public void check() {
this.isChecked = true;
}
}
上述程式碼我們看到:CheckBox類別用來記錄使否有被打勾,但只提供check,沒有提供unCheck。
沒有提供完整的介面不但會使客戶端困惑,同時也讓抽象化不完整、沒負擔它應盡的職責。
因此針對CheckBox類別增加缺少的介面unCheck,修改後程式碼如下:
private static class CheckBox {
private boolean isChecked;
public void check() {
this.isChecked = true;
}
public void unCheck() {
this.isChecked = false;
}
}
指派單一且有意義的責任
以下為違反指派單一且有意義的責任的範例程式碼:
public interface StudentRepository {
void save(Student student);
Student findById(Long id);
Score calculateScoreOfStudentById(Long id);
}
上述程式碼我們看到:StudentRepository類別負責了儲存Student物件的責任 (persistence)、domain layer的責任 (計算成績)。
這種設計在傳統Database Driven Design的系統中很常看到,因為Student物件對他們來說就是資料結構,沒有任何行為,所以仰賴Repository透過SQL來操作Student Table計算學生的分數 (Data Oriented),降低可測試性、維護性。
我們應該把責任分配到適當的物件上面,Student物件負責計算成績的責任、Repository負責儲存Student物件的責任,修改後程式碼如下:
public interface StudentRepository {
void save(Student student);
Student findById(Long id);
}
private static class Student {
Score score() {
// calculate score implementation...
}
}
刪除重複程式碼
以下為違反刪除重複程式碼的範例程式碼:
private static class Animal {
private String type;
void move() {
if (type.equals("bird")) {
System.out.println("fly");
} else if (type.equals("dog")) {
System.out.println("walk");
}
}
void makeSomeNoise() {
if (type.equals("bird")) {
System.out.println("tweet");
} else if (type.equals("dog")) {
System.out.println("woof");
}
}
}
上述程式碼我們看到:Animal類別使用type達到行為的變異化,但type的種類散落在move與makeSomeNoise函數,導致重複程式碼。
我們應該將Animal多種型態的資訊透過繼承封裝到各個子類別,達到降低重複程式碼的效果,修改後程式碼如下:
public abstract static class Animal {
abstract void move();
abstract void makeSomeNoise();
}
private static class Dog extends Animal {
@Override
void move() {
System.out.println("walk");
}
@Override
void makeSomeNoise() {
System.out.println("woof");
}
}
private static class Bird extends Animal {
@Override
void move() {
System.out.println("fly");
}
@Override
void makeSomeNoise() {
System.out.println("tweet");
}
}
在理解如何實際應用Abstraction原則後,我們將介紹三種Abstraction Code Smells…
Abstraction Code Smell
這個段落首先介紹三種Code Smells,緊接著提出違反這些Code Smells的範例,最後逐步介紹如何分析與改善。
書中提到的每一種Code Smell都違反某些原則,像Abstraction Code Smell就違反了Abstraction原則 (好像是廢話?)。
違反原則的原因為:當使用原則時,如果沒有遵守上述介紹的Enabling Technique就會違反原則,進而產生Code Smell。
所以當我們發現Code Smell並且知道他違反什麼原則時,我們只需要透過重構的方式搭配Enabling Technique即可修復Code Smell。
Multifaceted Abstraction:抽象化被指配過多責任,違反指配單一責任手段。
常見的例子有:
- God Object:集中所有控制與邏輯,導致負擔過多責任。
- Active Record Object:混合資料庫與商業邏輯,導致負擔過多責任。
- Over General Purpose Object:為了能夠被任何客戶端reuse,導致負擔過多責任;好的抽象化只被設計滿足使用特定功能 (specific functionality)的特定客戶端組 (client group)。
Imperative Abstraction:抽象化的產生是透過functional decomposition (切割entity的部分行為所產生的類別、透過切割步驟所產生的類別),違反從Problem Domain找出entity手段。
常見的例子有:
- Validator:Validator只負責某個物件的驗證邏輯 (切割entity的部分行為),物件的驗證應該由物件自己負責 (ex: Email物件負責自身驗證邏輯、Name物件負責自身驗證邏輯)。
- Service with Anemic Domain Model:刻意把資料與行為分開,導致過多間接層、暴露資料實作細節,Domain Model應該負責商業邏輯,而不是Service。
- Reflection Util:透過Reflection的方式操作物件內部資料:破壞物件封裝,概念與Service with Anemic Domain Model類似。
Missing Abstraction:在系統中沒有任何抽象化,違反提供明確的邊界與身份手段。
常見的例子有:
- Primitive Obsession:過度使用語言原生的primitive type,而放棄了抽象化的機會。 (使用Value Object能解決此問題)
- Embedded Rule:將重要的商業邏輯隱含在程式碼中,沒有表達一個完整的概念。 (使用Specification解決此問題)
- Over General Data Structure:過度使用List, Set, Map等資料結構存放資料,會增加客戶端與資料結構之間的耦合。(應使用類別來封裝資料結構,以此表達有用的概念)
實際範例:
假設我們有一個簡易的訂單系統,能計算價格 (考慮稅務、折扣後),初始設計如下:
private static class OrderCalculator {
Double calculatePrice(Order order) {
Double price = basePrice(order);
price = discountedPrice(price, order);
price = taxedPrice(price, order);
return price;
}
private Double basePrice(Order order) {
return order.getLineItemList()
.stream()
.map(item -> item.price * item.qty)
.reduce(0.0, Double::sum)
;
}
private Double discountedPrice(Double price, Order order) {
if (order.discountType.equals("50%Off")) {
return price * 0.5;
} else if (order.discountType.equals("30%ff")) {
return price * 0.3;
}
return price;
}
private Double taxedPrice(Double price, Order order) {
if (order.taxType.equals("A")) {
// 100行稅務運算邏輯
return price * 1.1;
} else if (order.taxType.equals("B")) {
// 100行稅務運算邏輯
return price * 1.2;
} else {
// 100行稅務運算邏輯
return price * 1.5;
}
}
}
private static class Order {
private Long id;
private List<LineItem> lineItemList;
private String discountType;
private String taxType;
private Order(Long id, List<LineItem> lineItemList, String discountType, String taxType) {
this.id = id;
this.lineItemList = lineItemList;
this.discountType = discountType;
this.taxType = taxType;
}
public List<LineItem> getLineItemList() {
return lineItemList;
}
}
private static class LineItem {
private Long id;
private Long productId;
private Double price;
private Integer qty;
LineItem(Long id, Long productId, Double price, Integer qty) {
this.id = id;
this.productId = productId;
this.price = price;
this.qty = qty;
}
public Double getPrice() {
return price;
}
public Integer getQty() {
return qty;
}
}
上述程式碼我們看到:計算的邏輯幾乎都由OrderCalculator負責,Order與LineItem為貧血domain object,這種程式碼在現實專案中很常看到,頻繁到大家會認為這種設計很正常。
因此,接下來我要用軟體中的Quality Attributes來分析這種設計有哪些問題。
- Understandability:OrderCalculator被指派過多的責任,像是:價格運算、折扣運算、稅務運算,稅務計算複雜 (三個case,每個case 100行),折扣種類多樣 (目前兩個case,但會持續增加),把這些責任塞到一個類別會增加大腦的認知複雜度,同時違反Separation of Conceren,降低可讀性。
- Changeability:由於OrderCalculator被指派過多的責任,當開發者想要修改其中一種責任時,會被其他兩種責任影響、修改稅務其中一個case會被其他case影響、修改折扣其中一個case會被其他case影響,以上種種原因,會讓修改影響的scope難以估計。 (現實專案不像範例if else那麼簡單,通常不同casec會混雜不恰當的shared code,或是根本看不出具體有哪幾種case)
- Extensibility:當要擴充稅務種類時,會被其他case影響、當要擴充折扣種類時會被其他case影響,開發者必須一直修改OrderCalculator (增加if else),違反OCP,OrderCalculator類別沒辦法有效的被Closed。
- Reusability:由於OrderCalculator被指派過多的責任,當我們要reuse稅務系統時,會耦合到折扣、價格計算,當我們要reuse折扣系統時,會耦合到稅務、價格計算。
- Testability:由於OrderCalculator被指派過多的責任,沒辦法針對每個責任單獨測試,測試類別會大量膨脹,降低測試可讀性,因此可測試性非常低。
- Reliability:修改與測試困難,造成OrderCalculator容易產生bug,降低模組的可靠程度,隨著需求的變異 (稅務、折扣),會產生越來越多的bug。
如何解決?答案:使用三種Abstraction Code Smell。
- Multifaceted Abstraction:OrderCalculator被指派過多責任,違反指派單一責任的手段。
- Imperative Abstraction:OrderCalculator負責Order物件的部分責任,違反從Problem Domain找Entities的手段。
- Missing Abstraction:稅務、折扣功能缺少有用的抽象化結構來封裝各自的變異,違反定義明確邊界與概念的手段。
我們將依序解決Multifaceted Abstraction、Imperative Abstraction、Missing Abstraction。
因為在專案中,危害最大的是Multifaceted Abstraction,過多責任造成團隊修改同一個模組,產生修改上的衝突。
接著是Imperative Abstraction,把資料與行為分開也違反模組化的原則,造成多個類別互相耦合,修改上容易發生cascade change。
最後是Missing Abstraction,通常實際影響沒有像Multifaceted Abstraction與Imperative Abstraction來得這麼大,但在擴充時很容易產生錯誤。
重構I (Multifaceted Abstraction)
為了遵守Abstraction原則,我們要遵守指派單一責任手段。因此,我們將稅務責任、折扣責任分配給兩個新類別:TaxStrategy、Discount。
修改後程式碼如下:
private static class OrderCalculator {
Double calculatePrice(Order order) {
Double price = basePrice(order);
price = order.getDiscount().price(price, order);
price = order.getTaxStrategy().price(price, order);
return price;
}
private Double basePrice(Order order) {
return order.getLineItemList()
.stream()
.map(item -> item.price * item.qty)
.reduce(0.0, Double::sum)
;
}
}
private static class Discount {
private final String discountType;
public Discount(String discountType) {
this.discountType = discountType;
}
private Double price(Double price, Order order) {
if (this.discountType.equals("50%Off")) {
return price * 0.5;
} else if (this.discountType.equals("30%ff")) {
return price * 0.3;
}
return price;
}
}
private static class TaxStrategy {
private final String taxType;
public TaxStrategy(String taxType) {
this.taxType = taxType;
}
private Double price(Double price, Order order) {
if (this.taxType.equals("A")) {
// 100行稅務運算邏輯
return price * 1.1;
} else if (this.taxType.equals("B")) {
// 100行稅務運算邏輯
return price * 1.2;
} else {
// 100行稅務運算邏輯
return price * 1.5;
}
}
}
private static class Order {
private Long id;
private List<LineItem> lineItemList;
private Discount discount;
private TaxStrategy taxStrategy;
private Order(Long id, List<LineItem> lineItemList, Discount discount, TaxStrategy taxStrategy) {
this.id = id;
this.lineItemList = lineItemList;
this.discount = discount;
this.taxStrategy = taxStrategy;
}
public List<LineItem> getLineItemList() {
return lineItemList;
}
public Discount getDiscount() {
return discount;
}
public TaxStrategy getTaxStrategy() {
return this.taxStrategy;
}
}
透過把稅務、折扣封裝到各自的類別,之前的discountType、taxType被升級成物件,讓他們負擔單一責任,解決了Multifaceted Abstraction Code Smell。
重構II (Imperative Abstraction)
為了遵守Abstraction原則,我們要遵守從Problem Domain找出Entities手段,由於我們已經有Order類別,我們把OrderCalculator inline到Order類別上面,藉此移除掉OrderCalculator。
修改後程式碼如下:
private static class Order {
private Long id;
private final List<LineItem> lineItemList;
private final Discount discount;
private final TaxStrategy taxStrategy;
private Order(Long id, List<LineItem> lineItemList, Discount discount, TaxStrategy taxStrategy) {
this.id = id;
this.lineItemList = lineItemList;
this.discount = discount;
this.taxStrategy = taxStrategy;
}
Double price() {
Double price = basePrice();
price = discount.price(price, this);
price = taxStrategy.price(price, this);
return price;
}
private Double basePrice() {
return this.lineItemList
.stream()
.map(item -> item.price * item.qty)
.reduce(0.0, Double::sum)
;
}
}
透過inline的方式,消除了OrderCalculator類別,降低資料在類別之間的流動性 (封裝在Order類別),可以看到原先很多getter都不需要了,代表物件的封裝性得到更好的加強,也解決了Imperative Abstraction Code Smell。
重構III (Missing Abstraction)
為了遵守Abstraction原則,我們要遵守提供明確的邊界與身份手段,在重構I時,我們已經找出系統中的Missing Abstraction:Discount與TaxStrategy,接下來只需要把不同種類的type封裝到子類別即可 (replace type case with polymorphism)。
修改後程式碼如下:
private interface Discount {
Double price(Double price, Order order);
}
private static class FiftyPercentageOffDiscount implements Discount {
@Override
public Double price(Double price, Order order) {
return price * 0.5;
}
}
private static class ThirtyPercentageOffDiscount implements Discount {
public Double price(Double price, Order order) {
return price * 0.3;
}
}
private static class NullDiscount implements Discount {
@Override
public Double price(Double price, Order order) {
return price;
}
}
private interface TaxStrategy {
Double price(Double price, Order order);
}
private static class TypeATaxStrategy implements TaxStrategy {
@Override
public Double price(Double price, Order order) {
// 100行稅務運算邏輯
return price * 1.1;
}
}
private static class TypeBTaxStrategy implements TaxStrategy {
@Override
public Double price(Double price, Order order) {
// 100行稅務運算邏輯
return price * 1.2;
}
}
private static class DefaultTaxStrategy implements TaxStrategy {
@Override
public Double price(Double price, Order order) {
// 100行稅務運算邏輯
return price * 1.5;
}
}
透過找出Discount、TaxStrategy概念並且把不同種類的稅務、折扣封裝到子類別,解決了Missing Abstraction Code Smell。
接下來我要用軟體中的Quality Attributes來分析改善後的設計。
- Understandability:每個抽象化都指派單責任,降低大腦認知複雜度,提高可讀性。
- Changeability:每個抽象化都指派單一責任,修改時影響的範圍只有一個類別,達到isolate change into one place,提高修改性。
- Extensibility:透過TaxStrategy、Discount的繼承結構,只需要新增子類別即可達到功能擴充效果,滿足OCP,提高擴充性。
- Reusability:每個抽象化都指派單一責任,可以很簡單的reuse不同功能也不會被迫耦合到不需要的元件,提高可重複利用性。
- Testability:每個抽象化都指派單一責任,每個責任皆可以被單獨自動化測試,提高測試性。
- Reliability:由於好測試、擴充,一個蘿蔔一個坑,修改變得非常簡單,降低開發者出錯的機率,提高模組可依賴性。
希望有耐心看到這邊的讀者,對於抽象化原則的掌握能更上一層樓,之後聽到別人討論軟體抽象化的議題時,就不會那麼害怕,說不定還能以更高的層次來下指導棋XD。
結論
- Abstraction = Reduction + Generalization。
- Abstraction能降低認知複雜度、提升溝通、控制複雜度。
- 應用原則時沒遵守Enabling techniques會違反原則而產生Code Smell、Code Smell能透過Enabling techniques去解決。
- 軟體中的Quality Attributes,能幫助我們以科學的方式評估目前設計,不是只靠感覺。
- 遇到抽象化問題時,依據危害大小來依序解決,分別是:Multifaceted、Imperative、Missing Abstraction。