Chapter 8باگ‌ها و خطاها

Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.

Brian Kernighan and P.J. Plauger, The Elements of Programming Style
Picture of a collection of bugs

معمولا در برنامه‌های کامپیوتری، به خطاها باگ به معنای حشره می گویند. این نام‌گذاری حس خوبی به برنامه‌‌نویسان می دهد چون ایراد‌های برنامه را مانند حشرات کوچکی تصور می‌کنند که درون برنامه خزیده اند. البته که در حقیقت خود ما این باگ‌ها را ایجاد کرده ایم.

در یک برنامه که به تدریج ساخته شده است، تقریبا توانید باگ‌ها را به آن هایی که از اشتباه در منطق برنامه رخ داده اند و آن هایی که از اشتباه در هنگام تبدیل منطق برنامه به کد به وجود آمده اند، تقسیم بندی کنید. تشخیص و برطرف کردن باگ‌های دسته‌ی اول معمولا با سختی بیشتری نسبت به دسته دوم همراه است.

زبان

در هنگام برنامه‌نویسی، در صورتی که کامپیوتر به اندازه‌ی کافی از کاری که سعی در انجامش داریم بداند، می تواند خیلی از اشتباهات را به صورت خودکار شناسایی و به ما نشان دهد. اما سیستم تسامح محور جاوااسکریپت، خود یک مانع محسوب می شود. مفاهیم متغیرها و خاصیت‌ها در جاوااسکریپت به اندازه‌ی کافی ابهام دارند که جاوااسکریپت، به ندرت پیش از اجرای برنامه، می تواند به اشتباهات تایپی پی ببرد. و حتی بعد از آن نیز، به شما اجازه می دهد که بعضی کارهای کاملا بدون معنی مانند محاسبه true * "monkey" را بدون تولید خطا انجام دهید.

مواردی وجود دارد که در صورت بروز، جاوااسکریپت نسبت به آن ها واکنش نشان می‌دهد. مثلا، نوشتن برنامه‌ای که از قواعد دستوری زبان پیروی نکند باعث بروز خطا می شود. دیگر موارد، مانند فراخوانی چیزی که از نوع تابع نیست یا اشاره به یک خاصیت روی یک مقدار تعریف نشده، باعث بروز خطایی می شود که در هنگام انجام آن دستور، گزارش می شود.

اما اغلب، انجام محاسبات بی‌معنا باعث تولید NaN (مقدار غیر عددی) می شود یا مقدار undefined را تولید می کند و برنامه با شادمانی ادامه می یابد و فرض می کند که آن محاسبه عمل معناداری بوده است. این اشتباه در آینده خودش را بروز می دهد زمانی که آن مقدار ناخواسته (جعلی) در طول برنامه بین توابع متعدد حرکت می کند. ممکن است اصلا خطایی تولید نکند اما به صورت چراغ‌خاموش باعث تولید خروجی غلط در برنامه می‌شود. پیدا کردن منبع این گونه خطاها می‌تواند واقعا سخت باشد.

روند پیدا کردن خطاها – باگ‌ها – را در برنامه‌ها، دیباگ کردن اشکال زدایی می نامند.

حالت سخت‌گیرانه (Strict Mode)

به وسیله‌ی فعال کردن حالت strict یا سخت‌گیرانه، می توان جاوااسکریپت را کمی بیشتر سخت‌گیر کرد. این کار را می توان با قرار دادن رشته‌ی "use strict" در بالای یک فایل یا بدنه‌ی یک تابع انجام داد:

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

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

در حالت عادی، اگر فراموش کنید که let را ابتدای متغیر خود بگذارید، مثل متغیر counter در مثال، جاوااسکریپت بی سروصدا متغیری با همین نام در فضای عمومی (global) ایجاد کرده و از آن استفاده می کند. در حالت strict، به جای این کار یک خطا به شما نشان داده می شود. این کار خیلی مفید است. البته باید توجه داشت که اگر متغیر قبلا در فضای عمومی تعریف شده باشد دیگر خطایی تولید نمی شود، و مقدار آن توسط متغیر همنام در حلقه دوباره مقدار دهی می شود.

