مفاهیم بنیادین تایپ سیستم ها

فهرست مطالب

مقدمه

هر برنامه‌نویسی، اسم «تایپ» ها به گوشش خورده است و مسلما در طور دوران کاری‌اش به کررات با آن‌ها مواجه شده است. این نوشته به سه دلیل نگارش شده است:

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

صحبت درباره تایپ سیستم‌ها نیازمند آشنایی با «تئوری تایپ» است. تئوری تایپ بیشتر از اینکه یک مبحث کامپیوتری باشد، شاخه‌ای از علوم ریاضیات و منطق است و دنیای گسترده‌ای برای خودش دارد؛ و به طبع برای مطالعه و آشنایی عمیق با آن نیاز دارید که به همان اندازه در ریاضیات و منتطق هم متخصص باشید. اکثر برنامه‌نویسان، علم بالایی نسبت به مباحث تئوری تایپ ندارند؛ در واقع لازم نیست که داشته باشن. مباحث مربوط به تئوری تایپ نه در حیطه‌ی کاری عموم برنامه نویسان است، و نه حتی در حیطه‌ی کاری سازندگان زبان‌های برنامه‌نویسی. تئوری تایپ در زمره‌ی چیزهایی هست که «محققین زبان‌های برنامه‌نویسی» با آن سر و کار دارند. اما بد نیست که در حد یک مطالعه‌ی سطح بالا با این مباحث آشنا باشید…

محتوایی که در زیر آمده، مانند خراشِ ناخن است روی یک کوه عظیم یخی؛ که همان تئوری تایپ‌ها باشد! برگردان این مفاهیم به زبان فارسی کاری حقیقتا دشوار است. مخصوصا اینکه تصمیم‌ام بر این بود که سطح نوشته را در حالتی نگه دارم که برای برنامه‌نویسان متوسط نیز قابل درک باشد.

همچنین دقت کنید که مفاهیم موجود در این نوشته، هر کدام می‌توانند به چندین روش و از چندین زاویه مختلف توضیح داده شوند. توضیحاتی که شما در این نوشته درباره این مفاهیم می خوانید، فقط زاویه دید «من» را منعکس می‌کنند؛ و بر این باور هستم که این مطالب به نسبت خیلی از منابع دیگر شفاف تر است.

کدهایی که در این نوشته خواهید دید همگی «شِبهِ کد» هستند.

متن زیر معادل چیزی بیشتر از ۲۵ صفحه‌ی چاپی است و نگارش آن یک هفته به طول انجامید؛ از آن مهم‌تر یادگیری و هضم این مفاهیم، به گونه‌ای که بتوانم آن را به دیگران نیز آموزش دهم سال‌ها زمان برده است! درخواست من از خوانندگان این است که اگر جایی از مطالب این نوشته استفاده کردند، حداقل لینک منبع را نیز لحالظ نمایند.

تعاریف

تایپ (Type)

خصوصیتی است که تعیین می‌کند یک «داده»، می‌تواند شامل چه «محتوا» ای باشد و چه کارهایی می‌توان با آن انجام داد.

فرضا اگر می‌گوییم متغیر A از تایپ int است، یعنی پاسخ این دو سوال را در دست داریم:

  • متغیر A شامل چه محتوایی خواهد بود؟ فقط اعداد صحیح.
  • چه کارهایی می‌توان با متغیر A انجام داد؟ مثلا می‌توان آن را با یک عدد صحیح دیگر جمع بست. یا مثلا نمی‌توان آن را با یک مقدار رشته‌ای ضرب کرد!

زبان‌های برنامه نویسی برای راحتی کار برنامه نویسان، غالبا تعدادی تایپ پیشفرض به همراه خود دارند. مانند تایپ int برای اعداد صحیح، یا تایپ string برای رشته‌ها؛ اما زبان‌ها امکانات مختلفی را فراهم کرده‌اند تا برنامه‌نویسان بتوانند خودشان نیز تایپ‌های جدیدی ایجاد کنند. فرضا وقتی در کدهای‌تان یک struct یا class جدید طراحی می‌کنید، در واقع یک تایپ جدید را به زبان معرفی کرده‌اید.

تایپ سیستم (Type System)

نظام ایست که با اتکا به «تایپ»ها، قوانین حاکم بر یک زبان برنامه‌نویسی را رقم می‌زند.

از آنجایی که تایپ‌ها تعیین کننده‌ی خصوصیات داده‌ها هستند، تایپ سیستم می‌تواند به کمک آن بقیه اجزای زبان مانند شرط‌ها، حلقه‌ها، توابع، کلاس‌ها، ماژول‌ها و … را تحت پوشش قرار دهد؛ چرا که تمام اجزای زبان با داده‌ها در ارتباط هستند. اگر زبان برنامه‌نویسی را مانند یک «کشور» تصور کنیم، تایپ سیستم حکم «قانون اساسی» را دارد. تایپ سیستم توسط سازندگان یک زبان برنامه‌نویسی و با توجه به اهداف آن زبان طراحی می‌گردد.

تایپ چکینگ (Typechecking)

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

تایپ چِکِر (Type checker)

یک ابزار نرم‌افزاری، که مسئولیت اجرای فرآیند تایپ چکینگ را بر عهده دارد. ابزار تایپ چکر به شکل‌های مختلفی در روند توسعه‌ی نرم‌افزار شرکت می‌کند. در خیلی از زبان‌های برنامه‌نویسی، تایپ چکر قسمتی از کامپایلر است؛ و در خیلی از زبان‌های دیگر، تایپ چکر جزئی از مفسر زبان است. حتی در تعدادی از زبان‌ها، تایپ چکر یک برنامه‌ی کاملا مستقل است که می‌توانید آن را به تنهایی در ترمینال یا IDE فراخانی کنید.

استاتیک تایپ (Static Type)

به زبانی «استاتیک تایپ» می‌گوییم که در آن فرآیند تایپ چکینگ «قبل از اجرای برنامه» اتفاق بیفتد.

زبان‌های استاتیک تایپ، غالبا کامپایلری هستند و فرآیند تایپ چکینگ را در هنگام عملیات کامپایل و قبل از شروع برنامه اعمال می‌کنند؛ سی و سی‌پلاس‌پلاس، سی‌شارپ، جاوا، گولنگ، اسکالا، راست، سوییفت، کاتلین و … جزو زبان‌های استاتیک تایپ هستند.

در یک زبان استاتیک، وقتی تایپ‌ای را به یک متغیر نسبت می‌دهید، دیگر نمی‌توانید تایپ آن متغیر را عوض نمایید.

