シェルスクリプトに、あとからオプションを1つだけ足したくなることがあります。
そんなとき、つい if [ "$1" = "-v" ] のような分岐を書き足してしまう。
最初の1個なら数行で済みますし、それで動いてしまうのがまた厄介なところ。
ところが2個目、3個目と増えたあたりで、「-v -o dir は通るのに -vo dir だと落ちる」といった穴が空きはじめます。
bash には、その面倒をまとめて引き受ける組み込みコマンドが最初から入っているのです。
手書きの引数解析はどこで崩れるのか
自前でオプションを解釈すると、最初に引っかかるのが「まとめ書き」です。
Unix のコマンドは慣習として、-v と -o を -vo のように連結して書けます。
この展開を自分で実装しようとすると1文字ずつ切り出すループが要るので、本来やりたい処理より引数の解釈のほうが長くなるという逆転が起きてしまう。
次に引っかかるのが、オプションと位置引数の境界。
「どこまでがオプションで、どこからがファイル名か」を数えながら shift していくと、オプションを1つ足すたびに数え直しが発生します。
この2つは書き方の工夫で片づく問題ではなく、解析器の仕事。
だからこそ bash は、最初から解析器を持っているのです。
getopts の基本形は while と case だけ
getopts は、呼ばれるたびにオプションを1つずつ取り出して、指定した変数に文字を入れる組み込みコマンドです。
取り出すものが無くなると 1 を返して終わるので、while と組み合わせるだけでループが完成します。
#!/bin/bash
verbose=0
outdir="."
while getopts "vo:h" opt; do
case "$opt" in
v) verbose=1 ;;
o) outdir="$OPTARG" ;;
h) echo "usage: backup.sh [-v] [-o DIR] FILE..."; exit 0 ;;
*) echo "usage: backup.sh [-v] [-o DIR] FILE..." >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
echo "verbose=$verbose outdir=$outdir rest=$*"
getopts の第1引数 "vo:h" が、受け付けるオプションの一覧。
文字の後ろに付いたコロンが「このオプションは値を取る」という意味で、ここでは -o だけが値を要求します。
値は OPTARG という変数に入るので、case の中で受け取るだけ。
手元の Git Bash(GNU bash 5.2.37、Windows 上の MSYS2 環境)で動かすと、こうなりました。
$ bash backup.sh -v -o /tmp a.txt b.txt
verbose=1 outdir=/tmp rest=a.txt b.txt
$ bash backup.sh -vo /tmp a.txt
verbose=1 outdir=/tmp rest=a.txt
自前の解析でいちばん面倒だった -vo のまとめ書きが、何も書かずに通っています。
shift $((OPTIND – 1)) が残りを渡してくれる
末尾の1行は呪文に見えますが、やっていることは単純です。
OPTIND には「次に読むべき引数は何番目か」が入っていて、ループを抜けた時点ではオプションの直後を指しています。
そこから1を引いた数だけ shift すれば、$@ にはファイル名などの位置引数だけが残るという寸法。
実際、-v -o /tmp file1 を渡した直後の OPTIND は 4 で、shift 後に残ったのは file1 だけでした。
なお getopts は、オプションでない引数に当たった時点で解析を止めてしまう。
そのため bash backup.sh a.txt -v のように後ろへ回した -v は、オプションとして解釈されず rest 側に残ります。
エラー処理を自分で書くサイレントモード
何も設定しないと、未知のオプションや値の欠落については getopts 自身がメッセージを出す仕様。
さきほどのスクリプトに存在しない -x を渡すと、次のように怒られました。
$ bash backup.sh -x a.txt
backup.sh: illegal option -- x
usage: backup.sh [-v] [-o DIR] FILE...
英語のメッセージで構わない場面は多いものの、自作ツールのエラーだけ日本語にしたいということもあります。
そこで使うのが、オプション文字列の先頭にコロンを置く書き方。
while getopts ":vo:" opt; do
case "$opt" in
v) echo "verbose on" ;;
o) echo "outdir=$OPTARG" ;;
:) echo "エラー: -$OPTARG には値が必要です" >&2; exit 2 ;;
\?) echo "エラー: 不明なオプション -$OPTARG" >&2; exit 2 ;;
esac
done
先頭のコロンを付けると getopts は黙り、代わりに値の欠落なら :、未知のオプションなら ? を変数に入れて、問題の文字を OPTARG で教えてくれます。
これで -x を渡すと「エラー: 不明なオプション -x」、-o を値なしで渡すと「エラー: -o には値が必要です」が出るところまで確認できました。
使い方の案内を自分の言葉で書けるようになるので、人に渡すスクリプトほど効いてきます。
getopts で書けないこと
万能ではありません。
いちばん大きな制約は、–verbose のようなロングオプションを扱えないこと。
1文字のオプションだけを対象にすることは、POSIX の getopts 仕様にも明記されているのです。
ロングオプションが欲しい場合は、外部コマンドの getopt を使う道があります。
手元の Git Bash には util-linux 2.40.2 の getopt が入っていましたが、macOS 標準の getopt はロングオプション非対応など環境差が大きく、配布するスクリプトでは前提が崩れがちです。
配布物なら、1文字オプションだけで設計して getopts に寄せるほうが安全。
どうしてもロングオプションが要るなら、while と case で –verbose を直接受ける手書きに戻すほうが、外部コマンドの差異に振り回されずに済むはず。
最後に
getopts を挟むだけで、まとめ書きの展開も位置引数の切り出しも、スクリプトの外側へ追い出せます。
書く量としてはたった数行。
それでいて、あとからオプションを1つ足すときの心理的なハードルがはっきり下がります。
自分用の使い捨てスクリプトでも、while getopts の3行を最初に置いておくと後々ラクになりますよ。
引数を受け取れるようになったら、次は途中で失敗したときに気づける形にしておくと安心です。
以上です。











コメントを残す