clojure_深入了解Clojure系列

clojure

InfoQ读者可以 使用结帐代码“ infoq35”, 获得任何版本的 《 Clojure的喜悦》 (早期阅读电子书,或早期阅读电子书+印刷书)的 35%独家折扣 优惠有效期至2010年6月16日。

如果您熟悉Clojure编程语言,那么您可能会知道它的核心是一组强大的不可变,持久性的集合类型。在本文中,我们将讨论这些集合类型的基础,包括深入探讨变成几个 即其向量和地图。 最后,我们将通过一个示例来结束本文,该示例说明如何通过Clojure的方式查看问题,从而可以极大地简化我们的设计。

关于不变性

为什么Clojure设计和实现时以不变性为基石? 虽然肯定没有灵丹妙药,但在语言级别促进不变性可以立即解决许多难题,同时可以简化许多其他难题。 由于语言背景中的可变性与命令式编程方法交织在一起,因此它经常需要进行重大的概念性飞跃,以使自己的思想接受和利用不变性和函数式编程。 在许多情况下,纯粹主义者将不变性的本质视为任何给定语言进入函数式编程类别的前提。 虽然我们当然不愿意为此类模糊主题设定硬性和快速性的定义,但我们会说数据结构的不变性和函数式编程确实可以很好地互补。 在本节中,我们将为不可变性建立概念基础,因为它与Clojure的基本理念有关,以及为什么即使在Clojure本身的温暖局限范围内,您也应该努力促进不可变性。

定义不变性

在Clojure的上下文中使用不变性一词时,我们经常会严格地指代其核心数据类型。 但是,不可变性的整体图景可以扩展到不仅严格的数据结构生态系统之外,还可以包含任何遵循不可变性概念的对象。 在许多情况下,当具体谈论Clojure的不可变数据结构时,我们实际上可以谈论的是不可变对象的更广泛类别。 但是,为了使这种情况发生,我们可能应该设置一些条件来定义我们所说的不变性。

每天就像星期日

有一个完整的哲学分支叫做“命题”(predestination),专门研究没有自由意志这样的观念,但是,我们现在或将来的一切都必须事先确定。 尽管这种可能性对于我们自己的生活来说似乎是暗淡的,但这一概念确实很好地封装了对象不变性的第一个原理。 即,不可变对象的所有可能属性在其构建时就已定义,此后不能更改。

约定存在不变性

不变的对象没有什么神奇的。 就是说,计算机系统在许多方面都是开放系统,如果人们很想抓住它们,则可以向金库提供密钥。 但是,为了在我们自己的系统中营造一种不变性的氛围,至关重要的是建立一个不变性的门面。 换句话说,不变性要求我们对提供不受限制的可变性的系统部分进行分层和抽象。 例如,在Java中创建不可变的类 [1] 要求我们以多种方式做到这一点。 首先,一个类本身及其所有字段都应标记为final 。 接下来,在构造过程中,对象的引用绝不会逃脱。 最后,任何内部可变对象都应在类本身内部以整块或通过副本形式发起,因此永远不要逃脱。 显然,我们在进行简化是为了说明,因为此食谱针对Java不变性有更详细的说明,但是就目前而言,这些简化的要点旨在表明,通过遵守约定,即使是固有可变的语言(例如Java)也可以变为不变。 在Java之类的语言中,不直接支持不变性,并且需要一些体操才能达到目的,而Clojure直接将不变性作为一种语言功能来支持 [2] 及其核心数据结构。 通过提供不变的数据结构作为主要语言功能,Clojure得以分离 [3] 从实现的复杂性来看,使用不可变结构的复杂性。

当然,如上所述,定义不可变对象的部分应视为对任何特定编程语言均不可知。 但是,通过提供不变性作为核心语言功能或通过约定,您可以为自己带来巨大的好处。

不变性的好处

Clojure的不可变数据结构不是经过深思熟虑或在菜单菜单中进行选择而附加到该语言的。 取而代之的是,将它们包含在语言中已深入其哲学核心。

不变量

基于不变式的编程涉及对类和函数的约束的定义,以确保如果其实例进入某些状态,则会出现断言错误。 在可变系统中提供不变性需要在任何给定类的方法中编织大量的断言。 但是,通过观察不变性的实践,不变量仅在构造机制中定义,此后再也不会违反。

推理

面对可变性,将任何对象传递给任何函数都可以消除跟踪其属性变化的可能性。 但是,由于不可变对象的生命是命运的其中之一,因此对其可能状态进行推理的问题就变得微不足道了。 因此,由此简化了测试这种系统的动作,因为限制了可能的状态和过渡的集合。

平等有其意义

存在可变性时的平等没有任何意义。 面对可变性和并发性,平等是绝对的疯狂。 也就是说,如果两个对象现在解析为相等,那么绝对不能保证它们会从现在开始。 如果两个对象永远不相等,那么从技术上讲它们就永远不相等 [4] 。 再次提供不可变的对象将赋予相等性意义,因为如果两个对象现在相等,那么它们将始终如此。

分享便宜

如果确定某个对象永远不会更改,那么共享该对象就成为提供对其引用的简单问题。 在Java中,这样做通常需要大量防御性复制。 沿着这样的脉络,因为我们可以自由地共享不可变对象的引用,所以我们同样可以免费地实习它们。

展平间接层

可变对象和可变引用之间存在显着差异。 Java中的默认设置是存在包含可变数据的可变引用。 但是,在Clojure中只有可变的引用。 这似乎是一个很小的细节,但是它确实可以减少不必要的复杂性。 为什么在下一代Java编程风格中还有最终的泛滥?

当然,在像Clojure这样的面向并发的编程语言中,不变性的主要好处是可以在各个线程之间自由共享核心类型,而无需担心。 在下一节中,我们将更详细地讨论这种特殊的好处。

不变性促进并发编程

不可变对象始终是线程安全的-Brian Goetz,Java并发实践

如果对象无法更改,则可以在不同执行线程之间自由共享该对象,而不必担心并发修改。 关于这一点的争论很少,但是这个事实并不能回答突变如何发生的问题。 在不深入研究细节的情况下,您可能已经知道Clojure将突变隔离为其参考类型,而包装它们的数据保持不变。 既然我们将把第9章专门介绍这个主题和相关主题,那么就足够单独讨论这一点了。

设计一个持久性玩具

我们不会详细介绍Clojure持久数据结构的内部-我们将其留给其他人 [5] 。 但是我们确实想探索结构共享的概念。 与Clojure的实现相比,我们的示例将大大简化,但是它应该有助于阐明所使用的一些技术。

最简单的共享结构类型是列表。 可以将两个不同的项目添加到同一列表的前面,从而产生两个共享其一部分的新列表。 通过实际创建一个基本列表,然后从相同的基本列表创建两个新列表来进行尝试:

(def baselist (list :barnabas :adam))
(def lst1 (cons :willie baselist))
(def lst2 (cons :phoenix baselist))


lst1
;=> (:willie :barnabas :adam)

lst2
;=> (:phoenix :barnabas :adam)

我们可以将baselist视为lst1和lst2的历史版本。 但这也是两个列表的共享部分。 不仅列表相等,而且两个列表的一部分是相同的 -完全相同的实例:

(= (next lst1) (next lst2))
;=>gt; true


(identical? (next lst1) (next lst2))
;=>gt; true

这样不太复杂,对吧? 但是列表支持的功能也很有限。 Clojure的向量和地图还提供结构共享,同时允许您在集合中的任何地方(而不仅仅是一端)更改值。 关键是每个内部使用的树结构。 为了帮助演示一棵树如何允许内部更改并同时维护共享结构,让我们构建一个自己的树。

我们树的每个节点将具有3个字段:一个值,一个左分支和一个右分支。 我们将它们放在地图中,如下所示:

{:val 5, :l nil, :r nil}

那是最简单的树-单个节点的值为5,左右分支为空。 当将单个项目添加到空树时,这正是我们要返回的树。 为了表示一棵空树,我们将只使用nil 。 让我们编写我们自己的conj函数来构建我们的树,仅从这种初始情况的代码开始:

(defn xconj [t v]
  (cond
    (nil? t) {:val v, :l nil, :r nil}))


(xconj nil 5)
{:val 5, :l nil, :r nil}

嘿,行得通! 不过还不太令人印象深刻,因此我们需要处理将项目添加到非空树的情况。 通过在左分支中放置小于节点:val的值,在右分支中放置其他值,使树保持顺序。 这意味着我们需要一个类似的测试:

(< v (:val t))

的确如此,我们需要新的值v进入左分支(:lt) 。 如果这是一棵可变的树,我们将:l的值更改为我们的新节点。 相反,让我们构建一个节点,复制不需要更改的旧节点部分。 就像是:

{:val (:val t),
 :l (insert-new-val-here),
 :r (:r t)}

这将是我们的新根节点。 现在我们只需要弄清楚在insert-new-val-here放置什么。 如果:l的旧值为nil,我们只需要一棵新的单节点树-我们甚至已经有相应的代码,因此可以使用(xconj nil v) 。 但是,如果:l不为零怎么办? 在那种情况下,我们只想在树:l指向的任何树中的适当位置插入v ,所以(:lt)而不是xconj表达式中的nil 。 这为我们提供了一个新的xconj ,如下所示:

(defn xconj [t v]
  (cond
    (nil? t)       {:val v, :l nil, :r nil}
    (

在那里,它正在工作。 至少看来-输出中有很多噪音,很难阅读。 这是一个小功能,可以按排序顺序遍历树,将其转换为将更简洁地打印的seq:

(defn xseq [t]
  (when t
    (concat (xseq (:l t)) [(:val t)] (xseq (:r t)))))

(xseq tree1)
;=>gt; (2 3 5)

看起来不错。 现在,我们只需要一个最终条件来处理小于节点值的值的插入:

(defn xconj [t v]
  (cond
    (nil? t)       {:val v, :l nil, :r nil}
    (

现在我们已经构建好了东西,希望您足够了解它是如何组合在一起的,这样对共享结构的演示就不足为奇了:

(def tree2 (xconj tree1 7))
(xseq tree2)
;=>gt; (2 3 5 7)

(identical? (:l tree1) (:l tree2))
;=>gt; true

考虑一下。 无论树的根节点的左侧有多大,都可以在右侧插入某些内容,而无需复制,更改甚至检查左侧。 所有这些值将与插入的值一起包括在新树中。 因此,此玩具示例演示了与Clojure的所有持久性集合相同的几个功能:

  • 每个“更改”都会至少创建一个新的根节点,并在通过树的路径中(根据需要在其中插入新值)创建新的节点。
  • 值和未更改的分支从不复制,但是对它们的引用从旧树中的节点复制到新树中的节点。
  • 此实现以易于检查的方式是完全线程安全的-在以任何方式更改对xconj的调用之前不存在任何对象,并且新创建的节点在返回之前处于其最终状态。 根本没有任何其他线程,甚至同一线程中的任何其他函数无法看到任何处于不一致状态的东西。

与Clojure的产品质量更高的代码相比,我们的示例在某些方面会失败。 这个玩具:

  • 只是一棵二叉树 [6]
  • 只能存储数字
  • 如果树太深,将溢出堆栈
  • 产生(通过xseq )非惰性seq,它将包含树的完整副本。
  • 可以创建不平衡的树,这些树将具有非常糟糕的“最坏情况”算法复杂度 [7]

尽管使用xconj作为基础示例描述的结构共享可以减少持久性数据结构的内存占用,但是仅凭它是不够的。 取而代之的是,Clojure在很大程度上依赖于惰性序列的概念,以进一步减少其内存占用空间,因为我们将在本书《 Joy of Clojure》中进一步探讨。   接触到类似于Clojure的集合类型的结构共享的基础知识,让我们花一些时间讨论Clojure中最普遍的两种集合类型:矢量和地图。

向量:创建和使用所有种类

向量存储零个或多个按数字顺序索引的值,有点像数组,但是当然是不变的和持久的。 它们用途广泛,可以有效利用小型和大型内存和处理器资源。

向量可能是Clojure代码中最常用的集合类型。 它们用作参数列表和let绑定的文字,用于存储大量应用程序数据,堆栈和映射条目。 我们将研究所有这些方面,最后对向量不起作用的情况进行研究。

建筑矢量

可以使用文字方括号[1 2 3]来构建向量。 与列表的圆括号相比,此语法是您可能选择在列表上使用向量的原因之一。 例如,如果let形式采用绑定的文字列表而不是文字vector ,那么它会以几乎相同的实现很好地工作。 然而,方括号是从周围的让利形式本身以及在咱们形式的身体可能函数调用的圆括号视觉上不同的,这是对我们人类非常有用。 使用向量来指示letwith-openfn等的绑定在Clojure中是惯用的,因此建议您在自己编写的任何类似宏中都遵循这种模式。

创建向量的最常见方法是使用上述文字语法。 但是在很多情况下,您都想从其他某种集合的内容中创建向量。 为此,有一个功能vec

(range 10)
;=>gt; (0 1 2 3 4 5 6 7 8 9)

(vec (range 10))
;=>gt; [0 1 2 3 4 5 6 7 8 9]

请注意, 范围结果的圆括号与vec结果的方括号相比。 这是我们的提示,即range返回了一个seq,而vec返回了一个由seq值填充的向量。

如果你已经有了一个载体,但要连接几个值吧, 是你的朋友:

(let [my-vector [:a :b :c]]
  (into my-vector (range 10)))

;=> [:a :b :c 0 1 2 3 4 5 6 7 8 9]

如果您希望它返回向量,则如上所述, into的第一个参数必须是向量。 但是,第二个arg可以是任何序列,例如返回的范围 ,或可以与seq函数一起使用的任何其他参数。 来自seq的值“倒入”提供的向量中。

由于向量本身可以与seq一起使用,因此您可以使用into将两个向量串联在一起:

(into [:a :b :c] [:x :y :z])
;=>gt; [:a :b :c :x :y :z]

但是请注意,这会将第二个向量视为seq,因此它实际上是基于第二个向量的大小的线性时间运算 [8]

实际上,这是构建向量所需的全部知识,尽管出于完整性考虑,我们将向您展示很少使用的向量函数,该函数根据您提供的参数而不是seq来构建向量:

(vector 1 2 3)
;=> [1 2 3]

几乎没有充分的理由使用向量而不是文字向量。 向量的确比列表更正确,但这已经超越了我们自己。

使用vecinto ,可以轻松构建比通常使用向量文字构建的向量大得多的向量。 一旦有了这样的大向量,您将如何处理? 接下来让我们看看。

大向量

当集合很小时,向量和列表之间的性能差异几乎没有关系。 但是,随着它们变大,彼此的运行速度会大大降低,而彼此仍然可以高效地运行。 向量在相对于列表的三件事上特别有效:从集合的右端添加或删除东西,通过数字索引访问或更改集合内部的项目以及以相反的顺序行走。 通过将向量视为堆栈来完成添加和删除操作,我们稍后将进行介绍。

向量中的任何项目都可以在基本恒定的时间内通过其索引号从0到但不包括(计数my-vector)的索引号进行访问 [9] 。 这可以使用函数nth来完成; 函数get ,本质上将向量像地图一样对待; 或者只是简单地将向量本身作为函数调用。 让我们看看应用于本示例矢量的每一个:

(def some-chars (vec (map char (range 65 75))))

some-chars
;=>gt; [\A \B \C \D \E \F \G \H \I \J]

所有这三个都做相同的工作,每个都返回\ E

(nth some-chars 4)
(get some-chars 4)
(some-chars 4) ; vector as a function

使用哪个来判断,所以选择时可能要考虑以下几点:

第n

得到

向量作为函数

向量为零

返回nil

返回nil

引发异常

索引超出范围

引发异常

返回nil

引发异常

支持“未找到”

没有

由于对向量进行了索引,因此可以有效地沿从左到右或从右到左的任一方向行走。 seqrseq函数返回执行以下 操作的序列:

(seq some-chars)
;=>gt; (\A \B \C \D \E \F \G \H \I \J)

(rseq some-chars)
;=>gt; (\J \I \H \G \F \E \D \C \B \A)

向量中的任何项目都可以使用assoc函数“更改”。 当然,由于向量是不可变的,因此实际上并没有改变-assoc返回的新向量与旧向量完全相同,除了更新后的值。 Clojure使用我们在本文开头所述的新旧载体之间的结构共享,在基本上恒定的时间内完成了此操作。

(assoc some-chars 4 "no longer E")
;=> [\A \B \C \D "no longer E" \F \G \H \I \J]

请注意, 某些字符的值完全没有改变。 REPL刚刚打印了一个新矢量,该矢量几乎但不完全像某些字符

向量的assoc函数仅适用于向量中已存在的索引,或者在特殊情况下,仅在结束后的一步上起作用。 在后一种情况下,返回的向量将比输入向量大一个项目。 然而,更常见的是使用conj函数“增长”向量,这将在下一部分中看到。

提供了一些在内部使用asoc的功能更高的功能。 例如, replace函数在seqs和vector上均可使用,但是当给定vector时,它将使用assoc修复并返回新的vector:

(replace {2 :a, 4 :b} [1 2 3 2 3 4])
;=> [1 :a 3 :a 3 :b]

assoc-inupdate-in函数用于处理矢量和/或地图的嵌套结构,就像这样 [10]

(def矩阵

(def matrix
     [[1 2 3]
      [3 4 5]
      [5 6 7]])

关联更新都采用一系列索引,以从每个更深层的嵌套级别中选择项目。 对于像上面的矩阵示例那样排列的向量,这等于行和列的坐标:

(assoc-in matrix [1 2] 'x)
;=> [[1 2 3] [3 4 x] [5 6 7]]

update-in函数以相同的方式工作,但是它不是使用值来覆盖现有值,而是使用了将函数应用于现有值的功能。 它将用给定函数的返回值替换给定坐标处的值:

(update-in matrix [1 2] * 10)
;=> [[1 2 3] [3 4 50] [5 6 7]]

坐标指的是值5,上面给出的函数是*,因此值5将被返回值(* 5 10)代替。 尾随的10来自提供给update-in的额外参数-可以给应用的函数添加任意数量的参数。

到目前为止,我们尚未更改所查看的任何向量的大小,只是向上查找并更改了项目。 接下来,我们将研究增长和收缩的向量-将它们视为堆栈。

向量作为堆栈

经典的可变堆栈至少具有两个操作pushpop 。 尽管它们分别称为conjpop ,但它们也可以在向量上工作,方法是在右侧添加和删除内容。 因为向量是不可变的,所以pop返回一个新向量,其中最右边的项目被丢弃了-这与通常返回被丢弃的项目的许多可变堆栈API有点不同。 因此, 偷看作为从堆栈顶部获取项目的主要方式变得更加重要。

谈话足够多-让我们做些事情。

(def my-stack [1 2 3])

(peek [1 2 3])
;=>gt; 3

(pop [1 2 3])
;=>gt; [1 2]

(conj my-stack 4)
;=>gt; [1 2 3 4]

(->gt; my-stack (conj 4) (conj 5) pop (conj 6))
;=>gt; [1 2 3 4 6]

这些操作中的每一个都在基本上恒定的时间内完成。 在大多数情况下,用作堆栈的向量会在其整个生命周期中使用这种方式。 记住这一点,即使在其他功能可能起作用的情况下,也要始终如一地使用堆栈操作,这对将来的代码阅读者很有帮助。 例如,向量上的last返回与peek相同的东西,但除了速度较慢之外,还导致不必要地混淆了如何使用该集合。 如果算法涉及调用堆栈,请使用conj not assoc 扩展向量, peek not last ,并pop not dissoc收缩向量。

如果conjpop对您来说不够快,则支持瞬态的向量可以使用更快的等效项,我们将在本书中介绍。

conjpoppeek函数可在实现clojure.lang.IPersistentStack的任何对象上工作 [11] 。 除了矢量,Clojure列表还实现了此接口,但是函数在列表的左侧而不是矢量的右侧运行。

使用向量而不是“反向”

向量在右端有效增长,然后从左向右行走的能力产生了值得注意的紧急行为:惯用的Clojure代码很少使用反向函数。 这与大多数Lisps和Schemes完全不同,因此,如果您熟悉其中的一种,我们可以简要了解一下它是如何实现的。 如果您不喜欢Clojure而不是Lisps(以及为什么会这样),请随时跳到“向量作为堆栈”。

处理列表时,通常要以相同的顺序生成一个新列表。 但是,如果您只有经典的Lisp列表,通常是最自然的算法 [12] 留下需要反向的后退列表。 这是一个函数示例,有点像Clojure的map ,但是严格而不是惰性:

(defn strict-map1 [f coll]
  (loop [coll coll, acc nil]
    (if (empty? coll)
      (reverse acc)
      (recur (next coll) (cons (f (first coll)) acc)))))

(strict-map1 odd? (range 5))
;=>gt; (false true false true false)

这是非常好的,惯用的Clojure代码,除了最终返回值的明显相反 。 遍历整个列表一次以产生所需的值后,再次反向遍历它以正确的顺序获得它们。 这既没有效率又没有习惯用法。 摆脱了反向的一种方法是使用,而不是一个列表作为蓄电池的载体:

(defn strict-map2 [f coll]
  (loop [coll coll, acc []]
    (if (empty? coll)
      acc
      (recur (next coll) (conj acc (f (first coll)))))))

(strict-map2 odd? (range 5))
;=>gt; [false true false true false]

进行了很小的更改,但是代码现在变得更加整洁,并且速度更快。 它的确返回一个向量而不是一个列表,但这很少出现问题,因为任何要将其视为seq的客户端代码通常都可以自动这样做。 不过,有时候返回(seq acc)而不是仅仅acc可能是合适的。

顺便说一句,摆脱逆转的另一种方法是建立一个惰性序列而不是严格的集合。 实际上,这就是Clojure自己的地图功能的实现方式。 我们将在本书的第5章中介绍有关懒惰序列的更多内容。

Clojure中的术语“向量”实际上是对任何实现clojure.lang.IPersistentVector的对象的口语描述。 有一些具体的类充当矢量:PersistentVector,SubVector和MapEntry [13] 到目前为止,我们所看到的示例都是简单的PersistentVectors,尽管它们适用于所有这些类型。 但是,现在让我们从SubVector开始,介绍其他矢量类型的特殊功能。

子向量

尽管不能从矢量中有效地删除项目(最右边的项目除外),但是SubVectors提供了一种非常快速的方法来根据起点和终点索引对现有矢量进行切片。 它们是使用subvec函数创建的。

让我们使用前面定义的一些字符向量:

some-chars
;=>gt; [\A \B \C \D \E \F \G \H \I \J]

subvec接受开始和结束索引,并返回一个新的向量:

(subvec some-chars 3 6)
;=>gt; [\D \E \F]

给予subvec第一指标是包容性( 索引3开始),但第二个是独特的(索引6 之前结束)。

但是真正有趣的是,这个新向量在内部悬挂在整个原始的一些字符向量上。 我们在新矢量上进行的每次查找都会使SubVector做一些偏移量数学运算,然后在原始矢量中进行查找。 这使得创建SubVector的速度非常快。 如果SubVector或其基础向量是可变的,则可以通过更改其中一个并观察另一个的影响来检测到这一事实。 但是由于两者都是不可变的,因此SubVector可以与其他任何向量完全一样地对待。

您可以在任何类型的向量上使用subvec ,它将正常工作。 但是它具有用于获取subvec的细分的特殊逻辑,在这种情况下,最新的SubVector保留对原始矢量的引用,而不是对中间SubVector的引用。 这样可以防止SubVectors-of-SubVectors不必要地堆叠,并使子Subvecs的创建和使用都保持快速。

向量作为MapEntries

就像其他许多语言中的哈希表或字典一样,Clojure的哈希图具有一种遍历整个集合的机制。 毫无疑问,Clojure针对此迭代器的解决方案是一个序列。 该序列的每个项目都需要同时包含键和值,因此它们被包装在MapEntry中。 打印时,每个条目看起来像一个向量:

(first {:width 10, :height 20, :depth 15})
;=>gt; [:width 10]

但MapEntry不仅看起来像矢量,而且确实是一个矢量:

(vector? (first {:width 10, :height 20, :depth 15}))
;=> true

这意味着我们可以在其上使用所有常规向量函数: conjget等等。 它甚至支持销毁,这非常方便。 例如,当地人维度和下方将在依次在每个键/值对的值:

(doseq [[dimension amount] {:width 10, :height 20, :depth 15}]
  (println (str (name dimension) ":") amount "inches"))
; width: 10 inches
; height: 20 inches
; depth: 15 inches
;=>gt; nil

如果您更喜欢使用conj而不是assoc ,则两个元素的矢量(无论它们是否为MapEntries)也可以用于将值添加到哈希映射或排序映射中:

(conj {} [:a 1] [:b 2])
;=> {:b 2, :a 1}

但是MapEntry是它自己的类型,并支持几个额外的函数来检索其内容: keyval ,它们分别与(nth my-map 0)(nth my-map 1)完全相同。 这些有时对于使代码清晰可见很有用,但由于使用起来非常方便,因此经常使用解构。

因此,现在您知道什么是向量,Clojure中包括哪些特定类型的向量,以及它们擅长做的一些事情。 为了完善我们对向量的理解,让我们以向量不好做的事情作为一个简短的结论。

什么不是向量

向量是非常通用的,但是有一些通常需要的模式,在这些模式下,它们似乎是一个好的解决方案,但实际上却不是。 尽管我们倾向于专注于积极的方面,但希望有一些负面的示例可以帮助您避免使用错误的工具来完成工作。

向量不稀疏

如果您有长度为n的向量,则唯一可以插入值的位置是在索引n处 ,该位置将追加到最右端。 您不能跳过某些索引并以更高的索引号插入。 如果要使用非序列号索引的集合,请考虑使用哈希图或排序图。

尽管您可以替换向量中的值,但是您无法插入或删除项目,因此必须调整后续项目的索引。 Clojure当前没有支持这种操作的本机持久性集合,但是手指树在某些用例中可能会有所帮助。

如果您总是想在顺序集合的左端添加东西,而不是矢量的右端,那么您可能已经找到了使用PersistentList的好机会。

向量不是队列

有些人试图将向量用作队列。 一种方法是使用conj推到向量的右端,然后使用restnext将项目从左侧弹出。 这样做的问题是restnext返回seq,而不是向量,因此后续的conj操作将无法正常运行。 使用转换的SEQ回向量为O(n),这是不太理想的每一个“流行”。

另一种方法是将subvec用作“ pop”,而忽略最左边的项目。 由于subvec确实返回向量,因此后续的conj操作将根据需要推到右侧。 但是,如上所述, subseq保留了对整个基础向量的引用,因此,不会以这种方式“弹出”任何项目。 也不太理想。

因此,这是理想的方式上做一个持久化集合队列操作? 为什么要使用Clojure提供的PersistentQueue。

向量不能包含基元

intbyte这样的Java原语占用的内存更少,并且处理起来通常比装箱的同类IntegerByte要快得多。 不幸的是,Clojure的持久性集合还没有支持原语。 将原语放入向量中的任何尝试都将导致商品的自动装箱,并且从向量中检索到的任何值都将类似地成为对象。 尽管已经计划了Clojure向量最终支持原语,但这还远远没有实现。 如果您确实需要集合中基元的内存或性能优势,那么目前唯一的选择是使用Java数组。

向量未设置

如果要找出向量是否包含特定值,可能会想使用contains? 功能,但您会对结果感到失望。 包含? 用于询问集合中是否有特定的而不是value ,这对于向量很少有用。 向量对象确实具有可通过Java互操作调用的contains方法,该方法将提供所需的结果,但是Clojure提供了更好的方法-实集集合。 有关集合的详细信息,请参见本章的第6节。

在那里,关于向量的细节肯定足够了。 我们看到了如何使用文字语法或通过编程来创建它们。 我们研究了如何将它们推,弹出,切成薄片并将它们塞入地图。 我们还研究了向量不能很好完成的一些事情。 其中之一是从左侧添加和删除项目。 尽管矢量无法做到这一点,但列表却可以。 让我们看看下一个。

地图品种及其使用方法

与Clojure提供的其他复合数据类型相比,似乎它的映射在用例中应该是明确的。 尽管在许多情况下确实是这样,但对于所提供的地图的不同风味,有一些要点。 此外,我们将重点介绍在许多可能会使用其他结构的情况下使用地图的情况。 但是,通常有一个简单的规则:在您的数据结构需要键到值的逻辑映射的任何情况下,都应使用映射。

无需某种类型的映射就很难编写任何大小的程序。 地图的使用在编写软件中无处不在,因为坦率地说,很难想象会有更强大的数据结构。 但是,作为程序员,我们倾向于将地图视为特殊的案例结构,而不是赋予类的正常提升。 也就是说,面向对象的思想流派已将地图降级为支持该类的支持者。 对于这里的降级,我们将不讨论其优缺点,但是在整本书中,我们讨论了远离类思考,而是顺序抽象,映射,集合和类型思考。 综上所述,几乎不必提及应使用映射来存储命名值。 但是,有时您可能希望使用序列中的位置元素来表示某些结构。 这种方法往往比较脆弱,通常可以通过使用映射来更好地实现,因为它们是通过使用命名值来自组织的。 Clojure的地图支持键和值的持久性类型的任意复杂嵌套,因此,如果您想要一个以地图矢量作为键的地图,那是完全可以的。 让我们讨论一下Clojure提供的每种形式的地图以及围绕每种形式的权衡。

哈希图

可以说,最普遍的 [14] 在Clojure程序中找到的映射的一种形式是哈希映射,它提供了未排序的键值关联结构。 除了在第1章中谈到的文字语法之外,还可以使用hash-map函数创建哈希映射,该函数同样采用交替的键/值对,并带有或不带有逗号:

(hash-map :a 1, :b 2, :c 3, :d 4, :e 5)

文字形式和构造函数之间的结果差异在于,后者总是提供哈希图,而前者有时可以提供数组图。 目前,这种不一致的原因并不重要,但将在即将到来的有关数组映射的部分中进行讨论。

Clojure哈希图支持异构密钥,这意味着它们可以是任何类型,每个密钥可以是不同类型。

(let [m {:a 1, 1 :b, [1 2 3] "4 5 6"}]
  [(get m :a) (get m [1 2 3])])

;=>gt; [1 "4 5 6"]

就像我们在本章开始提到的那样,Clojure的许多复合类型都可以用作函数,在映射的情况下,它们是其键的函数。 以这种方式使用映射的行为与上一个代码示例中的get函数的使用相同。

(let [m {:a 1, 1 :b, [1 2 3] "4 5 6"}]
  [(m :a) (m [1 2 3])])

;=>gt; [1 "4 5 6"]

提供一个映射到seq函数将返回一系列映射条目:

(seq {:a 1, :b 2})
;=>gt; ([:a 1] [:b 2])

当然,上面的序列似乎是由向量中包含的一组键/值对组成的,出于所有实际目的,应该这样对待。 实际上,可以使用此精确结构惯用地创建新的哈希图:

(into {} (list [:a 1] [:b 2]))
;=>gt; {:a 1, :b 2}

即使您的嵌入对不是向量,也可以将它们用于构建新地图:

(into {} (map vec '((:a 1) (:b 2))))
;=>gt; {:a 1, :b 2}

实际上,由于键/值对是按顺序连续排列的,因此不必对您的对进行显式分组,因为您可以使用apply创建哈希映射。

(apply hash-map [:a 1 :b 2])
;=>gt; {:a 1, :b 2}

您还可以通过这种方式将applysorted-maparray-map一起使用 。 生成地图的另一种惯用方法是使用zipmap将两个序列“压缩”在一起,其中第一个包含所需的键,第二个包含其对应的值:

(zipmap [:a :b] '(1 2))
;=>gt; {:b 2, :a 1}

我们对zipmap的使用很好地说明了哈希映射的最终属性。 也就是说,Clojure中的哈希映射没有顺序保证。 如果确实需要订购,则应该使用排序的地图,下面将进行讨论。

与已排序的地图保持按键顺序

使用地图代替列表和向量等其他常见结构存在潜在的不利影响; 不可知论者。 也就是说,对于标准Clojure映射,不可能依赖键值对的特定顺序,因为根本没有顺序保证。 如果地图条目的排序对您的应用程序很重要,则Clojure有两个地图实现可提供订单保证。 首先,我们将要讨论的是排序映射,可以使用一对函数之一来构建排序映射: sorted-mapsorted-map-by

默认情况下, sorted-map函数将构建一个通过比较其键进行排序的映射

(sorted-map :thx 1138 :r2d 2)
;=>gt; {:r2d 2, :thx 1138}

但是,您可能需要替代的密钥顺序,或者可能需要通常无法比较的密钥,例如非平凡的密钥。 在这些情况下,您必须使用sorted-map-by ,它具有附加的比较功能:

function:
(sorted-map "bac" 2 "abc" 9)
;=>gt; {"abc" 9, "bac" 2}

(sorted-map-by #(compare (subs %1 1) (subs %2 1)) "bac" 2 "abc" 9)
;=>gt; {"bac" 2, "abc" 9}

这意味着排序的映射通常不像哈希映射那样支持异构键,尽管它取决于比较 [15] 提供的功能,例如上面的假设所有键都是字符串。 默认的排序地图比较功能支持其键均为数字或彼此可比较的地图。 尝试使用所使用的任何比较功能均不支持的键通常会导致类强制转换异常:

(sorted-map :a 1, "b" 2)
;=>gt; java.lang.ClassCastException: clojure.lang.Keyword cannot be cast to java.lang.String

排序映射(以及排序集合)支持的一项独特功能是能够有效跳转到特定键并从集合中向前或向后前进的功能。 这分别通过subseqrsubseq函数分别用于正向和反向来完成。 即使您不知道所需的确切密钥,也可以使用这些功能来“舍入”存在的下一个最接近的密钥。

排序映射和哈希映射不同的另一种方式是它们对数字键的处理。 给定大小的数量可以用许多不同的类型表示,例如42可以是long,int,float等。哈希映射会将这些不同的对象视为不同 ,而排序的映射会将它们视为相同。 您可以在此示例中看到对比,其中哈希映射保留两个键,而排序映射仅保留一个键:

(assoc {1 :int} 1.0 :float)
;=>gt; {1.0 :float, 1 :int}

(assoc (sorted-map 1 :int) 1.0 :float)
;=>gt; {1 :float}

排序的映射将像哈希映射一样工作,并且可以互换使用。 如果需要指定或保证特定的键顺序,则应使用排序映射。 另一方面,如果您需要保持插入顺序,那么就需要使用数组映射,就像我们现在看到的那样。

使用数组映射使插入顺序保持顺序

在Clojure中处理地图文字时,您可能会对以下内容感到惊讶:

{:x 1 :x 2 :x 3}
;=> {:x 1, :x 2, :x 3}

(:x {:x 1 :x 2 :x 3})
;=> 1

映射可能允许重复的键似乎与逻辑背道而驰。 尽管这确实是正确的,但这是Clojure当前实现的地图文字的副作用。 也就是说,为了尽快构建映射文字,Clojure使用数组映射来存储其实例。 数组映射又将其键值对作为交替元素存储在平面数组中,可以通过忽略键值对的形式并将其盲目复制到适当位置来快速填充。 当然,此条件将Clojure文字映射的当前实现称为数组映射,可以想象随时改变。 但是,无论如何,都有一个重要的教训可以学习: 有时,您最好的选择根本不是地图

您可能已经听过很多遍了(包括我们的书),当搜索元素时,数组和列表为O(n)或与元素总数成比例,而映射为O(1)或常数。   Big-O表示法是指算法相当复杂的相对度量,并且忽略切线成本,例如哈希计算,索引操作等。 但是,实际上,这些切向成本在处理低于小阈值的尺寸时会产生影响。 也就是说,对于大小小于一定数量的结构,与地图查找相关的成本弥合了通过大小相等的数组或列表进行线性搜索之间的差距。 这并不是说映射会变慢,相反,它允许映射和线性实现是可比较的。 通过使用像数组映射这样的线性结构来保持插入顺序的优势有时可能会略微降低速度。 因此,有时在设计程序时考虑到有针对性的权衡的可能性很有用,例如使用列表或向量代替小于预定阈值的大小的映射。 您可以通过根本不假设映射结构与线性结构完全相反的方式来确保面对这些情况的鲁棒性,而应尽可能地反对序列抽象 。 我们以前在哪里听说过?

(defn raw [n] (map keyword (map str (map char (range 97 (+ 97 n))))))
(defn mk-lin [n] (interleave (raw n) (range n)))
(defn mk-map [n] (apply hash-map (mk-lin n)))

(defn lhas [k s] (some #{k} s))
(defn mhas [k s] (s k))

(defn churn [lookup maker n]
  (let [ks (raw n)
         elems (maker n)]
   (dotimes [i 100000]
     (doseq [k ks] (lookup k elems)))))


(time (churn lhas mk-lin 5))
; "Elapsed time: 998.997 msecs"


(time (churn mhas mk-map 5))
; "Elapsed time: 133.133 msecs"

正如我们在上面看到的,对于非常小的尺寸,线性版本比使用地图的版本慢大约7.5倍,这还不错。 线性映射实现不仅可以在小尺寸下提供可比的速度,而且还具有保持键插入顺序的优势。 而这确实是Clojure阵列图的最佳选择。 但是,当我们增加要处理的流失元素的数量时,画面突然变坏:

(time (churn lhas mk-lin 10))
; "Elapsed time: 3282.077 msecs"


(time (churn mhas mk-map 10))
; "Elapsed time: 172.579 msecs"

现在,将结构的大小加倍可以扩大线性和映射实现之间的执行时间间隔。 进一步增加尺寸最终将导致值得欣赏电影的停顿。 如果没有其他原因可以很好地尝试一下,除非它很好地表明了数据结构的大小将突出显示其数量级的差异。

在地图中思考-熟练的国际象棋棋子

众所周知,Java是一种非常冗长的编程语言。 尽管与Lisp语言家族相比这可能是正确的,但已经有相当多的人致力于设计减轻Java冗长程度的方法。 一种流行的技术被称为流畅的生成器 [16] 可以概括为Java方法的链式链接,以形成一种更具可读性和敏捷性的实例构造技术。 在本节中,我们将显示一个简单的示例,说明流畅的构建器支持下棋动作描述的构建。 然后,我们将说明Clojure如何不需要这种技术,而是提出一种更简单,简洁和可扩展的替代方法。 在最终解决方案中,我们将利用Clojure的映射,说明Java的基于类的范例与Clojure的基本原理背道而驰。 并经常对Java程序造成过多影响。

想象一下,我们希望代表Java中的国际象棋棋子。 通常,第一种方法是识别Move类的所有组成部分,包括:从和到正方形,一个标志,指示该移动是否为举动,如果可能的话,还可能包括所需的促销片。 当然,还有其他可能适用的要素,但是为了限制该问题,我们将“ 移动”的思想限制为上述那些要素。 当然,下一步将是创建一个具有其属性和一组构造函数的简单类,每个构造函数均采用预期属性的某种组合。 然后,我们将为属性生成一组访问器,但不会生成其相应的变体,因为移动实例最好是不可变的。 创建了这个简单的类并将其推广到我们的国际象棋移动API的客户后,我们开始注意到我们的用户正在将to字符串发送到构造函数中 from字符串之前是to字符串,有时放在促销字符串之后,依此类推。 但是,由于采用了一种称为“流畅构建器”的设计模式,因此我们可以使国际象棋移动API更易于阅读,明确且独立于操作顺序。 经过几个月的紧张设计以及数周的开发和测试,我们发布了以下被淘汰的象棋棋类:

public class FluentMove {
    String from, to, promotion = "";
    boolean castlep;

    public static MoveBuilder desc() { return new MoveBuilder(); }

    public String toString() {
        return "Move " + from +
           " to " + to +
           (castlep ? " castle" : "") +
           (promotion.length() != 0 ? " promote to " + promotion : "");
    }

    public static final class MoveBuilder {
        FluentMove move = new FluentMove();

        public MoveBuilder from(String from) {
            move.from = from; return this;
        }

        public MoveBuilder to(String to) {
            move.to = to; return this;
        }

        public MoveBuilder castle() {
            move.castlep = true; return this;
        }

        public MoveBuilder promoteTo(String promotion) {
            move.promotion = promotion; return this;
        }

        public FluentMove build() { return move; }
    }
}

显然,为了简洁起见,我们的代码有很多漏洞,例如缺少检查篱笆张贴错误的检查, null ,空字符串和不变量; 但是,通过以下主要方法,它确实可以说明该代码提供了一种流畅的构建器:

public static void main(String[] args) {
    FluentMove move = FluentMove.desc()
        .from("e2")
        .to("e4").build();

    System.out.println(move);

    move = FluentMove.desc()
        .from("a1")
        .to("c1")
        .castle().build();

    System.out.println(move);

    move = FluentMove.desc()
        .from("a7")
        .to("a8")
        .promoteTo("Q").build();

    System.out.println(move);
}

/* prints the following:
    Move e2 to e4
    Move a1 to c1 castle
    Move a7 to a8 promote to Q
*/

我们的构造函数模糊性消失了,唯一的折衷是实现的复杂性略有增加,并且打破了常见的Java getter / setter习惯用语-我们都想解决这两个问题。 但是,如果我们以Clojure项目的形式启动国际象棋移动API,那么对于最终用户而言,代码可能会是完全不同的体验。

Clojure国际象棋棋牌

为了开始为Clojure构建棋移动结构,我们可以做一个普遍的假设,即我们需要一个Move类来表示。 但是,正如我们在本章中一直在讨论的那样,Clojure提供了一组核心的复合数据类型,并且您可以从本节的标题中猜到,其映射类型是我们移动表示的理想选择。

{:from "e7", :to "e8", :castle? false, :promotion \Q}

简单不?

从面向对象方法论的背景出发,我们倾向于通过繁琐的类层次结构来查看问题。 但是,Clojure倾向于简化,它提供了一组复合类型,非常适合表示通常由类系统处理的大多数类别的问题。 如果您退后一步,考虑一下过去构建或处理的类层次结构,那么可以用一个简单的映射替换多少个组成类? 如果您过去的项目类似于我们的项目,那么这个数字可能很大。 在Java之类的语言中,通常将每个实体都表示为一个类,实际上,否则这样做要么得不到有效的支持,要么是非惯用的,要么是绝对的禁忌。 但是,要成为一名出色的Clojure公民,建议出于一个非常简单的原因尝试利用其复合类型:基于序列抽象的现有功能才可以正常工作

(defn build-move [& pieces]
  (apply hash-map pieces))

(build-move
  :from "e7"
  :to "e8"
  :promotion \Q)

;=>gt; {:from "e2", :to "e4", :promotion \Q}

在两行中,我们已经有效地用类似但更灵活的表示形式替换了上述Java实现。 术语“领域专用语言(DSL)”经常被用来描述诸如build-move之类的代码,但是对于Clojure(通常是Lisps)而言,DSL和API之间的界限变得模糊了。 上面的函数对所提供的移动部件的形式进行了很多假设,但是其含义不能否认。 在我们最初的FluentMove类中,我们需要大量的代码,以确保API与move元素的顺序无关。 使用地图,我们免费获得它。 此外, 虽然FluentMove相对简洁,但仍然受到基本Java句法和语义约束的约束。 另一方面,为了简洁起见,Clojure 构建移动功能被人为地限制为需要交替的键值对。 如果我们改为将build-move编写为宏,则实际上对我们的移动描述所采用的形式没有任何限制。 更倾向于DSL的经典概念。 最后,正如作家里奇·希基(Rich Hickey)所宣称的那样,任何新阶级一般都本身就是一个小岛。 任何地方,任何人编写的任何现有代码都无法使用。 所以我们的意思是-考虑将婴儿与洗澡水一起扔出去。

关注点分离

在本节结束之前,我们想解决两个实现问题,直到现在我们一直忽略:验证。 FluentMovebuild-move都对提供给它们的数据形式做出了巨大的假设,并且不对输入进行验证。 对于FluentMove面向对象的原则规定,对格式正确的动作(不是合法的动作思路)的验证应由类本身确定。 这种方法存在很多问题,最明显的是,为了让FluentMove确定其格式是否正确,它需要一些有关国际象棋规则的信息。 例如,一个举动不能既是城堡也不是促销品。 我们当然可以重写FluentMove引发异常,以防止这种情况的发生,但根本问题仍然存在-FluentMove实例太聪明了。 相反,您可以采取相反的极端做法,要求客户检查举动的有效性,但是如果他们首先可以做到这一点,那么他们可能根本不需要您的代码。 也许您不认为这是个问题,但是如果我们要扩展API以包括国际象棋游戏的其他方面,那么我们会发现,使用这种方法需要分散一点重叠的国际象棋规则在整个课程层次结构中。 确实,我们从一开始就希望移动表示是一个没有关联验证行为的值。

通过将移动结构视为一个简单的值图,我们的Clojure代码为实现整体解决方案提供了一定的自由度。 也就是说,我们将值及其验证行为解耦,从而允许后者由众多函数组成,每个函数都根据需要进行交换。

在本节中,我们已经介绍了Clojure贴图的基础知识,包括常用用法和构造技术。 Clojure映射减去几个实现细节,对于任何人都不应该感到惊讶。 习惯于处理不变地图将花费一些时间,但是随着时间的流逝,即使是这种细微差别也将成为第二天性。

面对不断增长的软件复杂性,Clojure倾向于简单。   如果我们的问题可以通过顺序抽象和集合抽象轻松解决,那么应该使用那些确切的抽象。   实际上,许多软件问题都可以以这种简单的抽象为模型,但是我们继续构建整体的类层次结构,试图欺骗自己,使自己相信我们正在镜像“真实世界”-无论意味着什么。   也许是时候意识到我们不再需要在已经本来就很复杂的软件解决方案之上增加自我施加的复杂性。   Clojure不仅提供了顺序,集合和映射类型,这些类型对于从低迷的软件复杂性中脱颖而出非常有用,而且还针对处理它们进行了优化。


[1]           有关更多详细信息,请参见Brian Goetz的“实践中的Java并发性”。

[2]           为了使本节重点关注,我们有意掩盖了支持可变性的Clojure功能,例如引用类型和瞬态。

[3]           雷金纳德·布雷思韦特(Reginald Braithwaite)在http://tinyurl.com/yyfpmm上出色的博客文章“为什么函数编程如此重要”中讨论了这种语言级别的关注点分离。

[4]           有关这些声明的更多信息,请参见Henry Baker的文章“功能对象的平等权利,或者,事物发生的变化越多,它们的相同性就越大”。

[5]           一个很好的例子是http://blog.higher-order.net/2009/02/01/understanding-clojures-persistentvector-implementation/

[6]           Clojure哈希映射,哈希集和向量每个节点最多具有32个分支。 这会导致树大大变浅,因此查找和更新速度更快。

[7]           Clojure的sorted-map和sorted-set确实在内部使用了二叉树,但是实现了红黑树以保持左右两侧的平衡。

[8]           向量的连接效率不能比O(n)更高,但是稍后我们将介绍可以的数据结构。 手指树(可能是Clojure的未来添加物)可以通过类似于矢量的数字索引来查找,但也可以在对数时间内级联。

[9]           在我们的书中,对Clojure持久数据结构的几种操作被描述为“基本上恒定的时间”。 在所有情况下,它们均为O(log32 n)。

[10]         嵌套向量远不是存储或处理向量的最有效方法,但是它们在Clojure中方便操作,因此在此处是一个很好的例子。 效率更高的选项包括单个向量,数组或用于矩阵处理的库,例如http://incanter.org上的Colt或Incanter。

[11]        conj函数还可以处理Clojure的所有其他持久性集合类型,即使它们没有实现clojure.lang.IPersistentStack

[12]         …最自然的尾递归算法

[13]         Clojure的1.0也有出于性能方面的一个LazilyPersistentVector,但是这不再是必要的,因为现在PersistentVector支持短暂的

[14]         尽管由于地图文字的普遍性,普遍性可能会落到数组地图上。

[15]         默认的排序图比较功能是Clojure的RT类的静态成员。 您可以像这样直接尝试:(. compare clojure.lang.RT / DEFAULT_COMPARATOR“ abc”“ bac”)显然,基于该调用的丑陋性,这不是您经常需要执行的操作。

[16]         基于Martin Fowler提出的原始概念“ Fluent Interface”。 http://martinfowler.com/bliki/FluentInterface.html

翻译自: https://www.infoq.com/articles/in-depth-look-clojure-collections/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1

clojure

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值