ふーん……。
ほな、便利コマンドメモとして使う時に一捻り加えるために、黒魔術のさわりのさの字だけ書いとこかな……。
レベル0:疑似ターゲットを使う
疑似ターゲットを使って、例えばmake initと実行すると何らかの初期化処理を実行する、というのは分かりやすいし、一見して便利そうである。
だけど、単に「疑似ターゲットに記述した処理を実行する」だけなら、例えば「シェルスクリプトでcase "$1" in」しても良いのではないか(ちょっと面倒だけど)、という話もある。
case "$1" in help) usage 0 ;; init) do_init ;; task1) do_task_1 ;; task2) do_task_2 ;; task3) do_task_3 ;; *) usage 1 ;; esac
make task1 task2 task3みたいな挙動も、シェルスクリプトでforとcaseを組み合わせて実現できるからなあ……実現は簡単だけどちょっとだけ面倒、というレベルで。
for i; do case "$i" in help) usage 0 ;; init) do_init ;; task1) do_task_1 ;; task2) do_task_2 ;; task3) do_task_3 ;; *) usage 1 ;; esac done
レベル1:疑似ターゲット間の依存関係を指定する
make(1)には「『依存関係』指向のルール記述言語」みたいな側面がある。これを利用して、例えば「疑似ターゲットinitに初期化処理をまとめておいて、他の疑似ターゲットの依存関係に指定しておく」という記述してみよう:
.PHONY: all init task1 task2 task3 all: task1 task2 task3 # all tasks done. init: # initialize for tasks. task1: init # exec task 1. task2: init # exec task 2. task3: init # exec task 3.
ここで興味深いことに、例えばmake allと実行した時に、各タスクの依存関係にinitが指定されているにも関わらず、initの初期化処理は1回しか実行されない。
$ make all # initialize for tasks. # exec task 1. # exec task 2. # exec task 3. # all tasks done. $ _
このような振る舞いをシェルスクリプトあたりで実現するのは面倒である。多分、有向グラフのトポロジカルソートとかを使う必要があるんじゃないかなあ、知らんけど。素直にRakefileやTaskfileなどの依存関係をサポートした専用ツールを使うべきだろう。
各タスクごとに毎回初期化処理を実行したいなら、初期化処理をシェルスクリプト等に独立させて、素直にタスク実行前に呼び出せばよい。GNU Makeなら、シェルスクリプト等の代わりにdefineディレクティブを使ってもよいかもしれない。
レベル2:ファイル間の依存関係を指定する
さて、make(1)の「依存関係」指向な側面は疑似ターゲットにおいても有用であるのだが、疑似ではないターゲット――ファイルに適用することで、より効率的なタスクランナーを実現できるようになる。
そもそもmake(1)は「コマンドにファイルAを入力して、出力としてファイルBを得る」というタスクの自動化ツールである。
make(1)がC/C++のビルドで用いられるのは、まさに「コンパイラにソースファイルを入力して、出力としてオブジェクトファイルを得る」というタスクの集合だからだ。とはいえGNU Makeの中をのぞき見すると、C/C++以外のプログラミング言語(Fortran・Modula-2・Pascal・アセンブラなど)のビルド、LexやYaccによるC言語のソースファイルの生成、Texinfoでのドキュメントの生成――など組み込みルールが含まれている。単にプログラムのビルドに留まらない、意外と間口が広いツールなのである。
ファイル間の依存関係において興味深いのは、ファイルの更新時刻を比較してタスクを実行するか否かを決定することだ。入力ファイルAの内容によって一意に出力ファイルBの内容が決定されると仮定するならば、一度ファイルAからファイルBを生成した後、ファイルAの内容が更新されない限りファイルBを生成し直す必要はない。このような挙動をリーズナブルに実現するために、make(1)は依存関係にあるファイルの更新時刻を比較する。もしもファイルAの内容が更新されたならば、ファイルAの更新時刻はファイルBよりも新しくなっているはずだ、という考え方だ。この方法は万能ではないし穴もあるが、それでも大抵のシチュエーションではうまく機能する。
例えばGoogleTestを使っていて、「CMakeを使ってMakefileを生成 → 生成されたMakefileを使って単体テスト用のプログラムをビルド」という作業を毎回コマンド実行するのが面倒なので整理してみよう:
WORKDIR ?= work cmakelists := ./CMakeLists.txt makefile := $(WORKDIR)/Makefile do := cd '$(WORKDIR)' && .PHONY: build clean configure distclean run build: $(makefile) $(do) make clean: $(do) make clean configure: $(makefile) distclean: clean $(RM) '$(WORKDIR)' run: build - '$(WORKDIR)/unittest' $(makefile): $(cmakelists) [ -d '$(WORKDIR)' ] || mkdir -p '$(WORKDIR)' $(do) cmake ..
CMakeはCMakeLists.txtを元にプロジェクトファイル(ここではMakefile)を生成する。一度プロジェクトファイルを生成したら、次にCMakeLists.txtが更新されるまでの間はプロジェクトファイルの再生成は不要だ。で、CMakeLists.txtが更新されたら、CMakeを実行してから単体テスト用プログラムをビルドし直す必要がある。
――ということを依存関係を用いて解決している。make runでプロジェクトファイルの生成から単体テスト用プログラムの実行までワンストップで実行するが、プロジェクトファイルの生成は必要な時にしか実行されない。
ここではUnix環境を想定しているため、プロジェクトファイルはMakefileであるが、Visual Studioのソリューションファイルを生成するケースでも同じようなことを実現できる。
ここで挙げた例のようなアプローチは、自動化したいタスクを注意深く分析することで、意外と適用できそうなシチュエーションを見つけられるものだ。個人的な話となるが、過去に依存関係を用いて「Dockerfileが更新されたらDockerイメージを作成し直す」というタスクを実現したことがある。まあDockerイメージそのものをファイルとして参照できなかったのでtouch(1)とダミーファイルを使ったのだが……。
レベル3:パターンルール/サフィックスルールを追加する
make(1)にはパターンルール(GNU Makeだとサフィックスルールも)が組み込まれている。これによって拡張子だけ異なるファイル間の変換(例えばfoo.cからfoo.oへの変換)を毎回Makefileに記述しなくても実行できるようになっている。
パターンルールやサフィックスルールは、よくあるパターンはmake(1)に組み込まれている(だからこそ、C/C++アプリのビルド時にMakefileに記述する内容を簡素化できるのだが)。この組み込みルールは、ユーザ側で一時的に上書きすることが可能だ。また、新たなルールを追加することもできる。
例えば、GNU Make向けに、以下の内容だけ記述したMakefileを用意してみよう:
%.html: %.md # pandoc -f markdown -t html -o $@ $<
以下のような風に遊ぶことができる。
$ ls Makefile foo.md $ cat Makefile %.html: %.md # pandoc -f markdown -t html -o $@ $< $ make foo.html # pandoc -f markdown -t html -o foo.html foo.md $ _
コメントアウトを止めてpandoc -f markdown -t html -o $@ $<にすると、pandoc(1)を利用可能な環境ではMarkdownからHTMLへの変換が可能となる。
まとめ
Makefileをタスクランナーや便利コマンドメモとして使うならば、最低でもレベル1に到達しないと「make(1)を選んだ旨味」が無いよねって思ってしまう。
まあ、実のところ後発のツールでもレベル2までは同じことを実現できるはず(レベル3については調べてないので分からない。Rakefileでは可能なはず)。
可読性その他に優れた後発ツールがあるにも関わらず、それでもMakefileを使うのは、単に「大抵の開発者の環境にmake(1)がインストールされているから」だ。
その、自分と他の開発者とで、開発環境の同質性が高い環境ならば、例えばTaskfileを使っても何の問題もないはずなのだ。何ならみんなbrew(1)を使える環境だからbrew install go-taskでインストールできるよね――といった具合。Rakefileだって、みんなRailsで開発してるからgem install rakeできるはずだよねって。
でも世の中そんな場所だけではない訳で、私なんて左右の席の開発者とは開発環境が結構異なるし、何なら左の席と開発者と右の席の開発者の間でも相応の差異がある。そもそも開発しているソフトウェアのターゲット環境が全く異なるのだ。
このような、開発環境の同質性に疑義が生じる環境においては、自分以外の開発者のPCにもデフォルトで入っていそうなツールは「ベストではないがベター」な選択肢となる。
まあでもLinuxやmacOSならGNU Makeを前提としても問題ないけど、Windowsだと「Visual StudioかQt Creatorが入ってるだろうから、NMAKE向けの文法で書こうか」となるのが辛いところだ。流石にMicrosoftが公式にGNU Makeを移植してくれるとは思えない……どちらかと言えば「NMAKEは止めてDevenvかMSBuildを使え」ってスタンスだよなあ。