シェルスクリプトの「お手本」が読みたいときには

シェルスクリプトを書くスキルを向上させたい、スクリプト作成の生産性をアップさせたいという場合、お手本となるうまくできたシェルスクリプトを読んでみるのは、良い方法です。たまに長めのシェルスクリプトを作成しようとするときなど、まず、お手本を探したりするものです。その場合、書籍よりも実際に動くスクリプトのほうが役に立つ場合もあると思います。

お手本になるスクリプトは、”/usr/bin”などのシステムディレクトリに数多く存在します。これらのシェルスクリプトは、例えば、文法を確認したり、”Usage”がそのまま流用できたりするなど、作成に欠かせない情報源となります。

以下のコマンドで、例えば、”/usr/bin”にあるシェルスクリプトを一覧表示できます。

$ find /usr/bin -exec file {} \; | fgrep 'shell script' | cut -d: -f1

その中で、オススメは”which”コマンドです。

whichコマンドは、環境変数”PATH”を参照して、実行可能なファイルのディレクトリパスを表示します。Linuxでは、シェルスクリプトで記述されています。

$ which which
/usr/bin/which
$ file `which which`
/usr/bin/which: POSIX shell script, ASCII text executable

Ubuntu 20.04の環境で、全体で63行のシェルスクリプトです(2021年2月時点)。

$ wc -l `which which`
63 /usr/bin/which

whichを読むのは、これからシェルを学習しようとする方には少し難しく感じるかもしれません。シェルには多くの「お約束」があるからです。一方で、基本的なシェルスクリプトを書くのに必要な文法も網羅されているので、良い教材になると思います。

本稿の末尾にソースコードを掲載しておきます。

少し脱線

whichのロジックにはとても難しい部分もあります。
もしよければ、クイズに挑戦してみてください。
一通り、whichのコードを読んだ方向けです。

Linuxのwhichのコードに、以下の部分があります。30行のあたりです。

case $PATH in
        (*[!:]:) PATH="$PATH:" ;;
esac

何をしているのでしょうか。

私の意見です

PATH環境変数の文字列の最後の文字が”:”(コロン)で終わっている場合、最後に”:”を追加しています。ただし、最後の2文字が”::”の場合は何もしません。整理するとこんな感じです。

“/bin:/usr/bin:” → ”/bin:/usr/bin::”(”:”が追加された)
“/bin:/usr/bin::” → ”/bin:/usr/bin::”(何もしない)
“/bin:/usr/bin” → ”/bin:/usr/bin”(何もしない)

それで、何のためにこんなことをしているかです。

まず、結論を言うと、シェル(”/bin/sh”)ではPATH環境変数の文字列が”:”で終わっている場合、カレントディレクトリをサーチパスに加わる仕様だからだと思います。PATHの文字列に、カレントディレクトリを表す”.”を省略できるのです。

① “/bin:/usr/bin:” → ”/bin:/usr/bin:.”と同じ
② “/bin:/usr/bin::” → ”/bin:/usr/bin:.”と同じ
③ “/bin:/usr/bin” → ”/bin:/usr/bin”(カレントディレクトリはサーチしない)

ここで、”/bin/sh”と書いている理由は、whichのスクリプトが以下の行で始まっているからです。

#! /bin/sh

ちなみに、sh以外に、bash,zsh,ksh,tcsh,cshを試してみましたが、実行結果は同じでした。

whichのロジックの中では、$PATHの文字列を、”:”をデリミタにして切出し、シェル変数ELEMENTに代入しています。その際、ELEMENTの文字列長が0の場合、カレントディレクトリを表す”.”を代入しています。45行あたりです。

   for ELEMENT in $PATH; do
    if [ -z "$ELEMENT" ]; then
     ELEMENT=.
    fi

ここで、ELEMENTの文字列長が0になるケースとは、$PATHの文字列に”::”がある場合です。ところが、上記①の場合は、”::”ではなく”:”で終わっているので、そもそも文字列として切出されません。しかし、”/bin/sh”の仕様は(おそらく)、「$PATHが単一の”:”で終わっていたら、カレントディレクトリをサーチする」なのです。(”/bin/sh”の実行結果から、左記のとおりであることを確認できます。しかし残念ながら、左記の仕様であることを記述した文書は見つけられませんでした。そのため「推測」になります。)

以上の理由で、「$PATHの文字列が単一の”:”で終わっていたら、カレントディレクトリをサーチするため”:”を加え、その後”::”を”.”に変換する」という解答になるかと思います。

シェルスクリプトの世界は、奥が深いですね。

最後に、Whichのスクリプトを掲載しておきます。

#! /bin/sh
set -ef

if test -n "$KSH_VERSION"; then
        puts() {
                print -r -- "$*"
        }
else
        puts() {
                printf '%s\n' "$*"
        }
fi

ALLMATCHES=0

while getopts a whichopts
do
        case "$whichopts" in
                a) ALLMATCHES=1 ;;
                ?) puts "Usage: $0 [-a] args"; exit 2 ;;
        esac
done
shift $(($OPTIND - 1))

if [ "$#" -eq 0 ]; then
 ALLRET=1
else
 ALLRET=0
fi
case $PATH in
        (*[!:]:) PATH="$PATH:" ;;
esac
for PROGRAM in "$@"; do
 RET=1
 IFS_SAVE="$IFS"
 IFS=:
 case $PROGRAM in
  */*)
   if [ -f "$PROGRAM" ] && [ -x "$PROGRAM" ]; then
    puts "$PROGRAM"
    RET=0
   fi
   ;;
  *)
   for ELEMENT in $PATH; do
    if [ -z "$ELEMENT" ]; then
     ELEMENT=.
    fi
    if [ -f "$ELEMENT/$PROGRAM" ] && [ -x "$ELEMENT/$PROGRAM" ]; then
     puts "$ELEMENT/$PROGRAM"
     RET=0
     [ "$ALLMATCHES" -eq 1 ] || break
    fi
   done
   ;;
 esac
 IFS="$IFS_SAVE"
 if [ "$RET" -ne 0 ]; then
  ALLRET=1
 fi
done

exit "$ALLRET"