Skip to content

فصل ۲ — چگونه کد فرو می‌پاشد

دویدن موفق ماراتن دستاورد چشمگیری است. من شخصاً هرگز این چالش را نپذیرفته‌ام، اما چندین دوستم این کار را کرده‌اند. شاید تعجب‌برانگیز باشد که اکثر این دوستان قبل از ثبت‌نام در اولین نیمه یا ماراتن کامل، دوندهٔ پرشور نبودند. با پایبندی به برنامهٔ تمرین منظم و پایدار، در چند ماه توانایی لازم را ساختند.

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

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

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

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

چرا فهم فروپاشی کد مهم است

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

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

به‌طور کلی، دو راه برای فروپاشی کد وجود دارد: یا نیازمندی‌های آنچه کد باید انجام دهد یا چگونه باید رفتار کند تغییر کرده، یا سازمان شما برای دستیابی به بیشتر در مدت کوتاه، گوشه‌ها را بریده است. به این‌ها به‌ترتیب «تغییر نیازمندی‌ها» (requirement shifts) و «بدهی فنی» (tech debt) می‌گوییم.

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

متأسفانه گاهی فقط با منابع بسیار محدود بهترین کار ممکن را انجام می‌دهیم. وقتی زمان یا پول کافی برای ساخت راه‌حل بهتر نداریم، گوشه‌ها را می‌بریم و بدهی فنی (tech debt) انباشته می‌شود. در حالی که اثر اولیهٔ آن بدهی ممکن است کم باشد، وزن اضافه‌اش روی کدبیس‌های ما می‌تواند با گذشت زمان به‌شدت انباشته شود. آسان است بدهی فنی را کد بد بدانیم، اما از شما دعوت می‌کنم دیدگاه را عوض کنید. گاهی راه‌حل مقاوم و سریع همان است که محصول یا قابلیت را سریع‌تر به بازار می‌رساند؛ اگر رساندن محصول به دست کاربران برای بقای شرکت حیاتی است، بدهی فنی ممکن است کاملاً ارزشش را داشته باشد.

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

با نویسندگان اصلی صحبت کنید

وقتی کدی را که می‌خواهید بازآرایی کنید مشخص کردید، اگر بتوانید با نویسندگان اصلی بنشینید، بینش ارزشمندی دربارهٔ چگونگی و چرایی راه‌حل اولیهٔ آن‌ها به دست می‌آورید. اغلب فوراً می‌توانند بگویند چرا کد فروپاشیده. اگر چیزی شبیه «نمی‌دانستیم که…» یا «آن زمان فکر می‌کردیم…» بگویند، احتمالاً با فروپاشی کد به‌خاطر تغییر نیازمندی‌ها روبه‌رو هستید. از طرف دیگر، اگر بگویند «آه، درست است، آن کد هرگز خوب نبود» یا «فقط می‌خواستیم مهلت را بزنیم»، می‌دانید احتمالاً با مورد معمول بدهی فنی (tech debt) سر و کار دارید.

تغییر نیازمندی‌ها

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

مقیاس‌پذیری

یکی از نیازمندی‌هایی که مکرراً سعی می‌کنیم تخمین بزنیم، جهت و میزانی است که محصول باید مقیاس بگیرد. این فهرست بلندبالا از نیازمندی‌ها می‌تواند طولانی شود و پارامترهای گسترده‌ای را شامل شود. مثلاً یک درخواست سادهٔ رابط برنامه‌نویسی کاربردی (API) برای ایجاد ورودی کاربر جدید در سیستم. ممکن است دستورالعمل‌هایی حول تأخیر مورد انتظار درخواست، تعداد پرس‌وجوهای پایگاه داده اجراشده در درخواست، تعداد کل درخواست‌های کاربر جدید مجاز در ثانیه و غیره تعیین کنیم.

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

دسترس‌پذیری

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

اگرچه از ۱۹۹۹ تعداد کمی تکرار روی بهترین شیوه‌های رسمی دسترس‌پذیری وب توسط Web Accessibility Initiative (WAI) توسعه یافته، چند بازنگری مهم استاندارد شده. با هر تکرار جدید، توسعه‌دهندگان وب‌سایت‌ها و برنامه‌های فعال باید کدی را که گاه سال‌ها دست‌نخورده مانده دوباره مرور کنند و تغییرات لازم برای انطباق با جدیدترین استانداردها پیاده کنند. تکرارها روی استانداردهای دسترس‌پذیری می‌توانند کیفیت برنامهٔ شما را کاهش دهند.

سازگاری با دستگاه

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

تغییرات محیطی

وقتی در محیط یک برنامه تغییر رخ می‌دهد، انواع رفتارهای غیرمنتظره می‌تواند ظاهر شود. قبل از عصر رایانه‌های بازی مدرن با واحدهای پردازش گرافیک (GPU) قدرتمند و ده‌ها گیگابایت حافظهٔ دسترسی تصادفی (RAM)، کنسول‌های بازی کوچک و فروتن در سالن‌های بازی و بعداً در اتاق نشیمن داشتیم. توسعه‌دهندگان بازی راه‌های هوشمندانه‌ای برای استفاده از سخت‌افزار محدود در دسترس برای ساخت کلاسیک‌هایی مثل Space Invaders و Super Mario Bros. پیدا کردند. آن زمان، استفاده از سرعت ساعت واحد پردازش مرکزی (CPU) به‌عنوان تایمر در بازی رایج بود. زمان پایدار و قابل اعتمادی فراهم می‌کرد. اگرچه برای بازی‌های کنسول — که کارتریج‌ها اغلب با نسل‌های جدیدتر و قدرتمندتر کنسول سازگار نبودند — مشکل نبود، برای بازی‌های روی رایانهٔ شخصی نادیده‌گیری جدی شد. با افزایش سرعت ساعت در رایانه‌های جدیدتر، سرعت گیم‌پلی هم بالا رفت. تصور کنید باید قطعات Tetris را بچینید یا از جریان Goombaها با دو برابر سرعت عادی اجتناب کنید؛ در یک نقطه، بازی کاملاً غیرقابل استفاده می‌شود. در هر دو مثال، نیازمندی این بود که کد روی سخت‌افزار فیزیکی مشخصی اجرا شود؛ متأسفانه سخت‌افزار از آن زمان به‌شکل چشمگیر تغییر کرده و در نتیجه کد فروپاشیده است.

این نوع تغییرات محیطی امروز هم نگرانی جدی است. در ژانویهٔ ۲۰۱۸، محققان امنیتی از Google Project Zero و Cyberus Technology، همکاری با تیمی در Graz University of Technology، دو آسیب‌پذیری امنیتی جدی را شناسایی کردند که همهٔ ریزپردازنده‌های Intel x86، پردازنده‌های IBM POWER و برخی ریزپردازنده‌های مبتنی بر Advanced RISC Machine (ARM) را تحت تأثیر قرار می‌داد. اولی، Meltdown، به فرایندهای مخرب اجازه می‌داد همهٔ حافظهٔ ماشین را بخوانند، حتی وقتی مجاز نبودند. دومی، Spectre، به مهاجمان اجازه می‌داد پیش‌بینی انشعاب (branch prediction) — ویژگی عملکردی پردازنده‌های تحت تأثیر — را به‌نفع خود به کار گیرند تا دادهٔ خصوصی دربارهٔ فرایندهای دیگر روی ماشین فاش شود. می‌توانید دربارهٔ این آسیب‌پذیری‌ها و جزئیات درونی‌شان در وب‌سایت رسمی بیشتر بخوانید.

در زمان افشا، همهٔ دستگاه‌هایی که هر نسخه‌ای جز جدیدترین iOS، Linux، macOS و Windows را اجرا می‌کردند تحت تأثیر بودند. تعداد زیادی سرور و سرویس ابری هم تحت تأثیر بودند، و همچنین اکثر دستگاه‌های هوشمند و جاسازی‌شده (embedded). ظرف چند روز، راه‌حل‌های موقت نرم‌افزاری برای هر دو آسیب‌پذیری در دسترس شد، اما هزینهٔ عملکردی ۵ تا ۳۰ درصد، بسته به بار کاری، داشتند. Intel بعداً گزارش داد روی راه‌های محافظت در برابر Meltdown و Spectre در نسل بعدی پردازنده‌هایش کار می‌کند. حتی چیزهایی که پایدارترین می‌دانیم (سیستم‌عامل‌ها، firmware) در برابر تغییرات در محیط خود آسیب‌پذیرند؛ و وقتی این سیستم‌های هسته‌ای و زیربنایی که بی‌شمار برنامه روی آن‌ها اجرا می‌شود تحت تأثیر قرار گیرند، ما هم تحت تأثیر قرار می‌گیریم.

وابستگی‌های بیرونی

هر قطعه نرم‌افزار وابستگی‌های بیرونی دارد؛ فقط چند مثال: مجموعه‌ای از کتابخانه‌ها، زبان برنامه‌نویسی، مفسر، یا سیستم‌عامل. میزان وابستگی (coupling) این وابستگی‌ها به نرم‌افزار متفاوت است. این وابستگی چیز جدیدی نیست؛ بسیاری از برنامه‌های تأثیرگذار از روزهای اولیهٔ تحقیق هوش مصنوعی در Lisp و زبان‌های برنامه‌نویسی تحقیقاتی شبیه Lisp توسعه یافتند، چون در دههٔ ۱۹۶۰ و اوایل ۱۹۷۰ به‌طور فعال توسعه می‌یافتند. SHRDLU، برنامهٔ زودهنگام درک زبان طبیعی، در Micro Planner روی PDP-6 نوشته شد، با ماکروها و کتابخانه‌های نرم‌افزاری غیراستاندارد که امروز دیگر وجود ندارند، و در نتیجه از فساد نرم‌افزاری (software rot) غیرقابل ترمیم رنج می‌برد.

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

فرض کنید برنامه‌ای اجرا می‌کنیم که به نسخهٔ ۱.۸ کتابخانهٔ متن‌باز Super Timezone Library وابسته است. فقط چند هفته پس از انتشار نسخهٔ ۴.۰، توسعه‌دهندگان Super Timezone Library اعلام می‌کنند دیگر هیچ نسخه‌ای زیر ۳.۰ را به‌طور فعال پشتیبانی نمی‌کنند. اکنون باید حداقل به نسخهٔ ۳.۰ ارتقا دهیم تا وصله‌های امنیتی را دریافت کنیم. متأسفانه نسخهٔ ۲.۵ تغییرات ناسازگار با گذشته (backward-incompatible) معرفی کرد و نسخهٔ ۲.۸ قابلیتی را منسوخ (deprecated) کرد که در برنامهٔ ما به‌طور گسترده استفاده می‌شد. آنچه می‌توانست سرمایه‌گذاری کوچک و منظم در به‌روز نگه داشتن کتابخانه در چند سال گذشته باشد، اکنون به سرمایه‌گذاری پیچیده‌تر و فوری تبدیل شده.

کد بلااستفاده

تغییر در نیازمندی‌ها می‌تواند به کد بلااستفاده منجر شود. مثلاً یک API عمومی. تیم تصمیم می‌گیرد API را منسوخ کند و توسعه‌دهندگان شخص ثالث را از تغییر آینده هشدار دهد. متأسفانه پس از اطلاع‌رسانی تغییر مورد نظر، حذف مستندات از وب‌سایت، و اطمینان از اینکه هیچ سیستم بیرونی هنوز به نقطهٔ پایانی (endpoint) متکی نیست، تیم فراموش می‌کند کد را حذف کند. چند ماه بعد، مهندس جدیدی قابلیت جدیدی پیاده می‌کند، به نقطهٔ پایانی API از رده خارج‌شده برمی‌خورد، و به‌طور طبیعی فرض می‌کند هنوز کار می‌کند. تصمیم می‌گیرد برای مورد استفادهٔ خودش از آن استفاده مجدد کند. متأسفانه زود متوجه می‌شود کد دقیقاً آنچه می‌خواست انجام نمی‌دهد، فقط به‌خاطر اینکه API رها شده و با بقیهٔ کدبیس و تکرارهای متعدد تغییر نیازمندی‌ها سازگار نشده.

