なぜmakeのeval,call,defineが欲しくなってしまうのか

なぜmakeのeval,call,defineが欲しくなってしまうのかについてぼーっと考えたりしてた(個人的な興味として、goのgithub.com/magefile/mageでmakeを置き換えられないかな−やっぱり無理かもみたいなものを考える上でmakeについてもう少しまじめに考える必要があった)。

結論から言うとタスク定義を動的にしたくなるからで。タスク定義を動的にしたい理由は、並列動作あるいはキャッシュの制御をmakeに任せる形式にしたいせい。

例えば、以下の様な例題を考えてみる

サブディレクトリ中のReadme.mdのタイトル部分だけを抜き出し表示する

もちろんこの例自体は並列実行する必要もなかったりするものではあるのだけれど。仮にこの処理がそこそこ重めの処理だったと仮定する。

(以下の様にして雑に作っておく)

$ function GEN () { mkdir -p $1 && echo "# $(basename $1)" > $1/readme.md && echo memo >> $1/readme.md; }
$ GEN docs/a
$ GEN docs/b
$ GEN docs/b/x
$ GEN docs/b/y
$ GEN docs/c

$ tree docs
docs
├── a
│   └── readme.md
├── b
│   ├── readme.md
│   ├── x
│   │   └── readme.md
│   └── y
│       └── readme.md
└── c
    └── readme.md

全てをシーケンシャルに実行すれば良い場合

特に何も考えずに書く場合にはforloopで済ませてしまうことが多い。シェルスクリプトを一個作るでも良いのだけれど。他に色々な操作がある場合にそれを1つのファイルでまとめたいというときにMakefileを使っている気がする。

例えば以下のようにMakefileを書く。

default:
  for i in `find . -name readme.md`; do cat $$i | grep -P '^#[^#]'; done

1行で済むし。短い。

Makefile中での $ の表記はmake由来の機能のほうに使われているのでエスケープのために $$ にする必要がある。同様にコマンドの実行結果を元にアレコレするにはバッククォート主体の方がやりやすい(実行時に既に展開されて欲しい場合には $(shell ...) を使う)。

実際に実行されるコマンドを調べるには -n オプションが便利。

$ make -n
for i in `find . -name readme.md`; do cat $i | grep -P '^#[^#]'; done

これを実行してみると以下の様になる。

$ make
for i in `find . -name readme.md`; do cat $i | grep -P '^#[^#]'; done
# c
# a
# x
# y
# b

全てをシーケンシャルに実行すれば良いだけならこれで十分。しかし以下の様な問題がある。

  • 途中でコマンドが失敗しても処理が中断されない
  • 全てのコマンドが全部実行されてしまう(不要なコマンドの呼び出しはスキップしたい)

make経由でキャッシュを効かせたいので手動でタスク定義する

とりあえず、上の条件の内、不要なコマンドの呼び出しをスキップしたいということだけに取り組むとする。要はmakeに xxx is up to date と言わせてタスク自体をスキップさせたい。

暗黙のルールの定義

基本的にmakeは、あるタスクの実行が必要か不要かを判断するためにmtimeを見る(PHONYなどを指定しない限り初めはファイルだと想定して扱う。そうでなかったらただのコマンド(アクション)としてのタスクと判断して実行される)。そんなわけで実行をスキップさせたりしたい場合には何らかのファイルを出力する必要がある。

例えば今回は.titleという拡張子のファイルに出力してみることにする。

.%.title: %.md
  cat $< | grep -P '^#[^#]' | tee $@

makeは暗黙のルールを指定することができ。これはある拡張子のファイルからある拡張子のファイルへの変換のタスクを動的に導出できるようにするための指定。 (上の例は.mdから.titleへの変換)

ファイルの依存関係の定義

暗黙のルールを書いただけではタスクとしては実行できない。まじめに依存関係を書く必要がある(実はここの記述は正確ではない。詳しくは末尾のおまけ参照)。このあたりでだんだんつらくなってくる。今現在の状況をそのまま直書きしてみることにする(冒頭のtreeコマンドの出力を参考に)。

docs/a/.readme.title: docs/a/readme.md
docs/b/.readme.title: docs/b/readme.md
docs/b/.x/readme.title: docs/b/x/readme.md
docs/b/.y/readme.title: docs/b/x/readme.md
docs/c/.readme.title: docs/c/readme.md

このように列挙してあげれば make docs/a/.readme.title でaに対するreadmeのタイトル部分だけを取り出したファイルが生成されるようになる。

$ make docs/a/.readme.title
cat docs/a/readme.md | grep -P '^#[^#]' | tee docs/a/.readme.title
# a

