۶ - ۵نوشتن نمونه‌ی تایپکلاس

هنوز از نوشتن نوع‌داده‌ها یا تایپکلاس‌های خودتون چیزِ زیادی نگفتیم؛ ولی هر دو کار رو می‌تونین انجام بدین، انجام هم میدین. در هر دو مورد پیش میاد که لازم بشه نمونه‌های تایپکلاسی رو خودتون بنویسین. با اینکه تایپکلاسِ ‏‎Eq‎‏ رو میشه خیلی راحت مشتق گرفت، ولی چون نوشتن نمونه‌هاش خیلی ساده‌ست، اینجا برای نشون دادنِ طرز نوشتن نمونه‌های تایپکلاس‌ها، خودمون می‌نویسیم‌شون.

نمونه‌های ‏‎Eq‎‏

همونطور که دیدیم، ‏‎Eq‎‏ نمونه‌هایی برای تشخیص تساوی ِ مقادیر ارائه میده، ساختنِ یه نمونه از اون هم برای یه نوع‌داده کارِ ساده‌ایه.

با مراجعه به راهنمای یه تایپکلاس در Hackage می‌تونین اون تایپکلاس رو بیشتر بررسی کنین. تایپکلاسی مثل ‏‎Eq‎‏ از کتابخونه ِ ‏‎base‎‏ ِه، که اینجاست. آدرس دقیقتر برای تایپکلاسِ ‏‎Eq‎‏ اینه.

در اون راهنما به یه جمله‌ی خاص دقت کنین:

Minimal complete definition: either == or /=.
./= حداقل تعاریفِ لازم برای کامل بودن: == یا

این جمله به شما متودهایی که باید تعریف کنین تا یه نمونه ِ معتبر از ‏‎Eq‎‏ داشته باشین رو میگه. در این مورد یا ‏‎(==)‎‏ (تساوی) یا ‏‎(/=)‎‏ (نامساوی) کافی‌اند، چرا که یکی‌شون برعکس اون یکی‌ه. چرا فقط ‏‎(==)‎‏ نه؟ کم پیش میاد ولی ممکنه برای یه نوع‌داده ِ خاص، برای هر تابع بتونین با زِرَنگی کُدِ سریعتری بنویسین، به همین خاطر امکان‌ش هست که هر دوشون رو تأمین کنین. اینجا ما با نوع‌داده ِ خاصی کار نمی‌کنیم و یکی‌شون کفایت می‌کنه.

اول با یه نوع‌داده ِ پیش و پا افتاده و کوچولو کار می‌کنیم... به اسم ‏‎Trivial‎‏!

data Trivial =
  Trivial

بدون عبارتِ ‏‎deriving‎‏ چسبیده به دُمِ این تعریفِ نوع‌داده، هیچ جور نمونه ِ تایپکلاسی‌ای نداریم. اگه همینجوری بخوایم برای این داده تساوی رو بررسی کنیم، GHCi خطای تایپ میده:

Prelude> Trivial == Trivial

No instance for (Eq Trivial) arising
  from a use of ‘==’
In the expression Trivial == Trivial
In an equation for ‘it’: it = Trivial == Trivial

GHC نمی‌تونه یه نمونه از ‏‎Eq‎‏ برای نوع‌داده ِمون ‏‎Trivial‎‏ پیدا کنه. هم می‌تونستیم به GHC بگیم برامون بنویسه، هم میشد خودمون بنویسیم، ولی هیچ چیز براش ننوشتیم و در زمان کامپایل به مشکل می‌خوره. در بعضی زبان‌ها چنین اشتباهاتی وقتی معلوم میشن که برنامه وسط اجراشه.

برخلاف بقیه‌ی زبان‌ها، هسکل اصلاً نوشته‌کننده (‏‎Show‎‏ یا چاپ) یا تساوی (‏‎Eq‎‏، چه تساویِ مقدار چه تساویِ اشاره‌گر) ِ عمومی اختصاص نمیده، چرا که همیشه عاقلانه و امن نیست، تو هر زبانی.

پس خودمون باید بنویسیم! خوشبختانه برای ‏‎Trivial‎‏ کارِ پیش‌و‌پا افتاده‌ایه*. همیشه نمونه‌های تایپکلاسیِ یه تایپ رو داخل همون فایلی که تایپ توش هست تعریف کنین (دلیلش رو بعداً میگیم):

data Trivial =
  Trivial'

instance Eq Trivial where
  Trivial' == Trivial' = True
*

م. لغت trivial به معنای پیش‌و‌پا افتاده‌ست.

همین! یه نمونه نوشتیم که به کامپایلر طریقه‌ی بررسیِ تساوی برای این نوع‌داده رو میگه. داده‌سازها و نوع‌سازها در هسکل معمولاً هم‌نام هستن که ممکنه یه کم گیج‌کننده باشه. اون پریم (م. یا پرایم) رو آخرِ داده‌ساز ِمون نوشتیم که هم دنبال کردن مثال راحت‌تر بشه، هم اینکه نشون بدیم لازم نیست یکی باشن.

اگه این رو بارگذاری کنین، فقط یه بیانیه هست که بتونین بنویسین:

Prelude> Trivial' == Trivial'
True

جزبه‌جزء اون نمونه رو بررسی می‌کنیم:

  instance   Eq   Trivial   where
--   [1]     [2]  [3]       [4]
    Trivial'   ==   Trivial' = True
--    [5]     [6]  [7]        [8]

instance Eq Trivial where
    (==) Trivial' Trivial' = True
--  [           9             ] 

۱.

کلیدواژه ِ ‏‎instance‎‏ شروعِ یه نمونه ِ تایپکلاس رو مشخص می‌کنه. نمونه ِ تایپکلاس‌ها برای این هستن که یه سری کارها مثلِ نوشته‌کننده (‏‎Show‎‏مرتب‌پذیری (‏‎Ord‎‏شمارش (‏‎Enum‎‏) و غیره رو برای یه نوع‌داده ِ خاص تعریف کنیم. بدون این نمونه نمی‌تونیم مقادیر رو برای تساوی بررسی کنیم. البته با این نوع‌داده ِ بخصوص جواب همیشه یه چیزه.

۲.

اولین اسم بعد از ‏‎instance‎‏، تایپکلاسی‌ه که این نمونه می‌خواد تأمین کنه، اینجا ‏‎Eq‎‏.

۳.

اون تایپی که داریم براش نمونه می‌نویسیم. در این مورد داریم تایپکلاسِ ‏‎Eq‎‏ رو برای نوع‌داده ِ ‏‎Trivial‎‏ تعریف می‌کنیم.

۴.

کلیدواژه ِ ‏‎where‎‏، تعاریف اولیه و شروعِ نمونه رو تموم می‌کنه. چیزهایی که بعدش میان متودها یا توابعی هستن که می‌خوایم تعریف کنیم.

۵.

داده‌ساز (یا مقدار) ِ ‏‎Trivial'‎‏ اولین آرگومانی‌ه که برای تابعِ ‏‎==‎‏ تأمین می‌کنیم. اینجا از ‏‎==‎‏ به صورت میانوند استفاده کردیم، پس آرگومانِ اولش سمت چپ میاد.

۶.

تابعِ میانوند ِ ‏‎==‎‏، این چیزیه که داریم تعریف می‌کنیم.

۷.

آرگومانِ دوم که مقدارش ‏‎Trivial'‎‏ ِه. باز هم به خاطرِ میانوند بودنِ ‏‎==‎‏، آرگومانِ دوم سمت راست میاد.

۸.

نتیجه‌ی ‏‎Trivial' == Trivial'‎‏ که ‏‎True‎‏ هست.

۹.

تابعِ ‏‎(==)‎‏ رو، به کمکِ پرانتز میشد پیشوندی هم تعریف کنیم. دقت کنید که این رو فقط برای نشون دادن نوشتیم؛ نمیشه دو تا نمونه از یه تایپکلاس برای یه تایپ داشته باشیم. نمونه‌های تایپکلاس در یه تایپ یکتا اند. می‌تونین امتحان کنین و هر دوی اینها رو تو یه فایل بنویسین، ولی خطا می‌گیرین.

خیلی خب، بریم سراغِ یه چیزی که کمتر ‏‎پیش‌وپا افتاده‎‏ باشه! نوع‌داده‌هامون رو خودمون می‌نویسیم – یکی برای روزهای هفته و یکی هم برای تاریخ که از تایپِ ‏‎DayOfWeek‎‏ استفاده می‌کنه:

data DayOfWeek =
  Mon | Tue | Weds | Thu | Fri | Sat | Sun

-- روز هفته و روز عددی ماه
data Date =
  Date DayOfWeek Int

چون اینها در هسکل پیش‌ساخته نیستن، هیچ تایپکلاسی هم ندارن. و چون هیچ عملیات‌ای براشون تعریف نشده، همینطوری به خودیِ‌خود هیچ کاری نمیشه باهاشون کرد. بیاین درستش کنیم. اولین نمونه ِ ‏‎Eq‎‏ رو برای ‏‎DayOfWeek‎‏ می‌نویسیم (با اندکی مشقت!):

instance Eq DayOfWeek where
  (==) Mon Mon   = True
  (==) Tue Tue   = True
  (==) Weds Weds = True
  (==) Thu Thu   = True
  (==) Fri Fri   = True
  (==) Sat Sat   = True
  (==) Sun Sun   = True
  (==) _ _       = False

حالا نمونه ِ ‏‎Eq‎‏ برای ‏‎Date‎‏ رو می‌نویسیم. این جذاب‌تره:

instance Eq Date where
  (==) (Date weekday dayOfMonth)
       (Date weekday' dayOfMonth') =
     weekday == weekday'
  && dayOfMonth == dayOfMonth'

برای این نمونه، طرز کارِ تساوی ِ ‏‎DayOfWeek‎‏ و اعداد رو دوباره تعریف نکردیم؛ فقط گفتیم که تساوی ِ محتویاتِ ‏‎Date‎‏ برای تساوی ِ خودِ تاریخ کفایت می‌کنه. کامپایلر خودش انتظار داره که آرگومان‌های ‏‎Date‎‏ از تایپ‌های ‏‎DayOfWeek‎‏ و ‏‎Int‎‏ باشن و لازم نیست مشخص کنیم. همین اطلاعاتی که تأمین کردیم برای بررسی تساوی ِ دو مقدارِ ‏‎Date‎‏ کافی‌اند.

کار می‌کنه؟

Prelude> Date Thu 10 == Date Thu 10
True
Prelude> Date Thu 10 == Date Thu 11
False
Prelude> Date Thu 10 == Date Weds 10
False

کامپایل شد و در هر سه مورد کار کرد!

به یه چیزِ دیگه از این تایپ‌ها اشاره می‌کنیم:

Prelude> Date Thu 10

<interactive>:26:1:
  No instance for (Show Date)
    arising from a use of ‘print’
  In a stmt of an interactive GHCi command:
    print it

با نمونه ِ ‏‎Eq‎‏ که نوشتیم، تونستیم تساوی ِ مقادیرش رو چک کنیم، ولی چون نمونه‌ای از ‏‎Show‎‏ براش ننوشتیم هنوز نمی‌تونیم تو REPL چاپ ِشون کنیم. اگه دوست دارین درستش کنین، یه عبارتِ ‏‎deriving Show‎‏ تَهِ هر کدوم از نوع‌داده‌های بالا اضافه کنین.

توابعِ ناقص – خطر غریب آشنا

قبل‌تر اعمالِ ناقص ِ توابع رو گفتیم ولی تابعِ ناقص چیز دیگه‌ایه. تابعِ ناقص تابعی‌ه که همه‌ی حالت‌ها براش در نظر گرفته نشده، یعنی سناریوهایی هستن که هیچ کُدی برای محاسبه‌شون تعریف نکردیم.

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

ata DayOfWeek =
  Mon | Tue | Weds | Thu | Fri | Sat | Sun

instance Eq DayOfWeek where
  (==) Mon Mon   = True
  (==) Tue Tue   = True
  (==) Weds Weds = True
  (==) Thu Thu   = True
  (==) Fri Fri   = True
  (==) Sat Sat   = True
  (==) Sun Sun   = True

حالتِ بی‌قیدوشرط رو فراموش کردیم. اگه آرگومان‌ها فرق داشته باشن چی؟ تا وقتی یکی باشن همه چیز به نظر خوب میاد، اما به محض این که آرگومان‌های متفاوت بهش بدیم، تو صورت‌مون می‌ترکه:

Prelude> Mon == Mon
True

Prelud> Mon == Tue
*** Exception: code/derivingInstances.hs:
(19,3)-(25,23):
  Non-exhaustive patterns in function ==

زرشک...! هسکل رو شروع کردیم که برنامه‌هامون وسط کار نترکن. قضیه چیه؟

خبر خوب اینکه یه کار میشه کرد تا GHC بیشتر راهنمایی‌تون کنه. اگه با دستورِ ‏‎-Wall‎‏ همه‌ی هشدارها رو تو REPL (یا تو تنظیماتِ ساخت) روشن کنیم، اون موقع هر وقت حالتی رو در نظر نگرفته باشیم GHC بهمون میگه:

Prelude> :set -Wall
Prelude> :l code/derivingInstances.hs
[1 of 1] Compiling DerivingInstances

code/derivingInstances.hs:19:3: Warning:
    Pattern match(es) are non-exhaustive
    In an equation for ‘==’:
        Patterns not matched:
            Mon Tue
            Mon Weds
            Mon Thu
            Mon Fri
            ...
Ok, modules loaded: DerivingInstances.

اگه نمونه ِتون رو درست کنین و اون حالتی که ‏‎False‎‏ برمی‌گرونه رو تأمین کنین، دیگه به خاطر الگوهای غیرفراگیر بهتون گیر نمیده.

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

f :: Int -> Bool
f 2 = True

اگه این رو کامپایل کنین دوباره هشدار می‌گیرین (با فرضِ اینکه هنوز ‏‎-Wall‎‏ رو روشن دارین). در این مورد چون ‏‎Int‎‏ مقادیرِ خیلی زیادی داره، اون پیغام از یه نوشتاری استفاده می‌کنه که بهتون میگه مقادیری که ۲ نیستن رو در نظر نگرفتین:

Pattern match(es) are non-exhaustive
In an equation for ‘f’:
  Patterns not matched:
    GHC.Types.I# #x with #x `notElem` [2#]

اگه یه مقدار دیگه رو به تابع‌مون اضافه کنیم، اون مقدار هم به لیستِ مقادیری که در نظر گرفتین اضافه میشه:

f :: Int -> Bool
f 1 = True
f 2 = True
Pattern match(es) are non-exhaustive
In an equation for ‘f’:
  Patterns not matched:
    GHC.Types.I# #x with #x `notElem` [1#, 2#]
f :: Int -> Bool
f 1 = True
f 2 = True
f 3 = True
attern match(es) are non-exhaustive
In an equation for ‘f’:
  Patterns not matched:
    GHC.Types.I# #x with #x `notElem` [1#, 2#, 3#]

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

f :: Int -> Bool
f 1 = True
f 2 = True
f 3 = True
f _ = False

یه چاره‌ی دیگه اینه که اگه فقط چند حالت برای بررسی دارین از یه نوع‌داده‌ای استفاده کنین که مثل ‏‎Int‎‏ گُنده نیست.

-- بی‌شوخی خیلی گُنده‌ست

Prelude> minBound :: Int
-9223372036854775808

Prelude> maxBound :: Int
9223372036854775807

اگه می‌خواین داده‌تون فقط چندتا حالت داشته باشه، اون رو تو یه تایپِ جمع مثل ‏‎DayOfWeek‎‏ که قبل‌تر نشون دادیم بنویسین. مثل اکثر برنامه‌نویس‌های زبان C از ‏‎Int‎‏ به عنوان یه تایپِ جمع ِ ضمنی استفاده نکنین.

گاهی اوقات باید بیشتر بخوایم

در نوشتن نمونه‌ای مثل ‏‎Eq‎‏ برای تایپ‌های با پارامترهای پلی‌مورفیک (مثل ‏‎Identity‎‏ در پایین)، گاهی پیش میاد که آرگومان‌های اون هم ملزوم به داشتن یه تایپکلاس‌هایی باشن تا نمونه‌هایی رو در اختیار ما بذارن که بتونیم به کمک‌شون نمونه ِ تایپکلاس مورد نظر رو برای نوع‌داده‌ای که شاملِ اون آرگومان‌ها هست، بنویسیم:

data Identity a =
  Identity a

instance Eq (Identity a) where
  (==) (Identity v) (Identity v') = v == v'

ما اینجا می‌خوایم به نمونه ِ ‏‎Eq‎‏ ِ آرگومانِ ‏‎Identity‎‏ (که در تعریفِ داده با ‏‎a‎‏ و در توصیفِ نمونه با ‏‎v‎‏ نشون دادیم) اتکا کنیم. ولی این تعریف یه اشکالی داره:

No instance for (Eq a) arising from a use of ‘==’
Possible fix: add (Eq a) to the
context of the instance declaration

In the expression: v == v'
In an equation for ‘==’:
  (==) (Identity v) (Identity v') = v == v'
In the instance declaration for
  ‘Eq (Identity a)’

مشکل اینجاست که ‏‎v‎‏ و ‏‎v'‎‏ هر دو از تایپ ‏‎a‎‏ هستن، و ما هیچ چیز از ‏‎a‎‏ نمی‌دونیم؛ پس نمی‌تونیم فرض کنیم یه نمونه از ‏‎Eq‎‏ دارن. ولی می‌تونیم از همون گرامرای که برای محدودیت تایپکلاسی در توابع استفاده می‌کردیم در تعریفِ نمونه‌مون هم استفاده کنیم:

instance Eq a => Eq (Identity a) where
  (==) (Identity v) (Identity v') = v == v'

حالا دیگه می‌دونیم a باید یه نمونه از ‏‎Eq‎‏ داشته باشه و همه چیز درسته. با این کار هسکل در زمانِ کامپایل جلوی بررسیِ تساوی با مقادیری که از ‏‎Eq‎‏ نمونه ندارن رو هم می‌گیره:

Prelude> Identity NoEqInst == Identity NoEqInst

No instance for (Eq NoEqInst)
  arising from a use of ‘==’

In the expression:
  Identity NoEqInst == Identity NoEqInst

In an equation for ‘it’:
  it = Identity NoEqInst == Identity NoEqInst

اگر هم بخوایم می‌تونیم بیشتر از نیازمون تایپکلاس تعیین کنیم، مثل زیر که برای ‏‎a‎‏ یه نمونه از تایپکلاس ‏‎Ord‎‏ رو اجبار کردیم؛ در حالی که ‏‎Eq‎‏ کمتر از ‏‎Ord‎‏ لازم داره و اینجا جواب نیاز ما رو میده:

instance Ord a => Eq (Identity a) where
  (==) (Identity v) (Identity v') =
    compare v v' = EQ

این کامپایل میشه، ولی اصلاً واضح نیست چرا کسی چنین چیزی بخواد... شاید دلایل رازآلود خودش رو داره...

تمرین‌ها: نمونه‌های ‏‎Eq‎‏

برای این نوع‌داده‌ها نمونه ِ ‏‎Eq‎‏ بنویسین.

۱.

data TisAnInteger =
  TisAn Integer

۲.

data TwoIntegers =
  Two Integer Integer

۳.

data StringOrInt =
    TisAnInt   Int
  | TisAString String 

۴.

data Pair a =
  Pair a a

۵.

data Tuple a b =
  Tuple a b

۶.

data Which a =
    ThisOne a
  | ThatOne a

۷.

data EitherOr a b =
    Hello a
  | Goodbye b