ขั้นตอนที่ตามมาเมื่อเขียน lexer เป็นไปตามไวยากรณ์คืออะไร?


13

ในขณะที่อ่านคำตอบสำหรับคำถามที่ชี้แจงเกี่ยวกับแกรมมาร์, Lexers และ Parsersคำตอบดังกล่าวระบุว่า:

[... ] ไวยากรณ์ BNF มีกฎทั้งหมดที่คุณต้องการสำหรับการวิเคราะห์คำและการแยกวิเคราะห์

สิ่งนี้เกิดขึ้นกับฉันค่อนข้างแปลกเพราะจนถึงตอนนี้ฉันมักจะคิดเสมอว่า lexer นั้นไม่ได้ยึดหลักไวยากรณ์เลยในขณะที่ parser มีพื้นฐานมาจากหนึ่ง ฉันมาถึงข้อสรุปนี้หลังจากอ่านโพสต์บล็อกจำนวนมากเกี่ยวกับการเขียน lexers และไม่มีใครเคยใช้1 EBNF / BNF เป็นพื้นฐานสำหรับการออกแบบ

หาก lexers รวมถึง parsers อิงตามไวยากรณ์ EBNF / BNF แล้วจะมีวิธีการสร้าง lexer โดยใช้วิธีนั้นอย่างไร นั่นคือฉันจะสร้าง lexer โดยใช้ไวยากรณ์ EBNF / BNF ที่กำหนดได้อย่างไร

ฉันเคยเห็นโพสต์มากมายที่เกี่ยวข้องกับการเขียนโปรแกรมแยกวิเคราะห์โดยใช้ EBNF / BNF เป็นแนวทางหรือพิมพ์เขียว แต่ฉันเจอมาไม่ถึงตอนนี้ที่แสดงเทียบเท่ากับการออกแบบเล็ก

ตัวอย่างเช่นใช้ไวยากรณ์ต่อไปนี้:

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

เราจะสร้าง lexer ที่ใช้ไวยากรณ์อย่างไร ฉันนึกภาพออกว่าตัวแยกวิเคราะห์สามารถเขียนจากไวยากรณ์เช่นนี้ได้อย่างไร แต่ฉันไม่เข้าใจแนวคิดของการทำแบบเดียวกันกับ lexer

มีกฎหรือตรรกะบางอย่างที่ใช้ในการทำงานเช่นนี้เช่นเดียวกับการเขียนโปรแกรมวิเคราะห์คำหรือไม่? ตรงไปตรงมาฉันเริ่มสงสัยว่าการออกแบบ lexer ใช้ไวยากรณ์ EBNF / BNF เลยหรือเปล่า


1 Extended Backus – Naur formและBackus – Naur form

คำตอบ:


18

Lexers เป็นเพียงตัวแยกวิเคราะห์อย่างง่าย ๆ ที่ใช้เป็นการเพิ่มประสิทธิภาพการทำงานสำหรับตัวแยกวิเคราะห์หลัก ถ้าเรามี lexer, lexer และ parser จะทำงานร่วมกันเพื่ออธิบายภาษาที่สมบูรณ์ โปรแกรมแยกวิเคราะห์ที่ไม่มีเลเยอร์แยกต่างหากบางครั้งเรียกว่า "สแกนเนอร์"

หากไม่มี lexers โปรแกรมแยกวิเคราะห์จะต้องดำเนินการตามตัวอักษรต่ออักขระ เนื่องจาก parser ต้องเก็บข้อมูลเมตาเกี่ยวกับทุกรายการอินพุตและอาจต้องคำนวณตารางล่วงหน้าสำหรับทุกสถานะของรายการอินพุตซึ่งจะส่งผลให้ปริมาณการใช้หน่วยความจำที่ยอมรับไม่ได้สำหรับขนาดอินพุตขนาดใหญ่ โดยเฉพาะอย่างยิ่งเราไม่จำเป็นต้องมีโหนดแยกต่างหากต่อตัวละครในแผนผังไวยากรณ์แบบนามธรรม

