آزاده خرسندنیا
آزاده خرسندنیا
خواندن ۱۵ دقیقه·۳ سال پیش

اگر Net. کار هستید، باید بدانید(قسمت دوم) -> ارث بری و اصل L در اصول SOLID

ارث بری یا وراثت

ارث بری یا Inheritance یکی از جنبه های مهم شی گرایی در سی شارپ است.

ارث بری در زبان ساده یعنی من یک کلاسی دارم که در آن یک سری خصوصیت و رفتار، پیاده سازی کردم. حالا میخوام از روی این کلاس، یک کلاس دیگر را Extend کنم. به نحوی که در اون کلاس Extend شده، یا بسط شده:

  • اولا عینا به همون خصیصه ها و رفتارهای کلاس اول دسترسی داشته باشم.
  • اگر خواستم، بتونم رفتارها یا متد های کلاس اول رو در کلاس دوم، بازنویسی یا override کنم.
  • و در نهایت اینکه بتونم به این کلاس Extend شده، خصیصه و رفتار جدید هم اضافه add کنم.

نکته ای که در ارث بری مهمه، اینه که در واقع ما میخواهیم از یک کدی که از پیش نوشته شده هست،، دوباره استفاده کنیم و اون کد رو به عنوان یک base یا پایه برای کد جدید، در نظر بگیریم و دیگه دوباره کاری نکنیم و از اول چرخ نسازیم.

و نکته مهم دیگه اینه که ارث بری ساختار سلسه مراتبی داره. یعنی چی؟ یعنی یک کلاس میتونه یک کلاس Extend شده ای داشته باشه، که خود اون کلاس Extend شده، باز کلاس دیگری ازش بسط یا Extend بشه و این روال تا بی نهایت هم بره!!

  سطح یا Level ارث بری
سطح یا Level ارث بری

نکته مهم

یک اشتباهی که در ارث بری به کار میره، اینه که ما از لفظ های Parent-Child یا پدر، فرزندی یا Master-Detail برای توصیف کلاس اول، و کلاس Extend شده استفاده میکنیم و این اشتباهه.

لفظ درست ارث بری که اصل L سالید یا همون قانون Liskov بهش اشاره داره، BaseType و SubType هست و سعی کنید که از این به بعد، با این لفظ به ارث بری ارجاع کنید.

البته غیر از این لفظ، از دوتایی SubClass-BaseClass یا DriveType-BaseType یا DriveClass-SuperClass هم استفاده می شود.

ارث بری و سینتکس #C

ارث بری یکی از ساده ترین کارهای ممکن در سی شارپه! اصلا سینتکس پیچیده ای هم نداره.

یک کلاس دارم به اسم A

public class A
{
}

یک کلاس دیگه دارم به اسم B

public class B
{
}

چطور بگم B از A ارث میبره؟ فقط کافیه یک : بگذارم، جلوی اعلان کلاس B و بعدش بنویسم A.

public class B : A
{
}

تمام! B از A ارث برد.

  • آیا B میتونه از یک کلاس دیگه ای هم زمان با A ارث ببره؟ نه! از کلاس دیگه ای نمیتونه. ولی از اینترفیس، اره میتونه. میتونه همزمان از چندین اینترفیس ارث ببره، یا از ترکیب یک کلاس با چندین اینترفیس.
  • چرا خب؟ چرا B میتونه از چندین اینترفیس ارث ببره، ولی فقط میتونه از یک کلاس ارث ببره؟ دلیلش دو عامل هست : اولیش اینه که اینترفیس پیاده سازی نداره و در واقع یک Contract محسوب میشه. و پیاده سازی یک اینترفیس، به طور مستقل، درون هر کلاسی که ازش ارث ببره می تونه اتفاق بیفته و این هیچ Conflict یا تضادی در برنامه ایجاد نمیکنه.

عامل دومش سازنده است!. کلاس ها، متد سازنده دارند. حتی اگر شما در کدتون صراحتا متد سازنده ای
نداشته باشید هم، پشت صحنه این سازنده وجود داره! و خود سی شارپ میسازدش. حالا فرض کنید من
شی ای از کلاس B میسازم. با کلمه کلیدی new. اونوقت این سازنده ها چطور صدا زده میشوند؟ سازنده
SuperType اول فراخوانی میشه. اگر من چند تا کلاس Super داشته باشم، اون وقت این ایجاد Conflict
ممکنه بکنه و اینکه نحوه فراخوانی این سازنده ها، برای برنامه گیج کننده باشه.

  • آیا اگر کلاس دیگه از B ارث ببره، مثلا C. اون وقت C به متدها و پراپرتی های A دسترسی داره؟ بله. البته اگر متد یا خصوصیت Private ای در A باشه. نه B بهش دسترسی داره و نه C. ولی به بقیه اش دسترسی داره.
  • آیا من میتونم رفتار یا همون متدی در A رو در کلاس B شخصی سازی کنم. یعنی مثلا A متدی داره به اسم Add و بعد من بیام با همین عنوان Add، یک بدنه(پیاده سازی) دیگه و مستقل،در B براش بنویسم؟ بله. فقط کافیه در اعلان متد Add در کلاس A از کلمه کلیدی Virtual استفاده کنید.

public class A
{
public virtual void Add()
{
}
}

و بعد در کلاس B، زمان تعریف متد Add از کلمه کلیدی Override استفاده کنید.

public class B : A
{
public override void Add()
{
}
}

  • خب حالا اگر توی همین تابع Add کلاس B، بخوام Add کلاس A رو صدا بزنم، باید چی کار کنم؟ یا اصلا یک فرض دیگه بکنیم. من یک Add توی A دارم و یک Add توی B که بدنه هاشون باهم فرق داره.چون من اومدم override اش کردم در ساب کلاس. حالا در یک متد دیگه ای، من میخوام Add صدا بزنم. برنامه از کجا بفهمه باید Add کلاس Base رو اجرا کنه یا Add کلاس Sub رو؟ اینجا هم یک کلمه کلیدی داریم به اسم base. شبیه this هست، فقط وقتی base رو به کار میبرید، یعنی BaseType. اگر Base نگذارید، برنامه Add کلاس فعلی، یعنی B رو اجرا میکنه.

public class B : A
{
public override void Add()
{
}
public void AddAndPrint()
{
base.Add();
}
}

پلی مورفیسم (چند ریختی) و رابطه اش با وراثت. قاطی نکنید!

پلی مورفیسم و وراثت، حکایت شون دقیقا حکایت ماست ها و قیمه هاست که نباید قاطی بشوند!

درسته که Override کردن یک متد Virtual، درون یک Subtype یک نمونه از کاربرد Polymorphism هست. ولی پلی مورفیسم یا چند ریختی، اصلا مترادف وراثت نیست و این دو در یک کفه با هم قرار نمیگیرند.

چند ریختی، یک جنبه دیگری از برنامه نویسی شی گراست. مثل وراثت. و مثل وراثت، به هدف استفاده مجدد کد ها به کار میره.

منتها وراثت، میگه اگر کلاسی از یک کلاس دیگه Extend شد، من همه رفتارها و خصیصه های غیر Private اش رو به اون کلاس دیگه هم میدم و میتونه اونم ازش استفاده کنه. ولی پلی مورفیسم، جزئی تر در مورد استفاده مجدد یک کد صحبت میکنه، نه لزوما در سطح کلاس.

چند ریختی به دو دسته کلی تقسیم میشه:

  • در زمان کامپایل یا Compile Time که Static binding هم بهش میگن.

که خود همین به دو دسته Function overloading و Operator overloading تقسیم میشه.

  • در حین اجرا یا Run Time که بهش Dynamic binding یا Late binding هم میگن.
تقسیم بندی پلی مورفیسم
تقسیم بندی پلی مورفیسم


حالا یعنی چی Compile Time و Run Time. این نامگذاری و تفکیک، از چی ناشی میشه؟

به این سوال جواب میدم ولی قبلش بیایم در مورد overriding و overloading صحبت کنیم.

مفهوم overloading

مفهوم Overloading ، یعنی من برای یک نام(Function) یا یک عملگر(operator)، شکل های رفتاری مختلفی ارائه بدم.

برای متد یا Function به سه طریق میشه این کار رو کرد:

  • تفاوت در تعداد پارامتر
  • تفاوت در نوع پارامتر
  • تفاوت در ترتیب پارامترها، با نوع های مختلف.

