۱۸ - ۳گرامر 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.")