Abstrakcyjne drzewo składniowe: definicjaCzęsto myślimy o drzewach składniowych (i o tym, jak są one konstruowane) w porównaniu do ich odpowiedników w postaci drzew parseków, które są nam już całkiem dobrze znane. Wiemy, że drzewa parse są drzewiastymi strukturami danych, które zawierają strukturę gramatyczną naszego kodu; innymi słowy, zawierają wszystkie informacje składniowe, które pojawiają się w „zdaniu” kodu i pochodzą bezpośrednio z gramatyki samego języka programowania.
Abstrakcyjne drzewo składniowe, z drugiej strony, ignoruje znaczną ilość informacji składniowych, które w przeciwnym razie zawierałoby drzewo parse.
W przeciwieństwie do tego, AST zawiera tylko informacje związane z analizą tekstu źródłowego i pomija wszelkie inne dodatkowe treści, które są używane podczas parsowania tekstu.
To rozróżnienie zaczyna mieć dużo więcej sensu, jeśli skupimy się na „abstrakcyjności” AST.
Przypominamy, że drzewo parsekcyjne jest ilustrowaną, obrazową wersją struktury gramatycznej zdania. Innymi słowy, możemy powiedzieć, że drzewo parseków reprezentuje dokładnie to, jak wygląda wyrażenie, zdanie lub tekst. Jest to w zasadzie bezpośrednie tłumaczenie samego tekstu; bierzemy zdanie i zamieniamy każdy jego mały fragment – od interpunkcji, przez wyrażenia, po tokeny – w drzewiastą strukturę danych. Ujawnia to konkretną składnię tekstu, dlatego też jest również określane jako konkretne drzewo składniowe, lub CST. Używamy terminu konkretne, aby opisać tę strukturę, ponieważ jest to gramatyczna kopia naszego kodu, token po tokenie, w formacie drzewa.
Ale co sprawia, że coś jest konkretne w porównaniu z abstrakcyjnym? Cóż, abstrakcyjne drzewo składniowe nie pokazuje nam dokładnie, jak wygląda wyrażenie, w sposób, w jaki robi to drzewo parse.
Raczej, abstrakcyjne drzewo składniowe pokazuje nam „ważne” kawałki – rzeczy, na których nam naprawdę zależy, które nadają znaczenie naszemu kodowi „zdanie” samo w sobie. Drzewa składniowe pokazują nam istotne kawałki wyrażenia, lub wyabstrahowaną składnię naszego tekstu źródłowego. Stąd, w porównaniu z konkretnymi drzewami składni, struktury te są abstrakcyjną reprezentacją naszego kodu (i pod pewnymi względami mniej dokładną), i właśnie stąd wzięła się ich nazwa.
Teraz, gdy rozumiemy różnicę pomiędzy tymi dwoma strukturami danych i różnymi sposobami, na jakie mogą one reprezentować nasz kod, warto zadać pytanie: gdzie abstrakcyjne drzewo składni pasuje do kompilatora? Najpierw przypomnijmy sobie wszystko, co wiemy o procesie kompilacji, jaki znamy do tej pory.
Powiedzmy, że mamy super krótki i słodki tekst źródłowy, który wygląda tak: 5 + (1 x 12)
.
Przypomnimy sobie, że pierwszą rzeczą, która dzieje się w procesie kompilacji, jest skanowanie tekstu, zadanie wykonywane przez skaner, w wyniku którego tekst jest rozbijany na jego najmniejsze możliwe części, które nazywamy leksemami. Ta część będzie niezależna od języka, a my skończymy z rozebraną wersją naszego tekstu źródłowego.
Następnie, te właśnie leksemy są przekazywane do lexera/tokenizatora, który zamienia te małe reprezentacje naszego tekstu źródłowego w tokeny, które będą specyficzne dla naszego języka. Nasze tokeny będą wyglądały mniej więcej tak:
. Wspólny wysiłek skanera i tokenizera składa się na analizę leksykalną kompilacji.
Następnie, gdy nasze dane wejściowe zostały tokenizowane, ich wynik jest przekazywany do naszego parsera, który następnie bierze tekst źródłowy i buduje z niego drzewo parsowania. Poniższa ilustracja pokazuje, jak wygląda nasz tokenizowany kod w formacie drzewa parsekcyjnego.
Praca polegająca na przekształcaniu tokenów w drzewo parsekcyjne jest również nazywana parsowaniem i jest znana jako faza analizy składni. Faza analizy składni zależy bezpośrednio od fazy analizy leksykalnej; dlatego analiza leksykalna musi być zawsze na pierwszym miejscu w procesie kompilacji, ponieważ parser naszego kompilatora może wykonać swoją pracę tylko wtedy, gdy tokenizer wykona swoją pracę!
Możemy myśleć o częściach kompilatora jak o dobrych przyjaciołach, którzy wszyscy zależą od siebie nawzajem, aby upewnić się, że nasz kod jest poprawnie przekształcony z tekstu lub pliku w drzewo parsowania.
Ale wracając do naszego pierwotnego pytania: gdzie abstrakcyjne drzewo składniowe pasuje do tej grupy przyjaciół? Cóż, aby odpowiedzieć na to pytanie, należy zrozumieć potrzebę istnienia AST w pierwszej kolejności.
Kondensacja jednego drzewa w drugie
Dobrze, więc teraz mamy dwa drzewa, które musimy trzymać prosto w naszych głowach. Mieliśmy już drzewo parse, a tu jeszcze jedna struktura danych do nauczenia się! I najwyraźniej, ta struktura danych AST jest tylko uproszczonym drzewem parseków. Więc, po co nam to? Jaki jest tego cel?
Przyjrzyjrzyjmy się naszemu drzewu parseków, dobrze?
Wiemy już, że drzewa parseków reprezentują nasz program w jego najbardziej wyraźnych częściach; w rzeczy samej, to dlatego skaner i tokenizer mają tak ważne zadania rozbijania naszego wyrażenia na jego najmniejsze części!
Co to właściwie znaczy reprezentować program w jego najbardziej wyraźnych częściach?
Jak się okazuje, czasami wszystkie odrębne części programu nie są dla nas aż tak użyteczne przez cały czas.
Przyjrzyjmy się przedstawionej tu ilustracji, która reprezentuje nasze oryginalne wyrażenie, 5 + (1 x 12)
, w formacie drzewa parsekcyjnego. Jeśli przyjrzymy się temu drzewu krytycznym okiem, zobaczymy, że istnieje kilka przypadków, w których jeden węzeł ma dokładnie jedno dziecko. Węzły te są również nazywane węzłami pojedynczego następcy, ponieważ mają tylko jeden węzeł potomny wynikający z nich (lub jeden „następca”).
W przypadku naszego przykładu drzewa parsekcyjnego, węzły pojedynczego następcy mają węzeł nadrzędny Expression
, lub Exp
, które mają pojedynczego następcę o pewnej wartości, takiej jak 5
, 1
, lub 12
. Jednakże, węzły nadrzędne Exp
nie dodają do nas nic wartościowego, prawda? Widzimy, że zawierają one węzły potomne tokenów/terminali, ale tak naprawdę nie obchodzi nas węzeł nadrzędny „wyrażenie”; wszystko, co naprawdę chcemy wiedzieć, to jakie jest to wyrażenie?
Węzeł nadrzędny nie daje nam żadnych dodatkowych informacji po sparsowaniu naszego drzewa. Zamiast tego, to co nas naprawdę interesuje, to węzeł z pojedynczym dzieckiem i pojedynczym następnikiem. W rzeczywistości jest to węzeł, który daje nam ważne informacje, część, która jest dla nas istotna: liczbę i wartość! Biorąc pod uwagę fakt, że te węzły nadrzędne są dla nas zbędne, staje się oczywiste, że to drzewo parsekcyjne jest w pewnym sensie nieczytelne.
Wszystkie te węzły pojedynczego następnika są dla nas zbędne i w ogóle nam nie pomagają. Więc pozbądźmy się ich!
Jeśli skompresujemy węzły pojedynczego następcy w naszym drzewie parseków, w końcu otrzymamy bardziej skompresowaną wersję tej samej dokładnej struktury. Patrząc na powyższą ilustrację, zobaczymy, że wciąż zachowujemy dokładnie takie samo zagnieżdżenie jak poprzednio, a nasze węzły/tokeny/terminale wciąż pojawiają się we właściwym miejscu w drzewie. Ale, udało nam się je trochę odchudzić.
I możemy również przyciąć trochę więcej naszego drzewa. Na przykład, jeśli spojrzymy na nasze drzewo parsowania w obecnej postaci, zobaczymy, że istnieje w nim struktura lustrzana. Podwyrażenie (1 x 12)
jest zagnieżdżone wewnątrz nawiasów ()
, które same w sobie są tokenami.
Jednakże te nawiasy tak naprawdę nie pomagają nam, gdy już mamy nasze drzewo. Wiemy już, że 1
i 12
są argumentami, które zostaną przekazane do operacji mnożenia x
, więc nawiasy nie mówią nam zbyt wiele w tym momencie. W rzeczywistości, moglibyśmy skompresować nasze drzewo parseków jeszcze bardziej i pozbyć się tych zbędnych węzłów liści.
Kiedy skompresujemy i uprościmy nasze drzewo parseków i pozbędziemy się zbędnego „kurzu” składniowego, otrzymamy strukturę, która w tym momencie wygląda wyraźnie inaczej. Ta struktura jest w rzeczywistości naszym nowym i wyczekiwanym przyjacielem: abstrakcyjnym drzewem składni.