۲۹ - ۲کلاسِ Exception و متودهاش

استثناها هم همون تایپ‌ها و مقادیری هستن که در طول کتاب دیدین. تایپ‌هایی که استثناها رو کدبندی می‌کنن باید یه نمونه از تایپکلاسِ ‏‎Exception‎‏ داشته باشن. سرآغازِ استثناهایی که امروز در هسکل هستن رو میشه در مقاله‌ی سایمون مارلو در رابطه با سلسله‌مراتب ِ قابلِ تعمیمی از استثناها که در زمان اجرا از هم متمایز میشن، مطالعه کرد. با استفاده از این سلسله‌مراتب ِ قابلِ تعمیم، هم میشه استثناهایی که ممکنه تایپ‌های متفاوتی داشته باشن رو گرفت، و هم میشه در صورت نیاز، تایپ‌های استثنای جدیدی اضافه کرد.

تعریفِ تایپکلاسِ ‏‎Exception‎‏ اینطوریه:

class (Typeable e, Show e) =>
      Exception e where
  toException :: e -> SomeException
  fromException :: SomeException -> Maybe e
  displayException :: e -> String
  -- GHC.Exception تعریف شده در

محدودیت ِ ‏‎Show‎‏ برای این وجود داره که بشه برای تایپی که ‏‎e‎‏ بهش تعیین میشه، استثنا رو با فرمتی خوانا به صفحه چاپ کرد. ‏‎Typeable‎‏ هم تایپکلاسی‌ه که متودهایی برای شناساییِ تایپ‌ها در زمانِ اجرا تعریف می‌کنه. به زودی بیشتر راجع به این صحبت می‌کنیم و میگیم که چرا این محدودیت‌ها برای تایپکلاسِ ‏‎Exception‎‏ لازم‌اند.

لیستِ تایپ‌هایی که نمونه ِ ‏‎Exception‎‏ دارن، خیلی بلنده:

-- بعضی نمونه‌ها حذف شدن
instance Exception IOException
instance Exception Deadlock
instance Exception BlockedIndefinitelyOnSTM
instance Exception BlockedIndefinitelyOnMVar
instance Exception AsyncException
instance Exception AssertionFailed
instance Exception AllocationLimitExceeded
instance Exception SomeException
instance Exception ErrorCall
instance Exception ArithException

ما تک‌تکِ اینها رو توضیح نمیدیم. اما شاید خودتون بتونین کاربردِ بعضی‌هاشون رو حدس بزنین (مثلاً ‏‎BlockedIndefinitelyOnMVar‎‏). فقط میگیم که یه نوع‌داده با یک عضوه:

data BlockedIndefinitelyOnMVar =
    BlockedIndefinitelyOnMVar
--  ^-----------------------^
--  MVar م. بلوکه‌ی ابدی روی

-- GHC.IO.Exception تعریف شده در

اگه نگاهی به ‏‎ArithException‎‏ بندازیم، می‌بینیم یه تایپِ جمع با چندین مقداره:

data ArithException
  = Overflow
  | Underflow
  | LossOfPrecision
  | DividedByZero
  | Denormal
  | RatioZeroDenominator

instance Exception ArithException

اگه ماژول ِ ‏‎Control.Exception‎‏ رو وارد کنین و یه کم با داده‌ساز‌های ‏‎ArithException‎‏ بازی کنین، می‌بینین که مقادیرِ معمولی‌اند، و اصلاً غیرعادی نیستن.

اما انگار اینجا اتفاقاتِ دیگه‌ای میوفته

همه‌ی اینها رو باز می‌کنیم تا ببینیم بخش‌های مختلف چطور با هم کار می‌کنن. اول نگاهی به متودهای تایپکلاسِ ‏‎Exception‎‏ بندازیم:

toException   :: e -> SomeException
fromException :: SomeException -> Maybe e

زیاد پیش نمیاد که از خودِ تابع‌های ‏‎toException‎‏ و ‏‎fromException‎‏ استفاده کنیم. در عوض از تابع‌های دیگه‌ای که اونها رو صدا میزنن استفاده می‌کنیم. متود ِ ‏‎toException‎‏ خیلی شبیهِ داده‌ساز ِ ‏‎SomeException‎‏ ِه. شاید متوجه شدین که ‏‎SomeException‎‏ هم توی اون لیستِ تایپ‌هایی که نمونه ِ ‏‎Exception‎‏ داشتن بود، حالا هم اینجا توی متودهای ‏‎Exception‎‏ ِه. یه کم به نظر حلقه‌ای میاد، اما نهایتاً ‏‎SomeException‎‏ کلیدِ مدیریت ِ استثناها‌ست.

یه معرفی مختصر از سورِ وجودی

‏‎SomeException‎‏ یه جورایی نقشِ تایپِ والد رو برای بقیه‌ی تایپ‌های استثنا بازی می‌کنه. به همین خاطر میشه در آنِ واحد تایپ‌های استثنای زیادی رو مدیریت کرد؛ بدون اینکه لازم باشه روی تک‌تکِ‌شون تطبیق بدیم. ببینیم چطوری:

data SomeException where
  SomeException
    :: Exception e => e -> SomeException

شاید در نگاه اول به نظر عجیب نیاد. دلیل‌ش اینه که بخشِ عجیب‌غریب‌ش توی یه ساختاری به اسمِ GADT (مخففِ نوع‌داده‌ی جبریِ تعمیم‌یافته) مخفی شده. بطور کلی، GADT‌ها در حوصله‌ی این کتاب نیستن. برای سرگرمی و یاد گرفتنِ هسکل در سطح متوسط خوب‌اند، ولی برای برنامه‌نویسی با هسکل واجب نیستن. اون چیزی که گرامر ِ GADT مخفی کرده، چیزی ِه به اسم سورِ وجودی.

میشد تایپِ ‏‎SomeException‎‏ رو بدون اینکه مفهوم‌ش تغییری کنه اینطوری بنویسیم:

data SomeException =
  forall e . Exception e => SomeException e

به صورتِ عادی، ‏‎forall‎‏ متغیرها رو بصورتِ عمومی سور می‌کنه (شاید از لغتِ ‏‎all‎‏ حدس زده بودین). اما نوع‌ساز ِ ‏‎SomeException‎‏ آرگومان نمی‌گیره؛ اون متغیرِ تایپیِ ‏‎e‎‏ یه پارامتر برای داده‌ساز ِه. انتقالِ سوردهنده به داده‌ساز، گستره‌ی کاربردش رو محدود می‌کنه، و معنای for all e رو به there exists some e تغییر میده (م. از به ازای هر e به وجود دارد e ای). این سورِ وجودی ِه. معنی‌ش اینه که هر تایپی که دارای تایپکلاسِ ‏‎Exception‎‏ باشه، می‌تونه اون ‏‎e‎‏ باشه و زیرِ تایپِ ‏‎SomeException‎‏ مشمول بشه.

ما اینجا سور وجودی رو عمیق بررسی نمی‌کنیم؛ فقط داریم مزه‌مزه می‌کنیم. معمولاً وقتی نوع‌سازها پارامتردار میشن، به صورت عمومی سور داده میشن. برای ارضای اونها، باید آرگومان‌هاشون تأمین بشن. تایپِ ‏‎Maybe a‎‏، همونطور که قبلاً هم اشاره کردیم، یه جور تابعی‌ه که منتظرِ یه آرگومان برای تکاملِ‌شه.

اما وقتی یه تایپ رو به صورتِ وجودی سور می‌کنیم (مثلِ ‏‎SomeException‎‏ کارِ زیادی با اون متغیرِ تایپِ پلی‌مورفیک در داده‌ساز‌ِش نمیشه انجام داد. نمیشه معین‌ِش کرد. نمی‌تونیم چیزی ازش بدونیم، فقط میشه بهش محدودیت اضافه کنیم. باید پلی‌مورفیک بمونه. میشه هر مقداری از هر تایپی که اون محدودیت‌ها رو رعایت می‌کنه بجاش بذاریم. مثل یه انگلِ پلی‌مورفیک می‌مونه که از تایپ‌مون آویزونه.

پس اون ‏‎e‎‏ می‌تونه هر تایپِ استثنایی باشه (هر تایپی که یه نمونه از تایپکلاسِ ‏‎Exception‎‏ داشته باشه) و به عنوانِ یه ‏‎SomeException‎‏ مدیریت بشه. همونطور که به زودی می‌بینیم، ‏‎Typeable‎‏ و ‏‎Show‎‏ رو برای تشخیصِ اینکه با چه تایپِ استثنایی کار می‌کنیم لازم داریم.

وایسا، چی شد؟

برای اینکه ببینیم سور وجودی اجازه‌ی چه کارهایی رو میده، مثالی میزنیم که به جادویِ دَم‌ودستگاهِ استثنای زمانِ اجرا وابسته نیست. اینجا خطاها رو در ‏‎Either‎‏ ای از دوتایپِ کاملاً متفاوت برمی‌گردونیم، بدون اینکه مجبور باشیم هردوشون رو زیرِ یه تایپِ جمع متحد کنیم:

{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE GADTs                     #-}

module WhySomeException where

import Control.Exception
  ( ArithException(..)
  , AsyncException(..))
import Data.Typeable

data MyException =
  forall e .
  (Show e, Typeable e) => MyException e

instance Show MyException where
  showsPrec p (MyException e) =
    showsPrec p e

multiError :: Int
           -> Either MyException Int
multiError n =
  case n of
    0 -> Left (MyException DividedByZero)
    1 -> Left (MyException StackOverflow)
    _ -> Right n

چیزی که در موردِ کُدِ بالا خاص‌ه، اینه که در ‏‎Either‎‏ حالتِ ‏‎Left‎‏ شامل مقادیرِ خطا با دو تایپِ کاملاً متفاوت‌ه، بدون اینکه جداگانه در یه تایپِ جمع دسته‌بندی‌شون کنیم. به نظر نمیرسه تایپِ ‏‎MyException‎‏ در نوع‌ساز‌ِش یه آرگومانِ پلی‌مورفیک داشته باشه، اما در داده‌ساز‌ِش داره. به خاطرِ تایپِ ‏‎e‎‏ که بصورتِ وجودی سور شده، میشه داده‌ساز ِ ‏‎MyException‎‏ رو به مقادیرِ تایپ‌های مختلف اعمال کنیم.

data SomeError =
    Arith ArithException
  | Async AsyncException
  | SomethingElse
  deriving (Show)

discriminateError :: MyException
                  -> SomeError
discriminateError (MyException e) =
  case cast e of
    (Just arith) -> Arith arith
    Nothing ->
      case cast e of
        (Just async) -> Async async
        Nothing -> SomethingElse

runDisc n =
  either discriminateError
  (const SomethingElse) (multiError n)

بعد اگه امتحان‌ش کنیم:

Prelude> runDisc 0
Arith divide by zero
Prelude> runDisc 1
Async stack overflow
Prelude> runDisc 2
SomethingElse

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

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

‏‎Typeable‎‏

تایپکلاسِ ‏‎Typeable‎‏ در ماژول ِ ‏‎Data.Typeable‎‏ تعریف شده. ‏‎Typeable‎‏ به تایپ‌ها اجازه میده تا در زمان اجرا شناخته بشن، یعنی یه جور تایپچِکینگِ دینامیک. علاوه بر اینکه اجازه میده در زمان اجرا تایپِ یک مقدار رو بشناسین، میذاره تایپِ دو مقدار رو با هم مقایسه کنین تا ببینین یکی هستن یا نه.

بطور معمول چنین کاری خوب نیست، اما وقتی صحبت از استثناها باشه، کارِ معقولی‌ه. برای مدیریتِ استثنا، باید بتونیم چک کنیم آیا مقادیرِ با تایپ‌های (به احتمال) متنوع با اون تایپِ ‏‎Exception‎‏ ای که می‌خوایم مدیریت کنیم جور هست یا نه؛ و باید بتونیم این کار رو در زمان اجرا انجام بدیم، یعنی زمانی که استثنا رخ میده. پس این شهودِ زمانِ اجرا برای تایپ‌های استثنا لازمه.

یه نگاه به نسخه‌ی ساده شده‌ی متود ِ ‏‎cast‎‏ از تعریف‌ش در ‏‎base‎‏ بندازیم:

cast :: (Typeable a, Typeable b)
     => a -> Maybe b

معمولاً این تابع رو بطور مستقیم صدا نمی‌زنیم، معمولاً تابعِ ‏‎fromException‎‏ صداش میزنه، تابعِ ‏‎fromException‎‏ هم تابعِ ‏‎catch‎‏ صدا میزنه.

در زمان اجرا، وقتی استثنایی انداخته میشه، توی stack (م. یا دسته‌ی تابع‌ها) قِل می‌خوره عقب، و دنبالِ یه ‏‎catch‎‏ میگرده. وقتی یه ‏‎catch‎‏ پیدا می‌کنه، چک می‌کنه ببینه این ‏‎catch‎‏ چه تایپِ استثنایی گیر میاره. توابع ‏‎fromException‎‏ و ‏‎cast‎‏ رو صدا میزنه تا ببینه تایپِ استثنایی که انداخته شده بود با یکی از تایپ‌های استثنایی که با ‏‎catch‎‏ مدیریت می‌کنیم جور هست یا نه. تابعِ ‏‎catch‎‏ ای که یه ‏‎SomeException‎‏ رو به عهده می‌گیره، به خاطر انعطاف‌پذیریِ اون تایپ، هر تایپِ استثنا ِ دیگه‌ای رو هم به عهده می‌گیره.

اگه با هم جور نشن، یه مقدارِ ‏‎Nothing‎‏ می‌گیریم؛ استثنا باز توی stack قل می‌خوره و دنبالِ یه ‏‎catch‎‏ ای می‌گرده که بتونه استثنایی که انداخته شده بود رو مدیریت کنه. اگر هم نتونه پیدا کنه، برنامه‌تون بطور ناخوشایندی می‌میره.

اگه با هم جور بشن، ‏‎Just a‎‏ اجازه میده استثنا رو بگیریم.