用Lisp写一个简单的翻译器-I

让我们尝试用Lisp编写……一种简单的命令式语言的翻译器。 不,不,我没记错-是翻译。 它将以Lisp代码广播。 然后,该代码可以由Lisp系统执行。


在这里,将通过以下事实提供无价的服务:Lisp在代码和数据之间没有障碍(这是某些编程语言的稀有属性,称为“同态”)。 但是Lisp的视觉功能也将发挥重要作用。


作为实现,我将使用HomeLisp 。 有兴趣的人可以将此项目改编为Common Lisp。 我马上要说-关于所考虑的问题,Common Lisp和HomeLisp之间的重要区别仅在于处理行和文件。


在此处下载可移植版本的HomeLisp。 文档也位于同一站点上。 那些人可以复制文章中的代码,并立即检查性能。


引起您注意的主题是我在著名的新西伯利亚LSHUP-2018上的研讨会的基础。 研讨会的结果可以在这里找到。 然后我提出了自己的方法。 我想读者熟悉Lisp语言。


下车


让我们从我们将在Lisp中广播的“简单命令式语言”开始。
该语言将仅处理数字数据。 这种语言的代码由函数(返回值的过程)组成。 在这些功能中,一个应称为main。 通过此功能,代码开始执行。 虽然为什么要这样绑定自己? 我们用命令式语言编写函数,它们以Lisp广播,并且可以与Lisp函数一起使用。 但让我们不要超越自己...


语言运算符的集合很常见:赋值,分支,算术循环,循环的提前退出,输入,输出和函数调用。 但是,语法上将函数调用作为赋值执行(调用结果)。 让注释在该行的第一个位置包含一个星号。 当然,该语言应具有创建递归函数的能力。 为了更加清楚,我将给出代码示例-打印连续的奇数并计算它们的和:


proc main() local s,n,k input n for i=1 to n k=2*i-1 print k s=s+k end_for print s end_proc 

就其精神而言,它是一种基本语言。 我将其称为“微型基础”。 我们的翻译人员应将给定的代码转换为以下Lisp函数:


 (defun main nil (let ((s 0) (n 0) (k 0)) (setq n (read)) (iter (for i from 1 to n) (setq k (- (* 2 i) 1)) (printline k) (setq s (+ sk))) (printline s))) 

我真的很喜欢iterate包,该包在专业Common Lisp包中作为宏实现。 在HomeLisp中,核心语言中包含iter函数(它实现了宏迭代的很大一部分功能)。 正是我对iter的沉迷使我们的“ mini-basic”的周期转化为iter调用。


