シェルスクリプト Tips

コーディング・スタイル

ここに書かれている内容は、あくまで筆者の好みでありほぼ完全に主観ではあるが、経験的に行き着いたスタイルでもあるので推奨します

スクリプトのヘッダを作成する

最近はあまり使用されることもないのかもしれないが @(#) の記述と、スクリプトの使用方法、および概要をファイルの前方にコメントとして記述しておくようにする。

@(#) は what コマンドで参照する情報を記述するための記号です。詳細は「whatコマンドについて」を参照。

#!/bin/bash
#
# @(#) hoge.sh ver.1.0.0 2008.04.24
#
# Usage:
#   hoge.sh param1 param2
#     param1 - パラメータ1です.
#     param2 - パラメータ2です.
#
# Description:
#   hoge.shスクリプトです.
#
###########################################################################

# ここから処理を記述します.
echo "start..."

インデントは半角スペース2つ

半角スペース4つが理想ではあるが、シェルスクリプトはパイプ処理があるため、横に長くなりやすい。そのためインデントは少なめに取る

{
  touch hoge
  ls hoge
  rm hoge
}

| と < > 前後のスペース

| は前後に1スペース空ける。

hoge | fuga

> は前に1スペース空ける。

hoge >hoge.txt

>> も同様に前に1スペース空ける。

hoge >>hoge.txt

<<<> と同様に前に1スペース空ける。

hoge <hoge.txt
hoge <<'__EOT__'

ちなみに、|>>> および <<< は前後にスペースが全くなくても文法エラーにはならない。

if と then は同一行に、セミコロンの直後にのみスペースを置く

thenif の後ろに記述すると iffi が揃うので読みやすい(iffi は対応する単語という意味で)。thenif の直下行にあると、可読性の低下と行の無駄遣いにしかならない。

if [ "$str" = "hoge" ]; then
  echo "strはhoge."
fi

for、while の do と done の頭を揃える

ifthen 場合とは異なり、doforwhile の直下行に記述する。その方が dodone が揃うため可読性が高い(dodone は対応する単語という意味で)。

for i in 1 2 3 4 5
do
  total=`expr $total + $i`
done
while [ $total -lt 10 ]
do
  total=`expr $total + 1`
done

関数定義の function は省略する

関数は function Hoge() と定義するが、このとき function は省略可能だ。function を省略しても可読性が低下したと感じることはなかったので、原則省略とする。

※ シェルスクリプト初心者は function を明示的に指定されている方が分かりやすいようなので、筆者は初心者に配慮して最近は function は省略しないようにしている。

初心者が参照する可能性がないのであれば、原則省略がよいと思う。 いずれにせよ、明示するか省略するかどちらか一方に統一すること。

Hoge()
{
  echo "関数 Hoge() がコールされました."
  return 0
}

if 文を省略しない

コマンドが成功した場合のみ次のコマンドを実行する && を使用した応用で、[ $cnt -eq 10 ] && exit 0 といったように if 文に相当する処理を簡素に記述することが可能である。

しかし、そのルートに対して処理を追加したい場合には、結局 if 文で記述することになる。また、if 文を省略していることにより処理の分岐箇所がわかりにくくなる弊害もあり、簡素に記述できること以外のメリットがあまりにも薄い。

if [ $cnt -eq 10 ]; then
  # あとでここに処理を追加する可能性がある.
  exit 0
fi

連続した同一ファイルへのリダイレクトはグルーピングする

連続した複数コマンドの実行結果の出力先が同一ファイルである場合は、{} でコマンドのグルーピングを行い、そのグループの出力をファイルへリダイレクトする。

echo "hoge" >>logfile.log のように、各コマンドごとにリダイレクトは行わないようにする。詳細は「複数コマンドのリダイレクトとパイプ」を参照。

{
  echo "hoge"
  echo "fuga"
  echo "foo"
  echo "bar"
} >>logfile.log

複数行の一括コメントアウト

この Tips は実践すると突飛な処理になるため非推奨ですが、ヒアドキュメントの理解を深めるため、また応用力向上のために一読しておくことをおすすめします。

シェルスクリプトで複数行をコメントアウトする場合は、対象となる各行の先頭に # を記述する必要がある。C 言語、等の /* コメント */ に相当する、複数行を一括してコメントにする機能は存在しない。

しかし、ヌルコマンドとヒアドキュメントを応用することで、複数行の一括コメントアウトが可能になる。

: (ヌルコマンド)は一切の処理を行わずに終了するコマンドで、コメントアウトしたい箇所をこのコマンドへのヒアドキュメントとすることで、該当箇所の処理を無効化し、結果的にコメントアウトした場合と同様の結果を得ることができる。

: <<'#__COMMENT_OUT__'
echo "ここの処理は全て無効になります."

# hogeファイルは作成されません.
touch hoge

# 終了文字をシングルクォートで囲んでいるのでコマンド置換も行われません.
dummy=`touch fuga`

#__COMMENT_OUT__

ヒアドキュメントは終了文字(※)をクォートもしくはエスケープすることで、ヒアドキュメント内を完全に文字列として扱うことができる

※ ヒアドキュメントが終了する箇所を識別する文字列、この場合は #__COMMENT_OUT__

したがって、変数の展開やバッククォートによるコマンド置換も発生しない、つまり一切の処理が行われないただの文字列としてヌルコマンドの標準入力に渡されることになる。

ヌルコマンドは何も処理を行わないコマンドなので、標準入力からの入力があってもこれを無視して終了する。結果的には一切の処理が行われないことになり、コメントアウトした場合と同じ結果となる。

また、終了文字の先頭に # を使用しているので、コメントアウトを解除したい場合は、ヌルコマンドの先頭に # を記述するのみでよい

それによってヒアドキュメントの開始が無効化されると、下方にある終了文字はあらかじめ先頭に # が付けられているため、自動的にコメントとなる

変数の命名規則

筆者は他の言語でもそうであるように、定数は大文字定数以外の変数は小文字を使用するようにしている。変数名が複数の単語からなる場合は「_」で結合した、いわゆるスネークケースを使用する (e.g. variable_name)。

また、定数は readonly 宣言を付加して、読み取り専用にすることを推奨する

# forループ用の変数は非定数なので小文字
for i in 1 2 3 4 5
do
  echo $i
done

# readコマンドで処理を一時停止する
echo -e "Enter to continue...\c"
read dummy
# 定数は readonly 宣言付きの大文字で
readonly MAX_COUNT=10
readonly FILENAME="hoge.txt"

使い捨てではない非定数の変数名には ab などの意味のない名前、temp など使用目的がはっきりしない変数名は使用しない。temp はつい使いそうになるが、何の temp なのか分からないため可読性が著しく低下する。そもそも変数はもとから全て temp だ。

ファイル名やディレクトリ名の定数化

メンテナンス性向上のために、ファイル名やディレクトリ名をスクリプト上方で変数として定義し、それらの指定には変数を使用するようにしている人が大半だと思うが、この命名規則が個人によりまちまちであるため (そもそもまったく規則性のない変数名を使用している人もいる)、可読性の低下やそれによるバグにつながることが多い。

筆者はファイル名、ディレクトリ名を格納する変数名には次の命名規則を用いている。

ファイル名

  1. フルパス(相対パス)指定ではなく単純にファイル名のみを設定する場合は、HOGE_FILENAME (もしくは HOGE_FILENM) のように FILENAME を変数名に使用する。
  2. 逆にフルパス(相対パス)指定でファイル名を設定する場合は、NAME は付けずに単純に HOGE_FILE とする。
  3. readonly で定義し、完全に定数として扱う

ディレクトリ名

  1. フルパス(相対パス)指定ではなく単純にディレクトリ名のみを設定する場合は、HOGE_DIRNAME (もしくは HOGE_DIRNM) のように DIRNAME を変数名に使用する。
  2. 逆にフルパス(相対パス)指定でディレクトリ名を設定する場合は、NAME は付けずに単純に HOGE_DIR とする。
  3. フルパス(相対パス)指定ディレクトリ名もしくは単純にディレクトリ名のみを設定する場合も、末尾に / は付加しない。
  4. readonly で定義し、完全に定数として扱う

例としてこの命名規則を使用して、ログファイルとその格納ディレクトリを変数で定義すると次のようになる。

# ディレクトリ名
readonly LOG_DIRNAME="hoge"

# フルパス指定ディレクトリ名
readonly LOG_DIR="/tmp/${LOG_DIRNAME}"

# ファイル名
readonly LOG_FILENAME="fuga.log"

# フルパス指定ファイル名
readonly LOG_FILE="${LOG_DIR}/${LOG_FILENAME}"

簡潔に説明すると、

  • NAME (もしくは NM) が付いている場合は名前のみ (パスは含まない)
  • NAME (もしくは NM) が付かない場合は全体 (パスを含み出力先、等としてそのまま使用できる)

となる。

また、各変数を readonly としていることで、処理の途中で値を変更され、思わぬバグが発生することを抑止している。

関数の振る舞いを変数で変更する

関数の振る舞いを変更する方法としてはパラメータが一般的だが、むやみにパラメータ化するとパラメータ処理が複雑化し、可読性の低下やバグにつながる。

パラメータ化するほどでもない細かな振る舞い変更は、変数化して関数外部で定義するとよい。例として次のような関数を作成してみる。

Message()
{
  _SEPARATOR=${_SEPARATOR:-"-----"}

  echo "$1"
  echo "$_SEPARATOR"
  echo "$2"

  return 0
}

この関数をそのまま実行してみる。

$ Message hoge fuga
hoge
-----
fuga

次に第1パラメータと第2パラメータの境界部分の文字列を変更してみる。変更するには関数呼び出し前に、変数 _SEPARATOR に変更後の文字列を設定する。

$ _SEPARATOR="*****"
$ Message hoge fuga
hoge
*****
fuga

このように変数を使用した関数の振る舞い変更ではパラメータ処理を複雑化することなく、簡素に処理に変更を加えることができる。

※ もちろん同様の処理を関数の出力に対して sed 等を使用して行ってもかまわないが、この方法の方が簡素であり、変数を一度定義するだけで振る舞いを変更できる点で推奨している。

今回使用した ${_SEPARATOR:-"-----"} は、変数 _SEPARATOR が使用されていないか、もしくは設定値が空("")であれば、デフォルト値(-----)を使用するという意味である。

また、変数名先頭のアンダーバーは関数内で使用される変数であることを意味している (筆者の個人的なコーディング規約)。

シェルスクリプトの変数は基本的に全てグローバル変数となるため、関数内で使用される変数で、特に local で宣言されていない変数は、関数外の変数との衝突を避けるため、変数名の先頭にアンダーバーを付けるようにするとよい。

local で宣言した場合は特にその必要はないが、関数内で使用される変数は変数名を一律にアンダーバーで始めるようにした方がよい

同様の方法で任意の値を変更可能な値とすることができるが、当然多用しすぎるとバグにつながるため、本当に必要な値だけを変更可能にすること

パイプライン処理

パイプライン処理を制す者は、シェルスクリプトを制す」と、筆者は思っている。while ループや for ループで行っていた処理をパイプ処理で置き換えられないか、を常に意識しているとレベルアップの近道になる (もちろん置き換えられない処理も多いが・・・)。

xargs という便利なコマンドもあるので、興味があれば以下を参照してほしい。

いまさらxargsの便利さを主張してみる

ループ処理を使うな

もちろん全く使うなという意味ではなく、無駄にループ処理を使用するな、という意味だ。例えば次のような場合を考える。

for str in "hoge" "fuga"
do
  echo "$str" >${str}.txt
done

たまにこういう書き方をしている人を見かけるが、問題なのは hoge や fuga がもうこれ以上増えることはないのに、わざわざ for ループを使用している点だ。

こんな処理にループを使用する必要は全くない。似たような処理が100個、200個なら話は別だが、2~5個程度ならそのまま記述した方が、遙かに可読性が高い。

echo "hoge" >hoge.txt
echo "fuga" >fuga.txt

少し増えてもループは使用せずにそのまま記述するべきだ。

echo "hoge" >hoge.txt
echo "fuga" >fuga.txt
echo "foo"  >foo.txt
echo "bar"  >bar.txt

ループ処理のネストより関数

この Tips は比較的大規模なスクリプトを想定しています

例えば次のような場合を考えてほしい。

for i in 1 2 3
do
  # 信じられないくらい長い処理
  ・・・

  for j in "hoge" "fuga"
  do
    # 死ぬほど長い処理
    ・・・
  done
done

ループ処理のネストはメンテナンス性が低い上に可読性の面で不利なので、関数で置き換えられないか検討するとよい。

ループ処理のネストを関数で置き換えると次のようになる。

SubNestedLoop()
{
  # 相当長い処理
  ・・・
  return 0
}
SubNestedLoop 1 "hoge"
SubNestedLoop 2 "hoge"
SubNestedLoop 3 "hoge"

SubNestedLoop 1 "fuga"
SubNestedLoop 2 "fuga"
SubNestedLoop 3 "fuga"

このようにするとループ処理のネストに比べて、ずいぶん読みやすくなるはずだ。

ポイントは関数の定義を、呼び出し箇所の直上で行うことである。こうすることでソースを上から下に読んでいくだけで処理を理解することが可能になる (呼び出し箇所直上ではなくスクリプト上方に定義すると、呼び出される箇所から上にスクロールして読まなければならなくなる)。

複数箇所から呼び出される関数は、スクリプトの上方に定義するべきだが、特定の場所からしか呼び出されない関数はその直上に定義するとよい

パイプの先はサブシェル

bash ではパイプで結合したその先がサブシェルで実行される、という仕様が存在している。

先日、シェルスクリプト初心者から、「なぜか変数に値が設定されない」という相談を受けたが、原因はまさにこの仕様であった。

以前、筆者も同じ問題に突き当たったことがあったため、すぐに原因は突き止められたが、この仕様が頭に入っていないと、解決できないバグになる可能性が高い。

以下に例 (pipe_bug.sh) を示す。

#!/bin/bash

str="dummy"

cat <<'__EOT__' >temp.$$
hoge hoge
fuga fuga
foo foo
bar bar
__EOT__

cat temp.$$ | while read line
do
  str="$line"
done

rm -f temp.$$

echo $str

このスクリプトは、変数 str にあらかじめ文字列「dummy」を設定した上で 、cat コマンドの出力をパイプで while ループに流し込み、read コマンドで流し込まれたデータを1行ずつ読み取る。さらに、読み込んだその値(行)で変数 str の値を上書きしている。

おそらく多くの人はこのスクリプトの実行結果は、while ループ内で変数 str に最後に設定される値、すなわち「bar bar」が出力されることを期待すると思う。

だが、実際の実行結果は以下のとおりとなる。

$ ./pipe_bug.sh
dummy

これは前述の通り、パイプで結合した先の while ループがサブシェルで実行されているためである。サブシェルで実行されることにより、変数 str は while ループの中と外でスコープが異なっているのである。

そのため、while ループ内で設定された値(ループ変数 str)は、while ループ終了とともに破棄され、最後の echo コマンドの出力結果はカレントシェルで変数 str に設定した値「dummy」となる。

while ループを抜けるとサブシェルが終了するので、以下のように export しても元の変数が破棄されるため、結果は変わらない。

※ pipe_bug.sh の while 部分のみ書き換え

cat temp.$$ | while read line
do
  str="$line"
  export str
done

※ export を追加した場合の実行結果。

$ ./pipe_bug_export.sh
dummy

export しても while ループ内の変更は反映されないのが確認できる。

ちなみにこの while ループ内では exit も無効となる (正確には exit コマンドを実行してもサブシェルを終了するだけで、カレントシェルは終了しない、という意味)。

※ pipe_bug.sh の while 部分のみ書き換え

cat temp.$$ | while read line
do
  str="$line"
  exit
done

※ exit を追加した場合の実行結果。

$ ./pipe_bug_exit.sh
dummy

exit を実行してもスクリプトは終了せず、最後の echo コマンドまで実行されているのが確認できる。

今回のような場合、問題を回避するには、パイプではなくリダイレクトを使用するとよい

※ pipe_bug.sh の while 部分のみ書き換え

while read line
do
  str="$line"
  exit
done <temp.$$

リダイレクトであれば while ループ内はカレントシェルのままとなるため、期待どおりの結果 (「bar bar」が出力される) となる。

実際の実行結果は以下のとおり。

$ ./pipe_bug_redirect.sh
bar bar

この問題に関しては、以下のサイトに詳しくまとめられている。

Solarisの/bin/shの変数の挙動について(サブシェルの問題)