کد بلااستفاده از منظر بهره‌وری توسعه‌دهنده هم می‌تواند مشکل‌ساز باشد. هر بار که به کدی برمی‌خوریم که فکر می‌کنیم بلااستفاده است، باید با دقت زیاد تعیین کنیم آیا می‌توانیم با اطمینان حذفش کنیم. مگر با ابزار قابل اعتماد برای برجسته‌کردن درست مرز کد مرده (dead code)، تعیین دقیق مرزها سخت است. اگر مطمئن نیستیم حذف کنیم، معمولاً رد می‌شویم و امیدواریم کس دیگری بعداً حلش کند. چه کسی می‌داند چند مهندس همان کد را می‌بینند و همان سؤال را می‌پرسند تا بالاخره حذف شود!

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

کد کامنت‌شده

در مورد کد کامنت‌شده، کاملاً واضح است که کد بلااستفاده است. همیشه توصیه می‌کنم توسعه‌دهندگانی که وسوسهٔ کامنت‌کردن کد دارند، اگر کد با کنترل نسخه (version control) ردیابی می‌شود، ساده حذفش کنند. اگر روزی دوباره لازم شد، به‌راحتی با مرور تاریخچهٔ کامیت (commit history) بازیابی می‌کنید.

تغییر در نیازمندی‌های محصول

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

آرگومان‌های Boolean توابع مثال خوبی از دشواری پیش‌بینی نیازمندی‌های آیندهٔ محصول در عمل هستند. بیشتر وقت‌ها آرگومان‌های Boolean برای تغییر رفتار توابع موجود معرفی می‌شوند. (یکی را در «مثال اول بازآرایی ما» در صفحهٔ ۱۸ دیدیم، جایی که پرچم Boolean برای تصمیم‌گیری اینکه آیا می‌خواهیم بدانیم هر یک از نمرات یا میانگین آن نمرات در بازهٔ داده‌شده قرار دارد استفاده شد.) افزودن پرچم Boolean اغلب کوچک‌ترین و ساده‌ترین تغییری است وقتی تابعی پیدا می‌کنید که تقریباً دقیقاً آنچه می‌خواهید انجام می‌دهد، با یک استثنای کوچک. متأسفانه این نوع تغییر می‌تواند انواع مشکلاتی در آینده ایجاد کند. برخی را در مثال ۲-۱ می‌بینیم، جایی که تابع کوچکی مسئول بارگذاری تصویر با نام فایل و پرچمی برای اینکه فایل PNG است داریم.

javascript
function uploadImage(filename, isPNG) {
  // some implementation details
  if (isPNG) {
    // do some PNG-specific logic
  }
  // do some other things
}

اگر چند ماه بعد تصمیم بگیریم فرمت تصویر جدیدی پشتیبانی کنیم چه؟ ممکن است آرگومان Boolean دیگری برای isGIF اضافه کنیم، مانند مثال ۲-۲.

javascript
function uploadImage(filename, isPNG, isGIF) {
  // some implementation details
  if (isPNG) {
    // do some PNG-specific logic
  } else if (isGIF) {
    // do some GIF-specific logic
  }
  // do some other things
}
  • آرگومان Boolean جدیدی برای مشخص کردن اینکه تصویر GIF است معرفی شد.
  • تصویر نمی‌تواند هم PNG و هم GIF باشد، پس بلوک «در غیر این صورت اگر» (else if) اضافه کردیم.

