Learning Objectives
- 用 extends 讓子類別繼承父類別的欄位與方法,再加上自己的新成員
- 說明建立子類別物件時建構子的執行順序,在需要時用 super(…) 呼叫父類別的建構子
- 覆寫(override)父類別的方法並加上 @Override,用 super.方法() 呼叫父類別原本的版本
- 分辨 public、protected、private 對子類別的影響,說明 final 類別與 final 方法的作用
- 覆寫 Object 的 toString 與 equals,並說明覆寫 equals 時為什麼也要覆寫 hashCode
為什麼需要繼承
第 08 課學會了用類別描述資料。寫一個系統時,常會遇到幾個類別「大部分一樣、只有一點不同」: 學生和老師都有姓名和信箱,學生多了學號,老師多了研究室;狗和貓都會吃東西,狗會叫、貓會抓老鼠。 把共同的部分複製到每個類別裡,之後要改就得每個地方都改。
繼承(inheritance)讓你把共同的部分寫在父類別(superclass),再讓子類別(subclass)用 extends 繼承它:
class Animal { // 父類別:所有動物共同的部分
String name;
void eat() { … }
}
class Dog extends Animal { // 子類別:自動擁有 name 和 eat(),再加上自己的東西
void bark() { … }
}
子類別和父類別是「是一種」(is-a)的關係:狗是一種動物,所以 Dog extends Animal 合理。
如果只是「有一個」(has-a),例如車子有引擎,應該把引擎當成車子的欄位,而不是讓 Car extends Engine。
子類別繼承欄位與方法
第 21 行建立的 Dog 物件有三個欄位:name、age 是從 Animal 繼承來的,breed 是 Dog 自己的。d.eat() 時框架名稱是 Animal.eat(),因為方法寫在 Animal 裡,但框架裡的 this 指向的是 Dog 物件。第 28 行的 Animal 物件沒有 breed:父類別不會擁有子類別新增的東西。
建構子與 super(…)
子類別不會繼承父類別的建構子,但建立子類別物件時,父類別的建構子一定會先執行,負責把「父類別的那部分」初始化好。順序是:
- 子類別建構子的第一行呼叫
super(…),進入父類別的建構子(父類別如果也有父類別,再往上一層) - 最上層的建構子先執行完,再一層一層回到子類別,執行
super(…)之後的程式
class Person {
String name;
Person(String name) { this.name = name; }
}
class Student extends Person {
int grade;
Student(String name, int grade) {
super(name); // 把 name 交給 Person 的建構子
this.grade = grade; // 再設定自己的欄位
}
}
super(…)必須寫在建構子的第一行(Java 21 的規定;JDK 25 起放寬,可以先寫不使用this的敘述,例如檢查參數)- 沒有寫
super(…)時,編譯器會自動在第一行補上super();,呼叫父類別不接受參數的建構子 - 所以父類別如果沒有不接受參數的建構子(像上面的
Person只有Person(String name)),子類別的建構子就必須自己寫super(…),否則編譯錯誤:constructor Person in class Person cannot be applied to given types - 同一行只能是
super(…)或this(…)其中一個:this(…)會先轉給同類別的另一個建構子,由那個建構子負責呼叫super(…)
三層繼承的建構順序
注意左邊的框架:GradStudent 的建構子一開始就呼叫 super(…),接著 Student 也是,最後疊成三層,三個框架的 this 都指向同一個物件。真正第一個印出訊息的是最上層的 Person,然後才一層一層回到子類別。
想一想:繼承與建構子
下面的程式會印出什麼?
class A { A() { System.out.print("A"); } }
class B extends A { B() { System.out.print("B"); } }
class C extends B { C() { System.out.print("C"); } }
public class Main {
public static void main(String[] args) {
new C();
System.out.println();
}
}
下面的程式會印出什麼?
class P {
P(String s) { }
}
class C extends P {
C() { System.out.println("C"); }
}
public class Main {
public static void main(String[] args) {
new C();
}
}
下面的程式會印出什麼?
class A {
A(int x) { System.out.print("A" + x); }
}
class B extends A {
B() { super(7); System.out.print("B"); }
B(int y) { this(); System.out.print(y); }
}
public class Main {
public static void main(String[] args) {
new B(9);
System.out.println();
}
}
覆寫與 @Override
繼承來的方法不一定適合子類別。子類別可以寫一個名稱、參數列都相同的方法,取代父類別的版本,這叫做覆寫(override):
class Shape {
double area() { return 0; }
}
class Rect extends Shape {
private double w, h;
@Override // 告訴編譯器:這個方法是要覆寫父類別的
double area() { return w * h; }
}
覆寫和多載不同:多載(第 07 課)是同名、參數列不同的兩個方法並存;覆寫是子類別用相同參數列的方法取代父類別的版本。
覆寫的規則:
- 名稱與參數列必須完全相同;回傳型別相同(或是原本回傳型別的子類別)
- 存取權限不能變小:父類別是
public,子類別也必須是public private方法不會被繼承,所以談不上覆寫;static方法也不能被覆寫
@Override 是寫給編譯器看的標記(annotation)。加上之後,如果你把名稱拼錯(tostring)或參數寫錯,
其實並沒有覆寫到任何方法,編譯器會報錯 method does not override or implement a method from a supertype。
沒加 @Override 的話,拼錯的方法只會變成一個新方法,程式能編譯,卻默默地用了父類別的版本。所以覆寫時一律加上 @Override。
在子類別裡,super.方法() 可以呼叫父類別原本的版本,常用在「沿用父類別的做法,再多做一點」。
覆寫之後,即使是父類別寫的程式碼呼叫這個方法,執行的也是子類別的版本:Java 依照物件實際的類別決定要執行哪個方法。下一課的多型會深入介紹這件事。
覆寫 area 與 describe
Shape.describe() 裡呼叫了 area()。第 42 行 s.describe() 時 this 是 Shape,所以用 Shape.area();第 43 行 r.describe() 先進入 Rect.describe(),它用 super.describe() 呼叫父類別的版本,而父類別版本裡的 area() 呼叫到的卻是 Rect.area():看框架名稱就知道執行的是哪一個。
protected、final 與單一繼承
存取修飾字與子類別:
| 修飾字 | 子類別能不能直接使用 |
|---|---|
public |
可以 |
protected |
可以(同一個套件的類別也可以) |
| 不寫 | 只有在同一個套件時可以 |
private |
不行,連子類別也不能直接存取,要透過父類別提供的方法 |
子類別的物件裡還是有父類別的 private 欄位(動畫裡看得到),只是子類別的程式碼不能直接寫它的名稱,
寫了會編譯錯誤 … has private access in …。想讓子類別使用、又不想公開給所有人,就用 protected;但封裝的原則依然適用,
能用方法存取的,就不要開放欄位。
final:
final class:這個類別不能被繼承,例如String就是 final 類別,寫class MyString extends String會編譯錯誤cannot inherit from final Stringfinal方法:子類別不能覆寫這個方法,用在「這個行為不允許被改變」的時候final變數(第 02 課):指定一次之後不能再改
單一繼承: Java 的類別只能 extends 一個父類別,class C extends A, B 是語法錯誤。這避免了兩個父類別有同名方法時不知道該用哪一個的問題。
需要「同時具備好幾種能力」時,Java 用介面(interface),一個類別可以實作多個介面(第 11 課)。
一個類別沒有寫 extends 時,它的父類別就是 java.lang.Object。所以每個類別最終都繼承自 Object,形成一棵以 Object 為根的繼承樹。
Object 類別:toString 與 equals
因為每個類別都繼承 Object,所有物件都有 Object 定義的幾個方法。最常覆寫的是:
| 方法 | Object 原本的行為 |
通常覆寫成 |
|---|---|---|
public String toString() |
回傳 類別名稱@雜湊碼,例如 Point@1b6d3586 |
回傳物件內容的文字說明 |
public boolean equals(Object o) |
和 == 一樣,只比是不是同一個物件 |
比較欄位內容 |
public int hashCode() |
依物件身分產生的整數,通常每個物件都不同 | 依照 equals 用到的欄位計算 |
toString() 會在字串相接("a 是 " + a)和 System.out.println(a) 時自動被呼叫。
覆寫 equals 的標準寫法:
@Override
public boolean equals(Object o) {
if (this == o) return true; // 同一個物件
if (!(o instanceof Point)) return false; // 型別不對(o 是 null 時也是 false)
Point p = (Point) o; // 轉型之後才能讀 p.x、p.y
return x == p.x && y == p.y;
}
參數型別一定要寫 Object。寫成 equals(Point p) 的話,參數列和 Object.equals(Object) 不同,變成了多載而不是覆寫,
HashSet、ArrayList.contains 這些只會呼叫 equals(Object) 的程式就用不到它。加上 @Override 時,編譯器會幫你抓出這個錯誤。
覆寫 toString 與 equals
第 28 行字串相接時,會自動進入 Point.toString()。第 29 行的 == 比參照,a、b 是兩個不同的物件;第 30 行呼叫覆寫過的 equals,逐步看它怎麼檢查型別、轉型、比較欄位。
覆寫 equals 時也要覆寫 hashCode
Java 有一條規則:兩個物件 equals 相等,hashCode() 就必須相同。
Object 原本的 hashCode 依物件身分計算,兩個分別建立的物件幾乎一定不同;所以只覆寫 equals、不覆寫 hashCode,就違反了這條規則。
違反的後果出現在 HashSet、HashMap 這類用雜湊找資料的集合(第 13 課)。它們加入或尋找元素時:
- 先用
hashCode()算出要去哪一格找 - 那一格裡有雜湊值相同的元素,才用
equals確認是不是真的相同
hashCode 不同,HashSet 會直接認定是不同的元素,連 equals 都不呼叫。結果是同樣內容的牌被放進去兩次,contains 也找不到。
覆寫 hashCode 最簡單的做法是用 java.util.Objects.hash(…),把 equals 比較的同一組欄位傳進去:
@Override
public int hashCode() {
return Objects.hash(suit, rank);
}
反過來,hashCode 相同不一定 equals 相等(不同內容也可能剛好算出同一個數字),這是允許的,只是會讓集合多做幾次 equals 比較。
沒有覆寫 hashCode 的後果
Card 只覆寫了 equals,GoodCard 繼承 Card 並補上 hashCode。前半段把兩張一樣的 Card 放進 HashSet,注意動畫裡完全沒有呼叫 equals,兩張都被放進去;後半段每次 add 都先進入 GoodCard.hashCode(),雜湊值相同時才進入 equals。
想一想:覆寫與 Object
下面的程式會印出什麼?
class A {
String hi() { return "A"; }
String call() { return hi(); }
}
class B extends A {
@Override
String hi() { return "B"; }
}
public class Main {
public static void main(String[] args) {
System.out.println(new B().call() + new A().call());
}
}
下面的程式會印出什麼?(注意方法名稱的大小寫)
class A {
public String toString() { return "A"; }
}
class B extends A {
@Override
public String tostring() { return "B"; }
}
public class Main {
public static void main(String[] args) {
System.out.println(new B());
}
}
P 沒有覆寫任何 Object 的方法。下面的程式會印出什麼?
class P {
int x = 1;
}
public class Main {
public static void main(String[] args) {
P p = new P();
String s = p.toString();
System.out.println(s.startsWith("P@") + " " + p.equals(new P()) + " " + p.equals(p));
}
}
下面的程式能不能編譯?
final class Base { }
class Derived extends Base { }
public class Main {
public static void main(String[] args) {
System.out.println("ok");
}
}
動手寫:主管的薪資
Employee 已經寫好。請完成 Manager:建構子用 super(…) 把姓名與底薪交給父類別,再設定自己的 bonus;覆寫 pay(),回傳父類別算出的底薪加上獎金。toString() 寫在 Employee 裡,會呼叫 pay()。
重點回顧
class 子類別 extends 父類別:子類別擁有父類別的欄位與方法,再加上自己的;兩者是「是一種」的關係- 建立子類別物件時,父類別的建構子先執行;
super(…)寫在建構子第一行,沒寫時自動補super();,父類別沒有無參數建構子時必須自己寫 - 覆寫:子類別用相同名稱與參數列取代父類別的方法,權限不能變小;一律加
@Override讓編譯器檢查;super.方法()呼叫父類別原本的版本 - 執行哪個版本的方法,由物件實際的類別決定,即使呼叫寫在父類別裡
private成員子類別不能直接存取,protected可以;final類別不能被繼承,final方法不能被覆寫- Java 的類別只能繼承一個父類別;沒寫
extends時父類別是Object - 覆寫
toString讓物件好印;覆寫equals時參數型別是Object,而且一定要一起覆寫hashCode(用Objects.hash傳入同一組欄位),否則HashSet、HashMap會出錯