همهچیز درمورد Descriptor های سحرآمیز پایتون
ما به صورت عادی از کلاسها برای این استفاده میکنیم که با ایجاد یک سطح Abstraction کمک کنند تا اشیاء مختلفی را که رفتارهای یکسانی دارند و تنها دادههایشان متفاوت است بسازیم.
حالا چطوری میتوان رفتارهای ویژگیهای مرتبط به یک کلاس را مدیریت کرد؟ چطوری میتوانیم کاری کنیم که ویژگیهای متفاوت کلاسهای مختلف به شکلی یکسان مدیریت شوند؟
اگر تا به حال اسم Descriptor به گوشتان نخورده یا اینکه اسمش را شنیدهاید ولی نمیداند که دقیقاً چیست و به چه دردی میخورد، در این نوشته میخواهیم با هم زیر و بم Descriptor ها را در بیاوریم.
فهرست مطالب
Descriptor چیست؟
اوّل از همه بیایید با هم یک تعریف کلّی از Descriptor پیدا کنیم. ما در حالت ساده هیچ کنترلی روی ویژگی (Attribute) هایی که یک کلاس دارد نداریم.
وقتی شما کلاسی دارید که یک ویژگی به نام x
دارد، هنگام کار با نمونههای ساختهشده از این کلاس میتوان هر مقداری را به این ویژگی نسبت داد.
مثلاً فرضکنید که ما یک کلاس به نام Person
داریم که مقدار سن شخص در آن نگهداری میشود:
class Person: def __init__(self, age): self.age = age
حالا ما موقع ساخت یک نمونه از این کلاس میتوانیم «هرچیزی» را به آن نسبت بدهیم:
person1 = Person(10000) person2 = Person(-10) person3 = Person("I am not even an integer!")در حالت ساده ما هیچ کنترلی روی ویژگیها نداریم. پس تمام نمونههای بالا از نظر زبان پایتون درست هستند. امّا این مقادیر منطق برنامهی ما را خراب میکنند. شما میتوانید برای اعتبارسنجی (Validation) مقادیر کدهای اضافیای را درون
__init__
قرار بدهید. امّا آن کدها دیگر تغییرات مقادیر را پس از ساخت شئ کنترل نمیکنند.
حتّی میتوانید متدهایی برای اعتبارسنجی درون کلاستان بگذارید. امّا این کار تنها استفاده از کدتان را برای دیگران سختتر میکند و همواره این احتمال وجود دارد که برنامهنویس فراموش کند که پس از هرتغییری در هر جای کد، آن متدها را فراخوانی کند.
از این مورد بگذریم. فرضکنید که شما میخواهید یک ویژگی درون کلاس A
همیشه عدد مثبت باشد. حالا در جای دیگر برنامه نیازدارید که چند ویژگی کلاس B
هم درست همین خاصیّت را داشته باشند.
کلاسهای A
و B
هم هیچ ربط منطقیای ندارند و نمیتوان به مواردی مثل ارثبری حتّی فکر کرد. عالی نبود اگر میشد کاری کرد که ویژگیهای کلاس های مختلف، بدون اینکه به هم ربطی پیدا کنند، به یک شکل مدیریت شوند؟
خب Descriptor پروتکلی است که همهی این کارها را برای ما میکند.
پروتکل Descriptor
پروتکل Descriptor خیلی ساده است. هر کلاسی که حداقل یکی از متدهای: __get__
، __set__
یا __delete__
را پیادهسازی کند یک Descriptor حساب میشود.
متد __ get __
متد__get__
اینطوری تعریف میشود:
__get__(self, obj, type=None) -> value
اوّلین پارامتر این متد مثل تمامی متدهای دیگر مقدار self
است. حواستان باشد که این self
دارد به کلاس Descriptor اشاره میکند، نه شیای که میخواهیم ویژگی (Attribute) آن را مدیریت کنیم.
پارامتر بعدی مقدار obj
است که همان شیای است که ویژگیای که میخواهیم آن را دریافت کنیم مربوط به آن است.
مثلاً در کد زیر، وقتی که متد __get__
فراخوانی میشود (به زودی میفهمیم چطوری)، مقدار obj
همان person1
است.
person1 = Person(10000) print(person1.age)
پارامتر آخر هم نوع همان پارامتر دوم است. یعنی کلاسی که obj
متعلّق به آن است. مقدار پیشفرض این پارامتر مقدار None
است.
در پایان اجرای متد __get__
اگر همه چیز خوب پیشرفت، باید مقدار ویژگیای که فراخوانی کردهایم (مثلاً در اینجا سن شخص) برگردد. معنی value->
هم همین است.
متد __ set __
متد __set__
اینطوری تعریف میشود:
__set__(self, obj, value) -> None
دو تا پارامتر اوّل این متد درست مثل متد قبلی اند. تنها پارامتر سوم با __get__
فرق میکند. در اینجا سومین پارامتر مقداری است که قرار است در این ویژگی ذخیره شود.
حواستان باشد که این متد مقداری را بر نمیگرداند. به همین خاطر آخر تعریف آن مقدار: None->
نوشته شده است. چون در پایتون وقتی که تابع (یا متد) چیزی را return
نمیکند، درحقیقت مقدار None
را دارد برمیگرداند.
متد __ delete __
آخرین متد، یعنی __delete__
هم اینطوری تعریف میشود:
__delete__(self, obj) -> None
این متد تنها خود obj
را به عنوان ورودی میگیرد و هنگامی فراخوانی میشود که میخواهیم یک ویژگی را پاککنیم.
پروتکل Descriptor به همین سادگی بود.
انواع Descriptor ها
ما دو نوع مختلف Descriptor داریم. اگر یک Descriptor هم __set__
را پیادهسازی کرده باشد و هم __get__
را، به آن data descriptor میگوییم. ولی اگر یک Descriptor تنها __get__
را پیادهسازی کرده باشد، به آن non-data descriptor میگوییم.
حالا تفاوت این دو در چیست؟ اگر ما دو تا Descriptor با یک اسم داشته باشیم که یکی data descriptor باشد و یکی non-data، اولویت با آنی است که از نوع data descriptor محسوب میشود و متدهای مربوط به آن اجرا میشوند.
این اولویتبندی چطوری کار میکند؟ برویم سراغ بخش بعد.
استفاده از Descriptor
بیایید اوّل یک Descriptor خیلی ساده (و البته بیمصرف) بنویسم.
class MyDescriptor: def __init__(self, value=None): self.value = value def __get__(self, instance, owner): print("getting value") return self.value def __set__(self, instance, value): print(f"Replace {self.value} with new value") self.value = value
همانطوری که گفتم Descriptor ها هم خودشان کلاس هستند. پس ما مثل همیشه یک کلاس عادی به نام MyDescriptor
میسازیم.
ما در __init__
مقدار ویژگیای که قرار است توسّط این Descriptor مدیریت شود را به عنوان پارامتر ورودی گرفتهایم و آن را درون ویژگی value
ذخیرهکردهایم. حواستان باشد که value
یک ویژگی متعلّق به MyDescriptor
است، نه ویژگیای متعلّق به کلاسی که از این Descriptor قرار است استفاده کند.
حالا برای اینکه این کلاس عادی تبدیل به یک data descriptor شود، باید متدهای __get__
و __set__
را برایش پیادهسازی کنیم.
کاری که در متد __get__
داریم انجام میدهیم خیلی ساده است. یک پیام چاپ میشود و بعد مقدار self.value
برگردانده میشود.
در متد __set__
هم ما ابتدا مقدار قبلی ویژگی value
را چاپ میکنیم و سپس مقدار آن را با مقدار جدید جایگزین میکنیم.
به همین راحتی ما برای خودمان یک Descriptor ساختهایم.
حالا بیایید از آن در یک کلاس استفاده کنیم.
class SampleClass: an_attribute = MyDescriptor() def __init__(self, n): self.an_attribute = n
ما ابتدا همان کاری که برای تعریف class attribute ها میکنیم را انجام دادهایم. خارج از هر متدی، یک ویژگی به نام an_attribute
تعریف کردهایم و مقدارش را برابر با یک نمونهی جدید از کلاس MyDescriptor
قرار داده ایم.
سپس درون متد __init__
مقدار این ویژگی را برابر با پارامتر ورودی گذاشتهایم.
حالا بیایید یکم با این کلاس بازی کنیم:
simple_instance = SampleClass(10) print(simple_instance.an_attribute) simple_instance.an_attribute = 100 print(simple_instance.an_attribute)
فکر میکنید اگر این کد را اجرا کنیم چه خروجیای دریافت میکنیم؟
Replace None with new value getting value 10 Replace 10 with new value getting value 100
اوّلین خط مربوط میشود به متد __init__
. وقتی که an_attribute
را تعریف کردیم، مقدار None
درون ویژگی value
ریخته شده است. حالا درون متد __init__
وقتی که میخواهیم مقدار an_attribute
را برابر پارامتر ورودی بگذاریم، متد __set__
درون MyDescriptor
فراخوانی میشود. پس پیامی که ابتدای آن قرار دارد چاپ میشود.
خطوط دوم و سوم خروجی حاصل اجرای دستور print
در خط دوم برنامه اند. خط اوّل به این خاطر پرینت شده است که با فراخوانی ویژگی an_attribute
، متد __get__
فراخوانی شده است. خط دوم هم خروجی دستور print
درون برنامه است که خروجی متد __get__
را چاپ میکند.
حالا در خط سوم برنامه ما مقدار an_attribute
را دوباره تغییردادهایم. پس طبیعتاً دوباره __set__
فراخوانی میشود و پیام ابتدایی آن چاپ میشود.
در خط آخر برنامه هم دوباره an_attribute
را چاپ کردهایم. پس همان اتّفاقات قبلی تکرار میشود.
همانطوری که میبینید وقتی که یک ویژگی برای خودش Descriptor دارد، برای کار با آن Descriptor لازم نیست کار خاصّی بکنیم. همان کارهای عادیای که هنگام کار با ویژگیهای یک کلاس انجام میدادیم متدهای مرتبط را روی Descriptor اجرا میکنند.
حالا شاید شما به هر دلیلی نخواهید یا نتوانید از عملگر .
مثل مثالهای قبلی استفاده کنید. در این حالت شما میتوانید مستقیماً متدهای __get__
یا __set__
را فراخوانی کنید.
برای اینکه ببینیم در این حالت چطوری میتوان از Descriptor ها استفاده کرد باید ببینم که وقتی یک ویژگی را با گذاشتن .
بعد از اسم شئ فراخوانی میکنیم چه اتّفاقی میافتد.
ویژگیهای مختلف چطوری در پایتون صدازده میشوند؟
وقتی که شما یک ویژگی را روی یک شئ فراخوانی میکنید، مثلاً مینویسید: simple_instance.an_attribute
، اتّفاقی که میافتد این است که متد __getattribute__
فراخوانی میشود.
موقعی که داریم از Descriptor ها استفاده میکنیم، این متد کد simple_instance.an_attribute
را به کد زیر تبدیل میکند:
type(simple_instance).__dict__["an_attribute"].__get__(simple_instance, type(simple_instance))
هر کلاس و هر شئ برای خودشان یک مقدار __dict__
دارند که در آن ویژگیهای مربوط به آن کلاس یا شئ درون یک دیکشنری ذخیره شده است.
این کد کاری که میکند این است که ابتدا با type(simple_instance)
کلاسی که simple_instance
از آن ساخته شده است را میگیرد. سپس اسم ویژگیای که آن را میخواهیم ، در اینجا an_attribute
، را به دیکشنری میدهد و Descriptor مرتبط با آن را میگیرد. آخر سر هم خود متد __get__
مربوط به آن Descriptor را فراخوانی میکند.
(پایتون استانداردی که ما داریم از آن استفاده میکنیم به زبان C نوشته شده است. یعنی شما همچین کدی را درون کدهای پایتون پیدا نمیکنید. این پیادهسازی معادل کد C به زبان پایتون است که در دایکومنتهای خود پایتون نوشته شده است.)
ما وقتی مقادیر را روی یک شئ فراخوانی میکنیم، مقادیر مختلف بر اساس یک اولویّتبندی خاص فراخوانی میشوند.
متد __getattribute__
ابتدا دنبال یک data descriptor با نامی که ما فراخوانی کردهایم میگردد. اگر همچین چیزی نبود، دنبال مقادیر عادی با همان نام میگردد. اگر باز هم چیزی پیدا نکرد میرود سراغ non-data descriptor ها. باز اگر چیزی پیدا نشد میرود سراغ __getattr__
و اگر باز هم چیزی با آن اسم پیدا نکرد، خطای AttributeError
را برمیگرداند.
خب با تمام این حرفها ما میتوانیم کد قبلی را اینطوری هم بنویسیم:
simple_instance = SampleClass(10) print(type(simple_instance).__dict__["an_attribute"].__get__(simple_instance, type(simple_instance))) type(simple_instance).__dict__["an_attribute"].__set__(simple_instance, 100) print(type(simple_instance).__dict__["an_attribute"].__get__(simple_instance, type(simple_instance)))
کد اصلاً خوانا نیست، ولی همان خروجی قبلی را میدهد.
روشهای مختلف تعریف Descriptor ها
ما تا الان فهمیدیم که Descriptor چیست و چطوری کار میکند. حالا زمان آن است که روشهای مختلف ساخت Descriptor ها را یادبگیریم.
ذخیرهی مقادیر در خود Descriptor
این سادهترین و البته کمکاربردترین روش ساخت یک Descriptor است. مثالی که با هم دیدیم به همین شیوه کار میکند. در این روش ما مقدار مرتبط با ویژگی را درون خود Descriptor ذخیره میکنیم. مثلاً در MyDescriptor
، ویژگی دارد درون value
ذخیره میشود.
مشکل این رویکرد چیست؟ اوّل بیایید یک برنامهی کوچک بنویسیم:
simple_instance = SampleClass(10) print(simple_instance.an_attribute) second_instance = SampleClass(12) print(f"Attribute value for simple instance: {simple_instance.an_attribute}") print(f"Attribute value for second instance: {second_instance.an_attribute}")
این برنامه دقیقاً مثل همان برنامهی بخش قبل است. با این تفاوت که یک نمونهی دیگر هم از کلاس SampleClass
به نام second_instance
ساختهایم.
حالا اگر برنامه را اجرا کنیم این خروجی را میگیریم:
Replace None with new value getting value 10 Replace 10 with new value getting value Attribute value for simple instance: 12 getting value Attribute value for second instance: 12
همانطوری که میبینید تغییر ویژگی an_instance
برای شئ second_instance
مقدار an_instance
متعلّق به simple_instance
را هم عوض میکند. چرا؟ چون این ویژگی اصلاً به خود شئ مربوط نشده است. ما داریم مقدار را درون descriptor ذخیره میکنیم و برای یک ویژگی در یک کلاس، فقط و فقط یک descriptor وجود دارد.
این یعنی تمامی نمونههایی که از یک کلاس ساخته میشوند از همان Descriptor معرّفی شده هنگام تعریف کلاس استفاده میکنند و به ازای هر نمونهی جدید، یک Descriptor جدید ساخته نمیشود.
به همین خاطر وقتی که ما مقدار ذخیره شده در Descriptor را عوض کنیم، مقدار ویژگی متناظر با آن Descriptor در تمامی اشیاء عوض میشود.
پس عملاً این روش تعریف Descriptor ها به درد ما نمیخورد.
استفاده از رفرنسهای ضعیف
یک کار دیگری که میتوانیم بکنیم این است که درون Descriptor همراه با خود مقدار، یک رفرنس را به شئای که این مقدار به آن مرتبط است نگهداریکنیم. و خب وقتی که قرار است یک جفت از دادهها را ذخیرهکنیم، اوّلین چیزی که به ذهن آدم میرسد استفاده از دیکشنری است.
class MyDescriptor: def __init__(self): self.values_dictionary = dict() def __get__(self, instance, owner): print("getting value") return self.values_dictionary[instance] def __set__(self, instance, value): print(f"Replace {self.values_dictionary.get(instance)} with new value") self.values_dictionary[instance] = value
ما این بار به جای اینکه خود مقدار را درون MyDescriptor
ذخیره کنیم، یک دیکشنری را درون MyDescriptor
تعریف میکنیم.
درون متد __set__
ما ابتدا مقدار قبلیای که درون دیکشنری values_dictionary
ذخیره شده است را برمیگردانیم.
سپس زوج شئ-مقدار را درون دیکشنری ذخیره میکنیم. یعنی خود instance
میشود یک کلید در دیکشنری و مقدار مرتبط با ویژگی آن هم میشود مقدار (value) متناظر با آن.
اینجا ما از متد get
روی دیکشنری استفاده کردیم تا وقتی که برای اوّلین بار قرار است مقدار ویژگی در کلاس اصلی (منظور همان SampleClass
است) ذخیره شود، با خطای وجود نداشتن کلید در دیکشنری مواجه نشویم.
درون متد __get__
هم نگاه میکنیم و اگر instance
مرتبط را پیدا کردیم، مقدار مربوط به آن را از دیکشنری برمیگردانیم.
حالا همان برنامهی قبلی را اجرا میکنیم:
class SampleClass: an_attribute = MyDescriptor() def __init__(self, n): self.an_attribute = n simple_instance = SampleClass(10) print(simple_instance.an_attribute) second_instance = SampleClass(12) print(f"Attribute value for simple instance: {simple_instance.an_attribute}") print(f"Attribute value for second instance: {second_instance.an_attribute}")
این بار با اجرای برنامه میبینم که مقدار هر شئ مخصوص به خود آن است و با تغییر مقدار یک نمونه، مقادیر مربوط به دیگر نمونهها تغییر نمیکنند:
Replace None with new value getting value 10 Replace None with new value getting value Attribute value for simple instance: 10 getting value Attribute value for second instance: 12
خب الان دیگر همهچیز خوب است؟ نه.
مسئله این است که هر مقدار در پایتون یک reference counter دارد. هر بار که کسی به آن مقدار اشاره میکند یک واحد به این شمارنده افزوده میشود و هروقت که از scope آن متغیّر خارج شویم، یک واحد از این شمارنده کم میشود.
هر وقت که reference counter یک مقدار برابر با صفر شود، Garbage collector آن مقدار را دور میریزد. اینطوری وقتی مقداری درون برنامه استفاده نمیشود الکی حافظه را اشغال نمیکند.
حالا ما در اینجا داریم یک رفرنس به هر شئ را درون دیکشنری موجود در MyDescriptor
نگهداری میکنیم. این یعنی اینکه تا زمانی که این Descriptor وجود دارد، هیچکدام از اشیائی که از آن ساخته شده اند از حافظه بیرون ریخته نمیشوند.
این کار مشکلات بزرگی را برای برنامهی ما ایجاد میکند. مخصوصاً وقتی که تعداد نمونههایی که از آن کلاس میسازیم زیاد باشد. اینطوری خیلی زود با کمبود حافظه روبهرو میشویم.
اگر Descriptor را اینطوری پیادهسازی کنیم، Garbage Collector تنها درصورتی میتواند حافظه را خالی کند که خود کلاس را پاککنیم. در غیر این صورت تا انتهای اجرای برنامه هیچکدام از اشیاء ساخته شده از حافظه پاک نمیشوند.
برای رفع این مشکل میتوانیم از کتابخانهی weakref که به صورت پیشفرض همراه با پایتون روی سیستمها وجود دارد استفاده کنیم.
شما با استفاده از این کتابخانه میتوانید دو مدل دیکشنری داشته باشید. WeakKeyDictionary
و WeakValueDictionary
.
در اوّلی کلیدهای آرایه رفرنسهای ضعیف اند. یعنی اینکه جزو رفرنسهایی که در Reference Counter محاسبه میشوند نیستند. اینطوری Garbage Collector به راحتی میتواند اشیائی که دیگر به آنها نیاز نیست را پیدا کند و دور بریزد و این موضوع که ما در این دیکشنری به آن اشاره میکنیم مانع از این کار نمیشود.
فقط حواستان باشد که وقتی که شئ توسّط Garbage Collector پاک شود، زوج key:value
آن هم از درون دیکشنری به صورت خودبهخود پاک میشود.
نوع WeakValueDictionary
هم دقیقاً مثل همان قبلی است. با این تفاوت که این بار مقادیر دیکشنری رفرنسهای ضعیف اند، نه کلیدهای آن.
خب حالا ما میخواهیم Descriptor خودمان را با استفاده از WeakKeyDictionary
بازنویسی کنیم:
from weakref import WeakKeyDictionary class MyDescriptor: def __init__(self): self.values_dictionary = WeakKeyDictionary() def __get__(self, instance, owner): print("getting value") return self.values_dictionary[instance] def __set__(self, instance, value): print(f"Replace {self.values_dictionary.get(instance)} with new value") self.values_dictionary[instance] = value
این کد دقیقاً کارآییای که ما میخواهیم را دارد.
این روش خیلی وقتها برای ما کار میکند. امّا باید به دو نکته توجّه داشته باشید.
اوّل اینکه شما نمیتوانید همهچیز را به عنوان کلید یک WeakKeyDictionary
استفاده کنید. شئای که به عنوان کلید میخواهد ذخیره شود باید hashable باشد و این یعنی اینکه از مقادیر mutable مثل دیکشنری، لیست و … نمیتوان استفاده کرد.
به علاوه استفادهی همزمان از رفرنس ضعیف و __slots__
خیلی اوقات باعث ایجاد مشکل میشود.
استفاده از مقدار ویژهی __ dict __
آخرین روش، که خیلی وقتها بهترین روش هم محسوب میشود، این است که مقدار ویژگی را به جای اینکه درون Descriptor ذخیرهکنیم، درون خود شئ ذخیره کنیم.
اینطوری Descriptor فقط عملکرد کنترلی دارد و دیگر لازم نیست نگران ذخیرهی مقادیر هم باشد. برای اینکار کافی است که ویژگی را درون __dict__
مربوط به هر شئ ذخیرهکنیم.
class MyDescriptor: def __init__(self, field_name): self.field_name = field_name def __get__(self, instance, owner): print("getting value") return instance.__dict__.get(self.field_name) def __set__(self, instance, value): print(f"Replace {self.__get__(instance, None)} with new value") instance.__dict__[self.field_name] = value
اینبار علاوه بر دو تا متد __get__
و __set__
، متد __init__
را هم تغییر داده ایم. به همین خاطر هربار که میخواهیم یک ویژگی را توسّط این Descriptor مدیریتکنیم، باید اسم آن را هنگام تعریف ویژگی مشخّص کنیم.
درون متد __get__
ما ویژگی __dict__
مربوط به instance
را میگیریم. سپس از self.field_name
به عنوان کلید این دیکشنری استفاده میکنیم و مقدار متناظر با آن را (که همان مقداری است که ما با صدازدن ویژگی میخواهیم بگیریم) بر میگردانیم.
در __set__
هم کاری که میکنیم این است که یک کلید با نام ویژگی تعریف میکنیم و مقدارش را برابر با مقداری میگذاریم که با value
مشخّص شده است (همان مقداری که مقابل علامت مساوی قرار میگیرد).
حالا میتوانیم کلاسمان را با استفاده از این Descriptor تعریف کنیم.
class SampleClass: an_attribute = MyDescriptor("an_attribute") def __init__(self, n): self.an_attribute = n simple_instance = SampleClass(10) print(simple_instance.an_attribute) second_instance = SampleClass(12) print(f"Attribute value for simple instance: {simple_instance.an_attribute}") print(f"Attribute value for second instance: {second_instance.an_attribute}")و خب کد دقیقاً همانطوری که ما میخواهیم کار میکند. تنها ایرادی که میتوان به این روش گرفت این است که ما اسم ویژگی را باید دو بار بنویسم (یک بار در خود کلاس و یک بار هم به عنوان ورودی به
MyDescriptor
). به علاوه این روش با استفاده از __slots__
سازگار نیست. چون در آن حالت ما اصلاً __dict__
نداریم.
چند نمونه از کاربردهای Descriptor
تا اینجا انواع و اقسام روشهای تعریف یک Descriptor را دیدم. الان وقت آن است که ببینیم اصلاً در دنیای واقعی در چه جاهایی میتوان از آن استفاده کرد.
اوّلین چیزی که به ذهن آدم میرسد هنگام کار با دیتابیس است. در یک سیستم ORM ما فیلدهایی داریم که به جدولهای مختلف تعلّق دارند و مقادیر مختلفی را در خودشان ذخیره میکنند، امّا نوعشان یکی است.
مثلاً ما یک نوع tinyint داریم که تنها میتواند مقادیر ۰ تا ۲۵۵ را در خودش ذخیره کند. حالا برای اینکه در مدلهای مختلف بتوانیم ویژگیهایی از این نوع داشته باشیم که مقدارشان برای ذخیره در ستونی از دیتابیس که از این نوع است مناسب باشد، میتوانیم یک Descriptor برای آن بنویسیم:
class TinyIntField: def __init__(self, field_name, not_null=False, max_value=255): self.field_name = field_name self.not_null = not_null self.max_value = 255 if max_value >= 255 else max_value def __get__(self, instance, owner): return instance.__dict__.get(self.field_name) def __set__(self, instance, value): if self.not_null and value is None: raise ValueError("Value can not be None") if value is not None and (value < 0 or value > self.max_value or self): raise ValueError(f"Value should be an integer between 0 and {self.max_value}") instance.__dict__[self.field_name] = value
این Descriptor علاوه بر اسم ویژگی، حداکثر مقداری که آن ویژگی میتواند داشته باشد را میگیرد. به علاوه شما میتوانید مشخّص کنید که این فیلد در دیتابیس میتواند NULL
باشد یا نه. (در __init__
اگر مقدار max_value
از ۲۵۵ که حداکثر مقدار قابل قبول است بیشتر شود، به جایش همان مقدار ۲۵۵ درنظر گرفته میشود).
متد __get__
را قبلاً دیدهایم. در متد __set__
هم کاری که میکنیم این است که میبینیم آیا مقداری که قرار است به این ویژگی نسبت داده شود درست است یا نه. مثلاً اگر پارامتر not_null
برابر با False
باشد ما نمیتوانیم مقدار None
را به این ویژگی نسبتبدهیم. پس باید خطا برگردانده شود. در شرط دوم هم بررسی میکنیم که آیا مقدار ورودی (در صورتی که یک عدد باشد) در بازهی مورد نظر قرار دارد یا نه.
اگر همهچیز درست بود، اسم ویژگی و مقدارش را به عنوان یک جفت درون __dict__
شئ ذخیره میکنیم. حالا شما میتوانید این فیلد را در مدلهای کاملاً متفاوت و بیربط به هم در سرتاسر کدتان استفاده کنید.
مثلاً من دو تا کلاس به عنوان مدل داده (کلاسهایی که دارند سطرهای یک ستون در دیتابیس را نمایش میدهند) میسازم. یکی برای جدول Person
که تنها سن افراد را ذخیره میکند و یکی هم برای جدول Product
که قیمت محصولات را نگهداری میکند.
class Person: age = TinyIntField("age", not_null=True, max_value=90) class Product: price = TinyIntField("price")
میبینید که من دو ویژگی در این دو کلاس تعریف کردهام و هر دو ویژگی را برابر با Descriptorی که بالاتر تعریف کردیم قرار دادهام.
حالا ما خیلی راحت میتوانیم از نمونههای این کلاسها برای نمایش سطرهای دیتابیسمان استفاده کنیم:
person = Person() bag = Product() PanahBarKhodaRezayee = Person() person.age = 20 bag.price = None PanahBarKhodaRezayee.age = 10 print(f"person age: {person.age}") print(f"bag price: {bag.price}") print(f"Panah bar khoda Rezayee age: {PanahBarKhodaRezayee.age}")
حالا اگر این برنامه را اجرا کنیم میبینم که خروجیها همانچیزهایی هستند که ما میخواهیم:
person age: 20 bag price: None Panah bar khoda Rezayee age: 10
حالا بیایید یک مقدار غلط را درون یک ویژگی ذخیره کنیم. مثلاً سن person
را برابر با یک عدد منفی قرار بدهیم:
person.age = -20
حالا اگر برنامه را اجرا کنیم، میبینم که به ما خطا میدهد:
raise ValueError(f"Value should be an integer between 0 and {self.max_value}") ValueError: Value should be an integer between 0 and 90
کاربردهای Descriptor ها به همینجا ختم نمیشود. شما میتوانید از آنها برای پیادهسازی الگوی Observer، کش کردن مقادیر و… استفاده کنید.
برای اینکه این نوشته از چیزی که الان هست طولانیتر نشود دیگر سراغ مثالهای آنها نمیرویم.
استفادهی راحتتر از Descriptor ها به کمک property
شما خیلی وقتها برای اینکه یک کلاس مجزا برای ساخت Descriptor نسازید، میتوانید از property
استفاده کنید. property
یک سینتکس خلاصه برای ساخت Descriptor است. مثلاً ما میتوانیم به جای این کد:
class MyDescriptor: def __init__(self, field_name): self.field_name = field_name def __get__(self, instance, owner): print("getting value") return instance.__dict__.get(self.field_name) def __set__(self, instance, value): print(f"Replace {self.__get__(instance, None)} with new value") instance.__dict__[self.field_name] = value
این کد را بنویسیم:
class SampleClass: def __init__(self, n): self._an_attribute = n @property def an_attribute(self): print("getting value") return self._an_attribute @an_attribute.setter def an_attribute(self, value): print(f"Replace {self.an_attribute} with new value") self._an_attribute = value
حالا برنامهی زیر را برای تست این حالت جدید مینویسیم:
simple_instance = SampleClass(10) print(simple_instance.an_attribute) second_instance = SampleClass(12) print(f"Attribute value for simple instance: {simple_instance.an_attribute}") print(f"Attribute value for second instance: {second_instance.an_attribute}")
اگر برنامه را اجرا کنیم میبینم که همان خروجی قبلی را میگیریم:
getting value 10 getting value Attribute value for simple instance: 10 getting value Attribute value for second instance: 12
فقط حواستان باشد که باید اسم ویژگیای که درون __init__
تعریف کردهایم با اسمی که برای property
میگذاریم فرق کند.
اگر بخواهیم کلاس Property
را به پایتون بنویسیم (باز هم جهت یادآوری: پایتون خودش به زبان C نوشته شده است) به کدی شبیه به این میرسیم (کد را از داکیومنتهای پایتون برداشتهام):
class Property(object): "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 __get__(self, obj, objtype=None): if obj is None: return self if self.fget is None: raise AttributeError("unreadable attribute") return self.fget(obj) def __set__(self, obj, value): if self.fset is None: raise AttributeError("can't set attribute") self.fset(obj, value) def __delete__(self, obj): if self.fdel is None: raise AttributeError("can't delete attribute") 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__)
از خیر توضیح این بخش میگذرم. چون با دانشی که تا الان به دست آوردیم باید متوجّه اینکه کد دارد چه کار میکند بشوید. هرچند اگر متوجّه هم نشدید مشکلی پیش نمیآید. 🙂
توابع و متدها هم Descriptor هستند
توابع و متدها non-data descriptor محسوب میشوند. وقتی که متد __get__
فراخوانی میشود، اگر مقدارinstance
برابر با None
بود (یعنی اینکه ما یا یک تابع را فراخوانی کردهایم یا اینکه متد را روی یک نمونه از کلاس صدا نزدهایم. بلکه اسم کلاس را نوشتهایم و بعد از علامت .
اسم متد را آوردهایم)، خود descriptor برگردانده میشود.
معادل پایتونی کلاس function (بله، در پایتون حتّی تابع هم خودش یک کلاس است) میشود کد زیر (کد را از اینجا برداشتهام):
class Function(object): . . . def __get__(self, obj, objtype=None): "Simulate func_descr_get() in Objects/funcobject.c" if obj is None: return self return types.MethodType(self, obj)
مثلاً یک کلاس خیلی ساده را در نظر بگیرید:
class SampleClass: def __init__(self): pass def my_method(self): pass
ما در این کلاس یک متد به نام my_method
داریم. حالا بیایید یک بار خود متد (نه خروجیاش) را روی خود کلاس و یک بار هم روی نمونهای که از کلاس ساختهایم چاپ کنیم:
instance = SampleClass() print(SampleClass.my_method) print(instance.my_method)
خروجی کد میشود این:
<function SampleClass.my_method at 0x7fbdd166d950> <bound method SampleClass.my_method of <__main__.SampleClass object at 0x7fbddcdbf810>>
همانطوری که میبینید وقتی که متد را روی خود کلاس فراخوانی میکنیم، دقیقاً انگار یک تابع را فراخوانی کردهایم.
امّا وقتی که متد را روی یک نمونهی ساخته شده از کلاس فراخوانی میکنیم، دیگر خروجی یک متد است نه یک تابع معمولی.
خب حالا چطوری میتوانیم یک تابع را به یک شئ اضافه کنیم طوری که تبدیل به یکی از متدهای آن شئ شود؟
همانطوری که همین الان دیدیم، ما زمانی یک متد (همان bound method
در خروجی بالا) را دریافت میکنیم که موقع فراخوانی متد __get__
، مقدار instance
یک شئ باشد، نه مقدار None
.
پس برای اینکه بتوانیم تابع را به شکل یک متد در بیاوریم، اوّل از همه باید خروجی متد __get__
آن را روی شئ مورد نظر دریافت کنیم.
مثلاً کد خیلی سادهی زیر را درنظر بگیرید:
class SimpleClass: def __init__(self, n): self.n = n def i_am_a_method(obj): print(obj) simple_instance = SimpleClass(10)
ما یک کلاس ساده به نام SimpleClass
داریم که متدی ندارد. یک تابع هم به نام i_am_a_method
داریم که میخواهیم آن را به عنوان یک متد به simple_instance
که یک نمونه از کلاس SimpleClass
است متّصل کنیم.
print(i_am_a_method) print(i_am_a_method.__get__(simple_instance, simple_instance.__class__))
در این تکّه کد ما اوّل خود تابع را چاپ کردهایم و بعد خروجی متد __get__
آن را وقتی که simple_instance
را به عنوان پارامتر اوّل گرفته است (در خط اول هم ما داریم __get__
را فراخوانی میکنیم. تنها تفاوتش این است که پارامترهای بعدی None
هستند).
خروجی این کد میشود مقدار زیر:
<function i_am_a_method at 0x7f746871cf80> <bound method i_am_a_method of <__main__.SimpleClass object at 0x7f746875b650>>
همانطوری که میبینید، مقدار دوم همانچیزی است که ما میخواهیم به شئ خودمان بچسبانیم. حالا برای اینکه متد به شئ بچسبد کافی است که از تابع setattr
استفاده کنیم:
bounded_method = i_am_a_method.__get__(simple_instance, simple_instance.__class__) setattr(simple_instance, "i_am_a_method", bounded_method)
در setattr
اوّلین ورودی خود شئ است. دومین ورودی اسم مقدار و سومین ورودی هم خود آن مقدار است.
حالا از اینجا به بعد میتوانیم i_am_a_method
را به عنوان یک متد روی این نمونه از کلاس SimpleClass
فراخوانی کنیم.
این یعنی اینکه الان پارامتر اوّل این تابع معادل همان کلمهی self
در متدهایی است که هنگام تعریف یک کلاس مینویسیم.
simple_instance.i_am_a_method() print(simple_instance)
حالا اگر این کد را اجرا کنیم خروجی زیر را میگیریم:
<__main__.SimpleClass object at 0x7f714b75b850> <__main__.SimpleClass object at 0x7f714b75b850>
و این یعنی اینکه ما به صورت موفّقیّتآمیز توانستیم تابع i_am_a_method
را به عنوان یک متد به شئ simple_instance
قالبکنیم و مثل تمامی متدها، خود شئ به عنوان ورودی اوّل به آن فرستاده میشود.
فقط حواستان باشد که این اتّفاق فقط برای شئ simple_instance
افتاده است و اگر بخواهیم این متد را روی یک نمونهی دیگر از کلاس SimpleClass
فراخوانی کنیم با خطا مواجه میشویم.
چطوری با کمک Descriptor ها متدهای static کار میکنند؟
متدهای static هم خودشان یک non-data descriptor هستند. فرقی نمیکند که ما یک متد static را روی کلاس فراخوانی کنیم یا روی شئ. در هر حالت قرار است یک «تابع» مشخّص اجرا بشود. پیادهسازی پایتونی متد static چیزی شبیه به کد زیر است (کد را از اینجا برداشتهام):
class StaticMethod(object): "Emulate PyStaticMethod_Type() in Objects/funcobject.c" def __init__(self, f): self.f = f def __get__(self, obj, objtype=None): return self.f
این یکی از معدود کاربردهای روش اوّل تعریف Descriptor ها است.
حالا بیایید همان مثال بخش قبل را این بار برای متد static اجرا کنیم:
class SampleClass: def __init__(self): pass @staticmethod def my_method(): pass instance = SampleClass() print(SampleClass.my_method) print(instance.my_method)
اگر این کد را اجرا کنیم خروجی میشود این:
<function SampleClass.my_method at 0x7f5da030a950> <function SampleClass.my_method at 0x7f5da030a950>
میبینید که هر دو دقیقاً به یک تابع میرسند.
چطوری با کمک Descriptor ها Class Method ها کار میکنند؟
class method ها هم مثل static method ها هستند. با این تفاوت که خود کلاس را به عنوان ورودی میگیرند. اگر کدی مشابه کد بخش قبلی برای آن بنویسیم، میبینیم که اینجا هم اتّفاقی شبیه به قبل میافتد:
class SampleClass: def __init__(self): pass @classmethod def my_method(klass): pass instance = SampleClass() print(SampleClass.my_method) print(instance.my_method)
class SampleClass: def __init__(self): pass @classmethod def my_method(klass): pass instance = SampleClass() print(SampleClass.my_method) print(instance.my_method)
خروجی این برنامه میشود این:
<bound method SampleClass.my_method of <class '__main__.SampleClass'>> <bound method SampleClass.my_method of <class '__main__.SampleClass'>>همانطوری که میبینید مثل دفعهی قبل هر دو دارند یک خروجی را برمیگردانند. با این تفاوت که اینجا هر دو متد هستند نه تابع.
The form you have selected does not exist.
یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.