C言語でシャローコピーとディープコピーを実装する

101, 2020-11-05

目次

はじめに

プログラムのコピーには大きく分けて「シャローコピー」と「ディープコピー」があります。
シャローコピーは浅いコピーで、ディープコピーは深いコピーです。

これらのコピーについて、具体的には↓を見ていきます。

  • コピーとは

  • コピーの重要性

  • シャローコピーとディープコピーとは?

  • シャローコピーとディープコピーの実装

また、シャローコピーとディープコードの実装例にはC言語を使っています。あらかじめご了承ください。

コピーとは

コピーとは、変数に別の変数の値、または値そのものを代入し、複製することを指します。
123という値を別の変数にコピーすると、その変数の値は123になります。

このとき、123という値は共有されていないということに注意が必要です。
共有というのはメモリ上のデータが共有されているということですが、コピーの場合はメモリ上のデータは共有されません。
つまりコピーされた値は、つねにメモリ上では新しいデータになっているわけです。
これは言い換えると、その値のメモリ上の番地が異なっているということです。

JavaやC++などの言語にはコピーの他には「参照」という機能もあります。
これはメモリ上のデータを共有する言語機能です。
言語の実装によりますが、これらの「参照」の機能は、C言語による実装であればポインタで実現されていることが多いです。

今回の実装で使うC言語には「参照」という機能はありません。
あるのは「値のコピー」だけです。
しかしポインタの機能によって、参照のような振る舞いを実装することが可能です。

つまり、実装レベルでのコピーによって言語レベルにおける参照という機能が実現できるということになります。
このようにコピーというのは実は奥が深い機能だったりします。

コピーは奥が深い

プログラムというものは言ってしまえばすべてどのようにコピーするかです。
コピーによってプログラムの振る舞いが決まります。

多くの言語では、このコピーについて深く考えなくてもなんとなくで使えるように設計されています。
しかし、このコピーの知識は、高度なプログラミングでは必ず必要になってきます。
また、理解できないバグに遭遇したときなどにも役に立つ知識です。

「なぜこんな値が出力されるんだ?」

という時に、コピーの知識があれば目星をつけることも出来るかもしれません。

コピーはアセンブラの時代から使われてきた最古のプログラミング言語の機能と言えます。
このコピーについて理解することで、一段深いプログラミングが可能になるかもしれません。

また、シャローコピーの特性と危険性について認識することで、バグの少ないプログラムを作れるようになるかもしれません。

言語におけるコピーの重要性

コンパイラ系の言語やインタプリタ系の言語では「コピー」という概念が重要になってきます。

オブジェクトなどのコピーを行う時に、そのオブジェクトをどのようにコピーするのか? ということです。
オブジェクト全体を参照としてコピーするのか、それともオブジェクト全体をメモリ上に新しく確保してからコピーするのか、という違いです。

もっと簡単に言うと、「参照としてコピー」するのか、「丸ごとコピー」するのか、ということになります。

この2つの違いは非常に重要です。
これら2つのコピーは、実際のプログラムの動作において全く違う結果を生みます。

コピーはひと言で言ってしまうとただの値の複製ですが、そのコピー方法によって分類が可能なほど違うものになります。
「参照としてコピー」する場合と「丸ごとコピー」する場合とでは、コピーした結果のオブジェクトがまったく異なるため、そのオブジェクトを使ったコードを書く場合に注意する必要があるのです。

シャローコピーとディープコピーとは?

先程出てきた「参照としてコピー」することを「シャローコピー」と言います。
いっぽう、「丸ごとコピー」することを「ディープコピー」と言います。
これら2つのコピーにはこのように名前がついていて、それらは明確に区別されています。

言い換えると、シャローコピーとは「浅いコピー」です。
再帰的にオブジェクトの内部を浅いコピー、つまりデータを参照としてコピーしていきます。
コピーされた結果はメモリ上に新しく配置されているデータではなく、メモリ上で共有されているデータです。

ディープコピーとは「深いコピー」です。
再帰的にオブジェクトの内部を深いコピー、つまりデータを丸ごとコピーしていきます。
コピーされた結果は元のデータとメモリ上で共有されず、まったく新しいメモリ上のデータになります。

実際の開発ではこれら2つのコピーは明確に区別して使います。
これらを混同して使うと、プロジェクトがカオスになるので気をつけてください。

シャローコピーは速度が重要な時、そしてオブジェクトをメモリ上で共有したい時に使います。
シャローコピーされたオブジェクトはメモリ上で共有された状態になるため、共有している1つのオブジェクトを変更すると、その変更は共有している別のオブジェクトにも影響します。
このため予期しない動作になることがあるため注意が必要なコピーです。

