۲۳ - ۳درکِ فرایند پارس‌کردن

پارسر یه تابع‌ه که یه ورودیِ نوشتاری می‌گیره (ممکنه ‏‎String‎‏ باشه، یا نوع‌داده‌ای دیگه مثل ‏‎ByteString‎‏ یا ‏‎Text‎‏) و یه ساختار رو به عنوان خروجی برمی‌گردونه. اون ساختار برای مثال ممکنه یه درخت، یا یه نگاشت اندیس‌دار از مکان‌های مختلف در داده ِ پارس‌شده باشه. پارسرها با توجه به قواعدِ تعیین شده در یه گرامر (چه گرامر ِ زبان انسانی، زبان برنامه‌نویسی، یا یه فرمت مثلِ JSON)، ساختار رو بررسی می‌کنن.

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

پارسر تابع‌ه، پس ترکیب‌کننده‌ی پارسر یه تابعِ سطح‌بالا ِه که آرگومان‌هاش پارسِر اند. واسط تعاملی ِ ترکیب‌کننده‌ها معمولاً شبیهِ موند ِ ‏‎State‎‏‌هاست، یعنی دادنِ آرگومان‌ها می‌تونه به سبکِ ‏‎Reader‎‏ و ضمنی باشه. ترکیب‌کننده‌ها امکانِ خوداتکایی و چسبوندنِ ماژولار ِ پارسرها به همدیگه رو دارن تا برمبنای قواعدی پیچیده داده‌ها رو پارس کنن.

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

قیاس برای ‏‎Monad‎‏

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

*

ما از این نسخه‌ی ‏‎trifecta‎‏ استفاده می‌کنیم.

کد بنویسیم:

module LearnParsers where

import Text.Trifecta

stop :: Parser a
stop = unexpected "stop"

با ‏‎unexpected‎‏ میشه در پارسرهایی مثل ‏‎trifecta‎‏ که نمونه‌ای از تایپکلاسِ ‏‎Parsing‎‏ هستن خطا داد. اینجا به منظور نمایش می‌خوایم کاری کنیم که پارسر شکست بخوره.

نمایشِ چی؟

مرسی که پرسیدین! ایده‌ی کلیِ پارسر، حرکتِ یه جور نشانگر روی یه جریان ِ خطی از نوشتاره. در ساده‌ترین حالت، هر واحد مجرد از این جریان یک حرف یا اندیشه‌نگاشت* ِه، ولی جلوتر که پیش میرین، بهتره نگرش‌تون به مسائلِ پارسینگ کلی‌تر بشه. این نشانگر شبیهِ خوندن با انگشت می‌مونه:

Julie bit Papuchon
^
*

م. اندیشه‌نگاشت یا ideograph علائمی هستن که از خودشون صدایی ندارن و برای نشون دادنِ یه جور مفهوم به کار میرن؛ اعداد از مثال‌های اونها هستن (ایموجی‌ها هم همینطور).

حالا فرض کنیم لغتِ Julie رو پارس کردیم – اون ورودی رو مصرف کردیم، پس نشانگر میره روی bit:

Julie bit Papuchon
      ^

اگه اینجا انتظارِ bit رو نداشتیم، پارسر شکست می‌خورد و سرِ لغتِ bit پیغامِ خطا می‌گرفتیم. ولی اگه با موفقیت bit رو پارس و متعاقباً اون ورودی رو مصرف می‌کردیم، اینطوری میشد:

Julie bit Papuchon
          ^

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

برگردیم به کُد

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

اول یه تابعِ کوچولو درست می‌کنیم که فقط یک کاراکتر رو پارس می‌کنه، بعد اون رو با ‏‎stop‎‏ سکانس می‌کنیم (م. یا تسلسل میدیم. به عبارتِ دیگه، ‏‎stop‎‏ رو میذاریم جلوی اون پارسر) تا یک حرف رو بخونه و بعد بمیره:

-- ‎‏رو بخون‏‎ '1' ‎‏یک کارکتر مجرد‏‎
one = char '1'

-- ‎‏رو بخون، بعد بمیر‏‎ '1' ‎‏یک کاراکتر مجرد‏‎
one' = one >> stop
-- char '1' >> stop معادل با

برای ‏‎one'‎‏ از عملگر ِ تسلسل از ‏‎Monad‎‏ برای ترکیب ِ دو پارسر ِ ‏‎stop‎‏ و ‏‎char '1'‎‏ استفاده کردیم. با توجه به تایپِ ‏‎(>>)‎‏:

(>>) :: Monad m => m a -> m b -> m b

میشه قاطعانه فرض کرد که هرچی ‏‎char '1'‎‏ از بیانیه‌ی:

char '1' >> stop

برمی‌گردونه دور انداخته میشه. اما هر اثری که اجراییه ِ ‏‎m a‎‏ روی بافت ِ موندی میذاره باقی می‌مونه. به عبارتِ دیگه مقدارِ حاصل از تابعِ پارس دور انداخته میشه، اما اثر ِ "حرکت دادنِ نشانگر" باقی می‌مونه. "باعثِ شکست ِ پارسر شدن،" یکی دیگه از اثراتِ احتمالی‌ه.

یه کم شبیه...

‏‎State‎‏. به علاوه‌ی شکست. جدی میگیم، یه نگاه به تعریفِ تایپِ ‏‎Parser‎‏ بندازین:

type Parser a = String -> Maybe (a, String)

این رو میشه اینطوری خوند:

۱.

منتظر یه مقدارِ ‏‎String‎‏ باش.

۲.

جوابی بده که شاید موفقیت‌آمیز باشه، شاید هم نباشه (مقدارِ ‏‎Nothing‎‏ یعنی پارس شکست خورده).

۳.

یه توپل از مقدارِ مورد نظر، و باقی‌مونده‌ی نوشته که برای تولیدِ مقدارِ تایپِ ‏‎a‎‏ مصرف نشد برگردون.

حالا تایپ‌های ‏‎Reader‎‏ و ‏‎State‎‏ رو به خاطر بیارین:

newtype Reader r a =
  Reader { runReader :: r -> a }

newtype State s a =
  State { runState :: s -> (a, s) }

اگه متقاعد شدین که ‏‎State‎‏ یه جور بسط از ‏‎Reader‎‏ ِه، و شباهتِ ‏‎Parser‎‏ با ‏‎State‎‏ هم می‌بینین، ادامه بدیم.

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

اگه به پشت‌پرده‌ی یه تابعِ پارسینگ مثلِ ‏‎char‎‏ نگاه کنیم، الگویی مشابهِ ‏‎State‎‏ می‌بینیم (فقط دقت کنین با اینکه این کد به عنوان یه تابعِ پارس ِ کاراکتر می‌تونه کار کنه، خیلی ساده‌ست و کد منبع ِ هیچ کتابخونه ِ پارسینگ ِ مُدِرنی این شکلی نیست):

-- ؛char پیاده‌سازی ابتدایی برای
-- فقط برای نمونه، در همین
-- .وضعیت کار نمی‌کنه
char :: Char -> Parser Char
char c = 
  Parser $ \s ->
    case s of
      (x:xs) -> if c == x
                then [(c, xs)]
                else []
      _ -> []

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

تایپِ پارسر ِ هاتون-مِیِر رو در نظر بگیرین:

-- Text.ParserCombinators.HuttonMeijer از
-- polyparse-1.11

type Token = Char
newtype Parser a =
  P ([Token] -> [(a, [Token])])

-- :همونه، فقط فرمت‌ش عوض شده
type Parser' a = String -> [(a, String)]

با این پارسر، برخلاف پارسر ِ قبلی (که ساده‌تره و رواجِ کمتری هم داره) میشه یه تعدادی از پارس‌های معتبرِ حاصل از ورودی رو گرفت. این پارسر از مدلِ ‏‎Maybe‎‏ قوی‌تره، اما دیگه از این طرح برای ترکیب‌کننده‌های پارسر در کتابخونه‌های محبوبِ هسکل استفاده نمیشه. با اینکه پیشرفت و کشفِ طرحهای جدید باعث تغییرات زیادی در کتابخونه‌های پارسینگ شدن، رابط تعاملی ِ بیشتر اونها در هسکل رفتاری شبیهِ ‏‎State‎‏ داره؛ از این لحاظ که پارس ِ هر چیزی، روی یک یا چند بخش از حالت یه اثر ِ قابل مشاهده میذاره.

معادل چنین چیزی در ‏‎State‎‏ اینه که هر ‏‎put‎‏ روی یه مقدارِ ‏‎State‎‏، برای اجراییه ِ بعدی در همون ‏‎Monad‎‏ قابل مشاهده باشه (برای امتحان کردنِ کُدِ زیر تو REPL، اول ‏‎Control.Monad.Trans.State‎‏ رو وارد کنین). این مثال‌ها از مدلِ ترانسفورمر ِ ‏‎State‎‏ استفاده می‌کنن، اما اگه اون T رو نادیده بگیرین، ایده‌ی کلی رو یاد می‌گیرین:

get :: Monad m => StateT s m s
put :: Monad m => s -> StateT s m ()
runStateT :: StateT s m a -> s -> m (a, s)
Prelude> runStateT (put 8) 7
((),8)
Prelude> runStateT get 8
(8,8)
Prelude> runStateT (put 1 >> get) 8
(1,1)
Prelude> (runStateT $ put 1 >> get) 0
(1,1)
Prelude> (runStateT $ put 2 >> get) 10021490234890
(2,2)
Prelude> (runStateT $ put 2 >> return 9001) 0
(9001,2)

حالا ‏‎put‎‏ یه مقدارِ واحد برمی‌گردونه (یه مقدارِ دورانداختنی) پس درهرصورت فقط برای اثری که میذاره حساب‌ش می‌کنیم. حالت رو تغییر میده ولی هیچ مقداری از خودش نداره. پس وقتی مقدارش رو دور میندازیم فقط اثرش روی حالت باقی می‌مونه. از طرفِ دیگه ‏‎get‎‏ اون مقدار رو بجای هردو جاهای ‏‎a‎‏ و ‏‎s‎‏ در توپل جاگذاری می‌کنه.

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

برگردیم به نظم سابق‌مون در کدنویسی

به سوی کدنویسی:

-- '2' و '1' دوتا کاراکتر بخون،
oneTwo = char '1' >> char '2'

-- ‎‏دوتا کاراکتر بخون،‏‎
-- ‎‏، بعد بمیر‏‎'2' ‎‏و‏‎ '1'
oneTwo' = oneTwo >> stop

testParse :: Parser Char -> IO ()
testParse p =
  print $ parseString p mempty "123"

آرگومانِ ‏‎p‎‏ یه پارسر ِه. دقیق‌تر بگیم، یه پارسر ِ کاراکتره. توابع ‏‎one‎‏ و ‏‎oneTwo‎‏ تایپ‌شون ‏‎Parser Char‎‏ هست. خودتون می‌تونین تایپِ ‏‎one'‎‏ و ‏‎oneTwo'‎‏ رو چک کنین.

به خاطرِ ابهامی که پیش میومد، لازم بود تایپِ ‏‎testParse‎‏ رو تعیین کنیم تا بشه چیزی که پارس کردیم رو نشون بدیم (‏‎Show‎‏ کنیم).

