第二週第三天:類別與介面

第二週第三天:類別與介面

學習資源


因為我自己對 JavaScript 的類別概念不是很熟悉,這篇筆記會以彙整書中提到的主要概念及用法為主。

JavaScript(ES6 及更新版本)和 TypeScript 都支持類別(Class)這一概念,類別主要用於封裝相關的數據和函式。類別在物件導向程式設計(OOP)中扮演著重要角色,它們允許我們定義自定義類型,並且可以通過繼承來實現程式碼複用和封裝。

類別的主要概念

建構函式(Constructor)

建構函式是一個特殊的方法,當使用 new 關鍵字創建一個類別的實例(instance)時(實例化),將自動呼叫建構函式。

class Animal {
    // 建構函式
    constructor(name) {
        this.name = name;
    }
}

const dog = new Animal('Jack'); // 創建實例
console.log(dog.name); // 顯示:Jack

在 ES6 建構函式通常用於初始化實例的屬性:

class Animal {
    constructor() {
        this.name = 'Jack'; // 初始化實例的屬性
    }
}

const dog = new Animal();
console.log(dog.name); // 顯示:Jack

在 ES13 後則可以直接在類別內定義:

class Animal {
    name = "Jack";  // 初始化實例的屬性
}

const dog = new Animal();
console.log(dog.name); // 顯示:Jack

屬性(Properties)

屬性是類別實例中儲存的值。它們通常在建構函式中初始化,然後在類別的其他方法中使用或修改。在 JavaScript 中,屬性在建構函式中通過 this 關鍵字設置。

class Animal {
    constructor(name) {
        // 用 this 定義屬性 name
        this.name = name;
    }
}

方法(Methods)

方法是類別中定義的函式,用於執行特定的操作。方法通常會使用或修改類別實例的屬性。

class Animal {
    constructor(name) {
        this.name = name;
    }
    // 定義方法 speak
    speak() {
        console.log(`${this.name} makes a noise.`);
    }
}

存取器(Getters and Setters)

存取器是一種特殊的方法,用於讀取和設置類別實例的屬性。它們可以用來對設置或讀取的值進行檢查或修改。

class Animal {
    constructor(name) {
        this._name = name;
    }

    get name() {
        return this._name;
    }

    set name(value) {
        this._name = value;
    }
}

const animal = new Animal('Jack');
console.log(animal.name); // Jack
animal.name = 'Tom';
console.log(animal.name); // Tom

靜態屬性和方法(Static properties and methods)

靜態屬性和方法是直接附加到類別本身,而不是類別的實例上。這意味著它們不能在類別的實例中使用。靜態屬性和方法通常用於實現與特定實例無關的功能。

class Animal {
    // 靜態屬性(ES13 新增)
    static numberOfAnimals = 0;

    constructor(name) {
        this.name = name;
        Animal.numberOfAnimals++;
    }

    speak() {
        console.log(`${this.name} makes a noise.`);
    }

    // 靜態方法
    static displayNumberOfAnimals() {
        console.log(`Number of animals: ${Animal.numberOfAnimals}`);
    }
}

const dog = new Animal('Jack');
const cat = new Animal('Tom');

console.log(Animal.numberOfAnimals); // 顯示:2

// 一般方法需要 Animal 類別用 new 實例化後才能呼叫
dog.speak(); // 顯示:Jack makes a noise.

// 靜態方法可以直接透過 Animal 類別呼叫
Animal.displayNumberOfAnimals(); // 顯示:Number of animals: 2

封裝(Encapsulation)

封裝是一種將類別的實現細節隱藏起來,只暴露一個公共介面的方法。

JavaScript 中的封裝可以通過在屬性和方法名前加上一個下劃線(_)來表示 "私有"。然而,這只是一個慣例,並不能真正阻止存取。在最新的 JavaScript(ES2022)中,引入了真正的私有屬性和方法,它們以 # 符號開頭:

class Animal {
    #name;

    constructor(name) {
        this.#name = name;
    }

    getName() {
        return this.#name;
    }
}

