【2021/9/13 追記】
既に Stage 4 になっているので諦めていたんですが、流石に見逃せないかなと思ったので TC39 の Discourse にトピックをたててみました。意見がある方はこちらにお願いします。
https://es.discourse.group/t/fix-at/983
議論に伴って私が実際に欲しかったものをモジュールにして公開してみました。
https://github.com/petamoriken/safe-at
それといまいちユーザーからの声が伝わっていない感じがしたのでハッシュタグ #fix_ecmascript_at を用意してみました。協力をよろしくおねがいします。
【2021/9/13 追記2】
String#char{At, CodeAt}
という存在を忘れてたんですが、この似た名前のメソッドたちが引数を整数に丸めるのに String#at
が丸めないのはたしかに変だということに気づいてしまったので、自分からはこれ以上はこの件については口をつぐむことにしました1。
プロパティアクセスに対したシンタックスシュガーが欲しければ、何か別の提案としてあげる必要がありそうですね……。
【2021/9/16 追記】
at
メソッドを一律で禁止するよりかは数値リテラルは許容するといいのではという指摘を受けました。確かにその通りだと思います。
折角なので eslint のルールとして書いてみました。ご自由にお使いください。
{
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.property.name='at']:not([arguments.0.type='Literal'],[arguments.0.type='UnaryExpression'][arguments.0.argument.type='Literal'])",
"message": "at method accepts only a literal argument"
}
]
}
【追記ここまで】
これについて解説します。ちなみに答えは 1
です。
配列のプロパティアクセスについて
JavaScript においてオブジェクトのプロパティにアクセスするキーは文字列(とシンボル)だけとなっています。その他の値をキーに入れた場合、文字列に暗黙的型変換されます2。ちなみに配列はオブジェクトの一種です。
const arr = [1, 2, 3];
console.log(arr["0"]); // => 1
console.log(arr["1"]); // => 2
console.log(arr["10"]); // => undefined
// 以下キーが "0" に変換されて 1 を返す
console.log(arr[0]);
console.log(arr[-0]);
console.log(arr[0n]);
console.log(arr[{ toString() { return "0"; } }]);
// プロパティが存在しないため undefined を返す
console.log(arr["foo"]);
console.log(arr[NaN]); // arr["NaN"]
console.log(arr[1.5]); // arr["1.5"]
配列の要素を後ろからアクセスしたい場合は Array#length
を使います。
const arr = [1, 2, 3];
console.log(arr[arr.length - 1]); // => 3
console.log(arr[arr.length - 2]); // => 2
ES2022 Array#at
について
配列を後ろからアクセスするのにわざわざ Array#length
を使わないといけないのが煩わしいと長い間言われてきました。そこで導入されたのが ES2022 Array#at
です3。引数に負の数を入れると後ろからアクセスできます。
const arr = [1, 2, 3];
console.log(arr.at(0)); // => 1
console.log(arr.at(1)); // => 2
console.log(arr.at(10)); // => undefined
console.log(arr.at(-1)); // => 3
console.log(arr.at(-2)); // => 2
これでわざわざ長ったらしく記述する必要がなくなりました。
奇妙な動作
ところで Array#at
では通常のプロパティアクセスとは異なり、以下のような奇妙な事が起きます。
const arr = [1, 2, 3];
console.log(arr.at("foo")); // => 1
console.log(arr.at(NaN)); // => 1
console.log(arr.at(1.5)); // => 2
どうしてこのような事が起きるのでしょうか。
通常のプロパティアクセスでは文字列に変換されるところですが、Array#at
では負の数の対応をしないといけないためその前に引数を数値に変換する必要があります。仕様を見てみましょう。
Array.prototype.at ( index )
1. Let O be ? ToObject(this value).
2. Let len be ? LengthOfArrayLike(O).
3. Let relativeIndex be ? ToIntegerOrInfinity(index).
4. If relativeIndex ≥ 0, then
a. Let k be relativeIndex.
5. Else,
a. Let k be len + relativeIndex.
6. If k < 0 or k ≥ len, return undefined.
7. Return ? Get(O, ! ToString(𝔽(k))).
ここで引数の index
はその型によらずに必ず ToIntegerOrInfinity
を通ることがわかります。これを使って数値に変換しているようです。その仕様が以下の通りです。
ToIntegerOrInfinity ( argument )
The abstract operation ToIntegerOrInfinity takes argument argument.
It converts argument to an integer, +∞, or -∞. It performs the following steps when called:
1. Let number be ? ToNumber(argument).
2. If number is NaN, +0𝔽, or -0𝔽, return 0.
3. If number is +∞𝔽, return +∞.
4. If number is -∞𝔽, return -∞.
5. Let integer be floor(abs(ℝ(number))).
6. If number < +0𝔽, set integer to -integer.
7. Return integer.
ToNumber
で数値に変換し4、NaN
は 0
にして返し、小数点以下のある数値ではそれを取り除いて整数値にして返すという処理を行うことがわかります。これが奇妙な動作の原因です。
……いやいやいや。単に ToNumber
のみを使って変換し、その後で整数値以外をはじくような処理を行えばこのような変なことにならない気がします。どうしてこのような仕様になってしまったんでしょう。
実はこれに関連して数値にならない文字列("foo"
など)や NaN
を入れた場合について指摘した issue があります。
TC39 メンバーの ljharb さんの返答は以下の通り。
I see the argument that
.at(NaN)
should perhaps always returnundefined
- however, given that the proposal is stage 3 and shipping in multiple browsers, it's unlikely we'd be able to make such a change.
……というわけで誰も指摘がされなかったままブラウザに実装されてしまったのでもう手遅れというのがオチみたいです。
【2021/9/13 追記】
どうやらこれは Array#slice
などのインデックスを受け取るメソッドで既に広く使われているやり方なので、Array#at
もそれに合わせたということみたいです。
どう考えても使い勝手が悪そうなんですが、何故そのような決定になったのでしょう。既に Stage 4 になっている仕様に対してこうやって意見を言うのもおかしい話ではあるんですが……。
【追記ここまで】
結び
Array#at
は引数が整数値かどうかチェックしてくれません。その上勝手に整数値に変換します。何らかの計算結果を Array#at
に入れる場合には注意しましょう5。
ところで現在 Stage 2 Change Array by Copy という提案があります。
この提案には Array#withAt
というものが含まれており、指定したプロパティの値のみを変更した新しい配列を作ることが出来ます。
const arr1 = [1, 2, 3];
const arr2 = arr1.withAt(1, 4); // メソッド名についてはまだ議論中です
console.log(arr1); // => Array(3) [1, 2, 3]
console.log(arr2); // => Array(3) [1, 4, 3]
現状このメソッドでは Array#at
の反省を活かしてか、キーに相当する引数が整数値でない場合は RangeError
を投げる仕様になっています。
JavaScript の言語仕様には一貫性がないことがよくあります。ES5 からある Array#indexOf
は NaN
を検知できませんが ES2015 Array#includes
では検知できるなどは結構有名なのではないでしょうか。
const arr = [1, 2, 3, NaN];
console.log(arr.indexOf(NaN)); // => -1
console.log(arr.includes(NaN)); // => true
こういった話は JavaScript の面白いところでもあり嫌われるところでもあるのかなと思います。ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。
-
理屈は納得したんですが、果たしてこの
at
は追加する必要があったのか……とは思います。個人的に規約などでこのメソッドの使用は禁止します。 ↩ -
仕様の上ではそうですが、実装では同じ結果さえ返せばいいのでどうなっているかわかりません。普通配列に対して数値でアクセスすることが多いため、実装ではそれを前提とした最適化を行っているかもしれません。無駄に文字列で配列のプロパティにアクセスするコードを書くのは辞めたほうがいいです。 ↩
-
TypedArray
やString
に対しても同様にat
メソッドが定義されています。中身はほとんど同じです。 ↩ -
ToNumber
にbigint
の値を入れるとTypeError
を投げます。よってArray#at
にbigint
は入れられません。 ↩ -
正直なところ、こんな罠を警戒するくらいなら多少記述が長くなっても
Array#length
を使ったほうがマシだと個人的に思います。 ↩