فهم العودية (Recursion) في JavaScript: شرح مفصل باستخدام تحدي freeCodeCamp

دقائق القراءة: 6

تُعد العودية (Recursion) أحد المفاهيم الأساسية والقوية في عالم البرمجة، ولكنها قد تبدو معقدة للمبتدئين. في هذا المقال، سنغوص في أعماق مفهوم Recursion في JavaScript، ونشرح آلياته خطوة بخطوة باستخدام تحدٍ عملي من منصة freeCodeCamp. هدفنا هو تقديم شرح واضح ومفصل يساعدك على فهم كيفية عمل الدوال التي تستدعي نفسها، وكيفية تتبع تدفق البيانات داخل مكدس الاستدعاءات (Call Stack).

تحدي freeCodeCamp: إنشاء نطاق من الأرقام باستخدام العودية

في نهاية قسم “خوارزميات وهياكل البيانات في JavaScript – أساسيات JavaScript” على منصة freeCodeCamp، يواجه المتعلمون تحديًا مثيرًا للاهتمام بعنوان “استخدام العودية لإنشاء نطاق من الأرقام”. تتلخص التعليمات كالتالي:

  • لقد قمنا بتعريف دالة باسم rangeOfNumbers تستقبل معاملين (parameters).
  • يجب أن تُرجع الدالة مصفوفة (array) من الأعداد الصحيحة تبدأ بالرقم الممثل بالمعامل startNum وتنتهي بالرقم الممثل بالمعامل endNum.
  • الرقم البدائي (startNum) سيكون دائمًا أصغر من أو يساوي الرقم النهائي (endNum).
  • يجب أن تستخدم دالتك العودية عن طريق استدعاء نفسها، ويُمنع استخدام أي نوع من الحلقات التكرارية (loops).
  • يجب أن تعمل الدالة أيضًا في الحالات التي يكون فيها startNum و endNum متساويين.

يبدو الأمر بسيطًا بما فيه الكفاية؛ فإذا قمت بتشغيل rangeOfNumbers(1, 5)، يجب أن تُرجع الدالة المصفوفة [1, 2, 3, 4, 5]. قد يبدو الحل بديهيًا للبعض، ولكن فهم كيفية عمل هذه الآلية بالضبط قد يكون غير واضح تمامًا. لا تقلق، سنكشف عن الحل ونشرح تفاصيله الدقيقة.

الحل المقترح: بناء المصفوفة عودياً

من المحتمل أن تتمكن من قراءة الكود التالي وفهم أنه عندما يصل إلى الحالة الأساسية (base case)، سيعيد قيمة startNum داخل مصفوفة. بعد ذلك، سيستمر في إضافة القيم الأخرى إلى تلك المصفوفة حتى تنتهي جميع الاستدعاءات العودية.

function rangeOfNumbers(startNum, endNum) {
  if (startNum === endNum) {
    return [startNum];
  } else {
    const numbers = rangeOfNumbers(startNum, endNum - 1);
    numbers.push(endNum);
    return numbers;
  }
}

يكمن التحدي الحقيقي في فهم كيفية عمل مكدس الاستدعاءات (Call Stack) بالضبط وكيفية إرجاع القيم. لذا، دعنا نفكك هذه الدالة ونشرح كيف تُرجع قيمتها النهائية.

فهم مكدس الاستدعاءات (Call Stack)

أول شيء يجب فهمه هو كيفية عمل مكدس الاستدعاءات. يمكننا الرجوع إلى شرح شبكة مطوري Mozilla (MDN) الذي يوضح أن:

عندما يستدعي نص برمجي (script) دالة، يضيف المفسر (interpreter) هذه الدالة إلى مكدس الاستدعاءات (Call Stack) ثم يبدأ في تنفيذها. أي دوال يتم استدعاؤها بواسطة تلك الدالة تُضاف إلى مكدس الاستدعاءات في مستوى أعلى، وتُنفذ عند الوصول إلى استدعاءاتها. عندما تنتهي الدالة الحالية، يزيلها المفسر من المكدس ويستأنف التنفيذ من حيث توقف في القائمة البرمجية السابقة.

باستخدام هذا الشرح، دعنا نطبق الكود أعلاه على استدعاء الدالة rangeOfNumbers(1, 5).

أولاً، يتم إنشاء سياق التنفيذ (Execution Context) لدالة rangeOfNumbers(1, 5) وتنفيذه بالقيم التالية:

مخطط يوضح مكدس الاستدعاءات لدالة rangeOfNumbers(1,5) في JavaScript

كما نرى في الصورة، أضفنا استدعاء دالة rangeOfNumbers(1, 5) غير المحسوم إلى مكدس الاستدعاءات. ثم ننتقل لإنشاء سياق التنفيذ لدالة rangeOfNumbers(1, 4)، وهكذا دواليك، مع إضافة كل من هذه الاستدعاءات إلى مكدس الاستدعاءات الخاص بنا حتى يتم حل استدعاء دالة أخيرًا. بعد ذلك، سيزيل المفسر تلك الدالة من المكدس وينتقل إلى الدالة التالية.

تتبع مكدس الاستدعاءات خطوة بخطوة

سينتهي مكدس الاستدعاءات لدينا بالترتيب التالي (من الأعلى إلى الأسفل):

rangeOfNumbers(1, 1)
rangeOfNumbers(1, 2)
rangeOfNumbers(1, 3)
rangeOfNumbers(1, 4)
rangeOfNumbers(1, 5)