برای فراخوانی این تابع و بارگذاری درست GIF، باید به خاطر بسپاریم آرگومان Boolean دوم را روی مقدار true تنظیم کنیم. خوانندگانی که کد فراخوانی uploadImage را می‌بینند احتمالاً گیج می‌شوند و باید به تعریف تابع مراجعه کنند تا نقش دو آرگومان Boolean را بفهمند.

آرگومان‌های Boolean و نام‌گذاری

در زبانی با آرگومان‌های نام‌دار (named arguments)، کمتر نگران نیاز به مراجعه به تعریف تابع برای دانستن نقش و ترتیب آرگومان‌ها هستیم. فارغ از انتخاب زبان، همچنان uploadImage(filename=filename, isPNG=true, isGIF=true) ممکن است بی‌معنی به نظر برسد، اما فراخوانی معتبر تابع است (و احتمالاً در آینده باگ ایجاد می‌کند). مثال ۲-۳ نمونه‌ای را نشان می‌دهد که برای خواننده ممکن است سخت باشد از بافت بفهمد uploadImage چه می‌کند.

javascript
function changeProfilePicture(filename) {
  // some implementation details
  if (isAnimated) {
    uploadImage(filename, false, true);
  } else {
    uploadImage(filename, true, false);
  }
  // do some other things
}
  • اینجا GIF بارگذاری می‌کنیم.
  • در غیر این صورت PNG بارگذاری می‌کنیم.

نه‌تنها برای توسعه‌دهندگان فهم uploadImage هنگام خواندن توابعی مثل changeProfilePicture سخت است، اگر فرمت‌های تصویر بیشتری در آینده معرفی شوند، الگوی ناپایداری برای ادامهٔ نگهداری است. توسعه‌دهنده‌ای که اولین آرگومان Boolean برای isPNG اضافه کرد، بیشتر نگران مسائل امروز بود تا فردا. رویکرد بهتر تقسیم منطق به توابع مجزا است: uploadJPG، uploadPNG و uploadGIF، مانند مثال ۲-۴.

javascript
function uploadImagePreprocessing(filename) {
  // some implementation details
}

function uploadImagePostprocessing(filename) {
  // do some other things
}

function uploadJPG(filename) {
  uploadImagePreprocessing();
  // do JPG things
  uploadImagePostprocessing();
}

function uploadPNG(filename) {
  uploadImagePreprocessing();
  // do PNG things
  uploadImagePostprocessing();
}

function uploadGIF(filename) {
  uploadImagePreprocessing();
  // do GIF things
  uploadImagePostprocessing();
}

حالا شاید بپرسید چرا افزودن آرگومان Boolean isPNG مشکل جدی است اگر بعداً می‌توانیم بازآرایی کنیم. برای جایگزینی درست همهٔ رخدادهای uploadImage، باید هر محل فراخوانی را جداگانه بازبینی کنیم و بسته به اینکه آرگومان Boolean روی true تنظیم شده یا نه، با uploadJPG یا uploadPNG جایگزین کنیم. چون این تغییرات دستی اما تکراری و خسته‌کننده هستند، احتمال جایگزینی اشتباه بالاست و می‌تواند به پسرفت‌های (regression) جدی منجر شود. بسته به گستردگی مشکل و میزان وابستگی آن به منطق کسب‌وکار حیاتی دیگر، بازآرایی آنچه آرگومان Boolean ساده به نظر می‌رسد می‌تواند کار سرسخت و دلهره‌آوری باشد.

بدهی فنی (tech debt)

رایج‌ترین مقصران پشت بدهی فنی (tech debt)، زمان محدود، تعداد محدود مهندس، و پول محدود است. با توجه به اینکه همهٔ شرکت‌های فناوری در یک یا چند محور با منابع محدود روبه‌رو هستند، هر یک از آن‌ها بدهی فنی دارد. استارتاپ‌های شش‌ماههٔ کوچک؛ توده‌های بزرگ چنددهه‌ای غول‌پیکر؛ و هر شرکتی بین این‌ها — سهم منصفانه‌ای از کد کهنه و پیچیده (crufty) دارند. در این بخش، نگاه دقیق‌تری به چگونگی انباشت بدهی فنی به‌خاطر این تأثیرات می‌اندازیم. اگرچه آسان است به نویسندگان اصلی کد انگشت‌اشاره کرد و آن‌ها را به‌خاطر تصمیماتی که امروز بهینه نیست سرزنش کرد، مهم است به یاد بیاوریم تحت محدودیت‌های جدی کار می‌کردند. باید بپذیریم گاهی تحت فشار شدید نوشتن کد خوب تقریباً غیرممکن است.

