LoginSignup
69
55

More than 3 years have passed since last update.

もう一度理解する、JavaScriptの配列とコピー

Last updated at Posted at 2019-12-24

この記事はKobe University Advent Calendar 2019の23日の記事です。一日の遅刻です。いい加減に遅刻癖がつきそうな感じなので本当によくないですね、申し訳ないです。


みなさんは配列をコピーするとき、array.slice()を使っていますか?それとも、[...array]でしょうか。1
本記事では、なんとなく曖昧になっているかもしれないJavaScriptでの配列のコピーについて、もう一度おさらいして理解を深めなおすことを目指します。

「配列」の「コピー」とは?

そもそも、配列をコピーする、とは、どのような操作のことを指すのでしょうか。

const a = [1, 2, 3];
const b = a; // aの「コピー」??

これはコピーではない、というのは、みなさんもご存知の通りのことと思います。2
記事に入るにあたって、まずはJavaScriptの配列とはどのようなものなのか、コピーするとはどういうことかを、ここでおさらいしてみることにしましょう。

JavaScriptにおける配列

プリミティブとオブジェクト

JavaScriptでは、変数に入れられるものは大きく2種類に分けることができます。
それは、プリミティブオブジェクトです。

プリミティブとは、数値・文字列・真偽値・シンボル・nullundefinedのことを指します。3
オブジェクトとはプリミティブではないものすべてで、基本的にはObjectとそれを継承したもののことです。4

配列は上に挙げたプリミティブのどれにも当てはまりませんから、配列はオブジェクトであるということになります。
具体的には、Arrayオブジェクトのインスタンスのことを配列といいます。
実際、typeof array"object"となり、array instanceof ObjectArray instanceof Objecttrueです。

もう少しだけ詳細な話をします。記事の本題からやや逸れるので、興味のない方は次の節まで読み飛ばしてください。
配列はArray Exotic Objectと呼ばれ、特殊なオブジェクトの一種です。
これは、たとえば配列に要素を追加するとlengthプロパティが自動的に書き換わりますが、このような動作が仕様レベルで組み込まれていることを意味します。
逆に言えば、ほとんどの基本的な動作は通常のオブジェクトと変わりません。
ところでオブジェクトのプロパティにアクセスするとき、

object.x = 1;
object["x"] = 1;

は全く同じ意味になる5、というのはよく知られています。
また、object["x"]のような書き方をする際、この"x"の部分は文字列またはシンボルのいずれかをとり6、それ以外の値が渡されたときは文字列に変換されて7からアクセスがなされます。
つまり、array[1]と書くのは、実際にはarray["1"]と書いているのと同じ意味で、JavaScriptの配列の添え字アクセスとは、単なるオブジェクトのプロパティアクセスにほかならないのです。

イテレータ

また、配列はIterableなオブジェクトでもあります。イテラブルは言いにくいので英語で書きます。
Iterableであるとは、「繰り返し可能である」とか、「反復可能である」と訳されます。

配列をfor文で回す際に、

for (const element of array) {
  // do something
}

のようにしてループをさせますが、for ofはIterableなものを繰り返し処理するための構文です。

オブジェクトがIterableであるためには、Symbol.iteratorプロパティとしてIterator(イテレータ)を持っている必要があります。
配列の持つIteratorは、要素を先頭から順に取り出してくれるようになっています。
そのため、for ofで配列を先頭の要素から順に回していくことができます。

Iteratorの詳細については、

など、詳細に説明されている良い記事が他にありますのでそちらに譲ります。

配列のほかにIterableなものとして、たとえば文字列やSetMapが挙げられます。

また、普通に配列をfor ofで回した場合には配列の値だけが得られますが、インデックスと値を同時に取得したいこともあります。
Array.prototype.entries()は、[index, value]という形式で値を取り出してくれるIteratorを返します。
そして、

for (const [index, value] of ["x", "y", "z"].entries()) {
  console.log(index, value);
}
// 0 x
// 1 y
// 2 z

のように使うことができます。

ちょっと待ってください、for ofで回せるのはIterableオブジェクトであって、Iteratorではなかったはずです。
Iteratorを持っているオブジェクトがIterableオブジェクトである、という関係でしたね。
実は、Array.prototype.entries()で返されるオブジェクトは、Iteratorであると同時にIterableでもあります。
これを、IterableIteratorといいます(なんじゃそりゃ)。8
JavaScriptに組み込みで用意されているIteratorは、すべてそれ自身がIterableであるようになっています。

配列のコピー

代入とコピーの違い

さて、JavaScriptの配列がどのようなものであったかを見てきましたが、これをコピーするとはどのような意味なのでしょうか。
先ほど、配列とはオブジェクトであるということをおさらいしましたが、JavaScriptでは、オブジェクトを変数に代入すると、他の言語で言うところの参照に相当するものが渡されます。
(もっとも、JavaScriptに「値渡し」や「参照渡し」という概念は存在しないので、厳密には「参照を渡す」という言葉は意味を持たないということに注意する必要があります。)

const yukarin = { name: "Yukari Tamura", age: 17 };
const yukari_tamura = yukarin;
yukari_tamura.age = 37;
console.log(yukarin.age); // 37

ここでyukarinyukari_tamuraは同一の人物オブジェクトであるため、片方の変数からプロパティを変更すると、もう一方からアクセスした場合もその変更が反映された状態です。

これは、オブジェクトの一種であるところの配列についても同じことがいえます。

const aegislash_shield_usum = [60, 50, 150, 50, 150, 60];
const aegislash_shield_swsh = aegislash_shield_usum;
// ギルガルド(シールドフォルム)のB,D種族値は剣盾で10ずつ下方修正されました
aegislash_shield_swsh[2] -= 10; // B
aegislash_shield_swsh[4] -= 10; // D

console.log(aegislash_shield_usum); // [60, 50, 140, 50, 140, 60]
console.log(aegislash_shield_swsh); // [60, 50, 140, 50, 140, 60]

=を使って配列を別の変数に代入した場合、代入先も代入元も同じオブジェクトですから、どちらかの要素(プロパティ)を変更するともう片方も影響を受けます。
ここで挙げた例のように、もともとの配列に入っているデータを一部改変して利用したいが、変更前のデータも引き続き利用したい、という場合に「配列のコピー」が求められることがあります。

よって、配列をコピーすると言った場合、「同じ要素を持っているが別オブジェクトであるような新しい配列を作る」ことを意味する、と考えることができます。

もしも期待通りに配列がコピーできた場合、上の例では以下のような結果が期待されますね。

console.log(aegislash_shield_usum); // [60, 50, 150, 50, 150, 60]
console.log(aegislash_shield_swsh); // [60, 50, 140, 50, 140, 60]

浅いコピーと深いコピー

ところで、配列をコピーするという操作にも、複数の種類があります。
これは、先ほど挙げた例では問題にならないのですが、たとえば二次元配列のように、配列の中にさらに配列が入っている場合に問題となってきます。

const roguelike_level01_map = [
  ["|", "<", ".", "|", " ", "|", "_"],
  ["|", "@", ".", "+", "#", "|", ">"],
  ["|", ".", ".", "|", "#", "+", "."],
];
const roguelike_level02_map = myPoorCopyFunction(roguelike_level01_map);
roguelike_level02_map[1][2] = "d"; // 主人公の隣にペットの犬を配置
console.log(roguelike_level01_map[1][2]); // "d"

二次元配列では、配列の中にある配列もコピーしなければ、やはり同じ問題が起きてしまいます。
そのため、コピーする配列の深さを考える必要があります。

配列の深さとは、配列の中の配列の中の配列…… が最大で何段階あるのかを言い表すものです。
ここでは、配列の中に配列が入っていないときは深さ $0$ 、いわゆる二次元配列では深さ $1$ とします。9
たとえば、Array.prototype.flat([depth=1]) は、配列を指定した深さ分だけ平らにならしてくれるメソッドです。
このメソッドの深さの指定を変えて試してみましょう。