ستكون الدالة rangeOfNumbers(1, 1) هي الأخيرة في مكدس الاستدعاءات، لأن هذا الاستدعاء سيعيد أخيرًا قيمة، مما يسمح لنا بالانتقال إلى الدالة التالية في المكدس. القيمة المرجعة من rangeOfNumbers(1, 1) هي [1]، كما افترضنا، لأنها تمثل الحالة الأساسية (base case) لدينا. الآن، نقوم بإزالة rangeOfNumbers(1, 1) من مكدس الاستدعاءات، ونعود إلى حيث توقفت دالة rangeOfNumbers(1, 2).

آلية إرجاع القيم وتجميع المصفوفة

عند العودة إلى rangeOfNumbers(1, 2)، كان الكود ينتظر نتيجة الاستدعاء:

const numbers = rangeOfNumbers(startNum, endNum - 1); // أي rangeOfNumbers(1, 1)

الآن، أصبحت قيمة numbers هي [1] (القيمة المرجعة من rangeOfNumbers(1, 1)). الخطوة التالية هي إضافة قيمة endNum، وهي 2، إلى مصفوفة numbers. هذا يعطينا [1, 2] في numbers، والآن نقوم بإرجاع هذه القيمة.

numbers.push(endNum); // الآن numbers تحتوي على [1, 2]
return numbers;        // تُنهي الدالة وتُرجع [1, 2]

وهكذا، تُزال rangeOfNumbers(1, 2) من المكدس بعد أن أعادت القيمة [1, 2].

فك شفرة الجزء الأكثر تعقيدًا

دعنا نتابع مع الاستدعاء التالي في مكدس الاستدعاءات: rangeOfNumbers(1, 3). في هذه النقطة، قيمة المتغير numbers هي [1, 2]، لأن هذه هي القيمة المرجعة من rangeOfNumbers(1, 2). هذه هي القيمة التي تم “توصيلها” عندما استدعينا rangeOfNumbers(1, 3)، لأن 3 طُرح منها 1، مما أدى إلى استدعاء rangeOfNumbers(1, 2)، والتي بدورها أعادت [1, 2].

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

بالعودة إلى rangeOfNumbers(1, 3): مصفوفة numbers تحتوي حاليًا على [1, 2]، لذا نضيف قيمة endNum وهي 3. الآن لدينا [1, 2, 3] ونقوم بإرجاع هذه القيمة. نزيل rangeOfNumbers(1, 3) من مكدس الاستدعاءات بعد أن أعادت القيمة [1, 2, 3].

كيف حصلنا على rangeOfNumbers(1, 3)؟ هذا صحيح، من استدعاء rangeOfNumbers(1, 4) عندما كانت قيمة endNum - 1 هي 3. ونحن نعلم أن rangeOfNumbers(1, 3) تُعطينا القيمة المرجعة [1, 2, 3]، وهي بالضبط ما لدينا في مصفوفتنا. الآن نضيف endNum (أي 4) إلى مصفوفة numbers، مما يعطينا [1, 2, 3, 4] ونقوم بإرجاع هذه القيمة. مرة أخرى، نزيل استدعاء هذه الدالة من المكدس لأنها أعطتنا ما أردناه.

تجميع النتائج النهائية

الآن نصل إلى الاستدعاء الذي بدأ كل شيء: rangeOfNumbers(1, 5). الخطوة الأولى هي تحديد القيمة التي لدينا في numbers. عندما تم استدعاء rangeOfNumbers(1, 4)، حصلنا، كما ذكرنا سابقًا، على [1, 2, 3, 4]. لذا، يمكننا الآن إضافة قيمة endNum وهي 5 إلى المصفوفة لنحصل على [1, 2, 3, 4, 5]، والتي سنقوم بإرجاعها. وبهذا يصبح مكدس الاستدعاءات فارغًا بعد آخر استدعاء لنا.

دعنا نراجع سريعًا ما هي القيم التي تم إرجاعها وبأي ترتيب:

rangeOfNumbers(1, 1) → تُرجع [1]
rangeOfNumbers(1, 2) → تُرجع [1, 2]
rangeOfNumbers(1, 3) → تُرجع [1, 2, 3]
rangeOfNumbers(1, 4) → تُرجع [1, 2, 3, 4]
rangeOfNumbers(1, 5) → تُرجع [1, 2, 3, 4, 5]

إذا كان هذا لا يزال مربكًا، فهذا أمر طبيعي؛ إنه موضوع قد يكون معقدًا بعض الشيء في البداية. أوصي بشدة بكتابة الكود الخاص بك في هذه الأداة الرائعة لتصور التنفيذ: http://www.pythontutor.com/javascript.html.

يعمل هذا النهج بأكمله لأننا بدأنا بحالة أساسية صغيرة، ثم قمنا ببناء طريقنا تدريجيًا إلى الأعلى. في كل مرة، تكون القيمة المرجعة أكبر قليلاً مما كانت عليه في الاستدعاء السابق، تمامًا كما لو كنت تقوم بنفس العملية باستخدام حلقة تكرارية (for loop).

الخلاصة التقنية

تُظهر العودية (Recursion) في JavaScript، كما يتضح من تحدي freeCodeCamp، قوة الدوال التي تستدعي نفسها لحل المشكلات المعقدة بطريقة أنيقة وموجزة. المفتاح لفهمها يكمن في إدراك مفهوم الحالة الأساسية (base case) التي توقف التسلسل العودي، وكيف يقوم مكدس الاستدعاءات (Call Stack) بتخزين سياقات التنفيذ وإعادة بناء النتائج تدريجيًا. على الرغم من أن العودية قد تستهلك موارد ذاكرة أكبر في بعض الحالات بسبب عمق المكدس، إلا أنها توفر حلولًا نظيفة وقابلة للقراءة، خاصة في مسائل مثل تتبع الشجرة (tree traversal) أو معالجة البيانات الهيكلية. إتقان هذا المفهوم يعزز بشكل كبير قدرة المطور على التفكير الخوارزمي وحل المشكلات بفعالية.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *