メインコンテンツまでスキップ

オブジェクトを浅くコピーする

オブジェクトとは色々なキーとプロパティの組み合わせをひとつのモノとして扱うことができます。

オブジェクトを扱っているとき、そのインスタンスに対する比較や代入は他の言語と同じように参照の比較、代入です。その参照をほかのどこかで持たれているとそこで書き換えられる可能性があります。

インスタンスを安易に上書きすると起こる弊害

たとえば生活習慣病に関するサービスを作るとします。そのサービスでは一日の食事を入力するとその食事から熱量 (カロリー) が計算され、さらに将来的に生活習慣病 (少々異なりますがMetabolic Syndromeとします) になるかどうか判定できるとします。

ここで一日の食事を意味するオブジェクトの型としてMealsPerDayを定義し、一日に摂取した食事の熱量からいずれ生活習慣病になるかどうか判定する関数willBeMetabo()を定義すれば次のようになります。

ts
type MealsPerDay = {
breakfast: string;
lunch: string;
dinner: string;
};
 
function willBeMetabo(meals: MealsPerDay): boolean {
// ...
}
ts
type MealsPerDay = {
breakfast: string;
lunch: string;
dinner: string;
};
 
function willBeMetabo(meals: MealsPerDay): boolean {
// ...
}

使い方としては次のようになります。

ts
// 439.2 kcal
const meals: MealsPerDay = {
breakfast: "a vegetable salad",
lunch: "a cod's meuniere",
dinner: "a half bottle of wine (white)",
};
 
willBeMetabo(meals);
false
ts
// 439.2 kcal
const meals: MealsPerDay = {
breakfast: "a vegetable salad",
lunch: "a cod's meuniere",
dinner: "a half bottle of wine (white)",
};
 
willBeMetabo(meals);
false

ですが、これだけだと食べ物ではないもの、たとえばネジなどの不正な入力があったときにサービスが予想しない反応をしかねません。そこで入力されているものが本当に食事かどうかをバリデーションする関数としてisMeals()を定義します。この関数は食事ではないものが与えられると例外を投げます。

isMeals()の構造は単純です。朝食、昼食、夕食をそれぞれそれが食事であるかどうかを判定するだけです。ひとつの食事が食事であるかを判定する関数isMeal()があるとすれば内部でそれを呼ぶだけです。isMeal()の実装については今回は重要ではないため省略します。

ts
function isMeals(meals: MealsPerDay): void {
if (!isMeal(meals.breakfast)) {
throw new Error("BREAKFAST IS NOT A MEAL!");
}
if (!isMeal(meals.lunch)) {
throw new Error("LUNCH IS NOT A MEAL!!!");
}
if (!isMeal(meals.dinner)) {
throw new Error("DINNER IS NOT A MEAL!!!");
}
}
ts
function isMeals(meals: MealsPerDay): void {
if (!isMeal(meals.breakfast)) {
throw new Error("BREAKFAST IS NOT A MEAL!");
}
if (!isMeal(meals.lunch)) {
throw new Error("LUNCH IS NOT A MEAL!!!");
}
if (!isMeal(meals.dinner)) {
throw new Error("DINNER IS NOT A MEAL!!!");
}
}

今回のユースケースではisMeals()でバリデーションを行ったあとその食事をwillBeMetabo()で判定します。食べられないものが与られたときは例外を捕捉して対応できればよいので大まかにはこのような形になるでしょう。

ts
function shouldBeCareful(meals: MealsPerDay): boolean {
try {
// ...
isMeals(meals);
 
return willBeMetabo(meals);
} catch (err: unknown) {
// ...
}
}
ts
function shouldBeCareful(meals: MealsPerDay): boolean {
try {
// ...
isMeals(meals);
 
return willBeMetabo(meals);
} catch (err: unknown) {
// ...
}
}

ここでisMeals()の制作者あるいは維持者が何を思ってかisMeals()に自分の好きなコッテコテギトギトの食事を、もとのインスタンスを上書きするようにプログラムを書いたとします。この変更によって前述のとても健康的で500 kcalにも満たない食事をしているはずのユーザーがisMeals()を19,800 kcalものカロリー爆弾を摂取していることになります。

ts
function isMeals(meals: MealsPerDay): void {
meals.breakfast = "a beef steak";
// beef steak will be 1200 kcal
meals.lunch = "a bucket of ice cream";
// a bucket of ice cream will be 7200 kcal
meals.dinner = "3 pizzas";
// 3 pizzas will be 11400 kcal
 
if (!isMeal(meals.breakfast)) {
throw new Error("BREAKFAST IS NOT MEAL!");
}
if (!isMeal(meals.lunch)) {
throw new Error("LUNCH IS NOT MEAL!!!");
}
if (!isMeal(meals.dinner)) {
throw new Error("DINNER IS NOT MEAL!!!");
}
}
 
console.log(meals);
439.2 kcal
 
isMeals(meals);
 
console.log(meals);
19,800 kcal!!!
 
