关于编译时函数评估和类型系统的思考

2020-12-13 06:12:48

从现在的1.26版本开始,Rust拥有了非常强大的CTFE或编译时函数评估机制,从那时起,人们就CTFE期间应允许哪些操作进行了各种讨论,哪些检查编译器应该做什么,这与促销有什么关系,以及我们在CTFE方面可以期望得到什么样的保证。这篇文章是我对这些主题的看法,而我将采用这样的类型也就不足为奇了:以系统为中心的视图:期望像结构化的人才转移之类的东西,所以在最后也有一些未解决的问题。

CTFE是编译器用来评估诸如const x:T = ...;之类的项目的机制。这里的...将是Rust代码,必须在编译时“运行”,因为它可以在代码中用作常量–例如,可以用于数组长度。

注意CTFE与常量传播不同:常量传播是由LLVM之类的编译器完成的优化过程,它将机会性地将3 + 4之类的代码更改为7,以避免运行时工作。 ,而不改变程序的行为,并且根本无法观察到(性能除外)。另一方面,CTFE涉及必须在编译时执行的代码,因为编译器需要知道其结果才能继续执行-例如,它需要知道数组的大小来计算如何在内存中布置数据。仅从代码的语法就可以静态地看到CTFE是否适用于某些代码:CTFE仅用于类似const的值或数组的长度。

fn demo(){const X:u32 = 3 + 4; // CTFE让x:u32 = 4 + 3; //没有CTFE(但可能会不断传播)}

我们说上面的3 + 4是在const上下文中,因此受CTFE的约束,但是4 + 3则不受此。

并非所有操作都可以在const上下文中使用。例如,计算数组长度是没有意义的,因为“请从磁盘中读取该文件并计算一些内容” –我们不知道程序运行时磁盘上的内容实际上可以运行。我们可以使用编译程序的计算机磁盘,但这听起来也不是很吸引人。当您考虑让程序将信息发送到网络时,情况变得更加糟糕。显然,我们不希望CTFE具有在编译之外实际上可以观察到的副作用。

实际上,仅仅天真地让程序读取文件也是非常不安全的:当计算数组长度两次时,获得相同的结果很重要。更新:正如@eddyb指出的,一旦考虑const泛型,特征和连贯性,情况会变得更糟:那时,您必须依靠在不同包装箱中评估相同的表达式来产生相同的结果。 /更新

如果不是这样,编译器可能最终会认为两个数组具有相同的长度,但后来又计算出不同的布局,那将是一场灾难,因此,任何类型的外部输入和任何类型的不确定性都是完全不可行的对于CTFE,这不仅涉及I / O,即使将引用转换为usize也是不确定的。

如果试图执行这样的操作,编译器将抛出CTFE错误。那些在const上下文中可执行的程序称为const safe:

如果程序可以由CTFE执行而不会出错,则该程序是const安全的(允许出现紧急情况)。

这与安全(或运行时安全,以区别于const安全)程序是一种不会引起任何内存错误或数据争用的程序非常相似。实际上,我们将看到这种比喻。在“在CTFE下表现良好的程序”(常量安全性)和“不会导致UB的程序”(运行时安全性)之间的距离可以带给我们很大的帮助。

现在,一个非常有趣的问题是是否应允许在const上下文中调用某些给定的函数foo。我们可以总是说“是”,并依赖于这样的事实,即当foo进行任何操作时,CTFE会抛出错误。这种方法是,如果foo在库中,则更新库可能会以使其不再是const安全的方式更改foo。换句话说,使任何函数不再是const-safe将不再是semver,因为可能会破坏下游的板条箱。

解决该问题的典型机制是具有一个将函数明确标记为“可在const上下文中使用”的注释。在Rust中,为此目的建议的机制为const fn;在C ++中,它称为constexpr。编译器现在可以拒绝在const上下文中调用非const函数,因此库作者可以添加非const安全的操作而不会破坏semver。

这导致我们产生一种有趣的情况,即编译器将在const上下文中拒绝代码,因为它只接受const上下文之外的内容。特别是,const fn的主体也被视为在const上下文中;否则,如果我们允许调用任意函数,我们将再次遇到相同的问题。对此进行考虑的一种有用方法是,我们有第二种类型的系统,即“ const类型系统”,用于在const上下文中对代码进行类型检查。不允许调用非const函数。

它可能也应该不允许转换对整数的引用,因为(如上所述)这是一种不确定的运算,在CTFE期间无法执行。还有什么?

在继续进行随机附加检查之前,让我们退后一步,思考一下我们的目标是什么。通常,类型系统的目的是为类型良好的程序建立某种保证。对于Rust的“主要” (“运行时”)类型的系统,保证是“没有未定义的行为”,这意味着没有内存错误和数据竞争。新的const类型系统的保证是什么?我们上面已经讨论过:const安全!这导致我们对const健全性的定义:

再次注意,这与运行时类型系统的正确性声明非常相似,后者保证了运行时的安全性。

我们肯定会允许这段代码,为什么==或%不应该是const安全的呢?

该语句当然不是const安全的,因为结果取决于分配器将Box放在哪里的位置,但是我们要将此问题归咎于usize,而不是is_eight_mod_256。

解决方案是,对于const类型系统,不仅要有关于允许哪些操作的单独规则,我们还必须更改对给定类型哪些值“有效”的概念。从指针获取的整数对于运行时的usize有效。 -time,但是对于const模式下的usize无效!毕竟,我们希望所有usize都支持一些基本的算术运算,而CTFE无法支持指针。

如果函数在使用const有效参数执行时未触发CTFE错误并返回const有效结果(如果它完全返回),则该函数是const安全的。

在此定义下,is_eight_mod_256是const安全的,因为只要x是实际整数,它的赋值就不会有任何错误,同时这表明将引用转换为usize并不是const安全的,因为此操作的输入是const有效,但输出不是!这为在const上下文中拒绝此类强制转换提供了坚实的理由。

在Rust中,CTFE由miri解释器执行,miri解释器曾经是一个单独的项目,但其核心引擎已集成到rustc.miri将逐步在const上下文中执行代码,并且只会抱怨并因错误而失败当无法执行操作时,这不仅涉及不确定性; miri不支持它可能支持的所有内容,因为@ oli-obk非常谨慎,不会意外稳定应该接受RFC的行为。

实际上,现在miri将拒绝所有对原始指针的操作,它们都会引发CTFE错误,因此必须全部由const类型系统拒绝。计划是更改miri,以便它可以支持更多操作,但是我们必须我已经提到过miri必须是确定性的,但还有另一点要考虑的是,您可能期望扮演更重要的角色:CTFE(至少如果成功的话)应该与运行时行为匹配!

如果CTFE永远循环,结果结束或出现紧急情况,而该行为与同一代码的运行时行为匹配,则CTFE是正确的。

当代码驻留在const上下文中并由CTFE运行时,以及将其编译为机器代码并“真正”执行时,我们显然不希望代码的行为有所不同。

还是我们?不要误会我的意思,我不是提倡故意破坏该财产,但是如果miri不是CTFE正确的话,那肯定会引起问题的,这也许值得考虑,也许令人惊讶的是,事实证明这不会正如我们已经讨论过的那样,我们关心的仅是为了确保CTFE具有确定性,我们不会在运行时重新运行相同的代码,而是依靠它仍在执行相同的操作,因此没有任何问题如果CTFE行为与运行时行为不同,则实际上会中断。

话虽如此,但CTFE的正确性无疑是非常令人惊讶的,我们应该尽力避免这种情况。然而,我被告知,要确定地实际预测浮点运算的结果非常困难,而LLVM并不能提供任何帮助。我可能不得不考虑将浮点运算视为const不安全(引发CTFE错误),或者在涉及浮点运算时不具有CTFE正确性。我认为可以为所有其他运算实现CTFE正确性,并且我认为我们应该努力做到这一点。

在继续之前,请注意,上面定义的CTFE正确性并没有说明CTFE因错误而失败的情况,例如由于操作不受支持,如果CTFE总是立即返回错误,则CTFE将完全正确(按上述意义)。但是,由于const安全程序在CTFE期间不会出错,因此我们从CTFE的正确性中知道这些程序实际上的行为在编译时和运行时完全相同。

假设我们要扩展miri以支持对原始指针的更多操作。我们知道在保持miri确定性和保持CTFE正确性方面必须格外小心。我们可以支持哪些操作?

请注意,此时尚未考虑const的健全性和相关的const安全性。当我们更改const类型系统以允许更多操作时,这些想法开始发挥作用,但是CTFE的确定性和正确性是CTFE引擎的特性。 (miri)本身。

const fn make_a_bool()->布尔{让x = Box :: new(0);让x_ptr =& * x as * const i32;下降(x);令y = Box :: new(0);让y_ptr =& * y作为* const i32; x_ptr == y_ptr}

在运行时,此函数返回true还是false取决于分配器在分配y时是否重用了x的空间。但是,由于CTFE是确定性的,我们必须在编译时选择一个具体答案,这可能不是正确的答案。因此,如果我们要保持CTFE的正确性,就不能允许该程序在CTFE下执行。以确定性的方式支持内存分配是完全可行的(实际上,miri已经实现了),并且进行了强制转换对原始指针的引用只会改变类型,这里唯一真正有问题的操作是测试两个原始指针是否相等:由于其中一个指针悬空了,我们无法确定地预测此比较的结果!

换句话说,如果/当miri学习如何比较指针时,如果其中一个指针悬空(指向未分配的内存),我们必须使其引发CTFE错误,否则我们将违反CTFE正确性。

现在,让我们向上看一下const类型系统,我们已经看到比较原始指针会产生CTFE错误,因此这实际上不是const安全操作,类似于将指针转换为整数一样,我们必须使const类型系统拒绝比较原始指针的代码,但是,将它们转换为原始指针后,甚至不允许比较两个引用是否相等似乎很可惜!毕竟,引用永远不会晃晃,所以这是一个完美的const-安全操作。

幸运的是,Rust已经满足了以受控方式绕过类型系统的需求的答案:不安全的块。const类型系统不允许比较原始指针,因为它不是const安全的,但是就像我们允许运行一样-time-unsafe操作要在不安全的块中执行,我们也可以允许const-unsafe操作。因此,我们应该能够编写以下内容:

const fn ptr_eq< & (x:& T,y:& T)->布尔{不安全{x为* const _ == y为* const _}}

像往常一样,编写不安全的代码时,我们必须注意不要违反类型系统通常会维护的安全保证。我们必须手动确保,如果我们的输入是const有效的,那么我们将不会触发CTFE错误并返回一个const有效结果。在此示例中,不会出现CTFE错误的原因是引用无法悬挂,因此我们可以提供ptr_eq作为抽象,在const上下文中使用是完全安全的,即使它包含潜在的const -不安全的操作。这再次非常类似于Vec这样的类型,即使Vec在内部使用了大量潜在的不安全操作,也可以从安全的Rust中完全安全地使用它。

每当我在上面说过const类型系统必须拒绝某些操作时,这实际上意味着该操作在const上下文中应该是不安全的。例如,即使指针到整数的强制转换也可以在const安全代码内部使用以完全确定的方式将其他位打包到指针的对齐部分中。

我还没有涉及到Rust中CTFE的另一个方面:静态值的提升。这是使以下代码的类型正确的机制:

这看起来似乎应该被拒绝,因为我们返回的是对生命周期为' static的本地创建值的引用,但发生了Magic(TM)。编译器确定3是可以计算的静态值。在编译时放入静态内存(例如静态变量),因此& 3可以具有生命周期' static。这也适用于例如&(3 + 4)。静态变量(如const)是在编译时计算的,因此CTFE发挥了作用。

根本上新的方面是用户没有要求提升价值,这意味着我们在决定提升价值时必须格外小心:如果我们推广miri无法评估的内容,则会出现CTFE错误并且我们没有充分的理由就破坏了编译。我们最好确保只推广我们期望miri能够实际计算的值,即,我们只应推广const安全代码的结果。您可能已经猜到了它,但是我建议的是使用const类型系统。Const健全性已经说过,这是确保const安全的一种方式。

我建议只推广安全使用const-well类型的值(因此,即使我们处于不安全的块中,我们也不会推广涉及const-unsafe操作的值)。当有函数调用时,该函数必须是一个安全const fn和所有参数都再次进行const-well-typed。例如,& is_eight_mod_256(13)将被升级,而& is_eight_mod_256(Box :: into_raw(Box :: new(0))会被提升)与类型系统通常一样,这是一个完全局部的分析,不会研究其他函数的主体。假设我们的const类型系统是健全的,那么从提升中可能会出现CTFE错误的唯一方法是当存在安全的const时fn带有不安全的声音。

值得注意的是,只要有可能该函数可能导致const有效输入的CTFE错误,我们甚至都依赖库作者正确地为私有函数编写不安全的const fn。如果存在实际上不安全的const fn,则认为“但是它是私有的,因此很好”将无济于事,因为编译器可能会决定提升该函数的结果。但是,这只能在同一包装箱内破坏代码,并且可以在本地固定,因此对我来说似乎是一个合理的折衷。

需要考虑的另一个有趣的观点是,在考虑升级时,我们可能会更加关心CTFE的正确性。毕竟,用户要求运行时行为;如果miri是CTFE正确的,除了模糊的浮点问题之外,这意味着依赖浮点运算特定行为的“唯一”人员可能会受到影响,并且可能LLVM已经违反了人们所做的任何假设。(miri的浮点实现完全理智,应该符合标准,LLVM和x87舍入的特殊性在这里是不确定性的来源。)有晋升。

我已经讨论了CTFE确定性和CTFE正确性(这是像miri这样的CTFE引擎的属性)以及const安全性(一段代码的属性)和const健全性(类型系统的属性)的概念。我建议在const上下文中对安全代码进行类型检查时,我们保证该代码是const安全的,即,它不会遇到CTFE错误(尽管允许恐慌,就像它们在“运行时” Rust中一样)码)。

仍然有很多悬而未决的问题,特别是关于const fn和traits交互的问题,但是我希望在进行这些讨论时可以使用该术语,让类型系统指导我们:)

感谢@ oli-obk对本文的草稿提供反馈,感谢@centril在#rust-lang中进行了有趣的讨论,这些讨论促使我发展了这些想法和术语。如果您有反馈或问题,请在内部论坛中进行讨论!

发表于Ralf' s Ramblings on 2018年7月19日。评论?给我发邮件或在论坛中留言!