閉じる

__slots__でクラスのメモリ使用量を節約する方法(Python)

Pythonのクラスは柔軟で扱いやすい反面、インスタンスごとのメモリ消費が大きくなりがちです。

特に大量のインスタンスを扱う場面では無視できないコストになります。

本記事では、クラス定義に__slots__を導入してメモリ使用量を抑える方法を、仕組みから計測方法、継承時の注意点まで丁寧に解説します。

__slots__とは何か(メモリ使用量を削減する理由)

インスタンスがメモリを消費する仕組み(__dict__)

通常のPythonクラスのインスタンスは、属性を動的に増減できるように__dict__(属性名から値へのマップ)を持ちます。

CPythonでは辞書実装の最適化(スプリットテーブル)により同一クラスの辞書キーは共有されますが、それでも各インスタンスごとに値配列や辞書管理のためのオーバーヘッドが存在します。

属性が少なくても、「動的に属性を追加できるための仕組み」自体がメモリを消費している点がボトルネックになります。

簡単な観察

次の例では、通常クラスのインスタンスが__dict__を持つことを確認します。

Python
# Python 3.8+ を想定

class Point:
    # __slots__ を使わない通常クラス
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.__dict__)  # 属性テーブル(辞書)が存在する
実行結果
{'x': 1, 'y': 2}

__slots__で属性テーブルを固定して節約

__slots__は、インスタンスが持てる属性名を事前に固定し、内部的に配列のようなスロットに直接格納する仕組みです。

これによりインスタンスごとの__dict__を作らずに済むため、メモリを大幅に節約できます。

副次的に属性アクセスもやや高速になることがあります。

注意: __slots__を定義すると、デフォルトでは__dict____weakref__も作られません。

必要な場合は__slots__にこれらの名前を含める必要があります。

__slots__を使うべきケースの見極め

大量のインスタンスを同時に保持する属性構成が固定動的に属性を増やす必要がない、といったデータ指向のクラスに適しています。

一方で、プラグインで属性を拡張したい、デバッグ用途で後付け属性を付けたい、複雑な継承階層を持つ、といったケースでは扱いが難しくなるため慎重に検討します。

特に継承の節で述べる通り、__slots__は子クラス側の設計にも影響します。

__slots__の基本的な使い方(Pythonクラス)

__slots__を定義した最小例

最小例では、xyの2属性のみを許可する座標クラスを作ります。

Python
class SlottedPoint:
    # インスタンスが持てる属性名を固定
    __slots__ = ('x', 'y')

    def __init__(self, x, y):
        # 各属性はスロットに直接格納される
        self.x = x
        self.y = y

sp = SlottedPoint(1, 2)

# __dict__ は存在しない(動的属性追加をしないための証)
print(hasattr(sp, '__dict__'))
# 許可された属性へのアクセスは通常通り
print(sp.x, sp.y)
実行結果
False
1 2

デフォルト値と__init__の書き方

__slots__では、インスタンス属性のデフォルト値は__init__内で設定します。

クラス変数でデフォルトを持たせるのではなく、コンストラクタ引数のデフォルトや__init__内の代入で表現します。

Python
class User:
    __slots__ = ('name', 'age', 'active')

    def __init__(self, name='unknown', age=0, active=True):
        # ここでインスタンス属性の初期値を設定する
        self.name = name
        self.age = age
        self.active = active

u1 = User()                 # すべてデフォルト
u2 = User('Alice', 30)      # 一部だけ指定

print(u1.name, u1.age, u1.active)
print(u2.name, u2.age, u2.active)
実行結果
unknown 0 True
Alice 30 True

動的な属性追加ができなくなる点

__slots__を使うと、許可していない属性名を後から追加できません。

これはメモリ節約の代償です。

Python
class Account:
    __slots__ = ('id', 'balance')

    def __init__(self, id, balance=0):
        self.id = id
        self.balance = balance

acc = Account('A-001', 100)

# 許可されていない属性を追加しようとするとエラー
try:
    acc.owner = 'Bob'  # owner は __slots__ にない
except AttributeError as e:
    print('AttributeError:', e)
実行結果
AttributeError: 'Account' object has no attribute 'owner'

メモリ削減の効果を計測する方法

sys.getsizeofの落とし穴とtracemallocの活用

sys.getsizeofは「そのオブジェクト自体のサイズ」しか返しません。

インスタンスが内部で参照する辞書やリストの容量までは含みません。

そのため、インスタンス全体のフットプリントを把握するには不十分です。

tracemallocを使うと、ある区間で増えたPythonレベルのメモリ割り当て量を測れます。

Python
import sys
import tracemalloc

# 通常クラス
class Normal:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

# __slots__ クラス
class Slotted:
    __slots__ = ('a', 'b', 'c')
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

def measure_allocation(make_objects, n=100_000):
    # n 個のインスタンスを作って増分メモリを測る
    tracemalloc.start()
    snap1 = tracemalloc.take_snapshot()
    objs = make_objects(n)
    snap2 = tracemalloc.take_snapshot()
    stats = snap2.compare_to(snap1, 'lineno')
    total = sum(s.size_diff for s in stats)  # 増分合計(bytes)
    # 生存させ続けないと解放されてしまうので戻り値で保持
    return total, objs

def make_normal(n):
    return [Normal(i, i+1, i+2) for i in range(n)]

def make_slotted(n):
    return [Slotted(i, i+1, i+2) for i in range(n)]

# 参考: getsizeof は"そのもの"のサイズのみ
tmp_n = Normal(1,2,3)
tmp_s = Slotted(1,2,3)
print('getsizeof(Normal instance) =', sys.getsizeof(tmp_n))
print('getsizeof(Slotted instance) =', sys.getsizeof(tmp_s))
print('has __dict__ ?', hasattr(tmp_n, '__dict__'), hasattr(tmp_s, '__dict__'))

# 大量生成時の増分メモリ
normal_bytes, normals = measure_allocation(make_normal, n=100_000)
slotted_bytes, slotteds = measure_allocation(make_slotted, n=100_000)

print('Normal total bytes:', normal_bytes)
print('Slotted total bytes:', slotted_bytes)
print('Per-instance diff (approx):', (normal_bytes - slotted_bytes) // 100_000, 'bytes')

実行例(筆者環境の一例: CPython 3.11, macOS, 最適化やアロケータにより数値は変動します)。

getsizeof(Normal instance) = 48
getsizeof(Slotted instance) = 48
has __dict__ ? True False
Normal total bytes: 28540992
Slotted total bytes: 10813440
Per-instance diff (approx): 177 bytes

getsizeofの値は同じでも、実際の割り当て量は__dict__の有無で大きく変わることが分かります。

測定にはtracemallocや外部ライブラリ(Pymplerなど)の併用が有効です。

インスタンス数と節約効果の目安

属性数やPythonのバージョンによって差はありますが、1インスタンスあたり数十〜数百バイトの削減が見込めます。

大量生成時に効いてきます。

目安(例: 3属性、CPython 3.11 の一例):

インスタンス数通常クラス増分__slots__増分1個あたり削減(概算)合計削減
1,000約0.29 MiB約0.10 MiB約180 B約0.19 MiB
10,000約2.9 MiB約1.0 MiB約190 B約1.9 MiB
100,000約27.2〜29 MiB約9〜11 MiB約170〜200 B約18〜20 MiB

常に正確な数字を出すことより、目的の規模で実測することが大切です。

実サービスではプロファイラと併せて確認してください。

継承と__slots__の注意点

親クラスと子クラスでの__slots__の合成

親が__slots__を定義していても、子クラスが__slots__を定義しない場合は__dict__が作られてしまい、節約効果が失われます

子クラスに新しい属性を追加しない場合でも、__slots__ = ()を明示して辞書の生成を防ぐのがポイントです。

Python
class Base:
    __slots__ = ('x',)

    def __init__(self, x):
        self.x = x

# 子が __slots__ を定義しない → __dict__ が復活
class ChildLoosen(Base):
    pass

# 子が属性追加しない場合の推奨形
class ChildTight(Base):
    __slots__ = ()  # 新規スロット無し。__dict__ も作らない

# 子が属性を追加したい場合
class ChildAdd(Base):
    __slots__ = ('y',)  # 親の 'x' に加えて 'y' を追加

a = ChildLoosen(1)
b = ChildTight(1)
c = ChildAdd(1)
c.y = 10

print('Loosen has __dict__:', hasattr(a, '__dict__'))
print('Tight has __dict__:', hasattr(b, '__dict__'))
print('Add has __dict__  :', hasattr(c, '__dict__'))
print('c.x, c.y =', c.x, c.y)
実行結果
Loosen has __dict__: True
Tight has __dict__: False
Add has __dict__  : False
c.x, c.y = 1 10

__dict__や__weakref__を併用したい場合

一部の用途では、__slots__を使いつつ辞書や弱参照を許可したい場合があります。

その際は次のようにスロット名として明示します。

Python
import weakref

class Node:
    __slots__ = ('value', '__weakref__')  # 弱参照を許可
    def __init__(self, value):
        self.value = value

n = Node(10)
r = weakref.ref(n)   # 弱参照が作れる
print(r() is n)

class Extensible:
    __slots__ = ('a', '__dict__')  # 動的属性を許可(→メモリ節約は減る)
    def __init__(self, a):
        self.a = a

e = Extensible(1)
e.b = 2  # __dict__ があるので追加できる
print(e.a, e.b)
実行結果
True
1 2
注意

__dict__を許可すると、メモリ節約効果は大きく損なわれます。

必要性を慎重に検討してください。

既存クラスやミックスインとの相性と制約

  • 複数継承では、いずれかの親が__dict__を持っていると子にも辞書が現れるケースがあります。全親クラスの設計を揃えるのが理想です。
  • 親子で同じスロット名を再定義することはできません。名前の衝突に注意します。
  • C実装の組み込み型を継承する場合でも__slots__は定義できますが、型ごとの制約により期待通りに振る舞わない場合があります。試験実装で挙動を確認しましょう。
  • ピックルとの相性は概ね良好ですが、カスタムのシリアライズを行う場合は__getstate__/__setstate__の実装方針を確認してください。

継承を多用する設計では、__slots__の導入は初期段階で方針を固めておくと後戻りが減ります。

まとめ

__slots__は、インスタンスが保持できる属性名を固定し、__dict__の生成を抑止することでメモリ使用量を削減する仕組みです。

特に大量の小さなオブジェクトを扱う場面で効果が高く、1インスタンスあたり数十〜数百バイトの削減が期待できます。

一方で、動的属性の追加ができなくなる、継承設計に影響するなどのトレードオフがあります。

実際の効果は環境と規模に依存するため、対象クラスを特定してtracemalloc等で実測することが肝要です。

属性が固定されたデータ指向クラスで、大量にインスタンス化される場合に絞って導入するのが、扱いやすさと効果の両立につながります。

この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!