學完本章你將能夠:

  • 解釋類別與物件的關係,定義自己的類別。

  • 使用 new 建立物件,存取欄位並呼叫方法。

  • 使用建構子初始化物件,並運用 this 與建構子多載。

  • 用 private 欄位加上 getter 與 setter 實現封裝。

  • 分辨 static 成員與實體成員的差異與適用場景。

  • 完成一個具備驗證邏輯的完整 BankAccount 類別。

本冊的壓軸登場。到目前為止,我們的程式由「零散的變數」與「處理它們的 方法」組成——這種風格稱為程序式程式設計。它應付小程式綽綽有餘,但當 資料一多、關係一複雜,變數與方法就會像沒有收納的房間一樣失控。物件 導向(Object-Oriented Programming,OOP)是 Java 給出的收納哲學: 把資料與操作資料的行為,打包成一個個「物件」。

Java 從骨子裡就是物件導向語言——你寫的第一行程式就住在 class 裡, 用了七章的 |String|、|Scanner|、|Arrays| 全是別人 寫好的類別。本章終於輪到你自己設計類別。這是初級篇最重要的一章, 也是通往中級篇(繼承、多型、介面)的大門。

為什麼需要物件導向

先感受一次「沒有物件」的混亂。想管理三位學生的姓名與成績,程序式的 寫法只能攤出一堆平行變數:

/** * 沒有物件的世界:三位學生的資料散落在六個變數裡。 */ public class ProceduralPain

public static void main(String[] args)

String name1 = "小明"; int score1 = 90; String name2 = "小美"; int score2 = 85; String name3 = "小華"; int score3 = 77;

// 印每位學生都得小心對準編號,張冠李戴不會有任何警告 System.out.println(name1 + ":" + score1 + " 分"); System.out.println(name2 + ":" + score2 + " 分"); System.out.println(name3 + ":" + score3 + " 分");

小明:90 分 小美:85 分 小華:77 分

問題一目瞭然:|name2| 與 |score2| 之間的「屬於同一位 學生」關係,只存在你的腦中,語言完全沒有幫你把關——寫成 |name2 + ":" + score3| 也照樣編譯執行。學生增加到三十位、 每位再加上座號與電話,這種寫法就徹底崩潰了。第 5 章的陣列能解決 「量」的問題,卻還是把姓名陣列與成績陣列拆成兩個世界。我們真正 需要的是一種「把相關資料綁在一起」的型別——這就是類別。

類別與物件:設計圖與成品

類別(class)是設計圖,物件(object)是照著設計圖 蓋出來的成品(也稱實體,instance)。「學生」類別描述每位學生都有 姓名與成績、都能自我介紹;「小明」「小美」則是照這張圖各自蓋出來的 兩個物件,資料各自獨立。定義類別的語法:

/** * 學生類別:把「一位學生的資料與行為」定義在一起。 * 這是一張設計圖,本身還不是任何一位具體的學生。 */ public class Student

// 欄位(field):每個物件都擁有自己的一份 String name; // 姓名 int score; // 成績

/** 方法:印出這位學生自己的資訊 */ void printInfo() // 這裡的 name 與 score,指的是「目前這個物件」的欄位 System.out.println(name + ":" + score + " 分");

這個檔案沒有 main 方法——設計圖自己不會動。用另一個類別來「蓋房子」, 兩個檔案放在同一個資料夾即可:

/** * 用 Student 設計圖建立物件(實體)並操作它們。 */ public class StudentDemo

public static void main(String[] args)

// new:照設計圖在記憶體中蓋出一個全新的 Student 物件 Student s1 = new Student(); s1.name = "小明"; // 用「變數.欄位」讀寫資料 s1.score = 90;

Student s2 = new Student(); s2.name = "小美"; s2.score = 85;

// 用「變數.方法()」呼叫物件的行為 s1.printInfo(); s2.printInfo();

// 兩個物件完全獨立:改 s1 不影響 s2 s1.score = 95; // 小明補考加分 s1.printInfo(); s2.printInfo(); // 小美不受影響

小明:90 分 小美:85 分 小明:95 分 小美:85 分

|new Student()| 那一刻發生的事,用文字描繪記憶體的樣貌:

  • JVM 在「堆積」(heap)記憶體配置一塊空間,放進一個新的 Student 物件,其中有 name 與 score 兩個欄位。

  • 變數 |s1| 本身只是一支遙控器(參考),存放這個物件 的位置——第 5 章陣列的規則在此完全適用。

  • 再 new 一次,堆積裡出現第二個獨立的物件,|s2| 指向 它。兩台電視、兩支遙控器,互不干擾。

  • 如果寫 |Student s3 = s1;|,那只是複製遙控器—— |s3.score = 0| 會讓 s1 的成績一起歸零,小心。

