S-Wyrażenia to podstawa języków rodziny Lisp. W tym wpisie przedstawie, jak napisać prosty parser S-Wyrażeń krok po kroku. Czyli właściwie podstawę dla parsera Lispa. Można by użyć do tego celu generatora parserów, ale prościej jest napisać parser samemu. Użyjemy do tego celu języka JavaScript.
Co to są S-Wyrażenia?
Jeśli nie miałeś jeszcze styczności z językiem LISP to S-Wyrażenia wyglądają tak:
Jest to format danych, gdzie wszystko składa się z atomów albo list otoczonych nawiasami (gdzie atomy są oddzielone spacjami).
Dodatkowo S-Wyrażenia mogą mieć dodatkowe typy danych, dokładnie tak jak JSON czyli:
- liczby
- symbole - które można dowolnie interpretować np. wartość
"true"
albo"t"
, może odpowiadaćtrue
z JavaScript. - ciągi znaków
Kod lispa składa się z S-Wyrażeń, ale to nie jest wszystko do czego mogą służyć. Nadają się doskonale jako format wymiany danych.
S-Wyrażenia są np. używane jako zapis WASM, który można przeczytać. Prawdopodobnie ze wzlędu na prostotę parsowania, oraz na to, że nie trzeba było wymyślać swojego własnego formatu.
Można go np. użyć do komunikacji między serwerem a przeglądarką. Można ich używać zamiast np. JSON-a.
Dodatkowo wyrażenie może mieć specjalny znak kropkę .
która tworzy parę
To para dwóch elementów (nie jestem pewien czy to także część S-Wyrażeń czy już języka LISP).
Jest to alternatywny zapis struktury listy, który bardziej mówi jak dokładnie wyglądają dane i np. listę
Można przedstawić jako
Dzięki takiemu zapisowi można tworzyć dowolne drzewa binarne. W każdym razie my nie będziemy obsługiwać tego typu S-Wyrażeń, aby nie komplikować parsera.
Tokenizer
Najpierw musimy podzielić ciąg znaków na tokeny, czyli nawiasy, ciągi znaków oraz atomy. Tak działają np. niektóre generatory parserów (np. sławna para lex i yacc oraz flex i bison, ta druga para to wolne oprogramowanie, część projektu GNU).
Najprościej jest to wykonać za pomocą wyrażeń regularnych.
Najprostszy przykład to:
i to prawie działa. Pierwszy problem to puste ciągi znaków, między dwoma dopasowaniami oraz na początku i końcu. czyli np. dla:
mamy 5 znaków zamiast 2.
Można to rozwiązać za pomocą funkcji filter
na wynikowej liście (tablicy).
nie będziemy potrzebowali też spacji więc one także można usunąć:
Drugi bardziej poważny problem to baz))
jako ostatni token, to przykład:
problemem jest wyrażenie \S+
które dopasowuje zachłannie. Aby to naprawić należy użyć, takiego wyrażenia:
[^\s()]+
czyli wszystko co nie jest nawiasem i białym znakiem (czyli to samo co \S+
tylko bez nawiasów).
Zapiszmy nasz tokenizer jako funkcje:
nie musimy używać length
ponieważ pusty ciąg znaków także jest konwertowany do wartości boolean
i ma
wartość false
.
Ale co z ciągami znaków? Jeśli np. spróbujemy przetworzyć taki ciąg:
(jest to funkcja w dialekcie Scheme języka LISP) Użyto tutaj szablonów ciągów znaków (ang. template literals), aby można było zapisać znaki nowej linii wewnątrz kodu.
To co jest wewnątrz ciągu znaków nam się już rozsypie (czyli
"funkcja square wywołanie (square 10) zwraca 100"
).
Wyrażenie regularne dla ciągów znaków
Należy dodać ciągi znaków jako wyjątek, najlepiej jako pierwszy element naszego wyrażenia regularnego.
Wyrażenie które dopasowuje się do ciągów znaków może wyglądać tak:
A oto jak będzie wyglądało nasze wynikowe wyrażenie:
Można by też dodać komentarze lispowe, ale ze względu na to, że nie jest to parser języka LISP, tylko S-Wyrażeń, nie będziemy dodawali komentarzy. Tak samo jak nie ma ich w formacje JSON (dodanie ich nie powinno być problemem).
Teraz nasz tokenizer zwraca poprawny wynik:
I to cały tokenizer
.
Parser
Jako że budujemy drzewo, tworząc nasz parser możemy się posłużyć stosem (czyli LIFO - ang. Last In First Out).
Aby w pełni zrozumieć działanie parsera dobrze jest wcześniej znać podstawowe struktury danych, tj. Listy jedno kierunkowe, drzewa binarne oraz Stos.
Oto pierwsza wersja parsera.
Funkcja tworzy tablicę z naszą strukturą w formie tablic. Jeśli będziemy parsowali więcej niż jedno S-Wyrażenie, będziemy mieli więcej elementów w tablicy:
Mimo że nie obsługujemy kropki, czyli S-Wyrażeń w formie:
((foo . 10) (bar . 20))
Nie musimy tworzyć specjalnej struktury dla naszej sparsowanej listy, aby mieć poprawny Parser.
(Taki parser może być np. podstawą jakiegoś interpretera Lispa). Ale dobrze od razu mieć strukturę,
w której będziemy przechowywać nasze S-Wyrażenia, będzie to konstruktor Pair
. Z którego możemy
zbudować drzewo S-Wyrażeń (czyli drzewo binarne). Umożliwi nam to tworzenie dowolnych drzew.
Musimy mieć też coś, co będzie reprezentować pustą listę, zazwyczaj w języku LISP jest to nil.
możemy napisać funkcje, która konwertuje tablicę na tą strukturę:
Aby dodać to do naszej funkcji parsera, wystarczy wstawić na końcu (oczywiście razem z return
):
Niestety jeżeli chciałbyś dodać później operator kropki, to do tworzenia pary musiałbyś już tworzyć strukturę ręcznie, wewnątrz parsera.
Nie konwertujemy samej tablicy result
, ponieważ jest to tylko kontener na S-Wyrażenia, które są
w środku. Każdy element tej tablicy powinien być listą, więc możemy użyć funkcji Array::map
.
sprawdźmy jak to działa
Wynikiem będzie taka struktura (jest to wynik JSON.stringify
z wstawionymi wartościami nil
).
Warto jeszcze napisać funkcje toString
dla obiektów typu Pair.
Sprawdźmy jak to działa:
Został nam jeszcze jeden problem, wynikowa struktura nie ma liczb, tylko wszystko jest ciągiem znaków:
Parsowanie atomów
Najpierw parowanie liczb, do tego celu użyjemy tych wyrażeń (znalezione gdzieś w sieci):
Dalej parsowanie ciągów znaków. Nasze ciągi, są prawie takie same jak te z JSON-a, tylko z jednym
wyjątkiem mogą mieć nowe linie (tak zazwyczaj jest w językach LISP). Aby użyć funkcji JSON.parse
można zamienić nową linie na slash i n czyli \n
na \\n
.
Dzięki temu dostajemy za darmo, obsługę wszystkich znaków ucieczki np, \t
oraz znaków Unicode.
Zawsze warto jest korzystać z udogodnień języka w którym pisany jest parser.
Kolejnym elementem S-Wyrażeń są symbole, czyli dowolny ciąg nie będący ciągiem znaków (tj. bez
cudzysłowów). Możemy utworzyć konstruktor LSymbol
, aby odróżnić go od funkcji Symbol
z języka
JavaScript.
całość funkcji parsującej atomy może wyglądać tak:
Nasz parser z dodaną funkcja parseAtom
:
Można także poprawić funkcje toString
dla obiektów Pair
, aby używała JSON.stringify
dla ciągów
znaków aby odróżnić je od symboli:
I oto cały parser. Pozostały nam jeszcze wartości true
oraz false
(oraz ewentualnie null
).
Zostawiam to jako ćwiczenie dla czytelnika.
Inne rozwiązanie parsera języka Lisp w JavaScript
Tego typu kod nadaje się do prostej implementacji języka Lisp. Aby mieć bardziej zaawansowany Parser i Lexer, możesz zerknąć na moją implementacje w projekcie LIPS: czyli interpreterze języka Scheme w Javascript, gdzie z powodu trudności w modyfikowaniu parsera bazującego na stosie, zastosowałem inne rozwiązanie. Bazuje ono na implementacji z projektu BiwaScheme. Także tokenizer został zastąpiony przez przyrostowy lexer, który umożliwia modyfikowanie kodu parsera, przez kod użytkownika, w tym samym pliku. W przypadku tokenizera, tokeny są generowane na samym początku, w przypadku projektu LIPS, parser używa Lexera, który czyta każde S-Wyrażenie oddzielnie i każde wyrażenie jest następnie parsowane. Dzięki temu w jednym pliku może znajdować się kod, który dodaje nowe tokeny do parsera (dokładnie do Lexera).
Zwróć uwagę na:
Lexer jest to prosta implementacja maszyny stanów (czyli automatu skończonego) z regułami zdefiniowanymi tutaj. Główny algorytm maszyny stanów znajduje w metodzie next_token.
Jest to o wiele bardzie eleganckie rozwiązanie, głównie dzięki swojej prostocie (w porównaniu z wersją poprzednią).
Komentarze
Hasło, które podasz umożliwi ponowne zalogowanie się i np. usunięcie komentarza, jest dobrowolne. Email jest szyfrowany i używany do wysyłania powiadomień o odpowiedziach do komentarzy oraz do pobierania awatara dzięki usłudze Gravatar.com.