C# 對型別系統擴充套件性的改進

語言: CN / TW / HK

前言

C# 對型別系統進行改進一直都沒有停過,這是一個長期的過程。C# 8 之後則主要圍繞擴充套件性方面進行各種改進,目前即將釋出的 C# 11 中自然也包含該方面的進度。這些改進當然還沒有做完,本文則介紹一下已經推出和即將推出的關於這方面改進的新特性。

介面

我們從最初的最初開始說起。

介面(interface)在 C# 的型別系統中是一個非常關鍵的部分,用來對行為進行抽象,例如可以抽象“能被從字串解析為整數”這件事情的介面可以定義為:

interface IIntParsable
{
    int Parse(string text);
}

這樣一切實現了該介面的型別就可以直接轉換為 IIntParsable ,然後呼叫其  Parse 方法把  string 解析成  int ,用來根據字串來建立整數:

class IntFactory : IIntParsable
{
    public int Parse(string text) { ... }
}

但是這樣顯然通用性不夠,如果我們不想建立 int ,而是想建立其他型別的例項的話,就需要定義無數個型別不同而抽象的事情相同的介面,或者將  Parse 的返回值改成  object ,這樣就能通用了,但是對值型別會造成裝箱和拆箱導致效能問題,並且呼叫方也無法在編譯時知道  Parse 出來的到底是個什麼型別的東西。

泛型介面

為了解決上面的這一問題,C# 進一步引入了泛型。泛型的引入允許介面定義型別引數,因此對於上面的介面而言,不再需要為不同型別重複定義介面,而只需要定義一個泛型介面即可:

interface IParsable<T>
{
    T Parse(string text);
}

這樣,當一個型別需要實現 IParsable<T> 時,就可以這麼實現了:

class IntFactory : IParsable<int>
{
    public int Parse(string text) { ... }
}

由此,我們誕生了各式各樣的工廠,例如上面這個 IntFactory 用來根據  string 來建立  int 。基於這些東西,甚至發展出了一個專門的工廠模式。

但是這麼做還有一個問題,假如我在介面中添加了一個新的方法 Foo ,那麼所有實現了這個介面的型別就不得不實現這個新的  Foo ,否則會造成編譯失敗。

介面的方法預設實現

為了解決上述問題,C# 為介面引入了預設介面實現,允許使用者為介面新增預設的方法實現。有了預設實現之後,即使開發者為一個介面添加了新的方法,只要提供一個預設實現,就不會導致型別錯誤而編譯失敗:

interface IParsable<T>
{
    T Parse(string text);
    public void Foo() { ... }
}

這樣一來, IParsable<T> 就有  Foo 方法了。不過要注意的是,這個  Foo 方法不同於  Parse 方法, Foo 如果沒有被實現,則不是虛方法,也就是說它的實現在介面上,而不會帶到沒有實現這個介面的類上。如果不給類實現  Foo 無法呼叫的,除非把型別強制轉換到介面上:

class IntFactory : IParsable<int>
{
    public int Parse(string text) { ... }
}

interface IParsable<T>
{
    T Parse(string text);
    public void Foo() { ... }
}

var parser = new IntFactory();
parser.Foo(); // 錯誤
((IParsable<int>)parser).Foo(); // 沒問題

介面的靜態方法預設實現

既然介面能預設實現方法了,那擴充一下讓介面支援實現靜態方法也是沒有問題的:

interface IParsable<T>
{
    T Parse(string text);
    public void Foo() { ... }
    public static void Bar() { ... }
}

不過,介面中的這樣的靜態方法同樣不是虛方法,只有在介面上才能進行呼叫,並且也不能被其他型別實現。跟類中的靜態方法一樣,想要呼叫的時候,只需要:

IParsable<int>.Bar();

即可。

你可能會好奇這個和多繼承有什麼區別,C# 中介面的預設實現都是非虛的,並且還無法訪問欄位和不公開的方法,只當作一個向前相容的設施即可,因此不必擔心 C++ 的多繼承問題會出現在 C# 裡面。

介面的虛靜態方法

將介面的靜態方法作為非虛方法顯然有一定的侷限性:

  • 只能在介面上呼叫靜態方法,卻不能在實現了介面的類上呼叫,實用性不高

  • 類沒法重寫介面靜態方法的實現,進而沒法用來抽象運算子過載和各類工廠方法

