2006-09-01

近況

いまの余暇コードは Makefile のかわりに SCons を使っている. Scons は python 製の make alternative. (概要は Radium Software に記事があった.) "#include" によるヘッダファイルの依存関係を勝手に解決してくれるのがいい. 私は何度やっても Makefile の dep ターゲットをうまく書けない. 泣きたくなる. gcc -MD で作った .dep ファイルが どのタイミングで Makefile に incldue されるのか, 実のところ未だによくわかっていない. 少し前にやった仕事でも, 試行錯誤の末になんとなく動いた Makefile をおそるおそる使っていた. (マニュアルをぱくったんだっけ...でも sed なんて使わなかったような...) 一体何がどの順序で評価されるのかさっぱりわからない. こんなのはもう嫌だ. SCons バンザイ. SCons ファイルの評価順もよくわかっていないけれど, そこは気にしないでおく.

そのほかに, Scons は built-in で gcc や cl (MS-C++) をサポートしている. そのサポートに乗れば実行するコマンドは勝手に選んでくれるからけっこう楽ちん. あとは自分で追加したいオプションを書き足せばいい.

SCons では Makefile 相当の SConstruct ファイルを python 文法で書く. script as DSL. そういえば ruby にも似たような make 代替品があったのを思いだした. rake. Rails が使っているやつ. rake はよく ruby を使った DSL の例として引き合いに出される. (Martin Fowlerの記事.)

build-in で色々な機能を持つ SCons と違い, rake は高機能でない. レガシー make の レガシー部分を濾過した, 単純なルールエンジンになっている. だから C/C++ をビルドする目的で使うんだったら支援機能のある SCons の方がいい. ただ rake はとてもコンパクトにまとまっている. DSL のケーススタディーとして眺めてみるにはいいかもしれない. 眺めてみた.

rake のコードを眺める

まず rake-0.7.1をダウンロード, 展開する. 行数を数えてみると 6000 行くらいある. ただ半分近い 2300 行がテスト(./test). 実コード(./lib) は 3200 行だ. 小さい. ./lib/rake.rb が本体で 1900 行ある. 余りはプラグインみたいな拡張部分. rake.rb だけ眺めればよさそう.

クラス一覧

rake.rb を見てます.

まずざっとクラスを眺めてみる. こんなのがある:

ふーん. Task は Makefile のターゲットに相当するものらしい. Application はシングルトンのコンテクストオブジェクトだ. 他は脇役かな.

bootsrap: Application.run()

rake.rb の起動部分はこんなかんじ:

if __FILE__ == $0 then
  Rake::Application.new.run
end

Application.run() が main みたいなものなのね. まずはこいつを追っていこう.

 class Application
   include TaskManager
   ...
   def run
     handle_options
     begin
       tasks = collect_tasks
       load_rakefile
       if (オプションがあったら..)
         ...
       else # こっちが本筋...
         tasks.each { |task_name| Rake::Task[task_name].invoke }
       end
     rescue Exception => ex
       ....
       exit(1)
     end
   end
 end

ぱっと見たかんじはシンプルだ. (引用したコードは余計なものを削ってある. 興味のある人は本体をあたってください.)

Application と TaskManager は Object と Kernel の関係に近くて, 雑多なものが TaskManager に押し出されている. TaskManager の出番はまだ少し先.

さて, 処理を眺めよう. collect_tasks() は実行するタスク名のリストをコマンドライン引数から取得する. load_rakefile() は Rakefile の評価. そのあと collect したタスク名から task を検索(Rake::Task[task_name]), 実行(invoke)している.

collect_tasks() はつまらなそうなのでパスして, まずは load_rakefile() を見てみよう.

Rakefile の評価: load_rakefile()

   def load_rakefile
     here = Dir.pwd
     # 注: have_rakefile() は副作用で みつけた Rakefile のパスを
     #     @rakefile に保存している
     while ! have_rakefile
      ... Rakefile が見付かるまで親ディレクトリをたどる
     end
     ...
     load File.expand_path(@rakefile) if @rakefile != ''
     ...
     load_imports
   end

これもそんなに面白くなかった...単に Rakefile をみつけて load しているだけ. 標準の挙動が親ディレクトリを探すのはちょっと嬉しい. load_imports() については後述.

Rakefile の評価は素朴だ. bind でコンテクストをつくって eval, とかではないんだね. となると, Rakefile で利用できるAPI(関数)はグローバルに定義されているはず. ドキュメント によると, "file", "directory", "task", "rule", "import" などの API がある. 探してみるとたしかに軒並みトップレベルで定義されている.

その定義はどうなっているのか, 代表的な API である "task", "file", "rule", "import" を順に眺めていく.

API: task()

まずは親玉格の "task" から.

def task(args, &block)
  Rake::Task.define_task(args, &block)
end

移譲するだけだ. 他の API もだいたい似たようなつくり. こいつは更に Rake.application.define_task() に移譲する. 結局, 実体は TaskManager にあった.

 module TaskManager
   ...
   # ここでは task_class に "Task" のクラスオブジェクトがやってくる.
   def define_task(task_class, args, &block)
     task_name, deps = resolve_args(args)
     task_name = task_class.scope_name(@scope, task_name)
     deps = [deps] unless deps.respond_to?(:to_ary)
     deps = deps.collect {|d| d.to_s }
     task = intern(task_class, task_name)
     task.application = self
     task.add_comment(@last_comment)
     @last_comment = nil
     task.enhance(deps, &block)
     task
   end

task_class の scope_name() は, 相対名のタスク("foo")を完全装飾名("baz:bar:foo")にする. "www" を "www.example.com" にするたみたいなもんか.

   # Rake Module Methods
   class << self
     ...
     # Apply the scope to the task name according to the rules for
     # this kind of task.  Generic tasks will accept the scope as
     # part of the name.
     def scope_name(scope, task_name)
       (scope + [task_name]).join(':')
     end

ついでに intern(). これも同じ TaskManager のメソッド.

   ...
   def intern(task_class, task_name)
     @tasks[task_name.to_s] ||= task_class.new(task_name, self)
   end

要するに, 引数から task の名前(task_name)と依存関係(deps)を切り出し, scope_name() で解決した task_name に対応するオブジェクトを表から検索/なければ作って, ("| = =" はそういうイディオムね.) ごにょごにょやって, 依存関係と block で task を enhance() する.

enhance が何かはさておいて, block の中身を確認しておこう. これは Rakefile で task に渡されるブロックだった. 要はそのタスクで実行したい処理. これをブロック文法で簡潔に書けるのは rake のいいところだとおもう. Rakefile はこんなかんじ(微妙に人為的なのは御愛嬌):

task :my_clean => [:foo, :bar] do
   rm CLEAN_FILES
end
my_clean が task_name,
foo, :bar が deps,

中のブロックが block に渡ってくる. "rm" はシェルじゃなくて ruby(rake) のメソッドね.

本筋に戻って enhance() を.

 class Task
   ...
   def enhance(deps=nil, &block)
     @prerequisites |= deps if deps
     @actions << block if block_given?
     self
   end

なんてことはなかった... @prerequisites と @action に それぞれ 引数の deps と block を追加する. 上書きじゃなくて追加なのがちょっと面白い. rake では同じ名前の task を定義して, 依存関係や挙動を追加できるんだね. つまりこんな Rakfile を動かすと...

task :default do
  print "hello,"
end

task :default do
  print "bye\n"
end

こうなる:

foobar:~/work/rails/hello/my-temp omo$ rake
(in /Users/omo/work/rails/hello/my-temp)
hello,bye

API: file()

次は file を見てみよう. これは FileTask.define_task に移譲される.

def file(args, &block)
  Rake::FileTask.define_task(args, &block)
end

...というか, FileTask クラスオブジェクトのインスタンスの define_task() メソッドが呼ばれる. (呼ばれるコードは Task.define_task と同じ.) だから intern() で作られるオブジェクトが FileTask のインスタンスになる. ちょっとメタっちい. ruby っぽいコードだなあ.

API: rule()

もう一つの主役級である rule.

def rule(args, &block)
  Rake::Task.create_rule(args, &block)
end

移譲. 結局また TaskManager(Application) が呼ばれる.

 module TaskManager
   ...
   def create_rule(args, &block)
     pattern, deps = resolve_args(args)
     pattern = Regexp.new(Regexp.quote(pattern) + '$') if String === pattern
     @rules << [pattern, deps, block]
   end

一行目は前と似ている. pattern は文字列だったら正規表現に直す. それから三つ組を @rule 配列に追加. この三つ組の中身を調べるために, まず Rakefile を見よう. rule はこんなかんじで使われる.

rule '.o' => ['.c'] do |t|
  sh "cc #{t.source} -c -o #{t.name}"
end

少しわかりにくいけど, 一番目の引数が pattern, 二番目が deps, ブロックが block になる. (deps の配列に拡張子を渡している点に注目. 忘れたころに出てくる.)

上級編:

rule( /\.o$/ => [
   proc {|task_name| task_name.sub(/\.[^.]+$/, '.c') }
 ]) do |t|
   sh "cc #{t.source} -c -o #{t.name}"
end

少しわかりにくいけど, 一番目の引数が正規表現, 二番目が proc.

結局 @rule には "正規表現か文字列, 依存関係の拡張子や proc, ブロック" という三つ組の配列が追加される. 私なら Task と揃えて "Rule" というクラスを作りそうだけれど, そこは配列を使った三つ組で済ませている. LL っぽい.

API: import()

最後は import. (だんだん飽きてきた...)

import はもともと依存関係のリストを読み込むためにある. make dep 相当だね. 先に Rakefile の例を見ておこう:

require 'rake/loaders/makefile' # この require はとりあえず気にしない

file ".depends.mf" => [SRC_LIST] do |t|
  sh "makedepend -f- -- #{CFLAGS} -- #{t.prerequisites} > #{t.name}"
end

import ".depends.mf"

makedepend は gcc -MD みたいなもの. それで作った依存関係リストを import で rake に取り込む.

コードはこれ:

def import(*fns)
  fns.each do |fn|
    Rake.application.add_import(fn)
  end
end

引数は配列を渡すんだったか. 行き先はこちら:

 class Application
   ...
   def add_import(fn)
     @pending_imports << fn
   end

配列に追加しておわり. 一番あっけない...

API まとめ

これで代表的な API はだいたい網羅した, つもり. ちょっとまとめておこう.

どの API も内部のデータ構造を更新するだけ. 実際に行動ユーザから見える行動を起こすことはない. 実行したい処理自体はブロックのまま保留されている.

DSL は宣言的な性格を持つことが多い. だから自家製 DSL の文法に ruby のような既存の言語を使う場合, スクリプトの中身が副作用そのものではなく宣言の組立になるよう設計するのは一つのアイデアだ. 実際の副作用はそのあと DSL 処理系(ライブラリ)の側で発行すればいい. イメージとしては ruby の 構文木を評価すると DSL の構文木(内部モデル)ができて, DSL エンジンはその内部モデルを評価するかんじ.

DSL の能力は制限されているのが普通だ. でも rake や SCons のように既存の言語を使った DSL は, その標準機能で表現しきれない部分はホスト言語で拡張することができる. その拡張部分を ruby の block としてインラインに書けるのが rake のいいところだと思う. block を使えば実際の作業を簡潔に書けるから, rake のコアはルールを解釈する機能だけ持てばいい.

SCons も同様の拡張ができるけれど, まず関数や callable なクラスを定義してから それを API で渡す必要がある. ちょっと面倒. ただかわりに組込みの機能は充実しているから, そもそもスクリプト側での拡張はやらずに済むことが多い. 実用上はそれで困らない. 便利に使える.

このように, make という古典的 DSL のモダン焼き直し対決として rake と SCons の違いを眺めると面白い. DSL の設計に落とすホスト言語の影を見ることができる.

...などとそれらしい能書きを垂れたら気が晴れてきた. 続きます.

実際の作業: Task.invoke()

Application.run() で load_rakefile() が終わると内部モデルができあがる. あとはそれを実行すればいい. Task.invoke() がその実行部分.

 class Task
   ...
   def invoke
     @lock.synchronize do
       ...
       return if @already_invoked
       @already_invoked = true
       invoke_prerequisites
       execute if needed?
     end
   end

呼ばれてたらスキップ, invoke_prerequire() で依存するタスクを先こなして, それから実体の excute() が実行される, というところだろうか. (lock しているのは紹介していない multitask API のため.)

まず invoke_prerequire() を見よう.

   def invoke_prerequisites
     @prerequisites.each { |n|
       application[n, @scope].invoke
     }
   end

依存をあらわす @prerequisites は文字列なので, それを TaskManager.[] で Task オブジェクトに変換する.

 module TaskManager
   ...
   # Find a matching task for +task_name+.
   def [](task_name, scopes=nil)
     task_name = task_name.to_s
     self.lookup(task_name, scopes) or
       enhance_with_matching_rule(task_name) or
       synthesize_file_task(task_name) or
       fail "Don't know how to build task '#{task_name}'"
   end

うーん. ここはちょっと頑張ってるね. 少し追ってみたけれど先は長そう. lookup(), enhance_with_matching_rule(), synthesize_file_task() は 保留して先に全体の流れを追おう.

Task.exexute()

