最新の類似投稿としてシェルスクリプトのコーディングルール2014も併せてどうぞ。
2014/10/09追記
ぼくがシェルスクリプトを書くときに気にしていること、過去の失敗で書き留めたことを忘れないために。
1. グローバル変数は大文字
PATH
や HOME
など、環境変数が大文字なので、エクスポートする変数を大文字で書くという習慣は一般的であるような気がしますが、エクスポートする変数を抱えるシェルスクリプトを作成する機会が稀なので。
- グローバル変数は大文字
- ローカル変数は小文字
- エクスポートする変数も大文字
関数内からグローバル変数にアクセスする場合がありますが、やはり区別していると、可読性が増すような気がするのでお勧めです。
2. awk を知る
Unix 上にて文書処理をするときに、数多くのフィルタコマンド(grep、cut、tr、head、sort、uniq、sed、awk、wc、paste)を駆使して加工してゆくと思いますが、awk オンリーで解決できる場合が多かったりします。
例として家計簿を記した csv ファイルをあげます。
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 found
や No 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 ループで変数が変更されない
ファイルからデータを読みこませる方法として while
と read
を組み合わせることはよくあります。
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
ファイルの上書きでもスマートな記述が可能になります。通常、ファイルを上書きしようとすると、空ファイルになってしまいます。
b4b4r07:~$ cat file
apple
orange
grape
banana
strawberry
cherry
pear
pineapple
mandarin
tangerine
melon
b4b4r07:~$ grep -v "e$" file >file #フィルタリングして上書き
b4b4r07:~$ cat file #何も出力されない(空)
b4b4r07:~$
コマンドオプションなどの標準で上書きできるのは、sort
や sed
くらいしかありません。自前で上書きするには、いっかい一時ファイルに書き出して、リネームしなければなりません。
$ grep -v "e$" file >file.tmp
$ mv -f file.tmp file
しかし、サブシェルを利用することで簡単に記述できます。
b4b4r07:~$ (rm file; grep "e$" >file) <file
b4b4r07:~$ cat file
apple
orange
grape
pineapple
tangerine
b4b4r07:~$
しかし、rm
を最初に行う関係上危険性が生じます。rm
ゴミ箱スクリプトのエイリアスや関数を使用されることをおすすめします。
6. 汎用性を高める
シェルスクリプトは、Unix 系 OS のユーザにとって最も身近なプログラミング言語でもあり、その習得は必須の技能であると言えます。ほとんど改変を加えずに、様々なシステムでそのまま利用できるという汎用性の高さは非常に優れたものです。
シェルスクリプトの使用は以下の様な利益をもたらします。
- システムの標準の機能
組み込み機器の問題などの様々な制約で、perl
や ruby
が利用できない場合においても 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項目にはランク・インしていませんが、「``」の使用なんかも可読性の問題です。ダブルクオートや括弧が入り混じる中にバッククォートなんか使っていては目が大変です。「$()」を使用すると、スッキリ見やすいでしょう(でもこの書き方は一部では利用できないようなので、汎用性という点で少し劣りますね)。