泻药。
Generic Math 此前在 F# 中有在编译器层面的实现,即通过 inline 为 call site 处的类型生成代码,例如:
let inline add x y = x + y
这段代码的类型签名是:
x: ^a -> y: ^b -> ^c when (^a or ^b) : (static member ( + ) : ^a * ^b -> ^c)
这是 F# 的静态解析类型,和 C++ 的 template 原理一致。这里将 x
和 y
的类型分别视为 ^a
和 ^b
,要求任意其一具有静态成员 +
,且该 +
成员是一个 binary operator。然后调用代码时,例如 let x: int = add 5 6
, 将这里的 int
类型代入 add
方法在编译期生成一个 int -> int -> int
的 add
方法。
但这么做显然是有问题的,最大的问题就在于不跨 ABI:定义的 inline
方法只有 F# 编译器能识别,而通过 ABI 暴露给 C# 或者其他语言使用的话,编译器则并不会知道这是个 inline
方法,也不具备在 call site 处为具体类型生成代码的行为,因此离开了 F# 之后这个方法就无法使用了。
然而,得益于 .NET 6 在类型系统上引入的 virtual static 支持,C# 10 可以通过 abstract static interface method(后续简称 SIM)这一特性在接口上定义抽象静态方法,F# 同样也不再需要上面这样的黑魔法。下面就举回我们熟悉的 C# 作为例子。
.NET 中的运算符都是以静态方法来定义的,例如 C# 的 +
运算符,在编译后的代码中表示为 static TResult op_Addition(TLeft, TRight)
,因此在具备 SIM 前无法对这类方法进行抽象。
但是现在如果要定义一个可以 +
的接口(这里假设操作数和结果类型都相同),则只需要简单的:
interface IAddable<TSelf> where TSelf : IAddable<TSelf> { abstract static TSelf operator+(TSelf left, TSelf right); }
熟悉 C++ 或者 Rust 的同学大概一眼就能看出来这其实是一个 CRTP trait,而 Rust 只是将 TSelf
隐含成了 self
类型。
那么我们对这个接口进行实现之后,就能定义各种泛型方法用于加法运算,例如求和:
T Sum(params T[] values) where T : IAddable<T> { return values.Length == 1 ? values[0] : values[0] + Sum(values[1..]); }
然后对于任意实现了 IAddable<T>
的 T
,我们都可以调用 Sum
方法来进行求和了。
于是标准库中自然也就提供了大量相关的预定义接口用来做这件事情,并且为所有的基础类型(例如 int
、float
等等)都实现了相应的接口,例如:
interface IAdditionOperators<TSelf, TOther, TResult> where TSelf : IAdditionOperators<TSelf, TOther, TResult> { static abstract TResult operator +(TSelf left, TOther right); static abstract TResult checked operator +(TSelf left, TOther right); }
目前尚处于早期阶段,API 未定型,可以通过引入 System.Runtime.Experimental
包来引入这些接口和实现。
不过由于 .NET 泛型约束目前尚不支持“或”关系,只支持“且”关系,因此仍然无法表达 F# 那样的 (^a or ^b) : static member (+)
约束。但是总比什么都没有强,这个可以以后再加。
组合一下加减乘除运算符:
interface INumber<T> : IAdditionOperators<T, T, T>, ISubtractionOperators<T, T, T>, IMultiplyOperators<T, T, T>, IDivisionOperators<T, T, T> { }
即可定义出来一个可以支持四则运算的类型 INumber<T>
。
此外,我们还能借助 parametric polymorphism 表达积类型:
T SomeMethodNeedAdditionAndMultiply<T>(T a, T b, T c) where T : IAdditionOperators<T, T, T>, IMultiplyOperators<T, T, T> { return a + b * c; }
这些全都是支持跨 ABI 调用的,意味着 .NET 上所有的语言都能受益,并且支持高效的互操作。
唯一比较遗憾的点就是前面所说的不支持和类型:泛型约束不属于方法签名的一部分,因此无法用于重载,无法表达类型的“或”关系,这就要等后续的 .NET 版本更新了。
再提一点大家可能会关注的性能问题,这个自然不必担心,.NET 的泛型会为所有的值类型特化一份实现,因此用起来是没有任何的额外开销的。而对于引用类型,尽管有 Shared Generics 机制,然而在 .NET 6 的 PGO 优化加持下也同样能做到和特化类型实现同样的高效。
因此结论是什么?
结论就是借助这一新的运行时特性和语言特性,各种运算库的作者们都可以轻而易举的实现高效的、可扩展的通用计算库,而完全不需要管具体的类型是什么,也不需要对不同类型写一大堆的重载,这将大大简化实现并减少所需要的精力。
不过需要注意的是,该特性在 .NET 6 中是 preview 特性,因此如果以后设计有所变动或者做了更好的实现方式的话,这个特性哪怕被大改甚至完全被删掉也是可能的。