简单易懂的程序语言入门小册子(2):基于文本替换的解释器,加入整数类型
为了有条不紊地实现一个解释器,我将按以下三个步骤走:
- 明确语法
- 针对语法描述求值过程
- 根据求值过程编写代码实现
语法
λ演算不适合作为一门实际使用的程序语言。 λ演算只有变量和函数两种类型,而其他常用类型如整数、布尔、字符等都没有。 虽然可以通过编码的方式表示这些常用类型,但这样也很麻烦。 通常直接扩展λ演算,加入一些常用类型以及针对这些类型的基本运算。 这种扩展后的语言简称为ISWIM,全称未知……
为简单起见,我只加入整数类型,以及加法和减法。 扩展后的语法如下: M,N,L=X|b|λX.M|(+MN)|(−MN)|(MN)
求值过程
为了描述一个计算这门语言的解释器的求值过程,首先要明确求值停止条件。 我们规定当归约到X,b,λX.M这三种表达式之一时,认为解释器已经求出了最终结果。 这三种表达式称为值,用字母V表示。 V=X|b|λX.M
用记号eval(M)表示表达式M的求值结果。 对于值,求值只需返回它们自身。 eval(X)=Xeval(b)=beval(λX.M)=λX.M
为了让解释器尽量简单,假设输入的程序是正确的。 也就是说,对于加减法运算的V1和V2都是整数,记为b1和b2; 函数调用里的V1是一个函数λX.L。
加减法如字面本意,就是作加减法。 函数调用过程是一个β归约过程。 eval((+b1b2))=b1+b2eval((−b1b2))=b1−b2eval((λX.LV2))=eval(L[X←V2])
由于加入了新的语法,替换过程也要添加相应的过程。 这里列上整个替换过程: X1[X1←N]=NX2[X1←N]=X2其中X1≠X2b[X←N]=b(λX1.M)[X1←N]=(λX1.M)(λX1.M)[X2←N]=(λX3.M[X1←X3][X2←N])其中X1≠X2,X3∉FV(N),X3∉FV(M)∖{X1}(+M1M2)[X←N]=(+M1[X←N]M2[X←N])(−M1M2)[X←N]=(−M1[X←N]M2[X←N])(M1M2)[X←N]=(M1[X←N]M2[X←N])
最后总结求值过程如下: eval(X)=Xeval(b)=beval(λX.M)=λX.Meval((+MN))=eval(M)+eval(N)eval((−MN))=eval(M)−eval(N)eval((MN))=eval(L[X←eval(N)])其中eval(M)=λX.L
实现
这里使用Racket语言来编写解释器。 解释器输入不使用字符串,而是用Racket的符号系统。 使用符号系统是为了简化语法分析的工作。 利用Racket的模式匹配可以方便地实现语法分析。 另外,计算机输入λ还是很麻烦的,所以在具体实现的语言中用(lambda X M)代替λX.M。
解释器是一个实现了eval函数的程序。 代码是求值过程的公式逐句转换,就不一一解释了。 value-of是求值过程:
substitute是替换过程:
在替换过程中有一处需要生成新变量(new-tmp-var)。 新变量不能和被代入的表达式中的自由变量重名。 一个选取新变量的方法就是选择程序里肯定不会出现的变量名。 我假定输入的程序没有以井号“#”开头的变量。 新生成的就以井号加数字的方式命名:#1, #2, #3,...。
测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 'a >> 'a 12 >> 12 '( + 12 13 ) >> 25 '( - 32 23 ) >> 9 '( lambda x ( + x 1 )) >> '( lambda x ( + x 1 )) '(( lambda x ( - x 1 )) 22 ) >> 21 '((( lambda x x) ( lambda y y)) 11 ) >> 11 '((( lambda x ( lambda y x)) y) 0 ) >> 'y |
惰性求值
惰性求值指对一个表达式,只有在需要它的计算结果时才对它求值。
对于函数调用的求值过程,参数N可以先不进行求值: eval((MN))=eval(L[X←N])其中eval(M)=λX.L
在函数调用过程中先对参数求值的调用方式叫做call-by-value。 下面用例子展示这两种调用方式的不同。
Call-by-value: (λx.(+xx)(+23))→(λx.(+xx)5)→(+55)→10
Call-by-name: (λx.(+xx)(+23))→(+(+23)(+23))→(+55)→10
在两者都能成功求值的情况下,call-by-name的求值结果和call-by-value的求值结果是一样的。 它们的区别在于两者的求值过程不同。 看下面这个表达式: ((λy.λx.x(λx.(xx)λx.(xx)))(+1221))
如果用call-by-value的方式求值,必然要先求(λx.(xx)λx.(xx))的值。 而(λx.(xx)λx.(xx))是个无限循环。 所以call-by-value的调用方式会陷入死循环。
如果用call-by-name的方式求值,由于函数λy.λx.x中的函数体其实没涉及到y的, 所以(λx.(xx)λx.(xx))这个参数就函数调用过程后默默地消失了: ((λy.λx.x(λx.(xx)λx.(xx)))(+1221))→(λx.x(+1221))→(+1221)→33
Call-by-name的代码实现只需在原来的基础上改一行:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人