اصول برنامه نویسی شی گرا

شما هم حتما مثل من زیاد شنیدین که کد هایی که می نویسید باید مطابق اصول شی گرایی باشه و اندر مزایای شی گرایی به اندازه موهای سر من نکته و مطلب خوندید. اما شاید شما هم مثل من به محض این که تسکی رو برای انجام برمی دارید انقدر درگیر جزئیات، چگونگی پیاده سازی و … تسک می شید که به کل یادتون میره چیزی به اسم شی گرایی هم وجود داره !!!

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

  • سوالاتی که در طراحی شی گرا باید به آن ها پاسخ دهیم : سیستم باید چه کاری انجام دهد، سیستم برای چه کاربرانیتوسعه پیدا می کند و چه موجودیت هایی با آن کار می کنند، خروجی سیستم چه باید باشد و آیا سیستم نیاز هایمشخص شده در سند نیازمندی ها را تامیین می کند یا نه
  • مقصود ما از طراحی شی گرا، تعریف و نحوه ارتباط و قرارگیری کلاس ها و اشیا می باشد
  • در بحث ارتباط کلاس ها با هم یکی از روش ها ارث بری می باشد درحالی که کلاس ها می توانند بدون ارث بردناز هم و صرفا با ترکیب اشیا با هم در زمان اجرا به سطح انعطاف پذیر تری از ارتباط دست پیدا کنند
  • یک دو راهی که همواره در برابر آن قرار داریم این است که یک عملیات خاص باید در قالب یک متد در کلاس قراربگیرید و یا آن عملیات بر عهده استفاده کننده از کلاس قرار دارد

برنامه نویسی شی گرا و رویه ای و تقسیم مسئولیت

یکی از مهم ترین تفاوت های برنامه نویسی شی گرا با برنامه نویسی رویه ای این است که در شی گرایی ما تلاشمی کنیم مسئولیت انجام تسک های گوناگون را به درون کلاس برده تا به این ترتیب هم از تکرار کد جلوگیری نماییمو هم به کلاس این اجازه را دهیم تا خود بر روی رفتارش کنترل داشته باشد و تصمیم گیری در سمت استفاده کننده ازکلاس به حداقل برسد. در مثال زیر ما می خواهیم یک سیستم برای نوشتن و خواندن از یک فایل کانفیگ را که درآن داده ها به صورت key:value قرار دارد شبیه سازی کنیم :

روش رویه ای :

function readParams(string $source): array
{
    $params = [];
    // read text parameters from $source
    // require the name of a source file
    // it attempts to open it, and reads each line, and looking for key/value pairs
    // it builds up an associative array as it goes
    return $params;
}

function writeParams(array $params, string $source)
{
    // write text parameters to $source
    // accepts an associative array and the path to a source file
    // it loops through the associative array, writing each key/value pair to the file 
}

حال فرض کنید یک نیازمندی جدید به سیستم اضافه شده است و از این به بعد سیستم باید بتواند ساختار XML شبیه زیر را نیز پشتیبانی کند :

<params>
    <param>
        <key>my key</key>
        <val>my val</val>
    </param>
</params>

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

function readParams(string $source): array
{
    $params = [];
    if (preg_match("/\.xml$/i", $source)) {
        // read XML parameters from $source
    } else {
        // read text parameters from $source
    }
    return $params;
}
function writeParams(array $params, string $source)
{
    if (preg_match("/\.xml$/i", $source)) {
        // write XML parameters to $source
    } else {
        // write text parameters to $source
    }
}

مشکلی که در این جا ایجاد می شود این است که چک کردن پسوند در دو تابع تکرار شده است و برای مثال اگرفرمت جدیدی به نیازمندی ها اضافه شود باید حواسمان باشد تا کد های مربوط به چک کردن آن فرمت جدید را درهر دو تابع اعمال کنیم (دو تابع باید با هم سینک باشند)

روش شی گرا :

در ابتدا یک کلاس abstract برای این کار می نویسیم :

