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

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

چطور با __ slots __ کلاس‌های پایتون را سریع‌تر و کم‌حجم‌تر کنیم؟

موضوعات: پایتون,
چطور با __ 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 __ باعث می‌شود که برنامه حجم کمتری را اشغال کند؟

ما وقتی در حالت عادی چند نمونه از یک کلاس می‌سازیم، اتّفاقی که می‌افتد شبیه به تصویر زیر است:

ساختار object پایتون هنگام استفاده از __dict__

هر نمونه (instance) یک رفرنس به کلاسی که از آن ساخته شده است نگهداری می‌کند. به علاوه یک ویژگی (Attribute) به نام __dict__ هم دارد که به یک دیکشنری اشاره می‌کند. داخل این دیکشنری هرکدام از ویژگی‌های کلاس به صورت یک زوج key/value نگهداری می‌شود.

امّا وقتی که داریم از __slots__ استفاده می‌کنیم ساختار کاملاً با این فرق می‌کند.

ساختار object پایتون هنگام استفاده از 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)

حالا این کد بدون هیچ مشکلی کار می‌کند و می‌توانیم به میمون‌بازی خودمان بپردازیم. هرچند کلاً این کارها آنقدرها درست نیستند. چون اضافه‌کردن همان دیکشنری باعث می‌شود که دوباره همان مشکلات سابق برگردند. حالا کمی یواش‌تر از قبل.

خب این هم تمام چیزهایی که باید درمورد استفاده از __slots__ بدانیم. هرچند استفاده از __slots__ یک راه حل اساسی برای مشکل کارآیی برنامه نیست، ولی استفاده از آن خیلی جاها به ما کمک می‌کند که برنامه‌های بهتری بنویسیم.

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

دیدگاهتان را بنویسید

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

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

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

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