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

Z-xuan Hong
32 min readMay 30, 2021

--

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

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

在上一篇文章中我們介紹了Duck Type,了解到其專注物件能接收的訊息而不是類別型態的特性能夠提升系統的彈性,今天文章要介紹繼承:一個最容易被誤用、誤解的物件導向特性,透過全面性的了解,讓我們在享受繼承獲的好處時,也能降低不必要的風險。

備註:此篇文章較為深入,把繼承所有優缺點列出,本人也是花很久時間才掌握繼承的原理 (因為沒有什麼深入探討繼承的文章),希望這篇文章能給初學者穩固的基礎,降低使用繼承的風險,但需要讀者反覆閱讀與思考,才能靈活運用文章提出的概念。

What is Inheritance

先前好幾篇文章中我們不斷反覆強調,物件導向是透過物件之間相互傳遞訊息來達成特定功能,而繼承也不例外,書上定義如下:

The idea of inheritance may seem complicated, but as with all complexity, there’s a simplifying abstraction. Inheritance is, at its core, a mechanism for automatic message delegation.

上述的定義白話文解釋就是:繼承是透過automatic message delegation來讓子類別能夠接收父類別能夠接收的訊息。

當物件接收到訊息時,有兩種回應方式:

  • Delegate:當接收到訊息時,將訊息傳送給其他物件,而本身沒有實作。

程式碼範例:

static class Receiver {
private final Collaborator collaborator;

Receiver(Collaborator collaborator) {
this.collaborator = collaborator;
}

void doSomething() {
this.collaborator.doSomething();
}

public static void main(String[] args) {
Receiver receiver = new Receiver(new Collaborator());
receiver.doSomething();
}
}

static class Collaborator {
void doSomething() {
// ....
}
}

上述範例可以看到:當receiver物件接收到doSomething時,本身沒有實作,單純把訊息傳送給collaborator物件。

  • Self-Delegate:當接收到訊息時,將訊息傳送給自身實作的函數。

程式碼範例:

static class Receiver {
Receiver() {
}

void doSomething() {
// ...
}

public static void main(String[] args) {
Receiver receiver = new Receiver();
receiver.doSomething();
}
}

上述範例可以看到:當receiver物件接收到doSomething時,本身呼叫實作的函數。

單純看上述的例子會覺得Delegate的方式有點脫褲子放屁,然而決定是否要自己實作或是Delegate給別人,能夠用以下方式來判斷:

  • 資料落於哪個物件:根據GRASP中的Information Expert擁有運算資訊的物件應該要負責此訊息,否則容易導致Feature Envy Code Smell。Information Expert是物件導向最基本的定義,違反Information Expert容易導致data flow遊走在多個模組之間,導致Cascade Change。
  • 訊息接收者本身的責任:有時為了遵守Information Expert,反而會違反單一責任原則、降低聚合力,此時反而會產生Divergent Change Code Smell。例如:將Domain Entity轉換成Json格式,為了遵守Information Expert,Domain Entity必須要提供toJson行為,但表達Problem Domain與序列化是兩種完全不同的責任,應該分別交給Domain Entity與Mapper來處理。

根據上面的互動方式,繼承屬於第一種:當子類別接收到其沒有實作的訊息時,系統會自動Delegate給父類別。

程式碼範例:

static class SuperClass {
public void foo() {
System.out.println("Fool me once shame on you");
System.out.println("Fool me twice shame on me");
}
}

static class SubClass extends SuperClass {
public static void main(String[] args) {
SubClass subClass = new SubClass();
subClass.foo();
}
}

上面我們可以看到:當subClass物件接收到foo訊息時,由於他沒有實作此訊息,系統會自動Delegate給superClass物件。

經過上述介紹你可能會覺得奇怪,怎麼跟你仿間學到的繼承不太一樣,普通的教學都會說物件的屬性 (Field)、函數可以重複利用節省程式碼,但其實這種介紹容易導致新手寫出無法維護的繼承架構。

