Skip to content

پارانویاهای عمل‌گرایانه

TIP 30

نمی‌توانید نرم‌افزار کامل بنویسید

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

پس با این واقعیت ناامیدکننده، یک برنامه‌نویس عمل‌گرا چگونه آن را به مزیت تبدیل می‌کند؟ موضوع این فصل همین است.

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

تشبیه با کدنویسی کاملاً آشکار است. ما مدام با کد دیگران — کدی که شاید به استانداردهای بالای ما نرسد — در تعامل هستیم و با ورودی‌هایی سر و کار داریم که ممکن است معتبر باشند یا نباشند. پس به ما یاد می‌دهند دفاعی کد بنویسیم. اگر شکی هست، همه اطلاعاتی که به ما داده می‌شود را اعتبارسنجی می‌کنیم. از assertionها برای کشف داده بد استفاده می‌کنیم. سازگاری را بررسی می‌کنیم، محدودیت روی ستون‌های پایگاه داده می‌گذاریم و به‌طور کلی از خودمان راضی هستیم.

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

در برنامه‌های مُرده دروغ نمی‌گویند می‌خواهیم مطمئن شویم در حین رفع باگ‌ها آسیبی وارد نمی‌کنیم. پس سعی می‌کنیم مرتب چیزها را بررسی کنیم و اگر اوضاع خراب شد برنامه را متوقف کنیم.

برنامه‌نویسی قاطع روشی ساده برای بررسی در طول مسیر توصیف می‌کند — کدی بنویسید که فعالانه فرضیات شما را تأیید کند.

استثناها، مانند هر تکنیک دیگر، اگر درست استفاده نشوند می‌توانند بیش از سود، آسیب برسانند. مسائل را در چه زمانی از استثناها استفاده کنیم بررسی می‌کنیم.

با پویا‌تر شدن برنامه‌ها، خود را در حال مدیریت منابع سیستم — حافظه، فایل، دستگاه و مانند آن‌ها — خواهید یافت. در چگونه منابع را متوازن کنیم راه‌هایی پیشنهاد می‌کنیم تا مطمئن شوید هیچ توپی را زمین نمی‌گذارید.

در دنیایی از سیستم‌های ناقص، بازه‌های زمانی مضحک، ابزارهای خنده‌دار و نیازمندی‌های غیرممکن، بیایید محتاطانه بازی کنیم.

وقتی همه واقعاً دنبال گرفتن شما هستند، پارانویا فقط تفکر خوب است. — وودی آلن


۲۱ — طراحی قراردادمحور

هیچ‌چیز مردم را به اندازه عقل سلیم و رفتار صریح شگفت‌زده نمی‌کند. — رالف والدو امرسون، مقالات

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

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

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

این ایده در سراسر جهان — رسمی و غیررسمی — برای کمک به تعامل انسان‌ها به کار می‌رود. آیا می‌توانیم همان مفهوم را برای تعامل ماژول‌های نرم‌افزاری به کار ببریم؟ پاسخ «بله» است.

DBC

برتراند میر [Mey97b] مفهوم Design by Contract را برای زبان Eiffel توسعه داد.¹ تکنیکی ساده اما قدرتمند که بر مستندسازی (و توافق بر) حقوق و مسئولیت‌های ماژول‌های نرم‌افزاری برای اطمینان از صحت برنامه تمرکز دارد. برنامه صحیح چیست؟ برنامه‌ای که نه بیشتر و نه کمتر از آنچه ادعا می‌کند انجام دهد. مستندسازی و تأیید این ادعا، قلب طراحی قراردادمحور (به اختصار DBC) است.

یادداشت ۱: بر اساس بخشی از کارهای پیشین دیجسترا، فلوید، هور، ویرث و دیگران. برای اطلاعات بیشتر درباره خود Eiffel، [URL 10] و [URL 11] را ببینید.

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

  1. پیش‌شرط‌ها (Preconditions). برای فراخوانی روتین چه چیز باید درست باشد؛ الزامات روتین. روتین هرگز نباید وقتی پیش‌شرط‌هایش نقض می‌شود فراخوانی شود. مسئولیت فراهم کردن داده خوب بر عهده فراخواننده است (جعبه صفحه ۱۱۵ را ببینید).

  2. پس‌شرط‌ها (Postconditions). روتین چه چیزی را تضمین می‌کند؛ وضعیت جهان وقتی روتین تمام می‌شود. داشتن پس‌شرط یعنی روتین حتماً به پایان می‌رسد: حلقه بی‌نهایت مجاز نیست.

  3. ناوردهای کلاس (Class invariants). کلاس تضمین می‌کند این شرط همیشه از دید فراخواننده درست باشد. در پردازش داخلی روتین، ناورد ممکن است برقرار نباشد، اما وقتی روتین تمام می‌شود و کنترل به فراخواننده برمی‌گردد، ناورد باید درست باشد. (توجه کنید کلاس نمی‌تواند دسترسی نوشتن نامحدود به هر عضوی که در ناورد شرکت دارد بدهد.)

بیایید قرارداد روتینی که یک مقدار داده را در فهرست یکتا و مرتب درج می‌کند بررسی کنیم. در iContract، پیش‌پردازنده‌ای برای Java از [URL 17]، آن را این‌گونه مشخص می‌کنید:

java
/**
  * @invariant forall Node n in elements() |
  *    n.prev() != null
  *      implies
  *         n.value().compareTo(n.prev().value()) > 0
  */
