Skip to content

هنگام کدنویسی

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

کدنویسی مکانیکی نیست. اگر بود، همه ابزارهای CASE که مردم در اوایل دهه ۱۹۸۰ به آن‌ها امید بسته بودند، مدت‌ها پیش جای برنامه‌نویسان را گرفته بودند. هر دقیقه تصمیم‌هایی باید گرفته شود — تصمیم‌هایی که اگر برنامه حاصل عمر طولانی، دقیق و پربار داشته باشد، نیازمند فکر و قضاوت دقیق است.

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

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

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

چیزی که هنگام تولید کد باید همیشه در ذهن داشته باشید این است که روزی باید آن را آزمایش کنید. کد را آسان برای آزمایش بسازید تا احتمال اینکه واقعاً آزمایش شود بیشتر شود — موضوعی که در کدی که آزمایش آن آسان است پیش می‌بریم.

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

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


۳۱ — برنامه‌نویسی تصادفی

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

آزمایش‌های اولیه سرباز برای مین چیزی نشان نداد، اما این فقط شانس بود. او به نتیجه غلط رسید — با نتیجه فاجعه‌بار.

ما به‌عنوان توسعه‌دهنده هم در میدان مین کار می‌کنیم. هر روز صدها تله منتظرمان است. با یادآوری داستان سرباز، باید از نتیجه‌گیری غلط بترسیم. باید از برنامه‌نویسی تصادفی — تکیه بر شانس و موفقیت‌های اتفاقی — دوری کنیم و به نفع برنامه‌نویسی آگاهانه عمل کنیم.

چگونه تصادفی برنامه‌نویسی کنیم

فرض کنید فرد (Fred) تکلیف برنامه‌نویسی گرفته. کمی کد می‌نویسد، امتحان می‌کند، به نظر کار می‌کند. باز کمی می‌نویسد، امتحان می‌کند، باز کار می‌کند. بعد از چند هفته کدنویسی این‌طور، ناگهان برنامه کار نمی‌کند و بعد از ساعت‌ها تلاش برای رفع، هنوز نمی‌داند چرا. فرد ممکن است وقت زیادی صرف تعقیب همان قطعه کد کند بدون اینکه بتواند درستش کند. هر کاری کند، درست به نظر نمی‌رسد.

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

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

تصادف‌های پیاده‌سازی

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

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

java
paint(g);
invalidate();
validate();
revalidate();
repaint();
paintImmediately(r);

اینجا به نظر می‌رسد فرد ناامیدانه می‌خواهد چیزی روی صفحه بیاید. اما این روتین‌ها هرگز برای این‌طور فراخوانی طراحی نشده‌اند؛ اگرچه به نظر کار می‌کنند، واقعاً فقط تصادف است.

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

آسان است فریب این خط فکر را خورد. چرا باید چیزی که کار می‌کند را دست بزنید؟ چند دلیل:

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

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

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

تصادف‌های زمینه

«تصادف‌های زمینه» هم دارید. فرض کنید ماژول کمکی می‌نویسید. فقط چون الان برای محیط GUI کد می‌زنید، آیا ماژول باید به وجود GUI تکیه کند؟ به کاربران انگلیسی‌زبان؟ باسواد؟ چه چیز دیگری را تکیه می‌کنید که تضمین نشده؟

فرضیات ضمنی

تصادف‌ها در همه سطوح — از تولید نیازمندی تا آزمایش — گمراه می‌کنند. آزمایش به‌ویژه پر از علیت‌های غلط و نتایج تصادفی است. آسان است فرض کنید X باعث Y می‌شود، اما همان‌طور که در اشکال‌زدایی (صفحه ۹۰) گفتیم: فرض نکنید، ثابت کنید.

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

TIP 44

تصادفی برنامه‌نویسی نکنید

چگونه آگاهانه برنامه‌نویسی کنیم

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

  • همیشه بدانید چه می‌کنید. فرد گذاشت همه‌چیز آرام آرام از کنترل خارج شود تا مثل قورباغه در سوپ سنگ و قورباغه‌های جوشان (صفحه ۷) پخته شد.
  • با چشم بسته کد نزنید. ساخت برنامه‌ای که کامل نمی‌فهمید یا استفاده از فناوری ناآشنا، دعوتنامه گمراه شدن توسط تصادف است.
  • از برنامه‌ای پیش بروید — چه در ذهنتان، چه پشت دستمال cocktail، چه روی پرینت CASE به اندازه دیوار.
  • فقط به چیزهای قابل اعتماد تکیه کنید. به تصادف یا فرض تکیه نکنید. اگر در شرایط خاص نمی‌توانید تفاوت را بگویید، بدترین را فرض کنید.
  • فرضیاتتان را مستند کنید. طراحی قراردادمحور (صفحه ۱۰۹) هم به روشن شدن فرضیات در ذهن شما و هم به انتقال آن‌ها به دیگران کمک می‌کند.
  • فقط کد را آزمایش نکنید — فرضیات را هم. حدس نزنید؛ واقعاً امتحان کنید. assertion بنویسید تا فرضیات را بیازمایید (برنامه‌نویسی قاطع، صفحه ۱۲۲). اگر assertion درست بود، مستندسازی کد بهتر شد. اگر فرضیات غلط بود، خوش‌شانسید.
  • تلاشتان را اولویت‌بندی کنید. روی بخش‌های مهم — که احتمالاً سخت‌اند — وقت بگذارید. اگر مبانی یا زیرساخت درست نباشد، زنگوله و سوت‌های درخشان بی‌ربطند.
  • برده تاریخ نباشید. بگذارید کد موجود آینده را دیکته نکند. همه کد اگر دیگر مناسب نباشد قابل جایگزینی است. حتی در یک برنامه، نگذارید آنچه تا الان نوشتید کاری که بعد می‌کنید را محدود کند — آماده بازسازی باشید (بازسازی، صفحه ۱۸۴). این تصمیم ممکن است برنامه پروژه را تحت تأثیر قرار دهد؛ فرض این است که تأثیر کمتر از هزینه نکردن تغییر است.¹

پس دفعه بعد که چیزی به نظر کار کرد اما نمی‌دانید چرا، مطمئن شوید فقط تصادف نیست.

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

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

تمرین‌ها

۳۱. آیا می‌توانید در قطعه C زیر تصادف‌هایی پیدا کنید؟ فرض کنید این کد عمیقاً در یک روتین کتابخانه‌ای دفن شده.

c
fprintf(stderr,"Error, continue?");
gets(buf);

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

۳۲. این قطعه C ممکن است گاهی روی بعضی ماشین‌ها کار کند. باز هم ممکن است نکند. مشکل چیست؟

c
/* Truncate string to its last maxlen chars */
void string_tail(char *string, int maxlen) {
  int len = strlen(string);
  if (len > maxlen) {
    strcpy(string, string + (len - maxlen));
  }
}

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

۳۳. این کد از یک مجموعه ردیابی عمومی Java است. تابع رشته‌ای را در فایل log می‌نویسد. آزمایش واحدش را می‌گذراند، اما وقتی یکی از توسعه‌دهندگان وب از آن استفاده می‌کند، می‌افتد. به چه تصادفی تکیه دارد؟

java
public static void debug(String s) throws IOException {
  FileWriter fw = new FileWriter("debug.log", true);
  fw.write(s);
  fw.flush();
  fw.close();
}

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

یادداشت ۱: اینجا هم می‌توان زیاده‌روی کرد. یک‌بار توسعه‌دهنده‌ای را می‌شناختیم که همه سورسی که می‌گرفت بازنویسی می‌کرد چون قرارداد نام‌گذاری خودش را داشت.


۳۲ — سرعت الگوریتم

در تخمین‌زنی (صفحه ۶۴) درباره تخمین چیزهایی مثل زمان پیاده‌روی در شهر یا مدت پروژه صحبت کردیم. اما نوع دیگری از تخمین هست که برنامه‌نویسان عمل‌گرا تقریباً روزانه استفاده می‌کنند: تخمین منابعی که الگوریتم‌ها مصرف می‌کنند — زمان، پردازنده، حافظه و غیره.

این نوع تخمین اغلب حیاتی است. بین دو روش، کدام را انتخاب می‌کنید؟ می‌دانید برنامه با ۱٬۰۰۰ رکورد چقدر طول می‌کشد، اما با ۱٬۰۰۰٬۰۰۰ چطور مقیاس می‌گیرد؟ کدام بخش‌ها نیاز به بهینه‌سازی دارند؟

این سؤالات اغلب با عقل سلیم، کمی تحلیل و روشی برای نوشتن تقریب به نام «big O» پاسخ داده می‌شوند.

منظورمان از تخمین الگوریتم چیست؟

بیشتر الگوریتم‌های غیرساده ورودی متغیر دارند — مرتب‌سازی رشته‌ها، معکوس کردن ماتریس (n \times n)، یا رمزگشایی پیام با کلید (k)-بیتی. معمولاً اندازه ورودی بر الگوریتم اثر می‌گذارد: ورودی بزرگ‌تر، زمان اجرا یا حافظه بیشتر.

اگر رابطه همیشه خطی بود (زمان متناسب با (n) بالا می‌رفت)، این بخش مهم نبود. اما بیشتر الگوریتم‌های مهم خطی نیستند. خبر خوب: بسیاری زیرخطی‌اند. مثلاً جست‌وجوی دودویی لازم نیست همه کاندیدها را ببیند. خبر بد: بعضی الگوریتم‌ها خیلی بدتر از خطی‌اند؛ زمان یا حافظه خیلی سریع‌تر از (n) بالا می‌رود. الگوریتمی که ده مورد را در یک دقیقه پردازش کند شاید برای ۱۰۰ مورد یک عمر طول بکشد.

هر وقت چیزی با حلقه یا فراخوانی بازگشتی می‌نویسیم، ناخودآگاه زمان و حافظه را بررسی می‌کنیم. این معمولاً رسمی نیست، بلکه تأیید سریع است که در این شرایط منطقی است. گاهی تحلیل دقیق‌تر لازم می‌شود — آنجا نماد (O(\cdot)) به کار می‌آید.

نماد (O(\cdot))

نماد (O(\cdot)) روش ریاضی برای تقریب است. وقتی می‌نویسیم یک مرتب‌ساز رکوردها را در زمان (O(n^2)) مرتب می‌کند، یعنی بدترین حالت تقریباً با مربع (n) متغیر است. تعداد رکورد را دو برابر کنید، زمان تقریباً چهار برابر. (O) را «در حد» (on the order of) بگیرید. (O(\cdot)) سقف بالایی روی مقدار چیزی که می‌سنجیم (زمان، حافظه و غیره) می‌گذارد. اگر بگوییم تابع در زمان (O(f(n))) اجرا می‌شود، می‌دانیم زمان از (f(n)) سریع‌تر رشد نمی‌کند. گاهی تابع پیچیده می‌آید، اما چون با بزرگ شدن (n) جمله با بیشترین درجه غالب می‌شود، قرارداد این است که جملات کم‌درجه و ضرایب ثابت حذف شوند. (3n^2) همان (n^2) است که معادل (n^2) است. این ضعف (O(\cdot)) است — یک الگوریتم (O(n^2)) ممکن است ۱٬۰۰۰ برابر سریع‌تر از دیگری باشد، اما از نماد نمی‌فهمید.

شکل ۶.۱ چند نماد (O(\cdot)) رایج را با نمودار مقایسه زمان اجرا نشان می‌دهد. واضح است که وقتی از (O(n \log n)) بالاتر برویم، اوضاع سریع از کنترل خارج می‌شود.

مثلاً روتینی دارید که ۱۰۰ رکورد را در ۱ ثانیه پردازش می‌کند. ۱٬۰۰۰ رکورد چقدر طول می‌کشد؟ اگر کد (O(1)) باشد، باز ۱ ثانیه. اگر (O(\log n)) باشد، شاید حدود ۳ ثانیه. (O(n)) افزایش خطی تا ۱۰ ثانیه. (O(n \log n)) حدود ۳۳ ثانیه. اگر بدشانس باشید و (O(n^2)) داشته باشید، ۱۰۰ ثانیه بنشینید. اگر الگوریتم نمایی (O(2^n)) دارید، قهوه درست کنید — روتین شاید حدود (10^{13}) سال طول بکشد. بگویید جهان چطور تمام می‌شود.

(O(\cdot)) فقط برای زمان نیست؛ برای هر منبعی که الگوریتم مصرف می‌کند به کار می‌رود. مثلاً مدل‌سازی مصرف حافظه اغلب مفید است (تمرین ۳۵ در صفحه ۱۸۳).

شکل ۶.۱ — زمان اجرای الگوریتم‌های مختلف

: traveling salesman                    : heapsort
          : selection sort
                              : sequential search
                                        : binary search
                                    : array access

برخی نمادهای رایج (O(\cdot)):

نمادمعنی
(O(1))ثابت (دسترسی به عنصر آرایه، دستورات ساده)
(O(\log n))لگاریتمی (جست‌وجوی دودویی) [(O(\log_2 n)) مخفف (O(\lg n)) است]
(O(n))خطی (جست‌وجوی ترتیبی)
(O(n \log n))بدتر از خطی اما نه خیلی (میانگین quicksort، heapsort)
(O(n^2))قانون مربع (selection sort و insertion sort)
(O(n^3))مکعبی (ضرب دو ماتریس (n \times n))
(O(2^n))نمایی (مسئله فروشنده دوره‌گرد، پارتیشن‌بندی مجموعه)

تخمین با عقل سلیم

با عقل سلیم می‌توان درجه بسیاری از الگوریتم‌های پایه را تخمین زد.

  • حلقه‌های ساده. اگر حلقه از ۰ تا (n-1) برود، الگوریتم احتمالاً (O(n)) است — زمان خطی با (n) بالا می‌رود. مثال: جست‌وجوی کامل، بیشینه آرایه، تولید checksum.
  • حلقه‌های تو در تو. اگر حلقه داخل حلقه باشد، الگوریتم (O(n \times m)) می‌شود که (n) و (m) حد حلقه‌هاست. در مرتب‌سازی‌های ساده مثل bubble sort رایج است: حلقه بیرونی هر عنصر را اسکن می‌کند و حلقه داخلی جای آن را در نتیجه مرتب پیدا می‌کند. چنین مرتب‌سازی‌هایی معمولاً (O(n^2))‌اند.
  • نیمه‌کردن (binary chop). اگر هر دور حلقه مجموعه را نصف کند، احتمالاً لگاریتمی (O(\log n)) است (تمرین ۳۷، صفحه ۱۸۳). جست‌وجوی دودویی در فهرست مرتب، پیمایش درخت دودویی، پیدا کردن اولین بیت ۱ در کلمه ماشین.
  • تقسیم و غلبه (divide and conquer). الگوریتم‌هایی که ورودی را پارتیشن می‌کنند، روی دو نیمه مستقل کار می‌کنند و نتیجه را ترکیب می‌کنند می‌توانند (O(n \log n)) باشند. مثال کلاسیک quicksort است که داده را به دو نیمه تقسیم و هر نیمه را بازگشتی مرتب می‌کند. از نظر فنی در ورودی مرتب‌شده به (O(n^2)) تخریب می‌شود، اما میانگین زمان (O(n \log n)) است.
  • ترکیبی (combinatoric). وقتی الگوریتم‌ها جایگشت‌ها را می‌بینند، زمان از کنترل خارج می‌شود چون جایگشت‌ها فاکتوریال دارند ((n!) جایگشت برای ارقام ۱ تا (n)). الگوریتم ترکیبی را برای پنج عنصر زمان بگیرید: برای شش شش برابر، برای هفت ۴۲ برابر طولانی‌تر. مثال: مسئله فروشنده دوره‌گرد، بسته‌بندی بهینه، پارتیشن اعداد با جمع یکسان و غیره. اغلب heuristics برای کاهش زمان در حوزه‌های خاص به کار می‌رود.

سرعت الگوریتم در عمل

بعید است زیاد وقت صرف نوشتن روتین مرتب‌سازی کنید — آن‌هایی در کتابخانه‌ها احتمالاً بدون تلاش زیاد از هر چیزی که بنویسید بهترند. اما انواع الگوریتم‌هایی که گفتیم بارها تکرار می‌شوند. هر وقت حلقه ساده می‌نویسید، (O(n)) دارید. اگر حلقه داخلی باشد، (O(n \times m)). بپرسید این مقادیر چقدر بزرگ می‌شوند. اگر محدودند، می‌دانید کد چقدر طول می‌کشد. اگر به عوامل بیرونی (تعداد رکورد batch شبانه، تعداد نام در فهرست) بستگی دارد، شاید لازم باشد اثر مقادیر بزرگ را روی زمان یا حافظه در نظر بگیرید.

TIP 45

درجه الگوریتم‌هایتان را تخمین بزنید

برای مشکلات احتمالی:

  • اگر الگوریتم (O(n^2)) دارید، سعی کنید تقسیم و غلبه پیدا کنید تا به (O(n \log n)) برسید.
  • اگر مطمئن نیستید کد چقدر طول می‌کشد یا چقدر حافظه می‌خورد، با تغییر تعداد رکورد یا هر چیزی که زمان را تحت تأثیر قرار می‌دهد اجرا کنید و نتایج را رسم کنید. زود شکل منحنی را می‌فهمید — رو به بالا خم می‌شود، خط مستقیم است یا با بزرگ شدن ورودی مسطح می‌شود؟ سه یا چهار نقطه کافی است.

در خود کد هم فکر کنید. حلقه (O(n^2)) ساده ممکن است برای (n) کوچک از حلقه پیچیده (O(n \log n)) با حلقه داخلی گران بهتر باشد.

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

TIP 46

تخمین‌هایتان را آزمایش کنید

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

بهترین همیشه بهترین نیست

در انتخاب الگوریتم مناسب هم عمل‌گرا باشید — سریع‌ترین همیشه بهترین برای کار نیست. برای ورودی کوچک، insertion sort ساده به‌خوبی quicksort کار می‌کند و کمتر وقت می‌برد برای نوشتن و دیباگ. اگر الگوریتم هزینه راه‌اندازی بالا دارد، برای ورودی کوچک این هزینه از زمان اجرا بزرگ‌تر می‌شود.

از بهینه‌سازی زودهنگام هم بترسید. قبل از سرمایه‌گذاری وقت، مطمئن شوید الگوریتم واقعاً گلوگاه است.

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

  • تخمین‌زنی، صفحه ۶۴

چالش‌ها

  • هر توسعه‌دهنده باید حس طراحی و تحلیل الگوریتم داشته باشد. رابرت سجوick کتاب‌های در دسترس زیادی نوشته ([Sed83, SF96, Sed92] و دیگران). یکی را به مجموعه‌تان اضافه و بخوانید.
  • اگر جزئیات بیشتر از سجوick می‌خواهید، کتاب‌های Art of Computer Programming دونالد کنوث را بخوانید ([Knu97a, Knu97b, Knu98]).
  • در تمرین ۳۴، مرتب‌سازی آرایه اعداد صحیح بلند را می‌بینیم. اگر کلیدها پیچیده‌تر و هزینه مقایسه بالا باشد چه؟ ساختار کلید بر کارایی مرتب‌سازی اثر دارد یا سریع‌ترین همیشه سریع‌ترین است؟

تمرین‌ها

۳۴. مجموعه‌ای از روتین‌های مرتب‌سازی ساده کد زده‌ایم که از وب‌سایت ما قابل دانلود است (www.pragmaticprogrammer.com). روی ماشین‌های در دسترس اجرا کنید. آیا نمودارها منحنی‌های مورد انتظار را دنبال می‌کنند؟ درباره سرعت نسبی ماشین‌ها چه می‌فهمید؟ اثر تنظیمات بهینه‌سازی کامپایل چیست؟ آیا radix sort واقعاً خطی است؟

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

۳۵. روتین زیر محتوای درخت دودویی را چاپ می‌کند. فرض کنید درخت متعادل است؛ تقریباً چقدر فضای پشته هنگام چاپ درخت ۱٬۰۰۰٬۰۰۰ عنصری مصرف می‌شود؟ (فرض کنید فراخوانی زیربرنامه سربار پشته قابل توجهی ندارد.)

c
void printTree(const Node *node) {
  char buffer[1000];
  if (node) {
    printTree(node->left);
    getNodeAsString(node, buffer);
    puts(buffer);
    printTree(node->right);
  }
}

پاسخ در صفحه ۳۰۰

۳۶. آیا راهی برای کاهش نیاز پشته روتین تمرین ۳۵ می‌بینید (جز کوچک کردن buffer)؟

پاسخ در صفحه ۳۰۰

۳۷. در صفحه ۱۸۰ گفتیم binary chop (O(\log n)) است. می‌توانید ثابت کنید؟

پاسخ در صفحه ۳۰۱

یادداشت ۲: در واقع، هنگام آزمایش الگوریتم‌های مرتب‌سازی این بخش روی Pentium 64MB، نویسندگان با radix sort بیش از هفت میلیون عدد حافظه واقعی تمام کردند. مرتب‌سازی swap space استفاده کرد و زمان‌ها شدیداً بد شد.


۳۳ — بازسازی (Refactoring)

همه‌جا تغییر و فساد می‌بینم... — اچ. اف. لایت، «Abide With Me»

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

متأسفانه رایج‌ترین استعاره توسعه نرم‌افزار، ساخت‌وساز است (برتراند میر [Mey97b] از «Software Construction» استفاده می‌کند). اما ساخت‌وساز به‌عنوان استعاره راهنما این گام‌ها را القا می‌کند:

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

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

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

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

بازنویسی، بازکار و بازمعماری کد را جمعاً بازسازی می‌نامند.

چه وقت بازسازی کنیم؟

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

  • تکرار. نقض اصل DRY (شرارت تکرار، صفحه ۲۶).
  • طراحی غیرارتگونال. کد یا طراحی که می‌توان ارتگونال‌تر کرد (ارتگونالیتی، صفحه ۳۴).
  • دانش کهنه. چیزها عوض می‌شوند، نیازمندی‌ها جابه‌جا می‌شوند و فهم مسئله عمیق‌تر می‌شود. کد باید همگام بماند.
  • کارایی. برای بهبود عملکرد باید قابلیت از یک بخش سیستم به بخش دیگر منتقل شود.

بازسازی — جابه‌جایی قابلیت و به‌روزرسانی تصمیم‌های قبلی — در واقع تمرین مدیریت درد است. بیایید صادق باشیم: تغییر سورس دردناک است؛ تقریباً کار می‌کرد و حالا پاره شده. بسیاری از توسعه‌دهندگان از پاره کردن کد فقط چون «کاملاً درست نیست» امتناع می‌کنند.

پیچیدگی‌های دنیای واقعی

به رئیس یا مشتری می‌گویید: «این کد کار می‌کند، اما یک هفته دیگر برای بازسازی لازم دارم.»

جوابشان را نمی‌توانیم چاپ کنیم.

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

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

TIP 47

زود بازسازی کنید، مکرر بازسازی کنید

چیزهایی که باید بازسازی شوند را پیگیری کنید. اگر فوراً نمی‌توانید، در برنامه قرار دهید. به کاربران کد تحت تأثیر بگویید که بازسازی برنامه‌ریزی شده و ممکن است چه اثری داشته باشد.

چگونه بازسازی کنیم؟

بازسازی از جامعه Smalltalk شروع شد و با روندهای دیگر (مثل الگوهای طراحی) مخاطب گسترده‌تری پیدا کرد. اما موضوع هنوز نسبتاً جدید است؛ چیز زیادی منتشر نشده. اولین کتاب بزرگ بازسازی ([FBB+99] و هم [URL 47]) تقریباً هم‌زمان با این کتاب منتشر شد.

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

بازسازی باید آهسته، آگاهانه و با دقت انجام شود. مارتین فاولر نکات ساده زیر را برای بازسازی بدون آسیب بیشتر پیشنهاد می‌کند (جعبه صفحه ۳۰ در [FS97]):

  1. هم‌زمان بازسازی و افزودن قابلیت نکنید.
  2. قبل از شروع آزمایش خوب داشته باشید. آزمایش‌ها را مکرر اجرا کنید تا سریع بفهمید تغییرات چیزی را شکسته‌اند.

بازسازی خودکار

تاریخاً کاربران Smalltalk همیشه class browser در IDE داشتند — نه مرورگر وب؛ برای پیمایش و بررسی سلسله‌مراتب کلاس و متدها. معمولاً class browser اجازه ویرایش کد، ساخت متد و کلاس جدید و غیره می‌دهد. گام بعدی refactoring browser است. refactoring browser می‌تواند عملیات رایج بازسازی را نیمه‌خودکار انجام دهد: تقسیم روتین بلند، انتشار خودکار تغییر نام متد و متغیر، drag and drop برای جابه‌جایی کد و غیره. هنگام نوشتن این کتاب، این فناوری هنوز از دنیای Smalltalk بیرون نیامده، اما احتمالاً به سرعت تغییر Java — یعنی سریع — عوض می‌شود. تا آن موقع، refactoring browser پیشگام Smalltalk در [URL 20] است.

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

بیشتر درباره آزمایش در این سطح در کدی که آزمایش آن آسان است (صفحه ۱۸۹) و آزمایش بزرگ‌تر در آزمایش بی‌رحمانه (صفحه ۲۳۷) صحبت می‌کنیم، اما نکته فاولر درباره نگه داشتن آزمایش‌های رگرسیون خوب، کلید بازسازی با اطمینان است.

همچنین مفید است تغییرات شدید ماژول — مثل تغییر رابط یا قابلیت به‌صورت ناسازگار — build را بشکند؛ یعنی کلاینت‌های قدیمی compile نشوند. سپس سریع کلاینت‌های قدیمی را پیدا و به‌روز کنید.

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

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

  • گربه سورس‌کد مرا خورد، صفحه ۲
  • آنتروپی نرم‌افزار، صفحه ۴
  • سوپ سنگ و قورباغه‌های جوشان، صفحه ۷
  • شرارت تکرار، صفحه ۲۶
  • ارتگونالیتی، صفحه ۳۴
  • برنامه‌نویسی تصادفی، صفحه ۱۷۲
  • کدی که آزمایش آن آسان است، صفحه ۱۸۹
  • آزمایش بی‌رحمانه، صفحه ۲۳۷

تمرین‌ها

۳۸. کد زیر واضحاً چند بار به‌روز شده اما ساختار بهتر نشده. آن را بازسازی کنید.

c
if (state == TEXAS) {
  rate = TX_RATE;
  amt = base * TX_RATE;
  calc = 2*basis(amt) + extra(amt)*1.05;
}
else if ((state == OHIO) || (state == MAINE)) {
  rate = (state == OHIO) ? OH_RATE : ME_RATE;
  amt = base * rate;
  calc = 2*basis(amt) + extra(amt)*1.05;
  if (state == OHIO)
    points = 2;
}
else {
  rate = 1;
  amt = base;
  calc = 2*basis(amt) + extra(amt)*1.05;
}

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

۳۹. کلاس Java زیر باید چند شکل دیگر را پشتیبانی کند. کلاس را برای افزودن‌ها بازسازی کنید.

java
public class Shape {
  public static final int SQUARE   = 1;
  public static final int CIRCLE   = 2;
  public static final int RIGHT_TRIANGLE = 3;
  private int    shapeType;
  private double size;
  public Shape(int shapeType, double size) {
    this.shapeType = shapeType;
    this.size      = size;
  }
  // ... other methods ...
  public double area() {
    switch (shapeType) {
    case SQUARE:   return size*size;
    case CIRCLE:   return Math.PI*size*size/4.0;
    case RIGHT_TRIANGLE: return size*size/2.0;
    }
    return 0;
  }
}

پاسخ در صفحه ۳۰۳

۴۰. این کد Java بخشی از چارچوبی است که در کل پروژه استفاده می‌شود. آن را عمومی‌تر و برای گسترش آینده آسان‌تر بازسازی کنید.

java
public class Window {
  public Window(int width, int height) { ... }
  public void setSize(int width, int height) { ... }
  public boolean overlaps(Window w) { ... }
  public int getArea() { ... }
}

پاسخ در صفحه ۳۰۳


۳۴ — کدی که آزمایش آن آسان است

IC نرم‌افزاری (Software IC) استعاره‌ای است که برای بحث قابلیت استفاده مجدد و توسعه مبتنی بر مؤلفه به کار می‌رود.³ ایده این است که مؤلفه‌های نرم‌افزاری مثل تراشه‌های مدار مجتمع ترکیب شوند. این فقط وقتی کار می‌کند که مؤلفه‌هایی که استفاده می‌کنید قابل اعتماد باشند.

تراشه‌ها برای آزمایش طراحی می‌شوند — نه فقط در کارخانه، نه فقط هنگام نصب، بلکه در میدان پس از استقرار. تراشه‌ها و سیستم‌های پیچیده‌تر ممکن است Built-In Self Test (BIST) داشته باشند که تشخیص پایه را داخلی اجرا می‌کند، یا Test Access Mechanism (TAM) که harness آزمایشی برای محیط بیرونی فراهم می‌کند.

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

آزمایش واحد (Unit Testing)

آزمایش در سطح تراشه برای سخت‌افزار تقریباً معادل آزمایش واحد در نرم‌افزار است — آزمایش هر ماژول به‌تنهایی برای تأیید رفتار. وقتی ماژول را در شرایط کنترل‌شده (حتی ساختگی) کامل آزمایش کردیم، بهتر می‌فهمیم در دنیای واقعی چطور واکنش نشان می‌دهد.

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

بعداً وقتی «ICهای نرم‌افزاری» را در سیستم کامل سرهم می‌کنیم، اطمینان داریم قطعات جدا کار می‌کنند و همان امکانات آزمایش واحد را برای آزمایش کل سیستم به کار می‌بریم. این بررسی بزرگ‌مقیاس را در آزمایش بی‌رحمانه (صفحه ۲۳۷) بحث می‌کنیم.

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

آزمایش در برابر قرارداد

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

در عمل یعنی چه؟ روتین جذر مربع را که اول در صفحه ۱۱۴ دیدیم در نظر بگیرید. قراردادش ساده است:

require
  argument >= 0;
ensure
  ((Result * Result) - argument).abs <= epsilon*argument;

این می‌گوید چه آزمایش کنیم:

  • آرگومان منفی بدهید و مطمئن شوید رد می‌شود.
  • آرگومان صفر بدهید و مطمئن شوید پذیرفته می‌شود (مقدار مرزی).
  • مقادیر بین صفر و بیشینه قابل نمایش بدهید و تأیید کنید تفاوت مربع نتیجه و آرگومان اصلی کمتر از کسری کوچک آرگومان است.

با این قرارداد و فرض اینکه روتین خودش پیش‌ و پس‌شرط را بررسی می‌کند، می‌توانید اسکریپت آزمایش پایه برای تابع جذر مربع بنویسید:

java
public void testValue(double num, double expected) {
  double result = 0.0;
  try {                      // We may throw a
    result = mySqrt(num);    // precondition exception
  }
  catch (Throwable e) {
    if (num < 0.0)           //   If input is < 0, then
      return;                //   we're expecting the
    else                     //   exception, otherwise
      assert(false);         //   force a test failure
  }
  assert(Math.abs(expected-result) < epsilon*expected);
}

سپس برای آزمایش تابع جذر مربع:

java
testValue(-4.0, 0.0);
testValue( 0.0, 0.0);
testValue( 2.0, 1.4142135624);
testValue(64.0, 8.0);
testValue(1.0e7, 3162.2776602);

این آزمایش ساده است؛ در دنیای واقعی ماژول غیرساده به ماژول‌های دیگر وابسته است. چطور ترکیب را آزمایش کنیم؟

فرض کنید ماژول A از LinkedList و Sort استفاده می‌کند. به ترتیب آزمایش می‌کنیم:

  1. قرارداد LinkedList، کامل
  2. قرارداد Sort، کامل
  3. قرارداد A که به قراردادهای دیگر تکیه دارد اما مستقیماً آن‌ها را expose نمی‌کند

این سبک آزمایش می‌طلبد زیرمؤلفه‌های ماژول را اول آزمایش کنید. وقتی زیرمؤلفه‌ها تأیید شدند، خود ماژول.

اگر آزمایش‌های LinkedList و Sort گذشتند اما A افتاد، تقریباً مطمئنیم مشکل در A یا استفاده A از زیرمؤلفه‌هاست. این تکنیک زمان دیباگ را کم می‌کند: سریع روی منبع محتمل در A تمرکز می‌کنید، نه بازبینی زیرمؤلفه‌ها.

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

TIP 48

برای آزمایش طراحی کنید

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

نوشتن آزمایش‌های واحد

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

با در دسترس بودن کد آزمایش، دو منبع ارزشمند به توسعه‌دهندگان می‌دهید:

  1. مثال استفاده از همه قابلیت‌های ماژول
  2. ابزار ساخت آزمایش رگرسیون برای تأیید تغییرات آینده

راحت است اما همیشه عملی نیست که هر کلاس یا ماژول آزمایش واحد خودش را داشته باشد. در Java مثلاً هر کلاس می‌تواند main داشته باشد. در همه فایل‌ها به‌جز کلاس اصلی برنامه، main برای آزمایش واحد به کار می‌رود و هنگام اجرای برنامه نادیده گرفته می‌شود. مزیت: کدی که تحویل می‌دهید هنوز آزمایش‌ها را دارد و برای تشخیص مشکل در میدان به کار می‌آید.

در C++ با #ifdef می‌توانید کد آزمایش واحد را انتخابی compile کنید. مثال ساده در C++، در ماژول، که تابع جذر مربع را با testValue مشابه Java بالا آزمایش می‌کند:

c
#ifdef __TEST__
int main(int argc, char **argv)
{
  argc--; argv++;       // skip program name
  if (argc < 2) {       // do standard tests if no args
    testValue(-4.0, 0.0);
    testValue( 0.0, 0.0);
    testValue( 2.0, 1.4142135624);
    testValue(64.0, 8.0);
    testValue(1.0e7, 3162.2776602);
  }
  else {                // else use args
    double num, expected;
    while (argc >= 2) {
      num = atof(argv[0]);
      expected = atof(argv[1]);
      testValue(num,expected);
      argc -= 2;
      argv += 2;
    }
  }
  return 0;
}
#endif

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

