世界を動かす技術を、日本語で。

Rustに触発された小さな言語「Lisette」がGoにコンパイルされる

概要

  • Lisette はRustに着想を得た 小型言語 で、Goに コンパイル される
  • 代数的データ型パターンマッチHindley-Milner型推論デフォルト不変 などをサポート
  • Goエコシステムとの 高い互換性 を持つ
  • nil禁止エラー安全型安全なAPI設計 が特徴
  • VSCode/Neovim/Zed などの LSP対応 も完備

Lisette言語の特徴

  • Rust風構文Go互換性 の両立

    • Goのパッケージをimport "go:..."で直接利用可能
    • cargo install lisetteでインストール
  • 代数的データ型(ADTs)とパターンマッチ

    • enumで複数バリアントを定義
    • match式で安全に分岐
    • 網羅性チェック による安全性強化
  • 構造体とimplブロック

    • structでデータ構造定義
    • implでメソッド実装
    • 不変デフォルト明示的可変(let mut)
  • 式指向・if let・let else構文

    • すべてが式として値を返す
    • if letlet elseによるパターンバインディング
  • インターフェースとジェネリクス

    • interfaceで抽象化
    • 型パラメータによる汎用関数
  • Option/Resultによるエラー・欠損値管理

    • nil禁止、欠損はOption<T>、エラーはResult<T, E>
    • ?演算子でエラー伝播
    • Resultの未処理検知 ・明示的破棄サポート
  • Goとのシームレスな型変換

    • Lisetteコード→Goコードへの安全な変換
    • 例:パターンマッチやエラー伝播がGoのif文・switch文に自動変換
  • パイプライン演算子・ラムダ式・メソッドチェーン

    • |>で関数合成
    • 無名関数・チェーン処理で記述性向上
  • 非同期・並行処理対応

    • taskでGoのgoroutine相当
    • Channel型とselect式で競合解決
  • tryブロック・panic/recover

    • 例外的状況も型安全に処理
    • recoverでpanicから安全に復帰
  • 属性によるシリアライズ・バリデーション

    • #[json(...)]#[tag(...)]で詳細な制御
  • 詳細なエラーメッセージとヒント

    • 網羅性・可変性・型の公開範囲・未初期化などを静的検査
    • 例:matchの未カバー・nil禁止・private type公開警告

Lisette言語の主な構文例

  • パターンマッチ

    enum Message { Ready, Write(string), Move { x: int, y: int } }
    fn handle(msg: Message) -> string {
      match msg {
        Message.Ready => "ready",
        Message.Write(text) => f"wrote: {text}",
        Message.Move { x, y } => f"move to ({x}, {y})",
      }
    }
    
  • if let/let else

    type Headers = Map<string, string>
    fn handle_headers(h: Headers) -> Result<(), string> {
      if let Some(token) = h.get("Authorization") {
        let user = authenticate(token)?
        authorize(user)?
      } else {
        return Err("missing credentials")
      }
      let Some(id) = h.get("X-Request-ID") else {
        return Err("missing request ID")
      }
      process(id)
    }
    
  • パイプライン演算子

    import "go:strings"
    fn slugify(input: string) -> string {
      input |> strings.TrimSpace |> strings.ToLower |> strings.ReplaceAll(" ", "-")
    }
    slugify(" Hello World ") // => "hello-world"
    
  • 並行処理

    let ch1 = Channel.new<string>()
    let ch2 = Channel.new<string>()
    task { ch1.send(fetch_primary()) }
    task { ch2.send(fetch_replica()) }
    let quickest = select {
      match ch1.receive() { Some(v) => v, None => "closed", },
      match ch2.receive() { Some(v) => v, None => "closed", },
    }
    
  • Goへのコンパイル例

    // Lisette
    fn classify(opt: Option<int>) -> string {
      match opt {
        Some(x) if x > 0 => "positive",
        Some(x) if x < 0 => "negative",
        Some(_) => "zero",
        None => "none",
      }
    }
    // Go
    func classify(opt lisette.Option[int]) string {
      if opt.Tag == lisette.OptionSome {
        x := opt.SomeVal
        if x > 0 { return "positive" }
        if x < 0 { return "negative" }
        return "zero"
      }
      return "none"
    }
    