public class dbc_list {
  /**
    * @pre contains(aNode) == false
    * @post contains(aNode) == true
    */
  public void insertNode(final Node aNode) {
    // ...

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

این پیش‌شرط‌ها، پس‌شرط‌ها و ناوردها را به زبان برنامه‌نویسی هدف — شاید با چند افزونه — می‌نویسید. مثلاً iContract عملگرهای منطق گزاره‌ای forall، exists و implies را علاوه بر ساختارهای معمول Java فراهم می‌کند. assertionهای شما می‌توانند وضعیت هر شیئی که متد به آن دسترسی دارد را بپرسند، اما مطمئن شوید این پرس‌وجو بدون اثر جانبی است (صفحه ۱۲۴ را ببینید).

DBC و پارامترهای ثابت

اغلب پس‌شرط از پارامترهای ورودی متد برای تأیید رفتار درست استفاده می‌کند. اما اگر روتین مجاز باشد پارامتر ورودی را تغییر دهد، شاید بتوانید قرارداد را دور بزنید. Eiffel اجازه این کار را نمی‌دهد، اما Java می‌دهد. اینجا از کلیدواژه final در Java برای بیان قصد استفاده می‌کنیم که پارامتر در متد تغییر نکند. این کاملاً ضد تقلب نیست — زیرکلاس‌ها می‌توانند پارامتر را دوباره به‌صورت غیر-final اعلام کنند. یا می‌توانید از نحو iContract یعنی variable@pre برای گرفتن مقدار اولیه متغیر همان‌طور که در ورود به متد بود استفاده کنید.

قرارداد بین روتین و هر فراخواننده بالقوه را می‌توان این‌گونه خواند:

اگر فراخواننده همه پیش‌شرط‌های روتین را برآورده کند، روتین تضمین می‌کند همه پس‌شرط‌ها و ناوردها در پایان درست باشند.

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

TIP 31

با قرارداد طراحی کنید

در ارتگونالیتی (صفحه ۳۴) توصیه کردیم کد «خجالتی» بنویسید. اینجا تأکید بر کد «تنبل» است: قبل از شروع در پذیرش سخت‌گیر باشید و در مقابل کمترین ممکن وعده بدهید. به یاد داشته باشید اگر قراردادتان بگوید هر چیزی را می‌پذیرید و در عوض دنیا را وعده می‌دهید، کد زیادی برای نوشتن دارید!

ارث‌بری و چندریختی سنگ‌بنای زبان‌های شیءگرا و جایی‌اند که قراردادها واقعاً می‌درخشند. فرض کنید با ارث‌بری رابطه «نوعی از» می‌سازید؛ یک کلاس «نوعی از» کلاس دیگر است. احتمالاً می‌خواهید به اصل جایگزینی لیسکوف [Lis88] پایبند باشید:

زیرکلاس‌ها باید از طریق رابط کلاس پایه قابل استفاده باشند بدون اینکه کاربر تفاوت را بداند.

به عبارت دیگر می‌خواهید مطمئن شوید زیرنوع جدید واقعاً «نوعی از» نوع پایه است — همان متدها را پشتیبانی می‌کند و معنای متدها یکسان است. می‌توانیم با قراردادها این کار را بکنیم. قرارداد را فقط یک‌بار در کلاس پایه مشخص می‌کنیم و خودکار روی هر زیرکلاس آینده اعمال می‌شود. زیرکلاس می‌تواند — اختیاری — دامنه ورودی گسترده‌تر بپذیرد یا تضمین‌های قوی‌تر بدهد. اما باید حداقل به اندازه والد بپذیرد و تضمین کند.

مثلاً کلاس پایه Java یعنی java.awt.Component را در نظر بگیرید. می‌توانید هر مؤلفه بصری در AWT یا Swing را به‌عنوان Component رفتار کنید بدون اینکه بدانید زیرکلاس واقعی دکمه، بوم، منو یا هر چیز دیگری است. هر مؤلفه می‌تواند قابلیت‌های اضافه و خاص داشته باشد، اما باید حداقل قابلیت‌های پایه تعریف‌شده در Component را فراهم کند. اما چیزی مانع نمی‌شود زیرنوعی از Component بسازید که متدهای درست‌نام اما نادرست‌رفتار داشته باشد. به‌راحتی می‌توانید متد paintای بسازید که نقاشی نکند، یا setFontای که فونت را تنظیم نکند. AWT قراردادی ندارد که نقض توافق را بگیرد.

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

java
/**
  * @pre f != null
  * @post getFont() == f
  */
  public void setFont(final Font f) {
     // ...

پیاده‌سازی DBC

بزرگ‌ترین سود DBC شاید این باشد که مسئله نیازمندی‌ها و تضمین‌ها را به صحنه می‌آورد. فقط شمارش در زمان طراحی که دامنه ورودی چیست، شرایط مرزی کدام‌اند و روتین چه وعده‌ای می‌دهد — یا مهم‌تر، چه وعده‌ای نمی‌دهد — جهشی بزرگ به سوی نرم‌افزار بهتر است. با بیان نکردن این‌ها، دوباره به برنامه‌نویسی تصادفی (صفحه ۱۷۲) برمی‌گردید؛ جایی که بسیاری پروژه‌ها شروع، پایان و شکست می‌خورند.

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

Assertionها

مستندسازی این فرضیات شروع خوبی است، اما با بررسی قرارداد توسط کامپایلر سود بیشتری می‌برید. در برخی زبان‌ها می‌توانید تا حدی با assertionها شبیه‌سازی کنید (برنامه‌نویسی قاطع، صفحه ۱۲۲ را ببینید). چرا فقط تا حدی؟ آیا نمی‌توان با assertionها همه کار DBC را کرد؟

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

همچنین مفهوم داخلی «مقادیر قدیم» وجود ندارد؛ یعنی مقادیر همان‌طور که در ورود به متد بودند. اگر با assertion قرارداد را اجرا می‌کنید، باید در پیش‌شرط کدی اضافه کنید تا اطلاعاتی که در پس‌شرط می‌خواهید ذخیره شود. با iContract مقایسه کنید که پس‌شرط می‌تواند به variable@pre ارجاع دهد، یا با Eiffel که old expression را پشتیبانی می‌کند.

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

پشتیبانی زبانی

زبان‌هایی با پشتیبانی داخلی DBC (مثل Eiffel و Sather [URL 12]) پیش‌ و پس‌شرط‌ها را خودکار در کامپایلر و زمان اجرا بررسی می‌کنند. در این حالت بیشترین سود را می‌برید چون کل پایگاه کد (کتابخانه‌ها هم) باید به قراردادهایش پایبند باشد.

اما زبان‌های رایج‌تر مثل C، C++ و Java چطور؟ برای این زبان‌ها پیش‌پردازنده‌هایی هستند که قراردادهای جاسازی‌شده در سورس به‌صورت کامنت‌های ویژه را پردازش می‌کنند. پیش‌پردازنده این کامنت‌ها را به کدی که assertionها را تأیید می‌کند گسترش می‌دهد.

برای C و C++ می‌توانید Nana [URL 18] را بررسی کنید. Nana ارث‌بری را مدیریت نمی‌کند، اما در زمان اجرا از دیباگر برای پایش assertionها به روشی نوآورانه استفاده می‌کند.

برای Java، iContract [URL 17] وجود دارد. کامنت‌ها (به شکل JavaDoc) را می‌گیرد و فایل سورس جدیدی با منطق assertion تولید می‌کند.

پیش‌پردازنده‌ها به اندازه امکان داخلی خوب نیستند. ادغام در پروژه می‌تواند دردسرساز باشد و کتابخانه‌های دیگر قرارداد ندارند. اما باز هم بسیار مفیدند؛ وقتی مشکلی این‌طور کشف می‌شود — به‌ویژه مشکلی که هرگز پیدا نمی‌کردید — تقریباً جادویی است.

DBC و خراب شدن زودهنگام

DBC با مفهوم خراب شدن زودهنگام (برنامه‌های مُرده دروغ نمی‌گویند، صفحه ۱۲۰) خوب جفت می‌شود. فرض کنید متدی جذر مربع می‌گیرد (مثل کلاس Eiffel DOUBLE). پیش‌شرطی لازم دارد که دامنه را به اعداد مثبت محدود کند. پیش‌شرط Eiffel با require و پس‌شرط با ensure اعلام می‌شود:

sqrt: DOUBLE is
      -- Square root routine
   require
      sqrt_arg_must_be_positive: Current >= 0;
   --- ...
   --- calculate square root here
   --- ...
   ensure
      ((Result*Result) - Current).abs <= epsilon*Current.abs;
      -- Result should be within error tolerance
   end;

چه کسی مسئول است؟

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

برنامه‌ای را در نظر بگیرید که عددی از کنسول می‌خواند، جذر مربعش را (با فراخوانی sqrt) محاسبه و نتیجه را چاپ می‌کند. تابع sqrt پیش‌شرط دارد — آرگومانش نباید منفی باشد. اگر کاربر عدد منفی وارد کند، بر عهده کد فراخواننده است که هرگز به sqrt نرسد. این کد گزینه‌های زیادی دارد: می‌تواند متوقف شود، هشدار بدهد و عدد دیگری بخواند، یا عدد را مثبت کند و «i» به نتیجه sqrt اضافه کند. هر انتخابی باشد، قطعاً مشکل sqrt نیست.

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

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

اگر به sqrt پارامتر منفی بدهید، زمان اجرای Eiffel خطای sqrt_arg_must_be_positive را همراه با ردیابی پشته چاپ می‌کند. این بهتر از جایگزین در زبان‌هایی مثل Java، C و C++ است که دادن عدد منفی به sqrt مقدار ویژه NaN (Not a Number) برمی‌گرداند. شاید مدتی بعد در برنامه روی NaN محاسبه کنید و نتایج عجیب بگیرید.

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

کاربردهای دیگر ناوردها

تا اینجا پیش‌ و پس‌شرط‌های متدهای منفرد و ناوردهای همه متدهای یک کلاس را بحث کردیم، اما راه‌های مفید دیگری برای ناوردها وجود دارد.

ناوردهای حلقه

درست کردن شرایط مرزی روی حلقه غیرساده می‌تواند مشکل‌ساز باشد. حلقه‌ها دچار مشکل موز (می‌دانم «موز» را چطور هجا کنم، اما نمی‌دانم کی متوقف شوم)، خطای تیرک نرده (نمی‌دانیم تیرک‌ها را بشماریم یا فاصله بینشان) و خطای معروف «یکی کم/زیاد» [URL 52] می‌شوند.

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

java
int m = arr[0];       // example assumes arr.length > 0
int i = 1;
// Loop invariant: m = max(arr[0:i-1])
while (i < arr.length) {
  m = Math.max(m, arr[i]);
  i = i + 1;
}

(arr[m:n] اختصار راحتی برای برش آرایه از اندیس m تا n است.) ناورد باید قبل از اجرای حلقه درست باشد و بدنه حلقه باید تضمین کند در طول اجرا درست بماند. این‌طور می‌دانیم وقتی حلقه تمام می‌شود ناورد هم برقرار است و نتیجه معتبر است. ناوردهای حلقه را می‌توان صریحاً به‌صورت assertion کدنویسی کرد، اما به‌عنوان ابزار طراحی و مستندسازی هم مفیدند.

ناوردهای معنایی

می‌توانید از ناوردهای معنایی برای بیان نیازمندی‌های تغییرناپذیر، نوعی «قرارداد فلسفی» استفاده کنید.

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

این قانون ساده، مستقیماً از نیازمندی‌ها، در مرتب‌سازی سناریوهای پیچیده بازیابی خطا بسیار کمک کرد و طراحی و پیاده‌سازی جزئی را در بسیاری بخش‌ها هدایت کرد.

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

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

به نفع مصرف‌کننده خطا کن.

این بیانیه‌ای واضح، مختصر و بدون ابهام است که در بخش‌های مختلف سیستم کاربرد دارد. قرارداد ما با همه کاربران سیستم و تضمین رفتار ماست.

قراردادهای پویا و عامل‌ها

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

قطعاً هر سیستمی که به فناوری عامل وابسته است وابستگی بحرانی به ترتیبات قراردادی دارد — حتی اگر پویا تولید شوند.

تصور کنید: با مؤلفه‌ها و عامل‌های کافی که بتوانند قراردادهای خود را برای رسیدن به هدف مذاکره کنند، شاید بحران بهره‌وری نرم‌افزار را حل کنیم و بگذاریم نرم‌افزار برایمان حل کند.

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

بخش‌های مرتبط:

  • ارتگونالیتی، صفحه ۳۴
  • برنامه‌های مُرده دروغ نمی‌گویند، صفحه ۱۲۰
  • برنامه‌نویسی قاطع، صفحه ۱۲۲
  • چگونه منابع را متوازن کنیم، صفحه ۱۲۹
  • جداسازی و قانون دمتر، صفحه ۱۳۸
  • جفت‌شدگی زمانی، صفحه ۱۵۰
  • برنامه‌نویسی تصادفی، صفحه ۱۷۲
  • کدی که تست آن آسان است، صفحه ۱۸۹
  • تیم‌های عمل‌گرا، صفحه ۲۲۴

Challenges

نکاتی برای تأمل: اگر DBC این‌قدر قدرتمند است، چرا گسترده‌تر استفاده نمی‌شود؟ سخت است قرارداد را درآورید؟ مجبورتان می‌کند به مسائلی فکر کنید که ترجیح می‌دهید فعلاً نادیده بگیرید؟ مجبورتان می‌کند فکر کنید! واضح است که ابزار خطرناکی است!

Exercises

پاسخ در صفحه ۲۸۸

۱۴. چه چیزی قرارداد خوبی می‌سازد؟ هر کسی می‌تواند پیش‌ و پس‌شرط اضافه کند، اما آیا سودی دارند؟ بدتر، آیا واقعاً بیش از سود آسیب می‌زنند؟ برای مثال زیر و تمرین‌های ۱۵ و ۱۶ مشخص کنید قرارداد خوب، بد یا زشت است و چرا.

اول مثال Eiffel. روتینی برای افزودن STRING به فهرست دوسویه حلقوی (به یاد داشته باشید پیش‌شرط‌ها با require و پس‌شرط‌ها با ensure برچسب‌گذاری می‌شوند):

-- Add a unique item to a doubly linked list,
-- and return the newly created NODE.
add_item (item : STRING) : NODE is
   require
      item /= Void                          -- '/=' is 'not equal'.
      find_item(item) = Void                -- Must be unique
   deferred                                 -- Abstract base class.
   ensure
      result.next.previous = result         -- Check the newly
      result.previous.next = result         -- added node's links.
      find_item(item) = result              -- Should find it.
   end

پاسخ در صفحه ۲۸۸

۱۵. حالا مثالی در Java — تا حدی شبیه تمرین ۱۴. insertNumber عدد صحیحی را در فهرست مرتب درج می‌کند. پیش‌ و پس‌شرط‌ها مانند iContract ([URL 17]) برچسب‌گذاری شده‌اند:

java
private int data[];
/**
  * @post data[index-1] < data[index] &&
  *       data[index] == aValue
  */
public Node insertNumber (final int aValue)
{
  int index = findPlaceToInsert(aValue);
  ...

پاسخ در صفحه ۲۸۹

۱۶. تکه‌ای از کلاس پشته در Java. آیا قرارداد خوبی است؟

java
/**
  * @pre anItem != null   // Require real data
  * @post pop() == anItem // Verify that it's
  *                       // on the stack
  */
public void push(final String anItem)

پاسخ در صفحه ۲۸۹

۱۷. مثال‌های کلاسیک DBC (مثل تمرین‌های ۱۴–۱۶) پیاده‌سازی ADT (نوع داده انتزاعی) — معمولاً پشته یا صف — را نشان می‌دهند. اما خیلی‌ها واقعاً این کلاس‌های سطح پایین را نمی‌نویسند.

پس در این تمرین رابطی برای مخلوط‌کن آشپزخانه طراحی کنید. در نهایت مخلوط‌کن مبتنی بر وب، اینترنت‌محور و CORBA‌ای می‌شود، اما فعلاً فقط رابط کنترل لازم است. ده تنظیم سرعت دارد (۰ یعنی خاموش). نمی‌توانید خالی کارش کنید و سرعت را فقط یک واحد در هر بار عوض می‌کنید (یعنی از ۰ به ۱ و از ۱ به ۲، نه از ۰ به ۲).

این متدها را دارید. پیش‌ و پس‌شرط و ناورد مناسب اضافه کنید:

int getSpeed()
void setSpeed(int x)
boolean isFull()
void fill()
void empty()

پاسخ در صفحه ۲۹۰

۱۸. چند عدد در این سری است؟ 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, ...

پاسخ در صفحه ۲۹۰


۲۲ — برنامه‌های مُرده دروغ نمی‌گویند

آیا متوجه شده‌اید گاهی دیگران قبل از خودتان می‌فهمند اوضاع خوب نیست؟ با کد دیگران هم همین است. اگر چیزی در یکی از برنامه‌هایمان دارد خراب می‌شود، گاهی اول کتابخانه‌ای آن را می‌گیرد. شاید اشاره‌ای سرگردان باعث شده دسته فایلی را با چیزی بی‌معنی بازنویسی کنیم. فراخوانی بعدی read آن را می‌گیرد. شاید سرریز بافر شمارنده‌ای را که برای تعیین مقدار حافظه لازم داریم خراب کرده. شاید از malloc خطا بگیریم. خطای منطقی چند میلیون دستورالعمل قبل یعنی انتخاب‌گر case دیگر ۱، ۲ یا ۳ نیست. به شاخه default می‌خوریم (یکی از دلایلی که هر case/switch باید بند default داشته باشد — می‌خواهیم بدانیم وقتی «غیرممکن» رخ داده).

آسان است در ذهنیت «نمی‌تواند اتفاق بیفتد» افتاد. بیشتر ما کدی نوشته‌ایم که موفق بسته شدن فایل یا نوشته شدن trace را بررسی نکرده. و در شرایط عادی شاید لازم نبود — کد تحت شرایط عادی شکست نمی‌خورد. اما دفاعی کد می‌نویسیم. به دنبال اشاره‌های سرگردان در بخش‌های دیگر برنامه که پشته را خراب می‌کنند می‌گردیم. بررسی می‌کنیم نسخه‌های درست کتابخانه‌های مشترک واقعاً بارگذاری شده‌اند.

همه خطاها اطلاعات می‌دهند. می‌توانید خود را قانع کنید خطا نمی‌تواند رخ دهد و آن را نادیده بگیرید. در عوض، برنامه‌نویسان عمل‌گرا به خود می‌گویند اگر خطایی هست، چیز بسیار بسیار بدی رخ داده.

TIP 32

زود خراب شو

خراب شو، خراب نکن

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

زبان و کتابخانه‌های Java این فلسفه را پذیرفته‌اند. وقتی چیز غیرمنتظره‌ای در سیستم زمان اجرا رخ می‌دهد، RuntimeException پرتاب می‌کند. اگر گرفته نشود، تا سطح بالای برنامه بالا می‌رود و برنامه را متوقف می‌کند و ردیابی پشته نشان می‌دهد.

در زبان‌های دیگر هم می‌توانید همین کار را بکنید. اگر مکانیزم استثنا ندارید یا کتابخانه‌ها استثنا پرتاب نمی‌کنند، خطاها را خودتان مدیریت کنید. در C، ماکروها برای این کار بسیار مفیدند:

c
#define CHECK(LINE, EXPECTED) \
   { int rc = LINE; \
     if (rc != EXPECTED) \
         ut_abort(__FILE__, __LINE__, #LINE, rc, EXPECTED); }

void ut_abort(char *file, int ln, char *line, int rc, int exp) {
  fprintf(stderr, "%s line %d\n'%s': expected %d, got %d\n",
                  file, ln, line, exp, rc);
  exit(1);
}

سپس فراخوانی‌هایی که هرگز نباید شکست بخورند را می‌توانید این‌طور بپیچید:

c
CHECK(stat("/tmp", &stat_buff), 0);

اگر شکست بخورد، پیامی در stderr می‌نویسد:

source.c line 19
'stat("/tmp", &stat_buff)': expected 0, got -1

گاهی فقط خروج از برنامه در حال اجرا نامناسب است. شاید منابعی claim کرده‌اید که آزاد نمی‌شوند، یا باید لاگ بنویسید، تراکنش‌های باز را مرتب کنید یا با فرایندهای دیگر تعامل کنید. تکنیک‌های چه زمانی از استثناها استفاده کنیم (صفحه ۱۲۵) اینجا کمک می‌کنند. اما اصل پایه همان است — وقتی کدتان می‌فهمد چیزی که قرار بود غیرممکن باشد رخ داده، برنامه دیگر قابل اعتماد نیست. هر کاری از این نقطه به بعد مشکوک است، پس هرچه زودتر متوقفش کنید. برنامه مُرده معمولاً خیلی کمتر از برنامه لنگان آسیب می‌زند.

بخش‌های مرتبط:

  • طراحی قراردادمحور، صفحه ۱۰۹
  • چه زمانی از استثناها استفاده کنیم، صفحه ۱۲۵

۲۳ — برنامه‌نویسی قاطع

لوکس بودن در سرزنش خود این است که وقتی خودمان را مقصر می‌دانیم، احساس می‌کنیم دیگران حق سرزنش ما را ندارند. — اسکار وایلد، تصویر دورین گری

به نظر می‌رسد هر برنامه‌نویسی باید زود در مسیرش این مانترا را حفظ کند. اصل بنیادی محاسبات، باوری که در نیازمندی‌ها، طراحی، کد، کامنت و تقریباً همه کارها به کار می‌بریم:

این هرگز نمی‌تواند اتفاق بیفتد

«این کد ۳۰ سال دیگر استفاده نمی‌شود، پس تاریخ دو رقمی کافی است.» «این برنامه هرگز خارج از کشور استفاده نمی‌شود، پس چرا بین‌المللی‌سازی کنیم؟» «count نمی‌تواند منفی باشد.» «این printf نمی‌تواند شکست بخورد.»

بیایید این خودفریبی را — به‌ویژه هنگام کدنویسی — تمرین نکنیم.

TIP 33

اگر نمی‌تواند اتفاق بیفتد، با assertionها مطمئن شو که نمی‌افتد

هر وقت فکر کردید «البته که آن هرگز نمی‌تواند اتفاق بیفتد»، کدی برای بررسی اضافه کنید. ساده‌ترین راه assertion است. در بیشتر پیاده‌سازی‌های C و C++، شکلی از ماکرو assert یا _assert پیدا می‌کنید که شرط بولی را بررسی می‌کند. این ماکروها بسیار ارزشمندند. اگر اشاره‌ای که به روتین شما داده می‌شود هرگز نباید NULL باشد، بررسی کنید:

c
void writeString(char *string) {
    assert(string != NULL);
    ...

Assertionها برای بررسی عملکرد الگوریتم هم مفیدند. شاید الگوریتم مرتب‌سازی هوشمندانه‌ای نوشته‌اید. بررسی کنید که کار می‌کند:

c
for (int i = 0; i < num_entries-1; i++) {
    assert(sorted[i] <= sorted[i+1]);
}

البته شرط داده‌شده به assertion نباید اثر جانبی داشته باشد (جعبه صفحه ۱۲۴ را ببینید). همچنین به یاد داشته باشید assertionها ممکن است در زمان کامپایل خاموش شوند — هرگز کدی که باید اجرا شود را داخل assert نگذارید.

از assertion به‌جای مدیریت خطای واقعی استفاده نکنید. Assertionها چیزهایی را بررسی می‌کنند که هرگز نباید رخ دهند؛ نمی‌خواهید کدی مثل این بنویسید:

c
printf("Enter 'Y' or 'N': ");
ch = getchar();
assert((ch == 'Y') || (ch == 'N'));             /* bad idea! */

و فقط چون ماکروهای assert آماده در شکست assertion exit می‌کنند، دلیلی ندارد نسخه‌های خودتان همین کار را بکنند. اگر باید منابع آزاد کنید، شکست assertion استثنا تولید کند، longjmp به نقطه خروج بزند یا handler خطا را فراخوانی کند. فقط مطمئن شوید کدی که در آن میلی‌ثانیه‌های پایانی اجرا می‌کنید به اطلاعاتی که شکست assertion را تحریک کرد وابسته نباشد.

assertionها را روشن نگه دارید

سوءتفاهم رایجی درباره assertion وجود دارد که کسانی که کامپایلر و محیط زبان می‌نویسند رواج می‌دهند. چیزی شبیه این:

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

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

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

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

یادداشت ۲: در زبان‌های مبتنی بر C می‌توانید از پیش‌پردازنده یا دستور if برای اختیاری کردن assertionها استفاده کنید. بسیاری از پیاده‌سازی‌ها اگر پرچم زمان کامپایل تنظیم (یا تنظیم نشود) شود، تولید کد برای ماکرو assert را خاموش می‌کنند. در غیر این صورت می‌توانید کد را در دستور if با شرط ثابت قرار دهید که بسیاری از کامپایلرها (از جمله سیستم‌های رایج Java) آن را بهینه‌سازی و حذف می‌کنند.

Assertionها و اثرات جانبی

شرم‌آور است وقتی کدی که برای کشف خطا اضافه می‌کنیم خودش خطای جدید بسازد. با assertion اگر ارزیابی شرط اثر جانبی داشته باشد این اتفاق می‌افتد. مثلاً در Java ایده بدی است چیزی مثل این بنویسید:

java
while (iter.hasMoreElements()) {
    Test.ASSERT(iter.nextElement() != null);
    Object obj = iter.nextElement();
    // ....
}

فراخوانی .nextElement() در ASSERT اثر جانبی دارد و iterator را از عنصر گرفته‌شده جلو می‌برد، پس حلقه فقط نیمی از عناصر مجموعه را پردازش می‌کند. بهتر است بنویسید:

java
while (iter.hasMoreElements()) {
    Object obj = iter.nextElement();
    Test.ASSERT(obj != null);
    // ....
}

این مشکل نوعی «هایزن‌باگ» است — دیباگی که رفتار سیستم دیباگ‌شده را عوض می‌کند ([URL 52] را ببینید).

بخش‌های مرتبط:

  • دیباگ، صفحه ۹۰
  • طراحی قراردادمحور، صفحه ۱۰۹
  • چگونه منابع را متوازن کنیم، صفحه ۱۲۹
  • برنامه‌نویسی تصادفی، صفحه ۱۷۲

Exercises

پاسخ در صفحه ۲۹۰

۱۹. یک بررسی واقعیت سریع. کدام‌یک از این «غیرممکن‌ها» می‌تواند رخ دهد؟

  1. ماهی با کمتر از ۲۸ روز
  2. stat(".", &sb) == -1 (یعنی دسترسی به دایرکتوری جاری ممکن نیست)
  3. در C++: a = 2; b = 3; if (a + b != 5) exit(1);
  4. مثلثی با مجموع زوایای داخلی ۲۰۰ درجه
  5. دقیقه‌ای که ۶۰ ثانیه نداشته باشد
  6. در Java: (a + 1) <= a

پاسخ در صفحه ۲۹۱

۲۰. یک کلاس ساده بررسی assertion برای Java توسعه دهید.


۲۴ — چه زمانی از استثناها استفاده کنیم

در برنامه‌های مُرده دروغ نمی‌گویند (صفحه ۱۲۰) پیشنهاد کردیم بررسی هر خطای ممکن — به‌ویژه غیرمنتظره‌ها — خوب است. اما در عمل این می‌تواند کد زشتی بسازد؛ منطق عادی برنامه ممکن است کاملاً زیر مدیریت خطا پنهان شود، به‌ویژه اگر به مکتب «روتین باید یک return داشته باشد» معتقد باشید (ما نیستیم). کدی دیده‌ایم شبیه این:

c
retcode = OK;
if (socket.read(name) != OK) {
  retcode = BAD_READ;
}
else {
  processName(name);
  if (socket.read(address) != OK) {
    retcode = BAD_READ;
  }
  else {
    processAddress(address);
    if (socket.read(telNo) != OK) {
       retcode = BAD_READ;
    }
    else {
       // etc, etc...
    }
  }
}
return retcode;

خوشبختانه اگر زبان از استثنا پشتیبانی کند، می‌توانید این کد را بسیار مرتب‌تر بازنویسی کنید:

java
retcode = OK;
try {
  socket.read(name);
  process(name);
    socket.read(address);
    processAddress(address);
    socket.read(telNo);
    // etc, etc...
}
catch (IOException e) {
  retcode = BAD_READ;
  Logger.log("Error reading individual: " + e.getMessage());
}
return retcode;

جریان عادی کنترل اکنون واضح است و همه مدیریت خطا به یک جا منتقل شده.

چه چیزی استثنایی است؟

یکی از مشکلات استثناها دانستن زمان استفاده است. باور داریم استثناها به‌ندرت باید بخشی از جریان عادی برنامه باشند؛ استثناها باید برای رویدادهای غیرمنتظره رزرو شوند. فرض کنید استثنای گرفته‌نشده برنامه را متوقف می‌کند و از خود بپرسید: «اگر همه handlerهای استثنا را بردارم، این کد هنوز اجرا می‌شود؟» اگر پاسخ «نه» است، شاید استثنا در شرایط غیراستثنایی استفاده می‌شود.

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

پاسخ ما: «بستگی دارد.» اگر فایل باید آنجا می‌بود، استثنا توجیه دارد. چیز غیرمنتظره‌ای رخ داده — فایلی که انتظار داشتید وجود داشته باشد ناپدید شده. در مقابل، اگر نمی‌دانید فایل باید باشد یا نه، نبودنش استثنایی به نظر نمی‌رسد و برگرداندن خطا مناسب است.

مثال حالت اول. کد زیر فایل /etc/passwd را باز می‌کند که باید روی همه سیستم‌های Unix باشد. اگر شکست بخورد، FileNotFoundException را به فراخواننده می‌دهد:

java
public void open_passwd() throws FileNotFoundException {
    // This may throw FileNotFoundException...
    ipstream = new FileInputStream("/etc/passwd");
    // ...
}

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

java
public boolean open_user_file(String name)
  throws FileNotFoundException {
  File f = new File(name);
  if (!f.exists()) {
    return false;
  }
  ipstream = new FileInputStream(f);
  return true;
}

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

TIP 34

از استثنا برای مشکلات استثنایی استفاده کنید

چرا این رویکرد را پیشنهاد می‌دهیم؟ استثنا انتقال فوری و غیرمحلی کنترل است — نوعی goto آبشاری. برنامه‌هایی که استثنا را بخشی از پردازش عادی به کار می‌برند همه مشکلات خوانایی و نگهداری کد اسپاگتی کلاسیک را دارند. این برنامه‌ها کپسوله‌سازی را می‌شکنند: روتین‌ها و فراخواننده‌ها از طریق مدیریت استثنا محکم‌تر به هم وابسته‌اند.

Handlerهای خطا جایگزین هستند

Handler خطا روتینی است که وقتی خطا کشف می‌شود فراخوانی می‌شود. می‌توانید روتینی برای دسته خاصی از خطاها ثبت کنید. وقتی یکی از این خطاها رخ دهد، handler فراخوانی می‌شود.

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

پیاده‌سازی برنامه کلاینت-سرور با RMI در Java را در نظر بگیرید. به‌دلیل نحوه پیاده‌سازی RMI، هر فراخوانی روتین از راه دور باید آماده مدیریت RemoteException باشد. اضافه کردن کد برای این استثناها خسته‌کننده می‌شود و نوشتن کدی که با روتین‌های محلی و از راه دور کار کند سخت است. راه‌حل ممکن این است که اشیای از راه دور را در کلاسی غیراز راه دور بپیچید. این کلاس رابط handler خطا را پیاده می‌کند و به کد کلاینت اجازه می‌دهد روتینی برای فراخوانی هنگام کشف استثنای از راه دور ثبت کند.

بخش‌های مرتبط:

  • برنامه‌های مُرده دروغ نمی‌گویند، صفحه ۱۲۰

Challenges

زبان‌هایی که استثنا پشتیبانی نمی‌کنند اغلب مکانیزم انتقال غیرمحلی دیگری دارند (C مثلاً longjmp/setjmp دارد). فکر کنید چگونه می‌توانید با این امکانات مکانیزم شبه‌استثنایی بسازید. سودها و خطرها چیست؟ چه گام‌های ویژه‌ای برای اطمینان از یتیم نشدن منابع لازم است؟ آیا منطقی است هر وقت در C کد می‌زنید از این راه‌حل استفاده کنید؟

Exercises

پاسخ در صفحه ۲۹۲

۲۱. هنگام طراحی کلاس container جدید، این شرایط خطای ممکن را شناسایی می‌کنید:

  1. حافظه برای عنصر جدید در روتین add موجود نیست

  2. ورودی درخواستی در روتین fetch پیدا نشد

  3. اشاره null به روتین add داده شد

هر کدام چگونه مدیریت شود؟ خطا تولید شود، استثنا پرتاب شود، یا شرط نادیده گرفته شود؟


۲۵ — چگونه منابع را متوازن کنیم

«من تو را به این دنیا آوردم،» پدرم می‌گفت، «و می‌توانم بیرونت کنم. برایم فرقی نمی‌کند. یکی مثل تو درست می‌کنم.» — بیل کازبی، پدری

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

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

TIP 35

کاری را که شروع می‌کنی تمام کن

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

c
void readCustomer(const char *fName, Customer *cRec) {
    cFile = fopen(fName, "r+");
    fread(cRec, sizeof(*cRec), 1, cFile);
}
void writeCustomer(Customer *cRec) {
    rewind(cFile);
    fwrite(cRec, sizeof(*cRec), 1, cFile);
    fclose(cFile);
}
void updateCustomer(const char *fName, double newBalance) {
    Customer cRec;
    readCustomer(fName, &cRec);
    cRec.balance = newBalance;
    writeCustomer(&cRec);
}

در نگاه اول updateCustomer خوب به نظر می‌رسد. منطق خواسته‌شده — خواندن رکورد، به‌روزرسانی موجودی، نوشتن برگشت — را پیاده می‌کند. اما این مرتب‌بندی مشکل بزرگی پنهان می‌کند. readCustomer و writeCustomer محکم به هم جفت شده‌اند — متغیر سراسری cFile را به اشتراک می‌گذارند. readCustomer فایل را باز می‌کند و اشاره‌گر فایل را در cFile ذخیره می‌کند و writeCustomer از همان اشاره‌گر برای بستن فایل استفاده می‌کند. این متغیر سراسری حتی در updateCustomer دیده نمی‌شود.

چرا بد است؟ برنامه‌نویس نگهداری بدبخت را در نظر بگیرید که می‌گویند مشخصات عوض شده — موجودی فقط اگر مقدار جدید منفی نباشد به‌روز شود. به سورس می‌رود و updateCustomer را عوض می‌کند:

c
void updateCustomer(const char *fName, double newBalance) {
    Customer cRec;
    readCustomer(fName, &cRec);
    if (newBalance >= 0.0) {
      cRec.balance = newBalance;
        writeCustomer(&cRec);
    }
}

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

راه‌حل بسیار بد این است که حالت خاص را در updateCustomer مدیریت کنیم:

c
void updateCustomer(const char *fName, double newBalance) {
    Customer cRec;
    readCustomer(fName, &cRec);
    if (newBalance >= 0.0) {
      cRec.balance = newBalance;
      writeCustomer(&cRec);
    }
    else
      fclose(cFile);
}

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

نکته کاری را که شروع می‌کنی تمام کن می‌گوید ایده‌آل این است که روتینی که منبع را تخصیص می‌دهد آن را هم آزاد کند. می‌توانیم با بازآرایی جزئی اینجا اعمال کنیم:

c
void readCustomer(FILE *cFile, Customer *cRec) {
  fread(cRec, sizeof(*cRec), 1, cFile);
}
void writeCustomer(FILE *cFile, Customer *cRec) {
  rewind(cFile);
  fwrite(cRec, sizeof(*cRec), 1, cFile);
}
void updateCustomer(const char *fName, double newBalance) {
  FILE *cFile;
  Customer cRec;
    cFile = fopen(fName, "r+");            // >---
    readCustomer(cFile, &cRec);            //      |
    if (newBalance >= 0.0) {               //      |
      cRec.balance = newBalance;           //      |
      writeCustomer(cFile, &cRec);           //      |
    }                                      //      |
    fclose(cFile);                         // <---
}

اکنون همه مسئولیت فایل در updateCustomer است. فایل را باز می‌کند و (تمام کردن کاری که شروع کرده) قبل از خروج می‌بندد. روتین استفاده از فایل را متوازن می‌کند: باز و بسته در یک جا هستند و برای هر باز، بسته متناظر وجود دارد. بازآرایی متغیر سراسری زشت را هم حذف می‌کند.

یادداشت ۳: برای بحث خطرات کد جفت‌شده، جداسازی و قانون دمتر (صفحه ۱۳۸) را ببینید.

تخصیص‌های تو در تو

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

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

  2. وقتی همان مجموعه منابع را در جاهای مختلف کد تخصیص می‌دهید، همیشه به همان ترتیب تخصیص دهید. احتمال بن‌بست کم می‌شود. (اگر فرایند A منبع۱ را گرفته و می‌خواهد منبع۲ را بگیرد، در حالی که B منبع۲ را گرفته و می‌خواهد منبع۱ را بگیرد، دو فرایند برای همیشه منتظر می‌مانند.)

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

اشیاء و استثناها

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

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

این رویکرد به‌ویژه وقتی با زبان‌هایی مثل C++ کار می‌کنید که استثنا می‌تواند در آزادسازی منبع دخالت کند، سود دارد.

توازن و استثناها

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

توازن منابع با استثناهای C++

C++ مکانیزم try/catch دارد. متأسفانه یعنی هنگام خروج از روتینی که استثنا را می‌گیرد و دوباره پرتاب می‌کند حداقل دو مسیر ممکن است:

cpp
void doSomething(void) {
    Node *n = new Node;
    try {
      // do something
    }
    catch (...) {
      delete n;
      throw;
    }
    delete n;
}

گرهی که می‌سازیم در دو جا آزاد می‌شود — یک‌بار در مسیر عادی خروج و یک‌بار در handler استثنا. نقض آشکار DRY و مشکل نگهداری در انتظار.

اما می‌توانیم از معناشناسی C++ به نفع خود استفاده کنیم. اشیای محلی هنگام خروج از بلوک محصورکننده خودکار نابود می‌شوند. چند گزینه داریم. اگر شرایط اجازه دهد، n را از اشاره‌گر به شیء Node واقعی روی پشته عوض کنیم:

cpp
void doSomething1(void) {
    Node n;
    try {
      // do something
    }
    catch (...) {
      throw;
    }
}

اینجا به C++ واگذار می‌کنیم نابودی Node را خودکار انجام دهد، چه استثنا پرتاب شود چه نشود.

اگر تغییر از اشاره‌گر ممکن نبود، همان اثر با پیچیدن منبع (اینجا اشاره‌گر Node) در کلاس دیگر حاصل می‌شود:

cpp
// Wrapper class for Node resources
class NodeResource {
  Node *n;
 public:
  NodeResource() { n = new Node; }
  ~NodeResource() { delete n; }
  Node *operator->() { return n; }
};
void doSomething2(void) {
    NodeResource n;
    try {
      // do something
    }
    catch (...) {
      throw;
    }
}

کلاس wrapper یعنی NodeResource تضمین می‌کند وقتی اشیایش نابود می‌شوند گره‌های متناظر هم نابود شوند. برای راحتی، عملگر dereference -> می‌دهد تا کاربران مستقیماً به فیلدهای Node محتوا دسترسی داشته باشند.

چون این تکنیک بسیار مفید است، کتابخانه استاندارد C++ کلاس قالب auto_ptr را دارد که wrapper خودکار برای اشیای تخصیص‌شده پویا می‌دهد:

cpp
void doSomething3(void) {
  auto_ptr<Node> p (new Node);
    // Access the Node as p->...
    // Node automatically deleted at end
}

توازن منابع در Java

برخلاف C++، Java شکل تنبل نابودی خودکار شیء را پیاده می‌کند. اشیای بدون ارجاع کاندید جمع‌آوری زباله‌اند و در صورت جمع‌آوری، finalize فراخوانی می‌شود. برای توسعه‌دهندگان راحت است — دیگر بیشتر نشت حافظه گردن آن‌هاست — اما پیاده‌سازی پاک‌سازی منبع با طرح C++ را دشوار می‌کند. خوشبختانه طراحان Java ویژگی finally را اضافه کردند. وقتی بلوک try بند finally دارد، کد آن بند تضمین می‌شود اگر هر دستوری در try اجرا شود، اجرا شود. مهم نیست استثنا پرتاب شود (یا حتی return در try اجرا شود) — کد finally اجرا می‌شود. یعنی می‌توانیم استفاده از منبع را با کدی مثل این متوازن کنیم:

java
public void doSomething() throws IOException {
    File tmpFile = new File(tmpFileName);
    FileWriter tmp = new FileWriter(tmpFile);
    try {
      // do some work
    }
    finally {
      tmpFile.delete();
    }
}

روتین از فایل موقت استفاده می‌کند که می‌خواهیم در هر صورت حذف شود. بلوک finally این را مختصر بیان می‌کند.

وقتی نمی‌توانید منابع را متوازن کنید

گاهی الگوی پایه تخصیص منبع مناسب نیست. معمولاً در برنامه‌هایی با ساختار داده پویا. یک روتین ناحیه حافظه تخصیص می‌دهد و به ساختار بزرگ‌تر وصل می‌کند و ممکن است مدتی بماند.

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

  1. ساختار سطح بالا مسئول آزاد کردن زیرساختارهایش هم هست. آن‌ها بازگشتی داده‌ای که دارند را حذف می‌کنند و همین‌طور ادامه.

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

  3. ساختار سطح بالا اگر زیرساختار دارد از آزاد کردن خودداری می‌کند.

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

در نهایت اگر ردیابی منابع سخت شد، می‌توانید با شمارش ارجاع روی اشیای تخصیص‌شده پویا شکل محدود جمع‌آوری زباله خودتان را پیاده کنید. کتاب More Effective C++ [Mey96] بخشی به این موضوع اختصاص داده.

بررسی توازن

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

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

در سطح پایین‌تر اما نه کم‌فایده‌تر، می‌توانید در ابزارهایی سرمایه‌گذاری کنید که (از جمله) برنامه‌های در حال اجرا را برای نشت حافظه بررسی می‌کنند. Purify (www.rational.com) و Insure++ (www.parasoft.com) انتخاب‌های رایج‌اند.

بخش‌های مرتبط:

  • طراحی قراردادمحور، صفحه ۱۰۹
  • برنامه‌نویسی قاطع، صفحه ۱۲۲
  • جداسازی و قانون دمتر، صفحه ۱۳۸

Challenges

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

Exercises

پاسخ در صفحه ۲۹۲

۲۲. بعضی توسعه‌دهندگان C و C++ پس از آزاد کردن حافظه‌ای که اشاره‌گر به آن اشاره می‌کرد، اشاره‌گر را NULL می‌کنند. چرا این ایده خوبی است؟

پاسخ در صفحه ۲۹۲

۲۳. بعضی توسعه‌دهندگان Java پس از اتمام استفاده از شیء، متغیر شیء را NULL می‌کنند. چرا این ایده خوبی است؟