🦔

Pythonのミュータブルとイミュータブルの違いと実践例

2025/01/19に公開

はじめに

Pythonのミュータブルとイミュータブルの違いを理解せずにコードを書くと、予期しない動作が起こることがあります。
今回は、実際に私が経験したエラーをもとに、これらの概念をわかりやすく解説できたらと思います。

結論

ミュータブルとイミュータブルの違いを知らないまま関数を用いると意図しないことが起きます。
習慣として、よく使うライブラリは1度でいいから公式ドキュメントに目を通しておくとよいです。

ミュータブルとイミュータブルの違い

ミュータブル、イミュータブルは以下に分類できます。

ミュータブル イミュータブル
dict, list int, float, str

ミュータブルとイミュータブルの違いは見ていただいた方が早いので、以下にイミュータブルのコードを記載します。

from typing import Tuple, List, Dict

def test_immutable_effects(immutable_int:int, immutable_str:str) -> Tuple[int, str]:
    immutable_int = immutable_int + 1
    immutable_str = immutable_str + "ST"

    return immutable_int, immutable_str

test_int = 1
test_str = "TE"

updated_int, updated_str = test_immutable_effects(test_int, test_str)
print(f"関数前:{test_int} 関数後:{updated_int}")  # 関数前:1 関数後:2
print(f"関数前:{test_str} 関数後:{updated_str}")  # 関数前:TE 関数後:TEST

こちらは特に違和感はないと思います。
次にミュータブルのコードを記載します。

def test_mutable_effects(mutable_list:List[int], mutable_dict:Dict[str, int]) -> Tuple[List[int], Dict[str, int]]:
    mutable_list.append(0)
    mutable_dict["ぶどう"] = 300

    return mutable_list, mutable_dict

test_list = [i for i in range(5)]
test_dict = {"りんご":100}

updated_list, updated_dict = test_mutable_effects(test_list, test_dict)

print(f"関数前:{test_list} 関数後:{updated_list}") # 関数前:[0, 1, 2, 3, 4, 0] 関数後:[0, 1, 2, 3, 4, 0]
print(f"関数前:{test_dict} 関数後:{updated_dict}") # 関数前:{'りんご': 100, 'ぶどう': 300} 関数後:{'りんご': 100, 'ぶどう': 300}

関数前と関数後の変数の値が同じになっています。
これは関数を渡す際の挙動が違います。
ミュータブルなオブジェクト(例:リスト、辞書)は関数に渡すとき、メモリ上の参照(ポインタ)が渡されます。そのため、関数内で変更を加えると元のオブジェクトにも影響します。一方、イミュータブルなオブジェクト(例:数値、文字列)は関数に渡すときに値そのものがコピーされます。そのため、関数内での変更は元のオブジェクトに影響しません。

実際にid関数を使用して、どのアドレスにデータが格納されているか見てみましょう。
id関数を使うと、オブジェクトのメモリアドレスが確認できるので、この関数で同じオブジェクトを参照しているのか新しいオブジェクトが作られたのかが分かります。

def test_mutable_effects(mutable_list:List[int], mutable_dict:Dict[str, int]) -> Tuple[List[int], Dict[str, int]]:
    mutable_list.append(0)
    mutable_dict["ぶどう"] = 300

    print(id(mutable_list)) # 1578528109376
    print(id(mutable_dict)) # 1578527899264

    return mutable_list, mutable_dict

test_list = [i for i in range(5)]
test_dict = {"りんご":100}
print(id(test_list)) # 1578528109376
print(id(test_dict)) # 1578527899264

updated_list, updated_dict = test_mutable_effects(test_list, test_dict)

同じメモリ上を参照しているのが分かると思います。
上記対策として、copy関数を使用します。

def test_mutable_effects(mutable_list:List[int], mutable_dict:Dict[str, int]) -> Tuple[List[int], Dict[str, int]]:
    mutable_list = mutable_list.copy()
    mutable_dict = mutable_dict.copy()
    mutable_list.append(0)
    mutable_dict["ぶどう"] = 300

    print(id(mutable_list)) # 1578528034688
    print(id(mutable_dict)) # 1578527870848

    return mutable_list, mutable_dict

test_list = [i for i in range(5)]
test_dict = {"りんご":100}
print(id(test_list)) # 1578527640448
print(id(test_dict)) # 1578527868672

updated_list, updated_dict = test_mutable_effects(test_list, test_dict)

ただ上記関数だと2重配列の際は上手くいきません。

def test_mutable_effects(mutable_list:List[int], mutable_dict:Dict[str, int]) -> Tuple[List[int], Dict[str, str]]:
    mutable_list = mutable_list.copy()
    mutable_dict = mutable_dict.copy()
    mutable_list[0].append(0)
    mutable_dict["user"]["002"] = "suzuki"

    return mutable_list, mutable_dict

test_list = [[0], [1]]
test_dict = {"user": {"001":"tanaka"}}


updated_list, updated_dict = test_mutable_effects(test_list, test_dict)
print(f"関数前:{test_list} 関数後:{updated_list}") # 関数前:[[0, 0], [1]] 関数後:[[0, 0], [1]]
print(f"関数前:{test_dict} 関数後:{updated_dict}") # 関数前:{'user': {'001': 'tanaka', '002': 'suzuki'}} 関数後:{'user': {'001': 'tanaka', '002': 'suzuki'}}

これは上記コードは浅いコピーと言われていて、一番外側の配列や辞書の形式はコピーしますが、内部はメモリ上を参照することになっています。そのため、浅いコピーは、リスト内のミュータブル(リストや辞書)はまだ参照アドレスで見られています。
上記の改善として、内部まで全てをコピーする場合はcopy.deepcopyを使います。

import copy

def test_mutable_effects(mutable_list:List[int], mutable_dict:Dict[str, int]) -> Tuple[List[int], Dict[str, int]]:
    mutable_list = copy.deepcopy(mutable_list)
    mutable_dict = copy.deepcopy(mutable_dict)
    mutable_list[0].append(0)
    mutable_dict["user"]["suzuki"] = 300

    return mutable_list, mutable_dict

test_list = [[0], [1]]
test_dict = {"user": {"001":"tanaka", "002": "satou"}}

updated_list, updated_dict = test_mutable_effects(test_list, test_dict)
print(f"関数前:{test_list} 関数後:{updated_list}") # 関数前:[[0], [1]] 関数後:[[0, 0], [1]]
print(f"関数前:{test_dict} 関数後:{updated_dict}") # 関数前:{'user': {'001': 'tanaka'}} 関数後:{'user': {'001': 'tanaka', '002': 'suzuki'}}

pandasのcopyについて(補足)

私は昔Excelをpandasで使用していた際、以下のようなコードを使用していました。

import pandas

def preprocess_df(df:pd.DataFrame):
    df_c = df.copy()
    # 処理
    return df_c

関数内でコピーして使用していました。
コードをテストしていた際、そういえばcopy.deepcopy使わないといけないの?と思って調べたことがありました。

結論として、不要です。ライブラリ側で考慮してくれているからです。
DataFrame.copy(deep=True)となっており、デフォルトでデータ全体をコピーしています。
当時はChatGPTがなかったのと、公式ドキュメントを読まず、Qiita等の記事を参考にしていたため、調べるのに時間がかかりました。
こういう細かなところを見る際は、まずは使用している公式ライブラリを見た方が良いと思います。
https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.copy.html

さいごに

記事を書くのは結構大変ですね。
この頃AWS関連の勉強をしている為、AWS関連の記事も上げたいと思います。

Discussion