حالت تاریک
فصل ۱۲ — پنهانسازی اطلاعات و انتزاع
پنهانسازی اطلاعات و انتزاع بهعنوان «فرایند حذف جزئیات یا ویژگیهای فیزیکی، فضایی یا زمانی در مطالعهٔ اشیاء یا سیستمها برای تمرکز توجه بر جزئیات مهمتر» تعریف میشود.[^1]
در عنوان این فصل دو ایدهٔ کمی متفاوت در علوم کامپیوتر را با هم آوردهام؛ متفاوتاند اما مرتبطاند و برای فکر کردن به اصول بنیادی مهندسی نرمافزار بهتر است با هم در نظر گرفته شوند.
انتزاع یا پنهانسازی اطلاعات
این دو ایده را ادغام میکنم چون فکر نمیکنم تفاوت بین آنها بهاندازهٔ کافی مهم باشد که نگرانش شویم. منظورم کشیدن خطوط یا درزها در کدمان است تا وقتی از «بیرون» به آن خطوط نگاه میکنیم، به آنچه پشت آنهاست اهمیت ندهیم. بهعنوان مصرفکنندهٔ تابع، class، library یا ماژول، نباید نیاز داشته باشم یا اهمیت دهم چطور کار میکند؛ فقط چطور از آن استفاده کنم.
برخی دیدگاهی بسیار باریکتر از پنهانسازی اطلاعات دارند، اما فکر نمیکنم چیز مفیدی اضافه کند. اگر نمیتوانید از نگرانی «پنهانسازی اطلاعات فقط دربارهٔ داده است» (نیست) رها شوید، هر وقت «پنهانسازی اطلاعات» گفتم، «انتزاع» فکر کنید.
اگر نمیتوانید از فکر «انتزاع فقط یعنی ساخت مفهوم-شیءهای انتزاعی» رها شوید — در حالی که بخشی از تعریف است اما منظورم نیست — شاید «پنهانسازی اطلاعات» فکر کنید.
اطلاعاتی که پنهان میکنم رفتار کد است. شامل جزئیات پیادهسازی و هم هر دادهای است که ممکن است استفاده کند یا نکند. انتزاعی که به دنیای بیرون ارائه میدهم باید این ترفند نگهداشتن رازها از سایر بخشهای کد را انجام دهد.
باید واضح باشد که اگر هدفمان مدیریت پیچیدگی است تا بتوانیم سیستمهای پیچیدهتری بسازیم که راحتتر در ذهنمان جا بگیرند، باید اطلاعات را پنهان کنیم.
میخواهیم بتوانیم روی کار/کد جلویمان تمرکز کنیم بدون نگرانی از آنچه جای دیگر میگذرد و چطور چیزهایی که الان نیازی به اهمیت دادن به آنها نداریم کار میکنند. این بنیادی به نظر میرسد، اما کد زیادی در دنیا اینطور نیست. برخی کدها در برابر تغییر شکنندهاند، جایی که تغییر در یک جا بخشهای دیگر کد را تحتالشعاع قرار میدهد. کدی هست که تنها راه پیشرفت این است که آنقدر باهوش باشید که بیشتر سیستم را بفهمید. این رویکرد مقیاسپذیر نیست!
چه چیزی «تودههای گل» (Big Balls of Mud) میسازد؟
گاهی به این codebaseهای سختکار «big balls of mud» میگوییم. اغلب آنقدر درهمتنیده و پیچیدهاند که مردم از تغییرشان میترسند. اکثر سازمانها — بهویژه بزرگترها — که مدتی نرمافزار ساختهاند، مقداری کد درهمتنیدهٔ چنین مالک هستند.
مشکلات سازمانی و فرهنگی
علل پیچیده و متنوعاند. یکی از شکایات رایج توسعهدهندگان و تیمهای توسعه «مدیرم اجازه نمیدهد XXX» است، که «XXX» یا «refactor»، یا «test»، یا «بهتر طراحی کنم»، یا حتی «آن باگ را رفع کنم» است.
قطعاً سازمانهای ناخوشایندی در دنیا هستند. اگر جایی مثل آن کار میکنید، توصیه میکنم کارفرمای بهتری پیدا کنید. با این حال در اکثریت قاطع موارد این شکایت سادهلوحانه درست نیست، یا حداقل کاملاً درست نیست. در بدترین حالت بهانه است. مقصر دانستن مردم را دوست ندارم، پس تفسیر سخاوتمندانهتر این است که بر پایهٔ سوءتفاهم مهمی استوار است.
اولین چیز این است: چرا ما بهعنوان توسعهدهندگان نرمافزار باید برای کار خوب اجازه بخواهیم؟ ما در توسعهٔ نرمافزار متخصصیم، پس بهترین جایگاه را برای فهمیدن چه کار میکند و چه نمیکند داریم.
اگر برای نوشتن کد استخدامم کنید، وظیفهام به شما بهترین کاری است که میتوانم انجام دهم. یعنی باید کارم را طوری بهینه کنم که بتوانم در مدت طولانی بهطور قابلاتکا، تکرارپذیر و پایدار کد تحویل دهم. کدم باید مسئلهای که با آن روبهروام حل کند و نیازهای کاربران و جاهطلبیهای کارفرمایم را برآورده کند.
پس باید کدی بسازم که کار کند، اما همچنین توانایی انجام این کار را در طول زمان، بهطور تکرارپذیر و قابلاتکا حفظ کنم. باید توانایی تغییر کد را با یادگیری بیشتر دربارهٔ مسئلهای که حل میکنیم و سیستمی که توسعه میدهیم حفظ کنم.
اگر آشپز رستوران باشم، احتمالاً یک وعده را سریعتر آماده میکنم اگر تصمیم بگیرم ابزار و محیط کارم را بعد از اتمام تمیز نکنم. احتمالاً برای یک وعده کار میکند. شاید برای دو وعده هم؛ چندشآور است، اما شاید کار کند. اگر همیشه اینطور کار کنم، اخراج میشوم!
اخراج میشوم چون به مشتریان رستوران مسمومیت غذایی میدهم. حتی اگر اخراج نشوم، تا وعدهٔ سوم خیلی کندتر و کمبهرهتر میشوم چون آشفتگی که ایجاد کرده مانع کارم میشود. باید برای هر کار محیط کار و ابزار را پاک کنم. با ابزارهایی که دیگر تیز نیستند دست و پنجه نرم میکنم و غیره. آشنا به نظر میرسد؟
اگر بهعنوان آشپز استخدامم کنید، هرگز نمیگویید «اجازه داری چاقوهایت را تیز کنی» یا «مسئولیت تمیز کردن محیط کارت با توست»، چون بهعنوان آشپز حرفهای، شما و من فرض میکنیم اینها بخش بنیادی حرفهای بودن است. بهعنوان آشپز، بخشی از وظیفهٔ مراقبتی من است.
بهعنوان حرفهایهای نرمافزار، وظیفهٔ ما فهمیدن آنچه برای توسعهٔ نرمافزار لازم است است. باید مسئولیت کیفیت کدی که روی آن کار میکنیم را بر عهده بگیریم. انجام کار خوب وظیفهٔ مراقبتی ماست. این خیرخواهانه نیست؛ عملی و عملگرایانه است. به نفع کارفرمایان، کاربران و خودمان است.
اگر برای ایجاد و حفظ کیفیت کدمان کار کنیم، کارفرمایان ویژگیهای جدید را کارآمدتر میگیرند. مشتریان کدی میگیرند که بیشتر معنا دارد و قابلاستفادهتر است و میتوانیم تغییرات را بدون نگرانی مداوم از شکستن چیزها انجام دهیم.
این به دلایل مختلف مهم است، نه کماهمیتترینش این است که دادهها بسیار روشناند.[^2] نرمافزار بازی برد کوتاهمدت نیست. اگر برای رسیدن به هدف تحویل کوتاهمدت آزمون را حذف میکنید، از refactor اجتناب میکنید یا وقت نمیگذارید طراحیهای مدولارتر و منسجمتر پیدا کنید، کندتر میروید نه سریعتر.
منطقی است سازمانی که نرمافزار میسازد بخواهد کارآمد انجام دهد. تأثیر اقتصادی دارد که همهٔ ما که برای چنین سازمانی کار میکنیم تحتالشعاع قرار میگیرد.
اگر میخواهیم سازمانهایی که در آن کار میکنیم شکوفا شوند و تجربهٔ خوشایندتری هنگام ساخت نرمافزاری که به شکوفایی سازمانها کمک میکند داشته باشیم، باید مؤثر کار کنیم.
هدفمان باید هر کاری که برای ساخت نرمافزار بهتر سریعتر لازم است انجام دهیم. دادهها مشخص است: کتاب Accelerate بخشی از آنچه لازم است را توصیف میکند و قطعاً شامل برش سادهلوحانهٔ گوشهها در کیفیت نیست. برعکس درست است.
یکی از یافتههای کلیدی گزارش «State of DevOps» که رویکرد علمی تحلیل عملکرد تیمهای نرمافزار outlined در کتاب Accelerate را پشتیبانی میکند این است که trade-off بین سرعت و کیفیت وجود ندارد. اگر کار ضعیفی در کیفیت انجام دهید، نرمافزار را سریعتر نمیسازید.
پس وقتی مدیر برای کاری تخمین میخواهد، به نفع شما، مدیر و کارفرما نیست که در کیفیت گوشه ببرید. در کل کندتر میروید، حتی اگر مدیر احمق باشد و فکر کند برعکس است.
سازمانهایی دیدهام که عمداً یا ناخواسته فشار برای سرعت بیشتر به توسعهدهندگان وارد کردهاند. با این حال اغلب خود توسعهدهندگان و تیمهای توسعه در تصمیمگیری دربارهٔ معنای «سرعت بیشتر» شریکاند.
معمولاً توسعهدهندگان کیفیت را رد میکنند، نه مدیران یا سازمان. مدیران و سازمانها «نرمافزار بهتر سریعتر» میخواهند، نه «نرمافزار بدتر سریعتر». در واقعیت حتی این هم trade-off نیست. همانطور که دیدیم، trade-off واقعی در دورههای طولانی بین «نرمافزار بهتر سریعتر» و «نرمافزار بدتر کندتر» است. «بهتر» با «سریعتر» همراه است. مهم است همهٔ ما این را بشناسیم و باور کنیم. کارآمدترین تیمهای توسعهٔ نرمافزار بهخاطر دور انداختن کیفیت سریع نیستند، بلکه بهخاطر پذیرش آن.
وظیفهٔ حرفهای مهندس نرمافزار شناختن این حقیقت و همیشه ارائهٔ مشاوره، تخمین و افکار طراحی بر پایهٔ نتیجهٔ باکیفیت است.
تخمینها و پیشبینیها را parse نکنید تا زمان کار خوب را جدا کنید؛ فرض کنید مدیران، همکاران و کارفرمایان میخواهند کار خوب انجام دهید و انجام دهید.
هزینهٔ انجام کار وجود دارد. در آشپزی، بخشی از آن زمان تمیز کردن و نگهداری ابزار در حین کار است. در توسعهٔ نرمافزار، آن هزینهها refactor، test، وقت گذاشتن برای طراحیهای خوب، رفع باگ هنگام یافتن، همکاری، ارتباط و یادگیری است. اینها گزینههای «خوب داشتن باشد» نیستند؛ بنیانهای رویکرد حرفهای به توسعهٔ نرمافزارند.
هر کسی میتواند کد بنویسد؛ این شغل ما نیست. توسعهٔ نرمافزار بیش از آن است. شغل ما حل مسئله است و این مستلزم دقت در طراحی و در نظر گرفتن اثربخشی راهحلهایی است که تولید میکنیم.
مشکلات فنی و مشکلات طراحی
با فرض اینکه به خودمان اجازهٔ کار خوب میدهیم، سؤال بعدی چه میطلبد؟ واقعاً موضوع این کتاب است. تکنیکهای بهینهسازی برای یادگیری شرحدادهشده در بخش دوم و تکنیکهای توصیفشده در این بخش، با هم ابزارهایی میدهند که کار بهتری انجام دهیم.
بهطور خاص در زمینهٔ اجتناب و اصلاح big balls of mud، ذهنیتی مهم است که باید اتخاذ شود: اینکه تغییر کد موجود چیز خوب و منطقی است.
بسیاری از سازمانها یا از تغییر کدشان میترسند یا نوعی احترام برای آن دارند که با واقعیت سازگار نیست. استدلال میکنم برعکس: اگر نمیتوانید یا نمیخواهید کد را تغییر دهید، کد عملاً مرده است. دوباره از Fred Brooks نقل میکنم:
بهمحض اینکه طراحی را منجمد کنید، منسوخ میشود.[^3]
دوستم Dan North دربارهٔ ایدهٔ جالبی صحبت کرده. Dan استعداد دارد ایده را در عبارتی هوشمندانه بیان کند. دربارهٔ «نیمهعمر نرمافزاری یک تیم» (software half-life) بهعنوان معیار کیفیت صحبت کرد.
نه من نه Dan دادهای برای پشتیبانی این ایده نداریم، اما جالب است. میگوید کیفیت نرمافزار تولیدشده توسط تیم تابعی از نیمهعمر نرمافزاری آن است — یعنی زمانی که طول میکشد تیم نیمی از نرمافزاری که مسئول آن است بازنویسی کند.
در مدل Dan، تیمهای خوب احتمالاً نیمی از نرمافزار مسئولیتشان را در چند ماه بازنویسی میکنند؛ تیمهای کمعملکرد شاید هرگز نیمی را بازنویسی نکنند.
مطمئناً ایدهٔ Dan بسیار زمینهای است؛ وقتی آن را مطرح کرد، در تیم معاملات مالی بسیار خوب و پرسرعت کار میکرد. به همان اندازه مطمئنم تیمهای زیادی هستند که این قاعده اعمال نمیشود. با این حال قطعاً ذرهای حقیقت در آن هست.
اگر همانطور که استدلال میکنم، رشتهای بنیاداً ریشه در توانایی یادگیریمان دارد، وقتی چیزهای جدیدی یاد میگیریم که دید ما از آنچه برای طراحیمان بهینه است (هرچه در زمینهمان معنا دارد) را تغییر میدهد، در آن نقطه باید بتوانیم آن را برای انعکاس درک عمیقتر جدیدمان تغییر دهیم.
وقتی Kent Beck زیرعنوان کتاب مشهورش دربارهٔ Extreme Programming را انتخاب کرد، Embrace Change را برگزید. نمیدانم هنگام انتخاب آن زیرعنوان چه در ذهن داشت، اما فکر میکنم پیامدهای بسیار گستردهتری از آنچه هنگام اولین خواندن تصور کردم دارد.
اگر به این فلسفهٔ بنیادی باور داشته باشیم که باید توانایی تغییر ایدهها، تیمها، کد یا فناوریمان را با یادگیری بیشتر حفظ کنیم، تقریباً همهٔ چیز دیگری که در این کتاب میگویم بهطور طبیعی پیامد دارد.
کار به شیوههایی که درهای اشتباه کردن و اصلاح آن را باز بگذارد؛ تعمیق درک مسئلهای که با آن روبهرویم و انعکاس آن در طراحی؛ تکامل افزایشی محصولات و فناوریها به سمت موفقیت هرجا و هرچه باشد — همهٔ اینها اهداف مهندسی خوب در نرمافزارند.
برای انجام این کار باید در گامهای کوچک که آسان undo شوند کار کنیم. باید کد فضای قابلسکونتی باشد که شاید ماهها یا سالها بعد برویم و هنوز بفهمیم. باید بتوانیم در یک بخش کد تغییر دهیم بدون اینکه بخشهای دیگر تحتالشعاع قرار گیرند. راهی برای validate سریع و مؤثر که تغییراتمان امن بودند لازم داریم. ایدهآل این است که بتوانیم برخی فرضیات معماری را با تغییر درک یا شاید محبوبیت سیستممان تغییر دهیم.
همهٔ ایدههای این کتاب به آن مربوطاند، اما انتزاع یا پنهانسازی اطلاعات به نظرم روشنترین مسیر به سیستمهای قابلسکونت (habitable) است.
بالا بردن سطح انتزاع
برای دستیابی به بهبود یک مرتبهای Brooksian چه لازم است؟ یکی از مسیرهای کاوش، بالا بردن سطح انتزاع برنامهنویسی است.
رایجترین موضوع در این خط فکر، تقویت ارتباط بین نمودارهای سطح بالایی که گاهی برای توصیف سیستمهایمان استفاده میکنیم است. «آیا خوب نیست وقتی سیستمم را نقاشی میکنم، از همان نقاشی برای برنامهنویسی سیستم هم استفاده کنم؟»
در طول سالها تلاشهای زیادی برای پیادهسازی این بوده و دورهای نسخههای جدید این ایده ظهور میکند. در زمان نگارش، تجسم فعلی این رویکرد low code development نامیده میشود.
با این حال چند مشکل مانع این رویکرد میشود.
یک رویکرد رایج توسعهٔ مبتنی بر نمودار، استفاده از نمودار برای تولید source code است. ایده این است که از نمودارها برای ایجاد ساختار کلی کد استفاده شود و سپس جزئیات را برنامهنویس دستی پر کند. این استراتژی تقریباً محکوم به شکست است بهخاطر یک مشکل سختحل: تقریباً همیشه با تکامل توسعهٔ هر سیستم پیچیده بیشتر یاد میگیرید.
در نقطهای باید به برخی تفکرات اولیهتان برگردید. یعنی نسخهٔ اول نمودار و در نتیجه اسکلت سیستم اشتباه است و با تعمیق درک باید تغییر کند. توانایی «round-trip» یا ساخت اسکلت، تغییر جزئیات دستی، تغییر نظر، بازتولید نمودار از کد، تغییر آن اما حفظ تغییرات جزئی مشکل دشواری است. همهٔ چنین تلاشهایی تاکنون در این مانع زمین خوردهاند.
پس حذف گام کدنویسی دستی چطور؟ چرا نمودارها را بهعنوان کد استفاده نکنیم؟ این هم بارها امتحان شده. چنین سیستمهایی معمولاً demo فوقالعادهای دارند. هنگام ساخت سیستم نمونهٔ ساده بسیار زیبا و آسان به نظر میرسند.
با این حال دو مشکل بزرگ وجود دارد. واقعاً سخت است سطح انتزاع را آنقدر بالا ببرید که با نقاشی بهجای نوشتن کد سود ببرید. همهٔ مزایایی را که در طول زمان برای پشتیبانی زبانهای برنامهنویسی متعارفتر تکامل یافتهاند — مثل exception handling، version control، پشتیبانی debugging، library code، آزمون خودکار، design patternها و غیره — از دست میدهید.
مشکل اول دلیل demo خوب اما مقیاسناپذیری به سیستمهای دنیای واقعی است. آسان است «زبان» گرافیکی بسازیم که مسائل ساده را اختصار بیان کند، اما ساخت «زبان» بصری مشابه با ابزارهای general-purpose برای ساخت هر منطق دلخواه بسیار سختتر است. زبانهای Turing-complete واقعاً از ایدههای بسیار رایج اما نسبتاً سطح پایین ساخته شدهاند. سطح جزئیاتی که برای توصیف و encode کردن سیستم نرمافزاری کارآمد و پیچیده نیاز داریم بهنظر ذاتاً پیچیده و fine-grained است.
نیاز افزودن نمودار به spreadsheet را در نظر بگیرید. اکثر برنامههای spreadsheet ابزارهایی دارند که افزودن نمودار را بهصورت گرافیکی ممکن میکند. میتوانید ردیفها و ستونهای داده را انتخاب کنید و تصویر نوع نمودار موردنظر را انتخاب کنید و برای موارد ساده برنامه نمودار تولید میکند. ابزارهای خوبیاند.
اما اگر داده بهراحتی در یکی از الگوهای از پیش تعریفشدهٔ ساده نمیگنجد سختتر میشود. هرچه نیازمندیهای نمودار خاصتر باشد، دستورالعملها به سیستم نمودارسازی spreadsheet باید جزئیتر شود. نقطهای میرسد که محدودیتهای ابزار استفاده را سختتر از آسانتر میکند. اکنون نهتنها ایدهٔ روشنی از نحوهٔ کار نمودار لازم دارید، بلکه درک عمیقی از نحوهٔ دور زدن یا اعمال مدل برنامهنویسی در ذهن توسعهدهندهٔ سیستم نمودارسازی.
متن راهی شگفتانگیز انعطافپذیر و اختصار برای encode کردن ایدههاست.
ترس از over-engineering
عوامل زیادی توسعهدهندگان را وادار میکند مسئولیت کیفیت را رها کنند. یکی فشار واقعی یا perceived برای انجام کارآمد کار است. شنیدهام افراد تجاری نگران «over-engineering» توسعهدهندگان و تیمها باشند. این ترس واقعی است و ما حرفهایهای فنی مقصرایم. گاهی گناه over-engineering را بر عهده داشتهایم.
انتزاع در برابر عملگرایی
روزی روی پروژهای برای مشتری، شرکت بیمهٔ بزرگ، کار کردم. این «پروژهٔ نجات» بود. برای consultancyای کار میکردم که برای رفتن و تحویل راهحلهای مؤثر به پروژههای گیرکرده یا شکستخورده مشهور بود.
این پروژه بهطور چشمگیر دو بار شکست خورده بود. بیش از سه سال در توسعه بود و چیز قابلاستفادهای برای نشان دادن نداشتند.
شروع به کار کردیم و پیشرفت مناسبی روی جایگزین داشتیم. معمار از «گروه استراتژی» یا نام مشابه نزدیک شد. اصرار کرد نرمافزارمان باید با «معماری سراسری» مطابقت داشته باشد. پس من بهعنوان tech lead پروژه بررسی کردم آن چه میطلبد.
برنامهٔ بزرگی برای معماری component-based توزیعشده و مبتنی بر service داشتند که کل کسبوکارشان را abstract میکرد. serviceهایی برای چیزهای فنی و هم رفتارهای مفید سطح domain داشتند. زیرساختشان امنیت و persistence را هم مراقبت میکرد و به سیستمهای enterprise اجازه میداد کاملاً با هم integrated باشند.
همانطور که حالا حدس میزنید، همهٔ این vapor-ware بود. اسناد زیاد و مقدار مناسبی کد داشتند که بهنظر من کار نمیکرد. این پروژه توسط تیمی بیش از ۴۰ نفر ساخته میشد و حدود سه یا چهار سال دیر بود. همهٔ پروژهها مجبور بودند از این زیرساخت استفاده کنند، اما هیچ پروژهای هرگز نکرد!
روی کاغذ معماری خوب به نظر میرسید، اما در عمل فقط تئوری بود.
مؤدبانه رد کردیم و سیستمی که میساختیم را بدون ایدهها یا tech این معماری تمام کردیم. روی کاغذ معماری خوب بود، اما در عمل فقط جادو بود؛ خیالی بود.
ما فناشنوردانیم. در نتیجه گرایشهای مشترکی داریم. یکی که باید آگاه باشیم و در برابرش محافظت کنیم، تعقیب «ایدههای فنی درخشان» است. من هم مثل هر کسی به ایدههای فنی علاقهمندم. بخشی از جذابیت رشتهٔ ماست، نوع یادگیری که ارزش مینهیم. اما اگر میخواهیم مهندس باشیم، باید درجهای عملگرایی، حتی شکاکیت، اتخاذ کنیم. بخشی از تعریف مهندسی در آغاز این کتاب عبارت «در محدودیتهای اقتصادی» بود. همیشه باید به سادهترین مسیر موفقیت فکر کنیم، نه جالبترین، نه پرتکنولوژیترین برای CV یا résumé.
حتماً با ایدههای جدید بهروز بمانید. از فناوریها یا رویکردهای جدید کار آگاه باشید، اما همیشه استفادهٔ آنها را در زمینهٔ مسئلهای که میخواهید حل کنید صادقانه ارزیابی کنید. اگر این tech یا ایده را برای یادگیری مفید بودنش به کار میبرید، این واقعیت را بشناسید و کاوش را سریع و کارآمد بهعنوان trial، prototype یا experiment انجام دهید، نه بهعنوان سنگبنای معماری جدیدی که آیندهٔ شرکت به آن وابسته است. آماده باشید اگر جواب نداد دور بیندازید و کل توسعه را روی techی که cool به نظر میرسد ریسک نکنید.
در تجربهٔ من، اگر «تلاش برای سادگی» را جدی بگیریم، بیشتر نه کمتر احتمال دارد به چیز cool برسیم. بیشتر نه کمتر احتمال دارد ارزش CV و résumهمان را هم افزایش دهیم.
راه دیگری هم هست که اغلب به over-engineer کردن راهحلهایمان فریب میخوریم: future-proof کردن. اگر گفته یا فکر کردهاید «شاید الان لازم نباشد، اما احتمالاً در آینده لازم میشود»، داشتید future-proof میکردید. من هم گذشته مثل بقیه گناهکار بودم، اما اکنون آن را نشانهٔ نابالغی طراحی و مهندسی میدانم.
این نوع future-proofing طراحی برای بیمهای که بتوانیم با بهبودها یا تغییر نیازمندیهای آینده کنار بیاییم انجام میدهیم. هدف خوبی است، اما راهحل اشتباه.
باز به کتاب Extreme Programming Explained Kent Beck، مفهومی معرفی کرد:
YAGNI: You Ain't Gonna Need It!
توصیهٔ Kent این بود که کد را برای حل مسئلهای که همین الان با آن روبهرویم بنویسیم و فقط همان. این توصیه را بهشدت تکرار میکنم، اما بخشی از کل بزرگتر است.
همانطور که بارها در این کتاب گفتهام، نرمافزار چیز عجیبی است. تقریباً بینهایت انعطافپذیر و فوقالعاده شکننده است. میتوانیم هر ساختاری که بخواهیم در نرمافزار بسازیم، اما با تغییر آن ریسک آسیب به آن ساختار را داریم. مشکلی که مردم هنگام over-engineer کردن با تلاش برای future-proof کردن میخواهند حل کنند این است که از تغییر کدشان میترسند.
در پاسخ به آن اضطراب، سعی میکنند طراحی را همین الان در زمان ثابت کنند، وقتی به آن توجه میکنند. هدفشان این است که در آینده نیازی به بازبینی نداشته باشند. اگر تا اینجای کتاب آمدهاید، میدانید فکر میکنم این ایدهٔ بسیار بدی است. پس چه میتوانیم بهجایش انجام دهیم؟
میتوانیم طراحی کدمان را طوری انجام دهیم که هر نقطه در آینده وقتی چیز جدیدی یاد گرفتیم بتوانیم به آن برگردیم و تغییرش دهیم. میتوانیم از آن انعطاف تقریباً بینهایت بهره ببریم. اکنون مشکلی که باید حل کنیم شکنندگی کدمان است.
چه چیزی اعتماد به تغییر امن کد در آینده میدهد؟ سه رویکرد وجود دارد و یکی احمقانه است.
میتوانیم آنقدر باهوش باشیم که کد و همهٔ پیامدهها و وابستگیهایش را کاملاً بفهمیم و با اطمینان تغییر دهیم. این مدل hero-programmer است و اگرچه احمقانهترین است، بهنظر من یکی از رایجترین استراتژیهاست. اکثر سازمانها تعداد کمی «قهرمان»[^4] دارند که هنگام بروز مشکل برای «نجات روز» فراخوانده میشوند یا برای تغییرات دشکار. اگر در سازمانتان قهرمان دارید، باید دانشش را پخش کند و با دیگران کار کند تا سیستم قابلفهمتر شود. این بهمراتب ارزشمندتر از آتشنشانی معمولتر «قهرمانها»ست.
راهحلهای واقعی ترس از تغییر کد، انتزاع و آزمون است. اگر کدمان را abstract کنیم، بهتعریف پیچیدگی یک بخش سیستم را از بخش دیگر پنهان میکنیم. یعنی میتوانیم با اطمینان بسیار بالاتر — حتی اگر اشتباه باشد — در یک بخش سیستم تغییر دهیم بدون اینکه بخشهای دیگر تحتالشعاع قرار گیرند. برای اطمینان بیشتر به آزمون هم نیاز داریم، اما مثل همیشه ارزش آزمون ساده نیست.
بهبود انتزاع از طریق آزمون
در شکل ۴.۲ نمودار مسطح Cost of Change را نشان دادم که وضعیت ایدهآلی را نشان میدهد که میخواهیم هر زمان با هزینهٔ تقریباً یکسان از نظر زمان و تلاش هر تغییری انجام دهیم.
برای دستیابی به این منحنی مسطح Cost of Change، استراتژی regression testing مؤثر و کارآمد لازم است — که واقعاً یعنی استراتژی regression testing کاملاً خودکار. تغییر دهید و آزمونها را اجرا کنید تا ببینید کجا چیزها را شکستید.
این ایده یکی از سنگبنایهای continuous delivery است، مؤثرترین نقطهٔ شروع رویکرد مهندسی که میشناسم. طوری کار میکنیم که نرمافزارمان «همیشه در حالت releasable» باشد و آن «releasability» را از طریق آزمون خودکار کارآمد و مؤثر تعیین میکنیم.
با این حال جنبهٔ دیگری از آزمون فراتر از گرفتن اشتباهاتمان مهم است و برای کسانی که هرگز اینطور کار نکردهاند تشخیصش سختتر است.
تأثیر قابلیت آزمون بر طراحی است که قبلاً توصیف کردم. در فصل ۱۴ این ایده را عمیقتر کاوش میکنیم. بهطور خاص در زمینهٔ انتزاع، اگر به آزمونها بهعنوان mini-specification برای رفتار مطلوب کدمان نگاه کنیم، آن رفتار مطلوب را از بیرون به درون توصیف میکنیم.
مشخصات را بعد از اتمام کار نمینویسید؛ قبل از شروع لازم دارید. پس مشخصات (آزمونها) را قبل از نوشتن کد مینویسیم. چون کد را نداریم، تمرکزمان روشنتر روی آسانتر کردن زندگی خودمان است. هدف در این نقطه این است که بیان مشخصات (آزمون) را تا حد امکان روشن و ساده کنیم.
ناگزیر — یا حداقل باید — خواستههایمان برای رفتاری که از کد میخواهیم را از منظر مصرفکنندهٔ آن، تا حد امکان روشن و ساده بیان میکنیم. نباید در این نقطه به جزئیات پیادهسازی لازم برای برآورده کردن آن mini-specification فکر کنیم.
اگر این رویکرد را دنبال کنیم، بهتعریف طراحیمان را abstract میکنیم. interfaceای به کدمان تعریف میکنیم که بیان ایدهها را آسان کند تا test case را خوب بنویسیم. یعنی کدمان هم استفاده از آن آسان است. نوشتن مشخصات (آزمون) عمل طراحی است. طراحی میکنیم چگونه انتظار داریم برنامهنویسان با کدمان تعامل کنند، جدا از نحوهٔ کار خود کد. همهٔ اینها قبل از رسیدن به جزئیات پیادهسازی. این رویکرد مبتنی بر انتزاع به جداسازی آنچه کد باید انجام دهد از نحوهٔ انجام آن کمک میکند. در این نقطه کم یا هیچ چیز دربارهٔ نحوهٔ پیادهسازی رفتار نمیگوییم؛ آن بعداً میآید.
این رویکرد عملی، عملگرایانه و سبک design by contract[^5] است.
قدرت انتزاع
بهعنوان توسعهدهندگان نرمافزار، همه با قدرت انتزاع آشنا هستیم — بهعنوان مصرفکننده. اما وقتی تولیدکنندهٔ نرمافزار میشویم، بسیاری از توسعهدهندگان به انتزاع در کد خودشان توجه کافی نمیدهند.
سیستمعاملهای اولیه نسبت به جانشینهای مدرنشان abstraction سختافزاری زیادی نداشتند. امروز اگر بخواهم کارت ویدیوی PC را عوض کنم، کل پشتهای از abstractionها برنامههایم را از چنین تغییراتی عایق میکند، پس میتوانم با اطمینان که برنامهها احتمالاً کار میکنند و چیزها را نمایش میدهند آن تغییر را انجام دهم.
vendorهای cloud مدرن مشغول abstract کردن بخش زیادی از پیچیدگی عملیاتی اجرای برنامههای توزیعشده و مقیاسپذیر پیچیدهاند. APIای مثل Amazon Web Service's S3 فریبآمیز ساده است. هر دنبالهای از بایتها را همراه برچسبی برای بازیابی و نام «bucket» برای قرار دادن submit میکنم و AWS آن را به مراکز داده در سراسر جهان توزیع میکند و برای هر کسی که مجاز به دسترسی است در دسترس قرار میدهد و SLAهایی میدهد که دسترسی را در همهٔ رویدادها جز فاجعهآمیزترینها حفظ میکند. این abstract کردن چیزهای نسبتاً پیچیدهای است!
انتزاعها میتوانند اصل سازماندهی در جبهٔ گستردهتر هم باشند. ساختارهای دادهٔ semantic-tagged مثل HTML، XML و JSON برای ارتباطات بسیار محبوباند. برخی میگویند بهخاطر «plain text» ترجیح میدهند، اما واقعاً درست نیست. plain text برای کامپیوتر چه معنایی دارد؟ در نهایت همه جریان الکترون از ترانزیستور است و الکترونها و ترانزیستورها هم abstraction هستند!
جذابیت HTML یا JSON برای پیامهای بین ماژولهای مختلف کد این است که ساختار داده در ارتباط صریح است و schema همراه محتوا منتقل میشود. میتوانستیم با مکانیزمهای پرکاراییتر مثل Google Protocol Buffers[^6] یا SBE[^7] این کار را بکنیم، اما عمدتاً نمیکنیم.
توسعهدهندگان واقعاً مکانیزمهای بهشدت ناکارآمد مثل JSON یا HTML را دوست دارند چون همه با آنها کار میکنند. بهخاطر abstraction مهم دیگر: plain text. plain text ساده نیست و text هم نیست؛ پروتکل و abstractionی است که به ما اجازه میدهد با اطلاعات سر و کار داشته باشیم بدون نگرانی زیاد از نحوهٔ سازماندهی آن، جز در سطح پایهای نمایندگی بهعنوان جریان کاراکتر. با این حال هنوز abstractionی است که اطلاعات را از ما پنهان میکند.
این اکوسیستم «plain text» در محاسبات pervasive است، اما طبیعی یا اجتنابناپذیر نیست. مردم آن را طراحی کردند و در طول زمان تکامل یافته. باید روی چیزهایی مثل byte ordering و الگوهای encoding توافق میکردیم. همهٔ اینها قبل از فکر کردن به abstractionهای زیرین که از طریق آنها سختافزاری که نرمافزارمان روی آن اجرا میشود را میفهمیم است.
abstraction «plain text» بسیار قدرتمند است. abstraction بسیار قدرتمند دیگر «فایلها» در محاسبات است، به اوج در مدل Unix که همهچیز فایل است. میتوانیم منطق را برای ساخت سیستمهای جدید پیچیدهتر با «pipe» کردن فایلها از خروجی یک ماژول به ورودی دیگر به هم وصل کنیم. همهٔ اینها «ساختهشده» است و فقط راه مفیدی برای تصور و سازماندهی آنچه واقعاً میگذرد است.
انتزاعها برای توانایی ما در برخورد با کامپیوترها بنیادیاند. برای فهمیدن و برخورد با سیستمهایی که برای افزودن ارزش به کامپیوترهایمان میسازیم هم بنیادیاند. یک نگاه به کاری که هنگام نوشتن نرمافزار انجام میدهیم (و بهنوعی تنها کاری که انجام میدهیم) ایجاد abstractionهای جدید است. کلید ایجاد abstractionهای خوب است.
انتزاعهای leaky
انتزاعهای leaky بهعنوان «انتزاعی که جزئیاتی را leak میکند که قرار بود abstract کند» تعریف میشوند.
این ایده توسط Joel Spolsky محبوب شد که گفت:
همهٔ انتزاعهای غیربدیهی leaky هستند.[^8]
گاهی شنیدهام مردم کد وحشتناک را با چیزی شبیه «همهٔ abstractionها leaky هستند، پس چرا زحمت؟» توجیه کنند. این کاملاً نکتهٔ پست اصلی و انتزاع بهطور کلی را از دست میدهد.
کامپیوترها و نرمافزار بدون انتزاع وجود نداشتند. ایدهٔ «leaky abstractions» استدلال علیه آنها نیست؛ بلکه توصیف میکند انتزاعها چیزهای پیچیدهایاند که باید مراقبشان باشیم.
انواع مختلف «leak» هم وجود دارد. leakهایی که اجتناب از آنها غیرممکن است که مؤثرترین مسیر فکر کردن دقیق و کمینهسازی تأثیرشان است. مثلاً اگر میخواهید سیستم low-latency بسازید که داده را «نزدیکترین حد ممکن به محدودیتهای سختافزار» پردازش کند، abstractionهای «garbage collection» و «random access memory» مانع میشوند چون از نظر زمان leak میکنند و latency متغیر میشود. پردازندههای مدرن صدها برابر سریعتر از RAM هستند، پس اگر به زمان اهمیت میدهید دسترسی random نیست. هزینهٔ متفاوتی در زمان بسته به منبع اطلاعاتی که میخواهید پردازش کنید وجود دارد. پس باید برای بهره از سختافزار بهینه کنید؛ abstractionهایش، cacheها، prefetch cycleها و غیره را بفهمید و در طراحی لحاظ کنید اگر میخواهید تأثیر leak را کمینه کنید.
نوع دیگر leak واقعاً نقطهای است که توهم illusionی که abstraction میخواهد القا کند بهخاطر کم آوردن زمان، انرژی یا تخیل برای پوشش آن شکست در طراحی میخورد.
service مجوزدهی که شکستهای عملکردی را بهعنوان خطاهای HTML گزارش میدهد و ماژول business logic که NullPointerException برمیگرداند، هر دو abstractionهای سطح کسبوکار را با شکستهای فنی میشکنند. هر دو نوعی شکست در تداوم توهم illusionی هستند که abstraction میخواهد القا کند.
بهطور کلی، با این نوع دوم leak سعی کنید تا حد امکان سطح انتزاع سازگار حفظ شود. ممکن است قابلقبول باشد component راهدور exposed بهعنوان web service از نوعی شکستهای ارتباطی را از طریق HTML گزارش کند؛ آن مشکل در قلمرو فنی abstraction شبکه و ارتباطات است، نه در دنیای خود service. اشتباه استفاده از کدهای خطای HTML برای شکستهای سطح کسبوکار service است. این شکست در abstraction است.
یک نگاه این است که انتزاع، همهٔ انتزاع، اساساً دربارهٔ مدلسازی است. هدف ایجاد مدلی از مسئله است که به استدلال و انجام کار کمک کند. این نقلقول George Box را دوست دارم:
همهٔ مدلها اشتباهاند، برخی مدلها مفیدند.[^9]
همیشه در این وضعیت هستیم. هرچقدر هم مدلهایمان خوب باشند، بازنمایی حقیقتاند نه خود حقیقت. مدلها میتوانند حتی وقتی اساساً نادرستاند فوقالعاده مفید باشند.
هدف کمال نیست؛ دستیابی به مدلهای مفیدی است که بهعنوان ابزار حل مسئله استفاده کنیم.
انتخاب abstractionهای مناسب
ماهیت abstractionهایی که انتخاب میکنیم مهم است. «حقیقت» جهانی اینجا نیست؛ اینها مدلاند.
نمونهٔ خوبی نقشهها (نوع انسانی «map»، نه ساختار دادهٔ زبان برنامهنویسی) هستند. همهٔ نقشهها البته abstraction دنیای واقعیاند، اما بسته به نیاز انواع abstraction متفاوت داریم.
اگر بخواهم قایق یا هواپیما را به مقصد هدایت کنم، نقشهای مفید است که مسیر بین دو نقطه را اندازه بگیرم. (این نوع نقشه، بهطور دقیق، chart نامیده میشود، یعنی میتوانم «bearing» روی chart اندازه بگیرم و اگر آن مسیر را هدایت کنم به جای درست میرسم.) ایدهٔ constant bearing chart توسط Mercator در ۱۵۶۹ اختراع شد.
بدون جزئیات زیاد، constant bearing chartها بر پایهٔ چیزهایی به نام Rhumb-lineها هستند. میتوانید bearing روی این نوع نقشه اندازه بگیرید و اگر از نقطهٔ A با آن bearing حرکت کنید (با قایق یا هواپیما) به نقطهٔ B میرسید.
همانطور که همه میدانیم، دنیا صفحهٔ تخت نیست؛ کره است، پس در واقعیت این کوتاهترین فاصله بین A و B نیست چون روی سطح کره کوتاهترین فاصله منحنی است و bearing مدام تغییر میکند. پس abstraction نقشه ریاضیات پیچیدهتر صفحات منحنی را پنهان میکند و ابزار عملی برای برنامهریزی مسیر میدهد.
این abstraction از نظر اینکه فاصلهای که طی میکنید از حد ضروری طولانیتر است leak میکند، اما چون برای سهولت استفاده هنگام برنامهریزی و دریانوردی بهینه میکنیم، همهچیز خوب است.
abstraction کاملاً متفاوتی برای اکثر نقشههای مترو استفاده میشود. این توسط Harry Beck در ۱۹۳۳ اختراع شد.
نقشهٔ Harry کلاسیک طراحی شده و ایده در سراسر جهان برای نشان دادن حرکت در شبکهٔ مترو استفاده میشود. Harry فهمید هنگام پیمایش London Tube (متروی لندن) وقتی در مسیر هستید به موقعیت مکانیتان اهمیت نمیدهید. پس نقشهٔ topologically accurate از شبکه ساخت که رابطهٔ واقعی با جغرافیای فیزیکی نداشت. این سبک نقشه، این abstraction، به مسافران اجازه میدهد ببینند کدام قطارها به کدام ایستگاهها میروند و کدام ایستگاهها به خطوط دیگر اتصال دارند — بسیار روشن. اما اگر بخواهید با آن بین ایستگاهها پیادهروی کنید، abstraction میشکند. برخی ایستگاهها چند قدم از هماند اما دور به نظر میرسند؛ برخی نزدیک به نظر میرسند اما دورند.
نکتهٔ من این است که داشتن abstractionهای مختلف — حتی abstractionهای مختلف برای همان چیز — مشکلی ندارد. اگر مأمور بودیم کابل شبکه بین ایستگاههای London Underground بکشیم، انتخاب نقشهٔ Harry احمقانه است. اما اگر بخواهیم از Arsenal Tube Station به Leicester Square برای شام برویم، انتخاب chart جغرافیایی احمقانه است.
انتزاع و مدلسازی در قلب طراحی است. هرچه abstractionها هدفمندتر به مسئلهای که میخواهید حل کنید باشند، طراحی بهتر است. توجه کنید نگفتم «دقیقتر». همانطور که نقشهٔ مترو Harry بهوضوح نشان میدهد، abstraction برای بسیار مفید بودن نیازی به دقت ندارد.
باز هم قابلیت آزمون میتواند بازخورد و الهام زودهنگام در تلاش برای abstractionهای مفید بدهد.
یکی از استدلالهای رایج علیه unit testing و گاهی علیه TDD این است که آزمونها و کد «به هم قفل میشوند» و تغییر همهچیز سختتر میشود. این بیشتر انتقاد unit testing است، جایی که آزمونها بعد از تکمیل کد نوشته میشوند. چنین آزمونهایی ناگزیر به system under test tightly coupled میشوند چون بهعنوان آزمون نوشته شدهاند نه مشخصات. TDD کمتر از این مشکل رنج میبرد چون آزمون (مشخصات) را اول مینویسیم و به abstract کردن مسئله هدایت میشویم.
ظرافت اینجا — و ارزش عظیم TDD — این است که اگر مشخصات abstract را نوشتهام، متمرکز بر آنچه کد باید انجام دهد نه چگونه، آنچه آزمون بیان میکند abstraction من است. پس اگر آزمون در برابر تغییر شکننده است، abstraction من در برابر تغییر شکننده است. پس باید بیشتر دربارهٔ abstractionهای بهتر فکر کنم. راه دیگری برای این نوع بازخورد نمیشناسم.
فصل بعد دربارهٔ coupling است. coupling نامناسب یکی از چالشهای مهم توسعهٔ نرمافزار است. کل این بخش کتاب واقعاً دربارهٔ استراتژیهای مدیریت coupling است. مشکل این است که ناهار مجانی نیست. طراحیهای بیشازحد abstract میتوانند به اندازهٔ طراحیهای کمانتزاع دردسر باشند. میتوانند ناکارآمد باشند و هزینههای توسعه و performance ناخواسته تحمیل کنند. پس نقطهٔ شیرینی وجود دارد و قابلیت آزمون سیستم ابزاری برای رسیدن به آن است.
بهطور کلی، هدفمان باید حفظ توانایی تغییر نظر دربارهٔ پیادهسازی — و تا جایی که میتوانیم طراحی — بدون کار اضافی زیاد باشد. دستور پخت ثابت اینجا نیست. این مهارت واقعی توسعهٔ نرمافزار خوب است و با تمرین و تجربه میآید. باید غریزهمان را تقویت کنیم تا انتخابهای طراحی که توانایی تغییر نظر بعداً را محدود میکنند بشناسیم و گزینهها را باز نگه داریم.
یعنی هر توصیهای که اینجا میدهم زمینهای است. با این حال چند راهنما، نه قاعده:
Abstractionهای برگرفته از حوزهٔ مسئله
مدلسازی حوزهٔ مسئله به طراحیتان راهنما میدهد. به شما اجازه میدهد تفکیک دغدغهٔ طبیعی برای حوزهٔ مسئله دست یابید و شاید حتی مجبورتان کند مسئلهای که میخواهید حل کنید را بهتر بفهمید. تکنیکهایی مثل event storming[^10] نقطهٔ شروع عالی برای نقشهکشی قلمرو مسئله است.
event storming میتواند خوشههای رفتار را شناسایی کند که ممکن است مفاهیم موردعلاقه را نمایندگی کنند و مفاهیم جالب کاندیدهای خوبی برای ماژولها یا serviceها در طراحیاند. میتواند bounded contextها و خطوط طبیعی abstraction در حوزهٔ مسئله را برجسته کند که تمایل دارند از یکدیگر decoupledتر از تقسیمبندیهای فنیتر باشند.
Domain-Specific Languages
ایدهای که قطعاً برای بالا بردن سطح انتزاع امیدوارکنندهتر است، domain-specific language (DSL) است. اما بهتعریف DSL general-purpose نیست. عمداً باریکتر متمرکز است و میتواند abstractتر باشد و جزئیات را پنهان کند.
این اثری است که واقعاً هنگام demo سیستمهای توسعهٔ مبتنی بر نمودار میبینیم. تأثیر DSL — در این مورد گرافیکی — روی حل مسائل با دامنهٔ محدود را میبینیم. در این فضا، این روشهای محدودتر برای نمایندگی ایدهها بسیار قدرتمند و مفیدند.
DSL ابزار بسیار مفیدی است و نقش مهمی در توسعهٔ سیستمهای قدرتمند، شاید حتی «قابلبرنامهنویسی توسط کاربر» دارد، اما همان چیز محاسبات general-purpose نیست، پس واقعاً موضوع این کتاب نیست؛ پس همینجا میگذاریم، اما بهعنوان نکتهٔ کوتاه، راه بهتری برای ایجاد test caseهای مؤثر از ساخت DSLای نیست که رفتارهای مطلوب سیستم را بهعنوان «مشخصات اجرایی» بیان کند.
Abstract کردن پیچیدگی تصادفی
نرمافزار روی کامپیوتر اجرا میشود. نحوهٔ کار کامپیوترها مجموعهای abstraction و محدودیت دارد که مجبوریم با آنها کنار بیاییم. برخی عمیقاند، در سطح اطلاعات و نظریهٔ اطلاعات، مثل concurrency و ارتباطات sync در برابر async. برخی کمی بیشتر مخصوص پیادهسازی سختافزار مثل معماری cache پردازنده یا تفاوت RAM و offline storage.
جز برای سادهترین سیستمها نمیتوانید اینها را نادیده بگیرید و بسته به ماهیت سیستم ممکن است باید بسیار عمیق در نظر بگیرید. با این حال abstractionهاییاند که ناگزیر leak میکنند. اگر شبکه قطع باشد، نرمافزارتان تحتالشعاع قرار میگیرد، بالاخره.
بهطور کلی در طراحیهایم هدف دارم interface بین قلمرو پیچیدگی تصادفی و قلمرو پیچیدگی ضروری (حوزهٔ مسئله) را تا جایی که میتوانم abstract کنم. این کمی تفکر طراحی خوب و کمی تفکر مهندسی میطلبد.
سؤال شروع این است: چطور دنیای پیچیدگی تصادفی را در قلمرو پیچیدگی ضروری نمایندگی کنم؟ منطق سیستمم چه باید دربارهٔ کامپیوتری که روی آن اجرا میشود بداند؟ باید تلاش کنیم آن دانش را کمینه کنیم.
Listing 12.1 سه مثال انسجام از فصل ۱۰ را نشان میدهد. اگر از منظر abstraction و تفکیک پیچیدگی تصادفی و ضروری نگاه کنیم، بینش بیشتری میگیریم.
python
def add_to_cart1(self, item):
self.cart.add(item)
conn = sqlite3.connect('my_db.sqlite')
cur = conn.cursor()
cur.execute('INSERT INTO cart (name, price) values (item.name, item.price)')
conn.commit()
conn.close()
return self.calculate_cart_total();
def add_to_cart2(self, item):
self.cart.add(item)
self.store.store_item(item)
return self.calculate_cart_total();
def add_to_cart3(self, item, listener):
self.cart.add(item)
listener.on_item_added(self, item)مثال اول، add_to_cart1، اصلاً abstract نمیکند و در نتیجه کمی آشفته است.
بعدی، add_to_cart2، بهتر است. abstraction برای ذخیرهٔ اطلاعات اضافه کردهایم. «درزی» در کد به نام store ایجاد کردهایم و این به کد اجازه میدهد منسجمتر باشد و خط تمیزی در تفکیک دغدغهها بین توابع ضروری domain — افزودن آیتم به سبدها و محاسبهٔ مجموعها — و پیچیدگی تصادفی ناشی از تمایز کامپیوترمان بین RAM سریع اما volatile و دیسک کندتر اما nonvolatile بکشد.
سرانجام در add_to_cart3 abstraction داریم که کد پیچیدگی ضروری را دستنخورده میگذارد. abstraction دستنخورده است با امتیاز بسیار جزئی معرفی ایدهٔ چیزی که به آنچه اتفاق افتاد علاقهمند است — Listener.
از نظر سازگاری abstraction، add_to_cart3 به نظرم بهترین است. حتی مفهوم ذخیرهسازی هم حذف شده.
زیبایی این abstraction این است که مدل accidental concerns چقدر تمیز است و در نتیجه آزمون یا تقویت این کد با رفتار جدید on_item_added چقدر آسان است.
هزینهٔ این abstraction، leakی که ممکن است مانع بهترین بودن add_to_cart3 شود، این سؤال را مطرح میکند: اگر تلاش برای ذخیره fail شود چه؟ اگر پایگاه داده از connection pool تمام شود یا دیسک پر شود یا کابل شبکه بین کد و پایگاه داده تصادفاً قطع شود؟
مثال اول مدولار نیست، انسجام ندارد، پیچیدگی تصادفی و ضروری را قاطی میکند و تفکیک دغدغه ندارد؛ هنوز فقط کد بد است!
دو تای دیگر بهترند، نه برای مفهوم مصنوعی زیبایی یا ظرافت، بلکه به دلایل عملی و عملگرایانه.
نسخههای ۲ و ۳ انعطافپذیرتر، کمتر coupled، مدولارتر و منسجمترند بهخاطر تفکیک دغدغهها و abstractionهایی که انتخاب کردهایم. انتخاب abstraction بین این دو واقعاً انتخاب طراحی است که باید توسط زمینهای که این کد در آن وجود دارد هدایت شود.
میتوانیم چند روش کار را تصور کنیم.
مثلاً اگر نبود ذخیرهسازی با افزودن آیتم به سبد transactional باشد، باید تغییر سبد را undo کنیم. این ناخوشایند است چون technicalities ذخیرهسازی به abstraction خالص قبلی ما نفوذ میکند. شاید بتوانیم گسترهٔ leak را محدود کنیم؛ به Listing 12.2 نگاه کنید.
python
def add_to_cart2(self, item):
if (self.store.store_item(item))
self.cart.add(item)
return self.calculate_cart_total();در Listing 12.2 از نسخهٔ کاملاً abstract شدهٔ ۳ عقبنشینی کردیم و مفهوم «ذخیرهسازی» را در abstraction مجاز کردیم. ماهیت transactional رابطه بین ذخیره و افزودن آیتم به سبد را با مقدار بازگشتی موفقیت یا شکست نمایندگی کردیم. توجه کنید abstraction را با برگرداندن کدهای خطای مخصوص پیادهسازی و leak آنها به abstraction سطح domain قاطی نمیکنیم. ماهیت فنی شکست را به مقدار Boolean بازگشتی محدود کردهایم. یعنی مسائل گرفتن و گزارش خطاها جای دیگری — شاید داخل پیادهسازی «storage» — حل میشوند.
این مثال دیگری از تلاش برای کمینهسازی تأثیر leakهای ناگزیر abstraction است. موارد شکست را هم مدل و abstract میکنیم. اکنون دوباره میتوانیم انواع پیادهسازی «store» را تصور کنیم. کد در نتیجه انعطافپذیرتر است.
یا میتوانیم دیدگاه relaxed و decoupledتر بگیریم. در add_to_cart3 در Listing 12.1 میتوانیم تصور کنیم پشت رویداد on_item_added برخی «تضمینها»[^11] وجود دارد. تصور کنیم اگر بهدلیلی on_item_added fail شود، تا موفقیت retry میشود. (در واقعیت باید باهوشتر باشیم، اما برای سادگی مثال همانطور بماند!)
اکنون مطمئنیم در نقطهای در آینده «store» یا هر چیز دیگری که به on_item_added پاسخ میدهد بهروز میشود.
این قطعاً پیچیدگی ارتباط زیر on_item_added را افزایش میدهد، اما abstraction را قویتر حفظ میکند و بسته به زمینه شاید ارزش پیچیدگی اضافی را داشته باشد.
هدف من با این مثالها کاوش همهٔ گزینهها نیست؛ بلکه نشان دادن برخی trade-offهای مهندسی است که بسته به زمینهٔ سیستم ممکن است انتخاب کنیم.
«تفکر مثل مهندس» که اشاره کردم و امیدوارم اینجا نشان دادم، در فکر کردن به روشهایی است که چیزها میتوانند اشتباه پیش بروند. شاید به یاد آورید Margaret Hamilton این را سنگبنای رویکردش هنگام اختراع اصطلاح software engineering توصیف کرد.
در این مثال تصور کردیم اگر ذخیرهسازی fail شود چه میشود. فهمیدیم در آن موقعیت abstraction leak میکند. پس مجبور شدیم بیشتر فکر کنیم و چند راه مختلف برای کنار آمدن با آن leak به ذهن رسید.
جداسازی سیستمها و کد third-party
تفاوت واضح دیگر بین نسخه ۱ add_to_store و نسخههای ۲ و ۳ این است که نسخه ۱ کدمان را به کد third-party مشخص — در این مورد sqlite3 — expose و couple میکند. این کتابخانهٔ رایجی در دنیای Python است، اما حتی پس کدمان بهطور concrete به این کتابخانهٔ third-party مشخص گره خورده. دلیل دیگر بدترین بودن این سه نسخه همین coupling به کد third-party است.
هزینهٔ کوچک و ناچیز برش بلوک کدی که دربارهٔ sqlite3، connectionها و بند INSERT صحبت میکند و انتقال آن به جای دیگر، دور از کدی که به هیچکدام اهمیت نمیدهد، گام بزرگی به سمت عمومیت بیشتر است. سود زیادی با کار بسیار کم.
بهمحض اینکه اجازه دهیم کد third-party وارد کدمان شود، به آن coupled میشویم. بهطور کلی ترجیح و توصیهٔ من این است که همیشه کدتان را با abstractionهای خودتان از کد third-party عایق کنید.
قبل از ادامه چند هشدار. واضح است زبان برنامهنویسی و کتابخانههای پشتیبانی رایج آن هم «کد third-party» هستند. پیشنهاد نمیکنم wrapper خودتان برای Stringها یا Listها بنویسید، پس مثل همیشه توصیهٔ من راهنماست نه قاعدهٔ سخت. با این حال توصیه میکنم با دقت فکر کنید چه چیزی را «داخل» کدتان مجاز میکنید. موضع پیشفرض من این است که مفاهیم زبان و کتابخانههای استاندارد را مجاز میکنم، اما هیچ کتابخانهٔ third-partyای که با زبانم نمیآید نه.
هر کتابخانهٔ third-partyای که استفاده میکنم از طریق facade یا adapter خودم دسترسی مییابد که interface من به آن را abstract و در نتیجه ساده میکند و لایهٔ عایق نسبتاً سادهای بین کد من و کد کتابخانه فراهم میکند. به همین دلیل نسبت به frameworkهای همهجانب که سعی میکنند مدل برنامهنویسیشان را بر من تحمیل کنند محتاطم.
شاید کمی extreme به نظر برسد و شاید extreme باشد، اما این رویکرد یعنی سیستمهایم composableتر و انعطافپذیرترند.
حتی در مثال پیشپاافتادهای که نگاه کردیم، add_to_cart2 abstractionای ارائه میدهد که در زمینهٔ پیادهسازی ذخیرهسازی من معنا دارد. میتوانم نسخهای بدهم که در اصل همان بلوک کد پیادهسازی ذخیره در sqlite3 از add_to_store1 است، اما همچنین نوع کاملاً متفاوتی از store بنویسم بدون نیاز به تغییر پیادهسازی add_to_cart2. میتوانم همان کد را در سناریوهای مختلف استفاده کنم و حتی نوعی composite از store بنویسم که آیتمها را در چند جا ذخیره کند اگر نیاز پیش آمد.
سرانجام میتوانیم کدمان را نسبت به این abstraction آزمون کنیم که همیشه نسخهٔ سادهتری از چیز واقعی خواهد بود. در نتیجه راهحلم بهطور چشمگیر انعطافپذیرتر و در صورت اشتباه آسانتر برای تغییر خواهد بود، با کار اضافی بسیار کم.
همیشه ترجیح دهید اطلاعات را پنهان کنید
راهنمای قوی دیگر برای هدایت کد به سمتی که درهای تغییر آینده را بدون شکستن YAGNI باز نگه دارد، ترجیح نمایندگیهای عمومیتر بهجای خاصتر است، اما این توصیه کمی بیشازحد ساده است. واضحترین نمایش این ایده احتمالاً از طریق signatureهای تابع و متد است.
Listing 12.3 سه نسخه از signature تابع نشان میدهد. یکی برای من خیلی بهتر از بقیه به نظر میرسد، اگرچه مثل همیشه زمینهای است.
java
public ArrayList<String> doSomething1(HashMap<String, String> map);
public List<Sting> doSomething2(Map<String, String> map);
public Object doSomething3(Object map);اولی بیشازحد خاص است. وقتی مقدار بازگشتی را جمع میکنم، واقعاً اهمیت میدهم ArrayList باشد نه هر نوع List دیگر؟ شاید موارد نادر و ناچیزی تصور کنم، اما بهطور کلی ترجیح میدهم اهمیت ندهم. تقریباً مطمئناً به List-بودن علاقهدارم نه ArrayList-بودن!
«خب،» میشنوید فریاد میزنید، «پس همیشه نمایندگی abstractترین و genericترین را ترجیح بده.» بله، اما در محدودهٔ منطقی که abstraction را حفظ میکند. احمقانه است این توصیه را دنبال کنم و signature ناخوشایند نسخهٔ doSomething3 را بسازم. این آنقدر generic است که احتمالاً بیفایده است. باز هم مواقعی ممکن است Object سطح abstraction درست باشد، اما باید نادر باشند و همیشه در قلمرو پیچیدگی تصادفی نه ضروری.
پس بهطور کلی doSomething2 احتمالاً هدف رایج من است. بهاندازهٔ کافی abstract هستم که به خاصیت فنی doSomething1 گره نخوردهام، اما بهاندازهٔ کافی خاص هستم که در ارائهٔ اشارههایی دربارهٔ نحوهٔ مصرف اطلاعاتی که تولید میکنم و انتظاراتم برای اطلاعاتی که مصرف میکنم مفید باشم.
مطمئناً خسته شدهاید که تکرار میکنم، اما باز هم توانایی شناسایی نقطهٔ شیرین abstractionهایمان با طراحی برای قابلیت آزمون تقویت میشود. تلاش برای نوشتن آزمون و شبیهسازی استفاده از interfaceای که میسازیم فرصتی میدهد درکمان از آن interface به کد تحت آزمون را تجربه و به کار بیندازیم.
این، در ترکیب با ترجیح پنهان کردن اطلاعات بهطور کلی و ترجیح نمایندگیهای genericتر اطلاعاتی که با آنها سر و کار داریم که در زمینهمان معنا دارند، باز هم به باز نگهداشتن درهای تغییر آینده کمک میکند.
خلاصه
انتزاع در قلب توسعهٔ نرمافزار است. مهارت حیاتی برای مهندس نرمافزار طلبکار است. اکثر مثالهایم احتمالاً شیگرا هستند چون اینطور به کد فکر میکنم. با این حال این برای برنامهنویس تابعی یا حتی برنامهنویسی اسمبلی هم درست است. کدمان، هر ماهیتی داشته باشد، وقتی درزهایی در آن میسازیم که اطلاعات را پنهان میکنند بهتر است.
[^1]: منبع: Wikipedia، https://en.wikipedia.org/wiki/Abstraction_(computer_science)
[^2]: کتاب Accelerate توصیف میکند تیمهایی که رویکرد منضبطتری به توسعه دارند «۴۴٪ زمان بیشتر روی کار جدید» نسبت به تیمهایی که ندارند صرف میکنند. ببینید https://amzn.to/2YYf5Z8.
[^3]: نقلقول از کتاب Fred Brooks، The Mythical Man Month، https://amzn.to/3oCyPeU.
[^4]: نمونهٔ داستانی دلپذیر این در کتاب Gene Kim، The Phoenix Project، جایی که شخصیت Brent Geller تنها کسی است که میتواند روز را نجات دهد.
[^5]: design by contract رویکردی به طراحی نرمافزار متمرکز بر قراردادهاست که مشخصات سیستم یا اجزای آن را پشتیبانی میکنند. ببینید https://en.wikipedia.org/wiki/Design_by_contract.
[^6]: Google Protocol Buffers قرار است نسخهٔ کوچکتر، سریعتر و کارآمدتر XML باشد. بیشتر بخوانید: https://bit.ly/39QsPZH.
[^7]: Simple Binary Encoding (SBE) در مالی استفاده میشود. رویکرد encode دادهٔ دودویی است که ساختارهای داده را تعریف میکنید و کد برای ترجمه در هر طرف تولید میشود. بیشتر: https://bit.ly/3sMr88c.
[^8]: میتوانید پست اصلی Joel Spolsky را اینجا بخوانید: https://bit.ly/2Y1UxNG.
[^9]: نقلقول از آمارشناس George Box، اگرچه ایده قدیمیتر است. ببینید https://bit.ly/2KWUgbY.
[^10]: event storming تکنیک تحلیل مشارکتی اختراعشده توسط Alberto Brandolini است که مدلسازی تعاملات در حوزهٔ مسئله را ممکن میکند. ببینید http://bit.ly/3rcGkdt.
[^11]: دانشمندان کامپیوتر — بهدرستی — میگویند ارائهٔ «تحویل تضمینشده» غیرممکن است. منظورشان این است که نمیتوانید «تحویل دقیقاً یکبار» را تضمین کنید، اما میتوانیم با آن کنار بیاییم. ببینید https://bit.ly/3ckjiwL.