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

الدوال من أكثر الأدوات محورية في برمجة JavaScript. لفكرة تغليف قطعة من البرنامج في قيمة استخدامات كثيرة. فهي تمنحنا طريقة لبنية برامج أكبر، ولتقليل التكرار، ولربط الأسماء ببرامج فرعية، ولعزل هذه البرامج الفرعية بعضها عن بعض.
أوضح تطبيق للدوال هو تعريف مفردات جديدة. إنشاء كلمات جديدة في النثر أسلوب رديء عادة، لكنه في البرمجة لا غنى عنه.
يمتلك المتحدث البالغ النموذجي بالإنجليزية نحو 20,000 كلمة في مفرداته. وقليلة هي لغات البرمجة التي تأتي مزودة بـ 20,000 أمر مدمج. كما أن المفردات المتاحة فعلاً تميل إلى أن تكون أكثر دقة في تعريفها، وبالتالي أقل مرونة، من مفردات اللغة البشرية. لذلك علينا إدخال كلمات جديدة لتجنب الإطالة المفرطة.
تعريف دالة
تعريف الدالة هو ارتباط عادي تكون قيمة الارتباط فيه دالة. مثلاً، تعرّف هذه الشيفرة square لتشير إلى دالة تُنتج مربع عدد معطى:
const square = function(x) {
return x * x;
};
console.log(square(12));
// → 144
تُنشأ الدالة بتعبير يبدأ بالكلمة المفتاحية function. وللدوال مجموعة من الوسطاء (في هذه الحالة، x فقط) وجسم (body) يحتوي الجمل التي ستُنفَّذ عند استدعاء الدالة. ويجب دائماً تغليف جسم دالة أُنشئت بهذه الطريقة بأقواس معقوفة، حتى عندما يتكون من جملة واحدة فقط.
يمكن أن يكون للدالة عدة وسطاء أو ألا يكون لها وسطاء على الإطلاق. في المثال التالي، لا يسرد makeNoise أي أسماء وسطاء، بينما يسرد roundTo (الذي يقرّب n إلى أقرب مضاعف للقيمة step) وسيطين:
const makeNoise = function() {
console.log("Pling!");
};
makeNoise();
// → Pling!
const roundTo = function(n, step) {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
console.log(roundTo(23, 10));
// → 20
بعض الدوال، مثل roundTo وsquare، تنتج قيمة، وبعضها لا يفعل، مثل makeNoise، الذي لا تكون نتيجته الوحيدة سوى تأثير جانبي. وتحدد جملة return القيمة التي تُرجعها الدالة. وعندما يصل التنفيذ إلى جملة كهذه، يقفز فوراً خارج الدالة الحالية ويمنح القيمة المُرجعة للشيفرة التي استدعت الدالة. والكلمة المفتاحية return دون تعبير بعدها تجعل الدالة تُرجع undefined. أما الدوال التي لا تحتوي على جملة return إطلاقاً، مثل makeNoise، فتُرجع undefined بالمثل.
تتصرف وسطاء الدالة كالارتباطات العادية، لكن قيمهم الأولية يمنحها المستدعي (caller) للدالة، لا الشيفرة الموجودة في الدالة نفسها.
الارتباطات والنطاقات
لكل ارتباط نطاق (scope)، وهو الجزء من البرنامج الذي يكون فيه الارتباط مرئياً. وبالنسبة للارتباطات المعرّفة خارج أي دالة أو كتلة أو وحدة (انظر الفصل 10)، فإن النطاق هو البرنامج كله—يمكنك الإشارة إلى ارتباطات كهذه حيثما شئت. وتسمى هذه الارتباطات عامة (global).
الارتباطات المنشأة لوسطاء الدوال أو المعلنة داخل دالة لا يمكن الإشارة إليها إلا في تلك الدالة، لذا تُعرف بالارتباطات المحلية (local). وفي كل مرة تُستدعى فيها الدالة، تُنشأ نسخ جديدة من هذه الارتباطات. وهذا يوفر قدراً من العزل بين الدوال—فكل استدعاء دالة يعمل في عالمه الصغير الخاص (بيئته المحلية)، ويمكن غالباً فهمه دون معرفة الكثير عما يجري في البيئة العامة.
الارتباطات المعلنة بـ let وconst محلية في الواقع للـكتلة التي عُلنت فيها، فإذا أنشأت واحداً منها داخل حلقة، فلا تستطيع الشيفرة قبل الحلقة وبعدها «رؤيته». في JavaScript ما قبل 2015، لم تكن سوى الدوال تنشئ نطاقات جديدة، لذا فإن الارتباطات بالطراز القديم، المنشأة بالكلمة المفتاحية var، تكون مرئية في الدالة كلها التي تظهر فيها—أو في النطاق العام كله، إن لم تكن داخل دالة.
let x = 10; // عام
if (true) {
let y = 20; // محلي في الكتلة
var z = 30; // عام أيضاً
}
يمكن لكل نطاق أن «ينظر للخارج» نحو النطاق المحيط به، لذا يكون x مرئياً داخل الكتلة في المثال. والاستثناء هو عندما تحمل ارتباطات متعددة الاسم نفسه—في هذه الحالة، لا تستطيع الشيفرة رؤية سوى الأعمق منها. مثلاً، عندما تشير الشيفرة داخل دالة halve إلى n، فإنها ترى n الخاصة بها، لا n العامة.
const halve = function(n) {
return n / 2;
};
let n = 10;
console.log(halve(100));
// → 50
console.log(n);
// → 10
النطاق المتداخل
لا تكتفي JavaScript بالتمييز بين الارتباطات العامة والمحلية. يمكن إنشاء الكتل والدوال داخل كتل ودوال أخرى، ما يُنتج درجات متعددة من المحلية.
مثلاً، هذه الدالة—التي تطبع المكوّنات اللازمة لتحضير دفعة من الحمص—تحتوي على دالة أخرى بداخلها:
const hummus = function(factor) {
const ingredient = function(amount, unit, name) {
let ingredientAmount = amount * factor;
if (ingredientAmount > 1) {
unit += "s";
}
console.log(`${ingredientAmount} ${unit} ${name}`);
};
ingredient(1, "can", "chickpeas");
ingredient(0.25, "cup", "tahini");
ingredient(0.25, "cup", "lemon juice");
ingredient(1, "clove", "garlic");
ingredient(2, "tablespoon", "olive oil");
ingredient(0.5, "teaspoon", "cumin");
};
تستطيع الشيفرة داخل دالة ingredient رؤية ارتباط factor من الدالة الخارجية، لكن ارتباطاتها المحلية، مثل unit أو ingredientAmount، غير مرئية في الدالة الخارجية.
تتحدد مجموعة الارتباطات المرئية داخل كتلة بموضع تلك الكتلة في نص البرنامج. ويمكن لكل نطاق محلي أيضاً أن يرى جميع النطاقات المحلية التي تحتويه، ويمكن لجميع النطاقات رؤية النطاق العام. وتسمى هذه الطريقة في تحديد ظهور الارتباطات النطاق المعجمي (lexical scoping).
الدوال كقيم
يعمل ارتباط الدالة عادة ببساطة كاسم لقطعة محددة من البرنامج. ويُعرَّف ارتباط كهذا مرة واحدة ولا يتغير أبداً. وهذا يجعل من السهل الخلط بين الدالة واسمها.
لكن الاثنين مختلفان. فقيمة الدالة تستطيع فعل كل ما تستطيع القيم الأخرى فعله—يمكنك استخدامها في تعبيرات عشوائية، لا مجرد استدعائها. ومن الممكن تخزين قيمة دالة في ارتباط جديد، وتمريرها كمعطى إلى دالة، وهكذا. وبالمثل، لا يزال الارتباط الذي يحمل دالة ارتباطاً عادياً، ويمكن، إن لم يكن ثابتاً، إسناد قيمة جديدة إليه، هكذا:
let launchMissiles = function() {
missileSystem.launch("now");
};
if (safeMode) {
launchMissiles = function() {/* لا تفعل شيئاً */};
}
في الفصل 5، سنناقش الأشياء المثيرة التي يمكننا فعلها بتمرير قيم الدوال إلى دوال أخرى.
صيغة التعريف
هناك طريقة أقصر قليلاً لإنشاء ارتباط دالة. فعندما تُستخدم الكلمة المفتاحية function في بداية جملة، فإنها تعمل بشكل مختلف:
function square(x) {
return x * x;
}
هذا تعريف دالة (function declaration). تُعرّف الجملة الارتباط square وتوجهه إلى الدالة المعطاة. وهو أسهل كتابة بقليل ولا يتطلب فاصلة منقوطة بعد الدالة.
هناك دقة واحدة في هذا الشكل من تعريف الدوال.
console.log("The future says:", future());
function future() {
return "You'll never have flying cars";
}
تعمل الشيفرة السابقة، رغم أن الدالة معرّفة أسفل الشيفرة التي تستخدمها. فتعريفات الدوال ليست جزءاً من التدفق العادي للتحكم من الأعلى إلى الأسفل. بل تُنقل مفهوماً إلى أعلى نطاقها، ويمكن استخدامها من كل الشيفرة في ذلك النطاق. وهذا مفيد أحياناً لأنه يمنحك حرية ترتيب الشيفرة بالطريقة التي تبدو أوضح، دون القلق من الاضطرار إلى تعريف كل الدوال قبل استخدامها.
دوال السهم
هناك صيغة ثالثة للدوال، تبدو مختلفة جداً عن الأخريين. فبدلاً من الكلمة المفتاحية function، تستخدم سهماً (=>) مكوناً من علامة يساوي ومحرف أكبر من (لا تخلط بينه وبين معامل أكبر من أو يساوي، المكتوب >=):
const roundTo = (n, step) => {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
يأتي السهم بعد قائمة الوسطاء ويتبعه جسم الدالة. وهو يعبّر عن شيء مثل «هذا المدخل (الوسطاء) يُنتج هذه النتيجة (الجسم)».
عندما يكون هناك اسم وسيط واحد فقط، يمكنك حذف الأقواس حول قائمة الوسطاء. وإذا كان الجسم تعبيراً واحداً بدلاً من كتلة بين قوسين معقوفين، فسيُرجع ذلك التعبير من الدالة. لذا، هذان التعريفان لـ square يفعلان الشيء نفسه:
const square1 = (x) => { return x * x; };
const square2 = x => x * x;
عندما لا يكون لدالة السهم وسطاء على الإطلاق، تكون قائمة وسطائها مجرد زوج فارغ من الأقواس.
const horn = () => {
console.log("Toot");
};
لا يوجد سبب عميق لوجود كل من دوال السهم وتعبيرات function في اللغة. فباستثناء تفصيل بسيط، سنناقشه في الفصل 6، فإنهما يفعلان الشيء نفسه. أُضيفت دوال السهم في 2015، معظمها لجعل كتابة تعبيرات الدوال الصغيرة ممكنة بطريقة أقل إسهاباً. وسنستخدمها كثيراً في الفصل 5.
مكدّس الاستدعاء
الطريقة التي يتدفق بها التحكم عبر الدوال معقدة نوعاً ما. لنلقِ نظرة أقرب عليها. إليك برنامجاً بسيطاً يجري بعض استدعاءات الدوال:
function greet(who) {
console.log("Hello " + who);
}
greet("Harry");
console.log("Bye");
تمريرة عبر هذا البرنامج تسير تقريباً هكذا: استدعاء greet يجعل التحكم يقفز إلى بداية تلك الدالة (السطر 2). وتستدعي الدالة console.log، التي تأخذ التحكم وتؤدي عملها ثم تعيد التحكم إلى السطر 2. وهناك تصل إلى نهاية دالة greet، فتعود إلى الموضع الذي استدعاها—السطر 4. والسطر التالي يستدعي console.log مجدداً. وبعد أن تعود، يصل البرنامج إلى نهايته.
يمكننا إظهار تدفق التحكم بشكل تخطيطي هكذا:
not in function
in greet
in console.log
in greet
not in function
in console.log
not in function
ولأن الدالة يجب أن تقفز عائدة إلى الموضع الذي استدعاها عندما تُرجع، فلا بد أن يتذكر الحاسوب السياق الذي حدث فيه الاستدعاء. في إحدى الحالتين، يجب أن تعود console.log إلى دالة greet عندما تنتهي. وفي الحالة الأخرى، تعود إلى نهاية البرنامج.
الموضع الذي يخزّن فيه الحاسوب هذا السياق هو مكدّس الاستدعاء (call stack). في كل مرة تُستدعى فيها دالة، يُخزَّن السياق الحالي فوق هذا المكدّس. وعندما تُرجع الدالة، تحذف السياق العلوي من المكدّس وتستخدم ذلك السياق لمواصلة التنفيذ.
يتطلب تخزين هذا المكدّس مساحة في ذاكرة الحاسوب. وعندما ينمو المكدّس كثيراً، يفشل الحاسوب برسالة مثل «نفدت مساحة المكدّس» أو «تعاود مفرط». وتوضح الشيفرة التالية ذلك بطرح سؤال صعب جداً على الحاسوب يسبب تبادلاً لا نهائياً بين دالتين. أو بالأحرى، سيكون لا نهائياً لو كان لدى الحاسوب مكدّس لا نهائي. أما وهو على ما هو عليه، فستنفد مساحتنا، أو «نفجّر المكدّس».
function chicken() {
return egg();
}
function egg() {
return chicken();
}
console.log(chicken() + " came first.");
// → ??
المعطيات الاختيارية
الشيفرة التالية مسموحة وتُنفَّذ دون أي مشكلة:
function square(x) { return x * x; }
console.log(square(4, true, "hedgehog"));
// → 16
عرّفنا square بوسيط واحد فقط. ومع ذلك، عندما نستدعيها بثلاثة معطيات، لا تشتكي اللغة. بل تتجاهل المعطيات الزائدة وتحسب مربع الأول.
JavaScript متسامحة للغاية بشأن عدد المعطيات التي يمكنك تمريرها إلى دالة. فإذا مررت أكثر من اللازم، تُتجاهل الزائدة. وإذا مررت أقل من اللازم، تُسند إلى الوسطاء الناقصين القيمة undefined.
الجانب السلبي من هذا هو أنه من الممكن—بل المرجح—أن تمرر بالخطأ عدداً خاطئاً من المعطيات إلى الدوال. ولن يخبرك أحد بذلك. أما الجانب الإيجابي فهو أنك تستطيع استخدام هذا السلوك للسماح باستدعاء دالة بعدد مختلف من المعطيات. مثلاً، تحاول دالة minus هذه تقليد معامل - بالعمل على معطى واحد أو معطيين:
function minus(a, b) {
if (b === undefined) return -a;
else return a - b;
}
console.log(minus(10));
// → -10
console.log(minus(10, 5));
// → 5
إذا كتبت معامل = بعد وسيط، يتبعه تعبير، فستحل قيمة ذلك التعبير محل المعطى عندما لا يُعطى. مثلاً، تجعل هذه النسخة من roundTo معطاها الثاني اختيارياً. وإذا لم تقدمه أو مررت القيمة undefined، فسيؤول إلى واحد:
function roundTo(n, step = 1) {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
console.log(roundTo(4.5));
// → 5
console.log(roundTo(4.5, 2));
// → 4
سيقدّم الفصل التالي طريقة يستطيع بها جسم الدالة الوصول إلى قائمة المعطيات كلها التي مُررت إليه. وهذا مفيد لأنه يسمح للدالة بقبول أي عدد من المعطيات. مثلاً، تفعل console.log هذا، فتطبع كل القيم المعطاة لها:
console.log("C", "O", 2);
// → C O 2
الإغلاق
القدرة على التعامل مع الدوال كقيم، مقترنة بحقيقة أن الارتباطات المحلية يُعاد إنشاؤها في كل مرة تُستدعى فيها دالة، تطرح سؤالاً مثيراً للاهتمام: ماذا يحدث للارتباطات المحلية عندما لا يعود استدعاء الدالة الذي أنشأها نشطاً؟
تعرض الشيفرة التالية مثالاً على ذلك. فهي تعرّف دالة، wrapValue، تُنشئ ارتباطاً محلياً. ثم تُرجع دالة تصل إلى هذا الارتباط المحلي وتُرجعه.
function wrapValue(n) {
let local = n;
return () => local;
}
let wrap1 = wrapValue(1);
let wrap2 = wrapValue(2);
console.log(wrap1());
// → 1
console.log(wrap2());
// → 2
هذا مسموح ويعمل كما تأمل—فلا يزال بالإمكان الوصول إلى كلتا نسختي الارتباط. وهذا الموقف برهان جيد على أن الارتباطات المحلية تُنشأ من جديد في كل استدعاء، وأن الاستدعاءات المختلفة لا يؤثر بعضها على ارتباطات بعضها المحلية.
هذه الميزة—القدرة على الإشارة إلى نسخة محددة من ارتباط محلي في نطاق حاوٍ—تسمى إغلاقاً (closure). والدالة التي تشير إلى ارتباطات من نطاقات محلية حولها تسمى دالة إغلاق. ولا يحررك هذا السلوك من القلق بشأن أعمار الارتباطات فحسب، بل يجعل أيضاً استخدام قيم الدوال ممكناً ببعض الطرق الإبداعية.
بتغيير بسيط، يمكننا تحويل المثال السابق إلى طريقة لإنشاء دوال تضرب في مقدار عشوائي.
function multiplier(factor) {
return number => number * factor;
}
let twice = multiplier(2);
console.log(twice(5));
// → 10
لا حاجة فعلاً إلى ارتباط local الصريح من مثال wrapValue لأن الوسيط نفسه ارتباط محلي.
التفكير في برامج كهذه يتطلب بعض التدريب. والنموذج الذهني الجيد هو التفكير في قيم الدوال على أنها تحتوي على الشيفرة في جسمها وعلى البيئة التي أُنشئت فيها معاً. وعند الاستدعاء، يرى جسم الدالة البيئة التي أُنشئ فيها، لا البيئة التي استُدعي فيها.
في المثال السابق، تُستدعى multiplier فتُنشئ بيئة يُربط فيها وسيطها factor بالقيمة 2. وقيمة الدالة التي تُرجعها، والمخزنة في twice، تتذكر هذه البيئة، فحين تُستدعى تضرب معطاها في 2.
التعاود
لا بأس على الإطلاق أن تستدعي الدالة نفسها، ما دامت لا تفعل ذلك مرات كثيرة بما يفيض المكدّس. والدالة التي تستدعي نفسها تسمى تعاودية (recursive). ويتيح التعاود كتابة بعض الدوال بأسلوب مختلف. خذ مثلاً دالة power هذه، التي تفعل الشيء نفسه الذي يفعله معامل ** (الرفع إلى قوة):
function power(base, exponent) {
if (exponent == 0) {
return 1;
} else {
return base * power(base, exponent - 1);
}
}
console.log(power(2, 3));
// → 8
هذا قريب إلى حد ما من الطريقة التي يعرّف بها الرياضيون الرفع إلى قوة، ويمكن القول إنه يصف المفهوم بوضوح أكبر من الحلقة التي استخدمناها في الفصل 2. وتستدعي الدالة نفسها مرات عدة بأسس أصغر وأصغر لتحقيق الضرب المتكرر.
لكن لهذا التنفيذ مشكلة واحدة: في تنفيذات JavaScript النموذجية، يكون أبطأ بنحو ثلاث مرات من نسخة تستخدم حلقة for. فالجريان عبر حلقة بسيطة أرخص عموماً من استدعاء دالة مرات عدة.
معضلة السرعة مقابل الأناقة معضلة مثيرة للاهتمام. يمكنك رؤيتها كنوع من الطيف المتصل بين الملاءمة للإنسان والملاءمة للآلة. ويمكن جعل أي برنامج تقريباً أسرع بجعله أكبر وأكثر تعقيداً. وعلى المبرمج أن يجد التوازن المناسب.
في حالة دالة power، لا تزال نسخة غير أنيقة (بحلقة) بسيطة إلى حد ما وسهلة القراءة. ولا معنى كبيراً لاستبدالها بدالة تعاودية. لكن غالباً ما يتعامل البرنامج مع مفاهيم معقدة إلى حد أن التخلي عن بعض الكفاءة لجعل البرنامج أكثر مباشرة مفيد.
قد يكون القلق بشأن الكفاءة تشتيتاً. فهو عامل آخر يعقّد تصميم البرنامج، وعندما تفعل شيئاً صعباً بالفعل، قد يكون هذا الأمر الإضافي الذي تقلق بشأنه مشلّاً.
لذلك، عليك عموماً أن تبدأ بكتابة شيء صحيح وسهل الفهم. وإذا كنت قلقاً من أنه بطيء جداً—وهو ليس كذلك عادة، لأن معظم الشيفرة ببساطة لا تُنفَّذ مرات كافية لتستغرق وقتاً ذا شأن—فيمكنك القياس لاحقاً وتحسينه إن لزم.
ليس التعاود دائماً مجرد بديل غير كفء للحلقات. فبعض المسائل حقاً أسهل حلاً بالتعاود منها بالحلقات. وفي الغالب تكون هذه مسائل تتطلب استكشاف أو معالجة عدة «فروع»، قد يتفرع كل منها بدوره إلى فروع أكثر.
تأمل هذه الأحجية: بالبدء من العدد 1 والقيام مراراً إما بإضافة 5 أو بالضرب في 3، يمكن إنتاج مجموعة لا نهائية من الأعداد. كيف تكتب دالة، عند إعطائها عدداً، تحاول إيجاد متتالية من هذه الإضافات والضربات تنتج ذلك العدد؟ مثلاً، يمكن الوصول إلى العدد 13 بالضرب في 3 أولاً ثم إضافة 5 مرتين، بينما لا يمكن الوصول إلى العدد 15 إطلاقاً.
إليك حلاً تعاودياً:
function findSolution(target) {
function find(current, history) {
if (current == target) {
return history;
} else if (current > target) {
return null;
} else {
return find(current + 5, `(${history} + 5)`) ??
find(current * 3, `(${history} * 3)`);
}
}
return find(1, "1");
}
console.log(findSolution(24));
// → (((1 * 3) + 5) * 3)
لاحظ أن هذا البرنامج لا يجد بالضرورة أقصر متتالية من العمليات. فهو يكتفي عندما يجد أي متتالية.
لا بأس إن لم ترَ كيف تعمل هذه الشيفرة فوراً. لنمضِ خلالها لأنها تشكل تمريناً رائعاً في التفكير التعاودي.
الدالة الداخلية find هي التي تقوم بالتعاود الفعلي. تأخذ معطيين: العدد الحالي ونصاً يسجل كيف وصلنا إلى هذا العدد. وإذا وجدت حلاً، تُرجع نصاً يبيّن كيفية الوصول إلى الهدف. وإذا لم تستطع إيجاد حل بدءاً من هذا العدد، تُرجع null.
ولفعل ذلك، تنفذ الدالة واحداً من ثلاثة إجراءات. إذا كان العدد الحالي هو العدد الهدف، فالتاريخ الحالي طريقة للوصول إلى ذلك الهدف، لذا يُرجع. وإذا كان العدد الحالي أكبر من الهدف، فلا معنى لمواصلة استكشاف هذا الفرع لأن الإضافة والضرب كلاهما سيزيدان العدد فقط، فتُرجع null. وأخيراً، إذا كنا لا نزال دون العدد الهدف، تجرب الدالة المسارين الممكنين اللذين يبدآن من العدد الحالي باستدعاء نفسها مرتين، مرة للإضافة ومرة للضرب. وإذا أرجَع الاستدعاء الأول شيئاً ليس null، يُرجع. وإلا، يُرجع الاستدعاء الثاني، بغض النظر عما إذا كان ينتج نصاً أم null.
لفهم أفضل لكيفية إنتاج هذه الدالة للتأثير الذي نبحث عنه، لننظر إلى كل استدعاءات find التي تُجرى عند البحث عن حل للعدد 13:
find(1, "1")
find(6, "(1 + 5)")
find(11, "((1 + 5) + 5)")
find(16, "(((1 + 5) + 5) + 5)")
too big
find(33, "(((1 + 5) + 5) * 3)")
too big
find(18, "((1 + 5) * 3)")
too big
find(3, "(1 * 3)")
find(8, "((1 * 3) + 5)")
find(13, "(((1 * 3) + 5) + 5)")
found!
تشير المسافات البادئة إلى عمق مكدّس الاستدعاء. في المرة الأولى التي تُستدعى فيها find، تبدأ الدالة باستدعاء نفسها لاستكشاف الحل الذي يبدأ بـ (1 + 5). وسيتعاود ذلك الاستدعاء أكثر لاستكشاف كل حل مستمر ينتج عدداً أقل من أو يساوي العدد الهدف. ولأنه لا يجد حلاً يصيب الهدف، يُرجع null إلى الاستدعاء الأول. وهناك يجعل معامل ?? الاستدعاء الذي يستكشف (1 * 3) يحدث. ولهذا البحث حظ أوفر—فاستدعاؤه التعاودي الأول، عبر استدعاء تعاودي آخر، يصيب العدد الهدف. وتُرجع تلك الدالة الأعمق نصاً، ويمرر كل معامل ?? في الاستدعاءات الوسيطة ذلك النص، لتعود الحل في النهاية.
نمو الدوال
هناك طريقتان طبيعيتان إلى حد ما لإدخال الدوال إلى البرامج.
تحدث الأولى عندما تجد نفسك تكتب شيفرة مشابهة مرات عدة. تفضّل ألا تفعل ذلك، لأن كثرة الشيفرة تعني مساحة أكبر للعلل كي تختبئ ومواد أكثر ليقرأها من يحاول فهم البرنامج. لذا تأخذ الوظيفة المتكررة، وتجد لها اسماً جيداً، وتضعها في دالة.
أما الطريقة الثانية فهي أن تجد أنك تحتاج وظيفة لم تكتبها بعد وتبدو جديرة بدالة خاصة بها. فتبدأ بتسمية الدالة، ثم تكتب جسمها. وقد تبدأ حتى بكتابة شيفرة تستخدم الدالة قبل أن تعرّف الدالة نفسها فعلاً.
مدى صعوبة إيجاد اسم جيد لدالة مؤشر جيد على مدى وضوح المفهوم الذي تحاول تغليفه. لنمضِ عبر مثال.
نريد كتابة برنامج يطبع عددين: عدد الأبقار وعدد الدجاج في مزرعة، مع الكلمتين Cows وChickens بعدهما وأصفار مبطّنة قبل العددين بحيث يكونان دائماً من ثلاثة أرقام:
007 Cows
011 Chickens
يتطلب هذا دالة بمعطيين—عدد الأبقار وعدد الدجاج. لنبدأ بالبرمجة.
function printFarmInventory(cows, chickens) {
let cowString = String(cows);
while (cowString.length < 3) {
cowString = "0" + cowString;
}
console.log(`${cowString} Cows`);
let chickenString = String(chickens);
while (chickenString.length < 3) {
chickenString = "0" + chickenString;
}
console.log(`${chickenString} Chickens`);
}
printFarmInventory(7, 11);
كتابة .length بعد تعبير نصي تعطينا طول ذلك النص. وهكذا تستمر حلقات while في إضافة أصفار أمام نصوص الأعداد حتى تصبح بطول ثلاثة محارف على الأقل.
أُنجزت المهمة! لكن بينما نحن على وشك إرسال الشيفرة إلى المزارعة (مع فاتورة باهظة)، تتصل وتخبرنا أنها بدأت أيضاً بتربية الخنازير، وتطلب منا من فضلك أن نوسّع البرنامج ليطبع الخنازير أيضاً؟
بالتأكيد نستطيع. لكن بينما نحن في طور نسخ ولصق تلك الأسطر الأربعة مرة أخرى، نتوقف ونعيد التفكير. لا بد من طريقة أفضل. إليك محاولة أولى:
function printZeroPaddedWithLabel(number, label) {
let numberString = String(number);
while (numberString.length < 3) {
numberString = "0" + numberString;
}
console.log(`${numberString} ${label}`);
}
function printFarmInventory(cows, chickens, pigs) {
printZeroPaddedWithLabel(cows, "Cows");
printZeroPaddedWithLabel(chickens, "Chickens");
printZeroPaddedWithLabel(pigs, "Pigs");
}
printFarmInventory(7, 11, 3);
إنها تعمل! لكن ذلك الاسم، printZeroPaddedWithLabel، غير مريح قليلاً. فهو يجمع ثلاثة أشياء—الطباعة، وتبطين الأصفار، وإضافة تسمية—في دالة واحدة.
بدلاً من انتزاع الجزء المتكرر من برنامجنا جملةً، لنحاول انتقاء مفهوم واحد:
function zeroPad(number, width) {
let string = String(number);
while (string.length < width) {
string = "0" + string;
}
return string;
}
function printFarmInventory(cows, chickens, pigs) {
console.log(`${zeroPad(cows, 3)} Cows`);
console.log(`${zeroPad(chickens, 3)} Chickens`);
console.log(`${zeroPad(pigs, 3)} Pigs`);
}
printFarmInventory(7, 16, 3);
دالة باسم جميل وواضح مثل zeroPad تسهّل على من يقرأ الشيفرة معرفة ما تفعله. كما أن دالة كهذه مفيدة في مواقف أكثر من هذا البرنامج المحدد. مثلاً، يمكنك استخدامها للمساعدة في طباعة جداول أعداد منسّقة جيداً.
ما مدى الذكاء والتنوع الذي ينبغي أن تكون عليه دالتنا؟ يمكننا كتابة أي شيء، من دالة بسيطة للغاية لا تستطيع سوى تبطين عدد ليصبح بعرض ثلاثة محارف إلى نظام معقد معمّم لتنسيق الأعداد يتعامل مع الأعداد الكسرية والسالبة ومحاذاة النقاط العشرية والتبطين بمحارف مختلفة، وهكذا.
من المبادئ المفيدة أن تمتنع عن إضافة الذكاء ما لم تكن متأكداً تماماً من أنك ستحتاجه. قد يكون مغرياً كتابة «أطر عمل» عامة لكل جزء من الوظائف تصادفه. قاوم تلك الرغبة. فلن تنجز أي عمل حقيقي—ستكون مشغولاً جداً بكتابة شيفرة لا تستخدمها أبداً.
الدوال والتأثيرات الجانبية
يمكن تقسيم الدوال تقريباً إلى تلك التي تُستدعى من أجل تأثيراتها الجانبية وتلك التي تُستدعى من أجل قيمتها المُرجعة (رغم أنه من الممكن أيضاً أن يكون لها تأثيرات جانبية وتُرجع قيمة في الوقت نفسه).
دالة المساعدة الأولى في مثال المزرعة، printZeroPaddedWithLabel، تُستدعى من أجل تأثيرها الجانبي: فهي تطبع سطراً. أما النسخة الثانية، zeroPad، فتُستدعى من أجل قيمتها المُرجعة. وليس من قبيل المصادفة أن الثانية مفيدة في مواقف أكثر من الأولى. فالدوال التي تنشئ قيماً أسهل في الدمج بطرق جديدة من الدوال التي تؤدي تأثيرات جانبية مباشرة.
الدالة النقية (pure) نوع محدد من الدوال المنتجة للقيم، لا يكون لها تأثيرات جانبية فحسب، بل لا تعتمد أيضاً على تأثيرات جانبية من شيفرة أخرى—فهي مثلاً لا تقرأ ارتباطات عامة قد تتغير قيمتها. وللدالة النقية خاصية محببة: أنها عند استدعائها بالمعطيات نفسها تنتج دائماً القيمة نفسها (ولا تفعل أي شيء آخر). ويمكن استبدال استدعاء دالة كهذه بقيمتها المُرجعة دون تغيير معنى الشيفرة. وعندما لا تكون متأكداً من أن دالة نقية تعمل بشكل صحيح، يمكنك اختبارها بمجرد استدعائها، وتعرف أنه إذا عملت في ذلك السياق فستعمل في أي سياق. أما الدوال غير النقية فتميل إلى الحاجة إلى مزيد من السقالات لاختبارها.
ومع ذلك، لا داعي للشعور بالسوء عند كتابة دوال غير نقية. فالتأثيرات الجانبية مفيدة غالباً. فلا توجد طريقة لكتابة نسخة نقية من console.log مثلاً، وconsole.log جيد أن يكون موجوداً. كما أن بعض العمليات أسهل في التعبير عنها بكفاءة عندما نستخدم التأثيرات الجانبية.
الملخص
علّمك هذا الفصل كيف تكتب دوالك الخاصة. الكلمة المفتاحية function، عند استخدامها كتعبير، يمكنها إنشاء قيمة دالة. وعند استخدامها كجملة، يمكن استخدامها لتعريف ارتباط ومنحه دالة كقيمة له. ودوال السهم طريقة أخرى لإنشاء الدوال.
// عرّف f لتحمل قيمة دالة
const f = function(a) {
console.log(a + 2);
};
// عرّف g كدالة
function g(a, b) {
return a * b * 3.5;
}
// قيمة دالة أقل إسهاباً
let h = a => a % 3;
جزء أساسي من فهم الدوال هو فهم النطاقات. فكل كتلة تنشئ نطاقاً جديداً. والوسطاء والارتباطات المعلنة في نطاق معين محلية وغير مرئية من الخارج. أما الارتباطات المعلنة بـ var فتتصرف بشكل مختلف—إذ ينتهي بها الأمر في أقرب نطاق دالة أو في النطاق العام.
تقسيم المهام التي يؤديها برنامجك إلى دوال مختلفة مفيد. فلن تضطر إلى تكرار نفسك كثيراً، ويمكن للدوال أن تساعد في تنظيم البرنامج بتجميع الشيفرة في قطع تؤدي أشياء محددة.
التمارين
الحد الأدنى
قدّم الفصل السابق الدالة القياسية Math.min التي تُرجع أصغر معطياتها. يمكننا الآن كتابة دالة كهذه بأنفسنا. عرّف الدالة min التي تأخذ معطيين وتُرجع أصغرهما.
// اكتب شيفرتك هنا.
console.log(min(0, 10));
// → 0
console.log(min(0, -10));
// → -10
إظهار التلميح
إذا واجهت مشكلة في وضع الأقواس المعقوفة والأقواس العادية في الموضع الصحيح للحصول على تعريف دالة صالح، فابدأ بنسخ أحد الأمثلة في هذا الفصل وتعديله.
قد تحتوي الدالة على جمل return متعددة.
التعاود
رأينا أنه يمكننا استخدام % (معامل الباقي) لاختبار ما إذا كان عدد زوجياً أم فردياً باستخدام % 2 لمعرفة ما إذا كان يقبل القسمة على اثنين. إليك طريقة أخرى لتعريف ما إذا كان عدد صحيح موجب زوجياً أم فردياً:
- الصفر زوجي.
- الواحد فردي.
- لأي عدد آخر N، تكون زوجيته هي نفسها زوجية N - 2.
عرّف دالة تعاودية isEven تقابل هذا الوصف. ينبغي أن تقبل الدالة وسيطاً واحداً (عدداً صحيحاً موجباً) وتُرجع قيمة منطقية.
اختبرها على 50 و75. ولاحظ كيف تتصرف مع -1. لماذا؟ هل يمكنك التفكير في طريقة لإصلاح ذلك؟
// اكتب شيفرتك هنا.
console.log(isEven(50));
// → true
console.log(isEven(75));
// → false
console.log(isEven(-1));
// → ??
إظهار التلميح
ستبدو دالتك على الأرجح شبيهة إلى حد ما بالدالة الداخلية find في مثال findSolution التعاودي في هذا الفصل، بسلسلة if/else if/else تختبر أي الحالات الثلاث تنطبق. والـ else الأخير، المقابل للحالة الثالثة، يقوم بالاستدعاء التعاودي. وينبغي أن يحتوي كل فرع على جملة return أو أن يرتب بأي طريقة أخرى إرجاع قيمة محددة.
عند إعطائها عدداً سالباً، ستتعاود الدالة مرة تلو الأخرى، وتمرر لنفسها عدداً سالباً أكثر وأكثر، فتبتعد أكثر وأكثر عن إرجاع نتيجة. وستنفد مساحة المكدّس في النهاية ويتوقف التنفيذ.
عدّ الفاصولياء
يمكنك الحصول على المحرف N، أو الحرف، من نص بكتابة [N] بعده (مثلاً، string[2]). وستكون القيمة الناتجة نصاً يحتوي على محرف واحد فقط (مثلاً، "b"). وللمحرف الأول الموضع 0، ما يجعل الأخير موجوداً عند الموضع string.length - 1. بعبارة أخرى، نص من محرفين طوله 2، ولمحرفيه الموضعان 0 و1.
اكتب دالة تسمى countBs تأخذ نصاً كمعطاها الوحيد وتُرجع عدداً يشير إلى عدد محارف B الكبيرة الموجودة في النص.
بعد ذلك، اكتب دالة تسمى countChar تتصرف مثل countBs، إلا أنها تأخذ معطاً ثانياً يشير إلى المحرف الذي سيُعد (بدلاً من عد محارف B الكبيرة فقط). وأعد كتابة countBs لتستفيد من هذه الدالة الجديدة.
// اكتب شيفرتك هنا.
console.log(countBs("BOB"));
// → 2
console.log(countChar("kakkerlak", "k"));
// → 4
إظهار التلميح
ستحتاج دالتك إلى حلقة تنظر إلى كل محرف في النص. يمكنها تشغيل فهرس من صفر إلى واحد أقل من طوله (< string.length). وإذا كان المحرف في الموضع الحالي هو نفسه الذي تبحث عنه الدالة، تضيف 1 إلى متغير عدّاد. وبعد انتهاء الحلقة، يمكن إرجاع العدّاد.
احرص على جعل كل الارتباطات المستخدمة في الدالة محلية للدالة بتعريفها بشكل صحيح بالكلمة المفتاحية let أو const.