willBeMetabo(meals);
true
ts
function isMeals(meals: MealsPerDay): void {
meals.breakfast = "a beef steak";
// beef steak will be 1200 kcal
meals.lunch = "a bucket of ice cream";
// a bucket of ice cream will be 7200 kcal
meals.dinner = "3 pizzas";
// 3 pizzas will be 11400 kcal
 
if (!isMeal(meals.breakfast)) {
throw new Error("BREAKFAST IS NOT MEAL!");
}
if (!isMeal(meals.lunch)) {
throw new Error("LUNCH IS NOT MEAL!!!");
}
if (!isMeal(meals.dinner)) {
throw new Error("DINNER IS NOT MEAL!!!");
}
}
 
console.log(meals);
439.2 kcal
 
isMeals(meals);
 
console.log(meals);
19,800 kcal!!!
 
willBeMetabo(meals);
true

isMeals()を呼んでしまったらもうどのような食事が与えられてもwillBeMetabo()は誰もが生活習慣病に一直線であると判別されることになります。変数mealsの変更はisMeals()内に留まらず、外側にも影響を与えます。

今回の問題

今回の例はisMeals()が悪さをしました。この関数が自分たちで作ったものであればすぐに原因を見つけることができるでしょう。このような問題のある関数を書かないようにすることはもちろん大事なことですが、未熟なチームメイトがいればこのような関数を書くかもしれません。人類が過ちを犯さない前提よりも過ちを犯すことがないようにする設計の方が大事です。

isMeals()が外部から持ってきたパッケージだとすると問題です。自分たちでこのパッケージに手を加えることは容易ではないため (できなくはありません) 。制作者にプルリクエストを出してバグフィックスが行われるまで開発を止めるというのも現実的ではありません。

どうすればよかったのか

そもそもインスタンスを書き換えられないようにしてしまうか、元のインスタンスが破壊されないようにスケープゴートのインスタンスを用意するのが一般的です。前者はバリューオブジェクトと呼ばれるものが代表します。ここで紹介するのは後者のスケープゴート、つまりコピーを用意する方法です。

浅いコピー (shallow copy) とは

題名にもあるとおり浅いとは何を指しているのでしょうか?それはオブジェクトのコピーをするにあたりオブジェクトがいかに深い構造になっていても (ネストしていても) 第一階層のみをコピーすることに由来します。当然対義語は深いコピー (deep copy) です。

浅いコピーをしたオブジェクトは等しくない

浅いコピーをする関数をshallowCopy()とします。実装は難しくありませんが今回は挙動についてのみ触れたいため言及は後にします。浅いコピーをしたオブジェクトとそのオリジナルは===で比較するとfalseを返します。これはコピーの原義から当然の挙動であり、もしtrueを返すようであればそれはコピーに失敗していることになります。

ts
const object1: object = {};
const object2: object = shallowCopy(object1);
 
console.log(object1 === object2);
false
ts
const object1: object = {};
const object2: object = shallowCopy(object1);
 
console.log(object1 === object2);
false

次の例は先ほどのインスタンスの上書きを浅いコピーをすることにより防いでいる例です。mealsのインスタンスは変化せずisMeals()に引数として与えたscapegoatだけが変更されます。

ts
const scapegoat: MealsPerDay = shallowCopy(meals);
 
console.log(meals);
{ breakfast: "a vegetable salad", lunch: "a cod's meuniere", dinner: "a half bottle of wine (white)" }
 
console.log(scapegoat);
{ breakfast: "a vegetable salad", lunch: "a cod's meuniere", dinner: "a half bottle of wine (white)" }
 
isMeals(scapegoat);
 
console.log(meals);
{ breakfast: "a vegetable salad", lunch: "a cod's meuniere", dinner: "a half bottle of wine (white)" }
 
console.log(scapegoat);
{ breakfast: "a beef steak", lunch: "a bucket of ice cream", dinner: "3 pizzas" }
ts
const scapegoat: MealsPerDay = shallowCopy(meals);
 
console.log(meals);
{ breakfast: "a vegetable salad", lunch: "a cod's meuniere", dinner: "a half bottle of wine (white)" }
 
console.log(scapegoat);
{ breakfast: "a vegetable salad", lunch: "a cod's meuniere", dinner: "a half bottle of wine (white)" }
 
isMeals(scapegoat);
 
console.log(meals);
{ breakfast: "a vegetable salad", lunch: "a cod's meuniere", dinner: "a half bottle of wine (white)" }
 
console.log(scapegoat);
{ breakfast: "a beef steak", lunch: "a bucket of ice cream", dinner: "3 pizzas" }

浅いコピーで防ぎきれない場合

先ほども述べたように浅いコピーはオブジェクトの第一階層のみをコピーします。そのためもしオブジェクトが深い、複雑な階層を持っている場合、それらをすべてコピーしているのではなく、第二階層以降は単なる参照になります。次の例は浅いコピーのプロパティにオブジェクトがある場合、それがコピーではなく参照になっていることを示しています。

ts
type NestObject = {
nest: object;
};
 
