[Python] Listの値がいつの間にか変更される!? 浅いコピー、深いコピーについて

2021年9月9日木曜日

Python

Listやdictの中身が知らぬ間に変わっていたことはないでしょうか?
本記事では不具合の原因になりやすい浅いコピー(shallow copy)と対策について紹介します

アイキャッチ

ミュータブルとイミュータブル

Listの中身が勝手に変わってしまう原因を理解するためにはミュータブルイミュータブルについて理解する必要があります。

イミュータブルとは

Pythonの公式ドキュメントを見ていきます。
公式ドキュメントの用語集には下記のように記載されています

immutableの定義
Python 3.9.4 用語集より

余談ですが、言語仕様やライブラリなどの不明点は極力公式ドキュメントから参照するようにしましょう
文章量が多く読むのがめんどくさい場合がありますが、出元不明の情報を参考にしてハマるよりトータルコストが少なくなり、間違いのない情報が得られます。

イミュータブルの説明に戻りますが、なかなか難しい説明ですね。
具体例を用いて見ていきます。説明にも出ている数値を例に見てみます。

a = 10
print(a, id(a))
# 10, 19910784

a = a+1
print(a, id(a))
# 11, 19910760

上記のプログラムは「a」という変数に「10」というオブジェクトを定義しています。
そして「id()」という関数はオブジェクトの識別子を出力する関数です。

上記のプログラムで注目頂きたい点は、
「10」のオブジェクトの識別子は「19910784」でしたが、
a = a+1されたオブジェクト「11」の識別子は「19910760」であり識別子が異なる点です。

つまり、オブジェクト「10」とオブジェクト「11」は個別に記憶されていることが分かります。
変数「a」に対してオブジェクト「10」が定義されていましたが、a+1した結果の値「11」は
オブジェクト「10」の値を変更したのではなく、新たにオブジェクト「11」を作成したということになります。

このようなイミュータブルなオブジェクトが存在するため辞書(dict)は成立しています。

dict_a = {
    "key_1" : 10,
    "key_2" : 20
}

上記の辞書dict_aにおいて「key_1」を指定した時、値「10」が取得されることを期待しています。
しかしイミュータブルが保証されない場合、「key_1」を指定したにもかかわらず
「key_2」の値「20」が取得される可能性があるわけです。

このような振る舞いは実装者の予期せぬ振る舞いになるため辞書のキーはイミュータブルなオブジェクトのみ指定可能です。

ミュータブルとは

イミュータブルと同じくミュータブルについてもPythonの公式ドキュメントを見てみます。

mutableの定義
Python 3.9.4 用語集より

イミュータブルに対して、簡潔な説明ですね。
id()が変わらないとのことなので、値を変更してもオブジェクトの識別子に変更はないようです。
こちらも具体例を用いて確認します。

b = [1,2,3,4,5]
print(b, id(b))
# [1, 2, 3, 4, 5], 140207035795072

b.append(6)
print(b, id(b))
# [1, 2, 3, 4, 5, 6], 140207035795072

上記プログラムではオブジェクト「[1,2,3,4,5]」に「6」を追加しています。
イミュータブルと異なる点は、オブジェクトの値を変更した際にオブジェクトの識別子が変わっていない点です。つまり、オブジェクトの値を直接変更しているということになります。

以下の図は、それぞれの違いを表現したイメージ図です。

オブジェクト管理の違い

ミュータブルのコピー

本題に入ります。
意図しない値の変更が起きるケースはミュータブルのオブジェクトをコピーした際に発生します。

始めに意図しない変更の具体例を見てみます。

# 配列の初期化
list_a = [[1,2], [3,4]]
list_b = [[5,6], [7,8]]
list_c = list_a + list_b

print(list_c, id(list_c))
# [[1, 2], [3, 4], [5, 6], [7, 8]], 139730646068488

# 意図しない変更が起きない場合
# list_aの値[1,2]を[10,20]に変更
list_a[0] = [10, 20]
print(list_c, id(list_c))
# [[1, 2], [3, 4], [5, 6], [7, 8]] 139730646068488

# 意図しない変更が起きる場合
# list_bの値5を50に変更
list_b[0][0] = 50
print(list_c, id(list_c)) # list_cの値も変更してしまっている
# [[1, 2], [3, 4], [50, 6], [7, 8]] 139730646068488

上記プログラムではlist_aとlist_bの配列を足し合わせてlist_cを生成しています。

この時、list_a[0]の値を変更した場合にlist_cに影響はありませんが、
list_b[0][0]の値を変更した場合には、list_cの値も変更されています。
この振る舞いを意図していない場合、思わぬ不具合が発生しそうですね。

なぜこのような事が起こるのか? それはlist_cに対して、list_aが浅いコピーされていることに起因します。
それでは、浅いコピーについて見ていきます

浅いコピー

浅いコピーとは何なのでしょうか? Pythonの公式ドキュメントを見てみます。

浅いコピー定義
Python 3.9.4 copyより

注目すべきは、後半の一行
その後 (可能な限り) 元のオブジェクト中に見つかったオブジェクトに対する参照を挿入
です。
オブジェクトに対する参照の挿入とは何でしょうか? 具体例で見ていきます。

# 配列の初期化
list_a = [[1,2], [3,4]]
list_b = [[5,6], [7,8]]
list_c = list_a + list_b

print(list_a, id(list_a))
# [[1, 2], [3, 4]] 139775712747016
print(list_a[0], id(list_a[0]))
# [1, 2] 139775712746888
print(list_a[0][0], id(list_a[0][0]))
# 1 139775840450240