繼承最重要的是子類別能接收父類別所有能接收的訊息,並且針對父類別提供Specialization的貢獻,其他像是重複利用程式碼重複利用屬性都是比較舊的觀念,並且有更好的替代方案,稍後的章節會詳細介紹此概念。

Why Inheritance

經過上述介紹,相信你可能猜到為什麼要使用繼承。

為了讓子類別能夠自動接收父類別能夠接收的訊息,並且針對父類別透過多型來進行Specialization

上述介紹可能還是不太明白,接下來用實際範例詳細介紹。

程式碼範例:

abstract class Animal {
private int age;

public Animal(int age) {
this.age = age;
}

public abstract void makeSound();

public int getAge() {
return this.age;
}

public void changeAge(int age) {
this.age = age;
}
}

class Dog extends Animal {
public Dog(int age) {
super(age);
}

@Override
public void makeSound() {
System.out.println("Bark, Bark, Bark!!!");
}
}

上面我們可以看到:animal能夠接收getAge、setAge、makeSound訊息,而dog除了要能接收有關年齡的訊息 (automatic message delegation),還要能發出不同聲音 (specialization)。因為不同動物發出聲音的方式不同。

如果不使用繼承的話,我們需要使用介面繼承的方式來實作:

interface Animal {
int getAge();
void setAge(int age);
void makeSound();
}

static class AnimalImp implements Animal {
private int age;

public AnimalImp(int age) {
this.age = age;
}

@Override
public int getAge() {
return this.age;
}

@Override
public void setAge(int age) {
this.age = age;
}

@Override
public void makeSound() {
throw new RuntimeException("AnimalImp should't respond makeSound message");
}
}

static class Dog implements Animal {
private AnimalImp animalImp;

public Dog(AnimalImp animalImp) {
this.animalImp = animalImp;
}

@Override
public int getAge() {
return this.animalImp.getAge();
}

@Override
public void setAge(int age) {
this.animalImp.setAge(age);
}

@Override
public void makeSound() {
System.out.println("Bark, Bark, Bark!!!");
}
}

可以看到跟繼承相比複雜許多,因為我們需要自行撰寫Delegate來替代原本繼承的automatic message delegation

想要使用類別的全部功能,並且只針對部分功能進行點綴時,正是使用繼承的原因,也是繼承要解決的問題。

How to User Inheritance Properly

我們已經知道了什麼是繼承、為何使用繼承、繼承解決什麼問題後,接下來將要教大家了解如何正確的使用繼承。

Metaphor (比喻)

學習繼承時,常常會聽到一個比喻:繼承是is-a的關係,乍聽之下好像很合理,實際上卻是沒什麼用的比喻,如果你了解了上述對於繼承的介紹,你會知道繼承是specialization的關係。

以下例子來說明為何使用is-a容易誤用繼承。

假設有employeemanager兩個物件,如果使用is-a的比喻:manager is a employee,你可能會設計出以下程式碼:

static class Employee {
void executeAssignedTask() {
// execute task assigned by manager
}
}

static class Manager extends Employee {
@Override
void executeAssignedTask() {
throw new RuntimeException("Manager can't be assigned task");
}

void assignedTaskTo(Employee employee) {
if (employee instanceof Manager)
return;
// assigned task to employee
}
}

上述程式碼用來模擬分配工作的任務,主管能夠分配任務給員工員工執行被分配的任務,此時使用繼承,會導致以下缺點:

  • 無法精確模擬Problem Domain中,只有主管能夠分配任務給員工員工只能執行被分配的任務。顯然程式碼沒辦法表達、強制此概念。
  • 子類別能接收他不需要的訊息manager物件因繼承automatic message delegation的特性,進而能接收executeAssignedTask訊息,但這不符合Problem Domain。我們只能針對executeAssignedTask丟出runtimeException來通知客戶端。
  • Type Casting:因為多型的關係,導致manager也能被分配,但這不符合Problem Domain,為了避免此問題,我們需要在assignedTaskTo的實作中,使用Type Casting來略過manager物件。(物件導向中,使用Type Casting會破壞多型,導致過度耦合,是一種anti-pattern)