const peano_number_4 = [0, [0], [0, [0]], [0, [0], [0, [0]]]];
console.log(JSON.stringify(peano_number_4.flat(0))); // [0,[0],[0,[0]],[0,[0],[0,[0]]]]
console.log(JSON.stringify(peano_number_4.flat(1))); // [0,0,0,[0],0,[0],[0,[0]]]
console.log(JSON.stringify(peano_number_4.flat(2))); // [0,0,0,0,0,0,0,[0]]
console.log(JSON.stringify(peano_number_4.flat(3))); // [0,0,0,0,0,0,0,0]

peano_number_4の配列の一番深いところにある0にアクセスするためには、peano_number_4[3][2][1][0]とする必要がありますから、配列の中にさらに3段階入れ子になった配列が入っており、この場合の深さは $3$ といえます。
flat()メソッドに3を与えたときの結果が、ちょうどすべての配列がならされて平らになっていますね。

配列をコピーする場合にも、何段階の深さだけコピーするのかによって違いがでてきます。
一般に、一番外側の配列だけをコピーして、残りは代入で済ませてしまうコピーを浅いコピー、またはシャローコピー(shallow copy)といいます。
また、配列の最も深いところまですべてコピーするものを、深いコピー、またはディープコピー(deep copy)といいます。

基本的には浅いコピーで事足りることが多く、この記事で後に紹介する方法も大半が浅いコピーになります。
とはいえ、行列の計算に使う二次元配列などをコピーしたくなることもあるかもしれませんから、深さが1以上の配列をコピーする際にはコピーの深さについて考える必要がある、ということを頭に留めておくようにしましょう。

配列をコピーする方法

さて、いよいよ配列をコピーする方法を見ていきましょう。

Array.prototype.slice(), Array.prototype.concat()

ES5以前では、みなさんは主にこの方法を使われていたのではないかと思います。

slice(begin, end)メソッドは、開始地点と終了地点を指定して、配列の中の一部分を新しい配列として取り出すものです。
引数2つをどちらも指定しなかった場合、開始地点は配列の先頭、終了地点は配列の末尾に設定されますから、もとの配列と同じ要素を持った配列が作られます。
これを利用することで、array.slice()を呼び出すことで配列の(浅い)コピーを得ることができます。

concat(value1, value2, ...)メソッドは、配列の後ろに要素や別の配列をくっつけた新しい配列を返すものです。
[0].concat(1,2)[0, 1, 2]を返しますが、引数に配列を与えた場合はその中身が連結されるようになっているので、[0].concat([1,2])としても[0, 1, 2]になることに注意しましょう。
引数を何も与えなかった場合、もともとの配列になにも追加しないということですから、やはり配列の浅いコピーを得るために使うことができます。

const aegislash_shield_usum = [60, 50, 150, 50, 150, 60];
const aegislash_shield_swsh = aegislash_shield_usum.slice();
aegislash_shield_swsh[2] -= 10; // B
aegislash_shield_swsh[4] -= 10; // D

console.log(aegislash_shield_usum); // [60, 50, 150, 50, 150, 60]
console.log(aegislash_shield_swsh); // [60, 50, 140, 50, 140, 60]

これらの方法を使うことで、コピー先の配列に手を加えたとしても元の配列は変化しないようにできますね。

余談ですが、これはsliceconcat非破壊的なメソッドであることを利用してコピーを行う方法です。
反対の意味として、sortpushのような破壊的なメソッドが挙げられます。
破壊的なメソッドはオブジェクト(配列)に直接手を加えて変化させるもので、非破壊的なメソッドは、新しいオブジェクトを生成するなどして元のオブジェクトは変化させないものを指します。

