9 个版本 (5 个重大更新)

0.6.1 2020 年 8 月 24 日
0.6.0 2020 年 8 月 19 日
0.5.1 2020 年 8 月 13 日
0.4.0 2020 年 8 月 3 日
0.1.1 2020 年 7 月 27 日

#340 in 编程语言

MIT 许可证

460KB
5K SLoC

Rust 4.5K SLoC // 0.1% comments TypeScript 203 SLoC // 0.2% comments C 188 SLoC // 0.3% comments Go 187 SLoC // 0.2% comments

Oak

无限便携的 C 编程语言替代品。

Example

为什么选择 Oak?

对于那些还记得 "free" 的人来说,oak 实际上是该项目的更健壮和高级版本。oak 的目标是尽可能地在前端保持高级,但在后端尽可能保持小巧和低级。

关于作者

我是一个刚刚高中毕业并进入大学的新生,正在寻找工作。如果您喜欢我的项目,请考虑通过给我买咖啡来支持我!

中间表示

oak 的不可思议的便携性的关键是其极其紧凑的后端实现。 Oak 后端代码可以用不到 100 行 C 代码表达。 这样小巧的实现仅因为中间表示的指令集很小。Oak 的 IR 只由 17 条不同的指令 组成。这与 brainfuck 相当!

oak 的后端功能非常简单。每条指令都在一个 内存带上 操作。这个带子实际上是一个双精度浮点数的静态数组。

      let x: num = 5.25;    ...     let p: &num = &x;  `beginning of heap`
          |                             |                      |
          v                             v                      v
[0, 0, 0, 5.25, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, ...]
                                                       ^
                                                       |
                          `current location of the stack pointer`

当在函数中定义一个变量时,它被分配一个相对于虚拟机当前基指针的静态位置。因此,当调用函数时,在堆栈上为函数的变量分配空间,并将基指针增加以使用这个新空间。然后,编译器只需将变量替换为其余代码中基指针偏移量加上地址即可!

此外,内存带还充当一个 和一个 。为程序的所有变量分配空间后,栈所使用的内存开始。栈在整个程序中随着数据的增加和减少而 增长收缩:例如,当两个数字相加时,它们从栈中弹出,并用结果替换。同样,堆在整个程序中也会增长和收缩。然而,堆用于 动态分配 的数据:在编译时未知内存足迹的信息。

现在你已经了解了 oak 后端的基本运行方式,以下是完整的指令集!

指令 副作用
push(n: f64); 将一个数字推入栈中。
add(); 从栈中弹出两个数字,并推入它们的和。
subtract(); 从栈中弹出两个数字。从第二个数字中减去第一个数字,并推入结果。
multiply(); 从栈中弹出两个数字,并推入它们的乘积。
divide(); 从栈中弹出两个数字。用第二个数字除以第一个数字,并推入结果。
sign(); 从栈中弹出数字。如果它大于或等于零,推入 1,否则推入 -1
allocate(); 从栈中弹出数字,并返回指向堆中该数量空闲单元的指针。
free(); 从栈中弹出数字,并转到内存中的该数字位置。再从栈中弹出另一个数字,并在此位置释放这么多的内存单元。
store(size: i32); 从栈中弹出数字,并转到该数字指向的内存位置。然后,从栈中弹出 size 个数字。将这些数字以相反的顺序存储在此内存位置。
load(size: i32); 从栈中弹出数字,并转到该数字指向的内存位置。然后,将 size 个连续内存单元推入栈中。
call(fn: i32); 通过编译器分配的 ID 调用用户定义的函数。
call_foreign_fn(name: String); 通过源中的名称调用外部函数。
begin_while(); 开始 while 循环。对于每次迭代,从栈中弹出数字。如果数字不为零,则继续循环。
end_while(); 标记 while 循环的结束。
load_base_ptr(); 加载已建立的栈帧的基本指针,该指针始终小于或等于栈指针。每个函数的变量都相对于基本指针存储。因此,定义了 x: numy: num 的函数,x 可能存储在 base_ptr + 1,而 y 可能存储在 base_ptr + 2。这允许函数根据需要动态地存储变量,而不是使用静态内存位置。
establish_stack_frame(arg_size: i32,local_scope_size: i32); 从栈中弹出 arg_size 个单元并存储起来。然后,调用 load_base_ptr 来在函数结束时恢复父栈帧。将 local_scope_size 个零推入栈中,为函数的变量腾出空间。最后,按照原始顺序将存储的参数单元重新推回栈中。
end_stack_frame(return_size: i32,local_scope_size: i32); 从栈中弹出 return_size 个单元并存储起来。然后,从栈中弹出 local_scope_size 个单元以丢弃栈帧的内存。从栈中弹出一个值并存储在基指针中以恢复父栈帧。最后,按照原始顺序将存储的返回值单元重新推回栈中。

仅使用这些指令,Oak就能够实现比C语言所能提供的更高级的抽象!这听起来可能并不多,但对于这样一个小巧的语言来说却非常强大。

语法和标志

Oak的语法深受Rust编程语言的影响。

使用 fn 关键字声明函数,其语法与Rust函数相同,除了 return 语义外。此外,用户定义的类型和常量分别使用 typeconst 关键字声明。

