> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# الاستدعاء الذاتي Recursion: كيف تفكر فيه ومتى تتجنبه
- URL: https://arabroot.io/articles/الاستدعاء-الذاتي-recursion-للمبتدئين/
- Published: 2026-10-30T22:25:53.000Z
- Updated: 2026-10-30T23:22:07.000Z
- Description: كيف تكتب دالة تستدعي نفسها لشجرة تعليقات أو مجلدات، وما الذي يحدث في ال Stack، وإلى أي عمق تصل Python وNode.js بالقياس، ومتى تكون الحلقة هي الحل الأنسب.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, أساسيات الحاسب والبرمجة

لنفرض أنك تكتب صفحة التعليقات في مدونتك أو في متجرك، والمطلوب بسيط في ظاهره: كل تعليق يمكن أن يرد عليه القراء، وكل رد يمكن أن يرد عليه قارئ آخر، وهكذا دون حد معروف. ثم يطلب منك أن تعرض بجانب كل تعليق عدد الردود تحته، وأن ترسم الردود بإزاحة تزيد مع كل مستوى. وهنا تكتشف أن الحلقة Loop العادية لا تكفي، فأنت لا تعرف كم حلقة متداخلة تكتب: حلقتين؟ خمس؟ والحل الذي يصل إليه أغلب المبرمجين بعد قليل من التفكير هو أن تكتب دالة تعد التعليق ثم تستدعي نفسها على كل رد، وهذا هو الاستدعاء الذاتي Recursion. والمشكلة نفسها تظهر لك بأشكال أخرى في عملك اليومي: مجلد فيه مجلدات فيها مجلدات وتريد حجمها الكلي، وقائمة تصنيفات في متجر لها تصنيفات فرعية، وملف JSON يرسله لك عميل فيه كائنات داخل كائنات.

