Ruby で作る Prolog 処理系 (補講)
2006.9.9 - 2006.10.10 (鈴)- 前回: Python で作る Prolog 処理系 2006.6.6 - 2006.7.14
- 1. はじめに
- 2. Ruby で構成する Prolog 構文
- 2.1 Lisp のようなリスト
- 3. 環境クラス Env
- 4. 単一化関数 _unify
- 5. イテレータ resolve
- 5.1 コールバック
- 5.2 カット演算子
- 6. おわりに
- 7.追記: ジェネレータとイテレータ
- 8.追記: ジェネレータとイテレータ − 実験
- 次回: Java で作る Prolog 処理系
- Suzu: Ling が Ruby 使いとは知らなかった。 CodeZine に 「Rubyで作るProlog処理系」を書いたんだよね?
- Ling: ええ,ちょっとね。 でも1画面以上の Ruby を書いたの,あれが初めてだったんだけど。
- S: そうなの? ずいぶん勇者じゃん。 で,このスクリーンショット,いつも使ってる Mac の?
- L: OS X "Tiger" の ターミナル と Carbon Emacs だけど, CodeZine ではかなりエトランゼだったかも...。
- S: どんなプログラムなのか教えてくれない?

Ruby 上の Prolog で書いたハノイの塔とその実行例
1. はじめに
ここで述べる Ruby による Prolog 処理系 (以下,本処理系) では, 簡単のため,Ruby の構文要素を流用して Prolog プログラムを表現する。 たとえば,
human(socrates). human(plato). mortal(X) :- human(X).
を下記のような Ruby スクリプトで表現する。 ここで require している tiny_prolog.rb が本処理系の実体である。
require 'tiny_prolog' human = pred 'human'; mortal = pred 'mortal' human['socrates'] .si human['plato'] .si mortal[:X] .si human[:X]
tiny_prolog.rb が定義する関数 pred は, 新しい 述語 (predicate) オブジェクトを作る。 メソッド si は 節 (clause) を構成し,述語オブジェクトに登録する。
- S: "si" って?
- L: ラテン語の sī 「スィー」,意味は「もしも」。 本当は英語の if を使いたかったけれど予約語だから。 あと,エスペラントの se 「セ」にしようかと随分迷ったけどね。
- S: まるでフランス語とイタリア語で迷った,みたいに聞こえるんだけど? たしかそんな単語じゃなかったっけ?
- L: たしかに語源的に無関係じゃないけど... 英語以外でなるべく 中立的 なアルファベットの単語を探した 偶然の一致だからね。
Prolog プログラムの実行とは, プログラムとして与えられた事実とルールについての質問, すなわち Prolog における ゴール (goal) に答えることである。 そのために本処理系ではイテレータ resolve を使う。 resolve はゴールを満たす変数値の集合体,つまり環境を算出してブロックに渡す。
resolve mortal[:X] do |env| printf "%s is mortal.\n", env[:X] end
この例では下記の結果が得られる。
$ ruby mortal.rb socrates is mortal. plato is mortal. $
本処理系は Linux, Mac, Windows (Cygwin) 上の Ruby 1.6.7, 1.8.2, 1.8.5 および Java 上の JRuby 0.9.0 で動作を確認した。 特別なライブラリ等を使っていないため, おそらく現行のどの妥当な Ruby 実装でも動くと考えられる。
- S: イテレータの do-end って {} と交換可能なんだよね? 上の例で {} にすると syntax error になるのはナゼ?
- L: 優先度が違うから,そのときは resolve の引数の () を省略せずに
resolve(mortal[:X]) { ... }としないとダメ。 do-end と {} の優先度をわざわざ別にして, 混乱することはあっても何か役に立つことがあるとは思えないんだけど...。
2. Ruby で構成する Prolog 構文
本処理系では Prolog 変数は Ruby のシンボルをそのまま利用する。 Prolog 述語はクラス Pred のインスタンスとして作る。
Pred の各インスタンスは名前 name と定義 defs を持つ。 defs の実体は Prolog 節を格納する配列である。 述語に対する配列要素参照演算を,ゴールを構築する関数として定義する。 少ない打鍵数で述語を構築できるように pred という関数を設ける。
class Pred
attr_reader :defs
def initialize(name)
@name = name
@defs = []
end
def inspect
return @name
end
def [](*args)
return Goal.new(self, args)
end
end
def pred(x) return Pred.new(x) end
class Goal
attr_reader :pred, :args
def initialize(pred, args)
@pred, @args = pred, args
end
def si(*rhs) # ラテン語の「もしも」
@pred.defs << [self, list(*rhs)]
end
def calls(&callback)
@pred.defs << [self, callback]
end
def inspect
return @pred.inspect + @args.inspect
end
end
Goal クラスの主要なメソッドは,節を定義するメソッド si である。 si は述語の defs に Prolog 節として左辺 (self) と右辺 (rhs) のペアを追加する。 メソッド calls は Ruby 関数を コールバックする節を定義する。
- S: 「変数」にはシンボルを利用してるっていうけど, Python 版プログラム と比べると,たしかに Var クラスがないね。
- L: ここでの実装方法では,
Prolog の変数には名前とアイデンティティだけあればよいから,
ちょうどシンボルで間に合うわけ。
Lisp の良いとこ取りというか,Ruby の偉い点の一つというか。
- S: でも...ゴールを書くのに配列要素参照演算というか [] を使うのは, () を使う元の Prolog 構文から離れすぎてない?
- L: C++ や Python
みたいに関数適用もメソッドとしてユーザ定義できたら良かったんだけど...。
その点,Ruby は不自由だから。
Ruby の偉くない点の一つというか。
- S: で,なぜに inspect? Ruby で文字列化といったら普通は to_s じゃなかったっけ?
- L: よくぞ聞いてくれました。 はじめはそう思っていたんだけど,Ruby の to_s は Python の str() や Java の toString() とは似て非なるものだったんです。 [1, 2, 3] の to_s って何になると思う?
- S: Java の ArrayList も,Python のリストも, 文字列化すると "[1, 2, 3]" だから,Ruby もそうじゃないの?
- L: びっくりしないでね。"123" なんです。 各要素を to_s して join しているんです。
- S: ...でもさ,それが役立つ場面とか用途って,かなり限られてない? たしか Ruby は「驚きが少ない」言語って触れ込みじゃなかったっけ?
- L: そう思ったんだけど, Widipedia などを見ると 「驚き最小化原則」は今はスローガンから外しているみたい...。 ともかく, 有名どころの Ruby の本をいくつかあたっても, このあたりのことは,どれも こっそり としか書いてないし, ここだけで半日は悩んじゃって...ね。
- S: それで inspect にしたわけ?
- L: そう。
inspect だったら "[1, 2, 3]" になるんです。
で,もしも inspect が未定義のときはデフォルトで to_s が使われる,
というのだったらともかく
inspect は inspect でデフォルトがあるから,結局,
いったん inspect で文字列化すると決めたら,
どのクラスも inspect にするしかないわけ...。
*1
- S:
えーと... Array の to_s を再定義する,という方法じゃダメ?
確か Ruby はそれができたはず。こんなかな?
class Array def to_s; inspect; end end
- L: これはこれで解だと思うけど...。でも,こんな勝手なことしたら, プログラムをライブラリとして他のところに組み込めなくなっちゃわない?
*1Ruby の inspect と to_s の区別は, Python の repr() と str() に似ている。 しかし, repr() が (望ましくは eval() で再び同じ値が得られる) 文字列表現 (representation), str() が (読みやすい) 文字列 (string), という明確な意味付けを持つのに対して, デバッグ用の文字列表現 inspect に対する to_s の (一般用の) 文字列表現という立場はかなり微妙である。
配列に対する ruby の to_s の仕様は,典型的には, ファイルの各行からなる配列を出力するときに .join の 5 文字を省くことで one-liner をわずかに短くするために有益である。 しかし,それ以外での有用性は必ずしも明らかではない。 ある意味, 汎用言語であることを犠牲にして one-liner に対する便宜を優先したために, 明確な意味付けを to_s に持たせることに失敗したともいえる。 nil の to_s の結果が空文字列である (したがって,たとえば [nil, nil, nil].to_s の結果も空文字列である) にもかかわらず,nil を print すると "nil" が得られる, という Ruby の不規則性も, to_s の意味付けの微妙さ (ないしは潜在的な不安定さ) を表している, といってよいだろう。
2.1 Lisp のようなリスト
メソッド si で使われている関数 list は, 引数から Lisp のリストのような入れ子の配列を作る。 list という名称も Lisp の同機能の関数に由来する。
list(g1, g2, g3) ⇒ [g1, [g2, [g3, nil]]]
ただし,これでは読みにくいため, inspect の結果が Lisp のリストのように "(g1 g2 g3)" と表現される Array 派生クラス Cons を定義する。 (ここで cons, car, cdr という奇妙な名前も Lisp に由来する。 それぞれコンス,カー,クダーと読む)。
class Cons < Array
def initialize(car, cdr)
super(2)
self[0], self[1] = car, cdr
end
def inspect
repr = proc {|x|
car, cdr = x[0], x[1]
if cdr.nil? then [car.inspect]
elsif Cons === cdr then repr[cdr].unshift(car.inspect)
else [car.inspect, '.', cdr.inspect]
end
}
return '(' + repr[self].join(' ') + ')'
end
end
def cons(car, cdr) return Cons.new(car, cdr) end
def list(*x)
y = nil
x.reverse_each {|e| y = cons(e, y)}
return y
end
- S: 先輩の Python 版プログラム では,これに相当するコードは, ここでの関数 list にあたる関数 pair だけだったよね?
- L: Lisp のようなリストを作る関数を先輩が "pair" という名前にしたのは, きっと Python では "list" が標準の関数の名前だったからで, それが Ruby では都合よく空いていたから 自分は気兼ねなく Lisp と同じ名前で定義して..., さらに,ついでだから Lisp と同じ cons 関数も設けて...
- S: ...それって,Lisp 大好き趣味,大爆発じゃない?
- L: そんな...別に Lisp のことが好きなわけじゃなくって, 本当は Haskell とか本来の Prolog 風にしたかったけど,表記方法のことがあったし, やっぱり,この種のデータの表し方としてマイナーだし...。
- S: はたから見ると Lisp 好きとしか...。 それはそれとして Cons の inspect は内部で関数 repr を定義していて, それが各要素の文字列表現からなる配列を返してるんだよね。 それをスペースではさんで join して,前後に丸カッコを付けて完成...なんだよね。
- L: repr は正確には「関数」ではなく Proc オブジェクト。だから,
repr[cdr]のように配列要素参照で呼び出しをしてるわけです。 - S: さっきの Pred クラスと似たようなことを標準の Proc クラスでもしてるんだ。
- L: あと, cons と Cons.new, pred と Pred.new が, 標準の proc と Proc.new の関係と同じとか... ちょっと風変わりに見えるかもしれないけど,これらは自分の発明じゃなくて, Ruby 自身のまねをしているだけだからね。
Cons クラスとその関数を利用すると,Prolog の例題として有名な, 第1引数と第2引数を連結したリストが第3引数に等しい, という述語 append を下記のように書ける。 このスクリプトの最後の3行では, 連結結果がリスト (1 2 3 4) に等しくなるような第1引数と第2引数の組み合わせを 求めている。
require 'tiny_prolog' append = pred 'append' append[nil, :A, :A] .si append[cons(:A, :X), :Y, cons(:A, :Z)] .si append[:X, :Y, :Z] resolve append[:A, :B, list(1, 2, 3, 4)] do |env| printf "%p and %p\n", env[:A], env[:B] end
実行すると次のような結果が得られる。
$ ruby append.rb nil and (1 2 3 4) (1) and (2 3 4) (1 2) and (3 4) (1 2 3) and (4) (1 2 3 4) and nil $
Ruby 1.6.* の場合,printf の引数の %p を %s に,env[...] を env[...].inspect に変更されたい。
- L: これは Python 版の 5. の 最初の例の焼き直しです。
- S: Python 版はタプルをそのまま使ってるから,
(1, (2, (3, None)))とおおげさな表示になってるけど, ここでは Cons の inspect のおかげで,同じようなデータ構造が すっきり(1 2 3)と表示されてるんだよね? - L: はい,本来の Prolog の表記方法では
[1, 2, 3]なんだけど... この例では Haskell も同じなんだけど...
見掛けは[1, 2, 3]でも中身は Ruby の Array の[1, [2, [3, nil]]]です...って説明しにくいから... - S: それで紛らわしくないように,あえて Lisp 風にした,というわけなんだね。
- L: そう,あえて です。別に Lisp のことが好きなわけじゃないからね!
3. 環境クラス Env
ここまでの記述では,イテレータ resolve がブロックに与えるパラメタとして 環境 (environment) を見てきた。 環境は単一化 (unification) 操作とともに Prolog の内部動作の基礎を構成する。
本処理系は環境をクラス Env のインスタンスで表す。 インスタンスはハッシュ表 (Prolog 変数をキー, その変数値と環境のペアを値とする) を保持する。 dereference メソッドは, 具体的な値または未セットの変数が得られるまで間接参照を繰り返す。 dereference メソッドの本体に含まれる式 Symbol === t は, t が Prolog 変数であるか否かを判定する。
class Env
def initialize
@table = {}
end
def put(x, pair)
@table[x] = pair
end
def get(x)
return @table[x]
end
def delete(x)
@table.delete(x) {|k| raise "#{k} not found in #{inspect}"}
end
def clear
@table.clear
end
def dereference(t)
env = self
while Symbol === t
p = env.get(t)
break if p.nil?
t, env = p
end
return [t, env]
end
def [](t)
t, env = dereference(t)
return case t
when Goal then Goal.new(t.pred, env[t.args])
when Cons then cons(env[t[0]], env[t[1]])
when Array then t.collect {|e| env[e]}
else t
end
end
end
- S: これは Python 版のEnv クラスとほぼ同じ内容というか 直訳っぽくて,かえって違いがよく分かりそうだね。
- L: 内部で使うインスタンス変数を表すのに self.__table ではなく @table と書くだけでよかったのは,圧倒的に Ruby の良い点のひとつでした。 派生クラスからの名前の隠蔽という点ではちょっと不利だけど...。 一方,ドキュメンテーション文字列や assert がなかったのは, あまり良くない点でした。
- S: ドキュメンテーション文字列って Lisp 由来だったっけ?
- L: 少なくとも 1984 年の Common Lisp の本にはあるから, Python よりもずっと古いはず。assert はもっと広い分野で古典的だよね。
- S: Ruby は当たり前にあるべきものが欠けている?
- L: そう一概にはいえないけど...。
assert は単体テスト専用のものがあるようだし。
それに rdtool のこともあるしね。
面倒そうで使わなかったけど。
- S: やっぱり,なんとなく end がうるさいね。
- L: Ada の if - end if, loop - end loop のように対応を明示できるか, せめて Algol 68 の if - fi, do - od, case - esac みたいに対になっていると, どの構文要素の終わりか分かるのに,{ } よりただ長いだけで全然情報がないから...。
- S: 68 って西暦 1968 年だよね? Algol 系をうたうには約 40 年ぶん後進的ってこと?
- L: Algol 68 から数えればね。
だからって半世紀も時代遅れだなんて決して言わないけど。
傍系には end だけってのが多いし。
Algol 60 から分派した Pascal とその子孫とか...。
- S: でも,isinstance() のようなものを陽に書かずに済む点は, Ruby のほうが簡単そう。 演算子の左にクラスをおくセンスはどうかと思うけど, これはたぶん慣れの問題だと思うし。
- L: Symbol === t のことだったら,t.is_a?(Symbol) と等価だし,
そうしようと思えば,isinstance() のかわりに is_a?() を使って直訳もできたけど,
ここは Suzu の言うように簡単だと思うから === や case 式を使いました。
Ruby は二項演算子の右項ではディスパッチできないから,
演算子の左に Class のインスタンスをおくのは自然な帰結だし,
そういった統一感は,むしろ好ましいとさえ言えるのだけど...
- S: ...だけど?
- L: 書いていてストレスだったのは, そんなことじゃなくて, 微妙に「タプル」オブジェクトがありそうに見えて, 実際には特別な構文でそう見せかけているだけ,という点でした。 dereference や put メソッドあたりを比べると分かると思うけど。 なんというのかな, 一般的な考え方で簡単にまとめられるはずのところを, 丸暗記するしかないバラバラの構成要素で表面的に似せているというか...。
これまで環境 env から Prolog 変数 :X の値を取り出すとき, 配列要素参照演算を使って env[:X] と書いてきた。 しかし,上記の def [](t) の記述から分かるように, この演算は入れ子になったデータ構造のなかに現れる変数を 再帰的に展開した値を返す関数であり, 引数には一般の Prolog 項 (term) が可能である。 したがって, 前節の append 述語スクリプトの最後の3行を 次のように書いてもよい。
t = append[:A, :B, list(1, 2, 3, 4)]
resolve (t) {|env|
p env[t]
}
実行例を示す。
$ ruby append2.rb append[nil, (1 2 3 4), (1 2 3 4)] append[(1), (2 3 4), (1 2 3 4)] append[(1 2), (3 4), (1 2 3 4)] append[(1 2 3), (4), (1 2 3 4)] append[(1 2 3 4), nil, (1 2 3 4)] $
- S: Python は
python -i p4.pyみたいにスクリプトを実行して, そのまま対話モードに入っていろいろ試せたよね? Ruby ではできないの? - L: 一応,
irb -rってのがあるけど...。 - S: どれどれ...。あ,ダメじゃん。
irb -r append2.rbで確かにスクリプトが実行されて プロンプトが出たけど,append も t も undefined local なんとかだって。 - L: ちょっと見苦しいけど,こんなふうに 大文字にしてみたら? つまり,定数にするわけ。 もっとも...使う人から見ると文脈がそのまま連続してるんだから, ここはローカル変数もそのまま有効なのが自然だとは思うんだけど...。
- S: つまり手抜きってこと? こんなよく使いそうなところなのにー!?
- L: きっと...本物の Ruby 使いは ふだん irb をこんな風には使ってないのでは...? もちろん,元々は実装上の制約だったんだろうけど,それが当然の前提となったら, ひょっとしたら,もうずっとこのままになる...かもしれないね。
$ cat append3.rb
require 'tiny_prolog'
APPEND = pred 'append'
APPEND[nil, :A, :A] .si
APPEND[cons(:A, :X), :Y, cons(:A, :Z)] .si APPEND[:X, :Y, :Z]
T = APPEND[:A, :B, list(1, 2, 3, 4)]
resolve(T) {|env|
p env[T]
}
$ irb -r append3.rb
append[nil, (1 2 3 4), (1 2 3 4)]
append[(1), (2 3 4), (1 2 3 4)]
append[(1 2), (3 4), (1 2 3 4)]
append[(1 2 3), (4), (1 2 3 4)]
append[(1 2 3 4), nil, (1 2 3 4)]
irb(main):001:0> t = APPEND[list(1, 2), list(3, 4), :X]
=> append[(1 2), (3 4), :X]
irb(main):002:0> resolve (t) {|env| p env[t]}
append[(1 2), (3 4), (1 2 3 4)]
=> [[append[nil, :A, :A], nil], [append[(:A . :X), :Y, (:A . :Z)], (append[:X, :Y, :Z])]]
irb(main):003:0>
- S: (1 2) と (3 4) を連結して,
確かに
append[(1 2), (3 4), (1 2 3 4)]と計算できてるんだけど, なんか => の後ろがうるさくない? - L: ええっと,これは Pred のインスタンス APPEND の defs では?
APPEND.defsと打鍵してみて。 - S: 同じ値だ。でもなぜ?
- L: きっと...たまたま最後にそれが評価されたんでは?
そのつもりはないんだけど,Ruby は別に return しなくてよいから。
- S: ところで Python 版だと query があったよね。 いちいち resolve なんとか |env| なんとかって面倒なんすけど...。
- L: そんなこともあろうかと,ここでの tiny_prolog.rb では query 関数を入れてあります。 本筋ではないから,CodeZine の記事では省いたけどね。
def query(*goals)
count = 0
printout = proc {|x|
x = x[0] if x.length == 1
printf "%d %s\n", count, x.inspect
}
resolve(*goals) {|env|
count += 1
printout[env[goals]]
}
printout[goals] if count == 0
end
irb(main):004:0> query APPEND[:X, :Y, list("foo", "bar", "baz")]
1 append[nil, ("foo" "bar" "baz"), ("foo" "bar" "baz")]
2 append[("foo"), ("bar" "baz"), ("foo" "bar" "baz")]
3 append[("foo" "bar"), ("baz"), ("foo" "bar" "baz")]
4 append[("foo" "bar" "baz"), nil, ("foo" "bar" "baz")]
=> nil
irb(main):005:0>
4. 単一化関数 _unify
処理系内部で単一化操作を実現しているのは下記の関数 _unify である。 環境 x_env, y_env のもとで項 x, y を単一化する。 単一化に成功したとき true を返す。 単一化の対象が未セットの変数ならば,他方の変数値にセットする。
def _unify(x, x_env, y, y_env, trail, tmp_env)
loop {
if Symbol === x
xp = x_env.get(x)
if xp.nil?
y, y_env = y_env.dereference(y)
unless x == y and x_env == y_env
x_env.put(x, [y, y_env])
trail << [x, x_env] unless x_env == tmp_env
end
return true
else
x, x_env = xp
x, x_env = x_env.dereference(x)
end
elsif Symbol === y
x, x_env, y, y_env = y, y_env, x, x_env
else
break
end
}
if Goal === x and Goal === y
return false unless x.pred == y.pred
x, y = x.args, y.args
end
if Array === x and Array === y
return false unless x.length == y.length
for i in 0 ... x.length
return false unless _unify(x[i], x_env, y[i], y_env, trail, tmp_env)
end
return true
else
return x == y
end
end
引数 trail は,この単一化でセットされた変数とその環境を記録する。 この記録は,後で同じ述語の別の定義を試す前に今の単一化を取り消す (いわゆるバックトラックをする) ために使われる。 引数 tmp_env は,すぐに廃棄される予定の環境であり, そこでの単一化は trail に記録しない。
前述の append 述語でリストの分離/連結操作が可能なのは, ここで Array の各要素について再帰的に単一化しているからである。
文字列定数や数値定数は,末尾の return x == y で等価性が判定される。
- S: Python 版の _unify 関数と比べると,ほとんど直訳だよね? 大ざっぱにみてタプルとリストが Ruby では配列に一本化されている点が違うだけ?
- L: Array x に対する
for i in 0 ... x.lengthは Python のfor i in range(len(x)):の直訳だけど, イテレータを使ってx.each_index do |i|とも書けます。 試した限りでは処理系のスピードは測定誤差でしか違わなかったけど...ね。 - S: どっちがおすすめ?
- L: 応用の利きやすさでは for 文だろうけど...。
配列全体について添え字でループする,という意図が明確だから,x.each_index かな?
ここでは直訳で for 文にしたけど, 範囲を表すのに..と...の紛わしい 2 種類がある点がちょっと...ね。 - S: たしかに,うっかり取り違えても気付きにくそうだね。
5. イテレータ resolve
イテレータ resolve の定義を下記に示す。
与えられたゴールの並びが nil ならば再帰の底である。 そうでなければ,先頭のゴール goal と残り rest に分割する (goal, rest = body)。
ゴールが :CUT の場合は後述する。
ゴールの述語定義の配列 goal.pred.defs の各要素について, for 文ループでゴールと定義左辺 d_head の単一化を試みる (_unify 呼び出し)。
定義右辺 d_body が Proc の場合は後述する。
単一化に成功したとき,定義右辺 d_body を, その環境 d_env のもとで解決する (外側の _resolve_body 呼び出し)。
外側の _resolve_body 呼び出しが解を一組みつけたとき, 仮引数と結合している実引数にその解がもたらされている。 もとのゴール並びの残り rest を,実引数の環境 env のもとで解決する (内側の _resolve_body 呼び出し)。
すべてに対して解決したら,yield する。
for 文で次のループに入る前に,単一化前の状態に変数値を戻す (trail に対する for 文と,d_env.clear)。 いわゆるバックトラックである。
def resolve(*goals)
env = Env.new
_resolve_body(list(*goals), env, [false]) {
yield env
}
end
def _resolve_body(body, env, cut)
if body.nil?
yield
else
goal, rest = body
if goal == :CUT
_resolve_body(rest, env, cut) {
yield
}
cut[0] = true
else
d_env = Env.new
d_cut = [false]
for d_head, d_body in goal.pred.defs
break if d_cut[0] or cut[0]
trail = []
if _unify_(goal, env, d_head, d_env, trail, d_env)
if Proc === d_body
if d_body[CallbackEnv.new(d_env, trail)]
_resolve_body(rest, env, cut) {
yield
}
end
else
_resolve_body(d_body, d_env, d_cut) {
_resolve_body(rest, env, cut) {
yield
}
d_cut[0] ||= cut[0]
}
end
end
for x, x_env in trail
x_env.delete(x)
end
d_env.clear
end
end
end
end
$_trace = false
def trace(flag)
$_trace = flag
end
def _unify_(x, x_env, y, y_env, trail, tmp_env)
lhs, rhs = x_env[x].inspect, y.inspect if $_trace
unified = _unify(x, x_env, y, y_env, trail, tmp_env)
printf("\t%s %s %s\n", lhs, (unified ? "~" : "!~"), rhs) if $_trace
return unified
end
- S: Python 版の resolve, _resolve_body, trace と _unify_ と比べると... これも直訳したんだよね? それもほとんど...逐語訳...?
- L: でも,見ためも動きも似てるけど,中身はかなり違います。 実行すると Ruby はどんどんスタックを積んでいくんだけど, Python はほとんど一定。 再帰関数のベンチマークで試すとよく分かるんだけど, Ruby 版はスタック不足ですぐギブアップします...。
- S: え,そうなの? 一体どんな魔法で?
- L: Python の _resolve_body はローカル変数をスタックに入れ子で積み上げないんです。 *2
- S: あ,Python のはジェネレータだから?
- L: スタックに積み上げるかわりに ループ状態をカプセル化したイテレータのオブジェクトを作るから, コルーチンみたいに好きな時と場所でループを1回ずつ回せるし, そうしたければ二つ並んで回すこともできるし...
- S: しかも一般のコルーチンと違って, 切替え時のスタックの深さが1だと割り切れるから, 実装もわりと効率良くできるしね...ってそうだったよね?
- L: そういったことは Suzu のほうが詳しくなかった?
たしかオリジナルは
Icon
言語のアイディアだったと思うけど...。
- S: Ruby のイテレータはコルーチンに見せかけているけど, 中身は環境付きの関数引数を呼び出してるだけなんだよね?
- L: 環境付きの関数引数...というかクロージャ...かな?
- S: たしかオリジナルのアイディアは CLU だったっけ?
- L:えぇとね... 文献 を読むと確かに... Ruby のイテレータは実装方法も含めて CLU の焼き直しみたい。 *3 時代はだいたい今から 30 年前の 1970 年代かな。 ループの構文だけは一応,1980 年の Smalltalk 80 にインスパイアされつつ Ruby のオリジナルみたいだけど。
- S: そうなんだ...。
- L: でも,結局は単純な関数呼び出しなんだから, むしろ...誰でもすぐ理解できるという利点があるわけだし...。 Python のジェネレータで _resolve_body のバックトラックがちゃんと機能することを飲み込むのに, ちょっと...というか,かなり苦労したよね。 それに,ちょっとした Lisp のマクロみたいに便利に使えるしね。
*2 この Ling の発言は典型的なジェネレータには妥当だが, _resolve_body のように自らや他のジェネレータを利用するジェネレータについては (当たらずとも遠からずではあるが) 不正確である。 7. および 8. を参照されたい。
*3
広い意味で Algol 系に属する
CLU のイテレータ定義はいわゆる関数定義と同様の構文であり,
return 文ではなく yield 文を使用した。
その動作は一見するとコルーチンのように見えるが,
任意の文脈で使うことはできず,
for 文の "for 変数宣言 in イテレータ呼び出し" に制限されていた。
文献 7 の 10.3.10 には
"...a yield effectively calls the loop body, which returns to the iterator
when it is finished."
「yield 文は実質的にループ本体を呼び出す。
ループ本体は終了するとイテレータにリターンする」
"Imposing the limitations on iterators was done to get the efficient,
single stack implementation, albeit at the expense of some expressive
power. For example, iterators cannot be used to compute whether two
lists have the same elements, because to do this you need to iterate
through the two lists side by side, and CLU only allows iterator calls
to be nested."
「イテレータに制限をかけたのは,いくらかの表現力を犠牲にしようとも,
効率の良い単一スタック実装を得るためだった。
たとえば,二つのリストが同じ要素を持つかどうかを計算するために
イテレータを使うことはできない。なぜなら,
そうするためには二つのリストを並行してイテレートしなければならないが,
CLU はイテレータ呼び出しを入れ子にすることしか許していないからである」
とあり,コルーチンではなく yield からの呼び出しによる実装方法,
それによる効率性と表現力の限界など,ほとんどあらゆる点で
Ruby のイテレータのさきがけとなっていたことがうかがわれる。
CLU のイテレータが Alphard のジェネレータ (用語が紛らわしいが,
これは今日の Python や Java のイテレータ・クラスの祖先にあたる。
インスタンス変数,コンストラクタ,
next メソッドに相当するものを構文として明示的に備えていた)
をヒントに発明されたのは 1975 年ごろのことである。
- S: CodeZine の記事で読者にイテレータの課題を出したんだよね?
- L: 誰も回答者はいなかったみたい...だけどね。問題文は:
「ただし,いわゆる内部イテレータですから, Python のジェネレータに比べ自由度が劣ります。 JRuby での実行をあきらめれば, Continuation クラスを使うことで外部イテレータにすることができます。 あるいは,可読性を犠牲にして, イテレータの処理ステップを分解し有限状態機械に構成し直すことによっても 外部イテレータにすることができます。 その実装や各方式の総合的な優劣の評価は読者への課題として残します」 - S: "continuation" って?
- L: 日本語で "継続"...。Scheme でおなじみの概念だけど... 簡単にいえば, ある時点でのスタック全体とプログラムカウンタをオブジェクトにしたものです。 このオブジェクトを保存しておけば,いったん関数を終えた後からでも, もとの関数のもとの実行箇所のところに再び戻ることができるから, 当然,コルーチンも実現できます。 逆に,呼び出し元の Continuation オブジェクトを作っておけば, 今度はどんなに深く入れ子になった関数からでも 一瞬で呼び出し元に脱出できます。
- S: なんかタイムマシンが作れそう... じゃなくて,いったん訪れたところに一瞬で戻れるんだから...。 えーと何のことだったっけ。 で,答えは?
- L: 実は Ruby の標準ライブラリにありました。 Mac を含む Unix で /usr/lib/ruby/1.8/generator.rb が使えるはずです。
- S: でもスタックを1重しか使わないのに,
そんな何でもできそうな,おおげさなからくりを使って
...激しくムダな実装になりそうな気がするんだけど...どうなんだろ?
たしかに概念と実装は違うから,実は案外効率良くできるのかもしれないけど...。
- L: もう一方の有限状態機械の方法は, ジェネレータに対し python が行うようなことを人間コンパイラになって書き下します。 ローカル変数はオブジェクトのインスタンス変数として記録します。 ジェネレータのどこで yield したかを状態変数に記録します。 実行を再開するときは,状態変数が指し示す箇所に分岐します。
- S: なんか面倒そう...。
- L: 制御を切替える時のスタックが1重ということは,つまり, 保存すべきスタックフレームが 1 個だけ, 変数の集合が1セットだけということだから, 結局,スタックのない有限状態機械で表現できるわけです。 実際に書こうとすると, 処理が細切れになって,とても読めたものじゃなくなると思うけど...。
- S: でも,Java のイテレータで作るんだったら実質それしかなくない? ジェネレータの呼び出しに対しそれぞれスレッドを作るような 大富豪なプログラムは別として...。
- L: え,Java って?
- S: あのね...。
5.1 コールバック
上記でもしも d_body が Proc だったならば, 配列要素参照演算 d_body[] で関数適用を行う。 これにより Prolog 内部から Ruby 関数を呼び出すことができる。 このとき引数として渡される コールバック用環境のクラス CallbackEnv の定義は次のとおりである。 実装詳細にかかわる trail と複雑な仕様の _unify を隠蔽し, 項の展開と単一化のメソッドだけを提供する。 これは典型的にはそれぞれ変数値の参照と設定に利用される。
class CallbackEnv
def initialize(env, trail)
@env, @trail = env, trail
end
def [](t)
return @env[t]
end
def unify(t, u)
return _unify(t, @env, u, @env, @trail, @env)
end
end
たとえば,第1引数から第2引数を減算した値と第3引数を単一化する述語は, 次のように書ける。
subt = pred 'subt'
subt[:A, :B, :X].calls {|env|
a, b = env[:A], env[:B]
env.unify(:X, a - b)
}
ここで subt[10, 2, :X] を resolve すると,:X の値が 8 になる。
5.2 カット演算子
cut と d_cut はカット演算子に対するフラグである。 入れ子になった関数呼び出しの奥底でフラグを立てられるように, 長さ1の配列で変数引数を代用する。 カット演算子 :CUT は, 探索木の枝刈りをすることによってバックトラックを制御する。 ゴール p[:X], g1, g2, g3 が与えられたとき,
p[:X] .si g1, :CUT, g2 p[:X] .si g3
で g1 が成功した (つまり満たす解が見つかった) とき, :CUT を通り過ぎた時点で g1 の別解, および述語 p に対するもうひとつの定義の本体 g3 への探索木が刈り取られる。 p[:X] が成功するかしないかは,g2 によって決定される。 g2 の解がすべて失敗したとき,他の可能性をさがすことなく p[:X] が失敗する。
つまり,Ruby 風に表現すると,次のような制御構造を表現できる。
def p(X)
if g1 then g2
else g3 end
end
カット演算子とコールバックの使用例としてクイックソートのプログラムを示す。
require 'tiny_prolog'
trace(false)
le = pred 'le' # Less than or Equal to
le[:A, :B].calls {|env|
a, b = env[:A], env[:B]
a <= b
}
qsort = pred 'qsort'; partition = pred 'partition'
qsort[cons(:X, :L), :R, :R0] .si \
partition[:L, :X, :L1, :L2],
qsort[:L2, :R1, :R0],
qsort[:L1, :R, cons(:X, :R1)]
qsort[nil, :R, :R] .si
partition[cons(:X, :L), :Y, cons(:X, :L1), :L2] .si \
le[:X, :Y], :CUT,
partition[:L, :Y, :L1, :L2]
partition[cons(:X, :L), :Y, :L1, cons(:X, :L2)] .si \
partition[:L, :Y, :L1, :L2]
partition[nil, :Y, nil, nil] .si
query qsort[list(3, 14, 1, 5, 926, 5, 35, 8, 9, 79), :RESULT, nil]
実行例を示す。qsort の第1引数をソートした結果が第2引数に得られる。
$ ruby qsort.rb 1 qsort[(3 14 1 5 926 5 35 8 9 79), (1 3 5 5 8 9 14 35 79 926), nil] $
プログラム中の trace(false) を trace(true) にするとトレース出力が得られる。
$ ruby qsort.rb
qsort[(3 14 1 5 926 5 35 8 9 79), :RESULT, nil] ~ qsort[(:X . :L), :R, :R0]
(...略...)
qsort[nil, :R1, (3 5 5 8 9 14 35 79 926)] ~ qsort[nil, :R, :R]
qsort[nil, :R, (1 3 5 5 8 9 14 35 79 926)] !~ qsort[(:X . :L), :R, :R0]
qsort[nil, :R, (1 3 5 5 8 9 14 35 79 926)] ~ qsort[nil, :R, :R]
1 qsort[(3 14 1 5 926 5 35 8 9 79), (1 3 5 5 8 9 14 35 79 926), nil]
qsort[(1), :R, (3 5 5 8 9 14 35 79 926)] !~ qsort[nil, :R, :R]
(...略...)
qsort[(3 14 1 5 926 5 35 8 9 79), :RESULT, nil] !~ qsort[nil, :R, :R]
$
参考のため本来の Prolog 構文による partition 述語を示す。 本来の表記方法ではカット演算子が「!」であること, cons(:X, :Y) が [X|Y] であることに注意されたい。
partition([X|L], Y, [X|L1], L2) :-
X =< Y, !,
partition(L, Y, L1, L2).
partition([X|L], Y, L1, [X|L2]) :-
partition(L, Y, L1, L2).
partition([], Y, [], []).
6. おわりに
ここでは Ruby の構文要素を利用して小さな Prolog 処理系を作成した。 バックトラックで次々と値を返すことが Prolog の基本動作だから, Prolog ゴールの問い合わせを Ruby のイテレータとして実現した。 組込み述語はないが, カット演算子やコールバックなどの基本機能を備えており, 潜在的に広範なプログラムが可能である。 例としてクイックソートを示した。
この処理系に,記号表などの隠された大域変数はない。 ローカルに定義した述語はローカルに, グローバルに定義した述語はグローバルに使用できる。 Ruby プログラム内で同時に複数の Prolog プログラムを並行的に使うこともできる。
参考資料
本稿の内容は基本的に下記の要約,注釈,および補足である。
- Rubyで作るProlog処理系 (CodeZine, 2006)
- まつもと & 石塚:「オブジェクト指向スクリプト言語 Ruby」, アスキー出版, 1999, ISBN 4-7561-3254-5
- 原:「Ruby プログラミング入門」, オーム社, 2000, ISBN 4-274-06385-2
- Thomas & Hunt 著, 田和訳: 「達人プログラマーガイド プログラミング Ruby」, ピアソン・エデュケーション, 2001, ISBN 4-89471-453-1
Haskell および Common Lisp (初版) については下記を参考にした。
- S. P. Jones 編:「Haskell 98 Language and Libraries, The Revised Report」, Cambridge University Press, 2003, ISBN 0-521-82614-4
- G. Steele:「Common LISP: The Language」, Digital Press, 1984, ISBN 0-932376-41-X
Algol 68, Pascal, CLU 等の歴史については下記を参考にした。
- T. J. Bergin & R. G. Gibson 編: "History of programming languages", ACM Press, 1996, ISBN 0-201-89502-1
CLU のループ文に影響を与えた Alphard については下記を参照した。 ここでいう generator は 今日の Java や Python に見られるイテレータ・クラスに相当する。
- M. Shaw, W. A. Wulf & R. L. London: Abstraction and verification in Alphard: defining and specifying iteration and generators, CACM, Vol. 20, No. 8 (August 1977), pp.553-564, 1977, ISSN 0001-0782
本処理系は,下記の Python による Prolog 実装をベースとしている。
5.2 のクイックソート・プログラムは下記で配布されている PrologCafe091 に含まれる examples/bench/qsort.pl を参考にした。 Prolog Cafe は Java による代表的な Prolog 処理系のひとつである。
- L: え,何?
- S: Java で動かそうと思ってるんだ。それでね。
- L: それで?
- S: 中身は今のはなしで大体どう作れそうか見当つくんだけど, そこに行く前の構文の処理が問題なんだ。 Java だと演算子の再定義でそれらしく見せるってできないから...。
- L: 構文解析器を手伝ってほしいわけ?。いいけど,今日はもう遅いから...。
- S: ありがと! どうか今度,お願いします。
- L: はい,まかせて。...*yawn*... じゃ,おやすみ。
- S: おやすみぃ。
7. 追記: ジェネレータとイテレータ
- S: 外部イテレータを作るのに有限状態機械を使うって, 実際にはどんな感じだろう。 えーと,たとえば Ruby でこんなイテレータがあったとすると...
def range(n)
i = 0
while i < n
yield i
i += 1
end
end
- S: クラス定義にして... ローカル変数をインスタンス変数にして... あと,有限状態機械なんだから, 状態変数 q をおいて初期値はとりあえず 0 にして...と。
class MyRange
def initialize(n)
@n = n
@q = 0
end
- S: ひとまず Python のやりかたにあわせて,
next メソッドで yield 値を返すことにすると...
状態変数で分岐するんだから case @q で始めればよさそうだけど...
あ,だめだ,それじゃ内部で状態遷移できない。
goto 文があったら簡単なんだけど...。
だから,つまり...
全体を loop で囲って,こうすれば goto 無しで好きなところにジャンプできるはず。
def next() loop { case @q when 0 then ... @q = 1 when 1 then ... @q = 2 ... } end後は,各ステップを状態機械の各状態に割り振っていけばよいんだから... あ,なんだ,これで完成じゃん。
def next()
loop {
case @q
when 0
@i = 0
@q = 1
when 1
if @i < @n
@q = 2
return @i # yield i
else
@q = 3
end
when 2
@i += 1
@q = 1
when 3
raise "end of iteration"
end
}
end
end
- S: できたファイルを実行してみると... うん,大丈夫。
$ irb -r range.rb
irb(main):001:0> range(3) {|i| p i}
0
1
2
=> nil
irb(main):002:0> r = MyRange.new(3)
=> #<MyRange:0x57d04 @q=0, @n=3>
irb(main):003:0> r.next()
=> 0
irb(main):004:0> r.next()
=> 1
irb(main):005:0> r.next()
=> 2
irb(main):006:0> r.next()
RuntimeError: end of iteration
from ./range.rb:32:in `next'
from ./range.rb:16:in `loop'
from ./range.rb:16:in `next'
from (irb):6
irb(main):007:0>
- S: ループを作って,条件が成立したら脱出して...というところは,
どれも似たようなものだから, 細かなところはともかく,
だいたい,この方法でワンパターンに解決できそうじゃん。
分かってみれば,ずいぶん簡単 :^)
...で,結局動いているのは...ただの next 関数...。 for 文を 2 重ループにしても各 for 文で 1 重にしか呼び出さないし, 3 重ループにしても 1 重にしか呼び出さない。
... あれ,待てよ。for 文をいくら入れ子にしても, next 関数呼び出しはどうやっても 1 重にしかならないけど... もしも next 関数の中から next 関数を呼び出したら... 呼び出しただけスタックが積み上がるはず。
...ジェネレータ定義に戻して考えると... 定義本体から直接にせよ間接にせよ for 文で自分や他人のジェネレータを利用すれば... next 関数の中から next 関数を呼び出すことになるはず...!!
あれれ,Ling が言ってたことって,ひょっとして...!?
あしたになったら Ling に聞いてみよっ...と ...*z-z-z*....
- S: ねぇねぇ, Python のジェネレータって,本当は呼び出しスタックを積み上げてない?
- L: ...おはよぅ,今日はお休みなのに...なぁに一体?
- S: 「Python の _resolve_body はローカル変数をスタックに入れ子で積み上げない」って Ling 言ってたよね。それって違うくない?
- L: ...えぇと,はい?
- S: Python の _resolve_body は 自分自身の定義本体の for ループで再帰的に _resolve_body を使ってるよね? これって実際には,実行時に作られるイテレータ・オブジェクトの next 関数を呼び出すんだよね? だったらスタックを使うよね?
- L: ...えぇと...えぇと...
for ループの制御変数の「次の値」を得るために
next メソッドを実行するとき,
ジェネレータ定義の本体が実行されるわけだけど...
そこにまたジェネレータを使った for ループがあったとすると...
あっ そうです...
そのイテレータの next メソッドを呼び出すわけだから...
えぇと,そのとおりです。ウソ言ってました。
ジェネレータの定義本体が, 直接・間接に自分や別のジェネレータを使って for ループを構成しているときは, 実行時に呼び出しスタックが積み上がります。 - S: でも,定義本体それ自体は積み上がったスタックの上にいるとしても, そこからの相対高さでみると, その内部のループ本体は, どんなに多重ループにしても積み上げ無し, というか,ループの外と地続きで段差が無いわけだよね。
- L: えぇ, Ruby や CLU とちがって,ループ本体が「呼び出されている」わけではないから, 段差がつくことはないです。 あのときは,そのことばかり考えて,ループ本体を取り囲む定義本体そのものが 再帰で積み上がる可能性を忘れていたんですね。
- S:
つまり,スタックは確かに積み上がるけど,
ループ自体からの視点では積み上げは見えない。
そういう意味では,
ジェネレータの魔法もあながち間違いじゃなかったんじゃない?
...あのとき「魔法」って言ったのは自分だけどね。
結局,2 重ループにしたときでも, 内側のループに積み上げが累積することがないから, Ruby のような内部イテレータと比べて, スタックの消費はずっと小さくなるはずだし。
- S: ところで,Ling が言ってた「再帰関数のベンチマーク」って実際には何なの?
- L: tak 関数です。 Python 版の tak.py は無事に動くんだけど, Mac で Ruby 版の tak.rbを実行するとね...
$ ruby -v
ruby 1.8.2 (2004-12-25) [powerpc-darwin8.0]
$ ./tak.rb
1 tak[4, 2, 0, 1]
./tiny_prolog.rb:104:in `_unify': stack level too deep (SystemStackError)
from ./tiny_prolog.rb:103:in `loop'
from ./tiny_prolog.rb:103:in `_unify'
from ./tiny_prolog.rb:130:in `_unify'
from ./tiny_prolog.rb:129:in `each'
from ./tiny_prolog.rb:129:in `_unify'
from ./tiny_prolog.rb:195:in `_unify_'
from ./tiny_prolog.rb:163:in `_resolve_body'
from ./tiny_prolog.rb:160:in `each'
... 952 levels...
from ./tiny_prolog.rb:160:in `_resolve_body'
from ./tiny_prolog.rb:142:in `resolve'
from ./tiny_prolog.rb:220:in `query'
from ./tak.rb:31
$
- S:
from ./tiny_prolog.rb:160:in `each'って,ファイルを見るとfor d_head, d_body in goal.pred.defsなんだけど, これって for 文も内部実装は each イテレータ ってことだよね? - L: 内部実装は詳しくないけど,そうじゃない? ...というか,この出力からするとそのはず。
- S: だとすると,スタックの積み上げがさらに高くなってることは間違いないよね。 事実は 2 重どころか 3 重ループでさらに累積するはず。 ...でも,これみると Ruby のイテレータって, 打たれ弱いというか,少しいじめるとすぐ限界が来るって感じなんだけど...。
- L: たしかに本来の意味でのイテレータの定義手段としては 少々原始的だけど...否定的な面ばかりじゃなくてね... コールバックの定義を tak.py と tak.rb で比べてみて。
- S: たしかに...コールバックの定義構文をユーザが作れる,
という点は良さげだね。
- L: 実は...ひとつ気がかりがあるんだけど...。 今まで Ruby について長所も短所も言ってきたはずけど... ひょっとして短所ばかり伝えてしまってるんじゃないかって...。
- S: 大丈夫,そんなことないよ。 というか,実はね,今まで敬遠してたんだ。 だって,Ruby 関係のどれも Ruby バンザイばかりでどうもね...。 それが,たしかに奇妙なクセはあるけど,それさえつかめば十分 普通に使えそう と分かったんだ。
- L: ...安心しました...自分が心配するのも変だけど。 いわゆる strongly-typed のシャレで strongly-hyped と言うのかな, たしかに客観的な評価ができていない過剰な宣伝文句というのもあるけど...ね。
- S: でも,使ってみれば,そんな宣伝文句の真偽はともかく, 結構,便利に使えるから,無視を決め込むのはもったいない...わけだよね。 ふしぎに気まぐれな構文*4とかで 惑わされがちだけど。
*4
この Suzu の発言は,
一見するとイテレータの構文についての疑問に基づいているが,実は過去のトラウマに起因している。その原因となったのは,
関数適用の解釈が空白文字に依存するという
(おそらくは Perl に由来する) Ruby の構文則である。
プログラム中で通常無視されるはずの空白文字が意味をもつことに関しては,
複合文の構造が字下げに依存する Python 等が通俗的には悪名高い。
しかし,見た目どおりの字下げ (ただし '\t' 文字は Unix の慣習に従い 8 タブ扱い)
ですべてが説明される Python の明快さと比較すると,表立ってはいないが,
空白に関する Ruby の構文則は微妙で複雑であり,簡潔な説明が困難である。
事実,本稿であげた Ruby の参考文献はどれも,
その正確なルールを記述せず,
「プログラマの意図をできるだけ推測して解釈を行います」(文献2.§2.4)
等の叙述にとどめている。
現実には,人間の意図を推測するなんらかの新技術が使われているわけではなく,
あらかじめ想定された書き方に対し,
定型ルールによる「推測」が見掛け上機能するにすぎない。
したがって,ときには気まぐれとも思われる結果が得られる。
たとえば,無引数関数 a の結果に「変数 b を足し,c を足す」ということを
Ruby スクリプト上で強調しようと a +b +c と書くと,
a(b + c) と解釈されてエラーになる。
空白だけの違いであるが a + b + c と書いたときは問題ない。
文脈依存だが,a が関数ではなく変数である場合はどちらでも問題ない。
GNU コーディング規約の書き方にならって関数と丸カッコのあいだに空白文字をおくと,
さらに非直感的な結果に悩まされることになる。
同様のルールをもつ Perl は
"If it looks like a function call, it is a function call."
(L. Wall et al.: Programming Perl 2nd ed., O'Reilly, 1996, p.77)
と説明している。しかし,一定の勢力をもつ GNU コーディング規約に対し,
この説明は意味をもたない。
プログラミング言語の構文解析器に人間のような常識や機転を与えることは,
現在の技術ではできない。
プログラミング言語に煩雑な構文則を規定しても,
決して自然言語と同じ柔軟性は得られない。
現代のプログラミング言語は,微妙で説明しにくいルールではなく,
適度に明快で分かりやすいルールを規定し,
その組み合わせの自由を提供するのが普通である。
この教訓に Perl と Ruby はなんら技術的裏付けなしに挑戦し,
技術的必然として失敗している。
今日の両言語の成功は,この挑戦のおかげではなく,
むしろこの挑戦にもかかわらず,あるいはこの挑戦とは関係なく
(他の長所と利点により) 得られたものであると言ってよい
(つまり,「そんな宣伝文句の真偽はともかく」である)。
Suzu が Ruby について部分的に詳しかったり,無知だったり,
無条件の称賛に懐疑的なのは,
過去いったんは学ぼうとして Ruby のこの欠陥を無意識に感じ取り,
「普通に使えそう」にないと見限って離れたことが背景にある。
8. 追記: ジェネレータとイテレータ − 実験
- S: ところで...さっきの話だと, Python の再帰的ジェネレータに比べて, Ruby の再帰的イテレータが多重ループでスタックを浪費しそうということが 定性的には分かるし,tak 関数の結果からも裏付けられそうなんだけど... ジェネレータのスタックの積み上げって,イテレータと比べて, 具体的というか定量的にはどうなんだろ?
- L: ええと... Prolog の処理系のイテレータを簡単化したような構造のイテレータで, ちょっと実験してみますね。
- S: 時間軸とスタック段数でグラフにすると,再帰的なんだから, なんとなく...だけど,一種のフラクタル図形みたいに...例えば コッホ曲線 みたいに再帰的に段々になるような気がするけど...。
実験に使用する 再帰的な2重ループの Python ジェネレータと Ruby イテレータを示す。 表記法の違いを除き,両者の定義本体は同一である。
def iter1(n):
if n == 1:
yield 1
else:
for x in iter1(n - 1):
for y in iter1(n - 1):
yield x + y
def iter1(n)
if n == 1
yield 1
else
iter1(n - 1) {|x|
iter1(n - 1) {|y|
yield x + y
}
}
end
end
実験プログラムは,上記の定義を含む
iter_test.py と
iter_test.rb である。
下図は n=4 に対する結果である。

n=4 に対する iter1 の呼び出しごとのスタック高さ
(左が Python のジェネレータ, 右が Ruby のイテレータ)
- L: このグラフは,横軸が右に向かってスタックの積み上げ段数で,
縦軸が下に向かって 1 行 1 行が 1 回 1 回の呼び出し,
つまり,単位はともかく時間の進行方向です。
各行の右端についている数字は,その呼び出しでの引数 n の値です。
最初が 4 で,1 になったら再帰の底です。
ただし,呼び出し (call) だけで戻り (return) がないから, 本当に時間軸に沿ってスタックの段数を描いた場合に比べて, グラフの山の下半分がない点は注意してください。 - S: 思った通り...ジェネレータのほうは再帰的にデコボコになってるね!
- L: これは 2 重ループの外と内とでそれぞれ 定義本体からの相対高さゼロのところに落ちる, ということを再帰的に繰り返しているわけだから... 実際,作図的にコッホ曲線にちょっと似ている点もあるわけですね。
- S: あと,ジェネレータのほうは一定以上積み上がらないけど, Ruby イテレータのほうはスタックがどんどん積み上がってるけど, この例だと再帰の底に着くまで積み上げが戻るタイミングがないからだね。
- L: どちらもインタープリタのトレース機能を使って,
実際の呼び出しフレームの段数を数えているから,
この結果は見掛けではなく実際の状態を示しているはずです。
ただし,内部実装をそのまま出しているから,
n=4 で開始した時点で左は 1 段,右は 4 段だけ下駄を履いていて,
その分は差し引く必要があるけど...。
...でも,ジェネレータのも,再帰的といっても, コッホ曲線みたいに再帰が深くなるごとに小さくなるわけじゃないから, n を大きくするとどんどん長くなる...というか,時間がかかるわけで...。 でも,もし再帰するごとにそれだけ近似計算でそれなりに速くすませていけるなら... ひょっとして,人間の思考ってそんな風にして, いわゆる「フレーム問題」を解決してる...ようにみせてるんでは? ...そうだとすると,縦軸を実時間でとったとき, これでフラクタル図形が描けるようになるまで, 同じような再帰ループの Prolog で人間のような人工知能なんて実現できっこない? ...でも,オートマトン的に手順がかっちり決まっているものを 近似計算でそれなりにって... - S: Ling, ねぇ Ling, 何考え込んでるの?
- L: あ,いえ,ちょっと...ね。
n=1 から n=20 までの,のべ呼び出し回数 (calls) と,最大スタック高さ (nests) を測った様子を示す。
$ ./iter_test.py 1 20 n=1: 2 calls, 2 nests n=2: 6 calls, 3 nests n=3: 14 calls, 4 nests n=4: 30 calls, 5 nests n=5: 62 calls, 6 nests n=6: 126 calls, 7 nests n=7: 254 calls, 8 nests n=8: 510 calls, 9 nests n=9: 1022 calls, 10 nests n=10: 2046 calls, 11 nests n=11: 4094 calls, 12 nests n=12: 8190 calls, 13 nests n=13: 16382 calls, 14 nests n=14: 32766 calls, 15 nests n=15: 65534 calls, 16 nests n=16: 131070 calls, 17 nests n=17: 262142 calls, 18 nests n=18: 524286 calls, 19 nests n=19: 1048574 calls, 20 nests n=20: 2097150 calls, 21 nests $
$ ./iter_test.rb 1 20
n=1: 1 calls, 5 nests
n=2: 3 calls, 8 nests
n=3: 7 calls, 15 nests
n=4: 15 calls, 30 nests
n=5: 31 calls, 61 nests
n=6: 63 calls, 124 nests
n=7: 127 calls, 251 nests
n=8: 255 calls, 506 nests
./iter_test.rb:18: stack level too deep (SystemStackError)
from ./iter_test.rb:17:in `iter1'
from ./iter_test.rb:7:in `iter1'
from ./iter_test.rb:8:in `iter1'
from ./iter_test.rb:7:in `iter1'
from ./iter_test.rb:8:in `iter1'
from ./iter_test.rb:8:in `iter1'
from ./iter_test.rb:7:in `iter1'
from ./iter_test.rb:7:in `iter1'
... 748 levels...
from ./iter_test.rb:7:in `iter1'
from ./iter_test.rb:27
from ./iter_test.rb:25:in `each'
from ./iter_test.rb:25
$
- S: えーと,Ruby イテレータのほうの,のべ呼び出し回数は 2n-1 だよね。
- L: 初項が a1 = 1 で, n が 1 増えるごとに iter1(n - 1) を 2 回呼び出して 自分自身の呼び出しが 1 回あるわけだから... an+1 = 2an + 1。 一般項はたしかに an = 2n-1 です。
- S: Python ジェネレータの呼び出し回数が 2 倍になっているのは何で? frame オブジェクトの f_lineno をみると,iter1 の定義先頭と yield 文の両方がトレースで出てくるけど...
- L: この例では,各 for ループは 1 回しかループしてないよね? 1 回めは最初の next() メソッド呼び出しで定義先頭から, 2 回めは yield 文のところから実行再開する next() メソッド呼び出しで, そちらのほうはループ終了をしらせる例外で終わってるんじゃない?
- S: つまり,単純に 2 倍というわけじゃなくて, 各ループでプラス 1 というわけ? 反対にいうと,Ruby の内部イテレータの場合, ループ終了かどうかは内部的に判定できるから, そのプラス 1 を節約できるんだよね。
- L: 各ループのループ回数が多いとありがたみがなくなりそうだけど...ね。
- S: あと,Ruby のほうの最大スタック高さは, 大体,のべ呼び出し回数に比例しているよね。 回数のほうが指数関数で増えているから, これもおおよそ指数関数的に増えるわけで, すぐに限界が来る...というわけだよね。
- L: Suzu が,Python のジェネレータをハンドコンパイルした外部イテレータで Java 版を作ろうとしてるのは, ここまでで判断する限り,たしかに正解...といってよさそう。
- S: 太鼓判 (?) くれて,ありがと。ちょい待ってね!
Java で作る Prolog 処理系 へつづく