銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

JavaScriptのArray.fillで気をつける事

f:id:takamints:20200512125305j:plain
photo credit: diana_robinson The Milky Way over a radio telescope at the Karl G. Jansky Very Large Array National Radio Astronomy Observatory in New Mexico via photopin (license)

JavaScriptで、あらかじめ一定の長さの配列に初期値を放り込んでおくために Array.fill を使いますが、勝手な思い込みからハマってしまいました。恥ずかしながら、その詳細をご報告。

目次

Array.fill はどんな関数?

Array.fill は、配列の全要素に同じ値を設定する関数です。 以下の例では、3個の数値配列を生成して値 0 で初期化しています。

const arr = Array(3).fill(0);

余談ですが、fillで初期化しておかないと、要素はすべて undefined になっており、そのままでは forEach などの列挙メソッドが回りません。

Objectで初期化したらおかしな挙動

以下のように初期値にObjectを与えていたのですが、思った通りの動きをしてくれませんでした。

const arr = Array(3).fill( { x: 0, y: 0 } );

3個の二次元座標値を持つ配列。。。のつもりですが、以下のようなことになりました。

const arr = Array(3).fill( { x: 0, y: 0 } );
arr[0].x = 10;
arr[0].y = 8;
console.log(JSON.stringify(arr));
//出力:[{"x":10,"y":8},{"x":10,"y":8},{"x":10,"y":8}]

0番目の情報を書き換えただけで、他のすべてのデータが書き換わってしまいました。 配列要素は確かに3つあるのですが、全ての要素が、ひとつの座標値を参照しているのです。

なぜこうなるか

JavaScriptのObjectはメソッドの引数や代入時には、必ず参照が渡されますから、このようなことが起こります。 ソースコードをよく見れば、fill に与えられている座標値は、1度しか生成されていません。 だから直感的でないにせよ、これは当たり前の挙動です。 つまり、ワタシが勝手に「別の複数のオブジェクトが生成される」と思い込んでいただけなのでした。

解決方法

唯一の正しい答えなんてありませんから、3つほどの解決方法を示してみます。 プリミティブな型なら最初の方法が良いと思いますが、プロパティを持つオブジェクトならば2番めの方法が好みです。 関数プログラミング的には最初の方法が正解だと思われますが。

(1) まるごと代入すれば良い

上の例の2行目と3行目を以下のように変えれば、別のオブジェクトを生成して代入していることになるので、なんら問題ありません。

const arr = Array(3).fill( { x: 0, y: 0 } );
arr[0] = { x: 10, y: 8 };
console.log(JSON.stringify(arr));
//出力:[{"x":10,"y":8},{"x":0,"y":0},{"x":0,"y":0}]

ただ、これがベストだとは思いません。 なんて言いましょうか、初期化のコードとまるごと代入部分で、統一感がないというか、思想に差があるというか、そんな妙なミスマッチ感がありますね。 最初は全要素がx,yともに0の単一オブジェクトを参照していますが、その後、値としては同じ(x,yとも0である)データを代入したなら、別のインスタンスを参照することになる。

あと、誰かがうっかり別のところで要素の「プロパティを書き換えちゃった」というような不測の(しかし予測可能な)事態に対して脆弱です。構文的な防御策がありませんからね。

加えて、多くのプロパティを持つオブジェクトである場合、インスタンスの生成コストも気になります。本来、気にするべきではないのですが。

(2) 別オブジェクトで初期化する

初期化時点で別のオブジェクトを設定しておくには、以下のように書けばよろしいかと思います。

const arr = Array(3).fill().map(e=>({ x: 0, y: 0 }));

fill()fill(null) と同じです。全要素をnullで初期化しています(初期値は nullでなくても undefined 以外ならOKです)。 これをしておかないと全ての要素が undefined になっていて、うしろの map が回ってくれないのです。 こうしておけば、以下のように間違いは発生しません。

const arr = Array(3).fill().map(e=>({ x: 0, y: 0 }));
arr[0].x = 10;
arr[0].y = 8;
console.log(JSON.stringify(arr));
//出力:[{"x":10,"y":8},{"x":0,"y":0},{"x":0,"y":0}]

(3) ループを回して初期化する

また、以下のようにループを回して初期化する方法もありますが、前時代的ではありますね。

const arr = Array(3);
for(let i = 0; i < arr.length; i++) {
  arr[i] = { x: 0, y: 0 };
}