17611538698
webmaster@21cto.com

我写了一个编程语言,你也可以做到!

编程语言 0 486 2024-04-09 10:52:29

图片

各位好。我一直在专注于开发一个称为“Pinecone”的语言,已经持续6个月的时间。

我还不能说它已经成熟,但是在一些场合下,它已经有足够多编程语言的特性了。

例如,它有以下主要特征:

  • 变量

  • 函数

  • 用户自定义的结构体


诚然,我现在还不是一个专家。

在我刚刚创建这个项目的时候,我自己对所做的事情毫无头绪、没有方向。但是我并没有放弃。

在创建新编程语言时,我的级别是0,只是在网上找了一些资料,但也没有遵循它们所给的“最佳实践”等建议。

结果我还是创造出了一个完整的、新的编程语言,并且它能够工作。所以,我一定做了一些正确的事情。

因此在本文里,我将深入展示 Pinecone ,如何把源码成为魔法的过程。我也会谈已经做出的一些权衡,以及为什么我会做出那些决定。

图片

开始入门

当我告诉其他开发者,我正在写一门编程语言时,常常会得到这样的回应:

“我都不知道我该从哪儿开始”。

如果你听后的反应也是这样,我将通过一些已经尝试过的决定与步骤,告诉大家如何开始创建一门新的编程语言。

编译型 vs 解释型语言

各位知道,编程语言主要有两种类型:编译型与解释型。

计算机的编译器会计算出一个程序即将执行的操作,将其转换为“机器码”(计算机可以运行的二进制格式,执行速度非常快),然后保存它以便在稍后执行。

一个解释器逐行分步执行源代码,弄清楚它在做什么。

从技术上讲,任何语言都可以被编译或者解释,但是一种或另一种语言通常对于特定场合更有意义。

解释型语言往往更加灵活,而编译型语言往往具有更高的性能。

这些只是解决复杂问题前的预热。

我非常重视性能,也看到不少缺乏高性能和简单性的编程语言,所以将 Pinecone 定位于编译型。

这是需要尽早确定的重要决定,因为很多语言设计者决策受到影响(例如,静态类型对于编译型语言来说是一个很大的优势,但对于解释型语言而言并不是那么重要)。

尽管 Pinecone 是按照编译型设计,但它也有唯一一个可运行的且功能完整的解释器。

选择一种语言开发

我知道这有点像是个元数据,但编程语言本身就是一个程序,因此你需要用一种语言来编写。我选择了 C++ ,因为它的性能和庞大的功能集丰富。另外我自己其实很喜欢使用 C ++ 完成工作。

如果你正在编写一种解释性编程语言,那么在编译语言(如 C、C ++ 或 Swift )中编写将是有意义的,因为解释型语言中的性能损失及其对应的解释器将会更加复杂。

如果你打算使用编译型,较慢的语言(如 Python 或 Java )是更为可接受的。编译时间可能很糟糕,但在我看来运行时间的差别并不是很大。

高级设计

一门编程语言通常被构造为一种线性管道。也就是说,它通常包含几个阶段。每个阶段的数据都会以明确的方式来格式化,还有将数据从这一阶段转换到下一个阶段的功能。

第一个阶段是一串包含了整个输入源文件的字符串。最终阶段是可以被运行的东西。当逐步完成 Pinecone 管道的时候,这一切就会变得清晰起来。

Lexing 词法分析

图片

大多数编程语言的第一步是词法分析或者分词。 

“Lex” 是词法分析的缩写,这是一个非常棒的词,是将一大堆文本分解成多个符号。“tokenizer” 这个词则更有意义,但是“词法分析”说起来很有趣,我经常使用它。

标记

标记或记号是编程语言的一个单元。标记可以是一个变量或函数名称,也可能是一个操作符或者数字,因此也称做标识符。

词法分析器的任务

词法分析器将包含源码的文件作为输入字符串,输出包含标记符号的列表。

流水线(编译过程)后面的阶段将不再参考这些字符串源代码,因此词法分析器必须产生所有后面各个阶段所需要的信息。

之所以会有这样相对严格的格式设计,是因为这个阶段词法分析器需要做一些工作,比如移除注释或检测标识符或数字等。

如果你将这些逻辑规则放在词法分析器里,那么在构造语言的其它部分时就不必再考虑这些规则了,并且可以方便地在同一个地方集中修改这些语法规则。

Flex

开始写这个语言的第一件事是定义词法,尽可能的简单。

生成词法的小工具称为Flex。只需要传入一个指定描述语言词法格式的文件,Flex就会生成一个C语言语法的代码。

我的决定

我仍然决定保留最初自己写的词法分类器。主要是因为我没有看到Flex特别大的优势,至少在添加依赖和完成复杂的构建没有达到我的要求。

而我自己写的词法分析器只有几百行代码,几乎没有发现什么Bug。后来我继续迭代它,又增加了很多的灵活性,比如在不编辑多个文件的情况向新语言添加操作符。

语法分析器

图片

管道流程的第二阶段就是语法分析器。

语法分析器把标识符列表解析为一个带有结点的树,用于存储这种数据的树称为抽象语法树,即Abstract Statement Tree,简称AST 。 

最后,在 Pinecone 的抽象语法树中不会包含任何标识符类型信息,它就是一个简单的结构化的标识符。

解析器的作用

解析器负责将结构添加到词法分析器,并产生有序列表中的令牌(Token)。为了防止歧义,解析器需要考虑括号与操作的顺序。

简单的解析运算符并不怎么困难,但随着更多的语言结构的添加,语法解析将变得非常复杂。

Bison

接下来,有一个决定将涉及第三方解析库,这便是是 Bison。Bison 很像 Flex,我们使用存储语法信息的自定义格式编写文件,然后 Bison 使用该文件生成将执行解析的 C 程序代码。 

但是,这次我仍然没有选择使用 Bison。

为什么自定义更好

在词法分析器中,我仍然决定使用自己的代码。首先,词法分析器是一个小程序,如果我自己不写,感觉就像不会写我自己的“left-pad”一样愚笨。

但是语法解析器是另一回事。我的Pinecone解析器实现目前是750行,我写了三次,因为前两版本都是垃圾。

我做出这样的决定原因有很多,虽然不算顺利,但大部分都是正确的。

总结下来,它的主要内容如下:

  • 最小化工作流中的上下文切换

    C ++和Pinecone之间的上下文切换是不够的,不会抛出Bison的语法

  • 保持构建简单

    每次语法改变Bison必须在构建之前运行。这可以是自动化的,但是在构建系统之间切换时会变得很麻烦

  • 我喜欢构建酷的东西

    为什么我自己决定做一个中心角色?自己完成解析器可能不是微不足道的,但它是完全可行的


一开始时,我并不完全确定这是否可行,但是我对Walter Bright(C ++的早期版本的开发者,D语言创造者)不得不说的是:

有一点更有争议的是,我不会因为词法分析器或解析器生成器和所谓的”编译器的编译器“浪费时间,这些太浪费生命。编写词法分析器和解析器只是编写编译器的一小部分工作。


使用一个生成器将花费与编写一个手工一样多的时间,它将把你与生成器(在将编译器移植到一个新平台上非常重要)相结合。


生成器也有时候会发出糟糕的错误信息和不和谐的声音。

行为树(Action Tree)

图片

以我自己的理解,所谓的‘行动树' 是最类似于 LLVM 的 IR(中间层表示)。

这是我花了相当长的一段时间才弄清楚的,行为树和抽象语法树之间有一个细微但非常重要的区别,这也促成了解析器的重新编写。

行为树 vs AST

从简单的原理上来讲,行为树是带有上下文的 AST。

上下文是一个函数返回类型的信息,或者两个地方使用的变量实际上是相同的变量。因为它需要弄清楚并记住所有这些上下文,生成行为树的代码需要大量的命名空间查找表和其它的内容。

来运行行为树

当我们有了行为树,运行代码就变得容易了。

每个行为节点都有一个函数“execute”,它接受一些输入,不管行为应该如何(包括可能调用子行为),都会返回行为的输出。

这是行为中的解释器。

编译的选择

稍等一下,Pinocone 不是应该先编译吗?是的,但是编译起来要比解释处理复杂的多,有以下几种解决方案:

新开发一个编译器

听起来是个好办法,我喜欢创造东西,早就想好好研究下编译领域了。

但是,写一个编译器并不是将语言的每个元素翻译成机器代码这么简单,因为有很多不同的架构和操作系统,个人想要编写一个跨平台的编译器不切实际。

即使是 Swift 团队的 Rust 和 Clang 也不想从头开始编写,他们的办法是...

LLVM

LLVM 是一个编译工具集,基本上就是一个库,可以把你的编程语言编译成可执行文件,看似是完美的选择,所以我马上使用了它,但不幸的是当时并未意识到水有多深。

LLVM 即使没有汇编语言那么难,也是一个异常庞大的库,几乎没法使用。即使他们有很好的帮助文档,但是我觉得在完全使用 LLVM 实现 Pinecone 之前,我还要多积累些经验。

转译

我想快速编译 Pinecone,所以我转向了一种可行的方法:转译。

我写了一个 Pinecone 到 C ++ 转译器,并添加了使用 GCC 自动编译输出源码的功能。这个目前适用于几乎所有 Pinecone 程序(但也有例外)。它不是一个特别便携或可扩展的解决方案,但是个可用的临时解决方案。

未来

假设我继续开发 Pinecone,它迟早将得到 LLVM 的编译支持。怀疑无论我做了多少工作,转译器永远不会完全稳定工作,LLVM 的好处则很多。问题是什么时候我才能有时间在 LLVM 中做一些示例项目,并掌握它。

在此之前,解释器对于微不足道的程序是非常好的,并且 C ++ 转译适用于大多数需要更多性能的时候。

图片

结语

最后,我希望我所编写的语言对你来说简单明了。

如果你想自己做一个,我强烈推荐借鉴它。当然,还有很多实现细节需要弄清楚,这里的大纲应该对大家有所帮助。

以下,是我给出的入门总结建议:

  • 如果有多的疑问,请先选择解释型

    解释型语言通常更易于设计、构建和学习。如果你确定你想要做的是编译型语言,我并不会阻止你尝试编写,但持观望态度;

  • 当谈到词法分析器和解析器,选择任何你想要的;

    这里有很多自己编写和反方的有效论据。最后,如果你给出了自己的设计,并以合理的方式实现了一切,这些并不重要;

  • 从本文管道中的问分学到一些技巧

    我在设计管道时有很多尝试,包括遇到错误。我试图消除AST,将AST变成action树,以及其它糟糕的想法。这个管道是可以工作的,所以不需要改动它,除非你有一个更好的主意;

  • 如果你没有时间或动机来实施复杂的通用语言,请尝试像Brainfuck一样实现一个深奥的语言。这些解释器可以短到几百行。


实际上,我在Pinecone的实现过程中是做了一些糟糕的决定的,但是我已经重写了大部分受这种错误影响的代码。

现在,Pinecone已经足够好了,特别是它的功能,可以接受改进。编写Pinecone对我而言是一项非常受益和愉快的经历,它才刚刚开始。

如果各位朋友有兴趣,可以看看 Pinecone 的引导页(landing page)或者它的 GitHub repo。

引导页:https://pinecone-lang.herokuapp.com/index.html

GitHub:https://github.com/william01110111/Pinecone

21CTO总结:

为什么要编写自己的编程语言?

我们真正应该问的问题是为什么要设计自己的语言?可能的答案:

  • 有趣

  • 拥有自己的编程语言真是太酷了

  • 这是一个很好的副业项目

  • 心理模型


虽然这三个可能都是正确的,但还有一个更大的动机:拥有正确的心智模型。当学习第一种编程语言时,你会通过该语言的视角来看待编程。快进到你的第二语言,这似乎很难,你必须重新学习它的语法,而这种新语言的做法有所不同。

使用更多的编程语言,你会意识到这些语言具有共同的主题。Java 和 Python 有对象,Python 和 JavaScript 不需要声明类型,这样的例子不胜枚举。进一步深入研究编程语言理论,你将了解现有的语言结构 - Java 和 Python 是面向对象的编程语言,而 Python 和 JavaScript 是动态类型的语言。

编程语言实际上是建立在你可能没有听说过的旧语言中的思想之上的。Simula 和 Smalltalk 引入了面向对象编程语言的概念。Lisp 引入了动态类型的概念。而且不断有新的研究语言出现,引入新的概念。一个更主流的例子:Rust 将内存安全构建到低级系统编程语言中。

构建自己的语言可以帮助我们更加批判性地思考语言设计,因此,再学习一门新语言时就会容易得多。

什么是编译器?

当你已经设计了一种奇特的新语言,它将彻底改变世界,但有一个问题。你如何运行它?这就是编译器的作用。我们首先回顾一下 19 世纪的电报时代。这里我们有这个奇特的新电报机,但是我们如何发送消息呢?同样的问题,不同的域。电报员需要接收语音并将其转换为莫尔斯电码,然后敲出代码。操作员要做的第一件事是理解语音 - 他们将其拆分为单词(词法分析),然后了解这些单词在句子中的使用方式(解析) - 它们是名词短语、从句等的一部分。他们通过将单词分类或类型(形容词、名词、动词)来检查句子是否有意义,并检查句子是否具有语法意义。最后,他们将每个单词翻译(编译)为点和划(莫尔斯电码),然后沿通信线路传输。

编译器的工作方式与电报机制相同,只是我们必须明确地对计算机进行编程才能做到这一点。上面的文章描述了一个简单的编译器由 4 个阶段组成:lex、解析、类型检查,然后翻译成机器指令。电报操作员还需要一些额外的工具来实际敲出摩尔斯电码;对于编程语言来说,这便是运行时环境。

在实践中,电报操作员可能会构建一些自己知道的如何转换为摩尔斯电码的速记符号。现在他们不是直接将语音转换为莫尔斯电码,而是将语音转换为标记,然后将标记转换为莫尔斯电码。在许多语言中,你不能直接从源代码到机器代码,还要逐步删除语言结构(例如 for 循环),直到我们剩下一小组可以执行的指令。编译器阶段分为前端、中端和后端,其中前端负责大部分解析/类型检查,中端和后端简化和优化代码。

编译器设计选择

可以根据上面文章的类比来构建很多语言和编译器的设计:

操作员是否在传输时将单词即时翻译为莫尔斯电码,还是预先将单词转换为莫尔斯电码,然后再传输莫尔斯电码?像 Python 这样的解释性语言执行前者,而像 C这样的预编译语言则为后者。Java 实际上介于两者之间,它使用即时编译器预先完成大部分工作,将程序转换为字节码,然后在运行时将字节码编译为机器代码。

现在可以思考这样一个场景:新的洛尔斯电码问世,它是摩尔斯电码的替代品。如果操作员被教导如何将速记转换为洛尔斯电码,那么说话的人不需要知道这是如何完成的,他们可以免费获得它。同样,讲不同语言的人只需要告诉操作员如何将其翻译成短语,然后他们就会得到洛尔斯和莫尔斯电码的翻译!这就是LLVM 的工作原理。LLVM IR(中间表示)充当程序和机器代码之间的垫脚石。C、C++、Rust 和大量其他语言都以 LLVM IR 为目标,然后将代码编译为各种机器的架构。

LLVM 编译器

静态类型与动态类型的区别?在第一种情况下,操作员要么在开始点击之前检查单词是否具有语法意义。或者他们发现“嗯,这没有意义”然后停下来。动态类型可以被视为比实验速度更快(如 Python、JS),但是当你发送该消息时,并不知道操作符是否会中途停止或崩溃。

我用电报员的例子来解释它,但任何类比都是有效的。如果想要进行新建编程语言试验,那么动态类型可能会更好,因为可以进步得很快。如果使用更大的代码库,则更难校对所有代码,并且更有可能出错误,可能应该转向静态类型以避免破坏代码。

类型与类型系统

编译器最有趣的部分是类型检查器。在我们的类比中,操作员将单词分类为词性(形容词、名词、动词),然后检查它们是否使用正确。类型的工作方式与此相同,我们根据希望程序值具有的行为对其值进行分类。例如,int对于可以相乘的数字,String对于可以连接在一起的字符流。类型检查器的作用是防止发生不正确的行为,例如连接int或String相乘, 这些操作是没有意义的,因此不应被允许。通过类型检查,程序员用类型注释值,编译器检查它们是否正确。通过类型推断,编译器可以推断并检查类型,这些规则(以及类型本身)的集合形成了一个语言的类型系统。

在这个系统里,实际上我们可以做很多事情:类型系统不仅仅检查int 或String 是否正确使用。更丰富的类型系统可以证明程序具有更强的不变量:它们可以终止、安全地访问内存,或者它们不包含数据竞争。比如 Rust 的类型系统保证内存安全和数据竞争自由,并持续检查传统int和String类型。

这还不是制作编程语言的完整教程,但是如果你对编程语言开发感到好奇,那么这是一个好的开始!

作者:有趣的大雄

参考:

https://www.freecodecamp.org/news/the-programming-language-pipeline-91d3f449c919/

评论