scriptを読む

Disclaimer

以下の解説は私(一井)のもので、作者の意図を正しく表しているかどうかはもちろん、そもそも本当に正しい解釈かどうかも保証の限りではないので、利用にあたっては注意されたい。

準備

/bin, /usr/bin, /usr/sbinから50行以上100行以下のスクリプトを探す。
$ wc -l `file /bin/* /usr/bin/* /usr/sbin/* | sed -n '/script/s,:.*$,,p'` | awk '{if (($1 >= 50) && ($1 <= 100)) {print}}' | sort -n > scripts.list

たくさんある中から、/bin/whichを選んだとする。

説明用に、行番号をつけて表示する。
$ pr -T -n /bin/which
または簡単に
$ cat -n /bin/which

Emacsなどプログラマ用のエディタを使えば、文法で定義された言語要素は色を変えるなどして見やすく表示してくれる。

scriptの例

上であげた、/bin/whichのスクリプトは以下の通り。このコマンドが何をするものかは、$ man whichで調べること。
sh (dash) のmanページを参照しつつ、内容を見ていく。
     1    #! /bin/sh
     2    set -ef
     3   
     4    if test -n "$KSH_VERSION"; then
     5        puts() {
     6            print -r -- "$*"
     7        }
     8    else
     9        puts() {
    10            printf '%s\n' "$*"
    11        }
    12    fi
    13   
    14    ALLMATCHES=0
    15   
    16    while getopts a whichopts
    17    do
    18            case "$whichopts" in
    19                    a) ALLMATCHES=1 ;;
    20                    ?) puts "Usage: $0 [-a] args"; exit 2 ;;
    21            esac
    22    done
    23    shift $(($OPTIND - 1))
    24   
    25    if [ "$#" -eq 0 ]; then
    26     ALLRET=1
    27    else
    28     ALLRET=0
    29    fi
    30    case $PATH in
    31        (*[!:]:) PATH="$PATH:" ;;
    32    esac
    33    for PROGRAM in "$@"; do
    34     RET=1
    35     IFS_SAVE="$IFS"
    36     IFS=:
    37     case $PROGRAM in
    38      */*)
    39       if [ -f "$PROGRAM" ] && [ -x "$PROGRAM" ]; then
    40        puts "$PROGRAM"
    41        RET=0
    42       fi
    43       ;;
    44      *)
    45       for ELEMENT in $PATH; do
    46        if [ -z "$ELEMENT" ]; then
    47         ELEMENT=.
    48        fi
    49        if [ -f "$ELEMENT/$PROGRAM" ] && [ -x "$ELEMENT/$PROGRAM" ]; then
    50         puts "$ELEMENT/$PROGRAM"
    51         RET=0
    52         [ "$ALLMATCHES" -eq 1 ] || break
    53        fi
    54       done
    55       ;;
    56     esac
    57     IFS="$IFS_SAVE"
    58     if [ "$RET" -ne 0 ]; then
    59      ALLRET=1
    60     fi
    61    done
    62   
    63    exit "$ALLRET"

解読

1行目:#!行。このスクリプトを/bin/shで実行することを示す。現在のDebianでは/bin/shの実体はdashというシェル。

2行目:setコマンド。シェルの内部コマンドで、シェルの動作を制御する。
    -eオプション … コマンドが正常終了しなかった場合(終了コードが0でない)スクリプトを終了
    -fオプション … パス名の展開をしない

4〜12行目:文字列の末尾に改行をつけて出力するputs()という関数(C言語のライブラリにあるputs()にならったもの)を定義する。
 まず4行目でKSH_VERSIONという環境変数が定義されているかどうかをtestコマンドに-nオプション(文字列の長さが0より大なら真)をつけて試している。KSH_VERSIONが定義されていれば、print -rを使い(6行目)、定義されていなければ、printfというC言語風のフォーマッティングができる内部コマンドを使ってputs()を実装する(10行目)。
KSH_VERSIONksh (Kohn Shell)を判別するもののようだが、最近の(Debianで使えるものを含む)kshprintfを持ち、KSH_VERSIONを定義しないようなので、kshであってもprintfで実装されることになる。

14行目:作業用変数ALLMATCHESを0に初期化する。

16〜22行目:内部コマンドgetoptsを使ってオプションを処理する。"-abc"や"-a -b -c"というように与えられるUnix式オプションを次々に処理するためwhileループが使われる。ここで定義されているオプションは-aだけで、認識されたオプションはwhichoptsという変数に入る。
18〜21行目:whileループ内で、whichopts変数に入れられたオプション文字をcase文により処理する。
19行目:-aオプションの処理。ALLMATCHES変数を1にする。(初期値は0だった。)
20行目:-a以外のオプションが与えられた場合は、コマンドの使い方を表示して異常終了(終了コード2)する。なお$0にはコマンド名が入るのでUsage: which [-a] argsと表示されることになる。

23行目:オプションを処理した後の引数を調整する。環境変数OPTINDには「次の」引数の番号が入るので、処理したオプションの数はそれより1小さい。$(( ))内にある式は算術式として評価され、内部コマンドshiftで引数をその数だけずらす。例えば
$ which -a ls
となっていれば、-aが1番目、lsが2番目の引数であり、whileループが終わった時点でOPTINDは2となっている。OPTIND - 1 = 1だけshiftすることで、whichで調べたいコマンドの名前であるlsが1番目の引数であったかのようにこの後で処理されることになる。もしオプションがなければ、最初からlsが1番目の引数になる。

25〜29行目:このプログラムの終了コードを与える変数ALLRETの値を与える。もしこの時点で引数が0個(つまりコマンド行が
$ which

$ which -a
だったということ)なら値を1とし、そうでなければ0とする。引数が0個ならここで終了してしまっても良さそうなものだが、プログラムの終了を最後に持ってくるという(一般には良いとされる)プログラミングスタイルを守るためにこうしているのかもしれない。

30〜32行目:後に処理しやすくするために、コマンドサーチパスを表す環境変数PATHを整形している。具体的には、PATH:で終わっているとき(正規表現へのマッチを利用)に、:PATHの末尾に追加する。こうすることで、:をセパレータとしてPATHを分解したとき、元のPATH:で終わっていたときに意図されていたと思われる、カレントディレクトリを指す空文字列が得られることになる。46〜47行目参照。

33〜61行目:このプログラムの主処理を行う部分。引数で与えられたコマンド名を一つずつPROGRAMという変数に格納して、システム内に見つかるかどうかを調べていく。
34行目:コマンドが見つかったかどうかを示す変数RETを1に初期化する。後に、見つかったら0にする。
35、36行目:文字列を分解するときに使う区切り文字(セパレータ)はIFSというシェル変数に入れておくが、35行目で元の値をセーブして、36行目で:に変更している。PATHを処理するときは:で、33行目で引数を処理するときは元の値(スペース)を使うことになるので、このようにしている。
37〜56行目:いよいよPROGRAMが存在するかどうかを見る。case文を使い、分岐は38行目と44行目にある。
38行目:PROGRAMの文字列が/を含む場合。このときは、コマンド名がパス名として与えられているので、PROGRAMをそのまま文字列として存在を調べる。
39行目:if文の中にある[は実はtestという内部コマンドと同じもので、オプション-fはファイルがあるかどうか、-xは実行可能かどうかを調べるもの(][に対応して見やすくするためにつけられているが特に意味はない。このようなものをシンタックスシュガーという)。この二つが論理積&&でつなげられているので、ファイルが存在して実行可能な場合に真となる。
40、41行目:PROGRAMがコマンドとして存在したとき、名前を4〜12行目で定義したputsで表示し、RETを0にする(このコマンドが見つかったということ)。
44行目:38行目にヒットしなかった場合、すなわち、/を含まない場合。この場合は、サーチパス上にあるかどうかを調べる。
45行目:PATHから:で分けられる文字列としてディレクトリ名を切り出してきてELEMENTに入れ、一つ一つ調べていく。
46〜48行目:もしELEMENTが空文字列の場合(30〜32行目参照)、ELEMENT.(カレントディレクトリ)にする。
49行目:ELEMENTPROGRAMの前につないで、そのフルパス名に対応するコマンドが存在するかどうかを調べる。39行目参照。
50、51行目:もし見つかったら、コマンドのフルパス名を表示してRETを0にする。
52行目:やや奇妙な構文に見えるが、testコマンドを評価し、結果が偽なら45行目からのforループをbreakによって中断させる。ALLMATCHESはオプション-aが与えられたときに1にセットされる変数で、同じ名前のコマンドがサーチパス中の複数のディレクトリにある場合にすべてをリストするという働きを示す。||は論理和で、左側(一つ目)の式が真ならば必ず真となるので右側(二つ目)の式は評価されない。左側が偽ならば右側も評価される。
57行目:セパレータIFSを元に戻す。
58〜60行目:RETの値を調べる。今調べたPROGRAMが見つかったらRETは0になっていて、そのときはALLRETの値は変わらない。RETが1だった場合はALLRETを1にする。つまり、引数として与えられた複数のコマンド名を調べていくときに、全て見つかればALLRETは0、もし一つでも見つからないものがあると1となる。

63行目:全ての処理が終わったので、ALLRETの値を終了コードとしてこのスクリプトの実行を終了する。

レポート

この課題をレポートにする場合は、長過ぎず短すぎず、動作がわかりやすいコマンドを選ぶとよい。
上の「解読」では解説風にやや冗長に書いたが、もっと簡潔に論理を追うのでもよい。
Comments