Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

介面與抽象

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 介面與抽象:用契約讓不相干的東西彼此合作〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。