JavaScript中的繼承和組合

一顆小行星 發表於 2022-04-29
JavaScript

繼承與組合都是物件導向中程式碼複用的方式,瞭解各自有什麼特點,可以讓我們寫出更簡潔的程式碼,設計出更好的程式碼架構。

這是一篇翻譯文章,作者是serhiirubets

“我應該怎麼使用繼承和組合”這是一個常見的問題,不僅是JavaScript相關,但是本篇我們只討論JavaScript相關的內容和示例。

如果你不知道什麼是組合繼承,我強烈推薦你去檢視相關內容,因為本文的主要講的就是怎麼使用和如何選擇它們,但是為了確定我們在一個頻道,讓我們先了解一下組合和繼承吧。

繼承是物件導向程式設計核心概念之一,可以幫助我們避免程式碼重複。主要的思想是我們可以建立一個包含邏輯的基類,可以被子類重用。我們來看個示例:

class Element {
  remove() {}
  setStyles() {}
}
class Form extends Element {}
class Button extends Element {}

我們建立了一個基類“Element”,子類會繼承Element中的通用邏輯。

繼承存在is-a關係:Form是一個Element, Button也是一個Element

組合:和繼承不同,組合使用的是has-a關係,將不同的關係收集到一起。

class Car {
  constructor(engine, transmission) {
    this.engine = engine;
    this.transmission = transmission;
  }
}
class Engine {
  constructor(type) {
    this.type = type;
  }
}
class Transmission {
  constructor(type) {
    this.type = type;
  }
}
const petrolEngine = new Engine('petrol');
const automaticTransmission = new Engine('automatic');
const passengerCar = new Car(petrolEngine, automaticTransmission) ;

我們建立了使用EngineTransmission建立了Car,我們不能說Engine是一個Car,但是可以說Car包含Engine。希望上面的例子可以幫助你理解什麼是繼承,什麼是組合

我們再來看兩個不同的示例,對比一下使用類的方法實現繼承和函式方法實現組合有什麼區別。

假設我們正在使用檔案系統,想實現讀取、寫入和刪除的功能。我們可以建立一個類:

class FileService {
  constructor(filename) {
      this.filename = filename;
  } 
  read() {}
  write() {}
  remove() {}
}

目前可以滿足我們想要的功能,之後我們可能想加入許可權控制,一些使用者只有讀取許可權,其他人可能有寫入許可權。我們應該怎麼辦?一個解決方案是我們可以把方法劃分為不同的類:

class FileService {
  constructor(filename) {
    this.filename = filename;
  }
}
class FileReader extends FileService {
  read() {}
}
class FileWriter extends FileService {
  write() {}
}
class FileRemover extends FileService {
  remove() {}
}

現在每個使用者可以使用其需要的許可權,但是還有一個問題,如果我們需要給一些人同時分配讀取和寫入許可權,應該怎麼辦?同時分配讀取和刪除許可權怎麼辦?使用當前的實現,我們做不到,應該怎麼解決?

第一個想到的方案可能是:為讀取和寫入建立一個類,為讀取和刪除建立一個類。

class FileReaderAndWriter extends FileService {
  read() {}
  write() {}
}
class FileReaderAndRemover extends FileService {
  read() {}
  remove() {}
}

按照這種做法,我們可能還需要以下類: FileReader, FileWriter, FileRemove, FileReaderAndWriter, FileReaderAndRemover。

這不是一個好的實現方式:第一,我們可能不僅有3種,而是10、20種方法,還需要在他們之間有大量的組合。第二是我們的類中存在重複的邏輯,FileReader類包含讀取方法,FileReaderAndWriter也包含同樣的程式碼。

這不是一個很好的解決方案,還有其他的實現方法嗎?多重繼承?JavaScript中沒有這個特性,而且也不是很好的方案:A類繼承了B類,B類可能繼承了其他類...,這樣的設計會非常混亂,不是一個良好的程式碼架構。

怎麼解決呢?一個合理的方法是使用組合:我們把方法拆分為單獨的函式工廠。

function createReader() {
    return {
        read() {
            console.log(this.filename)
        }
    }
}
function createWriter() {
    return {
        write() {
            console.log(this.filename)
        }
    }
}

上面的示例中,我們有兩個函式建立了可以讀取和寫入的物件。現在就可以在任何我們地方使用它們,也可以將它們進行組合:

class FileService {
    constructor(filename) {
        this.filename = filename ;
    }
}
function createReadFileService (filename ) {
    const file = new FileService(filename);
    return {
        ...file,
        ...createReader()
    }
}
function createWriteFileService (filename) {
    const file = new FileService(filename);
    return {
        ...file,
        ...createWriter(),
    }
}

上面的例子中,我們建立了讀取和寫入服務,如果我們想組合不同的許可權:讀取、寫入和刪除,我們可以很容易的做到:

function createReadAndWriteFileService (filename) {
    const file = new FileService(filename);
}
return {
    ...file,
    ...createReader(),
    ...createWriter()
}

const fileForReadAndWriter = createReadAndWriteFileService('test');
fileForReadAndWriter.read();
fileForReadAndWriter.write();

如果我們有5、10、20種方法,我們可以按照我們想要的方式進行組合,不會有重複的程式碼問題,也沒有令人困惑的程式碼架構。

我們再來看一個使用函式的例子,假設我們有很多員工,有計程車司機、健身教練和司機:

function createDriver(name) {
    return {
        name,
        canDrive: true,   
    }
}
function createManager(name) {
    return {
        name,
        canManage: true
    }
}
function createSportCoach(name) {
    return {
        name,
        canSport: true
    }
}

看起來沒有問題,但是假設有一些員工白天當健身教練,晚上去跑出租,我們應該怎麼調整呢?

function createDriverAndSportCoach(name) {
    return {
        name,
        canSport: true,
        canDriver: true
    }
}

可以實現,但是和第一個例子一樣,如果我們有多種型別混合,就會產生大量重複的程式碼。我們可以通過組合來進行重構:

function createEmployee(name,age) {
    return {
        name,
        age
    }
}
function createDriver() {
    return {
        canDrive: true
    }
}
function createManager() {
    return {
        canManage: true
    }
}
function createSportCoach() {
    return {
        canSport: true
    }
}

現在我們可以根據需要組合所有工作型別,沒有重複程式碼,也更容易理解:

const driver = {
    ...createEmployee('Alex', 20),
    ...createDriver()
}
const manager = {
    ...createEmployee('Max', 25),
    ...createManager()
}
const sportCoach = {
    ...createEmployee('Bob', 23),
    ...createSportCoach()
}
const sportCoachAndDriver = {
    ...createEmployee('Robert', 27) ,
    ...createDriver(),
    ...createSportCoach() 
}

希望你現在已經可以理解繼承組合之間的區別,一般來說,繼承可以用於is-a關係,組合可以用於has-a

但在實踐中,繼承有時候並不是一個好的解決方法:就像示例中,司機是員工(is-a關係),經理也是員工,如果我們需要把不同的部分進行混合,組合確實比繼承更合適。

最後我想強調的是:繼承組合都是很好實現,但是你應該正確的使用他們。一些場景組合可能更合適,反之亦然。

當然,我們可以將繼承組合結合在一起,比如我們有is-a關係,但想新增不同的值或方法:我們可以建立一些基類,為例項提供所有通用功能,然後使用組合來新增其他特定功能。

歡迎關注“混沌前端”公眾號