فصل هفتم - در حین کدنویسی
خرد متعارف میگوید که وقتی یک پروژه در مرحله کدنویسی قرار دارد، کار عمدتاً مکانیکی است و به معنای تبدیل طراحی به دستورات قابل اجراست. ما فکر میکنیم این نگرش، بزرگترین دلیل شکست پروژههای نرمافزاری است و باعث میشود بسیاری از سیستمها در نهایت زشت، ناکارآمد، با ساختار ضعیف، غیرقابل نگهداری یا به سادگی اشتباه از آب درآیند.
کدنویسی مکانیکی نیست. اگر اینطور بود، تمام ابزارهای CASE که مردم در اوایل دهه ۱۹۸۰ امید خود را به آنها بسته بودند، مدتها پیش جایگزین برنامهنویسان شده بودند. هر لحظه تصمیماتی وجود دارد که باید گرفته شوند—تصمیماتی که نیازمند تفکر و قضاوت دقیق هستند تا برنامه حاصل، عمری طولانی، دقیق و پربار داشته باشد.
حتی همه تصمیمات آگاهانه نیستند. شما میتوانید غریزه و افکار ناخودآگاه خود را بهتر مهار کنید، همانطور که در موضوع ۳۷، به مارمولک مغزتان گوش دهید، خواهیم دید. ما خواهیم دید که چگونه با دقت بیشتری گوش دهیم و به راههایی برای پاسخ فعالانه به این افکار که گاهی اوقات آزاردهنده هستند، نگاهی بیندازیم.
اما گوش دادن به غرایز به این معنا نیست که میتوانید با خلبان خودکار پرواز کنید. توسعهدهندگانی که به طور فعال در مورد کد خود فکر نمیکنند، در حال برنامهنویسی تصادفی هستند—کد ممکن است کار کند، اما دلیل خاصی برای آن وجود ندارد. در موضوع ۳۸، برنامهنویسی تصادفی، ما از درگیری مثبتتری با فرآیند کدنویسی حمایت میکنیم.
در حالی که بیشتر کدی که مینویسیم به سرعت اجرا میشود، گاهی اوقات الگوریتمهایی را توسعه میدهیم که پتانسیل به زانو درآوردن حتی سریعترین پردازندهها را دارند. در موضوع ۳۹، سرعت الگوریتم، ما راههایی برای تخمین سرعت کد را مورد بحث قرار میدهیم و نکاتی را برای تشخیص مشکلات بالقوه قبل از وقوع آنها ارائه میدهیم.
برنامهنویسان عملگرا به طور انتقادی در مورد همه کدها، از جمله کد خودشان، فکر میکنند. ما دائماً جایی برای بهبود در برنامهها و طراحیهای خود میبینیم. در موضوع ۴۰، بازآرایی کد (Refactoring)، ما به تکنیکهایی نگاه میکنیم که به ما کمک میکنند تا کد موجود را به طور مداوم در حین پیشرفت، اصلاح کنیم.
تست کردن به معنای پیدا کردن باگ نیست، بلکه به معنای دریافت بازخورد در مورد کد شماست: جنبههایی از طراحی، API، کوپلینگ و غیره. این بدان معناست که مزایای اصلی تست زمانی حاصل میشود که شما در مورد تستها فکر میکنید و آنها را مینویسید، نه فقط زمانی که آنها را اجرا میکنید. ما این ایده را در موضوع ۴۱، تست برای کدنویسی، بررسی خواهیم کرد.
اما البته وقتی کد خود را تست میکنید، ممکن است تعصبات خود را وارد کار کنید. در موضوع ۴۲، تست مبتنی بر ویژگی (Property-Based Testing)، خواهیم دید که چگونه کامپیوتر را وادار کنیم تا برخی تستهای گسترده را برای شما انجام دهد و چگونه با باگهای اجتنابناپذیری که پیش میآیند، برخورد کنید.
بسیار مهم است که کدی بنویسید که خوانا باشد و استدلال در مورد آن آسان باشد. دنیای بیرون، دنیای خشنی است، پر از افراد بدخواه که فعالانه در تلاش برای نفوذ به سیستم شما و ایجاد آسیب هستند. ما برخی از تکنیکها و رویکردهای بسیار اساسی را برای کمک به شما در موضوع ۴۳، آنجا بیرون، ایمن بمانید، مورد بحث قرار خواهیم داد.
در نهایت، یکی از سختترین کارها در توسعه نرمافزار، موضوع ۴۴، نامگذاری چیزها است. ما باید چیزهای زیادی را نامگذاری کنیم و از بسیاری جهات، نامهایی که انتخاب میکنیم، واقعیتی را که خلق میکنیم، تعریف میکنند. شما باید در حین کدنویسی از هرگونه انحراف معنایی بالقوه آگاه باشید.
بیشتر ما میتوانیم یک ماشین را تا حد زیادی با خلبان خودکار برانیم؛ ما به طور صریح به پای خود دستور نمیدهیم که پدال را فشار دهد، یا به بازوی خود که فرمان را بچرخاند—ما فقط فکر میکنیم «سرعت را کم کن و به راست بپیچ». با این حال، رانندگان خوب و ایمن، دائماً وضعیت را بررسی میکنند، به دنبال مشکلات بالقوه میگردند و خود را در موقعیتهای خوبی قرار میدهند تا در صورت وقوع اتفاقات غیرمنتظره، آمادگی داشته باشند. همین امر در مورد کدنویسی نیز صادق است—ممکن است تا حد زیادی روتین باشد، اما هوشیار بودن میتواند به خوبی از یک فاجعه جلوگیری کند.
موضوع ۳۷: به مغز مارمولکی خود گوش دهید
فقط انسانها هستند که میتوانند مستقیماً به چیزی نگاه کنند، تمام اطلاعات لازم برای پیشبینی دقیق را داشته باشند، شاید حتی برای لحظهای آن پیشبینی دقیق را انجام دهند، و سپس بگویند که «نه، اینطور نیست».
کار زندگی «گاوین دی بکر» کمک به مردم برای محافظت از خودشان است. کتاب او، هدیه ترس: و سایر سیگنالهای بقا که ما را از خشونت محافظت میکنند [de Becker 98]، پیام او را خلاصه میکند. یکی از مضامین کلیدی که در سراسر کتاب جریان دارد این است که ما به عنوان انسانهای متمدن یاد گرفتهایم که جنبه حیوانیتر خود را نادیده بگیریم؛ یعنی غرایز و مغز مارمولکی (Lizard Brain) خود را.
او ادعا میکند که اکثر افرادی که در خیابان مورد حمله قرار میگیرند، قبل از حمله احساس ناراحتی یا عصبی بودن کردهاند. این افراد فقط به خود میگویند که دارند احمقانه رفتار میکنند. و سپس آن سایه از درگاه تاریک بیرون میآید...
غرایز صرفاً پاسخی به الگوهایی هستند که در مغز ناخودآگاه (nonconscious) ما بستهبندی شدهاند. برخی ذاتی هستند، برخی دیگر از طریق تکرار آموخته میشوند. همانطور که به عنوان یک برنامهنویس تجربه کسب میکنید، مغز شما در حال ایجاد لایههایی از دانش ضمنی است: چیزهایی که کار میکنند، چیزهایی که کار نمیکنند، علل احتمالی یک نوع خطا، و تمام چیزهایی که در طول روزهای خود متوجه میشوید. این همان بخشی از مغز شماست که وقتی میایستید تا با کسی گپ بزنید، دکمه «Save» فایل را میزند، حتی وقتی خودتان متوجه انجام آن نیستید.
غرایز هر منشأیی که داشته باشند، در یک چیز مشترکاند: آنها کلمات ندارند. غرایز باعث میشوند احساس کنید، نه اینکه فکر کنید. بنابراین وقتی یک غریزه فعال میشود، شما یک لامپ چشمکزن با بنری که دور آن پیچیده شده باشد، نمیبینید. در عوض، عصبی میشوید، یا حالت تهوع میگیرید، یا احساس میکنید که این کار خیلی سخت و زیاد است. ترفند این است که اول متوجه شوید این حالت دارد اتفاق میافتد، و سپس بفهمید چرا.
بیایید ابتدا به چند موقعیت رایج نگاه کنیم که در آن مارمولک درونی شما سعی دارد چیزی به شما بگوید. سپس بحث خواهیم کرد که چگونه میتوانید آن مغز غریزی را از پوشش محافظتیاش بیرون بیاورید.
ترس از صفحه خالی
همه از صفحه خالی میترسند، آن مکاننمای (cursor) چشمکزن تنها که با انبوهی از «هیچ» احاطه شده است. شروع یک پروژه جدید (یا حتی یک ماژول جدید در پروژه موجود) میتواند تجربهای دلهرهآور باشد. بسیاری از ما ترجیح میدهیم تعهد اولیه برای شروع کار را به تعویق بیندازیم.
ما فکر میکنیم دو مشکل وجود دارد که باعث این امر میشود، و هر دو راه حل یکسانی دارند.
یک مشکل این است که مغز مارمولکی شما سعی دارد چیزی به شما بگوید؛ نوعی شک درست زیر سطح ادراک شما کمین کرده است. و این مهم است. به عنوان یک توسعهدهنده، شما چیزهایی را امتحان کردهاید و دیدهاید کدام کار میکند و کدام نه. شما تجربه و خرد اندوختهاید. وقتی شکی آزاردهنده را حس میکنید، یا هنگام مواجهه با یک وظیفه احساس بیمیلی میکنید، ممکن است آن تجربه باشد که سعی دارد با شما صحبت کند. به آن توجه کنید. ممکن است نتوانید دقیقاً انگشت روی مشکل بگذارید، اما به آن زمان بدهید؛ شکهای شما احتمالاً به چیزی جامدتر تبدیل میشوند، چیزی که میتوانید به آن رسیدگی کنید. بگذارید غرایزتان در عملکرد شما سهیم باشند.
مشکل دیگر کمی پیشپاافتادهتر است: ممکن است صرفاً بترسید که اشتباه کنید. و این ترسی منطقی است. ما توسعهدهندگان بخش زیادی از خودمان را در کدمان میگذاریم؛ ما میتوانیم خطاها در آن کد را به عنوان بازتابی از شایستگی خود تلقی کنیم. شاید عنصری از سندروم ایمپاستر (Imposter syndrome) نیز وجود داشته باشد؛ ممکن است فکر کنیم این پروژه فراتر از توان ماست. ما نمیتوانیم راه خود را تا پایان ببینیم؛ تا جایی پیش خواهیم رفت و سپس مجبور میشویم اعتراف کنیم که گم شدهایم.
مبارزه با خود
گاهی اوقات کد فقط از مغز شما به داخل ادیتور پرواز میکند: ایدهها ظاهراً بدون هیچ تلاشی تبدیل به بیتها میشوند. روزهای دیگر، کدنویسی مثل راه رفتن در سربالایی میان گلولای است. برداشتن هر قدم به تلاشی عظیم نیاز دارد و هر سه قدم که میروید، دو قدم به عقب لیز میخورید.
اما، چون حرفهای هستید، سرسختانه ادامه میدهید و قدمهای گلی را یکی پس از دیگری برمیدارید: شما وظیفهای برای انجام دادن دارید.
متأسفانه، این احتمالاً دقیقاً برعکس کاری است که باید انجام دهید. کد شما سعی دارد چیزی به شما بگوید. دارد میگوید که این کار سختتر از آن چیزی است که باید باشد. شاید ساختار یا طراحی اشتباه است، شاید دارید مشکل اشتباهی را حل میکنید، یا شاید دارید به اندازه یک مزرعه مورچه، باگ تولید میکنید. دلیل هرچه باشد، مغز مارمولکی شما بازخورد را از کد حس میکند و ناامیدانه سعی دارد شما را وادار به گوش دادن کند.
چطور به زبان مارمولکی حرف بزنیم
ما زیاد درباره گوش دادن به غرایز، به ناخودآگاه و مغز مارمولکی صحبت میکنیم. تکنیکها همیشه یکسان هستند.
نکته ۶۱: به مارمولک درونی خود گوش دهید
اول، کاری که انجام میدهید را متوقف کنید. به خودتان کمی زمان و فضا بدهید تا مغزتان خود را سازماندهی کند. فکر کردن درباره کد را متوقف کنید و برای مدتی کاری نسبتاً بدون فکر، دور از کیبورد انجام دهید. قدم بزنید، ناهار بخورید، با کسی گپ بزنید. شاید روی آن بخوابید. بگذارید ایدهها خودشان از لایههای مغزتان تراوش کنند و بالا بیایند: نمیتوانید به زور این کار را بکنید. در نهایت آنها ممکن است به سطح خودآگاه حباب کنند و بالا بیایند و شما یکی از آن لحظات «آها!» داشته باشید.
اگر این کار نکرد، سعی کنید مسئله را بیرونی کنید. درباره کدی که مینویسید خطخطی (Doodle) کنید، یا آن را برای یک همکار (ترجیحاً کسی که برنامهنویس نیست) یا برای اردک پلاستیکی خود توضیح دهید. بخشهای مختلف مغزتان را در معرض مسئله قرار دهید و ببینید آیا هیچکدام درک بهتری از چیزی که آزارتان میدهد دارند یا خیر.
ما حساب تعداد مکالماتی را که داشتهایم و در آن یکی از ما مشکلی را برای دیگری توضیح میداد و ناگهان گفت «آها! البته!» و حرفش را قطع کرد تا آن را درست کند، از دست دادهایم.
اما شاید شما این کارها را امتحان کردهاید و هنوز گیر کردهاید. وقت عمل است. ما باید به مغز شما بگوییم کاری که میخواهید انجام دهید اهمیتی ندارد. و این کار را با پروتوتایپسازی (Prototyping) انجام میدهیم.
وقت بازی است
اندی و دیو هر دو ساعتها به بافرهای خالی ادیتور خیره شدهاند. ما مقداری کد تایپ میکنیم، سپس به سقف نگاه میکنیم، سپس یک نوشیدنی دیگر میگیریم، سپس کمی دیگر کد تایپ میکنیم، سپس میرویم داستان خندهداری درباره گربهای با دو دم میخوانیم، سپس کمی دیگر کد تایپ میکنیم، سپس «انتخاب همه/حذف» (select-all/delete) را میزنیم و دوباره شروع میکنیم. و دوباره. و دوباره.
و در طول سالها ما یک هک مغزی پیدا کردهایم که به نظر میرسد کار میکند. به خودتان بگویید باید چیزی را پروتوتایپ کنید.
اگر با صفحه خالی مواجه هستید، به دنبال جنبهای از پروژه بگردید که میخواهید کاوش کنید. شاید دارید از فریمورک جدیدی استفاده میکنید و میخواهید ببینید Data Binding را چطور انجام میدهد. یا شاید یک الگوریتم جدید است و میخواهید بررسی کنید در موارد مرزی (edge cases) چطور کار میکند. یا شاید میخواهید چند سبک مختلف از تعامل کاربر را امتحان کنید.
اگر روی کد موجود کار میکنید و کد مقاومت میکند (پیش نمیرود)، آن را جایی پنهان کنید (stash) و به جای آن چیزی مشابه را پروتوتایپ کنید.
کارهای زیر را انجام دهید:
۱. روی یک کاغذ یادداشت چسبان بنویسید «من دارم پروتوتایپ میسازم» و آن را کنار صفحه نمایش خود بچسبانید.
۲. به خودتان یادآوری کنید که پروتوتایپها برای شکست خوردن هستند. و یادآوری کنید که پروتوتایپها دور ریخته میشوند، حتی اگر شکست نخورند. هیچ ضرری در انجام این کار نیست.
۳. در بافر خالی ادیتور خود، کامنتی ایجاد کنید که در یک جمله توصیف کند چه چیزی را میخواهید یاد بگیرید یا انجام دهید.
۴. کدنویسی را شروع کنید.
اگر شروع به شک کردن کردید، به یادداشت چسبان نگاه کنید. اگر در وسط کدنویسی، آن شک آزاردهنده ناگهان به یک نگرانی جامد و مشخص تبدیل شد، به آن رسیدگی کنید. اگر به پایان آزمایش رسیدید و هنوز احساس ناراحتی میکردید، دوباره با پیادهروی و صحبت کردن و مرخصی گرفتن شروع کنید.
اما، طبق تجربه ما، در نقطهای در طول اولین پروتوتایپ، شگفتزده خواهید شد که دارید با موسیقی خود زمزمه میکنید و از حس خلق کد لذت میبرید. آن حالت عصبی تبخیر شده و با حس فوریت جایگزین شده است: بیایید این کار را تمام کنیم!
در این مرحله، شما میدانید چه کار کنید. تمام کد پروتوتایپ را پاک کنید، کاغذ یادداشت را دور بیندازید و آن بافر خالی ادیتور را با کد جدید، براق و درخشان پر کنید.
نه فقط کدِ خودتان
بخش بزرگی از کار ما سر و کار داشتن با کد موجود است، که اغلب توسط افراد دیگر نوشته شده است. آن افراد غرایز متفاوتی نسبت به شما داشتهاند و بنابراین تصمیماتی که گرفتهاند متفاوت خواهد بود. نه لزوماً بدتر؛ فقط متفاوت.
شما میتوانید کد آنها را به صورت مکانیکی بخوانید، با سختی از میان آن عبور کنید و درباره چیزهایی که مهم به نظر میرسند یادداشت بردارید. این یک کار طاقتفرساست (حمالی است)، اما جواب میدهد.
یا میتوانید یک آزمایش انجام دهید. وقتی متوجه چیزهایی میشوید که به روشی عجیب انجام شدهاند، آن را یادداشت کنید. به این کار ادامه دهید و به دنبال الگوها بگردید. اگر بتوانید ببینید چه چیزی آنها را به نوشتن کد به آن روش سوق داده، ممکن است دریابید که کار درک آن بسیار آسانتر میشود. شما قادر خواهید بود آگاهانه الگوهایی را اعمال کنید که آنها به صورت ضمنی اعمال کردهاند. و ممکن است در این مسیر چیز جدیدی یاد بگیرید.
نه فقط کد
یادگیری گوش دادن به حس درونی (gut feeling) هنگام کدنویسی مهارت مهمی برای پرورش دادن است. اما این موضوع در مورد تصویر بزرگتر نیز صدق میکند.
گاهی اوقات یک طراحی صرفاً حس اشتباهی میدهد، یا یک نیازمندی باعث میشود احساس ناراحتی کنید. بایستید و این احساسات را تحلیل کنید. اگر در محیطی حمایتگر هستید، آنها را با صدای بلند بیان کنید. آنها را کاوش کنید. احتمال زیادی وجود دارد که چیزی در آن درگاه تاریک کمین کرده باشد. به غرایز خود گوش دهید و قبل از اینکه مشکل روی سرتان بپرد، از آن اجتناب کنید.
بخشهای مرتبط شامل
- موضوع ۱۳: پروتوتایپها و یادداشتهای چسبان (Prototypes and Post-it Notes)
- موضوع ۲۲: دفترچههای روزانه مهندسی (Engineering Daybooks)
- موضوع ۴۶: حل پازلهای غیرممکن (Solving Impossible Puzzles)
چالشها
- آیا کاری هست که میدانید باید انجام دهید، اما آن را به تعویق انداختهاید چون کمی ترسناک یا دشوار به نظر میرسد؟ تکنیکهای این بخش را اعمال کنید. آن را در یک محدودیت زمانی (Time box) یک ساعته، شاید دو ساعته قرار دهید و به خودتان قول دهید که وقتی زنگ به صدا در آمد، هر چه انجام دادهاید را پاک خواهید کرد. چه چیزی یاد گرفتید؟
موضوع ۳۸: برنامهنویسی تصادفی
آیا تا به حال فیلمهای جنگی سیاهوسفید قدیمی را تماشا کردهاید؟ سرباز خسته با احتیاط از میان بوتهها پیش میرود. در جلو یک فضای باز (محوطه) وجود دارد: آیا مینهای زمینی وجود دارد، یا عبور امن است؟ هیچ نشانهای وجود ندارد که نشاندهنده میدان مین باشد—بدون علامت، سیم خاردار، یا گودال انفجار. سرباز با سرنیزه خود به آرامی زمین جلوی پایش را سیخ میزند و با انتظار انفجار، چهرهاش را در هم میکشد. هیچ انفجاری رخ نمیدهد.
بنابراین او برای مدتی با زحمت و دقت از میدان عبور میکند، و در حین رفتن زمین را بررسی و سیخ میزند. در نهایت، متقاعد شده که میدان امن است، صاف میایستد و با غرور به جلو گام برمیدارد، تا اینکه تکهتکه میشود.
کاوشهای اولیه سرباز برای مین هیچ چیز را آشکار نکرد، اما این صرفاً شانس بود. او به یک نتیجهگیری غلط هدایت شد—با نتایجی فاجعهبار.
به عنوان توسعهدهنده، ما نیز در میادین مین کار میکنیم. هر روز صدها دام منتظر گرفتن ما هستند. با یادآوری داستان سرباز، باید از نتیجهگیریهای غلط بر حذر باشیم. ما باید از برنامهنویسی تصادفی (Programming by Coincidence)—تکیه بر شانس و موفقیتهای اتفاقی—اجتناب کنیم و در عوض به نفع برنامهنویسی عامدانه (Deliberate Programming) عمل کنیم.
چگونه تصادفی برنامهنویسی کنیم
فرض کنید به فرد مأموریتی برای برنامهنویسی داده میشود. فرد مقداری کد تایپ میکند، آن را امتحان میکند، و به نظر میرسد که کار میکند. فرد کد بیشتری تایپ میکند، آن را امتحان میکند، و باز هم به نظر میرسد کار میکند. بعد از چندین هفته کدنویسی به این روش، برنامه ناگهان از کار میافتد، و پس از ساعتها تلاش برای تعمیر آن، او هنوز نمیداند چرا. فرد ممکن است زمان قابل توجهی را صرف تعقیب این قطعه کد کند بدون اینکه هرگز بتواند آن را درست کند. هر کاری که میکند، انگار هیچ وقت درست کار نمیکند.
فرد نمیداند چرا کد شکست میخورد زیرا در وهله اول نمیدانست چرا کار میکرد. با توجه به «تست» محدودی که فرد انجام داد، به نظر میرسید کار میکند، اما این فقط یک تصادف بود. فرد با اعتماد به نفس کاذب، با سرعت به سمت نیستی شتافت.
حالا، اکثر افراد باهوش ممکن است کسی شبیه فرد را بشناسند، اما ما بهتر میدانیم. ما به تصادفات تکیه نمیکنیم—مگر نه؟
گاهی اوقات ممکن است بکنیم. گاهی اوقات اشتباه گرفتن یک تصادف خوشحالکننده با یک نقشه هدفمند میتواند خیلی آسان باشد. بیایید به چند مثال نگاه کنیم.
تصادفات پیادهسازی (Accidents of Implementation)
تصادفات پیادهسازی چیزهایی هستند که صرفاً به دلیل نحوه فعلی نوشتن کد اتفاق میافتند. شما در نهایت به شرایط خطای مستند نشده یا شرایط مرزی تکیه میکنید.
فرض کنید تابعی را با دادههای بد فراخوانی میکنید. روتین به روش خاصی پاسخ میدهد، و شما بر اساس آن پاسخ کد مینویسید. اما نویسنده قصد نداشته که روتین آنطور کار کند—اصلاً حتی به آن فکر هم نکرده بود. وقتی روتین «تعمیر» میشود، کد شما ممکن است بشکند. در شدیدترین حالت، روتینی که فراخوانی کردید ممکن است حتی برای انجام آنچه شما میخواهید طراحی نشده باشد، اما به نظر میرسد که خوب کار میکند.
فراخوانی چیزها به ترتیب اشتباه، یا در زمینه (Context) اشتباه، مشکل مشابهی است. در اینجا به نظر میرسد فرد ناامیدانه تلاش میکند چیزی را با استفاده از یک فریمورک رندرینگ GUI خاص روی صفحه نمایش دهد:
paint();
invalidate();
validate();
revalidate();
repaint();
paintImmediately();
اما این روتینها هرگز برای فراخوانی به این شکل طراحی نشدهاند؛ اگرچه به نظر میرسد کار میکنند، اما واقعاً فقط یک تصادف است.
برای بدتر کردن اوضاع، وقتی صحنه بالاخره رسم میشود، فرد سعی نخواهد کرد به عقب برگردد و فراخوانیهای جعلی را حذف کند. «الان کار میکند، بهتر است دست به ترکیب برنده نزنیم...» فریب خوردن با این طرز تفکر آسان است. چرا باید ریسک به هم ریختن چیزی را که کار میکند بپذیرید؟
خب، ما میتوانیم به چندین دلیل فکر کنیم:
- ممکن است واقعاً کار نکند—فقط ممکن است به نظر برسد که کار میکند.
- شرایط مرزی که به آن تکیه میکنید ممکن است فقط یک تصادف باشد. در شرایط متفاوت (رزولوشن صفحه متفاوت، هستههای CPU بیشتر)، ممکن است متفاوت رفتار کند.
- رفتار مستند نشده ممکن است با انتشار بعدی کتابخانه تغییر کند.
- فراخوانیهای اضافی و غیرضروری کد شما را کندتر میکنند.
- فراخوانیهای اضافی خطر معرفی باگهای جدید خودشان را افزایش میدهند.
برای کدی که مینویسید و دیگران فراخوانی خواهند کرد، اصول اولیه ماژولار کردن خوب و پنهان کردن پیادهسازی پشت رابطهای (Interfaces) کوچک و خوب مستند شده میتواند کمک کند. یک قرارداد خوب مشخص شده (نگاه کنید به موضوع ۲۳، طراحی بر اساس قرارداد) میتواند به حذف سوءتفاهمها کمک کند.
برای روتینهایی که فراخوانی میکنید، فقط به رفتار مستند شده تکیه کنید. اگر به هر دلیلی نمیتوانید، فرضیات خود را به خوبی مستند کنید.
به اندازه کافی نزدیک بودن [50]
ما زمانی روی پروژهای بزرگ کار میکردیم که دادههای ارسال شده از تعداد بسیار زیادی واحد جمعآوری داده سختافزاری در میدان را گزارش میداد. این واحدها ایالتها و مناطق زمانی مختلف را پوشش میدادند، و به دلایل مختلف لجستیکی و تاریخی، هر واحد روی زمان محلی تنظیم شده بود.
در نتیجه تفسیرهای متناقض منطقه زمانی و ناسازگاریها در سیاستهای ساعت تابستانی (Daylight Savings Time)، نتایج تقریباً همیشه اشتباه بودند، اما فقط با اختلاف یک ساعت. توسعهدهندگان پروژه عادت کرده بودند که فقط یک ساعت اضافه یا کم کنند تا جواب درست را بگیرند، با این استدلال که در این یک موقعیت فقط با اختلاف یک ساعت اشتباه است. و سپس تابع بعدی مقدار را با اختلاف یک ساعت به روش دیگر میدید و آن را برمیگرداند.
اما این واقعیت که "فقط" بعضی اوقات با اختلاف یک ساعت اشتباه بود، یک تصادف بود که یک نقص عمیقتر و بنیادیتر را پنهان میکرد. بدون یک مدل مناسب برای مدیریت زمان، کل پایگاه کد بزرگ به مرور زمان به تودهای غیرقابل دفاع از دستورات ۱+ و ۱- تبدیل شده بود. در نهایت، هیچکدام از آنها درست نبود و پروژه دور انداخته شد.
الگوهای خیالی (Phantom Patterns)
انسانها طوری طراحی شدهاند که الگوها و علتها را ببینند، حتی وقتی که فقط یک تصادف است. برای مثال، رهبران روسیه همیشه بین طاس و مودار متناوب هستند: یک رهبر دولت طاس (یا آشکارا در حال طاس شدن) روسیه جانشین یک رهبر غیر طاس («مودار») شده است، و بالعکس، برای نزدیک به ۲۰۰ سال. [51]
اما در حالی که شما کدی نمینویسید که به طاس یا مودار بودن رهبر بعدی روسیه وابسته باشد، در برخی حوزهها ما همیشه اینطور فکر میکنیم.
قماربازان الگوهایی را در شمارههای لاتاری، بازیهای تاس، یا رولت تصور میکنند، در حالی که در واقع اینها رویدادهای آماری مستقل هستند. در امور مالی، معاملات سهام و اوراق بهادار به طور مشابهی پر از تصادف به جای الگوهای واقعی و قابل تشخیص است.
یک فایل لاگ که خطای متناوبی را هر ۱۰۰۰ درخواست نشان میدهد ممکن است یک شرایط رقابتی (Race condition) دشوار برای تشخیص باشد، یا ممکن است یک باگ قدیمی ساده باشد. تستهایی که به نظر میرسد روی دستگاه شما پاس میشوند اما روی سرور نه، ممکن است نشاندهنده تفاوت بین دو محیط باشد، یا شاید فقط یک تصادف است. فرض نکنید، آن را ثابت کنید.
تصادفات زمینه (Context)
شما میتوانید «تصادفات زمینه» هم داشته باشید. فرض کنید در حال نوشتن یک ماژول کاربردی هستید. فقط به این دلیل که در حال حاضر برای محیط GUI کد مینویسید، آیا ماژول باید به وجود GUI وابسته باشد؟ آیا به کاربران انگلیسیزبان تکیه میکنید؟ کاربران باسواد؟ به چه چیز دیگری تکیه میکنید که تضمین شده نیست؟
آیا به قابل نوشتن بودن دایرکتوری جاری تکیه میکنید؟ به وجود متغیرهای محیطی خاص یا فایلهای پیکربندی؟ به دقیق بودن زمان روی سرور—با چه تحملی؟ آیا به در دسترس بودن شبکه و سرعت آن تکیه میکنید؟
وقتی کدی را از اولین پاسخی که در نت پیدا کردید کپی کردید، آیا مطمئن هستید که زمینه (Context) شما یکسان است؟ یا دارید کد «بارپرستگونه» (Cargo cult) میسازید، و صرفاً از فرم بدون محتوا تقلید میکنید؟ [52]
پیدا کردن پاسخی که اتفاقاً جور در میآید، همان پاسخ درست نیست.
نکته ۶۲: تصادفی برنامهنویسی نکنید
فرضیات ضمنی
تصادفات میتوانند در همه سطوح گمراهکننده باشند—از تولید نیازمندیها تا تست کردن. تست کردن به خصوص پر از علیتهای کاذب و نتایج تصادفی است.
آسان است فرض کنیم که X باعث Y میشود، اما همانطور که در موضوع ۲۰، دیباگ کردن گفتیم: فرض نکنید، آن را ثابت کنید.
در همه سطوح، افراد با فرضیات زیادی در ذهن عمل میکنند—اما این فرضیات به ندرت مستند میشوند و اغلب بین توسعهدهندگان مختلف در تضاد هستند. فرضیاتی که بر اساس حقایق ثابت شده نیستند، بلای جان همه پروژهها هستند.
چگونه عامدانه برنامهنویسی کنیم
ما میخواهیم زمان کمتری را صرف تولید کد کنیم، خطاها را هر چه زودتر در چرخه توسعه بگیریم و اصلاح کنیم، و در وهله اول خطاهای کمتری ایجاد کنیم. کمک میکند اگر بتوانیم عامدانه (Deliberately) برنامهنویسی کنیم:
- همیشه آگاه باشید که چه کاری انجام میدهید. فرد (Fred) اجازه داد همه چیز به آرامی از کنترل خارج شود، تا اینکه مثل قورباغه پخته شد.
- آیا میتوانید کد را با جزئیات برای یک برنامهنویس تازهکارتر توضیح دهید؟ اگر نه، شاید دارید به تصادفات تکیه میکنید.
- در تاریکی کد ننویسید. برنامهای بسازید که کاملاً درک نمیکنید، یا از تکنولوژی استفاده کنید که نمیفهمید، و به احتمال زیاد توسط تصادفات گزیده خواهید شد. اگر مطمئن نیستید چرا کار میکند، نخواهید دانست چرا شکست میخورد.
- از روی یک نقشه پیش بروید، خواه آن نقشه در سرتان باشد، پشت دستمال کاغذی، یا روی تخته وایتبرد.
- فقط به چیزهای قابل اعتماد تکیه کنید. به فرضیات وابسته نباشید. اگر نمیتوانید بگویید چیزی قابل اعتماد است یا نه، بدترین حالت را فرض کنید.
- فرضیات خود را مستند کنید. موضوع ۲۳، طراحی بر اساس قرارداد، میتواند به روشن شدن فرضیات در ذهن خودتان و همچنین کمک به ارتباط آنها با دیگران کمک کند.
- فقط کد خود را تست نکنید، بلکه فرضیات خود را نیز تست کنید. حدس نزنید؛ واقعاً امتحانش کنید. یک Assert برای تست فرضیات خود بنویسید (نگاه کنید به موضوع ۲۵، برنامهنویسی قاطعانه). اگر Assert شما درست باشد، مستندات کد خود را بهبود بخشیدهاید. اگر کشف کنید فرضیات شما اشتباه است، خود را خوششانس بدانید.
- تلاش خود را اولویتبندی کنید. روی جنبههای مهم وقت بگذارید؛ به احتمال زیاد، اینها بخشهای سخت هستند. اگر اصول یا زیرساخت درستی نداشته باشید، زرق و برقهای درخشان بیربط خواهند بود.
- برده تاریخ نباشید. نگذارید کد موجود، کد آینده را دیکته کند. تمام کدها اگر دیگر مناسب نباشند، قابل جایگزینی هستند. حتی در داخل یک برنامه، نگذارید آنچه قبلاً انجام دادهاید، آنچه بعداً انجام میدهید را محدود کند—آماده بازآرایی باشید (نگاه کنید به موضوع ۴۰، بازآرایی). این تصمیم ممکن است بر زمانبندی پروژه تأثیر بگذارد. فرض بر این است که تأثیر آن کمتر از هزینه انجام ندادن تغییر خواهد بود. [53]
بنابراین دفعه بعد که چیزی به نظر میرسد کار میکند، اما نمیدانید چرا، مطمئن شوید که فقط یک تصادف نیست.
بخشهای مرتبط شامل
- موضوع ۴: سوپ سنگ و قورباغههای پخته (Stone Soup and Boiled Frogs)
- موضوع ۹: DRY—بدیهای تکرار (DRY—The Evils of Duplication)
- موضوع ۲۳: طراحی بر اساس قرارداد (Design by Contract)
- موضوع ۳۴: حالت اشتراکی، حالت نادرست است (Shared State Is Incorrect State)
- موضوع ۴۳: آنجا بیرون، ایمن بمانید (Stay Safe Out There)
تمرینها
تمرین ۲۵ (پاسخ ممکن)
یک فید داده از یک فروشنده به شما آرایهای از تاپلها میدهد که نشاندهنده جفتهای کلید-مقدار هستند. کلید DepositAccount رشتهای از شماره حساب را در مقدار مربوطه نگه میدارد:
[ ... {:DepositAccount, "564-904-143-00"} ... ]
این در تست روی لپتاپهای ۴ هستهای توسعهدهندگان و روی ماشین بیلد ۱۲ هستهای کاملاً کار میکرد، اما در سرورهای پروداکشن که در کانتینرها اجرا میشوند، مدام شماره حسابهای اشتباه دریافت میکنید. چه خبر است؟
تمرین ۲۶ (پاسخ ممکن)
شما در حال کدنویسی یک شمارهگیر خودکار برای هشدارهای صوتی هستید و باید پایگاه دادهای از اطلاعات تماس را مدیریت کنید. ITU مشخص میکند که شماره تلفنها نباید بیشتر از ۱۵ رقم باشند، بنابراین شما شماره تلفن مخاطب را در یک فیلد عددی ذخیره میکنید که تضمین شده حداقل ۱۵ رقم را نگه دارد. شما در سراسر آمریکای شمالی به طور کامل تست کردهاید و همه چیز خوب به نظر میرسد، اما ناگهان سیلی از شکایات از سایر نقاط جهان دریافت میکنید. چرا؟
تمرین ۲۷ (پاسخ ممکن)
شما برنامهای نوشتهاید که دستور پختهای معمولی را برای یک رستوران کشتی کروز با ظرفیت ۵۰۰۰ نفر مقیاسدهی (Scale up) میکند. اما شکایاتی دریافت میکنید که تبدیلها دقیق نیستند. شما بررسی میکنید و کد از فرمول تبدیل ۱۶ فنجان به یک گالن استفاده میکند. این درست است، مگر نه؟
موضوع ۳۹: سرعت الگوریتم
در موضوع ۱۵، تخمین زدن، درباره تخمین زدن چیزهایی مثل اینکه چقدر طول میکشد تا پیاده از شهر عبور کنیم یا یک پروژه چه زمانی تمام میشود، صحبت کردیم. با این حال، نوع دیگری از تخمین زدن وجود دارد که برنامهنویسان عملگرا تقریباً هر روز از آن استفاده میکنند: تخمین منابعی که الگوریتمها استفاده میکنند—زمان، پردازنده، حافظه و غیره.
این نوع تخمین زدن اغلب حیاتی است. وقتی بین دو راه برای انجام کاری حق انتخاب دارید، کدام را انتخاب میکنید؟ شما میدانید برنامهتان با ۱,۰۰۰ رکورد چقدر طول میکشد اجرا شود، اما با ۱,۰۰۰,۰۰۰ رکورد چگونه مقیاسدهی (Scale) خواهد شد؟ کدام بخشهای کد نیاز به بهینهسازی دارند؟
مشخص شده که این سوالات اغلب میتوانند با استفاده از عقل سلیم، کمی تحلیل و روشی برای نوشتن تقریبها به نام نماد O بزرگ (Big-O notation) پاسخ داده شوند.
منظور ما از تخمین الگوریتمها چیست؟
اکثر الگوریتمهای غیر بدیهی (nontrivial) نوعی ورودی متغیر را مدیریت میکنند—مرتبسازی رشتهها، معکوس کردن یک ماتریس $N \times N$، یا رمزگشایی یک پیام با کلید $N$-بیتی. معمولاً اندازه این ورودی ($N$) بر الگوریتم تأثیر میگذارد: هرچه ورودی بزرگتر باشد، زمان اجرا طولانیتر یا حافظه مصرفی بیشتر میشود.
اگر این رابطه همیشه خطی بود (به طوری که زمان به نسبت مستقیم با مقدار $N$ افزایش مییافت)، این بخش مهم نبود. با این حال، اکثر الگوریتمهای مهم خطی نیستند. خبر خوب این است که بسیاری از آنها «زیر-خطی» (sublinear) هستند. برای مثال، یک جستجوی باینری (Binary Search) هنگام پیدا کردن یک مورد منطبق، نیازی ندارد به همه کاندیداها نگاه کند.
خبر بد این است که سایر الگوریتمها به طور قابل ملاحظهای بدتر از خطی هستند؛ زمان اجرا یا حافظه مورد نیاز بسیار سریعتر از $N$ افزایش مییابد. الگوریتمی که پردازش ده مورد آن یک دقیقه طول میکشد، ممکن است برای پردازش ۱۰۰ مورد، یک عمر زمان ببرد.
ما متوجه شدهایم که هر زمان کدی مینویسیم که شامل حلقهها یا فراخوانیهای بازگشتی است، ناخودآگاه الزامات زمان اجرا و حافظه را بررسی میکنیم. این به ندرت یک فرآیند رسمی است، بلکه بیشتر یک تأیید سریع است که کاری که انجام میدهیم در شرایط موجود معقول است. با این حال، گاهی اوقات متوجه میشویم که در حال انجام تحلیل دقیقتری هستیم. اینجاست که نماد Big-O به کار میآید.
نماد Big-O
نماد Big-O، که به صورت $O(f(n))$ نوشته میشود، راهی ریاضی برای برخورد با تقریبهاست. وقتی مینویسیم که یک روتین مرتبسازی خاص، $N$ رکورد را در زمان $O(N^2)$ مرتب میکند، به سادگی میگوییم که زمان گرفته شده در بدترین حالت متناسب با مربع $N$ تغییر خواهد کرد. تعداد رکوردها را دو برابر کنید، زمان تقریباً چهار برابر خواهد شد.
به $O$ به عنوان معنی «از مرتبهی» (on the order of) فکر کنید. این نماد یک حد بالا (upper bound) روی مقدار چیزی که اندازه میگیریم (زمان، حافظه و غیره) قرار میدهد. اگر بگوییم تابعی زمان $O(N^2)$ میگیرد، میدانیم که حد بالای زمانی که میگیرد سریعتر از $N^2$ رشد نخواهد کرد.
گاهی اوقات با توابع نسبتاً پیچیدهای مواجه میشویم، اما چون ترم با بالاترین مرتبه با افزایش $N$ بر مقدار غالب خواهد شد، عرف این است که تمام ترمهای با مرتبه پایین را حذف کنیم و زحمت نشان دادن هیچ فاکتور ضربکننده ثابتی را به خود ندهیم:
$$ \frac{1}{2} N^2 + 3N \rightarrow O(N^2) $$
این در واقع ویژگی این نماد است—یک الگوریتم $O(N^2)$ ممکن است ۱۰۰۰ برابر سریعتر از الگوریتم $O(N^2)$ دیگری باشد، اما شما این را از نماد متوجه نخواهید شد.
Big-O هرگز قرار نیست اعداد واقعی برای زمان یا حافظه یا هر چیز دیگری به شما بدهد: فقط به شما میگوید که این مقادیر با تغییر ورودی چگونه تغییر خواهند کرد.
شکل ۳، زمان اجرای الگوریتمهای مختلف، چندین نماد رایج که با آنها برخورد خواهید کرد را همراه با نموداری که زمان اجرای الگوریتمها در هر دسته را مقایسه میکند، نشان میدهد.
واضح است که وقتی از $O(N^2)$ بالاتر میرویم، اوضاع به سرعت از کنترل خارج میشود.
برای مثال، فرض کنید روتینی دارید که پردازش ۱۰۰ رکورد آن یک ثانیه طول میکشد. پردازش ۱۰۰۰ رکورد چقدر طول خواهد کشید؟
- اگر کد شما $O(1)$ باشد، همچنان یک ثانیه طول خواهد کشید.
- اگر $O(\lg N)$ باشد، احتمالاً حدود سه ثانیه منتظر خواهید بود.
- $O(N)$ افزایش خطی به ۱۰ ثانیه را نشان میدهد.
- در حالی که $O(N \lg N)$ حدود ۳۳ ثانیه طول میکشد.
- اگر آنقدر بدشانس باشید که روتین $O(N^2)$ داشته باشید، پس ۱۰۰ ثانیه بنشینید تا کارش را انجام دهد.
- و اگر از یک الگوریتم نمایی $O(2^N)$ استفاده میکنید، ممکن است بخواهید یک فنجان قهوه درست کنید—روتین شما باید در حدود $10^{250}$ سال دیگر تمام شود. به ما خبر دهید جهان چگونه به پایان میرسد.
این نماد فقط برای زمان کاربرد ندارد؛ میتوانید از آن برای نمایش هر منبع دیگری که توسط الگوریتم استفاده میشود نیز استفاده کنید. برای مثال، اغلب مفید است که بتوانیم مصرف حافظه را مدل کنیم (تمرینها را برای یک مثال ببینید).
- $O(1)$: ثابت (دسترسی به عنصر در آرایه، دستورات ساده)
- $O(\lg N)$: لگاریتمی (جستجوی باینری). پایه لگاریتم مهم نیست، بنابراین این معادل $O(\log N)$ است.
- $O(N)$: خطی (جستجوی ترتیبی)
- $O(N \lg N)$: بدتر از خطی، اما نه خیلی بدتر. (متوسط زمان اجرای quicksort، heapsort)
- $O(N^2)$: قانون مربع (مرتبسازیهای انتخابی و درجی)
- $O(N^3)$: مکعبی (ضرب دو ماتریس)
- $O(C^N)$: نمایی (مسئله فروشنده دورهگرد، پارتیشنبندی مجموعه)
(در اینجا تصویر نمودار مقایسه زمان اجراها وجود دارد که رشد نمایی، مربعی و... را نشان میدهد)
شکل ۳. زمان اجرای الگوریتمهای مختلف
تخمین با عقل سلیم
شما میتوانید مرتبه بسیاری از الگوریتمهای پایه را با استفاده از عقل سلیم تخمین بزنید.
- حلقههای ساده (Simple loops): اگر یک حلقه ساده از $1$ تا $N$ اجرا شود، الگوریتم احتمالاً $O(N)$ است—زمان به طور خطی با $N$ افزایش مییابد. مثالها شامل جستجوهای کامل، پیدا کردن مقدار ماکزیمم در یک آرایه، و تولید چکسام (checksum) هستند.
- حلقههای تو در تو (Nested loops): اگر حلقهای را داخل دیگری قرار دهید، الگوریتم شما $O(M \times N)$ میشود، که $M$ و $N$ حدود دو حلقه هستند. این معمولاً در الگوریتمهای مرتبسازی ساده رخ میدهد، مانند مرتبسازی حبابی (bubble sort)، جایی که حلقه بیرونی هر عنصر آرایه را به نوبت اسکن میکند و حلقه درونی محاسبه میکند که آن عنصر را کجای نتیجه مرتب شده قرار دهد. چنین الگوریتمهای مرتبسازی تمایل دارند $O(N^2)$ باشند.
- تقسیم باینری (Binary chop): اگر الگوریتم شما مجموعه چیزهایی را که در نظر میگیرد هر بار در حلقه نصف کند، احتمالاً لگاریتمی است، $O(\lg N)$. جستجوی باینری یک لیست مرتب شده، پیمایش یک درخت باینری، و پیدا کردن اولین بیت تنظیم شده در یک کلمه ماشین (machine word) همگی میتوانند $O(\lg N)$ باشند.
- تقسیم و غلبه (Divide and conquer): الگوریتمهایی که ورودی خود را پارتیشنبندی میکنند، روی دو نیمه به طور مستقل کار میکنند، و سپس نتیجه را ترکیب میکنند میتوانند $O(N \lg N)$ باشند. مثال کلاسیک مرتبسازی سریع (quicksort) است که با پارتیشنبندی دادهها به دو نیمه و مرتبسازی بازگشتی هر کدام کار میکند. اگرچه از نظر فنی $O(N^2)$ است (زیرا رفتارش وقتی ورودی مرتب شده به آن داده میشود تنزل میکند)، متوسط زمان اجرای quicksort برابر با $O(N \lg N)$ است.
- ترکیبی (Combinatoric): هرگاه الگوریتمها شروع به بررسی جایگشتهای (permutations) چیزها کنند، زمان اجرای آنها ممکن است از کنترل خارج شود. این به این دلیل است که جایگشتها شامل فاکتوریلها هستند ( $5! = 120$ جایگشت از ارقام ۱ تا ۵ وجود دارد). زمان یک الگوریتم ترکیبی را برای پنج عنصر اندازه بگیرید: برای شش عنصر شش برابر، و برای هفت عنصر ۴۲ برابر بیشتر طول خواهد کشید. مثالها شامل الگوریتمهایی برای بسیاری از مسائل سخت شناخته شده هستند—مسئله فروشنده دورهگرد، بستهبندی بهینه اشیاء در یک ظرف، پارتیشنبندی مجموعهای از اعداد به طوری که هر مجموعه مجموع یکسانی داشته باشد، و غیره. اغلب، از روشهای ابتکاری (heuristics) برای کاهش زمان اجرای این نوع الگوریتمها در دامنههای مسئله خاص استفاده میشود.
سرعت الگوریتم در عمل
بعید است که در طول دوران کاری خود زمان زیادی را صرف نوشتن روتینهای مرتبسازی کنید. روتینهای موجود در کتابخانههای در دسترس شما احتمالاً بهتر از هر چیزی که بدون تلاش قابل توجه بنویسید عمل خواهند کرد.
با این حال، انواع اصلی الگوریتمهایی که قبلاً توضیح دادیم بارها و بارها ظاهر میشوند. هر زمان که خود را در حال نوشتن یک حلقه ساده یافتید، میدانید که یک الگوریتم $O(N)$ دارید. اگر آن حلقه شامل یک حلقه داخلی باشد، آنگاه به $O(N^2)$ نگاه میکنید. باید از خود بپرسید این مقادیر چقدر میتوانند بزرگ شوند. اگر اعداد محدود هستند، آنگاه میدانید کد چقدر طول میکشد اجرا شود. اگر اعداد به عوامل خارجی بستگی دارند (مانند تعداد رکوردها در یک اجرای دستهای شبانه، یا تعداد نامها در لیست افراد)، ممکن است بخواهید متوقف شوید و تأثیر مقادیر بزرگ را بر زمان اجرا یا مصرف حافظه خود در نظر بگیرید.
نکته ۶۳: مرتبه الگوریتمهای خود را تخمین بزنید
رویکردهایی وجود دارد که میتوانید برای رسیدگی به مشکلات احتمالی اتخاذ کنید.
اگر الگوریتمی دارید که $O(N^2)$ است، سعی کنید رویکرد تقسیم و غلبهای پیدا کنید که شما را به $O(N \lg N)$ برساند.
اگر مطمئن نیستید کدتان چقدر طول میکشد، یا چقدر حافظه مصرف میکند، سعی کنید آن را اجرا کنید، و تعداد رکورد ورودی یا هر چیزی که احتمالاً بر زمان اجرا تأثیر میگذارد را تغییر دهید. سپس نتایج را رسم کنید. به زودی باید ایده خوبی از شکل منحنی بدست آورید. آیا با افزایش اندازه ورودی به سمت بالا خم میشود، خط مستقیم است، یا صاف میشود؟ سه یا چهار نقطه باید به شما ایده بدهد.
همچنین در نظر بگیرید دقیقاً چه کاری در خود کد انجام میدهید. یک حلقه ساده $O(N^2)$ ممکن است برای مقادیر کوچکتر $N$ بهتر از یک حلقه پیچیده $O(N \lg N)$ عمل کند، به ویژه اگر الگوریتم $O(N \lg N)$ حلقه داخلی پرهزینهای داشته باشد.
در میان تمام این تئوریها، فراموش نکنید که ملاحظات عملی نیز وجود دارد. زمان اجرا ممکن است برای مجموعههای ورودی کوچک به صورت خطی افزایش یابد. اما میلیونها رکورد به کد بدهید و ناگهان زمان اجرا تنزل میکند زیرا سیستم شروع به Thrashing (مبادله بیش از حد صفحات حافظه) میکند. اگر یک روتین مرتبسازی را با کلیدهای ورودی تصادفی تست کنید، ممکن است اولین باری که با ورودی مرتب شده مواجه میشود شگفتزده شوید. سعی کنید هم پایههای تئوری و هم عملی را پوشش دهید.
پس از تمام این تخمین زدنها، تنها زمانبندیای که به حساب میآید سرعت کد شماست که در محیط Production با دادههای واقعی اجرا میشود. این به نکته بعدی ما منجر میشود.
نکته ۶۴: تخمینهای خود را تست کنید
اگر بدست آوردن زمانبندی دقیق دشوار است، از پروفایلرهای کد (code profilers) برای شمردن تعداد دفعاتی که مراحل مختلف الگوریتم شما اجرا میشوند استفاده کنید و این ارقام را در برابر اندازه ورودی رسم کنید.
بهترین همیشه بهترین نیست
همچنین باید در مورد انتخاب الگوریتمهای مناسب عملگرا باشید—سریعترین همیشه بهترین برای کار نیست. با یک مجموعه ورودی کوچک، یک مرتبسازی درجی (Insertion sort) ساده دقیقاً به خوبی یک مرتبسازی سریع (Quicksort) عمل میکند، و زمان کمتری برای نوشتن و دیباگ کردن از شما میگیرد.
همچنین باید مراقب باشید اگر الگوریتمی که انتخاب میکنید هزینه راهاندازی (Setup cost) بالایی دارد. برای مجموعههای ورودی کوچک، این راهاندازی ممکن است زمان اجرا را تحتالشعاع قرار دهد و الگوریتم را نامناسب کند.
همچنین از بهینهسازی زودرس (Premature optimization) بر حذر باشید. همیشه ایده خوبی است که مطمئن شوید یک الگوریتم واقعاً گلوگاه است قبل از اینکه زمان ارزشمند خود را صرف تلاش برای بهبود آن کنید.
بخشهای مرتبط شامل
- موضوع ۱۵: تخمین زدن (Estimating)
چالشها
هر توسعهدهندهای باید حسی از نحوه طراحی و تحلیل الگوریتمها داشته باشد. رابرت سجویک (Robert Sedgewick) مجموعهای از کتابهای قابل فهم در این زمینه نوشته است (الگوریتمها [SW11]، مقدمهای بر تحلیل الگوریتمها [SF13] و دیگران). ما توصیه میکنیم یکی از کتابهای او را به مجموعه خود اضافه کنید و حتماً آن را بخوانید.
برای کسانی که جزئیات بیشتری نسبت به آنچه سجویک ارائه میدهد دوست دارند، کتابهای قطعی هنر برنامهنویسی کامپیوتر (Art of Computer Programming) دونالد کنوت (Donald Knuth) را بخوانید که طیف وسیعی از الگوریتمها را تحلیل میکنند.
- هنر برنامهنویسی کامپیوتر، جلد ۱: الگوریتمهای بنیادی [Knu98]
- هنر برنامهنویسی کامپیوتر، جلد ۲: الگوریتمهای نیمهعددی [Knu98a]
- هنر برنامهنویسی کامپیوتر، جلد ۳: مرتبسازی و جستجو [Knu98b]
- هنر برنامهنویسی کامپیوتر، جلد ۴A: الگوریتمهای ترکیبی، بخش ۱ [Knu11]
در تمرین اول که در ادامه میآید، ما به مرتبسازی آرایههایی از اعداد صحیح طولانی (long integers) نگاه میکنیم. اگر کلیدها پیچیدهتر باشند و سربار مقایسه کلید بالا باشد، چه تأثیری دارد؟ آیا ساختار کلید بر کارایی الگوریتمهای مرتبسازی تأثیر میگذارد، یا سریعترین مرتبسازی همیشه سریعترین است؟
تمرینها
تمرین ۲۸ (پاسخ ممکن) [54]
ما مجموعهای از روتینهای مرتبسازی ساده را در Rust کدنویسی کردیم. آنها را روی ماشینهای مختلفی که در دسترس دارید اجرا کنید. آیا ارقام شما منحنیهای مورد انتظار را دنبال میکنند؟ چه چیزی میتوانید درباره سرعتهای نسبی ماشینهای خود نتیجه بگیرید؟ اثرات تنظیمات مختلف بهینهسازی کامپایلر چیست؟
تمرین ۲۹ (پاسخ ممکن)
در بخش «تخمین با عقل سلیم»، ما ادعا کردیم که تقسیم باینری (binary chop) برابر با $O(\lg N)$ است. آیا میتوانید این را ثابت کنید؟
تمرین ۳۰ (پاسخ ممکن)
در شکل ۳، زمان اجرای الگوریتمهای مختلف، ما ادعا کردیم که $O(\lg N)$ همان $O(\log N)$ است (یا در واقع لگاریتمها در هر پایهای). آیا میتوانید توضیح دهید چرا؟
موضوع ۴۰: بازآرایی (Refactoring)
تغییر و زوال را در همه اطرافم میبینم...
— اچ. اف. لایت، Abide With Me
با تکامل یک برنامه، بازنگری در تصمیمات اولیه و کار مجدد روی بخشهایی از کد ضروری خواهد شد. این فرآیند کاملاً طبیعی است. کد نیاز به تکامل دارد؛ یک چیز ثابت نیست.
متأسفانه، رایجترین استعاره برای توسعه نرمافزار، ساختوساز ساختمان است. اثر کلاسیک برتراند مایر، ساختوساز شیگرا نرمافزار [Mey97] از اصطلاح «ساختوساز نرمافزار» استفاده میکند، و حتی نویسندگان متواضع شما در اوایل دهه ۲۰۰۰ ستون ساختوساز نرمافزار را برای IEEE Software ویرایش میکردند.
اما استفاده از ساختوساز به عنوان استعاره راهنما دلالت بر مراحل زیر دارد:
۱. یک معمار نقشهها را ترسیم میکند. [55]
۲. پیمانکاران پی را میکنند، روبنا را میسازند، سیمکشی و لولهکشی میکنند، و کارهای نهایی را انجام میدهند.
۳. مستأجران نقل مکان میکنند و با خوشی زندگی میکنند و برای رفع هر مشکلی با تعمیر و نگهداری ساختمان تماس میگیرند.
خب، نرمافزار دقیقاً اینطور کار نمیکند. به جای ساختوساز، نرمافزار بیشتر شبیه باغبانی است—بیشتر ارگانیک است تا بتنی.
شما چیزهای زیادی را در یک باغ طبق یک برنامه و شرایط اولیه میکارید. برخی رشد میکنند، برخی دیگر محکوماند که تبدیل به کمپوست شوند. ممکن است گیاهان را نسبت به یکدیگر جابجا کنید تا از تأثیر متقابل نور و سایه، باد و باران بهره ببرید. گیاهان بیش از حد رشد کرده تقسیم یا هرس میشوند، و رنگهایی که با هم تضاد دارند ممکن است به مکانهای دلپذیرتری از نظر زیباییشناختی منتقل شوند. علفهای هرز را میکنید، و به گیاهانی که نیاز به کمک اضافی دارند کود میدهید. شما دائماً سلامت باغ را زیر نظر دارید، و تنظیمات لازم (به خاک، گیاهان، چیدمان) را انجام میدهید.
افراد تجاری با استعاره ساختوساز ساختمان راحت هستند: علمیتر از باغبانی است، تکرارپذیر است، سلسله مراتب گزارشدهی سفت و سختی برای مدیریت وجود دارد، و غیره. اما ما آسمانخراش نمیسازیم—ما به اندازه مرزهای فیزیک و دنیای واقعی محدود نیستیم. استعاره باغبانی بسیار به واقعیتهای توسعه نرمافزار نزدیکتر است.
شاید یک روتین خاص بیش از حد بزرگ شده باشد، یا سعی دارد کارهای زیادی انجام دهد—نیاز دارد به دو قسمت تقسیم شود. چیزهایی که طبق برنامه پیش نمیروند نیاز به وجین یا هرس دارند.
بازنویسی، کار مجدد، و معماری مجدد کد به طور جمعی به عنوان تجدید ساختار (Restructuring) شناخته میشود. اما زیرمجموعهای از آن فعالیت وجود دارد که به عنوان بازآرایی (Refactoring) تمرین میشود.
بازآرایی [Fow19] توسط مارتین فاولر اینگونه تعریف میشود:
تکنیکی منضبط برای تجدید ساختار بدنه کد موجود، با تغییر ساختار داخلی آن بدون تغییر رفتار خارجی آن.
بخشهای حیاتی این تعریف عبارتند از:
۱. فعالیت منضبط است، نه هرجومرج و بیقانون.
۲. رفتار خارجی تغییر نمیکند؛ این زمان اضافه کردن ویژگیها نیست.
بازآرایی قرار نیست یک فعالیت ویژه، با تشریفات زیاد و گاهبهگاه باشد، مثل شخم زدن کل باغ برای کاشت مجدد. در عوض، بازآرایی یک فعالیت روزمره است، با برداشتن گامهای کوچک و کمخطر، بیشتر شبیه وجین کردن و شنکشی. به جای بازنویسی کامل و کلی پایگاه کد، این یک رویکرد هدفمند و دقیق برای کمک به آسان نگه داشتن تغییر کد است.
به منظور تضمین اینکه رفتار خارجی تغییر نکرده است، شما به تست واحد (Unit Testing) خودکار و خوب نیاز دارید که رفتار کد را اعتبارسنجی کند.
چه زمانی باید بازآرایی کنید؟
شما زمانی بازآرایی میکنید که چیزی یاد گرفتهاید؛ وقتی چیزی را بهتر از سال گذشته، دیروز، یا حتی همین ده دقیقه پیش میفهمید.
شاید به مانعی برخورد کردهاید چون کد دیگر کاملاً مناسب نیست، یا متوجه دو چیز شدهاید که واقعاً باید ادغام شوند، یا هر چیز دیگری که به نظرتان «اشتباه» میآید، در تغییر آن تردید نکنید. هیچ زمانی بهتر از اکنون نیست.
تعداد زیادی از موارد ممکن است باعث شود کد واجد شرایط بازآرایی شود:
- تکرار: شما نقض اصل DRY را کشف کردهاید.
- طراحی غیر متعامد: چیزی را کشف کردهاید که میتواند متعامدتر (Orthogonal) شود.
- دانش منسوخ: چیزها تغییر میکنند، نیازمندیها منحرف میشوند، و دانش شما از مسئله افزایش مییابد. کد باید همگام باشد.
- استفاده: همانطور که سیستم توسط افراد واقعی در شرایط واقعی استفاده میشود، متوجه میشوید برخی ویژگیها اکنون مهمتر از آنچه قبلاً فکر میکردید هستند، و ویژگیهای «باید داشته باشد» شاید نبودند.
- عملکرد: نیاز دارید عملکردی را از یک ناحیه سیستم به ناحیه دیگر منتقل کنید تا عملکرد (Performance) بهبود یابد.
- تستها پاس میشوند: بله. جدی میگوییم. ما گفتیم که بازآرایی باید یک فعالیت در مقیاس کوچک باشد که با تستهای خوب پشتیبانی میشود. پس وقتی مقدار کمی کد اضافه کردهاید، و آن یک تست اضافی پاس شد، اکنون فرصت عالی دارید تا شیرجه بزنید و آنچه را که تازه نوشتید مرتب و تمیز کنید.
بازآرایی کد شما—جابجا کردن عملکرد و بهروزرسانی تصمیمات قبلی—واقعاً تمرینی در مدیریت درد است. بیایید رو راست باشیم، تغییر دادن کد منبع میتواند کاملاً دردناک باشد: کار میکرد، شاید بهتر باشد دست به ترکیب برنده نزنیم. بسیاری از توسعهدهندگان تمایلی ندارند که بروند و یک قطعه کد را دوباره باز کنند فقط به این دلیل که کاملاً درست نیست.
پیچیدگیهای دنیای واقعی
پس شما پیش همتیمیها یا مشتری خود میروید و میگویید: «این کد کار میکند، اما من یک هفته دیگر وقت لازم دارم تا آن را کاملاً بازآرایی کنم.» ما نمیتوانیم پاسخ آنها را چاپ کنیم.
فشار زمان اغلب به عنوان بهانهای برای بازآرایی نکردن استفاده میشود. اما این بهانه واقعاً قابل قبول نیست: اگر الان بازآرایی نکنید، در آینده سرمایهگذاری زمانی بسیار بیشتری برای رفع مشکل لازم خواهد بود—زمانی که وابستگیهای (Dependencies) بیشتری وجود دارد که باید با آنها دست و پنجه نرم کرد. آیا آن موقع زمان بیشتری در دسترس خواهد بود؟ خیر.
ممکن است بخواهید این اصل را با استفاده از یک قیاس پزشکی برای دیگران توضیح دهید: به کدی که نیاز به بازآرایی دارد به عنوان «یک توده» (Growth) فکر کنید. برداشتن آن نیاز به جراحی تهاجمی دارد. میتوانید الان بروید و تا زمانی که هنوز کوچک است آن را خارج کنید. یا، میتوانید صبر کنید تا رشد کند و پخش شود—اما برداشتن آن در آن زمان هم گرانتر و هم خطرناکتر خواهد بود. حتی بیشتر صبر کنید، و ممکن است بیمار را به طور کامل از دست بدهید.
نکته ۶۵: زود بازآرایی کنید، زیاد بازآرایی کنید
آسیب جانبی در کد میتواند در طول زمان به همان اندازه کشنده باشد (نگاه کنید به موضوع ۳، آنتروپی نرمافزار). بازآرایی، مانند اکثر چیزها، زمانی که مسائل کوچک هستند آسانتر انجام میشود، به عنوان یک فعالیت مداوم در حین کدنویسی.
شما نباید نیاز به «یک هفته برای بازآرایی» یک قطعه کد داشته باشید—این یک بازنویسی کامل است. اگر آن سطح از اختلال لازم است، پس ممکن است واقعاً نتوانید بلافاصله آن را انجام دهید. در عوض، مطمئن شوید که در برنامه زمانبندی قرار میگیرد. مطمئن شوید که کاربران کد تحت تأثیر میدانند که قرار است بازنویسی شود و این ممکن است چه تأثیری بر آنها داشته باشد.
چگونه بازآرایی میکنید؟
بازآرایی در جامعه Smalltalk شروع شد و زمانی که ما ویرایش اول این کتاب را نوشتیم، تازه شروع به بدست آوردن مخاطبان وسیعتری کرده بود، احتمالاً به لطف اولین کتاب بزرگ در زمینه بازآرایی (بازآرایی: بهبود طراحی کد موجود [Fow19]، که اکنون در ویرایش دوم خود است).
در قلب خود، بازآرایی طراحی مجدد (Redesign) است. هر چیزی که شما یا دیگران در تیمتان طراحی کردهاید میتواند در پرتو حقایق جدید، درک عمیقتر، تغییر نیازمندیها و غیره دوباره طراحی شود. اما اگر شروع کنید به پاره کردن مقادیر عظیمی از کد با بیباکی وحشیانه، ممکن است خودتان را در موقعیتی بدتر از شروع کار بیابید.
واضح است که بازآرایی فعالیتی است که باید به آرامی، عامدانه و با دقت انجام شود. مارتین فاولر نکات ساده زیر را در مورد چگونگی بازآرایی بدون انجام آسیب بیشتر نسبت به خیر ارائه میدهد: [56]
۱. سعی نکنید همزمان بازآرایی کنید و ویژگی (Functionality) اضافه کنید.
۲. مطمئن شوید که قبل از شروع بازآرایی تستهای خوبی دارید. تستها را تا حد امکان اجرا کنید. به این ترتیب سریعاً خواهید فهمید که آیا تغییرات شما چیزی را خراب کرده است یا خیر.
۳. گامهای کوتاه و عامدانه بردارید: یک فیلد را از یک کلاس به کلاس دیگر منتقل کنید، یک متد را تقسیم کنید، نام یک متغیر را تغییر دهید. بازآرایی اغلب شامل انجام بسیاری از تغییرات محلی است که منجر به تغییری در مقیاس بزرگتر میشود. اگر گامهای خود را کوچک نگه دارید و بعد از هر گام تست کنید، از دیباگ طولانی مدت اجتناب خواهید کرد. [57]
بازآرایی خودکار
در ویرایش اول اشاره کردیم که «این تکنولوژی هنوز خارج از دنیای Smalltalk ظاهر نشده است، اما احتمالاً این تغییر خواهد کرد...». و در واقع، تغییر کرد، زیرا بازآرایی خودکار در بسیاری از IDEها و برای اکثر زبانهای اصلی در دسترس است.
این IDEها میتوانند نام متغیرها و متدها را تغییر دهند، یک روتین طولانی را به روتینهای کوچکتر تقسیم کنند، تغییرات لازم را به طور خودکار منتشر کنند، برای کمک به جابجایی کد از کشیدن و رها کردن (drag and drop) استفاده کنند و غیره.
ما در موضوع ۴۱، تست برای کدنویسی، درباره تست کردن در این سطح، و در تست بیرحمانه و مداوم درباره تست در مقیاس بزرگتر بیشتر صحبت خواهیم کرد، اما نکته آقای فاولر در مورد حفظ تستهای رگرسیون خوب، کلید بازآرایی ایمن است.
اگر مجبورید فراتر از بازآرایی بروید و در نهایت رفتار خارجی یا رابطها را تغییر دهید، آنگاه میتواند کمک کند که عامدانه بیلد را بشکنید: کلاینتهای قدیمی این کد نباید کامپایل شوند. به این ترتیب خواهید دانست چه چیزی نیاز به بهروزرسانی دارد.
دفعه بعد که قطعه کدی را دیدید که کاملاً آنطور که باید نیست، آن را درست کنید. درد را مدیریت کنید: اگر الان درد دارد، اما قرار است بعداً بیشتر درد داشته باشد، بهتر است همین الان کار را تمام کنید. درسهای موضوع ۳، آنتروپی نرمافزار را به یاد داشته باشید: با پنجرههای شکسته زندگی نکنید.
بخشهای مرتبط شامل
- موضوع ۳: آنتروپی نرمافزار (Software Entropy)
- موضوع ۹: DRY—بدیهای تکرار (DRY—The Evils of Duplication)
- موضوع ۱۲: گلولههای رسام (Tracer Bullets)
- موضوع ۲۷: از چراغهای جلوی خود جلو نزنید (Don’t Outrun Your Headlights)
- موضوع ۴۴: نامگذاری چیزها (Naming Things)
- موضوع ۴۸: جوهره چابکی (The Essence of Agility)
موضوع ۴۱: تست برای کدنویسی
اولین نسخه این کتاب در دوران بدویتری نوشته شد، زمانی که اکثر توسعهدهندگان هیچ تستی نمینوشتند—آنها فکر میکردند چه کاری است، به هر حال دنیا در سال ۲۰۰۰ به پایان میرسد. در آن کتاب، ما بخشی داشتیم در مورد اینکه چگونه کدی بسازیم که تست آن آسان باشد. این یک راه زیرکانه برای متقاعد کردن توسعهدهندگان به نوشتن تست بود.
اکنون دوران روشنتری است. اگر توسعهدهندگانی هنوز وجود دارند که تست نمینویسند، حداقل میدانند که باید بنویسند. اما هنوز یک مشکل وجود دارد. وقتی از توسعهدهندگان میپرسیم چرا تست مینویسند، طوری به ما نگاه میکنند که انگار پرسیدهایم آیا هنوز از کارت پانچ استفاده میکنند و میگویند «برای اینکه مطمئن شویم کد کار میکند» (و یک «احمق» ناگفته در انتها اضافه میکنند).
و ما فکر میکنیم این اشتباه است. پس ما چه چیزی را در مورد تست کردن مهم میدانیم؟ و فکر میکنیم شما چگونه باید این کار را انجام دهید؟ بیایید با یک بیانیه جسورانه شروع کنیم:
نکته ۶۶: تست کردن درباره پیدا کردن باگ نیست
ما معتقدیم که مزایای اصلی تست کردن زمانی اتفاق میافتد که شما در مورد تستها فکر میکنید و آنها را مینویسید، نه زمانی که آنها را اجرا میکنید.
فکر کردن درباره تستها
صبح دوشنبه است و شما برای کار روی مقداری کد جدید مستقر میشوید. باید چیزی بنویسید که از پایگاه داده پرسوجو کند تا لیستی از افرادی را که بیش از ۱۰ ویدیو در هفته در سایت شما تماشا میکنند، برگرداند. ادیتور خود را روشن میکنید و با نوشتن تابعی که کوئری را اجرا میکند شروع میکنید:
def return_avid_viewers do
# ... hmmm ...
end
بایستید! از کجا میدانید کاری که میخواهید انجام دهید چیز خوبی است؟ پاسخ این است که نمیتوانید بدانید. هیچ کس نمیتواند. اما فکر کردن درباره تستها میتواند آن را محتملتر کند.
این کار به این صورت انجام میشود: با تصور اینکه نوشتن تابع را تمام کردهاید و حالا باید آن را تست کنید، شروع کنید. چگونه این کار را انجام میدهید؟ خب، شما میخواهید از مقداری داده تست استفاده کنید، که احتمالاً به این معنی است که میخواهید در یک پایگاه داده که کنترلش دست شماست کار کنید. حالا برخی فریمورکها میتوانند این کار را برای شما انجام دهند، اما در مورد ما این به این معنی است که ما باید نمونه پایگاه داده (database instance) را به تابع خود پاس دهیم نه اینکه از یک نمونه سراسری (Global) استفاده کنیم، زیرا این به ما اجازه میدهد هنگام تست آن را تغییر دهیم:
def return_avid_users(db) do
سپس باید فکر کنیم که چگونه آن داده تست را پر کنیم. نیازمندی میگوید «لیستی از افرادی که بیش از ۱۰ ویدیو در هفته تماشا میکنند». بنابراین به طرحواره (schema) پایگاه داده برای فیلدهایی که ممکن است کمک کنند نگاه میکنیم. دو فیلد محتمل در جدولِ چه-کسی-چه-چیزی-تماشا-کرده پیدا میکنیم: opened_video و completed_video. برای نوشتن داده تست، باید بدانیم از کدام فیلد استفاده کنیم. اما معنی دقیق نیازمندی را نمیدانیم و رابط تجاری ما هم حضور ندارد. بیایید تقلب کنیم و نام فیلد را به عنوان پارامتر پاس دهیم (که به ما اجازه میدهد آنچه داریم را تست کنیم و بالقوه بعداً تغییر دهیم):
def return_avid_users(db, qualifying_field_name) do
ما با فکر کردن درباره تستهایمان شروع کردیم، و بدون نوشتن حتی یک خط کد، قبلاً دو کشف انجام دادیم و از آنها برای تغییر API متد خود استفاده کردیم.
تستها کدنویسی را هدایت میکنند
در مثال قبلی، فکر کردن درباره تست باعث شد کوپلینگ را در کد کاهش دهیم (با پاس دادن اتصال پایگاه داده به جای استفاده از نوع سراسری) و انعطافپذیری را افزایش دهیم (با پارامتر کردن نام فیلدی که تست میکنیم).
فکر کردن درباره نوشتن تست برای متدمان باعث شد از بیرون به آن نگاه کنیم، انگار که کلاینت کد هستیم، نه نویسنده آن.
نکته ۶۷: یک تست، اولین کاربر کد شماست
ما فکر میکنیم این احتمالاً بزرگترین فایدهای است که تست ارائه میدهد: تست کردن بازخوردی حیاتی است که کدنویسی شما را هدایت میکند.
یک تابع یا متد که به شدت به کد دیگر وابسته (coupled) است، تست کردنش سخت است، زیرا باید تمام آن محیط را قبل از اینکه بتوانید حتی متد خود را اجرا کنید، راهاندازی نمایید. بنابراین، قابل تست کردن چیزها، کوپلینگ آنها را نیز کاهش میدهد.
و قبل از اینکه بتوانید چیزی را تست کنید، باید آن را درک کنید. این احمقانه به نظر میرسد، اما در واقعیت همه ما بر اساس درکی مبهم از آنچه باید انجام میدادیم، وارد یک قطعه کد شدهایم. به خودمان اطمینان میدهیم که در طول مسیر آن را حل خواهیم کرد. و کد پنج برابر طولانیتر از آنچه باید باشد میشود زیرا پر از منطق شرطی و موارد خاص است.
اما نور یک تست را روی آن کد بتابانید، و همه چیز واضحتر میشود. اگر قبل از شروع کدنویسی درباره تست شرایط مرزی و نحوه کار آن فکر کنید، ممکن است الگوهایی را در منطق پیدا کنید که تابع را ساده میکند.
توسعه هدایتشده با تست (TDD)
مکتبی در برنامهنویسی وجود دارد که میگوید، با توجه به تمام مزایای فکر کردن درباره تستها در ابتدا، چرا جلو نرویم و آنها را همان اول ننویسیم؟ آنها چیزی به نام توسعه هدایتشده با تست یا TDD را تمرین میکنند (که گاهی «توسعه تست-اول» نیز نامیده میشود). [58]
چرخه اساسی TDD عبارت است از:
- در مورد یک قطعه کوچک از عملکردی که میخواهید اضافه کنید تصمیم بگیرید.
- تستی بنویسید که پس از پیادهسازی آن عملکرد پاس شود.
- تمام تستها را اجرا کنید. تأیید کنید که تنها شکست مربوط به تستی است که تازه نوشتید.
- کمترین مقدار کد لازم برای پاس شدن تست را بنویسید و تأیید کنید که تستها اکنون بدون مشکل اجرا میشوند.
- کد خود را بازآرایی کنید: ببینید آیا راهی برای بهبود آنچه نوشتید (تست یا تابع) وجود دارد. مطمئن شوید وقتی کارتان تمام شد تستها هنوز پاس میشوند.
این چرخه باید بسیار کوتاه باشد: در حد چند دقیقه.
ما مزیت بزرگی در TDD برای افرادی که تازه شروع به تست کردن کردهاند میبینیم. اگر جریان کاری TDD را دنبال کنید، تضمین میکنید که همیشه برای کد خود تست دارید. اما دیدهایم که برخی افراد برده TDD میشوند. آنها زمان زیادی را صرف تضمین پوشش ۱۰۰٪ تست میکنند یا تستهای زائد زیادی مینویسند.
بالا-به-پایین در مقابل پایین-به-بالا در مقابل روشی که باید انجام دهید
نه روش بالا-به-پایین (Top-down) و نه پایین-به-بالا (Bottom-up) واقعاً کار نمیکنند، زیرا هر دو یکی از مهمترین جنبههای توسعه نرمافزار را نادیده میگیرند: ما وقتی شروع میکنیم نمیدانیم داریم چه کار میکنیم.
نکته ۶۸: پایان-به-پایان بسازید، نه بالا-به-پایین یا پایین-به-بالا
ما قویاً معتقدیم که تنها راه ساخت نرمافزار، به صورت افزایشی (Incrementally) است. قطعات کوچک از عملکرد پایان-به-پایان (End-to-end functionality) بسازید، و در حین پیشروی در مورد مسئله یاد بگیرید.
حتماً TDD را تمرین کنید. اما اگر این کار را میکنید، فراموش نکنید که هر از گاهی بایستید و به تصویر بزرگ نگاه کنید. آسان است که فریب پیام سبز «تستها پاس شد» را بخورید و کد زیادی بنویسید که واقعاً شما را به راهحل نزدیکتر نمیکند.
بازگشت به کد
همانند همکاران سختافزاری خود، ما باید قابلیت تست (Testability) را از همان ابتدا در نرمافزار تعبیه کنیم و هر قطعه را قبل از تلاش برای وصل کردن آنها به هم، به طور کامل تست کنیم.
تست واحد (Unit Testing)
تست سطح چیپ در سختافزار تقریباً معادل تست واحد در نرمافزار است—تستی که روی هر ماژول، در انزوا، برای تأیید رفتار آن انجام میشود.
یک تست واحد نرمافزاری کدی است که یک ماژول را تمرین میدهد. معمولاً، تست واحد نوعی محیط مصنوعی ایجاد میکند، سپس روتینهای ماژول مورد تست را فراخوانی میکند. سپس نتایج بازگردانده شده را بررسی میکند.
تست در برابر قرارداد
ما دوست داریم به تست واحد به عنوان تست در برابر قرارداد (Test against contract) فکر کنیم (نگاه کنید به موضوع ۲۳، طراحی بر اساس قرارداد). ما میخواهیم موارد تستی بنویسیم که اطمینان حاصل کنند یک واحد معین به قرارداد خود احترام میگذارد.
بیایید با یک مثال ساده عددی شروع کنیم: یک روتین ریشه دوم (Square root).
قرارداد مستند شده آن ساده است:
- پیششرطها: آرگومان >= ۰
- پسشرطها:
((result * result) - argument).abs <= epsilon * argument
این به ما میگوید چه چیزی را تست کنیم:
- آرگومان منفی پاس دهید و اطمینان حاصل کنید که رد میشود.
- آرگومان صفر پاس دهید تا مطمئن شوید پذیرفته میشود (مقدار مرزی).
- مقادیری بین صفر و حداکثر مجاز پاس دهید و صحت نتیجه را با اپسیلون بررسی کنید.
assertWithinEpsilon(my_sqrt(0), 0)
assertWithinEpsilon(my_sqrt(2.0), 1.4142135624)
assertWithinEpsilon(my_sqrt(64.0), 8.0)
assertWithinEpsilon(my_sqrt(1.0e7), 3162.2776602)
assertRaisesException fn => my_sqrt(-4.0) end
در دنیای واقعی، ماژولها به ماژولهای دیگر وابسته هستند. فرض کنید ماژول A از DataFeed و LinearRegression استفاده میکند. به ترتیب، ما تست خواهیم کرد:
۱. قرارداد DataFeed، به طور کامل.
۲. قرارداد LinearRegression، به طور کامل.
۳. قرارداد A، که به قراردادهای دیگر متکی است اما مستقیماً آنها را در معرض نمایش نمیگذارد.
این سبک تست کردن مستلزم آن است که ابتدا زیرمؤلفههای (subcomponents) یک ماژول را تست کنید. این تکنیک راهی عالی برای کاهش تلاش دیباگینگ است.
نکته ۶۹: برای تست کردن طراحی کنید
تست Ad Hoc
تست Ad hoc زمانی است که ما به صورت دستی به کد خود سیخ میزنیم. این ممکن است به سادگی یک console.log() یا کدی که به صورت تعاملی در دیباگر یا REPL وارد میشود باشد. در پایان جلسه دیباگینگ، باید این تست موقت را رسمی کنید. اگر کد یک بار شکست، احتمال دارد دوباره بشکند. تستی را که ایجاد کردید دور نیندازید؛ آن را به زرادخانه تست واحد موجود اضافه کنید.
فرهنگ تست کردن
تمام نرمافزاری که مینویسید تست خواهد شد—اگر نه توسط شما و تیمتان، پس توسط کاربران نهایی—پس بهتر است برای تست کامل آن برنامهریزی کنید.
شما واقعاً فقط چند انتخاب دارید:
- تست در ابتدا (Test First)
- تست در حین کار (Test During)
- تست هرگز (Test Never)
تست در ابتدا، احتمالاً بهترین انتخاب شماست. تست در حین کدنویسی میتواند جایگزین خوبی باشد. بدترین انتخاب اغلب «تست بعداً» نامیده میشود، اما چه کسی را گول میزنید؟ «تست بعداً» واقعاً یعنی «تست هرگز».
یک فرهنگ تست کردن یعنی تمام تستها همیشه پاس میشوند. نادیده گرفتن انبوهی از تستها که «همیشه شکست میخورند» باعث میشود نادیده گرفتن تمام تستها آسانتر شود (نگاه کنید به موضوع ۳، آنتروپی نرمافزار).
با کد تست با همان مراقبتی رفتار کنید که با هر کد پروداکشنی رفتار میکنید. آن را جدا (decoupled)، تمیز و قوی نگه دارید. به چیزهای غیرقابل اعتماد تکیه نکنید.
نکته ۷۰: نرمافزار خود را تست کنید، وگرنه کاربران شما این کار را خواهند کرد
اشتباه نکنید، تست کردن بخشی از برنامهنویسی است. تست کردن، طراحی، کدنویسی—همه اینها برنامهنویسی هستند.
بخشهای مرتبط شامل
- موضوع ۲۷: از چراغهای جلوی خود جلو نزنید (Don’t Outrun Your Headlights)
- موضوع ۵۱: کیت شروع عملگرا (Pragmatic Starter Kit)
موضوع ۴۲: تست مبتنی بر ویژگی (Property-Based Testing)
اعتماد کن، اما راستیآزمایی کن. (Trust, but verify)
— ضربالمثل روسی
ما توصیه میکنیم برای توابع خود تست واحد (Unit Test) بنویسید. شما این کار را با فکر کردن به موارد معمولی که ممکن است مشکلساز باشند، بر اساس دانش خود از چیزی که تست میکنید، انجام میدهید.
با این حال، یک مشکل کوچک اما بالقوه قابل توجه در آن پاراگراف نهفته است. اگر شما کد اصلی را مینویسید و تستها را هم خودتان مینویسید، آیا ممکن است یک فرض نادرست در هر دو بیان شده باشد؟ کد تستها را پاس میکند، زیرا بر اساس درک شما، کاری را که قرار است انجام دهد، انجام میدهد.
یک راه حل این است که افراد متفاوتی تستها و کد تحت تست را بنویسند، اما ما این را دوست نداریم: همانطور که در موضوع ۴۱، تست برای کدنویسی گفتیم، یکی از بزرگترین مزایای فکر کردن به تستها، روشی است که کد شما را آگاه میکند. وقتی کار تست کردن از کدنویسی جدا میشود، شما آن مزیت را از دست میدهید.
در عوض، ما طرفدار یک جایگزین هستیم، جایی که کامپیوتر، که پیشفرضهای شما را ندارد، مقداری تست برای شما انجام میدهد.
قراردادها، نامتغیرها و ویژگیها
در موضوع ۲۳، طراحی بر اساس قرارداد، در مورد این ایده صحبت کردیم که کد قراردادهایی دارد که باید رعایت کند: وقتی ورودی به آن میدهید شرایط را برآورده میکنید، و آن تضمینهای خاصی در مورد خروجیهایی که تولید میکند خواهد داد.
همچنین نامتغیرهای (Invariants) کد وجود دارند، چیزهایی که در مورد بخشی از وضعیت (state) وقتی از تابعی عبور میکند، درست باقی میمانند. برای مثال، اگر لیستی را مرتب کنید، نتیجه همان تعداد عنصر اصلی را خواهد داشت—طول لیست نامتغیر است.
وقتی قراردادها و نامتغیرهای خود را (که ما آنها را با هم جمع میکنیم و ویژگیها (Properties) مینامیم) پیدا کردیم، میتوانیم از آنها برای خودکارسازی تستهایمان استفاده کنیم. کاری که در نهایت انجام میدهیم تست مبتنی بر ویژگی نامیده میشود.
نکته ۷۱: از تستهای مبتنی بر ویژگی برای اعتبارسنجی فرضیات خود استفاده کنید
به عنوان یک مثال مصنوعی، میتوانیم تستهایی برای لیست مرتبشده خود بسازیم. ما قبلاً یک ویژگی را تعیین کردهایم: لیست مرتبشده همان اندازه لیست اصلی است. همچنین میتوانیم بیان کنیم که هیچ عنصری در نتیجه نمیتواند بزرگتر از عنصری باشد که بعد از آن میآید. حالا میتوانیم این را در کد بیان کنیم.
اکثر زبانها نوعی فریمورک تست مبتنی بر ویژگی دارند. این مثال در پایتون است و از ابزار Hypothesis و pytest استفاده میکند، اما اصول تقریباً جهانی هستند.
در اینجا منبع کامل تستها آمده است:
from hypothesis import given
import hypothesis.strategies as some
@given(some.lists(some.integers()))
def test_list_size_is_invariant_across_sorting(a_list):
original_length = len(a_list)
a_list.sort()
assert len(a_list) == original_length
@given(some.lists(some.text()))
def test_sorted_result_is_ordered(a_list):
a_list.sort()
for i in range(len(a_list) - 1):
assert a_list[i] <= a_list[i + 1]
این چیزی است که وقتی آن را اجرا میکنیم اتفاق میافتد:
$ pytest sort.py
======================= test session starts ========================
... plugins: hypothesis-4.14.0
sort.py .. [100%]
===================== 2 passed in 0.95 seconds =====================
درام زیادی آنجا نیست. اما در پشت صحنه، Hypothesis هر دو تست ما را صد بار اجرا کرد و هر بار لیست متفاوتی را پاس داد. لیستها طولهای متفاوتی خواهند داشت و محتویات متفاوتی خواهند داشت. مثل این است که ما ۲۰۰ تست جداگانه با ۲۰۰ لیست تصادفی پخته باشیم.
تولید داده تست
مانند اکثر کتابخانههای تست مبتنی بر ویژگی، Hypothesis یک زبان کوچک برای توصیف دادههایی که باید تولید کند به شما میدهد. زبان حول فراخوانی توابع در ماژول hypothesis.strategies میچرخد، که ما آن را به عنوان some نامگذاری کردیم، فقط به این دلیل که خواناتر است.
اگر بنویسیم:
@given(some.integers())
تابع تست ما چندین بار اجرا خواهد شد. هر بار یک عدد صحیح متفاوت به آن پاس داده میشود.
اگر در عوض بنویسیم:
@given(some.integers(min_value=5, max_value=10).map(lambda x: x * 2))
آنگاه اعداد زوج بین ۱۰ و ۲۰ را دریافت خواهیم کرد.
همچنین میتوانید انواع را ترکیب کنید، به طوری که:
@given(some.lists(some.integers(min_value=1), max_size=100))
لیستهایی از اعداد طبیعی خواهد بود که حداکثر ۱۰۰ عنصر طول دارند.
این قرار نیست آموزشی برای یک فریمورک خاص باشد، بنابراین ما از جزئیات جالب صرفنظر میکنیم و در عوض به یک مثال دنیای واقعی نگاه میکنیم.
پیدا کردن فرضیات بد
ما در حال نوشتن یک سیستم ساده پردازش سفارش و کنترل موجودی هستیم (چون همیشه جا برای یکی دیگر هست). این سیستم سطح موجودی را با یک شیء Warehouse (انبار) مدل میکند. میتوانیم از انبار پرسوجو کنیم تا ببینیم چیزی در انبار موجود است یا خیر، چیزهایی را از انبار برداریم، و سطح موجودی فعلی را بگیریم.
این کد کلاس Warehouse است:
class Warehouse:
def __init__(self, stock):
self.stock = stock
def in_stock(self, item_name):
return (item_name in self.stock) and (self.stock[item_name] > 0)
def take_from_stock(self, item_name, quantity):
if quantity <= self.stock[item_name]:
self.stock[item_name] -= quantity
else:
raise Exception("Oversold {}".format(item_name))
def stock_count(self, item_name):
return self.stock[item_name]
ما یک تست واحد پایه نوشتیم که پاس میشود (کد test_warehouse که موجودی کفش و کلاه را چک میکند).
سپس تابعی نوشتیم که درخواست سفارش اقلام از انبار را پردازش میکند (order). این تابع تاپلی را برمیگرداند که عنصر اول آن یا "ok" یا "not available" است. ما همچنین تستهایی نوشتیم (test_order_in_stock، test_order_not_in_stock و...) و آنها پاس میشوند.
در ظاهر، همه چیز خوب به نظر میرسد. اما قبل از اینکه کد را شیپ کنیم (ارائه دهیم)، بیایید مقداری تست ویژگی اضافه کنیم.
یک چیزی که میدانیم این است که موجودی نمیتواند در طول تراکنش ما ظاهر یا ناپدید شود. این بدان معناست که اگر ما تعدادی کالا از انبار برداریم، تعدادی که برداشتیم به علاوه تعدادی که در حال حاضر در انبار است باید برابر با تعدادی باشد که در ابتدا در انبار بود.
در تست زیر، ما تستمانی را با پارامتر کالا که به صورت تصادفی از "کلاه" یا "کفش" انتخاب شده و مقدار که از ۱ تا ۴ انتخاب شده اجرا میکنیم:
@given(item = some.sampled_from(["shoes", "hats"]),
quantity = some.integers(min_value=1, max_value=4))
def test_stock_level_plus_quantity_equals_original_stock_level(item, quantity):
wh = Warehouse({"shoes": 10, "hats": 2, "umbrellas": 0})
initial_stock_level = wh.stock_count(item)
(status, item, quantity) = order(wh, item, quantity)
if status == "ok":
assert wh.stock_count(item) + quantity == initial_stock_level
بیایید آن را اجرا کنیم:
$ pytest stock.py
. . . stock.py:72: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
stock.py:76: in test_stock_level_plus_quantity_equals_original_stock_level
(status, item, quantity) = order(wh, item, quantity)
stock.py:40: in order
warehouse.take_from_stock(item, quantity)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <stock.Warehouse object at 0x10...>
(ادامه خروجی خطا نشان میدهد که یک استثنا رخ داده است، احتمالاً به دلیل تلاش برای برداشتن بیشتر از موجودی، که تستهای واحد معمولی آن را پوشش نداده بودند زیرا فقط مقادیر خاصی را تست میکردند اما تست ویژگی با مقادیر تصادفی توانست حالتهای مرزی را پیدا کند)
موضوع ۴۳: آنجا بیرون، ایمن بمانید
حصارهای خوب، همسایههای خوب میسازند.
— رابرت فراست، دیوار تعمیری
در بحث کوپلینگ کد در نسخه اول این کتاب، ما بیانیهای جسورانه و سادهلوحانه داشتیم: «ما نیازی نداریم به اندازه جاسوسان یا مخالفان سیاسی پارانوئید باشیم.»
ما اشتباه میکردیم. در واقع، شما باید هر روز به همان اندازه پارانوئید باشید.
در حالی که ما این را مینویسیم، اخبار روزانه پر از داستانهای نقض دادههای ویرانگر، سیستمهای ربوده شده و کلاهبرداریهای سایبری است. صدها میلیون رکورد به یکباره دزدیده میشوند، میلیاردها و میلیاردها دلار ضرر و هزینه جبران خسارت—و این ارقام هر سال به سرعت در حال رشد هستند.
در اکثریت قریب به اتفاق موارد، دلیل این نیست که مهاجمان بسیار باهوش یا حتی کمی باصلاحیت بودند. دلیل این است که توسعهدهندگان بیدقت بودند.
۹۰ درصد دیگر [62]
هنگام کدنویسی، ممکن است چندین چرخه از «کار میکند!» و «چرا کار نمیکند؟» را با گاهی اوقات «امکان ندارد این اتفاق افتاده باشد...» طی کنید. پس از چندین فراز و نشیب در این صعود دشوار، آسان است که به خودتان بگویید، «آخیش، همه چیز کار میکند!» و اعلام کنید که کد تمام شده است.
البته، هنوز تمام نشده است. شما ۹۰٪ کار را انجام دادهاید، اما اکنون باید ۹۰٪ دیگر را در نظر بگیرید.
کار بعدی که باید انجام دهید این است که کد را برای راههایی که ممکن است اشتباه شود تحلیل کنید و آنها را به مجموعه تست خود اضافه کنید. شما چیزهایی مانند پاس دادن پارامترهای بد، نشت منابع یا در دسترس نبودن منابع و این قبیل چیزها را در نظر خواهید گرفت.
در روزهای خوب گذشته، این ارزیابی از خطاهای داخلی ممکن بود کافی باشد. اما امروزه این فقط شروع است، زیرا علاوه بر خطاها از علل داخلی، باید در نظر بگیرید که چگونه یک عامل خارجی میتواند عمداً سیستم را خراب کند.
شاید اعتراض کنید، «اوه، هیچ کس به این کد اهمیت نمیدهد، مهم نیست، هیچ کس حتی در مورد این سرور نمیداند...»
دنیای بزرگی است و بیشتر آن به هم متصل است. خواه یک بچه حوصلهسررفته در آن سوی کره زمین باشد، تروریسم دولتی، باندهای جنایتکار، جاسوسی شرکتی، یا حتی یک شریک سابق انتقامجو، آنها آنجا هستند و شما را هدف گرفتهاند. زمان بقای یک سیستم پچنشده و قدیمی در شبکه باز در دقیقه—یا حتی کمتر—اندازهگیری میشود. امنیت از طریق ابهام (Security through obscurity) اصلاً کار نمیکند.
اصول اولیه امنیت
برنامهنویسان عملگرا مقدار سالمی از پارانویا دارند. ما میدانیم که نقصها و محدودیتهایی داریم و مهاجمان خارجی از هر روزنهای که باقی بگذاریم برای به خطر انداختن سیستمهای ما استفاده خواهند کرد.
محیطهای توسعه و استقرار خاص شما نیازهای امنیتی خاص خود را خواهند داشت، اما مشتی از اصول اولیه وجود دارد که باید همیشه در ذهن داشته باشید:
۱. حداقل کردن سطح حمله (Minimize Attack Surface Area)
۲. اصل حداقل امتیاز (Principle of Least Privilege)
۳. پیشفرضهای امن (Secure Defaults)
۴. رمزنگاری دادههای حساس (Encrypt Sensitive Data)
۵. حفظ بهروزرسانیهای امنیتی (Maintain Security Updates)
بیایید نگاهی به هر یک از اینها بیندازیم.
۱. حداقل کردن سطح حمله
سطح حمله یک سیستم، مجموع تمام نقاط دسترسی است که مهاجم میتواند داده وارد کند، داده استخراج کند یا اجرای سرویسی را فراخوانی نماید. در اینجا چند مثال آورده شده است:
-
پیچیدگی کد منجر به بردارهای حمله میشود: پیچیدگی کد سطح حمله را بزرگتر میکند، با فرصتهای بیشتر برای عوارض جانبی پیشبینینشده. به کد پیچیده به عنوان چیزی فکر کنید که سطح را متخلخلتر و بازتر برای عفونت میکند. یک بار دیگر، کد ساده و کوچکتر بهتر است. کد کمتر یعنی باگ کمتر، فرصتهای کمتر برای یک حفره امنیتی فلجکننده. استدلال در مورد کد سادهتر، جمعوجورتر و کمتر پیچیده آسانتر است و تشخیص نقاط ضعف بالقوه در آن راحتتر میباشد.
-
دادههای ورودی یک بردار حمله هستند [63]: هرگز به دادههای یک موجودیت خارجی اعتماد نکنید، همیشه قبل از پاس دادن آن به پایگاه داده، رندر ویو، یا سایر پردازشها، آن را پاکسازی (Sanitize) کنید.
برخی زبانها میتوانند در این زمینه کمک کنند. مثلاً در روبی، متغیرهایی که ورودی خارجی را نگه میدارند «آلوده» (tainted) محسوب میشوند که عملیات قابل انجام روی آنها را محدود میکند.
مثال کد روبی که ازwcاستفاده میکند نشان میدهد که چگونه یک کاربر بدخواه میتواند با وارد کردنtest.dat; rm -rf /سیستم را نابود کند. -
سرویسهای احراز هویت نشده یک بردار حمله هستند: ذاتاً، هر کاربری در هر جای دنیا میتواند سرویسهای احراز هویت نشده را فراخوانی کند، بنابراین بدون هیچ مدیریت یا محدودیتی، شما فوراً فرصتی برای حمله انکار سرویس (DoS) حداقل ایجاد کردهاید. بسیاری از نقضهای داده عمومی اخیر ناشی از قرار دادن تصادفی دادهها توسط توسعهدهندگان در مخازن داده ابری عمومی و بدون احراز هویت بوده است.
-
سرویسهای احراز هویت شده یک بردار حمله هستند: تعداد کاربران مجاز را در حداقل مطلق نگه دارید. کاربران و سرویسهای استفاده نشده، قدیمی یا منسوخ را حذف کنید. بسیاری از دستگاههای متصل به نت با پسوردهای پیشفرض ساده یا حسابهای مدیریتی استفاده نشده و محافظت نشده پیدا شدهاند. اگر حسابی با اعتبارنامههای استقرار (Deployment credentials) به خطر بیفتد، کل محصول شما به خطر افتاده است.
-
دادههای خروجی یک بردار حمله هستند: داستانی (احتمالاً ساختگی) درباره سیستمی وجود دارد که با وظیفهشناسی پیام خطای «رمز عبور توسط کاربر دیگری استفاده میشود» را گزارش میداد. اطلاعات را لو ندهید. مطمئن شوید دادهای که گزارش میدهید متناسب با مجوز آن کاربر است. اطلاعات بالقوه پرخطر مانند شماره تأمین اجتماعی یا سایر شمارههای شناسایی دولتی را کوتاه یا مبهم کنید.
-
اطلاعات دیباگینگ یک بردار حمله هستند: هیچ چیز به اندازه دیدن یک Stack Trace کامل با دادهها روی دستگاه خودپرداز محلی، کیوسک فرودگاه یا صفحه وب خراب شده دلگرمکننده نیست (البته برای هکرها!). اطلاعاتی که برای آسانتر کردن دیباگینگ طراحی شدهاند، میتوانند نفوذ را نیز آسانتر کنند. مطمئن شوید هر گونه «پنجره تست» و گزارش استثنای زمان اجرا از چشمهای جاسوس محافظت میشود. [64]
نکته ۷۲: آن را ساده نگه دارید و سطوح حمله را حداقل کنید
۲. اصل حداقل امتیاز (Principle of Least Privilege)
یک اصل کلیدی دیگر استفاده از کمترین میزان امتیاز برای کوتاهترین زمانی است که میتوانید با آن کار را پیش ببرید. به عبارت دیگر، به طور خودکار بالاترین سطح مجوز، مانند root یا Administrator را نگیرید. اگر آن سطح بالا مورد نیاز است، آن را بگیرید، حداقل کار را انجام دهید و سریعاً مجوز خود را رها کنید تا ریسک کاهش یابد.
این اصل به اوایل دهه ۱۹۷۰ برمیگردد:
هر برنامه و هر کاربر دارای امتیاز سیستم باید با استفاده از کمترین میزان امتیاز لازم برای تکمیل کار عمل کند.
— جروم سالتزر، ارتباطات ACM، ۱۹۷۴.
این موضوع فقط در مورد سطوح امتیاز سیستم عامل صدق نمیکند. آیا برنامه شما سطوح مختلف دسترسی را پیادهسازی میکند؟ آیا ابزاری زمخت است، مانند «مدیر» در مقابل «کاربر»؟ اگر چنین است، چیزی دقیقتر را در نظر بگیرید، جایی که منابع حساس شما به دستههای مختلف تقسیم میشوند و کاربران جداگانه فقط برای برخی از آن دستهها مجوز دارند.
۳. پیشفرضهای امن (Secure Defaults)
تنظیمات پیشفرض در برنامه شما، یا برای کاربران شما در سایتتان، باید امنترین مقادیر باشند. اینها ممکن است کاربرپسندترین یا راحتترین مقادیر نباشند، اما بهتر است اجازه دهید هر فرد خودش در مورد مصالحه بین امنیت و راحتی تصمیم بگیرد.
برای مثال، پیشفرض برای ورود رمز عبور ممکن است پنهان کردن رمز عبور هنگام ورود باشد. اگر در یک مکان عمومی شلوغ رمز عبور وارد میکنید، این پیشفرض معقولی است. اما برخی کاربران ممکن است بخواهند رمز عبور را ببینند.
۴. رمزنگاری دادههای حساس
اطلاعات قابل شناسایی شخصی (PII)، دادههای مالی، رمزهای عبور یا سایر اعتبارنامهها را به صورت متن ساده (Plain text) رها نکنید، چه در پایگاه داده و چه در فایلهای خارجی. اگر دادهها فاش شوند، رمزنگاری سطح اضافی از ایمنی را ارائه میدهد.
در موضوع ۱۹، کنترل نسخه، ما قویاً توصیه میکنیم همه چیز مورد نیاز برای پروژه را تحت کنترل نسخه قرار دهید. خب، تقریباً همه چیز. در اینجا یک استثنای بزرگ برای آن قانون وجود دارد:
اسرار (Secrets)، کلیدهای API، کلیدهای SSH، رمزهای عبور رمزنگاری یا سایر اعتبارنامهها را در کنار کد منبع خود در کنترل نسخه قرار ندهید (Check in نکنید).
کلیدها و اسرار باید جداگانه مدیریت شوند، معمولاً از طریق فایلهای پیکربندی یا متغیرهای محیطی به عنوان بخشی از بیلد و استقرار.
۵. حفظ بهروزرسانیهای امنیتی
بهروزرسانی سیستمهای کامپیوتری میتواند دردسر بزرگی باشد. شما به آن پچ امنیتی نیاز دارید، اما به عنوان یک اثر جانبی، بخشی از برنامه شما را میشکند. میتوانید تصمیم بگیرید صبر کنید و بهروزرسانی را به تعویق بیندازید.
این یک ایده وحشتناک است، زیرا اکنون سیستم شما در برابر یک اکسپلویت شناخته شده آسیبپذیر است.
نکته ۷۳: پچهای امنیتی را سریع اعمال کنید
این نکته بر هر دستگاه متصل به نت تأثیر میگذارد. و اگر فکر میکنید این واقعاً مهم نیست، فقط به یاد داشته باشید که بزرگترین نقضهای داده در تاریخ (تاکنون) ناشی از سیستمهایی بودند که در بهروزرسانیهای خود عقب مانده بودند. نگذارید این اتفاق برای شما بیفتد.
عقل سلیم در مقابل کریپتو [66]
مهم است که در نظر داشته باشید که عقل سلیم ممکن است در مورد مسائل مربوط به رمزنگاری (Cryptography) شما را ناامید کند.
اولین و مهمترین قانون در مورد کریپتو این است که هرگز خودتان انجام ندهید.
حتی برای چیزی به سادگی رمزهای عبور، شیوههای رایج اشتباه هستند (نگاه کنید به نوار کناری ضدالگوهای رمز عبور).
وقتی وارد دنیای کریپتو میشوید، حتی کوچکترین و بیاهمیتترین خطا میتواند همه چیز را به خطر بیندازد: الگوریتم رمزنگاری هوشمندانه، جدید و خانگی شما احتمالاً میتواند توسط یک متخصص در عرض چند دقیقه شکسته شود. شما نمیخواهید خودتان رمزنگاری انجام دهید. همانطور که جای دیگر گفتیم، فقط به چیزهای قابل اعتماد تکیه کنید: کتابخانهها و فریمورکهای خوب بررسی شده، کاملاً آزمایش شده، به خوبی نگهداری شده، مرتباً بهروزرسانی شده و ترجیحاً متنباز.
فراتر از کارهای ساده رمزنگاری، نگاهی سخت به سایر ویژگیهای مرتبط با امنیت سایت یا برنامه خود بیندازید. احراز هویت (Authentication) را در نظر بگیرید. برای پیادهسازی لاگین خود با رمز عبور یا احراز هویت بیومتریک، باید درک کنید که هشها و سالتها (Salts) چگونه کار میکنند، کرکرها چگونه از چیزهایی مانند جداول رنگینکمانی (Rainbow tables) استفاده میکنند، چرا نباید از MD5 یا SHA1 استفاده کنید و میزبان نگرانیهای دیگر.
یا، میتوانید رویکرد عملگرا را پیش بگیرید و اجازه دهید کس دیگری نگران آن باشد و از یک ارائهدهنده احراز هویت شخص ثالث استفاده کنید.
در هر صورت، این افراد تمام روزهای خود را صرف امن نگه داشتن سیستمهایشان میکنند و در این کار بهتر از شما هستند. آنجا بیرون، ایمن بمانید.
ضدالگوهای رمز عبور (Password Antipatterns)
یکی از مشکلات اساسی امنیت این است که اغلب اوقات امنیت خوب با عقل سلیم یا روشهای رایج در تضاد است. برای مثال، ممکن است فکر کنید الزامات سختگیرانه رمز عبور امنیت را افزایش میدهد. اشتباه میکنید. سیاستهای سختگیرانه رمز عبور در واقع امنیت شما را کاهش میدهند.
در اینجا لیست کوتاهی از ایدههای بسیار بد، همراه با برخی توصیهها از NIST [65] آمده است:
- طول رمز عبور را به کمتر از ۶۴ کاراکتر محدود نکنید. NIST عدد ۲۵۶ را به عنوان حداکثر طول خوب توصیه میکند.
- رمز عبور انتخابی کاربر را کوتاه نکنید.
- کاراکترهای خاص را محدود نکنید. اگر کاراکترهای خاص در رمز عبور سیستم شما را به خطر میاندازد (مثل تزریق SQL)، شما مشکلات بزرگتری دارید.
- راهنمایی رمز عبور (Password hints) ندهید و سوالات امنیتی خاص (مانند نام اولین حیوان خانگی) نپرسید.
- عملکرد Paste را در مرورگر غیرفعال نکنید. این کار کاربران را مجبور میکند پسوردهای سادهتر و کوتاهتر بسازند.
- قوانین ترکیب (Composition rules) اعمال نکنید. (مثلاً اجبار به استفاده از حروف بزرگ، کوچک، عدد و...).
- کاربران را خودسرانه مجبور به تغییر رمز عبور پس از مدتی نکنید. فقط اگر دلیل موجهی وجود دارد (مثل نقض امنیتی) این کار را بکنید.
شما میخواهید رمزهای عبور طولانی و تصادفی با درجه بالای آنتروپی را تشویق کنید. گذاشتن محدودیتهای مصنوعی آنتروپی را محدود میکند و عادات بد رمز عبور را تشویق میکند.
بخشهای مرتبط شامل
- موضوع ۲۳: طراحی بر اساس قرارداد (Design by Contract)
- موضوع ۲۴: برنامههای مرده دروغ نمیگویند (Dead Programs Tell No Lies)
- موضوع ۲۵: برنامهنویسی قاطعانه (Assertive Programming)
- موضوع ۳۸: برنامهنویسی تصادفی (Programming by Coincidence)
- موضوع ۴۵: گودال نیازمندیها (The Requirements Pit)
موضوع ۴۴: نامگذاری چیزها
آغاز خردمندی، نامیدن چیزها به نام درست آنهاست.
— کنفوسیوس
در یک نام چیست؟ وقتی برنامهنویسی میکنیم، پاسخ «همه چیز!» است.
ما برای برنامهها، زیرسیستمها، ماژولها، توابع و متغیرها نام میسازیم—ما دائماً در حال خلق چیزهای جدید و اعطای نام به آنها هستیم. و آن نامها بسیار، بسیار مهم هستند، زیرا چیزهای زیادی درباره قصد و باور شما آشکار میکنند.
ما معتقدیم که چیزها باید بر اساس نقشی که در کد شما بازی میکنند، نامگذاری شوند.
این بدان معناست که هر زمان چیزی میسازید، باید مکث کنید و فکر کنید «انگیزه من برای ساختن این چیست؟» این یک سوال قدرتمند است، زیرا شما را از ذهنیت حل مسئله فوری خارج میکند و باعث میشود به تصویر بزرگتر نگاه کنید. وقتی نقش یک متغیر یا تابع را در نظر میگیرید، دارید به این فکر میکنید که چه چیزی در مورد آن خاص است، چه کاری میتواند انجام دهد و با چه چیزی تعامل دارد.
اغلب، متوجه میشویم کاری که میخواستیم انجام دهیم بیمعنی بوده، همه به این دلیل که نتوانستیم نام مناسبی برای آن پیدا کنیم.
دانشی پشت این ایده وجود دارد که نامها عمیقاً معنادار هستند. مشخص شده است که مغز میتواند کلمات را واقعاً سریع بخواند و بفهمد: سریعتر از بسیاری فعالیتهای دیگر. این بدان معناست که کلمات اولویت خاصی دارند وقتی سعی میکنیم چیزی را درک کنیم. این را میتوان با استفاده از اثر استروپ (Stroop effect) نشان داد. [67]
به پنل زیر نگاه کنید. لیستی از نامهای رنگ یا سایهها دارد، و هر کدام به رنگ یا سایهای نمایش داده شدهاند. اما نامها و رنگها لزوماً مطابقت ندارند.
بخش اول چالش اینجاست—نام هر رنگ را همانطور که نوشته شده با صدای بلند بگویید: [68]
(تصویری که کلمات رنگها با رنگهای متفاوت نوشته شدهاند، مثلاً کلمه RED با رنگ آبی نوشته شده است)
حالا این کار را تکرار کنید، اما این بار رنگی را که برای کشیدن کلمه استفاده شده با صدای بلند بگویید.
سختتر است، نه؟ روان بودن هنگام خواندن آسان است، اما هنگام تلاش برای تشخیص رنگها بسیار سختتر است. مغز شما با کلمات نوشته شده به عنوان چیزی که باید احترام گذاشته شود رفتار میکند. ما باید مطمئن شویم نامهایی که استفاده میکنیم شایسته این احترام هستند.
بیایید به چند مثال نگاه کنیم:
ما افرادی را که به سایت ما که جواهرات ساخته شده از کارتهای گرافیک قدیمی میفروشد دسترسی دارند، احراز هویت میکنیم:
let user = authenticate(credentials)
متغیر user است چون همیشه user است. اما چرا؟ این هیچ معنایی ندارد. چطور است customer (مشتری) یا buyer (خریدار) باشد؟ به این ترتیب در حین کدنویسی دائماً یادآوری میشویم که این شخص سعی دارد چه کار کند و این برای ما چه معنایی دارد.
ما یک متد نمونه (instance method) داریم که تخفیفی روی سفارش اعمال میکند:
public void deductPercent(double amount) // ...
دو چیز اینجا وجود دارد. اول، deductPercent کاری است که انجام میدهد نه چرایی انجام آن. سپس نام پارامتر amount در بهترین حالت گمراهکننده است: آیا یک مقدار مطلق است یا درصد؟
شاید این بهتر باشد:
public void applyDiscount(Percentage discount) // ...
نام متد اکنون قصد آن را روشن میکند. ما همچنین پارامتر را از double به Percentage تغییر دادیم، نوعی که تعریف کردهایم. ما نمیدانیم شما چطورید، اما وقتی با درصدها سر و کار داریم هرگز نمیدانیم مقدار قرار است بین ۰ تا ۱۰۰ باشد یا ۰.۰ تا ۱.۰. استفاده از یک نوع (Type)، آنچه تابع انتظار دارد را مستند میکند.
ما ماژولی داریم که کارهای جالبی با اعداد فیبوناچی انجام میدهد. یکی از آن کارها محاسبه $n$امین عدد در دنباله است. بایستید و فکر کنید چه نامی به این تابع میدهید. اکثر افرادی که میپرسیم آن را fib مینامند. معقول به نظر میرسد، اما به یاد داشته باشید که معمولاً در زمینه ماژولش فراخوانی میشود، بنابراین فراخوانی Fib.fib(n) خواهد بود. چطور است آن را of یا nth بنامیم:
Fib.of(0) # => 0
Fib.nth(20) # => 4181
هنگام نامگذاری چیزها، دائماً به دنبال راههایی برای شفافسازی منظور خود هستید، و آن عمل شفافسازی شما را به درک بهتری از کدتان در حین نوشتن هدایت خواهد کرد.
استثنایی که قانون را ثابت میکند
در حالی که ما برای وضوح در کد تلاش میکنیم، برندینگ (Branding) موضوعی کاملاً متفاوت است. به معنای واقعی کلمه. سنتی جاافتاده وجود دارد که پروژهها و تیمهای پروژه باید نامهای مبهم و «هوشمندانه» داشته باشند. نامهای پوکمون، ابرقهرمانان مارول، پستانداران بامزه، شخصیتهای ارباب حلقهها، شما نام ببرید.
احترام به فرهنگ
اکثر متون مقدماتی کامپیوتر به شما تذکر میدهند که هرگز از متغیرهای تکحرفی مانند i، j، یا k استفاده نکنید. [69]
ما فکر میکنیم آنها اشتباه میکنند. تا حدی.
در واقع، این بستگی به فرهنگ آن زبان برنامهنویسی یا محیط خاص دارد. در زبان برنامهنویسی C، متغیرهای i، j و k به طور سنتی به عنوان متغیرهای افزایشی حلقه (Loop increment variables) استفاده میشوند، s برای رشته کاراکتری استفاده میشود و غیره. اگر در آن محیط برنامهنویسی میکنید، این چیزی است که عادت به دیدن آن دارید و نقض آن هنجار آزاردهنده (و در نتیجه اشتباه) خواهد بود.
از سوی دیگر، استفاده از آن قرارداد در محیطی متفاوت که انتظار نمیرود، درست به همان اندازه اشتباه است. شما هرگز کاری شنیع مثل این مثال کلوژر (Clojure) که رشتهای را به متغیر i اختصاص میدهد، انجام نمیدهید:
(let [i "Hello World"] (println i))
برخی جوامع زبانی camelCase را ترجیح میدهند، در حالی که برخی دیگر snake_case با زیرخط (underscore) را ترجیح میدهند. زبانها خودشان البته هر دو را میپذیرند، اما این آن را درست نمیکند. به فرهنگ محلی احترام بگذارید.
سازگاری (Consistency)
امرسون به نوشتن جمله «یک سازگاری احمقانه، دیو ذهنهای کوچک است...» مشهور است، اما امرسون در تیمی از برنامهنویسان نبود.
هر پروژه واژگان خاص خود را دارد: کلمات تخصصی (Jargon) که معنای خاصی برای تیم دارند. «Order» (سفارش) برای تیمی که فروشگاه آنلاین میسازد یک چیز معنی میدهد، و برای تیمی که برنامهشان اصل و نسب گروههای مذهبی را ترسیم میکند، چیزی بسیار متفاوت.
مهم است که همه در تیم بدانند این کلمات چه معنایی دارند و به طور سازگار از آنها استفاده کنند.
یک راه تشویق ارتباطات زیاد است. اگر همه برنامهنویسی دونفره (Pair programming) انجام دهند و جفتها مرتباً عوض شوند، اصطلاحات به صورت اسمزی پخش میشوند. راه دیگر داشتن یک واژهنامه پروژه است.
پس از مدتی، اصطلاحات پروژه جان میگیرند. همانطور که همه با واژگان راحت میشوند، میتوانید از اصطلاحات به عنوان خلاصهنویسی استفاده کنید و معنای زیادی را دقیق و مختصر بیان کنید. (این دقیقاً همان کاری است که یک زبان الگو (Pattern Language) انجام میدهد.)
تغییر نام حتی سختتر است
مهم نیست چقدر در ابتدا تلاش کنید، چیزها تغییر میکنند. کد بازآرایی میشود، استفاده تغییر میکند، معنی به ظرافت دگرگون میشود. اگر در بهروزرسانی نامها در حین کار هوشیار نباشید، به سرعت به کابوسی بدتر از نامهای بیمعنی سقوط خواهید کرد: نامهای گمراهکننده.
آیا تا به حال کسی ناهماهنگیهایی در کد را اینطور برایتان توضیح داده است: «روتینی که getData نامیده میشود، در واقع دادهها را در یک فایل آرشیو مینویسد»؟
همانطور که در موضوع ۳، آنتروپی نرمافزار بحث کردیم، وقتی مشکلی را دیدید، آن را درست کنید—همین جا و همین الان. وقتی نامی میبینید که دیگر قصد را بیان نمیکند، یا گمراهکننده یا گیجکننده است، آن را اصلاح کنید. شما تستهای رگرسیون کامل دارید، بنابراین هر موردی را که ممکن است از قلم انداخته باشید، پیدا خواهید کرد.
نکته ۷۴: خوب نامگذاری کنید؛ در صورت نیاز تغییر نام دهید
اگر به هر دلیلی نمیتوانید نامی را که اکنون اشتباه است تغییر دهید، پس مشکل بزرگتری دارید: نقض ETC (نگاه کنید به موضوع ۸، جوهره طراحی خوب). اول آن را درست کنید، سپس نام توهینآمیز را تغییر دهید. تغییر نام را آسان کنید و آن را اغلب انجام دهید.
در غیر این صورت باید با چهرهای جدی به افراد جدید تیم توضیح دهید که getData واقعاً دادهها را در فایل مینویسد.
بخشهای مرتبط شامل
- موضوع ۳: آنتروپی نرمافزار (Software Entropy)
- موضوع ۴۰: بازآرایی (Refactoring)
- موضوع ۴۵: گودال نیازمندیها (The Requirements Pit)
چالشها
- وقتی تابع یا متدی با نام بیش از حد عمومی پیدا میکنید، سعی کنید نام آن را تغییر دهید تا تمام کارهایی که واقعاً انجام میدهد را بیان کند. اکنون هدف آسانتری برای بازآرایی است.
- در مثالهایمان، پیشنهاد کردیم از نامهای خاصتری مثل
buyer(خریدار) به جایuser(کاربر) سنتی و عمومی استفاده کنید. چه نامهای دیگری را عادتاً استفاده میکنید که میتوانند بهتر باشند؟ - آیا نامها در سیستم شما با اصطلاحات کاربر از دامین (Domain) همخوانی دارند؟ اگر نه، چرا؟ آیا این باعث ناهماهنگی شناختی به سبک اثر استروپ برای تیم میشود؟
- آیا تغییر نامها در سیستم شما سخت است؟ برای تعمیر آن پنجره شکسته خاص چه میتوانید بکنید؟