const object1: NestObject = {
nest: {},
};
const object2: NestObject = shallowCopy(object1);
 
console.log(object1 === object2);
false
console.log(object1.nest === object2.nest);
true
ts
type NestObject = {
nest: object;
};
 
const object1: NestObject = {
nest: {},
};
const object2: NestObject = shallowCopy(object1);
 
console.log(object1 === object2);
false
console.log(object1.nest === object2.nest);
true

完全なコピーを作りたい場合は浅いコピーと一緒に出てきた深いコピーを使います。
深いコピーについて今回は深く触れません。浅いコピーに比べ深いコピーはコピーに時間がかかり、さらに参照ではなく実体をコピーするため、記憶領域を同じ量確保しなければなりません。何でもかんでも深いコピーをするとあっという間に時間的、空間的な領域を浪費します。浅いコピーでこと足りる場合は浅いコピーを使用する方がよいでしょう。

浅いコピーを実装する

浅いコピーの実装は昨今のJSでは大変楽になっており、次のコードで完成です。

ts
const shallowCopied: object = { ...sample };
ts
const shallowCopied: object = { ...sample };

もちろん変数sampleはオブジェクトである必要があります。この...はスプレッド構文です。スプレッド構文については関数の章を参照ください。

オブジェクトのコピーにスプレッド構文を使えるようになったのはES2018からです。たとえば次のような浅いコピーの例を

ts
const sample: object = {
year: 1999,
month: 7,
};
 
const shallowCopied: object = { ...sample };
ts
const sample: object = {
year: 1999,
month: 7,
};
 
const shallowCopied: object = { ...sample };

ES2018でコンパイルすると次のようになります。

ts
const sample = {
year: 1999,
month: 7,
};
const shallowCopied = { ...sample };
ts
const sample = {
year: 1999,
month: 7,
};
const shallowCopied = { ...sample };

ほぼ同じですがES2017でコンパイルすると次のようになります。

ts
const sample = {
year: 1999,
month: 7,
};
const shallowCopied = Object.assign({}, sample);
ts
const sample = {
year: 1999,
month: 7,
};
const shallowCopied = Object.assign({}, sample);

となります。スプレッド構文が実装される前はこのObject.assign()を使っていました。このふたつはまったく同じものではありませんがObject.assign({}, obj){...obj}のほぼ代替として使うことができます。

コピー用のAPIを使う

JavaScriptではオブジェクトによって、浅いコピーを簡潔に書くためのAPIが提供されているものがあります。MapSetはそれが利用できます。

Map<K, V>のコピー

Mapをコピーする場合は、MapコンストラクタにコピーしたいMapオブジェクトを渡します。

ts
const map1 = new Map([
[".js", "JS"],
[".ts", "TS"],
]);
const map2 = new Map(map1);
// 要素は同一だが、Mapインスタンスは異なる
console.log(map2);
Map (2) {".js" => "JS", ".ts" => "TS"}
console.log(map1 !== map2);
true
ts
const map1 = new Map([
[".js", "JS"],
[".ts", "TS"],
]);
const map2 = new Map(map1);
// 要素は同一だが、Mapインスタンスは異なる
console.log(map2);
Map (2) {".js" => "JS", ".ts" => "TS"}
console.log(map1 !== map2);
true

📄️ Map<K, V>

MapはJavaScriptの組み込みAPIのひとつで、キーと値のペアを取り扱うためのオブジェクトです。Mapにはひとつのキーについてはひとつの値のみを格納できます。

Set<T>のコピー

Setをコピーする場合は、SetコンストラクタにコピーしたいSetオブジェクトを渡します。

ts
const set1 = new Set([1, 2, 3]);
const set2 = new Set(set1);
// 要素は同一だが、Setのインスタンスは異なる
console.log(set2);
Set (3) {1, 2, 3}
console.log(set1 !== set2);
true
ts
const set1 = new Set([1, 2, 3]);
const set2 = new Set(set1);
// 要素は同一だが、Setのインスタンスは異なる
console.log(set2);
Set (3) {1, 2, 3}
console.log(set1 !== set2);
true

📄️ Set<T>

SetはJavaScriptの組み込みAPIのひとつで、値のコレクションを扱うためのオブジェクトです。Setには重複する値が格納できません。Setに格納された値は一意(unique)になります。

Array<T>のコピー

配列をコピーする方法はいくつかありますが、もっとも簡単なのは配列のスプレッド構文を用いたものです。

ts
const array1 = [1, 2, 3];
const array2 = [...array1];
ts
const array1 = [1, 2, 3];
const array2 = [...array1];

このときスプレッド構文...を書き忘れると配列の配列T[][]型ができあがるので気をつけてください。

関連情報

📄️ 配列のスプレッド構文「...」

JavaScript の配列ではスプレッド構文「...」を使うことで、要素を展開することができます。
  • 質問する ─ 読んでも分からなかったこと、TypeScriptで分からないこと、お気軽にGitHubまで🙂
  • 問題を報告する ─ 文章やサンプルコードなどの誤植はお知らせください。