این فصل در رابطه با کلاس ها و اشیاء است و درباره موضوعاتی مانند تمیز نگه داشتن کلاس ها و تمیز کار کردن با آن ها صحبت خواهیم کرد. همچنین قبل از شروع بحث باید تفاوت اشیاء واقعی و داده ساختارهای شبیه به کلاس را متوجه بشویم. در مرحله بعدی به سراغ قوانین کار با برنامه نویسی شیء گرا می رویم و با قوانین SOLID و demeter آشنا خواهیم شد. در نهایت به سراغ polymorphism می رویم با این تفاوت که این بار به طور خاص روی کلاس ها تمرکز خواهیم کرد.
طبیعتا ما در این فصل به سراغ آموزش برنامه نویسی شیء گرا نمی رویم و از شما انتظار می رود که کار با کلاس ها را یاد داشته باشید. هدف ما نوشتن کدی است که برای انسان ها خوانا باشد.
یکی از اولین مفاهیم بنیادین برای نوشتن کد تمیز در برنامه نویسی شیء گرا این است که تفاوت بین اشیاء واقعی (object) و داده ساختارها (data structure) را متوجه بشوید.
یک شیء واقعی محتوای درون خود (مانند خصوصیات) را مخفی می کند و فقط با استفاده از یک API عمومی (چند متد) به ما اجازه دسترسی به آن ها را می دهد. منظور ما از این چند متد یا API عمومی فقط متدهای getter و setter نیست بلکه تمام متدهایی که یک کار واقعی را انجام می دهند جزئی از این بخش هستند. از طرف دیگر داده ساختارها (data structure) که بعضا با نام data container (نگهدارنده داده) نیز شناخته می شوند اشیاء ساده ای هستند که محتوای درونشان را به صورت عمومی نمایش می دهند و تقریبا هیچ API عمومی یا متدی برای کار با آن ها نداریم.
با این حساب اگر از روش برنامه نویسی شیء گرا استفاده می کنید، اشیاء واقعی بسیار مهم خواهند بود و معمولا حاوی منطق اصلی و مرکزی برنامه شما هستند. از طرف دیگر در داده ساختارها هیچ منطق خاصی وجود ندارد بلکه از آن ها فقط برای نگهداری و ذخیره موقت داده ها استفاده می کنیم تا بتوانیم داده های مورد نظرمان را به قسمت های مختلف برنامه پاس بدهیم. همچنین زمانی که بحث از اشیاء واقعی است مفهومی به نام abstraction over concretion وجود دارد. این مفهوم یعنی ما abstraction (انتزاع) را بر صراحت (concretion) ترجیح می دهیم. یعنی چه؟ یعنی در اشیاء واقعی که از کلاس ها ساخته می شوند متدهایی داریم و این متدها عملیات های خاصی را انجام می دهند و برای ما مهم نیست که این عملیات چطور انجام می شود (این مفهوم انتزاع است) در صورتی که در داده ساختارها تقریبا هیچ متدی ندارند بنابراین داده ها به صورت concrete یا «صریح» در اختیار ما قرار دارند و ما مستقیما با این داده ها کار می کنیم.
به طور مثال به کلاس زیر توجه کنید:
class Database { private uri: string; private provider: any; private connection: any; constructor(uri: string, provider: any) { this.uri = uri; this.provider = provider; } connect() { try { this.connection = this.provider.establishConnection(this.uri); } catch (error) { throw new Error('Could not connect!'); } } disconnect() { this.connection.close(); } }
این کلاسی برای یک پایگاه داده است و با استفاده از آن می توانیم یک شیء واقعی بسازیم چرا که شیء ساخته شده از این کلاس، محتوایش را به صورت مخفی درون خود قرار داده است و سپس با استفاده از چند متد به ما اجازه انجام عملیات های خاصی را می دهد. به طور مثال متدهای connect و disconnect متدهای سطح بالایی هستند و کار های مهمی انجام می دهند اما زمانی که با شیء ساخته شده از این کلاس کار می کنیم اصلا نمی دانیم در پشت صحنه چه می گذرد و این متدها دقیقا چطور وظیفه خودشان را انجام می دهند بلکه فقط می دانیم متد connect ما را به یک پایگاه داده متصل می کند. به این مسئله، انتزاع یا abstraction می گویند.
از طرف دیگر اشیاء و کلاس هایی را داریم که در اصل data container یا نگهدارنده داده هستند:
class UserCredentials { public email: string; public password: string; }
همانطور که می بینید اصلا متدی وجود ندارد و برای کار با آن باید به صورت مستقیم با داده های آن مانند email و password کار کنیم.
احتمالا بپرسید چرا این تفاوت اهمیت دارد؟ اگر شما این دو نوع شیء را یکی در نظر بگیرید، معمولا کدهایی می نویسید که تمیز و استاندارد نخواهند بود. چه کل برنامه شما به صورت شیء گرا نوشته شده باشد و چه ترکیبی از برنامه نویسی رویه ای و شیء گرا باشد باز هم باید به این موضوع توجه کنید. به طور مثال کلاس Database را که بالاتر به شما نشان دادم در نظر بگیرید. اگر بخواهیم از این کلاس استفاده کنیم، کار ما آسان و به شکل زیر خواهد بود:
const database = new Database('my-database:8100', sqlEngine); database.connect(); database.disconnect();
این کد بسیار تمیز است و برای هر کسی خوانا خواهد بود. همچنین هر زمانی که نیاز به تغییر منطق اتصال (connect) و قطع اتصال (disconnect) باشد، به سادگی به تعریف کلاس برگشته و متدهای متناظر آن را ویرایش می کنیم و هیچ نیازی به ویرایش کد بالا (صدا زدن connect و disconnect) نخواهیم داشت. حالا اگر بخواهیم تفاوتی بین اشیاء واقعی و داده ساختارها قائل نشویم ممکن است خصوصیت های کلاس Database را به صورت public بنویسیم:
class Database { private uri: string; private provider: any; public connection: any; constructor(uri: string, provider: any) { this.uri = uri; this.provider = provider; } // ادامه کدها
مثلا من خصوصیت connection (اتصال ما به پایگاه داده) را به صورت public در آورده ام. در این حالت خصوصیت connection در خارج از کلاس نیز در دسترس است. مثلا ممکن است بخواهیم در قسمت دیگری از برنامه اتصال به پایگاه داده را به شکل زیر ببندیم:
const database = new Database('my-database:8100', sqlEngine); database.connect(); database.connection.close();
همانطور که می بینید من مستقیما از شیء database به connection دسترسی پیدا کرده ام و close را روی آن صدا زده ام. مشکل اینجاست که در حالت اول که حالت استاندارد بود، با صدا زدن متدها عملیات های خود را انجام می دادیم و زمانی که نیاز به ویرایش داشتیم فقط باید تعریف کلاس را ویرایش می کردیم اما در این حالت چطور؟ اگر قرار باشد مثل کد بالا مستقیما به محتوای شیء دسترسی داشته باشیم به مشکلات زیادی برخورد می کنیم.
مثلا اگر بعدا نام close به چیز دیگری مانند shutDown یا disconnect یا هر نام دیگری تغییر پیدا کند تمام کدهای ما شکسته می شود و نه تنها باید تعریف کلاس را ویرایش کنیم بلکه باید به تک تک قسمت هایی برویم که اتصال را close کرده ایم و سپس آن را ویرایش کنیم. طبیعتا این کار اصلا استاندارد نیست! علاوه بر آن database.connection.close از نظر خوانایی مناسب نیست. فردی که می خواهد از کد ما استفاده کند باید به صورت ناگهان یاد بگیرد connection چیست و از کجا آمده است و چرا آن را در هنگام اتصال (متد connect) نداشتیم و ده ها سوال دیگر که باعث سردرگمی توسعه دهندگان دیگر خواهد شد.
به همین دلیل است که درک کردن تفاوت اشیاء واقعی و data container ها بسیار مهم است. ما نمی توانیم با هر دو به یک روش رفتار کنیم.
اگر یادتان باشد در فصل قبل به صورت خلاصه در رابطه با polymorphism صحبت کردیم. در آن جلسه یک factory function را ایجاد کردیم که خودش یک شیء polymorphic یا چندریختی را ایجاد می کرد. این شیء توابع مختلفی را برای پرداخت های مختلف داشت. در همان جلسه نیز برایتان توضیح دادم که چندریختگی چه معنایی دارد؛ متدها یا اشیائی که در ظاهر شکل و نام ثابتی دارند (مثلا به یک شکل صدا زده می شوند) اما در عمل بر اساس نحوه استفاده شما رفتاری کاملا متفاوت دارد تا جایی که انگار از یک متد یا شیء دیگر استفاده کرده ایم.
مسئله اینجاست که چندریختگی معمولا در حوزه کلاس ها و اشیاء فعال است و کمتر در زمینه متدها توضیح داده می شود. به همین دلیل در این قسمت می خواهیم به طور خاص به چندریختگی در کلاس ها توجه کنیم. من در ابتدا یک کلاس ساده را برایتان آماده کرده ام:
type Purchase = any; let Logistics: any; class Delivery { private purchase: Purchase; constructor(purchase: Purchase) { this.purchase = purchase; } deliverProduct() { if (this.purchase.deliveryType === 'express') { Logistics.issueExpressDelivery(this.purchase.product); } else if (this.purchase.deliveryType === 'insured') { Logistics.issueInsuredDelivery(this.purchase.product); } else { Logistics.issueStandardDelivery(this.purchase.product); } } trackProduct() { if (this.purchase.deliveryType === 'express') { Logistics.trackExpressDelivery(this.purchase.product); } else if (this.purchase.deliveryType === 'insured') { Logistics.trackInsuredDelivery(this.purchase.product); } else { Logistics.trackStandardDelivery(this.purchase.product); } } }
این یک کلاس به نام delivery (دلیوری یا رساندن محصولات به مشتری) است که یک خصوصیت به نام purchase (خرید) دارد، یک constructor دارد و در نهایت دو متد دارد که اولی (deliverProduct) مسئول ارسال محصول بر اساس نوع ارسال است و دومی (trackProduct) مسئول رهگیری محصول ارسال شده می باشد. ما برای ارسال محصولاتمان سه نوع ارسال داریم: ارسال عادی (در بخش else صدا زده شده است)، express یا ارسال سریع و insured یا ارسال بیمه شده.
اگر بخواهیم در این مثال از چندریختگی استفاده کنیم باید به جای یک کلاس، کلاس هایمان را به چند کلاس مختلف تقسیم کنیم تا هر کلاس به طور اختصاصی مسئول انجام یک بخش از فرآیند ارسال باشد. به طور مثال یک کلاس پایه برای ارسال محصولات یا delivery خواهیم داشت که منطق اشتراکی بین تمام روش های ارسال را دارد و سپس برای هر نوع خاص از ارسال و پیگیری محصولات یک کلاس جداگانه خواهیم داشت:
class ExpressDelivery {} class InsuredDelivery {} class StandardDelivery {}
چرا این کار را انجام دادیم؟ در صورتی که به کد اولیه نگاه کنید متوجه خواهید شد که نحوه ارسال کالا نقش مهمی را در این کد اجرا می کند و همه چیز بر محور روش های ارسال می گردد بنابراین تقسیم کد بر اساس روش ارسال گزینه ایده آلی خواهد بود. حالا برای هر کلاس باید یک متد ارسال کالا و یک متد رهگیری کالا داشته باشیم. مثلا برای کلاس express می گوییم:
class ExpressDelivery { deliverProduct() { Logistics.trackExpressDelivery(this.purchase.product); } }
مشکل اینجاست که در حال حاضر کلاس ExpressDelivery یک کلاس مستقل است و دیگر خصوصیتی به نام purchase در آن وجود ندارد و طبعا ما نیز به آن دسترسی نداریم. برای حل این مشکل باید از ارث بری (inheritance) استفاده کنیم. یعنی کلاس ExpressDelivery فرزند کلاس Delivery (کلاس پایه و اصلی) باشد. اگر به کلاس Delivery نگاه کنید، می بینید که خصوصیت purchase از نوع خصوصی (private) است که یعنی فقط در خود کلاس Delivery قابل دسترس است. من آن را به protected تغییر می دهم تا در کلاس های فرزند نیز به آن دسترسی داشته باشیم:
class Delivery { protected purchase: Purchase; constructor(purchase: Purchase) { this.purchase = purchase; } // بقیه کدها
در مرحله بعدی نیز کلاس ExpressDelivery را extend می کنیم:
class ExpressDelivery extends Delivery { deliverProduct() { Logistics.issueExpressDelivery(this.purchase.product); } trackProduct() { Logistics.trackExpressDelivery(this.purchase.product); } }
همانطور که می بینید دو متد را در این کلاس داریم که اولی (deliverProduct) برای ارسال محصول و دومی (trackProduct) برای رهگیری آن است. دیگر از شرط های if نیز خبری نیست. تنها کاری که باقی مانده است، تکرار این کار برای دو کلاس دیگر است:
class ExpressDelivery extends Delivery { deliverProduct() { Logistics.issueExpressDelivery(this.purchase.product); } trackProduct() { Logistics.trackExpressDelivery(this.purchase.product); } } class InsuredDelivery extends Delivery { deliverProduct() { Logistics.issueInsuredDelivery(this.purchase.product); } trackProduct() { Logistics.trackInsuredDelivery(this.purchase.product); } } class StandardDelivery extends Delivery { deliverProduct() { Logistics.issueStandardDelivery(this.purchase.product); } trackProduct() { Logistics.trackStandardDelivery(this.purchase.product); } }
حالا که سه کلاس اختصاصی را برای ارسال و رهگیری انواع کالا ها داریم، باید به کلاس پایه (Delivery) رفته و هر دو متد آن را حذف کنیم چرا که دیگر نیازی به آن ها نداریم. با این حساب تمام کدهای این فایل به شکل زیر خواهند بود:
type Purchase = any; let Logistics: any; class Delivery { protected purchase: Purchase; constructor(purchase: Purchase) { this.purchase = purchase; } } class ExpressDelivery extends Delivery { deliverProduct() { Logistics.issueExpressDelivery(this.purchase.product); } trackProduct() { Logistics.trackExpressDelivery(this.purchase.product); } } class InsuredDelivery extends Delivery { deliverProduct() { Logistics.issueInsuredDelivery(this.purchase.product); } trackProduct() { Logistics.trackInsuredDelivery(this.purchase.product); } } class StandardDelivery extends Delivery { deliverProduct() { Logistics.issueStandardDelivery(this.purchase.product); } trackProduct() { Logistics.trackStandardDelivery(this.purchase.product); } }
احتمالا می پرسید پس شرط های if چه می شوند؟ از کجا مشخص کنیم که باید از کدام کلاس نمونه سازی کنیم و متدهایش را صدا بزنیم؟ مسئله بررسی روش ارسال را باید در قسمتی از برنامه انجام بدهید که می خواهید از این کلاس ها استفاده کنید. ما در اینجا در حال تعریف این کلاس ها هستیم و طبیعتا در برنامه های واقعی محل تعریف و استفاده از کلاس ها از هم جدا هستند.
به طور مثال فرض کنید در قسمتی از برنامه بخواهیم از این کلاس های دلیوری استفاده کنیم طبیعتا باید یک نمونه از آن ها را ساخته و با آن شیء کار کنیم. قبل از ایجاد تغییرات بالا برای ایجاد تغییر به شکل زیر عمل می کردیم:
const delivery = new Delivery({}); delivery.deliverProduct();
در حال حاضر امکان نوشتن چنین کدی وجود ندارد چرا که دیگر متدی به نام deliverProduct در کلاس اصلی Delivery وجود ندارد. نحوه استفاده از این کلاس ها به شدت به برنامه شما و فایل های شما و تصمیمات شخصی شما وابسته است اما اگر بخواهم یک مثال ساده را برایتان بزنم از این مثال استفاده می کنم:
let delivery: Delivery; if (purchase.deliveryType === 'express') { delivery = new ExpressDelivery(purchase); } else if (purchase.deliveryType === 'insured') { delivery = new InsuredDelivery(purchase); } else { delivery = new StandardDelivery(purchase); } delivery.deliverProduct();
در این مثال بر اساس نوع خرید کاربر (Express یا insured یا Standard) متغیر delivery را مقدار دهی می کنیم. در ضمن این کدها به زبان تایپ اسکریپت نوشته شده است، بنابراین از وجود علامت دو نقطه برای تعیین نوع متغیر delivery در ابتدای کدها تعجب نکنید. این ها مسائلی جزئی هستند و به کد تمیز ربطی ندارند. در نهایت نیز متد delivery را صدا زده ام. با اینکه این مثال ساده باید کلیت کار را به شما نشان بدهد اما برای تمرین بیشتر بیایید یک بار تمام کدهای این فایل را به صورت تمیز بازنویسی کنیم با این فرض که انگار می خواهیم از این کلاس ها در همین فایل استفاده کنیم.
در مثال بالا خطایی خواهید گرفت که متد deliverProduct اصلا روی شیء delivery وجود ندارد که صحیح است. هدف من از بازنویسی پاک کردن این خطا ها است. برای حل این مشکل می توانیم از interface های تایپ اسکریپت استفاده کنیم. مثال:
interface Delivery { deliverProduct(); trackProduct(); }
من با استفاده از این interface ساختار کلی Delivery را تعریف کرده ام. در مرحله بعدی کلاس Delivery را به نام DeliveryImplementation تغییر می دهم (به معنی پیاده سازی Delivery که interface ما است) تا نام گذاری دقیق تر باشد:
class DeliveryImplementation { protected purchase: Purchase; constructor(purchase: Purchase) { this.purchase = purchase; } }
در مرحله بعدی باید این کلاس جدید را extend کنیم (نام کلاس پدر را تغییر داده ایم بنابراین کدها بهم می ریزند) و سپس با کلیدواژه implement از interface برای کلاس های فرزند استفاده کنیم:
class ExpressDelivery extends DeliveryImplementation implements Delivery { deliverProduct() { Logistics.issueExpressDelivery(this.purchase.product); } trackProduct() { Logistics.trackExpressDelivery(this.purchase.product); } } class InsuredDelivery extends DeliveryImplementation implements Delivery { deliverProduct() { Logistics.issueInsuredDelivery(this.purchase.product); } trackProduct() { Logistics.trackInsuredDelivery(this.purchase.product); } } class StandardDelivery extends DeliveryImplementation implements Delivery { deliverProduct() { Logistics.issueStandardDelivery(this.purchase.product); } trackProduct() { Logistics.trackStandardDelivery(this.purchase.product); } }
در انتها نیز می توان از همان شرط های if ای که نشان دادم استفاده کرد اما معمولا در برنامه های واقعی چندین بار از این شرط ها استفاده می شود بنابراین بهتر است آن را در قالب یک تابع جداگانه (یک factory function) دربیاوریم:
function createDelivery(purchase) { if (purchase.deliveryType === 'express') { delivery = new ExpressDelivery(purchase); } else if (purchase.deliveryType === 'insured') { delivery = new InsuredDelivery(purchase); } else { delivery = new StandardDelivery(purchase); } return delivery; } let delivery: Delivery = createDelivery({}); delivery.deliverProduct();
این تابع خرید کاربر را دریافت می کند و سپس بر اساس نوع خرید، کلاس دلیوری صحیح را صدا می زند و آن را در قالب متغیر delivery برمی گرداند. در ادامه می توانیم متد deliverProduct را به سلیقه خودمان و در محل مناسب صدا بزنیم. من به جای یک سفارش واقعی یک شیء خالی را به createDelivery پاس داده ام چرا که کدهای ما واقعی نیست و خریدی واقعی نداریم که بخواهیم آن را پاس بدهیم اما شما در برنامه های واقعی خودتان این مشکل را نخواهید داشت. با این حساب تمام کدهای ما به شکل زیر در آمده است:
type Purchase = any; let Logistics: any; interface Delivery { deliverProduct(); trackProduct(); } class DeliveryImplementation { protected purchase: Purchase; constructor(purchase: Purchase) { this.purchase = purchase; } } class ExpressDelivery extends DeliveryImplementation implements Delivery { deliverProduct() { Logistics.issueExpressDelivery(this.purchase.product); } trackProduct() { Logistics.trackExpressDelivery(this.purchase.product); } } class InsuredDelivery extends DeliveryImplementation implements Delivery { deliverProduct() { Logistics.issueInsuredDelivery(this.purchase.product); } trackProduct() { Logistics.trackInsuredDelivery(this.purchase.product); } } class StandardDelivery extends DeliveryImplementation implements Delivery { deliverProduct() { Logistics.issueStandardDelivery(this.purchase.product); } trackProduct() { Logistics.trackStandardDelivery(this.purchase.product); } } function createDelivery(purchase) { if (purchase.deliveryType === 'express') { delivery = new ExpressDelivery(purchase); } else if (purchase.deliveryType === 'insured') { delivery = new InsuredDelivery(purchase); } else { delivery = new StandardDelivery(purchase); } return delivery; } let delivery: Delivery = createDelivery({}); delivery.deliverProduct();
امیدوارم متوجه بحث چندریختگی در کلاس ها شده باشید. در جلسه بعدی بیشتر در رابطه با کلاس ها صحبت می کنیم.
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.