Rendering 和「赌玉」

2017/01/29

大年三十夜里,在 TurboSquid 上买了个价位还算合理的 F-4J model 作为自己的春节礼物。以我的购买经验,刚刚打开的时候不出所料小小的失望了一番。Polygon 的粒度和座舱透明在 OBJ 格式上处理的都不对。紧急对 Nuo Model Viewer 的代码做了些调整,结果发现还是个不错的模型。更好的是起落架收起/放下只要用简单的隐藏/显示 model parts 就能实现。TurboSquid 对我这样用「非主流」renderer 的人不保证最终渲染效果,每次买 model 都像「赌玉」一样,可刺激了。

f-4

以前很喜欢调侃从 CS 专业毕业的同事「浪费」了四年时间。我自己的 CS 知识是工作后自学的,大学专业是通信工程,我一直觉得是更好的投资,因为自认为没信心和动力自己用四年时间建立通信方面的专业知识,。

从十多年前开始打算学 rendering,多次半途而费之后,突然意识到 CS 也渐渐成为了个人无法自己完成的学业。当一个学科越来越复杂的时候,在学习曲线初期能去征服的问题就变得不那么「有趣」。拿 rendering 来说,三十年前也许画个茶壶就是很「有趣」的项目,那段时间现在一去不复返。去年开始我终于靠不断「赌玉」勉强给自己注入学习的动力。如果是在大学里完成一两学年的课程,也许我会安于用圆球和茶壶来集中学习 rendering 知识,而现在我要花费 60% 的时间处理从各种途径获得的真实物体模型。

能够和一门学科共同成长的人是幸运的,因为这门学科最有趣的部分始终在个人能力可以触及的范围内。当一门学科开始成熟,它的热门问题就变的越来越对初学者不友好。这时学校的作用就是用自己的激励制度和环境让初学者能安于一些「枯燥」的初级问题。我庆幸还能在毕业后自学 CS 知识,也终于意识到今后 CS 领域的学习会大大不同于我们这批人的经历。

关于语言消亡

2016/11/17

不是长篇立论,只是突然想到一个有趣的话题。

我们通常会惊讶于旧技术的生命力。《What Technology Wants》里 Kevin Kelly 详实的阐述了旧技术永不消亡的事实。在 IT 领域,所有出现过的编程语言似乎也都还在发挥作用,提供着相当数量的工作机会。但是有一种语言,似乎还很年轻,辉煌的日子也并不久远,却突然的以一种和其历史记录完全不相称的速度从各个领域都消失了。这就是 Pascal。

Pascal 出现的时间并不太早。个人计算机早期被 Macintosh 钦定。IBM PC 上一开始就有 Borland 护航,后有 Delphi 中兴。像 Photoshop 等经典应用的早期版本也完全是 Pascal 写成。但是就消失了。

不要谈「正确性」

2016/09/29

很多程序员自嘲「数学不好」。反过来看就是在憧憬基于数学理论写就的程序一定完全正确。其实并非如此。比如说,简单的 parser 可以严格基于有限态自动机和 LL(n) 理论,但写过的人都知道调试起来并不简单,即便充分测试也不能达到 100% 正确。

mbe

另一个例子是 3D renderer。无论是物理真实的 ray tracing 还是简单的 phong model,renderer 就是数学公式的代码实现。最近用 Metal by Example 的例子作为起点写程序。一开始还运行得不错。直到加上摄像位置的变换之后,near/far 平面会切掉在 view volume 里的模型。一开始也没在意,给 near/far 参数随便加上个余量凑合用。混了两个星期后偶然想到 Metal by Example 用的 projection 和 OpenGL 的一模一样,而 Metal 的 depth 范围不同于 OpenGL [1] 。在 Twitter 上向作者确认之后果然是这个问题。Metal by Example 经过了 15 个月的开发,出版也有一年左右。但是这个基本问题一直没有被发现。

