ほんとにUnicodeコードポイントの昇順なの?


Arraysort() を使うと、配列の内容をソートできます。このメソッドは、引数に比較用の関数を渡すとソート順をカスタマイズできますが、何も渡さないとデフォルトの順序でソートされます。

console.log([1, 0, 11, 10, '01', '00'].sort());
// -> [0, "00", "01", 1, 10, 11]

デフォルトの順序では、数値でも文字列として比較されてるのが特徴です。これは『Unicodeコードポイント順になる』という表現で解説されてるサイトが多い気がします。

配列の要素をin placeでソートします。このソートは stable ではありません(訳注:同じ序列を持つ値の順番が保証されません)。 デフォルトではUnicodeコードポイントの昇順にソートされます。

が、コードポイントの昇順と言われると、個人的には↓の結果が引っかかります。

// これらを踏まえて……
console.log('_'.codePointAt(0)); // -> 65343
console.log('🐤'.codePointAt(0)); // -> 128036
console.log('🍆'.codePointAt(0)); // -> 127814
console.log('🍅'.codePointAt(0)); // -> 127813

console.log('_'.charCodeAt(0)); // -> 65343
console.log('🐤'.charCodeAt(0), '🐤'.charCodeAt(1)); // -> 55357 56356
console.log('🍆'.charCodeAt(0), '🍆'.charCodeAt(1)); // -> 55356 57158
console.log('🍅'.charCodeAt(0), '🍅'.charCodeAt(1)); // -> 55356 57157

// どうなるかな?
console.log(['_','🐤','🍆','🍅'].sort());
// -> ["🍅", "🍆", "🐤", "_"]

トマト → ナス → ひよこ はその通りですが、全角アンダースコアが最後になるのは『コードポイント順』って言われると不自然な気がします。これは単に文字コード順じゃないのか??

内部的にUTF-16で処理されてる言語だから、サロゲートペア使ってる文字を渡しても1バイト目で比較されるのはそういうもんだと思うのですが、でもそれならコードポイント順って表現が違うんじゃないかと思います。

仕様ではどういうことになってるんだろ? と思って仕様書見に行ってみたら、そもそもコードポイント順とは書かれてませんでした。英語読めないのですが、comparefnundefined のときは ToString して比較するって書いてある気がします。

ToString して比較されてることを確認するために、気持ち悪い配列を sort してみます。

console.log([
	null,
	'nall',
	undefined,
	'undafined',
	Infinity,
	'Infanity',
	'',
	'_',
	'🐤',
	'🍆',
	'🍅',
	'NuN',
	NaN,                  // .toString() ->  NaN
	[],                   // .toString() ->  (空文字)
	'1,1',
	[1,0],                // .toString() ->  1,0
	[0,1],                // .toString() ->  0,1
	'[object Object]_',
	{},                   // .toString() ->  [object Object]
	function a01() {},    // .toString() ->  function a01() {}
	function a1() {},     // .toString() ->  function a1() {}
	function a0() {},     // .toString() ->  function a0() {}
	'function a00() {}',
].sort());

/* -> [
	0: ""
	1: []
	2: (2) [0, 1]
	3: (2) [1, 0]
	4: "1,1"
	5: "Infanity"
	6: Infinity
	7: NaN
	8: "NuN"
	9: {}
	10: "[object Object]_"
	11: ƒ a0()
	12: "function a00() {}"
	13: ƒ a01()
	14: ƒ a1()
	15: "nall"
	16: null
	17: "undafined"
	18: "🍅"
	19: "🍆"
	20: "🐤"
	21: "_"
	22: undefined
] */

確かに toString 後に比較してソートされたような結果になってました。しかし null が「null」という文字列として比較されてるっぽいのは、初めて知りました。undefined はそうではないみたいなのですが。

調べてみたところ、nulltoString は無いけど、String(null) の結果は文字列の null になるようです。でも、それなら String(undefined) だって文字列の undefined になります。が、undefined が文字列として比較されてたらすごく嫌な気がするので、そういうものかもしれないです。

この結果をテストケースとして、sort のデフォルトの比較関数を再現してみたのが次のコードです。

/**
 * Array.prototype.sort のデフォルト比較関数を再現
 * @param {unknown} x
 * @param {unknown} y
 * @return {Number} x < y なら -1, x > y なら 1, x == y なら 0
 */
const compareFn = (x, y) => {
	if (x === undefined && y === undefined) {
		return 0;
	} else if (x === undefined) {
		return -1;
	} else if (y === undefined) {
		return 1;
	}
	const strX = String(x);
	const strY = String(y);
	if (strX < strY) {
		return -1;
	} else if (strX > strY) {
		return 1;
	}
	return 0;
};

// さっきと同じ配列
console.log([
	null, 'nall', undefined, 'undafined', Infinity, 'Infanity', '', '_', '🐤', '🍆', '🍅',
	'NuN', NaN, [], '1,1', [1,0], [0,1], '[object Object]_', {},
	function a01() {}, function a1() {}, function a0() {}, 'function a00() {}',
].sort(compareFn));

/* -> [
	0: ""
	1: []
	2: (2) [0, 1]
	3: (2) [1, 0]
	4: "1,1"
	5: "Infanity"
	6: Infinity
	7: NaN
	8: "NuN"
	9: {}
	10: "[object Object]_"
	11: ƒ a0()
	12: "function a00() {}"
	13: ƒ a01()
	14: ƒ a1()
	15: "nall"
	16: null
	17: "undafined"
	18: "🍅"
	19: "🍆"
	20: "🐤"
	21: "_"
	22: undefined
] */

なんかトリッキーな気がしてたのですが、こうして見るとシンプルです。

というわけでこの記事の結論では、Array.prototype.sort() のデフォルト順は、undefined 以外の値を String 型にキャストして比較演算子で比較した結果の昇順です。もしくは文字コード (UTF-16コード) の昇順です。