داینامیک تایپ (Dynamic Type)

به زبانی «داینامیک تایپ» می‌گوییم که در آن فرآیند تایپ چکینگ «در زمان اجرای برنامه» اتفاق بیفتد.

زبان‌های داینامیک تایپ، غالبا تفسیری هستند و فرآیند تایپ چکینگ را در هنگام تفسیر و اجرای کدها اعمال خواهند کرد؛ پایتون، روبی، پی‌اچ‌پی، جاوا اسکریپت، لوآ، ارلنگ و الیکسیر، کلوژور و خانواده‌ لیسپ، و … جزو زبان‌های داینامیک تایپ هستند.

در یک زبان داینامیک، تایپ متغیرها می تواند در زمان اجرای برنامه تغییر کند.

استاتیک تایپینگ داوطلبانه (Optional Static Typing)

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

در این زبان‌ها اصولا تایپ چکر به صورت یک برنامه‌ی مستقل حضور پیدا میکند و می‌تواند کدها را به صورت استاتیک آنالیز نماید تا کدها قبل از اجرای برنامه چک شوند. برای ارلنگ و الیکسیر ابزار Dialyzer، برای پایتون ابزار mypy، و برای جاوا اسکریپت هم یک زبان جانبی مانند TypeScript در دسترس است.

آیا تمام زبان‌های کامپایلری، استاتیک تایپ هستند؟

خیر. مثلا ارلنگ، الیکسیر، یا کلوژور همگی زبان‌های کامپایلری هستند، ولی تمام آن‌ها داینامیک تایپ می‌باشند. کامپایلری بودن یا نبودن زبان، ربطی به استاتیک یا داینامیک بودنش ندارد.

کاوشگرِ تایپ (Type inference)

فرآیندی که در آن تایپ یک داده مشخص می‌گردد.

هرچند که از عبارت «تایپ اینفِرِنس» معمولا موقعی استفاده می‌گردد که مکانیزم شناسایی تایپ، به طور اتوماتیک و بدون نیاز به نوشتن نام تایپ‌ها در کدها انجام پذیرد. مثلا در زبانی مانند جاوا وقتی قرار باشد یک متغیر از تایپ عددی تعریف کنید باید چنین کدی بنویسید:

int a = 10;

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

a = 10;

مکانیزم تایپ اینفرنس از روی مقداری که به a نسبت داده اید، می‌توانست به طور خودکار متوجه شود که این متغیر از تایپ int است.

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

تایپ نگاریِ صریح (Explicitly Typed)

هنگامی که به طور صریح، نام تایپ‌ها را در کدهایمان ذکر می‌کنیم؛ هنگامی که نام تایپ‌ها، جزیی از سینتکس زبان است.

تایپ نگاریِ ضمنی (Implicitly Typed)

هنگامی که لازم نیست نام تایپ‌ها را در کدهایمان ذکر کنیم (با توسل به تایپ اینفرنس)

آیا استاتیک بودن یک زبان، به معنی نوشتن تایپ در کنار متغیر است؟

خیر. استاتیک بودن یک زبان هیچ ربطی به نوشتن یا ننوشتن نام تایپ ها در کد ندارد. همانطور که در قسمت بالا اشاره کردیم، زبان‌هایی هستند که در آن‌ها لازم نیست نام تایپ‌ها را به شکل صریح در کدهایمان ذکر کنیم.

زبان با تایپ مستحکم (Strong Type)

جدای از این قضیه که زبان استاتیک تایپ باشد یا داینامیک تایپ، اگر زبانی سیستم تایپ را با جدیت کامل اعمال کند، می‌گوییم که آن زبان دارای تایپ مستحکم یا استرانگ تایپ است. مثلا زبان نباید اجازه دهد داده‌ی عددی 12 با داده‌ی رشته‌ای “hi” جمع و تفریق شود؛ چرا که چنین چیزی از نظر منطقی بی معناست و مشخص نیست که نتیجه اش چه خواهد شد. زبان‌هایی مانند جاوا و سی شارپ و پایتون و روبی و … همگی استرانگ تایپ هستند.

زبان با تایپ سُست (Weak Type)

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

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

تایپ سیستم دقیق (Sound type system)

بله، واژه Sound در انگلیسی که همگی آن را با معنی «صدا» می‌شناسیم، دارای معانی دیگری مانند دقیق، راسخ، استوار، یا سالم هم هست!

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

البته ثابت شده است که وجود چنین تایپ سیستم ای امکان پذیر نیست؛ فرضا وقتی در زمان اجرای برنامه برق رفت، تایپ سیستم (هرچقدر هم که قدرتمند باشد) چه کاری از دستش بر میآید؟ بنابراین وقتی می‌گوییم یک زبان دارای ساند تایپ سیستم است، در واقع منظورمان این است که آن زبان در یک سری از موارد مشخص، به ما گارانتی صددصد می دهد (نه در تمام موارد؛ چون امکان پذیر نیست). اکثر زبان‌های استاتیک تاپ امروزی، دارای ساند تایپ سیستم هستند.

تایپ سیستم مبتنی بر نام (Nominal type system)

گروهی از تایپ سیستم‌ها هستند که هماهنگی و برابر بودن تایپ‌ها را بر حسب اعلان‌های تایپ اعتبار سنجی می‌کنند.

این تایپ سیستم‌ها از روی اعلانات (اسمِ) تایپ‌ها، می‌توانند تشخیص دهند که آیا تایپ ها با یکدیگر هماهنگی دارند یا خیر؛ و یا اینکه رابطه‌ی تایپ‌ها با یکدیگر چگونه است (مثلا رابطه‌ی وراثت). سی‌پلاس‌پلاس، جاوا، سی‌شارپ، سوییفت، راست، و …. دارای همچین تایپ سیستمی هستند.

مقایسه اجمالی بین زبان‌های استاتیک و داینامیک

سرعت:

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

مستندات:

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

اطمینان از صحت کدها:

  • استاتیک: تایپ چکر قبل از اجرای برنامه، کدها را از نظر وجود ناهماهنگی ها ارزیابی می‌کند و می تواند تعداد بسیاری از مشکلات را قبل از اجرای برنامه شناسایی نماید. تایپ چکر حکم «دستیار» برنامه‌نویس را دارد!
  • داینامیک: این زبان‌ها معتقداند که هیچ تایپ سیستم‌ای نمی تواند ۱۰۰٪ گارانتی کند که کدهای‌مان بدون مشکل اجرا خواهند شد؛ و تنها راه حل موجود «تست» کردن کدهاست. پس با تاکید بر نگارش تست‌ها، این زبان‌ها عنوان می کنند که وجود تایپ چکر برای آن‌ها یک الزام نیست.

ابزارهای جانبی:

  • استاتیک: ابزارهای جانبی می‌توانند کدهای استاتیک را آنالیز نمایند و اطلاعات زیادی بدست آورند؛ آن‌ها به کمک این اطلاعات قادر خواهند بود خدمات زیادی به برنامه‌نویسان ارائه کنند. مثلا IDE ها با آنالیز کدها، هنگامی که برنامه‌نویس مشغول نگارش است مدام او را در امر نوشتن راهنمایی می کنند و گزینه‌های مختلف را به او نشان می‌دهند. وجود چنین ابزارهایی زبان را برای استفاده در تیم‌های بزرگ آماده می‌کند.
  • داینامیک: این یکی از این مواردی است که زبان‌های داینامیک جواب مناسبی برایش ندارند. اما نگرش توسعه در زبان‌های داینامیک به شکلی است که شاید وجود این ابزارها را کمتر حس کنند. زبان‌های داینامیک بر این باور هستند که یک نرم افزار بهتر است توسط تیم‌های کوچکِ چند نفری و در ارتباطی نزدیک با یکدیگر ساخته شود. یعنی از اساس با اینکه نرم افزار توسط تیم‌های بزرگ ساخته شود موافق نیستند. می توان هر نرم افزار بزرگ را جوری تقسیم کرد که قسمت‌های مختلف آن توسط تیم‌های کوچک ساخته شود.

پُلی مورفیسم - چندریختی (Polymorphism)

این عبارت طولانی و کمی ترسناک است! و در اغلب متون فارسی (و حتی انگلیسی) موقع توضیح دادن آن بسیار شلوغ کاری شده است؛ در حالی که مفهوم پلی مورفیسم بسیار ساده است:

«بکارگیری تایپ‌های مختلف، با روشی یکسان»

مثال: عملگر جمع + را در نظر بگیرید. این عملگر می‌تواند دو متغیر از نوع int را باهم جمع کند. دو متغیر از نوع float را با هم جمع کند. و حتی در بعضی زبان ها می‌تواند دو متغیر string را نیز با هم جمع کند (به هم بچسباند).

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

یک باور نادرست درباره پلی مورفیسم وجود دارد و آن این است که پلی مورفیسم فقط مختص زبان‌های شی‌گراست؛ این باور غلط مخصوصا در منابعی که زبان جاوا را آموزش می دهند بسیار شایع است؛ حتی کتب بسیار معتبر! دلیل‌اش این است که آن‌ها در حیطه‌ی خاصی سخن می‌گویند، اما خواننده ممکن است اشتباها برداشتی که می کند را برای تمامی زبان‌ها بسط دهد. در حالی که این باور ابدا درست نیست و مثلا حتی زبان‌های فانکشنال نیز پلی مورفیسم را ارايه می کنند. کلا پلی مورفیسم به شی‌گرا بودن یا نبود زبان ربطی ندارد!

شما نیز به عنوان برنامه‌نویس می‌توانید پلی مورفیسم را به کدهای خود اضافه کنید. برای این منظور، زبان‌های مختلف راه حل‌های گوناگونی را ارائه کرده اند:

  • تک منظوره (Ad hoc polymorphism) : که همان سربارگذاری توابع (Function overloading) می‌باشد.

  • مبتنی بر رابطه‌ی تایپ‌ها (Subtyping): ایجاد رابطه بین تایپ‌ها به اشکال گوناگونی صورت می‌گیرد. دو روش آشناتر، وراثت (Inheritance) و اینترفیس (Interface) ها هستند.

  • مبتنی بر پارامتر (Parametric polymorphism) : که همان «جِنِریک‌»ها (Generics) می‌باشند.

در ادامه یکی یکی این مفاهیم را شرح خواهیم داد…

اما ابتدا این نکته‌ی را به خاطر بسپارید : اینکه چه کاری می‌توان با یک تایپ انجام داد، در قالب توابعی که وابسته به آن تایپ هستند بیان می‌گردد:

  • یا یک تایپ خودش دارای توابع داخلی است (متدها).
  • یا توابعی خارج از تایپ وجود دارند که مخصوص کار با آن تایپ ساخته شده اند. (مثل تابع len در بعضی زبان‌ها که مخصوص تایپ آرایه است)

پلی مورفیسم تک منظوره (Ad hoc polymorphism)

راه حلی برای ارائه پلی مورفیسم است که بر مفهوم «سربارگذاری توابع» بنا شده است.

سربارگذاری تابع چیست؟ (Function overloading)

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

مثلا تابع ای داشته باشیم به اسم add که دو ورودی می‌گیرد. اگر این دو ورودی عدد صحیح بودند، آن‌ها را باهم جمع کند و حاصل جمع را برگرداند. ولی اگر ورودی‌ها رشته باشند، آن را به بهم بچسباند و حاصل را بگرداند:

function add(int a, int b) ⟹ int {
    return a + b;
}

function add(string a, string b) ⟹ string {
    return join(a, b);
}

add(10, 20);     ---> 30
add("h", "i");    ---> "hi"

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

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

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

پلی مورفیسم مبتنی بر رابطه‌ی تایپ‌ها (Subtyping)

زبان‌ها هر کدام راه حل‌های مختلفی برای ایجاد رابطه‌های گوناگون در بین تایپ‌ها ارائه می کنند. دو شیوه‌ای که بیشتر اسم‌شان شنیده می‌شود «وراثت» و «اینترفیس»ها می‌باشند. در ادامه هر کدام را توضیح خواهیم داد؛ همچنین مزایا و معایب و فرق‌ها و تشابهات آن را نیز خواهیم گفت.

وراثت (Inheritance)

راه حلی است برای ارائه پلی مورفیسم و «استفاده مجدد از کدها».

در خیلی از متون (مخصوصا متون فارسی)، برای توضیح دادن وراثت در برنامه نویسی، آن را با وراثت در دنیای واقعی مقایسه می‌کنند؛ مثلا می‌گویند وراثت مانند این است که یک فرزند، چشم و ابرو و اخلاق والدین اش را به ارث ببرد! یعنی کلاسی به عنوان «کلاس فرزند» داریم که می تواند خصوصیاتی از «کلاس والد» را به ارث ببرد.

توضیح دادن وراثت به روش بالا بسیار شبهه برانگیز است. وراثت در برنامه‌نویسی شکل دهنده‌ی یک رابطه‌ی منطقی بین تایپ‌ها است، در حالی که آن وراثتی که آن‌ها از آن سخن می‌گویند یک رابطه‌ی ژنیتیکی در عالم طبیعت است؛ این مدل حرف زدن درباره وراثت باعث ایجاد یک پیش زمینه‌ی فکری بسیار نامناسب در خواننده خواهد شد. از همین رو من به شکل دیگری وراثت را توضیح خواهم داد که به مفهوم آن در برنامه‌نویسی نزدیک‌تر است. حتی از الفاظی مانند کلاس فرزند یا والد نیز استفاده نخواهم کرد.

وراثت در اغلب اوقات در زبان‌هایی حضور دارد که دارای ساختار «کلاس» هستند. سناریوی زیر را در نظر بگیرید:

  • فرض کنید کلاسی داریم به اسم A، که مانند هر کلاس دیگری دارای یک سری فیلد و متد است.
  • حالا قرار است کلاس دیگری بسازیم به نام B که قابلیت‌های موجود در کلاس A را «بسط» می دهد. این کلاس قرار است همان فیلدها و متدهای کلاس A را داشته باشد (چون قسمتی از خدماتش با A مشترک است)، به علاوه‌ی یک سری فیلد و متد جدید که مختص خودش است.
  • در این حالت می‌توانیم تعیین کنیم کلاس B از A ارث ببرد. یعنی فیلد‌ها و متدهای «عمومیِ» کلاس A در کلاس B نیز حضور داشته باشند. اگر این کار را نکنیم، باید فیلدها و متدهای A را به صورت دستی در کلاس B کپی و پست کنیم!

با استفاده از مکانیزم وراثت در سناریو بالا، چه مزیتی برای ما حاصل شده است؟

  • از کپی و پست کردن کدها دوری کردیم و به جایش کدهایی که از قبل در A وجود داشت را مورد «استفاده مجدد» قرار دادیم.
  • همچنین بین کلاس B و A یک رابطه برقرار کردیم: یعنی می توانیم بگوییم B از جنسِ A است، چون حالا دیگر تمام امکانات A در B نیز وجود دارد.

وقتی به کمک وراثت میان دو کلاس رابطه برقرار می‌کنیم، هر تابعی که بتواند سوپر‌کلاس را به عنوان آرگومان بپذیرد، خواهد توانست که ساب‌کلاس هایش را نیز به عنوان آرگومان قبول کند. (چون هر دو از یک جنس تلقی می‌شوند).

یعنی توابع می‌توانند یک سوپرکلاس و تمام ساب‌کلاس هایش را به شکل یکسانی به کار بگیرند. این یعنی وجود پلی مورفیسم!

برای مثال یک کلاس برای موجودیت «خودرو» تعریف می‌کنیم به نام Car. این کلاس قابلیت‌های اولیه و بنیادی هر ماشینی را در خودش خواهد داشت: حرکت به جلو، حرکت به عقب، و توقف:

// Superclass - Base class.

class Car {
    var wheels = 4;

    public function moveForward() ⟹ string {
        "Car is moving forward..."
    }

    public function moveBackward() ⟹ string {
        "Car is moving backward..."
    }

    public function stop () ⟹ string {
        "Car is stopping!"
    }
}

حالا می‌خواهیم یک کلاس دیگر برای ماشین فورد طراحی کنیم. از آنجایی که فورد هم یک ماشین است، پس به تمام قابلیت‌های بنادی مانند حرکت به جلو و عقب نیاز خواهد داشت. به جای اینکه ما دوباره این‌ها را از اول در فورد تایپ کنیم، کافیست اعلام نماییم که کلاس Ford از Car ارث می‌برد:

// Subclass
class Ford extends Car {
    public function cooler() ⟹ string {
        "Ford's cooler is started!"
    }
}

f = new Ford();

f.moveForward();    ---> "Car is moving forward..."
f.cooler();    ---> "Ford's cooler is started!"
f.stop();    ---> "Car is stopping!"

کلاس Ford البته خودش یک تابع مجزا به نام cooler تعریف کرده است که در Car موجود نیست. ایرادی ندارد، ساب‌کلاس‌ها می‌توانند توابع خاص خودشان را داشته باشند و به این ترتیب امکانات سوپرکلاس را بسط دهند. اگر تابع cooler از کلاس Ford را صدا بزنیم، این تابع از داخل همین کلاس اجرا خواهد شد. اما اگر تابع moveForward را روی کلاس Ford صدا کنیم، چون Ford خودش فاقد این تابع است، moveForward به کمک سوپرکلاس Car اجرا می شود.

حالا فرض کنید که می‌خواهیم یک کلاس دیگر برای ماشین تویوتا طراحی کنیم. کلاس تویوتا هم می تواند مثل فورد از Car ارث ببرد:

// Subclass
class Toyota extends Car {
    public function radio() ⟹ string {
        "Toyota's radio is turned on!"
    }
    public function break () ⟹ string {
        "Toyota is stopping!"
    }
}

t = new Toyota();

t.moveForward();    ---> "Car is moving forward..."
t.radio();    ---> "Toyota's radio is turned on!"
t.stop();    ---> "Toyota is stopping!"

کلاس Toyota نیز از Car ارث برده است و همانند Ford برای خودش یک تابع اضافه به نام radio نیز دارد. اما این کلاس یک فرق کوچک با کلاس فورد دارد و آن این است که کلاس تویوتا تابع stop که در Car موجود بوده را به روش متفاوتی مجددا تعریف کرده است. حالا اگر ما تابع stop را روی Toyota صدا کنیم، به جای اینکه مثل Ford این تابع از Car فراخانی شود، تابع stop مستقیما از خود Toyota اجرا می گردد.

سایه اندازی (Overriding) چیست؟

یعنی ساب‌کلاس، تابعی از سوپرکلاس‌اش را مجددا تعریف کند؛ با این هدف که در آن تابع پیاده‌سازی خاص خودش را ارائه نماید. بدین ترتیب تابع موجود در ساب‌کلاس، روی تابع موجود در سوپرکلاس اش «سایه افکنده است»! از همین رو تعریف موجود در ساب‌کلاس از ارجحیت بالاتری برخوردار خواهند شد.

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

  • با وراثت، یک ساب‌کلاس می‌تواند قابلیت‌های سوپرکلاس را بسط دهد.
  • با وراثت، بین سوپرکلاس و ساب‌کلاس یک رابطه‌ی سلسله مرتبه‌ای ایجاد می‌شود. از همین رو ساب‌کلاس هم‌جنسِ سوپرکلاس شناخته خواهد شد.
  • تمام عناصر «عمومی» که در سوپرکلاس تعریف شده باشد، در ساب‌کلاس نیز در دسترس قرار خواهند گرفت.
  • ساب‌کلاس می‌تواند با توجه به نیازهایش، روی تعاریف مختلف سوپرکلاس «سایه اندازی» کند.