เนื่องจากข้อความบนพื้นฐานแบบตัวอักษรต่ออักขระค่อนข้างคลุมเครือสิ่งนี้จะส่งผลให้เกิดความกำกวมมากกว่าที่น่ารำคาญในการจัดการ R → identifier | "for " identifierลองนึกภาพกฎ โดยที่ตัวระบุถูกสร้างขึ้นจากตัวอักษร ASCII ถ้าฉันต้องการหลีกเลี่ยงความคลุมเครือตอนนี้ฉันต้องใช้ lookahead 4 ตัวอักษรเพื่อพิจารณาว่าควรเลือกตัวเลือกใด ด้วย lexer parser จะต้องตรวจสอบว่ามันมี IDENTIFIER หรือ FOR token - 1-token lookahead

ไวยากรณ์สองระดับ

Lexers ทำงานโดยการแปลตัวอักษรเป็นตัวอักษรที่สะดวกกว่า

เครื่องมือแยกวิเคราะห์สแกนเนอร์จะอธิบายไวยากรณ์ (N, Σ, P, S) ที่ไม่ใช่เทอร์มินัล N คือด้านซ้ายมือของกฎในไวยากรณ์ตัวอักษรΣคืออักขระ ASCII โปรดักชั่น P เป็นกฎในไวยากรณ์ และสัญลักษณ์เริ่มต้น S คือกฎระดับสูงสุดของตัวแยกวิเคราะห์

ตอนนี้ lexer กำหนดตัวอักษรของโทเค็น a, b, c, …. สิ่งนี้อนุญาตให้ parser หลักใช้โทเค็นเหล่านี้เป็นตัวอักษร: Σ = {a, b, c, …} สำหรับ lexer โทเค็นเหล่านี้ไม่ใช่เทอร์มินัลและกฎการเริ่มต้น S Lคือ S L →ε | A | b S | c S | …, นั่นคือ: ลำดับของโทเค็นใด ๆ กฎในไวยากรณ์ lexer เป็นกฎทั้งหมดที่จำเป็นในการสร้างโทเค็นเหล่านี้

ประโยชน์ที่มาจากผลการดำเนินงานการแสดงกฎ lexer ในฐานะที่เป็นภาษาประจำ สิ่งเหล่านี้สามารถแยกวิเคราะห์ได้อย่างมีประสิทธิภาพมากกว่าภาษาที่ไม่มีบริบท โดยเฉพาะภาษาปกติสามารถรับรู้ในพื้นที่ O (n) และเวลา O (n) ในทางปฏิบัติผู้สร้างโค้ดสามารถเปลี่ยน lexer ให้เป็นตารางกระโดดที่มีประสิทธิภาพสูงได้

แยกโทเค็นจากไวยากรณ์ของคุณ

ในการสัมผัสกับตัวอย่างของคุณ: digitและstringกฎจะแสดงในระดับตัวอักษรต่ออักขระ เราสามารถใช้สิ่งเหล่านี้เป็นโทเค็น ส่วนที่เหลือของไวยากรณ์ยังคงเหมือนเดิม นี่คือ lexer ไวยากรณ์ที่เขียนเป็นไวยากรณ์เชิงเส้นขวาเพื่อให้ชัดเจนว่าเป็นเรื่องปกติ:

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 ? - '"' ;

แต่เนื่องจากเป็นเรื่องปกติเราจะใช้นิพจน์ทั่วไปเพื่อแสดงไวยากรณ์โทเค็น ต่อไปนี้เป็นคำจำกัดความของโทเค็นข้างต้นว่าเป็น regexes เขียนโดยใช้ไวยากรณ์การยกเว้นคลาสอักขระ. NET และ POSIX charclasses:

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

ไวยากรณ์สำหรับ parser หลักจะมีกฎที่เหลือซึ่งไม่ได้จัดการโดย lexer ในกรณีของคุณนั่นเป็นเพียง:

input = digit | string ;

เมื่อ lexers ไม่สามารถใช้งานได้ง่าย

