فهم الاستدعاء الذاتي (Recursion) في JavaScript: دليل شامل للمبتدئين

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

مقدمة إلى عالم الاستدعاء الذاتي في JavaScript

يُقال: "لفهم الاستدعاء الذاتي، يجب على المرء أولاً أن يفهم الاستدعاء الذاتي" – مقولة مجهولة المصدر تعكس مدى تعقيد هذا المفهوم. إذا كنت مثلي، فربما لم تستوعب مفهوم recursion تمامًا في المرة الأولى التي قرأت عنه. بالنسبة لي، كان ذلك بسبب أن recursion مفهوم صعب بطبيعته، وبعض الشروحات والمقالات التي قرأتها لم تكن واضحة بما فيه الكفاية. لسبب ما، استخدمت معظم المقالات التي شرحت recursion أمثلة الأعداد العاملية (factorial numbers) ومتتالية فيبوناتشي (Fibonacci sequence). هذا يعني أنني اضطررت لفهم كيفية عمل أعداد فيبوناتشي ثم ربط ذلك بالاستدعاء الذاتي. لكننا سنتخذ مسارًا مختلفًا في هذه المقالة لتبسيط الفهم قدر الإمكان.

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

بأبسط المصطلحات، يحدث الاستدعاء الذاتي (recursion) عندما تستمر دالة (function) في استدعاء نفسها حتى لا تعود بحاجة لذلك. ماذا؟ نعم، تستمر الدالة في استدعاء نفسها ولكن بمدخل (input) أصغر في كل مرة. تخيل recursion كسباق دائري؛ إنه مثل الركض في نفس المسار مرارًا وتكرارًا، لكن اللفات (laps) تستمر في أن تصبح أقصر في كل مرة. في النهاية، ستركض اللفة الأخيرة والأصغر وسينتهي السباق. الأمر نفسه ينطبق على recursion: تستمر الدالة في استدعاء نفسها بمدخل أصغر، وفي النهاية تتوقف.

لكن الدالة لا تقرر بنفسها متى تتوقف. نحن من نخبرها متى تتوقف. نمنح الدالة شرطًا يُعرف باسم الحالة الأساسية (base case). الحالة الأساسية هي الشرط الذي يخبر الدالة متى تتوقف عن استدعاء نفسها. إنها مثل إخبار الدالة ما ستكون اللفة الأخيرة في السباق حتى تتوقف عن الركض بعد تلك اللفة.

أمثلة عملية على الاستدعاء الذاتي

حسنًا، هذا هو مفهوم recursion. دعنا نلقي نظرة على بعض الأمثلة لفهم كيفية عمله. هل تتذكر المرة الأولى التي تعلمت فيها عن الحلقات التكرارية (loops)؟ ربما كان أول مثال قمت به هو برنامج للعد التنازلي. دعنا نفعل ذلك باستخدام recursion.

مثال 1: برنامج العد التنازلي

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


// 5
// 4
// 3
// 2
// 1

كيف يمكننا برمجة هذا باستخدام recursion؟


let countDown = number => {
  // الحالة الأساسية (base case)
  if (number === 0) {
    return;
  }
  console.log(number);
  return countDown(number - 1);
};

console.log(countDown(5)); // الناتج: 5, 4, 3, 2, 1

إذًا، ما الذي يحدث هنا بالضبط؟ إذا لاحظت، فإن أول شيء فعلناه هو تعريف الحالة الأساسية (base case). لماذا؟ لأن الدالة تحتاج أولاً وقبل كل شيء إلى معرفة متى ستتوقف عن استدعاء نفسها. لن تبدأ سباقًا أبدًا دون معرفة مدته أولاً، أليس كذلك؟ إذا لم تخبر الدالة متى تتوقف، فسيحدث شيء يسمى تجاوز سعة المكدس (stackoverflow). سيمتلئ المكدس (stack) بالدوال التي يتم استدعاؤها ولكنها لا تعود أو لا يتم إزالتها من المكدس.

الجزء الاستدعائي (recursive bit) يحدث فعليًا في السطر 7. هناك نخبر الدالة أن تستمر في استدعاء نفسها ولكن مع تقليل المدخل (input) بمقدار واحد في كل مرة. لذا، فعليًا، هذا ما يحدث:


// المدخل الحالي هو 5
// هل 5 يساوي 0؟
// لا، حسنًا، دعنا نسجل 5 في الـ console.
// تستدعي نفسها مرة أخرى مع number - 1 أي 5 - 1؛
// المدخل الحالي هو 4
// هل 4 يساوي 0؟
// لا، حسنًا، دعنا نسجل 4 في الـ console.
// تتكرر العملية حتى يصبح المدخل 0، ثم تتوقف الدالة عن استدعاء نفسها.

مثال 2: تحديد ما إذا كان الرقم زوجيًا أم فرديًا

حسنًا، هذا منطقي. دعنا نجرب مثالًا آخر. هل تعلم كيف يمكننا معرفة ما إذا كان الرقم زوجيًا باستخدام عامل القسمة المتبقية (% operator)؟ فإذا كان أي رقم % 2 == 0، فإن هذا الرقم زوجي، أو إذا كان أي رقم % 3 == 0، فإن هذا الرقم فردي. حسنًا، اتضح أن هناك طريقة أخرى. إذا طرحنا باستمرار الرقم 2 من عدد حتى يصبح أصغر رقم إما 0 أو 1، فيمكننا معرفة ما إذا كان الرقم زوجيًا أم فرديًا.

دعنا نجرب ذلك باستخدام recursion. لذا، بالنظر إلى الرقم 6، يجب أن يُرجع برنامجنا "Even" (زوجي) لأن 6-2-2-2 = 0. بالنظر إلى الرقم 7، يجب أن يُرجع برنامجنا "Odd" (فردي) لأن 7-2-2-2 = 1. دعنا نرى ذلك في الكود:


let oddOrEven = (number) => {
  if (number === 0) {
    return 'Even';
  } else if (number === 1) {
    return 'Odd';
  } else {
    return oddOrEven(number - 2);
  }
};

console.log(oddOrEven(20));  // الناتج: Even
console.log(oddOrEven(75));  // الناتج: Odd
console.log(oddOrEven(98));  // الناتج: Even
console.log(oddOrEven(113)); // الناتج: Odd

مرة أخرى، كانت الخطوة الأولى هي إخبار الدالة متى تتوقف عن استدعاء نفسها. ثم أخبرناها بما يجب فعله عندما تستدعي نفسها. الاستدعاء الذاتي هو في الأساس استراتيجية "فرق تسد" (divide and conquer). نستمر في تقسيم المشكلة، مما يجعلها أصغر في كل مرة.

مقارنة بين الاستدعاء الذاتي والحلقات التكرارية (Loops)

عندما يتعلق الأمر بالسرعة، تعمل الحلقة التكرارية (loop) أسرع بكثير من الدالة الاستدعائية (recursive function). كما أنه من الأسهل كتابة حلقة تكرارية من دالة استدعائية. وعندما يتعلق الأمر بقابلية القراءة، فمن الأسهل معرفة ما يحدث مع حلقة تكرارية مقارنة بدالة استدعائية. لكن الدوال الاستدعائية أنيقة جدًا في تصميمها.

إذًا ما هو الخيار الأفضل؟ الكفاءة أم السرعة؟ إليك اقتباس من كتاب "Eloquent JavaScript":

"القلق بشأن الكفاءة يمكن أن يكون مصدر إلهاء. إنه عامل آخر يعقد تصميم البرنامج، وعندما تفعل شيئًا صعبًا بالفعل، فإن هذا الشيء الإضافي الذي يدعو للقلق يمكن أن يكون مشلاً. لذلك، ابدأ دائمًا بكتابة شيء صحيح وسهل الفهم. إذا كنت قلقًا من أنه بطيء جدًا – وهو ما لا يحدث عادةً نظرًا لأن معظم الكود لا يتم تنفيذه بشكل كافٍ ليستغرق وقتًا طويلاً – يمكنك القياس لاحقًا وتحسينه إذا لزم الأمر."

في هذه المرحلة، قد تتساءل لماذا قد تختار على الإطلاق كتابة دالة استدعائية بدلاً من حلقة تكرارية. أقصد أن الحلقات التكرارية أسهل بكثير، أليس كذلك؟ حسنًا، هذا صحيح – ولكن هناك بعض المشكلات التي يسهل حلها باستخدام recursion. إذا كنت ترغب في استكشاف إحدى هذه المشكلات، ففكر في قراءة الفصل 3 من كتاب "Eloquent JavaScript".

تمارين تطبيقية لتعزيز فهمك

الآن بعد أن اكتشفت قوة جديدة، دعنا نضعها في بعض الاستخدام. نفذ التمارين التالية باستخدام recursion. إذا شعرت أنك تستطيع أن تتحدى نفسك أكثر، فيمكنك حل مشكلات الأعداد العاملية (factorial) ومتتالية فيبوناتشي (Fibonacci sequence) الشهيرة.

  • عكس سلسلة نصية: اكتب برنامجًا يعكس سلسلة نصية (string) باستخدام recursion. بالنظر إلى السلسلة "freeCodeCamp"، يجب أن يُرجع برنامجك "pmaCedoCeerf".
  • عدد مرات ظهور حرف: اكتب برنامجًا يُرجع عدد مرات ظهور حرف (character) في سلسلة نصية. يجب أن يتلقى برنامجك سلسلة نصية وحرفًا. ثم يجب أن يُرجع عدد مرات ظهور الحرف في السلسلة. بالنظر إلى السلسلة "JavaScript" وحرف "a"، يجب أن يُرجع برنامجك 2.

تلميح: حاول أن تكتشف متى تريد أن تتوقف الدالة عن استدعاء نفسها وكيف تُرجع نسخة أصغر من المشكلة في كل مرة تستدعي فيها الدالة نفسها.

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

الاستدعاء الذاتي (recursion) في JavaScript هو أداة برمجية قوية وأنيقة، وإن كانت تتطلب فهمًا دقيقًا لمفهوم الحالة الأساسية (base case) لتجنب الأخطاء الشائعة مثل stackoverflow. على الرغم من أن الحلقات التكرارية (loops) قد تتفوق عليها في الأداء والوضوح في بعض السيناريوهات، إلا أن recursion يقدم حلولًا أكثر طبيعية وجمالية لمشكلات معينة ذات طبيعة متكررة أو هرمية. إن إتقان هذا المفهوم يوسع آفاق المبرمج ويضيف إلى صندوق أدواته حلولًا مبتكرة، مما يعزز قدرته على التعامل مع تحديات برمجية أكثر تعقيدًا بكفاءة.

اترك تعليقاً

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