bash によるオプション解析

  • 526
    Like
  • 5
    Comment

すこし記事が長いため、簡単なアウトラインを書いておきます。要点だけ掴みたい場合は、最終項の「まとめ」を読むのがいいかもしれません。

  • コマンドライン引数の一般的な解析手法
  • それぞれの特徴 〜 getopt と getopts の違い
  • getopts(メリット・デメリット)
  • getopt(メリット・デメリット)
  • 自前で解析しちゃう(唯一のデメリット)
  • まとめ

=============================

コマンドライン引数を処理する一般的な手法として、

  1. getopts
  2. getopt
  3. shift などで自力で解析

といった具合に、上から順に考えつくかと思います。getopt(3) は UNIX において、コマンドの引数を処理する一般的な C 言語のライブラリ関数です。それを用いて実装されたコマンドが getopt(1) です。Bourne シェル系だと内部関数になりますが、同系統の getopts という関数があります。

getopt だと外部コマンドであるため、色々と面倒なことがあるので、Bourne シェル系だと getopts を使った方が余計なことがなくて良いとされています。

それぞれの特徴 〜 getopt と getopts の違い

getopts

$ type getopts
getopts is a shell builtin

メリット

とりあえずgetopts は bash のビルドインコマンドであることです。ゆえに bash スクリプトとの親和性が高いとされます。getoptswhile ループと case 文を用いることが定石です。getopts は第一引数に、使用したいオプション文字列を受け付けます。もし、そのオプションが引数を取る場合はコロンを後に付けると、OPTARG 変数にその値が保持されます。疑問符が返ってきたときは、無効なオプションが渡された時です。break で抜けるか、使い方を表示して exit が一般的です。そして、最後に処理した引数の数だけ、shift し終了です。

説明文の羅列だけでは分かりづらいので以下、テンプレートです。

getopts.sh
#!/bin/bash

usage_exit() {
        echo "Usage: $0 [-a] [-d dir] item ..." 1>&2
        exit 1
}

while getopts ad:h OPT
do
    case $OPT in
        a)  FLAG_A=1
            ;;
        d)  VALUE_D=$OPTARG
            ;;
        h)  usage_exit
            ;;
        \?) usage_exit
            ;;
    esac
done

shift $((OPTIND - 1))

ここでの例では、-a オプションは引数を取らず、-d オプションは引数が必要としています。また、-h オプションまたはその他に関しては、ヘルプ表示を行うusage_exit 関数を実行し終了しています。

while getopts ad:h OPT にてオプション解析をしていきますが、ここで指名していないオプション(例えば -b など)を指定した場合やオプションに引数が必要なのに無い場合など、

./test.sh: option requires an argument -- d
./test.sh: illegal option -- x

といったようなエラー報告が行われます。ここで while getopts :ad:h OPT というように先頭にコロンを置くことで、こういったエラー表示は行われなくなりますので、自前でのエラー処理が行えます。

デメリット

  • コマンドの引数のあとでオプションが使用できない

bash スクリプトでオプション解析をする際には、bash 組み込みコマンドの getopts を使っていれば大抵の場合は問題ありません。しかし、getopts だと、コマンドラインの後半でオプションを指定できないことに気がつきました。 具体的には、

Usage: command.bash [-a] [-d dir] item1 item2 ... 

のような構文の場合に、-d diritem の後ろに指定しても、期待した処理がされません。

  • ロングオプションが使用できない

--help--version などのようなロングオプションが使用できないことです。一文字オプションでは文字の競合などが起こり、どのような規則で命名されたのか分かりにくく、これが必要なときもあります。

getopt

$ type getopt
getopt is /usr/bin/getopt

メリット

上で挙げた getopts のデメリットである、引数のあとにオプションを使用できない件と、ロングオプションの件を 条件付きで 満たしています。
一般に 使用される getopt のテンプレートは以下です。

getopt.sh
set -- 'getopt ad: "$@"'
if [ $? != 0 ]; then
    echo "Usage: $0 [-a] [-d dir]" 1>&2
    exit 1
