OOP - 4 抽象類別與介面 (1)


Posted by tsungtingdu on 2021-09-19

在上一篇文章中提到,我們可以將不同類別當中的共同屬性或方法,提取出來放在 parent 類別當中,然後透過繼承的方式,實現這些屬性或方法,同時也可以加入額外的屬性或方法。

以上次提到例子來說,BaseballPlayer 是一個 parent 類別,包含了 name 屬性和 hit 方法

class BaseballPlayer {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit`)
  }
}

接著,我們建立 Shortstop 類別來繼承 BaseballPlayer

class Shortstop extends BaseballPlayer {
  run() {
    console.log(`${this.name} can run`)
  }
}

當然我們也可以建立另外一個 Outfielder 類別來繼承 BaseballPlayer,創造出更多不同類型的 baseball players

class Outfielder extends BaseballPlayer {
  run() {
    console.log(`${this.name} can run very fast`)
  }
}

最後,我們就可以實際創造出類別的實例,並呼叫其方法

const lindor = new Shortstop('lindor')
const betts = new Outfielder('betts')

lindor.hit()  // lindor can hit
lindor.run()  // lindor can run
betts.hit()   // betts can hit
betts.run()   // betts can run very fast

奇怪的繼承

這時候,隔壁棚傳來新的需求,想要建立一個同樣可以實作出 name 屬性和 hit 方法的網球選手,於是就直接讓 TennisPlayer 去繼承 BaseballPlayer

class TennisPlayer extends BaseballPlayer {
  serve() {
    console.log(`${this.name} can serve`)
  }
}

const federer = new Golfer('federer')
federer.hit()                         // johnny can hit
federer.walk()                        // johnny can serve

網球選手繼承棒球選手?雖然實作出來的結果如預期,但是看起來就非常的奇怪,而且沒有邏輯。如果今天我們繼續充實 BaseballPlayer 類別,譬如加入 pitch 方法,變成

class BaseballPlayer {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit`)
  }

  pitch() {
    console.log(`${this.name} can pitch the ball`)
  }
}

結果就會發現網球選手 federer 也會開始投球了

const federer = new Golfer('federer')
federer.pitch()                       // federer can pitch

A 是 B 的一種

雖然我們可以任意的分類、抽取屬性和方法建立 parent 類別,然後任意的繼承某個類別來取得需要的屬性和方法,但這樣的「整理方式」,最終只會造成無限的混亂和錯誤發生。

所以通常在建立 parent 類別和繼承的時候,會遵循著「A 是 B 的一種」(is-a)的規則,譬如

  • Shortstop 是 BaseballPlayer 的一種
  • Outfielder 是 BaseballPlayer 的一種
  • 智人是人屬的一種
  • 靈長目是哺乳綱的一種
  • child 類別是 parent 類別的一種
  • ...

這樣一來,就能有邏輯的模擬真實世界的狀況,也不會有意外的錯誤發生。

如何整理和規範?

現在我們知道 TennisPlayer 不應該直接繼承 BaseballPlayer,不過這看起來好像也不是什麼大問題,只要直接建立兩個完全獨立的類別,然後分別實作各種方法,像是 hit, pitch, serve 等等。

但是我們還是希望可以稍微整理一下,讓這個共同的方法在某種程度上被「抽取出來」或「規範」,並在未來建立其他新的類別的時候可以被使用。

接下來我們來看看幾種不同的實作方式:

1. 建立 parent 類別

class Athlete {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit`)
  }
}

class BaseballPlayer extends Athlete {
  pitch() {
    console.log(`${this.name} can pitch the ball`)
  }
}

class TennisPlayer extends Athlete {
  serve() {
    console.log(`${this.name} can serve`)
  }
}

這裡我們建立了一個 Athlete 類別,並讓 BaseballPlayerTennisPlayer 分別繼承他的屬性和方法,如此一來,baseball player 和 tennis player 都可使用同樣的 hit 方法

const jeter = new BaseballPlayer('jeter')
const federer = new TennisPlayer('federer')
const someone = new Athlete('someone')

jeter.hit()     // jeter can hit
federer.hit()   // federer can hit
someone.hit()   // someone can hit

不過這時候又發現了幾個小問題,一個是雖然我們希望 baseball player 和 tennis player 都可以使用 hit,但是兩者實際實作 hit 的方式和細節可能不太一樣,譬如我們希望變成

jeter.hit()     // jeter can hit baseball
federer.hit()   // federer can hit tennis

另外一個是,也許我們不需要為 Athlete 建立實例 (e.g., someone)。我們期待各種運動選手都應該來自於各種選手的類別,然後這些類別共同繼承Athlete

2. 使用「抽象類別」

為了解決剛才提到的問題,所以現在我們換個方式做。相對於建立一個 parent 類別,這裡我們建立一個抽象類別 Athlete

abstract class Athlete {
  name: string

  constructor(name: string) {
    this.name = name
  }

  abstract hit(): void;
}

class BaseballPlayer extends Athlete {
  hit() {
    console.log(`${this.name} can hit baseball`)
  }

  pitch() {
    console.log(`${this.name} can pitch the ball`)
  }
}

class TennisPlayer extends Athlete {
  hit() {
    console.log(`${this.name} can hit tennis`)
  }

  serve() {
    console.log(`${this.name} can serve`)
  }
}

跟剛剛不一樣的地方是,我們無法透過抽象類別來建立實例,另一方面,我們只在這個抽象類別定義了 hit 這個方法的存在,但是沒有定義 hit 的實作細節。所以 baseball player 和 tennis player 可以有同樣的 hit 方法但是有不同的結果

const jeter = new BaseballPlayer('jeter')
const federer = new TennisPlayer('federer')
const someone = new Athlete('someone')     // error

jeter.hit()     // jeter can hit baseball
federer.hit()   // federer can hit tennis

3. 使用「介面」

除了抽象類別之外,我們還可以使用介面 (interface)。介面本身定義了 hit 方法的存在,但是沒有定義它的實作方式。hit 實作的方式被定義在使用 (implements) 該介面的類別當中

interface Hit {
  hit(): void;
}

class BaseballPlayer implements Hit {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit baseball`)
  }

  pitch() {
    console.log(`${this.name} can pitch the ball`)
  }
}

class TennisPlayer implements Hit {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit tennis`)
  }

  serve() {
    console.log(`${this.name} can serve`)
  }
}

和抽象類別一樣,介面無法建立實例

const jeter = new BaseballPlayer('jeter')
const federer = new TennisPlayer('federer')
const someone = new Hit()                    // error

jeter.hit()     // jeter can hit baseball
federer.hit()   // federer can hit tennis

不過,究竟什麼是「抽象類別」和「介面」,就讓我們下一篇文章多談一些吧!


鐵人賽發表網址:幫自己搞懂物件導向和設計模式


#OOP #Object-oriented programming #TypeScript #2021-ironman







Related Posts

在Gatsby GraphQL中組合出完美資料

在Gatsby GraphQL中組合出完美資料

[Day 05] - Vault dynamic secrets engine - Database(MySQL)

[Day 05] - Vault dynamic secrets engine - Database(MySQL)

[Node.js] 集群

[Node.js] 集群


Comments