因此,從 C# 10 開始,引入了抽象/虛靜態方法的概念,允許介面定義抽象靜態方法;在 C# 11 中則會允許定義虛靜態方法。這樣一來,之前的 IParsable<T> 的例子中,我們就可以改成:

interface IParsable<T>
{
    abstract static T Parse(string text);
}

然後我們可以對該介面進行實現:

struct Int32 : IParsable<Int32>
{
    public static int Parse(string text) { ... }
}

如此一來,我們組合泛型約束,誕生了一種全新的設計模式完全代替了原來需要建立工廠例項的工廠模式:

T CreateInstance<T>(string text) where T : IParsable<T>
{
    return T.Parse(text);
}

原來需要專門寫一個工廠型別來做的事情,現在只需要一個函式就能完成同樣甚至更強大的功能,不僅能省掉工廠自身的分配,編寫起來也更加簡單了,並且還能用到運算子上!原本的工廠模式被我們徹底扔進垃圾桶。

我們還可以將各種介面組合起來應用在泛型引數上,例如我們想編寫一個通用的方法用來計算 a * b + c ,但是我們不知道其型別,現在只需要簡單的:

V Calculate<T, U, V>(T a, U b, V c)
    where T : IMultiplyOperators<T, U, U>
    where U : IAdditionOperators<U, V, V>
{
    return a * b + c;
}

其中 IAdditionOperators 和  IMultiplyOperators 都是 .NET 7 自帶的介面,三個型別引數分別是左運算元型別、右運算元型別和返回值型別,並且給所有可以實現的自帶型別都實現了。於是我們呼叫的時候只需要簡單的  Calculate(1, 2, 3) 就能得到  5 ;而如果是  Calculate(1.0, 1.5, 2.0) 則可以得到  3.5

角色和擴充套件

至此,介面自身的演進就已經完成了。接下來就是 C# 的下一步計劃:改進型別系統的擴充套件性。下面的東西預計會在接下來的幾年(C# 12 或者之後)到來。

C# 此前一直是一門面向物件語言,因此擴充套件性當然可以通過繼承和多型來做到,但是這麼做有很大的問題:

  • 繼承理論本身的問題:例如根據繼承原則,正方形型別繼承自長方形,而長方形又繼承自四邊形,但是長方形其實不需要獨立的四邊長度、正方形也不存在長寬的說法,這造成了實現上的冗餘和定義上的不準確

  • 對類而言,只有單繼承,沒法將多個父類組合起來繼承到自類上

  • 與值型別不相容,因為值型別不支援繼承

  • 對介面而言,雖然型別可以實現多個介面,但是如果要為一個型別新增新的介面,則需要修改型別原來的定義,而無法進行擴充套件

最初為了支援給型別擴充套件新的方法,C# 引入了擴充套件方法功能,滿足了大多數情況的使用,但是侷限性很大:

  • 擴充套件方法只能是靜態方法,無法訪問被擴充套件型別內部的私有成員

  • 擴充套件方法不支援索引器,也不支援屬性,更不支援運算子

社群中也一直存在不少意見希望能讓 C# 支援擴充套件一切,C# 8 的時候官方還實現了這個功能,但是最終在釋出之前砍掉了。

為什麼?因為有了更好和更通用的做法。

既然我們已經有了以上對介面的改進,我們何必再去給一個侷限性很大的擴充套件方法縫縫補補呢?因此,角色和擴充套件誕生了。

在這個模式裡,介面將成為核心,同時徹底拋棄了繼承。介面由於自身的特點,在 C# 中也天然成為了 Rust 中 dyn trait 以及 Haskell 中  type class 的等價物。

注意:以下的東西目前都處於設計階段,因此下述內容只是對目前設計的介紹,最終的設計和實現可能會隨著對相關特性的進一步討論而發生變化,但是總體方向不會變。

角色

一個角色在 C# 中可以採用如下方式定義:

role Name<T> : UnderlyingType, Interface, ... where T : Constraint

這樣一來,如果我們想給一個已有的型別 Foo 實現一個有著介面  IBar 的角色,我們就可以這麼寫:

role Bar : Foo, IBar { ... }

這樣我們就建立了一個角色 Bar ,這個  Bar 則只實現了  IBar ,而不會暴露  Foo 中的其他成員。且不同於繼承, Foo 和  Bar 本質上是同一個型別,只是擁有著不同的角色,他們之前可以相互轉換。

