Pythonのクラスは柔軟で扱いやすい反面、インスタンスごとのメモリ消費が大きくなりがちです。
特に大量のインスタンスを扱う場面では無視できないコストになります。
本記事では、クラス定義に__slots__
を導入してメモリ使用量を抑える方法を、仕組みから計測方法、継承時の注意点まで丁寧に解説します。
__slots__とは何か(メモリ使用量を削減する理由)
インスタンスがメモリを消費する仕組み(__dict__)
通常のPythonクラスのインスタンスは、属性を動的に増減できるように__dict__
(属性名から値へのマップ)を持ちます。
CPythonでは辞書実装の最適化(スプリットテーブル)により同一クラスの辞書キーは共有されますが、それでも各インスタンスごとに値配列や辞書管理のためのオーバーヘッドが存在します。
属性が少なくても、「動的に属性を追加できるための仕組み」自体がメモリを消費している点がボトルネックになります。
簡単な観察
次の例では、通常クラスのインスタンスが__dict__
を持つことを確認します。
# 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__を定義した最小例
最小例では、x
とy
の2属性のみを許可する座標クラスを作ります。
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__
内の代入で表現します。
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__
を使うと、許可していない属性名を後から追加できません。
これはメモリ節約の代償です。
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レベルのメモリ割り当て量を測れます。
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__ = ()
を明示して辞書の生成を防ぐのがポイントです。
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__
を使いつつ辞書や弱参照を許可したい場合があります。
その際は次のようにスロット名として明示します。
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
等で実測することが肝要です。
属性が固定されたデータ指向クラスで、大量にインスタンス化される場合に絞って導入するのが、扱いやすさと効果の両立につながります。