给 .NET 实现了 Const Generics

2023-08-06 20:46:57 +08:00
 hez2010

.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

上面两次方法调用同样分别对 4212.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 是真的写得很棒。

2853 次点击
所在节点    程序员
32 条回复
Al0rid4l
2023-08-06 20:52:02 +08:00
Hez 大大 NB
Planehi
2023-08-06 20:59:10 +08:00
赞👍
forgottencoast
2023-08-06 21:55:50 +08:00
太牛了,实在是佩服。
能不能给出具体的使用场景的例子,很想知道具体为什么这种方式很有用。
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 的也类似
beginor
2023-08-06 22:31:34 +08:00
这才是真正的高手, 膜拜一下!
billzhuang
2023-08-06 22:57:41 +08:00
失敬
kgcHQbTYyvcz2w3j
2023-08-06 23:45:09 +08:00
真大佬
tyzandhr
2023-08-06 23:56:26 +08:00
佩服得五体投地
thinkershare
2023-08-07 00:46:32 +08:00
NB, 我一直想要研究 Runtime, 每次开始就被 C++劝退。现在都还没看完 IL 部分的文档。
Arainzhe
2023-08-07 00:50:52 +08:00
棒棒了,阔以的,期待一下子
nulIptr
2023-08-07 03:38:15 +08:00
属于是 ts 类型下放了,泛型传递值这种说法有点奇怪,所以 net9 是实现了类似 TS 的 literal type 吗
netabare
2023-08-07 05:04:13 +08:00
既然可以实现常量参数,是不是对依赖类型的支持也快了
Chad0000
2023-08-07 05:24:36 +08:00
牛人,我是无论如何也做不到的
thinkershare
2023-08-07 05:39:26 +08:00
.NET 需要在范型参数约束上添加的功能可太多了,我记得 c# 5.0 出来的时候 Joh Skeet 就在书中列出了一堆未来 Runtime 可能会支持的功能,结果十年过去了,这方面竟然毫无进展。
klo424
2023-08-07 08:10:01 +08:00
膜拜大佬
xujinkai
2023-08-07 08:33:16 +08:00
牛逼
Surbowl
2023-08-07 08:48:27 +08:00
大佬!
deali
2023-08-07 09:11:32 +08:00
大…大佬
hez2010
2023-08-07 10:20:14 +08:00
@nulIptr 我倾向于叫他 const generics ,因为和 rust 的 const generics 比较像。
另外,实现 typescript 那种 literal types 我们之前也考虑过,但是拒绝了,因为无法忍受每一个字面量都需要在运行时带上一个 TypeDesc 的开销,并且单纯的 literal types 除了能做体操之外意义不是很大,所以我们倾向于只做类似 C++ 和 Rust 那样的模板值特化。想要 literal types 的话没必要做在 runtime 层面,停留在 C# 编译器层面就行了。
这个特性目前还有一部分内容在设计中,例如算术约束和依赖类型等,我也是最近才刚刚完成了 CoreCLR 部分的实现。所以 .net 9 能不能有我不是很清楚,只是说最早能在 .net 9 实装。
sleeepyy
2023-08-07 10:33:49 +08:00

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/962851

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX