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