Loading web-font TeX/Main/Regular

简单易懂的程序语言入门小册子(9):环境,引入环境

环境类似于其他语言(C++、JAVA等)的“符号表”。 所谓符号表,是一张将变量名与变量代表的内容联系起来的一张表。 不过这里我们抛弃符号表的观点,单纯地从算法角度上引入环境这一概念。

引入环境

通过修改解释器求值过程的算法,可以很自然的引入环境这个概念。

在前面基于文本替换的解释器里可以看到,所谓的“计算”,其实不过是一堆符号根据某种规则替换来替换去最终得到一个不能再归约的结果的过程。 这个替换过程需要遍历表达式。 比如计算表达式(λX.MV),需要进行替换M[XV]。 这个替换过程需要遍历表达式M。 而求值过程本身也需要遍历表达式M。 这样就至少需要遍历两次表达式M。 另外,替换过程本身也很麻烦,其中有这么一条规则: (λX1.M)[X2N]=(λX3.M[X1X3][X2N])其中X1X2,X3FV(N),X3FV(M){X1}

在这种情况下,一次替换要遍历两次M

这样一来,在原来的算法里,由于直接使用替换,计算一个表达式需要一遍遍地遍历这个表达式的子表达式们。 这是一个效率很低的方法。 为了避免这些多余的遍历过程,我们可以延后替换过程,等到遍历到需要替换的变量时再对这个变量做替换。 这样只需要一次遍历就够了。 为了延后替换过程,需要一个保存这些“延后的替换”的数据结构。 这个数据结构叫做环境。 我们用字母E表示环境。

下面举个例子来粗略地说明如何用环境来进行计算(粗略的展示,不代表实际的计算步骤): ((λx.λy.(+xy)11)_22)(λy.(+xy),(x11)22)_(+xy),(y22),(x11)_(+1122)_,(y22),(x11)33_,(y22),(x11)33

引入环境后,一个表达式就可能不是一个“完整的表达式”了。 因为这个表达式可能有些自由变量应该被替换,只是替换被延后了,所以它看起来仍保持还没被替换的样子。 所以,一个“完整的表达式”,应该包括表达式和“延后的替换”的信息——也就是环境。 表达式和环境这个二元组叫做闭包,记为M,E

我一直故意没有描述环境的具体定义。 有了闭包这个概念后,现在可以说说环境的定义了。 从用途上看,环境可以理解成一个函数,它的输入是一个变量,它的输出是这个变量应该被替换成的东西。 这个“被替换成的东西”是什么东西呢? 一个直接的想法是表达式。 但这是不正确的! 因为一个表达式可能不是一个“完整的表达式”。 所以这个“被替换成的东西”应该是一个闭包。 也就是说,环境是一个将变量映射成闭包的函数: E:XM,E

为什么定义环境这么麻烦? 因为它是个递归定义。 考虑上闭包的话,环境和闭包就是互递归定义。

构造环境

考虑表达式(leta1(leta2a))。 这个表达式共有两次替换,第一次将变量a替换成1,第二次将变量a替换成2,而表达式最终的值是2。 可以看到,当遇到同名变量时,后替换的变量有效。 用于保存替换信息的环境应该具有后进入先找到(栈!)的性质。 环境可以用链表的形式保存。 每次保存替换信息时,将要替换的变量和应该替换成的闭包插入到链表的头部; 而查找是从头部开始查找。

构造环境的方法如下面公式所示: E=empty-env|extend-env,E,X,M,E

empty-env表示空环境。 空环境没有保存任何的“延后的替换”。 由于空环境表示没有替换,所以对空环境输入任何变量X,它的输出是个包含X的闭包X,empty-env (另一种处理方式是输出错误“未定义/无绑定的变量X”)。 extend-env这一行扩展了原来的环境,这个构造过程称为将变量X绑定到闭包M,E

当输入变量X时,从链表的头部开始搜索,返回第一次找到变量X时对应的闭包。 用E(X)表示在环境E中查找X的过程,这个过程如下: empty-env(X)=X,empty-envextend-env,E,X,M,E(X)=M,Eextend-env,E,X,M,E(X)=E(X)其中XX

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:XV,E

新的求值过程如下(可以对比一下CK machine的求值过程)(公式太多mathjax出问题只好不得不截图……):

cek-machine

递归的处理

fix表达式的求值过程需要特别的处理。 按照上面的技巧修改fix表达式的求值过程,这个求值过程有如下形式: (fixX1X2M),E,κvvλX2.M,Efix,κc

