Apa prosedur yang diikuti ketika menulis lexer berdasarkan tata bahasa?

13

Saat membaca jawaban atas pertanyaan Klarifikasi tentang Tata Bahasa, Sketsa dan Parser , jawabannya menyatakan bahwa:

[...] tata bahasa BNF berisi semua aturan yang Anda butuhkan untuk analisis leksikal dan penguraian.

Bagi saya ini agak aneh karena sampai sekarang, saya selalu berpikir bahwa lexer sama sekali tidak didasarkan pada tata bahasa, sedangkan parser sangat didasarkan pada satu. Saya sampai pada kesimpulan ini setelah membaca banyak posting blog tentang menulis lexers, dan tidak pernah menggunakan 1 EBNF / BNF sebagai dasar untuk desain.

Jika lexer, dan juga parser, didasarkan pada tata bahasa EBNF / BNF, lalu bagaimana cara membuat lexer menggunakan metode itu? Yaitu, bagaimana saya membangun lexer menggunakan tata bahasa EBNF / BNF yang diberikan?

Saya telah melihat banyak, banyak posting yang berhubungan dengan menulis parser menggunakan EBNF / BNF sebagai panduan atau cetak biru, tapi yang pernah saya jumpai tidak ada sejauh yang menunjukkan setara dengan desain lexer.

Misalnya, ambil tata bahasa berikut:

input = digit| string ;
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
string = '"', { all characters - '"' }, '"' ;
all characters = ? all visible characters ? ;

Bagaimana cara membuat lexer yang didasarkan pada tata bahasa? Saya bisa membayangkan bagaimana parser dapat ditulis dari tata bahasa seperti itu, tapi saya gagal memahami konsep melakukan hal yang sama dengan lexer.

Apakah ada aturan atau logika tertentu yang digunakan untuk menyelesaikan tugas seperti ini, seperti menulis parser? Terus terang, saya mulai bertanya-tanya apakah desain lexer menggunakan tata bahasa EBNF / BNF sama sekali, apalagi didasarkan pada satu.


1 Formulir Extended Backus – Naur dan Backus – Naur

Christian Dean
sumber

Jawaban:

18

Lexers hanyalah parser sederhana yang digunakan sebagai optimasi kinerja untuk parser utama. Jika kita memiliki lexer, lexer dan parser bekerja bersama untuk menggambarkan bahasa yang lengkap. Parser yang tidak memiliki tahap lexing terpisah kadang-kadang disebut "tanpa pemindai".

Tanpa lexers, parser harus beroperasi berdasarkan karakter-demi-karakter. Karena parser harus menyimpan metadata tentang setiap item input, dan mungkin harus melakukan pra-perhitungan tabel untuk setiap status item input, ini akan menghasilkan konsumsi memori yang tidak dapat diterima untuk ukuran input yang besar. Secara khusus, kita tidak perlu node terpisah per karakter di pohon sintaksis abstrak.

Karena teks berdasarkan karakter per karakter cukup ambigu, ini juga akan menghasilkan lebih banyak ambiguitas yang mengganggu untuk ditangani. Bayangkan sebuah aturan R → identifier | "for " identifier. di mana pengidentifikasi terdiri dari surat-surat ASCII. Jika saya ingin menghindari ambiguitas, sekarang saya perlu lookahead 4 karakter untuk menentukan alternatif mana yang harus dipilih. Dengan lexer, parser hanya perlu memeriksa apakah ia memiliki IDENTIFIER atau FOR token - lookahead 1-token.

Tata bahasa dua tingkat.

Lexers bekerja dengan menerjemahkan alfabet input ke alfabet yang lebih nyaman.

Pengurai tanpa pemindai menggambarkan tata bahasa (N, Σ, P, S) di mana non-terminal N adalah sisi kiri aturan dalam tata bahasa, alfabet Σ misalnya karakter ASCII, produksi P adalah aturan dalam tata bahasa , dan simbol awal S adalah aturan tingkat atas parser.

