mail: mjt _at_ cltn.org / [twitter] / [github] / [Qiita] / [pixiv] / [落書きコーナー](Tumblr)

Amazon FireTV Stick 4K

3台目、スマートTV機器としては5台目。

... 以前のはみんな2015年に買ってるのか。。当時は各社からこの手のデバイスが次々と投入されていて、microconsoleという言葉もちょっとしたブームになっていた。現状ではApple TV+ がFireTVに投入されることが発表される等、ハードウェアの主戦場は既にスピーカーに移っており、TVは当時ほどの盛り上りを見せていない。

今回は普通にAmazonで購入。AmazonのデバイスAmazonで購入した場合アカウント紐付になるという説明があるが、今回はセール期間に購入したためか、手動でアカウントの登録作業をする必要があった。

f:id:mjt:20190406173752p:plain

年々大型化している気がするAmazon Fire Stick。上が初代Stick(2014) 、下が今回購入したFireTV Stick 4K。初代はそもそも超クッソ激烈に熱暴走しやすくアプリのインストールやWebブラウズ等を行うと直ぐ熱暴走していた。

f:id:mjt:20190406181126p:plain

今回のStick 4Kは流石に通常の使用範囲では熱暴走することはなかった。

初代のGPURaspberry Piと同じVideo Core 4、今回のStick 4KのGPUPowerVRになっている。初代FireTV Stickは既にディスコンなため、実は現状のFireTV(やAppleTV)は全てGLES 3.0以降を仮定できる。

FireTV Stck 4KのUIパフォーマンスは比較的良好で、特に初代Stickと比べると雲泥の差がある。

FireOS 5 vs FireOS 6

前回のFireTV(gen3)はスキップし、FireTV Cube(gen1)は日本での扱いが無いので、今回のFireTV Stick 4Kが手元のデバイスとしては最初のFireOS 6デバイスになった。

FireOS 5 は Androidで言うところの 5.1 Lollipop (API Level 22)に相当し、 FireOS 6 は Android で言うところの 8.1 Nougat (API Level 25) に相当する。基本的にFireOS6はFireOS5の後方互換となっているが:

といった絶妙な違いがある。

ホーム画面や設定UI等Amazon独自アプリはFireOS5デバイスとFireOS6デバイスで共有されており、初代FireTV StickとFireTV Stick 4Kでパフォーマンスは大きく違うものの 基本的に全く同じ UIが表示される。(もちろんデバイスのケーパビリティや地域に合わせた変更は行われる -- 手元ではアップデート前は日本未発売のFireTV Recastの設定項目が表示されていたが、アップデートで削除された)

まだ 4Kでない FireTV stickがFireOS5で販売されているので、FireOS5が絶滅したわけではない。

