کدنویسی تمیز: قوانین مهم کلاس نویسی - بخش ۱

Clean Coding: Important Rules of Writing Classes - Part 1

25 خرداد 1400
درسنامه درس 13 از سری کدنویسی تمیز
کدنویسی تمیز: قوانین مهم کلاس نویسی - بخش ۱ (قسمت ۱۳)

در جلسه قبل در رابطه با چندریختگی در کلاس ها صحبت کردیم که یکی از قوانین نوشتن کد تمیز است اما قوانین دیگری نیز در رابطه با کلاس ها وجود دارد. در این جلسه می خواهیم انواع و اقسام این قوانین را بررسی کنیم.

کلاس ها باید کوچک باشند

معمولا تعریف کلاس هایتان باید کوچک باشند یا به عبارتی ترجیح بدهید چندین کلاس کوچک داشته باشید تا فقط دو یا سه کلاس بزرگ! اگر یادتان باشد در فصل های قبل توضیح دادم که توابع نیز باید کوچک باشند و روی عملیات خاصی تمرکز کنند. این قوانین برای کلاس ها نیز صادق هستند اما یک تفاوت مهم در مفهوم «کوچک» وجود دارد چرا که کلاس ها با توابع تفاوتی اصلی دارند. زمانی که از کوچک بودن توابع صحبت می کردیم منظورمان این بود که تابع فقط یک عملیات خاص را انجام بدهد اما زمانی که بحث کلاس ها باشد به قانون SRP مراجعه می کنیم. قانون SRP یا Single Responsibility Principle (قانون تک مسئولیتی یا قانون مسئولیت واحد) عنوان می کند که هر کلاس برای یک محصول باید روی حل انواع مشکلات مربوط به آن محصول تمرکز کند.

شاید به نظر شما این یک مسئله ساده و بدیهی باشد اما بسیاری از افراد در هنگام نوشتن کلاس هایشان آن را فراموش کرده و شروع به نوشتن کلاس ها چند بعدی می کنند. به طور مثال به کلاس زیر توجه کنید:

class OnlineShop {

  private orders: any;

  private offeredProducts: any;

  private customers: any;




  public addProduct(title: string, price: number) {}




  public updateProduct(productId: string, title: string, price: number) {}




  public removeProduct(productId: string) {}




  public getAvailableItems(productId: string) {}




  public restockProduct(productId: string) {}




  public createCustomer(email: string, password: string) {}




  public loginCustomer(email: string, password: string) {}




  public makePurchase(customerId: string, productId: string) {}




  public addOrder(customerId: string, productId: string, quantity: number) {}




  public refund(orderId: string) {}




  public updateCustomerProfile(customerId: string, name: string) {}




  // ...

}

همانطور که می بینید این کلاس متعلق به یک فروشگاه آنلاین است. من برای طولانی نشدن کدها و شلوغ نشدن ذهن شما کدهای واقعی درون متدها را حذف کرده ام. با یک نگاه ساده به این کد متوجه می شوید که همه چیز در هم پیچیده شده است. در اصل کدهای مربوط به «محصولات» و کدهای مربوط به «خریداران» و همینطور «سفارشات» و غیره باید از هم جدا شوند نه اینکه همه چیز در یک کلاس به نام OnlineShop ریخته شده باشد. توجه کنید که مشکل ما تعداد متدهای موجود در این کلاس نیست بلکه مشکل اصلی اهداف مختلفی است که در یک کلاس انجام می شود. با این حساب بر اساس قانون Single Responsibility («قانون تک مسئولیتی» یا «قانون مسئولیت واحد») کلاس ها، می توان گفت که این کلاس اشکال جدی دارد.

یک راه ساده برای حل این مشکل این است که کدهای مربوط به یک مسئولیت خاص را به یک کلاس جداگانه تبدیل کنیم. مثلا:

class Order {

  public refund() {}

}




class Customer {

  private orders: Order[];




  constructor(email: string, password: string) {}




  public login(email: string, password: string) {}




  public updateProfile(name: string) {}




  public makePurchase(productId: string) {}

}




class Product {

  constructor(title: string, price: number) {}




  public update(Id: string, title: string, price: number) {}




  public remove(Id: string) {}

}




class Inventory {

  private products: Product;




  public getAvailableItems(productId: string) {}




  public restockProduct(productId: string) {}

}

با یک نگاه ساده متوجه می شویم که این کد بسیار خواناتر و استاندارد تر است. ممکن است شما بخواهید این کد را به کلاس های بیشتری تقسیم کنید و این به سلیقه خود شما بستگی دارد اما به نظر من شکستن کلاس ها در همین حد کافی است.

به عنوان نکته نهایی باید به یاد داشته باشید که کوچک بودن کلاس ها با کوچک بودن توابع یکی نیست. در کوچک بودن توابع مفهوم اصلی انجام یک کار خاص بود اما کلاس ها اینطور نیستند چرا که هر کلاس متدهای مختلفی دارد و کار های مختلفی انجام می دهد:

class Product {

  constructor(title: string, price: number) {}




  public update(Id: string, title: string, price: number) {}




  public remove(Id: string) {}

}

این کلاس هم محصولات را به روز رسانی می کند و هم آن ها را حذف می کند (متدهای update و remove) بنابراین یک کار انجام نمی دهد اما هیچ مشکلی نیست. کوچک بودن کلاس ها به واحد بودن مسئولیت تمام این متدها (مثلا مدیریت محصول) و قانون SRP اشاره دارد.

آشنایی با مفهوم Cohesion

یکی دیگر از مفاهیمی که در رابطه با کلاس ها مطرح می شود، مفهوم cohesion یا «انسجام» است. انسجام به میزان استفاده متدها از خصوصیات کلاس و رابطه آن ها اشاره می کند. انسجام کامل یا maximum cohesion زمانی اتفاق می افتد که تک تک متدهای کلاس از تک تک خصوصیات کلاس استفاده کنند. در این حالت یک شیء منسجم و کامل را دارید. از طرف دیگر عدم وجود انسجام زمانی اتفاق می افتد که هیچ کدام از متدهای کلاس از هیچ کدام از خصوصیات آن استفاده نکنند. در این حالت شیء شما یک data container خواهد بود که فقط مسئول ذخیره سازی برخی داده ها است و در عین حال چند متد ساده و کمکی را نیز دارد.

از بین تمام این حالت ها، هدف ما انسجام بالا است. چرا هدف ما انسجام کامل نیست؟ به دلیل اینکه در برنامه های واقعی هیچ وقت و تحت هیچ شرایطی نمی توانید انسجام کامل داشته باشید. با خودتان فکر کنید. چه کلاسی می توان نوشت که تمام متدهایش از تمام خصوصیاتش استفاده کنند؟ این کار عملا غیرممکن است.

چرا چنین مفهومی برای ما مهم است؟ انسجام در دو زمینه به ما کمک می کند. زمینه اول نوشتن کدهای منسجم و خوانا است. یعنی زمانی که در حال تعریف یک کلاس هستید خصوصیات را زیر نظر دارید تا بی جهت خصوصیات بی استفاده و تزئینی را تعریف نکنید. دوما شما می توانید با مقایسه متدها و خصوصیات مورد استفاده آن ها متوجه بشوید که کلاس های بزرگ تر را از کجا بشکنید. آیا کلاس بزرگ قبلی را به یاد دارید؟ من در کنار هر متد (به صورت کامنت) نام خصوصیت مورد استفاده آن متد را برایتان نوشته ام:

class OnlineShop {

  private orders: any;

  private offeredProducts: any;

  private customers: any;




  public addProduct(title: string, price: number) {} // offeredProducts




  public updateProduct(productId: string, title: string, price: number) {} // offeredProducts




  public removeProduct(productId: string) {} // offeredProducts




  public getAvailableItems(productId: string) {} // offeredProducts




  public restockProduct(productId: string) {} // offeredProducts




  public createCustomer(email: string, password: string) {} // customers




  public loginCustomer(email: string, password: string) {} // customers




  public makePurchase(customerId: string, productId: string) {} // customers, orders, offeredProducts




  public addOrder(customerId: string, productId: string, quantity: number) {} // customers, orders, offeredProducts




  public refund(orderId: string) {} // customers, orders




  public updateCustomerProfile(customerId: string, name: string) {} // customers




  // ...

}

اگر این کد را از بالا تا پایین بخوانید متوجه این موضوع می شوید که هر دسته از متدها فقط از یک یا نهایتا دو خصوصیت استفاده می کنند. مثلا متدهای createCustomer و loginCustomer فقط از خصوصیت customers استفاده می کنند و با دو خصوصیت دیگر کاری ندارند که یعنی انسجام ما فقط یک سوم است. از طرف دیگر متدهای removeProduct و updateProduct و addProduct و getAvailableItems و restockProduct همگی فقط از خصوصیت offeredProducts استفاده می کنند.  این مسئله به ما نشان می دهد که کلاس ما انسجام خوبی ندارد و این مسئله به ما می گوید که باید این کلاس را بشکنیم.

قانون Demeter

در برنامه نویسی شیء گرا قوانین زیادی وجود دارند اما تقریبا تمام آن ها مربوط به maintenance (نگهداری کدها) و extensible بودن (قابلیت بزرگ تر شدن و افزودن قابلیت های بیشتر بدون دردسر) می باشند و معمولا ربطی به نوشتن کد خوانا ندارند. باز هم می گویم که کد خوانا یعنی کدی که درک آن برای دیگر توسعه دهندگان ساده باشد اما کد maintainable یعنی کدی که مدیریت آن (مثلا به روز رسانی یا ویرایش آن در آینده) ساده باشد. با اینکه مفاهیم maintainable و extensible و readable مفاهیم جداگانه ای هستند اما به نوعی به هم مربوط می شوند. به طور مثال هر چه کد شما خوانا تر باشد طبیعتا نگهداری از آن نیز ساده تر خواهد بود.

ما در این دوره قصد آموزش نوشتن کدهای maintainable یا extensible را نداریم چرا که بیشتر مربوط به برنامه نویسی فنی هستند تا اینکه به خوانایی مربوط باشند. در عین حال قوانین مختلفی برای نوشتن کدهای خوب در برنامه نویسی شیء گرا وجود دارد و بعضی از این قوانین سه مفهوم maintainable و extensible و readable را بیشتر به هم نزدیک می کنند در حالی که برخی دیگر از قوانین فقط روی maintainable و extensible تمرکز می کنند. تمرکز ما در این دوره روی قوانینی است که تمرکز بالایی روی کد خوانا دارند. یکی از این قوانین، قانون Demeter است.

Law of Demeter یا LoD یا «قانون دمیتر» با نام «قاعده دانش حداقلی» نیز شناخته می شود. این قانون می گوید هر واحد کد باید دانشی حداقلی درباره دیگر واحد های کد داشته باشد، آن هم واحد های کدی که به خودش نزدیک باشند. یعنی چه؟ به زبان ساده کد شما نباید به کدهای دیگری که از آن اطلاع نداریم وابستگی زیاد داشته باشد. به کد ساده زیر دقت کنید:

this.cusotmer.lastPurchase.date;

این بخشی از یک کد در یک متد از یک کلاس ساده است. ما در این کلاس خصوصیتی به نام customer را داریم بنابراین با this.customer به آن دسترسی پیدا کرده ایم اما خود customer (خریدار) خصوصیتی به نام lastPurchase (آخرین خرید) دارد و ما از طریق Customer به آن دسترسی پیدا کرده ایم که کار بدی نیست اما در آخر lastPurchase نیز یک خصوصیت دیگر به نام date (تاریخ آخرین خرید) را دارد. همانطور که می بینید این کد به کدهای «غریبه» یا strangers وابستگی زیادی دارد. در قانون دمیتر strangers به کدها یا اشیاء غریبه اشاره می کند، یعنی اشیائی که آن ها را به صورت مستقیم نمی شناسیم.

