抽象类别与介面 (1)

在上一篇文章中提到,我们可以将不同类别当中的共同属性或方法,提取出来放在 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()                         // federer can hit
federer.walk()                        // federer 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

不过,究竟什麽是「抽象类别」和「介面」,就让我们下一篇文章多谈一些吧!


<<:  Day03【JS】立即呼叫函式 IIFE

>>:  Day 5 韧体的烧录及可靠性

Day 17:RecyclerView 跳页&资料传递(2)

本篇文章同步发表在 HKT 线上教室 部落格,线上影音教学课程已上架至 Udemy 和 Youtu...

Day 26. 测试SSR常见问题

EADDRINUSE 表示你使用的Port被其他的Application占用,你可以把占用的appl...

Day 24 Flask-Mail

这个插件就如同名称一样,是专门寄信使用的(恩对,介绍就这样而已)。 准备 在开始使用之前要先做好前置...

Day25 Vue 双向绑定 vs 单向绑定

什麽是单向绑定什麽是双向绑定?简单来说一个只有单方面的传送,另一个则是可以来回传,wow讲完了,今天...

[Day 29] 阿嬷都看得懂的 JQuery 怎麽写

阿嬷都看得懂的 JQuery 怎麽写 昨天我们聊到怎麽使用 document 这个咒语,让神灯精灵帮...