فصل 8باگ‌ها و خطاها

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

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

برای برداشتن گامی بیشتر، مطمئن شوید که اگر تابع 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

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

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