وراثت کلاس و ترکیب بندی در زبان برنامه نویسی سوئیفت — به زبان ساده

۲۳۳ بازدید
آخرین به‌روزرسانی: ۰۹ مهر ۱۴۰۲
زمان مطالعه: ۷ دقیقه
وراثت کلاس و ترکیب بندی در زبان برنامه نویسی سوئیفت — به زبان ساده

یک سلسله‌مراتب با طراحی ضعیف می‌تواند مانند برج تصویر ابتدایی این نوشته ناپایدار باشد. طراحی‌های غیر منعطف ممکن است نتوانند پذیرای موجودیت‌ها یا خصوصیت‌های جدید باشد و این وضعیت منجر به کپی کردن و چسباندن حجم بالایی از کد می‌شود. همچنین در چنین وضعیتی، موجودیت‌هایی که در سطوح پایین سلسله‌مراتب قرار می‌گیرند، ممکن است رفتارهای غیرمنتظره‌ای نشان دهند یا پیاده‌سازی‌های ناخواسته‌ای صورت گیرد و مقادیر بالای overriding می‌تواند منجر به ناوبری دینامیک ضعیف شود. به طور خلاصه طراحی صحیح امری با اهمیت بالا محسوب می‌شود. دو رویکرد رایج برای طراحی کردن سلسله‌مراتب مناسب وجود دارد که شامل «وراثت کلاس» (Class Inheritance) و «ترکیب‌بندی» (Composition) می‌شود. شاید از درک این نکته که یکی از آن‌ها ارجحیت زیادی نسبت به دیگری دارد شگفت‌زده شوید. در این مقاله یک سلسله‌مراتب ساده را با استفاده از هر دو این رویکردها طراحی می‌کنیم تا مزیت‌ها و نواقص هر یک را بررسی کنیم.

رویکرد 1: وراثت کلاس

وراثت کلاس رویکردی کلاسیک برای طراحی کردن یک سلسله‌مراتب موجودیت در برنامه‌نویسی شیءگرا محسوب می‌شود و حتی افراد مبتدی در Swift نیز با مفاهیم این بخش آشنا هستند. به عنوان یک مرور کوتاه باید اشاره کنیم که وراثت یک رفتار بنیادی است که در کلاس‌ها نهادینه شده؛ اما در stuct ها وجود ندارد.

ما با توجه به مقاصد این مقاله قصد داریم کدی بسازیم که بازنمایی سلسله‌مراتبی آن به شکل زیر باشد:

کد آن به صورت زیر است:

class Animal {
    
    func consumeFood() {
        print("eating food")
    }
    
}

class Bird: Animal {
    
    func layEgg() {
        print("laying an egg")
    }
    
}

final class Puffin: Bird { }

final class HummingBird: Bird { }

final class Penguin: Bird { }

final class Ostrich: Bird { }

دقت کنید که Bird از Animal ارث می‌برد (خط 9) و سپس هر یک از چهار گونه پرنده از Bird ارث‌بری می‌کنند (خطوط 17، 19، 21 و 23). همچنین آن‌ها را طوری با کلیدواژه final طراحی کرده‌ایم که مطمئن باشیم کامپایلر بداند اجازه زیرکلاسسازی بیشتر ندارد و این مسئله به نوبه خود موجب بهبود ناوبری دینامیک می‌شود. متدهایی که در animal و Bird تعریف شده‌اند، اینک در چهار زیرکلاس Bird وجود دارند.

تا به این جا همه چیز درست به نظر می‌رسد و دلیل این مسئله آن است که تنها متدهایی که تا به این جا اضافه کرده‌ایم به درستی در سلسله‌مراتب جای گرفته‌اند. در نهایت هر نوع از پرنده غذا می‌خورد و تخم می‌گذارد. اما اگر بخواهیم متدهایی برای شنا کردن، (Swim) راه رفتن (Walk) و پرواز کردن (Fly) اضافه کنیم چطور؟ اگر بخواهیم این قابلیت‌ها را با توجه به هر یک از این چهار زیرکلاس Bird اضافه کنیم، به جدولی مانند زیر می‌رسیم:

در این بخش هیچ یک از قابلیت‌ها را نمی‌توان در سوپر کلاس Bird قرار داد چون هیچ کدام از آن‌ها در مورد هر چهار زیرکلاس پرنده‌ها مصداق ندارند. در عوض می‌توان ترکیب‌بندی آن‌ها را تغییر داد. برای این که این قابلیت‌ها صرفاً در موارد نیاز وجود داشته باشند، باید آن‌ها را به صورت زیر تعریف کنیم:

final class Puffin: Bird {

    func fly() {
        print("flying")
    }

    func swim() {
        print("swimming")
    }

    func walk() {
        print("walking")
    }

}

final class HummingBird: Bird {

    func fly() {
        print("flying")
    }

}

final class Penguin: Bird {

    func swim() {
        print("swimming")
    }

    func walk() {
        print("walking")
    }
}

final class Ostrich: Bird {

    func walk() {
        print("walking")
    }

}

حتی با یک نیم‌نگاه می‌توان گفت که این راه‌حل به چند دلیل نامناسب است. نخست این که کد تکراری زیادی در آن وجود دارد و پیاده‌سازی متد در موارد متعدد در کلاس‌های مختلف کپی شده است. صرفاً تصور کنید که بخواهیم تغییری در یکی از این متدها ایجاد کنیم، در این صورت باید این تغییر را در محل‌های زیادی اعمال کنیم.

دلیل دیگر این است که مفهوم «مسئولیت منفرد» نقض می‌شود. در مورد پافین (Puffin) هر سه متد دریک کلاس منفرد پیاده‌سازی شده‌اند. برای این که قابلیت نگهداری کد بالا برود باید، این مسئولیت‌های مختلف را در موجودیت‌های متفاوت کپسوله‌سازی کنیم.

ممکن است استدلال کنید که مقدار کپی کردن کد با ایجاد زیرکلاس‌ها به روشی خلاقانه می‌تواند کاهش یابد، اما در هر صورت ناگزیر هستیم با مسئله تداخل وراثت مواجه شویم که در موارد معرفی متدهای جدید یا در مواردی که کلاس‌های جدید صرفاً نیازمند برخی مدل‌های ارث‌بری شده هستند، مشکلات دیگری را ایجاد می‌کند. بدین ترتیب با یک سلسله‌مراتب شکننده مواجه هستیم که یک تغییر کوچک در یک سوپر کلاس می‌تواند نتایج غیرمنتظره‌ای در زنجیره سلسله‌مراتب ایجاد کند. همه این‌ها به کنار، زمانی که به نمودار سلسله مراتبی آغازین نگاه می‌کنیم، به خاطرمی آوریم که وضعیت ایده‌آل ما این بود که همه زیرکلاس‌های پرنده در سطح یکسان باشند که در این رویکرد امکان‌پذیر نیست.

بنابراین باید پرسید چرا رویکرد وراثت کلاس، نتیجه دلخواه را به ما نمی‌دهد؟ یک روش مفید برای جمع‌بندی این بحث آن است که به جای روابط «است» رابطه‌های «دارد» را بررسی کنیم. وراثت کلاس محدود به نمایش روابط «است» محسوب می‌شود و برای توصیف روابطی مانند «پافین یک (پرنده) است» و «پرنده یک (حیوان) است» مناسب است. با این وجود وقتی تلاش می‌کنیم یک رابطه «دارد» را تعریف کنیم با نواقصی مواجه می‌شویم. در این چارچوب، می‌توانیم تصور کنیم که رابطه «داشتن» می‌تواند دارا بودن یک خصوصیت (مانند داشتن منقار) یا متد (مانند داشتن قابلیت پرواز) را تعریف کند. دیدیم که توانایی‌های مورد نیاز ما یعنی ()run و ()walk و ()swim روی همه زیرکلاس‌های پرندگان به صورت یکنواختی اعمال نمی‌شوند و این امر نامعمولی نیست. هر چه دنیا را به جای «است» بیشتر به صورت «دارد» ببینید، بیشتر متوجه می‌شوید که دنیای طبیعی شامل مفاهیم چندان ساده‌ای نیست.

