.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 struct Test<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 是真的写得很棒。
1
Al0rid4l 2023-08-06 20:52:02 +08:00
Hez 大大 NB
|
2
Planehi 2023-08-06 20:59:10 +08:00 via Android
赞👍
|
3
forgottencoast 2023-08-06 21:55:50 +08:00
太牛了,实在是佩服。
能不能给出具体的使用场景的例子,很想知道具体为什么这种方式很有用。 |
4
USDT 2023-08-06 22:10:04 +08:00
惊呆了,太牛逼了!
@forgottencoast 抖个机灵,toy example: 不定长元组 Tuple<2, int, double> / Tuple<3, int, string, double> 然后说一个猜想,很可能不太对,比方说对于矩阵相关的:可能我有些 geometry 库需要对二维和三维的 point 做处理,也许就可以先写一个基础 lib ,实现 Vector<n>, Matrix<n>。然后 2D 的东西继承了 Vector<2>/Matrix<2>然后再加入其特有的 method ,3D 的也类似 |
5
beginor 2023-08-06 22:31:34 +08:00
这才是真正的高手, 膜拜一下!
|
6
billzhuang 2023-08-06 22:57:41 +08:00 via iPhone
失敬
|
7
kgcHQbTYyvcz2w3j 2023-08-06 23:45:09 +08:00
真大佬
|
8
tyzandhr 2023-08-06 23:56:26 +08:00 via Android
佩服得五体投地
|
9
thinkershare 2023-08-07 00:46:32 +08:00
NB, 我一直想要研究 Runtime, 每次开始就被 C++劝退。现在都还没看完 IL 部分的文档。
|
10
Arainzhe 2023-08-07 00:50:52 +08:00
棒棒了,阔以的,期待一下子
|
11
nulIptr 2023-08-07 03:38:15 +08:00
属于是 ts 类型下放了,泛型传递值这种说法有点奇怪,所以 net9 是实现了类似 TS 的 literal type 吗
|
12
netabare 2023-08-07 05:04:13 +08:00 via iPhone 1
既然可以实现常量参数,是不是对依赖类型的支持也快了
|
13
Chad0000 2023-08-07 05:24:36 +08:00 via iPhone
牛人,我是无论如何也做不到的
|
14
thinkershare 2023-08-07 05:39:26 +08:00
.NET 需要在范型参数约束上添加的功能可太多了,我记得 c# 5.0 出来的时候 Joh Skeet 就在书中列出了一堆未来 Runtime 可能会支持的功能,结果十年过去了,这方面竟然毫无进展。
|
15
klo424 2023-08-07 08:10:01 +08:00
膜拜大佬
|
16
xujinkai 2023-08-07 08:33:16 +08:00 via Android
牛逼
|
17
Surbowl 2023-08-07 08:48:27 +08:00
大佬!
|
18
deali 2023-08-07 09:11:32 +08:00
大…大佬
|
19
hez2010 OP @nulIptr 我倾向于叫他 const generics ,因为和 rust 的 const generics 比较像。
另外,实现 typescript 那种 literal types 我们之前也考虑过,但是拒绝了,因为无法忍受每一个字面量都需要在运行时带上一个 TypeDesc 的开销,并且单纯的 literal types 除了能做体操之外意义不是很大,所以我们倾向于只做类似 C++ 和 Rust 那样的模板值特化。想要 literal types 的话没必要做在 runtime 层面,停留在 C# 编译器层面就行了。 这个特性目前还有一部分内容在设计中,例如算术约束和依赖类型等,我也是最近才刚刚完成了 CoreCLR 部分的实现。所以 .net 9 能不能有我不是很清楚,只是说最早能在 .net 9 实装。 |
20
sleeepyy 2023-08-07 10:33:49 +08:00
|
21
thinkershare 2023-08-07 11:59:40 +08:00
@hez2010 要做算术约束了嘛?现在一堆重载太丑了。
|
22
hez2010 OP @thinkershare 算术约束我已经另一个分支实现了 prototype ,只不过最终实现方案还有待讨论。
|
23
thinkershare 2023-08-07 12:35:12 +08:00
@hez2010 非常需要这些针对泛型的增强功能,我本来以为 C#6.0/7.0 就应该修改 runtime ,然后实现约束。结果 C#6-C#9 更新的都是些我不怎么 care 的,除了 ref/span ,其它都是些不痛不痒的特性。
|
24
leeg810312 2023-08-07 14:28:41 +08:00
好牛 X ,期待在后续版本中体验这个特性
|
25
nikenidage1 2023-08-07 16:44:42 +08:00
主要是 CLR 或者说 IL 始终没有更新,从 2.0 开始就这样,导致很多特性功能束手束脚
|
26
geelaw 2023-08-12 15:40:17 +08:00
一个小问题:此前我实现这种泛型的时候,都是直接把所有的常量都塞到一个 struct 里面,现阶段的 JIT 编译器不能成功优化吗?
public interface IConstants { public int V { get; } } public class G<T> where T : IConstants { public static void Run() { Console.WriteLine(default(T).V); } } public static class Program { struct C : IConstants { int IConstants.V { get { return 1; } } } public static void Main() { G<C>.Run(); } } 我感觉最简单的实现方式是把 const generics 弄成语法糖……? |
27
hez2010 OP @geelaw 弄成语法糖显然是不行的,因为编译器生成的类型只会在它所在的项目编译出来的程序集里存在,假设你在 A 项目里用了个 `Foo<42>`,而 B 项目里用了个 `Bar<42>`,你会发现尽管 42 在两侧值都是一样的,但实际上不是一个类型(前者是 `struct A.IntConstant42`,后者是 `struct B.IntConstant42`),于是相互是不兼容的,于是无法相互传递。而且由于你在 A 处压根不知道 B 处的 `IntConstant42` 类型,隐式转换的代码也是无法生成的(隐式转换操作符不支持从接口转换,也不支持泛型)。
另外就是,这么一来,如果你在运行时通过反射调用包含 const generics 的 API ,那你就不得不现场 emit IL 产生一个新的类型,这和 nativeaot 就是完全不兼容的,通过使用 rd.xml 也无法解决。 |
28
geelaw 2023-08-14 09:40:22 +08:00
@hez2010 #27 第一个场景确实(这个类似于匿名类型的情况),但我没有理解第二个场景(反射 const generics )。
如果用反射为新的 T 调用 class G1<T> where T : struct { } 当然要现场生成代码。 用反射为新的 int 值调用 class G2<int t> { } 也需要现场生成新代码对不对? |
29
hez2010 OP @geelaw 不是的,对于后者,新的值只需要提前提供 instantiation 的参数就能让编译器提前生成代码,比如 42 ,你可以通过 rd.xml 之类的方法来指定需要的泛型参数,就能使得从任何地方调用 `G2<42>` 成功,例如:
```xml <Type Name="G2`1"> <GenericArgument Value="42" Type="System.Int32" /> </Type> ``` 而前者的话无法通过 rd.xml 来指定,因为在编译期对运行时创建的新类型一无所知,通过 rd.xml 只能指定类型的名字,而无法编写类型的实现。 |
30
geelaw 2023-08-14 12:04:08 +08:00 via iPhone
@hez2010 #29 那结论是如果希望用旧方法指定泛型参数的话就要在编译到原生代码的时候提前把类型建立出来,类似于用 rd.xml 设置所有可能需要的值的方式。
|