分享 如何制作编程语言-Bean 语言的设计与实现

lanzhiheng · 2020年04月08日 · 最后由 edyan 回复于 2021年07月05日 · 10870 次阅读
本帖已被管理员设置为精华贴

此文同步于个人博客站点 https://www.lanzhiheng.com/blogs/how-to-make-programming-language-design-and-develop-of-bean

Hacker


Bean 语言今天正式开源,代码托管于Github

Bean commit log


终于要开始写这篇文章了,从开始着手开发Bean 语言到今天一晃半年就过去了。虽说中途也有不少偷懒的时刻,但开发过程还算比较顺利,Bean 语言起码算得上是一门能用的通用目的编程语言了。

即便如此,心中还是有些忐忑,忐忑之心主要源于市面上流行的编程语言都太过于优秀,有丰富的工具库,完善的文档,还有大量优秀的开发者在背后默默地支持着。反观 Bean 语言,只是一个籍籍无名的开发者利用工作的空闲时间一步一个脚印“踩”出来的语言原型,需要改进的地方还很多,并且越往后开发,需要考虑的东西也越多,忐忑之心也逐渐升起。

不过不管怎样,这门语言的首个阶段已经完成。这篇文章主要分享我开发这门编程语言的心路历程,其中包括开发的契机,技术栈选型,词法分析,语法分析,时间的分配,内置工具库的开发,虚拟机的开发,以及自己的一点点感受。

开发之契机,为何取名为 Bean?

要着手开发一个东西,总是会有人问到,开发的初衷是什么?是现在的语言都太糟糕了吗?答案绝对是否定的。现在我们所能接触到的许多编程语言都是大量优秀开发者辛勤工作的成果。像 Ruby,Python 这些广受开发者们喜爱的语言就不必多说了,即便像 Java 这种语法遭受到不少非议的编程语言,你也绝不能忽视它在虚拟机上的造诣。因此,并不存在“市面上编程语言不够好,于是决定自己去开发一门。”这种说法。要切实回答这个问题,估计就只能重复 Linux 的作者常说的那句话了

Just for fun.

参加工作也快 5 年了,学过了不少的编程语言(虽然常用的也就那一两门),自然也好奇所谓的编程语言到底是怎么运作的?为什么我能用它来跟计算机打交道?曾经在一本书上看到过一句话

真正想要了解一只青蛙,传统的解剖不是办法,更好的方式是构造一只青蛙。

要了解编程语言的运作,或许也能够采用同样的策略,于是便决定着手去打造一门通用的编程语言。

为何取名为 Bean?这门语言之所以取名为 Bean,其实跟Mr.Bean没有多大关系。最主要的原因在于自己还在Beansmile工作,而半年前机缘巧合之下,在五月天的一首名为《有些事现在不做,一辈子都不会做了》的歌曲中看到阿信写下了这句歌词

以你为名的小说会是枯燥或是隽永。

当时就想“说不定,我也能开发编程语言。要不就试一下吧,给自己半年的时间。这门编程语言就用豆厂的名字来命名-Bean,说不定会很有趣呢?”村上春树 29 岁那一年突然决定要去写小说也是经历了一个类似这样的瞬间,他称之为epiphany,或许这就是属于我的一个epiphany吧。

技术栈的选型

技术栈的选型,似乎也没什么可选的,肯定要找一门系统级的编程语言作为宿主语言。Java 虽然不能算得上是一门系统级别的编程语言(毕竟隔着虚拟机),但也是一种选择,如今也有不少的编程语言(如:Clojure,Scala)构建于 Java 虚拟机之上,用 Java 作为宿主语言或许还可以免掉自己手写虚拟机的工作,并且还有相应的参考书。不过我思虑再三之后还是决定不用 Java。用真正的系统级编程语言吧,如果到时候真的需要虚拟机的话,自己去开发就好了(也不知道哪来的自信)。

