【Python】ミュータブル・イミュータブルなオブジェクトの違いと注意点

Pythonにおけるミュータブル・イミュータブルなオブジェクトの違いと注意点について解説します。

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

Pythonの変数は、中身を変更できる「ミュータブル」なものと、中身を変更できない「イミュータブル」なものがあります。

種類 説明
ミュータブル 中身を変更できる list, dict, set(リスト型、辞書型、集合型など)
イミュータブル 中身を変更できない int, float, str, tuple, bool(整数型、浮動小数点型、文字列型など)

これらの違いをよく理解していないと、関数に引数を渡すときなどに意図しない動作を引き起こしてしまいます。
オブジェクトがメモリ上のどこにあるか(ID:識別番号=)を確認できるid()関数を使って、「ミュータブル」と「イミュータブル」の違いを詳しく見てきましょう。ちなみに、IDはオブジェクトがメモリ上に存在する場所(アドレス)に対応する整数値です。

サンプルコード①

以下は、イミュータブルなオブジェクトの1つであるint型(整数型)の変数に値を再代入する例です。

pikori_hp = 110
print(f"再代入前のIDは{id(pikori_hp)}")

pikori_hp = pikori_hp + 10
print(f"再代入後のIDは{id(pikori_hp)}")
再代入前のIDは10860584
再代入後のIDは10860904

このように、イミュータブルなオブジェクトは中身を変更できないため、再代入すると新しいオブジェクトが生成され、IDが変わります。つまり、「再代入前のpikori_hp」と「再代入後のpikori_hp」のデータは、メモリ上の異なる場所にあることになります。

サンプルコード②

以下は、ミュータブルなオブジェクトの1つであるlist型(リスト型)の変数に要素を追加する例です。

pikori_bag = ["剣", "盾", "地図"]
print(f"要素を追加する前のIDは{id(pikori_bag)}")

pikori_bag.append("ポーション")
print(f"要素を追加した後のIDは{id(pikori_bag)}")
要素を追加する前のIDは140159741014464
要素を追加した後のIDは140159741014464

append()メソッドは、オブジェクトの中身を変更しているだけなので、IDが変わりません。つまり、「要素を追加する前のpikori_bag」と「要素を追加した後のpikori_bag」のデータは、メモリ上の同じ場所にあることになります。

サンプルコード③

以下は、ミュータブルなオブジェクトの1つであるlist型(リスト型)の参照をコピーする例です。

pikori_bag = ["剣", "盾", "地図"]
print(f"コピー前のIDは{id(pikori_bag)}")

pikori_bag2 = pikori_bag
print(f"コピー後のIDは{id(pikori_bag2)}")
コピー前のIDは140074429592000
コピー後のIDは140074429592000

再代入してもIDは変わりません。つまり、「コピー前のpikori_bag」と「コピー後のpikori_bag」のデータは、メモリ上の同じ場所にあることになります。異なるオブジェクトとしてコピーする場合はcopy()メソッドを使う必要があります。

サンプルコード④

以下は、ミュータブルなオブジェクトの1つであるlist型(リスト型)に再代入する例です。

pikori_bag = ["剣", "盾", "地図"]
print(f"再代入前のIDは{id(pikori_bag)}")

pikori_bag = ["ポーション", "巻物"]
print(f"再代入後のIDは{id(pikori_bag2)}")
再代入前のIDは139751152824768
再代入後のIDは139751150470400

新しいリストオブジェクトを作って再代入しているため、IDが変わります。ミュータブルでも、オブジェクト自体を入れ替えればIDは変わります

引数にミュータブルなオブジェクトを渡して変更した場合

Pythonでは、関数に引数を渡すときに「オブジェクトへの参照(アドレス)」が値として渡されます。
そのため、リスト・辞書などのミュータブル(変更可能)なオブジェクトは、関数内で中身を変更すると元の変数にも影響が及びます

サンプルコード①

以下は、関数にリストを引数を渡し、変更したときの例です。

def modify_inventory(bag):
    # リストに要素を追加
    bag.append("回復薬")
    print(f"関数内: アイテム袋 → {bag}")

# 勇者ぴこりの装備袋
pikori_bag = ["剣", "盾", "地図"]

# 関数の呼び出し
modify_inventory(pikori_bag)
print(f"関数外: アイテム袋 → {pikori_bag}")
関数内: アイテム袋 → [‘剣’, ‘盾’, ‘地図’, ‘回復薬’]
関数外: アイテム袋 → [‘剣’, ‘盾’, ‘地図’, ‘回復薬’]

bagpikori_bag同じリストオブジェクトを参照しているため、関数内で追加された「回復薬」が関数外にも反映されます。

サンプルコード②

以下は、関数にリストを引数を渡し、再代入したときの例です。

def modify_inventory(bag):
    # 変数bagにリストを再代入
    bag = ["弓", "盾", "地図"]
    print(f"関数内: アイテム袋 → {bag}")

