۲۱ - ۲شروعی تازه

این فصل رو یه کم متفاوت از بقیه‌ی فصل‌ها شروع می‌کنیم، چون امیدواریم از این راه نشون بدیم کارهایی که اینجا انجام میدیم تفاوتِ چندانی با چیزهایی که تا اینجا یاد گرفتین ندارن. پس با چندتا مثال شروع می‌کنیم. یه فایل رو مثل زیر شروع کنین:

import Control.Applicative

boop = (*2)
doop = (+10)

bip :: Integer -> Integer
bip = boop . doop

از روی تایپ‌های ‏‎boop‎‏، ‏‎doop‎‏، و ‏‎(.)‎‏ مشخصه که ‏‎bip‎‏، یک آرگومان می‌گیره. دقت کنین که اگه تایپ‌ها رو تعیین نکنین و از فایل بارگذاری کنین، بطورِ پیش‌فرض تک‌ریختی میشه؛ اگه بخواین ‏‎bip‎‏ چندریختی بشه، علاوه بر تایپِ خودش، باید تایپِ دوتا تابعی هم که ازشون درست شده رو پلی‌مورفیک کنین.

وقتی ‏‎bip‎‏ رو به یه آرگومان اعمال می‌کنیم، اول ‏‎doop‎‏ به اون آرگومان اعمال میشه، و بعد جوابش به عنوانِ ورودی به ‏‎boop‎‏ پاس میشه. تا اینجا چیزی جدید نبوده.

این ترکیب تابع رو اینطوری هم میشه نوشت:

bloop :: Integer -> Integer
bloop = fmap boop doop

هنوز ‏‎fmap‎‏ کردنِ یه تابع از روی یه تابعِ دیگه برامون کامل مأنوس نشده، و ممکنه واستون سؤال شده باشه که بافت ِ فانکتوری اینجا کجاست. منظورمون از بافت ِ فانکتوری همون ساختار (نوع‌داده) ایه که تابع از روش لیفت میشه تا به مقدارِ داخلِ اون ساختار اعمال بشه. برای مثال، لیست یه بافت ِ فانکتوری ِه که میشه تابع‌ها رو از روش لیفت کرد. میگیم تابع از روی ساختار ِ لیست لیفت میشه و به مقادیرِ داخلِ لیست اعمال یا نگاشت میشه.

در تابعِ ‏‎bloop‎‏، بافت‌ِمون یه تابعِ نیمه اعمال شده ِه. مشابهِ ترکیب توابع، ‏‎fmap‎‏ هم دوتا تابع رو قبل از اعمال به آرگومان، با هم ترکیب می‌کنه. پس جوابِ یکی به عنوانِ ورودی به اون یکی داده میشه. اینجا ‏‎fmap‎‏، تابعِ نیمه اعمال شده رو از روی تابعِ بعدی لیفت می‌کنه، چیزی شبیهِ این:

fmap boop doop x == (*2) ((+10) x)
-- که از راه میرسه، اولین x این
-- ‎‏میشه، و‏‎ (+10) آرگومان برای
-- جوابِ اون میشه اولین
-- .(*2) آرگومان برای

این فانکتور ِ توابع ِه. به زودی جزئیات بیشتری از این رو توضیح میدیم.

فعلاً بریم سراغِ چندتا مثالِ دیگه. این کُدها رو تو همون فایل بنویسین تا ‏‎boop‎‏ و ‏‎doop‎‏ در گستره باشن:

bbop :: Integer -> Integer
bbop = (+) <$> boop <*> doop

duwop :: Integer -> Integer
duwop = liftA2 (+) boop doop

حالا یه بافت ِ ‏‎Applicative‎‏ داریم. یه تابعِ دیگه اضافه کردیم تا از روی بافت ِ تابع‌های نیمه اعمال شده لیفت کنیم. این بار هم تابع‌های نیمه اعمال شده داریم که منتظرِ آرگومان‌اند، اما این متفاوت از ‏‎fmap‎‏ کردن کار می‌کنه. اینجا آرگومانِ ورودی به طورِ موازی به ‏‎boop‎‏ و ‏‎doop‎‏ داده میشه، و جواب‌هاشون با هم جمع میشه.

‏‎boop‎‏ و ‏‎doop‎‏ هردو منتظرِ یه ورودی‌اند. اینطوری می‌تونیم همزمان اعمال‌ِشون کنیم:

Prelude> bbop 3
19

کاری شبیهِ این انجام میده:

((+) <$> (*2) <*> (+10)) 3

