【JavaScript】配列のソートを極める – sort() から localeCompare() まで

配列のソートが必要になることは多いのですが、コードの書き方を意外に忘れてしまいます。また、実は非常に不効率なソートを行っている可能性もあります。今回は、sort() メソッドの基本的な使い方から、localeCompare() メソッドを使った高度な並べ替えまで、配列の様々なソートのケースを網羅的に見ていきます。

sort() メソッドの基本

まずは、もっともオーソドックスな sort() メソッドの基本的な使い方から見ていきましょう。次のサンプルコードは誰もが期待する通りの結果になります。

const list = ['Taro', 'Jiro', 'Saburo'];
list.sort();
console.log(JSON.stringify(list));
["Jiro","Saburo","Taro"]

sort() メソッドは基本的にはアルファベットを昇順に並べ替えます。また、sort() メソッドはソート済みの新たな配列を返すのではなく、オリジナルの配列をダイレクトにソートしてしまいます。この点、忘れがちなので注意しましょう。

sort() メソッドの間違いやすいポイント

前述のように sort() メソッドをシンプルに使った場合に陥りやすい間違いが 2 つありますので紹介します。

数値のソートの間違い

次のサンプルコードの結果は期待通りでしょうか?

const list = [3, 1, 21];
list.sort();
console.log(JSON.stringify(list));
[1,21,3]

恐らく [1, 3, 21] という結果を期待した人もいるのではないでしょうか。実は sort() メソッドは配列の要素の値をいったん文字列にしてから並べ替えを行っています。そのため、このサンプルコードでも、数値比較ではなく文字列比較になっていたのです。

日本語のソートの間違い

sort() メソッドは配列の要素を文字列として並べ替えるのですが、正確に言うと、UTF-16 コードの値を昇順に並べ替えます。つまり、コードの値を小さい順に並べ替えます。半角英数字であれば期待通りの順番になりますが、日本語の場合は、少し期待と違う部分があります。

日本語の文字で順番を気にするとしたら、まず思い浮かぶのが全角英数字、ひらがな、カタカナ、漢数字ですね。UTF-16 コードの値で小さい順に並べると、次の順番になります。

  1. 全角数字(0~9)
  2. 全角大文字アルファベット(A~Z)
  3. 全角小文字アルファベット(a~z)
  4. ひらがな(ぁあ~をん)
  5. 全角カタカナ(ァア~ヲンヴヵヶ)
  6. 漢数字

ここまでは、各グループの中も含めて期待通りですね。とりわけ、英数字に関しては半角英数字と同じ順番なので分かりやすいですね。ところが、漢数字の中を見ると期待通りの順番ではないんです。さらに大字(壱、弐、参など)が混じると、ややこしくなります。

const list = ['零', '一', '壱', 'ニ', '弐', '三', '参', '四', '五', '伍', '六', '七', '八', '九', '十', '拾'];
list.sort();
console.log(JSON.stringify(list));
["ニ","一","七","三","九","五","伍","八","六","十","参","四","壱","弐","拾","零"]

当然ですが、UTF-16 で漢数字は数字として定義されているのではなく、あくまでも漢字として定義されています。漢数字が表す数字の順にコードが割り振られているわけではありません。間違っても漢数字を sort() メソッドでソートすることがないよう、注意しましょう。

昇順と降順

前述の通り、sort() メソッドは配列の要素を昇順に並べ替えます。つまり小さい順です。もし降順、つまり大きい順に並べ替えたい場合は、どうするのが良いでしょうか。残念ながら、sort() メソッドには昇順と降順を切り替えるようなフラグオプションはありません。

もっともオーソドックスな方法は reverse() メソッドを使う方法でしょう。

let list = ['Taro', 'Jiro', 'Saburo'];
list.sort();
list = list.reverse();
console.log(JSON.stringify(list));
["Taro","Saburo","Jiro"]

もう一つの方法は sort() メソッドの比較関数を使う方法です。

let list = ['Taro', 'Jiro', 'Saburo'];
list.sort((a, b) => { return (b > a) ? 1 : -1 });
console.log(JSON.stringify(list));

