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的在线文档。
2.4.3 命名空间
根绑定存在于命名空间中。当你启动REPL,并创建了一个绑定时,就能证实这一点。
user=> (def foo 10)
-> #'user/foo
提示符user=>说明你当前正工作在user命名空间下。你可以将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的文档。