从哪里开始实施? 首先选择要广播的文件,然后逐行读取和打印该文件。 我们将不得不多次启动翻译器,因此让它从一开始就很方便。 以下是此函数的外观:


 (defun start (&optional (fname nil)) (setq *numline* 0) (setq *flagerr* nil) (when (null fname) (setq fname (sysGetOpenName (sysHome) "-|*.mbs"))) (let ((fi (gensym 'fi))) (when fname (filOpen fi fname _INPUT) (loop (getLine fi) (when (or *flagerr* (filEOF fi)) (return t))) (filClose fi) (when *flagerr* (printsline "****   ")))) (unset '*numline*) (unset '*flagerr*)) 

该函数具有一个可选参数fname-将广播其内容的文件的名称。 输入函数时,将创建两个全局变量: numLine(源文件的行号)和flagerr (错误状态标志)。 在函数终止之前,这些变量将被销毁(未设置 HomeLisp函数会销毁全局变量)。


如果省略了输入文件名,则将调用用于选择文件的标准Windows对话框(sysGetOpenName) 。 当前目录(sysHome)用作开始目录。 接下来,为文件操纵器创建一个唯一字符,并打开该文件以进行文本读取。 然后,在一个无限循环中,逐行读取文件( getLine函数)。 每次操作后,将检查是否发生错误以及是否到达文件末尾。 如果发生错误或文件结尾已修复,则循环中断,文件关闭,并且如果有错误,则显示最后一条消息。
实际从文件读取是由getLine函数执行的:


 (defun getLine (fil) (let ((stri "")) (loop (when (filEof fil) (return "")) (setq *numline* (add1 *numline*)) (setq stri (filGetline fil)) (printsline (strCat (format *numline* "0000") " " (strRTrim stri))) (setq stri (strATrim stri)) (unless (or (eq "" stri) (eq "*" (strLeft stri 1))) (return stri))))) 

此函数接受打开文件的标识符,并在无限循环中执行以下操作:


  • 检查文件状态结束。 在这种情况下,循环中断并且该函数返回一个空字符串。
  • 读取行的计数器增加一;
  • 读取文件的下一行;
  • 读取的行已打印,并去除了右侧的可能空格;
  • 如果读取的行不为空并且在第一位置不包含星号,则它
    从函数返回;

因此,文件的所有行以其原始形式落入输出列表中。


我们闯入程序


现在,让我们教我们的代码将输入流分成单独的过程。 首先,需要将输入的字符串划分为标记(不可分的输入词法单位)。 这个过程称为解析。 我们必须创建一个解析器。 编写解析器是一个经典主题,有现成的解析器库和特殊工具,可让您生成必要的解析器...我们将走自己的路。


在描述解析器算法之前,我们要注意以下事实:输入字符串的所有字符都可以分为两类:


  • 普通字符;
  • 分隔符。

因此,在赋值运算符“ x = 15 + y ^ 2”中,字符x,1,5,y2是普通字符,而字符“ space”+^是定界符。 普通字符与分隔符有何不同? 分隔符-始终将一个令牌与另一个令牌分开。 我们的赋值运算符被划分为令牌,如下所示: “ x”,“ =”,“ 15”,“ y”,“ ^”,“ 2”


如您所见,并非所有定界符都属于解析结果(尤其是空格,不属于分隔符)。 我们将不属于结果的分隔符称为第一种类型的分隔符。 其他分隔符将称为第二种分隔符。


解析器的输入将是一个字符串,输出是一个字符串标记列表。 作为驱动器,将使用本地变量-电池。 电池最初包含一个空字符串。


解析算法可以如下:我们逐字符读取输入行。 如果遇到常规字符,请将其与电池连接。 如果找到分隔符,则:


  • 对于第一种类型的分隔符,我们将电池值(如果不为空)重置为输出列表,清除电池并继续读取下一个字符;
  • 对于第二种类型的分隔符,我们还将非空电池的值转储到输出列表中,然后将其接受的第二种类型的分隔符(作为独立令牌)输入到输出列表中,清除电池并继续读取下一个字符。

这是解析器代码:


 (defun parser (txt &optional (d1 " ,") (d2 "()+-*/\^=<>%")) (let ((res nil) (lex "") ) (iter (for s in-string (strCat txt (strLeft d1 1))) (cond ((plusp (strInd d1 s)) (when (> (strLen lex) 0) (collecting lex into res)) (setq lex "")) ((plusp (strInd d2 s)) (when (> (strLen lex) 0) (collecting lex into res)) (collecting s into res) (setq lex "")) (t (setq lex (strCat lex s))))) res)) 

除了必需的参数外,该函数还有两个可选参数: d1包含一个字符串,其每个字符均具有第一种类型的分隔符,而d2行包含第二种类型的分隔符。


上面描述了解析器功能的程序逻辑。 仅应注意,在开始工作之前,在输入行的末尾附加一个分隔符。 这样做是为了使最后处理的令牌“挂在”电池中(局部变量lex充当电池的角色)。


让我们检查解析器的“作用”:


 (parser "x = 15 + y^2") ==> ("x" "=" "15" "+" "y" "^" "2") 

是的,不是吗? 但是,使用字符串列表并不是完全Lisp。 让我们从字符串列表移到原子列表。 为此,我们需要一个函数...将所有标记再次粘合到长行中(但在标记之间插入一个空格),然后将左括号粘贴到该行的开头,将右括号封闭到末尾...然后强制Lisp读取列表:


 (defun mk-intf (txt) (let ((lex (parser txt " ," "()+-*/\^=<>%")) (intf "")) (iter (for a in lex) (setq intf (strCat intf a " "))) (input (strCat "(" intf ")")))) 

现在,如果将赋值运算符提交给mk-intf函数的输入,则会得到:


 (mk-intf "x = 15 + y^2") ==> (X = 15 + Y ^ 2) 

您看到的哪个更好。


现在让我们稍微更改一下启动功能:该功能将必须读取并处理整个过程:


 (defun start (&optional (fname nil)) (setq *numline* 0) (setq *flagerr* nil) (when (null fname) (setq fname (sysGetOpenName (sysHome) "-|*.mbs"))) (when fname (let ((fi (gensym 'fi))) (filOpen fi fname _INPUT) (loop (let ((curr-proc (action-proc fi))) (when (or *flagerr* (filEOF fi)) (return t)) (eval curr-proc))) (filClose fi)) (when *flagerr* (printsline "****   "))) (unset '*numline*) (unset '*flagerr*)) 

在循环的主体中,将调用action-proc函数(以处理过程),该函数将构成Li​​sp上已接受的过程的主体。 然后,将过程主体作为S表达式存储在curr-proc变量中,然后传递给eval的输入。 在Lisp环境中,可以接受的功能是“转世的”!


action-proc应该做什么? 该函数接收打开文件的标识符作为参数。 该函数从文件中逐行读取文件,跳过空行和注释,解析其余行,将其转换为列表形式,并生成过程的主体。


我们将逐步“学习” 动作过程的生成。 让我们从教我们的函数来识别过程的开始和结束开始。 在迷你基础上,该过程的开始是:


 proc name(p1,p2,p3) 

尝试解析这样的一行:


 (mk-intf "proc name(p1,p2,p3)") ==> (PROC NAME (P1 P2 P3)) 

action-proc函数应如何响应此输入? 自然地,确保列表的开头是PROC原子,您需要将列表的第二个元素用作函数的名称,并将第三个元素作为参数的列表。 参数的名称和列表应存储在局部变量中。 读取end_proc运算符后,您需要从函数名称和参数列表中形成一个空的defun形式(到目前为止),并将此形式作为结果返回。 看起来是这样的:


 (defun action-proc (fi) (let ((stmt nil) (proc-name nil) (proc-parm nil)) (loop (setq stmt (mk-intf (getLine fi))) (when (null stmt) (return t)) (cond ((eq (car stmt) 'proc) (setq proc-name (nth 1 stmt)) (setq proc-parm (nth 2 stmt))) ((eq (car stmt) 'end_proc) (return t)) (t (printsline (strCat "****  " (output stmt) "  ")) (setq *flagerr* t)))) `(defun ,proc-name ,proc-parm (quote OK)))) 

为了最终形成defun子句,使用了反向锁定。 请注意,生成的过程将返回OK原子作为结果。


现在我们可以检查我们的代码了。 将以下代码放入文件0000.mbs中:


 proc f1(x,y) end_proc proc f2(x) end_proc 

运行启动过程,选择0000.mbs并在控制台上查看:


 0001 proc f1(x,y) 0002 end_proc 0003 proc f2(x) 0004 end_proc 

如果愿意,可以确保Lisp机器现在具有两个(到目前为止无用)功能f1f2


 (getd 'f1) ==> (EXPR (XY) (QUOTE OK)) (getd 'f2) ==> (EXPR (X) (QUOTE OK)) 

而且! 它们可以已经启动:


 (f1 1 2) ==> OK (f2 2) ==> OK 

我们的翻译第一次呼吸...


输入,输出和局部变量


现在该教我们的新生翻译人员如何处理输入打印本地操作员。


处理输入和打印的最简单方法。 两种运算符的语法结构相同:关键字和变量。 操作员输入x应该变成这样的Lisp形式(setq x(读取)) 。 因此, print x运算符变成一种形式(printline x) 。 要存储这些形式,必须在action-proc函数中提供局部变量主体 。 该变量将累积执行未来函数计算的表格。 然后,一切都非常简单:


 (defun action-proc (fi) (let ((stmt nil) (proc-name nil) (proc-parm nil) (loc-var nil) (body nil)) (loop (setq stmt (mk-intf (getLine fi))) (when (null stmt) (return t)) (cond ((eq (car stmt) 'proc) (setq proc-name (nth 1 stmt)) (setq proc-parm (nth 2 stmt))) ((eq (car stmt) 'end_proc) (return t)) ((eq (car stmt) 'print) (setq body (append body (list (cons 'printline (cdr stmt)))))) ((eq (car stmt) 'input) (setq body (append body (list (list 'setq (cadr stmt) (list 'read) ))))) (t (printsline (strCat "****  " (output stmt) "  ")) (setq *flagerr* t)))) `(defun ,proc-name ,proc-parm ,@body))) 

现在,让我们在迷你基础上准备此源代码:


 proc f1(x,y) print x print y end_proc proc f2(x) input x print x end_proc 

并尝试翻译它。我们将有两个Lisp函数f1f2 。 让我们看看它们的定义表达式,并确保它们正确生成:


 (getd 'f1) ==> (EXPR (XY) (PRINTLINE X) (PRINTLINE Y)) (getd 'f2) ==> (EXPR (X) (SETQ X (READ)) (PRINTLINE X)) 

您可以调用这些函数,并确保它们完全按预期工作。 我们在参数变量中输入值就不用打扰了-我们还没有局部变量...让我们添加它们。


本地操作员可以位于过程中的任何位置,并且可以多次出现。 如果在处理过程期间遇到本地运算符,则需要获取变量列表并将其保存在本地变量中。 满足end_proc语句后您需要生成let表单并将其“封闭”在其中的所有可执行语句(目前仅输入print )。 这是action-proc现在的样子:


 (defun action-proc (fi) (let ((stmt nil) (proc-name nil) (proc-parm nil) (loc-var nil) (lv nil) (body nil)) (loop (setq stmt (mk-intf (getLine fi))) (when (null stmt) (return t)) (cond ((eq (car stmt) 'proc) (setq proc-name (nth 1 stmt)) (setq proc-parm (nth 2 stmt))) ((eq (car stmt) 'end_proc) (return t)) ((eq (car stmt) 'print) (setq body (append body (list (cons 'printline (cdr stmt)))))) ((eq (car stmt) 'input) (setq body (append body (list (list 'setq (cadr stmt) (list 'read) ))))) ((eq (car stmt) 'local) (setq loc-var (append loc-var (cdr stmt)))) (t (printsline (strCat "****  " (output stmt) "  ")) (setq *flagerr* t)))) (iter (for a in (setof loc-var)) (collecting (list a 0) into lv)) `(defun ,proc-name ,proc-parm (let ,lv ,@body)))) 

局部变量列表累积在loc-var变量中。 在完成该过程的处理后,将从该列表中构建一个格式为(名称0)的对列表。 同时,不希望重复使用相同的名称……如何防止呢? 当然,可以检查本地操作员的每次处理是否存在重复的名称(如果有的话,给出错误信息)。 但是,在我看来,最好是消除重复,这就是setof调用的作用 。 现在,我们来翻译并运行该程序:


 proc f1(x,y) local a,b,c print x print y input a print a end_proc 

我们确保它完全按照算法建议的方式工作。 但是所有最有趣的事情就在眼前!


从这里您可以下载我们正在使用的最终版本 w 编码...


待续!

Source: https://habr.com/ru/post/zh-CN419103/


All Articles