وبلاگ شخصی
محمّدرضا
علی حسینی

جایی که تجربیات, علایق
و چیزهایی که یادگرفته‌ام را
با هم مرور می‌کنیم.

همه‌چیز درمورد Descriptor های سحرآمیز پایتون

موضوعات: پایتون,
همه‌چیز درمورد 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'>>

همان‌طوری که می‌بینید مثل دفعه‌ی قبل هر دو دارند یک خروجی را برمی‌گردانند. با این تفاوت که اینجا هر دو متد هستند نه تابع.

این هم از نگاه دقیق و طولانی ما به Descriptor ها در پایتون. حالا می‌توانیم بهتر روند اجرای بخش‌های مختلف برنامه را در پایتون بفهمیم و کدهای باکیفیت‌تر، استانداردتر و حرفه‌ای‌تری را به این زبان بنویسیم.

احترام به کپی‌رایت

در ساخت تصویر ابتدای این نوشته از یکی از تصاویر سایت freepik استفاده شده است و طبق درخواست این سایت، من لینک مربوط به آن را اینجا قرار داده‌ام.

Background vector created by freepik – www.freepik.com

«نوشته‌های مرتبط»

پاسخی بگذارید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

«نوشته‌های ویژه»

«نوشته‌های محبوب»

«دیدگاه کاربران»