使用.NET5自制编程语言

221 阅读9分钟

自制编译原理自始至终都是非常难学的知识,虽然网上能找到各种各样的教程及文档,但也极少有开发者深入研究。 这儿推荐一个基于.NET5的库,Facc,通过极简语法描述文法,自动生成AST代码。

GitHub:github.com/fawdlstty/F…
Gitee:gitee.com/fawdlstty/F…

下面我从0开始讲解编程语言原理,以及这个库的使用。

第一章:语言基础

编程语言,是一种人与计算机交互的方式。人想让计算机做什么,就通过编程语言的方式,与计算机达成共识。通常计算机无法直接识别一个语言,需要使用一种工具或程序,将编程语言转为二进制代码,计算机就能识别编程语言,从而开始执行。现存编程语言千千万,只要你能让你的语言转为计算机可识别的代码,那么等于你创造了一个编程语言。

第一节:代码和数据

代码或数据通常指代不同的东西,代码指的是让计算机执行的内容,数据指的是不可执行的内容。比如如下C#代码:

Console.WriteLine ("Hello World!");

此代码分为两部分,一部分是 "Hello World!" 这个字符串,这是一串数据,计算机无法执行;一部分是 Console.WriteLine () 这是一句可执行语句,代表执行这个函数,同时将前面的字符串内容传递给此函数,让此函数去执行。

虽然绝大部分语言的代码和数据区分的明明白白,但依旧有部分语言模糊了两者的关系,比如:

  • Lisp没有明显的区分代码和数据
  • JavaScript的eval函数能够执行字符串js代码
  • 等等…… 为了简单,本文档只以主流编程语言来解读,也就是明显区分代码与数据。当所有内容全部悉知后,你也能创建出模糊代码与数据关系的编程语言。

第二节:编译代码

编译,顾名思义,就是将人类可读的代码,翻译为计算机可读的指令。
假如我们设计了一个语言,首先将语言解析为语法树,然后将语法树转为masm汇编代码,最后调用汇编器编译汇编代码,翻译为二进制程序。
这个过程有很多可以省略或者增加步骤,比如C++之父开发的CFront这个编译器能够编译C++代码,最开始的实现是将其编译为C代码,然后调用C编译器完成编译。
当然,以上方式都不太主流了,主流方式有两种,一种是自己写一个虚拟机,然后解释执行虚拟指令(或者jit、AOT编译),比如C#、Java;再或者生成LLVM IR,调用LLVM生成对应环境可执行代码(比如clang、Rust)。 语法树其实也能直接生成CPU可执行的汇编代码,但没必要,因为现在已经有了汇编器工具了,假如全部靠手工从0实现各种轮子,那么只能说工作不饱和。
比如前段时间在公众号上非常火的V语言,编译方式是翻译为C++,然后调用C++编译器编译,然后很多人喷这种编译方式。其实真没必要喷,C++之父都这么搞的呢,何必。
此处就按LLVM IR作为中间语言。编译代码最关键的步骤就是代码转语法树,以及语法树转LLVM IR。实现这两个功能,就能实现出一个完整的编译器了。

第三节:语言前端和语言后端

听起来很像一个写UI一个写实现。其实不是,语言前端指的是,语言代码翻译为AST树,然后将AST树转为中间语言(比如LLVM IR);语言后端是将中间语言翻译为对应平台的可执行指令,比如流行的指令集有:Intel x86、arm、mips、risc-v……
我们一般做的自制编程语言指的是完成语言前端。后端这个就是另一回事了,比如自制了一块CPU,自己设计了指令集,然后想让代码跑在这个CPU上,就需要自己完成编程语言后端了,将LLVM IR翻译为自制的指令集。在此之后,使用LLVM的编译器就能编译自制指令集可执行的代码了。

第二章:编程语言语法

由字符组成的代码

代码的组成,实际就是各种各样的字符组成。比如如下代码:

static void Main (string [] args) {
    Console.WriteLine ("Hello World!");
}

首先是 static 这六个字符组成的关键字代表静态,然后是 void 这四个字符组成的关键字代表函数返回类型,后面还有可忽略的换行符、tab缩进符等。我们首先需要明确的概念就是,语言代码就是字符串由各种各样的字符组成。

各种语句的组合

上面的代码我们扩展一下:

static void Main (string [] args) {
    Console.WriteLine ("Hello World!");
    Console.WriteLine ("Hello World!");
    Console.WriteLine ("Hello World!");
}

很简单的代码,是吧?我们再来分析一下,这段代码与上面那段代码的区别,可以明显发现,hello world打印了三遍。我们由此可以大胆给出一个猜测,一个函数里面可以有0行代码,也可以有N行代码(废话)。
我们现在来定义一下函数的结构,首先是函数头,我们定义名称为func_begin,然后是语句,我们定义名称为stmt,再然后是函数尾,我们定义名称为func_end
通过一种特殊的语法来描述这种现象:

func_stmt ::= func_begin stmt* func_end