و برای Overloading در سطح عملگر. فرض کنید من یک کلاس دارم که میتونم ازش instance یا شی تعریف کنم. حالا میخوام برابر بودن دو شی از این کلاس رو چک کنم.

بدترین کار، اینه که بشینم دونه دونه، value های پراپرتی های شی ها رو باهم چک کنم.

بهترین کار اینه که عملگر == رو برای اون کلاس Overload کنم. به نحوی که هرجا لازم بود، تساوی دو شی رو باهاش چک کنم.

public class A
{
public int a { get; set; }
public int b { get; set; }
public static bool operator ==(A obj1, A obj2)
{
return (obj1.a == obj2.a) && (obj1.b == obj2.b);
}

public static bool operator !=(A obj1, A obj2)
{
return (obj1.a != obj2.a) || (obj1.b != obj2.b);
}
}

چند نکته مهم درباره Operator overloading اینکه :

  • اولا Operator ها اعضای Static یک کلاس یا struct محسوب میشوند.
  • دوم اینکه حتما باید حداقل یکی از پارامترهای پاس داده شده، در زمان overload کردن عملگر، از جنس همون کلاسی باشه که داریم عملگر رو براش overload میکنیم.
  • سوم اینکه بعضی از عملگرها جفت هستند. وقتی یکی رو دارین overload میکنین، حتما باید جفت اشم Overload کنین.وگرنه بهتون خطا میده. مثل همین مثالی که من نوشتم بالا. == یک جفت =! داره.
  • چهارم اینکه ترتیب پارامتر ورودی، نباید اثری در خروجی عملگر داشته باشه. یعنی الان برای == نباید فرقی بکنه در نتیجه خروجی اش، که کدوم شی، به عنوان پارامتر اول ارسال بشه.
  • پنجم، در Overload کردن یک عملگر، اولویت اعمال اون عملگر رو تغییر نمیده. یعنی اگر * بر + الویت داره. اگر من * و + رو overload کنم.باز هم این الویت پابرجاست.
  • و در آخر اینکه یک سری عملگر ها، مثل + باید حتما در خروجی خودشون، جنس پارامتر ورودی برگردونند. یعنی int + int ، خروجی اش میشه int. حالا اگر شما دارید + رو برای یک کلاس overload میکنین.باید به این نکته دقت داشته باشید.

مفهوم overriding

در بالا اشاره کردیم که overriding در زمان ارث بری کاربرد داره و به این شکل هست که من میتونم با virtual کردن یک متد در یک کلاس، با استفاده کلمه override در کلاس SubType، بدنه دیگری رو جایگزین این متد بکنم بدون اینکه اسمش رو تغییر بدم.

تقسیم بندی CompileTime و RunTime

حالا برگردیم به اون سوال اول که تقسیم بندی CompileTime و RunTime از کجا میاد.

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

بیاین ساده تر کنیم.

فرض کنید من یک متد Add نوشتم داخل یک کلاسی، که دو تا پارامتر int میگیره و جمع شون رو خروجی میده.

حالا توی همون کلاس، یک Add دیگه نوشتم که دو تا String میگیره و بهم دیگه join شون میکنه و خروجی میده.

من سورسم رو که کامپایل کنم و اسمبلی ازش بسازم. پلی مورفیسم من اتفاق می افته و دیگه هیچ ربطی به اتفاقات حین اجرا شدن اون برنامه نداره. این کلاس الان دیگه دارای دو تا Add شده!.تا به ابد. پلی مورفیسم رخ داده و اگر این سورس، به یک سورس دیگه Reference داشته باشه. هر بار من یک شی ایجاد کنم از این کلاس، دو تا Add براش میبینم.

ولی حالا فرض کنید که در همون مثال اولیه خودمون، یعنی کلاس B ، من اومدم متد Add رو توش override کردم و این شکلی از پلی مورفیسم بهره بردم. ولی توی بدنه اون متد override شده، بنویسم if کاربر لاگین کننده admin بود، آنگاه Base.Add وگرنه برو این کد دیگه رو اجرا کن. حالا من سورس رو کامپایل میکنم. آیا چند ریختی یا همون پلی مورفیسم اتفاق افتاده با کامپایل شدن؟ نه!

