Python: 浅いコピーと深いコピーの違いが学べた実例

Python: 浅いコピーと深いコピーの違いが学べた実例
Page content

オブジェクトのコピー操作の種類

先日、複数のJSONファイルを読み込み、JSON中のデータを変換しながら一つのJSONファイルにまとめるPythonプログラムを書いていたところ、データの変換が意図通りにいかないという謎の現象にどハマりして、解決にN時間を要した。

結論から言うと、この謎の現象に遭遇した理由は、私がその時点ではオブジェクトに関する「浅いコピー」 (shallow copy) しか知らす、論理的には「深いコピー」 (deep copy) を行うべきところで「浅いコピー」を行っていたから。

この理由が判明したとき、私は気持ちがめちゃくちゃ晴れ晴れしたので、せっかくだからPythonネタとしてメモを残しておく。どなたかの参考になれば幸い。

コピー操作による違いが分かるプログラムと実行結果

前述の、浅いコピーと深いコピーの動作の違いを思い知ったPythonプログラムの、内部処理をデフォルメした版をexample_copy_deepcopy.pyとして掲載する。

このプログラムは、リンゴとバナナの情報を、内部に定義された辞書を使って変換してから表示するものだ。実行結果を解説するとこのようになる。

  • 変換元要素がid=1のリンゴのみの時、リンゴ🍎の色は「赤🟥」と意図通りに出力される。つまり for example in input_dict['examples'] のループが回らない時 (for内の実行が1度だけの時) には.copy()copy.deepcopy()の動作の違いは発覚しない。
  • 変換元要素にid=2のバナナを加えた時、.copy()でテンプレートを複製した場合は、id=1のリンゴ🍎の、規格の情報としてid=2のバナナ🍌の情報が代入され、たとえば色は「黄🟨」として出力される。
    {
      "id": 1,
      "名前": "リンゴ🍎",
      "規格": {
        "色": "黄🟨",
        "価格": 200
      },
      "生産国": "日本🇯🇵"
    },
    

はて。なぜリンゴの色が黄色くなるのか? 正確には、名前や生産国には問題がないのに規格の中には情報がなぜ意図通りに代入されなかったのか?

上記ページから引用する。

浅いコピー (shallow copy) は新たな複合オブジェクトを作成し、その後 (可能な限り) 元のオブジェクト中に見つかったオブジェクトに対する 参照 を挿入します。

深いコピー (deep copy) は新たな複合オブジェクトを作成し、その後元のオブジェクト中に見つかったオブジェクトの コピー を挿入します。

すなわち、浅いコピーでテンプレートを複製した場合には、コピー先の複合オブジェクト —今回の例では「規格」の中— に関しては新しいオブジェクトではなく元オブジェクトへの参照が挿入されており、その後にコピー先に対して値を代入したら「規格」の中に関しては実は元オブジェクトに変更が加わっていた、ということなのだ。

浅いコピーの仕様と使い方が面白いぐらいどハマりしちゃったな……と思える良い経験だった。

プログラム: example_copy_deepcopy.py

#!/usr/bin/env python3

# example_copy_deepcopy.py (Ver.20250112)
# Ref: https://docs.python.org/ja/3/library/copy.html

import copy
import json

# 入力データを変換して出力する
def convert(method: str, input_dict: dict) -> list:
	# データ変換の辞書を用意する
	mapping_dict: dict = {
		'banana': 'バナナ🍌',
		'apple': 'リンゴ🍎',
		'red': '赤🟥',
		'yellow': '黄🟨',
		'Japan': '日本🇯🇵',
		'Philippines': 'フィリピン🇵🇭'
	}
	# 出力時のテンプレートを用意する
	template_dict: dict = {
		'id': None,
		'名前': None,
		'規格': {
			'色': None,
			'価格': None
		},
		'生産国': None
	}
	r: list = []
	# 入力データ(list)の各要素の値を変換して出力データに追加する
	for example in input_dict['examples']:
		if method == 'copy': # .copy() でテンプレートを複製
			output_dict: dict = template_dict.copy()
		elif method == 'deepcopy': # copy.deepcopy() でテンプレートを複製
			output_dict: dict = copy.deepcopy(template_dict)
		# テンプレートにデータ変換後の値を代入する
		output_dict['id'] = example['id'] # そのまま
		output_dict['名前'] = mapping_dict[example['name']] # 辞書を使う
		output_dict['規格']['色'] = mapping_dict[example['color']] # 辞書を使う
		output_dict['規格']['価格'] = example['price'] # そのまま
		output_dict['生産国'] = mapping_dict[example['country_of_origin']] # 辞書を使う
		r.append(output_dict)
	return r

# 結果を出力する
def print_result(d: dict) -> None:
	print('### .copy() でテンプレートを複製した場合 (要素数: {:d})'.format(len(d['examples'])))
	print(json.dumps(convert('copy', d), indent=2, ensure_ascii=False))
	print('### copy.deepcopy() でテンプレートを複製した場合 (要素数: {:d})'.format(len(d['examples'])))
	print(json.dumps(convert('deepcopy', d), indent=2, ensure_ascii=False))

if __name__ == '__main__':
	# テストデータを1個作成
	test_dict: dict = {
		'examples': [
			{
				'id': 1,
				'name': 'apple',
				'color': 'red',
				'price': 100,
				'country_of_origin': 'Japan'
			}
		]
	}
	print_result(test_dict)
	# テストデータに1個追加
	test_dict['examples'].append(
		{
			'id': 2,
			'name': 'banana',
			'color': 'yellow',
			'price': 200,
			'country_of_origin': 'Philippines'
		}
	)
	print_result(test_dict)

実行結果

$ ./example_copy_deepcopy.py
### .copy() でテンプレートを複製した場合 (要素数: 1)
[
  {
    "id": 1,
    "名前": "リンゴ🍎",
    "規格": {
      "色": "赤🟥",
      "価格": 100
    },
    "生産国": "日本🇯🇵"
  }
]
### copy.deepcopy() でテンプレートを複製した場合 (要素数: 1)
[
  {
    "id": 1,
    "名前": "リンゴ🍎",
    "規格": {
      "色": "赤🟥",
      "価格": 100
    },
    "生産国": "日本🇯🇵"
  }
]
### .copy() でテンプレートを複製した場合 (要素数: 2)
[
  {
    "id": 1,
    "名前": "リンゴ🍎",
    "規格": {
      "色": "黄🟨",
      "価格": 200
    },
    "生産国": "日本🇯🇵"
  },
  {
    "id": 2,
    "名前": "バナナ🍌",
    "規格": {
      "色": "黄🟨",
      "価格": 200
    },
    "生産国": "フィリピン🇵🇭"
  }
]
### copy.deepcopy() でテンプレートを複製した場合 (要素数: 2)
[
  {
    "id": 1,
    "名前": "リンゴ🍎",
    "規格": {
      "色": "赤🟥",
      "価格": 100
    },
    "生産国": "日本🇯🇵"
  },
  {
    "id": 2,
    "名前": "バナナ🍌",
    "規格": {
      "色": "黄🟨",
      "価格": 200
    },
    "生産国": "フィリピン🇵🇭"
  }
]