回到老把戏,或者说,生锈的婴儿步

2020-07-05 02:11:23

在过去的二十天左右的时间里,我一直在学习“锈”,我在读“布兰迪与奥伦多夫”这本书,边走边编。一旦我开始使用Rust特征和闭包以及相关类型,就会发现在Haskell中使用类型类、数据结构、闭包传递和相关类型进行编程的相似性非常明显。

作为热身,我想我应该尝试将流融合核心从Haskell移植到Rust。这是我十多年前编写的代码。在今天的“铁锈”杂志中,有多少是有效的,甚至是有意义的呢?

脚注:这是我两年多来尝试认真编写的第一个代码,因为我在团队建设和工程管理方面逗留了很长一段时间,现在又回到了软件工程领域。我感觉有点..。拉什蒂。如果我有什么困惑的地方,请告诉我。

基本流/列表/向量API的两个版本:具有盒式闭包传递和纯状态的数据结构。

以及一种更避免闭包的特征编码,它使用类型来静态索引所有代码。

大多数类型类的“看问题的方式”在“锈”中的作用大体相同。一般情况下,您可以索引/调度/使用类似的心智模型进行编程。几天后我就可以开始猜测语法了。

与我们在GHC中所做的使一切变得严格、未装箱和不堆分配的艰苦工作相比,这是Rust中的默认设置,这使得优化故事变得简单得多。

Ruust没有GC,默认情况下使用类似嵌套区域的分配策略。我必须预先承诺具体的共享和线性内存使用。这感觉很像一个类似ST-Monad的状态线程系统。借用和移动语义需要一些练习才能习惯。

特征版本看起来非常类似于标准Rust迭代器的核心,在玩具示例中的性能对于我所投入的工作量来说是非常好的。

Rust似乎推动了每个泛型函数的方法/特征/数据结构编程,这很有趣。与在Haskell中一样,在类型中对事物进行编码也是有好处的。

一般的Haskell,包括类型类设计,基本上可以直接移植到Rust,尽管您现在显式地注释了分配行为。

货运是一个高度打磨的“阴谋集团”,有很多合理的默认设置,而且对允许的内容有更多的限制。把重点放在好的用户体验上。

作为一个元观点,在最初的“将类型与类相关联”一文发表15年后,能够漫步到一种新的编程语言中,并且基本上找到了所有可用的原始概念,并以一些有趣的方式进行了扩展,这是令人惊讶的。对于使用Rust编写GHC优化/拆箱/流的人来说,有很多似曾相识的感觉。

首先,直接移植原文。一种用于单步执行流元素的数据类型,包括“Skip”,以便我们可以过滤内容。和保存流分步函数的结构,以及状态。与Haskell版本(在注释中)相比,有更多的仪式来声明堆上发生了什么,并将对象的生存期链接在一起。我这样做的回报是不需要垃圾收集器。当借阅检查程序类型检查时,有一个相当令人愉快的血清素奖励:-)。

您可能可以从语法中推断出这在操作上将是昂贵的:将闭包打包到堆上。Rust对存储位置和存储时间的显式控制感觉很像是为分配器编写脚本的类型系统,与Haskell相比,您当然非常清楚您在要求机器做什么。

总体而言,这是一个相当令人满意的转换,即使我将闭包放在堆上,当流被删除时,我仍然会将其取消分配。听着,妈妈,没有GC就结束了。

我们可以创建空流,也可以创建包含单个元素的流。请记住,流只是一个从一个值到另一个值的函数,以及启动它的状态:

lambda语法起初感觉有点重,但是您已经习惯了。这些定义中最难的部分是:

明确表示我的闭包是否会借用或拥有它捕捉到的价值的整个生命周期。在哈斯克尔你从来没有想过的事情,有一个GC来处理所有的想法(有偿的)。每个闭包最终都有一个唯一的类型,显示捕获的内容,感觉非常显式和可控。

没有RANK-2类型来隐藏后面的流状态类型。我能得到的最接近的返回类型是“Iml Seed”不透明返回类型,但它的行为不太像真正的存在类型,而且往往会在实现中泄漏。我希望看到在类型级别隐藏此状态值的规范方法,而无需强制将其打包。

a la Vector‘Prim’类型在Haskell中,我们使用Copy来说明我们何时希望值的移动成本较低(至少,这是我早期的思维模式)。

只要我仔细地将生存期参数线程化,就可以在不使用GC的情况下将值装箱并生成流。这有点令人吃惊。(独特的生存期令牌传递规程感觉非常像ST Monad及其对区域/嵌套的扩展)。同样,您可以“感觉”这将是多么昂贵,因为捕获和装箱到堆显式。动态调用的盒装闭包将会有代价。

还不算太糟。这里显示了尾递归的缺失,所以虽然我通常会将其编写为带有堆栈参数的“go”局部工作函数,以获得循环,但在Rust中,我们只需编写一个循环,并通过“mut”绑定直接查看和戳到内存。唉,好吧,但我保证我还在用递归的方式思考。

