۱۸ - ۲شرمنده، ولی موند بوریتو نیست

پس چیه؟*

*

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

همونطور که بالاتر هم گفتیم، موند، فانکتورِ اپلیکتیو با چند قابلیت خاص‌ه که اون رو از هرکدوم از اونها به تنهایی قوی‌تر می‌کنه. یه فانکتور، تابعی رو از روی یه ساختار نگاشت می‌کنه؛ اپلیکتیو تابعی رو که خودش داخلِ یه ساختار هست رو از روی یه ساختار ِ دیگه نگاشت می‌کنه و بعد مثلِ ‏‎mappend‎‏، اون دو لایه ساختار رو با هم ترکیب می‌کنه. موند‌ها رو هم میشه یه راه دیگه برای اعمال توابع از روی ساختار‌ها دونست که چندتا قابلیتِ اضافه دارن. اول تعریف تایپکلاس و عملیات‌های اصلی‌ش رو ببینیم.

اگه از GHC 7.10 یا جدیدتر استفاده می‌کنین، یه محدودیت ِ ‏‎Applicative‎‏ در تعریفِ ‏‎Monad‎‏ می‌بینین (که باید هم باشه):

class Applicative m => Monad m where
  (>>=) :: m a -> (a -> m b) -> m b
  (>>) :: m a -> m b -> m b
  return :: a -> m a

از اون محدودیت تایپکلاسی روی ‏‎m‎‏ شروع می‌کنیم و این تعریف رو با جزئیات بررسی می‌کنیم.

اپلیکتیو برای ‏‎m‎‏

در نسخه‌های قدیمی‌ترِ GHC، ‏‎Applicative‎‏ سوپرکلاسِ ‏‎Monad‎‏ نبود. با توجه به اینکه ‏‎Monad‎‏ از ‏‎Applicative‎‏، و ‏‎Applicative‎‏ از ‏‎Functor‎‏ قدرتمندتره، از ‏‎Monad‎‏ میشه به ‏‎Applicative‎‏ و ‏‎Functor‎‏ رسید، همونطور که میشه از ‏‎Applicative‎‏ به ‏‎Functor‎‏ رسید. این چه معنایی داره؟ یعنی میشه ‏‎fmap‎‏ رو بر مبنای عملیات‌های موندی نوشت و کار می‌کنه:

fmap f xs  =  xs >>= return . f

خودتون امتحان کنین:

Prelude> fmap (+1) [1..3]
[2,3,4]

Prelude> [1..3] >>= return . (+1)
[2,3,4]

جهت اطلاع، این یه قانون ِه. نمونه‌های ‏‎Functor‎‏، ‏‎Applicative‎‏، و ‏‎Monad‎‏ برای یه نوع‌داده باید با هم سازگاری داشته باشن.

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

Functor -> Applicative -> Monad

هرجا یه نمونه ِ ‏‎Monad‎‏ برای یه تایپ تعریف می‌کنین، اون تایپ حتماً یه نمونه ِ ‏‎Applicative‎‏ و ‏‎Functor‎‏ هم داره.

عملیاتهای اصلی

تایپکلاس ‏‎Monad‎‏ سه عملیات ِ اصلی رو تعریف می‌کنه، اما برای یه نمونه ِ ‏‎Monad‎‏ ِ کامل (م. minimally complete، یعنی حداقل عملیات ِ لازم برای کامل بودنِ نمونه فقط لازمه ‏‎>>=‎‏ رو تعریف کنین. هر سه‌تا رو ببینیم:

(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a

از ‏‎return‎‏ که میشه بگذریم؛ همون ‏‎pure‎‏ ِه. فقط یه مقدار می‌گیره و داخلِ ساختار ِ مورد نظرتون پس‌ش میده، که اون ساختار هم می‌تونه لیست، یا ‏‎Just‎‏، یا ‏‎IO‎‏ یا هر ساختار موندی ِ دیگه‌ای باشه. در فصلِ ماژول‌ها یه ذره ازش گفتیم، استفاده هم کردیم. فصلِ قبل هم ‏‎pure‎‏ رو توضیح دادیم، پس دیگه چیزِ زیادی برای ‏‎return‎‏ نمونده که بگیم.

عملگر ِ بعدی، ‏‎>>‎‏، هیچ اسم رسمی‌ای نداره... میشه جنابِ نوک‌تیز صداش کنیم. بعضی‌ها بهش اوپراتورِ تسلسل میگن، که راستش اسمِ گویاتری نسبت به جناب نوک‌تیزه. جناب نوک‌تیز دوتا اجراییه رو متسلسل می‌کنه و هر مقداری که از اجراییه ِ اول حاصل میشه رو دور میندازه. ‏‎Applicative‎‏ هم یه عملگر ِ مشابهِ این داره، که البته ما چیزی ازش نگفتیم. در بخشِ گرامر ِ ‏‎do‎‏ از این فصل، مثال‌هایی از این عملگر می‌بینیم.

میرسیم به بایند ِ بزرگ! به عملگر ِ ‏‎>>=‎‏ میگیم بایند، و این اون چیزیه (در واقع شامل اون چیزیه) که ‏‎Monad‎‏ رو خاص می‌کنه.

بخشِ جدیدِ ‏‎Monad‎‏

عموماً وقتی از موند‌ها استفاده می‌کنیم، از تابعِ بایند (‏‎>>=‎‏) استفاده می‌کنیم. گاهی اوقات بطورِ مستقیم، گاهی اوقات هم غیرمستقیم و به واسطه‌ی گرامر ِ ‏‎do‎‏. سؤالی که باید بپرسیم اینه که چه چیزی (حداقل از دیدگاه تایپ‌ها) مختصِ ‏‎Monad‎‏ ِه؟

دیدیم که ‏‎return‎‏ نیست؛ اون فقط یه اسم دیگه برای ‏‎pure‎‏ از ‏‎Applicative‎‏ ِه.

اشاره کردیم (و به زودی به وضوح می‌بینیم) که ‏‎>>‎‏ هم نیست.

حتی ‏‎>>=‎‏ هم نیست (حداقل نه همه‌ش). واضحه که تایپِ ‏‎>>=‎‏ کاملاً شبیهِ ‏‎fmap‎‏ و ‏‎<*>‎‏ ِه که منطقی هم هست، چون موند‌ها فانکتور‌های اپلیکتیو اند. اینجا برای شباهتِ بیشتر، بجای ‏‎m‎‏ برای ‏‎Monad‎‏، از ‏‎f‎‏ استفاده می‌کنیم:

fmap :: Functor f
     => (a -> b) -> f a -> f b
<*>  :: Applicative f
     => f (a -> b) -> f a -> f b
>>=  :: Monad f
     => f a -> (a -> f b) -> f b

خب، می‌بینیم که بایند خیلی شبیهِ ‏‎<*>‎‏ و ‏‎fmap‎‏ ِه، فقط دو آرگومانِ اولش جابجا شدن. پس این ایده‌ی اعمال ِ یه تابع از روی یه ساختار مختصِ ‏‎Monad‎‏ نیست.

میشه یه تابع با تایپِ ‏‎(a -> m b)‎‏ رو ‏‎fmap‎‏ کنیم تا بیشتر شبیهِ ‏‎>>=‎‏ بشه. مانعی نداره. به استفاده از تیلدا برای نشون دادنِ تعادلِ تقریبی ادامه میدیم:

-- b == f b اگه

fmap :: Functor f
     => (a -> f b) -> f a -> f (f b)

این ایده رو با لیست به عنوان ساختار ِمون نشون میدیم:

Prelude> let andOne x = [x, 1]
Prelude> andOne 10
[10,1]

Prelude> :t fmap andOne [4, 5, 6]
fmap andOne [4, 5, 6] :: Num t => [[t]]

Prelude> fmap andOne [4, 5, 6]
[[4,1],[5,1],[6,1]]

از روی تایپ می‌دونستیم آخرِ کار یه ‏‎f (f b)‎‏ می‌مونه – یعنی یه لایه ساختار ِ اضافه؛ جواب هم لیست‌های تودرتو شد. اگه بجای لیست‌های تودرتو، ‏‎Num a => [a]‎‏ می‌خواستیم چطور؟ فقط یه لایه ساختار ِ ‏‎f‎‏ می‌خوایم، ولی تابعی که نگاشت کردیم، خودش باز ساختار اضافه کرده! بعد از نگاشت ِ یه تابع که ساختار موندی اضافه می‌کنه، باید از طریقی یه لایه از ساختار‌ها رو دور بندازیم.

خوب، چطور میشه چنین کاری کرد؟ اوایل کتاب با لیست‌ها یه کار مشابه کردیم:

Prelude> concat $ fmap andOne [4, 5, 6]
[4,1,5,1,6,1]

جامع‌ترین تایپِ ‏‎concat‎‏:

concat :: Foldable t => t [a] -> [a]

-- تایپ اختصاصی‌تر هم میشه بهش داد

concat :: [[a]] -> [a]

میشه گفت ‏‎Monad‎‏ در واقع یه تعمیم از ‏‎concat‎‏ ِه! این تابع بخشِ خاصِ ‏‎Monad‎‏ ِه:

import Control.Monad (join)

join :: Monad m => m (m a) -> m a

-- مقایسه کنین

concat ::          [[a]]   -> [a]

همین که با اعمال ِ تابع ساختار اضافه می‌کنیم (در مقایسه با اپلیکتیو‌ها و فانکتور‌ها که کاری به کارِ ساختار نداشتن) میشه گفت چیزِ جدیدی‌ه. اجازه‌ی اینکه تابع ساختار رو دستکاری کنه، چیزیه که در ‏‎Functor‎‏ و ‏‎Applicative‎‏ ندیدیم، و جلوتر عواقب و قابلیت‌هاش رو بیشتر بررسی می‌کنیم (بخصوص سرِ موند ِ ‏‎Maybe‎‏). البته اگه بخوایم، با ‏‎fmap‎‏ هم میشه ساختار اضافه کنیم. ولی این قابلیت که بتونیم اون دو لایه ساختار رو به یکی لِه کنیم، ویژگیِ خاصِ ‏‎Monad‎‏ ِه. در واقع تابعِ بایند، یا ‏‎>>=‎‏، از کنارِ هم گذاشتن تابعِ ‏‎join‎‏ و تابعِ ‏‎fmap‎‏ بدست میاد.

پس چطوری به بایند برسیم؟

جواب این سؤال، تمرین‌ه

‏‎bind‎‏ رو با ‏‎fmap‎‏ و ‏‎join‎‏ تعریف کنین.

ترس قاتلِ ذهن‌ه، دوست من. تو می‌تونی!

-- ‎‏شده‏‎ flip ‎‏که‏‎ (>>=) ‎‏معادل تابع‏‎
bind :: Monad m => (a -> m b) -> m a -> m b
bind = undefined

‏‎Monad‎‏ چه چیزی نیست

از اونجا که Monad یه مفهومِ نسبتاً انتزاعی ِه، اکثرِ مردم از یکی دو جنبه‌ای که براشون راحت‌تره ازش حرف می‌زنن. معمولاً هم ‏‎Monad‎‏ رو از زاویه‌ی ‏‎IO Monad‎‏ توضیح میدن. ‏‎IO‎‏ یه نمونه ِ ‏‎Monad‎‏ داره، و یکی از رایج‌ترین کاربردهای موند‌هاست. اما درکِ موند‌ها فقط از طریقِ اون یک نمونه، علاوه بر حس ناقص از ماهیت و قابلیت‌هاشون، تا حدی هم درکِ ‏‎IO‎‏ رو مخدوش می‌کنه.

اینها چیزهایی‌اند که یه موند نیست:

۱.

ناخالص. توابعِ موندی، توابعِ خالص‌اند. ‏‎IO‎‏ یه نوع‌داده ِ انتزاعی ِه که امکانِ اجراییه‌های ناخالص، یا اثردار رو میده. ولی هیچ چیز درباره‌ی موند‌ها ناخالص نیست.

۲.

زبانِ تعبیه‌شده برای برنامه‌نویسی دستوری. سایمون پیتون-جونز، یکی از توسعه‌دهندگان و محققینِ اصلیِ هسکل و پیاده‌سازی‌ش در GHC، گفته: "هسکل بهترین زبان برنامه‌نویسی دستوری ِه." که داشت راجع به نقشِ موند‌ها در برنامه‌نویسیِ اثردار صحبت می‌کرد. با اینکه اکثراً از موند‌ها برای تسلسل ِ اجراییه‌ها به نحوی استفاده میشه که خیلی مشابهِ برنامه‌نویسی دستوری هست، موند‌های جابجایی‌پذیر هم وجود دارن که اجراییه‌ها رو مرتب نمی‌کنن. یکی از اونها رو چند فصل جلوتر که ‏‎Reader‎‏ رو توضیح بدیم می‌بینیم.

۳.

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

۴.

متمرکز به اکید بودن. عملیات‌های موندی ِ ‏‎bind‎‏ و ‏‎return‎‏ نااکید اند. بعضی عملیات‌ها رو در یه نمونه ِ خاص میشه اکید کرد. جلوتر در کتاب بیشتر از این مبحث صحبت می‌کنیم.

استفاده از موند‌ها نیازی به علم ریاضی هم نداره. یا نظریه‌ی رده‌ها. ملزم به سربه‌بیابون گذاشتن و وسطِ کویر گشنگی کشیدن هم نیست.

تایپکلاسِ ‏‎Monad‎‏ تعمیمی از دستکاری کردنِ ساختار هست، که با قوانینی اون دستکاری‌ها در چارچوبِ معقولی حفظ میشن. دقیقاً مثلِ ‏‎Functor‎‏ و ‏‎Applicative‎‏. همه‌ش همینه، جادویی در کار نیست.

‏‎Monad‎‏ هم لیفت می‌کنه!

کلاسِ ‏‎Monad‎‏ هم شاملِ یه دسته توابعِ ‏‎lift‎‏ ِه که با اونهایی که در ‏‎Applicative‎‏ دیدیم یکی‌اند. کارِ متفاوتی نمی‌کنن، ولی چون قبل از اکتشافِ اپلیکتیو‌ها بعضی کتابخونه‌ها ازشون استفاده کرده بودن هنوز هم وجود دارن. پس تابع‌های ‏‎liftM‎‏ هنوز هستن تا سازگاری رو حفظ کنن. هر از گاهی ممکنه ببینین‌شون. خیلی مختصر با معادل‌های اپلیکتیو‌ِشون مقایسه می‌کنیم:

liftA :: Applicative f
      => (a -> b) -> f a -> f b
liftM :: Monad m
      => (a1 -> r) -> m a1 -> m r

اگه یادتون باشه، این همون ‏‎fmap‎‏ با یه محدودیت تایپکلاسی ِ دیگه‌ست. اگه طرز کارش رو دوست دارین ببینین، پیشنهاد می‌کنیم با ‏‎fmap‎‏ در REPL چند بیانیه بنویسین، و نوبتی ‏‎fmap‎‏ رو با ‏‎liftA‎‏ و ‏‎liftM‎‏ جایگزین کنین.

این همه‌ش نیست:

liftA2 :: Applicative f
       => (a -> b -> c)
       -> f a
       -> f b
       -> f c

liftM2 :: Monad m
       => (a1 -> a2 -> r)
       -> m a1
       -> m a2
       -> m r

به غیر از متغیرهای تایپ، شبیهِ هم‌اند. امتحان‌شون کنیم ببینیم:

Prelude> liftA2 (,) (Just 3) (Just 5)
Just (3,5)

Prelude> liftM2 (,) (Just 3) (Just 5)
Just (3,5)

اگه خاطرتون باشه خیلی وقت پیش در فصل لیست‌ها از یه تابع به اسمِ ‏‎zipWith‎‏ صحبت کردیم. ‏‎zipWith‎‏ همون ‏‎liftA2‎‏ یا ‏‎liftM2‎‏ ِه که برای لیست‌ها اختصاصی شده:

Prelude> :t zipWith
zipWith :: (a -> b -> c)
        -> [a] -> [b] -> [c]
Prelude> zipWith (+) [3, 4] [5, 6]
[8,10]
Prelude> liftA2 (+) [3, 4] [5, 6]
[8,9,9,10]

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

خیلی خوب. سه‌تایی‌شون هم هست:

liftA3 :: Applicative f
       => (a -> b -> c)
       -> f a -> f b
       -> f c -> f d

liftM3 :: Monad m
       => (a1 -> a2 -> a3 -> r)
       -> m a1 -> m a2
       -> m a3 -> m r

یه تابع ‏‎zipWith3‎‏ هم داریم. ببینیم چی میشه:

Prelude> :t zipWith3
zipWith3 :: (a -> b -> c -> d) ->
            [a] -> [b] -> [c] -> [d]

Prelude> liftM3 (,,) [1, 2] [3] [5, 6]
[(1,3,5),(1,3,6),(2,3,5),(2,3,6)]
Prelude> zipWith3 (,,) [1, 2] [3] [5, 6]
[(1,3,5)]

اینجا هم با استفاده از دو مانوید ِ مختلف، به دو جواب متفاوت رسیدیم.

این توابع رو اینجا معرفی کردیم چون جلوتر در فصل تو چند مثال می‌بینیم‌شون، اما فقط مختصِ ‏‎Monad‎‏ نیستن، در فصلِ قبل هم دیده بودیم‌شون. پس برگردیم سراغِ موند، قبوله؟