初めて比較関数を見た人にとっては分かりづらいかもしれませんね。詳しく見ていきましょう。

比較関数

sort() メソッドの引数には比較関数を与えることができるのですが、これは sort() メソッドのデフォルトの並べ替えルールを自作するために使います。前述の通り、sort() メソッドのデフォルトの並べ替えルールは、文字列の昇順でしたね。そのデフォルトと違う並べ替えを行いたい場合は、この比較関数を使わざるを得ません。

では、数値の配列を小さい順に並べ替えてみましょう。

const list = [3, 1, 21];
list.sort((a, b) => { return a - b });
console.log(JSON.stringify(list));
[1,3,21]

sort() メソッドの引数に引き渡した比較関数に注目してください。

(a, b) => { return a - b }

比較関数には、2 つの引数が引き渡されます。ここでは変数 ab で受けています。そして、比較関数は、その 2 つの引数の値の差を返しています。これがどのような意味を持つのかを詳しく見ていきましょう。

ブラウザーは配列の要素の並べ替えのために、配列の中の 2 つの要素を必要な組み合わせで何度も比較関数を呼び出します。配列のどの値が ab に格納されるのかは私たちは知る必要はありません。私たちが知っておくべきことは、この比較関数でどんな値を返せばよいかだけです。

もし並べ替えたときに ab より前に配置したいなら負の数値を返します。そして、ab より後ろに配置したいなら正の数値を返します。ab が並べ替えにおいて同じであれば 0 を返します。

これを踏まえて、先ほどの数値を小さい順に並べ変える比較関数を眺めてみましょう。その比較関数では a - b の計算結果を返していました。もし a=3, b=1 なら比較関数は a - b = 2 で正の数値を返しますから、a=3b=1 より後ろに配置されることになります。もし a=1, b=21 なら比較関数は a - b = -20 で負の数値を返しますから、a=1b=21 より前に配置されることになります。つまり、数値の昇順(小さい順)に並べ替えられることになります。

この例を、if 文を使って初心者にも分かりやすいように書くと次のようになります。

const list = [3, 1, 21];
list.sort((a, b) => {
  if(a < b) {
    return -1;
  } else if(a > b) {
    return 1;
  } else {
    return 0;
  }
});

このように比較関数を使うことで、並べ替えルールがプログラミングで定義可能であれば、どんな並べ替えルールでも作り上げることができるようになります。

余談にはなりますが、比較関数がどの 2 つの要素を取り出して何度呼び出されるかを調べてみましょう。次のサンプルコードを見てください。

const list = [1, 2, 3, 4, 5];
list.sort((a, b) => {
  console.log(`a=${a}, b=${b}`);
  return a - b;
});

比較関数に引き渡される ab の値を出力しています。Forefox で試すと次の結果を出力します。配列の順番通りに 2 つの要素を取り出して 4 回関数を呼び出しています。

a=1, b=2
a=2, b=3
a=3, b=4
a=4, b=5

ちなみに、Chrome や Edge では、ab が逆になっています。非常に興味深いですね。もちろん、こんなことは知っている必要はありません。

a=2, b=1
a=3, b=2
a=4, b=3
a=5, b=4

もう少し意地悪な数値配列で試してみましょう。

const list = [3, 1, 21, 9, 5];
list.sort((a, b) => {
  console.log(`a=${a}, b=${b}`);
  return a - b;
});

Firefox だと、次のように出力します。

a=3, b=1
a=3, b=21
a=21, b=9
a=3, b=9
a=21, b=5
a=9, b=5
a=3, b=5

このように元の配列の要素の並び順が変換後の並び順と大きく違うと、その分、比較関数の呼び出し回数も増えているのが分かりますね。

オブジェクトの配列をソート

比較関数を理解したところ、より複雑な並べ替えを試してみましょう。次の配列を考えてみます。

const list = [
  { name: 'Taro', weight: 78 },
  { name: 'Hanako', weight: 54 },
  { name: 'Jiro', weight: 65 }
];

これはオブジェクトを要素とした配列です。このオブジェクトの weight の値で降順(値が高い順)に配列を並べ替えるとしたら、次のようになります。

list.sort((a, b) => {
  return b.weight - a.weight; 
});

これを応用すれば、どれだけ階層が深い値での比較であろうが、並べ替えすることができるわけです。

オブジェクトの複数のキーでソート

前述の例ではオブジェクトの一つのキーでソートしましたが、複数のキーでソートすることも可能です。次の配列を考えてみましょう。

const list = [
  { name: 'Taro', date: '2021-12-02', weight: 78.1 },
  { name: 'Hanako', date: '2021-12-02', weight: 54.2 },
  { name: 'Taro', date: '2021-12-03', weight: 77.9 },
  { name: 'Hanako', date: '2021-12-03', weight: 54.8 },
  { name: 'Taro', date: '2021-12-04', weight: 77.2 },
  { name: 'Hanako', date: '2021-12-04', weight: 54.4 }
];

この配列を、まず name で昇順にします。そして同じ name の中では date を降順に並べたいとします。つまり、配列 list を次のように並べ替えたいとします。

[
  { name: 'Hanako', date: '2021-12-04', weight: 54.4 }
  { name: 'Hanako', date: '2021-12-03', weight: 54.8 },
  { name: 'Hanako', date: '2021-12-02', weight: 54.2 },
  { name: 'Taro', date: '2021-12-04', weight: 77.2 },
  { name: 'Taro', date: '2021-12-03', weight: 77.9 },
  { name: 'Taro', date: '2021-12-02', weight: 78.1 },
];

これは次のコードで実現できます。

list.sort((a, b) => {
  // name を比較
  if(a.name < b.name) {
    return -1
  } else if(a.name > b.name) {
    return 1;
  }

  // date を比較
  if(a.date < b.date) {
    return 1
  } else if(a.date > b.date) {
    return -1;
  }

  // いずれにも当てはまらなければ 0 を返す
  return 0;
});

このコードを詳しく見てみましょう。まず name で比較します。ただし、name が同じ場合は return で戻さない点に注目してください。次に date で比較します。ここでも date が同じなら return で戻しません。これまでの条件のいずれにも一致しない場合、最後に 0 を返します。

文字列と数値の組み合わせをソート

もう少し難しい文字列のソートを考えてみましょう。次の配列をご覧ください。

const list = ['Rev 1.3', 'Rev 10.0', 'Rev 0.1'];

この文字列の配列を、数値の部分を見て小さい順に並べたいとしましょう。難しくないですか?やりかたはいくつかあります。まずは良くある間違いから見てみましょう。

list.sort((a, b) => {
  return parseFloat(a) - parseFloat(b);
});

無理やり数値として比較してしまえばよいと考えたのでしょう。しかし、残念ながら、”Rev ” を勝手に取り除いて数値として比較はしてくれません。先頭が数字でない文字列を強引に parseFloat() しても数値に変換されず NaN になるだけです。したがって、並べ替えが発生しません。この結果は次のようになります。

["Rev 1.3","Rev 10.0","Rev 0.1"]

次に、これまでの知識だけで頑張ってコードを書いてみましょう。恐らくこのような感じになるのではないでしょうか。

list.sort((a, b) => {
  return parseFloat(a.substr(4)) - parseFloat(b.substr(4));
});

これで期待通りに並べ替えができます。結果は次の通りになります。

["Rev 0.1","Rev 1.3","Rev 10.0"]

localeCompare() メソッドでエレガントに

前述のソートは、localeCompare() メソッドを使うと、もう少しエレガントに実現できます。

const list = ['Rev 1.3', 'Rev 10.0', 'Rev 0.1'];
list.sort((a, b) => {
  return a.localeCompare(b, 'en', { numeric: true });
});

localeCompare() メソッドは String オブジェクトのメソッドです。つまり文字列に使えるメソッドです。そして、第一引数と比べて並び順が前に来るのか後ろに来るのかを判定してくれます。もし並び順が前に来るなら負の数値を、並び順が後ろに来るなら正の数値を返します。並び順が同じなら 0 を返します。まさに sort() メソッドの比較関数のために用意されたといっても過言ではありませんね。

具体的にどんな数値が返るのかはブラウザーによって違いますので、その絶対値には意味がありません。私たちが気にするべきことは、その戻り値が正か負か 0 かだけです。

localeCompare() メソッドは名前の通り、ロケール、つまり言語特有の並び順を考慮したメソッドです。 localeCompare() メソッドの第二引数にはロケールを指定します。ここでは en、つまり英語を指定しています。

そして、第三引数には比較のアルゴリズムのオプションを指定することができます。いくつかのオプションが用意されているのですが、ここでは numeric プロパティを使い、true をセットしています。これによって比較対象の値を強引に数値とみなして比較することになります。

localeCompare() メソッドのオプション

localeCompare() メソッドのオプションには、前述の numeric だけでなく、いくつかのオプションが用意されています。ここでは良く使うものに絞って解説します。すべてのオプションを知りたい方は MDN の Intl.Collator() コンストラクターをご覧になると良いでしょう。

sensitivity

欧米の言語を例にとれば、「a、A、á」を区別するのかしないのかを決めます。日本語を例にとれば、「は、ば、ぱ」を区別するのかしないのかを決めます。sensitivity に指定できる値は次の通りです。

説明
base大文字小文字の違い、アクセント記号の違いは同じ文字とみなします。「a = A = á」、「は = ば = ぱ」です。
accent大文字小文字の違いは同じ文字とみなしますが、アクセント記号の違いは異なる文字とみなします。a = A ですが、a ≠ á です。日本語の場合は 「 は ≠ ば ≠ ぱ 」です。
case大文字小文字の違いは異なる文字とみなしますが、アクセント記号の違いは同じ文字とみなします。 a ≠ A ですが、a = á です。
variant 大文字小文字の違い、アクセント記号の違いは異なる文字とみなします。 「a ≠ A ≠ á」、「 は ≠ ば ≠ ぱ 」 です。

欧米のアルファベットは分かりやすいのですが、日本語の場合は分かりづらいですね。上表では「は、ば、ぱ」を比較しましたが、ここにカタカナを加えると結果に混乱します。例えば、どの値を指定しても、「は」と「ハ」は同じ文字として評価されてしまいます。日本語で sensitivity を使えるシーンは限られるかもしれませんね。

ignorePunctuation

true をセットすると、句読点を無視して比較します。

console.log('.'.localeCompare(',', 'en', { ignorePunctuation: true }));  // 0
console.log('。'.localeCompare('、', 'ja', { ignorePunctuation: true })); // 0

この例では句読点のみを比較しています。いずれも句読点を無視して比較しているため空文字列を比較したことになり同等と評価されます。つまり 0 が出力されます。

numeric

true をセットすると、文字列であっても数値として比較します。

console.log('国道21号'.localeCompare('国道8号', 'ja', { numeric: true })); // 1 (国道8号 国道21号)

caseFirst

大文字と小文字のどちらを先に並べるのかを指定します。”upper” を指定すれば大文字を、”lower” を指定すれば小文字を先に並べます。”false” を指定すると各言語のデフォルトの挙動になりますが、英語(en)や日本語(ja)では小文字を先に並べます。なお、”false” は Boolean の false ではなく、文字列の “false” ですので注意してください。

console.log('A'.localeCompare('a', 'en', { caseFirst: 'upper' })); // -1 (A a)
console.log('A'.localeCompare('a', 'en', { caseFirst: 'lower' })); //  1 (a A)
console.log('A'.localeCompare('a', 'en', { caseFirst: 'false' })); //  1 (a A)

まとめ

配列のソートは、比較関数の使い方さえ理解できれば、やりたいことはほぼできるでしょう。localeCompare() も使いこなせるようになると、より柔軟なソートが実現できます。回りくどいソートのコードがスッキリとするのではないでしょうか。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share