bash: readとパイプと環境変数


2011年 09月 21日

問題

シェル(ただし Bourne Shell 系に限る)はお友達です。
一見すると役に立たないように思えるコマンドでも、
組み合わせ次第で複雑な処理をこなすための道具になります。

例えば行毎に ID が記載されているファイル ids があったとしましょう。
各 ID を SHA1 ハッシュ値に置き換えたものが必要な場合、
以下のコマンドで生成することができます
(SHA1 ハッシュ値の算出には shasum を使っています):

cat ids | while read id
do
    echo -n "$id" | shasum
done | cut -d ' ' -f 1 >ids.sha1

このように、行単位で何か処理を行う場合には read を使います。
ちょっとしたことなら sedawk で済ませられるのですが、
上記のように「行毎にコマンドを実行して云々」をやろうとすると無理が出てくるので、
read を使う方が自然です
(若者ならば何であれ perl で済ませるところですが、
今回は read を使いたいので考えないことにします)。

ところで、いきなり上記のようなコマンド列がすらすら書ける人は稀です。
ふつうの人は単純な処理(例えば指定した ID から SHA1 ハッシュ値を生成するだけ)を実現してから、
徐々に目的の処理を実装していくことになります。

筆者の場合、 read は滅多に使ったことがないため、
まずおさらいがてら read の使い方の確認から始めました。
read は標準入力から1行読み込み、
読み込んだ文字列を引数で指定された名前の変数に格納します
(話を単純にするため「単語」分割云々は考えないことにします)。
ということは、以下のコマンドを実行すると i には 2 が格納されるので、
1 2 の順で出力が行われるはずです。試してみましょう:

$ i='1'; echo "$i"; echo '2' | read i; echo "$i"
1
1

えっ……

3回目の echo では 2 が出力されると予想したのですが、
何故か 1 が出力されました。一体 read が格納した文字列はどこへ消えたのでしょうか。

さらに不可思議なことに、以下のように while で括ると今度は期待通りの出力がなされます:

$ i='1'; echo "$i"; echo '2' | while read i; do echo "$i"; done
1
2

まさかと思って while の後に i の内容を出力してみると、今度は以下の結果になります:

$ i='1'; echo "$i"; echo '2' | while read i; do echo "$i"; done; echo "$i"
1
2
1

read の挙動を考えると、
while の有無(と前後)で i 内容がころころ変動するのは不可思議です。
これはどういうことなのでしょうか。

解答

答えはパイプ(|)にあります。

Bash Reference Manual – 3.2.2 Pipelines
によると、パイプの挙動に関して以下の説明があります:

Each command in a pipeline is executed in its own subshell (see Command Execution Environment).

つまり、

i='1'; echo "$i"; echo '2' | read i; echo "$i"

は、敢えて書き換えるならば

i='1'; echo "$i"; echo '2' | ( read i ); echo "$i"

と同等ということです。より誇張するならば

i='1'; echo "$i"; echo '2' | ( read xxx ); echo "$i"

ということです。こう書き換えれば 2 が出力されない理由ははっきりします。
read は確かに標準入力から読み込んだ内容を i に格納しているものの、
その i はサブシェルの i であり、元々のシェルのプロセスの i とは別物です。
ですから、 read が格納した内容はどこにも使われることがないまま捨てられていたということです。

次の問題は while の有無(と前後)で i の内容が変動しているように見えた点ですが、
これもパイプの定義を参照すれば理由が分かります。

Bash Reference Manual – 3.2.2 Pipelines
によるとパイプの定義は以下の通りです:

[time [-p]] [!] command1 [ [| or |&] command2 …]

ここで command1 や command2 は
ls のような単純なコマンドや
{ foo; bar; baz; } のようなグループ化されたコマンドや
for のような複合コマンドを指します。
while は複合コマンドなので、 while 全体がひとつの command として扱われています。
つまり、

i='1'; echo "$i"; echo '2' | while read i; do echo "$i"; done; echo "$i"

は、敢えて書き換えるならば

i='1'; echo "$i"; echo '2' | ( while read i; do echo "$i"; done ); echo "$i"

と同等ということです。
read が格納した値はサブシェル内、
この場合は while の中でしか参照できないということです。

予想外のところで躓いてしまいましたが、
これでまたひとつシェルと仲良くなれました。
やりましたね。