如果使用specialization的比喻,我們會有下面問題:

  • manager物件是否想要接收employee物件全部的訊息?
  • manager物件是否想要針對部分功能進行specialization

針對上述問題的回答皆為否:

  • manager物件不會想要接收employee物件的訊息。
  • manager物件不會想要透過多型來達到specialization。

我們會設計出以下程式碼:

static class Employee {
void executeAssignedTask() {
// execute task assigned by manager
}
}

static class Manager {
void assignedTaskTo(Employee employee) {
// assigned task to employee
}
}

上面我們可以看到:解決了原先不當使用繼承的副作用,不需要丟runtimeException、也不需要Type Casting,程式碼的複雜度也大幅度降低。

經由is-aspecialization的對比我們能發現,只有掌握正確的比喻才能正確使用繼承,並且發揮其所帶來好處。

Focus on Contract

specialization告訴我們,子類別能夠接收父類別全部的訊息並且針對部分功能進行差異化。

Liskov substitution principle (LSP)能夠協助我們來判斷繼承的使用情況,其定義如下:

我們能夠在不影響系統行為的情況下,針對SuperType的客戶端替換不同的SubType。

如果用LSP來驗證上面manageremployee的例子,我們會發現,當針對employee替換manager時,會影響系統行為,因為executeAssignedTask會丟出runtimeException、assignedTaskTo會直接略過manager物件。

LSP告訴我們Contract的驗證需要從客戶端的角度,然而is-a關係只是單純從實作面的角度去驗證,但specialization的關係也是從客戶端的角度去驗證因此繼承更適合specialization的比喻

Balance Abstraction

上述介紹了繼承的正確使用時機,但了解使用的時機遠遠不夠,這也不代表你能夠用得好,我們接下來要介紹另外重要的概念,Balance Abstraction。

Balance Abstraction:繼承架構每一層的抽象化程度皆需一致,反之則會降低可讀性。

上述定義你可能會覺得很抽象,但卻是能繼承好讀、好擴充、好維護的關鍵,下面用簡單範例來介紹此概念。

假設我們要設計一個能畫出長方形的圖形工具。

範例程式碼:

class Shape {
private Point start;
private Point end;
public void draw(Graphics2D graphics2D) {
// draw rectangle with start point and end point
}
}

上述程式碼很簡單,我們只定義shape類別,並提供draw訊息來滿足畫圖的需求。

你可能會想為什麼不要一次新增兩個類別:shaperectangle?因為我延遲到有多個Concrete Example再進行抽象化,透過延遲的手段,來降低Wrong Abstraction Boundary的可能性。況且現在只需一個類別就足夠了,如果系統需求沒有更動,那我只需要維護一個類別。

接著需求變動了,希望我們增加一個square,接下來的程式碼是很多人常犯的錯誤。

範例程式碼:

class Shape {
private Point start;
private Point end;
public Shape(Point start, Point end) {
this.start = start;
this.end = end;
}

public void draw(Graphics2D graphics2D) {
// draw rectangle with start point and end point
}
}
class Square extends Shape {
public Square(Point start, Point end) {
super(start, end);
}

public void draw(Graphics2D graphics2D) {
// draw square with start point and end point
}
}

上述範例我們可以看到:square繼承shape,並且override draw函數來提供square的功能。

此程式碼有以下缺點:

  • Unbalance Abstraction:在介紹Balance Abstraction時我們提到過,每一層繼承架構抽象化程度皆需要一致,很明顯的上述違反Balance Abstraction。因此即便名稱為shape,但實作還是保留了rectanglecontext。對於shape來說,其為所有圖形的抽象化,但卻混雜了rectangle的實作,導致Unbalance Abstraction。
  • Hard to understand control flow:當父類別的實作被子類別複寫時,代表其他繼承子類別的物件也能複寫其實作,導致整個程式的控制流程難以理解。

