Island Life

2016/12/15

parameterizeの面倒くさい話

(この記事はLisp Advent Calendar 2016の16日めの記事です)

あらかじめ言っておくと、今日のエントリはやたら長いし、 parameterizeの仕様の重箱の隅をつつくような、面倒くさい話である。 Scheme処理系の実装者でもなければ「細けぇことはいいんだよ!」で流したくなるかもしれない。 でも、Schemeの一機能の話ではなく、 ぱっと見で簡単に見える仕様でも隙無く実装するのが難しいことがあるよ、って話だと 読んでもらえれば。

なお、文中のコードは https://github.com/shirok/parameterize-pitfalls に置いてある。


parameterizeとは

レキシカルスコープを採用したSchemeにおいて、 ダイナミックスコープをライブラリレベルで実現する仕組み。 いくつかの処理系に昔からあったが、2003年に基本部分がSRFI:39で規格化され、 R7RSにも取り込まれた (ただし、R7RSの仕様はsrfi-39とは微妙に違う)。 基本的には、動的束縛のメカニズムをクロージャの中に隠すという発想だ。

Common Lispの以下のようなコードは:

(defvar *special* 1)

(defun get-special ()
  *special*)

(let ((*special* 2))
  (get-special))  ;=> 2

(get-special) ;=> 1

parameterizeを使ってこう書ける。

(define special (make-parameter 1)) ; [1]

(define (get-special)
  (special))                        ; [2]

(parameterize ((special 2))         ; [3]
  (get-special))  ;=> 2

(get-special) ;=> 1

make-parameter は、束縛を包み込んだクロージャを返す[1]。 引数無しで呼べば、現在の動的環境での値が返る[2]。 マクロparameterizeによって、動的スコープで値を変えることができる[3]。 (make-parameterが返すものは単なるクロージャで良いのだが、 以下では説明のために「パラメータオブジェクト」と呼ぶ。)

単なる変数ではなくパラメータオブジェクトにしてあるのは、 実装に柔軟性を持たせるためだ。単なる変数でも、動的束縛マクロの中で グローバルな値を差し替えてやれば動的変数のように振る舞わせることは可能だが、 例えば後から拡張してスレッド安全にしたくなっても、打つ手がひどく限られてしまう。 パラメータオブジェクトとして扱っておけば後からいろいろやりようがある。

さて、これから、パラメータオブジェクトとparameterizeの実装を何通りか 見てゆくけれど、中にはバグのあるものが混ざっている。 Schemeに心得のある読者は、「……ほんとに?」が出てきたら、 バグの解説を見る前に、問題を見抜けるかどうかちょっと考えてみてほしい。


実装例1

最もストレートな実装は、パラメータオブジェクトPを0個または1個の引数を取る クロージャにして、(P)と呼ばれたら現在の値を返し、 (P newval) のように呼ばれたら、newvalを新たなパラメータの値に するとともに以前の値を返す、ということにするものだ。 SRFI:39もこの線で仕様化されている。

(define (make-parameter init)
  (case-lambda
    [() init]                 ; 引数なしの時は現在値を返す
    [(newval)
     (begin0 init             ; 引数ありの時はそれを新たな現在値にして元の値を返す
       (set! init newval))]))

(define-syntax parameterize
  (syntax-rules ()
    [(_ ((param val) ...) body ...)
     (let* ([params (list param ...)]   ;paramのリスト
            [vals   (list val ...)]     ;valのリスト
            [saves  (map (^[p v] (p v)) params vals)]) ;入る前の値を保存 [1]
       (dynamic-wind
         (^[] #f)
         (^[] body ...)
         (^[] (for-each (^[p v] (p v)) params saves))))])) ;[2]

parameterizeに入ったら入る前のパラメータの値を保存し、 パラメータを指定された値に書き換える。 パラメータオブジェクトは引数つきで呼ばれると「新しい値の設定と、古い値の返却」を 同時に行うので、[1]の行だけで「各paramに対応するvalの値を設定すると ともに、savesに以前の値を保存する、というアクションが起きる。

bodyの実行中にエラーが起きた時でも確実にparamの値が復元できるように、 dynamic-windを使って保存されていた値を再設定している[2]。

実行してみよう。

gosh> (define special (make-parameter 1))
special
gosh> (define (get-special) (special))
get-special
gosh> (parameterize ((special 2))
        (get-special))
2
gosh> (get-special)
1

parameterizeの実行中に呼び出されたget-special はその時点での動的束縛である2を返し、外側で呼ばれたget-specialは元の値を 返している。 うまくいっているようだ。

……ほんとに?


実装例1の問題

dynamic-windの呼び出しが非対称だからこれはわかりやすかったかも。

問題が出るのはたとえば次のコード。

gosh> (define cc #f)
cc
gosh> (parameterize ((special 3))
        (call/cc (lambda (c) (set! cc c))) ;[1]
        (get-special))
3
gosh> (cc #f)
1

parameterizeの本体で継続を捕捉し、 parameterizeから抜けた後でその継続を再起動している。 つまり、(cc #f)の呼び出しは[1]の時点に戻る(引数#fは [1]の式の返り値になるけれどここでは捨てられるので、何でも良い。) そこで再びget-specialを呼ぶけれど、ここは再びparameterizeの 有効期間の中なので、動的束縛された3が返されなければならない。

(「一旦抜けてしまったら戻さない」という仕様を別途考えることもできる。 けれども、継続というのは「その時点の動的環境を捕まえるもの」なので、 パラメータを動的束縛の仕組みとするなら、値が戻るのが正しい。 例えば継続によるコルーチンを考えれば、値が戻ってくれないと困ることがわかるだろう。)

この問題をfixするのは易しい:


実装例2

make-parameterは実装例1と同じ。 parameterizeで、savesの設定をdynamic-windbeforeサンクで やるようにした。

(define (make-parameter init)
  (case-lambda
    [() init]                 ; 引数なしの時は現在値を返す
    [(newval)
     (begin0 init             ; 引数ありの時はそれを新たな現在値にして元の値を返す
       (set! init newval))]))

(define-syntax parameterize
  (syntax-rules ()
    [(_ ((param val) ...) body ...)
     (let ([params (list param ...)]   ;paramのリスト
           [vals   (list val ...)]     ;valのリスト
           [saves  #f])
       (dynamic-wind
         (^[] (set! saves (map (^[p v] (p v)) params vals)))
         (^[] body ...)
         (^[] (for-each (^[p v] (p v)) params saves))))]))

call/ccによる再起動もok。

gosh> (define special (make-parameter 1))
special
gosh> (define (get-special) (special))
get-special
gosh> (define cc #f)
cc
gosh> (parameterize ((special 3))
        (call/cc (lambda (c) (set! cc c)))
        (get-special))
3
gosh> (cc #f)
3
gosh> (get-special)
1

これで無問題だ。

……ほんとに?


実装例2の問題

問題が出るのは次のようなケース。

gosh> (parameterize ((special 3))
        (special 5)          ; [1]
        (call/cc (lambda (c) (set! cc c)))
        (get-special))
5
gosh> (cc #f)
3

[1]でspecialの値が5に変更されているので、継続を再起動したら 5が見えなければならない。

ちなみに、specialの設定を"""call/cc""の後ろに持ってくると 一見動いてるように見えるんだけど:

gosh> (parameterize ((special 3))
        (call/cc (lambda (c) (set! cc c)))
        (special 5)
        (get-special))
5
gosh> (cc #f)
5

こうしてみるとまずいことがわかる:

gosh> (parameterize ((special 3))
        (call/cc (lambda (c) (set! cc c))) ; [1]
        (print "special=" (get-special))
        (special 5) ; [2]
        (get-special))
special=3   ; printによる表示
5
gosh> (cc #f)
special=3   ; ここは5でないとならない
5

クロージャが静的環境(レキシカル環境)を捕捉するように、call/ccは 動的環境を捕捉する。[1]でcall/ccが捕まえるのはspecialの 値そのものではなく、その時点でparameterizeが作っている動的環境だ。

クロージャ内で閉じ込んだ変数にset!すればそのその変更が一旦クロージャから 抜けても残るように、動的変数の変更の効果は永続的でなければならない。 [2]によるspecialの値の変更は、parameterizeが作る動的環境の 変更なので、継続の再起動で[1]に戻ってきた時にはspecialは既に5になっているというわけ。

fixは次のとおり:


実装例3

やはりmake-parameterは同じ。 parameterizeのafterサンクで、「抜ける時点でのパラメータの値」を 再びvalsに保存しておく。bodyに再び戻ってきたら、抜けた時点での 値が復元される。

(define (make-parameter init)
  (case-lambda
    [() init]                 ; 引数なしの時は現在値を返す
    [(newval)
     (begin0 init             ; 引数ありの時はそれを新たな現在値にして元の値を返す
       (set! init newval))]))

(define-syntax parameterize
  (syntax-rules ()
    [(_ ((param val) ...) body ...)
     (let* ([params (list param ...)]   ;paramのリスト
            [vals   (list val ...)])    ;valのリスト 兼 現在の値のセーブ
       (dynamic-wind
         (^[] (set! vals (map (^[p v] (p v)) params vals)))
         (^[] body ...)
         (^[] (set! vals (map (^[p v] (p v)) params vals)))))]))

確認。

gosh> (define special (make-parameter 1))
special
gosh> (define (get-special) (special))
get-special
gosh> (define cc #f)
cc
gosh> (parameterize ((special 3))
        (special 5)
        (call/cc (lambda (c) (set! cc c)))
        (get-special))
5
gosh> (cc #f)
5
gosh> (get-special)
1

実装例3は動的束縛を実現しているが、srfi-39およびR7RSのパラメータには追加仕様があって これだけでは不十分だ。その点については後で見て行くが、 その前にもうひとつ、気になる点に触れておく。


実装例4 中間リスト作成を避ける

実装例3では、parameterizeに入って抜けるまでにリストが4本作られる。 性能に敏感なSchemerはそこに眉をひそめるかもしれない。パラメータの数は コンパイル時にわかるのだから、わざわざリストにせずとも、マクロ展開時に 同数の一時変数を用意してそれぞれにセーブしてやれば良い。

;; これは例3までと同じ
(define (make-parameter init)
  (case-lambda
    [() init]                 ; 引数なしの時は現在値を返す
    [(newval)
     (begin0 init             ; 引数ありの時はそれを新たな現在値にして元の値を返す
       (set! init newval))]))

;; 値のセーブをリストにするのではなく、各パラメータごとに一時変数を作る方式
(define-syntax parameterize
  (syntax-rules ()
    [(_ (binding ...) body ...)
     (%parameterize (binding ...) () () () (body ...))]))

;; 補助マクロ。再帰しつつparam、valと同数のtmpを作る。
(define-syntax %parameterize
  (syntax-rules ()
    [(_ () (param ...) (val ...) (tmp ...) (body ...))
     (let ((tmp val) ...)
       (dynamic-wind
         (^[] (set! tmp (param tmp)) ...)
         (^[] body ...)
         (^[] (set! tmp (param tmp)) ...)))]
    [(_ ((param val) . rest) (p ...) (v ...) (t ...) bodies)
     (%parameterize rest (p ... param) (v ... val) (t ... tmp) bodies)]))

syntax-rulesで不定個の一時変数を用意するのはちょっとまどろっこしい。 見慣れてない人は戸惑うかもしれないので1ステップづつ追っておこう。 入力が

(parameterize ((a x) (b y) (c z)) body)

の場合、まず補助マクロが

(%parameterize ((a x) (b y) (c z)) () () () (body))

と呼ばれる。これが%parameterizeの2番目の節にマッチして、再帰的に 束縛がひとつづつばらされて、対応する一時変数が追加されてゆく。

(%parameterize ((b y) (c z)) (a) (x) (tmp) (body))
↓
(%parameterize ((c z)) (a b) (x y) (tmp tmp) (body))
↓
(%parameterize () (a b c) (x y z) (tmp tmp tmp) (body))

束縛が空になったら%parameterizeの1番目の節がマッチして、 letdynamic-windに展開される。 変数名tmpが重なっているように見えるが、衛生マクロによって 再帰の度に内部的には別変数として扱われるので衝突しない。

実際の展開例も示しておこう。macroexpand-allの出力は わかりやすいようにインデントして示す。

gosh> (macroexpand-all '(parameterize ((a 3)(b 5)) (list a b)))
(letrec ((tmp.0 '3)
         (tmp.1 '5))
  (dynamic-wind
    (lambda ()
      (begin (set! tmp.0 (a tmp.0)) (set! tmp.1 (b tmp.1))))
    (lambda ()
      (list a b))
    (lambda ()
      (begin (set! tmp.0 (a tmp.0)) (set! tmp.1 (b tmp.1))))))

展開例でわかるように、この実装例は余分なリストを作らない。めでたしめでたし。

……ほんとに?


実装例4の問題と実装例5

実装例4には非常に微妙な問題がある。

gosh> (define special (make-parameter 1))
special
gosh> (parameterize ((special 3))
        (set! special string->number))

*** ERROR: string required, but got 1
Stack Trace:
_______________________________________
  0  (special tmp)
        [unknown location]
  1  (^ () (set! tmp (special tmp)))
        [unknown location]
  2  (eval expr env)
        at "/usr/share/gauche-0.9/0.9.5/lib/gauche/interactive.scm":269

パラメータの保持する値ではなく、パラメータを指す変数そのものを書き換えてしまった 場合にエラーになる。

もちろんそんなことをする意味はほとんどない。specialset!した後では specialをパラメータとしては使えなくなる。それでも、parameterizeの ボディ中でパラメータspeciallを一切参照していないにもかかわらず、 エラーが出てしまうのは抽象化の破れである。

このfixは簡単だ。parameterizeに与えられたパラメータ式自身も 一時変数に束縛しておけばよい。

;; 実装例5
(define-syntax %parameterize
  (syntax-rules ()
    [(_ () (param ...) (val ...) (ptmp ...) (vtmp ...) (body ...))
     (let ((ptmp param) ...
           (vtmp val) ...)
       (dynamic-wind
         (^[] (set! vtmp (ptmp vtmp)) ...)
         (^[] body ...)
         (^[] (set! vtmp (ptmp vtmp)) ...)))]
    [(_ ((param val) . rest) (p ...) (v ...) (pt ...) (vt ...) bodies)
     (%parameterize rest (p ... param) (v ... val)
                    (pt ... ptmp) (vt ... vtmp) bodies)]))

展開例は次のとおり。

gosh> (macroexpand-all '(parameterize ((a 3)(b 5)) (list a b)))
(letrec ((ptmp.0 a)
         (ptmp.1 b)
         (vtmp.2 '3)
         (vtmp.3 '5))
  (dynamic-wind
    (lambda ()
      (begin (set! vtmp.2 (ptmp.0 vtmp.2)) (set! vtmp.3 (ptmp.1 vtmp.3))))
    (lambda ()
      (list a b))
    (lambda ()
      (begin (set! vtmp.2 (ptmp.0 vtmp.2)) (set! vtmp.3 (ptmp.1 vtmp.3))))))

では次に、srfi-39/r7rsのフル仕様のサポートを考えよう。


converter手続きと実装例6

srfi-39/r7rsのmake-parameterは追加引数を取れる:

    (make-parameter init-value [converter])

converterは1引数の手続きで、パラメータの値が変更される際に、 新たな値としてパラメータに渡された値を受け取る。 そこで適切な型変換をしたり、値の有効性をチェックしたりできる。

例えば、パラメータの値は常に文字列であって欲しいとしよう。次のとおり、 converter手続きで文字列に変換するようにしておけば:

(define prompt
  (make-parameter
   ">"
   (lambda (x)
     (if (string? x)
       x
       (with-output-to-string (lambda () (write x)))))))

こうなる:

gosh> (prompt)
">"
gosh> (parameterize ((prompt '$))
        (prompt))
"$"

では実装例3のmake-parameterを改造してconverter手続きをサポートしてみよう。 要は、newvalが与えられた時にconverterを噛ませてやればいいだけのはずだ。

(define (make-parameter init :optional (converter identity))
  (let1 val (converter init)
    (case-lambda
      [() val]
      [(newval) (begin0 val (set! val (converter newval)))])))

;; これは例3と同じ
(define-syntax parameterize
  (syntax-rules ()
    [(_ ((param val) ...) body ...)
     (let* ([params (list param ...)]   ;paramのリスト
            [vals   (list val ...)])    ;valのリスト
       (dynamic-wind
         (^[] (set! vals (map (^[p v] (p v)) params vals)))
         (^[] body ...)
         (^[] (set! vals (map (^[p v] (p v)) params vals)))))]))

これで、上のpromptの例も動く。簡単だね。

……ほんとに?


実装例6の問題

こんなパラメータspecialを作ってみる。

gosh> (define special
        (make-parameter 1 -))
special

動的束縛時に与えられる値の符号を反転したものが値となる。

gosh> (special)
-1
gosh> (parameterize ((special -5))
        (special))
5

動いているように見える。しかし…

gosh> (define cc #f)
cc
gosh> (parameterize ((special -5))
        (call/cc (lambda (c) (set! cc c)))
        (special))
5
gosh> (cc #f)
-5     ;; 5になるべき

継続ccでもってparameterizeの中に戻ったら、specialの 値の符号が反転してしまった。


実装例7

問題は、converterが冪等でない場合、動的束縛時と 値のリストア時で処理を変えなければならない点にある。 動的束縛時にはconverter手続きが適用されるが、値のリストア時はconverter手続きを バイパスしなければならない。 これは、パラメータオブジェクトの保持する値を変更するのに、(P newval)以外の もうひとつ別のチャネルが必要であることを意味する。

SRFI:39の参照実装ではパラメータオブジェクトの値を保持するセルを グローバルに見えるリストにつないでおいて、値の復帰はそちらを直接いじるようにしている。 でもこれまで各パラメータ内に値を保持してきたのだから、 ちょっとAPIをいじることで適合できないだろうか。 (昔のLispの用語を使えば、SRFI:39の方法はグローバルに環境をスタックして そこから探すのでdeep binding的、ここでやっている方法は各変数(パラメータ)が 動的状態を保持するのである意味shallow binding的と言える。)

動的束縛の値を変える二つの方法を区別できれば良いのだから、 例えばパラメータオブジェクトにこっそり、 converter手続きを通さない第3のモードを追加すれば実現できそうだ。

;; parameter手続きのインタフェースを変更
;;  (proc)                  => 現在の値を返す
;;  (proc newval)           => newvalを新しい値とし、以前の値を返す
;;  (proc newval something) => newvalをconverter手続きを通さずに直接セット
;; 3番目は内部的に使う
(define (make-parameter init :optional (converter identity))
  (let1 val (converter init)
    (case-lambda
      [() val]
      [(newval)   (begin0 val (set! val (converter newval)))]
      [(newval _) (begin0 val (set! val newval))])))

(define-syntax parameterize
  (syntax-rules ()
    [(_ ((param val) ...) body ...)
     (let* ([params (list param ...)]   ;paramのリスト
            [vals   (list val ...)]     ;valのリスト
            [saves  #f])
       (dynamic-wind
         (^[] (if saves
                (set! saves (map (^[p v] (p v #t)) params saves))
                (set! saves (map (^[p v] (p v)) params vals))))
         (^[] body ...)
         (^[] (set! saves (map (^[p v] (p v #t)) params saves)))))]))

先の例は、今度は動く。

gosh> (define special
        (make-parameter 1 -))
special
gosh> (special)
-1
gosh> (define cc #f)
cc
gosh> (parameterize ((special -5))
        (call/cc (lambda (c) (set! cc c)))
        (special))
5
gosh> (cc #f)
5

……ほんとに?

gosh> (define a (make-parameter 1 (^x (unless (number? x) (error "!!")) x)))
a
gosh> (define b (make-parameter 2 (^x (unless (number? x) (error "!!")) x)))
b
gosh> (parameterize ((a 10) (b 'abc)) (list a b))
*** ERROR: !!
Stack Trace:
_______________________________________
  0  (error "!!")
        at "(standard input)":61
  1  (converter newval)
        at "(standard input)":43
  2  (map (^ (p v) (p v)) params vals)
        [unknown location]
  3  (^ () (if saves (set! saves (map (^ (p v) (p v #t)) params sa ...
        [unknown location]
  4  (eval expr env)
        at "/usr/share/gauche-0.9/0.9.5/lib/gauche/interactive.scm":282
gosh> (a)
10  ; 1に戻ってない

実装例8

実装例7では、動的束縛を逐次処理していたが、converter手続きがエラーを投げた場合、 それまでに変更した束縛を元に戻すチャンスが無かった。 converter手続きがエラーを投げる可能性を考えると、束縛を変更する前に 以前の値を保存しておき、エラーが出たら巻き戻すようにすればいけそうだ。

;; これは例7と同じ
(define (make-parameter init :optional (converter identity))
  (let1 val (converter init)
    (case-lambda
      [() val]
      [(newval)   (begin0 val (set! val (converter newval)))]
      [(newval _) (begin0 val (set! val newval))])))

(define-syntax parameterize
  (syntax-rules ()
    [(_ ((param val) ...) body ...)
     (let* ([params (list param ...)]   ;paramのリスト
            [vals   (list val ...)]     ;valのリスト
            [saves  #f]
            [restarted #f])
       (dynamic-wind
         (^[] (if restarted
                (set! saves (map (^[p v] (p v #t)) params saves))
                (set! saves (map (^[p] (p)) params)))) ; 初回の値を保存
         (^[]
           (unless restarted  ; 初回のみここで値をセット。エラーが起きたら巻き戻される
             (set! saves (map (^[p v] (p v)) params vals)))
           body ...)
         (^[]
           (set! restarted #t)
           (set! saves (map (^[p v] (p v #t)) params saves)))))]))

やってみよう。

gosh> (define a (make-parameter 1 (^x (unless (number? x) (error "!!")) x)))
a
gosh> (define b (make-parameter 2 (^x (unless (number? x) (error "!!")) x)))
b
gosh> (parameterize ((a 10) (b 'abc)) (list a b))
*** ERROR: !!
Stack Trace:
_______________________________________
  0  (error "!!")
        at "(standard input)":78
  1  (converter newval)
        at "(standard input)":43
  2  (map (^ (p v) (p v)) params vals)
        [unknown location]
  3  (eval expr env)
        at "/usr/share/gauche-0.9/0.9.5/lib/gauche/interactive.scm":282
gosh> (a)
1

めでたしめでたし。

……ほんとに?


教訓

ここに上げた経緯は、ほぼGaucheのparameterize実装がたどった経緯である。 パラメータオブジェクトそのものはスレッドローカルな値を保存するように 細工がしてあるが、parameterizeの現時点の実装はロジックとしては 実装例8にあげたものと同等になっている。今のところ問題は見つかっていないが、 これで完璧かどうかはわからない。

SRFI:39の参照実装のようにグローバルな動的環境リストを持つようにしていれば、 そちらの方が落とし穴が少なかったかもしれない。 (パラメータオブジェクトの値の参照に毎回環境リストを探さねばならないという点で deep bindingの欠点を引き継ぐことになるが。) その意味では、最初の方針が悪かったせいで回り道をしたとも言えるが、 ここからいくつか教訓を引き出すことは可能だと思う。

単純であっても落とし穴がないとは限らない

(p)で値の取得、(p v)で値の変更」という仕様は十分に単純に見える。 これを見たら多分真っ先にクロージャ内に現在の値を保持することを思いつくだろうし、 実装例2くらいまでならすぐに思い浮かびそうな気がする。

実際は、parameterizeを使わずに(p v)で直接値を変更できることが 落とし穴になっていた。動的束縛を実現したいのなら、必要なのはオブジェクトの 作成(make-parameter)、束縛された値の参照、および束縛の実現(parameter) の3点であって、どうやって束縛された値を変更するかというのは実装に任せておけば 良かったのである。

実際、R7RSではパラメータオブジェクトの値の変更はparameterizeのみによって 行うことになっていて、パラメータオブジェクトに引数を渡した時の振る舞いは未規定である。

複数の役割を持たせることの危うさ

make-parameterのconverter手続き、および(p v)での値が変更できること、 という仕様は、「制御されたグローバル変数」としての用途を念頭においていたようにも 思える。(p v)でグローバルに見える状態を変更できるが、 converter手続きによって少なくとも変な値がセットされることを防いだり、 いつ変更されたかをトレースすることができる。

ナイーブに考えると、make-parameterにconverter手続きを追加することは ちょっとしたハックに見える。 ちょっと変えればこっちの用途にも使えて便利じゃん、というノリだ。

実際は、converter手続きの存在によって、 パラメータオブジェクトの「値の変更」と「値のリストア」に別の操作が必要になってしまった。 元々、(p)(p v)だけあればその上に抽象を組める、と思っていたのに、 ちょっとしたハックがその前提を壊してしまったわけだ。

「制御されたグローバル変数」としてのパラメータはそれはそれで便利なこともあるので、 このデザインが全く誤りだとい言いたいわけではないが、 一見無害なちょっとしたハックが思わぬ影響を及ぼすことがある、という教訓にはなるだろう。

Tags: Scheme, Gauche, r7rs, srfi-39

2016/12/08

Schemeとunwind-protect

(この記事はLisp Advent Calender 2016の 9日目の記事です。)


Schemeでファイルを開いて何かするには、with-系やcall-with-系の 手続きを使うのが定番だ。次のコードはファイルfooを開いて最初の行を呼んで返す。 ファイルは、lambdaフォームによるクロージャが戻った後で閉じられる。

(call-with-input-file "foo"
  (lambda (port) (read-line port)))

ここで、ファイルを読んでいる最中にエラーが起きて、call-with-input-fileから 抜けてしまった場合、オープンされていたファイルはどうなるだろうか。

(call-with-input-file "foo"
  (lambda (port) (error "oops!")))

スコープを抜ける時にリソースを解放するイディオムに慣れた人なら、 call-with-input-fileを抜けた時点でファイルが閉じられることを 期待するかもしれない。

実は、Schemeの規格では、そこでファイルが閉じられることは保証されていない。 実際Guileでは閉じられないようだ (Cf. Guile では call-with-input-file の proc 内でエラーが起きるとポートが閉じられない件)。 Gaucheでは閉じるのだが、その理由については後述する。


規格の該当部分を見てみよう。R7RSではcall-with-input-filecall-with-portで実装されるとあり、call-with-portの 仕様には次のくだりがある。

If proc does not return, then the port must not be closed automatically unless it is possible to prove that the port will never again be used for a read or write operation.

つまり、procが戻らなかった場合、portが二度と読み書きに使われないことを 証明できない限り、portを勝手に閉じてはいけない、というのが仕様だ。

多くのプログラミング言語では、スコープを一度抜けてしまったら、 スコープ内の変数にアクセスする手段がない。従って、スコープの寿命と リソースの確保を結びつけてある場合、正常終了だろうが異常終了だろうが スコープを抜けた時点でリソースを解放して構わない。

しかしSchemeには第一級継続があるので、スコープの処理の途中で一度抜けて、 後で戻ることが可能なのだ。 次の手続きfooは、「ファイルの2行目を読む」という継続[2]を変数cに 束縛して、1行目だけを読んで(call-with-input-fileを抜けて)戻ってくる。 次にcを呼び出すと、ファイルの2行目が読まれて帰ってくる。

(define c #f)

(define (foo file)
  (call/cc (lambda (k0)   ; k0は[1]へジャンプする継続
             (call-with-input-file file
               (lambda (port)
                 (call/cc (lambda (k1) ; k1は[2]へジャンプする継続
                            (set! c k1)
                            (k0 (read-line port)))) ;; [1]へと脱出
                 ;; [2]
                 (read-line port))))
  ;; [1]
  ))

やってみよう。cに与える引数は内側のcall/ccの戻り値になるが、 今は使ってないので何でも良い。

gosh> (foo "README")
"Gauche is a Scheme scripting engine aiming at being a handy tool that helps"
gosh> (c #f)
"programmers and system administrators to write small to large scripts quickly."

(一度cを起動して[2]からの継続を実行すると、call-with-input-fileへと 正常に制御が戻るので、仕様どおりportは閉じられる。 従ってcをもう一度呼び出すとエラーになる。)

この例はあまり便利そうには見えないが、例えば継続を使ったコルーチンを実装して、 call-with-input-fileの中で別のコルーチンにスイッチする、といった場合にも 同じことが起きる。なので、単に「制御が外に飛び出す」だけでは、 ポートを閉じて良いかどうかはわからないのだ。

portが二度と読み書きに使われない」ことが確実になるタイミングのひとつは、 portがGCされる時である。なので、GCされる時点でポートを閉じるという実装は、 処理系としてはひとつの選択肢である。


時々、他の言語での「後始末」をする構文 (try〜finally、begin〜ensure等)に 相当するものをSchemeでやろうとして、dynamic-windをこんな感じで 使っているのを見ることがある。

(let ((port (open-input-file ...)))
  (dynamic-wind
    (lambda () #f)                   ; before thunk、特にやることなし
    (lambda () (read-line port))     ; body thunk、 処理本体
    (lambda () (close-port port))))  ; after thunk、必ずportを閉じる

制御がbody thunkから外に出る際にafter thunkは必ず呼ばれるので、 そこで後始末をするのは一見妥当に見える。 けれども、after thunkは「後で本体に戻るかもしれないけど一時的に抜ける」場合にも 呼ばれてしまう。だから、dynamic-windで後始末をやってはいけないのだ。

じゃあSchemeで後始末を確実にするにはどうすればいい? 実は、Schemeにそのための構文や手続きは備わっていない。 どうしてもやりたいのなら、エラーハンドリング構文のguardを使って 異常終了時の後始末を書くことになるが、 正常終了時の後始末は別に書かなければならないのがなんだか残念な感じである。

(let ((port (open-input-file ...)))
  (guard (e [else (close-port port)  ; エラー時。portを閉じてエラーを再度投げる
                  (raise e)])
    (let ((r (read-line port)))
      (close-port port)              ; 正常終了のパス
      r)))

なので、Gaucheではこのイディオムをunwind-protectというマクロにしている (名前は同様の機能を持つCommon Lispのマクロから拝借した)。 dynamic-windの例と違うのは、call/ccを使って出たり入ったりする際には 後始末部分は呼ばれない、という点である。あくまで、 「正常終了か、エラーによる異常終了」という特定の制御パスだけを扱っている。

(let ((port (open-input-file ...)))
  (unwind-protect
      (read-line port)    ; 本体
    (close-port port)))   ; 正常終了でも異常終了でもここを通る

dynamic-windは、guardなどのエラーハンドリングを実装するための、 もう一段下にあるメカニズムと考えるのが良いだろう。

第一級継続があると unwind-protectが実装できない、との意見もあるが、 継続による制御が低レベルのレイヤなのに対し、エラーによる大域脱出はその上に 乗った別レイヤであるので、上のレイヤで実装することは可能なのだ。

なお、エラーではない継続で本体を脱出した後、制御を戻さなければ、 後始末ルーチンは呼ばれないことになる。これは、マルチスレッドプログラムで 一つのスレッドがブロックしている場合と同等であると考えることができる。 再開のための継続をどこかに持っていれば、それが生きている限りはリソースが 保持されるが、その継続がGCされれば(スレッドがキャンセルされる場合に相当)、 掴んでいるリソースも解放される。

(追記(2016/12/10 02:02:35 UTC): Gaucheのunwind-protectは上に示したguard による後始末よりちょっと込み入ったことをやっている。詳しくはソース参照)


さて、実はGaucheではさらに一歩踏み込んで、call-with-input-fileの中で unwind-protectを使ってポートを閉じるようにしている。これは実際のコード:

(define-in-module scheme (call-with-input-file filename proc . flags)
  (let1 port (apply open-input-file filename flags)
    (unwind-protect (proc port)
      (when port (close-input-port port)))))

(whenportをチェックしているのは、flagsによっては port#fになる可能性があるため。)

その根拠はこうだ。エラーが投げられて制御が外側に抜けるということは、 もはや内側の状態が処理の継続が不可能なほどに一貫性を失っているということである。 従ってこの後再びprocの中に制御を戻すことは意味のあることではなく、 正常な動作は保証できないのだから、戻ることは考えなくて良い。

これはかなりアグレッシブな立場だ。 ポートと関係ないところでエラーが発生したが、それ以前に保存してあった継続を使って 一旦本体内に戻って何らかの処理をして、また(エラーではない)継続で抜ける、という 例を作ることは可能である。しかしエラーの発生箇所と影響範囲を厳密に知っていなければ そういったコードが確実に動く保証はできないし、それはあまり現実的な仮定ではない、 というのがGaucheの解釈である。

逆に、call-with-input-fileでエラー時にポートを閉じずGCに任せるとどうなるだろうか。 Schemeの世界だけで完結できるなら、ファイルをopenしようとしたところで ディスクリプタが足りなければそこでGCを走らせて、使っていないディスクリプタを回収できる。 しかしGaucheのように、他言語で書かれたコンポーネントと一緒に使うことを想定している場合、 Gaucheがファイルディスクリプタを全部抱え込んでしまっている状態で サードパーティのC言語のライブラリ内でopen(2)が呼ばれたら、 failするしかないわけで、 それは実用的ではない、という判断だ。 他の処理系には、それぞれのユースケースに応じた別の判断があって良いだろう。

Tags: Programming, Scheme, Gauche

2016/11/24

Google翻訳と下訳

Google翻訳を下訳に使って手早く技術文書を翻訳してみました、という例:

訳された文章はこちら: Hadoop: Fair Scheduler

おそらくこれからはこういう形のやり方も増えてゆくと思うので試みとして面白い。 同時に、現在の機械翻訳で弱そうなところ(従って、下訳から修正する際に気をつけるべきところ)が 見えてる気がする。「イントロダクション」の節からいくつか例を挙げてみる。

(引用は現時点での訳。徐々にフィードバックを受けて改善されると思うので後で見る時は 変わっている可能性あり)


現訳:

デフォルトでは、フェアスケジューラはスケジューリングフェアネス決定をメモリ上でのみ行います。Ghodsiらによって開発されたDominant Resource Fairnessの概念を使用して、メモリとCPUの両方でスケジュールを設定することができます。

原文:

By default, the Fair Scheduler bases scheduling fairness decisions only on memory. It can be configured to schedule with both memory and CPU, using the notion of Dominant Resource Fairness developed by Ghodsi et al.

"on memory" は典型的にはデータを操作する際にそれがメモリに乗っていることを指し、 「メモリ上で」と訳されることが多い。 けれどもここのonは "base X on Y"、「Yに基づいてXを構築する/Xの基礎にYを置く」 のonであって「メモリにのみ基づいて(スケジューリングの公平性を決定する)」という意だ。

on memoryを場所を示す副詞句と取れば「メモリ上で決定を行う」という解釈も 構文的には可能である。

そちらの解釈が却下される理由は、次の文の意味とのつながりにある。次の文ではメモリとCPUの両方を 考慮してスケジュールすることも可能だと言っている。原文を読む人間は、 最初の文ではっきり意味を確定していなくても、次の文を読んだ段階で

「さっきメモリのみと言っていて、次にメモリとCPUの両方ってのを持ち出して来たんだから、 スケジューリング決定のパラメータとして、メモリのみ、あるいはメモリとCPUの両方、という オプションがあるんだな」

と理解する。

現訳でも内容を良く考えればその解釈に至ることは可能だと思うけれど、 私は一読してわからず原文を読んでしまった。例えばこう書いてあったら 一読で理解できたんではないかと思う。

デフォルトでは、フェアスケジューラはスケジューリングフェアネス決定をメモリのみに基づいて行います。Ghodsiらによって開発されたDominant Resource Fairnessの概念を使用して、メモリとCPUの両方でスケジュールを設定することできます。

原文2文目にalsoは入っていないけれど、元の英文では2文目冒頭にIt can beが来て 別の可能性に言及していることがすぐ分かるのに対し、日本語では「できます」が 2文目末に来てしまうので、「も」を補なうことで流れをわかりやすくできる。


現訳

スケジューラはアプリケーションを「キュー」にまとめ、これらのキュー間でリソースを公平に共有します。

原文

The scheduler organizes apps further into “queues”, and shares resources fairly between these queues.

ここは単文での翻訳としては問題はない。問題は、この前のパラグラフで、

「アプリのキューを形成するデフォルトのHadoopスケジューラとは異なり、(Unlike the default Hadoop scheduler, which forms a queue of apps,)」

と言っている点だ。「アプリのキューを作るデフォルトのスケジューラとは違う」と言っておきながら、 「アプリケーションをキューにまとめ」と来るので読者は混乱する。

先のキューと今出てきた「キュー」は違うものを指していて、原文でクオートされているのも それを強調する意図があるのだろうけれど、もうひとつ、原文では 先に出てきたキューが単数で、後に出てきたキューが複数であることが、 両者が違うものを示すという重要な手がかりになっている。

一般には単数形/複数形は訳出せずに、文脈で示唆するに止めることが多い。 この文でも「これらのキュー間」がすぐ後に来てるから複数形であることは示唆されている。 のだけれど、文脈の流れで数が重要な情報を担っている場合は、陽に訳出してあれば 読者が何度も読み直したり原文に当たったりする必要が減るだろう。

スケジューラはアプリケーションを(複数の|いくつかの)「キュー」にまとめ、~

対照をより強調するために、前パラグラフのデフォルトのスケジューラについて「アプリを一つのキューに並べるデフォルトの…」としておくこともできるだろう。

また、この後に、デフォルトでは"default"という単一のキューが使われると出てくるのだけれど、 それに続けて複数のキューに振り分ける方法がいくつか説明される。 私が訳すとしたら、その流れを示すために 次の文を「デフォルトでは〜単一のキューを共有します、アプリケーションが〜」 といった具合に原文にない接続詞を補うかもしれない。 (原文に無い言葉をどこまで補うべきか、というのは難しい問題だが、 技術文書の場合は読者の想像力に訴えるよりは意味をなるべく明確に伝達する方が優先されるだろうから、 論理の流れを示すような補間はわりと有効だと思う。)


現訳

フェアスケジューラは、すべてのアプリケーションをデフォルトで実行することができますが、configファイルを使用して、実行中のアプリを実行するアプリ数をユーザごとおよびキューごとに制限することもできます。

原文

The Fair Scheduler lets all apps run by default, but it is also possible to limit the number of running apps per user and per queue through the config file.

後半の「実行中のアプリを実行するアプリ」は多分編集ミスだろうけれど、 ここで見たいのは "lets all apps run" の方。 「何もしなければ全部走らせる→configで制限かけることもできる」というのがここの流れだが、 「Aできますが、Bもできます」では並置の印象が強く、原文にある流れが消えているのが 読みづらさの要因だろうと思う。

フェアスケジューラは、デフォルトではすべてのアプリケーションを実行しますが、 configファイルを使用して、同時に実行するアプリ数をユーザ毎やキュー毎に制限することも できます。


現訳

これは、一度に何百ものアプリケーションを送信しなければならない場合や、一度に多数のアプリケーションを実行すると、中間データが非常に多く生成されたり、コンテキスト切り替えが多すぎたりすると、パフォーマンスを向上させるために役立ちます。

原文

This can be useful when a user must submit hundreds of apps at once, or in general to improve performance if running too many apps at once would cause too much intermediate data to be created or too much context-switching.

現訳は一番外側の「これは、〜パフォーマンスを向上させるために役立ちます。」で主語述語の 対応が取れてるように見えるために、校正時にもつい読み流してしまうのかもしれない。けれど 間を良く読んでみると翻訳の日本語で節間のつながりが良く分からなくなっている。 二つめの「すると」は意味的に「役立ちます」にはつながらない。

原文の構造は次の通り:

   useful 
    +- when a user must submit ... at once
    +- (in general) to improve performance
          +- if running too many apps at once would cause
               +- too much intermediate data ...
               +- or too much context switching

when節とto improveの句が並置されている(意味的には、単なる並置というより、 前者を後者で一般化説明していると考える方が良いだろう)。

ということは翻訳文は次のように解釈するのが正しいことになる。

  これは、
     - 一度に何百ものアプリケーションを送信しなければならない場合
     - 一度に多数の〜パフォーマンスを向上させるため
  に役立ちます

翻訳文をこう切れば(接続語の問題を除いては)間違いとは言いきれないが、 自然言語ではネストをうまく表現できないので、人間が今の翻訳を一読すると

   これは、
     - 一度に何百ものアプリケーションを送信しなければならない場合や
     - 一度に多数のアプリケーションを実行する
   と、
     - 中間データが非常に多く生成されたり、
     - コンテキスト切り替えが多すぎたり[するので]、
   パフォーマンスを向上させるために役立ちます。

という具合に読んでしまうのではなかろうか。

これは、木構造をシリアライズするにあたって、語順の違いによって曖昧性が生じてしまう例である。 日本語の場合、枝に分岐した後、述語で合流させないとならないので、切り方が難しい。 接続詞と句読点を工夫したり、主語述語の塊を後ろに持ってくるといった手が使える:

一度に何百ものアプリケーションを送信しなければならない場合、より一般的には、一度に多数のアプリケーションを実行して中間データが非常に多く生成されたりコンテキスト切り替えが多くなりすぎる時の性能を向上させるのに、これは役に立ちます。

あるいは、in general以降が一般化した説明であるという意図を汲んで文を 分割するか:

これは、一度に何百ものアプリケーションを送信しなければならない場合に便利です。 より一般的には、一度に多数のアプリケーションを実行して、中間データが非常に多く生成されたり、コンテキスト切り替えが多くなりすぎる時の性能を向上させるのに役に立つでしょう。


共通点としてなんとなく見えてくるのは、以下の点の弱さだ。

  • 文やパラグラフをまたいだ「流れ」を読者に示すための適切な訳語の選択
  • 翻訳文を読んで読者が頭の中に構造を描けるか、という読者視点からの配慮

機械翻訳がこれら、特に後者に弱いのは致し方ないとも言えるので、 下訳から直してゆく際に翻訳者が特に注意すべき点と考えた方が良いと思う。

翻訳者はなまじ原文を読み込んでいるために、下訳の日本語が少々おかしくても 好意的に解釈して見逃してしまうという危険があるかもしれない。 「流れ」を示す訳語の選択は、時として本当に些細な接続詞や句読点の 違いだったりする。そんな、一見どうでもいいような違いを自信を持って修正できるには、 原文が何を言っているのかを明確に把握している必要がある。 その点で、参照エントリが言うように、機械翻訳を使えるからといって 翻訳者に必要とされる英語力が下がるということは当分は無いだろう。

Tags: 翻訳, 英語

2016/11/01

Big Island

撮影でしばらくハワイ島のコナに行っていた。出番はそんなに無いんだけど撮影日が微妙に離れてて、10日間滞在することに。 プロダクションが用意してくれたホテルはSheraton Kona Resortで良いホテルなんだけど周囲に何もない。ホテル内のレストランは高いし選択肢が限られる。徒歩圏で買い物が出来るのは1マイルくらい先のモールのみ。仕方ないのでそこのスーパーで食材を買い込んで暮らしてた。まあ、per diemの経費が給料とは別に出るので少々高くついても損はしないのだけれど。

今回の映画はSAGのLow budgetプロダクション(総予算が基本$2.5Mまで、条件によっては$3.75Mまで←今回のは多分こちら)なんだけどlow budgetと言ってもキャストのほとんどはLAやNYから呼んでるし、給与の最低基準が低いのといくつかの労働条件制限が緩和されてること以外はフルのSAGプロダクションと待遇は変わらない印象。これまでよくやってたインディーの低予算映画の「低予算」とは全然違う。(そういう印象どおりの「低予算」はSAGのultra low budgetカテゴリとかが該当する)

ペイはもちろん多くもらえるに越したことはないけど、自分にとってはベテランの役者さんとシーンをやれる経験が貴重だ。クラスで学んだことを実践に使ってみてうまくいくことを確認する、という意味で学ぶところが大きい。

公開は来年。詳細は公式発表があってからこちらにも書こうと思う。

Tags: 芝居, 映画

2016/09/21

ソヴィエト

先週マウイに行った折、金曜の午後のスケジュールが空いていたので マウイ在住のアマチュアピアニストの友人宅にお邪魔した。 ハレアカラの山腹、クラにえらく広い地所があって、山のロッジ風の広い家だった。 天井が高くてリビングに置いたピアノが良く響く。 途中から、ロシア出身のピアニストの知人も合流して代わりばんこにいろんな曲を弾いた。

自分は、ShostakovichのA majorのPrelude&Fugueを復習してるのでそれと、 Kapustin Op.40の6,7,8、あとこないだ弾いたBach。

ShostakovichのA major (Op.87-7) は自分は可愛い曲だと思うんだけど、 ロシアのその人は「ずいぶんリリカルに弾くのねえ」と。 「ソヴィエトだった頃は、こういう曲は勇ましく、マーチみたいに弾くように言われたものよ。 人民を鼓舞するようにって、そういうのじゃないと党にウケが悪いから。」

へぇ~。確かにそう言われてみればこのテーマはトランペットのように聴こえなくもない。 Shostakovich本人やNikolayevaの演奏だとむしろ鐘って感じかなあ。かなり速いから。 でもOp.87全体が、社会情勢とは離れたとても個人的、内面的な作品って感じがするんだよなあ。

Tag: Piano

More entries ...