3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

競技プログラミングの練習をしていたとき、ネストした配列を一次元に潰したい場面がありました。「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 に左右される

まずは素朴な自作版

多くの人が最初に書くであろう実装はこれだと思います。

myFlat.js
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 ループで書き直すと差が出ます。

naive-loop.js
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 は内部で thisToObject してから 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 を上書きすると戻り値の型を変えられます。

species.js
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・存在チェックに対応)。

specFlat.js
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 なので自然に再現されます)。

完全な仕様準拠を目指すなら ArraySpeciesCreateLengthOfArrayLike の再現が必要ですが、学習目的ならこの程度で「なるほど」が十分得られます。


まとめ

「配列を平坦化するだけ」の flat() に、これだけの仕様が詰まっていました。

特に面白かったのは:

  • 穴は undefined で埋まらず消える(罠1)
  • プロトタイプ汚染で穴が埋まることがある(罠2)
  • depth の正規化ルール(罠3)
  • flat配列以外にも使えるジェネリックなメソッドであること(罠5)

組み込みメソッドを自作してみると、普段ブラックボックスにしている部分の設計が見えてきて、仕様書を読むのが楽しくなります。アルゴリズムの練習で配列をいじっていて行き詰まったときの、ちょっとした寄り道としておすすめです。

参考資料

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?