软件开发的现状略带讽刺 —— 严格依据数学理论的程序反而缺乏有效的工具来验证其正确性。但这里其实也没什么讽刺性,因为数学本身的理论正确性也并非自动获得。一个新证明的正确性必需要经过其他数学家的人工验证,有时需要历时几年,甚至在最后才发现证明中的错误。数学的正确性是依靠数学界的「社交活动」达成的 [2] 。正如测试是软件开发的「社交活动」。

那么,数学理论是不是对软件质量没有帮助呢?也对也不对。数学理论并不能直接保证正确性,无法帮助发现错误。但是一旦发现了错误,数学理论可以帮助更快的修复软件。当我们在一个复杂的 ad hoc if-else 分支群里发现错误行为之后,很难立刻理清修复方案。而面对上文的 projection 错误,立刻就可以修正错误的参数。我把这种特性称为「可修复性」。可修复性和「正确性」不是一个层面的问题。其实软件开发的很多 best pratice 并非追求「正确性」,而是去提高「可修复性」。例如提高代码的可读性,以及我以前讨论的 MVC 模式的局限和突破,都是追求「可修复性」。经常听到很多团队讨论开发流程和实践的时候以「正确性」作为争论的立脚点,讨论怎么做才能节约测试成本,这样就走偏了。

「可修复性」不是「正确性」。也没有办法直接降低测试成本。但是「可修复性」是软件的一个隐形 spec。当错误被发现的时候,「可修复性」将一切争论局限在实现层面,避免了在设计层面的争论,更不会出现 feature vs. bug 的可笑争吵。「可修复性」还避免了修改中无意引入新的 bug。就目前的软件开发现状,我认为没有任何编程行为能有效提高「正确性」,那些能产生正确代码的程序员也无非是把代码丢给测试团队之前,自己先系统测试一番。一切编程本身的实践,都应该围绕「可修复性」来讨论。一切针对「正确性」的讨论,都应该交给测试领域。

脚注:

  1. Metal 的 canonical view volume 是 2x2x1,它的 z-buffer 范围不超过 [0, 1] 。OpenGL 为 2x2x2 ,z-buffer 范围为 [-1, 1] 。
  2. 当然,类似 Coq 这样的形式验证工具说明有可能改变这样的现状,但是距离实现仍然有一定距离。

Program by Debug

2016/09/10

老手都知道「debugging」是书本很少涉及但是对生产力影响最大的编程手段。但在不断提高自身修养的过程中也听过大师告诫不要「program by debug」,令人时常前思后想不敢动手 coding 。反复调试时充满负罪感。

如果因为一句「不要 program by debug」就坚持敲代码之前要深思熟虑,那就是和自己过不去了。因为人并不擅长在不同层次进行通盘思考。花费很多精力思考的大计划在细节上必然充满逻辑漏洞。所以编程就是先让粗糙的代码在简单输入下勉强运行起来,然后这里紧一下「螺栓」,那里调一下「杠杆」,最终获得一个稳定的系统。所以恰恰就是要「program by debug」。如果重新思考这个问题,重点不在于是否让 debugger 成为 coding 的线索,而在于如何使用从 debugger 得到的信息,不应该用「头疼医头」的方式去修补问题,而是要从 debugger 暴漏出的现象扩展出一般化的问题,寻找一般化的解决方案。

大师的观点一定程度上和工具的发展程度有关。Debugging 是高度依赖工具的手段。工具永远有覆盖不到的地方,所以脱离 debuging 的慎重思考也总有不可替代的地位。不过工具发展之后,要让我们的头脑从工具成熟的领域中解放出来。

五六年前尝试学过 rendering ,还写过几篇《 OpenGL 随想 》,现在看来十分惭愧。除了熟悉基本概念之外,实际的练习都是浅尝辄止。那个时候我的印象是 GPU pipeline 的编程并不像大多数工程可以大量依靠 debugging 。今年公司给了去 SIGGRAPH 2016 的福利。为了不太辜负这次见闻,回来之后打算再进阶一次自己的 rendering 知识。总结一下前几次进阶夭折的原因,有一点在于只用下面这样的简单过程生成的模型。因为无聊而失去动力。所以我想这次要先花些力气让代码直接用上从网上下载的大量模型。

bqhwpgrcyaeokfr-png-large

