Super Technique 講座

bash 超プログラム術

bash の解説なんて、ネット上には結構あったりするのだが、これをわざわざ公開しようというのは、次の理由による。

  1. 某ソフトハウスでのUNIX講座用に書いてしまったから。
  2. ネット上にある bash 解説だと、表面的な構文解説程度であり、きっちりスクリプト言語として使い倒すレベルの解説はあまりない。まあ、プログラミング言語として凝ったサンプルもやってみようじゃないの、というノリで割とディープに解説する。
  3. アクセスを増やすための人気取り(苦笑)。

まあ、そんな不純な目的による bash 解説である。とはいえ、日常的に使い慣れている bash であっても、「え、こんな使い方があったの!?」という発見もあることであろう。苦笑しながらでも読んでくれたまえ。だから、初歩的なリダイレクションなんかは解説しないからそのつもりで。

ちなみに参考書としたのはオライリー・ジャパン刊「入門 bash 第2版」である。この本は bash 2.x 以降のあまり使い倒されてはいない新しい構文をけっこうマジに解説している。読んでいくと「え、こんなスクリプト見たことない...」というサンプルが山のように登場する。とはいえ、本文書での解説は、基本的に筆者が「見たことのある」レベルで新しい機能も説明していこう。


まず、これって常識

シェルスクリプトとは、シェルによって解釈実行されるスクリプト・プログラムである。MS-DOS にもバッチファイルがあったが、UNIXのシェルの能力はそれと大きくかけ離れ、コマンドラインの便利ツールと言う域を越え、ちょっとしたプログラムを書くことも造作もない。

しかし、このシェルスクリプトが使うのは、UNIXのシェルの機能といくつかの外部プログラムの機能に過ぎない。つまり、UNIX のシェルは本格的なインタプリタであり、さまざまなシェルの内部コマンドと、それを補う実行ファイル形式のツール(よく sh-utils とか textutils と呼ばれる)を使ってさまざまな機能を実現している。

だから、基本的にはコマンドラインで実行する文をそのままファイルに保存すれば、シェルスクリプトになる。しかし、次のことをしておくと、さらに便利に使える。

  1. 実行パーミッションを立てて、PATH 環境変数によって検索されるパスに置いておけば、通常のプログラムと同様に使うことができる。「chmod 755 myscript」の要領で実行パーミッションを与えることができるのはすでに触れた。実行パーミッションを与えないのならば、「sh ./myscript」の要領で起動しなくてはならない。
  2. さらに、シェルにはさまざまな種類がある。それを特定するには、スクリプトの最初の行に次の行を入れておく。
    #!/bin/sh
    
    こうすると、実行パーミッションが立っている場合、最初に読み込んだ行がこの形ならば、その実行コマンドをスクリプトの実行コマンドとして扱う。つまり、このスクリプトは /bin/sh によって解釈実行される。たとえば、perl で扱うスクリプトの場合には、「#!/usr/bin/perl -Leuc」などとし、「-Leuc」のようにオプション(これはEUCで書かれた2byte文字列を「正しく」扱うオプションである)を与えることもできる。
  3. ちなみに、2バイト日本語コードを含むスクリプトを書いた時、プログラムローダが「間違えて」バイナリファイルだと認識してしまうことがある。一般論として、特に先頭行に「#!」による解釈指定がない場合、/bin/sh によるスクリプトであるとして実行されるが、日本語を含むスクリプトの場合には「#!/bin/sh」がないと誤解するのである... 2バイトコードは差別されてるな!

シェルの種類

さて、シェルにはいろいろな種類がある。これをざっと見てみよう。

bsh あるいは ash
Steve Bourne が書いたものを祖先とする古典的なシェル(Bourne SHell)である。デフォルトのシェルとしてよく扱われ、POSIX 標準でも基本的にこのシェルが標準とされている。つまり、bsh で書いておけば、大概のPOSIX環境で同じように動作する可能性が高い。しかし、古典的な bsh ではヒストリ機能・エリアスを欠くためにコマンドラインでは使いにくく、bsh 以降で採用された機能も多く欠く。だから現在それほど使われてはいないと見てよい。一応 Linux などのディストリビューションでは、ash というパッケージで SysV互換のフリーソフトの bsh が入っている。勿論、コマンドライン編集やヒストリ機能を欠いているためにログインシェルとしては大変使いにくいが、サイズは bash の1/6しなかく、やたらと軽いので使い途がないわけでもないし、互換性が重要なケースでスクリプト用に使ってみても良かろう。一応 /bin/bsh のリンク先は /bin/ash である(A SHell の略だそうだ)。

「エキスパートCプログラミング」(邦訳:アスキー、名著)で van der Linden が Bourne オリジナルの sh をハックした時の裏話を書いていたりするが、malloc(3) も使わずに独自のメモリ管理ストラテジを使っていたりするようなとんでもないプログラムだったようだ.... あと、ちなみに作者の Bourne はもともと Algol68 のプログラマだったようで、「if 〜 fi」とか「case 〜 esac」のように、キーワードのスペルを逆にして適用範囲を示すのは、Algol68 の仕様から来ている。更にオリジナルのソースのトンデモ度は、「マクロを使って Alogol 風制御構造をCの上で実現していた」という程に高いものだそうだ(フツーこんなことを解説しているネット文書はないよな)。

bash
bsh の代わりに使われているのが Bourne Again SHell である。GNUプロジェクトで書かれたフリーソフトであり、古典的 bsh に対してほぼ完全な上位互換にある。というわけで現在では単に「bsh」と呼んだときでさえも、普通にこの「bash」が使われていることが多く、Linux などのフリーUNIXでは単純に /bin/sh が /bin/bash にシンボリックリンクされている。このテキストでは、bsh との互換性を保つ範囲で、この bash をベースに解説をしていく。ちなみに bash 2.0 では大変多くの機能が追加されているが、まだそれほど使われているわけではないので、これらにはそんなに触れない(一応 /bin/bash2 でインストールされるから、開発元でも区別してるわけだし...)。
csh
BSD-UNIX で開発されたシェル(Bill Joy作)である。C言語文法に近い構文を採用しており、bsh 系との間の互換性はほとんどない。コマンドライン用のシェルとして使う人も多いが、セキュリティとリダイレクションにやや問題がないわけではなく、シェルスクリプト言語としてはほとんど使われない。現在普通に使われるのは、やはりフリーソフトの「tcsh」が多い。筆者はシェルスクリプトとして csh で書きたがるプログラマは変態だと思う(たまにいる...)
ksh
ベル研の David Korn によって、bsh, csh, tcsh などのフィーチャーをいろいろと採用して新たに作り直された商用のシェル(Korn SHell)である。それほど多くのユーザは現在ではいないようであるが、当初は先進的なシェルとして評価を集めた。現在 pdksh というパブリックドメイン版の ksh が各種フリーUNIXのディストリビューションに入っている。
zsh
比較的最近使われるようになったシェルである。ベースは ksh だが、結構無節操に bash や csh の機能を採り入れている。for(foreach) 構文だって、bsh 風でもcsh 風でもどっちでも動いたりする! プログラマブルな補完機能や再帰的グロビングなど、親切すぎて使うのが恐いような機能が多い。ネームングは「Z=究極の」シェルだそうだ。
/sbin/sash
これは特殊用途である。何か障害が起きたときに使うために設計されている。だから外部コマンドが起動できないケースに対応するため、組み込みコマンドとして「-ls,-cp,-mkdir,-mount,-sync,-dd」といった最低限必要なコマンド相当のものが用意されている。ダイナミックライブラリは一切リンクせず、すべてスタティックにリンクされているあたりは当然の配慮である。だから /sbin にあるわけだ。ただし、コマンドライン編集などは当然ないし、プログラミング機能はかなり欠いている。

そりゃ、ログインシェルとして /usr/bin/tclsh を使う人(Tcl/Tkインタプリタ)だっていないとは言えないだろうけど、物事には限度と慣習があることは事実だ。まあ、シェルくらい大勢に合わせて bash を使っても「順応主義者!」とは非難されないと思うよ。


応用:起動スクリプト

シェルスクリプトは伝統的に UNIX の起動時の初期化に大活躍をしている。つまり、起動時にカーネルが立ち上がると、さまざまなサービスを行うデーモンを起動していく。このために使われるのが、/etc/rc.d 内にあるシェルスクリプトである。Linux(RedHat系) の場合、次の手順で起動時のセットアップをしている。

  1. まず、/etc/rc.d/rc.sysinit が実行される。これはファイルシステムをマウントし、キーボードを設定し、ネットワーク環境が使えるように準備するなど、基本的なシステムのセットアップを行う。
  2. 次に /etc/rc.d/rc を実行し、各種のサーバを起動する。現在の Linux ではこれが二段構えになっており、ランレベルに合わせて /etc/rc[0-6].d ディレクトリがある。このランレベルに応じたディレクトリの中に「S数値」「K数値」で始まるリンクが置いてある。これらはそれぞれそのデーモンの起動/停止などの処理をする /etc/rc.d/init.d 内にあるスクリプトを指している。数値はそれらの実行順を示し、「S」は開始、「K」は停止を示す。だから、/etc/rc.d/rc はランレベルに合わせて /etc/rc[0-6].d ディレクトリのファイル名を検索し、適切なデーモンを適切な順番で起動する。
  3. 最後はそのマシンのローカルな設定を記述している /etc/rc.d/rc.local を実行する。

特殊なサービスをする場合には、rc.local に記述できることはいうまでもないが、システム管理者が新しいデーモンを起動しておくことにする場合、次のようにする。

  1. まず、実際のデーモンのプログラムを適当なディレクトリ(/usr/sbinなど)にインストールする。
  2. 起動・終了・再起動を引数によって処理するスクリプトを /etc/rc.d/init.d に置く。デーモンについて来る場合もあるし、自分で書いても問題はない。このスクリプトは「スクリプト名 start」でデーモンが開始し、「stop」でデーモンが停止し、「restart」で設定ファイルを読み直して再開する機能が実装されていれば良い。
  3. 適切なランレベル(1=single user mode, 2=not networking, 3=full multi user mode, 5=X11, 6=reboot)の /etc/rc.d/rc[0-6].d 内に、/etc/rc.d/init.d 内の起動スクリプトを指すリンクを適切な名前で張る。

だから、新しいサービスを開始する場合でも、rc スクリプトの変更を一切しなくても良いのである。また、シェルスクリプトの書き方を勉強する際には、この /etc/rc.d 内のスクリプトがどういう風に書かれているのかを読むのが一番勉強になる。皆さんも読んでやって欲しい。また、UNIXのコマンドの中には、初期設定をサボるために起動をシェルスクリプトでする応用もある。後で述べる。


変数とその評価

しかし、bsh 文法はかなり異色である。まったく新しい言語を憶えるつもりにならなければ、理解することは難しい。なぜなら、bsh は「テキスト置換マクロ言語」の部類に入る言語だからである。似たような言語としては、bsh 風に使えることを意図して設計された Tcl や、テキストフォーマッタである TeX マクロがある。また、m4 や cpp などのマクロ・プロセッサもそうである。要するに「変数には文字列が格納され、それを評価する時には他の文の中で展開される」というタイプの言語である。次の例を見てみよう。

#!/bin/sh
target=gif
${target}topnm test.gif > test.ppm

代入式「target=gif」は、シェル変数 target に「gif」という文字列をセットする。しかし、それを使う時には「$target」という風に先頭に「$」をつけてやることで、参照の意味になる。この時、その次にある文字列「topnm」と繋げて1つのシンボルにしたいわけで、空白をいれて変数名を区切るわけにはいかないので、「{ }」によって変数名の範囲を明示している。結果として3行目は「giftopnm test.gif > test.ppm」というコマンドになり、これは NetPBM の画像ファイル変換コマンドを呼び出して、GIF画像を PBM画像に変換している。

つまり、シェル言語での変数の内容は「単なる文字列」であり、それが「$」による参照の結果、その内容でコマンドライン行の中にシンボルを置換し、実際に実行すべきコマンドラインを生成する。