OK,那剩下来的选择似乎也不多了,C,C++,Go,Rust。最后我选择了 C,不选 C++ 的理由很简单,第一眼看上去就难以接受,如此之复杂的语法,很难保证到了最后是自己在写这门语言还是被这门语言给“写”了。业界都喜欢把 C 跟 C++ 放在一起,但是我觉得他们根本就不是一回事。

Go 跟 Rust 目前都很火,我也写过一些,个人觉得会比 C++ 更值得拥有。如果当时没有选 C 或者会用 Rust 来开发 Bean 语言。并不是 Go 语言本身不好,而是自己的个人偏见,因为 Go 无论怎么看都像是一个“富二代”,有个好爹-Google,心理上便更偏向于 Rust 多一些。不过 C 语言是上述语言里面我用得比较多的(大学课程设计你懂的),最后也就决定用 C 了。

既然决定用 C,那么编译工具自然就是GCC,开发工具为Emacs,开发平台为 MacOS。这是所有的技术栈了,没有所谓的框架,脚手架。目录结构什么的自己决定就好了。

词法分析器 - 手写与生成的抉择

万事开头难,要着手开发一门编程语言,一开始还真不知道要如何下手。尝试过去读编译原理方面的书籍,最终都以失望告终,并不是这些书不好,而是理论过于艰深,个人理解能力有限。于是想着与其让自己淹没在知识的海洋中,还不如撸起袖子就是干。稍微从网上了解了一下编程语言的运行步骤,其中大概会包括

Basic flow

从学术角度来说,这个流程图并不严谨。不过毕竟我不是在写学术论文,只列出一些主要部件就好了。很自然的,我会以词法分析器为起点。

稍微去了解了一下现代编程语言是如何做词法分析器的,发现许多流行的编程语言其实都不会手动去实现它,而是采用一种叫做词法分析器生成器的工具,通过设定规则的方式,来生成对应的词法分析器。市面上这类工具很多,似乎不同的语言都会有自己对应的词法分析器生成器,如果你想用 C 语言来开发,那么可以使用Flex,如果你用 Java 来开发便可以使用JFlex

使用起来也十分简便,基本上就是定义正则表达式以及它所对应的 C 语言(或者其他语言)代码片段,这个文件经过对应的生成器处理之后就会生成一份宿主语言的源码文件,而这份源码文件就是词法分析器。里面会包含大量的 goto 语句,代码相当混乱,一般我们不需要直接去读它,从定义的规则来了解它的作用即可。

以这种方式来生成词法分析器比起自己去手写,好处在于能够大大缩减开发周期,并且能够避免掉一部分人为的编码失误。不过性能比起自己手写还是会差一些。既然是第一次开发编程语言,我还是决定自己去实现它,现代编程语言里面也不乏采取这种措施的存在,比如说Lua,于是我的首个词法分析器便以 Lua 的源码作为参考。

所幸之前有过开发 JSON 解析器的经验,如何进行词法分析心里大概也有个底,再配合上 Lua 的源码,基本上没有遇到太大的困难。虽然有 Lua 的源码为引导,但是也不能照搬直抄,这门最快的脚本语言还真不是说笑的,几乎所有的东西都是自己实现,哪怕是简单的数字类型检测,它都是自定义一些函数去检测,而没有使用一些系统自带的工具库。然而对 Bean 来说这种做法就不太必要了,能用系统工具库解决的,我还是尽量采用工具库。况且有些规则也不太一样,比如 Bean 里面我打算像 JavaScript 那样用{}来表示代码块,而 Lua 却更像 Ruby,它是通过end来作为代码块的结束符

function a()
  return 1111
end

print(a()) // => 1111

是否真有点 Ruby 范儿呢?总的来说写词法分析器脑力活其实不多,还是体力活比较多一点。难怪业界的人都倾向于用生成器来完成相关的工作,可能也只有 Lua 这种对性能执着到了极致的才会考虑自己手写吧。或许我下一门编程语言(如果有的话)的词法分析器也会考虑用生成器去完成吧。

词法分析器的工作方式,总结起来大概就是Char By Char(逐个字符) 地去读字符流,然后判断前后字符的结合性,并返回匹配的 Token。

大概是下面这种流程

自动机

它们分别能够匹配关键字 if字符串"hello"数字 1024,这种用于识别字符组合的机器我们一般称之为确定有限自动机(名字倒是很吓人)。其实写词法分析器就是实现一个又一个这样的机器。不过也有些特殊情况,比如上面的数字和字符串,我们只能够识别"hello"1024这种特殊序列,这也是确定有限自动机的局限性,我们不可能针对每一种字符串或者数字组合都写一个自动机,这种时候我们可能需要通过循环或者条件语句来让我们的机器能够识别出各种组合的数字或字符串,这种新机器叫做非确定有限自动机非确定有限自动机是多个确定有限自动机的集合。而上面提到的生成器,其实就是通过正则表达式的规则模式来生成对应的非确定有限自动机

词法分析之后自然需要产出某种东西,而Token就是词法分析器的产物。Token包含的东西很简单,基本上就是类型以及语义值。其中Token的类型直接用枚举来表示即可,而语义值则用来记录对应Token的语义信息。

举个例子,我们平时用到的关键字ifelsewhile其实都不需要语义值,我们只需要存储它的类型信息就可以了。而像上面的数字1024所对应的Token除了要知道它是数字类型之外,还需要存储它的值,因为不同的数字就会代表不同的数值,这也是语义信息的作用。

以上就是词法分析阶段,而它的产物就是Token流,这个流会在接下来的语法分析阶段由语法分析器进一步处理。

语法分析器的制作

如果说词法分析器是一个字符一个字符地处理源码文件,并生成对应的Token流。那么语法分析器就可以简单看成是从流中逐个读入Token,并根据这些Token的组合识别出不同的语法,生成抽象语法树,供解析器处理。

不过稍微看了下一些语言的实现,如Luamjs(一款嵌入式的 JavaScript 以性能为主),发现他们都不直接生成抽象语法树了,而是在语法分析阶段把对应语法编译成指令流,接下来把指令流交由虚拟机去执行。不过我还是打算先生成抽象语法树,至于这棵树要怎么处理就以后再说吧(总能找到办法的吧)。

PS: 我甚至去 Github 上翻 Ruby1.8 以前的源代码(在那之前还没采用虚拟机),找找线索,不过 Github 上的相关记录似乎已经被抹去了。

在这里我决定按自己的理解去做,毕竟树我还是知道长什么样的,不考虑内存使用以及性能的情况下,用语法分析器识别不同的语法进而生成不同类型的树。这种做法似乎最直观也最简单,那就这么办吧。以下是 Bean 语言抽象语法树结构的定义

typedef struct expr {
  EXPR_TYPE type;

  union {
    bean_Number nval;  /* for VKFLT */
    bu_byte bval;
    TString * sval;
    Function * fun;

    struct {
      struct expr * condition;
      dynamic_expr * if_body;
      dynamic_expr * else_body;
    } branch;

    struct {
      int op; // Store the TokenType
      struct expr * left;  /* for   TK_ADD, TK_SUB, TK_MUL, TK_DIV, */
      struct expr * right;
      int assign; // Need reassign
    } infix;

    struct {
      struct expr * condition;
      bool firstcheck;
      dynamic_expr * body;
    } loop;
  }
  ...
}

每个表达式都有一个对应的 union 的成员,可以根据不同的类型来做不同的操作。对于像数字,字符串这样的比较直观的表达式我直接在 union 上面存储它的值或者指针。而像代码块这种不能事先预估的语法结构,我则采用了动态的数据结构来存储,当达到一定阀值的时候会自动扩容。

语法分析结束之后,便会得到对应的抽象语法树。举个简单的例子,比如表达式100 + 300 - 33 * 66经过解析之后便会得到一颗这样的语法树。

语法树

至于这些树最后要怎么变成可运行的代码,容我下一章再谈。

TDOP-Top Down Operator Precedence

其实要写一个简单的语法分析器并不困难,我个人觉得比较麻烦的是处理操作符优先级问题的时候,比如一段算数表达式100 + 300 * 333我们是解析成(100 + 300) * 333还是100 + (300 * 333)?LISP 会比较好处理一些(反正有的是括号,都不需要考虑这种问题)。不过在一些常规语言里面这个坑还是逃不掉。针对这种情况的处理方式据说有很多,我采用了比较简单的一种,叫做 Top Down Operator Precedence(自顶向下算符优先分析法)。《JavaScript:The Good Parts》的作者曾经发表文章详细谈论过它。

原理大概就是给每一个操作符附上权重,优先级较高的操作符权重较大。每次解析到操作数对应的Token时,就会比较该操作数前后两个操作符的权重,如果前面的操作符权重较高则先跟前面的部分结合,否则的话就交给后面部分去递归。借此来构造出符合优先级规则的语法树。

语法分析器生成器

跟词法分析器一样,语法分析器也有相关的生成器,用起来也相当方便,在 C 语言项目里面,可以使用Bison来作为语法分析器的生成器,在它之前一般用得比较多的是Yacc。一些编程语言(比如:Ruby)的源码库中一般会包含一份.y为后缀名的文件,其实就是给这类工具使用的。主要通过巴科斯范式来描述语法规则,这些规则之间可能会相互归约,语法分析器生成器便是根据这些语法规则来生成对应的语法分析器。

以下是 bison 官方文档提供的demon,主要用来解析逆波兰表达式的语法。

input:
  %empty
| input line
;

line:
  '\n'
| exp '\n'      { printf ("%.10g\n", $1); }
;

exp:
  NUM
| exp exp '+'   { $$ = $1 + $2;      }
| exp exp '-'   { $$ = $1 - $2;      }
| exp exp '*'   { $$ = $1 * $2;      }
| exp exp '/'   { $$ = $1 / $2;      }
| exp exp '^'   { $$ = pow ($1, $2); }  /* Exponentiation */
| exp 'n'       { $$ = -$1;          }  /* Unary minus   */
;
%%

举个例子,根据上述模式3 10 +会转换成对应的 C 语言表达式3 + 10,最后当遇到换行符号的时候则用结果替换掉打印语句的占位符$1。以打印出3 + 10的结果13

可见采用生成器来制作语言的语法分析器还是比较方便的。可能最需要花心思去处理的就是一些二义性的问题,比如两条语法规则相冲突的时候应该采用哪一个呢?语法规则越是多的语言这种情况出现得越频繁。Ruby 的语法规则表就相当长,如果不用生成器而是手动去实现的话,工作量难以想象,也容易引发 Bug。

或许也就那些语法规则还算简单的,并且对性能要求较高的语言会采用手写的方式来制作语法分析器吧。比如说Luamjs。Bean 语言也在此列,不过 Bean 在这个阶段并不会太过于在意性能(我更在意"Hello World"什么时候能够被打印出来),这门语言的语法不会太复杂,手写语法分析器工作量应该还算能够接受。

要是能够早点看《松本行弘 - 编程语言的设计与实现》这本书就好了。看了这本书之后才发现,Matz 开发的一门新语言 Streem 就没有采用虚拟机,如果当时参考它的源码应该能开发得更有信心一些。幸好后来发现,我的做法是合理的,没有走到一条黑路上去,这点很值得欣慰。

解析器 - 解析抽象语法树

语法分析之后我们就会得到抽象语法树,只要有对应的解析器,就可以直接拿去运行了。只不过刚开始我并没有这种概念,也没找到能够参考的代码(当时还没读 Matz 的新书),心里一点底都没有,只好继续摸黑前行。

这个时候突然想到许多的动态语言都有一个叫做eval的语句,能够接收字符串形式的代码,并返回当前代码计算后的值。而eval中的代码我们一般称之为语句,这些所谓的语句在语法分析阶段我已经把它们转换成抽象语法树了,不同类型的语句都会有对应类型的语法树。那么只要针对类型开发出对应语法树的eval函数(可以称之为eval_xxxx),那么当他们整合在一起的时候就是一个通用的eval了,而只要利用这个通用的函数逐句去解析所有的语句,似乎就能够拥有一台功能简单的解释器。

