当我写到这里的时候,我自己都吃了一惊。 环境、存储这些比较让人耳熟的还没讲到,continuation先出来了。 维基百科里对continuation的翻译是“延续性”。 这翻译看着总有些违和感而且那个条目也令人不忍直视。 总之continuation似乎没有好的中文翻译,仿佛中国的计算机科学里没有continuation这个概念似的。

Continuation这个概念相当于过程式语言里的函数调用栈。 它是用于保存“现在没空处理,待会再处理的事”的数据结构。 这样说有点抽象,举个例子,函数应用那条表达式的求值过程(call-by-value)是这样: \begin{equation*}\begin{array}{lcl}   eval((M \; N)) &=& eval(L[X \leftarrow eval(N)]) \\                  && \text{其中} eval(M) = \lambda X.L \end{array}\end{equation*}这个过程依次做这三件事:

  1. 计算$eval(M)$,结果是$\lambda X.L$,
  2. 计算$eval(N)$,
  3. 做替换$L[X \leftarrow eval(N)]$。

解释器一次只能计算一个表达式,所以当它计算$eval(M)$前,要先把第二步和第三步要做的事备忘到continuation。

假设$M$还是个函数调用$M=(M_1 \; M_2)$。 那么,计算$eval(M)$又要分三步: 先计算$eval(M_1)=\lambda X.L_1$,第二步计算$eval(M_2)$,第三步做替换$L_1[X \leftarrow eval(M_2)]$。 解释器只能先计算$eval(M_1)=\lambda X.L_1$,将第二步计算$eval(M_2)$,第三步做替换$L_1[X \leftarrow eval(M_2)]$备忘到continuation。 于是,continuation备忘的事情按待处理的顺序依次是:

  1. 计算$eval(M_2)$,
  2. 做替换$L_1[X \leftarrow eval(M_2)]$。
  3. 计算$eval(N)$,
  4. 做替换$L[X \leftarrow eval(N)]$。

可以看到,continuation是一个FILO(先进后出)的数据结构,也就是栈。

在之前的解释器里并没有提到continuation,但是解释器仍然能正常工作, 这是因为解释这个解释器的解释器——Racket解释器——帮我们管理了continuation。 这一节的目标是自己管理continuation。

从一个简单的递归函数做起

先说一个概念:尾调用。 假设一个函数体中有一句函数调用表达式$(f \; n)$,这个函数调用被称为尾调用,如果这条表达式是函数体里最后求值的表达式。 如$\lambda n.(f \; n)$,和$\lambda n.({if} \; ({iszero} \; n) \; 0 \; (f \; n))$中的$(f \; n)$都是尾调用。 而$\lambda n.(+ \; 1 \; (f \; n))$中的$(f \; n)$不是尾调用,因为计算完$(f \; n)$后还要再算个加法。 一个简单的判断是否尾调用的方法是看这条调用表达式是不是不在一个参数位置(let表达式和if表达式先做宏展开)。

学过C或者C++都知道,调用函数的时候是要保存信息到函数调用栈的(我上学时学的这俩,不知道其他语言的课上会不会讲函数调用栈)。 尾调用的特点是:尾调用理论上不需要保存信息到函数调用栈——实际上也不需要,但是有些语言就不支持,比如说Python。 换句话说,尾调用不会导致continuation增长。 这个很好理解,因为尾调用是最后求值的表达式了,不需要备忘后面要做的事(后面没有事了),所以也就不会导致continuation增长。 后面会从continuation的角度说明为什么尾调用不会令continuation增长。

题外话: 刚才提到“理论上”和“实际上”。 经常能听到这种句式:理论上怎么怎么,可惜,实际上是吧啦吧啦。 其中“吧啦吧啦”是一句对“怎么怎么”的否定。 我觉得这是反科学以及读书无用论的潜意识在作祟,另外有时候是用来逃避责任的借口。 理论上怎么样,实际上就应该怎么样。 如果有套理论的结论和现实的结果不同,那么这就不能称为理论,最多算个猜想,而且是个错误的猜想。

如果一个尾调用是一个递归调用,那么就称为尾递归。 尾递归又叫迭代。 我们现在要做的事其实就是把一个递归程序(之前写的解释器)中的非尾递归全改成尾递归, 也就是递归转迭代

为了熟悉递归转迭代的套路,先拿一个简单的递归函数练练手。 这个函数就是之前用到的double函数: \begin{equation*}\begin{array}{lcl}   double(0) &=& 0 \\   double(n) &=& 2 + double(n-1), \text{其中} n \neq 0 \end{array}\end{equation*} 计算double函数的过程有一个状态量:当前计算的表达式$double(n)$或者一个数字$n$(计算结果)。 现在加入另一个状态量continuation,记为$\kappa$(因为continuation第一个字母c发音k,所以用了个长的像k的希腊字母……)。 计算double函数的过程只有第二行是递归过程,备忘的事是加2。 $\kappa$定义为: \begin{equation*}\begin{array}{lcl}   \kappa &=& {mt} \\          &|& \left<\kappa, +2\right> \end{array}\end{equation*} mt表示空的continuation,也就是说没有后续的事情了。

加入continuation后的求值过程如下: \begin{equation*}\begin{array}{lcl}   \left<double(0), \kappa\right>_v &\rightarrow_{v}& \left<0, \kappa\right>_c \\   \left<double(n), \kappa\right>_v &\rightarrow_{v}& \left<double(m), \left<\kappa, +2\right>\right>_v \\                                         && \text{其中} n \neq 0, m = n - 1 \\   \left<n, \left<\kappa, +2\right>\right>_c &\rightarrow_{c}& \left<m, \kappa\right>_c \\                                      && \text{其中} m = n + 2 \\   \left<n, {mt}\right>_c &\rightarrow_{c}& \text{输出} n \end{array}\end{equation*} 下面解释一下。 计算开始时的状态表示为$\left<double(n), {mt}\right>_v$。 下标v表示第一个状态量是一个表达式,要对这个表达式求值。 用箭头$\rightarrow$表示一步,带下标v的箭头$\rightarrow_v$表示这一步对表达式求值, 带下标c的箭头$\rightarrow_c$表示这一步从continuation中取出一件备忘的事执行。 对于$n \neq 0$,$\left<double(n), \kappa\right>_v$的下一步要做的是,计算$double(n-1)$,同时将加2备忘到$\kappa$, 也就是$\left<double(m), \left<\kappa, +2\right>\right>_v$,其中$m=n-1$。 当$n = 0$时,$double(0)$求得值$0$,所以$\left<double(0), \kappa\right>_v \rightarrow_{v} \left<0, \kappa\right>_c$。 用下标c表示第一个状态是一个数字,下一步该从continuation取出备忘的事来办了。 最后是$\left<n, \kappa\right>_c$的情况,如果$\kappa$不是空:$\kappa=\left<\kappa',+2\right>$,就加2到$n$上; 如果$\kappa$是空:$\kappa={mt}$,说明continuation没事了,而这时表达式也求完了,于是返回最终结果$n$。

然后是写代码。 针对$\rightarrow_v$和$\rightarrow_c$需要两个函数。 函数value-of/k是$\rightarrow_v$(Lisp命名规范里一般用斜杠表示with的意思)。 函数apply-cont是$\rightarrow_c$。

上面代码中用Lisp里的list数据结构来保存continuation。 我们可以不用list来保存continuation。 Continuation备忘的是“待做的事”。 这个“待做的事”可以理解为一个过程,也就是一个函数。 所以,可以用函数来保存continuation!

空mt是一个直接返回参数的函数(lambda (v) v)。 $\left<\kappa, +2\right>$先加2,然后应用$\kappa$:(lambda (v) (apply-cont cont v)),其中cont是$\kappa$。 完整代码:

用函数来保存continuation的写法看起来蛮像回调函数(callback)。 Lisp的一个噱头是程序与数据统一对待,这也算是一个体现吧。 用函数还能实现对象(面向对象编程的对象),这是题外话了。 用函数保存数据一个好处是熟悉这种方法后写起来很方便; 坏处是扩展访问方式比较麻烦,并且调试的时候不能打印详细信息。

顺便再说一下,这种带着一个continuation当参数传来传去的代码风格叫continuation passing style,也就是传说中的CPS。 将一段不带continuation的代码转换成continuation passing style的过程叫CPS变换。 (更准确的说,CPS变换是指将代码中的非尾调用转换成尾调用。)

简单易懂的程序语言入门小册子(6):基于文本替换的解释器,引入continuation的更多相关文章

  1. 简单易懂的程序语言入门小册子(1):基于文本替换的解释器,lambda演算

    最近比较闲,打算整理一下之前学习的关于程序语言的知识.主要的内容其实就是一边设计程序语言一边写解释器实现它.这些知识基本上来自Programming Languages and Lambda Calc ...

  2. 简单易懂的程序语言入门小册子(1.5):基于文本替换的解释器,递归定义与lambda演算的一些额外说明

    这一篇接在第一篇lambda演算的后面.讲讲一些数学知识. 经常有些看似很容易理解的东西,一旦要描述得准确无误,就会变得极为麻烦. 软件工程里也有类似情况:20%的代码实现了核心功能,剩下80%的代码 ...

  3. 简单易懂的程序语言入门小册子(5):基于文本替换的解释器,递归,不动点,fix表达式,letrec表达式

    这个系列有个显著的特点,那就是标题越来越长.忽然发现今天是读书节,读书节多读书. ==下面是没有意义的一段话============================================== ...

  4. 简单易懂的程序语言入门小册子(3):基于文本替换的解释器,let表达式,布尔类型,if表达式

    let表达式 let表达式用来声明一个变量. 比如我们正在写一个模拟掷骰子游戏的程序. 一个骰子有6个面. 所以这个程序多次用到了6这个数字. 有一天,我们忽然改变主意,要玩12个面的骰子. 于是我们 ...

  5. 简单易懂的程序语言入门小册子(7):基于文本替换的解释器,加入continuation,重构解释器

    或许在加入continuation之前要先讲讲费这么大劲做这个有什么意义. 毕竟用不用continuation的计算结果都是一样的. 不过,这是一个兴趣使然的系列,学习这些知识应该完全出于好奇与好玩的 ...

  6. 简单易懂的程序语言入门小册子(4):基于文本替换的解释器,递归,如何构造递归函数,Y组合子

    递归.哦,递归. 递归在计算机科学中的重要性不言而喻. 递归就像女人,即令人烦恼,又无法抛弃. 先上个例子,这个例子里的函数double输入一个非负整数$n$,输出$2n$. \[ {double} ...

  7. Go语言入门篇-gRPC基于golang & java简单实现

    一.什么是RPC 1.简介: RPC:Remote Procedure Call,远程过程调用.简单来说就是两个进程之间的数据交互. 正常服务端的接口服务是提供给用户端(在Web开发中就是浏览器)或者 ...

  8. C语言入门(2)——安装VS2013开发环境并编写第一个C语言程序

    在C语言入门系列中,我们使用Visual studio 2013 Professional作为开发工具.本篇详细介绍如何安装Visualstudio 2013 Professional并写出我们第一个 ...

  9. 《Java从入门到失业》第一章:计算机基础知识(三):程序语言简介

    1.3程序语言简介 我们经常会听到一些名词:低级语言.高级语言.编译型.解释型.面向过程.面向对象等.这些到底是啥意思呢?在正式进入Java世界前,笔者也尝试简单的聊一聊这块东西. 1.3.1低级语言 ...

随机推荐

  1. thinkphp自动填充分析

    thinkphp有一个自动填充字段的方法填充规则如下 array( array(完成字段1,完成规则,[完成条件,附加规则]), array(完成字段2,完成规则,[完成条件,附加规则]), .... ...

  2. 让Python代码更快运行的 5 种方法

    不论什么语言,我们都需要注意性能优化问题,提高执行效率.选择了脚本语言就要忍受其速度,这句话在某种程度上说明了Python作为脚本语言的不足之处,那就是执行效率和性能不够亮.尽管Python从未如C和 ...

  3. C#装箱和拆箱。

    装箱:值类型-->引用类型. 拆箱:引用类型-->值类型 装箱:把值类型拷贝一份到堆里.反之拆箱. 具有父子关系 是拆装箱的条件之一. 所以: class Program { static ...

  4. dotnetcore-officeaddin-toolbox : Office 365 Add-in开发人员的工具箱

    在上一篇文章(.NET Core开源行动:一键创建Excel Add-in) 中我给大家展示了一套为Office 365 Add-in开发人员准备的模板库,你可以通过 dotnet new excel ...

  5. [android] 保存文件到手机内存

    /*****************2016年5月4日 更新*******************************/ 知乎:Android 没有沙盒保护机制吗,WhatsApp 信息为何可被随 ...

  6. 为啥JQuery被淘汰了?

    摘要: 技术进步永不止步. 原文:jQuery的没落和技术发展的一般规律 作者:凌霄光 Fundebug经授权转载,版权归原作者所有. jQuery的成就 jQuery是一个伟大的库, 它解决了dom ...

  7. 消息队列redis

    1.消息队列流程 2.接收用户请求 <?php header("Content-type:text/html;charset=utf-8"); //首先加载redis组键 $ ...

  8. 魔幻般冒泡背景的CSS3按钮动画

    这是一款非常有特点的CSS3按钮,按钮的背景不是北京图片,也不是单纯的颜色,而是一组魔幻般的冒泡背景动画.当我们将鼠标滑过按钮时,按钮的冒泡背景动画就可以展示出来.可以说这款CSS3按钮的设计风格相当 ...

  9. Windows下git设置代理服务器

    SVN中,使用TortoiseSVN来进行版本控制时,设置代理非常简单,只需要在设置里面添加代理的信息即可.而 git 在GUI(v0.17.GITGUI)中却无法找到类似的设置,只能求助 git b ...

  10. mysql语句执行顺序图示