深入淺出ES6(十):集合

爲什麼要集合?

熟悉JS一定會知道,我們已經有了一種類似哈希表的東西:對象(Object)。

一個普通的對象畢竟就只是一個開放的鍵值對集合。你可以進行獲取、設置、刪除、遍歷——任何一個哈希表支持的操作。所以我們到底爲什麼要增加新的特性?

好吧,大多數程序簡單地用對象來存儲鍵值對就夠了,對它們而言,沒什麼必要換用MapSet。但是,直接這樣使用對象有一些廣爲人知的問題:

  • 作爲查詢表使用的對象,不能既支持方法又保證避免衝突。
  • 因而,要麼得用Object.create(null)而非直接寫{},要麼得小心地避免把Object.prototype.toString之類的內置方法名作爲鍵名來存儲數據。
  • 對象的鍵名總是字符串(當然,ES6 中也可以是Symbol)而不能是另一個對象。
  • 沒有有效的獲知屬性個數的方法。

ES6中又出現了新問題:純粹的對象不可遍歷,也就是,它們不能配合for-of循環或...操作符等語法。

嗯,確實很多程序裏這些問題都不重要,直接用純對象仍然是正確的選擇。MapSet是爲其它場合準備的。

這些ES6中的集合本來就是爲避免用戶數據與內置方法衝突而設計的,所以它們不會把數據作爲屬性暴露出來。也就是說,obj.keyobj[key]不能再用來訪問數據了,取而代之的是map.get(key)。同時,不像屬性,哈希表的鍵值不能通過原型鏈來繼承了。

好消息是,不像純粹的ObjectMapSet有自己的方法了,並且,更多標準或自定義的方法可以無需擔心衝突地加入。

Set

一個Set是一羣值的集合。它是可變的,能夠增刪元素。現在,還沒說到它和數組的區別,不過它們的區別就和相似點一樣多。

首先,和數組不同,一個Set不會包含相同元素。試圖再次加入一個已有元素不會產生任何效果。

這個例子裏元素都是字符串,不過Set是可以包含JS中任何類型的值的。同樣,重複加入已有元素不會產生效果。

其次,Set的數據存儲結構專門爲一種操作作了速度優化:包含性檢測。

    > // 檢查"zythum"是不是一個單詞
    > arrayOfWords.indexOf("zythum") !== -1  // 慢
        true
    > setOfWords.has("zythum")               // 快
        true

Set不能提供的則是索引。

    > arrayOfWords[15000]
        "anapanapa"
    > setOfWords[15000]   // Set不支持索引
        undefined

以下是Set支持的所有操作:

  • new Set:創建一個新的、空的Set
  • new Set(iterable):從任何可遍歷數據中提取元素,構造出一個新的集合。
  • set.size:獲取集合的大小,即其中元素的個數。
  • set.has(value):判定集合中是否含有指定元素,返回一個布爾值。
  • set.add(value):添加元素。如果與已有重複,則不產生效果。
  • set.delete(value):刪除元素。如果並不存在,則不產生效果。.add().delete()都會返回集合自身,所以我們可以用鏈式語法。
  • set[Symbol.iterator]():返回一個新的遍歷整個集合的迭代器。一般這個方法不會被直接調用,因爲實際上就是它使集合能夠被遍歷,也就是說,我們可以直接寫for (v of set) {...}等等。
  • set.forEach(f):直接用代碼來解釋好了,它就像是for (let value of set) { f(value, value, set); }的簡寫,類似於數組的.forEach()方法。
  • set.clear():清空集合。
  • set.keys()set.values()set.entries()返回各種迭代器,它們是爲了兼容Map而提供的,所以我們待會兒再來看。

在這些特性中,負責構造集合的new Set(iterable)是唯一一個在整個數據結構層面上操作的。你可以用它把數組轉化爲集合,在一行代碼內去重;也可以傳遞一個生成器,函數會逐個遍歷它,並把生成的值收錄爲一個集合;也可以用來複制一個已有的集合。

上週我答應過要給ES6中的新集合們挑挑刺,就從這裏開始吧。儘管Set已經很不錯了,還是有些被遺漏的方法,說不定補充到將來某個標準裏會挺不錯:

  • 目前數組已經有的一些輔助函數,比如.map().filter().some().every()
  • 不改變原值的交併操作,比如set1.union(set2)set1.intersection(set2)
  • 批量操作,如set.addAll(iterable)set.removeAll(iterable)set.hasAll(iterable)

