Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

類別與物件

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 上跑起來的物件。

Java 類別與物件概念示意圖

類別是藍圖,物件是蓋出來的房子

先釐清兩個最常被混淆的詞。類別(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(...) 前面的 voidboolean 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(外部不可直接存取),只透過受控的方法對外開放讀寫,這些方法慣例上叫 gettersetter

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 這扇門,而門口有警衛(驗證邏輯)。publicprivate 這些叫存取修飾子(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 執行。你會看到 alicebob 各自記得自己的點數,互不干擾——這就是「一份藍圖、多個獨立物件」的實感。

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 物件導向時,這幾個雷幾乎人人踩過,先打預防針:

  1. 以為宣告變數就等於建立了物件。 Member m; 只是宣告了一個型別為 Member 的參考變數,它此刻是 null,並沒有任何物件。一定要 m = new Member(...) 才真的造出物件,否則對 null 呼叫方法會丟 NullPointerException

  2. 建構子忘了 this,導致欄位永遠是預設值。 寫成 name = name; 時,因為名稱遮蔽,你只是把參數指派給自己,欄位 name 始終是 null。記得用 this.name = name;

  3. 自訂建構子後,忘了預設建構子已經消失。 一旦你寫了 Member(String, int),那個免費的無參數 Member() 就不存在了。若你還需要它,得自己再補一個 Member() {}

  4. 誤把建構子寫上回傳型別。 寫成 void Member(...) {} 的瞬間,它就不再是建構子,而退化成一個剛好同名的普通方法,new Member(...) 會找不到對應建構子而編譯失敗。

  5. 把欄位全開成 public 圖方便。 這等於拆掉封裝的門,任何人都能把物件改成非法狀態。慣例是欄位 private、透過方法控管存取。

重點回顧

  • 類別是藍圖(型別定義),物件是 new 出來、活在記憶體中的實例。
  • 欄位存狀態、方法定行為;this 指向「目前這個物件自己」。
  • 建構子與類別同名、不寫回傳型別,負責讓物件一誕生就處於合法狀態。
  • 封裝=欄位 private + 受控的 getter/setter;Java 的 private 是語言強制,不是約定。
  • package 提供命名空間並與目錄結構強制對應。

深入探討(研究所視角)

到這裡你已經會用 Java 寫物件了。但若要真正理解這門語言,得往下挖一層,看物件在記憶體裡長什麼樣、變數到底裝了什麼。

物件配置在堆積,變數握的是參考

當你寫 Member bob = new Member("Bob", 50);,背後其實發生了兩件事,分別動到兩塊記憶體區域:

  • 堆積(heap)new 在堆積上配置一塊空間,放置真正的 Member 物件(它的 namepoints 等欄位資料就住在這裡)。
  • 堆疊(stack):區域變數 bob 本身放在當前方法的呼叫堆疊框(stack frame)裡,它不是物件本體,而是一個參考(reference)——你可以把它想成一個指向堆積位址的「把手」。

這就是 Java 的參考語意(reference semantics)。Java 中的變數,凡是物件型別(非 intdouble 等基本型別 primitive),裝的都是參考而非物件本身。理解這點,下面這個經典陷阱就不再神祕:

Member a = new Member("Amy", 100);
Member b = a;          // 複製的是「參考」,不是物件!a 與 b 指向同一個物件
b.topUp(50);
System.out.println(a.getPoints());   // 輸出:150(a 也變了,因為根本是同一個物件)

b = a 只複製了把手,沒有複製房子。ab 握著同一塊堆積記憶體的兩個把手,透過任一個修改都會反映在另一個上。相較之下,基本型別(int x = 5; int y = x;)複製的是值本身,y 改了不影響 x

物件不再被任何參考指向時(例如把 ab 都設成 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)。

為什麼這條契約如此重要?因為 HashMapHashSet 這些以雜湊為基礎的集合,存取流程是先用 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.equalsObjects.hash 是慣例做法——它們會妥善處理 null,並確保 equalshashCode 基於「相同的欄位集合」計算,這正是維持契約的關鍵。正確的雜湊讓 HashMap 的平均查找維持在 $O(1)$;若雜湊設計糟糕、大量碰撞退化成一條鏈,最壞情況會掉到 $O(n)$(現代 JDK 在單一桶過長時會把鏈轉成紅黑樹,最壞情況改善為 $O(\log n)$)。

把這條線索串起來:Java 是 OOP-first、靜態強型別、以參考語意操作物件、靠 JVM 上的 GC 自動管理堆積記憶體。你今天寫的每一個 new、每一次 equals 覆寫,背後都連著這套機制。理解了它,你就不只是「會用 Java」,而是真的「懂 Java」。

AI 共讀助教正在陪你讀:Java 類別與物件:從一張會員卡看懂 OOP-first 的世界觀
嗨!我是這篇文章的共讀助教,只根據〈Java 類別與物件:從一張會員卡看懂 OOP-first 的世界觀〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。