abstract class ParamHandler
{
    protected $source;
    protected $params = [];
    public function __construct(string $source)
    {
        $this->source = $source;
    }
    public function addParam(string $key, string $val)
    {
        $this->params[$key] = $val;
    }
    public function getAllParams(): array
    {
        return $this->params;
    }
    public static function getInstance(string $filename): ParamHandler
    {
        if (preg_match("/\.xml$/i", $filename)) {
            return new XmlParamHandler($filename);
        } 
        return new TextParamHandler($filename);
    }
    abstract public function write(): bool;
    abstract public function read(): bool;
}


همان طور که مشاهده می کنید آرایه ای در نظر گرفته شده است که صرف نظر از فرمت نهایی، کلاس با آن کار میکند. نکته بسیار بسیار مهمی که در این کلاس وجود دارد این است که متد استاتیک getInstance درباره فرزنداناین کلاس اطلاع دارد و مشکل از جایی شروع می شود که شما بخواهید فرمت جدید دیگری را پستیبانی کنید کهبرای این منظور باید به سراغ دستکاری کلاس پدر بروید که این کار خود نقض کننده یکی از اصول مهم شی گراییبه نام Open/Close Principle هست. و در بدترین حالت اگر شما مالک این کلاس پدر نباشید و در قالب یکthird party از آن استفاده می کنید که دیگر اوضاع بسیار خراب می شود.

حال به سراغ کلاس های فرزند می رویم :

class XmlParamHandler extends ParamHandler
{
    public function write(): bool
    {
        // write XML
        // using $this->params
    }
    public function read(): bool
    {
        // read XML 
        // and populate $this->params
    }
}
class TextParamHandler extends ParamHandler
{
    public function write(): bool
    {
        // write text 
        // using $this->params
    }
    public function read(): bool
    {
        // read text 
        // and populate $this->params
    }
}

حال استفاده کننده از کلاس می تواند با خیال راحت و بدون هیچ مسئولیت و دانش خاصی از این دو فرمت استفاده کند :

$test = ParamHandler::getInstance(__DIR__ . "/params.xml");
$test->addParam("key1", "val1");
$test->addParam("key2", "val2");
$test->addParam("key3", "val3");
$test->write(); // writing in XML format
 
$test = ParamHandler::getInstance(__DIR__ . "/params.txt");
$test->read(); // reading in text format
$params = $test->getAllParams();
print_r($params);

نتایج برنامه نویسی شی گرا :

  • مسئولیت : دقت داشته باشید که در روش شی گرا بر خلاف روش رویه ای تصمیم گیری برای فرمت فقط یک جاصورت می گیرد و آن هم در کلاس پدر است (Factory). استفاده فقط می داند که دارد با کلاسی از نوعParamHandler کار می کند و از بابت وجود متد هایی مانند addParam مطمئن است پس در کد شی گرا اینمسئولیت تشخیص به عهده خود کلاس قرار گرفته شده است و کاربر صرفا با یک واسط مشخص کار می کند. درروش شی گرا اضافه کردن فرمت جدید تقریبا مثل آب خوردن است.
  • انسجام : این انتقال تصمیم گیری برای فرمت در یکجا سبب ایجاد پیوستگی شده و علاوه بر حذف تکرار کد و ایجادوابستگی در کد، اضافه کردن فرمت جدید را نیز آسان کرده و باعث می شود با کمترین میزان تغییر این اضافه کردن انجام شود.
  • جفت شدگی : جفت شدگی در کد سبب می شود تغییر در یک قسمت از برنامه نیازمند ایجاد تغییر در دیگر قسمت هابه صورت آبشاری شود قسمت هایی که حتی ممکن است کاملا از هم مجزا باشند. در روش رویه ای ما شاهد اینجفت شدگی در تابع های writeParams و readParams بودیم چرا که هر دو یک تست واحد را بر رویپسوند فایل انجام می دادند حال اگر قرار باشد این تست تغییر کند باید در هر دو تابع این تغییر اعمال شود. حال درمثال شی گرا دیگر این جفت شدگی وجود ندارد و اگر فرمت جدید اضافه شد علاوه بر نوشتن کلاس جدیدی برای آنو ارث بردن از ParamHandler باید تنها متد getInstance نیز عوض شود و دیگر به کلاس های موجودبرای xml و text دست زده نمی شود (close for modification)
  • در پایان دقت کنید که آن چه که ما از آن به عنوان روش رویه ای نام بردیم می تواندخیلی شیک و مجلسی در قالب یک کلاس قرار بگیرد و با افتخار بگوییم که ماطراحی شی گرا کرده ایم. پس وجود کلاس دلیلی برای طراحی شی گرا نیست.