好消息是,這些都可以用ES6已經提供了的方法來實現。

Map

一個Map對象由若干鍵值對組成,支持:

  • new Map:返回一個新的、空的Map
  • new Map(pairs):根據所含元素形如[key, value]的數組pairs來創建一個新的Map。這裏提供的pairs可以是一個已有的Map 對象,可以是一個由二元數組組成的數組,也可以是逐個生成二元數組的一個生成器,等等。
  • map.size:返回Map中項目的個數。
  • map.has(key):測試一個鍵名是否存在,類似key in obj
  • map.get(key):返回一個鍵名對應的值,若鍵名不存在則返回undefined,類似obj[key]
  • map.set(key, value):添加一對新的鍵值對,如果鍵名已存在就覆蓋。
  • map.delete(key):按鍵名刪除一項,類似delete obj[key]
  • map.clear():清空Map
  • map[Symbol.iterator]():返回遍歷所有項的迭代器,每項用一個鍵和值組成的二元數組表示。
  • map.forEach(f) 類似for (let [key, value] of map) { f(value, key, map); }。這裏詭異的參數順序,和Set中一樣,是對應着Array.prototype.forEach()
  • map.keys():返回遍歷所有鍵的迭代器。
  • map.values():返回遍歷所有值的迭代器。
  • map.entries():返回遍歷所有項的迭代器,就像map[Symbol.iterator]()。實際上,它們就是同一個方法,不同名字。

還有什麼要抱怨的?以下是我覺得會有用而ES6還沒提供的特性:

  • 鍵不存在時返回的默認值,類似 Python 中的collections.defaultdict
  • 一個可以叫Map.fromObject(obj)的輔助函數,以便更方便地用構造對象的語法來寫出一個Map

同樣,這些特性也是很容易加上的。

到這裏,還記不記得,開篇時我提到過運行於瀏覽器對語言特性設計的特殊影響?現在要好好談一談這個問題了。我已經有了三個例子,以下是前兩個。

JS是不同的,第一部分:沒有哈希代碼的哈希表?

到目前爲止,據我所知,ES6的集合類完全不支持下述這種有用的特性。

比如說,我們有若干 URL 對象組成的Set:

    var urls = new Set;
    urls.add(new URL(location.href));  // 兩個 URL 對象。
    urls.add(new URL(location.href));  // 它們一樣麼?
    alert(urls.size);  // 2

這兩個 URL 應該按相同處理,畢竟它們有完全一樣的屬性。但在JavaScript中,它們是各自獨立、互不相同的,並且,絕對沒有辦法來重載相等運算符。

其它一些語言就支持這一特性。在Java, Python, Ruby中,每個類都可以重載它的相等運算符;Scheme的許多實現中,每個哈希表可以使用不同的相等關係。C++則兩者都支持。

但是,所有這些機制都需要編寫者自行實現一個哈希函數並暴露出系統默認的哈希函數。在JS中,因爲不得不考慮其它語言不必擔心的互用性和安全性,委員會選擇了不暴露——至少目前仍如此。

JS是不同的,第二部分:意料之外的可預測性

你多半覺得一臺計算機具有確定性行爲是理所應當的,但當我告訴別人遍歷Map或Set的順序就是其中元素的插入順序時,他們總是很驚奇。沒錯,它就是確定的。

我們已經習慣了哈希表某些方面任性的行爲,我們學會了接受它。不過,總有一些足夠好的理由讓我們希望嘗試避免這種不確定性。2012年我寫過:

  • 有證據表明,部分程序員一開始會覺得遍歷順序的不確定性是令人驚奇又困惑的。1 2 3 4 5 6
  • ECMAScript中沒有明確規定遍歷屬性的順序,但爲了兼容互聯網現狀,幾乎所有主流實現都不得不將其定義爲插入順序。因此,有人擔心,假如TC39不確立一個確定的遍歷順序,“互聯網社區也會在自行發展中替我們決定。” 7
  • 自定義哈希表的遍歷順序會暴露一些哈希對象的代碼,繼而引發關於哈希函數實現的一些惱人的安全問題。例如,暴露出的代碼絕不能獲知一個對象的地址。(向不受信任的ES代碼透露對象地址而對其自身隱藏,將是互聯網的一大安全漏洞。)

