14. 泛型

泛型的意思是在需要型態的地方先用一個符號來代替明確的資料型態,等到要用時再把代替資料型態的符號換成真正的型態,這樣我們就不需要為了不同的資料型態而且出一大堆功能差不多的程式碼。

14.1 泛型函數

設計一個函數,這個函數可以將傳進去的兩個參數內容對調。參數型態前加上inout相當於傳址呼叫,代表函數中如果更動了參數內容,實際上會把原本傳進這個函數的變數內容改掉,請參考第6章函數。

無泛型時

func swap(_ a: inout Int, _ b: inout Int) {
    let tmp = a
    a = b
    b = tmp
}

宣告兩個變數a = 10,b = 20呼叫swap函數之後從印出來的結果可以發現變數a與b的內容對掉了。因為參數宣告時加上了inout關係,因此呼叫時變數前需要加上&符號。

var a = 10
var b = 20

swap(&a, &b)
print(a, b)
// Prints "20 10"

這是一個很常見的對調函數,但對調的內容不一定只有Int型態,如果是字串型態,我們就必須再設計一個同樣內容的函數,但是參數型態為String。

func swap(_ a: inout String, _ b: inout String) {
    let tmp = a
    a = b
    b = tmp
}

除了Int與String之外,還有Double、Bool、Int32、UInt甚至是自訂型態等一大堆的型態可能都需要swap(_:_:)。這時候問題就浮現了,我們不可能把所有型態的swap(_:_:)函數都實作一次。這時如果我們能夠把特定的資料型態從函數中抽離,用一種「一般的資料型態」來代替特定的資料型態,這樣我們只要設計一個函數即可。這種一般的資料型態就稱為泛型。

使用泛型

現在使用泛型來設計swap(_:_:)函數,其中Int整數的位置改用T來表示,T不代表任何型別,僅僅是一個代號而已,用任何字串都可以,但經常使用大寫T或U等單一字母來表示。為了告訴編譯器T是一個泛型符號而不是我們真正定義了一個資料型態T,因此在函數名稱後方用 <T> 來說明T是泛型符號。

func swap<T>(_ a: inout T, _ b: inout T) {
    let tmp = a
    a = b
    b = tmp
}

現在不論要傳進swap(_:_:)參數的資料型態為何,編譯器都會在呼叫swap(_:_:)時自動根據傳進去的數值將泛型符號T替換成真正的資料型態。所以swap(_:_:)函數只要用泛型方式寫一次,就支援所有不同的資料型態了。例如下面的程式碼,因為變數a與變數b的型態為String,所以呼叫swap(_:_:)函數時,泛型符號T就會替換成String。

var s1 = "aaa"
var s2 = "bbb"

swap(&s1, &s2)
print(s1, s2)
// Prints "bbb aaa"

14.2 泛型類別與結構

類別與結構也可以使用泛型,以類別為例,設計一個支援泛型的單向鏈節串列。單向鏈節串列的特性是資料只能從尾端加入,因為是單向,所以瀏覽每一筆資料時也只能從頭依序到尾,無法跳過中間的節點,除非中間節點的位置被額外記錄下來。

不使用泛型的類別

在不使用泛型的情況下,節點中的屬性body用來存放節點中的資料,假設節點中只能儲存Int型態的資料或nil,因此body的資料型態為Int?,此外,每個節點有一個next屬性用來指向下一個節點。程式碼如下:

class Node {
    var body: Int?
    var next: Node?
    
    init(_ body: Int) {
        self.body = body
    }
}

另外撰寫一個管理單向鏈節串列的類別,負責將Node串成串列,主要提供增加節點add(_:_:)與列出串列中所有節點forEach(_:_:)這兩個函數。屬性head永遠指向第一個節點或是nil,若為nil代表此串列中沒有任何節點。這個類別設計如下:

class LinkedList {
    private var head: Node?
    
    func add(_ node: Node) {
        guard var tail = head else {
            head = node
            return
        }
        
        while tail.next != nil {
            tail = tail.next!
        }
        
        tail.next = node
    }
    