دور زدن انتخاب‌های فناوری

هنگام پیاده‌سازی چیز جدید، باید تصمیمات حیاتی دربارهٔ فناوری‌هایی که می‌خواهیم استفاده کنیم بگیریم. زبان، مدیر وابستگی (dependency manager)، پایگاه داده و غیره را انتخاب می‌کنیم. فهرست بلندبالایی از تصمیمات قبل از در دسترس بودن برنامه برای کاربران وجود دارد. بسیاری از این تصمیمات با توجه به تجربهٔ مهندسان گرفته می‌شود؛ اگر با یک فناوری راحت‌تر از دیگری باشند، پروژه را سریع‌تر راه می‌اندازند تا اگر پشتهٔ جدید (stack) بپذیرند.

وقتی پروژه راه‌اندازی شده و جذب کاربر پیدا کرده، این تصمیمات فناوری اولیه آزمایش می‌شوند. اگر مشکل انتخاب فناوری به‌اندازهٔ کافی زود در عمر برنامه پیش بیاید، پیدا کردن جایگزین مناسب و تغییر مسیر (pivot) ممکن است آسان و ارزان باشد، اما اغلب محدودیت‌های آن انتخاب‌ها تا بعد از رشد برنامه فراتر از این نقطه آشکار نمی‌شوند. یکی از این تصمیمات ممکن است توسعهٔ برنامه با زبان برنامه‌نویسی با نوع پویا (dynamically typed) به‌جای با نوع ایستا (statically typed) باشد.

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

در حالی که مزایای زیادی برای زبان‌های با نوع پویا وجود دارد، وقتی برنامه‌ها از آستانهٔ بحرانی (critical mass) فراتر می‌روند مدیریتشان سخت می‌شود. چون نوع‌ها فقط در زمان اجرا (runtime) بررسی می‌شوند، مسئولیت توسعه‌دهنده است با نوشتن مجموعهٔ کامل آزمون واحد (unit test) که همهٔ مسیرهای اجرا را پوشش دهد و رفتار مورد انتظار را تأیید کند، صحت نوع را تضمین کند. توسعه‌دهندگان جدیدی که می‌خواهند با نحوهٔ تعامل ساختارهای مختلف آشنا شوند، اگر نام متغیرها فوراً نوع را نشان ندهد ممکن است سخت داشته باشند. غیرمعمول نیست به برنامه‌نویسی دفاعی (defensive programming) نیاز پیدا کنیم، مانند مثال ۲-۵، جایی که تأیید می‌کنیم مقداری که به تابع داده شده ویژگی‌های خاصی دارد و ناخواسته null نیست.

javascript
function addUserToGroup(group, user) {

  if (!user) {
    throw 'user cannot be null';
  }

  // assert required fields
  if (!user.name) {
    throw 'name required';
  }

  if (!user.email) {
    throw 'email required';
  }

  if (!user.dateCreated) {
    throw 'date created required';
  }

  // assert no empty strings or other invalid values
  if (user.name === "") {
    throw 'name cannot be empty';
  }
  if (user.email === "") {
    throw 'email cannot be empty';
  }
  if (user.dateCreated === 0) {
    throw 'date created cannot be 0';
  }

  group.push(user);
  return group;
}

