天天看點

C# 對類型系統擴充性的改進

作者:DotNET技術圈

前言

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

,它隻暴露了

Id

Name

Age

屬性,而不會暴露來自

Data

Values

屬性。以及,它可以被傳到任何接受

Person

Data

或者

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

那樣的強大表達力和擴充性。而且由于是靜态類型,從頭到尾都不需要擔心任何的類型安全問題。也可以預想到,随着這些特性的推出,将會有不少已有的設計模式因為有了更好的做法而被取代和淘汰。

繼續閱讀