تا اینجا همه‌چیز خوب بود… حالا برسیم به روی تاریک وراثت!

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

چرا وراثت مورد انتقاد است؟

  • وراثت یک سیستم درختی، سلسله مرتبه‌ای، و از بالا به پایین را بین تایپ‌ها ایجاد می کند. مثلا: ماشین > تویوتا > پرادو > …. دیزاین کدها به این صورت انعطاف پذیر نخواهد بود. فرض کنید کلاسی داشته باشیم به نام «دنده»، که کلاس ماشین به قابلیت‌های آن نیاز دارد. آیا درست است که ماشین از دنده ارث ببرد؟ آیا ماشین زیر شاخه‌ی دنده است؟ آیا دنده زیر شاخه‌ی ماشین است؟ (دنده جزیی از ماشین است، اما زیرگونه‌ی ماشین نیست!)
  • همیشه عناصر سوپرکلاس ارجحیت دارند مگر اینکه در ساب‌کلاس صریحا روی آن‌ها سایه اندازی شود.
  • تمام عناصر عمومی از سوپرکلاس وارد ساب‌کلاس می‌شوند. این در حالیست که ساب‌کلاس ممکن است به همه‌ی آن‌ها نیاز نداشته باشد. ولی این عناصر در هر صورت جزو API های عمومی ساب‌کلاس نیز قلمداد خواهند شد. این قضیه یک مشکل اساسی در طراحی کدهاست و «کپسوله سازی» را خدشه دار می کند.
  • با وجود مکانیزم سایه اندازی، درک اینکه توابع دقیقا از کدام یکی از کلاس‌ها (سوپرکلاس یا ساب‌کلاس) اجرا میشوند سخت تر خواهد شد.
  • وقتی مکانیزم سایه‌گذاری با مکانیزم سربارگذاری همراه شود، دیگر یک آش شله قلمکار پدید می‌آید! حالا نه تنها باید دنبال این باشیم که تابع از کدام کلاس اجرا شده، باید به این هم دقت کنیم که کدام «نسخه» از تابع قرار است اجرا شود (به دلیل سربارگذاری)
  • وراثت، تفکیک بین تایپ‌ها را مشکل تر می کند.
  • همه‌ی این‌ها به کنار، قضیه وقتی جالب می‌شود که بدانید زبان‌هایی مانند سی‌پلاس‌پلاس از وراثت چندگانه پشتیبانی می‌کنند! یعنی هر کلاس می تواند از چندین کلاس دیگر ارث ببرد. به عبارتی تمام مشکلاتی که در بالا گفتیم، چند برابر خواهند شد.

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

متاسفانه وراثت سال‌هاست که در زبان‌های شی‌گرا مانند سی‌پلاس‌پلاس و جاوا و پایتون و پی‌اچ‌پی‌ و غیره پخش شده و نمی‌شود به این راحتی از آن دور ماند. اما اگر به ذهن‌تان رسید قسمتی از «کدهای جدیدتان» را با اتکا به مکانیزم وراثت بنویسید، همیشه سه مورد زیر را در نظر داشته باشد:

  • تا جایی که امکان دارد از وراثت استفاده نکنید.
  • تا جایی که امکان دارد از وراثت استفاده نکنید.
  • تا جایی که امکان دارد از وراثت استفاده نکنید.

وراثت در چه زمانی سودمند و بی‌خطر است؟

در زمانی که شرایط زیر دقیقا برقرار باشد:

  • رابطه‌ی is-a بین کلاس‌ها برقرار باشد. یعنی از خود سوال کنید که آیا ساب‌کلاس، «از جنس» یا «از گونه‌ی» سوپر‌کلاس هست یا خیر. مثلا آیا تویوتا یک ماشین است؟ بله؛ آیا مرغابی یک پرنده است؟ بله؛ آیا قهوه یک نوشیدنی است؟ بله؛ آیا کیبورد یک لپ‌تاپ است؟ خیر؛ آیا آهن یک میخ است؟ خیر؛ اگر چنین رابطه‌ای برقرار نبود بهتر است به جای از وراثت از اینترفیس‌ها استفاده کنید (که در ادامه آن‌ها را توضیح خواهیم داد.)

  • ساب‌کلاس واقعا به «تمام» API های عمومی سوپرکلاس نیازمند باشد. اگر حتی یکی از عناصر سوپرکلاس برای ساب‌کلاس بی‌مصرف بود، بهتر است به جای وراثت از اینترفیس‌ها استفاده نمایید.

  • اگر ساب‌کلاس فقط یکی دو مورد از عناصر سوپرکلاس را سایه اندازی کند ایرادی ندارد؛ اما اگر بیشتر از این تعداد سایه اندازی کند، یعنی در حقیقت به پیاده‌سازی‌های موجود در سوپرکلاس نیاز ندارد و بیشتر امضای متدها برایش مهم بوده است. در این شرایط بهتر است به جای وراثت از اینترفیس‌ها استفاده کنید.

همانطور که می‌بینید، وراثت مانند آتش سودمند است، اما به همان اندازه نیز خطرناک است. از همین رو توصیه رایج در دنیای برنامه‌نویسی این است که تا حد امکان، راه حل دیگری را انتخاب کنید: استفاده از کامپوزیشن (Composition) به جای وراثت!

کامپوزیشن چیست؟

معنی انگلیسی این واژه این است: ترکیب یک سری از عناصر که باعث ایجاد موجودیت جدیدی می‌شود. فرضا اگر بگوییم «کامپوزیشن این غذا چیست؟»، در حقیقت منظورمان این است که «این غذا از چه ترکیباتی ساخته شده؟»؛ برنج، روغن، نمک…

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

  • دیدگاه وراثت به پلی مورفیسم، جوابِ این سوال است: این تایپ «از چه جنس» ای است.
  • دیدگاه کامپوزیشن به پلی مورفیسم، جوابِ این سوال است: این تایپ قادر به انجام «چه کارهایی» است.

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