اگر پاسخ درست آزمایش واحد خروج یا abort برنامه باشد چه؟ باید آزمایش را انتخاب کنید — شاید با آرگومان خط فرمان. اگر شرایط شروع متفاوت لازم است، پارامتر بدهید.

اما فراهم کردن آزمایش واحد کافی نیست. باید اجرا کنید — و مکرر. کمک می‌کند اگر کلاس گاهی آزمایش‌ها را بگذراند.

استفاده از Test Harness

چون معمولاً کد آزمایش زیاد می‌نویسیم و زیاد آزمایش می‌کنیم، برای خودمان harness استاندارد پروژه می‌سازیم. main بخش قبل harness بسیار ساده است؛ معمولاً قابلیت بیشتری لازم داریم.

harness عملیات رایج مثل log وضعیت، تحلیل خروجی برای نتایج مورد انتظار و انتخاب و اجرای آزمایش‌ها را انجام می‌دهد. harness ممکن است GUI داشته باشد، به همان زبان پروژه نوشته شود یا ترکیبی از makefile و Perl باشد. harness ساده در پاسخ تمرین ۴۱ (صفحه ۳۰۵) است.

در زبان‌ها و محیط‌های شیءگرا می‌توانید کلاس پایه با این عملیات مشترک بسازید. آزمایش‌ها از آن ارث می‌برند و کد خاص اضافه می‌کنند. با قرارداد نام‌گذاری استاندارد و reflection در Java می‌توانید فهرست آزمایش‌ها را پویا بسازید — رعایت DRY؛ فهرست آزمایش‌ها را نگه نمی‌دارید. اما قبل از نوشتن harness خودتان، xUnit Kent Beck و Erich Gamma در [URL 22] را ببینید. کتاب Pragmatic Unit Testing [HT03] برای معرفی JUnit.

هر فناوری که انتخاب کنید، harness باید این قابلیت‌ها را داشته باشد:

  • روش استاندارد setup و cleanup
  • انتخاب آزمایش منفرد یا همه
  • تحلیل خروجی برای نتایج مورد انتظار (یا غیرمنتظره)
  • گزارش شکست استاندارد

آزمایش‌ها باید ترکیب‌پذیر باشند — آزمایش می‌تواند از زیرآزمایش زیرمؤلفه‌ها به هر عمقی ساخته شود. با این قابلیت بخش‌های انتخابی یا کل سیستم را با همان ابزار آزمایش می‌کنیم.

آزمایش موردی (Ad Hoc Testing)

هنگام دیباگ ممکن است آزمایش‌های موقتی بسازید — از print ساده تا کد در debugger یا IDE. در پایان دیباگ، آزمایش موردی را رسمی کنید. اگر کد یک‌بار شکست خورد، دوباره می‌شکند. آزمایش را دور نیندازید؛ به آزمایش واحد موجود اضافه کنید.

مثلاً با JUnit (عضو خانواده xUnit در Java)، آزمایش جذر مربع:

java
public class JUnitExample extends TestCase {
  public JUnitExample(final String name) {
    super(name);
  }
  protected void setUp() {
    // Load up test data...
    testData.addElement(new DblPair(-4.0,0.0));
    testData.addElement(new DblPair(0.0,0.0));
    testData.addElement(new DblPair(64.0,8.0));
    testData.addElement(new DblPair(Double.MAX_VALUE,
                                    1.3407807929942597E154));
  }
  public void testMySqrt() {
    double num, expected, result = 0.0;
    Enumeration enum = testData.elements();
    while (enum.hasMoreElements()) {
      DblPair p = (DblPair)enum.nextElement();
      num       = p.getNum();
      expected = p.getExpected();
      testValue(num, expected);
    }
  }
  public static Test suite() {
    TestSuite suite= new TestSuite();
    suite.addTest(new JUnitExample("testMySqrt"));
    return suite;
  }
}

JUnit ترکیب‌پذیر است: هر تعداد آزمایش به suite اضافه کنید و هر کدام می‌تواند suite باشد. رابط گرافیکی یا batch برای اجرا دارید.

پنجره آزمایش بسازید

بهترین مجموعه آزمایش هم همه باگ‌ها را پیدا نمی‌کند — چیزی در رطوبت و گرمای production باگ‌ها را بیرون می‌آورد.

پس گاهی باید نرم‌افزار را پس از استقرار — با داده واقعی — آزمایش کنید. برخلاف برد مدار، در نرم‌افزار pin آزمایش نداریم، اما می‌توانیم بدون debugger (که در production ناخوشایند یا غیرممکن است) به وضعیت داخلی ماژول نگاه کنیم.

فایل‌های log با پیام trace یکی از این مکانیزم‌هاست. پیام‌ها باید قالب منظم و یکسان داشته باشند؛ شاید بخواهید خودکار parse کنید تا زمان پردازش یا مسیر منطقی را استنتاج کنید. تشخیص بدقالب یا ناهماهنگ فقط «spew» است — خواندن سخت و parse عملی نیست.

مکانیزم دیگر «ترکیب کلید میانبر» (hot-key) است. با فشردن آن، پنجره کنترل تشخیص با پیام وضعیت باز می‌شود. معمولاً به کاربر نهایی نشان نمی‌دهید، اما برای help desk بسیار مفید است.

برای کد سرور بزرگ و پیچیده، تکنیک خوب دید به عملیات، گنجاندن وب‌سرور داخلی است. هر کسی می‌تواند مرورگر را به پورت HTTP برنامه (معمولاً غیراستاندارد مثل 8080) بزند و وضعیت داخلی، log و شاید پنل دیباگ ببیند. پیاده‌سازی سخت به نظر می‌رسد اما نیست. وب‌سرورهای HTTP رایگان و قابل تعبیه در زبان‌های مدرن زیادند. شروع خوب [URL 58] است.

فرهنگ آزمایش

