RPL 漫谈:函数、运算符、变量、临时变量、函数式
本帖最后由 MalachiteN 于 2023-12-18 03:38 编辑RPL 是用于惠普 28/48/49/50 系列的编程语言。它的函数和临时变量机制很独特,许多特性令人着迷,还有些尚未被我们阐明。
先从函数和运算符说起。平时我们书写表达式的方式叫作 ALG,Algebra Notation,代数表示法。简单的表达式就像 a+b 这样,运算符在中间,所以又叫作中缀表示法。同时,函数就像 f(x+1) 这样,写在括号括起来的参数表前面。但我们很容易理解,常见的运算符其实也是函数——比如加号,就是有两个参数、返回其和的函数;而平方根号,则是有一个参数,返回其平方根的函数。
有没有一种办法,能让函数和运算符号等同呢?答案是肯定的。前缀表达式——运算符在算式开头的表示法中,我们可以把函数写成 (f 1) 这种形式,把运算写成 (+ 1 1) 这种形式。这样,无论是函数还是运算符,都在一样的位置,发挥一样的职能。这正是古老而强大的 Lisp 语言(注意并非 RPL)的写法。
大家如果使用过 RPL 就一定知道,这种写法并不是 RPL 的写法。RPL 是怎样写的呢?是 « 1 1 + »。抛开角引号不谈,我们发现,它的运算符位置反了过来,跑到了最后面。这是因为 RPL 是基于 RPN 的语言。RPN 是什么?是 Reversed Polish Notation,逆波兰表示法。那么大家肯定会先问,波兰表示法是什么?答案是,波兰表示法就是前缀表达式,在上世纪20年代由波兰数学家扬·武卡谢维奇发明。那么逆波兰表示法,当然就是后缀表达式。
正如前文所说,Lisp 采用了前缀表达式,也就是波兰表示法。而 RPL 的名字也很好理解了,正是 Reversed Polish Lisp。这意味着 RPL 意欲成为一个与 Lisp 强大程度相似的语言。无论是前缀还是后缀,都有运算符号等同于函数的优势,这很容易理解;并且,它们都还有无须考虑运算符号优先级别的优势,因为符号或者函数都只会结合离自己最近的两个对象。但为什么 RPL 在表示法上与原本的 Lisp 产生了分歧呢?原因还不少,大体可以分为人机功效和工程两类原因。
先解释简单的人机功效吧。其实很简单:我们人类,平时做算术的时候,看到的和写下的一般当然是 ALG 表达式,也就是中缀式。但我们的脑子真的在对两个数字做运算的时候——比如计算 1+2,只有一种可能,那就是先看符号两端的两个数,然后对它们做符号指定的运算。我们会发现,其实我们脑子的内部构造是适应于 RPN 的,只是因为长久的 ALG 熏陶让我们第一时间因为反直觉而不太习惯而已;而习惯了 RPN 的人会非常喜欢 RPN,因为这才是真正的直觉。
然后是工程上的问题。前缀表达式的实现需要维护一个列表 “树”——每个函数都可以看成是一个列表的开头,而函数的每个参数组成列表的每个空位,它们可由值,也可由其他函数 “列表” 填上(待会儿再求值)。这组成了一个很吃内存的树形结构,并且仰赖复杂的解释器进行优化、化简和求值。这也是最早的函数式语言 Lisp 名字的来历:LISP = LISt Process 表处理语言。
那么 RPL 是怎么做的呢?它维护一个栈,就是一种后进先出、先进后出的数据结构。我们可以理解成一个装羽毛球的筒,只开了一个口。那么先放入的羽毛球在最底下被压着,如果不先把后放入的羽毛球拿出来,就没法把它们拿出来。接下来,我们可以想象这堆羽毛球上,每个都写了一个数,同时我们还有一些小纸条上面是运算符号,我们把这些羽毛球和纸条按照 RPN 顺序摆好,比如 1 2 - 3 +,要按顺序算这些数。作为人类我们可以简单的算出来结果是 2,那机器呢?
它先把 1 放入,2 放入,栈为:
2: 1
1: 2
然后看到减号,减号要两个参数,它就从栈里面掏两个球出来,减完得到 - 1,再压回去,栈为:
1: -1
然后再看到 3,放进去,自动升栈。这时的栈就是:
2: -1
1: 3
然后它看到加号,拿掉两个参数,用于计算加法,再塞回去:
1: 2
那么我们计算的结果就在 1 栈(栈顶)里面了。
这就完全不用建立树形数据结构,也不用存储复杂的表。大家可能会觉得这过程复杂,但实现它所需的电路和软件就非常简单,并且还有可扩展性:比如计算 sin π/6,我们也只需要 π 6 / SIN 即可。因此,这缩减了设计和制造的成本,同时带来了源自 Lisp 的强大能力。
回到之前的话题,如果我们在 Lisp 中,从 (+ 1 1) 里去掉一个 1 ,会发生什么呢?我们得到了 (+ 1) 。它显然缺少一个参数来满足加号的需求,因此似乎还“想要”一个参数才能完整。那么,我们直觉上就认为,如果 Lisp 的加号是个需要两个参数的函数,那么 (+ 1) 就是一个需要一个参数的函数。那么,(+ 1 1) 也就可以写作 ((+ 1) 1)。
进一步地,看着这个 ((+ 1) 1),我们可以认为加号本身并不是一个返回两参数和的二元函数,而是个返回一个函数的一元函数。其返回的又是一个一元函数,然后返回的是一个值,即和。如果继续推广下去,我们可以发现,任何一个函数,都可以看成是返回一个值的一元函数,或者返回一元函数的一元函数。这种嵌套返回叠加 n 次,就模仿出了一个 n 元函数。
返回函数的函数,我们称为高阶函数。将 n 元函数转换成一连串一元高阶函数的过程,我们称之为柯里化。当然这是非形式的定义——这只是一个漫谈级别的科普文章,笔者也恳请大家不要奢求这里作出形式化的定义。有兴趣的话,可以去查阅维基百科:柯里化。在百科中,大家也能看到高阶函数和柯里化的种种效果和应用。
那么 RPL 呢?如果我们摆出 « 1 + »,那么我们会发现它会把一个 1 推到栈上,然后运行一次加法。而加号需要 2 个参数——它需要“运行它之前,1 栈上就再有一个参数”。再回想一下我们是如何为 sin 函数传参的呢?π 6 / SIN 中,我们先算出了 π 6 /,结果位于 1 栈;然后我们运行了 SIN,结果也在 1 栈。我们会发现,自己为函数 SIN 提供参数的方式就是把参数推到 1 栈上。那么 « 1 + » 这个整体,和 SIN 并无区别——它正是一个一元函数!与上文的 Lisp 特性类比,我们也许可以说,惠普通过栈实现 RPN 的方式,让 RPL 天然地具备了类似高阶函数(虽然不是柯里化,但效果似乎已经很接近)的能力。这一无须专门花心思实现的、“免费”的函数式特性,让我们惊异于惠普工程师的天才。
再谈变量和临时变量。我们知道,RPL 中一切皆为对象。栈上的数字、字符串、列表什么的当然没有名字;RPL 中的程序或者说函数是 « 1 + » 这种形式,亦为没有名字即可运行的对象(实际上,它只有在命令行或者在栈上才能被编辑)。我们将一个数字对象赋值给一个名称,所用的语法类似 1 'A' STO 这种。其中的变量名 'A',反编译到 SysRPL 会看到它背后是 ID A,也是一个对象——标识符(Identifier,ID)对象。而 STO 命令的作用,是将赋值的值和标识符一起从栈上移除,但值的本体在内存的位置不变,并与作为变量名标识符相“绑定”,而以后再出现这个标识符时,多数情况都会直接使用对应绑定的值进行替换。这种 “绑定” 就是 RPL 中的 “变量定义” 的实质,是否与其他计算器有很大区别呢?对于笔者,计算器上出现这样的东西,又带来一种十分新颖的感觉。
惠普给我们的临时变量语法,让我们可以把函数写成类似 « → X Y ... « M » » 的形式(M 是一个可能包含 X、Y…… 等变量的 RPN 表达式)。这样的函数会从栈上“捕获”(实际上是“绑定”)数量等于临时变量个数的对象,并按照顺序赋值给这些临时变量。查阅《Programming in System RPL》、《RPLMAN》,笔者这个机制也可以说是对于 Lisp 等语言的 “Lambda binding” 机制的模仿。临时变量是通过建立一个个相互嵌套的临时环境管理的。所有临时环境处在一个栈里面。函数运行时,临时环境们会依执行顺序被压栈,解释器可在栈中所有层级查阅临时变量。某个函数体结束的时候,解释器通过从栈中弹出来抛弃对应的临时环境,其中包含的所有临时变量自此失效。这很类似于其他语言中的 Lambda 表达式,RPL 函数打包了它自己的 “临时环境”,因此实际上是一个闭包。
这个函数式特性更加引起了我们的兴趣。在维基百科搜索可知,λ 演算中,函数有这样的表示法:λx.M。其中 λ 到 . 之间的部分叫作 binding variables,也就是绑定变量,这里用 x 表示。M 是包含它们的表达式(当然也可以不全包含,也能包含不在 x 中的变量)。这些绑定变量,或者说形参(arguments),只在 M 里面有定义,出了 M 就无定义了。这样的函数也没有名字(不像 f(x)、g(x) 那样的“命名函数”,而是“匿名函数”)。这除了边界符号和表达式表示法与 RPL 不同,其余的几乎一模一样。Lambda 匿名函数与闭包,又是一个由惠普使用 RPN 实现的奇迹。
正因其神奇,笔者十分好奇它的内部实现。正巧一位群友志同道合、鼎力相助,与笔者齐心协力,运用他深厚的 Saturn 汇编语言功底,对 “→” 的功能做了许多反编译、资料查找和调试,初步确定了它的一些运行逻辑。“→”,反编译为 xRPN->,栈图示为 (ob1 ... obn → ) 。它首先会检查用户有没有按 ON/CANCEL 取消运行(CK0ATTNABORT),然后根据它后面的临时变量名列表建立足够数量的临时变量名(LAM)对象,比如 LAM X、LAM Y 等等,并取得一共建立的 LAM 对象的个数(对于 48G 系列是 PTR 23502,对于 49/50 系列是 PTR 3889D,均没有 Entries 条目,反编译出来还全是 Saturn 汇编),然后乘以 2,通过 DEPTH 求栈的总深度并比较,从而判断原来栈上的参数个数是否足够。如果不足够,临时环境就建立失败,删除所有 LAM,并使用 SETSTACKERR 抛出 Too Few Arguments 错误;如果足够,临时环境就成功建立,直到运行到 x>>ABND(其实就是打包的 » 和 ABND,其中 ABND 是 abandon 的缩写,表示抛弃/出栈当前临时环境),闭包结束。
但这里就有个很奇怪的事情。我们知道,在 RPL 中,命令只能操作栈上的对象。但变量名 X Y 什么的都该在 → 后面。这意味着 xRPN-> 运行的时候,栈上还没有那些 LAM 对象。那么 xRPN-> 到底是怎么获得局部变量名的呢?答案也许潜藏在地址 23502 和 3889D 中,但我的朋友全力调试,发现它们一直在操作 CPU 内部的 RSTK,人难以理解其行为,并猜测这是 RPL 解释器的一部分。最终我们还是未能彻底解明 xRPN-> 的全部工作原理,让我们将其留作一个未解之谜吧。
当然,如果使用纯的 SysRPL 进行编程,就无须在意 xRPN-> 的奇怪行为。用户可以用 { name } BIND 命令开始一个 Lambda binding 闭包(当然也可以不够“闭”),用 ABND 结束。另外,《Programming in System RPL》还提供了更快的、使用“匿名” LAM 对象作为临时变量名,然后用顺序索引来使用它们的值的方式,作为 SysRPL 编程的强烈建议:
LAM 可以不指定特定名称,而全部指定为NULLLAM,由nGETLAM指令从堆栈中绑定, 并用nPUTLAM指令放回堆栈对应位置。SysRPL最大支持22个匿名局部变量。为了增强程序可读性,可以用DEFINE命令定义一些宏,来“命名”这些匿名的局部变量。
为了减少程序体积,我们需要注意到,NULLLAM这个标志是可以作为类似NOVAL的“符号”,在RPL栈里面DUP的。用NDUPN指令大量生产NULLLAM,然后用DOBIND指令装入列表并立即开始一个BIND块,可以节约很多储存空间。而像Jazz这样的编译器,甚至可以用 {{ name1 name2 }} 这样的语法糖来替代 name1 name2 DOBIND,进一步节约程序代码空间 (这大概是存储空间以K计算的年代独有的设计目的……)。
小小几个机制,背后有极大的、甚至经过研究仍旧未知的领域。RPL,是惠普从 30 年前穿越而来,带给今天的我们的宝藏。如果本文有错漏,请在评论区指正,感激不尽。 支持原创 学习!
页:
[1]