R

ReflexKR

Reflex公式ドキュメント
名札の空港:Pythonのオブジェクトモデル
2限目

時間 0:50–1:40

封印されたかばん:ミュータブルとイミュータブル

紛失届

LOST-002

封印されたかばんが膨らんだ

届出人

免税店の在庫担当、オさん

なくしたもの

封印シールを貼ったかばん(タプル)が一つ。はかりは「封印されたかばんは開けられない」というエラーを出したのに、開けてみると中身が一つ増えていた。

解決の条件

エラーが出たのに中身が入ってしまった理由を突き止め、本当に開けられないかばんを作る。

ハヌルソム国際空港 手荷物センター

朝礼で、ド・ミラン班長が封印シールを一枚掲げてみせます。「これが貼られたかばんは、誰にも開けられない。なのに今日の届けは、封印されたかばんが膨らんだって話だ」

免税店の在庫担当者から届いたコードは短いものです。タプルに品物を入れようとしたら TypeError が出ました。ところがタプルをもう一度表示してみると、品物が入っていたのです。班長が言います。「封印は外側のかばんに貼っただけ。中のかばんまで封印したわけじゃない」

2-1. 封印されたかばんと、開いたかばん (9分)

オブジェクトは二種類に分かれます。作ったあとで中身を変えられるものがミュータブル(mutable)、変えられないものがイミュータブル(immutable)です。

種類代表的な型空港でいえば
ミュータブルlist、dict、setファスナーが開くかばん。出し入れしても同じかばんのまま。
イミュータブルint、float、str、tuple、frozenset、bool、None封印されたかばん。中身を変えたければ、新しいかばんを詰め直すしかない。

イミュータブルなオブジェクトには、中身を変える手段がそもそもありません。

word = "gate"
print(word.upper(), word)
# GATE gate
word[0] = "G"
# TypeError: 'str' object does not support item assignment
fs = frozenset({1, 2})
fs.add(3)
# AttributeError: 'frozenset' object has no attribute 'add'

word.upper() は word を書き換えません。大文字になった新しい文字列を作って返します。文字列のメソッドはすべてこう動きます。

では n = n + 1 は何でしょう? 整数10というかばんを書き換えたのではありません。11という新しい整数オブジェクトを得て、タグ n をそちらへ付け替えたのです。イミュータブルなオブジェクトの「変更」は、いつでも新しいオブジェクト+再束縛です。

下のタグボードは、整数も一つずつかばんとして描きます。3行目のあと、n の矢印がどこへ行くか見てください。

タグボード

ステップ 1 / 0

次に実行する行: 0

実習台を準備中…

いま変わったもの

封印されたかばん(イミュータブル)

3行目のあとも整数10はそのまま残り、11という新しいかばんができて、タグ n だけがそちらへ移ります。m は相変わらず10を指しています。

2-2. += の二つの顔 (10分)

a += b は、見た目には一つの操作です。しかしPythonはこう処理します。

  1. a の型に __iadd__(インプレース加算)メソッドがあれば、それを呼びます。かばんをその場で変え、同じかばんを返します。
  2. なければ、a = a + b と同じように __add__ で新しいオブジェクトを作り、タグを付け替えます。
print(hasattr(list, "__iadd__"), hasattr(int, "__iadd__"))
# True False
print(hasattr(str, "__iadd__"), hasattr(tuple, "__iadd__"))
# False False

リストはミュータブルなので __iadd__ を持っています。イミュータブルな型にはありません。封印されたかばんは、その場で変えられないからです。そのため、同じ += がこう分かれます。

a = [1, 2]
b = a
a += [3]
print(a, b, a is b)
# [1, 2, 3] [1, 2, 3] True

a = (1, 2)
b = a
a += (3,)
print(a, b, a is b)
# (1, 2, 3) (1, 2) False

a = "T1"
b = a
a += "-A"
print(a, b, a is b)
# T1-A T1 False
型a += x がすること別のタグ b から見えるか
list__iadd__:同じかばんに extend見える
tuple、str、int__add__:新しいオブジェクトを作り、a だけを付け替える見えない

1限目の a = a + [3] と、リストの a += [3] が違う結果になる理由がこれです。前者は新しいリスト、後者は同じリストです。set の |= や dict の |= も、リストと同じくその場で変えます。