فرضا «حرکت رو به جلو»، یک «عمل» است:

  • انسان می تواند رو به جلو حرکت کند.
  • دانشجو می تواند رو به جلو حرکت کند.
  • موتور می تواند رو به جلو حرکت کند.
  • دوچرخه می تواند رو به جلو حرکت کند.
  • ماشین می تواند رو به جلو حرکت کند.
  • گربه می تواند رو به جلو حرکت کند.
  • کبوتر می تواند رو به جلو حرکت کند.

همانطور که می‌بینید تمام این‌ها قادر هستند «عمل» ای که از آن‌ها انتظار داریم را اجرایی کنند؛ یعنی «رابطه‌ی» بین این تایپ‌ها بر مبنای اشتراک در اعمالی که می‌توانند انجام دهند استوار است؛ حتی با اینکه ممکن است هیچ ربطی به هم نداشته باشند: آیا موتور از جنس انسان است؟ آیا گربه از جنس پرنده است؟ چگونه می‌خواستید چنین رابطه‌ی را با وراثت داشته باشید؟

اما با کامپوزیشن می توانیم بین این تایپ‌ها ارتباط ایجاد کنیم. وقتی این ارتباط را ایجاد کردیم، درهای پلی مورفیسم به روی ما باز می‌گردد! فرضا می‌توانیم یک تابع داشته باشیم که برای آرگومان اش چنین چیزی را تعریف کرده ایم: «تایپ این آرگومان، برابر باشد با تمام تایپ‌هایی که از قابلیتِ حرکت رو جلو برخوردار هستند». از این به بعد این تابع می‌تواند پذیرای تمام تایپ‌هایی باشد که دارای قابلیتِ حرکت رو به جلو هستند؛ انسان، موتور، ماشین، گربه، …

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

اجرایی نمودن کامپوزیشن

همانطور که گفتیم کامپوزیشن یک نوع نگرش است، و ممکن است به شیوه‌های گوناگونی اجرایی گردد. ما دو روش که محبوبیت بیشتری دارند را توضیح می دهیم:

  • اگر کلاس B به قابلیت‌های موجود در کلاس A نیاز داشت، لازم نیست از آن ارث بگیرد. می‌تواند یک ارجاع از کلاس A را در یکی از فیلدهای «خصوصی» اش قرار دهد و هر موقع به خدمات A نیاز داشت، به آن فیلد رجوع کند.
  • طراحی کدها حولِ محور اینترفیس‌ها

در این قسمت، مورد اول را با یک مثال توضیح خواهیم، و در قسمت بعد به اینترفیس‌ها خواهیم پرداخت.

برای مثال، بیایید همان کلاس‌های Car و Ford را به شکل دیگری به هم ربط دهیم:

class Car {
    var wheels = 4;

    public function moveForward() ⟹ string {
        "Car is moving forward..."
    }

    public function moveBackward() ⟹ string {
        "Car is moving backward..."
    }

    public function stop () ⟹ string {
        "Car is stopping!"
    }
}

class Ford {
    private var car = new Car();                           --->   a private reference to Car.

    public function cooler() ⟹ string {
        "Ford's cooler is started!"
    }
    public function moveForward() ⟹ string {
        this.car.moveForward();
    }
    public function moveBackward() ⟹ string {
        this.car.moveBackward();
    }
    public function stop () ⟹ string {
        this.car.stop();
    }
}


f = new Ford();

f.moveForward();    ---> "Car is moving forward..."
f.cooler();    ---> "Ford's cooler is started!"
f.stop();    ---> "Car is stopping!"

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

  • با اینکه باز هم یک ساختار شبه درختی وجود دارد، اما عمق شاخه‌های درخت کم خواهد شد. همچنین سلسله مراتب از پایین به بالا است؛ یعنی کلاس فرزند در اینجا در اولویت قرار دارد.
  • کاملا مشخص است تابعی که صدا میزنیم مال کدام کلاس است.
  • API عمومی سوپرکلاس در ساب‌کلاس در دسترس نیست و کپسوله سازی کاملا حفظ شده است.
  • تایپ‌ها کاملا از هم تفکیک شده‌اند.

این راه ساده است؛ قابل درک است؛ و در خیلی اوقات کار راه انداز است! اما یک مشکل اساسی دارد: درست است که در روش بالا «استفاده‌ی مجدد از کدها» صورت پذیرفته ، اما از پلی مورفیسم خبری نیست! از نظر تایپ سیستم، Car و Ford به هم ربط داده نشده‌اند. بنابراین اگر یک تابع آرگومانی از جنس Car را بپذیرد، نمی‌تواند آرگومانی از جنس Ford را پذیرا باشد.

اینجاست که اینترفیس‌ها وارد می‌شوند!

اینترفیس (Interface)

اینترفیس یکی از روش‌های ارائه‌ی پلی مورفیسم است که بر ایده‌ی کامپوزیشن بنا شده. به زبان ساده، اینترفیس مشابه یک «عهدنامه» است.

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

«شرایط مورد انتظار» را به کمک توابع عنوان می‌کنیم، که به این شکل در یک اینترفیس لحاظ می‌شوند:

interface Sporter {
    function start() ⟹ string;
}

در مثال بالا یک اینترفیس با عنوان «ورزشکار» (Sporter) ساخته‌ایم. درون این اینترفیس یک تابع قرار داده ایم؛ یعنی تمام تایپ‌هایی که می‌خواهد این اینترفیس را قبول کنند باید تابعی به اسم start داشته باشند. اگر دقت کنید، فقط نام تابع را نوشته‌ایم و آن را پیاده‌سازی نکردیم ؛ چون هر تایپی ممکن است به شکل متفاوتی تابع start را پیاده سازی نماید. برای ما فقط مهم این است که تایپ ها حتما این تابع را تعریف کرده باشند، حالا اینکه چگونه آن را پیاده سازی کنند ربطی به اینترفیس ندارد.

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

فرض کنید سه ورزشکار داریم: بوکسور، دونده، شناگر

اگر داور داد بزند «شروع»، چه اتفاقی میفتد؟

  • بوکسور، مبارزه را شروع می‌کند.
  • دونده، دویدن را شروع می‌کند.
  • شناگر، شیرجه می‌زند!

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

class Boxer {
    function fight() ⟹ string {
        "Lets fight!";
    }
}

class Runner {
    function run() ⟹ string {
        "Lets run!";
    }
}

class Swimmer {
    function dive() ⟹ string {
        "Lets dive!";
    }
}

