オブジェクトを浅くコピーする
オブジェクトとは色々なキーとプロパティの組み合わせをひとつのモノとして扱うことができます。
オブジェクトを扱っているとき、そのインスタンスに対する比較や代入は他の言語と同じように参照の比較、代入です。その参照をほかのどこかで持たれているとそこで書き換えられる可能性があります。
インスタンスを安易に上書きすると起こる弊害
たとえば生活習慣病に関するサービスを作るとします。そのサービスでは一日の食事を入力するとその食事から熱量 (カロリー) が計算され、さらに将来的に生活習慣病 (少々異なりますがMetabolic Syndromeとします) になるかどうか判定できるとします。
ここで一日の食事を意味するオブジェクトの型としてMealsPerDay
を定義し、一日に摂取した食事の熱量からいずれ生活習慣病になるかどうか判定する関数willBeMetabo()
を定義すれば次のようになります。
ts
typeMealsPerDay = {breakfast : string;lunch : string;dinner : string;};functionwillBeMetabo (meals :MealsPerDay ): boolean {// ...}
ts
typeMealsPerDay = {breakfast : string;lunch : string;dinner : string;};functionwillBeMetabo (meals :MealsPerDay ): boolean {// ...}
使い方としては次のようになります。
ts
// 439.2 kcalconstmeals :MealsPerDay = {breakfast : "a vegetable salad",lunch : "a cod's meuniere",dinner : "a half bottle of wine (white)",};willBeMetabo (meals );
ts
// 439.2 kcalconstmeals :MealsPerDay = {breakfast : "a vegetable salad",lunch : "a cod's meuniere",dinner : "a half bottle of wine (white)",};willBeMetabo (meals );
ですが、これだけだと食べ物ではないもの、たとえばネジなどの不正な入力があったときにサービスが予想しない反応をしかねません。そこで入力されているものが本当に食事かどうかをバリデーションする関数としてisMeals()
を定義します。この関数は食事ではないものが与えられると例外を投げます。
isMeals()
の構造は単純です。朝食、昼食、夕食をそれぞれそれが食事であるかどうかを判定するだけです。ひとつの食事が食事であるかを判定する関数isMeal()
があるとすれば内部でそれを呼ぶだけです。isMeal()
の実装については今回は重要ではないため省略します。
ts
functionisMeals (meals :MealsPerDay ): void {if (!isMeal (meals .breakfast )) {throw newError ("BREAKFAST IS NOT A MEAL!");}if (!isMeal (meals .lunch )) {throw newError ("LUNCH IS NOT A MEAL!!!");}if (!isMeal (meals .dinner )) {throw newError ("DINNER IS NOT A MEAL!!!");}}
ts
functionisMeals (meals :MealsPerDay ): void {if (!isMeal (meals .breakfast )) {throw newError ("BREAKFAST IS NOT A MEAL!");}if (!isMeal (meals .lunch )) {throw newError ("LUNCH IS NOT A MEAL!!!");}if (!isMeal (meals .dinner )) {throw newError ("DINNER IS NOT A MEAL!!!");}}
今回のユースケースではisMeals()
でバリデーションを行ったあとその食事をwillBeMetabo()
で判定します。食べられないものが与られたときは例外を捕捉して対応できればよいので大まかにはこのような形になるでしょう。
ts
functionshouldBeCareful (meals :MealsPerDay ): boolean {try {// ...isMeals (meals );returnwillBeMetabo (meals );} catch (err : unknown) {// ...}}
ts
functionshouldBeCareful (meals :MealsPerDay ): boolean {try {// ...isMeals (meals );returnwillBeMetabo (meals );} catch (err : unknown) {// ...}}
ここでisMeals()
の制作者あるいは維持者が何を思ってかisMeals()
に自分の好きなコッテコテギトギトの食事を、もとのインスタンスを上書きするようにプログラムを書いたとします。この変更によって前述のとても健康的で500 kcalにも満たない食事をしているはずのユーザーがisMeals()
を19,800 kcalものカロリー爆弾を摂取していることになります。
ts
functionisMeals (meals :MealsPerDay ): void {meals .breakfast = "a beef steak";// beef steak will be 1200 kcalmeals .lunch = "a bucket of ice cream";// a bucket of ice cream will be 7200 kcalmeals .dinner = "3 pizzas";// 3 pizzas will be 11400 kcalif (!isMeal (meals .breakfast )) {throw newError ("BREAKFAST IS NOT MEAL!");}if (!isMeal (meals .lunch )) {throw newError ("LUNCH IS NOT MEAL!!!");}if (!isMeal (meals .dinner )) {throw newError ("DINNER IS NOT MEAL!!!");}}console .log (meals );isMeals (meals );console .log (meals );willBeMetabo (meals );
ts
functionisMeals (meals :MealsPerDay ): void {meals .breakfast = "a beef steak";// beef steak will be 1200 kcalmeals .lunch = "a bucket of ice cream";// a bucket of ice cream will be 7200 kcalmeals .dinner = "3 pizzas";// 3 pizzas will be 11400 kcalif (!isMeal (meals .breakfast )) {throw newError ("BREAKFAST IS NOT MEAL!");}if (!isMeal (meals .lunch )) {throw newError ("LUNCH IS NOT MEAL!!!");}if (!isMeal (meals .dinner )) {throw newError ("DINNER IS NOT MEAL!!!");}}console .log (meals );isMeals (meals );console .log (meals );willBeMetabo (meals );
isMeals()
を呼んでしまったらもうどのような食事が与えられてもwillBeMetabo()
は誰もが生活習慣病に一直線であると判別されることになります。変数meals
の変更はisMeals()
内に留まらず、外側にも影響を与えます。
今回の問題
今回の例はisMeals()
が悪さをしました。この関数が自分たちで作ったものであればすぐに原因を見つけることができるでしょう。このような問題のある関数を書かないようにすることはもちろん大事なことですが、未熟なチームメイトがいればこのような関数を書くかもしれません。人類が過ちを犯さない前提よりも過ちを犯すことがないようにする設計の方が大事です。
isMeals()
が外部から持ってきたパッケージだとすると問題です。自分たちでこのパッケージに手を加えることは容易ではないため (できなくはありません) 。制作者にプルリクエストを出してバグフィックスが行われるまで開発を止めるというのも現実的ではありません。
どうすればよかったのか
そもそもインスタンスを書き換えられないようにしてしまうか、元のインスタンスが破壊されないようにスケープゴートのインスタンスを用意するのが一般的です。前者はバリューオブジェクトと呼ばれるものが代表します。ここで紹介するのは後者のスケープゴート、つまりコピーを用意する方法です。
浅いコピー (shallow copy) とは
題名にもあるとおり浅いとは何を指しているのでしょうか?それはオブジェクトのコピーをするにあたりオブジェクトがいかに深い構造になっていても (ネストしていても) 第一階層のみをコピーすることに由来します。当然対義語は深いコピー (deep copy) です。
浅いコピーをしたオブジェクトは等しくない
浅いコピーをする関数をshallowCopy()
とします。実装は難しくありませんが今回は挙動についてのみ触れたいため言及は後にします。浅いコピーをしたオブジェクトとそのオリジナルは===
で比較するとfalse
を返します。これはコピーの原義から当然の挙動であり、もしtrue
を返すようであればそれはコピーに失敗していることになります。
ts
constobject1 : object = {};constobject2 : object =shallowCopy (object1 );console .log (object1 ===object2 );
ts
constobject1 : object = {};constobject2 : object =shallowCopy (object1 );console .log (object1 ===object2 );
次の例は先ほどのインスタンスの上書きを浅いコピーをすることにより防いでいる例です。meals
のインスタンスは変化せずisMeals()
に引数として与えたscapegoat
だけが変更されます。
ts
constscapegoat :MealsPerDay =shallowCopy (meals );console .log (meals );console .log (scapegoat );isMeals (scapegoat );console .log (meals );console .log (scapegoat );
ts
constscapegoat :MealsPerDay =shallowCopy (meals );console .log (meals );console .log (scapegoat );isMeals (scapegoat );console .log (meals );console .log (scapegoat );
浅いコピーで防ぎきれない場合
先ほども述べたように浅いコピーはオブジェクトの第一階層のみをコピーします。そのためもしオブジェクトが深い、複雑な階層を持っている場合、それらをすべてコピーしているのではなく、第二階層以降は単なる参照になります。次の例は浅いコピーのプロパティにオブジェクトがある場合、それがコピーではなく参照になっていることを示しています。
ts
typeNestObject = {nest : object;};constobject1 :NestObject = {nest : {},};constobject2 :NestObject =shallowCopy (object1 );console .log (object1 ===object2 );console .log (object1 .nest ===object2 .nest );
ts
typeNestObject = {nest : object;};constobject1 :NestObject = {nest : {},};constobject2 :NestObject =shallowCopy (object1 );console .log (object1 ===object2 );console .log (object1 .nest ===object2 .nest );
完全なコピーを作りたい場合は浅いコピーと一緒に出てきた深いコピーを使います。
深いコピーについて今回は深く触れません。浅いコピーに比べ深いコピーはコピーに時間がかかり、さらに参照ではなく実体をコピーするため、記憶領域を同じ量確保しなければなりません。何でもかんでも深いコピーをするとあっという間に時間的、空間的な領域を浪費します。浅いコピーでこと足りる場合は浅いコピーを使用する方がよいでしょう。
浅いコピーを実装する
浅いコピーの実装は昨今のJSでは大変楽になっており、次のコードで完成です。
ts
constshallowCopied : object = { ...sample };
ts
constshallowCopied : object = { ...sample };
もちろん変数sample
はオブジェクトである必要があります。この...
はスプレッド構文です。スプレッド構文については関数の章を参照ください。
オブジェクトのコピーにスプレッド構文を使えるようになったのはES2018からです。たとえば次のような浅いコピーの例を
ts
constsample : object = {year : 1999,month : 7,};constshallowCopied : object = { ...sample };
ts
constsample : object = {year : 1999,month : 7,};constshallowCopied : object = { ...sample };
ES2018でコンパイルすると次のようになります。
ts
constsample = {year : 1999,month : 7,};constshallowCopied = { ...sample };
ts
constsample = {year : 1999,month : 7,};constshallowCopied = { ...sample };
ほぼ同じですがES2017でコンパイルすると次のようになります。
ts
constsample = {year : 1999,month : 7,};constshallowCopied =Object .assign ({},sample );
ts
constsample = {year : 1999,month : 7,};constshallowCopied =Object .assign ({},sample );
となります。スプレッド構文が実装される前はこのObject.assign()
を使っていました。このふたつはまったく同じものではありませんがObject.assign({}, obj)
を{...obj}
のほぼ代替として使うことができます。
コピー用のAPIを使う
JavaScriptではオブジェクトによって、浅いコピーを簡潔に書くためのAPIが提供されているものがあります。Map
やSet
はそれが利用できます。
Map<K, V>
のコピー
Map
をコピーする場合は、Map
コンストラクタにコピーしたいMap
オブジェクトを渡します。
ts
constmap1 = newMap ([[".js", "JS"],[".ts", "TS"],]);constmap2 = newMap (map1 );// 要素は同一だが、Mapインスタンスは異なるconsole .log (map2 );console .log (map1 !==map2 );
ts
constmap1 = newMap ([[".js", "JS"],[".ts", "TS"],]);constmap2 = newMap (map1 );// 要素は同一だが、Mapインスタンスは異なるconsole .log (map2 );console .log (map1 !==map2 );
📄️ Map<K, V>
Set<T>
のコピー
Set
をコピーする場合は、Set
コンストラクタにコピーしたいSet
オブジェクトを渡します。
ts
constset1 = newSet ([1, 2, 3]);constset2 = newSet (set1 );// 要素は同一だが、Setのインスタンスは異なるconsole .log (set2 );console .log (set1 !==set2 );
ts
constset1 = newSet ([1, 2, 3]);constset2 = newSet (set1 );// 要素は同一だが、Setのインスタンスは異なるconsole .log (set2 );console .log (set1 !==set2 );
📄️ Set<T>
Array<T>
のコピー
配列をコピーする方法はいくつかありますが、もっとも簡単なのは配列のスプレッド構文を用いたものです。
ts
constarray1 = [1, 2, 3];constarray2 = [...array1 ];
ts
constarray1 = [1, 2, 3];constarray2 = [...array1 ];
このときスプレッド構文...
を書き忘れると配列の配列T[][]
型ができあがるので気をつけてください。