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 比起來,具體有哪些不同呢?
例如在 Rust 裡,String 這類非 Copy 的值,在賦值時會發生移動;Lisette 沒有這套移動語意,賦值之後,原來的變數還能繼續用。
順帶說明,Rust 裡的字串字面值是實作了 Copy 的 &str;上面提到的移動行為,指的是 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。