本章介紹物件導向程式設計(Object-Oriented Programming, OOP)。OOP 是一種廣為流行的程式設計範式,它將程式碼與資料組織成物件,並透過物件之間的互動來完成任務、模擬真實世界的實體,提供一套有結構的方式來建構靈活、可維護的軟體。
為什麼要學 OOP?#
理解 OOP 的基礎概念(如封裝)與進階設計原則(如 SOLID),是 OOD 面試表現出色的關鍵。OOP 知識能帶給你:
- 清晰的心智模型與基礎能力
- 做出符合業界共識的設計決策的能力
- 與面試官清楚闡述推理過程的詞彙
- 高效解決常見問題的成熟模式
OOD 面試題目常反映真實業務應用與技術元件。掌握 OOP 與 SOLID 不僅讓你準備面試,也能讓你成為更強的開發者。
雖然函數式程式設計(Functional Programming)等其他範式日漸普及,OOP 仍是一般軟體開發的主要支柱。除了本章重點外,建議搭配其他資源深化理解。
OOP 的四大基石#
物件導向程式設計建立在四個基本原則之上:
- 封裝(Encapsulation)
- 抽象(Abstraction)
- 繼承(Inheritance)
- 多型(Polymorphism)
這些原則指引我們如何組織程式碼與設計軟體。其他技巧與設計模式皆源自這些原則,也是評估解法的重要依據。以下逐一以實例說明。
封裝(Encapsulation)#
封裝是將資料(屬性)與邏輯(方法)綁在一起,置入單一單位——物件——的概念。物件的內部狀態對外隱藏,外界只能透過公開方法所提供的明確介面來存取資料。
- 對某類物件的描述稱為類別(class)
- 具體的物件稱為實例(instance)
如何達成封裝?#
要實現封裝,請依循以下步驟:
- 定義類別:從需求中辨識物件,思考它們持有的資料與支援的功能。例如
Person類別包含name、age資料,以及存取與修改它們的功能 - 強制封裝:將類別的資料成員(屬性)宣告為
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類別的name與age為 private,無法從外部直接存取getName()與getAge()為公開 getter,供外部讀取setName()與setAge()為公開 setter,供外部修改
這個實作確保 Person 物件的內部狀態受到保護,外部僅能透過受控方法互動。
何時使用封裝?#
封裝特別適用於以下情境:
- 保護資料完整性:當需要確保物件資料維持一致與有效時。例如
setAge()可強制非負值規則 - 控制存取與提升安全性:限制對敏感資料的直接存取。例如處理密碼類資訊時,封裝是基礎防線之一
- 模組化與重用性:設計能跨應用重用的類別時。
Person的清晰介面讓它能輕鬆嵌入學校管理或社交網路系統
常見陷阱#
封裝雖然強大,但要避免下列錯誤:
- 過度封裝(Over-encapsulation):為每個屬性都加上 getter/setter,會讓程式碼冗長且難維護
- 封裝不足(Under-encapsulation):未隱藏內部細節會導致緊密耦合與低模組性。例如將
name、age設為 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() | 使用 private、public 存取修飾子與方法,例如 Circle 內 private radius 搭配公開 getter/setter |
理解這些差異後,就能用抽象簡化介面、用封裝保護資料,建構出穩健且易用的系統。
繼承(Inheritance)#
繼承讓一個類別(子類別或衍生類別)繼承另一個類別(父類別或基底類別)的屬性與行為,藉此促進程式碼重用,並建立階層式關係。
可以把繼承想像成家族樹:子代繼承父母的特質,孫代又繼承父母與祖父母的特質。子類別可擴充並特化父類別的功能,減少重複程式碼。
常見的類別階層模式#
單一繼承(Single Inheritance)#
子類別只繼承一個父類別。這是 Java 標準支援的繼承形式。
例如 Dog 繼承 Animal,並繼承 eat() 方法。
多層繼承(Multilevel Inheritance)#
子類別繼承自另一個子類別,形成繼承鏈。例如 Animal → Mammal → Dog。
Dog 同時繼承 Animal 與 Mammal 的行為。多層繼承適合具有階層關係且每個子類別在前一階層上特化的情境。
階層繼承(Hierarchical Inheritance)#
多個子類別繼承自同一父類別。例如 Car 與 Motorcycle 都繼承 Vehicle。
當多個類別共享同一父類別的屬性或行為時,階層繼承特別有用。
何時使用繼承?#
- 物件間具有 「is-a」關係(例如
Dog是Animal) - 多個類別共享共同屬性或方法,可由父類別定義一次,子類別繼承
- 類別形成自然階層,例如
Animal是Dog與Cat的父類別
繼承的缺點#
繼承雖促進程式碼重用,但過度使用會使設計變得複雜:
- 緊密耦合(Tight Coupling):子類別嚴重依賴父類別。父類別變動可能破壞所有子類別
- 不適當的行為繼承:強迫子類別繼承不適用的行為。例如把
fly()加到Animal,會假設所有子類別都會飛- 彈性受限:繼承關係在設計時就被鎖定。若後來需要
RobotDog(會吠但不吃飯),它無法乾淨地繼承Animal
要解決這些問題,可考慮組合(Composition)或介面等替代方案。
繼承 vs. 組合#
繼承形成**「is-a」關係(Dog 是 Animal);組合則形成「has-a」**關係(一個類別包含其他物件以提供行為),就像在手機上安裝 app 來執行特定任務。
以 Dog 與 RobotDog 為例:
- 使用繼承:
RobotDog繼承Animal取得bark(),但也得到不適用的eat() - 使用組合:定義
BarkBehavior介面,Dog與RobotDog各自持有一個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 按鈕控制,但內部處理邏輯完全不同。使用者只與統一介面互動,多型則在背後處理變化。
多型的兩種類型#
- 編譯期(靜態)多型
- 執行期(動態)多型
編譯期多型:方法重載(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方法Dog與Cat各自覆寫sound- 執行期 JVM 依物件實際型別呼叫對應方法,即便參考型別是
Animal
何時使用多型?#
- 共享介面:多個類別需要以不同方式執行同一動作,例如各種媒體類型的
play。介面或父類別確保跨實作的一致契約 - 可擴展性:設計能容納新類別而無需更動既有程式碼的系統。新增媒體類型只需實作
play介面 - 客製化:子類別需調整繼承方法的行為,例如
Dog與Cat各自實作sound()
SOLID 設計原則#
除了 OOP 四大基石,你還應熟悉 SOLID 原則。SOLID 提供建構易懂、易改、易擴軟體的指導方針。在 OOD 面試中,能清楚闡述設計決策與其依據是脫穎而出的關鍵。
SOLID 縮寫代表:
以下逐一以實例介紹。
單一職責原則(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 同時承擔兩個職責:
- 計算員工薪資
- 產生薪資報表
這代表類別會因兩個無關的原因而變動——薪資邏輯更新或報表格式調整——讓維護變得困難。
修正方式#
重構以分離關注點:
Employee:管理員工資料(name、salary)並計算年薪PayrollReportGenerator:接受員工資料並產生薪資報表
這樣一來,薪資計算的變動不會影響報表,反之亦然。
最佳實務#
- 為每個類別定義清晰角色,聚焦於單一任務
- 若類別承擔多項任務,重構為多個職責單一的小類別
- 設計使一項任務的變動不會影響其他
開放/封閉原則(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 抽象類別定義共通行為:
Rectangle、Circle等具體形狀繼承Shape並提供各自的面積計算- 新形狀(如三角形)只需新增類別,不需修改
AreaCalculator或既有形狀類別
最佳實務#
- 引入抽象類別或介面,建立可擴充的藍圖
- 允許子類別覆寫方法以提供特定行為
- 透過多型用共同介面或基底類別處理不同類別的物件
里氏替換原則(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 behaviorOstrich 繼承 Bird 但拋出例外,因為鴕鳥不會飛。這破壞了「任何 Bird 都會飛」的期待,使設計不可靠。
修正方式#
重構階層以確保可替換性:
- 將
Bird重新定義為抽象類別,提供更通用的行為(如move),讓所有鳥類都能執行 Sparrow的move描述飛行,Ostrich的move描述在地面奔跑
這樣任何衍生類別都能順利替換 Bird,維持程式正確性。
最佳實務#
- 確保衍生類別維持基底類別的行為相容性
- 覆寫方法時,衍生類別必須遵守基底類別的方法契約:
- 前置條件(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 實作 eat 與 sleep,而這兩者對機器人並不適用,導致出現不被支援的操作。
修正方式#
將介面拆成更小、更貼合需求的介面:
Workable:只包含work,所有 worker 通用Eatable:包含eat,適用於人類Sleepable:包含sleep,僅適用於人類
這樣每個類別只需實作它真正需要的方法。
最佳實務#
- 設計目的明確的介面,僅包含與該目的直接相關的方法
- 建立多個小型介面,由類別各自選擇實作,而非單一龐大介面
- 從實作類別的角度出發,僅提供它們所需的方法
依賴反轉原則(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介面,定義turnOn與turnOff Switch只依賴Switchable,不依賴具體設備LightBulb實作Switchable
如此 Switch 可搭配任何實作 Switchable 的設備(如 Fan、Heater)而無需修改,使系統更彈性、更易擴充。
最佳實務#
- 引入介面或抽象類別作為依賴,讓高階模組僅依賴抽象
- 透過**依賴注入(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