简单地把每条语句运行的过程看成如下样子

eval('100 + 22')
eval('if(a < 10) { a ++ }')
...

进一步转换就是

eval_infix('100 + 200')
eval_if('if(a < 10) { a ++ }')
...

100, 22这种数值的信息本身就是放在抽象语法树里面的,而变量a的值则需要从当前运行的上下文中去获取。接下来再看具体一点的例子。假设我有一颗这样的语法树,类型为INFIX

var num1 {
  type: NUMBER,
  value: 1
}

var num2 {
  type: NUMBER,
  value: 100
}

var tree = {
  type: INFIX,
  left: num1
  right: num2,
  op: TK_ADD
}

那么其对应的解析函数就是

function eval(tree) {
  switch(tree.type) {
    case(INFIX): {
      return eval_infix(tree)
    }
    case(NUMBER): {
      return tree.value
    }
    .....
  }
}

function eval_infix(tree) {
  switch(tree.op) {
    case(TK_ADD): {
      return eval(tree.left) + eval(tree.right)
    }
    case(TK_SUB): {
      return eval(tree.left) - eval(tree.right)
    }
    .....
  }
}

即便有点粗暴,不过照这个思路做下去,这门编程语言就运行有望了。现实也如我预期一般完满。Hello World终于被打印出来了。

> print("Hello World")
Hello World
=> nil

不得不说,当第一个"Hello World"被打印出来的时候,心情真的是异常地激动,毕竟开发词法分析器跟语法分析器的时候完全就不理解自己做的这些前置工作对于一门语言的运行到底有何意义。直到第一个"Hello World"从终端打印出来之后,突然有一种豁然开朗的感觉。

基本类型与内置工具库

在聊虚拟机之前先聊聊这门语言的基本类型以及工具库吧。Bean 语言是一门基于原型的面向对象编程语言,大概结构图如下:

Prototype

这门语言设计的初衷是尽可能地简单,所以一开始并没有打算做任何的工具库,只打算实现上图中的那些基本的数据类型(异色)。其中就包括NumberStringArrayHashBooleanFunction,或许称他们为原型更贴切一些,他们本质上其实都是散列表,主要用于存放对应类型的方法或者属性。在 Bean 语言里nil就是空值,它是一个特殊值,位于原型链的顶端不包含任何属性,不过它有自己的类型。

> typeof nil
=> "nil"
> typeof true
=> "boolean"
> typeof 1
=> "number"
> typeof "string"
=> "string"
> typeof []
=> "array"
> typeof {}
=> "hash"
> typeof fn() {}
=> "function"

这样的设计或许算不上高明。这门语言从开发至今一直都是摸着石头过河,有些设计并没有经过深思熟虑,都是在开发过程中不断地调整才得出来的。不管怎么说这也是目前为止我个人比较能够接受的一种设计了(如果你知道之前的设计有多糟糕的话)。

开发到一半之后觉得还是得有一些内置工具库,于是我用 C 语言实现了DateRegexMath这三种比较常用的工具库(看名字应该就知道它们的用处)。而名为JSON的工具库(主要用于解析 JSON 数据)我是直接采用 Bean 语言去实现了,在每次脚本运行之前都把它加载进去。因为我很早之前就用 JavaScript 写过一个 JSON 的解析器,把它的逻辑用 Bean 语言的代码写一遍是很容易的事情,比起用 C 语言去开发能省下不少的功夫。

原本还有HTTP工具库,用来发送网络请求,并且我已经实现了简单的 CURD 功能。这个库需要依赖第三方的工具libcurl,C 语言项目似乎都不怎么倾向于采用包管理,处理起来要麻烦一些。而且这个工具实用性也不高。因此,即便我已经花费了不少时间(大概一周多)去开发,还是忍痛暂时把它给去掉了。

虚拟机

终于要开始讲虚拟机了,最早接触的虚拟机应该是操作系统的虚拟机,折腾电脑的时候总会想着在自己的 Windows 操作系统上面装一个 Linux 的虚拟机(你大学时是否也这样装过逼?)。后来开始慢慢接触一些编程语言,发现虚拟机并不仅仅限于操作系统,一些编程语言也有对应的虚拟机。最广为人知的应该要数 Java 的虚拟机了。Java 语言的包之所以能够实现跨平台,在某种程度上还应归功于它的虚拟机。

语言的虚拟机

曾经在 Matz 的书籍中以及一些 Ruby 相关的文章中了解过

为什么 1.8 版本之前的 Ruby 会这么慢?

Matz 的原话是这样的

从缓存访问的立场来看,语法树解析器是最糟糕的。构成语法树的节点都是一个个单独的结构体,各自的地址不一定临近,也不会连续。这就导致难以事先将接下来要访问的内存空间读入到缓存(他指的是 CPU 的缓存)中。

如果将语法树转换成指令序列,并储存到连续的内存空间上,那么内存访问的局部性就会有所增强,性能也会因为缓存的作用而得到极大的提升。

是不是真的如此我也没考证过,不过我还是把虚拟机给开发出来了。我只知道在 1.8 版本之后引进的虚拟机对于 Ruby 的提速有很大的帮助。其实不仅仅是 Ruby,现代的很多编程语言都有虚拟机,其中就包括 Python,Node.js 等等。我上面提到的 mjs 跟 Lua 其实也都有对应的虚拟机实现(所以我那时候已经很难找到会直接解析抽象语法树的编程语言来作为参考了)。

这些都是解释型语言,但是他们内部却具有编译型的特征。而像 Java,C,Go,Haskell 这些编译型语言,也有了像范型,宏机制,类型推导这些偏动态的特征。所以才说在 21 世纪,已经很难一刀就把语言分割成解释型编译型两大阵营了,我所理解的现代编程语言许多都同时具备了这两种特征,只是所占的比重不同罢了。

虚拟机的实现

虚拟机的开发始于 2020 年 3 月 18 日,也就是半个月前,当时 Bean 语言的基本语法已经全部测试通过,本来想着让自己悠闲一点,找找 Bug,静待发布日,然而虚拟机的开发却是启动项目之初就定下来的计划。

Plan

也就是说虚拟机没完成,这门语言的“马拉松”就不算跑完,于是便决定利用剩下的时间来完成虚拟机的开发(如果当时对编程语言有更多的了解的话,先开发垃圾回收器会是更好的选择)。

编程语言领域较为流行的虚拟机主要分两种栈式虚拟机以及寄存器式虚拟机。据 Matz 统计,目前 Ruby 的 YARV,Java 的 JVM,Python 的 CPython 都是栈式的虚拟机,而 Lua,mruby 以及 Dalvik(Android 上的 Java)则采用了寄存器式的虚拟机。

似乎在一些性能要求较高的领域中,寄存器式的虚拟机应用会比较多一些,确实比起栈式的虚拟机它能给语言带来更好的性能,比起后进先出的的栈,它的可操作性也更强一些,一些数据交换的工作也更为方便。不过栈式的虚拟机虽说“操作困难”,但是胜在简单易懂,而且我对 Bean 语言的性能要求不高,所以一开始就打算采用栈式的虚拟机。

所谓虚拟机,就是模拟硬件的行为,而跟硬件离得最近的语言并不是 C 语言,而是汇编语言(当然机器语言会更为接近一些,但它真的能算得上是一门“语言”吗?)。汇编指令经过汇编器的处理直接转换成用于操作硬件的二进制,这些指令我们一般称为指令集。而我们要开发的虚拟机在我看来就像是汇编器,它需要能够理解我们自己定义的一套“汇编指令”,并把指令转换成宿主语言,利用宿主语言去跟操作系统打交道。

要把指令系统设计得足够合理,需要兼顾很多东西,既要省内存,还要考虑性能方面的影响。Matz 在书中介绍 Ruby 如今的指令系统就由多种模式组成,每个指令长度都是 32 位,其中会包含操作数,操作符,以及对应的状态位来识别不同的模式,这种做法在取指优化上看是一个很不错的设计,虚拟机每次都会从指令流中取固定的 4 个字节的数据。他说这种指令已经不能称之为字节码了,称为字码更合适。

