FLAGS

MENU

NOTICE

2020年2月8日土曜日

Bashのリダイレクションの書き方について (oka01-mrzjgivvrnlmzbyc)


とてもわかりにくい Bashのリダイレクションの書き方をとてもわかりやすく説明した『よくわかるリダイレクション・チュートリアル』という良い英語の記事がある。Bashでスクリプトを書く方々がいつもきまって悩む部分をとてもわかりやすく説明してくれる記事だ。しかしこの件について日本語で説明している記事がネット上にほとんど見当たらなかったので、今回大雑把にざっと訳してみた。

翻訳について

当記事は次の記事の翻訳です。
よくわかる!リダイレクションのチュートリアル!Bashハッカーの為のウィキ
利用規約:翻訳者オカアツシはこの翻訳についての著作権を放棄します。また翻訳の間違いが元で発生した損失について一切の責任を負いません。利用を以って当規約に同意としたものとみなします。

初めに

このチュートリアルはリダイレクションの完全なガイドではありません。ヒヤドキュメントや名前付きパイプなどについては一切説明しません。ただ僕は単に例の 3>&2 とか 2>&1 とか 1>&3- とかそういう奴が何をしているのかを理解するお手伝いをしたいと思うだけです。

stdin, stdout, stderrとは

Bash がスタートすると通常3つのファイル記述子が作られます。それは一般的に 標準入力・標準出力・標準エラーと呼ばれています。たとえば LinuxターミナルエミュレーターでBashを実行すると次のように表示されるでしょう。

# lsof +f g -ap $BASHPID -d 0,1,2
COMMAND   PID USER   FD   TYPE FILE-FLAG DEVICE SIZE/OFF NODE NAME
bash    12135 root    0u   CHR     RW,LG 136,13      0t0   16 /dev/pts/5
bash    12135 root    1u   CHR     RW,LG 136,13      0t0   16 /dev/pts/5
bash    12135 root    2u   CHR     RW,LG 136,13      0t0   16 /dev/pts/5

この /dev/pts/5 は擬似ターミナルです。Bash は この擬似ターミナルから標準入力を読み取り stdout/stderrを介してこの擬似ターミナルに出力します。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

コマンド・複合コマンド・サブシェルなどが実行されると、これらのファイル記述子が継承されます。 たとえば echo foo を実行すると foo というテキストが /dev/pts/5 に送り込まれます。これは何故かというと、このコマンドを実行したシェルの標準出力 が /dev/pts/5 に接続しているからです。実行されたコマンドはこのファイル記述子1を /dev/pts/5に接続した状態を継承するわけです。 

単純なリダイレクション

出力のリダイレクション "n> file"

> は最もシンプルなリダイレクションでしょう。

echo foo > file

このコマンド直後に置かれた > fileecho コマンドのファイル記述子を変化させます.  これがファイル記述子 file に繋がる様に変化させるわけです。  (> file1>fileと同じ) するとこうなるでしょう。
the > file after the command alters the file descriptors belonging to the command echo. It changes the file descriptor 1 (> file is the same as 1>file) so that it points to the file file. They will look like:

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| file                  |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

すると今しがた echo によって標準出力へ送り込まれた文字は最終的に file というファイルに流れ着きます。同じように command 2> file は標準エラー出力を変化させ fileに流れ着くように変化させるわけです。 では command 3> file  は何をするのでしょうか。これは新しいファイル記述子を作成してfileにつなげるわけです。これらの初期化が終わったあとで、コマンドが実行されます。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
new descriptor   ( 3 ) ---->| file                  |
                  ---       +-----------------------+

このコマンドはこの新しいファイル記述子に何をするのでしょうか。それは状況によります。しばしば何もしません。以下で何故この新しい記述子が必要になるのかを見ていきましょう。

入力のリダイレクション "n< file"

もし  command < file をつかってコマンドを実行したら何が起こるのでしょうか。これは実はファイル記述 0 を変化させます。実行結果は次の様になります。

                  ---       +-----------------------+
standard input   ( 0 ) <----| file                  |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

この状態でコマンドが標準入力から読み込みを行うと、コマンドはコンソールではなく file から入力を行います。 >と同じ様に、< も新しいファイル記述子を作ることができます。のちに command 3<file が何故便利なのかを見ていくことになるでしょう。


