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