Go 里我喜欢的则是 GC 和并发。因为有 GC,就不用再操心谁拥有谁、谁借给了谁,而 goroutine 和 channel 又让并发写起来不需要太多仪式。再加上编译很快,通常可以编译成一个便于分发的独立二进制,部署也省心。
所以我想要的,就是把两边各取一半,语法和类型系统用 Rust 的,运行时用 Go 的。
自己写一门语言
既然没有现成的,那就自己写一个,我也确实这么试过。
那段时间我用 SOTA 模型烧了十几 B 的 token,拿 Zig 写了几十万行代码。可是一门完整的语言要做的事情实在太多了,词法分析、语法分析、类型推导和类型检查、代码生成、运行时、GC、标准库,这些还只是语言本身。如果想好好用,还得有格式化工具、LSP 和包管理,不然写起来会很难受,而且每一块拿出来都是个大工程。
于是尽管代码堆了不少,离能用依然任重而道远,我也就逐渐放弃了。
Lisette 是什么
Lisette 的思路是,干脆不去写运行时。它自己的定位是一门受 Rust 启发、编译成 Go 的小语言。作者说,这是一封用 Rust 写给 Go 的情书(A love letter to Go written in Rust)。
写代码的时候用的是 Rust 风格的语法,文件扩展名是 .lis。编译器本身是用 Rust 写的,它会把 Lisette 的代码翻译成 Go 代码,然后交给 Go 自己的工具链去编译。所以最后跑起来的就是一个普通的 Go 程序,用的是 Go 的运行时、GC 和调度器,标准库和第三方的 Go 模块也都能直接用。
这样一来,我当年觉得最麻烦的那一大堆,也就是运行时、GC、并发和标准库,一下子就都有了,而且全是别人已经做得很好的东西。语言本身只剩下前端和代码生成要做。不过编译器本身也不小,光 Rust 代码就有将近二十万行,其中生成 Go 的那部分就占了四万多行,看来即使只做前端和翻译,工作量也一点都不小。
这看起来就是 Rust,只有类型名用的是 Go 那一套,比如 float64。如果 match 漏掉了某个变体,编译器同样会报错。
至于链式调用,它加了一个管道运算符 |>,所以原本在 Go 里要一层层套着写的函数调用,现在可以顺着从上往下读。
Rust
let slug = " Hello World "
|> strings.TrimSpace
|> strings.ToLower
|> strings.ReplaceAll(" ", "-") // "hello-world"
另外它没有 nil,可能不存在的值用 Option<T>,可能失败的操作用 Result<T, E>,? 和 let ... else 也都能用。比如下面这个。
Rust
let Ok(bytes) = os.ReadFile(path) else {
fmt.Println("could not read")
return
}
再加上默认不可变、let mut 才能改,impl 块,泛型,表达式风格的 if 和 match,还有 f-string,这些也都在,读起来和 Rust 很像。
编译出来的 Go
那么它翻译出来的 Go 又是什么样子呢?我拿一个带守卫的 match 来看,枚举有三种情况。
Rust
enum Shape { Circle(int), Square(int), Tri(int) }
fn test(s: Shape) -> int {
match s {
Circle(r) => r,
Square(w) => w,
Tri(h) if h > 10 => h * 2,
Tri(h) => h,
}
}
翻出来的 Go 是下面这样,MakeShapeCircle 之类的构造函数我省略掉了。
GO
type ShapeTag uint8
const (
ShapeCircle ShapeTag = iota
ShapeSquare
ShapeTri
)
type Shape struct {
Tag ShapeTag
Circle int
Square int
Tri int
}
func test(s Shape) int {
if s.Tag == ShapeCircle {
return s.Circle
}
if s.Tag == ShapeSquare {
return s.Square
}
h := s.Tri
if h > 10 {
return h * 2
}
return h
}
枚举变成了一个带标签的结构体,match 变成了 if,分支比较整齐的时候还会变成 switch。整体看下来,就像是一个熟悉 Go 的人手写出来的。而且它最近两个版本的新功能,大部分都在做同一件事,就是让生成的 Go 更地道,比如把 or-pattern 翻成 switch 里的多个 case,看来作者在这上面花了不少心思。
我一直超级喜欢 Rust 的语法。它的类型系统、模式匹配、链式调用,还有整套抽象的设计,我都觉得好到不行,读起来顺,用起来也顺,是我心目中一门语言最好的样子。
可是所有权在很多时候对开发效率的阻碍太大了。当然,现在基本都是 vibe coding 的时代,很多代码根本不用自己一行行敲,所以这个问题也就没那么扎眼。但是偶尔手写代码的时候,我还是会不经意地想,要是有一门语言,语法像 Rust 一样符合直觉,有一样的模式匹配和链式调用,除了所有权之外什么都有,同时又带着 Go 那种好用的 GC 和并发,那写代码的体验该有多爽。
最近我发现了 Lisette,它走的是一条我从来没想到过的路。
想要的那门语言
我想要的东西,可以拆成两半来说。
Rust 里我最喜欢的是枚举加模式匹配。一个值可能是几种情况之一,就用枚举把它们写出来,处理的时候用 match,万一漏掉一种,编译器会直接提醒。再配上 Option 和 Result,空值和错误就都成了类型的一部分,出了错用 ? 一路往上传就行,写起来很顺手。然后链式调用也很舒服,一串 map、filter 接下去,读代码的人顺着往下看,不用在脑子里来回跳。
Go 里我喜欢的则是 GC 和并发。因为有 GC,就不用再操心谁拥有谁、谁借给了谁,而 goroutine 和 channel 又让并发写起来不需要太多仪式。再加上编译很快,通常可以编译成一个便于分发的独立二进制,部署也省心。
所以我想要的,就是把两边各取一半,语法和类型系统用 Rust 的,运行时用 Go 的。
自己写一门语言
既然没有现成的,那就自己写一个,我也确实这么试过。
那段时间我用 SOTA 模型烧了十几 B 的 token,拿 Zig 写了几十万行代码。可是一门完整的语言要做的事情实在太多了,词法分析、语法分析、类型推导和类型检查、代码生成、运行时、GC、标准库,这些还只是语言本身。如果想好好用,还得有格式化工具、LSP 和包管理,不然写起来会很难受,而且每一块拿出来都是个大工程。
于是尽管代码堆了不少,离能用依然任重而道远,我也就逐渐放弃了。
Lisette 是什么
Lisette 的思路是,干脆不去写运行时。它自己的定位是一门受 Rust 启发、编译成 Go 的小语言。作者说,这是一封用 Rust 写给 Go 的情书(A love letter to Go written in Rust)。
写代码的时候用的是 Rust 风格的语法,文件扩展名是 .lis。编译器本身是用 Rust 写的,它会把 Lisette 的代码翻译成 Go 代码,然后交给 Go 自己的工具链去编译。所以最后跑起来的就是一个普通的 Go 程序,用的是 Go 的运行时、GC 和调度器,标准库和第三方的 Go 模块也都能直接用。
这样一来,我当年觉得最麻烦的那一大堆,也就是运行时、GC、并发和标准库,一下子就都有了,而且全是别人已经做得很好的东西。语言本身只剩下前端和代码生成要做。不过编译器本身也不小,光 Rust 代码就有将近二十万行,其中生成 Go 的那部分就占了四万多行,看来即使只做前端和翻译,工作量也一点都不小。
这个项目虽然很新,但作者更新得非常频繁,维护得超级积极,是我在这种个人语言类项目里见过的最稳定、最持续在维护的一个。
写起来是什么样
先看一段枚举和模式匹配。
这看起来就是 Rust,只有类型名用的是 Go 那一套,比如 float64。如果 match 漏掉了某个变体,编译器同样会报错。
至于链式调用,它加了一个管道运算符 |>,所以原本在 Go 里要一层层套着写的函数调用,现在可以顺着从上往下读。
另外它没有 nil,可能不存在的值用 Option<T>,可能失败的操作用 Result<T, E>,? 和 let ... else 也都能用。比如下面这个。
再加上默认不可变、let mut 才能改,impl 块,泛型,表达式风格的 if 和 match,还有 f-string,这些也都在,读起来和 Rust 很像。
编译出来的 Go
那么它翻译出来的 Go 又是什么样子呢?我拿一个带守卫的 match 来看,枚举有三种情况。
翻出来的 Go 是下面这样,MakeShapeCircle 之类的构造函数我省略掉了。
枚举变成了一个带标签的结构体,match 变成了 if,分支比较整齐的时候还会变成 switch。整体看下来,就像是一个熟悉 Go 的人手写出来的。而且它最近两个版本的新功能,大部分都在做同一件事,就是让生成的 Go 更地道,比如把 or-pattern 翻成 switch 里的多个 case,看来作者在这上面花了不少心思。
没有所有权,用 GC
Lisette 里没有所有权,也没有借用,所以那些让我头疼的编译错误自然也就没有了。那么和 Rust 比起来,具体有哪些不同呢?
比如 Rust 里,String 这类非 Copy 的值在赋值时会发生移动;Lisette 没有这套移动语义,赋值之后原来的变量还能继续用。
这里顺带区分一下:Rust 的字符串字面量是 &str,属于 Copy,所以类似的赋值在 Rust 里也不会移动;上面说的移动针对的是 String 这类非 Copy 的值。
String 和 &str 合并成了一个 string,所以不用再区分拥有的还是借来的。闭包直接按引用捕获,被捕获的东西由 GC 来保活,因此既没有 move 关键字,也没有借用规则。trait 换成了 Go 风格的 interface,一个类型只要方法对得上,就算实现了这个接口,不用再写 impl Trait for。顺带一提,它也没有 unwrap()。
引用还是有的,写成 Ref<T> 和 mut Ref<T>,大致对应 Rust 里的 &T 和 &mut T。不过 mut Ref<T> 可以同时存在好几个,不像 Rust 那样要求独占,能不能写,取决于绑定或者字段自己的声明。
所以 Rust 里我最喜欢的那些东西,基本都留下来了,只有所有权被换成了 GC。
并发
并发直接沿用了 Go 的模型。语言里自带 Channel 和 task,其中 task 就是 goroutine,task { ... } 会被翻成 go func() { ... }()。
rx.receive() 返回的是 Option,channel 关闭了就是 None,所以 Go 里要靠 v, ok := <-ch 判断的事情,在这里就直接交给 match 了。另外 select 也是有的。
和 Go 的互操作
在和 Go 互操作的部分,我感觉它的设计非常舒服。Go 的包用 import "go:..." 引入,用起来和在 Go 里差不多。
os.ReadFile 在 Go 里返回的是 ([]byte, error),到了 Lisette 里就自动变成了 Result,所以可以直接用 ?。Go 里可能为 nil 的指针、map 和接口也一样,到了边界上都会变成 Option,比如下面这样。
这样一来,nil 在 Lisette 里就彻底没有了,要么是 None,要么就一定有值。这个转换靠的是一份类型声明文件,也就是 .d.lis。如果想用第三方的 Go 模块,只要 lis add google/uuid 这样添加,它就会把模块下载下来,生成对应的声明,再把依赖写进 lisette.toml。
编译期的检查
它会在编译期检查很多 Go 里常见的运行时错误和隐患,诊断光是数量就有五百多条。除了前面说的 nil,还有 match 漏掉变体、返回的 Result 或 Option 被直接丢掉、调了 os.Exit 却跳过了 defer、context 的 cancel 函数忘了调用,以及在 task 里才去调用 WaitGroup.Add,甚至连把十进制数字当成文件权限来写都会被提醒。
所以这些在写 Go 的时候很容易踩的坑,现在基本都被编译器和语法给保护住了。
工具链
命令行叫 lis,该有的差不多都有了。lis new 用来创建项目,生成的目录里有 src/main.lis 和 lisette.toml,还有一份给 AI 看的 AGENTS.md。lis run 和 lis build 负责编译运行,后者可以用 --target 交叉编译,lis emit 则只生成 Go 代码,想看翻译结果的时候就用它。此外还有做类型检查和 lint 的 lis check(加上 --fix 可以自动修复)、lis format、跑 #[test] 的 lis test,以及前面提到的 lis add。
单文件的脚本也可以直接跑,只要在开头加上 shebang,就能当成脚本来执行。
至于编辑器,VS Code、Zed 和 JetBrains 在扩展市场里搜 Lisette 就能装,Neovim 和 Helix 则需要自己配置一下,让它们接上 lis lsp。另外还有一个能在浏览器里用的 playground,所以想先看看效果的话,连安装都可以省掉。
怎么试一下
因为最后的编译是交给 Go 做的,所以需要先装好 Go 1.26 以上的版本。以下这些方式都可以安装 Lisette。
装完之后,新建一个项目跑起来。
项目的地址是 ivov/lisette,文档在 lisette.run。