中学英語808712 views
いろは2986023 views
高校日本史189857 views
ヒストリア284143 views
教育148875 views
中学社会667106 views
高校物理158224 views
高校国語785655 views
数学講師2852771 views
MathPython491378 views
Help
Tools

English

__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
slots=True なし(デフォルト)

各インスタンスが dict を持つ。属性の動的追加が可能。メモリ消費が大きい。

slots=True あり

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=Trueslots=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"   # AttributeError

frozen で変更を禁止し、slots でメモリを節約する。さらに frozen によって __hash__ が自動生成されるため、辞書のキーや集合の要素としても使える。大量の座標データやレコードを扱う場面では、この組み合わせが定番パターンになる。

dataclass(slots=True) を指定したとき、インスタンスに起きる変化はどれか?

  • __init__ が生成されなくなる
  • フィールドの型チェックが有効になる
  • __dict__ が生成されずメモリ消費が減る
  • __repr__ が省略される
__RESULT__

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

slots を使うべき場面

slots は万能ではない。導入すべきかの判断は、インスタンスの数とプログラムの性質に依存する。

数個から数百個のインスタンスしか作らないなら、メモリの差は無視できる範囲であり、slots を指定する必要性は低い。一方で、数万以上のインスタンスを扱うデータ処理やシミュレーション、ゲームのエンティティ管理などでは、slots の効果が顕著に現れる。

また、動的に属性を追加する必要があるクラスには向かない。プラグインシステムやメタプログラミングで実行時に属性を生やすような設計とは根本的に相性が悪い。フィールドが設計時に確定しており、変更される見込みがないデータクラスにこそ slots は適している。