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 那样的强大表达力和扩展性。而且由于是静态类型,从头到尾都不需要担心任何的类型安全问题。也可以预想到,随着这些特性的推出,将会有不少已有的设计模式因为有了更好的做法而被取代和淘汰。