الفصل 08

العلل والأخطاء

تصحيح الأخطاء أصعب بمرتين من كتابة الشيفرة في المقام الأول. لذلك، إن كتبت الشيفرة بأكبر قدر ممكن من البراعة، فأنت بحكم التعريف لست ذكياً بما يكفي لتصحيحها.

— برايان كيرنيغان وبي. جاي. بلاوغر، عناصر أسلوب البرمجة

رسم توضيحي يُظهر حشرات متنوعة وأم أربعة وأربعين

العيوب في برامج الحاسوب تُسمى عادة العلل (bugs). ومن المريح للمبرمجين أن يتخيلوها أشياء صغيرة زحفت إلى عملهم بمحض المصادفة. لكننا في الواقع، بالطبع، من وضعها هناك بأنفسنا.

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

اللغة

كان يمكن للحاسوب أن ينبّهنا تلقائياً إلى أخطاء كثيرة لو كان يعرف ما نحاول فعله معرفة كافية. لكن تراخي JavaScript هنا عائق. فمفهومها للارتباطات والخصائص غامض بما يكفي لأنها نادراً ما تلتقط الأخطاء المطبعية قبل تشغيل البرنامج فعلياً. وحتى حينها، تسمح لك بفعل أشياء بلا معنى بوضوح دون اعتراض، مثل حساب true * "monkey".

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

لكن في كثير من الأحيان، لن يُنتج حسابك اللامعنى سوى NaN (ليس عدداً) أو قيمة undefined، بينما يواصل البرنامج سعيداً، مقتنعاً بأنه يفعل شيئاً ذا معنى. ولن تظهر العلّة إلا لاحقاً، بعد أن تكون القيمة الزائفة قد انتقلت عبر عدة دوال. وقد لا تُثير خطأً إطلاقاً، بل تُسبب بصمت أن يكون مخرَج البرنامج خاطئاً. وقد يكون العثور على مصدر مشكلات كهذه صعباً.

عملية العثور على الأخطاء — العلل — في البرامج تسمى تصحيح الأخطاء (debugging).

الوضع الصارم

يمكن جعل JavaScript أكثر صرامة قليلاً بتفعيل الوضع الصارم (strict mode). ويمكن فعل ذلك بوضع النص "use strict" في أعلى ملف أو جسم دالة. إليك مثالاً:

function canYouSpotTheProblem() {
  "use strict";
  for (counter = 0; counter < 10; counter++) {
    console.log("Happy happy");
  }
}

canYouSpotTheProblem();
// → ReferenceError: counter is not defined

الشيفرة داخل الأصناف والوحدات (وسنناقشها في الفصل 10) صارمة تلقائياً. ولا يزال السلوك القديم غير الصارم موجوداً فقط لأن بعض الشيفرة القديمة قد تعتمد عليه، ويعمل مصممو اللغة بجد لتجنب كسر أي برامج قائمة.

عادةً، عندما تنسى وضع let أمام ارتباطك، كما في counter في المثال، تُنشئ JavaScript بهدوء ارتباطاً عاماً وتستخدمه. أما في الوضع الصارم، فيُبلَّغ عن خطأ بدلاً من ذلك. وهذا مفيد جداً. ويجدر التنبيه مع ذلك إلى أن هذا لا يعمل عندما يكون الارتباط المعني موجوداً بالفعل في مكان ما في النطاق. في تلك الحالة، ستظل الحلقة تكتب فوق قيمة الارتباط بهدوء.

تغيير آخر في الوضع الصارم هو أن ارتباط this يحمل القيمة undefined في الدوال غير المستدعاة كطرائق. فعند إجراء استدعاء كهذا خارج الوضع الصارم، يشير this إلى كائن النطاق العام، وهو كائن خصائصه هي الارتباطات العامة. لذا إن استدعيت طريقة أو بانية استدعاءً خاطئاً في الوضع الصارم، ستُنتج JavaScript خطأً بمجرد أن تحاول قراءة شيء من this، بدلاً من الكتابة بسعادة إلى النطاق العام.

على سبيل المثال، انظر إلى الشيفرة التالية، التي تستدعي دالة بانية بدون كلمة new بحيث لا يشير this فيها إلى كائن مُنشأ حديثاً:

function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // عفوًا
console.log(name);
// → Ferdinand

نجح الاستدعاء الزائف لـ Person، لكنه أرجع قيمة undefined وأنشأ الارتباط العام name. أما في الوضع الصارم، فالنتيجة مختلفة.

"use strict";
function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // نسينا new
// → TypeError: Cannot set property 'name' of undefined

فنُخبَر فوراً بأن هناك شيئاً خاطئاً. وهذا مفيد.

لحسن الحظ، ستشتكي البواني المنشأة بترميز class دائماً إن استُدعيت بدون new، مما يجعل هذه مشكلة أصغر حتى في الوضع غير الصارم.

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

باختصار، وضع "use strict" في أعلى برنامجك نادراً ما يضر وقد يساعدك على اكتشاف مشكلة.

الأنواع

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

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

يمكنك إضافة تعليق كالتالي قبل دالة findRoute من الفصل السابق لوصف نوعها:

// (graph: Object, from: string, to: string) => string[]
function findRoute(graph, from, to) {
  // ...
}

هناك عدد من الاصطلاحات المختلفة لتعليق برامج JavaScript بالأنواع.

من خصائص الأنواع أنها تحتاج إلى إدخال تعقيدها الخاص لتكون قادرة على وصف شيفرة كافية لتكون مفيدة. ما رأيك في نوع دالة randomPick التي تُرجع عنصراً عشوائياً من مصفوفة؟ ستحتاج إلى إدخال متغير نوع، T، يمكن أن ينوب عن أي نوع، حتى تعطي randomPick نوعاً مثل (T[]) → T (دالة من مصفوفة من T إلى T).

عندما تكون أنواع البرنامج معروفة، يصبح من الممكن للحاسوب أن يتحقق منها نيابة عنك، فيشير إلى الأخطاء قبل تشغيل البرنامج. وهناك عدة لهجات من JavaScript تضيف أنواعاً إلى اللغة وتتحقق منها. وأشهرها يُسمى TypeScript. وإن كنت مهتماً بإضافة مزيد من الصرامة إلى برامجك، فأوصيك بتجربته.

في هذا الكتاب، سنواصل استخدام شيفرة JavaScript خام خطرة بلا أنواع.

الاختبار

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

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

الحواسيب جيدة في المهام المتكررة، والاختبار مهمة متكررة مثالية. والاختبار الآلي هو عملية كتابة برنامج يختبر برنامجاً آخر. وكتابة الاختبارات عمل أكثر قليلاً من الاختبار اليدوي، لكن بعد أن تفعلها، تكسب نوعاً من القوة الخارقة: لا يستغرق التحقق من أن برنامجك ما يزال يتصرف بشكل سليم في جميع الحالات التي كتبت لها اختبارات سوى ثوانٍ. وعندما تكسر شيئاً، ستلاحظه فوراً بدلاً من أن تصادفه عشوائياً في وقت لاحق.

تأخذ الاختبارات عادة شكل برامج صغيرة مُسمّاة تتحقق من جانب ما من شيفرتك. على سبيل المثال، قد تبدو مجموعة اختبارات للطريقة toUpperCase (قياسية، وعلى الأرجح اختبرها شخص آخر بالفعل) هكذا:

function test(label, body) {
  if (!body()) console.log(`Failed: ${label}`);
}

test("convert Latin text to uppercase", () => {
  return "hello".toUpperCase() == "HELLO";
});
test("convert Greek text to uppercase", () => {
  return "Χαίρετε".toUpperCase() == "ΧΑΊΡΕΤΕ";
});
test("don't convert case-less characters", () => {
  return "مرحبا".toUpperCase() == "مرحبا";
});

كتابة اختبارات كهذه تنزع إلى إنتاج شيفرة مكررة ومتعثرة نوعاً ما. لحسن الحظ، توجد قطع برمجية تساعدك على بناء مجموعات من الاختبارات (أجنحة الاختبار) وتشغيلها، بتوفير لغة (على شكل دوال وطرائق) مناسبة للتعبير عن الاختبارات، وبإخراج معلومات مفيدة عند فشل اختبار. وتُسمى هذه عادة مشغّلات الاختبارات (test runners).

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

تصحيح الأخطاء

بمجرد أن تلاحظ أن هناك شيئاً خاطئاً في برنامجك لأنه يسلك سلوكاً سيئاً أو ينتج أخطاءً، تكون الخطوة التالية هي معرفة ما المشكلة.

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

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

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

function numberToString(n, base = 10) {
  let result = "", sign = "";
  if (n < 0) {
    sign = "-";
    n = -n;
  }
  do {
    result = String(n % base) + result;
    n /= base;
  } while (n > 0);
  return sign + result;
}
console.log(numberToString(13, 10));
// → 1.5e-3231.3e-3221.3e-3211.3e-3201.3e-3191.3e-3181.3…

حتى لو رأيت المشكلة بالفعل، فتظاهر للحظة أنك لم ترها. نعلم أن برنامجنا معطوب، ونريد معرفة السبب.

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

ووضع بضع نداءات console.log مدروسة في البرنامج طريقة جيدة للحصول على معلومات إضافية عما يفعله البرنامج. في هذه الحالة، نريد أن تأخذ n القيم 13 ثم 1 ثم 0. لنكتب قيمتها عند بداية الحلقة.

13
1.3
0.13
0.013
…
1.5e-323

صحيح. قسمة 13 على 10 لا تنتج عدداً صحيحاً. فبدلاً من n /= base، ما نريده فعلاً هو n = Math.floor(n / base) بحيث يُنقل العدد «إزاحة» سليمة إلى اليمين.

وبديل استخدام console.log للتلصص على سلوك البرنامج هو استخدام قدرات المصحّح (debugger) في متصفحك. فتأتي المتصفحات بقدرة على تعيين نقطة توقف (breakpoint) عند سطر معين من شيفرتك. وعندما يصل تنفيذ البرنامج إلى سطر فيه نقطة توقف، يُوقَف مؤقتاً، ويمكنك فحص قيم الارتباطات عند تلك النقطة. لن أخوض في التفاصيل، لأن المصححات تختلف من متصفح لآخر، لكن ابحث في أدوات مطوّري متصفحك أو ابحث في الويب عن تعليمات.

وطريقة أخرى لتعيين نقطة توقف هي تضمين جملة debugger (تتكون ببساطة من تلك الكلمة المفتاحية) في برنامجك. وإن كانت أدوات مطوّري متصفحك نشطة، فسيتوقف البرنامج مؤقتاً كلما وصل إلى جملة كهذه.

انتشار الأخطاء

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

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

لنقل إن لديك دالة promptNumber تسأل المستخدم عن عدد وتُرجعه. ماذا ينبغي أن تُرجع إذا أدخل المستخدم «برتقالة»؟

أحد الخيارات أن تجعلها تُرجع قيمة خاصة. والخيارات الشائعة لهذه القيم هي null أو undefined أو -1.

function promptNumber(question) {
  let result = Number(prompt(question));
  if (Number.isNaN(result)) return null;
  else return result;
}

console.log(promptNumber("How many trees do you see?"));

الآن على أي شيفرة تستدعي promptNumber أن تتحقق مما إذا كان قد قُرئ عدد فعلي، وإن لم يكن، أن تتعافى بطريقة ما — ربما بالسؤال مرة أخرى أو بملء قيمة افتراضية. أو يمكنها أن تُرجع بدورها قيمة خاصة إلى مستدعيها للإشارة إلى أنها فشلت في فعل ما طُلب منها.

في مواقف كثيرة، غالباً عندما تكون الأخطاء شائعة ويجدر بالمستدعي أن يأخذها في الحسبان صراحةً، يكون إرجاع قيمة خاصة طريقة جيدة للإشارة إلى خطأ. لكن لهذا مساوئه. أولاً، ماذا لو كانت الدالة قادرة أصلاً على إرجاع كل نوع ممكن من القيم؟ في دالة كهذه، سيتعين عليك فعل شيء كتغليف النتيجة في كائن لتتمكن من التمييز بين النجاح والفشل، كما تفعل طريقة next في واجهة المُكرِّر.

function lastElement(array) {
  if (array.length == 0) {
    return {failed: true};
  } else {
    return {value: array[array.length - 1]};
  }
}

المشكلة الثانية في إرجاع القيم الخاصة هي أنها قد تؤدي إلى شيفرة متعثرة. فإذا استدعت قطعة من الشيفرة promptNumber عشر مرات، فعليها التحقق عشر مرات مما إذا كانت null قد أُرجعت. وإن كانت استجابتها لوجدان null هي مجرد إرجاع null بنفسها، فسيتعين على مستدعي الدالة بدورهم التحقق منها، وهكذا.

الاستثناءات

عندما لا تستطيع دالة المضي قدماً بشكل طبيعي، فما نود فعله غالباً هو التوقف عما نفعله والانتقال فوراً إلى مكان يعرف كيف يتعامل مع المشكلة. هذا ما يفعله التعامل مع الاستثناءات.

الاستثناءات آلية تجعل من الممكن للشيفرة التي تصادف مشكلة أن ترفع (أو تقذف) استثناءً. والاستثناء يمكن أن يكون أي قيمة. ورفع استثناء يشبه إلى حد ما إرجاعاً معززاً من دالة: فهو يقفز خارج الدالة الحالية وخارج مستدعيها أيضاً، وصولاً إلى أول استدعاء بدأ التنفيذ الحالي. ويُسمى هذا فكّ المكدس (unwinding the stack). ربما تذكر مكدس نداءات الدوال المذكور في الفصل 3. فالاستثناء ينحدر عبر هذا المكدس، طارحاً كل سياقات الاستدعاء التي يصادفها.

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

إليك مثالاً:

function promptDirection(question) {
  let result = prompt(question);
  if (result.toLowerCase() == "left") return "L";
  if (result.toLowerCase() == "right") return "R";
  throw new Error("Invalid direction: " + result);
}

function look() {
  if (promptDirection("Which way?") == "L") {
    return "a house";
  } else {
    return "two angry bears";
  }
}

try {
  console.log("You see", look());
} catch (error) {
  console.log("Something went wrong: " + error);
}

تُستخدم الكلمة المفتاحية throw لرفع استثناء. أما التقاطه فيتم بتغليف قطعة من الشيفرة في كتلة try، تليها الكلمة المفتاحية catch. وعندما تُسبب الشيفرة الموجودة في كتلة try رفع استثناء، تُقيَّم كتلة catch، مع ارتباط الاسم الموجود بين قوسين بقيمة الاستثناء. وبعد أن تنتهي كتلة catch — أو إن انتهت كتلة try دون مشكلات — يمضي البرنامج أسفل جملة try/catch بأكملها.

في هذه الحالة، استخدمنا البانية Error لإنشاء قيمة استثنائنا. هذه بانية JavaScript قياسية تُنشئ كائناً له خاصية message. وتجمع نسخ Error أيضاً معلومات عن مكدس الاستدعاءات الذي كان موجوداً عند إنشاء الاستثناء، وهو ما يسمى تتبّع المكدس (stack trace). وتُخزَّن هذه المعلومات في خاصية stack، ويمكن أن تكون مفيدة عند محاولة تصحيح مشكلة: فهي تخبرنا بالدالة التي وقعت فيها المشكلة وبالدوال التي أجرت الاستدعاء الفاشل.

لاحظ أن دالة look تتجاهل تماماً احتمال أن يخطئ promptDirection. وهذه هي الميزة الكبيرة للاستثناءات: فشيفرة التعامل مع الأخطاء ضرورية فقط عند النقطة التي يقع فيها الخطأ وعند النقطة التي يُعالج فيها. أما الدوال الواقعة بينهما فيمكنها نسيان الأمر كله.

حسناً، تقريباً...

التنظيف بعد الاستثناءات

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

وهذا يعني أنه عندما تكون للشيفرة عدة تأثيرات جانبية، فحتى لو بدا تدفق تحكمها «العادي» وكأنها ستحدث كلها دائماً، فقد يمنع استثناء بعضها من الحدوث.

إليك بعض الشيفرة المصرفية السيئة حقاً:

const accounts = {
  a: 100,
  b: 0,
  c: 20
};

function getAccount() {
  let accountName = prompt("Enter an account name");
  if (!Object.hasOwn(accounts, accountName)) {
    throw new Error(`No such account: ${accountName}`);
  }
  return accountName;
}

function transfer(from, amount) {
  if (accounts[from] < amount) return;
  accounts[from] -= amount;
  accounts[getAccount()] += amount;
}

تنقل دالة transfer مبلغاً من حساب معطى إلى آخر، طالبةً اسم الحساب الآخر في أثناء ذلك. وإذا أُعطي اسم حساب غير صالح، يقذف getAccount استثناءً.

لكن transfer أولاً تزيل المال من الحساب وثم تستدعي getAccount قبل أن تضيفه إلى حساب آخر. وإن قُوطعت باستثناء عند تلك النقطة، فستجعل المال يختفي فحسب.

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

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

ولأن ذلك ليس عملياً دائماً، فجمل try لها ميزة أخرى: يمكن أن تتبعها كتلة finally إما بدلاً من كتلة catch أو إضافة إليها. وكتلة finally تقول «مهما حدث، نفّذ هذه الشيفرة بعد محاولة تنفيذ الشيفرة في كتلة try».

function transfer(from, amount) {
  if (accounts[from] < amount) return;
  let progress = 0;
  try {
    accounts[from] -= amount;
    progress = 1;
    accounts[getAccount()] += amount;
    progress = 2;
  } finally {
    if (progress == 1) {
      accounts[from] += amount;
    }
  }
}

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

لاحظ أنه على الرغم من أن شيفرة finally تُنفَّذ عند قذف استثناء في كتلة try، فإنها لا تتدخل في الاستثناء. فبعد تنفيذ كتلة finally، يواصل المكدس تفكيكه.

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

الالتقاط الانتقائي

عندما يصل استثناء إلى قاع المكدس دون أن يُلتقط، تتولى البيئة التعامل معه. وما يعنيه هذا يختلف بين البيئات. ففي المتصفحات، يُكتب عادة وصف للخطأ في وحدة تحكم JavaScript (يمكن الوصول إليها عبر قائمة الأدوات أو المطوّر في المتصفح). أما Node.js، بيئة JavaScript بلا متصفح التي سنناقشها في الفصل 20، فهي أكثر حرصاً بشأن تلف البيانات. فهي تُجهض العملية كلها عند وقوع استثناء غير معالَج.

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

أما المشكلات التي يُتوقع حدوثها أثناء الاستخدام الروتيني، فالانهيار باستثناء غير معالَج استراتيجية فظيعة.

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

عندما يُدخل جسم catch، فكل ما نعرفه أن شيئاً ما في جسم try سبب استثناءً. لكننا لا نعرف ما الذي فعل ذلك ولا أي استثناء سبب.

لا توفر JavaScript (في سهو صارخ نوعاً ما) دعماً مباشراً لالتقاط الاستثناءات انتقائياً: فإما أن تلتقطها كلها أو لا تلتقط أياً منها. وهذا يجعل من المغري افتراض أن الاستثناء الذي تحصل عليه هو الذي كنت تفكر فيه عندما كتبت كتلة catch.

لكنه قد لا يكون كذلك. فقد يكون افتراض آخر قد انتُهك، أو تكون قد أدخلت علّة تُسبب استثناءً. إليك مثالاً يحاول مواصلة استدعاء promptDirection حتى يحصل على إجابة صالحة:

for (;;) {
  try {
    let dir = promtDirection("Where?"); // ← خطأ مطبعي!
    console.log("You chose ", dir);
    break;
  } catch (e) {
    console.log("Not a valid direction. Try again.");
  }
}

بنية for (;;) طريقة لإنشاء حلقة لا تنتهي من تلقاء نفسها عن قصد. ولا نخرج من الحلقة إلا عند إعطاء اتجاه صالح. لسوء الحظ، أخطأنا في كتابة promptDirection، مما سيؤدي إلى خطأ «متغير غير معرَّف». ولأن كتلة catch تتجاهل تماماً قيمة استثنائها (e)، بافتراض أنها تعرف ما المشكلة، فإنها تعامل خطأ الارتباط خطأً على أنه يشير إلى مدخل سيئ. ولا يُسبب هذا حلقة لا نهائية فحسب، بل «يدفن» أيضاً رسالة الخطأ المفيدة عن الارتباط المكتوب خطأً.

وكقاعدة عامة، لا تلتقط الاستثناءات التقاطاً شاملاً إلا لغرض «توجيهها» إلى مكان ما — مثلاً عبر الشبكة لإخبار نظام آخر بأن برنامجنا انهار. وحتى حينها، فكّر بعناية في الكيفية التي قد تخفي بها معلومات.

نريد التقاط نوع محدد من الاستثناءات. ويمكننا فعل ذلك بالتحقق في كتلة catch مما إذا كان الاستثناء الذي حصلنا عليه هو الذي يهمنا، وإن لم يكن، إعادة قذفه. لكن كيف نتعرف على استثناء؟

يمكننا مقارنة خاصية message فيه برسالة الخطأ التي نتوقعها. لكن هذه طريقة مهزوزة لكتابة الشيفرة — إذ سنستخدم معلومات مخصصة لاستهلاك البشر (الرسالة) لاتخاذ قرار برمجي. وبمجرد أن يغيّر أحدهم الرسالة (أو يترجمها)، ستتوقف الشيفرة عن العمل.

بدلاً من ذلك، لنعرّف نوعاً جديداً من الأخطاء ونستخدم instanceof للتعرف عليه.

class InputError extends Error {}

function promptDirection(question) {
  let result = prompt(question);
  if (result.toLowerCase() == "left") return "L";
  if (result.toLowerCase() == "right") return "R";
  throw new InputError("Invalid direction: " + result);
}

الصنف الجديد للأخطاء يمتد من Error. وهو لا يعرّف بانيته الخاصة، مما يعني أنه يرث بانية Error، التي تتوقع رسالة نصية كمعطى. بل إنه لا يعرّف أي شيء على الإطلاق — فالصنف فارغ. وتتصرف كائنات InputError مثل كائنات Error، إلا أن لها صنفاً مختلفاً نتعرف عليها به.

الآن يمكن للحلقة أن تلتقط هذه بعناية أكبر.

for (;;) {
  try {
    let dir = promptDirection("Where?");
    console.log("You chose ", dir);
    break;
  } catch (e) {
    if (e instanceof InputError) {
      console.log("Not a valid direction. Try again.");
    } else {
      throw e;
    }
  }
}

سيلتقط هذا نسخ InputError فقط ويترك الاستثناءات غير المتصلة تمر. وإن أعدت الخطأ المطبعي، فسيُبلَّغ عن خطأ الارتباط غير المعرَّف بشكل سليم.

التأكيدات

التأكيدات (assertions) فحوصات داخل برنامج تتحقق من أن شيئاً ما على الحال التي ينبغي أن يكون عليها. ولا تُستخدم للتعامل مع حالات قد تظهر في التشغيل الطبيعي بل للعثور على أخطاء المبرمج.

فمثلاً، إذا وُصفت firstElement بأنها دالة لا ينبغي أبداً استدعاؤها على مصفوفات فارغة، فقد نكتبها هكذا:

function firstElement(array) {
  if (array.length == 0) {
    throw new Error("firstElement called with []");
  }
  return array[0];
}

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

لا أوصي بمحاولة كتابة تأكيدات لكل نوع ممكن من المدخلات السيئة. فذلك عمل كثير وسيؤدي إلى شيفرة كثيرة الضجيج. ستحب أن تحتفظ بها للأخطاء السهلة الوقوع (أو التي تجد نفسك ترتكبها).

الملخص

جزء مهم من البرمجة هو العثور على العلل وتشخيصها وإصلاحها. ويمكن أن تصبح المشكلات أسهل في الملاحظة إن كان لديك جناح اختبارات آلي أو أضفت تأكيدات إلى برامجك.

المشكلات الناتجة عن عوامل خارجة عن سيطرة البرنامج ينبغي عادة التخطيط لها بنشاط. وأحياناً، عندما يمكن التعامل مع المشكلة محلياً، تكون القيم المُرجَعة الخاصة طريقة جيدة لتتبّعها. وإلا، فقد تكون الاستثناءات أفضل.

قذف استثناء يؤدي إلى فكّ مكدس الاستدعاءات حتى كتلة try/catch المحيطة التالية أو حتى قاع المكدس. وستُعطى قيمة الاستثناء إلى كتلة catch التي تلتقطه، والتي ينبغي أن تتحقق من أنه فعلاً النوع المتوقع من الاستثناء ثم تفعل شيئاً به. وللمساعدة في معالجة تدفق التحكم غير المتوقع الذي تسببه الاستثناءات، يمكن استخدام كتل finally لضمان أن قطعة من الشيفرة تعمل دائماً عند انتهاء كتلة.

التمارين

إعادة المحاولة

لنقل إن لديك دالة primitiveMultiply تضرب عددين في 20 بالمئة من الحالات، وتقذف في الـ 80 بالمئة الأخرى استثناءً من النوع MultiplicatorUnitFailure. اكتب دالة تغلّف هذه الدالة الركيكة وتواصل المحاولة فحسب حتى ينجح نداء، ثم تُرجع النتيجة بعده.

تأكد من أنك تتعامل فقط مع الاستثناءات التي تحاول التعامل معها.

class MultiplicatorUnitFailure extends Error {}

function primitiveMultiply(a, b) {
  if (Math.random() < 0.2) {
    return a * b;
  } else {
    throw new MultiplicatorUnitFailure("Klunk");
  }
}

function reliableMultiply(a, b) {
  // ضع شيفرتك هنا.
}

console.log(reliableMultiply(8, 8));
// → 64
إظهار التلميح

ينبغي بالتأكيد أن يحدث نداء primitiveMultiply في كتلة try. وينبغي لكتلة catch المقابلة أن تعيد قذف الاستثناء عندما لا يكون نسخة من MultiplicatorUnitFailure، وأن تضمن إعادة المحاولة عندما يكون كذلك.

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

الصندوق المقفل

انظر إلى الكائن التالي (المفتعل نوعاً ما):

const box = new class {
  locked = true;
  #content = [];

  unlock() { this.locked = false; }
  lock() { this.locked = true;  }
  get content() {
    if (this.locked) throw new Error("Locked!");
    return this.#content;
  }
};

إنه صندوق بقفل. وهناك مصفوفة داخل الصندوق، لكن لا يمكنك الوصول إليها إلا عندما يكون الصندوق مفتوحاً.

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

const box = new class {
  locked = true;
  #content = [];

  unlock() { this.locked = false; }
  lock() { this.locked = true;  }
  get content() {
    if (this.locked) throw new Error("Locked!");
    return this.#content;
  }
};

function withBoxUnlocked(body) {
  // ضع شيفرتك هنا.
}

withBoxUnlocked(() => {
  box.content.push("gold piece");
});

try {
  withBoxUnlocked(() => {
    throw new Error("Pirates on the horizon! Abort!");
  });
} catch (e) {
  console.log("Error raised: " + e);
}
console.log(box.locked);
// → true

ولنقاط إضافية، تأكد من أنه إذا استدعيت withBoxUnlocked بينما الصندوق مفتوح بالفعل، فسيبقى الصندوق مفتوحاً.

إظهار التلميح

يستدعي هذا التمرين كتلة finally. ينبغي أن تفتح دالتك الصندوق أولاً ثم تستدعي الدالة المعطاة من داخل جسم try. وينبغي لكتلة finally بعده أن تقفل الصندوق مرة أخرى.

وللتأكد من أننا لا نقفل الصندوق عندما لم يكن مقفلاً بالفعل، افحص قفله عند بداية الدالة وافتحه وأقفله فقط عندما يبدأ مقفلاً.