یافتن کلاس ها در سیستم

یکی از روش های یافتن کلاس ها در سیستم می تواند این باشد که ما به دنبال موجودیت هایی که مابه ازای خارجیدارند گشته و بر هر کدام از آن ها یک کلاس تعریف کرده و از طریق متد ها به آن کلاس ها، کاربری اختصاصدهیم. اما اگر با این دیدگاه جلو رویم به تدریج به کلاس هایی خواهیم رسید که تعداد زیادی متد داشته و وظایف زیادیرا انجام می دهند (Single Responsibility Principle).

برای مثال فرض کنید در یک سیستم فروشگاهی ما کلاسی برای محصولات داریم حال می خواهیم مکانیزمی داشتهباشیم تا اطلاعات یک محصول را به صورت خلاصه در اختیار ما قرار دهد. اولین ایده ای که به ذهن می رسد ایناست که خب متدی به نام writeSummary به کلاس اضافه کنیم. حال فرض کنید با گذشت زمان این نیاز ایجاد شدکه ما این خلاصه را در فرمت های HTML و XML ارائه دهیم خب حالا چه باید کرد !!! شاید دو راه به ذهن برسدیکی نوشتن دو متد برای این کار writeHtmlSummary و writeXmlSummary. خب ناگفته پیدا است که دراین دو متد شاهد یک عالمه تکرار کد خواهیم بود. خب به سراغ راه حل دوم برویم که این است که همان متدwriteSummary را با گذاشتن یک ساختار شرطی طوری تغییر دهیم تا بسته به شرایط یا XML بنویسد و یاHTML بنویسد (Single Responsibility Principle کجا رفت !!!). پس هر دو راه بد است و اگر قرار باشد فرمت جدید دیگری نیز اضافه شود که اوضاع بدتر هم می شود.

مشکل کار اینجا است که خانه از پای بست ویران است و ما با قرار دادن writeSummmary در کلاس محصولباعث شدیم این کلاس بیش از حد کار انجام دهد. این نوشتن خلاصه خود یک وظیفه جدید است و باید برای آن کلاسجدیدی نوشته شود. پس در نوشتن کلاس ها دقت کنیم قرار نیست تمام موجودیت های قابل لمس در سیستم فقط کلاسباشند.

پس همان طور که گفتیم کلاس باید یک وظیفه واحد داشته باشد به نحوی که این وظیفه تا جای ممکن یکتا و متمرکزشده باشد. می توان برای تست کردن این اصل وظایف کلاس را در قالب یک پاراگراف نوشت اگر طول اینپاراگراف بیش از ۲۵ کلمه شد، کلی اما و اگر در آن بود و جملات طولانی داشت پس می توان نتیجه گرفت کهکلاس بیش از یک وظیفه و مسئولیت داشته و باید به فکر کلاس و یا کلاس های جدیدی بود.

در مثال بالا این جدا کردن ها مثل یک شمشیر دو لبه است چرا که وقتی ما کلاسیبرای WriteSummary داشته باشیم باید شی ای از کلاس Product را به آن پاسدهیم این کار اولا سبب می شود که جفت شدگی و وابستگی در سیستم افزایشیابد ثانیا دیگر در کلاس WriteSummary ما دسترسی کامل به تمامی متد ها و فیلدهای کلاس Product نداریم (اگر متد writeSummary در خود کلاس Product بود ماآنجا دسترسی کامل به همه چی داشتیم مثلا متد ها و فیلد هایprivate/protected)

ارث بری

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

  • یکی از نشانه های نیاز به ارث بری وجود ساختار های شرطی یکسان در متد ها می باشد. مانند آن چه که در روشرویه ای نوشتن در فایل کانفیگ مشاهده کردید. اما باید توجه داشت هر ساختار شرطی دلیل بر ارث بری نیست مثلاساختار شرطی متد getInstance در کلاس ParamHandler کاملا به جا بوده و سبب ایجاد مرکزیت درتصمیم گیری می شود. ولی می توان گفت که اگر یک ساختار شرطی در دو یا چند متد عینا تکرار شد احتمال بسیارزیاد نیاز به ارث بری است.
  • برای شروع طراحی شی گرا همیشه نگاه از بالا به پایین داشته، نخست از interface و کلاس های abstractشروع کرده و تنها به فکر نوشتن یک واسط گویا و کاربردی باشید. در این مرحله به هیچ وجه درگیر جزيیات پیادهسازی نشوید خواهید دید که اگر واسط گویا برای کلاس ها طراحی کنید می توانید به راحتی آن ها را پیاده سازیکرده و به سادگی آن ها را گسترش داده و یا با کم ترین هزینه تغییر دهید.
Gang of Four : Program to an interface, not an implementation

چهار نشانه برای اصلاح و تفکر بیشتر در طراحی و کد نویسی

  • تکرار : تکرار در کد که خود سبب ایجاد جفت شدگی در کد می شود.
  • کلاسی که بیش از حد می داند : در این صورت کلاس شما وابسته می شود به اطلاعاتی خارج از محدوده و کنترلخودش و همین امر قابلیت استفاده مجدد را از کلاس می گیرد و سبب وابستگی کلاس می شود.
  • نقض Single Responsibility Principle
  • وجود ساختار های شرطی یکسان که قبلا در باره آن ها صحبت کردیم.

