『入門 Unix シェルプログラミング』で Unix の世界を学ぶ

Photo by Noah S on Unsplash

『入門 Unix シェルプログラミング』で Unix の世界を学ぶ

『入門 UNIX シェルプログラミング シェルの基礎から学ぶ UNIX の世界』 は UNIX シェルプログラミングの入門書です.

良いと思ったのは網羅的で実用的なところです.
おそらく一通りの基礎的なトピックスに触れていて, 本書を読んでおけば大抵のことには対応できる土台が身に付くと感じました. 基礎だけにとどまらず, 豊富な実例を伴った解説がなされるので, 辞書やクックブックとしても使えそうです.

基本的だけど知らなかったこと

検索しづらいようなトピックスがきちんと紹介されているのが嬉しいポイントです.
これまで何となく知っているけどよく分からずにいたことが分かって, 知りたいことが知れたという気分がしました.

#/bin/sh とは何か

シェルスクリプトの最初の行に書くシェバンというものですが, これはスクリプトを実行するのインタープリタを指定する記述です.

シェバンがなければ余計な手順が掛かり, 意図したのとは異なるシェルでスクリプトが実行される可能性があります.
シェルはまず exec システムコールでコマンド実行を試みますが, シェルスクリプトは実行ファイルではないので exec は失敗します. 次にファイルに実行権限があればシェルスクリプトだと判定し, 現在のシェルでスクリプトを実行します.

余計な手間を省く, 実行されるシェルを統一するという役割があるのです.
そういうわけなので, スクリプトとして実行されないファイルであればシェバンは不要です. 例えば関数を定義してドットコマンド . で読み込んで使うファイルの場合です.

ドットコマンド .

ファイルの内容を展開するコマンドです. 例えば . abc とするとファイル abc の内容が実行されます. C 言語の #include のようなものですね. 用途としては, 関数を定義しておいたり, 環境変数を設定したりするのに便利です.

こういう記号はネットでは検索しづらいので本に書いてあると助かります. 教科書的な本を読む利点だと思います.

ワイルドカード

ls ~/* でホームディレクトリ以下の全てのファイルが見られますが, これはワイルドカードという機能の一部です. 以下のような記号でファイルを指定できます.

  • * : 任意の文字列
  • ? : 任意の 1 文字
  • [ABC]: A/B/C のいずれかの文字
  • [!ABC]: A/B/C 文字の文字

よって echo * とすれば ls と同じ効果が得られます. もちろん ls [!0-9][a-zA-Z]??* のように組み合わせて使うこともできます.

今までも ls ~/* のようにできることは知っていましたが, これは ls の機能なのかと思っていました. 例えば grep の検索パターンで * をエスケープしなければならないのはシェルに解釈されないようにするためですね.

グルーピング

コマンドのグルーピングは (){} の 2 つです.

(command1; command2; command3) とするとサブシェルでコマンドが実行されます. サブシェルはカレントシェルとは別のプロセスなので, 例えば (cd ~; make) とすると, カレントディレクトリを変更せずにホームディレクトリで make を実行することができます.

{command1; command2; command3;} はカレントシェルでコマンドが実行されますが, コマンドの結果をまとめる場合によく利用します. 例えば {date; make;} > make.log とすると make の結果の前に日付を入れられます.

特殊変数

特殊変数とは自動的に設定される読み取り専用の変数のことです. 例えば以下のようなものがあります.

  • $?: 直前のコマンドの終了コード
  • $$: カレントシェルのプロセス ID
  • $!: バックグラウンドで実行したプロセスの ID
  • $0 - $9: コマンドライン引数
  • $@: 0 番目を除く全てのコマンドライン引数
  • $*: 0 番目を除く全てのコマンドライン引数

$? はコマンドの成否によって分岐する場合などに使用します. "tmp.$$" のようにしてプロセス固有のファイル名を生成できるので $$ も意外に便利かも知れません.

$@$*"" で囲ったときの処理が異なります. "$@" は引数それぞれが "" で囲まれますが, $* は引数全体が "" で囲まれます.

ヌルである変数の設定

以下のように定義されている変数は値がヌルです.

VAR1=
VAR2=""

値がヌルである場合に変数に値を代入したり, 代わりの値を返したりすることができます.

  • ${VAR:=value}: VAR がヌルなら value を代入する
  • ${VAR:-value}: VAR がヌルなら value を返す (代入はしない)
  • ${VAR:+value}: VAR がヌルではないなら value を返す (代入はしない)
  • ${VAR:?message}: VAR がヌルなら message を表示して終了

厳密にはこれらの表記は「まだ使用されていない変数」と「ヌルが代入されている変数」両方をヌルとみなします. : を省略するとまだ使用されていない変数のみを対象とします.

どれもそれなりに便利そうです. よく使われる書き方に ${@:+"$@"} というのがあります. 一見ぎょっとするような見た目ですが, これはコマンドライン引数が渡されていたら "$@", 何も引数が渡されていなければ $@ となります. コマンドライン引数をそのまま別のコマンドに渡したいときに使います. なぜ分岐が必要なのかというと, 引数がないのか, 空文字が指定されたのかを区別するためです. $@ は引数がなければヌルですが, "$@" とすると空文字になってしまうからです.

これは以下と同じです.

if [ $# -eq 0 ]; then
  command
else
  command "$@"
fi

この方がわかりやすいですが, ${@:+"$@"} のほうが短いですし, 定形表現としてよく使われるということだと思います. 濫用するとあっという間に訳がわからなくなりそうなので使用頻度は高くないかも知れません. しかし, こういう記法があるということを知っていなければ, そもそも理解する気すら起きなさそうです.

リダイレクト

あるファイルディスクリプタを別のファイルディスクリプタに向けることができます. 例えば echo aaa 1>file とすると標準出力 (=1 番) が file に向きます.

一般的に, m 番を n 番に向けるには m>&n と書きます.

コマンドの出力を捨てる際に command >/dev/null 2>&1 のように書くと良いと見たことがあったのですが, よく分かっていませんでした. なぜ 2 は 2 なのに 1 は&1 と書かなければならないのか, command 2>&1 >/dev/null の順番ではいけないのかといったことが疑問で, 書き方も覚えられず都度ネットで調べていました.
ファイルディスクリプタを指すのに&が必要なのはファイル名と区別するためですね. リダイレクト元は必ずファイルディスクリプタなので&は不要ということで納得です.
リダイレクトの順番は重要で, 左から右に処理されます. 1 と 2 両方を /dev/null に向けたいなら, まず >/dev/null で 1 のリダイレクトをした後に 2>&1 とする必要があります. もし先に 2>&1 とすると, その時点では 1 は標準出力を向いているので, 2 が標準出力を向くことになります.

ちなみに, 利用頻度は低そうですが >-&m とするとディスクリプタを閉じることもできます.

基本的なルールが分かってしまえば, 例えば標準エラー出力にメッセージを出す, 標準エラー出力を捨てるといったことは簡単です.
簡単な入出力のリダイレクトはよく利用していましたが, 一般的なルールを知ることができてすっきりしました.

細かい Tips 集

分類しきれないような細かい Tips がたくさん紹介されています. test, expr, sed などの使い方であったり, コンピュータのホスト名を調べる方法, ユーザ名を得る方法などなど.

特に印象に残ったことを紹介します.

sed の使い方

sed は置換のコマンドですが, 置換意外にも便利な使い方があると知りました. 特に感心したのは行の指定です.
sed -e '3,8s/old/new/g' のようにすると 3-8 行目を対象にできます. ファイル末尾は $ で指定可能です.

これによって, 例えばファイルの 10 行目から 25 行目のみを表示するには sed -n -e '10,25p' とすれば良いです. -n は暗黙的な print をやめるオプションで, p と組み合わせることでマッチした行のみを print します.
キーワードによる行指定も可能で, 例えば sed -e '/^BEGIN$/,/^END$/d' とすると BEGIN から END までの行を削除します.

sed は基本的な置換の記法しか知らなかったので, もし sed -n -e '10,25p' などを見ていたら理解することを諦めていそうです. こういう機能もあると分かったので, 今後は多少の応用は飲み込めそうです.

ファイルから 1 行ずつ読み取って処理する

ありがちな処理ですが以下のようにすれば可能です.

while read LINE
do

done < file

シンプルですが知らないと割と悩みそうです.

もし同じことを for でやりたい場合どうすれば良いでしょうか. 単純に以下のようにすると改行だけでなく空白やタブでも区切りられてしまうので, 1 行ずつの処理にはなりません.

for LINE in `cat file`
do

done

そこで, 改行のみで区切るように区切り文字を変更すると意図した通りに動きます.

IFS='
'
for LINE in `cat file`
do

done

IFS 変数はデフォルトでは空白, タブ, 改行です. 処理が終わったら元に戻せるように, IFS に値を代入する前に別の変数にコピーしておくと良いです.

デバッグ

sh -xv file.sh でほとんどの用は足りると思います. これでどういう風にスクリプトが実行されるのか, 十分に詳細を把握することができます.
プログラムはある程度の規模になると大抵の場合はデバッグをすることになると思うので, デバッグの仕方が紹介されているのは実用的で良いと思いました.

サンプルで学ぶ

本書では数多くのシェル関数やシェルスクリプトがサンプルとして紹介されています. 実例を通じて学びたいときや題材が欲しいときにはうってつけです. 一部を上げると以下のようなものがあります (実際は何倍もあります).

  • ファイルやディレクトリのフルパスを得る関数
  • 端末画面をクリアする関数
  • 文字列が数値であるかどうか判定する関数
  • プロセスに対してシグナルを送るスクリプト
  • マシンの IP アドレスを得るスクリプト

例があると「こういうときどう書けばいいんだっけ」という場合にも役立ちそうです.
ちなみに, 本を真似するだけなのもつまらないので, 自分でもいくつか考えてみました (momori256/note-intro-to-unix-shell). 2 つのファイルの更新日時を比較する datecmp, あいまい検索の fzf を利用した cd, open, man などです.
大抵のことなら書きたいと思ったときにすぐ書けるようになった...とまでは行きませんが, 経験を積めばかなりの範囲をカバーできるようになりそうです.

結び

必要になったときに都度調べながらワンライナーやスクリプトを書くのも良いですが, 体系立った知識を得ることが遠回りなように見えて一番の近道なのではないかと思いました. 学問に王道なしということかも知れません.

シェルスクリプトは簡単な処理を短く書くのに非常に向いていると感じました. 枯れているので昔の情報も役立ちますし, バージョンアップで動かなくなるといった心配も無用です.
一方で, 期待したほどの汎用性はないのだなと思いました. 例えばコマンドの結果がシステムによって異なることはよくあるようで, その違いは地道に吸収するしかありません. lsuname などの基本的なコマンドでさえ仕様が統一されていないとなると, 汎用性の高い本格的なスクリプトを書くのはなかなかに骨が折れそうです.
結局, 他の環境でも動く汎用性を追求するなら C 言語なり C# なりで書いた方が良さそうだと思いました (C 言語もシステムによって include するべきファイルが違ったりしますが). とはいえ, ちょっとした作業をスクリプトやワンライナーで書ける身軽さはシェルスクリプトが圧倒的に有利なので, 日常的に使っていくことになりそうです.

sedawk の強力さの一端が示されていましたが, これらは単体で一冊の本になりそうなくらい奥が深い雰囲気を感じました. 今後ある程度の基礎が身についたところで, さらに学びを深めていっても良いかも知れないと思いました. 本書は至るところに小さなコツが書いてあり, 全てを把握できたわけではないので折りに触れて読み返すことになりそうです.