تجزیه فرآیند تبدیل متن به دادههای معنادار است.
توابع بستهی String در این زمینه محدودند و استفاده از عبارتهای باقاعده دشوار است، بنابراین روش مرسوم در Elm برای تجزیه، استفاده از بستهی elm/parser است.
بیایید یک نمونه از نوشتن تجزیهگر برای (زیرمجموعهای از) زبان برنامهنویسی LOLCODE را با هم مرور کنیم. برنامهی کامنتگذاریشدهی زیر را در نظر بگیرید:
HAI 1 # program version 1
CAN HAS STDIO? # import stdio
VISIBLE "HAI WORLD!" # print "HAI WORLD!"
KTHXBYE # end of program
اول، بیایید یک نوع در Elm بنویسیم تا دستورهای ممکن را نمایش دهیم:
type Command
= Version Int
| Import String
| Print String
سپس میتوان برنامهی LOLCODE ما را بهصورت یک List Command مدل کرد.
بیایید با یک تجزیهگر برای خط HAI 1 شروع کنیم:
versionP : Parser Command
versionP =
Parser.succeed Version
|. Parser.keyword "HAI"
|. Parser.spaces
|= Parser.int
versionP با استفاده از یک «خط لولهی تجزیهگر» ساخته میشود.
عملگر (|.) یعنی «رشته را تجزیه کن اما نتیجه را دور بریز» و (|=) یعنی «رشته را تجزیه کن و نتیجه را نگه دار».
در این مثال، Parser.keyword "HAI" یک «HAI» را مصرف میکند، اما آن نتیجه نادیده گرفته میشود.
Parser.spaces هر تعدادی از کاراکترهای ' '، '\n' و '\r' را مصرف میکند، اما آن هم نادیده گرفته میشود.
Parser.int یک عدد صحیح را تجزیه میکند و مقدار آن عدد (مثلاً ۱) به بالاترین Parser.succeed Version پاس داده میشود و تجزیهگر با موفقیت مقدار Version 1 را برمیگرداند.
اگر هر یک از تجزیهگرهای خط لوله شکست بخورد، یک Parser.DeadEnd برمیگرداند و کل تجزیهگر شکست میخورد.
یک تجزیهگر را میتوان با Parser.run اجرا کرد:
Parser.run versionP "HAI 1"
--> Ok (Version 1)
Parser.run versionP "HAI 2"
--> Ok (Version 2)
Parser.run versionP "BYE 2"
--> Err [{ problem = ExpectingKeyword "HAI", col = 1, row = 1 }]
Parser.run versionP "HAI MOM"
--> Err [{ problem = ExpectingInt, col = 5, row = 1 }]
بیایید به سراغ خط «CAN HAS STDIO?» برویم و آن را با کد زیر تجزیه کنیم:
importP : Parser Command
importP =
Parser.succeed Import
|. Parser.keyword "CAN HAS"
|. Parser.spaces
|= packageP
|. Parser.symbol "?"
importP هم یک خط لولهی تجزیهگر است، اما این بار با Parser.symbol تمام میشود که برای تجزیهی نمادهایی مانند «?» مناسب است.
مقداری که میخواهیم نگه داریم (اسم بستهای که باید وارد شود) با کد زیر تجزیه میشود:
packageP : Parser String
packageP =
Parser.chompWhile Char.isAlphaNum
|> Parser.getChompedString
|> Parser.map String.toLower
چون نمیدانیم اسم بسته چه خواهد بود، نمیتوانیم از Parser.keyword استفاده کنیم؛ در عوض از Parser.chompWhile استفاده میکنیم که تا زمانی که کاراکترها یک شرط را برآورده کنند (اینجا، الفباییعددی بودن)، آنها را یکییکی مصرف میکند.
این کاراکترهای مصرفشده سپس توسط Parser.getChompedString جمعآوری میشوند که یک Parser String برمیگرداند.
در این مورد میخواهیم اسم بستهها را یکدست کنیم، بنابراین از Parser.map استفاده میکنیم تا رشتهی موجود در تجزیهگر را تغییر دهیم و آن را به حروف کوچک تبدیل کنیم.
Parser.run importP "CAN HAS STDIO?"
--> Ok (Import "stdio")
Parser.run importP "CAN HAS StDiO?"
--> Ok (Import "stdio")
Parser.run importP "CAN HAS STDIO"
--> Err [{ problem = ExpectingSymbol "?", col = 14, row = 1 }]
در مرحلهی بعد، VISIBLE "HAI WORLD!" با کد زیر تجزیه میشود:
printP : Parser Command
printP =
Parser.succeed Print
|. Parser.keyword "VISIBLE"
|. Parser.spaces
|. Parser.symbol "\""
|= stringP
|. Parser.symbol "\""
stringP : Parser String
stringP =
Parser.chompWhile (\c -> c /= '"')
|> Parser.getChompedString
Parser.run printP "VISIBLE \"HAI WORLD!\""
--> Ok (Print "HAI WORLD!")
حالا برای هر دستور یک تجزیهگر داریم. طبعاً در یک برنامهی عمومی، ترتیب این دستورها قابل پیشبینی نیست، بنابراین یک تجزیهگر عمومی برای دستورها میسازیم:
commandP : Parser Command
commandP =
Parser.oneOf
[ versionP
, importP
, printP
]
Parser.oneOf تجزیهگرها را یکییکی و به ترتیب دادهشده امتحان میکند.
اگر یک تجزیهگر شکست بخورد، تجزیهگر بعدی امتحان میشود، اما اگر موفق شود، نتیجهاش بدون امتحان کردن تجزیهگرهای باقیمانده برگردانده میشود.
اگر همهی تجزیهگرها شکست بخورند، Parser.oneOf شکست میخورد.
Parser.run commandP "HAI 2"
--> Ok (Version 2)
Parser.run commandP "CAN HAS STDIO?"
--> Ok (Import "stdio")
Parser.run commandP "VISIBLE \"HAI WORLD!\""
--> Ok (Print "HAI WORLD!")
Parser.run commandP "O NOES"
--> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 1 }
-- , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 1 }
-- , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 1 }
-- ]
دقت کنید که هر تلاش ناموفق یک Parser.DeadEnd تولید میکند که Parser.oneOf آنها را جمعآوری میکند.
برای تجزیهی یک برنامهی کامل، باید چندین دستور را پشت سر هم با کد زیر تجزیه کنیم:
programP : Parser (List Command)
programP =
Parser.sequence
{ start = ""
, separator = "\n"
, end = "KTHXBYE"
, spaces = Parser.succeed ()
, item = commandP
, trailing = Parser.Optional
}
Parser.sequence یک تجزیهگر بسیار قابلتنظیم با چندین گزینه است.
start، separator و end رشتههایی میگیرند که عناصر دنباله را از هم جدا میکنند؛ برای تجزیهی یک لیست در Elm از "["، "," و "]" استفاده میکردیم.
spaces با گرفتن یک تجزیهگر که فاصلههای احتمالی بین عناصر و جداکنندهها را مدیریت میکند، انعطاف ایجاد میکند؛ برای تجزیهی یک لیست در Elm از Parser.spaces استفاده میکردیم، اما در این مورد هیچ فاصلهای نمیپذیریم، پس از Parser.succeed () استفاده میکنیم که بلافاصله و بدون مصرف واقعی چیزی موفق میشود.
item تجزیهگر عناصر را میگیرد و در نهایت، trailing به ما اجازه میدهد تصمیم بگیریم که جداکنندههای انتهایی Parser.Forbidden، Parser.Mandatory یا Parser.Optional باشند.
Parser.run programP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run programP "KTHXBYE"
--> Ok []
Parser.run programP "HAI 2\nCAN HAS STDIO?\n"
--> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 3 }
-- , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 3 }
-- , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 3 }
-- , { problem = Expecting "KTHXBYE", col = 1, row = 3 }
-- ]
وقتی تجزیهگرها موفق میشوند، مصرف رشته را متوقف میکنند:
Parser.run Parser.int "1"
--> Ok 1
Parser.run Parser.int "1 BTW VISIBLE \"U SEE NOTHING\""
--> Ok 1
پس اگر باید مطمئن شویم که همهچیز در رشته را تجزیه کردهایم، میتوانیم از Parser.end استفاده کنیم:
fullProgramP : Parser (List Command)
fullProgramP =
programP
|. Parser.spaces
|. Parser.end
Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE\nBTW VISIBLE \"U SEE NOTHING\""
--> Err [{ problem = ExpectingEnd, col = 1, row = 4 }]
در نهایت، گاهی باید بر اساس محتوای تجزیهشده تصمیم بگیریم که یک تجزیهگر را شکست دهیم. برای مثال، اگر به تجزیهگری نیاز داشتیم که برنامههایی را که با دو دستور «HAI» پشت سر هم شروع میشوند شکست دهد، میتوانستیم از کد زیر استفاده کنیم:
singleVersionProgramP : Parser (List Command)
singleVersionProgramP =
fullProgramP
|> Parser.andThen
(\commands ->
case commands of
(Version _) :: (Version _) :: _ ->
Parser.problem "Multiple versions defined"
_ ->
Parser.succeed commands
)
Parser.andThen میتواند محتوای یک تجزیهگر را بررسی کند و بسته به مقدارش یک تجزیهگر جدید برگرداند.
اینجا، در یک حالت تجزیهگر را با یک پیام سفارشی و با استفاده از Parser.problem شکست دادیم و در حالت دیگر مقدار اصلی را برگرداندیم.
Parser.run singleVersionProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run singleVersionProgramP "HAI 1\nHAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Err [{ problem = Problem "Multiple versions defined", col = 8, row = 4 }]
شما هیجانزده هستید که بهعنوان مدیر جدید رستوران، به Paola's Prestigious Pizza بپیوندید. برای اینکه این رستوران را بهشکل کارآمد اداره کنید نقشههای زیادی در سر دارید و برای این کار به چند داده نیاز دارید. اول از همه، باید منو را تحلیل کنید.
انواع مختلفی از پیتزا در منو وجود دارد. پائولا فهرستی را در یک فایل متنی به شما داده است که در هر خط آن یک پیتزا قرار دارد. هر ورودی پیتزا سه بخش دارد: اسم آن، یک نشانگر گیاهخواری اختیاری و یک قیمت.
به دو خط اول منو نگاه میکنید:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
قیمت به نظر جای خوبی برای شروع است. همهی قیمتها اعداد صحیحی در واحد پول یورو هستند.
priceParser را پیادهسازی کنید تا قیمتها را تجزیه کند.
از آنجا که همهی قیمتها به یورو هستند، نیازی نیست واحد پول را پیگیری کنید، هرچند همچنان باید آن را تجزیه کنید.
Parser.run priceParser "8€"
--> Ok 8
Parser.run priceParser "8"
--> Err [{ problem = ExpectingSymbol "€", col = 2, row = 1 }]
دوباره به منو نگاه میکنید:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
نشانگر گیاهخواری "(v)" هم کاملاً ساده است، پس تصمیم میگیرید که مورد بعدی همین باشد!
vegetarianParser را پیادهسازی کنید تا نشانگر را تجزیه کند.
تجزیهکننده باید یک Bool برگرداند: اگر نشانگر وجود داشته باشد True و در غیر این صورت False.
Parser.run vegetarianParser "(v)"
--> Ok True
Parser.run vegetarianParser ""
--> Ok False
خب، مورد بعدی چیست؟
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
مواد اولیه و اسمهای پیتزا نسبتاً سرراست به نظر میرسند: همهشان کلمههای تکی هستند که از حروف بزرگ و کوچک ASCII ساخته شدهاند.
wordParser را پیادهسازی کنید تا اسمها را تجزیه کند.
همینجا همهی کلمهها را برای یکدستی به حروف کوچک تبدیل کنید.
Parser.run wordParser "REGINA"
--> Ok "regina"
Parser.run wordParser "tomato"
--> Ok "tomato"
Parser.run wordParser "(v)"
--> Ok ""
Parser.run wordParser ""
--> Ok ""
اما مواد اولیه بهصورت گروهی میآیند:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
آنها کلمههای تکی هستند که با ویرگول و احتمالاً فاصله از هم جدا شدهاند.
ingredientsParser را پیادهسازی کنید تا مواد اولیه را تجزیه کند.
Parser.run ingredientsParser "tomato, emmental"
--> Ok ["tomato", "emmental"]
Parser.run ingredientsParser "tomato"
--> Ok ["tomato"]
حالا که همهی مواد اولیه را دارید، وقت ساختن یک Pizza است!
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
اول اسم پیتزا را دارید، بعد نشانگر گیاهخواری اختیاری، یک دونقطه ':'، فهرست مواد اولیه، یک خط تیره '-' و قیمت، که ممکن است فاصلههایی لابهلای آنها باشد.
pizzaParser را پیادهسازی کنید تا پیتزای کامل را تجزیه کند.
Parser.run pizzaParser "Regina: tomato, ham, mushrooms, cantal - 11€"
--> Ok (Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11)
وقت جشن گرفتن است!
menuParser را پیادهسازی کنید تا فهرستی از همهی پیتزاها را که در فایل متنی با کاراکترهای خط جدید '\n' از هم جدا شدهاند تجزیه کند.
مطمئن شوید که همهی پیتزاها را گرفتهاید: تجزیهکننده را اجرا کنید تا به انتهای فایل برسد.
Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\nFormaggio (v): tomato, emmental - 8€"
--> Ok [Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11,
-- Pizza "formaggio" True ["tomato", "emmental"] 8]
Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\[END]"
--> Err [{ problem = ExpectingEnd, col = 1, row = 2 }]
وای نه، به نظر میرسد چیزی را از قلم انداختهاید. چند خط پایینتر در منو، بعضی از مواد اولیه تککلمهای نیستند:
...
Tonno: tomato sauce, tuna - 10€
Hawaii: tomato sauce, fresh pineapple, ham - 9€
...
oneIngredientParser را پیادهسازی کنید که حروف بزرگ و کوچک ASCII یا فاصلهها ' ' را میپذیرد.
همچنین از این فرصت استفاده کنید تا یک ایراد کوچک wordParser را برطرف کنید: مطمئن شوید که رشتههای خالی بهعنوان مواد اولیهی معتبر شناسایی نمیشوند و بهجای آن Problem "empty string" تولید میشود.
همینطور مطمئن شوید که رشتهها را به حروف کوچک تبدیل میکند و کاراکترهای فاصله را از دو طرف حذف میکند.
Parser.run oneIngredientParser "Tomato Sauce"
--> Ok "tomato sauce"
Parser.run oneIngredientParser " tomato sauce "
--> Ok "tomato sauce"
Parser.run oneIngredientParser ""
--> Err [{ problem = Problem "empty string", col = 1, row = 1 }]
پس از تعریف تجزیهکننده، از آن در ingredientsParser استفاده کنید.