[译] Rust标准库有些特殊,让我们改它

本篇是对 RustConf 2023中的The standard library is special. Let's change that.这一视频的翻译与整理, 过程中为符合中文惯用表达有适当删改, 版权归原作者所有.

今天我将讨论Rust的标准库,更具体地说,是关于标准库有何特殊之处,以及为什么我们应该改变这一点。首先声明一下,任何团队的成员都没有看过这个演讲,一切都是我的观点,部分是基于观察,部分是理想化的。显然,理想化的部分不保证会发生。

那么,当我说标准库是特殊的时候,我是什么意思呢?更重要的是,为什么我应该关心呢?事实证明,标准库在很多方面都是特殊的。

首先,标准库在稳定版上使用了nightly特性。虽然大量这些特性计划被稳定化,但有些特性打算永远保持在nightly上。这些应该尽可能被移除。

标准库还能够绕过一致性。这也不是绝对必要的。目前core、alloc和std是三个独立的crate。如果它们被合并成一个单一的crate,绕过一致性的需求就会完全消除。

标准库另一种独特的方式是它有一个预导入(Prelude)。其他crate也有预导入,但它们不是真正的预导入,因为它们必须手动导入到每个模块中。

标准库最明显的显著特殊之处是它不包含在cargo中。虽然看似无关紧要,但它确实有实际影响。标准库没有任何公开暴露的特性标志,这些标志可以用于基于能力的库。除此之外,这还可以允许禁用文件系统或网络访问。

std也是为单个优化目标速度而构建的,而像嵌入式系统这样的用例可能希望合理地优化大小。另一个限制是std不能独立版本控制,也不能做出破坏性更改。有一种可能的未来,可以在不需要std 2.0的情况下做出破坏性更改,但我今天不会讨论这个。

最后,我们已经在使标准库变得不那么特殊了。我认为以一种有组织的方式, 而不是像多年来那样临时进行这项工作是有意义的。

好吧,所以我们想让标准库不那么特殊。当然,这肯定有限制,对吧?是的,但这引出了一个问题:什么是(对std库)必须特殊对待的内在因素?

首先是语言项(language items)。语言项是编译器出于各种原因需要了解的函数、trait、类型等。目前有130个这样的项。例如,Add trait是一个语言项,因为编译器需要了解它以支持加法运算符。其中许多是必要的,比如用于语法集成和Rust的运行时,即panic和分配。然而,语言项的数量一直在增加,部分原因是有些是不必要的。Result及其变体都是语言项。这样做的唯一原因是优化。它们直到2020年8月才成为语言项,当时添加它们是为了获得轻微的性能提升。那么,为什么不为所有像Result这样的枚举实现优化呢?这避免了对语言项的需求,并且普遍适用于整个生态系统。这将为所有crate带来性能优势,而不仅仅是标准库。

另一个必须特殊对待的项是编译器内部函数(compiler intrinsics)。顾名思义,内部函数是由编译器本身实现的函数。有232个这样的函数,每个后端都必须实现它们。有些存在是为了直接与硬件交互,其中许多仅仅用于原子操作。其他内部函数的存在是为了访问只有编译器才有的信息。类型有多大?这个问题不问编译器是不可能回答的。像语言项一样,许多编译器内部函数可以被移除。取浮点值的平方根是一个内部函数,但这可以重写为使用内联汇编。在64位x86架构上,它只是一条指令。我实际上已经验证了这是可能的,而且没有任何副作用。我轻易找到的其他例子有assume,它可以trivially用unreachable来实现,以及unlikely,它可以用likely来实现。

语言项编译器内部函数构成了必须予以特殊处理的内在因素的主干。出于这个原因,它们的数量应该缩减到最低限度,并移到它自己的crate中,这个crate将保持特殊。这将允许标准库的其余部分继续成为另一个(普通)crate的旅程得以继续。

在开始时,我提到这部分是基于观察的。这是因为让std变得不那么特殊并不是一个新想法。在这方面已经进行了相当多的工作,可以追溯到多年前。有一个工作组叫做"std aware Cargo",其目标是允许用户在本地构建标准库。这个的实验性实现确实存在于nightly上。存在一些bug,比如与代码覆盖率不兼容,但它在很大程度上是可用的。未来可以而且将会改进其人体工程学,因为有时可能会有点麻烦才能让你想要发生的事情发生。

在更具体的方面,还有diagnostic_on_unimplemented。这在标准库内部已经存在了相当长的时间,作为rust_c_on_unimplemented。当一个trait没有实现但预期会实现时(可能是由于trait约束),它改善了错误消息。将其从rust_c属性转移到diagnostic命名空间已经被RFC接受,目前部分实现了。

