皆さんこんにちは。今回の記事ではJavaScriptの等値比較について見ていこうと思います。
「どうせ==
と===
の違いとかだろ? 今さらそんな記事書くなよバーカw」と思った人はぜひ期待せずに読み進めてみてください。
「じゃあObject.is
でしょ? 知ってる知ってる、使ったことないけど」と思った人はまあ読まなくても大丈夫です。
さて、等値比較というのは、2つの値が等しいかどうか判定することです。JavaScriptにおいて等値比較はどのように行うのか、そしてどのような場面で等値比較が発生するのかをこの記事では余すことなく紹介します。
以降、この記事で仕様書という場合はECMAScript® 2018 Language Specificationを指すものとします。
==
と===
とはいえ、まずは==
と===
の話をしないことには始まりません。==
はJavaScript初心者がとりあえず習う等値比較の演算子ですが、その実あまり使われることはありません。実際にはほとんど===
が使われます。
その理由は、==
には暗黙の型変換機能が付いているからです。数値と文字列の比較では両方が文字列に変換されたり、プリミティブとオブジェクトを比較するとオブジェクトがプリミティブに変換されたりします。(プリミティブとは、数値や文字列などの、オブジェクト以外の値のことです。)
console.log(3 == "3"); // true("3"が3に変換されるので)
console.log(1000 == "1e3"); // true("1e3"が1000に変換されるので)
console.log(3 === "3"); // false(数値と文字列で型が違うので)
console.log(1000 === "1e3"); // false(数値と文字列で型が違うので)
==
の挙動については以下の記事でも解説しましたので、気になる方は目を通してみてください。
==
は型変換機能がやっかいなのであまり使いませんが、== null
は結構使うことがあります。==
はnull
とundefined
を同一視するので、== null
は(null
またはundefined
である)という意味の比較になるわけですが、この2種類の値は「プロパティを参照するとエラーが発生する値である」という点で共通しているため、これらの値をまとめて弾きたいときに重宝します。実際、eslintで===の使用を強制するeqeqeqルールでもnull
の場合は許可する"null"
オプションが用意されています。
等値比較の基礎
特に===
はJavaScriptにおける基本的な等値比較ですから、これの挙動をもう少し解説します。といっても、基本的には値が同じならtrueで違ったらfalseというわけですが、
まず、オブジェクトは参照として比較されます。つまり、===
でオブジェクト同士を比較する場合、参照として同じオブジェクトのときのみtrueとなります。以下の例で見るように、形だけ同じ場合はfalse
となります。
const obj1 = { foo: 'bar' };
const obj2 = { foo: 'bar' };
console.log(obj1 === obj2); // falseになる(同じ形のオブジェクトでも別々に作ったら別のオブジェクトなので)
const obj3 = { hoge: 123 };
const obj4 = obj3;
console.log(obj3 === obj4); // true(obj3とobj4は参照として同じなので)
JavaScriptでは配列もオブジェクトの一種なので、配列についても同じことがいえます。
const arr1 = [0, 1, 2];
const arr2 = [0, 1, 2];
console.log(arr1 === arr2); // false
プリミティブの比較も普通です。値として同じものを===
で比較したときのみtrue
になります。
console.log(3 === 3); // true(同じ値を比較すると当然true)(なんかあほみたいな例ですが)
console.log(30 === 300); // false
console.log('foo' === 'FOO'); // false
console.log(3000 === '3000'); // false(型が違うものは全部false)
ただし、NaN
についてはNaN === NaN
の結果がfalse
になるため注意してください。
const num1 = Number.NaN;
console.log(num1 === Number.NaN); // false
NaN
のこの挙動についてはJavaScriptがおかしいわけではなく、浮動小数点数(IEEE 754)の仕様から来るものです。
Object.is
さて、等値比較の基本が分かったところで、上でちらっと出てきたObject.is
の紹介に移ります。Object.is
はES2015で追加された比較的新しい機能です。
このObject.is
は渡された2つの値が等しいかどうか調べる関数です。この関数による等値判定は基本的には===
と同じです。まあ、やり方によって等値性がころころ変わってもらっては使い物にならないので当然ですが。
とはいえ、Object.is
は===
と少しだけ異なるところがあり、ある意味で**===
よりもさらに厳密な等値判定**が可能になっています。Object.is
が===
と異なるのは次の2点だけです。
-
NaN
同士を比較するとtrue
になります。 -
+0
と-0
を比較するとfalse
になります。
// NaNに対する動作の違い
console.log(Object.is(NaN, NaN)); // true
console.log(NaN === NaN); // false
// +0と-0の区別
console.log(Object.is(+0, -0)); // false
console.log(+0 === -0); // true(===は+0と-0を同じとみなす)
それ以外の点ではObject.is
は===
と同じです。暗黙の型変換は行なわれず、オブジェクトは参照として比較されるなどの点も同じです。
ここで、一部の方は「おいおいちょっと待て、+0と-0って何やねん」と思っているかもしれません。実はJavaScriptにはゼロが+0と-0の2種類あるのです(実はこれもJavaScriptには、というよりはIEEE 754浮動小数点数に存在する概念です)。
我々が普通に0
と書いたときに得られるのは+0です。また、-5 + 5
などの計算で得られるのも+0です。一方で、「負の数 × +0」や「正の数 × -0」という掛け算の結果としては-0が得られます。もちろん割り算も同様です。また、細かいところでは、-0 + 0
や0 + (-0)
は+0ですが(-0) + (-0)
は-0です。
このように、数としては同じ0でも計算の方法によって+0になったり-0になったりします。これらはどちらもゼロなのだから等しいとして扱いたいことが多いと思われますから、===
は+0と-0を区別しないのです。これのおかげで、我々は普段の数値計算プログラムで+0と-0の違いを心配しなくても(滅多なことが無い限りは)大丈夫なのです。
その一方で、値として異なるのだから区別しなければいけないという場面がもしかしたらあるかもしれません。そのような場合はObject.is
を使うことで+0と-0を区別した等値判定ができるというわけです。また、Object.is
はNaN
同士の比較をちゃんとtrue
にしてくれますから、数値計算がどうとかではなくとりあえず値として同じか調べたいというときには一番素直かもしれません。(その場合でも+0と-0を区別すべきかどうかは別に考える必要がありますが。)
余談ですが、Object.is
に頼らずに+0と-0を区別する方法は一応あります。正の数をゼロで割ってみて、正の無限大になったら+0で負の無限大になったら-0です。
console.log(1 / 0); // Infinity
console.log(1 / -0); // -Infinity
これを使ってObject.is
を再実装してみるとだいたいこんな感じになります。
function Object_is(left, right) {
// 左辺がNaNのときの処理
if (Number.isNaN(left)) {
// 右辺もNaNのときのみtrue
return Number.isNaN(right);
}
// 両辺が0または-0のときの処理
if (left === 0 && right === 0) {
// 上述の方法で+0と-0を判別
const leftSign = 1 / left;
const rightSign = 1 / right;
return leftSign === rightSign;
}
// 他の場合は===と同じ動作
return left === right;
}
以上で、JavaScriptで等値比較を行う3種類の方法、すなわち==
と===
とObject.is
を紹介しました。この記事の話題はもう1つあります。
等値比較が発生する場面
上で紹介したような手法を使う以外にも、半ば暗黙に等値比較が行なわれる場面はあります。ここでは、そのような場合にどのような等値比較が行なわれるのかを紹介します。
switch文
switch
文を使ったことがある方は多いと思います。switch
文はこういうやつです。
switch (userName) {
case 'admin':
console.log('ようこそ、管理者様');
break;
case 'John Smith':
console.log('ようこそ、ジョンさん');
break;
default:
console.log('お前誰?');
}
このように、与えられた値が特定の値であるときの処理を好きなだけ書くことができます。実はここで、暗黙のうちに等値判定が発生していますね。例えばuserName
が'admin'
と等しいかどうかとか、userName
がJohm Smith'
と等しいかどうかが判定されます。では、ここでの等値判定はどの等値判定でしょうか。
答えは===
です。上の例ではまずuserName === 'admin'
という比較が行なわれ、その次にuserName === 'John Smith'
という比較が行なわれることになります。以下の例で確かめてみましょう。
switch(0) {
case "0":
console.log("これは表示されない(数値と文字列を===で比較するとfalseなので)");
break;
case -0:
console.log("これは表示される(+0と-0を===で比較するとtrueなので)");
}
ちなみに、switchの比較が===
で行なわれることはどうやって確かめるのでしょうか。答えはもちろん仕様書です。switchの評価手順(13.12.11)を見ると、実際の処理は13.12.9 Runtime Semantics: CaseBlockEvaluationで行なわれていることが分かります。その中でも、case
を実行するかどうか判定するCaseClauseIsSelectedを引用すると次の画像のようになっています。
特に4の部分を見ると、===
で比較するとちゃんと書いてありますね。
4. Return the result of performing Strict Equality Comparison input === clauseSelector.
余談
これは本題とは関係ありませんが、上の引用部分の2を見ると、case
のあとに書かれた式を評価していることが分かります。
2. Let exprRef be the result of evaluating the Expression of C.
そして、switch文の一致判定は上から順番に評価されます。また、switch文は一度case
に合致したあとは(break
でswitch文から抜けない限り)以降のcase
も全部実行するという動作をしますから、一度合致したあとは次以降のcase
文の式を評価する必要はありません。
以上のことから、case
の式に副作用を仕込むと少しおもしろいです。
// aを参照するたびにconsole.logしてaの値が1増えるオブジェクト
var obj = {
_a: 0,
get a() {
console.log('a is', this._a);
return this._a++;
},
};
switch(5) {
case obj.a:
case obj.a:
case obj.a:
case obj.a:
case obj.a:
case obj.a:
case obj.a:
case obj.a:
case obj.a:
case obj.a:
}
console.log('the final value of a is', obj._a);
/* コンソールの出力:
a is 0
a is 1
a is 2
a is 3
a is 4
a is 5
the final value of a is 6
*/
この例ではobj.a
が6回評価されていることが分かります。
Array#indexOf
さて、本題に戻りましょう。前の節ではswitch
文による比較が===
で行なわれることを見ました。次は配列のindexOf
メソッドです。これは、指定した値の配列内での位置を返すメソッドです。存在しなかったら-1
が返ります。また、複数存在する場合は一番最初の番号が返されます。
var arr = [1, 1, 2, 3, 5, 8, 13];
console.log(arr.indexOf(5)); // 4
このメソッドでも等値比較が行われていることは明らかですね。この場合、配列の各要素が与えられた5
と比較されて一致したらその要素番号が返されます。では、これはどのような等値比較が行なわれているのでしょうか。
答えはこれまた===
です。もう一々仕様書を引用したりしませんが、仕様書の22.1.3.12節に書いてあります。
また、左からではなく右から検索するArray#lastIndexOf
についても同じく===
です。まあ、ここが違ったらとても混乱しますので困りますが。
Array#includes
また、indexOf
とちょっと似ているメソッドとしてincludes
があります。これは、指定した要素が配列に含まれていたらtrue
、含まれていなかったらfalse
を返すメソッドです。
var arr = [1, 1, 2, 3, 5, 8, 13];
console.log(arr.includes(5)); // true(5は含まれているので)
console.log(arr.includes(10)); // false(10は含まれていないので)
これはES2016で追加されたちょっと新しいメソッドです。これもindexOf
と同様に等値比較を行うメソッドです。なぜなら、値が値に含まれているか判定するには、配列の各値が与えられた値と一致するかどうかを判定しなければならないからです。
どうでしょうか、「は? こんなんarr.indexOf(5) >= 0
とかでええやん」と思いましたか? しかし、実は違うんです。なんと、Array#includes
が行う比較は===
ではないのです。仕様書の22.1.3.11節から引用します。
NOTE 3 The
includes
method intentionally differs from the similarindexOf
method in two ways. First, it uses the SameValueZero algorithm, instead of Strict Equality Comparison, allowing it to detect NaN array elements. Second, it does not skip missing array elements, instead treating them as undefined.
(意訳)
includes
メソッドはindexOf
とは意図的に異なる仕様にしている部分が2つあります。1つは===
ではなくSameValueZeroアルゴリズムを使って等値比較することによってNaN
を発見できるようにしている点です。もうひとつは存在しない要素を飛ばすのではなくundefinedとして扱う点です。
特に前半部分に目を向けると、SameValueZeroアルゴリズムを使って等値比較を行うと書いてあります。このSameValueZeroというのが、==
とも===
ともObject.is
とも異なる第4の等値比較です。
SameValueZeroは===
とほとんど同じですが、NOTEに書いてある通りNaN同士の比較がtrueになる点が異なります。ただし、===
と同様に**+0と-0は等しいものとして扱う**ことになっており、ここがObject.is
と異なる点です。表にまとめるとこのようになります。
=== |
SameValueZero | Object.is |
|
---|---|---|---|
NaN とNaN
|
等しくない | 等しい | 等しい |
+0 と-0
|
等しい | 等しい | 等しくない |
Array#includes
はこのSameValueZeroを使っているため、ちゃんとNaN
を発見することができます。というか、indexOf
は逆にNaN
を発見できなかったことがちょっと驚きですね。
var arr = [1, 2, NaN, 3];
console.log(arr.indexOf(NaN) >= 0); // false(indexOfはNaNを発見できないので)
console.log(arr.includes(NaN)); // true(includesはNaNを発見できる)
SameValueZeroはNaN
とNaN
が等しい上に+0
と-0
を等しいものとして扱うという、なんだか一番直感的な気がしないでもない等値比較です。残念ながらこれを直に行ってくれる関数は用意されていません。必要ならこんな感じで作るといいかもしれません。
function sameValueZero(left, right) {
return [left].includes(right);
}
余談
これまた余談ですが、先ほど引用したNOTEの後半部分にもちょっと気になることが書いてありますね。詳しいことは省略しますが、これは穴あきの配列に対するindexOf
とincludes
の挙動の違いを述べています。ここでは違いが見える例だけ載せておきます。
var arr = [0, , 2, 3, , 5];
console.log(arr.indexOf(undefined) >= 0); // false
console.log(arr.includes(undefined)); // true
Map
とか
では次の例に移りましょう。ES2015以降のJavaScriptにはMap
が入っています。これはいわゆる辞書で、何らかの値(キー)に対して別の値を紐付けることができるものです。
var map = new Map();
// 2をキーとして対応する値を登録
map.set(2, "two");
// getメソッドでキーに対応する値を取り出せる
console.log(map.get(2)); // "two"
var obj = { foo: 'bar' };
// オブジェクトもキーに使える
map.set(obj, "オブジェクト");
console.log(map.get(obj)); // "オブジェクト"
// 存在しないキーでgetを行うとundefinedが返る
var obj2 = { foo: 'bar' };
console.log(map.get(obj2)); // undefined
特徴は、キーとしてどんな値でも使うことができ、オブジェクトでさえキーにすることができるという点です。
また、Map
に類似するものとしてSet
やWeakMap
、そしてWeakSet
が存在します。すごく余談ですが、イテレータがまだ実装されていない時代にMap
やSet
が無いのにWeakMap
やWeakSet
がブラウザに実装されていた時代が懐かしいですね。
もうお分かりと思いますが、このMapもやはり等値比較を行います。get
メソッドが呼ばれたとき、それと同じ値をキーとして登録されているデータを探さなければいけませんからね。
さっさと答えに行きましょう。このときの比較はさっき紹介したSameValueZeroです。NaNもキーにできて安心ですね。
var map = new Map();
map.set(NaN, 'I am NaN');
map.set(0, 'I am zero');
console.log(map.get(NaN)); // "I am NaN"
console.log(map.get(-0)); // "I am zero"
JavaScriptで等値比較の方法が気になる場面はこれくらいのはずです。
まとめ
ということで、冒頭で紹介した==
, ===
, Object.is
の他に実はもう1種類の等値比較、SameValueZeroがあることが明らかになりました。表でまとめ直してみます。
== |
=== |
SameValueZero | Object.is |
|
---|---|---|---|---|
暗黙の型変換 | 行う | 行わない | 行わない | 行わない |
NaN とNaN
|
等しくない | 等しくない | 等しい | 等しい |
+0 と-0
|
等しい | 等しい | 等しい | 等しくない |
また、暗黙に等値比較が行われる場面で使われる等値比較の種類は以下の通りでした。
場面 | 等値比較 |
---|---|
switch 文 |
=== |
Array#indexOf , Array#lastIndexOf
|
=== |
Array#includes |
SameValueZero |
Map 系 |
SameValueZero |
これを見ると、なんか昔は全部===
で頑張ってたけどES2015のときにSameValueZeroとかいう便利なやつ追加したんでそれを使うようにしましたみたいな経緯が感じられますね。特に、Array#indexOf
とかでNaN
が検出できないのはやはり場合によってはつらいものがありした。
逆に、Object.is
相当の比較は全然使われていませんね。やはり、+0
と-0
が区別されるのはバグの元という考えのようです。それでも異なる値である以上区別する方法が無いと困るということで追加されたのがObject.is
でしょう。
皆さんも普段何気なく行っている等値比較ですが、裏にはこのような事情があるということに思いを馳せつつコードを書いてみてはいかがでしょうか。
関連記事
-
JavaScriptのプリミティブへの変換を完全に理解する 本文中でも紹介しましたが、この記事であまり触れなかった
==
の挙動(暗黙の型変換)について詳しく述べた記事です。