01 月 22 日(月)

実用的なシェルスクリプト

シェルスクリプトでよく利用される機能について, 実験し理解しよう.

作業の前に,端末またはブラウザで bash マニュアル を開いておこう.

$ man bash

入出力制御

コマンドの入出力を制御するために,リダイレクトがある. 基本的なリダイレクトは次の通り:

出力・追加リダイレクトでは,後述のように, 標準エラー出力を取り扱うためのものも用意されている.

また,今回は詳しく紹介しないが,入出力制御には,パイプラインもある:

$ コマンド | コマンド | ... | コマンド
リダイレクトパイプ については,すでに,前期のプログラミング言語II でも使っていた.

ヒアドキュメント

次の例では, ヒアドキュメント(here document)について自己記述的に説明している:

$  echo "短文の出力には echo を利用する."
短文の出力には echo を利用する.

$  cat << EOF
> 複数行にわたる長文の出力には,
> 複数の echo コマンドではなく,
> cat コマンドと ヒアドキュメントを利用する.
> EOF
複数行にわたる長文の出力には,
複数の echo コマンドではなく,
cat コマンドと ヒアドキュメントを利用する.

$  cat << FIN
> 終端文字列は EOF とは限らないぜ.好きなように決めるんだ.
> もし,EOF 限定だと,文字列 "EOF" を出力できないだろぅ?
> あと,変数だって使えるんだぜ.$BASH
> FIN
終端文字列は EOF とは限らないぜ.好きなように決めるんだ.
もし,EOF 限定だと,文字列 "EOF" を出力できないだろぅ?
あと,変数だって使えるんだぜ./bin/bash

なお,ここで行の先頭の > はプロンプトだ. シェルスクリプト化する場合,> を記述する必要はない.

ヒアドキュメントでは空白文字もそのまま出力されてしまうことに注意. シェルスクリプト内でインデントしたい場合には, というか,インデントは必須 なので,このままでは困る. しかし,<< の代わりに <<- を使えば, 行頭の Tab だけを無視してくれる.

というわけで,シェルスクリプト内のヒアドキュメントについては, <<- を使い,Tab でインデントしよう. スペースじゃダメよダメダメ.

出力リダイレクト

出力リダイレクトの基本的な使い方は次の通り: (前期のプログラミング言語IIで,すでに使っている.)

$  touch  boo.c  foo.c  woo.h	# 準備:練習用の空ファイルを作成

$  ls
boo.c  foo.c  woo.h	# ファイルを表示

$  ls > ls.txt
			# 無言

$  cat ls.txt		# ls の実行結果は,リダイレクト先のファイルに出力されている
boo.c
foo.c
woo.h

コマンドの実行結果にエラーがある場合, 次のようにリダイレクトを使い分ける:

$  ls boo.c poo.c
ls: poo.c: そのようなファイルやディレクトリはありません	# ← 標準エラー出力
boo.c						# ← 標準出力

# 標準出力だけをリダイレクト
$  ls boo.c poo.c > ls.txt
ls: poo.c: そのようなファイルやディレクトリはありません
$  cat ls.txt
boo.c

# 標準エラー出力も一緒にリダイレクト
$  ls boo.c poo.c &> ls.txt
						# エラー出なくなった?
$  cat ls.txt
ls: poo.c: そのようなファイルやディレクトリはありません	# ファイルに出てたー
boo.c

# 標準出力と標準エラー出力とを分離してリダイレクト
$  ( ls boo.c poo.c > ls.txt ) &> err.txt
$  cat ls.txt
boo.c
$  cat err.txt
ls: poo.c: そのようなファイルやディレクトリはありません

なお,最後の例の ( ) は, 複合コマンド(複数のコマンドや入出力制御の組み合わせ)を 単純コマンド(ひとつのコマンド)として取り扱うためのものだ.

特殊ファイル

シェルスクリプトでは, スクリプト内で実行されたコマンドからのエラーメッセージを 必要としない(表示したくない)場合も多い. (エラー出力だけでなく標準出力をも必要としない場合さえある.) この場合,次のような特殊ファイルを利用すればよい:

$  ls boo.c poo.c
ls: poo.c: そのようなファイルやディレクトリはありません	# 標準エラー出力
boo.c						# 標準出力

# 標準出力だけを端末表示し,標準エラー出力を捨てる
$  ( ls boo.c poo.c > /dev/tty ) &> /dev/null
boo.c						# 標準出力

エラー処理

終了ステータス

コマンドが 成功(正常に終了)したか/失敗(エラーが発生)したか を判断するために, 特殊変数 $? がある. コマンドの戻り値が自動的に $? へ代入される.

大抵のコマンドは,成功すると 0,失敗すると 0 以外を返す. C言語の場合, main( ) の戻り値がコマンドの戻り値だったなー.

とにかく,使ってみよう:

$  ls boo.c
boo.c
$  echo $?
0		# 直前の ls は成功だった

$  ls poo.c
ls: poo.c: そのようなファイルやディレクトリはありません	# エラー
$  echo $?
1		# 直前の ls は失敗だった
$  echo $?
0		# 直前の echo は成功だった
$? の値は, コマンドの実行のたびに書き換えられるので注意しよう. コマンドの終了直後に $? の値を検査すること.

例題

前回作成したスクリプト backup.bash にエラー処理を追加しよう. このスクリプトでエラーが発生し得るのは,次の部分:

echo "$file -> $file.org"
\cp $file $file.org

たとえば, コピー元ファイル $file が存在しない場合や コピー先ファイル $file.org が書き込み禁止の場合, エラーとなってしまう. 確認しよう:

$  ls -l boo.c*		# ファイルの詳細情報を表示
-rw-rw-r--  ...  boo.c
-rw-rw-r--  ...  boo.c.org

$  chmod a-w boo.c.org	# a:全ユーザ  -:アクセス拒否  w:書き込み
$  ls -l boo.c*
-rw-rw-r--  ...  boo.c
-r--r--r--  ...  boo.c.org

$  ./backup.bash boo.c
boo.c -> boo.c.org
cp: ファイル ``boo.c.org'' を作ることができませんでした: 許可がありません
		# 書き込み禁止ファイルへの書き込みなのでエラー

$  chmod u+w boo.c.org	# u:所有者  +:アクセス許可  w:書き込み
$  ls -l boo.c*
-rw-rw-r--  ...  boo.c
-rw-r--r--  ...  boo.c.org

$  ./backup.bash boo.c
boo.c -> boo.c.org
		# 書き込めるようになった

もしかすると,上書きしてよいかどうか,たずねられるかもしれない. cpcp -i へエイリアスされている場合, \cp\ を付け忘れていると, このような状況になる.

シェルスクリプト中では, このようなエイリアス置換を抑制するために, cp ではなく \cp と書けばよい. または,/bin/cp としても OK.

ところで,上の実験でエラーが出た場合について, backup.bash コマンドを実行したつもりなのに, cp コマンドからのエラーが報告されてしまっていた. こんなエラーに直面したとき, 普通のユーザは思考停止におちいってしまうだろう. こんな事態を回避すべきだ.

このエラーを表示せず検出だけするには, backup.bash スクリプトの一部を次のように書き換えよう:

...

echo -n "$file -> $file.org  ...  "
\cp $file $file.org &> /dev/null	# cp のエラーを非表示に

if [ $? -ne 0 ]		# エラーを検出したら...
then
	echo "失敗"		# backup.bash のエラーを表示
else
	echo "成功"
fi
...

関数

既に紹介したように,bash では,関数も定義できる. 基本パターンは次の通り:

...

function func		# 関数 func の定義
{
	...

	return ステータス値
}

...

func 引数1 引数2 ...	# 関数 func の実行
if [ $? ... ] ...	# 実行結果に応じた処理
...

Cとは異なり, 関数定義の行に仮引数が記述されていないが, 間違いではない. 関数の仮引数は,コマンドライン引数と同様, $1$2,…,$9 によって参照できる.

また,関数の戻り値は,ステータス値だ. コマンドのステータスと同様, 関数終了直後にだけ $? で参照できる.


コマンドライン処理

例題

Unix コマンドの多くは,簡単なヘルプ機能を備えている. 大抵の場合,次のようにオプション引数を付けて実行すると, コマンドの使い方を表示してくれる:

$  コマンド -h	または	コマンド --help
知っているコマンド(lslesscat,等) で試してみよう. ただし,すべてのコマンドにこの機能が実装されているわけではない.

これを真似して,backup.bash にもヘルプ機能を追加してみよう. 次のように書き換える:

#!/bin/bash

function help {
	cat <<- EOF
[Tab]	説明:引数に指定されたファイルのバックアップコピーを作る
[Tab]	使い方:backup.bash [-h] ファイル名 ...
[Tab]	オプション:
[Tab]	  -h  このヘルプを表示する.
[Tab]	EOF

	exit 1
}

if [ $# -lt 1 ]; then help; fi
if [ $1 = "-h" ]; then help; fi
...

なお,ヘルプ表示における括弧 [ ] は, そのキーワードが省略可能であることを示している.

引数配列の操作

shift コマンドを使うと, コマンドライン引数を 1 個ずつ削除できる. 次のシェルスクリプトを試せば,理解できるだろう. shift.bash

#!/bin/bash

if [ $# -lt 2 ]; then exit 1; fi

echo $1		# 1 番目の引数を表示

shift		# $1 を削除($2 以降は前へ移動)
echo $1		# 元々の $2 を表示

shift
echo $1		# 元々の $3 を表示
$  ./shift.bash  boo  foo  woo
boo
foo
woo

どんな場合に使うのか? 大抵のスクリプトでは, あるコマンドライン引数について処理が完了したら, その引数を二度と使うことはない. なので,完了した引数を削除してしまえば, いつでも最初の引数だけを処理対象にすれば OK, ということになるので, スクリプトを簡潔に記述できるようになるかもしれない.

たとえば, すべてのコマンドライン引数についての処理を 次のように記述できる:

...
while [ $# -gt 0 ]
do
        echo $1
        shift
done

これと同じことを前回は, for-in$* によって実現していた. 比較してみよう.

なお,「いつでも,shift の方が効率的」と言っているわけではない. 場合に応じて,$*shift とをうまく使い分けるべきだ.

パラメータ展開

パラメータ展開を使えば,変数内の文字列に対して, 検索・置換・削除などの処理を実行できる. 例として,ファイル名の文字列を変換してみよう:

$ file=apple.txt
$ touch $file			# ファイルを作成
$ ls
apple.txt


$ echo ${file%.*}	# ファイル名の本体部分を抽出
	# (パターン「.*」と後方一致する文字列(拡張子部分)を削除)
apple

$ echo ${file#*.}	# ファイル名の拡張子部分を抽出
	# (パターン「*.」と前方一致する文字列(本体部分)を削除)
txt

$ mv $file ${file/%.txt/pen.txt}	# ファイル名を変更
	# (パターン「.txt」と後方一致する文字列を「pen.txt」に変換)
$ ls
applepen.txt
くわしくは,Bash マニュアルの「パラメータの展開」の項を参照しよう.

なお,このようなファイル名の文字列処理は, ファイル形式の変換などを自動化する際に役立つ.


練習問題

backup.bash に次の実行例のようなオプション機能を追加せよ:

$  ls *
boo.c  foo.c  woo.h

# 通常のバックアップ(デフォルト動作)
$  ./backup.bash *.c poo.txt
boo.c -> boo.c.org ... 成功
foo.c -> foo.c.org ... 成功
poo.txt -> poo.txt.org ... 失敗

# これも通常のバックアップ
$  ./backup.bash -b *.?
boo.c -> boo.c.org ... 成功
foo.c -> foo.c.org ... 成功
woo.h -> woo.h.org ... 成功

# バックアップから元のファイルを復元(recovery)
$  rm woo.h
$  ./backup.bash -r woo.h
woo.h.org -> woo.h ... 成功

# タイムスタンプ(timestamp)を比較し,必要な場合だけバックアップ
# (タイムスタンプを比較するには,条件演算子 -nt や -ot  が使える.
# Bash マニュアルの「条件式」の項を参照.)
$  touch foo.c			# foo.c だけ更新しとく
$  ./backup.bash -t *.?
boo.c -> boo.c.org ... 更新済
foo.c -> foo.c.org ... 成功
woo.h -> woo.h.org ... 更新済

# 実行結果を冗長(verbose)に出力
$  ./backup.bash -v *.c poo.txt
boo.c -> boo.c.org ... 成功
foo.c -> foo.c.org ... 成功
poo.txt -> poo.txt.org ... 失敗
2 個のファイルをバックアップしました.
1 個のファイルのバックアップに失敗しました.

なるべく,複数のオプションを組み合わせられるようにしてみよう.

作成例


(c) 2018, yanagawa@kushiro-ct.ac.jp