用go实现Parsers & Lexers

在当今网络应用和REST API的时代,编写解析器似乎是一种垂死的艺术。你可能会认为编写解析器是一个复杂的工作,只保留给编程语言设计师,但我想消除这种观念。在过去几年中,我为JSON,CSS3和数据库查询语言编写了解析器,所写的解析器越多,我越喜欢他们。

基础(The Basics)

让我们从基础开始:什么是词法分析器?什么解析器?当我们分析一种语言(或从技术上讲,一种“正式语法”)时,我们分两个阶段进行。首先我们将一系列的字符分解成tokens。对于类似SQL的语言,这些tokens可能是“whitespace“,”number“,“SELECT“等。这个处理过程叫作lexing(或者tokenizing,或scanning)

以此简单的SQL SELECT语句为例:

SELECT * FROM mytable

当我们标记(tokenize)这个字符串时,我们会得到:

`SELECT` • `WS` • `ASTERISK` • `WS` • `FROM` • `WS` • `STRING<"mytable">`

这个过程称为词法分析(lexical analysis),与阅读时我们如何分解句子中的单词相似。这些tokens随后被反馈给执行语义分析的解析器。

解析器的任务是理解这些token,并确保它们的顺序正确。这类似于我们如何从句子中组合单词得出意思。我们的解析器将从token序列中构造出一个抽象语法树(AST),而AST是我们的应用程序将使用的。

在SQL SELECT示例中,我们的AST可能如下所示:

type SelectStatement struct {
         Fields []string
         TableName string
}

解析器生成器 (Parser Generators)

许多人使用解析器生成器(Parser Generators)为他们自动写一个解析器(parser)和词法分析器(lexer)。有很多工具可以做到这一点:lex,yacc,ragel。还有一个内置在go 工具链中的用go实现的yacc(goyacc)。

然而,在多次使用解析器生成器后,我发现有些问题。首先,他们涉及到学习一种新的语言来声明你的语言格式,其次,他们很难调试。例如,尝试阅读ruby语言的yacc文件。Eek!

在看完Rob Pike关于lexical scanning的演讲和读完go标准库包的实现后,我意识到手写一个自己的parser和lexer多么简单和容易。让我们用一个简单的例子来演示这个过程。

用Go写一个lexer

定义我们的tokens

我们首先为SQL SELECT语句编写一个简单的解析器和词法分析器。首先,我们需要用定义在我们的语言中允许的标记。我们只允许SQL 语言的一小部分:

// Token represents a lexical token.
type Token int
 
const (
         // Special tokens
         ILLEGAL Token = iota
         EOF
         WS
 
         // Literals
         IDENT // fields, table_name
 
         // Misc characters
         ASTERISK // *
         COMMA    // ,
 
         // Keywords
         SELECT
         FROM
)

我们将使用这些tokens来表示字符序列。例如WS将表示一个或多个空白字符,IDENT将表示一个标识符,例如字段名或表名称。

定义字符类  (Defining character classes)

定义可以检查字符类型的函数很有用。这里我们定义两个函数,一个用于检查一个字符是否为空格,另一个用于检查字符是否是字母。

func isWhitespace(ch rune) bool {
         return ch == ' ' || ch == '\t' || ch == '\n'
}
 
func isLetter(ch rune) bool {
         return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
}

定义“EOF”也是有用的,以便像任何其他character一样对的EOF:

var eof = rune(0)

Scanning our input

接下来,我们要定义我们的扫描器类型。这个类型将用一个bufio.Reader包装输入阅读器,我们可以从头部取字符。我们还添加帮助函数(helper function),用于从底层Reader读取,取消读取字符。

// Scanner represents a lexical scanner.
type Scanner struct {
         r *bufio.Reader
}
 
// NewScanner returns a new instance of Scanner.
func NewScanner(r io.Reader) *Scanner {
         return &Scanner{r: bufio.NewReader(r)}
}
 