# 2回目はskipされる
$ make docs/a/.readme.title
make: 'docs/a/.readme.title' is up to date.

# 無理やり再実行したい場合には -B をつける
$ make -B docs/a/.readme.title
cat docs/a/readme.md | grep -P '^#[^#]' | tee docs/a/.readme.title
# a

全ての依存を利用したタスクの定義

そして先程定義した全ての依存を利用したタスクを定義してあげる。

default: docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
  cat $^

こうすると、依存されたファイルが更新されたら、それに対応するタスクだけが実行される(出力される成果物としてのファイル(上の例では.title)が再生成される)ということができるようになる。

$ make -B
cat docs/a/readme.md | grep -P '^#[^#]' | tee docs/a/.readme.title
# a
cat docs/b/readme.md | grep -P '^#[^#]' | tee docs/b/.readme.title
# b
cat docs/b/x/readme.md | grep -P '^#[^#]' | tee docs/b/x/.readme.title
# x
cat docs/b/y/readme.md | grep -P '^#[^#]' | tee docs/b/y/.readme.title
# y
cat docs/c/readme.md | grep -P '^#[^#]' | tee docs/c/.readme.title
# c
cat docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
# a
# b
# x
# y
# c

# 再実行
$ make
cat docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
# a
# b
# x
# y
# c

2回めの実行のときには、不要なタスクがスキップされている。

ついでに並行処理もさせたくなったら、-j オプションをつける(本来のタスクは重めのものなので並行で動いてくれると嬉しい)。

$ make -B -j 10
cat docs/a/readme.md | grep -P '^#[^#]' | tee docs/a/.readme.title
cat docs/b/readme.md | grep -P '^#[^#]' | tee docs/b/.readme.title
cat docs/b/x/readme.md | grep -P '^#[^#]' | tee docs/b/x/.readme.title
cat docs/b/y/readme.md | grep -P '^#[^#]' | tee docs/b/y/.readme.title
cat docs/c/readme.md | grep -P '^#[^#]' | tee docs/c/.readme.title
# b
# a
# c
# x
# y
cat docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
# a
# b
# x
# y
# c

ゴミのお掃除もしたくなるのでcleanみたいなタスクも定義しておく。

clean:
  find . -name ".readme.title" | xargs rm -vf
.PHONY: clean

と、まぁ、こんな感じで結構なたいへんなお仕事になる。

そして、新しい依存が増えるたびに設定を追加する必要がある(例えばdocs/e/readme.mdが増えた場合)。

--- 01.mk    2018-06-09 23:04:34.065070983 +0900
+++ Makefile  2018-06-09 23:04:57.320719432 +0900
@@ -1,7 +1,7 @@
 .%.title: %.md
    cat $< | grep -P '^#[^#]' | tee $@
 
-default: docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
+default: docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title docs/e/.readme.title
    cat $^
 
 docs/a/.readme.title: docs/a/readme.md
@@ -9,6 +9,7 @@
 docs/b/.x/readme.title: docs/b/x/readme.md
 docs/b/.y/readme.title: docs/b/x/readme.md
 docs/c/.readme.title: docs/c/readme.md
+docs/e/.readme.title: docs/e/readme.md
 
 clean:
    find . -name ".readme.title" | xargs rm -vf

全部まとめたmakefileは以下の様になる。

.%.title: %.md
  cat $< | grep -P '^#[^#]' | tee $@

default: docs/a/.readme.title docs/b/.readme.title docs/b/x/.readme.title docs/b/y/.readme.title docs/c/.readme.title
  cat $^

docs/a/.readme.title: docs/a/readme.md
docs/b/.readme.title: docs/b/readme.md
docs/b/.x/readme.title: docs/b/x/readme.md
docs/b/.y/readme.title: docs/b/x/readme.md
docs/c/.readme.title: docs/c/readme.md

clean:
  find . -name ".readme.title" | xargs rm -vf
.PHONY: clean

頑張ってタスクの依存関係を定義したことによって、以下の様なことが達成された。

  • キャッシュを効かせる(不要なタスクの実行をスキップ)
  • 並行して実行してくれるようにする
  • (エラーが発生したタイミングで実行を打ち切る)

とは言え、逆に言うと頑張ってタスクの依存関係を定義しなければこの恩恵が受けられないというわけで、ちょっとしたビルドシステムっぽい感じになってしまいなんとなくだるい。

実行される依存関係を動的に定義したい

ある操作のユーザーとしての利便性は、上の例のように手動で定義(静的に定義)することによって満たされたのだけれど。