不过对于 Bean 语言,留给开发虚拟机的时间并不多,没有太多的时间做指令层面的优化(关键是我也不懂),所以我采用了 mjs 的策略 - 使用真正的字节码,每条指令的长度都为一个字节,虚拟机根据不同的指令从字节流或者是从数据栈中去获取对应的操作数。一个字节有 8 位,能够表示 256 个数字,也就是能够表示 256 种不同的指令,这对 Bean 来说完全足够了。

不过字节流中的操作数处理方式跟 mjs 稍有不同,它是自己设计了一套编码机制,不同类型的数据可能会以不同的长度来写入到指令流中。比如操作数为数字55的时候,一个字节就足够保存,所以最终写到指令流中的数据也就是一个字节的长度。虚拟机执行的时候便以对应的解码方式把数据从指令流中拿出来。

毕竟 Bean 语言还处于试验阶段,所以我决定采用定长写入的方式,不管它是什么,我统一把它们的指针以 8 个字节的长度存入到字节流中,每次取的时候也是取 8 个字节 (其实是每次取一个字节,取 8 次),虽然会多耗费一些内存,读取次数也会增多,但大大减轻了我的调试负担,不过后期有时间的话我还是会考虑改成像 mjs 这种变长的处理方式。

那么虚拟机是如何解析指令的呢?大概像下面这样

while(true) {
  switch(GET_OP()) {
    case OP_ADD: {
      value1 = POP();
      value2 = POP();
      PUSH(value1 + value2);
      break;
    }
    case OP_OTHER: {
      value1 = DECODE_FROM_STREEM();
      value2 = POP();
      PUSH(special_handle(value2, value1));
      break;
    }
  }

  if (end) break;
}

为了节省篇幅,我用 JavaScript 来描述这个过程,其实也没什么花哨的技术,都是一些平时大家看不上眼的很普通的写法。每次都用GET_OP函数来获取下一条指令,然后判断这条指令是什么,并进行相应的操作。而当前指令的操作数既可以从栈中获取 - 通过POP,也可以从指令流中获取 - 通过DECODE_FROM_STREEM,最后把操作结果用PUSH函数放回栈中。当然栈上的数据最开始也是来自于指令流,有对应的指令把它堆到栈上,而这个栈其实就相当于数据的中转站

基本结构还是蛮容易实现的,不过要真正让整门编程语言在上面跑起来,还真得花费不少的功夫。Bean 语言目前没有引入异常机制,不过函数调用,条件语句,循环这些基本的东西还是有的。虽说语言本身很简单不过实现递归的时候也把我调试得够呛,因为其中还涉及到调用栈,作用域的问题。难怪 Matz 也说

理论跟实践之间还是隔着一条巨大的鸿沟。

感慨

虽然已经历时半年,不过这门语言的实用性还是不够高,开发过程都是走一步看一步,难免存在着一些决策上的失误(可能不少)。比如垃圾回收应该尽早规划,我却花了许多时间去开发内置工具库。应该先开发文件操作相关的工具函数,我却受了客户端 JavaScript 的影响,先去开发网络请求相关的东西。不过还好,总体来看我还算是比较满意。以后要怎么优化就再说咯。

在此也不得不感慨要打造一门通用编程语言的难度,需要考虑的东西实在太多了。宿主语言的特性无疑也是需要列入考虑范围之内,我所用的宿主语言是 C 语言,需要手动分配并且释放内存,而自己在之前也没有在大项目中使用过 C 语言,对于哪些指针应该回收哪些指针不需要回收心中缺乏尺度,偶尔也会出现一些内存管理不善导致的异常,虽然已经尽力去修复,但偶尔还是会引发内存问题,缺乏垃圾回收器始终让人感觉少了一层保障。

再者,也感受到自身的渺小,我们日常所用的编程语言,诸如 Ruby,Java,Python 一开始都是由作者发明,接着便有成千上万的开发者参与维护至今,我们才能够拥有如此趁手的“工作伙伴”,这并非凭一己之力就能够做到的。在此,应该致敬开源社区以及那些不断促进社区发展的开发者们。

平时批判一门语言都是很单纯地只是从语法,或者流行度来判断,现在看来还是很片面的。以前我不了解 Java 的文化,不了解虚拟机的价值,总是对这门语言嗤之以鼻。然而当自己真正去开发虚拟机的时候才明白这项工作要考虑的东西有多少。虽说要开发出一款能用的虚拟机不算太困难,但是要做出一款兼顾性能,内存使用并且可靠性高的虚拟机不仅需要投入大量的人力,还需要有庞大的理论知识体系来支撑。比方说,要用哪些数据结构?要用什么算法?这里该用一个字节,两个字节还是四个字节?都是需要斟酌的事情,这些工作并非一个人就能够统筹兼顾的。

突然想起 Linux 的作者林纳斯似乎曾经说过

如果我知道开发操作系统的工作是这么繁重,当时可能就不敢开始了。

对我来说开发一门编程语言也是如此,如果当初知道开发一门通用语言需要考虑的事情如此之多,可能真的就不敢开始了。不过第一阶段总算是告一段落,是该好好休息一下并美美地睡上一觉了。

尾声

开发语言这半年,基本上过着深居简出的生活,其中也会有跟朋友相聚的时候,不过几乎不会提起自己在开发语言这件事情,毕竟有些路还是得自己去走吧,也不想听到一些反对的意见(虽然可能更多是支持)。村上春树在《悉尼》中说过

孤独这东西具有毒性,会腐蚀人心,也拥有强大而辉煌的价值,要想获得其中许诺给我们的价值,我们就必须学会与这种毒性共生的技巧。需要紧张感和注意力。只要略一松懈,那毒性就会一口咬过来。就像狡猾的蛇。

经过这半年的旅途也能够深深体会到他这句话的深意。在孤独的辉煌价值下我创造了一门编程语言,而孤独的毒性之下我牺牲了一些睡眠时间,以及与朋友家人相聚的时间(特别是最后冲刺这一个月),也经历过一些绝望的时刻。不过总体而言拿捏还算得当,该去找朋友好好喝上一杯了。

参考资料

hooopo 将本帖设为了精华贴。 04月08日 08:19

可以呀!老伙计!

Good Job ! 年轻就是好。

luaxlou 回复

已然不甚年轻。😅

oatw 回复

哈哈。彼此彼此。

乐豆人才备出,支持一波。👍

lyfi2003 回复

哈哈 thx。

我们团队在设计 DSL 语法和编译器,以及对应的 IDE 工具,楼主要不要了解一波? https://www.v2ex.com/t/656785

dannnney 回复

哈哈,这工资水平让我很愿意了解一下。😂 不过恕我理解能力有限,你们的业务好像有点多,我都没看出来重点是在哪里。是要做一款代码生成工具吗?通过写你们自定义的一套 DSL 来生成多个平台的代码?有没有相关产品链接贴一下?

@dannnney 有趣的项目,有木有介绍或者 demo?

lanzhiheng 回复

是的,通过自定义的一套 DSL 来生成多个平台的代码。现在有正在研发的重点,具体可以私聊。没找到 BSS 私信的入口,发你微博私信了。

aldrich 回复

项目还没有公开上线,咱们可以私聊哈。

dannnney 回复

收到,微信账号已发。

lex 生成语法形式?

zzz6519003 回复

lex 只是做词法分析。根据一堆字符的组合,来判断它到底表示的是一门语言里面的哪些关键字或者是字面量,并把信息存储在 Token 中。

这玩意还是解析正则文法,然后 NFA 再到 DFA 吧,手撸 BUG 多。。。

pynix 回复

是啊,手撸 Bug 多,还费劲。

matz 的书给你启发了么 hhh

用 Bean 实现 Bean 可以么

绝望的时刻?

zzz6519003 回复

😅 遇到瓶颈就挺绝望的。

@Rei 21 楼,灌水的,赌博网站广告。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号