۵ - ۵چندریختی

چندریخت یا polymorph لغتِ نسبتاً جدیدی‌ِه. در اوایل قرن نوزدهمِ میلادی، از لغات یونانی poly به معنای "چندتا" و morph به معنای "فُرم و حالت" اختراع شد. پسوندِ ‏‎-ic‎‏ در polymorphic یعنی "ساخته شده از." پس پلی‌مورفیک یعنی "ساخته شده از چندین فرم." در برنامه‌نویسی، این لغت و مونومورفیک (به معنای "ساخته شده از یک فرم") متضاد همدیگه اند.

متغیرهای تایپ ِ پلی‌مورفیک به ما این امکان رو میدن تا بیانیه‌هایی بنویسیم که با تنوعی از آرگومان‌ها و جواب‌ها کار کنن، بدون اینکه لازم باشه برای هر تایپِ معین، یک نسخه‌ی مخصوص از همون بیانیه رو بنویسیم. خیلی جالب نبود اگه مجبور بودیم برای حساب، توابع تکراری برای تک‌تکِ تایپ‌های عددی تعریف کنیم. توابع عددیِ استاندارد در GHC و ‏‎Prelude‎‏ به طور پیش‌فرض پلی‌مورفیک اند. کلّی بگیم، تایپ سیگنچرها می‌تونن سه نوع تایپ داشته باشن: معَین، پلی‌مورفیک ِ محدود، یا پلی‌مورفیک ِ پارامتری.

پس در هسکل دو گروه پلی‌مورفیسم وجود داره: پارامتریک و محدود. احتمالاً هر پلی‌مورفیسم ای تا به حال دیدین، محدود بوده، که بهِش اد-هاک یا "موردی" هم میگن. پلی‌مورفیسم ِ اد-هاک* در هسکل با تایپکلاس‌ها پیاده‌سازی شده.

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

به خاطر بیارین که در تایپ سیگنچرها، اسم‌هایی که با حرف کوچیک شروع میشن متغیرِ تایپ و پلی‌مورفیک هستن (مثل ‏‎a‎‏، ‏‎t‎‏، و غیره). اگه تایپی با حرفِ بزرگ شروع بشه یعنی معیّن ِه، مثل ‏‎Int‎‏، ‏‎Bool‎‏، و غیره.

حالا یه تابع با پلی‌مورفیسم ِ پارامتری رو بررسی می‌کنیم: تابعِ همانی. تابعِ ‏‎id‎‏ در ‏‎Prelude‎‏ قرار داره و با همه‌ی تایپ‌ها کار می‌کنه. در مثال بعدی، متغیرِ تایپ ِ ‏‎a‎‏، پلی‌مورفیسم ِ پارامتریک داره و با هیچ تایپکلاسی محدود نشده. هر مقداری به ‏‎id‎‏ بدین، همون مقدار رو پس می‌گیرین.

id :: a -> a

این تایپ میگه: به ازای همه‌ی ‏‎a‎‏ ها، یک آرگومان از تایپ ‏‎a‎‏ بگیر و یه مقدار از همون تایپِ ‏‎a‎‏ برگردون.

این تایپ سیگنچر ِ ‏‎id‎‏ با بیشترین پلی‌مورفیسم به حساب میاد، که اجازه میده با هر نوع‌داده ای کار کنه:

Prelude> id 1
1
Prelude> id "blah"
"blah"
Prelude> let inc = (+1)
Prelude> inc 2
3
Prelude> (id inc) 2
3

بر اساسِ تایپ ‏‎id‎‏، چنین رفتاری تضمین شده‌ست – اصلاً کار دیگه‌ای نمی‌تونه انجام بده! متغیر ‏‎a‎‏ در تمام تایپ سیگنچر به یه تایپ معیّن ثابت میشه (‏‎a == a‎‏) و نمی‌تونه تغییر کنه. اگه ‏‎id‎‏ به یه مقدار از تایپِ ‏‎Int‎‏ اعمال بشه، دیگه ‏‎a‎‏ هم به ‏‎Int‎‏ ثابت میشه. بطورِ پیش‌فرض، وضعیتِ متغیرهای تایپ از چپ‌ترین بخشِ تایپ سیگنچر مشخص میشن، و به محض اینکه اطلاعاتِ کافی برای انقیاد به یه تایپِ معین فراهم بشه، متغیرها به اون تایپ تثبیت میشن.

