モダンPython上級 #2 ディスクリプタと __set_name__

読了 7分

#1 マジックメソッドの深さ__getattr__/__setattr__ が属性アクセスをフックする道具だと見ました。その上にもう一段 — 属性そのものをオブジェクト化する 道具がディスクリプタです。@property@classmethod@staticmethod、ORM の Column、dataclass のフィールドまで — すべてディスクリプタ の上に作られています。

ディスクリプタとは? #

__get____set____delete__ のいずれかが定義されたクラスのインスタンスがディスクリプタです。 クラスの属性として置けば、その属性にアクセスするときディスクリプタのメソッドが自動で呼ばれます。

最も単純なディスクリプタ
class Constant:
    def __init__(self, value):
        self.value = value

    def __get__(self, instance, owner):
        return self.value

class Config:
    PI = Constant(3.14)

c = Config()
print(c.PI)              # 3.14 — Constant.__get__() が呼ばれる
print(Config.PI)         # 3.14 — 同じメソッド、instance が None

c.PI という単純な属性アクセスが 実はメソッド呼び出し に展開されます。ここにどんなコードでも差し込めます。

__get__ のシグネチャ #

def __get__(self, instance, owner): ...
  • self — ディスクリプタのインスタンス
  • instance — 属性にアクセスしたオブジェクト。クラス自身を介してアクセスすると None
  • owner — その属性を持つクラス

データ vs 非データディスクリプタ #

ここがディスクリプタで一番ややこしい部分です。

種類定義優先順位
データディスクリプタ__set__ または __delete__ を持つインスタンス dict より 優先
非データディスクリプタ__get__ だけを持つインスタンス dict が 優先

実際の違いを見てみましょう。

データディスクリプタ
class DataDesc:
    def __get__(self, instance, owner):
        return "from descriptor"

    def __set__(self, instance, value):
        pass

class A:
    x = DataDesc()

a = A()
a.__dict__["x"] = "from instance dict"
print(a.x)   # 'from descriptor'  ← ディスクリプタが勝つ
非データディスクリプタ
class NonDataDesc:
    def __get__(self, instance, owner):
        return "from descriptor"

class B:
    x = NonDataDesc()

b = B()
b.__dict__["x"] = "from instance dict"
print(b.x)   # 'from instance dict'  ← インスタンス dict が勝つ

この優先順位がなぜ重要かというと、次のとおりです。

  • メソッドは非データディスクリプタ。インスタンスに同名の属性を作るとそちらが優先 (メソッドオーバーライド可能)
  • @property はデータディスクリプタ。ユーザーが同じ名前でインスタンス属性を作っても常に property が動作

@property は実はディスクリプタ #

property の正体
class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14 * self.radius ** 2

c = Circle(5)
print(c.area)   # 78.5 — area.__get__() が呼ばれる

@property デコレータは property ディスクリプタクラスのインスタンス を作ります。Circle.area は関数ではなく property オブジェクトです。自分で作ってみると、次のようになります。

property を自作
class my_property:
    def __init__(self, fget):
        self.fget = fget

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.fget(instance)

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @my_property
    def area(self):
        return 3.14 * self.radius ** 2

c = Circle(5)
print(c.area)   # 78.5

標準ライブラリの property は、ここに setter/deleter まで備えた形です。

ユーザー定義 — バリデーションディスクリプタ #

よく使われるパターンは 属性をバリデーションするディスクリプタ です。

🚫 最初の試み — 衝突
class PositiveInt:
    def __init__(self):
        self.value = 0    # ✗ すべてのインスタンスが同じディスクリプタを共有

    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("負数は不可")
        self.value = value

問題: PositiveInt() という 1 つのディスクリプタインスタンスがすべての使用箇所で 共有 されます。User のインスタンスを 2 つ作ると age が同じ値になります。

解決: 値を インスタンス dict に 保存します。

✅ インスタンスに保存
class PositiveInt:
    def __set_name__(self, owner, name):
        self.name = "_" + name    # バッキングフィールド名

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.name)

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError(f"{self.name[1:]} は負数不可")
        setattr(instance, self.name, value)

class User:
    age = PositiveInt()
    score = PositiveInt()

u = User()
u.age = 30        # OK
u.age = -1        # ✗ ValueError
u.score = 95      # 別のインスタンスに別の値を保存可能

__set_name__ — きれいな名前の自動付与 (3.6+) #

上のコードのカギが __set_name__ です。

def __set_name__(self, owner, name): ...

このメソッドは クラスが定義されるとき に自動で呼ばれます。

  • owner — ディスクリプタを持つクラス (User)
  • name — ディスクリプタが束ねられた属性名 (agescore)

このフックは 3.6 以前 にはなかったので、名前を直接引数として渡す必要がありました。

昔のやり方 — 新しいコードでは使わない
class User:
    age = PositiveInt("age")     # 名前の重複 — DRY 違反
    score = PositiveInt("score")

__set_name__ がその重複を解消してくれます。ユーザーは単に PositiveInt() と書けばよくなります。

より精緻なバリデーション — Generic で #

これを 複数の型のバリデーションディスクリプタ に一般化すると、次のようになります。

バリデーションディスクリプタのベース
class Validator:
    def __set_name__(self, owner, name):
        self.private_name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        self.validate(value)
        setattr(instance, self.private_name, value)

    def validate(self, value):
        raise NotImplementedError    # サブクラスが実装

class IntRange(Validator):
    def __init__(self, lo, hi):
        self.lo, self.hi = lo, hi

    def validate(self, value):
        if not isinstance(value, int):
            raise TypeError(f"int が必要、got {type(value).__name__}")
        if not (self.lo <= value <= self.hi):
            raise ValueError(f"{self.lo} ~ {self.hi} の範囲")

class StringLen(Validator):
    def __init__(self, min_len, max_len):
        self.min_len, self.max_len = min_len, max_len

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError("str が必要")
        if not (self.min_len <= len(value) <= self.max_len):
            raise ValueError("長さの範囲を超えた")

class User:
    age = IntRange(0, 150)
    name = StringLen(1, 50)

u = User()
u.age = 30          # OK
u.age = 999         # ValueError
u.name = ""         # ValueError

同じパターンで正規表現バリデーション、メール形式バリデーションなどをディスクリプタとして作れます。

ディスクリプタ vs @property — どちらをいつ? #

ケース適した道具
1 つのクラスに 1〜2 か所 のバリデーション@property (シンプル)
複数のクラス / 複数のフィールド で同じバリデーションパターンディスクリプタ (再利用)
単純な計算 (read-only)@property または @cached_property
ORM のカラム、フォームのフィールドのようにメタデータまで束ねるときディスクリプタ

日常のコードのほとんどは @property で解決できます。ディスクリプタを直接作る場面は ライブラリ/フレームワーク のコードです。

@cached_property — よく出会う一形態 #

標準ライブラリにすでにあります。

cached_property
from functools import cached_property

class Document:
    def __init__(self, text):
        self.text = text

    @cached_property
    def word_count(self):
        print("計算中...")
        return len(self.text.split())

d = Document("a b c d")
print(d.word_count)   # 計算中... 4
print(d.word_count)   # 4 (キャッシュ — 計算しない)

最初の呼び出しのときだけ計算し、結果をインスタンス dict に保存。以降は通常の属性のように動作します。@property は毎回計算する点が違います。

cached_property非データディスクリプタ です。だから最初の計算後、インスタンス dict に保存された値が優先され、ディスクリプタが再び呼ばれません。

__get__ の呼び出しフロー — 全体像 #

obj.attr 一行が実行されるときに Python が探す順番。

  1. データディスクリプタ (クラスにあって、__set__ または __delete__ を持つ) → 呼び出し
  2. インスタンス dict → そのまま返す
  3. 非データディスクリプタ / クラス属性 → メソッドなら呼び出し、通常なら返す
  4. __getattr__ → フォールバック
  5. AttributeError → 全部失敗

この優先順位が、上で見たデータ vs 非データの違いの正体です。

__delete__ — 削除フック #

del をフック
class Locked:
    def __get__(self, instance, owner):
        return getattr(instance, "_value", None)

    def __set__(self, instance, value):
        instance._value = value

    def __delete__(self, instance):
        raise AttributeError("削除禁止")

class Box:
    x = Locked()

b = Box()
b.x = 5
del b.x   # AttributeError

頻繁に使うわけではありませんが、リソースの後片付け・ロールバックの場面で必要なケースがあります。

slot とディスクリプタの関係 #

中級 #1__slots__ は実は 各スロットをディスクリプタとして 作ります。

slot もディスクリプタ
class Point:
    __slots__ = ("x", "y")

print(Point.x)   # <member 'x' of 'Point' objects>
print(type(Point.x).__set__ is not None)   # True (データディスクリプタ)

だから __slots__ のオブジェクトは __dict__ がなくても属性アクセス/代入が動作するわけです。

ライブラリでのディスクリプタの居場所 — 一覧表 #

ライブラリ/ツールディスクリプタの居場所
ビルトインpropertyclassmethodstaticmethod
functoolscached_property
dataclassesfield() (比較/生成のメタデータの一部)
ORM (SQLAlchemyDjango ORM)ColumnForeignKey、モデルフィールド
フォーム/バリデーション (WTFormsDjango forms)フィールドクラス群
attrsPydanticフィールド定義

ライブラリのコードを読んでいて「なぜこのクラス変数が魔法のように動くのか」と思ったら、ほぼ常にディスクリプタです。

まとめ #

今回見たこと。

  • ディスクリプタ = __get__ / __set__ / __delete__ のいずれかが定義されたクラスのインスタンス
  • クラス属性として置けば、属性アクセスがメソッド呼び出しに展開される
  • データディスクリプタ (__set__ を持つ) > インスタンス dict > 非データディスクリプタ
  • @property、メソッド、cached_property がすべてディスクリプタの一形態
  • バリデーションディスクリプタ: インスタンス dict にバッキングフィールドとして保存
  • __set_name__ (3.6+) で名前を自動取得 — DRY が解消
  • ディスクリプタはライブラリ/フレームワークの領域、日常のコードは @property で十分
  • __slots__ もディスクリプタとして動作

次回 (#3 メタクラス) では、もう一段上 — クラスを作るクラス、メタクラスの居場所を扱います。メタクラス vs __init_subclass__ vs ディスクリプタ vs デコレータの分担も合わせて。

X