JavaScript
ECMAScript

JavaScriptの等値比較を全部理解する

皆さんこんにちは。今回の記事では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は結構使うことがあります。==nullundefinedを同一視するので、== 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になるため注意してください。

NaNの比較結果
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になります。
Object.isの例
// 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 + 00 + (-0)は+0ですが(-0) + (-0)は-0です。

このように、数としては同じ0でも計算の方法によって+0になったり-0になったりします。これらはどちらもゼロなのだから等しいとして扱いたいことが多いと思われますから、===は+0と-0を区別しないのです。これのおかげで、我々は普段の数値計算プログラムで+0と-0の違いを心配しなくても(滅多なことが無い限りは)大丈夫なのです。

その一方で、値として異なるのだから区別しなければいけないという場面がもしかしたらあるかもしれません。そのような場合はObject.isを使うことで+0と-0を区別した等値判定ができるというわけです。また、Object.isNaN同士の比較をちゃんとtrueにしてくれますから、数値計算がどうとかではなくとりあえず値として同じか調べたいというときには一番素直かもしれません。(その場合でも+0と-0を区別すべきかどうかは別に考える必要がありますが。)

余談ですが、Object.isに頼らずに+0と-0を区別する方法は一応あります。正の数をゼロで割ってみて、正の無限大になったら+0で負の無限大になったら-0です。

+0と-0の判別
console.log(1 / 0);  // Infinity
console.log(1 / -0); // -Infinity

これを使ってObject.isを再実装してみるとだいたいこんな感じになります。

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文の例
switch (userName) {
  case 'admin':
    console.log('ようこそ、管理者様');
    break;
  case 'John Smith':
    console.log('ようこそ、ジョンさん');
    break;
  default:
    console.log('お前誰?');
}

このように、与えられた値が特定の値であるときの処理を好きなだけ書くことができます。実はここで、暗黙のうちに等値判定が発生していますね。例えばuserName'admin'と等しいかどうかとか、userNameJohm 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を引用すると次の画像のようになっています。

Screenshot from Gyazo

特に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の式に副作用を仕込むと少しおもしろいです。

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が返ります。また、複数存在する場合は一番最初の番号が返されます。

indexOfの例
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を返すメソッドです。

includesの例
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 similar indexOf 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
NaNNaN 等しくない 等しい 等しい
+0-0 等しい 等しい 等しくない

Array#includesはこのSameValueZeroを使っているため、ちゃんとNaNを発見することができます。というか、indexOfは逆にNaNを発見できなかったことがちょっと驚きですね。

includesとindexOfが異なる例
var arr = [1, 2, NaN, 3];

console.log(arr.indexOf(NaN) >= 0); // false(indexOfはNaNを発見できないので)
console.log(arr.includes(NaN)); // true(includesはNaNを発見できる)

SameValueZeroはNaNNaNが等しい上に+0-0を等しいものとして扱うという、なんだか一番直感的な気がしないでもない等値比較です。残念ながらこれを直に行ってくれる関数は用意されていません。必要ならこんな感じで作るといいかもしれません。

function sameValueZero(left, right) {
  return [left].includes(right);
}

余談

これまた余談ですが、先ほど引用したNOTEの後半部分にもちょっと気になることが書いてありますね。詳しいことは省略しますが、これは穴あきの配列に対するindexOfincludesの挙動の違いを述べています。ここでは違いが見える例だけ載せておきます。

var arr = [0, , 2, 3, , 5];

console.log(arr.indexOf(undefined) >= 0); // false
console.log(arr.includes(undefined)); // true

Mapとか

では次の例に移りましょう。ES2015以降のJavaScriptにはMapが入っています。これはいわゆる辞書で、何らかの値(キー)に対して別の値を紐付けることができるものです。

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に類似するものとしてSetWeakMap、そしてWeakSetが存在します。すごく余談ですが、イテレータがまだ実装されていない時代にMapSetが無いのにWeakMapWeakSetがブラウザに実装されていた時代が懐かしいですね。

もうお分かりと思いますが、この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
暗黙の型変換 行う 行わない 行わない 行わない
NaNNaN 等しくない 等しくない 等しい 等しい
+0-0 等しくない 等しい 等しい 等しくない

また、暗黙に等値比較が行われる場面で使われる等値比較の種類は以下の通りでした。

場面 等値比較
switch ===
Array#indexOf, Array#lastIndexOf ===
Array#includes SameValueZero
Map SameValueZero

これを見ると、なんか昔は全部===で頑張ってたけどES2015のときにSameValueZeroとかいう便利なやつ追加したんでそれを使うようにしましたみたいな経緯が感じられますね。特に、Array#indexOfとかでNaNが検出できないのはやはり場合によってはつらいものがありした。

逆に、Object.is相当の比較は全然使われていませんね。やはり、+0-0が区別されるのはバグの元という考えのようです。それでも異なる値である以上区別する方法が無いと困るということで追加されたのがObject.isでしょう。

皆さんも普段何気なく行っている等値比較ですが、裏にはこのような事情があるということに思いを馳せつつコードを書いてみてはいかがでしょうか。

関連記事