更通用的是什么呢?枚举FromTo,用支持加法的任何类型的连续整数值填充一个范围?

特征参数感觉很像Haskell num类的超层版,我实际上是在挑选和选择要分派给哪些方法。数值重载也略有不同(A::One()),而不是重载的文字。同样,与Haskell版本几乎相同,但具有显式内存注释和更结构化的类型类/特征层次结构。其他操作,如贴图、过滤等,都非常直接。很好:开始觉得我在这门语言上绝对可以很有效率。

另一个似曾相识的时刻,安装Critarion来对事情进行基准测试。“货架”整合相当甜蜜:

因此,对于1mi64个元素上的四个逻辑环路,大约为7ms。考虑到我不知道自己在做什么,这似乎是合理的,实际上也不算坏。

到盒装闭包的动态分派的开销几乎肯定会占主导地位,然后很可能会中断内联和算术优化,因此,虽然我们确实得到了融合循环,但我们会按顺序获得循环的所有步骤。我稍微调整了一下消费循环的内联,它缩短了1毫秒,但仅此而已。

快速浏览一下程序集的f释放模式,我认为它做得很好,是的,这不会很快。到处都是成吨的收银机、分配机和发货单。乌克。

但是它起作用了!直接翻译的第一件事是工作,它基本上具有显式闭包调用所期望的行为。还不错!。

那个包装箱的封口让我有点不舒服。分派到静态已知的东西。静态解决问题的通常技巧是将工作移到类型系统。在本例中,我们希望按类型查找正确的“step”函数。因此,我需要流API中每个生成器和转换器函数的类型。我们在“铁锈”中也可以采用这种方法。

将STREAMS的“step”函数类型移动到Stream特征中

为每个生成器或转换器函数创建一个数据类型,然后为Stream实现该数据类型。这是消除求解阶跃函数的开销的关键。

我对此进行了几种不同的尝试,最终决定将州数据放入“API key”类型。这实际上看起来真的很像我们已经知道如何做的事情--将流作为Rust迭代器--Snoyman在3年前就已经写过了!-经过一些n00b的试错,我基本上在这里采用了他的方法。

“step”类型几乎相同,多态的“Stream”类型及其存在种子成为特征定义:

现在有点不同的是,我们将如何解析函数来生成流的每个步骤。这现在是与某些实例和元素类型相关联的特征方法。

因此,例如,如果我想生成一个空流,我需要一个类型、一个实例和一个包装器:

好的,还不算太差。我对单步执行的闭包不是“下一步”方法。原本具有嵌入闭包的Stream‘Object’现在变成了一个特征实例,其中‘Next’函数可以被静态解析。

我可以像这样转换所有的生成函数。例如,要复制流,我需要知道有多少元素,以及元素是什么。它不是在闭包中捕获元素,而是显式数据类型:

Step函数仍然与Haskell版本中的基本相同,但是为了获得更好的Rust方法语法,我们让它都与“self”对话。

我们还需要每个流转换器的类型:因此,“map”现在是一个具有mapper函数的结构,与它映射到的底层流对象配对。

这一部分稍微复杂一些-当映射应用于流元素时,我们返回该元素的f(X),并将流状态提升到下一步的映射流状态。

我现在可以实现Stream-Generic Folds了-再一次,因为我没有尾递归来消费我显式循环的流。这是我们工作的真正“驱动因素”,实际的循环拉动了我们构建的一系列“stream.next()”。

为了理解方法解析是如何工作的,我不得不在这里写出类型。我们构建了一个很好的类型信息链,它准确地说明了我们想要在什么类型上使用什么函数。整个管道是一个映射<;过滤器<;范围<;…。类型>;,全部为Stream的实例。

所以这应该没问题,对吧?没有封装闭包,可能会有一些查找和分派,但是这里有足够的类型信息来静态地了解所有调用。对于Rust将如何优化嵌套的Year/Skip构造函数链,我没有太多的直觉。但是我很乐观,因为标签只有2位大小,而且我在特定程序中的任何地方都没有使用Skip。

因此,类型信息和不分配给堆的承诺在这里为我们做了很多工作。我要求Cargo Rustc--bin stream_test--release-发出ASM作为乐趣。这基本上就是我想看到的:单循环,没有分配,一点数学。太棒了。

它将%2/*2正文转换为添加一个带跨距的直接i64加法循环。我怀疑只要稍微刺激一下,它就可以将这个问题静态地分解为一个常数,但无论如何,这只是一个玩具。所有的中间数据结构都消失了。

总体而言,这是一个相当令人满意的结果。只需很少的工作,我就得到了一个开箱即用性能良好的融合迭代器/流API。默认情况下,铁锈会将代码推向较低的开销。这会让人感觉相当满意。