第二週第二天:元組與列舉

第二週第二天:元組與列舉

學習資源


什麼是元組(Tuple)?

元組在 TypeScript 是一種複合型別,它是另一種陣列(Array)型別,與陣列不同的是它允許我們將多個不同型別的值儲存在一個有序的集合中,而陣列是將相同型別的值儲存在一個有序的集合。

元組的語法也類似於陣列,但在定義元組型別時,我們需要為每一個元素指定其型別。元組的元素可以是任意型別,並且每個元素的型別可以不同。

雖然我能理解元組是什麼,不過很好奇 ChatGPT 會如何解釋給十歲小孩聽,我覺得它的比喻蠻有趣的,貼上來記錄一下。
「元組(Tuple)就像一個特殊的小盒子,裡面有固定數量的東西,而且每個東西都有特定的類型。你可以把元組想像成一個裝有不同種類糖果的小盒子,每種糖果都有固定的位置。」

我們一樣用例子來學習元組的語法跟特性:

// 定義一個元組型別,包含三個元素:一個字串、一個數字和一個布林值
type MyTuple = [string, number, boolean];

// 建立一個符合 MyTuple 型別的元組
const myTuple: MyTuple = ['hello', 42, true];

// 🔴 錯誤初始化元組:需要提供所有元組型別指定的元素數量與型別
const errorInitialTuple: MyTuple = ['hello', 42,];

let errorAssignTuple: MyTuple;
// 🔴 錯誤賦值元組:需要提供所有元組型別指定的元素數量與型別
errorAssignTuple = ['hello'];

// 🟢 正確使用元組
const str: string = myTuple[0];
const num: number = myTuple[1];
const bool: boolean = myTuple[2];

// 🔴 錯誤使用元組:存取不存在的元素(越界)
const error: undefined = myTuple[3]; // 編譯時報錯,因為元組只有三個元素

// 🔴 錯誤使用元組:存取元素時型別不匹配
const anotherError: number = myTuple[0]; // 編譯時報錯,因為 myTuple[0] 的型別是 string,不是 number

什麼是「越界(Out of bounds)」?

就是小學的時候你手肘超過跟隔壁同學桌子中間的縫隙 當我們存取或新增的元素位置在陣列或元組的長度之外時,即被視為「越界」。

在 TypeScript 的元組中,如果我們新增了超出元組宣告長度的元素,則該索引的型別會是元組中所有型別的聯合型別。以下是一個例子:

// 定義一個元組型別,長度為 2
type MyTuple = [string, number];

// 初始化一個符合 MyTuple 型別的元組
const myTuple: MyTuple = ['Hello', 42];

// 新增聯合型別 `string | number` 內的越界元素
myTuple.push('Hi');
myTuple.push(30);

// 新增聯合型別 `string | number` 以外的越界元素
myTuple.push(true);
// 錯誤:類型 'boolean' 的引數不可指派給類型 'string | number' 的參數。

另外書中這個例子是錯誤的,因為在使用 tom[0] 前,還沒有給 tom 任何值(初始化)。所以 tom 仍然是 undefined,所以試圖賦值給 tom[0] 會導致錯誤。

let tom: [string, number];
tom[0] = 'Tom'; // 🔴 Cannot set properties of undefined (setting '0')
tom[1] = 25;

tom[0].slice(1);
tom[1].toFixed(2);

有以下兩種調整方式:

// 1. 一次賦值整個 tom
let tom: [string, number];
tom = ['Tom', 12]; // 👈

// 2. 在使用 tom 之前初始化它
let tom: [string, number] = ['', 0]; // 👈
tom[0] = 'Tom';

什麼是列舉(Enum)?

列舉是一個特殊的型別,用於表示一組有名字的常數值。列舉的主要目的是讓我們能夠更方便、更清晰地表示某些特定的值,並提高程式碼的可讀性和可維護性。

在 TypeScript 中,我們可以使用 enum 關鍵字來定義一個列舉。以下是一個簡單的例子:

enum Direction {
    Up,
    Down,
    Left,
    Right
}

在這個例子中,我們定義了一個名為 Direction 的列舉,它包含四個列舉項(enumeration members):UpDownLeftRight。現在,我們可以在程式碼中使用這些列舉項,而不是直接寫固定的數字或字串。

// 使用列舉值
let currentDirection: Direction = Direction.Up;

// 判斷當前方向
if (currentDirection === Direction.Up) {
    console.log('We are going up!');
}

預設情況下,列舉的第一個值從 0 開始,然後逐個遞增,同時也會對列舉值與列舉名進行反向映設(Reverse Mapping):

console.log(Direction.Up); // 0
console.log(Direction.Down); // 1
console.log(Direction.Left); // 2
console.log(Direction.Right); // 3

console.log(Direction[0]); // Up
console.log(Direction[1]); // Down
console.log(Direction[2]); // Left
console.log(Direction[3]); // Right

當 TypeScript 的列舉被編譯成 JavaScript 時,它會產生一個物件,物件的屬性名是列舉項的名字,屬性值是對應的數值。以下是列舉 Direction 編譯成 JavaScript 的結果:

// 原本長這樣
// enum Direction {
//     Up,
//     Down,
//     Left,
//     Right
// }

var Direction;
(function (Direction) {
    Direction[Direction["Up"] = 0] = "Up";
    Direction[Direction["Down"] = 1] = "Down";
    Direction[Direction["Left"] = 2] = "Left";
    Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));

手動指定列舉值

我們也可以手動為列舉值指定具體的數字:

enum Direction {
    Up = 3,
    Down = 2,
    Left,
    Right
}

console.log(Direction.Up); // 3
console.log(Direction.Down); // 2
console.log(Direction.Left); // 3 👈 沒有指定,會接著上一個列舉項遞增
console.log(Direction.Right); // 4 👈 沒有指定,會接著上一個列舉項遞增

如果手動為列舉值指定具體的數字與未指定的值重複了,是不會造成編譯錯誤的,但在使用列舉時可能產生非預期內的結果。像上方的例子,列舉項 UpLeft 都被賦予了相同的數值 3 ,在使用 Direction 時,反向映設 Direction[3] 的值原先是 Up 但之後被 Left 覆蓋:

console.log(Direction[3]); // Left

所以在手動為列舉值賦值時,要盡量避免這種互相覆蓋的情況。

手動指定列舉值也可以是字串:

enum Color {
    Red = 'RED',
    Green = 'GREEN',
    Blue = 'BLUE'
}

列舉值使用字串相對於用數字的好處是:

  1. 可讀性更好、更容易除錯:字串列舉項有明確的含意,我們可以用有意義的單詞表示,而不僅僅是數字。像上面 Color 的例子,比起數字 0 我們更容易一眼理解字串 RED 代表的含意。

  2. 防止意外賦值:字串列舉不允許我們使用列舉以外的字串或數字作為值。相反,數字列舉允許任何數字作為值,即使它沒有被定義在列舉中。讓我們先看一個字串列舉的例子:

     enum Color {
       Red = "RED",
       Green = "GREEN",
       Blue = "BLUE",
     }
    
     // 正確的使用方式
     let myColor: Color = Color.Red;
    
     // 錯誤的使用方式,TypeScript 會報錯
     let wrongColor: Color = "INVALID_COLOR"; // Error: Type '"INVALID_COLOR"' is not assignable to type 'Color'.
    

    現在讓我們看一個數字列舉的例子:

     enum Direction {
       Up = 1,
       Down = 2,
       Left = 3,
       Right = 4,
     }
    
     // 正確的使用方式
     let myDirection: Direction = Direction.Up;
    
     // 錯誤的使用方式,但 TypeScript 不會報錯
     let wrongDirection: Direction = 999; // No error
    

常數項和計算所得項

在 TypeScript 的列舉中,列舉項可以分為兩類:常數項(constant members)和計算所得項(computed members)。我們先了解一下這兩種列舉項的差別,然後透過一些範例來理解它們。

常數項(constant members)

這些列舉項的值在編譯時就已經確定,它們可以是數字或字串,但是它們的值不會在運行時改變。

enum Days {
  Sunday = 0,
  Monday = 1,
  Tuesday = 2,
  Wednesday = 3,
  Thursday = 4,
  Friday = 5,
  Saturday = 6,
}

在上面的例子中,Days 的所有列舉項都是常數項,因為它們在編譯時就已經被賦值了。

計算所得項(computed members)

這些列舉項的值是通過運行時的計算得到的。它們的值可能會根據條件或其他運行時因素而改變。

enum FileAccess {
  None,
  Read = 1 << 1,
  Write = 1 << 2,
  ReadWrite = Read | Write,
  G = "123".length,
}

在上面的例子中,FileAccess 列舉中的 ReadWriteReadWrite 列舉項是常數項,因為它們的值是通過使用常數列舉表達式作為運算元的位操作符計算得到的。而 G 才是一個計算所得項,因為它的值是由字串的長度計算得到的。

根據官方文件的定義,如果列舉項滿足以下條件,會被當做是常數項:

  • 字面量列舉表達式(基本上是字串字面量或數字字面量)。

  • 對先前定義的常數列舉項的引用(可以來自不同的列舉)。

  • 帶括號的常數列舉表達式。

  • 應用於常數列舉表達式的一元運算符 +-~

  • 常數列舉表達式作為運算元的二元運算符 +-*/%<<>>>>>&|^。若常數列舉表達式求值後為 NaN 或 Infinity,會在編譯時期產生錯誤。

在所有其他情況下,列舉項被認為是計算所得項。

常數列舉(Const Enums)

常數列舉是一種特殊的列舉,它們在編譯過程中會被內聯(也就是被移除),進而提高執行效能。常數列舉使用 const enum 關鍵字來定義。由於它們在編譯後被內聯,所以它們的列舉項不能在運行時被變更,也就表示不能包含計算所得項。

例如:

const enum Directions {
    Up,
    Down,
    Left,
    Right
}

let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];

在編譯後的 JavaScript 代碼中,Directions 列舉將不會出現,而是將對應的數值直接內聯在程式碼中,例如:

let directions = [0 /* Up */, 1 /* Down */, 2 /* Left */, 3 /* Right */];

假如常數列舉內包含計算所得項,則會在編譯階段報錯:

const enum Directions {
    Up,
    Down,
    Left,
    Right = 'Right'.length // 錯誤:const enum member initializers must be constant expressions.
}

外部列舉(Ambient Enums)

外部列舉主要用來描述已經存在的列舉型別的形狀。外部列舉使用 declare enum 關鍵字來定義。外部列舉與一般列舉的區別在於,它們描述是用來已經在其他地方定義的列舉,不會創建新的列舉。

例如,假設我們有一個 JavaScript 函式庫,其中包含一個名為 Colors 的列舉:

const Colors = {
  Red: 1,
  Green: 2,
  Blue: 3,
};

要在 TypeScript 中使用這個已經存在的 Colors 列舉,我們可以定義一個外部列舉:

// 需要正確地引用實際定義 Colors 列舉的 JavaScript 檔案,才不會出現 "Colors is not defined" 的錯誤
declare enum Colors {
  Red = 1,
  Green = 2,
  Blue = 3,
}

let color: Colors = Colors.Red;

TypeScript 編譯器會假定 Colors 列舉在其他地方已經定義,所以列舉不會生成任何 JavaScript 程式碼:

var color = Colors.Red;

要注意的是,外部列舉的使用場景相對較少,因為理論上 TypeScript 的型別宣告檔案(d.ts 檔案)已經可以很好地描述現有的 JavaScript 函式庫。在大多數情況下,我們會直接使用宣告檔案來描述現有的 JavaScript 程式碼。


在讀「常數項和計算所得項」時,發現 ChatGPT 的範例說明跟官方文件的描述有出入,貼了官方說明跟它確認後,竟然被鄭重地道歉,有點被嚇到,想說沒有那麼嚴重啦~(不好意思揮手)。但想想這就是我喜歡跟它對話的原因吧,有錯就直接承認,不拐彎抹角,這點自己也需要向它學習呢!