---
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 的狀態是否正確。

你會發現在 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()
}
}
```

但是這樣每次跑 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)
}
}
```

這樣不用修改程式碼也能完成 unit test。
修改過後的專案在[這裡](https://github.com/lumanmann/open-closed-principle/tree/ocp)。
## 優缺點
如[威廉的 PPT](https://github.com/kuotinyen/Slides) 所寫,放封閉原則的優缺點如下:

## 總結
如果新增 case 時便需修改原本的實作或新增判斷,就是沒有實踐到開放封閉原則,但實踐的同時也需注意不要 over design。