静的検査・エラーメッセージ例

  • match網羅性チェック
    • 未カバー分岐を検出し、ヒントを表示
  • nil禁止
    • 欠損値は必ずOption<T>で表現
  • Result未処理警告
    • ?matchで必ずハンドリング
  • private type公開警告
    • 公開APIにprivate型を含めると警告
  • 不変変数の可変渡し警告
    • let mutで明示的に可変化
  • 構造体の未初期化警告
    • すべてのフィールド初期化が必須

開発環境サポート

  • LSP対応 :VSCode、Neovim、Zedなど主要エディタ
  • Goエコシステムとの連携 :Goのライブラリ・ツールチェーンを活用可能

Lisetteは 型安全性・エラー安全性・Go互換性 を高次元で両立した、 現代的なシステムプログラミング言語。Rustの洗練とGoの実用性を両取りしたい開発者に最適。

Hackerたちの意見

これ、すごくいい仕事だね。エラーメッセージだけでも、かなり気を使ってるのがわかるし、「ヘルプ」ヒントも本当に役に立ちそう。コンパイラのノイズじゃない感じがする。だけど、コンパイルされたGoの出力が気になるな。Resultのデスugarが結構冗長になるけど、生成されたコードには全然問題ないよね。でも、ランタイムで何かが壊れた時は、たぶんGoを読んでるんじゃなくてLisetteを読んでることになるよね。LSPはエラーをソースの位置にマッピングするのを扱ってるの?それと、既存のGoコードからLisetteを呼び出すことについても気になるな(逆の方向だけじゃなくて)。それが混合コードベースでの採用の難しい部分な気がする。最終的にはプロダクションレディを目指してるの?それとも言語デザインの探求がメインなのかな?どちらにしても、クールなプロジェクトだね。

ありがたいお言葉、ありがとうございます! :) CLIコマンドの lis run--debug フラグをサポートしていて、生成されたGoコードに //line source.lis:21:5 のディレクティブを挿入できるんです。これでランタイムエラーのスタックトレースが元のLisetteのソース位置に戻るんですよ。LSPはコンパイル時のエラーを処理していて、これは定義上 .lis ファイルを参照します。既存のGoからLisetteを呼び出すのはまだサポートされてなくて、あなたが言った通り、そっちの方向は難しいですね。頭にはあるんですが、今のところの優先事項は、ユーザーがLisetteから任意のGoのサードパーティパッケージをインポートできるようにすることです。Lisetteは探求から始まったけど、製品として使えるようにするつもりです。

正しい列挙サポートだけで満足だわ。