欄位的預設值

第 2 章說過「區域變數用前必須初始化」,欄位卻不同:物件誕生時, 每個欄位自動獲得型別的預設值:

/** * 欄位有預設值,區域變數沒有——用前者驗證給你看。 */ public class DefaultValueDemo

int count; // 數值型別預設 0 double ratio; // 預設 0.0 boolean active; // 預設 false String label; // 參考型別預設 null

public static void main(String[] args)

DefaultValueDemo demo = new DefaultValueDemo(); System.out.println("count = " + demo.count); System.out.println("ratio = " + demo.ratio); System.out.println("active = " + demo.active); System.out.println("label = " + demo.label);

// 區域變數就沒這種待遇,下面取消註解會編譯錯誤: // int local; // System.out.println(local); // might not have been // // initialized

count = 0 ratio = 0.0 active = false label = null

「欄位有預設值、區域變數沒有」是考試與面試的常客,更是實務地雷: 參考型別欄位的預設值是 |null|,忘記初始化就呼叫它的方法 (如 |label.length()|),執行期會拋出 NullPointerException ——Java 世界最惡名昭彰的例外。好習慣:物件建立時就用「建構子」 把每個欄位初始化成有意義的值——先看看這顆地雷爆炸的樣子, 建構子隨後登場。

null 與 NullPointerException

參考型別欄位沒有初始化時,裡面裝的是 |null|——代表「沒有 指向任何物件」的特殊值。對 |null| 呼叫方法或存取欄位, 程式會在執行期當場中止:

/** * 對 null 參考呼叫方法:Java 最著名的執行期例外。 */ public class NullPointerDemo

static String message; // 參考型別欄位,預設值是 null

public static void main(String[] args)

System.out.println("message 的值:" + message);

// 對 null 呼叫方法,下一行會拋出例外並中止程式 int len = message.length(); System.out.println("長度:" + len); // 永遠執行不到

message 的值:null Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "NullPointerDemo.message" is null at NullPointerDemo.main(NullPointerDemo.java:11)

學會解讀這段訊息,除錯效率立刻翻倍:|Cannot invoke| 後面是 「想對 null 做的事」,|because| 後面直接點名「誰是 null」, 最後一行給出出事的檔名與行號。修正方向只有兩個:在使用前確實把它 初始化,或先用 |if (message != null)| 檢查再使用。

建構子:物件的出廠設定

剛才的流程「先 new、再一項項指定欄位」既囉嗦又危險——忘了設定某個 欄位也沒人提醒。建構子(constructor)解決這個問題:它是 與類別同名、沒有回傳型別的特殊方法,在 |new| 的當下自動執行,負責把物件初始化成可用狀態。搭配 |this| 關鍵字(代表「目前這個物件」),欄位與參數同名也不會混淆:

/** * 平面座標點:示範建構子多載與 this 的兩種用法。 */ public class Point

int x; int y;

/** 無參數建構子:把點放在原點 */ Point() // this(...):呼叫本類別的另一個建構子,避免重複邏輯 this(0, 0); System.out.println("(用無參數建構子建立)");

/** 雙參數建構子:直接指定座標 */ Point(int x, int y) // this.x 是欄位,x 是參數——this 讓兩者涇渭分明 this.x = x; this.y = y; System.out.println("建立點 (" + x + ", " + y + ")");

public static void main(String[] args)

Point origin = new Point(); // 觸發無參數版本 Point p = new Point(3, 4); // 觸發雙參數版本

System.out.println("origin:(" + origin.x + ", " + origin.y + ")"); System.out.println("p:(" + p.x + ", " + p.y + ")");

建立點 (0, 0) (用無參數建構子建立) 建立點 (3, 4) origin:(0, 0) p:(3, 4)

觀察輸出順序:|new Point()| 先跳去執行 |this(0, 0)| (所以先印出「建立點 (0, 0)」),回來才印無參數版本自己的訊息。 規則:|this(...)| 只能放在建構子的第一行。

你從沒寫過建構子,之前卻能 |new Student()|?因為類別完全 沒有定義建構子時,編譯器會偷偷送你一個空的「預設建構子」。但 只要你定義了任何一個建構子,這份贈品就會被收回——此時 再呼叫 |new Student()| 會編譯錯誤(constructor Student in class Student cannot be applied to given types)。若兩種用法都要 支援,請像上例一樣把無參數建構子明確寫出來。

封裝:把資料鎖進保險箱

目前 Student 的欄位是裸奔狀態:任何人都能寫 |s1.score = -999;|,資料的正確性毫無防線。物件導向的第一大 支柱「封裝」(encapsulation)就是為此而生:欄位宣告成 private(僅限類別內部存取),對外只開放必要的 public 方法,所有 讀寫都得走方法這道門——門後就能設檢查哨。慣例上,讀取的方法叫 getter(|getXxx|),寫入的叫 setter(|setXxx|):

/** * 封裝示範:private 欄位加上有驗證的 setter。 * 一個檔案可以放多個類別,但 public 類別只能有一個, * 且檔名必須跟 public 類別同名。 */ class Player

private String name; private int hp; // 血量:規定必須在 0 到 100 之間

Player(String name, int hp) this.name = name; setHp(hp); // 建構子也走 setter,統一由一處把關

/** getter:對外提供唯讀窗口 */ public int getHp() return hp;

/** setter:寫入前先驗證,不合法的值一律擋下 */ public void setHp(int hp) if (hp < 0 || hp > 100) System.out.println("血量 " + hp + " 不合法,維持原值"); return; // 衛述句:直接離開,不動欄位 this.hp = hp;

public class EncapsulationDemo

public static void main(String[] args)

Player hero = new Player("勇者", 80); System.out.println("目前血量:" + hero.getHp());

hero.setHp(150); // 嘗試塞入不合法的值 System.out.println("目前血量:" + hero.getHp());

hero.setHp(65); // 合法值正常寫入 System.out.println("目前血量:" + hero.getHp());

// hero.hp = 999; // 編譯錯誤:hp 是 private,外界碰不到

目前血量:80 血量 150 不合法,維持原值 目前血量:80 目前血量:65

封裝的價值在最後一行註解:想直接繞過檢查改血量?編譯器直接說不。 資料的每一次變動都必須經過你設下的關卡,「不合法的狀態根本無法 存在」——這是大型系統可靠性的基石。

不是每個欄位都要反射性地配上 getter 與 setter。先問:外界真的 需要讀它嗎?真的可以改它嗎?唯讀欄位只給 getter;不該被外界碰的 連 getter 都不給。「開最小的門」是封裝的精髓。

static:屬於類別的成員

到目前為止的欄位都是「每個物件一份」。有些資料卻是全類別 共用一份才合理——例如「總共建立了幾個物件」。在成員前加上 |static|,它就屬於類別本身,而不屬於任何個別物件:

/** * static 欄位:全類別共用的計數器。 */ class Visitor

static int count = 0; // 靜態欄位:所有 Visitor 共用一份 String name; // 實體欄位:每個物件自己一份

Visitor(String name) this.name = name; count++; // 每建立一個物件,共用計數器加一

public class StaticDemo

public static void main(String[] args)

System.out.println("目前人數:" + Visitor.count);

Visitor v1 = new Visitor("小明"); Visitor v2 = new Visitor("小美"); Visitor v3 = new Visitor("小華");

// 靜態成員用「類別名稱.成員」存取,不需要任何物件 System.out.println("目前人數:" + Visitor.count); System.out.println("v1 的名字:" + v1.name);

目前人數:0 目前人數:3 v1 的名字:小明

方法也可以是 static:不依賴任何物件狀態、純粹「輸入進、答案出」的 工具方法最適合。你早就用過一整個類別的例子——|Math|:

/** * Math 類別:全部都是靜態方法,不必 new 直接用。 */ public class MathDemo public static void main(String[] args) System.out.println(Math.max(3, 7)); // 較大值 System.out.println(Math.abs(-5)); // 絕對值 System.out.println(Math.sqrt(16)); // 平方根 System.out.println(Math.pow(2, 10)); // 2 的 10 次方

7 5 4.0 1024.0

謎底揭曉時間:main 方法為什麼是 static?因為 JVM 啟動時,你的程式 還沒有任何物件存在——JVM 必須能「不建立物件就呼叫 main」,static 正是這個意思。這也解釋了第 6 章所有方法都加 static 的原因:static 方法(如 main)只能直接呼叫其他 static 方法。

/** * 靜態方法不能直接存取實體成員——示範正確的繞法。 */ public class StaticError

String name = "小明"; // 實體欄位:屬於「某個物件」

