本文 AI 產出,尚未審核

TypeScript 課程 – 類別(Classes)

主題:抽象類別(abstract class)


簡介

在物件導向程式設計中, 類別 是抽象化實體的核心概念。隨著程式規模的增長,我們常常需要在「共通行為」與「具體實作」之間建立明確的界線,以免混淆或重複程式碼。
抽象類別(abstract class) 正是為了這個目的而設計的:它允許我們定義只能被繼承、不能直接實例化的基底類別,並在其中聲明抽象方法、屬性或提供部分實作。

在 TypeScript 中使用抽象類別,有助於:

  1. 強化型別安全:編譯期即可捕捉子類別未實作必須方法的錯誤。
  2. 提升程式可讀性:開發者一眼即可看出哪些成員是「必須」被實作的。
  3. 促進程式碼重用:共通的實作可以寫在抽象類別裡,子類別只需要關注各自的差異。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,直到實際應用場景,完整帶你掌握抽象類別的使用方式。


核心概念

什麼是抽象類別?

  • 抽象類別 是一種不能直接被 new 建構的類別。
  • 它可以包含 抽象成員abstract 方法或屬性)以及 具體成員(已實作的方法、屬性、建構子等)。
  • 任何繼承抽象類別的子類別 必須 實作所有抽象成員,否則子類別本身也必須宣告為抽象類別。
abstract class Shape {
    // 抽象方法:子類別必須提供實作
    abstract getArea(): number;

    // 具體方法:所有子類別都會繼承此實作
    describe(): string {
        return `This shape has an area of ${this.getArea()} units².`;
    }
}

為什麼要使用 abstract 關鍵字?

  • 編譯期檢查:TypeScript 編譯器會在編譯時驗證抽象方法是否被實作,減少執行時錯誤。
  • 語意清晰abstract 明確告訴閱讀程式碼的人,「這裡的行為是由子類別決定」;同時避免誤用 new Shape() 產生不可預期的行為。

抽象屬性與抽象存取子(getter / setter)

抽象類別不僅可以宣告抽象方法,還能宣告抽象屬性抽象存取子,讓子類別自行決定儲存方式或計算邏輯。

abstract class Vehicle {
    // 抽象屬性:子類別必須提供實際的欄位或存取子
    abstract readonly wheels: number;

    // 抽象 getter:必須回傳一個值
    abstract get speed(): number;
}

多層繼承與抽象類別的組合

抽象類別可以互相繼承,形成多層抽象階層。例如:

abstract class Animal {
    abstract makeSound(): void;
    move(): void {
        console.log('Moving...');
    }
}

abstract class Mammal extends Animal {
    abstract nurseYoung(): void;
}

在這個例子中,Mammal 繼承了 Animal,同時加入了自己的抽象成員 nurseYoung。最終的具體類別(例如 Dog)需要同時實作 makeSoundnurseYoung


程式碼範例

以下提供 5 個實用範例,從最簡單的抽象方法到結合介面、泛型與存取子的進階寫法,幫助你快速上手。

範例 1:基本抽象類別與子類別實作

// 抽象基底類別
abstract class Employee {
    constructor(public name: string) {}

    // 必須在子類別實作的抽象方法
    abstract calculateSalary(): number;

    // 具體方法,所有子類別皆可使用
    getInfo(): string {
        return `${this.name} 的月薪為 ${this.calculateSalary()} 元`;
    }
}

// 正式員工
class FullTimeEmployee extends Employee {
    constructor(name: string, private monthlyBase: number) {
        super(name);
    }

    // 實作抽象方法
    calculateSalary(): number {
        return this.monthlyBase;
    }
}

// 合約工
class Contractor extends Employee {
    constructor(name: string, private hourlyRate: number, private hours: number) {
        super(name);
    }

    calculateSalary(): number {
        return this.hourlyRate * this.hours;
    }
}

// 測試
const alice = new FullTimeEmployee('Alice', 50000);
const bob = new Contractor('Bob', 800, 160);
console.log(alice.getInfo()); // Alice 的月薪為 50000 元
console.log(bob.getInfo());   // Bob 的月薪為 128000 元

重點Employee 不能被 new,必須透過子類別產生實例;子類別若忘記實作 calculateSalary,編譯器會直接報錯。


範例 2:抽象屬性與抽象 getter

abstract class Account {
    // 抽象屬性:子類別必須提供實際欄位
    abstract readonly accountNumber: string;

    // 抽象 getter:計算餘額的方式由子類別決定
    abstract get balance(): number;

    // 具體方法:列印帳戶資訊
    printInfo(): void {
        console.log(`帳號:${this.accountNumber},餘額:${this.balance} 元`);
    }
}

class SavingsAccount extends Account {
    readonly accountNumber: string;
    private _balance: number;

    constructor(accountNumber: string, initialDeposit: number) {
        super();
        this.accountNumber = accountNumber;
        this._balance = initialDeposit;
    }

