本章介紹物件導向程式設計(Object-Oriented Programming, OOP)。OOP 是一種廣為流行的程式設計範式,它將程式碼與資料組織成物件,並透過物件之間的互動來完成任務、模擬真實世界的實體,提供一套有結構的方式來建構靈活、可維護的軟體。

為什麼要學 OOP?#

理解 OOP 的基礎概念(如封裝)與進階設計原則(如 SOLID),是 OOD 面試表現出色的關鍵。OOP 知識能帶給你:

  • 清晰的心智模型與基礎能力
  • 做出符合業界共識的設計決策的能力
  • 與面試官清楚闡述推理過程的詞彙
  • 高效解決常見問題的成熟模式

OOD 面試題目常反映真實業務應用與技術元件。掌握 OOP 與 SOLID 不僅讓你準備面試,也能讓你成為更強的開發者。

雖然函數式程式設計(Functional Programming)等其他範式日漸普及,OOP 仍是一般軟體開發的主要支柱。除了本章重點外,建議搭配其他資源深化理解。

OOP 的四大基石#

物件導向程式設計建立在四個基本原則之上:

  1. 封裝(Encapsulation)
  2. 抽象(Abstraction)
  3. 繼承(Inheritance)
  4. 多型(Polymorphism)

Cornerstones of OOP

這些原則指引我們如何組織程式碼與設計軟體。其他技巧與設計模式皆源自這些原則,也是評估解法的重要依據。以下逐一以實例說明。

封裝(Encapsulation)#

封裝是將資料(屬性)與邏輯(方法)綁在一起,置入單一單位——物件——的概念。物件的內部狀態對外隱藏,外界只能透過公開方法所提供的明確介面來存取資料。

  • 對某類物件的描述稱為類別(class)
  • 具體的物件稱為實例(instance)

如何達成封裝?#

要實現封裝,請依循以下步驟:

  • 定義類別:從需求中辨識物件,思考它們持有的資料與支援的功能。例如 Person 類別包含 nameage 資料,以及存取與修改它們的功能
  • 強制封裝:將類別的資料成員(屬性)宣告為 private,限制外部直接存取,再提供公開的 getter 與 setter 方法
  • 使用存取修飾子private 限制外部直接存取屬性;公開方法則作為與外界互動的介面,藏起內部實作細節並維護物件的完整性

Java 範例#

public class Person {
    // Private data members (attributes)
    private String name;
    private int age;

    // Public constructor
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Public getter methods (accessors)
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    // Public setter methods (mutators)
    public void setName(String name) {
        this.name = name;
    }

    public void setAge(int age) {
        if (age >= 0) {
            this.age = age;
        }
    }
}

說明:

  • Person 類別的 nameage 為 private,無法從外部直接存取
  • getName()getAge() 為公開 getter,供外部讀取
  • setName()setAge() 為公開 setter,供外部修改

這個實作確保 Person 物件的內部狀態受到保護,外部僅能透過受控方法互動。

何時使用封裝?#

封裝特別適用於以下情境:

  • 保護資料完整性:當需要確保物件資料維持一致與有效時。例如 setAge() 可強制非負值規則
  • 控制存取與提升安全性:限制對敏感資料的直接存取。例如處理密碼類資訊時,封裝是基礎防線之一
  • 模組化與重用性:設計能跨應用重用的類別時。Person 的清晰介面讓它能輕鬆嵌入學校管理或社交網路系統

常見陷阱#

封裝雖然強大,但要避免下列錯誤:

  • 過度封裝(Over-encapsulation):為每個屬性都加上 getter/setter,會讓程式碼冗長且難維護
  • 封裝不足(Under-encapsulation):未隱藏內部細節會導致緊密耦合與低模組性。例如將 nameage 設為 public,外部就能任意修改,造成不一致

抽象(Abstraction)#

抽象透過隱藏不必要的細節來簡化複雜系統。它將物件的「做什麼(what)」與「怎麼做(how)」分離,讓使用者透過簡化的介面與物件互動。

例如,電視遙控器上的音量鍵讓你輕鬆調整音量,而無需了解內部電路。在程式中,抽象透過**抽象類別(Abstract Class)介面(Interface)**來實現。

如何達成抽象?#

定義抽象類別或介面,宣告未實作的抽象方法,由子類別實作。這讓使用者呼叫方法時無需了解內部細節。

