1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Python】"copy()"したのにコピーされていない?深いコピーに関する思い込みと失敗

Last updated at Posted at 2020-09-13

投稿日:2020/9/13

はじめに

 この記事では参照の代入、浅いコピー、深いコピーについて書いています。すでに複数の記事がありますが、この記事では私が間違いに気づいた時の状況や、その他調べている中で新しく知った内容を含めています。

 私は浅いコピーと深いコピーを理解できておらず、参照の代入=浅いコピー、.copy()=深いコピーであると思っていました。しかし、今回の失敗で調べてみて、代入には3種類あることを知りました。

3つの代入

参照の代入

a = [1,2]
b = a
b[0] = 100
print(a)  # [100, 2]
print(b)  # [100, 2]

 こうすると、bを書き換えるとaも書き換わりますよね。これが参照の代入1です。abは同じ物(オブジェクト)を指しているため、片方を書き換えるともう片方も書き換わったように見えます。

  id()でオブジェクトIDを確認してみましょう。

print(id(a))  # 2639401210440
print(id(b))  # 2639401210440
print(a is b)  # True

 idが同じですね。abが同じであることがわかります。

浅いコピー

 では、baとは別のオブジェクトとして扱いたい場合はどうすれば良いでしょうか。私は普段.copy()を使っています。

a = [1,2]
b = a.copy()  # 浅いコピー
b[0] = 100
print(a, id(a))  # [1, 2] 1566893363784
print(b, id(b))  # [100, 2] 1566893364296
print(a is b)  # False

 ちゃんとabが分かれていますね。これが浅いコピーです。浅いコピーは他にも方法があります。

リストの浅いコピー
# リストの浅いコピー
import copy
a = [1,2]

b = a.copy()  # .copy()を使う例
b = copy.copy(a)  # copyモジュールを使う例
b = a[:]  # スライスを使う例
b = [x for x in a]  # リスト内包表記を使う例
b = list(a)  # list()を使う例
辞書の浅いコピー
# 辞書の浅いコピー
import copy
a = {"hoge":1, "piyo":2}

b = a.copy()  # .copy()を使う例
b = copy.copy(a)  # copyモジュールを使う例
b = dict(a.items())  # items()で取り出したものを辞書にする例

深いコピー?

では、深いコピーをやってみましょう。

import copy

a = [1,2]
b = copy.deepcopy(a)  # 深いコピー
b[0] = 100
print(a, id(a))  # [1, 2] 2401980169416
print(b, id(b))  # [100, 2] 2401977616520
print(a is b)  # False

結果は浅いコピーと変わらないですね。

"copy()"したのにコピーされていない

では、次の例はどうでしょうか。

a = [[1,2], [3,4]]  # 変更
b = a.copy()
b[0][0] = 100
print(a)  # [[100, 2], [3, 4]]
print(b)  # [[100, 2], [3, 4]]

 一行目のaを二次元リストにしました。
 コピーを取ったはずなのに、aも書き換わっていますね。先程の例との違いはなんでしょうか。

ミュータブルオブジェクト

 Pythonにはミュータブル(変更可能)オブジェクトとイミュータブル(変更不可能)オブジェクトがあります。分類すると,

ミュータブル:list, dict, numpy.ndarray2など
イミュータブル:int, str, tupleなど

という感じです3 4。上の例ではリスト(list)の中にリストを入れています。つまりミュータブルオブジェクトの中にミュータブルオブジェクトを入れました。では、同じオブジェクトかどうかを確認しましょう。

print(a is b, id(a), id(b))
# False 2460506990792 2460504457096

print(a[0] is b[0], id(a[0]), id(b[0]))
# True 2460503003720 2460503003720

 外側のリストa bは異なりますが、その中のリストa[0] b[0]は同じです。つまり、b[0]を書き換えるとa[0]も書き換わります。

 よってこの挙動は、オブジェクトの中にミュータブルオブジェクトが入っているのに浅いコピーを使ったことが原因です5。そして、このようなときに用いるのが深いコピーです。

解決策 - 深いコピー

1.深いコピーを使う

深いコピーを使います。

import copy

a = [[1,2], [3,4]]
b = copy.deepcopy(a)
b[0][0] = 100
print(a)  # [[1, 2], [3, 4]]
print(b)  # [[100, 2], [3, 4]]

同じオブジェクトかどうかも見てみましょう。

print(a is b, id(a), id(b))
# False 2197556646152 2197556610760

print(a[0] is b[0], id(a[0]), id(b[0]))
# False 2197556617864 2197556805320

print(a[0][1] is b[0][1], id(a[0][1]), id(b[0][1]))
# True 140736164557088 140736164557088

 あれ、最後は同じですね。b[0][1]はイミュータブルオブジェクトであるintであり、再代入時に自動的に別オブジェクトが作られるので問題ありません6

それを除けば、ミュータブルオブジェクトはidが異なるので、コピーされたことがわかります。

2. numpy.ndarrayを使う

 今回の内容とは少しずれているということと、余計難しくなるような内容なので、下に持っていきました。「解決策その2 numpy.ndarrayにする」の項を見てください。

私が間違いに気づいたときのコード

 私が間違いに気づいた時とほぼ同じコードを掲載します。私は以下のようなデータを作成しました。

import numpy as np

a = {"data":[
        {"name": "img_0.jpg", "size":"100x200", "img": np.zeros((100,200))},
        {"name": "img_1.jpg", "size":"100x100", "img": np.zeros((100,100))},
        {"name": "img_2.jpg", "size":"150x100", "img": np.zeros((150,100))}],
    "total_size": 5000
}

 このように、辞書の中にリストを、その中に辞書、さらに画像(ndarray)といった感じで、ミュータブルオブジェクトを入れ子にしたデータを作成しました。そして、この中からimgだけを省いた別の辞書をjson書き出し用に作成しました。

 その後、元の辞書からimgを取り出そうとするとKeyErrorが発生しました。コピーしたはずなのにどうしてだろう、としばらく悩んで、辞書の中のオブジェクトの参照が同じである可能性に気づきました。

# 問題を起こしたコード
data = a["data"].copy()  # ここが間違い
for i in range(len(data)):
    del data[i]["img"]  # 辞書からimgを削除
b = {"data":data, "total_size":a["total_size"]}  # 新しい辞書

img_0 = a["data"][0]["img"]  # aを触っていないのにKeyError
# KeyError: 'img'

 解決方法としてはdata = copy.deepcopy(a["data"])のように深いコピーに変更するのが一番簡単ですが、この場合、後で消す画像をわざわざコピーすることになり、メモリや実行速度に影響が出る可能性があります。

 ゆえに、元データから不要なデータを消すのではなく、必要なデータを取り出す形で書くのが良いと思います。

# 必要なデータを取り出す形で書きなおしたコード
data = []
for d in a["data"]:
    new_dict = {}
    for k in d.keys():
        if(k=="img"):  # imgだけ含めない
            continue
        new_dict[k] = d[k]  # コピーではないので注意
    data.append(new_dict)
b = {"data":data, "total_size":a["total_size"]}  # 新しい辞書

img_0 = a["data"][0]["img"]  # 動作する

 私は、コピーしたデータをjson形式で書き出すために使ったので、上のコードで問題ないですが、もしコピー後のデータを書き換えるのであればdeepcopyを使わなければなりません(ミュータブルオブジェクトを含む場合)。

浅いコピーと深いコピー

以上の例からも分かる通り、

浅いコピー:対象のオブジェクトのみ
深いコピー:対象のオブジェクト+対象のオブジェクトに含まれるミュータブルオブジェクト全て

がコピーされます。詳しくは、Pythonのドキュメント(copy) をご覧ください。一度目を通しておくと良いと思います。

実行速度検証

 テキストを含む辞書aを作成し、浅いコピーと深いコピーの実行速度テストを行いました。

import copy
import time
import numpy as np

def test1(a):
    start = time.time()
    # b = a
    # b = a.copy()
    # b = copy.copy(a)
    b = copy.deepcopy(a)
    process_time = time.time()-start
    return process_time

a = {i:"hogehoge"*100 for i in range(10000)}
res = []
for i in range(100):
    res.append(test1(a))
