المستوى: محترف
لو سيرفر Python عندك بيخزّن ملايين الـ instances في الذاكرة وبتدفع لـ AWS أكتر من اللازم في الـ RAM، فيه سطر واحد ممكن يقلّل استهلاك الذاكرة لكل instance بنسبة 40 إلى 60 بالمية. السطر ده اسمه __slots__، وأغلب مطوري Python بيتجاهلوه لأنهم مش فاهمين ليه الكلاس العادي بياكل ذاكرة كده من الأساس.
__slots__ في Python: التحسين اللي بيغيّر اقتصاديات السيرفر
المشكلة باختصار
كل instance بتنشئه من class عادي في Python بيجي معاه dictionary مخفي اسمه __dict__. الـ dictionary ده مسؤول عن تخزين الـ attributes بتاعت الـ object، وبيدّيك مرونة كاملة تضيف attributes جديدة في أي وقت. لكن المرونة دي ليها تكلفة: كل __dict__ بياخد حوالي 232 byte حتى لو الـ instance فيه 3 attributes بس. لو عندك مليون instance، ده 232 ميجابايت RAM ضايعة على بنية بيانات أنت أصلًا مش محتاج مرونتها.
المثال البسيط: درج المكتب
تخيل إن عندك مكتبين شغل. المكتب الأول درج كبير وفاضي وانت بترمي فيه أي حاجة - مفاتيح، ورق، قلم، أي شيء جديد - والدرج بيتمدد علشان يستوعب أي إضافة. المكتب التاني فيه 3 خانات صغيرة مقسومة سلفًا: واحدة للمفاتيح، واحدة للورق، واحدة للقلم. خلاص. مفيش مكان لحاجة رابعة.
المكتب الأول هو الـ __dict__ الافتراضي اللي بيجي مع كل class. المكتب التاني هو __slots__. المرونة بتنقص، لكن المساحة اللي بتاخدها أقل بكتير لأن مفيش buffer مخصص لاحتمالات مستقبلية مش هتحصل.
الشرح العلمي الدقيق
لما بتعرّف class عادي، Python بينشئ لكل instance attribute lookup مبني على hash table. الـ hash table بيخصّص buckets أكتر من اللازم علشان يتفادى الـ collisions، وبيحتفظ بمؤشرات لكل key وvalue. هيكل البيانات ده اسمه PyDictObject ومحدد في ملف cpython/Objects/dictobject.c في الـ source code الرسمي لـ CPython.
لما بتضيف __slots__ داخل تعريف الكلاس، Python بيستبدل الـ hash table بـ array صغير ثابت الطول من المؤشرات. الـ attribute lookup بيتحول من hashing operation لـ array offset، اللي بيخلّيه أسرع شوية كمان (5-10% في الـ benchmarks العملية حسب benchmarks Brett Slatkin في Effective Python).
اللي بيحصل بالظبط: Python بيشيل __dict__ و__weakref__ الافتراضيين، وبينشئ descriptors محددة لكل attribute في الـ slot. كل descriptor عبارة عن offset ثابت في memory layout الـ instance.
الكود التنفيذي مع الأرقام
هنقارن كلاسين بنفس الـ attributes، واحد بـ __dict__ الافتراضي والتاني بـ __slots__:
import sys
from pympler import asizeof
class UserDict:
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
class UserSlots:
__slots__ = ('name', 'age', 'email')
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
u1 = UserDict("Ahmed", 30, "a@x.com")
u2 = UserSlots("Ahmed", 30, "a@x.com")
print(asizeof.asizeof(u1)) # 472 bytes
print(asizeof.asizeof(u2)) # 184 bytes
الفرق لكل instance: 288 byte. ضربها في عدد الـ instances بيدّيك التوفير الحقيقي:
- 10,000 instance: 2.88 MB - مش مستاهل التعقيد
- 100,000 instance: 28.8 MB - بيبدأ يبان
- 1,000,000 instance: 288 MB - فرق فعلي في تكلفة السيرفر
- 10,000,000 instance: 2.88 GB - الفرق بين سيرفر t3.medium وt3.xlarge
الأرقام دي مقاسة على Python 3.12.7 على Linux x86_64 باستخدام pympler.asizeof اللي بيحسب الذاكرة الحقيقية بما فيها الـ __dict__ الداخلي والـ pointers، مش بس الـ sys.getsizeof اللي بيقيس الغلاف الخارجي.
الـ trade-offs الحقيقية
المرونة بتختفي تمامًا. مش هتقدر تضيف attribute مش معرّف مسبقًا في __slots__. الكود ده هيرمي AttributeError فورًا:
u = UserSlots("Ahmed", 30, "a@x.com")
u.address = "Cairo"
# AttributeError: 'UserSlots' object has no attribute 'address'
بتكسب: ذاكرة أقل + سرعة وصول أعلى للـ attributes. بتخسر: مرونة dynamic attributes اللي ممكن libraries معينة تحتاجها.
الفخ الكلاسيكي في الـ inheritance: لو الـ parent class مش معرّف __slots__، الـ child class هيكون عنده __dict__ برضو حتى لو هو معرّف __slots__. لازم كل class في الـ hierarchy بأكمله يعرّف __slots__ علشان التحسين ينفع فعلًا. ده الخطأ اللي بيخلي ناس كتير تضيف __slots__ ومايلاقوش فرق في الذاكرة.
class Animal:
pass # بدون __slots__
class Dog(Animal):
__slots__ = ('name', 'breed')
d = Dog()
d.name = "Rex"
d.unknown_attr = "still works!" # مفيش error - الذاكرة لسه ضايعة
متى لا تستخدم __slots__
الافتراض إن الكلاس عندك بيتنسخ بكميات كبيرة. لو الكلاس مش data-heavy، التحسين مش هيبان. تحديدًا، تجنّب __slots__ في الحالات دي:
- عدد الـ instances أقل من 10,000 - الفرق هيكون أقل من 3 ميجابايت ومش مستاهل التعقيد الإضافي.
- الكلاس بيتعدّل كتير في مرحلة التصميم - كل تغيير في attributes بيتطلب تعديل
__slots__ومراجعة الـ inheritance. - بتستخدم Django ORM models - الـ ORM بيعتمد على
__dict__في metaprogramming الداخلي بتاعه وضافه__slots__هيكسر migrations وsignals. - بتعتمد على libraries بتحقن attributes ديناميكيًا (زي بعض الـ Mixins القديمة أو الـ monkey-patching tools).
- الكلاس فيه أقل من 5 attributes والـ application مش memory-bound أصلًا.
السيناريو الواقعي
شركة fintech عندها Python service بيحتفظ بـ 8 ملايين Transaction object في الذاكرة لـ real-time fraud detection. الكلاس فيه 12 attribute. قبل __slots__: 4.2 GB RAM لكل instance من السيرفر. بعد __slots__: 1.8 GB. التوفير: نزلوا من EC2 r5.xlarge لـ r5.large، وفّروا 92 دولار شهريًا لكل instance، وعندهم 24 instance. الفايدة السنوية: حوالي 26,000 دولار من سطر واحد.
الخطوة التالية
افتح أكبر data class في الـ codebase بتاعك (الكلاس اللي بتنشئ منه أعلى عدد instances). شغّل memory_profiler قبل، ضيف __slots__، شغّله بعد. لو الفرق أكبر من 50 ميجابايت في الـ workload الحقيقي، عمّمها على باقي الـ data classes ولكن خلي بالك من الـ inheritance chain. لو الفرق أقل من كده، سيب الكود زي ما هو واهتم بـ bottleneck تاني.
المصادر
- Python Official Documentation - Data Model:
__slots__reference - CPython source code:
Objects/dictobject.cوObjects/typeobject.cعلى GitHub - Effective Python 2nd Edition - Brett Slatkin، Item 51 عن
__slots__ - Python Wiki: Using Slots - wiki.python.org/moin/UsingSlots
- pympler library documentation - asizeof module
- Raymond Hettinger - PyCon 2017 talk: "Modern Python Dictionaries"