このある操作の提供者として見た場合に、維持するのがなかなかに面倒でしんどい。これを楽にできないかと考えると結局、タスクの依存関係の動的な導出をしたいということになってしまう。

ここでevalとcallとdefineが出てくる。要はマクロ(プリプロセッサ)のようなものが必要になり一種のメタプログラミング的なものが必要ということになる。

callとdefineの必要性について

callとdefineが必要になるのは雑に今風に解釈するならテンプレートエンジン的なものが欲しくなるため。defineで定義してcallで呼び出す。

evalの必要性について

makeのファイルの文法(の一部)を簡略的に表すと以下の様になる。

<task>: <target> ...
    <action>

ここで単にcallとdefineで文字列置換をするだけだと、定義した文字列がタスク定義として解釈されない。そんなわけで動的にタスクを定義したい場合にはevalが必要になる。

実際例えば以下のようなmakefileを書いてみて確認することができる

define fooT
foo:
   echo $(1) yyyy
endef

$(call fooT,hello)

evalを使わず定義した(つもりになっている)fooを利用しようとしても無いと言われる。

$ make -n foo
make: *** No rule to make target 'echo', needed by 'foo'.  Stop.

evalをつけると大丈夫。

--- 03.mk    2018-06-09 23:25:22.718195260 +0900
+++ Makefile  2018-06-09 23:25:28.098113932 +0900
@@ -3,4 +3,4 @@
    echo $(1) yyyy
 endef
 
-$(call fooT,hello)
+$(eval $(call fooT,hello))

タスクは定義されている。

$ make -n foo
echo hello yyyy

eval,call,defineを使って動的に依存関係を定義

ファイルの依存関係の定義。

SUBDIRS := $(dir $(shell find . -name "readme.md"))

# 依存関係を定義

define genTitleT
$(1).readme.title: $(1)readme.md

endef

$(foreach d,${SUBDIRS},$(eval $(call genTitleT,$(d))))

eval,call,defineを使っている。どのように展開されるかはevalの部分をinfoにしたりすると確認できる(もう少し良い方法が欲しい(lispで言えばmacroexpand的なもの)。-pの結果を見ることでなんとなく確認することはできる)。

動的に定義される依存(展開結果)は以下のようなもの

# 依存関係を定義

./docs/c/.readme.title: ./docs/c/readme.md

./docs/a/.readme.title: ./docs/a/readme.md

./docs/b/x/.readme.title: ./docs/b/x/readme.md

./docs/b/y/.readme.title: ./docs/b/y/readme.md

./docs/b/.readme.title: ./docs/b/readme.md

すべての依存を使うdefaultタスクを定義。

SUBDIRS := $(dir $(shell find . -name "readme.md"))

# 全ての依存を使うタスクを定義
default: $(addsuffix .readme.title,${SUBDIRS})
  cat $^

これでタスクの依存関係に動的に定義することができた。作られたMakefileは以下の通り。

SUBDIRS := $(dir $(shell find . -name "readme.md"))

.%.title: %.md
  cat $< | grep -P '^#[^#]' | tee $@

# 全ての依存を使うタスクを定義
default: $(addsuffix .readme.title,${SUBDIRS})
  cat $^

# 依存関係を定義
define genTitleT
$(1).readme.title: $(1)readme.md

endef

$(foreach d,${SUBDIRS},$(eval $(call genTitleT,$(d))))

clean:
  find . -name ".readme.title" | xargs rm -vf
.PHONY: clean

それにしてもこのmakefileはすぐに読めるものなんだろうか。という疑問は常に残る。

どこにでもあるのでmakeを使っているけれど。良い代替があれば移行したい。

そういう意味では第一言語が決まっている組織というのは便利で良い(例えばほぼrubyだけならrakeを仮定して良いみたいな話)。goのmageについても同様の理屈が言えてしまい、他の言語の島の人にもmageを使ってとは言えないし。。。結局消極的にmakeを。。。という感じになってしまう。

(そしてmageには動的にタスクを定義する機能がないような気がする)

おまけ(暗黙のルールだけで済むなら依存関係の定義は不要の場合も)

実は今回に限っては暗黙のルールが定義されているので依存部分を書く必要がなかったりする。

SUBDIRS := $(dir $(shell find . -name "readme.md"))

.%.title: %.md
  cat $< | grep -P '^#[^#]' | tee $@

# 全ての依存を使うタスクを定義
default: $(addsuffix .readme.title,${SUBDIRS})
  cat $^

clean:
  find . -name ".readme.title" | xargs rm -vf
.PHONY: clean

というのも、欲しい .title が決まれば、どういう readme.md が必要かは定まるので。