.NET 的泛型一直以来只能传递类型,而不能传递值。
在 Const Generics (常量泛型)中,允许常量被作为泛型参数传递到泛型变量中,代码会根据常量参数而进行特化,从而确保无开销,并可以直接在代码中作为常量来使用。这个特性在 Rust 中也叫做 Const Generics , 而在 C++ 中叫做模板值特化。
这个特性在各种数值计算、图形/游戏编程、AI/ML 等场景都及其有用。
于是我最近花了一些时间,给 .NET 实现了原生的常量泛型支持,也就是说,.NET 的泛型支持传递常量了!并且我是直接在底层 IL 和 runtime 的层面提供原生的支持,意味着 .NET 上的任何语言都将能够享受到常量泛型。
首先来看一个最基本的例子:
.class public sequential ansi sealed beforefieldinit Test`1<literal int32 T> extends [System.Runtime]System.ValueType { .pack 0 .size 1 // 我们定义的 Add 方法 .method public hidebysig newslot virtual instance int32 Add<literal int32 U>() cil managed { .maxstack 8 ldtoken !T ldtoken !!U add ret } } 上面这段代码简单翻译成伪 C# 代码就是:
public structTest<int T> { public int Add<int U>() { return T + U; } } 现在我们调用 new Test<1>().Add<2>() 看看效果。
Main 函数代码:
.method private hidebysig static void Main () cil managed { .locals init ( [0] valuetype Test`1<int32 (1)> ) .maxstack 8 .entrypoint ldloca.s 0 dup initobj valuetype Test`1<int32 (1)> call instance int32 valuetype Test`1<int32 (1)>::Add<int32 (2)>() call void [System.Console]System.Console::WriteLine(int32) ret } 运行后输出 3。
再看看 Main 函数的反汇编:
sub rsp, 40 mov ecx, 3 call [System.Console:WriteLine(int)] nop add rsp, 40 ret 非常干净!可以看到,等价于直接调用 Console.WriteLine(3),我们的常量泛型参数确实被当作常量处理,于是直接在编译期就计算完结果了。
如果我们手动禁止 Add 方法被内联的话,可以看到 Add 方法的反汇编代码:
mov eax, 3 ret 你会发现,Add 方法的代码居然直接就返回了 3!这是因为我在实现 .NET 的常量泛型时,为不同的常量泛型参数都进行了特化,因此这个 Add 方法实际上是 Test<1>.Add<2>,所有的常量泛型参数都是编译期已知的。
除了上面的简单例子,我还实现了虚方法的支持,因此子类型多态在有常量泛型的场景之下仍然能够正常工作。
另外,在常量泛型参数类型上的泛型我也一并实现了,于是你可以写类似下面的代码:
void Foo<T, T Value>() { Print(Value); } [MethodImpl(MethodImplOptions.NoInlining)] void Print<T>(T value) { Console.WriteLine(value); } 我们调用 Foo<int, 42> 和 Foo<double, 12.3>,可以得到以下输出:
42 12.3 上面两次方法调用同样分别对 42 和 12.3 特化出了两个不同的 Foo 的代码。
42 版本的 Foo:
sub rsp, 40 mov edx, 42 call [Test:Print[int](int):this] nop add rsp, 40 ret 12.3 版本的 Foo:
sub rsp, 40 vzeroupper vmovsd xmm1, qword ptr [reloc @RWD00] call [Test:Print[double](double):this] nop add rsp, 40 ret ; RWD00 dq 402899999999999Ah 上面这些预计最早明年的 .NET 9 就能正式和大家见面。
除此之外,目前我还在着手设计和实现常数泛型的算术约束和算术依赖类型相关的支持。
例如,有了算术约束,将能够约束常量泛型参数值,比如可以约束 N > 10,又或者 N > U && N < U + 10 等等。而有了算术依赖类型,将能实现诸如 Array<T, N + 1> Push<T, int N>(Array<T, N> array, T elem) 的方法。
相信这些基础设施将能为 .NET 的类型系统带来更好的灵活性和表达力,使得 .NET 上所有的语言都能够从中受益。
最后是一些感想。
这次实际上接触和编写了 CoreCLR (.NET 的运行时)的源代码之后,发现尽管 runtime 核心( type loader 、jit 等等)是 C++ 写的,但是代码居然出乎意料地干净易懂,注释也写得非常的详细,上手和调试都很容易。代码里面定义了很多宏用来做 contract ,只需要摆在函数的最开头就行,可以自动验证各种前置/后置条件,以及对 GC 、异常行为等等进行约束,有点类似高级版本的 C++ concepts (之所以说高级版本是因为这些宏既能做编译时验证也能做运行时验证)。
除了注释写的很详细之外,代码中还有大量的 assert ,这些 assert 给我实现 Const Generics 带来了巨大的帮助,因为通过这些 assert 你能立马知道哪里需要修改、哪里做错了等等,甚至不需要了解全部的代码,只通过 assert 就能知道一处代码的改动会影响哪些地方。而且这些 assert 只在 debug 时生效,所以对于实际的性能也没有任何影响。这比从单元测试来猜测试所跑的代码路径中哪一部分出了问题方便多了。
不过 .NET runtime 源代码同样是禁止使用 C++ STL 的,但是代码仓库里面有各种他们自己实现的 utils ,例如 LookupMap 和 Hashtable 等等,用起来非常方便。
另外 .NET 还有 JIT Dump 这种非常好用的设施,不需要挂着调试器就能观察 JIT 的编译过程,有点类似 LLVM Opt Pipeline 但是比 LLVM Opt Pipeline 输出的东西更详细,从 IL 导入到 Tree/IR ,到 SSA 的构建,到 inline 决策,到各种优化 pass ,再到寄存器分配过程全都一目了然。
最后就是非常感谢 .NET runtime 的官方开发人员和社区的开发人员,在我实现 Const Generics 的过程中给了我非常大的帮助,提出的问题也能很及时地得到回应,同时还帮我提意见和测试,使得我能够不断地完善 Const Generics 的设计和实现。
多亏了上面这些,给 .NET 实现 Const Generics 的过程非常顺利。不得不说这个 runtime 是真的写得很棒。
