Rumah  >  Artikel  >  pembangunan bahagian belakang  >  Bahagian I: Laksanakan penterjemah ungkapan untuk membina DSL - Perkenalkan penghurai PEG

Bahagian I: Laksanakan penterjemah ungkapan untuk membina DSL - Perkenalkan penghurai PEG

PHPz
PHPzasal
2024-08-05 20:40:20397semak imbas

Part I: Implement an expression interpreter for building DSL - Introduce the PEG parser

Dalam jawatan terakhir saya telah memperkenalkan anda KENAPA dan BAGAIMANA saya memulakan projek ini dan BAGAIMANA rupa DSL pada akhirnya. Bermula dari jawatan ini saya akan berkongsi pelaksanaan keseluruhan projek dengan anda.

Secara amnya, apabila kita melaksanakan bahasa, perkara pertama yang terlintas dalam fikiran kita ialah lexer dan kemudian parser. Jadi dalam siaran ini saya akan memperkenalkan kepada anda bagaimana saya melaksanakan DSL saya dengan perincian yang dinyatakan tetapi kurang konsep supaya ia tidak terlalu keliru, saya harap.

Apa itu lexer?

Secara umum, lexer digunakan untuk Analisis Leksikal atau tokenisasi jika anda mahu. Mari kita ambil fasal "Kami akan menggegarkan anda!" (muzik rock and roll terkenal daripada Queen) sebagai contoh. Selepas kami menandakannya mengikut peraturan tatabahasa Bahasa Inggeris, ia akan mengeluarkan senarai [Subjek("Kami"), Auxiliary("will"), Verb("rock"), Object("you"), Tanda Baca("!" )]. Jadi lexer kebanyakannya digunakan untuk mengelaskan teks kepada jenis tertentu mengikut makna leksikalnya. Ini penting bagi kita kerana sintaks sebenarnya terdiri daripada unsur leksikal dan bukannya aksara atau perkataan.

Biasanya kami melaksanakan lexer dengan beberapa penjana kod yang boleh menghuraikan Regular Express seperti Ragel, nex dan sebagainya. Tetapi saya fikir anda akan terkejut betapa mudahnya untuk melaksanakan lexer selepas menyemak Lexical Scanning in Go dari Rob Pike. Dia telah memperkenalkan corak yang menarik untuk melaksanakan mesin keadaan terhingga yang saya fikir adalah teras lexer.

Bagaimana dengan penghurai?

Jadi bagaimana pula dengan penghurai? Untuk apa ia digunakan? Pada asasnya, penghurai digunakan untuk mengenal pasti senarai unsur leksikal dengan corak yang ditentukan yang juga kami panggil tatabahasa. Ambil contoh "Kami akan menggegarkan anda!" yang kami perkenalkan sebelum ini, ia menghasilkan urutan [Subject("Kami"), Auxiliary("will"), Verb("rock"), Object("you"), Tanda Baca("!")] yang sepadan dengan corak 'Future tense' tatabahasa dalam bahasa Inggeris. Jadi inilah yang dilakukan oleh penghurai dan dipanggil 'analisis sintaks'.

Mari kita ambil contoh lain dengan cara yang lebih komputer, bagaimana pula dengan ungkapan seperti 1 + 2 * 3 ? Jelas sekali bahawa mereka akan diterjemahkan ke dalam [Number(1), Operator(+), Number(2), Operator(*), Number(3)] oleh lexer, tetapi urutan ini akan diterjemahkan oleh parser dengan tatabahasa ungkapan matematik biasa? Secara umum, jujukan unsur leksikal akan diterjemahkan ke dalam Pokok Sintaksis Abstrak(atau ringkasnya AST) dengan penghurai seperti ini:

      *
     / \
    +   3
   /  \
  1    2

Dengan pepohon sintaks abstrak, kami boleh menganalisis sintaks dalam susunan yang betul mengikut peraturan tatabahasa kami.

Mari kita laksanakan penghurai

Sekarang kami akan melaksanakan penghurai sendiri. Bukan sepenuhnya sendiri, kami masih memerlukan beberapa alat untuk membantu kami menjana kod untuk penghurai kami kerana sukar untuk melaksanakannya dengan betul dan penghurai tulisan tangan mungkin sukar untuk dikekalkan, walaupun anda melakukannya, prestasinya mungkin miskin.

Nasib baik, terdapat banyak alat generatif parser matang yang membantu kami menjana kod golang dengan fail definisi tatabahasa. Pilihan pertama datang dalam fikiran saya ialah go-yacc yang merupakan alat rasmi untuk menjana kod go untuk parser. Saya pernah menggunakannya untuk menulis penganalisis SQL dan ia tidak menyenangkan kerana ia:

  • memerlukan lexer luaran.
  • kekurangan dokumentasi.
  • definisi kesatuan dan pengisytiharan jenis nilai kedua-duanya agak mengelirukan.