print(list_c, id(list_c)) # list_aとは異なるid
# [[1, 2], [3, 4], [5, 6], [7, 8]] 139775712746696
print(list_c[0], id(list_c[0])) # list_aと同じid
# [1, 2] 139775712746888
print(list_c[0][0], id(list_c[0][0])) # list_aと同じid
# 1 139775840450240

上記プログラムで注目すべきはオブジェクトの識別子です。
list_aとlist_cは異なるオブジェクトの識別子ですが、一方でlist_a[0]とlist_c[0]のオブジェクトの識別子は同じになっています。

つまり、list_aとlist_cはまったく異なるオブジェクトのようですが、
配列の中のオブジェクトは同じオブジェクトなのです。

このオブジェクトの中のオブジェクトを共有する点が、浅いコピーの参照を挿入しているという状態を示しています。

浅いコピーの概念図

浅いコピーのメリット・デメリット

一見、不具合を引き落とす原因になりそうな浅いコピーですが、もちろんメリットも存在します。
それは、メモリ使用量が少ないという点です。
先ほどの例でいうと、list_aをもとに、list_cを生成していますが、中のオブジェクトは共有しているため
新たにメモリを確保する必要はありません。list_aが非常に巨大な配列であるとき、大きなメモリの節約に繋がります。
機械学習等大きな配列を取り扱う場合に大きな効果を発揮します。

一方でデメリットも存在します。
それはタイトルにもある通り、不具合の原因になるという点です。
ただしこのデメリットは浅いコピーそのもののデメリットというより、正しく利用しなかった場合のデメリットと言えます。

このようなデメリットを引き起こさないためには、浅いコピーをどのように場面で使うのが望ましいのでしょうか?

浅いコピーの使いどころ

浅いコピーの使いどころを考えてみます。
端的に言えば、ミュータブルなオブジェクトの値を変更しないときです。
値を参照するだけ、並び変える(ソート)だけの場合など、対象のオブジェクトの値を更新しない場合は、問題になりません。

意図しない値の変更を恐れて全てのオブジェクトをコピーするとメモリを大量に消費します。
正しく使ってスリムで分かりやすいプログラムが書けると良いですね。

また、もちろんコピー元、コピー先共に値を変更したい場合も存在すると思います。
そんな時には、深いコピーを利用します。

深いコピー

深いコピーとは何なのでしょうか? Pythonの公式ドキュメントを見てみます。

深いコピーの定義
Python 3.9.4 copyより

注目すべきは、後半の一行
その後元のオブジェクト中に見つかったオブジェクトの コピー を挿入
です。
コピーの挿入とは何でしょうか? 具体例で見ていきます。

print(list_a, id(list_a))
# [[1, 2], [3, 4]] 140360795841736
print(list_a[0], id(list_a[0]))
# [1, 2] 140360795839304
print(list_a[0][0], id(list_a[0][0]))
# 1 140360923543232

print(list_c, id(list_c)) # list_aとは異なるid
# [[1, 2], [3, 4], [5, 6], [7, 8]] 140360795842952
print(list_c[0], id(list_c[0])) # list_aと異なるid
# [1, 2] 140360795843144
print(list_c[0][0], id(list_c[0][0])) # list_aと同じid
# 1 140360923543232

浅いコピーと異なり、list_c[0]のオブジェクトの識別子が異なっています。
これが深いコピーです。オブジェクトの中のオブジェクトも新たに生成し挿入していることがわかります。

深いコピーの概念図

しかし、list_a[0][0]と、list_c[0][0]が同じ識別子ではないかと思われた方もいるかと思います。
これに関しては本記事冒頭のイミュータブルを思い出して頂ければともいます。
list_a[0][0]の値は「1」です。この値を変更した時、イミュータブルなオブジェクトの値は変更できないため、
新しいオブジェクトが生成されlist_a[0][0]に紐づけられます。このためlist_cに対しては、何ら影響が及びません。

深いコピーにて値を変更した場合

深いコピーのメリット・デメリット

浅いコピーの反対になります。
メリットとしては、コピー元の変更が、コピー先に影響しないので
意図しない値の変更は発生しません。

一方で、デメリットは全てのオブジェクトをコピーするためメモリ使用量が増大します。
深いコピーが原因でOutOfMemoryが起きる場合もあります。

深いコピーの使いどころ

深いコピーの使いどころを考えてみます。
端的に言えば、コピー元の値を変更したい場合です。
コピー元、コピー先共に保持し続け値を変更したい場合、それぞれの値の変更の影響を避けるため深いコピーを利用した方が安全と言えるでしょう

さいごに

イミュータブルとミュータブル、浅いコピーと深いコピーについて解説させて頂きました。
実装初心者の方には新しい概念が多く難しかったかもしれません

これらの概念を理解するときに、根本的にプログラムがどのようにデータを保存しているのかを、理解しておくと飲み込みが早いかもしれません。多彩なフレームワークやライブラリが存在している今だからこそ、メモリの振る舞いや、ネットワークの仕組みなど基礎をしっかりと学んでおくと様々な知識に応用できます。

話がそれましたが、開発においてはハマりやすいポイントですので、頭の片隅にでも留めて頂き少しでも早い問題解決の手助けになれば幸いです。

AIで副業ならココから!

まずは無料会員登録

プロフィール

メーカーで研究開発を行う現役エンジニア
組み込み機器開発や機会学習モデル開発に従事しています

本ブログでは最新AI技術を中心にソースコード付きでご紹介します


Twitter

カテゴリ

このブログを検索

ブログ アーカイブ

TeDokology