چون ممکنه کاربر لاگین کننده ادمین باشه و اینجوری اصلا من ریخت! دیگری از Add ارائه ندادم. چون گفتم Base.Add. که یعنی برو همون Add کلاس A رو صدا بزن. ولی اگر کارم ادمین نبود.الان پلی مورفیسم اتفاق می افته.

اصل L در اصول SOLID

سالید، یک مجموعه از اصول در شی گرایی هست که متشکل شده از پنج اصل:

  • Single Responsibility Principle
  • Open-Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

الان میخوایم اصل سوم که به اختصار LSP گفته میشود و در رابطه با ارث بری هست رو، به تفصیل اینجا بررسی کنیم. اصل لیسکو! که در بعضی منابع به اصل جایگزینی مشهوره. قرارم نیست تاریخچه بگیم که چی شده و کی بوده و چجور شده.

اصل Liskov، این پرسش رو مطرح میکنه که چه زمانی، یک کلاس میتواند از کلاس دیگری ارث ببرد؟ آیا تنها یک سینتکس، که من بنویسم

public class B : A

یعنی ارث بری نوشته شده، درست و صحیح است؟

نه!.

و برای صحیح بودنش، یک قاعده کلی رو مطرح میکنه و اون اینه که :

یک SubType باید از رفتار BaseType خودش پیروی کنه. منظورش چیه؟ منظورش اینه که هر جا شما در کد ات نوشتی :

A myObj = new B();

و Subtype رو به جای BaseType قرار دادی(جایگزین) کردی، رفتار شی myObj، مشابه تمام آبجکت هایی باشه که از نوع کلاس A هستند و فرقی نکنه.

و بابت توضیحش که منظورش چیه، یک مثال کلاسیک میزنه:

میگه فرض کنید یک کلاس دارید به اسم Rectangle یا مستطیل.

public class Rectangle
{
public int Hight {get; set;}
public int Width {get; set;}


public Rectangle(int H,int W)
{
Hight = H;
Width = W;
}

public int Area
{
return Hight * Width;
}
}

کلاس Rectangle یک سازنده داره، که میاد به پراپرتی های خودش مقدار میده. و یک تابع محسابه ی محیط داره.

حالا شما میخواین یک کلاس دیگه بسازین به اسم مربع و با خودتون میگین :

  • مربع همون پراپرتی های مستطیل رو داره، یعنی طول و عرض.
  • مربع هم یک تابع Area داره که شیوه محاسبه اش مشابه مستطیل هست.یعنی ضرب طول در عرض.
  • از نظر هندسی هم، مربع همون مستطیلی است که طول و عرض یکسان دارد.

پس میشه که من کلاس مربعی که می سازم، از مستطیل ارث ببرم، و از اون کد ها دوباره استفاده کنم. با این تفاوت که شرط یکسان بودن طول و عرض رو هم بهش اعمال کنم:

public class Squre : Rectangle
{
public Squre(int H,int W) : base(H,W)
{
Hight = Width = H;
}

}

حالا در متد Main ، قاعده کلی Liskov رو مینویسیم .

یعنی دو تا شی مشابه ایجاد میکنیم از نوع BaseType یعنی مستطیل. منتها برای یکیشون، یعنی S، اومدیم SubType رو جایگزین کردیم. و انتظار داریم S ، رفتارش مشابه با R باشه. چون مربع هم یک جور مستطیله. اگر ما طول و عرض رو یکسان پاس ندهیم، انتظار داریم مربع تبدیل بشه به مستطیل و بتونه جایگزین BaseType خودش بشه.

static void Main(string[] args)
{
Rectangle R = new Rectangle(2, 3);
Rectangle S = new Squre(2, 3);
System.Console.WriteLine(R.Area());
System.Console.WriteLine(S.Area());
System.Console.ReadKey();
}

و هیچ خطای کامپایلی هم تولید نمیکنیم و سورسمون اجرا میشه.

اما خروجی اون چیزی که انتظار داریم نیست.

شی Rectangle S رفتارش در خروجی، به ازای شرایط پارامتری یکسانی که پاس داده شده، مشابه با Rectangle R نیست.
چرا میگیم یکسان نیست؟ چون خروجی متد محاسبه محیط برای شی R میشه 6، ولی برای S میشه 2.