شاید بگویید چرا نباید چنین کدی بنویسیم؟ به دلیل اینکه اگر بعدا ساختار درونی lastPurchase تغییر کند (مثلا date به purchase_date تغییر کند)، تمام کدهایی که بدین شکل نوشته شده باشند خراب خواهند شد و برنامه crash می شود (از کار می افتد). در چنین حالتی باید به دنبال تک تک این موارد بگردیم و آن ها را اصلاح کنیم.

بر اساس قانون دمیتر، کدهای هر متد تنها می توانند به خصوصیات و متدهای درونی زیر دسترسی داشته باشند:

  • شیء ای که کدها به آن تعلق داشته باشند.
  • اشیائی که در خصوصیات شیء ذخیره شده اند. مثلا دسترسی به customer در مثال بالا مشکلی ندارد چرا که خصوصیت اصلی کلاس است و دسترسی به lastPurchase نیز مشکلی ندارد چرا که به طور مستقیم درون خصوصیت customer است.
  • اشیائی که به عنوان پارامتر یک متد دریافت شده اند.
  • اشیائی که در متد ساخته شده اند. مثلا اگر از یک کلاس دیگر نمونه سازی کرده باشیم، کار با آن شیء مشکلی ندارد.

به مثال زیر نگاهی بیندازید:

class DeliveryJob {

  customer: any;

  warehouse: any;




  constructor(customer, warehouse) {

    this.customer = customer;

    this.warehouse = warehouse;

  }




  deliverLastPurchase() {

    const date = this.customer.lastPurchase.date;

    this.warehouse.deliverPurchasesByDate(this.customer, date);

  }

}

همانطور که می بینید مثال this.customer.lastPurchase.date از همین کد آمده است. همانطور که توضیح دادم بخش date در this.customer.lastPurchase.date نباید بدین شکل در دسترس باشد چرا که یک خصوصیت مستقیم در customer نیست، بلکه دسترسی ما به آن به شکل غیر مستقیم است که بر اساس قانون دمیتر غیر مجاز است.

به نظر شما چطور می توانیم این کد را حل کنیم؟ یکی از راه حل های ممکن این است که به کلاس Customer برویم و متد خاصی را برای دریافت تاریخ مشخص کنیم. من سعی کرده ام این راه حل را به صورت خلاصه و در کد زیر به شما نشان بدهم:

class Customer {

  lastPurchase: any;




  getLastPurchaseDate() {

    return this.lastPurchase.date;

  }

}




class DeliveryJob {

  customer: any;

  warehouse: any;




  constructor(customer, warehouse) {

    this.customer = customer;

    this.warehouse = warehouse;

  }




  deliverLastPurchase() {

    // const date = this.customer.lastPurchase.date;

    const date = this.customer.getLastPurchaseDate();

    this.warehouse.deliverPurchasesByDate(this.customer, date);

  }

}

من در اینجا کلاس Customer را تعریف کرده ام و به آن متدی به نام getLastPurchaseDate را داده ام که یک getter بوده و مسئول دریافت تاریخ است. در این حالت دسترسی به date از lastPurchase مشکلی نخواهد داشت چرا که date خصوصیتی مستقیم برای lastPurchase است. به همین دلیل من کد قبلی را در متد deliverLastPurchase کامنت کرده ام و به جای آن از این متد استفاده کرده ام که روش صحیح است. با اینکه کد ما در حال حاضر از قانون دمیتر پیروی می کند اما ما آن را مجبور به پیروی از این قانون کردیم! اگر به کد کامنت شده و کد جایگزین آن نگاهی بیندازید متوجه خواهید شد که کد ما خیلی تمیزتر نشده است. در واقع به جای تمیز کردن کدها،‌ قسمت غیر استاندارد را به کلاس Customer پاس داده ایم تا کد به ظاهر تمیز تر شود.