舉一些現實的例子,假設我們有一個介面 IPerson

interface IPerson
{
    int Id { get; }
    string Name { get; }
    int Age { get; }
}

然後我們有一個型別 Data 使用字典儲存了很多資料,並且  Data 自身具有一個  Id

class Data
{
    public int Id { get; }
    public Dictionary<string, string> Values { get; } =  ...;
}

那我們就可以給 Data 建立一個  Person 的角色:

role Person : Data, IPerson
{
    public string Name => this.Values["name"];
    public int Age => int.Parse(this.Values["age"]);
}

其中,無需實現 Id ,因為它已經在  Data 中包含了。

最終,這個 Person 就是一個只實現了  IPerson 的  Data ,它只暴露了  IdName 和  Age 屬性,而不會暴露來自  Data 的  Values 屬性。以及,它可以被傳到任何接受  PersonData 或者  IPerson 的地方。

我們還可以組合多個介面來建立這樣的角色,例如:

interface IHasAge
{
    int Age { get; }
}

interface IHasName
{
    string Name { get; }
}

role Person : Data, IHasAge, IHasName
{
    // ...
}

這樣我們把 IPerson 拆成了  IHasAge 和  IHasName 的組合。

另外,在不實現介面的情況下,角色也可以用來作為型別的輕量級封裝:

role Person : Data
{
    public string Name => this.Values["name"];
    public int Age => int.Parse(this.Values["age"]);
}

如此一來, Person 將成為一種提供以“人”的方式訪問  Data 的方法的型別。可以說,角色就是對同一個“data”的不同的“view”,一個型別的所有角色和它自身都是同樣的型別,在本質上和繼承是完全不同的!與其他語言的概念類比的話,角色就等同於 concepts,這也意味著 C# 向 structural typing 邁出了一大步。

擴充套件

有了角色之後,為了解決擴充套件性的問題,C# 將會引入擴充套件。有時候我們不想通過角色來訪問一個物件裡的東西,我們可以直接在外部擴充套件已有的型別。

extension DataExtension : Data
{
    public string Name => this.Values["name"];
    public string ToJson() { ... }
}

這樣, Data 型別就有了名為  Name 的屬性和  ToJson 的方法,可以直接呼叫。除了屬性和方法之外,擴充套件一個索引器自然也不在話下。

其中的 ToJson 類似以前的擴充套件方法,不過如此一來,以前 C# 的擴充套件方法特性已經徹底被新的擴充套件特性取代,而且是上位替代,功能性和靈活性上遠超原來的擴充套件方法。

我們還可以給型別擴充套件實現介面:

extension DataExtension : Data, IHasName
{
    public string Name => this.Values["name"];
}

這樣一來, Data 就實現了  IHasName ,可以傳遞到任何接受  IHasName 的地方。

甚至藉助介面的虛靜態方法和泛型,我們可以給所有的整數型別擴充套件一個遍歷器,用來按位元組遍歷底層的表示:

extension ByteEnumerator<T> : T, IEnumerable<byte> where T : unmanaged, IShiftOperators<T, T>
{
    public IEnumerator<byte> GetEnumerator()
    {
        for (var i = sizeof(T); i > 0; i--)
        {
            yield return unchecked((byte)this >> ((i - 1) * 8));
        }
    }
}

foreach (var b in 11223344556677L)
{
    Console.WriteLine(b);
}

配合介面的靜態方法,我們甚至能給已有的型別擴充套件實現運算子!

extension MyExtension : Foo, IAdditionOperators<Foo, Foo, Foo>
{
    public static Foo operator+(Foo left, Foo right) { ... }
}

var foo1 = new Foo(...);
var foo2 = new Foo(...);
var result = foo1 + foo2;

總結

C# 從 8 版本開始逐漸開始對介面進行操刀,最終的目的其實就是為了實現角色和擴充套件,改善型別系統的擴充套件性。到了 C# 11,C# 對介面部分的改造已經全部完成,接下來就是角色和擴充套件了。當然,目前還為時尚早,具體的設計和實現也可能會變化。

最終,藉助介面、泛型、角色和擴充套件,C# 的型別系統將擁有等同於 Haskell 的 type class 那樣的強大表達力和擴充套件性。而且由於是靜態型別,從頭到尾都不需要擔心任何的型別安全問題。也可以預想到,隨著這些特性的推出,將會有不少已有的設計模式因為有了更好的做法而被取代和淘汰。