R

ReflexKR

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

時間 8:20–9:10

持ち主のいないかばん:メモリとGC

紛失届

LOST-011

13番コンベヤーの幽霊かばん

届出人

夜間設備点検員のユン技師

なくしたもの

タグが一枚もないのに廃棄されないかばんが二つ。11日目になっても13番コンベヤーを回り続けている。

解決の条件

二つのかばんが廃棄されない理由を突き止め、片付けたあと二度と生まれないようにする。

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

初日から報告が上がっていた13番コンベヤーの二つのかばんが、今日も回っています。タグは一枚もありません。規則どおりなら、タグのないかばんはその日のうちに廃棄場へ送られるはずです。

ド・ミラン班長が、これまでの記録を三枚、机に広げます。3限目のX線写真、6限目のメモ、7限目の partner の記録です。「今日はあの二つの正体を明かそう。その前に、廃棄の規則を正確に知っておかないとね」

11-1. タグを数える:参照カウント (9分)

CPythonのすべてのオブジェクトは、自分を指している参照がいくつあるかを数える数字を持っています。これが参照カウント(reference count)です。タグを付ければ1増え、外せば1減り、0になった瞬間にその場で廃棄されます。

import sys

a = []
print(sys.getrefcount(a))
# 2
b = a
print(sys.getrefcount(a))
# 3
c = [a, a]
print(sys.getrefcount(a))
# 5
del b, c
print(sys.getrefcount(a))
# 2
  • 最初の値が1ではなく2なのは、sys.getrefcount(a) を呼ぶときに引数として渡した参照まで数えるからです。
  • c = [a, a] はリストの中にタグを2枚差すので、2増えます。かばんの中に差したタグも、タグです。
  • del b, c で名前 b が外れ、リスト c が廃棄されると、その中のタグ2枚も一緒に消えます。

数字そのものに頼らないでください。3.14からは、インタープリターが参照の増減の一部を省く最適化をしています。同じコードを関数の中で動かすと、最初の値はCPython 3.14では1、3.13では2です。見るべきは「タグ1枚につき1」という差です。

0になった瞬間に廃棄されることは、__del__ で確かめられます。オブジェクトが廃棄される直前に呼ばれるダンダーメソッドです(信用してはいけない理由は11-4で)。

class Bag:
    def __init__(self, label):
        self.label = label
    def __del__(self):
        print("discard", self.label)

a = Bag("A")
b = a
del a
print("after del a")
del b
print("after del b")
# after del a
# discard A
# after del b

def check_in():
    tmp = Bag("T")
    print("inside")

check_in()
print("back")
# inside
# discard T
# back
  • del a のあとも、タグ b が残っているので廃棄されません。最後のタグを外す del b で、すぐに "discard A" が表示されます。
  • 関数が終わると呼び出し枠が消え、ローカル名 tmp も外れます。だから "back" より先に廃棄されます。
  • 6限目で班長が言っていたのはこのことです。終わった関数の持ち物でも、クロージャのセルがつかんでいれば参照カウントが0にならず、残り続けます。

下のGC観測台は、ワーカープロセスで本物のオブジェクトを作り、本物の gc を動かします。かばんごとに「参照」の数(名札+ほかのかばんの中に差してあるタグ)が見えます。どの名札から出発してかばんの中をたどっても届かないかばんは、赤い点線で描かれます。点線タグは、w() → … でいま何を指しているかを示します。

GC観測台

Python

tags では、タグを外すたびに参照の数が1ずつ減り、0になる操作で廃棄されます。chain の4番目の操作では、b に名札はありませんが a の中にあるのでまだ参照が1あり、a が廃棄されるときに一緒に廃棄されます。

ピンの案内

→ sys.getrefcount の数字は、バージョンや場所(モジュールか関数の中か)によって変わります。絶対値ではなく、タグを1枚付けたり外したりしたときに1ずつ動く「差」を見てください。

11-2. 互いを抱え込んだ二つのかばん:循環参照とGC (12分)

三枚の記録をつなぎ合わせると、正体が見えてきます。3限目のX線写真の [[...]] は、かばんが自分自身を抱え込んだ姿でした。7限目の記録では、二つのかばんが partner 属性で互いを指していました。二つをそのままコードにすると、こうなります。

タグボード

ステップ 1 / 0

次に実行する行: 0

実習台を準備中…

いま変わったもの

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

9行目のあと、二つのかばんが partner で互いを指します。10行目のあとボードから二つのかばんが消えるのは、ボードが名前から出発して届くかばんだけを描くからです。

del a, b で、名札はすべて外れました。ところがAの中にはBを指すタグが、Bの中にはAを指すタグが残っています。どちらも参照カウントが1なので、いつまでも0になりません。これが循環参照(reference cycle)です。

名前から出発して届かないオブジェクトは、プログラムがもう二度と使う手段のないゴミです。タグボードから消えたのも、そのためです。しかし参照カウントだけでは、このゴミを片付けられません。

そこでCPythonには、二人目の清掃係がいます。循環ガベージコレクター(gc モジュール)です。

  • リスト、dict、インスタンスのように、ほかのオブジェクトを入れられるオブジェクトを調べます。
  • その集まりの中でやり取りしている参照を除いて数えます。外から来る参照が一つもない集まりは、互いをつかみ合っているだけのゴミです。
  • そうした集まりを丸ごと廃棄します。gc.collect() はこの作業をいますぐ行わせ、片付けたオブジェクトの数を返します。
import gc
gc.disable()   # 例が揺れないよう、自動GCをいったん止める

class Bag:
    def __init__(self, label):
        self.label = label
        self.partner = None
    def __del__(self):
        print("discard", self.label)

a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
del a, b
print("tags gone")
print(gc.collect() > 0)
gc.enable()
# tags gone
# discard A
# discard B
# True
  • del a, b のあとでは何も廃棄されません。"discard" は gc.collect() の中でようやく表示されます。2行の順番は決まっていません。
  • ふだんは gc.collect() を呼ばなくてもかまいません。オブジェクトをたくさん作れば、インタープリターが自動で動かします。gc.disable() は例のためだけに使いました。
  • 自動GCがいつ、何段階で動くかはバージョンによって違い、同じ3.14の中でも変わったことがあります。特定のタイミングや数字に頼るコードは書かないでください。

3限目の、自分自身を抱え込んだリストも同じケースです。

import gc

a = []
a.append(a)
print(a, a[0] is a)
# [[...]] True
del a
print(gc.collect())
# 例:23

返ってくる数字は、それまでにたまったほかのゴミも一緒に数えるので、状況やバージョンによって違います。このPCで同じファイルを3.13.9で動かすと1、3.14.2で動かすと23になりました。下の観測台は、始めるときに一度掃除しておいてから数えるので、毎回同じ値になります。

GC観測台

Python

cycle では名札を全部外しても参照が1ずつ残り、gc.collect() がかばん2個とその中のリスト2個、合わせて4個を片付けます。break は先に循環を断つので gc なしで廃棄され(gc.collect() は0)、self は3限目の [[...]] です。

循環も最後には片付けられるのだから、問題ないのでしょうか? 二つの点が違います。

  • 参照カウントは0になったその行で廃棄しますが、循環GCはあとでまとめて片付けます。そのあいだ、メモリを占め続けます。
  • 循環の中のオブジェクトがファイルや接続を握っていると、それもGCが動くまで閉じられません。

循環をなくす方法は二つあります。使い終わったら片方のタグを自分で外す(break のように)か、最初から片方を弱参照にするかです。

11-3. 点線タグ:弱参照 (8分)

弱参照(weak reference)は、参照カウントに数えられない点線タグです。かばんを指しはしますが、つかんではいません。かばんが廃棄されると、点線タグはひとりでに空になります。

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

a = Bag("A")
w = weakref.ref(a)
print(w() is a)
# True
del a
print(w())
# None
  • w() のように呼んで、はじめてかばんが得られます。すでに廃棄されていれば None が返るので、使う前にいつも確かめます。
  • すべてのオブジェクトに点線タグを付けられるわけではありません。weakref.ref([1, 2]) は TypeError: cannot create weak reference to 'list' object です。int、str、tuple、dict も同じで、自作クラスのインスタンスなら付けられます。

GC観測台

Python

点線タグ w は参照の数に入りません。weak では最後の名札を外した瞬間に廃棄され、weak+cycle では点線タグがあっても、循環は gc.collect() が片付けます。

弱参照がいちばんよく使われるのはキャッシュです。普通の dict でキャッシュを作ると、入れておいたオブジェクトをキャッシュがいつまでもつかんでいるので、メモリが減りません。WeakValueDictionary は値を点線タグで持つので、ほかで使われなくなれば項目がひとりでに消えます。

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

desk = weakref.WeakValueDictionary()
c = Bag("C")
desk["C"] = c
print("C" in desk, len(desk))
# True 1
del c
print("C" in desk, len(desk))
# False 0

キーの側を点線で持つ WeakKeyDictionary、要素を点線で持つ WeakSet もあります。オブジェクトに別の情報を付けたいけれど、そのオブジェクトの寿命は延ばしたくない、というときに使います。

かばんが廃棄されるときにすることを登録しておきたいなら、weakref.finalize を使います。

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

d = Bag("D")
f = weakref.finalize(d, print, "discard D")
print(f.alive)
# True
del d
# discard D
print(f.alive)
# False

finalize が呼ばれるのは多くても一度で、プログラムが終わるまでかばんが生きていれば、終了時に呼ばれます。次の節の __del__ より頼りになる仕組みです。ただし、渡す関数や引数がそのかばんをつかんでしまうと(例:d.close を渡す)、かばんがいつまでも廃棄されないので注意しましょう。

11-4. __del__ を信用しない:後始末は with で (6分)

__del__ は後始末のコードを入れるのによさそうに見えますが、三つの理由で信用できません。

  1. いつ呼ばれるかわかりません。循環の中にあれば、GCが動くまで先送りされます。PyPyのように参照カウントを使わないほかのPython実装では、もっと遅くなることもあります。
  2. 中で起きた例外が外に出てきません。標準エラーに一度表示されて、無視されます。
  3. プログラムの終了時に生きているオブジェクトは、__del__ が呼ばれる保証がありません。
class Jam:
    def __del__(self):
        raise RuntimeError("belt jam")

x = Jam()
del x
print("still running")
# (標準エラー)例:Exception ignored while calling deallocator <function Jam.__del__ at 0x...>:
# (標準エラー)... RuntimeError: belt jam
# still running

ファイル、ソケット、ロックのように必ず閉じなければならないリソースは、with で閉じます。ブロックを出た瞬間、たとえ例外が起きても __exit__ が呼ばれます(8限目)。

class Belt:
    def __enter__(self):
        print("belt on")
        return self
    def __exit__(self, *exc):
        print("belt off")
        return False

with Belt() as belt:
    print("moving")
# belt on
# moving
# belt off

try:
    with Belt():
        raise ValueError("stuck")
except ValueError as e:
    print("caught", e)
# belt on
# belt off
# caught stuck

open() が返すファイルも、with open(path) as f: で使います。後始末のタイミングが、コードの目に見える場所に決まります。

ピンの案内

→ __del__ は「いつ」を約束してくれません。必ず閉じるべきものは with で、オブジェクトの寿命に結び付けたい後始末は weakref.finalize でやりましょう。

11-5. 不死のオブジェクト、かばんの重さ、そして3.14t (5分)

None、True、小さな整数のように、どこでも使われるオブジェクトは不死のオブジェクト(immortal object)です(3.12、PEP 683)。参照カウントが固定されていて、いくらタグを付けたり外したりしても廃棄されません。

import sys

print(sys.getrefcount(None))
# 3221225472
x = None
print(sys.getrefcount(None))
# 3221225472
print(sys._is_immortal(None), sys._is_immortal([]))
# True False
  • この数字は「不死」を表す目印の値なので、バージョンによって違います。CPython 3.14では3221225472、3.13では4294967295でした。
  • sys._is_immortal は3.14で加わりました。名前がアンダースコアで始まるので、CPythonの実装の細部です。

sys.getsizeof は、かばんそのものの重さだけを量ります。中身は数えません。

import sys

print(sys.getsizeof([]), sys.getsizeof([1, 2, 3]))
# 56 88
big = [0] * 1000
print(sys.getsizeof(big), sys.getsizeof([big]))
# 8056 64

CPython 64ビットでは、リストは空の本体56バイトに、タグを差す場所一つにつき8バイトが加わります。作り方によっては場所を多めに確保しておくこともあるので、[1, 2, 3] は80ではなく88です。[big] は場所が一つのかばんなので64バイトです。中に入っている big が8056バイトでも、数えません。

フリースレッド版(3.14t)について一言。GILのないビルドでは、複数のスレッドが同時に参照カウントを変えます。そこで、オブジェクトを作ったスレッドは自分の分をすばやく数え、ほかのスレッドは共有の分を別にアトミックに数えます(biased reference counting)。外から見える規則は同じです。この回の例を3.14.3t(sys._is_gil_enabled() が False)で動かしても、参照カウントの値、即時の廃棄、循環を gc.collect() が片付けることまで同じでした。違ったのは、gc.collect() が返した数字だけです。

事件解決 (10分)

二つのかばんは、互いの partner で互いをつかみ合う循環参照でした。タグはないのに参照カウントが1ずつ残って廃棄されず、循環GCが動くまでコンベヤーに残っていたのです。班長が gc.collect() を動かすと、13番コンベヤーは空になりました。

二度と生まれないようにするには、相方を点線タグで覚えさせます。10限目の property で包めば、使う側のコードはそのままです。

import weakref

class Bag:
    def __init__(self, label):
        self.label = label
        self._partner = None

    @property
    def partner(self):
        return self._partner() if self._partner is not None else None

    @partner.setter
    def partner(self, other):
        self._partner = weakref.ref(other)   # 相方は点線タグで覚える

a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
print(a.partner.label, b.partner.label)
# B A
watch = weakref.ref(a)
del a
print(watch() is None, b.partner)
# True None

del a の1行で、Aはすぐに廃棄され、Bの partner は None になります。GCを待つ循環が、最初から存在しません。

退勤前、ユン技師がもう1行記録を持ってきます。幽霊かばんBは先月、遺失物ロッカーに保管されたことがあるのに、そのとき誰も取り出せなかったというのです。班長は「それは明日、最後に見よう」と言って、ロッカーの鍵を机に置きます。

解決報告 — LOST-011。幽霊かばん二つは、partner で互いをつかみ合う循環参照だった。gc.collect() で片付け、相方を weakref.ref で覚えるように変えて再発を防止。

練習問題

  1. a = []、a.append(a) のあとの sys.getrefcount(a) を予想してみてください。モジュールで実行すると何が出て、なぜ2ではないのでしょう?
  2. weakref.WeakValueDictionary() に文字列を値として入れてみてください。どんなエラーになり、自作クラスのインスタンスで包むとどうなるでしょう?
  3. ファイルを開いたままにして __del__ で閉じるクラスを作り、それを __enter__・__exit__ で閉じるように変えてみてください。ブロックの中で例外が起きても閉じられるか確かめましょう。

班長の業務手帳

  • 参照カウント=かばんに付いたタグの数。0になったその行で、すぐに廃棄される。

  • sys.getrefcount は引数として渡した参照も数える。値はバージョンごとに違うので、差だけを見る。

  • 互いを抱え込んだかばん(循環参照)は0にならない。循環GCがあとで片付ける。gc.collect() は片付けた数を返す。

  • 自動GCのタイミングや世代の構成はバージョンごとに違う。そこに頼らない。

  • 弱参照は点線タグ。キャッシュには WeakValueDictionary、廃棄時の処理には weakref.finalize。

  • __del__ は信用しない。閉じるべきリソースは with で。

  • None のような不死のオブジェクトは廃棄されない。getsizeof はかばんの本体だけを量る。

← 前へ

10限目 · 検査場:属性アクセスの秘密

次へ →

12限目 · ロッカー番号:ハッシュと等価性、総まとめ

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

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

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

質問と回答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex