简单易懂的程序语言入门小册子(9):环境,引入环境
环境类似于其他语言(C++、JAVA等)的“符号表”。 所谓符号表,是一张将变量名与变量代表的内容联系起来的一张表。 不过这里我们抛弃符号表的观点,单纯地从算法角度上引入环境这一概念。
引入环境
通过修改解释器求值过程的算法,可以很自然的引入环境这个概念。
在前面基于文本替换的解释器里可以看到,所谓的“计算”,其实不过是一堆符号根据某种规则替换来替换去最终得到一个不能再归约的结果的过程。 这个替换过程需要遍历表达式。 比如计算表达式(λX.MV),需要进行替换M[X←V]。 这个替换过程需要遍历表达式M。 而求值过程本身也需要遍历表达式M。 这样就至少需要遍历两次表达式M。 另外,替换过程本身也很麻烦,其中有这么一条规则: (λX1.M)[X2←N]=(λX3.M[X1←X3][X2←N])其中X1≠X2,X3∉FV(N),X3∉FV(M)∖{X1}
这样一来,在原来的算法里,由于直接使用替换,计算一个表达式需要一遍遍地遍历这个表达式的子表达式们。 这是一个效率很低的方法。 为了避免这些多余的遍历过程,我们可以延后替换过程,等到遍历到需要替换的变量时再对这个变量做替换。 这样只需要一次遍历就够了。 为了延后替换过程,需要一个保存这些“延后的替换”的数据结构。 这个数据结构叫做环境。 我们用字母E表示环境。
下面举个例子来粗略地说明如何用环境来进行计算(粗略的展示,不代表实际的计算步骤): ((λx.λy.(+xy)11)_22)→(⟨λy.(+xy),⟨(x←11)⟩⟩22)_→⟨(+xy),⟨(y←22),(x←11)⟩⟩_→⟨(+1122)_,⟨(y←22),(x←11)⟩⟩→⟨33_,⟨(y←22),(x←11)⟩⟩→33
引入环境后,一个表达式就可能不是一个“完整的表达式”了。 因为这个表达式可能有些自由变量应该被替换,只是替换被延后了,所以它看起来仍保持还没被替换的样子。 所以,一个“完整的表达式”,应该包括表达式和“延后的替换”的信息——也就是环境。 表达式和环境这个二元组叫做闭包,记为⟨M,E⟩。
我一直故意没有描述环境的具体定义。 有了闭包这个概念后,现在可以说说环境的定义了。 从用途上看,环境可以理解成一个函数,它的输入是一个变量,它的输出是这个变量应该被替换成的东西。 这个“被替换成的东西”是什么东西呢? 一个直接的想法是表达式。 但这是不正确的! 因为一个表达式可能不是一个“完整的表达式”。 所以这个“被替换成的东西”应该是一个闭包。 也就是说,环境是一个将变量映射成闭包的函数: E:X→⟨M,E⟩
构造环境
考虑表达式(leta1(leta2a))。 这个表达式共有两次替换,第一次将变量a替换成1,第二次将变量a替换成2,而表达式最终的值是2。 可以看到,当遇到同名变量时,后替换的变量有效。 用于保存替换信息的环境应该具有后进入先找到(栈!)的性质。 环境可以用链表的形式保存。 每次保存替换信息时,将要替换的变量和应该替换成的闭包插入到链表的头部; 而查找是从头部开始查找。
构造环境的方法如下面公式所示: E=empty-env|⟨extend-env,E,X,⟨M,E′⟩⟩
当输入变量X时,从链表的头部开始搜索,返回第一次找到变量X时对应的闭包。 用E(X)表示在环境E中查找X的过程,这个过程如下: empty-env(X)=⟨X,empty-env⟩⟨extend-env,E,X,⟨M,E′⟩⟩(X)=⟨M,E′⟩⟨extend-env,E,X′,⟨M,E′⟩⟩(X)=E(X)其中X′≠X
CEK machine
还记得CK machine吗? CEK machine指的是加入环境后的CK machine。
为了简化问题,暂时先不考虑递归表达式(fixX1X2M)。 无fix表达式的Alligator的语法(我真的没在凑字数): M,N,L=X|b|λX.M|(+MN)|(−MN)|(iszeroM)|(MN)
CEK machine的求值过程从CK machine的求值过程稍微改改得到。 无fix表达式的Alligator的语法中只有函数调用(MN)会用到替换。 现在我们不使用替换,取而代之的是扩展环境。 其他的改动还有:原来的表达式M改成闭包⟨M,E⟩; 而变量X现在要到环境查找对应的闭包E(X)。 另外,Alligator使用call-by-value的调用顺序,所以要求环境值域的闭包里的表达式是一个值。 也就是说,CEK machine里用到的环境应该是这样的函数: E:X→⟨V,E⟩
递归的处理
fix表达式的求值过程需要特别的处理。 按照上面的技巧修改fix表达式的求值过程,这个求值过程有如下形式: ⟨⟨(fixX1X2M),E⟩,κ⟩v→v⟨⟨λX2.M,Efix⟩,κ⟩c
增加了构造环境方法后,fix表达式的求值过程如下: ⟨⟨(fixX1X2M),E⟩,κ⟩v→v⟨⟨λX2.M,⟨extendrec-env,E,X1,X2,M⟩⟩,κ⟩c
代码实现
写代码咯,首先,用Racket的结构来表示闭包:
环境的代码,同样用结构来表示环境:
函数apply-env是在环境中查找变量的过程:
最后是求值过程的代码:
简化CEK machine
CEK machine里环境的值域只有三种类型的闭包: 包含变量的闭包⟨X,empty-env⟩、 包含常数的闭包⟨b,E⟩ 以及包含函数的闭包⟨λX.M,E⟩。 这三种闭包只有函数的情况需要环境。 而变量和常数的情况下不需要环境。 修改值类型为: V=X|b|⟨λX.M,E⟩
修改环境的定义,将环境的定义改为变量到值的映射: E:X→V
思考
- 用函数表示环境。
- 关于递归的处理,extendrec-env的扩展方式可能看起来有点违和感。 使用带有“副作用”的方法可以避免这种扩展方式的使用。修改环境为带有存储的环境(方括号表示这个是一个引用,这个引用指向的存储里的值是V): E=empty-env|⟨extend-env,E,X,[V]⟩另外增加一个修改存储的方法(set-value-env!EV′), 其中这里的E=⟨extend-env,E,X,[V]⟩。 这个方法修改环境E为⟨extend-env,E,X,[V′]⟩。为了计算fix表达式(fixX1X2M)扩展的环境Efix, 先将X1绑定到一个未初始化的值: Efix=⟨extend-env,E,X1,[uninitialized]⟩其中uninitialized表示未初始化。 然后修改Efix: (set-value-env!Efix⟨λX2.M,Efix⟩)这样就能得了Efix。
- 这是一个算法练习。 在一些代码分析过程中,知道代码中所有变量的使用次数是很有用的。 我们希望统计所有变量的使用次数,并标记在变量声明的位置。 例如输入一段代码:
输出:1234567
(((
lambda
x
(
lambda
y
(
+
((
lambda
x x)
33
)
(
+
y (
+
x x)))))
11
)
22
)
如何实现这个算法?要求只遍历代码一次。1234567(((
lambda
(x
2
)
(
lambda
(y
1
)
(
+
((
lambda
(x
1
) x)
33
)
(
+
y (
+
x x)))))
11
)
22
)
【推荐】国内首个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训练数据并当服务器共享给他人