    get balance(): number {
        // 儲蓄帳戶的餘額直接回傳
        return this._balance;
    }

    deposit(amount: number): void {
        this._balance += amount;
    }
}

// 使用
const sav = new SavingsAccount('SA-001', 20000);
sav.deposit(5000);
sav.printInfo(); // 帳號:SA-001,餘額:25000 元

技巧:若子類別只需要「讀」取值,使用 abstract get 可以保證外部只能以屬性方式存取,避免意外修改。


範例 3:抽象類別結合介面(Interface)

有時候我們想把 行為規範(介面)與 共用實作(抽象類別)分開,讓不同的類別可以混搭。

// 介面:定義可列印的行為
interface Printable {
    print(): void;
}

// 抽象類別:提供共用的日誌功能
abstract class Document implements Printable {
    constructor(public title: string) {}

    // 抽象方法:必須自行決定列印內容
    abstract print(): void;

    // 具體方法:所有子類別共用
    log(): void {
        console.log(`[${new Date().toISOString()}] ${this.title} 被列印`);
    }
}

// PDF 文件
class PdfDocument extends Document {
    print(): void {
        console.log(`列印 PDF:${this.title}`);
        this.log(); // 呼叫共用的 log 方法
    }
}

// Word 文件
class WordDocument extends Document {
    print(): void {
        console.log(`列印 Word:${this.title}`);
        this.log();
    }
}

// 測試
const pdf = new PdfDocument('TypeScript 教學');
const word = new WordDocument('會議記錄');
pdf.print();   // 列印 PDF:TypeScript 教學
word.print();  // 列印 Word:會議記錄

重點Document 同時實作 Printable 介面,抽象類別負責提供 log 這類共用功能,而列印的細節交給子類別。


範例 4:抽象類別與泛型(Generics)

在資料結構或服務層中,我們常需要讓基底類別支援不同型別的資料。

// 抽象資料儲存庫
abstract class Repository<T> {
    protected items: T[] = [];

    // 抽象方法:取得單一項目
    abstract getById(id: number): T | undefined;

    // 具體方法:新增項目
    add(item: T): void {
        this.items.push(item);
    }

    // 具體方法:列出所有項目
    getAll(): T[] {
        return [...this.items];
    }
}

// 具體的使用者儲存庫
interface User {
    id: number;
    name: string;
}

class UserRepository extends Repository<User> {
    getById(id: number): User | undefined {
        return this.items.find(u => u.id === id);
    }
}

// 測試
const userRepo = new UserRepository();
userRepo.add({ id: 1, name: 'Amy' });
userRepo.add({ id: 2, name: 'Ben' });

console.log(userRepo.getById(2)); // { id: 2, name: 'Ben' }
console.log(userRepo.getAll());   // [{ id: 1, name: 'Amy' }, { id: 2, name: 'Ben' }]

實務意義:透過泛型,我們只需要寫一次抽象的 CRUD 基礎,子類別只負責實作特定的查詢邏輯,既降低重複碼,又保證型別安全。


範例 5:多層抽象 + 具體子類別的完整範例(遊戲角色系統)

// 基礎抽象類別:所有角色都有名字、生命值與攻擊方式
abstract class Character {
    constructor(public name: string, protected hp: number) {}

    // 抽象方法:每個角色的攻擊方式不同
    abstract attack(target: Character): void;

    // 具體方法:受到傷害
    receiveDamage(amount: number): void {
        this.hp = Math.max(this.hp - amount, 0);
        console.log(`${this.name} 受到 ${amount} 點傷害,剩餘 HP ${this.hp}`);
    }

    isAlive(): boolean {
        return this.hp > 0;
    }
}

// 抽象子類別:近戰角色
abstract class MeleeCharacter extends Character {
    // 抽象屬性:武器傷害
    abstract readonly weaponDamage: number;

    attack(target: Character): void {
        console.log(`${this.name} 使用近戰攻擊!`);
        target.receiveDamage(this.weaponDamage);
    }
}

// 具體角色:劍士
class Swordsman extends MeleeCharacter {
    readonly weaponDamage = 25;

    constructor(name: string) {
        super(name, 120);
    }
}

// 具體角色:斧豪
class Axeman extends MeleeCharacter {
    readonly weaponDamage = 35;

    constructor(name: string) {
        super(name, 150);
    }
}

// 測試對戰
const hero = new Swordsman('亞瑟');
const monster = new Axeman('哥布林');

hero.attack(monster);   // 亞瑟 使用近戰攻擊! 哥布林 受到 25 點傷害,剩餘 HP 125
monster.attack(hero);  // 哥布林 使用近戰攻擊! 亞瑟 受到 35 點傷害,剩餘 HP 85

