介面在 C# 裡常被簡化成一句話:「它是規格,不是實作。」這句話方向沒錯,但如果只停在這裡,很多實際行為會很難理解,例如隱式實作、顯式實作、泛型介面、值型別轉成介面時的 boxing。從 CLR 角度把它看清楚,這些規則其實是一整套連續設計。

介面定義的是能力契約

介面可以定義方法、屬性、事件與索引器,但它不負責提供實例狀態。從設計意圖上看,介面描述的是「某個型別承諾能做什麼」,而不是「這個型別內部怎麼存資料」。

public interface IWorker
{
    void Work();
    string Name { get; }
}

這段只是在說: 任何實作 IWorker 的型別,都必須能工作,並且能提供名稱。它沒有說明欄位長怎樣,也沒有規定內部演算法。

在 CLR 眼裡,介面也是型別定義

雖然介面不能直接 new 出物件,但 CLR 仍然把它當成一種正式型別。這代表:

  • 介面有自己的 metadata
  • 可以透過反射取得它的方法與成員資訊
  • runtime 需要維護「某個具體型別實作了哪些介面、每個介面成員對應到哪個方法」

也就是說,介面不是單純的編譯器語法概念,而是 runtime 真正理解的一級公民。

實作介面時,方法表映射才是關鍵

當一個型別實作介面時,真正重要的不只是「有沒有同名方法」,而是 CLR 必須知道這個介面的每個成員要映射到該型別的哪個方法。

public interface ITestInterface
{
    void SealTest();
    void VirtualTest();
}

public class TestConcreteClass : ITestInterface
{
    public void SealTest() { }
    public virtual void VirtualTest() { }
}

這裡 SealTestVirtualTest 都能滿足介面契約,但兩者是否可被衍生類別覆寫,取決於類別本身是否把方法定義成 virtual,不是取決於介面。

也就是說:

  • 介面只要求「必須有這個能力」
  • 類別自己決定「這個能力能否再被覆寫」

隱式介面實作是最常見形式

最直覺的寫法,就是直接寫一個 public 成員來對應介面:

public interface IDisposableLike
{
    void Dispose();
}

public sealed class SimpleType : IDisposableLike
{
    public void Dispose()
    {
        Console.WriteLine("Dispose");
    }
}

這種寫法稱為隱式介面實作。好處是:

  • 類別使用者直接透過物件就能呼叫方法
  • 介面參考也能呼叫同一個方法
  • API 最直觀
var obj = new SimpleType();
obj.Dispose();

IDisposableLike disposable = obj;
disposable.Dispose();

這兩次呼叫最終都會落到同一個實作。

方法是否 virtual,影響的是類別繼承鏈,不是介面本身

如果類別把介面方法定義成 virtual,衍生類別就能覆寫:

public class BaseWorker : IWorker
{
    public virtual void Work()
    {
        Console.WriteLine("Base Work");
    }

    public string Name => "Base";
}

public class DerivedWorker : BaseWorker
{
    public override void Work()
    {
        Console.WriteLine("Derived Work");
    }
}

若沒有 virtual,衍生類別即使寫出同名方法,也只是 new 出一個新成員,不是覆寫原本那個介面映射的方法。

這也是為什麼同一個物件,在「以類別型別看」和「以介面型別看」時,可能會出現不同呼叫結果。關鍵不是語法表面,而是 runtime 綁定到哪個介面實作槽位。

顯式介面實作用來隱藏或分流介面成員

除了隱式實作,C# 還支援 EIMI: Explicit Interface Method Implementation。

public sealed class SimpleType : IDisposable
{
    public void Dispose()
    {
        Console.WriteLine("SimpleType Dispose");
    }

    void IDisposable.Dispose()
    {
        Console.WriteLine("IDisposable Dispose");
    }
}

這時:

var obj = new SimpleType();
obj.Dispose(); // SimpleType Dispose

((IDisposable)obj).Dispose(); // IDisposable Dispose

顯式實作的特性很明確:

  • 不能寫存取修飾詞
  • 不能直接透過類別實例名稱呼叫
  • 只能先轉成對應介面後再呼叫
  • 通常視為 private 且不可再覆寫的介面對應實作

它的主要用途不是炫技,而是解決名稱衝突或故意把某些介面能力藏在介面視圖後面。