بسیار محتمل است نویسندهٔ نمونهٔ کد به‌طور منظم با کاربرهای نامعتبری که به‌خاطر ماهیت پویای JavaScript در زمان اجرا از پشتهٔ فراخوانی (callstack) عبور می‌کنند مشکل دارد. نویسنده فقط می‌خواهد مطمئن باشد فقط کاربرهای معتبر به گروه اضافه می‌شوند، و این کاملاً قابل فهم است. متأسفانه اکنون addUserToGroup عمدتاً نگران اطمینان از معتبر بودن کاربر داده‌شده است، نه افزودن کاربر به گروه. با تصمیمات بیشتر دربارهٔ آنچه کاربر معتبر می‌سازد، هر یک از این اعتبارسنجی‌های موردی (ad hoc) پراکنده در کدبیس باید به‌روز شود. همچنین شانس بیشتری هست با فراموش کردن به‌روزرسانی یکی از این مکان‌ها باگ معرفی کنیم. سرانجام، توابع طولانی، پیچیده و مستعد باگ همه‌جا داریم.

می‌توانیم تابع جدیدی برای کاهش فروپاشی کد معرفی کنیم. فرض کنید تابع کمکی ساده‌ای برای در بر گرفتن (encapsulate) همهٔ منطق اعتبارسنجی شیء user می‌نویسیم؛ validateUser می‌نامیمش. مثال ۲-۶ پیاده‌سازی آن را نشان می‌دهد.

javascript
function validateUser(user) {
  if (!user) {
    throw 'user cannot be null';
  }

  // assert required fields
  if (!user.name) {
    throw 'name required';
  }

  if (!user.email) {
    throw 'email required';
  }

  if (!user.dateCreated) {
    throw 'date created required';
  }

  // assert no empty strings or other invalid values
  if (user.name === "") {
    throw 'name cannot be empty';
  }
  if (user.email === "") {
    throw 'email cannot be empty';
  }
  if (user.dateCreated === 0) {
    throw 'date created cannot be 0';
  }

  return;
}

سپس می‌توانیم addUserToGroup را برای استفاده از تابع کمکی جدید به‌روز کنیم و منطق را به‌شکل چشمگیر ساده کنیم، مانند مثال ۲-۷.

javascript
function addUserToGroup(group, user) {
  validateUser(user);
  group.push(user);
  return group;
}

متأسفانه، در حالی که فراخوانی validateUser برای ما بسیار آسان‌تر است، جایگزینی همهٔ مکان‌هایی که قبلاً هر بررسی را فهرست کرده بودیم کار آسان نیست. اول باید هر یک از آن نقاط را شناسایی کنیم. اگر با کدبیس بزرگ سر و کار داریم، ممکن است کار سرسخت باشد. دوم، در بازبینی هر یک از این مکان‌ها، احتمالاً چند موردی پیدا می‌کنیم که یک یا دو بررسی را فراموش کرده‌ایم. در برخی موارد این باگ است و می‌توانیم با اطمینان بررسی‌ها را با یک فراخوانی validateUser جایگزین کنیم؛ در موارد دیگر ممکن است عمدی بوده باشد و نمی‌توانیم کورکورانه کد موجود را با تابع کمکی جدید جایگزین کنیم به خطر معرفی پسرفت (regression). بنابراین، سبک‌تر کردن بار برنامه‌نویسی دفاعی ما نیاز به برنامه‌ریزی و اجرای بازآرایی قابل توجه دارد.

فقدان پایدار نظم

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

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

آشپزهای زیاد در آشپزخانه

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

با مهندسان زیاد روی همان کدبیس، ارگونومی را لزوماً بر اساس آنچه برای سلامت بلندمدت کدبیس بهترین است تعریف نمی‌کنید، بلکه بر اساس آنچه می‌دانید باید دور مشارکت‌کنندگان دیگر کار کنید. این می‌تواند به کد دفاعی طولانی، یا کد نامناسب جای‌گذاری‌شده برای اجتناب از تعارض ادغام (merge conflict) بالقوه منجر شود.

حرکت بیش از حد سریع