رویکرد 2: ترکیب‌بندی از طریق پروتکل‌ها

در این بخش می‌خواهیم مسئله فوق را با رویکرد ترکیب‌بندی بررسی کنیم. این رویکرد در جمله زیر خلاصه شده است:

تلاش کنید برنامه‌ریزی شما برای یک رابط باشد و نه یک پیاده‌سازی.

در iOS ما رابط‌ها (interfaces) را از طریق پروتکل‌ها تعریف می‌کنیم. هر موجودیت که یک پروتکل دارد باید به طور قراردادی با رابط مورد نیاز سازگار باشد. این وضعیت با جمله فوق مطابقت دارد. اینک تنظیمات ابتدایی ما به صورت زیر خواهد بود:

protocol Animal {
    func consumeFood()
}

protocol Bird: Animal {
    func layEgg()
}

struct Puffin: Bird {
    
    func layEgg() {
        print("Puffin laying an egg")
    }
    
    func consumeFood() {
        print("Puffin consuming fish")
    }
    
}

struct HummingBird: Bird {
    
    func layEgg() {
        print("Hummingbird laying an egg")
    }
    
    func consumeFood() {
        print("Hummingbird, consuming nectar")
    }
    
}

struct Penguin: Bird {
    
    func layEgg() {
        print("Penguin laying an egg")
    }
    
    func consumeFood() {
        print("Penguin consuming fish")

    }  
    
}

struct Ostrich: Bird {
    
    func layEgg() {
        print("Ostrich laying an egg")
    }
    
    func consumeFood() {
        print("Ostrich consuming roots and seeds")
    }
    
}

توجه کنید که ما دو پروتکل Animal و Bird را داریم که مورد دوم از اولی ارث‌بری می‌کند (خط 5). سپس چهار موجودیت داریم که هر یک از آن‌ها از پروتکل Bird استفاده کرده و با آن مطابقت دارند. اینک هر خصوصیتی که در Animal و Bird وجود داشته باشند، باید در struct های ما نیز پیاده‌سازی شوند.

در ترکیب‌بندی، ایجاد پروتکل‌هایی برای رفتارهای منفرد و نه صرفاً برای موجودیت‌ها، امری رایج محسوب می‌شود. برای نمونه ما در پروتکل Bird رفتار ()layEgg را داریم. وقتی در این مورد تامی می‌کنیم، به سرعت ببینیم که حیوان‌های دیگر نیز تخم می‌گذارند. این رفتار می‌تواند نخستین پیشنهاد ما برای پروتکل خاص باشد که به صورت زیر نوشته می‌شود:

protocol Animal {
    func consumeFood()
}

protocol EggLayable {
    func layEgg()
}

protocol Bird: Animal, EggLayable { }

دقت کنید که متد ()layEgg که در Bird داریم، اینک به یک پروتکل جدید به نام EggLayable (خطوط 5 تا 7) انتقال یافته است. برخلاف روش وراثت کلاس که تنها امکان ارث‌بری یک کلاس از سوپر کلاس منفرد را می‌دهد، می‌بینیم که پروتکل‌ها، امکان ارث‌بری چندگانه را می‌دهند. در مثال ما Bird می‌تواند از Bird و همچنین EggLayable (خط 9) ارث‌بری بکند. این یکی از قدرتمندترین ویژگی‌های ترکیب‌بندی است که با استفاده از آن می‌توان خصوصیتی را که می‌خواهید به موجودیت بدهید، انتخاب کنید. دقت کنید که بسته‌بندی مجدد پروتکل‌ها در ترکیب‌بندی‌های مختلف می‌تواند روش مناسبی برای جلوگیری از گزینه‌های اختیاری در پروتکل‌های شلوغ باشد.

