۱۲ - ۳تایپ 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)