-- :fmap اول
(*2) :: Num a => a -> a
(+)  :: Num a => a -> a -> a
(+) <$> (*2) :: Num a => a -> a -> a

اگه تابعی که منتظرِ دو آرگومان هست رو روی یه تابع که یک آرگومان می‌خواد نگاشت کنیم، تابعی درست میشه که دو آرگومان می‌خواد.

دقت کنین که این دقیقاً عینِ ترکیب توابع ِه:

(+) . (*2) :: Num a => a -> a -> a

جوابش هم همینطور:

Prelude> ((+) . (*2)) 5 3
13
Prelude> ((+) <$> (*2)) 5 3
13

پس چه اتفاقی داره میوفته؟

((+) <$> (*2)) 5 3

-- به یاد داشته باشین که این
-- .هست (.) ‎‏پشت پرده همون‏‎

((+) . (*2)) 5 3

-- f . g = \ x -> f (g x)

((+) . (*2)) == \ x -> (+) (2 * x)

نکته اینجاست که حتی بعد از اعمال ِ ‏‎x‎‏، می‌رسیم به اعمالِ ناقص ِ ‏‎(+)‎‏ روی آرگومانِ اول‌ش (که همون ‏‎x‎‏ ِه که با ‏‎(*2)‎‏ دوبرابر شده). پس آرگومانِ دوم‌ش میشه عددی که به آرگومانِ اول (که دوبرابر شده) اضافه میشه:

-- (*2) :اولین تابعی که اعمال میشه
-- 5    :اولین آرگومان
-- ‎‏هم یه آرگومان می‌گیره، پس داریم:‏‎ (*2)

((+) . (*2)) 5 3
(\ x -> (+) (2 * x)) 5 3
(\ 5 -> (+) (2 * 5)) 3
((+) 10) 3

-- ‎‏رو جمع می‌کنه.‏‎ 3 ‎‏و‏‎ 10 ‎‏بعد‏‎
13

اوکِی، ولی قسمت دوم‌ش چطور؟

((+) <$> (*2) <*> (+10)) 3

-- ...وایسا ببینم
-- آرگومان اول کجا رفت؟
((+) <$> (*2) <*> (+10)) :: Num a => b -> b

یکی از خوبی‌های هسکل اینه که میشه یه تایپِ معین‌تر برای توابعی مثل ‏‎(<*>)‎‏ اعلام کنیم و ببینیم که آیا کامپایلر موافقت می‌کنه یا نه. اول تایپِ ‏‎(<*>)‎‏ رو دوره کنیم:

λ> :t (<*>)
(<*>) :: Applicative f => f (a -> b) -> f a -> f b

-- f ~ ((->) a) :در این مورد می‌دونیم
-- :پس همین رو تعیین می‌کنیم
λ> :t (<*>) :: (a -> a -> b) -> (a -> a) -> (a -> b)
(<*>) :: (a -> a -> b) -> (a -> a) -> (a -> b)

کامپایلر می‌پذیره که این یه تایپِ ممکن برای ‏‎(<*>)‎‏ هست.

حالا چطوری کار می‌کنه؟ اتفاقی که میوفته اینه که به ‏‎(*2)‎‏ و ‏‎(+10)‎‏ یکی یه آرگومان میدیم و دوتا جوابی که می‌گیریم، دوتا آرگومانِ ‏‎(+)‎‏ میشن:

((+) <$> (*2) <*> (+10)) 3

((3*2) + (3+10)) 

(6 + 13)

19

مواقعی از این استفاده می‌کنیم که دو تابع یه ورودیِ مشترک می‌گیرن و می‌خوایم یه تابعِ دیگه به جوابِ اون دوتا اعمال کنیم تا به یه جوابِ نهایی برسیم. خیلی بیشتر از اونکه اول‌ش به نظر میرسه لازم میشه، یک مثال ازش در کُدِ واقعی ببینیم:

module Web.Shipping.Utils ((<||>)) where

import Control.Applicative (liftA2)

(<||>) :: (a -> Bool)
       -> (a -> Bool) 
       -> a 
       -> Bool
(<||>) = liftA2 (||)

ایده همون ایده‌ی ‏‎duwop‎‏ ِه که بالاتر دیدیم.

یه مثالِ دیگه:

bbop :: Integer -> Integer
bbop = do
  a <- boop
  b <- doop
  return (a + b)

این دقیقاً همون کاری رو می‌کنه که مثالِ ‏‎Applicative‎‏ می‌کرد، فقط اینجا بافت‌ِمون مونَدی شده. این تمایز تأثیری تو این تابعِ بخصوص نداره. متغیرِ ‏‎a‎‏ رو به تابعِ نیمه اعمال‌شده ِ ‏‎boop‎‏ نسبت میدیم، و متغیرِ ‏‎b‎‏ رو به ‏‎doop‎‏. به محضِ گرفتنِ یه ورودی، جاهای خالیِ ‏‎boop‎‏ و ‏‎doop‎‏ پر میشن؛ جواب‌هاشون به ‏‎a‎‏ و ‏‎b‎‏ انقیاد و به ‏‎return‎‏ داده میشن.

پس تا اینجا دیدیم که میشه برای تابع‌های نیمه اعمال‌شده ‏‎Functor‎‏، ‏‎Applicative‎‏، و ‏‎Monad‎‏ داشته باشیم. در همه‌ی این حالت‌هاشون یک آرگومان می‌گیرن تا هر دو تابع‌شون محاسبه بشه. ‏‎Functor‎‏ ِ توابع همون ترکیب توابع ِه. ‏‎Applicative‎‏ و ‏‎Monad‎‏ علاوه بر ترکیب، آرگومان رو همزمان به همه‌ی تابع‌ها میدن (اپلیکتیو‌ها و موند‌ها هردو حالت‌های خاصی از فانکتور‌ها اند، پس در ذات‌شون اون رفتارِ فانکتوری رو دارن).

این ایده‌ی ‏‎Reader‎‏ ِه؛ راهی برای زنجیر‌کردنِ تابع‌هایی‌ه که همه‌شون منتظرِ یک ورودی از یه محیطِ مشترک‌اند. جزئیات و نحوه‌ی کارش رو الان میگیم، اما درکِ مهمی که باید ازش پیدا کنین، اینه که ‏‎Reader‎‏ راهی برای انتزاعی کردنِ اعمالِ توابع ِه؛ در نتیجه با ‏‎Reader‎‏ میشه محاسبات رو بر حسبِ آرگومانی انجام داد که هنوز تأمین نشده. کاربردش معمولاً برای مواقعی‌ه که یه مقدار ثابت رو از محیطی خارج از برنامه می‌گیریم و به عنوانِ آرگومان برای یه عالَم تابع ازش استفاده می‌کنیم. با استفاده از ‏‎Reader‎‏ دیگه لازم نیست اون آرگومان رو صراحتاً به توابع بدیم.

تمرین کوتاه: نرمش

اینجا می‌خوایم کاری مشابهِ چیزی که بالا دیدین انجام بدیم، هم برای تمرین، هم برای اینکه یه حسی از مفاهیمِ پیشِ رو پیدا کنین. در واقع انقدر شبیهِ همون‌هاست که تقریباً میشه همه‌شو کپی/پِیست کنین، پس زیادی فکر نکنین.

اول یه فایل رو با این کُدها شروع کنین:

import Data.Char

cap :: [Char] -> [Char]
cap xs = map toUpper xs

rev :: [Char] -> [Char]
rev xs = reverse xs

دو تابعِ ساده با تایپِ یکسان. میشه با ‏‎(.)‎‏ یا ‏‎fmap‎‏ با هم ترکیب ِشون کنیم:

composed :: [Char] -> [Char]
composed = undefined

fmapped :: [Char] -> [Char]
fmapped = undefined

خروجیِ هردوشون باید یکسان باشه: یه نوشته که هم همه‌ی حروف‌ش بزرگ شدن، و هم سروته شده:

Prelude> composed "Julie"
"EILUJ"
Prelude> fmapped "Chris"
"SIRHC"

حالا می‌خوایم جوابِ ‏‎cap‎‏ و ‏‎rev‎‏ رو تو یه توپل مثلِ زیر برگردونیم:

Prelude> tupled "Julie"
("JULIE","eiluJ")
-- یا
Prelude> tupled' "Julie"
("eiluJ","JULIE")

از یه ‏‎Applicative‎‏ استفاده کنین. تایپ‌ش اینطوری میشه:

tupled :: [Char] -> ([Char], [Char])

دلیلی برای موندی بودنِ چنین تابعی نیست، ولی برای تمرین این کار هم انجام بدین. یه بار با گرامر ِ ‏‎do‎‏، یه بار هم با ‏‎(>>=)‎‏ تعریف کنین. تایپ‌شون مثل تایپِ ‏‎tupled‎‏ میشه.