限定継続であれ、無限定の継続であれ、第一級の継続と、ローカル名前環境の変更は相性が悪い、ということに気づきました。お酒を飲んだのでたぶん思考がゆるいですが、だらだら書きます。
名前環境の変更
たとえば次のRubyプログラムは、「x = 1」によってローカル名前環境に新しい変数xが、 *1「y = 2」によってyが追加されるので、この時点でローカル名前環境が変更されている、ということにします。
def foo # local-env = {} x = 1 # local-env = {x: 1} y = 2 # local-env = {x: 1, y: 2} return x + y end
Scheme/Racketプログラムでは、普通はローカル名前環境は変更されません。下記プログラムでは、変数yに2が代入された時点で、名前環境が変更されるのではなく、新しい名前環境が導入されます。
(define (foo) (let [(x 1)] (let [(y 2)] (+ x y)))) ; あるいは (define (foo) (let* [(x 1) (y 2)] (+ x y)))
次のようにset!を使えば、それぞれ呼び出した時点で名前環境が変更されます。
(define (foo) (let* [(x #f) (y #f)] (set! x 1) (set! y 2) (+ x y)))
継続と名前環境の変更
Rubyでのshift/resetを次のように定義します *2。
require 'continuation' def shift(&block) callcc {|shift_cont| lambda {|v| $cont[v] }[ block[lambda {|v| reset { shift_cont[v] } }] ] } end def reset(&block) orig_cont = $cont callcc {|reset_cont| $cont = lambda {|v| $cont = orig_cont reset_cont[v] } lambda {|v| $cont[v] }[block[]] } end
3引数を取ってリストを作って返す関数を、カリー化したようなメソッドを、shift/resetで作ってみます。これは問題ないバージョン。
def triple(first) reset { [first, shift {|k1| k1 }, shift {|k2| k2 }] } end foo = triple(:foo) foo_bar = foo[:bar] # (1) p foo_bar[:baz] # => [:foo, :bar, :baz] # (2) p foo[:X][:Y] # => [:foo, :X, :Y] # (3) p foo_bar[:baz] # => [:foo, :bar, :baz]
次は同じ関数の、しかし危険なバージョン。(2)でfoo[:X]を呼び出した瞬間に、呼び出された限定継続はtriple_unsafeのローカル名前環境をfoo_barと共有しているので、secondへの:Xの代入が、foo_barにも影響を与えてしまいます。したがって、(1)と(3)は同じ形の呼び出しであるにもかかわらず、(3)の結果のリストの二番目の要素は:Xに変わってしまっています。
def triple_unsafe(first) reset { second = shift {|k1| k1 } third = shift {|k2| k2 } [first, second, third] } end foo = triple_unsafe(:foo) foo_bar = foo[:bar] # (1) p foo_bar[:baz] # => [:foo, :bar, :baz] # (2) p foo[:X][:Y] # => [:foo, :X, :Y] # (3) p foo_bar[:baz] # => [:foo, :X, :baz] !!!
結論
同じ名前環境で同じ箇所のコードが複数回実行されて、その名前空間が変更されると、たとえば一見して関数的な限定継続が副作用を持ってしまうので、痛い。
lambda {|second| ...}[shift{|k1| k1 }]のように、letをエミュレートする手もあるけど、普通のRubyコードではなくなってしまいます。
2017-03-30 追記
Shiro Kawaiさんから次のコメントをいただきました。ShiroさんはScheme処理系Gaucheの開発者です。
これはSchemeの実装でもはまりやすい点。最適化のつもりでlet*をlet+set!にすると動かなくなったり(Gaucheでも昔やった)。ただ、名前空間だけでなく破壊的変更全般の問題でもある。mapの実装で結果のリストをset-cdr!で作ってはいけない、とか。 https://t.co/guYPzUM7Di
— Shiro Kawai (@anohana) 2017年3月30日
(承前)ただ、継続と破壊的変更を組み合わせることで簡単に書ける制御構造もあるので、一律に食い合わせが悪いというわけでもないと思う。継続がローカルスコープな副作用をグローバルに引っ張り出してしまうので、ローカルだからと気軽に副作用を使ってはまずい、とは言える。
— Shiro Kawai (@anohana) 2017年3月30日
@miyakawa_taku ども。初期化と最初の代入の違いを示すのに、ying-yangパズルというのがあります。Gaucheのバグ(let*をlet+set!でやってた)もこれで露呈しました。 https://t.co/5N6xK5rPSL
— Shiro Kawai (@anohana) 2017年3月30日