ShellScript
Bash

シェルスクリプトを書くときに気をつける9箇条

More than 1 year has passed since last update.

最新の類似投稿としてシェルスクリプトのコーディングルール2014も併せてどうぞ。
2014/10/09追記

ぼくがシェルスクリプトを書くときに気にしていること、過去の失敗で書き留めたことを忘れないために。

1. グローバル変数は大文字

PATHHOME など、環境変数が大文字なので、エクスポートする変数を大文字で書くという習慣は一般的であるような気がしますが、エクスポートする変数を抱えるシェルスクリプトを作成する機会が稀なので。

  • グローバル変数は大文字
  • ローカル変数は小文字
  • エクスポートする変数も大文字

関数内からグローバル変数にアクセスする場合がありますが、やはり区別していると、可読性が増すような気がするのでお勧めです。

2. awk を知る

Unix 上にて文書処理をするときに、数多くのフィルタコマンド(grep、cut、tr、head、sort、uniq、sed、awk、wc、paste)を駆使して加工してゆくと思いますが、awk オンリーで解決できる場合が多かったりします。

例として家計簿を記した csv ファイルをあげます。

sh
b4b4r07:~$ cat test.csv 
日付,カテゴリ,金額,備考
09/25,仕送り,+25000,
10/01,食費,-1253,
10/02,医療,-1200,皮膚科
10/02,医療,-760,薬局の処方薬
10/02,PC用品,-3180,外付けCDドライブ
10/03,食費,-379,
10/03,食費,-398,
10/03,美容室,3150,
10/04,食費,-580,
10/05,食費,-667,
10/06,食費,-420,
b4b4r07:~$ declare -i sum=0; for i in $(seq 1 `grep "食費" test.csv | wc -l`); do sum+=`grep "食費" test.csv | cut -d, -f3 | head -$i | tail -1`; done
b4b4r07:~$ echo $sum
-3697
b4b4r07:~$ 

家計簿から食費だけを算出しようとしたとき、フィルタコマンドの組み合わせでやろうとすると、結構複雑に書かなければなりません。しかし awk を知ると

b4b4r07:~$ awk -F, '$2=="食費"{ sum += $3 }; END{ print sum }' test.csv 
-3697

と書けてしまいます。収支の算出も簡単です。

b4b4r07:~$ awk -F, '$3 ~ /\+/{p += $3}; $3 ~ /^\-/{m += $3}; END{print "[income]",p, "[used]",m, "[rest]",p+m}' test.csv
[income] 25000 [used] -8837 [rest] 16163

スマートに書くことが出来ます。awk や sed はフィルタコマンドの域を超えた小さなプログラミング言語と称されることが多いです。そのくらいに多くのことができるので、知っておくまたは使いこなせるようになるとできることの幅がグッと広がります。

3. bash に依存しているのに #!/bin/sh と書かない

普段シェバンとしてシェルスクリプトの冒頭に書くアレですが、#!/bin/sh の実態は環境によってまちまちです。さまざまなシェルのシンボリックリンクになっていることが多いです(Ubuntu では ash 亜種の dash となっている模様)。なので、予期せぬエラーや動作しないといったことが起きる場合があります。bash やその他シェルに依存したスクリプトを書くのなら、それ上での動作を予定しているわけだから、シェバンには #!/bin/bash と書いたほうが良い。

4. 改行コードに気をつける

改行コードについてはWikipedia: 改行コードを参照してください。歴史的背景やその実践について書かれています。
簡単に書き表すと以下のようになります。Mac OS とはバージョン9までを指します。今はなかなかお目にかからないので、その心配は大丈夫かと思います。

Windows Mac OS Unix (OS X含む)
改行コード CR+LF CR(復帰) LF(改行)

Windows で作成したスクリプトを Unix 環境で実行すると command not foundNo such file or directory など予期せぬエラーが出る場合があります。これらの大抵の原因は改行コードによるものです。Windows 側で指定しなければデフォルトでは CR+LF で保存されます。狭義での"改行"とは Unix でも標準である LF のみです。
ゆえに Windows での #!/bin/bash<CR><LF>#!/bin/bash ですが、Unix などでは #!/bin/bash<CR> となるのです。つまり、改行判断の際に余った CR をコマンドの一部として解釈しようとするため、エラーが出るのです。

$ cat test.sh
#!/bin/bash
cat myfile

$ ./test.sh
cat: file\r: No such file or directory

一見、問題のないようなスクリプトですが実行してみると、エラーが吐かれてしまいます。

$ cat -A test.sh
#!/bin/bash^M$
cat file^M$

cat -A で確認してみると CR を表す ^M が可視化されています。ちなみに"改行"は $ です。

$ tr -d '\r' test.sh

で、削除できるので、ようやく解決です。

ちなみに、BSD 版の cat には -A オプションはありません。GNU 版オンリーです。BSD 版では cat -e で同様のことが出来ます。

\r = CR 復帰 キャリッジリターン ^M

\n = LF 改行 ラインフィード ^J

5. サブシェルを意識する

意識するという表現は、嵌ってしまいやすいが、逆に利用してやるとスマートな記述が可能になるという意味です。

嵌りやすい場合

(1) while ループで変数が変更されない

ファイルからデータを読みこませる方法として whileread を組み合わせることはよくあります。

while read LINE
do
    ……
    ……
done < file

注意するのはこの while ループはサブシェルで動作しているということです。このとき、while の入力をファイルからリダイレクトしているためです。
ループで利用した変数はループの外では無価値です。いくらループの中で何かに値を入れたりデータを編集しても、ループの外ではその結果を利用できません。

これを解決するには、

exec < file
while read LINE
do
    ……
    ……
done

として、exec コマンドにリダイレクトし、カレントシェルとしてやらなくてはいけません。

(2) while ループで変数が変更されない・2

上の例で紹介した while は少し前の時代の while です。今使用されている多くのシェルではこの仕様(バグ?)は修正されて「変数が変更されない」ということはなくなったようです。しかし、後述する汎用性を高めるという点で、(1) の方法が一番無難でしょう。

(3) パイプ先はサブシェル

find したファイルを files という配列に代入したいとします。

files=()
find . -type f | while read f; do
    files+=("$f")
done
echo "${files[@]}"

しかし、この結果で echo は何も出力しません。パイプの先はサブシェルで動作します。そもそもパイプとは標準出力をそのまま別のプロセスの標準入力に渡す仕込みのことです。「|」で多段に接続された各々のコマンドは別プロセスとして動きますので、そのいずれのプロセスも親である shell に変数を渡すことは出来ません。
パイプの場合、前段の実行が完了しなくても後段の実行が始まります。
 これは個々のプロセスが親である shell とは独立して動いていることを意味しますので、その while は sub shell と呼ばれる子プロセスによって実行されている訳です。
 shell 変数は一つのプロセス内でしか共有出来ませんから、その内容を他のプロセスから見ることは不可能です。

これを解決するには以下のようにするのが良いでしょう。

files=()
while read f; do
    files+=("$f")
done < <(find . -type f)
echo "${files[@]}"

diff のときによくやるあれです。プロセス置換と呼ばれています。この機能を使用しないで期待通りの結果を導きたい場合は、いったん外部ファイル(tmpfile のような)に出力して、それを入力としてやるくらいしかありません。しかしそれだと、外部ファイルの後始末の処理を組み込まないといけないので、すこし面倒だし、美しくないでしょう。

利用してやる場合

サブシェルでの変数の変更などは、カレントシェルに影響しません。これを巧みに使用すると IFS をサブシェルに閉じ込めることで、変更による影響をなくすことができます。

(IFS=$'\n'; echo "${array[*]}")

cd なども同じです。サブシェルでの移動は影響しないです。カレントディレクトリを一時的に変更して、すぐに戻りたい場合なんかに利用するといいでしょう。

( cd ~/src && tar cf - myproject ) | gzip -c > myproject.tar.gz

ファイルの上書きでもスマートな記述が可能になります。通常、ファイルを上書きしようとすると、空ファイルになってしまいます。

sh
b4b4r07:~$ cat file 
apple
orange
grape
banana
strawberry
cherry
pear
pineapple
mandarin
tangerine
melon
b4b4r07:~$ grep -v "e$" file >file #フィルタリングして上書き
b4b4r07:~$ cat file #何も出力されない(空)
b4b4r07:~$ 

コマンドオプションなどの標準で上書きできるのは、sortsed くらいしかありません。自前で上書きするには、いっかい一時ファイルに書き出して、リネームしなければなりません。

$ grep -v "e$" file >file.tmp
$ mv -f file.tmp file

しかし、サブシェルを利用することで簡単に記述できます。

sh
b4b4r07:~$ (rm file; grep "e$" >file) <file
b4b4r07:~$ cat file 
apple
orange
grape
pineapple
tangerine
b4b4r07:~$

しかし、rm を最初に行う関係上危険性が生じます。rm ゴミ箱スクリプトのエイリアスや関数を使用されることをおすすめします。

6. 汎用性を高める

シェルスクリプトは、Unix 系 OS のユーザにとって最も身近なプログラミング言語でもあり、その習得は必須の技能であると言えます。ほとんど改変を加えずに、様々なシステムでそのまま利用できるという汎用性の高さは非常に優れたものです。