"Mengapa tidak mencuba sesuatu yang baharu?" Saya fikir, jadi begitulah, dengan pasak alat yang menakjubkan saya dapat melaksanakan kedua-dua lexer dan parser dalam satu fail tatabahasa tunggal, satu antara muka tunggal. Mari kita lihat lebih dekat pada pasak.

Melihat lebih dekat pada PEG

PEG adalah singkatan kepada Parsing Expression Grammar yang diperkenalkan oleh Bryan Ford pada tahun 2004, yang merupakan alternatif kepada Context Free Grammar tradisional yang digunakan untuk menerangkan dan menyatakan sintaks dan protokol bahasa pengaturcaraan.

Sejak beberapa dekad yang lalu, kami telah menggunakan alat generatif penghurai seperti yacc, bison yang menyokong CFG untuk menjana kod penghurai, dan jika anda telah menggunakannya sebelum ini, anda mungkin sukar untuk mengelakkan kekaburan dan menyepadukannya dengan lexer atau ungkapan biasa. Sebenarnya, sintaks bahasa pengaturcaraan bukan hanya corak unsur leksikal tetapi juga peraturan unsur leksikal yang entah bagaimana CFG hilang jadi apabila kita menggunakan alat seperti yacc kita perlu melaksanakan lexer sendiri. Selain itu, untuk mengelakkan kekaburan (seperti keutamaan antara tambah dan darab, lihat ini) dalam CFG kita perlu menentukan keutamaan bagi setiap operator. Semua fakta penting ini menjadikannya sukar untuk membangunkan penghurai.

But thanks to Bryan Ford, now we have another good choice, the PEG that allows us to define the lexical and syntax rule all in one single file with a tight DSL and resolve ambiguity in an elegant and simple way. Let me show you how easy it can be done with peg.

Example and code come first

I gonna take examples from my gendsl library which implements a lisp-like syntax(you can check it out here). Here is a simple snippet that can parse hex and decimal number literals in the golang style:

package playground

type parser Peg {
}

Script          <- Value EOF

EOF             <- !.

Value           <- IntegerLiteral

IntegerLiteral  <- [+\-]? ('0' ('x' / 'X') HexNumeral 
                           / DecimalNumeral ) [uU]?

HexNumeral      <- HexDigit ([_]* HexDigit)* / '0'

HexDigit        <- [0-9] / [A-F] / [a-f]

DecimalNumeral  <- [1-9] ([_]* [0-9])* / '0'     

# ...                      

The first line package gendsl is package declaration which decides which package the generated golang file belongs to. The following type declaration type parser Peg {} used to define the parser type, which we will use it later for evaluation but you can ignore it for now.

After the parser type declaration we can start to define your syntax rule till the end. This is different from the workflow I used to work with with yacc when I have to define a union type and a lot of token types before I can actually define my grammar, which could be really confusing. Anyway, let's take a quick look at the grammar definition.

The very first rule

If you have worked with CFG before you might find the definition DSL syntax quite familiar. The right hand side of the '<-' refers to the pattern of lexical elements, which could be some other patterns or character sequence, and the left hand side is the name of the pattern. Pretty easy, right?

Let's pay attention to the first pattern rule here since the first rule is always to entry point of the parser. The entry point Script here is consist of two parts, one is a rule refers to Value which is consist of a sequence of specified characters(we will get back to this later), the other one EOF is kind of interesting. Let's jump to the next rule to find the pattern of EOF. As you can see, EOF is consist of !.. What does !. mean? The !actually means NOT, and . means any character, so !. means NOTHING AT ALL or End Of File if you will. As a result whenever the parser find there is no character to read, it will stop here and treat it as an dummy rule call EOF which might produces the rule Script. This is quite a common pattern for PEG.

More about the rule syntax

So much like the regular expression(RE), the syntax of PEG is simple:

  • . stands for any character.
  • character set like [a-z] is also supported.
  • X matches one character if X is a character or a pattern when X is the name of an rule.
  • X? matches one character or pattern or none, while X* matches zero or more and 'X+' matches one or more.
  • X \ Y matches X or Y where X and Y could be characters, patterns or rule name.

Take the rule of DecimalNumeral as an example. The first part [1-9] means the start of an DecimalNumeral must be one of a digit ranging from 1 to 9, ([_]* [0-9])* means starting from the second position, all character, if there is any, must all be digit(0-9) that has might have no '_' or more than one '_' as its prefix so it could match string like "10_2_3". Otherwise, indicated by the operator \, it could also just be one single character '0' which means 0 obviously .