// read reads the next rune from the bufferred reader.
// Returns the rune(0) if an error occurs (or io.EOF is returned).
func (s *Scanner) read() rune {
         ch, _, err := s.r.ReadRune()
         if err != nil {
                 return eof
         }
         return ch
}
 
// unread places the previously read rune back on the reader.
func (s *Scanner) unread() { _ = s.r.UnreadRune() }

Scanner的入口函数是Scan()方法,它返回下一个token和它所代表的文字字符串。

// Scan returns the next token and literal value.
func (s *Scanner) Scan() (tok Token, lit string) {
         // Read the next rune.
         ch := s.read()
 
         // If we see whitespace then consume all contiguous whitespace.
         // If we see a letter then consume as an ident or reserved word.
         if isWhitespace(ch) {
                 s.unread()
                 return s.scanWhitespace()
         } else if isLetter(ch) {
                 s.unread()
                 return s.scanIdent()
         }
 
         // Otherwise read the individual character.
         switch ch {
         case eof:
                 return EOF, ""
         case '*':
                 return ASTERISK, string(ch)
         case ',':
                 return COMMA, string(ch)
         }
 
         return ILLEGAL, string(ch)
}

该入口函数从读取第一个字符开始。如果字符是whitespace,那么它将与所有连续的whitespace一起使用。如果是一个letter,则被视为identifier和keyword的开始。否则,我们将检查它是否是我们的单字符tokens之一。

扫描连续字符  Scanning contiguous characters

当我们想要连续使用多个字符时,我们可以在一个简单的循环中执行此操作。在scanWhitespace()中,我们假设在碰到一个非空格字符前所有字符都是whitespaces。

// scanWhitespace consumes the current rune and all contiguous whitespace.
func (s *Scanner) scanWhitespace() (tok Token, lit string) {
         // Create a buffer and read the current character into it.
         var buf bytes.Buffer
         buf.WriteRune(s.read())
 
         // Read every subsequent whitespace character into the buffer.
         // Non-whitespace characters and EOF will cause the loop to exit.
         for {
                 if ch := s.read(); ch == eof {
                          break
                 } else if !isWhitespace(ch) {
                          s.unread()
                          break
                 } else {
                          buf.WriteRune(ch)
                 }
         }
 
         return WS, buf.String()
}

相同的逻辑可以应用于扫描identifiers。在scanident()中,我们将读取所有字母和下划线,直到遇到不同的字符:

// scanIdent consumes the current rune and all contiguous ident runes.
func (s *Scanner) scanIdent() (tok Token, lit string) {
         // Create a buffer and read the current character into it.
         var buf bytes.Buffer
         buf.WriteRune(s.read())
 
         // Read every subsequent ident character into the buffer.
         // Non-ident characters and EOF will cause the loop to exit.
         for {
                 if ch := s.read(); ch == eof {
                          break
                 } else if !isLetter(ch) && !isDigit(ch) && ch != '_' {
                          s.unread()
                          break
                 } else {
                          _, _ = buf.WriteRune(ch)
                 }
         }
 
         // If the string matches a keyword then return that keyword.
         switch strings.ToUpper(buf.String()) {
         case "SELECT":
                 return SELECT, buf.String()
         case "FROM":
                 return FROM, buf.String()
         }
 
         // Otherwise return as a regular identifier.
         return IDENT, buf.String()
}

这个函数在后面会检查文字字符串是否是一个保留字,如果是,将返回一个指定的token。

用Go写一个解析器 Writing a Parser in Go

设置解析器

一旦我们准备好lexer,解析SQL语句就变得更加容易了。首先定义我们的parser:

// Parser represents a parser.
type Parser struct {
         s   *Scanner
         buf struct {
                 tok Token  // last read token
                 lit string // last read literal
                 n   int    // buffer size (max=1)
         }
}
 
// NewParser returns a new instance of Parser.
func NewParser(r io.Reader) *Parser {
         return &Parser{s: NewScanner(r)}
}

我们的解析器只是包装了我们的scanner,还为上一个读取token添加了缓冲区。我们定义helper function进行扫描和取消扫描,以便使用这个缓冲区。

