۱۶ - ۴بریم سراغ f

همونطور که بالاتر گفتیم، در طولِ تعریفِ تایپکلاسِ ‏‎Functor‎‏، ‏‎f‎‏ باید همون ‏‎f‎‏ بمونه، و باید به تایپی اشاره کنه که فانکتور داره. در این بخش عواقبِ عملی از این مشخصات رو با جزئیات بررسی می‌کنیم.

اولین چیزی که می‌دونیم اینه که ‏‎f‎‏ اینجا باید گونه‌ش ‏‎* -> *‎‏ باشه. در فصل‌های قبل از تایپ‌های گونه‌بالا صحبت کردیم، و به خاطر داریم که یه ثابتِ تایپ، یا تایپِ معین، گونه ِ ‏‎*‎‏ داره. یه تایپ با گونه ِ ‏‎* -> *‎‏، منتطرِ اعمال شدن به یه ثابتِ تایپ با گونه ِ ‏‎*‎‏ هست.

به دو دلیل می‌دونیم که گونه ِ ‏‎f‎‏ در تعریفِ ‏‎Functor‎‏ باید ‏‎* -> *‎‏ باشه، که اول توصیف می‌کنیم و بعد هم نشون میدیم:

۱.

هر آرگومان ( و جواب) در تایپ سیگنچر ِ یه تابع باید یه تایپِ تماماً اعمال‌شده باشه. هر آرگومان باید کایند ِ ‏‎*‎‏ داشته باشه.

۲.

تایپِ ‏‎f‎‏ در دوجا به یه آرگومان اعمال شده بود: ‏‎f a‎‏ و ‏‎f b‎‏. چون ‏‎f a‎‏ و ‏‎f b‎‏ هرکدوم باید کایند ِ ‏‎*‎‏ داشته باشن، پس خودِ ‏‎f‎‏ باید گونه ِ ‏‎* -> *‎‏ باشه.

دیدنِ مفهومِ اینها در عمل خیلی راحت‌تره، ما هم با یه عالَم کُد نشون میدیم.

ای ستاره‌ی درخشان، پرده ز رخسار بردار

همه‌ی آرگومان‌های نوع‌ساز ِ ‏‎->‎‏ باید از گونه ِ ‏‎*‎‏ باشن. اگه کایند ِ نوع‌ساز ِ تابع رو استعلام کنیم، معلوم میشه:

Prelude> :k (->)
(->) :: * -> * -> *

جواب و همه‌ی آرگومان‌های هر تابع باید ثابتِ تایپ باشن، نه یه نوع‌ساز. با توجه به این موضوع، از روی تابعِ ‏‎fmap‎‏، یه چیزی در موردِ ‏‎Functor‎‏ واضح میشه:

class Functor f where
  fmap :: (a -> b) -> f a -> f b
-- کایند:    *    ->  *  ->  *

تایپ سیگنچر ِ ‏‎fmap‎‏ میگه ‏‎f‎‏ که در تعریفِ کلاسِ ‏‎Functor‎‏ معرفی شده، باید یک آرگومانِ تایپی قبول کنه، و در نتیجه از گونه ِ ‏‎* -> *‎‏ باشه. چنین چیزی رو میشه بدون شناختنِ تایپکلاس هم تشخیص داد. با چندتا تایپکلاسِ بی‌معنی میشه نشون داد:

class Sumthin a where
  s :: a -> a

class Else where
  e :: b -> f (g a b c) 

class Biffy where
  slayer :: e a b
         -> (a -> c)
         -> (a -> d)
         -> e c d

حالا بازشون می‌کنیم:

class Sumthin a where
  s :: a -> a
--    [1]  [1]

۱.

تایپِ آرگومان و جواب هردو یه ‏‎a‎‏ اند. چیزِ دیگه‌ای هم نیست، پس ‏‎a‎‏ دارای گونه ِ ‏‎*‎‏ هست.

class Else where
  e :: b  -> f   (g a b c)
--    [1]   [2]  [3]

۱.

این ‏‎b‎‏، مشابهِ ‏‎a‎‏ در مثالِ قبل، به تنهایی اولین آرگومانِ ‏‎(->)‎‏ هست، پس کایند ِش میشه ‏‎*‎‏.

۲.

اینجا ‏‎f‎‏ بیرونی‌ترین نوع‌ساز برای تایپ ِآرگومانِ دوم (تایپِ جواب) از ‏‎(->)‎‏ هست. یک آرگومان می‌گیره، همون تایپِ ‏‎g a b c‎‏ که تو پرانتزه. پس گونه ِ ‏‎f‎‏ میشه ‏‎* -> *‎‏.

۳.

و ‏‎g‎‏ به سه آرگومانِ ‏‎a‎‏، ‏‎b‎‏، و ‏‎c‎‏ اعمال شده. در نتیجه کایند ِش میشه ‏‎* -> * -> * -> *‎‏.

-- استفاده از :: برای کایند سیگنچر
g :: * -> * -> * -> *

-- همه * هست ،c و ،b ،a کایندِ

g :: * -> * -> *  ->    *
g    a    b    c    (g a b c)
class Biffy where
  slayer :: e a b
--         [1]
         -> (a -> c)
--          [2]  [3]
         -> (a -> d)
         -> e c d

۱.

اول از همه، ‏‎e‎‏ یکی از آرگومان‌های ‏‎(->)‎‏ ِه، پس اعمال به آرگومان‌های خودش باید گونه ِ ‏‎*‎‏ رو نتیجه بده. با توجه به این موضوع، و همچنین اینکه می‌بینیم خودش دوتا آرگومان داره، ‏‎a‎‏ و ‏‎b‎‏، پس مشخص میشه که کایند ِش ‏‎* -> * -> *‎‏ هست.

۲.

این ‏‎a‎‏ یکی از آزگومان‌های یه تابع‌ست، و خودش هیچ آرگومانی نمی‌گیره، پس کایند ش میشه ‏‎*‎‏.

۳.

داستانِ ‏‎c‎‏ درست مثل ‏‎a‎‏ ِه، فقط تو یه نقطه‌ی دیگه از همون تابع قرار گرفته.

بررسیِ کایندها برای مثال‌های بعدی شکست می‌خوره:

class Impish v where
  impossibleKind :: v -> v a

class AlsoImp v where
  nope :: v a -> v

اسمِ متغیر در یه تعریفِ تایپکلاس قبل از ‏‎where‎‏، همه‌ی موردهای دیگه از اون متغیر در تمامِ تعریف رو به اون اسم مقیّد می‌کنه. GHC متوجه میشه که ‏‎v‎‏ بعضی اوقات آرگومان داره و بعضی اوقات نداره، و اگه چنین چرندیاتی بهش بدیم، مُچ‌مون رو می‌گیره:

‘v’ is applied to too many type arguments
In the type ‘v -> v a’
In the class declaration for ‘Impish’

Expecting one more argument to ‘v’
Expected a type, but ‘v’ has kind ‘k0 -> *’
In the type ‘v a -> v’
In the class declaration for ‘AlsoImp’

GHC علاوه بر استنتاجِ تایپ، استنتاجِ کایند هم داره. و مشابهِ تایپ‌ها، نه تنها کایندها رو استنتاج می‌کنه، بلکه یکپارچگی و منطقی بودن‌شون رو هم بررسی می‌کنه.

تمرین‌ها: مهربون باشین

در هرکدوم از تایپ سیگنچر‌های زیر، کایند ِ همه‌ی متغیرهای تایپ رو پیدا کنید:

۱.

کایند ِ ‏‎a‎‏ چیه؟

a -> a

۲.

کایند ِ ‏‎b‎‏ و ‏‎T‎‏ چی‌اند؟ (‏‎T‎‏ عمداً بزرگ نوشته شده!)

a -> b a -> T (b a)

۳.

کایند ِ ‏‎c‎‏ چیه؟

c a b -> c b a

ستاره‌ای درخشان برای نگاه تو

خوب اگه تایپ‌مون گونه‌بالا نباشه چطور؟ با یه ثابتِ تایپ امتحان کنیم ببینیم چی میشه:

-- functors1.hs

data FixMePls =
    FixMe
  | Pls
  deriving (Eq, Show)

instance Functor FixMePls where
  fmap =
    error "مهم نیست، کامپایل نمیشه"

دقت کنین هیچ‌جا آرگومانِ تایپی وجود نداره– همه‌شون تک ستاره‌ی درخشان (و مهربون!) اند. اگه این فایل رو از GHCi بارگذاری کنیم، چنین پیغامی می‌گیریم:

Prelude> :l functors1.hs
[1 of 1] Compiling Main
 ( functors1.hs, interpreted )

functors1.hs:8:18:
The first argument of ‘Functor’
  should have kind ‘* -> *’,
  but ‘FixMePls’ has kind ‘*’
In the instance declaration for
  ‘Functor FixMePls’
Failed, modules loaded: none.

کلاً درخواستِ ‏‎Functor‎‏ برای ‏‎FixMePls‎‏ خیلی منطقی نیست. اگه تایپ‌ها رو بررسی کنیم، مشخص میشه چرا:

-- Functor:
fmap :: Functor f => (a -> b) -> f a -> f b

-- جابجا کنیم FixMePls رو با f اگه
(a -> b) -> FixMePls a -> FixMePls b

-- هیچ آرگومان تایپی FixMePls ولی
-- :نمی‌گیره، پس در واقع اینطوریه
   (FixMePls -> FixMePls)
-> FixMePls
-> FixMePls

هیچ نوع‌ساز ِ ‏‎f‎‏ ای وجود نداره! اگه این رو تا آخر پلی‌مورفیک کنیم، چنین تایپی میشه:

(a -> b) -> a -> b

پس در حقیقت، نبودِ آرگومانِ تایپی یعنی:

($) :: (a -> b) -> a -> b

بدونِ آرگومانِ تایپی، همون اعمالِ تابع میشه.

فانکتور همون اعمالِ تابع هست

الان دیدیم که چطور نتیجه‌ی ‏‎Functor‎‏ دادن به یه ثابتِ تایپ، معادل با اعمالِ تابع شد. اما در حقیقت، ‏‎fmap‎‏ یه حالتِ خاص از همون اعمالِ تابع هست. تایپ‌ها رو ببینیم:

fmap :: Functor f => (a -> b) -> f a -> f b

‏‎fmap‎‏ یه عملگر ِ میانوند هم داره. اگه از یکی از نسخه‌های قدیمی‌ترِ GHC استفاده می‌کنین، ممکنه لازم باشه ‏‎Data.Functor‎‏ رو وارد کنین تا ازش در REPL استفاده کنین. واضح‌ه که تایپ‌ش عینِ ‏‎fmap‎‏ ِ ِپیشوندی ِه:

-- ه fmap مستعارِ میانوندی برای <$>
(<$>) :: Functor f
      => (a -> b)
      -> f a
      -> f b

متوجه چیزی شدین؟

(<$>) :: Functor f
      => (a -> b)  -> f a -> f b
 ($) ::  (a -> b)  ->  a  ->  b

Functor تایپکلاسی‌ه برای اعمال ِ تابع از روی یا بالا سَر ِ یه ساختاری که می‌خوایم نادیده بگیریم و دست نخورده بمونه. جلوتر که قانون‌های ‏‎Functor‎‏ رو بگیم، منظورمون از دست نخورده بمونه رو هم با جزئیات توضیح میدیم.

ستاره‌ای درخشان برای نگاه تو تا حقیقتِ ‏‎f‎‏ رو دریابی

دلیلِ نیاز به گونه‌بالا بودنِ ‏‎f‎‏ رو یه کم بیشتر بررسی کنیم.

اگه یه آرگومانِ تایپی به نوع‌داده‌ای که بالاتر تعریف کردیم اضافه کنیم، ‏‎FixMePls‎‏ تبدیل به یه نوع‌ساز میشه و کُدِ زیر کار می‌کنه:

-- functors2.hs

data FixMePls a =
    FixMe
  | Pls a
  deriving (Eq, Show)

instance Functor FixMePls where
  fmap =
    error "مهم نیست، کامپایل نمیشه"

حالا کامپایل میشه!

Prelude> :l code/functors2.hs
[1 of 1] Compiling Main
Ok, modules loaded: Main.

وایسا... اون error رو دیگه لازم نداریم! پس اون نمونه ِ ‏‎Functor‎‏ صحیح‌ش اینطوری میشه:

-- functors3.hs

data FixMePls a =
    FixMe
  | Pls a
  deriving (Eq, Show)

instance Functor FixMePls where
  fmap _ FixMe = FixMe
  fmap f (Pls a) = Pls (f a)

اگه این نمونه رو با تایپِ ‏‎fmap‎‏ مقایسه کنیم:

fmap :: Functor f
     => (a -> b) -> f a   -> f b
fmap       f      (Pls a) = Pls (f a)
--      (a -> b)    f  a     f    b

از حرفِ ‏‎f‎‏ در تایپِ ‏‎fmap‎‏ برای نشون دادنِ ‏‎Functor‎‏ استفاده کردیم، برای آرگومانی که خودش یه تابع‌ه هم استفاده کردیم. هردوی اینها جزء رسم و رسوم‌های نوشتاری‌اند. فکر نکنین که ‏‎f‎‏ در نمونه ِ ‏‎FixMePls‎‏ و ‏‎f‎‏ در تعریفِ تایپکلاسِ ‏‎Functor‎‏ یکسان‌اند.

حالا کُدمون خوشحاله!

Prelude> :l code/functors3.hs
[1 of 1] Compiling Main
Ok, modules loaded: Main.
Prelude> fmap (+1) (Pls 1)
Pls 2

ببینید چطور تابع از روی ساختار به داخل‌ش اعمال شد. هسکل‌نویس‌های حرفه‌ای همینطوری تابع‌های گُنده و سنگین رو از روی ساختار‌های انتزاعی بلند می‌کنن!

یه اشتباهِ دیگه هم ببینیم. اگه تایپِ نمونه ِ ‏‎Functor‎‏ رو از ‏‎FixMePls‎‏ به ‏‎FixMePls a‎‏ تغییر بدیم چی میشه؟

-- functors4.hs

data FixMePls a =
    FixMe
  | Pls a
  deriving (Eq, Show)

instance Functor (FixMePls a) where
  fmap _ FixMe = FixMe
  fmap f (Pls a) = Pls (f a)

دقت کنین تایپ رو تغییری ندادیم؛ هنوز فقط یه آرگومان می‌گیره. اما حالا اون آرگومان جزئی از ساختار ِ ‏‎f‎‏ شده. اگه این کُدِ بدقواره رو بارگذاری کنیم:

Prelude> :l code/functors4.hs
[1 of 1] Compiling Main

functors4.hs:8:19
The first argument of ‘Functor’
  should have kind ‘* -> *’,
  but ‘FixMePls a’ has kind ‘*’
In the instance declaration for
  ‘Functor (FixMePls a)’
Failed, modules loaded: none.

خطای مشابه این رو قبل‌تر هم گرفتیم. بعد از اعمال ِ اون نوع‌ساز، گونه ِش از ‏‎* -> *‎‏ به ‏‎*‎‏ تغییر کرد.

تایپکلاس‌ها و کلاس‌های سازنده

شاید اولین بار که نوع‌ساز با گونه ِ ‏‎* -> *‎‏ رو در تعریفِ ‏‎Functor‎‏ دیدین یه کم تعجب کردین– این کاملاً طبیعی‌ه! در واقع نسخه‌های اولیه‌ی هسکل اصلاً قابلیتِ بیانِ تایپکلاس‌ها با تایپ‌های گونه‌بالا رو نداشتن. مارک جونز* در حالی که داشت روی یکی از پیاده‌سازی‌های هسکل به نامِ Gofer کار می‌کرد، این قابلیت رو طراحی کرد. این کار تایپکلاس‌ها رو از محدودیت به تایپ‌های با گونه ِ ‏‎*‎‏ (که بهشون ثابت هم میگیم)، به کارا بودن برای تایپ‌های گونه‌بالا (نوع‌ساز‌ها) تعمیم داد.

تو هسکل این دو حالت* طوری تلفیق شدن که دیگه کلاس‌های سازنده رو متمایز از تایپکلاس‌ها نمی‌دونیم. این رو فقط به این دلیل گفتیم چون خوبه که بدونین اینجا اتفاق مهمی افتاده. حالا راهی داریم که راجع به محتویاتِ تایپ‌ها، مستقل از تایپی که ساختار ِ اون محتویات هست صحبت کنیم. به همین خاطر هم یه چیزی مثل ‏‎fmap‎‏ داریم تا بتونیم محتویاتِ یک مقدار رو، بدون اینکه ساختار ِ بیرونی‌ش ( یه لیست، یا یه ‏‎Just‎‏) رو دست بزنیم تغییر بدیم.

*

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