Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

介面與抽象

Java 介面與抽象:用契約讓不相干的東西彼此合作

從 interface、implements 到 default 方法與 lambda,看 Java 如何用「編譯期契約」達成多重繼承的能力,並對比 Python 的鴨子型別與多重繼承哲學。

一份「會員資格」如何讓不相干的東西彼此合作

想像你正在開發一個校園系統。有「學生」「印表機」「圖書館預約」三個完全不相干的東西,但它們都需要被「列印成一張紙本紀錄」。你不會說學生「是一種」印表機,也不會說圖書館預約「繼承自」學生——它們之間沒有血緣關係。它們只是剛好都會做同一件事:產生一段可列印的文字。

在 Java 裡,這種「我承諾我會做某件事,但我跟你沒有家族關係」的契約,就叫做介面(interface)。這是 Java 物件導向設計裡最核心、也最能展現它與 Python 哲學差異的一塊。如果你讀過本專區的 Python 篇,會記得 Python 靠「鴨子型別」(duck typing)——「會呱呱叫的就當鴨子用」,執行時才知道行不行。Java 反其道而行:它要你在編譯期就白紙黑字簽好契約,編譯器會替你檢查每一條承諾是否兌現。

這篇文章我們就來把這份「契約」拆開看清楚。

Java 介面與抽象概念示意圖

interface 與 implements:簽一份編譯期契約

先看最小的例子。我們定義一個「可列印」的契約:

public interface Printable {
    String toPrintableText();   // 只有方法簽章,沒有實作(body)
}

interface 裡的方法預設是 public abstract——只宣告「要有這個方法」,不寫怎麼做。任何想成為 Printable 的類別,都得用 implements 關鍵字簽約,並親自實作這個方法:

public class Student implements Printable {
    private String name;
    private int studentId;

    public Student(String name, int studentId) {
        this.name = name;
        this.studentId = studentId;
    }

    @Override
    public String toPrintableText() {
        return "學生 " + name + "(學號 " + studentId + ")";
    }
}

注意那個 @Override 標註。它不是必要的,但強烈建議寫上:它要求編譯器確認「我真的是在覆寫某個契約方法」。如果你不小心把方法名打成 toPrintabelText()(拼錯),有了 @Override,編譯器會直接報錯,而不是默默讓你定義出一個沒人呼叫的孤兒方法。

這就是 Java 與 Python 最直觀的差異:

  • Python:你不用宣告 implements,只要物件「碰巧有」那個方法,傳進去就能跑;錯了要等執行到那一行才爆。
  • Java:你沒實作完契約方法,程式根本編譯不過,連跑都跑不起來。錯誤被攔在出貨之前。

這種「靜態強型別」的取捨,正是 Java 在大型企業系統、團隊協作裡受歡迎的原因:契約是寫死的,IDE 能自動補全、能重構、能在你按下儲存的那一刻就告訴你哪裡違約了。

為什麼要這層抽象?讓呼叫端不在乎細節

光定義契約還看不出威力。威力在於:呼叫端只認契約,不認實作

public class PrintService {
    // 參數型別是「介面」,不是任何具體類別
    public static void print(Printable item) {
        System.out.println("=== 列印中 ===");
        System.out.println(item.toPrintableText());
    }
}

PrintService.print() 完全不知道、也不需要知道傳進來的是學生、印表機還是圖書館預約。它只知道「這東西保證有 toPrintableText() 可以呼叫」。我們可以再加一個八竿子打不著的類別:

public class LibraryReservation implements Printable {
    private String bookTitle;
    private String date;

    public LibraryReservation(String bookTitle, String date) {
        this.bookTitle = bookTitle;
        this.date = date;
    }

    @Override
    public String toPrintableText() {
        return "預約《" + bookTitle + "》於 " + date;
    }
}

於是這兩個毫無關係的類別,可以走同一條列印通道。這就是多型(polymorphism):同一個 print() 呼叫,依實際傳入的物件展現不同行為。介面讓「呼叫端」與「實作端」徹底解耦——你日後新增一百種可列印的東西,PrintService 一行都不用改。這是良好軟體設計的基石之一,業界常說的「面向介面編程,而非面向實作編程」(program to an interface, not an implementation)。

多重介面:一個類別可以簽很多份契約

一個類別只能繼承一個父類別(extends 只能一個),但可以同時 implements 任意多個介面,用逗號分隔。

public interface Printable {
    String toPrintableText();
}

