繰り返しはグロブで、戻り値は終了コードで
一言でいうと
シェル関数は値を返しません。終了コードで真偽を知らせ、値は標準出力に出します。そして、ファイルの一覧を得るためにlsをパースすると、いつか必ず壊れます。
なぜ必要なのか
for f in $(ls *.log)は覚えやすく、そのためよく使われます。しかしこの1行には、落とし穴が3つ重なっています。
lsの出力をコマンド置換で受け取ると、空白を基準に単語分割されます。access 2026.logは2つになります。- 一致するファイルが1つもないと
lsがエラーを出し、そのエラーメッセージがループの入力になります。 - ファイル名に改行やグロブ文字が入っていると、予測できない動作をします。
正解は、シェルに直接やらせることです。
for f in /var/log/*.log; do
[ -f "$f" ] || continue
...
done
ここで[ -f "$f" ] || continueが核心です。一致するファイルがないと、グロブパターンがそのまま文字列として残るので、この防御線がないと、/var/log/*.logという存在しないファイルを処理しようとします。実際のラボでも、この落とし穴が出てきます。
どう動くのか
繰り返しの3つの形を、状況に合わせて使います。
for x in a b c: 決まった一覧for f in 경로/*.log: ファイルの巡回。シェルが直接展開します(プレースホルダーはパスです)while IFS=, read -r name role; do ... done < users.csv: 行単位の入力
3つ目の形で、IFS=,をreadの前に付けると、そのコマンドにだけ区切り文字が適用されます。-rはバックスラッシュをエスケープとして解釈しないという意味で、事実上いつも付けます。
関数のインターフェースは、次の3つで構成されます。
| 何を | どこへ |
|---|---|
| 計算した値 | 標準出力(echo) |
| 成功/失敗、真/偽 | 終了コード(return 0 / return 1) |
| 人へ送るメッセージ | 標準エラー出力 |
is_number 123のような判定関数が、結果をecho yesで出力すると、if is_number "$x"のようには使えません。判定は必ず終了コードで知らせます。
引数の扱いで最も重要なのは、"$@"と$*の違いです。"$@"は、引数の1つ1つをそれぞれクォートで囲んだかのように展開し、$*はすべてを1つの文字列にまとめます。コマンドをそのまま再実行するリトライ関数のような場所では、必ず"$@"でなければなりません。
関数の中の変数は、デフォルトではグローバルです。localを付けないと、呼び出した側の同じ名前の変数を黙って上書きします。関数の中で作る変数は、例外なくlocalで宣言する習慣をつけるのがよいです。
現場での姿
ライブラリの分離。複数のスクリプトが同じ関数をコピペしていると、直す場所が複数になります。lib.shに関数を集め、. /root/bin/lib.shで読み込めば、1か所だけ直せば済みます。sourceと.は同じコマンドで、新しいプロセスを作らず現在のシェルで実行するため、関数定義が残ります。
ループ内の外部コマンドが性能を削ります。1000行を回るループの中で$(echo "$v" | sed ...)を呼ぶと、プロセスが2000個できます。同じことを${v//old/new}で行えば、プロセスは0個です。シェルスクリプトが遅いという印象の半分は、ここから来ています。
並列化が必要なときは、xargs -Pや& + waitを使います。ただし、並列で回した作業の失敗を見逃さないためには、各PIDを集めておいて、wait "$pid"で1つずつ状態を確認する必要があります。
スクリプトが静かに間違える場面
シェルはエラーに出会っても、次の行へ進みます。そのため、スクリプトが成功したかのように終わりながら、何もしていない状態がよく起こります。これを防ぐ仕組みがいくつかあり、それぞれが何を防ぐのかを知ったうえで使う必要があります。
set -eは、コマンドが失敗すると止まります。ただし例外が多くあります。条件文の中、&&や||の左側、!の後ろでは、失敗しても止まりません。そして、関数の中で失敗したことが、条件文の一部として呼ばれると、その関数全体が止まりません。そのためset -e1つに頼ってはいけないので、重要な場面には明示的に確認を付けます。
set -uは、未定義の変数を使うと止まります。タイプミス1つで空文字列になり、rm -rf "$PREFX/"がrm -rf /になる事故を防ぎます。ただし、引数がないかもしれない場面では、${1:-}のようにデフォルト値を書いておく必要があります。
set -o pipefailは、パイプラインの終了コードを失敗したもののうち最後のものに変えます。これがないと、curl ... | jq ...でcurlが失敗しても、jqが成功すれば全体が成功になります。前のコースで見た、あの落とし穴です。
3つを一緒にオンにするのが慣例ですが、オンにしたあとで1度実行してみて、意図せず止まる場所がないかを確認する必要があります。特にset -uは、既存のスクリプトに入れると、普段使われない分岐で急に死にます。
そして最後に、後始末を保証します。trap 'rm -rf "$TMP"' EXITのようにかけておけば、途中で失敗しても一時ファイルが残りません。失敗したときにだけ残したいなら、成功経路で明示的に削除するほうがよく、どちらにしても、デバッグのために残すものと消すものを決めておくことが、後でディスクを埋めない方法です。
次のラボですること
数字を出力するループから始めて、グロブでの巡回、CSVの読み取り、関数の分離とsource、終了コードに基づく判定、空白を含む引数の安全な処理、可変長引数の合計を経て、最後にはディレクトリを受け取って要約を出すスクリプトを作ります。