    func forEach(_ body: (Node?) -> Void) {
        var p = head
        while p != nil {
            body(p)
            p = p!.next
        }
    }
}

現在產生一個串列,然後增加三個節點,最後呼叫forEach方法把三個節點的內容印出來。

var link = LinkedList()
link.add(Node(10))
link.add(Node(20))
link.add(Node(30))

link.forEach {
    print($0!.body!)
}
// 10
// 20
// 30

使用泛型的類別

現在我們把這個單向鏈節串列改成泛型,也就是Node裡面存放的內容不一定是Int型態,其他型態也可以。只要在類別名稱後方加上 <T> 然後類別中所有Int的位置換成符號 T 就可以了。

class Node<T> {
    var body: T?
    var next: Node?
    
    init(_ body: T) {
        self.body = body
    }
}

因為類別Node已是泛型,而LinkedList類別中會使用到Node型態,因此LinkedList類別也要修改,修改方式有兩種,一種是將LinkedList類別改為泛型,例如class LinkedList<T>,另一種是將LinkedList中的Node改為Node<Any>。差異在於如果要讓串列中的每一個Node都是同一型態,使用前者,如果Node可以是任何型態,選擇後者。這裡選擇前者當範例。

class LinkedList<T> {
    private var head: Node<T>?
    
    func add(_ node: Node<T>) {
        guard var tail = head else {
            head = node
            return
        }
        
        while tail.next != nil {
            tail = tail.next!
        }
        
        tail.next = node
    }
    
    func forEach(_ body: (Node<T>?) -> Void) {
        var p = head
        while p != nil {
            body(p)
            p = p!.next
        }
    }
}

現在要產生LinkedList實體時就必須指定資料型態了,例如指定Int型態,這樣每一個節點能夠儲存的內容也必須是Int型態才可以。

let list = LinkedList<Int>() 

14.3 泛型協定與Associated Type

使用泛型設計的結構或類別在產生實體的時候需透過指定型態來告訴編譯器真正的型態是什麼。使用泛型設計的函數,編譯器可以透過傳入的參數與函數中return的資料而知道該用什麼型態來替換泛型型態。但是協定無法拿來產生實體,因此要設計一個泛型協定就要透過associatedtype這個保留字了。這裡以標準函數庫中的Identifiable協定作為例子。Identifiable協定要求符合此協定的結構或類別必須實作id屬性。下面這段程式碼是Identifiable協定的原始定義,可以看到ID為泛型符號並且可以作為ID的資料型態還要符合Hashable協定的規範。

public protocol Identifiable {
    associatedtype ID : Hashable
    var id: Self.ID { get }
}

舉個例子。如果會員Member有會員id,商品Product有商品id,因此Member與Product這兩個結構都要符合Identifiable協定的規範,此外,會員id與商品id的資料型態是不一樣的,會員id為整數型態,而商品編號為字串型態,我們可以這樣設計。

struct Member: Identifiable {
    var id: Int
}

struct Product: Identifiable {
    var id: String
}

let m1 = Member(id: 1)
let p1 = Product(id: "A001")
let p2 = Product(id: "C300-2")

14.4 限制泛型範圍

泛型在預設情況下可以代表任何型態,但有的時候需要加以限制,讓泛型只適用於特定的幾個型態。要做到限制得要透過協定(protocol)或父類別,也就是讓泛型的範圍限制在符合特定協定的型態或繼承某個類別時才適用。例如上一節我們看過的將泛型ID的型態限制在必須符合Hashable協定的型態才可以使用。所以要限制泛型的範圍就是在泛型符號後面加上冒號,然後再加上協定名稱或類別名稱即可。

舉個例子。假如要設計一個用來計算平方的函數,由於文字型態或布林型態的數值無法進行平方運算,所以泛型範圍必須限制在數字型態,泛型符號 <T: Numeric> 意思就是型態必須符合Numeric協定的型態才可以呼叫square(_:)函數。Numeric協定包含了Int、Int32、Int64、Float、Double…等數字類型。

func square<T: Numeric>(_ value: T) -> T {
    value * value
}

