這個做法建議多使用 Interface 來設計一個類別而不是使用 Abstract 搭配繼承。

在設計新類別時討論該使用介面還是抽象是一個常常提到的問題。

抽象基底類別(Abstract base classes) 是把多個有相同概念的類別整理出來, 並透過抽象把這些概念廣義化最後集中在一個類別內變成一個父類別,之後其它子類別只要繼承這個父類別就能使用共用的功能, 一個經典的記憶方法是衍生類別與基底類別必須是 is a 關係。

介面擁有類似的概念,也是透過抽象把相同的概念提取出來,不過介面同常的是描述共通的行為或能力,用英文 behaves likecan do 比較容易理解, 也可以說介面是一種定義行為合約,你可以在這份合約中定義方法、屬性用來假設未來實做介面的類別必須擁有相同的行為能力。

介面與抽象類別最大的不同是介面不能含有實做的內容,因為假如你在某一個介面加上了新的成員,那麼有時作這個介面的類別會顯示報錯並要求你必須實做內容, 但是在 C# 8 中提供了Default Interface Methods, 就是能在介面中實做預設的方法避免更新介面導致程式碼需要重新實做與編譯。

也就是說 C# 介面基本上可以做到與 abstract 相同的事情,只有介面不能建立 field 的區別,不過可以透過自動屬性達到相同的能力, 例如下面的範例 C# 8 以前會報錯,因為它在介面中實做了具體內容,但是現在 C# 能夠允許這樣的寫法。

void Main()
{
	MyInterface m = new MyClass1();
	Console.WriteLine(m.Add());
}

public interface MyInterface
{
	public int Add() => 1;
}

public class MyClass1 : MyInterface {}

這本書的內容只有包含到 C# 7 的功能,所以這個做法之後的內容是以 C# 7 為前提。

在以前可以透過針對介面撰寫擴充方法,來達到好像是在介面實做具體內容的能力,例如 LINQ 中常用到的幾個擴充方法就是透過這個方式來進行, 例如下面這段程式碼定義了一個 MyInterface 與實做它的 MyClass 類別。

void Main()
{
	MyClass m = new MyClass();
	m.Write();
}

public interface MyInterface
{
	public void Write();
}

public class MyClass : MyInterface
{
	public void Write()
	{
		Console.WriteLine("Extensions");
	}
}

透過擴充方法的特性可以做到好像實做了一個方法的假象,讓這個方法好像就是在介面裡面實做的。

void Main()
{
	MyClass1 m = new MyClass1();
	m.Write();
}

public interface MyInterface
{
}

public class MyClass1 : MyInterface
{
}

public static class Extensions
{
	public static void Write(this MyInterface m)
	{
		Console.WriteLine("Extensions");
	}
}

Abstract 則是透過基底類別來提供方法,讓常用的功能都寫在基底的方法裡面來達到共用的效果,這樣就能在衍生的類別重複利用這些方法, 並且在未來只要在基底類別新增功能,其它衍生類別都能透過繼承使用這些功能,這個在傳統的介面是做不到的。

不過由於 C# 只允許單一繼承也就代表一個類別只能擁有一個父類別,這個就會給設計帶來限制,但是介面則不同,一個類別可以同時實做多個介面, 這一點很重要並且可以給開發者帶來更大的彈性。

例如下面三種方法,第一種寫法是使用 IEnumerable 做為傳入參數的型別,代表只要實做這個介面的型別都能傳入, 相比之下第二種與第三種的彈性就沒有這麼高,特別是第三種代表只允許這個型別傳入,所以建議使用介面做為方法參數型別。

public static void PrintCollection<T>(IEnumerable<T> collection)
{
	foreach (T o in collection)
		Console.WriteLine($"Collection contains {o}");
}
public static void PrintCollection(IEnumerable collection)
{
	foreach (object o in collection)
		Console.WriteLine($"Collection contains {o}");
}
public static void PrintCollection(WeatherDataStream collection)
{
	foreach (object o in collection)
		Console.WriteLine($"Collection contains {o}");
}

另外使用介面做為 API 的回傳型別也是相同的道理,如果使用 List 做回方法回傳的型別,那麼在未來想要改用其它型別例如 SortedList 就會帶來麻煩, 所以選擇更廣泛的介面 IEnumerable 或是 IList 是比較好的設計。

介面也可以用來管理共同的屬性,例如在 Employee, Customer, Vendor 這三個類別中就有共同的屬性,雖然可以使用抽象類別與繼承來設計, 但是在這個例子中關聯的強度沒有這麼大,所以依靠介面定義共同屬性是比較合理的設計。

public class Employee
{
	public string FirstName { get; set; }
	public string LastName { get; set; }
	public string Name => $"{LastName}, {FirstName}";
}
public class Customer
{
	public string Name => customerName;
	private string customerName;
}
public class Vendor
{
	public string Name => vendorName;
	private string vendorName;
}

下面就定義了一個介面 IContactInfo 把共用的屬性整理裡面,之後

public interface IContactInfo
{
	string Name { get; }
}
public class Employee : IContactInfo
{
	public string Name => "Allen";
}

這樣的設計就能為原本不相干的型別建立出共用的方法,例如下面只要求傳入的引數有實做 IContactInfo 就可以使用, 代表 Employee, Customer, Vendor 這三個沒有太大關聯的類別都可以使用這個方法。

public void PrintMailingLabel(IContactInfo ic)
{
    // implementation deleted.
}

最後是介面在 Value Type 中的運用,由於 box 與 unbox 會造成的效能問題,所以在下面的寫法會有 box 的產生。

void Main()
{
	var m = new MyStruct();
	Add(m);
}

public int Add(MyInterface m)
{
	return 1;
}

public struct MyStruct : MyInterface {}
public interface MyInterface {}

由於這個方法的需求是引數需要實做 MyInterface 介面,所以可以換個思路搭配泛型來避免 box 的產生。

void Main()
{
	var m = new MyStruct();
	Add<MyStruct>(m);
}

public int Add<T>(T value) where T : MyInterface<int>
{
	return 1;
}

public struct MyStruct : MyInterface<int> {}
public interface MyInterface<T> {}

Summary

這個做法在說明在以前的 C# 版本中使用介面的彈性比抽象基底類別大得多,建議是有強關聯性的才使用抽象基底類別, 如果有共同行為可以使用介面就好。