[Python]イミュータブルなオブジェクトは何がどう変更不可なのか

Pythonに限らず、プログラミング言語でよく使われる言葉に「ミュータブル/イミュータブルなオブジェクト」というのがあります。

この言葉が一体何を指すのか、分かりやすさ重視でハッキリさせておきたいというのが今回のテーマです。

ミュータブル/イミュータブルの意味

英単語の意味そのままですが、ミュータブルなオブジェクトといえば「変更可能なオブジェクト」、イミュータブルなオブジェクトは「変更不可なオブジェクト」を指します。

Pythonの例で言えばlist型やdict(辞書)型などはミュータブルですが、文字列や数値、tuple型などはイミュータブルです。

しかし、どうもそんな簡単な説明だけではスッキリしません…。

「変更不可」の意味が無い?

なぜなら変更不可なオブジェクトであるはずの文字列やタプルで、このようなコードが通ってしまうからです。

tup = (1,2,3,4,5)
tup += (6,7,8)
print(tup)     #(1, 2, 3, 4, 5, 6, 7, 8)

タプルには別のタプルをつなぎ合わせることができます。これによって変数tupが指し示す値は変わってしまいます。

これでは変更不可として定義する意味は全く無いようにも思えますが、しかしこれは何を変更したのでしょうか?

組み込み関数idで識別値を調査する

コードによって生成された値には1つ1つ固有の番号が割り振られています。これを識別値といい、値が有効である間はユニークかつ定数です。

Pythonではこの識別値を、組み込み関数であるid()で調べることができます。

試しにこの関数を使って、本当に値が変更されたのか調べてみることにしましょう。

tup = (1,2,3,4,5)
print(id(tup))     #2581906101648
tup += (6,7,8)
print(id((1,2,3,4,5)))     #2581906101648
print(id(tup))     #2581907160192

2行目と4行目はどちらも同じIDです。このため、+=の処理前の変数tupは確かに(1,2,3,4,5)と全く同じ値が代入されていることが分かります。

しかし最後の5行目ではIDが変わってしまっています。これは一体どういうことなのか?

その謎は変数への代入という行為を少し掘り下げることで見えてきます。

変数は箱ではない

まず大前提として、変数に値を代入する=「変数という何らかの名前が付いた箱に、値そのものが入る」というイメージは、(最初の取っ掛かりには良いかもしれませんが)正しくありません。

tup = (1,2,3,4,5)

上のコード1行目ではtupという変数と(1,2,3,4,5)という値が、それぞれメモリ上の別々の領域に確保されます。

この「変数」と代入された「値」は「箱」と「中身」という関係よりも、ヒモや鎖のようなものでつながれた関係と考えたほうがより実際のイメージと近いのではないでしょうか。

この状態を変数が値を参照していると言います。

print(id(tup))     #2581906101648
print(id((1,2,3,4,5)))     #2581906101648

2行目と4行目を見てみましょう。このとき、変数tupと新たに生成したように見える(1,2,3,4,5)は同じ値を指しているので、変数も値そのものもIDは同じになります。

tup += (6,7,8)

問題はこの3行目です。見た目は元々の(1,2,3,4,5)に(6,7,8)を加えているように見えますが、実は(1,2,3,4,5)+(6,7,8)という新たな値がここで生成されています

そして変数は、参照先を(繋がった鎖の先を)新たな値へとつなぎ替えているわけです。

このように考えれば、次の行が最初に作った値と同じIDであることも頷けます。

print(id((1,2,3,4,5)))     #2581906101648

(1,2,3,4,5)というタプルは全く同じものが1行目で生成され、既にメモリ上に存在します。

そのため4行目では改めて値を生成すること無く、既にある値を読み込んでいるからこそ、2行目と同じIDが出現するということです。

つまり(1,2,3,4,5)という値は+=の式で変更されたわけでも破棄されたわけでもなく、最初の行からずっと存在していたと言えます。

疑問と回答のまとめ

一旦ここでまとめておきましょう。ここまでの疑問は

  1. イミュータブルなオブジェクトの値を変更するということは、実際何を変更しているのか?
  2. 変更不可なはずのタプルの値が書き換えられたのはなぜか?

という2点でした。それに対する回答はこちらです。

  1. 値そのものは変更していない。変更したのは変数の参照先である
  2. 値は書き換えられていない。書き換えられたように見えるのは新たに生成された値である

おまけ

ここからは蛇足ですが、ではミュータブルなオブジェクトであるlist型ではどうなるでしょう。

lst = [1,2,3]
print(id(lst))     #1983219240064
lst +=[4,5,6]
print(id(lst))     #1983219240064

思ったとおりIDは全く同じです。このコードに値は同時に2つは存在していません。では次に、これをタプルに入れてみましょう。

lst = [1,2,3]
tup = (lst,4,5)
print(tup)     #([1, 2, 3], 4, 5)

今度は少し特殊なタプルです。タプルの0番目の要素はミュータブルなリストが入っています。

このコードの後に続けて、リストの要素を変更してみましょう。

lst[0] = 10
print(tup)     #([10, 2, 3], 4, 5)

タプルの要素の1つであるlist型はミュータブルなオブジェクトなので、要素の値を変更することが可能です。

しかしtuple型自体はイミュータブルで、変更に見えても実際は新たな値が生成され、変数の参照先が変更されるのでした。

ではこの場合、上の変数tupと下の変数tupのIDは同じでしょうか?それとも違うものになるでしょうか?

正解

コード全体を見ていきます。

lst = [1,2,3]
tup = (lst,4,5)
print(tup)     #([1, 2, 3], 4, 5)
print(id(tup))     #2127296139456

lst[0] = 10

print(tup)     #([10, 2, 3], 4, 5)
print(id(tup))     #2127296139456

正解は「同じ」です。

それどころかこのタプルは、「自分の中身が変わっていない」と思いこんでいます。頭悪いのかと思いきや、そうではありません。実際にこのタプルは変更されていません

タプルの要素である変数lstは、確かに同じものを参照し続けています。この中身の値がいくら変わろうが「同じもの」である限り、タプルが変更されたことにはなりません。

タプルの頑固さ

蛇足ついでにもう1つだけコードをご紹介しておきます。

tup1 = (1,2,3)
tup2 = (tup1,4,5)
print(tup2)     #((1, 2, 3), 4, 5)

tup1 += (10,11)

print(tup1)     #(1, 2, 3, 10, 11)
print(tup2)     #((1, 2, 3), 4, 5)

タプルの中にタプルが入った2次元タプルですが、変数tup1の参照先を変更したとしても、tup2の要素の1つである変数tup1が指し示すものに変わりはありません。

これでは「tup1」が指し示す値が2つ存在することになってしまいます。しかしこれが「同じもの」を参照し続けるというイミュータブルオブジェクトの頑固さであり、最初に生成された(1,2,3)という値が、+=の処理後も存在し続けるという証拠にもなります。

タイトルとURLをコピーしました