除了用協定來限制泛型的範圍外,也可以使用類別來限制,意思是只要該類別的子類別都可以使用。例如下述程式碼中參數a的型態必須繼承SomeClass才可以呼叫此函數。

func someFunc<T: SomeClass >(a: T) {

}

where 語句

如果我們要比較兩個符合Identifiable協定的結構或是類別所產生的實體是否一樣時,我們可以使用屬性id來比較,例如將身份證字號一樣的資料視為是同一個人的資料。所以我們寫了以下這個函數,但卻得到一個錯誤。

func isEqual<T: Identifiable, U: Identifiable>(_ a: T, _ b: U) -> Bool {
    a.id == b.id
}

造成錯誤的原因是編譯器無法確定a.id資料型態是否與b.id的資料型態一樣,例如a.id是Int但b.id是String,這時是無法比較的,因此必須先用where語句限制a.id與b.id的資料型態必須一樣才能比較。除此之外,還要確認a與b資料型態也需要一樣,才代表a與b是相等的,例如某會員id剛好跟某學生的學號一樣,這時不能說該會員就是該學生,因為會員的資料型態是Member,學生的資料型態是Student,即使這兩個型態中的id型態是一樣的,還是不能比較。因此這個函數有兩個地方的資料型態都需要一樣時才可以進行比較。

func isEqual<T: Identifiable, U: Identifiable>(_ a: T, _ b: U) -> Bool where T.ID == U.ID
  {
    return a.id == b.id && T.self == U.self
}

where語句後面的 T.ID == U.ID 用來比較兩個泛型符號ID的型態是否一樣,函數中的 T.self == U.self 用來比較 T 與 U 本身是否是屬於同一個資料型態。

14.5 Any 與 Generic 使用時機

Any型態代表可以接受任何型態的資料,而泛型也是可以接受任何型態的資料,這兩者差異是什麼?什麼時候應該使用Any什麼時候應該使用泛型?這一章最後我們來探討一下這個問題。

Swift是一種型態安全的語言(type-safe),代表一旦宣告了變數或常數的資料型態,該變數或常數就不應該出現另一種不同的型態,有這樣特性的語言稱為型態安全語言。編譯器會在編譯前或執行時期確保每個地方的型態都是一致的。

Swift的泛型是在不犧牲型態安全的前提下,讓我們可以設計出能支援各種不同型態的程式碼。我們回到14.2的LinkedList類別,在14.2所設計的類別是泛型類別,所以可以在class後方看到泛型符號 <T>。

class LinkedList<T> {
    private var head: Node<T>?
    
    func add(_ node: Node<T>) {
	...
    }
    
    func forEach(_ body: (Node<T>?) -> Void) {
	...
    }
}

這個泛型類別在產生LinkedList實體時要告訴編譯器這個類別所能接受的節點型態為何,一旦確定了,之後每個節點的型態都必須與當初宣告時一致,否則編譯器的型態檢查會提出型態錯誤訊息。像下面這個串列能夠接受的節點型態為Int,如果要加入String型態的節點就會出現錯誤。

let list = LinkedList<Int>()
list.add(Node("ABC"))
// Cannot convert value of type 'String' to expected argument type 'Int'

如果把LinkedList類別改為可接受任何型態的節點,設計如下:

class LinkedList {
    private var head: Node<Any>?
    
    func add(_ node: Node<Any>) {
	...
    }
    
    func forEach(_ body: (Node<Any>?) -> Void) {
	...
    }
}

這時我們要加入任何型態的節點時,編譯器的語法檢查不會產生任何的抱怨,包含節點中的內容是另外一個節點都可以。讓我們可以用最大彈性來處理不同型態的資料。

let list = LinkedList()
list.add(Node(10))
list.add(Node("ABC"))
list.add(Node([1, 2, 3]))
list.add(Node(Node(20)))

現在應該很清楚的知道,如果使用泛型,我們可以很彈性的設計函數、類別、結構與協定,支援各種不同的資料型態但不會犧牲Swift的型態安全性。如果使用Any或AnyObject,我們可以繞過編譯器的型態檢查然後做任何想要做的事情,但有些地方必須自己處理,例如型態轉換。

發表迴響