Java 範例#

// Abstract class
abstract class Shape {
    protected String color;

    public Shape(String color) {
        this.color = color;
    }

    // Abstract method
    public abstract double area();

    // Concrete method
    public void displayColor() {
        System.out.println("This shape is " + color + ".");
    }
}

// Interface
interface Drawable {
    void draw();
}

// Concrete class implementing Shape and Drawable
class Circle extends Shape implements Drawable {
    private double radius;

    public Circle(String color, double radius) {
        super(color);
        this.radius = radius;
    }

    // Implementing abstract method from Shape
    @Override
    public double area() {
        return Math.PI * radius * radius;
    }

    // Implementing method from Drawable interface
    @Override
    public void draw() {
        System.out.println("Drawing a circle.");
    }
}

實作展現抽象的方式:

  • Shape 抽象類別宣告抽象方法 area(),由子類別實作;同時提供具體方法 displayColor() 作為預設行為
  • Drawable 介面宣告 draw() 方法,由實作類別定義
  • Circle 繼承 Shape 並實作 Drawable,提供 area()draw() 的具體邏輯

這個結構讓使用者透過 area()draw() 等高階方法操作形狀,無需了解背後邏輯。

何時使用抽象?#

  • 簡化複雜系統:提供一致、乾淨的介面。例如 area() 讓使用者免於計算細節
  • 促進程式碼彈性:當預期子類別會提供具體實作時。Shape 的抽象 area() 確保不同形狀都能各自實作
  • 支援可擴展性:新增 Triangle 只需實作 area(),無需更動既有程式碼

抽象 vs. 封裝#

抽象與封裝是兩個獨立但互補的 OOP 原則,常被混淆,因為兩者都涉及隱藏細節。差異如下:

特性抽象(Abstraction)封裝(Encapsulation)
焦點透過簡化介面暴露物件「做什麼」,隱藏「怎麼做」將資料與方法綁在類別中,並透過限制存取保護資料
目的簡化使用者互動,藉由定義高階行為促進彈性透過控制資料存取,確保資料完整性與可維護性
實作使用抽象類別與介面,例如 Shape 抽象類別的 area()Drawable 介面的 draw()使用 privatepublic 存取修飾子與方法,例如 Circleprivate radius 搭配公開 getter/setter

理解這些差異後,就能用抽象簡化介面、用封裝保護資料,建構出穩健且易用的系統。

繼承(Inheritance)#

繼承讓一個類別(子類別衍生類別)繼承另一個類別(父類別基底類別)的屬性與行為,藉此促進程式碼重用,並建立階層式關係。

可以把繼承想像成家族樹:子代繼承父母的特質,孫代又繼承父母與祖父母的特質。子類別可擴充並特化父類別的功能,減少重複程式碼。

常見的類別階層模式#

單一繼承(Single Inheritance)#

子類別只繼承一個父類別。這是 Java 標準支援的繼承形式。

Single inheritance

例如 Dog 繼承 Animal,並繼承 eat() 方法。

多層繼承(Multilevel Inheritance)#

子類別繼承自另一個子類別,形成繼承鏈。例如 AnimalMammalDog

Multilevel inheritance

Dog 同時繼承 AnimalMammal 的行為。多層繼承適合具有階層關係且每個子類別在前一階層上特化的情境。

階層繼承(Hierarchical Inheritance)#

多個子類別繼承自同一父類別。例如 CarMotorcycle 都繼承 Vehicle

Hierarchical inheritance

當多個類別共享同一父類別的屬性或行為時,階層繼承特別有用。

何時使用繼承?#

  • 物件間具有 「is-a」關係(例如 DogAnimal
  • 多個類別共享共同屬性或方法,可由父類別定義一次,子類別繼承
  • 類別形成自然階層,例如 AnimalDogCat 的父類別

繼承的缺點#

繼承雖促進程式碼重用,但過度使用會使設計變得複雜:

  • 緊密耦合(Tight Coupling):子類別嚴重依賴父類別。父類別變動可能破壞所有子類別
  • 不適當的行為繼承:強迫子類別繼承不適用的行為。例如把 fly() 加到 Animal,會假設所有子類別都會飛
  • 彈性受限:繼承關係在設計時就被鎖定。若後來需要 RobotDog(會吠但不吃飯),它無法乾淨地繼承 Animal

要解決這些問題,可考慮組合(Composition)介面等替代方案。

繼承 vs. 組合#

繼承形成**「is-a」關係(DogAnimal);組合則形成「has-a」**關係(一個類別包含其他物件以提供行為),就像在手機上安裝 app 來執行特定任務。

DogRobotDog 為例:

  • 使用繼承RobotDog 繼承 Animal 取得 bark(),但也得到不適用的 eat()
  • 使用組合:定義 BarkBehavior 介面,DogRobotDog 各自持有一個 BarkBehavior 物件(如 DogBark 印「Woof!」、RobotBark 印「Beep!」),讓 RobotDog 能吠但不繼承 eat()
interface BarkBehavior {
    void bark();
}

class DogBark implements BarkBehavior {
    public void bark() {
        System.out.println("Woof!");
    }
}

class RobotBark implements BarkBehavior {
    public void bark() {
        System.out.println("Beep!");
    }
}

class Dog {
    private BarkBehavior barkBehavior;

    public Dog(BarkBehavior barkBehavior) {
        this.barkBehavior = barkBehavior;
    }

    public void bark() {
        barkBehavior.bark();
    }
}

class RobotDog {
    private BarkBehavior barkBehavior;

    public RobotDog(BarkBehavior barkBehavior) {
        this.barkBehavior = barkBehavior;
    }

    public void bark() {
        barkBehavior.bark();
    }
}

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog(new DogBark());
        RobotDog robotDog = new RobotDog(new RobotBark());
        dog.bark(); // Output: Woof!
        robotDog.bark(); // Output: Beep!
    }
}

何時使用組合?#

在 OOD 面試中,當需要彈性或鬆耦合時優先使用組合。例如解釋 RobotDog 透過 BarkBehavior 避開繼承的緊密耦合。可以提及繼承在簡單階層中的適用性,但同時點出其缺點。

選擇指南:

  • 設計面:穩定且共享行為的「is-a」關係用繼承;「has-a」或需可替換行為時用組合,更易修改與維護
  • 面試策略:強調為什麼選擇某種途徑,並能說明取捨

接著看多型如何在這些概念之上建構更靈活的行為。

多型(Polymorphism)#

多型是指物件能以多種形式存在,或在不同情境下表現出不同行為,但都透過共同介面進行互動。它讓你能在不修改既有程式碼的前提下加入新行為。

以媒體播放器為例:音訊、影片、串流內容都用同一個渲染元件播放、由同一個 play 按鈕控制,但內部處理邏輯完全不同。使用者只與統一介面互動,多型則在背後處理變化。

多型的兩種類型#

  1. 編譯期(靜態)多型
  2. 執行期(動態)多型
編譯期多型:方法重載(Method Overloading)#

方法重載允許同一類別擁有多個同名方法,但參數不同。編譯器在編譯期根據傳入引數的數量與型別決定呼叫哪個方法。

class MathOperations {
    public int add(int a, int b) {
        return a + b;
    }

    public double add(double a, double b) {
        return a + b;
    }

    public String add(String str1, String str2) {
        return str1 + str2;
    }
}

public class Main {
    public static void main(String[] args) {
        MathOperations math = new MathOperations();

        int sum1 = math.add(5, 10);
        double sum2 = math.add(3.5, 7.2);
        String result = math.add("Hello, ", "World!");

        System.out.println("Sum of integers: " + sum1);
        System.out.println("Sum of doubles: " + sum2);
        System.out.println("Concatenated string: " + result);
    }
}

說明:

  • MathOperations 定義多個 add 方法,各自接受不同型別或數量的參數
  • 編譯器依引數選擇對應的 add,使用單一名稱代表相關操作,提升可讀性
執行期多型:方法覆寫(Method Overriding)#

方法覆寫發生在子類別為父類別已定義的方法提供具體實作時。實際呼叫的方法在執行期由物件的真實型別決定,而非參考型別。這稱為動態分派(Dynamic Dispatch),是執行期多型的典型特徵。

class Animal {
    public void sound() {
        System.out.println("Animal makes a sound.");
    }
}

class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("Dog barks: Woof!");
    }
}

