Learning Objectives
- 定義與呼叫 static 方法,分辨參數與引數,說明回傳值與 void 的差別
- 用 return 提早結束方法,說明區域變數只在自己的方法裡有效
- 說明 Java 一律傳值:傳基本型別改不到呼叫端的變數,傳陣列則可以改到陣列內容
- 依照參數列寫出多載(overload)方法,知道只有回傳型別不同不算多載;使用可變長度參數
- 寫出有終止條件的遞迴方法,用呼叫堆疊說明遞迴怎麼一層一層返回
為什麼要寫方法
程式寫長了,常會發現同一段計算出現在好幾個地方,或是 main 長到一眼看不完。方法(method)把一段工作包起來、取一個名字,
需要的時候呼叫它就好。你其實已經用過很多方法:System.out.println(…)、Math.sqrt(…)、Arrays.sort(…)。
把程式拆成方法有三個好處:
- 不重複:同樣的計算只寫一次,要修正也只改一個地方
- 好讀:
if (isPrime(n))比一整段迴圈更容易看懂意圖 - 好測試:每個方法負責一件事,可以單獨確認它是否正確
本課的方法都加上 static,表示它屬於 Main 這個類別,在 main 裡可以直接呼叫。
沒有 static 的方法要先建立物件才能呼叫,這是第 08 課的內容。
學過 Python 的話:Java 的方法相當於 Python 的 def,但每個參數和回傳值都要寫出型別,編譯器會檢查你傳的引數和接收的回傳值型別對不對。
定義與呼叫
static int square(int n) { // 回傳型別 int、方法名稱 square、參數 int n
int result = n * n;
return result; // 把結果交回呼叫的地方
}
int a = square(3); // 呼叫:引數 3 複製給參數 n,回傳值 9 存進 a
| 部分 | 說明 |
|---|---|
static |
屬於類別的方法(本課的方法都要加) |
| 回傳型別 | 方法算完交回的值是什麼型別;不回傳任何值就寫 void |
| 方法名稱 | 習慣用小寫開頭的駝峰式(camelCase),通常是動詞:printReport、isPrime、getTotal |
| 參數列 | 小括號裡的 型別 名稱,用逗號分隔;沒有參數也要寫 () |
- 定義時小括號裡的
n叫參數(parameter);呼叫時實際傳進去的3叫引數(argument)。引數的個數、順序、型別要和參數列對得上 - 呼叫方法時,程式跳進方法裡執行,碰到
return(或void方法的結尾)再回到呼叫的地方繼續 - 有回傳值的方法可以放進任何運算式:
square(2) * square(5)、System.out.println(square(4)) void方法沒有回傳值,寫int r = greet("Amy", 2);會編譯錯誤
每呼叫一次方法,動畫左邊就多一個框架(frame),放這次呼叫的參數與區域變數;方法結束,框架就消失。這一疊框架叫做呼叫堆疊(call stack)。
呼叫方法、拿回回傳值
每次呼叫 square,左邊就疊上一個新的框架,裡面的 n 是這次傳進來的引數;回傳步驟會在框架上顯示回傳值,接著框架消失,回到 main。注意 greet 的參數有兩個,引數 "Amy" 和 2 依照順序對應到 name 和 times。
return 與區域變數
return 會立刻結束方法,後面的程式不再執行。所以一個方法可以有好幾個 return,先把特殊情況處理掉:
static String grade(int score) {
if (score < 0 || score > 100) {
return "分數不合理"; // 不合理的輸入,直接結束
}
if (score >= 80) return "A";
if (score >= 60) return "B";
return "C";
}
- 有回傳型別的方法,每一條可能的執行路徑都必須
return一個值,否則編譯錯誤missing return statement void方法也可以寫不帶值的return;提早結束- 在迴圈裡
return會連同迴圈一起結束整個方法,常用在「找到答案就停」,例如判斷質數時找到一個因數就回傳false
區域變數(local variable):在方法裡宣告的變數(包括參數)只屬於這次呼叫,方法結束就消失。
main 不能直接使用 square 裡的 result,兩個方法裡也可以各有一個同名的變數,彼此互不相干。
需要把結果交給呼叫端,就用回傳值。
提早 return
grade(85) 在第 8 行就 return,第 10 行之後不會執行;grade(120) 更早,在第 5 行就結束。isPrime(9) 在迴圈裡找到因數 3 就直接 return,迴圈不會跑完。
想一想:呼叫、回傳值與範圍
下面的程式會印出什麼?
public class Main {
static void p(String s) { System.out.print(s); }
static int twice(int n) { p("t"); return n * 2; }
public static void main(String[] args) {
int v = twice(twice(3));
System.out.println(" " + v);
}
}
下面的程式會印出什麼?
public class Main {
static void hello() { System.out.println("hi"); }
public static void main(String[] args) {
int r = hello();
}
}
下面的程式會印出什麼?
public class Main {
static void swap(int a, int b) {
int t = a;
a = b;
b = t;
}
public static void main(String[] args) {
int x = 1, y = 2;
swap(x, y);
System.out.println(x + " " + y);
}
}
傳值呼叫
Java 呼叫方法時一律傳值(pass by value):把引數的值複製一份給參數。差別在於「值」是什麼:
| 引數的型別 | 複製的是 | 方法裡修改參數的結果 |
|---|---|---|
基本型別(int、double…) |
數值本身 | 只改到複本,呼叫端的變數不變 |
| 陣列(以及第 08 課的物件) | 參照(指向同一個陣列) | 改元素:呼叫端看得到;讓參數改指向新陣列:呼叫端不受影響 |
所以 swap(x, y) 交換不了兩個 int 變數。要讓方法的計算結果影響呼叫端,最清楚的做法是用回傳值:num = addOne(num);。
方法直接修改傳進來的陣列雖然可行,但呼叫的人不一定想得到,最好在方法名稱或註解裡說清楚(例如 Arrays.sort 就是直接改原陣列)。
基本型別與陣列的差別
呼叫 change(num, data) 時,新框架的 x 是 5 的複本,arr 的箭頭則指向 main 的同一個陣列。第 6 行改 x,只有 change 框架裡的值變了;第 7 行改 arr[0],右邊那個陣列真的變了。回到 main 後比較 num 和 data。
多載與可變長度參數
同一個類別裡可以有好幾個同名的方法,只要參數列不同(參數的個數不同,或對應位置的型別不同),這叫做多載(overload)。
System.out.println 就是多載的例子:println(int)、println(double)、println(String) 都叫同一個名字。
static int max(int a, int b) { … }
static double max(double a, double b) { … } // 型別不同:可以
static int max(int a, int b, int c) { … } // 個數不同:可以
static double max(int a, int b) { … } // 只有回傳型別不同:編譯錯誤
呼叫時,編譯器依照引數的型別與個數選出要用哪一個。只有回傳型別不同不算多載,因為呼叫 max(3, 8) 時看不出你要哪一個,
javac 會報 method max(int,int) is already defined。參數的名稱不同也不算,只看型別。
找不到完全相符的版本時,編譯器會嘗試把引數自動放寬(例如 int 轉成 long 或 double)再找。
可變長度參數(varargs):在最後一個參數的型別後面加 ...,方法就能接受 0 個或任意多個引數:
static int sum(int... nums) { // 在方法裡,nums 就是一個 int[]
int total = 0;
for (int n : nums) total += n;
return total;
}
sum(); // nums 是長度 0 的陣列
sum(1, 2, 3); // nums 是 {1, 2, 3}
一個方法只能有一個可變長度參數,而且必須放在參數列的最後:static String join(String sep, String... words)。
編譯器依引數挑選版本
看每次呼叫時左邊框架的名稱:max(int a, int b)、max(double a, double b)、max(int a, int b, int c) 是三個不同的方法。三個參數的版本自己又呼叫兩次兩個參數的版本。最後的 sum 每次呼叫,右邊都會出現一個新的 int[],就是可變長度參數收到的引數。
遞迴
方法可以呼叫自己,這叫遞迴(recursion)。有些問題可以拆成「一個比較小的同類問題」,用遞迴寫起來特別直接。 例如階乘:4! = 4 × 3!,而 3! = 3 × 2!……直到 1! = 1。
static int factorial(int n) {
if (n <= 1) {
return 1; // 終止條件(base case):不再呼叫自己
}
return n * factorial(n - 1); // 遞迴呼叫:問題變小一點
}
一個正確的遞迴方法一定要有兩個部分:
- 終止條件:最簡單的情況直接回傳答案
- 遞迴呼叫:每次呼叫都讓問題更接近終止條件
每次呼叫都會在呼叫堆疊上疊一個新框架,每個框架有自己的 n。碰到終止條件之後,才從最上層開始一層一層返回、把結果乘回來。
如果忘了終止條件,或問題沒有變小,框架會一直疊下去,最後拋出 StackOverflowError。
費氏數列 fib(n) = fib(n - 1) + fib(n - 2) 也可以直接寫成遞迴,但同一個值會被重複計算很多次(下面的動畫裡 fib(2) 算了兩次),
n 一大就非常慢。實務上這類問題改用迴圈或把算過的結果記下來。遞迴適合用在問題本身就是樹狀、可以自然拆小的情況。
呼叫堆疊上的遞迴
factorial(4) 會在左邊疊出四層 factorial(int n) 框架,每一層的 n 都不同。到 n 是 1 時碰到終止條件,接著連續幾步都是回傳:每一層拿到上一層的結果,乘上自己的 n 再交回去。後半段的 fib(4) 呼叫次數比較多,注意 fib(2) 被計算了兩次。
想一想:多載、可變長度參數與遞迴
下面的程式會印出什麼?
public class Main {
static int f(int x) { return x; }
static double f(int x) { return x * 1.5; }
public static void main(String[] args) {
System.out.println(f(2));
}
}
下面的程式會印出什麼?
public class Main {
static void show(int x) { System.out.print("int "); }
static void show(double x) { System.out.print("double "); }
public static void main(String[] args) {
show(5);
show(5L);
show('A');
}
}
下面的程式會印出什麼?
public class Main {
static int f(int n) {
if (n == 0) return 0;
return n + f(n - 1);
}
public static void main(String[] args) {
System.out.println(f(4));
}
}
下面的程式會印出什麼?
public class Main {
static int count(String s, char... cs) {
return s.length() + cs.length;
}
public static void main(String[] args) {
System.out.println(count("ab") + " " + count("ab", 'x', 'y'));
}
}
方法的命名與單一職責
寫方法時,可以用下面幾點檢查:
- 名稱說出它做什麼:動詞開頭(
printReport、computeTax),回傳boolean的方法用is/has/can開頭(isPrime、hasNext),讀起來像一句話:if (isLeapYear(y)) - 一個方法只做一件事(單一職責):名稱裡出現「和」(例如
readAndPrintAndSave)通常表示該拆開。負責「計算」的方法回傳結果,負責「顯示」的方法再去印,比在計算方法裡直接println更容易重複使用 - 參數不要太多:超過四、五個時,可以想想是不是該整理成一個類別(第 08 課)
- 長度適中:一個方法超過一個畫面,通常可以再拆出幾個小方法
下面的練習就是這樣:isLeapYear 只負責判斷一年,countLeapYears 呼叫它來計數。
動手寫:閏年
完成兩個方法。isLeapYear(year):能被 4 整除但不能被 100 整除,或能被 400 整除的年份是閏年。countLeapYears(from, to):計算 from 到 to(包含兩端)之間有幾個閏年,請呼叫 isLeapYear 來判斷。
重點回顧
- 方法的寫法:
static 回傳型別 名稱(參數列) { … };不回傳值用void;呼叫時引數依照順序複製給參數 return立刻結束方法;有回傳型別的方法每條路徑都要回傳值- 參數與方法裡宣告的變數是區域變數,只在這次呼叫裡有效;每次呼叫都有自己的框架,疊成呼叫堆疊
- Java 一律傳值:基本型別傳數值的複本,改不到呼叫端;陣列傳參照的複本,改元素呼叫端看得到。想改變呼叫端的變數,用回傳值接回來
- 多載:同名、參數列不同(個數或型別);只有回傳型別或參數名稱不同不算
- 可變長度參數
int... nums在方法裡是陣列,必須放在參數列最後 - 遞迴要有終止條件,而且每次呼叫都讓問題變小;否則會
StackOverflowError - 方法名稱說清楚做什麼,一個方法只做一件事