۵ - ۴کاری کردن یا currying
مثل جبر لاندا، تمامی توابع در هسکل، فقط یک آرگومان میگیرن، و فقط یک جواب برمیگردونن. اگه با زبانهای برنامهنویسیِ دیگه تجربه دارین، میدونین که توی او زبانها، اکثراً امکان تعریفِ توابعی که چند آرگومان قبول میکنن وجود داره. اما هسکل اینطور نیست. در عوض، هسکل در گرامر ِش تسهیلاتی رو ارائه میده که ساختِ توابعِ کاری شده به طور پیشفرض انجام میشه. به تودرتو کردنِ چند تابعی که هر کدوم یه آرگومان میگیرن و یه جواب میدن، کاری کردن میگیم. با این کار میتونیم توابعی که چند آرگومان میگیرن رو تداعی کنیم.
فلِشهایی که در تایپ سیگنچرها دیدیم، تایپِ تابع رو نشون میدن. بالاتر تعریف تایپِش رو دیدیم:
data (->) a b
یه تابع، یک ورودی (a
) لازم داره تا تابع به اون اعمال بشه، و یک جواب (b
) برگردونه. هر یک فلش در تایپ سیگنچر معادل یه آرگومان و یه جواب ِه (که تایپِ نهایی همون جواب نهاییه). حالا اگه تابعی با چند آرگومان لازم دارین، اون b
میتونه یه تابع دیگه باشه (البته a
هم میتونه یه تابع دیگه باشه). اون موقع چندتا تابعِ تودرتو دارین، درست مثل تجریدهای لاندا که چند سَر داشتن.
با بررسیِ جزبهجزء تابعِ جمع (که چند آرگومان لازم داره)، این قضیه رو بیشتر توضیح میدیم:
(+) :: Num a => a -> a -> a
-- | 1 |
(+) :: Num a => a -> a -> a
-- | 2 |
(+) :: Num a => a -> a -> a
-- [3]
۱.
محدودیتِ تایپکلاسی که میگه a
باید یه نمونه از Num
داشته باشه. جمع در تایپکلاسِ Num
تعریف شده.
۲.
ممکنه بازهی نشون داده شده به نظر پارامترهای تابعِ (+)
بیان، ولی همهی توابع هسکل یه آرگومان میگیرن و یه جواب میدن. برای اینکه توابع هسکل بتونن "چند" آرگومان بگیرن، تودرتو میشن، مثل عروسکهای ماتروشکا. طریقهای که نوعساز ِ (->)
برای توابع کار میکنه اینه که a -> a -> a
اعمال چند تابعِ پشت سر هم رو نشون میده، که هر کدوم یه آرگومان میگیرن و یه جواب برمیگردونن. با این تفاوت که بیرونیترین لایه یه تابعِ دیگه برمیگردونه، و اون تابع، آرگومان بعدی رو میگیره. به این میگیم کاری کردن.
۳.
این تایپِ جواب برای این تابعه؛ یه عدد از همون تایپِ دو ورودی.
نحوهی تعریفِ نوعساز ِ توابع، (->)
، باعث میشه کاری کردن خودبهخود اتفاق بیوفته – به خاطرِ میانوند بودن و شرکتپذیری از راست. به خاطرِ همین شرکتپذیری از راست، تایپها در واقع اینطوری پرانتزگذاری میشن:
f :: a -> a -> a
-- معادل
f :: a -> (a -> a)
یه مثال دیگه:
map :: (a -> b) -> [a] -> [b]
-- معادل
map :: (a -> b) -> ([a] -> [b])
هر فلش یک ورودی و یک خروجی رو نشون میده. پس اینجا شرکتپذیری، یا دستهبندی با پرانتزها، برای کنترلِ تقدم یا ترتیبِ محاسبه نیست؛ بلکه فقط برای گروه کردن پارامترها به آرگومان و جوابه. از طرف دیگه، فلِشها همشون تقدم ِ یکسان دارن، و همین باعث میشه شرکتپذیری تأثیری روی ترتیب محاسبات نذاره.
به خاطر داشته باشین که هر وقت یه بیانیهی لاندا که ظاهراً دو تا پارامتر داره دیدین، در واقع دو تا لاندای تودرتو دیدین. اعمال اون بیانیه به یه آرگومان، یه تابعِ دیگه برمیگردونه که منتظرِ اعمال به یه آرگومان دومه. بعد از اعمالِ اون تابع به آرگومانِ دوم، جواب نهایی حاصل میشه. البته لانداهای بیشتری هم میشه تودرتو کرد، ولی پروسه تغییری نمیکنه: یه آرگومان، یه جواب؛ که اون جواب ممکنه تابعی باشه که یه آرگومانِ دیگه میگیره.
این چیزها که از جبر لاندا گفتیم، برای نوعساز ِ توابع هم صادق اند؛ فقط زبانِ متفاوتی دارن. در هسکل هم وقتی "دو آرگومان" داریم، و تابعمون رو به یکی از اونها اعمال میکنیم، جوابی پس میگیریم که خودش یه تابعِ دیگهست و باید به یه آرگومانِ دومی اعمال بشه؛ درست مثل بیانیههای لاندا.
گاهی اوقات برای نشون دادن ترتیب محاسبه، از پرانتزگذاریِ صریح استفاده میشه، مثل وقتهایی که پارامترِ ورودی خودش یه تابعه (مثلِ تابع map
در بالا)، ولی شرکتپذیری ِ ضمنی ِ تایپِ تابع (که از راست دستهبندی میکنه) باعث نمیشه که داخلترین یا آخرین پرانتز (به کلام دیگه، تایپِ جواب)، اول حساب بشه. اعمال توابع معادل با محاسبهست؛ به عبارت دیگه، تنها راه محاسبهی هر چیز، با اعمالِ توابعه، و اعمالِ توابع هم شرکتپذیری از چپ داره. پس با فرض اینکه قرار به محاسبهی چیزی باشه (از اونجا که هسکل نااَکید حساب میکنه، نمیشه فرض کرد که همه چیز آناً حساب میشه، بعداً بیشتر توضیح میدیم)، چپترین یا بیرونترین آرگومان، اول حساب میشه.
اعمالِ ناقص
کاری کردن برای خیلیها جالبه، ولی اکثراً ارزش و فایدهی عملیِ اون رو نمیدونن. اینجا به روشی به اسمِ اعمالِ ناقص نگاه میندازیم تا فایدهی کاری کردن رو ببینیم. چیزیه که در طول کتاب هم بیشتر بررسی میکنیم.
تخصیصِ تایپ رو با دو تا دونقطه انجام میدیم. معیّن کردنِ یه تایپ، محدودیتِ تایپکلاسی رو حذف میکنه:
addStuff :: Integer -> Integer -> Integer
addStuff a b = a + b + 5
تابع addStuff
در ظاهر دو آرگومان Integer
میگیره و یه جواب Integer
برمیگردونه. ولی میتونیم تو GHCi ببینیم که یه آرگومان میگیره و یه تابعی برمیگردونه که که اون هم یه آرگومان میگیره و یه جواب برمیگردونه.
Prelude> :t addStuff
addStuff :: Integer -> Integer -> Integer
Prelude> let addTen = addStuff 5
Prelude> :t addTen
addTen :: Integer -> Integer
Prelude> let fifteen = addTen 5
Prelude> fifteen
15
Prelude> addTen 15
25
Prelude> addStuff 5 5
15
تو این مثال، fifteen
با addStuff 5 5
برابره، چون addTen
مساوی با addStuff 5
ِه. به این قابلیتِ اعمالِ فقط چندتا از آرگومانهای یه تابع، اعمالِ ناقص میگیم.
وقتی بدونیم که (->)
یک نوعساز ِه و شرکتپذیری از راست داره، این قابلیت واضحتر میشه:
addStuff :: Integer -> Integer -> Integer
-- با پرانتزگذاری صریح
addStuff :: Integer -> (Integer -> Integer)
اعمالِ addStuff
به یک آرگومانِ Integer
، تابع addTen
رو به ما داد، که تابعِ خروجی از addStuff
ِه. اعمالِ addTen
هم به یک آرگومانِ Integer
، یه مقدار رو خروجی میده، در نتیجه fifteen
تایپِ Integer
داره – دیگه تابع نیست.
حالا با یه تابعی که جابجاییپذیری نداره امتحان کنیم:
subtractStuff :: Integer
-> Integer
-> Integer
subtractStuff x y = x - y - 10
subtractOne = subtractStuff 1
Prelude> :t subtractOne
subtractOne :: Integer -> Integer
Prelude> let result = subtractOne 11
Prelude> result
-20
چرا این جواب؟ به خاطر ترتیبی که آرگومانها رو اعمال کردیم. result
با 1 - 11 - 10
برابره.
کاری کردن و کاریِ معکوس به صورتِ دَستی
هسکل بطورِ پیشفرض کاری میکنه، ولی امکانِ کاریِ معکوس ِ توابع هم وجود داره. معنیِ کاریِ معکوس اینه که توابع رو از داخل هم خارج کنیم، و مثلاً دو تابع رو با یه توپل ای از دو مقدار جابجا کنیم (مقادیرِ داخل توپل، همون آرگومانها میشن). اگه (+)
رو کاریِ معکوس کنیم، تایپِش از Num a => a -> a -> a
به Num a => (a, a) -> a
تغییر میکنه، که سازگاریِ خیلی بیشتری با تعریف "دو آرگومان میگیره، یک جواب برمیگردونه" داره. بعضی زبانهای تابعی ِ قدیمیتر، حالتِ پیشفرض ِشون برای توابعِ چند آرگومانی همین رَوِشِه (استفاده از یک تایپِ ضرب مثل توپل).
توابعی که کاریِ معکوس شدن: یک تابع، چند آرگومان.
توابعی که کاری شدن: چند تابع، هر کدوم یک آرگومان.
اگر هم بخواین، خودتون میتونین با تودرتو کردنِ آرگومانها در لانداها، کاری کردن ِ اتوماتیک رو تلخ کنید. البته نیاز به چنین کاری خیلی کم پیش میاد.
اینجا با گرامر ِ لاندای بینام چند مثال از کاریِ معکوس میزنیم. گرامر ِ توابع بینام در هسکل رو در زیر نوشتیم. میتونین اون خط موربِ وارو رو به چشمِ یه کاراکترِ لاندا ببینین:
\x -> x -- تابع همانی
nonsense :: Bool -> Integer
nonsense True = 805
nonsense False = 31337
curriedFunction :: Integer -> Bool -> Integer
curriedFunction i b = i + (nonsense b)
uncurriedFunction :: (Integer, Bool)
-> Integer
uncurriedFunction (i, b) =
i + (nonsense b)
anonymous :: Integer -> Bool -> Integer
anonymous = \i b -> i + (nonsense b)
anonNested :: Integer -> Bool -> Integer
anonNested =
\i -> \b -> i + (nonsense b)
اگه در REPL تست کنیم:
Prelude> curriedFunction 10 False
31347
Prelude> anonymous 10 False
31347
Prelude> anonNested 10 False
31347
همه یه تابعاند و همهشون یک جواب میدن. در anonNested
، خودمون دستی لانداهای بینام رو تودرتو کردیم تا یه تابع که مفهوم یکسانی با curriedFunction
داشت بدست آوردیم، با این تفاوت که از کاری کردن ِ اتوماتیک استفاده نکردیم. نتیجهای که میگیریم اینه که توابعی که به نظر چند آرگومانی میان، مثل a -> a -> a
در حقیقت توابع سطح بالا اند: تا زمانی که هیچ نوعساز ِ (->)
ای باقی نَمونه، با اعمال هر آرگومان، مقادیر تابعیِ بیشتری میدن، و نهایتاً یه مقدار غیرِ تابع رو به عنوان جواب برمیگردونن.
کاری و کاریِ معکوس کردنِ توابعِ از پیش تعریف شده
عموماً توابعِ چند پارامتری رو میشه بدونِ نوشتن کدِ جدید برای هر کدوم، کاری و کاریِ معکوس کرد. به مثال زیر برای کاری کردن نگاه کنید:
Prelude> let curry f a b = f (a, b)
Prelude> :t curry
curry :: ((t1, t2) -> t) -> t1 -> t2 -> t
Prelude> :t fst
fst :: (a, b) -> a
Prelude> :t curry fst
curry fst :: t -> b -> t
Prelude> fst (1, 2)
1
Prelude> curry fst 1 2
1
و مثال برای کاریِ معکوس:
Prelude> let uncurry f (a, b) = f a b
Prelude> :t uncurry
uncurry :: (t1 -> t2 -> t) -> (t1, t2) -> t
Prelude> :t (+)
(+) :: Num a => a -> a -> a
Prelude> (+) 1 2
3
Prelude> uncurry (+) (1, 2)
3
کاری کردن و کاریِ معکوس ِ توابع با سه پارامتر یا بیشتر هم کاملاً شدنیِه، ولی یه ذره سختتره. ما تو این کتاب انجام نمیدیم، ولی اگه دوست داشتین، خودتون امتحان کنید.
بخشبندی
در فصل ۲ به بخشبندی اشاره کردیم، اما حالا که از کاری کردن و اعمالِ ناقص صحبت کردیم، احتمالاً طرز کارِ بخشبندی هم روشنتر میشه. بخشبندی همون اعمالِ ناقص ِ عملگرهای میانوند ِه، که به خاطر گرامر ِ خاصِش، امکانِ انتخابِ اینکه آرگومانِ دادهشده، برای پارامترِ اول یا دومِ عملگر باشه وجود داره:
Prelude> let x = 5
Prelude> let y = (2^)
Prelude> let z = (^2)
Prelude> y x
32
Prelude> z x
25
با توابع جابجاییپذیر مثل جمع، ترتیب آرگومانها اهمیتی ندارن (معمولاً تابع جمع رو به شکل (+3)
بخشبندی میکنیم)، ولی وقتی شروع به استفادهی مکرر از توابع نیمه اعمال شده با map ها، fold ها، و غیره بکنیم، اهمیتِ ترتیب آرگومانها در بخشبندی ِ توابعِ بدونِ جابجاییپذیری، واضحتر میشه.
البته بخشبندی فقط برای حساب به کار نمیره:
Prelude> let celebrate = (++ " woot!")
Prelude> celebrate "naptime"
"naptime woot!"
Prelude> celebrate "dogs"
"dogs woot!"
با گرامر ای که قبلاً گفتیم (گذاشتنِ توابع بین دو تا اَکسان گراو) میتونین توابعِ پیشوندی رو میانوندی کنین، و از بخشبندی برای اونها هم استفاده کنین (دقت کنین در مثالهای زیر، دو تا نقطهها (..
) یه خلاصهنویسی برای ساخت لیست با همهی المانهای بین اولین و آخرین مقدارِ داده شدهست – یه کم تو REPL باهاش بازی کنین):
Prelude> elem 9 [1..10]
True
Prelude> 9 `elem` [1..10]
True
Prelude> let c = (`elem` [1..10])
Prelude> c 9
True
Prelude> c 25
False
اگه elem
رو در فرمِ پیشوندی ِ معمولِش اعمالِ ناقص میکردین، به ناچار elem
رو به آرگومانِ اولِش اعمال میکردین:
Prelude> let hasTen = elem 10
Prelude> hasTen [1..9]
False
Prelude> hasTen [5..15]
True
اعمالِ ناقص انقدر تو هسکل زیاده، که در طولِ زمان، غریزی میشه. وجودِ گرامر ِ بخشبندی آزادی بیشتری در انتخاب آرگومانی که یه عمل دوتایی به اون اعمال میشه در اختیار میذاره.
تمرینها: آرگومانهای تایپی
با تابعِ داده شده و تایپِش، بگین از اعمالِ همه یا بعضی از آرگومانهاش چه تایپی حاصل میشه.
جوابتون رو میتونین اینطوری تو REPL چک کنین (با سؤال اول برای نمونه):
Prelude> let f :: a -> a -> a -> a; f = undefined
Prelude> let x :: Char, x = undefined
Prelude> :t f x
یکی از امکاناتِ جالب در هسکل اینه که میتونین تایپِ چیزها رو بدون اینکه تعریف شده باشن بررسی کنین. فقط لازمه که تایپ سیگنچر ِشون رو به undefined
بدین.
۱.
اگه a -> a -> a -> a
تایپِ f
باشه، و Char
تایپِ x
باشه، تایپِ f x
چیه؟
a)
Char -> Char -> Char
b)
x -> x -> x -> x
c)
a -> a -> a
d)
a -> a -> a -> Char
۲.
اگه a -> b -> c -> b
تایپِ g
باشه، تایپِ g 0 'c' "woot"
چیه؟
a)
String
b)
Char -> String
c)
Int
d)
Char
۳.
اگه (Num a, Num b) => a -> b -> b
تایپِ h
باشه، بیانیهی h 1.0 2
چه تایپی داره؟
a)
Double
b)
Integer
c)
Integral b => b
d)
Num b => b
دقت کنید به خاطرِ اینکه متغیرهای تایپ ِ a
و b
متفاوتاند، کامپایلر هم باید فرض کنه که تایپها ممکنه فرق کنن.
۴.
اگه (Num a, Num b) => a -> b -> b
تایپِ h
باشه، بیانیهی h 1 (5.5 :: Double)
چه تایپی داره؟
a)
Integer
b)
Fractional b => b
c)
Double
d)
Num b => b
۵.
اگه (Ord a, Eq b) => a -> b -> a
تایپِ jackal
باشه، بیانیهی jackal "keyboard" "has the word jackal in it"
چه تایپی داره؟
a)
[Char]
b)
Eq b => b
c)
b -> [Char]
d)
b
e)
Eq b => b -> [Char]
۶.
اگه (Ord a, Eq b) => a -> b -> a
تایپِ jackal
باشه، بیانیهی jackal "keyboard"
چه تایپی داره؟
a)
b
b)
Eq b => b
c)
[Char]
d)
b -> [Char]
e)
Eq b => b -> [Char]
۷.
اگه (Ord a, Num b) => a -> b -> a
تایپِ kessel
باشه، بیانیهی kessel 1 2
چه تایپی داره؟
a)
Integer
b)
Int
c)
a
d)
(Num a, Ord a) => a
e)
Ord a => a
f)
Num a => a
۸.
اگه (Ord a, Num b) => a -> b -> a
تایپِ kessel
باشه، بیانیهی kessel 1 (2 :: Integer)
چه تایپی داره؟
a)
(Num a, Ord a) => a
b)
Int
c)
a
d)
Num a => a
e)
Ord a => a
f)
Integer
۹.
اگه (Ord a, Num b) => a -> b -> a
تایپِ kessel
باشه، بیانیهی kessel (1 :: Integer) 2
چه تایپی داره؟
a)
Num a => a
b)
Ord a => a
c)
Integer
d)
(Num a, Ord a) => a
e)
a