解构(let,第2部分)

转载自:http://book.2cto.com/201304/20377.html

使用Clojure编程很多时候需要跟各种类型的数据结构实现打交道,而顺序性数据结构和map是其中最关键、最常用的两种。很多Clojure函数都接受这两种类型作为参数或者返回它们作为返回值,而且接受或者返回的是抽象类型,而不返回这些类型的某个具体的实现——绝大多数Clojure应用都是依赖这些抽象类型而不是具体的数据结构实现、Java实现等。这使得我们的函数在调用Clojure类库的时候不需要额外的代码去对接具体的数据结构实现,也就不需要一些黏胶代码(gluecode)来做类型转换之类的事情,可保持代码简单。

使用抽象集合的一个挑战是如何简洁地去访问一个集合中的多个数值,比如下面是一个Clojure的vector:
(defv[42"foo"99.2[512]])
;=#'user/v

下面是访问这个vector中元素的几种方法:
(firstv)
;=42
(secondv)
;="foo"
(lastv)
;=[512]
(nthv2)
;=99.2
(v2)
;=99.2
(.getv2)
;=99.2
Clojure提供了方便的函数来访问顺序集合的第一个、第二个以及最后一个值。

同时还提供了一个nth函数来返回顺序集合的指定下标的元素。

Clojure里面的vector本身也是函数,它接受数组下标作为参数,返回该下标中保存的元素。

所有的Clojure的顺序集合都实现了java.util.List这个接口,所以你还可以使用List接口的.get方法来返回顺序集合中的元素。

所有的这些函数使得对于一维集合里面元素的访问都很方便,那如果集合里面的元素本身是一个集合,要访问这个内嵌集合里面的元素,这时候就不是很方便了:
(+(firstv)(v2))
;=141.2
(+(firstv)(first(lastv)))
;=47

Clojure的解构特性提供了一种简洁的语法来声明式地从一个集合里面选取某些元素,并且把这些元素绑定到一个本地let绑定上去。并且因为解构这个特性是由let提供的, 它可以在任何间接使用了let的地方使用,比如fn、defn、loop。

let支持两种类型的解构:对于顺序集合的解构以及对于map的解构。

顺序解构

顺序解构可以对任何顺序集合进行解构,包括:

Clojure原生的list、vector以及seq。

任何实现了yjava.util.List接口的集合(比如ArrayList和LinkedList)。

Java数组。

字符串,对它解构的结果是一个个字符。

下面是一个最基本的例子,解构的是我们上面讨论的那个v:

示例1-3:最基本的顺序解构
(defv[42"foo"99.2[512]])
;=#'user/v
(let[[xyz]v]
(+xz))
;=141.2

在let最简单的用法中,let的绑定数组中包含的是一个个键值对,但是这里我们指定了一组名字:[xyz]——而不是一个名字。这么写的意思是让它对这个顺序集合v进行解构,而第一个元素绑定到x这个名字,第二个元素绑定到y,第三个元素绑定到z。然后我们就可以像使用其他本地绑定一样使用它们。上面的代码跟下面这些是等价的:
(let[x(nthv0)
y(nthv1)
z(nthv2)]
(+xz))
;=141.2

Python中有和Clojure的顺序解构类似的unpacking机制。比如上面的例子用Python写是这样的:
>>>v=[42,"foo",99.2,[5,12]]
>>>x,y,z,a=v
>>>x+z
141.19999999999999
Ruby中也差不多:
>>x,y,z,a=[42,"foo",99.2,[5,12]]
[42,"foo",99.2,[5,12]]
>>x+z
141.2

Clojure、Python和Ruby的解构从表面上看起来是差不多的,但是深入看一下的话就会发现,Ruby和Python所提供的解构跟Clojure的解构完全不在一个层次上面。Clojure中的解构被设计成能够清晰反映被解构的集合的结构。所以如果把我们的解构形式和被解构的集合排在一起的话,你可以很清楚地看出来每个要解构的符号所对应的值:
注26
[xyz]
[42"foo"99.2[512]]

解构的形式还支持嵌套解构形式,所以我们可以很轻松地解构嵌套集合:
注27
(let[[x__[yz]]v]
(+xyz))
;=59

如果再把我们的解构形式和要被解构的集合摆在一起,你会看到,各个符号的值依然很简单就能看出来:
[x__[yz]]
[42"foo"99.2[512]]

