۱۸ - ۳گرامر do و موندها
گرامر ِ do
رو در فصلِ ماژولها معرفی کردیم. از این شکر گرامری در بافت ِ IO
استفاده کردیم تا راحت بتونیم نتیجهی یک اجراییه رو به عنوان ورودیِ اجراییه ِ بعدی بدیم، و اجراییهها رو سکانس کنیم. با اینکه گرامر ِ do
با هر موندی کار میکنه – نه فقط IO
– اکثراً در استفاده با IO
دیده میشه. تو این بخش توضیح میدیم که چرا این یه شکر گرامری ِه و کاری که join
از تایپکلاسِ Monad
میتونه انجام بده رو نشون میدیم. اینجا برای نشوندادن از IO Monad
استفاده میکنیم، ولی بعداً مثالهایی از گرامر ِ do
بدونِ IO
هم میبینیم.
برای شروع، این تابعهای مرتبط رو ببینین:
(*>) :: Applicative f => f a -> f b -> f b
(>>) :: Monad m => m a -> m b -> m b
برای کار ما، (*>)
و (>>)
یک چیزاند: متسلسل کنندهی توابع، اما با دو محدودیت ِ متفاوت. در هر حالت باید یه کار انجام بدن:
Prelude> putStrLn "Hello, " >> putStrLn "World!"
Hello,
World!
Prelude> putStrLn "Hello, " *> putStrLn "World!"
Hello,
World!
در ظاهر یکسان.
بعد از اینکه کامپایلر گرامر ِ do
رو تلخ میکنه، این شکلی میشه:
sequencing :: IO ()
sequencing = do
putStrLn "blah"
putStrLn "another thing"
sequencing' :: IO ()
sequencing' =
putStrLn "blah" >>
putStrLn "another thing"
sequencing'' :: IO ()
sequencing'' =
putStrLn "blah" *>
putStrLn "another thing"
هرسهتاشون یه نتیجه میدن. چنین کاری رو با انقیاد ِ متغیرها در گرامر ِ do
هم میشه انجام داد:
binding :: IO ()
binding = do
name <- getLine
putStrLn name
binding' :: IO ()
binding' =
getLine >>= putStrLn
بجای نامگذاریِ متغیر و پاسکردنش به عنوانِ آرگومان به تابعِ بعدی، از >>=
استفاده کردیم که خودش مستقیماً اون مقدار رو به تابعِ بعدی میده.
وقتی fmap
به تنهایی کافی نیست
دقت کنین اگه putStrLn
رو روی getLine
نگاشت (fmap
) کنین، کاری انجام نمیده. این رو توی REPL امتحان کنین:
Prelude> putStrLn <$> getLine
چون از getLine
استفاده کردین، بعد از enter زدن منتظرِ ورودی ِ شما میمونه. یه چیزی بنویسین و دوباره enter بزنین و ببینین چی میشه.
هرچیزی که نوشته بودین چاپ نشد، با اینکه به نظر میرسه باید چاپ میشد چون putStrLn
رو روی getLine
نگاشت کرده بودین. اجراییه ِ IO
ای که ورودی میخواست رو محاسبه کردیم، اما اونی که چاپ میکرد محاسبه نشد. قضیه چیه؟
خوب اول از تایپها شروع کنیم. تایپ کاری که کردین اینطوره:
Prelude> :t putStrLn <$> getLine
putStrLn <$> getLine :: IO (IO ())
خُردِش میکنیم تا بفهمیم چرا کار نکرد. اول، getLine
یه I/O انجام میده تا یه String
بگیره:
getLine :: IO String
و putStrLn
یه آرگومانِ String
میگیره، I/O انجام میده، و هیچ چیزِ جذابی برنمیگردونه – والدینی که به بچههاشون پولتوجیبی میدن خوب درک میکنن:
putStrLn :: String -> IO ()
با putStrLn
و getLine
، تایپِ fmap
چی میشه؟
-- تایپی که باهاش شروع میکنیم
<$> :: Functor f => (a -> b) -> f a -> f b
-- putStrLn میشه (a -> b) اینجا
-- (a -> b )
putStrLn :: String -> IO ()
اون b
به تایپِ IO ()
اختصاصی میشه، که یعنی یه اجراییه ِ IO
ِ دیگه توی I/O ای که getLine
اجرا میکنه، جا میده. این هم شبیه همون مثالیه که یه تابعِ (a -> m b)
رو fmap
کردیم. تایپهامون اینطوری تغییر میکنن:
f :: Functor f => f String -> f (IO ())
f x = putStrLn <$> x
g :: (String -> b) -> IO b
g x = putStrLn <$> x
putStrLn <$> getLine :: IO (IO ())
خیلی خوب... پس کدوم IO
کدوم ِه، و چرا ورودی میگیره ولی چیزی چاپ نمیکنه؟
-- [1] [2] [3]
h :: IO (IO ())
h = putStrLn <$> getLine
۱.
این ساختار ِ IO
ِ بیرونی، نمایندهی اثراتی ِه که getLine
باید پیادهسازی کنه تا یه String
از ورودی ِ کاربر برگردونه.
۲.
این ساختار ِ IO
ِ درونی، نمایندهی اثراتی ِه که اگه putStrLn
محاسبه میشد، اونها هم پیادهسازی میشدن.
۳.
اون واحد، واحدی ِه که putStrLn
برمیگردونه.
یکی از قدرتهای هسکل اینه که میشه بدون اجرای محاسباتِ اثردار، بهشون ارجاع بدیم، ترکیبِشون کنیم، و از روشون نگاشت کنیم. برای یه مثالِ سادهتر که چطور میشه برای اجراییههای IO
(یا در کل هر محاسبهای) صبر کرد، کُد زیر رو در نظر بگیرین:
Prelude> let printOne = putStrLn "1"
Prelude> let printTwo = puteStrLn "2"
Prelude> let twoActions = (printOne, printTwo)
Prelude> :t twoActions
twoActions :: (IO (), IO ())
با تعریفِ اون توپل از دوتا اجراییه ِ IO
، حالا میشه یکی رو بگیریم و محاسبه کنیم:
Prelude> fst twoActions
1
Prelude> snd twoActions
2
Prelude> fst twoActions
1
دقت کنین که میشه هر اجراییه ِ IO
رو چند بار محاسبه کنیم. این نکته بعداً حائز اهمیت میشه.
برگردیم به مسئلهی خودمون که چرا نمیشه putStrLn
رو روی getLine
با fmap
نگاشت کنیم. احتمالاً تا الان متوجه شدین باید چی کار کنیم. باید اون دو لایه IO
رو با هم تلفیق کنیم. برای رسیدن به جواب، همون چیزِ خاص از Monad
رو لازم داریم: join
. ببینین چه میکنه:
Prelude> import Control.Monad (join)
Prelude> join $ putStrLn <$> getLine
blah
blah
Prelude> :t join $ putStrLn <$> getLine
join $ putStrLn <$> getLine :: IO ()
کاری که join
اینجا انجام داد، تلفیق ِ اثراتِ getLine
و putStrLn
به یک اجراییه ِ IO
بود. ترتیبی که این اجراییه ِ IO
ِ تلفیق شده، اثرات رو پیادهسازی میکنه، برمبنای تودرتویی ِ اجراییههای IO
تعیین میشه. در جبر لاندا هم، تمیزترین راه برای بیان ترتیبِ بیانیهها، از طریق تودرتویی ِ اونها یا لانداهاست.
درست فهمیدین، هنوز جبر لاندا رو پشت سر نذاشتیم. تسلسل ِ موندی و گرامر ِ do
در ظاهر خیلی ازش فاصله دارن. ولی در حقیقت اینطور نیست. همونطور که گفتیم، اجراییههای موندی هنوز خالصاند، و این عملگرهای تسلسل که به کار بردیم در واقع راهی برای تودرتو کردن ِ لانداها هستن. البته IO
یه کم فرق داره، چون اجازهی عوارض جانبی رو میده، اما به دلیلِ اینکه اون اثرات فقط محدود به تایپِ IO
اند، بقیهی چیزها هنوز جبر لاندا ِ خالصاند.
گاهی اوقات، معلق کردن یا پیادهسازی نکردنِ یه اجراییه ِ I/O تا تکمیلِ یه تصمیم، میتونه با ارزش باشه؛ پس تایپهایی مثل IO (IO ())
لزوماً نامعتبر نیستن. اما باید بدونید که چه چیزی میتونه این مثال رو به جواب برسونه.
حالا با درکِ بیشتری که از کاربردِ موندها پیدا کردیم، برگردیم به تلخ کردنِ گرامر ِ do
:
bindingAndSequencing :: IO ()
bindingAndSequencing = do
putStrLn "name pls:"
name <- getLine
putStrLn ("y hello thar: " ++ name)
bindingAndSequencing' :: IO ()
bindingAndSequencing' =
putStrLn "name pls:" >>
getLine >>=
\name ->
putStrLn ("y hello thar: " ++ name)
این تودرتویی که زیاد میشه، فایدهی گرامر ِ do
هم واضحتر میشه:
twoBinds :: IO ()
twoBinds = do
putStrLn "name pls:"
name <- getLine
putStrLn "age pls:"
age <- getLine
putStrLn ("y hello thar: "
++ name ++ " who is: "
++ age ++ " years old.")
twoBinds' :: IO ()
twoBinds' =
putStrLn "name pls:" >>
getLine >>=
\name ->
putStrLn "age pls:" >>
getLine >>=
\age ->
putStrLn ("y hello thar: "
++ name ++ " who is: "
++ age ++ " years old.")