パイプについて |

では | は何をするのでしょうか。左側のコマンドの標準出力を右側の標準入力につなげます。つまりこれはパイプという名前の新しいファイルを作成して、左側のコマンドの書き込み先に設定し、右側のコマンドの読み込み元に設定する訳です。

           echo foo               |                cat

 ---       +--------------+               ---       +--------------+
( 0 ) ---->| /dev/pts/5   |     ------>  ( 0 ) ---->|pipe (read)   |
 ---       +--------------+    /          ---       +--------------+
                              /
 ---       +--------------+  /            ---       +--------------+
( 1 ) ---->| pipe (write) | /            ( 1 ) ---->| /dev/pts     |
 ---       +--------------+               ---       +--------------+

 ---       +--------------+               ---       +--------------+
( 2 ) ---->| /dev/pts/5   |              ( 2 ) ---->| /dev/pts/    |
 ---       +--------------+               ---       +--------------+

これが可能になるのはこれらの接続作業がコマンドが実行される前に実行されているからです。そしてコマンドはこれらの接続済みのファイル記述子を継承しているだけなのです。


ファイル記述子について知る

ファイル記述子の複製 2>&1

これまでファイル記述子を作ったりリダイレクトしたりする方法を見てきました。では次にこれを複製するためにどうすればいいかを見ていきましょう。まず最初に見るのはお馴染みの 2>&1 です。これはどういう意味でしょうか。ファイル記述子 に送り込まれた文字がファイル記述子に送り込まれるべき場所に送られる様になります。 command 2>&1 では面白くないので ls /tmp/ doesnotexist 2>&1 | less を使いましょう。



 ---       +--------------+              ---       +--------------+
( 0 ) ---->| /dev/pts/5   |     ------> ( 0 ) ---->|from the pipe |
 ---       +--------------+    /   --->  ---       +--------------+
                              /   /
 ---       +--------------+  /   /       ---       +--------------+
( 1 ) ---->| to the pipe  | /   /       ( 1 ) ---->|  /dev/pts    |
 ---       +--------------+    /         ---       +--------------+
                             /
 ---       +--------------+  /           ---       +--------------+
( 2 ) ---->|  to the pipe | /           ( 2 ) ---->| /dev/pts/    |
 ---       +--------------+              ---       +--------------+

何故ファイル記述子の複製と呼ぶのでしょうか。それは 2>&1 を実行すると2つのファイル記述子が1つの同じファイルを指す様になるからです。 これのことを『別名(エリアス)』と呼ばないでください。何故かというと 2>&1 を実行して2をファイル B にリダイレクトしてもファイル記述子2 は依然としてファイル A に対して開かれているからです。 これは標準入力と標準出力の両方をリダイレクトしようとする人がしばしば誤解していることです。このことについてもう少し見ていきましょう。仮に2つのファイル記述子 s と tが次のような状態だったとします。

                  ---       +-----------------------+
 a descriptor    ( s ) ---->| /some/file            |
                  ---       +-----------------------+
                  ---       +-----------------------+
 a descriptor    ( t ) ---->| /another/file         |
                  ---       +-----------------------+

次のリダイレクション t>&s  (但し ts は任意の整数 ) は次のような処理を行います。

ファイル記述子 s の内容がなんであれ取り敢えず全部 ファイル記述子 t にコピーしろ

つまり得られるファイル記述子は

                  ---       +-----------------------+
 a descriptor    ( s ) ---->| /some/file            |
                  ---       +-----------------------+
                  ---       +-----------------------+
 a descriptor    ( t ) ---->| /some/file            |
                  ---       +-----------------------+

実際に内部的に見ると、これらのファイル記述子は実はシステムコール fopen によって開かれたものでその正体はただの書き込み/読み込み用のファイルへのポインターです。なお継承する時はファイルの読み込み位置/書き込み位置もあわせて継承されることに注意しましょう。

同じ様に書き込みファイル記述子 s に書き込んだ行は ファイル記述子 t にも書き込まれることに
なります。

この文法はとても混乱を招きやすいものです。この矢印を見たら誰でもその矢印の方向がコピー の方向を表している様に感じると思うでしょうが、実はこの方向は逆なのです。これは実は 到着 >& 出発 ということなのです。 つまりやや煩雑ですが次のようなことをするのと同じです。

exec 3>&1         # Copy 1 into 3
exec 1> logfile   # Make 1 opened to write to logfile
lotsa_stdout      # Outputs to fd 1, which writes to logfile
exec 1>&3         # Copy 3 back into 1
echo Done         # Output to original stdout

リダイレクションの順番 "> file 2>&1" vs. "2>&1 >file"

コマンドライン中のリダイレクション指定の場所は特に指定されていませんが、その順番には意味があります。リダイレクションは左から右へ処理されていきます。次の例を考えてみましょう。
  • 2>&1 >file
よくある間違いは command 2>&1 > filestderrstdout の両方に実行することです。これがどういうことなのか見ていきましょう。 ではコマンドを実行してファイル記述子がどうなるか見てみましょう。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

するとシェルは 2>&1 を見つけ 1 を複製し、次のようになります。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

そのとおりです! 何も変わりません! 2は既に1と同じ場所を指しているからです。

次にシェルは > file つまり stdout を変更します。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| file                  |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

これは期待する動作ではありません!

では次の(正しい)例を見てみましょう。

  • >file 2>&1
シェルは同じ様に左から処理して最初に >file  を見つけます。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| file                  |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

そして複製 2>&1 を行います。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| file                  |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| file                  |
                  ---       +-----------------------+

見事に 12 がファイルにリダイレクトされるようになりました。

何故 sed 's/foo/bar/' file >file はダメなのか

これもよくある間違いです。同じファイルから読み込んで標準出力に書き込みたい。これを実現するため標準出力を書き換えようとするファイルに変更してしまう。ここでの問題はこれまで見てきたようにリダイレクション処理が実はコマンドの実行が始まる前に行われるからです。

つまり標準出力は sed が始まる前に既にリダイレクトされてしまっているのです。そして同時に > が持っている副作用『書き込む前にファイルを空にする』が実行されます。つまり sedの実行が開始される時、そのファイルは既に空になってしまった後ということになります。

内部コマンド exec 文の使い方

Bash では exec 内部コマンドは、シェルを特定のプログラムによって置き換えるためのコマンドです。このコマンドがリダイレクションにどの様な影響を与えるのでしょうか。実は  exec はファイル記述子を操作する機能があります。もしもプログラムを指定せずに exec を実行すると exec 以降 シェルのファイル記述子(訳注:デフォルトのリダイレクション)の設定が変更されます。
 
例えば exec 2>fileを実行したあとの全てのコマンドは次のようなファイル記述子を持ちます。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| file             |
                  ---       +-----------------------+

exec 2>file を実行したあとの全てのエラーは全てのコマンドに 2>file をつけて実行したように、全てのエラーが stderr にリダイレクトされます。

つまり exec は例えばスクリプトファイルで実行する全てのコマンドのエラーをログとしてファイルに保存しようと思ったら スクリプトファイル先頭で exec 2>myscript.errors を実行するだけでよいのです。

他の用途を見てみましょう。ファイルを一行一行見ていきたいとします。これは簡単です。 次のように実行すればよいのです。

 while read -r line;do echo "$line";done < file

さて次にそれぞれの行を表示したあとで一時停止するように改造したくなったらどうでしょうか。

 while read -r line;do echo "$line"; read -p "Press any key" -n 1;done < file

驚くべきことに、これは正しく動作しません。何故でしょうか。それはwhile 文のなかのシェルのファイル記述子は次のようになっているからです。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| file                  |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+


そして read はこれらのファイル記述子を継承します。そして(read -p "Press any key" -n 1)もそれを継承することになります。では help read を読んで read が読みだすファイル記述子を指定することにしましょう。次の様に exec 使って新しいファイル記述子を作成します。

 exec 3<file-
 while read -u 3 line;do echo "$line"; read -p "Press any key" -n 1;done

ファイル記述子は次のようになります。

                  ---       +-----------------------+