如果内嵌的vector中还有一个内嵌的vector,仍然可以用同样的方式继续内嵌解构形式。解构机制对于内嵌的层数是没有限制的,但是为了保持代码的可读性,最好不要内嵌太多层次。如果内嵌4层以上,那么别人来看你的代码可能根本看不懂,你写过之后可能自己都看不懂。

顺序解构还有另外两个特性,大家应该有所了解:

保持“剩下的”元素

可以使用&符号来保持解构剩下的那些元素,这个跟Java里面不限制参数个数的方法的机制很类似,同时也是Clojure函数里面的剩余参数的基础:
(let[[x&rest]v]
rest)
;=("foo"99.2[512])

这对于操作一个序列来说太方便了,不管你是做递归调用,或者是用loop形式来做循环。注意,这里的rest是一个序列,而不是一个vector,虽然被解构的参数v是一个vector。

保持被解构的值

你可以在解构形式中指定:as选项来把被解构的原始集合绑定到一个本地绑定:
(let[[x_z:asoriginal-vector]v]
(conjoriginal-vector(+xz)))
;=[42"foo"99.2[512]141.2]

这里,original-vector被绑定到未做修改的集合v。如果要解构的集合是一个函数调用的返回值,你想对这个返回值集合进行解构,同时又想保持对于这个集合的绑定,那么这个特性就很方便了。如果没有这个特性的话,你可能就要写类似下面的代码:
(let[some-collection(some-function...)
[xyz[ab]]some-collection]
...dosomethingwithsome-collectionanditsvalues...)

map解构

map解构跟顺序解构在概念上是一样的:通过解构形式从map中抽出一些元素。map解构对于下面几种数据结构有效。

Clojure原生的yhash-map、array-map,以及记录类型。任何实现了yjava.util.Map的对象gety方法所支持的任何对象。比如:

Clojure原生vector

字符串

数组

先来看一个最简单的map解构:
(defm{:a5:b6
:c[789]
:d{:e10:f11}
"foo"88
42false})
;=#'user/m
(let[{a:ab:b}m]
(+ab))
;=11

这里把mapm里面的:a所对应的值绑定到了a,:b所对应的值绑定到了b。跟前面在介绍顺序解构所做的一样,同样把解构形式和map摆在一起,我们可以很清楚地看出解构形式和要解构的集合之间的对应关系:
{a:ab:b}
{:a5:b6}

要注意的是,可以在map解构中用做key的不止是关键字,可以是任何类型的值,比如字符串:
(let[{f"foo"}m]
(+f12))
;=100
(let[{v42}m]
(ifv10))
;=0

而如果要进行map解构的是vector、字符串或者数组的话,那么解构key则是数字类型的数组下标。这个在你操作一个用vector表示的矩阵时会很方便(因为你可能只需要取矩阵中间的某个值,这时候如果用顺序解构会很麻烦):
(let[{x3y8}[1200-18446001]]
(+xy))
;=-17

跟顺序解构一样,map解构也可以处理内嵌map:
(let[{{e:e}:d}m]
(*2e))
;=20

这个外部map解构——{{e:e}:d}——直接作用在要解构的集合m的顶级元素上,获取:d所对应的元素:{:e10:f11}。而内部解构——{e:e},则是作用在{:e10:f11}上的,所以我们得到的值是10。还可以把顺序解构和map解构结合起来:
(let[{[x_y]:c}m]
(+xy))
;=16
(defmap-in-vector["James"{:birthday(java.util.Date.7316)}])
;=#'user/map-in-vector
(let[[name{bd:birthday}]map-in-vector]
(strname"wasbornon"bd))
;="JameswasbornonThuFeb0600:00:00EST1973"

map解构也有一些额外的特性。

保持被解构的集合。跟顺序解构一样,可以在解构形式中使用:as来获得对于被解构的集合的引用然后你可以像使用任何其他let绑定一样使用这个被解构的集合:
(let[{r1:xr2:y:asrandoms}
(zipmap[:x:y:z](repeatedly(partialrand-int10)))]
(assocrandoms:sum(+r1r2)))
;={:sum17,:z3,:y8,:x9}

默认值。你可以使用:or来提供一个默认的map,如果要解构的key在集合中没有的话,那么默认map中的值会作为默认值绑定到我们的解构符号上去:
(let[{k:unknownx:a
:or{k50}}m]
(+kx))
;=55

这使得我们不必在进行map解构之前把源map和默认map合并,或者手动给没有解构到的符号设置默认值,这对于追求简洁的Clojure程序员来说是不能容忍的:
(let[{k:unknownx:a}m
k(ork50)]
(+kx))
;=55

