---
# System prepended metadata

title: Open Close Principle
tags: [' Design Pattern', mentorship]

---

---
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。