const animal = new Animal('Jack');
console.log(animal.getName()); // Jack
console.log(animal.#name); // SyntaxError: Private field '#name' must be declared in an enclosing class

繼承(Inheritance)

繼承是一種允許類別從另一個類別繼承屬性和方法的機制。繼承的主要目的是為了實現程式碼複用,並建立一個層次結構,使得子類別可以根據需要修改和擴展父類別的功能。

繼承通過使用 extends 關鍵字來實現。在子類別中,super 關鍵字用於呼叫父類別的建構函數或方法。

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a noise.`);
    }
}

// 子類別 Dog 繼承父類別 Animal 的所有屬性和方法
class Dog extends Animal {
    constructor(name) {
        super(name); // 呼叫父類別的 constructor
    }

    speak() {
        super.speak(); // 呼叫父類別的 speak 方法
    }
}
const dog = new Dog('Mitzie');
dog.speak(); // 顯示:Mitzie makes a noise.

多型(Polymorphism)

多型是一種允許子類別覆寫(override)或擴展(extend)父類別方法的機制。多型的主要目的是為了提供一個統一的接口,讓不同的類別可以在共同的方法名下實現不同的行為。這表示對於具有相同方法名的不同類別對象,可以使用相同的接口進行操作,而不用關心對象的具體類型。

在 JavaScript 中,可以通過原型繼承來實現多型。

class Animal {
    constructor(name) {
        this.name = name;
    }

    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

class Dog extends Animal {
    // 子類別 Dog 覆寫父類別 Animal 的 speak 方法 
    speak() {
        console.log(`${this.name} barks.`);
    }
}

const animal = new Animal("Generic animal");
animal.speak(); // 顯示:Generic animal makes a sound.

const dog = new Dog("Buddy");
// 一樣可以用 speak 方法,不用管是類別 animal 還是 dog 
dog.speak(); // 顯示:Buddy barks.

讀到這裡我不太懂繼承和多型的差別在哪裡,一樣變身十歲小孩請 ChatGPT 解釋給我聽,發現是兩者關注點不太一樣,只是 JavaScript 都是用 extends 來實作這兩個物件導向程式設計的概念:

想像一下,你有一個動物王國,裡面有很多種動物。為了表示所有動物的共同特徵,我們可以創建一個基本的動物類別(Animal),這個類別有一個動作叫做 "發出聲音"(makeSound)。

  1. 繼承(Inheritance):

    現在我們想要添加更具體的動物,比如狗(Dog)。狗是一種動物,所以狗應該擁有動物的一些特徵,例如 "發出聲音"。這時候,我們可以讓狗類別(Dog)繼承動物類別(Animal)。這樣一來,狗類別就自動獲得了動物類別的所有特徵,包括 "發出聲音"。這就是繼承的概念。」

  2. 多型(Polymorphism):

    雖然狗繼承了動物類別的 "發出聲音",但狗發出的聲音(例如 "汪汪")和其他動物是不一樣的。所以我們需要讓狗類別自己定義它的 "發出聲音" 行為。這就是多型的概念,讓狗類別可以根據自己的特性修改或擴展它從動物類別繼承來的 "發出聲音" 行為。

想像一下,如果我們有一個動物園(Zoo),裡面有很多動物。我們不需要知道每個動物是什麼類型,只要知道它們都能 "發出聲音"。這樣,我們就可以通過多型讓每個動物以自己的方式 "發出聲音"。

總之,繼承是讓一個類別獲得另一個類別的特徵,而多型是讓不同類別可以用不同的方式實現相同的行為

TypeScript 類別的用法

TypeScript 的類別具有與 JavaScript 類似的語法,但添加了型別檢查和其他功能,如存取修飾符以及介面實現等。

類別的型別檢查

給類別加上型別的方式與介面類似:

class Animal {
    private name: string;

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

    public speak(): void {
        console.log(`${this.name} makes a noise.`);
    }
}

const dog: Animal = new Animal('Jack');
dog.speak();

存取修飾符(Access Modifiers)

修飾符用於限制類別屬性或方法的可存取性。TypeScript 提供了 public(默認)、private (私有)和 protected (受保護)三種修飾符。

class Person {
    public name: string;
    private age: number;
    protected gender: string;

    constructor(name: string, age: number, gender: string) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }
}

const person = new Person("John", 30, "male");
console.log(person.name); // 可存取
console.log(person.age); // 🔴 錯誤:age 是私有的
console.log(person.gender); // 🔴 錯誤:gender 是受保護的

私有屬性只能在類別內部存取,而受保護屬性可以在類別及其子類別中存取:

class Animal {
    private name;
    protected age;
    public constructor(name, age) {
        this.name = name;
        this.age = age;
    }
}

class Cat extends Animal {
    constructor(name, age) {
        super(name, age);
        console.log(this.name); // 🔴 錯誤:私有屬性 name 不能在子類別存取
        console.log(this.age); // 受保護屬性 age 可以在子類別存取
    }
}

當建構函式被標記為私有時,該類別不允許被繼承或實例化,而當建構函式受保護的時,該類別只允許被繼承。

class Animal {
    public name;
    // 建構函式被標記為私有
    private constructor (name) {
        this.name = name;
  }
}
// 🔴 錯誤:無法延伸類別 'Animal'。類別建構函式已標記為私用。
class Cat extends Animal {
    constructor (name) {
        super(name);
    }
}

let cat = new Animal('Jack'); // 🔴 錯誤:類別 'Animal' 的建構函式為私用,並且只能在類別宣告內存取。
class Animal {
    public name;
    protected constructor (name) {
        this.name = name;
  }
}
class Cat extends Animal {
    constructor (name) {
        super(name);
    }
}

let cat = new Animal('Jack'); // 🔴 錯誤:類別 'Animal' 的建構函式受到保護,並且只能在類別宣告內存取。

修飾符也可以使用在建構函式的引數中,同等於類別中定義該屬性,使程式碼更簡潔:

class Animal {
    public constructor (public name) {
        this.name = name;
    }
}

// 等同於
class Animal {    
    public name: string;
    public constructor (name) {
        this.name = name;
    }
}

抽象類別(Abstract Class)

抽象類別是一種特殊的類別,它不能被直接實例化。抽象類別通常作為其他類別的基類(也就是父類),並包含一個或多個抽象方法。抽象方法是在抽象類別中宣告但不包含具體實現的方法。在 TypeScript 中,我們使用 abstract 關鍵字來定義抽象類別和抽象方法。

// 建立抽象類別
abstract class Animal {
  constructor(public name: string) {}
  // 抽象方法
  abstract speak(): void;
}

class Dog extends Animal {
  speak() {
    console.log(`${this.name} 汪汪汪!`);
  }
}

class Cat extends Animal {
  speak() {
    console.log(`${this.name} 喵喵喵!`);
  }
}

// 以下這行將導致編譯錯誤,因為 Animal 是一個抽象類別,不能被實例化
// let animal = new Animal("動物");

// 這些是正確的,因為 Dog 和 Cat 類別繼承了 Animal 並實現了抽象方法 speak
let dog = new Dog("旺財");
let cat = new Cat("咪咪");

dog.speak(); // 顯示:旺財 汪汪汪!
cat.speak(); // 顯示:咪咪 喵喵喵!

介面實現(Interface Implementation)

實現(Implements)是物件導向程式設計的一個重要概念。

一般來說,一個類別只能繼承另一個類別,但有時不同類別之間有部分相同的特性(屬性或方法),這時我們就可以把這些相同的特性提取成介面(Interface),讓不同類別共享這些特性。換句話說,當不同類別使用這個介面時,表示類別「實現」了這個介面。

當類別要實現一個介面時,該類別必須定義介面中宣告的所有方法和屬性。在 TypeScript 中用 implements 關鍵字來進行介面實現。

讓我們用一個簡單的例子來示範如何從兩個不同類別中提取共同特性,並使用類別實現介面的方法。

  1. 假設我們有兩個類別:DogCat,它們都有 name 屬性和 speak 方法。

     class Dog {
         name: string;
    
         constructor(name: string) {
             this.name = name;
         }
    
         speak() {
             console.log(this.name + " barks!");
         }
     }
    
     class Cat {
         name: string;
    
         constructor(name: string) {
             this.name = name;
         }
    
         speak() {
             console.log(this.name + " meows!");
         }
     }
    
  2. 我們可以看到,DogCat 類別有相同的 name 屬性和 speak 方法。現在我們想提取這些共同特性並創建一個介面。

     interface Animal {
         name: string;
         speak(): void;
     }
    
  3. 現在我們已經創建了 Animal 介面,接下來讓 DogCat 類別分別實現這個介面。

     class Dog implements Animal {
         name: string;
    
         constructor(name: string) {
             this.name = name;
         }
    
         speak() {
             console.log(this.name + " barks!");
         }
     }
    
     class Cat implements Animal {
         name: string;
    
         constructor(name: string) {
             this.name = name;
         }
    
         speak() {
             console.log(this.name + " meows!");
         }
     }
    
  4. 通過這種方式,我們確保了 DogCat 類別都擁有 Animal 介面所定義的特性。這使得程式碼更加靈活和可擴展,並且提高了程式碼的可維護性。同時,我們可以在不影響其他程式碼的情況下,獨立地修改 DogCat 類別。

一個類別也可以實現多個介面,只需在 implements 關鍵字後使用逗號分隔多個介面名稱即可:

interface Walks {
    walk(): void;
}

interface Eats {
    eat(): void;
}

class Dog implements Walks, Eats {
    walk() {
        console.log("The dog is walking.");
    }

    eat() {
        console.log("The dog is eating.");
    }
}

在上述範例中,Dog 類別同時實現了 WalksEats 介面。

介面繼承介面

介面可以繼承其他介面,使用 extends 關鍵字。

interface Animal {
    breathe(): void;
}

interface Mammal extends Animal {
    feedMilk(): void;
}

class Human implements Mammal {
    breathe() {
        console.log("The human is breathing.");
    }

    feedMilk() {
        console.log("The human is feeding milk.");
    }
}

在上述範例中,Mammal 介面繼承了 Animal 介面,Human 類別實現了 Mammal 介面。

書中提到介面可以繼承類別,但我發現新的官方文件似乎已經找不到這部份的描述了,用英文跟中文 Google 後也找不太到相關的說明,因此我先略過這部份,等之後有找到比較明確的說明我再補上。