更进一步说,:or能区分到底是没有赋值,还是赋给的值就是逻辑false(nil或者false),看下面的例子:
(let[{opt1:option}{:optionfalse}
opt1(oropt1true)
{opt2:option:or{opt2true}}{:optionfalse}]
{:opt1opt1:opt2opt2})
;={:opt1true,:opt2false}

绑定符号到map中同名关键字所对应的元素。通常,对于一个特定意义的值会给它一个固定的名字,而通常在let中绑定的时候也会倾向于用map中与key的名字一样的符号来绑定key所对应的值,但是这样写的话,代码会变得很冗长:
(defchas{:name"Chas":age31:location"Massachusetts"})
;=#'user/chas
(let[{name:nameage:agelocation:location}chas]
(format"%sis%syearsoldandlivesin%s."nameagelocation))
;="Chasis31yearsoldandlivesinMassachusetts."

要把每个key的名字写两遍也跟我们追求简洁的精神相悖。在这种情况下,Clojure提供了:keys、:strs和:syms来指定map中key的类型::keys表示key的类型是关键字;:strs表示key的类型是字符串;:syms表示key的类型是符号。它们的用法如下:
(let[{:keys[nameagelocation]}chas]
(format"%sis%syearsoldandlivesin%s."nameagelocation))
;="Chasis31yearsoldandlivesinMassachusetts."

这样对于key的名字就只需要写一遍了,再看一个:strs和:syms的例子:
(defbrian{"name""Brian""age"31"location""BritishColumbia"})
;=#'user/brian
(let[{:strs[nameagelocation]}brian]
(format"%sis%syearsoldandlivesin%s."nameagelocation))
;="Brianis31yearsoldandlivesinBritishColumbia."
(defchristophe{"name"Christophe"'age33'location
"Rhône-Alpes"})
;=#'user/christophe
(let[{:syms[nameagelocation]}christophe]
(format"%sis%syearsoldandlivesin%s."nameagelocation))
;="Christopheis31yearsoldandlivesinRhône-Alpes."

将来你会发现你使用:keys会远远多于:strs和:syms,因为在Clojure中我们习惯使用关键字来作为map中的key。

对顺序集合的“剩余”部分使用map解构。前面介绍过可以用&来把顺序集合的剩余部分收集进一个集合,并且组合使用顺序解构和map解构来深入解构任何数据结构。下面例子里面的集合,前面几个是普通元素,后面则是关键字/值对:
(defuser-info["robert8990"2011:name"Bob":city"Boston"])
;=#'user/user-info

这种数据其实是很常见的,但是处理这种数据的办法却从来都不优雅。在Clojure中,如果我们“手动”解构这个数据,大概会是这样的:
(let[[usernameaccount-year&extra-info]user-info
{:keys[namecity]}(applyhash-mapextra-info)]
(format"%sisin%s"namecity))
;="BobisinBoston"

我们会先用顺序解构解构前面几个普通数据,并且把数组的剩余部分收集起来。

然后把剩下的部分生成一个map,最后用map解构来解构这个map。

但是,我们Clojure程序员追求简洁、优雅的精神让我们还是不能容忍这样的代码。

Clojure提供的更好的办法是直接用map解构来解构集合的剩余部分——如果剩余部分的元素个数是偶数的话,顺序解构会把剩余部分当做一个map来处理,神奇吧?
(let[[usernameaccount-year&{:keys[namecity]}]user-info]
(format"%sisin%s"namecity))
;="BobisinBoston"

这跟前面手动方式的结果是一样的,但是简洁多了,这个也是后面第39页要介绍的Clojure的“关键字参数”的基础。

定义函数:fn

函数在Clojure中是“头等公民”。我们用特殊形式——fn来创建函数,fn本身也有与let和do类似的语义。

下面是一个把传入参数加10再返回的一个简单函数:
(fn[x]
(+10x))

fn接受let样式的那种绑定数组,绑定数组定义函数的参数的名字以及参数的个数;我们在第28页的“解构(let,第2部分)”讨论的解构在这里也同样可以适用。

绑定数组后面的所有的形式组成了函数体。整个函数被隐式地包括在一个do形式中,所以函数体中可以包含任意多个形式;同样因为是隐式地使用了do,所以函数体中的最后一个形式的值就是整个函数的返回值。

函数定义时的参数与调用函数时实际传递的参数之间的对应是通过参数位置来完成的:
((fn[x](+10x))8)
;=18

8是这个函数的唯一参数,并且绑定到函数参数x,所以这个函数调用跟下面的let形式的效果是一样的:
(+10x))

你也可以定义接受多个参数的函数:
((fn[xyz](+xyz))
3412)
;=19

在这种情况下,函数调用跟下面的let形式是等价的:
(let[x3
y4
z12]
(+xyz))

同时函数还可以有多个参数列表。这里我们用一个var来保持函数的引用,使得我们可以通过var来多次调用这个函数:
(defstrange-adder(fnadder-self-reference
([x](adder-self-referencex1))
([xy](+xy))))
;=#'user/strange-adder
(strange-adder10)
;=11
(strange-adder1050)
;=60

在定义多参数列表的函数时,每套参数-函数体都要放在一个单独的括号内。函数调用在决定到底执行哪个函数体的时候是根据传入的参数个数来决定的。

在上面的例子中,你可能已经注意到了,代码体的第一个参数是一个可选的函数名字adder-self-reference。这个可选的函数名字使我们可以在函数体中引用函数自己,这样一个参数的实现就可以调用两个参数的实现来实现自己的逻辑。

用letfn来解决函数定义互相引用的问题

具名函数(比如上面的adder-self-reference)使我们可以很简单地创建自递归的函数。一个更变态的情况是定义两个相互引用的函数。

对于这种比较极端的例子,Clojure提供了特殊形式:letfn来解决,它允许同时定义多个有具函数,而且这些函数可以互相引用。看看下面重新实现的odd?和even?。
(letfn[(odd?[n]
(even?(decn)))
(even?[n]
(or(zero?n)
(odd?(decn))))]
(odd?11))
;=true

可以看出这个vector由多个普通的fn函数体组成,只是fn本身被省略了。

defn是基于fn的。前面已经见过使用defn的例子了,而且刚才的那个例子大家看着应该很眼熟吧?defn是一个封装了def和fn功能的宏,它使我们可以很简洁地定义一个具名函数,并且把这个函数注册到当前的命名空间里去,比如下面的两个定义是等价的:
(defstrange-adder(fnstrange-adder
([x](strange-adderx1))
([xy](+xy))))
(defnstrange-adder
([x](strange-adderx1))
([xy](+xy))))

也可以定义只有一个参数列表的函数,因为只有一个参数列表,那么多余的括号就可以省掉了。下面两个定义是等价的:
(defredundant-adder(fnredundant-adder
[xyz]
(+xyz)))
(defnredundant-adder
[xyz]
(+xyz))

我们在后面会大量使用defn而不是fn来演示函数的功能,因为用defn来定义具名函数比fn的可读性更好一些。

解构函数参数

得益于defn使用let来做函数参数的绑定,对于函数参数,Clojure也支持解构,你可以再温习一下前面讨论的解构的所有特性,下面我们会讨论一些在解构函数参数时非常常用的用法。

可变参函数。函数可以把调用它传入的多余的参数收集到一个链表里去,这里使用的机制跟顺序解构使用的机制是一样的。这样的函数叫做可变参函数,被收集起来的参数通常称为剩余参数或者不定参数。下面的这个例子中,函数接受一个固定位置参数,剩下的所有参数都作为“剩余参数”:
(defnconcat-rest
[x&rest]
(applystr(butlastrest)))
;=#'user/concat-rest
(concat-rest01234)
;="123"

而“剩余参数”链表可以像其他序列一样进行解构,在下面的例子中,我们对函数的参数进行解构,使得它使用起来像是一个没有定义参数的函数。
(defnmake-user
[&[user-id]]
{:user-id(oruser-id
(str(java.util.UUID/randomUUID)))})
;=#'user/make-user
(make-user)
;={:user-id"ef165515-6d6f-49d6-bd32-25eeb024d0b4"}
(make-user"Bobby")
;={:user-id"Bobby"}