Lexer sekarang mendefinisikan alfabet token a, b, c, .... Ini memungkinkan pengurai utama untuk menggunakan token ini sebagai alfabet: Σ = {a, b, c, ...}. Untuk lexer, token ini adalah non-terminal, dan aturan awal S L adalah S L → ε | a S | b S | c S | ..., yaitu: urutan token apa pun. Aturan dalam tata bahasa lexer adalah semua aturan yang diperlukan untuk menghasilkan token ini.

Keuntungan kinerja berasal dari mengekspresikan aturan lexer sebagai bahasa biasa . Ini dapat diurai jauh lebih efisien daripada bahasa bebas konteks. Secara khusus, bahasa reguler dapat dikenali dalam O (n) ruang dan O (n) waktu. Dalam praktiknya, pembuat kode dapat mengubah lexer menjadi tabel lompatan yang sangat efisien.

Mengekstrak token dari tata bahasa Anda.

Untuk menyentuh contoh Anda: digitdan stringaturan diekspresikan pada tingkat karakter-demi-karakter. Kita bisa menggunakan itu sebagai token. Tata bahasanya tetap utuh. Berikut tata bahasa lexer, ditulis sebagai tata bahasa linier-kanan untuk memperjelas bahwa itu biasa:

digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
string = '"' , string-rest ;
string-rest = '"' | STRING-CHAR, string-rest ;
STRING-CHAR = ? all visible characters ? - '"' ;

Tetapi karena ini biasa, kami biasanya menggunakan ekspresi reguler untuk mengekspresikan sintaks token. Berikut adalah definisi token di atas sebagai regex, ditulis menggunakan sintaks pengecualian kelas karakter NET dan. Charclass POSIX:

digit ~ [0-9]
string ~ "[[:print:]-["]]*"

Tata bahasa untuk parser utama kemudian berisi aturan yang tersisa yang tidak ditangani oleh lexer. Dalam kasus Anda, itu hanya:

input = digit | string ;

Ketika lexers tidak dapat digunakan dengan mudah.

Saat mendesain bahasa, kami biasanya berhati-hati bahwa tata bahasa dapat dipisahkan dengan bersih menjadi tingkat lexer dan tingkat parser, dan bahwa tingkat lexer menggambarkan bahasa biasa. Ini tidak selalu memungkinkan.

  • Saat menyematkan bahasa. Beberapa bahasa memungkinkan Anda untuk interpolasi kode ke string: "name={expression}". Sintaks ekspresi adalah bagian dari tata bahasa bebas konteks dan oleh karena itu tidak dapat dikenali oleh ekspresi reguler. Untuk mengatasi ini, kami menggabungkan kembali parser dengan lexer, atau kami memperkenalkan token tambahan seperti STRING-CONTENT, INTERPOLATE-START, INTERPOLATE-END. Aturan tata bahasa untuk string maka akan terlihat seperti: String → STRING-START STRING-CONTENTS { INTERPOLATE-START Expression INTERPOLATE-END STRING-CONTENTS } STRING-END. Tentu saja Ekspresi dapat berisi string lain, yang membawa kita ke masalah selanjutnya.

  • Kapan token bisa berisi satu sama lain. Dalam bahasa mirip-C, kata kunci tidak dapat dibedakan dari pengidentifikasi. Ini diselesaikan dalam lexer dengan memprioritaskan kata kunci di atas pengidentifikasi. Strategi seperti itu tidak selalu memungkinkan. Bayangkan file config di mana Line → IDENTIFIER " = " REST, di mana sisanya adalah karakter apa pun hingga akhir baris, bahkan jika sisanya terlihat seperti pengenal. Baris contoh akan menjadi a = b c. Lexer benar-benar bodoh dan tidak tahu di mana urutan token dapat terjadi. Jadi jika kita memprioritaskan IDENTIFIER daripada REST, lexer akan memberi kita IDENT(a), " = ", IDENT(b), REST( c). Jika kami memprioritaskan ISTIRAHAT daripada IDENTIFIER, lexer hanya akan memberi kami REST(a = b c).

    Untuk mengatasi ini, kita harus menggabungkan kembali lexer dengan parser. Pemisahan dapat dipertahankan agak dengan membuat lexer malas: setiap kali parser membutuhkan token berikutnya, ia meminta dari lexer dan memberi tahu lexer set token yang dapat diterima. Secara efektif, kami membuat aturan tingkat atas baru untuk tata bahasa lexer untuk setiap posisi. Di sini, ini akan menghasilkan panggilan nextToken(IDENT), nextToken(" = "), nextToken(REST), dan semuanya berfungsi dengan baik. Ini membutuhkan parser yang mengetahui set token lengkap yang dapat diterima di setiap lokasi, yang menyiratkan parser bottom-up seperti LR.

  • Ketika lexer harus mempertahankan status. Misalnya bahasa Python membatasi blok kode bukan oleh kurung kurawal, tetapi oleh lekukan. Ada beberapa cara untuk menangani tata letak yang peka terhadap sintaksis dalam tata bahasa, tetapi teknik-teknik itu berlebihan untuk Python. Sebagai gantinya, lexer memeriksa lekukan setiap baris, dan memancarkan token INDENT jika blok indentasi baru ditemukan, dan token DEDENT jika blok telah berakhir. Ini menyederhanakan tata bahasa utama karena sekarang dapat berpura-pura token itu seperti kurung kurawal. Namun lexer sekarang perlu mempertahankan status: lekukan saat ini. Ini berarti lexer secara teknis tidak lagi menggambarkan bahasa biasa, tetapi sebenarnya bahasa yang peka konteks. Untungnya perbedaan ini tidak relevan dalam praktiknya, dan lexer Python masih dapat bekerja dalam waktu O (n).

