Skip to content

Go とデータとタスク

Go 自身のライブラリ、フレームワークのライブラリ、タイマー、ウィンドウの外でする処理、そして保存すると書き換わるウィンドウ。

Go 自身の標準ライブラリ

fmtstringsstrconvsortmathtimeencoding/jsonencoding/csvregexposnet/http は言語自身のもので、両方の実行が同じコンパイル済みのパッケージを呼びます。 インタプリタはそれらを実装し直しません。 呼ぶのはコマンドに組み込まれたパッケージそのものです。 だから fmt.Sprintf("%.2f", x) が返すバイト列は、両方の実行で同じです。 ゲートがライブラリの二つの実装を比べることもありません。

package main

import (
    "encoding/json"
    "fmt"
    "regexp"
    "sort"
    "strconv"

    . "github.com/i2y/yokan/gomamochi"
)

type Stdlib struct {
    scores []int
    spread string
    doc    string
    sum    int
}

func (s *Stdlib) stats() {
    sorted := append([]int{}, s.scores...)
    sort.Ints(sorted)
    s.spread = fmt.Sprintf("median %d min %d max %d", sorted[len(sorted)/2], sorted[0], sorted[len(sorted)-1])
}

func (s *Stdlib) parse() {
    var doc map[string]any
    json.Unmarshal([]byte(`{"name": "gomamochi", "ok": true}`), &doc)
    s.doc = fmt.Sprintf("%v %v", doc["name"], doc["ok"])
}

func (s *Stdlib) scan() {
    s.sum = 0
    for _, m := range regexp.MustCompile(`\d+`).FindAllString("a1b22c333", -1) {
        v, _ := strconv.Atoi(m)
        s.sum += v
    }
}

func (s *Stdlib) View() Element {
    return Column(
        Text("spread: "+s.spread),
        Text("json: "+s.doc),
        Text(fmt.Sprintf("scan: %d", s.sum)),
        Row(
            Button("stats").OnClick(func() { s.stats() }),
            Button("parse").OnClick(func() { s.parse() }),
            Button("scan").OnClick(func() { s.scan() }),
        ).Spacing(6),
    ).Spacing(6).Padding(14)
}

func main() {
    Run(&Stdlib{scores: []int{3, 5, 8, 13, 21}, spread: "-", doc: "-"}, Title("stdlib"))
}

インタプリタが知っているライブラリは Go 1.22 のもので、そのリリースにあるパッケージと関数です。 言語のほうは、それより手前までしか知りません。 minmax(1.21)も、数を渡す range(1.22)も、関数を渡す range(1.23)もありません。 1.23 以降にライブラリへ足されたものも使えません。 最初の三つは、check がその名前を挙げて知らせます。 ファイルは os、ネットワークは net/http、大きな数は math/big です。 デモはファイルを読み、自分でページを立てて取得し、CSV を解析しますが、使うのは標準ライブラリだけです。

フレームワークの標準ライブラリ

エンジンが仲立ちするものは、フレームワークのパッケージから来ます。 そこでは一つの実装が、同じ C API を通して両方の実行に答えます。 スクリプトで走らせるときは、どちらの実行でもダイアログに file:<path> の手順が答え、音は鳴りません。

関数 働き
SqliteExec(db, sql, params...) 文を実行し、変わった行数を返す
SqliteQueryText, SqliteQueryInt, SqliteQueryRows 問い合わせの最初の列、最初のセル、行全体
SqliteQueryTextOr, SqliteQueryIntOr, SqliteQueryRowsOr 同じもの。問い合わせが失敗しても止まらず、何も返さない
ClipboardSetText(s), ClipboardGetText() システムのクリップボード
OpenDialog(title), SaveDialog(name) プラットフォーム自身のパネル。人の操作を待つので、Task の中で呼ぶ
AudioPlay(path, volume), AudioStop() WAV ファイルを鳴らして、あとは放っておく
NotifySend(title, body) 通知

文には ? を書き、値はそのあとの引数として渡します。 そうすれば、人が打った文字が文の一部になることはありません。

package main

import (
    "fmt"

    . "github.com/i2y/yokan/gomamochi"
)

const db = "demo/.gate/tour-notes.db"

