Java 物件導向核心:Class、Interface、繼承與多型
TypeScript 的 interface 編譯完就消失,Java 的卻是執行期真實存在的契約——這篇整理 class、繼承、多型、access modifier,以及 abstract class 跟 interface 到底該用哪個。
系列第二篇。上一篇打了語法跟 JVM 的基礎,這篇進到 Java 真正的核心——物件導向設計。下一篇是 Collections、泛型、例外處理,再下一篇是實作 Spring Boot API 的〈Spring Boot 入門〉。
一、TS 的 class/interface 只是「型別檢查工具」,Java 的是真正的契約
這是從 TS 轉來寫 Java 最容易低估的一個差異。TypeScript 的 interface 編譯完就消失了,執行期完全沒有這個東西存在過的痕跡——它只是編譯器拿來做型別檢查的工具。
interface Animal {
speak(): string;
}
class Dog implements Animal {
speak() { return "Woof"; }
}
// 編譯成 JS 之後,Animal 這個 interface 整個消失,
// 執行期你完全查不到「Dog 有沒有實作 Animal」這件事
Java 的 interface 在執行期是真實存在的型別,instanceof 查得到,反射(reflection)也讀得到:
interface Animal {
String speak();
}
class Dog implements Animal {
public String speak() {
return "Woof";
}
}
Animal a = new Dog();
System.out.println(a instanceof Animal); // true,執行期真的能查
這代表在 Java 裡,interface 不只是給你自己看的型別提示,是整個語言執行期都認得的契約,框架(尤其是 Spring)大量依賴這個特性做動態代理、依賴注入——這也是為什麼 Java 生態這麼強調「針對介面寫程式,不要針對實作寫程式」,不是教條,是因為底層機制真的把 interface 當一等公民對待。
二、Class 基本語法
public class BankAccount {
private String owner; // 欄位(field),private 是慣例預設
private double balance;
// 建構子(constructor):跟 class 同名,沒有回傳型別
public BankAccount(String owner, double initialBalance) {
this.owner = owner;
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("金額必須大於 0");
}
this.balance += amount;
}
public double getBalance() {
return this.balance;
}
}
用法:
BankAccount acc = new BankAccount("Tommy", 1000.0);
acc.deposit(500.0);
System.out.println(acc.getBalance()); // 1500.0
跟 TS 的 class 語法長得很像,但注意 Java 沒有 TS 那種建構子參數直接宣告成員的語法糖(constructor(private owner: string)),欄位要自己先宣告、建構子裡自己 this.owner = owner 賦值,寫起來比較囉唆但也更明確。
三、Access Modifier:Java 從第一天就認真在管這件事
JS 直到 ES2022 才有真正私有的 #field,在那之前「private」全靠 _ 開頭這種君子協定,約束力等於零。Java 從語言設計最初就有完整的存取層級,而且是編譯器強制的:
| Modifier | 同一個 class | 同一個 package | 子類別(不同 package) | 其他任何地方 |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
| (什麼都不寫,package-private) | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
慣例上:欄位(field)幾乎一律 private,靠 public 的 getter/setter 方法對外開放——這叫封裝(encapsulation),好處是你之後想在 deposit() 裡加驗證邏輯(像上面那個 IllegalArgumentException),呼叫方完全不用改,因為他們一開始就沒辦法繞過方法直接改欄位。
四、繼承(Extends)與多型(Polymorphism)
abstract class Shape {
abstract double area(); // 沒有實作,子類別必須自己實作
void printArea() {
System.out.println("Area: " + area());
}
}
class Circle extends Shape {
private double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
double area() {
return Math.PI * radius * radius;
}
}
class Rectangle extends Shape {
private double width, height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
double area() {
return width * height;
}
}
多型的重點在這裡:
Shape[] shapes = { new Circle(2), new Rectangle(3, 4) };
for (Shape s : shapes) {
s.printArea(); // 呼叫的是 printArea,但 area() 會依照實際物件型別執行對應的版本
}
shapes 陣列型別宣告成 Shape,但每個元素在執行期實際是 Circle 或 Rectangle,呼叫 area() 時 JVM 會依照物件的實際型別(不是宣告型別)決定要跑哪個版本——這就是多型,概念上跟 JS 的 prototype chain 找方法有點像,但 Java 是編譯期就檢查過方法簽名一定存在,只有「跑哪個版本」是執行期才決定。
五、Abstract Class vs Interface:什麼時候用哪個
這是新手常問的問題,判斷方式其實不複雜:
| Abstract Class | Interface | |
|---|---|---|
| 能不能有欄位(state) | 可以 | 不行(Java 8 以後可以有 static/default 方法,但沒有實例欄位) |
| 能不能有建構子 | 可以 | 不行 |
| 一個 class 能繼承幾個 | 只能一個(extends) | 可以實作多個(implements A, B, C) |
| 語意 | "is-a",而且共享一部分實作邏輯 | "can-do",純粹描述能力/契約 |
經驗法則:如果子類別之間需要共用一些狀態或已經寫好的邏輯,用 abstract class;如果只是想描述「這個東西必須能做什麼事」,不在乎怎麼做到,用 interface。上面的 Shape 例子用 abstract class 是因為 printArea() 這個共用邏輯值得放在父類別;如果只是想說「這個東西可以計算面積」,不需要共用實作,寫成 interface Measurable { double area(); } 會更乾淨,而且不會因為「Java 一個 class 只能繼承一個父類別」這個限制卡住之後想加別的能力。
下一篇會接著講 Collections(List/Map/Set)、泛型,還有 Java 特有、JS 完全沒有對應概念的 checked exception——這些是每天寫 Java 都會直接碰到的東西。