amon
sumber
Jawaban yang sangat bagus @amon, Terima kasih. Saya harus meluangkan waktu untuk mencernanya sepenuhnya. Namun saya bertanya-tanya beberapa hal tentang jawaban Anda. Di sekitar paragraf kedelapan, Anda menunjukkan bagaimana saya bisa memodifikasi tata bahasa EBNF contoh saya menjadi aturan untuk parser. Apakah tata bahasa yang Anda tunjukkan juga digunakan oleh pengurai? Atau apakah masih ada tata bahasa terpisah untuk pengurai?
Christian Dean
@ Insinyur Saya telah membuat beberapa suntingan. EBNF Anda dapat digunakan langsung oleh parser. Namun, contoh saya menunjukkan bagian tata bahasa mana yang dapat ditangani oleh lexer terpisah. Aturan lain masih akan ditangani oleh pengurai utama, tetapi dalam contoh Anda itu saja input = digit | string.
amon
4
Keuntungan besar parser tanpa pemindai adalah bahwa mereka lebih mudah untuk dikomposisikan; contoh ekstremnya adalah parser combinator libraries, di mana Anda tidak melakukan apa-apa selain menulis parser. Membuat parser menarik untuk kasus-kasus seperti ECMAScript-embedded-in-HTML-embedded-in-PHP-ditaburi-dengan-SQL-dengan-template-bahasa-di-atas atau Ruby-contoh-tertanam-dalam-Markdown- tertanam di Ruby-dokumentasi-komentar atau sesuatu seperti itu.
Jörg W Mittag
Poin terakhir sangat penting tetapi saya merasa cara Anda menulisnya menyesatkan. Memang benar bahwa lexers tidak dapat digunakan dengan mudah dengan sintaks berbasis indentasi, tetapi parsing tanpa pemindai bahkan lebih sulit dalam hal ini. Jadi Anda benar - benar ingin menggunakan lexer jika Anda memiliki bahasa seperti itu, menambahnya dengan keadaan yang relevan.
user541686
@Mehrdad Indent / dedent yang digerakkan oleh gaya python lexer-driven hanya dimungkinkan untuk bahasa sensitif indentasi yang sangat sederhana, dan umumnya tidak berlaku. Alternatif yang lebih umum adalah tata bahasa atribut, tetapi dukungan mereka kurang dalam alat standar. Idenya adalah bahwa kita membubuhi keterangan setiap fragmen AST dengan lekukannya, dan menambahkan kendala pada semua aturan. Atribut mudah untuk ditambahkan dengan parsing combinator, yang juga membuatnya mudah untuk melakukan parsing tanpa pemindai.
amon