یکی از نکات کلیدی اینه که داریم با پارسرها مثلِ مقادیر برخورد می‌کنیم و ترکیب ِ اونها رو با توابعِ معمولی یا عملگر‌های ‏‎Applicative‎‏ و ‏‎Monad‎‏ انجام میدیم. اینجا ساختار ِ ‏‎Applicative‎‏ یا ‏‎Monad‎‏، خودِ ‏‎Parser‎‏ ِه.

بعد یه تابع می‌نویسیم که یه نوشته رو با یه پیشوندِ خط‌جدید (‏‎\n‎‏) به خروجیِ استاندارد (یا stdout) چاپ کنه، و اون تابع رو به عنوانِ بخشی از یه ‏‎main‎‏ که نشون میده تا نقطه‌ای که رسیده چی بدست آورده استفاده می‌کنیم.

pNL s =
  putStrLn ('\n' : s)

main = do
  pNL "stop:"
  testParse stop
  pNL "one:"
  testParse one
  pNL "one':"
  testParse one'
  pNL "oneTwo:"
  testParse oneTwo
  pNL "oneTwo':"
  testParse oneTwo'

اجراش کنیم و نتیجه‌هاش رو ببینیم. چون اینها برگ چای نیستن و بجاش حروفِ روی صفحه‌ی کامپیوتراند، اسم‌ش رو میذاریم کار علمی. اگه هنوز به ارزش علمی این کار متقاعد نشدین، اجازه دارین یه کت آزمایشگاهی بپوشین و خروجی رو با یه چاپگر ماتریس سوزنی چاپ کنین. بعضی از شما شاید اصلاً ندونین یه چاپگر ماتریس سوزنی چی هست.*

*

مشت به آسمون...

‏‎main‎‏ رو اجرا کنین و ببینین چی میشه:

Prelude> main

stop:
Failure (interactive):1:1: error: unexpected
    stop
123<EOF>
^

آناً بدون اینکه هیچ ورودی‌ای مصرف کنه شکست خورد، به همین خاطر اون نشانگر در پیغامِ خطا، اولِ نوشته‌ست.

جواب بعدی:

one:
Success '1'

یه کاراکتر مجرد رو پارس کردیم، رقمِ ۱. اینکه می‌دونیم موفق شدیم، جواب‌ه. اما مابقیِ جریان ِ ورودی چطور؟ خوب اون چیزی که باهاش پارسر رو اجرا کردیم، باقیِ ورودی رو انداخت زمین. راه‌هایی برای تغییر چنین رفتاری وجود دارن که تو تمرین‌ها توضیح میدیم.

بعدی:

one':
Failure (interactive):1:2: error: unexpected
    stop
123<EOF>
 ^

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

و جواب آخر:

oneTwo:
Success '2'

oneTwo':
Failure (interactive):1:3: error: unexpected
    stop
123<EOF>
  ^

مشابه قبلی‌هاست، فقط دو کاراکتر رو مستقل از هم پارس کردیم. اگه نخوایم اولین کاراکتری که پارس کردیم رو دور بندازیم، و در عوض ‏‎"12"‎‏ رو پارس کنیم چطور؟ تمرینِ زیر!

تمرین‌ها: تمرینِ پارسینگ

۱.

یه ترکیب‌کننده وجود داره که میشه باهاش تعیین کرد در یک جای معین از جریان ورودی، انتظارِ پایانِ جریان رو داشته باشه. در کتابخونه ِ ‏‎parsers‎‏ اسمِ این تابع ‏‎eof‎‏ ِه (مخففِ end-of-file به معنای پایانِ فایل) و در ماژول ِ ‏‎Text.Parser.Combinators‎‏ قرار داره. ببینین می‌تونین کاری کنین پارسرهای ‏‎one‎‏ و ‏‎oneTwo‎‏ به خاطرِ نرسیدن به پایانِ جریان شکست بخورن!

۲.

