فصل چهارم - پارانویاگراییِ عملگرا
نکتهٔ ۳۶ نمیتوانی نرمافزارِ بینقص بنویسی
درد داشت؟ نباید داشته باشد. این را بهعنوان یک اصلِ قطعیِ زندگی بپذیر. در آغوشش بگیر. حتی جشنش بگیر. چون نرمافزارِ بینقص وجود ندارد. در تاریخ کوتاهِ محاسبات، هیچکس هرگز نرمافزاری تولید نکرده که بیاشتباه باشد. بعید است تو نخستین نفر باشی. و اگر این را نپذیری، فقط وقت و انرژیات را برای دنبالکردن رؤیایی ناممکن تلف خواهی کرد.
با وجود این حقیقتِ نهچندان دلچسب، یک برنامهنویس عملگرا چطور آن را به یک مزیت تبدیل میکند؟ این همان موضوعِ این فصل است.
همه میدانند که فقط خودشان رانندهٔ خوبِ جهان هستند. بقیه آمدهاند تا ما را از پا دربیاورند؛ از چراغقرمز رد میشوند، بین خطوط زیگزاگ میزنند، راهنما نمیزنند، وسط رانندگی پیام میفرستند و درکل به استانداردهای ما نمیرسند. پس دفاعی رانندگی میکنیم؛ پیش از وقوع، خطر را میبینیم، غیرمنتظره را پیشبینی میکنیم و خودمان را هرگز در موقعیتی نمیگذاریم که راهِ خروج نداشته باشد.
قیاس برنامهنویسی با این داستان کاملاً روشن است. دائماً با کدِ دیگران سروکار داریم—کُدی که شاید با استانداردهای ما فاصله داشته باشد—و با ورودیهایی که ممکن است معتبر باشند یا نباشند. بنابراین یاد میگیریم دفاعی کدنویسی کنیم. اگر شک داریم، همهٔ اطلاعاتی را که دریافت میکنیم اعتبارسنجی میکنیم. با assertionها دادهٔ بد را شناسایی میکنیم و به ورودیهای افرادِ مهاجم یا شیطنتکار اعتماد نمیکنیم. سازگاری را بررسی میکنیم، روی ستونهای پایگاه داده محدودیت میگذاریم و معمولاً از خودمان هم راضی هستیم.
اما برنامهنویسان عملگرا یک گام جلوتر میروند. آنها حتی به خودشان هم اعتماد نمیکنند. چون میدانند هیچکس کدِ کامل نمینویسد، ازجمله خودشان، پس در برابر اشتباهات خودشان هم دفاعهایی میسازند.
در موضوع ۲۳ طراحی مبتنی بر قرارداد، دربارهٔ اینکه «مشتری» و «تأمینکننده» باید دربارهٔ حقوق و مسئولیتها توافق کنند صحبت میکنیم.
در موضوع ۲۴ برنامههای مُرده دروغ نمیگویند، هدف این است که هنگام رفع باگها، آسیبی وارد نکنیم. پس تا میتوانیم چیزها را چک میکنیم و اگر اوضاع خراب شد، برنامه را متوقف میکنیم.
موضوع ۲۵ برنامهنویسی تأکیدی یک روش ساده برای بررسی حین کار ارائه میدهد—کدی بنویس که فعالانه فرضیاتت را تأیید کند.
وقتی برنامههایت پویاتر شوند، خودت را وسط jongling منابع سیستم—حافظه، فایل، دستگاهها و...—میبینی. در موضوع ۲۶ چطور منابع را متعادل نگه داریم راههایی پیشنهاد میکنیم تا هیچکدام از این توپها از دستت نیفتد.
و از همه مهمتر، همیشه با گامهای کوچک حرکت میکنیم. همانطور که در موضوع ۲۷ از چراغجلوی خود جلو نزن توضیح داده شده، تا از لبهٔ پرتگاه سقوط نکنیم.
در جهانی پر از سیستمهای ناقص، زمانبندیهای خندهدار، ابزارهای مضحک و نیازمندیهای ناممکن، باید محتاط بود. و همانطور که وودی آلن گفته:
«وقتی واقعاً همه دنبال گیر انداختن تو هستند، پارانویا فقط نوعی درست فکر کردن است.»
موضوع ۲۳
طراحی مبتنی بر قرارداد (Design by Contract)
هیچ چیز انسانها را به اندازهٔ عقل سلیم و برخورد صادقانه متعجب نمیکند.
ـ رالف والدو امرسون، مقالات
کار با سیستمهای رایانهای سخت است. کار با آدمها سختتر. با این حال، ما انسانها هزاران سال فرصت داشتهایم دربارهٔ تعاملات انسانی فکر کنیم و برای مشکلاتش راهحلهایی بسازیم. بعضی از این راهحلها در دنیای نرمافزار هم قابل استفادهاند. یکی از بهترین راهها برای تضمین «برخورد صادقانه»، قرارداد است.
قرارداد حقوق و مسئولیتهای هر طرف را مشخص میکند؛ همچنین توافقی دربارهٔ پیامدهای نقض قرارداد وجود دارد. شاید قرارداد کاری داشته باشی که ساعات کار و قواعد رفتاریات را تعیین میکند. در مقابل، شرکت به تو حقوق و مزایا میدهد. هر دو طرف به تعهداتشان عمل میکنند و همه سود میبرند.
این ایده در سراسر جهان—چه رسمی چه غیررسمی—برای مدیریت روابط انسانی استفاده میشود. آیا میتوانیم همین مفهوم را برای تعامل بخشهای مختلف نرمافزار به کار ببریم؟ پاسخ «بله» است.
برتراند مایر در کتاب ساخت نرمافزار شیءگرا مفهوم طراحی مبتنی بر قرارداد را برای زبان Eiffel ابداع کرد. روشی ساده اما قدرتمند که بر مستندسازی (و توافق بر سر) حقوق و مسئولیتهای ماژولهای نرمافزاری تمرکز دارد تا «درستی» برنامه تضمین شود. برنامهٔ درست چیست؟ برنامهای که نه کمتر از آنچه قول داده انجام دهد و نه بیشتر. مستندسازی و راستیآزمایی این ادعا، قلب Design by Contract یا DBC است.
هر تابع یا متُدی در برنامه کاری انجام میدهد. پیش از شروع آن کار، تابع ممکن است دربارهٔ «وضعیت جهان» انتظاراتی داشته باشد، و پس از پایان نیز ادعاهایی دربارهٔ وضعیت جهان بیان کند. مایر این انتظارات و ادعاها را چنین تعریف میکند:
پیششرطها (Preconditions):
چیزهایی که باید پیش از فراخوانی روال درست باشند؛ الزامات تابع. تابع نباید زمانی صدا زده شود که پیششرطهایش نقض شده باشند. مسئولیت دادهٔ درست، بر عهدهٔ فراخواننده است.
پسشرطها (Postconditions):
چیزهایی که تابع تضمین میکند پس از پایانشان برقرار باشند. وجود پسشرط یعنی تابع قطعاً به پایان میرسد؛ حلقهٔ بینهایت پذیرفته نیست.
ناورداییها (Class Invariants):
شرطی که باید از نگاه فراخواننده همیشه برقرار باشد. ممکن است هنگام اجرای داخلی تابع این شرط برقرار نباشد، اما هنگام خروج تابع باید دوباره برقرار شده باشد. (کلاسی که ناوردایی دارد نمیتواند امکان نوشتن آزادانه روی دادههای مربوط به آن ناوردایی را بدهد.)
بنابراین قرارداد میان تابع و فراخواننده چنین خوانده میشود:
اگر همهٔ پیششرطهای تابع توسط فراخواننده رعایت شود، تابع تضمین میکند که هنگام پایان، همهٔ پسشرطها و ناورداییها برقرار خواهند بود.
اگر هر یک از طرفین از قرارداد تخطی کند، راهحلِ از پیشتوافقشده اجرا میشود—شاید یک استثنا پرتاب شود یا برنامه پایان یابد. مهم این است که نقض قرارداد، یک باگ است، نه یک حالت عادی. به همین دلیل پیششرطها نباید جایگزین اعتبارسنجی ورودی کاربر شوند.
بعضی زبانها پشتیبانی بهتری برای این مفاهیم دارند. برای مثال Clojure از pre و post پشتیبانی میکند. مثال زیر تابعی است برای دریافت سپرده:
(defn accept-deposit [account-id amount]
{ :pre [ (> amount 0.00)
(account-open? account-id) ]
:post [ (contains? (account-transactions account-id) %) ] }
"Accept a deposit and return the new transaction id"
;; Some processing...
(create-transaction account-id :deposit amount))
دو پیششرط وجود دارد: مبلغ باید مثبت باشد و حساب باید باز و معتبر باشد. پسشرط تضمین میکند که تراکنش جدید در فهرست تراکنشهای حساب یافت شود.
اگر فراخوانی درست باشد، تابع کارش را انجام میدهد. اگر مثلاً مقدار منفی بدهی، با خطای زمان اجرا روبهرو میشوی:
AssertionError: (> amount 0.0)
در زبانهایی مانند Elixir نیز میتوان با «گارد کلوز»ها رفتار مشابهی داشت. مقدار نامعتبر اساساً نمیتواند تابع را فراخوانی کند:
def accept_deposit(account_id, amount) when (amount > 0) do
# processing...
end
اگر مقدار ≤۰ بدهی، اصلاً هیچ نسخهای از تابع با آن پارامترها وجود ندارد:
(FunctionClauseError)
این بهتر از چک کردن دستی ورودی است؛ چون تابع اساساً قابل فراخوانی با دادهٔ خارج از محدوده نیست.
نکتهٔ ۳۷
با قرارداد طراحی کن
در موضوع ۱۰ دربارهٔ «کد خجالتی» صحبت شد. اینجا دربارهٔ «کد تنبل» حرف میزنیم: در قبول ورودی سختگیر باش، و در وعدهٔ خروجی کمگو. اگر قرارداد تو قولِ زیاد بدهد، باید کوهِ کد بنویسی.
DBC در هر زبان—تابعی، شیءگرا یا رویهای—مجبورت میکند فکر کنی.
DBC و توسعهٔ آزمونمحور
آیا در دنیایی که TDD و تست واحد و تست property-based وجود دارد، هنوز به DBC نیاز است؟
پاسخ کوتاه: بله.
DBC و تست دو رویکرد متفاوت برای «درستی برنامه» هستند. هر دو ارزشمندند. برخی مزایای DBC نسبت به تست:
- نیاز به تنظیمات و Mock ندارد.
- موفقیت و شکست را برای همهٔ حالات تعریف میکند، نه فقط یک نمونهٔ مورد تست.
- تستها فقط هنگام «ساخت» اجرا میشوند، اما DBC و assertionها دائمیاند: در طراحی، توسعه، استقرار و نگهداری.
- TDD روی رفتار بیرونی (black box) متمرکز است؛ نه ناورداییهای داخلی.
- DBC مؤثرتر و DRYتر از «برنامهنویسی دفاعی» است که همه مجبورند ورودی را دوباره بررسی کنند.
- TDD معمولاً تمرکز را به «مسیر خوشحال» سوق میدهد، نه دنیای واقعی پر از دادهٔ بد، کاربران مخرب، نسخههای اشتباه و مشخصات ناقص.
ناورداییها در زبانهای تابعی
در Eiffel، ناوردایی «کلاس» خوانده شد چون شیءگرا بود. اما مفهوم، کلیتر است: ناوردایی، قانون وضعیت است. در زبانهای تابعی نیز وضعیت به توابع داده و بازگردانده میشود، و ناورداییها بسیار مفیدند.
پیادهسازی DBC
اینکه قبل از نوشتن کد، دامنهٔ ورودی، مرزها، و چیزهایی که تابع قول میدهد (یا قول نمیدهد) مشخص شوند، جهشی بزرگ در جهت نوشتن نرمافزار بهتر است. اگر این کار انجام نشود، دوباره به «برنامهنویسی تصادفی» برمیگردیم.
در زبانهایی که پشتیبانی DBC ندارند، میتوان آن را در کامنتها یا تستها نوشت. این هم ارزشمند است.
Assertionها
استفاده از assertionها بخشی از قرارداد را پیادهسازی میکند، اما نه همهٔ آن را. برخی مشکلات:
- ارثبری معمولاً پیششرطها و پسشرطها را خودکار منتقل نمیکند.
- ناورداییها باید دستی فراخوانی شوند.
- مفهوم مقدار old وجود ندارد، باید دستی ذخیره شود.
- کتابخانهها و APIها قرارداد را رعایت نمیکنند، و خطاها در مرز سیستمها بیشتر رخ میدهد.
چه کسی مسئول پیششرط است؟
در زبانهای دارای DBC، هیچکدام. سیستم پیششرط را قبل از ورود تابع بررسی میکند.
بنابراین اگر ورودی اشتباه است، تابع هرگز آن را نخواهد دید.
مثلاً sqrt پیششرط دارد: عدد منفی نباید دریافت کند. اگر کاربر عدد منفی وارد کند، وظیفهٔ تابع sqrt نیست؛ وظیفهٔ فراخواننده است که رفتار مناسب انتخاب کند.
با تعریف پیششرط، بارِ درستی را به دوش فراخواننده میگذاری—جایی که باید باشد.
DBC و «سریع کرش کردن»
DBC کاملاً با ایدهٔ «زود کرش کن» هماهنگ است.
اگر sqrt عدد منفی بگیرد، پیام خطای واضحی میدهد.
در زبانهایی مثل C/C++/Java نتیجه NaN میشود، و شاید خیلی دیرتر از محل واقعی خطا به مشکل بخوری.
ناورداییهای معنایی (Semantic Invariants)
برخی قوانین، «جوهر معنا» را بیان میکنند؛ نه سیاستهای قابل تغییر.
مثلاً در سامانهٔ تراکنش کارت بانکی، قانونی وجود داشت:
هرگز یک تراکنش نباید دوباره اعمال شود.
این قانون راهنمای طراحی و پیادهسازی در بخشهای زیادی شد.
باید مراقب بود که قوانین بنیادی با قوانین سیاستی اشتباه گرفته نشوند.
قانون بنیادی چیزی است که «تعریفکنندهٔ ماهیت سیستم» است.
قراردادهای پویا و عاملها (Agents)
تا اینجا قراردادها را ثابت فرض کردیم، اما در سیستمهای عاملمحور (autonomous agents) قراردادها میتوانند پویا باشند. عامل میتواند بگوید:
«این را نمیدهم، اما اگر این را بدهی، چیز دیگری میدهم.»
در چنین جهانی، همهچیز به قرارداد وابسته است—even اگر قرارداد در لحظه تولید شود.
شاید روزی با تعداد کافی عامل هوشمند، خود نرمافزار با مذاکرهٔ قراردادی مشکلات تولید نرمافزار را حل کند.
اما تا وقتی نتوانیم قرارداد را دستی بسازیم، قرارداد خودکار نیز ممکن نیست.
بخشهای مرتبط
موضوع ۲۴: برنامههای مُرده دروغ نمیگویند
موضوع ۲۵: برنامهنویسی تأکیدی
موضوع ۳۸: برنامهنویسی برحسب تصادف
موضوع ۴۲: تست مبتنی بر ویژگی
موضوع ۴۳: در امان بمان
موضوع ۴۵: تلهٔ نیازمندیها
چالشها
اگر DBC اینقدر قدرتمند است، چرا اینقدر کم استفاده میشود؟
شاید سخت باشد. شاید وادارت کند دربارهٔ چیزهایی فکر کنی که میخواهی فعلاً نادیدهشان بگیری. شاید مجبور کند فکر کنی.
ابزار خطرناکی است!
تمرینها
تمرین ۱۴:
رابط یک مخلوطکن آشپزخانه طراحی کن. ده سرعت دارد (۰ یعنی خاموش). خالی نباید کار کند. سرعت فقط یک واحد یک واحد تغییر میکند.
روشها:
int getSpeed()
void setSpeed(int x)
boolean isFull()
void fill()
void empty()
پیششرطها، پسشرطها و ناوردایی مناسب اضافه کن.
تمرین ۱۵:
چند عدد در دنبالهٔ ۰، ۵، ۱۰، ۱۵، …، ۱۰۰ وجود دارد؟
موضوع ۲۴
برنامههای مُرده دروغ نمیگویند
گاهی دیگران قبل از اینکه خودت بفهمی، از حالوروزت میفهمند چیزی درست نیست. در مورد کدهای دیگران هم همینطور است. اگر در برنامهٔ ما مشکلی در حال شکلگیری باشد، گاهی اولین جایی که متوجه میشود یک کتابخانه یا تابع چارچوب است. شاید یک مقدار nil پاس دادهایم، یا یک لیست خالی. شاید کلید موردنظر در هش وجود ندارد، یا مقداری که فکر میکردیم یک هش است درواقع لیست از کار درآمده. شاید خطای شبکه داشتهایم، یا خطای فایلسیستم، و دادهٔ خالی یا خراب تحویل گرفتهایم. شاید چند میلیون دستور قبل یک خطای منطقی باعث شده انتخابگر یک دستور case دیگر ۱ یا ۲ یا ۳ نباشد، و وارد حالت پیشفرض شویم. به همین دلیل است که هر دستور switch/case نیاز به یک حالت پیشفرض دارد؛ باید بفهمیم «ناممکن» اتفاق افتاده است.
خیلی راحت میتوانیم بگوییم «این که نمیشود!». بسیاری از ما کدی نوشتهایم که چک نکرده فایل درست بسته شده یا نه، یا اینکه پیام لاگ واقعاً نوشته شده یا نه. احتمالاً هم لازم نبوده—کد در شرایط عادی مشکل نداشته. اما ما داریم دفاعی کدنویسی میکنیم. مطمئن میشویم داده همان چیزی است که تصور میکردیم، نسخهٔ کد تولید همان کدی است که باید باشد، و وابستگیها همان نسخهای هستند که فکر میکنیم.
هر خطا اطلاعاتی به تو میدهد. میتوانی خودت را قانع کنی که «این خطا نمیتواند رخ دهد» و آن را نادیده بگیری. اما برنامهنویسان عملگرا به خود میگویند: اگر خطایی وجود دارد، اتفاقی خیلی خیلی بد رخ داده. فراموش نکن پیام خطا را بخوان.
گرفتن و دوباره انداختن استثناها مخصوص ماهیگیری است
بعضی برنامهنویسان فکر میکنند کارِ درستی است که تمام استثناها را بگیرند، پیامی بنویسند، و دوباره آن را پرتاب کنند. کدهایشان پر است از چنین الگوهایی:
try do
add_score_to_board(score)
rescue InvalidScore
Logger.error("Can't add invalid score. Exiting")
raise
rescue BoardServerDown
Logger.error("Can't add score: board is down. Exiting")
raise
rescue StaleTransaction
Logger.error("Can't add score: stale transaction. Exiting")
raise
end
برنامهنویس عملگرا اینگونه مینویسد:
add_score_to_board(score)
دو دلیل دارد:
اول اینکه کد اصلی زیر انبوه خطایابی دفن نمیشود.
دوم اینکه coupling کمتر میشود. در نسخهٔ پرحرف، باید تمام استثناهای ممکن را لیست کنیم. اگر نویسندهٔ تابع استثنای جدیدی اضافه کند، کد ما بیسروصدا منسوخ میشود. اما در نسخهٔ کوتاه، استثنای جدید خودش بهصورت طبیعی بالا میآید.
نکته ۳۸
زود کرش کن
کرش کن، نه خرابکاری
مزیت کشف سریع مشکل این است که میتوانی سریعتر کرش کنی—و در بسیاری مواقع، بهترین کار همین است. ادامهدادن ممکن است باعث شود به پایگاه دادهٔ حیاتی دادهٔ خراب بنویسی، یا ماشین لباسشویی را برای بیستمین بار وارد دور خشککن کنی.
در زبانهای Erlang و Elixir این فلسفه یک اصل است. جو آرماسترانگ، خالق Erlang، جملهٔ معروفی دارد:
«برنامهنویسی دفاعی اتلاف وقت است. بگذار کرش کند!»
برنامهها در این محیطها طوری طراحی میشوند که بتوانند شکست بخورند، اما شکست مدیریت میشود. یک Supervisor وظیفه دارد در صورت خرابی، پروسه را تمیز کند، دوباره راهاندازی کند، یا کار مناسب دیگری انجام دهد. اگر Supervisor خراب شود، Supervisor دیگری آن را مدیریت میکند. این ساختار درختی بسیار مؤثر است و دلیل استفادهٔ این زبانها در سیستمهای بسیار پایدار و خطاپذیر است.
در محیطهای دیگر شاید خروج از برنامه مناسب نباشد. ممکن است منابعی در اختیار داشته باشی که باید آزاد شوند، یا نیاز به لاگنویسی، ساماندهی تراکنشهای باز یا هماهنگی با پردازههای دیگر باشد.
اما اصل موضوع ثابت است:
وقتی کد تو متوجه میشود چیزی که «ناممکن» بوده اتفاق افتاده، برنامه از نظر منطقی دیگر قابل اعتماد نیست. از این نقطه به بعد، هر کاری انجام دهد مشکوک است. پس باید در سریعترین زمان ممکن متوقف شود.
یک برنامهٔ مُرده معمولاً بسیار کمتر از یک برنامهٔ نیمهجان آسیب میزند.
بخشهای مرتبط
موضوع ۲۰: اشکالزدایی
موضوع ۲۳: طراحی مبتنی بر قرارداد
موضوع ۲۵: برنامهنویسی تأکیدی
موضوع ۲۶: چگونه منابع را متعادل نگه داریم
موضوع ۴۳: مراقب باش
موضوع ۲۵
برنامهنویسی تأکیدی (Assertive Programming)
در سرزنشکردنِ خود لذتی هست؛ وقتی خودمان را مقصر میدانیم، حس میکنیم دیگران حق سرزنش ما را ندارند.
ـ اسکار وایلد، تصویر دوریان گری
بهنظر میرسد برنامهنویسها از همان اوایل کار چیزی شبیه یک مانترا یاد میگیرند؛ اصلی بنیادین که به همهٔ جنبهها سرایت میکند—نیازمندیها، طراحیها، کدها، کامنتها، هر چیزی. آن اصل این است:
«این اتفاق هرگز نمیافتد…»
«این برنامه هیچوقت خارج از کشور استفاده نمیشود، پس چرا بینالمللیاش کنیم؟»
«count که نمیتواند منفی باشد.»
«لاگنویسی که خراب نمیشود.»
این خودفریبی را—بهخصوص هنگام نوشتن کد—تمرین نکنیم.
نکتهٔ ۳۹
با assertionها جلوی ناممکن را بگیر
هر وقت در ذهنت این جمله شکل گرفت «خب معلومه، این که نمیتونه اتفاق بیفته»، همان لحظه کدی اضافه کن که آن را چک کند.
سادهترین راه، استفاده از assert است. در اغلب زبانها میتوانی شرط بولی را با یک assert بررسی کنی. این چکها بسیار ارزشمندند. اگر مقدار ورودی یا خروجی هرگز نباید null باشد، صریحاً آن را بررسی کن:
assert (result != null);
در جاوا حتی میتوانی پیام توصیفی اضافه کنی:
assert result != null && result.size() > 0 : "Empty result from XYZ";
assertionها برای بررسی درستی الگوریتم هم مفید هستند. فرض کن تابع مرتبسازی خلاقانهای نوشتهای به اسم my_sort:
books = my_sort(find("scifi"))
assert(is_sorted?(books))
اما assertion جایگزین خطایابی واقعی نیست. assertion مخصوص شرایطی است که نباید رخ دهد. مثلاً این بسیار بد است:
puts("Enter 'Y' or 'N': ")
ans = gets[0]
assert((ans == 'Y') || (ans == 'N')) # اشتباه کامل!
اینکه بیشتر assertها هنگام شکست، برنامه را متوقف میکنند، دلیل نمیشود نسخههای سفارشی تو هم همین کار را بکنند. اگر لازم داری منابع را آزاد کنی، میتوانی استثنای assertion را بگیری و رسیدگی کنی—فقط مراقب باش در آن «میلیثانیههایِ جاندادنِ برنامه»، از دادهٔ خراب استفاده نکنی.
Assertionها و اثر جانبی (Side Effects)
گاهی کدی که برای کشف خطا افزودهای، خودش خطای جدید ایجاد میکند. اگر شرط assertion اثر جانبی داشته باشد، این خطر بالاست.
مثلاً این یک فاجعه است:
while (iter.hasMoreElements()) {
assert(iter.nextElement() != null);
Object obj = iter.nextElement();
// ...
}
چرا؟
چون nextElement() در خود assertion یک عنصر جلو میرود، و حلقه فقط نصف عناصر را پردازش میکند!
نسخهٔ درست:
while (iter.hasMoreElements()) {
Object obj = iter.nextElement();
assert(obj != null);
// ...
}
این نوع باگها شبیه Heisenbug هستند—بگهایی که هنگام بررسی، خودِ عملِ بررسی رفتار سیستم را تغییر میدهد.
(و البته امروزه که اکثر زبانها ابزارهای درستوحسابی برای پیمایش مجموعهها دارند، چنین حلقههایی از نظر ما «بدفرم» و غیرضروری است.)
Assertionها را روشن نگه دار
برداشتی غلط دربارهٔ assertionها وجود دارد:
«assertionها فقط برای دیباگ هستند. وقتی برنامه تست شد و منتشر شد، باید خاموش شوند تا سرعت بیشتر شود.»
اما دو فرض کاملاً اشتباه در این طرز تفکر وجود دارد:
۱) این فرض که تست همهٔ باگها را پیدا میکند. برای هر برنامهٔ پیچیده، تنها بخش کوچکی از وضعیتهای ممکن تست میشوند.
۲) این فراموشی که برنامهٔ ما در جهانی «خطرناک» اجرا میشود. هنگام تست، موشها کابل شبکه را قطع نمیکنند، بازیکنی حافظهٔ دستگاه را نمیبلعد، یا لاگفایلها پارتیشن را پر نمیکنند. اما در محیط واقعی؟ چرا که نه.
خط دفاع اول: چک کردن هر حالت ممکن
خط دفاع دوم: assertion برای یافتن خطاهایی که از دست دادهای
خاموشکردن assertionها در محیط واقعی مثل راهرفتن روی طناب بدون تور نجات است فقط چون یکبار در تمرین موفق بودهای. نمایش خوبی دارد؛ امنیتی ندارد.
اگر مشکل عملکرد داری، فقط assertionهایی را خاموش کن که واقعاً هزینهزا هستند—نه همه را.
استفاده از assertion در تولید = ثبات فوقالعاده
یکی از همسایههای سابق اندی مدیر یک شرکت کوچک سازندهٔ تجهیزات شبکه بود. یکی از «رازهای موفقیت» آنها این بود که assertionها را در نسخهٔ تولید روشن گذاشته بودند.
Assertionها با دقت طراحی شده بودند تا اطلاعات کامل و قابلاستفاده ارائه دهند—در همان شرایط واقعی استفاده. نتیجه چه شد؟
رفع دقیق و سریع باگهای عمیق و سختقابلتکثیر.
محصولی فوقالعاده پایدار و محکم.
و در نهایت، خرید شرکت با رقمی چندصد میلیون دلاری.
فقط همین.
تمرین ۱۶
یک چک واقعیت سریع. کدام یک از موارد «ناممکن» زیر میتواند واقعاً رخ دهد؟
• ماهی با کمتر از ۲۸ روز
• خطای سیستم: عدم دسترسی به دایرکتوری فعلی
• در ++C: مقدار a=2، b=3؛ اما (a + b) برابر ۵ نشود
• مثلثی با مجموع زوایای داخلی ≠ ۱۸۰ درجه
• دقیقهای که ۶۰ ثانیه ندارد
• برقرار بودن عبارت: (a + 1) <= a
بخشهای مرتبط
موضوع ۲۳: طراحی مبتنی بر قرارداد
موضوع ۲۴: برنامههای مُرده دروغ نمیگویند
موضوع ۴۲: تست مبتنی بر ویژگی
موضوع ۴۳: مراقب باش
موضوع ۲۶
چگونه منابع را متعادل نگه داریم
روشنکردن یک شمع یعنی ساختن یک سایه…
ـ اورسولا لو گویین، جادوگر اِرسیا
وقتی برنامه مینویسیم، همیشه در حال مدیریت منابع هستیم: حافظه، تراکنشها، تردها، اتصالهای شبکه، فایلها، تایمرها—هر چیزی که کمیاب است. اغلب الگوی استفاده از منابع ساده است: منبع را میگیری، استفاده میکنی، و بعد آزادش میکنی.
اما بسیاری از برنامهنویسان دربارهٔ تخصیص و آزادسازی منابع هیچ برنامهٔ منسجمی ندارند. پس بیایید یک نکتهٔ ساده پیشنهاد کنیم:
نکتهٔ ۴۰
کاری را که شروع میکنی، تمام کن
در اکثر موقعیتها این اصل بسیار ساده است:
تابع یا شیئی که یک منبع را تخصیص میدهد، باید مسئول آزاد کردن آن نیز باشد.
یک مثال از کد بد بررسی کنیم—بخشی از کدی Ruby که یک فایل را باز میکند، مشتری را میخواند، موجودی را تغییر میدهد و دوباره مینویسد. برای سادگی، خطایابی حذف شده:
def read_customer
@customer_file = File.open(@name + ".rec", "r+")
@balance = BigDecimal(@customer_file.gets)
end
def write_customer
@customer_file.rewind
@customer_file.puts @balance.to_s
@customer_file.close
end
def update_customer(transaction_amount)
read_customer
@balance = @balance.add(transaction_amount, 2)
write_customer
end
در نگاه اول update_customer تمیز است: میخواند، آپدیت میکند، مینویسد.
اما همین تمیزی، مشکل بزرگی را پنهان کرده است.
تابعهای read_customer و write_customer بهشدت به هم جفتومنجوق شدهاند؛ هر دو از متغیر مشترک @customer_file استفاده میکنند. این مسئولیت در update_customer حتی دیده هم نمیشود.
چرا این بد است؟
تصور کن برنامهنویس تعمیرکار مجبور شده نیازمندی جدیدی اضافه کند:
بهروزرسانی فقط اگر مقدار جدید منفی نباشد.
کد را اینطور تغییر میدهد:
def update_customer(transaction_amount)
read_customer
if (transaction_amount >= 0.00)
@balance = @balance.add(transaction_amount, 2)
write_customer
end
end
در تستها همهچیز خوب است.
اما در محیط واقعی، بعد از چند ساعت برنامه از کار میافتد—با شکایت «فایلهای زیاد باز شده».
چرا؟
چون اگر شرط برقرار نباشد، write_customer فراخوانی نمیشود و فایل هرگز بسته نمیشود.
راهحل بد (اما معمول):
else
@customer_file.close # ایدهٔ بسیار بد!
end
این مشکل را ظاهراً حل میکند، اما حالا سه تابع از طریق یک متغیر مشترک درگیر شدهاند. مدیریت وضعیت فایل پیچیده و خرابکار میشود. تعادل از بین رفته است.
راهحل: تخصیص و آزادسازی را در یک جا نگهدار
طبق اصل «کاری را که شروع میکنی تمام کن»، تابعی که فایل را باز میکند باید آن را ببندد.
بازنویسی تمیز:
def read_customer(file)
@balance = BigDecimal(file.gets)
end
def write_customer(file)
file.rewind
file.puts @balance.to_s
end
def update_customer(transaction_amount)
file = File.open(@name + ".rec", "r+")
read_customer(file)
@balance = @balance.add(transaction_amount, 2)
file.close
end
اکنون update_customer هم فایل را باز میکند و هم میبندد.
تعادل برقرار است، متغیر مشترک حذف شده، و کد شفافتر است.
استفاده از بلوک—یک قدم بهتر
بسیاری از زبانهای مدرن میتوانند محدودهٔ زندگی منبع را به یک بلاک محدود کنند. Ruby این را عالی پشتیبانی میکند:
def update_customer(transaction_amount)
File.open(@name + ".rec", "r+") do |file|
read_customer(file)
@balance = @balance.add(transaction_amount, 2)
write_customer(file)
end
end
وقتی بلاک تمام شود، فایل خودکار بسته میشود.
بدون فراموشی. بدون نشتی. بدون دردسر.
وقتی شک داری، دامنه را کوچک کن.
نکتهٔ ۴۱
محلی عمل کن
تا اینجا منابع «زودگذر» را بررسی کردیم. اما منابعی هم هستند که جا میمانند:
فایلهای لاگ، فایلهای دیباگ، رکوردهایی که در دیتابیس میسازیم.
آیا برنامهٔ پاکسازی داری؟
چرخش لاگها انجام میشود؟
دادهٔ قدیمی حذف میشود؟
هر چیزی که مصرف میکنی باید نوعی تعادل هم برایش در نظر بگیری.
تخصیصهای تو در تو (Nested Allocations)
هنگام استفادهٔ همزمان از چند منبع:
۱) منابع را برعکس ترتیب تخصیص آزاد کن.
۲) در همهٔ نقاط برنامه، منابع را در یک ترتیب ثابت تخصیص بده.
این دو اصل خطر orphan شدن و deadlock را بهشدت کاهش میدهد.
اشیاء و استثناها
تخصیص/آزادسازی مثل سازنده/مخرب در زبانهای شیءگراست. اگر منبع را در یک کلاس کپسوله کنی، destructor میتواند هنگام خروج از scope، منبع را آزاد کند—حتی هنگام وقوع استثنا.
مدیریت منابع در شرایط استثنا
دو روش کلی:
۱) استفاده از scope (مثل C++ یا Rust)
۲) استفاده از finally (در try/catch)
نمونهای از Rust:
{
let mut accounts = File::open("mydata.txt")?;
// ...
}
// فایل اینجا خودکار بسته میشود
نمونهٔ درست استفاده از finally:
❌ نسخهٔ اشتباه:
begin
thing = allocate_resource()
process(thing)
ensure
deallocate(thing)
end
اگر allocate_resource شکست بخورد، finally سعی میکند چیزی را آزاد کند که اصلاً وجود ندارد!
✔ نسخهٔ درست:
thing = allocate_resource()
begin
process(thing)
ensure
deallocate(thing)
end
وقتی نمیتوانی منابع را متعادل کنی
در ساختارهای پویا (مثل درختها، گرافها)، یک روال حافظه را تخصیص میدهد و در ساختار ذخیره میکند. اینجا باید ناوردایی معنایی داشته باشی:
چه کسی مسئول آزادسازی است؟
سه گزینهٔ معمول:
۱) ساختار سطح بالا همهٔ زیرساختها را آزاد کند.
۲) فقط خودش را آزاد کند و زیرساختها یتیم شوند.
۳) اگر هنوز زیرساخت دارد، اجازهٔ آزادسازی ندهد.
هر ساختار باید تصمیم شفاف و ثابت داشته باشد.
برای زبانهایی مثل C بهتر است یک ماژول برای هر ساختار بنویسی:
شامل تخصیص، آزادسازی، چاپ دیباگ، serialize/deserialize، traversal و…
بررسی تعادل (Checking the Balance)
برنامهنویس عملگرا حتی به خودش هم اعتماد ندارد.
پس ابزار میسازد: wrapperهایی برای منابع که تمام تخصیص/آزادسازیها را ثبت کنند.
نقاط کلیدی برنامه—مثل اول حلقهٔ اصلی—جای خوبی برای چک وضعیت منابع است.
در سطح پایینتر، ابزارهای بررسی memory leak همیشه مفیدند.
بخشهای مرتبط
موضوع ۲۴: برنامههای مُرده دروغ نمیگویند
موضوع ۳۰: دگرگونی برنامهنویسی
موضوع ۳۳: شکستدادن coupling زمانی
چالشها
هیچ تضمینی برای آزادسازی کامل وجود ندارد، اما الگوهای طراحی—بهویژه ناورداییهای معنایی و طراحی مبتنی بر قرارداد—کمک بزرگی میکنند.
تمرین ۱۷
چرا C/C++ برنامهنویسانی هستند که بعد از free کردن پوینتر، مقدار آن را NULL میکنند؟
تمرین ۱۸
چرا بعضی از برنامهنویسان جاوا وقتی از یک شیء کارشان تمام میشود آن را NULL میکنند؟
موضوع ۲۷
از چراغجلوی خود جلو نزن
پیشبینی سخت است، مخصوصاً دربارهٔ آینده.
ـ یوگی بِرا، برگرفته از ضربالمثل دانمارکی
نیمهشب است. تاریک. باران سیلآسا. یک ماشین دونفره با سرعت بالا پیچهای تیز جادهٔ کوهستانی را رد میکند، روی لبهٔ پیچها سر میخورد. یک پیچِ تندِ سوزنی پیش روست—راننده جا میماند، از حفاظ ضعیف کنار جاده رد میشود و در درهٔ زیر پایش سقوط میکند. مأموران پلیس میآیند، افسر ارشد سری تکان میدهد:
«حتماً از چراغجلوهای خودش جلو زده.»
یعنی ماشین با سرعت نور حرکت میکرد؟ نه. این «پیشروی بیش از توان چراغها» یعنی راننده آنقدر سریع میرفت که چراغها دیگر نمیتوانستند پیشِ رو را روشن کنند، و راننده نمیتوانست بهموقع ترمز یا فرمان بدهد.
چراغهای جلو محدودهٔ مشخصی دارند: فاصلهٔ تابش (throw distance). بعد از آن، نور پراکنده و بیاثر میشود. چراغها فقط جلو را روشن میکنند؛ پیچها، تپهها، فرورفتگیها بیرون از میدان دید میمانند. طبق آمار سازمان ایمنی بزرگراههای آمریکا، بردِ چراغهای نور پایین حدود ۱۶۰ فوت است. اما مسافت توقف در ۴۰ مایل حدود ۱۸۹ فوت و در ۷۰ مایل حدود ۴۶۴ فوت است.
پس واقعاً آسان است که از چراغها جلو بزنی.
در توسعهٔ نرمافزار، «چراغجلو»های ما نیز همینطور محدودند. نمیتوانیم آینده را خیلی دور ببینیم. هرچه خارج از محور نگاه کنیم، تاریکتر میشود.
به همین دلیل، برنامهنویسان عملگرا یک قانون ثابت دارند:
نکتهٔ ۴۲
همیشه با گامهای کوچک حرکت کن
همیشه گامهای کوچک و حسابشده بردار؛ هر گام را با بازخورد چک کن و سپس ادامه بده.
سرعت تو همان «نرخ بازخورد» است. اگر بازخورد کند است، نباید سریعتر از آن پیش بروی.
بازخورد یعنی چه؟ هر چیزی که مستقل از تو نشان دهد کارت درست بوده یا نه:
• خروجیِ REPL برای آزمودن API و الگوریتم
• تستهای واحد برای بررسی تغییر آخر
• دمو و گفتوگو با کاربر برای ارزیابی ویژگیها و کاربری
کارِ «بزرگ» چیست؟
هر کاری که نیاز به «غیبگویی» داشته باشد. همانطور که برد چراغها محدود است، پیشبینی ما هم حداکثر چند ساعت یا چند روز آینده را پوشش میدهد. بعد از آن، حدسها وارد قلمرو خیال و قمار میشوند.
از کجا میفهمی داری وارد غیبگویی میشوی؟ وقتی مجبوری:
• زمان تحویل پروژه را برای چند ماه بعد حدس بزنی
• برای آیندهٔ نامعلوم طراحی کنی
• نیازهای آیندهٔ کاربران را پیشبینی کنی
• حدس بزنی ابزارها و تکنولوژیهای آینده چه خواهند بود
شاید بپرسی: «اما مگر نباید برای آیندهٔ قابل نگهداری طراحی کنیم؟»
بله—اما فقط به اندازهای که میتوانی ببینی.
هرچه آینده را بیشتر حدس بزنی، ریسک بیشتری برای اشتباه داری.
بهجای تلاش برای طراحیِ آیندهای ناشناخته، بهتر است کاری کنی که کدت قابل تعویض باشد.
قابلتعویض بودن به طراحی بهتر، کاهش coupling، افزایش cohesion و رعایت DRY کمک میکند.
حتی اگر حس میکنی آینده را میفهمی، همیشه امکان ظهور یک «قویِ سیاه» هست.
قویهای سیاه (Black Swans)
در کتاب The Black Swan، نسیم نیکلاس طالب میگوید که رویدادهای مهم تاریخ معمولاً ناشی از اتفاقات نادر، غیرقابلپیشبینی و بسیار اثرگذار بودهاند. این وقایع خارج از الگوی معمول هستند اما تأثیرشان عظیم است. افزون بر این، سوگیریهای ذهنی ما تغییرات آرام را نمیبینند؛ تغییراتی که در لبههای حوزهٔ کاریمان رخ میدهند (بنبنگِ موضوع ۴: «سوپ سنگی و قورباغهٔ آبپز» را ببین).
زمان چاپ نسخهٔ اول برنامهنویس عملگرا، یک بحث شدید در دنیای کامپیوتر جریان داشت:
«در جنگ رابطهای کاربری دسکتاپ، Motif برنده میشود یا OpenLook؟»
سؤال اشتباه بود.
احتمالاً نام هیچکدام را نشنیدهای. هیچکدام «برنده» نشدند. وبِ مرورگرمحور همهچیز را بلعید.
نکتهٔ ۴۳
از غیبگویی دوری کن
بیشتر وقتها فردا شبیه امروز است—اما روی آن حساب نکن.
بخشهای مرتبط
موضوع ۱۲: گلولههای ردیاب
موضوع ۱۳: نمونهسازی و برگههای چسبی
موضوع ۴۰: بازآرایی
موضوع ۴۱: از تست به کد
موضوع ۴۸: جوهرهٔ چابکی
موضوع ۵۰: نارگیل برنده نیست