週刊nmosh - 非同期手続きのパターン / 表整形手続き / 名前付き引数マクロとキーワード

新REPLが纏まったら0.2.8にしようということで一つ。。あとcurlバインディング

非同期手続きのパターン

(あとでちゃんと書く)
nmoshはsocketや諸々の処理が全て非同期でデザインされている(DNS Lookupのようにまだ非同期になっていないものも有るが...)。これはNode.jsのような非同期I/Oフレームワークに近い。
ただ、けっこう行き当たりばったりで作られている部分もあるので、パターンを整理して統一感のあるAPIを作りたい。

  • 結果はコールバックで受け取る

例えば、handleからデータをcountバイト読み取るread手続きが有ったとすると、

(let1 result (read handle byte (lambda (ok? data) ....)) ...)

のように、末尾にコールバック手続きを与える。

  • 手続きの直接の戻り値はenqueueの成功/失敗を表す

この手のI/O処理は2つの成功/失敗ステータスが有る。1) 処理のキューイングの失敗。この場合、結果コールバックは呼ばれない。2) I/O自体の失敗。
1)の失敗は、read手続きの戻り値を使って表現する。2)の失敗は結果コールバックで通知される。
1)の失敗は次の非同期処理構文と食べ合わせが悪いので廃止しようかと思っている。ただ、これを廃止するとI/Oキャンセルのためのハンドルを渡す上手い方法がない。I/Oのキャンセルは微妙に高級な機能で、ポータブルなAPIではまず提供されておらず、WindowsだとVista以降にしか無い。ただ、需要が大きいのも事実。。

  • 非同期処理構文

コールバックスタイルAPI入れ子地獄になりがちなので、非同期処理のための専用構文を用意する。
↑の例は:

(seq (=> read handle byte => ok? data) ...)

のように書ける。

  • 結果コールバックの最初の引数は成功/失敗を表す

dataが偽だったら失敗  みたいなプロトコルでも良いんだけどMsgPackのようにSchemeオブジェクトが転送できるトランスポートでは結果自体が#fになりうる状況が有るので専用にした。

  • コールバックは一度呼ばれるもの(result型のI/O)と複数呼ばれるものがある(subscribe型のI/O)

I/Oは、高々一度しかコールバックを呼ばない一発型のI/Oと、複数回コールバックを呼ぶsubscribe型のI/Oの2種類がある。nmoshだと、fdからのreadやWindowシステムのメッセージ・キュー等がsubscribe型の代表例になっている。

  • 接続要求APIは、結果として書き込み手続きを返す(書き込み用の手続きを別途エクスポートしない)

connectのようなAPIは、確保したfdなどのパラメタをクロージャに包んで、クロージャ自体を返す。

(seq (=> connect address => writer control)
     (=> writer bv => ok?)
     (unless ok? (error "Something wrong"))
     (=> control 'disconnect => ok?))

これはプラットフォーム毎の違いを隠蔽するのに便利。書き込み手続きは制御のためにも使用する。例えば、大抵は(writer #f)とすることでconnectionを切断する。
あと、切断等を行うcontrol手続きを返すこともある。これはMsgPackのように#fがデータとして正当な場合や、他に多くの制御が必要な場合に使う。

  • コールバック引数は最後にevent / subscribe / resultの順で並べる

非同期処理構文が面倒を見るのはresult型 I/Oだけなので、これを最後に配置する。他は特にルールは無いが、一般に通常のパラメタを前に置き、コールバックは後ろにする。
これはSchemeの一般的な習慣とは逆な気がするが、そもそも非同期処理APIは通常のScheme手続き感覚では使えないのでワザと逆にしている。

  • バッファはbytevector1つ または bytevector + countの形式で渡す(?)

これはまだまだ悩んでいるところ。
nmoshはR6RS Schemeなので、データのやりとりにはbytevectorを使う。この時、bytevectorのどこからどこまでが実際に送信すべきデータなのかを指示する上手い方法が必要になる。
考えられるのは、1) bytevector単体(全体をデータとみなす) 2) bytevector + 長さ 3) bytevector + オフセット + 長さ 4) 1-3のリストやベクタ。
substringを効率的に扱えるcopy-on-write stringが有るように、copy-on-write bytevectorが有れば1)に統一してもいいかもしれない。
2の必要性は一見謎だが、バッファになるbytevectorを予め確保しておき、OSのread処理は常にbytevectorのオフセット 0 に対して行うケースで使われる。

  • 結果手続きに渡したbytevectorの寿命は、結果手続きを抜けるまで

= 結果コールバックからは一度しか抜けてはいけない。これは平たく言えば結果コールバックの継続を保存すべきでないということでもある。
このルールを用意しておくことで、一度コールバックに渡したbytevectorを気兼ねなく再利用できる。もちろん、この制約もcopy-on-writeやその他の方法で改善できる。
ただ、seq構文を使うと結果コールバックから抜けるのはseqブロックから抜けるまでなのでバッファの利用効率が低下する可能性がある。

表整形手続き

表データを整形する手続きを作ってみた。

(import (rnrs)
        (yuni text tabular))

(define table
  '(#("Name~C" "X~bR" "Y~LL") ;; Use Vector for Header field
    (Test 17 10000)
    (Hoge 53289 1)
    (Test 7 518890)))

;; Normal
(display (tabular->string table))(newline)

;; Fixed-width
(display (tabular->string table
                          'fixed-width: 50))(newline)

このようなコードで:

Name         X        Y
=============================
Test            10001 10000
Hoge 1101000000101001 1
Test              111 518890

    Name                 X            Y
=====================================================
    Test                        10001 10000
    Hoge             1101000000101001 1
    Test                          111 518890

固定幅等の機能にも微妙に対応。名前付き引数を使う(↑の例だとfixed-width:の部分)。
yuniで扱う表は伝統的にフォーマット指定部(表のヘッダ部)をVectorにし、データをリストにする。その方がソースコードで見やすいかな。と。

名前付き引数マクロとキーワード

vectorを使って他のオブジェクトと区別する手法は、名前付き引数のある手続きのdefineでも使っている。

(define* (format-tabular #((width-limit: #f)
                           (fixed-width: #f)) l)

ここでは、名前付き引数width-limit:とfixed-width:を省略値#fで定義している。
名前付き引数を設定した手続きは強制的に(lambda x ...)の任意長引数手続きになり、該当するbind処理が挿入される。
で、nmoshにはキーワードオブジェクトが無いので"本物"の名前付き引数に比べていくらかの欠点がある。

  • 名前に使ったシンボルと本物の引数の区別が付かない

こちらは実用上大きな問題にはならないが、positionalな(= 通常の)引数としてシンボルを渡すことができない。

  • 使われなかったシンボルが単純に漏れる

これが微妙に問題で、名前付き引数として渡すシンボルをスペルミスなりなんなりで間違えてしまうと意図しないオブジェクトが引数としてわたってしまう。
この手の間違いに対して適切なエラーを表示させるのは事実上できないことになる。
あと、Gaucheのような:optionalに相当する機能は無い。↑のように、非同期処理手続きで"手続きの最後の引数が結果を返すコールバック"という習慣を採用しているので、positionalな引数の後方がoptionalという手続きは微妙に使えない。