طراحی شیءگرا
توسعه چابک یعنی تولید مرحله به مرحله نرم افزار در فواصل کوچک با ارتباطات تنگاتنگ با مشتری. سوالی که پیش میآید این است که اگر چابکی عبارت است از ساخت نرم افزار در فواصل کوچک، چگونه می توانیم نرم افزار را طراحی کنیم؟ چگونه می توانیم مطمئن شویم که نرم افزار دارای ساختار خوب، انعطاف پذیر، قابل نگهداری و قابل استفاده مجدد است؟ اگر شما در فواصل کوچک کار کنید آیا تصویر کلی نرم افزار را از دست نخواهید داد؟
پاسخ این است که در تیم چابک، تصویر کلی با خود نرم افزار رشد می کند. در هر تکرار، طراحی نرم افزار بهبود مییابد تا جایی که برای حال حاضر خوب باشد. تیم وقت زیادی را صرف این نمی کند که به نیازمندیهایی که ممکن است در آینده رخ دهد، بپردازد. بر عکس تیم روی ساختار جاری سیستم تمرکز میکند و تا جایی که امکان دارد آن را خوب میسازد. این کار به معنی رها کردن معماری و طراحی نیست، بلکه راهی برای رشد و توسعه پیوسته و دائمی طراحی است.
برای آنکه طراحی قوی و درست را یاد بگیریم، لازم است که نشانههای طراحی ضعیف را بدانیم. این نشانهها عبارتند از:
۱- Rigidity (انعطاف ناپذیری): یک ماژول انعطاف ناپذیر است، اگر یک تغییر در آن، منجر به تغییرات در سایر ماژولها گردد. هر چه میزان تغییرات آبشاری بیشتر باشد، نرم افزار خشک تر و غیر منعطف تر است.
۲- Fragility (شکنندگی): وقتی که تغییر در قسمتی از نرم افزار باعث به بروز اشکال در بخشهای دیگر شود.
۳- Immobility (تحرک ناپذیری): وقتی نتوان قسمت هایی از نرم افزار را در جاهای دیگر استفاده نمود و یا به کار گیری آن هزینه و ریسک بالایی داشته باشد.
۴- Viscosity (لزجی): وقتی حفظ طراحی اصولی پروژه مشکل باشد، میگوییم پروژه لزج شده است. به عنوان مثال وقتی تغییری در پروژه به دو صورت اصولی و غیر اصولی قابل انجام باشد و روش غیر اصولی راحت تر باشد، می گوییم لزج شده است. البته لزجی محیط هم وجود دارد مثلا انجام کار به صورت اصولی نیاز به Build کل پروژه دارد که زیاد طول میکشد.
۵- Needless Complexity (پیچیدگی اضافی): زمانی که امکانات بدون استفاده در نرم افزار قرار گیرند.
۶- Needless Repetition (تکرارهای اضافی): وقتی که کدهایی با منطق یکسان در جاهای مختلف برنامه کپی میشوند، این مشکلات رخ می دهند.
۷- Opacity (ابهام): وقتی که فهمیدن یک ماژول سخت شود، رخ می دهد و کد برنامه مبهم بوده و قابل فهم نباشد.
چرا نرم افزار تمایل به پوسیدگی دارد؟
در روش های غیر چابک یکی از دلایل اصلی پوسیدگی، عدم تطابق نرم افزار با تغییرات درخواستی است. لازم است که این تغییرات به سرعت انجام شوند و ممکن است که توسعه دهندگان از طراحی ابتدایی اطلاعی نداشته باشند. با این حال ممکن است تغییرایی قابل انجام باشد ولی برخی از آنها طراحی اصلی را نقض می کنند. ما نباید تغییرات نیازمندیها را مقصر بدانیم. باید طراحی ما قابلیت تطبیق با تغییرات را داشته باشد.
یک تیم چابک از تغییرات استقبال می کند. وقت بسیار کمی را روی طراحی اولیه کل کار می گذارد و سعی میکند که طراحی سیستم را تا جایی که ممکن است ساده و تمیز نگه دارد با استفاده از تست های واحد و یکپارچه از آن محافظت کند. این طراحی را انعطاف پذیر می کند. تیم از قابلیت انعطاف پذیری برای بهبود همیشگی طراحی استفاده میکند. بنابراین در هر تکرار نرم افزاری خواهیم داشت که نیازمندی های آن تکرار را برآورده می کند.
رعایت این اصول باعث میشوند کد برنامه سادهتر، انعطاف پذیرتر و قابل نگهداریتر باشد. در ادامه این اصول بیان میگردند.
KISS
قاعده Keep it simple stupid بیان می کند که به ساده ترین شکل ممکن کد بزنید. در حقیقت یکی از مشکلات رایج در طراحی نرم افزار، حل مسائل از روش های پیچیده است در حالی که می توان با روش ساده تر آن کار را انجام داد.
Dry
قاعده Don’t Repeat Yourself بیان کننده دوری کردن از تکرار کد در برنامه است. برای این کار کدهای تکراری با انتزاع به کلاس های واحدی انتقال می یابند. تکرار نه فقط در کد که در منطق و دانش برنامه هم باید حذف شده و به یک جا منتقل گردد.
Tell, Don’t Ask
این قاعده بیان کننده اصل بسته بندی در شیءگرایی است. بیان به کلاس های بگوییم که چه چیزی از آنها می خواهیم نه اینکه از وضعیت درونی آن سوال کنیم و سپس تصمیم بگیریم که چه چکاری را انجام دهند. این اصل باعث تنظیم مسئولیت های هر کلاس می گردد و از چسبیدگی کلاس ها به یکدیگر جلوگیری می کند.
YAGNI
قاعده You ain’t gonna need it میگوید که آنقدر کد بزنید که لازم است نه بیشتر. این قاعده در توسعه تست محور[۱] نقش بسیار مهمی دارد. نباید بر اساس تصورات در آینده و هر دلیلی غیر از ضرورت در حال حاضر، کد اضافه به برنامه وارد گردد.
SoC
قاعده Seperation of Concern عبارت است از دسته بندی کردن کد برنامه بر اساس کارکردشان. معماری لایهای یک نمونه عالی از اعمال SoC است. جداسازی برنامه به مسئولیتهای جدا از هم نقش مهمی در استفاده مجدد، نگهداری و تست پذیری سیستم دارد.
[۱] Test Driven Development
اصول طراحی SOLID
این اصول حاصل تجربیات تعداد زیادی از محققات و توسعه دهندگان نرم افزار است. تیم های چابک این اصول را برای رفع مشکلات طراحی به کار میبرند. دقت کنید بدون دلیل از این اصول استفاده نکنید، زیرا در این صورت با مشکل پیچیدگی اضافی مواجه خواهید شد.
اصل مسئولیت واحد (SRP[1])
هر کلاس باید تنها دارای یک مسئولیت باشد و در نتیجه فقط یک دلیل برای تغییر داشته باشد.
هدف این قانون جدا سازی مسئولیتهای چسبیده به هم است. به عنوان مثال کلاسی که هم مسئول ذخیره سازی و هم مسئول ارتباط با واسط کاربر است، این اصل را نقض میکند و باید به دو کلاس مجزا تقسیم شود.
دلیل اینکه باید هر کلاس یک مسئولیت داشته باشد این است که هر مسئولیت یک بعد برای تغییر است. وقتی که یک نیازمندی تغییر میکند باید کلاسی که مسئول آن است، تغییر کند. این تغییر ممکن است باعث اختلال در مسئولیت دیگری شود که این کلاس بر عهده دارد. این نوع چسبیدگی مسئولیتها منجر به طراحی شکننده میشود به طوری که ممکن است ما را با خطاهای غیر منتظرهای مواجه سازد.
مثال: مسئولیت واحد در کلاس Rectangle
در شکل زیر کلاس Rectangle دارای دو متد است. متد Draw() یک مستطیل را روی صفحه نمایش رسم میکند و متد Area() مساحت مستطیل را محاسبه میکند.
همچنین دو برنامه متفاوت از این کلاس استفاده می کنند. برنامه Computational Geometry Application محاسبات هندسی انجام میدهد، اما هرگز مستطیل رسم نمی کند. برنامه GeographicalApplication هم محاسبات هندسی انجام دهد و هم مستطیل رسم کند.
این طراحی اصل SRP را زیر پا می گذارد زیرا کلاس Rectangle دو مسئولیت دارد. این کار باعث بروز مشکلاتی میگردد. اولاً باید اسمبلی GUI در برنامه محاسبات هندسی بارگذاری گردد که اصلا چنین کاری منطقی نیست، زیرا این برنامه هیچ استفادهای از این اسمبلی نمیکند. ثانیاً اگر تغییری در GraphicalApplication رخ دهد که باعث تغییر در کلاس Rectangle شود، آنگاه ComputationalGeometryApplication که در این بین هیچ نقشی ندارد، تحت تاثیر قرار میگیرد. راه حل این مشکل جداسازی این دو مسئولیت در دو کلاس مجزا است.
قاعده باز بسته (OCP[2])
یک موجودیت نرم افزاری (کلاس، متد و غیره) باید برای توسعه باز باشد ولی برای تغییر بسته.
چگونه ممکن است که رفتار یک برنامه تغییر کند بدون اینکه کد آن ویرایش شود؟ چگونه می توانیم بدون تغییر یک موجودیت نرم افزاری کارکرد آن را تغییر دهیم؟
پاسخ این سوال در انتزاع است. میتوان یک انتزاع ایجاد کرد که ثابت بوده و گروه نامحدودی از رفتارهای هم خانواده را در بر بگیرد. انتزاع در OOP به صورت کلاسهای انتزاعی[۳] و یا واسط ها[۴] بوده و گروه نامحدودی از رفتارهای ممکن توسط کلاسهای مشتق شده[۵] از آنها بیان میگردند.
بنابراین یک ماژول میتواند دارای یک انتزاع باشد. این ماژول برای ویرایش بسته است زیرا وابسته به انتزاعی است که ثابت است، اما رفتار آن می تواند با ایجاد اشتقاق های جدید، تغییر یا توسعه یابد.
مثال: وابستگی Client و Server
شکل زیر یک مثال ساده از نقض اصل OCP را نشان میدهد. هر دو کلاس Client و Server به صورت واقعی[۶] بوده و انتزاعی نیستند. اگر بخواهیم Client از یک Server دیگری استفاده کند، کلاس Client باید تغییر کند تا نام کلاس Server جدید در آن بنشیند.
طراحی زیر بر اساس اصل OCP بنا نهاده شده است. در اینجا کلاس Client به واسط ClientInterface (که یک انتزاع از سرویسی است که باید ارائه شود) وابسته است. بنابراین کلاسهای مشتق شده از ClientInterface میتوانند آن را به هر شکلی که میخواهند پیاده سازی کنند و کلاس Client از تغییرات در امان خواهد بود.
طراحی فوق از الگوی طراحی Strategy بهره میبرد که در پارت ۳ معرفی میگردد.
علاوه بر الگوی Strategy از الگوی Template Method نیز میتوان برای ایجاد انتزاع و رعایت اصل OCP استفاده کرد. در طراحی زیر کلاس Policy دارای تعدادی توابع سیاست گذاری (PolicyFunction) است که بر اجرای عملیات سیاست گذاری میکنند.
در توابع سیاست گذاری، توابع عملیاتی (SerivceFunction) فراخوانی میشوند تا سرویسهای درخواستی را انجام دهند. توابع عملیاتی توسط کلاس Policy پیاده سازی نمیشوند، بلکه کلاسهای مشتق شده از آن (Implementation) این سرویسها را فراهم میکنند. بنابراین عملکرد کلاس Policy میتواند با ایجاد کلاسهای مشتق شده از آن و باز نویسی توابع عملیاتی تغییر یابد.
قاعده جایگزینی لیسکو (LSP[7])
زیر نوع ها باید بتوانند جایگزین نوع پایه خود باشند.
مکانیزم اساسی در پشت OCP انتزاع و چند شکلی است که با استفاده از وراثت محقق میشود. به کمک وراثت میتوانیم کلاسهای مشتق شده ایجاد کنیم تا متدهای انتزاعی کلاس پایه را پیاده سازی کنند. سوالی که پیش میآید این است که چگونه باید وراثت را انجام دهیم؟ قواعد طراحی در وراثت چیست؟ بهترین سلسله مراتب کلاس ها به چه شکل است؟ پاسخ این سوالات در اصل Liskov نهفته است. این قاعده میگوید که اشیاء کلاسهای مشتق شده (زیر نوعها) باید بتوانند جایگزین اشیاء کلاس پایه خود (نوع پایه) شوند.
مثال: کلاس Shape
یک نشانه آشکار نقض LSP استفاده از دستورات شرطی برای بررسی نوع اشیاء است. معمولا برای فراخوانی متد مناسب بر اساس نوع شیء این بررسی انجام میگیرد. کد زیر را در نظر بگیرید:
۱
۲
۳
۴
۵
۶
۷
۸
۹
۱۰
۱۱
۱۲
۱۳
۱۴
۱۵
۱۶
۱۷
۱۸
۱۹
۲۰
۲۱
۲۲
۲۳
۲۴
۲۵
۲۶
۲۷
۲۸
|
public class Shape { private ShapeType type; public Shape(ShapeType t) { type = t; } public static void DrawShape(Shape s) { if (s.type == ShapeType.square) (s as Square).Draw(); else if (s.type == ShapeType.circle) (s as Circle).Draw(); } } public class Circle : Shape { private Point center; private double radius; public Circle() : base (ShapeType.circle) { } public void Draw() { /* draws the circle */ } } public class Square : Shape { private Point topLeft; private double side; public Square() : base (ShapeType.square) { } public void Draw() { /* draws the square */ } } |
کلاسهای Square و Circle از کلاس Shape مشتق شده اند و دارای متد Draw() هستند، ولی هیچ متدی در Shape را پنهان نمیکنند. از آنجایی که این دو کلاس قابل تعویض با Shape نیستند، متد DrawShape باید نوع Shape ورودی را بررسی کند و بر اساس نوع آن متد Draw مناسب را فراخوانی نماید.
به این خاطر که کلاسهای Square و Circle نمیتوانند جایگزین Shape شوند، اصل LSP نقض میشود. از طرف دیگر متد DrawShape() قاعده OCP را زیر پا میگذارد، زیرا این متد درباره هر کلاس مشتق شده از کلاس پایه Shape باید آگاه بوده و تغییر کند. نقض LSP نتیجه مستقیم نقض اصل OCP است.
قاعده معکوس کردن وابستگی (DIP[8])
ماژول های سطح بالا نباید به ماژولهای سطح پایین وابسته باشند، هر دو باید به انتزاعات وابسته باشند. انتزاعات نباید وابسته به جزئیات باشند، بلکه جزئیات باید وابسته به انتزاعات باشند.
در طراحی ساخت یافته، ماژولهای سطح بالا به ماژولهای سطح پایین وابسته بودند. این مسئله دو مشکل ایجاد میکرد:
۱- ماژولهای سطح بالا (سیاست گذار) به ماژولهای سطح پایین (مجری) وابسته هستند. در نتیجه هر تغییری در ماژولهای سطح پایین ممکن است باعث اشکال در ماژولهای سطح بالا گردد.
۲- استفاده مجدد از ماژولهای سطح بالا در جاهای دیگر مشکل است، زیرا وابستگی مستقیم به ماژولهای سطح پایین دارند.
برای رفع این مشکل باید وابستگی به انتزاعات داشته باشیم، یعنی تا جایی که میتوانیم قراردادهای زیر را رعایت کنیم:
۱- هیچ متغیری نباید از نوع یک کلاس واقعی باشد.
۲- هیچ کلاسی نباید از یک کلاس واقعی مشتق شود.
۳- هیچ متدی نباید متد پیاده سازی شده در کلاس پایه خود را پنهان (Override) کند.
مثال: وابستگی کلید به لامپ
مطابق شکل زیر دو کلاس کلید و لامپ را داریم که در آن کلید (Button) به لامپ (Lamp) وابسته است.
اشکال طراحی فوق چیست و چرا اصل DIP زیر پا گذاشته میشود؟ دقت کنید که کلاس Button مستقیما به کلاس Lamp وابسته است. این وابستگی یعنی اینکه هر تغییر در کلاس Lamp ممکن است بر کلاس Button تاثیر بگذارد. همچنین از کلاس Button نمیتوان در جاهای دیگر مانند یک موتور، پنکه و غیره استفاده کرد. در اینجا یک نمونه از Button فقط و فقط یک نمونه از Lamp را کنترل میکند. این راهکار DIP را نقض میکند زیرا راهکارهای سطح بالا (روش و خاموش شدن) از پیاده سازیهای سطح پایین (یک لامپ) جدا نیستند و در نتیجه انتزاعات به جزئیات وابسته هستند.
پیدا کردن انتزاع نهفته
در این مثال سیاست سطح بالا کدام است؟ انتزاعی که در پشت این برنامه نهفته است، حقیقتی است که تغییر نمیکند حتی اگر جزئیات تغییر کنند. در مثال کلید/لامپ انتزاع نهفته عبارت است از اشاره روش/خاموش کردن کاربر و ربط دادن آن اشاره به شیء هدف. چه مکانیزمی برای شناسایی اشاره کاربر وجود دارد؟ (ربطی ندارد!) شیء مقصد چیست؟ (بی ربط است!). اینها جزئیاتی هستند که نباید انتزاع را تحت تاثیر قرار دهند. بنابراین طراحی قبلی میتواند با طراحی زیر جایگزین شود. در این مدل وابستگی کلید به لامپ معکوس شده است.
در این طراحی کلاس Button به واسط ButtonServer وابسته است. این واسط، قراردادی برای روشن و خاموش کردن چیزی تعریف میکند و کلاس Button به این انتزاع وابسته میشود. اکنون کلاس Lamp واسط ButtonServer را پیاده سازی میکند. در اینجا وابستگی معکوس شده است زیرا در طراحی قبل Button به کلاس Lamp وابسته بود ولی در اینجا کلاس Lamp است که به واسط ButtonServer وابسته است. طراحی فوق باعث میشود که کلاس Button با هر شیءی که ButtonServer را پیاده سازی میکند، کار کند مانند یک موتور، پنکه و غیره.
قاعده تفکیک واسط ها (ISP[9])
کلاینت ها نباید وابسته به متدهایی باشند که آنها را پیاده سازی نمیکنند.
این قانون برای رفع مشکلات واسطهای چاق است. وقتی که کلاسی یک واسط را پیاده سازی میکند، باید همه متدهای آن را نیز پیاده سازی کند. حالا اگر خیلی از ای متدها توسط این کلاس استفاده نشود، میگوییم که این کلاس از مشکل واسط چاق رنج میبرد.
برای رفع این مشکل، آن واسط را به واسطهای کوچکتر تقسیم میکنید. این تقسیم بندی باید بر اساس استفاده کنندگان از واسطها صورت گیرد.
مثال: آلودگی واسط در کلاسهای Door و TimedDoor
در سیستم امنیتی یک سازمان همه دربها از واسط زیر پیروی میکنند.
۱
۲
۳
۴
۵
۶
|
public interface Door { void Lock(); void Unlock(); bool IsDoorOpen(); } |
حال یک پیاده سازی از این واسط با نام TimedDoor را در نظر بگیرد. اگر این درب برای مدت زمانی مشخصی باز بماند، باید علامت هشدار بدهد. برای این منظور کلاس Timer و واسط TimerClient را بدین صورت تعریف میکنیم.
۱
۲
۳
۴
۵
۶
۷
۸
۹
۱۰
|
public class Timer { public void Register( int timeout, TimerClient client) { /*code*/ } } public interface TimerClient { void TimeOut(); } |
اگر شیءی بخواهد از پایان مهلت (Timeout) آگاه شوند، باید از متد Register استفاده کند تا مهلت زمانی و شیء آگاه شونده را مشخص نماید. در شکل زیر این موضوع بیان شده است.
در این طراحی Door واسط TimerClient را پیاده سازی میکند در حالی که Door به زمانبندی کاری ندارد و فقط به نقش یک درب را دارد. این نقض اصل ISP است زیرا کلاسهای مشتق شده از Door موظف هستند که واسط TimerClient را پیاده سازی کنند، در حالی که نیازی به آن ندارند.
جدا سازی کلاینتها به معنی جدا سازی واسطها است
Door و TimedDoor واسطهایی هستند که توسط دو کلاینت متفاوت استفاده میشوند. چون کلاینتها جدا هستند در نتیجه واسطها هم باید جدا باشند. زیرا کلاینتها روی واسطهای خود اعمال قدرت میکنند.
واسطهای کلاس در مقابل واسطهای شیء
دوباره کلاس TimedDoor را در نظر بگیرید. در اینجا یک شیء داریم که دارای دو واسط است و توسط دو کلاینت مجزا استفاده میگردد (Timer و کلاینتهای Door). این دو واسط باید در یک شیء واحد قرار گیرند زیرا پیاده سازیهای هر دو واسط، دادههای یکسانی را نگهداری میکنند. در این شرایط چگونه میتوانیم قاعده ISP را اعمال کنیم؟ چگونه میتوانیم واسط ها را جدا کنیم در حالی که آنها باید با یکدیگر بمانند. پاسخ سوال این است که کلاینتهای یک شیء لازم نیست که از طریق واسط به آن دسترسی یابد، بلکه از طریق کلاس پایهاش (وراثت) نیز میتوانند این کار را انجام دهند.
جداسازی واسطها از طریق وراثت
شکل زیر نشان میدهد که چگونه وراثت چندگانه میتواند منجر به رعایت اصل ISP شود. در این طراحی، TimedDoor از Door و TimerClient ارث بری میکند. با اینکه کلاینتهای هر دو واسط میتوانند از TimedDoor استفاده کنند، هیچ یک از آنها به TimedDoor وابسته نیستند. در واقع آنها به یک شیء از طریق دو واسط مجزا دسترسی دارند.
[۱] The Single-Responsibility Principle
[2] Open Close Principle
[3] Abstract Class
[4] Interface
[5] Derived Class
[6] Concrete
[7] Lisko Substantial Principle
[8] Dependency Inversion Principle