fi
for OPT in "$@"
do
    case $OPT in
        -a) A_FLAG=1
            shift
            ;;
        -d) B_ARG=$2
            shift 2
            ;;
        --) shift
            break
            ;;
    esac
done

getopt の機能を拡張したものが getopts であるため、一般に使用する場合その優位性は getopts にあります。しかし、既存のシェルスクリプトでは getopt によって書かれたものも多く存在するので、すくなくとも読めたほうが良いです。

デメリット

getopt は 外部コマンドのため、バージョンや環境によって差異があります。それが、BSD 系と GNU 系の違いです。

BSD系 実装 GNU 実装
主なOS Mac OS X Ubuntu
ロングオプション 不可能 可能
引数のあとにオプション 不可能 可能

つまり、getopt は使用する環境によって大きく処理が異なります。
なら、BSD でも GNU のものを使えばいいじゃないか!ということで GNU 版 getopt のソースを入手しても、Mac OS X ではコンパイルは出来ません。

$ gcc -o getopt getopt.c
Undefined symbols for architecture x86_64:
  "_main", referenced from:
      start in crt1.10.6.o
ld: symbol(s) not found for architecture x86_64
collect2: ld returned 1 exit status
exit 1

Ubuntu 上ではコンパイルも実行も可能ですが、当然ながら Mac OS X 上では作動してくれません。

$ ./getopt
-bash: ./getopt: cannot execute binary file
exit 126
$ file getopt
getopt: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, not stripped

しかし、完全に Ubuntu でしか使用しないなら getopt の優位性は逆転します。getopts のデメリットを解消できるので、以下に使用テンプレを載せておきます。

getopt4ubuntu.sh
OPT=`getopt -o ab:c --long long-a,  long-b:,long-c -- "$@"`
if [ $? != 0 ] ; then
    exit 1
fi
eval set -- "$OPT"

while true
do
    case "$1" in
    -a | --long-a)
        # -a のときの処理
        shift
        ;;
    -b | --long-b)
        # -b のときの処理
        shift 2
        ;;
    -c | --long-c)
        # -c のときの処理
        shift
        ;;
    --)
        shift
        break
        ;;
    *)
        echo "Internal error!" 1>&2
        exit 1
        ;;
    esac
done

GNU 版の optget は、ロングオプションも使用でき、

$ getopt ab:c -b aaa -a bbb -c
-b aaa -a -c -- bbb

というように、-c オプションの前にあるコマンドの引数が、順番が入れ替えられて正常に出力される。

結果として、

  • Ubuntu なら getopts << getopt
  • Mac OS X なら getopts >> getopt

であると言えます。

重大なバグに関して

getopt には重大な落とし穴があります。それは、スペースや特殊文字が引数に含まれていた場合、正しく処理できないということです。あるシェルスクリプトで getopt を使った場合、空白や特殊文字は絶対に使うな、という注意書きが必要になります。これを何とかしのごうとするのは容易ではありません。

いっそ自前で解析しちゃう

これらの組み込み関数や外部コマンドを使用しない簡単な例として、以下のように書くことが一般的であるといえるでしょう。

shift.sh
for OPT in "$@"
do
    case $OPT in
        '-a' )
            FLAG_A=1
            ;;
        '-b' )
            FLAG_B=1
            VALUE_B=$2
            shift 2
            ;;
    esac
    shift
done

if [ "$FLAG_A" ]; then
    echo "Option -a specified."
fi

if [ "$FLAG_B" ]; then
    echo "Option -b $VALUE_B specified."
fi

このままでは、ロングオプションとコマンドの引数のあとでオプション指定の件が満たされていません。BSD 系の getopt 状態です。これらのデメリットを解消するため、実装していきましょう。

getopt(s) をめぐる差異は非常に厄介なので、shift しながらゴリゴリ処理してしまうパターン改。
以下、ソース。

shift2.sh
PROGNAME=$(basename $0)
VERSION="1.0"

usage() {
    echo "Usage: $PROGNAME [OPTIONS] FILE"
    echo "  This script is ~."
    echo
    echo "Options:"
    echo "  -h, --help"
    echo "      --version"
    echo "  -a, --long-a ARG"
    echo "  -b, --long-b [ARG]"
    echo "  -c, --long-c"
    echo
    exit 1
}

