學完本章你將能夠:
用 enum 取代整數常數,寫出帶欄位、方法與各常數專屬行為的列舉。
分辨四種巢狀類別(靜態巢狀、內部、區域、匿名)與各自的使用時機。
用匿名類別現場實作介面,為進階篇的 lambda 鋪路。
熟用 |Math| 與 |Random|,寫出正確的範圍亂數。
用 |java.time| 處理日期時間:計算、格式化、解析。
綜合以上工具實作一個訂單狀態機。
前六章打通了物件導向與核心容器,本章補齊三塊日常開發天天用到的拼圖:列舉(enum)讓「固定的一組選項」型別安全;巢狀類別讓輔助類別待在該待的地方;再加上 |Math|、|Random| 與 |java.time| 這些標準 API——第 8 章的專案會同時用上它們(訂單狀態、借閱日期都在等著)。
enum 基礎:告別整數常數
需要表達「星期幾」時,老派做法是 |public static final int MON = 0;| 一路排下去。問題在型別不安全:參數宣告成 |int|,傳 |99| 或「星期一加星期二」這種無意義運算,編譯器一律放行。|enum| 把選項升格為獨立型別:值只能是列舉出的那幾個,錯誤在編譯期全數攔截。
/** 星期列舉:一組有名字的、型別安全的常數 */ enum Weekday MON, TUE, WED, THU, FRI, SAT, SUN
public class EnumBasicsDemo
public static void main(String[] args)
Weekday today = Weekday.SAT;
System.out.println("today = " + today); // 自動印常數名 System.out.println("名稱:" + today.name()); System.out.println("序號:" + today.ordinal()); // 從 0 起算 System.out.println("解析:" + Weekday.valueOf("MON"));
// values():取得全部常數,順序同宣告順序 for (Weekday d : Weekday.values()) System.out.print(d + " "); System.out.println();
// enum 進 switch:case 直接寫常數名,不必加型別前綴 switch (today) case SAT, SUN -> System.out.println("週末:睡到自然醒"); case MON -> System.out.println("星期一:收心上工"); default -> System.out.println("平日:好好上課");
today = SAT 名稱:SAT 序號:5 解析:MON MON TUE WED THU FRI SAT SUN 週末:睡到自然醒
三個內建成員最常用:|values()| 回傳全部常數的陣列、|name()| 回傳常數名字串、|valueOf("MON")| 從字串解析回常數(找不到會拋 |IllegalArgumentException|)。|ordinal()| 回傳宣告順序,但不要拿它當商業邏輯的數值——哪天有人在中間插一個常數,所有序號全部位移。範例中的 switch 使用 Java 14 的箭頭語法:不會貫穿(fall-through)、一個 case 可列多個常數,建議一律採用。
帶欄位與方法的 enum
Java 的 enum 遠不只常數清單——它是功能完整的類別:可以有欄位、建構子與方法;每個常數都是該類別的一個實體。經典示範是行星:每顆行星帶著自己的質量與半徑,還能算表面重力:
/** 行星列舉:每個常數自帶質量(公斤)與半徑(公尺) */ enum Planet
// 宣告常數時以括號呼叫建構子 MERCURY(3.303e23, 2.4397e6), EARTH(5.976e24, 6.37814e6), MARS(6.421e23, 3.3972e6);
private final double mass; // 質量 private final double radius; // 半徑
/** 萬有引力常數 */ private static final double G = 6.67300E-11;
/** enum 的建構子:只在載入常數時被呼叫 */ Planet(double mass, double radius) this.mass = mass; this.radius = radius;
/** 表面重力加速度:G 乘質量除以半徑平方 */ double surfaceGravity() return G * mass / (radius * radius);
/** 質量 otherMass 的物體在該行星上的重量(牛頓) */ double surfaceWeight(double otherMass) return otherMass * surfaceGravity();
public class PlanetDemo public static void main(String[] args) double myMass = 70.0; // 一位 70 公斤的太空旅人 for (Planet p : Planet.values()) System.out.printf("在 p, p.surfaceWeight(myMass));
在 MERCURY 上的重量:259.2 牛頓 在 EARTH 上的重量:686.2 牛頓 在 MARS 上的重量:259.9 牛頓
enum 的建構子隱含 |private|(寫 |public| 會編譯錯誤):實體只能是宣告的那幾個常數,外界永遠不能 |new Planet(...)|。這保證了「全系統只有這幾個實體」,也因此 enum 可以放心用 |==| 比較,天生就是完美的單例。
更進一步,enum 可以宣告抽象方法,強迫每個常數提供自己的實作(語法:常數名後面接大括號的「常數專屬類別本體」)——第 2 章的多型在列舉身上重演:
/** 四則運算列舉:每個常數自帶計算邏輯 */ enum Operation
PLUS("+") @Override public double apply(double a, double b) return a + b; , MINUS("-") @Override public double apply(double a, double b) return a - b; , TIMES("*") @Override public double apply(double a, double b) return a * b; , DIVIDE("/") @Override public double apply(double a, double b) return a / b; ;
private final String symbol; // 運算符號
Operation(String symbol) this.symbol = symbol;
/** 抽象方法:每個常數都「必須」提供自己的版本 */ public abstract double apply(double a, double b);
public String getSymbol() return symbol;
public class OperationDemo public static void main(String[] args) double a = 12, b = 4; for (Operation op : Operation.values()) // op.apply(...) 依實際常數執行不同邏輯:多型! System.out.printf(" a, op.getSymbol(), b, op.apply(a, b));
12 + 4 = 16.0 12 - 4 = 8.0 12 * 4 = 48.0 12 / 4 = 3.0
比起在一個方法裡 switch 判斷運算子,這種寫法把「每種運算的邏輯」關進各自的常數:新增 |POWER| 時編譯器會強迫你實作 |apply|,永遠不會漏掉分支。
EnumSet 與 EnumMap
當集合的元素或鍵是列舉時,別用泛用的 |HashSet| 與 |HashMap|——標準庫有專屬的高效版本:|EnumSet| 底層是位元向量(一個 |long| 就裝得下 64 個常數的有無),|EnumMap| 底層是依 |ordinal| 索引的陣列,兩者都比雜湊結構更快、更省記憶體,且遍歷順序固定為宣告順序:
import java.util.EnumMap; import java.util.EnumSet; import java.util.Map;
/** EnumSet 與 EnumMap:搭配 EnumBasicsDemo 的 Weekday */ public class EnumCollectionsDemo
public static void main(String[] args)
// range:取宣告順序中的一段 EnumSet<Weekday> workdays = EnumSet.range(Weekday.MON, Weekday.FRI); // complementOf:取補集(其餘常數) EnumSet<Weekday> weekend = EnumSet.complementOf(workdays);
System.out.println("工作日:" + workdays); System.out.println("週末:" + weekend);
// EnumMap:鍵是列舉的 Map,建構時要提供類別資訊 Map<Weekday, String> plan = new EnumMap<>(Weekday.class); plan.put(Weekday.SAT, "爬山"); plan.put(Weekday.SUN, "寫程式"); System.out.println("週末計畫:" + plan);
工作日:[MON, TUE, WED, THU, FRI] 週末:[SAT, SUN] 週末計畫:SAT=爬山, SUN=寫程式
巢狀類別四人組
類別可以宣告在另一個類別、甚至方法裡面,統稱巢狀類別。四種形態一次看完:
/** 給匿名類別實作用的小介面 */ interface Greeter void greet();
/** 四種巢狀類別的最小示範 */ public class NestedDemo
private String outerField = "外部類別的欄位";
/** 一、靜態巢狀類別:不持有外部實體,最常用 */ static class StaticNested String hello() return "靜態巢狀類別:與外部實體無關";
/** 二、內部類別:隱含持有一份外部類別的 this */ class Inner String hello() // 可以直接讀外部類別的 private 欄位 return "內部類別讀到:" + outerField;
void localAndAnonymous()
// 三、區域類別:宣告在方法裡,只有本方法看得到 class Local String hello() return "區域類別:方法內的小幫手"; System.out.println(new Local().hello());
// 四、匿名類別:宣告與 new 一次完成,連名字都省了 Greeter greeter = new Greeter() @Override public void greet() System.out.println("匿名類別:一次性的實作"); ; greeter.greet();
public static void main(String[] args)
// 靜態巢狀類別:直接 new,不需要外部實體 System.out.println(new StaticNested().hello());
// 內部類別:必須先有外部實體,語法是 outer.new Inner() NestedDemo outer = new NestedDemo(); System.out.println(outer.new Inner().hello());
outer.localAndAnonymous();
靜態巢狀類別:與外部實體無關 內部類別讀到:外部類別的欄位 區域類別:方法內的小幫手 匿名類別:一次性的實作
| 種類 | 宣告位置 | 使用時機 |
|---|---|---|
| 靜態巢狀類別 | 類別內,加 |static| | 只服務外部類別、不需外部狀態 |
| 內部類別 | 類別內,不加 |static| | 需要直接存取外部實體的欄位 |
| 區域類別 | 方法內 | 只在單一方法內使用的小類別 |
| 匿名類別 | 運算式中 | 一次性實作介面或抽象類別 |
內部類別(非靜態)的每個實體都偷偷抓著一份外部物件的參考。這有兩個後果:一、沒有外部實體就造不出內部實體;二、只要內部實體活著,外部物件就無法被垃圾回收——在長生命週期場景(快取、監聽器)這是知名的記憶體洩漏來源。守則:用不到外部實體狀態時,一律宣告成靜態巢狀類別。第 4 章 |Map.Entry|、第 8 章專案的輔助類別都遵循這條守則。
匿名類別實作 Comparator
匿名類別最常見的用途,是替「只用一次」的介面實作省去獨立檔案。第 4 章排序用的 |Comparator| 是絕佳示範:
import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List;
/** 用匿名類別提供「依字串長度排序」的規則 */ public class AnonymousComparatorDemo
public static void main(String[] args)
List<String> cities = new ArrayList<>(List.of( "Kaohsiung", "Taipei", "Tainan", "Hsinchu"));
// new 介面名() ... :宣告兼實體化,一步到位 Collections.sort(cities, new Comparator<String>() @Override public int compare(String a, String b) // 短的排前面;同長度維持原順序(穩定排序) return Integer.compare(a.length(), b.length()); );
System.out.println(cities);
你可能覺得這語法囉嗦——沒錯。第 4 章已偷跑過的 lambda 寫法 |(a, b) -> Integer.compare(a.length(), b.length())| 就是它的精簡形:對「只有一個抽象方法的介面」,編譯器允許只寫方法本體。進階篇會完整拆解 lambda 與方法參考;現在先記住:看到匿名類別實作單方法介面,心裡自動翻譯成 lambda,讀懂新舊程式碼都不成問題。
Math 與 Random
|Math| 是純 |static| 工具類別,常用方法整理如下:
| 方法 | 功能 | 備註 |
|---|---|---|
| |abs(x)| | 絕對值 | 各數值型別皆有多載 |
| |max(a, b)|、|min(a, b)| | 較大、較小值 | |
| |pow(a, b)| | a 的 b 次方 | 回傳 |double| |
| |sqrt(x)| | 平方根 | 回傳 |double| |
| |round(x)| | 四捨五入 | |double| 版回傳 |long| |
| |ceil(x)|、|floor(x)| | 無條件進位、捨去 | 回傳 |double| |
| |random()| | 0.0 到 1.0(不含)亂數 | 簡便但不建議當主力 |
產生亂數的正式工具是 |java.util.Random|。範圍亂數的正確公式:|rand.nextInt(max - min + 1) + min| 產生 min 到 max(含兩端)的整數:
import java.util.Random;
/** Math 常用方法與 Random 的正確用法 */ public class MathRandomDemo
public static void main(String[] args)
// — Math:static 方法直接呼叫,不必建立物件 — System.out.println("絕對值:" + Math.abs(-7)); System.out.println("四捨五入:" + Math.round(3.56)); System.out.println("無條件進位:" + Math.ceil(3.14)); System.out.println("無條件捨去:" + Math.floor(3.99)); System.out.println("次方:" + Math.pow(2, 10)); System.out.println("平方根:" + Math.sqrt(144)); System.out.println("較大值:" + Math.max(17, 23));
// — Random:擲骰子一萬次,驗證範圍公式 — Random rand = new Random(); int min = 7, max = 0; for (int i = 0; i < 10000; i++) // nextInt(6) 給 0 5,加 1 平移成骰子的 1 6 int dice = rand.nextInt(6) + 1; min = Math.min(min, dice); max = Math.max(max, dice); System.out.println("擲一萬次:最小 " + min + "、最大 " + max);
絕對值:7 四捨五入:4 無條件進位:4.0 無條件捨去:3.0 次方:1024.0 平方根:12.0 較大值:23 擲一萬次:最小 1、最大 6
單次骰子點數當然每次執行都不同,所以範例改印一萬次的極值來驗證「範圍公式」正確。兩個補充:測試想要可重現的亂數序列,建構時給固定種子 |new Random(42)|;多執行緒環境改用 |ThreadLocalRandom.current()|(進階篇說明原因)。
日期時間:java.time
Java 8 之前的 |Date| 與 |Calendar| 是公認的設計失誤:可變(到處被偷改)、月份從 0 起算(十月是 9)、又非執行緒安全。Java 8 引入的 |java.time| 套件全面翻新:所有型別不可變、API 語意清楚,舊 API 只在維護老專案時才需要面對。
三個核心類別各司其職:|LocalDate|(只有日期)、|LocalTime|(只有時間)、|LocalDateTime|(兩者皆有)。搭配 |Period|(日期差)、|Duration|(時間差)與 |DateTimeFormatter|(格式化與解析):
import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.Period; import java.time.format.DateTimeFormatter;
/** java.time 的建立、計算、格式化與解析 */ public class TimeDemo
public static void main(String[] args)
// 教學用固定日期讓輸出可重現;實務常用 LocalDate.now() LocalDate today = LocalDate.of(2026, 8, 14); LocalTime lunch = LocalTime.of(12, 30); LocalDateTime meeting = LocalDateTime.of(2026, 8, 14, 14, 0);
System.out.println("今天:" + today); System.out.println("星期:" + today.getDayOfWeek());
// 不可變:plus 與 minus 都回傳「新物件」,原值不動 LocalDate deadline = today.plusDays(30); System.out.println("30 天後截止:" + deadline); System.out.println("today 沒被改動:" + today);
// with 系列:換掉某個欄位,同樣回傳新物件 System.out.println("本月一號:" + today.withDayOfMonth(1));
// Period:以年月日計的日期差,算年齡最順手 LocalDate birthday = LocalDate.of(2004, 3, 21); Period age = Period.between(birthday, today); System.out.println("年齡:" + age.getYears() + " 歲又 " + age.getMonths() + " 個月");
// Duration:以時分秒計的時間差 Duration gap = Duration.between(lunch, meeting.toLocalTime()); System.out.println("午餐到會議相隔 " + gap.toMinutes() + " 分鐘");
// DateTimeFormatter:自訂顯示格式 DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm"); System.out.println("格式化:" + meeting.format(fmt));
// 解析:字串轉回日期物件(ISO 格式可直接 parse) LocalDate parsed = LocalDate.parse("2026-10-10"); System.out.println("解析結果:" + parsed.getMonthValue() + " 月 " + parsed.getDayOfMonth() + " 日");
今天:2026-08-14 星期:FRIDAY 30 天後截止:2026-09-13 today 沒被改動:2026-08-14 本月一號:2026-08-01 年齡:22 歲又 4 個月 午餐到會議相隔 90 分鐘 格式化:2026年08月14日 14:00 解析結果:10 月 10 日
格式樣板(pattern)常用代號:
| 代號 | 意義 | 代號 | 意義 |
|---|---|---|---|
| |yyyy| | 西元年(四位) | |HH| | 時(24 小時制,補零) |
| |MM| | 月(補零) | |mm| | 分(補零) |
| |dd| | 日(補零) | |ss| | 秒(補零) |
| |E| | 星期幾(依語系) | |a| | 上午、下午標記 |
大小寫是有意義的:|MM| 是月、|mm| 是分;|HH| 是 24 小時制、|hh| 是 12 小時制。把 |yyyy-MM-dd| 誤寫成 |yyyy-mm-dd| 不會編譯錯誤,只會默默把「分鐘」印在月份的位置——用測試資料驗證一次你的樣板再上線。
以上的 |Local| 家族刻意「不帶時區」。跨時區需求(國際航班、全球服務)使用 |ZonedDateTime|,例如 |ZonedDateTime.now(ZoneId.of("Asia/Taipei"))|;系統內部與資料庫儲存則慣用 UTC 時間戳 |Instant|。時區規則複雜(夏令時間、歷史變更),原則是:內部一律 UTC,只在顯示給人看的最後一刻轉當地時區。
綜合範例:訂單狀態機
把本章工具組裝起來:|OrderStatus| 列舉內建狀態轉移規則(狀態機),|OrderItem| 用內部類別實作,時間計算交給 |java.time|:
import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List;
/** 訂單:整合列舉狀態機、內部類別與日期時間 */ public class Order
/** 訂單狀態:合法轉移路徑寫死在列舉裡(巢狀 enum 隱含 static) */ enum OrderStatus
CREATED, PAID, SHIPPED, COMPLETED, CANCELLED;
/** 狀態機核心:只回答「能不能走到 target」 */ boolean canTransitionTo(OrderStatus target) // switch 運算式(Java 14):涵蓋所有常數,不必 default return switch (this) case CREATED -> target == PAID || target == CANCELLED; case PAID -> target == SHIPPED || target == CANCELLED; case SHIPPED -> target == COMPLETED; case COMPLETED, CANCELLED -> false; // 終態 ;
/** 內部類別:訂單項目,離開訂單就沒有意義的小物件 */ class OrderItem
private final String product; private final int quantity; private final int unitPrice;
OrderItem(String product, int quantity, int unitPrice) this.product = product; this.quantity = quantity; this.unitPrice = unitPrice;
int subtotal() return quantity * unitPrice;
@Override public String toString() // 內部類別可直接使用外部訂單的 id 欄位 return "[" + id + "] " + product + " x" + quantity + " = " + subtotal() + " 元";
private final String id; // 訂單編號 private final LocalDateTime createdAt; // 下單時間 private final List<OrderItem> items = new ArrayList<>(); private OrderStatus status = OrderStatus.CREATED;
Order(String id, LocalDateTime createdAt) this.id = id; this.createdAt = createdAt;
void addItem(String product, int quantity, int unitPrice) items.add(new OrderItem(product, quantity, unitPrice));
/** 狀態轉移:先問狀態機,合法才放行 */ void changeStatus(OrderStatus target) if (!status.canTransitionTo(target)) System.out.println("不允許:" + status + " -> " + target); return; System.out.println("狀態轉移:" + status + " -> " + target); status = target;
void printSummary() DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM/dd HH:mm"); int total = 0; for (OrderItem item : items) System.out.println(item); total += item.subtotal(); System.out.println("總金額:" + total + " 元"); System.out.println("下單時間:" + createdAt.format(fmt)); // 簡化規則:下單後 3 天送達 System.out.println("預計送達:" + createdAt.plusDays(3).format(fmt));
public static void main(String[] args)
Order order = new Order("A001", LocalDateTime.of(2026, 8, 14, 10, 30)); order.addItem("機械鍵盤", 1, 2990); order.addItem("滑鼠墊", 2, 250);
order.printSummary(); order.changeStatus(OrderStatus.PAID); // 合法 order.changeStatus(OrderStatus.COMPLETED); // 不合法:還沒出貨 order.changeStatus(OrderStatus.SHIPPED); // 合法 order.changeStatus(OrderStatus.COMPLETED); // 合法
機械鍵盤 x1 = 2990 元 [A001] 滑鼠墊 x2 = 500 元 總金額:3490 元 下單時間:08/14 10:30 預計送達:08/17 10:30 狀態轉移:CREATED -> PAID 不允許:PAID -> COMPLETED 狀態轉移:PAID -> SHIPPED 狀態轉移:SHIPPED -> COMPLETED
把「哪些轉移合法」收進 enum,而不是散落在各處的 |if|,是狀態機的正統寫法:規則有單一權威來源,新增狀態時 switch 運算式的窮盡檢查會強迫你補齊所有分支。這個模式將在第 8 章的借閱流程中再次登場。
本章小結
enum 讓固定選項型別安全:非法值進不來,switch 搭配箭頭語法乾淨俐落;|values|、|name|、|valueOf| 是三大內建工具,別把 |ordinal| 當商業數值。
enum 是完整類別:可帶欄位、方法與 |private| 建構子(隱含);抽象方法加常數專屬本體,讓每個常數自帶行為。
元素或鍵是列舉時,優先用 |EnumSet| 與 |EnumMap|:更快、更省、順序固定。
巢狀類別四種:靜態巢狀(首選)、內部(要外部狀態才用,小心持有外部參考造成洩漏)、區域(單一方法內)、匿名(一次性實作)。
匿名類別實作單方法介面,是 lambda 的前身;兩種寫法要能互相翻譯。
範圍亂數公式:|nextInt(max - min + 1) + min|;測試用固定種子讓序列可重現。
|java.time| 全面取代 |Date| 與 |Calendar|:型別不可變,|plus|、|minus|、|with| 都回傳新物件。
|Period| 算日期差、|Duration| 算時間差;格式化與解析用 |DateTimeFormatter|,注意樣板代號大小寫。
跨時區用 |ZonedDateTime|;原則是內部 UTC、顯示時才轉當地時區。
本章練習
觀念題:與 |int| 常數相比,enum 在型別安全上提供了哪些保證?|ordinal()| 為什麼不該當成商業邏輯的數值?(提示:想想「插入一個新常數」會發生什麼事。)
實作題:設計 |Season| 列舉(四季),每個常數帶「平均溫度」欄位與「建議活動」方法,用 |values()| 迴圈印出完整介紹。(提示:仿照 Planet 的建構子寫法。)
實作題:用 enum 實作紅綠燈 |TrafficLight|(RED、GREEN、YELLOW),提供 |next()| 方法回傳下一個狀態(紅轉綠、綠轉黃、黃轉紅),模擬十次變換。(提示:可用 switch 運算式,或每個常數覆寫抽象方法。)
觀念題:四種巢狀類別各適合什麼場景?為什麼「用不到外部狀態就宣告成靜態巢狀類別」?(提示:非靜態內部類別多抓了一份什麼東西?)
實作題:寫程式計算你出生至今活了幾天、幾歲又幾個月,分別用 |ChronoUnit.DAYS.between| 與 |Period.between|。(提示:|ChronoUnit| 在 |java.time.temporal| 套件。)
實作題:用 |DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm")| 解析字串 |"2026/10/10 08:30"| 成 |LocalDateTime|,再以不同樣板格式化輸出。(提示:解析用 |LocalDateTime.parse(text, fmt)|。)