type Notes struct {
    changed int
    rows    []string
}

func (nt *Notes) setup() {
    SqliteExec(db, "CREATE TABLE IF NOT EXISTS notes(t TEXT)")
    SqliteExec(db, "DELETE FROM notes")
    nt.changed = SqliteExec(db, "INSERT INTO notes VALUES (?), (?)", "alpha", "beta")
}

func (nt *Notes) View() Element {
    return Column(
        Text(fmt.Sprintf("inserted=%d rows=%d", nt.changed, len(nt.rows))),
        Row(
            Button("setup").OnClick(func() { nt.setup() }),
            Button("load").OnClick(func() { nt.rows = SqliteQueryText(db, "SELECT t FROM notes ORDER BY t") }),
        ).Spacing(6),
        ListView(len(nt.rows), func(i int) Element { return Text(nt.rows[i]) }).ItemHeight(22).Height(80),
    ).Spacing(8).Padding(12)
}

func main() {
    Run(&Notes{}, Title("notes"))
}

テーブルができる前に走るかもしれない問い合わせは、何も返さない …Or の形で書きます。 帳簿の最初の読み込みがそうです。 素の形は、ライブラリ自身と同じく panic になり、その値はライブラリの文言です。 ハンドラの中の recover が受け取れます。 受け取らなければ、アプリはそこで止まります。

タイマーと、ウィンドウの外でする処理

決まった間隔で呼んでほしい処理は、Every(seconds, tick)tick に渡します。 書くのは Run より前です。 二つの実行は一つの時計で動きます。 その時計を進めるのは、ウィンドウでは 1 フレーム、スクリプトでは advance:<ms> の 1 手順です。 だから、どちらの実行にも同じ数のティックが届きます。 ゲームが動くのも、キーボードを読むのも、このティックの中です。

Task(work, done) は、work を専用の goroutine で走らせます。 work が終わると、それが返した値を渡して、done をウィンドウのスレッドで呼びます。 work の中では、アプリのフィールドにも画面にも触りません。 決まりはこれだけです。 結果をどこかに書き込むのではなく done の引数で受け取るのも、そのためです。

package main

import (
    "fmt"

    . "github.com/i2y/yokan/gomamochi"
)

type Jobs struct {
    status string
    answer int
}

func (j *Jobs) start() {
    j.status = "working"
    Task(func() any {
        total := 0
        for i := 0; i < 300000; i++ {
            total += i % 7
        }
        return total
    }, func(v any) {
        j.answer = v.(int)
        j.status = "done"
    })
}

func (j *Jobs) View() Element {
    return Column(
        Text(fmt.Sprintf("%s: %d", j.status, j.answer)),
        Button("start").OnClick(func() { j.start() }),
    ).Spacing(10).Padding(14)
}

func main() {
    Run(&Jobs{status: "idle"}, Title("tasks"))
}

スクリプトは、次の手順に進む前にタスクが終わるのを待ちます。 だから click:start のあとの dump は、どちらの実行でも結果を出力します。 フレームワークのライブラリ(ダイアログや問い合わせ)は、タスクの goroutine からでも、アプリが自分で始めた goroutine からでも呼べます。 一つの呼び出しは、終わるまで同じスレッドで動きます。 アプリの側で気にすることは何もありません。

書いているあいだ

gomamochi run はアプリのファイルを監視しています。 保存すると、その変更がウィンドウに反映されます。 ファイルは新しいインタプリタで読み直され、ウィンドウが持っているアプリが、自分の値を新しいアプリに引き継ぎます。 名前と型が同じフィールドは、それまでの値をそのまま保ちます。 新しいファイルで足したフィールドは main が与えた値から始まり(与えなければゼロ値です)、型が変わったフィールドは値を引き継ぎません。 ファイルが宣言しているタイマーとショートカットは、新しいアプリに結び直されます。 コンパイルできないファイルを保存しても、ウィンドウは直前の表示のままで、端末にそのことが出ます。 次にコンパイルできるファイルを保存すれば、それが効きます。

保存するとファイル全体が読み直され、main も走り直します。 ただし、main がアプリに渡す初期値で、ウィンドウの持っている値が置き換わることはありません。 値は引き継がれます。 それがこの仕組みの狙いです。