私家版シェルスクリプト Coding Style


シェルスクリプトを書くときに僕が気をつけていることをまとめます。この文書は「シェル芸」や「ワンライナー」といった技芸・難読化には触れません。

シェルスクリプトを諦めるとき

シェルスクリプトでプログラムを書く利点はいくつも考えられます。POSIXで定義されたコマンド群のみを使って書けばすべてのUnix上で動作するソフトウェアができます。おおよその伝統的なUnixコマンドはC言語で実装されていますが、それらを呼び出しパイプとリダイレクトで糊付けするシェルスクリプトもまた高速です。手動でおこなっていた作業を自動化するさい、シェルスクリプトならひとつひとつの手順を上から順に貼り付けていくだけでおおよそは完了してしまいます。

一方シェルスクリプトで書かれたコードは読みづらく、この一点こそが中・大規模以上なシェルスクリプト実装のソフトウェアを諦める理由でもあります。一度書けばどのような環境でも動くことは、一度書けば二度とコードを読まなくてもいいことと等価ではないのです。保守をしなくてもいい理由にもなりません。使い捨てのコードでない限り、誰かがそのコードを一行でも読むときがやってくるのです。さらに、その誰かとは二ヶ月後の自分である可能性が大です。

Googleのシェルスクリプトスタイルガイドを読んでまとめてみた技術系記事はインターネット上で多数公開されています。XML形式の文書を英語で読みたくない方はそれらを参考にしてください。このスタイルガイドでは冒頭に、シェルスクリプトは小さなユーティリティか簡単なラッパーを書きたいときのみ使うこと("Shell should only be used for small utilities or simple wrapper scripts.")、シェルスクリプトのコードが100行を超える規模になったのであれば、代わりにPythonで書き直すべきであること("If you are writing a script that is more than 100 lines long, you should probably be writing it in Python instead.")が指摘されています。これは正論です。むしろ僕としては50行を超えそうな時点でPythonで書きたいところです。

以下の事項のうちひとつでも該当するのであれば、50行を超えた時点で別の言語で実装しましょう。

Bourne Shell

シェルスクリプトに使う言語はBourne Shellです。Shebangは#!/bin/shです。RHELかUbuntuでしか動かす予定がないのであればBashを使っても構いませんが、後述するPOSIX準拠ではなくなります。

標準でBashがインストールされていないOSも存在することだけは頭の片隅に入れておくといいでしょう。たとえばNetBSDやAIXなどです。

静的解析ツール ShellCheck

ShellCheckはHaskellで実装された、シェルスクリプトの静的解析ツールです。基本的にはShellCheckの指示にしたがってコードを書きます。よくある警告は変数を二重引用符で囲わないときに出るSC2086でしょう。どのように修正すればいいかはGitHubのWikiに詳しく書いてあります。警告が出たときはエラーコード(SCxxxx)のページを参照してください。

しかし、なにも考えずなんでもかんでも変数を二重引用符で囲ってしまうと、それはそれでシェルスクリプトが意図しない動作をする可能性もあります。コーディング中の動作確認を頻繁におこないましょう。

#!/bin/sh
# 典型的な例

list="a b c d"

# ループしない
for i in "$list"; do # 変数listを二重引用符で囲ったパターン
    echo "$i"
done
# 出力は
# a b c d

# このコードはスペース区切りでループする。
list="a b c d"
for i in $list; do # 変数listは囲われていない
    echo "$i"
done
# 出力は
# a
# b
# c
# d

ユニットテストフレームワーク shunit2

shunit2はシェルスクリプトのためのユニットテストフレームワークです。どのように使うかはREADMEを読めばおおよそ理解できます。

シェルスクリプトであるにもかかわらずテスト駆動開発を実践したいと思うほど大規模なコードは書かないかもしれません。しかしテストケースを考えてユニットテストを残しながらコーディングすると、後から関数の動作を見直したいときや、変数の考慮すべきパターンが増えたときに便利です。

インデント・1行あたりの文字数

インデントはスペース4つです。Googleのスタイルでは2つですが、インデントが浅すぎると目で見たときにソースコードの構造がはっきりと分かりません。僕は4つにしています。一方8つは長すぎました。

1行に何百文字も書いてしまうと、ディスプレイの大きさによっては文字が折り返してしまいコードの見栄えが汚くなることもあります。僕の場合、コードは1行あたり80文字で収めるよう努力します。ここで「努力する」と書いたのは、長い文字列を処理するときにどうしても80文字を超えてしまうこともあるからです。100文字は越えないようにしています。

パイプでコマンドの出力をつなげていくと80文字を超えてしまう場合もあります。そのときは複数行に分けて書きます。

#!/bin/sh
# 例
# これは分割しなくてもいいですが……
grep "Test" "test.txt" | sort | uniq
# 複数行に分ける場合
grep "Test" "test.txt" \
    | sort \
    | uniq

上記の例は各フィルタが短すぎて複数行に分ける利点がないかもしれませんが、ソースコード管理システムで差分をとったとき、どのフィルタをどのように変更したのかわかりやすくなります。

関数名

関数名はコマンド名との混同を避ける目的で接頭辞をつけます。たとえば"fn_", "_", "__"などです。"_"から始まるコマンドは少なくともPOSIXで定義されたコマンド群には存在しません。特定の接頭辞を付けておけば、第三者がコードを読んだとき関数だとすぐに見分けられます。

#!/bin/sh
fn_function()
{
    echo "prefix example"
}

_function()
{
    echo "prefix example"
}

__function()
{
    echo "prefix example"
}

コメント

以前は関数定義のさいに与えられる引数と期待する戻り値についてコメントを書いていました。理由は、シェルスクリプトは読みにくいため、コメントを書いておくとコードを読まなくても予期される動作を把握できるからです。

# _function -- Short description of the function
#
# Args:
#   $1 filter pattern
#
# Return:
#   filtered strings
_function()
{
    # process ...
}

しかし、最近はコメントにかける情熱を関数名を考えることへ注ぐようにしています。実装とコメントの食い違いにより「嘘のコメント」がいつの間にか生まれてしまうリスクを考慮しています。嘘のコメントはコード中のコメントの割合が多ければ多いほど発生しやすくなります。

エラー処理

エラー処理は厳格におこないます。シェルスクリプトは他の高級なプログラミング言語よりも、どの箇所でエラーが発生したのか把握が難しいからです。ファイルを開くとき・ファイルを削除するとき・ディレクトリを移動するとき……などは必ずそのファイルやディレクトリが存在するかをtest(1)で確認します。またコマンドの実行が成功・失敗したかどうかの判定はコマンドの戻り値を使います。

エラーメッセージを表示してexit 1する関数を定義しておくと便利です。

#!/bin/sh
# 現実的ではないが簡単な例

# _bomb -- Show given error message then exit
#
# Args:
#   error messages
#
_bomb()
{
    printf "%s\\n" "$@"
    exit 1
}

dir="./path/to/directory"

test -d "$dir" || _bomb "$dir: Not found"
cd "$dir" || _bomb "cd $dir: failed"
test -f "Makefile" || _bomb "$PWD/Makefile: Not found"
make || _bomb "make: failed"

この_bombという関数名はNetBSDのbuild.shから拝借しました。オリジナルのbombは次のようになっています。

# Copyright (c) 2001-2011 The NetBSD Foundation, Inc.
# All rights reserved.
#
# This is 2-Clause BSD License
bomb()
{
  cat >&2 <<ERRORMESSAGE

ERROR: $@
*** BUILD ABORTED ***
ERRORMESSAGE
  kill ${toppid}    # in case we were invoked from a subshell
  exit 1
}

POSIX

POSIXはUnix OSの標準規格です。POSIXではShellコマンドも定義されています。Utilitiesに書かれているコマンドのみを使ってシェルスクリプトを書けば、POSIX準拠の移植性の高いシェルスクリプトであると言えます。

書き捨てるシェルスクリプトか、実行環境が限定されているシェルスクリプトであればPOSIX準拠を考える必要はありません。しかし小規模かつ複数の環境での実行を想定する場合や、実行環境が商用Unixかつその環境でプログラミングやテストをするのが難しい(本番環境だからなるべく下手なことをしたくない、外部のサービスを契約して1日単位でレンタルする必要があり開発には向かない、……など)場合は、POSIXにしたがってコーディングすることでコードの動作を保証できます。

巨大なソフトウェアをシェルスクリプトで実装しようというモチベーションのひとつには、POSIX準拠の実装による移植性の担保があるように思えます。しかし前述した理由により、優れた移植性による恩恵よりもコードリーディングの難しさによる問題のほうがよほど目立つでしょう。僕は推奨しません。