حالا می‌خواهیم تابعی داشته باشیم که اگر تایپ‌های مربوط به ورزشکاران مختلف را به آن ارسال کردیم، آن تابع عمل شروع را روی آن‌ها اعمال کند. اما ما می‌دانیم که در یک زبان استاتیک، باید تایپ آرگومان‌های یک تابع را مشخص نماییم؛ پس اگر این تابع آرگومانی از تایپ Boxer را بگیرد، دیگر نمی‌تواند آرگومانی از تایپ Runner را بپذیرد. حالا باید چکار کنیم؟ مجبور هستیم برای تک تک این تایپ‌ها، توابع جداگانه تعریف کنیم:

function start_boxing(Boxer) ⟹ string {
    Boxer.fight();
}

function start_running(Runner) ⟹ string {
    Runner.run();
}

function start_swimming(Swimmer) ⟹ string {
    Swimmer.dive();
}

b = new Boxer();
r = new Runner();
s = new Swimmer();

start_boxing(b);
start_running(r);
start_swimming(s);

عملیات شروع برای تمام تایپ‌های ورزشکاران لازم است. حالا اگر ما به جای سه تا ورزشکار، ۲۰ تا ورزشکار داشتیم باید چکار می‌کردیم؟ یعنی باید ۲۰ تابع مختلف برای تک تک تایپ‌ها می‌ساختیم؟ مسلما راه بهتری وجود دارد: اینترفیس‌ها.

پس یک اینترفیس تعریف می‌کنیم (مانند Sporter که در بالاتر تعریف کردیم)، و در آن اینترفیس مشخص می کنیم که تمام تایپ‌ها باید تابعی به نام start داشته باشند. حالا تمام تایپ‌های مربوط به ورزشکاران مختلف را ملزم به رعایت این اینترفیس می نماییم:

class Boxer implements Sporter {
    function start() ⟹ string {
        this.fight();
    }
    function fight() ⟹ string {
        "Lets fight!";
    }
}

class Runner implements Sporter {
    function start() ⟹ string {
        this.run();
    }
    function run() ⟹ string {
        "Lets run!";
    }
}

class Swimmer implements Sporter {
    function start() ⟹ string {
        this.dive();
    }
    function dive() ⟹ string {
        "Lets dive!";
    }
}

حالا دیگر لازم نیست برای تک تک تایپ‌های مربوط به ورزشکاران یک تابع شروع جداگانه داشته باشیم، فقط کافیست یک تابع بسازیم؛ با این تفاوت که آرگومان آن تابع را برابر با تایپ اینترفیس قرار می‌دهیم! هر تایپی که از این اینترفیس تبعیت کرده باشد، می تواند به جای آن اینترفیس به تابع فرستاده شود:

function start_match(Sporter) ⟹ string {
    Sporter.start();
}

b = new Boxer();
r = new Runner();
s = new Swimmer();

start_match(b);
start_match(r);
start_match(s);

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

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

اینترفیس از مفاهیم بسیار حیاتی در زبان‌های برنامه‌نویسی است؛ و در اکثر زبان‌های برنامه‌نویسی، پلی مورفیسم با محوریت اینترفیس ارائه شده است. از همین رو مطالعه و تمرینِ اینترفیس ‌ها شدیدا توصیه می‌گردد.

تایپ سیستم مبتنی بر ساختار (Structural type system)

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

وقتی می‌گوییم تایپ سیستم ساختار و بدنه‌ی تایپ‌ها را پالایش می کند منظورمان چیست؟

یعنی تایپ سیستم، تایپ ها را از نظر اینکه چه رفتارهایی را پیاده‌سازی کرده‌اند مورد سنجش قرار می دهد.

مثال:

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

داک تایپینگ (Duck typing)

اگر یک زبان داینامیک باشد، می‌تواند تایپ سیستمِ مبتنی بر ساختار را در «زمان اجرا» اعمال نماید. معروف ترین زبانی که این عمل را انجام می‌دهد پایتون است. واژه‌ی «داک تایپ» نیز از جامعه‌ی کاربری پایتون به بیرون درز کرده است.

واژه‌ی داک تایپینگ از کجا آمده؟

Duck به معنی اردک است! این قضیه به جواب این سوال برمی‌گردد که «اردک چیست؟».

می‌توانیم پاسخ خود را با این استدلال عنوان کنیم: اگر یک پرنده مثل اردک راه رفت، مثل اردک بال زد، مثل اردک غذا خورد، مثل اردک کواک کواک کرد، … پس می‌توانیم اینطور فرض کنیم که این پرنده یک اردک است!

واضح است که «اردک» فقط یک تشبیه بوده است؛ پیام اصلی این است که شناسایی و هماهنگی تایپ‌ها از روی رفتار و اعمالی که قادر به انجامش هستند انجام شود. (به پیروی از قضیه کامپوزیشن و اینترفیس‌ها و …)

پلی مورفیسم مبتنی بر پارامتر (Parametric polymorphism)

راه حلی برای ارائه پلی مورفیسم است که بر مفهوم «جنریک»‌ها بنا شده است. این آخرین شیوه‌ی ارائه پلی مورفیسم است که ما آن را در این نوشته مورد بحث قرار خواهیم داد.

جنریک (Generic)

جنریک، قابلیت ایست که یک زبان استاتیک را قادر می‌سازد پلی مورفیسم و امکان ساخت کامپوننت‌های چند منظوره را با توسل به مکانیزم «جای‌گذاری تایپ» ارائه کند. بهترین کار این است که جنریک را با مثال توضیح دهیم.

مثال: ساخت تابع echo

در ترمینال‌های یونیکس دستوری وجود دارد به نام echo که آرگومانی که به آن ارسال کنیم را عینا برگشت می‌دهد. مثلا اگر بنویسیم echo “hi” ، مقدار “hi” برگشت داده خواهد. ما می‌خواهیم در این مثال تابعی شبیه این را پیاده سازی کنیم.

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

function echo( int arg ) ⟹ int{
    return arg;
}

این تابع یک مقدار int می‌گیرد و عینا همان مقدار int را برگشت می‌دهد. حالا چه اتفاقی میفتد اگر ما بخواهیم آرگومان‌هایی از تایپ float یا string را به تابع echo ارسال کنیم؟ مسلما تایپ چکر به ما ایراد خواهد گرفت و اجازه همچین کاری را به ما نخواهد داد. یک راه حل این است که برای هر تایپ، یک تابع echo مخصوص بسازیم:

function echoInt( int arg ) ⟹ int{
    return arg;
}

function echoFloat( float arg ) ⟹ float {
    return arg;
}

function echoString( string arg ) ⟹ string {
    return arg;
}

به فرض که این کار را هم کردیم؛ بقیه تایپ‌ها چه می‌شوند؟ آیا باید برای هر تایپ‌ای یک تابع echo مجزا بسازیم؟ این کار نه عملی است، و نه عاقلانه. به جایش می‌توانیم تابع echo را به کمک جنریک‌ها بازنویسی کنیم:

function echo<T>( T arg)  ⟹ T {
    return arg;
}

ممکن است در نگاه اول این تابع کمی عجیب به نظر برسد؛ عبارت < T > که درکد بالا نوشته ایم چه کاره است؟ (حواستان باشد این‌ها شبه کد هستند و شاید در زبان مورد علاقه شما جنریک‌ها به شکل دیگری ظاهر شوند):

عبارت < > یک «جا نگه دار» است؛ این علامت‌ها عنوان می‌دارند که قرار است در موقع کامپایل، در این نقطه از کد، تایپ دیگری جایگزین شود (جای‌گذاری تایپ)

حرف T که میان «جا نگه دار» آمده است را «متغیرِ تایپ» خطاب می‌کنیم. همانطور که یک متغیر معمولی می تواند یک «مقدار» را در خود نگه دارد، متغیرهای تایپ نیز گونه‌ی خاصی از متغیرها هستند که می‌توانند یک «تایپ» را در خود نگه دارند.

البته T فقط یک اسم دلخواه است که برای متغیرِ تایپ تعیین کرده‌ایم. می‌توانستیم هر حرف دیگری هم بجایش بنویسیم. هنگام استفاده از تابعی مانند تابع بالا، تایپ‌هایی که قرار است در هنگام کامپایل جایگزین شوند در «متغیرهای تایپ» نگه داری خواهند شد.

حالا که با «جا نگه دار» و «متغیرِ تایپ» آشنا شدید، می توانیم طرز استفاده از تابع بالا را شرح دهیم:

echo<int>(12);    ---> 12
echo<float>(3.14);    ---> 3.14
echo<string>("hi");    ---> "hi"

در کد بالا، ما به کمک جنریک توانستیم تابع echo را برای چندین تایپ مختلف استفاده کنیم. به شیوه‌ای که تابع را فراخانی کردیم توجه کنید؛ دقت کنید که چگونه برای تابع توضیح دادیم که آرگومانی که قرار است برایش بفرستیم از چه تایپی خواهد بود.

چون موقع تعریف تابع echo، هم تایپ آرگومان ورودی و هم تایپ نتیجه‌ی خروجی را همنام انتخاب کرده بودیم (به کمک متغیرِ تایپِ T)، یعنی ورودی و خروجی تابع را از یک تایپ اعلام کرده ایم. (اگر تابع int بگیرد، int تحویل خواهد داد).

آیا برای نام‌گذاری متغیرهای تایپ قوانین خاصی وجود دارد؟

خیر. قانون خاصی وجود ندارد. اما عُرف این است که اسامی زیر را برای اهدف متناظرشان انتخاب کنیم:

  • اگر هدف یک تایپ مستقل و معمولی بود، از T استفاده کنید.
  • اگر هدف تایپِ یک عنصر بود (مثلا عناصر یک آرایه)، از E استفاده کنید.
  • اگر هدف تایپِ یک کلید بود (کلید مربوط به ساختاری مثل هَش‌مَپ‌ها)، از K استفاده کنید.
  • اگر هدف تایپِ یک مقدار بود (مقدار متناظر کلیدهای یک هَش‌مَپ‌ )، از V استفاده کنید.

قابل پیش‌بینی نبودن تایپ‌های جایگزین در جنریک

تا زمانی که یک تابع جنریک را صدا نزنیم، نمی دانیم تایپ‌هایی که قرار است جایگزین شوند ممکن است چه باشند. یعنی هنگام تعریف تابع، نمی توانیم فرض را بر این بگذاریم که این تابع قرار است فقط تایپ‌های int و float را جای‌گذاری کند؛ باید در نظر داشته باشیم که هر تایپ دیگری هم ممکن است به تابع وارد شود. بنابراین در بدنه‌ی تابع، نباید «متغیرهای تایپ» را به گونه به کار ببریم که رفتار مربوط به تایپ خاصی را انتظار داشته باشند.

مثلا نمی توانیم بگوییم T.toString()، چرا که ممکن است تایپی به تابع ارسال شود که فاقد متد toString باشد. پس همیشه هنگام نوشتن توابع جنریک، خود را برای ورود هر تایپی آماده کنید.

محدود کردن دامنه‌ی تایپ‌های جاگزین در جنریک

مسلما با این قضیه که نمی‌شود «متغیرهای تایپ» را پیش بنی کرد، توابع ما بسیار عمومی خواهند شد و نمی‌توانیم خیلی از کارهایی که دوست داریم را با «متغیرهای تایپ» انجام دهیم. خوب می‌شد اگر می‌توانستیم تعیین کنیم که فقط تایپ‌هایی که دارای یک سری خصوصیات مد نظر ما هستند در «متغیرهای تایپ» وارد شوند؛ اینجاست که اینترفیس‌ها به کمک ما می‌آیند.

در قسمت مربوط به اینترفیس (Interface) ها، ما یک اینترفیس طراحی کردیم به نام Sporter که تایپ‌های Boxer و Runner و Swimmer از آن تبعیت می‌کردند. فرض کنید می خوایم برای تابع جنریک اینطور تعریف کنیم که فقط تایپ‌هایی را که از اینترفیس Sporter تبعیت می‌کنند به عنوان ورودی بپذیرد:

interface Sporter <T> {
    function start( ) ⟹ string;
}


function start_match<T: Sporter>( T ) {
    Sporter.start();
}

در تابع start_match تعیین کردیم که تایپ جایگزین باید حتما از اینترفیس Sporter تبعیت کند. حالا با خیال راحت می‌توانیم در بدنه‌ی تابع خود از متد start استفاده کنیم چون مطمئن هستیم تمام تایپ‌هایی که اینترفیس Sporter را پیاده‌سازی کردند حتما دارای این متد نیز می‌باشند:

b = new Boxer();
r = new Runner();
s = new Swimmer();

start_match<Sporter>( b );
start_match<Sporter>( r );
start_match<Sporter>( s );

جنریک‌ها قابلیت‌های گسترده تری را نیز ارائه می‌کنند که ما در این نوشته تمام آن‌ها را توضیح نخواهیم داد؛ ولی حالا دیگر می دانید که وقتی صحبت از جنریک به میان می‌آید، قرار است با چه چیزی طرف شوید.

سخن آخر

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

نظرات

comments powered by Disqus