Resolving ambiguity

I'd like to spend more time to explain the or operator \, since it is quite important as the solution to the ambiguity. The PEG will always try to match the first pattern and then the second, the third and so on til it finds one matched, which is considered as earliest-match-first. For example, a string "ab" will never be able to match the grammar G <- 'a' / 'a' 'b', since the first character 'a' will be reduced to G but the 'b' left cannot match anything. By the way, CFG doesn't allow such a rule and will throw the reduce/shift conflict error.

There is no much syntax left, you can explore them yourself in the pointlander/peg README or peg doc.

Let's have a try

Now that we already have a simple syntax rule prepared above, though it is not the whole grammar for the gendsl project but it can still parse some numbers. Anyway let's generate some code and see if it works as we expect.

Preparation

First we have to install the peg binary tool for code generate following this guide, then we gonna setup our workspace directory for playing:

> mkdir peg_playground && peg_playground 
> go mod init peg_playground 
> touch grammar.peg

Paste the grammar we have before into the peg_playground/grammar.peg, now we should be able to genreate the code using the generate tool but why not make a Makefile in peg_playground/makefile

GO := go

.SUFFIXES: .peg .go

grammar.go: grammar.peg
    peg -switch -inline -strict -output ./$@ $<

all: grammar.go

clean:
    rm grammar.go 

Generate and test the parser

Now that we have everything ready, let's generate the code for parser:

make grammar.go

After running the command, you should see a generated grammar.go in the workspace directory. Let's write a function to parse a string and access our parser:

// peg_playground/parser.go
package playground

func PrintAST(script string) error {
    parser := &parser{
        Buffer: script,
        Pretty: true,
    }

    if err := parser.Init(); err != nil {
        return err
    }
    if err := parser.Parse(); err != nil {
        return err
    }

    parser.PrintSyntaxTree()
    return nil
}

The snippet here is pretty simple, it initializes the parser, parses the script we pass to it and print the syntax tree in final. Let's write an unit test to see if it works:

// peg_playground/parser_test.go
package playground

import (
    "testing"
)

func TestPrintTree(t *testing.T) {
    if err := PrintAST(`0x123`); err != nil {
        t.Fatal(err)
    }
    t.Log("-----------------------------------------------------")

    if err := PrintAST(`10_2_3`); err != nil {
        t.Fatal(err)
    }
    t.Log("-----------------------------------------------------")
}

The test function TestPrintTree calls the PrintAST and check the error. Let's run it now and see what it gonna print:

go test . -v

Now we should see the whole syntax tree in the output:

=== RUN   TestPrintTree
Script "0x123"
 Value "0x123"
  IntegerLiteral "0x123"
   HexNumeral "123"
    HexDigit "1"
    HexDigit "2"
    HexDigit "3"
    parser_test.go:11: -----------------------------------------------------
Script "10_2_3"
 Value "10_2_3"
  IntegerLiteral "10_2_3"
   DecimalNumeral "10_2_3"
    parser_test.go:16: -----------------------------------------------------
--- PASS: TestPrintTree (0.00s)
PASS
ok      playground      0.649s

It looks great, right? Everything works as we expected. No syntax error thrown and it prints every rule matched and the string it matches in a format of tree, which could be really useful when debugging.

Wrap it up

In this post, I have introduced you the two basic but significant parts of interpreter programming language:

  • Lexer, for lexical analysis that transforms a string into a sequence of lexical elements.
  • Parser, for syntax analysis that identify the the pattern (so called grammar) in the lexical elements and produces a syntax tree.

And then I introduce the PEG for parser code generating, and address its advantages comparing the traditional CFG:

  • Lexer rule integrated, no standalone lexer need to be implemented.
  • Simple, regular expression like syntax to start up fast.
  • No ambiguity, no reduce/shift conflict, always earlier-match-first.

Finally I have a tiny demonstration of how to generate parser with PEG, which is the basis of our interpreter.
In next post, I will walk you through the gendsl grammar in detail.
Thank you for checking this post, hope you enjoy it.

Atas ialah kandungan terperinci Bahagian I: Laksanakan penterjemah ungkapan untuk membina DSL - Perkenalkan penghurai PEG. Untuk maklumat lanjut, sila ikut artikel berkaitan lain di laman web China PHP!

Kenyataan:
Kandungan artikel ini disumbangkan secara sukarela oleh netizen, dan hak cipta adalah milik pengarang asal. Laman web ini tidak memikul tanggungjawab undang-undang yang sepadan. Jika anda menemui sebarang kandungan yang disyaki plagiarisme atau pelanggaran, sila hubungi admin@php.cn