學完本章你將能夠:

  • 定義方法並呼叫它,正確區分參數與引數。

  • 使用回傳值與 void,並用 return 提早結束方法。

  • 解釋 Java 的傳值呼叫,預測基本型別與陣列參數的行為差異。

  • 使用方法多載與可變參數設計彈性的介面。

  • 寫出正確終止的遞迴方法,並追蹤它的呼叫過程。

  • 說明變數作用域,並依單一職責原則重構程式。

寫到現在,你的 main 方法應該越來越長了——計算、驗證、輸出全部擠在 一起,想重複某段邏輯只能複製貼上。複製貼上是程式設計的萬惡之源: 同一段邏輯散落多處,改需求時漏改一處就是臭蟲。業界把解藥稱為 DRY 原則(Don’t Repeat Yourself,別重複你自己),而 Java 實踐 DRY 的 基本工具就是「方法」(method):把一段有名字的邏輯封裝起來,需要時 呼叫名字即可。

方法帶來三個立即的好處:重用(寫一次,用一百次)、 可讀(|calculateBmi(w, h)| 比二十行計算式更能表達 意圖)、好測試(小積木可以個別驗證)。本章從動機出發, 一路學到方法的進階玩法——多載、可變參數與遞迴。

為什麼需要方法

先看「沒有方法」的痛。下面的程式想在報表中畫三條分隔線,同一行 程式被複製了三次——今天想把等號換成減號,就得改三個地方:

/** * 反面教材:畫分隔線的邏輯被複製了三次。 */ public class RepeatPain public static void main(String[] args) System.out.println("=============================="); System.out.println("成績單"); System.out.println("=============================="); System.out.println("國文:90 分"); System.out.println("==============================");

============================== 成績單 ============================== 國文:90 分 ==============================

用方法重構:把「畫分隔線」封裝起來,定義一次、呼叫三次:

/** * 用方法消除重複:分隔線的畫法只定義一次。 */ public class LinePrinter

/** 印出一條 30 個等號組成的分隔線 */ static void printLine() System.out.println("==============================");

public static void main(String[] args) printLine(); // 呼叫:跳去執行方法 System.out.println("成績單"); // 執行完回到這裡繼續 printLine(); System.out.println("國文:90 分"); printLine();

輸出與前一版完全相同,但質變發生了:想改分隔線樣式,現在只需要改 一個地方。注意執行流程——程式遇到 |printLine();| 時 「跳」進方法執行,執行完再「跳」回原處繼續往下走。

方法定義的完整語法解剖(以 |static int add(int a, int b)| 為例):

  • |static|:修飾詞。本章的方法都加 static,讓 main 能直接呼叫(背後原因在第 8 章揭曉)。

  • |int|:回傳型別——方法算完後交回什麼型別的值; 不交回任何值就寫 |void|。

  • |add|:方法名稱,慣例用「動詞開頭的 camelCase」。

  • |(int a, int b)|:參數列——這個方法需要外界提供的 材料清單,每個參數都要寫型別。

參數、引數與回傳值

兩個容易混用的術語,其實分工明確:參數(parameter)是方法 定義處的「材料清單」,引數(argument)是呼叫時實際遞過去的 「材料」。以下面的程式為例,|width| 與 |height| 是參數, 呼叫處的 |5| 與 |3| 是引數。

/** * 參數、引數、回傳值與 return 提早結束的完整示範。 */ public class AreaCalculator

/** * 計算矩形面積。 * 參數 width、height 是方法內部的區域變數, * 呼叫時由引數的值來初始化。 */ static int rectangleArea(int width, int height) int area = width * height; return area; // 把結果交回呼叫端,方法就此結束

/** 印出報告。不需要交回任何值,回傳型別寫 void */ static void printReport(String name, int area) if (area <= 0) System.out.println(name + ":尺寸不合法!"); return; // void 方法也能用 return 提早離開 System.out.println(name + " 的面積是 " + area);

public static void main(String[] args)

// 回傳值可以存進變數…… int a1 = rectangleArea(5, 3); printReport("客廳", a1);

// ……也可以直接嵌在更大的運算式裡 System.out.println("兩房總面積:" + (rectangleArea(4, 3) + rectangleArea(2, 3)));

// 引數也可以是另一個方法的回傳值 printReport("儲藏室", rectangleArea(0, 5));

客廳 的面積是 15 兩房總面積:18 儲藏室:尺寸不合法!

關於 |return| 的三個規則:有回傳型別的方法,每條執行 路徑都必須 return 一個對應型別的值;|void| 方法可以完全不寫 return,也可以用「不帶值的 return」提早離開(衛述句的好搭檔); return 之後的程式碼永遠執行不到,編譯器甚至會直接報錯 (unreachable statement)。

