يتطلب مستوى: مبتدئ
المكدّس والكومة: ليه المتغيّر المحلي بيموت والكائن بيفضل عايش؟
لو عدّلت نسخة جوه دالة ولقيت الأصل بره اتغيّر، أو ضربت RecursionError فجأة، السبب واحد: مكان تخزين البيانات في الذاكرة. بعد المقال ده هتفرّق بين المكدّس (Stack) والكومة (Heap) في دقيقة، وتعرف تتوقّع سلوك كودك قبل ما تشغّله.
المشكلة باختصار
كل برنامج بيشتغل بيقسّم الذاكرة لمنطقتين رئيسيتين. المتغيّرات المحلية والمكالمات بين الدوال بتعيش في المكدّس. الكائنات الأكبر (القوائم، القواميس، النصوص الطويلة) بتعيش في الكومة. عدم فهم الفرق ده بيسبب باجات محيّرة: قيمة اتغيّرت من غير سبب واضح، أو برنامج اتعلّق من استدعاء متكرر.
مثال بسيط الأول: طاولة المطبخ والمخزن
خلّينا نقرّب الصورة بمثال ملموس. المكدّس زي طاولة صغيرة في المطبخ بتشتغل عليها دلوقتي. كل ما تبدأ خطوة جديدة بتحط ورقة فوق الطاولة، وأول ما تخلّص الخطوة بترمي الورقة اللي فوق فورًا. الطاولة صغيرة، سريعة، ومنظّمة: آخر ورقة دخلت أول ورقة تطلع.
الكومة زي مخزن كبير في آخر البيت. لما يبقى عندك حاجة كبيرة وعايزها تفضل موجودة بعد ما تخلص الخطوة الحالية، بتحطها في المخزن وبتكتب عنوانها على ورقة صغيرة على الطاولة. الورقة بتموت مع نهاية الخطوة، لكن الكرتونة في المخزن بتفضل لحد ما محدش يفضل مسكها.
وبعدين علميًا: إيه اللي بيحصل فعلاً
لما دالة تتنادى، البرنامج بيبني ليها إطار (stack frame) فوق المكدّس فيه متغيّراتها المحلية وعنوان الرجوع. الإطار ده بيتشال تلقائيًا لحظة خروج الدالة. عشان كده المتغيّر المحلي عمره قصير: بيموت مع نهاية الدالة.
لكن لما تعمل كائن زي قائمة، الكائن نفسه بيتخزّن على الكومة. المتغيّر اللي على المكدّس بيمسك عنوان الكائن، مش الكائن نفسه. لو أكتر من متغيّر مسكوا نفس العنوان، أي تعديل بيظهر عند الجميع. وده بالظبط مصدر الباج الشهير في الصورة الجاية.
مثال تنفيذي تجرّبه بنفسك
الكود ده بيوضّح النقطتين مع بعض: المتغيّر المحلي بيختفي، والكائن المشترك بيتعدّل عند الأصل.
def add_tax(prices):
# prices متغير محلي على المكدّس، لكنه يمسك عنوان القائمة على الكومة
prices.append(round(prices[-1] * 0.14, 2)) # تعديل نفس الكائن
total = sum(prices) # total محلي، سيموت مع الدالة
return total
cart = [100, 200]
print(add_tax(cart)) # 342.28
print(cart) # [100, 200, 28.0] <-- الأصل اتغيّر!
# total غير موجود هنا؛ مات مع خروج الدالة
القيمة total عاشت وماتت على المكدّس. لكن cart وprices كانوا بيمسكوا نفس العنوان على الكومة، فالتعديل ظهر بره. لو غيّرت السطر لـ new = prices + [x] هتعمل كائن جديد على الكومة، والأصل مش هيتغيّر.
الفرق الجوهري بالأرقام
تخصيص متغيّر على المكدّس هو مجرد تحريك مؤشّر، عملية بزمن ثابت O(1) وأسرع بكتير من تخصيص الكومة اللي بيمر على مُخصِّص ذاكرة. لكن المكدّس محدود: في كثير من الأنظمة حجمه بين 1 و8 ميجابايت لكل خيط فقط. الكومة أكبر بمراحل وبتكبر حسب الحاجة.
مثال واقعي: لو عندك دالة بتنادي نفسها بعمق كبير، هتصطدم بحد المكدّس. في بايثون الحد الافتراضي 1000 استدعاء متداخل، تقدر تتأكد منه بـ import sys; sys.getrecursionlimit(). تعدّي الحد ده بترمي RecursionError مش لأن المنطق غلط، لكن لأن المكدّس امتلأ.
الـ trade-off اللي لازم تعرفه
المكدّس بيكسبك سرعة وتنظيف تلقائي، بتخسر معاه الحجم المحدود وقصر العمر. الكومة بتكسبك مرونة وعمر طويل للكائن، بتخسر معاها زمن تخصيص أعلى وحاجة لتتبّع المراجع. الافتراض هنا إنك بتشتغل بلغة مُدارة الذاكرة زي بايثون أو جافاسكريبت، فجامع القمامة بيحرّر كائنات الكومة لما محدش يفضل مسكها.
متى لا تشغّل بالك بالتفصيل ده
لو شغلك اليومي كود تطبيقات عادي بلغة مُدارة، مش محتاج تخصّص ذاكرة يدويًا ولا تفكّر في العناوين. الفرق بيبقى مهم فعلاً في ثلاث حالات: لما تقابل RecursionError أو Stack Overflow، لما قيمة بتتغيّر من غير سبب واضح بسبب مرجع مشترك، أو لما تكتب بلغة زي C/C++ بتدير الذاكرة بنفسك. غير كده، اكتفِ بالفهم ولا تفرط في التحسين المبكّر.
الخطوة التالية
افتح مفسّر بايثون دلوقتي وشغّل الكود اللي فوق، وبعدين غيّر prices.append(...) لـ إنشاء قائمة جديدة وقارن نتيجة print(cart). بعدها اطبع sys.getrecursionlimit() وجرّب دالة تنادي نفسها بعمق أكبر منه عشان تشوف حد المكدّس بعينك.
المصادر
- توثيق بايثون الرسمي —
sys.getrecursionlimit: https://docs.python.org/3/library/sys.html#sys.getrecursionlimit - إدارة الذاكرة في CPython (الكومة والمُخصِّص): https://docs.python.org/3/c-api/memory.html
- Stack-based memory allocation — Wikipedia: https://en.wikipedia.org/wiki/Stack-based_memory_allocation
- Memory management (Heap) — Wikipedia: https://en.wikipedia.org/wiki/Memory_management