یک مقدمه تئوری بر Polymorphism

اگه ازت بپرسن Polymorphism چیه، احتمالا یه چیزایی راجع به interface و inheritance بگی ولی اگه بپرسم اصلا چرا وجود داره چی؟ اینجا میخوام به این سوال جواب بدم.

یک مقدمه تئوری بر Polymorphism

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

توضیح پلی مورفیسم در یکی دو جمله تقریبا غیر ممکنه و بدتر شمارو گیج میکنه در نتیجه من بیشتر این رایت آپ رو به شکل مثال جلو بردم تا مفهوم برای شما جا بیفته، یعنی همین الان شما منو یقه کنی بپرسی پلی مورفیسم یعنی چی نمیتونم در یکی دو جمله جواب درستی بهت بدم.

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

الان یه مثال خیلی ساده پلی مورفیسم بنظرم اون پریز های برق خونتونه.

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

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

توی کد این شکلی میشه:

interface Device
    turnOn()

Fridge implements Device
    turnOn(): startCooling()

Laptop implements Device
    turnOn(): bootUp()

function plugIn(device: Device):
    device.turnOn()

فانکشن plugIn هیچوقت نمیپرسه "حاجی اینی که الان وصل کردی یخچاله یا لپتاپ؟" و اصلا براش مهمم نیست.

همین Polymorphism ـه: یه دستور واحد با رفتار های متفاوت.

و مزیتش اینه که فردا بخوای یه تلویزیون هم به برق وصل کنی دیگه لازم نیست دیوار خونه رو خراب کنی و تلویزیون رو میزنی به همون دو شاخه برقی که آلردی رو دیوار هست.

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

من اینجا حرفم اینه که Polymorphism چیزی فراتر از inheritance و interface و شی گراییه.

یعنی Polymorphism در واقع یک مکانیزمی فرای این مباحث هست.

تو این رایت آپ میخوایم بریم از صفر صفر و first principle بررسی کنیم که پلی مورفیسم یعنی چی، از یه دیزاین شروع میکنیم که اصلا هیچ Polymorphism ـی توش نیست، محدودیت هاشو به چشم میبینیم و کم کم خودمون Polymorphism رو به عنوان راه حل اون محدودیت ها در میاریم.

پس تقریبا برعکس بقیه رایت آپ ها عمل میکنم و اول خودم یک Problem ایجاد میکنم که شما قشنگ تا مغز اسخوونت درد رو حس کنی و بعد با استفاده از Polymorphism میرم اون Problem رو حل میکنم که کانسپت کاملا برات جا بیفته.

یه سیستم بدون Polymorphism

فرض کن قراره یه سرویس نوتیفیکیشن تحت وب بسازی.

اولش requirement ها ساده ان و هر وقت یه اتفاق مهم تو سیستم افتاد، میخوای از طریق این کانال ها به یوزر خبر بدی:

  • ایمیل
  • پیامک
  • پوش نوتیفیکیشن

یه پیاده سازی سرراست احتمالا همچین شکلی میشه:

function sendNotification(user, message, channel):

    if channel == "email":
        EmailService.send(
            user.email,
            message
        )

    else if channel == "sms":
        SmsService.send(
            user.phoneNumber,
            message
        )

    else if channel == "push":
        PushService.send(
            user.deviceId,
            message
        )

این کد کار میکنه و خوندنش هم راحته و اصلا به نظرم این رایج ترین چیزیه که یه مهندس نرم افزار موقع دیدن همچین مسئله ای اول از همه به ذهنش میرسه و اصلا خب زیاد هم بد نیست.

فانکشن sendNotification مسئوله که تصمیم بگیره هر نوتیفیکیشن چطوری باید تحویل داده بشه.

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

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

  • Slack
  • Microsoft Teams
  • WhatsApp
  • Discord

و ایجاد هر کانال جدید یعنی دستمالی کردن همون کد قبلی:

function sendNotification(user, message, channel):

    if channel == "email":
        ...

    else if channel == "sms":
        ...

    else if channel == "push":
        ...

    else if channel == "slack":
        ...

    else if channel == "teams":
        ...

    else if channel == "whatsapp":
        ...

    else if channel == "discord":
        ...

فانکشن مدام داره ورم میکنه :))))

اون چیزی که قراره نوتیفیکیشن بفرسته باید از تک تک کانال هایی که سیستم ساپورت میکنه خبر داشته باشه.

یعنی هر بار یه کانال جدید اضافه میشه، خود اون sender باید تغییر کنه.

یعنی الان این sender دیگه فقط مسئول فرستادن نوتیفیکیشن نیست مسئول دونستن تمام مکانیزم های تحویل هم هست.

اینجا من یه سوال ازت میپرسم:

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

مشکل اصلی، Dependency ـه!

الان شاید وسوسه بشی بگی مشکل همین زنجیره if / else ـه که داره هی درااز و درااااز تر میشه.

ولی به نظر من اون if ها اصلا مشکل نیستن.

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

مشکل اصلی اینه که sender اومده تمام دانش مکانیزم های زیرین سیستم رو گذاشته تو شکم خودش.

الان sender میدونه که:

  • ایمیل چطوری فرستاده میشه
  • پیامک چطوری فرستاده میشه
  • پوش نوتیفیکیشن چطوری فرستاده میشه
  • اسلک چطوری کار میکنه
  • و هر کانال دیگه ای که قراره در آینده اضافه بشه رو هم باید یاد بگیره و بتپونه کنار بقیه.

این همه اطلاعات برای یه فانکشنی که کل کارش "به این یارو خبر بده"ـس ، خیلی زیادیه!

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

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

جدا کردن "چی" از "چطور"

بیا یه لحظه بشینیم فکر کنیم sender واقعا چی میخواد.

بنظر من که اصلا برای sender مهم نیست که نوتیفیکیشن با ایمیل رفته، با پیامک رفته، با پوش رفته، با اسلک رفته یا با هر کوفت و زهرماری که سال دیگه اختراع میشه.

این sender فقط یه رفتار میخواد، اونم اینه که نوتیفیکیشن فرستاده بشه.

یعنی اصلا لازم نیست که sender چیزی راجع به SMTP سرور، SMS gateway، پروایدر های پوش نوتیفیکیشن، یا API اسلک بدونه.

اینا جزئیات پیاده سازی خود اون مکانیزم هاست و نباید دغدغه کسی/چیزی باشه که داره مصرفش میکنه.

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

  • پروتکل هاشون فرق داره.
  • با پروایدر های مختلف حرف میزنن.
  • کانفیگ های کاملا متفاوت میخوان

ولی از دید sender همشون دقیقا یه رفتار رو ارائه میدن.

همشون میتونن یه نوتیفیکیشن تحویل بدن و جزئیات پیاده سازیشون اصلا موضوعیت نداره.

همینکه جزئیات رو نادیده بگیری، یه پترن خودش رو بهت نشون میده:

send(notification)

خب اگه همه مکانیزم های تحویل دقیقا یه رفتار رو ارائه میدن، شاید دیزاین ما هم باید همینو منعکس کنه؟ 🤔

یعنی به جای اینکه ایمیل و پیامک و پوش و اسلک رو به عنوان چهار تا مفهوم جدا مدل کنیم، بیایم اون رفتار مشترک رو مدل کنیم.

ساختن یک Contract

تا اینجا یه رفتار مشترک بین همه مکانیزم های تحویل پیدا کردیم.

همشون میتونن نوتیفیکیشن بفرستن.

و چون این قابلیت برای دیزاین ما مهمه، بنظرم شایستگی اینو داره که صریحا و مستقیم تو کد نمایش داده بشه.

یکی از راه های مدل کردنش اینه که یه contract تعریف کنیم:

interface NotificationChannel

    send(notification)

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

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

پیاده سازی Contract

حالا که contract رو داریم، هر مکانیزم تحویل میتونه ورژن خودش از اون رفتار رو پیاده کنه:

EmailChannel implements NotificationChannel

    send(notification):
        sendEmail(notification)
SmsChannel implements NotificationChannel

    send(notification):
        sendSms(notification)
PushChannel implements NotificationChannel

    send(notification):
        sendPushNotification(notification)

با اینکه پیاده سازی این سه تا کاملا با هم فرق داره، ولی همشون نیاز های یک contract رو برآورده میکنن.

از بیرون، همشون یه رفتار واحد رو نشون میدن:

send(notification)

این contract فقط به اینکه اون رفتار وجود داشته باشه اهمیت میده و اینکه چطوری پیاده سازی شده اصلا به contract ربطی نداره.

و دقیقا به همین خاطره که دیگه sender هم دیگه لازم نیست نحوه پیاده سازی رو یاد بگیره!

اینجا Polymorphism ظاهر میشود

حالا اینجاس که sender میتونه به جای پیاده سازی های کانکریت، به contract وابسته بشه:

function sendNotification( channel: NotificationChannel, notification ):

    channel.send(notification)

میبینی چی عوض شد؟

الان sender دیگه هیچ ایده ای نداره که داره با ایمیل کار میکنه، با اس ام اس، با پوش، یا با هر کانالی که سال دیگه اضافه میشه و فقط به رفتاری که contract تعریف کرده وابسته ست.

تا وقتی یه مکانیزم تحویل اون contract رو برآورده کنه، دیگه لازم نیست حتی یه خط از sender عوض بشه.

به عبارت دیگه، sender میتونه از طریق یه انتزاع مشترک با پیاده سازی های مختلف تعامل کنه!

به همین توانایی میگن Polymorphism.

Polymorphism یک کانسپت ـه، نه یک کیوورد

میدونم اینجا خیلی وسوسه شدی که فکر کنی Polymorphism همون contract ـیه که ساختیم:

NotificationChannel

یا شاید همون پیاده سازی هایی که ازش پیروی میکنن:

  • EmailChannel
  • SmsChannel
  • PushChannel

ولی هیچکدوم اینا Polymorphism نیستن :)))))

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

این contract صرفا یکی از مکانیزم هاییه که این ایده رو ممکن میکنه.

در واقع Polymorphism رو با چند روش مختلف میشه به دست آورد:

  • Interface ها میتونن Polymorphism بدن
  • Inheritance میتونه Polymorphism بده
  • Generic ها میتونن Polymorphism بدن

ولی ایده زیربنایی همیشه یکیه، وابستگی کمتر به تایپ های کانکریت، و وابستگی بیشتر به انتزاع.

پس Polymorphism یه کانسپته، و تکنیک های مختلف برنامه نویسی که وجود دارن، صرفا ابزار رسیدن به Polymorphism هستن.

روش اول: Interface

اون مکانیزمی که تا الان تو این رایت آپ استفاده کردیم اسمش interface-based polymorphism ـه.

این همون contract که ساختیم:

interface NotificationChannel

    send(notification)

و این پیاده سازی های مختلف که از اون contract پیروی میکنن:

EmailChannel implements NotificationChannel

    send(notification):
        sendEmail(notification)
SmsChannel implements NotificationChannel

    send(notification):
        sendSms(notification)
PushChannel implements NotificationChannel

    send(notification):
        sendPushNotification(notification)

و sender هم فقط به interface وابسته ست:

function sendNotification( channel: NotificationChannel, notification ):

    channel.send(notification)

دقت کن که sender نمیدونه داره با کانال ایمیل کار میکنه یا اس ام اس یا push.

فقط میدونه داره با "یه چیزی که بلده نوتیفیکیشن بفرسته" کار میکنه.

بخوام ساده بگم، این ته ایده interface-based polymorphism ـه:

پیاده سازی های مختلف، یه رفتار مشترک، از طریق یه contract.

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

روش دوم: Inheritance

تو بخش قبل دیدیم interface ها اجازه میدن چند تا پیاده سازی یه رفتار مشترک رو از طریق یه contract ارائه بدن.

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

ولی interface ها فقط رفتار رو تعریف میکنن و هیچ پیاده سازی ای بهت نمیدن.

بیا یه سیستم شبکه رو در نظر بگیریم:

interface Connection
    connect()
    disconnect()
    send(data)

این اولش شبیه همون مثال نوتیفیکیشن به نظر میاد ولی یه تفاوت مهم اینجا هست و اونم اینه تایپ های کانکشن ممکنه مقدار خیلی زیادی از فانکشنالیتی رو با هم شریک باشن:

TcpConnection

    connect():
        validateAddress()
        allocateBuffer()
        openSocket()

    disconnect():
        releaseBuffer()
        closeSocket()
UdpConnection

    connect():
        validateAddress()
        allocateBuffer()
        openSocket()

    disconnect():
        releaseBuffer()
        closeSocket()
WebSocketConnection

    connect():
        validateAddress()
        allocateBuffer()
        openSocket()

    disconnect():
        releaseBuffer()
        closeSocket()

اینجا دیگه فقط بحث رفتار نیست و داریم پیاده سازی رو هم تکرار میکنیم. ( واضحه که این دو تا با هم فرق دارن )

هر تایپ کانکشن داره دقیقا همون setup و cleanup رو انجام میده.

قبلاinterface بهمون اجازه داد بگیم این تایپ ها رفتار مشترک دارن ولی هیچ مکانیزمی برای شریک شدن خودِ پیاده سازی بهمون نداد.

یعنی داریم عین کد رو داخل هر متد کپی پیست میکنیم!

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

Connection

    connect():
        validateAddress()
        allocateBuffer()
        openSocket()

    disconnect():
        releaseBuffer()
        closeSocket()

    send(data)

و بعد هر تایپ کانکشن اون پیاده سازی مشترک رو به ارث ببره:

TcpConnection extends Connection

    send(data):
        sendUsingTcp(data)
UdpConnection extends Connection

    send(data):
        sendUsingUdp(data)
WebSocketConnection extends Connection

    send(data):
        sendUsingWebSocket(data)

اینجا یه اتفاق جالب میفته.

خودِ Connection تبدیل میشه به همون انتزاع مشترک. یعنی کدی که مصرف کننده ست میتونه فقط Connection رو بشناسه:

function transmit( connection: Connection, data ):

    connection.connect()
    connection.send(data)
    connection.disconnect()

این کد نمیدونه داره با TCP کار میکنه یا UDP یا WebSocket. ولی موقع اجرا، هر آبجکتی که واقعا پاس داده شده، ورژن خودش از send رو اجرا میکنه!

به این میگن method overriding و اون لحظه ای که برنامه تصمیم میگیره کدوم ورژن send صدا زده بشه اسمش dynamic dispatch ـه.

پس دوباره همون اتفاق افتاد فقط با یه مکانیزم متفاوت تر و کد مصرف کننده به یه انتزاع وابستست و از پیاده سازی واقعی هیچی نمیدونه.

فرق inheritance با interface اینه که علاوه بر رفتار مشترک، پیاده سازی مشترک رو هم به ارث میده.

فقط یه هشدار بهت بدم و اونم اینه که طبق تجربم inheritance خیییلی راحت از کنترل خارج میشه و اگه تایپ هایchild فقط یه ذره کد مشترک دارن، دیگه inheritance انتخاب اشتباهیه و آخرش به یه درخت وراثت پنج طبقه میرسی که هیچکس جرئت نمیکنه دست بهش بزنه و عملا خیلی از legacy code ها همینجوری ایجاد میشن و اون موقع composition گزینه خیلی سالم تریه.

روش سوم: Generics

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

جنریک ها کاملا برعکسن و از یه پرسپکتیو دیگه ای به مسئله نگاه میکنن:

یه پیاده سازی، چند تا تایپ

