پایتونپیشرفته

دیسکریپتورها در پایتون: پروتکلی که پشت property و متدهاست

property، متدهای bound، cached_property و __slots__ همه روی یک پروتکل کوچک کار می‌کنند. یاد بگیرید دیسکریپتورها چطور کار می‌کنند، ترتیب دقیق جست‌وجوی attributeها چیست و چطور دیسکریپتور خودتان را بنویسید.

دسترسی به یک attribute، یعنی obj.price، که از متدهای __get__ و __set__ یک دیسکریپتور عبور می‌کند
در این صفحه

تا حالا فکر کرده‌اید @property چطور یک متد را به چیزی تبدیل می‌کند که شبیه یک attributeِ ساده است؟ یا وقتی obj.method() را صدا می‌زنید، self دقیقاً از کجا می‌آید؟ یا فیلدهای مدل در Django و ستون‌های SQLAlchemy از کجا می‌فهمند متعلق به کدام شیء هستند؟

همهٔ این‌ها روی یک سازوکار کوچک بنا شده‌اند: پروتکل دیسکریپتور (descriptor protocol). این یکی از معدود بخش‌های واقعاً «جادویی» پایتون است و وقتی آن را بفهمید، هم می‌توانید سازوکار درونی زبان را بخوانید و هم منطق attributeها را تمیزتر و قابل‌استفادهٔ مجدد بنویسید.

اگر دکوریتورها هنوز برایتان تازه‌اند، اول دکوریتورها در پایتون، قدم‌به‌قدم را بخوانید؛ در ادامه از آن‌ها استفاده می‌کنیم.

دیسکریپتور چیست؟#

دیسکریپتور هر شیئی است که کلاسش دست‌کم یکی از این متدها را تعریف کرده باشد:

Python
__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 را دو بار بنویسید. با دیسکریپتور، قانون را یک بار می‌نویسید و دوباره از آن استفاده می‌کنید:

positive.pyPython
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
Python
>>> 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__ را تعریف می‌کند (توابع معمولی دیسکریپتور غیرداده‌اند).
obj.attr⁧این‌ها را به ترتیب بررسی می‌کند و در اولین تطابق می‌ایستد⁩ 1234 ⁧دیسکریپتور داده روی کلاس⁩⁧دیکشنری نمونه⁩⁧دیسکریپتور غیرداده یا attributeِ کلاس⁩⁧آخرین راه⁩ ⁧⁦property⁩، اعضای ⁦__slots__⁩، ⁦Positive()⁩⁩obj.__dict__['attr']⁧متدها، ⁦classmethod⁩، ⁦cached_property⁩⁩__getattr__(self, name) __get__(obj, type)⁧مقدار ذخیره‌شده⁩⁧⁦__get__()⁩ یا خودِ مقدار⁩⁧نتیجهٔ آن، وگرنه ⁦AttributeError⁩⁩
پایتون اول دیسکریپتور داده روی کلاس را بررسی می‌کند، بعد دیکشنری نمونه را، بعد دیسکریپتورهای غیرداده و attributeهای کلاس را، و در آخر __getattr__ را

این تفاوت مهم است، چون دیکشنری نمونه بین این دو قرار می‌گیرد. می‌توانید خودتان ثابتش کنید:

lookup_order.pyPython
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 از همین‌جا می‌آید:

Python
>>> 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 کردن در تست‌ها خیلی به کار می‌آید):

Python
>>> g.hello = lambda: "patched"
>>> g.hello(), Greeter().hello()
('patched', 'hi')

classmethod و staticmethod هم دیسکریپتور غیرداده‌اند: __get__ آن‌ها به‌جای نمونه، کلاس را bind می‌کند یا اصلاً چیزی را bind نمی‌کند.

ساختن property از صفر#

property یک دیسکریپتور داده است که به زبان C نوشته شده، اما یک نسخهٔ کارآمد از آن به پایتون خالص در یک صفحه جا می‌شود:

my_property.pyPython
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
Python
>>> 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__ دیگر هرگز صدا زده نمی‌شود:

Python
>>> 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 recomputes

cached_property به dict نمونه نیاز دارد

چون کش در obj.__dict__ نگه داشته می‌شود، cached_property روی کلاس‌هایی که __slots__ را بدون "__dict__" تعریف کرده‌اند کار نمی‌کند.

باگ کلاسیک: ذخیرهٔ وضعیت روی خودِ دیسکریپتور#

برای هر attributeِ کلاس فقط یک شیء دیسکریپتور وجود دارد که همهٔ نمونه‌ها در آن شریک‌اند. اگر داده‌های هر شیء را روی self داخل دیسکریپتور ذخیره کنید، همهٔ نمونه‌ها در آن شریک می‌شوند:

Python
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__ را مستقیم ببینید:

Python
>>> 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 باز کنید، دیسکریپتوری بنویسید که در هر متدش چیزی چاپ کند، و ببینید پایتون دقیقاً کِی آن را صدا می‌زند.