۲۶ - ۵میشه هسکل رو اکید کنیم؟
ببینیم میتونیم رفتار یه زبانِ اکید رو بازسازی کنیم؟ اینطوری تفاوت هسکل رو هم بهتر متوجه میشیم. تو مثالِ بالا، اینطوری میشه اکید بودن رو اضافه کنیم:
hypo' :: IO ()
hypo' = do
let x :: Integer
x = undefined
s <- getLine
case x `seq` s of
"hi" -> print x
_ -> putStrLn "hello"
اجراش چنین جوابی میده:
Prelude> hypo'
asd
*** Exception: Prelude.undefined
چرا؟ چون اون تابعِ کوچولوی seq
، خیلی جادویی محاسبهی آرگومانِ اولش رو، در صورتِ محاسبهی آرگومانِ دومش، اجبار میکنه. اون seq
رو که اضافه کردیم، یعنی هر موقع s
محاسبه بشه، x
هم باید محاسبه بشه. کمی جلوتر با جزئیات بیشتری میگیم.
اما قبل از ادامه، دقت کنین تونستیم تابعِ getLine
رو قبل از محاسبهی تهی اجرا کنیم، پس هنوز رفتارش مثل یه زبانِ اکید نشده. بطور کلی بیانیههای case محاسبه رو اجبار میکنن؛ البته واضحه، چون باید بیانیه محاسبه بشه تا case بتونه حالتهای مختلف رو براش بررسی کنه. با یه مثال کوچیک نشون میدیم:
let b = ???
case b of
True -> ...
False
اینجا b
هر چیزی که باشه، اول باید محاسبه بشه تا معلوم بشه True
میشه یا False
.
برگردیم به seq
قبل از ادامه به اکیدتر کردنِ هسکل، یه کم دیگه از seq
صحبت کنیم. اول از همه، تایپش یه کم... عجیبه:
seq :: a -> b -> b
حتماً با flip const
فرقی داره. در نسخههای قدیمیِ هسکل تایپش اینطور بوده:
seq :: Eval a => a -> b -> b
Eval
خلاصهی محاسبه به حالتِ معمولی با سرِ ضعیف (WHNF)هست، و یک متود برای اجبارِ محاسبه تأمین میکرد؛ همهی تایپهای base
یه نمونه ازش داشتن. حالا که این تایپکلاس حذف شده، دیگه برای استفاده از seq
لازم نیست یه محدودیت به تایپهای پلیمورفیک اضافه بشه و یه عالَم تغییرات اضافی رو اجبار کنه. نسبت به تهی، تابعِ seq
اینطور تعریف میشه:
seq bottom b = bottom
seq literallyAnythingNotBottom b = b
-- ^------------------------^
-- م. به معنای واقعی هرچیزی غیر از تهی
حالا چرا seq
خیلی شبیه پسرعموی const
ِه؟ چون در هسکل، محاسبه برمبنای تقاضاست، و به هیچ عنوان نمیشه تضمین کرد که یه چیزی محاسبه میشه. پس باید در بین گرهها در گرافِ بیانیهها پلهایی بکشیم تا اجبارِ یک بیانیه، یه بیانیهی دیگه رو هم اجبار کنه. یه مثال دیگه ببینیم:
Prelude> :{
*Main| let wc x z =
*Main| let y =
*Main| undefined `seq` 'y' in x
*Main| :}
Prelude> foldr wc 'z' ['a'..'e']
'a'
Prelude> foldr (flip wc) 'z' ['a'..'e']
'z'
اینجا هیچ وقت y
رو اجبار نکردیم، و متعاقباً تهی هم اجبار نکردیم. با این حال میشه یه وابستگیِ داده ِ دیگه از y
به x
درست کنیم:
Prelude> let bot = undefined
Prelude> :{
*Main| let wc x z =
*Main| let y =
*Main| bot `seq` 'y'
*Main| in y `seq` x
*Main| :}
Prelude> foldr wc 'z' ['a'..'e']
*** Exception: Prelude.undefined
Prelude> foldr (flip wc) 'z' ['a'..'e']
*** Exception: Prelude.undefined
دفعهی قبل وابستگیِ محاسباتی بین مقدارِ تهی و y
بود:
undefined `seq` 'y' in x
-- الزاماً تهی هم اجبار میکنه y اجبارِ
y -> undefined
با این تغییری که دادیم، این اتفاق میوفته:
undefined `seq` 'y' `seq` 'x'
-- هم اجبار میکنه y الزاماً x اجبارِ
-- الزاماً تهی هم اجبار میکنه y اجبارِ
x -> y -> undefined
ما بهش به چشم یه واکنشِ زنجیری نگاه میکنیم.
در واقع داریم از یه مقدار به یه مقدار دیگه یه قایق نجات میبندیم میگیم "اگه میخوای به اون برسی، اول باید از من رَد شی!" حتی میشه این هم-قایقها رو غوطهور بذاریم! نگاه کنین:
notGonnaHappenBru :: Int
notGonnaHappenBru =
let x = undefined
y = 2
z = (x `seq` y `seq` 10, 11)
in snd z
مثال بالا تهی نمیشه! این رفیقهای قایقی همینطور وسط دریا شناور موندن و هیچ یدککشِ محاسباتی نمیاد ببَردِشون.
seq
و حالت معمولی با سر ضعیف
seq
بیانیه رو تا حالت معمولی با سر ضعیف محاسبه میکنه. این رو قبلاً توضیح دادیم، اما اگه دوست دارین خیلی عمیقتر تفاوت بینِ حالت معمولی با سر ضعیف و حالت معمولی رو یاد بگیرین، به شدت کتابِ سایمون مارلو به اسمِ برنامهنویسی موازی و همزمان در هسکل رو پیشنهاد میکنیم. محاسبهی WHNF یعنی بعد از اولین دادهساز یا لامبدا متوقف میشه. بذارین این فرضیه رو امتحان کنیم!
Prelude> let dc = (,) undefined undefined
Prelude> let noDc = undefined
Prelude> let lam = \_ -> undefined
Prelude> dc `seq` 1
1
Prelude> noDc `seq` 1
*** Exception: Prelude.undefined
Prelude> lam `seq` 1
1
خیلی خوب. چیزی که غیرمنتظره نبود؟ درسته؟ اوکِی.
به خاطر اینکه dc
یه دادهساز داره، لازم نیست seq
به مقادیرِ داخل اون سازنده اهمیتی بده؛ برای حالت معمولی با سر ضعیف، محاسبهی سازنده کفایت میکنه. از طرف دیگه، noDc
بیرون از مقدارِش دادهساز یا لاندا نداره، پس هیچ سَری وجود نداره که محاسبه رو نگه داره. در آخر، lam
یه لامبدا بیرون از بیانیه داره، که در محاسبه همون تأثیری رو داره که یه دادهساز داره.
تطبیق ِ case هم محاسبات رو زنجیر میکنه
بدونِ seq
هم این اجبار کردن اتفاق میوفته! برای مثال، وقتی روی یه چیزی تطبیق ِ case یا الگو انجام میدین، دارین مقداری که روش تطبیق انجام دادین رو اجبار میکنین، چون باید تا عمقی که دادهسازها رو براش تعیین کردین بشناسه تا بتونه تطبیق انجام بده. یه مثال ببینیم:
data Test =
A Test2
| B Test2
deriving (Show)
data Test2 =
C Int
| D Int
deriving (Show)
forceNothing :: Test -> Int
forceNothing _ = 0
forceTest :: Test -> Int
forceTest (A _) = 1
forceTest (B _) = 2
forceTest2 :: Test -> Int
forceTest2 (A (C i)) = i
forceTest2 (B (C i)) = i
forceTest2 (A (D i)) = i
forceTest2 (B (D i)) = i
اول forceNothing
رو تست میکنیم:
Prelude> forceNothing undefined
0
Prelude> forceNothing (A undefined)
0
هیچ وقت تهی نمیشه چون هیچ چیز رو اجبار نمیکنه. در واقع یه مقدارِ ثابته که آرگومانش رو میندازه زمین. forceTest
چطور؟
Prelude> forceTest (A undefined)
1
Prelude> forceTest (B undefined)
2
Prelude> forceTest undefined
*** Exception: Prelude.undefined
فقط زمانی تهی میشه که مقدارِ Test
ِ بیرونی تهی باشه، دلیلش هم اینه که تنها مقداری که روش تطبیق الگو انجام میدیم اون دادهساز ِه. میرسیم به forceTest2
:
Prelude> forceTest2 (A (C 0))
0
Prelude> forceTest2 (A (C undefined))
*** Exception: Prelude.undefined
Prelude> forceTest2 (A undefined)
*** Exception: Prelude.undefined
Prelude> forceTest2 undefined
*** Exception: Prelude.undefined
خدمتِ شما: بیرون -> داخل.
روگرفت حافظه
منظور اون روگرفتِ حافظه که ممکنه فکر کنین نیست. اینجا منظور اون زبانِ پشتپردهایه که کامپایلر بعد از تلخ کردنِ کدِمون، GHC Haskell رو بهش ساده میکنه.
اولین راهی که برای بررسیِ اکید بودن داشتیم، تزریقِ تهی به بیانیهها و مشاهدهی محاسبهشون بود. تزریقِ تهی باعث میشه به وضوح ببینیم چه چیزی اکیداً حساب میشه و چه چیزی نمیشه. راه دوم برای بررسیِ اکید بودن در هسکل، بررسیِ GHC Core ِه.
با این مثال کار میکنیم:
module CoreDump where
discriminatory :: Bool -> Int
discriminatory b =
case b of
False -> 0
True -> 1
اینطوری توی GHC بارگذاریش کنین:
Prelude> :set -ddump-simpl
Prelude> :l code/coreDump.hs
[1 of 1] Compiling CoreDump
================ Tidy Core ==============
... یه کم شلوغی ...
بعد باید این GHC Core رو به عنوان خروجی بگیرین:
discriminatory :: Bool -> Int
[GblId, Arity=1,
Caf=NoCafRefs,
Str=DmdType]
discriminatory =
\ (b_aZJ :: Bool) ->
case b_aZJ of _ [Occ=Dead] {
False -> GHC.Types.I# 0;
True -> GHC.Types.I# 1
}
رک بگیم: GHC Core یه کم زشته. با این حال راههایی هست که یه کم تروتمیز بشه. یکیش استفاده از پرچم ِ -dsuppress-all
ِه:
Prelude> :set -dsuppress-all
Prelude> :r
دقت کنین ممکنه برای اینکه بارگذاریِ مجدد رو اجبار کنین لازم بشه اول فایل رو یه دستکاری بکنین. حالا خروجی این میشه:
discriminatory
discriminatory =
\ b_aZY ->
case b_aZY of _ {
False -> I# 0;
True -> I# 1
}
یه ذره خواناتر شد. با این زبانِ Core ِ سادهتر، راحتتر میشه فهمید که دقیقاً کِی یه چیزی محاسبه میشه. برای سادگی، یکی از مثالهای قبلی رو بازبینی کنیم:
forceNothing _ = 0
تو Core این شکلی میشه:
forceNothing = \ _ -> I# 0#
به خاطر اینکه بیانیههای case باید محاسبه بشن، اینجا تو GHC Core دنبال بیانیههای case میگردیم تا ببینیم کجای کدِمون اکیدی داره. اینجا هیچ case ای نیست، پس هیچ چیز رو اجبار نمیکنه. اون I# 0#
ارائهی پشتپردهی لفظ ِ Int
ِه، که توی GHC Core معلوم شده. نمایش رو ادامه میدیم!
ببینیم Core برای forceTest
چه شکلیه:
forceTest =
\ ds_d2oX ->
case ds_d2oX of _ {
A ds1_d2pI -> I# 1#;
B ds1_d2pJ -> I# 2#
}
از روی GHC Core ِ این تابع میشه دید که یک مقدار رو اجبار میکنه: بیرونیترین دادهساز ِ تایپِ Test
رو. به محتویات اون دادهسازها اسمهایی داده شده، اما هیچ وقت استفاده نشدن پس محاسبه هم نمیشن.
forceTest2 =
\ ds_d2n2 ->
case ds_d2n2 of _ {
A ds1_d2oV ->
case ds1_d2oV of _ {
C i_a1lo -> i_a1lo;
D i_a1lq -> i_a1lq
};
B ds1_d2oW ->
case ds1_d2oW of _ {
C i_a1lp -> i_a1lp;
D i_a1lr -> i_a1lr
}
}
بیرونبودن و داخلبودن با forceTest2
بهتر معلومه. در بخش بیرونیِ تابع، همون کاری که در forceTest
کردیم رو انجام دادیم، با این تفاوت که اینجا محتویاتِ دادهساز ِ Test
ِ بیرونی هم اجبار کردیم. تابع میتونه چهار جواب داشته باشه که تهی نیستن، و اگه بهش تهی پاس نشه، همیشه دوبار اجبار میکنه – یه بار برای Test
و یه بار برای Test2
. محتویاتِ Test2
رو برمیگردونه، اما خودش هیچ وقت اونها رو اجبار نمیکنه.
در Core، همیشه چیزی که روش case میشه، محاسبه میشه – حتی اگه هیچ تطبیق الگویی انجام نشه – اما در خودِ هسکل، مقادیر وقتی اجبار میشن که روی دادهسازها منطبق میشن. اگه دوست دارین با بهرهگیری از Core کاراییِ کدِ هسکلتون یا رفتارش رو بهتر درک کنین، پیشنهاد میکنیم مستنداتِ GHC در خصوص Core رو بخونین.
حالا با استفاده از اینها یه چیز رو بررسی کنیم:
discriminatory :: Bool -> Int
discriminatory b =
let x = undefined
case b of
False -> 0
True -> 1
برای این Core چه شکلیه؟
discriminatory
discriminatory =
\ b_a10c ->
case b_a10c of _ {
False -> I# 0;
True -> I# 1
}
با این دوز و کلکها نمیشه GHC رو گول زد! میدونه هیچ وقت x
رو محاسبه نمیکنیم، اون هم ولش میکنه. اگه مجبورش کنیم اول x
رو محاسبه کنه بعد b
چطور؟
discriminatory :: Bool -> Int
discriminatory b =
let x = undefined
in case x `seq` b of
False -> 0
True -> 1
به زبانِ Core:
discriminatory =
\ b_a10D ->
let {
x_a10E
x_a10E = undefined } in
case
case x_a10E of _ {
__DEFAULT -> b_a10D
} of _ {
False -> I# 0;
True -> I# 1
}
حالا دوتا بیانیهی case وجود داره. یکیشون توی اون یکی تودرتو شده تا به ناچار محاسبهی x
قبل از محاسبهی b
انجام بشه. seq
اینطوری کدتون رو تغییر میده.
یه تفاوت در Core
در هسکل، تطبیق ِ case (یا حداقل تطبیقِ الگوش) تا WHNF اکید ِه. در Core، caseها همیشه به WHNF اکید اند. این تفاوتی نیست که به نظر مهم بیاد، اما زمانهایی وجود دارن که این تفاوت اهمیت پیدا میکنه. این بیانیه توی هسکل تهی نمیشه:
case undefined of { _ -> False}
وقتی این کد به Core تبدیل میشه، تشخیص میده که از تطبیق ِ case برای هیچ کاری استفاده نکردیم و کل بیانیهی case رو میندازه، و به یه دادهساز ِ False
ساده میشه.
با اینکه این بیانیهی Core از لحاظِ گرامری شبیهِ کدِ هسکلِ بالاست، اما این تهی میشه:
case undefined of { DEFAULT -> False}
case در Core اکید ِه، حتی اگه فقط یه دونه باشه و روی چیزی انطباق انجام نده. Core و هسکل یک زبان نیستن، اما اگه زمانی خواستین بدونین آیا دوتا بیانیه در هسکل یکساناند یا نه، یه راهِ قطعیش اینه که Core رو بررسی کنین.
حالا یه کم اکیدتَر
بعد از گشتی که تو سرزمین عجایب زدیم (!) برگردیم به اصل موضوع: هنوز نتونستیم رفتار تابعِ hypo
در یه زبانِ اکید رو به درستی شبیهسازی کنیم؛ آخرین بار بیانیهش تا نصفه محاسبه شد. s
رو محاسبه کردیم که اون هم x
(همون چیزی که منجر به استثنا میشد) رو اجبار کرد. اگه یه زبان اکید بود، حتی s
رو هم محاسبه نمیکرد، چون اول باید x
رو محاسبه میکرد.
چی کار کنیم این برنامهی هسکلمون همون کاری که یه زبانِ اکید میکرد رو انجام بده؟
hypo'' :: IO ()
hypo'' = do
let x :: Integer
x = undefined
s <- x `seq` getLine
case s of
"hi" -> print x
_ -> putStrLn "hello"
دقت کنین seq
رو به زودترین جای ممکن در اجراییه ِ IO
بردیم. این دیگه بدونِ اجازه میترکه:
Prelude> hypo''
*** Exception: Prelude.undefined
دلیلش اینه که قبل از محاسبهی getLine
(که اثر ِ "منتظر ورودیِ کاربر بودن" رو پیاده میکرد)، محاسبهی تهی رو اجبار کردیم. با اینکه این کد در ظاهر رفتارِ یه زبانِ اکید رو تداعی کرد، اما اگه میخواست دقیقاً مثل اون باشه، باید خطا در زمان ساخته شدنِ تهی داده میشد. تا زمانی که مسیرِ محاسباتِ برنامه به یه بیانیه نخوره، امکان نداره اون بیانیه محاسبه بشه.
تمرینها: محاسبه کن
هر بیانیه رو با بیشترین جزئیات ممکن گسترش بدین. بعد از بیرون به داخل پیش برین و ببینید به چه چیزی ساده میشه.
۱.
const 1 undefined
۲.
const undefined 1
۳.
flip const undefined 1
۴.
flip const 1 undefined
۵.
const undefined undefined
۶.
foldr const 'z' ['a'..'e']
۷.
foldr (flip const) 'z' ['a'..'e']