也许列表中最令人惊讶的是deprecated属性,它最初只用于标准库。它有一段很长的历史,始于Rust 1.0发布之前。原始实现被重命名为rust_c_deprecated,并只提供给标准库。引入了一个新的deprecated属性,它没有任何代码与rust_c_deprecated相同。

deprecated属性在Rust 1.0发布后约一年就稳定了,但rust_c_deprecated仍然存在。这种情况一直持续到2022年3月,当时我完全合并了这两个属性的前端,此时功能几乎相同。我说几乎是因为仍然有一个区别,那就是对已弃用项的建议。这允许作者指出一个已弃用的项被什么替换了。目前,这必须通过note字段完成,这需要最终用户阅读并手动去进行更改。在这个例子中,alpha字段已被弃用,转而使用beta。这通过一个新的suggestion字段表示,并提供建议。编译器将产生更好的诊断信息。它将清楚地显示对Alpha的调用必须替换为beta。顺便说一下,这个建议是机器可应用的,所以它可以与cargo fixrust analyzer一起使用。这个功能自2019年1月就已实现,当时它是rust_c_deprecated属性的一部分。当它与deprecated合并时,该功能在nightly上对所有人开放,这就是它目前的状态。

标准库独特的一个有趣之处是其依赖于未指定的行为。在标准库中有一条评论说"只有标准库可以做出这个保证"。在标准库中可以找到一些类似的评论。当我第一次遇到这样的评论时,我觉得很奇怪。为什么标准库要这样做呢?

在我为这次演讲做研究时,我发现了两种主要情况,在这些情况下,标准库依赖于对其他库不保证的行为。一种涉及Niche value optimization (中文一般称为利基值优化),这种情况不是未指定的行为,而是根本不保证能编译。另一种情况是标准库依赖于胖指针的大小和布局,这就是这条评论所指的。

译者注:

Niche value optimization(利基值优化)是Rust编译器使用的一种内存优化技术。这种优化利用了某些类型的"空隙"或"利基"来存储额外的信息,从而减少内存使用并可能提高性能。让我为您详细解释一下:

  1. 基本概念:

    在Rust中,某些类型可能有一些值永远不会被使用。这些未使用的值就构成了一个"利基"(niche)。

  2. 常见例子:

    最常见的例子是Option<&T>。在正常情况下,&T是一个非空指针,因此Option<&T>理论上需要一个额外的布尔值来表示Some或None。但是,由于&T永远不会是null,编译器可以使用null指针来表示None,从而节省了额外存储布尔值的空间。

  3. 优化过程:

    编译器识别出类型中的未使用值,并利用这些值来编码额外的信息,通常是用于表示枚举变体或Option的None值。

  4. 应用场景:

    除了Option<&T>,这种优化也适用于其他情况,如包含不可能值的整数类型(例如,用作数组索引的非负整数)。

  5. 优势:

    • 减少内存使用
    • 可能提高性能(因为减少了内存访问)
    • 使某些类型更适合在FFI(外部函数接口)中使用
  6. 限制:

    这种优化是由编译器自动完成的,开发者通常不需要(也不应该)手动干预这个过程。

如果您想了解更多细节或有具体的使用场景需要讨论,我很乐意为您进一步解释。

只有一个问题:两者都是未指定的。编译器对这段代码没有任何保证。如果指针的布局发生变化,行为会悄悄地改变。更糟糕的是,如果指针的大小发生变化,那将导致未定义行为,因为某些代码将执行越界读取。

虽然胖指针的大小一直是usize类型大小的两倍,但据我所知,这并没有被正式保证。布局也是如此,它从未改变,但并没有得到保证。这段代码之所以能存在,只是因为编译器与标准库耦合。就标准库而言,它的代码在当前行为下工作,这就足够了。话虽如此,要让标准库变得不那么特殊,有两个选择。一是将胖指针变成一个语言项,这将确保它由编译器实现,从而保持同步。另一个选择当然是简单地保证胖指针的大小和布局。在我看来,这是更可取的,因为它也将是朝着稳定ABI迈出的一小步但却是重要的一步。

接下来讨论一些仍在nightly上的东西,但与大多数nightly特性不同,它在标准库的公共API中暴露出来。这个特性是负面实现(negative implementations)。负面实现在功能上是一个承诺,永远不会实现某个trait。可能最常见的用例是想要选择退出自动trait,即Send和Sync。

