JavaScriptにおける配列の破壊的・非破壊的な配列操作メソッドの挙動とコピーの特性
概要
破壊的な配列操作メソッドがとる挙動の違和感と、関連する配列操作周りの知識について雑に深堀りする。
前提
破壊的・非破壊的な配列操作メソッドの前提として、以下を抑えておく。
破壊と非破壊の特性
- 破壊
- 元の配列に対して直接変更を加え、その結果を戻り値で返す
- 非破壊
- 元の配列をコピーして、そのコピーした配列に対して変更を加えて変更後の結果を戻り値で返す
配列操作とコピーの特性
破壊的な配列操作メソッドが戻り値を返す違和感
配列操作の破壊的なメソッドが戻り値を返すことの違和感について言語化してみる。
破壊的なメソッドも非破壊的なメソッドも、それぞれ形は違えど戻り値が返ってくるようになっている。
破壊的なメソッドでいえば、Array.sort()などがわかりやすい例ではあるが、Array.sort() は元の配列に対して直接順番の変更を加えて、その結果を戻り値で返すというもの。
const array = [3, 2, 1];
// 破壊的操作の実行const newArray = array.sort((a, b) => a - b);
console.log(array); // [1,2,3] - 元となる配列console.log(newArray); // [1,2,3] - 元となる配列の参照このように、arrayを直接変更しているのにもかかわらず、変更後の元の配列の”参照”を戻り値で受け取ることができる。
いっそのこと破壊的であるならvoidであるほうが書き手にとっても読み手にとってもわかりやすいのに、なぜ戻り値に元配列の参照を渡しているのか気になってChatGPTに聞いてみたところ、”メソッドチェーンを可能にするための仕様”であるという回答だった。
確かに、Array.sort().map(… のようにつなげていくことは可能なのでごもっともらしいが、その設計思想が言及されているエビデンスは見当たらなかった。
Array.prototype.sort() - JavaScript | MDN
非破壊的な配列操作メソッドを上手に扱うには
ここでは、配列のコピーの特性を抑えつつ、上手く非破壊的な配列操作メソッドを扱うための方法を言語化してみる。
前提として、JavaScriptの配列コピー操作(非破壊的な配列操作メソッドの戻り値や、スプレッド構文など)は基本的にシャローコピーされる。
例えば、
const array = [3, 2, 1];
const newArray = [...array];const sortNewArrayDestruction = newArray.sort((a, b) => a - b);const sortNewArrayNonDestructive = newArray.toSorted((a, b) => a - b);
console.log(array); // [3,2,1] - 元となる配列console.log(newArray); // [3,2,1] - 元となる配列のコピーconsole.log(sortNewArrayDestruction); // [1,2,3] - 元となる配列のシャローコピーに対する実行結果console.log(sortNewArrayNonDestructive); // [1,2,3] - 元となる配列のシャローコピーに対する実行結果このように、元の配列のコピーであるnewArray を作成したとして、そのコピーに対してそれぞれ破壊・非破壊メソッドを実行すると上記のような結果が得られる。
newArrayはarrayをもとに浅いコピーを作成し、そのコピーに対する配列操作を後続では行っているため、破壊的なメソッドを実行しても元の配列は破壊されていないという結果になる(これが参照に対する配列操作であれば元の配列は破壊されている)。
では以下の場合はどうだろうか。
const array = [ { id: 3, name: "hoge" }, { id: 2, name: "fuga" }, { id: 1, name: "piyo" },];
const newArray = [...array];const sortNewArrayNonDestructive = newArray.toSorted((a, b) => a.id - b.id);
sortNewArrayNonDestructive[0].id = 999; // 非破壊的な配列操作メソッドの実行結果に対するオブジェクト操作
console.log(array); // [{id:3, name: "hoge"}, {id:2, name:"fuga"}, {id:999, name:"piyo"}]; - 元となる配列console.log(newArray); // [{id:3, name: "hoge"}, {id:2, name:"fuga"}, {id:999, name:"piyo"}]; - 元となる配列のコピーconsole.log(sortNewArrayNonDestructive); // [{id:999, name: "piyo"}, {id:2, name:"fuga"}, {id:3, name:"hoge"}]; - 元となる配列のシャローコピーに対する実行結果元の配列オブジェクトのコピーを作成し、そのコピーに対して非破壊的な配列操作メソッドを実行。
さらにその下の行のsortNewArrayNonDestructive[0].id = 999; をぱっとみると、sortNewArrayNonDestructive変数のインデックス0番目のidに対してのみ、変更を加えたいような意図のコードに見えるが、実際の結果は元の配列も変更されてしまっている。
これは、困惑しやすいのだがsortNewArrayNonDestructiveは元配列のシャローコピーに対してソートした結果であり、そのシャローコピー内のネストしたデータは”コピー”ではなく”参照”なので、idの変更は参照に対する変更を行ったことになる。
そのため、元の配列オブジェクトのidが変更されてしまっていることがわかる。
ここでわかるのは二点
- 非破壊的な配列操作メソッドの実行結果は元の配列オブジェクトには影響していない(ソート結果が元の配列に反映されていないことから分かる)
- 非破壊的な配列操作メソッドの実行結果に対するオブジェクト操作は元の配列オブジェクトに影響してしまっている
非破壊的な配列操作メソッドは元の配列の第一階層にのみ処理を施すが、オブジェクト操作の場合はシャローコピーされたデータよりもより深い場所にあるデータを操作している(すなわち、コピーされていない参照されているデータに対して操作している)ので、元の配列オブジェクトに影響してしまっている。
これはシャローコピーは配列の第一階層のみをコピーし、それ以降の深いデータに関してはコピーではなく参照を返すという特性から来ているものなので、非破壊的な配列操作メソッドの戻り値は元配列のコピーであるからどんな破壊的な操作を行っても元の配列に影響はせず、安心であるというわけではない。
非破壊的な配列操作メソッドは、配列の構造は破壊しないけどその中のデータの内容の非破壊性までは保証してくれないことに注意が必要。
内部のデータもろとも非破壊にしたい場合は、配列をコピーする段階でディープコピーを行う必要がある。
const array = [ { id: 3, name: "hoge" }, { id: 2, name: "fuga" }, { id: 1, name: "piyo" },];
const newArray = structuredClone(array); // ディープコピーconst sortNewArrayNonDestructive = newArray.toSorted((a, b) => a.id - b.id);
sortNewArrayNonDestructive[0].id = 999; // 非破壊的な配列操作メソッドの実行結果に対するオブジェクト操作
console.log(array); // [{id:3, name: "hoge"}, {id:2, name:"fuga"}, {id:3, name:"piyo"}]; - 元となる配列console.log(newArray); // [{id:3, name: "hoge"}, {id:2, name:"fuga"}, {id:999, name:"piyo"}]; - 元となる配列のコピーconsole.log(sortNewArrayNonDestructive); // [{id:999, name: "piyo"}, {id:2, name:"fuga"}, {id:3, name:"hoge"}]; - 元となる配列のシャローコピーに対する実行結果元の配列のコピーに対して非破壊的な配列操作メソッドを実行し、その結果に対してオブジェクト操作を行っている。
newArrayは元の配列のディープコピーだが、非破壊的な配列操作メソッドの実行結果はシャローコピーなので、
sortNewArrayNonDestructive[0].id = 999;
は元の配列には影響しないけど、newArrayには影響するという形になる(newArrayにも影響させたくない場合はnewArrayもディープコピーする必要がある)。
破壊的な配列操作メソッドのメリデメとユースケース
破壊的な配列操作メソッドのメリデメが分かればおのずと非破壊的な配列操作のメリデメがわかると思うので、
破壊的な配列操作メソッドのメリデメのみ記述する。
破壊的な配列操作のメリット:ある条件下においてはパフォーマンスがより優れている
ある条件下というのは、配列内のデータ量が非常に多い場合の時。
破壊的なメソッドは配列をコピーせずに元の配列を直接書き換えるという特性上、大きな配列をコピーすることがないので、配列を一度コピーする非破壊的な配列操作メソッドよりもパフォーマンスに優れているという点は挙げられる。
破壊的な配列操作のデメリット1:想定している実態と、実際の結果が異なる
const base = [1, 2, 3];
base.push(4);このときbase の実態は[1,2,3,4]に変わっている。しかし、この変化は出力結果を確認するまでコード上ではわからない。
ベースとなる配列が定義されている時点と、実際の状態の乖離があることが可読性、認知負荷の観点からあまり望ましくないと感じる。
また、前提としてpushメソッドが元の配列を操作するメソッドであることが前提として知らなければいけないのと、
より複雑なコードの中でpushメソッドを目視で確認しない限り、元配列が定義時点と出力結果で異なることがわからないという点が認知負荷が高いと個人的には感じる。
破壊的な配列操作のデメリット2:影響範囲がわかりにくい
破壊的メソッドは、実行したその瞬間から元の配列に変更が加わる。
const base = [1, 2, 3];
const removeLast = (arr) => { arr.pop(); // 元配列を直接変更 return arr;};
removeLast(base); // [1,2]例えばあるベースとなる配列から末尾の値を削除したい場合があるとして、removeLast関数を作成して元配列に対して破壊的な配列操作を行う。
この時の期待値としては[1,2] が返却されることであり、現状はそれを満たしているが、以下のように元配列に対して末尾にデータを追加する関数が増えたときにどうなるだろうか。
const base = [1, 2, 3];
const addValue = (arr, value) => { arr.push(value); // 元配列を直接変更 return arr;};
const removeLast = (arr) => { arr.pop(); // 元配列を直接変更 return arr;};
addValue(base, 4); // [1,2,3,4]removeLast(base); // [1,2,3]removeLast関数自体のロジックは変更していないが、元配列の構造が変わったことにより出力結果が変わってしまっている。
このように元配列を変更してしまうことは予期せぬ結果を引き起こす可能性があるため、基本的には元となる配列から新しい配列を作成して返却することが望ましい。
const base = [1, 2, 3];
const addValue = (arr, value) => { return [...arr, value]; // 新しい配列を返す};
const removeLast = (arr) => { return arr.slice(0, -1); // 新しい配列を返す};
addValue(base, 4); // [1,2,3,4]removeLast(base); // [1,2]こうすると、addValueを何度実行しても、removeLastを何度実行しても、元配列を変更せずそれぞれ元配列に対しての結果が返されるため、影響範囲を狭めることができる。