Java 類別與物件:從一張會員卡看懂 OOP-first 的世界觀
用一個健身房會員系統,親手把類別、物件、建構子、this、封裝與 package 串起來,並從參考語意與 equals/hashCode 契約理解 Java 與其他語言的根本差異。
從一張會員卡開始:為什麼 Java 一定要先有「類別」
想像你要替一間健身房寫一個會員管理程式。每位會員都有姓名、會員編號、剩餘點數,還能做「儲值」「扣點」這些動作。如果你寫過 Python,你可能會直覺地先開一個函式、開幾個變數,邊寫邊長出結構。但在 Java,你連印出一個「Hello」都得先把它放進一個 class 裡——程式碼沒有類別就無處安身。
這不是 Java 在為難你,而是它的世界觀:萬物皆在類別之中(everything lives in a class)。Java 是一門 OOP-first 的語言,物件導向不是「可選風格」,而是語言的地基。相較於 Python 允許你寫純函式腳本、把物件導向當作進階選項,Java 從第一行就要你用「類別」來組織想法。這篇文章帶你親手把那張會員卡,從一個概念變成一個能在 JVM 上跑起來的物件。

類別是藍圖,物件是蓋出來的房子
先釐清兩個最常被混淆的詞。類別(class)是一份藍圖、一個型別(type)的定義;物件(object)是依藍圖造出來、實際存在於記憶體裡的個體,也常被稱為「實例(instance)」。
一份藍圖可以蓋出無數棟房子。Member 這個類別只有一份,但你可以 new 出成千上萬個會員物件,每個物件各自帶著自己的姓名與點數。
public class Member {
String name; // 欄位(field):每個物件各自擁有的狀態
int points;
}
把這份藍圖實例化(instantiate)成物件,要用 new 關鍵字:
Member alice = new Member(); // 在堆積上配置一個 Member 物件
alice.name = "Alice";
alice.points = 100;
這裡有個 Java 與 Python 的關鍵差異:Member alice 這段宣告了變數 alice 的型別就是 Member。Java 是靜態強型別(statically & strongly typed)語言——每個變數的型別在編譯期就固定,編譯器會在你執行前就抓出「把字串塞進 int」這類錯誤。Python 則是動態型別,型別要到執行時才確定。靜態型別讓 Java 在大型專案、多人協作、企業系統裡格外吃香,因為很多錯誤不必等到上線才爆炸。
欄位與方法:物件的狀態與行為
一個物件由兩部分組成:欄位(field)存放狀態(state),方法(method)定義行為(behavior)。我們替 Member 加上儲值與扣點的能力:
public class Member {
String name;
int points;
// 方法:對「這個物件自己的」欄位進行操作
void topUp(int amount) {
points += amount;
}
boolean spend(int cost) {
if (cost > points) {
return false; // 點數不足,交易失敗
}
points -= cost;
return true;
}
}
方法寫在類別大括號內,預設就能直接存取同一個物件的欄位。注意 void topUp(...) 前面的 void 與 boolean spend(...) 前面的 boolean——這是回傳型別(return type)。Java 要求你明確寫出方法回傳什麼型別;void 代表「不回傳值」。這又是靜態型別的體現:相較於 Python 的 def 不必宣告回傳型別,Java 把契約寫得更死,但也更清楚。
建構子:物件誕生那一刻
前面的寫法有個尷尬:先 new 出一個空物件,再一行一行手動填欄位。萬一忘了填 name,就會得到一個半成品物件。建構子(constructor)就是為了解決這個問題——它是物件被建立時自動執行的特殊方法,負責把物件初始化到「一出生就可用」的狀態。
public class Member {
String name;
int points;
// 建構子:方法名稱必須與類別同名,且「沒有」回傳型別
Member(String name, int points) {
this.name = name;
this.points = points;
}
}
現在建立物件變得乾淨又安全:
Member bob = new Member("Bob", 50); // 一次到位,不會有半成品
建構子有兩個鐵則:名稱與類別完全相同、不寫任何回傳型別(連 void 都不寫)。如果你不寫建構子,Java 會偷偷給你一個無參數的「預設建構子」;但只要你自己寫了任何一個建構子,那個免費的預設建構子就消失了。這是初學者常踩的雷,後面會再提。
this:到底是「哪一個」物件?
在上面的建構子裡,你看到了 this.name = name;。this 是一個指向「目前這個物件自己」的參考(reference)。
為什麼需要它?因為建構子的參數叫 name,欄位也叫 name,發生了名稱遮蔽(shadowing)。此時光寫 name = name 會變成「參數指派給參數自己」,欄位永遠不會被更新。用 this.name 明確表示「我要的是這個物件的欄位」,把參數值正確寫進去。
Member(String name, int points) {
this.name = name; // this.name 是欄位;右邊的 name 是參數
this.points = points;
}
當方法被呼叫時,例如 bob.topUp(30),在 topUp 內部的 this 就指向 bob;換成 alice.topUp(30),this 就指向 alice。同一份方法程式碼,靠 this 區分「現在操作的是哪一個物件」。這跟 Python 把第一個參數 self 顯式寫進方法簽章不同:Java 的 this 是隱含存在的,你不必在參數列宣告它,需要時直接用即可。
封裝:把欄位藏起來,只開一扇門
到目前為止,Member 的欄位是「裸露」的——任何人都能寫 bob.points = -9999; 把點數改成負數,物件的狀態瞬間失去意義。這違反了物件導向的核心原則之一:封裝(encapsulation)。
封裝的做法是:把欄位設為 private(外部不可直接存取),只透過受控的方法對外開放讀寫,這些方法慣例上叫 getter 與 setter。
public class Member {
private String name; // private:外部碰不到
private int points;
public Member(String name, int points) {
this.name = name;
this.points = points >= 0 ? points : 0; // 在入口就守住規則
}
// getter:唯讀對外開放
public String getName() {
return name;
}
public int getPoints() {
return points;
}
// setter:寫入時可以加驗證,擋掉非法值
public void setPoints(int points) {
if (points < 0) {
throw new IllegalArgumentException("點數不可為負");
}
this.points = points;
}
}
加上 private 後,外部程式再也不能亂改點數,只能走 setPoints 這扇門,而門口有警衛(驗證邏輯)。public、private 這些叫存取修飾子(access modifier),它們決定誰能看見、誰能碰。封裝的價值在於:內部實作可以隨時改,只要對外那扇門的形狀不變,外部程式完全不受影響。
這裡又能對比 Python:Python 沒有真正的 private,靠的是 _name 這種「命名約定」表達「請別亂碰」,但語言層級攔不住你。Java 的 private 是編譯器強制執行的硬規則,這也是企業級程式碼偏好 Java 的理由之一——規則由語言守,不靠君子協定。
動手寫一段:完整可執行的會員系統
把上面所有概念組裝成一支能跑的程式。注意 Java 的進入點固定是 public static void main(String[] args):
public class MemberDemo {
public static void main(String[] args) {
Member alice = new Member("Alice", 100);
Member bob = new Member("Bob", 50);
alice.topUp(30); // Alice: 100 + 30 = 130
boolean ok = bob.spend(60); // Bob 想花 60,但只有 50
System.out.println(alice.getName() + " 的點數:" + alice.getPoints());
System.out.println(bob.getName() + " 扣點是否成功:" + ok);
System.out.println(bob.getName() + " 的點數:" + bob.getPoints());
}
}
class Member {
private String name;
private int points;
public Member(String name, int points) {
this.name = name;
this.points = points >= 0 ? points : 0;
}
public void topUp(int amount) {
points += amount;
}
public boolean spend(int cost) {
if (cost > points) {
return false;
}
points -= cost;
return true;
}
public String getName() {
return name;
}
public int getPoints() {
return points;
}
}
// 輸出:
// Alice 的點數:130
// Bob 扣點是否成功:false
// Bob 的點數:50
把這段存成 MemberDemo.java,用 javac MemberDemo.java 編譯(產生 .class 位元組碼),再用 java MemberDemo 執行。你會看到 alice 與 bob 各自記得自己的點數,互不干擾——這就是「一份藍圖、多個獨立物件」的實感。
package:替類別找一個家
當專案長大,幾百個類別擠在同一個資料夾會亂成一團,而且兩個人都想叫某個類別 Member 時就會撞名。package(套件)是 Java 的命名空間與檔案組織機制,用來把相關類別歸類,並避免名稱衝突。
package com.uedu.gym; // 必須是檔案的第一行程式碼
public class Member {
// ...
}
package 名稱對應資料夾結構:com.uedu.gym.Member 這個類別,原始檔就放在 com/uedu/gym/Member.java。業界慣例是用反轉的網域名稱開頭(如 com.uedu.*)來確保全球唯一。要在別的 package 用到它,就 import:
import com.uedu.gym.Member;
相較於 Python 用「模組即檔案、套件即資料夾」的較鬆散方式,Java 的 package 與目錄結構是強制對應的,編譯器會檢查。這份嚴謹是 Java 龐大企業生態(Spring、Maven 等)能穩定運作的基礎。
常見錯誤
初學 Java 物件導向時,這幾個雷幾乎人人踩過,先打預防針:
-
以為宣告變數就等於建立了物件。
Member m;只是宣告了一個型別為Member的參考變數,它此刻是null,並沒有任何物件。一定要m = new Member(...)才真的造出物件,否則對null呼叫方法會丟NullPointerException。 -
建構子忘了
this,導致欄位永遠是預設值。 寫成name = name;時,因為名稱遮蔽,你只是把參數指派給自己,欄位name始終是null。記得用this.name = name;。 -
自訂建構子後,忘了預設建構子已經消失。 一旦你寫了
Member(String, int),那個免費的無參數Member()就不存在了。若你還需要它,得自己再補一個Member() {}。 -
誤把建構子寫上回傳型別。 寫成
void Member(...) {}的瞬間,它就不再是建構子,而退化成一個剛好同名的普通方法,new Member(...)會找不到對應建構子而編譯失敗。 -
把欄位全開成
public圖方便。 這等於拆掉封裝的門,任何人都能把物件改成非法狀態。慣例是欄位private、透過方法控管存取。
重點回顧
- 類別是藍圖(型別定義),物件是
new出來、活在記憶體中的實例。 - 欄位存狀態、方法定行為;
this指向「目前這個物件自己」。 - 建構子與類別同名、不寫回傳型別,負責讓物件一誕生就處於合法狀態。
- 封裝=欄位
private+ 受控的 getter/setter;Java 的private是語言強制,不是約定。 - package 提供命名空間並與目錄結構強制對應。
深入探討(研究所視角)
到這裡你已經會用 Java 寫物件了。但若要真正理解這門語言,得往下挖一層,看物件在記憶體裡長什麼樣、變數到底裝了什麼。
物件配置在堆積,變數握的是參考
當你寫 Member bob = new Member("Bob", 50);,背後其實發生了兩件事,分別動到兩塊記憶體區域:
- 堆積(heap):
new在堆積上配置一塊空間,放置真正的Member物件(它的name、points等欄位資料就住在這裡)。 - 堆疊(stack):區域變數
bob本身放在當前方法的呼叫堆疊框(stack frame)裡,它不是物件本體,而是一個參考(reference)——你可以把它想成一個指向堆積位址的「把手」。
這就是 Java 的參考語意(reference semantics)。Java 中的變數,凡是物件型別(非 int、double 等基本型別 primitive),裝的都是參考而非物件本身。理解這點,下面這個經典陷阱就不再神祕:
Member a = new Member("Amy", 100);
Member b = a; // 複製的是「參考」,不是物件!a 與 b 指向同一個物件
b.topUp(50);
System.out.println(a.getPoints()); // 輸出:150(a 也變了,因為根本是同一個物件)
b = a 只複製了把手,沒有複製房子。a 和 b 握著同一塊堆積記憶體的兩個把手,透過任一個修改都會反映在另一個上。相較之下,基本型別(int x = 5; int y = x;)複製的是值本身,y 改了不影響 x。
物件不再被任何參考指向時(例如把 a、b 都設成 null 或它們離開作用域),這塊堆積空間就成為垃圾。Java 的自動記憶體回收(garbage collection, GC)會在適當時機回收它——這是 Java 相對於 C/C++ 的一大解放:你不必手動 free,也因此避開了一整類「忘記釋放」「重複釋放」「懸空指標」的記憶體錯誤。代價是 GC 會在背景花費運算資源,且回收時機不完全由你掌控。
== 比的是把手,equals 才比內容
既然變數握的是參考,== 運算子對物件比較的就是「兩個把手是否指向同一塊堆積位址」,也就是同一性(identity),而不是「內容是否相等」。
Member x = new Member("Amy", 100);
Member y = new Member("Amy", 100);
System.out.println(x == y); // 輸出:false(兩個不同物件,位址不同)
兩個會員資料一模一樣,但它們是堆積上兩棟不同的房子,== 自然是 false。要比較「邏輯上是否相等」,必須覆寫(override)equals 方法。而 Java 規定:只要你覆寫 equals,就必須一起覆寫 hashCode,這就是著名的 equals/hashCode 契約(contract)。
契約的核心規則是:若 a.equals(b) 為 true,則 a.hashCode() 必須等於 b.hashCode()(反之不必然,兩個不相等的物件允許湊巧同雜湊值,稱為碰撞 collision)。
為什麼這條契約如此重要?因為 HashMap、HashSet 這些以雜湊為基礎的集合,存取流程是先用 hashCode 定位到某個桶(bucket),再用 equals 在桶內逐一比對。它們依賴 hashCode 把相等的物件導向同一個桶。若你只改 equals 而不改 hashCode,兩個「相等」的物件可能算出不同雜湊值、被丟進不同的桶,HashSet 就會誤判它們不重複,集合的去重與查找邏輯整個壞掉。
import java.util.Objects;
public class Member {
private String name;
private int points;
// ... 建構子與 getter 省略 ...
@Override
public boolean equals(Object o) {
if (this == o) return true; // 同一物件,快速通過
if (o == null || getClass() != o.getClass()) return false;
Member other = (Member) o;
return points == other.points
&& Objects.equals(name, other.name); // 逐欄位比對
}
@Override
public int hashCode() {
return Objects.hash(name, points); // 用相同欄位算雜湊,保證契約
}
}
用 Objects.equals 與 Objects.hash 是慣例做法——它們會妥善處理 null,並確保 equals 與 hashCode 基於「相同的欄位集合」計算,這正是維持契約的關鍵。正確的雜湊讓 HashMap 的平均查找維持在 $O(1)$;若雜湊設計糟糕、大量碰撞退化成一條鏈,最壞情況會掉到 $O(n)$(現代 JDK 在單一桶過長時會把鏈轉成紅黑樹,最壞情況改善為 $O(\log n)$)。
把這條線索串起來:Java 是 OOP-first、靜態強型別、以參考語意操作物件、靠 JVM 上的 GC 自動管理堆積記憶體。你今天寫的每一個 new、每一次 equals 覆寫,背後都連著這套機制。理解了它,你就不只是「會用 Java」,而是真的「懂 Java」。