بیا فرض کنیم تو همون سیستم نوتیفیکیشن، میخوایم یه صف داشته باشیم که آیتم ها رو نگه داره تا بعدا پردازش بشن.

بدون Generic ها معمولا این اتفاق میفته:

NotificationQueue

    enqueue(item: Notification)
    dequeue(): Notification

EmailQueue

    enqueue(item: Email)
    dequeue(): Email

EventQueue

    enqueue(item: Event)
    dequeue(): Event

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

پس ما یدونه پیاده سازی رو صرفا به خاطر تایپ داریم سه بار مینویسیم، که خب مسخرست :))))

با Generic ها میتونیم تایپ رو تبدیل به یه پارامتر کنیم:

Queue<T>

    enqueue(item: T)
    dequeue(): T

حالا یه پیاده سازی داریم که با هر تایپی کار میکنه:

Queue<Notification>
Queue<Email>
Queue<Event>

و حالا اون Queue هیچی راجع به Notification یا Email یا Event نمیدونه و اصلا نمیخواد هم بدونه و فقط میدونه یه چیزی میگیره و همون چیز رو پس میده.

اینم دوباره Polymorphism ـه چون یه تیکه کد داره با تایپ های مختلف کار میکنه بدون اینکه بهشون وابسته باشه.

اسم فنی ترش parametric polymorphism ـه.

فرقش با دوتای قبلی هم اینه:

  • تو interface و inheritance، رفتار جاییه که تنوع داره. یعنی هر پیاده سازی کار متفاوتی میکنه.
  • تو Generic ها، رفتار ثابته و تایپ جاییه که تنوع داره.

اون چیزی که بین همشون مشترکه

سه تا مکانیزم دیدیم که سه شکل کاملا متفاوت دارن:

  • Interface که رفتار مشترک رو تعریف میکنه
  • Inheritance که رفتار و پیاده سازی مشترک رو به ارث میده
  • Generic که تایپ رو تبدیل به پارامتر میکنه

ولی هر سه تاشون دقیقا یه کار رو میکنن!

یعنی هر سه تاشون دانش رو از کدی که مصرف کننده ست میگیرن و میبرن یه جای دیگه.

مثلا sendNotification قبلا میدونست ایمیل چطوری فرستاده میشه، الان نمیدونه یا transmit نمیدونه TCP چطوری کار میکنه یا Queue نمیدونه Notification چیه.

و همین ندونستنه که بهت آزادی عمل میده! چون وقتی یه تیکه کد چیزی نمیدونه، پس با تغییر اون چیز هم مجبور نیست عوض بشه.

پس اصل مشترک اینه:

کد باید به چیزی وابسته باشه که ثابت میمونه، نه به چیزی که تغییر میکنه.

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

جمع بندی

خلاصه اینکه:

  • Polymorphism یه فیچر زبان نیست، یه کانسپته
  • شی گرایی و پلی مورفیسم دو تا کانسپت جدا از هم هستن.
  • Interface و Inheritance و Generic ها فقط مکانیزم های رسیدن به پلی مورفیسم هستن.
  • ایده مرکزی اینه که کد به انتزاع وابسته باشه نه به تایپ های کانکریت.

و یه نکته آخر که به نظرم از همه اینا مهم تره:

استفاده از پلی مورفیسم یک سری هزینه های پنهان داره و شما هر بار که یه انتزاع به کد اضافه میکنی، یه لایه جدید اضافه کردی که یکی باید بخونتش و بفهمتش و یادت باشه اول همین رایت آپ گفتم اگه سیستم ما تا ابد فقط سه تا کانال داشت، همون if / else کاملا اوکی بود.

پس دنبال این نباش که همه جا بری زارت یدونه interface بکاری چون شاید اون انتزاع فقط داره پیچیدگی اضافه میکنه بدون اینکه نتیجه مثبتی بهت بده.

بهترین کد اونیه که به اندازه ی نیازش انعطاف داره و نه بیشتر.