使用Scala构建您自己的编程语言

2020-11-09 14:14:55

Scala的一个优点是实现编程语言。即使您的目标不是实现一种全新的编程语言,这些技术仍然很有用:用于编写内存、程序分析器、查询引擎和其他类似工具。这篇博客文章将带您完成用Scala实现简单编程语言的过程,介绍一些基本概念,最后介绍一个简单编程语言的可用解释器。

关于作者:Haoyi是一名软件工程师,也是许多开源Scala工具(如Ammonite REPL和Mill构建工具)的作者。如果你喜欢这个博客上的内容,你可能也会喜欢作者的《Scala编程实战》一书。

本练习的目标是实现Jsonnet编程语言的一个子集:

本地问候语=";Hello";;本地人员=函数(姓名){";姓名";:姓名,";欢迎";:问候语+姓名+";!";};{";Pers1";:Person(";Alice";),";Person 2";:Person(";Bob";),";Person 3";:)}。

{";Person 1";:{";姓名";:";Alice";,";欢迎";:";Hello Alice!";},";Person 2";:{";姓名";:";Bob";,";欢迎";:";Hello Bob!";},";人物3";:{";姓名";:";查理;,";欢迎";:";你好,查理!";}}。

Jsonnet是一种用于构造JSON配置文件的简单语言:评估.jsonnet文件的输出是一个包含字典、列表、字符串、数字和布尔值的JSON结构。然后,输出的JSON可用于配置Kubernetes、CloudForformation、Terraform或其他软件系统。Google、Databricks和其他公司大量使用Jsonnet来管理其庞大而复杂的系统配置。

本教程将引导您完成实现我们的简单解释器的所有三个阶段,让您对所涉及的技术、数据结构和算法有一个直观的了解。虽然本教程不是一个全面的或可用于生产的实现,但它有望为您提供足够的基础,让您能够开始自己的简单语言相关项目。

在本练习中,我们将在Ammonite Scala REPL中完成所有工作。菊石可以通过以下方式安装:

$sudo sh-c;(ECHO";#!/usr/bin/env sh&34;&;&;curl-L https://github.com/lihaoyi/Ammonite/releases/download/1.6.8/2.13-1.6.8)&>;/usr/LOCAL/bin/amm&;&;chmod+x/usr/LOCAL/bin/amm&39;&;&;AmmLoading...欢迎使用Ammonite Repl 1.6.8(Scala 2.13.0 Java 11.0.2)如果您喜欢Ammonite,请通过www.patreon.com/lihaoyi@支持我们的开发。

本地问候语=";Hello";;本地人员=函数(姓名){";姓名";:姓名,";欢迎";:问候语+姓名+";!";};{";Pers1";:Person(";Alice";),";Person 2";:Person(";Bob";),";Person 3";:)}。

Jsonnet本身类似于JSON,但引入了帮助您整理JSON配置的冗长或重复部分的构造:本地变量(如上面的问候语)、函数定义(如Person)以及基本操作(如用+连接字符串)。出于本练习的目的,我们将停止实现这三个简单的功能。进一步的特性和改进可以用同样的方式实现,我们将把彻底实现整个Jsonnet语言的任务留给像Sjsonnet这样的产品解释器。

Jsonnet有一组与JSON类似的原语。在本教程中,我们只考虑其中的一部分:

目前,我们假设字符串不包含\n或\";等转义序列,并省略其他数据类型,如数字、布尔值、数组或NULL。

您可以使用LOCAL关键字、为名称分配表达式、分号;,然后使用最终表达式来定义局部变量:

与大多数编程语言一样,调用函数时使用包含要传递的参数的圆括号。

这里描述的语言功能可以以任意方式组合,例如,可以在字典值内调用函数:

本快速教程只是完整Jsonnet语言的一小部分,但对本教程来说已经足够了。

为了解析Jsonnet,我们将使用本文介绍的Fastparse库:

让我们从定义最小Jsonnet语法的语法树开始:字符串、字典、函数和局部定义。我们将使用Scala密封的特征和案例类来实现这一点:

密封的特征Exproject expr{case class Str(s:String)扩展expr case类Iden(name:String)扩展expr case类Plus(节点:SEQ[expr])扩展expr case类dict(配对:MAP[String,expr])扩展expr case类Local(name:string,assigned:expr,body:expr)扩展expr case类Func(argNames:SEQ[string],body:expr)扩展expr case类调用(expr:expr。

(要将此代码输入到Ammonite REPL中,请用一对卷边将其括起来{...},这样它就可以作为一个单元输入)。

这里,expr数据结构旨在表示Jsonnet语法中有意义的部分。下面是一些示例代码片段,以及我们希望它们解析到的内容。

当然,我们希望能够解析组合在一起的这些语言功能的任意组合。

首先,让我们为Str编写解析器。为简单起见,我们将忽略转义字符,这意味着字符串只是一个";,后跟零个或多个非";字符,最后是另一个";字符:

@def str[_:P]=P(";\";";~~/CharsWhile(_!=';";';,0).!~~";\";";).map(Expr.Str)定义函数str@fast parse.parse(";\";Hello\";";,str(_))res10:),7)@fast parse.parse(";\";Hello world\";";,str(_))res11:parsed[Str]=Success(Str(";Hello world";),13)@fast parse.parse(";\";\";";,str(_))res12:parsed[Str]=Success(Str(";,str(_))Parsed.Failure(位置1:1,找到";123";)。

注意我们是如何在";\";";后引号后面使用~~/运算符的:~~表示我们不想在这里使用空格(因为我们在一个字符串中),而/是Fastparse Cut,这意味着如果解析失败,我们希望避免回溯。关于削减给我们带来什么的详细讨论留给了链接的文档。

@defident[_:P]=P(Charin(";a-Za-Z_";)~~CharsWhileIn(";a-Za-Z0-9_";,0)).!.map(Expr.Iden)定义函数ident@fast parse.parse(";Hello";,ident(_))res17:parsed[Iden]=Success(";a-Za-Z0-9_";,0)。,ident(_))分析失败(位置1:1,找到";123";)。

我们将研究的下一个语法树节点是Expr.Plus,它用于对a+b语法建模。

表示+的Expr.Plus节点以及我们语法树中的所有其他case类都稍微复杂一些:它们有一个递归定义,其中Plus是expr,但是expr可以是Plus节点。这可以通过使我们的解析器递归来解决,如下所示:

@{def expr[_:P]:P[expr]=P(prefix Expr~plus.rep).map{case(e,Nil)=>;e case(e,Items)=>;Expr.Plus(e+:Items)}def prefix Expr[_:P]=P(str|ident)def str[_:P]=P(";\";";~/。";).map(Expr.Str)defident[_:P]=P(Charin(";a-Za-Z_";)~~CharsWhileIn(";a-Za-Z0-9_";,0)).!.Map(Expr.Iden)def plus[_:P]=P(";+";~prefix Expr)}@fast parse.。,expr(_))res65:parsed[expr]=Success(Plus(List(Ident(";a";),Iden(";b";)),5)@fast parse.parse(";a+b+c";,expr(_))res66:parsed[expr]=Success(Plus(List(Iden(";a";),Ident(&#。),9)@fast parse.parse(";a+\";\";+c";,expr(_))res67:parsed[expr]=Success(Plus(List(Ident(";a";),Str(";";),Ident(";c";)),11)。

请注意,我们不能简单地将PLUS定义为expr~";+";~expr;这是因为PLUS解析器将被保留为递归,从而导致在解析时进行无限递归。相反,我们需要将plus定义为后缀";+";~prefix Expr,并让expr解析器通过~plus.rep执行重复plus的工作,如果不为空,则将结果聚合到Expr.Plus节点中。

Expr.Dict节点也是递归的,每个逗号分隔的键值对包含一个Expr.Str键和一个expr值。我们可以按如下方式解析它们:

@{def expr[_:P]:P[expr]=P(prefix Expr~plus.rep).map{case(e,Nil)=>;e case(e,Items)=>;Expr.Plus(e+:Items)}def prefix Expr[_:P]=P(str|ident|dict)def str[_:P]=P(Str0).map(Expr.Str)de。";';';,0).~~";\";";)defident[_:P]=P(Charin(";a-Za-Z_";)~CharsWhileIn(";a-Za-Z0-9_";,0)).!.MAP(Expr.Iden)def plus[_:P]=P(";+&##。~/(str0~";:";~/expr).rep(0,";,";)~";}";).map(kvs=>;Expr.Dict(kvs.toMap))}@fast parse.parse(";";";a";:";b";,";cde&34。,expr(_))res84:parsed[expr]=Success(dict(Map(";a";->;str(";b";),";CDE";->;Ident(";id";)),21)@fast parse.parse(";";";{";a";:";:id}";";";,expr(_))res85:parsed[expr]=Success(dict(Map(";a";->;str(";b";),";cde";->;ident(";id";),21)@fast parse.parse(";";";,";cde";:id,";嵌套";:{}}";";";,expr(_)res86:parsed[expr]=Success(dict(";a";->;str(";b";),";cde";->;标识(";id&34;),DICT(地图((),35)。

注意我们是如何从str提取str0解析器的:str0返回解析的原始字符串,而str将其包装在Expr.Str语法树节点中。由于Expr.Dict键在语法上与Expr.Strs相同,但不需要包装在Expr.Str节点中,因此我们也可以在dict解析器中重用str0解析器来解析它们。

将函数解析器、本地解析器和调用解析器添加到此代码中,我们会得到以下结果:

对象解析器{def expr[_:p]:P[expr]=P(prefix Expr~plus.rep).map{case(e,Nil)=>;e case(e,Items)=>;Expr.Plus(e+:Items)}def prefix Expr[_:P]:P[expr]=P(callExpr~call.rep).map{case(e,Items)=>;item.。Expr.Call(f,args)}def callExpr[_:P]=P(str|dict|local|func|ident)def str[_:P]=P(Str0).map(Expr.Str)def str0[_:P]=P(";\";";~~/CharsWhile(_!=';";';,0).!~。)defident[_:P]=P(Ident0).map(Expr.Iden)def ident0[_:P]=P(Charin(";a-Za-Z_";)~~CharsWhileIn(";a-Za-Z0-9_";,0))。!Def dict[_:P]=P(";{";~/(str0~";:";~/expr).rep(0,";,";)~";}";).map(KVS=>;Expr.Dict(kvs.toMap))def local[_:P]=P(";local";~/ident0~&#。~expr).map(Expr.Local.tupled)def函数[_:P]=P(";函数";~/";(";~ident0.rep(0,";,";)~";)";~expr).map(Expr.Local.tupled)def plus[_:P]=P(";+";~prefix Expr)。~/expr.rep(0,";,";)~";)";)}。

Func和local相对简单:每个函数都以一个关键字开头,并且可以递归地解析它们,而不会出现问题。我们还将ident0从ident中分离出来,因为func解析器使用与ident相同的语法来解析其参数列表,但不需要将标识符装箱到Expr.Iden语法树节点中。

请注意,call还需要我们将prefix Expr进一步拆分为callExpr,因为a()调用语法将是左递归的,类似于我们前面看到的a+b加号语法。

@fast parse.parse(";\";123\";";,Parser.expr(_))res63:parsed[expr]=Success(Str(";123";),5)@fast parse.parse(";id";,Parser.expr(_))res64:parsed[expr]=Success(Iden(";id";),2)。,Parser.expr(_))res65:parsed[expr]=Success(Plus(标识(";a";),标识(";b&34;)),5)@fast parse.parse(";a+b+c&34;,Parser.expr(_))res66:parsed[expr]=Success(Plus(标识(";a";),Plus(Iden(";a";),Plus。),9)@fast parse.parse(";";";{";a";:";A";,";b";:";bee";}";";";,Parser.expr(_))res69:parsed[expr]=Success(dict(Map(";a";->;B";->;Str(";bee";)),22)@fast parse.parse(";";";f()(A)+g(b,c)";";";,Parser.expr(_))res95:parsed[expr]=Success(Plus(List(Call(Call(Ident(";f";)),List(。)),呼叫(身份(";g#34;),列表(身份(";b#34;),身份(";c";),16)。

我们编程语言的语法是递归的:LOCAL、Function、PLUS和DICT表达式可以包含其他任意深度嵌套的表达式。我们可以通过将这样的嵌套示例提供给expr解析器来测试这一点:

@fast parse.parse(";";";局部变量=";Kay";;{";a";:";A";,";f";:函数(A)a+a,";嵌套";:{";k";:变量}}";";";,Parser.expr(_))res74:parsed[expr]=Success(Local(";Variable&34;,Str(";Variable&34;),Str(";Kay&34;),Dict(";A";->;Str(";A";),";f";-&>;Func(List(";a";),Plus(";a";),)),";嵌套的&34;->;dict(Map(";k";->;标识(";变量";),85)。

现在我们已经有了expr节点的语法树,下一步是解释语法树以提供值的运行时数据结构。我们将价值定义如下:

密封特征ValueObject Value{case类Str(s:String)扩展值case类dict(配对:Map[String,Value])扩展值case类Func(call:SEQ[value]=>;value)扩展值}。

(要将此代码输入到Ammonite REPL中,请用一对卷边将其括起来{...},这样它就可以作为一个单元输入)。

请注意,虽然expr语法树包含表示标识符、局部变量、函数应用程序等的节点,但值只能是str、dict或Func。Value.Str来自哪里并不重要:无论是源代码中的文字Expr.Str,作为函数参数传递给Expr.Func,还是通过Expr.Local绑定到局部变量,它都是相同的Value.Str。然后,整个Jsonnet程序的最终值被转换为JSON字符串,作为程序的输出。

Value.Str和Value.Dict的内容应该是不言而喻的。Value.Func就不那么明显了:通过将其定义为Func(call:SEQ[value]=>;value),我们说的是一个函数,您可以将一列参数值传递给它并返回值。稍后我们将了解如何实例化这些Value.Func节点。

这里的基本任务是编写一个将expr转换为值的函数:

然而,通过计算expr返回的值不仅取决于该expr的内容:它还取决于包含的作用域,因为Expr.Iden标识符的值取决于通过局部声明或函数参数绑定到该名称的值。

在您的程序中,这种名称到值的映射通常被称为词法作用域。因此,我们可以将EVALUE定义为:

文字字典也很简单:Expr.Dicts变成Value.Dicts,具有相同键的Dicts,只是我们需要将每个值计算成其相应的表达式:

字典文字不会在词法作用域中添加或删除任何内容,因此传递的作用域参数没有变化。通过测试,我们可以了解到:

@EVALUATE(fastparse.parse(";";";{";hello";:";WORLD&34;,";KEY";:";VALUE";}";";,Parser.expr(_)).get.value,Map.Empty)res81:Value=dict(Map(";Hello";->;Str(";WORLD&34;),";键";->;字符串(";值";))。

接下来,我们将查看Expr.Plus节点。我们只定义了它们的行为来处理字符串值(Value.Str),因此评估它们涉及到:

定义评估(expr:expr,作用域:MAP[字符串,值]):Value=expr Match{Case Expr.Str(S)=>;Value.Str(S)Case Expr.Dict(Kvs)=>;Value.Dict(kvs.map{Case(k,v)=>;(k,Evaluate(v,Scope)})Case Expr.Plus(Items)=>;Value.Str(item.。

@fast parse.parse(";";";";本地问候=";Hello";;greeting+greeting&34;";";";,Parser.expr(_))res85:parsed[expr]=Success(Local(";greting";,Str(";Hello";),Plus(Ident(";Greeting&34;),Iden。

LOCAL的目的是对赋值的表达式求值,将该值赋给该名称,然后使用绑定到该名称的值计算主体表达式。我们可以用如下代码编写该代码:

案例Expr.Local(名称,已分配,正文)=>;Val assignedValue=Evaluate(已分配,范围)Evaluate(正文,范围+(名称->;assignedValue))。

一旦本地将名称放入作用域,计算标识标识符节点就很简单:只需在作用域中获取该名称的值:

@EVALUATE(fast parse.parse(";";";local greeting=";Hello";;greeting+greting";";";,expr(_)).get.value,Map.Empty)res94:value=Str(";Hello";)。

@EVALUATE(fast parse.parse(";";";local x=";Hello";;local y=";;x+y";";";,expr(_)).get.value,Map.Empty)res96:value=Str(";Hello world";)。

@EVALUATE(Fastparse.parse(";";";";本地问候=";Hello";;no+nope";";";,expr(_)).get.value,Map.Empty)java.util.NoSuchElementException:Key Not Found:nope scala.collection.immutable.Map$Map1.apply(Map.scala:242)amamite.$sess.cmd93$.valuate(cmd93.sc:10)amitite.$sess.。(cmd95.sc:3)。

定义评估(expr:expr,作用域:MAP[字符串,值]):Value=expr Match{Case Expr.Str(S)=>;Value.Str(S)Case Expr.Dict(Kvs)=>;Value.Dict(kvs.map{Case(k,v)=>;(k,Evaluate(v,Scope)})Case Expr.Plus(Items)=>;Value.Str(item.。S}.mkString)案例Expr.Local(名称,已分配,正文)=>;Val assignedValue=Evaluate(已分配,范围)Evaluate(正文,范围+(名称->;assignedValue))案例经验标识(名称)=&>;范围(名称)}。

我们最后要计算的是Expr.Func函数文字和Expr.Call函数应用程序节点:

Case类Func(params:SEQ[字符串],Body:expr)扩展Exprcase类调用(expr:expr,args:SEQ[expr])扩展expr

在Value.Func上计算Expr.Call应该会给出计算该函数的结果。结果可以是Value.Str、Value.Dict,甚至是另一个Value.Func。

计算Expr.Call节点很简单:我们只需将expr:expr求值为Value.Func,将args:SEQ[expr]求值为一系列参数值,然后对求值的参数值调用Value.Func#调用函数即可给出结果。

案例Expr.Call(expr,args)=>;valValue.Func(Call)=Evaluate(expr,Scope)Val EvaluatedArgs=args.map(Evaluate(_,Scope))Call(EvaluatedArgs)。

这里的难题是:我们如何计算Expr.Func以生成一个Value.Func,它的Call属性执行我们想要的操作?

当您思考调用函数的真正含义时,它可以归结为四个步骤:

使用在cal传递的参数值在定义站点创建原始作用域的修改副本。

.