それゆえ、シェル言語の変数には「型」はなく、単なる文字列だけがデータ型である。そして、その置き換えによって実際に実行されるコマンドが生成されて、実行される。ちょうどC言語のマクロプロセッサと同じような雰囲気である。つまり、シェル言語は「文字面」しか見ずに、置換を行うだけの言語である。あとの評価機構は単にコマンドインタプリタとしての働きとまったく同じである。

ではこのようなシェル変数といわゆる「環境変数」とはどう違うのだろう。実体にはほとんど違いはないのである。しかし、1つだけ大いに違う点がある。それはシェル変数はサブプロセスに引き継がれないのに対し、環境変数はサブプロセス(サブシェルを含む)に引き継がれる。外部に引き継がれるようにするには、export 内部コマンドによってそれを指示してやれば良い。つまり、

MY_ENV_VAR='use at sub shell'
export MY_ENV_VAR

これだけのことである。環境変数はすべて大文字にする習慣がある。

また、シェル変数はそのシェル内部でしか有効ではないことに注意されたい。これは別スクリプトでシェル変数を定義し、その別スクリプトを実行しても、それはサブシェルで設定されることになり、一切親シェルに反映されることはない。これは困る。だから、現在の Bsh では「source」コマンドが用意されており、これは別スクリプトをあたかも include するように、現在のシェルによって別スクリプトを解釈する。

MY_ENV_VAR='parent'
source sub.sh       # sub.sh 内部で新しくセットした MY_ENV_VAR が
echo $MY_ENV_VAR    # 親シェルにも反映する

さらに bsh には変態的な変数評価がある。次のような書方である。

count=`expr ${count:-0} + 1`

これはシェル変数 count をインクリメントするのだが、最初は当然 count の内容は未定義だから空文字列である。だからこう書くと expr コマンドは困る。

% count=`expr $count + 1`
→
expr: syntax error

それゆえ、最初空文字の時には値を「0」とする、ということを変数評価の中に畳み込んでしまっているのが上の表現なのである。つまり、次と同様。

if [ "$count" = "" ]; then
    tmp=0
else
    tmp=$count
fi
count=`expr $tmp + 1`

まあ、こんな「文字列演算子」と呼ばれる機能があったりするが、使うとスクリプトが短くなるな(けど、初心者はとまどうぞ)。


引数の変数

シェルスクリプトもやはり引数を与えて起動できる。この引数は特別なシンボル「$0, $1, $2, ...」で参照できる。つまり、シェル変数「$0」にはその起動されたスクリプト名が入り、「$1」には第1引数が入る。以下同様であるが、沢山の引数を処理するに便利な「shift」コマンドがあり、あまり大きい数の引数変数は使わないことが多い(後述)。引数の個数は「$#」で取得できるし、引数の全体は「$*」で取得できる(ただし、$0は別扱い)。

あと、憶えておくと大変便利なのは、$$ という変数がそのシェルのプロセス番号を保持していることである。これは一時作業ファイルを作成するときにとっても役にたつ。一意なファイル名が欲しい時に、UNIX環境で一番一意なリソースは何か、というとそれはプロセス番号なのである。だから、今のシェルのプロセス番号を付加した作業ファイルに書き出すようにすれば、仮に複数そのスクリプトが起動したとしても、内容がごっちゃにならないわけだ! さあ、憶えたね! (わざわざ強調するのは、当然これが一番探しにくく、「あれ、シェルのプロセス番号ってどうやって取得するんだっけ?」と悩み易いものだからだよ! なんて親切なんだ....)

ちなみにクイズ。次のコマンドの実行結果はどうなるだろう?

#!/bin/sh

echo "parent=$$"
( echo "subshell=$$" )
echo `echo "backquote=$$"`
{ echo "brace=$$" }

知識のある人は、「あれ、(〜) はサブシェルの起動じゃなかったけ? だったら、サブシェルのプロセスIDでこれだけ違うんじゃないのかなあ」とか思うかも知れない。実はこれらの結果はすべて同じ(すべて親シェルのプロセスID)である。その理由は...(See Next!)


コマンドラインの評価順