standard input   ( 0 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard output  ( 1 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
standard error   ( 2 ) ---->| /dev/pts/5            |
                  ---       +-----------------------+

                  ---       +-----------------------+
new descriptor   ( 3 ) ---->| file                  |
                  ---       +-----------------------+

これで正しく動作するでしょう。

ファイル記述子を閉じよう

ファイル記述子を閉じるのは簡単です。それを - として複製すればよいだけです。 例えば stdin <&- と stderr 2>&-  を閉じてみましょう。


 bash -c '{ lsof -a -p $$ -d0,1,2 ;} <&- 2>&-'
 COMMAND   PID USER   FD   TYPE DEVICE SIZE NODE NAME
 bash    10668 pgas    1u   CHR  136,2         4 /dev/pts/2

このように {} のなかではファイル記述子が 1 しかないことがわかります。もしスクリプトファイルがファイル記述子をごちゃごちゃに開いてしまっても、恐らくOSが綺麗に掃除をしてくれる筈ですが、ファイル記述子を作ったらきちんとそれを閉じたほうが恐らくよいでしょう。何故かというと例えばもし exec 3>file というファイル記述子を開いたとしたら、それ以降実行する全てのコマンドはそのファイル記述子を継承してしまうでしょう。よってファイル記述子を作ったら次のようにするとよいかも知れません。

exec 3>file
.....
#commands that uses 3
.....
exec 3>&-

#we don't need 3 any more

僕はこれまで stderr を捨てるために command 2>&- みたいなことをしている人たちを見たことがあるのですが、あまりよいアイデアではないような気がします。確かにそれでも動くでしょうが、そのあとに実行される全てのアプリケーションがその閉じてしまった stderr に対して正しく動作するかどうかは疑わしいと僕は思うのです。

だから僕は可能な限り2>/dev/null を使います

実際の例

この例は this post (ffe4c2e382034ed9) ( comp.unix.shell )から持ってきたものです。

{
  {
    cmd1 3>&- |
      cmd2 2>&3 3>&-
  } 2>&1 >&4 4>&- |
    cmd3 3>&- 4>&-

} 3>&2 4>&1

リダイレクションは左から右に処理されます。しかしファイル記述子は継承されるため外側から内側へ継承される文脈も追跡しなければいけません。

一番外側の { } 3>&2 4>&1 を見てみましょう。

 ---       +-------------+    ---       +-------------+
( 0 ) ---->| /dev/pts/5  |   ( 3 ) ---->| /dev/pts/5  |
 ---       +-------------+    ---       +-------------+

 ---       +-------------+    ---       +-------------+
( 1 ) ---->| /dev/pts/5  |   ( 4 ) ---->| /dev/pts/5  |
 ---       +-------------+    ---       +-------------+

 ---       +-------------+
( 2 ) ---->| /dev/pts/5  |
 ---       +-------------+


標準出力と標準エラーを2つコピーします。ここで 3>&1 4>&1 だったとしても同じ結果になることに注意しましょう。 何故かというとここではこのコマンドをターミナルから実行するからです。この場合だと 12 両方共にターミナルにつながっているからです。ここでは練習として 1file.stdout として 2 を file.stderr として設定することにしましょう。この方がリダイレクションのメカニズムの素晴らしさをよく理解できるからです。

続きを見ていきましょう。次は2番めのパイプです。 | cmd3 3>&- 4>&-


 ---       +-------------+
( 0 ) ---->| 2nd pipe    |
 ---       +-------------+

 ---       +-------------+
( 1 ) ---->| /dev/pts/5  |
 ---       +-------------+

 ---       +-------------+
( 2 ) ---->| /dev/pts/5  |
 ---       +-------------+

これは前回のファイル記述子を継承しており、3 と 4 のファイル記述子を閉じて読み込み用のパイプを設定しています。次に左側にある2番めのパイプ {…} 2>&1 >&4 4>&- | です。

 
 ---       +-------------+  ---       +-------------+
( 0 ) ---->| /dev/pts/5  | ( 3 ) ---->| /dev/pts/5  |
 ---       +-------------+  ---       +-------------+

 ---       +-------------+
( 1 ) ---->| /dev/pts/5  |
 ---       +-------------+

 ---       +-------------+
( 2 ) ---->| 2nd pipe    |
 ---       +-------------+

ファイル記述子1はパイプにつながっており、2は1のコピーになっています。 つまり1つのファイル記述子をパイプ (2>&1) につなげています。そして 1 は 4(>&4) のコピーに、そして 4 は閉じられています。これらは内側の {} からのファイル記述子になっています。更に内側に入って右側のパイプを見ていきます。 | cmd2 2>&3 3>&-

 ---       +-------------+
( 0 ) ---->| 1st pipe    |
 ---       +-------------+

 ---       +-------------+
( 1 ) ---->| /dev/pts/5  |
 ---       +-------------+

 ---       +-------------+
( 2 ) ---->| /dev/pts/5  |
 ---       +-------------+

これは前のファイル記述子を継承し0を最初のパイプに接続します。ファイル記述子3によって2を作成し3を閉じます。最後に左側のパイプを見ていきます。

 ---       +-------------+
( 0 ) ---->| /dev/pts/5  |
 ---       +-------------+

 ---       +-------------+
( 1 ) ---->| 1st pipe    |
 ---       +-------------+

 ---       +-------------+
( 2 ) ---->| 2nd pipe    |
 ---       +-------------+

これもまた左側の2番めのパイプを継承します。ファイル記述子1は最初のパイプに接続され3は閉じられます。

これがもしこれらのコマンドだけであれば、全体の目的はとても明快です。

                                                   cmd2

                                           ---       +-------------+
                                       -->( 0 ) ---->| 1st pipe    |
                                      /    ---       +-------------+
                                     /
                                    /      ---       +-------------+
         cmd 1                     /      ( 1 ) ---->| /dev/pts/5  |
                                  /        ---       +-------------+
                                 /
 ---       +-------------+      /          ---       +-------------+
( 0 ) ---->| /dev/pts/5  |     /          ( 2 ) ---->| /dev/pts/5  |
 ---       +-------------+    /            ---       +-------------+
                             /
 ---       +-------------+  /                       cmd3
( 1 ) ---->| 1st pipe    | /
 ---       +-------------+                 ---       +-------------+
                             ------------>( 0 ) ---->| 2nd pipe    |
 ---       +-------------+ /               ---       +-------------+
( 2 ) ---->| 2nd pipe    |/
 ---       +-------------+                 ---       +-------------+
                                          ( 1 ) ---->| /dev/pts/5  |
                                           ---       +-------------+

                                           ---       +-------------+
                                          ( 2 ) ---->| /dev/pts/5  |
                                           ---       +-------------+

最初に1 と 2を異なるファイルに設定しましたが、最初に予告した通りに cmd2cmd3 からの標準入力が元々の標準入力につながり、標準エラー出力が元々の標準エラー出力につながるところを観察することができました。

リダイレクションの文法について

僕はかつて 0&<3 3&>1 3>&1 ->2 -<&0 &-<0 0<&- みたいなやつを正しく選ぶのにいつも苦労していたのですが、多分それは、記号が結果の状態を表していて実際にどういう処理をしているのかを表していないからではないかと思うのです。例えば「開く」「閉じる」「複製する」等々が書き表されてないと思うんです。で、もし(僕が発見した)このルールがこれを読む人の状況にフィットしていれば、助けなるかなと思うのでここに書き込みます。
 
(訳注:中略。興味がある方は各自オリジナル記事を参照のこと。)

コーディングスタイルについて

(訳注:中略。興味がある方は各自オリジナル記事を参照のこと。)

結論

このチュートリアルが読者の助けになることを願っています。
1>&3- について説明するの忘れたのでマニュアル読んで下さい。 ;-)
Stéphane Chazelasさんどうもありがとうございます。
僕はサンプルと冒頭の文章を盗みました。
僕はこのイントロに触発されたのですが、ここにいくと更に詳しいサンプルを見ることができます。
最後の例は 次のポストから持ってきました。

参考文献






著者オカアツシについて


小学生の頃からプログラミングが趣味。都内でジャズギタリストからプログラマに転身。プログラマをやめて、ラオス国境周辺で語学武者修行。12年に渡る辺境での放浪生活から生還し、都内でジャズギタリストとしてリベンジ中 ─── そういう僕が気付いた『言語と音楽』の不思議な関係についてご紹介します。

特技は、即興演奏・作曲家・エッセイスト・言語研究者・コンピュータープログラマ・話せる言語・ラオ語・タイ語(東北イサーン方言)・中国語・英語/使えるシステム/PostgreSQL 15 / React.js / Node.js 等々




おかあつ日記メニューバーをリセット


©2022 オカアツシ ALL RIGHT RESERVED