タスクの実際の処理をする execute() はこんなの

 class Task
   ...
   def execute
     ...
     application.enhance_with_matching_rule(name) if @actions.empty?
     @actions.each { |act| result = act.call(self) }
   end

@actions は API に渡されたブロックだった. 何かしらブロックがあればそれを実行し, なければ rule で引っ張ってきて (enhance_with_matching_rule()) 実行する.

はい, これで大筋はおしまい.

Rakefile を実行して依存関係を構築し, タスクのブロックを関連づけ, それから引数の task を検索して依存関係を解決しながら関連づけられたブロックを実行する. そういう無難なケースは網羅したはず.

無難なケースとは, 実行するアクション(ブロック)も依存関係も すべて明示的に指定されたようなケースのこと. rake 自身はもっと賢くふるまう. たとえば rule もまだ登場していない. action を明示的に指定しなくてもなんとなく動く. そのへんの肝はどうやら横着してスキップした TaskManager.[] の先にあるらしい.

仕方ない. 続きを追おう.

愚直なタスクの検索: TaskManager.lookup()

   # (和訳)
   # task を探す. そのためにスコープや, タスク名にあるスコープのヒントを使う.
   # このメソッドは file task の合成やルールは使わず愚直な検索をする.
   # 特別なスコープ名 (例:'^') は識別される. スコープ引数がないときは今のスコープを使う.
   # みつからなければ nil を返す.
   def lookup(task_name, initial_scope=nil)
     initial_scope ||= @scope
     task_name = task_name.to_s
     if task_name =~ /^rake:/
       scopes = []
       task_name = task_name.sub(/^rake:/, '')
     elsif task_name =~ /^(\^+)/
       scopes = initial_scope[0, initial_scope.size - $1.size]
       task_name = task_name.sub(/^(\^+)/, '')
     else
       scopes = initial_scope
     end
     lookup_in_scope(task_name, scopes)
   end

親切なコメントに救われた. まず scopes 配列を作って lookup_in_scope() を呼ぶ. "rake:" はグローバルスコープ. "^" はファイルシステムでいう "../" みたいなものらしい.

   def lookup_in_scope(name, scope)
     n = scope.size
     while n >= 0
       tn = (scope[0,n] + [name]).join(':')
       task = @tasks[tn]
       return task if task
       n -= 1
     end
     nil
   end

依存関係を少しずつ装飾していって task を検索する. (@tasks は完全装飾名をキーに task を保存していたのを思いだそう.) たしかに愚直だ.

ルールベースのタスク検索: TaskManager.enhance_with_matching_rule()

愚直に探してみつからないと, 今度は rule を使う.

   # (和訳)
   # タスク名にマッチするルールがみつかったら,
   # そのルールからのアクションと依存関係を使って enhance する.
   # 適当な source 属性をセットする. enhance したタスクを返すか,
   # ルールがみつからなかったら nil を返す.
   def enhance_with_matching_rule(task_name, level=0)
     fail Rake::RuleRecursionOverflowError,
     "Rule Recursion Too Deep" if level >= 16
     @rules.each do |pattern, extensions, block|
       if md = pattern.match(task_name)
         task = attempt_rule(task_name, extensions, block, level)
         return task if task
       end
     end
     nil
     ...
   end

コメントどおり. rake の作者はいいひとだなあ... "task" API と違い, "rule" の第一引数は正規表現だった. /.o$/ みたいの.

無限再帰のチェックがあるあたりから, rule の検索はどうも再帰になっているらしいこともわかる.

肝心の task を作るのは attempt_rule().

   # Attempt to create a rule given the list of prerequisites.
   def attempt_rule(task_name, extensions, block, level)
     sources = make_sources(task_name, extensions)
     prereqs = sources.collect { |source|
       if File.exist?(source) || Rake::Task.task_defined?(source)
         source
       elsif parent = enhance_with_matching_rule(sources.first, level+1)
         parent.name
       else
         return nil
       end
     }
     task = FileTask.define_task({task_name => prereqs}, &block)
     task.sources = prereqs
     task
   end

中カッコと do...end の使いわけにポリシーを感じないね. まあいいや.

まず enhance_with_matching_rule() が目につく, たしかに再帰している. 内容は, まず make_sources() で依存関係の候補を作る. 次にそれを少しトリッキーな方法でフィルタし, それを元に新しい task を定義している.

make_sources() に進もう.

   # (和訳) ファイル名拡張子のリストから source のリストを作る.
   # proc だったらそれを変換する.
   def make_sources(task_name, extensions)
     extensions.collect { |ext|
       case ext
       when String
        task_name.sub(/\.[^.]*$/, ext)
       when Proc
         ext.call(task_name)
       else
         fail "Don't know how to handle rule dependent: #{ext.inspect}"
       end
     }.flatten
   end

コメントが親切で言うことないよ...

extension はファイルの拡張子(の配列). "file" API の引数には 拡張子の文字列が渡っていたのを思いだそう. あれです. 上級向けの例に表われた proc もちゃんと使われている. ("ext.call()" の部分.)

せっかくなのでルールが再帰する例を作ってみた.

rule(/.a/ => [".b"]) {}
rule(/.b/ => [".a"]) {}

こんなかんじで怒られる.

foobar:~/work/rails/hello/my-temp omo$ rake foo.a
rake aborted!
stopping only thread
        note: use sleep to stop forever
(See full trace by running task with --trace)

ちなみに Makefile だと...

foo.a: foo.b
	echo "hello"
foo.b: foo.a
	echo "hello"

で...

contentiss:~/work/rails/hello/my-temp omo$ make
make: Circular foo.b <- foo.a dependency dropped.
echo "hello"
hello

おお. ちょっと賢い.

ファイルタスク: TaskManager.synthesize_file_task()

何をしてたんだっけ...

ええと, Rakefile で明示的に指定されていない依存関係やタスクが どうやって関連づけられているのかを追っていたのだった. で, 明示的な場合と rule を使った場合についてはわかったと.

残りは synthesize_file_task() なのですが,

   def synthesize_file_task(task_name)
     return nil unless File.exist?(task_name)
     define_task(Rake::FileTask, task_name)
   end

あっけなかった. 文字列だったのを FileTask にインスタンス化して終わり.

make dep 問題にとりくむ : load_imports()

これでもう rake はばっちりだ...と思ったけれど何かが抜けている気がする. 読み直してみる...

あらら, load_rakefile() の最後にある load_imports() を忘れてた. これは import() API で渡したものを処理するだけだから import() を使わなければ関係ない. でも一応見ておこう.

 class Application
   ...
   def load_imports
     while fn = @pending_imports.shift
       next if @imported.member?(fn)
       Rake::Task[fn].invoke if Rake::Task.task_defined?(fn)
       ext = File.extname(fn)
       loader = @loaders[ext] || @default_loader
       loader.load(fn)
       @imported << fn
     end
   end

なんで Enumerable.each() じゃなくて shift() を使うんだろう. 見えないところで誰かが @pending_imports に副作用を起こすのかもね.

さて, ループの中を見よう. いきなり task を invoke() しているのを見て面喰らうかもしれない. これは import で読み込みたいファイルが無ければ作るということ. 依存関係ファイルの依存関係...というようなやや鶏と卵な気配がある.

@loaders はファイルの拡張子をキーとしたテーブルらしい. 拡張子に応じて import() の振舞いは違うんだね. 何もしないとテーブルは空. @default_loader にはこんなオブジェクトが入っている.

 class DefaultLoader
   def load(fn)
     Kernel.load(File.expand_path(fn))
   end
 end

先の makedepend を使った例を眺める. require している 'rake/loaders/makefile' が怪しい. ./lib/rake/loaders/makefile.rb を見ると MakefileLoader なんてのが定義されている. これが .mf ファイルを解釈するわけか.

module Rake
  ....
  # Install the handler
  Rake.application.add_loader('mf', MakefileLoader.new)

end

rake はこうして "make depend わけわからん問題" (いま命名) を解決しているんだね. なっとく.

SCons には似たような仕組みとして Scanner というのがある. これも pluggable にファイルを解釈し, Makefile (相当) にあらわれない依存関係を解決するのに使われている. やっぱりみんな困ってたんだな. ちょっと安心した.

手頃な ruby 教材としての rake

こうしてみると rake はなかなか良くできてるね. ツールとしてはまだそれほどパワフルではないかも知れないけれど, アイデアは整理されているしコードはめちゃめちゃ読みやすい. なので, ruby の入門書を読み終えて少し書き捨てスクリプトを書いたくらいの人が 暇潰しに読むのとけっこう楽しいんじゃないかと思う.

コードが読みやすい他にも, 読み物や教材に適している理由はいくつかある.

Java の勉強に JUnit を読めということがある. 適度な規模, 適切な設計, 現実に汚れず自己完結で読みやすいコード. ふだん使っているものを理解する楽しさ. rake もそんな教材になりうる. なんてのは手前味噌にも程があります.