ククログ

株式会社クリアコード > ククログ > シェルスクリプトとMakefileの使い分け

シェルスクリプトとMakefileの使い分け

先日紹介したシェルスクリプトで「ビルドスクリプト」を作る時に便利なテクニックへのコメントとして「なぜMakefileでやらないのか」「Makefileの方がいいのではないか」といったものがありました。確かにmakeはメジャーなビルドツールなので、そのような疑問が出てくるのも当然でしょう。

なぜシェルスクリプトなのかということの理由はいくつかあります。

1つは、先のエントリの題材としたスクリプトが元々はWindows用のバッチファイルをLinuxのシェルスクリプトに移植したものだったからという理由です。Windowsのバッチファイルのベタ移植として作成したシェルスクリプトを継続的にメンテナンスしてきた間の改良の結果として、いくつかのテクニックが盛り込まれるようになったため、そのテクニックにスポットを当てて紹介しようというのが、先のエントリの発端でした。

もう1つは、シェルスクリプトは「シェルのコマンドを列挙したビルドスクリプト」を作る上では最適な手段だからという理由です。「シェルのコマンドを列挙したビルドスクリプト」の場合は、必ずしもMakefileだけで完結させるのが最良とはいえません。以下、先のエントリの補足も兼ねて、こちらの点について詳しく説明します。

Makefileとは

シェルスクリプトとMakefileの違いについて述べる前に、まずMakefileについて簡単に説明します。

アプリケーションを使える状態にするには、実行用のファイルをビルドする必要があったり、あるファイルをビルドするにあたって依存する別のファイルを先にビルドしておかなくてはならなかったりと、色々と気をつかわなくてはならないことがあります。そのため、個々のファイルのビルド手順と、ファイル同士の依存関係を整理して、「依存するすべてのファイルを自動的にビルドする」「一部の構成ファイルに変更があった場合はそれに依存するファイルだけを再ビルドする」といった事を自動的に行えるようにする仕組みが必要とされてきました。その代表的なツールがmakeであり、make用の設定ファイルがMakefileです。

Makefileの最も単純な使い方では、以下のような書式でファイル同士の依存関係と各ファイルのビルド方法を記述します。

# source makefile
ビルド対象のファイル: 依存するファイル
	ビルドするためのコマンド列1
	ビルドするためのコマンド列2
	...

コマンド列を記述する行は、行頭にタブ文字を置いてインデントします。

例えば「ZIP形式のアーカイブであるmyaddon.jarというファイルをビルドする必要があり、content、locale、skinという3つのディレクトリが必要である」という時には、以下のように書きます1

# source makefile
myaddon.jar: content locale skin
	zip -q -r -9 myaddon.jar content locale skin

また、Makefileの中では以下のようにして「マクロ」を定義しておくこともできます。

# source makefile
PACKAGE_NAME = myaddon

マクロとは、特定の文字列に名前を付けて、同じ事を何度も書かなくてもその名前を書くだけで参照できるようにする仕組みです。定義したマクロは、以下のようにして自由に参照できます。

# source makefile
JAR_TARGET_FILES = content locale skin
PACKAGE_NAME = myaddon

$(PACKAGE_NAME).jar: $(JAR_TARGET_FILES)
	zip -q -r -9 $(PACKAGE_NAME).jar $(JAR_TARGET_FILES)

以上を踏まえて、Firefox用のアドオンのインストーラパッケージを作成するMakefileの具体例を以下に示します。ここでは、PACKAGE_NAMEPACKAGE_VERSIONJAR_TARGET_FILES、およびXPI_TARGET_FILESの4つのマクロと、xpi、および$(PACKAGE_NAME).jarの2つのビルド対象ファイル(これを「ターゲット」と呼びます)を記述しています。ビルド手順としてのコマンド列は、ファイルをコピーしたり、ディレクトリを用意したりした上で、ZIP形式で圧縮するだけという単純なものです。

# source makefile
PACKAGE_NAME = myaddon
JAR_TARGET_FILES = \
	content \
	locale \
	skin
