Clojure程序设计
上QQ阅读APP看书,第一时间看更新

3.2 使用序列库

Clojure序列库提供了一组丰富的函数,可用于任意序列。如果你的背景是来自被名词统治着的面向对象世界,那么序列库就真的可以被称作是“动词的复仇”Steve Yegge的“名词王国的死刑”(http://tinyurl.com/the-kingdom-of-nouns)认为,面向对象编程将名词推到了不切实际的主导位置,而这正在随着时间发生改变。了。这些函数提供了丰富的基础功能,任何遵循了基本first/rest/cons契约的数据结构都能从中获益。

接下来介绍的函数分为四大类。

● 创建序列的函数。

● 过滤序列的函数。

● 序列谓词。

● 序列转换函数。

这样来分类是有一些武断。由于序列是不可变的,所以大多数序列函数实际上都会创建新的序列。还有一些序列函数,同时兼具过滤和转换的功能。然而,这些分类毕竟提供了一份粗略的路线图,能在探索这个大型的函数库时提供帮助。

3.2.1 创建序列

除了序列字面量以外,Clojure还提供了若干函数用于创建序列。ranage会生成一个从start开始到end结束的序列,每次的增量为step。

        (range start? end step?)

范围包含了start,但并不包含end。如果你没有指定的话,start默认为0,step默认为1。试试看在REPL中创建几个“范围”。

        (range 10)
        -> (0 1 2 3 4 5 6 7 8 9)
        (range 10 20)
        -> (10 11 12 13 14 15 16 17 18 19)
        (range 1 25 2)
        -> (1 3 5 7 9 11 13 15 17 19 21 23)

repeat函数会重复n次元素x。

        (repeat n x)

用REPL中尝试一下repeat。

        (repeat 5 1)
        -> (1 1 1 1 1)
        (repeat 10 "x")
        -> ("x" "x" "x" "x" "x" "x" "x" "x" "x" "x")

range和repeat都表现出来了这样一种想法:它们可以无限延伸。你不妨把iterate就想成是range的无限扩展。

        (iterate f x)

iterate起始于值x,并持续地对每个值应用函数f,以计算下一个值,直至永远。

如果你是用inc来进行迭代,并且起始于1,那么你就能生成所有的正整数。

        (take 10 (iterate inc 1))
        -> (1 2 3 4 5 6 7 8 9 10)

由于这是个无限序列,你需要另一个新函数,来帮助你在REPL中查看这个序列。

        (take n sequence)

take会返回一个包含了容器中前n项元素的惰性序列,这就提供了一种在无限序列上创建有限视图的途径。

能包含全部整数的序列相当有用,让我们将其定义为函数,供将来使用。

        (defn whole-numbers [] (iterate inc 1))
        -> #'user/whole-numbers

当只传入一个参数时,repeat会返回一个惰性的无限序列。

        (repeat x)

在REPL中尝试一下这种形式的repeat吧,不要忘了用take把结果包起来哦。

        (take 20 (repeat 1))
        -> (1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1)

cycle函数接受一个容器,并无限的对其进行循环。

        (cycle coll)

在REPL中尝试对一些容器进行循环吧。

        (take 10 (cycle (range 3)))
        -> (0 1 2 0 1 2 0 1 2 0)

interleave函数接受多个容器作为参数,并产生一个新的容器,这个新容器会从每个参数容器中交错地提取元素,直至其中某个容器元素被耗尽。

        (interleave& colls)

当其中的某个容器被耗尽时,interleave就会终止。所以,你可以把有限容器与无限容器混合到一块儿。

        (interleave (whole-numbers) ["A" "B" "C" "D" "E"])
        -> (1 "A" 2 "B" 3 "C" 4 "D" 5 "E")

与interleave密切相关的interpose函数,把输入序列中的每个元素用分隔符隔开,并作为新的序列返回。

        (interpose separator coll)

你可以使用interpose来创建,分隔的字符串。

        (interpose "," ["apples" "bananas" "grapes"])
        -> ("apples" "," "bananas" "," "grapes")

interpose和(apply str ...)的结合,正好可以用来产生输出字符串。

        (apply str (interpose \, ["apples" "bananas" "grapes"]))
        -> "apples,bananas,grapes"

作为一种惯用法,(apply str ...)是如此的常用,以至于Clojure甚至专门把它封装为clojure.string/join。

        (join separator sequence)

下面借助clojure.string/join,用逗号来对一个单词列表进行分隔。

        (use '[clojure.string :only (join)])
        (join \, ["apples" "bananas" "grapes"])
        -> "apples,bananas,grapes"

对应每种Clojure中的容器类型,都有一个可以接受任意数量参数的函数,用来创建该类型的容器。

        (list_& elements)
        (vector_& elements)
        (hash-set_& elements)
        (hash-map key-1 val-1 ...)

hash-set的近亲set与其工作方式稍有不同:set函数期望其第一个参数是个容器。

        (set [1 2 3])
        -> #{1 2 3}

而hash-set则接受可变的参数列表。

        (hash-set 1 2 3)
        -> #{1 2 3}

vector也有一个近亲vec,vec接受容器作为参数,而非可变的参数列表。

        (vec (range 3))
        -> [0 1 2]

现在,你已经掌握了创建序列的基础知识。接下来,你可以使用一些其他的Clojure函数,来对序列进行过滤和转换。

3.2.2 过滤序列

Clojure提供了若干函数用来过滤序列,即由原序列得到一个子序列。这些函数中,最基本的是filter。

        (filter pred coll)

filter接受一个谓词和一个容器作为参数,并返回一个序列,这个序列的所有元素都经谓词判定为真。你可以对上一节的whole-numbers进行过滤,得到奇数或是偶数。

        (take 10 (filter even? (whole-numbers)))
        -> (2 4 6 8 10 12 14 16 18 20)
        (take 10 (filter odd? (whole-numbers)))
        -> (1 3 5 7 9 11 13 15 17 19)

你还可以使用take-while从序列中截取开头的一段,其每个元素都被谓词判定为真。

        (take-while pred coll)

例如,为了从字符串中逐个获取第一个元音字符之前的所有非元音字符,可以如下这么做。

        (take-while (complement #{\a\e\i\o\u}) "the-quick-brown-fox")
        -> (\t \h)

在这儿发生了两件有趣的事情。

● 集合同时也可作为函数。所以你可以把#{\a\e\i\o\u}读作“元音集”,或是“用于检测参数是否为元音的函数。”

● complement 会反转另一个函数的行为。前例的那个反转函数用于检测参数是不是一个元音。

与take-while相对的是drop-while函数。

        (drop-while pred coll)

drop-while 从序列的起始位置开始,逐个丢弃元素,直至谓词判定为真,然后返回序列剩余的部分。你可以使用drop-while来丢弃字符串中起头的那些非元音字符。

        (drop-while (complement #{\a\e\i\o\u}) "the-quick-brown-fox")
        -> (\e \- \q \u \i \c \k \- \b \r \o \w \n \- \f \o \x)

split-at和split-with能把一个容器一分为二。

        (split-at index coll)
        (split-with pred coll)

split-at接受一个索引作为参数,而split-with则接受一个谓词。

        (split-at 5 (range 10))
        ->[(0 1 2 3 4) (5 6 7 8 9)]
        (split-with #(<= % 10) (range 0 20 2))
        ->[(0 2 4 6 8 10) (12 14 16 18)]

当然,所有take-、split-和drop-打头的函数,返回的都是惰性序列。

3.2.3 序列谓词

过滤函数往往会接受谓词作为其参数,并返回新的序列。序列谓词与其密切相关。序列谓词会要求其他谓词应如何判定序列中的每一个元素。例如,every?要求其他谓词对序列中的每个元素都必须判定为真。

        (every? pred coll)
        (every? odd? [1 3 5])
        -> true
        (every? odd? [1 3 5 8])
        -> false

some设置的门槛相对较低。

        (some pred coll)

只要有一个元素被谓词判定为非假,some就会返回这个值,如果没有任何元素符合,则some返回nil。

        (some even? [1 2 3])
        -> true
        (some even? [1 3 5])
        -> nil

注意,some没有以问号结尾。尽管总被当作谓词使用,但some并非谓词。因为some 返回的是第一个符合项的值,而非 true。由于 even?本身就是一个谓词,所以当some与even?配对使用时,其间的差异并不明显。为了体验非true匹配,让我们试着用some和identity一起来找出序列中的第一个非nil值吧。

        (some identity [nil false 1 nil 2])
        -> 1

其他谓词从名称就能很明显的表现出其行为。

        (not-every? pred coll)
        (not-any? pred coll)

不是所有的正整数都是偶数。

        (not-every? even? (whole-numbers))
        -> true

但声称所有的正整数都是偶数则显然是一句谎言。

        (not-any? even? (whole-numbers))
        -> false

请注意,这里我们选择的问题都是事先就已经知道答案的。一般情况下,在对无限容器应用谓词时你要格外小心。它们可能会无止境的运行下去。

3.2.4 序列转换

转换函数用于对序列中的值进行转换。最简单的转换是映射函数map。

        (map f coll)

map接受一个源容器coll和一个函数f作为参数,并返回一个新的序列。该序列的所有元素,都是通过对coll中的每个元素调用f得到的。你可以使用map把一个容器的每个元素都用HTML标记给包起来。

        (map #(format "<p>%s</p>" %) ["the" "quick" "brown" "fox"])
        -> ("<p>the</p>" "<p>quick</p>" "<p>brown</p>" "<p>fox</p>")

还可以传入多个容器给map。在这种情况下,f必须是一个多参函数。map会从每个容器分别取出一个值,作为参数来调用f,直到数量最少的那个容器被耗尽为止。

        (map #(format "<%s>%s</%s>" %1 %2 %1)
        ["h1" "h2" "h3" "h1"] ["the" "quick" "brown" "fox"])
        -> ("<h1>the</h1>" "<h2>quick</h2>" "<h3>brown</h3>"
        "<h1>fox</h1>")

另一个常用的转换是归纳函数reduce。

        (reduce f coll)

其中f是一个接受两个参数的函数。reduce首先用coll的前两个元素作为参数来调用f,然后用得到的结果和第三个元素作为参数,继续调用f,以此类推。比如,你可以用reduce来让一些数字相加。

        (reduce + (range 1 11))
        -> 55

或者让它们相乘。

        (reduce * (range 1 11))
        -> 3628800

你可以使用sort或sort-by对容器进行排序。

        (sort comp? coll)
        (sort-by a-fn comp? coll)

sort 会依据元素的自然顺序对容器进行排序,sort-by 则会对每个元素调用 a-fn,再依据得到的结果序列来进行排序。

        (sort [42 1 7 11])
        -> (1 7 11 42)
        (sort-by #(.toString %) [42 1 7 11])
        -> (1 11 42 7)

如果不打算按照自然顺序排序,你可以为sort或sort-by指定一个可选的比较函数comp。

        (sort > [42 1 7 11])
        -> (42 11 7 1)
        (sort-by :grade > [{:grade 83} {:grade 90} {:grade 77}])
        -> ({:grade 90} {:grade 83} {:grade 77})

所有过滤和转换的祖先都是列表解析(list comprehension)。列表解析使用集合记号法(set notation),基于一个已存在的列表来创建新的列表。换句话说,解析式描述了结果列表必须满足的性质。一般情况下,一个列表解析会包含以下内容。

● 输入列表。

● 输入列表中元素所对应的占位变量这是数学意义上的“变量”,而不是命令式编程中的。你不可能改变它们。产生这种混淆,是我们自然语言的问题,对此,我诚挚的表达歉意。

● 作用于元素的谓词。

● 一个输出形式,它负责基于那些满足谓词要求的列表元素来产生输出。

当然,Clojure把列表解析的概念泛化为了序列解析(sequence comprehension)。在Clojure中,是使用for列表解析的for,与命令式编程中的那个for循环没有任何关系。宏来进行解析的。

        (for [binding-form coll-expr filter-expr? ...] expr)

for 接受一个向量作为参数,该参数是由一系列 binding-form/coll-expr 和可选的filter-expr组成的,然后是依据表达式expr来产生结果序列。

列表解析比诸如 map 和filter 这样的函数更加通用,而且,事实上它可以模拟之前的大多数过滤和转换函数。

你可以用列表解析来重写之前那个map的例子。

        (for [word ["the" "quick" "brown" "fox"]]
          (format "<p>%s</p>" word))
        -> ("<p>the</p>" "<p>quick</p>" "<p>brown</p>" "<p>fox</p>")

这样读起来几乎就像是英语一样:“For [each] word in [a sequence of words] format [according to format instructions]”。即按照格式要求,对一系列单词逐个进行格式化。

借助:when子句,解析也可以用来模拟filter函数。你可以把even?传给:when,用来过滤偶数。

        (take 10 (for [n (whole-numbers) :when (even? n)] n))
        -> (2 4 6 8 10 12 14 16 18 20)

只要:while字句的表达式保持为真,它就会继续进行求值。

        (for [n (whole-numbers) :while (even? n)] n)
        -> ()

当你有多个绑定表达式时,for才会真正地发挥其威力。例如,你可以通过绑定rank和file,用代数计数法来表示棋盘上的所有位置。

        (for [file "ABCDEFGH" rank (range 1 9)] (format "%c%d" file rank))
        -> ("A1" "A2" ...已省略... "H7 ""H8")

序列解析中,Clojure会从按照右到左的顺序,来遍历绑定表达式。由于在绑定形式中,rank列于file的右手边,所以rank会迭代的更快。如果你希望让file迭代的更快,可以反转绑定的顺序,把rank列在第一位。

        (for [rank (range 1 9) file "ABCDEFGH"] (format "%c%d" file rank))
        -> ("A1" "B1" ...已省略... "G8" "H8")

在很多语言中,转换、过滤和解析都是立即执行的。但千万不要认为Clojure也是如此。绝大多数的序列函数都不对元素进行遍历,除非你真的尝试要去使用它们。