اصول SOLID

  • تنها یک مسئولیت : همان طور که قبلا صحبت شد هر کلاس باید یک و فقط یک مسئولیتداشته باشد. می توان این طور نیز به این اصل نگاه کرد که تنها باید یک دلیل برای تغییر کلاس وجود داشته باشد نهبیشتر. برای یافتن منشا آن دلیل می توان به استفاده کنندگان آن کلاس فکر کرد چرا که آن ها هستند که درخواستتغییر را خواهند داد. به جای غرق شدن در تعداد زیادی مخاطب و استفاده کننده، بهتر است به دنبال نقش هایی باشیمکه با کلاس کار می کنند چرا که همین نقش ها هستند که درخواست تغییر می دهند. برای فهم بیشتر می توانید بهقسمت Classic Examples در لینک Single Responsibility Principle مراجعه کنید. رعایت این اصلسبب می شود تا میزان وابستگی و جفت شدگی کلاس ها به هم کمتر و سبک تر شود. در پایان باید اشاره کرد کههیچ اصل ای ۱۰۰ درصد درست نیست بلکه باید با توجه به نیازمندی ها و سیستمی که با آن کار می کنید بهترینگزینه را انتخاب کنید و همچنین مواظب باشید تا از اون ور بوم نیفتید.
  • بسته برای تغییر و باز برای گسترش : تمامی موجودیت های برنامه نویسی نظیر کلاس ها، متد ها و … کدشان می تواندبرای توسعه باز باشد اما باید برای اعمال تغییرات بسته بماند. آن چه که در این اصل می خواهیم به آن برسیم ایناست که در صورت ایجاد یک نیازمندی جدید در سیستم کد های موجود را تغییر نداده و صرفا با نوشتن کد های جدیدکه از کد های قدیم استفاده می کنند به آن نیاز پاسخ دهیم. این اصل ارتباط بسیار خوبی با SRP داشته و در صورترعایت هر کدام از آن ها رسیدن به دیگری بسیار ساده می باشد. در سخت ترین حالت ممکن می توان گفت هرکلاسی که با کلاس دیگر ارتباط داشته باشد و از شی ای از آن استفاده کند این اصل را نقض می کند اما باید توجهداشت که در صورتی می توان گفت که این ارتباط نقض کننده OCP است که دو کلاسی که با هم در ارتباط هستند در یک سطح از abstraction قرار نداشته باشند مثلا یک کلاس بسیار کلی و دیگری جزئی باشد. یکی از طراحیهای مناسب برای برقراری این اصل، الگو طراحی Strategy می باشد که در آن صورت می توانیم یک interface را به کلاس استفاده کننده، وابسته کنیم و دیگر خود را به کلاس های جزئی و جزئیات وابسته نکنیم وهمچنین با این کار می توانیم به راحتی سیستم را گسترش دهیم (چرا که با ایجاد یک نیاز جدید صرفا کافی است برایآن یک کلاس نوشته شود که از آن interface ارث ببرد و با این کار کلاس استفاده کننده اولیه هیچ نیازی به تغییرندارد). بار دیگر باید تاکید کرد که در استفاده از این اصل زیاده روی نکنید وگرنه در همان ابتدا کار با تعداد زیادیinterface برای هر کلاس مواجه خواهید شد.
  • اصل جایگزینی Liskov : این اصل می گوید که اگر کاربر یک کلاس بتواند با کلاسی کار کند بایدبتواند با کلاس های فرزند آن نیز کار کند. برای توضیحات بیشتر می توانید به بخش The Classic Exampleدر لینک LSP مراجعه کنید.
  • جدا سازی Interface ها : مطابق این اصل به جای آن که یک interface بسیار بزرگ داشتهباشید که همه کلاس هایی که آن را پیاده سازی می کنند می بایست کلی متد نامربوط را نیز پیاده سازی کنند، آنinterface را شکسته و اجازه دهید هر کلاس تنها آن چرا که به آن نیاز دارد پیاده سازی کند.
  • قبل از اینکه به سراغ آخرین اصل برویم بگذارید اندکی درباره وابستگی صحبت کنیم. مطابق آن چه که تا کنونصحبت شد وابستگی امری غیر قابل اجتناب می باشد اما باید توجه داشت که وابستگی نباید سبب شود تا در اثر تغییردر نیازمندی ها برای اعمال آن ها کد های موجود نیز تغییر پیدا کنند بلکه طراحی باید به گونه ای باشد تا صرفا بانوشتن کد های جدید و تنها اندکی تغییر در کدهای موجود به آن نیاز پاسخ گفت. یکی از مهم ترین دلایل ایجاد اینمشکلات این است که شیوه وابستگی ما غلط است و کلاس هایی را به هم وابسته می کنیم که به لحاظ سطح جزئیاتدر یک سطح قرار ندارند مثلا یک interface را به یک کلاس concrete وابسته می کنیم. مطابق اصلDependency Inversion دو کلاس که با هم مرتبط و وابسته هستند باید در یک سطح از abstraction قرارداشته باشند نه این که یکی کلی تر و دیگری جزئی تر باشد. همان طور که حتما متوجه شده اید رعایت این اصل تاجای خوبی ما را به سمت رعایت OCP نزدیک می کند چرا که رعایت این اصل سبب ایجاد درخت های ارث بریو استفاده از interface و یا کلاس abstract می شود و بدین ترتیب با تغییر در نیازمندی ها کلاس های موجودتغییر پیدا نمی کند و تنها کلاس جدید به درخت ارث بری افزوده می شود. همچنین با رعایت این اصل و استفاده ازروش کلی که به آن اشاره شد (ارث بری) می توان تا حد خوبی به SRP نیز دست پیدا کرد. (روشی که در رعایتاین اصل مطرح است این است که کلاس استفاده کننده صرفا با یک interface کار کند و جزئیات ما در قالب کلاسهای concrete ای که آن interface را پیاده سازی می کنند پنهان شود. کلاس استفاده کننده تنها با آن واسط درارتباط بوده و دیگر درگیر جزئیات نمی شود).