修改後程式碼:

abstract class Shape {
public abstract void draw(Graphics2D graphics2D);
}

class Rectangle extends Shape {
private Point start;
private Point end;

public Rectangle(Point start, Point end) {
this.start = start;
this.end = end;
}

@Override
public void draw(Graphics2D graphics2D) {
// draw rectangle with start point and end point
}
}


class Square extends Shape {
private Point start;
private Point end;

public Square(Point start, Point end) {
this.start = start;
this.end = end;
}

public void draw(Graphics2D graphics2D) {
// draw square with start point and end point
}
}

上述程式碼改善了有以下優點:

  • Balance Abstraction:shape不在混雜rectangle實作細節,變成abstract class來代表抽象化。
  • Clear Control Flow:shape、rectangle、square分別代表三個不同概念,都被程式碼表達出來,容易理解最終複寫的地方。
  • Good Normalize:好的繼承,只會有一個、唯一的地方複寫。

良好的繼承,不僅僅是Balance Abstraction,同時也遵守Dependency Inversion Principle。

  • 高階模組與低階模耦合到抽象化:shape的客戶端只會知道shape的存在,不會知道rectangle與square。
  • 抽象化不包含實作細節:shape不包含rectangel的實作細節。

以下用視覺化說明上述步驟的演進:

When you use wrong

Type Casting

上述範例已經證明,錯誤使用繼承違反LSP時,會導致過多的Type Casting,增加物件與物件之間的耦合。

Hard to understand control flow

當錯誤使用繼承,導致繼承階層深入,子類別複寫來複寫去時,會導致control flow難以理解,嚴重破壞系統維護性,在很多專案都能看到這種anti-pattern。

如下面程式碼:

class A {
public void f1() {
System.out.println("f1 A");
}

public void f2() {
System.out.println("f2 A");
}

public void f3() {
System.out.println("f3 A");
}

public void f4() {
System.out.println("f4 A");
}
}

class B extends A {
public void f1() {
System.out.println("f1 B");
}

public void f2() {
System.out.println("f2 B");
}
}

class C extends B {
public void f1() {
System.out.println("f1 B");
}
public void f3() {
System.out.println("f3 C");
}

public void f4() {
System.out.println("f4 C");
}
}

class D extends C {
public void f1() {
System.out.println("f1 D");
}

public void f2() {
System.out.println("f2 D");
}

public void f3() {
System.out.println("f3 D");
}

public void f4() {
System.out.println("f4 D");
}
}

上述程式碼非常難理解,因為複寫來複寫去的關係,我們需要檢視全部類別才能推斷出訊息被哪個子類別接收。

在Balance Abstraction的介紹中我們提到,良好的繼承要能達到normalize的目標,如果不行,代表繼承使用時機的錯誤。

我們能將上面程式碼修改成介面繼承與object composition的方式:

interface Abstraction {
void f1();
void f2();
void f3();
void f4();
}
class A implements Abstraction {
public void f1() {
System.out.println("f1 A");
}
public void f2() {
System.out.println("f2 A");
}
public void f3() {
System.out.println("f3 A");
}
public void f4() {
System.out.println("f4 A");
}
}
class B implements Abstraction {
private A a;
public B(A a) {
this.a = a;
}
@Override
public void f1() {
System.out.println("f1 B");
}
@Override
public void f2() {
System.out.println("f2 B");
}
@Override
public void f3() {
a.f3();
}
@Override
public void f4() {
a.f4();
}
}
class C implements Abstraction {
private B b;
public C(B b) {
this.b = b;
}
@Override
public void f1() {
this.b.f1();
}
@Override
public void f2() {
this.b.f2();
}
public void f3() {
System.out.println("f3 C");
}
public void f4() {
System.out.println("f4 C");
}
}
class D implements Abstraction {
@Override
public void f1() {
System.out.println("f1 D");
}
@Override
public void f2() {
System.out.println("f2 D");
}
@Override
public void f3() {
System.out.println("f3 D");
}
@Override
public void f4() {
System.out.println("f4 D");
}
}

透過介面繼承與object composition,幾乎能夠替換掉所有繼承的使用,同時保持control flow的可讀性。缺點就是沒有automatic message delegation,至於這中間的取捨就留改讀者自行決定。

Fragile Super Class

錯誤使用繼承會導致破壞封裝,因此當父類別修改時,會導致所有的子類別受到影響,也就是俗稱的Fragile Super Class。物件導向最重要的就是能夠Isolate Change,Fragile Super Class違反了這樣一個特性。

下面舉出兩個常見的例子:

  • Subclass Make Implicit Assumption of the SuperClass’s Implementation

假設我們有一個stack類別,如下:

static class Stack {
private int stack_pointer = 0;

private ArrayList values = new ArrayList();

public void push(Object article) {
values.add(stack_pointer++, article);
}

public Object pop() {
return values.remove(--stack_pointer);
}

public void pushMany(Object[] objects) {
for (int i = 0; i < objects.length; ++i)
push(objects[i]);
}
}

接著透過繼承的方式來新增一個MonitorableStack,他能幫助我們紀錄在stack生命週期中,最多有幾個元素,如下:

static class MonitorableStack extends Stack {
private int maxSize = 0;
private int current_size;

public void push(Object article) {
if (++current_size > maxSize)
maxSize = current_size;
super.push(article);
}

public Object pop() {
--current_size;
return super.pop();
}

public int getMaxSize() {
return maxSize;
}

public static void main(String[] args) {
MonitorableStack stack = new MonitorableStack();

Object[] objects = new Object[] {1, 2, 3, 4};
stack.pushMany(objects); // make implicit assumption of implementation of the superclass

assertEquals(4, stack.getMaxSize());
}
}

上述我們看到:MonitorableStack很隱含的假設父類別的pushMany函數的實作,他認為pushMany會多次呼叫push。

如果父類別的pushMany修改成下面的方式,MonitorableStack脆弱的假設將會被破壞:

static class Stack {
private int stack_pointer = -1;
private Object[] stack = new Object[1000];

public void push(Object article) {
assert stack_pointer < stack.length;
stack[++stack_pointer] = article;
}

public Object pop() {
assert stack_pointer >= 0;
return stack[stack_pointer--];
}

public void pushMany(Object[] articles) {
assert (stack_pointer + articles.length) < stack.length;
System.arraycopy(articles, 0, stack, stack_pointer + 1,
articles.length);
stack_pointer += articles.length;
}
}

上述優化了Stack的效能,其pushMany的實作不在透過迴圈來呼叫push,而是直接複製全部的元素。

此時, assertEquals(4, stack.getMaxSize())的假設將會失效,因為父類別實作的修改,破壞子類別原有的功能。

解決方式很簡單:favor object composition over class inheritance,我們使用介面繼承與object composition的方式來避免fragile super class的問題。

範例程式碼:

static class MonitorableStack implements Stack {
private int maxSize = 0;
private int current_size;
private SimpleStack simpleStack;

public MonitorableStack(SimpleStack simpleStack) {
this.simpleStack = simpleStack;
}

@Override
public void push(Object article) {
if (++current_size > maxSize)
maxSize = current_size;
simpleStack.push(article);
}

@Override
public Object pop() {
--current_size;
return simpleStack.pop();
}

@Override
public void pushMany(Object[] objects) {
if (current_size + objects.length > maxSize)
maxSize = current_size + objects.length;

simpleStack.pushMany(objects);
}

public int getMaxSize() {
return maxSize;
}

public static void main(String[] args) {
MonitorableStack stack = new MonitorableStack(new SimpleStack());

Object[] objects = new Object[]{1, 2, 3, 4};
stack.pushMany(objects); // make implicit assumption of implementation of the superclass

assertEquals(4, stack.getMaxSize());
}
}

