再談JavaScript數組去重

JavaScript的數組去重是一個老生常談的話題了。隨便搜一搜就能找到非常多不同版本的解法。

細想一下,這樣一個看似簡單的需求,如果要做到完備,涉及的知識和需要注意的地方著實不少。

定義重複(相等)

要去重,首先得定義,什麼叫作“重複”,即具體到代碼而言,兩個數據在什麼情況下可以算是相等的。這並不是一個很容易的問題。

對於原始值而言,我們很容易想到1和1是相等的,'1'和'1'也是相等的。那麼,1和'1'是相等的麼?

如果這個問題還好說,只要回答“是”或者“不是”即可。那麼下面這些情況就沒那麼容易了。

NaN

初看NaN時,很容易把它當成和null、undefined一樣的獨立數據類型。但其實,它是數字類型。

// number
console.log(typeof NaN);

根據規範,比較運算中只要有一個值為NaN,則比較結果為false,所以會有下面這些看起來略蛋疼的結論:

// 全都是false
0 < NaN;
0 > NaN;
0 == NaN;
0 === NaN;

以最後一個表達式0 === NaN為例,在規範中有明確規定():

4. If Type(x) is Number, then

a. If x is NaN, return false.

b. If y is NaN, return false.

c. If x is the same Number value as y, return true.

d. If x is +0 and y is −0, return true.

e. If x is −0 and y is +0, return true.

f. Return false.

這意味著任何涉及到NaN的情況都不能簡單地使用比較運算來判定是否相等。比較科學的方法只能是使用isNaN():

var a = NaN;
var b = NaN;
// true
console.log(isNaN(a) && isNaN(b));

原始值和包裝對象

看完NaN是不是頭都大了。好了,我們來輕鬆一下,看一看原始值和包裝對象這一對冤家。

如果你研究過'a'.trim()這樣的代碼的話,不知道是否產生過這樣的疑問:'a'明明是一個原始值(字符串),它為什麼可以直接調用.trim()方法呢?當然,很可能你已經知道答案:因為JS在執行這樣的代碼的時候會對原始值做一次包裝,讓'a'變成一個字符串對象,然後執行這個對象的方法,執行完之後再把這個包裝對象脫掉。可以用下面的代碼來理解:

// 'a'.trim();
var tmp = new String('a');
tmp.trim();

這段代碼只是輔助我們理解的。但包裝對象這個概念在JS中卻是真實存在的。

var a = new String('a');
var b = 'b';

a即是一個包裝對象,它和b一樣,代表一個字符串。它們都可以使用字符串的各種方法(比如trim()),也可以參與字符串運算(+號連接等)。

但他們有一個關鍵的區別:類型不同!

typeof a; // object
typeof b; // string

在做字符串比較的時候,類型的不同會導致結果有一些出乎意料:

var a1 = 'a';
var a2 = new String('a');
var a3 = new String('a');
a1 == a2; // true
a1 == a3; // true
a2 == a3; // false
a1 === a2; // false
a1 === a3; // false
a2 === a3; // false

同樣是表示字符串a的變量,在使用嚴格比較時竟然不是相等的,在直覺上這是一件比較難接受的事情,在各種開發場景下,也非常容易忽略這些細節。

對象和對象

在涉及比較的時候,還會碰到對象。具體而言,大致可以分為三種情況:純對象、實例對象、其它類型的對象。

純對象

純對象(plain object)具體指什麼並不是非常明確,為減少不必要的爭議,下文中使用純對象指代由字面量生成的、成員中不含函數和日期、正則表達式等類型的對象。

如果直接拿兩個對象進行比較,不管是==還是===,毫無疑問都是不相等的。但是在實際使用時,這樣的規則是否一定滿足我們的需求?舉個例子,我們的應用中有兩個配置項:

// 原來有兩個屬性
// var prop1 = 1;
// var prop2 = 2;
// 重構代碼時兩個屬性被放到同一個對象中
var config = {
 prop1: 1,
 prop2: 2
};