print(np.average(res)*1000, np.min(res)*1000, np.max(res)*1000)

結果

処理 平均(ms) 最小(ms) 最大(ms)
b=a 0.0 0.0 0.0
a.copy() 0.240 0.0 1.00
copy.copy(a) 0.230 0.0 1.00
copy.deepcopy(a) 118 78.0 414

 適当な検証なのであまりあてになりませんが、浅いコピーと深いコピーの差が大きいことは分かります。よって書き換えしないデータは浅いコピーを使うなど、使用するデータや使用方法によって使い分けたほうがよさそうです。

その他検証等

自作クラスのコピー

import copy
class Hoge:
    def __init__(self):
        self.a = [1,2,3]
        self.b = 3

hoge = Hoge()
# hoge_copy = hoge.copy() # copyメソッドがないのでエラー
hoge_copy = copy.copy(hoge)  # 浅いコピー
hoge_copy.a[1] = 10000
hoge_copy.b = 100
print(hoge.a)  # [1, 10000, 3](書き換わっている)
print(hoge.b)  # 3 (書き換わっていない)

自作クラスの場合も、メンバ変数がミュータブルオブジェクトなら浅いコピーだけでは不十分です。

タプルのコピー

タプルと言っても、タプルの中にミュータブルオブジェクトを入れた場合です。

import copy
a = ([1,2],[3,4])
b = copy.copy(a)  # 浅いコピー
print(a)  # ([1, 2], [3, 4])
b[0][0] = 100  # これが実行できる
print(a)  # ([100, 2], [3, 4])(書き換わっている)
b[0] = [100,2]  # TypeErrorで書き換え不可

 タプルはイミュータブルなので値の書き換えができませんが、タプルに含まれるミュータブルオブジェクトの中は書き換えができてしまいます。この場合も同様に浅いコピーでは中のオブジェクトはコピーされません。

リストの.copy()について

リストのコピーb = a.copy()について、どのような処理になっているのか気になったので、Pythonのソースコードを見てみました。

cpytnon/Objects/listobject.c 812行目 (2020/9/11現在のmasterブランチより引用)
ソースのリンク(位置は変わっているかもしれません)

/*[clinic input]
list.copy
Return a shallow copy of the list.
[clinic start generated code]*/

static PyObject *
list_copy_impl(PyListObject *self)
/*[clinic end generated code: output=ec6b72d6209d418e input=6453ab159e84771f]*/
{
    return list_slice(self, 0, Py_SIZE(self));
}

コメントにもあるように、

Return a shallow copy of the list.

と、浅いコピー(shallow copy)であることが書かれています。その下の実装もlist_sliceと書いてあるので、b = a[0:len(a)]のようにスライスしているだけだと思われます。

解決策その2-numpy.ndarrayにする

 今回の話とは少しずれますが、多次元配列を扱うのであればリストではなくNumPyのndarrayを使う方法もあります。ただし、注意が必要です。

import numpy as np
import copy

a = [[1,2],[3,4]]
a = np.array(a)  # ndarrayに変換
b = copy.copy(a)
# b = a.copy()  #これでも可
b[0][0] = 100
print(a)
# [[1 2]
# [3 4]]
print(b)
# [[100   2]
# [  3   4]]

このように、copy.copy()を使うか、.copy()を使えば問題ありませんが、スライスを使うとリストと同じく、元の配列が書き換わってしまいます。これは、NumPyのcopyとviewの違いによるものです。

参考:https://deepage.net/features/numpy-copyview.html (NumPyのコピー(copy)とビュー(view)を分かりやすく解説)

# スライスを使った場合
import numpy as np

a = [[1,2], [3,4]]
a = np.array(a)
b = a[:]  # スライス(= viewを作成)
# b = a[:,:]  # これでも同じ
# b = a.view()  # これと同じ
b[0][0] = 100
print(a)
# [[100   2]
# [  3   4]]
print(b)
# [[100   2]
# [  3   4]]

また、この場合はisによる比較結果がリストと同じようにはなりません。

import numpy as np

def check(a, b):
    print(id(a[0]), id(b[0]))
    print(a[0] is b[0], id(a[0])==id(b[0]))

# リストをスライスした場合
a = [[1,2],[3,4]]
b = a[:]
check(a,b)
# 1778721130184 1778721130184
# True True

# ndarrayをスライス(viewを作成)した場合
a = np.array([[1,2],[3,4]])
b = a[:]
check(a,b)
# 1778722507712 1778722507712
# False True

 最後の行を見ればわかるように、idは同じですが,isによる比較結果がFalseになっています。そのため、is演算子でオブジェクトの同一性を確認して、Falseであったとしても書き換わってしまうことがあるので注意しなければなりません。

 is演算子について調べるとidが同じかどうかを返すと書かれていますが7 8 9、この場合そうはなっていません。numpyが特殊なのでしょうか。よくわかりません。

実行環境はPython3.7.4 & numpy1.16.5+mklです。

おわりに

 私はこれまで.copy()で困ったことがなかったので、コピーについて全く気にしたことがありませんでした。これまでに書いたコードの中に、思いがけず書き換えを行っているデータがあるかもしれないと思うと非常に恐ろしいです。

 Pythonにおけるミュータブルオブジェクトの問題には関数のデフォルト引数の話もあります。こちらも知らないと気づかずに意図しないデータを生成することになるので、ご存知ない方は確認してみてください。
 
http://amacbee.hatenablog.com/entry/2016/12/07/004510 (Pythonの値渡しと参照渡し)
https://qiita.com/yuku_t/items/fd517a4c3d13f6f3de40 (引数のデフォルト値はimmutableなものにする)

# デフォルト引数にミュータブルオブジェクトを指定しないほうが良い

def hoge(data=[1,2]): # 悪い例
def hoge(data=None): # 良い例1
def hoge(data=(1,2)): # 良い例2
# こういうことも起きる
a = [[0,1]] * 3
print(a)  # [[0, 1], [0, 1], [0, 1]]
a[0][0] = 3
print(a)  # [[3, 1], [3, 1], [3, 1]] (全部書き換わっている)

参考

[1] https://qiita.com/Kaz_K/items/a3d619b9e670e689b6db (Pythonのcopyとdeepcopyについて)
[2] https://www.python.ambitious-engineer.com/archives/661 (copyモジュール 浅いコピーと深いコピー)
[3] https://snowtree-injune.com/2019/09/16/shallow-copy/ ( Python♪次は理屈で覚えよう「参照渡し」「浅いコピー」「深いコピー」)
[4] https://docs.python.org/ja/3/library/copy.html (copy --- 浅いコピーおよび深いコピー操作)

  1. 参照の代入と書きましたが、Pythonにおいてインターネット上でそのような言い方は見つかりませんでした。「参照渡し」は関数の引数で用いられる用語であり、他に良さそうな言い方が見つからなかったので、参照の代入としました。

  2. ndarrayはイミュータブルにすることも可能らしい(https://note.nkmk.me/python-numpy-ndarray-immutable-read-only/

  3. https://hibiki-press.tech/python/data_type/1763 (主な組み込み型のミュータブル、イミュータブル、イテラブル)

  4. https://gammasoft.jp/blog/python-built-in-types/ (Pythonの組み込みデータ型の分類表(ミュータブル等))

  5. Pythonのドキュメント には、「複合オブジェクト(リストやクラスインスタンスのような他のオブジェクトを含むオブジェクト)」と書いてあります。よって正確にいうと、複合オブジェクトを浅いコピーしたのが原因です。別項で書きましたが、イミュータブルであるタプルの中にリストを入れても同じことが発生します。

  6. https://atsuoishimoto.hatenablog.com/entry/20110414/1302750443 (is演算子のふしぎ)Pythonはメモリ削減のためにイミュータブルオブジェクトは使いまわすらしい。

  7. https://www.python-izm.com/tips/difference_eq_is/ (==とisの違い)

  8. https://qiita.com/i13602/items/6d8914e019c13e858c72 (pythonにおける「==」と「is」の違いについて)

  9. https://docs.python.org/ja/3/reference/expressions.html#is (6.10.3. 同一性の比較)公式リファレンスにも「オブジェクトの同一性はid()関数を使って判定されます。」と書いてある。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?