class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("Cat meows: Meow!");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal animal1 = new Dog();
        Animal animal2 = new Cat();

        animal1.sound(); // Dog's sound() method is called
        animal2.sound(); // Cat's sound() method is called
    }
}

說明:

  • Animal 定義通用 sound 方法
  • DogCat 各自覆寫 sound
  • 執行期 JVM 依物件實際型別呼叫對應方法,即便參考型別是 Animal

何時使用多型?#

  • 共享介面:多個類別需要以不同方式執行同一動作,例如各種媒體類型的 play。介面或父類別確保跨實作的一致契約
  • 可擴展性:設計能容納新類別而無需更動既有程式碼的系統。新增媒體類型只需實作 play 介面
  • 客製化:子類別需調整繼承方法的行為,例如 DogCat 各自實作 sound()

SOLID 設計原則#

除了 OOP 四大基石,你還應熟悉 SOLID 原則。SOLID 提供建構易懂、易改、易擴軟體的指導方針。在 OOD 面試中,能清楚闡述設計決策與其依據是脫穎而出的關鍵。

SOLID 縮寫代表:

SOLID Principles

以下逐一以實例介紹。

單一職責原則(SRP)#

「S」代表單一職責原則(Single Responsibility Principle, SRP):一個類別應該只有一個變動的理由——換言之,它應該只承擔一個明確定義的職責。

違反 SRP 的範例#

class Employee {
    private String name;
    private double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    public double calculateSalary() {
        return salary * 12; // Annual salary
    }

    public void generatePayrollReport() {
        System.out.println("Payroll Report for " + name + ": $" + salary * 12);
    }
}

Employee 同時承擔兩個職責:

  1. 計算員工薪資
  2. 產生薪資報表

這代表類別會因兩個無關的原因而變動——薪資邏輯更新或報表格式調整——讓維護變得困難。

修正方式#

重構以分離關注點:

  • Employee:管理員工資料(name、salary)並計算年薪
  • PayrollReportGenerator:接受員工資料並產生薪資報表

這樣一來,薪資計算的變動不會影響報表,反之亦然。

Single Responsibility Principle (SRP)

最佳實務#

  • 為每個類別定義清晰角色,聚焦於單一任務
  • 若類別承擔多項任務,重構為多個職責單一的小類別
  • 設計使一項任務的變動不會影響其他

開放/封閉原則(OCP)#

開放/封閉原則(Open/Closed Principle, OCP)主張軟體實體(如類別)應該對擴充開放,但對修改封閉。亦即,新增功能時不應更動既有程式碼。

違反 OCP 的範例#

class Rectangle {
    private double width;
    private double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public double calculateArea() {
        return width * height;
    }
}

class AreaCalculator {
    public double calculateArea(Rectangle rectangle) {
        return rectangle.calculateArea();
    }
}

AreaCalculator 只能處理 Rectangle。若要支援新形狀(如圓形或三角形)就必須改它的程式碼,難以維護又容易出錯。

修正方式#

引入 Shape 抽象類別定義共通行為:

  • RectangleCircle 等具體形狀繼承 Shape 並提供各自的面積計算
  • 新形狀(如三角形)只需新增類別,不需修改 AreaCalculator 或既有形狀類別

Open/Closed Principle (OCP)

最佳實務#

  • 引入抽象類別或介面,建立可擴充的藍圖
  • 允許子類別覆寫方法以提供特定行為
  • 透過多型用共同介面或基底類別處理不同類別的物件

里氏替換原則(LSP)#

**里氏替換原則(Liskov Substitution Principle, LSP)**主張:衍生類別的物件應能取代基底類別的物件,而不影響程式正確性。若 A 是 B 的子型別,B 的實例應可被 A 的實例替換而不出問題。

違反 LSP 的範例#

class Bird {
    public void fly() {
        System.out.println("Flying in the sky.");
    }
}

class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Ostriches cannot fly.");
    }
}

// Program calls bird.fly() to test bird behavior

Ostrich 繼承 Bird 但拋出例外,因為鴕鳥不會飛。這破壞了「任何 Bird 都會飛」的期待,使設計不可靠。

修正方式#

重構階層以確保可替換性:

  • Bird 重新定義為抽象類別,提供更通用的行為(如 move),讓所有鳥類都能執行
  • Sparrowmove 描述飛行,Ostrichmove 描述在地面奔跑

這樣任何衍生類別都能順利替換 Bird,維持程式正確性。

Liskov Substitution Principle (LSP)

最佳實務#

  • 確保衍生類別維持基底類別的行為相容性
  • 覆寫方法時,衍生類別必須遵守基底類別的方法契約:
    • 前置條件(Preconditions):衍生類別方法的前置條件應與基底類別相同或更寬鬆
    • 後置條件(Postconditions):衍生類別方法的後置條件應與基底類別相同或更嚴格
    • 不變式(Invariants):衍生類別必須維持基底類別的所有不變式
  • 透過多型讓衍生類別物件取代基底類別物件,覆寫方法以提供特化行為的同時維持核心功能

介面隔離原則(ISP)#

「I」代表介面隔離原則(Interface Segregation Principle, ISP):客戶端不應被迫依賴它們不使用的介面。介面應該只包含與實作類別相關的特定且聚焦的方法。

違反 ISP 的範例#

interface Worker {
    void work();

    void eat();

    void sleep();
}

class Robot implements Worker {
    public void work() {
        System.out.println("Performing tasks like welding.");
    }

    public void eat() {
        throw new UnsupportedOperationException("Robots don't eat.");
    }

    public void sleep() {
        throw new UnsupportedOperationException("Robots don't sleep.");
    }
}

class Human implements Worker {
    public void work() {
        System.out.println("Performing tasks like coding.");
    }

    public void eat() {
        System.out.println("Eating a meal.");
    }

    public void sleep() {
        System.out.println("Sleeping for rest.");
    }
}

Worker 強迫 Robot 實作 eatsleep,而這兩者對機器人並不適用,導致出現不被支援的操作。

修正方式#

將介面拆成更小、更貼合需求的介面:

  • Workable:只包含 work,所有 worker 通用
  • Eatable:包含 eat,適用於人類
  • Sleepable:包含 sleep,僅適用於人類

這樣每個類別只需實作它真正需要的方法。

Interface Segregation Principle (ISP)

最佳實務#

  • 設計目的明確的介面,僅包含與該目的直接相關的方法
  • 建立多個小型介面,由類別各自選擇實作,而非單一龐大介面
  • 從實作類別的角度出發,僅提供它們所需的方法

依賴反轉原則(DIP)#

依賴反轉原則(Dependency Inversion Principle, DIP)主張:高階模組不應依賴低階模組,兩者都應依賴抽象(如介面)。這個原則鼓勵以抽象介面解耦高階元件與低階細節。

違反 DIP 的範例#

class LightBulb {
    public void turnOn() {
        System.out.println("LightBulb is on.");
    }

    public void turnOff() {
        System.out.println("LightBulb is off.");
    }
}

class Switch {
    private LightBulb bulb;

    public Switch(LightBulb bulb) {
        this.bulb = bulb;
    }

    public void operate() {
        bulb.turnOn();
    }
}

Switch 直接依賴低階的 LightBulb,而非抽象。這種緊密耦合使得替換 LightBulb 為其他設備(如 Fan)必須改動 Switch 的程式碼。

修正方式#

引入抽象:

  • 引入 Switchable 介面,定義 turnOnturnOff
  • Switch 只依賴 Switchable,不依賴具體設備
  • LightBulb 實作 Switchable

如此 Switch 可搭配任何實作 Switchable 的設備(如 FanHeater)而無需修改,使系統更彈性、更易擴充。

Dependency Inversion Principle (DIP)

最佳實務#

  • 引入介面或抽象類別作為依賴,讓高階模組僅依賴抽象
  • 透過**依賴注入(Dependency Injection)**將具體實作注入高階模組,促進鬆耦合

本章小結#

本章涵蓋了封裝抽象繼承多型以及 SOLID 原則。這些概念構成穩健軟體設計的骨幹,讓你能建構靈活、可維護且可擴展的系統。善用這些工具,將幫助你清楚闡述設計決策、佐證選擇,並展現對業界標準實務的掌握。

想進一步精進,建議閱讀以下資源:

  • Clean Code: A Handbook of Agile Software Craftsmanship,Robert C. Martin
  • Design Patterns: Elements of Reusable Object-Oriented Software,Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides
  • Tidy First?: A Personal Exercise in Empirical Software Design,Kent Beck