第二週第四天:泛型與宣告合併

第二週第四天:泛型與宣告合併

學習資源


什麼是泛型(Generics)?

在第一週第四天有提到過用泛型表示法來定義陣列的型別,那究竟什麼是泛型呢?

泛型是一種程式設計概念,它讓你在定義函式、介面或類別時,不預先指定具體的型別,而是在使用的時候再指定具體型別的一種特性。這讓開發者能夠在不失去型別安全的情況下,撰寫更靈活、可重用的程式碼。泛型通常在靜態型別語言(如TypeScript、Java 和 C#)中使用。

以 TypeScript 為例,泛型的語法是在函式名稱、類別名稱或介面名稱後面使用尖括號(<T>),其中 T 通常表示型別變數(Type Variable)。然後,你可以在函式、類別或介面的實作中使用這個型別變數,而不是具體的型別。

泛型變數命名慣例上使用 TUV 等大寫字母,但實際上可以使用任意的變數名稱。

以下是 TypeScript 的泛型範例:

// 傳入跟回傳值會是同個型別,用型別變數 T 來代表
function identity<T>(arg: T): T {
    return arg;
}

// 使用函式時可以指定不同的型別
let outputString = identity<string>("myString");
let outputNumber = identity<number>(7);

在這個範例中,identity 函式是泛型函式,它接受一個型別變數 T。該函式的功能是回傳與其輸入相同的值。當我們使用這個函式時,可以指定具體的型別,如 string,這樣 TypeScript 編譯器就可以對傳入參數進行型別檢查。泛型使得這個函式可以為任何型別的參數提供相同的功能,同時保留型別安全。

泛型跟任意型別有什麼區別?

當我還沒完全理解什麼是泛型時,心裡不禁有這樣的疑問,一樣請 ChatGPT 解釋給我聽:

泛型(Generics)和任意型別(Any)在 TypeScript 中都可以用來表示一個可以容納不同型別的容器,但它們的使用場景和目的有很大的不同。

泛型是一種在程式設計時保持型別安全的方式,讓開發者可以為函式、類別或介面創建靈活且可重用的組件。泛型允許你在定義組件時保留一個或多個未指定的型別,然後在使用該組件時,由開發者明確地指定這些型別。在整個過程中,TypeScript 編譯器會追蹤這些型別,並確保它們在使用時是型別安全的。

任意型別(Any)則完全不同。當你使用任意型別時,你告訴 TypeScript 編譯器「我不在乎這個變數的型別,讓我自由地使用它,不要給我任何型別型檢查的限制。」換句話說,任意型別會讓 TypeScript 的型別系統失去效力,因為它允許你在不考慮型別安全的情況下進行操作。

總之,泛型和任意型別的主要區別在於:

  1. 泛型保持了型別安全,而任意型別則不考慮型別安全。

  2. 泛型讓你在定義組件時保留未指定的型別,然後在使用時明確指定這些型別;而任意型別完全不在乎變數的型別。

在實際開發中,應儘量避免使用任意型別,以保持型別安全和更好的程式碼可讀性。

多個型別引數

我們可以定義多個泛型型別,可以在尖括號中用逗號分隔它們,如下所示:

function swapKeyValue<T, U>(keyValuePair: [T, U]): [U, T] {
  return [keyValuePair[1], keyValuePair[0]];
}

const swappedPair = swapKeyValue<number, string>([1, "apple"]);

泛型約束

有時我們會需要限制泛型的可以接受的型別範圍,避免因為傳入某些值而發生非預期的錯誤。這時我們可以使用 extends 關鍵字來對泛型進行約束:

// 若沒有對泛型變數的型別進行約束
function logLength<T>(arg: T): T {
    console.log(arg.length); // 🔴 錯誤:類型 'T' 沒有屬性 'length'。
    return arg;
}
interface HasLength {
  length: number;
}

// 用 extends 對泛型進行約束,只允許傳入包含 length 屬性的變數
function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length);
  return arg;
}

呼叫函式時若是傳入的引數不包含 length 屬性,編譯時也會報錯:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length);
  return arg;
}

logLength(7); // 🔴 錯誤:類型 'number' 的引數不可指派給類型 'HasLength' 的參數。

泛型介面

泛型也可以用於介面,讓我們定義具有泛型型別的介面:

interface KeyValuePair<T, U> {
  key: T;
  value: U;
}

let pair: KeyValuePair<number, string> = { key: 1, value: "apple" };

泛型類別