三つの型を切り替えて動かしてみてください。

タグボード

ステップ 1 / 0

次に実行する行: 0

実習台を準備中…

いま変わったもの

封印されたかばん(イミュータブル)

list では3行目のあとも、2本の矢印が同じかばんを指しています。tuple と str では新しいかばんができ、a だけが移ります。新しいタプルの中の 1、2 は、元のタプルと同じ整数オブジェクトです。

ピンの案内

→ 関数でリストを受け取って x += [...] をすると、呼び出し側のリストまで変わります。整数や文字列なら外側はそのまま。同じ1行が型によって違う動きをすることを覚えておいてください(4限目)。

2-3. 封印は外側だけ:タプルの中のリスト (8分)

タプルがイミュータブルだというのは、正確には「タプルが入れている参照を変えられない」という意味です。各枠がどのオブジェクトを指すかは固定されます。けれども、その枠が指すオブジェクトがリストなら、リストそのものはいくらでも変わります。

t = (1, [2])
t[1].append(3)
print(t)
# (1, [2, 3])
t[1] = [9]
# TypeError: 'tuple' object does not support item assignment

届出人のコードは append ではなく += を使っていました。このとき、奇妙なことが起こります。

t = (1, [2])
try:
    t[1] += [3]
except TypeError as e:
    print("TypeError:", e)
print(t)
# TypeError: 'tuple' object does not support item assignment
# (1, [2, 3])

エラーが出たのに、リストは変わっています。t[1] += [3] をばらして書くと、二段階になるからです。

t = (1, [2])
tmp = t[1]
tmp = tmp.__iadd__([3])   # 段階1:リストをその場で変える(成功)
print(t)
# (1, [2, 3])
t[1] = tmp                # 段階2:タプルの枠に入れ直す(失敗)
# TypeError: 'tuple' object does not support item assignment

段階1で、リストはすでに変わっています。段階2の枠への代入が失敗してエラーになりますが、元に戻す手段はありません。はかりが「封印されたかばんは開けられない」と叫びながら、品物を入れてしまった理由です。

タグボード

ステップ 1 / 0

次に実行する行: 0

実習台を準備中…

いま変わったもの

封印されたかばん(イミュータブル)

最後のステップでエラーが表示されますが、図のリストにはもう3が入っています。タプルの枠は相変わらず同じリストを指しています。

中まで封印されているかを確かめる簡単な方法があります。hash() が通るかを見ることです。ハッシュできるタプルは、中身もすべてハッシュできる(たいていはイミュータブルな)オブジェクトです(12限目)。

print(hash(("passport", ("snack",))) == hash(("passport", ("snack",))))
# True
hash(("passport", ["snack"]))
# TypeError: unhashable type: 'list'

2-4. 文字列を += でつなぐコスト (6分)

文字列はイミュータブルなので、s += "x" は原則として毎回新しい文字列を作ります。長さnの文字列を1文字ずつつなぐと、コピーする文字数は 1 + 2 + … + n と膨らみます。

def plus(n):
    s = ""
    for i in range(n):
        s += "x"
    return s

def plus_shared(n):
    s = ""
    for i in range(n):
        last = s          # 同じ文字列にタグがもう一枚付いている
        s += "x"
    return s

def join(n):
    return "".join(["x" for i in range(n)])

print(plus(5) == plus_shared(5) == join(5))
# True

CPythonでは、文字列に付いているタグが一枚だけなら、+= がこっそりその場で伸ばす最適化をします。そのため plus は思ったより速いのです。しかしタグがもう一枚付いただけで、この最適化は消えます。このPC(CPython 3.14.2)で測った例です。

繰り返し回数plusplus_sharedjoin
100,000約6ms約120ms約3ms
200,000約12ms約460ms約5ms

plus_shared は回数が2倍になると、時間が4倍近くに増えました。コピー量が2乗で増えている様子です。この最適化は言語が約束したものではないので、ループで文字列を集めるときは、リストに入れておいて "".join() で一度につなぎます。

2-5. 小さな整数とインターン:値の比較に is を使わない理由 (7分)

イミュータブルなオブジェクトは変わることがないので、同じ値なら一つのオブジェクトを皆で共有しても安全です。CPythonはこれを利用して、よく使うオブジェクトをあらかじめ作っておきます。

小さな整数のキャッシュ。CPythonでは、-5から256までの整数を起動時に作っておき、いつも同じオブジェクトを返します。

print(int("256") is int("256"), int("257") is int("257"))
# True False
print(int("-5") is int("-5"), int("-6") is int("-6"))
# True False

int("…") で実行中に整数を作っているのはわざとです。コードに 257 is 257 のようにリテラルを直接書くと、コンパイラが同じ定数を一つにまとめて True になることがあります。Pythonもこうした比較には警告を出します。

x = 256
print(x is 256)
# SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
# True

文字列のインターン。CPythonは、名前のような形の文字列定数を一つのオブジェクトにまとめて使います(インターン)。実行中に作った文字列は、値が同じでも別のオブジェクトかもしれません。sys.intern() を使えば、同じ値の文字列を一つのオブジェクトにそろえられます。

import sys
a = "gate"
b = "".join(["ga", "te"])
print(a == b, a is b)
# True False
print(sys.intern(b) is a)
# True

結果がキャッシュやインターンの有無で変わるので、is で値を比べると、ある値では正しく、別の値では間違います。値はいつでも == で、is は None のように一つしかないオブジェクトにだけ使います。

ピンの案内

→ テストで小さな数だけを確かめていると、x is 100 がいつも True なのでバグが隠れます。本番で1000が来た瞬間に False になるんです。数値と文字列は == で比べましょう。

事件解決 (10分)

届出人のコードと、本当に開けられないかばんを作る方法です。

sealed = ("passport", ["snack"])
try:
    sealed[1] += ["perfume"]
except TypeError as e:
    print("scale:", e)
print(sealed)
# scale: 'tuple' object does not support item assignment
# ('passport', ['snack', 'perfume'])

sealed = ("passport", ("snack",))   # 中もタプルにする
try:
    sealed[1] += ("perfume",)
except TypeError as e:
    print("scale:", e)
print(sealed)
# scale: 'tuple' object does not support item assignment
# ('passport', ('snack',))
  • 一つ目のかばんは、外側だけが封印されていました。中のリストの __iadd__ が先に成功し、枠への代入だけが失敗しました。
  • 二つ目のかばんは、中もタプルです。タプルには __iadd__ がないので新しいタプルを作るだけで、枠への代入で失敗します。元のかばんはそのままです。

班長は新しい封印シールを中のかばんにも貼りながら言います。「封印は外側で終わりじゃない。中に入っているかばんまで見ないと」

解決報告 — LOST-002。タプルが固定するのは入れている参照だけで、中のリストはミュータブルだった。t[1] += [...] はその場での拡張のあと、枠への代入で失敗。中までイミュータブル(タプル)にして解決。

練習問題

  1. x = 1000、y = x、x += 1 を実行したあと、y は何でしょう? タグの図を描いてから確かめてください。
  2. s = {1}、t = s、s |= {2} を実行すると、t はどうなるでしょう? frozenset({1}) に変えても試してみてください。
  3. t = ([],) のとき、t[0] += [1] と t[0].extend([1]) はそれぞれどう終わるでしょう? 実行後に t も表示してみてください。

班長の業務手帳

  • ミュータブル:list, dict, set。イミュータブル:int, float, str, tuple, frozenset。

  • イミュータブルなオブジェクトの「変更」は、いつでも新しいオブジェクトを作ってタグを付け替えること。

  • += は __iadd__ があればその場で(list)、なければ新しいオブジェクト(int, str, tuple)。

  • タプルが固定するのは入れている参照だけ。中のリストは変わる。t[1] += [...] はエラーを出しながらも変える。

  • ループで文字列を集めるときは、リストに入れて "".join()。

  • 小さな整数のキャッシュとインターンはCPythonの事情。値の比較はいつでも ==。

← 前へ

1限目 · タグはかばんではない:名前とオブジェクト

次へ →

3限目 · かばんの中のかばん:コンテナとコピー

新しい授業・講座のお知らせを受け取る

この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。

収集項目:メールアドレス・目的:この講座の新しい授業・公開のお知らせ・保有期間:解除のご依頼まで。他の目的には使わず、第三者にも提供しません。お知らせメールに返信いただければすぐに削除します。

質問と回答

この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。

リンクや書式なしで、そのままの文字で表示されます。

R

ReflexKR

触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。

ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する

アフィリエイトと支援について
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex