💬

ES2022 Array#at がちょっとおかしい #fix_ecmascript_at

2021/09/19に公開約6,100字
【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 メソッドを一律で禁止するよりかは数値リテラルは許容するといいのではという指摘を受けました。確かにその通りだと思います。

https://twitter.com/yuta0801_/status/1438132509661745158?conversation=none

折角なので 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"
    }
  ]
}

【追記ここまで】


https://twitter.com/petamoriken/status/1435170216304988160

これについて解説します。ちなみに答えは 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"]

https://262.ecma-international.org/12.0/#sec-topropertykey

配列の要素を後ろからアクセスしたい場合は 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))).

https://tc39.es/ecma262/#sec-array.prototype.at

ここで引数の 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.

https://tc39.es/ecma262/#sec-tointegerorinfinity

ToNumber で数値に変換し[4]NaN0 にして返し、小数点以下のある数値ではそれを取り除いて整数値にして返すという処理を行うことがわかります。これが奇妙な動作の原因です。

……いやいやいや。単に ToNumber のみを使って変換し、その後で整数値以外をはじくような処理を行えばこのような変なことにならない気がします。どうしてこのような仕様になってしまったんでしょう。

実はこれに関連して数値にならない文字列("foo" など)や NaN を入れた場合について指摘した issue があります。

https://github.com/tc39/proposal-relative-indexing-method/issues/49

TC39 メンバーの ljharb さんの返答は以下の通り。

I see the argument that .at(NaN) should perhaps always return undefined - 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 という提案があります。

https://github.com/tc39/proposal-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 を投げる仕様になっています。

https://tc39.es/proposal-change-array-by-copy/#sec-array.prototype.withAt

JavaScript の言語仕様には一貫性がないことがよくあります。ES5 からある Array#indexOfNaN を検知できませんが ES2015 Array#includes では検知できるなどは結構有名なのではないでしょうか。

const arr = [1, 2, 3, NaN];
console.log(arr.indexOf(NaN)); // => -1
console.log(arr.includes(NaN)); // => true

こういった話は JavaScript の面白いところでもあり嫌われるところでもあるのかなと思います。ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。

脚注
  1. 理屈は納得したんですが、果たしてこの at は追加する必要があったのか……とは思います。個人的に規約などでこのメソッドの使用は禁止します。 ↩︎

  2. 仕様の上ではそうですが、実装では同じ結果さえ返せばいいのでどうなっているかわかりません。普通配列に対して数値でアクセスすることが多いため、実装ではそれを前提とした最適化を行っているかもしれません。無駄に文字列で配列のプロパティにアクセスするコードを書くのは辞めたほうがいいです。 ↩︎

  3. TypedArrayString に対しても同様に at メソッドが定義されています。中身はほとんど同じです。 ↩︎

  4. ToNumberbigint の値を入れると TypeError を投げます。よって Array#atbigint は入れられません。 ↩︎

  5. 正直なところ、こんな罠を警戒するくらいなら多少記述が長くなっても Array#length を使ったほうがマシだと個人的に思います。 ↩︎

Discussion

ログインするとコメントできます