シェルスクリプトの使用は以下の様な利益をもたらします。

  • システムの標準の機能

組み込み機器の問題などの様々な制約で、perlruby が利用できない場合においても Bourne Shell は使用可能なので頼もしいです。

  • 互換性が非常に高い

OS の種類やバージョンに依らず利用できるということは、システム間の互換性が非常に高いということです。シェルごとの若干の方言や、インストールされている外部コマンドの違いにさえ注意すれば、同じスクリプトを使いまわすことができます。移植の際の再コンパイル作業等も必要ありません。

などなど、数多くのメリットが有ります。しかし、Unix 系に関して些細な方言が存在するのを頭の隅にでもいれておかなければならない状況が来ると思います。「汎用性を高める」とは要するに、どのシステムにも含まれており、処理が異ならないコマンドだけを使っていれば、一番汎用性が高いことになります。つまり /bin/sh でシェルスクリプトを書くということです。しかしながら、そういうコマンドばかり使っているわけにもいかない(たとえば、echo コマンド。System V 系と BSD 系では動作が異なる)ので、その対処法についてなのですが、これに関しては記述量が多いため、別記事にしたいと思います。

7. 例外に関する対応

$? によって条件分岐したり、&&|| を利用しましょう。また、直前のコマンドにパイプが含まれる場合は、一番最後のコマンドの終了結果しか見てくれないので、$? を使う際は最初のコマンドのみをまず捕捉するか、bash などの限定表現にはなりますが、${PIPESTATUS[@]}を使いましょう。

8. @(#) を書き示す

シェルスクリプトの冒頭に、

# @(#) This script is ~.

といった記述がありますが、これは what コマンドで使用するためです。

what コマンドとはプログラムやそれを構成するモジュールのバージョンを調べるときに使用します。シェルスクリプトの場合もこれを書くことで対応させることができます。

$ what mycmd
mycmd
        This script is ~.

9. 環境変数 PATH を管理する

PATH 変数に .(ドット)を含ませることの危険性についてです。セキュリティ意識を高めるためにも重要な項目です。

シェルスクリプトやプログラムを新規に作ったとき、当然ためしに実行させてみるものです。abc というコマンドを作ったとして、それをそのディレクトリで abc とタイプしても not found と言われてしまうことがあります。それは PATH 変数に .(ドット)が含まれていないからです。ドットはカレントディレクトリを指すので、PATH にこれがセットされていない場合は、現在のディレクトリにある abc というコマンドを探してはくれません。よってテスト実行するときには、./abc というように「このディレクトリの」の部分を明示しなくてはなりません。

これを避けるために、PATH にドットを含めている人も少なからずいます。確かにこれがあるとないとでは使い勝手がずいぶん違うため、ある方がよっぽど快適に作業を行えます。

しかし、ドットを PATH に入れておくことは非常に危険なことであるということも認識しておいてください。ls というコマンドは非常に頻繁に利用されるかと思います。誰か悪意を持った人が、誰かのディレクトリの下に ls というシェルスクリプトを置いたとします。そのシェルスクリプトの内容が「rm -rf *」というものであったらどうなるでしょうか。その人は気づかないままリストを表示しようとして ls とタイプし、全部ファイルを消してしまうことにもなりかねません。

PATH の先頭にドットを置くからこのようなことが起こり得るのであって、PATH の一番最後に置けば問題ないと考えられるかもしれませんが、結局は同じです。ls については、これで問題回避できるかもしれませんが、l というファイルを作って、あなたがタイプミスするのを待っているのかもしれないのです。

自分のデータは自分で守る、ということをきちんとやらなくてはならない場合、「PATH 変数にドットを入れない」ことをまず最初に考慮します。

また、シェルスクリプトの中で PATH に相対パスを追加してはいけません。ディレクトリが変わったら相対パスも変わってくるので、PATH の中に不要なディレクトリのリストが含まれてしまうだけです。

まとめ

  • 可読性(保守性)
  • 汎用性(移植性)

この2点を意識するというのに尽きるかもしれません。可読性が増せば、コード自体が美しくなるし、保守もしやすくなります(シェルスクリプト自体の可読性が良くないというツッコミはなしで)。汎用性が高いコードを書けば、もちろん移植のしやすさに直結するでしょう。Unix の精神にも徹することができるわけです。9項目にはランク・インしていませんが、「``」の使用なんかも可読性の問題です。ダブルクオートや括弧が入り混じる中にバッククォートなんか使っていては目が大変です。「$()」を使用すると、スッキリ見やすいでしょう(でもこの書き方は一部では利用できないようなので、汎用性という点で少し劣りますね)。