Joxa: 一种基于 Erlang VM 的现代 Lisp 编程语言

Joxa 是一种基于 Erlang VM 的现代 Lisp 编程语言,创始人是美国的 Eric Merritt[1]。通过在 Erlang VM 上引入一个精心设计的 Lisp 语法,它保留了 Lisp 和 Erlang 两者的众多优点:简洁而且语义清晰的 Lisp 语法,强大的 Macro,鼓励交互式开发,支持高并发,函数式风格等,并且与现有的 Erlang 平台保持良好兼容。它是一门功能全面的通用编程语言。

Joxa 的官方网站是 joxa.org,在这个官方网站上,有它的源代码 github 地址,以及在线文档。为什么这门编程语言取名为 Joxa 呢?关于 Joxa 这个名字的由来,Eric Merritt 曾经对我在邮件组的提问做过如下解释:

其实这个名字并无特别的意义。很多年前我想开始一个“基于 Java 的某个项目”,于是有了它的缩写 Joxa 这个名字。这个项目从来没有开始做过,但我把域名买下来了并一直持有着。到今天,4 个字母的域名已经很少能注册到了,所以我决定用它来做为这门语言的名字。[2]

除了名字开头有个字母”J”,Joxa 与 Java 并没有太大的关系了,Joxa 主要受到了 Erlang 及基于 JVM 的编程语言 Clojure 的影响。下面我们先介绍一下 Erlang 和 Clojure,再讨论 Joxa 受到了它们的哪些影响。

1. Erlang, Clojure 以及 Joxa

1.1 Erlang,高并发的函数式容错编程语言

Erlang 是一门通用的并发程序设计语言,它由瑞典爱立信的 Joe Armstrong 在上世纪 80 年代开发,并于 1998 年对外开源,Erlang 这个名字来源自丹麦数学家及统计学家 Agner Krarup Erlang。经过近 30 年的发展,Erlang 目前是支持高并发的编程语言翘楚之一,它在语言层面封装了 Actor 模型,实现了用户空间的轻量级进程,将消息传递作为 Actor 间通信的唯一方式,避免了由传统的线程和锁在并发方面的限制与缺点。Erlang 被设计为电信级系统的编程语言,强调分布式,容错,软实时和公平调度,在语法上它主要受到 Prolog 及 Lisp 的影响,保留了函数式,动态,交互式开发等特点。具体来讲,Erlang 这个名字可以分成 3 个方面的要素:

  1. 编程语言 Erlang 本身
  2. 总称为 OTP 的一系列程序设计原则及代码库
  3. 称为 Erlang VM 或 BEAM 的 Erlang 虚拟机

Erlang 设计于近 30 年前,因此从现在的角度来看,它在语法,编程环境(包括文档等)及工具链等方面有很多地方都有改进空间。由于 Erlang VM 是目前工业界支持高并发的最成熟的 VM 之一,大量技术专家及工程师们在上面投入了无数的工作,通过在 Erlang VM 上设计一门新语言来重用 Erlang VM 的优良特性,既能发挥 Erlang VM 的长处,又能改善 Erlang 语言本身在语法、工具链等方面的缺点,扬长而避短,是比较完美的方案。业界近几年涌现了众多基于 Erlang VM 的编程语言,下面介绍其中几种:

  1. LFE
    LFE 是 Lisp Flavored Erlang 的缩写,它是由 Robert Virding[3]于 2007 年开始开发的一门函数式的并发的通用编程语言,LFE 采用了 Lisp-2[4]风格的语法,通过将 LFE 代码编译为 Core Erlang 代码运行在 BEAM 上,保留了 Erlang VM 分布式,容错,软实时等优点,同时支持 Lisp Macro,使得 LFE 兼有强大的元编程能力,并实现了一个功能丰富的 REPL[5]
  2. Reia
    Reia 是一门基于 Erlang VM 上的类似 Ruby 的脚本编程语言,它由 Tony Arcieri[6]于 2010 年中开始开发,它在 Erlang VM 的分布式,并发,容错,热更新的基础之上,引入了 Ruby 的友好语法,灵活的代码块,反射及元编程功能力。遗憾的是,Reia 在 2011 年宣布不再更新。
  3. Elixir
    Elixir 是一门动态的函数式编程语言,它由 José Valim[7]于 2012 年开发。Elixir 同样采用了类 Ruby 的语法,通过支持强大的 Macro 功能,它在简洁的语言核心上,建立了一系列的标准库,包括 Unicode 字符串及相关操作,重写了单元测试框架,丰富的数据类型等,它吸收了 Clojure 的 Protocol,严格和惰性 API,还提供了现代的交互命令行,脚本相关的库函数及项目管理工具。通过将 Elixir 代码编译为 Erlang AST,Elixir 得以重用 Erlang VM 的高并发及高效率,克服了 Ruby 在并发方面的缺陷。由于得到 Jose 及其它 Ruby 界牛人的喜爱及宣传[8],Elixir 在近两年开始流行起来。

由于本文主要是讲 Joxa,上述这几种编程语言只是简单带过,就不详细展开了。

上面几种编程语言的设计方案虽然有很多不同之处,但从整体思路上几乎是相同的,那就是:通过将代码编译成为 Erlang VM 上的代码,良好兼容 Erlang VM,于是保留了上述 3 要素中的后两点,同时从头设计语言的语法,并在标准库,工具链等一些方面做补充完善。Joxa 的设计也是类似,它选择了 Lisp 做为 Erlang VM 上的新语言,不过它走了一条和 LFE 不同的道路。Eric Merritt 后来专门写了一个博客文章《Differences Between Joxa and LFE》来谈 Joxa 和 LFE 的不同之处,他说:

最主要和重要的区别在于这两门语言的目标。我认为 Robert 实现 LFE 的主要目标在于提供一个可变的语法可扩展的 Erlang 版本,如此一来人们就可以在需要时改变语言。同时我坚信 Robert 喜欢实现编程语言,他应该很享受实现 LFE 的过程。我当然也乐于实现 Joxa,然而,当坐下来实现 Joxa 的时候我怀有一些非常特定的目标:

  1. 我需要一个用于开发 DSL(Domain Specific Language,领域特定语言)的平台
  2. 我想要一个更具交互性和动态的开发环境。类似于 Slime 和 Swank 那种[9]
  3. 我希望充分利用所有已经存在的相当优秀的 Lisp 工具

上述每点都可以在 Erlang 里面解决。例如,我可以用 Leex 和 Yecc[10]实现 DSL,但我实现 DSL 的最好体验总是来自 Lisp:使用 Lisp 函数和 Macro 来打造这些 DSL。不过我使用 Erlang 有很长时间了,我不愿意放弃 Erlang VM 上面的优良功能来换成 Lisp 的种种优点。唯一的解决方法似乎只有使用一门基于 Erlang VM 之上的 Lisp 语言。

显而易见的首先选择是 LFE,于是我花了几周时间深入研究这门语言和它的内部实现。最后我得到这个结论:它并没有满足我的需求。剩下的唯一退路就是我自己重新创造一门语言(同时也有一点怀疑自己不太明智)。

从整体来看,LFE 更像一门披着 Lisp 外衣的 Erlang,相当于给原来 Erlang 语法添加了括号和 Macro,这与 Eric Merritt 理想中的 Erlang VM 上的 Lisp 语言相去甚远,于是他创造了 Joxa,而 Joxa 的语法及风格受到 Clojure 的影响更大。为什么 Clojure 能受到 Eric 的如此青睐呢?它到底有什么出众之处呢?下面我们来了解一下。

1.2 Clojure,JVM 上的函数式 Lisp 编程语言

Clojure 是一门动态的强类型编程语言,作者是 Rich Hickey。它寄居在 JVM 之上,设计成能够与 JVM/Java 良好互操作,既利用了 JVM 所提供的成熟高效的运行环境,也兼容众多流行的 Java 库与框架,同时它采用了 Lisp 语法和 Macro,非常便于表达 DSL,加上一套函数式的持久数据结构,并提供并发机制及惰性语义,使得简洁优雅语言成为函数式编程,并发编程的良好载体,同时重用了成熟流行的 JVM 平台,使得它便于在现有 Java 程序员中推广并流行,在这一点上区别于以往所有独立开发的函数式语言。此外它也吸收了 Java 中的面向对象思想和 CLOS[11],发展出 Protocol 及多重方法。另外,Clojure 自带一系列丰富的标准库,定义了一套项目管理规范,并提供了优秀的项目工具及 REPL,使得它在开发环境,交互式开发方面成为佼佼者。