假設在某些場景下,我們需要比較兩次運行的配置項是否相同。在重構前,我們分別比較兩次運行的prop1和prop2即可。而在重構後,我們可能需要比較config對象所代表的配置項是否一致。在這樣的場景下,直接用==或者===來比較對象,得到的並不是我們期望的結果。

在這樣的場景下,我們可能需要自定義一些方法來處理對象的比較。常見的可能是通過JSON.stringify()對對象進行序列化之後再比較字符串,當然這個過程並非完全可靠,只是一個思路。

如果你覺得這個場景是無中生有的話,可以再回想一下斷言庫,同樣是基於對象成員,判斷結果是否和預期相符。

實例對象

實例對象主要指通過構造函數(類)生成的對象。這樣的對象和純對象一樣,直接比較都是不等的,但也會碰到需要判斷是否是同一對象的情況。一般而言,因為這種對象有比較複雜的內部結構(甚至有一部分數據在原型上),無法直接從外部比較是否相等。比較靠譜的判斷方法是由構造函數(類)來提供靜態方法或者實例方法來判斷是否相等。

var a = Klass();
var b = Klass();
Klass.isEqual(a, b);

其它對象

其它對象主要指數組、日期、正則表達式等這類在Object基礎上派生出來的對象。這類對象各有各的特殊性,一般需要根據場景來構造判斷方法,決定兩個對象是否相等。

比如,日期對象,可能需要通過Date.prototype.getTime()方法獲取時間戳來判斷是否表示同一時刻。正則表達式可能需要通過toString()方法獲取到原始字面量來判斷是否是相同的正則表達式。

==和===

在一些文章中,看到某一些數組去重的方法,在判斷元素是否相等時,使用的是==比較運算符。眾所周知,這個運算符在比較前會先查看元素類型,當類型不一致時會做隱式類型轉換。這其實是一種非常不嚴謹的做法。因為無法區分在做隱匿類型轉換後值一樣的元素,例如0、''、false、null、undefined等。

同時,還有可能出現一些只能黑人問號的結果,例如:

[] == ![]; //true

Array.prototype.indexOf()

在一些版本的去重中,用到了Array.prototype.indexOf()方法:

再談JavaScript數組去重

既然==和===在元素相等的比較中是有巨大差別的,那麼indexOf的情況又如何呢?大部分的文章都沒有提及這點,於是只好求助規範。通過規範(),我們知道了indexOf()使用的是嚴格比較,也就是===。

再次強調:按照前文所述,===不能處理NaN的相等性判斷。

Array.prototype.includes()

Array.prototype.includes()是ES2016中新增的方法,用於判斷數組中是否包含某個元素,所以上面使用indexOf()方法的第二個版本可以改寫成如下版本:

再談JavaScript數組去重

那麼,你猜猜,includes()又是用什麼方法來比較的呢?如果想當然的話,會覺得肯定跟indexOf()一樣嘍。但是,程序員的世界裡最怕想當然。翻一翻規範,發現它其實是使用的另一種比較方法,叫作“SameValueZero”比較。

1. If Type(x) is different from Type(y), return false.

2. If Type(x) is Number, then

a. If x is NaN and y is NaN, return true.

b. If x is +0 and y is -0, return true.

c. If x is -0 and y is +0, return true.

d. If x is the same Number value as y, return true.

e. Return false.

3. Return SameValueNonNumber(x, y).

注意2.a,如果x和y都是NaN,則返回true!也就是includes()是可以正確判斷是否包含了NaN的。我們寫一段代碼驗證一下:

var arr = [1, 2, NaN];
arr.indexOf(NaN); // -1
arr.includes(NaN); // true

可以看到indexOf()和includes()對待NaN的行為是完全不一樣的。

一些方案

從上面的一大段文字中,我們可以看到,要判斷兩個元素是否相等(重複)並不是一件簡單的事情。在瞭解了這個背景後,我們來看一些前面沒有涉及到的去重方案。

遍歷

雙重遍歷是最容易想到的去重方案:

再談JavaScript數組去重

