Rust に似ていて Go にコンパイルされる言語、Lisette を見つけた

22日前 / , ,
簡体中文日本語
要約:
Rust の構文は好きだけれど、所有権には苦労する。自分で言語を作ろうとしたこともある。最近見つけた Lisette は、Rust 風の構文を Go にコンパイルする、思いもよらない答えだった。

私は以前から Rust の構文がとても好きです。型システム、パターンマッチ、メソッドチェーン、抽象化の設計まで、どれもよくできていると思います。読みやすく、書きやすい。自分の理想に一番近い言語です。

ただ、所有権が開発の手を止めてしまう場面も少なくありません。今は vibe coding が中心になり、コードを一行ずつ自分で書くことも減ったので、以前ほど気にならなくなりました。それでも、たまに手で書くと考えてしまいます。Rust のように直感的な構文、パターンマッチ、メソッドチェーンがあり、所有権だけがなくて、Go の扱いやすい GC と並行処理まで使えたら、どれだけ気持ちよく書けるだろう、と。

最近見つけた Lisette は、私が考えもしなかった方法を選んでいました。

欲しかった言語

欲しいものは、大きく二つに分けられます。

Rust で一番好きなのは、列挙型とパターンマッチの組み合わせです。値がいくつかの状態を取り得るなら、それを列挙型で表し、match で処理する。ケースを一つでも忘れれば、コンパイラーが教えてくれます。Option と Result があれば、値がないことやエラーも型の一部になり、エラーは ? で呼び出し元に伝えればいい。メソッドチェーンも便利で、map や filter をつなげれば、読む側も頭の中を行ったり来たりせず、上から順に追えます。

Go で好きなのは、GC と並行処理です。GC があるので、誰が何を所有し、誰に貸しているかを考えずに済みます。goroutine と channel のおかげで、並行処理にも大げさな準備はいりません。コンパイルが速く、通常は単体で配布しやすいバイナリを作れるので、デプロイも楽です。

つまり、両方から半分ずつ取りたい。構文と型システムは Rust、ランタイムは Go、という言語です。

自分で言語を作ってみた

既存のものがないなら自分で作ろう、と実際に試したこともあります。

そのころは最先端のモデルに百数十億トークンを使い、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 のコードだけで約20万行、そのうち Go のコード生成が4万行以上を占めています。フロントエンドと変換だけでも、簡単には済まないようです。

新しいプロジェクトですが、作者の更新頻度は高く、メンテナンスもとても積極的です。個人が作る言語プロジェクトの中でも、私が見たものでは特に安定して開発が続いています。

どんなコードを書くのか

まずは列挙型とパターンマッチです。

Rust
enum Shape {
  Circle(float64),
  Rectangle { width: float64, height: float64 },
}

fn area(shape: Shape) -> float64 {
  match shape {
    Circle(r) => 3.14 * r * r,
    Rectangle { width, height } => width * height,
  }
}

見た目はほとんど Rust で、float64 のような型名だけが Go のものになっています。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 に慣れた人が手で書いたようなコードです。直近2バージョンの新機能も、生成する Go コードをより自然にするものが中心でした。たとえば、or-pattern を switch の複数の case に変換します。このあたりには、かなり気を配っているようです。

所有権の代わりに GC

Lisette には所有権も借用もないので、私を悩ませていたコンパイルエラーもありません。Rust と具体的にどう違うのでしょうか。

たとえば、Rust では String のような Copy でない値を代入すると、ムーブが起きます。Lisette にはこのムーブの仕組みがないので、代入したあとも元の変数を使えます。

Rust
let s1 = "hello"
let s2 = s1
fmt.Println(s1)  // 没问题,s1 还能用

なお、文字列リテラルは Rust では Copy な &str なので、この例の代入でもムーブは起きません。上の違いが表れるのは、String のような値です。

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() { ... }() に変換されます。

Rust
let (tx, rx) = Channel.new<string>().split()
task {
  tx.send("hello")
  tx.close()
}
match rx.receive() {
  Some(msg) => fmt.Println(msg),
  None => fmt.Println("closed"),
}

rx.receive() は Option を返し、channel が閉じると None になります。Go で v, ok := <-ch を使って判定するところを、ここでは match に任せられます。select もあります。

Go との相互運用

Go と組み合わせる部分も、使いやすい設計だと思いました。Go のパッケージは import "go:..." で読み込み、Go で書くのと近い感覚で使えます。

Rust
import "go:os"
import "go:encoding/json"
import "go:fmt"

fn load_config(path: string) -> Result<Cfg, error> {
  let data = os.ReadFile(path)?
  let mut config = Cfg { port: 8080, debug: false }
  json.Unmarshal(data, &config)?
  Ok(config)
}

Go では os.ReadFile は ([]byte, error) を返しますが、Lisette 側では自動的に Result になるので、そのまま ? を使えます。nil になり得る Go のポインター、map、interface も、境界で Option に変わります。たとえば次のようになります。

Rust
// Go:  func find(id int) *Person
// Lis: fn find(id: int) -> Option<Ref<Person>>

match find(1) {
  Some(person) => fmt.Println(person.name),
  None => fmt.Println("no person"),
}

これで Lisette 側には nil がなくなり、None であるか、値が必ずあるかのどちらかになります。この変換を支えるのが、.d.lis という型宣言ファイルです。サードパーティーの Go モジュールも、lis add google/uuid のように追加するだけでダウンロードされ、宣言ファイルが生成され、依存関係が lisette.toml に記録されます。

コンパイル時のチェック

Go でよくある実行時エラーやその原因になり得る問題を、コンパイル時にチェックします。診断の種類だけで500以上あります。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 を書けば、普通のスクリプトとして使えます。

Rust
#!/usr/bin/env -S lis run

import "go:fmt"
import "go:os"

fn main() {
  fmt.Println("hello,", os.Args[1])
}

エディターについては、VS Code、Zed、JetBrains なら拡張機能のマーケットプレイスで Lisette を検索してインストールできます。Neovim と Helix は、lis lsp につなぐ設定を自分で書きます。ブラウザーで使える playground もあるので、まず触ってみたいだけならインストールも不要です。

試してみる

最終的なコンパイルは Go に任せるので、Go 1.26 以降が必要です。Lisette は、以下のいずれかの方法でインストールできます。

Shell
curl -fsSL https://lisette.run/install.sh | sh
# 或者
brew install lisette
mise use -g lisette
cargo install lisette

インストールしたら、新しいプロジェクトを作って実行します。

Shell
lis new hello-world
cd hello-world
lis run

リポジトリは ivov/lisette、ドキュメントは lisette.run にあります。

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...