實務應用

基於這篇是在講概念,所以程式碼的部分就寫個意思,並不能真的執行的。

這裡也不太想要老調重彈的說一些 Duck, Animal, Dog, Cat 的例子,直接來點實務上面的應用,來說明一下物件導向的優點。

在幾年前,我在一家寫POS系統的公司工作,當時需要列印電子發票,公司找了Epson的印表機的代理商,從他們那邊確定了貨源。

於是乎,有了以下這段程式碼。


final class EpsonPrinterHelper {

    static let shared = EpsonPrinterHelper()

    private init() {}

    /// 列印發票
    func print(invoice: Invoice) {
        let printer = EpsonPrinter()
        printer.setText(invoice.title)
        printer.setText(invoice.date)
        printer.setText(invoice.number)
        
        ...
        
        printer.print()
        printer.cut()
    }

    /// 列印收據
    func print(receipt: Receipt) {
        let printer = EpsonPrinter()
        ...
    }
}

/// 結帳頁面
class CheckoutViewController: UIViewController {

    func printInvoice() {
        let invoice = Invoice()
        EpsonPrinterHelper.shared.print(invoice: invoice)
    }

    func printReceipt() {
        let receipt = Receipt()
        EpsonPrinterHelper.shared.print(receipt: receipt)
    }
}

這段程式碼運作起來很正常,很美好,直到公司決定要賣其他廠牌印表機的那一天。

先來看一下這段程式碼會有什麼問題。由內往外逐步分析

問題一:每一個方法都直接對應一種單據,所以如果要新增一種單據,就要修改這個檔案。

這就是違反了「單一職責」與「開放封閉」,那麼造成的問題呢?這個檔案就會參雜許多不同的異動記錄,有問題會難以追根溯源。

例如

  • 修復:「工單_00321」發票QRCode大小異常
  • 新增:「工單_00322」列印打卡記錄
  • 移除:「工單_00323」盤點庫存明細

顯然,如果異動記錄是這樣會比較好

  • 修復:「工單_00321」發票QRCode大小異常
  • 新增:「工單_00322」發票明細增加商品金額
  • 新增:「工單_00323」發票頂部新增自訂圖片

好處一目瞭然:所有的異動都是針對發票,而不是其他要列印的單據。

commit message 要寫好,不過這個會另外寫一篇文章來講。

問題二:列印畫面直接依賴在EpsonPrinter,沒有辦法替換印表機。

這個就是違反「子物件替換」與「介面隔離」。就如同上面的修改,如果每次要新增一個新的印表機類型,就要來修改結帳畫面。但是結帳畫面的「職責」是「整理要結帳資料」而不是選擇印表機

基於上面的問題,於是有了以下的修改版本。

這段程式碼會有一點長,所以拆開來講解

第一步:拆分列印任務

protocol PrinterTask {
    func print(with printer: Printer)
}

struct PrintInvoiceTask: PrinterTask {

    let invoice: Invoice

    func print(with printer: Printer) {
        printer.setText(invoice.title)
        printer.setText(invoice.date)
        printer.setText(invoice.number)
        ...
        printer.print()
        printer.cut()
    }
}

struct PrintReceiptTask: PrinterTask {

    let receipt: Receipt

    func print(with printer: Printer) {
        printer.setText(receipt.title)
        printer.setText(receipt.date)
        ...
        printer.print()
        printer.cut()
    }
}

如此,就能把不同的列印任務,拆分到不同的類別中。針對檔案的 commit 也能夠更精確地說明修改內容。

第二步:高階物件使用抽象介面

final class PrinterHelper {

    static let shared = PrinterHelper()

    private init() {}

    /// 列印
    func print(task: PrinterTask, with: Printer) {
       task.print(with: Printer)
    }
}

乍看之下感覺這個物件沒什麼用處,只是單純的 Middle Man

是因為這邊並沒有引入錯誤處理的流程。

這邊可以另外負責列印任務失敗以後的處理,諸如重印、拋出錯誤、記錄 log 等等,這個物件的職責就會從「處理各種單據」變成「處理列印任務」,這樣職責就細化了。

第三步:印表機職責

上面的程式碼會發現宣告了一個Printer protocol,但是並沒有實作。因為這邊牽扯到一個稍微複雜的設計,所以放到後面才講。

照一般的寫法,會是這樣的設計

class EpsonPrinter {
    func printInvoice
    func printReceipt
    ...
}

class StarPrinter {
    func printInvoice
    func printReceipt
    ...
}

這樣寫的問題也很明顯,一來程式碼重複性很高,二來兩邊的邏輯就有可能會對不上。有可能發生「修好一個印表機的發票格式,忘了修其他的」。所以這邊需要引入一個 Design Pattern

使用了 Bridge Pattern 改寫以後的程式碼是這樣,將抽象與實作分離

protocol Printer {
    func setText(_ text: String)
    func setBarcode(_ text: String)
    func setQrcode(_ text: String)
    ...
}

class EpsonPrinter: NSObject, EpsonPrinterDelegate, Printer {
    ...
}

class StarPrinter: Printer {
    ...
}

把商業邏輯從物件中去掉,讓Printer只具有基本能力。

如此一來,結帳頁面就可以改寫成這個版本

/// 結帳頁面
class CheckoutViewController: UIViewController {

    func printInvoice() {
        let invoice = Invoice(...)
        let task = PrintInvoiceTask(invoice: invoice)
        let invoicePrinter = PrinterManager.shared.getInvoicePrinter()

        PrinterHelper.shared.print(task: task, invoicePrinter)
    }

    func printReceipt() {
        ...
    }
}

當然過程中有很多奇奇怪怪的坑要踩,列印流程也不是一次到位,要根據不同的印表機整理出一個共同能使用的基本流程本來就很複雜。

優點也很明顯,後面有新增第三台印表機的時候,可以直接讓印表機實作Printer這個協定,然後PrinterManager這裡面提供這個印表機實體就處理完了。

也不會遇到邏輯不一致的問題,因為邏輯封裝在各種的Task內部。

結論

物件導向的重點

這樣的流程也是當初慢慢摸索出來的

重點是 「如果沒有需求,就不要花時間設計」