۱۸ - ۲شرمنده، ولی موند بوریتو نیست
پس چیه؟*
عنوان این بخش، با کمال احترام و تشکر، اشاره به مطلبی در بلاگِ مارک جیسون دومینوس تخت عنوان "موندها مثل بوریتو میمونن" داره، که یکی از مقالههای کلاسیک تو این مبحثه.
همونطور که بالاتر هم گفتیم، موند، فانکتورِ اپلیکتیو با چند قابلیت خاصه که اون رو از هرکدوم از اونها به تنهایی قویتر میکنه. یه فانکتور، تابعی رو از روی یه ساختار نگاشت میکنه؛ اپلیکتیو تابعی رو که خودش داخلِ یه ساختار هست رو از روی یه ساختار ِ دیگه نگاشت میکنه و بعد مثلِ 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
نیستن، در فصلِ قبل هم دیده بودیمشون. پس برگردیم سراغِ موند، قبوله؟