类似于Rust的外部属性,Oak引入了许多编译时标志。以下展示了其中一些标志以及Oak的其他特性。

Syntax Example

编译过程

那么Oak编译器到底是如何工作的呢?

  1. 将结构扁平化为函数

    • Oak中的结构与其它语言中的结构不同。对象本身只是内存单元的数组:它们没有任何成员或属性。结构通过使用 方法 来获取其“成员”的地址来 独家 获取其数据。然后,这些方法被扁平化为简单的函数。所以,putnumln(*bday.day) 变成了 putnumln(*Date::day(&bday))。这是一个相当简单的过程。
  2. 计算每个操作类型的尺寸

    • 由于Oak的中间表示结构,编译器必须知道每个表达式的类型才能继续编译。编译器遍历每个表达式并找到其类型的尺寸。从现在开始,代码的表示看起来像这样
// `3` is the size of the structure on the stack
fn Date::new(month: 1, day: 1, year: 1) -> 3 {
    month; day; year
}
// self is a pointer to an item of size `3`
fn Date::day(self: &3) -> &1 { self + 1 }

fn main() -> 0 {
    let bday: 3 = Date::new(5, 14, 2002);
}
  1. 静态计算程序的内存占用

    • 在总计所有静态分配的数据后,例如静态变量的整体内存大小和字符串字面量后,程序预先在栈上留出适当的内存空间。这实际上意味着栈指针在程序开始时立即移动以腾出所有数据的空间。
  2. 将Oak表达式和语句转换为等效的IR指令

    • 大多数表达式都很简单:函数调用只是将其参数按逆序推入栈中并通过其ID调用函数,变量的引用只是将它们的分配位置作为数字推入栈中,等等。但是,方法调用有点复杂。

    存在许多不同的情况下方法调用是有效的。方法始终以结构体的指针作为参数。然而,调用方法的对象不一定是指针。例如,以下代码是有效的:let bday: Date = Date::new(); bday.print();。变量 bday 不是一个指针,但方法 .print() 仍然可以使用。原因如下。

    当编译器看到扁平化的方法调用时,它需要找到一种方法将“实例表达式”转换为指针。对于变量来说,这很简单:只需添加一个引用!对于已经是指针的实例表达式,甚至更简单:不做任何事情!但是对于任何其他类型的表达式,这就有点冗长。编译器会偷偷地引入一个隐藏变量来存储表达式,然后再使用这个变量作为实例表达式重新编译方法调用。是不是很酷?

  3. 为特定目标组装IR指令

    • 由于Oak的IR非常小,它可以支持多个目标。更好的是,添加目标非常简单。在Oak的crate中,有一个名为Target的trait。如果您使用Target trait为您自己的语言实现IR的每条指令,那么Oak可以自动编译到您的新编程语言或汇编语言!是的,就像听起来那么简单!

文档工具

为了让用户在没有互联网访问的情况下也能阅读库和文件的文档,Oak提供了doc子命令。这允许作者在代码中添加文档属性,以帮助其他用户理解他们的代码或API,而无需在源代码中查找并阅读注释。

以下是一些示例代码。

#[std]
#[header("This file tests Oak's doc subcommand.")]

#[doc("This constant is a constant.")]
const CONSTANT = 3;
// No doc attribute
const TEST = CONSTANT + 5;

#[doc("This structure represents a given date in time.
A Date object has three members:
|Member|Value|
|-|-|
|`month: num` | The month component of the date |
|`day: num`   | The day component of the date   |
|`year: num`  | The year component of the date  |")]
type Date(3) {
    #[doc("The constructor used to create a date.")]
    fn new(month: num, day: num, year: num) -> Date {
        return [month, day, year];
    }

    #[doc("Get the `month` member of the object")]
    fn month(self: &Date) -> &num { return self as &num }
    #[doc("Get the `day` member of the object")]
    fn day(self: &Date)   -> &num { return (self+1) as &num }
    #[doc("Get the `year` member of the object")]
    fn year(self: &Date)  -> &num { return (self+2) as &num }

    #[doc("Print the date object to STDOUT")]
    fn print(self: &Date) {
        putnum(self->month); putchar('/');
        putnum(self->day); putchar('/');
        putnumln(self->year);
    }
}

#[doc("This function takes a number `n` and returns `n * n`, or `n` squared.")]
fn square(n: num) -> num {
    return n * n
}

fn main() {
    let d = Date::new(5, 14, 2002);
    d.print();
}

以下是如何使用doc子命令将格式化文档打印到终端的示例。

Documentation Example

安装

开发版本

要获取当前的开发版本,请克隆仓库并安装。

git clone https://github.com/adam-mcdaniel/oakc
cd oakc
cargo install -f --path .

版本

要获取当前的版本,可以从crates.io安装。

# Also works for updating oakc
cargo install -f oakc

安装后

然后,可以使用oakc二进制文件编译oak文件。

oak c examples/hello_world.ok -c
main.exe

依赖项

C后端 - 支持 C99 的任何 GCC 编译器

Go后端 - Golang 1.14 编译器

TypeScript后端 - TypeScript 3.9 编译器

依赖项

~11–20MB
~287K SLoC