۱۲ - ۳تایپ Either

راهی می‌خوایم که دلیلِ جوابِ ناموفق از سازنده ِ ‏‎mkPerson‎‏ رو بیان کنه. برای چنین کاری از نوع‌داده ِ ‏‎Either‎‏ که در ‏‎Prelude‎‏ به صورتِ زیر تعریف شده استفاده می‌کنیم:

data Either a b = Left a | Right b

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

data PersonInvalid = NameEmpty
                   | AgeTooLow
                   deriving (Eq, Show)

حتماً دلیل مشتق گرفتن ِ ‏‎Show‎‏ رو می‌دونین، اما ‏‎Eq‎‏ هم باید مشتق بگیریم تا بتونیم داده‌سازها رو برای تساوی بررسی کنیم. تطبیق الگو یه بیانیه‌ی case ِه که داده‌ساز، نقشِ شرط رو بازی می‌کنه. بیانیه‌های case و تطبیق الگو بدونِ نمونه ِ ‏‎Eq‎‏ کار می‌کنن، اما گاردها بدونِ ‏‎(==)‎‏ کار نمی‌کنن. همونطور که قبلاً نشون دادیم، اگه رفتار خاصی برای تایپ‌تون از ‏‎Eq‎‏ انتظار دارین، می‌تونین نمونه‌ش رو خودتون تعریف کنین. اما بیشتر مواقع لازم نمیشه و مشتق گرفتن ِش جوابگو هست. با کُدِ زیر تفاوت رو نشون دادیم:

module EqCaseGuard where

data PersonInvalid = NameEmpty
                   | AgeTooLow

-- کامپایل میشه Eq بدون
toString :: PersonInvalid -> String
toString NameEmpty = "NameEmpty"
toString AgeTooLow = "AgeTooLow"

instance Show PersonInvalid where
  show = toString

-- این تابع بدون یه نمونه
-- کار نمی‌کنه Eq از
blah :: PersonInvalid -> String
blah pi
  | pi == NameEmpty = "NameEmpty"
  | pi == AgeTooLow = "AgeTooLow"
  | otherwise = "???"

اگه فرض کنیم برای تطبیق الگو هم یه نمونه از ‏‎Eq‎‏ لازم داشتیم، نمونه ِ ‏‎Eq‎‏ ش رو چطور می‌نوشتین؟

در مرحله‌ی بعد تایپِ سازنده‌مون رو تغییر میدیم:

mkPerson :: Name
         -> Age
         -> Either PersonInvalid Person

این تایپ نشون میده که اگه تابع موفق باشه یه مقدارِ ‏‎Person‎‏ میده، و اگر هم شکست بخوره یه مقدارِ ‏‎PersonInvalid‎‏ میده. حالا باید تعریفِ تابع هم تغییر بدیم تا مقادیرِ ‏‎PersonInvalid‎‏ رو در یه سازنده ِ ‏‎Left‎‏ برگردونه:

type Name = String
type Age = Integer

data Person = Person Name Age deriving Show

data PersonInvalid = NameEmpty
                   | AgeTooLow
                   deriving (Eq, Show)

mkPerson :: Name
         -> Age
         -> Either PersonInvalid Person
--           [1]       [2]        [3]
mkPerson name age
  | name /= "" && age >= 0 =
      Right $ Person name age
--            [4]
  | name == "" = Left NameEmpty
--                    [5] 
  | otherwise = Left AgeTooLow

۱.

تایپِ ‏‎mkPerson‎‏ یه ‏‎Name‎‏ و ‏‎Age‎‏ می‌گیره و یه جوابِ ‏‎Either‎‏ میده.

۲.

جوابِ ‏‎Left‎‏ از ‏‎Either‎‏ یه شخصِ نامعتبر ِه، برای مواقعی که اسم یا سن معتبر نیستن.

۳.

جوابِ ‏‎Right‎‏ برای شخص ِ معتبر ِه.

۴.

در حالت اولِ تابعِ ‏‎mkPerson‎‏، مقدارِ ‏‎Person‎‏ به داده‌ساز ِ ‏‎Right‎‏ داده میشه و تابع خروجیِ ‏‎Either‎‏ برمی‌گردونه. اینطور هم میشد بنویسیم:

name /= "" && age >= 0 =
  Right (Person name age)

بجای استفاده از علامت دلار.

۵.

در دو حالت بعدی، بسته به دلیلِ شکست یکی از مقادیرِ ‏‎InvalidPerson‎‏ رو به داده‌ساز ِ ‏‎Left‎‏ میده و باز هم یه ‏‎Either‎‏ برمی‌گردونه.

اینکه از ‏‎چپ‎‏ برای شکست استفاده می‌کنیم بی‌دلیل نیست. این کار یکی از مرسوماتِ هسکل‌ه، اما این رسم هم دلیلی داشته. دلیل‌ش برمی‌گرده به ترتیب تایپ‌های آرگومانی و اعمال ِ توابع. بطور معمول، چیزی که باعث تعلیق برنامه میشه، خطا یا جوابِ نامعتبر ِه. ‏‎Functor‎‏ روی تایپِ سمت چپ (م. داخلِ ‏‎Left‎‏) نگاشت نمیشه، چون اون اعمال شده رفته. شاید ‏‎Functor‎‏ رو از معرفیِ ‏‎fmap‎‏ در فصلِ لیست‌ها به خاطر داشته باشین؛ اگه متوجه نشدین نگران نباشین، به زودی ‏‎Functor‎‏ رو کامل توضیح میدیم. اعمال و نگاشت ِ توابع معمولاً روی حالتی که برنامه رو معلّق نمی‌کنه مطلوب ِه (یعنی حالتی که خطا نیست به همین خاطر هم از داده‌ساز ِ ‏‎Left‎‏ از ‏‎Either‎‏ برای حالتی که برنامه رو نگه میداره استفاده میشه.

ببینیم اگه داده ِ خوب داشته باشیم چی میشه (البته جالی شخص نیست...*).

Prelude> :t mkPerson "Djali" 5
mkPerson "Djali" 5 :: Either PersonInvalid Person
Prelude> mkPerson "Djali" 5
Right (Person "Djali" 5)
*

منظورمون رو متوجه نشدین؟ رو اینترنت دنبالِ جالی بگردین.

نتیجه با داده ِ بد هم میشه دید:

Prelude> mkPerson "" 10
Left NameEmpty
Prelude> mkPerson "Djali" (-1)
Left AgeTooLow
Prelude> mkPerson "" (-1)
Left NameEmpty

دقت کنین که در مثالِ آخر وقتی هم اسم و هم سن اشتباه بودن، فقط نتیجه‌ی اولین شکست مشخص شد، نه هر دو.

پس هنوز بی‌نقص نشده، با این تابع نمیشه یه لیست از خطاها بیان کرد. اما میشه درست‌ش کرد! بجای اینکه همه‌ی داده‌های لازم برای ‏‎Person‎‏ رو همزمان تأیید کنیم، اول توابعی تعریف می‌کنیم که جداگانه هر مشخصه رو چک کنن و بعد اونها رو با هم ترکیب می‌کنیم. به غیر از یه تایپ مستعار که اضافه کردیم، مابقیِ کُدِ زیر مثلِ قبل‌ه:

type Name = String
type Age = Integer
type ValidatePerson a =
  Either [PersonInvalid] a

data Person = Person Name Age deriving Show

data PersonInvalid = NameEmpty
                   | AgeTooLow
                   deriving (Eq, Show)

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

ageOkay :: Age 
        -> Either [PersonInvalid] Age
ageOkay age = case age >= 0 of
  True  -> Right age
  False -> Left [AgeTooLow]

nameOkay :: Name 
         -> Either [PersonInvalid] Name
nameOkay name = case name /= "" of
  True  -> Right name
  False -> Left [NameEmpty]

میشه تایپ جمع ِ ‏‎PersonInvalid‎‏ رو در سمتِ چپ ِ ‏‎Either‎‏ تودرتو کنیم؛ مشابهِ این کار رو فصل قبل هم انجام دادیم (البته تایپی که اونجا داشتیم شبیهِ ‏‎Either‎‏ بود، نه خودِش).

چندتا نکته:

  • مقدارِ ‏‎Name‎‏ فقط وقتی این جوابِ نامعتبر رو میده که یه ‏‎String‎‏ ِ خالی باشه.

  • از اونجا که ‏‎Name‎‏ یه مقدارِ ‏‎String‎‏ ِه، هر حرفی هم می‌تونه داشته باشه، یعنی مثلاً ‏‎"42"‎‏ یه اسم ِ معتبر به حساب میاد. امتحان کنین.

  • اگه سعی کنین بجای اسم، یه ‏‎Integer‎‏ بدین خطای تایپ می‌گیرین، نه جوابِ ‏‎Left‎‏. امتحان کنین. اگه به تابعِ ‏‎ageOkay‎‏ هم ‏‎String‎‏ بدین خطا میده.

  • می‌خوایم خروجی‌مون یه لیست از جواب‌های ‏‎PersonInvalid‎‏ باشه که بتونیم هردو ‏‎NameEmpty‎‏ و ‏‎AgeTooLow‎‏ رو برگردونیم.

    توابع‌مون با ‏‎Either‎‏ مقادیرِ اسم و سن رو جداگانه تأیید می‌کنن. حالا می‌تونیم تابعِ ‏‎mkPerson‎‏ رو با استفاده از تایپِ مستعار ِ ‏‎ValidatePerson‎‏ بنویسیم:

    mkPerson :: Name
             -> Age 
             -> ValidatePerson Person
    --              [1]          [2]
    
    mkPerson name age = 
      mkPerson' (nameOkay name) (ageOkay age)
    --  [3]           [4]            [5]
    
    mkPerson' :: ValidatePerson Name
              -> ValidatePerson Age 
              -> ValidatePerson Person
    --                 [6]
    
    mkPerson' (Right nameOk) (Right ageOk) =
      Right (Person nameOk ageOk) 
    mkPerson' (Left badName) (Left badAge) =
      Left (badName ++ badAge) 
    mkPerson' (Left badName) _ = Left badName
    mkPerson' _ (Left badAge) = Left badAge

    ۱.

    یه تایپ مستعار برای ‏‎Either [PersonInvalid] a‎‏.

    ۲.

    این میشه آرگومانِ ‏‎a‎‏ برای تایپِ ‏‎ValidatePerson‎‏.

    ۳.

    دیگه تابعِ اصلی‌مون رو بر مبنای یه تابعِ کمکی با اسمِ مشابه تعریف کردیم.

    ۴.

    اولین آرگومانِ این تابعِ کمکی، جوابِ تابعِ ‏‎nameOkay‎‏ میشه.

    ۵.

    دومین آرگومان هم جوابِ تابعِ ‏‎ageOkay‎‏.

    ۶.

    باز هم تایپِ مترادف برای ‏‎Either‎‏.

    مابقیِ ‏‎mkPerson'‎‏ هم با چندتا تطبیقِ الگو نوشتیم.

    خب ببینیم چه کردیم:

    Prelude> mkPerson "" (-1)
    Left [NameEmpty,AgeTooLow]

    آخِیش، بهتر شد! دیگه با یه خروجی همه‌ی اشتباهات کاربر رو میگیم. جلوتر در کتاب می‌تونیم ‏‎mkPerson‎‏ و ‏‎mkPerson'‎‏ رو با تعریف زیر عوض کنیم:

    mkPerson
      :: Name
      -> Age 
      -> ValidatePerson Person
    mkPerson name age = 
      liftA2
        Person (nameOkay name) (ageOkay age)