我很无聊,所以我创建了一个编程语言 Tisp(Toy Lisp)

2025-05-27

我很无聊,所以我创建了一种编程语言

蒂斯普(Toy L isp

2020 年第一次封锁的一个美好的夜晚,我无所事事地坐着,不知道该做什么。

冷静地惊慌失措。

你看,我当时真的需要做点什么。我之前一直在兼职做几个 Web 相关的项目,至少暂时不想再做了。所以我开始考虑做一些“更贴近实际”的事情,一些比反复向 Web 服务器发送请求低级得多的事情。于是我很快就打开了“通过做 Y 来学习 X”这个网站,搜索了一些有趣的东西,最终找到了“构建你自己的Lisp”(我们每个人都会经历 Lisp 阶段,只是轮到我了)。

图像

对于初学者来说,Lisp(简单地说)是一类编程语言,其语法或多或少看起来像这样(您可以很快看出括号笑话的来源)。

(defun area-circle(rad)
   "Calculates area of a circle with given radius"
   (terpri)
   (format t "Radius: ~5f" rad)
   (format t "~%Area: ~10f" (* 3.141592 rad rad)))
(area-circle 10)
Enter fullscreen mode Exit fullscreen mode

但我不想随便写一个 Lisp,我想根据自己的喜好做一些修改。所以只有一种方法可以实现这一点。

提出一种语法

让我接触 Lisp 和编写 Lisp 的(令人惊讶的是)是Emacs 文本编辑器(或者说,对于硬核用户来说,是 Emacs 操作系统)。我为什么要去任何离 Emacs 稍远的地方呢?我不知道,改天再告诉你。要知道,编写 Emacs Lisp 是配置 Emacs 的推荐方式。虽然我并没有写过很多 Emacs Lisp 代码,但我在那里看到的一些关键字对我来说感觉不太自然(尤其是在使用 Ruby 之后)。所以我的主要目标之一就是拥有更多听起来“自然”的关键字

图像

谈到 Lisp 就不能不谈到括号。对于其他语言来说,括号在可读性方面有一个很大的优势,那就是所有内容都不在括号内。方括号通常表示数组,大括号通常表示代码块,而括号用于函数调用。这使得代码的可视化 grepping 变得更容易,但在 Lisp 中却并非如此,尤其是对于初学者而言。所以我需要减少括号的使用,但我不能改变它的所有语法,因为那样它可能就不再是 Lisp 了(忒修斯之船,有人吗?)。我认为Clojure在保持 Lisp 和改进可视化 grepping 之间取得了很好的平衡。所以另一个目标是减少对括号的依赖,以便代码更具可读性

'(1 2 3)     ; list
[1 2 3]      ; vector
#{1 2 3}     ; set
{:a 1, :b 2} ; map
Enter fullscreen mode Exit fullscreen mode

(来自Clojure 语法指南的示例

所以它开始了

在设定了所有关于该语言的要求后,我终于开始着手构建它了。我选择用 Rust 来构建它,因为它的速度和内存安全性看起来是合适的工具。我正在构建自己的编程语言,感觉自己像个“真正的程序员”(顺便说一句,根本没有这种感觉),所以很自然地,我开始在推特上分享它。

从简单开始

我想从尽可能简单的事情开始,比如加几个数字。感觉这是一个完美的起点。我将计算一个用 Lisp 编写的简单算术表达式,例如2 * 3 + 1 - 4,在 Lisp 中可以写成(- (+ 1 (* 2 3)) 4)。对于不了解情况的人来说,这是波兰表示法的形式,可以将其视为一个带有多个参数的简单函数。(+ 1 2 3)它只是 1、2 和 3 的和。然后,它的结果可以用作父函数的参数,然后对其进行求值。同样,可以使用下面的方法求解整个表达式。

图像
(来源:Crafting Interpreters

构建初始原型后,我用(+ 2 (- 4 6) 8)进行了测试。手动计算后,您可以得出结果为4 - 6 + 2 + 8 = 8。如果您在底部红色矩形中看到结果,则结果确实是 8。

替代文本

奠定基础

现在我们已经有了一些东西可以尝试,是时候进一步发展并为一个合适的编译器奠定基础了。任何编译器的第一步都是读取源文件,并将其转换为机器可读的对象流,称为标记(token)。此步骤称为词法分析(lexical analysis),或 lexing。它只是将编程语言中所有可能的字符映射到一个 Token 对象。如下所示:

特点 代币
( 令牌 { kind: OpenParen, value: None }
令牌 { 种类:标识符, 值:Some("let") }
-?([0-9])+ 令牌 { 种类:数字,值:Some(getVal()) }

通过这种方式,您可以为标记及其各种类型构建一个映射,以便下一步更轻松地构建语法树。

太棒了,现在我可以生成 token 了,是时候在这个抽象的基础上进行构建,并赋予 token 流一个合适的结构,以便更容易使用。我们将检查每一个 token,并通过生成语法树来理解它们。这一步称为解析,解析结束后,我们将得到类似这样的结果:

抽象语法树
(来源:抽象语法树:维基百科

因此,我开始尝试构建一个语法树,首先以枚举的形式定义一个基本语法,如下所示

pub enum Expr {
    Constant(Value),
    Builtin(Ident), 
    Call(Box<Expr>, Vec<Expr>),
    While {
        condition: Box<Expr>,
        body: Vec<Expr>,
    },
}
Enter fullscreen mode Exit fullscreen mode

阅读Rust 编程语言的源代码非常有帮助,我可能从中借鉴了一些概念。让我快速定义一下这些类型:

  • 常量:任何类型的文字Value(数字、字符串、布尔值)
  • 内置:任何内置标记,如运算符(+、-、*、/)或关键字(let、print、if)
  • 调用:这很简单,任何函数调用(说实话,一切都应该只是一个函数调用,因为 Lisp 是一种函数式语言)
  • While:while 循环(呃)

(如果您好奇的话,广义上讲, Box是 Rust 的指针内置类型。)

大致就是这样,剩下的过程就是利用我已有的词法单元流,找到构建语法树的方法。虽然进展缓慢,但我稳步前进。

替代文本

至此,语法树已经足够强大,可以满足我目前对语言功能的期望了。是时候继续追求更大更好的目标了

LLVM

低级虚拟机?猜得不错,但现在不是了。LLVM 是什么?我们先回顾一下,看看为什么需要它。编译器有两个方面——前端和后端——前端包含词法分析和语法分析,而后端包含中间代码生成、优化和目标代码生成。到目前为止,我构建的只是前端,而我拥有的是后端可以使用的内部表示。

要从头构建所有后端,我需要执行以下操作。首先,我将自己限制在一种架构上,因为由于处理器工作方式的复杂性,支持多种处理器架构是不可能的。在选择了最流行的处理器架构之后,我需要很好地掌握它的汇编语言。由于微处理器的设计和制造方式不同,它们都有不同的指令集。要支持某种指令集,您需要将用户用您的语言编写的代码翻译成该特定指令集的指令形式。处理器的工作方式可能很奇怪,有时是出于兼容性原因,有时仅仅是因为它们可以工作。因此,在优化生成的代码时,必须将所有这些因素都考虑在内。假设您成功地支持了一种架构,但现在(不知何故)其他架构对您的语言的需求很旺盛,您将不得不重复这个过程,然后再为另一个架构重复这个过程,依此类推。更不用说如何调试特定于某些架构的错误了。这会造成巨大的混乱。

LLVM 是一种抽象

LLVM 的作用就在于此,它为所有目标指令集提供了一个便捷的抽象层。您只需负责将其转换为其自身的内部表示(LLVM IR)。LLVM 会负责所有针对其 IR 的代码优化,然后再针对目标指令集进行优化。但最好还是先优化代码,然后再将其交给 LLVM,而不是指望 LLVM 包揽所有工作。

因此,要使用 LLVM,我只需要将我的 AST 转换为 LLVM IR。我使用了Inkwell( Rust 中的 LLVM API)来实现。它是 LLVM 构建的 C API 的包装器,并在此基础上提供了 Rust 保证的内存安全。哦,对了,LLVM IR 的文本格式是这样的(不要试图理解它,感受它):

; ModuleID = 'example'
source_filename = "/home/faraaz/hello.tp"

@format_string = private unnamed_addr constant [4 x i8] c"%f \00", align 1

define i32 @main(i32 %0) {
entry:
  %num = alloca double
  store double 4.200000e+02, double* %num
  %num1 = load double, double* %num
  %printf = call double (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @format_string, i32 0, i32 0), double %num1)
  ret i32 0
}

declare double @printf(i8*, ...)
Enter fullscreen mode Exit fullscreen mode

LLVM IR 中的代码将变量设置为 420 并打印出来

让一切合法化

到目前为止,执行编译器的二进制文件只能做一件事,而且对于初次使用它的人来说,它的可配置性不强,帮助也不大。我希望它有一个合适的命令行界面 (CLI),像这样:

htop 工具的 CLI

所以我使用Clap板条箱(又名包)来构建 CLI,我认为结果还不错。

Tisp 的 CLI

整合所有

现在我已经设置好了编译器,并且该语言也具备了存储变量、执行算术运算、循环(嵌套循环尚不支持)和屏幕打印等功能,是时候用它编写一个小程序了。下面是我在 Tisp 中编写的一个程序,用于计算前 5 个斐波那契数列,然后编译并运行它:

如果您想亲自检查一下,这就是该项目。

GitHub 徽标 法拉扎赫迈德/蒂斯普

一种基于 Lisp 并内置 Rust 和 LLVM 的玩具编程语言

蒂斯普(Toy L isp

一种类似 Lisp 的类型化和编译型编程语言。它基于 LLVM 构建,旨在支持多种处理器架构。它的灵感源自 Rust、Lisp 和 Elixir 等编程语言。

当前工作示例

计算前 5 个斐波那契数的程序:

(let first 0)
(let second 1)
(let fib)
(let n 0)

(while (< n 5)
    (let fib (+ first second))
    (let second first)
    (let first fib)

    (let n (+ n 1))
    (print fib)
)
Enter fullscreen mode Exit fullscreen mode

要构建的功能

  • 将原始代码转换为标记流
  • 将标记流转换为表达式树
  • 处理多层嵌套表达式
  • 每个文件有多个(独立)表达式
  • 为当前支持的功能生成 LLVM IR
  • 添加 CLI 标志以发出 llvm
  • 添加 while 循环
  • 声明变量
  • 添加…

以下是我了解到的情况

编程语言很难。首先,阅读代码就很难。理解和学习一门编程语言更是难上加难。构建一门编程语言的难度可想而知,更不用说构建一门被广泛使用的编程语言了。这个项目主要是我在尝试回答“它到底有多难?”这个问题,我想我现在找到了答案。但它其实也挺容易的。一旦你尝试过最难的部分,下次再做的时候,就会感觉容易一些。每天尝试一些困难的事情,它就会变得容易一些,但你必须每天都坚持下去,这才是最难的部分。

文章来源:https://dev.to/faraazahmad/i-was-bored-so-i-built-my-own-programming-language-30f1
PREV
React & Firebase:将 Firebase 添加到 React 应用 注意:随着 Firebase 库 v9 的发布,此文章已过时。入门总结
NEXT
9 个 HTML CSS JavaScript Bootstrap 网站教程