引子:面試受挫#

小菜大四在求職途中收到一份計算機面試題:

請用 C++、Java、C# 或 VB.NET 任意一種物件導向語言實現一個計算器控制台程式,要求輸入兩個數和運算符號,得到結果。

小菜十分鐘內就交了卷,自認為沒有錯誤,卻在半個月後仍未收到回覆。表哥大鳥看完他的程式後指出:

  • 命名不規範(ABCD 等)
  • 多個 if 分支讓電腦做了無用功,應改為 switch
  • 沒處理除數為 0、輸入非數字等錯誤情境
  • 最關鍵:沒有用「物件導向」的方式去實現

「程式無錯就是優?」——能跑通的程式不等於好的程式。容易維護、容易擴展、容易複用,才是物件導向追求的品質。

活字印刷的啟示#

大鳥透過「曹操做詩」的故事,讓小菜理解物件導向:

  • 曹操吟詩後屢次更改詞句,工匠每改一字就要重新刻一次整版
  • 若採用活字印刷,每次只需更改要改的字,其餘工作不必白做

活字印刷對應物件導向的四大好處:

  • 可維護:要改,只需更改要改的字
  • 可複用:這些字在後來的印刷中可以重複使用
  • 可擴展:要加字,只需另刻字加入即可
  • 靈活性好:字可橫排可豎排,移動即可滿足排列需求

活字印刷不僅是技術進步,更是思想的成功,物件導向的勝利

傳統刻版的問題在於「所有字都刻在同一版面上」造成耦合度太高。物件導向則透過封裝、繼承、多型把程式的耦合度降低。

第一次重構:業務與介面分離#

小菜先把運算邏輯封裝為一個 Operation 類別,將計算與顯示分離:

public class Operation
{
    public static double GetResult(double numberA, double numberB, string operate)
    {
        double result = 0d;
        switch (operate)
        {
            case "+": result = numberA + numberB; break;
            case "-": result = numberA - numberB; break;
            case "*": result = numberA * numberB; break;
            case "/": result = numberA / numberB; break;
        }
        return result;
    }
}

這樣不論是 Windows、Web、PDA 或手機程式,都能複用 Operation 類別。

這只是用到了物件導向三大特性中的「封裝」,還沒有用到繼承多型

第二次重構:繼承與多型#

問題:若要新增「平方根」運算,得修改 Operation 類別,連帶讓加減乘除被重新編譯,風險高。

解決:把 Operation 抽象化,每種運算各自繼承並覆寫 GetResult

public class Operation
{
    public double NumberA { get; set; }
    public double NumberB { get; set; }
    public virtual double GetResult() => 0;
}

class OperationAdd : Operation
{
    public override double GetResult() => NumberA + NumberB;
}

class OperationDiv : Operation
{
    public override double GetResult()
    {
        if (NumberB == 0) throw new Exception("除數不能為 0。");
        return NumberA / NumberB;
    }
}

修改任一運算不會影響其他運算;新增運算只需擴展子類別。但問題隨之而來:客戶端如何知道該實例化哪一個子類別?

簡單工廠模式#

把「實例化哪一個子類別」這個容易變化的決定,封裝到一個獨立的工廠類別中:

public class OperationFactory
{
    public static Operation createOperate(string operate)
    {
        Operation oper = null;
        switch (operate)
        {
            case "+": oper = new OperationAdd(); break;
            case "-": oper = new OperationSub(); break;
            case "*": oper = new OperationMul(); break;
            case "/": oper = new OperationDiv(); break;
        }
        return oper;
    }
}

客戶端只需傳入運算符號,由工廠決定該回傳哪個子類別實例:

Operation oper = OperationFactory.createOperate("+");
oper.NumberA = 1;
oper.NumberB = 2;
double result = oper.GetResult();

此後若要修改加法,只需改 OperationAdd;新增複雜運算(平方根、立方根、對數、三角函數等)只需新增子類別並在工廠加分支;介面變動則完全不影響運算邏輯。

結構圖#

classDiagram
    class Operation {
        +double NumberA
        +double NumberB
        +GetResult() double
    }
    class OperationAdd {
        +GetResult() double
    }
    class OperationSub {
        +GetResult() double
    }
    class OperationMul {
        +GetResult() double
    }
    class OperationDiv {
        +GetResult() double
    }
    class OperationFactory {
        +createOperate(string) Operation$
    }
    Operation <|-- OperationAdd
    Operation <|-- OperationSub
    Operation <|-- OperationMul
    Operation <|-- OperationDiv
    OperationFactory ..> Operation : creates

UML 類圖速成#

書中順帶介紹 UML(Unified Modeling Language)類圖的基本符號,是後續章節閱讀基礎:

類別與介面#

  • 類別:分三層——類別名稱、特性(欄位/屬性)、操作(方法)
  • 可見性:+ public、- private、# protected
  • 抽象類:類別名稱以斜體顯示
  • 介面:頂端標 <<interface>>,亦可用「棒棒糖表示法」

類別之間的關係#

  • 繼承(Inheritance):空心三角形 + 實線
  • 實現介面(Realization):空心三角形 + 虛線
  • 關聯(Association):實線箭頭,當一個類別「知道」另一個類別
  • 聚合(Aggregation):空心菱形 + 實線箭頭,「擁有」關係,但部分不屬於整體
  • 合成/組合(Composition):實心菱形 + 實線箭頭,嚴格的部分與整體關係,生命週期一致
  • 依賴(Dependency):虛線箭頭,A 需要 B 才能完成功能

連線兩端可標基數(multiplicity),例如鳥有 12 翅膀;不限數量則用 n

本章小結#

編程不只是寫完能跑就好,更要思考如何讓程式碼簡練、易維護、易擴展、易複用

一個小小的計算器,從「面試敗筆」演化到使用簡單工廠模式(Simple Factory Pattern)的優雅實現,正是物件導向設計威力的最佳示範。