قوانین مربوط به برنامه نویسی شیء گرا و نوشتن کلاس های مناسب معمولا دو گروه هستند:
این تفکیک معمولا فقط در تئوری به این سادگی است. در عمل معمولا قوانین هر دو دسته با یکدیگر همپوشانی دارند و به یکدیگر کمک می کنند. در جلسه قبل با Demeter Law یا قانون دمیتر آشنا شدیم اما در این جلسه نوبت به مجموعه قوانین SOLID می رسد که مهم ترین دسته قوانین برنامه نویسی شیء گرا هستند. من در جلسه قبل به صورت خلاصه آن را برایتان نام بردم اما حالا می خواهیم هر کدام را به صورت جداگانه بررسی کنیم.
SOLID به مجموعه ای از قوانین گفته می شود که برای نوشتن کدهای تمیز در برنامه نویسی شیء گرا کاربرد دارند. کلمه SOLID در لغت معانی مختلفی مانند «مستحکم» یا «استوار» یا «قابل اعتماد» را دارد اما در اصل یک کلمه نیست بلکه مخفف ۵ قانون است:
این ۵ قانون از مهم ترین قوانین نوشتن کدهای شیء گرا است و تمام برنامه نویسان حرفه ای باید تا حد مناسبی از آن پیروی کنند. همانطور که قبلا توضیح دادم بین نوشتن کدهای مناسب از نظر فنی و کدهای خوانا معمولا رابطه مستقیمی وجود دارد. در اینجا نیز باید بدانید که این ۵ قانون به طور مستقیم مسئول تنظیم کدهای خوانا نیستند اما به نوشتن کدهای تمیز کمک می کنند (مخصوصا دو قانون اول یا حروف S و O).
قانون مسئولیت واحد می گوید:
ما در ابتدای همین فصل و زمانی که در مورد کوچک بودن کلاس ها صحبت می کردیم، با قانون مسئولیت واحد آشنا شدیم اما می خواهم در این بخش کمی فنی تر در رابطه با آن صحبت کنیم. بر اساس توضیحات قانون مسئولیت واحد، تعریف «مسئولیت» هر عملیات ریز و درشت موجود در یک کلاس نیست. به طور مثال کار کردن یک کلاس به صورت همزمان روی محصولات و کاربران غیرمجاز نیست. مسئولیت واحد بیشتر مربوط به تعدد منطق اجرایی برنامه است، یعنی کلاس ما در حال حاضر در چند حوزه مختلف درگیر است. بگذارید یک مثال ساده را به شما نشان بدهم:
// این کد از قانون مسئولیت واحد تخطی نمی کند class User { login(email: string, password: string) {} signup(email: string, password: string) {} assignRole(role: any) {} } // این کد از قانون مسئولیت واحد تخطی می کند class ReportDocument { generateReport(data: any) {} createPDF(report: any) {} }
در کد بالا دو کلاس را با نام های User و ReportDocument مشاهده می کنید. با اینکه این کلاس تنها دو متد generateReport و createPDF را دارد اما هنوز از نظر قانون مسئولیت واحد یک کد بد و شلوغ محسوب می شود. چرا؟ به دلیل اینکه این کلاس در دو حوزه کاملا متفاوت درگیر است. حوزه اول مربوط به generateReport (تولید گزارش) است که معمولا نیاز به دریافت اطلاعات از پایگاه داده دارد. حوزه دوم createPDF (ساخت یک فایل PDF) است که با فایل ها و ذخیره آن روی دیسک یا دانلود آن سر و کار دارد و وارد مباحث طراحی PDF و تعداد صفحات آن می شود. این دو حوزه به طور کامل از هم جدا هستند و نباید در یک کلاس باشند بنابراین قانون مسئولیت واحد را می شکنند.
از طرف دیگر کلاس User چندین متد مختلف دارد و هر کدام از این متدها نیز کار متفاوتی را انجام می دهند. متد login مسئول بررسی اطلاعات وارد شده توسط کاربر و وارد کردن او به حساب کاربری اش می باشد که نیاز به ساخت یک session دارد. متد sign up مسئول دریافت مشخصات کاربر و ثبت او به عنوان یک کاربر جدید در پایگاه داده می باشد. متد assignRole سطح دسترسی این کاربر را مشخص کرده و می گوید که role یا نقش کاربر در برنامه ما چیست؛ آیا ادمین است، آیا یک کاربر عادی است، آیا مهمان است و الی آخر. با اینکه این متدها کار های مختلفی را انجام می دهند، تمام آن ها مربوط به یک حوزه کاری واحد هستند و از زمینه های مختلف نیامده اند. ممکن است از نظر شما assignRole نباید درون این کلاس باشد و به حوزه جداگانه ای تعلق داشته باشد. آیا من می توانم ادعا کنم که حرف شما غلط است؟ خیر! مسئله اینجاست که قانون مسئولیت واحد یک فرمول ریاضی نیست که جواب قطعی داشته باشد بلکه به برنامه شما و همچنین به سلیقه شما بستگی دارد. من تا حدی که توانسته ام این قانون را برایتان توضیح داده ام اما از اینجا به بعد بر عهده خود شما است.
قانون دوم ما قانون باز-بسته یا open-closed نام دارد. این قانون می گوید که هر کلاس باید برای فرزند شدن و توسعه بیشتر (extension) باز اما برای ویرایش (modification) بسته باشد. بهترین روش برای درک این قانون، دیدن آن در قالب یک مثال است:
class Printer { printPDF(data: any) { // ... } printWebDocument(data: any) { // ... } printPage(data: any) { // ... } verifyData(data: any) { // ... } }
ما در این کد یک کلاس به نام Printer داریم که متدهای مختلفی برای پرینت کردن داده های مختلف دارد (پرینت صفحات وب، پرینت فایل های PDF و غیره). متد verifyData نیز مسئول بررسی داده ها و اعتبارسنجی آن ها قبل از پرینت کردنشان است.
حالا مشکل این کلاس چیست؟ هر بار که بخواهیم قابلیت جدیدی را به این کلاس اضافه کنیم، کلاس بزرگ تر خواهد شد. مشکل اینجاست! مثلا اگر بخواهیم متدی را تعریف کنیم که فایل های word یا اکسل را پرینت کند، باید یک متد جداگانه تعریف کنیم که مسئول پرینت این نوع صفحات باشد. چنین کلاسی از نظر ویرایش (modification) بسته یا closed نیست و هر بار باید به آن برگردیم و محتوایش را ویرایش کنیم. احتمالا با خواندن این مطلب ذهنتان به سمت مباحث چندریختی (polymorphism) رفته است و البته به جای بیراهی نیز نرفته اید! اگر بخواهیم کلاس بالا را بر اساس قانون باز-بسته بازنویسی کنیم، چنین کدی را خواهیم داشت:
interface Printer { print(data: any); } class PrinterImplementation { verifyData(data: any) {} } class WebPrinter extends PrinterImplementation implements Printer { print(data: any) { // print web document } } class PDFPrinter extends PrinterImplementation implements Printer { print(data: any) { // print PDF document } } class PagePrinter extends PrinterImplementation implements Printer { print(data: any) { // print real page } }
همانطور که می بینید یک interface را داریم که مشخص می کند محتوای هر کلاس باید به چه شکلی باشد. هر کلاسی که interface بالا را داشته باشد باید درون خود متدی به نام print داشته باشد. همچنین یک کلاس پایه به نام PrinterImplementation را داریم که متد verifyData را دارد. حالا هر نوع عملیات چاپ خاص را در یک کلاس جداگانه تعریف کرده ایم که کلاس پایه را extend می کند. مزیت این روش این است که کلاس پایه (PrinterImplementation) از نظر ویرایش بسته خواهد بود و دیگر کاری با آن نداریم. از این به بعد برای اضافه کردن قابلیت های بیشتر نیازی به ویرایش این کلاس و همچنین کلاس های دیگر نداریم. مثلا اگر بخواهیم قابلیت چاپ فایل های word را به برنامه خودمان اضافه کنیم، یک کلاس دیگر را تعریف خواهیم کرد و به کلاس های دیگر دست نمی زنیم.
حالا از شما سوالی دارم: ما می دانیم که قاعده باز-بسته به صورت مستقیم جهت خوانایی کد ایجاد نشده است بلکه بیشتر برای مدیریت کدها و استاندارد های فنی نوشته شده است. با این حساب چرا این قانون به خوانایی کدهای ما کمک می کند؟ همانطور که در ابتدای دوره توضیح دادم، کلاس ها باید کوچک باشند و این قاعده باعث کوچک شدن کلاس های شما خواهد شد. همچنین به ما کمک خواهد کرد که از قاعده DRY (مخفف Don't Repeat Yourself یا تکرار نکردن کدها) پیروی کنیم. اگر به مثالی که از چندریختی حل کردیم برگردید، متوجه این حرف من خواهید شد. ما در آن جلسه بسیاری از شرط های if تکراری را حذف کردیم و کدهایمان بسیار خلاصه تر شد.
دو قانون اولی که در مجموعه قوانین SOLID بررسی کردیم، مستقیما در خوانایی کدها دخیل بودند و باعث کوچک تر شدن کلاس های ما می شدند اما سه قانون بعدی بیشتر مربوط به maintaining مربوط هستند. با این حساب شاید بپرسید چرا آن ها را بررسی می کنیم؟ این قوانین به صورت «غیر مستقیم» در خوانایی نقش دارند و کدهایمان را بهتر می کنند.
اصل جایگزینی لیسکوف می گوید اشیاء باید بدون اینکه رفتارشان تغییر کند، قابلیت جایگزینی با نمونه هایی از زیرکلاس هایشان (کلاس های فرزند) را داشته باشند. احتمالا شما متوجه معنی این جمله نشده باشید بنابراین بهتر است با یک مثال آن را برایتان توضیح بدهم. به مثال زیر توجه کنید:
class Bird { fly() { console.log("Flying..."); } } class Eagle extends Bird { dive() { console.log("Diving..."); } } const bird = new Bird(); bird.fly();
در کد بالا یک کلاس به نام Bird (پرنده) داریم که متد fly (پرواز کردن) را دارد. از طرفی کلاس دیگری به نام Eagle (عقاب) را داریم که کلاس اصلی را extend می کند و متد Dive (شیرجه رفتن به سمت زمین) را در خود دارد. طبیعتا هر پرنده ای نمی تواند شیرجه بزند بنابراین این کلاس مخصوص عقاب است. در این حالت کلاس Bird یک کلاس اصلی (پدر) و کلاس Eagle یک زیرکلاس برای آن (فرزند) خواهد بود. اصل جایگزینی لیسکوف می گوید ما باید بتوانیم به جای کلاس Bird در هنگام نمونه سازی (new Bird) از یکی از زیرکلاس های آن (Eagle در این مثال) استفاده کنیم، البته به شرطی که رفتار آن تغییر نکند. به مثال زیر توجه کنید:
class Bird { fly() { console.log("Flying..."); } } class Eagle extends Bird { dive() { console.log("Diving..."); } } const eagle = new Eagle(); eagle.fly();
من در این کد، به جای کلاس Bird از کلاس Eagle استفاده کرده ام. با این تغییر، باز هم متد fly کار می کند و رفتار آن نیز تغییر نمی کند (مثلا fly باعث شیرجه زدن نمی شود بلکه همان پرواز کردن است). البته توجه داشته باشید که کلاس Eagle ممکن است کار های بیشتر از fly انجام بدهد:
const eagle = new Eagle(); eagle.fly(); eagle.dive();
همانطور که می بینید علاوه بر fly می توانیم dive نیز انجام بدهیم اما این مسئله قانون لیسکوف را رد نمی کند. مهم این است که متد fly در کلاس های فرزند نیز دقیقا یک رفتار خاص را داشته باشد.
حالا بیایید یک مثال را بررسی کنیم که در آن قانون لیسکوف شکسته می شود:
class Bird { fly() { console.log("Flying..."); } } class Eagle extends Bird { dive() { console.log("Diving..."); } } class Penguin extends Bird { // مشکل اینجاست که پنگوئن ها پرواز نمی کنند } const eagle = new Eagle(); eagle.fly();
من کلاس Penguin (پنگوئن) را اضافه کرده ام. مشکل اینجاست که پنگوئن ها توانایی پرواز را ندارند. در این حالت کلاس اصلی یا پدر (Bird) برای چنین موقعیتی اشتباه است. برای حل این مشکل باید کلاس پایه را تغییر بدهیم. من این کلاس را از bird به FlyingBird تغییر می دهم:
class Bird {} class FlyingBird extends Bird { fly() { console.log("Fyling..."); } } class Eagle extends FlyingBird { dive() { console.log("Diving..."); } } const eagle = new Eagle(); eagle.fly(); eagle.dive(); class Penguin extends Bird { // که پنگوئن ها پرواز نمی کنند }
در اینجا کلاس پایه bird را داریم و می توانیم درون آن خصوصیات یا متدهایی را بنویسیم که بین تمام پرندگان (پروازی و غیر پروازی) مشترک هستند اما من فعلا آن را خالی گذاشته ام. حالا کلاس FlyingBird را داریم که کلاس اصلی را extend می کند و بقیه کلاس های ما کلاس FlyingBird را extend می کنند.
با این حساب می توان گفت وظیفه اصلی قانون لیسکوف این است که شما را مجبور کند مدل سازی صحیحی برای داده هایتان انجام بدهید اما چرا این قانون برای خوانایی و تمیز بودن کدها اهمیت دارد؟ قانون لیسکوف باعث می شود کدهای شما یک نظم منطقی بگیرد. با اینکه این نظم منطقی، تاثیر بسزایی در خوانایی کد ندارد اما بی تاثیر نیز نمی باشد و نظمی ذهنی را برای خواننده ایجاد می کند.
اصل بعدی، اصل تفکیک اینترفیس یا اصل تفکیک روابط است. این قانون که به طور خلاصه ISP نامیده می شود، می گوید داشتن چندین اینترفیس که هر کدام برای یک کلاینت خاص طراحی شده اند بهتر از داشتن یک اینترفیس بزرگ برای تمام کلاینت ها است. طبیعتا این یک تعریف انتزاعی است و کاربرد زیادی برای کاربران تازه کار ندارد بنابراین باید به سراغ مثالی عملی برویم:
interface Database { storeData(data: any); connect(uri: string); } class SQLDatabase implements Database { connect(uri: string) { // connecting... } storeData(data: any) { // Storing data... } }
ما در این مثال یک کلاس به نام SQLDatabase داریم که از یک interface به نام Database تبعیت می کند. در صورتی که نمی دانید interface چیست، می توان به صورت خلاصه گفت که interface ها شکل یک کلاس را تعریف می کنند! یعنی قرارداد هایی هستند که اگر کلاسی بخواهد به آن پایبند باشد، باید ساختارش را طبق آن شکل بدهد. مثلا در کد بالا interface ما می گوید که دو متد به نام های connect و storeData داریم. حالا هر کلاسی که این اینترفیس را implement کند باید این دو متد را دقیقا به همین شکل داشته باشد. ما این کار را در کلاس بالا (SQLDatabase) انجام داده ایم بنابراین مشکلی نیست.
حالا فرض کنید علاوه بر این پایگاه داده SQL یک پایگاه داده دیگر از نوع in-memory (پایگاه های داده ای که داده ها را در رم سیستم ذخیره می کنند) نیز داشته باشیم:
interface Database { storeData(data: any); connect(uri: string); } class SQLDatabase implements Database { connect(uri: string) { // connecting... } storeData(data: any) { // Storing data... } } class InMemoryDatabase implements Database { connect(uri: string) { // ??? } storeData(data: any) { // Storing data... } }
مشکل اینجاست که یک پایگاه داده in-memory داده هایش را در مموری برنامه (رم سیستم) ذخیره می کند بنابراین اصلا متدی به نام connect ندارد چرا که به جایی متصل نمی شود (البته بعضی از این پایگاه داده ها connect دارند که اینجا برای ما اهمیتی ندارد). با این حساب interface ما در اینجا اشتباه است چرا که نمی دانیم درون connect چیزی بنویسیم، بلکه مجبور شده ایم برای جلوگیری از بروز خطا فقط این متد را تعریف کرده و آن را خالی بگذاریم. احتمالا این شرایط شما را به یاد اصل جایگزینی لیسکوف بیندازد؛ در اصل لیسکوف مسئله تعریف کلاس پایه (پدر) اشتباه بود بنابراین به وراثت یا inheritance مربوط می شد اما در اینجا مسئله تعریف اینترفیس اشتباه است.
به اینترفیس Database یک اینترفیس چند منظوره یا general-purpose می گوییم، یعنی اینترفیسی که چندین مورد استفاده مختلف را یکجا درون خود دارد. بر اساس اصل تفکیک اینترفیس ها بهتر است ما این اینترفیس را به دو اینترفیس جداگانه بشکنیم و کدهای خود را به شکل زیر ویرایش کنیم:
interface Database { storeData(data: any); } interface RemoteDatabase { connect(uri: string); } class SQLDatabase implements Database, RemoteDatabase { connect(uri: string) { // connecting... } storeData(data: any) { // Storing data... } } class InMemoryDatabase implements Database { storeData(data: any) { // Storing data... } }
همانطور که می بینید ما دو اینترفیس را تعریف کرده ایم: Database برای ذخیره داده و RemoteDatabase برای اتصال به پایگاه داده. کلاس SQLDatabase هر دو اینترفیس را implement کرده است که در تایپ اسکریپت با یک علامت ویرگول ساده قابل انجام است اما کلاس InMemoryDatabase تنها اینترفیس Database را implement کرده است بنابراین دیگر نیازی به تعریف متد connect برای این کلاس نداریم.
دقیقا مانند اصل جایگزینی لیسکوف، نقش مهم اصل تفکیک اینترفیس ها در نوشتن کدهای maintainable و extensible غیر قابل نفی است و شما باید از نظر فنی از تمام قوانین SOLID پیروی کنید اما این قانون به صورت مستقیم نقشی در خوانایی و درک راحت تر کدها ندارد. البته اگر به آن فکر کنید متوجه می شوید که در برنامه های بزرگ می تواند تاثیر محدود و مثبتی داشته باشد. من در مثال های این دوره همه چیز را یکجا نوشته ام تا شما بتوانید تمام کدها را ببینید اما در برنامه های واقعی اینترفیس ها را درون فایل هایی جداگانه می نویسیم. زمانی که بخواهید اینترفیس ها را مشاهده کنید، کوچک تر بودن و مشخص بودن وظیفه هر کدام از این اینترفیس ها باعث آسانی در خواندن و درک آن ها می شود.
آخرین قانون از مجموعه قوانین SOLID به اصل وارونگی وابستگی ها یا Dependency Inversion Principle معروف است. این قانون می گوید شما باید به جای تصریحات (concretion) بر انتزاعات (abstraction) تکیه کنید. اگر متوجه معنی این قوانین نمی شوید اصلا جای نگرانی نیست. این قوانین توسط افراد حرفه ای و به صورت انتزاعی تعریف شده اند و درک آن ها در بار اول بسیار سخت است. تنها نکته مهم در این تعریف کلمات concretion (تصریح) و abstraction (انتزاع) هستند. تعریف یک کلاس، انتزاع محسوب می شود اما شیء ساخته شده از یک کلاس چیزی مجسم و صریح است. این تفاوت به درک شما از این قانون بسیار کمک خواهد کرد.
ما مثل همیشه با یک مثال به سراغ توضیح این قاعده می رویم. من در این بخش از همان مثال اصل تفکیک اینترفیس ها (با کمی ویرایش) استفاده می کنم:
interface Database { storeData(data: any); } interface RemoteDatabase { connect(uri: string); } class SQLDatabase implements Database, RemoteDatabase { connect(uri: string) { // connecting... } storeData(data: any) { // Storing data... } } class InMemoryDatabase implements Database { storeData(data: any) { // Storing data... } } class App { private database: SQLDatabase | InMemoryDatabase; constructor(database: SQLDatabase | InMemoryDatabase) { if (database instanceof SQLDatabase) { database.connect("my-url"); } this.database = database; } saveSettings() { this.database.storeData("some data"); } }
این کد همان مثال پایگاه داده خودمان است که برایش دو اینترفیس جداگانه تعریف کرده بودیم با این تفاوت که حالا کلاسی به نام App را نیز داریم و App به این کلاس های پایگاه داده وابسته است (به آن ها نیاز دارد). کلاس App در ابتدا خصوصیتی به نام database دارد که روی یکی از انواع دو پایگاه داده ما تنظیم می شود (پایگاه داده SQL یا In-memory). در مرحله بعدی constructor را داریم که بر اساس نوع پایگاه داده ممکن است متد connect را صدا بزند. در نهایت متد saveSettings را داریم که برای ذخیره کردن برخی از داده تعریف شده است.
آیا می دانید مشکل این کد کجاست؟ مشکل این کد اینجاست که نقطه اتکای ما در آن روی تصریحات یا اشیاء مجسم است. یعنی چه؟ یعنی با نگاه به تعریف کلاس App متوجه می شویم که این کلاس وابسته به شیء مجسم و ساخته شده از کلاس پایگاه داده است. مثلا ما هر دو نوع پایگاه داده را دریافت می کنیم و سپس بر اساس نوع آن (شرط instanceof) عملیات خاصی را انجام می دهیم. اگر چنین کاری را انجام بدهیم، با اضافه شدن انواع پایگاه داده باید انواع شرط های if دیگری را نیز تعریف کنیم و اگر متدی مانند connect تغییر پیدا کند باید آن را در کلاس های دیگر مانند App نیز تغییر بدهیم.
برای تصحیح این کد باید چه کار کرد؟ اینترفیس ها انتزاعی هستند بنابراین می توانیم از آن ها استفاده کنیم. یعنی می توان گفت که ما فقط یک پایگاه داده را در App می خواهیم و فعلا برایمان مهم نیست این پایگاه داده از چه نوعی می باشد. حالا جایی که بخواهیم از App استفاده کنیم، پایگاه داده مورد نظر را به آن پاس می دهیم. به همین خاطر است که به این قانون، اصل وارونگی وابستگی می گوییم؛ در این حالت ما در زمان تعریف کلاس با وابستگی ها (dependency) کاری نداریم بلکه در زمان ساخت یک نمونه از آن ها با وابستگی ها کار می کنیم. همچنین هر کسی که بخواهد از کلاس App استفاده کند مجبور می شود پایگاه داده مناسب را پاس بدهد چرا که در غیر این صورت اینترفیس ها به او خطا خواهند داد. به مثال زیر توجه کنید:
interface Database { storeData(data: any); } interface RemoteDatabase { connect(uri: string); } class SQLDatabase implements Database, RemoteDatabase { connect(uri: string) { console.log("Connecting to SQL database!"); } storeData(data: any) { console.log("Storing data..."); } } class InMemoryDatabase implements Database { storeData(data: any) { console.log("Storing data..."); } } class App { private database: Database; constructor(database: Database) { this.database = database; } saveSettings() { this.database.storeData("Some data"); } } const sqlDatabase = new SQLDatabase(); sqlDatabase.connect("my-url"); const app = new App(sqlDatabase);
ما در این مثال، مسئولیت صدا زدن متد connect را بر عهده کلاس SQLDatabase گذاشته ایم تا اصلا نیازی به بررسی آن در App نباشد (وابستگی را وارونه کرده ایم). با استفاده از این روش می توانیم در چند نقطه محدود در برنامه یک شیء را بسازیم و سپس در قسمت های مختلف برنامه به آن وابستگی داشته باشیم.
قوانین که در این دوره برایتان توضیح داده شد باید در تملک شما باشند نه اینکه شما در تملک آن ها باشید. یعنی باید بدانید که این قوانین قانون ثابت و محکمی نیستند و پیروی از آن ها به هر قیمتی الزامی نیست. آشنایی با این قوانین به شما کمک می کند که کدهای خوانا تر و بهتری بنویسید و هدف این دوره نیز همین بوده است. به طور مثال تقسیم توابع به توابع دیگر باید در محدوده منطق باشد و وارد افراط و تفریط نشود. اگر یک تابع ساده را به ده تابع دیگر تقسیم کردید احتمالا در حال زیاده روی هستید. در نهایت تصمیم گیرنده شما و منطق شما است.
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.