const tristar = ["神崎美月", "一ノ瀬かえで"];
const tristar_tentative = tristar.concat("紫吹蘭"); // 非破壊的操作 (もとの配列に影響しない)
console.log(tristar_tentative); // ["神崎美月", "一ノ瀬かえで", "紫吹蘭"]
console.log(tristar); // ["神崎美月", "一ノ瀬かえで"]

tristar.push("藤堂ユリカ"); // 破壊的操作 (もとの配列に影響する)
console.log(tristar_tentative); // ["神崎美月", "一ノ瀬かえで", "紫吹蘭"]
console.log(tristar); // ["神崎美月", "一ノ瀬かえで", "藤堂ユリカ"]

push(x)concat(x)はともに、「配列の末尾に要素xを付け加える」という操作ですが、pushでは配列の内容が変化しているのに対して、concatでは新しい配列が返され、元の配列は変更されていないことに注意してください。
一般的には、新しい配列を作るよりはもともとの配列に値を付け加えるほうが速度が高速であるため、何度も繰り返し配列の末尾に値を追加する場合や、必ずしも配列をコピーする必要がない場合にはpushのほうが用いられます。
似たような理由で、一般に同じ動作をするメソッドでも破壊的なメソッドの方が処理速度の観点では有利なことが多いです。10

余談おわり。

[...array]

現代では、先ほど挙げたslice()concat()を使う方法のかわりに、ES2015で追加されたスプレッド構文を利用した浅いコピーができます。
見た目がよりシンプルなので、基本的にはこれを使うのがよいと思っています。

const aegislash_shield_usum = [60, 50, 150, 50, 150, 60];
const aegislash_shield_swsh = [...aegislash_shield_usum];
// 以下略

[...x]という構文は、イテレータを使ってxの要素を配列として展開するものです。
そのため、スプレッド構文を使うと、Iterableなオブジェクトであれば展開して配列の形にしてしまうことができます。
まあ、この記事は配列のコピーについて扱っているので、配列以外のものをコピーすることについては知ったことではありませんね。

slice()concat()を使う方法よりやや処理が遅いと言われていますが、大概の場合、こんなところでプログラム全体の動作が重くなったりはしません。つまり気にしなくてよいです。

Array.from()

Array.from()メソッドは、ES2015で追加されたメソッドです。
Array.from(array)とすることで、もとの配列の浅いコピーを得ることができます。
配列(または他のIterableなオブジェクト)を新しい配列の形にする、という使い方としては、上記のスプレッド構文との違いはありません。
そのため、単に配列をコピーする場合には、[...array]としてもArray.from(array)としても違いはないので、見た目上シンプルな[...array]のほうを使えばよいと思います。

スプレッド構文とArray.from()に違いが生じる場面は二つあります。

まず、Array.from()はIterableではない配列風オブジェクト(array-like object)を配列にすることができます。11
配列風オブジェクトとは、配列のような感じだけれども配列ではないオブジェクトのことです。
具体的には、lengthという名前のプロパティを持っていれば配列風オブジェクトということができます。

Array.from()に渡されたオブジェクトがIterableではなく、かつlengthプロパティが存在していた場合に、lengthプロパティの値を整数に変換し12[0]から順に[length-1]までプロティアクセスすることで新しい配列が作られます。
使用例としては、Document.querySelectorAll()などで返されるNodeListオブジェクトがまさしく配列風オブジェクトであるため、これを配列に変換したい場合に有用です。
配列風オブジェクトを配列に変換するという用途には、ES5以前では[].slice.call(array_like)というコードを書いていましたが、可読性でいえばArray.from(array_like)のほうが大幅に優れているといえるでしょう。

もっとも、我々は配列のコピーがしたいので、配列に似た配列でないオブジェクトのことなんて興味がありませんね。

もうひとつの違いとしては、Arrayを継承して作ったオブジェクトをサブクラスのままコピーできる、という点が挙げられます。
具体的な例を見てみましょう。

class ExArray extends Array {
  myAwesomeMethod() {
    return this[0];
  }
}