具体的目标定为 Wavefront OBJ 格式的 viewer。本着 program by debug 的精神,从网上找了几个 OBJ loader 库,然后给它们分别写些简单的测试代码来 debug ,看它们产生的结果是否好理解。然后选中 tinyobjloader 加到 Metal by Example 的例子里直接开始编码。每次 debugging 暴漏的问题的时候,不能只是把当前出错的这一个 OBJ 文件的情况糊弄过去,而是要考虑类似情况如何反映在所有的 OBJ 文件里。用点合理猜测,偶尔去查相关的 OBJ 标准,程序就一步一步的稳定起来。最后把例子一步步的 refactor 成现在的 Nuo Model Viewer

Parsing model 的构建诠释了 program by debug 的作用,但是这毕竟不算 rendering 本身。和几年前相比,GPU debugging 的工具也丰富多了。例如 rendering order 就不用费力猜了。

2016-0908-modelviewer

Lua 的语言实现难度

2016/06/10

谈到各种语言的实现,Lua 的 single-pass compiler 常被拿来说一番,似乎给人的印象是为性能牺牲了简洁。这个 compiler 的代码我一年前读过,做了些笔记。在复杂度方面,其实大体感觉比采用 explict AST 加单独的 code generating pass 的 compiler 并没高多少。在某些情况下,OP code generator 里晚些运行的步骤要为之前步骤已经生成的代码打 patch。主要集中在表达式计算结果的寄存器分配,以及分支语句跳转的目标地址上。

不过,为了 compiler 的实现简洁,Lua 还是「偷偷」地保留了一棵「语法树」。我并不是说像 FuncState 这样在 compile-time 按需生长的暂态树,而是一棵最终保留到运行时的常青树。它的结点为 struct Proto,表示源代码级别的 lexical scope function 的嵌套关系,也就是通常所说的 closure 定义。

在接触 Lua 实现之前,我最好奇的就是如何设计一个支持 closure 的指令集。看了 Lua 的实现之后,突然觉得有种「受骗」的感觉,因为对 closure 的处理明显不是用一般意义上的「指令」实现的。当时我还发了一条 Twitee 说「Lua 指令集要是做成真的 CPU 应该算是 extremely complex instruction set」。回头再想想,其实就是一个粗节点的 AST。所以 Lua VM 除了运行虚拟指令集,还混合了直接解释 AST 的方式。这点有效的控制了 compiler 的复杂度。

Lua 坚持采用手写的 single-pass compiler 的主要目的是满足作为「数据描述」语言的需求。这里有一个有趣的「迂回」。大多数「数据描述」语言其实并不需要 single-pass compiler 也能满足性能需求,因为它们的应用场景到 AST 为止,根本谈不上后面的 code generation —— AST 就是它们描述的数据,即 declarative 形式。但这也限制了这些语言的描述能力。Lua 通过 compile 舍弃了大多数 AST 信息,再运行 OP code 恢复和 AST 类似的数据结构。看似做了些「无用功」,但是最后的数据并不一定要和源代码的语法完全一致,灵活性远胜 declarative 形式的数据描述语言。

Lua 的 single-pass compiler 算是用一套 imperative language 的方案兼顾了大多数 declarative data expression 的需求。追求性能的同时,仍然保留了粗粒度 AST 降低实现复杂度。是个比较完美的折衷。

合理的肮脏

2016/05/11

2016-05-10-AutoBodyShop

上班时因为没及时换 lane,所以选了另一条路,经过一个整形喷漆的修车场。场外停满等待修理的车,每辆身上都有些变形和漆面擦落。整个环境充斥着萧条的气氛。

当然,在商业环境讲求效率的标准下,我们被教导了要「学会用正确的方式看待『整洁』」。

It took me a couple of months … before I realized what they meant. In the bakery, clean meant no dough on the machines. Clean meant no fermenting dough in the trash. Clean meant no dough on the floors.

Clean did not mean the paint on the ovens was nice and white. Painting the ovens was something you did every decade, not every day. Clean did not mean no grease. In fact there were a lot of machines that needed to be greased or oiled regularly and a thin layer of clean oil was usually a sign of a machine that had just been cleaned.

类似的视角并不限于商业。例如,我们都希望住进有专门车库的房子。不仅仅为了存放车辆,更是希望一些爱好的副产品远离起居室。在明亮的起居室欣赏飞机模型的时候,车库里存放着一张不必每天收拾的工作台,上面散布喷漆的污渍。

如果这种「合理的肮脏」均分在每个人的生活中,不仅是应该的,甚至是完美的点缀。可是在从微观到宏观的层面上,最优效率的组织方式都是类似的 —— 这是上面 Joel 的 blog 里毫不费力的用面包房来为后文的代码组织做类比的合理性所在。在某个层面上,合理的肮脏所跨越的范围必然覆盖到某些人群绝大部分生活空间。像上面的修车场附近的居民区,对他们来说,打开窗户就能看到,走出房门就会接触到的修车场并不像一个享受爱好之后就可以离开的车库。我写这篇 blog 的原因正是联想到类似的情形在地区和国家的层次上,对于在「车库」里的人来说,生活并不是那么愉快。

Functional UI Programming

2016/01/13

最近挤出些时间来看 Haskell 和 Functional Reactive Programming。由于主要工作领域和兴趣都是 user interface,所以就一知半解地写写用 FP 实现 UI 的想法。关于这方面入门者的困惑很多,我觉得有两个主要原因,第一是 FP 社区本身对 UI 领域投资不多;第二是比较熟悉 FP 的人谈及 UI 的时候必然会提到,而且往往只提到 FRP。这第二点让人产生传统的 model-view-controller 不适合 FP 的错觉。

Model-View-Controller Recap

谈论 FRP 前先回顾一下在我本人经历中占主导地位的传统 MVC 模式 [1]。这种模式中的 V 是 stateless view。从 model 发送到 view 的唯一通知是「model 发生了(详情不知的)改变」。Stateless view 可以天然的被看作 FP 意义上的函数,参数是 model,输出是整个或部分 UI 的 bitmap(或者说是 render 系统的 render command 序列)。

这个模式下的 controller 和 model 也都几乎可以看作函数。如果采用 FP 模式,model 不能是保存状态的「对象」[2],而是变成一个 immutable 文档的列表。这样做并没有初看上去那么复杂:现代基于文档的 app 都要支持 undo/redo,所以如今的 model 已然无论如何要花力气实现文档列表(这个序列里的新文档通常是对旧文档进行 copy on write 得到,如果 FP compiler/runtime 实现得当应该可以达到同样效果)。

如上所述,我的最初感觉是传统 MVC 能够并不费力的和 FP 模式吻合,所以一再听到熟悉 FP 的人说 FRP 是 FP 在 UI 领域的主流甚至唯一解决方案让我有点惊讶,怀疑是否之前的想法过于简单化。

控件化 UI

上文谈到「传统 MVC」时所说的 view 其实和大多数人脑中的稍微有些不一样。很多 UI 是由现成的控件 (toolkit control) 组合而成。而传统 MVC 的 view 是指由 render 系统生成的一块 bitmap。举例来说,直接基于 Cocoa 和 MFC 的 NSView 和 CView 实现的 custom view 更符合 stateless view 的特性。如果你的 app 里没有 custom view,而是完全由 built-in control 组合而成,那么最外层的 container view 接受 model 之后的输出就不是 render command 序列,而是把整体的 model 分成不同部分来更新 inner view 的 model。在《MVC:用来打破的原则》的最后一节谈到了这种嵌套 MVC 结构。

控件化 UI 让很多程序员产生了一种「错觉」,就是 MVC 里的 view 的行为不是 render bitmap,而是把 model 的某部分同步到某个 property 上去。而 view 也变成了需要维护自身状态的对象。其实这只是看问题的角度不同。当你要自己实现足够复杂的 custom view,例如一个 editable canvas 时,仍然要回到基本的 stateless view 模式。经常写 custom view 的人处理大量 controls 的时候也把 controls 的更新看作 container view 的一种 render 行为而非数据的同步。

Functional Reactive

这时回来看 Functional Reactive Programming,它是更符合控件化 UI 的一种解决方案。FRP 所构建的 DAG 的末端和 control 相联的 event 或者 behavior 是和这个 control 自身的 model/proprty 的粒度直接对应。当整个 app 没有一个统一的 model 而仅仅用 controls 自身的 model 集合来维护所有状态的情况下,FRP 的 DAG 解决了这个 model 集合的同步问题,从而构建了一个 virtual global model。在传统 MVC 里经常提到 model 要负责 data integrity,DAG 正是实现这个任务的一个特定的形式化方法。

基于这个分析,可以总结关于 FRP 的三个结论。第一,FRP 解决了离散的 model 集合的同步问题,这只是 MVC 中 M 的部分。把 FRP 看作一个 UI 解决方案是忽略了 built-in control 所做的 render 工作。FRP 是一个 model 同步方案。

第二,可以通过在 app 中保持一个集中化的 model 来避免使用 FRP 的 DAG。集中化的 model 更符合传统的 MVC,从而也可以通过 stateless view 来采用 FP 模式。但这不代表构建集中化的 model  就一定是更好的做法。因为在控件化 UI 里强行采用 stateless view 模式意味着蛮力复制很多没有变化的数据。如果 FRP 的理论足够扎实,它的 DAG 似乎是更优雅的方法。而且即便真的采用了集中化的 model,仍然可以在内部采用类似 DAG 的方式来保证 data integrity。

第三,当 UI 中的某个 view 足够复杂而无法由 built-in control 来实现的时候,至少在这个部分必须回到传统的 MVC 模式。所以 FRP 和传统 MVC 实际是在 UI 实现里互相补充的两个部分。倾向于哪一个的决定往往更多地取决于系统性能等等非架构因素,而并非由系统的架构硬性决定,也不是和 programming paradigm 绑定的。

脚注:

  1. 关于这方面我写过几篇 blog()。
  2. 《MVC:用来打破的原则》里说过,model 其实有「反对象」的特性。

怎么做 Code Review

2015/10/04

Code review 是人人都明白要做的东西,不过做得得心应手的不多。好的实践要解决两个问题:第一是发现 code 的问题;第二是把问题正确传达给所有参与者 (reviewers) 。

通常说 code review 工具就会提到 GitHub 的 pull request,或者 Code Collaborator。这些工具解决的是第二个问题。比如说怎么知道其他 reviewers 是否已经提出相同的问题。或者 author 对某个 reviewer 提出的问题是否有了回应,refine 的对不对。诸如此类问题,不能说不重要。但只是 code review 的两方面之一。交流问题的前提是发现问题。眼光局限于上述这些工具,就是以为大家在一起聊着聊着问题就被发现了。问题绝不是靠盯着 pull request 或者 Code Collaborator 的 change list 页面看看就能自然而然地被发现。哪怕仅仅是两三行改动也需要放到整个 code base 中去检验。最好的 review 环境是既有清晰的 code change visualization,又能在整个 code base 里进行检索,还可以自由地运行修改前后的 code。PR 和 CL 提供了不错的 visualization,但缺少对后两点的支持。

所以 code review 的第一步是要把修改后的整个 code base,而不仅仅是修改本身的 visualization,共享给 reviewers。这种共享不但要让 reviewers 拿到 code change,还要能「玩起来」—— 能编译,能运行,能加入自己的修改来验证建议。

Git 这样的提供 cheap branch 的版本系统很容易做到这种共享。而 Perforce 的 branch 很 expensive,通常是几个人的 sub-team 共享同一个 branch。所以早先用 Perforce 的团队做 code review 往往就走马观花了。其实大概五年前推出的 shelve 功能就是专门为了 code review 设计的。Perforce 的 pending change list 相当于只有一个临时 commit 的没有历史纪录的 short live branch,同样能提供类似 rebase 的功能。Shelve 则提供了共享这个临时 branch 的能力。

共享问题解决之后,回头看 visualization 的问题。Perforce 在 branching 方面的弱点反而让 visualization 略显容易,因为缺乏历史纪录,所以大多数 IDE 都能自动把 shelved change list 唯一的选择 —— uncommited vs. committed 进行不错的可视化。而面对 Git 就比较头疼,因为 code author 在自己的 branch 里可以想怎么搞就怎么搞,开三五个 sub-branch 然后 merge,或者从别人的 branch merge 乃至 cherry pick 都算是 common practice。其实解决的方法也很简单,在本地做一个 uncommitted merge,然后 review 这个 merge。

最后多说一句闲话,Git 实践多了会发现 uncommitted merge 的用处不限于 code review。比如说,当 repo 里的 branch 很多的时候,在 SourceTree GUI 里看 logs 的时候会选择某一个 branch 而不是 all branches。如果这时还想对比另一个 branch 的情况就可以做一个 uncommitted merge。不过我还没想到把这个 trick 推广到两个以上 branches 的方法。

搬家

2015/09/23

来美国将近两年,一直住在同一个小区的 apartment 里。上个月终于买下了新房。之后就陆陆续续打包搬运。这是搬家前一周的景象。Apartment 里一直堆满了打包的东西。儿子在新家里看书。

一直到上周六,是在 apartment 的最后一晚。

感谢这间 apartment 承载了我在美国的最初时光。

不用 Lisp 学 Lisp

2015/03/22

发布上一篇 blog 一周后我离开北京来到了美国,当时没想到这一篇会隔了这么久才动笔。写 blog 一直是在 wordpress.com 上 draft 和发布,然后再复制到 techsingular.net 上。所以之前还在想搬来后写作条件也许会大大改善(因为 wordpress.com 众所周知的原因)。没想到一拖就是一年多。期间还两次忘记给 techsingular.net 的域名和主机续费。多亏杜超和 @mozetianxing 提醒。感谢大家一直关注。

大概两个月前开始有再写 blog 的想法。但中断这么久后一时不知道说点儿什么。或者说想写的还不少,不知从什么开始。这次终于动笔源自在 Twitter 上和张克炎 (@keyanzhang) 关于《 Lisp in Small Pieces 》的讨论。

另外说起「写作条件大大改善」,wordpress.com 的 editor 确实改进了不少。techsingular.net 的主机商也给免费升级了空间。Wordpress 升级到了 4.1.1。一年多真是很长的时间。

学编程语言理论的一个有意思的途径是读关于 Lisp 的书。但我并不想花精力摆弄任何一个 Lisp 的实现,更不要说先得从许多选择中挑一个。所以几年前就尝试在完全不安装 Lisp 环境的前提下读《 Structure and Interpretation of Programming Language 》。更确切地说,我的计划是在整个过程中用 Lua 逐步构建环境。现在看来这个计划很成功。SICP 中的 Scheme 代码都能简单对应为 Lua 代码。最后的小成果是用 Lua 写的 Scheme evaluator

当时的版本非常简单,不仅没有 macro,也没有著名的 first-class continuation (call/cc) 。我当然希望加上更复杂的 feature,离开北京前就开始看《 Lisp in Small Pieces 》(LiSP) 和《 The Little Scheme 》。不过这两个计划很快被搁置了。部分原因是大搬迁的影响。还有部分原因是兴趣转到了理解 Lua VM 本身的实现。因为 Lua 和 Scheme 如此类似,它的 VM 实现又非常简洁,特别是和 C 的交互部分做得比任何语言都好,所以对 Lisp 的兴趣很大程度上转化成对 Lua VM 的研究。几个月前恢复阅读 LiSP 后才意识到还有很大一部分原因是这本书的行文常常误导读者,没有不断改错的毅力实在读不下去。

在网上搜索对《 Lisp in Small Pieces 》的评价得到的几乎都是溢美之词。一本面向小众读者的专业书在 Amazon 上得到 12 个五星评价,不能不说这本书相当出色,覆盖了极广的知识、例子代码很详尽。但是它的问题也很严重,而网上一面倒的正面评价让我花了很长时间才确信阅读时遇到的很多困难并非自己的原因。简单的说,这本书犯了很多论文和教科书经常犯的错误 —— 证明推导时经常来个「显而易见」。它倒是很少在字面上出现「显而易见」,而是犯更隐蔽的等价错误:举例和结论之间经常有很多缺环  (gap)。

比如第 3.6.2 节 Tail Recursion 后半部分讲如何在 begin sequence 结束时省掉 continuation。这本来没什么问题。问题在于此节前半部分大幅以 fact (阶乘)函数举例。这个例子在多个层次上都有问题。首先是前文里有两个 fact 函数的例子:普通递归风格和 CPS 风格的。这无疑增加了读者误解的几率。

第二层问题是,普通风格的 fact 函数根本不是 tail-recursion。CPS 风格的虽然是 tail-recursion(而且也确实是文中用到的例子),但与其相关的 continuation 有两种:一是 CPS 风格代码每个函数的参数 k,二是 CPS 风格的代码和任何代码一样也有自己的 continuation。一般提到 CPS 时通常讨论第一种 continuation。第二种 continuation 几乎不会提及 —— 因为 CPS 风格代码都是 tail-call,可以说本身根本没有 continuation。《 Lisp in Small Pieces 》却反其道行之。用 CPS 风格代码举例,讨论的却不是参数 k,而是 (fact n k) 函数本身代码的 continuation。读到这里的时候,我先是花了好久都搞不懂要说什么。搞懂了之后又不明白作者为什么非要举这个例子。尽管这节后半部分的结论没问题,evaluator 代码的 tail-call 优化很简洁,但用来开篇的例子糟糕到不行。

接下来是一个同样糟糕的例子,引起了和 @keyanzhang 的讨论。在第 3.7 节 Partial Continuation 里有一个例子。

第一眼看上去,我很怀疑这段代码是否能按照文中所示返回 3 和 4。但我没有标准的 Lisp 环境,只有自己实现的还没有 REPL 的 evaluator。运行上面的代码的结果是死循环。所以我把问题发到了 Twitter 上。@keyanzhang 回复说他的运行结果如下,看起来和 LiSP 类似:

这就比较尴尬了…… 当初决定用 Lua 学 Lisp 时最大的顾虑是出了问题只能硬想,没有真正的 Lisp 环境用来参考。现在果然遇到了。不过话说回来,如果只用标准的 Scheme 环境,也许这个问题实验一下就过去了。现在有了真正的 Scheme 运行结果和我的 evaluator 的差异,问题可能出在两个地方:我的 evaluator 实现不对,或者又是一个《 Lisp in Small Pieces 》的行文问题。有过 fact 函数的例子,我比较怀疑是后者。第一,在第 3.1.5 节曾经有用 call/cc 模拟 goto 的例子,用那个例子来对比,这里的 (foo 3) 明显像 infinite loop。第二,和书上简单的代码片段不同,@keyanzhang 的例子是在 REPL 中。

经过痛苦的思考,终于发现正是 REPL 的原因。REPL 在每次收到 Enter 之后都用新的 bottom continuation 调用一次 evaluator,而不是调用一次 evaluator 运行所有代码。所以 REPL 中 (foo 3) 不是前面代码的 continuation,也就打破了 infinite loop。本来一直懒得给自己的 evluator 写一个 REPL,想通了这次困扰好几天的问题,立刻就写了一个。这回的结果终于和书上一致了。其实,无论是 @keyanzhang 的例子还是我最后的 REPL 结果都和原文并不一致,也不可能一致。

通过这两三年断断续续、磕磕绊绊的经历,说明熟悉编程的人完全可以只用 Lua 来学 Lisp。为什么要这么大费周章呢?下载一个 Racket、使用 Emacs 或者其它 Lisp 环境很难吗?因为我更喜欢 no-drill 的方式 —— 尽量用接近工作的环境来学习,不喜欢摆弄最终在产品中用不到的玩具。我们很少有人能在工作中用到 Lisp,大多数人学 Lisp 是希望借鉴思想。另一方面,Lua 已经在借鉴 Scheme 方面实践的很好,由于其 embedable/extendable 的特点又很容易在各种真正的生产环境中采用。所以希望研究 Lisp 所体现的编程思想,也可以尝试不安装任何 Lisp 环境。