Java 是傳值呼叫

重頭戲來了。呼叫方法時,引數是怎麼交給參數的?Java 的答案永遠只有 一種:傳值呼叫(pass by value)——把引數的值複製 一份給參數。這句話對基本型別與參考型別會產生截然不同的表面行為, 是面試的常客,也是初學者的大魔王:

/** * 傳值呼叫的兩張面孔:交換失敗與修改陣列成功。 */ public class PassByValue

/** 企圖交換兩個整數——注定失敗 */ static void swap(int x, int y) int temp = x; // x、y 只是 a、b 的「複本」 x = y; y = temp; System.out.println("方法內:x = " + x + ", y = " + y);

/** 把陣列的第一個元素改成 99——會成功! */ static void modifyFirst(int[] arr) // arr 是參考的複本,但它指向的是「同一個」陣列 arr[0] = 99;

public static void main(String[] args)

int a = 1; int b = 2; swap(a, b); System.out.println("方法外:a = " + a + ", b = " + b);

int[] numbers = 1, 2, 3; modifyFirst(numbers); System.out.println("陣列內容:" + numbers[0] + ", " + numbers[1] + ", " + numbers[2]);

方法內:x = 2, y = 1 方法外:a = 1, b = 2 陣列內容:99, 2, 3

用遙控器比喻一次講透。基本型別:盒子裡放的是「值本身」,複製給 參數的就是值的複本——方法內怎麼折騰複本,原變數都不動,所以交換 失敗。參考型別(陣列):盒子裡放的是「遙控器」,複製給參數的是 遙控器的複本——遙控器有兩支,電視只有一台。透過複本遙控器 按下「把第一格改成 99」,唯一那台電視當然變了。但請注意:如果方法 內寫 |arr = new int[3];|,只是讓複本遙控器改配對到新電視, 呼叫端的遙控器毫無感覺。一句話總結:Java 複製的永遠是 盒子裡的東西——值或參考——而不是它們指向的實體。

方法多載

同一個類別裡可以有多個同名方法,只要它們的參數列(型別、 數量或順序)不同,這稱為多載(overloading)。編譯器會根據你傳入的 引數自動挑選版本。其實你早就在用多載了——|println| 能印字串、 整數、小數,正是因為它有一大票多載版本。

/** * 方法多載:同名不同參數列,編譯器依引數挑選版本。 */ public class OverloadDemo

static int add(int a, int b) System.out.print("[int 版本] "); return a + b;

static double add(double a, double b) System.out.print("[double 版本] "); return a + b;

static int add(int a, int b, int c) System.out.print("[三參數版本] "); return a + b + c;

public static void main(String[] args) System.out.println(add(1, 2)); System.out.println(add(1.5, 2.5)); System.out.println(add(1, 2, 3));

3 [double 版本] 4.0 [三參數版本] 6

多載只看參數列,與回傳型別無關。兩個方法若參數列完全 相同、只有回傳型別不同,編譯器直接報錯——因為呼叫時它無從分辨 你要哪一個。另外,多載版本之間的行為應該「語意一致」:都叫 add 就都該做加法,若 int 版做加法、double 版做乘法,使用者遲早翻車。

可變參數 varargs

如果連「參數個數」都想開放呢?在型別後面加三個點 (|int... nums|),方法就能接受任意數量的引數,方法內部把它 當成陣列使用:

/** * 可變參數:呼叫端想傳幾個就傳幾個。 */ public class VarargsDemo

/** nums 在方法內就是一個 int 陣列 */ static int sum(int... nums) int total = 0; for (int n : nums) total += n; return total;

public static void main(String[] args) System.out.println(sum()); // 傳 0 個也合法 System.out.println(sum(10)); System.out.println(sum(1, 2, 3)); System.out.println(sum(1, 2, 3, 4, 5));

0 10 6 15

規則有二:一個方法只能有一個可變參數,而且它必須排 在參數列的最後——否則編譯器無法判斷哪些引數屬於誰。例如 |static void log(String tag, int... values)| 合法;把兩者 順序對調則不合法。

遞迴:呼叫自己的方法

方法可以呼叫別的方法,那能不能呼叫自己?可以,這叫遞迴 (recursion)。遞迴的思考方式是「把大問題丟給小一號的自己」: 5 的階乘是「5 乘上 4 的階乘」,4 的階乘是「4 乘上 3 的階乘」…… 一路縮小到「1 的階乘就是 1」為止。每個遞迴都必須有兩個要素:

  • 終止條件(base case):小到不能再小、可以直接回答 的情況。沒有它,遞迴就是無底洞。

  • 遞迴關係:如何把問題縮小一號,並用小問題的答案 組出大問題的答案。

