はじめに
競技プログラミングの練習をしていたとき、ネストした配列を一次元に潰したい場面がありました。「flat() 使えばいいか」と書いた直後、ふと「これ自分で実装したらどうなるんだろう」と思って手を動かしたのが運の尽きでした。
素朴に書いた自作版と本物の flat() の挙動を見比べると、思っていた以上に食い違う。仕様書(ECMAScript の Array.prototype.flat)を読み込んだら、普段まったく意識しない挙動がぞろぞろ出てきました。
本記事では、flat() を自作する過程で見つけた 6つの仕様の罠 をまとめます。実務でハマる類のものではありませんが、「配列を平坦化するだけ」の関数にこれだけの仕様が詰まっているのか、という発見があります。
対象読者
- JavaScript の基礎を一通り理解している方
- 組み込みメソッドの裏側の挙動に興味がある方
- 「仕様書を読むと何が分かるのか」を体感したい方
動作環境
| 項目 | バージョン |
|---|---|
| Node.js | v20 |
| 言語仕様 | ECMAScript 2024 |
TL;DR
flat() を素朴に自作すると、以下の6点で本物と挙動が食い違います。
- 罠1: 配列の穴(hole)は消える。
undefinedで埋まるわけではない - 罠2: 穴なのにプロトタイプから値が降ってくることがある
- 罠3:
depthは整数に切り捨てられ、NaNや負数は浅いコピー扱い - 罠4: 配列風オブジェクトは平坦化されない(中身が配列でも展開されない)
- 罠5:
flat自体はジェネリックで、配列以外にもcallできる - 罠6: 戻り値の型は
Symbol.speciesに左右される
まずは素朴な自作版
多くの人が最初に書くであろう実装はこれだと思います。
function myFlat(arr, depth = 1) {
return depth > 0
? arr.reduce((acc, v) =>
acc.concat(Array.isArray(v) ? myFlat(v, depth - 1) : v), [])
: arr.slice();
}
[1, [2, [3]]] のような普通の入力なら、これで本物と同じ結果になります。問題は「普通じゃない入力」のときです。
罠1: 穴(hole)は消える
JavaScript の配列には「穴(hole)」という概念があります。[1, , 3] の真ん中のように、要素が存在しないインデックスのことです。
[1, , 3].flat(); // → [1, 3]
undefined で埋まると思いきや、消えます。仕様では各インデックスに対してまず HasProperty で存在チェックを行い、無ければスキップするためです。
一方、素朴な自作版は reduce を使っているので、そもそも reduce が穴をスキップします。ここは偶然一致しますが、for ループで書き直すと差が出ます。
function flatLoop(arr, depth = 1) {
const result = [];
for (let i = 0; i < arr.length; i++) {
const v = arr[i]; // 穴なら undefined になる
if (depth > 0 && Array.isArray(v)) {
result.push(...flatLoop(v, depth - 1));
} else {
result.push(v); // undefined が混入する
}
}
return result;
}
flatLoop([1, , 3]); // → [1, undefined, 3](本物と違う!)
arr[i] でアクセスすると穴は undefined として読めてしまうため、存在チェックが必要です。仕様に合わせるなら i in arr で穴を判定してスキップします。
罠2: 穴なのにプロトタイプから値が降ってくる
ここが一番ニッチで面白い挙動です。罠1で「存在チェックは HasProperty」と書きましたが、HasProperty はプロトタイプチェーンも探索します。
つまり、配列のインデックスが穴でも、Array.prototype に同じインデックスのプロパティがあると、そちらが拾われます。
Array.prototype[1] = 'ghost';
[1, , 3].flat(); // → [1, 'ghost', 3]
穴だったはずの [1] が、プロトタイプ上の 'ghost' で埋まりました。in 演算子で確認すると、穴であってもプロトタイプ経由で「存在する」と判定されているのが分かります。
const a = [1, , 3];
1 in a; // → true(Array.prototype[1] が存在するため)
これは実務で踏むことはまずありませんが、「flat() の存在チェックは自身のプロパティに限定していない」という仕様の証拠になります。検証後は delete Array.prototype[1] で必ず元に戻してください。
罠3: depth は整数に切り捨て、NaN や負数は浅いコピー
depth 引数は数値ならなんでも受け付けますが、内部で ToIntegerOrInfinity という変換を通ります。これにより小数は切り捨てられ、特殊値は独特の挙動になります。
const nested = [1, [2, [3, [4]]]];
nested.flat(2.9); // → flat(2) と同じ(小数切り捨て)
nested.flat(NaN); // → flat(0) と同じ(NaN は 0 扱い)
nested.flat(-5); // → 浅いコピー(負数は展開しない)
nested.flat(Infinity); // → 完全に平坦化
素朴な自作版は depth - 1 をそのまま再帰に渡すので、2.9 を入れると 2.9 → 1.9 → 0.9 と進み、本物より1段深く展開してしまいます。
仕様に合わせるなら、入口で一度だけ正規化します。
function myFlat(arr, depth = 1) {
+ depth = Math.trunc(depth); // 入口で整数化
+ if (Number.isNaN(depth)) depth = 0;
return depth > 0
? arr.reduce((acc, v) =>
acc.concat(Array.isArray(v) ? myFlat(v, depth - 1) : v), [])
: arr.slice();
}
罠4: 配列風オブジェクトは平坦化されない
flat() は要素が配列かどうかを IsArray で判定します。length を持つだけの「配列風オブジェクト」は、たとえ中身が配列でも展開されません。
const arrayLike = { length: 2, 0: [1], 1: [2] };
// flat 自体は呼べる(罠5参照)が…
Array.prototype.flat.call(arrayLike); // → [[1], [2]](展開されない)
中の [1] や [2] は本物の配列ですが、IsArray([1]) が true でも、それを格納している arrayLike 自体が配列でないため、要素として concat されるだけです。素朴な自作版で Array.isArray(v) を使っているなら、この点は自然に一致します。
罠5: flat はジェネリックで、配列以外にも call できる
罠4でしれっと Array.prototype.flat.call(arrayLike) と書きましたが、これが動くこと自体が仕様の特徴です。flat は内部で this を ToObject してから length を読むだけなので、配列でなくても配列風でありさえすれば動作します。
const arrayLike = { length: 3, 0: 'a', 1: ['b'], 2: 'c' };
Array.prototype.flat.call(arrayLike); // → ['a', 'b', 'c']
['b'] だけが展開され、文字列の 'a', 'c' はそのまま残ります。配列でないものを flat できるという事実は、知らないと驚きます。
罠6: 戻り値の型は Symbol.species に左右される
最後はサブクラス絡みの話です。flat() が結果配列を生成するとき、ArraySpeciesCreate という抽象操作を使います。これは Symbol.species を参照して、どのコンストラクタで結果を作るかを決めます。
通常は元の配列のサブクラスで結果が作られますが、Symbol.species を上書きすると戻り値の型を変えられます。
class MyArray extends Array {
static get [Symbol.species]() {
return Array; // 結果を素の Array にする
}
}
const ma = new MyArray(1, [2, 3]);
const flattened = ma.flat();
flattened instanceof MyArray; // → false
flattened instanceof Array; // → true
素朴な自作版は [] リテラルで結果を作るため、この挙動は再現できません。「平坦化結果の型まで仕様で決まっている」という点は、組み込みメソッドの設計の細かさを感じさせます。
仕様準拠版を書いてみる
ここまでの罠を踏まえて、できるだけ仕様に寄せた実装がこちらです(Symbol.species までは扱わず、穴・depth・存在チェックに対応)。
function specFlat(arr, depth = 1) {
depth = Math.trunc(depth);
if (Number.isNaN(depth)) depth = 0;
const result = [];
flattenInto(result, arr, depth);
return result;
}
function flattenInto(target, source, depth) {
const len = source.length >>> 0; // 符号なし整数に正規化
for (let i = 0; i < len; i++) {
if (i in source) { // HasProperty 相当(穴をスキップ)
const element = source[i];
if (depth > 0 && Array.isArray(element)) {
flattenInto(target, element, depth - 1);
} else {
target.push(element);
}
}
}
}
i in source で穴を判定している点が肝です。これで罠1の穴の挙動には対応できます(プロトタイプ汚染がある罠2の挙動も in なので自然に再現されます)。
完全な仕様準拠を目指すなら ArraySpeciesCreate や LengthOfArrayLike の再現が必要ですが、学習目的ならこの程度で「なるほど」が十分得られます。
まとめ
「配列を平坦化するだけ」の flat() に、これだけの仕様が詰まっていました。
特に面白かったのは:
- 穴は
undefinedで埋まらず消える(罠1) - プロトタイプ汚染で穴が埋まることがある(罠2)
-
depthの正規化ルール(罠3) -
flatが配列以外にも使えるジェネリックなメソッドであること(罠5)
組み込みメソッドを自作してみると、普段ブラックボックスにしている部分の設計が見えてきて、仕様書を読むのが楽しくなります。アルゴリズムの練習で配列をいじっていて行き詰まったときの、ちょっとした寄り道としておすすめです。
参考資料