关键字参数。定义一个接受很多参数的函数时通常有一些参数不是必选的,有一些参数可能有默认值;而且有时候我们希望函数的使用者不必按照某个特定的顺序来传参。fn(以及defn)对于这种情况通过关键字参数来支持,这个是构建在let对于剩余参数的map解构的基础上的。关键字参数是跟在固定位置参数后面的一个个参数名和参数默认值的对子。这些关键字参数会被收集到一个map中,然后函数通过map解构来获取具体某个参数的值:
(defnmake-user
[username&{:keys[emailjoin-date]
:or{join-date(java.util.Date.)}}]
{:usernameusername
:join-datejoin-date
:emailemail
;;2.592e9->onemonthinms
:exp-date(java.util.Date.(long(+2.592e9(.getTimejoin-date))))})
;=#'user/make-user
(make-user"Bobby")
;={:username"Bobby",:join-date#<DateMonJan0916:56:16EST2012>,
;=:emailnil,:exp-date#<DateWedFeb0816:56:16EST2012>}
(make-user"Bobby"
:join-date(java.util.Date.11101)
:email"[email protected]")
;={:username"Bobby",:join-date#<DateSunJan0100:00:00EST2011>,
;=:email"[email protected]",:exp-date#<DateTueJan3100:00:00EST2011>}


上面定义的make-user函数接受一个限定位置的参数username,而剩下的其他参数则被当成一个个参数名、参数值的对子被收集到一个map中,然后在&符号后面对这个map进行解构。

在这个map解构中,我们给join-date定义了一个默认值——当前时间。

当我们传一个参数去调用make-user的时候,返回的是一个usermap,usermap中的内容是用户名,默认的是join-date、expiration-date,以及值为nil的email——因为我们没有提供email。

给make-user提供的多余的参数会被当做map来解构,而且不考虑这些参数的顺序。

因为关键字参数是利用let的map解构的特性来实现的,所以关键字的参数名字理论上可以用任何类型的值(比如字符串、数字甚至集合),比如:
(defnfoo
[&{k["m"9]}]
(inck))
;=#'user/foo
(foo["m"9]19)
;=20

["m"9]在这里被当做一个关键字参数的名字。

虽然说是这么说,但是从没有人这么干过。在Clojure中,大家都习惯用关键字来作为map的key的名字,参数的名字,这也是我们把这种参数称为关键字参数的原因。

前置条件和后置条件。fn提供对函数参数和函数返回值进行检查的前置和后置条件。这个特性在单元测试以及确保参数正确性方面非常有用,我们会在487页的“前条件和后条件”一节详细介绍。

函数字面量

我们在20页“reader的一些其他语法糖”一节简要介绍过函数字面量。函数字面量类似Ruby中的blocks以及Python中的lambda表达式:当你要定义一个匿名函数,特别是非常简单的函数的时候,函数字面量提供了非常简洁的语法来做这个事情。

比如,下面这些匿名函数表达式是一样的:
(fn[xy](Math/powxy))
#(Math/pow%1%2)

后一个形式其实是前一个形式的reader语法糖,它在运行时会被扩展成前一种形式;我们可以简单证实一下:

(read-string"#(Math/pow%1%2)")
;=(fn*[p1__285#p2__286#](Math/powp1__285#p2__286#))

fn跟函数字面量的区别在于:

函数字面量没有隐式地使用do。普通的fn(以及由它引申出来的所有变种)把它的函数体放在一个隐式的do里面,我们在36页的“定义函数:fn”一节已经介绍过了。这使得你可以定义下面这样的函数:
(fn[xy]
(println(strx\^y))
(Math/powxy))

而如果使用函数字面量的话,那么你要显式地使用一个do形式:
#(do(println(str%1\^%2))
(Math/pow%1%2))

使用非命名的占位符号来指定函数的参数个数。上面fn的例子中,我们使用x和y来指定函数接受参数的个数,同时也利用它们来指定参数的名字。而对于函数字面量,我们则使用非命名的占位符%来指定函数参数个数以及引用具体参数,%1表示第一个参数,%2表示第二个参数等。而且最大的占位符指定函数的参数个数,因此如果我们要定义一个接受4个参数的函数,那么我们要在函数体内引用到%4才行。

对于函数字面量的参数,还有两个小技巧告诉大家:

1.因为很多函数字面量都只接受一个参数,所以可以简单地使用%来引用它的第一个参数,所以#(Math/pow%%2)和(Math/pow%1%2)是等价的,而Clojure中则鼓励使用比较短的表达方式。

2.你可以定义个不定参数的函数,并且通过%&来引用那些剩余参数。因此下面这些函数是等价的:
(fn[x&rest]
(-x(apply+rest)))
#(-%(apply+%&))

函数字面量不能嵌套使用。虽然下面这个是完全没有问题的:
(fn[x]
(fn[y]
(+xy)))

但是这个就不行了:
#(#(+%%))
;=#<IllegalStateExceptionjava.lang.IllegalStateException:
;=Nested#()sarenotallowed>

暂且不说我们设计函数字面量的本意是用来表达那些简短、简单的表达式,把函数字面量嵌套将会使得函数字面量很难读懂;同时也没有一个简短的办法使我们能区分出来%参数到底是指向内层的字面量还是外层的字面量的参数。

你可能感兴趣的:(解构(let,第2部分))