在2012年2月以上種種意見被提出時,我是支持不確定遍歷序的。然後,我決定用實驗證明,保存插入序將過度降低哈希表的效率。我寫了一個C++的小型基準測試,結果卻令我驚奇地恰恰相反

這就是我們最終爲JS設計了按插入序遍歷的哈希表的過程。

推薦使用弱集合的重要原因

上篇文章我們討論了一個JS動畫庫相關的例子。我們試着要爲每個DOM對象設置一個布爾值類型的標識屬性,就像這樣:

    if (element.isMoving) {
      smoothAnimations(element);
    }
    element.isMoving = true;

不幸的是,這樣給一個DOM對象增加屬性不是個好主意。原因我們上次已經解釋過了。

上次的文章裏,我們接着展示了用Symbol解決這個問題的方法。但是,可以用集合來實現同樣的效果麼?也許看上去會像這樣:

    if (movingSet.has(element)) {
      smoothAnimations(element);
    }
    movingSet.add(element);

這隻有一個壞處。Map和Set都爲內部的每個鍵或值保持了強引用,也就是說,如果一個DOM元素被移除了,回收機制無法取回它佔用的內存,除非movingSet中也刪除了它。在最理想的情況下,庫在善後工作上對使用者都有複雜的要求,所以,這很可能引發內存泄露。

ES6給了我們一個驚喜的解決方案:用WeakSet而非Set。和內存泄露說再見吧!

也 就是說,這個特定情景下的問題可以用弱集合(weak collection)或Symbol兩種方法解決。哪個更好呢?不幸的是,完整地討論利弊取捨會把這篇文章拖得有些長。簡而言之,如果能在整個網頁的生 命週期內使用同一個Symbol,那就沒什麼問題;如果不得不使用一堆臨時的Symbol,那就危險了,是時候考慮WeakMap來避免內存泄露了。

WeakMap和WeakSet

WeakMap和WeakSet被設計來完成與Map、Set幾乎一樣的行爲,除了以下一些限制:

  • WeakMap只支持new、has、get、set 和delete。
  • WeakSet只支持new、has、add和delete。
  • WeakSet的值和WeakMap的鍵必須是對象。

還要注意,這兩種弱集合都不可迭代,除非專門查詢或給出你感興趣的鍵,否則不能獲得一個弱集合中的項。

這些小心設計的限制讓垃圾回收機制能回收仍在使用中的弱集合裏的無效對象。這效果類似於弱引用或弱鍵字典,但ES6的弱集合可以在不暴露腳本中正在垃圾回收的前提下得到垃圾回收的效益。

JS是不同的,第三部分:隱藏垃圾回收的不確定性

弱集合實際上是用 ephemeron 表實現的。

簡單說,一個WeakSet並不對其中對象保持強引用。當WeakSet中的一個對象被回收時,它會簡單地被從WeakSet中移除。WeakMap也類似地不爲它的鍵保持強引用。如果一個鍵仍被使用,相應的值也就仍被使用。

爲什麼要接受這些限制呢?爲什麼不直接在JS中引入弱引用呢?

再 次地,這是因爲標準委員會很不願意向腳本暴露未定義行爲。孱弱的跨瀏覽器兼容性是互聯網發展的痛苦之源。弱引用暴露了底層垃圾回收的實現細節——這正是與 平臺相關的一個未定義行爲。應用當然不應該依賴平臺相關的細節,但弱引用使我們難於精確瞭解自己對測試使用的瀏覽器的依賴程度。這是件很不講道理的事情。

相比之下,ES6的弱集合只包含了一套有限的特性,但它們相當牢靠。一個鍵或值被回收從不會被觀測到,所以應用將不會依賴於其行爲,即使只是緣於意外。

這是針對互聯網的特殊考量引發了一個驚人的設計、進而使JS成爲一門更好語言的一個例子。

什麼時候可以用上這些集合呢?

總計四種集合類在Firefox、Chrome、Microsoft Edge、Safari中都已實現,要支持舊瀏覽器則需要 ES6 - Collections 之類來補全。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章