說明Character 為最上層抽象類別,提供共用的生命值與受傷邏輯;MeleeCharacter 再次抽象化「近戰」共通屬性與行為;最終的具體角色只需要填入武器傷害即可。這種層層抽象的設計,讓未來加入遠程角色、魔法角色或新武器時,只需要新增相應的抽象或具體類別,既不破壞既有程式,又保持清晰的結構。


常見陷阱與最佳實踐

常見問題 可能原因 解決方式 / 最佳實踐
忘記實作抽象方法 子類別未覆寫 abstract 成員 編譯器會直接報錯,務必在 IDE 中開啟 嚴格模式"strict": true)以確保錯誤不被忽略。
抽象類別被直接 new 誤以為抽象類別只是普通類別 抽象類別在編譯後仍會產生 JavaScript 函式,執行時不會阻止 new在程式碼層面避免此行為;若想在執行時保護,可在抽象類別建構子內拋出錯誤:
if (new.target === AbstractClass) throw new Error('Cannot instantiate abstract class');
抽象屬性未給初始值 TypeScript 允許抽象屬性沒有實作,但子類別忘記賦值 在子類別的建構子中 必須 為抽象屬性賦值,否則會在執行時得到 undefined
抽象類別與介面混用時產生衝突 同名成員在介面和抽象類別中定義不同型別 盡量讓 介面只負責行為契約,抽象類別負責 實作。若必須混用,使用 交叉類型class A extends B implements C {})並確保型別一致。
過度抽象化 把太多細節抽成抽象類別,導致層級過深 遵循 YAGNI(You Aren't Gonna Need It) 原則,只有在確定有多個子類別共用行為時才抽象化;過度抽象會降低可讀性。

最佳實踐

  1. 只抽象必要的共用行為:抽象類別的目的在於避免重複程式碼,若只有一兩個子類別共享,考慮使用 Mixin介面
  2. 保持抽象類別的單一職責:每個抽象類別應聚焦在同一個概念(例如「資料儲存」或「圖形繪製」),避免混雜不相關的功能。
  3. 使用 protected 修飾子類別可見的成員:讓子類別能存取但外部無法直接修改。
  4. 在抽象類別建構子加入防護:如上所示的 new.target 檢查,可在執行時避免錯誤實例化。
  5. 文件化抽象契約:即使 TypeScript 本身提供型別檢查,仍建議在抽象類別的 JSDoc 中說明每個抽象成員的目的與使用方式,方便團隊成員快速上手。

實際應用場景

場景 為何適合使用抽象類別 範例簡述
企業級 API 客戶端 不同的 API 端點有相同的請求/回應流程,但實作細節(URL、參數)不同 建立 abstract class ApiClient,內含 abstract getEndpoint(): stringrequest<T>() 具體方法;子類別 UserApiClient, OrderApiClient 各自實作 getEndpoint
遊戲開發的角色/怪物系統 角色與怪物共用生命值、攻擊、防禦等屬性,但行為(攻擊方式、AI)各異 如前面的 範例 5,使用多層抽象類別分離「共通屬性」與「具體攻擊方式」。
前端 UI 元件庫 多種 UI 元件(按鈕、輸入框、下拉選單)都有渲染、事件綁定的共通流程 abstract class UIComponent 提供 render()bindEvents(),抽象 getTemplate() 讓每個子元件自行提供 HTML 模板。
資料庫存取層(Repository Pattern) 多個資料模型(User、Product、Order)需要相同的 CRUD 基礎,但查詢條件不同 abstract class Repository<T>(見範例 4)提供 add, getAll,抽象 getById 讓各模型自行實作。
跨平台的日誌系統 不同執行環境(Node、瀏覽器、React Native)需統一介面,但寫檔方式不同 abstract class Logger 定義 abstract log(message: string): void,子類別 ConsoleLogger, FileLogger, RemoteLogger 各自實作。

以上情境皆能透過 抽象類別 讓程式碼保持 高內聚、低耦合,同時在編譯期即捕捉錯誤,提升開發效率與維護性。


總結

抽象類別是 TypeScript 物件導向設計中不可或缺的工具,它提供了 「只能被繼承、不能直接實例化」 的概念,讓開發者能:

  • 明確劃分共通行為與具體實作,減少重複程式碼。
  • 在編譯期即檢查子類別是否完整實作,避免執行時錯誤。
  • 結合介面、泛型、存取子,打造彈性且型別安全的程式架構。

在實務開發中,適度使用抽象類別可以讓 API 客戶端、資料存取層、UI 元件、遊戲角色系統 等多種情境變得更具可讀性與可維護性。記得遵守 單一職責適度抽象 的原則,並善用 TypeScript 的嚴格型別設定,才能發揮抽象類別最大的價值。

關鍵一句話:抽象類別不是「限制」而是「指引」——它告訴團隊「這裡的行為必須由子類別自行決定」,同時提供共用的基礎實作,讓程式碼更乾淨、更安全。祝你在 TypeScript 的世界裡寫出更好的抽象!