--- title: Open Close Principle tags: mentorship, Design Pattern description: 2019/10/10 --- # SOLID 之 O — 開放封閉原則 [TOC] SOLID 的第二個原則是 Open Close Principle,開放封閉原則。 它的定義: > “software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification” > 「對擴展開放,對修改關閉」 白話一點來說,就是「只能加程式碼,不能改程式碼」,意思是指新的功能不需要靠修改原有的程式碼來完成,而是在原有的程式碼加上寫新的程式碼去完成。 這樣同樣是為了降低修改程式碼而破壞原有功能的可能, 若只新增程式碼,原有程式碼因為沒有修改,理論上問題會比較少。 但 SOLID 只是最高指導原則,實作上很難做到完全不修改,個人認為不用太過糾結是不是會改到程式這件事,等經驗和實力累積起來就會做得來愈好。 ## 例子一 譬如我們要寫一個遊戲,當中有兩個的角色-普通人和火系法師,我們需要在 Game 的類別中寫一個 function 讓遊戲的角色進行攻擊,我們可能會這樣寫: ```swift class Human { let name: String init(name: String) { self.name = name } func attack() { print(name, "使用物理攻擊") } } class FireMage { let name: String var mana = 200 init(name: String) { self.name = name } func fireballAttack() { mana -= 50 print(name, "使用火球術") } } class Game { func start() { let player1 = Human(name: "A") let player2 = FireMage(name: "B") doAttack(player: player1) doAttack(player: player2) } private func doAttack(player: Any) { if let player = player as? Human { player.attack() } else if let player = player as? FireMage { player.fireballAttack() } } } Game().start() ``` 但當我們要新增角色,我們必需對 Game 裡面的 doAttack(player: Any) 進行修改: ```swift class WindMage { let name: String var mana = 200 init(name: String) { self.name = name } func windAttack() { mana -= 50 print(name, "使用龍捲風") } } class Game { func start() { let player1 = Human(name: "A") let player2 = FireMage(name: "B") let player3 = WindMage(name: "C") doAttack(player: player1) doAttack(player: player2) doAttack(player: player3) } // 修改程式碼 private func doAttack(player: Any) { if let player = player as? Human { player.attack() } else if let player = player as? FireMage { player.fireballAttack() } else if let player = player as? WindMage { player.windAttack() } } } Game().start() ``` 以上這種寫法,非常不好維護。 一旦角色變多 doAttack(player: Any) 的內容就會變的十分冗贅,我們很難去檢查是否每一種角色都判斷到。 所以我們應該在原有的程式碼加上寫新的程式碼去完成這件事,我們可以利用多型: ```swift class Human { let name: String init(name: String) { self.name = name } func attack() { print(name, "使用物理攻擊") } } class FireMage: Human { var mana = 200 override init(name: String) { super.init(name: name) } override func attack() { mana -= 50 print(name, "使用火球術") } } class WindMage: Human { var mana = 200 override init(name: String) { super.init(name: name) } override func attack() { mana -= 50 print(name, "使用龍捲風") } } class Game { func start() { let player1 = Human(name: "A") let player2 = FireMage(name: "B") let player3 = WindMage(name: "C") doAttack(player: player1) doAttack(player: player2) doAttack(player: player3) } // 新增角色時不用修改程式碼 private func doAttack(player: Human) { player.attack() } } Game().start() ``` 這樣寫新增角色時便不用修改 doAttack() 的程式碼,也可以把多個 player 寫成一個 array,讓程式碼變得更整潔: ```swift class Game { func start() { var players: [Human] = [Human(name: "A"), FireMage(name: "B")] players.append(WindMage(name: "C")) doAttack(players: players) } private func doAttack(players: [Human]) { players.forEach{ $0.attack() } } } ``` ## 例子二 再以一個我們比較會碰到的例子看看 OCP 能怎麼幫助我們減少 bug 發生的機率。 以 call api 為例,我們可能會像這個 [repo](https://github.com/lumanmann/open-closed-principle) 中這樣寫: ```swift struct APIService { static func getStationsList(_ completion:@escaping (Error?, UBikeResponseModel?) -> ()) { URLSession.shared.dataTask( with: URL(string: "https://tcgbusfs.blob.core.windows.net/blobyoubike/YouBikeTP.json")! ) { (data, response, error) in if let error = error { completion(error, nil) return } guard let data = data, let result = try? JSONDecoder().decode(UBikeResponseModel.self, from: data) else { completion(nil, nil) return } completion(nil, result) }.resume() } } ``` 可是很多時候我們需要就回傳的資料狀態顯示不同的 UI,也需要檢查 API 回傳後 view controller 的狀態是否正確。 ![](https://i.imgur.com/CGXSt8n.png) 你會發現在 API 是正常的情況下, unit test 中這兩個 test case 永遠都跑不過: ```swift func testViewControllerErrorState() { let vc = ViewController(apiService: APIService()) _ = vc.view DispatchQueue.global().asyncAfter(deadline: .now() + 5) { if case ViewControllerState.error(_) = vc.state { } else { XCTFail() } } } func testViewControllerEmptyState() { let vc = ViewController(apiService: APIService()) _ = vc.view DispatchQueue.global().asyncAfter(deadline: .now() + 5) { if case ViewControllerState.empty = vc.state { } else { XCTFail() } } } ``` 我們可以做的是把 getStationsList(_:)回傳的資料改掉再跑 test case。 (如果你夠大膽的話也可以叫後端修改 API 讓你測看看) 把 getStationsList(_:)回傳的資料改成 nil 就可以測到 empty 的狀態: ```swift struct APIService { static func getStationsList(_ completion:@escaping (Error?, UBikeResponseModel?) -> ()) { URLSession.shared.dataTask( with: URL(string: "https://tcgbusfs.blob.core.windows.net/blobyoubike/YouBikeTP.json")! ) { (data, response, error) in if let error = error { completion(error, nil) return } guard let data = data, let result = try? JSONDecoder().decode(UBikeResponseModel.self, from: data) else { completion(nil, nil) return } completion(nil, nil) // 把 result 改成 nil 以測試 empty 的狀態 }.resume() } } ``` ![](https://i.imgur.com/4VEm7D7.png) 但是這樣每次跑 test case 我們也會要把程式碼改來改去,如果哪天我們忘記改回來就發佈出去的話,嗯...請為自己點根蠟燭。 所以為了做到測試的時候不要動到原有的程式碼,我們也可以用多型。 APIService 可以改成用 protocol ,再在 view controller init 的時候把不同的 protocol 實例塞進去以拿到不同的回傳資料: ```swift protocol APIServiceProtocol { func getStationsList(_ completion:@escaping (Error?, UBikeResponseModel?) -> ()) } struct APIService: APIServiceProtocol { func getStationsList(_ completion:@escaping (Error?, UBikeResponseModel?) -> ()) { URLSession.shared.dataTask( with: URL(string: "https://tcgbusfs.blob.core.windows.net/blobyoubike/YouBikeTP.json")! ) { (data, response, error) in if let error = error { completion(error, nil) return } guard let data = data, let result = try? JSONDecoder().decode(UBikeResponseModel.self, from: data) else { completion(nil, nil) return } completion(nil, result) }.resume() } } struct APIServiceMockLoading: APIServiceProtocol { func getStationsList(_ completion:@escaping (Error?, UBikeResponseModel?) -> ()) { // 不做事,模擬 loading } } struct APIServiceMockEmpty: APIServiceProtocol { func getStationsList(_ completion:@escaping (Error?, UBikeResponseModel?) -> ()) { completion(nil, nil) } } struct APIServiceMockError: APIServiceProtocol { func getStationsList(_ completion:@escaping (Error?, UBikeResponseModel?) -> ()) { completion(NSError(domain: "Mock error", code: 404, userInfo: nil), nil) } } ``` ![](https://i.imgur.com/vuDQbxg.png) 這樣不用修改程式碼也能完成 unit test。 修改過後的專案在[這裡](https://github.com/lumanmann/open-closed-principle/tree/ocp)。 ## 優缺點 如[威廉的 PPT](https://github.com/kuotinyen/Slides) 所寫,放封閉原則的優缺點如下: ![](https://i.imgur.com/N79EDl9.png) ## 總結 如果新增 case 時便需修改原本的實作或新增判斷,就是沒有實踐到開放封閉原則,但實踐的同時也需注意不要 over design。