YARDというRuby用のドキュメンテーションツールがあります。この記事ではCで書かれたRubyのライブラリにYARD用のドキュメントを書く方法を紹介します。
YARDはソースコード中にドキュメントを埋め込むタイプのドキュメンテーションツールです。ドキュメントはコメントとして書きます。ドキュメントに@タグ名
という記法でメタデータを書けることが特徴*1です。YARDに添付されているyardoc
というコマンドを使うことで、ソースコード中に書いたドキュメントからHTMLのリファレンスマニュアルを作成することができます。
Ruby*2はライブラリをRubyでもCでも書けます*3。Cでライブラリを書くと、処理を高速化したり、既存のC/C++で書かれたライブラリをRubyから使えるようにできます。例えば、rroonga*4はC/C++で書かれた全文検索エンジンライブラリgroongaをRubyから使えるようにするライブラリです。
YARDはRubyで書いたライブラリもCで書いたライブラリもサポートしています*5。Cで書いたライブラリにYARD用のドキュメントを書くには少しコツがいります。ここでは、例をつけながら、Cで書いたライブラリにYARD用のドキュメントを書く方法を紹介します。具体的には次の5つについて説明します。
Hash
に指定できるキーの説明を書く方法まず、例として使うCのコードを示します。このコードは、rroongaで実際に使われているコードの一部です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
static VALUE rb_grn_database_defrag (int argc, VALUE *argv, VALUE self) { grn_ctx *context; grn_obj *database; int n_segments; VALUE options, rb_threshold; int threshold = 0; rb_scan_args(argc, argv, "01", &options); rb_grn_scan_options(options, "threshold", &rb_threshold, NULL); if (!NIL_P(rb_threshold)) { threshold = NUM2INT(rb_threshold); } rb_grn_database_deconstruct(SELF(self), &database, &context, NULL, NULL, NULL, NULL); n_segments = grn_obj_defrag(context, database, threshold); rb_grn_context_check(context, self); return INT2NUM(n_segments); } void Init_database () { VALUE mGrn; mGrn = rb_define_module("Groonga"); rb_cGrnDatabase = rb_define_class_under(mGrn, "Database", rb_cObject); rb_define_method(rb_cGrnDatabase, "defrag", rb_grn_database_defrag, -1); } |
このコードで何をしているかを簡単に説明します。
このコードでは次の2つの関数を定義しています。
rb_grn_database_defrag()
関数Init_database()
関数1つ目のrb_grn_database_defrag()
関数は、Groonga::Database
オブジェクトのdefrag
メソッドの実体です。defrag
メソッドを呼ぶと、この関数が実行されます。
2つ目のInit_database()
関数は、Groonga::Database
オブジェクトのdefrag
メソッドとrb_grn_database_defrag()
関数を結びつけています。初期化をしている関数です。
それでは、このCのコードにYARD用のドキュメントを書きながら、冒頭で挙げた次の5つについて説明します。
Hash
に指定できるキーの説明を書く方法なお、この記事ではどこにどうタグ*6を書くかに焦点を当てているため、個別のタグに対する詳細な説明は省いています。タグの詳細についてはYARDのドキュメント(英語)を参照してください。
YARD用のドキュメントはコメント内に書きます。ドキュメント用のコメントはメソッドの実体となる関数の直前に書きます。関数の直前に書くと、関数定義とメソッドのドキュメントが結びつきます。結びつけられるとYARDが生成するHTMLのリファレンスマニュアルでは「View source」のリンク先に関数定義が表示されます。
例を示します。以下のコード内の「ここにYARD用のドキュメントを書く」と書かれた部分にドキュメントを書きます。こうすることにより、これから書くdefrag
メソッドのドキュメントとrb_grn_database_defrag()
関数の定義が結びつきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
/* * ここにYARD用のドキュメントを書く */ static VALUE rb_grn_database_defrag (int argc, VALUE *argv, VALUE self) { grn_ctx *context; grn_obj *database; int n_segments; VALUE options, rb_threshold; int threshold = 0; rb_scan_args(argc, argv, "01", &options); rb_grn_scan_options(options, "threshold", &rb_threshold, NULL); if (!NIL_P(rb_threshold)) { threshold = NUM2INT(rb_threshold); } rb_grn_database_deconstruct(SELF(self), &database, &context, NULL, NULL, NULL, NULL); n_segments = grn_obj_defrag(context, database, threshold); rb_grn_context_check(context, self); return INT2NUM(n_segments); } void Init_database () { VALUE mGrn; mGrn = rb_define_module("Groonga"); rb_cGrnDatabase = rb_define_class_under(mGrn, "Database", rb_cObject); rb_define_method(rb_cGrnDatabase, "defrag", rb_grn_database_defrag, -1); } |
なお、これ以降、例にはrb_grn_database_defrag()
関数の定義部分とドキュメントのみを載せます。それ以外の部分はYARD用のドキュメントとは関係ないため省略します。
それではメソッドのドキュメントを書いていきます。
メソッド定義の直前のコメントにタグ*7を使わずにドキュメントを書くと、YARDはその文章をメソッドの説明として扱います。メソッドの説明にはどのような処理をするメソッドかということを書きます。
defrag
メソッドの場合は以下のようになります。
1 2 3 4 5 6 7 8 |
/* * Defrags all variable size columns in the database. */ static VALUE rb_grn_database_defrag (int argc, VALUE *argv, VALUE self) { /* ... */ } |
このコードをexample.cというファイルに保存し、yardoc
コマンドを実行するとHTMLのリファレンスマニュアルを生成できます。
% yardoc example.c
リファレンスマニュアルには以下のようにコメントに書いたドキュメントがメソッドの説明として表示されています。
メソッドの説明は通常の文章として書きました。
メソッドの説明の後は、メソッドの引数についての説明を書きます。メソッドの引数の説明はタグを使って書きます。タグとはメタデータを指定するためのYARDの機能です。YARDではメソッドの引数をメタデータとして扱うため、統一感のある読みやすいリファレンスマニュアルを生成することができます*8。
引数の説明には@param
タグを使います。@param
タグの書式は以下の通りです。
@param [引数のクラス] 引数名 引数の説明
では、実際に@param
タグで引数の説明を書きましょう。@param
タグを書く位置はメソッドの説明の下がよいでしょう。HTMLのリファレンスマニュアル上では順序は関係ありませんが、コード中のドキュメントを読む場合に読みやすくなります。この順序にすると、メソッド全体の説明を読み、次に引数の説明に入る、という順序になります。メソッドの全体像を把握してから細部を読めるのでドキュメントを理解しやすくなります。
1 2 3 4 5 6 7 8 9 10 |
/* * Defrags all variable size columns in the database. * * @param [Hash] options custom options. */ static VALUE rb_grn_database_defrag (int argc, VALUE *argv, VALUE self) { /* ... */ } |
「引数の説明」の「custom options.」の最後には常に「.」を付けておいたほうがよいでしょう。説明が「custom options. Optional」と2文以上になった場合に最後の文だけ「.」がついていないと、もやっとするからです。
例のdefrag
メソッドのシグニチャー*9はRubyで書くと以下の通りです。
1 2 3 |
def defrag(options={}) # ... end |
options
引数はHash
なので、@param
タグで[Hash]
と書いてその情報を伝えています*10。
ここまでで書いたドキュメントからyardoc
コマンドでHTMLのリファレンスマニュアルを生成すると次のようになります。
リファレンスマニュアルに引数の説明が追加されています。引数の説明を追加するために@param
タグを使いました。
戻り値の説明には@return
タグを使います。@return
タグの書式は以下の通りです。
@return [戻り値] 戻り値の説明
では、実際に@return
タグで戻り値の説明を書きましょう。@return
タグを書く位置は@param
タグの下がよいでしょう。入力を読んでから出力を確認する、という順序で読めます。
1 2 3 4 5 6 7 8 9 10 11 |
/* * Defrags all variable size columns in the database. * * @param [Hash] options custom options. * @return [Integer] the number of defraged segments. */ static VALUE rb_grn_database_defrag (int argc, VALUE *argv, VALUE self) { /* ... */ } |
例のdefrag
メソッドは戻り値として整数を返すので、@return
タグで[Integer]
と書いてその情報を伝えています。
ここまでで書いたドキュメントからyardoc
コマンドでHTMLのリファレンスマニュアルを生成すると次のようになります。
リファレンスマニュアルに戻り値の説明が追加されています。戻り値の説明を追加するために@return
タグを使いました。
Hash
に指定できるキーの説明を書く方法最後に、Hash
を引数として受け取るメソッドのドキュメントを書きます。Rubyでは、Pythonのキーワード引数相当のことを実現するために、引数をHash
として受け取り、メソッド内で必要な値を取り出します。このようなメソッドを使う側は、Hash
に指定できるキーと、その値が何を意味するのかが気になります。これをドキュメントに書いておくことで、有用なドキュメントになります。
Hash
にどんなキーを指定できるのかというドキュメントを書くには、次の3つのタグを使います。
@overload
タグ@param
タグ@option
タグまず、@overload
タグを使ってメソッドのシグニチャーを指定します。@overload
タグの書式は以下の通りです。
@overload メソッド名(メソッドの引数)
なお、@overload
タグは引数にHash
を指定しない場合でも常に指定することをオススメします。Rubyで書かれたメソッドは@overload
タグを書かなくても引数名などの引数の情報がつきますが、Cで書かれたメソッドには@overload
タグを書かないと引数の情報がつかないからです*11。
では、実際に@overload
タグでメソッドのシグニチャーを書きましょう。@overload
タグを書く位置は@param
タグの上がよいでしょう。まず、引数全体を確認してから個々の引数を確認する、という順序で読めます。
1 2 3 4 5 6 7 8 9 10 11 12 |
/* * Defrags all variable size columns in the database. * * @overload defrag(options={}) * @param [Hash] options custom options. * @return [Integer] the number of defraged segments. */ static VALUE rb_grn_database_defrag (int argc, VALUE *argv, VALUE self) { /* ... */ } |
ここまでで書いたドキュメントからyardoc
コマンドでHTMLのリファレンスマニュアルを生成すると次のようになります。
リファレンスマニュアル内の、「Instance Method Summary」と「Instance Method Details」にあるdefrag
メソッド名のところに、「(options = {})
」が追加されています。
次に、@param
タグでHash
で指定するオプション全体についての説明を書きます。@param
タグの説明では「Hash
でオプションを渡すことができる」ということを説明するのがよいでしょう。
@param
タグの書き方で注意するポイントは、必ず@overload
タグよりも下に書き、さらにその@overload
タグよりもインデントして書かなければいけないという点です。インデントして書くことで、YARDがその@param
タグは@overload
タグで書いたシグニチャーに対応していると認識します。
実は@overload
タグを複数指定することにより複数のシグニチャーを指定することができます。もし、@param
タグが@overload
タグと同じインデントレベルにある場合は「すべての@overload
タグで共有される@param
タグ」と認識されます。多くの場合はそれぞれのシグニチャー毎に引数の説明は異なるため、@overload
タグ毎に@param
タグが認識される書き方の方が適切です。
例ではすでに@param
タグが書かれていますが、@overload
タグと同じインデントレベルになっています。そのため、@overload
タグよりもインデントして@param
タグを書くように修正します。同様に@return
タグもインデントします。インデントする理由は@param
タグと同じです。
1 2 3 4 5 6 7 8 9 10 11 12 |
/* * Defrags all variable size columns in the database. * * @overload defrag(options={}) * @param [Hash] options custom options. * @return [Integer] the number of defraged segments. */ static VALUE rb_grn_database_defrag (int argc, VALUE *argv, VALUE self) { /* ... */ } |
これで、@param
タグと@return
タグが@overload
タグで書いたシグニチャーに対応しているとYARDが認識するようになります。
ここまでで書いたドキュメントからyardoc
コマンドでHTMLのリファレンスマニュアルを生成すると次のようになります。
@return
タグに書いた戻り値の説明が、メソッドの説明の後ろに追加されています。@param
タグに書いた引数の説明は追加されていませんが、これは、@overload
タグが1つしかないため、1つの@overload
タグで使われているかすべての@overload
タグで共有されているかの見分けがつかないためです。*12
@overload
タグで書いたシグニチャーに@param
タグと@return
タグが対応しているとYARDに認識させるために、@param
タグと@return
タグをインデントしました。
最後に、@option
タグでHash
に指定できるキーとその説明について書きます。@option
タグの書式は以下の通りです。
@option Hash引数の名前 Hashのキー名 (デフォルト値) 値の説明
ここでの「デフォルト値」とは、Hash
にキーを指定しなかったときに、そのキーに対応する値として使用される値のことです。
@option
タグは@param
タグの下に同じインデントで書きます。
では、実際に@option
タグを書きましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/* * Defrags all variable size columns in the database. * * @overload defrag(options={}) * @param [Hash] options custom options. * @option options [Integer] :threshold (0) the threshold to * determine whether a segment is defraged. Available * values are -4..22. -4 means all segments are defraged. * 22 means no segment is defraged. * @return [Integer] the number of defraged segments */ static VALUE rb_grn_database_defrag (int argc, VALUE *argv, VALUE self) { /* ... */ } |
ここまでで書いたドキュメントからyardoc
コマンドでHTMLのリファレンスマニュアルを生成すると次のようになります。
オプションについての説明が追加され、引数のHashに指定できるキーが:threshold
であることと、:threshold
に対応する値は整数を指定することと、指定しなかったときには0が使われることがわかります。Hashでオプションを指定する場合のドキュメントには@overload
タグ、@param
タグ、@option
タグを使いました。
Cで書いたライブラリにYARD用のドキュメントを書く方法を説明しました。ポイントは@overload
タグを使うことです。Rubyで書いたライブラリの場合は@overload
タグは必須ではありませんが、Cで書いたライブラリの場合は必須と言ってよいでしょう。YARD用のドキュメントを書くことで、ユーザーにとって有用なドキュメントを書いてみてはいかがでしょうか。よいソフトウェアを書くためにドキュメントを書くことが役に立つこともありますよ。
*1 RDocでは:XXX:
という記法でディレクティブを指定できます。ディレクティブはメタデータを指定するというよりは出力を制御するものです。ただし、ディレクティブの中には:category:
などメタデータを指定するものもあります。
*2 CRubyやMRIと呼ばれている実装。
*3 Cで書かれたRubyのライブラリを拡張ライブラリと呼びます。
*4 rubyforge.orgからranguba.orgに移動しました。
*5 RDocもRubyとCを両方サポートしています。
*6 後述。
*7 もっと後述。
*8 RDocはドキュメントを書く人それぞれが引数の説明っぽくドキュメントを書くという方式で、RDoc自身は引数の説明を特別扱いしません。そのため、書く人により表示のされ方は様々です。
*9 メソッドの名前、引数、戻り値に関する情報のこと。Rubyのメソッド定義の構文には戻り値に関する情報は含まれない。
*10 options
引数が省略可能という情報は後で指定します。
*11 Rubyで書かれたメソッドの場合はメソッド定義から引数の情報を抽出しているが、Cではそもそも引数の情報が書かれていないため。
*12 もう少し言うと、もう1つ@overload
タグがあると、その@overload
タグの下には現在ある@param
タグの説明が表示されないので見分けがつきます。
プログラムの種類によっては、そのまま実行できるものと、実行できるようにするために「ビルド」が必要なものとがあります。Cなどのコンパイルが必要な言語で書かれたプログラムは当然ビルドが必要ですし、コンパイルが不要な言語であっても、インストーラパッケージを作るというビルド作業が必要な場合はあります。
ビルド作業の自動化のためのツールとしてmakeなどがありますが、そこまで本格的な事をやる必要がない場合は、シェルスクリプトで「ビルドスクリプト」を作るのが手軽でおすすめです。この記事では、そのような場合に役立つシェルスクリプトのテクニックを4つご紹介します。
はじめに紹介するテクニックは問題が発生した時に気づきやすくするためのテクニックです。
シェルスクリプトを使って簡易的なビルドスクリプトを作る時には、「途中のどこかで失敗したらすべての処理を中断する」ということをやりたくなるものです。そうしないと、途中でエラーが起こっているのにビルド処理が最後まで走ってしまい、「見た目だけはきちんとしているけれども実は壊れている」という中途半端な生成物ができてしまうからです。
中途半端な生成物になってしまっているかどうかは、実際にそれをインストールする・動かすなどの操作をしてみないと分かりません。それに対して、そもそもエラー発生時にビルド処理が中断されていれば、「最終生成物ができていない」ということ自体から容易にトラブルの発生に気がつけます。
エラー発生時に全体の処理を中断する最も簡単なやり方は、シェルの内部コマンドの set
を使う方法です。bashやzshなどのsh互換のシェルでは、 set -e
とすると、途中のコマンドのどれか1つでも失敗した*1時点で自動的にシェルスクリプトの実行が終了するようになります。これは、個々のコマンド列について command || exit 1
などと書いても同じような効果を得られます。
1 2 3 4 5 6 7 8 9 10 11 |
#!/bin/sh # set-e-test.sh echo "Run 'cp /etc/hosts /root/' without 'set -e'" cp /etc/hosts /root/ set -e echo "Run 'cp /etc/hosts /root/' with 'set -e'" cp /etc/hosts /root/ echo "Finished" |
実行してみましょう。
% chmod +x set-e-test.sh % ./set-e-test.sh Run 'cp /etc/hosts /root/' without 'set -e' cp: cannot stat `/root/hosts': Permission denied Run 'cp /etc/hosts /root/' with 'set -e' cp: cannot stat `/root/hosts': Permission denied
最初のcp
は失敗しても実行が継続しますが、2つめのcp
は失敗したらそこで終了しています。これは「Finished」が出力されていないことからわかります。
ただ、この方法には1つ問題があります。それは、「どのコマンドの実行に失敗したのか」が分からないという事です。
通常、シェルスクリプトでは実行したコマンドの実行結果は出力されますが、どのようなコマンドを実行したのかまでは出力されません。単に「Permission denied.」とだけ出ても、何が原因でそのエラーが発生したのかが分からないと、デバッグは非常に困難です*2。
シェルの内部コマンドの set
を使うと、この問題を解消できます。set -x
*3とすると、シェルスクリプトの中で実行したコマンド列そのものが標準エラー出力に出ます。
1 2 3 4 5 6 7 8 9 |
#!/bin/sh # set-x-test.sh echo "Run 'cp /etc/hosts /root/' without 'set -x'" cp /etc/hosts /root/ set -x echo "Run 'cp /etc/hosts /root/' with 'set -x'" cp /etc/hosts /root/ |
実行してみましょう。
% chmod +x set-x-test.sh % ./set-x-test.sh Run 'cp /etc/hosts /root/' without 'set -x' cp: cannot stat `/root/hosts': Permission denied + echo Run 'cp /etc/hosts /root/' with 'set -x' Run 'cp /etc/hosts /root/' with 'set -x' + cp /etc/hosts /root/ cp: cannot stat `/root/hosts': Permission denied
「+
」から始まる行がset -x
が出力している行です。コマンドを実行する前にコマンド列を出力しています。
ということで、set -ex
とすると「コマンドの実行に失敗したらその場で終了する」と「どのコマンドで失敗したかを知る」という2つのことを実現できるのですが、これには1つデメリットがあります。set -x
すると、echo
のように失敗することがないコマンドなども含めてすべてのコマンド列が出力されるようになるため、出てくる情報が多すぎるのです。多すぎる出力は重要な情報を埋もれさせてしまうので、できれば慎みたいものです。
要するに、「コマンドの実行に失敗したらその場で終了する」と「どのようなコマンド列が実行されたかを表示する」という2つのことを、全部のコマンド列でやるのではなく、特定のコマンド列*4に対してだけやれるようにしたい、ということになります。set
コマンドの影響はそのシェルスクリプト全体に及んでしまうので、個々のコマンド列のレベルで同じような目的を達成するには、何か別の方法を使わなくてはなりません。
前述しましたが、command || exit 1
とすると、command
が失敗した時にだけexit 1
が実行されてそこでスクリプトの実行が中断される、という挙動にできます。シェルスクリプト全体ではなく、個々のコマンド毎に挙動を変更できるのでやりたいことに近くなります。ただ、これだけだと「どのようなコマンド列の実行に失敗したのか」を知ることができません。両方の望みを同時に叶えるためには、シェル関数を定義する必要があります。
シェル関数にすることには、もう1つメリットがあります。それは、そのコマンド列が「失敗しても継続するものなのかどうか」を判別しやすくなるという点です。コマンド列の最後に|| exit 1
を付けた場合、行末を見ないと、コマンドが失敗したらどうなるのかということを判別できません。行の長さはまちまちなので、これでは余計に判別が困難です。それに対して、シェル関数にしておくと、関数名 command
という風に行頭に情報が表れるため、ざっとスクリプト全体を眺めた時に、どれが失敗しても大丈夫でどれがそうでないのかという事が分かりやすくなります。
例えば、関数名
をCHECK
とすると以下のような見た目になります。
1 2 3 4 5 6 7 |
# 行末を見て判断 mkdir /usr/local/bin || exit 1 # 失敗してはダメ mkdir /usr/local/bin # 失敗してもよい # 行頭を見て判断 CHECK mkdir /usr/local/bin # 失敗してはダメ mkdir /usr/local/bin # 失敗してもよい |
前述のような事情から、run command
と書くと「コマンドの実行に失敗したらその場で終了する」と「どのようなコマンド列が実行されたかを表示する」が実現されるようなシェル関数run
を定義して利用しています。ここでのrun
が前述のCHECK
相当の関数です。
1 2 3 4 5 6 7 8 9 10 11 |
# run.sh run() { "$@" result=$? if [ $result -ne 0 ] then echo "Failed: $@ [$PWD]" >&2 exit $result fi return 0 } |
このように使います。
1 2 3 4 5 6 7 |
#!/bin/bash # run-sample.sh source ./run.sh run echo "Start" run cp /etc/hosts /root run echo "Finished" |
実行するとこうなります。
% chmod +x run-sample.sh % ./run-sample.sh Start cp: cannot stat `/root/hosts': Permission denied Failed: cp /etc/hosts /root [/tmp]
コマンドの実行に失敗したときだけコマンド列を標準エラー出力に出しています。
それでは、run
関数の中身を順番に説明します。
1 |
"$@" |
$@
は、シェルスクリプト自体に渡されたすべての引数を参照する変数です。シェル関数の中で参照した場合は、そのシェル関数に渡された引数を参照することになります。ここでは渡されたコマンド列そのものを実行しています。run
には「実行したいコマンド」も含めた内容が渡されますので、$@
はそのままコマンド列として実行することができます。単に$@
とせずに、ダブルクォートで囲って"$@"
としていることに注意してください。こうしないとスペース入りの引数が複数の引数に分割されてしまいます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#!/bin/bash # at-with-double-quote-test.sh with_double_quote() { "$@" } without_double_quote() { $@ } with_double_quote ruby -e 'p(ARGV)' a "b c" d without_double_quote ruby -e 'p(ARGV)' a "b c" d |
このスクリプトを実行すると違いがわかります。
% chmod +x at-with-double-quote-test.sh % ./at-with-double-quote-test.sh ["a", "b c", "d"] ["a", "b", "c", "d"]
ダブルクォートで囲んだ"$@"
だと空白入りの引数"b c"
がそのまま1つの引数として渡っています。一方、ダブルクォートで囲まない$@
だと空白入りの引数が分割されています。
$@
と同じような用途の変数で$*
もありますが、引数をそのまま実行するという用途には"$@"
が適切です*5。
1 |
result=$? |
$?
は、直前に実行したコマンド列の終了時のステータスコードを示す変数です。後でもう一度使うので、ここでは一旦別の名前の変数を定義しています。
1 2 3 4 |
if [ $result -ne 0 ] then # ... # ステータスコードが0以外であった場合の処理 fi |
ここでは、ステータスコードが0かどうかを判別しています。0であればコマンド列の実行に成功しており、それ以外であれば失敗です。[ A -ne B ]
は整数型の値同士の比較で、両者が等しくなければ(not equal)結果が真になります。
1 2 |
echo "Failed: $@ [$PWD]" >&2 exit $result |
ステータスコードで失敗と示されている場合には、実行したコマンド列とカレントディレクトリの位置*6を出力します。その後、exit
でスクリプトの実行を中断しています。この時、exit
の引数に先の「本当に実行したかったコマンド列の終了時のステータスコード」を渡すことによって、このスクリプト自体が他のスクリプトから呼ばれている場合であっても、エラーの発生が呼び出し元のスクリプトに伝搬するようになります。
1 |
return 0 |
コマンド列の実行に成功していた場合はステータスコード0を返してシェル関数を抜けます。
ここで紹介したようなrun
関数を使えば、コマンドの失敗を見つけやすくなるはずです。
シェルスクリプトでは、出力を色分けするのもおすすめです。すべての出力が同じ色だと、コマンドの引数と結果とを見間違えたり、そのような見間違いを警戒して出力を読むスピードが低下したり、といった形でデバッグのしやすさが低下してしまいます。
先の「失敗したコマンド列を出力する」という話にも言えることですが、バグは発生しないものだ*7という前提で考えてしまうと、エラー発生時の対策をなおざりにしてしまいがちです。そのような場合に備えた対策がなされていないと、デバッグに時間がかかって、余計に消耗してしまいます。そうではなく、人はミスをするものだ・バグは発生するものだ、という前提に立ってデバッグのしやすさに気を遣うようにすると、余計なストレスに悩まされずに済みます。
以下は、前述のシェル関数run
の例について、どの部分が実際に実行されたコマンド列なのか、どの部分が作業ディレクトリのパスなのか、といったことを色分けして表示するようにした例です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# run-with-color.sh red=31 yellow=33 cyan=36 colored() { color=$1 shift echo -e "\033[1;${color}m$@\033[0m" } run() { "$@" result=$? if [ $result -ne 0 ] then echo -n $(colored $red "Failed: ") echo -n $(colored $cyan "$@") echo $(colored $yellow " [$PWD]") exit $result fi return 0 } |
このように使います。
1 2 3 4 5 6 7 |
#!/bin/bash # run-with-color-sample.sh source ./run-with-color.sh run echo "Start" run cp /etc/hosts /root run echo "Finished" |
実行するとこうなります。
% chmod +x run-with-color-sample.sh % ./run-with-color-sample.sh Start cp: cannot stat `/root/hosts': Permission denied Failed: cp /etc/hosts /root [/tmp]
色つき文字列の出力には、エスケープシーケンスを使用します。エスケープシーケンスを使うと、カラーコードを指定して、echo
で出力される文字列に任意の色を付けることができます*8。
なお、上記のスクリプトはbash用スクリプトとして書かれていますが、bashでecho
コマンドを実行した場合、初期状態ではエスケープシーケンスが無視されてしまいます*9。ですので、エスケープシーケンスを明示的に有効化するために、colored
の中で明示的にecho -e
としています。dash用スクリプトとして実行する*10場合は、dashのecho
では初期状態でエスケープシーケンスが有効なため、-e
オプションを指定する必要はありません。
元のシェル関数run
は各シェルスクリプトにコピー&ペーストして使ってもまだ大丈夫な規模でしたが、色づけ表示するようにした例では、カラーコードの定義行なども含めるとそれなりの規模になってしまっています。このような場合は、コピー&ペーストするのではなく、ライブラリとして独立したファイルにしておいた方が便利でしょう。
シェル関数を定義する内容のシェルスクリプトをrun.sh
のような名前で設置しておき、他のスクリプトからsource ./run.sh
などとしてインポートすると、他のスクリプトの中でもrun
を利用できるようになります。これは、実はrun
関数の実行例の説明のときにすでに使っていたテクニックです。
ビルド対象が複数のモジュールに別れている時には、サブディレクトリに置いてある各モジュールをそれぞれビルドして、最後に上位のディレクトリで全体をビルドする、という順番で処理を行うことになります。
この時、うっかり以下のようなスクリプトを書いてしまうと、困った事になる場合があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
main() { build_module module1 build_module module2 } build_module() { cd $1 && \ ./build.sh && \ cp $1.zip ../dist/ && \ cd .. } main |
cd
で各モジュールのディレクトリに移動してそのモジュールごとのビルドスクリプトを実行し、できあがったファイルを最終出力のディレクトリにコピーした後、上位のディレクトリに戻るというスクリプトです。
このスクリプトの一番大きな問題点は、「モジュールのビルドに失敗した時に、カレントディレクトリが移動したままになってしまう」ということです。上記の例であれば、もしmodule1のビルドスクリプトの実行中にエラーが発生した場合、カレントディレクトリはmodule1のディレクトリのままで関数build_module
を抜けてしまい、次のbuild_module module2
に処理が進んでしまいます。そのため、ディレクトリが見つからなかったり、ファイルが見つからなかったり、期待していた内容と異なる内容のファイルが最終出力のディレクトリにコピーされてしまったり、といった予想外のトラブルが発生してしまう恐れがあります。
このような場合には、ディレクトリの移動を伴う一連のコマンドを(...)
で括ると安全です。
1 2 3 4 5 |
build_module() { (cd $1 && \ ./build.sh && \ cp $1.zip ../dist/) } |
(...)
の中には通常通りコマンド列を記述できますし、;
を区切りとして使えば複数のコマンドを記述することもできます。この時ありがたいことに、(...)
の内側でどれだけカレントディレクトリを移動しても、)
を抜けた後は(
に入る前のカレントディレクトリに強制的に戻されるという特性があります。この特性を利用すれば、複数のディレクトリに別れたサブモジュール群のビルド工程を含むビルドも安心して実行できます*11。
以上、シェルスクリプトを使って簡易的なビルドスクリプトを記述する際に有用なテクニックを4つご紹介しました。
定型的な作業は、このようにして極力自動化しておくことが大事です。パッケージング1つとっても、単にファイルをZIPで固めるだけだから……といった感じで毎回手作業でやっていると、人為的なミスが入り込む余地がありますし、他の人に引き継ぐのも大変です*12。また、そのうち作業が億劫になってきて、リリース自体が滞ってしまうことすらあります。
また、ミスを犯さないよう慎重になることは重要ですが、それとフットワークの重さとは別の話です。余計なことに多くの時間を取られるせいでミスが見過ごされてしまっては、元も子もありません。
ソフトウェアやサービスをリリースすることを、英語圏ではshipと表現することがあります*13。皆さんも、この記事で紹介したテクニックなどを駆使してshippabilityを高く保ち、プロジェクトを常にshippableな状態にしておくことを心がけてください。
*1 終了時のステータスコードが0以外になった
*2 シェルスクリプト自体のエラーであれば行番号が出ますが、外部コマンドではその限りではありませんし、コマンドによっては何もメッセージを出さずに終了時のステータスコードだけで異常を知らせるものもあります。
*3 上記の -e
と組み合わせるのであればset -ex
*4 例えば、失敗する可能性がある、失敗すると影響が大きいコマンド列
*5 'bash(1)'の「特殊パラメータ」の「*」と「@」の説明を読むと違いがわかります。
*6 デバッグ性を高めるため。
*7 自分はミスをしない
*8 カラーコードに応じて文字の色を変えるのはシェルではなくGNOME端末などの端末側の機能です
*9 エスケープ文字も含めてそのまま出力されます。
*10 例えばUbuntuでは#!/bin/sh
とするとdashが使われます。
*11 サブシェルで実行しているためです。この機能について興味がある人はサブシェルで調べてみてください。
*12 他の人でなくても、自分自身が過去にやっていた作業をしばらくぶりにまたやるようになったという場合にも、手順が複雑だと、また覚え直すのに苦労することになります
*13 出荷するという意味
最近のプログラミング言語はパッケージ管理システムを持っていることがほとんどです。PerlにはCPAN*1がありますし、PythonにはPyPi*2がありますし、RubyにはRubyGems*3がありますし、Node.jsにはnpm*4があります。パッケージ管理システムがあると簡単にライブラリやツールをインストールできるので、手元にたくさんのコードが集まります。そんな手元のコードを簡単に検索できるようにする方法を紹介します。ただし、ここで紹介するのはRubyGemsでインストールしたパッケージのコードを簡単に検索できるようにする方法だけです。他のパッケージ管理システムについては触れません。
ライブラリを使っていて、期待した動作をしないときはどうしますか?まず、ドキュメントを確認することでしょう。ドキュメントを読んでも解決しないときはWebで検索したり、実際にコードを読んでみることでしょう。ここで紹介する方法を使えばコードを読むまでのコストが下がるので今までより気軽にコードを確認できるようになるはずです。
gem-milkodeというgemをインストールするだけです。
% gem install gem-milkode
後はいつも通りgem install
でgemをインストールしてください。
% gem install rails
検索したくなったらmilk web
を実行してください。Webブラウザーに検索画面が表示されるので、そこからサクサク検索できます。
% milk web
gem install
じゃなくてbundle install
を使っているんだけど…最近はgem install
で個々のgemをインストールするのではなく、Bundlerでgemをインストールすることが多くなりましたね。そんなあなたはbundle-milkodeをインストールしてください。
% gem install bundle-milkode
後はbundle install
、bundle update
の代わりにbundle-milkode
を使うだけです。
% bundle-milkode install % bundle-milkode update
これでBundlerでインストールしたgemも簡単に検索できるようになります。
検索したくなったらmilk web
を実行してください。Webブラウザーに検索画面が表示されるので、そこからサクサク検索できます。
% milk web
gem-milkodeもbundle-milkodeもgemをインストールする時にMilkodeにgemのコードを登録しているだけです。Milkodeは行指向のソースコード検索システムです。コードを登録すればあとはMilkodeがいい感じにやってくれます。gem-milkodeとbundle-milkodeは「Milkodeにコードを登録する」という少し面倒な作業をこっそりやってくれるだけです。でも、それが便利なんです。
さて、それでは、gem-milkodeとbundle-milkodeはどのような仕組みで動いているのでしょうか。
bundle-milkode
はbundle
のラッパーみたいなものなので特別に何かをしているわけではありません。bundle
の機能を実行した後に新しくインストールされたgemをMilkodeに登録しているだけです。短いコードなので、コードを読むとすぐにわかります。
コード: bundle-milkode
gem-milkodeはRubyGemsのプラグイン機能を使っています*5。プラグイン機能の使い方は簡単です。gemにlib/rubygems_plugin.rb
*6というファイルを含めるだけです。後はRubyGemsが勝手に読み込んでくれます。
注意する点は、インストールされているすべてのgemのlib/rubygems_plugin.rb
が読み込まれるということです。例えば、gem-milkode-1.0.1とgem-milkode-1.0.2がインストールされているときは、1.0.1のlib/rubygems_plugin.rb
も1.0.2のlib/rubygems_plugin.rb
も読み込まれます。そのため、プラグインが提供する同じ機能が何度も実行される可能性があります。
これを回避するために、「複数のバージョンをインストールしないように!」と呼びかける方法と、複数のバージョンがインストールされていても最新のものだけ実行する方法があります。gem-milkode 1.0.2までは前者でしたが、1.0.3からは後者になっています*7。gem-milkodeも短いコードなので、やり方はコードを見てください。
コード: rubygems_plugin.rb
インストールしたgemを自動でMilkodeに登録する小さなツールを紹介しました。このツールを使うことで簡単にgemのコードを検索できるようになります。gemのコードを読む敷居が下がるので、たくさんコードを読んでみてください。
おそらく、RubyGems以外のパッケージ管理システムにもプラグインのような機能があるはずなので、ここで紹介したツールと同じようなツールを作ればNode.jsやPythonでも簡単にコードを検索できるようになりますね。
*1 フロントエンドはいくつかあるみたい。
*2 パッケージ管理システムというかパッケージ配布サイト。フロントエンドはいくつかある。
*3 これはパッケージ配布サイトもパッケージ管理ツールも提供。
*4 これもサイトもツールも提供。
*5 リンク先を見ればわかる通り、すでにいろんなプラグインがあります。エディターでgemのファイルを開くコマンドを提供するプラグインがいくつもあることが興味深いですね。やはり、みんなコードを読みたくなるようです。gemのソースをgit clone
するプラグインもあります。
*6 lib/
以下じゃなくても$LOAD_PATH
が通っている場所であればどこでもよいです。説明が面倒になるので、ここではlib/
に置くということで進めます。
*7 つまり、gem-milkode 1.0.2以下はさっさとアンインストールしてgem-milkode 1.0.3以降を使ってください、ということです。
先日紹介したシェルスクリプトで「ビルドスクリプト」を作る時に便利なテクニックへのコメントとして「なぜMakefileでやらないのか」「Makefileの方がいいのではないか」といったものがありました。確かにmakeはメジャーなビルドツールなので、そのような疑問が出てくるのも当然でしょう。
なぜシェルスクリプトなのかということの理由はいくつかあります。
1つは、先のエントリの題材としたスクリプトが元々はWindows用のバッチファイルをLinuxのシェルスクリプトに移植したものだったからという理由です。Windowsのバッチファイルのベタ移植として作成したシェルスクリプトを継続的にメンテナンスしてきた間の改良の結果として、いくつかのテクニックが盛り込まれるようになったため、そのテクニックにスポットを当てて紹介しようというのが、先のエントリの発端でした。
もう1つは、シェルスクリプトは「シェルのコマンドを列挙したビルドスクリプト」を作る上では最適な手段だからという理由です。「シェルのコマンドを列挙したビルドスクリプト」の場合は、必ずしもMakefileだけで完結させるのが最良とはいえません。以下、先のエントリの補足も兼ねて、こちらの点について詳しく説明します。
シェルスクリプトとMakefileの違いについて述べる前に、まずMakefileについて簡単に説明します。
アプリケーションを使える状態にするには、実行用のファイルをビルドする必要があったり、あるファイルをビルドするにあたって依存する別のファイルを先にビルドしておかなくてはならなかったりと、色々と気をつかわなくてはならないことがあります。そのため、個々のファイルのビルド手順と、ファイル同士の依存関係を整理して、「依存するすべてのファイルを自動的にビルドする」「一部の構成ファイルに変更があった場合はそれに依存するファイルだけを再ビルドする」といった事を自動的に行えるようにする仕組みが必要とされてきました。その代表的なツールがmakeであり、make用の設定ファイルがMakefileです。
Makefileの最も単純な使い方では、以下のような書式でファイル同士の依存関係と各ファイルのビルド方法を記述します。
1 2 3 4 |
ビルド対象のファイル: 依存するファイル ビルドするためのコマンド列1 ビルドするためのコマンド列2 ... |
コマンド列を記述する行は、行頭にタブ文字を置いてインデントします。
例えば「ZIP形式のアーカイブであるmyaddon.jarというファイルをビルドする必要があり、content、locale、skinという3つのディレクトリが必要である」という時には、以下のように書きます*1。
1 2 |
myaddon.jar: content locale skin zip -q -r -9 myaddon.jar content locale skin |
また、Makefileの中では以下のようにして「マクロ」を定義しておくこともできます。
1 |
PACKAGE_NAME = myaddon |
マクロとは、特定の文字列に名前を付けて、同じ事を何度も書かなくてもその名前を書くだけで参照できるようにする仕組みです。定義したマクロは、以下のようにして自由に参照できます。
1 2 3 4 5 |
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_NAME、PACKAGE_VERSION、JAR_TARGET_FILES、およびXPI_TARGET_FILESの4つのマクロと、xpi、および$(PACKAGE_NAME).jar
の2つのビルド対象ファイル(これを「ターゲット」と呼びます)を記述しています。ビルド手順としてのコマンド列は、ファイルをコピーしたり、ディレクトリを用意したりした上で、ZIP形式で圧縮するだけという単純なものです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
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の場合、個々のコマンド列は直接シェルによって実行されるのではなく、一旦makeによって解釈されるという点に気をつけなくてはいけません。
上記のMakefileの例において、$(PACKAGE_NAME)
と書いている点に注目して下さい。シェルで変数を参照する場合は${変数名}
と書きます。$(...)
はコマンド置換の書き方です。前述した通り、このコマンド列はまずmakeによって解釈され、その上でシェルのコマンドとして実行されます。よって、$(PACKAGE_NAME)
という記述はシェルのコマンド置換ではなくMakefileでのマクロの参照として処理され、シェルにはその結果が渡されます。
では、シェルのコマンド置換を使いたい場合はどうなるでしょうか。例えば、バージョン番号をversion.txtというファイルで管理していて、そのファイルの内のにあるバージョン番号を生成するファイル名の一部に使いたいという場合です。単純に考えると、例えば以下のようになるでしょう。
1 2 3 4 5 6 |
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にマクロの参照として認識させたくない$
をエスケープする必要があります。$$
と書くと、マクロ参照の指示ではない文字として$
を記述できます。
1 |
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ファイルに圧縮する、ということをしたくなったとしましょう。単純に考えると、以下のように書きたくなるところです。
1 2 3 4 5 6 7 8 9 |
$(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行にまとめて記述する必要があります。
1 2 3 4 5 6 |
$(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行が長すぎるので、このような場合には改行をエスケープして見た目上折り返すのが一般的です。
1 2 3 4 5 6 7 8 |
$(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
をつけて以下のように書きます。
1 |
zip -r ../$(PACKAGE_NAME).jar $(JAR_TARGET_FILES) || true |
以上のように、いくつかの点に気をつければMakefileでも複雑な処理をこなすことはできます。しかしながら、シェルスクリプトとは別の決まりがたくさんあって、それに気をつかいながらコマンドを書いていくということは、ミスを誘発しやすく、また、デバッグもしにくくなります。
シェルの複雑なコマンドとMakefileの単純なルールを組み合わせたために分かりにくくなってしまうのであれば、Makefileの機能をフルに活用すればよいのではないか、ということはいえます。実際に、Makefileには前述した単純なルール以外にも非常に多くの機能があり、シェルで複雑なコマンドを書かなくてもMakefileの機能で代用できる場合もあります。たとえば、FreeBSDのパッケージ管理システムであるFreeBSD Portsはmakeベースのシステムです。
ただ、makeで実現できる高度な機能の多くはmakeの実装毎にMakefileの書き方が異なるため、汎用的に使えるMakefileとするためには注意が必要です。Makefileを生成する仕組みがたくさんあるのはこのためです。GNUのビルドシステム*3もそうですし、imakeやCMake*4などもそうです。
そのため、Makefileだけで頑張るよりは、他のツールを使ったり、他のツールと一緒に活用するのが現実的です。そのときに使えるツールの1つが、makeでも使っているシェルです。先のエントリでは「簡単なビルドスクリプトであればシェルスクリプトで……」といった前置きを書いていましたが、むしろ、Makefileの書き方自体に明るくないのであれば、複雑な処理が必要なビルドスクリプトであればあるほど、シェルスクリプトとして記述した方が簡単です。
ただ、Makefileには、ファイルの依存関係からビルドの順番を自動的に解決するという、シェルスクリプトにはない非常に便利な機能があります。依存関係の解決にだけ着目すれば、覚えなければならないことはそれほどないため、習得はそれほど難しくないでしょう。
よって、以下のように使い分けるのが、シェルスクリプトとMakefileのよいところを互いに引き出しあえて、作るのも管理するのも効率がよくなります。
また、make以外にもRakeやOMakeなど依存関係を解決してくれるツールがあります。makeだけにこだわらず、自分が実現したいことに適したツールを選択するとよいでしょう。