# 勇者ぴこりの装備袋
pikori_bag = ["剣", "盾", "地図"]
# 関数の呼び出し
modify_inventory(pikori_bag)
print(f"関数外: アイテム袋 → {pikori_bag}")
関数内: アイテム袋 → [‘弓’, ‘盾’, ‘地図’]
関数外: アイテム袋 → [‘剣’, ‘盾’, ‘地図’]

bag に新しいリストを再代入したことで、関数内の変数は別のオブジェクトを指すようになり、元の pikori_bag には影響しません。このように、再代入は影響しないところが通常の参照渡しとは異なり、混乱しやすいポイントです。

IDの確認

サンプルコード①と②の「オブジェクトの参照」をid() 関数で確認してみましょう。

■サンプルコード①の確認

def modify_inventory(bag):
    bag.append("回復薬")
    print(f"関数内: アイテム袋 → {bag}")
    print(f"関数内: 変数 bag の ID → {id(bag)}")

# 勇者ぴこりの装備袋
pikori_bag = ["剣", "盾", "地図"]
modify_inventory(pikori_bag)
print(f"関数外: アイテム袋 → {pikori_bag}")
print(f"関数外: 変数 pikori_bag の ID → {id(pikori_bag)}")
関数内: アイテム袋 → [‘剣’, ‘盾’, ‘地図’, ‘回復薬’]
関数内: 変数 bag の ID → 140583200747904
関数外: アイテム袋 → [‘剣’, ‘盾’, ‘地図’, ‘回復薬’]
関数外: 変数 pikori_bag の ID → 140583200747904

id() 関数で確認すると、関数内外で2つのリストが同じID(=同じオブジェクト)を指していることがわかります

■サンプルコード②の確認

def modify_inventory(bag):
    # 変数bagにリストを再代入
    bag = ["弓", "盾", "地図"]
    print(f"関数内: アイテム袋 → {bag}")
    print(f"関数内: 変数 bag の ID → {id(bag)}")

# 勇者ぴこりの装備袋
pikori_bag = ["剣", "盾", "地図"]
# 関数の呼び出し
modify_inventory(pikori_bag)
print(f"関数外: アイテム袋 → {pikori_bag}")
print(f"関数外: 変数 pikori_bag の ID → {id(pikori_bag)}")
関数内: アイテム袋 → [‘弓’, ‘盾’, ‘地図’]
関数内: 変数 bag の ID → 139866289410752
関数外: アイテム袋 → [‘剣’, ‘盾’, ‘地図’]
関数外: 変数 pikori_bag の ID → 139866291745216

id() 関数で確認すると、関数内外で2つのリストが異なるID(=異なるオブジェクト)を指していることがわかります。

種別 説明
IDが同じになる場合(サンプルコード①) 同じオブジェクトを複数の変数が参照している場合(例:a = b)、IDは同じになります。これは「同じアイテム袋を複数のラベルで呼んでいる」ような状態です。
IDが異なる場合(サンプルコード②) 新しいオブジェクトを作成した場合(例:再代入やコピー)、IDは異なるものになります。これは「新しいアイテム袋を用意して、別のラベルを貼った」ような状態です。

Pythonは「オブジェクトの参照を値として渡す」という独特な方式を採用しており、C++などの「通常の参照渡し」とは動作が異なる点に注意が必要です。

種別 説明 動作
通常の参照渡し(C++など) 変数そのもの(=メモリの場所)を渡す 関数内で変数の中身を変更したり、再代入すると元の変数も変わる
参照渡しのような動作(Python) 変数が指すオブジェクトへの参照を値として渡す 関数内でオブジェクトの中身を変更すれば反映されるが、再代入しても元の変数には影響しない

引数にイミュータブルなオブジェクトを渡して変更した場合

数値・文字列・タプルなどのイミュータブル(変更不可)なオブジェクトの場合、関数内で値を変更しても元の変数には影響しません
この挙動は、値渡しのように見えるため、混乱しやすいポイントです。

サンプルコード①

以下は、数値(int)の変数を関数内で変更する例です。

def modify_power(power):
    power = 999
    print(f"関数内: 魔力 → {power}")

# 勇者ぴこりの魔力
pikori_power = 100
modify_power(pikori_power)
print(f"関数外: 魔力 → {pikori_power}")
関数内: 魔力 → 999
関数外: 魔力 → 100

関数内で power = 999 に再代入しても、元の pikori_power は変化しません。これは power が新しいオブジェクトを参照するようになったためです。

サンプルコード②

以下は、文字列(str)の場合の変数を関数内で変更する例です。

def rename_hero(name):
    name = "伝説のぴこり"
    print(f"関数内: 名前 → {name}")

pikori_name = "ぴこり"
rename_hero(pikori_name)
print(f"関数外: 名前 → {pikori_name}")
関数内: 名前 → 伝説のぴこり
関数外: 名前 → ぴこり

文字列もイミュータブルなので、関数内で再代入しても外には影響しません

IDの確認

id() 関数で、オブジェクトの識別番号(identity)を確認してみましょう。

■サンプルコード①のIDを確認

def modify_power(power):
    print(f"関数内: ID → {id(power)}")
    power = 999
    print(f"関数内: 再代入後の ID → {id(power)}")

pikori_power = 100
print(f"関数外: ID → {id(pikori_power)}")
modify_power(pikori_power)
関数外: ID → 10860264
関数内: ID → 10860264
関数内: 再代入後の ID → 139795537873424

出力結果では、関数内の再代入後のIDが変化していることがわかります。つまり、新しいオブジェクトが作られたということになります。
Pythonでは「オブジェクトの参照を値として渡す」ため、イミュータブルなオブジェクトは再代入しても元の変数に影響しないという挙動になります。

デフォルト引数の罠

Pythonでは、関数の引数に初期値(デフォルト値)を設定することができます。しかし、以下のようにミュータブル(変更可能)なオブジェクトを初期値にすると、予期しない動作が起こることがあります

サンプルコード

def add_to_list(value, lst=[]):
    lst.append(value)
    return lst

print(add_to_list("剣"))  # ['剣']
print(add_to_list("盾"))  # ['剣', '盾'] ← あれ?前回の値が残ってる!
[‘剣’]
[‘剣’, ‘盾’]

Pythonでは、関数のデフォルト引数は関数が定義された瞬間に一度だけ評価されます。そのとき、ミュータブルなオブジェクト(例:リスト [])が使われると、そのオブジェクトへの参照が保存され続けます。つまり、lst の初期値 [] は、関数が定義されたときに一度だけ作られたあと保持され続けるため、 関数を何度呼び出しても、同じリストが使い回されてしまいます。です。これを避けるには、以下のようにデフォルト値に None を使い、関数内で新しいリストを作ります

def add_to_list(value, lst=None):
    if lst is None:
        lst = []
    lst.append(value)
    return lst

print(add_to_list("剣"))  # ['剣']
print(add_to_list("盾"))  # ['盾'] ← 前回の値は残っていない!
[‘剣’]
[‘盾’]

None は「引数に何も渡されなかったよ」という指示です。そのときだけ新しいリスト [] を作ることで、毎回別のオブジェクトが使われるようになります。

Python公式ドキュメントの説明

Python公式ドキュメント「4. その他の制御フローツール」では以下のように記述されています。

関数を呼び出す際の実際の引数 (実引数) は、関数が呼び出されるときに関数のローカルなシンボルテーブル内に取り込まれます。そうすることで、実引数は 値渡し (call by value) で関数に渡されることになります (ここでの 値 (value) とは常にオブジェクトへの参照(reference) をいい、オブジェクトの値そのものではありません) [1]。ある関数がほかの関数を呼び出すときや、自身を再帰的に呼び出すときには、新たな呼び出しのためにローカルなシンボルテーブルが新たに作成されます。

(略)

脚注
[1]
実のところ、オブジェクトへの参照渡し (call by object reference) という言ったほうがより正確です。というのは、変更可能なオブジェクトが渡されると、呼び出された側の関数がオブジェクトに行った変更 (例えばリストに挿入された要素) はすべて、関数の呼び出し側にも反映されるからです。

関連ページ(もっと学びたい人へ)

Pythonの関数の使い方について、以下ページから詳しく学ぶことができます。

【Python超入門】ユーザー定義関数の作り方(def文)
Pythonにおけるユーザー定義関数の作り方(def文)について入門者向けにまとめました。

Pythonの基礎から応用例まで、以下ページから詳しく学ぶことができます。

【Python超入門】基礎から応用例まで幅広く解説
PythonについてPythonは、統計処理や機械学習、ディープラーニングといった数値計算分野を中心に幅広い用途で利用されているプログラミング言語です。他のプログラミング言語と比較して「コードが短くて読みやすい、書きやすい」「ライブラリが豊...
この記事を書いた人
西住技研

プログラミング言語「Python」を研究、仕事、趣味でデータ分析や作業自動化などに活用してきたノウハウを情報発信しています。
筆者の詳しいプロフィールやお問合せはこちらのページまで。
YoutubeX(旧Twitter)でも情報発信中です!

西住技研をフォローする
Python基礎

コメント

  1. juner より:

    Pythonには参照渡しは無いのではないでしょうか?

    公式ドキュメントにも無いと明言されています。

    ここで言っている参照渡しはミュータブルなインスタンスを渡しているだけでは……(共有渡しともいわれるものでは)

  2. juner より:

    > 渡された値が変更されると元の値は変更されない(新しい領域が確保される)。

    とありますが、変更手段が用意されていないならそもそも変更できないのではないでしょうか……?