راهنمای توصیفگر¶
- نویسنده:
ریموند هتینگر
- تماس:
<python at rcn dot com>
توصیفگرها (Descriptors) به اشیاء امکان میدهند تا جستجو، ذخیرهسازی و حذف ویژگیها را سفارشیسازی کنند.
این راهنما دارای ۴ بخش اصلی است:
«primer» یک مرور کلی مقدماتی ارائه میدهد و بهآرامی از مثالهای ساده پیش میرود و هر بار یک قابلیت را اضافه میکند. اگر با توصیفگرها تازهکار هستید، از اینجا شروع کنید.
بخش دوم نمونهای کامل و کاربردی از توصیفگر (descriptor) را نشان میدهد. اگر از قبل مبانی را میدانید، از همانجا شروع کنید.
بخش سوم، آموزش فنیتری ارائه میدهد که به سازوکار دقیق چگونگی کار توصیفگرها میپردازد. بیشتر افراد به این سطح از جزئیات نیازی ندارند.
بخش آخر شامل معادلهای پایتون خالص برای توصیفگرهای توکاری است که به زبان C نوشته شدهاند. اگر درباره چگونگی تبدیل توابع به متدهای مقید یا پیادهسازی ابزارهای رایجی مانند
@classmethod،@staticmethod،@propertyو __slots__ کنجکاوید، این بخش را بخوانید.
مقدمات¶
در این آموزش مقدماتی، با سادهترین مثال ممکن شروع میکنیم و سپس قابلیتهای جدید را یکییکی اضافه خواهیم کرد.
مثال ساده: یک توصیفگر که یک مقدار ثابت برمیگرداند¶
کلاس Ten یک توصیفگر است که متد __get__() آن همیشه ثابت 10 را برمیگرداند:
class Ten:
def __get__(self, obj, objtype=None):
return 10
برای استفاده از توصیفگر، باید آن را بهعنوان یک متغیر کلاس در کلاس دیگری ذخیره کنید:
class A:
x = 5 # Regular class attribute
y = Ten() # Descriptor instance
یک نشست تعاملی تفاوت میان جستجوی عادی ویژگی و جستجوی توصیفگر را نشان میدهد:
>>> a = A() # Make an instance of class A
>>> a.x # Normal attribute lookup
5
>>> a.y # Descriptor lookup
10
در جستوجوی ویژگی a.x، عملگر نقطه 'x': 5 را در دیکشنری کلاس مییابد. در جستوجوی a.y، عملگر نقطه یک نمونهی توصیفگر را مییابد که با متد __get__ خود شناخته میشود. فراخوانی آن متد 10 را برمیگرداند.
توجه داشته باشید که مقدار 10 در هیچیک از دیکشنری کلاس یا دیکشنری نمونه ذخیره نمیشود. در عوض، مقدار 10 در صورت درخواست محاسبه میشود.
این مثال نشان میدهد که یک توصیفگر ساده چگونه کار میکند، اما چندان مفید نیست. برای بازیابی ثابتها، جستجوی عادی ویژگی بهتر است.
در بخش بعدی، چیزی مفیدتر ایجاد خواهیم کرد: یک جستجوی پویا.
جستجوهای پویا¶
توصیفگرهای جالب معمولاً بهجای برگرداندن ثابتها، محاسبات را اجرا میکنند:
import os
class DirectorySize:
def __get__(self, obj, objtype=None):
return len(os.listdir(obj.dirname))
class Directory:
size = DirectorySize() # Descriptor instance
def __init__(self, dirname):
self.dirname = dirname # Regular instance attribute
یک نشست تعاملی نشان میدهد که جستجو پویا است — هر بار پاسخهایی متفاوت و بهروز محاسبه میکند:
>>> s = Directory('songs')
>>> g = Directory('games')
>>> s.size # The songs directory has twenty files
20
>>> g.size # The games directory has three files
3
>>> os.remove('games/chess') # Delete a game
>>> g.size # File count is automatically updated
2
علاوه بر نشان دادن این که توصیفگرها چگونه میتوانند محاسبات را اجرا کنند، این مثال همچنین هدف از پارامترهای __get__() را آشکار میکند. پارامتر self، size است که یک نمونه از DirectorySize است. پارامتر obj یا g است یا s، یعنی یک نمونه از Directory. این پارامتر obj است که به متد __get__() امکان میدهد پوشه هدف را بشناسد. پارامتر objtype، کلاس Directory است.
ویژگیهای مدیریتشده¶
یک کاربرد رایج توصیفگرها، مدیریت دسترسی به دادههای نمونه است. توصیفگر به یک ویژگی عمومی در دیکشنری کلاس اختصاص داده میشود، در حالی که داده واقعی بهصورت یک ویژگی خصوصی در دیکشنری نمونه ذخیره میشود. متدهای __get__() و __set__() توصیفگر، هنگام دسترسی به ویژگی عمومی فراخوانی میشوند.
در مثال زیر، age ویژگی عمومی و _age ویژگی خصوصی است. هنگام دسترسی به ویژگی عمومی، توصیفگر جستوجو یا بهروزرسانی را ثبت میکند:
import logging
logging.basicConfig(level=logging.INFO)
class LoggedAgeAccess:
def __get__(self, obj, objtype=None):
value = obj._age
logging.info('Accessing %r giving %r', 'age', value)
return value
def __set__(self, obj, value):
logging.info('Updating %r to %r', 'age', value)
obj._age = value
class Person:
age = LoggedAgeAccess() # Descriptor instance
def __init__(self, name, age):
self.name = name # Regular instance attribute
self.age = age # Calls __set__()
def birthday(self):
self.age += 1 # Calls both __get__() and __set__()
یک نشست تعاملی نشان میدهد که همه دسترسیها به ویژگی مدیریتشده age ثبت میشوند، اما ویژگی معمولی name ثبت نمیشود:
>>> mary = Person('Mary M', 30) # The initial age update is logged
INFO:root:Updating 'age' to 30
>>> dave = Person('David D', 40)
INFO:root:Updating 'age' to 40
>>> vars(mary) # The actual data is in a private attribute
{'name': 'Mary M', '_age': 30}
>>> vars(dave)
{'name': 'David D', '_age': 40}
>>> mary.age # Access the data and log the lookup
INFO:root:Accessing 'age' giving 30
30
>>> mary.birthday() # Updates are logged as well
INFO:root:Accessing 'age' giving 30
INFO:root:Updating 'age' to 31
>>> dave.name # Regular attribute lookup isn't logged
'David D'
>>> dave.age # Only the managed attribute is logged
INFO:root:Accessing 'age' giving 40
40
یکی از مشکلات اساسی این مثال این است که نام خصوصی _age در کلاس LoggedAgeAccess بهصورت سختکد تعریف شده است. این بدان معناست که هر نمونه فقط میتواند یک ویژگی ثبتشده داشته باشد و نام آن تغییرناپذیر است. در مثال بعدی، این مشکل را برطرف خواهیم کرد.
نامهای سفارشی¶
هنگامی که یک کلاس از توصیفگرها استفاده میکند، میتواند به هر توصیفگر اطلاع دهد که کدام نام متغیر استفاده شده است.
در این مثال، کلاس Person دارای دو نمونه از توصیفگرها، name و age است. هنگامی که کلاس Person تعریف میشود، یک فراخوانی بازگشتی به __set_name__() در LoggedAccess انجام میدهد تا نام فیلدها بتوانند ثبت شوند و به هر توصیفگر، public_name و private_name خودش داده شود:
import logging
logging.basicConfig(level=logging.INFO)
class LoggedAccess:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = '_' + name
def __get__(self, obj, objtype=None):
value = getattr(obj, self.private_name)
logging.info('Accessing %r giving %r', self.public_name, value)
return value
def __set__(self, obj, value):
logging.info('Updating %r to %r', self.public_name, value)
setattr(obj, self.private_name, value)
class Person:
name = LoggedAccess() # First descriptor instance
age = LoggedAccess() # Second descriptor instance
def __init__(self, name, age):
self.name = name # Calls the first descriptor
self.age = age # Calls the second descriptor
def birthday(self):
self.age += 1
یک نشست تعاملی نشان میدهد که کلاس Person متد __set_name__() را فراخوانی کرده است تا نام فیلدها ثبت شوند. در اینجا vars() را فراخوانی میکنیم تا توصیفگر را بدون فعالسازی آن جستجو کنیم:
>>> vars(vars(Person)['name'])
{'public_name': 'name', 'private_name': '_name'}
>>> vars(vars(Person)['age'])
{'public_name': 'age', 'private_name': '_age'}
کلاس جدید اکنون دسترسی به هر دو name و age را ثبت میکند:
>>> pete = Person('Peter P', 10)
INFO:root:Updating 'name' to 'Peter P'
INFO:root:Updating 'age' to 10
>>> kate = Person('Catherine C', 20)
INFO:root:Updating 'name' to 'Catherine C'
INFO:root:Updating 'age' to 20
این دو نمونهی Person فقط شامل نامهای خصوصی هستند:
>>> vars(pete)
{'_name': 'Peter P', '_age': 10}
>>> vars(kate)
{'_name': 'Catherine C', '_age': 20}
سخن پایانی¶
به هر شیءای که __get__()، __set__()، یا __delete__() را تعریف میکند، یک descriptor گفته میشود.
بهصورت اختیاری، توصیفگرها میتوانند یک متد __set_name__() داشته باشند. این متد فقط در مواردی استفاده میشود که یک توصیفگر نیاز دارد بداند یا کلاسی که در آن ایجاد شده است یا نام متغیر کلاسی که به آن اختصاص داده شده است. (این متد، در صورت وجود، حتی اگر کلاس یک توصیفگر نباشد نیز فراخوانی میشود.)
توصیفگرها در حین جستوجوی ویژگی، بهوسیله عملگر نقطه فراخوانی میشوند. اگر به یک توصیفگر بهطور غیرمستقیم از طریق vars(some_class)[descriptor_name] دسترسی پیدا شود، نمونهی توصیفگر بدون فراخوانی آن برگردانده میشود.
توصیفگرها تنها زمانی کار میکنند که بهعنوان متغیرهای کلاس استفاده شوند. هنگامی که در نمونهها قرار داده شوند، هیچ تأثیری ندارند.
انگیزهی اصلی توصیفگرها، فراهم کردن قلابی است که به اشیاء ذخیرهشده در متغیرهای کلاس اجازه میدهد کنترل کنند در هنگام جستوجوی ویژگی چه اتفاقی میافتد.
بهطور سنتی، کلاس فراخوان کنترل میکند که در حین جستجو چه اتفاقی بیفتد. توصیفگرها آن رابطه را معکوس میکنند و به دادهی مورد جستجو اجازه میدهند در این موضوع دخالت داشته باشد.
توصیفگرها در سراسر زبان استفاده میشوند. این سازوکاری است که از طریق آن توابع به متدهای مقید (bound methods) تبدیل میشوند. ابزارهای رایجی مانند @classmethod، @staticmethod، @property و @functools.cached_property همگی بهعنوان توصیفگر پیادهسازی شدهاند.
مثال عملی کامل¶
در این مثال، یک ابزار کاربردی و قدرتمند برای یافتن اشکالهای خرابی داده که یافتنشان بسیار دشوار است، میسازیم.
کلاس اعتبارسنج¶
اعتبارسنج توصیفگری برای دسترسی مدیریتشده به ویژگی است. پیش از ذخیره هرگونه داده، بررسی میکند که مقدار جدید محدودیتهای مختلف نوع و دامنه را برآورده میکند. اگر این محدودیتها برآورده نشوند، استثنایی را پرتاب میکند تا از فساد داده در مبدأ جلوگیری شود.
این کلاس Validator هم یک کلاس پایه انتزاعی و هم یک توصیفگر ویژگی مدیریتشده است:
from abc import ABC, abstractmethod
class Validator(ABC):
def __set_name__(self, owner, name):
self.private_name = '_' + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.private_name)
def __set__(self, obj, value):
self.validate(value)
setattr(obj, self.private_name, value)
@abstractmethod
def validate(self, value):
pass
اعتبارسنجهای سفارشی باید از Validator ارثبری کنند و یک متد validate() برای آزمایش محدودیتهای مختلف در صورت نیاز فراهم کنند.
اعتبارسنجهای سفارشی¶
در اینجا سه ابزار کاربردی برای اعتبارسنجی دادهها آمده است:
OneOfتأیید میکند که یک مقدار یکی از اعضای مجموعهای محدود از گزینهها است.Numberتأیید میکند که یک مقدار یاintاست یاfloat. بهصورت اختیاری، تأیید میکند که یک مقدار بین یک کمینه یا بیشینهی دادهشده باشد.Stringتأیید میکند که یک مقدار از نوعstrاست. بهصورت اختیاری، حداقل یا حداکثر طول دادهشده را اعتبارسنجی میکند. همچنین میتواند یک محمول (predicate) تعریفشده توسط کاربر را نیز اعتبارسنجی کند.
class OneOf(Validator):
def __init__(self, *options):
self.options = set(options)
def validate(self, value):
if value not in self.options:
raise ValueError(
f'Expected {value!r} to be one of {self.options!r}'
)
class Number(Validator):
def __init__(self, minvalue=None, maxvalue=None):
self.minvalue = minvalue
self.maxvalue = maxvalue
def validate(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f'Expected {value!r} to be an int or float')
if self.minvalue is not None and value < self.minvalue:
raise ValueError(
f'Expected {value!r} to be at least {self.minvalue!r}'
)
if self.maxvalue is not None and value > self.maxvalue:
raise ValueError(
f'Expected {value!r} to be no more than {self.maxvalue!r}'
)
class String(Validator):
def __init__(self, minsize=None, maxsize=None, predicate=None):
self.minsize = minsize
self.maxsize = maxsize
self.predicate = predicate
def validate(self, value):
if not isinstance(value, str):
raise TypeError(f'Expected {value!r} to be a str')
if self.minsize is not None and len(value) < self.minsize:
raise ValueError(
f'Expected {value!r} to be no smaller than {self.minsize!r}'
)
if self.maxsize is not None and len(value) > self.maxsize:
raise ValueError(
f'Expected {value!r} to be no bigger than {self.maxsize!r}'
)
if self.predicate is not None and not self.predicate(value):
raise ValueError(
f'Expected {self.predicate} to be true for {value!r}'
)
کاربرد عملی¶
در اینجا چگونگی استفاده از اعتبارسنجهای داده در یک کلاس واقعی آمده است:
class Component:
name = String(minsize=3, maxsize=10, predicate=str.isupper)
kind = OneOf('wood', 'metal', 'plastic')
quantity = Number(minvalue=0)
def __init__(self, name, kind, quantity):
self.name = name
self.kind = kind
self.quantity = quantity
توصیفگرها از ایجاد نمونههای نامعتبر جلوگیری میکنند:
>>> Component('Widget', 'metal', 5) # Blocked: 'Widget' is not all uppercase
Traceback (most recent call last):
...
ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'
>>> Component('WIDGET', 'metle', 5) # Blocked: 'metle' is misspelled
Traceback (most recent call last):
...
ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}
>>> Component('WIDGET', 'metal', -5) # Blocked: -5 is negative
Traceback (most recent call last):
...
ValueError: Expected -5 to be at least 0
>>> Component('WIDGET', 'metal', 'V') # Blocked: 'V' isn't a number
Traceback (most recent call last):
...
TypeError: Expected 'V' to be an int or float
>>> c = Component('WIDGET', 'metal', 5) # Allowed: The inputs are valid
آموزش فنی¶
آنچه در ادامه میآید، آموزش فنیتری دربارهی سازوکار و جزئیات چگونگی کار توصیفگرها است.
چکیده¶
توصیفگرها را تعریف میکند، پروتکل را خلاصه میکند و نحوه فراخوانی توصیفگرها را نشان میدهد. مثالی ارائه میکند که نشان میدهد نگاشتهای شیء-رابطهای چگونه کار میکنند.
یادگیری توصیفگرها نهتنها دسترسی به مجموعهابزار بزرگتری را فراهم میکند، بلکه درک عمیقتری از نحوه کارکرد پایتون ایجاد میکند.
تعریف و مقدمه¶
بهطور کلی، توصیفگر یک مقدار ویژگی است که یکی از متدهای پروتکل توصیفگر را دارد. آن متدها عبارتاند از __get__()، __set__() و __delete__(). اگر هر یک از آن متدها برای یک ویژگی تعریف شده باشد، به آن توصیفگر گفته میشود.
رفتار پیشفرض برای دسترسی به ویژگی این است که ویژگی از دیکشنری یک شیء دریافت، تنظیم یا حذف شود. برای مثال، a.x یک زنجیره جستجو دارد که با a.__dict__['x'] شروع میشود، سپس به type(a).__dict__['x'] میرسد و از طریق ترتیب حل متد type(a) ادامه مییابد. اگر مقدار جستجوشده شیئی باشد که یکی از متدهای توصیفگر را تعریف میکند، پایتون ممکن است رفتار پیشفرض را نادیده بگیرد و در عوض متد توصیفگر را فراخوانی کند. اینکه این اتفاق در کجای زنجیره اولویت رخ میدهد، به این بستگی دارد که کدام متدهای توصیفگر تعریف شدهاند.
توصیفگرها یک پروتکل قدرتمند و با کاربرد عمومی هستند. آنها سازوکار پشت ویژگیها، متدها، متدهای ایستا، متدهای کلاس و super() هستند. آنها در سراسر خود پایتون استفاده میشوند. توصیفگرها کد C زیربنایی را ساده میکنند و مجموعهای انعطافپذیر از ابزارهای جدید برای برنامههای روزمره پایتون ارائه میدهند.
پروتکل توصیفگر¶
descr.__get__(self, obj, type=None)
descr.__set__(self, obj, value)
descr.__delete__(self, obj)
تمام مطلب همین است. هر یک از این متدها را تعریف کنید، آن شیء یک توصیفگر محسوب میشود و میتواند رفتار پیشفرض را هنگامی که بهعنوان یک ویژگی مورد جستجو قرار میگیرد، بازنویسی کند.
اگر یک شیء __set__() یا __delete__() را تعریف کند، یک توصیفگر داده محسوب میشود. توصیفگرهایی که فقط __get__() را تعریف میکنند، توصیفگرهای غیرداده نامیده میشوند (آنها اغلب برای متدها استفاده میشوند، اما ممکن است کاربردهای دیگری نیز داشته باشند).
توصیفگرهای دادهای و غیردادهای در نحوه محاسبه بازنویسیها نسبت به ورودیهای دیکشنری یک نمونه تفاوت دارند. اگر دیکشنری یک نمونه ورودیای با همان نام یک توصیفگر دادهای داشته باشد، توصیفگر دادهای اولویت دارد. اگر دیکشنری یک نمونه ورودیای با همان نام یک توصیفگر غیردادهای داشته باشد، ورودی دیکشنری اولویت دارد.
برای ساخت یک توصیفگر دادهی فقطخواندنی، هر دو __get__() و __set__() را تعریف کنید، بهگونهای که __set__() هنگام فراخوانی یک AttributeError پرتاب کند. تعریف متد __set__() با یک جاینگهدار که استثنا پرتاب میکند، برای تبدیل آن به یک توصیفگر داده کافی است.
نمای کلی از فراخوانی توصیفگر¶
میتوان یک توصیفگر را مستقیماً با desc.__get__(obj) یا desc.__get__(None, cls) فراخوانی کرد.
اما رایجتر است که یک توصیفگر بهطور خودکار از طریق دسترسی به ویژگی فراخوانی شود.
عبارت obj.x ویژگی x را در زنجیرهای از فضای نامها برای obj جستجو میکند. اگر جستجو یک توصیفگر را خارج از __dict__ نمونه بیابد، متد __get__() آن بر اساس قواعد تقدم فهرستشده در زیر فراخوانی میشود.
جزئیات فراخوانی به این بستگی دارد که obj یک شیء، کلاس یا نمونهای از super باشد.
فراخوانی از یک نمونه¶
جستجوی نمونه، زنجیرهای از فضای نامها را پیمایش میکند و بیشترین اولویت را به توصیفگرهای دادهای میدهد؛ پس از آنها متغیرهای نمونه، سپس توصیفگرهای غیردادهای، سپس متغیرهای کلاس و در نهایت __getattr__()، اگر فراهم شده باشد، قرار دارند.
اگر توصیفگری برای a.x یافت شود، سپس با desc.__get__(a, type(a)) فراخوانی میشود.
منطق جستجوی نقطهدار در object.__getattribute__() قرار دارد. در اینجا یک معادل پایتون خالص آمده است:
def find_name_in_mro(cls, name, default):
"Emulate _PyType_Lookup() in Objects/typeobject.c"
for base in cls.__mro__:
if name in vars(base):
return vars(base)[name]
return default
def object_getattribute(obj, name):
"Emulate PyObject_GenericGetAttr() in Objects/object.c"
null = object()
objtype = type(obj)
cls_var = find_name_in_mro(objtype, name, null)
descr_get = getattr(type(cls_var), '__get__', null)
if descr_get is not null:
if (hasattr(type(cls_var), '__set__')
or hasattr(type(cls_var), '__delete__')):
return descr_get(cls_var, obj, objtype) # data descriptor
if hasattr(obj, '__dict__') and name in vars(obj):
return vars(obj)[name] # instance variable
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # non-data descriptor
if cls_var is not null:
return cls_var # class variable
raise AttributeError(name)
توجه داشته باشید که در کد __getattribute__() هیچ قلاب __getattr__() وجود ندارد. به همین دلیل است که فراخوانی مستقیم __getattribute__() یا از طریق super().__getattribute__، __getattr__() را بهطور کامل دور میزند.
در عوض، این عملگر نقطه و تابع getattr() هستند که هرگاه __getattribute__() استثنای AttributeError را پرتاب کند، مسئول فراخوانی __getattr__() هستند. منطق آنها در یک تابع کمکی کپسوله شده است:
def getattr_hook(obj, name):
"Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
try:
return obj.__getattribute__(name)
except AttributeError:
if not hasattr(type(obj), '__getattr__'):
raise
return type(obj).__getattr__(obj, name) # __getattr__
فراخوانی از یک کلاس¶
منطق جستجوی نقطهدار مانند A.x در type.__getattribute__() قرار دارد. این مراحل مشابه مراحل object.__getattribute__() هستند، اما جستجوی دیکشنری نمونه با جستجو در ترتیب حل متد کلاس جایگزین میشود.
اگر یک توصیفگر یافت شود، با desc.__get__(None, A) فراخوانی میشود.
پیادهسازی کامل به زبان C را میتوان در type_getattro() و _PyType_Lookup() در Objects/typeobject.c یافت.
فراخوانی از super¶
منطق جستجوی نقطهدار super در متد __getattribute__() برای شیء برگرداندهشده توسط super() قرار دارد.
یک جستجوی نقطهای مانند super(A, obj).m در obj.__class__.__mro__ به دنبال کلاس پایه B میگردد که بلافاصله پس از A قرار دارد و سپس B.__dict__['m'].__get__(obj, A) را برمیگرداند. اگر توصیفگر نباشد، m بدون تغییر برمیگردد.
پیادهسازی کامل C را میتوان در super_getattro() در Objects/typeobject.c یافت. یک معادل پایتون خالص را میتوان در Guido's Tutorial یافت.
خلاصه منطق فراخوانی¶
سازوکار توصیفگرها در متدهای __getattribute__() برای object، type و super() تعبیه شده است.
نکات مهمی که باید به خاطر داشته باشید:
توصیفگرها توسط متد
__getattribute__()فراخوانی میشوند.کلاسها این سازوکار را از
object،typeیاsuper()به ارث میبرند.بازنویسی
__getattribute__()از فراخوانیهای خودکار توصیفگر جلوگیری میکند، زیرا تمام منطق توصیفگر در آن متد قرار دارد.object.__getattribute__()وtype.__getattribute__()__get__()را بهصورت متفاوتی فراخوانی میکنند. فراخوانی اول شامل نمونه است و ممکن است شامل کلاس باشد. فراخوانی دوم برای نمونهNoneقرار میدهد و همیشه شامل کلاس است.توصیفگرهای داده همیشه بر دیکشنریهای نمونه اولویت دارند.
توصیفگرهای غیردادهای ممکن است توسط دیکشنریهای نمونه پوشانده شوند.
اطلاعرسانی خودکار نام¶
گاهی مطلوب است که یک توصیفگر بداند به نام کدام متغیر کلاس اختصاص داده شده است. هنگامی که یک کلاس جدید ایجاد میشود، فراکلاس type دیکشنری کلاس جدید را پیمایش میکند. اگر هر یک از آیتمها توصیفگر باشند و اگر __set_name__() را تعریف کرده باشند، آن متد با دو آرگومان فراخوانی میشود. owner کلاسی است که توصیفگر در آن استفاده میشود، و name متغیر کلاس است که توصیفگر به آن اختصاص داده شده است.
جزئیات پیادهسازی در type_new() و set_names() در Objects/typeobject.c قرار دارد.
از آنجا که منطق بهروزرسانی در type.__new__() قرار دارد، اعلانها فقط در زمان ایجاد کلاس رخ میدهند. اگر توصیفگرها بعداً به کلاس اضافه شوند، باید __set_name__() بهصورت دستی فراخوانی شود.
مثال ORM¶
کد زیر اسکلت سادهشدهای است که نشان میدهد چگونه میتوان از توصیفگرهای داده برای پیادهسازی یک نگاشت شیء-رابطهای استفاده کرد.
ایده اصلی این است که دادهها در یک پایگاه داده خارجی ذخیره میشوند. نمونههای پایتون تنها کلیدهای جدولهای پایگاه داده را نگه میدارند. توصیفگرها به جستوجوها یا بهروزرسانیها رسیدگی میکنند:
class Field:
def __set_name__(self, owner, name):
self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'
def __get__(self, obj, objtype=None):
return conn.execute(self.fetch, [obj.key]).fetchone()[0]
def __set__(self, obj, value):
conn.execute(self.store, [value, obj.key])
conn.commit()
میتوانیم از کلاس Field برای تعریف مدلها استفاده کنیم که طرحواره هر جدول در یک پایگاه داده را توصیف میکنند:
class Movie:
table = 'Movies' # Table name
key = 'title' # Primary key
director = Field()
year = Field()
def __init__(self, key):
self.key = key
class Song:
table = 'Music'
key = 'title'
artist = Field()
year = Field()
genre = Field()
def __init__(self, key):
self.key = key
برای استفاده از مدلها، ابتدا به پایگاه داده متصل شوید:
>>> import sqlite3
>>> conn = sqlite3.connect('entertainment.db')
یک نشست تعاملی نشان میدهد که چگونه دادهها از پایگاه داده بازیابی میشوند و چگونه میتوان آنها را بهروزرسانی کرد:
>>> Movie('Star Wars').director
'George Lucas'
>>> jaws = Movie('Jaws')
>>> f'Released in {jaws.year} by {jaws.director}'
'Released in 1975 by Steven Spielberg'
>>> Song('Country Roads').artist
'John Denver'
>>> Movie('Star Wars').director = 'J.J. Abrams'
>>> Movie('Star Wars').director
'J.J. Abrams'
معادلهای پایتون خالص¶
پروتکل توصیفگر ساده است و امکانات هیجانانگیزی را فراهم میکند. چندین مورد استفاده آنقدر رایج هستند که از پیش در قالب ابزارهای توکار بستهبندی شدهاند. پراپرتیها، متدهای مقید، متدهای ایستا، متدهای کلاس و __slots__ همگی مبتنی بر پروتکل توصیفگر هستند.
ویژگیها¶
فراخوانی property() روشی مختصر برای ساختن یک توصیفگر داده (data descriptor) است که هنگام دسترسی به یک ویژگی، باعث فراخوانی یک تابع میشود. امضای آن به این صورت است:
property(fget=None, fset=None, fdel=None, doc=None) -> property
مستندات یک کاربرد معمول برای تعریف یک ویژگی مدیریتشده x را نشان میدهد:
class C:
def getx(self): return self.__x
def setx(self, value): self.__x = value
def delx(self): del self.__x
x = property(getx, setx, delx, "I'm the 'x' property.")
برای اینکه ببینید property() چگونه بر اساس پروتکل توصیفگر (descriptor protocol) پیادهسازی شده است، در اینجا یک معادل پایتون خالص آمده است که بیشتر قابلیتهای اصلی را پیادهسازی میکند:
class Property:
"Emulate PyProperty_Type() in Objects/descrobject.c"
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
if doc is None and fget is not None:
doc = fget.__doc__
self.__doc__ = doc
def __set_name__(self, owner, name):
self.__name__ = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
تابع توکار property() هرگاه که یک رابط کاربری دسترسی به ویژگیها را اعطا کرده باشد و سپس تغییرات بعدی نیازمند مداخلهی یک متد باشند، به کمک میآید.
برای مثال، یک کلاس صفحه گسترده ممکن است دسترسی به مقدار یک سلول را از طریق Cell('b10').value فراهم کند. بهبودهای بعدی برنامه ایجاب میکنند که سلول در هر بار دسترسی دوباره محاسبه شود؛ با این حال، برنامهنویس نمیخواهد بر کد کلاینت موجود که بهطور مستقیم به ویژگی دسترسی دارد، تأثیر بگذارد. راهحل، قرار دادن دسترسی به ویژگی value در یک توصیفگر داده از نوع property است:
class Cell:
...
@property
def value(self):
"Recalculate the cell before returning value"
self.recalc()
return self._value
در این مثال، هم میتوان از property() توکار و هم از معادل Property() خودمان استفاده کرد.
توابع و متدها¶
ویژگیهای شیءگرای پایتون بر پایهی یک محیط مبتنی بر تابع بنا شدهاند. با استفاده از توصیفگرهای غیردادهای (non-data descriptors)، این دو بهصورت یکپارچه در هم ادغام میشوند.
توابع ذخیرهشده در دیکشنریهای کلاس، هنگام فراخوانی به متد تبدیل میشوند. متدها تنها در این با توابع معمولی تفاوت دارند که نمونه شیء به ابتدای آرگومانهای دیگر افزوده میشود. طبق قرارداد، نمونه self نامیده میشود، اما میتواند this یا هر نام متغیر دیگری باشد.
متدها را میتوان بهصورت دستی با types.MethodType ایجاد کرد، که تقریباً معادل است با:
class MethodType:
"Emulate PyMethod_Type in Objects/classobject.c"
def __init__(self, func, obj):
self.__func__ = func
self.__self__ = obj
def __call__(self, *args, **kwargs):
func = self.__func__
obj = self.__self__
return func(obj, *args, **kwargs)
def __getattribute__(self, name):
"Emulate method_getset() in Objects/classobject.c"
if name == '__doc__':
return self.__func__.__doc__
return object.__getattribute__(self, name)
def __getattr__(self, name):
"Emulate method_getattro() in Objects/classobject.c"
return getattr(self.__func__, name)
def __get__(self, obj, objtype=None):
"Emulate method_descr_get() in Objects/classobject.c"
return self
برای پشتیبانی از ایجاد خودکار متدها، توابع شامل متد __get__() برای مقیدسازی متدها در حین دسترسی به ویژگی هستند. این بدان معناست که توابع، توصیفگرهای غیردادهای هستند که متدهای مقیدشده را در حین جستجوی نقطهای از یک نمونه بازمیگردانند. نحوهی کار به این صورت است:
class Function:
...
def __get__(self, obj, objtype=None):
"Simulate func_descr_get() in Objects/funcobject.c"
if obj is None:
return self
return MethodType(self, obj)
اجرای کلاس زیر در مفسر نشان میدهد که توصیفگر تابع (function descriptor) در عمل چگونه کار میکند:
class D:
def f(self):
return self
class D2:
pass
این تابع دارای ویژگی qualified name برای پشتیبانی از دروننگری است:
>>> D.f.__qualname__
'D.f'
دسترسی به تابع از طریق دیکشنری کلاس، __get__() را فراخوانی نمیکند. در عوض، فقط شیء تابع زیرین را برمیگرداند:
>>> D.__dict__['f']
<function D.f at 0x00C45070>
دسترسی نقطهای از یک کلاس، __get__() را فراخوانی میکند که صرفاً تابع زیربنایی را بدون تغییر برمیگرداند:
>>> D.f
<function D.f at 0x00C45070>
رفتار جالبتوجه در حین دسترسی نقطهای از یک نمونه رخ میدهد. جستوجوی نقطهای، __get__() را فراخوانی میکند که یک شیء متد مقید را برمیگرداند:
>>> d = D()
>>> d.f
<bound method D.f of <__main__.D object at 0x00B18C90>>
بهصورت داخلی، متد مقید (bound method) تابع زیربنایی و نمونهی مقید را ذخیره میکند:
>>> d.f.__func__
<function D.f at 0x00C45070>
>>> d.f.__self__
<__main__.D object at 0x00B18C90>
اگر تاکنون از خود پرسیدهاید که self در متدهای معمولی یا cls در متدهای کلاس از کجا میآید، همینجا همان جاست!
انواع متدها¶
توصیفگرهای غیردادهای سازوکاری ساده برای حالتهای مختلف الگوهای معمول مقیدسازی توابع به متدها فراهم میکنند.
برای جمعبندی، توابع یک متد __get__() دارند تا بتوانند هنگام دسترسی بهعنوان ویژگی، به یک متد تبدیل شوند. توصیفگر غیردادهای یک فراخوانی obj.f(*args) را به f(obj, *args) تبدیل میکند. فراخوانی cls.f(*args) به f(*args) تبدیل میشود.
این نمودار مقیدسازی و دو مورد از مفیدترین گونههای آن را خلاصه میکند:
تبدیل
فراخوانیشده از یک شیء
فراخوانیشده از یک کلاس
تابع
f(obj, *args)
f(*args)
staticmethod
f(*args)
f(*args)
classmethod
f(type(obj), *args)
f(cls, *args)
متدهای ایستا¶
متدهای ایستا تابع زیربنایی را بدون تغییر برمیگردانند. فراخوانی هر یک از c.f یا C.f معادل جستجوی مستقیم در object.__getattribute__(c, "f") یا object.__getattribute__(C, "f") است. در نتیجه، تابع بهطور یکسان از یک شیء یا یک کلاس قابل دسترسی میشود.
گزینههای مناسب برای متدهای ایستا، متدهایی هستند که به متغیر self ارجاع نمیدهند.
برای مثال، یک بستهی آمار ممکن است شامل یک کلاس ظرف برای دادههای آزمایشی باشد. این کلاس متدهای معمولی را برای محاسبهی معدل، میانگین، میانه و سایر آمارهای توصیفی فراهم میکند که به دادهها وابستهاند. با این حال، ممکن است توابع مفیدی وجود داشته باشند که از نظر مفهومی مرتبطاند اما به دادهها وابسته نیستند. برای مثال، erf(x) یک روال تبدیل کاربردی است که در کارهای آماری پیش میآید، اما مستقیماً به یک مجموعهدادهی خاص وابسته نیست. میتوان آن را از یک شیء یا از کلاس فراخوانی کرد: s.erf(1.5) --> 0.9332 یا Sample.erf(1.5) --> 0.9332.
از آنجا که متدهای ایستا تابع زیربنایی را بدون هیچ تغییری برمیگردانند، فراخوانیهای مثال چندان جالب نیستند:
class E:
@staticmethod
def f(x):
return x * 10
>>> E.f(3)
30
>>> E().f(3)
30
با استفاده از پروتکل توصیفگر غیردادهای (non-data descriptor protocol)، نسخهی پایتون خالصِ @staticmethod به این شکل خواهد بود:
import functools
class StaticMethod:
"Emulate PyStaticMethod_Type() in Objects/funcobject.c"
def __init__(self, f):
self.f = f
functools.update_wrapper(self, f)
def __get__(self, obj, objtype=None):
return self.f
def __call__(self, *args, **kwds):
return self.f(*args, **kwds)
@property
def __annotations__(self):
return self.f.__annotations__
فراخوان functools.update_wrapper() یک ویژگی __wrapped__ میافزاید که به تابع زیرین ارجاع دارد. همچنین ویژگیهای لازم را منتقل میکند تا تابع پوششی شبیه تابع پوشیدهشده به نظر برسد، از جمله __name__، __qualname__ و __doc__.
متدهای کلاس¶
برخلاف متدهای ایستا، متدهای کلاس پیش از فراخوانی تابع، ارجاع کلاس را به ابتدای فهرست آرگومانها اضافه میکنند. این قالب چه فراخواننده یک شیء باشد و چه یک کلاس، یکسان است:
class F:
@classmethod
def f(cls, x):
return cls.__name__, x
>>> F.f(3)
('F', 3)
>>> F().f(3)
('F', 3)
این رفتار هر زمان که متد فقط به یک ارجاع به کلاس نیاز داشته باشد و به دادههای ذخیرهشده در یک نمونهی خاص متکی نباشد، مفید است. یکی از کاربردهای متدهای کلاس، ایجاد سازندههای جایگزین برای کلاس است. برای مثال، متد کلاس dict.fromkeys() یک دیکشنری جدید از فهرستی از کلیدها ایجاد میکند. معادل پایتون خالص آن به صورت زیر است:
class Dict(dict):
@classmethod
def fromkeys(cls, iterable, value=None):
"Emulate dict_fromkeys() in Objects/dictobject.c"
d = cls()
for key in iterable:
d[key] = value
return d
اکنون میتوان یک دیکشنری جدید با کلیدهای یکتا را به این صورت ساخت:
>>> d = Dict.fromkeys('abracadabra')
>>> type(d) is Dict
True
>>> d
{'a': None, 'b': None, 'r': None, 'c': None, 'd': None}
با استفاده از پروتکل توصیفگر غیردادهای (non-data descriptor protocol)، یک نسخهی پایتون خالص از @classmethod به این شکل خواهد بود:
import functools
class ClassMethod:
"Emulate PyClassMethod_Type() in Objects/funcobject.c"
def __init__(self, f):
self.f = f
functools.update_wrapper(self, f)
def __get__(self, obj, cls=None):
if cls is None:
cls = type(obj)
return MethodType(self.f, cls)
فراخوان functools.update_wrapper() در ClassMethod ویژگی __wrapped__ را میافزاید که به تابع زیربنایی ارجاع میدهد. همچنین این فراخوان ویژگیهای لازم را منتقل میکند تا دربرگیرنده شبیه به تابع پوشیدهشده به نظر برسد: __name__، __qualname__، __doc__ و __annotations__.
اشیای عضو و __slots__¶
هنگامی که یک کلاس __slots__ را تعریف میکند، دیکشنریهای نمونه را با آرایهای با طول ثابت از مقادیر جایگاه جایگزین میکند. از دیدگاه کاربر، این موضوع چندین اثر دارد:
۱. امکان تشخیص فوری اشکالهای ناشی از اشتباه املایی در انتساب ویژگیها را فراهم میکند. فقط نام ویژگیهای مشخصشده در __slots__ مجاز است:
class Vehicle:
__slots__ = ('id_number', 'make', 'model')
>>> auto = Vehicle()
>>> auto.id_nubmer = 'VYE483814LQEX'
Traceback (most recent call last):
...
AttributeError: 'Vehicle' object has no attribute 'id_nubmer'
۲. به ایجاد اشیای تغییرناپذیری کمک میکند که در آنها توصیفگرها دسترسی به ویژگیهای خصوصی ذخیرهشده در __slots__ را مدیریت میکنند:
class Immutable:
__slots__ = ('_dept', '_name') # Replace the instance dictionary
def __init__(self, dept, name):
self._dept = dept # Store to private attribute
self._name = name # Store to private attribute
@property # Read-only descriptor
def dept(self):
return self._dept
@property
def name(self): # Read-only descriptor
return self._name
>>> mark = Immutable('Botany', 'Mark Watney')
>>> mark.dept
'Botany'
>>> mark.dept = 'Space Pirate'
Traceback (most recent call last):
...
AttributeError: property 'dept' of 'Immutable' object has no setter
>>> mark.location = 'Mars'
Traceback (most recent call last):
...
AttributeError: 'Immutable' object has no attribute 'location'
۳. در حافظه صرفهجویی میکند. در یک ساخت لینوکس ۶۴ بیتی، یک نمونه با دو ویژگی، با __slots__ برابر ۴۸ بایت و بدون آن ۱۵۲ بایت فضا اشغال میکند. این الگوی طراحی فلایتویت (flyweight design pattern) احتمالاً تنها زمانی اهمیت دارد که قرار باشد تعداد زیادی نمونه ایجاد شوند.
۴. سرعت را بهبود میبخشد. خواندن متغیرهای نمونه با __slots__ ۳۵٪ سریعتر است (همانطور که با پایتون 3.10 روی پردازندهی Apple M1 اندازهگیری شده است).
۵. ابزارهایی مانند @functools.cached_property را که برای عملکرد صحیح به یک دیکشنری نمونه نیاز دارند، مسدود میکند:
from functools import cached_property
class CP:
__slots__ = () # Eliminates the instance dict
@cached_property # Requires an instance dict
def pi(self):
return 4 * sum((-1.0)**n / (2.0*n + 1.0)
for n in reversed(range(100_000)))
>>> CP().pi
Traceback (most recent call last):
...
TypeError: No '__dict__' attribute on 'CP' instance to cache 'pi' property.
ایجاد یک نسخهی پایتون خالصِ دقیق و کاملاً جایگزین (drop-in) از __slots__ ممکن نیست، زیرا به دسترسی مستقیم به ساختارهای C و کنترل بر تخصیص حافظهی شیء نیاز دارد. با این حال، میتوانیم یک شبیهسازی تا حد زیادی وفادار بسازیم که در آن ساختار C واقعی جایگاهها با یک فهرست خصوصی _slotvalues شبیهسازی میشود. عملیات خواندن و نوشتن روی آن ساختار خصوصی توسط توصیفگرهای عضو مدیریت میشود:
null = object()
class Member:
def __init__(self, name, clsname, offset):
'Emulate PyMemberDef in Include/descrobject.h'
# Also see descr_new() in Objects/descrobject.c
self.name = name
self.clsname = clsname
self.offset = offset
def __get__(self, obj, objtype=None):
'Emulate member_get() in Objects/descrobject.c'
# Also see PyMember_GetOne() in Python/structmember.c
if obj is None:
return self
value = obj._slotvalues[self.offset]
if value is null:
raise AttributeError(self.name)
return value
def __set__(self, obj, value):
'Emulate member_set() in Objects/descrobject.c'
obj._slotvalues[self.offset] = value
def __delete__(self, obj):
'Emulate member_delete() in Objects/descrobject.c'
value = obj._slotvalues[self.offset]
if value is null:
raise AttributeError(self.name)
obj._slotvalues[self.offset] = null
def __repr__(self):
'Emulate member_repr() in Objects/descrobject.c'
return f'<Member {self.name!r} of {self.clsname!r}>'
متد type.__new__() افزودن اشیای عضو به متغیرهای کلاس را بر عهده دارد:
class Type(type):
'Simulate how the type metaclass adds member objects for slots'
def __new__(mcls, clsname, bases, mapping, **kwargs):
'Emulate type_new() in Objects/typeobject.c'
# type_new() calls PyTypeReady() which calls add_methods()
slot_names = mapping.get('slot_names', [])
for offset, name in enumerate(slot_names):
mapping[name] = Member(name, clsname, offset)
return type.__new__(mcls, clsname, bases, mapping, **kwargs)
متد object.__new__() مسئول ایجاد نمونههایی است که بهجای دیکشنری نمونه، جایگاهها (slots) دارند. در اینجا یک شبیهسازی تقریبی در پایتون خالص آمده است:
class Object:
'Simulate how object.__new__() allocates memory for __slots__'
def __new__(cls, *args, **kwargs):
'Emulate object_new() in Objects/typeobject.c'
inst = super().__new__(cls)
if hasattr(cls, 'slot_names'):
empty_slots = [null] * len(cls.slot_names)
object.__setattr__(inst, '_slotvalues', empty_slots)
return inst
def __setattr__(self, name, value):
'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
cls = type(self)
if hasattr(cls, 'slot_names') and name not in cls.slot_names:
raise AttributeError(
f'{cls.__name__!r} object has no attribute {name!r}'
)
super().__setattr__(name, value)
def __delattr__(self, name):
'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
cls = type(self)
if hasattr(cls, 'slot_names') and name not in cls.slot_names:
raise AttributeError(
f'{cls.__name__!r} object has no attribute {name!r}'
)
super().__delattr__(name)
برای استفاده از شبیهسازی در یک کلاس واقعی، کافی است از Object ارثبری کنید و فراکلاس را روی Type تنظیم کنید:
class H(Object, metaclass=Type):
'Instance variables stored in slots'
slot_names = ['x', 'y']
def __init__(self, x, y):
self.x = x
self.y = y
در این نقطه، فراکلاس اشیای عضو برای x و y را بارگذاری کرده است:
>>> from pprint import pp
>>> pp(dict(vars(H)))
{'__module__': '__main__',
'__doc__': 'Instance variables stored in slots',
'slot_names': ['x', 'y'],
'__init__': <function H.__init__ at 0x7fb5d302f9d0>,
'x': <Member 'x' of 'H'>,
'y': <Member 'y' of 'H'>}
هنگامی که نمونهها ایجاد میشوند، آنها یک فهرست slot_values دارند که ویژگیها در آن ذخیره میشوند:
>>> h = H(10, 20)
>>> vars(h)
{'_slotvalues': [10, 20]}
>>> h.x = 55
>>> vars(h)
{'_slotvalues': [55, 20]}
ویژگیهایی که بهاشتباه نوشته شده یا تخصیص داده نشده باشند، یک استثنا پرتاب خواهند کرد:
>>> h.xz
Traceback (most recent call last):
...
AttributeError: 'H' object has no attribute 'xz'