در صورتی که بخواهید در کل برنامه خود بدین شکل عمل کنید، به تعداد زیادی متد کمکی به شکل متدهای بالا نیاز خواهید داشت و کم کم کلاس هایتان پر از متدهایی می شوند که تنها کارشان برگرداندن یک خصوصیت است. برای اینکه بتوانیم این کد را واقعا تمیز کنیم باید از قاعده ای به نام Tell, Don't ask (بگو، درخواست نکن) استفاده کنیم. ما در کدهای بالا از متد deliverLastPurchase برای دریافت تاریخ استفاده کرده ایم و سپس آن تاریخ را به متد دیگری به نام deliverPurchasesByDate پاس داده ایم. ما می توانستیم به جای درخواست تاریخ سفارش، به کلاس خودمان بگوییم که کل این فرآیند را چطور انجام دهد. یعنی به جای اینکه بخواهیم کلاس Customer را ویرایش کنیم، باید کلاس warehouse را ویرایش کنیم (کلاسی که متد deliverPurchasesByDate را دارد). به مثال زیر دقت کنید:

class DeliveryJob {

  customer: any;

  warehouse: any;




  constructor(customer, warehouse) {

    this.customer = customer;

    this.warehouse = warehouse;

  }




  deliverLastPurchase() {

    // const date = this.customer.lastPurchase.date;

    // const date = this.customer.getLastPurchaseDate();

    // this.warehouse.deliverPurchasesByDate(this.customer, date);

    this.warehouse.deliverPurchase(this.customer.lastPurchase);

  }

}

همانطور که می بینید من lastPurchase را مستقیما به متد deliverPurchase پاس داده ام بنابراین وظیفه استخراج تاریخ و کار با آن بر عهده متد deliverPurchase خواهد بود. همانطور که قانون دمیتر می گوید زمانی که شیء ای به عنوان پارامتر یک متد پاس داده شود اجازه داریم با خصوصیات درونی آن (مانند date در مثال بالا) کار کنیم. با این حساب دیگر قانون دمیتر را نشکسته ایم و در عین حال کدهایمان را تمیز کرده ایم.

قوانین SOLID

SOLID به مجموعه ای از قوانین گفته می شود که برای نوشتن کدهای تمیز در برنامه نویسی شیء گرا کاربرد دارند. کلمه SOLID در لغت معانی مختلفی مانند «مستحکم» یا «استوار» یا «قابل اعتماد» را دارد اما در اصل یک کلمه نیست بلکه مخفف ۵ قانون است:

  • حرف S: این حرف مخفف قانون Single Responsibility Principle یا «قانون مسئولیت واحد» است.
  • حرف O: این حرف مخفف قانون Open-Closed Principle است.
  • حرف L: این حرف مخفف قانون Liskov Substitution Principle است.
  • حرف I: این حرف مخفف قانون Interface Segregation Principle است.
  • حرف D: این حرف مخفف قانون Dependency Inversion است.

این ۵ قانون از مهم ترین قوانین نوشتن کدهای شیء گرا است و تمام برنامه نویسان حرفه ای باید تا حد مناسبی از آن پیروی کنند. همانطور که قبلا توضیح دادم بین نوشتن کدهای مناسب از نظر فنی و کدهای خوانا معمولا رابطه مستقیمی وجود دارد. در اینجا نیز باید بدانید که این ۵ قانون به طور مستقیم مسئول تنظیم کدهای خوانا نیستند اما به نوشتن کدهای تمیز کمک می کنند (مخصوصا دو قانون اول یا حروف S و O). در جلسه بعدی در رابطه با این قوانین صحبت خواهیم کرد.

تمام فصل‌های سری ترتیبی که روکسو برای مطالعه‌ی دروس سری کدنویسی تمیز توصیه می‌کند:
نویسنده شوید
دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.