public static void main(String[] args)

// 直接寫 System.out.println(name); 會編譯錯誤: // non-static variable name cannot be referenced // from a static context // 因為 main 執行時根本沒有物件,哪來的 name?

// 正確做法:先建立物件,再透過物件存取 StaticError obj = new StaticError(); System.out.println(obj.name);

小明

「non-static variable cannot be referenced from a static context」 是初學者在 main 裡最常撞見的編譯錯誤。口訣:靜態的世界裡 沒有 this——static 方法不知道「目前是哪個物件」,自然拿不到實體 欄位。解法二選一:把該成員也改成 static(若它確實是全類別共用), 或先 new 出物件再存取(若它屬於個別物件)。

套件與 import

類別一多就需要分類收納。「套件」(package)是 Java 的資料夾系統, 慣例用公司網域倒過來命名(如 |com.example.school|),對應到 實際的目錄結構。檔案第一行(註解之外)宣告所屬套件;使用其他套件 的類別前,用 |import| 引入:

// 檔案第一行宣告套件:這個類別的全名是 // com.example.school.HelloPackage package com.example.school;

// 引入其他套件的類別,之後就能用短名稱稱呼它 import java.util.Scanner;

public class HelloPackage public static void main(String[] args) // java.lang 套件(System、String、Math 等)自動引入, // 所以我們從第 1 章用到現在都不需要 import 它們 System.out.println("我住在 com.example.school 套件");

套件宣告後,檔案必須放在對應的資料夾(|com/example/school/|) 且執行時要用「全名」。編譯時用 |-d| 讓 javac 自動建立目錄:

\(javac -d out HelloPackage.java\) java -cp out com.example.school.HelloPackage 我住在 com.example.school 套件

現在你也看懂了用了七章的咒語:|import java.util.Scanner;| 的意思是「引入 java.util 套件裡的 Scanner 類別」。初學階段的練習 程式省略 package 宣告(落在「預設套件」)沒有問題,正式專案則 一定會分套件。

綜合範例:BankAccount

初級篇的畢業考:設計一個銀行帳戶類別,整合本章全部觀念——private 欄位(封裝)、建構子驗證、this、static 計數器,以及具備完整驗證的 存提款方法。金額用整數(元)而不是 double,呼應第 3 章的浮點數 教訓:

/** * 銀行帳戶:初級篇所學的總集合。 */ class BankAccount

// —- 靜態成員:所有帳戶共用 —- static int totalAccounts = 0; // 總開戶數

// —- 實體欄位:每個帳戶各自一份,private 保護 —- private String owner; // 戶名 private int balance; // 餘額(以元為單位,避開浮點誤差)

/** 開戶:指定戶名與開戶金額(負數視為 0 處理) */ BankAccount(String owner, int initialDeposit) this.owner = owner; if (initialDeposit > 0) this.balance = initialDeposit; totalAccounts++; // 每開一戶,共用計數器加一

/** 存款:金額必須是正數 */ void deposit(int amount) if (amount <= 0) System.out.println("存款金額必須大於零!"); return; // 衛述句:不合法就提早離開 balance += amount; System.out.println(owner + " 存入 " + amount + ",餘額 " + balance);

/** 提款:金額須為正數,且不能超過餘額 */ void withdraw(int amount) if (amount <= 0) System.out.println("提款金額必須大於零!"); return; if (amount > balance) System.out.println(owner + " 餘額不足,無法提領 " + amount); return; balance -= amount; System.out.println(owner + " 提領 " + amount + ",餘額 " + balance);

/** 只提供 getter:餘額不允許外界直接竄改 */ int getBalance() return balance;

/** 覆寫 toString:定義物件被 println 時顯示的文字 */ @Override public String toString() return "帳戶[" + owner + ",餘額 " + balance + " 元]";

/** * 測試程式:開兩個帳戶,執行一連串存提款操作。 */ public class BankDemo

public static void main(String[] args)

BankAccount a = new BankAccount("小明", 1000); BankAccount b = new BankAccount("小美", 500);

a.deposit(300); // 1000 + 300 = 1300 a.withdraw(2000); // 超過餘額,被擋下 a.withdraw(800); // 1300 - 800 = 500 b.deposit(-100); // 不合法金額,被擋下 b.deposit(250); // 500 + 250 = 750

System.out.println("小明餘額:" + a.getBalance()); System.out.println("小美餘額:" + b.getBalance()); System.out.println("總開戶數:" + BankAccount.totalAccounts);