Clojure 设计成为 Java 的一个库包,Clojure 代码会编译成 JVM byte code,正因为它以一种非侵入性的方式运行在 JVM 之上,所以在函数式的语言层面,会有一些其它函数式语言不可能出现的“瑕疵”,例如函数没有尾递归优化。兼容 JVM 平台的已有代码,在重用/连接已有项目方面既是一种优势,但有时混合函数式与命令式代码也会产生实际冲突。在并发方面,语言提供的多种并发原语,delay, future, promise,agent,STM 等虽然强大,但从语言整体来看比较复杂。Clojure 的很多地方可以体会到作者有意保持简单与功能(复杂)的平衡,在设计上做了务实折衷的克制。与此相反,另外一个基于 JVM 的语言 Scala 在设计上就显得博爱放任,看到各个好的特性就收入到语言当中,宛如中国古代的皇帝举国征选妃嫔。

相比各种“主流”编程语言,Clojure 至今仍是小众语言,虽然如此,它的推出仍然不可谓不成功,既培养了一个健康壮大的社区,也在市场上占有一定的流行度,产生了一批具有相当影响力的项目,如流式数据处理框架 Storm 等。Clojure 成功地向人们展示了这几个可能性:

  1. 在 JVM 平台实现一个函数式,并发的动态编程语言
  2. 通过融合持久数据结构,Protocol 等优异特性,复兴 Lisp
  3. 如何语言设计上在功能、简单与务实之间取得折衷平衡并树立起自身的特色

正因为有些如此之多的优点,Clojure 才对程序员们有着如此之大的吸引力。也难怪身为老 Lisp 爱好者的 Eric Merritt 在创造 Joxa 时会受到 Clojure 的较大的影响。下面我们来谈谈 Joxa 的设计。

1.3 Joxa, Erlang VM 上的新 Lisp 编程语言

对照上述多种语言的实现,Joxa 的设计主要有如下几个要点:

  1. 上层语言为 Lisp,主要目标为用于写 DSL(或者作为其它上层 Lisp 的元语言),语言的核心部分要简洁
  2. 底层将 Joxa 代码编译成 Erlang VM 代码,将 Joxa 代码映射到 Erlang 上的对应语法结构,比如 Joxa 里面的函数即为 Erlang 函数
  3. 语言核心之外提供 REPL,方便编译/执行脚本的命令行工具等

其中 Lisp 的语法可以参考简洁,优雅的 Clojure,由于 Erlang VM 与 JVM 有着非常多的差异,正如 Erlang 语言与 Java 语言有着非常多的差异,所以可以预期的是,Joxa 在语法上面不能完全保持与 Clojure 一致,同时这里面有一个目标用户的问题:Joxa 更多的是为了 Clojure 程序员转向 Erlang 平台而设计,还是为了 Erlang 程序员转向 Lisp 而设计。若为前者,就尽量保留可能多的 Clojure 语法及规范,若为后者则将语法尽可能向 Erlang 靠拢比较理想。这时 Joxa 选择了后一种,即认为 Joxa 主要是解决 Erlang 现有的问题,所以从语法上来考虑,最后出来的结果很可能是一种 Lisp 与 Erlang 的独一无二的新结合。所有的 Lisp 语言从结构上来看,都具有一种类似数学的体系结构,包括以下几个部分:

  1. 一切表达式皆为 List,List 有两种,原子及函数调用。代码即数据(总结为同像性)
  2. 7 个基本原语(又称之为特殊 Form)加上可以操纵语言本身的 Macro,两者作为核心,在此之上演化出整个语言

这就像数学体系,最核心的部分是几条基本原理,然后通过逻辑推导,演化出其它数学分支以构成整个体系,可以不断向外扩展。Joxa 将会有同样的结构,核心部分将保持尽可能的简洁,只包括基本原语及 Macro,极简的核心既节省开发成本,也给外延留下尽可能大的空间。此处的外延包括针对特定问题领域而言的 DSL,也包括其它上层的 Lisp 语言,从本质上来说这两者本来就没有区别,只不过因为针对的范围有大有小所以说法不同。从语法设计上,Joxa 会跟 LFE 有如下的不同:

  1. Joxa 会是 Lisp-1,而 LFE 是 Lisp-2
  2. Joxa 的语法会向 Lisp 靠拢,而 LFE 更像 Erlang
  3. Joxa 中 Macro 求值语义与 Lisp 更为一致,而 LFE 的 Macro 求值语义与函数求值语言不同

为了保持与 Erlang VM 现有的平台等保持无缝兼容,以充分利用现有的 Erlang VM 的开发规范与代码库等,第 2 点是必需的。将 Joxa 建立在 Erlang VM 平台的生态环境之上,固然是因为作者对 Erlang VM 的熟悉与喜欢,客观上也可以充分发挥 Erlang 生态的优势。从上面的叙述也可以看到,众多 Erlang VM 上的非 Erlang 编程语言也采用了这种“无缝兼容”设计,虽然它们在实现层面会有一些不同之处。这一点同时确定了 Joxa 将会保留 Erlang 的一些语言特性,例如按文件划分的模块化,函数式风格,代码要求先通过编译等。

第 3 点与开发环境相关,REPL 是各种 Lisp 方言已经是司空见惯了,Erlang 在设计的时候也吸收了这个概念,但是实现得不如 Lisp 的 REPL 那么好用,比如强制输入为表达式(每行的后面必须输入”.”号),Record 不能用等。Joxa 的 REPL 会参考 Clojure 与 Erlang 的 REPL,结合前者的完整性和后者的功能,在易用性给予特别的关注。同时针对编译、脚本化等开发流程中的各个阶段都提供编辑器、命令行工具等支持。

2. 设计与实现细节

下面我们来详细讨论 Joxa 的设计与实现。根据上述的设计要点,要将 Joxa 代码要编译成 Erlang VM 代码,必需先熟悉 Erlang 代码的编译过程,在此过程中找出合适的切入点。

2.1 Erlang 编译过程

一个经典的编译过程可以分为如下图所示的多个阶段:

传统编译过程的各阶段
图 1 经典编译过程的各阶段[12]

经典的编译过程可以分为词法分析,语法分析,语义分析,中间代码生成,中间代码优化,机器码生成等多个阶段。Erlang 的代码编译过程跟经典的编译过程基本一致,也可以分成类似的多个阶段,各个阶段的输入输出如下图所示:

Erlang 编译过程的各阶段
图 2 Erlang 编译过程各阶段的输入输出

其中 Core Erlang 为于 Erlang 代码与 VM 内部中间代码之间的一层,它是在 1999 年前后提出的一种 BEAM(Erlang VM 的最新实现)上的语言,它被设计为:

  1. 语法清晰简单,严格的更高阶函数式语言
  2. 尽可能规范化,以便相关代码遍历工具的开发
  3. 从 Erlang 代码向 Core Erlang 代码的翻译应该直白,从 Core Erlang 向 VM 内部实现中间代码的翻译也应该简单
  4. 有良好定义的文本表示形式,语法简单无歧义,便于人阅读,调试及测试

由于 Core Erlang 是清晰简单,有良好定义的文本语言,便于作为目标语言,而且 Erlang 的代码优化和错误检测大多都在 Core Erlang 层进行,如果我们要在 Erlang VM 打造新编程语言,那么将新语言的代码编译成 Core Erlang(或 AST),将会是一个很好的解决方案。很多 Erlang VM 上的语言都选择了这种方案,比如 LFE,但也有语言选择了编译成 Erlang AST,比如 Elixir,精通 Elixir Macro 的人对 Erlang AST 应该比较熟悉。相对于 Core Erlang,Erlang AST 更接近于 Erlang 本身,层次也更高。Core Erlang 相关的功能定义在cerl.erl这个模块里面,包括对如模块、函数等各种 Erlang 语言结构的初始化、操纵等功能的一系列函数。

2.2 一个简单例子

下面举一个简单的 Hello World 程序作为例子[13],让读者对 Erlang AST 与 Core Erlang 有一个感性认识。原始的 Erlang 代码如下:

1
2
3
4
-module(test).
-export([hello_world/0]).
hello_world() ->
    io:format("Hello World").

对应的 Core Erlang 代码如下所示:

1
2
3
4
5
6
module 'test' ['hello_world'/0]
    attributes []
'hello_world'/0 =
    fun () ->
        call 'io':'format'
            (Hello World)

对比两份代码,可以看到 Core Erlang 与 Erlang 之间的映射还是很直观的。将原始的 Erlang 代码编译为 Erlang AST,可以得到:

1
2
3
4
5
6
7
[{attribute,1,module,test},
 {attribute,2,export,[{hello_world,0}]},
 {function,2,hello_world,0,
   [{clause,2,[],[],
     [{call,3,
       {remote,3,{atom,3,io},{atom,3,format}},
         [{string,3,"Hello World"}]}]}]}]

编译为 Core Erlang AST 即得到:

1
2
3
4
5
6
7
8
9
10
11
12
{c_module,[],
  {c_literal,[],test},
    [{c_var,[],{hello_world,0}}],
      [],
     [{ {c_var,[],{hello_world,0}},
       {c_fun,[2,{file,[]}],
         [],
         {c_call,[3,{file,[]}],
           {c_literal,[3,{file,[]}],io},
           {c_literal,[3,{file,[]}],format},
           [{c_literal,[3,{file,[]}],
              "Hello World"}]}}}]}

对比两者,容易看出 Erlang AST 更高层更抽象,Core Erlang AST 更底层更规范。

2.3 编译器

下面我们继续来讨论 Joxa 的编译过程,由于 Core Erlang(及 AST)可以由 Erlang 编译器编译成最终的机器码,我们只需将 Joxa 代码编译成 Core Erlang AST 便可实现将 Joxa 编译成机器码整个编译过程,从编译领域的分类来看,目标生成的代码是 Core Erlang AST,操纵 Core Erlang AST 可以直接调用cerl.erl的接口函数,因此编译器后端这一块相对是比较简单的,重点在于前端部分:即将 Joxa 代码编译成 Core Erlang AST。由于 Joxa 是 Lisp 语法,Lisp 代码以括号划分边界的代码树的方式来表示,本身就已经有良好的结构,所以前端部分也比较简单。区别于 LFE 或 Elixir 用 Leex 或 Yecc 来生成 LALR[14]式 Lexer 与 Parser,Joxa 采用了手写 PEG[15] Lexer 和 Parser 的方式。PEG 编译器的代码量较小,Joxa 编译器是在 Erlang PEG 生成器 Neotoma 生成代码的基础上写成的。(本节涉及到很多编译领域的术语或技术,由于本文主要是介绍 Joxa,篇幅所限故不会详细解释这些术语或技术,有兴趣的读者可以自行寻找相关的资料做进一步了解)

特别值得一提的是,Joxa 的编译过程是自举的,即 Joxa 编译器本身是由 Joxa 代码编写的,这与 LFE 或 Elixir 的编译器用 Erlang 编写不同。Joxa 的自举要求先有一份以 Core Erlang AST 格式存在的具有正常编译功能的代码,这部分代码在 Joxa 的 Github 代码库中,相对根目录的路径是src/ast(后面给出所有的代码路径都相对于根目录)。通过用 Erlang 编译器将这份 AST 代码编译成 BEAM 代码,然后就得到一个能直接在 BEAM 上执行的 Joxa 编译器,然后就可以运行此编译器,将编译器的 Joxa 源代码编译成 Core Erlang AST 格式。整个流程和依赖如下图所示:

Joxa 的自举及编译流程
图 3 Joxa 的自举及编译流程

由上图可以看到整个流程三个步骤是一个循环,要成功实现自举,必然要先实现其中的一个部分,在此基础上才能实现其中其它两个部分。在 Joxa 的编译器实现中,AST 这部分先由作者 Eric 人手先写出基本的语法解析功能,然后再编写对应功能的编译器的 Joxa 代码,用 AST 编译出来可执行的编译器,去验证对应的 Joxa 代码,然后再按此流程不断添加更多的功能,错误一般出现在编译 Joxa 代码的时候,此时遇到的错误是由新添加的 AST 代码还是 Joxa 代码引起的,有时并不容易定位出来。虽然 Core Erlang 简单清晰,但手写 Core Erlang AST 是相当繁琐的,而且由于 Joxa 语法本身还在不断演变,从头开发一个这样的自举编译器,其难度可以猜想是比较大的。我曾经为 Joxa 添加过 Map 语法的支持,对此开发流程的复杂性有较深的体会。编程语言的自举也可以按另外一个思路来做:先用另外一门常见的语言,比如 C 语言来写编译器,然后当语言的语法发展到比较稳定成熟的时候,再使用这门语言的本身来实现自身的编译器,由于已经有了一个能够工作经过充分检验的 C 编译器,所以自举的实现就有了一个可靠的保障,大大降低其难度。

编译器这部分的 Joxa 代码的路径是src/joxa-cmp-*.jxa(其中*符号表示通配)。按照编译器前端和后端的分类法,下面我们讨论一下各主要文件的代码分布。PEG 的词法分析部分需要构造一系列对应于词素(lexeme)的正则表达式,首先需要有正则表达式的元操作的函数定义,所谓“元操作”,用各种编程语言里面的正则表达式的术语来说,即是元字符,比如”*“符号用于“匹配 0 个或多个”。在 PEG 里面元操作是通过函数来表达的,这部分的代码在src/joxa-cmp-peg.jxa。词素,比如注释或数字,它们的定义放在src/joxa-cmp-lexer.jxasrc/joxa-cmp-parser.jxa则包含 Parser 的代码。编译器的主要逻辑放在src/joxa-compiler.jxa,它调用 Parser 来解析读入的字符流,成功解析之后调用make-forms函数递归遍历解析得到的语法树来生成 Core Erlang AST,在编译过程中会执行对函数调用的合法性检查,Macro 的递归展开等动作。后端的代码按语义的分类分成下述多个文件:

1
2
3
4
5
6
7
8
9
├── joxa-cmp-binary.jxa           # Binary
├── joxa-cmp-call.jxa             # 函数调用
├── joxa-cmp-case.jxa             # case 语句
├── joxa-cmp-defs.jxa             # 函数、宏定义
├── joxa-cmp-expr.jxa             # 表达式
├── joxa-cmp-joxa-info.jxa        # 模块 info
├── joxa-cmp-literal.jxa          # 常量,常量表达式
├── joxa-cmp-ns.jxa               # namespace
└── joxa-cmp-spec.jxa             # spec

以上即为 Joxa 编译器实现各部分代码的所在文件。整个编译器实现从代码量上来说并不大。

2.4 数据类型

与 Elixir 在 Erlang 数据类型的基础上添加了 Range、正则表达式、Unicode 字符串等新数据类型不同,Joxa 支持的数据类型与 Erlang 保持一致,并没有添加新的数据类型,所有的数据类型包括如下几种[16]

  1. 简单类型:不定长整数,浮点数,原子
  2. 系统类型:PID, Port, Reference
  3. 集合类型: Tuple, Record,Map, List, Binary

各种数据类型的字面量语法请参考 Joxa 的在线文档。值得一提的是,围绕 Record 的各种操作,Joxa 在语法上做了包装,便于解耦 Record 的内部实现与接口,提高了可用性,Elixir 在这个方面走得更远,引入了 Clojure 的 Protocol。另外一个常用的集合类型 set 是通过 Erlang 库提供的,并没有赋予特别的语法。

2.5 特殊 Form 及标准库基础原语

Joxa 里面的特殊 Form 及标准库中的基础原语包括以下几个:

  • let*, let
    用于绑定变量,不同于 Erlang 中的绑定操作或 Clojure 的let操作,let*并不支持 Pattern Matching 或解构,Pattern Matching 或解构需要通过case,标准库中的let是一个用let*case实现的对应支持 Pattern Matching 的版本
  • case
    整个 Joxa 语言中为数很少的一个支持 Pattern Matching 的原语之一,与 Erlang 里面在函数签名,变量匹配,case语句等各种语法结构都可以做 Pattern Matching 不同
  • receive
    接收消息,但没有对应的send原语,这可以通过调用 Erlang 模块或 OTP 库接口实现,支持 Pattern Matching
  • do
    分组表达式成一块,类似于 Lisp 里面的progn
  • apply
    以列表函数调用指定函数,类似于 Lisp 的apply
  • fn
    构造匿名函数,类似于 Erlang 的fun,或 Lisp 的lambda
  • defn, defn+
    定义模块内可见,模块外可见函数
  • defspec
    用于定义前置声明
  • defmacro, defmacro+, quote, quasiquote, ~, ~@, gensym, macroexpand-1
    Macro 操作,下一节再展开讨论
  • use, require, as
    namespace 相关操作,源自于 Clojure 里面的对应物,要注意的是不同于 Clojure 默认会在所有 namespace 自动导入clojure.core,Joxa 并不会自动导入joxa-core
  • try*, try
    两者catch用于异常捕取,用法跟 Erlang 里面的对应物类似,两者的区别在于是否支持 Pattern Matching,类似于let*let
  • 特殊常量,都以函数方式进行调用

      ($filename)       ;; 当前文件的文件名(连后缀)
      ($namespace)      ;; 当前的 namespace
      ($line-number)    ;; 当前的行号
      ($function-name)  ;; 当前的函数名
    
  • 其它如attrwhen等原语就不一一列举了。

2.6 Macro

作为 Lisp 类语言的杀手级特性,以及表达 DSL 的终极利器,一直以来 Macro 在 Lisp 类语言中都有着重要的地位。Joxa 中的 Macro 原语与 Common Lisp 或 Clojure 等之前的 Lisp 语言保持了一致,详细列出如下:

1
2
3
4
5
6
7
defmacro     -- 定义模块内部 Macro
defmacro+    -- 定义对模块外部可见 Macro
quote        -- 抑制求值
quasiquote   -- 对应 Common Lisp 里面的 back quote,或 Clojure 里面的 syntax quote,部分求值
~            -- unquote,对符号后面元素进行求值
~@           -- unquote-splicing,对符号后面的 List 元素进行求值并展开到当前位置
gensym       -- 动态生成新变量,用于保证 Macro 健康(或称 Macro 卫生)

另外为方便调试,标准库中提供了macroexpand-1函数,用于单次展开 Macro,这个函数也沿袭于传统的 Lisp 语言,但是并没有提供macroexpand(macroexpand-all)。

在 Joxa 里面使用 Macro,跟之前的 Lisp 语言并没有什么不同,以一个标准库joxa-core模块里的代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
(defmacro+ let (args &rest body)
  (let* (process-arg-body
        (fn (arg)
            (case arg
              ([r e]
               `(case ~e
                  (~r ~@body)))
              ((r . (e . rest))
               `(case ~e
                  (~r ~(process-arg-body rest))))
              (detail (erlang/error {:malformed-let-expression detail})))))
    (process-arg-body args)))

Joxa 在语法上直接定义为 Lisp 风格,因此在 Macro 的定义及使用上面,与传统一脉相承并无修改。在这个 Macro 定义里面,除了 Pattern Matching,以及递归调用process-arg-body之外,与传统 Lisp 语言并无不同,熟悉传统 Lisp 的人可以很快就读懂。对比的来看,在 Elixir 这样的非 Lisp 语言中引入 Macro,由于上层语言的语法与 AST 并不一致,所以程序员必需记住/区分上层语言与 AST 两种环境,因此相对较为复杂,比如,在 Elixir Macro 的签名处 Pattern Matching Erlang AST,就在以 Elixir 的语法编写的 Macro 定义中,暴露了底层的 AST 格式。这种复杂性虽然从设计上来说是必需的折衷,但在习惯了 Lisp Macro 的人看来可能不会太喜欢。Joxa 作者 Eric 在 2015 年一次接受《This is not a Monad tutorial》的采访中就表示过不喜欢 Elixir Macro 的复杂性。

2.7 标准库概览

Joxa 的标准库只包括少数几个基本函数以及对 OTP 的简单包装。详细列出如下:

  • joxa-core
    基本操作: !=, lte, gte, and, or, +, -, incr, decr, if, when, unless, try, let, define
  • joxa-eunit
    eunit 相关函数封装
  • joxa-lists
    list 相关的功能函数: dolist, hd, tl, foldl, map, lists-binding, #, all, any
  • joxa-records
    Record 相关函数
  • joxa-shell
    REPL 函数的简单实现
  • joxa-otp, joxa-otp-gen-server, joxa-otp-supervisor, joxa-otp-application
    Erlang OTP 相关接口的封装函数

代码规模很小,跟 Elixir 的标准库相比差得很远。其功能比较简陋,称之为标准库也许太大,或者称之为帮助函数更加准确。由于 Joxa 可以直接调用 Erlang 代码,因此功能缺失之处可由其它 Erlang 库补充。

2.8 开发环境及工具链

目前 Joxa 只有一个名为joxa的命令行工具,用于编译 Joxa 源代码,启动 REPL,这个工具的功能也比较简陋,跟 Clojure 的 REPL 还有很大的距离。源代码中有一个 Emacs 的 Major Mode 配置文件emacs/joxa-mode.el,可以在用 Emacs 开发 Joxa 时设置缩进,关键字高亮,键绑定等。上面提到的与 Slime 和 Swank 集成,则尚未开发。

2.9 项目状态

Joxa 从 2011 年底开始开发,一直到 2013 年初都比较活跃,这之后代码提交量变得相当少,在当前这个时间点(2015 年 8 月)回头看看提交日志,已经有一年多的时间没有任何更新。虽然还未完成原来的设计目标,wiki 上面的计划也有很多开发要做,但是由于作者 Eric 工作上比较忙,而 Joxa 社区实在太弱小,除作者之外并没有其它的人员贡献过大量代码,因此短期之内似乎项目状态不会重新变得活跃。在 Google Group 上有 Joxa 的邮件组,在近一年多时间内也相当少人发言。在应用上,除了作者 Eric 将 Joxa 用于编写他的创业项目之外,目前市面上没有看到其它的应用[17]。综合来看,Joxa 的项目状态是比较停滞的。

3. 总结

Joxa 是一种基于 Erlang VM 的现代 Lisp 语言,有着简洁清晰的 Lisp 语法,支持强大的 Macro,是在 Erlang VM 编写 DSL 的一个很好的载体,它无缝兼容 Erlang VM 平台,是一门功能全面的通用编程语言。它在语言设计及编译器实现方面质量优良,但目前完成度不高,工具链并不完整,市面上也少见应用。作为一门较新的基于 Erlang VM 的编程语言,它有待进一步的发展完善。

注:
[1] Eric Merritt 是《Erlang and OTP in Action》(中文译本《Erlang/OTP 并发编程实战》)一书的作者之一,Erlware 项目联合创始人,Afiniate 公司的 CTO。
[2] 翻译自邮件原文
It doesn’t actually mean anything. Many years ago it was an acronym for some project I wanted to start ‘Java oriented something or other’. I never made that project but I bought the domain and have kept it these years. Four letter domains are pretty uncommon these days, so I just decided to use it as the name for the language. Thats all.
[3] 作为 Erlang 语言联合创始人以及 Joe Armstrong 的长期亲密战友,Robert Virding 自从当年在爱立信计算机科学实验室开始,长期以来在 Erlang 的设计,标准库,编译器,发展推广等方方面面都做了杰出的贡献。
[4] Lisp-1 和 Lisp-2 的区别在于函数与变量是否共用同一命名空间,一个详细的解释可以参考文章《What’s Lisp-1, What’s Lisp-2? Bad Jargon or Good Jargon?》
[5] REPL 是 Read-Eval-Print Loop 的缩写,最早被用于指代开发 Lisp 程序过程中,交互式命令行不断执行读取程序员的输入代码,对其进行求值并打印出求值结果的循环动作。后来这个概念被 Python,Ruby 及各种交互式命令行工具吸收并推广开来。
[6] Tony Arcieri 是一位美国的软件工程师,他的博客见这里
[7] José Valim 是一位波兰的软件工程师,他最被人熟知的两个身份是 Ruby On Rails 的核心成员以及 Elixir 编程语言的创始人。
[8] 比如 Joe Armstrong 于 2013 年中写了博客文章《A Week with Elixir》盛赞了 Elixir“结合了 Ruby 和 Erlang 的优良特性”,Dave Thomas 于 2014 年发布了新书《Programming Elixir》向有其它语言经验的程序员提供了一本系统的教程。
[9] 译者注:Slime 是 Superior Lisp Interaction Mode for Emacs 的缩写,它为 Emacs 提供了一整套交互式开发 Common Lisp 的功能集,包括编译,调试,文档查找等等,Slime 是客户端,Swank 是对应的服务器端,它们共同组成了一个强大的程序开发环境。
[10] 译者注:LeexYecc 是 Erlang 语言的 Lex 和 Yacc 工具集。
[11] CLOS 是 the Common Lisp Object System 的缩写,是指在 Common Lisp 中实现面向对象机制的一系列代码库。
[12] 此图来自编译领域的经典著作《Compilers: Principles, Techniques, & Tools》第三版,中译本《编译原理》。
[13] 这个例子来自 Eric Merritt 2012 年 8 月在 Chicago Erlang User Group 上的技术分享《Joxa: A Full Featured Lisp on the Erlang VM》,录像视频见这里.
[14] LALR 是 LookAhead LR 的缩写,LR 中的 L 表示对输入进行从左到右的扫描,R 表示反向构造出一个最右的推导序列。LALR 是流行的自底向上语法分析方法。
[15] PEG 是 Packrat Expression Parsing 的缩写,它是一种相对较新的自顶向下语法分析方法。
[16] 其中 Map 的语法支持由我添加,写此文时未进入主干分支。
[17] 从推广应用的角度来看,在 2011 年中开始开发的 Elixir 在各个基于 Erlang VM 的新编程语言上是走得最前的。

人体工学机械键盘 Ergodox

作为一个码农,我每天的工作主要就是敲打键盘,因为工作的特殊性,我平均每天面对电脑使用键盘鼠标至少达到 10 个小时,长期如此,我发现我的手腕时不时会出现无力、僵硬甚至酸痛的现象,这种感觉有时在夜晚睡觉时会特别明显,而右手又比左手要严重。后来经了解这是管腕综合症的症状,是由于长期敲击键盘和使用鼠标造成重复性的神经压迫受损引起的。要缓解这这种症状,除了平时注意多休息之外,我还特别了解过有没有什么工具对此有所帮助,然后发现其实我们最常用的传统键盘,在对手和肩的人体工学上并不是太科学。因为传统键盘是一个长方形的整体,它的键位分布也是各行平行,使用的时候就需要人弯曲手腕和肘弯来形成合适的角度,这种弯曲一旦长时间保持,就会造成手腕肩头等部位的习惯性疲劳甚至病症。如下图左边所示:

普通键盘和人体工学键盘对比图

一个考虑到人体工学的键盘,设计上应该是左右两边从中间分开,让身体两侧的肘部和手腕,形成更舒适自然的角度,如上图的右边所示。很多的人体工学键盘都带有这样的设计,其中包括微软出品的几款薄膜键盘:停产的 Natural Keyboard Elite 和后继者 Natural Ergonomic Keyboard 4000,较新的 Sculpt Ergonomic Keyboard。

微软人体工学键盘 Elite

带人体工学设计的键盘还有较少见的 Truly Ergonomic 公司推出的 True Ergonomic KeyboardKinesis 的 Advantage 和 FreeStyle 2 等,而像 Cherry 的 MX 5000 这种早就停产的绝版产品已经难于在市场上觅到其踪影。

Truly Ergonomic Keyboard

这些键盘左右两边都分叉开,有的可以自由调整分叉角度,有的则角度固定,左右两边都是连成一体的。这些键盘中有的全部键位都采用 MX 机械轴,有的则为薄膜键盘,薄膜键盘的一大缺点就是手感差,不像 MX 轴那样寿命长手感好。另外其中有的键盘键位与普通的差异比较大,由于不支持编程,无法自定义键位,用户必然需要一段较长的时间去适应它的独特键位。有没有一个键盘,既有人体工学的设计,也是全键位 MX 轴,甚至支持全键位编程,可以满足文字工作者对键盘在人体工学、手感和寿命各方面的要求呢?它就是 Ergodox。

Ergodox 简介

Ergodox 是一个硬件开源项目,它的项目网站见这里。它主要有如下几个特点:

  1. 分体式设计
    整个键盘分为左右两个相互独立的部分,左右两边相互对称,中间用线材连接用于通讯。除了常见的字母/数字键区之外,每边还有一个延伸的拇指区,和一些特殊的控制大键位。分体式设计使得用户在使用时可以将左右两边以任何角度或位置摆放,提供了很大的灵活性和舒适度。

  2. 直列错行键位
    在传统键位中,键位的行是平行的,行与行之间,不同列是错开的,这种设计来源于最早的机械式打字机设计,因为每个键的连杆都要错开一定角度,同一个位置不能有两条连杆重合,否则就会造成冲突。如下图所示:
    传统机械式打字机
    这种错列的设计从人手的角度来看并不科学,因为对于每一根手指,直上直下才是比较方便的,而且不同的手指长度不同,平行的行键位只是为了方便制造。后来计算机的键盘沿用了打字机这种错位的设计,但现代的键盘都是电信号触发的,连杆早已不复存在,这种平行错列的设计只是简单地追随传统。
    常见的直行错列键位
    Ergodox 给出了一个更科学的设计:直列错行。每根手根负责一个直列,不同行即不同手指则按各指长度错开成一定距离。
    Ergodox 直列错行键位

  3. 全键位 MX 轴
    全部键位都使用 MX 轴,其中每个键位可以自由搭配不同的轴,以供用户的个人定制。常见的 MX 轴定制方案有:基本键位一种轴,扩展(特殊)键位配另一种轴;或 4 指控制部分一种轴,拇指部分配另一种轴等。MX 轴的使用既改善了手感也提升了寿命。

  4. 全键位可编程
    全键位可编程,满足了高端用户定制键位映射的需要,虽然键映射可以在不同类型的操作系统中做软件映射来做(如 Mac 中有 karabiner,Windows 中有 Auto Hotkey)。但是在硬件层面做,可定制的范围更广可玩性更强,同时可以做到换系统即插即用的效果,而且 Ergodox 支持多达 30 个 layout,足以满足绝大多数的键位定制需求。

获得 Ergodox

由于 Ergodox 是硬件开源的,有兴趣的用户可以根据官网给出的配件列表自行收集配件组装,如果你和我一样,基于不方便购买配件或没时间等原因,不愿意一点一点的攒配件,也可以通过网络购买。目前主要有两种方式可以买到 Ergodox:

1. Massdrop 团购

Massdrop 是美国的一个团购网站,上面主要集中了一批对音响、电子、机械键盘等方面的爱好者,围绕着相关产品用户可以参与团购,社区交流等活动。早在两年之前,Massdrop 就开展了 Ergodox 的团购,在 Massdrop 的 Ergodox 主页上,有 Ergodox 的一个较详细介绍,包括它由哪些配件组成,可以选择不同颜色的 MX 轴体,半掌全掌两个不同的尺寸,DSA 和 DCS 两种不同的键帽,不同颜色的阳极铝材质上面板,组装完成之后的成品图等等。由于 Massdrop 出售的只是 Ergodox 的零配件,并非组装好的键盘,因此它还提供了一个图文并茂的组装教程,另外有一个方便 Ergodox 用户做定制键位的键位编程配置网页

有兴趣团购的用户可以点击网页右边的蓝色”Request”按钮,来申请团购。一般每过 3 个月左右,申请人数到达 200 个之后,Massdrop 就会开放团购,通知之前申请过团购的人去提交订单,当然之前没申请过的人也可以马上下单,下单的时候用户可以定制尺寸,MX 轴颜色等选项,最后通过信用卡扣款。在团购结束之后,Massdrop 会向上游供应商提出订单生产或购货,越多人组团下单,优惠的尺度越大,一般来说组团的人每批都能达到 200 个以上,成本会降低到$199,当然这只是键盘本身的价格,如果加订键帽则需要加钱。

在 Massdrop 团购耗时会比较长,以我参加的 2014 年 9 月这一批次团购的经验来看,除了要等待团购开放这段时间,下单付款之后,大概要 8~9 周后才开始发货,然后快递走 UPSMI(UPS Mail Innovations)到国内大概花了 3 周,以此估算从下单到收到邮件需要 12 周也就是 3 个月的时间。因为 Massdrop 上出售的是零件,Ergodox 的亚克力材质外壳、PCB、一堆零件和轴等等装在一起也是比较大件的包裹,包裹通过海关时运气不好的话可能被征关税。UPSMI 包裹在国内这段是由 EMS 负责运送的,EMS 的送货服务非常高明:包裹运到本地邮局,EMS 的工作人员就打电话给我,叫我在限时之内去领取包裹,所以它也不用考虑邮件地址写得是否准确——因为最准确的投递服务就是让用户自行跑过去领,不用投递。当然 EMS 的工作人员还是经验丰富的,他们在通知我领包裹的电话里就提醒我包裹已经被税,去领取邮件时记得带上钱付关税。

在 2014 年 9 月这一批次我订的配件如下:

配件 价格
Ergodox 本身(包括 PCB,电容电阻等零件,亚克力壳,轴体) $199
亚克力壳(全掌)加钱 $10
DCS 键帽 $49.99
银色铝质上面板 $39.99
运费 $24.99

全部配件总计 298.98 美刀,加上运费共 323.97 美刀,按当时汇率换成人民币是 1995 块。由于包裹上贴着的配件单写的是这批团购的键盘价格$199,海关收关税是按包裹所标总价的 10%征收。我按 EMS 工作人员的指示交了 122 块人民币的关税,把包裹带回家对照票据后发现如果按真正的配件价格来收取关税的话,需要多交几十块人民币的关税,由于 Massdrop 寄件人员没有将全部配件写在包裹外面的工作失误,使得我个人在为国家的光荣税收方面少作了一些贡献,作为人民群众的我表示情绪稳定。

收到配件之后,下一步就是组装了,除了 Massdrop 上面的组装教程,另外在 Youtube 上面有一个名为 Ergo-Dox keyboard assembly 的组装视频,对于新手比较有参考价值。组装过程中需要把电容,MX 轴等焊接到 PCB 上,这需要用到烙铁,由于我很久都没有做过焊接而且手上也没有烙铁,焊锡这些工具,所以花了大概 200 块人民币通过淘宝购买了一些工具,其中包括入门级的黄花 907 自动调温烙铁,无铅焊锡,吸锡器,斜口钳,镊子等工具,因为拿不准用哪种型号的烙铁头适合焊接 Ergodox 的配件,所以多要了几个不同型号的烙铁头,全部焊装完成之后发现其实型号为 MT-3927 的 1.6D 平头烙铁头适合用于几乎所有焊点,而型号为 900M-T-B 的烙铁自带尖头用得不多,只有少数几个焊点适合用上,比如 Ergodox 对外的 USB 接头处几个小焊点。其中镊子最好是弯头的,用于夹住每个键位下面的小电容,方便做 SMT 焊接。