یک تفاوت دیگر در حالت strict این است که در توابعی که به صورت متد فراخوانی نمی‌شوند، متغیر this مقدار undefined را خواهد داشت. بیرون از این حالت (strict)، متغیر this به شی‌ء فضای سراسری (global) اشاره می کند، شیئی که خاصیت‌هایش همان متغیرهای سراسری می‌باشند. بنابراین، اگر به صورت تصادفی متدی یا سازنده‌ای را به شکل نادرست در حالت strict فراخوانی کنید، جاوااسکریپت به محض اینکه به قسمت خواندن مقدار this برسد به جای رجوع به فضای سراسری ، خطا تولید می کند.

به عنوان مثال، کد پیش رو را در نظر بگیرید، کدی که یک تابع سازنده را بدون استفاده از کلیدواژه‌ی new فراخوانی می کند که در نتیجه this مربوط به آن، به شیء تازه ساخته شده، اشاره نخواهد کرد:

function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // oops
console.log(name);
// → Ferdinand

بنابراین این فراخوانی جعلی Person اجرا خواهد شد اما مقدار undefined را تولید کرده و متغیر name را در فضای سراسری ایجاد می کند. در حالت strict نتیجه متفاوت خواهد بود.

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

بلافاصله به ما اعلام می شود که خطایی وجود دارد که این اعلام بسیار سودمند است.

خوشبختانه، سازنده‌هایی که با دستور class ایجاد می شوند اگر بدون new فراخوانی شوند، خطا تولید می کنند که این باعث می شود کمی از مشکل حتی در حالت غیر strict کاسته شود.

در حالت strict چند اتفاق دیگر نیز می افتد. در این حالت نمی توان تابعی تعریف کرد که پارامتر‌های مختلف آن نام یکسانی داشته باشند. همچنین بعضی از قسمت‌های مشکل‌ساز زبان را نیز به طور کلی حذف می کند. (مثل دستور with، که به علت بدرد نخور بودن در این کتاب بحث نمی شود.).

کوتاه اینکه، قرار دادن "use strict" در بالای برنامه به ندرت مشکل ایجاد می کند و ممکن است به شما در پیدا کردن و رفع یک مشکل کمک کند.

انواع

بعضی زبان‌های برنامه‌نویسی علاقمند هستند نوع متغیر‌ها و عبارت‌ها را قبل از اجرای برنامه در اختیار داشته باشند. با این کار، به محض استفاده‌ی نادرست از یک نوع داده، شما را از آن آگاه می کنند. جاوااسکریپت انواع داده را فقط زمانی در نظر می گیرد که برنامه را اجرا می کند، حتی در همان زمان هم سعی می کند که به صورت ضمنی مقدارها را به نوعی که انتظارش را دارد تبدیل کند، که این کار زیاد سودمند نیست.

با این حال، انواع داده چارچوب مفیدی برای بحث در مورد برنامه ها فراهم می کنند. خیلی از اشتباهات از آنجا ناشی می شود که در مورد نوع داده‌ای که به یک تابع ارسال می شود یا از آن دریافت می شود ابهام وجود دارد. اگر این گونه اطلاعات را مکتوب داشته باشید، کمتر دچار سردرگمی خواهید شد.

می توانید توضیحی شبیه زیر به بالای تابع goalOrientedRobot مربوط به فصل پیش، اضافه کنید و نوع آن را توصیف کنید:

// (VillageState, Array) → {direction: string, memory: Array}
function goalOrientedRobot(state, memory) {
  // ...
}

سبک‌های گوناگونی برای یادداشت کردن انواع داده در برنامه‌های جاوااسکریپت وجود دارد.

یک نکته که در مورد انواع داده وجود دارد این است که لازم است پیچیدگی خودشان را داشته باشند زیرا تنها در صورتی مفید‌ هستند که بتوانند کدهای کافی را توصیف کنند. به نظر شما نوع داده‌ی تابع randomPick که یک عنصر تصادفی را از آرایه برمی گرداند چیست؟ لازم است تا یک متغیر نوع معرفی کنید، T، که بتواند برای هر نوعی استفاده شود؛ در نتیجه بتوانید به تابع randomPick یک نوع شبیه ([T]) → T اختصاص دهید ( تابعی از آرایه‌ای از Tها به یک T).

زمانی که انواع داده‌ی یک برنامه شناخته شدند، اکنون می توان از کامپیوتر برای بررسی آن‌ها استفاده نمود تا بتوانیم اشتباهات را قبل از اجرا تشخصی دهیم. گویش‌های متعددی از جاوااسکریپت وجود دارد که انواع داده را به زبان اضافه کرده و بررسی می کنند. محبوب ترین آن ها TypeScript است. اگر علاقه دارید تا صلابت و استحکام بیشتری به برنامه هایتان اضافه کنید، پیشنهاد می کنم که با آن آشنا شوید.

در این کتاب ما از همان کدهای خام مشکل‌زای و بدون نوع جاوااسکریپت استفاده خواهیم کرد.

آزمودن (Testing)

اگر زبان برنامه نویسی قرار نیست کمک خاصی به ما در پیدا کردن اشتباهات بکند، بایستی آن ها را از راهی سخت تر پیدا کنیم: با اجرای برنامه و مشاهده‌ی اینکه آیا درست کار می کند یا خیر.

انجام این کار به صورت دستی و به تعداد زیاد واقعا ایده بدی است. نه تنها آزاردهنده است، بلکه در حقیقت غیر موثر خواهد بود؛ به این دلیل که با هر تغییر کوچک، زمان زیادی برای آزمودن همه چیز از ابتدا صرف می شود.

کامپیوترها کارهای تکراری را خیلی خوب انجام می دهند، و آزمودن یک کار تکراری ایده‌آل محسوب می شود. آزمودن خودکار روندی است که در آن برنامه‌ای نوشته می شود تا یک برنامه‌ی دیگر را بیازماید. نوشتن یک آزمون کار بیشتری از آزمودن دستی یک برنامه نیاز دارد، اما به محض اینکه آن را نوشتید به نوعی یک قدرت ماورایی بدست خواهید آورد: فقط چند ثانیه لازم است تا مطمئن شوید برنامه‌تان در همه‌ی شرایطی که برایش آزمون نوشته اید درست کار می کند یا خیر. زمانی که قسمتی از برنامه با مشکل روبرو می شود، بلافاصله متوجه آن خواهید شد، تا اینکه در آینده به صورت تصادفی با آن روبرو شوید.

آزمون‌ها معمولا به شکل برنامه‌های کوچکی که هر کدام برچسبی دارند نوشته می شوند که بعضی از جنبه‌های کد شما را بازبینی می کنند. به عنوان مثال، یک مجموعه‌ی آزمون برای متد 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 runner) می گویند.

آزمودن بعضی کدها آسان‌تر است. عموما، هر چه بیشتر کد برنامه با اشیاء بیرونی در ارتباط باشد، به وجود آوردن فضایی که آزمون در آن صورت پذیرد سخت تر می شود. سبک برنامه‌نویسی‌ای که در فصل پیش آمد، که از مقدارهای پایای مستقل استفاده می کرد، نسبت به استفاده از اشیاء تغییر پذیر، قابلیت آزمودن آسان‌تری دارد.

اشکال‌ زدایی

بعد از اینکه متوجه وجود مشکلی در برنامه‌تان شدید چه به علت کارکرد اشتباه یا نمایش خطا، گام بعدی پیدا کردن مشکل است.

گاهی اوقات مشکل واضح است. متن خطا به خط خاصی از برنامه اشاره می کند و اگر به توضیحاتی که در خطا آمده و خطی که خطا در آن رخ داده است دقت کنید، معمولا مشکل را پیدا می کنید.

اما همیشه این طور نیست. گاهی اوقات خطی که مشکل را بروز داده فقط اولین جایی بوده است که مقدار مشکل‌دار، که خود در جای دیگری تولید شده، به صورت نادرستی به کار گرفته شده است. اگر تمرین‌هایی که در فصل‌ها پیش آمده است را حل کرده باشید حتما با موارد اینچنینی روبرو شده اید.

برنامه‌ی زیر سعی می کند یک عدد صحیح را بر اساس مبنای داده شده (دهدهی، دودویی،…)، به یک رشته تبدیل کند. برای این کار تابع به صورت مداوم، آخرین رقم را می گیرد و عدد را تقسیم می‌کند تا از آن رقم خلاص شود. اما خروجی عجیب غریب آن نشان می دهد که برنامه باگ دارد.

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 در برنامه برای دانستن رفتار برنامه، استفاده از ابزارهای اشکال‌زدایی مرورگر است. مرورگرها قابلیتی دارند که می توان با استفاده از آن در یک خط خاص از کد، یک نقطه‌ی توقف (breakpoint) ایجاد کرد. زمانی که اجرای برنامه به آن خط که دارای نقطه‌ی توقف است برسد ، برنامه متوقف می شود و می توانید مقادیر متغیرها را در آن نقطه بررسی کنید. قصد ندارم وارد جزئیات شوم زیرا ابزارهای رفع خطا در مرورگرهای مختلف متفاوت هستند ، می توانید به قسمت ابزارهای توسعه‌دهنده‌ی (Developer Tools) مرورگر خودتان رجوع کنید یا در اینترنت در باره‌ی آن جستجو کنید.

روش دیگر برای ایجاد یک نقطه‌ توقف استفاده از دستور debugger در برنامه است ( همین دستور با همین کلیدواژه). اگر ابزارهای توسعه‌دهنده مرورگر شما فعال است برنامه به محض اینکه به این دستور برسد متوقف می گردد.

انتشار خطا (Error propagation)

متاسفانه نمی‌توان به‌وسیله‌ی برنامه‌نویس از بروز همه‌ی مشکلات برنامه جلوگیری کرد. اگر برنامه شما با جهان بیرون به هر شکلی در ارتباط باشد، ممکن است ورودی ناقصی دریافت کند که باعث کار اضافی و بیش از حد شود یا ارتباط با شبکه مختل شود.

اگر تنها برای خودتان برنامه نویسی می کنید، می توانید این گونه مشکلات را تا زمانی که رخ نداده اند، در نظر نگیرید. اما اگر چیزی می سازید که قرار است توسط دیگران استفاده شود، معمولا لازم است که در صورت بروز مشکل، برنامه به طور کامل از کار نیفتد و واکنش بهتری بروز دهد. گاهی کار درست این است که با وجود دریافت ورودی نامناسب به اجرای برنامه ادامه دهیم. در دیگر شرایط، خوب است گزارش علت مشکل را پیش از بستن برنامه به کاربر نشان دهیم. به هر حال در هر دو موقعیت، برنامه بایستی به صورت فعال کاری نسبت به مشکل به وجود آمده انجام دهد.

فرض کنید تابعی به نام promptNumber دارید که از کاربر می خواهد تا عددی را وارد کند تا آن را برگرداند. اگر کاربر ورودی “orange” را بفرستد، خروجی تابع چه خواهد بود.؟

یک گزینه میتواند بازگرداندن یک مقدار خاص باشد. انتخاب رایج برای این شرایط، 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 را فراخوانی می کند، باید بررسی کند که یک عدد واقعی خوانده شده باشد و در غیر این صورت، به نحوی باید آن را پوشش دهد – شاید با درخواست دوباره یا ارسال یک مقدار عددی پیش فرض. همچنین می تواند دوباره یک مقدار خاص را به فراخواننده خود برگرداند تا نشان دهد که مشکلی رخ داده است.

در بسیاری از موقعیت ها، مخصوصا زمانی که خطاها رایج هستند و تابع فراخواننده باید صراحتا آن‌ها را در نظر بگیرد، بازگرداندن یک مقدار خاص راه خوبی برای شناسایی بروز یک خطا محسوب می شود. روش خوبی است اما مشکلات خود را نیز دارد. اول اینکه اگر تابع می توانست هر نوع ممکنی از مقدار را برگرداند چه خواهد شد؟ در تابعی با این ویژگی، برای اینکه بتوانید بین موفقیت و شکست تمایز ایجاد کنید، باید کاری انجام دهید؛ مانند قراردادن نتیجه‌ی تابع درون یک شیء.

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

مشکل بعدی با بازگرداندن مقدار‌های خاص این است که این کار می تواند منجر به کدی نامناسب بشود. اگر کدی تابع promptNumber را 10 مرتبه فراخوانی کند، باید 10 بار چک کند که آیا null برگردانده شده یا خیر. و اگر حاصل بررسی null، خود با برگرداندن null مشخص شود، فراخواننده این تابع باید به آن نیز رسیدگی کند و الی آخر.

استثناءها (Exception)

زمانی که یک تابع نمی تواند به صورت عادی به کار خود ادامه‌ دهد، کار درست این است که تابع متوقف شده و بلافاصله کنترل برنامه به قسمتی منتقل شود که برای حل این مشکل پیش بینی شده است. این کاری است که مدیریت استثناء انجام می دهد.

استثناءها مکانیزمی هستند که برای کدی که دچار مشکل شده است این امکان را فراهم می کنند تا بتواند یک استثناء تولید (صادر) کند. یک استثناء می تواند هر مقداری باشد. صدور یک استثناء تاحدی شبیه به یک ابرخروجی از یک تابع است: نه تنها از تابع بیرون می آید بلکه از فراخواننده تابع نیز خارج می شود تا به اولین فراخوانی‌ای برسد که اجرای فعلی را شروع کرده است. به این کار بازکردن پشته می گویند. شاید پشته‌ی فراخوانی توابع را که در فصل 3 بحث شد به یاد داشته باشید. یک استثناء، این پشته را باز کرده و تمامی زمینه‌های فراخوانی‌ای را که می بیند از پشته بیرون می‌کشد.

اگر استثناءها همیشه تا انتهای پشته‌ حرکت کنند، کار مفیدی صورت نداده‌اند. با این کار فقط راهی جدید برای بهم ریختن برنامه‌تان فراهم ساخته اند. قدرت آن ها در این واقعیت است که می توانید “موانعی” را در پشته ایجاد کنید تا بتوانید این استثناءها را هنگامی که در حال درنوردیدن پشته هستند بگیرید (catch) (به دام بیاندازید). به محض اینکه یک استثناء را بگیرید، می توانید کاری برای حل مشکل انجام دهید و بعد از آن، برنامه به اجرای خود ادامه دهد.

به مثال توجه کنید:

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 استفاده کرده ایم تا مقدار استثناء را تولید کنیم. این تابع یک سازنده‌ی استاندارد جاوااسکریپت است که یک شیء با خاصیتی به نام message تولید می کند. در اکثر محیط‌های جاوااسکریپت، نمونه‌هایی که با این سازنده ایجاد می شوند، اطلاعاتی درباره‌ی پشته‌ی فراخوانی دارند که در هنگام بروز استثناء وجود داشته است که اصطلاحا به آن ردپای پشته (stack trace) می گویند. این اطلاعات در خاصیت stack ذخیره می گردند و می توانند در زمان اشکال‌زدایی مفید باشند: تابعی که مشکل در آن رخ داده و توابعی که آن فراخوانی مشکل‌دار را انجام داده اند، به ما نشان داده می شوند.

توجه داشته‌باشید که تابع look احتمال به مشکل خوردن تابع promptDirection را اصلا در نظر نمی‌گیرد. این یک مزیت بزرگ استفاده از استثناءها است: کد مدیریت خطا فقط جایی که خطا رخ می دهد یا جایی که خطا مدیریت می شود مورد نیاز است. توابع بین این دو می توانند اصلا به آن نپردازند.

بسیارخوب، تقریبا …

پاکسازی بعد از استثناءها

تاثیر یک استثناء در برنامه، نوعی دیگر از جریان کنترل است. هر عملی که باعث تولید یک استثناء بشود، تقریبا هر فراخوانی تابع و دسترسی به خاصیت، ممکن است کنترل برنامه را از کد شما بگیرد.

این یعنی وقتی یک کد اثرات جانبی متعددی دارد، حتی اگر جریان کنترل عادی آن به نظر برسد که همیشه برقرار خواهد بود، بروز یک استثناء ممکن است از اجرای بعضی از آن ها جلوگیری کند.

در اینجا کد بانکی بسیار بدی را مشاهده خواهید کرد:

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

function getAccount() {
  let accountName = prompt("Enter an account name");
  if (!accounts.hasOwnProperty(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 در نظر گرفته شده است. می توان بعد از try، به جای بلاک catch یا علاوه بر آن، از یک بلاک finally استفاده کرد. یک بلاک 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، پشته به بازشدن خود ادامه می دهد.

نوشتن برنامه‌هایی که همیشه به شکل ایده‌آل عمل کند حتی زمانی که یک استثناء در موقعیت‌های پیش بینی نشده رخ می دهد، کار بسیار دشواری است. بسیار از برنامه‌نویسان خودشان را به زحمت نمی اندازند و چون معمولا استثناءها در شرایط خاصی رخ می دهند، این مشکل به ندرت به وجود می‌آید و کسی متوجه‌ آن نمی شود. خوب یا بد بودن آن بستگی به میزان خسارتی دارد که در صورت بروز به بار خواهد آورد.

گرفتن استثناءها به صورت گزینشی

زمانی که یک استثناء تا انتهای پشته بدون اینکه جایی گرفته شود حرکت می کند، در انتها توسط محیط اجرایی مدیریت می شود. معنای این عبارت برای محیط‌های مختلف متفاوت است. در مرورگرها، توصیفی از خطا معمولا در کنسول جاوااسکریپت نوشته می شود (که می توان در قسمت ابزار مروگر یا منوی توسعه‌دهنده (Developer Menu) آن را پیدا کرد). در node.js، محیط بدون مرورگر مبتنی بر جاوااسکریپت که در فصل 20 به آن خواهیم پرداخت، دقت بیشتری درباره‌ی خرابی داده‌ها لحاظ می شود. در صورت وجود یک استثناء مدیریت نشده، تمامی روند برنامه متوقف می شود.

برای اشتباهات برنامه نویس، اغلب بهتر است بگذارید که خطا مسیر خودش را طی کند. یک استثناء مدیریت نشده، دلیل خوبی برای مشکل دار بودن یک برنامه است و کنسول جاوااسکریپت در مرورگرهای مدرن، اطلاعاتی در مورد فراخوانی‌هایی که در پشته در هنگام بروز مشکل وجود داشته اند، برای شما فراهم می کند.

برای مشکلاتی که پیش بینی می شود در هنگام استفاده از برنامه رخ دهد، متوقف شدن برنامه به خاطر یک استثناء مدیریت نشده، استراتژی وحشتناکی است.

استفاده‌های نادرست از زبان مانند ارجاع به متغیری که وجود ندارد، درخواست خاصیتی از مقدار null یا فراخوانی چیزی که از نوع تابع نیست، نیز باعث تولید استثناء می شود. این گونه استثناءها را نیز می توان گرفت و مدیریت کرد.

زمانی که اجرای برنامه به بدنه‌ی catch می رسد، می دانیم که چیزی در بلاک try باعث تولید استثناء شده است. اما نمی دانیم چه چیزی یا کدام استثناء رخ داده است.

جاوااسکریپت (نسبتا در یک غفلت آشکار) از امکان گزینش مدیریت استثناءها به صورت رسمی پشتیبانی نمی کند: یا همه‌ی استثناءها را می گیرید یا هیچ کدام را نمی‌گیرد. این باعث می شود که در زمان نوشتن بلاک catch تصور کنید استثنائی که دریافت کرده اید همانی هست که به آن فکر می کردید.

اما ممکن است آن نباشد. شاید این استثناء از فرض‌های اشتباه دیگری پدید آمده باشد یا ممکن است باگی را به‌وجود آورده باشید که باعث تولید یک استثناء شده است. در اینجا مثالی را مشاهده‌ می کنید که سعی می کند فراخوانی تابع promptDirection را تا زمانی که یک پاسخ معتبر دریافت کند ادامه دهد:

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

ساختار for (;;) روشی است که عامدانه حلقه‌ای ایجاد می‌کنیم که توسط خودش پایان نمی یابد. زمانی حلقه را می شکنیم که تابع یک جهت معتبر دریافت کند. اما promptDirection اشتباه تایپ شده است و خطای “undefined variable” را تولید می‌کند. بلاک 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 را برگردانیم‌ ( چیزی که در صورت خواندن خانه‌ای از آرایه که وجود ندارد، دریافت می کنید)، روش بالا باعث می شود که برنامه در صورت استفاده نادرست کلا متوقف شود. استفاده از این روش موجب می شود که اشتباهاتی از این دست کمتر نادیده گرفته شوند و آسان تر بتوان علت‌شان را پیدا کرد.

پیشنهاد نمی کنم که از assertion برای هر گونه ورودی نادرست استفاده کنید. این نیاز به کار زیادی دارد و کدی شلوغ تولید می کند. بهتر است که آن ها را برای اشتباهاتی که انجامشان ساده است (یا شما زیاد مرتکب آن ها می شوید) نگه دارید.

خلاصه

اشتباهات و ورودی های نادرست واقعیت های زندگی هستند. یکی از بخش های مهم برنامه نویسی، پیداکردن، تشخیص و رفع باگ‌ها و اشکالات است. اگر مجموعه‌ای از آزمون‌های خودکار تعریف کرده باشید یا از assertion ها در برنامه‌هایتان استفاده نمایید، می توانید مشکلات برنامه را آسان‌تر شناسایی کنید.

باید مشکلاتی که توسط عوامل بیرون از کنترل برنامه ایجاد ‌می‌شوند را نیز به خوبی مدیریت کرد. گاهی اوقات، مثلا زمانی که می توان یک مشکل را به صورت محلی مدیریت کرد، استفاده از مقادیر خروجی خاص،‌ روش خوبی برای مدیریت خطاها محسوب می‌شود. در دیگر موارد، استفاده از استثناء‌ها ممکن است گزینه‌ی بهتری باشد.

صدور یک استثناء باعث می شود که پشته‌ی فراخوانی تا زمانی که به بلاک 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) {
  // Your code here.
}

console.log(reliableMultiply(8, 8));
// → 64

فراخوانی تابع primitiveMultiply باید قطعا درون یک بلاک try قرار گیرد. بلاک catch مربوط به آن باید استثناء دریافتی را درصورتی که نمونه‌ای از MultiplicatorUnitFailure نباشد، دوباره صادر کند و اطمینان حاصل کند که در صورت وجود استثناء مورد نظر، تابع را دوباره فراخوانی می‌کند.

برای انجام عمل تلاش مجدد، می توانید از یک حلقه استفاده کنید که فقط زمانی متوقف می‌شود که یک فراخوانی، موفق می شود - مانند مثال look که پیش‌تر در این فصل آمد - یا از روش بازگشتی استفاده کنید و امیدوار باشید که با رشته‌ای از شکست روبرو نمی‌شود که در این صورت باعث سرریز پشته خواهد شد.

جعبه قفل شده

شیء (ساختگی) زیر را در نظر بگیرید:

const box = {
  locked: true,
  unlock() { this.locked = false; },
  lock() { this.locked = true;  },
  _content: [],
  get content() {
    if (this.locked) throw new Error("Locked!");
    return this._content;
  }
};

این شیء یک جعبه است که دارای یک قفل می‌باشد. درون این جعبه‌، یک آرایه وجود دارد؛ اما فقط زمانی می توانید به آن دسترسی داشته باشید که جعبه قفل نباشد. دسترسی مستقیم به خاصیت خصوصی _content ممنوع است.

تابعی به نام withBoxUnlocked بنویسید که یک مقدار تابع را به عنوان آرگومان دریافت می کند، جعبه را باز می کند، تابع را اجرا می کند، و بعد اطمینان حاصل می کند که قبل از برگرداندن آن، جعبه دوباره قفل شده باشد، فارغ از اینکه تابع آرگومان به صورت نرمال برگردانده شده است یا یک استثناء تولید کرده است.

const box = {
  locked: true,
  unlock() { this.locked = false; },
  lock() { this.locked = true;  },
  _content: [],
  get content() {
    if (this.locked) throw new Error("Locked!");
    return this._content;
  }
};

function withBoxUnlocked(body) {
  // Your code here.
}

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

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

برای برداشتن گامی بیشتر، مطمئن شود که اگر تابع withBoxUnlocked را در هنگام باز بودن جعبه فراخوانی می کنید، جعبه باز باقی بماند.

این تابع به یک بلاک finally نیاز دارد. تابع شما ابتدا باید جعبه را باز کند سپس تابع موجود در آرگومان را از درون یک بلاک try فراخوانی کند. بلاک finally بعد از آن باید جعبه را دوباره قفل کند.

برای حصول اطمینان از قفل نکردن جعبه زمانی که پیش از این باز بوده است، قفل بودن‌ آن را در ابتدای تابع چک کنید و فقط زمانی آن را باز و بسته کنید که در ابتدا قفل بوده است. hint}}