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

2.7 我的for循环哪儿去了

Clojure没有for循环,也没有可以直接改变的变量Clojure提供了间接可变的引用,但这需要在你的代码中显式地指明才行。详情参阅第5章“状态”。。那么,应该如何编写你习惯于使用for来完成的那些循环呢?

这次我们不再凭空假想出一个示例了,我们决定在开源的Java源码中翻一翻,随便找出一个使用了for循环和变量的方法,然后将其移植到Clojure。我们打开了应用广泛的Apache Commons项目,并选择Commons Lang中的StringUtils类:这是一个只需要少量领域知识即可理解的类。随后我们开始寻找那种在多处用到 for 循环和局部变量的方法,最后找到了indexOfAny。

        data/snippets/StringUtils.java
        // 源自Apache的Commons Lang, 参见http://commons.apache.org/lang/
        public static int indexOfAny(String str, char[] searchChars) {
            if (isEmpty(str) || ArrayUtils.isEmpty(searchChars)) {
                return -1;
            }
            for (int i = 0; i < str.length(); i++) {
                char ch = str.charAt(i);
                for (int j = 0; j < searchChars.length; j++) {
                    if (searchChars[j] == ch) {
                        return i;
                    }
                }
            }
            return -1;
        }

indexOfAny对str中的字符进行遍历,并报告第一处能与searchChars中任意一个字符相匹配的字符位置,如果未能成功匹配则返回-1。

下面的示例结果来自于indexOfAny文档。

        StringUtils.indexOfAny(null, *)                 = -1
        StringUtils.indexOfAny("", *)                   = -1
        StringUtils.indexOfAny(*, null)                 = -1
        StringUtils.indexOfAny(*, [])                   = -1
        StringUtils.indexOfAny("zzabyycdxx",['z','a']) =  0
        StringUtils.indexOfAny("zzabyycdxx",['b','y']) =  3
        StringUtils.indexOfAny("aba", ['z'])            = -1

indexOfAny中有两处if、两处for、三处可能的返回点和三个可变的局部变量,同时方法的长度为14行(用David A. Wheeler的SLOCCount工具http://www.dwheeler.com/sloccount/。统计得出)。

现在让我们来一步步地创建Clojure版本的index-of-any。倘若只想要找出匹配,使用Clojure的filter即可做到。但我们还希望能够找出匹配位置的索引。所以,首先让我们创建一个indexed函数,它接受一个容器作为参数,对该容器建立索引并返回结果。

        src/examples/exploring.clj
        (defn indexed [coll] (map-indexed vector coll))

indexed 返回一个序列,该序列由成对的[idx elt]组成。下面试试看对一个字符串建立索引。

        (indexed "abcde")
        -> ([0 \a] [1 \b] [2 \c] [3 \d] [4 \e])

下一步,我们需要找出字符串中所有与搜索集相匹配的字符索引。

让我们创建一个与Clojure的filter相似的index-filter函数,只不过它返回的是索引,而非匹配项本身。

        src/examples/exploring.clj
        (defn index-filter [pred coll]
          (when pred
            (for [[idx elt] (indexed coll) :when (pred elt)] idx)))

Clojure的for用于序列解析(sequence comprehension,参见第3.2.4小节“序列转换”),而不是循环。仅当(pred elt)为真时,(indexed coll)中的索引/元素对才会与名称idx和elt绑定。最后,根据每一对匹配结果的idx值将出结果序列。

Clojure的集合(set)本身也可以用做函数,判定目标是否存在。所以,你可以向index-filter 传入一个字符集合和一个字符串,然后得到同属于它们两者的字符在字符串中的索引。快用几个字符串和字符集合尝试一下吧。

        (index-filter #{\a \b} "abcdbbb")
        -> (0 1 4 5 6)
        (index-filter #{\a \b} "xyz")
        -> ()

至此,我们甚至超出了既定目标。index-filter 返回了所有匹配位置的索引,然而我们仅需要第一处而已。因此,下面的 index-of-any 只是简单的摘取了 index-filter 结果中的第一项。

        src/examples/exploring.clj
        (defn index-of-any [pred coll]
          (first (index-filter pred coll)))

下面用了几个不同的输入来测试index-of-any是否正常。

        (index-of-any #{\z \a} "zzabyycdxx")
        -> 0
        (index-of-any #{\b \y} "zzabyycdxx")
        -> 3

相比Java那样的命令式版本,Clojure的这个版本无论从哪个方面来度量,都要简单得多(参见表2-2“命令式与函数式indexOfAny的复杂度对比”)。究竟是什么导致了这样的差异呢?

● 命令式的indexOfAny 必须处理一些特殊情况:字符串为 null,或者是个空字符串;搜索字符集为 null,或者是个空集合。为了处理这些特殊情况,流程分支和方法退出点不可避免的增多了。然而对于函数式而言,无须任何显式的编码,绝大多数此类特例都能得到正确的处理。

● 命令式的indexOfAny引入了局部变量用于遍历容器(字符串和字符集合)。通过使用map这样的高阶函数和for这样的序列解析式,函数式的index-of-any能够规避所有对变量的需求。

不必要的复杂性如同雪球一样越滚越大。例如,命令式的indexOfAny 的特殊情况分支中,使用了魔数(magic number)-1表示未匹配。那么,这个魔数是否应该定义为一个符号常量呢?无论你认为正确答案是什么,在函数式的版本中,这个问题本身就根本不存在。此外,除了更加短小并且更为简单,函数式的index-of-any也更加通用。

● indexOfAny只能检索字符串,然而index-of-any可以检索任意序列。

● indexOfAny只能依据一组字符进行匹配,而index-of-any可以依据任意谓词进行匹配。

● indexOfAny 只能返回第一处匹配,而 index-filter 能够返回所有的匹配,并且还能进一步与其他过滤器进行组合。

为了体验函数式的 index-of-any 能通用到什么程度,你可以参照我们刚刚编写的代码,从一系列抛硬币的结果中,找出第三次正面朝上的索引位置。

        (nth (index-filter #{:h} [:t :t :h :t :h :t :t :t :h :h])
        2)
        -> 8

表2-3表明了编写函数式风格的 index-of-any值得一提的是,你也可以使用原生的 Java 编写一个函数式风格的 indexOfAny,尽管这与惯例不符。等 Java语言提供了闭包功能之后,也许这么做会变得更顺畅一些。更多信息参见http://functionaljava.org/。,不需要循环或者变量。此外相比命令式的indexOfAny而言,函数式的index-of-any更简单、更不容易出错,也更加通用。在规模较大的代码中,这些优势会变得更有说服力。

表2-3 命令式与函数式indexOfAny的复杂度对比

2.8 元数据

维基百科是这么解释元数据的http://en.wikipedia.org/wiki/Metadata。:用来“描述数据的数据。”这样说没错,但还不够具体。在Clojure中,元数据是与一个对象逻辑上的值产生正交的那些数据。例如,一个person的名和姓是普通的旧式数据(plain old data)。但一个person对象可以被序列化为XML这件事情,其实与person对象本身毫不相干,这就是元数据。同样,person对象当前正处于脏状态,需要被刷新到数据库中,这也是元数据。

读取器元数据

Clojure语言自身在许多地方用到了元数据。例如,变量持有一个元数据映射表,包含了文档、类型信息和源码信息。此处显示了变量str的元数据。

        (meta #'str)
        -> {:ns #<Namespace clojure.core>,
            :name str,
            :file "core.clj",
            :line 313,
            :arglists ([] [x] [x & ys]),
            :tag java.lang.String,
            :doc "With no args, ... etc."}

表2-4展示了一些通用元数据的键以及它们的用途。

表2-4 通用元数据的键

一个变量的大部分元数据都是由Clojure编译器自动添加的。为了给一个变量添加你自己的元数据键/值对,可以使用元数据读取器宏。

        ^metadata form

例如,你可以创建一个简单的shout函数,用来把字符串转换为大写,并用:tag注明其参数和返回值都必须是字符串。

; 稍后会在后面看到它的元数据

        (defn ^{:tag String} shout [^{:tag String} s] (.toUpperCase s))
        -> #'user/shout

检查一下shout的元数据,看看Clojure是否添加了:tag。

        (meta #'shout)
        -> {:arglists ([s]),
            :ns #<Namespace user>,
            :name shout,
            :line 32,
            :file "NO_SOURCE_FILE",
            :tag java.lang.String}

你提供了:tag,Clojure则提供了其它的一些键。其中:file的值为NO_SOURCE_FILE,表示这些代码是在REPL中录入的。

因为元数据:tag实在是太常用了,你也可以使用其简化形式^Classname,它会被展开为^{:tag Classname}。使用这个简化形式,你可以这么来重写shout。

        (defn ^String shout [^String s] (.toUpperCase s))
        -> #'user/shout

在你阅读函数定义时,如果发觉元数据会造成视觉混乱,也可以把它们放到最后。这得用到 defn 的一个变体,先是一或多个带括号的函数主体,随后紧接一个元数据映射表。

        (defn shout
          ([s] (.toUpperCase s))
          {:tag String})

2.9 小结

这章可真够长的。不妨回顾一下你已经走过了多少地方:你学会了实例化基础字面类型;定义并调用函数;管理命名空间;读取和写入元数据。你还学会了编写纯粹的函数式代码,也能在需要时,轻松地引入副作用。此外,你已经遇见了包括读取器宏、特殊形式和解构在内的一些Lisp概念。

对于大多数语言来说,往往需要好几百页才能覆盖上述内容,而我们只用了一章。真是因为Clojure的方式要简单得多吗?某种程度上,是的。本章能如此高效,有一半的荣誉应归于Clojure。Clojure优雅的设计和抽象决策,使得这门语言相较大多数其它语言,学起来要更加轻松。

乍一看,学习这门语言似乎并不十分轻松。那是得益于Clojure强大的力量,我们的前进速度大大超越了其他大多数的编程语言书籍罢了。

因此,本章的另外一半荣誉应属于你,亲爱的读者。Clojure会对你的付出给予多得多的回报。要想充分领会本章的那些示例,并对得心应手地使用 REPL,你可能还需要一些时间。没关系,本书的剩余部分有足够多的机会能让你做到这一点。