2.4 变量、绑定和命名空间

当你使用def或defn定义了一个对象时,这个对象会被存储在一个Clojure变量(var)中。例如,下面的def创建了一个名为user/foo的变量。

        (def foo 10)
        -> #'user/foo

符号user/foo指向一个变量,该变量绑定了10这个值。如果你要求Clojure对符号foo进行求值,那么,它会返回与其关联的那个变量所绑定的值。

        foo
        -> 10

一个变量的初始值被称为它的根绑定(root binding)。有时候为一个变量提供线程内绑定(thread-local bindings)是非常有用的,该话题参见第5.5节“用变量管理线程内状态”。

你可以直接引用一个变量。特殊形式(special form)var能返回变量自身,而不是变量的值。

        (var a-symbol)

你可以使用var来获取绑定到user/foo上的那个变量。

        (var foo)
        -> #'user/foo

在Clojure代码中,你几乎找不到直接用var的地方。相反,你会见到与其等价的读取器宏#',它同样会返回与符号绑定的那个变量。

        #'foo
        -> #'user/foo

那什么时候你会想要直接去引用一个变量呢?大多数时候这是不需要的,你总是可以简单的忽略符号与变量之间的差异。

但请务必留意,除了用来保存值以外,变量还有许多其他能力。

● 同一个变量,可以在多个命名空间中具有别名(参见第2.4.3小节“命名空间”)。这样你就可以使用便利的短名称了。

● 变量可以有元数据(第2.8节“元数据”)。元数据包括文档(第1.3.2小节“查找文档”)、用于优化的类型提示,还有单元测试。

● 变量可基于每个线程进行动态重绑定(第5.5节“用变量管理线程内状态”)。

2.4.1 绑定

除了变量与名称之间的绑定之外,也有针对其他类型的绑定。例如,在函数调用中,参数值与参数名称之间的绑定。看下面这次调用,在triple函数内部,10和名称number绑定了。

        (defn triple [number] (* 3 number))
        -> #'user/triple
        (triple 10)
        -> 30

函数的参数绑定具有词法范围:它们仅在函数主体代码的内部可见。函数并不是创建词法绑定的唯一方式。作为另外一个特殊形式,let 的作用就是来建立一组词法绑定。

        (let [bindings*] exprs*)

这里的bindings会在随后的exprs中生效,此外,exprs中最后一个表达式的值,就会成为let的返回值。

试想你要根据给定的bottom、left和size,为一个正方形的四个角建立坐标。你可以基于这些给出的值,使用let来绑定top和right坐标。

        src/examples/exploring.clj
        (defn square-corners [bottom left size]
          (let [top (+ bottom size)
                right (+ left size)]
            [[bottom left] [top left] [top right] [bottom right]]))

let对top和right进行了绑定。这省却了你要来来回回计算top和right的麻烦。两者都需要计算两次。然后 let 返回了其最后一个形式,在本例中这也成为了square-corners函数的返回值。

2.4.2 解构

在许多编程语言中,即使你需要访问的只是某个容器中的一部分元素,你也不得不把整个容器都绑定到一个变量上。

假想你正在使用一个保存了图书作者的数据库。你同时保存了姓和名字,但有的函数只需要名字就足够了。

        src/examples/exploring.clj
        (defn greet-author-1 [author]
          (println "Hello," (:first-name author)))

greet-author-1函数一切正常。

        (greet-author-1 {:last-name "Vinge" :first-name "Vernor"})
        | Hello, Vernor

可是你不得不绑定整个 author。这一点实在无法令人满意。事实上你并不需要整个author,你需要的只是first-name罢了。Clojure中通过解构来解决这一问题。在任意一个需要绑定名称的位置,你都可以在绑定式中嵌入一个向量或是映射表,藉此深入容器内部,绑定你真正需要的那个部分。下面是一个greet-author的变形,它仅绑定了名字。

        src/examples/exploring.clj
        (defn greet-author-2 [{fname :first-name}]
          (println "Hello," fname))

{fname :first-name}告诉clojure,应该把参数fname绑定至:first-name。greet-author-2具有和greet-author-1相同的行为。

        (greet-author-2 {:last-name "Vinge" :first-name "Vernor"})
        | Hello, Vernor

正如使用映射表可以解构任何关联性容器,你也能用向量来解构任何顺序性容器。例如,你可以仅绑定三维坐标空间中的前两个坐标。

        (let [[x y] [1 2 3]]
          [x y])
        -> [1 2]

表达式[x y]对向量[1 2 3]进行解构,将x和y绑定到了1和2上。由于最后一个元素3没有符号与之排列对应,所以它也就不会与任何东西绑定。

有时候你会想要跳过容器的几个起始元素。此处展示了你要怎样做,才能只绑定z坐标。

        (let [[_ _ z] [1 2 3]]
          z)
        -> 3

下划线(_)是一个合法的符号,同时作为惯用法,它还用来表示:“我对这个绑定毫不关心”。由于绑定是从左向右进行的,所以“_”实际上被绑定了两次。

        ; 不符合惯例!
        (let [[_ _ z] [1 2 3]]
          _)
        -> 2

另外它也可以同时绑定整个容器与容器内的元素。在解构表达式内部,:as字句允许你绑定整个闭合结构。例如,你可以单独绑定 x 和 y 坐标,并把整个容器绑定至coords,以报告维度的总数。

        (let [[x y :as coords] [1 2 3 4 5 6]]
          (str "x: " x ", y: " y ", total dimensions " (count coords)))
        -> "x: 1, y: 2, total dimensions 6"

下面尝试使用解构来创建一个ellipsize函数。ellipsize接受一个字符串,并返回该字符串的前三个单词,并在末尾加上省略号。

        src/examples/exploring.clj
        (require '[clojure.string :as str])
        (defn ellipsize [words]
          (let [[w1 w2 w3] (str/split words #"\s+")]
            (str/join " " [w1 w2 w3 "..."])))
        (ellipsize "The quick brown fox jumps over the lazy dog.")
        -> "The quick brown ..."

split基于空格来对字符串进行切分,然后使用解构形式来[w1 w2 w3]捕获其前三个单词。正如我们所期望的,解构忽略了其他内容。最后,通过join将这三个单词重组,并在末尾追加省略号。

解构本身就是一门小型的语言,还有其他几个特性未能在此处展示。第 5.6 节“Clojure 贪吃蛇”中的贪吃蛇游戏,大量的使用了解构。完整的解构选项列表,请参见let的在线文档http://clojure.org/special_forms。

2.4.3 命名空间

根绑定存在于命名空间中。当你启动REPL,并创建了一个绑定时,就能证实这一点。

        user=> (def foo 10)
        -> #'user/foo

提示符user=>说明你当前正工作在user命名空间下。为保持简洁,本书中列出的大多数的REPL会话都省却了REPL提示符。但在本节中,如果当前命名空间显得非常重要,那么就会包含REPL提示符。你可以将user视为一个用于探索性开发的临时命名空间。

当Clojure解析名称foo时,它会用当前命名空间user对foo进行命名空间限定(namespace-qualifies)。你可以通过调用resolve来加以验证。

        (resolve sym)

resolve会返回在当前命名空间中,解析符号得到的变量或是类。下面使用resolve对符号foo进行显式解析。

        (resolve 'foo)
        -> #'user/foo

你可以使用in-ns来切换命名空间,必要时Clojure还会新建一个新的。

        (in-ns name)

试试看创建一个myapp命名空间。

        user=> (in-ns 'myapp)
        -> #<Namespace myapp>
        myapp=>

你现在已经位于myapp命名空间中了,这时候你def或defn的任何东西都将属于myapp。

当你使用in-ns新建了一个命名空间时,Clojure会自行导入java.lang包。

        myapp=> String
        -> java.lang.String

在学习Clojure期间,每当你转移到一个新的命名空间时,你都应该立即使用use来导入clojure.core命名空间,这样Clojure的核心函数才能在这个新的命名空间中使用。

        myapp=> (clojure.core/use 'clojure.core)
        -> nil

默认情况下,java.lang以外的其他类都必须使用全限定名。例如,你不能只写File。

        myapp=> File/separator
        -> java.lang.Exception: No such namespace: File

相反,你必须指定全限定的 java.io.File。请注意,你的文件分割符可能会与此处显示的不同。

        myapp=> java.io.File/separator
        -> "/"

倘若不想使用全限定类名,你可以使用import把一个或者多个类名从Java包映射到当前命名空间中。

        (import '(package Class+))

一旦导入了一个类,你就可以使用其短名称了。

        (import '(java.io InputStream File))
        -> java.io.File
        (.exists (File. "/tmp"))
        -> true

import仅用于Java类。你如果想使用另一个命名空间中的Clojure变量,同样也需要采用其全限定名,或者将其名称映射到当前空间中。例如,位于 clojure.string 的Clojure函数split。

        (require 'clojure.string)
        (clojure.string/split "Something,separated,by,commas" #",")
        -> ["Something" "separated" "by" "commas"]
        (split "Something,separated,by,commas" #",")
        -> Unable to resolve symbol: split in this context

为了在当前命名空间中引入split别名,可以包含对split的命名空间clojure.string调用require,并用str用作其别名。

        (require '[clojure.string :as str])
        (str/split "Something,separated,by,commas" #",")
        -> ["Something" "separated" "by" "commas"]

就像早些时候展示的那样,这种简单形式的require会把clojure.string中所有的公共变量引入到当前命名空间内,并且还可以通过别名str来访问它们。不过,这可能会令人感到有些困惑,因为引入了哪些名称其实并不明确。

作为惯例,在一个Clojure源文件的顶部,我们会使用ns宏来import Java类和require命名空间。

        (ns name& references)

ns宏将当前命名空间(可通过*ns*获取)设置为name,必要时还会创建这个命名空间。references部分则可以包含:import、:require和:use。它们的工作方式与各自对应的同名函数类似。这样仅需要一个形式,就可以完成命名空间映射相关的所有设置。例如,在本章实例代码的顶部,就调用了ns。

        src/examples/exploring.clj
        (ns examples.exploring
          (:require [clojure.string :as str])
          (:import (java.io File)))

Clojure命名空间函数的功能,要比我在此处展示的多得多。

你可以遍历命名空间,并随时添加或者删除映射。欲了解更多信息,可在 REPL中输入以下命令。由于我们刚才在REPL中执行过一些操作,所以还需要确保我们位于user命名空间中,这样REPL工具才能有效地为我们工作。

        (in-ns 'user)
        (find-doc "ns-")

或是浏览位于http://clojure.org/namespaces的文档。