XPI_TARGET_FILES = \
	install.rdf \
	components/*.js \
	components/*.xpt \
	chrome \
	defaults \
	modules \
	chrome.manifest

xpi: $(PACKAGE_NAME).jar
	rm -rf chrome
	mkdir -p chrome
	cp $(PACKAGE_NAME).jar chrome/
	rm -f $(PACKAGE_NAME).xpi
	zip -q -r -9 $(PACKAGE_NAME).xpi $(XPI_TARGET_FILES)

$(PACKAGE_NAME).jar: $(JAR_TARGET_FILES)
	rm -f $(PACKAGE_NAME).jar
	zip -q -r -9 $(PACKAGE_NAME).jar $(JAR_TARGET_FILES)

ターゲット「xpi」は、配布用のパッケージを作ることが目的のコマンドに名前を付けた便宜的なターゲットです。実際のファイル名とは結びついていません。

シェルスクリプトとMakefileの違い

シェルスクリプトはシェルで実行するコマンドを列挙したものです。対するMakefileは、ターゲットごとにビルド手順のコマンドを列挙したものです。シェルのコマンドを列挙するものであるという点で両者はよく似ていますが、いくつか違うところもあります。

シェルの特殊な機能を使う際に注意が必要

Makefileの場合、個々のコマンド列は直接シェルによって実行されるのではなく、一旦makeによって解釈されるという点に気をつけなくてはいけません。

上記のMakefileの例において、$(PACKAGE_NAME)と書いている点に注目して下さい。シェルで変数を参照する場合は${変数名}と書きます。$(...)はコマンド置換の書き方です。前述した通り、このコマンド列はまずmakeによって解釈され、その上でシェルのコマンドとして実行されます。よって、$(PACKAGE_NAME)という記述はシェルのコマンド置換ではなくMakefileでのマクロの参照として処理され、シェルにはその結果が渡されます。

では、シェルのコマンド置換を使いたい場合はどうなるでしょうか。例えば、バージョン番号をversion.txtというファイルで管理していて、そのファイルの内のにあるバージョン番号を生成するファイル名の一部に使いたいという場合です。単純に考えると、例えば以下のようになるでしょう。

# source makefile
xpi: $(PACKAGE_NAME).jar
	rm -rf chrome
	mkdir -p chrome
	cp $(PACKAGE_NAME).jar chrome/
	rm -f $(PACKAGE_NAME)-*.xpi # ファイル名が不定になるため
	zip -q -r -9 $(PACKAGE_NAME)-$(cat version.txt).xpi $(XPI_TARGET_FILES)

ところが、これでは期待通りの結果が得られません。コマンド置換のつもりで書いた箇所が、makeによって先にマクロとして展開されてしまうため、シェルに渡されるコマンド列は以下のようになってしまうからです2

zip -q -r -9 myaddon-.xpi content locale skin

こうならないようにするためには、makeにマクロの参照として認識させたくない$をエスケープする必要があります。$$と書くと、マクロ参照の指示ではない文字として$を記述できます。

# source makefile
	zip -q -r -9 $(PACKAGE_NAME)-$$(cat version.txt).xpi $(XPI_TARGET_FILES)

makeによってマクロが展開された後、実際にシェルに渡されるコマンド列は以下のようになります。

zip -q -r -9 myaddon-$(cat version.txt).xpi content locale skin

これでやっと期待通りの結果を得られます。

なお、ここではコマンド置換の例を示しましたが、forでループする処理を書く必要がある場合など通常のシェル変数を使う時にも、シェル変数を参照するための$は同様に$$とエスケープしなくてはなりません。

ここでのポイントは、「Makefileに複雑なコマンド列を書くとエスケープが大変」ということです。$をエスケープし忘れて期待しない挙動になることがあります。

個々のコマンド列は別々のシェルで実行

Makefileでは1つのターゲットについて2行以上のビルド用コマンドを記述できますが、それぞれの行は別々のプロセスのシェルで実行されるという事に注意しなくてはいけません。

例えば、先のMakefileの例について、ソースコードを直接ZIPファイルに圧縮するのではなく、一旦作業ディレクトリにコピーして、コメント行を削除してからZIPファイルに圧縮する、ということをしたくなったとしましょう。単純に考えると、以下のように書きたくなるところです。

# source makefile
$(PACKAGE_NAME).jar: $(JAR_TARGET_FILES)
	rm -f $(PACKAGE_NAME).jar
	rm -rf jar_temp
	mkdir -p jar_temp
	cp -r $(JAR_TARGET_FILES) jar_temp/
	cd jar_temp
	find -name *.js | xargs sed -i -r -e "s#^\s*//.*##"
	zip -q -r -9 ../$(PACKAGE_NAME).jar $(JAR_TARGET_FILES) -x \*.git/\*
	cd ..

ですが、これは期待通りに動作しません。各行のコマンドは別々のプロセスのシェルで実行されるため、cdでのカレントディレクトリの移動のようにそのプロセス内でのみ効果があるコマンドは、効果が各行でリセットされてしまいます。そのため、これでは「トップレベルのディレクトリからテンポラリディレクトリにcdした後、そこでfindコマンドを実行する」ではなく、「cdした後すぐにシェルを終了する。次に、トップレベルのディレクトリでfindコマンドを実行する」ということになってしまい、変更されて欲しくないファイルにまで変更が及んでしまいます。

よって、このような場合は一連のコマンドとして実行されて欲しい内容を1行にまとめて記述する必要があります。

# source makefile
$(PACKAGE_NAME).jar: $(JAR_TARGET_FILES)
	rm -f $(PACKAGE_NAME).jar
	rm -rf jar_temp
	mkdir -p jar_temp
	cp -r $(JAR_TARGET_FILES) jar_temp/
	cd jar_temp && find -name *.js | xargs sed -i -r -e "s#^\s*//.*##" && zip -q -r -9 ../$(PACKAGE_NAME).jar $(JAR_TARGET_FILES) -x \*.git/\*