参考上面给出的组装教程网页和视频,一般人都可以完成整个焊接过程,不过特别提醒一下,组装之前要细看每一个步骤所用到的配件和安装说明,注意安装的正反面和位置,如果漏掉一些关键信息可能会造成问题,比如我在焊 PCB 下面对应每个键位 SMT 电容时,没有注意看电容上面的极性,当整个键盘都组装完成之后,烧入固件连上电脑才发现有一半的键没有反应,然后出动了万用表检查,才发现有问题的键位 SMT 电容焊反了(电容上有标记的一端应该对应 PCB 方块焊头的一头,而不是圆焊点的一头),只能拆下来重新焊上。

Ergodox In Assembly

在整个焊接过程中,我个人认为刚才提到的 SMT 电容和对外的 USB 接头这两处是比较难焊接的,前者是因为电容很小,而且 SMT 容易虚焊,如果像 Youtube 视频那样用熔锡和热风枪来做是比较容易的,但用一般的烙铁操作起来比较繁琐,我用了一个土办法:先为一边点上热锡,用镊子夹住电容粘上去放好位置,再把另一边也点上热锡,最后轻轻按紧电容,两边焊点都用烙铁加热一下,以免虚焊。如此折腾下来,最后到整个键盘组装完成之后,没有发现有虚焊。第二个难点就是 USB 接头处,首先把 Mini USB 的接头裁切出来的时候要小心,尽量不要弄断各黑、白、绿、红各颜色接线里面的铜丝,焊接的时候各颜色的线头和 PCB 的连接处的位置保持好也很关键,Youtube 视频中在焊实底面焊点之前,他在 PCB 上表面先点上一点焊锡以做固定,这办法比较有效。即使如此,由于这几个焊点小,线材小加上位置比较容易移动,而且焊接时间要控制好,一旦烙铁靠得太久线材就会开始熔掉,要焊好还是不太容易的。至于 MX 轴,由于对应的焊点比较粗,相对来说容易焊得多。像我没有太多焊接经验,组装好一整套 Ergodox 并确认功能正常(拆掉重装过 SMT 电容)大概需要花 10 多小时。

由于我个人不太喜欢灯(我觉得闪亮的键盘灯是一种打扰,Macbook 上的键盘灯也从来不用),而且手头的 Ergodox 键帽都是不透明的,在键轴上面加灯也看不见,所以我没有像标准安装教程所示的那样装灯。要注意的一点是,由于 PCB 在 MX 轴的下面,如果要装灯则需要先把 MX 轴的上盖拆下来,加上灯,再把 MX 轴焊到 PCB 上,上面给出的 Youtube 教程是先把 MX 轴焊到 PCB,再用一个叫做 beast switch tool 的工具从上面打开 MX 的上盖加灯,这个 switch tool 很难买到[1],所以一般还是要先拆开上盖再焊 MX 轴。

焊接完成之后,给 teensy 芯片烧入固件,就可以正常使用了,为了确认焊接没出问题,烧录完固件之后可以第一时间试试每个键确认功能都正常,再装上亚克力外壳和键帽。一般来说亚克力板是比较容易粘上灰尘和指纹的,上表面指纹容易擦掉但是每层亚克力板之间的灰尘如果长期累积下来,对于有洁癖的人来说可能会觉得影响观感,所以我组装的时候保留了亚克力板表面深棕色的薄纸没有撕掉,上表面装上特别订购的铝质面板,这样一来上表面不会留指纹,也看不见键盘里面的亚克力面板之间的灰尘,不过由于上表面是金属,冬天会冻手,和 Macbook 键盘前面的手托类似。

2. 淘宝网购

由上面我自己的经历可以知道,通过 Massdrop 团购 Ergodox 零件再自行组装的成本是相当高昂的,需要花掉团购成本、关税、焊接工具成本共 2500 块人民币左右的金钱成本,而且要付出很大的人力和时间成本:很长的团购和到货时间,取国际邮件、购买烙铁等工具的时间,还有 10 多个小时的组装人工和时间。除非自己动手能给你带来很大的乐趣,不然通过这种方法来搞一套 Ergodox 并非良策。其实 Ergodox 所用到的电容电阻,MX 轴等零件都是市面上比较容易搞到的大路货,PCB 和亚克力板也可以在深圳华强北找商家打印或通过淘宝定制,它的成本远远低于 Massdrop 这一条渠道。话说回来,通过 Massdrop 买到的零件也有一些国内生产的配件(比如 TRRS 线),当你耗费时日不远万里从国外搞到一套 Ergodox 的配件,打开包裹一看发现很多”Made In China”的时候,我不知道你会有什么反应,反正我是对着关税的收据一轮苦笑。

有个朋友 rabinz 比较早接触到 Ergodox,他原本打算在国内攒零配件来组 Ergodox,由于零配件一般是成批卖的,所以后来他攒了很多的零配件,此后更是在原有的 Ergodox 的基础上做了一些设计方面的改进。他在淘宝上开了一个网店,以一个实惠的价格出售整套组装好的 Ergodox,并向用户提供一些定制服务:比如各种轴的搭配,大键区卫星轴、平衡轴的选择等,对 Ergodox 有兴趣的话可以去那里看看。

我之前在网上多收了一副 rabinz 做的半掌 Ergodox,下面简单的比较一下它和前面所讲的我自己组装的一套,看看两者有何区别。

  1. 整体
    Ergodox Classic
    Ergodox Fullhand
    两套都是 Ergodox,硬件键位自然是相同。前者是半掌尺寸带灰色 DSA 键帽,上表面是透明的亚克力板;后者是全掌尺寸带黑色 DCS 键帽,上表面是不透明银色的阳极氧化铝板。

  2. 接合
    Ergodox Classic Screw
    半掌 Ergodox 使用了两条小螺钉在中间通过内嵌螺母连接在一起,在上下两个表面都做了沉孔处理,本来尺寸就较小的螺钉头只比亚克力面板表面高出一点;全掌的一套则使用单条大螺钉和螺母,螺钉头比较粗,上表板也没有做沉孔处理,所以螺钉头明显高出上表面很多,螺母头为半圆球形,尺寸相当于两个螺钉头。全掌的几块亚克力板总厚度比半掌的要薄,但由于上下两面的螺钉头占用了比较多的空间,所以从整体高度上来看,全掌的反而显得略厚。
    Ergodox Height

  3. 钢板
    从上面几个图明显看到,半掌键盘中间有一块黑色的钢板,全掌的则没有,机械键盘是否带钢板会对手感有所影响,带钢板的话受力时刚性会较好,按键触底会更硬朗一些。

  4. 大键区 Ergodox Thumb Area
    半掌键盘是青轴,两个大键使用了卫星轴,而全掌的则是白轴,大键区只是普通单轴。在常见的机械键盘产品中,一般大于 2X 宽度的键轴都会加上卫星轴或者平衡轴,避免键轴受力走偏,以改善大键的手感。要安装卫星轴或平衡轴的话,需要从 PCB 到中间面板都准备相应的孔位,但 Massdrop 出售的 Ergodox 无论是 PCB 还是中间面板都没有准备对应的孔位,因此只能装上单轴。

经过上面的比较,容易看出 rabinz 做的 Ergodox 加入了一些优化,跟 Massdrop 渠道的自组 Ergodox 相比占有相当的优势,即使只考虑金钱成本,Massdrop 的也高出很多,所以综合来看,通过淘宝向 rabinz 购买组装好的 Ergodox 成品,是更好的选择。如果你只是个一般用户,希望拥有一把 Ergodox 但不愿意自己攒零件动手焊接组装,那么通过淘宝向 rabinz 订购更是绝佳的选择。

体验与总结

有了 Ergodox,我在敲打键盘时两手就能以舒服的角度随意放在桌面上,使用了一段时间的 Ergodox 之后,我手腕的不适症状已经大为减轻,它的人体工学设计起到了很好的效果。在我逐渐上手 Ergodox 的过程中也遇到一些问题,比如需要一点时间适应它独特(但设计正确)的键位布局:刚刚开始使用 Ergodox 时,我对 Ergodox 的键位布局还不是太习惯,由于它的直列错行布局跟一般键盘的直行错列不同,盲打时只要一离开 Home Row 手指经常会打错列,经过一段时间的练习之后,基本上已经熟悉它的布局,现在无论是普通键盘还是 Ergodox,一上手都能操作自如。另外目前市面上并没有适用于全掌 Ergodox 的包(半掌 Ergodox 的包倒是有,可以到这里购买),全掌 Ergodox 个头大便携性不好,只能放在家里使用。

总体来看,Ergodox 设计独特,成本不菲,对于像我一样想改善管腕综合症症状的电脑用户,Ergodox 是一个很值得买的机械键盘,对于喜欢 DIY 喜欢折腾机械键盘的玩家,Ergodox 也是一个具有很高可玩性的产品。

注:
[1] 有关 beast switch tool 在 Ergodox 组装过程中的使用方法可以参考这里,但它太难买到,你也可以参考这里用常见的文件夹自已 DIY 一个。

层次状态机

计算机程序是写给人看的,只是顺便能运行。
              ——《计算机程序的构造和解释》[1]

FSM

在计算机领域,FSM(有限状态机)是一个在自动机理论和程序设计实践中很常见的术语,简单来说,有限状态机表示的是系统根不同输入/不同条件在各个状态之间进行跳转的模型。可以通过图或表来描述有限状态机,这种图或表一般被称为状态图/状态转移图(State Chart)或状态转移表。因为图更加直观,本文统一使用状态图来描述有限状态机。

在状态图里面,一般用圆圈或方框来表示状态,用箭头来表示状态之间的跳转,箭头可以带上跳转需要的输入或条件,也可以带附带其它描述。一个从空白处引出,没有源状态的箭头则表示整个系统的启动,启动后进入的第一个状态可以称为开始状态,可以用双重圆圈特别标出。整个状态图就是一个有圆圈,箭头及描述的有向图形。下面是一个简单例子

图 1 计算输入包含奇数还是偶数个 0 的状态机

上图表示一个接受二进制输入(输入为 0 或者 1),计算输入包含奇数还是偶数个 0 的状态机。其中 S1 状态表示”偶数个 0”,S2 表示”奇数个 0”。系统启动后,沿着图中最左边的箭头进入 S1 状态,此时没有读入任何输入(0 个 0)。S1 圆圈上方带 1 的箭头表示如果输入是 1,则跳转到 S1,即保持原状态不变。如果输入是 0,则跳转到 S2。其它箭头也可以类似理解。当全部输入都处理完之后,只需看当前状态是 S1 还是 S2 即可得出结论:输入具有奇数个 0 还是偶数个 0。

由于状态机可以将问题整体的分解成各个部分状态及跳转,直观地对系统进行建模,所以它不仅被用于理论研究过程当中,而且被广泛用于程序设计实践,在操作系统,网络协议栈,各种分布式应用中都可以见到它的身影。

程序设计中的 FSM

由上面的表述我们得知,FSM 是对系统的建模,是将问题/解决方案以一种条理化系统化的方式表达出来,映射到人的认知层面,而要在程序中表达 FSM,也需要一定的建模工具,即用某种代码编写的方式(或称之为 FSM 模式),将 FSM 以一种条理化系统化的方式映射到代码层面。在程序设计领域,到底有哪些常用的 FSM 实现方式呢?下面我们来做一个简单的回顾。

从简单到复杂,下面我们浏览一下常见的几种 FSM 实现模式[2]

a. 嵌套 if-else/switch 模式

自从 1960 年第一个 Lisp 实现引入条件表达式以来,if-else/switch 语句[3]已经成为每个程序员手头必备的工具,每当需要”根据不同条件进入不同分支”,就搬出它来组织代码,这与 FSM 里面”状态之间根据不同输入进行跳转”的概念有简单的对应关系,这就使得 if-else/switch 语句成为人们要表达 FSM 时最先选择的方式。

仍然以图 1 例子进行说明,我们用 if-else/switch 语句编写它的实现代码,用一个变量 state 表示当前状态,state 可以取两个值 S1, S2,输入 input 表示下一个输入的数字是 0 还是 1,那么就有下列代码[4]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type State int
type Input int

var (
	StateS1 State = 1 + iota
	StateS2 State
)

var (
	Zero Input = 0
	One  Input = 1
)

var state = StateS1

func NumberOfZero(i Input) {
	switch state {
	case StateS1:
		switch i {
		case Zero:
			state = StateS2
		case One:
		}
	case StateS2:
		switch i {
		case Zero:
			state = StateS1
		case One:
		}
	}
}

上面的代码有一个明显的嵌套形式的结构,最外层的switch语句是根据当前状态 state 变量进入不同的分支,内层switch针对的则是输入,所有代码像挂在衣柜中的衣服一样从上到下一一陈列,结构比较清晰。这种嵌套形式 if-else/switch 语句的 FSM 代码组织方式,我们将其称之为*嵌套 if-else/switch *模式。由于这种模式实现起来比较直观简明,所以它最为常见。

嵌套 if-else/switch 具有形式嵌套,代码集中化的特点,它只适合用来表达状态个数少,或者状态间跳转逻辑比较简单的 FSM。嵌套意味着缩进层次的叠加,一个像图 1 那么简单的实现就需要缩进 4 层,如果状态间的逻辑变得复杂,所需要的缩进不断叠加,代码在水平方向上会发生膨胀;集中化意味着如果状态个数增多,输入变复杂,代码从垂直方向上会发生指数级别的膨胀。即使通过简化空分支,抽取逻辑到命名函数[5]等方法来”压缩”水平/垂直方向上的代码行数,依然无法从根本上解决膨胀问题,代码膨胀后造成可读性和可写性的急剧下降,例如某个状态里面负责正确设置 20 个相关变量,而下翻了几屏代码之后,下面的某个状态又用到上面 20 个变量里面其中的 5 个,整个代码像一锅粥一样粘糊在一起,变得难于理解和维护。

a. 状态表

另一个比较流行的模式是状态表模式。状态表模式是指将所有的状态和跳转逻辑规划成一个表格来表达 FSM。仍然以图 1 为例子,系统中有两个状态 S1 和 S2,不算自跳转,S1 和 S2 之间只有两个跳转,我们用不同行来表示不同的状态,用不同的列来表示不同的输入,那么整个状态图可以组织成一张表格:

State\Input Zero One
S1 DoSomething, S2 null
S2 DoSomething, S1 null

对应 S1 行, Zero 列的”DoSomething, S2”表示当处于状态 S1 时,如果遇到输入为 Zero,那么就执行动作 DoSomething,然后跳转到状态 S2。由于图 1 的例子状态图非常简单,DoSomething 动作为空,这里将它特别的列出来只是为了说明在更一般化的情况下如果有其它逻辑可以放到这里来。根据这个状态表,我们可以写出下列代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
type State int
type Input into

const (
	StateUndefined State = 0 + iota
	StateS1
	StateS2
)

var (
	Zero Input = 0
	One  Input = 1
)

type Action func(i Input)

func DoSomething1(_ Input) {
	// Do nothing here
}

func DoSomething2(_ Input) {
	// Do nothing here
}

type Item struct {
	Action    Action
	NextState State
}

var StateTable = [][]*Item{
	[]*Item{
		&Item{
			Action:    DoSomething1,
			NextState: StateS2,
		},
		nil,
	},
	[]*Item{
		&Item{
			Action:    DoSomething2,
			NextState: StateS1,
		},
		nil,
	},
}

var state = StateS1

func NumberOfZero(i Input) {
	item := StateTable[int(state)][int(i)]
	if item != nil {
		item.Action(i)
		if item.NextState != StateUndefined {
			state = item.NextState
		}
	}
}

从上述例子我们可以看到,用这种方式实现出来的代码跟画出来的状态表有一个直观的映射关系,它要求程序员将状态的划分和跳转逻辑细分到一定的合适大小的粒度,事件驱动的过程查找是对状态表的直接下标索引,性能也很高。状态表的大小是不同状态数量 S 和不同输入数量 I 的一个乘积 S * I,在常见的场景中,这张状态表可能十分大,占用大量的内存空间,然而中间包含的有效状态跳转项却相对少,也就是说状态表是一个稀疏的表。

c. 状态模式

在 OOP 的设计模式[6]中,有一个状态模式可以用于表达状态机。状态模式基于 OOP 中的代理和多态。父类定义一系列通用的接口来处理输入事件,做为状态机的对外接口形态。每个包含具体逻辑的子类各表示状态机里面的一个状态,实现父类定义好的事件处理接口。然后定义一个指向具体子类对象的变量标记当前的状态,在一个上下文相关的环境中执行此变量对应的事件处理方法,来表达状态机。依然使用上述例子,用状态模式编写出的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
type Input int

const (
	Zero Input = 0 + iota
	One
)

type State interface {
	OnEventZero(i Input)
	OnEventOne(i Input)
}

var (
	S1 = "S1"
	S2 = "S2"
)

type StateS1 struct {
	c *Context
}

func (self *StateS1) OnEventZero(i Input) {
	self.doSomething1(i)
	self.c.Tran(S2)
}

func (self *StateS1) OnEventOne(_ Input) {
	// do nothing here
}

func (self *StateS1) doSomething1(_ Input) {
	// do nothing here
}

type StateS2 struct {
	c *Context
}

func (self *StateS2) OnEventZero(i Input) {
	self.doSomething2(i)
	self.c.Tran(S1)
}

func (self *StateS2) OnEventOne(_ Input) {
	// do nothing here
}

func (self *StateS2) doSomething2(_ Input) {
	// do nothing here
}

type Context struct {
	allStates    map[string]State
	currentState State
}

func NewContext() *Context {
	object := &Context{}
	states := make(map[string]State)
	states[S1] = &StateS1{c: object}
	states[S2] = &StateS2{c: object}
	object.allStates = states
	object.currentState = states[S1]
	return object
}

func (self *Context) Tran(nextState string) {
	if s, ok := self.allStates[nextState]; ok {
		self.currentState = s
	}
}

func (self *Context) Handle(i Input) {
	switch i {
	case Zero:
		self.currentState.OnEventZero(i)
	case One:
		self.currentState.OnEventOne(i)
	}
}

var context = NewContext()

func NumberOfZero(i Input) {
	context.Handle(i)
}

状态模式将各个状态的逻辑局部化到每个状态类,事件分发和状态跳转的性能也很高,内存使用上也相当高效,没有稀疏表浪费内存的问题。它将状态和事件通过接口继承分隔开,实现的时候不需要列举所有事件,添加状态也只是添加子类实现,但要求有一个 context 类来管理上下文及所有相关的变量,状态类与 context 类之间的访问多了一个间接层,在某些语言里面可能会遇到封装问题(比如在 C++里面访问 private 字段要使用 friend 关键字)。

d. 优化的 FSM 实现

结合上述几种 FSM 实现模式,我们可以得到一个优化的 FSM 实现模式,它用对象方法表示状态,将状态表嵌套到每个状态方法中,因此它包含了上述几种模式的优点:事件和状态的分离,高效的状态跳转和内存使用,直接的变量访问,直观而且扩展方便。用它重写上述例子,得到下述的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
type Input int

const (
	Zero Input = 0 + iota
	One
)

type EventType uint32

const (
	EventInitialize EventType = 0 + iota
	EventFinalize
	EventStateEntry
	EventStateExit
	EventUser
)

type Event interface {
	Type() EventType
}

type FSMEvent struct {
	T EventType
}

func (self *FSMEvent) Type() EventType {
	return self.T
}

var (
	FSMEvents = []*FSMEvent{
		&FSMEvent{
			T: EventInitialize,
		},
		&FSMEvent{
			T: EventFinalize,
		},
		&FSMEvent{
			T: EventStateEntry,
		},
		&FSMEvent{
			T: EventStateExit,
		},
	}
)

type FSM interface {
	Init()
	Dispatch(i Input)
	Tran(target string)
}

type State func(e Event)

const (
	EventInput EventType = EventUser + 1 + iota
)

type InputEvent struct {
	T EventType
	I Input
}

func NewInputEvent(i Input) *InputEvent {
	return &InputEvent{
		T: EventInput,
		I: i,
	}
}

func (self *InputEvent) Type() EventType {
	return self.T
}

type BaseFSM struct {
	AllStates map[string]State
	S         State
}

func NewBaseFSM() *BaseFSM {
	return &BaseFSM{}
}

func (self *BaseFSM) Register(name string, state State) {
	self.AllStates[name] = state
}

func (self *BaseFSM) InitState(s State) {
	self.S = s
	self.S(FSMEvents[EventInitialize])
}

func (self *BaseFSM) Dispatch(i Input) {
	self.S(NewInputEvent(i))
}

func (self *BaseFSM) Tran(target string) {
	s, ok := self.AllStates[target]
	if !ok {
		panic("invalid target state")
	}
	self.S(FSMEvents[EventStateExit])
	self.S = s
	self.S(FSMEvents[EventStateEntry])
}

type ZeroCounter struct {
	*BaseFSM
	count int
}

func NewZeroCounter() *ZeroCounter {
	return &ZeroCounter{
		BaseFSM: NewBaseFSM(),
		count:   0,
	}
}

func (self *ZeroCounter) Init() {
	self.Register("S1", self.S1)
	self.Register("S2", self.S2)
	self.InitState(self.S1)
}

func (self *ZeroCounter) S1(e Event) {
	switch e.Type() {
	case EventInitialize:
	case EventStateEntry:
	case EventStateExit:
	case EventInput:
		event, _ := e.(*InputEvent)
		if event.I == Zero {
			self.count++
			self.Tran("S2")
		}
	}
}

func (self *ZeroCounter) S2(e Event) {
	switch e.Type() {
	case EventStateEntry:
	case EventStateExit:
	case EventInput:
		event, _ := e.(*InputEvent)
		if event.I == Zero {
			self.count++
			self.Tran("S1")
		}
	}
}

var (
	counter *ZeroCounter
)

func init() {
	counter := NewZeroCounter()
	counter.Init()
}

func NumberOfZero(i Input) {
	counter.Dispatch(i)
}

在这种模式中可以添加整个状态机的初始化动作,每个状态的进入/退出动作。上述代码中ZeroCounter.S1()方法的case EventInitialize分支可以放入状态机的初始化逻辑,每个状态方法的case EventStateEntrycase EventStateExit分支可以放入对应状态的进入/退出动作。这是一个重要的特性,在实际状态机编程中每个状态可以定制进入/退出动作是很有用的。

e. HSM

上述几种模式中,状态之间都是相互独立的,状态图没有重合的部分,整个状态机都是平坦的。然而实际上很多问题的状态机模型都不会是那么简单,有可能问题域本身就有状态嵌套的概念,有时为了重用大段的处理逻辑或代码,我们也需要支持嵌套的状态。这方面一个经典的例子就是图形应用程序的编写,通过图形应用程序的框架(如 MFC, GTK, Qt)编写应用程序,程序员只需要注册少数感兴趣的事件响应,如点击某个按钮,大部分其它的事件响应都由默认框架处理,如程序的关闭。用状态机来建模,框架就是父状态,而应用程序就是子状态,子状态只需要处理它感兴趣的少数事件,大部分事件都由向上传递到框架这个父状态来处理,这两种系统之间有一个直观的类比关系,如下图所示:

Anatomy of a GUI application

这种事件向父层传递,子层继承了父类行为的结构,我们将其称为行为继承,以区别开 OOP 里面的类继承。并把这种包含嵌套状态的状态机称为* HSM(hierarchical state machine)*,层次状态机。

加上了对嵌套状态的支持之后,状态机的模型就可以变得任意复杂了,大大的扩大了状态机的适用场景和范围,如此一来用状态机对问题建模就好比用 OOP 对系统进行编程:识别出系统的状态及子状态,并将逻辑固化到状态及它们的跳转逻辑当中。

那么在状态机实现模式里如何支持嵌套状态呢?从整个状态图来看,状态/子状态所形成的这张大图本质上是一个单根的树结构,每个状态图有一个根结点 top,每个状态是一个树结点,可以带任意多的子状态/子结点,每个子状态只有一个父结点,要表达嵌套状态,就是要构造出这样一棵树。

go-hsm

我用 Golang 编写了一个 HSM 框架go-hsm,设计要点如下:

  1. 用类来表示状态,局部化状态内部变量及逻辑,整棵状态树由具体应用构造
  2. 支持嵌套状态及行为继承,支持进入退出动作,
  3. 支持自定义事件,支持静态和动态两种跳转

它的代码在这里,go-hsm 的使用例子则放在另一个项目go-hsm-examples。由于 Golang 本身的语言特点,有一些地方的实现较其它语言多了一些缺点,比如 Golang 里面的 binding 是静态的,为了将子类对象的指针传播到父类方法,要显式传递 self 指针,子类的接口函数也需要由应用重写。但由于 HSM 本身的灵活强大,go-hsm具有良好的可调整性及扩展性,是一个很好的状态机建模工具,一门灵活有效表达复杂状态机的 DSL(Domain Specific Language)

注:
[1] 《Structure and Interpretation of Computer Programs》一书的中文译本。
[2] 本文内容主要出自 Miro Samek 博士的经典著作《Practical Statecharts in C/C++: Quantum Programmming for Embedded Systems》,其中文译本书名为《嵌入式系统的微模块化程序设计:实用状态图 C/C++实现》,翻译甚差,不推荐阅读。
[3] 各种编程语言里面的条件判断关键字和语句都不尽相同,有的 if 语句带then关键字,有的不带then,有的支持switch,这里将它们简单统称为 if-else/switch 语句。
[4] 本文中所有代码都为 Go 语言代码。
[5] 之所以强调函数是命名的,是因为很多语言支持匿名函数(即 lambda 函数),在嵌套 if-else/switch 模式内部写匿名函数定义对降低代码膨胀起不了作用。
[6] OOP 领域设计模式的流行,源于这本书《Design Patterns: Elements of Reusable Object-Oriented Software》的出版,其中文译本见这里