public interface Comparable2<T> {
    int compareTo(T other);
}

public interface Saveable {
    void saveToDisk();
}

// 一個類別同時簽三份契約
public class Report implements Printable, Comparable2<Report>, Saveable {
    private String title;
    private int pageCount;

    public Report(String title, int pageCount) {
        this.title = title;
        this.pageCount = pageCount;
    }

    @Override
    public String toPrintableText() {
        return "報告:" + title + "(" + pageCount + " 頁)";
    }

    @Override
    public int compareTo(Report other) {
        return Integer.compare(this.pageCount, other.pageCount);
    }

    @Override
    public void saveToDisk() {
        System.out.println("已存檔:" + title);
    }
}

Report 同時是「可列印的」「可比較的」「可存檔的」。在系統的不同角落,它能被當成不同角色使用——排序演算法只在意它是不是 Comparable,列印服務只在意它是不是 Printable。一個物件,多個身分,互不干擾。

為何 Java 用介面達成「多重繼承」

這裡要解釋一個 Java 設計史上的關鍵決策。C++ 允許一個類別同時繼承多個父類別(多重繼承),但這會引發惡名昭彰的「菱形問題」(diamond problem):

        Animal
        /      \
   會游泳的    會飛的
        \      /
       飛魚(?)

如果 會游泳的會飛的 都從 Animal 繼承了一個 move() 方法、各自又改寫過,那麼 飛魚 呼叫 move() 時,到底該用哪一份?C++ 需要程式設計師額外用 virtual 繼承等機制去消歧義,複雜且容易出錯。

Java 的設計者乾脆把問題從根斬斷:類別只能單一繼承extends 一個),但介面可以多重實作implements 多個)。為什麼這樣就安全?因為傳統介面只有契約、沒有實作(沒有狀態、沒有方法本體)。既然沒有「具體的兩份 move()」可以衝突,自然就沒有菱形問題——所有衝突的方法本體都得由實作類別自己提供唯一一份。

這就是常聽到的那句話:Java 用介面達成了「多重繼承的能力」(一個類別可以是多種型別),卻避開了多重繼承的麻煩(實作衝突)。介面繼承的是「型別與契約」,而不是「程式碼與狀態」。

相較於 Python——Python 直接允許多重繼承類別,並用一套明確的 MRO(Method Resolution Order,方法解析順序,C3 線性化演算法)來決定呼叫哪一個父類別方法。兩種語言面對同一個難題,給了截然不同的答案:Python 選擇「允許但定好規則」,Java 選擇「從型別系統上禁止衝突來源」。

預設方法(default):讓介面也能帶實作

到這裡你可能想問:那介面就永遠不能有方法本體嗎?Java 8(2014 年)之後,答案變成「可以,但要用 default 關鍵字」。

引入這個功能的原因很實際:Java 的標準函式庫有上千個既有介面被全世界數十萬專案實作。如果某天要在 List 介面加一個新方法 sort(),傳統做法會讓所有已實作 List 的類別瞬間編譯失敗(因為它們沒實作新方法)。這在生態系裡是災難。

default 方法解決了這個「介面演化」難題——你可以給介面方法一個預設實作,已實作該介面的舊類別不改一行就自動獲得新方法

public interface Greeter {
    String name();   // 抽象方法,實作類別必須提供

    // default 方法:有本體,實作類別可用可不用
    default String greet() {
        return "你好,我是 " + name();
    }
}

public class Teacher implements Greeter {
    @Override
    public String name() {
        return "陳老師";
    }
    // 不必實作 greet(),自動沿用 default 版本
}

實作類別若不滿意預設行為,仍可選擇覆寫它。default 方法讓介面在「純契約」與「帶實作的能力混入」之間取得平衡——但要謹記它的初衷是向後相容地演化介面,不是拿來當「偷渡狀態的偽抽象類別」。介面依然不能有實例欄位(沒有狀態)

介面 vs 抽象類別:什麼時候用哪個?

Java 還有另一個容易和介面搞混的東西:抽象類別(abstract class)

public abstract class Shape {
    protected String color;          // 抽象類別「可以有」欄位(狀態)

    public Shape(String color) {     // 可以有建構子
        this.color = color;
    }

    public abstract double area();   // 抽象方法:子類別必須實作

    public String describe() {       // 具體方法:直接給子類別用
        return color + " 圖形,面積 " + area();
    }
}

public class Circle extends Shape {
    private double radius;

