1. 目的
shellを制作する際に見落としがちな機能などについて1年ほど前に制作したshellのことを振り返りつつその時に知ったshellの挙動や機能について書いていきたいと思います.
題名がshellを自作する際の留意点となっていますが, 実際にshellを制作する際のアルゴリズムやプログラム等の説明はしません. あくまでshellの機能や挙動の説明に留めたいと思います.
以降, 特に言及がない限りbashを基にして話を進めていきます. バージョンは3.2です.
2. 文字列の解析
bashでは, 文字列の展開は以下順番で行われます(詳しい説明は省略).
挙動を確認し合わせるのではなくこの通りに実装をしないとあとで分けのわからないことになります.
1. ブレース展開
文字列を展開する
$> echo {1..9}
1 2 3 4 5 6 7 8 9
参考
2. チルダ展開
ホームディレクトリ等を置換する
$> echo ~
/User/hoge
3. パラメータの展開
環境変数を置換する
$> echo $HOME
/User/hoge
参考
4. コマンド置換
コマンドの実行結果を文字列に置き換える
$> echo `echo hello world`
hello world
5. 算術式展開
数式の結果を置換する
$> echo $((1+2))
3
6. 単語の分解
空白やタブによって区切られた文字が分割される
$> echo hello world
hello world
7. パス名展開
パターンにマッチするファイルやディレクトリに置き換える
$> ls *.txt
a.txt b.txt c.txt
8. クォートの削除
ダブルクォートやシングルクォートを削除する
$> echo "hello world"
hello world
以上の順番でコマンドは展開されることになります.
留意点
「$」の置換
bashで「$」を付けることでshell変数や環境変数に置換することができます. しかし「$」単体で扱った場合に少し奇妙な動作をします.
$> echo $+ $
$+ $
$> echo $""
$>
通常環境変数名に使用することができない文字や「$」単体で入力した場合には, 「$」が出力されますが, 「$」の後にダブルクオーテーションを付けた場合には「$」が出力されません.
3. ビルドインコマンド
bashのコマンドには大きく分けてビルドインコマンドと外部コマンドの2種類があります.
この2つには, bash自体のプロセスで実行されるか, bashを親プロセスとする子プロセスで実行されるかの違いがあります. プロセスが異なるということは, 影響が及ぶ範囲がプロセス単体のコマンドでおおきな違いがでます.
例えば, lsはディレクトリの状態やファイルの情報を表示するだけなので実行するプロセスはどこであってもかまいません. 同様の理由でmkdir等のコマンドも外部コマンドで問題はありません.
しかし, cdやexportなど外部コマンドでは不都合が起きます. exportコマンドでは, 環境変数の変更や追加を行いますが, 環境変数は親プロセスから子プロセスに渡されるものであり, 子プロセスから親プロセスへ影響を及ぼすことはありません. そのためもしも, exportが外部コマンドとして呼び出された場合には以下のような, なにもしないコマンドになります.
$> echo $HELLO
$> export HELLO=world
$> echo $HELLO
$>
同様の理由でcdも外部コマンドの場合には, なにもしないコマンドになります.
また, コマンドがビルドインコマンドが外部コマンドかを見分けるにはtypeコマンドを用います
$> type ls
ls is hashed (/bin/ls)
$> type cd
cd is a shell builtin
留意点
実装する必要のあるビルドインコマンド
上記の理由から, shellを自作する際には最低でもcd, export, unset程度は自作しておく必要があります. でなければ完成しても, ディレクトリも移動できない, 環境変数も設定できなくなってしまいます.
ビルドインコマンドがある場合に外部コマンドを呼び出す方法
先述のようにあまりshell上で使用する上であまり意味はありませんが, 以下のようにコマンド名と最低1文字, 大文字小文字を反転させることで外部コマンドとして呼び出すことができます. (ファイルシステムが大文字小文字を判別していない場合)
$> pwd
/User/hoge
$> CD ..
$> pwd
/User/hoge
これは, ビルドインコマンドが文字列が一致しているか否かで判定されているのに対して, 外部コマンドがファイル名が一致しているか否かで判定されている為です.
また, 判定の順番はビルドインコマンド, 外部コマンドの順番である為, 大文字小文字が完全に一致する場合にはビルドインコマンドが呼び出されます.
4. 環境変数とPATH
2で述べたパラメータ展開時に使用される環境変数は先述のビルドインコマンドであるexportによって設定することができます.
$> export $A=hello
hello
また, その中でもPATHと名前の付く環境変数は外部コマンドの参照先として特別な意味を持っています.
$> echo $PATH
/usr/gnu/bin:/usr/local/bin:/usr/ucb:/bin:/usr/bin
環境変数PATHにはコロンで区切られたいくつかのディレクトリのパスが入っており, そのディレクトリにある実行ファイルを外部コマンドとして呼ぶことができるようになっています.
留意点
PATHの順番
コロンで区切られたディレクトリは左から検索されることになっています.
$> echo $PATH
/usr/gnu/bin:/usr/local/bin:/usr/ucb:/bin:/usr/bin
$> ls /bin/hoge /usr/bin/hoge # 2つのディレクトリにhogeという名前の実行ファイルが存在する
/bin/hoge /usr/bin/hoge
$> hoge # /bin/hogeが実行される
空のパス
PATHで空のパスを入れた際にはカレントディレクトリを参照するようになります.
man bash より
A zero-length (null) directory name in the value of PATH indicates the current directory.
$> export PATH=":"$PATH
$> ls huga
/User/hoge/huga
$> huga # カレントディレクトリにあるhugaが実行される
「:」のエスケープ
留意すべき点というよりかは, 留意しなくてもいい点です.
上記のようにPATHはコロンによっていくつかのパスが区切られていますが, 同時にコロンを含む名前のディレクトリやファイルを制作することも可能です. その際にはパスを区切るコロンが優先され, 実行ファイルを見つけることができなくなります.
$> mkdir hoge:huga
$> cp /bin/ls $PWD/hoge\:huga
$> export PATH=$PWD/hoge\:huga
$> ls
bash: ls: command not found
5. コマンドの分割
「|」や「;」, 「&」などの記号によってコマンドを1行で複数のコマンドを実行し, 実行結果によって次の処理を変化させたりすることができます.
例えばパイプを例に挙げると
$> ls | grep hello
hello.txt
hello.py
このようにlsで実行した結果(標準出力)をgrepに入力するなどの処理が可能です.
また, セミコロンでは
$> echo hello; echo world;
hello
world
このように複数のコマンドを同時に実行することが可能です.
留意点
実行順序
セミコロンでは, 左から順にコマンドが実行され左のコマンドが終了すると右のコマンドが実行されます. しかし, パイプでは繋がれたコマンド全てが同時に実行されます.
その為, 以下のコマンドを実行した際にはこのような違いが出ます.
$> start=`date +'%s'`; sleep 4 ; sleep 4; echo $((`date +'%s'` - ${start}))
8
$> start=`date +'%s'`; sleep 4 | sleep 4; echo $((`date +'%s'` - ${start}))
4
sleepコマンドは標準入出力を使用しない為, 入力待ちは起こらずに第一引数で与えられた秒数だけ停止した後にプログラムを停止します.
1行目のセミコロンを用いたコマンドでは4秒停止した後に4秒再度停止する為, 合計で8秒間停止します.
一方, パイプの処理用いた場合では1つ目と2つ目のsleepコマンドは同時に4秒間の停止を行うため, 全体で4秒間処理を停止します.
終了ステータス
前述のようにセミコロンやパイプでは実行順序に違いがあります.
しかし, この2つの終了ステータスはどちらも最も右のコマンドの終了ステータスが最終的な終了ステータスとなります. (オプションを指定していない場合)
man bashより
The return status of a pipeline is the exit status of the last command.
$> sleep 10 | UnownCommand
Error msg
$> echo $?
127
上記の処理で最後に処理が終わるのはパイプの前のsleepコマンドですが, 最終的な終了ステータスは左のコマンドの終了ステータスになります.
パイプのプロセス
先述のようにパイプは同時に実行されます, もっと具体的に言えばプロセスによる同時実行が行われています.
その為, パイプ内のコマンドはすべてshellとは別のプロセスによって実行されます.
ビルドインコマンドの項で述べたようにプロセスが異なる場合にshellに影響を及ぼさないコマンドが存在しそれらのコマンドはパイプ内で使用することはできません.
$> pwd
/user/hoge
$> cd .. | echo hello
hello
$> pwd
/user/hoge
パイプに必要なファイルディスクリプタの数
この項目は仕様ではないと思うので踏襲する必要はないですが, より高い利便性を得ることができます.
パイプはファイルディスクリプタを用いてプロセス間で通信を行っています.
例えば, 以下のように3つのコマンドがパイプによって並んでいる時, 真ん中のコマンド(プロセス)で必要なファイルディスクリプタの数は入力用(親のプロセスから引き継ぐ)と出力用と子のプロセスの入力用に3つです.
$> ps | sort | less
試しにプロセス単位で使用できるファイルディスクリプターの数を6個(標準入出力, エラー出力, パイプ用3つ)に制限してみても, 問題なくパイプが繋がることが確認できるかと思います.
$> ulimit -n 6
$> echo hello | cat | cat | cat | cat | cat | cat
hello
これは, パイプによって区切られたコマンドがすべてshellのプロセスからforkされているのではなく, 右のコマンドから順番にforkされていく為です.
参考: http://web.cse.ohio-state.edu/~mamrak.1/CIS762/pipes_lab_notes.html
6. リダイレクト
標準出力の結果をファイルに書き込んだり, ファイルの内容を入力したりする際にはリダイレクトを用いることができます.
$> echo hello > output.txt
$> cat < output.txt
hello
また, エラー出力をリダイレクトしたり, 追記モードでファイルを開いたりすることも可能です
$> ls jvisjafi 2> error.txt # エラー出力
$> cat error.txt
Error message
$> echo hello > res.txt; echo world >> res.txt # 追記モード
$> cat res.txt
hello
world
留意点
複数リダイレクトのリダイレクト先
一つのコマンドに対して複数のリダイレクト先を設定しようとすると, シェルは一番右のファイルに対してリダイレクトを行います.
$> echo hello > a.txt > b.txt > c.txt
$> head *.txt
==> a.txt <==
==> b.txt <==
==> c.txt <==
hello
またこの時, 一番右以外のファイルは初期化が行われます.
パイプとの優先順位
ファイルディスクリプタとパイプを同時に接続しようとしたとき, シェルはリダイレクトを優先します.
$> echo hello > a.txt | cat
$> cat a.txt
hello
おわりに
まだまだ, 紹介しきれていない点は存在しますが書き出すときりがないので一度このあたりで止めておきます.
より詳しく知りたい方は, bashのmanを参照してください.
man page of bash