透過介面繼承與object composition能到達到Black Box Reuse,能夠避免子類別耦合到父類別實作的問題,也更符合物件導向的特性,Isolate change into one place。

  • Coupled to super class’s data structure
public abstract class SuperClass {
protected int a;
protected int b;
protected int c;

abstract void f();
}

public class ConcreteA extends SuperClass {

@Override
void f() {
processA();
processB();
processC();
variationLogicA();
}

private void processA() {
this.a++;
}

private void processB() {
this.b++;
}

private void processC() {
this.c++;
}

private void variationLogicA() {

}
}

public class ConcreteB extends SuperClass {

@Override
void f() {
processA();
processB();
processC();
variationLogicB();
}

private void processA() {
this.a++;
}

private void processB() {
this.b++;
}

private void processC() {
this.c++;
}

private void variationLogicB() {

}
}

上述我們可以看到:superClass有三個屬性:a、b、c,跟一個abstract method f(),concreteA與concreteB皆繼承superClass,並且控制整個流程,同時操作a、b、c三個屬性,這種方式有以下缺點:

  • 父類別與子類別強烈耦合:當父類別的資料結構增加、減少時,所有的子類別都會受到影響。
  • 重複程式碼:基本的演算法流程散落在子類別當中,當流程修改所有子類別皆需要修改。

解決方法很簡單:template method design pattern,我們使用template method design pattern來封裝抽象控制流程,並且只讓子類別提供specialization。

修改後如下:

static abstract class SuperClass {
private int a;
private int b;
private int c;

final void f() {
processA();
processB();
processC();
variationLogic();
}

private void processA() {
this.a++;
}

private void processB() {
this.b++;
}

private void processC() {
this.c++;
}

protected abstract void variationLogic();

}

static class ConcreteA extends SuperClass {
@Override
protected void variationLogic() {
variationLogicA();
}

private void variationLogicA() {

}
}

static class ConcreteB extends SuperClass {
@Override
protected void variationLogic() {
variationLogicB();
}

private void variationLogicB() {

}
}

修改後程式碼有以下優點:

  • 父類別與子類別耦合降低:因為子類別不在操作父類別的資料結構,只專注提供specialization的功能。當父類別資料結構修改只需要修改父類別即可。
  • 刪除重複程式碼:抽象的控制流程被封裝在父類別中,當控制流程改變,只需要修改父類別。
  • 好繼承好擴充:沒有使用template method時,如果要新增一個子類別,我們要撰寫很多程式碼,但修改後只需要專注在specialization,其他邏輯皆由父類別完成,這是Inversion of Control的一種展現,能夠降低複雜度,專注要解決的問題。

Skill to Prevent Fragile Class in Java

看完上面全部的介紹,了解到了繼承是一把雙面刃,用的好上天堂,用不好下地獄。

在Java語言中,有一個Keyword能夠透過compile time checking的方式,來避免大部分Fragile Class的問題,那就是final

我們只需要遵守以下規則,就能避免錯誤的繼承:

  • 所有Concrete Class皆為final:經過上面介紹我們知道,父類別需要完整的抽象化,因此如果是Concrete Class基本上是不能被繼承的,會導致Unbalance Abstraction
  • 所有public non abstract method皆為final:了解了Unbalance Abstraction後,如果不是abstract method沒有被覆寫的必要,真的想要複寫能透過介面繼承與object composition來達成

結論:

  • 繼承主要提供了automatic message delegation的機制讓子類別能接收父類別的訊息。
  • 繼承主要為的比喻為Specialization,不是is-a。
  • 子類別需要遵守客戶端的Contract、遵守LSP
  • 良好的繼承遵守Balance Abstraction、Dependency Inversion Principle、Normalize
  • 避免針對父類別的實作進行不必要的假設。
  • 避免耦合到父類別的資料結構。
  • 使用final限制類別不能被繼承、實作不能被隨便複寫。
  • 遵守Black Box Inheritance,而不是White Box Inheritance。

Material Reference:

https://www.infoworld.com/article/2073649/why-extends-is-evil.html

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

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

No responses yet