🐇

MoonBit が WebAssembly 時代の理想(の原型)だった

2024/04/09に公開

最近 moonbit という言語を知ったのですが、これが調べれば調べるほど好きになる言語だったので、紹介させてください。

https://www.moonbitlang.com/

文法的には GC 付きの Rust で、 WebAssembly にコンパイルされます。とくに CDN Edge Worker 上での実行を想定しているようです。もう好き。

注意: まだ若い言語なので、これから言語仕様がガンガン変わっていくと思われます。あくまで現時点での情報です。

tl;dr

  • Pros
    • だいたい GC あり Rust と捉えていい
      • 文法面のキャッチアップが容易
      • ライフタイムの難しさを考えなくていい
    • すでに vscode 拡張やパッケージマネージャ等のエコシステムが整っている
  • Cons
    • まだ安定していない / しばらくはソースコードが公開されない
    • 現時点では学習リソースやパッケージ数が足りず、書き手の腕力が求められる

はじめに: JS/TS/Rust への不満

フロントエンドで使える TypeScript 代替言語をずっと求めていました。

というのも、 そもそも JS をベースにした TS では拡張に限界があると考えています。
これらは自分が常々思っていることです。

  • 整数と浮動小数点の区別がなく Number 型が基準になっている
    • これによって素の JS で wasm 用にコンパイルしたり、連携するのが困難
    • そもそも JIT のクセを見抜いてパフォーマンス最適化が必要
  • せっかく型システムがあるのに、トランスパイル時点で捨てていて、コンパイラに使われずもったいない
  • 後方互換性のために過去の文法が捨てられず、一部の仕様が硬直している
    • TC39 はよくやっている。しかし...
  • パターンマッチがない
  • オブジェクトをレコードの代替として使っているので、すべてに type: "datatype" のようなプロパティが増えてしまい、無理がある運用になっている

何かを拡張するベースとしては Rust の方が整っています。ただし、GUI のようなアプリケーション層で使うにはかなりしんどいです。

Rust Wasm の難しさを理解するために、requestAnimationFrame を使う例をみてみましょう。

#[wasm_bindgen(start)]
fn run() -> Result<(), JsValue> {
    let f = Rc::new(RefCell::new(None));
    let g = f.clone();
    let mut i = 0;
    *g.borrow_mut() = Some(Closure::new(move || {
        if i > 300 {
            body().set_text_content(Some("All done!"));
            let _ = f.borrow_mut().take();
            return;
        }
        i += 1;
        let text = format!("requestAnimationFrame has been called {} times.", i);
        body().set_text_content(Some(&text));
        request_animation_frame(f.borrow().as_ref().unwrap());
    }));
    request_animation_frame(g.borrow().as_ref().unwrap());
    Ok(())
}

これは Rust がだめな言語という話ではなく、フロントエンドのような GUI で多用するイベントハンドラでは、ライフタイム管理の難しさとわずらわしさが直撃するという話です。

前にこれを理解するために koba789 と YouTube で配信したアーカイブがあります。

https://www.youtube.com/watch?v=Cij3CUJmLXI

wasm 用の言語として zig や grain 等も悪くはないのですが、 wasm をメインにしているわけではないので、ちょっと無理がありました。


というわけで、moonbit を使ってみましょう

インストール

ブラウザ上の vscode で試せます。

https://try.moonbitlang.com/

手元にインストールする場合

https://www.moonbitlang.com/download/

# mac の例
/bin/bash -c "$(curl -fsSL https://cli.moonbitlang.com/mac_m1_moon_setup.sh)"
# パスを通したりは略

vscode extension を入れておけば問題なくローカル開発ができます。

https://marketplace.visualstudio.com/items?itemName=moonbit.moonbit-lang

公式の補完拡張もあると便利です。これはローカルサンプリングとグローバルサンプリングという概念があって、言語レベルで Copilot 的な補完に対応しています。

https://marketplace.visualstudio.com/items?itemName=moonbit.moonbit-ai

速習 moonbit

とりあえずプロジェクトを作って実行してみます。

$ moon new hello
$ cd hello
$ moon run main
hello, world

とりあえず実行できました。

それでは main/main.mbt を書いてみましょう。

だいたい Rust です。GC 言語なのでライフタイムトレイトはありません。

// function
fn add(a: Int, b: Int) -> Int {
  return a + b
}

// generics
fn self[T](v: T) -> T {
  return v
}

// generics trait
fn _lge[X : Compare](a: X, b: X) -> Bool {
  return a >= b
}

// data structure
enum T {
  A
  B
}

struct Point {
  x: Int
  y: Int
} derive(Debug)

fn distance(self: Point, other: Point) -> Double {
  sqrt((self.x * other.x + self.y * other.y).to_double())
}

// Generics and derived trait
struct Point3d[N] {
  x: N
  y: N
  z: N
} derive(Debug)

// newtype
// type Point3dInt Point3d[Int]

// trait
trait Animal {
  speak(Self) -> Unit
}
struct Duck {
  name: String
}
fn speak(self: Duck) -> Unit {
  let name = self.name
  println("\(name): Quak!")
}

fn main {
  println("Hello, World!")

  // variable
  let _x = "hello"
  let mut y: Int = 2

  let _multiline_text =
    #| hello
    #| world
    #| multiline

  y = 3
  let p = Point::{x: 1, y: 2}
  println(p.distance(Point::{x: 3, y: 4}))
  debug(p)

  // function and call
  let _ = add(1, 2)
  // pipeline
  let _ = 1 |> add(2) |> add(4)

  // call ./foo.mbt: pub fn foo() -> Int { 1 }
  let _ = foo()

  // list and iterator
  let list = [1, p.x, p.y]
  let mapped = list.map(fn (x) -> Int { return x + 1 })
  println(mapped)

  // inference
  let _: Int = self(1) // as Int

  // trait
  let duck = Duck::{name: "Donald"} as Animal
  duck.speak()

  // if else
  if y == 2 {
    println("y is 1")
  } else {
    println("y is not 1")
  }

  // match
  let mut m = T::A
  m = T::B
  let _ = match m {
    T::A => 1
    T::B => 2
  }

  // for
  for i = 1; i < 5; i = i + 1 {
    print(i)
  }
  println("")

  // while
  let mut i = 0
  while i > 0 {
    i = i - 1
  }

  // scope
  {
    let x = 1
    println(x)
  }
}

// inline test
test {
  let a = 1
  let b = 2
  let c = add(a, b)
  @assertion.assert_eq(c, 3)?
}

実行

$ moon run main
Hello, World!
3.3166247903554
{x: 1, y: 2}
[2, 2, 3]
Donald: Quak!
y is not 1
1234
1

マクロや deribe やジェネリクスの書き方が違う以外は、だいたい Rust ですね。シンタックスハイライトも Rust を借用しています。

sturct とパターンマッチ、パイプライン演算子がいい感じです。TypeScript と違って、コンパイルが通ったら実行できるという安心感があります。書いてみたらわかりますが、補完もいい感じです。

もちろん moonbit の言語機能はこれだけではありません。説明が難しいモジュールシステムやパッケージマネージャ周りは省いています。

が、一旦他のプログラミングがわかるなら、これで一通り理解できるでしょう。

wasm compile: ブラウザでも使う

moonbit は wasm 向けにコンパイルされる言語なので、ブラウザで実行することができます。

https://github.com/moonbitlang/moonbit-docs/tree/main/examples/wasm-gc

さっきのコードをブラウザで実行してみましょう。

$ moon build

これで ./target/wasm/build/main/main.wasm が生成されます。
これをブラウザから読み込んで見ましょう。

<html lang="en">

<body>
  <canvas id="canvas" width="150" height="150"></canvas>
</body>
<script>
  let memory;
  const [log, flush] = (() => {
    let buffer = [];
    function flush() {
      if (buffer.length > 0) {
        console.log(new TextDecoder("utf-16").decode(new Uint16Array(buffer).valueOf()));
        buffer = [];
      }
    }
    function log(ch) {
      if (ch == '\n'.charCodeAt(0)) { flush(); }
      else if (ch == '\r'.charCodeAt(0)) { /* noop */ }
      else { buffer.push(ch); }
    }
    return [log, flush]
  })();

  const importObject = {
    spectest: {
      print_char: log
    },
    js_string: {
      new: (offset, length) => {
        const bytes = new Uint16Array(memory.buffer, offset, length);
        const string = new TextDecoder("utf-16").decode(bytes);
        return string
      },
      empty: () => { return "" },
      log: (string) => { console.log(string) },
      append: (s1, s2) => { return (s1 + s2) },
    }
  };

  WebAssembly.instantiateStreaming(fetch("/target/wasm/release/build/main/main.wasm"), importObject).then(
    (obj) => {
      memory = obj.instance.exports["moonbit.memory"];
      obj.instance.exports._start();
      flush();
    }
  )
</script>

</html>

ブラウザで表示してみましょう。

$ npx http-server -c-1 ./

ホスト言語から moonbit.memory を割り当てるだけです。簡単ですね。

ビルドサイズもこの時点で 11k と、現実的なサイズです。コード量に対してサイズが大きい気がしなくもないですが、コアランタイムが十分小さいのとそれなりに言語機能を使っているので、この程度な気がします。

ちなみに一番小さい状態でビルドしたら、107 bytes でした。

fn main { () }

moonbit で困った時の調べ方

困ったら公式 examples で調べると、だいたいのパターンは見つかります。

https://github.com/moonbitlang/moonbit-docs/tree/main/examples

discuss で調べると色々出てきます。

https://discuss.moonbitlang.com

awesome-moonbit はまだあんまり数がないです。

https://github.com/moonbitlang/awesome-moonbit

Moonbit の良さ

Moonbit は Rust と AI, Wasm による Edge Worker を強く意識した言語です。この方向性が自分はすでに好きです。

https://www.moonbitlang.com/blog/moonbit-ai

MoonBit は、ChatGPT の発表と同時に 2022 年 10 月に開始されました。 MoonBit プラットフォームは、単なるプログラミング言語ではなく、IDE、コンパイラー、ビルド システム、パッケージ マネージャーなどを含む開発者ツールチェーンとしても登場しました。私たちは、プログラミング言語ツールチェーン全体が AI と最適に連携する方法を再考するユニークな立場にありました。

(Google 翻訳)

AI を前提とした補完とした補完も面白いですね。

ツールチェインがすでに整っているので、意外と書き味もよいです。

Moon 言語への不満

ライブラリの少なさ

まずは、パッケージがとにかく少ないです。これ自体は仕方ないですね。

https://mooncakes.io/

モジュールシステム

良い点でも悪い点でもあるのですが、moon.pkg.json や moon.mod.json でスコープを明示するので、JS の import や Rust の use に相当する文法がないです。これは補完インターフェースを IDE に伝えるのが容易にするための制約だと思うのですが、ファイルスコープでそのファイルの役割を類推するのが個人的にちょっとむずかしく思いました。

AI が効かない

また、AI による補完を謳っていますが、現状は似ている Rust に引っ張られすぎてしまいます。

なので、自分は Copilot を止めています。

.vscode/settings.json
{
  "github.copilot.editor.enableCodeActions": false,
  "github.copilot.editor.enableAutoCompletions": false
}

展望

まだ言語として若すぎるし、自分もまだ巨大なコードベースをターゲットにしたわけではないので、ゲームのような前提の少ない特定のユースケース以外での実用は難しい気がします。ただ、方向性が素晴らしく、未来を感じます。

とくに CDN edge worker は技術スタックに対して前提が少ないので(JSONやwasmを返すだけ)、こういう新しい技術を使うのにうってつけです。

公式にも CF Worker の例があります。

https://github.com/moonbitlang/moonbit-docs/tree/main/examples/cf_worker

pub fn fib(n : Int) -> Int64 {
  loop 0L, 1L, n {
    a, _, 0 => a
    a, b, n => continue b, a + b, n - 1
  }
}
import wasm from '../target/wasm-gc/release/build/hello.wasm';
const module = await WebAssembly.instantiate(wasm);
module.exports._start();
export interface Env {}

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
		return new Response(`Hello World! ${module.exports.fib(10)}`);
	}
};

今は数値計算をしているだけですが、エコシステムが整えば wasm で書くことができるようになるでしょう。というか見た感じまだ async や非同期ランタイムがないので、これも必要になりますね。

これから破壊的な変更が続くと思いますが、その先に moonbit が覇権を取る未来があるかもしれないです。というか、なってほしいですね。

Discussion