关键是Efix是什么? 由于λX2.M还有自由变量X1,所以Efix是在E基础上将变量X1绑定到代表这个递归函数的闭包。 这个闭包是λX2.M,Efix。 所以Efix满足下面这个方程: Efix=extend-env,E,X1,λX2.M,Efix
这个方程是没法解出来咯。 只能换个方式来得到Efix。 直接扩展构造环境的方法: E=...|extendrec-env,E,X1,X2,M
extendrec-env方式扩展的环境在查找变量X时的行为如下: extendrec-env,E,X,X2,M(X)=λX2.M,extendrec-env,E,X,X2,Mextendrec-env,E,X1,X2,M(X)=E(X)其中X1X

增加了构造环境方法后,fix表达式的求值过程如下: (fixX1X2M),E,κvvλX2.M,extendrec-env,E,X1,X2,M,κc

代码实现

写代码咯,首先,用Racket的结构来表示闭包:

struct-closure

环境的代码,同样用结构来表示环境:

struct-env

函数apply-env是在环境中查找变量的过程:

apply-env

最后是求值过程的代码:

value-of-k-1

value-of-k-2

apply-k-1

apply-k-2

简化CEK machine

CEK machine里环境的值域只有三种类型的闭包: 包含变量的闭包X,empty-env、 包含常数的闭包b,E 以及包含函数的闭包λX.M,E。 这三种闭包只有函数的情况需要环境。 而变量和常数的情况下不需要环境。 修改值类型为: V=X|b|λX.M,E

我们一直以来将值当作一种特殊的表达式,也就是说值是表达式的子集。 而修改后的值类型已经不是表达式的子集了。

修改环境的定义,将环境的定义改为变量到值的映射: E:XV

构造环境的方法: E=empty-env|extend-env,E,X,V|extendrec-env,E,X1,X2,M
求值过程的简化主要是去掉一些不必要的闭包的构造。 简化后的求值过程如下: X,E,κvvE(X),κcb,E,κvvb,κcλX.M,E,κvvλX.M,E,κc(fixX1X2M),E,κvvλX2.M,extendrec-env,E,X1,X2,M,κc(onM1M2...Mn),E,κvvM1,E,opd,κ,E,on,(M2...Mn),()vV,E,opd,κ,E,on,(Mi+1...Mn),(V1...Vi1)ccMi+1,E,opd,κ,E,on,(...Mn),(V1...Vi1V)vV,opd,κ,E,on,(),(V1...Vn1)ccV,κc其中V=on(V1,...,Vn1,V)(MN),E,κvvM,E,arg,κ,E,NvV,arg,κ,E,NccN,E,fun,κ,VvV,fun,κ,λX.L,EccL,extend-env,E,X,V,κv
代码实现:略。

思考

  1. 用函数表示环境。
  2. 关于递归的处理,extendrec-env的扩展方式可能看起来有点违和感。 使用带有“副作用”的方法可以避免这种扩展方式的使用。修改环境为带有存储的环境(方括号表示这个是一个引用,这个引用指向的存储里的值是V): E=empty-env|extend-env,E,X,[V]
    另外增加一个修改存储的方法(set-value-env!EV), 其中这里的E=extend-env,E,X,[V]。 这个方法修改环境Eextend-env,E,X,[V]。为了计算fix表达式(fixX1X2M)扩展的环境Efix, 先将X1绑定到一个未初始化的值: Efix=extend-env,E,X1,[uninitialized]
    其中uninitialized表示未初始化。 然后修改Efix(set-value-env!EfixλX2.M,Efix)
    这样就能得了Efix
  3. 这是一个算法练习。 在一些代码分析过程中,知道代码中所有变量的使用次数是很有用的。 我们希望统计所有变量的使用次数,并标记在变量声明的位置。 例如输入一段代码:
    1
    2
    3
    4
    5
    6
    7
    (((lambda x
        (lambda y
          (+ ((lambda x x)
              33)
             (+ y (+ x x)))))
       11)
     22)
    输出: 
    1
    2
    3
    4
    5
    6
    7
    (((lambda (x 2)
        (lambda (y 1)
          (+ ((lambda (x 1) x)
              33)
             (+ y (+ x x)))))
      11)
     22)
    如何实现这个算法?要求只遍历代码一次。
posted @   古霜卡比  阅读(1349)  评论(2编辑  收藏  举报
编辑推荐:
· 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训练数据并当服务器共享给他人
点击右上角即可分享
微信分享提示