11. 列舉

列舉是一種很特殊的資料型態,目的是讓具有關連性的數值群組在一起,如此寫出來的程式碼具有高可讀性,並且在對變數或常數指定值的時候不容易出錯。

11.1 語法

列舉語法如下,保留字case用來定義不同的狀態:

enum EnumName {
    case case1
    case case2
}

舉個例子。如果我們需要使用一個變數來儲存燈泡目前是亮還是暗,我們可以定義這個變數內容為1代表亮0代表暗,所以這個變數的資料型態應為Int,或者更省一點記憶體用UInt8。但不論是Uint8還是Int,能夠儲存的數字範圍都遠超過0跟1,換句話說,如果不小心輸入6,這時候就不知道燈泡的狀態了。這時候會很自然的想到,布林型態應該比整數型態更合適,因為布林型態只有true與false兩種,這時定義true為亮false為暗在描述上比使用整數要好很多。

但燈泡不一定只有兩種狀態,我們還希望知道燈泡是不是壞了,一個壞的燈泡不論有沒有通電,都是不亮的。這時候問題就來了,我們找不到一個資料型態是剛剛好只能能描述三種狀態的。列舉型態就是為了解決這個問題而發明的一種資料型態,他可以讓我們自己定義可以描述的狀態數量,不會多也不會少。例如描述燈泡亮、暗與壞掉這三種狀態的列舉型態可以設計成如下:

enum BulbStatus {
    case on
    case off
    case broken
}

列舉型態用enum保留字,後面接一個型態名稱(第一個字大寫),內容就是使用case語句將所有可能的值一一列出來。使用時將變數或常數的資料型態指定為列舉型態即可,接下來這個變數能夠儲存的值就只能在列舉型態中的case之間挑一個,如下:

var bulb: BulbStatus = .off
bulb = .on

上面程式碼中的第一行表示變數bulb的初始值為「BulbStatus.off」,但因為bulb的資料型態為BulbStatus,因此在給值的時候型態名稱可以省略,只要填「.off」即可。第二行我們把電燈打開,所以填入「.on」代表目前電燈是亮的。等到哪一天燈泡壞了,我們就填入「.broken」就可以精準的描述電泡壞了。列舉型態的值是可以比較的,我們可以使用if語句來判斷變數中的值是屬於哪一個case。

if bulb == .broken {
    print("燈泡壞了要買新的")
}

再舉個例子,假設我們需要一個專門用來描述星期幾的資料型態,由於只有星期日到星期六這七種狀態,因此非常適合使用列舉型態來設計。在Week這個列舉型態中,特別使用中文來作為選項,不用擔心會不會出現亂碼而造成錯誤,中文不會有任何編碼上的問題,甚至用表情符號也可以。

enum Week {
    case 星期日
    case 星期一
    case 星期二
    case 星期三
    case 星期四
    case 星期五
    case 星期六
}

var today = Week.星期六

列舉型態中各個case項目可以合併成一行,項目與項目間用逗號隔開就可以了,例如:

enum Week {
    case 星期六, 星期日
    case 星期一, 星期二, 星期三, 星期四, 星期五
}

在確定變數或常數的內容屬於列舉型態中的哪一個項目時,switch語句通常會是列舉型態的好朋友。雖然透過if語句也可以判斷出來,但如果要判斷的項目比較多或比較複雜的話,switch語句會比if語句來的更合適,例如:

var bulb: BulbStatus = .off
switch bulb {
case .on:
    print("電燈亮了")
case .off:
    print("電燈關了")
case .broken
    print("電燈壞了")
}

Swift的語法檢查器會幫我們檢查switch中的case是不是涵蓋列舉型態中所有的case,如果只有部分的話,會要求在switch中加上default區段,用來確保所有狀態都會處理到,如果用if語句來判斷列舉型態的話,就沒有這樣的檢查了。

像下面這樣的狀況,我們只要要判斷是不是週末,所以只要一個case就可以處理,其他週間就放在default裡面。

var today: Week = .星期一

switch today {
case .星期六, .星期日:
    print("放假日")
default:
    print("要工作")
}

11.2 自訂Raw Value

列舉型態中的每一個case項目,編譯器都會將這些項目實際對映到一個雜湊值,之後程式在運行時就是靠這個雜湊值來確認變數或常數中儲存的是哪一個項目。我們可以使用hashValue將這個值印出來看看。

print(Week.星期一.hashValue)
// Prints "3055310525691266515"

有的時候為了方便,我們需要改變這個值。列舉型態可以讓我們自訂每一個case項目所對映的值,這個值稱為case的原始值(raw value)。原始值可以是整數、小數、字串或字元。如果我們要自訂原始值,必須先設定該列舉型態的原始值資料型態為何,例如要將星期日的原始值設定為字串”sun”,這時就要在enum名稱後方加上String這個資料型態,然後在每一個case項目後方用等號來設定原始值為何,如下:

enum Week: String {
    case 星期日 = "sun"
    case 星期一 = "mon"
    case 星期二 = "tue"
    case 星期三 = "wed"
    case 星期四 = "thu"
    case 星期五 = "fri"
    case 星期六 = "sat"
}

let today = Week.星期一
print(today.rawValue)
// Prints "mon"

從Raw Value得到case名稱

如果要設定的原始值是整數,並且每個case的原始值都相差1,我們只要設定第一個case就好,其他的編譯器會自動幫我們填進去,例如只設定星期日為0,星期一、星期二就會自動的以1, 2, 3…依序編號下去。

enum Week: Int {
    case 星期日 = 0
    case 星期一, 星期二, 星期三, 星期四, 星期五, 星期六
}

let today = Week.星期一
print(today.rawValue)
// Prints "1"

我們可以透過rawValue來取得case名稱,如下,由於輸入的rawValue可能找不到對應的case,因此傳回值是Optional型態,所以使用if let語句來判斷會不會是nil。

if let weekname = Week(rawValue: 3) {
    print(weekname)
    // Prints "星期三"
}

使用下標

這裡還可以設計一個下標語法從原始值得到case名稱,如下:

enum Week: Int {
    case 星期日 = 0
    case 星期一, 星期二, 星期三, 星期四, 星期五, 星期六
    
    static subscript(n: Int) -> Week {
        return Week(rawValue: n)!
    }
}

print(Week[0])
// Prints "星期日"
print(Week[5])
// Prints "星期五"

以case名稱當成Raw Value

如果指定了列舉的原始值型態,但是卻沒有設定case的原始值,這時編譯器會自動幫我們加上原始值。如果是String,原始值就是每個case後面的名稱,如果是數字,會從0開始依序編號。以String為例,如下:

enum Week: String {
    case 星期日
    case 星期一, 星期二, 星期三, 星期四, 星期五, 星期六
}

let today = Week.星期一
print(today.rawValue)
// Prints "星期一"

11.3 關連值

列舉型態中的每一個case都有一個唯一的值,不論是預設的雜湊值還是自己指定的raw value。但有時候我們希望可以有其他額外的資訊能夠跟這些case綁在一起同時處理,這些額外的資訊就稱為關連值Associated Values。

若希望case可以額外輸入關連值時,必須要先定義關連值格式,其實跟函數定義參數的方式有點類似,只不過僅需要資料型態就可以,也可以加上標籤名稱。舉個例子,電話號碼分為行動電話與室內電話,行動電話的格式只有一個號碼,室內電話除了電話號碼外還要加上區碼。這兩種電話號碼格式不同,因此列舉型態設計如下,其中關連值的參數標籤可加可不加,例如case mobile的參數沒有標籤名稱,而case home有。

enum PhoneFormat {
    case mobile(String)
    case home(area: String, number: String)
}

有了這個列舉型態後,我們在選擇電話號碼類型時就可以依照case定義好的關連值格式輸入電話號碼。

var phone: PhoneFormat
phone = .mobile("0912345678")
phone = .home(area: "02", number: "12345678")

如果要將case中的關連值取出,可以先用switch語句來判別屬於行動電話還是室內電話,然後按照關連值格式對映取出。如下:

var phone: PhoneFormat = .home(area: "02", number: "12345678")
switch phone {
case .mobile(let number):
    print("行動電話: \(number)")
    
case .home(let area, let number):
    print("市內電話: (\(area))\(number)")
}
// Prints "市內電話: (02)12345678"

Switch語句中的let也可以從括號裡面移到case後面,形成更簡潔的case let語句,如下:

switch phone {
case let .mobile(number):
    print("行動電話: \(number)")
    
case let .home(area, number):
    print("市內電話: (\(area))\(number)")

關連值可以是任何型態,包含自訂的類別或是結構都可以,例如當購物網站的使用者欲使用購物車功能時,如果會員有登入系統,就可以將購物車與會員資料綁在一起。

struct Member {
    let id: String
}

enum ShoppingCar {
    case guest
    case member(Member)
}

var car: ShoppingCar = .member(Member(id: "A01"))

11.4 遞迴

既然列舉中的相關值型態可以是任何型態,當然也可以包含自己,於是形成了遞迴形式。舉個俄羅斯娃娃的例子。俄羅斯娃娃是由很多個大小不一的娃娃,然後一個套一個組合而成,從最外面看只有最大的娃娃,打開後裡面還有小一點的娃娃。我們設計一個enum型態來儲存俄羅斯娃娃。其中case none代表已經將娃娃打開到最裡面再也沒有東西了,case contains的第一個參數用來描述娃娃,這裡用字串形式來描述,第二個參數的型態就是本身自己,因此形成遞迴。如果enum中的case形成遞迴,必須在case前面或是在enum前面加上indirect保留字。

indirect enum RussianNestingDoll {
    case none
    case place (String, RussianNestingDoll)
}

有了俄羅斯娃娃這個列舉型態,接下來產生一個有三層的俄羅斯娃娃語法如下,這裡特意分成四行讓程式碼在結構上看起來清楚一點。

let doll: RussianNestingDoll =
    .place("第一層娃娃",
              .place("第二層娃娃",
                        .place("第三層娃娃", .none)))

然後設計一個將俄羅斯娃娃一層一層打開的函數,這邊的函數也需要使用遞迴呼叫,然後就可以將俄羅斯娃娃一層一層打開了。

func open(_ doll: RussianNestingDoll) {
    switch doll {
    case .none:
        break
    case let .place(des, inside) :
        print("打開\(des)")
        open(inside)
    }
}

open(doll)
// 打開第一層娃娃
// 打開第二層娃娃
// 打開第三層娃娃

11.5 初始化

列舉型態是可以初始化的,下面的例子在BulbStatus中加入一個初始化器,讓這個列舉在產生實體的時候就已經預先設定了初始值,例如剛買回來的燈泡一定是不亮的,語法如下:

enum BulbStatus {
    case on
    case off
    case broken
    
    init() {
        self = .off
    }
}

var bulb = BulbStatus()
// bulb == .off

發表迴響