آرگومان‌های توابع با چندریختیِ پارامتریک، مثل ‏‎id‎‏، ممکنه از هر تایپ یا تایپکلاسی، یا هر چیز دیگه‌ای باشن؛ و این موضوع نشون میده جملاتِ تابع هیچ متود یا اطلاعاتی همراه خودشون ندارن، بنابراین جملات چنین توابعی محدودیت خیلی بیشتری دارند. تابع همانی با تایپ ‏‎id :: a -> a‎‏ هیچ کاری غیر از برگردوندنِ ‏‎a‎‏ نمی‌تونه انجام بده، چون هیچ متود یا اطلاعاتی همراهِ پارامترِش نیست – اصلاً هیچ کاری با ‏‎a‎‏ نمیشه انجام داد. در مقابل، تابعی مثلِ ‏‎negate‎‏ که تایپ سیگنچر ِ مشابهی داره (‏‎Num a => a -> a‎‏)، متغیر رو به داشتن یه نمونه از تایپکلاسِ ‏‎Num‎‏ محدود می‌کنه. حالا ‏‎a‎‏ گزینه‌های کمتری برای معین شدن داره، ولی در کنارِ این محدودیت، یه مجموعه از متودها هم برای استفاده با ‏‎a‎‏ اومده، یه مجموعه از کارهایی که میشه با ‏‎a‎‏ انجام داد.

همونطور که یه متغیر نماینده‌ی مجموعه‌ای از مقادیرِ ممکنه، یه متغیرِ تایپ هم نماینده‌ی یه مجموعه از تایپ‌های ممکنه. وقتی هیچ تایپکلاسی محدودیت اعمال نکنه، مجموعه‌ی تایپ‌های ممکن برای اون متغیرِ تایپ در واقع بینهایت‌ه. تایپکلاس‌ها مجموعه‌ی تایپ‌های ممکن (و متعاقباً مقادیر ممکن) رو محدود می‌کنن، ولی در عین حال، توابعِ مشترکی هم ارائه میدن که میشه برای اون مقادیر استفاده بشن.

تایپ‌های معین، نسبت به تایپ‌های محدود، انعطاف‌پذیری بیشتری در محاسبات دارن. دلیلش هم ذاتِ جمع‌پذیر ِ تایپکلاس‌هاست. برای مثال، تایپ ‏‎Int‎‏ فقط یه ‏‎Int‎‏ ِه، ولی می‌تونه از متودهای هردو تایپکلاس‌های ‏‎Num‎‏ و ‏‎Integral‎‏ بهره ببره، چراکه از هر دوی اونها نمونه داره. میشه ‏‎Num‎‏ رو یه اَبَرکلاس ای از خیلی تایپکلاس‌های عددیِ دیگه توصیف کرد، که همه‌ی اونها متودهای ‏‎Num‎‏ رو به ارث می‌برن.

بخوایم مطالب بالا رو جمع‌بندی کنیم، اگه یه متغیر بتونه هر چیزی باشه، چون هیچ مِتود ای نداره، پس کارهای کمی هم هست که بشه باهاش انجام داد. اگه بتونه بعضی از تایپ‌ها باشه (مثلاً یه نمونه از ‏‎Num‎‏ داشته باشه)، اون موقع چندتا متود هم داره. اگه تایپی معین باشه، انعطافِ تایپ رو از دست میده، ولی با جمع شدنِ متودها در وراثت از تایپکلاس‌ها، دارای متودهای بیشتری میشه. یه نکته‌ی مهم اینه که این وراثت از یه اَبرکلاس، مثل ‏‎Num‎‏، به کلاس‌های فرعی مثل ‏‎Integral‎‏، و بعد به ‏‎Int‎‏ ِه، یعنی از بالا به پایین؛ و نه برعکس. برای مثال، اگه تایپی یه نمونه از تایپکلاس ‏‎Num‎‏ داشته باشه ولی از ‏‎Integral‎‏ نداشته باشه، نمی‌تونه متودهای ‏‎Integral‎‏ رو هم پیاده‌سازی کنه. یک تایپکلاسِ فرعی نمی‌تونه متودهای اَبرکلاس ِش رو پایمال کنه.

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

تمرین‌ها: پارامتریسیته

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

۱.

سعی کنین یه تابع با تایپِ ‏‎a -> a‎‏ بنویسین که کاری غیر از تابع همانی رو انجام بده. غیرممکنه، ولی خوبه که سعی کنین.

۲.

با نگاه به ‏‎a -> a -> a‎‏ پارامتریسیته رو بهتر می‌شناسیم. این تابعِ فرضی، فقط و فقط دو تعریف داره. هر دو نسخه‌ی ممکنِ ‏‎a -> a -> a‎‏ رو بنویسین. بعد از اون هم سعی کنین محدودیتهای مقادیر با پلی‌مورفیسم ِ پارامتریک رو (که بالاتر گفتیم) نقض کنین.

۳.

تابع با تایپ ‏‎a -> b -> b‎‏ رو پیاده‌سازی کنین. آیا رفتار تابع با تغییر تایپ‌های ‏‎a‎‏ و ‏‎b‎‏ هم تغییر می‌کنه؟

ثابت های پلی‌مورفیک

دیدیم که تایپ‌های عددی زیادی در هسکل داریم، و محدودیت‌هایی هم برای استفاده از اونها با توابع متفاوت وجود دارن. ولی به نظر عجیب میومد اگه نمی‌تونستیم محاسبه‌ای مثل ‏‎-10 + 6.3‎‏ رو انجام بدیم. امتحان کنیم:

Prelude> (-10) + 6.3
-3.7

کار کرد، ولی چرا؟ ببینیم با دیدن تایپِ‌شون می‌فهمیم یا نه:

Prelude> :t (-10) + 6.3
(-10) + 6.3 :: Fractional a => a
Prelude> :t (-10)
(-10) :: Num a => a

لفظ های عددی مثل (۱۰-) و ۶٫۳ پلی‌مورفیک اند و تا زمانی که یه تایپِ مشخص‌تر براشون تعیین نشه، چندریخت می‌مونَن. نوشته‌های ‏‎Num a =>‎‏ یا ‏‎Fractional a =>‎‏ محدودیت های تایپکلاسی‌اند، و ‏‎a‎‏ هم متغیرِ در گستره هست. دیدیم که کامپایلر تایپِ کلِ معادله رو اعدادِ ‏‎Fractional‎‏ استنتاج کرد تا بتونه با ۶٫۳ هم سازگار بشه. اما (۱۰-) چطور؟ می‌بینیم که تایپ (۱۰-) با یه نمونه از تایپکلاسِ ‏‎Num‎‏ بیشترین پلی‌مورفیسم رو داره، یعنی می‌تونه هر نوع عددی باشه. به این میگیم یه ثابتِ پلی‌مورفیک؛ واضحه که (۱۰-) یه متغیر نیست، اما تایپی که می‌تونه بشه، ممکنه هر تایپِ عددی‌ای باشه، پس در باطن پلی‌مورفیک ِه. برای محاسبه شدن بالاخره باید یه تایپِ معین به خودش بگیره.

میشه با تعریف تایپ‌ها، کامپایلر رو وادار به تخصیص تایپ‌های مشخص‌تر کنیم:

Prelude> let x = 5 + 5
Prelude> :t x
x :: Num a => a
Prelude> let x = 5 + 5 :: Int
Prelude> :t x
x :: Int

در مثال اول تایپی برای اعداد تعیین نکردیم، پس تایپ سیگنچر ِ اون هم جامع‌ترین تایپِ ممکن شد، ولی بار دوم به کامپایلر گفتیم که از تایپِ ‏‎Int‎‏ استفاده کنه.

دور زدن محدودیت‌ها

تابع ‏‎length‎‏ رو قبلاً دیده بودیم که یه لیست می‌گرفت، تعداد المان‌هاش رو می‌شمارد و اون عدد رو با تایپِ ‏‎Int‎‏ برمی‌گردوند. در فصلِ قبل دیدیم که چون ‏‎Int‎‏ یه عددِ ‏‎Fractional‎‏ نیست، چنین تابعی کار نمی‌کنه:

Prelude> 6 / length [1, 2, 3]

No instance for (Fractional Int) arising
  from a use of ‘/’

In the expression : 6 / length [1, 2, 3]
In an equation for ‘it’: it = 6 / length [1, 2, 3]

مشکل اینه که ‏‎length‎‏ به اندازه‌ی کافی پلی‌مورفیک نیست. ‏‎Fractional‎‏ اعداد زیادی رو شامل میشه، اما ‏‎Int‎‏ جزئشون نیست، و ‏‎length‎‏ هم فقط ‏‎Int‎‏ برمی‌گردونه. در هسکل راه‌هایی برای دور زدنِ چنین مشکلاتی وجود داره. در این مورد، از تابعِ ‏‎fromIntegral‎‏ استفاده می‌کنیم، که ورودیِ ‏‎Integral‎‏ ِش رو مجبور به داشتنِ تایپکلاسِ ‏‎Num‎‏ می‌کنه، و باعث میشه پلی‌مورفیک بشه. تایپ‌ش اینه:

Prelude> :t fromIntegral
fromIntegral :: (Num b, Integral a) => a -> b

پس یه مقدار ‏‎a‎‏ با یکی از تایپ‌های ‏‎Integral‎‏ می‌گیره و به عنوان یه مقدار ‏‎b‎‏ با هر تایپی از ‏‎Num‎‏ برمی‌گردونه. ببینیم با تقسیمِ کسری که بالاتر داشتیم چطور کار می‌کنه:

Prelude> 6 / fromIntegral (length [1, 2, 3]) 
2.0

خوشبخت شدیم.