/** * 用遞迴計算階乘:n! = n * (n-1) * ... * 2 * 1。 */ public class Factorial

static long factorial(int n) // 終止條件:1 以下直接回答,不再往下呼叫 if (n <= 1) return 1; // 遞迴關係:把 n-1 的問題交給「小一號的自己」 return n * factorial(n - 1);

public static void main(String[] args) for (int i = 1; i <= 5; i++) System.out.println(i + "! = " + factorial(i));

1! = 1 2! = 2 3! = 6 4! = 24 5! = 120

呼叫 |factorial(3)| 時,記憶體裡的「呼叫堆疊」是這樣運作的 (每一層呼叫都會疊上一個新的工作紀錄,算完再一層層拆掉):

  • |factorial(3)| 需要 |3 * factorial(2)|,先暫停, 往下呼叫。

  • |factorial(2)| 需要 |2 * factorial(1)|,再暫停, 再往下。

  • |factorial(1)| 命中終止條件,直接回傳 1。

  • 回到 |factorial(2)|:|2 * 1| 得 2,回傳。

  • 回到 |factorial(3)|:|3 * 2| 得 6,回傳。完工。

忘記寫終止條件(或條件永遠碰不到),方法就會無限呼叫自己,呼叫 堆疊被塞爆,JVM 拋出 |StackOverflowError| ——史上最有名的 程式錯誤之一,知名問答網站 Stack Overflow 就是以它命名的。寫遞迴 的第一步永遠是:先寫終止條件,再寫遞迴關係,並確認每次 遞迴都朝終止條件前進。

再看一個經典:費氏數列(每一項是前兩項之和)。它有兩個 終止條件與兩路遞迴呼叫:

/** * 費氏數列:1, 1, 2, 3, 5, 8, ...(前兩項之和)。 */ public class Fibonacci

static long fib(int n) if (n <= 2) return 1; // 終止條件:第 1、2 項定義為 1 return fib(n - 1) + fib(n - 2); // 兩路遞迴

public static void main(String[] args) // 印出數列的前 10 項 for (int i = 1; i <= 10; i++) System.out.print(fib(i) + " "); System.out.println();

1 1 2 3 5 8 13 21 34 55

這個寫法直觀,但效能很差:|fib(50)| 會觸發指數等級的重複 計算(|fib(48)| 被算了兩次、|fib(47)| 三次……)。 用第 4 章的迴圈改寫只需線性時間。這是重要的一課:遞迴是 思考工具,不一定是最佳實作——能自然轉成迴圈的遞迴,通常轉掉更好; 真正適合遞迴的是樹狀結構等「天生巢狀」的問題(中級篇再會)。

變數作用域

作用域(scope)指「變數在哪個範圍內存在且可用」。Java 的規則很 一致:變數活在宣告它的那對大括號裡,出了大括號就消失。 參數的作用域是整個方法;迴圈變數的作用域只在迴圈裡:

/** * 作用域示範:變數只活在宣告它的大括號內。 */ public class ScopeDemo

static void printSquare(int n) // 參數 n:整個方法可用 int square = n * n; // 區域變數:只活在此方法 System.out.println(n + " 的平方是 " + square);

public static void main(String[] args)

printSquare(4);

for (int i = 1; i <= 2; i++) // i 只活在這個 for 裡 int doubled = i * 2; // 每圈都是全新的 doubled System.out.println("i = " + i + ",兩倍 = " + doubled);

// 下面兩行若取消註解都會編譯錯誤: // System.out.println(i); // i 已隨迴圈結束而消失 // System.out.println(square); // square 屬於別的方法

4 的平方是 16 i = 1,兩倍 = 2 i = 2,兩倍 = 4

作用域不是刁難,而是保護:範圍越小,變數能被誤用的機會就越少。 好習慣是在最接近使用處、最小的範圍內宣告變數。

最後認識一個名詞:遮蔽(shadowing)——內層範圍出現與外層 同名的變數時,內層名稱會暫時「遮住」外層。Java 不允許方法裡的 區域變數彼此同名,所以初級階段少有機會撞上;但「參數與類別欄位 同名」是合法且常見的遮蔽,第 8 章介紹 |this| 關鍵字時, 你就會親手解決這個歧義。

方法設計的四條軍規:(1)單一職責——一個方法只做一件事, 「計算並列印並存檔」就是三件事,拆開;(2)命名用動詞, 名字要能讀出行為,如 |calculateTotal|、|isValidEmail|; (3)參數不要超過三四個,太多代表這個方法管太寬; (4)長度以一眼看完為度,超過一個螢幕就該考慮拆分。 評斷標準只有一個:半年後的你能不能十秒內看懂它。

綜合範例:重構簡易計算機

驗收時間:把「讀輸入、算結果、印報告」全部拆成方法,讓 main 縮成 一份流程總表。對照看看:同樣的功能,這一版的每個片段都可以獨立 理解、獨立重用:

import java.util.Scanner;

/** * 簡易計算機 2.0:main 只負責指揮,實際工作全部交給方法。 */ public class CalculatorV2

/** 顯示提示文字並讀取一個整數 */ static int readNumber(Scanner scanner, String prompt) System.out.print(prompt); return scanner.nextInt();

/** 依運算符號計算;除以零時提示並回傳 0 */ static double calculate(int a, int b, char op) switch (op) case ’+’: return a + b; case ’-’: return a - b; case ’*’: return a * b; case ’/’: if (b == 0) System.out.println("除數不能為零!"); return 0; return (double) a / b; // 轉型避免整數除法 default: System.out.println("不支援的運算:" + op); return 0;

/** 統一的結果輸出格式 */ static void printResult(int a, int b, char op, double value) System.out.println(a + " " + op + " " + b + " = " + value);

public static void main(String[] args)

Scanner scanner = new Scanner(System.in);

int a = readNumber(scanner, "請輸入第一個數字:"); int b = readNumber(scanner, "請輸入第二個數字:");

// 同一組數字,四種運算各展示一次 printResult(a, b, ’+’, calculate(a, b, ’+’)); printResult(a, b, ’-’, calculate(a, b, ’-’)); printResult(a, b, ’*’, calculate(a, b, ’*’)); printResult(a, b, ’/’, calculate(a, b, ’/’));

scanner.close();

請輸入第一個數字:12 請輸入第二個數字:5 12 + 5 = 17.0 12 - 5 = 7.0 12 * 5 = 60.0 12 / 5 = 2.4

順帶一提:|calculate| 裡的 |case ’+’: return a + b;| 沒有寫 break——因為 |return| 已經直接離開整個方法,落穿 根本無從發生。這是傳統 switch 少數可以安心省略 break 的場合。

本章小結

  • 方法實踐 DRY 原則:邏輯只定義一次,到處呼叫;同時提升 可讀性與可測試性。

  • 定義語法四件套:修飾詞、回傳型別(無則 void)、動詞命名、 參數列;參數是清單,引數是實料。

  • 有回傳型別的方法每條路徑都要 return;void 方法可用 「不帶值的 return」提早離開。

  • Java 永遠是傳值呼叫:基本型別複製值(方法內改不動外面), 參考型別複製參考(可透過它修改同一個物件的內容)。

  • 多載:同名方法靠參數列區分,與回傳型別無關;語意要一致。

  • 可變參數 |int... nums| 在方法內是陣列;只能有一個 且必須放參數列最後。

  • 遞迴必備終止條件與遞迴關係;忘記終止條件會導致 StackOverflowError;能自然改寫成迴圈的遞迴通常該改寫。

  • 變數活在宣告它的大括號內;在最小必要範圍內宣告變數。

  • 方法設計軍規:單一職責、動詞命名、參數精簡、長度一眼可讀。

本章練習

  1. 觀念題:參數與引數差在哪裡?在定義 |static int max(int a, int b)| 與呼叫式 |max(3, 7)| 中,誰是參數、誰是引數? (提示:定義處與呼叫處。)

  2. 觀念題:為什麼 |swap(a, b)| 交換不了 main 裡的兩個 int,但方法卻能修改傳入陣列的內容?(提示:複製的是值 還是遙控器。)

  3. 實作題:寫 |static boolean isPrime(int n)| 判斷質數, 並用它印出 2 到 50 之間的所有質數。(提示:從 2 檢查到 n 的平方根即可;先處理 n 小於 2 的情況。)

  4. 實作題:多載三個 |max| 方法:兩個 int、三個 int、 兩個 double,並在 main 各呼叫一次驗證。(提示:三參數版 可以重用兩參數版:max(max(a, b), c)。)

  5. 實作題:用遞迴寫 |static int sumTo(int n)| 計算 1 加 到 n;先寫終止條件。再思考:n 給 100000 會發生什麼事? (提示:堆疊深度是有限的。)

  6. 重構題:把第 5 章的成績統計程式重構成多個方法: |sum|、|average|、|maxOf| 各自獨立, main 只負責呼叫與輸出。(提示:每個方法都接收 int 陣列 參數。)