どうもこんにちは。うめきです。
これはアドベントカレンダーほぼ厚木の民の1日目の記事です。
本日は少しマニアックだけどとても重要なPythonのデータ同一性について書きます。こまごまと書いているので少しわかりにくいかもしれませんが、最後までお付き合いいただければ嬉しいです。
この記事を読んでわかること
- Pyhtonで書き換えていないリストまで値が書き換わってしまうのはなぜ?
- Pythonのデータの実体はどのように管理されているの?
- イミュータブルとミュータブルってなに?
- copyとdeepcopyの違いは?
おや?なんだかPythonの挙動がおかしい?
Pythonって簡単にかけるのが良いですよね。ただ、どうにでもかけちゃうので「実際この変数の実体はどこにあるんだろう?」とか、「なんでこんな挙動をするんだろう?」と思ったことはないでしょうか?例えばこんな例です。
def initialize_head(x):
x[0] = 0
return x
a = [1, 2, 3]
b = initialize_head(a)
print(f'a: {a}')
print(f'b: {b}')
処理としては非常にシンプルです。initialize_headは入力リストの最初の値を0に変更して返す関数です。上記のコンソールへの出力結果を見てみましょう。
a: [0, 2, 3]
b: [0, 2, 3]
あれ?と思った方はいないでしょうか。bの出力結果については問題ないと思いますが、aの出力結果が[1, 2, 3]であると思った人もいたのではないかと思います。
もうひとつ例を挙げます。
def to_list(data, default_list=[]):
try:
return list(data)
except:
return default_list
list1 = to_list(1)
list2 = to_list(2)
list1.append(1)
list2.append(2)
print(f'list1: {list1}')
print(f'list2: {list2}')
to_listは入力データをリストに変換する関数です。リストに変換できない場合はデフォルト引数に指定されている空のリストを返します。list1やlist2は、これらの関数に1や2といった整数を渡しているのでリストに変換されずデフォルトの空リストが格納されているはずです。そして、これら2つのリストにそれぞれ1と2を追加しています。さて、出力はどのようになるでしょうか?
list1: [1, 2]
list2: [1, 2]
list1は[1]、list2は[2]という結果を予想していた方も多いのではないでしょうか。Pythonにおいて変数がどのように作成され、どのように管理されているかを知ればこれらの挙動の理由を知ることができます。この理由に加えて、上記の挙動を発生させないためにはどうすればよいのかを順番に解説していきます。
Pythonのデータはオブジェクト
Pythonにおいて、「データはすべてオブジェクトまたはオブジェクトの関係」で表現されます。この定義はPythonの公式サイトにも書かれています。例えば、1は整数オブジェクト、0.1は浮動小数点オブジェクト、'hoge'は文字列オブジェクトといった感じです。そして、['apple', 'orange', 'banana']はリストオブジェクトであり、リストオブジェクトの各要素は、'apple', 'orange', 'banana'という3つの文字列オブジェクトで構成されています。つまり、すべてのデータは「オブジェクトまたはオブジェクトの関係」で表現できるということです。
さて、これらのオブジェクトたちはそれぞれ「型」・「値」・「同一性」を持ちます。このうち「型」と「値」は特に説明しなくてもよいと思います(1について、「型」は整数型(int型)で、「値」は1であるというだけ)。Pythonの挙動を正確に理解するために重要なものは「同一性」です。同一性(identity)は、生成されたあとには変更されないアドレスのようなものです。つまり、任意のオブジェクトが生成されてから削除されるまでそのidentityは変わることはありません。
オブジェクトのidentityは組み込みのid関数で取得することができます。
>>> a = 1
>>> id(a)
1858515168
変数aに格納されている整数型オブジェクト1のidentityが1858515168であるということがわかりました。では、別の変数bにこのaを代入したらbのidentityがどうなるか確認してみましょう。
>>> b = a
>>> id(b)
1858515168 # 変数aと同じidentity
変数bについても、変数aと同一の数値型オブジェクト1を格納していることがわかりました。この事実から、Pythonにおける変数名は各オブジェクトにつけられるタグのようなものであると理解することができます。
つまり、変数が紐づいているオブジェクトが完全に同一のものであった場合、そのidentityは一致することになります。ん?ということは、aをbに代入しなくてもidentityは一致しているのでしょうか??早速確認してみましょう。今回は変数aとbをそれぞれ別で定義し、ソースコード上で紐づけられているようには書いていません。
>>> a = 1
>>> b = 1
>>> id(a)
1858515168
>>> id(b)
1858515168
aとbのidentityは一致しており、予想が的中しています。え、じゃあ同じ定義であればidentityはいつも一致しているのでしょうか?
>>> list1 = [1, 2]
>>> list2 = [1, 2]
>>> id(list1)
1994134265928
>>> id(list2)
1994134278664 # list1 と list2 のidentity は異なる
もちろん違います。新規に作成したリストは異なるオブジェクトとして管理されています。普通に考えて、別で定義したリストのアドレスが勝手に紐づいていたら、片方のリストの要素を変更しただけでもう一方のリストも書き換わってしまうので非常に不便です。ここで、最初の例では異なる変数が共通の実体を指示していたという事実がぼんやりと見えてきましたね。
ちなみに、整数型オブジェクト1はすでに生成されていましたので、list1とlist2で共通のオブジェクトに紐づいています。
>>> id(list1[0])
1858515168
>>> id(list2[0])
1858515168 # 整数1のidentityは共通
簡単なことを小難しく書いているかもしれませんがもう少し辛抱してください。
イミュータブルとミュータブル
ここまでで、Pythonのデータはオブジェクトで構成されており、変数はオブジェクトに紐づけられるタグのようなものであることが分かりました。では、オブジェクト本体との紐づけが変更されるケースと、オブジェクトとの紐づけが変わらないケースってどのようなときに発生するのでしょうか?ここを理解するためには、イミュータブル(変更不可)とミュータブル(変更可)について知っておく必要があります。
Pythonのオブジェクトは、イミュータブルなオブジェクトとミュータブルなオブジェクトに分けることができます。
- イミュータブル:同じオブジェクトのまま値を変更することができない
- ミュータブル:同じオブジェクトのままで値を変更することができる
例えば、int型やstr型はイミュータブルなオブジェクトです。したがって、以下のように変数aの値を変更した場合、整数型オブジェクト1の値を2に変更することはできません。つまり、aが1であったときと、aが2であったときのid番号は異なることになります。別の言い方をするならば、aに異なる値を代入した瞬間にaは異なるオブジェクトに紐づけられることになります。
>>> a = 1
>>> id(a)
1858515168
>>> a = 2
>>> id(a)
1858515200
一方で、list型やdict型はミュータブルです。したがって、以下のように変数listAの値を変更したとしても、listAのid番号が変わることはありません。もちろんlistAの最初の要素のid番号は整数型オブジェクト1から別の整数型オブジェクト0へと紐づけ(id番号)が変更されています。
>>> listA = [1, 2]
>>> id(listA)
1994134279048
>>> listA[0] = 0 # [0, 2]に変更
>>> id(listA)
1994134279048 # 値変更後もlistAは同一のオブジェクト
ここまでくると最初のよくわからない挙動を理解できるようになります。もう一度最初のコードを見てみましょう。
def initialize_head(x):
x[0] = 0
return x
a = [1, 2, 3]
b = initialize_head(a)
print(f'a: {a}') # a: [0, 2, 3]
print(f'b: {b}') # b: [0, 2, 3]
挙動としては以下の順番にデータが処理されていることになります。
-
a = [1, 2, 3]の部分で新しいリスト型オブジェクトが作成される -
initialize_head関数にaが渡され、x = aにより関数内変数xもaと同一のリストオブジェクトに紐づけられる -
xとaが同一のリスト型オブジェクトに紐づけられた状態で、最初の要素が書き換えられる - 関数内変数
xの結果が、b = xにより変数bに渡されるため、変数bも同一のリスト型オブジェクトに紐づけられる
ごちゃごちゃと書きましたが、最終的に変数aとbは同一のリスト型オブジェクトに紐づいていることになります。この結果から、イミュータブルなオブジェクトに関しては、式の代入だけでは別オブジェクトが生成されないために注意が必要ということがわかります。要するに、なんとなく代入して値書き換えとかやっていたら思わぬ書き換えが発生しちゃいますよということです。
最初にあげていたもう一つの例についても挙動を説明します。
def to_list(data, default_list=[]):
try:
return list(data)
except:
return default_list
list1 = to_list(1)
list2 = to_list(2)
list1.append(1)
list2.append(2)
print(f'list1: {list1}') # list1: [1, 2]
print(f'list2: {list2}') # list2: [1, 2]
こちらについては、Pythonの特徴的な挙動を知っていなければ完全な説明ができないためは少し意地悪でした(ごめんなさい)。その挙動というのは、デフォルト引数は一度しか評価されないというものです。つまり、default_list = []が実行されるのは、関数が定義されるタイミングだけということになります。したがって、この関数で返されるdefault_listというのは常に同一のオブジェクトに紐づいた状態になっています。list1とlist2は別々に定義されているように見えて、実は同一のリスト型オブジェクトに紐づけられていたのでした。
かなり余談ですが、デフォルト引数を動的に毎回評価したいときは、デフォルト引数をNoneにしておき、関数内に新しいオブジェクトを生成してターゲットの変数に渡す処理を記述する必要があります。
def to_list(data, default_list=None):
try:
return list(data)
except:
if default_list is None:
default_list = []
return default_list
いかがでしたか?Pythonの挙動を正しく知っておくことで、意図しない変数の書き換えは防ぐことができます。じゃあどう防ぐのか?ここを次に説明します。
copyとdeepcopy
値は同じだけど別のオブジェクトを生成したい!そんなときにはおなじみのcopyモジュールを利用します。最初の例もcopyモジュールを使えば解決します。
import copy
a = [1, 2, 3]
b = initialize_head(copy.copy(a))
print(a, b) # [1, 2, 3] [0, 1, 2]
なんだ簡単じゃないか!と思った方、こちらを見てください。
a = [[1, 2], [3, 4]]
b = copy.copy(a)
a[0][0] = 0
print(a) # [[0, 1], [3, 4]]
print(b) # [[0, 1], [3, 4]]
あれ、copyモジュールじゃダメなの?もしかしてaとbが別のオブジェクトに紐づいているだけで、その中の値としてのリストオブジェクトは同一のオブジェクトを指示しているのかな?と気づくかと思います。実際に確認してみましょう。
>>> id(a) == id(b)
False
>>> id(a[0]) == id(b[0])
True
>>> id(a[1]) == id(b[1])
True
見事に予想が的中しております。関連するミュータブルなオブジェクト全てを再帰的に別のオブジェクトとして生成しないとこれまでの意図しない書き換えが発生してしまいます。この再帰的な別オブジェクトの生成を行ってくれるのがcopy.deepcopyメソッドです。
>>> c = copy.deepcopy(a)
>>> id(a) == id(c)
False
>>> id(a[0]) == id(c[0])
False
>>> id(a[1]) == id(c[1])
False
確かに再帰的に内部のリストまで別オブジェクトとして生成されていることがわかります。copyとdeepcopyについてはもう少し細かい話も書けるのですが長くなってきたので今回は概要程度にとどめておきます。
まとめ
- Pythonのデータはオブジェクトとオブジェクトの関係で定義される
- Pythonにおける変数は各オブジェクトに紐づいたタグのようなもの
- オブジェクトはイミュータブル(変更不可)とミュータブル(変更可)が存在する
- ミュータブルなオブジェクトは思わぬ値の書き換えを発生させる可能性があるので必要に応じてcopyモジュールを使う
- copyモジュールにはcopyメソッドとdeepcopyが存在し、どこまでコピーするかに応じて使い分ける
おわりに
いかがでしたか?最初はよくわからなかったPythonの挙動も、少しだけ踏み込んで理解してみるだけでかなり見え方が変わったのではないでしょうか。今回は触れませんでしたが、numpyにもcopyとviewという概念があって、ある変数のviewを作成するといった場合には複数の変数が同一のオブジェクトを指し示すことになります。numpyのcopyメソッドは基本的にdeepcopyであるとかここも話し始めたら長いので気になる方はまた聞いてください。最後まで読んでいただきありがとうございました。