Swift學習(10)- 列舉(程式碼完善版)

Swift學習(10)- 列舉(程式碼完善版)

August 25, 2021·Jingyao Zhang
Jingyao Zhang

image

列舉(Enum)為一組相關的值定義了一個共同的型別,使你能在程式碼中以型別安全的方式使用這些值。

如果你熟悉C語言,會知道在C語言中,列舉會為一組整數值分配相關聯的名稱。而Swift中的列舉更加靈活,不必為每個列舉成員提供一個值。如果給列舉成員提供一個值(稱為原始值),則該值的型別可以是字串、字元、整數或浮點數。

此外,列舉成員可以指定任意型別的關聯值(Associated Value)儲存到列舉成員中,就像其他語言中的聯集(unions)或變體(variants)。你可以在一個列舉中定義一組相關聯的列舉成員,每個成員都可以有適當型別的關聯值。

Swift中,列舉型別是一等公民(First-Class Type)。它們擁有許多傳統上只有類別(class)才有的特性,例如計算屬性(Computed Properties),用來提供列舉值的額外資訊;實例方法(Instance Methods),用來提供與列舉值相關的功能。列舉也可以定義初始化器(Initializers)來提供初始值;可以在原始實作的基礎上擴充功能;還可以遵循協定(Protocols)來提供標準功能。


列舉語法

使用enum關鍵字來建立列舉,並將其整個定義放在一對大括號內:

enum SomeEnumeration {
        // 列舉定義寫在這裡
}
// 以下是用列舉表示指南針四個方向的範例:
enum CompassPoint {
        case north
        case south
        case east
        case west
}

列舉中定義的值(如north、south、east和west)是這個列舉的成員值(或稱成員)。可以使用case關鍵字來定義新的列舉成員值。

注意

與C和Objective-C不同,Swift的列舉成員在被建立時不會自動賦予預設的整數值。在上面的CompassPoint範例中,north、south、east和west不會被隱式賦值為0、1、2和3。相反,這些列舉成員本身就是完整的值,其型別已明確定義為CompassPoint。 多個成員值可以寫在同一行,用逗號分隔:

enum Planet {
        case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
}

每個列舉都定義了一個全新的型別。像Swift中的其他型別一樣,它們的名稱(例如CompassPoint和Planet)以大寫字母開頭。建議給列舉型別取單數名稱而非複數名稱,這樣更容易閱讀:

var directionToHead = CompassPoint.west

directionToHead的型別也可以在它被CompassPoint的某個值初始化時自動推斷出來。一旦directionToHead被宣告為CompassPoint型別,可以使用更簡短的點語法將其設為另一個CompassPoint的值:

directionToHead = .east

directionToHead的型別已知時,再次賦值時可以省略列舉型別名稱。這種寫法讓程式碼更具可讀性。

print("directionToHead: \(directionToHead).")
---
output: directionToHead: east.

使用Switch語句比對列舉值

你可以使用switch語句來比對單一列舉值:

directionToHead = .south
switch directionToHead {
case .north:
        print("很多行星都有北方。")
case .south:
        print("小心企鵝。")
case .east:
        print("太陽從這裡升起。")
case .west:
        print("天空最藍的地方。")
}  // 輸出「小心企鵝。」
---
output: Watch out for penguins.

你可以這樣理解這段程式碼:

「判斷directionToHead的值。當它等於.north,印出‘很多行星都有北方。’;當它等於.south,印出‘小心企鵝。’」……以此類推。

如同在控制流程中介紹的,當你比對一個列舉型別的值時,switch語句必須涵蓋所有情況。如果忽略了.west這種情況,上述程式碼將無法通過編譯,因為沒有考慮到CompassPoint的所有成員。強制涵蓋所有情況可確保列舉成員不會被意外遺漏。

當你不需要比對每個列舉成員時,可以提供一個default分支來涵蓋所有未明確處理的成員:

let somePlanet = Planet.earth
switch somePlanet {
case .earth:
        print("大致上無害。")
default:
        print("對人類來說不是安全的地方。")
}  // 輸出「大致上無害。」
---
output: Mostly harmless.

列舉成員的遍歷

有時你可能需要取得一個包含所有列舉成員的集合。可以透過如下方式實現:

讓列舉遵循CaseIterable協定。Swift會自動生成一個allCases屬性,表示包含所有列舉成員的集合。以下是一個範例:

enum Beverage: CaseIterable {
        case coffee, tea, juice
}
let numberOfChoices = Beverage.allCases.count
print("\(numberOfChoices) 種飲料可選。")
---
output: 3 beverages available.

在前面的範例中,透過Beverage.allCases可以取得包含所有Beverage列舉成員的集合。allCases的用法和其他集合一樣:集合中的元素是列舉型別的實例,所以在這個例子中,元素是Beverage的值。上述範例統計了總共有多少個列舉成員。以下範例則用for-in迴圈遍歷所有成員:

for beverage in Beverage.allCases {
        print(beverage)
}  // coffee  // tea  // juice
---
output: coffee
tea
juice

上述範例的語法表示這個列舉遵循了CaseIterable協定。