基盤バージョンの違いが一番不味い形で表われるのはDolby Digitalのサポートで、最初にJelly Beanベースでリリースされた初代FireTV / FireTV Stickを想定すると相当に複雑な処理が必要になってしまう( https://developer.amazon.com/ja/docs/fire-tv/dolby-integration-guidelines.html )。Amazonは、これらの旧世代機向けにExoPlayerの移植版を提供( https://github.com/amzn/exoplayer-amazon-port )することで移植コストの低減を図っている。

ゲーミング

Amazon FireTV gen2 は専用のゲームコントローラを同時に発売し、コントローラ側のヘッドホンサポートや専用ゲームの展開等それなりにゲームを推していた。しかし、2017年以降はこの傾向は失われてきた。専用のゲームプラットフォームであり、初代ゲームコントローラには専用ボタンまで存在した GameCircleは廃止 されているし、FireTVのパッケージでも一切ゲームへの言及は無い。過去のFireTVでは:

(初代FireTV stickのパッケージ)

簡単に使える

テレビに直接差し込んで、すぐに使い始められます。簡単な操作で映画、TV番組、アプリ、ゲームが見つかります。

(FireTV gen2のパッケージ)

快適なゲーム体験

アクションからカジュアルまで、人気のゲームが勢揃い。別売りのゲームコントローラーを追加すればゲームがより本格的に。

のようにゲームに言及していた。

Stick 4Kのゲーム対応状況は何とも言えない。聖剣伝説( https://www.amazon.co.jp/dp/B01CZKNQQM )は 非対応 、GLES2でも動作する脅威の互換性のアスファルト8( https://www.amazon.co.jp/dp/B00EQ0CKRQ )は当然の権利のように対応、Unity製のクロッシーロード( https://www.amazon.co.jp/dp/B00QW8TYWO )も当然対応。スクエア・エニックスAmazon AppStoreに積極的にゲームを提供しているが、ネイティブ動作のゲームでStick 4K対応のものはかなり少い。(FF12等、G-Clusterが展開するクラウドストリーミング版は相当な数があるものの。)

もっとも、Amazonがゲームから手を引いたわけではない。自身のゲームスタジオであるAmazon Game Studiosは現在はPCやコンソール向けにゲームを供給しており、Prime Videoコンテンツのゲーム化である The Grand Tour Game をリリースしている。

ちなみにFireTVは標準のWebブラウザとしてFirefoxを採用しており、このFirefoxゲームコントローラAPIWebGLもサポートしている (元々YouTube見せる用なんだからWebAPIの対応状況が良いのは当たり前な気もするが)。XboxOneのBluetooth対応コントローラも接続でき、ゲームコントローラとして使用できた。

f:id:mjt:20190406181544p:plain

(スクリーンショットは初代FireTV StickにFireTVゲームコントローラとXboxOneコントローラの両方を無線接続したもの。 最近のFireTVはFireTVゲームコントローラに対応しない 。)

ただMansion Demo( https://www.babylonjs.com/demos/mansion/ )でも10fps出るかどうかというところなのであんまりゲームプラットフォームとして使うのは実用的ではないかも知れない。

f:id:mjt:20190406190311j:plain

他のブラウザ選択肢としてはAmazon Silkが有り、タブレット版とは異なり単なるWebKitベースブラウザのようだ。

Unbox Experience

最近のFireTVは電源投入時の導入動画に続けて、アプリケーションのインストールを促す画面が挟まれるようになった。

f:id:mjt:20190407005145j:plain

"趣味・教養"の欄には麻雀ゲームであるMaru-Jan、"その他おすすめ"には オンラインカジノトレバ (ストリーミングによるクレーンゲーム)、テトリス等がある。表向きにはゲームは推していないものの、セットアップ時にはインストールを促されるし、アプリのカテゴリにはゲーム自体は健在となっている。

Amazonのアカウントリンクはパスワードを手で入力する必要があり、なかなか辛いものになっている。Nexus Playerではワンタイムパスコード6桁の入力で済む( http://g.co/AndroidTV )ことを考えると。。ちなみに箱に載っているniconicoも同様で、OSKでパスワードをポチポチ打つ必要がある。

開発、PowerVR SDK

GPUとしてIMGのGE8300を搭載しているため、PowerVR SDKを使用してパフォーマンスカウンタ等の読取が可能になっている。ただし、 Vulkanは使用できないファイルシステムには libvulkan.so が有るものの /vendor 側にドライバが無く、正常にロードできないようだ。公称のOpenGLESバージョンは 3.2 。

SDKサンプルはそのままで正常にインストール / 起動できる。

f:id:mjt:20190407000003j:plain

Graphics APIとしてOpenGL ESを含めておけばUnityのプロジェクトもそのまま実行でき、PVRHub経由で起動すればプロファイルも可能となっている。

FireTV伝統のオンスクリーンデバッグインターフェースである System XRay はなんと 完全に日本語化 され、項目の拡充が行われている。(同じ機能はFireOS5を採用するデバイスでも使用できる)

f:id:mjt:20190407002832j:plain

System XRayはビデオCODECの状況を表示するデバッグ機能が有るなど、ストリーミングプレイヤとしての機能性にそれなりの配慮が見られる。

f:id:mjt:20190407154233j:plain

基本的にハードウェアCODECを使用すると常に表示されるため、例えばMiracast受信を行ってもちゃんとCodec情報やFPSが表示される。MiracastはWi-Fi directであるため、NETが空欄で表示される。

FireTV Stick 4K は最後のTVデバイスになるか?

Stick 4KはそれなりのパフォーマンスをStick型のフォームファクタで実現した。有線LANの無いシステムはこれが最終形なのだろうか。

依然RokuやAppleは4Kプラットフォームを箱型で提供しているし、FireTV Cubeのようなハイブリッドデバイスには依然席は有るかもしれない。しかし、microconsoleに始まる"インタラクティブ10-feet screenプラットフォーム"としては、Stick 4Kの実現している内容が最終形に見える。

今のコンテンツプロバイダが提供する、HDR 4K + Dolby Atmosコンテンツ以上のスペックのコンテンツがストリーミングプロバイダから出てくることは考えづらいし、Stadiaのようなインタラクティブストリーミング技術のリーチは今後それなりの人口に達すると見られている。これが意味する所は、(ゲーム専用機のような例外を除いて、) スマートTVのスペックは限界に達した という点だろう。

その限界に達したスペックは、カメラ処理や他の需要によって進化を続ける電話のスペックを微妙に下回った位置にあるため、スマートTV向けのインタラクティブコンテンツの状況はあまり良い位置に居ないと言える。

逆に言えば、スマートTV向けのアプリケーションについては強い省力化の圧力があるため、そこを埋めるようなテクノロジには需要があるかもしれない。インタラクティブストリーミングに掛かるコストと、スマートTVネイティブアプリケーションのコストには広いギャップが有り、そこに"タダ乗り"するようなプラットフォームが今後登場するのではないだろうか。

処理系間で共通にできるeval APIのサブセット

小ネタ。処理系毎にevalやそれに与えられる環境の機能性が微妙に違うので共通APIを考える会。

eval APIの移植性

LispSchemeと言えばeval。JavaScriptや他の言語でもAPIとして存在するが、Lispではその場でS式を組み立てて使えるのでそれなりに利便性が高い。ただ、意外と移植性が低く、

  • R6RS / R7RS標準には環境を操作する手続きが無い 。多くのSchemeでは eval 手続きに与える環境をfirst class objectとして扱い、それなりの数の操作APIを用意していることが多いが、これらが標準に含まれない。このため、任意のバインディングを環境に取り込むためには、事前にライブラリにしておく必要がある。
  • そもそも環境を指定できない処理系がある 。Gambit等。

独自の環境操作手続きとしては、例えば Chez Schemeset-top-level-value! が有り、yuniではGuile( module-define! https://github.com/okuoku/yuni/blob/96fa6edd7c0b2b6c97597c60ccf1a3314b73ace1/lib-runtime/selfboot/guile/selfboot-entry.sps#L142 ) やRacket( namespace-set-variable-value! https://github.com/okuoku/yuni/blob/96fa6edd7c0b2b6c97597c60ccf1a3314b73ace1/lib-runtime/selfboot/racket/selfboot-entry.rkt#L212 )で同じAPIを実装している。

更に、これらの手続きはマクロも操作できることが多い。例えば、MIT/GNU Schemeには environment-define-macro があり、任意の環境に対してマクロを導入できる。

... 大抵の処理系でこれらの環境操作手続きが使えるならyuniで互換層を提供しても良いかもしれないが、Gambitのように、そもそも eval が環境を取らない処理系ではどうしようもないため最大公約数を探す方向とした。

(eval expr [env]) procedure

The first parameter is a datum representing an expression. The eval procedure evaluates this expression in the global interaction environment and returns the result. If present, the second parameter is ignored (it is provided for compatibility with R5RS).

環境を取らないevalで環境をエミュレートする

実はすっごく難しく考えていて、これ実装できないんじゃないかと思っていたが、実は超簡単だった。 普通に環境を let に見立て、これを lambda に変換すれば良い。

よくある even? odd? の例を考えると、

(define (odd? i)
  (if (= i 0)
    #f
    (even? (- i 1))))

(define (even? i)
  (if (= i 0)
    #t
    (odd? (- i 1))))

eval して、できた手続き odd?even? を取り出したいとする。例えば、R7RSでは (scheme repl) ライブラリに interaction-environment 環境が規定されているのでそれを使って、

(import (scheme base)
        (scheme write)
        (scheme eval)
        (scheme repl))

(define env (interaction-environment)) ;; envはREPL環境を指す

;; REPL環境を破壊的に更新する
(eval '(begin
         (define (xodd? i)
          (if (= i 0)
            #f
            (xeven? (- i 1))))
         (define (xeven? i)
           (if (= i 0)
             #t
             (xodd? (- i 1)))))
      env) 

;; 更新したREPL環境からxeven?とxodd?を取り出して使う
(let ((xeven? (eval 'xeven? env))
      (xodd? (eval 'xodd? env)))
  (display (list (xeven? 2) (xodd? 2))) (newline))

と書ける。しかし、 eval の引数に環境を取らないSchemeではこの方法は取れない( eval 間で環境が共有される保証が無いため)。代わりに、大抵のSchemeには define シーケンスと同等の効果を持つ letrec* 構文があるため、これに変換して、

;; このevalは作られた odd? even? の手続きを返す
(eval '(letrec* ((odd? (lambda (i) (if (= i 0)
                                     #f
                                     (even? (- i 1)))))
                 (even? (lambda (i) (if (= i 0)
                                      #t
                                      (odd? (- i 1))))))
         (list odd? even?)))

のようにできる。つまり、 letrec* で束縛したものを、リストかベクタに纏めて、そのオブジェクトを受け取れば良い。

この定義された odd?even? を使った式を eval したいときは、単に lambda に変換して、 eval からは一旦クロージャを受け取ることで実装できる。

(let ((proc (eval (lambda (odd? even?) ;; odd? と even? を束縛するためのクロージャを返す
                    ;; ココの内容は上の例と同じ
                    (display (list (even? 2) (odd? 2))) (newline)))))
  ;; 実際に呼び出す
  (proc xodd? xeven?))

この方法は環境に構文を導入することはできない という欠点は有るものの、環境を取らないevalで環境を取るevalと同様に手続きの定義や使用を行うことができる。

APIの実装

今回、この制約に従う evallighteval と呼ぶことにしてyuniのライブラリに導入した。lightevalはyuniがサポートしている処理系全てについて、環境付きのevalをエミュレートするためのツールを提供する。

eval プリミティブ

yuniでは、基盤となるScheme語彙として (yuni scheme) をライブラリとして定義している。ライブラリとしては この環境のみを eval の環境として保証し 、唯一の移植性のある eval 手続きとして提供する。R6RS/R7RSでは、このevalは以下のように定義できる:

(define (eval/yuni frm)
  (eval frm (environment '(yuni scheme))))

Gambitのように eval が環境を取らない処理系では、単に (define eval/yuni eval) し、実行環境に (yuni scheme) の語彙が揃っていることは別の手段で保証する。

この eval/yuni が唯一の移植性プリミティブとなる。(lightevalライブラリ自体は環境の表現にハッシュテーブルを使用するため、ハッシュテーブルも実装されている必要があるが。。)

環境オブジェクトとadd-globals!

環境オブジェクトとしてはハッシュテーブルを直接使用する。というわけで、 make-symbol-hashtable したものがそのまま環境オブジェクトとして使用される。

add-globals! は環境にグローバル変数define するための手続きで、シンボル名と eval したいコードの連想リストを受けとり、その内容を letrec* に開いた上で eval/yuni して結果を環境に格納する。

(define (lighteval-env-add-globals! env alist)
  (let ((names (map car alist))
        (code (map (lambda (p)
                     (let ((name (car p))
                           (obj (cdr p)))
                       (list name obj)))
                   alist)))
    ;; lighteval-bind(後述) 手続きが、環境の内容を展開した上でeval/yuniを行う
    (let ((out (lighteval-bind env `(letrec* ,code (list ,@names)))))
      (for-each (lambda (name obj)
                  (lighteval-env-set! env name obj))
                names
                out))))

bind

環境内のシンボルを使用したコードをevalするには、bind手続きを使う。bind手続きは、環境を lambda に開いて eval/yuni し、更にevalが返したクロージャapply した結果を返す。

(define (lighteval-bind env frm)
  (let-values (((k v) (hashtable-entries env)))
              (let* ((names (vector->list k))
                     (objs (vector->list v))
                     (proc (eval/yuni `(lambda ,names ,frm))))
                (apply proc objs))))

... 名前が良くない気がする。

何の役に立つのか?

lightevalでは、全ての変数定義を明示的な add-globals! 手続き、または、環境を表現するハッシュテーブルのアクセスを通して行う必要がある。このような eval が何故必要かというと、 define-macro のexpanderをyuni上に実現するために必要になる。。

s7やBiwaSchemeのようなdefine-macroを使用した処理系で、パフォーマンスのため事前にマクロを展開した状態でyuniライブラリを提供したいという気持ちがある。この展開処理自体もyuniのプログラムとして書いてしまいたい。

yuniにはScheme-on-Schemeに実装されたVMがライブラリとして有る( https://mjt.hatenadiary.com/entry/20170525/p1 )ので、それを使えば eval をエミュレートすること自体は可能だが、それだとあんまりなので互換性を考慮した eval ラッパーAPIを用意し、処理系を生かす形でexpanderを実装したかった。

他の応用としてはREPLの実装が考えられるが、実用的なREPLを実装するためには例外の処理もどうにかポータブルに記述できるようにする必要があり、なかなか難易度が高い。

yuniのselfboot

もう当分Racket触りたくねェ... (後述)

nmoshの互換ライブラリ層を分離した yuni は、どんなScheme実装でもR6RS風のライブラリシステムとR7RS-smallのサブセットを提供することを目的としている。で、従来はCMakeを使ったビルドシステムで一旦ビルドしないと使えないという微妙に面倒な仕組みになっていたが、"selfboot"と呼んでいる新しいbootstrapによって リポジトリをチェックアウトするだけで使える 状況を目指している。

もっとも、selfbootはyuniのbootstrap -- 普通のライブラリで言うところの"ビルド" -- と、簡単なテストのために使うことを想定している。特に、 selfbootはFFI互換層を提供しない ため、(yuni自体のビルドを除いた)実用的なアプリケーションをselfbootで実行することはスコープにない。例外的に、BiwaSchemeとs7ではselfbootが今のところ唯一のyuni環境になっている。

selfbootでできること

yuniのリポジトリに簡単なサンプルを置いた:

(library (A)
         (export A stxA)
         (import (yuni scheme))

(define-syntax stxA
  (syntax-rules ()
    ((_ sym)
     (begin
       (display "stxA: Symbol ")
       (display 'sym)
       (display " value is: ")
       (write sym)
       (display "\n")))))

(define (test)
  (display "This is `test` in library (A)\n")
  'A)
...
)

のようなライブラリを使ったプログラムが、非R6RS処理系や syntax-rules の無い処理系でも同様のコマンドラインで実行できる:

  • (Chibi-scheme - R7RS) chibi-scheme ../../lib-runtime/selfboot/chibi-scheme/selfboot-entry.scm -LIBPATH . app.sps
  • (Gauche - R7RS) gosh ../../lib-runtime/selfboot/gauche/selfboot-entry.scm -LIBPATH . app.sps
  • (Sagittarius - R7RS/R6RS) sagittarius ../../lib-runtime/selfboot/sagittarius/selfboot-entry.sps -LIBPATH . app.sps
  • (ChezScheme - R6RS) scheme --program ../../lib-runtime/selfboot/chez/selfboot-entry.sps -LIBPATH . app.sps
  • (s7 - Generic scheme) s7yuni ../../lib-runtime/selfboot/s7/selfboot-entry.scm -LIBPATH . app.sps
  • (Racket) racket ../../lib-runtime/selfboot/racket/selfboot-entry.rkt -LIBPATH . app.sps

(s7は単体の処理系が存在しないので、自前の s7yuni がyuniのリポジトリに収録されている。)

selfbootによって可能になるのは、yuniの基本ライブラリで構成された、R6RS風アプリケーションを適当なSchemeで起動できること。 ...たったコレだけでは有るけど、各処理系で微妙に異なるライブラリ構成や構文を無視して各種処理系で一発動作するのは中々に気持が良い。特に、s7のような素のSchemeからR6RS、R7RSまでをカバーしているローダーは多分他に無いんじゃないだろうか。SLIBとか他のライブラリと一緒になった奴は有るけど、インストール不要ではない。

しくみ

selfbootなyuniは、 load 呼び出し生成器 と要約できる。通常のScheme処理系は load 手続きでファイル名を指定すればライブラリをロードできるが、R6RSプログラムではライブラリ名は (rnrs) とか (yuni scheme) のようにシンボルで記述されるため、これを適当にファイルパスに変換してやる必要がある。

よって、

  1. プログラムを読み取り、依存ライブラリを収集する
  2. 依存ライブラリシンボルをパスに変換し、依存ライブラリの依存関係を再帰的に収集する
  3. ライブラリの依存関係をトポロジカルソートし、パス名をロード順に並び換える
  4. 処理系の load に渡し、ライブラリを実際にロードする

yuniではどの処理系でもyuniのライブラリを直接読み取れるように構文要素を注意深く選んでいる。このため、yuniのソースコードは基本的に処理系の read で直接読み取ることができる。...その後更に load するので2度読みになってしまうが。。依存関係の抽出等の処理は、各処理系に共通する語彙だけを使って共通のSchemeソースとして記述できる。

ただ、処理系の load が直接yuniのR6RS形式ライブラリを読み取れるのは 当然R6RS処理系に限られる ため、これができない処理系では load に相当する手続きを自前で実装している。この 自前で実装した load こそがselfbootの実装のキモ ということになる。

rationale

Schemeアプリケーションを書いている人にとっては、yuniの "ライブラリ書式はR6RS、語彙はR7RS" という組み合せに違和感が有るかもしれない。これには幾つか理由があって、

  • ライブラリシステムは差し替えが難しいため、低機能な方に合わせる方が好ましい。つまり、R7RSの define-library よりもR6RSlibrary のほうがずっと低機能なので実装しやすい。
  • define-library は実装によって挙動がまちまちで使いづらい。例えば include フォームがライブラリパスを尊重するかどうかに標準が無い。このため、 define-library はライブラリ定義と実装の分離を念頭にデザインされているものの、ライブラリ定義とライブラリファイルを同じディレクトリに置かないとうまく働かない。
  • R6RSの標準ライブラリよりもR7RSの標準ライブラリの方が普及している実装に合っている(既存のSRFIに素直な仕様になっていることが多い)。

selfbootを実装するまでは、R6RS処理系の一部(Chezやnmosh)だけがyuniのアプリケーションを直接起動することができた。これらの処理系では、単にライブラリパスにyuniのライブラリを追加するだけで直接R6RSプログラムとしてyuniのアプリケーションを処理できた。R6RS処理系全部をサポートできないのは、Racketのように #!r6rs を付けないとライブラリがR6RSとして認識されなかったり、Guileのように補助構文が束縛されていなかったりといった細かい違いによる。

selfbootの実装で、yuniをいちいちビルドしなくてもyuniアプリケーションが起動できるようになるので、もうちょっとyuni自体の開発効率が上がるんじゃないかという気がしている。

各種処理系でのselfboot実装

Scheme処理系でselfbootを実装する方法は、処理系毎に異なる。...要するに、R6RSとかR7RS標準だけではyuniのselfbootは実装できないため、処理系固有の実装がどうしても必要になってしまう。。

selfbootの実装に必要なのは:

  • library に相当する構文を eval で処理すること
  • 後続の eval で、事前にevalされたライブラリを import できること

の2点で、おおざっぱに言えばREPL上で module とか library を定義し、使用するのと大して変わらない。

ChezScheme

ChezSchemeのselfboot実装はそんなに難しくない。ChezではREPL用に interaction-environment が提供されるため、単にその環境をコピーして library でも何でも eval してしまえば良い。

yuniはR6RS形式のライブラリを採用しているため、Chez上では単純に load すれば良い。Chezの load は第3引数としてハンドラを渡すことができ、その中で eval することになる。

Guile

GNU GuileはChez同様 interaction-environment を備えている。ただ、Guileは標準ライブラリ内部で補助構文を束縛していないため、 export 内で再エクスポートされる場合は取り除く必要がある。このため、 load のような直接ロードは使用できず、フィルタ処理が必要になる。

このようなソースコードの書き換えが必要な処理系では、構文情報が抜けてしまうため行番号情報等が無くなってしまうというデメリットがある。真面目に syntax-case で加工すれば多分何とかなるが。。

Sagittarius

SagittariusでもChez同様に実装できる。

selfbootでは、ライブラリパスの解決は自前の処理で行うことになるが、処理系付属のライブラリは検索する必要がないため事前に除く必要がある。SagittariusR6RS/R7RS Hybrid処理系なため、R6RSライブラリとR7RSライブラリの両方が列挙されることになる。

Racket

これが本当に辛かった。。

Racketは言語処理系の実装フレームワークとも言うべく充実を誇っているが、この手のhackを実装するのは本当に超クッソ激烈に大変でゼロから実装した方が早いんじゃないかという気がしてくる。

標準のR6RS実装はRacketの言語として実装されており、単体で流用することをあまり想定していないように見える。selfbootではreaderだけ流用し、Racketネイティブの module への変換は自前のものを実装している。このとき、何故か (quote id) 形式のライブラリ解決が quote: not a require sub-form と言われて使用できず( quote を再exportしているから?)、自前のライブラリlookupも追加で実装している。。

R6RSのreaderを使うには、 (require (prefix-in r6rs: r6rs/lang/reader)) のように r6rs/lang/reader を直接importし、このライブラリの read-syntax を使う。これによって読み取ったコードは自動的にRacketの module 構文に開かれるが、この module はRacketのR6RS実装に固有のものなので直接使い廻すことができない。今回は syntax-case で中身を取り出し、更に syntax->datum して構文情報を取り去ってから、改めて module を組み立てて eval している。

また地味なポイントとしてRacketでは cons 等の手続きが生成するペアはimmutable pairとなっており set-car! 等が使用できない。RacketのR6RS実装では自動的にmutable pairを使うが、これらには専用の mcar といった手続きを使う必要があるため、R6RSとRacketでやりとりするには変換が必要になる。ここではイテレータ in-mlist を使って (for/list ((y (in-mlist x))) y) で変換を済ませている。

多分Racketのプロなら syntax-case で直接実装できると思うがどうやってもreaderが作ってくるsyntaxを使いつつ module 構文だけ差し替えるということが出来ず、4時間くらい格闘した挙句諦めてしまった。。Racket標準のR6RS実装は R6RSライブラリがRacket的な意味でCollectionを構成する必要が有り 、yuniのようにR6RS / R7RSの両処理系から読み取れるという要件を同時に達成するのは不可能になっている。

chibi-schemeGauche

chibi-schemeでは、 define-library のようなライブラリ構文は "(meta)" と呼ばれる環境に入っているためこれを environment 手続きで取り出して使う。Gaucheでは current-module 手続きで現在の環境を取り出してそちらを書き換える方向で実装している。

chibi-schemeinclude は何故か絶対パスを処理できないため、単にソースコードread してそのまま quasiquote で直接突っ込みevalしている。...手抜きでGaucheでもまったく同じ実装にしている。

どちらの処理系でも、環境内に library 構文を定義してしまって直接 load するという手法は使えるはずだがまだ検証していない。

残件

  • KawaやIronScheme等他の処理系での実装。
  • ビルドシステムの移行。今はbootstrap schemeとしていくつかのR6RS/R7RS処理系を選び、それらに純粋なR6RS/R7RSで書いたビルドシステムを実行させているが、selfboot処理系に置き換えることでもっと多くの処理系をyuniのビルドに使用できるようになると期待される。
  • R7RSやRacketで構文情報を捨てているのをやめる。
  • OS情報などビルド時定数を何とかサポートできないか検討する。

実際に作ってみると、selfbootは何で今まで無かったんだろうという便利さで、もう毎回ランタイムをビルドしていた生活には戻れそうも無い。多分FFI互換層だけをCMakeでビルドするように替えて、Scheme側はCMakeでビルドするのを止めても良いんじゃないかと思っている。

ファイル更新に耐性のあるテキストタグ手法を考える

コンパイラの警告やシナリオのコメント、クラッシュレポートの処理等、"行の特定位置にタグを振りファイルが更新されても追跡したい"という需要がそれなりにある。...需要自体は有るんだけど、実装がバラバラになってしまっているので、ちゃんとモデル化し、フレームワークとして統一を図りたい。

行コンテンツとblame履歴によるアドレッシング

今回考えたタグのモデルは、ファイルを行に分割し、各行のコンテンツおよび初出現リビジョンのハッシュをアドレスとする。

このアドレッシングにより、ファイルが更新されたとしてもタグを頻繁に書き換える必要は無い。もちろん、行を書き換えた場合はハッシュ値が変わるためタグも"打ち直し"が必要になるが、それでも行番号を使用した場合よりも高い生存率が期待される。

これはblameにタグを付けるのと同じと言える。つまり、 VCS上の履歴を伴ったファイルにしかタグ付けできないし、タグを打つためには必ずblame情報を生成しなければならない 。(blameは一般にVCS上の履歴を集積したビューを指す。例: https://github.com/torvalds/linux/blame/master/Makefile LinuxMakefileのSUBLEVELバージョンはLinux3.0以降変わっていない)

blameを常に生成することを考えると、ソースコードを行ごとにバラした状態でDBに格納するのが良いと考えられる。Gitを始め多くのVCSではファイル 全体 のハッシュを使用してファイルを管理することになるが、1段階間接参照を増やしてファイルを行ハッシュのリストで表現することで簡単なデルタ圧縮と行に対するID付けの両方を得られる。

タグをwhitespace文字に付与することは無いため、コンテンツのハッシュをwhitespaceを無視したものに対して取ることでwhitespaceの変更について"耐性"を獲得することもできる。この場合行データは本来の行とwhitespaceを除いたデータの両方を持たせる必要がある。

耐性モデル

ここでの"耐性"は、ファイルに付けたタグが "迷子" になりづらい、または正確に迷子と見做されることを指す。例えば、ソースコードをビルドした際に出たコンパイラの警告をタグとした場合、 ファイルを更新したとしても同一の警告が出つづけたならば同じタグと見做す 必要がある。この特性は、例えば警告が導入されてから解消するまでの期間を計測したり、ある特定の警告を無視対象として追加した場合の効力に効いてくることになる。

GitやSubversionのようなVCS上で管理されるファイルの更新にはいくつかパターンが有り、耐性もそれぞれのパターンに対して考えることができる:

  1. 行挿入耐性 : 特に、タグが付与された行よりも前に行が追加された場合、行番号はズレることになる。例えば、タグの位置情報として"ファイル名:行番号"の2アドレスを使用する方式は行挿入耐性が無いと言える。
  2. 行コピー/移動耐性 : ある行がファイル中の別の行にコピーされた場合に、タグがどちらの側に付いていたかを判断する必要がある。
  3. 行改変耐性 : 行の中に文字列が挿入された場合に、当該行に付与されていたタグが生き残る必要がある。また、逆に、タグが付与されていた位置が削除された場合はタグを(積極的に)迷子にする必要がある。Whitespaceの無視をすることで、whitespace変更に対して耐性を獲得することもできる。
  4. ファイルrevert耐性 : ファイルがrevertされた場合に、元のタグを復活させる必要がある。...これ自体は一般的な期待だが、真面目に実装するべきかは何とも言えない。

これらの耐性モデルを考えた場合、一般的な(例えばCTAGSに見られるような) "ファイル名:行番号:桁番号" のような3アドレス方式ではいかなる耐性も提供できないことはすぐにわかる。例えば、タグ位置よりも上位に行が挿入された場合は、それ以下のタグは全て無効化する必要がある。

単純なblame履歴によるアドレッシングも行挿入以外の編集には耐性が無い。このため、何らかのヒューリステイックや人力のアノテーションを使って異なるタグ同士を纏める手法が別途必要になる。逆に、これらに耐性のあるblameをデザインすることでアサインされるタグにも同様の耐性を付けることができる。

Subversion、MercurialとGitのセマンティクス差をどう埋めるか

ゲームのビルド環境には未だにSubversionMercurialからインポートしているリポジトリがいくつか存在している(Perforceはついに撲滅に成功した)。しかし、細かいインフラを作っていく中でVCSはできれば統一したい。VCS毎にUIとかいくつも作っていられないからね。。

目標

"あるexecutableのあるバイトを生成する要因になったコードの作者をO(logN)で特定する"

当然ゲームには複数のオープンソースなりNDAしたミドルウェアが含まれるため、

必要がある。全てをGitに突っ込めば(= monorepoを生成すれば)それなりに簡単だが、プロジェクトの規模が大きくなると非現実的になってくるので、在野のリポジトリをそのまま扱うために既存のVCSをそのまま扱えるフレームワークを考察している。

Subversion

Subversionはだいぶ考察が進んできていて、いくつかのFOSSリポジトリを使って実験を進めているところ。

SubversionMercurialやGitと最も違うのは、そのリポジトリ再帰的構造を取る 点と言える。普通、つまりGitのようなVCSであれば、リポジトリのルートが厳密に決定でき "リポジトリの履歴 == プロジェクトの履歴" と言える(プロジェクトが複数のリポジトリで構成されることは考えない)。

しかし、Subversionリポジトリ再帰的であるため、"ツリーのどこを取り出してもSubversionリポジトリとして成立する"。つまり、:

  1. 任意のサブディレクトリをチェックアウトしてリポジトリとして使用できる
  2. 無関係なプロジェクトを単一のリポジトリに格納できる
  3. あるプロジェクトのブランチを自由な場所に作成できる
  4. 単一のコミットで複数のブランチを同時に修正できる

という特徴がある。どれを取っても比較的最悪で、例えばSubversion自身のリポジトリは、ASF財団の 100万リビジョンを超える Subversionリポジトリ https://svn.apache.org/repos/asf/ の一部を占めていて、Subversion自身の trunk (Gitで言うところの master )は subversion/trunk/ に位置している。 ...Subversionでは、こういうリポジトリの構造を人間が決めることができ、機械で自動的に検出するには、ある程度inferが必要になる。

(実際には、Subversionリポジトリには単一のUUIDが有り、そのUUIDに紐付く静的なrootも1つに決定できる。ただし、このroot自体にはプロジェクト履歴という意味ではあんまり意味が無い。リポジトリの操作履歴では有るんだけど。)

1つのリビジョンで複数のブランチを変更するというのが妥当かどうかは何とも言えない。履歴をconciseに保つためにそのようなスタイルを好む人が居るかもしれないし、コミット単位のrevertができなくなるので避けるべきという意見も有るかもしれない。

例えば、Rubyは1つの修正を複数のブランチに単一のコミットで導入していて、

Subversionでは1つのコミットになっているものが、そのGitミラーでは複数のコミットに分散しているのがわかる。

文化的差異

Subversionリポジトリの文化的差異として挙げられるのは:

  1. 無関係なヒストリを単一リポジトリで扱う - Gitで言うと subtree マージのように、複数の無関係なツリーを単一のリポジトリで扱う傾向にある。例えば、OSのような大きなプロジェクトには vendor ブランチがあり、他所のプロジェクトのリリースをブランチ経由で取り込むために使用されることがある。
  2. コミットログにサブジェクトが無い - これはRCSCVSから由来した文化と言えるが、コミットログに1行の要約を付ける文化が無い。このためUIの方で適当にコンテキストを抽出して表示する必要がある。
  3. 巨大ファイルをコミットしがちである - これはゲーム固有かもしれないがSubversionやPerforceのようなVCSを採用したプロジェクトはアセットの管理にもVCSを使う傾向がある。
  4. ディレクトリを扱える - ...CVSの反省だろうか。。ただしこの特徴はブランチを扱うために重要になる(後述)
  5. 独特のmergeinfo属性を使用する - Subversionにはリポジトリのマージを構造として持たず(= リポジトリの履歴は厳密に木構造となる )、UIヒントとしてmergeinfo属性を使用する。実際にはサーバ側でもmergeinfoを使用するがリビジョンが明確な親を複数持てるGitやMercurialとは異なっている。

vendorブランチの処理はまだ上手いソリューションが浮かんでいない。この問題はGitでもSubtreeマージや、外部プロジェクトの取り込みが課題として有りVCS非依存の問題として解くことになるんじゃないかと思っている。

コミットログの問題は、コミットのコンテキストを抽出するためにビルドリプレイを使用したアーティファクトの抽出(ソースコードとC関数の対応表をメタデータとして持つ)と、コミットログへの静的なタグ付けをサポートする方向で考えている。例えば、手元のRubyミラーでは

            - ruby-redmine0:
                type: tag
                id: ruby-redmine
                url: https://bugs.ruby-lang.org/issues/NNNNN
                pattern: "[Bug #NNNNN]"
            - ruby-redmine1:
                type: tag
                id: ruby-redmine
                url: https://bugs.ruby-lang.org/issues/NNNNN
                pattern: "[#NNNNN]"

のように、コミットログに付与されるバグトラッカへのリンクを抽出してタグを打つことにしている。

巨大ファイルと空ディレクトリはどうしようもないので外部にメタデータリポジトリを持つことになる。

mergeinfoは単にヒントとして使うのが良いと考えている。Subversionの文化として、mergeはGitで言うところの cherry-pick に近く、それまでの履歴を全て包含することは保証していないことが多い印象がある。リリースブランチを持つプロジェクトでは、上記のようにcherry-pickやマージではなく別立てのコミットを実施することが多い。

ブランチ作成を追え!

SubversionリポジトリをGit風のセマンティクスで扱う上で最大の障害になるのは、 人間が自由に決められるリポジトリ構造を自動的に認識しGitリポジトリの構造に反映する 点となる。

もっとも、Rubyのような単一プロジェクトで単一のSubverionリポジトリを使っている場合は、標準レイアウトをそのまま使用できるので大きな問題にはならない( git-svn もそれを想定している)。問題になるケースは、例えば、個人がサブディレクトリを掘ってそれ以下にブランチを作ったりしているような大きなリポジトリの構造を良く認識することにある。

ブランチを自動認識する、とは、 ユーザが実行した svn copy コマンドをリポジトリの履歴から推論する ことと等価と言える。未だきちんと纏められていないが、いわゆるFOSSのSubversionリポジトリsvn copyディレクトリではなくファイル単位で実行してブランチを作っている事例は発見できなかった。

他のアプローチとしては、ファイル単位の履歴を全て追跡してツリー内の全ファイルで比較を行う手がある。ただ、これが必要なケースは皆無で、個人的には ディレクトリのコピーさえ追えばブランチは発見できる と結論している。そもそもディレクトリ以外も追跡するアプローチだと各パス毎に履歴情報を記録する必要があって、あまり現実的なコストで実現できない気がしている。

もちろん、 svn copy コマンドを抽出するだけではブランチの推論にはならない。COPYが発生するのは

  1. ブランチの作成
  2. ブランチ内でのファイルコピー
  3. git-svn のようなツールが生成するブランチを超えたファイルコピー
  4. vendorブランチからのコピー

といった要因があり、適切なヒューリスティックを使ってブランチの作成イベントを抽出する必要がある。簡単には "trunkのコピー、または、そのコピーのコピーはブランチと見做す" というものが考えるが、

  1. 複数のブランチが含まれるディレクトリをCOPYする
  2. サブディレクトリのみをブランチしてマージで戻す

といった例外的なワークフローが有るため一筋縄では行かない。

git-svn はブランチ内のファイルコピーを積極的にCOPYに展開するが、適切に設定されていない場合ブランチを超えたコピーをinferしてしまうことがある。

Mercurial

Mercurialの考察は始まったばかりの状態に有るが、SDLやUnityのような重要なミドルウェアMercurialなので避けては通れないと思っている。

もっとも、MercurialとGitの表現力はかなり近く、Mercurial固有の機能(タグや名前付きブランチの履歴管理)はメタデータを別のリポジトリで管理することで代替できるためSubversionよりはシンプルでコンパクトに表現できるのではないかと考えている。

MercurialからGitへのミラーツールはHgGitやgit-cinnabar( https://github.com/glandium/git-cinnabar )が既にあり、これらは今回の目的に十分な機能性を持っている。よって、今回実装する必要があるのはメタデータの抽出部分だけになる。

メタデータの抽出

Subversionと同じく、メタデータを一旦テキストファイルに変換し専用のGitリポジトリに格納する形で管理したい。このためには、リポジトリに対してメタデータ列挙 を行う。 Subversionでは svndump の出力をパースする形で行ったが、Mercurialでは割合スマートに実現できる。

hg clone <REPOSITORY>
hg checkout null
hg pull

MercurialにはBareリポジトリの概念が無いが、単に hg checkout null することでworking directoryを空にすることができる。

  • 1。タグ、ブランチのHEADの取得
hg tags --template "{node}\t{tag}\n"
hg branches --template "{node}\t{branch}\n" -c

Gitで言うところのコミットハッシュは {node} で表現される。Mercurialの名前付きブランチにはcloseの概念があり、 -c オプションで表示を有効化できる。

  • 2。Changeset(リビジョン)の列挙
hg log -r "all()" --template "{node}\t{rev}\t{p1node}\t{p2node}\t{branch}\n"

Mercurialにはrevsetsと呼ばれる専用のクエリ言語があり、これで表示対象のリビジョンを選択できる。MercurialでのChangesetには親は高々2つまでであるため、p1nodeとp2nodeの指定で必要十分な情報を出力できる。

このコマンドは

8db358c7a09ac8827c384030ce80b4abb864465d        12532   af47ff0de5ab1ddd8dbf0e30dd982a23fa45ab5f        0000000000000000000000000000000000000000        default
eb22f5f6d5a54b8f3d441fc1874e0e5ab0b09d7a        12533   8db358c7a09ac8827c384030ce80b4abb864465d        0000000000000000000000000000000000000000        default
682d9b5ecbedab90f0ddf647cd8124fdd39607c2        12534   eb22f5f6d5a54b8f3d441fc1874e0e5ab0b09d7a        0000000000000000000000000000000000000000        default
edb58d9516563ac8a01d8a04a5c3ef1d19babf8f        12535   2560bdcf3130485c44409fe6bb04e87961a9af4b        0000000000000000000000000000000000000000        SDL-1.2
6472da23f3ef931c9694ee4602867bf25a69d5b8        12536   edb58d9516563ac8a01d8a04a5c3ef1d19babf8f        0000000000000000000000000000000000000000        SDL-1.2
8586f153eedec4c4e07066d6248ebdf67f10a229        12537   6472da23f3ef931c9694ee4602867bf25a69d5b8        0000000000000000000000000000000000000000        SDL-1.2

のように、ブランチを跨いだ履歴を表示できる。 12537 のような番号はリポジトリローカルのリビジョン番号だが、ローカルのコミットが一切無ければリモートのものと一致する。

  • 3。Changesetのダンプ
hg debugdata -c 8586f153eedec4c4e07066d6248ebdf67f10a229

Changeset内容のダンプは、 debugdata コマンドで行える。(FIXME: logはmanifest(Gitのtreeオブジェクトに相当する)が出ない .,.?)

e6e2f259e0fb09741aa4c393d9aff7bcc35ee9a7
Patrice Mandin <patmandin@gmail.com>
1547389670 -3600 branch:SDL-1.2
src/audio/mint/SDL_mintaudio.c
src/audio/mint/SDL_mintaudio.h
src/audio/mint/SDL_mintaudio_it.S

atari: Fill audio buffer with silence when too many interrupts triggered on same buffer

(manifest以外の部分はlogコマンドにテンプレートを与えることで出力させられる。)

$ hg log -r 8586f153eedec4c4e07066d6248ebdf67f10a229 --template "{author}\n{date}\n{desc}"
Patrice Mandin <patmandin@gmail.com>
1547389670.0-3600
atari: Fill audio buffer with silence when too many interrupts triggered on same buffer

リビジョンのマッチング

リビジョンのマッチングは、トポロジを考慮した方式やら色々試したが、殆どのケースで authorと時刻のペア によるマッチングが期待通り動作する。もちろんトポロジの一致をチェックする必要が有るが、 殆どのケースで人間は常識的な方法でコミットを実施する と裏付けられたと言える。

ただ、Subversionでは Authorのマッチ自体が一仕事 になる。というのも、Subversionは履歴にauthorのメールアドレスを一般に持っておらず、Gitミラー側に付与するメールアドレスとのマッチングを考察する必要がある。(SubversionはDVCSではないため、単一のユーザ名名前空間を持つ。対して、MercurialやGitはDVCSであるためメールアドレスを使用することでユーザのuniquenessを確保している。)

簡単な方法は、RubyGitHubミラーのようにe-mailアドレスを git-svn 形式( <SVNUSER>@<SVN-UUID> )のままにすることが考えられる。GitHub(Enterprise)ではこの形式のメールアドレスもe-mailアドレスとして設定できるため、PGP署名のような機能を使わないならこれで十分と言える。

ミラーによってはe-mailを真面目に設定していることがあるため、この方法は万能ではない。

Mercurialについては、コミットオブジェクトの表現力がほぼ同じため、各コミットオブジェクトとMercurialのChangesetは1対1対応させることができる。

未解決の問題

現状の構成、つまり、 Gitミラー + メタデータを外部リポジトリで持つ という構造はそれなりの表現力が確保でき、かつ、実際にビルドシステムを廻す上でも運用上十分なことが多いというのが結論になりつつある。(もちろんプロジェクトによっては自身がSubversionのキーワード置換に依存していたり等100%の互換は無いが。。)

現時点で未解決の問題は、

  1. マージアノテーション 。blameを実現する上でマージ情報を良く処理するのは重要なことだが、同時に、マージ情報そのものが正しいかどうかを検証したり、不正なマージ情報を打ち消すといったアノテーションの付与が必要になるのではないかと考えている。
  2. 巨大ファイルgit-lfs に開くのが簡単な気もしているが、これを行ったリポジトリgit-svn を使えるのかが未調査。
  3. Rename表現のアノテーションSubversionはファイルの移動/コピーをアノテーションできる。Gitはtreeという形でディレクトリ単位の抽象化を選択しているが、ファイル単位での処理にした方が良いかもしれない(1つのオブジェクトが複数のファイル名を持つモデル)
  4. リポジトリ間のマージ 。いわゆるVendorブランチのマージのような操作を表現するためのメタデータをどのように持つべきかは考察し切れていない。

あたりがある。

define-macro処理系をできるだけHygieneにする

追記: これsyntax-rulesに展開されるsyntax-rulesがダメなんじゃないかという気がする。もっとも、何度リネームしても実害は無い気がするけど。。
前回( http://d.hatena.ne.jp/mjt/20181209/p1 )、define-macro処理系上のsyntax-rulesでシンボルのリネームを実現するために __1 とか __2 のような予約シンボルを導入する方法を考えたが、既存のコードを書き換えるのは超たいへんなのでテンプレートの方を暗黙に書き換えるというインチキで乗り切ることにした。
とりあえず手元のアプリを分析したところ、lambdaとlet、defineさえ救えば十分なことがわかったのでそれらだけ救う対応を入れた。

救われるコード

lambda、let、defineでのbindingは暗黙にリネームされるようになった。つまり、

(import (yuni scheme))

(define-syntax out
  (syntax-rules ()
    ((_ x code)
     ((lambda (a) (code a)) x))))

(define-syntax out2
  (syntax-rules ()
    ((_ x code)
     (let ((a x))
      (code a)))))

(define-syntax out3
  (syntax-rules ()
    ((_ x code)
     (begin
       (define a x)
       (code a)))))

(define a 10) ;; それぞれsyntax-rulesの中ではaに20を代入している
(out3 20 (lambda (b) (display (list a b)) (newline)))
(out 20 (lambda (b) (display (list a b)) (newline)))
(out2 20 (lambda (b) (display (list a b)) (newline)))

のようなコードがs7やBiwaSchemeでも正常に"(10 20)"を出すようになった。これは、例えば上記のdefineであれば、

(define-syntax out3
  (syntax-rules ()
    ((_ x code)
     (begin
       (define __1 x)
       (code __1)))))

のように暗黙に書き換えるのと同じ操作を実装している。

前提

この対応によって救うことができるのはletやdefineといった予約語がsyntax-rulesのテンプレート内に完全な形で出現している必要がある。つまり、

  • letやdefineの定義を置き換えていない。yuniでは構文のリネームは許さない方向で制約しているが、依然letとかlambdaで別のものを束縛するのは合法となっている。今のところ、define-syntaxをトップレベル以外では許していない(= define-syntaxされる文脈は常にtop-levelなので他の構文がbindされようが無い -- (define let 10) とかしない限り)が、今後define-syntaxもスコープできるようにすると問題になるかもしれない。
  • syntax-rulesテンプレート中に完全な形で構文を使用する。syntax-rules内のテンプレートを見てテンプレートを直接書き換える手法は、2段以上のマクロ展開を使った場合に成立しない可能性がある。letやlet*のような構文シンボルをマクロの外部から与えること自体は合法だが、今回のsyntax-rulesではこのようなケースでは内部でletが行われることを知りようが無い。

前者はともかく、後者はついうっかりやってしまいそうな気はする。
もちろん、処理系がdefine-record-typeとかその他bindをする非標準の構文を持っていたら、それぞれの対応を導入する必要がある。

実装

実装は、syntax-rulesのテンプレートをスキャンし、letとかdefineでbindされる位置にあるシンボルが有ったら、そのシンボルをテンプレート変数の1つとして昇格している。Generic runtimeではchibi-schemeのsyntax-rules実装を改造してsyntax-rulesを実装しているので、それをパッチする形で実装した。

  1. letとかdefineに対する、bindされる可能性があるシンボルを抽出する手続きをそれぞれ用意する https://github.com/okuoku/yuni/commit/19495050f7ea03e9f461be43a2007e6e925351d6#diff-6fc3666a46e36a52d314bb3257b9b2edR117
  2. expand-pattern に引数を追加し、テンプレート内でbindされる可能性があるシンボルのリスト(potential-binds)を渡せるようにする https://github.com/okuoku/yuni/commit/19495050f7ea03e9f461be43a2007e6e925351d6#diff-9e4667071efa1a5846f19ee88540ae32R272
  3. expand-pattern 先頭で、bindされる可能性があるシンボルのリストを map してそれぞれgensymしておく https://github.com/okuoku/yuni/commit/19495050f7ea03e9f461be43a2007e6e925351d6#diff-9e4667071efa1a5846f19ee88540ae32R198
  4. テンプレート変数を展開するときに、テンプレート変数のシンボルにマッチしなかったものをpotential-bindsと突き合わせて、マッチした場合はgensymしたシンボルに差し替える https://github.com/okuoku/yuni/commit/19495050f7ea03e9f461be43a2007e6e925351d6#diff-9e4667071efa1a5846f19ee88540ae32R217

現状の実装は処理系独自のscan手続きを追加することができない。

意義

ここまでしてsyntax-rules""の構文を使うことに何の意味があるのかというのは微妙な問題だが、これ(define-macroでエミュレートできるようにsyntax-rulesの機能を制約する)が今のところ各種Schemeでマクロを記述する最大公約数なのではないかと思っている。もちろんexpanderを載せて真面目にsyntax-rulesなりsyntax-caseなりを実装する手も有るが。。
完全にテンプレート展開のみに絞って明示的に __1 のようにgensym位置を書かせるのと、今回のように暗黙にリネームを挟むのとどちらが良いのかはなんとも言えない。ただ、個人的には暗黙にリネームを挟む方がUXとしては優れているのではないかと考えている。bindされるシンボルに意味のある名前を付けられるし、処理系が対応していればそれなりに見易いエラーを出力することもできる。
例えば、

(import (yuni scheme))

(define-syntax check
  (syntax-rules ()
    ((_ temp)
     (define (temp in)
       (car in)))))

(check check2) ;; 手続きcheck2を定義
(check2 10) ;; check2にペアでない値を渡す(エラー)

のようなコードをyuniのGeneric runtimeをloadしたs7で実行すると

;car argument, 10, is an integer but should be a pair
;  check.sps[10]
;
; check2: (car {in}-16)                      ; {in}-16: 10
; ((load prog)); ((cdr args*) (%%extract-program-args (cdr...
; ((set! %%selfboot-yuniroot "."))

のように、ちゃんと引数名が表示される。
syntax-rulesじゃなくてdefine-macroの方に寄せないのかよという意見も有るかもしれないけど、fomentのようにsyntax-rulesしかマクロを持たない処理系も有るし、何よりletくらい普通に書かせてくれた方が便利だし。。

"Generic Scheme"仕様を考える

yuniのBiwaScheme対応を進める上で用意した"Generic Scheme"仕様が意外と便利な気がしてきたので一般化してSRFI-96( https://srfi.schemers.org/srfi-96/srfi-96.html )のようなrequirement仕様にできないか考えてみる。
"Generic Scheme"はexpanderが簡略化されるぶんR7RS Smallよりも更に小さなScheme仕様になるので、syntax-rulesのサブセット実装をyuniに丸投げすることで簡単に市場の他のScheme処理系と共通のライブラリを使える環境を目指したい。

BiwaSchemeの制約

BiwaScheme( https://www.biwascheme.org/ )はJavaScript上に実装されたSchemeインタプリタで、R6RSの多くの手続きを実装している。(Bytevector等のバイナリ手続きは無いのでyuniでは専用のforkであるbiwasyuni https://github.com/yuniscm/biwasyuni/blob/0e5339ecbce44b148137070bfc7acf285da958b4/biwasyuni_core.js を使用している)
yuniは基本的にR6RS/R7RS処理系専用だが、GambitのようにAlexpanderを使って無理矢理サポートしている処理系もいくつかある。BiwaSchemeでもAlexpanderを使うことを考えたが、Alexpanderはちょっと遅いためBiwaSchemeの仕様に合わせて適度に規模縮小を図って実装することにした。
重要な制約には以下がある:

  • syntax-rulesなどの健全マクロは無く、define-macroのみ備える。yuniのGambitサポートはAlexpanderを使って完全にexpandしてから処理系に渡すアプローチを取っているが、これはexpandのコストが掛かるため避けたい。このため、syntax-rulesを適当に制約を付けてdefine-macroで実現する( http://d.hatena.ne.jp/mjt/20180521/p1 )方向を取った。
  • define-macroをトップレベルにしか書けない。Gambit等のdefine-macro処理系は通常defineとの混ぜ書きが可能で、let等によってスコープすることができる。BiwaSchemeではmacroはスコープすることができない。

マクロがスコープできないということは、let-syntaxが実装できないという点が問題になり得る。しかし、yuniでlet-syntaxを使っているのはidentifierと ... (ellipsis)の検出に使っているOlegのテクニック https://github.com/okuoku/yuni/blob/dcffed4556cdbccef46bba0a9c1198a8be2fe527/lib/yuni/base/match.sls#L566 くらいなので、ここだけライブラリ化してしまえば事足りる。
もっと直接的な問題は構文をリネームする方法が無い点が有るが、通常のシチュエーションではユースケースは無い、と思う。
これをユーザに見える制約にすると、

  • define-syntaxはライブラリ/プログラムのトップレベルにしか書けない。手元のSchemeアプリだとたまにletの中でdefine-syntaxしているものが有り、そういうコードはトップレベルに移動してやる必要がある。マクロを使ってトップレベル以外で定義したものを挿入できないという制約とも言える。
  • define-syntaxしているライブラリはトップレベルにしかimportできない。逆に言うと、define-syntaxを含まないライブラリは(let () ...)で囲むことでグローバル定義を汚さずにimportできる。Gambitやs7のようなdefine-macroをスコープできる処理系にはこの問題は無い。
  • syntax-rulesは特殊なサブセット仕様となる。これはちょっと複雑。

syntax-rulesサブセット仕様

define-macroを使って、かつ、(letやdefineのような)組込み構文の知識無しでsyntax-rulesを実装する場合、どうしても避けて通れないバインディングの生成問題がある。syntax-rulesで新しいバインディングを導入する場合は、字面上の名前が同じであってもマクロが展開される度に新しいバインディングを生成しなければならない。

  • Generic Schemeでは上手くいかないケース
(import (yuni scheme))

(define-syntax chk
  (syntax-rules ()
    ((_ nam val)
     (begin
       (define tmp val) ;; ★ Generic schemeではそのまま "tmp" をdefineしてしまう
       (define (nam) tmp)))))

(chk a 10) ;; (a) => 10 のはず
(chk b 20) ;; (b) => 20 のはず

(display (list (a) (b))) (newline) ;; ★ (20 20) を出力

ここで定義している構文 chk は、引数に指定した値を返す手続きをdefineするが、テンポラリなシンボルとして使用したtmpがそのままコードに出力されてしまうため、2回目の chk の使用でtmpが上書きされてしまい、Generic Scheme処理系ではうまく動かない。これはsyntax-rulesを適切に実装した処理系ではちゃんと (10 20) を返す。

(define-syntax chk
  (syntax-rules ()
    ((_ nam val)
     (begin
       (define __1 val)
       (define (nam) __1)))))

そこで、Generic Schemeではsyntax-rulesの_ とか ... のようなリテラルに加えて、 __1 〜 __9 を予約して、tmpのような仮置きのシンボルとして使えるようにした。(R7RSやSRFI-46では ... を置き換えることができるが、__1 〜 __9 を置き換える方法は提供しない。) これは内部的にはよくある gensym 手続きを呼び出すだけとなっている。
... たぶん、letやlambdaのような標準のbind構文は全て自動的にこれらを置き換える機能を用意してあげた方が良い気はしている。現状だと

(lambda (a) a)

のような記述をsyntax-rules内に書く場合、

(lambda (__1) __1)

のように書き換えてやらないと、マクロ展開の外で識別子 a がbindされていた場合に意図せずマスクしてしまう。

  • 上手くいかない例
(import (yuni scheme))

(define-syntax out
  (syntax-rules ()
    ((_ x code)
     ((lambda (a) (code a)) x))))

(define a 10)
(out 20 (lambda (b) (display (list a b)) (newline))) ;; ★ やっぱり (20 20) と出力される
  • 手動で書き換えた例
(define-syntax out
  (syntax-rules ()
    ((_ x code)
     ((lambda (__1) (code __1)) x))))

通常の展開器実装では、define-syntaxした位置で出力テンプレートがbindされていなければ適宜リネームという戦略を取ることができるが、今回はdefine-macroを使う縛りなのでsyntax-rulesの側にlambdaとかletといった標準構文の知識が必要になる。

Generic Schemeの意義

こんなサブセットをしてまでdefine-macro処理系を取り込む必要が有るのかはちょっと何とも言えないが、ちゃんとしたライブラリのサポートとsyntax-rulesの実装は意外と面倒で、かつ、どうしても処理系のバリエーションを増やしてしまうので、それをyuniに丸投げして処理系本体をコンパクトにできるなら言うほど悪くないんじゃないかという気がしている。
今のところGeneric Schemeターゲットになり得るのは:

あたりが有る。Gambitにはpsyntaxベースのsyntax-rulesが有るが、Gambit本体のマクロ展開器とよく連携しないので、直接Gambitのマクロ展開器を使うにはGeneric Schemeのようなアプローチが必要になる。
もちろん上にライブラリシステムやexpanderを被せてこれらの処理系をちゃんとしたR7RS処理系にすることも不可能ではないと思うが、処理系本来のdefine-macroとマクロ展開器を活用した、可能な限り薄い互換レイヤというのも方向性としては有りなんじゃないかと思う。すくなくともこの3つとも非常に魅力的な処理系で、syntax-rulesが無いからといって非サポートにするのは非常に惜しい。