اینک می‌توانیم متدهای ()walk() ,swim و ()fly را به سلسله‌مراتب خود اضافه کنیم. از آنجا که می‌خواهیم این رفتارها برای حیوانات دیگر که پرنده نیستند نیز وجود داشته باشند، باید مانند کاری که در EggLayable کردیم، برای آن‌ها نیز پروتکل‌های خاصی بسازیم. با این وجود، این بار متدها را تنها یک بار پیاده‌سازی می‌کنیم و آن پیاده‌سازی منفرد را میان همه موجودیت‌های پرنده خود به اشتراک می‌گذاریم. برای انجام این کار باید از بسط‌های پروتکل استفاده کنیم.

protocol Flyable { }

extension Flyable {
    
    func fly() {
        print("flying")
    }
    
}

protocol Swimmable { }

extension Swimmable {
    
    func swim() {
        print("swimming")
    }
    
}

protocol Walkable {}

extension Walkable {
    
    func walk() {
        print("walking")
    }
    
}

struct Puffin: Bird, Walkable, Flyable, Swimmable {
  
    func layEgg() {
        print("Puffin laying an egg")
    }

    func consumeFood() {
        print("Puffin consuming fish")
    }

}

struct HummingBird: Bird, Flyable {

    func layEgg() {
        print("Hummingbird laying an egg")
    }

    func consumeFood() {
        print("Hummingbird, consuming nectar")
    }

}

struct Penguin: Bird, Walkable, Swimmable {

    func layEgg() {
        print("Penguin laying an egg")
    }

    func consumeFood() {
        print("Penguin consuming fish")

    }

}

struct Ostrich: Bird, Walkable {

    func layEgg() {
        print("Ostrich laying an egg")
    }

    func consumeFood() {
        print("Ostrich consuming roots and seeds")
    }

}

سه پروتکل جدید در ابتدای فایل اعلان شده‌اند و متدهای آن‌ها در بسط‌ها به صورت پیاده‌سازی‌های پیش‌فرض جای گرفته‌اند. هر یک از این چهار struct اینک پروتکل‌های Walkable, Swimmable و Flyable مورد نیاز خود را دارند و می‌بینید که به هیچ پیاده‌سازی در خود struct نیاز نداریم.

اگر بخواهیم یک استثنا در پیاده‌سازی پیش‌فرض در بسط پروتکل ایجاد کنیم، می‌توانیم آن را به صورت زیر بنویسیم:

struct Penguin: Bird, Walkable, Swimmable {

    func layEgg() {
        print("Penguin laying an egg")
    }

    func consumeFood() {
        print("Penguin consuming fish")

    }
    
    func swim() {
        print("Penguin swimming")
    }

}

اکنون متد ()swim در موجودیت (خط 12) دیده می‌شود که به این معنی است که پیاده‌سازی پیش‌فرض در بسط پروتکل Swimmable از سوی این موجودیت هرگز مورد دسترسی قرار نمی‌گیرد.

سخن پایانی

در این نوشته دیدیم که در طراحی سلسله‌مراتب پایدار و انعطاف‌پذیر، «ترکیب‌بندی» (composition) بر «وراثت کلاس» (class inheritance) مزیت دارد. پروتکل‌های ما می‌توانند از وراثت‌های چندگانه برای کسب هر آن چه که نیاز دارند استفاده کنند و موجودیت‌های ما می‌توانند به طور مشابه از چندین پروتکل استفاده کرده و با آن‌ها سازگار باشند. به سادگی می‌توانیم پیاده‌سازی پیش‌فرض متدها را ارائه کرده و رفتار خاص یک موجودیت منفرد را در هر مورد که نیاز است، ایجاد کنیم. اینک مثال ما در مورد سلسله‌مراتب موجودیت نه تنها برای هر پرنده دیگر که بخواهیم ایجاد کنیم، بلکه برای هر شیئی که بخواهیم تخم بگذارد، شنا کند، پرواز کند یا راه برود نیز قابل استفاده هستند.

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

اگر این مطب برایتان مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

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

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *