دیسکریپتورها در پایتون: پروتکلی که پشت property و متدهاست
property، متدهای bound، cached_property و __slots__ همه روی یک پروتکل کوچک کار میکنند. یاد بگیرید دیسکریپتورها چطور کار میکنند، ترتیب دقیق جستوجوی attributeها چیست و چطور دیسکریپتور خودتان را بنویسید.
در این صفحه
تا حالا فکر کردهاید @property چطور یک متد را به چیزی تبدیل میکند که شبیه یک attributeِ ساده است؟ یا وقتی obj.method() را صدا میزنید، self دقیقاً از کجا میآید؟ یا فیلدهای مدل در Django و ستونهای SQLAlchemy از کجا میفهمند متعلق به کدام شیء هستند؟
همهٔ اینها روی یک سازوکار کوچک بنا شدهاند: پروتکل دیسکریپتور (descriptor protocol). این یکی از معدود بخشهای واقعاً «جادویی» پایتون است و وقتی آن را بفهمید، هم میتوانید سازوکار درونی زبان را بخوانید و هم منطق attributeها را تمیزتر و قابلاستفادهٔ مجدد بنویسید.
اگر دکوریتورها هنوز برایتان تازهاند، اول دکوریتورها در پایتون، قدمبهقدم را بخوانید؛ در ادامه از آنها استفاده میکنیم.
دیسکریپتور چیست؟#
دیسکریپتور هر شیئی است که کلاسش دستکم یکی از این متدها را تعریف کرده باشد:
__get__(self, obj, objtype=None) # called when the attribute is read
__set__(self, obj, value) # called when it is assigned
__delete__(self, obj) # called on `del obj.attr`نکتهٔ مهم: این فقط وقتی کار میکند که دیسکریپتور بهعنوان attributeِ کلاس ذخیره شده باشد. وقتی پایتون دنبال obj.attr میگردد و روی کلاس به یک دیسکریپتور میرسد، بهجای برگرداندن خودِ دیسکریپتور، متد آن را صدا میزند.
اولین دیسکریپتور شما: یک attributeِ اعتبارسنجیشده#
فرض کنید هر Product باید price و quantity مثبت داشته باشد. با property باید همان getter و setter را دو بار بنویسید. با دیسکریپتور، قانون را یک بار مینویسید و دوباره از آن استفاده میکنید:
class Positive:
"""A number that must stay above zero."""
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = "_" + name
def __get__(self, obj, objtype=None):
if obj is None: # accessed on the class: Product.price
return self
return getattr(obj, self.private_name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.public_name} must be positive, got {value!r}")
setattr(obj, self.private_name, value)
class Product:
price = Positive()
quantity = Positive()
def __init__(self, name, price, quantity):
self.name = name
self.price = price # goes through Positive.__set__
self.quantity = quantity>>> p = Product("Keyboard", 49.0, 3)
>>> p.price
49.0
>>> vars(p)
{'name': 'Keyboard', '_price': 49.0, '_quantity': 3}
>>> p.quantity = -1
ValueError: quantity must be positive, got -1سه جزئیات ارزش دقت دارد:
__set_name__(از پایتون ۳٫۶ به بعد) فقط یک بار و هنگام ساخته شدن کلاس صدا زده میشود و نام attributeی را که دیسکریپتور به آن نسبت داده شده میگیرد. همین است که یک کلاسPositiveمیفهمد ازpriceمحافظت میکند یا ازquantity.obj is Noneیعنی attribute از روی خودِ کلاس خوانده شده (Product.price)، نه از یک نمونه. قرارداد این است که در این حالت خودِ دیسکریپتور برگردانده شود؛ این کار باعث میشود ابزارهایی مثلhelp()درست کار کنند.- مقدارهای واقعی روی نمونه و با نامهای
_priceو_quantityذخیره میشوند. دیسکریپتور فقط تنظیمات را نگه میدارد.
ترتیب جستوجوی attributeها#
برای استفادهٔ مطمئن از دیسکریپتورها باید قوانین دقیقی را بدانید که object.__getattribute__ برای obj.attr دنبال میکند. دیسکریپتورها دو نوعاند:
- دیسکریپتور داده (data descriptor) متد
__set__یا__delete__را تعریف میکند (Positiveوpropertyدیسکریپتور دادهاند). - دیسکریپتور غیرداده (non-data descriptor) فقط
__get__را تعریف میکند (توابع معمولی دیسکریپتور غیردادهاند).
این تفاوت مهم است، چون دیکشنری نمونه بین این دو قرار میگیرد. میتوانید خودتان ثابتش کنید:
class DataDesc:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
raise AttributeError("read-only")
class NonDataDesc:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class Demo:
a = DataDesc()
b = NonDataDesc()
d = Demo()
d.__dict__["a"] = "instance dict" # bypass __set__ and write directly
d.__dict__["b"] = "instance dict"
print(d.a) # data descriptor ← beats the instance dict
print(d.b) # instance dict ← beats the non-data descriptorهمین یک قانون بخش بزرگی از رفتار پایتون را توضیح میدهد؛ در بخشهای بعد میبینید چطور.
متدها دیسکریپتور هستند#
توابع یک متد __get__ دارند. وقتی یک تابع را از طریق یک نمونه میخوانید، __get__ یک متد bound برمیگرداند؛ شیء کوچکی که هم تابع و هم نمونه را به خاطر دارد. self از همینجا میآید:
>>> class Greeter:
... def hello(self):
... return "hi"
...
>>> g = Greeter()
>>> f = Greeter.__dict__["hello"] # the raw function, no descriptor magic
>>> f
<function Greeter.hello at 0x7fc890f70160>
>>> f.__get__(g, Greeter) # exactly what g.hello does
<bound method Greeter.hello of <__main__.Greeter object at 0x7fc890f63760>>
>>> g.hello == f.__get__(g, Greeter)
Trueچون توابع دیسکریپتور غیرداده هستند، attributeی با همان نام روی نمونه برنده میشود. به همین دلیل میتوانید یک متد را فقط برای یک شیء عوض کنید (که برای mock کردن در تستها خیلی به کار میآید):
>>> g.hello = lambda: "patched"
>>> g.hello(), Greeter().hello()
('patched', 'hi')classmethod و staticmethod هم دیسکریپتور غیردادهاند: __get__ آنها بهجای نمونه، کلاس را bind میکند یا اصلاً چیزی را bind نمیکند.
ساختن property از صفر#
property یک دیسکریپتور داده است که به زبان C نوشته شده، اما یک نسخهٔ کارآمد از آن به پایتون خالص در یک صفحه جا میشود:
class MyProperty:
def __init__(self, fget, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("read-only attribute")
self.fset(obj, value)
def setter(self, fset):
return type(self)(self.fget, fset) # a new descriptor with both functions
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@MyProperty
def fahrenheit(self):
return self.celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5 / 9>>> t = Temperature(100)
>>> t.fahrenheit
212.0
>>> t.fahrenheit = 32
>>> t.celsius
0.0@MyProperty همان fahrenheit = MyProperty(fahrenheit) است. بعد @fahrenheit.setter یک دیسکریپتور دوم میسازد که هر دو تابع را نگه میدارد و نام را به آن نسبت میدهد. چون MyProperty همیشه __set__ را تعریف میکند، حتی بدون setter هم دیسکریپتور داده است؛ به همین دلیل یک property فقطخواندنی را نمیتوان با مقداردهی روی نمونه پوشاند.
cached_property: استفادهٔ آگاهانه از ترتیب جستوجو#
functools.cached_property استفادهٔ هوشمندانهای از همان قانون قبلی است. این یک دیسکریپتور غیرداده است: در اولین دسترسی، متد شما را اجرا میکند و نتیجه را با همان نام در __dict__ نمونه ذخیره میکند. از آن به بعد دیکشنری نمونه در جستوجو برنده میشود و __get__ دیگر هرگز صدا زده نمیشود:
>>> from functools import cached_property
>>> class Report:
... def __init__(self, rows):
... self.rows = rows
...
... @cached_property
... def total(self):
... print("computing…")
... return sum(self.rows)
...
>>> r = Report([3, 4, 5])
>>> r.total
computing…
12
>>> r.total # served straight from r.__dict__
12
>>> vars(r)
{'rows': [3, 4, 5], 'total': 12}
>>> del r.total # clear the cache; the next access recomputescached_property به dict نمونه نیاز دارد
چون کش در obj.__dict__ نگه داشته میشود، cached_property روی کلاسهایی که __slots__ را بدون "__dict__" تعریف کردهاند کار نمیکند.
باگ کلاسیک: ذخیرهٔ وضعیت روی خودِ دیسکریپتور#
برای هر attributeِ کلاس فقط یک شیء دیسکریپتور وجود دارد که همهٔ نمونهها در آن شریکاند. اگر دادههای هر شیء را روی self داخل دیسکریپتور ذخیره کنید، همهٔ نمونهها در آن شریک میشوند:
class Broken:
def __get__(self, obj, objtype=None):
return self.value
def __set__(self, obj, value):
self.value = value # stored on the descriptor, which every instance shares
class Account:
balance = Broken()
a, b = Account(), Account()
a.balance = 100
b.balance = 5
print(a.balance) # 5 ← a's balance was overwritten by bبهجای آن مقدارها را روی obj ذخیره کنید، همانطور که Positive با setattr(obj, self.private_name, value) این کار را میکند.
مراقب بازگشت بیپایان باشید
داخل __set__ هرگز ننویسید setattr(obj, self.public_name, value). مقداردهی به نام عمومی دوباره __set__ را صدا میزند، و این تا ابد ادامه پیدا میکند. همیشه با یک نام دیگر ذخیره کنید، یا مستقیم در obj.__dict__ بنویسید.
کجاهای دیگر به دیسکریپتورها برمیخورید#
وقتی الگو را بشناسید، همهجا آن را میبینید:
| قابلیت | نوع دیسکریپتور | کاری که __get__ انجام میدهد |
|---|---|---|
| متدهای معمولی | غیرداده | یک متد bound برمیگرداند |
classmethod / staticmethod |
غیرداده | کلاس را bind میکند / چیزی را bind نمیکند |
property |
داده | تابع getter را صدا میزند |
functools.cached_property |
غیرداده | یک بار محاسبه میکند و در obj.__dict__ کش میکند |
ورودیهای __slots__ |
داده | یک خانهٔ ثابت در حافظهٔ شیء را میخواند |
| فیلدهای Django، ستونهای SQLAlchemy | بسته به نوع فیلد | مقدار را از پایگاه داده میخواند، تبدیل یا ردیابی میکند |
حتی میتوانید دیسکریپتورهای __slots__ را مستقیم ببینید:
>>> class Point:
... __slots__ = ("x", "y")
...
>>> Point.__dict__["x"]
<member 'x' of 'Point' objects>جمعبندی#
- دیسکریپتور یک attributeِ کلاس است که کلاسش
__get__،__set__یا__delete__را تعریف کرده باشد. - دیسکریپتورهای داده (با
__set__یا__delete__) بر دیکشنری نمونه مقدماند؛ دیسکریپتورهای غیرداده به آن میبازند. - متدها،
property،classmethod،staticmethod،cached_propertyو__slots__همه روی دیسکریپتورها ساخته شدهاند. - با
__set_name__نام attribute را بفهمید و مقدارهای هر نمونه را روی خودِ نمونه ذخیره کنید، هرگز روی دیسکریپتور. - وقتی منطق یکسانی برای attributeها (اعتبارسنجی، تبدیل، بارگذاری تنبل) در چند attribute یا چند کلاس تکرار میشود، سراغ یک دیسکریپتور سفارشی بروید.
بهترین راه برای اینکه این موضوع در ذهنتان بماند: یک REPL باز کنید، دیسکریپتوری بنویسید که در هر متدش چیزی چاپ کند، و ببینید پایتون دقیقاً کِی آن را صدا میزند.