🙆‍♀️

Pythonの参照とcopyについてのメモ

4 min read

はじめに

変数にオブジェクトを代入する際に、代入されるオブジェクトの型によって、その後の挙動に違いが生じる。本項は、現時点で学習した「値」と「参照」の違いと、オブジェクトのコピー方法についてのメモとなる。

int型が代入された変数の例

a = 1
b = a
a = 2

print(a)  # -> 2
print(b)  # -> 1

b = aの時点で、aの値を持つ新たなオブジェクトbが生成されるイメージ。
以降、aに対して値の変更がなされても、bの値は変更されることはない。

list型が代入された変数の例

一方、list型を変数に代入する時は上記とは異なる挙動をする。

a = [0, 1, 2]
b = a
a[0] = 100

print(a)  # -> [100, 1, 2]
print(b)  # -> [100, 1, 2]  え、君も変わっちゃうの!?

a[0]に対する値の変更が、b[0]にも引き継がれている。

Pythonにおいて、変数にlist型を代入したときは、リスト内の具体的な値が変数に代入されるわけではなく、その「参照」が変数に代入される
a = [0, 1, 2]のいう行の結果、一見aが[0, 1, 2]という値そのものを持つように見えるが、実際は[0, 1, 2]という(仮想の?)リストを参照するようになる。

これはExcelでイメージすると分かりやすい。
あるセル同士が一見同じ値を表示していても、セルが具体的な値を持つ場合と、セルが別のセルの値を参照している場合がある。前者のセルが「値を持つオブジェクト」、後者のセルが「参照を持つオブジェクト」と言える。list型を代入した変数は、後者(参照を持つオブジェクト)となる。

一見A2とA3は同じに見える


A2は値「4」を持つ

A3はセル番地「C2」を参照している

上記Excelに例えた考察を踏まえて、上記コードの各行の処理を言語化すると以下の通りとなる。

# リストを変数に代入すると、値ではなく、参照が代入される

a = [0, 1, 2]  # 参照先にオブジェクトを生成?しつつ、a自体はその参照先のオブジェクトを参照する
b = a          # aが値を参照しているセル番地をbに渡す
a[0] = 100     # aの値を更新しているように見えて、実は参照先の[0, 1, 2]の値を更新している

# aとbは同じセル番地を参照しているので、同じ値を出力する
print(a)  # -> [100, 1, 2]
print(b)  # -> [100, 1, 2]

参照ではなく値をコピーしたい場合

上記リスト型の例におけるb = aでは、aの参照がbにコピーされる。
対してaの値をコピーする方法としては、copyモジュールの関数を使うか、list()関数を使う方法がある。

copyモジュールの関数を使う

# copyモジュールの関数を使う場合
import copy

a = [0, 1, 2]
b = copy.copy(a)  # aが持つ値をbに渡す
a[0] = 100

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

list()関数を使う

# list()を使う場合
a = [0, 1, 2]
b = list(a)  # aが持つ値をbに渡す
a[0] = 100

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

[検討] copy.copy()とcopy.deepcopy()の違い

本項は検討段階で、情報の正確さを保証できません。あくまで参考程度にご覧ください。

copy.copy()は通称"shallow copy"、copy.deepcopy()は通称"deep copy"と呼ばれる。
このメソッドの違いは中々複雑で、厳密に理解することが難しい。
多少雑にまとめると以下のようになる。

  • copy.copy():配列の1次元目のみ値を渡す。2次元以上の階層にある要素は参照を渡す。
    • ただし、一度参照先の1次元目の値が更新されると、それ以下の階層にある要素が参照から値に変化する。
  • copy.deepcopy():配列が何次元であろうと、配列内の全ての要素を値で渡す。
# 2次元リストで試してみる
import copy

a = [[0, 0, 0], [1, 1, 1]]

# リストaをコピーする
a_shallow = copy.copy(a)  # list()でもよい
a_deep = copy.deepcopy(a)