简单解释一下,这段描述符的含义是,函数结构(func_stmt)由三个部分组成:函数头、函数体(由语句stmt组成,星号代表重复0次到无限次)、函数尾。
这个描述符就能完成匹配上面两段代码了,不管Main函数里面有几串表达式语句。

终结符和非终结符

简而言之,终结符就是代码中的字符或字符串;非终结符就是我们对一个结构的定义。
比如我们想要解析这一段语句:

1+2*3-4/5

这段语句由9个部分组成,刚好一个字符就是一个语句,其中又包含含义相同的元素,我们可以将其理解为

expr ::= num op num op num op num op num

简化一下:

expr ::= num (op num)+

op与num的组合在后面重复了4次,我们就折叠一下,并要求重复次数至少1次以上。
然后我们定义一下num与op:

num ::= [0-9]+
op ::= '+' | '-' | '*' | '/'

发现没,我们定义语言文法时, 由 ::= 符号左右的两个部分组成,左边是对非终结符的定义,右边可以是终结符或者非终结符的组合。
我们可以将一串代码理解为多叉树的根节点,非终结符为树的茎节点,终结符为树的叶子节点。我们定义语法就是规定,一个茎节点允许有哪些子节点类型,比如是否允许有叶子节点等等,当我么定义好之后,拿一串代码,从根节点开始匹配,一直匹配到所有叶子节点,此时这棵多叉树就是我们的AST。

第三章:Facc语法规范

基本语法

Facc 语法描述格式为:非终结符名称、::=、表达式。

non_terminal ::= expr

非终结符名称可以取数字、大小写字母、下划线_,但不能以数字开头。然后是::=,由于语言文法的有个老大哥级别的规范,叫EBNF表达式,能做的事情和Facc完全一致,此处沿用EBNF规范。当然,其他很多地方没有沿用规范的原因是,个人觉得这样写更简单,没必要沿用,于是顺手改了。

表达式“与”模式、“或”模式

接下来就是表达式了。表达式允许两种类型,“与”,以及“或”。“与”模式下要求表达式项同时存在。比如我们想定义一个取数组元素的表达式(比如:arr[10]),需要变量名、方括号、索引同时存在。我们假设定义了id规范及num规范,那么表达式可以定义为:

array_access_expr ::= id '[' num ']'

“或”模式要求表达式中的项只允许存在一个。比如数字间的运算符允许是加减乘除等。表达式可以定义为:

op ::= '+' | '-' | '*' | '/'

终结符

终结符有两种定义方式,字符终结符或字符串终结符。
字符终结符允许匹配代码中的一个字符。比如需要匹配一个标识符:

id ::= [a-zA-Z_] [0-9a-zA-Z_]*

方括号内允许使用单个字符或者字符范围,比如匹配一个标识符的第一个字符,允许使用小写字母(a-z)、大写字母(A-Z)、下划线(_)。需注意,如果需匹配单个字符‘-’,为避免被当做字符范围解析,需放置方括号内的第一个位置。

重复次数

默认只允许重复1次,通过在末尾加入不同符号代表标识取值范围

  • ? 0次~1次
  • * 0次~无数次
  • + 1次~无数次

通过在表达式中加入括号,组成一个组,可以通过这种方式,控制重复次数。比如要求两种类型符号交替出现可以写为:

expr ::= num (op num)+

注释

注释为对代码描述语句的备注,注释内容将被忽略,不参与解析。
两种注释方式。
“//”为行注释,注释此标识到行结束,不过与C++不同的地方在于不允许续行(C++通过行末“\”续行,能让下一行也变成注释)
“/**/”为块注释,能跨行使用。

Facc 的使用

首先,NuGet上安装Facc。
生成AST:

var _grammar = @"   // 语法描述字符串
                    // 方括号代表匹配其中任一字符
num                 ::= [0-9]+
                    // 单引号或双引号代表匹配整个字符串,“|”代表“或”关系,匹配任一串字符串
op2_sign            ::= '+' | '-' | '*' | '/'
                    // 空格连接代表“与”关系,所有元素必须同时存在
op0_expr            ::= '(' expr ')'
                    // 匹配 1+2*3-4 这样的字符串
op2_expr            ::= expr (op2_sign expr)+
                    // 表达式允许纯数字、括号或四则运算字符串
expr                ::= num | op0_expr | op2_expr
";
string _path = "D:\\ASTs"; // AST解析文件生成路径
string _namespace = "Facc.Example.ASTs"; // 生成的AST解析文件的命名空间
var _generator = new AstGenerator (_grammar, _path, _namespace);
_generator.ClearPath (); // 清空指定路径下的所有文件
_generator.Generate (); // 生成AST解析文件

执行生成的AST代码,解析文法:

var _root = AstParser.Parse<ASTs.ExprAST> ("3+2*5-4");
_root.PrintTree (0);

解析为语法树之后,再对应生成LLVM IR对接LLVM,或者对应生成其他语言,再或者生成一种自定义字节码,自己写虚拟机执行,一个编程语言就完成啦。