توسعهدهنده نرمافزار
رسیدگی به خطاها (قسمت دوم)
مطلبی که میخوانید ترجمهی قسمت ۲۱ از رادیو مهندسی نرمافزار است. رادیو مهندسی نرمافزار هر یکی دو هفته یک بار مصاحبهای دربارهی یکی از موضوعات حوزهی مهندسی نرمافزار با افراد خبره و با تجربه در موضوع مورد بحث ترتیب میدهد.
در این قسمت، مارکوس ولتر و مارکوس آرنو نگاه عمیقتری به استثناها (Exception) و شرایط خطا، نحوه دستهبندی آنها و نحوه مقابله با آنها خواهند داشت. نگاهی به سطوح مختلف ضمانتهایی خواهیم داشت که یک تکه کد در ارتباط با شرایط استثنا میتواند فراهم کند و با تعدادی از سرمشقها (Best Practice) و مصالحههایی (Trade-off) که دارند بحث را به پایان میرسانیم.
فکر میکنم ما در مورد دو دسته شرایط استثنا (Exception) بحث کردیم. آیا میتوانید به طور خلاصه آنها را توضیح دهید؟
بله، خوب است که با واژهشناسی (Terminology) شروع کنیم. نه به خاطر اینکه من به این جور موارد رسمی علاقهمندم بلکه به این خاطر که خوب است برای ادامه، معانی لغات شفاف باشد. در ارتباط با استثنا (Exception) و شرایط آن، تمایز از آن جا ناشی میشود که آیا واقعاً برای برنامهنویس مورد انتظار (Expected) هستند یا خیر. استثناهای موردانتظار، مواردی هستند که درباره آنها اطلاع دارید و میتواند رخ دهد و فردی ممکن است بخواهد آن را به نحوی رسیدگی کند. مواردی که فراخواننده کد شما ممکن است انتظار آن را داشته باشد یا شما بخواهید که انتظارش را داشته باشد. به عنوان مثال، وقتی شبکه یا پایگاه داده از کار افتاده است یا وقتی که کسی، یک نام غیرمعتبر وارد کرده است. در این زمان، فراخواننده را آگاه کرده و انتظار دارید که فراخواننده به آن رسیدگی کند. شما در مستندات کلاس، متد، زیرسیستم یا ... مسیرهای ممکن را مستند میکنید.
و دسته دوم؟
دسته دوم چیزهایی هستند که موردانتظار نیستند. در وهله اول، فکر کردن و اطلاع دادن آنها غریب به نظر میرسد. منشأ اصلی استثناهایی که مورد انتظار نیستند، باگها یا هر شرایطی هستند که کسی فکر نکرده است که نیاز به رسیدگی دارد. اگر این موارد رخ دهد، نمیتوان آن را ترمیم کرد فقط میتوان مطمئن شد که چیز خیلی بدی رخ نداده است و سیستم را خاموش کنیم و مواردی از این قبیل.
بنابراین خودتان را به این محدود کنید که شرایط استثنا موردانتظار را به روش خوشتعریفی رسیدگی کنید و در مورد شرایطی که انتظار آن نمیرود، یک بلاک دریافت (Catch) خواهید داشت که در همه موارد آنها را رسیدگی میکند که اطمینان مییابد که چیز خیلی بدی رخ نداده است و سیستم مانند شرایط مورد انتظار، به شکل برازندهای، رفتار میکند.
من معتقدم صحبت در مورد شرایط استثنا، خطاها و مشکلات نرمافزار، یکی از رهاشدهترین موضوعات در مهندسی نرمافزار است. رسیدگی به خطاها، به خوبی شناخته نشده است. تعداد زیادی تعریف و روش مختلف وجود دارد اما تعداد خیلی خیلی کمی، قاعده تعریفشده مشخص در مورد آن داریم که چگونه باید آن را انجام داد و چگونه میتوان آن را خوب انجام داد.
بله، توصیه ما در این مورد این است که اصطلاحات را از هم مجزا کنید. شرایط استثنایی که به نوعی انتظارش را دارید خطا (Error) خوانده میشوند و شرایط استثنایی ای که به هیچ عنوان انتظارش نمیرفته را استثنا (Exception) میگوییم. و این نباید با اصطلاحات زبان جاوا به اشتباه گرفته شود.
بله، دقیقاً. زبان جاوا این موارد را به خوبی از هم مجزا نمیکند. اما این تمایز برای نحوه رسیدگی به آنها در سطح معماری بسیار مفید خواهد بود.
من فکر میکنم مدلی که در مورد سطوح ضمانت (Guarantee) داریم بر روی طراحی معماری نرمافزار نقش دارد.
بله، دقیقاً. در واقع، تعریف این سطوح ضمانت به هرب ساتر برمیگردد که در کتابش، ++Exceptional C آنها را مستند کرد. سطوح ضمانت که ما میخواهیم معرفی کنیم مرتبط با همان سطوحی است که ساتر در کتابش گفته است. البته دقیقاً همان دستهبندیهای او نیست اما به آن نزدیک است. آن کتاب، کتاب خیلی خوب و قابل توصیهای است.
اولین و بنیادیترین سطح ضمانت، میگوید: هر اتفاقی بیافتد، باید پایدار باشیم. باید بتوانیم بعد از رخداد مشکل، دوباره فراخوانی داشته باشیم و نباید نشتی منابع (Resource Leak) داشته باشیم. کدهای زیادی هستند که این سطح از ضمانت را پشتیبانی نمیکند با این وجود باید، این سطح از ضمانت، یک سطح مبنایی باشد که همه بخشهای کد محصول آن را برآورده کند و اگر در جایی، بعضی جنبههای این سطح بنیادی ضمانت برآورده نمیشود، باید فکر کنیم که مشکل کجاست که میتواند هرگونه مشکل یا استثنایی باشد که منجر به نشتی یک منبع میشود مثلاً میتواند یک دستگیره (Handle) پایگاه داده، دستگیره GUI، سوکت، نخ (Thread) یا هر چیز دیگری باشد. شرایطی که یک منبعی از سیستمعامل برای همیشه باقی بماند یا حداقل به اندازه طول عمر پردازش (Process) باقی بماند که به همان اندازه باقی ماندن برای همیشه، بد است. همه بخشهای کد باید اطمینان یابند که هر شرایط استثنایی که رخ دهد، منابع آزاد خواهند شد. در زبانهای برنامهنویسی مختلف روشهای مختلفی برای این امر وجود دارد. در زبان جاوا، بلاکهای finally را داریم. در زبان ++C، توابع مخرب (Destructor) را داریم. هر زبانی ابزار خاص خود را دارد اما این مسأله واقعاً مهمی است که تحت هیچ شرایطی هیچ منبعی نباید نشتی داشته باشد.
مورد دیگر این است که باید پس از فراخوانی بخشی از کد، در یک وضعیت پایدار باقی بمانیم خصوصاً در مورد توابع سازنده (Constructor) این مطلب صادق است. اگر تابع سازنده استثنایی (Exception) ایجاد کند به این معناست که شیء مقداردهی اولیه نشده و در دسترس نیست. این یکی از فواید توابع سازنده است که یا یک شیء کاملاً آماده و مقداردهی اولیه شده خواهید داشت و یا اصلاً شیءای نخواهید داشت. اما فرض کنید، مقداردهی اولیه را مدتی بعد و از طریق تابع مقداردهی اولیه (Initializer) انجام میدهید و در حین آن مشکلی پیش میآید. در اینجا ممکن است یک شیء نیمه آماده داشته باشید. در این صورت، حتی اگر نشتی منبع نداشته باشید، این امر میتواند در آینده، به هر نوع رفتار تعریفنشدهای بیانجامد و ضمانت بنیادی (Fundamental Guarantee) رعایت نمیشود. ضمانت بنیادی میگوید که هر اتفاقی که بیافتد باید پایدار و در یک وضعیت تعریفشده و قابل فراخوانی باشیم. برای ضمانت بنیادی کافیست که با رخداد هر خطایی یک استثنا (Exception) پَرت شود اما در ضمن لازمست که در یک وضعیت تعریفشده قرار بگیریم.
وقتی تابع سازنده، به خطا میخورد واقعاً چه کار میتوانم بکنم؟ در آن صورت یک شیء ای که در یک وضعیت نامطمئن قرار دارد خواهم داشت. آن موقع چه کار باید بکنم؟
در مورد تابع سازنده، عموماً مشکلی نخواهد بود زیرا اگر تابع سازنده بصورت غیرعادی، شکست بخورد، زبانی که استفاده میکنید اطمینان میدهد که ارجاعی به شیء موردنظر بدست نخواهید آورد. اکثر زبانها این طور رفتار میکنند. بنابراین اگر تابع سازنده با نوعی استثنا، شکست بخورد، اصلاً شیءای نخواهید داشت که بخواهید ارجاع یا اشارهگری به آن داشته باشید. مطمئناً در مورد شیء غیرقابل فراخوانی، مشکلی نخواهد بود.
بنابراین من یک اشارهگر تهی (Null) یا چیزی شبیه به آن خواهم داشت.
شما از تابع سازنده، یک استثنا (Exception) میگیرید. هیچ اشارهگری برگشت نخواهد شد و زبانها اطمینان میدهند که اشارهگری که بتوانید آنها را به متغیرها انتساب دهید، نخواهید داشت. به همین علت است که اگر بخواهید ضمانت بنیادی را برآورده کنید، مقداردهی اولیه به اشیاء در توابع سازنده، ایده خوبی است.
بسیار خوب. این ضمانت سطح اول بود؟
بله، بگذارید یک مثال اضافه کنم. به عنوان مثال در دنیای جاوا، فریمورک Hibernate یک نمونه بد در این مورد است. در آن جا، هندل به پایگاه داده، جلسه (Session) خوانده میشود که یک شیء پیچیده بزرگ است. هر جلسه، یک نهانگاه (Cache) سطح یک دارد که اشیاء را نگهداری و ثبت میکند. مستندات Hibernate میگوید که در صورتی که در جلسه هر نوع استثنایی رخ دهد (که در حالت خوشبینانه میتواند یک لاگ یا هر چیز دیگری باشد)، جلسه به یک وضعیت مستندنشده میرود و لازمست که آن را دور انداخته و با یک جلسه جدید آغاز کنید. این برعکس چیزی است که ضمانت بنیادی میگوید. این یک طراحی بد است. ایده خوبی است که Hibernate طور دیگری رفتار کند.
بیایید به سطح بعدی ضمانت برویم: ضمانت ابتدایی (Basic Guarantee). ضمانت ابتدایی، بر روی ضمانت بنیادی ساخته میشود اما همچنین نیازمند این است که شیء، قابل استفاده باشد. ضمانت ابتدایی میگوید که: نباید نشتی منبع وجود داشته باشد و شیء باید در یک وضعیت پایدار و تعریفشده قرار بگیرد. ضمانت بنیادی به شما اجازه میدهد که هنگام بروز خطا یک نشانهای قرار دهید که مشکلی رخ داده است و سپس برای هر متدی، یک حفاظی قرار دهید که استثنایی (Exception) پَرت کنید که بگوید این شیء در یک وضعیت غیرمجاز قرار دارد و از یک شیء دیگر استفاده کنید اما ضمانت ابتدایی میگوید که هر اتفاق و استثنایی که رخ دهد شیء باید همچنان قابل استفاده باشد. در صورتی که از مؤلفههای بدونحالت (Stateless) استفاده کنید این نوع ضمانت اغلب فراهم خواهد بود. زیرا در مؤلفههای بدونحالت، حالت قابل مشاهدهای وجود ندارد و هر اتفاقی که رخ دهد مؤلفهها قابل فراخوانی خواهند بود. دادهها وارد میشوند، عبور میکنند اما مؤلفهها میتوانند دوباره و دوباره و دوباره فراخوانی شوند. بله، ممکن است استثنایی رخ دهد اما هر اتفاق یا استثنایی که رخ دهد مؤلفه را غیرمعتبر نمیکند و همچنان میتواند استفاده شود. به همین علت است که بدونحالت بودن برای داشتن کدهای محکم (Robust Code)، خوب است و به همین علت است که مؤلفههای بدون حالت، در مقایسه با مؤلفههای حالتدار، رسیدگی به خطا را تا حد زیادی سادهتر میکند.
سطح سوم ضمانت، ضمانت قوی (Strong Guarantee) است که از اصطلاحات هرب ساتر است. ضمانت قوی، معانی تراکنش (Transaction) و عقبگرد (Rollback) را الزام میکند. اگر یک فراخوانی شکست بخورد، ضمانت قوی الزام میکند که شیء دقیقاً در حالت قابل مشاهدهای (Observable State) قرار میگیرد که قبل از فراخوانی قرار داشته است. تفاوت بین حالت درونی و حالت قابل مشاهده این است که یک شیء ممکن است یک نهانگاه (Cache) داخلی داشته باشد. ضمانت قوی الزام نمیکند که این نهانگاه داخلی به قبل بازگردد بلکه باید هرگونه وضعیت قابلمشاهده از خارج از شیء (که تنها به خاطر بهینه کردن کارایی نیستند) به عقب برگردد. مفهوم عقبگرد، زمانی مطلوب است که شما با حالات اشیاء سروکار دارید. اگر عقبگرد نمیداشتید، فراخواننده مجبور میبود که قبل از فراخوانی یک کپی بگیرد و حالت قبلی شیء را نگه دارد و با پیچیدگیهایی مواجه بود. خیلی بهتر است که این کارها به صورت درونی در خود مؤلفه فراخوانیشده انجام شود.
شیء تغییرناپذیر (Immutable) یک روش طراحی خوب برای رسیدن به ضمانت قوی است. اگر یک شیء تغییرناپذیر داشته باشید هر اتفاقی که بیافتد حالتش تغییر نمیکند. اگر فراخوانی داشته باشید که بخواهد حالت را تغییر دهد، یک کپی تغییریافته از شیء برگشت داده میشود اما شیء اصلی تغییر نمیکند بنابراین به وضوح مفهوم عقبگرد برآورده میشود زیرا شیء اصلی اصلاً تغییر نمیکند.
گاهی اوقات علاقهمندیم که تراکنشهای انحصاری داشته باشیم و فراخوانیهای مختلف، تثبیت (Commit) و عقبگردهای (Rollback) منحصر به خودشان را داشته باشند. اما دشوار است که بخواهید یک چنین سیستم پایگاه دادهای را خودتان بنویسید و عموماً نمیخواهیم این کار را انجام دهیم. عموماً روشی که استفاده میشود این است که در لایه منطق کسبوکار (Business Logic) از مؤلفههای بدون حالت (Stateless) استفاده میشود و حالتها داخل پایگاه داده قرار میگیرد و مفهوم عقبگرد را پایگاه داده فراهم میکند که این روش آسانتر از این است که بخواهیم این کارها را در داخل مؤلفههایی که بالای لایه پایگاه داده قرار دارند، انجام دهیم.
بسیار خوب، این ضمانت قوی بود و ضمانت بعدی، ضمانت پَرت نکردن (No Throw) است.
درست است. ضمانت پَرت نکردن، همان چیزی را تضمین میکند که اسمش میگوید. هرچه اتفاق بیافتد، نباید چیزی پَرت کنید. چنین چیزی خیلی نادر است. خصوصاً در زبانهایی مانند #C و Java خیلی نادر است. زیرا در آن جا هر فراخوانی که داشته باشید ممکن است به خطای داخلی ماشین مجازی (Virtual Machine) یا کم آوردن حافظه (Out of Memory) و موارد این چنینی بیانجامد بنابراین چنین چیزی به ندرت در زبانهایی مانند #C یا جاوا ضرورت پیدا میکند. ولی این ضمانت، اگر به لایههای زیرین و داخل سیستمعامل نزدیک شوید، یا اگر به سطوح زیرین ماشین نزدیک شوید، در مورد سیستمهای تعبیه شده و در مورد سیستمهای پایگاه داده، اهمیت مییابد. در آن جا مهم است که بخشی از کد داشته باشید که بتوانید روی این حساب کنید که هیچ چیزی پَرت نمیشود.
نکته کلیدی این است که نخواهید به بالاترین سطح ضمانت ممکن برسید. برای رسیدن به بالاترین سطح باید بیشترین هزینه را بکنید که اغلب بصرفه نیست بلکه باید به طراحیتان فکر کنید که کدتان چه سطحی از ضمانت را برآورده میکند یا به عبارت دیگر هر بخشی از کد من، نیاز است که چه سطحی از ضمانت را برآورده کند. نکته کلیدی، این است که همه اجزاء سیستم میبایست ضمانت بنیادی را برآورده کنند: هر اتفاقی که بیافتد نباید نشتی منبع داشته باشید و هر اتفاقی که بیافتد نباید کدی داشته باشید که در یک وضعیت تعریفنشده قرار بگیرد و باید همچنان در دسترس استفادهکنندگان باشد. بعد از آن میتوانید به برآورده کردن ضمانت ابتدایی هم فکر کنید: هر اتفاقی که بیافتد مؤلفهها باید قابل استفاده مجدد باشند. و ضمانت قوی: باید مفاهیم تراکنش و عقبگرد فراهم شود و ضمانت پَرت نکردن: بخشی از کد باید باشد که بتوانیم در مورد آن اطمینان داشته باشیم که هر اتفاقی از جمله هر شرایط استثنایی که بیافتد با آن به صورت برازندهای، رفتار کند.
فکر میکنم که خیلی سخت باشد که در مورد مؤلفههای بزرگ سیستم به ضمانت پَرت نکردن برسیم.
بله، مطمئناً سخت است و اغلب ضرورتی ندارد ولی وقتی در مورد سیستمهای تعبیه شده با مأموریتهای بحرانی فکر میکنم، امیدوارم یک مصلح که در قلب سیستم قرار دارد یا یک سیستم کنترل سبک که در پسزمینه قرار دارد، بخشهایی باشند که ضمانت پَرت نکردن را فراهم میکنند. بله، مطمئناً کدهایی درون سیستم وجود دارند که این گونهاند اما مؤلفههای بزرگتر باید شامل هرگونه شرایط مشکلداری باشند. موافقم که برای بسیاری از سیستمها از جمله سیستمهای متداول کسبوکار، خیلی دشوار است که به چنین ضمانتی برسیم و چنین زحمتی بصرفه نیست.
برگردیم به اصطلاحات قبلی شما. اینکه میبایست فقط با خطاها (Error) برخورد کنیم و برطرفشان کنیم و یاد بگیریم که چطور با آنها برخورد کنیم. اما اساساً خطاها، استثنا (Exception) نیستند.
سئوال خوبی است. اول از همه، درست است که این مهم است که با خطاها با این روشهای دارای ضمانت برخورد کنیم. جهت یادآوری عرض میکنم که خطاها، مشکلات مورد انتظار هستند. اما شرایطی مانند سیستمهای تعبیهشده با مأموریتهای بحرانی هستند که ضمانت پَرت نکردن را ارائه میکنند و لازم است که حتی با شرایط استثنا هم بصورت برازندهای برخورد کنیم. بنابراین میتوان گفت: بله، عموماً در هنگام طراحی سیستم در مورد برخورد با خطاها فکر میکنید اما در مورد باگها و این گونه چیزها، فقط به چیزهایی از قبیل پایان دادن برازنده برنامه فکر میکنید. اما مواردی هم هست که نیاز دارید یا مطلوب است که در مورد باگها هم به این فکر کنید که تا چه حد قابلیت اطمینان میخواهید در مورد آنها داشته باشید.
فکر میکنم این مطلب ما را به موضوع دیگری که در سطح معماری در ارتباط با برخورد با استثنا ها داریم، میبرد. مهمترین سرمشق (Best Practice) در مورد برخورد با استثناها این است که استثناها را در مرزهای سیستم رسیدگی کنید. این که اگر یک سیستم بزرگ با ماژولهای مختلف دارید آن را تفکیک کرده و استثناها را تنها در مرزهای سیستم، رسیدگی کنید. ممکن است یک سیستم با تنها یک مؤلفه داشته باشید و استثناها را در GUI یا دقیقاً در سطح زیر GUI رسیدگی کنید. مثلاً این که یک پیغام بالا بیاید که: «یک خطای داخلی رخ داده است و من برنامه را بصورت برازندهای میبندم.» یا اینکه: «خطایی رخ دادهاست. من کاری نمیکنم و دعا میکنم که سیستم به صورت پایدار ادامه یابد.» اما مهم است که همه جای کدتان را با بلاکهای دریافت (Catch) برای رسیدگی به استثناها پر نکنید زیرا باعث ناخوانا شدن کد میشود و خیلی دشوار میشود که بفهمیم چه کار دارد انجام میشود.
این توصیه شما در مورد استثناها بود اما در مورد خطاها چطور؟
خطاها متفاوت هستند. خطا یعنی یک مشکل قابل انتظار رخ داده است. بخش قابل انتظار بودن که در تعریف خطا داریم به این معناست که کسی فکر میکند که میخواهید آن را به صورت محلی رسیدگی کنید. شما ممکن است به آن علاقه داشته باشید و بخواهید با آن برخورد کنید. بنابراین اگر میتوانید و برایتان معنی میدهد آن را به صورت محلی، رسیدگی کنید. مثال آن، خطا خوردن اتصال است. اگر پایگاه داده، پایین آمده باشد و مؤلفهای بگوید که اتصال از بین رفته است. در آنصورت شما یک استثنا (Exception) فنی میگیرید که البته در زبان اصطلاحات ما، به آن خطا (Error) میگوییم. در آن صورت ممکن است بخواهید که برای اتصال به پایگاه داده تلاش مجدد داشته باشید یا اینکه به پایگاه داده دیگری، تعویض کنید که به ویژگیهای کیفی سرویس سیستمتان وابسته است. یا مثلاً در شرایطی که شبکه، پایین آمده باشد و دادهها بر روی سرور ذخیره شده باشند، ممکن است بخواهید در عوض، دادهها را از نهانگاه (Cache) بخوانید یا اگر فراخوانی، مربوط به ذخیره کردن دادهها در سرور باشد ممکن است بخواهید آن را در یک صف قرار دهید تا بعداً ارسال کنید.
این یک استراتژی معماری است که در مورد این فکر کنید که چطور میتوانید خطاها را بصورت محلی رسیدگی کنید. این به آن معنا نیست که مانند مثال پایگاه داده یا استفاده از نهانگاه، همیشه میتوانید هنگام در دسترس نبودن شبکه، این کارها را انجام دهید. بله، تعداد زیادی از سیستمها این کار را میکنند اما خیلی از سیستمها هم این کار را نمیکنند. در این صورت، این امر باعث می شود که این موارد از گروه خطاهای قابل انتظار به گروه موارد غیرقابل انتظار که مانند باگ با آنها برخورد میشود منتقل شوند. بنابراین تمایز بین آنها خیلی قطعی نیست اما اگر اموری باشند که در دسته خطاها قرار بگیرند و قابل انتظار باشند، باید با آنها به صورت محلی برخورد شود و روشهایی از قبیل تلاش مجدد برای آنها استفاده شود. یا لااقل باید در طراحیتان به این امور فکر کنید.
اما نکته مهم این است که هیچ گاه یک استثنا یا خطای تکنیکی را دریافت (Catch) نکنید و - بدون این که کار دیگری بکنید- فقط انتظار داشته باشید که کار درست پیش خواهد رفت. به علت وجود استثناهای چِکشده (Checked Exception) در جاوا به این کار گرایش فراوانی وجود دارد زیرا در آن جا مجبور هستید به نوعی با قضیه برخورد کنید. این یک روش بد است که فقط استثنا را دریافت کنیم و امیدوار باشیم کسی آن را نفهمد. اگر استثنا وجود دارد بگذارید تا عبور کند و آن را در یکی از مرزهای زیرسیستمها، مرز تیم یا مرز زیرسیستمهای توزیعشده، رسیدگی کنید و اگر مشکل یک خطا است، در مورد برخورد با آن به صورت محلی فکر کنید و اگر نمیتوانید به صورت محلی برخورد کنید بگذارید عبور کند و آن را اساساً تبدیل به استثنا کنید.
بسیار خوب، فکر میکنم در ادامه چند قاعده سرانگشتی برایمان داشته باشید.
بله، دقیقاً. این قواعد سرانگشتی برگرفته از کتاب Zidosley (ممکن است نام نویسنده بدرستی تشخیص داده نشده باشد - مترجم) هستند. آنها دقیقاً مطابق با آن کتاب نیستند ولی این قواعد از آنجا الهام گرفته شده است. اولین قاعده سرانگشتی این است که بین خطا (Error) و استثنا (Exception) تمایز قائل شوید. وقتی دارید مؤلفه یا بخشی از سیستم، و یک واسط (Interface) قابل فراخوانی از بیرون را طراحی میکنید، خوب است که به این فکر کنید که چه چیزهایی هست که میتواند با مشکل مواجه شود و برای کدام یک از آنها معنی میدهد که فراخواننده با آن برخورد داشته باشد و برای کدام یک از آنها معنی نمیدهد. خوب است که در این مورد فکر کنید اما تمایز دقیق آن، سخت است. شما نمیدانید چه کسانی کد شما را فراخوانی میکنند و چگونه درگیر آن میشوند.
یک مثال که تمایز آن دشوار است، اعتبارسنجی (Authorization) است. یک روش متداول این است که اگر اعتبارسنجی ناموفق بود، یک استثنا پَرت کنیم. آیا چنین چیزی مورد انتظار است یا مورد انتظار نیست؟ اگر مورد انتظار باشد، ما میتوانیم همه جا آن را داشته باشیم. همه جای کد، ممکن است چنین خطایی تولید شود. اگر بخواهیم بصورت استثنا با آن برخورد کنیم و آن را در سطح بالا، رسیدگی کنیم، دیگر نمیتوانیم به صورت محلی آن را رسیدگی کنیم. این نشان میدهد که ما چنین مواردی داریم که باید به این تمایز دادنها فکر کنیم. اما بیش از حد در مورد آن فکر نکنید و تلاش کنید بیش از حد در بکارگیری این تمایزها سختگیری نکنید. نکته کلیدی، این است که مستنداتی در این مورد فراهم کنید که با یک فراخوانی چه مشکلاتی میتواند پیش بیاید و چه استثناهای فنی و چه نوع خطاهایی میتواند تولید شود. این مستندات میتواند توسط یک تولیدکننده خودکار مستندات API تهیه شود یا میتواند در ابزار تست خودکارتان باشد و یا از هر طریق دیگری فراهم شود. به هرحال به یک روشی باید مستند شود.
قاعده خوب سرانگشتی دیگر این است که خطاها و استثناها را خودمستند (Self Document) کنید. همان طور که در قسمت اول رسیدگی به خطا گفتیم، چندین گروه مختلف هستند که به اطلاعات خطاها علاقهمندند. اول، کاربران هستند. راضی کردن آنها اغلب ساده است. اگر مشکلی پیش آمده که یک شرایط خطای معنیدار برای آنها نیست، اجازه دهید که تعامل داشته باشند و به آنها بگویید که یک خطای داخلی رخ داده است. گروه دیگر، مدیرسیستمها هستند که روی کامپیوترهای مراکز داده کار میکنند. آنها نیاز به اطلاعات مشخص دارند. اما مهمترین گروهی که بیشترین میزان جزییات را نیاز دارند، برنامهنویسان هستند. هرگاه نوعی استثنا فنی یا خطایی رخ دهد لازم است که شامل همه دادههای مورد نیاز برای فهمیدن آن، برای بازسازی شرایط زمینهای آن (Context) و برای رسیدگی به آن فراهم باشد. این به آن معناست که همواره کلاسهای استثنا شامل دادههایی هستند که به آنها اضافه شده است و یک سلسلهمراتب ارثبری دارید که نحوه برخورد متفاوت با آنها را بازتاب میدهد.
نکته مهم این است که سطح درستی از جزییات را نگهداری کنید. اغلب، این کار، کار دشواری است. برخی سطوح جزییات خیلی مفید است مثلاً اینکه سرور بعد از رخداد استثنا پایدار بوده یا پایدار نبوده است. برخی افراد یک سلسله مراتب عظیم از انواع استثناها میسازند که خیلی ریزدانه است درحالیکه نحوه رسیدگی به استثناهای مختلف آن تفاوتی ندارد. این، کار هزینهبری است که صرفهای ندارد. بله، میبایست استثناها را خودمستند کنیم اما نباید در این کار زیادهروی کنیم.
این ما را به قاعده سرانگشتی سوم میرساند که تنها برای استثناها و خطاهایی، کلاس مجزا بسازید که برای کنترل جریان کار نیاز دارید. به این معنا که تا حدی که ممکن است آن را ساده نگه دارید.
بله، دقیقاً. این همان چیزی است که من میگفتم. معمولاً با تعداد زیادی از استثناها با یک روش خیلی عمومی برخورد و رسیدگی میشود. اغلب یک بلاک دریافت (Catch) دارید و برای رسیدگی به آنها، تنها آنها را لاگ میزنیم و یک پیغام به کاربر نمایش میدهیم که یک خطای داخلی رخ داده است و یا مدیرسیستم را آگاه میکنیم تا سرور را راهاندازی مجدد کند یا مواردی از این قبیل. بنابراین در رسیدگی به آنها تفاوتی نیست و فایدهای در داشتن استثناهای نوعهای مختلف نیست و صرفهای در آن نیست.
زبانهای برنامهنویسی مختلف و فرهنگهای برنامهنویسی مختلفی وجود دارد. در بسیاری از زبانها نوعی فرهنگ طراحی سلسلهای عظیم از کلاسهای کامل استثنا وجود دارد. اما اغلب نیازی به آن نیست و زحمتی است که عموماً صرفهای ندارد.
قاعده سرانگشتی دیگر این است که تنها راه گزارش کردن استثناهای فنی از طریق کلاسهای استثنا نیست. اغلب این طور هست اما همچنان امکان استفاده از مقادیر خروجی تابع هم فراهم است. به شکل خاص، گردآوری پارامترها (Collecting Parameters) یک الگوی خوب برای گزارش مشکلات با یک روش عموماً سادهتر و دمدستیتر از پَرت کردن استثنا است. در این روش یک گردآورد (Collection) به عنوان پارامتر ورودی متد میدهید و اگر مشکلی داخل متد پیش بیاید، اقلامی به آن اضافه میشود. این روش اجازه میدهد که بتوانید در عوض یک مشکل، چندین مشکل را به فراخواننده گزارش دهید. این کار برای مثال در اعتبارسنجی در واسط کاربر، خیلی کاربرد دارد. اگر دیالوگی داشته باشید که مثلاً دو فیلد داشته باشد و بر روی هر دو قیودی داشته باشید که باید اعتبارسنجی شوند، طراحی بدی است که در کد بررسیکننده، اگر اولین فیلد مشکل داشت، یک استثنا پَرت کنید و یک پیغام به کاربر نمایش دهید که فلان قید برآورده نشده است مثلاً «باید فیلد نام را وارد کنید». و آنگاه کاربر مجدد درخواست را ارسال کند و بررسی بعدی انجام شود. این طراحی خوبی نیست اما از طریق استثنا کار دیگری نمیتوانید بکنید. اما یک طراحی خوب در اینجا این است که یک گردآورد (Collection) به تابع بررسیکننده بفرستیم و برای هر اعتبارسنجی که برآورده نمیشود یک قلم به آن اضافه نماییم و بتوانیم فهرست همه مشکلات را در پیغامی که به کاربر نمایش میدهیم داشته باشیم. بنابراین پَرت کردن استثنا تنها روش برای گزارش کردن مشکلات و شرایط خطا نیست و خوب است این را خصوصاً در طراحی واسط کاربر به خاطر داشته باشید.
بسیار خوب، و قاعده سرانگشتی آخر؟
قاعده سرانگشتی آخر این است که استحکام (Robustness) در درجه اول اهمیت قرار دارد و بهینه سازی کارایی (Performance) در درجه دوم است اگر در درجه سوم و چهارم نباشد! استحکام در درجه اول قرار دارد. این مطلب بارها گفته شده و باید روشن باشد اما هنوز روشن نیست و بارها و بارها افراد دوباره میگویند که استفاده از استثناها باعث کاهش کارایی میشود. سرعت را کاهش میدهد و سربار عظیمی دارد بنابراین من از آن استفاده نمیکنم. از مقدار خروجی تابع استفاده میکنم چون سریعتر است یا اینکه من استثناها را از قبل میسازم و در موقع آن، استثناهای از پیش ساخته را استفاده میکنم. اینکار، عجیبغریب است. این به آن معناست که در گزارش خطا، اطلاعاتی میگیرید که ممکن است دقیق نباشد. شرایط استثنا نادر هستند یا باید نادر باشند! و فکر کردن در مورد کارایی هنگام رسیدگی به خطا واقعاً ایده خیلی بدی است. شرایط استثنا آن قدر نادر هستند که عموماً اگر سربار داشته باشند اهمیتی ندارد و استفاده از استثناها با تکنولوژی امروزی کاملاً سریع است. بله، از خروجی تابع کندتر است اما چیزی است که اصلاً اهمیتی ندارد. اگر شما به آن توجه کنید برای کسی مهم نیست!
اگر سیستم تعبیه شده دارید حداقل، تأثیر این سربار را اندازهگیری کنید. اگر آن را اندازهگیری کردید و به این نتیجه رسیدید که بله، استثناها، گلوگاه (Bottleneck) شما هستند، در آن صورت شما باید سیستم را مجدداً طراحی کنید. اما اگر موفق نشدید و همه این کارها شکست خورد، آن موقع به حذف کردن استثناها و کاهش استحکام فکر کنید اما قبل از آن، طراحی خود را ساده کنید. استثناها یک ابزار ساده برای جدا نگه داشتن بیشتر بخشهای کد شما از جزییات برخورد با مشکلات هستند. روشهای دیگر، مستعد خطا هستند بنابراین از استثناها استفاده کنید و مسائل کارایی را درنظر نگیرید مگر آن که اندازهگیری کرده باشید که این کار تأثیر وخیمی بر روی کارایی گذاشته است که من جداً در مورد آن شک دارم.
من در این مورد تجربهای دارم. وقتی برنامهنویسی ++C برای یک سیستم تعبیهشده میکردم، سربار قرار دادن کدهای استثنا کامپایلر در خروجی باینری را اندازهگیری کردیم. در آنجا سربار رد پا (Footprint) و سربار زمان اجرا داشتیم که مربوط به ساختن پُشته (Stack) و پاک کردن آن برای رسیدگی به استثنا بود. بنابراین در ++C مقداری سربار وجود دارد اما همانطور که آرنو گفت، آن را اندازهگیری کنید و ببینید که آیا اهمیتی دارد و آیا بر روی کارایی برنامهتان تأثیری دارد یا خیر.
بله، خصوصاً در مورد سیستمهای تعبیهشده، قطعاً شرایطی هست که نمیخواهید از استثنا استفاده کنید اما آن را اندازهگیری کنید و در مورد سیستمهای معمولی کسبوکار، سربار اهمیتی برای سیستمتان نخواهد داشت.
این مطلب من را به یک نوع دیدگاه در مورد استثناها رهنمون میکند. چیزی که تا حدی غیرعادی است اما کمک میکند تا خیلی از مسائل فنی مرتبط با برخورد با استثناها را درک کنید و آن، این است که استثناها واقعاً پیچیده هستند. آنها از این که رسیدگی به خطا را بدون آنها انجام دهید سادهتر هستند اما با این وجود استثناها یک نوع دستور goto غیرمحلی هستند. و اضافه بر آن، یک goto غیرمحلی به جایی هستند که نمیشناسید. اگر این طور به استثناها نگاه کنید، کمک میکند تا طراحیتان را ساده نگه دارید و آن را به صورت مؤثر و سازگار با سیستمتان استفاده کنید. از استثناها به صورت خردمندانه استفاده کنید و آن را تا جایی که میشود ساده نگه دارید زیرا رسیدگی به خطا خیلی خیلی پیچیده است.
مایکل، آیا میخواهی این قسمت را جمعبندی کنی؟
بسیار خوب، آنچه آموختم این بود که فراخوانی متدها باید حتی در شرایط استثنا، همواره منابع را آزاد کنند که همان ضمانت بنیادی است. اغلب مطلوب است که ضمانت قویتری داشته باشیم به عنوان مثال ضمانت ابتدایی که اگر مشکلی پیش آمد بتوانیم فراخوانی را تکرار کنیم یا اینکه ضمانتهای قوی و یا پَرت نکردن داشته باشیم. هر API باید به صراحت بیان کنند که چه استثناهایی و در چه وقتی پَرت میکنند. ما اصطلاحی را در اینجا معرفی کردیم که خطا به شرایط قابل انتظار اشاره دارد. در نهایت آرنو توصیه کرد که استثناها را همواره در مرزهای تیمها یا مرز توزیعشدگیها رسیدگی کنیم یعنی هرجایی که یک مرز شبکهای بین زیرسیستمها یا مؤلفهها وجود دارد اما ترجیحاً در سطحهای درشتتر و نه در سطحهای ریز.
مطلبی دیگر از این انتشارات
نگاشتگرهای شیء به رابطه (ORM)
مطلبی دیگر از این انتشارات
رسیدگی به خطاها (قسمت اول)
مطلبی دیگر از این انتشارات
برآورد نرمافزار