リストをコピーしたつもりで別の変数に入れ、そちらに要素を足したら、元のリストまで変わっていた。そんな経験はありませんか?
文字列や数値では同じことをしても何も起きないのに、リストだけ挙動が違う。初めてこの現象に出会うと、Pythonが壊れたのかと思ってしまいますよね。
この不思議な動きの正体は、ミュータブルとイミュータブルという性質の違いです。この記事では、言葉の意味から実際の落とし穴まで、順番にほどいていきます。サンプルの実行結果は、手元のPython 3.11で確認したものです。
ミュータブルとイミュータブルの意味¶
最初に、2つの言葉の意味をはっきりさせておきましょう。どちらも英語の単語がそのままカタカナになったものです。
ミュータブル(mutable)は、作ったあとで中身を変えられるという意味です。イミュータブル(immutable)はその反対で、一度作ったら中身を変えられないという意味になります。
Python公式ドキュメントのデータモデルの章でも、値が変わりうるオブジェクトをミュータブル、作成後に値を変えられないオブジェクトをイミュータブルと呼ぶと説明されています。そして、どちらになるかはオブジェクトの型で決まるとされています。
つまり、ある値がミュータブルかどうかは、その値がリストなのか、文字列なのか、といった型を見ればわかるわけです。では、代表的な型がどちらに分類されるのかを見ていきましょう。
型ごとの分類を表で確認しよう¶
よく使う組み込み型を、ミュータブルとイミュータブルに分けて表にまとめました。まずはこの表をざっと眺めて、全体の地図を頭に入れてください。
| 型 | 例 | 分類 | 中身を変える操作の例 |
|---|---|---|---|
| int(整数) | 10 | イミュータブル | なし |
| float(小数) | 3.14 | イミュータブル | なし |
| str(文字列) | "abc" | イミュータブル | なし(新しい文字列を作る) |
| tuple(タプル) | (1, 2) | イミュータブル | なし |
| frozenset | frozenset({1, 2}) | イミュータブル | なし |
| bool / None | True, None | イミュータブル | なし |
| list(リスト) | [1, 2] | ミュータブル | append()、要素の代入 |
| dict(辞書) | {"a": 1} | ミュータブル | キーへの代入、update() |
| set(集合) | {1, 2} | ミュータブル | add()、remove() |
ざっくり言うと、数値や文字列、タプルは変えられない側です。リスト、辞書、集合は変えられる側だと覚えておくと、ほとんどの場面で困りません。
試しに、文字列の1文字目を書き換えてみましょう。次のようにすると、Pythonはきっぱりと拒否してきます。
s = "abc"
s[0] = "z"
# TypeError: 'str' object does not support item assignment
文字列は作ったあとに1文字だけ差し替えることができません。upper()やreplace()のような文字列のメソッドも、元の文字列を変えるのではなく、新しい文字列を作って返しています。
変数は値に貼られた名札である¶
ミュータブルの話を理解するには、Pythonの変数の仕組みを知っておく必要があります。少し意外かもしれませんが、変数は値を入れる箱ではありません。
Pythonの変数は、メモリ上にあるオブジェクトに貼られた名札のようなものです。b = a と書くと、aが指しているオブジェクトに、bという名札がもう1枚貼られるだけになります。
リストで名札を2枚貼るとどうなるか¶
この仕組みを踏まえて、冒頭の現象をコードで再現してみます。bに要素を足しただけなのに、aの表示も変わる点に注目してください。
a = [1, 2]
b = a
b.append(3)
print(a) # [1, 2, 3]
print(b) # [1, 2, 3]
print(a is b) # True
aとbは、同じ1つのリストに貼られた2枚の名札です。b.append(3) はそのリストの中身を変えるので、aから見ても変わって見えるというわけです。
a is b がTrueになっているのは、2つの名札が同じオブジェクトを指している証拠です。isと==の違いについては、こちらの記事で詳しく解説しています。 【関連記事】Pythonのisと== は何が違う? Pythonの比較演算子で知っておくべき罠
文字列では同じことが起きない理由¶
では、文字列で同じことをするとどうなるでしょうか。今度はtに文字を足しても、sは元のままです。
s = "abc"
t = s
t += "d"
print(s) # abc
print(t) # abcd
print(s is t) # False
文字列はイミュータブルなので、中身を書き換えることができません。そのため t += "d" を実行すると、Pythonは新しい文字列 "abcd" を作り、tの名札だけをそちらへ貼り替えます。
sの名札は元の "abc" に貼られたままなので、影響を受けません。同じ += でも、ミュータブルなら中身が変わり、イミュータブルなら名札が貼り替わるのです。
id()で同じオブジェクトかどうかを確かめる¶
名札の貼り替えが本当に起きているのか、自分の目で確かめる方法もあります。それが、オブジェクトごとの識別番号を返す組み込み関数のid()です。
公式ドキュメントでは、この識別番号はオブジェクトが存在している間は変わらないと説明されています。ですから、操作の前後でid()の結果を比べれば、同じオブジェクトのままなのかがわかります。
次のコードでは、整数とリストで操作の前後のid()を比べています。結果の違いに注目してください。
x = 10
before = id(x)
x += 1
print(id(x) == before) # False(新しい整数に貼り替わった)
nums = [1]
before = id(nums)
nums += [2]
print(id(nums) == before) # True(同じリストの中身が増えた)
nums = nums + [3]
print(id(nums) == before) # False(新しいリストが作られた)
最後の2つを見比べると、少しおもしろいことがわかります。リストでも、nums = nums + [3] のように書くと新しいリストが作られ、名札が貼り替わるのです。
+= は中身を直接書き換え、+ は新しいリストを作る。この細かな違いが、あとで紹介する落とし穴につながっていきます。
関数の引数で起きるすれ違い¶
ミュータブルかどうかの違いがいちばん表に出るのは、関数に値を渡したときです。Pythonでは、引数を渡すときもオブジェクトに名札が1枚増えるだけで、コピーは作られません。
そのため、関数の中でリストを書き換えると、呼び出し元のリストも変わります。反対に、整数を受け取って足し算しても、呼び出し元の値はそのままです。
def add_item(items):
items.append("みかん")
def add_one(n):
n += 1
fruits = ["りんご"]
add_item(fruits)
print(fruits) # ['りんご', 'みかん']
count = 0
add_one(count)
print(count) # 0
add_item()はリストの中身を変えたので、外のfruitsにも反映されました。add_one()は新しい整数に名札を貼り替えただけなので、外のcountには届いていません。
呼び出し元を変えたくないときの書き方¶
関数の中でリストを加工したいけれど、元のリストは残しておきたい。そんなときは、関数の中でコピーを作ってから操作するのが基本です。
list(items) や items.copy() で新しいリストを作れば、元のリストには影響しません。ただし、これは浅いコピーと呼ばれるもので、リストの中にリストがある場合は内側まで複製されない点に注意が必要です。
浅いコピーと深いコピーの違いは、次の記事で図解しています。入れ子のデータを扱うことが多い方は、ぜひ読んでおいてください。 【関連記事】Pythonの浅いコピー(シャローコピー)と深いコピー(ディープコピー)の違いを徹底解説!
関数まわりでは、デフォルト引数にリストを指定したときの有名な落とし穴もあります。呼ぶたびに前回の値が残ってしまう現象で、原因はまさにミュータブルな性質です。 【関連記事】Pythonのデフォルト引数の落とし穴とは?空のリストを指定した関数が壊れる理由
実務でハマったリスト共有の失敗¶
ここで、私自身の失敗談をひとつ紹介させてください。私は10年ほどエンジニアとして開発に関わってきましたが、このミュータブルの罠には何度も足をすくわれています。
以前、ユーザーごとの設定を辞書で管理するプログラムで、初期値の辞書をそのまま全員に代入してしまったことがあります。ある1人の設定を変えたところ、全員の設定が同時に変わってしまい、原因に気づくまで半日かかりました。
それ以来、ミュータブルな値を別の変数や属性に渡すときは、共有したいのか、別々にしたいのかを必ず意識するようにしています。コードレビューでも、ここは真っ先に確認するポイントのひとつです。
辞書のキーや集合に使える型¶
ミュータブルかどうかは、辞書のキーや集合の要素に使えるかどうかにも関わってきます。キーに使えるのは、ハッシュ可能(hashable)なオブジェクトだけです。
ハッシュ可能とは、オブジェクトから計算した値(ハッシュ値)が、存在している間ずっと変わらないことを指します。辞書や集合は、このハッシュ値を手がかりにして高速にデータを探しているのです。
中身が変わるリストをキーにしてしまうと、あとからハッシュ値の前提が崩れてしまいます。そのため、Pythonの組み込みのミュータブルなコンテナは、キーには使えません。
d = {}
d[(1, 2)] = "OK" # タプルはキーにできる
d[[1, 2]] = "NG" # TypeError: unhashable type: 'list'
座標や日付の組み合わせのように、複数の値をまとめてキーにしたい場面はよくあります。そんなときは、リストではなくタプルを使いましょう。
集合を集合の要素にしたい場合も、通常のsetはミュータブルなので使えません。代わりに、変更できない集合であるfrozensetを使えば、キーや要素として扱えます。
辞書と集合そのものの使い方を復習したい方は、こちらの記事も参考になります。 【関連記事】Pythonの辞書(dict)とは?キーと値でデータを管理する基本
タプルの中のリストという落とし穴¶
最後に、少し上級者向けの落とし穴を紹介します。タプルはイミュータブルですが、その中にリストを入れることはできます。
公式ドキュメントでは、このような場合、中のリストの値が変わればタプルの値も変わって見えると説明されています。それでもタプルがイミュータブルとされるのは、どのオブジェクトを含むかという組み合わせは変えられないからです。
この性質が、次のような奇妙な現象を生みます。エラーが出たのに、中身は変わっているのです。
t = (1, [2, 3])
try:
t[1] += [4]
except TypeError as e:
print(e) # 'tuple' object does not support item assignment
print(t) # (1, [2, 3, 4])
エラーで止まったのだから何も変わっていないはず、と思いますよね。ところが表示してみると、リストには4がしっかり追加されています。
なぜエラーなのに書き換わるのか¶
この現象は、Python公式のプログラミングFAQにも、加算はうまくいくのになぜ例外が出るのか、という項目として載っています。ポイントは、+= が実は2段階の処理だということです。
1段階目で、リストの中身を直接増やす処理が実行されます。2段階目で、その結果をタプルの t[1] に代入し直そうとして、タプルは代入を受け付けないためTypeErrorになるのです。
1段階目はすでに終わっているので、リストは変わったまま残ります。中身を変えたいときは、t[1].append(4) のようにリストのメソッドを直接呼べば、エラーなく追加できます。
ミュータブルとうまく付き合うコツ¶
ここまで見てきたように、ミュータブルな値は便利な反面、思わぬところで共有されてしまう危うさがあります。日々のコードで意識しておきたい考え方を、表で整理しておきます。
| 場面 | 起きやすい問題 | おすすめの対策 |
|---|---|---|
| 変数にリストを代入する | 片方の変更がもう片方に伝わる | 別々にしたいならcopy()で複製する |
| 関数にリストを渡す | 呼び出し元のデータが変わる | 関数の中でコピーしてから加工する |
| デフォルト引数にリストを使う | 前回の呼び出しの値が残る | Noneを既定値にして関数内で作る |
| 変える予定のないデータ | 誤って書き換えてしまう | タプルやfrozensetを使う |
| 複数の値を辞書のキーにする | リストだとTypeErrorになる | タプルにまとめる |
とくに4行目の考え方は、覚えておくと役に立ちます。変える必要のないデータは、最初からイミュータブルな型で持っておくと、うっかり書き換える事故そのものが起きません。
設定値の一覧や、曜日の名前のように決まったデータなら、リストよりタプルのほうが意図が伝わります。読む人にとっても、これは変わらない値なんだな、という目印になるからです。
もちろん、リストや辞書を避ける必要はまったくありません。中身を変えるという性質を理解したうえで、共有したいのか、別々にしたいのかを意識すれば十分です。
まとめ¶
今回は、Pythonのミュータブルとイミュータブルについて解説しました。最後に要点を振り返っておきましょう。
ミュータブルは作ったあとに中身を変えられる型で、リスト、辞書、集合が代表です。イミュータブルは変えられない型で、数値、文字列、タプルなどが当てはまります。
Pythonの変数は値に貼られた名札なので、代入や引数の受け渡しではコピーは作られません。だからこそ、ミュータブルな値を共有すると、片方の変更がもう片方に伝わります。
リストが勝手に書き換わるという現象も、名札の仕組みがわかれば怖くありません。おかしいなと思ったら、id()やisで同じオブジェクトかどうかを確かめるところから始めてみてください。
ここまでお読みいただきありがとうございました。