我在Rust中重写了Clojure工具

2020-12-22 01:57:06

大约两年前,我在Clojure中编写了一个非常复杂的diff工具。它太复杂了,以至于我都难以适应算法,而且输入量也很大,以至于我不得不付出一些努力来提高性能。

大约半年后,我开始学习Rust,将Clojure程序的当前状态移植到Rust中,对更改1感到非常满意,并继续使用Rust。在进行该项目时,我对这两种语言提出了一些意见,尤其是关于错误处理和性能的看法:

我认为这些是Rust擅长的领域,而它们却是Clojure 2的弱点之一。

将我的经验放在上下文中:转移到Rust时,我在Clojure已有一年多的经验。diff工具是迄今为止我编写的最大的Clojure程序,大约只有3000行。当我开始编写Rust时,重新实现现有的Clojure代码是我的第一个Rust代码之一。此后,我继续学习Rust,并且几乎停止编写Clojure。如果最近Clojure发生了变化,请告诉我,我将更新本文。

该项目中的错误处理要求不是很复杂,只需记录所有错误并将其返回给用户即可。唯一略有不同寻常的要求是,解析和验证逻辑应在两个上载的excel文件中显示每行的所有错误(而不是仅显示第一个错误),因此我不得不累积错误。

Clojure中的错误处理没有理由。与错误消息类似,在Clojure中存在的错误处理习惯用法在我看来很大程度上是偶然的,或者是从Java继承的。

标准库主要支持异常。有些库支持返回错误值,而不是抛出诸如错误处理库failjure之类的异常。其他解析库instaparse则返回其自己的自定义错误值3。

我使用了failjure来以更好的方式帮助累积错误(并且因为它吸引了我受Haskell影响的口味)。

让我们看一下我的diff工具中的Clojure函数,该函数使用try-all来解析和验证输入数据。如果发生任何错误,所有错误都将汇总为一个字符串:

(defn parse [国家/地区数据]#_" if如果有任何绑定返回失败,则所有尝试功能将提前退出"(fail / attempt-all [标头(标头行数据)解析(map# (parse-rule标头国家/地区映射%)(content-rows数据))#_"👇失败列表在此处汇总" failed-parses(->解析(过滤失败/失败?) (映射失败/消息))#_"👇这会返回失败,并触发提早退出" parse-result(如果(为空(failed-failed-parses))解析为#_"👇单个失败值包含连接到字符串"的失败列表(/ fail / fail(让[msg(str"无法解析"(计算失败的解析数))"规则:" )](str msg" \ n" failed-parses))))#_"👇这也可能会返回失败" spec-result(util / check-specs&#34 ; Rules":rule / id :: spec / rule parse-result)]#_"👇如果一切成功,则返回" spec-result#_"👇如果有任何失败发生了,则返回" (失败/失败[失败](做(记录/警告(str"未能解析数据"数据":\ n"(失败/消息失败)))失败))))

方括号中的标识符-表达式对使用与Clojure的let-form相同的语法,这使它看起来很熟悉。

我可以选择在最后添加一个错误处理函数,这对例如记录函数的自变量很有帮助,就像我在这里所做的那样。

我看不到哪些功能实际上会失败。我必须阅读它们才能找到答案。

由于Clojure生态系统没有统一的错误处理风格,因此我必须手动将异常或其他错误(例如无固定的错误值)转换为failjure错误。

我的判断是:由于Clojure是一个Lisp,因此如果您愿意的话,可以使用大多数错误处理并使它看起来很好。在大多数情况下,最实用的解决方案是使用异常。

我发现当使用不太明确的错误处理时,尤其是在动态语言中,可读性可能会受到影响。

由于错误处理方法的选择自由,我很想尝试更多的尝试,而不是使用更固执的语言。

Rust对错误处理颇有见解.Rust社区致力于开发和改进常见的习惯用法,其中一些习惯用法已纳入标准库中,从而改善了基准错误处理能力。

虽然没有任何改进,但还是没有改进,并且频繁的更改一直是抱怨的源头。尽管向后兼容性从未中断,但希望自己的代码惯用的人们还是必须对其进行更新,因此旧的教程和指南也是如此。变得过时了。

关于Rust 4中的错误处理,有很多不错的最新文章,可以帮助您了解当前的习惯用法。

在Rust中,可能出错的函数返回Result类型5。有几个库可以使您轻松创建自己的错误或从库中处理错误,但是(大多数)它们仅使用标准库中的类型,而不会引入会导致错误的结果。 39;与其他生态系统不兼容。

pub fn parse(工作簿:& mut工作簿,country_mapping:CountryMapping,)->结果< Vec>规则> {让范围=工作簿。 worksheet_range(" Rules")//此问号会触发提前退出//因为有一个Option //包含一个Result are,所以有两个。 ok_or(format_err!("缺少规则表")))??; //👇let range = skip_to_header_row(range)?;让解析= RangeDeserializerBuilder :: new()。 has_headers(true)。 from_range(& range)//👇上下文(无法阅读规则表")? let rules = collect_errs(已解析的map(| parse_result | {parse_result //👇将lambda映射到错误值上。map_err(| e | e。到())// //在某些其他语言中,这将被称为flatMap。 (| row | row。parse(& country_mapping))})))//👇这会将错误列表转换为包含字符串的单个错误//。 map_err(| es | {format_err!("无法解析{}规则:\ n {}&#34 ;, es。len(),//👇非常优雅... //这只是一个Kotlin中的.joinToString调用es。into_iter()。map(| e | e。to_string()).collect ::< Vec< _>>()。join(" \ n") )//👇})?确定(规则)}

标准库,我见过的每个Rust库以及我自己的应用程序代码始终使用相同的Result类型,这使事情保持了很好的兼容性。

?运算符使易错功能可见,但保持简洁。它还在可能的情况下自动转换错误类型,从而减少了手动类型转换的需要。

库中在此处使用的错误类型无论如何都支持.context方法,该方法为否则无用的低级错误提供了必要的上下文。通常使用基于异常的语言通过捕获,包装和重新抛出来完成此操作,但这看起来更加令人愉快。

我必须保持错误类型兼容,在这种情况下,我根本不需要区分所有错误类型6来实现。我仍然必须返回一个错误值,这意味着我必须手动将所有错误列表详细地转换为单个-在这种情况下为换行符分隔的字符串。

如果我想在整个函数返回错误时记录某些内容或向其中添加一些.context,我希望在整个函数主体中具有一个try / catch-block等效项。这还不存在7,目前的最佳做法似乎是将整个身体移入内部功能或lambda中。

我的判断是:在Rust中,您将使用Result类型,并且会喜欢它。8.主要的设计决策是是否使用某些帮助程序库以及如何设计错误类型。

但是,设计错误类型可能是一个挑战,特别是因为它与设计错误类型有些不同。 Java异常层次结构。我很幸运,跟上不断发展的错误处理习惯用法对我来说并不难,因为我没有时间上的压力,并且经常在业余时间以学习为主要目标工作。维护更大生产系统的团队。希望有大量的错误处理教程和文章可以使现在的学习比几年前更容易。

撇开学习曲线:对我来说,Rust的错误处理感觉就像是秘密武器的一部分,这使它成为我所知道的最有前途的正确性语言。

导致性能问题的程序部分是diff算法,在此之前稍稍进行了数据标准化步骤。我遇到的性能问题的类型主要是受CPU限制的,不得不生成和比较大量的临时数据。大量数据还经常导致两种语言的内存问题。

在Clojure中,使我的代码更快通常意味着使它变得不那么惯用。在针对性能优化代码9时,不鼓励许多Clojure的惯用法和设计方法:

最后,我总是有更多可用的选项-甚至可能用Java编写最热的部分-但其中许多选项会使代码变得不那么漂亮。

让我们看一下Clojure函数之一,该函数是程序的中等热门部分的一部分,在该部分中,输入数据已被规范化:

(defn rule-field-diff [rule1 rule2](让[field-diff-fn(fn [operations](->#_"👇lazyness👇destructuring"(对于[{:keys [ field op]}操作:let [field1(获取rule1字段)field2(获取rule2字段)]]#_"👇在(中等)热循环中使用的哈希图" {:field field:eq? (op field1 field2):left field1:right field2})#_"👇查找哈希表"(filter#(not(:eq?%)))#_"👇hashmap operation" (map#(dissoc%:eq?))))diff(field-diff-fn rule-must-be-equal-operations)mergeable-diff(field-diff-fn mergeable-rule-operations)]#_&#34 ;👇在(中等)热循环中使用的哈希图" {:rule1 rule1:rule2 rule2:diff diff:mergeable-diff mergeable-diff}))

这是最热部分的数据,其中数据是通过其字段之一进行比较的:

(defn diff-rules-by-keys#_"👇解构" [{:keys [group-by-key-fn键名]}路径rules1 rules2]#_"👇哈希映射查找& #34;(让[key-> rules1(group-by-key-fn rules1)key-> rules2(group-by-key-fn rules2)keys1(set(keys key-> rules1))keys2(设置(keys key-> rules2))key-union(set / union keys1 keys2)]#_"👇惰性"(对于[k key-union:let [path(conj path {:key- name key-name:key-val k})]](case [(contains?keys1 k)(contains?keys2 k)]#_" hot在热循环中使用的哈希映射" [true true] { :: continue true:path path :: rules1(获取key-> rules1 k):: rules2(get key-> rules2 k)} [false true] {:plus true:path path:rules(set(get key -> rules2 k))} [true false] {:-minus true:path path:rules(set(get key-> rules1 k))})))))

因此,有些明显的效率低下可能值得改变,太好了!不幸的是,这样做会使小细节变得更难看(删除结构或for表达式时),引入记录以更快的速度替换所有小哈希图将是一种即插即用的替换,这很好。

Clojure成功地使在必要时编写快速代码成为可能,但是惯用的Clojure愿意牺牲一些性能来提高表达能力和柔韧性。很好,Clojure不想成为低级语言。实际上,对于该语言中旨在在必要时加快处理速度的所有逃生舱门,我感到非常惊喜。

我喜欢自由地引入根本不需要运行时开销的抽象,因此让我们看一下Rust:

Rust的设计目标之一是性能,它可以与C竞争,所以我想这意味着它成功了。但这并不能自动使我的程序变得最快-我可以用任何语言编写慢速代码!对我来说更有趣的是,如果我开始运行程序,然后花一个或两个有动机的周末尽可能地进行优化,我的程序将会有多快。

Rust的零成本抽象无疑有助于实现这一目标。我可以引入包装器类型以使我的类型签名更具可读性,而丝毫不影响性能。我可以使用功能强大的Iterator方法(例如map和filter),获得与使用命令式循环相同的性能。

我还可以在Rusts类型系统的监视下自由地使用可变性,它既可以提高性能,又可以使事情更具可读性。老式的命令式/ OO语言(如Java)允许在任何地方进行可变性,这通常会引起问题10.Haskell和然后,Clojure尝试通过尽可能消除可变性来解决可变性问题。 Rust使可变性再次变得可口11。

Rust程序员经常必须在简单代码和性能之间做出决定的地方是在不必要地复制数据或使用对数据的引用之间做出决定。使用对单个数据实例的引用可以提高性能,但需要使用生命周期注释,有时还需要重新设计代码12。

// here可变性在这里!最好通过pub fn交集(mut self,其他:& Self)->选项<自我> {match(self。$ field。accepts_all(),其他。$ field。accepts_all()){//使用限制性更强的字段(true,false)=> {*自我。 $ field =其他。 $ field。克隆(); } // self更受约束,因此我们保留self(false,true)=> {} //都接受一切,我们可以保持self(true,true)=> {} //都受约束,我们需要计算交集(false,false)=> {让交集=自我。 $ field。交集(& other。$ field); //空交集->如果相交则无法满足。 is_empty(){//👇有时命令式逻辑很好!不返回} //👇易变性再次自我。 $ field =交点; }} //如果我们没有提早返回None,则该字段必须是可满足的!一些(自我)}

请注意主体中的$ field-实际上是在宏定义内部!在Rust中通过字段访问和类型对逻辑进行参数化有点笨拙.Clojure版本中的类似函数仅接受关键字参数。

fn next_step< T:等式+ Ord +哈希,F:RuleField< < a,项目= T>>(& self,匹配器:FieldMatcher< a,T,F>)->自我{//根据堆跟踪的TODO,这是RAM热点。做什么?让old_rules:Vec< _> =自我.old_rules。 iter()//👇这个过滤器应该很快。过滤器(| vs |matcher。matches_inlined(vs))。复制的()//👇堆分配// //过去的Timo,您如何对此做些事情! 。收藏 ();让new_rules:Vec< _> =自我.new_rules。 iter()。过滤器(| vs |matcher。matches_inlined(vs))。复制()。收藏 (); //👇路径值很小,但是//我仍然克隆在热循环中,让mut path = self .path。克隆();匹配器。 add_step_inlined(& mut路径); DiffState {路径,old_rules,new_rules,}}

这里有一些效率低下的地方,它们可能是提高性能的最重要点。但是它们仍然存在,因为修复它们对我来说太费力和/或费时了。

我认为这证明了我可以编写一种我几乎无法理解的算法并将这种性能提高到某种状态的性能,即性能问题是由我故意创建和处理的数据量引起的(而不是偶然的低效率) )。

如果以前不是很明显:我已经成为Rust的忠实粉丝,现在我想成为我的首选语言。

在我停止编写Clojure之后,我肯定想念Clojure REPL和Paredit,并且我很想在Kotlin或Rust 13中有类似的经验。使用几乎所有内容都使用一些基本数据结构然后使用函数式编程来处理的设计方法可能会导致制作非常简单的程序14。

另一方面,Rust是最可用的语言,它提供的功能否则只能在ML或Haskell中才能使用.Rust编写有时可以感觉像编写Kotlin一样高级和高效,但具有更具表现力的类型系统,更高的性能上限和更少的运行时限制15。

它的所有权和可变性跟踪功能也确实有助于编写正确的软件,我想念其他语言的那些功能。

切换到Rust之后,我不得不实现更复杂的逻辑来解决我正在扩散的规则之间的依赖关系,这变得非常复杂,以至于即使使用静态类型系统,我也几乎无法理解它,我怀疑我是否能够完成它在Clojure。

最后,我达到了该工具似乎可以正常工作的阶段:设法将性能提高到可接受的水平,然后停止使用该工具。我编写该工具的同事偶尔会继续使用一年以上任何投诉。

我希望工具输入的整个系统(希望)将很快被关闭,替换系统将提供一种更简单的方法来分析更改:它将维护一个数据库,该数据库计算存在的实际实体,并根据规则中的字段进行索引适用于。

我的差异工具将替换为数据库查找,对此我感到非常高兴。

1我立即被超乎寻常的快速性能所吸引-最初主要是启动时间与各自的excel解析库,docjure和calamine之间的差异。

2我不想在这里尝试使用Clojure(在Rust和Kotlin之后,这是我的第三喜欢的语言)。我正在尝试通过将Rust与一种非常好的语言进行比较来展示Rust的强大之处。

3这是一个完全明智的设计,但这意味着我只能通过使用instaparse函数insta / failure处理instaparse错误?

5还有panic宏,它与异常相似,因为它可以停止并展开程序,但是与异常不同的是,捕获panic很少是一个好主意。

9请参阅以下有关如何提高Clojure性能的文章,据我所知,它们非常准确并且包含很好的建议,它们都不鼓励常规的Clojure习语或推荐较少惯用的替代方法。

11有趣的是,我中的一些像我和Rust一样喜欢Kotlin的同事似乎比我更讨厌变异。

12当热循环使用对数据的引用时,代码的某些部分必须拥有数据才能保持其活动状态。 13 Rust-analyzer和IntelliJ虽然支持语义扩展/收缩选择,但这是Paredit的重要功能。Paredit的粗俗特性可能在没有S表达式的语言中没有多大意义。 14有些部分,例如我在diff算法中对数据结构的字段进行抽象的部分,在Rust中显然要痛苦得多。