从零开始用函数式实现 JSON Parser
Posted 印记中文
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零开始用函数式实现 JSON Parser相关的知识,希望对你有一定的参考价值。
@工业聚:携程研发高级经理,负责前端框架和基础设施的设计、研发与维护。开源项目 react-lite 和 react-imvc 作者。本文已获得作者授权转载。
在《》里,我们介绍了 Constrcutor,Combinator 和 Primitive 等概念,并演示了如何借助它们去实现和优化从中间展开的循环算法。
在那里我说用这件武器是在高射炮打蚊子。那么,今天这篇文章我们就来讲一下,Combinator Pattern 的经典应用之 Parser Combinator。
我们将从零开始,基于函数式编程的方式,先实现 Parser Combinator 的多个核心方法,然后基于它实现 JSON Parser。
请不必担心自己看不懂代码。因为,基于 Combinator Pattern 的代码通常都很短,并且可以单独调试。反复阅读这篇文章,把代码一一敲出来,仔细查看每一个函数的运行输入与输出,理解它们之间的层次关系。不断推敲,让自己习惯和适应这种思维方式,或有所得。
那么我们开始吧。
与传统的基于数据结构和算法的思维模式不同,我们先设计的是可组合的计算结构。而函数,是最朴素,最天然,最直接的可组合计算结构。
我们不是先考虑如何处理 source code(源码字符串),而是如何描述 Parser 这个计算过程,并设计出一种计算结构。
我们对 Parser 的期望是,输入一个字符串类型的源代码,输出一个抽象语法树(AST: Abstract Syntax Tree)。因此,用函数这种计算结构来表达是:
parser : source -> ast
然而它有个问题,它拿到输入的 source,直接输出了 ast。我们还能组合什么,我们只得在这一个函数里,手动读取一个个字符,手动 peek 前面的字符,手动判断 if/else 它们是哪些字符。顶多先抽取 lexer/tokenizer 等辅助函数。
这些操作都太聪明了。我们现在不想写太聪明的代码,我们就想写简单的,然后在简单的代码里开出一样聪明的花朵。
因此,相比开放式的 source -> ast 的无限复杂想象;我们反其道而行之,最简单的 parser 是什么?
它可能叫 first;如果源码为空字符串,就返回一个提示信息 empty source。如果不是,读取源码的第一个字符,将剩余部分放到旁边。我们很难想象有其它 parser 能比 first 做更少的事情。
first 的类型可以大概描述为:source -> error | (result, rest_source)。此文里采用的类型标注,都是伪代码,请各位同学意会。
first 是我们最简单的 parser,要么返回错误提示,要么返回 result * rest_source 的二元组(我们用长度为 2 的数组来表示)。所有 parser 的类型都是一样的。因此:
parser : source -> error | [result, rest_source]
我们得到了一个初步的计算结构,可以用来描述 parser。当 rest_source 为空字符串时,说明所有源代码都被解析,result 就是最终的 AST。解析期间,任何一个环节发生错误,parser 都返回 error 信息。
我们新的计算结构,增加了 error 和 rest_source 等信息,相比 source -> ast 而言,表达能力更强,信息量更大。
有同学可能疑惑,first/parser 是 Constructor 还是 Combinator?它是不是 Primitive?
答案是,first/parser 既不是 Constructor 也不是 Combinator,也不是 Combinator Pattern 意义上的 Primitive。如果你翻看上一篇讲 Combinator Pattern 的文章,你会看到
Constructor。它负责把一个普通的数据,放入计算结构。相当于构造出一种计算,函数类型大致长这样:a -> m a。
parser 是一个计算结构,输入源代码字符串,计算出 AST。而 Constructor 是构造出计算结构的函数。
我们依然可以问,对于 parser 这种计算结构来说,最简单的 Constructor 是怎样的?
如上所示,inject 接受一个 value,返回一个 source -> [value, source],后者是一个 parser。它没有消费 source 里的任何一个字符,它仅仅是把第一个参数 value,放到 (result, rest_source) 的 result 位置。
我们很难想象有其它 Constructor 能比 inject 做更少的事情。因此,inject 可以被认为无法由其它 Constructor 和 Combinator 组合出来,它还可能参与组合出其它 Constructor 和 Combinator。按照我们之前的说法,inject 是一个在 Parser Combinator 层面上的 Primitive。
我们来重新梳理一下,计算结构、Constructor、Combinator 和 Primitive 的关系。因为我们现在都是用函数来表达它们,因此它们的关系,可以体现在函数里的特征上。
计算结构(在这里是 parser): source -> error | (result, rest_source)。调用一次,就产出结果,或是错误信息,或是数据信息。
Constructor : a -> source -> error | (result, rest_source)。传参调用一次,产出一个 parser,传参 source 再调用一次,产出数据。相比 parser,它多了一层函数调用,多了一个数据维度,具有更强的表达能力。
Combinator: (parserA, parserB, d) -> parserC。传参,并且参数类型里包含 parser/计算结构,返回新的 parser/计算结构。相比普通的 Constructor,Combinator 的参数还可以是其它 parser。因此,它能呈现更复杂的计算过程。不过,它其实还是 Constructor,不管用什么参数构造出 parser,都不会改变它是一个 parser 构造器的本质特征。
Primitives,被选用来作为最底层的 Constructor 和 Combinator 的函数集合。
不管是 Combinator 还是 Primitive,我们可以看到,它只是一个分类,并不包含实际的东西。只有计算结构和 Constructor 属于实打实的。计算结构真正地完成计算行为,Constructor 通过参数,动态化地构造出计算结构。
为了控制篇幅,在做了上面的铺垫后,我们开始做代码层面的展开。
实现 isFailed 函数,判断 result 是不是字符串,如果是,表示失败(我们之前用返回字符串来表示传达错误信息)。
然后实现 map 函数,接受一个 parser 和一个 f 函数,它返回新的 parser,里面包含的计算就是在参数的 parser 的结果基础上,叠加一个 f 计算。如果参数的 parser 返回失败,它则什么都不做,返回失败。
比如,inject(1)的计算结果基础上,将 1 传入 n => n + 3,然后构造新的 result。抛开所谓的 parser, constructor, combinator, primitive 词汇,这里并没有发生任何魔法。并且,其实所有代码都是如此。
map 是一个 combinator,因为它参数里包含其它 parser。map 也是一个 primitive,因为它实质做的只是在某个条件下调用了一次 f(data) 函数。实在太简单,即便它能被其它 combinator 组合出来,我们也乐意手动实现,并把它作为 primitive 使用。
实现 apply 方法,它做的事情,跟 map 方法差不多。在 map 方法里,fn 是参数直接给定的,arg 来自作为参数的 parser 里返回值。而 apply 里的 fn,也来自参数里的 parser。它跟 map 一样,parserFn 和 parserArg 如任意一个解析失败,都返回失败的结果。
至此,可能有同学很困惑。我们不是要写 JSON Parser 吗?为什么几乎看不到 JSON 相关的内容,而是 first, inject, map 和 apply 这种看不出在编写 JSON Parser 时有什么利用价值的函数?
这个疑惑很正常。请保持耐心,我们在一个更普适的、高维的层面上编写着代码,后续它将通过降维打击的方式,解决 JSON Parser 的问题。
如你所见,apply 其实是在 parser 的维度上实现“函数调用”。
它不就把相同层级的 n => n + 1 函数和 1 参数 apply 起来吗?
有了 inject,我们可以在 parser 维度,去定义函数。有了 apply,我们可以在 parser 维度去调用函数。
我们常说 XX 是把大家的智商拉到很低的维度,然后用它丰富的经验战胜对方。在这里,我们用 inject 把普通函数拉到 parser 的维度,然后用 apply 去在 parser 维度调用它。对于函数定义和函数调用,我们有丰富的经验。
再拓展一个 applyAll,让 apply 的参数从 2 个变成多个,这样可以支持高阶函数,x => y => z => x + y + z。
至此,我们实现了 parser 维度的函数定义跟函数调用,那么其它一些计算行为,比如条件判断,或运算,列表结构,树形结构等,是不是也可以构造出来?
没错,这正是我们接下来要做的。
我们实现了 satisfy 方法,它接受一个 predicate 函数,它做的事情就是,先用 first 拿出当前第一个字符,将它传入 predicate 函数,如果返回 false,那就当作解析失败,如果返回 true,则什么都不处理,返回 first parser 的结果。
它相当于实现了 parser 维度的字符条件语句。(只是比 first 这个 parser 多一级别 predicate 判断而已,并没有什么了不得的)。
现在我们可以组合出一些有趣的 parser 了,比如 digit 判断是不是数字,lower 判断是不是小写字母,upper 判断是不是大写字母, char 判断是不是跟给定参数相等,not 则反过来判断是不是跟给定参数不相等。
如果你开始对代码的行为感到不可思议,可以停下来,敲代码,断点,调试,熟悉一下。适应之后,你会发现很简单,不就是 digit -> predicate -> first -> source 里的调用链嘛。
从 source 里通过 first 取得第一个字符,用 predicate 判断一下是否符合条件 digit 指定的 0~9 的条件,任意一个环节出错,都返回错误信息,否则返回数字结果。
我们又很简单地实现了,在 parser 维度上的或运算。即 parserA 失败了,就用 parserB 的结果,如果它也失败了,那整体就是失败。parserA 成功解析,则直接取它的结果。
像 applyAll 一样,either 也可以有支持超过 2 个参数的版本,可以称之为 oneOf。
然后基于 oneOf 和 char,我们可以组合出 whiteSpace 这个 parser,它能命中空格,换行和制表符中的任意一个。
我们用 applyAll 和 either 实现了 parser 层面的列表构建之 many 方法。many 方法接受一个 parser 参数,它会反复应用它直到匹配失败,然后把匹配成功的结果收集到数组里,如果无一命中,则返回空数组。
实现 many 是一个推理的结果,并不需要发挥什么创造性。我们能在 parser 层面定义函数和调用函数,我们有或运算,我们就有了很强的表达能力。
先用 inject 定义一个 parser 层面的 concat 操作,item => list => [item, ...list],这就够了。接下来,就看 item 参数来自哪里,list 参数来自哪里。
item 显然来自 parser,因为只有它干事儿,其它的只是函数定义、函数调用,列表构建,或运算而已。而 list,则通过递归的方式调用 many 去匹配剩余的 source。
这里之所以写 source => many(parser)(source) 而不是直接 many(parser) 是为了防止死循环,让 many 调用按需执行,而不是立即执行(毕竟它在 applyAll 的第三个参数,而第二个 parser 参数失败了,也就不用执行第三个参数了)。
如果 concat 里发生解析失败,通过 either 的第二个 parser 参数注入空数组作为结果,这样可以保证 item => list => [item, ...list] 的第二个 list 参数,一定是数组,起码是空数组。
这里也并没有什么魔法,只是 parser 层面的递归函数调用。把我们的思想,从处理一个 value 值的维度,拉高到 parser 层面,去使用惯常的函数定义、函数调用,或运算,递归函数等技巧。
many1 这个 combinator,就是在 many 的基础上,检查返回值 list 里,如果是空,就报错。many 描述的是 0 或多,而 many1 描述的则是 1 或多。这里颇有正则表达式的 ? 和 + 的意味。
相似的思路,实现 string parser,给定字符串 str,返回匹配这个 str 的 parser。
至此,我们拥有了很多计算结构,可以小试牛刀。如上图所示,我们用 lower || upper 组合出了 letter。
用 many1(digit) 匹配 1 到多个数字,匹配了正整数,然后用 map 将结果从字符串构成的列表,转换成真正的数字。
在此基础上,用 applyAll + char + positiveInteger 组合出 negativeInteger 负整数。它无非就是正整数前面有个负号嘛。通过 inject 定义一个取反函数,忽略 char('-') 提供的参数即可。
而匹配数字的 parser,则是 either(正整数,负整数)的产物。
对于浮点数,我们的技巧一样,用 inject 定义函数所需的参数结构,用 many1 进行多次匹配,用 applyAll 去收集参数并传参给 inject 提供的高阶函数。
然后用 either 把它们整合起来,得到 number parser,它能匹配正整数,负整数,正浮点数,负浮点数。
再实现 ignoreFirst 忽略第一个 parser 参数的结果,取第二个 parser 参数的结果。
实现 separateBy combinator,可以匹配多个 parser 参数被 separator 分隔的模式。
bracket combinator 则是可以匹配 open 和 close 包裹的 parser 结构。
aroundBy 类似于 bracket,只不过 open 和 close 都是同一个结构,并且可以出现多次。
aroundBySpace 则是 aroundBy + whiteSpace 组合的产物,匹配被空格环绕的结构。
这些地方并无特殊之处,applyAll + inject + many 的组合拳而已,常规操作。
花了这么多篇幅,我们终于可以进入 JSON Parser 的阶段,我们需要证明做这么多准备工作,是值得的。我们能够降维打击,轻易解决 JSON Parser。
JSON 有 6 种数据类型,null, boolean, number, string, array 和 object。因此 JSON Parser 也有 6 种,分别是 jNull, jBoolean, jNumber, jString, jArray 和 jObject。
用 oneOf 将 6 个 parser 整合到 jValue parser 中。
jNull 很简单,匹配字面量 null,然后通过 map 将字符串映射为 null。
jNumber 直接用前面组合出来的 number 即可。
jBoolean 跟 jNull 类似,匹配字面量 false 或者 true,然后用 map 将字符串映射为 boolean 值。
jString 则是由双引号包裹住 0 到多个非引号的字符串。
jArray 无非是各种空格环绕的方括号包裹的 jValue 被逗号分隔的列表。
而 jObject 则是由很多 jKey : jValue 组成的 jPair 被逗号分隔,被 {} 包裹的结构。由于 jPairs 返回的是 [[key, value]] 的数据,因此要通过 map 将它映射成对象。
如上所示,我们成功匹配了 null, string ,number, array 等多种 JSON 字符串。二元组的第二个参数都是空字符串,说明充分消费和匹配了源码。
匹配更复杂一点的结构,也毫无压力。
如你所见,我们实现 JSON Parser 的过程是如此之快,与组合其它 parser 的方式是如此之像。以至于并没有值得可以拿出来说的部分。
那就来整体分析一下,我们的 Parser Combinator 里,哪些是 Primitives。亦即,谁在真正的干活儿。
Constructor 的部分,inject 和 satisfy 是 Primitives。inject 主要用来注入函数定义,而 satisfy 通过内部的 first parser 产生真正的对 source 的消费。
我们所有消费,都是通过 satisfy 里的 first parser 完成的。
这正是 Combinator Pattern 的精髓之一。我们不用重复手写同样的操作,一旦有了一个,就可以基于它派生出更复杂的结构。从而,我们能够只做一处调整而全局生效。
Combinator 的部分,map, apply 和 either 是 Primitives。如果你去翻看它们的代码实现,你会发现里面并没有其它 Constructor 和 Combinator。而其它 Constructor 和 Combinator 直接或间接地依赖了它们。
map 是对 parser 内的 result 进行转换;
apply 是对两个 parser 内的 fn 和 arg 进行调用;
either 则是先尝试 parserA,失败后再尝试 parserB;
把 Constructor 和 Combinator 的 Primitives 放到一起,我们会发现,也就只有 5 个而已。甚至,map 可以被 inject + apply 所实现。
把 map 的 f 参数,通过 inject 拉入 parser 维度,就进入了 apply 的范畴。
也就是说,只需要 inject, satisfy, apply 和 either 四个 Primitives,我们就可以构建出更复杂的 Parser。如果你对它们组合出来的东西,感到不解,可以细细品味一下这四个简单的 Primitives。
请坚信,复杂的东西,不可能凭空产生。它一定有简单事物的渊源,我们通过咀嚼简单事物及其组合关系,在编程迷雾中摸索出一条清晰的路径。
我们不必担心会记不住那么多 Constructor 和 Combinator,我们只需要记住 Primitives,通过 Combinator Pattern 以推理的方式,就可以重新一一实现更复杂的计算结构。
点击阅读原文,可以查看本文的源代码(需翻墙)。
阅读原文链接:https://gist.github.com/Lucifier129/c8a57793753d6caa9a51861b807a1ac7
以上是关于从零开始用函数式实现 JSON Parser的主要内容,如果未能解决你的问题,请参考以下文章
从零开始实现ASP.NET Core MVC的插件式开发 - 插件安装
LFS 系列从零开始 DIY Linux 系统:构建 LFS 系统 - XML::Parser-2.44
Python 小白从零开始 PyQt5 项目实战菜单和工具栏