雙重遍歷還有一個優化版本,但是原理和複雜度幾乎完全一樣:

再談JavaScript數組去重

這種方案沒什麼大問題,用於去重的比較部分也是自己編寫實現(arr[i] === arr[j]),所以相等性可以自己針對上文說到的各種情況加以特殊處理。唯一比較受詬病的是使用了雙重循環,時間複雜度比較高,性能一般。

使用對象key來去重

再談JavaScript數組去重

這種方法是利用了對象(tmp)的key不可以重複的特性來進行去重。但由於對象key只能為字符串,因此這種去重方法有許多侷限性:

1. 無法區分隱式類型轉換成字符串後一樣的值,比如1和'1'

2. 無法處理複雜數據類型,比如對象(因為對象作為key會變成[object Object])

3. 特殊數據,比如'__proto__'會掛掉,因為tmp對象的__proto__屬性無法被重寫

對於第一點,有人提出可以為對象的key增加一個類型,或者將類型放到對象的value中來解決:

再談JavaScript數組去重

該方案也同時解決第三個問題。

而第二個問題,如果像上文所說,在允許對對象進行自定義的比較規則,也可以將對象序列化之後作為key來使用。這裡為簡單起見,使用JSON.stringify()進行序列化。

再談JavaScript數組去重

Map Key

可以看到,使用對象key來處理數組去重的問題,其實是一件比較麻煩的事情,處理不好很容易導致結果不正確。而這些問題的根本原因就是因為key在使用時有限制。

那麼,能不能有一種key使用沒有限制的對象呢?答案是——真的有!那就是ES2015中的Map。

Map是一種新的數據類型,可以把它想象成key類型沒有限制的對象。此外,它的存取使用單獨的get()、set()接口。

var tmp = new Map();
tmp.set(1, 1);
tmp.get(1); // 1
tmp.set('2', 2);
tmp.get('2'); // 2
tmp.set(true, 3);
tmp.get(true); // 3
tmp.set(undefined, 4);
tmp.get(undefined); // 4
tmp.set(NaN, 5);
tmp.get(NaN); // 5
var arr = [], obj = {};
tmp.set(arr, 6);
tmp.get(arr); // 6
tmp.set(obj, 7);
tmp.get(obj); // 7

由於Map使用單獨的接口來存取數據,所以不用擔心key會和內置屬性重名(如上文提到的__proto__)。使用Map改寫一下我們的去重方法:

再談JavaScript數組去重

Set

既然都用到了ES2015,數組這件事情不能再簡單一點麼?當然可以。

除了Map以外,ES2015還引入了一種叫作Set的數據類型。顧名思義,Set就是集合的意思,它不允許重複元素出現,這一點和數學中對集合的定義還是比較像的。

var s = new Set();
s.add(1);
s.add('1');
s.add(null);
s.add(undefined);
s.add(NaN);
s.add(true);
s.add([]);
s.add({});

如果你重複添加同一個元素的話,Set中只會存在一個。包括NaN也是這樣。於是我們想到,這麼好的特性,要是能和數組互相轉換,不就可以去重了嗎?

function unique(arr){
 var set = new Set(arr);
 return Array.from(set);
}

我們討論了這麼久的事情,居然兩行代碼搞定了,簡直不可思議。

然而,不要只顧著高興了。有一句話是這麼說的“不要因為走得太遠而忘了為什麼出發”。我們為什麼要為數組去重呢?因為我們想得到不重複的元素列表。而既然已經有Set了,我們為什麼還要捨近求遠,使用數組呢?是不是在需要去重的情況下,直接使用Set就解決問題了?這個問題值得思考。

小結

最後,用一個測試用例總結一下文中出現的各種去重方法:

再談JavaScript數組去重

測試中沒有定義對象的比較方法,因此默認情況下,對象不去重是正確的結果,去重是不正確的結果。

再談JavaScript數組去重

最後的最後:任何脫離場景談技術都是妄談,本文也一樣。去重這道題,沒有正確答案,請根據場景選擇合適的去重方法。


分享到:


相關文章: