۲۳ - ۱۰مارشال از یک AST به یک نوع‌داده

گفته باشیم: این بخش نسبت به بقیه‌ی بخشها یه ذره بیشتر پیش‌زمینه لازم داره. مطالبی که اینجا گفتیم، بدون یه کم تجربه‌ی برنامه‌نویسی خیلی مفید نیستن. ممکنه بعضی مفاهیم و لغات هم براتون ناآشنا باشن.

عملِ پارسینگ رو میشه راهی برای کاهشِ کاردینالیتی ِ ورودی‌ها به مجموعه‌ی چیزهایی‌ه که برنامه براشون جواب داره دونست. وقتی ورودیِ برنامه ‏‎String‎‏، ‏‎Text‎‏، یا ‏‎ByteString‎‏ باشه، احتمالِ اینکه بشه یه کار اختصاصی و معنی‌دار باهاشون انجام داد خیلی کمه. ولی اگه بشه یکی از اونها رو به چیزی با ساختار پارس کنین و ورودی‌های نامناسب رو رَد کنین، اون موقع میشه برنامه‌ی مناسبی نوشت. از اشتباهاتی که برنامه‌نویس‌ها در نوشتن برنامه‌هایی که ورودیِ نوشتاری می‌گیرن انجام میدن اینه که داده‌ها رو در همون فرمتِ نوشتاری نگه می‌دارن و کارهای عجیب‌غریبی می‌کنن تا با ذاتِ بی‌ساختار ِ اونها کنار بیان.

در بعضی موارد، پارس‌کردن به تنهایی کافی نیست. ممکنه یه جور AST یا ارائه‌ای ساختاردار از چیزی که پارس کردین داشته باشین، ولی می‌خواین به یه فرمِ خاصی برسه. یعنی می‌خوایم کاردینالیتی رو محدودتر کنیم و ظاهر داده‌مون رو از اون هم خاص‌تر کنیم. به این مرحله‌ی دوم معمولاً آنمارشال‌کردن ِ داده گفته میشه. مارشال‌کردن به آماده‌کردنِ داده برای سریال‌سازی گفته میشه، چه از حافظه به خودیِ‌خودِش (مرزِ واسطِ تعاملی با تابع خارجی چه از یه واسط تعاملیِ شبکه.

ایده‌ی کلی اینه که دو مسیر برای داده‌هاتون دارین:

     Text (نوشته) -> Structure (ساختار) -> Meaning (مفهوم)
--   parse -> unmarshall

    Meaning  -> Structure -> Text
--  marshall -> serialize

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

مارشال‌کردن و آنمارشال‌کردن ِ داده ِ JSON

در حال حاضر، ‏‎aeson‎‏ محبوب‌ترین کتابخونه ِ JSON* برای هسکل‌ه. برنامه‌نویس‌هایی که از پایتون، روبی ، کلوژر ، جاواسکریپت، یا زبان‌های مشابه میان به هسکل، از اینکه معمولاً هیچ مرحله‌ی مارشال/آنمارشالی وجود نداره کمی گیج میشن. در عوض AST ِ خامِ JSON مستقیماً به عنوانِ یه تیکه داده‌ی بی‌تایپ ارائه میشه. کاربرهای زبان‌های تایپی احتمالاً با چنین چیزی مواجه شدن. ما برای مثال‌های زیر از ‏‎aeson‎‏ نسخه‌ی ‏‎0.10.0.0‎‏ استفاده می‌کنیم.

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes       #-}

module Marshalling where

import Data.Aeson
import Data.ByteString.Lazy (ByteString) 
import Text.RawString.QQ

sectionJson :: ByteString
sectionJson = [r|
{ "section":  {"host": "wikipedia.org"},
  "whatisit": {"red": "intoothandclaw"}
}
|]
*

JSON مخففِ JavaScript Object Notation، به معنای "نشان‌گذاریِ اشیاء جاواسکریپت" هست. خوشبختانه یا بدبختانه، JSON یه فرمت استاندارد و بسیار محبوب‌ه که برای انتقالِ داده، بخصوص بین مرورگرها و سرورها به کار میره. به همین خاطر کار با JSON یکی از کارهای رایج در برنامه‌نویسی‌ه، پس بهتره از الان بهش عادت کنین.

دقت کنین که اینجا تعیین کردیم تایپِ ‏‎sectionJson‎‏ یه ‏‎ByteString‎‏ ِ تنبل ِه. اگه تایپ‌های ‏‎ByteString‎‏ ِ اکید و تنبل رو با هم غاطی کنید، خطا می‌گیرین.

وقتی انتظارِ یه ‏‎ByteString‎‏ ِ تنبل میره، و اکید‌ِش رو تأمین می‌کنین:

<interactive>:10:8:
Couldn't match expected type
Data.ByteString.Lazy.Internal.ByteString
with actual type ByteString

NB:
Data.ByteString.Lazy.Internal.ByteString
  is defined in
  Data.ByteString.Lazy.Internal

ByteString
  is defined in
  Data.ByteString.Internal

تایپ واقعی تایپی‌ه که ما تأمین کردیم؛ تایپ مورد انتظار تایپی‌ه که می‌خواسته. اون ‏‎NB:‎‏ در پیغام خطا مخففِ nota bene به معنای "نکته" است. یا کُدِمون اشتباه بوده (پس تایپِ موردِ انتظار باید تغییر کنه)، یا مقادیرِ اشتباه رو تأمین کردیم (تایپ واقعی – تایپ یا مقادیری که تأمین کردیم باید تغییر کنن). با اشتباهِ زیر در ماژول ِ ‏‎Marshalling‎‏ خودتون هم می‌تونین اون خطا رو بازسازی کنین:

-- :رو از این ByteString وارداتِ نوع‌سازِ
import Data.ByteString.Lazy (ByteString)

-- :به این تغییر بدین
import Data.ByteString (ByteString)

تأمینِ یه ‏‎ByteString‎‏ ِ تنبل، وقتی اکید‌ِش انتظار میره:

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes       #-}

module WantedStrict where

import Data.Aeson
import Data.ByteString.Lazy (ByteString) 
import Text.RawString.QQ

sectionJson :: ByteString
sectionJson = [r|
{ "section":  {"host": "wikipedia.org"},
  "whatisit": {"red": "intoothandclaw"}
}
|]

main = do
  let blah :: Maybe Value
      blah = decodeStrict sectionJson
  print blah

بارگذاری‌ش که کنین، خطای تایپ ِ زیر رو می‌گیرین:

code/wantedStrictGotLazy.hs:19:27:
Couldn't match expected type
  ‘Data.ByteString.Internal.ByteString’
  with actual type ‘ByteString’
NB:
‘Data.ByteString.Internal.ByteString’
  is defined in ‘Data.ByteString.Internal’
‘ByteString’
  is defined in ‘Data.ByteString.Lazy.Internal’

In the first argument of ‘decodeStrict’,
  namely ‘sectionJson’
In the expression: decodeStrict sectionJson

اطلاعاتِ مفیدتر توی ‏‎NB:‎‏ یا nota bene هستن، که به ماژول‌های ‏‎Internal‎‏ اشاره کرده. چیزی که باید به خاطر بسپرین اینه که تایپ واقعی یعنی "کُدِ خودتون،" تایپ مورد انتظار یعنی "تایپی که انتظار دارن،" و اینکه ماژول ِ ‏‎ByteString‎‏ که تو اسم‌ش ‏‎Lazy‎‏ نداره، نسخه‌ی اکید‌ِش‌ه. میشه کُدِمون رو تغییر بدیم تا خطاهای تایپِ بهتری بگیریم:

-- رو ByteString واردات
-- :با این دوتا عوض کنین
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS

-- :تایپ سیگنچر هم به این تغییر بدین
sectionJson :: LBS.ByteString

بعد خطای تایپ‌ِمون اینطوری میشه:

Couldn't match expected type ‘BS.ByteString’
          with actual type   ‘LBS.ByteString'

NB: ‘BS.ByteString’ is defined in
    ‘Data.ByteString.Internal’

    ‘LBS.ByteString is defined in
    ‘Data.ByteString.Lazy.Internal’

In the first argument of ‘decodeStrict’,
  namely ‘sectionJson’
In the expression: decodeStrict sectionJson

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

برگردیم به JSON

رایج‌ترین توابعی که از ‏‎aeson‎‏ استفاده میشن اینها هستن:

Prelude> import Data.Aeson
Prelude> :t encode
encode :: toJSON a => a -> LBS.ByteString
Prelude> :t decode
decode :: FromJSON a => LBS.ByteString -> Maybe a

این توابع یه جورایی دارن مرحله‌ی میانیِ گذشتن از تایپِ ‏‎Value‎‏ رو دور میزنن (اون یکی از تایپ‌های ‏‎aeson‎‏ ِه که یه نوع‌داده ِ AST ِ JSON ِه) – میگیم "یه جورایی،" چون به هر حال میشه از یه داده‌ی خام JSON به یه ‏‎Value‎‏ رسید:

Prelude> decode sectionJson :: Maybe Value
Just (Object (fromList [
("whatisit",
  Object (fromList [("red",
  String "intoothandclaw")])),
("section",
  Object (fromList [("host",
  String "wikipedia.org")]))]))

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

Prelude> decode sectionJson
Nothing

حالا اگه بخوایم این JSON رو بهتر نشون بدیم چطور؟ خب اول نوع‌داده‌های خودمون رو تعریف می‌کنیم تا ببینیم میشه JSON رو به تایپ‌های خودمون decode* کنیم یا نه:

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes       #-}

module Marshalling where

import Data.Aeson
import Data.ByteString.Lazy (ByteString)
import qualified Data.Text as T
import Data.Text (Text)
import Text.RawString.QQ

sectionJson :: ByteString
sectionJson = [r|
{ "section":  {"host": "wikipedia.org"},
  "whatisit": {"red": "intoothandclaw"}
}
|]

data TestData =
  TestData {
    section :: Host
  , what :: Color
  } deriving (Eq, Show)

newtype Host =
  Host String
  deriving (Eq, Show)

type Annotation = String

newtype Color =
    Red Annotation
  | Blue Annotation
  | Yellow Annotation
  deriving (Eq, Show)

main :: IO ()
main = do
  let d = decode sectionJson :: Maybe TestData
  print d
*

م. این لغت متضاد encode هست که تو این کتاب کدبندی ترجمه شده. پس شاید کدگشایی معادل مناسبی برای decode باشه.

این کُد بهتون خطای تایپ میده؛ از نبودِ یه نمونه ِ ‏‎FromJSON‎‏ برای ‏‎TestData‎‏ گله می‌کنه. درست میگه! GHC هیچ ایده‌ای نداره چطور داده ِ JSON (در فُرمِ یک ‏‎Value‎‏) به یه مقدارِ ‏‎TestData‎‏ آنمارشال کنه. پس نمونه‌ِش رو اضافه می‌کنیم:

instance FromJSON TestData where
  parseJSON (Object v) =
    TestData <$> v .: "section"
             <*> v .: "whatisit"
  parseJSON _ =
    fail "Expected an object for TestData"

instance FromJSON Host where
  parseJSON (Object v) =
    Host <$> v .: "host"
  parseJSON _ =
    fail "Expected an object for Host"

instance FromJSON Color where
  parseJSON (Object v) =
       (Red <$> v .: "red")
   <|> (Blue <$> v .: "blue")
   <|> (Yellow <$> v .: "yellow")
  parseJSON _ =
    fail "Expected an object for Color"

دقت کنین که با استفاده از نیم‌نقل‌ها در REPL، دیگه لازم نیست از backslash برای یه سری کاراکترها استفاده کنین (م. به این کار میگن رَدِ کاراکتر):

λ> :set -XOverloadedString
λ> decode "{\"blue\": \"123\"}" :: Maybe Color
Just (Blue "123")
λ> :set -XQuasiQuotes
λ> decode [r|{"red": "123"}|] :: Maybe Color
Just (Red "123")

نمونه ِ ‏‎FromJSON‎‏ تایپِ ‏‎Value‎‏ می‌گیره، و ‏‎ToJSON‎‏ یه تایپِ ‏‎Value‎‏ ایجاد می‌کنه و حلقه ِ زیر بسته میشه (بالاتر رابطه‌ی بین پارسینگ و مارشال‌کردن رو با یه فرمِ کلی‌تر از این حلقه نشون دادیم):

-- FromJSON            v------v م. تایپ خودتون
ByteString -> Value -> yourType
-- parse -> unmarshall

-- ToJSON
  yourType  -> Value -> ByteString
-- marshall -> serialize

در زمانِ نوشتارِ این کتاب، ‏‎Value‎‏ اینطور تعریف شده:

-- | JSON ارائه‌ی یک مقدار
-- | .به عنوان یک مقدار هسکل
data Value = Object !Object
           | Array !Array
           | String !Text
           | Number !Scientific
           | Bool !Bool
           | Null 
           deriving (Eq, Read, Show,
                     Typeable, Data)

اگه بخوایم چیزی رو آنمارشال کنیم که ممکنه یه ‏‎Number‎‏ یا ‏‎String‎‏ باشه چطور؟

data NumberOrString = 
    Numba Integer
  | Stringy Text 
  deriving (Eq, Show)

instance FromJSON NumberOrString where
  parseJSON (Nubmer i) = return $ Numba i
  parseJSON (String s) = return $ Stringy s
  parseJSON _ =
    fail "NumberOrString must\
         \ be number or string"

این کُد، اول‌ش درست کار نمی‌کنه. مشکل اینجاست که JSON (و برحسب اتفاق جاواسکریپت) فقط یک تایپِ عددی داره: ممیزِ شناورِ IEEE-754. JSON (و متأسفانه جاواسکریپت) هیچ تایپِ صحیحی ندارن، در نتیجه ‏‎aeson‎‏ باید چیزی انتخاب کنه که بتونه همه‌ی اعدادِ ممکن در JSON رو ارائه بده. دقیق‌ترین گزینه، تایپِ ‏‎Scientific‎‏ ِه که دقتِ نامحدود داره (خیلی وقت پیش تو فصلِ ۴ این رو گفتیم). پس باید از یه ‏‎Scientific‎‏ به یه ‏‎Integer‎‏ تبدیل کنیم:

import Control.Applicative
import Data.Aeson
import Data.ByteString.Lazy (ByteString)
import qualified Data.Text as T
import Data.Text (Text)
import Text.RawString.QQ
import Data.Scientific (floatingOrInteger)

data NumberOrString = 
    Numba Integer
  | Stringy Text 
  deriving (Eq, Show)

instance FromJSON NumberOrString where
  parseJSON (Number i) = 
    case floatingOrInteger i of
      (Left _) ->
        fail "Must be integral number"
      (Right integer) ->
        return $ Numba integer
  parseJSON (String s) = return $ Stringy s
  parseJSON _ =
    fail "NumberOrString must\
         \ be number or string"

-- تا بدونه می‌خوایم به چی پارس کنیم:
dec :: ByteString
    -> Maybe NumberOrString
dec = decode

eitherDec :: ByteString
          -> Either String NumberOrString
eitherDec = eitherDecode

main = do
  print $ dec "blah"

ببینیم چه می‌کنه:

λ> main
Nothing

چی شد؟ میشه با ‏‎eitherDecode‎‏ امتحان کنیم تا خطای تایپ ِ مفیدتری بگیریم:

main = do
  print $ dec "blah"
  print $ eitherDecode "blah"

مجدداً در REPL:

λ> main
Nothing
Left "Error in $: Failed reading:
      not a valid json value"

پس اینطوری میشه پیغام‌های خطای پربارتری از ‏‎aeson‎‏ بگیریم. اگه بخوایم این کُد کار کنه، می‌تونیم مثال‌های زیر رو امتحان کنیم:

λ> dec "123"
Just (Numba 123)
λ> dec "\"blah\""
Just (Stringy "blah")

حتی اگه زیاد قصدِ کار کردن با JSON ندارین، خوبه که با ‏‎aeson‎‏ آشنا بشین چون خیلی از کتابخونه‌های مرتبط با سریال‌کردن در هسکل، API ِ مشابهی باهاش دارن. با مثالی که زدیم بازی کنین و ببینین چطور با تغییر تایپِ ‏‎dec‎‏ میشه یه لیست از اعداد یا نوشته رو پارس کرد.