關聯值

在列舉語法那一節的範例中,展示了如何定義和分類列舉成員。你可以為Planet.earth設定一個常數或變數,並在賦值後檢查這個值。然而,有時候將其他型別的值與成員值一起儲存會很有用。這些額外資訊稱為關聯值,而且每次在程式中使用該列舉成員時,都可以修改這個關聯值。

你可以定義Swift列舉來儲存任意型別的關聯值,如果需要的話,每個列舉成員的關聯值型別可以不同。這種特性類似於其他語言中的可識別聯集(Discriminated Unions)、標籤聯集(Tagged Unions)或變體(Variants)。

例如,假設一個庫存追蹤系統需要利用兩種不同型別的條碼來追蹤商品。有些商品標有使用0到9數字的UPC格式一維條碼。每個條碼都有一個代表數字系統的數字,接著是五位代表廠商代碼的數字,再來是五位代表「產品代碼」的數字,最後一位是檢查碼,用來驗證條碼是否正確掃描:

Bar Code

其他商品則標有QR碼格式的二維條碼,可以使用任何ISO 8859-1字元,並可編碼最多2953個字元的字串:

QR Code

這讓庫存追蹤系統可以用包含四個整數的元組儲存UPC碼,以及用任意長度的字串儲存QR碼:

Swift中,可以用以下方式定義表示兩種商品條碼的列舉:

enum BarCode {
        case upc(Int, Int, Int, Int)
        case qrCode(String)
}

上述程式碼可以這樣理解:

「定義一個名為BarCode的列舉型別,其一個成員值是具有(Int, Int, Int, Int)型別關聯值的upc,另一個成員值是具有String型別關聯值的qrCode。」

這個定義並未提供任何IntString型別的關聯值,它只是定義了,當BarCode常數或變數等於BarCode.upcBarCode.qrCode時,可以儲存的關聯值型別。

然後可以用任一種條碼型別建立新的條碼。例如:

var productBarCode = BarCode.upc(8, 85909, 51226, 3)

上述範例建立了一個名為productBarCode的變數,並將BarCode.upc賦值給它,關聯的元組值為(8, 85909, 51226, 3)。

同一個商品也可以被指定為不同型別的條碼,例如:

productBarCode = .qrCode("ABCDEFGHIJKLMNOP")

此時,原本的BarCode.upc及其整數關聯值會被新的BarCode.qrCode及其字串關聯值取代。BarCode型別的常數與變數可以儲存.upc.qrCode(連同其關聯值),但同一時間只能儲存其中一個。

你可以使用switch語句來檢查不同的條碼型別,和前面用switch語句比對列舉值的範例一樣。不過這次,關聯值可以在switch語句中被提取出來作為常數(用let前綴)或變數(用var前綴)來使用:

switch productBarCode {
case .upc(let numberSystem, let manuFacturer, let product, let check):
        print("UPC: \(numberSystem), \(manuFacturer), \(product), \(check).")
case .qrCode(let productCode):
        print("QR code: \(productCode).")
}  // 輸出「QR code: ABCDEFGHIJKLMNOP」
---
output: QR code: ABCDEFGHIJKLMNOP.

如果一個列舉成員的所有關聯值都被提取為常數,或都被提取為變數,為了簡潔,可以只在成員名稱前標註一個letvar

productBarCode = .upc(8, 86751, 62119, 3)
switch productBarCode {
case let .upc(numberSystem, manuFactor, product, check):
        print("UPC: \(numberSystem), \(manuFactor), \(product), \(check).")
case let .qrCode(productCode):
        print("QR code: \(productCode)")
}  // 輸出「UPC: 8, 86751, 62119, 3。」
---
output: UPC: 8, 86751, 62119, 3.

原始值

在關聯值那一節的條碼範例中,展示了如何宣告儲存不同型別關聯值的列舉成員。作為關聯值的替代方案,列舉成員也可以被預設值(稱為原始值)填充,這些原始值的型別必須相同

以下是一個使用ASCII碼作為原始值的列舉:

enum ASCIIControlCharacter: Character {
        case tab = "\t"
        case lineFeed = "\n"
        case carriageReturn = "\r"
}

列舉型別ASCIIControlCharacter的原始值型別被定義為Character,並設定了一些常見的ASCII控制字元。Character的說明請參見字串與字元章節。

原始值可以是字串、字元、任意整數或浮點數。每個原始值在列舉宣告中必須是唯一的。

注意

原始值與關聯值不同。原始值是在定義列舉時預先填入的值,像上述三個ASCII碼。對於特定的列舉成員,其原始值始終不變。關聯值則是在建立基於列舉成員的常數或變數時才設定的值,列舉成員的關聯值可以變動。

原始值的隱式賦值

當你使用整數或字串作為列舉的原始值時,不必明確為每個成員設定原始值,Swift會自動賦值。

例如,當使用整數作為原始值時,隱式賦值會自動遞增1。如果第一個成員沒有原始值,則其原始值預設為0。

以下的列舉是對先前Planet列舉的細化,利用整數原始值表示每個行星在太陽系中的順序:

enum Planets: Int {
        case mercury = 1, venus, earth
        case mars, jupiter, saturn, uranus, neptune
}
extension Planets: CaseIterable {}
for planet in Planets.allCases {
        print("\(planet)的原始值是\(planet.rawValue)。")
}
---
output: mercury's raw value is 1.
venus's raw value is 2.
earth's raw value is 3.
mars's raw value is 4.
jupiter's raw value is 5.
saturn's raw value is 6.
uranus's raw value is 7.
neptune's raw value is 8.

在上述範例中,Planet.mercury的原始值明確設定為1,Planet.venus的原始值則隱式為2,依此類推。

當你使用字串作為列舉型別的原始值時,每個成員的隱式原始值就是該成員的名稱。

以下範例是CompassPoint列舉的細化,使用字串原始值表示各方向名稱:

enum CompassPoints: String {
        case north, south, east, west
}

上述範例中,CompassPoints.south的隱式原始值為south,依此類推。

你可以透過列舉成員的rawValue屬性來存取其原始值:

extension CompassPoints: CaseIterable {}
for point in CompassPoints.allCases {
        print("這個方向的原始值是\(point.rawValue)。")
}
---
output: The point's raw value is north.
The point's raw value is south.
The point's raw value is east.
The point's raw value is west.

使用原始值初始化列舉實例

如果你在定義列舉型別時使用了原始值,將自動獲得一個初始化方法,這個方法接受一個名為rawValue的參數,參數型別即為原始值型別,回傳值則是列舉成員或nil。你可以用這個初始化方法建立新的列舉實例。

以下範例利用原始值7建立列舉成員Uranus

let possiblePlanet = Planets(rawValue: 7)  // possiblePlanet型別為Planets?值為Planets.uranus
print("\(possiblePlanet!)的原始值是\(possiblePlanet!.rawValue)。")
---
output: uranus's raw value is 7.

然而,並非所有Int值都能找到對應的行星。因此,原始值初始化器總是回傳一個可選的列舉成員。在上述範例中,possiblePlanetPlanets?型別,也就是「可選的Planets」。

注意

原始值初始化器是一個可失敗的初始化器,因為不是每個原始值都有對應的列舉成員。

如果你試圖尋找第11個位置的行星,透過原始值初始化器回傳的Planets值會是nil

let positionToFind = 11
if let somePlanet = Planets(rawValue: positionToFind) {
        switch somePlanet {
        case .earth:
                print("大致上無害。")
        default:
                print("對人類來說不是安全的地方。")
        }
} else {
        print("第\(positionToFind)個位置沒有行星。")
}  // 輸出「第11個位置沒有行星。」
---
output: There isn't a planet at position 11.

這個範例使用了可選綁定(Optional Binding),試圖透過原始值11來取得行星。if let somePlanet = Planets(rawValue: 11)語句建立了一個可選的Planets,如果有值就賦給somePlanet。在這個例子中,找不到第11個位置的行星,所以執行else分支。


遞迴列舉

遞迴列舉是一種列舉型別,其中一個或多個成員使用該列舉型別的實例作為關聯值。使用遞迴列舉時,編譯器會插入一層間接引用。你可以在列舉成員前加上indirect來表示該成員可遞迴。

例如,以下範例中,列舉型別用來儲存簡單的算術運算式:

enum ArithmeticExpression {
        case number(Int)
        indirect case addition(ArithmeticExpression, ArithmeticExpression)
        indirect case multiplication(ArithmeticExpression, ArithmeticExpression)
}

你也可以在列舉型別前加上indirect關鍵字,表示所有成員都可遞迴:

indirect enum ArithmeticExpressions {
        case number(Int)
        case addition(ArithmeticExpressions, ArithmeticExpressions)
        case multiplication(ArithmeticExpressions, ArithmeticExpressions)
}

上述定義的列舉型別可以儲存三種算術運算式:純數字、兩個運算式相加、兩個運算式相乘。列舉成員additionmultiplication的關聯值也是算術運算式,這些關聯值讓巢狀運算式成為可能。例如,運算式(5 + 4) * 2,乘號右邊是一個數字,左邊則是一個運算式。因為資料是巢狀的,所以用來儲存資料的列舉型別也必須支援這種巢狀,這表示列舉型別必須支援遞迴。以下程式碼展示如何用ArithmeticExpression這個遞迴列舉建立運算式(5 + 4) * 2:

let five = ArithmeticExpressions.number(5)
let four = ArithmeticExpressions.number(4)
let sum = ArithmeticExpressions.addition(five, four)
let product = ArithmeticExpressions.multiplication(sum, ArithmeticExpressions.number(2))

要操作具有遞迴特性的資料結構,使用遞迴函式是一種直接的方式。例如,以下是一個對算術運算式求值的函式:

func evaluate(_ expression: ArithmeticExpressions) -> Int {
        switch expression {
        case let .number(value):
                return value
        case let .addition(left, right):
                return evaluate(left) + evaluate(right)
        case let .multiplication(left, right):
                return evaluate(left) * evaluate(right)
        }
}
print(evaluate(product))  // 輸出「18」
---
output: 18
最後更新於