复合作用域
复合作用域
程序底层(从CPU到栈帧)
让我们从一般程序运行的底层开始讲起。。。
方便起见,我们只讨论支持函数作为一等公民(First-Class Citizen)的动态语言,在这些语言中,函数的定义可以嵌套在其它函数中。
程序从代码的编写开始,它被语法和宏展开为语法树,并最终变成指令列表,为了兼容反射,我们将其称为代码对象。
随后程序的运行需要计算(cpu线程),空间(内存),以及交互(io)。
空间被分为代码段、数据段、堆和栈,可执行代码(指令)和已初始化的全局/静态数据及常量被初始化进代码段和数据段中,通常来说一个进程可以有多个线程,线程在自己的栈上工作,所有线程共享一个堆空间。
线程根据代码和数据执行操作:
- 代码:表示要执行的操作序列。
- 每个线程在同一时刻只有一个指令指针(Instruction Pointer, IP),通常存储在CPU的寄存器中,与线程绑定。
- 它指向代码段某个代码对象上的一个确定的指令。
- 它的作用是标记当前执行到了什么地方,接下来要做什么。
- 代码对象通常对应一个函数体,或更广义的代码块(如模块、类体或匿名代码块)。
- 数据:表示程序运行时的状态和变量。
- 每个线程在同一时刻只在一个栈帧(Stack Frame)上工作,它是当前调用栈的顶部栈帧。
- 栈帧用于存储局部变量,可被视为一个变量字典,对应一个确定的代码对象。
- 栈帧一般在函数调用时创建(入栈),返回时销毁(出栈),形成调用栈;一个线程在同一时刻只有一条确定的、线性的调用栈;从时间上看,形成对调用树的深度优先搜索。
- 栈帧通常在内存中连续排列,且空间有限;一般从内存中的高位向低位增长,顶层对应地址最低位;通过分段栈技术可以让它按链表方式连接,允许动态增长。
- 作用域与变量捕获:
- 栈帧隔离变量的创建以形成作用域,变量优先从局部读写。
- 动态作用域允许跨越栈帧读写变量。
- 词法作用域(又名静态作用域)允许从函数对象定义时捕获的闭包中读写变量。
- 需要注意的是,通常来说,函数捕获的顶层作用域变量不被称为闭包,因为闭包的关键特性是生命周期的延长(解决了局部变量在函数执行完毕后被销毁的问题),而顶层作用域的变量始终不会被销毁(至少在模块的生命周期中,因为模块内所有函数,不管是通过词法还是动态,最终都能回溯到顶层作用域)
- 但从其原始定义出发,表示其定义环境的变量,我觉得没问题。
- 每次调用函数时的运行流程:
- 外部函数(或底层栈帧)在运行时调用函数。
- 调用者将要传递的实参准备好(压入栈,或者放入特定的CPU寄存器),这通常遵循语言或操作系统的调用约定。
- 当前的指令指针(即
CALL
指令的下一条指令地址)作为返回地址被压入栈顶,等待函数返回值以恢复并继续执行;它属于哪个栈帧取决于当前语言的定义。 - 指令指针被更新为指向被调用函数对应代码对象的入口地址。此时,控制权正式转移到被调用函数。
- 一个新的栈帧被创建并压入调用栈顶部
- 指向上一层栈的指针和指向当前函数对象的指针被初始化进栈中。
- 从上一层传入实参,结合代码对象的形参初始化变量。
- 函数执行。
- 将返回值放入指定的寄存器或内存位置。
- 销毁当前栈帧,并从之前保存的返回地址恢复指令指针。
- 控制权返回到调用者,开始继续执行。
- 每次定义函数时的运行流程:
- 外部函数(或底层栈帧)在运行时定义函数。
- 如果是纯动态作用域,那么函数对象只需要存储函数名(如果有)、形参,和指向这个函数对应代码对象入口的指令指针。
- 如果是词法作用域,根据定义函数所需要的变量(包括内层函数所需要的),结合变量捕获规则捕获变量,形成一个独立的变量字典,称为闭包(Closure),存入函数对象。
- 函数对象最终被保存在堆上,栈上的变量可能会保留指向其的引用,并在未来调用它。
变量作用域(词法与动态)
函数的嵌套形成了深度优先搜索形式的调用树,这种结构也同样发生在表达式的求值中,不同的是,函数通过定义和调用从两个环境收集值,它们可能在调用树的不同地方,因而其中的代码有两个外界,由此在向外访问函数的非局部变量时产生了词法和动态作用域的区别。
而表达式只收集一次,其求值往往定义后立即执行,因此非函数的局部作用域是不区分词法还是动态的;函数如果在定义后原地调用,也是不区分词法还是动态的。
额外的,我们可以通过“对变量的创建进行遮蔽”来定义作用域。
之所以这样定义,是因为注意到作用域一般不阻挡对外部变量的读写,但是内部创建的变量会在离开范围时失效。
对于函数和块级作用域,以 JavaScript 为例:
- 变量的读取是不阻挡的。
- 变量的写入也是不阻挡的。
- 块级作用域如
for
允许对外部变量赋值,并在离开作用域后影响不会消失。 - 函数内部能对闭包变量赋值,并保留影响。
- 块级作用域如
- 变量的创建总是不会泄露到作用域外的。
- 不仅出不去,还会阻挡外部的变量进来(暂时性死区 Temporal Dead Zone)。
另一个定义作用域的视角是以变量不变性为基础的,在这个视角下,变量赋值本身就是一种作用域的创建,范围从这一点到所在作用域的末尾,这是因为变量绑定是不变的,那么变量的变化只能是被该赋值产生的作用域遮蔽了。
事实上,Haskell 的do
语法糖就是这样做的,当前作用域的后续部分被作为回调函数定义,其形参作为“左值”被“赋值”,对应函数的原地调用(准确来说是bind
到monad
,等待在其预定义的副作用环境里被调用),于是通过函数的局部作用域实现了“赋值”的行为。这也指向了形参和赋值的等价性。
但是这很难运用在非函数式语言中,因为在非函数式语言中,变量写入造成的影响往往会泄露到作用域外部,这导致了作用域的交叉,一般无法被妥善处理。
网状对称
如果我们允许复合作用域,也就是允许函数同时从动态和词法作用域中捕获变量,会得到一些非常有意思的行为。
作为讨论的前提,需要让函数能够显式声明哪些变量需要从动态作用域获取,哪些变量需要从词法作用域获取。考虑到词法作用域隐式继承更符合习惯和直觉,我认为可以这样规定:函数需要显式声明哪些变量需要从动态作用域中捕获,而剩余部分则认为需要从词法作用域中捕获,至于找不到时是否要转入另一个不在当前的讨论重点。
当我们在一个函数内,想要考虑其运行时某个变量来自于哪里时,首先在其局部找,如果局部没找到:
- 若在动态作用域中:
- 找到调用它的作用域,首先在其局部找,如果没找到:
- 若其为顶层作用域,查找失败,否则肯定是在某个运行时的函数内调用的,归结为原始问题,递归。
- 若在词法作用域中,运行时直接在闭包中找就行了,但编译前追溯时,还是需要继续找:
- 找到定义它的作用域,首先在其局部找,如果没找到:
- 若其为顶层作用域,查找失败,否则肯定是在某个运行时的函数内定义的,归结为原始问题,递归。
因此它并不是单纯的沿着调用栈往上找变量,也不是单纯的沿着词法链往外找变量,每个变量在每个函数的定义上被路由,交织成网,并最终汇聚到同一个顶层作用域。
另外也注意到,对于每个作用域,它对内部的函数定义和调用起到的作用也都是相同的,于是调用树和词法链在另一个方向上同样也能交织成网——实际上这是同一个网,这形成了美妙的对称性。