bash のアレコレ


1. ユーザインタフェースとしての bash

Linux においての「コマンド」とは、シェルによって実行ファイルが実行されていることを意味します。
たとえば ls コマンドをユーザが入力した際、その入力はシェルにとっての入力でしかありません。シェルはユーザから入力された ls という文字列を「ls という実行ファイルを実行する」と解釈し、カーネルに伝えます。
ls という実行ファイルを実行した結果がシェルによってユーザに伝えられ、あたかもユーザは ls という実行ファイルを直接実行したように感じることができます。
このように「実行したように」というのがユーザインタフェースとしてのシェルの役目です。
しかし、今回はスクリプト言語としての bash をメインに扱うので、この章は軽く流していただいて結構です。


2. bash の変なところ

ご存じのように bash はプログラミング言語(シェルスクリプト)のひとつとして動作します。
プログラミング言語として、最低限備わっているべき機能はなんでしょう?おそらく一般的な回答としては「特定の値を格納できる変数」「制御構造(if,while,など)」「繰り返し(for,foreachなど)」でしょう。bash はこれらのすべての機能を備えています。

しかし、bash は一般の言語と比較して変なところがたくさんあります。ここではそんな例を紹介します。



2.1 bash における変数
bash における変数の取り扱いは、一般的な言語(C,Java など)と異なります。C においては
int a;
と変数を宣言すれば、その変数に値を代入する際も、その変数の値を参照する際も、
a = 5;
printf("%d" , a);
というふうに、「a」というのが変数を表すすべての手段、かつ唯一の手段となります。
しかし bash においては、変数に値を代入する際は、
a=5
として扱えますが、変数 a の中身を参照する際は、
echo $a
echo は引数として与えられたものを標準出力に出力するコマンド
のように、変数名の先頭に「$」をつける必要があります。

代入、参照の二通りの変数の使い方についてそれぞれ注意する点があります。
変数に代入するとき a=5 と書きましたが、a と = 、 = と 5 の間にはスペースをいれてはいけません。a=5 とすべてくっつけて書いてください。
また、参照の際に
echo a
と間違えて記述すると、標準出力に「a」という文字が出てきます。これは $ をつけないことで、変数展開されず、a はただの文字列としての「a」を意味することになります。変数として参照する際は $ をつけなければいけません。
これが bash において、変数を取り扱う際の大きな特徴になります。
また、変数名の部分を明確にするために {} に囲う表現もあります。変数を a を参照するときに
echo ${a}
として書くこともできます。できます、というかこの書き方の方がより正しい書き方です。
a=1
ab=2
abc=3
echo $abc

というプログラムがあった場合、最後の echo $abc はどのような解釈ができるか考えてみましょう。

  1. $abc の値を出力する -> 3
  2. $ab の値の後に、(文字列としての) c を付加して出力する -> 2c
  3. $a の内容に(文字列としての)bc を付加して出力する -> 1bc
のような3通りの解釈があります。処理の対象となる変数を明確にするため、{} で囲う書き方をしましょう。

一般的な言語においては変数は型を持ち、特に数値と文字列の区別は明確に行われますが、bash においては変数に代入される値はすべて文字列になります。仮に
a=1
とした場合でも、変数 a には「文字列としての 1 」が格納されていることになります。
プログラミングにおいてよく使うインクリメントも bash においては以下のように特殊になります。
num=1
let num="${num} + 1"
echo $num

結果は 2 が出力される。

ダメな例
num=1
num=${num}+1
echo ${num}
の結果は 1+1 という文字列が出力されます。以前にも説明したように、bash での変数は文字列しか格納できません。よって ${num}+1 は "1+1" でしかないのです。

Appendix にもあるように「let」演算子は与えられた文字列を数式として解釈し、その結果を返す演算子です。つまりここでは、変数が展開されていると考えると
1 + 1
という数式の結果が num に新たに代入されることを意味します。
上記のようにあくまでも「文字列として与えられた」数式であることが条件なので、"" でクウォートすることを癖にしてください。

2.2 bash における特殊変数
1章でも述べたようにシェルは OS(カーネル)との連動性を主に考えているため、特徴的な特殊変数が存在します。以下に特殊変数を列挙します。

$1 , $2 , $3 ...
これらにはシェルスクリプトを実行する際に与えられた引数が格納されています。仮に myscript というシェルスクリプトがあった場合に、
myscript first_arg second_arg third_arg
と実行すると、$1 には first_arg という文字列が自動的に代入され、$2 , $3 も同様な扱いになります。C における argv と同じものです。
コマンドラインオプション(シェルスクリプトにとっての引数)によってプログラムの挙動を変えたい場合に非常に重要になってくるものなので必修です。

また $0 は実行プログラムそのものの名前が格納されています。上記のコマンドを実行した際には $0 には myscript が自動的に格納されています。

$#
引数の数を格納しています。
test a b c
とコマンドした場合、$# には自動的に 3 が格納されています。C における argc と同じです。
引数として与えられた物をすべて走査する場合の判定条件となるのでこれも必修です。

$$
スクリプトが実行された際に、そのスクリプトの PID(プロセス ID)が格納されています。PID は一般にユニークなものなので、テンポラリファイルを作る際に $$ を使うことで他のファイルとの重複をある程度回避することができます。


3. 配列

bash における配列は C のそれと似ています。配列変数 array に a b c という値を格納したい場合は、
array[0]=a
array[1]=b
array[2]=c

と書くことができます。代入の際は $ をつけない、というのは通常の変数と同じです。あとは C と同様に [] の中に要素番号を書いてやれば、「配列の n 番目」を対象とすることができます。
また、代入作業においては上記よりも便利な方法があります。配列 array に a b c という文字列を各要素に代入したい場合
array=(a b c)
として記述することができます。
$() 演算子と組み合わせることで、(複数行ある)コマンドの実行結果をすべて配列に格納することができます。
カレントディレクトリにあるファイルとディレクトリの名前を配列 ls_result に格納するには
ls_result=$(ls)
とすればできます。

Appendix にも書きましたが、$() はそのコマンドを実行した結果(標準出力に出力される文字列)を返す演算子です。
後述する for 文と配列を組み合わせることで非常に便利な使い方ができるので、配列はぜひ覚えておいてください。


4. 制御文

bash には if , while , for , case という基本的な制御文が備わっています。この章ではそれらの使い方を説明します。


4.1 if 文

制御文の基本です。与えられた条件式が真か偽かで制御を行います。
bash における if 文の基本は以下の通りです。
if [ 値1 比較演算子 値2 ];then
  実行文
fi

bash では if 文も特殊で、気をつけて書かないとすぐに構文エラーになります。以下に簡単な例を一つ示します。
num1=1
num2=2
if [ ${num1} -eq ${num2} ];then
  echo num1=num2
elif [ ${num1} -gt ${num2} ];then
  echo "num1 > num2"
else
  echo "num1 < num2"
fi

-eq というのは C でいう == 演算子です。左右を数値として見たとき、等価ならば真を、そうでなければ偽を返すというものです。また -gt は > にあたります。
構文エラーになると書いたのは、[ と ${num1} の間にスペースを入れなければいけないからです。スペースを入れなければいけないのは [ と ${num1} の間だけではありません。
  1. if と [ の間
  2. [ と ${num1} の間
  3. ${num1} と -eq の間
  4. -eq と ${num2} の間
  5. ${num2} と ] の間
のすべてがそうです。
なぜこうなるのかというと、[ というのは bash における記号ではなく、コマンドそのものだからです。 ${num1} -eq ${num2} が引数として与えられた時に [ コマンドが返す値で真偽の判断をしています。ここは分かりにくいですが、こういうものだと思ってください。外部コマンドを使わなければ数値の比較さえできないのが bash なのです。

他のプログラミング言語を理解している人なら else , elif の使い方は分かると思います。


4.2 比較演算子

本来なら if 文の説明の前にこの説明をするべきですが、bash の if 文があまりにも独特な形をしているので、先に制御文の書き方を説明しました。
以下に比較演算子を列挙します。日本語の説明と同時に、同じ意味を表す C における比較演算子を書いておきます。

○数値における比較演算子
構文意味
${num1} -eq ${num2}num1 と num2 が等価の時に真を返します。num1 == num2。
${num1} -ne ${num2} num1 と num2 が等価でないときに真を返します。num1 != num2
${num1} -lt ${num2} num1 が num2 未満の時に真を返します。 num1 < num2
${num1} -le ${num2} num1 が num2 以下の時に真を返します。num1 <= num2
${num1} -gt ${num2} num1 が num2 より大きい時に真を返します。num1 > num2
${num1} -ge ${num2} num1 が num2 以上の時に真を返します。num1 >= num2

2.1節 で bash の変数はすべて文字列であると書きましたが、この比較演算においても例外ではありません。ただ [ コマンドが文字列としての "1" を数値に置き換えて解釈してくれているので、数値としての比較が可能になります。

では次に文字列における比較演算子です。
○文字列における比較演算子
構文意味
-n ${string} string の長さが 0 より大きければ真。strlen(string)
-z ${string} string の長さが 0 であれば真。(!strlen(string))
${string1} = ${string2} 二つの文字列が等しければ真。== ではない のに注意してください。(!strcmp(string1 , string2))
${string1} != ${string2} 二つの文字列が等しくなければ真。strcmp(string1 , string2)
論理演算子の AND(&&) と OR(||) はそれぞれ -a , -o と書きます。
num1=1
num2=2
num3=3
if [ $num1 -lt $num2 -a $num1 -lt $num3 ]; then
echo true
fi
のような使い方になります。


4.3 while 文

while 文は if 文が分かっていれば簡単です。例を以下に示します。
num=0
while [ $num -lt 5 ];do
  echo ${num}
  let num="${num}+1"
done

結果は
0
1
2
3
4
となります。if 文の then が do に、fi が done に変わっただけです。


4.4 for 文

bash における for 文は C の for 文とはまったく異なります。C などの一般的な言語においては、for 文とは、

を3つのフィールドに分けて表記するような書き方が大半です。
bash における for 文は VBA での for each VARIABLE in ARRAY にそっくりです。
bash における for 文は
num=0
for num in 1 2 3 4;do
echo ${num}
done

のように書きます。
結果は
1
2
3
4
となります。これは in 以下の値を num に代入して do〜done のループの中身を実行する、という挙動をします。

3 章で「for 文と配列を組み合わせることで非常に便利な使い方ができる」と書いたのは、for 文がこのような働きをするからです。
array=(1 2 3 4)
という配列 array があったとき、その中身をスペースを区切りに展開するには @ 演算子を使います。
array=(1 2 3 4)
num=0
for num in ${array[@]};do
  echo ${num}
done

と書けば、for num in 1 2 3 4;do と書いたのと同じことを意味します。
この便利な for の使い方を以下のスクリプトを例に示します。
current_files_directories=$(ls)
for temp in ${current_files_directories[@]};do
  echo ${temp}
done

これでカレントディレクトリにあるファイルとディレクトリ(ls コマンドの結果)をすべて表示させることができます。
説明していませんでしたが、bash では変数、配列を宣言せずに使うことができます。

このように、bash では配列に対して要素番号をパラメータにすることなく配列の中身参照することができます。


5. サンプルプログラム

シェルとして bash を使って行うと面倒な作業でも、スクリプトを書いておくとその作業の効率は飛躍的にあがります。ここではそんな例をいくつかっサンプルプログラムとして紹介します。


5.1自分のホームディレクトリ以下にある特定の名前のファイルのみを表示する

仕事の内容ごとにディレクトリを作ってあり、それぞれのディレクトリには必ずその仕事の内容が詳しく書いてある memo.txt というファイルがあるとします。そのファイルを一括に表示するサンプルプログラムです。

/home/user 以下に work1 , work2 , work3 というディレクトリがある。それぞれのディレクトリの中身は以下の通り。
work1: test.txt , memo.txt , main.c , main.h
work2: result.txt , test.cgi , a.conf
work3: memo.txt test.pl , test.f
この場合は /home/user/work1/memo.txt と /home/user/work3/memo.txt のみを表示する。
1: #!/bin/bash 2: target_dir=/home/user #この下に階層的にそれぞれの仕事のディレクトリがある 3: find_file=memo.txt 4: filename=$(find ${target_dir} -name ${find_file}) 5: split="-----" #各ファイルどうしを区切る区切り文字 6: for temp in ${filename[@]};do 7: echo ${split} >> /tmp/mymemo 8: echo ${temp} >> /tmp/mymemo 9: cat ${temp} >> /tmp/mymemo 10: done 11: less /tmp/mymemo 12: rm /tmp/mymemo

△解説
: /bin/bash をインタプリタとしてこのスクリプトを実行する場合必ず必要です。
2: 特定のファイルを探す先のディレクトリです。この場合は、の場合は、ホームディレクトリを探すので、/home/user を値としています。
3: 探すファイル名です。
4: find_name で指定したファイルを find コマンドで検索し、その結果を filename という配列にすべて格納しています。
5: 結果を表示する際に、各ファイルを区切る文字です。
6: filename 配列の各要素 を変数 temp に展開して ループをまわします。
7: 区切り文字を /tmp/mymemo というファイルに書き込みます。
8: ファイル名を /tmp/mymemo に書き込みます。
9: ファイルの内容を /tmp/mymemo に書き込みます。
10: ひとつのファイルに対するループが終わる。
11: 検索されたファイルの内容をひとつにまとめた /tmp/mymemo というファイルを less コマンドで表示します。
12: 使用済みのファイルを消します。

どうでしょうか、流れが見えたでしょうか。ここでは find コマンドがいかに使いこなせているか、ということがポイントになります。また同時に、bash の for 文の便利さも理解できたと思います。


このプログラムを2行変更することで、汎用的なプログラムにすることができます。
今の段階では、探すファイル名と探す先のディレクトリが固定ですが、これをユーザ指定にすることができればどんなファイルとディレクトリ構造に対しても有効なプログラムになります。

答えは簡単です。上記プログラムの 2行目と3行目を変更するだけでできます。
特殊変数 $1,$2,$3... を使うことで、プログラムを実行する際に渡された引数を参照できることは説明済みです。よって変更は、
target_dir=$1
find_file=$2
とするだけです。

また、贅沢にもエラー処理をさせることもできます。探すファイル、探す先のディレクトリをユーザ指定にすることで、このプログラムを実行するのに必要な引数は 2つでなければいけません。よって、引数の数が格納してある特殊変数 $# によってエラーを回避することができます。
変更後のプログラムはいかの通りです。
1: #!/bin/bash 2: target_dir=$1 3: find_file=$2 4: if [ $# -ne 2 ];then 5: echo "引数は必ず2つ指定してください" 6: exit 7: else 8: filename=$(find ${target_dir} -name ${find_file}) 9: split="-----" #各ファイルどうしを区切る区切り文字 10: for temp in ${filename[@]};do 11: echo ${split} >> /tmp/mymemo 11: echo ${temp} >> /tmp/mymemo 13: cat ${temp} >> /tmp/mymemo 14: done 15: less /tmp/mymemo 16: rm /tmp/mymemo 17: fi

上の例だと引数の数によって判断をしていますが、文字列比較演算子 -n を使うことで、変数 target_dir,find_file の値によっても判断することができます。


5.2 特定のディレクトリに存在するすべてのファイルのバックアップをとる

/home/user/test 以下のファイルを /home/user/test/bak にバックアップをとります。本来ならディレクトリまるごとを圧縮してしまうのが簡単ですが、個々のファイルごとに圧縮しておいた方が便利なので、ここではファイル一つ一つを圧縮します。

今、/home/user/test には 1.txt , 2.txt , 3.txt というファイルがあるとします。
そしてこのスクリプトを実行すると、/home/user/test/bak に 1.txt.bz2 , 2.txt.bz2 , 3.txt.bz2 という3つの圧縮されたファイルが作られる、というのが希望する動作です。

1: #!/bin/bash 2: target_dir=$1 3: target_files=$(find ${target_dir} -maxdepth 1 -type f) 4: end_slash=$(expr ${target_dir} : '.*/$') 5: if [ ${end_slash} -eq 0 ];then 6: backup_dir=${target_dir}/bak$(date +%y%m%d)/ 7: else 8: backup_dir=${target_dir}bak$(date +%y%m%d)/ 9: fi 10: mkdir ${backup_dir} 11: for temp in ${target_files[@]};do 12: outfile=$(basename ${temp}) 13: bzip2 -c ${temp} > ${backup_dir}${outfile}.bz2 14: done

△解説

3: 引数で渡されたディレクトリに存在するファイル(ディレクトリを含まない)の名前を target_files に格納。
4: バックアップを保存するディレクトリの名前を決定する。「引数で渡されたディレクトリ/bak日付」という名前にしたいので、target_dir の後に 「bak日付」を付加してやればいいのですが、単純に付加するだけだとダメな場合があります。
引数で渡されたディレクトリの名前が / で終わっていない場合「bak日付」を付加すると、引数に /home/hoge/work が渡された場合、バックアップ用のディレクトリの名前は「/home/hoge/workbak日付」となってしまいます。エラーにはなりませんが、ダメすぎです。
そこで expr コマンドの機能である正規表現を使って、引数で渡されたディレクトリが / で終わっているかどうかをここでチェックしています。
Appendix で詳しく解説しますが、expr コマンドを使って最後が / で終わっているかをチェックしたとき、/ で終わっていると、0 以外が返ります。/ で終わっていなければ 0 が返ります。
5行目は / で終わっていなかった場合です。6 行目で ${target_dir}/bak となっています。
8行目は / で終わっている場合なので ${target_dir}bak となっています。

bash では、/home/hoge/test/ を /home//hoge////test/ と書いても正しく /home/hoge/test/ と認識してくれるようになっているので、場合分けをせずに、 backup_dir={$target_dir}/bak$(date +%y%m%d%) (/home/hoge/test//bak になってしまう)と書いても問題はありませんが、あまり美しいソリューションではありません。

10: バックアップ用のディレクトリを作る。
12: バックアップを取るファイルからパスの部分を取り除き、純粋にファイル名だけにする。(/home/hoge/test.txt から test.txt のみを取り出す)


5.3 CSV ファイルの特定のフィールドだけ取り出し、その値の合計値を求める

CSV などの単純な構造(区切りが「,」でなくてもよい)のファイルについては、フィールド単位であれば簡単に処理できます。

CPU,10000
HDD,10000
メモリ,6000
キーボード,2000
という見積りのようなものが書かれた CSV ファイルがあったときに、合計金額を求めるプログラムを以下に示します。

1: #!/bin/bash 2: file=$1 3: money=$(cut -f2 -d, ${file}) 4: sum=0 5: for temp in ${money[@]};do 6: let sum="${sum} + ${temp}" 7: done 8: echo "合計金額は ${sum} 円"

△解説

2: ファイル名を引数に取れることぐらいもう当然 :-)
3: このプログラムの最も大事な部分。
cut -fM -dS FILENAME: M はいくつ目のフィールドを採用するか、S は区切りに使う文字。
4〜 あとはただのループ


6. Appendix

bash プログラミングをする上で知っていると便利なコマンドを列挙します。