SPECTER-用于不可变编程的Clojure API(2017)

2020-09-05 05:36:20

经过两年的开源和大量的开发,Specter已经达到了1.0。在这篇文章中,我将解释为什么我调用Specter";Clojure;的缺失部分,以及为什么应该在大多数Clojure和ClojureScript程序中使用它。

近7年来,我一直使用Clojure作为我的主要语言。有了它,我创建了阿帕奇风暴(Apache Storm),它成为了大数据世界中一个大型、重要的国际项目。我还使用Clojure成功地创建了一个名为BackType的初创公司,该公司被Twitter收购。我认为Clojure是迄今为止最好的通用语言,并一直在宣传它。

尽管我非常喜欢用Clojure编程,但我开始意识到,除了基本用例之外,Clojure无法为不可变的编程提供优雅的API。Clojure中的这一缺陷在我的项目中表现为代码不可读、复杂性、大量错误和糟糕的性能。

为了演示这一不足之处,我将从几个示例开始,说明Clojure在不变编程方面做得很好:

(assoc{:A 1:B 2}:A 3);=>;{:A 3,:B 2}(dissoc{:A 1:B 2}:A);=>;{:B 2}(conj[1 2 3]4);=>;[1 2 3 4](POP[1 2 3]);=>;[1 2](conj#{1 2 3}4);=>;#{1 2 3 4。

这就是一成不变的编程方式。您对您的数据执行一个操作,然后返回一个新的数据结构,其中显示了您所做的更改。封装了不变性的实现细节,如结构共享、复制和创建相同类型的新实例。

不幸的是,只有在对基本数据结构执行基本操作时才能获得这种优雅。对于许多常见的操作,尤其是涉及复合数据结构的操作,这种优雅就消失了。不幸的是,您的问题域经常要求您必须使用复合数据结构。在我自己的工作中,我使用了很多有向无环图。节点可以是各种记录类型,字段范围涵盖所有数据结构。某些节点中可能嵌入了另一个DAG。用普通的Clojure操作像这样的数据结构是不可能的,我不得不想出Specter来处理它。

作为普通Clojure缺乏优雅的一个例子,考虑一下限定映射键的名称空间的任务。对Vanilla Clojure执行此操作的明显方式如下:

(defn ns-qualify-keys[m](into{}(map(fn[[k v]][(keyword(str*ns*)(Name K))v]))。

这个函数有两个问题:它速度慢,并且它是错误的。在性能方面,此函数的运行速度是最佳实现的3倍以上。事实证明,迭代输入映射和构造输出映射的最佳方式完全取决于映射的类型。为这项任务编写最佳代码需要Clojure 1内部的深奥知识。对于像操作程序数据这样基本的事情,惯用的方法也应该是性能最好的方法。仅这一点就足以保证一种新的不可变编程方法。

然而,还有更重要的一点:这个代码是错误的。如果您给此函数一个排序的映射,它将返回一个未排序的映射作为结果。这是一个完全意想不到的影响,这只是一个转换,在每个地图的关键。虽然函数的目标是更改映射的某些子值(映射键),但要正确编写函数,还必须重新构造围绕这些子值的所有内容。这是一堆额外的东西,你必须做,而且很容易在重建过程中出错。因此,虽然您可以通过将{}替换为(空m)来修复该函数,但您必须这样做的事实本身就是问题所在。

这不仅仅是你在这个函数中要重建的地图。这也是关键词。您唯一想要修改的是每个关键字的名称空间,但是要做到这一点,您必须构造一个全新的关键字,并且所有其他内容都完全相同。因此,需要在匿名函数中调用名称和关键字。重建映射和关键字与更改名称空间的愿望交织在一起。这就是Rich Hickey在他的伟大演讲“让简单变得容易”中对复杂性的定义。我甚至不能开始描述我有多少次想要撕裂我的头发调试问题,这些问题最终是由重建过程中的一个愚蠢的错误引起的,比如使用map而不是mapv。

改进这段代码的方法是剔除可重用的函数、映射键和更新命名空间,每个函数都关心如何做好自己的一项任务。然后,您可以将它们组合起来,以适当分离关注点(并优化性能)来实现原始功能:

(defn ns-qualify-keys[m](map-key(fn[k](update-nampace k(fn[_](str*ns*)m)。

这段代码得到了显著改进。不再需要重新构建地图和关键字-代码本身只涉及导航到名称空间并更改它们。更新名称空间和重建周围数据结构之间先前的交织已经被梳理出来了。

不幸的是,这还不够。虽然拥有一组扩展的转换函数(如映射键和更新名称空间)是正确的开始,但是使用嵌套的匿名函数将它们组合在一起并不能很好地工作。代码很快就变得不可读,很难维护。即使是这段代码也很难读懂,而且随着嵌套级别的增加,情况会变得更糟。考虑这样的代码,它递增映射矢量的映射中的偶数(使用假设的映射函数):

(map-vals(fn[v](mapv(fn[m2])map-visions(fn[n](if(Even?N)(INC N)n))m2))v))m)。

这是完全不可读的。需要的是变换函数和一种将它们组合在一起的优雅方式。Clojure缺少这两样东西。

-转换映射的每个键-转换映射的每个值-转换通用序列的每个值-追加到通用序列-添加到通用序列的任意位置(例如[1 2 4 5]=>;[1 2 3 4 5])-从通用序列的任意位置删除(例如';(1:A2)=>;';';(1 2))-更新关键字或符号的名称或命名空间。

(其中一些不能有效地实现,比如在O(1)时间内添加到向量的前缀。我猜这就是为什么Clojure内核不提供该功能的原因。然而,在现实中,有时会出现执行这些低效操作的需要,效率方面并不重要,因为它不常见或不是瓶颈。)。

其中一些很难以最佳方式实现。再说一次,实施这些是不够的。还需要一种优雅的方式来组合它们,这样嵌套的数据结构就像顶级数据结构一样易于处理(并且可读)。

SPECTER提供了一种称为导航的抽象,它完全解决了在封装了所有细节(例如重建)的任意复杂数据结构上进行优雅、高性能的不可变转换的问题。下面是如何使用Specter命名空间限定映射键的方法:

此代码仅更改地图的键-因此地图的类型保持与以前相同。此外,无论映射类型如何,性能都接近最佳(也就是说,编写更快的代码非常困难)。

Setval就像服用类固醇的Assoc-in。它被赋予一个路径、一个值和一条要转换的数据。路径告诉它要将哪些子值设置为新值。您应该认为路径是一步一步导航到数据结构中。在本例中,它导航到每个映射键,然后为每个映射键导航到其名称空间。导航器封装了迭代的细节和转换过程中任何必要的重构。

Setval是对更一般的操作转换的简单包装。Transform接受一个函数以应用于每个导航的子值。例如:

(转换[全部:偶数?]。Inc[{:A 1}{:A 2:B 1}{:A 4}]);;=>;[{:A 1}{:A 3,:B 1}{:A 5}]。

此转换导航到向量的每个元素,然后导航到值:对于每个地图,仅当值为偶数时才保持导航值,然后应用Inc函数。

幽灵有两个组件。第一个是Specter的核心,它定义了导航器抽象(Defnav),并实现了一种高性能的组合导航器的方法。你可以在这里读到它是如何工作的。实现这个方法是我写过的最困难的代码,它甚至是可能的,这是对Clojure力量的巨大证明。

Specter的第二个组件是一套全面的Clojure核心数据结构导航器。这些功能包括前面提到的所有Clojure缺少的转换函数的实现,以及许多其他类型的导航。这些导航器封装了执行遍历和重建的最有效的方法,通常利用Clojure内部的深奥知识。需要注意的是,导航器(就像所有的导航器一样,映射键,命名空间)是一级对象,而不是语法,这对于那些刚接触Specter的人来说是一个常见的混淆之处。

如前所述,这是Clojure缺少的两个部分。通过填补这些漏洞,Specter可以优雅地完成所有不可变的转换,并且具有近乎最佳的性能。

最初,我为已经展示的示例类型开发Specter:转换复合数据结构中的子值。然后我发现了一个新的导航员类别,它的表现力令我震惊:下层结构导航员。它们提供了对数据操作的非凡控制,并使我相信通过组合导航器来操作数据应该是所有函数式程序员的基本技能。我将介绍几个子结构导航器的示例。

Srange会将您导航到列表或向量的子序列。然后,该子序列被它转换成的任何序列替换。在其他任务中,此导航器可用于将新元素拼接到序列中或删除元素。例如:

(setval[:A(Srange 2 4)][]{:A[1 2 3 4 5]});;=>;{:A[1 2 5]}(setval(Srange 2 2)[:A:A]&39;(1 2 3 4 5));;=>;(1 2:A:A:A 3 4 5)(变换[:A(Srange 1 5)]反转{:A[1 2 3 4 5]});;=>。

筛选器会将您导航到与谓词匹配的所有元素的序列。该序列上的变换以原始序列的形式返回。例如:

(变换(滤光器偶数?)。反转[1 2 3 4 5 6 7 8 9]);;=>;[1 8 3 6 5 4 7 2 9]。

子选择可将您导航到与给定路径匹配的元素序列。与筛选器类似,该序列上的转换将返回到每个值的原始位置。例如:

(变换(小选全部:A EVEN?)。反向[{:A 1}{:A 2:B 1}{:A 4}{:A 5}{:A 6}{:A 8}];;=>;[{:A 1}{:A 8:B 1}{:A 6}{:A 5}{:A 4}{:A 2}]。

通过将子结构导航器与其他导航器相结合,可以实现一些真正美观的代码。看看如何递增序列中的最后一个奇数:

(变换[(过滤器奇数?)。Last]Inc[1 2 3 4 5 6]);;=>;[1 2 3 4 6]。

这不是对复合数据结构的操作,但是事实证明,对于这个任务,导航是一个漂亮的抽象,比您用普通Clojure编写的任何东西都要优雅得多。我再怎么强调这一点也不为过。尽管复合数据结构是最需要Specter的用例,但是Specter被证明对于非复合数据结构的操作非常有用。它的本质是一个可组合的抽象,使您可以简洁地表达非常复杂的转换。每个新定义的导航器都组合地增加了用例的范围。

已经展示了用于执行转换的导航抽象,但该抽象也自然地将其自身用于查询。这里有一个例子:

(SELECT[ALL:A EVEN?][{:A1}{:A2:B1}{:A4}]);;=>;[24]。

SELECT始终返回结果矢量。要仅选择单个值,请使用SELECT-ANY:

这段代码看起来与等效的get-in用法几乎相同,只是它的运行速度快了30%。所有导航器都可以用于查询,因为对于查询和转换,导航的心智模型是相同的。

如前所述,Specter的核心是defnav操作符,Specter的整个导航器集合都建立在该操作符之上。Specter的可扩展性是其最重要的特性之一,因为您可以使用Clojure提供的以外的数据结构。正如我提到的,我做了大量的图形工作,如果没有图形导航器的内部集合(例如,子图、拓扑遍历、到节点ID、到传出节点、到传入节点等),我就不能以任何合理的方式使用它们。

SPECTER的导航器接口是完全通用的,能够表示任何导航器。让我们通过查看一个简单的导航器Namespace:的实现来看看它是如何工作的:

(defnav^{:doc";)导航到关键字或符号的名称空间部分";}Namespace[](SELECT*[This Structure Next-Fn](Next-Fn(Next-Fn(名称空间结构)(Transform*[This Structure Next-Fn](let[Name(Name Structure)new-ns(Next-Fn(名称空间结构))])(cond(关键字?结构)(关键字new-ns name)(符号?结构)(Symbol new-ns name):Else(i/Throw-非法";命名空间只能用于符号或关键字-";Structure)。

有两条代码路径,一条用于查询(SELECT*),另一条用于转换(Transform*)。在Specter中查询的工作方式与传感器的工作方式非常相似。它通过避免创建任何中间数据结构来实现出色的性能。在本例中,它通过对该值调用Next-Fn来导航到该值的命名空间。导航到多个子值(如全部)的导航器必须对每个子值调用Next-fn。(顺便说一句,SELECT*Used的工作方式与List Monad完全一样。但是,在导航过程中物化中间列表会降低性能,因此在0.12.0中更改了该方法以避免该问题。)

Transform*应该使用Next-Fn转换任何子值,并执行任何必要的操作来重建周围的数据结构。在本例中,它使用Next-Fn转换命名空间,并根据原始值的类型创建新的关键字或符号。

如您所见,定义导航器的方式是完全通用的。每个代码路径都是一个函数,可以自由执行其需要的任何操作。这是一个简单但非常有效的界面。由于每个导航员都与所有其他导航员合作,所以可能性是无穷的。我建议浏览源代码,看看内置导航器是如何定义的。

除了使用defnav定义导航器之外,您还可以通过将其他导航器组合在一起来定义导航器。当使用特定应用程序的特定复合数据结构时,这通常很有用。例如,假设您正在开发一个二十一点游戏,并按如下方式存储游戏信息:

{:PERCES[{:Name";Hopper";,:Funds 100,:Games-Played 10}{:Name";Eleven";,:Funds 6941,:GAMES-PLAGED 7}{:Name";Will";,:Funds-12,:GAMES-Played-8}]:Bank{:Funds 9850}}。

玩家是通过他们的ID(例如,索引0、索引1)来索引的,而不是通过他们的名字来索引的。如果您想按玩家的名字轻松操作,可以为其定义一个导航器:

(Defn Player-by-Name[Name](路径:Player all#(=(:Name%)Name));;从Hopper中提取$1(Transform[(Player-by-Name";Hopper";):Funds]Dec Data);;Retrieve:游戏-为Will(SELECT-ANY[(Player-by-Name";Will";):Games-played]data)。

您也可以想象按ID定义一个类似的导航器玩家。像这样抽象出特定于域的导航器可以显著降低使用数据的认知负担,因为您可以按照特定于应用程序的概念而不是较低级别的数据结构概念编写代码。

最后,值得一提的是最后一个用于构建导航器的概念,称为动态导航。这些有点像宏,但供导航员使用。动态导航在这篇文章中解释得太多了,但是你可以在这里阅读到它们。而且,源代码中有很多这样的示例。

不足为奇的是,Specter非常擅长处理递归数据结构。举个简单的例子,假设您有一棵使用向量表示的树:

递归路径允许您的路径定义引用自身,在本例中使用p。该路径定义利用if-path导航器,该导航器使用谓词来确定要继续的路径。如果当前导航到向量,则会递归导航该向量的所有元素。否则,它使用Stay停止遍历并在当前点处完成导航。所有这一切的效果是深度优先遍历每个叶节点。

;增量偶数叶(变换[树值偶数?]。INC树);;=>;[1[3[[3]]5][[5]7][7]9[[9];;获取奇数叶(选择[树值奇数?]。树);;=>;[1 3 5 7 9];;偶数叶的逆序(基于深度优先搜索的顺序)(转换(子选树值偶数?)。反转树);;=>;[1[8[[3]]6][[5]4][7]2[[9]。

您可以一次定义如何获取您关心的值,并轻松地将该逻辑重用于查询和转换,这是非常有价值的。而且,与往常一样,查询和转换的性能都接近最佳。

Sprter1.0对该项目来说是一个非常重要的里程碑。我和斯佩克特的目标一直是击中那个甜蜜点:

-为Clojure感觉完整的核心数据结构提供一组导航器-可以完全动态使用(例如,将路径作为参数传递,稍后存储和使用路径等)-对于所有用例而言,性能接近最佳。

花了几年时间才弄清楚如何实现这一切,经过大量工作,斯佩克特终于出现了。

Specter 1.0版本最重要的部分是对向后兼容性的承诺。0.11.0、0.12.0和0.13.0版本都有重大的突破性变化,所有这些都是使Specter达到最佳状态所必需的。现在斯佩克特在那里,它将非常稳定地向前迈进。

Sprter1.0也有一些重要的新特性,填补了库中的空白。对于初学者来说,它现在可以高性能地从映射和序列中删除元素:

;;从向量中删除nil元素(setval[all nil?]。None[1 2 nil 3 nil]);;=>;[1 2 3];;从贴图中删除偶数值(setval[map-val偶数?]。无{:A 1:B 2:C 3});;=>;{:A 1,:C 3};;从嵌套映射中删除键(setval[:A:B:C]None{:A{:B{:C 1});;=>;{:A{:B{}。

;;附加到字符串(setval[:a end]";!!";{:a";Hi";}){:A";Hi!!";};;删除字符串中间(setval(Srange 1 3)";";";abcd";);;=>;";ad";

另一个主要的新功能是通过新的遍历所有操作与Clojure的传感器集成。使用Traverse-All可以更优雅地表达许多常见的变频器用例:

;;使用Vanilla Clojure(Transduce(comp(map:A)(mapcat标识)(滤镜奇数?))+[{:A[1 2]}{:A[3]}{:A[4 5]}]);;=>;9;;与Specter表达的逻辑相同(Transduce(Traverse-all[:a all odd?])+[{:A[1 2]}{:A[3]}{:A[4 5]}])。

Specter 1.0还添加了一些新的导航器,并改进了许多用例的性能。请在此处查看完整的发行说明。

我对Specter的看法与其他图书馆不同。虽然大多数库提供特定任务的功能,如与数据库交互,但Specter提供的功能是函数式编程的基础。操纵数据结构是您作为程序员所做的最核心的事情之一,所以EV。

.