这在技术上是可能的,但这样做相当不符合人体工程学,因为它需要一些hack。这怎么可能呢?嗯,标准库包含有这些trait的负面实现的类型。具体来说,MutexGuard实现了!Send, Cell实现了!Sync,可变指针实现了!Send和!Sync。这很好,除了你可能想避免在内存中存储这些类型。相反,你可以将它们包装在PhantomData中,PhantomData在运行时不存在,但如果任何人都可以不使用这个hack就退出自动trait,那肯定会更简单。

负面实现还有其他用途。它们对trait解析也很有用,因为它们允许看似重叠的实现。

例如,标准库有一个实现,允许任何错误类型转换为Box<dyn Error>。但如果我们也想为Box<dyn Error>实现From<String>呢?为此,我们需要一个图表。在顶部是可能实现Error的类型,无论是当前还是将来。左边是已经实现的类型,右边是永远不会实现的类型。所有类型都从图表的顶部开始,但作者可以选择向下移动到左边或右边。对所有Error类型的blanket实现考虑了所有可能实现Error的类型,无论它们当前是否实现。出于这个原因,第二行是被禁止的。String可能在将来实现Error。为了满足编译器中的重叠检查,我们必须明确承诺String永远不会实现Error。这样做,我们将String移到了图表的右侧,将它们从blanket实现中排除。

值得注意的是,所有作者都可以选择在图表中向下移动。然而,向上移动是一个破坏性的变化,因为它是删除了对编译器和其他用户做出的承诺。这对正面和负面实现都是如此。

Rust的一个期待已久的特性是特化(specialization)。特化是一个特性,在某种程度上允许重叠的实现。这里的限制是一个实现必须是另一个的子集。然而,这仍然允许非常有用的行为。

目前,Default只为长度最多为32的类型实现。这是因为长度为零的数组不需要Default约束,而所有其他长度都需要。有了特化,我们可以为所有长度有一个默认实现,特化长度为零以避免约束。虽然这看起来足够简单,但这个例子目前在nightly上不能编译。

虽然特化是一个强大的特性,但要正确实现它也非常困难。当前的实现已知是不健全的。有一个min_specialization试图避免这种不健全,但这仍然不够。

可能是由于困难,特化的工作基本上停滞了。特化可能需要具有相当多类型理论知识的人来提出一个健全的子集,这需要大量努力才能稳定。尽管困难,但特化目前用于优化, 所有用法都经过仔细检查,没有出现在公共API中。

提议特化的RFC于2015年7月发布,就在Rust 1.0发布两个月后。在RFC中,特化被描述为"trait系统的一个相对较小的扩展"。我想我们都同意这有点乐观了。特化可能是本次演讲中提到的最困难的项目。

虽然特化可能非常困难,但这里有一个不应该那么困难的:Prelude是由crate提供的,在每个模块中自动导入的东西。这就是让你可以使用Vec却不必手动导入它的原因。

标准Prelude的内容默认是隐式的。Alloc Prelude过去存在,但它被删除了,因为它的内容不是自动进入作用域的。

所以我有一个问题:为什么不让每个crate声明一个Prelude呢?虽然一些crate确实有它们称为Prelude的模块,但它们的内容不是自动进入作用域的。我想做的事情从设计角度来看相对简单。

首先,我们可以注解任何将成为Prelude一部分的项。任意数量的项可以被注解,它们不必在一个共享模块中。在这个例子中,我们也重命名了该项。这与普通的use语句相同,它被用来避免潜在的命名冲突。

如果我们想在某个位置排除Prelude怎么办? 没问题,模块可以根据需要选择退出。这可以通过在模块上放置注解并指示我们想要排除哪些crate的Prelude来实现。

也许最重要的问题是,什么阻止crate声明巨大的Prelude,破坏每个人的体验?这有一个简单的解决方案:留给最终用户。最终用户选择在Cargo.toml中使用哪些Prelude。用户必须明确选择使用给定crate的Prelude。这允许最大的灵活性,因为如果没有明确请求,什么都不会进入作用域。

虽然自定义Prelude最初在2015年2月被提出,但这与那个提案有显著偏差。我相信crate Prelude在适度使用时会提供显著的好处,像itertools和rayon这样的crate是极好的用例。

今天的最后一项是稳定性属性。这可能是标准库能做而其他库不能做的事情中最明显的方式。稳定性属性用于指示一个项是否稳定。例如,OnceCell是稳定的,稳定性属性指示了它之前可用的特性名称和该特性稳定的版本。

如果一个项不稳定呢?不稳定的项非常有用,因为它们允许crate在不提供保证的情况下实验API。LazyLock目前是不稳定的。属性显示了用于启用LazyLock使用的特性名称,更重要的是,它显示了issue编号。这允许用户准确知道在哪里查看当前状态,甚至更好地提供反馈。

