Try   HackMD

SOLID 之 O — 開放封閉原則

SOLID 的第二個原則是 Open Close Principle,開放封閉原則。

它的定義:

“software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification”
「對擴展開放,對修改關閉」

白話一點來說,就是「只能加程式碼,不能改程式碼」,意思是指新的功能不需要靠修改原有的程式碼來完成,而是在原有的程式碼加上寫新的程式碼去完成。

這樣同樣是為了降低修改程式碼而破壞原有功能的可能,
若只新增程式碼,原有程式碼因為沒有修改,理論上問題會比較少。

但 SOLID 只是最高指導原則,實作上很難做到完全不修改,個人認為不用太過糾結是不是會改到程式這件事,等經驗和實力累積起來就會做得來愈好。

例子一

譬如我們要寫一個遊戲,當中有兩個的角色-普通人和火系法師,我們需要在 Game 的類別中寫一個 function 讓遊戲的角色進行攻擊,我們可能會這樣寫:

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) 進行修改:


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) 的內容就會變的十分冗贅,我們很難去檢查是否每一種角色都判斷到。

所以我們應該在原有的程式碼加上寫新的程式碼去完成這件事,我們可以利用多型:

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,讓程式碼變得更整潔:

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 中這樣寫:


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 永遠都跑不過:

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 的狀態:

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 實例塞進去以拿到不同的回傳資料:


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。

修改過後的專案在這裡

優缺點

威廉的 PPT 所寫,放封閉原則的優缺點如下:

總結

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