เมื่อออกแบบภาษาเรามักจะดูแลว่าไวยากรณ์สามารถแยกออกเป็นระดับ lexer และ parser อย่างหมดจดและระดับ lexer อธิบายภาษาปกติ มันเป็นไปไม่ได้เสมอไป

  • เมื่อฝังภาษา บางภาษาอนุญาตให้คุณใส่รหัสเข้าไปในสตริง"name={expression}"ได้ ไวยากรณ์ของนิพจน์เป็นส่วนหนึ่งของไวยากรณ์ที่ไม่ใช้บริบทดังนั้นจึงไม่สามารถโทเค็นโดยนิพจน์ทั่วไป เพื่อแก้ปัญหานี้เราทั้ง recombine parser กับ lexer STRING-CONTENT, INTERPOLATE-START, INTERPOLATE-ENDหรือเราแนะนำราชสกุลเพิ่มเติมเช่น String → STRING-START STRING-CONTENTS { INTERPOLATE-START Expression INTERPOLATE-END STRING-CONTENTS } STRING-ENDกฎไวยากรณ์สตริงแล้วอาจมีลักษณะเช่น: แน่นอนว่านิพจน์อาจมีสตริงอื่นซึ่งทำให้เรามีปัญหาต่อไป

  • เมื่อโทเค็นสามารถมีซึ่งกันและกัน ในภาษาที่เหมือนกับ C คำหลักนั้นแยกไม่ออกจากตัวระบุ สิ่งนี้ถูกแก้ไขใน lexer โดยจัดลำดับความสำคัญของคำหลักมากกว่าตัวระบุ กลยุทธ์ดังกล่าวไม่สามารถทำได้เสมอไป ลองนึกภาพไฟล์Line → IDENTIFIER " = " RESTกำหนดค่าที่ส่วนที่เหลือเป็นตัวอักษรใด ๆ จนถึงจุดสิ้นสุดของบรรทัดแม้ว่าส่วนที่เหลือจะดูเหมือนตัวระบุ a = b cสายตัวอย่างจะเป็น lexer นั้นโง่จริงๆและไม่ทราบว่าโทเค็นใดอาจเกิดขึ้น ดังนั้นหากเราจัดลำดับความสำคัญ IDENTIFIER กว่าส่วนที่เหลือ lexer IDENT(a), " = ", IDENT(b), REST( c)จะให้เรา ถ้าเราให้ความสำคัญกับส่วนที่เหลือกว่าตัวระบุ lexer REST(a = b c)เพียงแค่จะให้เรา

    เพื่อแก้ปัญหานี้เราต้องรวมตัวเล็กกับตัวแยกวิเคราะห์อีกครั้ง การแยกสามารถรักษาได้ด้วยการทำให้ lexer ขี้เกียจ: ทุกครั้งที่ parser ต้องการโทเค็นถัดไปมันจะร้องขอมันจาก lexer และบอกให้ lexer เป็นชุดของโทเค็นที่ยอมรับได้ อย่างมีประสิทธิภาพเรากำลังสร้างกฎระดับสูงสุดใหม่สำหรับไวยากรณ์เล็กซ์เซอร์สำหรับแต่ละตำแหน่ง ที่นี่จะส่งผลให้การโทรnextToken(IDENT), nextToken(" = "), nextToken(REST)และทุกอย่างทำงานได้ดี ต้องมีตัวแยกวิเคราะห์ที่รู้โทเค็นที่ยอมรับได้อย่างสมบูรณ์ในแต่ละสถานที่ซึ่งแสดงถึงตัวแยกวิเคราะห์จากล่างขึ้นบนเช่น LR

  • เมื่อ lexer ต้องรักษาสถานะ เช่นภาษา Python delimits บล็อกรหัสไม่ได้โดยวงเล็บปีกกา แต่โดยเยื้อง มีวิธีในการจัดการกับไวยากรณ์ที่มีความอ่อนไหวของเลย์เอาต์ภายในไวยากรณ์ แต่เทคนิคเหล่านั้น overkill สำหรับ Python แทนเล็กซัสจะตรวจสอบการเยื้องของแต่ละบรรทัดและปล่อยโทเค็น INDENT หากพบบล็อกที่เยื้องใหม่และโทเค็น DEDENT หากบล็อกนั้นจบลง สิ่งนี้จะทำให้ไวยากรณ์หลักง่ายขึ้นเพราะตอนนี้มันสามารถแกล้งโทเค็นเหล่านั้นได้เหมือนกับการจัดฟันแบบหยิก อย่างไรก็ตาม lexer ต้องการรักษาสถานะ: การเยื้องปัจจุบัน นี่หมายถึง lexer ในทางเทคนิคไม่ได้อธิบายภาษาปกติอีกต่อไป แต่จริงๆแล้วเป็นภาษาที่ไวต่อบริบท โชคดีที่ความแตกต่างนี้ไม่เกี่ยวข้องในทางปฏิบัติและผู้ใช้ Python ยังสามารถใช้งานได้ในเวลา O (n)


คำตอบที่ดีมาก @amon ขอบคุณ ฉันจะต้องใช้เวลาในการย่อยอย่างเต็มที่ อย่างไรก็ตามฉันสงสัยบางสิ่งเกี่ยวกับคำตอบของคุณ ในย่อหน้าที่แปดคุณแสดงให้เห็นว่าฉันสามารถแก้ไขตัวอย่าง EBNF ไวยากรณ์ของฉันเป็นกฎสำหรับตัวแยกวิเคราะห์ได้อย่างไร โปรแกรมแยกวิเคราะห์จะใช้ไวยากรณ์ที่คุณแสดงหรือไม่ หรือมีไวยากรณ์แยกต่างหากสำหรับ parser หรือไม่
Christian Dean

@ วิศวกรฉันได้ทำการแก้ไขสองสามครั้ง EBNF ของคุณสามารถใช้งานได้โดย parser โดยตรง อย่างไรก็ตามตัวอย่างของฉันแสดงว่าส่วนใดของไวยากรณ์อาจถูกจัดการโดย lexer แยกต่างหาก กฎระเบียบอื่น ๆ จะยังคงได้รับการจัดการโดยตัวแยกวิเคราะห์หลัก input = digit | stringแต่ในตัวอย่างของคุณว่าเป็นเพียง
amon

4
ข้อได้เปรียบที่สำคัญของตัวแยกวิเคราะห์แบบไม่มีสแกนเนอร์คือการเขียนง่ายกว่ามาก ตัวอย่างสุดขั้วของ parser combinator libraries ซึ่งคุณไม่ได้ทำอะไรนอกจากเขียน parsers ตัวแยกวิเคราะห์การเขียนเป็นสิ่งที่น่าสนใจสำหรับกรณีเช่น ECMAScript-embedded-in-HTML-embedded-in-PHP-sprinkled-with-SQL-with-a-template-language-on-top หรือ Ruby-samples-embedded-in-Markdown- ฝังตัวในทับทิม - เอกสารความคิดเห็นหรืออะไรทำนองนั้น
Jörg W Mittag

สัญลักษณ์แสดงหัวข้อสุดท้ายมีความสำคัญมาก แต่ฉันรู้สึกว่าวิธีที่คุณเขียนมันทำให้เข้าใจผิด มันเป็นความจริงที่ lexers ไม่สามารถใช้งานได้อย่างง่ายดายด้วยไวยากรณ์ที่อิงการเยื้อง แต่การแยกวิเคราะห์แบบสแกนเนอร์นั้นทำได้ยากกว่าในกรณีนี้ ดังนั้นคุณต้องการใช้ lexer จริง ๆถ้าคุณมีภาษาแบบนั้นเพิ่มด้วยสถานะที่เกี่ยวข้อง
user541686

@ Mehrdad Python ในรูปแบบของ lexer-indent / toent tokens เป็นไปได้สำหรับภาษาที่มีความอ่อนไหวง่าย ๆ และไม่สามารถใช้ได้ ทางเลือกทั่วไปคือแกรมม่าแอตทริบิวต์ แต่การสนับสนุนของพวกเขาขาดเครื่องมือมาตรฐาน แนวคิดคือเราใส่หมายเหตุทุกส่วนของ AST ด้วยการย่อหน้าและเพิ่มข้อ จำกัด ให้กับกฎทั้งหมด แอ็ตทริบิวต์นั้นง่ายต่อการเพิ่มด้วยการแยก combinator ซึ่งทำให้การแยกวิเคราะห์แบบสแกนเนอร์ทำได้ง่าย
amon
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.