const ex_array = new ExArray(3, 2, 1); // ExArray(3) [3, 2, 1]
console.log(ex_array.myAwesomeMethod()); // 3

const copy_of_ex_array = ExArray.from(ex_array);
console.log(copy_of_ex_array.myAwesomeMethod()); // 3

Arrayを継承して、ExArrayというクラスを作ってみました。
これは普通の配列として使えるほか、独自に定義したmyAwesomeMethod()メソッドを呼び出すことができる素晴らしいオブジェクトです。
ここでex_arrayをコピーしようとした場合、ExArray.from()を使用します。
[...ex_array]Array.from(ex_array)を用いると、ExArrayではなくArrayのインスタンスとしての配列が得られます。
そのため、せっかくのmyAwesomeMethod()を呼び出すことができなくなってしまいます。

ExArrayArrayを継承している以上配列といえる気がするので、これを正しくコピーできるというのは重要ですね。
Arrayを継承して作った独自の配列をコピーしたい場合には、そのfrom()メソッドを使うということを覚えておきましょう。

JSON.parse(JSON.stringify(array))

これまで挙げてきた方法は、すべて浅いコピーを行うためのものでした。
そこで、手っ取り早く深いコピーを行うための方法として、渋々ながらJSON.stringify()JSON.parse()を使った方法を紹介せざるを得ません。

JSON.stringify()メソッドは、JavaScriptのオブジェクトを文字列の形でシリアライズするためのものです。
JSON.parse()メソッドはその逆で、JSON文字列をJavaScriptのオブジェクトの形に変換します。

深くネストした配列を一度JSON文字列の形に変換してしまい、それをもう一度配列に戻すことで、どれだけの深さがあっても深いコピーを実現することができます。
この方法の致命的な欠点として、コピー元がJSONで完全に表現可能でなければ、一度JSONを経由する過程で情報が破損してしまうことが挙げられます。
JSONでは、配列と数値・文字列・真偽値・null、およびそれらを値として持つオブジェクトのみを表現することができます。
そのため、undefinedや関数オブジェクト、シンボルなどが配列に含まれていた場合、この方法を使ってコピーを行うことはできません。

console.log(JSON.parse(JSON.stringify([undefined, function () { }, Symbol()]))); // [null, null, null]

これはバグのもとになるので、数値だけの多次元配列のような、JSONへの変換を起因とする問題が起きないことが明らかな場合、かつプロトタイピングなどで手っ取り早く実装してしまいたいような場合ぐらいにしか使うべきではありません。13

深いコピーを実装する

結局のところ、簡単に配列の深いコピーを行う方法というのは、現在のJavaScriptでは提供されていません。
そのため、浅いコピーを繰り返し使用するなどの方法で、深いコピーを行う処理を実装する必要があります。

// 二次元配列をコピーする
function copyMatrix(base) {
  const result = [];
  for (const line of base) {
    result.push([...line]);
  }
  return result;
}

const matrix = [
  [1, 0, 0],
  [0, 1, 0],
  [0, 0, 1]
];

const copy_of_matrix = copyMatrix(matrix);

深いコピーが必要になる場面というのは限られてくるため、コピーしたい配列の形に応じて簡単に実装してしまえばよいでしょう。

配列の深さが予め分かっていないような場合には、入れ子になっているすべての要素を再帰的に調べていくことで深いコピーを実装できます。


const peano_number_4 = [0, [0], [0, [0]], [0, [0], [0, [0]]]];

function copyDeepArray(base) {
  if (!Array.isArray(base)) return base;
  const result = [];
  for (const sub of base) {
    result.push(copyDeepArray(sub));
  }
  return result;
}

console.log(peano_number_4[3][2][1] === copyDeepArray(peano_number_4)[3][2][1]); // false

2019/12/25追記:
@Yametaro さんに指摘を頂きましたが、上記の関数では、ある特定の場合に無限ループが発生してしまいます。14
それは、以下のような配列をコピーする場合です。

const infinite_depth_array = [];
infinite_depth_array.push(infinite_depth_array);
console.log(infinite_depth_array[0][0][0][0][0][0][0][0] === infinite_depth_array); // true
copyDeepArray(infinite_depth_array); // Uncaught RangeError: Maximum call stack size exceeded

配列の要素の中にその配列自身が含まれている場合など、配列が有限の深さを持たない場合があり、そのようなときには深いコピーは有限回の手続きで停止しません。
うっかり正則性公理に反するような配列を投げてしまわないように注意しましょう。

一度辿った配列を覚えておくことで無限ループを検知するなどの方法でエラーを防ぐことはできますが、その場合にはパフォーマンスへの影響も考慮する必要が出てくる可能性があります。
なお、JSON.stringify()にループした配列を渡すと、Converting circular structure to JSONエラーが発生します。

おわりに

配列を複数の変数で使いまわしたい場合には、適切な方法でコピーしなければバグの原因になってしまうことがあります。
特に、配列が入れ子になっているような場合には、浅いコピーと深いコピーのどちらが適切かを正しく判断することが大切です。

単純な一次元の配列のコピー、浅いコピーには[...array]を使いますが、配列を継承した独自のオブジェクトをコピーしたい場合には、Array.from()(を継承したもの)を使うようにしましょう。15
深いコピーを行う場合には、専用の処理を自前で実装する必要があります。

ただし、深いコピーを行うとき、それが本当に必要なのかという点については留意しておくべきでしょう。
二次元配列のようなシンプルな場合を除き、配列が入れ子になった構造が生じている時点で、それらを木構造を持ったオブジェクトなどの形で表現してしまったほうがよいことも十分に考えられます。
また、配列を何でもかんでもコピーしてしまうと、変更したと思った要素が変更されておらず、逆にバグを生み出してしまうことがあるかもしれません。

配列のコピーについての基礎的な事項をしっかり理解して、思わぬバグを防ぐようにしていきましょう。


  1. 以後、arrayと書いたときには、[][1,2,3]などの任意の配列を指すこととします。 

  2. よくわからない、という方も、後でもう一度説明をしますので安心してください。 

  3. https://www.ecma-international.org/ecma-262/10.0/index.html#sec-primitive-value また、近いうちにStage 4のBigIntが新しいプリミティブとして追加されると思われます。 

  4. 例外として、Objectを継承しないオブジェクトとしてObject.create(null)で生成したものなどがあります。 

  5. https://www.ecma-international.org/ecma-262/10.0/index.html#sec-property-accessors 

  6. https://www.ecma-international.org/ecma-262/10.0/index.html#sec-object-type 

  7. https://www.ecma-international.org/ecma-262/10.0/index.html#sec-property-accessors-runtime-semantics-evaluation 

  8. もしかしたらIterableIteratorはECMAScriptの用語ではなく、TypeScriptで型付けに使われている用語だったかもしれません。 

  9. もしかしたら $1$ から数え始める場合もあるかもしれません。とりあえずこの記事では $0$ から始めています。 

  10. 逆に、あらゆる変数が非破壊的であるといろいろなメリットがあるので、オブジェクトの不変性をより重視して破壊的メソッドの使用を嫌う場合もあります。 (https://immutable-js.github.io/immutable-js/

  11. https://www.ecma-international.org/ecma-262/10.0/index.html#sec-array.from 

  12. https://www.ecma-international.org/ecma-262/10.0/index.html#sec-tointeger 

  13. プロトタイピングで手っ取り早くバグを埋め込むのはやめろ つまり使うな 

  14. 厳密には無限ループで止まらなくなるのではなく、再帰が深くなりすぎてMaximum call stack size exceededエラーが発生して停止します。 

  15. そもそもみんな配列を継承して新しいクラスを作ったりしませんよね なので基本的には[...array]だけ覚えておけば充分です。 

69
55
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
69
55