多個介面有同名成員時,顯式實作通常最乾淨

如果同一個類別同時實作兩個介面,而兩個介面有相同簽章的方法,顯式實作通常是最直接的做法:

public interface ITestA
{
    void Test();
}

public interface ITestB
{
    void Test();
}

public class Tester : ITestA, ITestB
{
    void ITestA.Test()
    {
        Console.WriteLine("A");
    }

    void ITestB.Test()
    {
        Console.WriteLine("B");
    }
}

呼叫時就必須先轉型:

var tester = new Tester();
((ITestA)tester).Test();
((ITestB)tester).Test();

這裡的重點不是「語法麻煩」,而是你明確告訴 runtime: 同一個類別可以對不同介面提供不同版本的實作。

介面映射是型別內部結構的一部分

當型別載入到 CLR 時,runtime 會建立這個型別的內部描述資料,其中包含它自己的方法,也包含它所實作介面的映射資訊。

可以把它理解成一種「介面方法到具體方法的對照表」。因此,介面呼叫不是單純比對名字,而是透過型別資訊找到正確的實作入口。這也是為什麼顯式實作、覆寫、隱藏等行為會有細微差異。

泛型介面把型別安全與效能一起拉上來

非泛型介面在早期很常見,例如 IComparable

public interface IComparable
{
    int CompareTo(object obj);
}

問題是這會帶來兩個成本:

  • 編譯期型別資訊不足,呼叫端得自己保證傳對型別
  • 值型別參與時,常常需要 boxing

因此現代 C# 更常用泛型介面,例如 IComparable<T>

public sealed class Number : IComparable<int>, IComparable<string>
{
    private readonly int _value = 5;

    public int CompareTo(int other)
    {
        return _value.CompareTo(other);
    }

    public int CompareTo(string other)
    {
        return _value.CompareTo(int.Parse(other));
    }
}

這種設計的好處很實際:

  • 編譯器能檢查型別是否正確
  • 一個型別可以針對不同型別提供不同比較邏輯
  • 值型別使用時,更容易避免不必要 boxing

介面約束讓泛型方法只接受有特定能力的型別

介面除了拿來做多型,也常被拿來當泛型約束:

void M<T>(T value) where T : IComparable, IConvertible
{
}

這表示只有同時實作 IComparableIConvertible 的型別才能傳進來。

int x = 5;
M(x); // OK

Guid g = Guid.NewGuid();
// M(g); // 編譯失敗

這種設計把「能力需求」提早到編譯期表達,而不是等執行時再丟例外。

介面約束還能減少值型別 boxing

這是介面約束很容易被忽略的一個實際收益。比較下面兩種寫法:

void N(IComparable value)
{
}

void M<T>(T value) where T : IComparable
{
}

若你把 int 傳進 N,因為參數型別是介面參考,int 通常必須先 boxing 才能被當成 IComparable 傳遞。

M<T> 不一樣。編譯器和 JIT 知道 T 必須實作 IComparable,卻不必先把它裝箱成介面物件再呼叫,因此在很多情況下能保留值型別語意,減少額外配置。

這也是泛型介面與泛型約束常被視為「不只更型別安全,也更有效率」的原因。

什麼時候該用介面,什麼時候不該硬上

適合用介面的場景:

  • 你要表達的是能力契約,而不是共用狀態
  • 多個不相干型別都需要提供同一種操作
  • API 呼叫端應該依賴抽象,而不是依賴具體類別

不適合硬塞介面的場景:

  • 根本不需要多型,只是為了「看起來有架構」
  • 型別之間其實有大量共享實作,抽成基底類別更自然
  • 介面拆得太碎,導致呼叫方反而更難理解整體模型

介面是抽象工具,不是架構裝飾品。若沒有清楚的多型需求,介面越多通常只會讓系統更難讀。

實務上該記住什麼

  • 介面描述的是能力契約,不是物件狀態。
  • CLR 真的會追蹤介面到具體方法的映射,不只是編譯器表面規則。
  • 隱式實作最直觀;顯式實作用來處理衝突或刻意隱藏介面成員。
  • 泛型介面比非泛型介面更型別安全,也更容易避免 boxing。
  • 介面約束能把能力需求提早到編譯期,並改善值型別傳遞效率。

參考資料

  • 《CLR via C#》 Jeffrey Richter