TypeScriptは型安全じゃないからすばらしい

「TypeScriptではじめる型システム」という記事をn月刊ラムダノートに寄稿しました。

どんな内容?

TypeScriptの極小サブセットに対する型検査器を書き、それを通して型システムを体感してみよう、という内容です。

詳しく言うと、boolean型とnumber型と関数型しかないTypeScriptサブセット言語がターゲットです。 型検査器の実装言語にもTypeScript(処理系はDeno)を使います。 TypeScriptづくしの一品です。

わかる人向けに言うと、「型システム入門」という本(通称TAPL)の単純型付きラムダ計算に相当する内容をTypeScriptで説明してみた、ただし定理や証明はすべて省いた、という感じです。

なぜ書いた?

きっかけがいくつかありました。

TypeScript界隈でTAPLが読まれているらしいと聞いた

もともと、型システムを実務プログラマ向けに説明する記事を書いてみたいという気持ちはずっとありました。 TAPLは情報科学専攻の大学院生向けの教科書なのですが、型システムというキャッチーな内容から、実務プログラマにも注目されています。 しかしTAPLは、あまり実務で見かけないMLという言語で説明されている上、定理と証明が紙面の半分くらいを占めているので、「途中で脱落した」「型システム入門入門が欲しい」という話をよく聞いていました。

TAPLを翻訳したのは12年も前のことですが、今年になって急に、日本のTypeScript界隈でTAPLを読んでいる人たちが結構いるらしいと小耳にはさみました。 それも複数の経路で同時多発的に。 そのうちのひとつでは、TAPLの訳者として声をかけていただき、トークさせてもらいました。

これで、「いまこそ記事を書くべきなのでは?」という気分が高まりました。

TypeScriptの裏側を調べてみたかった

もうひとつの動機としては、TypeScriptの設計方針や型検査器の実装を少し真面目に理解してみたいと思ったことです。 私はTypeProfというRubyの型解析器を開発してます。

github.com

TypeProfを作るうえで、TypeScriptはいろいろ参考にしてます。 ただ、TypeScriptのユーザとしての体験は多少知っているつもりですが、裏側を真面目に見たことはなかったので、ここらで少しだけきちんと調べておきたいと思っていたのでした。 この執筆は、そのきっかけにもなりました。

TypeScriptは型安全じゃない?

そうして調べているうちにわかったのですが、TypeScriptはとてもいい意味で「雑」に作られているということがわかりました。 これは、漸進的型付けがどうこうという話ではなく、もっと全体的な設計・実装方針の話です。

TypeScriptが見逃すエラー

典型的な例としては、次のような未初期化変数の参照をするプログラムが型エラーになりません。

型検査器がこのような見逃しをする場合、型安全性があるとは言わないのがふつうだと思います。 これはTDZ(Temporal Dead Zone)と呼ばれるJavaScriptの仕様なので、ちょっと考えてみると、JavaScriptの構文を保ったまま解決するのはむずかしそうです。 実際、TypeScriptにissueがあがっていますが、「めったに出てこないケースなので気にすんな」ということでWontfixとなってました。

他にも、「全然関係ない変数宣言を削除すると、出るべきエラーが消えてしまう」という、かなり理解不能な挙動をする例も作れてしまいました。

この挙動の私の理解ですが、TypeScriptの型検査器はヒューリスティックに再帰を打ち切るところがたくさんあって、そのせいだと思います。つまりこの例は、ジェネリック型の展開打ち切りの閾値ぎりぎりのところに型エラーがあって、8行目の型判定を先にやっておくと判定結果がメモ化されることで探索範囲がぎりぎり型エラーに届くけど、8行目がないと型エラーまでの探索が深くなりすぎて打ち切られる、みたいな感じなのかなと思ってます。

ちなみに、TypeScriptが本当に型安全でないかは、型安全の定義によります。JavaScriptにトランスパイルされるので、未定義動作に陥ることは原理的にないから、「TypeScriptは何もしなくても型安全だ」という主張もふつうにありえます。このへんは記事のコラムにいろいろ書いたので参照ください。

それでもTypeScriptはよいもの

「雑」「型安全じゃない」というと、TypeScriptを悪く言っていると思われるかも知れませんが、まったく逆です。

現実問題として、TypeScriptが便利であることに異論を挟む人は少ないと思います。 JavaScriptは控えめに言っても(動的型付け云々以前に)いろいろと厳しい言語だと思うのですが、TypeScriptはJavaScriptのハンデを負ってなお便利なので、文句なくすごいです。

型システムに詳しい人は型安全性を気にしがちなのですが、TypeScriptは「別に全部の型エラーを検出できなくてもよい(型安全じゃなくてよい)」という思い切った割り切りをしています。 それでいて、「現実的によくあるバグは大体検出できて便利」という絶妙なバランスを達成しています。

ちなみに、ちょうどkmizuさんがほぼ同じようなことを言ってました。

TypeScriptの設計者の言葉

Anders Hejlsberg自身が、次のように言っています。

If you can't achieve perfection then you don't even try to go there and that means you cut out a whole bunch of possible things that you could do that you might not be able to prove soundness for, but we don't have that restriction and that actually makes our work very interesting because we can go in places where people typically don't go.

雑な意訳:型の研究者は型安全性が証明できそうにないことと試そうともしないので、実はいろいろできることを切り捨ててしまっている。TypeScriptにはそういう制約がない。そのおかげでわれわれは、他の型研究者がふつうは行かない領域に踏み込めるので、とても面白い仕事ができている。

www.youtube.com

これ、型の研究者には結構辛辣ですね。

個人的には、「ぜんぜん型安全じゃなくても便利な型解析器」は実際に存在できるんだというのが確信できてよかったです。 TypeProfもそういうところを目指していて、実現できるのか自分でも半信半疑でやっているのですが、達成した実例があるというのは心強く感じました。 TypeProfがそのクオリティとバランスを目指したいものです。

記事について雑多なこと

パーサはどうした

TypeScriptサブセットの型検査器を作るためにはパーサが必要だったのですが、その書き方を解説していると日が暮れるので、あらかじめ用意しておきました。

https://github.com/LambdaNote/support-ts-tapl/blob/main/utils.ts

eslintで使われているtypescript-estreeをラップする感じで作ってます。

TypeScriptプロジェクトのよい書き方がわからなかったので、とりあえずDeno専用になってます。 TypeScriptに詳しい人がみたら変な感じになってそうなので、適当にPRくれるとうれしいです。

Rubyじゃないのはなぜ

Rubyの型の開発に参加している立場でありながら、Ruby + RBSを記事の題材にしなかったのは、上に書いた動機のとおりなのですが、さらに技術的な理由として、Rubyでは無名関数が第一級の言語機能じゃないからです。 TAPLベースの型システムを説明したいという記事の方向性には、無名関数が必要でした。 もちろんProcはありますが、defを使わずにProcだけでプログラムを書くのはふつうのRubyじゃなさすぎるので。

続きが読みたい

単純型付きラムダ計算で終わっているので、人によっては「これで終わり?」って思うかもしれません。 basic.tsは部分型付け、型エイリアス、ジェネリクスなどを付け足していくための土台になっています。 今回の反響がよければ、それらで言語拡張するという続きを出せたらと思っています。

RubyでSlackのボットを書く方法(なるべく自力で)

RubyでSlackのボットを書くには、slack-ruby-client gemやruboty gemなどを使うのが一般的だと思います。 しかし個人的には、Slackボット程度でgemを使うのは好みでないので、なるべく素のRubyだけで書くようにしています。 その方法をまとめておきます。

Slack appを登録する

まず、https://api.slack.com/appsで"Create New App"して、適当に設定をします。

次のYAMLを"App Manifest"に貼ってSave Changesすると一気に設定できます。

display_information:
  name: Sample Slack App
features:
  bot_user:
    display_name: Sample Slack App
    always_online: true
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - chat:write
settings:
  event_subscriptions:
    request_url: https://example.com/
    bot_events:
      - app_mention
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false

かんたんに説明すると、メンションされたときに通知を受け取る(app_mentions:read Scopeとapp_mention Event Subscription)、チャンネルで発言する(chat:write Scope)、という設定です。

なお、メンションではない発言も全部受け取りたかったらchannels:history Scopeとmessage.channels Event Subscriptionを足すとよいです。

ボットからSlackにメッセージを送る

chat.postMessage APIを叩くだけです。 標準ライブラリだけで簡単にできます。

require "net/http"
require "json"

TOKEN = "xoxb-..."    # Bot User OAuth Token を埋める
CHANNEL = "CXXXXXXXX" # Channel ID を埋める

resp = Net::HTTP.post_form(
  URI.parse("https://slack.com/api/chat.postMessage"),
  {
    token: TOKEN,
    channel: CHANNEL,
    text: "Hello",
  }
)

json = JSON.parse(resp.body, symbolize_names: true)
pp json[:ok]  #=> true on success, false on failure

必要な設定は2つです。

  • Slack appの設定画面の"OAuth & Permissions"からBot User OAuth Tokenをコピーして、TOKENに入れる
  • 発言したいSlackチャンネルの"View channel details"の最下部にあるChannel IDをコピーして、CHANNELにいれる

そうしてコードを実行すれば、発言できるはずです。

ボットから発言した様子

ここでは単純に"Hello"というテキストを送っていますが、text: "Hello"の代わりに次のようなものを書けば、Slackのmarkdown風のマークアップができます。

{
  blocks: [
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: "*Hello* `world`"
      }
    }
  ]
}

このあたりについて詳しくはReference: blocksをご参照ください。 Block Kit Builderでインタラクティブに構築することもできるようです。

Slackからイベント通知を受け取る

Slackから「メンションされた」や「誰かが発言した」などのイベントの通知を受け取るには、Events APIを使います。

2024年現在、Events APIには2種類の通信方法があります。

  • publicなHTTPサーバを立てて、HTTPリクエストとして通知を受け取る
  • WebSocketでSlackに接続し、プッシュ通知を受け取る(Socket Mode)

両方かんたんに説明します。

HTTPサーバを立てる方法

sinatra gemでHTTPサーバを書きます。gemですが、sinatraは自分の心の許容リストに入っているのでOKとしています。

require "sinatra"
require "json"
require "openssl"

SIGNING_SECRET = "..." # Signing Secret で埋める

def verify_signature(timestamp, body, sig_actual)
  msg = ["v0", timestamp, body].join(":")
  sig_expected = "v0=" + OpenSSL::HMAC::hexdigest(OpenSSL::Digest::SHA256.new, SIGNING_SECRET, msg)
  OpenSSL.secure_compare(sig_actual, sig_expected)
end

post "/" do
  body = request.body.read

  # Slack からの POST であることを検証する
  halt 401, "{}" unless verify_signature(
    request.env["HTTP_X_SLACK_REQUEST_TIMESTAMP"],
    body,
    request.env["HTTP_X_SLACK_SIGNATURE"]
  )

  json = JSON.parse(body, symbolize_names: true)

  case json[:type]
  when "url_verification"
    # Slack に URL を登録するときのイベント、challenge をそのまま返せば良い
    json[:challenge]

  when "event_callback"
    event = json[:event]

    case event[:type]
    when "app_mention"
      # メンションされた
      p event[:text]
    end
    ""

  else
    ""
  end
end

必要な設定は1つだけです。

  • Slack appの設定画面の"Basic Information"の"App Credentials"からSigning Secret(hexで32桁)をコピーして、SIGNING_SECRETにいれる

このサーバをインターネットからアクセスできるところで実行します。 実験ではngrokなど使うとよいかもしれません。

$ ruby ~/bot-server.rb
...
== Sinatra (v4.0.0) has taken the stage on 4567 for development with backup from WEBrick
[20XX-XX-XX XX:XX:XX] INFO  WEBrick::HTTPServer#start: pid=XXXXXX port=4567

そして、"Event Subscriptions"のRequest URLで、立てたサーバのURLを入力します。 うまく行けば"Verified"となります。

URLの設定に成功した様子(Verified)

Slackでボットに対して@Sample Slack Bot Helloなどとメンションしてみましょう。 うまく行っていれば、HTTPサーバの方に発言内容が出ているはずです。

"<@XXXXXXXXXXX> Hello"
XXX.XXX.XXX.XXX - - [XX/XXX/20XX:XX:XX:XX +0900] "POST / HTTP/1.1" 200 - 0.0064

Socket Modeを使う方法

Socket Modeは、RubyからWebSocketでSlackに接続してプッシュ通知を受け取る方法です。 publicなHTTPサーバを用意しなくてよいので、運用は手軽かもしれません。

残念ながら、RubyでgemなしでWebSocketクライアントを使うのは大変です。いろいろ調べましたが、満足できる方法はみつけられませんでした。

調べたこと(クリックしたら詳細表示)

WebSocketプロトコルを扱うwebsocket gemはよくできていて、依存もゼロなので好みです。

ただ、実際に通信するgemとなると、eventmachineだったりfaradayだったり、巨大なgemに依存しがちです。

その点websocket-client-simple gemは、その手のものに依存しないクライアントというコンセプトは好きなのですが、細かいところで気になることが多かったです *1 。これを好みに合わせて直すくらいなら、Slackボットに特化したかんたんなものを自作するかって気分になりました。

net/httpがWebSocketをサポートしてくれたらいいのになあ。