با استفاده از ‏‎string‎‏ یه ‏‎Parser‎‏ بسازین که از ورودیِ نمونه (در مثال‌های بالا) به ترتیب ‏‎"1"‎‏، ‏‎"12"‎‏، و ‏‎"123"‎‏ رو پارس کنه. سعی کنین با ‏‎stop‎‏ هم ترکیب ِش کنین. یعنی یک دونه پارسر باید بتونه هر سه‌تای اون نوشته‌ها رو پارس کنه.

۳.

سعی کنین با استفاده از ‏‎char‎‏ یه ‏‎Parser‎‏ بنویسین که همون کارِ ‏‎string‎‏ رو انجام میده.

آنتراکت: پارس ِ بیشتر

بیاین با این پارسرها بازی کنیم! معمولاً برای اجرای پارسرها از تابعِ ‏‎parseString‎‏ استفاده می‌کنیم، اما اگه راهی پیدا کردین که باهاش راحت‌تر بودین، ازش استفاده کنین! یه ذره پارسینگ نوشتیم تا یه کم بیشتر با قضیه آشنا بشین:

λ> import Text.Trifecta
λ> :t char
char :: CharParsing m => Char -> m Char
λ> :t parseString
parseString
  :: Parser a
  -> Text.Trifecta.Delta.Delta
  -> String
  -> Result a
λ> let gimmeA = char 'a'
λ> :t parseString gimmeA mempty
parseString gimmeA mempty :: String -> Result Char

λ> parseString gimmeA mempty "a"
Success 'a'
λ> parseString gimmeA mempty "b"
Failure (interactive):1:1: error: expected: "a"
b<EOF>
^

λ> parseString (char 'b') mempty "b"
Success 'b'
λ> parseString (char 'b' >> char 'c') mempty "b"
Failure (interactive):1:2: error: unexpected
    EOF, expected: "c"
b<EOF>
 ^
λ> parseString (char 'b' >> char 'c') mempty "bc"
Success 'c'
λ> parseString (char 'b' >> char 'c') mempty "abc"
Failure (interactive):1:1: error: expected "b"
abc<EOF>
^

به نظر میرسه بجای اینکه مجبور باشیم بیت به بیت پارسِرهای کاراکتر رو پشت هم سوار کنیم. باید یه راهی برای اینکه بگیم "این نوشته رو پارس کن" وجود داشته باشه، اینطور نیست؟ از قرار معلوم، وجود داره:

λ> parseString (string "abc") mempty "abc"
Success "abc"
λ> parseString (string "abc") mempty "bc"
Failure (interactive):1:1: error: expected: "abc"
bc<EOF>
^

λ> parseString (string "abc") mempty "ab"
Failure (interactive):1:1: error: expected: "abc"
ab<EOF>
^

یک پارسر به تنهایی قرار نیست تمام ورودی‌ش رو مصرف کنه – فقط مقداری از نوشته رو که برای تولید یه مقدار از تایپِ درخواستی لازم داره مصرف می‌کنه.

λ> parseString (char 'a') mempty "abcdef"
Success 'a'
λ> let stop = unexpected "stop pls"
λ> parseString (char 'a' >> stop) mempty "abcdef"
Failure (interactive):1:2: error: unexpected
    stop pls
abcdef<EOF>
 ^
λ> parseString (string "abc") mempty "abcdef"
Success "abc"
λ> parseString (string "abc" >> stop) mempty "abcde"
Failure (interactive):1:4: error: unexpected
    stop pls
abcde<EOF>
   ^

با ‏‎trifecta‎‏، مقادیرِ ‏‎ByteString‎‏ که با ‏‎UTF-8‎‏ کدبندی شدن هم میشه پارس کرد:

λ> import Text.Trifecta
λ> :t parseByteString
parseByteString
  :: Parser a
  -> Text.Trifecta.Delta.Delta
  -> Data.ByteString.Internal.ByteString
  -> Result a
λ> parseByteString (char 'a') mempty "a"
Success 'a'

بازی دیگه بسه. برگردیم سراغ چیزای جدی.