    public Circle(String color, double radius) {
        super(color);
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

兩者的差異,用一張表最清楚:

比較項目 介面 interface 抽象類別 abstract class
一個類別能有幾個 多個(implements A, B, C 只能一個(extends 單一)
能有實例欄位(狀態) 不能
能有建構子 不能
方法本體 只有 default / static 能有 可以有任意具體方法
表達的語意 「能做什麼」(can-do / 能力) 「是什麼」(is-a / 本質分類)

設計準則

  • 當你描述的是一種能力或角色,且這能力可能落在毫不相干的類別上——用介面PrintableComparableRunnable)。
  • 當你描述的是一條本質的分類繼承軸,且需要在父層共享狀態與部分實作——用抽象類別Shape 之於 CircleRectangle)。
  • 拿不定主意時,優先選介面。它更靈活(可多重實作),耦合更鬆。

動手寫一段

把上面學到的東西串成一個能跑的完整程式。複製到一個檔案 Demo.java,用 javac Demo.java && java Demo 執行:

import java.util.ArrayList;
import java.util.List;

// 契約一:可列印
interface Printable {
    String toPrintableText();

    // default 方法:給一個附帶的「列印標籤」能力
    default String tag() {
        return "[未分類]";
    }
}

class Student implements Printable {
    private String name;
    private int score;

    Student(String name, int score) {
        this.name = name;
        this.score = score;
    }

    @Override
    public String toPrintableText() {
        return "學生 " + name + ",成績 " + score;
    }

    @Override
    public String tag() {           // 覆寫 default
        return score >= 60 ? "[及格]" : "[待加強]";
    }
}

class Course implements Printable {
    private String title;

    Course(String title) {
        this.title = title;
    }

    @Override
    public String toPrintableText() {
        return "課程《" + title + "》";
    }
    // 不覆寫 tag(),沿用 default 的 "[未分類]"
}

public class Demo {
    // 呼叫端只認介面 Printable,不認 Student / Course
    static void printAll(List<Printable> items) {
        for (Printable item : items) {
            System.out.println(item.tag() + " " + item.toPrintableText());
        }
    }

    public static void main(String[] args) {
        List<Printable> items = new ArrayList<>();
        items.add(new Student("小明", 85));
        items.add(new Student("小華", 48));
        items.add(new Course("資料結構"));
        printAll(items);
    }
}

預期輸出:

// 輸出:
// [及格] 學生 小明,成績 85
// [待加強] 學生 小華,成績 48
// [未分類] 課程《資料結構》

注意 printAll 收的是 List<Printable>——學生和課程這兩個毫不相干的類別,因為都簽了同一份契約,就能放進同一個清單、走同一條處理邏輯。這正是介面帶來的「以契約統一異質物件」的力量。

常見錯誤

  • 忘了實作所有抽象方法:只要漏掉介面裡任何一個方法,整個類別編譯不過。Java 不像 Python 會「跑到那行才報錯」——它在編譯期就攔下你。看到 Class X is not abstract and does not override abstract method... 就是漏簽契約了。
  • 以為介面能放欄位來存狀態:介面裡寫的 int count = 0; 不是實例欄位,它其實是 public static final 常數(編譯器自動加上),所有實作類別共用且不可改。想存「每個物件各自的狀態」請用類別欄位或抽象類別。
  • extends 去實作介面:類別實作介面是 implements,繼承類別才是 extends。(但介面繼承介面確實用 extends,例如 interface B extends A——這是少數例外,別被搞混。)
  • 濫用 default 方法塞商業邏輯default 的初衷是向後相容地演化介面,不是把抽象類別的活搬進介面。介面一旦塞滿實作,就失去了「純契約」的清爽,也容易踩到多介面 default 同名衝突(此時 Java 強制你在類別裡手動覆寫消歧義)。
  • 漏掉 @Override:雖非語法強制,但少了它,一旦方法簽章打錯字,編譯器不會提醒你「你以為的覆寫其實沒覆寫到」。養成標註的習慣。

深入探討(研究所視角)

介面的演化:從純契約到 default / static

Java 8 是介面語意的分水嶺。在那之前,介面是教科書級的「純抽象型別」——只有 public abstract 方法和 public static final 常數,零實作。Java 8 為了讓 Collection 等核心介面能加上 stream()forEach() 等方法而不破壞既有實作,引入了兩種帶本體的介面成員:

public interface Calculator {
    int compute(int x);                      // 抽象方法