泛型也可以用於類別,讓我們創建靈活且可重用的類別:

class Queue<T> {
  private data: T[] = [];

  push(item: T) {
    this.data.push(item);
  }

  pop(): T | undefined {
    return this.data.shift();
  }
}

const queue = new Queue<number>();
queue.push(1);
queue.push(2);
console.log(queue.pop()); // 1

泛型引數的預設型別

在 TypeScript 2.3 後,我們可以為泛型中的型別引數指定預設型別。如果在使用泛型時沒有指定型別,將會使用預設型別。用 = 來指定預設型別:

// 將泛型引數的預設型別指定為 string
function createArray<T = string>(length: number, value: T): T[] {
  return Array(length).fill(value);
}

// 沒有像這樣 createArray<string>(3, "default") 指定泛型引數的型別
const defaultArray = createArray(3, "default"); // ['default', 'default', 'default']
const numberArray = createArray<number>(3, 0); // [0, 0, 0]

什麼是宣告合併(Declaration Merging)?

宣告合併是 TypeScript 的一個特性,它允許在多個地方定義相同名稱的宣告,然後 TypeScript 會將這些宣告合併成一個單一的宣告。這對於擴展現有的宣告或擴展庫非常有用。宣告合併可以應用於函式、介面或類別。

函式合併

函式不能直接進行宣告合併。但是,可以使用函式多載來達到類似的效果:

function add(a: number, b: number): number;
function add(a: string, b: string): string;

function add(a: any, b: any): any {
  if (typeof a === 'number' && typeof b === 'number') {
    return a + b;
  } else if (typeof a === 'string' && typeof b === 'string') {
    return a.concat(b);
  }
}

console.log(add(1, 2)); // 3
console.log(add('Hello', ' world!')); // "Hello world!"

介面合併

當多個介面具有相同的名稱時,它們將被合併成一個介面。以下是一個介面合併的範例:

interface Box {
  height: number;
  width: number;
}

interface Box {
  depth: number;
}

const myBox: Box = {
  height: 5,
  width: 6,
  depth: 7
};

有相同名稱的屬性時將會合併,兩者的型別需一致:

interface Info {
    name: string;
    age: number;
}

interface Info {
    email: string;
    age: string; // 這裡會報錯,因為先前的宣告中 age 是 number
}

類別合併

類別不能直接進行宣告合併,但是可以使用混入(Mixin)來達到類似的效果。以下是一個混入範例:

// 定義一個混入函式,將來源類別的屬性和方法複製到目標類別
function applyMixin(targetClass: any, sourceClass: any): void {
  Object.getOwnPropertyNames(sourceClass.prototype).forEach(name => {
    targetClass.prototype[name] = sourceClass.prototype[name];
  });
}

class Flyable {
  fly(): void {
    console.log('I can fly!');
  }
}

class Swimmable {
  swim(): void {
    console.log('I can swim!');
  }
}

class Animal {}

// 將 Flyable 和 Swimmable 的屬性和方法添加到 Animal 類別
applyMixin(Animal, Flyable);
applyMixin(Animal, Swimmable);

const animal = new Animal();
(animal as any).fly(); // "I can fly!"
(animal as any).swim(); // "I can swim!"

什麼是混入(Mixin)?

混入是一種在多個類別之間共享行為的方法。透過混入,我們可以將多個類別的功能組合在一個類別中,而不需要使用繼承。混入通常用於將一些可重複使用的功能抽取出來,然後將這些功能注入到其他類別中。

// 定義一個可被混入的類別
class Runnable {
    running = false;

    run() {
        this.running = true;
        console.log("Running...");
    }

    stop() {
        this.running = false;
        console.log("Stopped.");
    }
}

// 定義一個應用混入的類別
class Animal {
    name: string;

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

// 這是一個將混入類別合併到目標類別的函數
function applyMixin(targetClass: any, mixinClass: any) {
    Object.getOwnPropertyNames(mixinClass.prototype).forEach(name => {
        if (name !== "constructor") {
            targetClass.prototype[name] = mixinClass.prototype[name];
        }
    });
}

// 應用混入
applyMixin(Animal, Runnable);

// 測試
const dog = new Animal("Buddy");
(dog as any).run(); // Running...
(dog as any).stop(); // Stopped.

在這個例子中,我們定義了一個 Runnable 混入類別,它包含了 runstop 方法。然後,我們使用 applyMixin 函數將 Runnable 的方法注入到 Animal 類別中。這樣,Animal 類別的實例就可以使用 runstop 方法了。