همه نرم‌افزاری که می‌نویسید آزمایش می‌شود — اگر نه توسط شما و تیم، توسط کاربران نهایی — پس بهتر است از قبل برنامه‌ریزی کنید. کمی پیش‌اندیشی هزینه نگهداری و تماس help desk را کم می‌کند.

با وجود شهرت hacker، جامعه Perl تعهد قوی به آزمایش واحد و رگرسیون دارد. نصب استاندارد ماژول Perl با

% make test

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

TIP 49

نرم‌افزارتان را آزمایش کنید، وگرنه کاربران شما خواهند کرد

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

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

تمرین‌ها

۴۱. برای رابط blender توصیف‌شده در پاسخ تمرین ۱۷ (صفحه ۲۸۹) یک test jig طراحی کنید. اسکریپت shell بنویسید که آزمایش رگرسیون blender را انجام دهد. باید عملکرد پایه، خطا و شرایط مرزی و تعهدات قراردادی را آزمایش کند. محدودیت تغییر سرعت چیست؟ رعایت می‌شوند؟

پاسخ در صفحه ۳۰۵

یادداشت ۳: اصطلاح «Software IC» (Integrated Circuit) به نظر می‌رسد ۱۹۸۶ توسط Cox و Novobilski در کتاب Objective-C آن‌ها Object-Oriented Programming [CN91] ابداع شد.


۳۵ — جادوگران شیطانی

انکار نمی‌شود — برنامه‌ها سخت‌تر و سخت‌تر نوشته می‌شوند. به‌ویژه رابط کاربری پیچیده‌تر شده. بیست سال پیش، برنامه متوسط رابط teletype شیشه‌ای داشت (اگر اصلاً داشت). ترمینال‌های ناهمگام معمولاً نمایش تعاملی کاراکتری می‌دادند و دستگاه‌های pollable (مثل IBM 3270) اجازه می‌دادند کل صفحه را پر کنید و بعد SEND بزنید. حالا کاربران GUI گرافیکی با راهنمای حساس به زمینه، cut and paste، drag and drop، یکپارچگی OLE و MDI یا SDI می‌خواهند. یکپارچگی مرورگر وب و پشتیبانی thin-client.

هم‌زمان برنامه‌ها پیچیده‌تر شده‌اند. بیشتر توسعه‌ها مدل چندلایه دارند، شاید با middleware یا transaction monitor. انتظار می‌رود پویا، انعطاف‌پذیر و با برنامه‌های شخص ثالث کار کنند.

آه، و گفتیم همه‌اش هفته آینده لازم است؟

توسعه‌دهندگان برای همگام ماندن تلاش می‌کنند. اگر همان ابزارهای ۲۰ سال پیش برای برنامه dumb-terminal استفاده می‌کردیم، هیچ‌وقت کاری تمام نمی‌شد.

پس سازندگان ابزار و فروشندگان زیرساخت جادوگر (wizard) را به‌عنوان گلوله جادویی آوردند. جادوگرها عالی‌اند. MDI با پشتیبانی OLE container لازم دارید؟ یک دکمه، چند سؤال ساده، جادوگر خودکار اسکلت کد می‌سازد. Microsoft Visual C++ برای این سناریو بیش از ۱٬۲۰۰ خط کد تولید می‌کند. جادوگرها در جاهای دیگر هم کار می‌کنند: ساخت component سرور، Java bean، رابط شبکه — همه حوزه‌های پیچیده که کمک متخصص خوب است.

اما استفاده از جادوگر طراحی‌شده توسط guru به‌خودی‌خود Joe developer را guru نمی‌کند. Joe خوب احساس می‌کند — انبوهی کد و برنامه خوش‌ظاهر ساخته. فقط قابلیت خاص برنامه را اضافه می‌کند و آماده تحویل است. مگر Joe واقعاً کدی که به جایش تولید شده را بفهمد — در غیر این صورت خودش را فریب می‌دهد. برنامه‌نویسی تصادفی می‌کند. جادوگر یک‌طرفه است — کد را می‌سازد و می‌رود. اگر کد درست نباشد یا شرایط عوض شود و باید سازگار شود، تنهاید.

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

TIP 50

از کد جادوگری که نمی‌فهمید استفاده نکنید

بعضی می‌گویند موضع افراطی است. می‌گویند توسعه‌دهندگان معمولاً به چیزهایی که کامل نمی‌فهمند تکیه می‌کنند — مکانیک کوانتومی IC، ساختار interrupt پردازنده، الگوریتم زمان‌بندی، کد کتابخانه‌ها و غیره. موافقیم. و اگر جادوگرها فقط مجموعه‌ای از فراخوانی کتابخانه یا سرویس OS استاندارد بودند که می‌توان به آن‌ها تکیه کرد، درباره جادوگر هم همین‌طور فکر می‌کردیم. اما نیستند. جادوگرها کدی تولید می‌کنند که جزء جدایی‌ناپذیر برنامه Joe می‌شود. کد جادوگر پشت رابط مرتب فاکتور نشده — خط به خط با قابلیتی که Joe می‌نویسد درهم است.⁴ بالاخره دیگر کد جادوگر نیست و کد Joe می‌شود. و هیچ‌کس نباید کدی تولید کند که کامل نمی‌فهمد.

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

  • ارتگونالیتی، صفحه ۳۴
  • مولدهای کد، صفحه ۱۰۲

چالش‌ها

  • اگر wizard ساخت GUI دارید، با آن برنامه اسکلت بسازید. هر خط کدی که تولید می‌کند را ببینید. همه را می‌فهمید؟ می‌توانستید خودتان بنویسید؟ واقعاً می‌نوشتید، یا کارهایی می‌کند که لازم ندارید؟

یادداشت ۴: تکنیک‌های دیگری برای مدیریت پیچیدگی هست. دو مورد — beans و AOP — را در ارتگونالیتی (صفحه ۳۴) بحث می‌کنیم.