// 把物件直接交給 println,會自動呼叫它的 toString System.out.println(a); System.out.println(b);

小明 存入 300,餘額 1300 小明 餘額不足,無法提領 2000 小明 提領 800,餘額 500 存款金額必須大於零! 小美 存入 250,餘額 750 小明餘額:500 小美餘額:750 總開戶數:2 帳戶[小明,餘額 500 元] 帳戶[小美,餘額 750 元]

請放慢速度把這支程式讀三遍——它是整冊教材的縮影:變數與型別 (第 2 章)、條件判斷(第 4 章)、方法與參數(第 6 章)、字串輸出 (第 7 章)、類別與封裝(本章)。兩個帳戶物件各自持有獨立的餘額, 卻共用同一個 static 計數器;所有金額變動都必須通過驗證關卡。如果 你能不看解說就理解每一行的用意,恭喜,初級篇的目標已經達成。

最後一個新面孔是 |toString| 方法:把物件交給 |println| 時,Java 會自動呼叫它取得物件的文字描述,不必再逐一印出欄位。 方法上方的 |@Override| 標註表示「我正在覆寫繼承來的方法」 ——覆寫與繼承的完整故事,正是中級篇的開場主題。

本冊總結與下一步

回顧這趟旅程:你從安裝 JDK、印出第一行 Hello 開始,一路掌握了變數 與型別、運算子、流程控制、陣列、方法、字串,最後親手設計了自己的 類別。你已經具備用 Java 解決小型問題的完整能力。中級篇將站在 「類別」這個地基上蓋出高樓:繼承與多型讓類別之間建立親屬關係、 介面定義行為契約、例外處理讓程式優雅面對錯誤、集合框架與泛型 取代長度固定的陣列,最後以檔案 I/O 與綜合專案收官。休息一下, 我們中級篇見。

本章小結

  • 物件導向把「資料」與「操作資料的行為」打包成物件,解決 程序式風格資料零散的痛點。

  • 類別是設計圖,物件(實體)是成品;|new| 在堆積 配置物件,變數保存的是參考。

  • 欄位有預設值(數值 0、boolean 為 false、參考為 null); 區域變數沒有,用前必須初始化。

  • 建構子與類別同名、無回傳型別,在 new 時自動執行;可以 多載;|this(...)| 呼叫其他建構子且必須在第一行。

  • 自訂了任何建構子後,編譯器就不再贈送預設建構子。

  • |this| 代表目前物件,用來區分同名的欄位與參數。

  • 封裝:private 欄位加 public getter 與 setter,在 setter 設驗證關卡,讓不合法資料無法進入物件。

  • static 成員屬於類別、全體共用,用「類別名.成員」存取; 靜態方法沒有 this,不能直接存取實體成員——main 是 static 的原因也在此。

  • 套件是類別的資料夾,import 引入其他套件的類別; java.lang 自動引入。

  • 對 null 呼叫方法會拋出 NullPointerException,訊息會點名 誰是 null;覆寫 |toString| 可自訂物件被 println 時的文字描述。

本章練習

  1. 觀念題:類別與物件的關係是什麼?在 |Student s = new Student();| 這行裡,哪個是類別? 物件在哪裡?s 又是什麼?(提示:設計圖、成品、遙控器。)

  2. 觀念題:為什麼欄位要宣告成 private?直接讓外界讀寫欄位 會失去什麼?(提示:想想 setter 裡的驗證關卡。)

  3. 實作題:設計 |Rectangle| 類別:private 的寬與高、 帶參數的建構子、|area()| 與 |perimeter()| 方法,並在測試類別建立兩個矩形驗證。(提示:面積是寬乘 高,周長是兩者和的兩倍。)

  4. 實作題:替本章的 Student 類別加上建構子與 getter、 setter,並在 setter 中把成績限制在 0 到 100 之間,超出 範圍時印出警告且不修改。(提示:仿照 Player 的 setHp。)

  5. 實作題:寫一個 |Counter| 類別,用 static 欄位統計 「程式從頭到尾總共呼叫了幾次 |tick()| 方法」, 在 main 建立多個 Counter 物件各呼叫幾次,驗證計數是 全體共用的。(提示:static 欄位加 static getter。)

  6. 挑戰題:擴充 BankAccount——加入轉帳方法 |transfer(BankAccount target, int amount)|, 轉出前檢查餘額,轉帳成功與失敗都要印出明細。 (提示:轉帳等於自己 withdraw 加上對方 deposit, 但要先驗證再動手。)