تکرار سریع و توسعهٔ محصول می‌تواند اگر کنترل نشود، کیفیت نرم‌افزار را به‌سرعت فرو بپاشد. هنگام ساخت قابلیت‌های محصول جدید تحت مهلت‌های تهاجمی، گوشه‌ها را می‌بریم: چند مورد آزمون حذف می‌کنیم، نام‌های عمومی به متغیرها می‌دهیم، یا چند دستور if اضافه می‌کنیم جایی که می‌توانستیم تابع جدید بسازیم. اگر گوشه‌هایی که بریده‌ایم را درست یادداشت نکنیم و زمان لازم برای اصلاح فوراً پس از رسیدن به مهلت هدف اختصاص ندهیم، انباشته می‌شوند. به‌زودی توابع بسیار طولانی، پر از منطق انشعابی و با پوشش آزمون واحد (unit test) کم تا هیچ در سراسر کدبیس دارید. در برنامه‌های پیچیده‌تر، جایی که چند تیم روی قابلیت‌های متمایز کنار هم تکرار می‌کنند، اثرات حرکت بیش از حد سریع تشدید می‌شود. مگر هر تیم بتواند تغییرات محصول را مؤثر با هر تیم دیگر هماهنگ کند، مقدار کد اضافی (cruft) انباشته می‌شود. نمونهٔ این اثر تشدیدشونده را در شکل ۲-۱ می‌بینید.

بسیاری از ما روی برنامه‌های مدرن یکپارچه‌سازی و تحویل مستمر (continuous integration and delivery) را اجرا می‌کنیم؛ تغییرات را تا حد ممکن به شاخهٔ اصلی ادغام می‌کنیم، جایی که با اجرای آزمون‌های خودکار روی ساخت جدید برنامه اعتبارسنجی می‌شوند. مطمئن می‌شویم مشتریان به قابلیت‌های نیمه‌پخته و رفع‌های جزئی باگ در معرض قرار نگیرند با مسدود کردن (gating) این تغییرات پشت پرچم‌های قابلیت (feature flags) — که feature toggle هم نامیده می‌شوند. در حالی که این‌ها انعطاف خوبی در توسعهٔ فعال می‌دهند، پس از معرفی موفق تغییر به همهٔ کاربران، فراموش کردنشان آسان است.

شکل ۲-۱. نمودار تجمع کد اضافی (cruft) در طول زمان.

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

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

به‌کارگیری دانش ما

فروپاشی کد اجتناب‌ناپذیر است. هرچقدر سخت تلاش کنیم از آن‌ها اجتناب کنیم، تغییراتی در نیازمندی‌ها خواهد بود که برنامه‌های ما باید با آن‌ها سازگار شوند. می‌توانیم توسعه تحت فشار را کمینه کنیم، اما گاه باید گوشه ببریم تا سریع عرضه کنیم و به کسب‌وکار مزیت رقابتی بدهیم. اگر فروپاشی کد اجتناب‌ناپذیر است، بازآرایی در مقیاس (refactoring at scale) به همان اندازه اجتناب‌ناپذیر است. همیشه نیاز داریم مسائل پیچیده و سیستمی در کدبیس‌هایمان را رفع کنیم. اگر فکر می‌کنیم به نقطه‌ای رسیده‌ایم که فروپاشی بیش از حد سنگین است و تیم مهندسی را از توسعه به خوبی ممکن باز می‌دارد، باید کلاه ایمنی بگذاریم و هم چرا و هم چگونه به این نقطه رسیدیم را روشن کنیم.

وقتی یاد می‌گیریم فراتر از مشکلات فوری کد ببینیم و در عوض شرایطی که در آن در ابتدا نوشته شده را بفهمیم، می‌بینیم کد ذاتاً بد نیست. همدلی می‌سازیم و از این دیدگاه جدید برای شناسایی مشکلات بنیادین واقعی کد و طراحی برنامه برای بهبود به بهترین شکل ممکن استفاده می‌کنیم. این فرایند را یک تمرین بزرگ باستان‌شناسی کد (code archaeology) بدانید!

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


بخش دوم

برنامه‌ریزی