序
Stellaの話、第三段。 今回は、特徴的な「Stella logicとDSL」の話をしていく。
正直なところ、Stella logicは仕方ないとはいえあまりスマートではない設計がなされている。 なおかつYAML形式だ。 確かに知識が少なくても書けるようには工夫されているが、その一方で労力、時間、そしてミス発生の余地という犠牲を払うことになる。
だが、このYAML形式、というのがポイントになっている。 その不自由は、与えられたものを使うだけの人にとってのみのものだからだ。
Stella logicの構造
Stella logicの肝となるのはcontext leafと呼ばれるものだが、これは
[ method, arg ]という構造をしている。 methodはtests及びactionsが存在し、それぞれ「テスト名」「アクション名」と呼ばれている。 argの型はmethodによって決まっており、
[ method, arg1, arg2...]
のようにはならず、常に単一の値である。
例えば
[ match, String ]
のように定義されており、ものによっては
[ msg, (String|Array)]
のように定義されている。
もうなんとなくわかるかもしれないが、思いっきりLispである。 もっとも、Stella logicはなんとなくLispっぽいだけで純粋にLispスタイルなわけではない。
基本的にはcontext leafまでは次のように定義されている。
root.is_a? Hash
root.has_key? "Context"
root["Context"].is_a? Array
root["Context"].all? do |context_tree|
context_tree.all? do |context_branch|
context_branch.is_a? Hash && context_branch.has_key? "tests" && context_branch.has_key? "actions" and
context_branch["tests"].is_a? Array and
context_branch["actions"].is_a? Array
end
endもっと具体的には次のようになる。
root = {"Context" = {"main" => Array.new} }
root["Context"]["main"].push({"tests" => Array.new, "actions" => Array.new})有効なcontext leafまで書くと
root = {"Context" = {"main" => Array.new} }
root["Context"]["main"].push({"tests" => [ ["match", "Hello"] ], "actions" => [ ["msg", ["Hello, ", "world!"] ] ]})みたいな感じ。
Lispで書くと
'(
("Context" (
("main" (
("tests" (
("match" "Hello")
))
("actions" (
("msg" ("Hello, " "world!"))
))
))
))
)である。そして、YAMLにすると
Context:
main:
-
tests:
- [match, Hello]
actions:
- [msg, ["Hello,", "world!"]]となる。 類似の感覚でRubyで書くと
{
"Context" => {
"main" => [
{
"tests" => [
["match", "Hello"]
],
"actions" => [
["msg", ["Hello, ", "world!"]]
]
}
]
}
}さらにJSONで書くと
{"Context":{"main":[{"tests":[["match","Hello"]],"actions":[["msg",["Hello, ","world!"]]]}]}}この中ではRubyで書くのが,を忘れそうで一番危ない。 ちなみに、これに関してはPerlで書いてもRubyと全く同じになる。
入れ子が深く複雑に見えるが、作業自体は骨格ができてしまえばコピペ+改変でがんばれるので難易度自体は低い。 これを可能にするためコピペベースとなるサンプルも提供されている。
「YAMLで書く」である合理性
見ての通り、Stella Logicは連想配列, 配列, 文字列の組み合わせによって表現できる。 これらは多くのプログラミング言語に備わる基本的データ型である。
そして、そのような基本的データ型であるからこそYAMLやJSONで表すことができる。
YAMLは汎用性のあるデータフォーマットだ。
以上を以て「Stella logicは任意の言語で記述できる」が成立する。 例えばRubyなら
require 'YAML'
root = { "Context" => { "main" => [
{
"tests" => [
["match", "Hello"]
],
"actions" => [
["msg", ["Hello, ", "world!"]]
]
}
]}}
YAML.dump root, STDOUTで良いわけだ。
専用の記述法を提供すればもっと簡単に書けるようになるが、そうなるとその記述法以外は許されなくなってしまう。 「このようにYAMLで書いてください」ということをそのまま受け取るだけであれば、もっと良い記述法が提供されるべきと思ってしまうだろうが、知識と発想さえあれば汎用性があり容易な手段で提供されることは良いことだと判断できるだろう。
ちなみに、JSONでなくYAMLである理由は、「JSONだと閉じ括弧のあとのカンマを忘れるから」である。 プログラマですら忘れるものを一般の人が意識できるわけもない。
なお、YAMLでの出力は難しい言語処理系を使う場合、JSONで出力しておき、
$ ruby -ryaml -rjson -e 'YAML.dump JSON.load(ARGF), STDOUT' logic.json
なんてワンライナーで変換できる。
だからDSL
とはいえ、YAMLで書くのは結構かったるい。 基本的なスタンスは「書きやすい方法は各々用意すべし」なのだが、それが難しく不自由を強いられるケースもあるだろう。
そのため、Stella logic builderというRubyライブラリを提供している。
このライブラリは、Stella logicを書きやすいようにするための語彙を提供する。 例えばコンテキストリーフノードmsgについて
msg("Hello, ", "world!")のように書けるようにする。これを使うだけで前述のコードが
{ "Context" => { "main" => [
{
"tests" => [
match("Hello")
],
"actions" => [
msg("Hello, ", "world!")
]
}
]}}とちょっと読みやすくなる。 もちろん、こんな使い方をするためでなく、Rubyで独自のより書きやすい書き方を制作する際の補助である。
同ライブラリには私なりの書きやすい書き方も用意されている。
c = Stella::Builder.new
c.cxt("main")
c.push(
"tests" => [match("Hello")]
"actions" => [msg("Hello, ", "world!")]
)
c.outStella::Builder#cxtはデフォルトが"main"なので別に呼ばなくて良い。
Stella::Builder#outは出力用メソッドなので、前述と同じ内容の記述であれば必要ない。 つまり、
c = Stella::Builder.new
c.push(
"tests" => [match("Hello")]
"actions" => [msg("Hello, ", "world!")]
)である。すっきり。
これはRubyistにとって書きやすい設計を目指しているものであるから、「不自由を強いられる人にとって書きやすい書き方」ではない。 同ライブラリはそのような人に向けた書き方も提供している。それがStella DSLだ。
Stella DSLを用いて書くと次のようになる。
dsl
as "main" do
on match("Hello")
act msg("Hello," "world!")
end
finish次のような書き方も可能。
dsl
as "main" do
lets do
match("Hello")
msg("Hello, ", "world!")
end
end
finishあまり違いがないように見えるかもしれないが、コンテキストリーフノードが複数ある場合は大きく変わってくる。
跡形もないだろう? Stella LogicがYAMLであることが何かを縛り付けるためではなく、それぞれの技倆における自由を与えるものであることがおわかりいただけたかと思う。 もちろん、Sella DSLが正解なわけではなく、むしろ可能なのであれば最も書きやすい方法で書くことが推奨される。
Stella DSLはRuby内部DSLであるため、書き方の自由度は高く、表現上の自由度は大幅に上がっている。 Chienomiを読んでいる諸兄諸姉はプログラミングのできる人がかなり多かろうから、ここまで説明すれば十分に理解していただけていることと思う。
ちなみに、メタ記述法をとらないのであればmatch("Hello")と書く方法自体はとても簡単で
def match(str)
["match", str]
endで良い。
Stella DSLはプログラムを自在に書けない人のためのStella logicの別記法であると同時に、「Stella logicはあなたの技術力によって生成するものである」という原則の純正サンプルである、ということだ。
専用記法とは
専用記法を採用するとしたら、多分こんな感じだろう。
@main
/test
match Hello
/act
msg "Hello, " world!
@でコンテキスト切り替え、/で定義する内容の宣言になっている(testで新規リーフになる)。 メソッドの引数はShell wordsとして解釈される。
他の記法と比べて文法が非常に寛容でエラーになる可能性が低い。 この記法自体は良いと考えていると、なんなら今からでも採用したいくらいだし、実際それは可能である。
だが、もしいまからこの記法を採用するとしたら、それはStella Logic(YAML)を生成するメタフォーマットになるだろう。 このルールがあるため、新たなる記法を追加するのは簡単だ。
それがもし、Stellaが受け付けるのがこのフォーマットだけだとしたらどうだろう? もちろん、それをプログラムによって生成することは不可能ではないが、そのジェネレータ、トランスレータは各々が実装しなくてはならない。 そして、これはジェネレータが生成しやすいフォーマットでもない。
YAMLという一般的なフォーマットをStellaが受け付けることによって扱いやすくなっていることが分かるだろう。
in the codes
Stella DSLは基本的に記法が異なるだけで、考え方は変わっていない。 ところが、最終的にYAMLを出力すればいいため、自由度はもっともっと高い。
例えば私が書いた「選択後特定のアクションを経て同一のコンテキストに合流する」というコードは
def sel(msgs: nil, opts: nil, params: nil)
ccount = @ccount
cpre = @cpre
pname = @pname
as("#{@cpre}#{@ccount}") {
on finally
actarg = []
msgs.each {|i| actarg.push msg(i)} if msgs
params.each {|i| actarg.push appendparam(pname, i)} if params
ohash = {}
opts[0].each_with_index {|x, i| ohash[x] = "#{cpre}#{ccount}-#{i + 1}" }
actarg.push(sel(ohash))
act *actarg
}
opts[1].each_with_index do |x, i|
as "#{@cpre}#{@ccount}-#{i + 1}" do
on finally
act *x, pass("#{cpre}#{ccount + 1}")
end
end
@ccount += 1
endとなっている。(Stella DSLを使っている)
余談だが、インスタンス変数をわざわざローカル変数にしているのは、StellaDSL#asはObject#instance_evalを呼ぶのでインスタンス変数が読めなくなるためである。
Stella::BuilderクラスはStella::Builder#pushなどによって簡単にコンテキストリーフの追加が可能であるため、よりプログラム的に生成しやすい。
例えば実用的な意味があるかどうかは別として、学習を元に条件とアクションのペアを生成していき、トポロジカルソートによって構成する、という方法もありうるわけだ。
finally
“Stella logicはLispライクな考え方のYAMLである”
これによって得られたものは
- 容易に変換でき、任意の言語で書くことができる
- 任意の言語を用いて容易に異なる記法を実装することができる
- ループや再利用、関数的コールなど本質的記述に含められない機能を生成に含めることで利用できる
- 単純な記述でなくプログラムによって生成するといった応用が効きやすい
結局、どれほど気の利いたフォーマットや記法を提供することよりも、汎用性があり単純な形式を採用するほうがユーザーに利する。 そこまで考えて採用したわけではないのだが、そのことが改めて実感できるものであった。