for OPT in "$@"
do
    case "$OPT" in
        '-h'|'--help' )
            usage
            exit 1
            ;;
        '--version' )
            echo $VERSION
            exit 1
            ;;
        '-a'|'--long-a' )
            if [[ -z "$2" ]] || [[ "$2" =~ ^-+ ]]; then
                echo "$PROGNAME: option requires an argument -- $1" 1>&2
                exit 1
            fi
            ARG_A="$2"
            shift 2
            ;;
        '-b'|'--long-b' )
            if [[ -z "$2" ]] || [[ "$2" =~ ^-+ ]]; then
                shift
            else
                shift 2
            fi
            ;;
        '-c'|'--long-c' )
            shift 1
            ;;
        '--'|'-' )
            shift 1
            param+=( "$@" )
            break
            ;;
        -*)
            echo "$PROGNAME: illegal option -- '$(echo $1 | sed 's/^-*//')'" 1>&2
            exit 1
            ;;
        *)
            if [[ ! -z "$1" ]] && [[ ! "$1" =~ ^-+ ]]; then
                #param=( ${param[@]} "$1" )
                param+=( "$1" )
                shift 1
            fi
            ;;
    esac
done

if [ -z $param ]; then
    echo "$PROGNAME: too few arguments" 1>&2
    echo "Try '$PROGNAME --help' for more information." 1>&2
    exit 1
fi

コマンドの引数($@ にあたるもの)は param 変数に保持されます。ゆえにコマンドが引数の必要を絶対としない場合は、下からの5行はコメントアウトしてください。

唯一のデメリット

GNU 版 getopt と肩を並べた以上のサンプルコードですが、組み込み関数 getopts・BSD 版 getopt・GNU 版 getopt では問題なかった、-ab というようなオプションの複数同時指定ができません。-a -b というようにバラさなければいけないのです。もちろん実装できなくもないですが、オプション解析がスクリプト本体の行数を超えかねないので、簡潔さを優先させ断念しました。

(以下 2014/10/10 追記)

と以前は書いていたのですが単純ですが以下の方法で -ab-ba というようなオプションの併記も可能です。

manually_complete_version.sh
declare -i argc=0
declare -a argv=()

while (( $# > 0 ))
do
    case "$1" in
        -*)
            if [[ "$1" =~ 'n' ]]; then
                nflag='-n'
            fi
            if [[ "$1" =~ 'l' ]]; then
                lflag='-l'
            fi
            if [[ "$1" =~ 'p' ]]; then
                pflag='-p'
            fi
            shift
            ;;
        *)
            ((++argc))
            argv=("${argv[@]}" "$1")
            shift
            ;;
    esac
done

bash の組み込みである [[ を使用して正規表現的に「含まれているか」を調べてオプションを設定しています。
実引数(オプションを除く引数)の数 argc や実引数 argv も取得できます。
よって直前の shift2.sh のスクリプトと組み合わせると、

  • ショートオプション/ロングオプション対応 $ command --long arg1 arg2
  • オプションの後付け可能 $ command -s arg1 arg2 --long
  • オプションの複数併記可能 $ command -ab -dc arg1
  • 環境に依存しない

これらの問題を克服して bash でオプション解析が可能になります。

まとめ

  • getopts は bash の組み込みコマンドである。ロングオプションは使用不可能、コマンドライン行後半でのオプション指定は不可能
  • BSD 版 getoptgetopts に劣るといえる
  • しかし、GNU 版 getopt はロングオプションが使用でき、コマンドライン行後半でのオプション指定は可能であるが、BSD 環境での使用はできない
  • getopt は外部コマンドのため、以上のような環境での違いもあり、重大なバグも存在するためその使用は推奨されない
  • 自前での解析は、数々の問題点をすべて解消できる。 しかし唯一、オプションの複数同時指定ができないという問題が発生する(2014/10/10 追記)
  • やっぱり自前解析が一番いいとおもう(2014/10/10 現在の結論)