RustConf 2023 - How Powerful is Const

https://www.youtube.com/watch?v=zXCr1BH5Y-4

大家好,我是尼古拉·瓦斯克斯。有些人可能知道我在Rust社区的一些工作,特别是static assertions这个crate,这是我最受欢迎的库。最近我一直在用const做一些有趣的事情,今天我要和大家分享这些。

那么,const到底有多强大呢?const非常非常强大。谢谢大家。不,别担心,我知道你们来这里不只是为了听这个答案。所以让我们从什么是Rust中的const开始说起。

const值是不可变的,在编译时求值,它们可以相互组合,因为你可以在其他const中使用const。使const能够很好工作的底层工具叫做Miri。Miri代表中级中间表示解释器。这意味着Rust被转换成一种更容易让编译器处理的表示,更容易求值,因为它获得了所有的语义,不需要做Rust前端已经做过的额外工作。这与许多语言的工作方式非常相似。然后Miri被转换为LLVM IR,这与Swift的工作方式相同,Swift也有一个中间表示被转换为LLVM IR,然后转换为机器代码。

const最基本的用法是,比如你有算术运算,像1加2,这将直接求值为3。然后,每次你使用n时,就好像你直接在那里写了3一样。所以你可以使用const来初始化静态变量,静态变量和const的区别在于静态变量是一个运行时值,有特定的地址,而且静态变量可以是可变的,const则永远不能被改变。

const也可以用作类型的参数。比如在这个例子中,我们有一个数组类型。我们看到有三个值,所以我们可以说这是一个长度为n(也就是3)的数组。类似于数组,Rust最近采用了const泛型。这使得你可以传入const值,例如在这个例子中,我们有MyGeneric类型,你可以有任何大小的数组,但是大小需要在编译时知道。所以这里我们有let g是MyGeneric,我们具体说这需要内部有一个长度为3的数组,使用可爱的turbofish语法。和之前一样,我们内部有一个包含三个值的数组。

但const能做的不止这些。你还可以有逻辑和循环。这里我们基本上是在计算2的10次方,也就是1024,但是使用了一个循环。就我使用Rust的时间而言,这是相对较新的功能,但我猜现在已经有一段时间了。

const还可以做编译时断言。在这个例子中,我们说我们想在编译时运行assert宏,如果断言通过,则没有值需要返回,所以我们使用unit类型。如果我们不想让这个assert符号存在,我们可以使用下划线,这是一个通配符,基本上使const成为匿名的。

大多数使用Rust一段时间的人都会熟悉const的表面用法,比如静态值、断言、将枚举转换为整数、我刚提到的循环、可以在编译时求值的const函数,以及类型上的关联常量。

但const能做的远不止大多数人所知道的。你可以做一些有趣的事情,比如连接切片、解析整数或将字符串从UTF-8转码为UTF-16。你可能想进行字符串转码的一个情况是,比如说你在与Java、C或Objective-C交互。这些语言使用UTF-16作为它们的原生编码。所以如果你想对那些你在编译时就知道要直接从Rust传递到这些语言的字符串进行高效处理,你可以在编译时将这些字符串转换为UTF-16。

我要详细介绍的具体例子是连接切片。假设我们有一个字符串str,我们有我们的字符串”howdy”。如果我们想把这个传递给C,我们想要附加一个空字节,这样C就知道字符串在哪里结束。抱歉,这里的字符串不同是我的错误。

但问题是你需要访问字符串字面量,所以如果你想更通用,能够处理任何const,这就不太行得通。如果我们想处理任何const,我们会希望能够连接一个空字节。所以我们可以从取字符串的字节切片开始,然后在创建C字符串时封装一些逻辑。

给定字节… 抱歉,由于一些问题,我没有我的演讲笔记,所以我现在有点即兴发挥。但是,给定你想转换为C字符串的内容,你想要做的是附加一个空字节。所以你想为那个空字节创建空间,因为静态值已经有了特定的分配空间,你不能just改变现有的字符串。

所以我们可以在这里创建新的const字节,为那个空字节多留出一个字节的空间。一旦我们创建了这些字节,我们就可以把它们转换回UTF-8。如果我们想在那部分保持安全,我们可以进行检查转换,正如我之前提到的,你可以在编译时发生恐慌,所以我们可以在那里放置unreachable宏。顾名思义,那段代码不应该运行。如果你对你在这里做的事情非常有信心,你也可以在这里使用from_utf8_unchecked。

你可以通过创建一个包含你想要的字符串数据的包装结构体,然后是空字节的空间,来处理附加空字节。我们可以通过使用repr(C)来保证空字节会在末尾。对于那些不知道的人,C基本上确保结构体具有与你在C中写同样的结构体完全相同的内存表示。在C中,字段的顺序需要是你呈现它们的顺序。在Rust中,如果优化器注意到这个结构体可能会因为把某些字段放在更高或更低的位置而变小,它就被允许重新排列字段,这是因为对齐的原因。

在构造这个空字节时,我们可以通过获取字符串的指针,然后将其转换为u8[Len]数组的指针,然后解引用该指针来提供数据,也就是直接从我们刚刚转换的指针读取。对于null,我们就放一个0,因为这是我们最终想要做的,让C满意。

鉴于append_null保证与长度加1的缓冲区大小相同,我们可以将其转换为原始字节数组。这将给我们提供我们想要的连续数组的布局。这基本上就是你实际需要进行字符串或切片连接的所有代码。具体来说,我看到它主要被用于为C字符串添加空值,但这个方法适用性很广。你可以让T类型是Copy的,然后连接那种类型的切片。但这是我最常见到的特定实例。

这些是你可以用const使用的一些更高级的功能,你可能不会经常用到,或者你可能从未遇到过需要这样做的情况。但对我们中的一些人来说,我们最终会处理这些,而且不仅仅是这些,我们最终会处理一些诅咒般的const。所以情况会变得更糟。

这三个crate做了一些非常有趣的事情。const_format crate基本上提供了类似于format宏的功能,但可以在编译时使用。impls crate让你检查一个类型是否实现了给定的一组trait,你可以将检查结果(布尔值)赋给一个常量。它在底层的工作方式也是使用常量来实现的。还有另一个crate叫const_type,我将介绍它的实现,它允许你进行条件类型。这意味着给定一个条件(true或false),你可以选择类型A或类型B。

考虑你有一个需要处理字节吞吐量的函数。自然地,我们处理切片,所以切片的长度将是一个usize。usize特别是一个指针宽的整数。这是因为,在32位的情况下,内存地址将是4字节。你不能引用超过2GB的内存,所以usize能够表示大于2GB的大小是没有意义的。

我们自然会使用这个与切片一起使用,这工作得很好。所以我们有这个数据块,它具体是什么并不重要,但你知道它适合内存,我们想要记录我们正在处理的吞吐量,或者你想管理那个值。但考虑到我们也想让这个适用于从文件系统获取的字节,我们不一定想一次性读取整个文件,因为考虑你在一个32位系统上,你有这个非常大的文件,它就是不能适应内存。结果,文件长度实际上最终使用u64,因为磁盘可以存储比32位系统上的内存更多的内容。所以你最终在usize和u64之间产生了不匹配,这由编译器演示。它会很好地告诉你,哦,你知道,你可以尝试转换它,如果它恰好适合usize,那么它就通过了,否则如果你调用这个try_into并unwrap,你可能会出现恐慌。

所以你可能会尝试将handle_throughput改为接受一个u64。这在大多数情况下工作得很好,但考虑一下未来我们有128位系统的情况。不是说我们在近期内一定需要它,但假设你真的想要未来的兼容性,或者说你知道这种转换为u64有时你使用usize,有时你使用u64,你可能会不小心在从u64到usize或反之时截断。

我们确实有这些usize的转换,我们不能just做一个From实现,因为我们看到u32和u64没有列为转换。所以即使Rust标准库也说过,哦,是的,这可能最终会截断,所以我们不想确保这是一个无损转换。

所以我们有这两种类型的冲突,我们想能够表示这两种类型中较大的那个。让我们使用那个。在64位系统上,这两个将是相同的,在32位系统上,你最终会使用u64,在假设的128位系统上,你最终会使用usize作为两者中较大的。

你可以将其表示为一个枚举,有一个usize的变体和一个u64的变体,但问题是你最终需要额外的内存just为了能够在运行时说哦,我使用的是哪一个,即使你在编译时知道你想要哪一个。

