__slots__ と dataclass - メモリ効率の改善|Python
Python のオブジェクトは通常、属性を __dict__ という辞書で管理している。柔軟だが、インスタンスごとに辞書を持つためメモリ消費が大きい。数万・数十万のインスタンスを扱う場面では、この辞書のオーバーヘッドが無視できなくなる。__slots__ はこの問題を解決する仕組みで、Python 3.10 以降は dataclass でも簡単に使えるようになった。
slots とは何か
通常のクラスでは、インスタンスの属性は __dict__ に格納される。
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1.0, 2.0)
print(p.__dict__) # {'x': 1.0, 'y': 2.0}__dict__ は Python の辞書オブジェクトそのものだ。辞書はハッシュテーブルを内部に持つため、キーと値のペアだけでなくテーブル構造自体のメモリも消費する。
__slots__ を定義すると、辞書の代わりに固定長の配列的な構造で属性を管理するようになる。
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1.0, 2.0)
print(p.x) # 1.0
print(hasattr(p, "__dict__")) # False__dict__ が存在しないため、定義されていない属性を後から追加することもできない。p.z = 3.0 とすると AttributeError になる。
dataclass での slots 指定
Python 3.10 で @dataclass(slots=True) が導入された。これにより、手動で __slots__ を書く必要がなくなった。
from dataclasses import dataclass
@dataclass(slots=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
print(p.x) # 1.0
print(hasattr(p, "__dict__")) # False各インスタンスが dict を持つ。属性の動的追加が可能。メモリ消費が大きい。
dict を持たず固定の属性スロットを使う。属性の動的追加は不可。メモリ消費が小さい。
デコレータのオプション一つで切り替えられるため、既存の dataclass に後から追加するのも容易だ。
メモリ消費を実測する
実際にどれだけ差が出るのか、sys.getsizeof とオブジェクトの __dict__ のサイズを合わせて計測してみる。
import sys
from dataclasses import dataclass
@dataclass
class NormalPoint:
x: float
y: float
@dataclass(slots=True)
class SlotPoint:
x: float
y: float
n = NormalPoint(1.0, 2.0)
s = SlotPoint(1.0, 2.0)
normal_size = sys.getsizeof(n) + sys.getsizeof(n.__dict__)
slot_size = sys.getsizeof(s)
print(f"通常: {normal_size} bytes") # 通常: 152 bytes 程度
print(f"slots: {slot_size} bytes") # slots: 56 bytes 程度インスタンスひとつで約 100 バイトの差がある。1 つなら誤差だが、100 万インスタンスを作ると約 100 MB の差になる。大量のデータオブジェクトを扱うプログラムでは、この差は十分に実用的な意味を持つ。
sys.getsizeof はオブジェクト自体の浅いサイズだけを返す。通常の dataclass では <span class="rollpie-article-note__mark">dict</span> が別オブジェクトとして存在するため、両方のサイズを足さないと正確な比較にならない。逆に slots 版は <span class="rollpie-article-note__mark">dict</span> がないため、getsizeof の返り値がほぼ実際の消費量になる。
ネストしたオブジェクトの参照先までは含まないため、深い計測には pympler などの外部ライブラリを使う。
大量インスタンスでの比較
より現実的なシナリオとして、100 万個のインスタンスを生成してメモリ消費を比較する。
import tracemalloc
from dataclasses import dataclass
@dataclass
class NormalUser:
name: str
age: int
score: float
@dataclass(slots=True)
class SlotUser:
name: str
age: int
score: float
# 通常版
tracemalloc.start()
normal_list = [NormalUser("user", i, i * 0.1) for i in range(1_000_000)]
normal_mem = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
# slots版
tracemalloc.start()
slot_list = [SlotUser("user", i, i * 0.1) for i in range(1_000_000)]
slot_mem = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(f"通常: {normal_mem / 1024 / 1024:.1f} MB")
print(f"slots: {slot_mem / 1024 / 1024:.1f} MB")環境によって数値は変わるが、slots 版は通常版の 40〜60% 程度のメモリで済むことが多い。フィールド数が少ないほど __dict__ のオーバーヘッドの割合が大きくなるため、軽量なデータクラスほど slots の恩恵が目立つ。
属性アクセスの速度
メモリだけでなく、属性アクセスの速度にもわずかな差がある。__dict__ を経由するハッシュテーブルの探索が不要になるためだ。
import timeit
from dataclasses import dataclass
@dataclass
class Normal:
x: float
y: float
@dataclass(slots=True)
class Slotted:
x: float
y: float
n = Normal(1.0, 2.0)
s = Slotted(1.0, 2.0)
t_normal = timeit.timeit(lambda: n.x, number=10_000_000)
t_slot = timeit.timeit(lambda: s.x, number=10_000_000)
print(f"通常: {t_normal:.3f}s")
print(f"slots: {t_slot:.3f}s")差はわずかだが、タイトなループ内で属性に繰り返しアクセスする処理では累積的に効いてくる。ただし、属性アクセスの高速化を主目的として slots を導入するケースは少なく、あくまでメモリ効率が主な動機になることが多い。
slots の制約
slots にはいくつかの制約がある。これを知らずに使うと予期しないエラーに遭遇する。
dict がないため、定義されていない属性を後から追加すると AttributeError になる。デバッグ時に一時的な属性をつけるような使い方ができない。
slots を使ったクラス同士の多重継承では、スロットの衝突に注意が必要。同名のスロットを持つクラスを多重継承すると問題が起きる場合がある。
通常の dataclass と同様に field(default_factory=…) を使う必要がある。slots 固有の問題ではないが、組み合わせ時に忘れやすい点だ。
frozen と slots の組み合わせ
frozen=True と slots=True は併用できる。イミュータブルかつメモリ効率のよいデータクラスが作れるため、設定値や定数の表現に最適な組み合わせだ。
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Coordinate:
latitude: float
longitude: float
c = Coordinate(35.6762, 139.6503)
print(c) # Coordinate(latitude=35.6762, longitude=139.6503)
# c.latitude = 0.0 # FrozenInstanceError
# c.memo = "Tokyo" # AttributeErrorfrozen で変更を禁止し、slots でメモリを節約する。さらに frozen によって __hash__ が自動生成されるため、辞書のキーや集合の要素としても使える。大量の座標データやレコードを扱う場面では、この組み合わせが定番パターンになる。
dataclass(slots=True) を指定したとき、インスタンスに起きる変化はどれか?
- __init__ が生成されなくなる
- フィールドの型チェックが有効になる
- __dict__ が生成されずメモリ消費が減る
- __repr__ が省略される
slots を使うべき場面
slots は万能ではない。導入すべきかの判断は、インスタンスの数とプログラムの性質に依存する。
数個から数百個のインスタンスしか作らないなら、メモリの差は無視できる範囲であり、slots を指定する必要性は低い。一方で、数万以上のインスタンスを扱うデータ処理やシミュレーション、ゲームのエンティティ管理などでは、slots の効果が顕著に現れる。
また、動的に属性を追加する必要があるクラスには向かない。プラグインシステムやメタプログラミングで実行時に属性を生やすような設計とは根本的に相性が悪い。フィールドが設計時に確定しており、変更される見込みがないデータクラスにこそ slots は適している。












slots=True を指定すると dict の代わりに固定スロットで属性を管理するため、インスタンスあたりのメモリ消費が大幅に減少します。