    default int computeTwice(int x) {        // default:實例方法,可被覆寫
        return compute(compute(x));
    }

    static Calculator identity() {           // static:屬於介面本身,不可被覆寫
        return x -> x;                       // 回傳一個 lambda(下節說明)
    }
}

static 介面方法屬於介面型別本身(用 Calculator.identity() 呼叫),常用來放工廠方法或工具函式,過去這些只能塞在另一個 XxxUtils 類別裡,現在能收攏回介面內。Java 9 更進一步允許介面有 private 方法,供 default 方法之間共用程式碼,避免重複——這標誌著介面從「型別契約」逐步長出了「行為混入」(mixin)的能力。

值得從型別系統角度想清楚:即使有了 default,介面仍然沒有實例狀態。這正是它避開菱形問題的根本——衝突的永遠只是「方法解析」(哪份 default 算數),編譯器可用一條明確規則裁決(類別自身 > 子介面 > 父介面,仍衝突則強制手動覆寫),而不會有「兩份相互矛盾的欄位值」這種無解狀況。Python 的多重繼承因為類別帶狀態,才需要 C3 線性化那套較重的 MRO 機制;Java 把狀態擋在介面之外,是用型別系統設計換取了衝突解析的簡單性。

函數式介面與 lambda 初探

當一個介面恰好只有一個抽象方法(default / static 不算),它就是函數式介面(functional interface)。這類介面在 Java 8 後有了特殊地位:它可以用 lambda 運算式極簡地實作。

@FunctionalInterface                 // 標註:編譯器會檢查「真的只有一個抽象方法」
interface Transformer {
    int apply(int x);
}

傳統上,你要實作它得寫一個冗長的匿名類別:

Transformer doubler = new Transformer() {
    @Override
    public int apply(int x) {
        return x * 2;
    }
};

Java 8 之後,因為編譯器知道 Transformer 只有一個抽象方法,整段可以濃縮成一行 lambda

Transformer doubler = x -> x * 2;    // 等價於上面那一大段
System.out.println(doubler.apply(21));   // 輸出:42

x -> x * 2 的本質是「一段可傳遞的程式碼」。編譯器看到等號右邊是 lambda、左邊是函數式介面,就自動把它包裝成該介面的一個實例。這讓 Java 終於能把「行為」當參數傳遞,寫出接近函數式風格的程式:

import java.util.Arrays;
import java.util.List;

public class LambdaDemo {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Charlie", "Alice", "Bob");

        // Comparator 是函數式介面,用 lambda 當「排序規則」傳進去
        names.sort((a, b) -> a.length() - b.length());
        System.out.println(names);   // 輸出:[Bob, Alice, Charlie]

        // forEach 收一個函數式介面 Consumer,同樣用 lambda
        names.forEach(n -> System.out.println("名字:" + n));
    }
}

標準函式庫的 java.util.function 套件預先定義了一整組通用函數式介面——Function<T,R>(一進一出)、Predicate<T>(回傳布林)、Consumer<T>(只吃不吐)、Supplier<T>(只吐不吃)。Java 8 的整個 Stream API 都建立在這套之上。

從更高的角度看,這是一段耐人尋味的語言演化軌跡:Java 以「萬物皆物件、OOP-first」起家,曾被批評把簡單的函數也得包成厚重的物件。lambda 與函數式介面是它向函數式範式靠攏的橋——但它沒有像 Python 那樣讓函數直接成為一等公民(first-class function),而是巧妙地把 lambda 解讀為函數式介面的實例,在不破壞既有型別系統的前提下引進函數式能力。底層它也不是每次都 new 一個物件,而是透過 JVM 的 invokedynamic 位元組碼指令在執行期動態生成、並可重複利用實作——這是「靜態強型別、OOP-first 的 JVM 語言」如何在保留型別安全與既有生態相容性的同時,優雅吸納新範式的一個經典案例。

理解了這條線,你就能看懂 Java 介面為何如此中心:它從「多型的契約」出發,承載了「避免多重繼承之惡」的型別設計,再演化成「函數式編程的載體」。一個關鍵字 interface,串起了 Java 物件導向與函數式兩個世界。

AI 共讀助教正在陪你讀:Java 介面與抽象:用契約讓不相干的東西彼此合作
嗨!我是這篇文章的共讀助教,只根據〈Java 介面與抽象:用契約讓不相干的東西彼此合作〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。