笨办法学 Python · 续 练习 33:解析器

简介: 练习 33:解析器 原文:Exercise 33: Parsers 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译想象一下,你将获得一个巨大的数字列表,你必须将其输入到电子表格中。

练习 33:解析器

原文:Exercise 33: Parsers

译者:飞龙

协议:CC BY-NC-SA 4.0

自豪地采用谷歌翻译

想象一下,你将获得一个巨大的数字列表,你必须将其输入到电子表格中。一开始,这个巨大的列表只是一个空格分隔的原始数据流。你的大脑会自动在空格处拆分数字流并创建数字。你的大脑像扫描器一样。然后,你将获取每个数字,并将其输入到具有含义的行和列中。你的大脑像一个解析器,通过获取扁平的数字(记号),并将它们变成一个更有意义的行和列的二维网格。你遵循的规则,什么数字进入什么行什么列,是你的“语法”,解析器的工作就是像你对于电子表格那样使用语法。

我们再来看一下练习 32 中的微型 Python 代码,再从三个不同的角度讨论解析器:

def hello(x, y):
    print(x + y)

hello(10, 20)

当你查看这个代码时,你看到什么?我看到一棵树,类似于我们之前创建的BSTreeTSTree。你看到树了吗?我们从这个文件的最上方开始,学习如何将字符转换为树。

首先,当我们加载一个.py文件时,它只是一个“字符”流 - 实际上是字节,但 Python 使用Unicode,所以必须处理字符。这些字符在一行中,毫无结构,扫描器的任务是增加第一层次的意义。扫描器通过使用正则表达式,从字符串流中提取意义,创建记号列表。我们已经将一个字符列表转换为一个记号列表,但看看def hello(x,y):函数。这是一个函数,里面有代码块。这意味着某种形式的“包含”或“东西里面的东西”的结构。

一个很容易表示包含的方式是用一棵树。我们可以使用表格,像你的电子表格一样,但它并不像树那么容易。接下来看看hello(x, y)部分。我们有一个NAME(hello)记号,但是我们要抓取(...)部分的内容,并且知道它在括号内。再次,我们可以使用一个树,我们将(...)部分中的x, y部分“嵌套” 为树的子节点或分支。最终,我们就拥有了一棵树,从这个 Python 代码的根开始,并且每个代码块,print,函数定义和函数调用都是根的分支,它们也有子分支,以此类推。

为什么我们这样做?我们需要基于其语法,知道 Python 代码的结构,以便我们稍后分析。如果我们不将记号的线性列表转换成树结构,那么我们不知道函数,代码块,分支或表达式的边界在哪里。我们必须以“直线”方式在飞行中确定边界,这不容易使其可靠。很多早期的糟糕语言是直线语言,我们现在知道了他们不必须是这样。我们可以使用解析器构建树结构。

解析器的任务是从扫描器中获取记号列表,并将其翻译成更有意义的语法树。你可以认为解析器是,对记号流应用另一个正则表达式。扫描器的正则表达式将大量字符放入记号中。解析器的“正则表达式”将这些记号放在盒子里面,它里面有盒子,以此类推,直到记号不再是线性的。

解析器也为这些盒子添加了含义。解析器将简单地删除()括号记号,并为可能的Function类创建一个特殊的parameters列表。它会删除冒号,无用的空格,逗号,任何没有真正意义的记号,并将其转换为更易于处理的嵌套结构。最后的结果可能看起来像,上面的示例代码的伪造树:

* root
  * Function
    - name = hello
    - parameters = x, y
    - code:
      * Call
        - name = print
        - parameters =
            * Expression
              - Add
                - a = x
                - b = y
  * Call
    - name = hello
    - parameters = 10, 20

递归下降解析

有几种已建立的方法,可以为这种语法创建解析器,但最简单的方法称为递归下降解析器(RDP)。我实际上在我《笨办法学 Python》练习 49 中讲解了这个话题。你创建了一个简单的 RDP 解析器来处理你的小游戏语言,你甚至不了解它。在本练习中,我将对如何编写 RDP 解析器进行更正式的描述,然后让你使用我们上面的 Python 小代码片段来尝试它。

RDP 使用多个相互递归的函数调用,它实现了给定语法的树形结构。RDP 解析器的代码看起来像你正在处理的实际语法,只要遵循一些规则,它们就很容易编写。RDP 解析器的两个缺点是:它们可能不是非常有效,并且通常需要手动编写它们,因此它们的错误比生成的解析器更多。对于 RDP 解析器可以解析的东西,还有一些理论上的限制,但是由于你手动编写它们,你通常可以解决很多限制。

为了编写一个 RDP 解析器,你需要使用三个主要操作,来处理扫描器的记号:

peek

如果下一个记号能够匹配,返回它,但是不从流中移除。

match

匹配下一个记号,并且从流中移除。

skip

由于不需要下个记号,跳过它,将其从流中移除。

你会注意到,这些是我在练习 33 中让你为扫描器创建的三个操作,这就是为什么。你需要他们来实现一个 RDP 解析器。

你可以使用这三个函数来编写语法解析函数,从扫描器中获取记号。这个练习的一个简短的例子是,解析这个简单的函数:

def function_definition(tokens):
    skip(tokens) # discard def
    name = match(tokens, 'NAME')
    match(tokens, 'LPAREN')
    params = parameters(tokens)
    match(tokens, 'RPAREN')
    match(tokens, 'COLON')
    return {'type': 'FUNCDEF', 'name': name, 'params': params}

你可以看到我只是接受记号并使用matchskip处理它们。你还会注意到我有一个parameters函数,它是“递归下降解析器”的“递归”部分。当它需要为函数解析参数时,function_definition会调用parameters

BNF 语法

尝试从头开始编写一个 RDP 解析器是没有某种形式的语法规范的,有点棘手。你还记得当我要求你将单个正则表达式转换成 FSM 吗?这很难吗?它需要更多的代码,不只是正则表达式中的几个字符。当你为这个练习编写 RDP 解析器时,你将会做类似的事情,因此它有助于使用一种语言,它是“语法的正则表达式”。

最常见的“语法的正则表达式”被称为 Backus–Naur Form(BNF),以创作者 John Backus 和 Peter Naur 命名。BNF 描述了所需的记号,以及这些记号如何重复来形成语言的语法。BNF 还使用与正则表达式相同的符号,所以*+?有相似的含义。

对于这个练习,我将使用 https://tools.ietf.org/html/rfc5234 上面的 IETF 增强 BNF 语法,来规定上面的微型 Python 代码段的语法。ABNF 运算符大部分与正则表达式相同,只是由于某种奇怪的原因,它们在要重复的东西之前放置重复符号。除此之外,请阅读规范,并尝试弄清楚下面的意思:

root = *(funccal / funcdef)
funcdef = DEF name LPAREN params RPAREN COLON body
funccall = name LPAREN params RPAREN
params = expression *(COMMA expression)
expression = name / plus / integer
plus = expression PLUS expression
PLUS = "+"
LPAREN = "("
RPAREN = ")"
COLON = ":"
COMMA = ","
DEF = "def"

让我们仅仅查看funcdef那一行,并将其与function_definition Python 代码比较,匹配每一个部分:

funcdef =

我们使用def function_definition(tokens)来复制,并且它是我们的语法的这个部分的开始。

DEF

它在语法中规定了DEF = "def",并且在 Python 代码中,我们使用skip(tokens)跳过了它。

name

我需要它,所以我使用name = match(tokens, 'NAME')匹配它。我使用 CAPITALS 的约定,在 BNF 中表示我会跳过的东西。

LPAREN

我假设我收到了一个def,但是现在我打算确保有一个(,所以我要匹配它。但是我使用match(tokens, 'LPAREN')来忽略结果。它就像“需要但是忽略”。

params

在 BNF 中我将params定义为了新的“语法产生式”,或者“语法规则”。意思是在我的 Python 代码中,我需要一个新的函数。这个函数中,我可以使用params = parameters(tokens)来调用那个函数。之后我定义了parameters函数来为函数处理逗号分隔的参数。

RPAREN

同样我需要但是去掉了它,使用match(tokens, 'RPAREN')

COLON

同样,我去掉了匹配match(tokens, 'COLON')

body

我这里实际上跳过了函数体,因为 Python 的缩进语法对于这个例子太难了。你不需要在练习中处理这个例子,除非你喜欢它。

这基本上是,你如何读取 ABNF 规范,并将其系统地转换为代码。你从根开始,将每个语法产生式实现为一个函数,并让扫描器处理简单的记号(我用CAPITAL(大写)字母表示)。

简单的示例黑魔法解析器

这是我快速 Hack 出来的 RDP 解析器,你可以使用它,作为你的更正式和简洁的解析器的基础。

from scanner import *
from pprint import pprint

def root(tokens):
    """root = *(funccal / funcdef)"""
    first = peek(tokens)

    if first == 'DEF':
        return function_definition(tokens)
    elif first == 'NAME':
        name = match(tokens, 'NAME')
        second = peek(tokens)

        if second == 'LPAREN':
            return function_call(tokens, name)
        else:
            assert False, "Not a FUNCDEF or FUNCCALL"

def function_definition(tokens):
    """
    funcdef = DEF name LPAREN params RPAREN COLON body
    I ignore body for this example 'cause that's hard.
    I mean, so you can learn how to do it.
    """
    skip(tokens) # discard def
    name = match(tokens, 'NAME')
    match(tokens, 'LPAREN')
    params = parameters(tokens)
    match(tokens, 'RPAREN')
    match(tokens, 'COLON')
    return {'type': 'FUNCDEF', 'name': name, 'params': params}

def parameters(tokens):
    """params = expression *(COMMA expression)"""
    params = []
    start = peek(tokens)
    while start != 'RPAREN':
        params.append(expression(tokens))
        start = peek(tokens)
        if start != 'RPAREN':
            assert match(tokens, 'COMMA')
    return params

def function_call(tokens, name):
    """funccall = name LPAREN params RPAREN"""
    match(tokens, 'LPAREN')
    params = parameters(tokens)
    match(tokens, 'RPAREN')
    return {'type': 'FUNCCALL', 'name': name, 'params': params}

def expression(tokens):
    """expression = name / plus / integer"""
    start = peek(tokens)

    if start == 'NAME':
        name = match(tokens, 'NAME')
        if peek(tokens) == 'PLUS':
            return plus(tokens, name)
        else:
            return name
    elif start == 'INTEGER':
        number = match(tokens, 'INTEGER')
        if peek(tokens) == 'PLUS':
            return plus(tokens, number)
        else:
            return number
    else:
        assert False, "Syntax error %r" % start

def plus(tokens, left):
    """plus = expression PLUS expression"""
    match(tokens, 'PLUS')
    right = expression(tokens)
    return {'type': 'PLUS', 'left': left, 'right': right}


def main(tokens):
    results = []
    while tokens:
        results.append(root(tokens))
    return results

parsed = main(scan(code))
pprint(parsed)

你会注意到,我正在使用我写的scanner模块,拥有我的matchpeekskipscan函数。我使用from scanner import *,仅使这个例子更容易理解。你应该使用你的Scanner类。

你会注意到,我把这个小解析器的 ABNF 放在每个函数的文档注释中。这有助于我编写每个解析器代码,稍后可以用于错误报告。在尝试挑战练习之前,你应该研究此解析器,甚至可能作为“代码大师副本”。

挑战练习

你的下一个挑战是,将你的 Scanner类与新编写的Parser类组合在一起,你可以派生并重新实现使我这里的简单的解析器。你的基础Parser类应该能够:

  • 接受一个Scanner对象并执行自身。你可以假设任何默认函数是语法的起始。
  • 拥有错误处理代码,比我简单的assert用法更好。

你应该实现PunyPythonPython,它可以解析这个微小的 Python 语言,并执行以下操作:

  • 不是仅仅产生dicts的列表,你应该为每个语法生产式的结果创建类。这些类之后成为列表中的对象。
  • 这些类只需要存储被解析的记号,但是要准备做更多事情。
  • 你只需要解析这个微小的语言,但你应该尝试解决“Python 缩进”问题。你可能需要秀阿贵扫描器,使其更智能,才能在行的开头匹配INDENT空白字符,并在其他位置忽略它。你还需要跟踪如何多少缩进了多少,同时也记录零缩进,所以你可以“压缩”代码块。

一个泛用的测试套件涉及到,将这个微小的 python 的更多样本交给解析器,但现在只需要得到一个小文件来解析。尝试在测试中获得良好的覆盖率,并尽可能多地发现错误。

研究性学习

这个练习相当庞大,所以只需要完成。花点时间,一次做一点点。我强烈建议学习我这里的小型样本,直到你完全弄清楚,并打印正在处理的关键位置的记号。

深入学习

查看 David Beazley 的 SLY 解析器生成器,以便让你的计算机为你生成你的解析器和扫描器(也称为分词器)。随意尝试用 SLY 重复此练习来进行比较。

相关文章
|
19天前
|
存储 缓存 算法
Python中collections模块的deque双端队列:深入解析与应用
在Python的`collections`模块中,`deque`(双端队列)是一个线程安全、快速添加和删除元素的双端队列数据类型。它支持从队列的两端添加和弹出元素,提供了比列表更高的效率,特别是在处理大型数据集时。本文将详细解析`deque`的原理、使用方法以及它在各种场景中的应用。
|
2天前
|
调度 Python
Python多线程、多进程与协程面试题解析
【4月更文挑战第14天】Python并发编程涉及多线程、多进程和协程。面试中,对这些概念的理解和应用是评估候选人的重要标准。本文介绍了它们的基础知识、常见问题和应对策略。多线程在同一进程中并发执行,多进程通过进程间通信实现并发,协程则使用`asyncio`进行轻量级线程控制。面试常遇到的问题包括并发并行混淆、GIL影响多线程性能、进程间通信不当和协程异步IO理解不清。要掌握并发模型,需明确其适用场景,理解GIL、进程间通信和协程调度机制。
18 0
|
2天前
|
API Python
Python模块化编程:面试题深度解析
【4月更文挑战第14天】了解Python模块化编程对于构建大型项目至关重要,它涉及代码组织、复用和维护。本文深入探讨了模块、包、导入机制、命名空间和作用域等基础概念,并列举了面试中常见的模块导入混乱、不适当星号导入等问题,强调了避免循环依赖、合理使用`__init__.py`以及理解模块作用域的重要性。掌握这些知识将有助于在面试中自信应对模块化编程的相关挑战。
17 0
|
3天前
|
索引 Python
python 格式化、set类型和class类基础知识练习(上)
python 格式化、set类型和class类基础知识练习
23 0
|
6天前
|
SQL API 数据库
Python中的SQLAlchemy框架:深度解析与实战应用
【4月更文挑战第13天】在Python的众多ORM(对象关系映射)框架中,SQLAlchemy以其功能强大、灵活性和易扩展性脱颖而出,成为许多开发者首选的数据库操作工具。本文将深入探讨SQLAlchemy的核心概念、功能特点以及实战应用,帮助读者更好地理解和使用这一框架。
|
7天前
|
存储 JSON JavaScript
「Python系列」Python JSON数据解析
在Python中解析JSON数据通常使用`json`模块。`json`模块提供了将JSON格式的数据转换为Python对象(如列表、字典等)以及将Python对象转换为JSON格式的数据的方法。
24 0
|
19天前
|
数据采集 数据挖掘 Python
Python中collections模块的Counter计数器:深入解析与应用
在Python的`collections`模块中,`Counter`是一个强大且实用的工具,它主要用于计数可哈希对象。无论是统计单词出现的频率,还是分析数据集中元素的分布情况,`Counter`都能提供快速且直观的结果。本文将深入解析`Counter`计数器的原理、用法以及它在实际应用中的价值。
|
25天前
|
安全 Python
Python封装:深入解析与应用
封装是Python面向对象编程的关键,通过隐藏对象属性和实现细节,提供公共访问方式,确保代码安全和可维护。实现封装主要通过类和对象,使用私有属性(__前缀)及访问器/修改器方法。封装能隐藏内部状态、统一接口、复用代码和增强扩展性。示例展示了如何用私有属性和访问器方法控制属性访问。掌握封装有助于编写高效、灵活的代码。
|
25天前
|
Python
Python类与对象:深入解析与应用
本文介绍了Python中的核心概念——类和对象,以及它们在面向对象编程中的应用。类是用户定义的类型,描述具有相同属性和行为的对象集合;对象是类的实例,具备类的属性和方法。文章通过示例讲解了如何定义类、创建及使用对象,包括`__init__`方法、属性访问和方法调用。此外,还阐述了类的继承,允许子类继承父类的属性和方法并进行扩展。掌握这些概念有助于提升Python编程的效率和灵活性。
|
26天前
|
缓存 数据库 Python
Python装饰器:深入解析与应用
Python装饰器是函数,接收函数作为参数并返回新函数,用于在不修改原函数的情况下添加额外功能。它们基于Python的函数一等公民特性,允许在执行前后插入逻辑。应用场景包括日志记录、性能分析、权限校验、缓存和事务管理。学习装饰器能提升代码质量和维护性。

推荐镜像

更多