// scan returns the next token from the underlying scanner.
// If a token has been unscanned then read that instead.
func (p *Parser) scan() (tok Token, lit string) {
         // If we have a token on the buffer, then return it.
         if p.buf.n != 0 {
                 p.buf.n = 0
                 return p.buf.tok, p.buf.lit
         }
 
         // Otherwise read the next token from the scanner.
         tok, lit = p.s.Scan()
 
         // Save it to the buffer in case we unscan later.
         p.buf.tok, p.buf.lit = tok, lit
 
         return
}
 
// unscan pushes the previously read token back onto the buffer.
func (p *Parser) unscan() { p.buf.n = 1 }

我们的parser此时已经不关心whitespaces了,所以将定义一个helper 函数来查找下一个非空白标记(token)

// scanIgnoreWhitespace scans the next non-whitespace token.
func (p *Parser) scanIgnoreWhitespace() (tok Token, lit string) {
         tok, lit = p.scan()
         if tok == WS {
                 tok, lit = p.scan()
         }
         return
}

解析输入 Parsing the input

我们的解析器的entry function是parse()方法。该函数将从Reader中解析下一个SELECT语句。如果reader中有多个语句,那么我们可以重复调用这个函数。

func (p *Parser) Parse() (*SelectStatement, error)

我们将这个函数分解成几个小部分。首先定义我们要从函数返回的AST结构

stmt := &SelectStatement{}

然后我们要确保有一个SELECT token。如果没有看到我们期望的token,那么将返回一个错误来报告我们我们发现的字符串。

if tok, lit := p.scanIgnoreWhitespace(); tok != SELECT {
         return nil, fmt.Errorf("found %q, expected SELECT", lit)
}

接下来要解析以逗号分隔的字段列表。在我们的解析器中,我们只考虑identifiers和一个星号作为可能的字段:

for {
         // Read a field.
         tok, lit := p.scanIgnoreWhitespace()
         if tok != IDENT && tok != ASTERISK {
                 return nil, fmt.Errorf("found %q, expected field", lit)
         }
         stmt.Fields = append(stmt.Fields, lit)
 
         // If the next token is not a comma then break the loop.
         if tok, _ := p.scanIgnoreWhitespace(); tok != COMMA {
                 p.unscan()
                 break
         }
}

在字段列表后,我们希望看到一个From关键字:

// Next we should see the "FROM" keyword.
if tok, lit := p.scanIgnoreWhitespace(); tok != FROM {
         return nil, fmt.Errorf("found %q, expected FROM", lit)
}

然后我们想要看到选择的表的名称。这应该是标识符token

tok, lit := p.scanIgnoreWhitespace()
if tok != IDENT {
         return nil, fmt.Errorf("found %q, expected table name", lit)
}
stmt.TableName = lit

如果到了这一步,我们已经成功分析了一个简单的SQL SELECT 语句,这样我们就可以返回我们的AST结构:

return stmt, nil

恭喜!你已经建立了一个可以工作的parser

深入了解,你可以在以下位置找到本示例完整的源代码(带有测试)https://github.com/benbjohnson/sql-parser

这个解析器的例子深受InfluxQL解析器的启发。如果您有兴趣深入了解并理解多个语句解析,表达式解析或运算符优先级,那么我建议您查看仓库:

https://github.com/influxdb/influxdb/tree/master/influxql

如果您有任何问题或想聊聊解析器,请在Twitter上@benbjohnson联系我。

Handwritten Parsers & Lexers in Go (翻译)的更多相关文章

  1. Handwritten Parsers & Lexers in Go (Gopher Academy Blog)

    Handwritten Parsers & Lexers in Go (原文地址  https://blog.gopheracademy.com/advent-2014/parsers-lex ...

  2. Writing a simple Lexer in PHP/C++/Java

    catalog . Comparison of parser generators . Writing a simple lexer in PHP . phc . JLexPHP: A PHP Lex ...

  3. RAPIDXML 中文手册,根据官方文档完整翻译!

    简介:这个号称是最快的DOM模型XML分析器,在使用它之前我都是用TinyXML的,因为它小巧和容易上手,但真正在项目中使用时才发现如果分析一个比较大的XML时TinyXML还是表现一般,所以我们决定 ...

  4. 《Django By Example》第十二章 中文 翻译 (个人学习,渣翻)

    书籍出处:https://www.packtpub.com/web-development/django-example 原作者:Antonio Melé (译者注:第十二章,全书最后一章,终于到这章 ...

  5. R-CNN论文翻译

    R-CNN论文翻译 Rich feature hierarchies for accurate object detection and semantic segmentation 用于精确物体定位和 ...

  6. AlexNet论文翻译-ImageNet Classification with Deep Convolutional Neural Networks

    ImageNet Classification with Deep Convolutional Neural Networks 深度卷积神经网络的ImageNet分类 Alex Krizhevsky ...

  7. 《Django By Example》第十二章(终章) 中文 翻译 (个人学习,渣翻)

    书籍出处:https://www.packtpub.com/web-development/django-example 原作者:Antonio Melé (译者注:第十二章,全书最后一章,终于到这章 ...

  8. 翻译Lanlet2

    Here is more information on the basic primitives that make up a Lanelet2 map. Read here for a primer ...

  9. 【翻译】Knowledge-Aware Natural Language Understanding(摘要及目录)

    翻译Pradeep Dasigi的一篇长文 Knowledge-Aware Natural Language Understanding 基于知识感知的自然语言理解 摘要 Natural Langua ...

随机推荐

  1. Unity3D_GUI (1)--按钮控件

    这是自己的第一篇记录自己的技术文章,自己还是个菜鸟,有错误之处还望大家能够多多指点. 下面记录的是自己在学GUI.Button的自己认知,这里用的是代码进行控制,当然当你学熟练了就可以直接使用GUI ...

  2. Numpy入门 - 数组基本运算

    本节主要讲解numpy数组的基本运算,包括两数组相加.相减.相乘和相除. 一.两数组相加add import numpy as np arr1 = np.array([[1, 2, 3], [4, 5 ...

  3. 教我徒弟Android开发入门(一)

    前言: 这个系列的教程是为我徒弟准备的,也适合还不懂java但是想学android开发的小白们~ 本系列是在Android Studio的环境下运行,默认大家的开发环境都是配置好了的 没有配置好的同学 ...

  4. Android APP 性能优化的一些思考

    说到 Android 系统手机,大部分人的印象是用了一段时间就变得有点卡顿,有些程序在运行期间莫名其妙的出现崩溃,打开系统文件夹一看,发现多了很多文件,然后用手机管家 APP 不断地进行清理优化 ,才 ...

  5. php 写程序求三个数的最大值

    最简单的调用PHP自带的max函数即可:echo max(1,2,3,4,5);如果要自定义函数的话:function test($a,$b,$c){ return $a > $b ?($a & ...

  6. java8版本base64加密解密

    首先,先是加密,这里我使用了base64类 try { String asB64 = Base64.getEncoder().encodeToString("http://www.baidu ...

  7. HTML5标签总结笔记

    HTML5标签笔记 1.格式标签 元素名和属性一般不区分大小写,特殊的如id和class需要区分 格式标签: <acronym> 定义只取首字母的标签 <abbr>定义缩写 & ...

  8. HDU-1828-Picture(线段树)

    Problem Description A number of rectangular posters, photographs and other pictures of the same shap ...

  9. Hibernate常见问题 No row with the given identifier exists问题的解决办法及解决

    (1)在学习Hibernate的时候遇到了这个问题"No row with the given identifier exists"在网上一搜看到非常多人也遇到过这个问题! 问题的 ...

  10. 自学Python5.2-类、模块、包

    类.模块.包  一.类 类的概念在许多语言中出现,很容易理解.它将数据和操作进行封装,以便将来的复用. 二.模块module 通常模块为一个文件,直接使用import来导入就好了.可以作为module ...