ただ、これでは1行が長すぎるので、このような場合には改行をエスケープして見た目上折り返すのが一般的です。

# source makefile
$(PACKAGE_NAME).jar: $(JAR_TARGET_FILES)
	rm -f $(PACKAGE_NAME).jar
	rm -rf jar_temp
	mkdir -p jar_temp
	cp -r $(JAR_TARGET_FILES) jar_temp/
	cd jar_temp && \
	  find -name *.js | xargs sed -i -r -e "s#^\s*//.*##" && \
	  zip -q -r -9 ../$(PACKAGE_NAME).jar $(JAR_TARGET_FILES) -x \*.git/\*

ここでのポイントは、「コマンド列間での情報共有が大変」ということです。カレントディレクトリも変わりませんし、変数を定義しても伝わりません。共有したい場合は;&&などを組み合わせて1行のコマンド列として実行しましょう。一見、欠点のような書き方になっていますが、どのコマンドもキレイな状態で動くので他のコマンドの影響を受けにくいという利点でもあります。

その他、気をつけるべきこと

ビルド用のコマンド列を記述する行は必ずタブ文字でインデントする必要がある、というのも地味ですが重要な点です。Webページ上に記載されたスクリプトをコピー&ペーストすると、タブ文字が連続する半角スペースに変換されてしまうことがあり、そのままMakefileとして使用した際にエラーになってしまいます。

シェルスクリプトではset -eしないとコマンドが失敗しても実行を継続しますが、Makefileではそこで実行が中断します。Makefileでは、失敗してもよいコマンドの場合は最後に|| trueをつけて以下のように書きます。

# source makefile
	zip -r ../$(PACKAGE_NAME).jar $(JAR_TARGET_FILES) || true

シェルスクリプトとMakefileはどちらを使えばよいか

以上のように、いくつかの点に気をつければMakefileでも複雑な処理をこなすことはできます。しかしながら、シェルスクリプトとは別の決まりがたくさんあって、それに気をつかいながらコマンドを書いていくということは、ミスを誘発しやすく、また、デバッグもしにくくなります。

シェルの複雑なコマンドとMakefileの単純なルールを組み合わせたために分かりにくくなってしまうのであれば、Makefileの機能をフルに活用すればよいのではないか、ということはいえます。実際に、Makefileには前述した単純なルール以外にも非常に多くの機能があり、シェルで複雑なコマンドを書かなくてもMakefileの機能で代用できる場合もあります。たとえば、FreeBSDのパッケージ管理システムであるFreeBSD Portsはmakeベースのシステムです。

ただ、makeで実現できる高度な機能の多くはmakeの実装毎にMakefileの書き方が異なるため、汎用的に使えるMakefileとするためには注意が必要です。Makefileを生成する仕組みがたくさんあるのはこのためです。GNUのビルドシステム3もそうですし、imakeやCMake4などもそうです。

そのため、Makefileだけで頑張るよりは、他のツールを使ったり、他のツールと一緒に活用するのが現実的です。そのときに使えるツールの1つが、makeでも使っているシェルです。先のエントリでは「簡単なビルドスクリプトであればシェルスクリプトで……」といった前置きを書いていましたが、むしろ、Makefileの書き方自体に明るくないのであれば、複雑な処理が必要なビルドスクリプトであればあるほど、シェルスクリプトとして記述した方が簡単です。

Makefileの効果的な利用

ただ、Makefileには、ファイルの依存関係からビルドの順番を自動的に解決するという、シェルスクリプトにはない非常に便利な機能があります。依存関係の解決にだけ着目すれば、覚えなければならないことはそれほどないため、習得はそれほど難しくないでしょう。

よって、以下のように使い分けるのが、シェルスクリプトとMakefileのよいところを互いに引き出しあえて、作るのも管理するのも効率がよくなります。

  • Makefileではビルド対象のファイル同士の依存関係を管理する。
  • ファイルのビルド手順が複雑な場合、シェルスクリプトでビルド手順を記述してしまい、Makefileからは単にそのシェルスクリプトを呼ぶようにする。

また、make以外にもRakeOMakeなど依存関係を解決してくれるツールがあります。makeだけにこだわらず、自分が実現したいことに適したツールを選択するとよいでしょう。

  1. 依存するファイルとしてファイルではなくディレクトリも指定できます。ディレクトリ直下のファイルの変更は検出できますが、直下ではなく深い位置のファイルの変更は検出できないので注意してください。個々のファイルを指定する方が安全です。

  2. 今はcat version.txtというマクロが定義されていないため空文字列に置き換えられている。

  3. GNU Autoconf/GNU Automake/GNU Libtool

  4. CMakeはMakefile以外にもVisual Studioのslnファイルなども生成できる。