ディープコピーはオブジェクトを完全に別物としてコピーしたいとき、つまりメモリ上で違う番地のオブジェクトにしたい時に使います。
ディープコピーされたオブジェクトは、そのオブジェクトへの変更がコピー元のオブジェクトや他のオブジェクトに伝わらないため、安全に使うことができます。
速度はシャローコピーに劣りますが、安全性ではディープコピーの方が勝ります。
そのためセキュアな処理を書きたい時に多用するコピーであると言えます。

Pythonのcopyモジュール

インタプリタ系の言語であるPythonには、これらのシャローコピーとディープコピーを扱うためのモジュールがあります。
その名もcopyです。

copyはシャローコピーを行うcopy関数とディープコピーを行うdeepcopy関数を持っています。
これらのコピーを使うことで、適切なコピーの結果を受け取ることが可能です。

copyモジュールの使用例↓。

import copy

class A:
  def __init__(self):
    self.a = 1

a = A()
print(id(a))
print(id(copy.deepcopy(a)))

出力結果。

23716912
32118024

↑の例ではid()によって出力される変数aのメモリ上のアドレスが違うものになっています。
これはつまり、copy.deepcopy()によって変数aが丸ごとコピーされ、メモリ上に新しく領域が確保されたということです。

Pythonでは基本的にオブジェクトの代入などは、オブジェクトのアドレス値のコピーでやり取りされます。
そのため代入文などで余分なコピーは発生しません。
これはPythonが速度を上げるための仕様です。

インタプリタは基本的に動作が遅いので、オブジェクトの代入ではアドレス値のコピーだけで済ませ、実体のコピーは行わないのが一般的です。
アドレス値のコピーとはポインタ変数に他のポインタ変数の値を代入するということになります。

アドレス値のコピーも、厳密にはコピーに分類されますが、シャローコピーとディープコピーとは区別されて使われているようです。
このアドレス値のコピーと言う表現はC言語では一般的ですが、より高度な言語ではあまり一般的な表現ではありません。
しかしそれらの言語の実装にはこのアドレス値のコピーが使われていることを考えると、とても重要なコピーだということがわかります。

シャローコピーとディープコピーの実装

C言語でシャローコピーとディープコピーを実装してみましょう。
↓のコードではエラー処理とメモリの解放処理は省略しています。

#include <stdio.h>
#include <stdlib.h>

typedef struct {
  int legs;
} body_t;

typedef struct {
  body_t *body;
} animal_t;

body_t *
body_new(int legs) {
  body_t *self = calloc(1, sizeof(*self));
  self->legs = legs;  // intは値のコピーのみ
  return self;
}

body_t *
body_deep_copy(const body_t *other) {
  return body_new(other->legs);  // ディープコピー
}

body_t *
body_shallow_copy(const body_t *other) {
  return body_deep_copy(other);  // legsはintなのでディープコピーで処理(シャローコピーの必要なし)
}

animal_t *
animal_new(int legs) {
  animal_t *self = calloc(1, sizeof(*self));
  self->body = body_new(legs);
  return self;
}

animal_t *
animal_shallow_copy(const animal_t *other) {
  animal_t *self = calloc(1, sizeof(*self));
  self->body = other->body;  // シャローコピー
  return self;
}

animal_t *
animal_deep_copy(const animal_t *other) {
  return animal_new(other->body->legs);  // ディープコピー
}

int
main(void) {
  animal_t *tama = animal_new(4);
  animal_t *mike = animal_shallow_copy(tama);  // シャローコピー
  animal_t *tanuki = animal_deep_copy(tama);  // ディープコピー

  printf("tama body %p (source object)\n", tama->body);
  printf("mike body %p (shallow copied)\n", mike->body);
  printf("tanuki body %p (deep copied)\n", tanuki->body);
  return 0;
}

出力結果。

tama body 00000000001C1420 (source object)
mike body 00000000001C1420 (shallow copied)
tanuki body 00000000001C1480 (deep copied)

注目してほしいのはanimal_shallow_copy()animal_deep_copy()です。
animal_tが持っているメンバ変数bodyの扱い方を見てください。
animal_shallow_copy()ではポインタ変数の代入(アドレス値のコピー)だけで済ましているのに対して、animal_deep_copy()ではbodyのメモリを新しく確保しています。

結果を見ると、シャローコピーしたmikebodyは、元になったtamabodyと同じアドレス値になっていますが、ディープコピーしたtanukibodyは、tamabodyとは違うアドレス値になっています。
これはtanukiがディープコピーされたものだからです。

シャローコピーによってbodyの実体のアドレス値が共有されている状態になっています。
このためメモリの解放にはガーベジコレクションなどの仕組みが必要になるので、↑のコードではメモリの解放処理は省略しています。

おわりに

プログラミングによる開発ではシャローコピーとディープコピーの理解と、明確な区別が必要です。
これらのコピーを区別して扱うことによって、プロジェクトの品質を上げることが出来るでしょう。

コピーを制する者は設計を制する

だまってガーベジコレクションだ!