# リストaの2次元階層[0][0]を更新した場合
a[0][0] = 9

print(a)          # -> [[9, 0, 0], [1, 1, 1]]  変更される
print(a_shallow)  # -> [[9, 0, 0], [1, 1, 1]]  変更される
print(a_deep)     # -> [[0, 0, 0], [1, 1, 1]]  変更されない


# リストaの1次元階層[0]を更新した場合
a[0] = [5, 5, 5]

print(a)          # -> [[5, 5, 5], [1, 1, 1]]  変更される
print(a_shallow)  # -> [[9, 0, 0], [1, 1, 1]]  変更されない
print(a_deep)     # -> [[0, 0, 0], [1, 1, 1]]  変更されない


# [参考] このタイミング(リストaの1次元階層[0]を1度更新済)で、2次元階層[0][0]を更新した場合
a[0][0] = 8
print(a)          # -> [[8, 5, 5], [1, 1, 1]]  変更される
print(a_shallow)  # -> [[9, 0, 0], [1, 1, 1]]  変更されない!!
print(a_deep)     # -> [[0, 0, 0], [1, 1, 1]]  変更されない

多次元配列のコピーはdeepcopyを使うのが無難?

上記コードを見ても分かる通り、多次元配列に対するcopy.copy()で作成されたオブジェクトの性質は、参照先の値の更新の有無によって変わってくる。予期せぬ挙動を避けるために、多次元配列に対してはcopy.deepcopy()を使用することが望ましいと思われる。

公式ドキュメントでは以下のように説明されている。(強調は筆者によるもの)
元のオブジェクト(参照先)の要素の変化により、「参照先が見つからなくなる」ということだと思われる。(Excelでいうところの#REF!状態になるイメージ)

浅い (shallow) コピーと深い (deep) コピーの違いが関係するのは、複合オブジェクト (リストやクラスインスタンスのような他のオブジェクトを含むオブジェクト) だけです:
浅いコピー (shallow copy) は新たな複合オブジェクトを作成し、その後 (可能な限り) 元のオブジェクト中に見つかったオブジェクトに対する 参照 を挿入します。
深いコピー (deep copy) は新たな複合オブジェクトを作成し、その後元のオブジェクト中に見つかったオブジェクトの コピー を挿入します。

しかし、copy.deepcopy()に関しては以下のように説明されている。これを見る限り、計算両面で不安が残るため、競プロのような実行時間制限が厳しい状況においては、リストをコピーすることはそもそも避け、別の手段によって問題を解決したほうが良いと言える。

深いコピー操作には、しばしば浅いコピー操作の時には存在しない 2 つの問題がついてまわります:
再帰的なオブジェクト (直接、間接に関わらず、自分自身に対する参照を持つ複合オブジェクト) は再帰ループを引き起こします。
深いコピーは何もかもコピーしてしまうため、例えば複数のコピー間で共有するつもりだったデータも余分にコピーしてしまいます。

[補足] オブジェクトidについて

なお、オブジェクトidの変化を都度観察することで、copy.copy()とcopy.deepcopy()の挙動の差を厳密に理解することも試みたが、同一のidのオブジェクトに対する値の更新でも、値が変化するオブジェクトとそうでないオブジェクトがあり、idレベルの情報を観察するだけでは現象を正しく把握することができなかった。

まとめ

  • list型のデータを複製するときに、単純に代入すると参照渡しになる。

  • 値を渡したいときは、list(リスト)copy.copy(リスト)を代入する。

  • 多次元配列を複製したい時に、copy.copy(リスト)を代入すると、その変数が思わぬ挙動をすることがある。(値渡しになったり参照渡しになったりする)

  • 多次元配列内の全要素を値で渡したい時は、copy.deepcopy(リスト)を代入する。

  • ただし、copy.deepcopy(リスト)の処理は計算量面で不安なので、別の手段を考えてみる。

Discussion

ログインするとコメントできます