ということで、あきらめてwebsocket gemのみに依存するslack_socket_mode_botというgemを作りました。それを使う例だけ示しておきます。

require "slack_socket_mode_bot"

SLACK_BOT_TOKEN = "xoxb-..."
SLACK_APP_TOKEN = "xapp-..."

bot = SlackSocketModeBot.new(token: SLACK_BOT_TOKEN, app_token: SLACK_APP_TOKEN) do |data|
  if data[:type] == "events_api" && data[:payload][:event][:type] == "app_mention"
    event = data[:payload][:event]

    p event[:text]
  end
end

bot.run

必要な設定は3つです。

  • Slack appの設定画面の"Socket Mode"でEnable Socket Modeを有効にする
  • Slack appの設定画面の"OAuth & Permissions"からBot User OAuth Tokenをコピーして、SLACK_BOT_TOKENに入れる(`xoxb-で始まるもの)
  • Slack appの設定画面の"Basic Information"の"App-Level Tokens"を作って、SLACK_APP_TOKENにいれる(xapp-で始まるもの)

あとは、このコードを実行するとSlackと通信し始めます。

$ ruby bot.rb

Slackでボットに対して@Sample Slack Bot Helloなどとメンションしてみましょう。 うまく行っていれば、発言内容が通知され、pで出力されるはずです。

"<@XXXXXXXXXXX> Hello"

ちなみに、Socket ModeのWebSocketはあくまでイベント通知を受け取るためだけのものであり、これを経由してchat.postMessageなどのAPIを呼ぶことはできません。 slack_socket_mode_botでは、SlackSocketModeBot#call(method, data)というAPIを呼ぶ方法もおまけで付けておきました。

まとめ

なるべくgemに頼らずにRubyでSlackのボットを書く方法を説明しました。 ここで述べた方法は、Ruby開発者のSlack workspaceで動くボットたちで長期間運用しています。

techlife.cookpad.com

もちろんslack-ruby-client gemなどを使うのがかんたんだと思うし、抵抗がないならそれが賢いと思います。 ただ、slack-ruby-client gemはSlack社謹製ではないので、たとえばまだSocket Modeに対応していないようです。

なぜgemに頼らないかというと、依存を減らしたいという個人的な好み(いわゆるNot Invented Here症候群かも)が最大の理由ですが、Slackはわりと頻繁にAPIを仕様変更するので、多少面倒でもSlack公式のAPIを直接叩いておくほうが長期的にはメンテナンス性が高いのでは? と思ったり思わなかったり。

変更履歴

  • 06/24 22:00 OpenSSL.secure_compare ã‚„ halt 401, "{}" を使うようにした(thanks @sora_h)

*1:websocket gem以外にも依存があるところ、内部的にThreadを作っているところ、ソケットを1文字ずつ読み込んでいるところ、pingフレームを処理してくれないところ、などなど。

継承はなんでダメ?

「オブジェクト指向の継承を使うな」という主張が広まっているようです。なんでダメになったんでしょうか。

インターネットで見かけた「継承はダメ」という主張をいくつか眺めて、友人と議論しつつ、考えてみました。

「コードが読みにくくなる」

継承があると、メソッド呼び出しが実際にどのメソッド定義を呼び出すのか字面でわからない。 デバッガを使って、親クラスのメソッドに飛んだり、子クラスに飛んだりするのを追いかけないと行けない。 つらい。という主張。

めっちゃわかる。わかるんですが、これは「高度に共通化されたコードは読みにくい」という一般的な側面がかなり大きいような。 たとえば継承の代わりに高階関数を使うと、関数呼び出しがどのクロージャに飛ぶか字面でわからなくなる。 ひどいとコールバック地獄になって何が何やらになります。

継承がことさらにまずい理由を想像すると、すべてのメソッド呼び出しがポリモーフィックになりうるのは、読みにくさを増してるかも。 高階関数なら、高階関数の呼び出しだけ注意すればよいから。C++ の virtual キーワードは正しかったのだ(?)。

そういえば「インターフェイスの継承は良いが、実装の継承はダメ」という派閥も見かけますが、この問題はあまり変わらなそうです。 なんなら、飛び先の候補が「親子クラスだけ」から「インターフェイスを実装した全クラス」になるので、悪化する可能性も。

「すぐ神クラスになる」

基底クラスについつい便利メソッドを生やしてしまい、気づけば便利メソッドを寄せ集めた「神クラス」になっている。 その便利メソッドたちは、誰が使ってるかわからないので、変更できなくなってしまう。 つらい。という主張。

前半(寄せ集め)はややわかる。 コンポジション等で切り出そうと思っても、既存のインスタンス変数にアクセスする必要があったりすると、ついついインスタンスメソッドにしてしまったり。 インスタンスメソッドが暗黙的に this/self を受け取り、容易にインスタンス変数にアクセスできてしまうのは、継承が強力すぎるせいといえる気はします。

ただ、「神クラス」を作ってしまう者は、継承を禁止すると、すべてを詰め込んだ単一「神モジュール」を育成させたりしないのかな。

後半(変更できなくなる)は、コード共通化そのものの問題で、継承以外でも起きる気がします。 いろんなところから使われているコードを変更するのはどうしたってしんどい。 関数で切り出しても、mix-in にしても、コンポジション・委譲で切り出しても、それは本質的にはかわらない。

継承だと特別しんどくなる理由があるだろうか。あまり思いつかなかった。 それこそインスタンス変数のアクセス性確保のために、切り出しがむずかしい面はあるかなあ?

「変な継承をする人がいる」

コードを再利用するためだけに、親子関係とは言えない継承をしてしまうプログラマがいる。 愚かものが悪用してしまう継承は禁止すべきだ。という主張。

幸い、意図的にそんなことをするプログラマは身近にいないので、最初はピンときませんでした。 でも、そういう継承関係が生まれるシナリオは思いつきました。

  • もともと Clock というクラスでアナログ時計を実装していた。
  • デジタル時計も実装したくなった。
  • Clock を基底クラスにし、AnalogClock クラスと DigitalClock クラスを派生させるとよさそう。
  • と思ったが、すでにいろんな人が new Clock() している。全部 new AnalogClock() に書き換えてもらう? うーん……
  • 互換性を重視して、やむなく DigitalClock extends Clock で妥協する。
  • 「デジタル時計がアナログ時計を継承する」という結果だけ見た人は、書いた人をバカだと思ってしまう。

ということで、互換性の配慮などの事情で、奇妙な継承関係は確かに生じうる。というか、身に覚えもある。

ただ、これは継承以外でも普通に起きる。 アナログ時計を作る create_clock() という関数を公開しちゃうと、後から create_analog_clock() にするのは大変。 未来のユーザは「なんで create_clock() がアナログ時計なの、バカなの」って思うのかもしれません。

(時計の例がしっくりこない人は、モノクロテレビ・カラーテレビとか。レトロニム一覧から好きな例を選んで読み替えてください)

継承がことさらに問題だとしたら、後知恵だと良い設計が見えやすく、そうなっていないことが気になりすぎるのかも。 関数なら「はいはい」って流せるけど、オブジェクト指向はドグマになっていて、奇妙な設計を見ることに耐えられない。 でもそれで継承自体を禁止しようというのは、もはやオブジェクト指向への偏執的な愛情って感じがしますね……。

まとめ

「継承はダメ」という理由で、ある程度共感したのはいまのところ2つ。

  • すべてのメソッド呼び出しがポリモーフィックになりうるので、どこに飛ぶか常に注意が必要で、コード読解が大変(いまどきならエディタ支援でなんとかならんかな)
  • 暗黙的に this を受け取るインスタンスメソッドは便利すぎて悪用しやすく、寄せ集めの「神クラス」につながりがち(ただ、継承禁止で本当に解決するかはわからない)

「継承の問題はそんな些末なことじゃない! もっと大きい問題だ!」という人がいたら、ぜひやさしく教えて下さい。

ブラウザでRubyを動かす夢

何に使うわけでもないけど、とにかくブラウザで Ruby を動かしたかったんです。

その夢が、ついにかなった気がします。

振り返ってみると、ここに来るまで 6 年もかかったようです。ちょっと嬉しくなったので経緯を書き残します。

Emscripten で Ruby をビルドする

2018 年、ふと思い立って、Emscripten で Ruby をビルドできるようにしました。

Emscripten は、要するに C/C++ プログラムを JavaScript や Wasm に変換してくれるコンパイラです。C で書かれた Ruby を Emscripten でビルドすれば、ブラウザで動く Ruby が作れるはず。

意外と微修正だけで miniruby *1 がビルドできて、簡単なコードなら動かせるようになりました。

mametter.hatenablog.com

安定した Emscripten'ed Ruby をメンテナンスする

ただ、ちょっと凝ったコードを走らせると落ちたり刺さったりして、まともに使えるクオリティにはできませんでした。また、Ruby も Emscripten も変わっていくので、しばらくしたらビルドできなくなってしまいました。

しょうがないので放置していたら、kateinoigakukun さんっていうスーパーハッカーがさっそうと現れて、Ruby の Wasm/WASI 対応をはじめてくれました。RubyKaigi 2022のキーノートは記憶に新しいですね。

rubykaigi.org

kateinoigakukun さんは自分と違って Wasm にちゃんと超詳しいので、不安定な挙動に対して場当たり的に対応するのではなく、まじめに根本原因を調べて改善することをやってくれました。また、ビルドシステムのメンテナンス力も高く、Wasm/WASI Ruby の nightly ビルドを配布してくれるようになりました。

github.com

katei さんの興味は WASI *2 のはずですが、なぜか Emscripten ビルドも提供してくれました。このおまけにより、そこそこ安定的な Ruby の Emscripten ビルドを無料で得られるようになったのでした。最高。

Emscripten プログラムを xterm.js につなぐ

ブラウザで Ruby が動くようになったので、いくつかアプリを書けました。楽しい。

ruby-puzzles-2022.cookpad.tech mame.github.io

ただ、別に Ruby でブラウザアプリが書きたかったわけではないんですよ。やっぱり REPL を動かしたい。何に使うわけでもないけど。

そのためには、ブラウザで動く端末につないで irb を動かさなきゃいけない。ブラウザで動く端末エミュレータには xterm.js があり、これは vscode でも使われている超安定ライブラリなので、あとは Emscripten'ed Ruby とつなぐだけでした。

これがまた大変でした。

Emscripten プログラムに限らないのですが、端末エミュレータとプログラムは通常、直結していません。Linux でも、C プログラムが printf("Hello\n"); とやると、端末エミュレータには Hello\r\n という文字列が渡されます。\r が挿入されていることに注意。これを挿入するのは、プログラムでも端末エミュレータでもなく、実は Linux カーネルです。Line discipline という機能がそれです。*3

xterm.js で Linux プログラムを動かす場合、node-pty という定番ライブラリがあり、vscode などもこれを使っているようです。が、これは Linux の pty のラッパなので、ブラウザでは動きません。Emscripten のために、ブラウザの上で動く pty がほしいという声はちらほらあるようでしたが、作った人はいないようでした。

ないなら作るかってことで、xterm-pty という Emscripten プログラムとつなぐための xterm.js アドオンを作りました。

github.com

これは要するに Line discipline を気合で実装したものです。ブラウザで Ruby を動かしたいと思ったら、いつのまにか Line discipline を JavaScript で実装していた。

まあ、xterm-pty で Vim が動いたときはなかなかの達成感でした。Vim のデモは↓を参照。

xterm-pty.netlify.app

なお、Vim を Emscripten すること自体は既出でした。あちらは Vim に特化したレンダラを自作したのに対し、こちらは xterm.js とつないで動かしたところが新規性。汎用的なので Vim 以外の CUI プログラムも動きます。詳しくはデモを参照。

xterm-pty をまともにする

で、irb を動かすために作った xterm-pty でしたが、実際に irb と満足につなぐには課題がありました。

Line discipline は、IO のやり取りだけでなく、シグナルを投げる役割もあります。Ctrl+C が押されたときに SIGINT を投げるのは、実は Line discipline です。しかし Emscripten プログラムにはシグナルという概念そのものがなかったので、投げようがありませんでした。

また、xterm-pty と Emscripten プログラムとつなぐ部分が極めて不安定でした。Emscripten ランタイムをモンキーパッチして read/write/select などのシステムコールをインターセプトしていたので、Emscripten がちょっと変数名を変えると動かなくなる。

そんなわけで、しょうがなく放置していたのですが、なんか Ingvar Stepanyan っていう Emscripten に超詳しいスーパーハッカーがさっそうと現れて、「Emscripten にシグナルを実装した」「モンキーパッチでなく Emscripten のプラグイン的に xterm-pty を使えるようにした」と言う、ほとんど作り直しに近い PR をくれたので、突如解決しました。最高。

github.com

ついに Ruby の REPL がブラウザで動く

刷新された xterm-pty をリリースしたので、組み合わせて irb を動かしてみたら、なんと一切のパッチを必要とせずに動きました。すごい、すごすぎる。6 年前と比べると別世界。

補完も出るし、イースターエッグのアニメーションも動きます。

twitter.com

いやー、これが見たかったんですよ。夢がかなった瞬間。何に使うわけでもないけど。

まとめ

ブラウザで Ruby を動かす夢がかないました。かなったんじゃないかな。

まあ、Thread.new が動かないとか、拡張ライブラリをロードできないとか、まだまだ課題はあるわけですが。

適当なものを作って放置してたらスーパーハッカーたちが直してくれる人生だったので、さらなるスーパーハッカーの登場を待ちたい。

おまけ

実は、kateinoigakukun さん自身が irb.wasm をやっています。

irb-wasm.vercel.app

これは Emscripten ではなく WASI で実現されています。

irb.wasm は jquery-terminal を使っているそうで、補完などは出ません。が、RubyWorld Conference 2022 で picoruby/picoirb を発表してた hasumikin さんに相談したら irb.wasm で xterm.js を使うモードを実装してくれたので、そっちなら補完が出ます。ただ、pty をまじめに模倣しているわけではなく、irb や reline にモンキーパッチをあててどうにかしているようなので、再現性はやや微妙かも。たとえば例のイースターエッグは(まだ)動かなかった。irb.wasm で xterm-pty を使うようにするとよさそうだけど、できるのかな?

*1:Ruby のビルド時に中間的に作られる簡易な Ruby インタプリタ。拡張ライブラリがロードできないなど、制限がある。

*2:WASI は、Wasm のポータブルなシステムインターフェイス。Emscripten がブラウザ特化の Wasm を出力するのに対し、WASI に基づいた Wasm はブラウザだけでなく配布用実行ファイルやエッジコンピューティング環境などで共通して使える。

*3:pty とか termios とかのキーワードのほうがわかりやすいかも。pty は Line discipline で繋がれたマスター・スレーブのペアで、termios はスレーブ側から Line discipline を制御するための API 、だと思ってますが、正確な定義は自信ない。

Rubyの全バージョンで動くQuine

このプログラムは、Ruby 0.49(1994年リリース)からRuby 3.2.1(今月リリース)まで、現在確認されているすべてのCRubyで動作するQuineです。

          eval($s=("t='eval($s=('+d=34.chr;s=3
        2.chr+$s*i=8;v=$VERSION||eval('begin;v=V
      ERSION;rescue;v||RUBY_VERSION;end');f=('?'*8
    +'A|'+'?'*20+'G?c'+'?'*15+'A@CXx@~@_`OpGxCxp@~pO
  xS|O~G?c?q?xC`AP|q?x_|C_xC_xO@H@cG?G?qA|_|_`GCpOxC|H
NFccqq@`_|OF@`?q?x_@x_x_`GB`O``O~G?C@qCxCxP@D@|G~C?pO|C?
  pO|C?AP|A~HNN`ccxC|Q@L@B"+"GpGpc@p?x_`GB`???_@FO|OB@
     xC|P`@?c?q?HPx@~@_`G@`????@L^`?q?x?xq@|_|O~GC`
        xA~@_@GBD').unpack('c*');w=4+v.length*u=
           15;r=10.chr;j=0;while-24+w*u>i=1+i
              ;x=i%w;x>0||t=t+d+'+'+r+d;k=
                 i/w%12>2&&x%u>3&&x%u+i
                    /w*11-34+('-._'.
                       index(c=v[
                         x/u,1]
                )||c.hex        +3)*99|
               |0;    k=f     [k/6   ][k%
                       6];    t=t     +s[
                      k*j     =k+     j,1
                 ]end;pr      int     (t+
                      d+'     ).s     pli
                       t.j    oin     [0,
               609    ])#     Y.E.   '+r)
                ").split        .join)#

プログラミング言語Ruby 30周年記念イベントでLT発表したものです。コードはGitHubに置きました。

github.com

動作方法

全バージョンで実行するには、rubylang/all-rubyというdocker imageを使ってください。

$ wget https://raw.githubusercontent.com/mame/all-ruby-quine/main/all-ruby-quine.rb

$ docker run --rm -ti -v `pwd`:/src rubylang/all-ruby

./bin/ruby-0.49 /src/all-ruby-quine.rb とすると、ruby 0.49で実行できます。

# ./bin/ruby-0.49 /src/all-ruby-quine.rb
eval($s=("t='eval($s=('+d=34.chr;s=32.chr+$s*i=8;v=$VERSION||eval"+
"('begin;v=VERSION;rescue;v||RUBY_VERSION;end');f=('?'*8+'A|'+'?'"+
"*20+'G?c'+'?'*15+'A@CXx@~@_`OpGxCxp@~pOxS|O~G?c?q?xC`AP|q?x_|C_x"+
"C_xO@H       @cG?G?qA|_|_`GCpOxC|HNFcc     qq@`_|O         F@`?q"+
"?x_@    x_x    _`GB`O``O~G?C@qCxCxP@D      @|G~C?    pO|    C?pO"+
"|C?A   P|A~H   NN`ccxC|Q@L@BGpGpc@p?   x   _`GB`?   ??_@F   O|OB"+
"@xC|   P`@?c   ?q?HPx@~@_`G@`????@L   ^`   ?q?x?x    q@|_   |O~G"+
"C`xA   ~@_@G   BD').unpack('c*');w   =4+   v.lengt          h*u="+
"15;r   =10.c   hr;j=0;while-24+w*u   >i=   1+i;x=i%w;x>0|   |t=t"+
"+d+'   +'+r+   d;k=i/w%12>2&&x%u>3           &&x%u+i/w*11   -34+"+
"('-.    _'.    index(c=   v[x/u,1])||c.h   ex+3)*   99||    0;k="+
"f[k/6]       [k%6];t=t+   s[k*j=k+j,1]en   d;print         (t+d+"+
"').split.join[0,609])#Y.E.'+r)t='eval($s=('+d=34.chr;s=32.chr+$s"+
"*i=8;v=$VERSION||eval('begin;v=VERSION;rescue;v||RUBY_VERSION;en"+
"d');f=('?'*8+'A|'+'?'*20+'G?c'+'?'*15+'A").split.join[0,609])#Y.E.

同様に、./bin/ruby-3.2.1 /src/all-ruby-quine.rb とすればruby 3.2.1で実行できます。

# ./bin/ruby-3.2.1 /src/all-ruby-quine.rb
eval($s=("t='eval($s=('+d=34.chr;s=32.chr+$s*i=8;v=$VERSION||eval('begin;v=VERSI"+
"ON;rescue;v||RUBY_VERSION;end');f=('?'*8+'A|'+'?'*20+'G?c'+'?'*15+'A@CXx@~@_`Op"+
"GxCxp@~pOxS|O~G?c?q?xC`AP|q?x_|C_xC_xO@H@cG?G?qA|_|_`GCpOxC|HNFccqq@`_|OF@`?q?x"+
"_@x_x        _`GB`O``O~G?C@qCxCxP@D         @|G~C?pO|C?pO|C?AP|A~HN    N`ccxC|Q"+
"@L@B   GpGp   c@p?x_`GB`???_@FO|OB   @xC|P   `@?c?q?HPx@~@_`G@`???     ?@L^`?q?"+
"x?xq@|_|O~GC   `xA~@_@GBD').unpack('c*');w   =4+v.length*u=15;r=1  0   .chr;j=0"+
";while-24+w   *u>i=1+i;x=i%w;x>0||t=t+d+'+   '+r+d;k=i/w%12>2&&x%u>3   &&x%u+i/"+
"w*11-3       4+('-._'.index(c=v[x/u,1])|    |c.hex+3)*99||0;k=f[k/6]   [k%6];t="+
"t+s[k*j=k+j   ,1]end;print(t+d+').spli    t.join[0,609])#Y.E.'+r)t='   eval($s="+
"('+d=34.chr;   s=32.chr+$s*i=8;v=$VE    RSION||eval('begin;v=VERSION   ;rescue;"+
"v||R   UBY_   VERSION;e   nd');f=(    '?'*8+'A|'+'?'*   20+'G?c'+'?'   *15+'A@C"+
"Xx@~@        _`OpGxCxp@   ~pOxS|O~           G?c?q?xC   `AP|q?x_|         C_xC_"+
"xO@H@cG?G?qA|_|_`GCpOxC|HNFccqq@`_|OF@`?q?x_@x_x_`GB`O``O~G?C@qCxCxP@D@|G~C?pO|"+
"C?pO|C?AP|A~HNN`ccxC|Q@L@BGpGpc@p?x_`GB`???_@FO|OB@xC|P`@?c?q?HPx@~@_`G@`????@L"+
"^`?q?x?xq@|_|O~GC`xA~@_@GBD').unpack('c*');w=4+v.length").split.join[0,609])#Y.E.

./bin/ruby-3.2.0 /src/all-ruby-quine.rb | ./bin/ruby-0.49 で、ruby 3.2.0の出力をruby 0.49で動かせます。逆も可。

全バージョンで動かしたかったら ./all-ruby all-ruby-quine.rb としてください。

Rubyの全バージョンで動くプログラムの作り方

たぶん誰の役にも立ちませんが、このQuineを書くのに得られた知見をまとめておきます。

ブロックは使用不可

ruby 0.49のブロックは、do ary.each using x ... end という記法だったようです。現代で見る ary.each {|x| ... } や ary.each do |x| ... end のようなブロックはありません。旧式の文法は現代のRubyではsyntax errorになるので、全バージョンで動かすにはブロックは使えません。while文などでがんばりましょう。

x += 1は使用不可

ruby 0.49には x += 1 や x ||= 1 のような構文がありません。x = x + 1 などと書き下します。

三項演算子は使用不可

ruby 0.49には cond ? a : b がまだありません。if文はありますが、ちょっと長いので cond && a || b などとするとおしゃれで しょう。

大きい文字列リテラルは使用不可

ruby 0.49で大きめ(700バイトくらい)の文字列リテラルを作るとSEGVするようでした。文字列の連結を使って回避します。ちなみに文字列の連結で動くということは、全バージョンでGCがそこそこ安定して動いているってことなので、結構すごいことです。

%-記法のリテラルや式展開は使用不可

この手のQuineで非常に便利な %(...) という文字列リテラルは、ruby 0.49では未実装です。String#splitやArray#joinがあるので、コードのアスキーアート化は eval(s="...メインのコード...".split.join) とすれば可能です。

あと、"#{expr}"もないので注意。がんばって文字列連結しましょう。

evalの中でメソッド定義は使用不可

ruby 1.3.1-990324 では eval("def foo; end") などとすると nested method definition という例外になります。おそらくバグなんですが、いずれにせよ全バージョンで動かすためには使えません。

evalの中でコメントは使用不可

ruby 1.1d0 で eval("1#") などとするとSEGVします。間違いなくバグですが、コメントを使うのは避けましょう。

evalでローカル変数を参照するのは不可

再現条件がやや微妙なのですが、ruby 0.51など初期バージョンでは次のコードでSEGVします。

s = "hello"; eval("print(t = s)")

evalで外のローカル変数を読み出すところにバグがあるようでした。グローバル変数を使うと安定して動きました。

$s = "hello"; eval("print(t = $s)")

str[idx]に注意

Ruby 1.8まで、str[idx]はidx番目のバイトを整数で返していましたが、1.9からは1文字の文字列に変わっています。なのでstr[idx] は使わないのが無難です。どうしても使いたかったら、たとえば次のように書けば良いでしょう。

ch="ABC"[1]; "@"[0] == 64 && ch=[ch].pack("C*")

これで全バージョンで ch == "B" になります。ポイントは、"@"[0] が整数かどうかを見て分岐するところです(?@ == 64 でもよい)。型がなくてよかったですね。

利用可能な組み込みメソッドに注意

当然ですが、昔のRubyは今ほど組み込みメソッドが充実していないので、何が利用可能かを慎重に試す必要があります。とはいえ、ruby 0.49の時点で意外と多いです。現代の組み込みメソッドの半分以上はすでにあるんじゃないかな。

どんなメソッドがあるかは、ruby 0.49に同梱されているspecというファイルが便利です。かつて「Rubyにはドキュメントがない」と言われていたのはなんだったのか。

Rubyのバージョン番号を出力するプログラム

all-ruby-quineの肝はインタプリタのバージョン番号を取得するところなのですが、これが結構 hacky でした。その部分だけ取り出した次のコードが、各Rubyバージョンでどのように解釈されるか説明しておきます。

print($VERSION||eval('begin;v=VERSION;rescue;v||RUBY_VERSION;end'))

このコードが各バージョンでどのように解釈されるかを説明しておきます。

0.49 .. 0.65

これらのバージョンでは $VERSION が定義されています。よって、$VERSION || ... の後半は評価されず、そのまま $VERSION が返ります。後半でeval を使っているのは、これらのバージョンでは begin; ...; end の構文がまだ存在せず、そのまま書いたら syntax error になるからです。

0.69 .. 0.76

ここが一番おもしろいです。これらのバージョンでは $VERSION が定義されておらず、定数のVERSIONが定義されています。後述する1.9.0からはRUBY_VERSIONにリネームされるのですが、RUBY_VERSIONを参照すると例外になってしまうので、少し工夫が必要です。次のようにしました。

begin
  v = VERSION
  rescue
  v || RUBY_VERSION
end

rescue のインデントがおかしいのは意図的です。というのも、これらのバージョンでは例外捕捉キーワードは resque であり、rescue はただのメソッド呼び出しと解釈されます。「なら undefined method エラーになるのでは?」と思うかもしれませんが、幸いなことにこれらのバージョンでは「未定義メソッド呼び出しは黙って nil を返す」というパワーのある仕様です。よって、変数 v に VERSION の中身が代入されたあと、v || RUBY_VERSION の後半は評価がショートカットされるので、無事 VERSION が返されます。

0.95 .. 1.8.7-p374

これらのバージョンでは VERSION が定義されています。0.95で resque が rescue に変わったので、素直に v = VERSION だけ評価されて値を返します。

1.9.0 ..

VERSION 定数が削除されたので、v = VERSION は NameError を投げます。しかし rescue によって補足されるので、RUBY_VERSION が評価されて値を返します。

感想

なにより驚いたのは、ruby 0.49の時点でかなりRubyになっていることです。現代のRubyの機能の半分以上はすでにありそう。

そして、そこからほとんど変わってないのもすごい。このQuine程度に非自明なプログラムが書けるくらいに、本質的な非互換がない。やりはじめたときは、ここまでできるとは思ってませんでした。

「Rubyは未完成だったので開発協力者がたくさん現れて、それがOSSとしての成功につながった」のようにmatzが語っているのはわりと有名ですが、そうはいっても「最初の時点でかなり完成していること、そしてそこからブレないこと」も成功するOSSの秘訣なのではないかという気がします。

それから、初期も含めて各リリースの品質が非常に高い。バグ回避のテクニックをたくさん書いたので言ってることと違うと思うかもしれませんが、言語処理系って多くの人が実戦で使ってはじめてまともになるものなんですよね。信じられない人はquine-relayを作ってみるといいです。世のマイナー言語処理系たちがいかにふつうのコードでもすぐSEGVするとか、致命的に機能が足りないとか、そもそも起動もしないとかがわかります。それを考えると、ユーザがほとんどいなかった初期でもRubyの各リリースの品質がここまで高いのは驚異的です。

ということで、matzはすごい!ということを体感できる遊びでした。だれかPythonとか他の言語でもやるといいと思います。

蛇足

ruby 0.49以前のコードが発掘されませんように。

Go言語の不満

ちょっとバイナリ配布したいツール↓があったので、Go言語と戯れました。

zenn.dev

ほぼはじめてGoを使ったので、にわかほど語りたがる法則に従って、Go言語の感想を書きます。

新しい言語にふれたときは、できることには気づきにくく、できないことに気づきやすいので、不満が多めです。主な比較対象はRuby、C言語、JS/TS、Rustあたりです。

よかったところ

  • ひとことで言えば「便利になったC言語」という感じでした。結構低レベルなAPIも揃っていてよかった(デーモン化が素直にできなかったこと以外)。
  • Rustと比べたらストレスフリーです。思った通りに書くだけでとりあえず動いてくれる。すばらしい。
  • 見た目はあきらかに長くてダサいですが、こだわりを捨てて割り切って書けると言えなくもない。
  • 配布しやすいシングルバイナリが作れるのはやはりよい。今回Goを選んだ理由がこれ。

細かいカプセル化がむずかしい

カプセル化の単位がパッケージしかないのがとにかくつらいです。ローカル変数以外の変数や構造体のフィールドが、パッケージ内のどこからも参照できてしまう。

今回はじめて自覚したのですが、ぼくは数十行単位の細かい抽象化を積み上げてプログラムを書くようです。たとえばRubyだと、インスタンス変数は基本的にclass~endの中でしかアクセスできません。これにより、単一ファイル内でも細かくカプセル化ができます。

同じことをGoでやるには、パッケージ(つまりディレクトリ)を分けないといけないので、数十行のファイルが一つだけあるディレクトリを大量に作るハメになりそうです。そんなの明らかにやりたくないし、Go的に推奨されてもなさそうです。

別案として、すべてのstructをinterfaceにキャストして扱う手も教わりましたが、それは明らかに面倒くさすぎる(わがまま)。

慣れの問題もあると思いますが、同一パッケージ内でもファイル単位などで手軽にカプセル化したい。この点はextern宣言やヘッダファイルを#includeするC言語のほうがマシとすら思った。

goroutineむずかしい

むずかしくないですか? wsl2-ssh-agentでは、複数のsshクライアントからのリクエストを受けて、それらをシーケンシャルに処理するサーバを書く必要があったのですが、設計に数時間以上かかりました。

とりあえず動かすだけなら10分くらいでできたんですが、雑に立ち上げたgoroutineをすべてきちんと終了させるのがめちゃくちゃしんどい。雑にチャンネルをクローズすると、それに書き込むgoroutineがpanicする可能性が(稀なレースコンディションとして)見つかったりするので、終了させるべき順序がめちゃくちゃ繊細です。今もバグが取り切れた自信はないです。

Goではcontextが便利という噂を聞いてましたが、IO待ちがcontextを受け取ってくれないので、肝心なところで使えない印象でした。

この問題は他言語なら楽というわけでもないですが、今回の問題に限って言えば、シングルスレッドでselect(2)みたいなAPIのほうが楽そうだったなあと思いました。Goを捨ててC言語で書き直すか迷ったくらい。「goroutineをすべてきちんと終了させる」なんて考えないほうがいいんですかね。

その他雑な感想

  • そうは言ってもやっぱ記述が冗長すぎる。if err != nil {} つらい。サンプルコードの読解すらつらい。
  • めっちゃ書かされるわりには型が弱いので、なんとなく割に合ってない気分。nil unsafeとかは別にいいんだけど、組み込みでoptional intくらい欲しい。
  • めっちゃ書かされるわりにはエラーの扱いが微妙? エラーのとき、どこで何が起きたか把握するのに手間がかかると思うことが多かった。スタックトレースで見たい。
  • テストむずかしい。os.Exit(0)を呼ぶ関数はほぼテスト不能とか *1 。動的言語のテスタビリティの高さをあらためて感じた。

*1:os.Exit を変数経由で呼ぶようにしておいてテスト時に差し替えるみたいなテクニックもあるようですが、コードにとって完全に無意味な複雑性をテストのためだけに入れるのはちょっとなー。

6x6リバーシの神

絶対に勝てない6x6リバーシを作りました。あなたは黒番、AIが白番です。

これは何?

6x6の盤面のリバーシは後手必勝 *1 であることが知られています。 このAIは白番(後手)で完璧にプレイします。つまり黒番のあなたは絶対に勝てません。無力感を楽しんでください。

技術的な話

このAIはWebAssemblyになっているので、全部あなたのブラウザの上で動いてます。真のサーバーレスです。

AIのソースコードはRustで書きました。わりと堅実なゲーム木探索になってます。UIは普通にTypeScriptとthree.jsで実装しました。

github.com

作った順に説明します。

盤面の表現

なにはともあれ、盤面を表すデータ構造を作りました(src/bitboard.rs)。 ファイル名の通り、ビットボードです。 Rustのu64(64ビット整数)を2つ使って1つの盤面を表現します(1つが黒石の位置、もう1つが白石の位置)。

着手可能数のカウントなどすべて、ビット演算を駆使してやります。 石をひっくり返す処理は探索性能に直結するのでがんばってます。 36マスすべてに特化したコードをマクロで生成してます(src/bitboard/flip.rs)。 ただし、最終的にWASMにするので、アセンブリを書くようなことはしてません。

ちなみにビットボードを選んだ理由は、そうしないとRustの所有権や借用がすごく面倒くさそうだったからです。 ビットボードならu64が2つだけなので、雑にCopyトレイトを実装しても大丈夫。

終盤のアルゴリズム

最初に書いた探索アルゴリズムは、単純なネガマックス法です。 これはゲーム終盤(空きマスが11個以下)の探索に使っています。

終盤の探索は、凝ったアルゴリズムより、定数オーダの最適化がよく効くところです。 たとえば、空きマスを探すために全マスをチェックすると遅いです(終盤の場合、多くのマスはすでに石があるので無駄)。 そこで、終盤の探索に入る前にすべての空きマスの位置を列挙して連結リストでつなぎ、再帰のたびにこのリストをなめるようにしています。

残念ながら、Rustはリスト構造の扱いが極めて不得意な言語です *2 。 Rustでリストを作るには(型チェックを通すために)リファレンスカウントのセルを使うのが定番のようですが、今回はパフォーマンスが重要なのでunsafeを使いました。 こういうときはunsafeで良いという知見をもらっていたので。

中盤のアルゴリズム

次に、単純なネガマックス法で深く読むことはできないので、空きマスが12個以上あるときのためのもう少し賢いアルゴリズムを実装しました。 置換表付きMTD(f)+評価関数によるムーブオーダリングという、とても昔ながらの方法です(src/search/midgame.rs)。

評価関数は、手動で作った特徴量(いわゆるedge2xのパターンと、着手可能数)を入力とし、勝ちそうか負けそうかを予測します。 これの学習のために、中盤の盤面を数十万ほどランダム生成し、それぞれに対して終盤探索のアルゴリズムを使って実際の勝敗を判定しました。 そして実際の勝敗を教師データとして焼きなまし法と最急降下法を適当に使って重みを決めました(src/learn.rs)。 学習した重みは浮動小数点数が3000個ちょっとなので、Rustの配列としてコードに直接埋め込んであります(data/weight.rs)。

あと、ビットボードのedge2xパターンから高速に係数を算出するために、完全ハッシュを用意しました。

補足 このヒューリスティックな評価関数はあくまでムーブオーダリング(良さそうな手から先に調べることで枝刈りを増やすテクニック)のために使っているだけです。メインの探索では、空きマスが11個のところまで読んだら終盤探索アルゴリズム自体を評価関数として使います。なので、全体としては近似解法ではなく完全探索になります。

序盤のアルゴリズム

6x6のリバーシは1993年に後手必勝であることが示されたそうです。 当時のワークステーションで数週間くらいかけて探索したらしいですが、そんな計算も現代では一瞬……かと思ったのですが、ここまでで作ったアルゴリズムで初期盤面からの探索をすると、1分強かかりました。ディープラーニングで評価関数作ってムーブオーダリングするともっと速くなるのかなあ?

これでリアルタイムに探索するのは厳しいので、序盤の16手をあらかじめ計算してしまうことにしました。 黒が任意の手を選ぶのに対し、白の手は1つだけ覚えればいいので、深さ8のツリーを作ります。 白は最善手(最も大きい石差で勝つ手)のうち、黒の着手可能数が小さくなる枝を選ぶようにすることで、なるべく覚える数を減らしました。 結果的に、およそ10万盤面程度を覚えることになりました。

ツリー部分は簡潔データ構造を使ったLOUDSという表現でエンコードし、およそ700キロバイトとなっています *3 。 そんなに大きくないので、include_bytes!マクロでバイナリデータを直接実行ファイルに埋め込みました。

これでAIは完成です。

ブラウザで動かす

ここまでは、Linuxでのネイティブ実行ファイルとして作ってました。 今回は探索をブラウザで動かす目標だったので、wasm-bindgenでブラウザ向けのAPIを用意し(src/api.rs)、wasm-packでWebAssembly(以下wasm)にしました。 emscriptenに比べるとびっくりするほど簡単にwasmができたのでびっくりしました。

ただ、動かして見るとpanicで落ちました。 「やっぱりwasm生成は完成度そんなに高くないのかなー」などと他人のせいにしつつ、何が起きているか調べてみると、usizeでオーバーフローが発生してました。 試してたネイティブビルドは64ビットなのでオーバーフローしないのですが、wasmは32ビットなのでオーバーフローが起きる。 要するに完全に自分のせいでした。完成度高いなあ。

直したら無事にwasmがWeb workerから動きました。 なお、panicは調べ始めてから数十分で直せたのに対し、wasm+Web workerのwebpackの設定に数時間ほど消費しました。webpack許せない。

UI部分

ここまでRustで開発してきたので、UIもyewで! などと考えなくもなかったのですが、この時点でRustへのヘイトが溜まりすぎていたのでTypeScriptで書きました。 いやー、ちょっとmapしたいだけなのにiterだのcollectだの書かないといけないとか、雑なコード書くとunwrapだらけになるし……。

閑話休題。

かっこいいUIを作りたくなったので、three.jsを使うことにしました。 最初はReactで実装しようとしましたが、three.jsが状態のかたまりで相性最悪だった *4 ので、素で書きました。

雑感

完全解析されているゲームは、どうやっても勝てないところを実際に体験したいですよね。 6x6リバーシのパーフェクトプレイの棋譜は発見者のJoel Feinsteinが書いたニュースレターを始めいろいろなところで見かけますが、インタラクティブにできるものは見つからなかったので作りました。

あと、wasmで遊んでみたかったのも動機です。 6x6リバーシは全盤面をデータベースにするのは明らかに無理なので、ブラウザで現実的に動かせる速度とバイナリサイズを実現するにはwasmがよいかなと。 はやくWebアプリのフロントエンドが全部wasmで動く世界になるといいなあ。

まとめ

6x6のリバーシはやっぱり勝てませんでした。

*1:後手が正しい手を選び続けたら、先手が絶対に勝てないこと。

*2:Rustとリストで検索すると「リスト構造は実用プログラムではほとんど登場しないからいいんだ」みたいな主張を多数見かけます。すっぱいぶどう?

*3:正確に言うと、ツリー構造のデータは80キロバイト程度で、大半は黒の手の価値(最終的な石差)と白が打つべき位置のデータです。

*4:最初は@react-three/fiberを試しましたが、まともにパフォーマンスを出すためにはアニメーション状態などをReactから別管理するしかなさそうで、Reactを使う意味が薄そうだった。