Goにコンパイルされる言語はいくつかあって、より良いGoを目指してるんだよね。思いつくのは、XGo (https://github.com/goplus)、Borgo (https://github.com/borgo-lang/borgo)、Soppo (https://github.com/halcyonnouveau/soppo) かな…

コンパイルエラーは、ターゲット言語からソース言語にどうやって伝播するの?

BorgoもLisetteも、(T, error)の返り値がResultの合計型と同じだと考えているようですが、これはすべてのケースで意味的に正しいわけではありません。例えば、io.ReaderインターフェースのReadメソッドは、(n!=0, io.EOF)が有効な返り値のパターンであるだけでなく、それがエラー条件ですらなく、単なる端末条件であることを指定しています。もしこの2つの返り値を相互排他的に扱うと、読み取りを停止すべきだということが見えなくなったり、バッファに有効なバイト数が置かれたことが見えなくなったりします。これは特に扱われるべきこととして十分知られているでしょうが、他のライブラリも複数の返り値の非排他性を創造的に利用していることがあります。

見た目は素晴らしいね。でも、ちょっと疑問に思うんだけど、Rustに似てるなら、機能が一致するところはRustと同じにすればいいのに。なんで "foo.bar" をインポートするの?それよりも、foo::barを使う方が良くない?Bar.Baz => の代わりに Bar::Baz => なのはなんで?何を達成しようとしてるの?Rustを知ってる人がまた別の言語を学ばなきゃいけないように、微妙に違う必要があるの?Rustを知らない人は、知識が1:1/naturallyにRustを書くのに役立たないほど違う言語を学ぶことになるの?それと、intなのにfloat64?編集:タイプミス

「(開発者が)そうしたいから」っていうのは、満足できる答えだと思う。こういう小さな言語は、実際のプロダクションで使われることを目指してるわけじゃなくて、楽しみや探求のために作られてることが多いからね。

これはただの構文の違いで、学ぶのは簡単だし、言語の主な目的ではないと思う。Rustの型システムの利点をGoにもたらすことが目的だからね。intとfloat64については、Goの数値型の名前から来てる。int、int64、float64はあるけど、floatはない。Rustにもisizeはあるけど、fsizeはないのと似てる。

同じだよ。最初はTypeScriptをベースにした高レベルのRustを書いてたけど、Rustはそんなに難しくないって気づいた。

Rustにインスパイアされてるけど、Rustを目指してないってこと?それにGoの開発者向けなんだよね?

私は言語を頻繁に切り替えていて、今はPHPを学んでるところ。構文の似てるところが逆に危険だと感じることもあるよ。「function」って見たらJavaScriptを書いてると思っちゃうけど、実際にはPHPで「+」で文字列を連結しようとして気づくんだ。「.」を使わなきゃいけないのにね。こういうのは学び始めの頃に特に感じる。

GC言語(Golangを含む)で実際のRustを書くのはかなり変なことになるよ。GCがもたらす制約を考慮して、メモリのモデルを完全に変えなきゃいけないからね。複数のアドレス空間があることによる制約に似てるけど、さらに変なのは、すべてのオブジェクトが自分自身の小さなアドレス空間で、参照はただのアドレス空間の記述子だから。

RustやGoにコンパイルされるPythonに似たプログラミング言語があったら、すごくいいね。

それってどんなメリットがあるの?すでに https://cython.org/ があるじゃん。

Spy (https://github.com/spylang/spy) はこういうものの初期バージョンだよ。Cにコンパイルされると思うけど、Nimみたいな感じかな。実際、Nimの話をすると、これの中では一番成熟した言語だと思うけど、SpyよりもPythonっぽくないかな。

これだよ。https://github.com/google/grumpy でも、最後のコミットは9年前だから、Python 2.7をターゲットにしてるね。

Mojoは、Swiftの作者が作った、高速な機械コードにコンパイルされるPython風の構文を持つ言語だよ。https://www.modular.com/open-source/mojo

F#はインデントベースだから、Pythonにすごく似てるよね。Fableを使えばRust(またはPython)にトランスパイルできるよ:https://github.com/fable-compiler/fable

NimはPythonに似ていて、ファーストクラスの型システムを持ち、wasmやCなど多くの異なるターゲットにコンパイルできます。

このスキルに記載されている静的Pythonです。 https://github.com/py2many/static-python-skill

ソースコードレベルじゃなくて、アセンブリやオブジェクトファイルレベルでGoと統合するのはどうなるのかな。Goのソースコードじゃなくて、Goのアセンブリにコンパイルされたらどうなるんだろう。

そのアプローチを試したことがあるけど、Goのアセンブリを生成するのは見た目よりも難しいよ。†: RustのコードをWASMを通してGoのアセンブリにトランスパイルしようとしたことがあるし、Goのバイナリにトランポリンを注入する方法も探ったことがある(これもGoのアセンブリ生成が関わってる)。

作者とは少し話したことがあるけど、実際にその言語を試したことはないんだ。すごく面白そうで、明らかに改善されてるね。Goが好きじゃないことはあまり隠さないけど、改善の限界があるかもしれないと思ってる。たとえば、typed nilはインターフェース型の変数(純粋なGoコードから来るもの)がLisetteにOption>として入るべきって意味だよね。確かに、Some(Some(h))でマッチさせれば二重のunwrapがいらなくなるけど、やっぱりちょっと awkward だよね。(注:このダブルOptionは、少なくとも今のLisetteでは存在しない)Lisetteは、Goがやるように非常に awkward な方法でdeferを呼ぶ必要をなくすわけじゃないし。例えば、書き込み用に開いたファイルは必ず二重にクローズすることが求められる。TypeScriptはJavaScriptを書くのに役立つけど、それはWASMが出るまではブラウザで実行できる他の言語オプションがなかったからだよね。だから、今やWASMができるからTypeScriptも売りにくくなると思う。基本的に、Rustがそこにあるのに、どうしてGoをRustに近づけようとするの?作者はその中間を目指してるのかもしれないけど、既存のコードベースの問題もあるし、すべてがグリーンフィールドじゃないからね。だから、既存のGoコードベースに最適だと思うし、何らかの理由でGoのランタイムを使いたいとき(確かに、Javaのランタイムよりはマシだし)に、より良い言語で使えるって感じだね。そして、確かにより良い言語に見える。だから、私にとって明らかじゃないのは(これを作者にも言ったけど)、次のファイルをGoじゃなくてLisetteにするためのクイックスタートガイドは何なのかってこと。これは欠点だとは思わないけど、単にいくつかの空白を埋める必要があるだけだと思う。

Rustの非同期処理はGoよりも使いにくいよね。主にガベージコレクションがないからなんだけど、それ自体が理由としてはいいのかも?

基本的に、RustがあるのにGoをRustに似せようとする理由は何?Goは計算とメモリ効率の良い並行GCにアクセスできるから、他にはあまりないよ。GCが本当に必要な問題領域(スパゲッティのような参照グラフをいじる場合)には素晴らしいプラットフォームだけど、Goの変なユーザーモードのスタックフルファイバー方式によって引き起こされる互換性の問題のせいで、巨大なC-FFIエコシステムを諦めることになるね(Cgoを使わない限り、Cgoはある意味Goじゃないし)。

基本的に、RustがあるのにGoをRustに似せようとする理由は何?平均的な開発者はGC言語の方がずっと早く動けるよ。最近、RustとPythonの両方でチャットボットを作ってみたけど、Rustに少し経験があっても、Pythonの方がずっと早かった。Goはこういう簡単なCLIツールを作るのにも最適だよね:https://github.com/sa-/wordle-tui

あなたのブログ記事から: > Goは10億ドルのミスに満足せず、2種類のNULLを持つことにした。 こういうことをわかりやすく取り上げてくれてありがとう。今、私が理解できないのは、TypeScriptがJavaScriptをもっと扱いやすくするためのものであったとしても、これを修正しなかったことです! TSはこの点でさらに悪化しています。それでもNodeJSエコシステムでは誰も気にしていないようです。だから、誰かの役に立つかもしれないと思って、自分のOption型のパッケージをNPMに作ったんです: https://www.npmjs.com/package/fp-sdk

赤ちゃんの名前を探してる人には朗報だね!今のところ、私のリストにはPascal、Ada、Dylan、Crystal、Lisa、Julia、Ruby、そしてLisetteが入ってるよ。

嫌なニュースです。アイデアや構文は結構好きなんですが、今離婚中の妻を思い出させるんですよね。ずっと思い出させられるのはちょっと辛いかも。

大きなデータ型は、PreludeのOption/Result/Tupleに問題を引き起こすかもしれませんね。ポインタとして保存されず、全ての受け取りは値渡しですから。

Goのランタイムはずっと好きだったけど、言語自体はちょっと使いにくいと思うし、改善されることはないだろうな(彼らは何も問題がないと思っているから)。でも、トランスパイラーを使うには本当にその言語が嫌いじゃないといけないですね。