モダンPython上級 #2 ディスクリプタと __set_name__
#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 が Nonec.PI という単純な属性アクセスが 実はメソッド呼び出し に展開されます。ここにどんなコードでも差し込めます。
__get__ のシグネチャ
#
def __get__(self, instance, owner): ...self— ディスクリプタのインスタンスinstance— 属性にアクセスしたオブジェクト。クラス自身を介してアクセスするとNoneowner— その属性を持つクラス
データ 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 は実はディスクリプタ
#
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 オブジェクトです。自分で作ってみると、次のようになります。
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— ディスクリプタが束ねられた属性名 (age、score)
このフックは 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 — よく出会う一形態
#
標準ライブラリにすでにあります。
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 が探す順番。
- データディスクリプタ (クラスにあって、
__set__または__delete__を持つ) → 呼び出し - インスタンス dict → そのまま返す
- 非データディスクリプタ / クラス属性 → メソッドなら呼び出し、通常なら返す
__getattr__→ フォールバックAttributeError→ 全部失敗
この優先順位が、上で見たデータ vs 非データの違いの正体です。
__delete__ — 削除フック
#
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__ は実は 各スロットをディスクリプタとして 作ります。
class Point:
__slots__ = ("x", "y")
print(Point.x) # <member 'x' of 'Point' objects>
print(type(Point.x).__set__ is not None) # True (データディスクリプタ)だから __slots__ のオブジェクトは __dict__ がなくても属性アクセス/代入が動作するわけです。
ライブラリでのディスクリプタの居場所 — 一覧表 #
| ライブラリ/ツール | ディスクリプタの居場所 |
|---|---|
| ビルトイン | property、classmethod、staticmethod |
functools | cached_property |
dataclasses | field() (比較/生成のメタデータの一部) |
ORM (SQLAlchemy、Django ORM) | Column、ForeignKey、モデルフィールド |
フォーム/バリデーション (WTForms、Django forms) | フィールドクラス群 |
attrs、Pydantic | フィールド定義 |
ライブラリのコードを読んでいて「なぜこのクラス変数が魔法のように動くのか」と思ったら、ほぼ常にディスクリプタです。
まとめ #
今回見たこと。
- ディスクリプタ =
__get__/__set__/__delete__のいずれかが定義されたクラスのインスタンス - クラス属性として置けば、属性アクセスがメソッド呼び出しに展開される
- データディスクリプタ (
__set__を持つ) > インスタンス dict > 非データディスクリプタ @property、メソッド、cached_propertyがすべてディスクリプタの一形態- バリデーションディスクリプタ: インスタンス dict にバッキングフィールドとして保存
__set_name__(3.6+) で名前を自動取得 — DRY が解消- ディスクリプタはライブラリ/フレームワークの領域、日常のコードは
@propertyで十分 __slots__もディスクリプタとして動作
次回 (#3 メタクラス) では、もう一段上 — クラスを作るクラス、メタクラスの居場所を扱います。メタクラス vs __init_subclass__ vs ディスクリプタ vs デコレータの分担も合わせて。