والاستدعاء الذاتي من الأفكار التي تبدو سحرية في البداية، فالدالة تستدعي نفسها قبل أن تنتهي، فكيف تعرف متى تتوقف؟ ومن أين تأتي بالإجابة؟ ثم بعد أن تفهمه تكتشف أن له ثمناً: ذاكرة لكل استدعاء، وحداً للعمق يختلف من لغة لأخرى، وأحياناً حسابات تتكرر آلاف المرات دون أن تنتبه. وفي [مقال الذاكرة للمبرمجين](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86-stack-%D9%88-heap-%D9%88%D8%A7%D9%84%D9%85%D8%A4%D8%B4%D8%B1%D8%A7%D8%AA/#stack) رأينا أن ال Stack محدود الحجم وأن الاستدعاء الذاتي العميق يملؤه، وفي هذا المقال سوف ننزل إلى التفاصيل: كيف تفكر في الحل بطريقة Recursive، وكم عمقاً تتحمله Python وNode.js بالقياس الفعلي، ولماذا يتغير هذا الرقم، ومتى يكون الحل الأفضل أن تتركه وتكتب حلقة.

وسوف نناقش في هذا المقال ما يلي:

- ما هو الاستدعاء الذاتي، والجزآن اللذان تحتاجهما كل دالة Recursive: الحالة الأساسية Base Case والحالة التكرارية Recursive Case.
- ماذا يحدث في الذاكرة عند كل استدعاء، وكيف تتراكم إطارات الاستدعاء Stack Frames ثم تنحل، بمخرج حقيقي من برنامج التعليقات.
- كيف تفكر بطريقة Recursive دون أن تتبع كل استدعاء في رأسك.
- حدود العمق بالقياس: الخطأ RecursionError في Python والخطأ RangeError في Node.js، وأثر حجم الإطار والخيار `--stack-size`، وما حدث عندما رفعناه أكثر من اللازم.
- لماذا يقرأ `json.loads` ملفاً متداخلاً بعمق 5,000 رغم أن حد Python هو 1000، وحادثة موثقة في مكتبة Jackson بسبب JSON متداخل.
- الحفظ المؤقت Memoization بالقياس، واستدعاء الذيل Tail Call ولماذا لا تحوله Python ولا Node.js إلى حلقة.
- متى تختار الحلقة أو Stack تديره بنفسك، مع أمثلة حقيقية: المرور على الشجرة، والترتيب بالدمج Merge Sort، والملء بالفيضان Flood Fill.
- الأخطاء الشائعة، وجدول قرار، وقائمة فحص قبل أن تكتب دالة Recursive.

## ما هو الاستدعاء الذاتي Recursion؟

الاستدعاء الذاتي هو أن تحل الدالة المشكلة بأن تستدعي نفسها على نسخة أصغر من المشكلة نفسها، ثم تبني إجابتها من إجابة النسخة الأصغر. و[تعريف MDN](https://developer.mozilla.org/en-US/docs/Glossary/Recursion?ref=arabroot.io) مختصر جداً: «The act of a function calling itself»، أي أن تستدعي الدالة نفسها، ثم يضيف مباشرة أن الاستدعاء الذاتي يحتاج إلى شرط خروج حتى لا يستمر إلى ما لا نهاية.

والتشبيه الذي يساعدك هنا هو الدمى الروسية: تفتح الدمية فتجد داخلها دمية أصغر بنفس الشكل، وتفتحها فتجد أصغر منها، حتى تصل إلى دمية صغيرة لا تفتح، وعندها تتوقف. وشجرة التعليقات بنفس الشكل تماماً، فالرد على التعليق هو نفسه تعليق، له نص وله ردود، والرد على الرد تعليق أيضاً. فإذا عرفت كيف تعد تعليقاً واحداً مع ردوده، فقد عرفت كيف تعد أي فرع من الشجرة مهما كان عمقه، لأن كل فرع شجرة أصغر من نفس النوع. والصورة التالية تبين هذه الفكرة على شجرة التعليقات التي سوف نستخدمها في المختبر:

![شجرة تعليقات من ستة تعليقات: التعليق الأول «المقال ممتاز» له ردان «أتفق» و«عندي سؤال»، وتحت «أتفق» رد واحد، وتحت «عندي سؤال» رد «تفضل» وتحته «شكراً»، وكل فرع محاط بإطار منقط يقول إنه شجرة أصغر من نفس النوع، وبجانب كل تعليق رقم العد الذي ترجعه الدالة count](https://arabroot.io/content/images/2026/10/recursion-01-comments-tree.webp)

كل رد هو تعليق كامل له ردوده، فالدالة التي تعد التعليق تعد أي فرع بنفس الطريقة

وقد يتساءل البعض: لماذا لا أكتب حلقات متداخلة وأنتهي؟ والإجابة أن عدد الحلقات المتداخلة يجب أن تعرفه وأنت تكتب الكود، أما عمق الردود فيعرفه القراء وهم يكتبون، فإذا كتبت ثلاث حلقات متداخلة فسوف يأتي قارئ يرد على الرد الثالث ويضيع رده من العد. والاستدعاء الذاتي يحل هذه المشكلة لأن عدد الاستدعاءات المتداخلة يتحدد أثناء التشغيل حسب البيانات نفسها، وليس أثناء كتابة الكود.

## الحالة الأساسية Base Case والحالة التكرارية Recursive Case

كل دالة Recursive صحيحة فيها جزآن، ولا تعمل إذا نقص أحدهما:

- **الحالة الأساسية Base Case:** وهي الحالة الصغيرة التي تعرف إجابتها مباشرة دون أن تستدعي نفسها، مثل تعليق ليس له ردود فعدده 1، أو قائمة فيها عنصر واحد فهي مرتبة. وهذه هي الدمية التي لا تفتح، ووجودها هو ما يوقف الاستدعاءات.
- **الحالة التكرارية Recursive Case:** وهي التي تقسم فيها المشكلة إلى نسخة أصغر أو أكثر، وتستدعي الدالة نفسها عليها، ثم تجمع النتائج. والشرط المهم هنا أن تكون النسخة الأصغر أقرب فعلاً إلى الحالة الأساسية، وإلا فلن تصل إليها أبداً.

وفي دالة عد التعليقات التي سوف نراها بعد قليل لاحظ أن الحالة الأساسية لا تظهر كسطر `if` مستقل، وإنما هي ضمنية في الحلقة: إذا كانت قائمة الردود فارغة فلن تدخل الحلقة ولن تستدعي الدالة نفسها، وترجع 1 مباشرة. وهذا شائع في الدوال التي تمر على الأشجار، ولكن عندما تتعلم لأول مرة فالأفضل أن تكتب الحالة الأساسية صريحة في أول الدالة كما في `fib` و`merge_sort` لاحقاً، والسبب أن القارئ يجب أن يرى من النظرة الأولى أين تتوقف الدالة.

والخطأ الكلاسيكي هو أن تنسى الحالة الأساسية أو تكتبها بشرط لا يتحقق أبداً، مثل دالة تعد تنازلياً بخطوة 2 من رقم فردي وشرط التوقف `n == 0`، فتقفز فوق الصفر إلى السالب وتستمر حتى يمتلئ ال Stack. والصورة التالية تبين الجزأين، وما يحدث عندما تغيب الحالة الأساسية:

![على اليمين دالة countdown فيها صندوق أخضر للحالة الأساسية if n == 0 return acc وصندوق برتقالي للحالة التكرارية return countdown(n - 1, acc + n)، وتحتها سلسلة استدعاءات countdown(3) ثم 2 ثم 1 ثم 0 تتوقف عند علامة صح خضراء، وعلى اليسار نفس السلسلة بشرط n == 0 مع خطوة 2 تبدأ من 3 فتمر على 1 ثم -1 ثم -3 بلا نهاية حتى تصل إلى علامة خطأ حمراء وRecursionError](https://arabroot.io/content/images/2026/10/recursion-02-base-case.webp)

الحالة الأساسية هي ما يوقف الاستدعاءات، والحالة التكرارية يجب أن تقترب منها في كل خطوة

## تجهيز المختبر Lab: Python وNode.js داخل Docker

كل الأمثلة في هذا المقال شغلناها فعلاً، والمخرجات المنسوخة هنا هي المخرجات الحقيقية. ولكي تحصل على نفس الأرقام تقريباً، نشغل أمثلة Python داخل Container من ال Image الرسمية `python:3.13.17-slim` (وهي مبنية على Debian 13)، وأمثلة JavaScript داخل `node:22-alpine` وفيها Node.js 22.23.4\. والملفات مقسمة إلى مجلدين `lab/python` و`lab/node`، وفي كل مجلد سكربت `run.sh` يشغل الأمثلة بالترتيب، والأمر التالي يشغل أمثلة Python من مجلد المختبر:

```bash
docker run --rm --name scp2-rec-py -v "$PWD:/lab:ro" python:3.13.17-slim sh /lab/python/run.sh
```

والأمر التالي يشغل أمثلة Node.js:

```bash
docker run --rm --name scp2-rec-node -v "$PWD:/lab:ro" node:22-alpine sh /lab/node/run.sh
```

في الأمرين أعلاه لاحظ التالي:

- الخيار `--rm` يحذف ال Container بعد انتهاء السكربت، فلا يبقى شيء على جهازك بعد التجربة.
- الخيار `-v "$PWD:/lab:ro"` يربط مجلد المختبر داخل ال Container للقراءة فقط، فالأمثلة لا تكتب شيئاً على جهازك.
- الأرقام الخاصة بالوقت تختلف من جهاز لآخر ومن تشغيل لآخر، أما أرقام العمق وعدد الاستدعاءات فثابتة كما سوف نرى، لأنها تعتمد على الإصدار وحجم ال Stack وليس على سرعة المعالج.

⚠️

إذا كنت تستخدم Git Bash على Windows، فأول تشغيل للأمر قد يعطيك الخطأ `sh: 0: cannot open C:/Program Files/Git/lab/python/run.sh: No such file`، وهذا ما ظهر لنا فعلاً. والسبب أن Git Bash يرى المسار `/lab/python/run.sh` كمسار Linux فيحوله إلى مسار Windows داخل مجلد Git قبل أن يصل إلى Docker. والحل أن تضع `MSYS_NO_PATHCONV=1` قبل الأمر، فيصل المسار كما هو، أو أن تشغل الأمر من PowerShell أو من WSL.

## ماذا يحدث في الذاكرة؟ إطارات الاستدعاء Stack Frames

لنبدأ ببرنامج التعليقات نفسه، والملف التالي يبني شجرة من ستة تعليقات ثم يعدها بالدالة `count`، ويطبع كل دخول إلى الدالة وكل خروج منها مع إزاحة تساوي العمق، ثم يعرض الشجرة بالدالة `show`:

```python
# Nested comments: each comment has replies, and each reply can have replies.
# count() and show() walk the tree recursively; trace=True prints every call entering and leaving.
comments = {
    "text": "المقال ممتاز", "replies": [
        {"text": "أتفق", "replies": [
            {"text": "وأنا كذلك", "replies": []},
        ]},
        {"text": "عندي سؤال", "replies": [
            {"text": "تفضل", "replies": [
                {"text": "شكراً", "replies": []},
            ]},
        ]},
    ],
}

def count(node, depth=0, trace=False):
    pad = "  " * depth
    if trace:
        print(f"{pad}enter count({node['text']!r}) depth={depth}")
    total = 1                       # this comment
    for reply in node["replies"]:   # base case: no replies -> loop does nothing
        total += count(reply, depth + 1, trace)
    if trace:
        print(f"{pad}leave count({node['text']!r}) -> {total}")
    return total

def show(node, depth=0):
    print("    " * depth + "- " + node["text"])
    for reply in node["replies"]:
        show(reply, depth + 1)

print("total:", count(comments, trace=True))
print()
show(comments)
```

والمخرج سوف يكون كما يلي:

```text
enter count('المقال ممتاز') depth=0
  enter count('أتفق') depth=1
    enter count('وأنا كذلك') depth=2
    leave count('وأنا كذلك') -> 1
  leave count('أتفق') -> 2
  enter count('عندي سؤال') depth=1
    enter count('تفضل') depth=2
      enter count('شكراً') depth=3
      leave count('شكراً') -> 1
    leave count('تفضل') -> 2
  leave count('عندي سؤال') -> 3
leave count('المقال ممتاز') -> 6
total: 6

- المقال ممتاز
    - أتفق
        - وأنا كذلك
    - عندي سؤال
        - تفضل
            - شكراً
```

في المخرج أعلاه لاحظ التالي:

- السطر الأول يدخل إلى `count` للتعليق الأعلى بعمق 0، ولكنه لا يخرج منها إلا في السطر قبل الأخير، فالاستدعاء الأول هو آخر استدعاء ينتهي، وهذا هو ترتيب «آخر من يدخل أول من يخرج» الذي يعمل به ال Stack.
- عند «وأنا كذلك» بعمق 2 كانت هناك ثلاث نسخ من `count` تعمل في نفس اللحظة، وكل نسخة لها متغيراتها الخاصة: `node` و`depth` و`total`. فالمتغير `total` في نسخة «أتفق» لا علاقة له بالمتغير `total` في نسخة «المقال ممتاز»، رغم أن الاسم واحد.
- «وأنا كذلك» ليس له ردود، فلم تدخل الحلقة ورجعت الدالة 1 مباشرة، وهذه هي الحالة الأساسية الضمنية التي ذكرناها.
- «أتفق» رجعت 2، أي نفسها مع ردها، ثم أضاف الاستدعاء الأعلى هذا الرقم إلى `total` الخاص به، فالإجابة تبنى من الأسفل إلى الأعلى أثناء الرجوع وليس أثناء النزول.
- أعمق نقطة وصلنا إليها هي «شكراً» بعمق 3، أي أربع نسخ من الدالة في الذاكرة في نفس اللحظة، وهذا الرقم هو ما يهمنا عندما نتحدث عن حدود العمق، وليس عدد التعليقات الكلي.

والآن ماذا يحدث داخل البرنامج؟ عند كل استدعاء لدالة، أي دالة وليس فقط الدوال Recursive، يحجز البرنامج إطار استدعاء Stack Frame فيه معاملات الدالة ومتغيراتها المحلية ومكان الرجوع، أي السطر الذي سوف يكمل منه البرنامج عندما تنتهي الدالة. ويضع هذا الإطار فوق مكدس الاستدعاءات Call Stack، وعندما ترجع الدالة يزال إطارها ويكمل البرنامج من مكان الرجوع في الإطار الذي تحته. والدالة Recursive لا تختلف في شيء عن غيرها هنا، إلا أن الإطارات المتراكمة كلها لنفس الدالة. والصورة التالية تبين ال Call Stack في أربع لحظات من تشغيل البرنامج أعلاه:

![أربع لقطات متتالية لمكدس الاستدعاءات: في الأولى إطار واحد count المقال ممتاز مع depth=0 وtotal=1، وفي الثانية فوقه إطار أتفق ثم إطار وأنا كذلك بعمق 2، وفي الثالثة يخرج إطار وأنا كذلك ويرجع 1 بسهم أخضر إلى إطار أتفق فيصبح total=2، وفي الرابعة أعمق نقطة بأربعة إطارات حتى شكراً بعمق 3، وفوق كل لقطة رقم السطر المقابل من المخرج](https://arabroot.io/content/images/2026/10/recursion-03-call-stack.webp)

كل استدعاء يضيف إطاراً فوق ال Stack بمتغيراته الخاصة، والقيمة ترجع إلى الإطار الذي تحته عندما تنتهي الدالة

وأنت تشاهد هذا ال Stack كل يوم دون أن تنتبه، فرسالة الخطأ Traceback في Python أو Stack Trace في JavaScript هي قائمة الإطارات الموجودة في ال Stack لحظة الخطأ، ولهذا عندما يفشل استدعاء Recursive عميق ترى السطر نفسه يتكرر في الرسالة مئات المرات. وتطبع Python في هذه الحالة السطر مرات قليلة ثم تختصر الباقي بسطر مثل `[Previous line repeated 996 more times]`، وهذا ما ظهر لنا عندما شغلنا دالة بلا حالة أساسية في نفس ال Container، فالرسالة لا تملأ الشاشة ولكنها تخبرك أن نفس الإطار تكرر قرابة ألف مرة.

## كيف تفكر بطريقة Recursive؟ ثق بالاستدعاء الأصغر

الخطأ الذي يقع فيه أغلب من يتعلم الاستدعاء الذاتي هو أن يحاول تتبع كل الاستدعاءات في رأسه، فينزل مع الاستدعاء الأول ثم الثاني ثم الثالث حتى يضيع. وهذه الطريقة تنفع لمثال صغير مثل المخرج أعلاه، ولكنها لا تصلح لكتابة الكود. والطريقة الأفضل تشبه الاستقراء الرياضي Mathematical Induction الذي درسته في المدرسة، وتطبيقها على أي مشكلة يكون بثلاثة أسئلة:

1. **ما هي أصغر نسخة من المشكلة أعرف إجابتها مباشرة؟** تعليق بلا ردود عدده 1، ومجلد فارغ حجمه 0، وقائمة فيها عنصر واحد مرتبة. هذه هي الحالة الأساسية.
2. **كيف أقسم المشكلة إلى نسخ أصغر من نفس النوع؟** التعليق هو نفسه زائد ردوده، والمجلد هو ملفاته زائد مجلداته الفرعية، والقائمة هي نصفها الأيسر ونصفها الأيمن.
3. **إذا افترضت أن الدالة تعطيني الإجابة الصحيحة للنسخ الأصغر، كيف أبني منها إجابة النسخة الكاملة؟** هنا تثق بالاستدعاء الأصغر ولا تتبعه، فتكتب: عدد التعليق يساوي 1 زائد مجموع أعداد ردوده، دون أن تسأل كيف حسبت الدالة عدد كل رد.

وقد يبدو لك السؤال الثالث غشاً، فكيف تفترض أن الدالة تعمل وأنت ما زلت تكتبها؟ والإجابة أن هذا الافتراض صحيح بشرطين، الأول أن تكون الحالة الأساسية صحيحة، والثاني أن يقترب كل استدعاء منها فعلاً. فإذا كانت الدالة صحيحة للتعليقات التي ليس لها ردود، وكانت صحيحة لأي تعليق بشرط أن تكون صحيحة لردوده، فهي صحيحة للتعليقات التي لها ردود بلا ردود، ثم للمستوى الذي فوقها، وهكذا حتى أعلى الشجرة. وهذا هو المقصود بعبارة «ثق بالاستدعاء الأصغر» Leap of Faith التي تسمعها في دروس البرمجة.

ولنطبق الأسئلة الثلاثة على مشكلة جديدة، وهي حساب الحجم الكلي لمجلد. أصغر نسخة هي الملف، وحجمه معروف من نظام الملفات. والمجلد ينقسم إلى عناصره، وكل عنصر إما ملف وإما مجلد أصغر من نفس النوع. وإذا افترضت أن الدالة تعطيك حجم كل مجلد فرعي بشكل صحيح، فحجم المجلد هو مجموع أحجام ملفاته وأحجام مجلداته الفرعية. وبهذا الشكل نكون قد كتبنا الدالة في ثلاث جمل قبل أن نكتب سطر كود واحد، وتحويلها إلى Python أو JavaScript هو الجزء السهل. ولاحظ أن Python فيها [os.walk](https://docs.python.org/3/library/os.html?ref=arabroot.io#os.walk) التي تمر على المجلدات لك، فلا تكتب هذه الدالة بنفسك في مشروع حقيقي إلا إذا كان عندك سبب، ولكن فهمها هو ما يجعلك تفهم ما تفعله `os.walk`.

## إلى أي عمق يصل الاستدعاء الذاتي؟ حدود العمق Recursion Depth بالقياس

شجرة التعليقات في المثال عمقها 4، ولكن ماذا لو كتب مستخدم سكربتاً يرد على رده خمسين ألف مرة؟ أو أرسل لك JSON فيه قائمة داخل قائمة مئة ألف مرة؟ هنا يصبح السؤال عملياً: كم إطاراً يتحمل ال Stack قبل أن يفشل البرنامج؟ والإجابة تختلف بين Python وNode.js اختلافاً كبيراً، لأن كل واحدة تضع الحد بطريقة مختلفة، فلنقس كل واحدة على حدة.

### Python: حد العمق Recursion Limit والخطأ RecursionError

تضع Python حداً لعدد الإطارات المتداخلة، تقرؤه بالدالة `sys.getrecursionlimit()` وتغيره بالدالة `sys.setrecursionlimit()`، والملف التالي يقيس العمق الذي تصل إليه دالة تستدعي نفسها دون حالة أساسية، بالحد الافتراضي ثم بثلاثة حدود أعلى:

```python
# How deep can a Python function recurse before RecursionError?
import sys, platform

print("python", platform.python_version(), "| recursion limit:", sys.getrecursionlimit())

def down(n):
    global deepest
    deepest = n
    return down(n + 1)

deepest = 0
try:
    down(1)
except RecursionError as e:
    print("RecursionError:", e, "| deepest call reached:", deepest)

for limit in (10_000, 100_000, 1_000_000):
    sys.setrecursionlimit(limit)
    deepest = 0
    try:
        down(1)
    except RecursionError:
        pass
    print(f"limit {limit:>9,} -> deepest {deepest:>9,}")
```

والمخرج سوف يكون كما يلي:

```text
python 3.13.17 | recursion limit: 1000
RecursionError: maximum recursion depth exceeded | deepest call reached: 999
limit    10,000 -> deepest     9,999
limit   100,000 -> deepest    99,999
limit 1,000,000 -> deepest   999,999
```

في المخرج أعلاه لاحظ التالي:

- الحد الافتراضي 1000، والدالة وصلت إلى العمق 999 وليس 1000، والسبب أن الحد يعد كل الإطارات في ال Stack، ومنها إطار البرنامج الرئيسي نفسه الذي استدعى `down(1)`، فبقي للدالة 999 إطاراً.
- الخطأ [RecursionError](https://docs.python.org/3/library/exceptions.html?ref=arabroot.io#RecursionError) استثناء عادي يمكنك التقاطه بـ `try/except` كما فعلنا، وهو بحسب التوثيق مشتق من `RuntimeError`، فالبرنامج لم ينهر وإنما أكمل إلى الأسطر التالية.
- عندما رفعنا الحد إلى عشرة آلاف ثم مئة ألف ثم مليون، وصلت الدالة كل مرة إلى الحد ناقص واحد بالضبط دون أن ينهار البرنامج، وهذا قد يفاجئك إذا كنت قرأت تحذيرات قديمة بأن رفع الحد يسبب انهيار Python، فلنفهم لماذا.

توثيق [sys.setrecursionlimit](https://docs.python.org/3/library/sys.html?ref=arabroot.io#sys.setrecursionlimit) يقول إن هذا الحد «prevents infinite recursion from causing an overflow of the C stack and crashing Python»، أي أنه يمنع الاستدعاء اللانهائي من ملء ال Stack الخاص بلغة C التي كتب بها مفسر Python ومن انهيار البرنامج، ويحذر من أن الحد العالي جداً قد يؤدي إلى الانهيار. وهذا كان صحيحاً تماماً في الإصدارات القديمة، حيث كان كل استدعاء لدالة Python يستدعي دالة C داخل المفسر، فكل إطار Python يأخذ معه إطاراً حقيقياً في C Stack. ولكن في Python 3.11 تغير هذا، وبحسب [صفحة ما الجديد في 3.11](https://docs.python.org/3/whatsnew/3.11.html?ref=arabroot.io#inlined-python-function-calls) أصبح المفسر عندما تستدعي دالة Python دالة Python أخرى ينشئ إطاراً جديداً وينتقل إليه دون أن يستدعي دالة C، فأصبحت أغلب الاستدعاءات «consume no C stack space»، أي لا تستهلك شيئاً من C Stack، وذكرت الصفحة أن هذا يعني أن الدوال Recursive يمكن أن تصل إلى عمق أكبر بكثير إذا رفعت الحد. ثم في 3.12، كما في [صفحة ما الجديد في 3.12](https://docs.python.org/3/whatsnew/3.12.html?ref=arabroot.io)، أصبح الحد يطبق على كود Python فقط، أما الدوال المبنية في اللغة Builtin Functions المكتوبة بـ C فتحميها آلية أخرى منفصلة.

لذلك وصلنا إلى مليون إطار دون انهيار، والإطارات هنا يحجزها المفسر في ذاكرة يديرها بنفسه وليس في C Stack. ولكن هذا لا يعني أنها مجانية، وهذا ما يقيسه المثال التالي.

### ماذا يحدث إذا رفعت الحد بـ sys.setrecursionlimit إلى مليون؟

هذه تجربة «ماذا يحدث إذا…» الأولى. سوف نرفع الحد إلى مليون، ثم نجرب نوعين من الاستدعاء: الأول تستدعي فيه الدالة نفسها عبر الدالة المبنية `map()`، أي أن كل مستوى يمر بكود C، والثاني استدعاء Python مباشر كما في المثال السابق، ونقيس الذاكرة القصوى التي استخدمها البرنامج Peak RSS بالوحدة [resource](https://docs.python.org/3/library/resource.html?ref=arabroot.io):

```python
# What happens if we raise the limit a lot and every call also passes through C code?
# Here each level calls the next one through the builtin map(), so it uses the C stack too.
import sys, resource
print("RSS MB at start:", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024)
sys.setrecursionlimit(1_000_000)

deepest = 0
def through_c(n):
    global deepest
    deepest = n
    return list(map(through_c, [n + 1]))

try:
    through_c(1)
except RecursionError as e:
    print("RecursionError:", e, "| deepest:", deepest)

# Pure Python frames with the same limit: how much memory do 999,999 frames take?
def down(n):
    global deepest
    deepest = n
    return down(n + 1)
deepest = 0
try:
    down(1)
except RecursionError:
    pass
print("pure python deepest:", deepest, "| peak RSS MB:", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024)
```

والمخرج سوف يكون كما يلي:

```text
RSS MB at start: 7
RecursionError: maximum recursion depth exceeded | deepest: 5000
pure python deepest: 999999 | peak RSS MB: 287
```

في المخرج أعلاه لاحظ التالي:

- بدأ البرنامج بذاكرة 7 MB، وهذه ذاكرة المفسر نفسه قبل أي استدعاء.
- الدالة التي تمر عبر `map()` توقفت عند العمق 5000 بالضبط مع `RecursionError`، رغم أن الحد مليون. فكل مستوى هنا يدخل إلى كود C حقيقي، وهذا يأخذ من C Stack، والآلية المنفصلة التي ذكرتها صفحة 3.12 أوقفت البرنامج قبل أن يمتلئ C Stack فعلاً. فالحد الذي تضعه بـ `setrecursionlimit` لا يكفي وحده لمعرفة العمق الذي سوف تصل إليه، لأن ذلك يعتمد على ما تمر به الدالة في الطريق.
- الدالة المباشرة وصلت إلى 999,999 إطاراً، والذاكرة القصوى 287 MB، أي أن قرابة مليون إطار أخذت نحو 280 MB، وهذا يعني نحو 290 بايت لكل إطار في هذه الدالة الصغيرة التي فيها متغير واحد. فإذا كانت دالتك تعمل داخل سيرفر يستقبل طلبات كثيرة في نفس الوقت، فكل طلب يصل إلى هذا العمق يأخذ هذه الذاكرة.

والدرس العملي أن رفع الحد في Python الحديثة لم يعد يسبب الانهيار بنفس السهولة القديمة، ولكنه يؤجل المشكلة ولا يحلها، فالذاكرة تنمو مع العمق، وأي استدعاء يمر بكود C يتوقف عند حد آخر لا تتحكم فيه بنفس الدالة. ولا ينصح برفع الحد كحل لبيانات يتحكم المستخدم في عمقها، والحل الأنسب لها هو الحلقة مع Stack تديره بنفسك كما سوف نرى.

### ملفات JSON المتداخلة: json.loads فيها استدعاء ذاتي أيضاً

قد تقول إنك لا تكتب دوال Recursive أصلاً، فلماذا تهمك هذه الحدود؟ والإجابة أن المكتبات التي تستخدمها كل يوم تكتبها لك، وأشهرها قارئ JSON، فالقيمة في JSON قد تكون قائمة فيها قيم، وكل قيمة قد تكون قائمة، وهذا نفس شكل شجرة التعليقات، والطريقة الطبيعية لقراءته دالة تستدعي نفسها عند كل قائمة أو كائن. والملف التالي يبني نصوص JSON فيها قوائم متداخلة بأعماق مختلفة ويقرؤها بـ [json.loads](https://docs.python.org/3/library/json.html?ref=arabroot.io):

```python
# A user sends deeply nested JSON. json.loads and repr are recursive too (in C).
import json, sys
print("recursion limit:", sys.getrecursionlimit())
for depth in (500, 900, 1_000, 5_000, 100_000):
    text = "[" * depth + "]" * depth
    try:
        json.loads(text)
        print(f"depth {depth:>7,}: parsed")
    except RecursionError as e:
        print(f"depth {depth:>7,}: RecursionError: {e}")
```

والمخرج سوف يكون كما يلي:

```text
recursion limit: 1000
depth     500: parsed
depth     900: parsed
depth   1,000: parsed
depth   5,000: parsed
depth 100,000: RecursionError: maximum recursion depth exceeded while decoding a JSON array from a unicode string
```

وهنا مفاجأة صغيرة واجهتنا أثناء التجربة، فقد توقعنا أن يفشل العمق 1,000 لأن الحد 1000، ولكن القارئ قرأ حتى العمق 5,000 دون مشكلة. والسبب هو نفسه الذي رأيناه في المثال السابق، فقارئ JSON في CPython مكتوب بـ C، وبعد 3.12 لا يخضع للحد الذي تضعه `setrecursionlimit` وإنما للآلية المنفصلة الخاصة بكود C. ولكنه في النهاية توقف عند المئة ألف برسالة تشرح بالضبط ما كان يفعله: «while decoding a JSON array». والنقطة المهمة هنا أن Python رمت استثناء يمكن التقاطه، فالسيرفر الذي يقرأ هذا الطلب يستطيع أن يرد بخطأ 400 ويكمل، أما إذا انهارت العملية كلها فكل طلب من هذا النوع يوقف خدمتك، وهذا ما حدث فعلاً في مكتبة مشهورة.

🧱

في مارس 2022 نشرت قاعدة بيانات الثغرات في GitHub التحذير [GHSA-57j2-w4cx-62h2](https://github.com/advisories/GHSA-57j2-w4cx-62h2?ref=arabroot.io) المسجل باسم CVE-2020-36518 لمكتبة jackson-databind، وهي أشهر مكتبة لقراءة JSON في Java، ونصه أن المكتبة تسمح بخطأ Java Stack Overflow وبحجب الخدمة Denial of Service «via a large depth of nested objects»، أي عبر كائنات متداخلة بعمق كبير. فالمكتبة كانت تقرأ الكائنات المتداخلة بدالة Recursive، والخطأ `StackOverflowError` في Java من نوع `Error` الذي لا يتوقع أن تلتقطه، فكان طلب واحد صغير فيه أقواس متداخلة كافياً لإيقاف معالجة الطلب. وأصلحت المكتبة المشكلة في الإصدارين 2.12.6.1 و2.13.2.1 كما في التحذير، وتفاصيل الإصلاح في [التذكرة رقم 2816](https://github.com/FasterXML/jackson-databind/issues/2816?ref=arabroot.io) التي عنوانها تحسين الكلاس `UntypedObjectDeserializer` من ناحية الاستدعاء الذاتي. والدرس أن كل قارئ لبيانات يتحكم فيها المستخدم يجب أن يكون له حد للعمق يرجع خطأ واضحاً، قبل أن تصل إلى حد ال Stack.

### Node.js: الخطأ RangeError وحجم ال Stack

في Node.js لا يوجد حد بعدد الإطارات كما في Python، وإنما حد بحجم ذاكرة ال Stack نفسه بالكيلوبايت، يضعه محرك V8 الذي يشغل JavaScript، والدالة تستمر حتى تمتلئ هذه المساحة. والملف التالي يقيس العمق لدالتين تستدعيان نفسيهما بنفس الطريقة، الأولى `small` فيها معامل واحد، والثانية `big` فيها ستة عشر متغيراً محلياً تستخدمها في القيمة المرجعة:

```javascript
// How deep can a JavaScript function recurse in Node.js before RangeError?
// small(): one argument. big(): the same recursion with many local variables, so each frame is larger.
let deepest = 0;
function small(n) {
  deepest = n;
  return small(n + 1) + 1;
}
function big(n) {
  const a = n + 1, b = a * 2, c = b * 3, d = c - 4, e = d + 5, f = e * 6, g = f - 7, h = g + 8;
  const i = h * 2, j = i - 1, k = j + 3, l = k * 2, m = l - 9, o = m + 1, p = o * 3, q = p - 2;
  deepest = n;
  return big(n + 1) + a + b + c + d + e + f + g + h + i + j + k + l + m + o + p + q;
}
for (const fn of [small, big]) {
  deepest = 0;
  try { fn(1); } catch (err) {
    console.log(`${fn.name.padEnd(5)} ${err.name}: ${err.message} | deepest: ${deepest}`);
  }
}
```

وسكربت `run.sh` يطبع أولاً الإعداد الافتراضي لحجم ال Stack كما يعرضه V8 نفسه، وحجم Stack الذي يعطيه نظام التشغيل للبرنامج بالأمر `ulimit -s`، ثم يشغل الملف بالحجم الافتراضي:

```bash
#!/bin/sh
# Runs every Node.js example of the article; depth.js with the default stack and with --stack-size.
cd /lab/node
echo "=== node $(node --version)"
node --v8-options | grep -A1 -- "  --stack-size"
echo "ulimit -s: $(ulimit -s) KB"
echo "=== node depth.js"; node depth.js; echo "exit code: $?"
for kb in 492 2000 7800 20000; do
  echo "=== node --stack-size=$kb depth.js"; node --stack-size=$kb depth.js; echo "exit code: $?"
done
echo "=== node memo.js"; node memo.js; echo "exit code: $?"
echo "=== node tail.js"; node tail.js; echo "exit code: $?"
```

والمخرج الخاص بالجزء الأول سوف يكون كما يلي:

```text
=== node v22.23.4
  --stack-size (default size of stack region v8 is allowed to use (in kBytes))
        type: int  default: --stack-size=984
ulimit -s: 8192 KB
=== node depth.js
small RangeError: Maximum call stack size exceeded | deepest: 10462
big   RangeError: Maximum call stack size exceeded | deepest: 4483
exit code: 0
```

في المخرج أعلاه لاحظ التالي:

- V8 يسمح لنفسه افتراضياً بـ 984 كيلوبايت من ال Stack، وهذا الرقم أقل من 1 MB بقليل، وهو حد يضعه V8 لنفسه وليس حداً من نظام التشغيل.
- نظام التشغيل يعطي الخيط الرئيسي Main Thread مساحة 8192 كيلوبايت، أي 8 MB، فهناك مساحة أكبر بكثير مما يستخدمه V8 فعلاً، وسوف نعود إلى هذا الفرق بعد قليل.
- الدالة `small` وصلت إلى العمق 10,462، وهو رقم أعلى من حد Python الافتراضي بعشر مرات تقريباً، والخطأ هو `RangeError` بالرسالة «Maximum call stack size exceeded»، وهو استثناء عادي التقطناه بـ `try/catch`. و[صفحة MDN لهذا الخطأ](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Too%5Fmuch%5Frecursion?ref=arabroot.io) تذكر أن نفس المشكلة تظهر في Firefox باسم آخر هو `InternalError: too much recursion`، فالاسم يختلف بين المحركات والسبب واحد.
- الدالة `big` وصلت إلى 4,483 فقط، أي أقل من النصف، رغم أن ال Stack هو نفسه.
- شغلنا الملف ثلاث مرات متتالية، وكان العمق في كل مرة 10,462 و4,483 بالضبط، فهذه الأرقام ثابتة لنفس الإصدار ونفس الإعداد.

### حجم الإطار Frame Size: لماذا تقلل المتغيرات المحلية العمق؟

الفرق بين 10,462 و4,483 هو أهم ما في هذه التجربة، لأنه يبين أن العمق في Node.js ليس رقماً ثابتاً تحفظه، وإنما نتيجة قسمة: مساحة ال Stack مقسومة على حجم الإطار. فالدالة `big` تحتاج أن تحفظ متغيراتها الستة عشر في الإطار لأنها تستخدمها بعد أن يرجع الاستدعاء الداخلي، فإطارها أكبر، فيمتلئ ال Stack بعدد أقل من الإطارات. ولو قسمنا الأرقام تقريبياً: 984 كيلوبايت على 10,462 إطاراً يعطي نحو 96 بايت لكل إطار في `small`، وعلى 4,483 يعطي نحو 225 بايت لكل إطار في `big`. وهذه أرقام تقريبية، لأن المحرك قد يغير حجم الإطار حسب ما إذا كانت الدالة تعمل بالمفسر أو بعد أن يحولها المترجم JIT إلى كود آلة، ولكن الاتجاه واضح.

وهذا يعني شيئاً عملياً: الدالة Recursive التي عملت بعمق 8,000 في اختبارك قد تفشل بعد أن يضيف زميلك إليها بضعة متغيرات أو استدعاء دالة مساعدة، دون أن يتغير شيء في البيانات. لذلك لا تعتمد على أن «الحد عشرة آلاف تقريباً» في JavaScript، وإذا كان العمق قريباً من آلاف المستويات فهذه علامة على أن الحلقة هي الحل الأنسب.

### ماذا يحدث إذا رفعت --stack-size؟ خطأ التجزئة Segmentation Fault

وهذه تجربة «ماذا يحدث إذا…» الثانية. الخيار `--stack-size` من خيارات V8 التي يقبلها Node.js، وتجد قائمتها كاملة بالأمر `node --v8-options` كما تشرح [صفحة خيارات سطر الأوامر في Node.js](https://nodejs.org/api/cli.html?ref=arabroot.io)، ويغير المساحة التي يسمح بها V8 لنفسه. وشغلنا نفس الملف بأربعة أحجام: النصف 492، ثم 2000، ثم 7800 وهو أقل بقليل من 8192 التي يعطيها النظام، ثم 20000 وهو أكثر منها، فكان المخرج كما يلي:

```text
=== node --stack-size=492 depth.js
small RangeError: Maximum call stack size exceeded | deepest: 5214
big   RangeError: Maximum call stack size exceeded | deepest: 2234
exit code: 0
=== node --stack-size=2000 depth.js
small RangeError: Maximum call stack size exceeded | deepest: 21299
big   RangeError: Maximum call stack size exceeded | deepest: 9128
exit code: 0
=== node --stack-size=7800 depth.js
small RangeError: Maximum call stack size exceeded | deepest: 83166
big   RangeError: Maximum call stack size exceeded | deepest: 35642
exit code: 0
=== node --stack-size=20000 depth.js
Segmentation fault (core dumped)
exit code: 139
```

في المخرج أعلاه لاحظ التالي:

- العمق يتناسب مع حجم ال Stack تقريباً: النصف 492 أعطى 5,214، أي نصف 10,462 تقريباً، و2000 أعطى 21,299، أي الضعف تقريباً، و7800 أعطى 83,166\. وهذا يؤكد أن العمق في Node.js قسمة مساحة على حجم إطار.
- النسبة بين `small` و`big` بقيت قريبة من 2.3 في كل الأحجام، فحجم الإطار لا يتغير مع حجم ال Stack.
- عند 20000 لم نر `RangeError` أصلاً، وإنما توقفت العملية كلها، وطبع ال Shell الرسالة `Segmentation fault (core dumped)`، ورمز الخروج 139، وهو 128 زائد رقم الإشارة 11 أي `SIGSEGV`. وكود `catch` لم يعمل، والسطر الخاص بالدالة `big` لم يطبع.

وهذه هي المشكلة الحقيقية التي واجهناها في هذه التجربة، وسببها أن V8 يصدق الرقم الذي تعطيه إياه، فيظن أن أمامه 20000 كيلوبايت، بينما النظام أعطى الخيط الرئيسي 8192 فقط كما رأينا في `ulimit -s`. فاستمرت الدالة في النزول بعد نهاية ال Stack الحقيقي، ولمست صفحة ذاكرة غير مسموح بها، فأوقف النظام العملية بإشارة `SIGSEGV` قبل أن يصل V8 إلى حده الذي يرمي عنده `RangeError`. وقد شرحنا كيف يحمي النظام الصفحات غير المحجوزة في [مقال الذاكرة الافتراضية Virtual Memory](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D8%A7%D9%84%D8%A7%D9%81%D8%AA%D8%B1%D8%A7%D8%B6%D9%8A%D8%A9-virtual-memory-%D9%88%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B5%D9%81%D8%AD%D8%A7%D8%AA/). والفرق بين النتيجتين كبير جداً في سيرفر حقيقي: `RangeError` يفشل طلباً واحداً ويرد بخطأ، أما Segmentation Fault فيوقف العملية بكل الطلبات التي تعالجها، ولا يكتب شيئاً في سجلات التطبيق.

🛑

لا تقم برفع `--stack-size` في الإنتاج لتحل مشكلة دالة Recursive عميقة، والسبب أن الرقم الآمن يعتمد على حجم ال Stack الذي يعطيه النظام للخيط، وهو يختلف بين الأجهزة وبين الخيط الرئيسي وخيوط العمل Worker Threads، فإذا تجاوزته تحولت المشكلة من استثناء يمكن التقاطه إلى انهيار كامل للعملية. أعد كتابة الدالة بحلقة كما في قسم الحلقة مع Stack خاص بك.

والصورة التالية تجمع أرقام العمق من Python وNode.js في مكان واحد:

![أعمدة أفقية لعمق الاستدعاء المقاس: Python بالحد الافتراضي 999، وPython عبر map بحد مليون 5000، واستدعاء Python مباشر بحد مليون 999,999، وNode.js small بالحجم الافتراضي 984 كيلوبايت 10462 وbig 4483، ثم small مع stack-size 2000 21299 ومع 7800 83166، وفي الأسفل صف أحمر stack-size 20000 أكبر من ulimit 8192 مع علامة خطأ وSegmentation fault ورمز الخروج 139](https://arabroot.io/content/images/2026/10/recursion-04-depth-limits.webp)

في Python الحد بعدد الإطارات، وفي Node.js بحجم ال Stack، وتجاوز ما يعطيه النظام يتحول إلى انهيار كامل

## الحسابات المكررة والحفظ المؤقت Memoization

حتى الآن كانت المشكلة في عمق الاستدعاءات، وهناك مشكلة ثانية مختلفة تماماً وهي عددها. فشجرة التعليقات تمر على كل تعليق مرة واحدة، ولكن بعض المشاكل تقسم نفسها إلى نسخ أصغر متداخلة فيما بينها، فتحسب الدالة نفس النسخة مرات كثيرة. والمثال المشهور هو متتالية Fibonacci، حيث كل رقم هو مجموع الرقمين قبله، وقد شرحنا تعقيدها في [قسم الاستدعاء الذاتي في مقال الخوارزميات وتعقيد Big O](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%88%D8%A7%D8%B1%D8%B2%D9%85%D9%8A%D8%A7%D8%AA-%D9%88%D8%AA%D8%B9%D9%82%D9%8A%D8%AF-big-o-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#recursion)، وهنا سوف نقيس أثر الحفظ المؤقت في Python وJavaScript ونرى ما يكشفه كل قياس.

### كم استدعاء تحتاج fib(30)؟ شجرة الاستدعاءات Call Tree

الملف التالي يكتب الدالة بطريقتين، الأولى مباشرة مع عداد للاستدعاءات، والثانية مع المزخرف Decorator المسمى [functools.cache](https://docs.python.org/3/library/functools.html?ref=arabroot.io#functools.cache)، وهو بحسب التوثيق ذاكرة مؤقتة بسيطة بلا حد للحجم تسمى أحياناً «memoize»:

```python
# Naive Fibonacci versus the same function with functools.cache (memoization).
import time
from functools import cache

calls = 0
def fib(n):
    global calls
    calls += 1
    if n < 2:               # base case
        return n
    return fib(n - 1) + fib(n - 2)

@cache
def fib_memo(n):
    if n < 2:
        return n
    return fib_memo(n - 1) + fib_memo(n - 2)

for n in (20, 25, 30, 32):
    calls = 0
    t = time.perf_counter(); r = fib(n); dt = time.perf_counter() - t
    print(f"fib({n}) = {r:>8,}  calls {calls:>10,}  {dt*1000:9.1f} ms")

for n in (30, 90, 300):
    fib_memo.cache_clear()
    t = time.perf_counter(); r = fib_memo(n); dt = time.perf_counter() - t
    print(f"fib_memo({n}) = {r}  {fib_memo.cache_info()}  {dt*1000:.3f} ms")
```

والمخرج سوف يكون كما يلي:

```text
fib(20) =    6,765  calls     21,891        4.5 ms
fib(25) =   75,025  calls    242,785      112.4 ms
fib(30) =  832,040  calls  2,692,537     1221.3 ms
fib(32) = 2,178,309  calls  7,049,155     2840.3 ms
fib_memo(30) = 832040  CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)  0.027 ms
fib_memo(90) = 2880067194370816120  CacheInfo(hits=88, misses=91, maxsize=None, currsize=91)  0.090 ms
fib_memo(300) = 222232244629420445529739893461909967206666939096499764990979600  CacheInfo(hits=298, misses=301, maxsize=None, currsize=301)  0.324 ms
```

في المخرج أعلاه لاحظ التالي:

- fib(30) احتاجت 2,692,537 استدعاء وأكثر من ثانية، وزيادة n بمقدار 2 فقط من 30 إلى 32 ضاعفت الاستدعاءات أكثر من مرتين ونصف، وهذا النمو الأسي الذي يجعل fib(50) بهذه الطريقة غير عملية.
- العمق في كل هذه الاستدعاءات لم يتجاوز 30 أو 32، فالمشكلة هنا ليست ال Stack وإنما العدد، ولهذا لم نر أي `RecursionError`.
- مع `cache` حسبت fib(30) في 0.027 ms، والسطر `CacheInfo` يشرح السبب: `misses=31` أي أن الدالة حسبت فعلاً 31 قيمة فقط من 0 إلى 30، و`hits=28` أي أنها في 28 مرة وجدت النتيجة محفوظة فلم تنزل في الشجرة أصلاً.
- fib(300) رقم من 63 خانة، وحسبته Python بالضبط لأن الأعداد الصحيحة فيها بلا حد للحجم، وتذكر هذا الرقم لأننا سوف نقارنه مع JavaScript بعد قليل. ولاحظ أيضاً أن fib(300) مع الحفظ تحتاج عمقاً يقارب 300 إطار، فلو طلبت fib\_memo(5000) من أول مرة لوصلت إلى `RecursionError` رغم أن الحفظ حل مشكلة العدد.

والصورة التالية تبين لماذا تتكرر الحسابات في الطريقة المباشرة، وكيف يقطع الحفظ الفروع المكررة:

![على اليمين شجرة استدعاءات fib(5) الكاملة فيها 15 عقدة، وتظهر fib(3) مرتين وfib(2) ثلاث مرات بلون برتقالي مع كلمة مكرر، وعلى اليسار نفس الدالة مع cache حيث ينزل فرع n-1 فقط حتى fib(0) وfib(1) وكل فرع n-2 مكرر يصبح عقدة خضراء مكتوب بجانبها hit، تسعة استدعاءات منها ثلاثة من الذاكرة، وفي الأسفل أرقام المختبر fib(30) بدون حفظ 2,692,537 استدعاء و1221 ms ومع cache misses=31 و0.027 ms](https://arabroot.io/content/images/2026/10/recursion-05-fib-memo.webp)

بدون حفظ تحسب الدالة نفس القيمة في كل فرع، ومع الحفظ تحسب كل قيمة مرة واحدة وتأخذ الباقي من الذاكرة

### functools.cache في Python وMap في JavaScript

في JavaScript لا يوجد مزخرف جاهز للحفظ، فنكتبه بأنفسنا بكائن [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Map?ref=arabroot.io) يحفظ كل نتيجة بمفتاح n:

```javascript
// Naive Fibonacci versus the same function with a Map as a cache (memoization).
let calls = 0;
function fib(n) {
  calls++;
  if (n < 2) return n;            // base case
  return fib(n - 1) + fib(n - 2);
}
const memo = new Map();
function fibMemo(n) {
  if (n < 2) return n;
  if (memo.has(n)) return memo.get(n);
  const r = fibMemo(n - 1) + fibMemo(n - 2);
  memo.set(n, r);
  return r;
}
for (const n of [20, 25, 30, 35]) {
  calls = 0;
  const t = process.hrtime.bigint(); const r = fib(n);
  const ms = Number(process.hrtime.bigint() - t) / 1e6;
  console.log(`fib(${n}) = ${r}  calls ${calls}  ${ms.toFixed(1)} ms`);
}
for (const n of [35, 90]) {
  memo.clear();
  const t = process.hrtime.bigint(); const r = fibMemo(n);
  const ms = Number(process.hrtime.bigint() - t) / 1e6;
  console.log(`fibMemo(${n}) = ${r}  cached ${memo.size}  ${ms.toFixed(3)} ms`);
}
```

والمخرج سوف يكون كما يلي:

```text
fib(20) = 6765  calls 21891  6.0 ms
fib(25) = 75025  calls 242785  2.1 ms
fib(30) = 832040  calls 2692537  64.7 ms
fib(35) = 9227465  calls 29860703  809.9 ms
fibMemo(35) = 9227465  cached 34  0.280 ms
fibMemo(90) = 2880067194370816000  cached 89  0.066 ms
```

وفي هذا المخرج ثلاثة أشياء تستحق التوقف:

- **عدد الاستدعاءات هو نفسه في اللغتين:** 21,891 لـ fib(20) و2,692,537 لـ fib(30)، لأن العدد صفة في الخوارزمية نفسها وليس في اللغة، أما الوقت فمختلف كثيراً، فـ fib(30) أخذت 64.7 ms في Node.js مقابل 1221 ms في Python، لأن V8 يترجم الدالة إلى كود آلة أثناء التشغيل. ولكن لاحظ أن الحفظ في Python كان أسرع من أسرع نسخة بدون حفظ في Node.js بآلاف المرات، فتغيير الخوارزمية أهم من تغيير اللغة.
- **fib(25) أسرع من fib(20):** 2.1 ms مقابل 6.0 ms رغم أنها تحتاج استدعاءات أكثر بعشر مرات، والسبب أن المترجم JIT في V8 بدأ بتشغيل الدالة بالمفسر في fib(20)، ثم لاحظ أنها تستدعى كثيراً فترجمها إلى كود آلة محسن، فكانت جاهزة عندما بدأت fib(25). وهذا درس مهم في القياس عموماً: أول قياس في JavaScript قد لا يمثل السرعة الحقيقية، فشغل الكود عدة مرات قبل أن تبدأ القياس.
- **fib(90) في JavaScript خاطئة:** النتيجة 2880067194370816000، أما Python فأعطت 2880067194370816120، والفرق 120 في آخر الخانات. والسبب أن الأرقام في JavaScript من النوع `double`، وهذا النوع يحفظ الأعداد الصحيحة بدقة حتى 2 أس 53 فقط، أي نحو 9 مليون مليار، وfib(90) أكبر من ذلك، فقربها إلى أقرب رقم يستطيع حفظه. وقد شرحنا هذه المشكلة بالتفصيل في [قسم الأرقام الصحيحة الكبيرة في مقال الأرقام العشرية Floating Point](https://arabroot.io/articles/%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B4%D8%B1%D9%8A%D8%A9-floating-point-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/#big-integers)، والحل هنا أن تستخدم النوع `BigInt` إذا احتجت أرقاماً بهذا الحجم.

ومتى يفيد الحفظ؟ يفيد عندما تحقق الدالة شرطين: أن تكون نتيجتها تعتمد على معاملاتها فقط، فلا تقرأ الوقت ولا قاعدة البيانات ولا متغيراً عاماً يتغير، وأن تتكرر نفس المعاملات فعلاً. أما في شجرة التعليقات فكل تعليق يزار مرة واحدة، فالحفظ هناك لا يوفر شيئاً ويأخذ ذاكرة فقط. ولاحظ أيضاً أن `cache` بلا حد للحجم، فإذا كانت المعاملات كثيرة ومتنوعة فاستخدم [lru\_cache(maxsize=...)](https://docs.python.org/3/library/functools.html?ref=arabroot.io#functools.lru%5Fcache) التي تحذف أقدم النتائج عندما تمتلئ، وحدها الافتراضي 128.

## الاستدعاء الذاتي أم الحلقة Loop؟ قياس السرعة والعمق

كل دالة Recursive يمكن كتابتها بحلقة، والسؤال هو متى تستحق الكتابة بالحلقة جهدها. والملف التالي يقارن بين الطريقتين في مهمتين: الأولى جمع الأرقام من 1 إلى عشرة آلاف، وهي مهمة خطية لا تحتاج الاستدعاء الذاتي أصلاً، والثانية عد عناصر شجرة مجلدات على شكل سلسلة طويلة، كل مجلد فيه مجلد واحد، وهو أسوأ شكل للشجرة من ناحية العمق:

```python
# Same job three ways: sum 1..n recursively, with a loop, and walking a deep tree.
import sys, time
sys.setrecursionlimit(20_000)

def sum_rec(n):
    return 0 if n == 0 else n + sum_rec(n - 1)

def sum_loop(n):
    total = 0
    for i in range(1, n + 1):
        total += i
    return total

def bench(f, n, rounds=200):
    t = time.perf_counter()
    for _ in range(rounds):
        f(n)
    return (time.perf_counter() - t) / rounds * 1e6   # microseconds per call

n = 10_000
print(f"sum_rec({n})  = {sum_rec(n)}  {bench(sum_rec, n):8.0f} us")
print(f"sum_loop({n}) = {sum_loop(n)}  {bench(sum_loop, n):8.0f} us")

# A folder tree that is a long chain: each folder has one subfolder.
def make_chain(depth):
    root = {"name": "d0", "children": []}
    node = root
    for i in range(1, depth):
        child = {"name": f"d{i}", "children": []}
        node["children"].append(child)
        node = child
    return root

def count_rec(node):
    return 1 + sum(count_rec(c) for c in node["children"])

def count_stack(root):
    total, stack = 0, [root]        # our own stack, it lives on the heap
    while stack:
        node = stack.pop()
        total += 1
        stack.extend(node["children"])
    return total

for depth in (1_000, 50_000):
    tree = make_chain(depth)
    try:
        print(f"depth {depth:>6,}: count_rec   = {count_rec(tree)}")
    except RecursionError as e:
        print(f"depth {depth:>6,}: count_rec   -> RecursionError: {e}")
    print(f"depth {depth:>6,}: count_stack = {count_stack(tree)}")
```

والمخرج سوف يكون كما يلي:

```text
sum_rec(10000)  = 50005000      7533 us
sum_loop(10000) = 50005000      2149 us
depth  1,000: count_rec   = 1000
depth  1,000: count_stack = 1000
depth 50,000: count_rec   -> RecursionError: maximum recursion depth exceeded
depth 50,000: count_stack = 50000
```

في المخرج أعلاه لاحظ التالي:

- رفعنا الحد في أول الملف إلى 20,000 حتى تعمل `sum_rec(10000)` أصلاً، فبالحد الافتراضي كانت سوف تفشل عند 999.
- الحلقة أسرع بنحو ثلاث مرات ونصف في هذا التشغيل، 2149 ميكروثانية مقابل 7533، وفي تشغيل سابق لنفس الملف كان الفرق مرتين وثلث، 1877 مقابل 4323، فالرقم الدقيق يتغير مع ضغط الجهاز، ولكن الحلقة كانت الأسرع في كل مرة. والسبب أن كل استدعاء يكلف إنشاء إطار وتمرير المعاملات والرجوع منه، والحلقة لا تدفع هذا الثمن.
- عند العمق 1,000 أعطت الطريقتان نفس النتيجة، وعند 50,000 فشلت `count_rec` بالخطأ `RecursionError` لأن الحد 20,000، أما `count_stack` فعدت الخمسين ألفاً دون مشكلة.

### كيف تحول الاستدعاء الذاتي إلى حلقة مع Stack خاص بك؟

الدالة `count_stack` هي أهم ما في الملف أعلاه، لأنها الطريقة العامة لتحويل أي مرور على شجرة من الاستدعاء الذاتي إلى حلقة. والفكرة أن الاستدعاء الذاتي يستخدم Call Stack لكي يتذكر «أين كنت وماذا بقي»، وأنت تستطيع أن تحفظ نفس المعلومات في قائمة عادية تسميها `stack`: تضع فيها الجذر، ثم في كل دورة تأخذ آخر عنصر بـ `pop()` وتعالجه وتضيف أبناءه، وتكرر حتى تفرغ القائمة. وهذه القائمة تعيش في ال Heap كأي قائمة أخرى، فتكبر حتى حدود ذاكرة الجهاز وليس حتى حد ال Stack، كما شرحنا في [قسم المكدس Stack في مقال هياكل البيانات](https://arabroot.io/articles/%D9%87%D9%8A%D8%A7%D9%83%D9%84-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#stack). والصورة التالية تبين الطريقتين جنباً إلى جنب:

![على اليمين Call Stack في منطقة ال Stack المحدودة فيه إطارات count_rec فوق بعضها حتى يصل إلى سقف أحمر مكتوب عليه حد العمق وRecursionError عند 50,000، وعلى اليسار حلقة while stack فيها خطوات pop ثم total += 1 ثم extend children، وتحتها قائمة stack في ال Heap تحتوي على عنصر واحد في كل دورة، مع علامة صح ونتيجة count_stack = 50000](https://arabroot.io/content/images/2026/10/recursion-06-explicit-stack.webp)

الاستدعاء الذاتي يحفظ «ماذا بقي» في Call Stack المحدود، والحلقة تحفظه في قائمة داخل ال Heap

ولاحظ ثلاثة أشياء عند هذا التحويل. الأول أن ترتيب المرور يتغير: `pop()` تأخذ آخر ابن أضفته، فتمر الحلقة على الأبناء بالترتيب المعكوس، وإذا كان الترتيب يهمك، كما في عرض التعليقات بنفس ترتيب كتابتها، فأضف الأبناء معكوسين بـ `reversed()`. والثاني أن الحسابات التي تحتاج نتائج الأبناء قبل أن تكمل، مثل حجم المجلد الذي يحتاج أحجام مجلداته الفرعية، أصعب قليلاً في الحلقة، لأنك تحتاج أن تزور العنصر مرتين، مرة عند النزول ومرة بعد أن تنتهي أبناؤه، أو أن تجمع النتائج في قاموس منفصل. والثالث أنك إذا أخذت العنصر من أول القائمة بدلاً من آخرها، باستخدام `collections.deque` و`popleft()`، فقد حولت المرور من العمق أولاً Depth-First إلى العرض أولاً Breadth-First، أي مستوى بعد مستوى.

## استدعاء الذيل Tail Call: لماذا لا يحميك في Python وNode.js؟

إذا قرأت عن الاستدعاء الذاتي في لغات مثل Scheme أو Haskell فسوف تجد فكرة تحل مشكلة العمق: إذا كان الاستدعاء الذاتي هو آخر شيء تفعله الدالة، ولا يبقى بعده أي حساب، فلا داعي لحفظ إطار الدالة الحالية، ويمكن للمحرك أن يعيد استخدام نفس الإطار، فتصبح الدالة حلقة في الحقيقة. وهذا يسمى استدعاء الذيل Tail Call، وتحويله إلى حلقة يسمى Tail Call Optimization واختصاراً TCO. والدالة `countdown` التالية مكتوبة بهذا الشكل، فالمجموع يحمل في المعامل `acc` والاستدعاء هو آخر سطر:

```python
# A tail call: the recursive call is the last thing the function does.
# Python does not eliminate tail calls, so this still uses one frame per call.
import sys
def countdown(n, acc=0):
    if n == 0:
        return acc
    return countdown(n - 1, acc + n)   # tail position

print(countdown(900))
try:
    print(countdown(5_000))
except RecursionError as e:
    print("RecursionError:", e)
```

والمخرج سوف يكون كما يلي:

```text
405450
RecursionError: maximum recursion depth exceeded
```

أي أن Python لم تحول الاستدعاء إلى حلقة، ففشلت عند العمق 5,000 كأي دالة أخرى. وهذا قرار مقصود، فقد كتب جيدو فان روسم Guido van Rossum، مصمم Python، في أبريل 2009 مقالاً بعنوان [Tail Recursion Elimination](https://neopythonic.blogspot.com/2009/04/tail-recursion-elimination.html?ref=arabroot.io) يشرح لماذا لا يريدها في اللغة، وأول أسبابه أنها «incompatible with nice stack traces»، أي أنها تتعارض مع رسائل الخطأ الواضحة، لأن الإطارات المحذوفة تختفي من ال Traceback. والسبب الثاني أن المطورين سوف يكتبون كوداً يعتمد عليها ثم لا يعمل في تطبيقات Python الأخرى التي لا تدعمها. وأنهى المقال بسؤال: هل إعادة كتابة الدالة بحلقة أمر صعب فعلاً؟ والسبب أن كل استدعاء ذيل يمكن تحويله إلى حلقة بسهولة.

أما JavaScript فقصتها أغرب، فالمواصفة نفسها ECMAScript 2015 تطلب ما تسميه Proper Tail Calls، وتجد تعريف موضع الذيل في [قسم IsInTailPosition في المواصفة](https://tc39.es/ecma262/?ref=arabroot.io#sec-isintailposition)، والشرط أن يكون الكود في الوضع الصارم Strict Mode. وكتبنا `tail.js` بهذا الشرط:

```javascript
'use strict';
// A tail call in strict mode. ES2015 specifies proper tail calls, but V8 (Node.js) does not implement them.
function countdown(n, acc = 0) {
  if (n === 0) return acc;
  return countdown(n - 1, acc + n);   // tail position
}
console.log(countdown(1000));
try { console.log(countdown(100000)); } catch (err) { console.log(`${err.name}: ${err.message}`); }
```

والمخرج سوف يكون كما يلي:

```text
500500
RangeError: Maximum call stack size exceeded
```

فرغم `'use strict'` ورغم أن الاستدعاء في موضع الذيل، فشل Node.js عند العمق 100,000\. والسبب كما شرح فريق V8 في [مقاله عن دعم ES2015 في أبريل 2016](https://v8.dev/blog/modern-javascript?ref=arabroot.io) أنهم طبقوا الميزة ثم أوقفوها عمداً لسببين: الأول أن المبرمج لا يعرف بسهولة أي استدعاء في موضع الذيل فعلاً، فيكتشف الخطأ عندما يمتلئ ال Stack، والثاني أن حذف الإطارات يضيع معلومات من ال Stack Trace ويصعب ال Debugging، وهو نفس سبب Guido تقريباً. ولم يشحن V8 هذه الميزة حتى اليوم، أما محرك Safari فطبقها كما في [مقال فريق WebKit عنها](https://webkit.org/blog/6240/ecmascript-6-proper-tail-calls-in-webkit/?ref=arabroot.io)، لذلك نفس الكود قد يعمل في Safari ويفشل في Chrome وNode.js. والنتيجة العملية: لا تعتمد على استدعاء الذيل في Python ولا في JavaScript، وإذا كتبت دالة على شكل استدعاء ذيل فقد أنجزت نصف العمل، فحولها إلى حلقة `while` مباشرة.

## أين تجد الاستدعاء الذاتي في عملك؟ أمثلة حقيقية Use Cases

بعد كل هذه الحدود قد تظن أن الاستدعاء الذاتي شيء يجب تجنبه، وهذا غير صحيح. فهو الطريقة الأوضح لكتابة الحل عندما تكون البيانات نفسها Recursive، أي تحتوي على نسخ أصغر من نفسها، وعندما يكون العمق محدوداً ومعروفاً. وفيما يلي أكثر الأماكن التي سوف تقابله فيها.

### المرور على الشجرة Tree Traversal: المجلدات والتعليقات وJSON

كل شيء على شكل شجرة يمر عليه الاستدعاء الذاتي بشكل طبيعي: شجرة التعليقات التي بدأنا بها، والمجلدات، وعناصر صفحة HTML في ال DOM، وقوائم التصنيفات في المتاجر، وشجرة القرارات، والكود نفسه بعد أن يحوله المترجم إلى شجرة البنية Abstract Syntax Tree. والسؤال الذي يحدد الطريقة هو عمق الشجرة وليس حجمها: شجرة فيها مليون عنصر وعمقها 20، مثل شجرة B-Tree في قاعدة البيانات، لا مشكلة فيها مع الاستدعاء الذاتي، أما سلسلة فيها خمسون ألف عنصر بعمق خمسين ألفاً فتفشل كما رأينا. لذلك إذا كانت الشجرة تأتي من المستخدم، مثل ردود يمكن أن يتداخل فيها أي عدد، فضع حداً للعمق في قاعدة البيانات أو في الواجهة، كما تفعل أغلب المنتديات التي تتوقف عن الإزاحة بعد مستوى معين، أو استخدم الحلقة مع Stack خاص بك.

### فرق تسد Divide and Conquer: الترتيب بالدمج Merge Sort

النوع الثاني من الاستخدامات لا يأتي من شكل البيانات وإنما من طريقة الحل، وهو «فرق تسد» Divide and Conquer: تقسم المشكلة إلى نصفين، وتحل كل نصف بنفس الطريقة، ثم تجمع الحلين. وأشهر مثال الترتيب بالدمج Merge Sort، وقد شرحنا سرعته وتعقيده في [مقال الخوارزميات](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%88%D8%A7%D8%B1%D8%B2%D9%85%D9%8A%D8%A7%D8%AA-%D9%88%D8%AA%D8%B9%D9%82%D9%8A%D8%AF-big-o-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#merge-sort)، وهنا نهتم بشكل الاستدعاءات نفسها. والملف التالي يطبع كل استدعاء وكل دمج على قائمة من ستة أرقام، ثم يرتب 200 ألف رقم ويقارن مع `sorted()`:

```python
# Divide and conquer: merge sort splits the list in two, sorts each half recursively, then merges.
import random, time

def merge_sort(items, depth=0, trace=False):
    if trace:
        print("  " * depth + f"sort {items}")
    if len(items) <= 1:                 # base case: one item is already sorted
        return items
    mid = len(items) // 2
    left = merge_sort(items[:mid], depth + 1, trace)
    right = merge_sort(items[mid:], depth + 1, trace)
    merged, i, j = [], 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            merged.append(left[i]); i += 1
        else:
            merged.append(right[j]); j += 1
    merged += left[i:] + right[j:]
    if trace:
        print("  " * depth + f"merged {merged}")
    return merged

print(merge_sort([5, 2, 9, 1, 7, 3], trace=True))

data = [random.random() for _ in range(200_000)]
t = time.perf_counter(); a = merge_sort(data); t1 = time.perf_counter() - t
t = time.perf_counter(); b = sorted(data); t2 = time.perf_counter() - t
print(f"200,000 items: merge_sort {t1*1000:.0f} ms, sorted() {t2*1000:.0f} ms, same result: {a == b}")
```

والمخرج سوف يكون كما يلي:

```text
sort [5, 2, 9, 1, 7, 3]
  sort [5, 2, 9]
    sort [5]
    sort [2, 9]
      sort [2]
      sort [9]
    merged [2, 9]
  merged [2, 5, 9]
  sort [1, 7, 3]
    sort [1]
    sort [7, 3]
      sort [7]
      sort [3]
    merged [3, 7]
  merged [1, 3, 7]
merged [1, 2, 3, 5, 7, 9]
[1, 2, 3, 5, 7, 9]
200,000 items: merge_sort 2851 ms, sorted() 161 ms, same result: True
```

في المخرج أعلاه لاحظ التالي:

- الإزاحة تبين العمق كما في مثال التعليقات، وأعمق مستوى هو 3 لستة عناصر، لأن كل استدعاء يقسم القائمة إلى النصف، فالعمق ينمو مع لوغاريتم الحجم وليس مع الحجم نفسه. لذلك ترتيب 200 ألف رقم احتاج عمقاً يقارب 18 فقط، وهذا بعيد جداً عن أي حد، وهذا هو نوع الاستدعاء الذاتي الآمن.
- أسطر `sort` عند النزول لا ترتب شيئاً، وكل الترتيب يحدث في أسطر `merged` أثناء الرجوع، كما رأينا في عد التعليقات حيث الإجابة تبنى أثناء الرجوع.
- الحالة الأساسية واضحة في أسطر مثل `sort [5]` التي لا تظهر بعدها أي استدعاءات، فالقائمة ذات العنصر الواحد مرتبة.
- `sorted()` كانت أسرع بنحو 18 مرة، لأنها مكتوبة بـ C وتستخدم خوارزمية مختلفة، وهذا تذكير بأن تكتب خوارزميات الترتيب لتفهمها، وتستخدم دالة اللغة في المشاريع الحقيقية.

والصورة التالية ترسم نفس المخرج كشجرة استدعاءات، التقسيم من الأعلى إلى الأسفل والدمج من الأسفل إلى الأعلى:

![شجرة استدعاءات merge_sort للقائمة 5 و2 و9 و1 و7 و3: تنقسم بأسهم رمادية إلى 5 و2 و9 و1 و7 و3، ثم إلى عناصر منفردة في المستوى الثالث مع علامة الحالة الأساسية، ثم تصعد بأسهم خضراء مكتوب عليها merged إلى 2 و9 و2 و5 و9 و3 و7 و1 و3 و7 حتى القائمة المرتبة 1 و2 و3 و5 و7 و9، مع عمود جانبي يبين العمق 0 إلى 3](https://arabroot.io/content/images/2026/10/recursion-07-merge-sort.webp)

في «فرق تسد» يقسم النزول المشكلة ويبني الرجوع الحل، والعمق ينمو مع لوغاريتم الحجم فقط

### الملء بالفيضان Flood Fill: أداة دلو الطلاء

النوع الثالث هو البحث في شبكة أو رسم بياني Graph، ومثاله أداة دلو الطلاء في برامج الرسم: تضغط على خلية فتتلون هي وكل الخلايا المتصلة بها التي لها نفس اللون. والحل Recursive بطبيعته: لون الخلية ثم استدع نفسك على جيرانها الأربعة، والحالة الأساسية خلية خارج الشبكة أو ملونة من قبل. والملف التالي يكتب الحل بالطريقتين ويجربهما على شبكتين فارغتين:

```python
# Flood fill (the paint bucket): recursive version versus an explicit stack, on a grid of empty cells.
import sys

def fill_rec(grid, r, c):
    if r < 0 or c < 0 or r >= len(grid) or c >= len(grid[0]) or grid[r][c] != ".":
        return 0                        # base case: outside, wall, or already painted
    grid[r][c] = "#"
    return 1 + fill_rec(grid, r + 1, c) + fill_rec(grid, r - 1, c) + fill_rec(grid, r, c + 1) + fill_rec(grid, r, c - 1)

def fill_stack(grid, r, c):
    painted, stack = 0, [(r, c)]
    while stack:
        r, c = stack.pop()
        if r < 0 or c < 0 or r >= len(grid) or c >= len(grid[0]) or grid[r][c] != ".":
            continue
        grid[r][c] = "#"
        painted += 1
        stack += [(r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)]
    return painted

for size in (20, 100):
    for fn in (fill_rec, fill_stack):
        grid = [["."] * size for _ in range(size)]
        try:
            print(f"{size}x{size} {fn.__name__}: painted {fn(grid, 0, 0)}")
        except RecursionError as e:
            print(f"{size}x{size} {fn.__name__}: RecursionError: {e}")
```

والمخرج سوف يكون كما يلي:

```text
20x20 fill_rec: painted 400
20x20 fill_stack: painted 400
100x100 fill_rec: RecursionError: maximum recursion depth exceeded
100x100 fill_stack: painted 10000
```

وهذا المثال يبين فخاً لا يظهر في الشجرة: الشبكة 100 في 100 عمقها الظاهر 100 فقط، ولكن الدالة لا تنزل مستوى مستوى، وإنما تتبع أول جار ثم أول جار له، فتمشي في طريق طويل يمر على أغلب الخلايا قبل أن ترجع، فوصل العمق إلى أكثر من 999 مع شبكة فيها عشرة آلاف خلية فقط. وفي برنامج رسم حقيقي الصورة فيها ملايين البكسلات، فالنسخة Recursive غير مناسبة إطلاقاً هنا، والحل هو نفس الحلقة مع Stack التي رأيناها، وهي التي لونت العشرة آلاف خلية دون مشكلة.

## أخطاء شائعة Common Mistakes

- **نسيان الحالة الأساسية أو كتابة شرط لا يتحقق:** مثل `n == 0` مع خطوة 2 من رقم فردي، أو مقارنة رقم عشري بالصفر بعد طرح 0.1 عدة مرات فلا يصل إلى الصفر بالضبط. اكتب شرط التوقف بـ `<=` عندما يمكن أن تقفز فوق القيمة، واختبر الدالة بأصغر مدخل ممكن قبل أي شيء آخر.
- **استدعاء لا يصغر المشكلة:** مثل أن تمرر `items` بدلاً من `items[1:]`، أو أن تستدعي `merge_sort` على القائمة كلها عندما يكون طولها 2 لأن `mid` حسبت بشكل خاطئ. والعلامة في المخرج أن نفس السطر يتكرر في ال Traceback مع نفس القيم.
- **الاستدعاء الذاتي على بيانات يتحكم المستخدم في عمقها:** ردود متداخلة، أو JSON من طلب API، أو مجلدات يرفعها المستخدم في ملف مضغوط. ضع حداً صريحاً للعمق يرجع خطأ واضحاً، أو استخدم الحلقة مع Stack، والسبب ما رأيناه في حادثة Jackson.
- **رفع الحد بدلاً من حل المشكلة:** `sys.setrecursionlimit(10**6)` أو `--stack-size` كبير، والنتيجة ذاكرة أكبر لكل طلب في Python، أو Segmentation Fault في Node.js كما رأينا.
- **التقاط RecursionError أو RangeError ثم الإكمال كأن شيئاً لم يحدث:** هذا الخطأ يعني غالباً خطأ في المنطق أو بيانات غير متوقعة، فسجله وأرجع خطأ للمستخدم، ولا تبتلعه، كما شرحنا في [مقال كيف تتعامل لغات البرمجة مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/).
- **حساب نفس القيمة مرات كثيرة دون أن تنتبه:** كل دالة تستدعي نفسها أكثر من مرة على مدخلات متداخلة، مثل fib، تحتاج أن تسأل نفسك: هل تتكرر نفس المعاملات؟ وإذا كان الجواب نعم فاستخدم `cache` أو حولها إلى حلقة تبني النتائج من الأصغر إلى الأكبر.
- **الحفظ لدالة غير نقية:** وضع `@cache` على دالة تقرأ من قاعدة البيانات أو تعتمد على الوقت، فترجع نتيجة قديمة إلى الأبد، أو على دالة معاملاتها كثيرة التنوع فتنمو الذاكرة بلا حد.
- **بناء نسخ جديدة من القائمة في كل استدعاء دون حاجة:** مثل `items[1:]` في كل خطوة على قائمة طويلة، فكل نسخة تأخذ وقتاً وذاكرة بحجم القائمة، والأفضل أن تمرر الفهرس `index` بدلاً من نسخ القائمة.

## متى تستخدم الاستدعاء الذاتي؟ جدول القرار Decision Table

| الحالة                                                                        | الاختيار                                    | والسبب                                                              |
| ----------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------- |
| شجرة عمقها صغير ومعروف (B-Tree، قوائم تصنيفات بمستويات محدودة، AST لكود عادي) | الاستدعاء الذاتي                            | الكود أوضح، والعمق بعيد عن أي حد                                    |
| فرق تسد يقسم إلى النصف (Merge Sort، البحث الثنائي)                            | الاستدعاء الذاتي                            | العمق ينمو مع اللوغاريتم، فمليار عنصر يحتاج عمقاً نحو 30            |
| مهمة خطية (جمع، عد، المرور على قائمة)                                         | الحلقة                                      | أسرع بمرتين إلى ثلاث في قياسنا، ولا حد للعمق                        |
| بيانات يتحكم المستخدم في عمقها (ردود، JSON، ملفات مضغوطة)                     | الحلقة مع Stack، أو حد صريح للعمق           | العمق غير معروف، والفشل يجب أن يكون خطأ واضحاً وليس انهياراً        |
| البحث في شبكة أو Graph كبير (Flood Fill، الطرق في خريطة)                      | الحلقة مع Stack أو Queue                    | العمق قد يصل إلى عدد العناصر كلها                                   |
| دالة تستدعي نفسها عدة مرات على نفس المعاملات (fib، مسائل العد)                | الحفظ بـ cache أو حلقة من الأصغر إلى الأكبر | العدد ينمو أسياً بدون حفظ                                           |
| استدعاء ذيل في Python أو JavaScript                                           | الحلقة while                                | Python وV8 لا تحولانه إلى حلقة، والكود نفسه يفشل في Node.js وChrome |

## قائمة فحص Checklist قبل أن تكتب دالة Recursive

- هل الحالة الأساسية مكتوبة، وهل تصل إليها كل المدخلات الممكنة، بما فيها القيمة الفارغة والسالبة والصفر؟
- هل كل استدعاء يقرب المشكلة من الحالة الأساسية فعلاً، وليس فقط في المثال الذي جربته؟
- ما أقصى عمق ممكن؟ هل يحدده الكود أم البيانات؟ وإذا حدده المستخدم، أين الحد الذي يرجع خطأ واضحاً؟
- هل العمق قريب من 1000 في Python أو من بضعة آلاف في Node.js؟ إذا كان كذلك فاكتبها بحلقة الآن، ولا تنتظر أن تفشل في الإنتاج.
- هل تستدعي الدالة نفسها أكثر من مرة على مدخلات تتكرر؟ إذا كان الجواب نعم فهل هي نقية حتى تستخدم الحفظ؟
- هل اختبرت الدالة بأصغر مدخل، وبمدخل عميق جداً، وبمدخل فيه حلقة، مثل مجلد فيه رابط رمزي Symbolic Link يشير إلى أبيه؟
- هل تعتمد على استدعاء الذيل أو على رفع `setrecursionlimit` أو `--stack-size`؟ إذا كان كذلك فراجع القرار.

## تدريب عملي

- عدل الدالة `show` في `comments.py` لتكتب بحلقة مع Stack، وتأكد أنها تطبع الردود بنفس ترتيب النسخة Recursive. وتلميح: ستحتاج إلى `reversed()` وإلى حفظ العمق مع كل عنصر في ال Stack.
- أضف إلى `depth.js` دالة ثالثة فيها ثمانية متغيرات محلية فقط، وتوقع عمقها قبل التشغيل من الرقمين 10,462 و4,483، ثم شغلها وقارن.
- اكتب دالة تحسب حجم مجلد بطريقة الأسئلة الثلاثة، ثم شغلها على مجلد فيه رابط رمزي يشير إلى المجلد الأب، ولاحظ ماذا يحدث، ثم أصلحها بحفظ المجلدات التي زرتها.

## الخلاصة

- الاستدعاء الذاتي أن تحل الدالة المشكلة باستدعاء نفسها على نسخة أصغر، ويحتاج حالة أساسية توقفه وحالة تكرارية تقترب منها في كل خطوة.
- كل استدعاء يضيف إطاراً فوق Call Stack بمتغيراته الخاصة، والإجابة تبنى غالباً أثناء الرجوع، وأعمق نقطة هي ما يحدد استهلاك ال Stack وليس عدد العناصر.
- فكر بالأسئلة الثلاثة: ما أصغر نسخة، وكيف تقسم المشكلة، وكيف تبني الإجابة إذا وثقت بالاستدعاء الأصغر.
- في Python 3.13 الحد الافتراضي 1000 ووصلنا إلى 999، ورفعه إلى مليون عمل مع نحو 280 MB من الذاكرة، ولكن أي استدعاء يمر بكود C توقف عند 5,000، وقارئ JSON قرأ عمق 5,000 وفشل عند 100,000 باستثناء يمكن التقاطه.
- في Node.js 22 الحد بحجم ال Stack، 984 KB افتراضياً، فوصلت دالة صغيرة إلى 10,462 ودالة بمتغيرات أكثر إلى 4,483، ورفع `--stack-size` فوق ما يعطيه النظام سبب Segmentation Fault ورمز خروج 139 بدلاً من `RangeError`.
- الحفظ المؤقت حول fib(30) من 2,692,537 استدعاء إلى 31 حساباً، وكشف القياس في JavaScript أثر JIT وخطأ دقة الأرقام فوق 2 أس 53.
- Python وNode.js لا تحولان استدعاء الذيل إلى حلقة، فإذا كان العمق كبيراً أو غير معروف فاكتب حلقة مع Stack خاص بك في ال Heap.

وإذا أردت أن تكمل من هنا، فهذه مقالات مرتبطة بالموضوع:

[![](https://arabroot.io/content/images/2026/10/memory-basics-cover-cover.webp)دليلالذاكرة للمبرمجين: كيف تعمل ال Stack وال Heapلماذا تتغير قائمة لم تطلب تغييرها، وكيف تتسرب الذاكرة في لغة فيها جامع قمامة Garbage Collector، بأمثلة حقيقية من عدة لغات.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86-stack-%D9%88-heap-%D9%88%D8%A7%D9%84%D9%85%D8%A4%D8%B4%D8%B1%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/data-structures-cover-cover.webp)دليلهياكل البيانات Data Structures للمبتدئينلماذا تكون نفس العملية سريعة في هيكل بيانات وبطيئة في آخر، وأين تقابل كل هيكل في السيرفرات وقواعد البيانات.](https://arabroot.io/articles/%D9%87%D9%8A%D8%A7%D9%83%D9%84-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/algorithms-bigo-cover-cover.webp)دليلBig-O للمبتدئين: لماذا يبطؤ كودك عندما تكبر البياناتلماذا يعمل الكود بسرعة على مئة صف ثم يتوقف على مئة ألف، وكيف تقرأ Big-O وتكتشف الخوارزمية البطيئة في كودك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%88%D8%A7%D8%B1%D8%B2%D9%85%D9%8A%D8%A7%D8%AA-%D9%88%D8%AA%D8%B9%D9%82%D9%8A%D8%AF-big-o-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/error-handling-cover-cover.webp)دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)[![](https://arabroot.io/content/images/2026/10/debugging-basics-cover-cover.webp)دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.](https://arabroot.io/articles/%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-%D8%B1%D8%B3%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AE%D8%B7%D8%A7-stack-trace-%D9%88%D8%A7%D9%84%D8%AA%D8%B5%D8%AD%D9%8A%D8%AD-debugging/)[![](https://arabroot.io/content/images/2026/10/files-io-streams-cover-cover.webp)دليلالملفات Files والتدفقات Streams: كيف يقرأ برنامجك ملفاً؟لماذا يظهر النص العربي في ملف CSV من Excel مشوهاً، ولماذا يتوقف تقرير بسيط عندما يكبر ملف السجلات، وكيف تقرأ الملفات وتكتبها بأمان في Python وNode.js بقياسات حقيقية.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%AA%D8%AF%D9%81%D9%82%D8%A7%D8%AA-files-%D9%88-streams-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/floating-point-cover-cover.webp)دليلFloating Point من الداخل: كيف يخزن الحاسب الكسوركيف يحفظ معيار IEEE 754 الكسور في البتات، ولماذا تكبر الفجوة بين الأرقام وتضيع خانات المعرفات، ومتى تحتاج النوع العشري.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B4%D8%B1%D9%8A%D8%A9-floating-point-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/)[![](https://arabroot.io/content/images/2026/10/os-virtual-memory-cover-cover.webp)دليلالذاكرة الافتراضية Virtual Memory: جداول الصفحات وخطأ الصفحةنتابع العنوان من برنامجك إلى شريحة الذاكرة، ونفهم بأرقام حقيقية لماذا تخدعك أعمدة VIRT وRES، ومتى تسرع الصفحات الكبيرة برنامجك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D8%A7%D9%84%D8%A7%D9%81%D8%AA%D8%B1%D8%A7%D8%B6%D9%8A%D8%A9-virtual-memory-%D9%88%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B5%D9%81%D8%AD%D8%A7%D8%AA/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال مع مختبر حقيقي بلغة Python 3.13.17 داخل `python:3.13.17-slim` وNode.js 22.23.4 داخل `node:22-alpine`، اعتماداً على توثيق Python وصفحات ما الجديد في 3.11 و3.12، ومواصفة ECMAScript ومقالي V8 وWebKit عن استدعاء الذيل، ومقال Guido van Rossum، وتحذير GitHub عن ثغرة jackson-databind.