我们可以通过使用联合来缓解这个问题,但这带来了它自己的问题。为了访问… 回顾一下,联合和枚举的区别在于,枚举在运行时会标记你使用的是哪个变体,而联合本质上是未标记的枚举,反之亦然,枚举有时被称为标记联合。

因为我们在运行时丢弃了这个标记,我们需要在写代码时确定我们实际上想要确定使用哪一个,这就是为什么你需要用unsafe来包装,以访问这里的usize字段或u64字段。

一些项目禁止使用unsafe,这just不是一个解决方案。此外,你现在有另一个你要处理的类型,所以这可能会变得笨拙,所以你可能需要在上面实现方法,说哦,from_usize或from_u64,然后以一种相当尴尬的方式处理它。

所以有一种方法可以让我们根据usize是否大于64来表示usize_64为u64或usize。我们可以根据一个泛型常量来做到这一点。这里我有一个叫做Proxy的trait,我试图获取这个关联类型u64的访问权。我们可以在unit类型上实现这个Proxy trait,just为了有something来实现它。所以我们可以说,当指针大于64时,让我们使用usize,如果不是,如果是false,让我们使用u64。

这实际上工作得相当好。你可以有你的条件,哦,如果usize大于u64,那么你可以最终获得那个关联类型的访问权,它将根据那个常量最终成为usize或u64。这真的很笨拙。它确实让我们达到了我们想要的目的,但这是非常奇怪的代码看起来,它不是真正最好的可维护的东西,你真的不会想要说哦,unit作为Proxy大于u64 usize_64,just为了能够在编译时动态地决定你是使用usize还是u64。

我们实际上可以将这个结果分配给一个类型别名,然后我们just可以继续使用那个类型别名,因为我们有usize或u64,我们just可以直接使用整数字面量。我们也可以… 我错过了一件事,抱歉… 但我们可以使这个成为泛型,想象你有一个有泛型布尔值的类型别名,它可以最终成为你之前看到的

好的,我会继续翻译和整理剩余的内容:

同样的代码。它可以别名为U元组,或者说空元组,也称为unit,作为Proxy usize_64,然后我们可以将其别名为const_type。这就是const_type crate所做的。它还为其他大小提供了这些usize_64,如果你需要的话。

回到我们处理吞吐量的例子,比如说日志记录。现在当我们获取文件长度或切片长度时,我们可以无损地转换,无论目标平台如何,都可以传递给这个函数。我们总是会有一个正确的整数,我们永远不会截断,我们也永远不会实际使用超过我们需要的空间。

这些就是包装这些非常奇怪和不同的const用法的一些crate。如果你真的想走得那么远的话。除此之外,还有更多即将到来的功能。有各种特性,我认为很多人都很兴奋,正在开发中。特别是const_heap最终将允许在编译时分配向量,然后基本上创建在运行时相当于切片的东西,而不进行任何运行时分配,因为它是在编译时完成的。

另一个我非常期待的特性是const trait impl。这具体意味着你可以在const中使用各种trait。现在它非常有限,真正唯一可以使用的trait是Copy,但这just是说你不需要调用clone,它不是move-only的。所以它没有真正的方法,just是编译器自己如何处理这个值的问题。

如果你真的想在const方面走得更远,超越这些正在稳定的特性所做的,有一个不稳定的标志叫做”unleash_the_miri_inside_you”,它能让你用const做更多的事情。然而,编译器很可能会崩溃。

所以这个冰山变成了冰山。回顾一下,大多数人真的只会处理某一部分,但知道如果你遇到非常有趣的情况,你可以使用这些其他非常独特和不同的解决方案,这真的很好。

所以总结一下,这就是我一直在做的工作。const_type和impls crate是我的。我很高兴看到人们可以用const做什么。希望这能激发灵感。如果你想跟上我在做什么,我在Mastodon上是@nikolai@hacky.dev,在Twitter上是@nikolai_vasquez。如果你想订阅电子邮件更新,我现在有一个通讯,你可以在news.nicolaivasquez.com上订阅。

我有两个有趣的公告。我正在开发一个我很自豪的基准测试库,我希望下周宣布它,它叫做Devon,所以请关注它。此外,我目前正在与Frederick Greenaway合作,他就在那里,帮助他使他的Trustfall项目更强大,该项目能够查询各种数据资源,看看我们如何能够使企业变得更有生产力。

是的,这很令人兴奋。所以,就是这样。非常感谢大家。

文章目录