本篇是对 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 fix
和rust analyzer
一起使用。这个功能自2019年1月就已实现,当时它是rust_c_deprecated
属性的一部分。当它与deprecated合并时,该功能在nightly上对所有人开放,这就是它目前的状态。
标准库独特的一个有趣之处是其依赖于未指定的行为。在标准库中有一条评论说"只有标准库可以做出这个保证"。在标准库中可以找到一些类似的评论。当我第一次遇到这样的评论时,我觉得很奇怪。为什么标准库要这样做呢?
在我为这次演讲做研究时,我发现了两种主要情况,在这些情况下,标准库依赖于对其他库不保证的行为。一种涉及Niche value optimization (中文一般称为利基值优化),这种情况不是未指定的行为,而是根本不保证能编译。另一种情况是标准库依赖于胖指针的大小和布局,这就是这条评论所指的。
译者注:
Niche value optimization(利基值优化)是Rust编译器使用的一种内存优化技术。这种优化利用了某些类型的"空隙"或"利基"来存储额外的信息,从而减少内存使用并可能提高性能。让我为您详细解释一下:
-
基本概念:
在Rust中,某些类型可能有一些值永远不会被使用。这些未使用的值就构成了一个"利基"(niche)。 -
常见例子:
最常见的例子是Option<&T>。在正常情况下,&T是一个非空指针,因此Option<&T>理论上需要一个额外的布尔值来表示Some或None。但是,由于&T永远不会是null,编译器可以使用null指针来表示None,从而节省了额外存储布尔值的空间。 -
优化过程:
编译器识别出类型中的未使用值,并利用这些值来编码额外的信息,通常是用于表示枚举变体或Option的None值。 -
应用场景:
除了Option<&T>,这种优化也适用于其他情况,如包含不可能值的整数类型(例如,用作数组索引的非负整数)。 -
优势:
- 减少内存使用
- 可能提高性能(因为减少了内存访问)
- 使某些类型更适合在FFI(外部函数接口)中使用
-
限制:
这种优化是由编译器自动完成的,开发者通常不需要(也不应该)手动干预这个过程。
如果您想了解更多细节或有具体的使用场景需要讨论,我很乐意为您进一步解释。
只有一个问题:两者都是未指定的。编译器对这段代码没有任何保证。如果指针的布局发生变化,行为会悄悄地改变。更糟糕的是,如果指针的大小发生变化,那将导致未定义行为,因为某些代码将执行越界读取。
虽然胖指针的大小一直是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。如果你有兴趣赞助我的工作,请这样做。我向你保证,这将是值得的。谢谢。