二つの実行
アプリは 1 ファイルです。 それを二通りに走らせ、その二つが同じプログラムであることが、Gomamochi の狙いのすべてです。 このページは、それぞれの実行が何でできていて、二つが何を共有していて、同じでない数少ない場所がどこかの話です。
二つの実行はどちらも Go ですが、Go のコンパイラが動くのは片方だけです。
配るときは、go build がファイルをコンパイルして、手渡すバイナリを作ります。
書いているあいだは、同じファイルを gomamochi コマンドに埋め込まれたインタプリタが読みます。
この非対称性の向きは、このエンジンの上のほかの言語とは逆です。
ほかの言語では、基準はインタプリタで、ずれる可能性があるのはコンパイルした実行のほうです。
Gomamochi では基準がコンパイルした実行で、ずれる可能性があるのは解釈実行のほうです。
二つの実行の違いは、すべてここから来ます。
何かが走る前に check がいくつかの書き方を断るのも、同じ理由からです。
一つの言語と一つのファイル
Gomamochi は独自の言語を定義せず、翻訳もしません。 書くのは Go で、そこにライブラリが足されています。
| 受け取る範囲 | |
|---|---|
go build |
Go 1.25 のすべて。配るアプリはこれでビルドします。check はコンパイラと同じ go/types でファイルを検査するので、そこで出る誤りは Go 自身の誤りで、Go 自身の言葉で返ります。 |
| yaegi | インタプリタとしての Go 1.22。ジェネリクス、クロージャ、goroutine、チャネル、そしてコマンドにコンパイルされた標準ライブラリのパッケージ。min と max、数や関数に対する range、アプリ自身の型に対する %T、reflect、unsafe、cgo、//go:embed はなく、標準ライブラリの外のモジュールもまだ読めません。それらは何かを組み立てる前に gomamochi check が断ります(Gomamochi が断る書き方)。 |
Gomamochi がその上に足すものは一つで、それは言語ではなくライブラリです。
画面を組み立てる要素と、Run、Every、Task、SqliteExec とその仲間です。
構造体もメソッドも goroutine も Go 自身のパッケージも、Go のものです。
書いているあいだに走るもの
解釈実行はこれだけです。 コマンドは Go のプログラムです。 yaegi(0.16.1 に固定)と、標準ライブラリのシンボルと、purego でエンジンのライブラリを開くパッケージを、コンパイルして組み込んであります。 コマンドはファイルを読み、新しいインタプリタに渡します。 インタプリタが呼ぶのは、バイナリがリンクするのと同じコンパイル済みのパッケージです。 エンジンを開くパッケージより上には、どちらの実行なのかを知っている場所はありません。
インタプリタがファイルを読む前に、書き換えが一つだけ入ります。
Go 1.22 からループ変数は反復ごとに作られ、それをキャプチャしたクロージャは自分の分を見ます。
yaegi は古い規則のままです。
そこで、クロージャがキャプチャするループ変数には、ループ本体の先頭でコピーを作ります。
コピーは波括弧と同じ行に置くので、行番号はずれません。
これでインタプリタも、コンパイラがクロージャに渡すのと同じものを見ます。
このコピーで隠れてしまう形が一つだけあります。
三節の for の変数に本体で代入する形で、これは書き換えずに断ります。
ウィンドウを開いたまま保存すると、ファイルが読み直されます。
新しいインタプリタが新しいファイルを読みます。
そこで Run が呼ばれても、ウィンドウはすでに開いているので、開き直しません。
ウィンドウが持っていたアプリの値は、名前と型の一致するフィールドだけ、新しいアプリにコピーされます。
値は残ります。
型の変わったフィールドは、初期値からやり直しです。
配るときに走るもの
書いたファイルをそのまま、CGO_ENABLED=0 の go build に通します。
翻訳はなく、書き換えもありません。
ループ変数の規則はコンパイラ自身のものだからです。
バイナリはシステム自身のライブラリ以外を何もリンクせず、起動時に、隣に置いたエンジンのライブラリを開きます。
--app は二つを一つのバンドルにまとめます。
二つが共に動かすもの
エンジンは一つで、C API の向こうにあります。
crates/pixie-capi(pixie のカーネルと、描画を受け持つ gpui)を一つの共有ライブラリにしたもので、どちらの実行も purego で開きます。
cgo は使いません。
Gomamochi の形がほかの言語と違うのは、ここだけです。
どちらの実行も C API を通り、どちらもエンジンを静的にリンクしません。
この API はわざと狭くしてあります。 要素は開いて、番号で書き込んで、閉じます。 ハンドラはエンジンを開くパッケージが配る番号で、エンジンからのコールバックが運ぶのは整数だけです。 エンジンは Go の値を持ちません。 一つの実装が、相手がどちらなのかを知らないまま、インタプリタにもコンパイル済みバイナリにも応えられるのはそのためです。
その番号も手では書きません。
elements.toml が唯一の表で、すべての要素、そのキーワード、型、既定値が入っています。
go run ./tools/gen がそこから、elements.go(要素ごとの型と、キーワードごとのメソッド)と、インタプリタから見えるパッケージの表を書き出します。
どちらかが表より古ければ、tools/gate_all.sh が落ちます。
だから、ある要素が Go では一つの意味を持ち、描かれる側では別の意味を持つ、ということが起きません。
インタプリタが、バイナリのリンクするものと違うパッケージを見ることもありません。
このエンジンの上のほかの三つの言語も、同じ表を読んでいます。
二つが同じでない場所
仕様はコンパイラです。 二つが食い違ったら、正しいのはコンパイルした実行で、解釈実行のほうに誤りがあります。 ゲートが何を言えるかは、ここから決まります。 緑のゲートが言うのは、書いているあいだに見ていたウィンドウが、配るものを見せていた、ということです。 違いは三種類あり、今どれを見ているのかを知っておくと役に立ちます。 三つとも、それを捕まえるものが違うからです。
1. 走る前に断られるもの
差の大半は、この引き算があらかじめ塞いでいます。
インタプリタが止まってしまう書き方や、動きはするのに黙って違う答えを出す書き方は、その行と書き換え方を添えて断られます。
何かを組み立てる前です。
check はまず go/parser でファイルを読みます。
これにツールチェーンは要りません。
パスに go があれば、続けてツールチェーン自身のエクスポートデータに照らして、go/types で型を検査します。
マップに対する range と、数や関数に対する range を見分けられるのは、この型検査です。
一覧はGomamochi が断る書き方にあり、文面を保持しているファイルから引いています。
そのうち三つはここで名前を挙げます。
Go を書く人が、考えずに書いてしまう書き方だからです。
min と max は Go 1.21 のもので、インタプリタは知りません。
比較を書き出すか、小さな関数を自分で書きます。
for i := range n は Go 1.22 のもので、インタプリタはそこで止まります。
三節の for を書きます。
マップに対する for k := range m は、ビューの中では断られます。
マップの順序は実行のたびに変わるからです。
並べ替えたキーのリストをハンドラで作ってアプリに持たせ、ビューではそのリストを回します。
ハンドラの中では、マップを自由に回せます。
2. 答えが違うもの
Go 自身の標準ライブラリは、両方の実行で同じコンパイル済みのコードです。
インタプリタが呼ぶ strconv、strings、math、sort はコマンドにコンパイルされたもので、バイナリは同じパッケージをリンクします。
だから、正しさを押さえるための双子も、Go が何を出力するかの表も要りません。
数の書式も、整数のオーバーフローも、文字列の大文字化も同じです。
ただし、インタプリタから見える標準ライブラリの表は Go 1.22 のものです。
それより後に足されたものは見えません。
答えが違うものが二つあります。
アプリ自身の型に %T を使うと、インタプリタが付けた名前が出ます。
Go の名前ではないので、これは断ります。
実行時の panic の文言も、インタプリタでは違う言い回しになります。
それを recover して画面に描くアプリなら、二つの実行に違いが出て、ゲートがそれを捕まえます。
これを断るものはまだありません。
フレームワーク自身の標準ライブラリは、一つの実装です。
データベース、クリップボード、OS のダイアログ、音、通知はエンジンのもので、どちらの実行も C API を通して同じコードに触ります。
そこでゲートが突き合わせているのは、一つのライブラリが二度答えたものです。
ファイル、ネットワーク、JSON、時計は Go 自身のパッケージ(os、net/http、encoding/json、time)で、両方の実行で同じコードです。
3. 同じプログラムで、速さが違うもの
コンパイルした実行のほうが速く走ります。
キャンバスのゲームの 1 フレームぶんの計算は、解釈実行では 100 倍ほど遅くなりました。
それでも 0.1 ミリ秒はかからず、1 フレームの中に十分収まります。
再帰の fib(25) は 200 倍遅くなりました。
書いているあいだ遅く感じるだけの長い計算は、振る舞いの違いではありません。
すぐに戻らないハンドラは、どちらの実行でもウィンドウを固めます。
Task があるのはそのためです。
時計
二つの実行は、一つの時計で動きます。
Every で宣言したタイマーは、ウィンドウでは 1 フレーム、スクリプトでは advance:<ms> の 1 手順で発火します。
だから、同じ数の刻みが両方に届きます。
ビューはマシンの時計を読めません。
ビューの中の time.Now は check が断ります。
読むのはハンドラかタイマーで、読んだ値はアプリに持たせます。
どちらの実行も、アプリが持っている値を描きます。
そのうえで確かめるもの
ゲートです。 一つのスクリプトで二つの実行を動かし、1 バイトずつ比べます。
$ ./bin/gomamochi gate demo/counter.go --script "click:+1,dump"
GATE OK — 3 dump lines identical in both runs
緑のゲートが言うのは、スクリプトが触れたすべてについて二つの実行が一致した、ということです。
スクリプトが届かなかったところについては何も言いません。
まして、フレームワーク自身のライブラリについては何も言いません。
そこは一つの実装が両方に答えるので、その実装にある誤りは両方の誤りになります。
tools/gate_all.sh があるのはそのためです。
すべてのデモをゲートに通し、断りの文面を置いてあるファイルと照らし合わせ、ツアーの完全な例もすべて通します。
三つ合わせて言えることが、Gomamochi の主張です。
コンパイルした実行は Go のコンパイラのものであり、書いているあいだに見ていたウィンドウは、それと一致していました。