چطور با __ slots __ کلاسهای پایتون را سریعتر و کمحجمتر کنیم؟
سرعت پایین و مصرف حافظهی زیاد مشکلاتی است که همیشه هنگام کار با پایتون به سراغ آدم میآیند. از طرفی راحتی کد زدن به زبان پایتون باعث شده که خیلیها ترجیحبدهند که برنامههایشان را به این زبان بنویسند.
اگر برنامههایی دارید که تعداد زیادی شئ در آنها ساخته میشوند، در این نوشته میخواهیم روشی را یادبگیریم که هم برنامه را سریعتر میکند و هم کمحجمتر.
فهرست مطالب
ویژگیها به صورت عادی چطوری در یک شئ ذخیره میشوند؟
به صورت عادی هر شئ یک ویژگی (Attribute) به نام __dict__
دارد. نوع این ویژگی دیکشنری است. درون آن اسم ویژگیها به عنوان کلید ذخیره شده است و مقدار مرتبط به هر کلید، مقداری است که درون آن ویژگی ریخته شده است.
مثلاً کلاس زیر را درنظر بگیرید:
class SimpleClass: def __init__(self): self.x = 10 self.y = 100 self.z = 5000000
حالا یک نمونه از این کلاس میسازیم و مقدار __dict__
آن را چاپ میکنیم:
simple_instance = SimpleClass() print(simple_instance.__dict__)
خروجی این کد میشود این:
{'x': 10, 'y': 100, 'z': 5000000}
دیکشنری هر شئ مربوط به خودش است. یعنی اینکه ما اگر دوتا نمونه از این کلاس بسازیم، با اینکه مقادیر هر دو یکسان است، امّا هر کدام یک دیکشنری جداگانه برای نگهداری این مقادیر دارند.
simple_instance = SimpleClass() simple_instance2 = SimpleClass() print(simple_instance.__dict__) print(simple_instance2.__dict__) print(simple_instance2.__dict__ is simple_instance.__dict__)
در این کد ما یک نمونهی دیگر هم از این کلاس ساختهایم و پس از چاپکردن __dict__
هر دو، میخواهیم با عملگر is
بررسی کنیم که آیا دیکشنری این دو شئ یک دیکشنری واحد است یا نه.
{'x': 10, 'y': 100, 'z': 5000000} {'x': 10, 'y': 100, 'z': 5000000} False
همانطور که میبینید __dict__
ها خودشان دو شئ کاملاً متفاوت هستند.
شیوهی استفاده از __ slots __
خب حالا چطوری میتوانیم از __slots__
استفاده کنیم؟ برای استفاده از __slots__
، باید ویژگی __slots__
را هنگام تعریف کلاس تعریف کنیم و درون آن تمامی ویژگی (attribute) هایی که در کلاسمان وجود دارند را مشخّص کنیم.
مثلاً کد زیر معادل همان کلاس قبلی است:
class LittleSlot: __slots__ = ['x', 'y', 'z'] def __init__(self): self.x = 10 self.y = 100 self.z = 5000000
حالا فرق این کلاس با کلاس قبلی چیست؟ در هر دو میتوانیم به ویژگیها با استفاده از عملگر .
دسترسی داشته باشیم. امّا در دومین کلاس دیگر خبری از مقدار __dict__
نیست.
my_slot = LittleSlot() print(my_slot.__dict__)
اگر این کد را اجرا کنیم با پیام خطا مواجه میشویم:
print(my_slot.__dict__) AttributeError: 'LittleSlot' object has no attribute '__dict__'
این درست تفاوتِ وقتی است که داریم از __slots__
استفاده میکنیم. همین ازبینرفتن __dict__
باعث میشود که اشیاء ما هم سریعتر باشند و هم سبکتر.
چرا __ slots __ باعث میشود که برنامه حجم کمتری را اشغال کند؟
ما وقتی در حالت عادی چند نمونه از یک کلاس میسازیم، اتّفاقی که میافتد شبیه به تصویر زیر است:
هر نمونه (instance) یک رفرنس به کلاسی که از آن ساخته شده است نگهداری میکند. به علاوه یک ویژگی (Attribute) به نام __dict__
هم دارد که به یک دیکشنری اشاره میکند. داخل این دیکشنری هرکدام از ویژگیهای کلاس به صورت یک زوج key/value نگهداری میشود.
امّا وقتی که داریم از __slots__
استفاده میکنیم ساختار کاملاً با این فرق میکند.
همانطوری که میبینید ما اینجا برای هر نمونه یک دیکشنری مجزا نگهداری نمیکنیم. به جای این یک نگاشت (map) عمومی داریم که برای تمام نمونههایی که از یک کلاس خاص ساخته میشوند یکسان است.
در این نگاشت ایندکس مرتبط به هرکدام از ویژگیهای موجود در کلاس وجود دارد. حالا مقادیر مربوط به ویژگیهای هر کلاس درون یک آرایه نگهداری میشود. وقتی که شما یک ویژگی را فراخوانی میکنید، ایندکس متناظر با آن از درون نگاشت برداشته میشود و رفرنس آن ویژگی را میتوانیم از درون آرایه برداریم.
این بار به جای اینکه یک دیکشنری داشته باشیم که اسم هر ویژگی و مقدارش درون آن نگهداری میشود، ما تنها خود مقادیر مربوط به یک نمونه را درون یک آرایه که مخصوص به همان نمونه است نگهداری میکنیم. پس اینجا دیگر هیچکدام از سربارهای مربوط به دیکشنری را نخواهیم داشت.
حالا این نگاشتی که این بالا گذاشتهایم چطوری ساخته شده است؟ با Descriptor ها. کد زیر (که آن را از اینجا برداشتهام) پیادهسازی پایتونی چیزی است که در زبان پایتون واقعاً اتّفاق میافتد (پایتون خودش به زبان C نوشته شده است. پس طبیعتاً همچین چیزی را درون خود کدهای آن پیدا نمیکنید).
class Member(object): 'Descriptor implementing slot lookup' def __init__(self, i): self.i = i def __get__(self, obj, type=None): return obj._slotvalues[self.i] def __set__(self, obj, value): obj._slotvalues[self.i] = value class Type(type): 'Metaclass that detects and implements _slots_' def __new__(self, name, bases, namespace): slots = namespace.get('_slots_') if slots: for i, slot in enumerate(slots): namespace[slot] = Member(i) original_init = namespace.get('__init__') def __init__(self, *args, **kwds): 'Create _slotvalues list and call the original __init__' self._slotvalues = [None] * len(slots) if original_init is not None: original_init(self, *args, **kwds) namespace['__init__'] = __init__ return type.__new__(self, name, bases, namespace) class Object(object): __metaclass__ = Type class A(Object): _slots_ = 'x', 'y'
در این کد ما برای هر شئ یک ویژگی به نام slotvalues_
ساختهایم. این مقدار خودش یک لیست است که طولش برابر تعداد رشتههایی است که درون __slots__
تعریف کردهایم. به علاوه در خط ۱۶ ما داریم تمام رشتههایی که درون __slots__
تعریف شده اند را به عنوان فیلدهای تعریف شده برای این کلاس مشخّص میکنیم. مقدار هرکدام را هم برابر یک نمونه از Descriptor خودمان، یعنی Member
میگذاریم.
حالا درون این Descriptor ما ایندکس متناظر با هرکدام از مقادیر موجود در __slots__
را ذخیره میکنیم و درون توابع __set__
و __get__
با استفاده از آن ایندکس، به مقدار مورد نظر دسترسی پیدا میکنیم.
یعنی ما در حقیقت داریم تمام ویژگی (attribute) های کلاس را تبدیل به Descriptor میکنیم (اگر نمیدانی Descriptor ها چی هستند، با کلیک روی این نوشته به کاملترین آموزش Descriptor های پایتون در عالم هستی برو).
با استفاده از __ slots __ چقدر حافظهی کمتری مصرف میشود؟
پاسخ این سؤال برای هر کلاس متفاوت است. بیایید با هم همین کد سادهای که تا اینجا دیدیم را بررسی کنیم.
برای اینکه حافظهی مصرفی توسّط این کلاسها را بررسی کنیم، میتوانیم از ماژول tracemalloc استفاده کنیم.
برای این کار کافی است که کد سادهی زیر را بنویسیم:
import tracemalloc tracemalloc.start() list_of_objects = [] for i in range(10000): list_of_objects.append(SimpleClass()) print(tracemalloc.get_traced_memory()) tracemalloc.stop()
در این کد ابتدا بررسی میزان حافظه را با فراخوانی start
شروع میکنیم. سپس ده هزار نمونه از کلاس SimpleClass
میسازیم و در پایان میزان حافظهی مصرف شده را چاپ میکنیم.
خروجی میشود این:
(1922708, 1922756)
اوّلین مقدار این تاپل حجم بلوکهای حافظه در حال حاضر است و دومی بیشترین حجمی که این بلوکها در طول اجرای برنامه داشته اند.
حالا بیایید ببینم اگر این کد را برای کلاس دیگرمان که از __slots__
استفاده میکند اجرا کنیم خروجی چه میشود:
(807588, 807636)
همانطوری که میبینید مقدار حافظهی مصرفی حدوداً ۵۷٪ کاهش داشته. یعنی به جای اینکه برنامهی ما ۱٫۸ مگابایت حافظه مصرف کند، تنها ۷۰۰ کیلوبایت مصرف حافظه داشته است.
حالا شما فرضکنید که همین کار در یک برنامهی بزرگ با تعداد زیادی شئ چقدر بهبود در مصرف حافظه ایجاد میکند.
چرا __ slots __ باعث میشود که برنامه سریعتر اجرا شود؟
ما قبلاً در توضیح Descriptor ها ترتیب جست و جو برای پیداکردن یک ویژگی را دیدم. وقتی شما چیزی را با گذاشتن علامت .
صدا میکنید، متد __getattribute__
ابتدا سعی میکند که یک data descriptor با آن نام را پیدا کند. اگر نشد میرود سراغ یک ویژگی معمولی با آن اسم و همینطور این روند را برای تمام حالات ممکن ادامه میدهد.
حالا ما داریم تمام ویژگیها را به data descriptor تبدیل میکنیم. پس دیگر لازم نیست این زنجیره تا انتها تکرار شود و در همان مرحلهی اوّل به مقداری که میخواهیم میرسیم.
این کار باعث میشود که سرعت اجرای برنامه افزایش پیدا کند.
با استفاده از __ slots __ چقدر سرعت دسترسی به ویژگیها افزایش پیدا میکند؟
این هم باز به کد شما و سیستمی که دارد روی آن برنامه را اجرا میشود بستگی دارد. بیایید یک امتحان کوچک بکنیم.
برای این کار میتوانیم از ماژول timeit استفاده کنیم.
import timeit times = [] for i in range(1000): timer = timeit.Timer("instance.x; instance.y; instance.z", setup="instance=SimpleClass()", globals={"SimpleClass": SimpleClass}) times.append(timer.timeit(1000000)) print(sum(times) / len(times))
کاری که ما میکنیم خیلی ساده است. در خط ۵ یک Timer
ساختهایم. کدی که قرار است هر بار اجرا بشود را باید به شکل یک رشته به عنوان پارامتر اوّل این Timer داد. کاری هم که ما میخواهیم بکنیم این است که به ویژگیهای x
و y
و z
دسترسی پیدا کنیم.
پارامتر دوم که اسمش setup
است، کدی است که قرار است پیش از اجرای چندبارهی پارامتر اوّل اجرا بشود. ما در اینجا صرفاً یک متغیّر به نام instance
از نوع SimpleClass
تعریف کردهایم.
در بخش globals
هم باید کدهای خارجی، در اینجا یعنی چیزهایی که خارج از دو تا رشتهی اوّل تعریف شده اند، را با یک نام دلخواه به Timer معرّفی کنیم. من هم گفتهام که هرجا کلمهی SimpleClass
را نوشتهایم، منظورم همان کلاسی است که قبل از تعریف timer
آن را نوشتهام.
حالا در خط ۶ ما به timer
میگوییم که یک میلیون بار این کدی که در خط قبل گفتیم را اجرا کن و زمان اجرایش را (برای یک میلیون اجرا) به صورت یک عدد فلوت در مبنای ثانیه برگردان.
حالا چون وضعیت رایانه ثابت نیست و در هر لحظه ممکن است پردازندههای مختلفی در حال اجرا باشند و زمانهای متفاوتی به برنامهی ما برای اجرا روی پردازنده اختصاص داده شود، تمام این فرآیند را هزار بار تکرار کردهایم و آخر سر میانگین آن را چاپ کردهایم.
0.10347388921301172
این زمان تقریبی یک میلیون بار اجرای کد (هر بار به ۳ ویژگی دسترسی پیداکردهایم) روی دستگاه من است.
حالا بیایید همین کار را برای LittleSlot
بکنیم:
0.09551146111519483
همانطوری که میبیند زمان اجرای برنامه حدوداً ۷٪ سریعتر است. همانطوری که گفتم این مقادیر بسیار متغیّر اند. یعنی اینکه ممکن است در بعضی کدها تا ۳۰٪ هم بهبود سرعت داشته باشید.
مشکلاتی که هنگام استفاده از __ slots __ پیش میآیند
خب تا الان درمورد خوبیهای استفاده از __slots__
حرف زدیم. حالا وقت آن است که در مورد مشکلات استفاده از آن هم حرف بزنیم.
ارثبری
یکی از مشکلاتی که موقع کار با __slots__
به وجود میآید هنگامی است که ما میخواهیم در کدمان ارثبری داشته باشیم.
مثلاً کد زیر را درنظر بگیرید:
class GreatSlot: __slots__ = ["a", "b"] def __init__(self, a, b): self.a = a self.b = b class LittleSlot(GreatSlot): def __init__(self, c, d): self.c = c self.d = d super().__init__(c / 2, d / 2)
ما یک کلاس به نام GreatSlot
داریم که مقدار __slots__
در آن تعریف شده است. حالا در کلاس __LittleSlot__
از آن ارثبری کردهایم. حالا آیا رفتاری که به خاطر وجود __slots__
در کلاس پدر قرار داشته هم به فرزنش به ارث رسیده؟
خب بیایید امتحان کنیم:
my_little_slot = LittleSlot(6, 8) print(my_little_slot.__dict__) print(my_little_slot.__slots__)
اگر رفتار پدر به فرزند به ارث رسیده باشد این کد باید خطا بدهد. چون نباید مقدار __dict__
اصلاً وجود داشته باشد.
پس بیایید کد را اجرا کنیم:
{'c': 6, 'd': 8} ['a', 'b']
همانطوری که میبینید شئ ساخته شده از کلاس LittleSlot
ویژگی __dict__
را دارد. با وجود اینکه اگر مقدار __slots__
آن را چاپ کنیم مقدار __slots__
پدر را دریافت میکنیم، ولی دیگر در این کلاس خبری از آن رفتار نیست.
برای اینکه رفتار مدّنظر را داشته باشیم باید در خود کلاس تمامی ویژگیها را درون __slots__
تعریف کنیم. یعنی اینکه ما مجبوریم هر بار همهچیز را از نو بنویسم:
class LittleSlot(GreatSlot): __slots__ = ['a', 'b', 'c', 'd'] def __init__(self, c, d): self.c = c self.d = d super().__init__(c / 2, d / 2)
حالا دیگر __dict__
وجود ندارد و ما رفتاری که از __slots__
انتظار داشتیم را میبینم.
برای اینکه کد تکراری ننویسیم، میتوانیم __slots__
فرزند را اینطوری هم تعریف کنیم:
class LittleSlot(GreatSlot): __slots__ = ['c', 'd'] + GreatSlot.__slots__ def __init__(self, c, d): self.c = c self.d = d super().__init__(c / 2, d / 2)
فقط حواستان باشد که شما میتوانید هر مقدار iterableی را به عنوان مقدار __slots__
قرار بدهید. چون ما اینجا از لیست استفاده کردهایم این سینتکس قابل استفاده است.
میمونبازی ممنوع!
ما در حالت عادی میتوانیم با monkey patching در هنگام اجرای برنامه به اشیاء ویژگی (attribute) هایی که در کلاس تعریف نشده اند را نسبت بدهیم.
مثلاً کد زیر را ببینید:
class MonkeyFriendlyClass: def __init__(self): self.n = 10 too_much_monkey_business = MonkeyFriendlyClass() too_much_monkey_business.a_new_attribute = 100 print(too_much_monkey_business.a_new_attribute)
ما درون تعریف کلاس هیچ اسمی از a_new_attribute
نیاوردهایم. ولی بعد از ساختن یک نمونه از آن ما گفتهایم که از این به بعد a_new_attribute
هم یکی از ویژگیهای این نمونه است و باید مقداری برابر با ۱۰۰ داشته باشد.
این ویژگی صرفاً متعلّق به متغیّر too_much_monkey_business
است. یعنی اگر یک نمونهی دیگر از کلاس MonkeyFriendlyClass
بسازیم دیگر ویژگیای به نام a_new_attribute
ندارد.
در حقیقت ما مقدار جدید را داریم درون همان __dict__
مخصوص به این نمونه (instance) از کلاس MonkeyFriendlyClass
ذخیره میکنیم.
حالا وقتی که داریم از __slots__
استفاده میکنیم دیگر __dict__
وجود ندارد و ما نمیتوانیم همچین کارهایی انجام بدهیم.
البته میتوانیم این مشکل را به شکلی حل کنیم. کافی است که خود __dict__
را هم درون مقادیر __slots__
قرار بدهیم:
class LittleSlot: __slots__ = ['c', 'd', '__dict__'] def __init__(self, c, d): self.c = c self.d = d my_little_slot = LittleSlot(6, 8) my_little_slot.new_value = 1000 print(my_little_slot.new_value)
حالا این کد بدون هیچ مشکلی کار میکند و میتوانیم به میمونبازی خودمان بپردازیم. هرچند کلاً این کارها آنقدرها درست نیستند. چون اضافهکردن همان دیکشنری باعث میشود که دوباره همان مشکلات سابق برگردند. حالا کمی یواشتر از قبل.
The form you have selected does not exist.
خب این هم تمام چیزهایی که باید درمورد استفاده از __slots__
بدانیم. هرچند استفاده از __slots__
یک راه حل اساسی برای مشکل کارآیی برنامه نیست، ولی استفاده از آن خیلی جاها به ما کمک میکند که برنامههای بهتری بنویسیم.
یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.