本章主要介紹 Clojure 的集合數(shù)據(jù)結(jié)構(gòu)。這是個(gè)無聊但是又很重要的章節(jié), 可以說函數(shù)式編程最基本最重要的就是集合操作。本章會介紹:
當(dāng)然,已經(jīng)熟悉 Clojure 的讀者自然可以略過本章。
上一章大致提到了 JavaScript 的數(shù)據(jù)結(jié)構(gòu)都是可變的數(shù)據(jù)結(jié)構(gòu)。也就是說一些操作會改變數(shù)據(jù)結(jié)構(gòu)中的數(shù)據(jù)內(nèi)容。當(dāng)然 JavaScript 原生的 Number 和 String 本身就是不可變的,而且不會有太多的操作,所以本章主要是介紹 集合 數(shù)據(jù)結(jié)構(gòu)。而且,集合也是函數(shù)式編程中最常使用的數(shù)據(jù)結(jié)構(gòu)。
本章開始會使用 mori,想在本機(jī)使用 mori 非常簡單,如果使用 node,只需要
npm install mori
然后var mori = require('mori')
引入即可。如果使用瀏覽器,可以使用 cdnjs。
向量是帶有索引(index)的一組數(shù)據(jù)。跟 JavaScript 的 Array 非常像,但是區(qū)別在于
這里兩個(gè)概念聽起來很相似,但是其實(shí)有一點(diǎn)點(diǎn)區(qū)別。
不可變 指的是一旦被創(chuàng)建,就再也不能改變。比如我創(chuàng)建一個(gè)向量 Alice,那么不管發(fā)生什么,判等 Alice 的話只需要簡單的等號,因?yàn)?Alice 的內(nèi)容不可能被改變,所以完全不需要深入判等。
而 持久性 是“改變”不可變數(shù)據(jù)結(jié)構(gòu)的一種方式,每當(dāng)嘗試去修改一個(gè)不可變數(shù)據(jù)結(jié)構(gòu)的時(shí)候,其實(shí)是建立在舊的數(shù)據(jù)結(jié)構(gòu)的基礎(chǔ)上,做對應(yīng)的“修改”。本節(jié)只會涉及不可變性,下一節(jié)會通過介紹向量的持久性數(shù)據(jù)結(jié)構(gòu),加深對持久性的認(rèn)識。
下面我將介紹如何使用向量數(shù)據(jù)結(jié)構(gòu),當(dāng)然,我不是深入細(xì)節(jié),只是為了顯示不可變數(shù)據(jù)結(jié)構(gòu)與 JavaScript 原生可變數(shù)據(jù)結(jié)構(gòu)的區(qū)別。
創(chuàng)建向量 使用 Clojure 可以字面的(lieral)創(chuàng)建一個(gè)向量,或者用 vector 函數(shù),效果都是一樣的。
[1 2 3 4]
(vector 1 2 3 4)
其中,方括號用于字面創(chuàng)建向量,而圓括號表示調(diào)用了 vector
函數(shù),參數(shù)列表為 1 2 3 4
。
使用 mori 創(chuàng)建向量也非常的類似:
console.log(1+1)
mori.vector(1,2,3,4)
// => [1 2 3 4]
從 JavaScript 或其他語言轉(zhuǎn)換 clojure 或 lisp 的語法非常簡單,只需要將括號 ( 向左移一個(gè)函數(shù)名,逗號去掉即可。因此,mori 的 api 則正是這個(gè)過程的反轉(zhuǎn)。
如果是在 node 的 repl 中就可以看見類似 Clojure 字面定義 vector 的輸出 [1 2 3 4]
。
獲取向量中的元素
通常在 JavaScript Array 中我們獲取元素通常會通過 somearray[0]
直接用索引獲取,當(dāng)然使用 vector 也非常類似,只不過需要使用 get 方法:
var vec = mori.vector(1,2,3,4)
mori.get(vec, 0)
當(dāng)然,為了更符合 JavaScript 的習(xí)慣,我們還可以使用 vector 的成員變量 get 獲取元素:
vec.get(0)
添加元素 Clojure 中所有集合的添加操作都可以通過 conj 函數(shù),conj全稱 conjoin。對于不同數(shù)據(jù)結(jié)構(gòu)的 conj 操作可能添加的方向是不一樣的,但不管怎么樣,conj 都會選擇最容易的添加(即復(fù)雜度最低)的方向添加數(shù)據(jù)。而在向量數(shù)據(jù)結(jié)構(gòu)中,conj 的方向是往尾部添加元素:
mori.conj(vec, 5) //=> [1 2 3 4 5]
vec // => [1 2 3 4]
這很像 JavaScript Array 的 push 方法,但是值得注意的是,push (以及其他數(shù)組操作)是一個(gè)可變操作(mutation operation),也就是說,push 會改變 Array 中的數(shù)據(jù)。
var array = [1,2,3,4]
array.push(5) // => 5
array // => [1,2,3,4,5]
注意看 push 的返回值是添加的數(shù)據(jù),而 push 之后的 array 變化成添加過的數(shù)據(jù)的數(shù)組了。
彈出元素 彈出元素跟 Array 一樣都是使用 pop,當(dāng)然,由于幾乎所有的 clojure 的數(shù)據(jù)結(jié)構(gòu)都是不可變的,彈出也不例外。所以彈出會返回一個(gè)新的“刪除”尾部元素的向量,而原來的向量保持不變:
mori.pop(vec) //=> [1 2 3]
vec // => [1 2 3 4]
首個(gè)元素及剩余元素 另外在函數(shù)式編程中,特別是遞歸的時(shí)候,經(jīng)常會把列表分為首元素,和剩余(rest)元素集合。
mori.first(vec) //=> 1
mori.rest(vec) // => (2 3 4)
注意看 rest 返回的是圓括號,為什么變成圓括號了呢?我會在最后一節(jié)做詳細(xì)的解釋。
獲取子向量(subvec) subvec 操作返回一個(gè)持久性的子向量,比如:
mori.subvec(vec, 1) // => [2 3 4]
mori.subvec(vec, 1, 2) //=> [2]
vec // [1 2 3 4]
看到這里,可能細(xì)心的讀者會發(fā)現(xiàn)向量的所有操作都是不可變的,不管如何操作該向量,用于會返回一個(gè)新的向量而不是修改原有向量。這樣每次都返回一個(gè)新的數(shù)據(jù)結(jié)構(gòu),聽起來像是又拷貝了一份再做操作,效率不是會很低嗎?這個(gè)問題會在下節(jié)解釋持久性數(shù)據(jù)結(jié)構(gòu)的時(shí)候得到解答。
雖然想只介紹 vector 就好了,但是 ES6 的把 Map 納入了標(biāo)準(zhǔn),這里順便介紹一下 Map 對應(yīng)的 Clojure 的數(shù)據(jù)結(jié)構(gòu)好了。在 Map 還沒有被所有瀏覽器廠商實(shí)現(xiàn)之前,絕大多數(shù)情況下我們在寫 JavaScript 時(shí)會使用 Object 來當(dāng)做 Map 使用。當(dāng)然,到底是使用 Map 還是 Object 并不是本書的重點(diǎn),不管是 Map 還是 Object,重點(diǎn)是他們?nèi)匀皇强勺兊摹?/p>
var map = new Map();
map.set(0, "零"); // => {0:"零"}
map.set(1, "壹"); // => {0:"零",1:"壹"}
map 實(shí)例的內(nèi)容在不同的地方值有可能發(fā)生改變。同樣的,Clojure 提供不可變的 Map 數(shù)據(jù)結(jié)構(gòu),hash-map。同樣的,我們都可以通過 mori 在 JavaScript 中使用到 Clojure 的 hash-map。
我們可以簡單的使用 mori.hashMap 創(chuàng)建一個(gè) ClojureScript 的 hashmap 實(shí)例,當(dāng)然,所有操作都不會改變原來的不可變對象。
var m0 = mori.hashMap("零", 0, "壹", 1);
// => {"零" 0, "壹" 1}
mori.get(m0, "零"); // => 0
var m1 = mori.assoc(m0, mori.vector(1,2), 2);
// m1 = {"零" 0, "壹" 1, [1 2] 2}
m0 // => {"零" 0, "壹" 1}
mori.get(m1, m.vector(1,2)); // => 2
m0 永遠(yuǎn)是 m0。 其中 mori.assoc
是更新操作,有意思的是,assoc 操作也同樣可以用在 vector 上。
mori.assoc(mori.vector(1,2,3),1,8) // => [1 8 3]
跟 vector 一樣,也可以用 conj 操作連接 hash map:
mori.conj(m0, mori.vector("foo", "bar")) // => {"零" 0, "壹" 1, "foo" "bar"}
借用函數(shù)組合子這個(gè)詞來代表集合上的一些通用方法,如 map, filter, reduce。更詳細(xì)的組合子定義可以在 stackoverflow 上找到非常好的解釋。先不用去管具體定義,下面我會簡單列舉一些函數(shù)式編程,特別是 Clojure 編程中經(jīng)常會使用到的一些函數(shù)組合子。
map 把參數(shù)中的函數(shù)應(yīng)用到集合中每一個(gè)元素上,并返回函數(shù)返回的元素組成的新集合。 比如要把一包奧利奧變成餡被舔掉的奧利奧:
mori.map(lip, oreoPack)
這樣產(chǎn)生一包里面都是沒有餡的奧利奧。
如果 oreoPack 是一個(gè) JavaScript Array,同樣可以直接使用 Array 的 map 組合子:
oreoPack.map(lip)
似乎后者更符合我們的閱讀習(xí)慣,不過我會在下一章解釋什么情況更適合哪種情況。 但在本章我會一直使用 Clojure 的組合子使用習(xí)慣。
filter 接收一個(gè)謂詞函數(shù)(predicate function),用于判斷哪些元素應(yīng)該保留,哪些應(yīng)該被剔除掉。謂詞函數(shù)顧名思義就是用作謂詞的函數(shù),謂詞自然應(yīng)該就是“是”,“等于”,“大于”,“屬于”之類的詞。
mori.filter(mori.isEven, [1,2,3,4,5]);
// => (2 4)
同樣的,Array 也有 filter 方法:
[1,2,3,4,5].filter(x=>x%2==0);
前面都是集合內(nèi)容的轉(zhuǎn)換,而使用 reduce 則方便的可以將集合規(guī)約成值,比如我們很容易的可以用 reduce 些一個(gè) sum 函數(shù):
mori.reduce((a,b)=>a+b, 0, [1,2,3,4,5])
// => 15
其中,第一個(gè)函數(shù)描述如何進(jìn)行規(guī)約,第二個(gè)函數(shù)是規(guī)約的初始值,最后是集合。
用以把嵌套的集合展平:
var v = mori.toClj([[1, 2], 3, [4], [[5, 6], 7]]);
mori.flatten(v); // => (1 2 3 4 5 6 7)
take 會經(jīng)常用于從一個(gè)惰性的集合中取出一部分集合,比如:
var s = mori.range(); // 無限序列
mori.take(10, s); // => (0 1 2 3 4 5 6 7 8 9)
注意 s 是從 0 開始的無限整數(shù)序列,當(dāng)使用 take 取出前 10 個(gè)是,會得到包含著前10個(gè)整數(shù)的序列。更多關(guān)于惰性的話題會在第5節(jié)繼續(xù)。
groupBy 根據(jù)提供的函數(shù)的結(jié)果來分區(qū),產(chǎn)生相同結(jié)果的元素會被分到一個(gè)區(qū):
mori.partitionBy(x=>x%2==0?'event':'odd', [1,2,3,4,5])
// => {"even" (2 4) "odd" (1 3 5)}
大概對集合中的向量與 hashMap,以及集合的常用組合子 簡單的做了介紹,應(yīng)該還記得介紹向量時(shí)提到的效率問題嗎?我們來以向量為例,深入研究一下向量的數(shù)據(jù)結(jié)構(gòu)到底是怎樣的,又是如何做到持久性和不可變性,同時(shí)還保證效率的?
首先在解釋向量的數(shù)據(jù)結(jié)構(gòu)之前,我想再普及一下什么是持久性數(shù)據(jù)結(jié)構(gòu)和不可變性。
持久性是指數(shù)據(jù)結(jié)構(gòu)在被操作的時(shí)候永遠(yuǎn)保持著前一版本,這種保存之前結(jié)構(gòu)的行為就像是持久化。不可變性是說明不管怎么樣,在被創(chuàng)建之后就再也不能改變。所以持久性更像是數(shù)據(jù)結(jié)構(gòu)的實(shí)現(xiàn),而不可變性約束的數(shù)據(jù)結(jié)構(gòu)的操作。好了,概念的東西就說到這,我們來舉個(gè)例子,
還是前面那個(gè)例子,假設(shè)數(shù)組和向量的數(shù)據(jù)結(jié)構(gòu)都是鏈表。
那么,如果我要往中添加一項(xiàng):
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/persistent-conj.png" alt="" />
圖5 持久化數(shù)據(jù)的增加操作
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/mutable-push.png" alt="" />
圖6 非持久化數(shù)據(jù)的增加
前方高能預(yù)警,一大波 Clojure 源代碼來襲。
當(dāng)然,Clojure 的向量數(shù)據(jù)結(jié)構(gòu)并不是簡單的鏈表,而是 Rich Hickey 發(fā)明的樹形數(shù)據(jù)結(jié)構(gòu)。官方文檔也提到了向量的所有操作的復(fù)雜度都是 O(log32N),但為什么是32呢。回憶一下二分查找是多少,log2N,而二分查找類似于一顆平衡二叉樹,那么猜想 log32N 復(fù)雜度應(yīng)該是一個(gè)32叉的平衡樹才對。
好吧,偷看了一眼源代碼,確實(shí)證明這個(gè)猜想是對的。
Node(AtomicReference<Thread> edit){
this.edit = edit;
this.array = new Object[32];
}
通過這個(gè)結(jié)構(gòu)體明顯確定是每一個(gè)節(jié)點(diǎn)有 32 叉的樹型結(jié)構(gòu)。我們繼續(xù)往下看我們關(guān)心的問題:如何持久化的?
源代碼一直往下翻直到 217 行,會看到 cons 方法,而且這是 IPersistentVector 接口里的方法,這應(yīng)該就是添加元素了。
1: public PersistentVector cons(Object val){
2: int i = cnt
3: if(cnt - tailoff() < 32) // <= 1
4: {
5: Object[] newTail = new Object[tail.length + 1];
6: System.arraycopy(tail, 0, newTail, 0, tail.length);
7: newTail[tail.length] = val;
8: return new PersistentVector(meta(), cnt + 1, shift, root, newTail);
9: }
10: //full tail, push into tree
11: Node newroot;
12: Node tailnode = new Node(root.edit,tail);
13: int newshift = shift;
14: //overflow root?
15: if((cnt >>> 5) > (1 << shift)) // <= 2
16: {
17: newroot = new Node(root.edit);
18: newroot.array[0] = root;
19: newroot.array[1] = newPath(root.edit,shift, tailnode);
20: newshift += 5;
21: }
22: else // <= 3
23: newroot = pushTail(shift, root, tailnode);
24: return new PersistentVector(meta(), cnt + 1, newshift, newroot, new Object[]{val});
25: }
很明顯這段代碼里有三個(gè)分支,不要著急,我們一個(gè)一個(gè)看一下:
可以看到 第3行 中的 cnt 應(yīng)該就是當(dāng)前向量的長度,tailoff 往前找一下會發(fā)現(xiàn)是抹掉二進(jìn)制后五位,也就是除掉最后一片葉子的大小。所以,這個(gè)分支是處理當(dāng)最后一片葉子不完整時(shí)的情況。如果是二叉樹的話,就是非滿二叉樹的情況。
如果不滿足 1 自然就是子樹的葉子都是滿的情況,但是滿葉子的情況又分兩種,如果是比完全樹多一片滿的葉子,再加一個(gè)葉子就溢出了。
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/tail-off.png" alt="" />
圖7 tailoff 的區(qū)域
下面我們再仔細(xì)看看如何處理這三種情況。
這種情況是第一個(gè)分支, 一共才 4 行代碼,我們不妨仔細(xì)讀讀。
1: Object[] newTail = new Object[tail.length + 1]; // <= 1
2: System.arraycopy(tail, 0, newTail, 0, tail.length); // <= 2
3: newTail[tail.length] = val; // <= 3
4: return new PersistentVector(meta(), cnt + 1, shift, root, newTail); // <= 4
System.arraycopy 的 API 是:
public static void arraycopy(Object src, //拷貝源 int srcPos, // 拷貝開始的索引 Object dest, // 拷貝目標(biāo)地址 int destPos, // 目標(biāo)起始索引 int length) // 拷貝長度
newTail
newTail
PersistentVector
并把 tail
設(shè)置成 newTail
所以以下列代碼為例,我們很容易想象這種情況下添加元素的過程。注意,由于畫32叉樹實(shí)在是太長了太難看了,因此這里我畫成二叉樹,只是為了表示如何插入元素的過程。當(dāng)然讀者應(yīng)該不介意把它“腦補(bǔ)”成32叉的吧。
var vec = mori.vector(1,2,3,4,5,6,7)
var vec2 = mori.conj(vec, 8)
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/vec-conj-8.png" alt="" />
圖8 向 vec 添加新元素 8
細(xì)心的讀者會發(fā)現(xiàn),新的 vec2.root
還是指向舊的 vec.root
,只是 vec2.tail
為 vec1.tail
的拷貝再加上新的元素而已。這個(gè)操作應(yīng)該是 O(1) 才對。沒有錯(cuò),這種情況下添加元素確實(shí)效率是 O(1)。但是再想想, vec2
不像是一顆連貫的樹啊,tail 指到了一個(gè)完全分離的數(shù)組拷貝上。
帶著問題我們繼續(xù)來看如果我再 conj 一個(gè)元素會發(fā)生什么?
var vec3 = mori.conj(vec2, 9)
這時(shí)就會進(jìn)入到這個(gè)分支了,現(xiàn)在 vec2
的所有葉子都滿了,按正常的思路我們需要創(chuàng)建一個(gè)新的葉子節(jié)點(diǎn)來放我們的新元素 7。我們來看看 Clojure 是怎么做的:
1: Node newroot;
2: Node tailnode = new Node(root.edit,tail); //
3: int newshift = shift; //
4: ...
5: newroot = pushTail(shift, root, tailnode); //
6: return new PersistentVector(meta(), cnt + 1, newshift, newroot, new Object[]{val}) //
也只有四行代碼,我們來仔細(xì)讀一下:
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/vec-conj-9.png" alt="" />
圖9 在滿葉子的情況下添加元素9 這時(shí)候我們再添加 10:
var vec4 = mori.conj(vec3, 10)
應(yīng)該還是第一種情況,有葉子不滿,那么我們再添加 11 會怎么樣呢?
var vec5 = mori.conj(vec4, 11)
如果是向量元素總數(shù)大于一顆完全樹的所有葉子,而且所有葉子是完整的,那再往 vec4中添加元素就是這種情況了。
newroot = new Node(root.edit);
newroot.array[0] = root; // <= 1
newroot.array[1] = newPath(root.edit,shift, tailnode); // <= 2
newshift += 5; // <= 3
return new PersistentVector(meta(), cnt + 1, newshift, newroot, new Object[]{val}); // <= 4
這種情況下代碼也不太多,需要看的也就是四行代碼:
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/vec-conj-11.png" alt="" />
圖10 添加11
好了,看到這里,我們已經(jīng)看到了 Clojure 的向量數(shù)據(jù)結(jié)構(gòu)完整的添加元素的過程。我們可以看到整個(gè)過程并沒有做全部數(shù)據(jù)的拷貝,而只是最多 log32N次,也就是樹的高度次的拷貝??傮w來說復(fù)雜度應(yīng)該是非常可觀的,因?yàn)橐粋€(gè) 6 層的 32 叉樹已經(jīng)能存放 10億(1,073,741,824)個(gè)元素了,而10億個(gè)元素的添加操作最多也只是 O(6*32),效率是非常不錯(cuò)的。
既然學(xué)會了看 Clojure 的源碼,下來更新元素和彈出元素的過程可以留給讀者研究了。類似的,效率也是O(log32N)。
在函數(shù)式世界里,所有東西在被創(chuàng)建出來之后都應(yīng)該是不可變的,換句話說,如果我泡了一杯茶,那這杯茶會一直在那里,不對變多,也不會變少,也不會變成牛奶。所以這杯茶在任何時(shí)候,都應(yīng)該恒等于它被創(chuàng)建時(shí)的狀態(tài)。
本小節(jié)嚴(yán)重劇透,好奇心強(qiáng)的讀者請看完電影再回來接著看。
如果你看過克里斯托弗·諾蘭的電影《致命魔術(shù)》(The Prestige),應(yīng)該會對里面的安吉爾用特斯拉給的神秘裝置復(fù)制自己來完成瞬間移動的魔術(shù)。雖然安吉爾不停的殺死自己確實(shí)做法極端,但是完全又印證了片中開頭和結(jié)束解釋的變魔術(shù)的三個(gè)步驟:
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/The-Prestige.png" alt="" />
圖11 電影《致命魔術(shù)》海報(bào) 回到我們的話題上來,那么可變操作就像是讓小鳥消失再回來,其實(shí)永遠(yuǎn)都找不回來消失的那只小鳥了。
var magic = function(cage){
cage[0] = {name:‘翠花’}
}
var birdInACage = [{name:’tweety’}]
magic(birdInACage)
birdInACage// => [{name:‘翠花’}]
可以看到,經(jīng)過 magic 函數(shù)后,tweety 就消失了,籠子里只有翠花,而這只被 magic 變沒有的 tweety,不久之后會被 javascript 的 GC(垃圾回收)鏟走。
但是,函數(shù)式編程并不喜歡魔術(shù),就像博登在臺上把小鳥“變回來”時(shí),臺下的小朋友哭著說我要原來那只小鳥一樣。函數(shù)式編程希望不論何時(shí)都可以找回來原來那只小鳥。
因此,我們需要一種神奇的模式把 twetty 隱藏起來。
var anotherBirdInTheCage = magic(birdInACage)
function magic(birdInCage){
return birdInCage.map(function(bird){return bird.name='翠花'})
}
anotherBirdInTheCage// => [{name:‘翠花’}]
birdInACage // => [{name:'tweety'}]
太好了,twetty 沒有“消失”,只是多了一只叫做翠花的小鳥。
雖然可變性 給我們編程帶來了一些便利,這可能是因?yàn)槲覀兊恼鎸?shí)世界的所有東西都是可變的,這非常符合我們真實(shí)環(huán)境的思維方式。但是,這種可變性也能帶來類似現(xiàn)實(shí)世界一樣不可預(yù)測性的問題,有可能在不經(jīng)意間會給我?guī)硪恍├_,而卻很難推理產(chǎn)生這種困擾的原因。
由于所有的對象都是可變的,就像現(xiàn)實(shí)世界一樣,對象之間靠消息通信,而通過各種消息發(fā)來發(fā)去之后誰也不知道在某一時(shí)間這些對象的狀態(tài)都是些什么。然而對象的行為又可能依賴于其他對象的狀態(tài)。這樣依賴,如果想推測一個(gè)對象某個(gè)時(shí)間的行為,可能需要先確定其所有有消息通信相關(guān)的對象這時(shí)的狀態(tài)。
寫過前端 JavaScript 的人都應(yīng)該非常清楚前端代碼是非常難推理的,光看一段代碼片段很難推測出其行為。通常,自由變量越多,行為越不確定,而前端的 自由變量太多太多:
而通常 JavaScript 或前端一些框架,都或多或少的依賴于這些因素。
有意思的是的 ReactJS 就相對更容易推理。因?yàn)樗褂昧藛蜗驍?shù)據(jù)流狀態(tài)機(jī)模型,VirtualDOM 的使用很好的隔離開了 DOM 的狀態(tài)。React 的成功也充分的詮釋了面向?qū)ο蠛秃瘮?shù)式編程的完美結(jié)合。正常一個(gè) React 控件是這樣工作的:
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/react-flow.png" alt="" />
圖12 React 控件隔離變化
所以,React 的模型為更高內(nèi)聚的模型,只有當(dāng)自己的屬性和狀態(tài)發(fā)生變化時(shí),才會重新的返回該狀態(tài)和屬性下的 全新 控件。注意是全新的,不同于傳統(tǒng)的修改 DOM 的可變性模型,React 的任何操作都是返回全新控件的不可變操作,就像操作 vector 一樣,不會去修改,而是再建一個(gè)新的。而且,React 把所有可變的部分都隔離了,所有的可變的因素如,用戶事件,數(shù)據(jù)變化,其他上下游控件的影響,都隔離在狀態(tài)和屬性之外。這樣做使得我們的控件行為更簡單,容易推理,也容易測試。就像接受兩個(gè)參數(shù)(狀態(tài),屬性)的函數(shù),給定這兩個(gè)參數(shù) ,那么返回的控件一定是一樣的。而可變的 DOM,也被 VirtualDOM 隔離了。所以完全可以把所有 React 的控件編寫的像純函數(shù)一樣。因此,也可以像純函數(shù)一樣輕松的把一個(gè)組件替換掉,輕松解耦了組件之間的關(guān)系。
前端 JavaScript 雖然說是單線程的,但是基于事件循環(huán)的并發(fā)模型一樣會遇到多線程的線程安全問題。線程不安全是指一個(gè)值會被多個(gè)線程中的操作同時(shí)修改。帶來的問題是你很難預(yù)測以及重現(xiàn)這個(gè)值在某個(gè)時(shí)間到底是什么。 解決線程安全通常會用到互斥鎖,原子操作等等,這些方式大大的增加編程和測試的難度。
在前端即使沒有多線程同樣會遇到一樣的問題,比如在期望線程安全的一個(gè)事物操作中,某個(gè)值突然被修改了:
// 假設(shè)收錢比如使用第三方支付寶之類的, 這里假設(shè)100ms之后知道支付成功,然后調(diào)用回調(diào)函數(shù)
function charge(order,callback){
setTimeout(callback.bind(this,order), 100)
}
// 假設(shè)熊孩子喝牛奶只需要99ms(可能熊孩子是閃電俠)
function drinkMilkThenChange(order){
setTimeout(order.push({name:'R2D2',price:99999}),
99)
}
// 打印發(fā)票
function printReceipt(order){console.log(order)}
// 熊孩子買了兩個(gè)東西
var order = [{name:'kindle',price:99}, {name:'drone', price:299}];
// 熊孩子結(jié)賬
charge(order, printReceipt)
// 熊孩子喝了杯牛奶后過來修改訂單
drinkMilkThenChange(order)
// 這時(shí)熊孩子發(fā)票上有三個(gè)東西
// [{name:'kindle',price:99}, {name:'drone', price:299}, {name: 'R2D2', 99999}]
這里到底發(fā)生了什么?單線程也不安全嗎?難道要給 order 加鎖嗎? 這里的 setTimeout 都是寫死的多少秒,如果是真實(shí)代碼多幾個(gè)熊孩子而且發(fā) ajax 請求不確定回調(diào)時(shí)間之類的,你永遠(yuǎn)猜不到最后打印出來的發(fā)票上有些什么。
首先,讓我來解釋一下這里到底發(fā)生了什么。使用多線程的思路的話,charge 應(yīng)該是個(gè) io 操作,通常需要 fork 一個(gè)線程來做,這樣就不阻塞主線程。于是 printReceipt 就是運(yùn)行在 fork 出來的另一個(gè)線程,意味著我在主線程的操作修改到了子線程依賴的值,導(dǎo)致了線程不安全。
但是 JavaScript 在單線程的運(yùn)行環(huán)境下如何做到線程不安全?單線程,說的是 JavaScript 運(yùn)行的主線程,但是瀏覽器可以有若干線程處理這樣的 IO 操作,也就是維護(hù)傳說中的 事件循環(huán) 。就拿剛才簡單的 setTimeout 為例,其實(shí)是另一個(gè)線程在100毫秒之后把回調(diào)函數(shù)放入到事件循環(huán)的隊(duì)列中。
所以解決方式是加鎖嗎? 在每次收錢之前,把訂單鎖上:
function charge(order,callback){
Object.freeze(order);
setTimeout(callback.bind(this,order), 100)
}
drinkMilkThenChange(order)
// Uncaught TypeError: Cannot assign to read only property 'length' of [object Array]
當(dāng)然加鎖可以解決,但是更容易而且無需考慮是多線程的方式則是簡單的使用不可變數(shù)據(jù)結(jié)構(gòu)。簡單的把 order 的類型改成 vector 就可以了:
function charge(order,callback){
setTimeout(callback.bind(this,order), 100)
}
function drinkMilkThenChange(order){
setTimeout(mori.conj(order,{name:'R2D2',price:99999}),
99)
}
var order = mori.vector({name:'kindle',price:99}, {name:'drone', price:299})
function printReceipt(order){console.log(order.toString())}
charge(order, printReceipt)
drinkMilkThenChange(order)
// [#js {:name "kindle", :price 99} #js {:name "drone", :price 299}]
不可變性保證了不管是主線程代碼還是回調(diào)函數(shù),拿到的值都能一直保持不變,所以不再需要關(guān)心會出現(xiàn)線程安全問題。
還記得介紹向量時(shí)這個(gè)怪怪的返回嗎?
mori.rest(vec) // => (2 3 4)
我明明是取一個(gè)向量的尾部,為什么返回的不是方括號的向量,而是圓括號呢?
這個(gè)圓括號代表惰性序列(lazy sequence),當(dāng)然,我接著要來定義 惰性 和 序列 。
這一章既介紹了集合 API 又讀了 Clojure 源代碼,實(shí)在是太無聊了,我自己都快寫不下去了,所以我們不妨先暫停一下,來一個(gè)十分生動的故事稍微提提神。
還是吃奧利奧這件事情,如果你已經(jīng)忘了,我們來回顧一下之前的吃法:
這是吃一個(gè)奧利奧的方法,我要把這個(gè)步驟寫下來(這個(gè)故事的設(shè)定是我的記憶力極差,不寫下來我會忘了該怎么吃)。既然學(xué)過 map 函數(shù),我們試試要怎么將我的吃法 map 到一整包奧利奧上。首先封裝一下如何吃一個(gè)奧利奧的步驟:
function lipMiddle(oreo){
var wetOreo = dipMilk(oreo);
var [top, ...middleBottom] = wetOreo;
var bottom = lip(middleBottom);
return [top, bottom];
}
eat(lipMiddle(oreo));
然后我們開始吃整包奧利奧(underscore 版吃法):
var _ = require('underscore')
var oreoPack = _.range(10).map(function(x){return ["top","middle","bottom"]})
var wetOreoPack = _.map(oreoPack,lipMiddle);
_.each(wetOreoPack, eat)
問題是,我其實(shí)并不知道自己能不能吃完整包,但是按照這種吃法的話, 我會打開并且著急的把所有奧利奧都沾了下牛奶,把餡舔掉,又塞回了袋子里。
假如我吃了兩塊就發(fā)現(xiàn)吃不下去了,我把袋子封好,然后困得不行去睡覺了。過了兩天打開袋子發(fā)現(xiàn)我的奧利奧全發(fā)霉了。于是開始抱怨為什么當(dāng)初不吃的要手賤去沾一下牛奶,太浪費(fèi)了不是嗎。
我是個(gè)特別摳門的人,于是開始苦思冥想到底吃奧利奧的方式哪里有問題。
很明顯我不應(yīng)該貪心的先吃掉整包奧利奧的餡,我應(yīng)該吃多少就舔多少奧利奧的餡。但是問題是,我怎么知道我要吃多少呢?
又經(jīng)過一番又一番的苦思冥想,我終于想到了在不知道能吃多少塊的情況下怎樣完美的吃一包奧利奧(mori 版吃法):
寫成代碼該是長這樣的:
var oreoPack = mori.repeat(["top","middle","bottom"]);
var wetOreoPack = mori.map(lipMiddle,oreoPack);// (ref:)
// 條都塞好了,現(xiàn)在該吃了,假設(shè)我吃3塊
mori.each(eat, mori.take(3, wetOreoPack));//(ref:)
故事就這么圓滿的結(jié)束了!于是公主和王子……
等等,這個(gè)實(shí)現(xiàn)怎么看著跟前面 underscore 的實(shí)現(xiàn)沒有什么兩樣,到底是在哪里把小條塞進(jìn)去的?
那么現(xiàn)在我們來看看 mori 是如何把小條塞進(jìn)去的。在這之前,我們再來看看 underscore 版本的實(shí)現(xiàn),細(xì)心的讀者會發(fā)現(xiàn)我還沒有實(shí)現(xiàn) lip 函數(shù),這個(gè)函數(shù)具體如何去舔奧利奧我們并不是很關(guān)心,暫且簡單的打印出來點(diǎn)東西好了:
function lip(oreo){
console.log("舔了一下")
return oreo
}
function dipMilk(orea){
console.log("沾一下牛奶")
return oreo
}
那么, map 我的吃奧利奧方式到整包奧利奧的時(shí)候會發(fā)生什么呢?
var wetOreoPack = _.map(oreoPack,lipMiddle);
// => " 沾一下牛奶" “舔了一下” 這兩句話被打印10次
而同樣的 mori 版本的 map 卻什么也不會打印出來:
var wetOreoPack = mori.map(lipMiddle,oreoPack) // 無打印信息
為什么會什么都沒打印,難道沒 map 上嗎?當(dāng)然不是,map 是成功的,但是 mori 的 map 不會真對每一塊奧利奧都執(zhí)行我的吃奧利奧流程 lipMiddle,它只會在奧利奧上貼上一張描述如何吃奧利奧的流程的小條。因此,什么也不會返回,相當(dāng)于我把整包奧利奧打開,貼上小條,再放回原位,封好袋子。
http://wiki.jikexueyuan.com/project/clojure-flavored-javascript/images/lazy-oreo.png" alt="" />
圖13 惰性吃奧利奧法
好了,生動的故事真的要圓滿結(jié)束了,如果這個(gè)故事都聽明白了的話,再加上幾個(gè)學(xué)術(shù)名詞,我想我已經(jīng)解釋完什么是惰性和為什么要使用惰性了。故事中的小條,叫做 thunk (我在第一章提過),而這種貼過條的序列,叫做 惰性序列 ,對應(yīng)的 map 操作方式,叫 惰性求值 。 Underscore 的這種立即執(zhí)行的 map 方式,叫做 及早求值 。
在了解這一大堆名詞之后,我們來進(jìn)一步研究如何具體實(shí)現(xiàn)一個(gè)惰性的數(shù)據(jù)結(jié)構(gòu)。我將繼續(xù)以吃奧利奧為例子,解釋如何實(shí)現(xiàn)這個(gè)惰性的 map。
之前見到的 mori.map(lipMiddle,oreoPack)
沒有打印出任何信息,按照我的例子的說法是因?yàn)椤癿ap 只把操作的過程寫成小條貼到餅干上”。那么,具體是如何把過程貼到這包奧利奧里的呢?
只要是涉及到實(shí)現(xiàn),我必然要貼源代碼,因?yàn)闆]有什么文檔會比代碼更真實(shí)。首先我們大眼看一下 map 的實(shí)現(xiàn):
1: ([f coll]
2: (lazy-seq ;; <= 1
3: (when-let [s (seq coll)]
4: (if (chunked-seq? s) ;; <= 2
5: (let [c (chunk-first s)
6: size (int (count c))
7: b (chunk-buffer size)]
8: (dotimes [i size]
9: (chunk-append b (f (.nth c i))))
10: (chunk-cons (chunk b) (map f (chunk-rest s))))
11: (cons (f (first s)) (map f (rest s))))))) ;; <= 3
(chunk b)
和 (map f (chunk-rest s))
(f (first s))
和 (map f (rest s))
兩種分支其實(shí)返回的都差不多,都是兩個(gè)元素, 而第二個(gè)元素都是遞歸的再次調(diào)用 map
。我們先別看第一個(gè)分支,看看第二個(gè)簡單分支。重要的是,所有的過程都放在一個(gè)叫 lazy-seq
的 macro 中。如果我們把 (map lipMiddle oreoPack)
代換展開的話會得到:
(lazy-seq (cons (lipMiddle (first oreoPack) (map lipMiddle (rest oreoPack)))))
其中 lazy-seq
做的事情就是阻止 (cons...)
被求值,把序列從 應(yīng)用序 變成 正則序 ?;氐轿覀兊睦?,這樣一來, map
其實(shí)就是創(chuàng)建了一個(gè) lazy-seq
的對象或者容器,容器內(nèi)的序列其實(shí)還沒有被求值。所以在 map
之后不會有任何的打印信息,因?yàn)樗械臇|西其實(shí)都還沒有被求值,也就是我例子中說的,只是給奧利奧貼上了寫滿過程的小條而已。 這個(gè)例子中,就是在吃奧利奧的時(shí)候,我們才真正需要進(jìn)行這么一個(gè)吃奧利奧的過程。所以當(dāng)我從一包奧利奧中拿一個(gè)準(zhǔn)備吃的時(shí)候,我需要安裝條上的過程操作一遍:
(take 1 (map lipMiddle oreoPack))
那么 lazy-seq 中的序列會被求值,意味著,兩個(gè)元素都會被求值
(cons lipedOreo (map lipMiddle (rest oreoPack))))
(lipMiddle (first oreoPack)
求值得到 lipedOreo
而 (map lipMiddle (rest oreoPack)
求值變成又一個(gè) lazy-seq
(lazy-seq (cons (lipMiddle (first (rest oreoPack))) (map lipMiddle (rest (rest oreoPack)))))
以此類推,需要吃第二塊奧利奧時(shí),同樣的再對上式 lazy-seq
容器中的序列求值。
好了,生動的故事真的要圓滿結(jié)束了,如果這個(gè)故事都聽明白了的話,再加上幾個(gè)學(xué)術(shù)名詞,我想我已經(jīng)解釋完什么是惰性和為什么要使用惰性了。故事中的小條,叫做 thunk (我在第一章提過),而這種貼過條的序列,叫做 惰性序列 ,對應(yīng)的 map 操作方式,叫 惰性求值 。 Underscore 的這種立即執(zhí)行的 map 方式,叫做 及早求值 。