值得注意的是,每个公共项如果在crate中的任何地方使用,都需要一个稳定性属性。这是为了确保一切要么是稳定的,要么是不稳定的。不可能既不是稳定也不是不稳定的。

话虽如此,不可能自由使用不稳定的项。不稳定的项是选择加入的,需要一个特性门。如果你尝试不正确地使用一个不稳定的项,你会得到一个编译器错误。避免这个错误的唯一方法是在crate层面添加一个特性门。

有一些边缘情况需要考虑,比如当一个不稳定的项在稳定上下文中使用时,比如trait约束。这是允许的,但绝对应该有一个lint来确保这是deliberate的,因为如果不小心处理,对下游用户来说会令人困惑。

stable和unstable属性处理一个项是否总体上稳定,但还有const_stable和const_unstable属性来处理函数是否保证是const函数。

稳定性属性在Rust世界中已经存在很长时间了。2014年10月,在一篇官方博客文章中说,库作者可以继续使用稳定性属性。自那以后已经过去了将近9年,然而与那篇文章相反,稳定性属性目前明确仅用于标准库。尝试在其他crate中使用它们会导致编译器发出警告。我认为是时候最终为每个人提供这个功能了,因为我们知道它非常有用。

这确实是很多内容。在这些方面实际上做了什么呢?令人惊讶的是,已经做了相当多的工作。与Cargo的集成正在由专门为此目的而存在的工作组进行。它在nightly上可用,处于可用状态。

减少语言项和内部函数的数量是一个目标,但还没有完成任何工作。我打算研究一些更简单的情况,包括前面提到的那些。我相信其中一些可以t被简单地消除。

deprecated项的建议没有正式提案,但它已经实现了一段时间。它在nightly上可用,在deprecated_suggestion特性标志下。在稳定之前可能需要解决几个点,但不应该需要太多努力。

对于标准库中的未指定行为,这幸运地范围非常小。需要就期望的解决方案进行讨论,但任何实现都会很快跟进。

负面实现在nightly上实现,但该特性有已知的bug和边缘情况,必须在稳定之前解决。

特化不幸停滞了。据我所知,没有人在积极致力于此。然而,毫无疑问地,人们存在对特化的渴望。可能需要有类型理论知识的人来取得进展, 鉴于此,没有明确的时间表来解决问题,更不用说稳定该特性了。

至于crate Prelude,我实际上正在写一个RFC。正如熟悉这个过程的人所知,这需要一段时间。在被接受之前,更不用说实现和稳定,将会有大量的反馈和修订。

在我完成crate Prelude的RFC后,我将开始为稳定性属性写一个。这些属性在标准库中广泛使用,所以我们拥有实现这一点的能力和知识。稳定性属性有已知的限制,在广泛可用之前应该解决,但这是一个可以解决的问题。

总的来说,今天提到的许多项目已经有了工作,尽管工作程度不同。有些需要正式提案,而其他需要主题专家。它们都需要额外的工作。我个人正在尽我所能将标准库的有用功能带给每个人。我希望你们能分享我对这个目标的热情,并尽可能地协助实现它。

让标准库变得不那么特殊将需要大量的时间和努力,但这是Rust项目的一个总体和长期目标,整个Rust社区都将从中受益。

最后,屏幕上有大量信息。你可以在许多平台上找到我,包括GitHub和Mastodon,我的用户名是JH_Pratt。如果你有兴趣赞助我的工作,请这样做。我向你保证,这将是值得的。谢谢。

相关推荐
豌豆花下猫5 分钟前
Python 潮流周刊#78:async/await 是糟糕的设计(摘要)
后端·python·ai
YMWM_7 分钟前
第一章 Go语言简介
开发语言·后端·golang
只因在人海中多看了你一眼8 分钟前
python语言基础
开发语言·python
2401_8582861110 分钟前
101.【C语言】数据结构之二叉树的堆实现(顺序结构) 下
c语言·开发语言·数据结构·算法·
y250811 分钟前
《Object类》
java·开发语言
小技与小术15 分钟前
数据结构之树与二叉树
开发语言·数据结构·python
码蜂窝编程官方23 分钟前
【含开题报告+文档+PPT+源码】基于SpringBoot+Vue的虎鲸旅游攻略网的设计与实现
java·vue.js·spring boot·后端·spring·旅游
hccee37 分钟前
C# IO文件操作
开发语言·c#
hummhumm42 分钟前
第 25 章 - Golang 项目结构
java·开发语言·前端·后端·python·elasticsearch·golang
J老熊1 小时前
JavaFX:简介、使用场景、常见问题及对比其他框架分析
java·开发语言·后端·面试·系统架构·软件工程