この評価の順序を先にまとめておく。

  1. 1行の入力から、トークンを抜き出す。
  2. 最初のトークンが制御用の「キーワード」であるかどうかを判定し、もし1行で終らずに複数の行を前提とするフロー制御の「複合コマンド」であればその準備をする。たとえば、if 文がないのに、then, else が現われたら、ここでエラーになる。サブシェルの起動もここで扱われる。
  3. 最初のトークンがエリアスならば置換する。エリアスは「alias ll='ls -l'」というように定義しておくと、「ll」が最初のトークンに現われた時にそれを展開する。csh から採用された機構である。まあ、エリアスは難しい機能じゃないから説明は省略ね。だけど、このように評価が早いから、適当に個人用の .bashrc などにエリアスで個人コマンド定義を書いておくのだ、ということだけは記憶しておいた方がいいな。
  4. ブレース展開をする。これの説明は省略するが、異常に賢いワイルドカード展開のようなものである。
  5. チルダの展開をする。~/test ならば、環境変数 $HOME を見て、それを補うし、~test ならば、/home/test などに展開する。
  6. $ で始まる変数を、その内容に展開する。[答え、変数展開の評価順は早いのだ...]
  7. コマンド置換を行う。これはコマンドライン自体の実行に先だって、一部分だけを先に実行し、その標準出力への出力があたかも最初から書いてあったかのように扱う機構である。このコマンド置換は古典的には ` (バッククォート)で行っていたが、最近の bash では 「$()」で行う。これは入れ子にする時の対策と、読みやすさに関する配慮である。たとえば、
    filesize=`cat $file | wc -c`
    # あるいは
    filesize=$(cat $file | wc -c)
    
    で、filesizeシェル変数に $file で示されるファイルのサイズが入る。
  8. $((4+5)) のような数値演算式を評価する。これは古典的にはサブプログラムの expr で評価していた。つまり、
    filesize2=$(($filesize * 2))
    filesize3=`expr $filesize \* 2`  # * がワイルドカード展開されるのを防ぐ 
    
    のようにすると、数字文字を数値として扱って計算がなされる。
  9. 変数置換、コマンド置換、数値演算式の結果をコマンドラインに戻して、さらに解析を続ける
  10. *,?,[] のワイルドカード文字を展開する。
  11. では先頭の「コマンド」の正体はなんだろうか? 先頭キーワードは次の順で検索されて、対応する実体を探す。
    1. function で定義されるシェルの関数
    2. シェルの内部組み込みコマンド。たとえば、cd など。
    3. 環境変数 PATH から検索される外部コマンド
  12. 入出力リダイレクションを設定して、コマンドを実行する。

さらに「'〜'」によるクォーテーションをした時には、1から10までのステップをすべて省略する。また、「"〜"」によるクォーテーションをした時には、1〜4、9〜10のステップは実行されない。これによって、コマンドの一部を展開せずに取り扱うことができるのである。


リダイレクションの補足

こんな文書を読む人はリダイレクションなんてきっと知ってるな、だから省略。フツーにコマンドラインで使うリダイレクションはすべてシェルスクリプトで利用できる。しかし、ほぼスクリプト専用と言って良いリダイレクションもあるので、それを説明しよう。

ヒアドキュメント

まず「ヒアドキュメント」である。これは複数の行にわたる入力が必要なとき、いちいち echo 文で外部ファイルに書き出して、さらに読み込むなどという不効率なことをせずに済むために加えられたリダイレクションである。たとえば複数行の内容をメールする場合、

echo "Subject: How are you?" >tmp.$$
echo >>tmp.$$
echo "Hi! This is K. Sugiura." >>tmp.$$
echo "How are you, Mr. $user? " >>tmp.$$
echo "I am fine." >>tmp.$$
/bin/mail $user <tmp.$$
rm tmp.$$

なんて書くのは大変ダサい上に、一時ファイルの管理でトラブる可能性を完全には否定できない(昔の MS-DOS の command.com スクリプトってこんなんばっかだったけど...)。だから、こういうテキストはシェルスクリプトの中で直接管理して、直接リダイレクトできるとうれしい。これが「ヒアドキュメント(今ここにあるドキュメント.. perl なんかにもあるな)」だ。

/bin/mail $user << EOF
Subject: How are you?

Hi! This is K. Sugiura.
How are you, Mr. $user? 
I am fine.
EOF

このとき、複数行にわたる /bin/mail への入力は、EOF で最後を区切って「そこにある」。このヒアドキュメントの中でもやはり変数の置換は行われる。この変数置換を抑制するには、「/bin/mail $user << 'EOF'」のように最後を区切るシンボルをクォートしてやる。


標準エラー出力の扱い

エラー出力が標準出力と分離されているのは、UNIXのパイプライン設計の成功の大きな原因となっているが、しかしスクリプトでは、標準エラー出力を独立して扱うことを考慮してスクリプトを書かなくてはならない場合がある。たとえば、cron ジョブに指定するスクリプトの場合、スクリプトは端末とは切り離されたデーモンのコンテキストで実行されるので、標準エラー出力がメールとして配信されることにもなる。これはいかにもうっとおしいことがある。だから、シェルスクリプトでは標準出力と同時に標準エラー出力もきっちりと処理することが多い。また、「grep」のように一致するパターンがあればそれを表示して戻り値として0を返すが、一致パターンがない時には1を返すプログラムを使うとき、一致パターンの表示には関心がなく、戻り値の結果だけが必要なこともある(ホントは -quiet オプションが使えるが)。このような時に、標準出力も標準エラー出力も /dev/null に捨ててしまうことも、スクリプトではよくなされる。

たとえば次の通り。

if `grep $pat $file >/dev/null 2>/dev/null` ; then

また、標準出力と標準エラー出力を合体させてパイプに送りたい場合もある。これはエラー出力(2) を標準出力(1) へとファイルデスクリプタのレベルで合体させる指定をする。

% find / -name '*.java' 2>&1 | less

この例だとローカルマシンの上にあるすべての「*.java」ファイルを検索するが、パーミッションに関するエラーもすべて見たい(一般ユーザなので)ときにこれを使う。これは実は csh だと実に簡単なのであるが、bsh ではちょっくら複雑な書き方をするために、あまり知られているとは言えなかったりするのだ。

# csh だとこう。
% find / -name '*.java' |& less 

勿論、

% find / -name '*.java' 2| less | less

でうまくいくはずがないことは明白である(less の出力をさらに別の less で受けてしまう)。


サブシェルなどとリダイレクション

シェルスクリプトでは、ちょっとした作業をまとめてそのシェルから起動される、子シェルに実行させることができるのは周知の事実である。このようなサブシェルを使うメリットは、要するに複数のコマンドをまとめてリダイレクションする、ということにある。たとえば、次の通り。

(echo "Subject: Disk is full?"; echo; date; 
 echo "現在のディスク利用状況"; df) | mail $user

「;」で複数のコマンドを連結できることなんて、わざわざ説明するまでもないな。これは要するに複数のコマンドの実行結果をまとめてパイプに送り、結果としてメールする、ということをしているのである。この時、「( )」で囲まれた部分は、子プロセスとして起動されたシェルによって実行されているわけだ。だから次のコマンドと等価であり、その略記に過ぎない(-c オプションは引数文字列を実行コマンドとして解釈する)。

/bin/sh -c 'echo "Subject: Disk is full?"; echo; date; \
echo "現在のディスク利用状況"; df' | mail $user

しかし、これは古典的な方法である(勿論だから移植性は良い)。最近の bash などでは、いちいちサブシェルを別プロセスとして起動するのは効率が悪い、という理由で、「複数コマンドをまとめてリダイレクトする」機能が実装されている。勿論、後述する関数によってまとめるのも手ではあるが、「{ }」でまとめると、サブシェルではなくそのシェルによってコマンドをまとめてリダイレクトできるのである。(ちょっとだけ注意... どうやら {の後と、} の前には空白を入れた方が無難のようである...)

{ echo "Subject: Disk is full?"; echo; date; 
  echo "現在のディスク利用状況"; df } | mail $user

これを「コマンドブロック」と呼ぶ。一応 bsh でも使えるようだが、あまり見かけないなあ。また、更に凄いことには、if や for, while などの制御構文の最後の部分(要するに fi とか done など)にリダイレクションを書くことさえできる。たとえば次の通り。

while read ip host alias; do
  echo "IP: $ip  host: $host  alias: $alias"
done </etc/hosts

これは /etc/hosts が3つの空白で区切られたカラムを持つので、それを分解して標準入力から読み込み(read)、シェル変数(ip, host, alias)にそれぞれセットする。そして整形出力をするわけだ。ちょっと「おいおい」というような機能である。


if文とcase文

さて、シェルスクリプトはプログラムである。ということは制御構造を持つわけであるし、制御構造には変数や関数の評価がつきものである。Cプログラムで void main() ではなくて int main() で宣言するように、と言われるのは、パイプラインでの利用だけではなくて、スクリプトから利用した時の結果(=コマンドの戻り値)を評価して、そのコマンドが成功していれば何かをし、失敗していればそのようにする判断分岐ができるからなのである。

実行結果の取得と if 文

一般にコマンドの戻り値は特殊変数「$?」で参照できる。しかし、この特殊変数を参照するよりも、if 文の中で判定することの方がよくなされる。シェル言語の if 文はかなり特殊で、文法的にも不寛容なところがあるので、正確に理解して欲しい。

if cat $file >/dev/null; then echo here; else echo absent; fi

もし、「cat $file >/dev/null」が成功(戻り値=0:ファイルがある)ならば、「echo here」が実行され、失敗(戻り値≠0:ファイルがない)ならば「echo absent」が実行されるひな型である。これはわざわざ1行で書いた。なぜなら、どこで改行を入れるのかにシェルは神経質である。「;」で示される位置は改行を入れることができる。

if cat $file >/dev/null
then echo here
else echo absent
fi

また、then の後、else の後も入れることができる。

if cat $file >/dev/null
then
    echo here
else 
    echo absent
fi

つまり、if, then, else, fi がコマンドラインの先頭にキーワードとしてこなければならないのである。これを原則として理解して欲しい。


test コマンドによる文字列の比較とファイル属性

また、コマンド実行の成功/失敗による if 文だけではなく、シェル変数の内容や、ファイルの属性について判断分岐をすることもできる。これには特殊な構文 [ ] を使うが、この判定は内部組み込みコマンド(同内容の外部コマンドも /usr/bin/test にある)「test」の判定と同じである。つまり、

% test "" = ""
% echo $?
0
% test "" = "s"
% echo $?
1
% [ "" = "" ]
% echo $?
0
% /usr/bin/test  "" = "s"
% echo $?
1

の関係にある。[ ] 演算子を使う場合には判定式との間に空白がなくてはならないのが落し穴である。注意されたい。だから、if 文と組み合わせると次のようになる。

if [ "$var1" = 'test' ];
then echo 'string is SAME'
else echo 'string is NOT same'
fi

このとき、変数参照をすべて "〜" で囲むのがコツである。これは変数が未定義のために展開されないと、test コマンドに対する引数が不足してしまうからである。注意されたい。

test コマンドの文字列比較には次のものがある。

表現真($? == 0)を返す場合
str1 = str2文字列の一致
str1 != str2文字列の不一致
-n str文字列が空ではない
-z str文字列が空である
str1 -gt str2数値表現として、str1 > str2
str1 -ge str2数値表現として、str1 >= str2
str1 -lt str2数値表現として、str1 < str2
str1 -le str2数値表現として、str1 <= str2
!結果の真偽を逆転する

また、ファイルに対してその属性を判定できる。次のような演算子がやはりある。

表現真($? == 0)を返す場合
-d file ファイル名がディレクトリである
-e fileファイル名が存在する
-f fileファイル名が通常ファイルである
-r fileファイル名がパーミッションの上で読むことができる
-s fileファイル名が存在し、かつ空ではない
-w fileファイル名がパーミッションの上で書き込むことができる
-x fileファイル名がパーミッションの上で実行可能である
-O fileそのファイルの所有者である
-G fileそのファイルの所有者と同じグループに属する
file1 -nt file2更新時間を比較し、file1 の方が新しい
file1 -ot file2更新時間を比較し、file1 の方が古い

これらを使うと、かなりいろいろなことができる。


case 文

C言語などの switch 文に相当する case 文も存在する。次の通り。

case "$#" in
   0) echo 'No argments';;
   1) echo '1 argment';;
   2) echo '2 argments';;
   *) echo 'more argments'
      echo "argnum = $#";;
esac

これは引数の個数を判定する case 文である。「;;」が break 文の役割を果たしていることに注意。


for文とwhile文

for文

シェル言語はプログラム言語なので、繰り返し処理ができるのは当然である。繰り返し構造の1つが for 文である。しかしこれはいわゆる「foreach構文」である。つまり、

for i in 1 2 3 4 5
do
    echo $i
done

の構造を持ち、順次シェル変数「i」に1から5の値が代入されてdo〜doneブロック内を実行する。もちろん、数値でなくても構わない。現在のディレクトリ内でディレクトリだけを表示するのならば、次のようにする。

for file in *
do
   if [ -d $file ]; then ls -d $file; fi
done

つまり in に続くリストがワイルドカード展開されて、カレントディレクトリのすべてのファイルになる。それらを1つ1つシェル変数 file に代入し、do〜done ブロックを繰り返し実行しているのである。

だからこれは引数オプションの処理にも使える。多少実用的な例として、次のスクリプトを考えてみよう。NetPBM を使って、複数の任意の画像ファイルを GIF に変換する。コマンドラインは次の通り。

% convert.sh [-bw] [-gray] [-color] 画像ファイル...
-bw
2値白黒画像に変換
-gray
グレイスケールに変換
-color
カラーで変換(出力がGIFなので、どうせ256色モードしかない)

ます、利用しているコマンドを解説しておく。

ppmtopgm
フルカラーのPPM画像をグレイスケールのPGM画像に変換する
pgmtopbm
グレイスケールのPGM画像を2値白黒のPBM画像に変換する
pbmtopgm 4 4
2値白黒のPBM画像をグレイスケールのPGM画像に変換する。引数は4×4pixel のエリアから16pixel分のグレイスケールの明るさを決定することを示す。
pgmtoppm white
グレイスケールのPGM画像をフルカラーのPPM画像に変換する。引数はPGMの上で白を示すピクセルを、何色に変換するかを指定する。この場合、単に白に直している。
ppmquant 256
GIFは256色モードしかないので、オリジナルの色数を256色に減らす。
basename $arg
ファイルパス名から、ファイル名だけを取り出す。
sed 's/^[^.]*\.//'
sed は汎用の置換フィルターである。正規表現を使って置換を行う。このケースではファイル名から拡張子だけを取り出している。
sed 's/[^.]*$/gif/'
このケースでは拡張子を gif に変換している。
ppmtogif
PPM画像をGIF画像に変換している。
eval 文字列
文字列をシェルコマンドとして実行する内部組み込みコマンド。

スクリプトはこうである。

#!/bin/sh

conv='| ppmquant 256 ' # デフォルトの変換(カラー→カラー)に対応
pbmdir=/usr/bin   # PBMユーティリティのファイル名を検索するので
for arg in $@; do   # 引数によるループ
    case "$arg" in
    # 各オプション処理は、単に変換フィルター conv をセットするだけ
    -bw)  conv='| ppmtopgm | pgmtopbm | pbmtopgm 4 4 | pgmtoppm white';;
    -gray) conv='| ppmtopgm | pgmtoppm white';;
    -color) conv='| ppmquant 256 ';;
    -*) echo "iilegal option $arg"; exit 1;;  # 異常なオプションの検出
    *)  # ファイルが指定されるので、現実の処理をする
        name=`basename $arg`  # name にはパスを削除したファイル名が入る
        type=`echo $name | sed 's/^[^.]*\.//'`  # 拡張子を取り出す
        newfile=`echo $name | sed 's/[^.]*$/gif/'` # 出力ファイル名をつくる
        # 拡張子から変換ツールを推測する。つまり、PBMユーティリティのあるパスに
        # 拡張子名toppm か、拡張子名topnm があれば、それが変換のための
        # フィルターであると考える。
        if [ -x "$pbmdir/${type}toppm" ] ;  
           then maker="$pbmdir/${type}toppm";
        elif [ -x "$pbmdir/${type}topnm" ] ;
           then maker="$pbmdir/${type}topnm";
        else 
           echo "Not here $maker"; exit 1;
        fi
        # 作り上げたコマンドラインを eval で評価して実行する。エラー出力は捨てる
        eval "$maker $arg $conv | ppmtogif > $newfile" 2> /dev/null
        echo "created $newfile"
        ;;
    esac
done

たとえば、「convert.sh my.bmp」の場合には次のようなコマンドラインが作り上げられる。

/usr/bin/bmptoppm my.bmp | ppmquant 256  | ppmtogif > my.gif

他の例も一括して示す。

% convert.sh my.tiff
→ /usr/bin/tifftopnm my.tiff | ppmquant 256  | ppmtogif > my.gif
% convert.sh -gray my.tga
→ /usr/bin/tgatoppm my.tga | ppmtopgm | pgmtoppm white | ppmtogif > my.gif
% convert.sh -bw my.tiff
→ /usr/bin/tifftopnm my.tiff | ppmtopgm | pgmtopbm | pbmtopgm 4 4 
                                       | pgmtoppm white | ppmtogif > my.gif

while文とuntil文

当然、white文もある。until文もあるが、これは while文判定条件が偽の場合に実行されるものに過ぎず、両方とも事前判定型である(事後判定ループ構造はない)。

先程の for 文の例だと、実はこんなコマンドラインも有効である。

% convert.sh -bw test1.gif -color test2.gif test3.gif -bw test4.gif

単にオプションが切り替わって行くため、直感通りに実行されるから、これはこれで有用である。しかし、すべてオプション指定を前に集めて指定し、オプションではないファイル名が現われたらそれ以降はオプション指定不可なのが、UNIXのコマンドラインの常道である。このかたちに書き換えてみよう。

引数参照は $0〜 で可能だが、この時に while 文の中で使う便利なコマンドがある。それは「shift」コマンドである。この「shift」コマンドは現在の $1〜の順番を1つ先にずらす。だから shift を実行すると、今までの $2$1 になり、$3$2 になる。これを使うと現在の引数の位置を気にせずに処理ができる。ただし、起動されたシェルスクリプト名が入っている $0 はこの shift の影響を受けない。

また、以下のスクリプトでは while, until, for などのループから脱出する break を使っている。感覚的にはC言語の break と同じであるが、case 文が break を使わないので、case 文中にある break は、case 文ではなくて while 文を抜ける違いがある。

# 前略  shift を使うので、引数参照は $1 で行うことで統一できる
while [ -n "$1" ]; do
    # 今回の case 文には shift が入っている
    case "$1" in
    -bw)  conv='| ppmtopgm | pgmtopbm | pbmtopgm 4 4 | pgmtoppm white'; shift;;
    -gray) conv='| ppmtopgm | pgmtoppm white'; shift;;
    -color) conv='| ppmquant 256 '; shift;;
    -*) echo "iilegal option $1"; exit 1;;
    *)  break;;  # while ループを抜ける
    esac
done
# この時、$1 にはオプションが終った後の実ファイル名が入っているはずである
while [ -n "$1" ]; do
    name=`basename $1`
    # 略
    echo "created $newfile"
    shift  # shift して次のファイルを $1 にする
done

数値計算

シェル言語は数値を扱うこともできる。bash では特別な数値計算用コマンドが用意されているが、互換性を考えて bsh でも扱うことのできる、外部コマンドによるやり方のほうが一般的である。計算を行う外部コマンドは「expr」である。これの使い方は次の通り。

% expr 4 + 3
7
% num=5
% expr $num + 3
8
% expr 4 \* 3   # * がワイルドカードとして扱われるのを防ぐ
12

通常の四則演算の他に論理演算やパターンマッチングもあるが、四則演算だけが普通は使われる。当然、シェル変数にセットするには、バッククォートによるコマンド置換を利用する。では、C言語などの通常の for 文をこれで実現してみよう。連番を振られたファイルがあり、それらを連番の数値順に何かの処理を行わせるスクリプトである。ls コマンドでは文字順に順がなってしまい、「test1.dat」の次は「test11.dat」になってしまう。そうではなくて、正しい数値順にアクセスする。

仕様は次の通り。

numproc.sh -pattern <file%.exp> [-min <start>] [-max <end>] [commands...]
たとえば、
numproc.sh -pattern test%.txt ls -l
-pattern file%.exp
必須。検索するパターンを指定する。この時「%」で示される部分が連番の数値に展開される。
-min start
省略可。開始する数値を指定する。デフォルトは1とする。
-max end
省略可。終了の数値を指定する。無限ループを回避するために、このオプションが指定されていない場合には、初めて連番の数値に相当するファイルが存在しない場合に停止する。-max オプションが指定されていれば、連番数値に相当するファイルが存在しない場合でも単に無視して処理を継続する。
commands...
省略可。起動されるコマンドを指定する。いくつ引数があっても良いが、それらの引数の最後に実ファイル名が展開されるものとする。さっきの例だと「ls -l test3.txt」のように展開する。もし、この引数が省略されている場合には、単に「ls」が指定されているものとみなす。
#!/bin/sh

num=1
max=999999
excheck='yes'
commands=''
while [ -n "$1" ]; do
    case "$1" in
    # オプション処理。オプションは次の引数を値にするので、shift を2回する
    -pattern) shift; pat=$1; shift;;
    -max) shift; max=$1; excheck='no'; shift;;
    -min) shift; num=$1; shift;;
    -*) echo "illegal option $1"; exit 1;;
    *)  break;;
    esac
done

# -pattern オプションが指定されていない時に usage を出力
if [ -z "$pat" ]; then
   echo "$0 -pattern <file%.exp> [-min <start>] [-max <end>] [commands...]"; exit 1
fi

# $1〜 のすべての引数を展開することも、$* でできる。
commands="$*"
if [ -z "$commands" ]; then
    commands='ls'
fi

# メインループの終了条件は、数値が最大指定値を越えた時
while [ "$num" -le "$max" ]; do
    # file シェル変数に具体的な連番数値を展開
    file=`echo $pat | sed 's/%/'$num'/'`
    if [ -e $file ];  # 存在すれば単に実行する
    then eval "$commands $file" || exit 1;  # エラーで止まる
    elif [ "$excheck" = "yes" ];  # ファイルが存在せず、最大値が設定されていない
        then break                # その時は停止
    fi                            # ファイルが存在せず、最大値があれば何もしない
    num=`expr $num + 1`  # 数値を一つ増やす
done

関数定義とシグナル

関数の作り方

また、一部の bsh バージョンの仕様から採り入れられて、関数(シェル内部で使うサブルーチン)を定義することもできる。一見通常のプログラミング言語の「サブルーチン」のようだが、これは本質的にはスクリプト内部のスクリプトのように動作するものである。つまり、次の通りである。

#!/bin/bash

function sub {
# あるいは
# sub() {
    echo $0 $1 $2
}

sub "test" "function" "argments"

この出力結果は意外なことに「func.sh test function」であり、引数の個数は特にチェックもされず(仮引数もないし...)、$1,$2は実引数に置換されるけども、$0はそのファイル名自体が置換され、関数名ではない。このスクリプトに対して、引数を与えて実行しても結果は同じである。これは要するに、次のようなルールであると理解すれば良い。

  1. $0はグローバルな変数であり、スクリプト名が格納されている。
  2. $1,...はトップレベルではスクリプト起動引数が格納されるのだが、関数呼び出しの際には関数呼び出しの実引数が、それを隠蔽する。
  3. $#,$*,$@などの扱いは関数内部でも、起動引数の場合に準じて、関数呼び出しの実引数を取り扱う。
  4. ローカル変数が必要ならば、localコマンドによってローカル変数名を宣言して使う。
  5. 実際には bsh の関数はマクロ置換に近いものである。だから、実質上関数から何かを戻り値にするのは出来ない。一応 return コマンドがあって、戻り値を呼び元に返すことができるみたいだが、これは単に関数の実行を中断して呼び元に戻り、「終了ステータス(要するに$?だ)」を与えることができるものに過ぎない。言い替えればスクリプト全体の実行を中止するexitと同様にしか扱えない(不便だ...)。

だからもし、関数から文字列の戻り値を得たいのならば、次のように標準出力を使って変数に格納するのが合理的である。

function sub
{
    wc $1
}

ret=`sub $1`
echo "ret is $ret"

また、この関数は RedHat 系 Linux の rc である /etc/rc.d/rc 以下のスクリプトで多用されている。/etc/rc.d/init.d/functions が共用のサブルーチン集といった格好で、/etc/rc.d/init.d 内にある各種デーモンを管理するスクリプトが共通して利用している。まあ、きっとあなたの UNIX の rc でも似たようなことをしているに違いない。確認すると勉強になるぞ。


シグナルのトラップ

さて、関数を説明したついでに、シグナルとの関連について述べてみよう。Cでプログラムを書くならば signal(2) などを使ってシグナルハンドラを定義するが、同様のことがシェルでさえできる。つまり、シグナルをトラップできるのである。シグナルが判らない人は「Super Technique 講座〜シグナルとコールバック」でも見てくれたまえ。

シグナルをトラップするには trap を使う。使い方は単純である。

#!/bin/sh

trap "echo 'pushed Ctrl+C'" INT

while true
do sleep 60
done

trap コマンド シグナル名...」という書式である。複数のシグナルに対して同一のハンドラを与えることも出来る。だから、次のようにも書ける。

#!/bin/sh

function handler {
   echo "catch signal!"
}

trap "handler" INT TERM

while true
do sleep 60
done

また、デフォルト動作に戻すには単にコマンドに「-」を設定すれば良い。細かいことを言うと、筆者の環境の bash 1.14.7 では、大体POSIXシグナル関数 sigaction(2) のデフォルトのように動作している。つまり、ハンドラが起動されたらハンドラ登録がキャンセルされることはなく、システムコールはアボートしてエラーを返す。しかし、ちょっと妙なのは再入の扱いで、再入すると強制的に sleep をアボートさせており、シグナルを保留しているわけではないようである。おそらく SysV シグナルの仕様で実装し、ハンドラの実行後にまた設定し直しているようである(ここらへんの議論は「Super Technique 講座〜シグナルとコールバック〜POSIXシグナルシステムコール」でも見ると判るぞ)。実験してみるならば次のスクリプトを実行してみると良かろう。

#!/bin/sh

function handler {
   echo "handler now sleep"
   sleep 10
   echo "handler awaked"
}

trap "handler" INT

while true
do
    read TEST
    echo "staus=$? text=$TEST"
    if [ "$TEST" = "quit" ]
    then  exit 0
    fi
done

ちなみに「read」はシェルの組み込み関数で、標準入力から入力を読み込んでそれを引数のシェル変数に設定する。対話型スクリプトを書くには必須なコマンドである。

また、大変面白いことに、シグナル番号0が「ある」のだ。もちろん、signal(2) が扱うシグナルには番号0なぞないのだが、これは「EXIT 疑似シグナル」であって、bsh が「シグナルのつもりで」、スクリプト終了を「トラップ」できるようにしたものである。要するにCライブラリの atexit(3) とか on_exit(3) と同様な結果を得ることができるのである。まあ、ちょっと「ニヤッ」と出来るような仕様であると言える。

これは当然次のように使う。

#!/bin/sh
tmpfile=tmpfile.$$
trap "rm $tmpfile" 0
# 当然シンボルを使って「trap "rm $tmpfile" EXIT」でも良い。こっちの方が良いな

#何か一時作業ファイルを使った処理
cat *.htm >$tmpfile # とか
......

スクリプトの後始末として、一時作業ファイルを消しているのである。ここで拡張子に「$$」変数を使っているので、これがそのシェルのプロセス番号に展開されて、ユニークな一時作業ファイル名を生成することに注意されたい。このトラップはたとえば Ctrl+C 入力(SIGINT=2だ..)などのホントのシグナルでも正しく動作する(まあ、SIGKILL とかだと問答無用に終了なのでそうもいかないが...)。


その他の bash の機能

ホントはその他にも bash 2.0 から使えるミョウチクリンなコマンドなどが大量にあるのだが、ここらへんはまだ現実のスクリプトにそれほど活用されているわけではないので、どんなものがあるかだけを述べておこう。見て驚くように(ただし使って他のプログラマの御理解を得ようと言うのはまだ早い!)。

ブレース置換
これはワイルドカード展開と似ているが、ファイル名に依存した展開ではなくて、任意の文字列を生成できる。たとえば、
echo b{ed,olt,ar}s
→beds bolts bars
のように展開する。派手な機能だがどれほど使うんだろう?
パターン照合演算子
これはGNUにありがちなヤリ過ぎな機能の一つだろう。要するにファイル名の拡張子をいじる時に、古いやり方だとバッククォートで囲んで sed でも起動して拡張子を置き換えるのだが、これを bash の特殊構文でやってしまおうという寸法だ。たとえば、
cginam=`echo $filenam | sed s/\.htm$/.cgi/`
# と同等なパターン照合演算子版は
cginam=${filenam%.htm}.cgi
だったりするのである。凝りすぎだなあ(筆者は sed が好きだ...)。
select 制御構造
複数項目から対話的に1つ選択する操作を実装したものである。だからテキストメニューが簡単に作れてしまう。まあ、こんなものは邪道だ(オリジナルは ksh のようだ)。
配列変数
2.0以降では配列が定義できてしまう。姿は
% names=(1st 2nd 3rd)
% echo ${name[1]}
2nd
のようなものである。どう使うんだろうね?
disown
これを使うと、スクリプトをデーモンに出来る。あれ、知ってたっけ? デーモンがデーモンたるゆえんは、それが起動されたシェルのプロセスグループから追い出されて、init の子プロセスとして扱われるように取り扱いを変更する、という動作をすべきだってことを。だからうっかり Ctrl+C を押してもデーモンは終了しない。(あと、標準入出力も閉じるべきだな...) これをシェルスクリプトでやってしまうのが disown だ(bash 2.0 以降)。

Cプログラムからの応用

シェルの機能はCプログラムからも利用可能である。一番簡単にシェルを起動するには、int system(char *); を使う。

#include <stdlib.h>
...........
    system( "ls *.dat >list" );

これは単純に引数としてシェルのコマンドを記述する。サブシェルとして、/bin/sh を起動して引数を実行する。引数の実行結果は戻り値などに反映されない。

シェルから export された環境変数は char *getenv(char *nam); で取得し、

int setenv( char *name, char *value, int override ); でセットできる。

ただし、setenv された環境変数は、現在のプログラムと、「現在のプログラムから起動した別なプログラム」の中でないと有効ではないことに注意されたい。親プロセスのシェルの環境変数をセットする手段はない。

この getenv(3) を使うと、アプリケーションの設定ファイルをシェルスクリプトで代用することができる。つまり、シェルスクリプトの中でカスタマイズしたい設定をシェルの環境変数に与えておいて、そのスクリプトからCプログラムを起動する。そしてCプログラムの中で getenv(3) を使って設定を取得するのである。この手は Xを起動する /usr/X11R6/bin/startx などで活用されている。シェルスクリプトの柔軟なプログラム機能を利用して、さまざまな条件に合わせた設定が可能になる。

だから皆さん、ちゃんと凝ったシェルスクリプトが書けると、設定ファイルをカスタマイズ可能なシェルスクリプトに任せて、Cプログラムは本来の作業に専念できることにもなるわけだ。プログラマにとっても、シェルスクリプトの道はおそろかには出来ないことが、ちゃんと理解できたかな?



copyright by K.Sugiura, 1996-2006