چرا اینطوری شد؟ چون کلاس Squre با سازنده ای که درون خودش نوشته، قاعده مستطیل بودن baseType رو نابود کرده! و اینطوریه که با پارامتر های طول و عرض متفاوت، خروجی ایجاد کرده که جوریه که انگار یک مربع با طول و عرض 2 هست!

و قاعده جایگزینی، یا لیسکو، یا LSP میگه که این ارث بری، ناصحیح است.

ارث بری سلسله مراتبی
ارث بری سلسله مراتبی


حالا چطور بفهمیم که ارث بری مون صحیح هست :

لیسکو، 6 شرط رو اعلام میکنه و میگه اگر این 6 شرط بین BaseType و SubType برقرار بود. بدونین ارث بری صحیح بوده.

  • شرط اول : Covarient

در خروجی متد ها یا Return Type ها مطرح میشه.

به این ترتیب که میگه در سلسله مراتب وراثت، ما یک Base کلاس داریم و یک Sub کلاس. حالا اگر متدی در Base هست که داره یک نوع خروجی برمیگردونه، SubType یا باید از همون نوع خروجی بده یا یک کمی محدود تر.

یعنی چی؟ یعنی اگر در کلاس base ما متدی داریم که double برمیگردونه. نباید Subtype بتونه در کد خودش، object برگردونه به جای double. باید یا double برگردونه یا محدودتر، مثل int.

  • شرط دوم : Contravarient

این مشابه با بالایی هست.ولی در سطح پارامتر های ارسالی یا Method Arguments.

اگر متدی در Base هست که داره یک نوع ورودی، SubType یا باید از همون نوع ورودی یا یک کمی محدود تر.

یعنی اگر مثلا در کلاس Base ما متدی داریم که double میگیره. نباید Subtype بتونه در کد خودش، به این متد Object پاس بده. یا double پاس میده یا محدودتر میتونه پاس بده و int بده.

  • شرط سوم : Exceptions

این شرط میگه که SubType باید از Exception هایی که BaseType میده پیروی کنه و Exception جدیدی از خودش نده. مگر اونکه Exception های خودش هم ارث برده شده از Exception های base باشه.

  • شرط چهارم : PreCondition

به هر شرطی که قبل از اجرای کامل یک متد یا کلی تر بگیم یک قطعه کد درون یک Scope، باید true یا صحیح باشه میگن PreCondition یا پیش شرط. مثلا متدی که به عنوان پارامتر ورودی عدد صحیح یا int میگیره و بعد هنوز اجرا نشده یک if گذاشته و میگه عدد پاس داده شده بزرگتر از 10 بود اونوقت بدنه رو اجرا کن، وگرنه برو بیرون از متد. خب این یک PreCondition هست.

اصل Liskov میگه که یک SubType حق این رو نداره که پیش شرط ها رو سخت گیرانه تر کنه.

یعنی چی؟ یعنی بیاد توی همون مثال متدی که پارامتر ورودی عدد صحیح میگیره،، شرط دیگه هم کنار بزرگتر از ده، قرار بده و بگه and عدد صحیح زوج!. این تغییر جریان در PreCondition نباید برای هیچ متدی از BaseType اتفاق بیفته.

  • شرط پنجم : PostCondition

بر خلاف، PreCondition ، که شرط های قبل از اجرا شدن هستند، PostCondition ها بعد از اجرا حتما صحیح یا true هستند. یعنی چی؟ یعنی مثلا همون متد قبلی فرض کنید که درون بدنه ما همون اول نوشتیم که if پارامتر نال بود، برو به جاش بزار 0. این شرط یک شرط postCondition هست. یعنی این کد اجرا بشه، پارامتر ما دیگه نال نیست. 0 ئه! چون ما داخل متد 0 رو به جاش گذاشتیم.

حالا Liskov میگه که یک SubType حق نداره که PostCondition ها رو ضعیف کنه. یعنی چی؟ یعنی نباید پارامتر رو نال رها کنه. و postCondition رو ضعیف کنه.

  • و شرط ششم : Invarient

یعنی ثابت ها. اگر کلاس Base داره به طور ثابت یک سری مقدار دهی میکنه. یک سری عدد های ثابتی رو داره در کد اش قرار میده. Subtype هم باید از همون ثابت های BaseType پیروی کنه.


ارث بریsolidچندریختی
برنامه نویس
شاید از این پست‌ها خوشتان بیاید