Extension 是 Swift 裡用來延伸既有型別的東西。透過 Extension,當我們想為某個型別加功能的時候,就可以不用把新的功能寫在該型別的主體裡面。比如說,如果我們想為 Cat
增加一個 purr()
方法的時候,可以這樣寫:
class Cat {
func meow() {
print("Meow!")
}
}
extension Cat {
func purr() {
print("咕嚕嚕嚕嚕...")
}
}
let myCat = Cat()
myCat.purr() // 咕嚕嚕嚕嚕...
這種能力讓我們可以為一些碰不到原始碼的型別,加上我們自己給的功能。這也就是所謂的回顧式建模 (Retrospective Modeling)── 在不更改原本型別的前提下,去為這個型別增加功能。
雖然這感覺並不是甚麼厲害的設計模式,但其實善加運用的話,就可以大幅簡化 Massive View Controller。以下,我們就一起來看看它有甚麼樣的用法。
模組化
Extension 並不只是拿來加功能而已,它也可以用來打散你自己寫的型別。比如說,我們可以把所有的工廠方法都丟到同一個 Extension 裡面:
class MyViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let label = makeLabel()
view.addSubview(label)
}
// ...
}
extension MyViewController {
func makeLabel() -> UILabel {
// ...
}
// 其它工廠方法...
}
這樣做的好處,是在閱讀這個型別的程式碼時,我們可以更容易去定位某一個方法。不要小看單純視覺上的分區,當你找 bug 找到焦頭爛額的時候,你會感謝當初自己有把程式碼好好整理過。
你可能會想,這跟用 // MARK:
來做段落有甚麼差別呢?// MARK:
還讓你可以為段落命名勒!其實,這兩種東西並不會互相排斥,我們完全可以將它們搭配使用,像這樣:
// ...
// MARK: - 工廠方法
extension MyViewController {
// 所有的工廠方法實作...
}
如此一來,我們就可以在 Xcode 的 Jump bar 下拉式選單裡,知道這個 Extension 是做甚麼的:
同時,Extension 確實也提供了一些 // MARK:
不具有的功能。
程式碼折疊
在最新版的 Xcode 裡,所有被大括號(「{」與「}」)括起來的程式碼都可以被折疊。Extension 即是用大括號把一些程式碼包起來,所以我們可以把整個 Extension 都折疊起來,像這樣:
要怎麼折疊呢?除了彼得潘所提到的方法── 使用 Code folding ribbon 之外,也可以:
- 將鍵盤輸入游標移動到該 Extension 所處的任何一行。
- 按下鍵盤快捷鍵:Command-Option-Left Arrow
如果要展開的話,改按 Command-Option-Right Arrow就可以了。
程式碼折疊對於想專心處理其它部份的程式碼時非常有用,畢竟眼不見為淨。然而除了折疊之外,想隱藏程式碼還有另一種更基進的做法。
移到新檔案
沒錯,把整個 Extension 移到別的檔案去,你就不會再在這裡看到它了。比如說,我們可以新增一個叫做 MyViewController+FactoryMethods.swift 的檔案,並把相關的 Extension 整個剪貼過去:
// MyViewController+FactoryMethods.swift
extension MyViewController {
// 所有的工廠方法實作...
}
這在原本的 View Controller 行數太多時尤其有用;但除此之外,新檔案也意味著幾件事。
首先是創造了新的隱私權範圍。不管是 private
還是 fileprivate
,現在 (Swift 4.2) 都是以檔案作為最大範圍的。也就是說,我們得以在原本的 MyViewController.swift 與新的 MyViewController+FactoryMethods.swift 裡,各自定義只能在檔案內部讀取的方法,以此增強個別的模組化,並降低複雜度。
再來是開啟了在不同 Target(編譯目標)之間、不同組合的可能性。由於 Swift 編譯器是以檔案為基本單位的,所以我們可以選擇在某些 Target 裡不要包含某些檔案。比如說,我們可能只有在主程式裡才需要編輯功能,那就可以把所有的編輯功能都透過 Extension 放到一個新檔案裡,並只在主程式 Target 裡包含該檔案。
遵守 Protocol
Extension 可以加的功能並不只是方法與計算型屬性而已,它還可以加上對 Protocol 的遵守。比如說,我們可以使 MyViewController
的 Extension 去遵守 UITableViewDataSource
:
extension MyViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// ...
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ...
}
}
使用 Extension 來遵守 Protocol 可以說是模組化的最佳實踐,因為這使得我們能馬上就明白哪些方法與屬性是屬於哪個 Protocol 的。這也可以提醒自己把同個 Protocol 的方法寫在一起,使我們不用去煩惱哪個方法要寫在哪一行的同時,也可以寫出有條不紊的程式碼。
另外前面也有提到,我們可以在不同 Target 之間採取不同的檔案組合,以控制某型別功能的差異;但我們並沒有說要怎麼判斷該型別是否有某種功能。雖然我們可以用 Swift 編譯器的 Active Compilation Conditions 編譯符、與 #if/#else/#endif
判斷式,去決定所在的 Target 有沒有包含某個 Extension;但用 Protocol 來判斷的話,就不用去碰編譯設定了。
比如說,假設我們想要把 MyViewController
的編輯功能限縮在主程式之內的話,那我們可以這樣寫:
// MyViewController.swift
// 包含於所有 Target。
class MyViewController: UIViewController {
@IBOutlet weak var imageView: UIImageView!
@IBAction func deleteButtonWasPressed() {
// 判斷 self 是不是 Editable。
if let editableSelf = self as? Editable {
editableSelf.setImage(nil)
}
}
}
// Editable.swift
// 包含於所有 Target。
protocol Editable {
func setImage(_ image: UIImage?)
}
// MyViewController+Editing.swift
// 僅包含於主程式 Target。
extension MyViewController: Editable {
func setImage(_ image: UIImage?) {
imageView.image = image
}
}
如此一來,在使用者按下刪除、並觸發 deleteButtonWasPressed()
的時候,Swift 的 Runtime 就會去檢查 MyViewController
是不是 Editable
,進而執行編輯指令 setImage(_:)
。而由於只有在主程式裡 MyViewController
才遵守 Editable
,所以在其它的 Target 裡 setImage(_:)
是不會被執行的。
將程式碼歸類到適合的型別裡
在寫 View Controller 的時候,我們經常會用到各種工廠方法與輔助方法 (Helper Method)。它們可以使程式碼更整齊,也能幫助我們理解某一段程式碼的意義。
比如說,我們要寫一個負責彈出刪除警告視窗的輔助方法。而一如前文提到,我們可以把它放到一個 Extension 裡面去,以方便管理。
class MyViewController: UIViewController {
// 負責刪除內容。
func deleteContent() {
// ...
}
// 處理刪除按鍵被按下的事件。
@IBAction func deleteButtonItemWasPressed() {
// 呼叫輔助方法。
presentDeleteConfirmationAlert()
}
}
// MARK: 輔助方法
extension MyViewController {
// 顯示刪除確認警告的輔助方法。
func presentDeleteConfirmationAlert() {
// 創造 alertController。
let alertController = UIAlertController(title: nil, message: "確定要刪除嗎?", preferredStyle: .alert)
let deleteAction = UIAlertAction(title: "刪除", style: .destructive) { [weak self] _ in
// 呼叫 self 的 deleteContent()。
self?.deleteContent()
}
alertController.addAction(deleteAction)
let cancelAction = UIAlertAction(title: "取消", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
// 呈現 alertController。
present(alertController, animated: true, completion: nil)
}
}
透過把樣板程式碼 (boilerplate code) 用一個輔助方法包起來,並收納到 Extension 裡,我們使型別主體的程式碼更容易閱讀了。然而,這還不是最乾淨的方式,因為雖然這個輔助方法已經在 Extension 裡了,但它仍然跟型別主體互相依賴。這個時候,我們就可以想想有沒有更乾淨的寫法。
首先,讓我們來想想這個輔助方法到底做了甚麼事?在這裡,它主要就做了兩件事:創造一個 UIAlertController
,以及去呈現它。但其實呈現所需的程式碼也就只有一行而已,所以這個方法最主要的職責,其實就是創造 UIAlertController
的實體。
沒錯,聽起來很像工廠方法。不過,Swift 其實提供了另一種工具來滿足這個需求:Convenience Initializer。也就是說,與其把它改寫成這樣:
extension MyViewController {
func makeDeleteConfirmationAlert(deletionHandler: @escaping () -> Void) -> UIAlertController {
// ...
}
}
不如試試寫成這樣:
extension UIAlertController {
convenience init(deletionHandler: @escaping () -> Void) {
// ...
}
}
為甚麼會有 deletionHandler
這個參數呢?因為原來的寫法裡,刪除的動作會直接去呼叫 self?.deleteContent()
,造成對 MyViewController
的依賴。用閉包來取代掉這一行的話,就可以解除依賴了。整個程式碼如下:
class MyViewController: UIViewController {
// 負責刪除內容。
func deleteContent() {
// ...
}
// 處理刪除按鍵被按下的事件。
@IBAction func deleteButtonItemWasPressed() {
let deleteConfirmationAlert = UIAlertController(deletionHandler: { [weak self] in
self?.deleteContent()
})
present(deleteConfirmationAlert, animated: true, completion: nil)
}
}
extension UIAlertController {
convenience init(deletionHandler: @escaping () -> Void) {
self.init(title: nil, message: "確定要刪除嗎?", preferredStyle: .alert)
let deleteAction = UIAlertAction(title: "刪除", style: .destructive) { _ in
// 呼叫傳入的 deletionHandler。
deletionHandler()
}
addAction(deleteAction)
let cancelAction = UIAlertAction(title: "取消", style: .cancel, handler: nil)
addAction(cancelAction)
}
}
現在,型別與型別之間的分工更清楚了!MyViewController
負責在適當時機要 UIAlertController
創造一個刪除警告出來,然後再呈現它;而 UIAlertController
則負責創造這個刪除警告。這就符合了「高聚合,低耦合 (High Cohesion, Low Coupling)」原則,或者簡單說,就是各司其職。
另一個潛在的好處,是我們現在也可以在別的地方使用這個 Convenience Initializer 了。由於它沒有對官方框架以外的型別有任何依賴,所以它是可以在任何有 import UIKit
的檔案裡使用。
不過,程式碼的整理並不需要以重用性為目標。我們甚至可以把這個 Convenience Initializer 標記為 fileprivate
,以使它只能在同一個檔案裡被使用。我們光是讓程式碼易於理解就已經達到目標了,不必要為了重用性而犧牲易讀性。
還有很棒的一點是:這樣寫可以省下很多字符。如果比較一下輔助方法版本的 presentDeleteConfirmationAlert()
與 Convenience Initializer 版本的 init(deletionHandler:)
,你會發現後者完全沒有出現 alertController
這個辨識符!因為前者的 alertController
在後者其實就是 self
,所以可以整個省略掉。
事實上,這也可以拿來判斷說,把程式碼放到哪個型別的 Extension 會比較好。在一個輔助方法中,如果某個辨識符出現的頻率越高,那它就越可能是這個輔助方法中的主角,我們就可以試著把整個輔助方法移到該辨識符型別的 Extension 裡。
擴展通用型別
Extension 可以只針對滿足了某條件的通用型別 (Generic Type) 增加功能,這適合應用在內建的許多集合型別上,因為集合型別大多都是通用型別。比如說,在使用 UITableView
與 UICollectionView
的時候,我們常常會需要用 IndexPath
去取值,像是這樣:
struct Section {
var title: String
var texts: [String]
}
class MyViewController: UIViewController {
var sections: [Section] = []
// ...
}
extension MyViewController: UITableViewDataSource {
// ...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cellIdentifier", for: indexPath)
// 用 indexPath 向 self.sections 取值。
let text = self.sections[indexPath.section].texts[indexPath.row]
cell.textLabel?.text = text
return cell
}
}
可以看到,取個值就要寫一串長長的程式碼,用了兩次下標(Subscript)語法。但既然兩次下標的輸入來源都是同一個 indexPath
,我們有沒有辦法簡化這整個陳述呢?
有的,Extension 就可以做到了。
// 設定為只有在 Array<Section> 的時候才加功能。
extension Array where Element == Section {
subscript(indexPath: IndexPath) -> String {
get {
// 因為確認 Element 是 Section,所以 self 的型別已經是 [Section] 了。
return self[indexPath.section].texts[indexPath.row]
}
// 必須加上 mutating 才能更動 self,因為 Array 是一個 Structure。
mutating set {
self[indexPath.section].texts[indexPath.row] = newValue
}
}
}
用 Extension 加入上述的下標之後,我們就可以把原本的方法改成這樣:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cellIdentifier", for: indexPath)
cell.textLabel?.text = self.sections[indexPath]
return cell
}
是不是簡化許多了呢?
結論
軟體重構這件事,其實就是對程式碼做整體的整理。除了大規模的架構更動之外,小規模的分段、分檔案等都是能增加程式碼可讀性的手段,而 Extension 就很適合拿來應用在小規模的重構。有的時候,你甚至會發現有些問題其實不用去寫一個新的型別來處理,只要一個 Extension 就可以解決了。讀完這篇,也希望你得到一些活用 Extension 的靈感!