🐚

shell scriptで標準出力と標準エラー出力を入れ替えて出力する

5 min read 2

環境

以下が、本記事執筆時点での筆者の環境です。

  • OS
    • Arch Linux 5.11.7-arch1-1
  • shell
    • bash - 5.1.4(1)-release (x86_64-pc-linux-gnu)
    • zsh - 5.8 (x86_64-pc-linux-gnu)
    • fish - 3.1.2

問題設定

ふと、このような疑問が頭を過ぎりました。「shell scriptで、あるコマンドの標準出力と標準エラー出力を入れ替えてから次のコマンドにパイプさせることはできないだろうか。」

標準出力と標準エラー出力にそれぞれ出力するコマンドがあるとします。

以降のコマンド実行では、そのままコマンド実行する場合、標準エラー出力を捨てる場合、標準出力を捨てる場合の3パターンを記載します。デフォルトで標準出力と標準エラー出力は、現在接続しているターミナルになっているので、このようにしないと、標準出力として出力されたのか、標準エラー出力として出力されたのかの判断ができません。標準エラー出力への出力は捨てる場合、ターミナルに出力されているのは標準出力だと判断できます。逆に、標準出力を捨てる場合、ターミナルに出力されているのは標準エラー出力だと判断できます。

# 標準出力と標準エラー出力にそれぞれ出力するコマンド
stdout_and_stderr () {
  echo 'to stdout'
  echo 'to stderr' >&2
}

$ stdout_and_stderr
to stdout
to stderr

$ stdout_and_stderr 2>/dev/null
to stdout

$ stdout_and_stderr >/dev/null
to stderr

これをこのように、もともと標準出力に出力していた内容を標準エラー出力に、逆にもともと標準エラー出力に出力していた内容を標準出力に出力されるようにしたいのです。

# stdout_and_stderrの標準出力を標準エラー出力に、標準エラー出力を標準出力に出力するコマンド
inverse_stdout_and_stderr () {
  echo 'to stdout' >&2
  echo 'to stderr'
}

$ inverse_stdout_and_stderr
to stdout
to stderr

$ inverse_stdout_and_stderr 2>/dev/null
to stderr

$ inverse_stdout_and_stderr >/dev/null
to stdout

もちろん、このようにそれぞれの関数を定義するのではなく、どちらか一方を定義して、それを後から別のコマンドによって変更できるようにしたいです。

解決策

結論からいうと、以下のシェル関数に標準出力と標準エラー出力を入れ替えたいコマンドを渡せばよいです。

# bash or zsh
stdswap () {
  eval "$@" 3>&2 2>&1 1>&3 3>&-
}
# fish
function stdswap
  eval "$argv" 3>&2 2>&1 1>&3 3>&-
end

このシェル関数は次のように利用します。

$ stdswap stdout_and_stderr
to stdout
to stderr

$ stdswap stdout_and_stderr 2>/dev/null
to stderr

$ stdswap stdout_and_stderr >/dev/null
to stdout

$ stdswap inverse_stdout_and_stderr
to stdout
to stderr

$ stdswap inverse_stdout_and_stderr 2>/dev/null
to stdout

$ stdswap inverse_stdout_and_stderr >/dev/null
to stderr

これと、上記の入れ替え前のコマンドの出力を比較すれば、たしかに標準出力と標準エラー出力が入れ替わっていることがわかります。

補足

では、ここからは、上記のシェル関数が何をしているのかを見てみます。

この関数を構成する要素は、以下の2つです。

  • eval
  • redirect

eval

eval は、引数を受け取ってそれをシェルに解釈させます。"$@" でシェル関数に渡された引数を全て取得できます。これにより、シェル関数に引数としてコマンドを渡して実行させることができます。

redirect

redirectについては、シェルの基本ですが、普段あまり見かけない使い方をしているので言及します。上記のシェル関数でredirectを使っている箇所は 3>&2 2>&1 1>&3 3>&- です。ただ、これについては以下のブログ記事が詳しいのでそちらを参照してください。

https://fhiyo.github.io/2017/09/12/consecutive-shell-command-redirection.html

大雑把な説明をすると、これは、一時変数を使った値の入れ替えと似ています。一時変数ならぬ、一時ファイルディスクリプタを使用して、標準出力のファイルディスクリプタと標準エラー出力のファイルディスクリプタを入れ替えています。

なお、上記のブログ内では、3>&-は利用されていません。これは3というファイルディスクリプタを閉じるという操作です。

一時変数を使った値の入れ替えをshell scriptで記述すると、こうなります。

a=1
b=2

tmp=$a
a=$b
b=$tmp
unset tmp

余談

さて、ふと思い浮かんだ疑問を解消してみましたが、はたしてこれを活用できるような状況はあるのでしょうか。正直に言うと、特にないのではと思っています。また、上記で参照させていただいたブログ内には、この入れ替えの使い所が書いてあるサイトが見つからないとありました。

ですが、なんとか使い所を考えてみました。それが、timeコマンドとの組み合わせです。といっても、zshのtimeコマンドです。ほかのシェルについては関知しません。

さて、僕はtimeコマンドでzshの起動時間を測定することがあります。このとき、何度か測定して、その平均値をもって起動時間としています。これを実現するために、timeコマンドの出力をパイプで別のコマンドに渡して、その平均時間を計算してもらいます。zshのtimeコマンドは、測定時間を標準エラー出力に出力します。よって、これを標準出力にリダイレクトさせた上で、平均時間計測用のコマンドにパイプさせます。そして、この処理を標準出力と標準エラー出力を入れ替えによって実現させます。たとえば、このようにします。

# on zsh

# zshの起動時間をn回測定
zt () {
  local -r cmd='time (zsh -i -c "exit" 2>/dev/null)'
  local -ri num=${1}

  repeat "$num"; do
    sleep 1
    eval "$cmd"
  done
}

# パイプされた入力を標準出力に出力しつつ同時に加工する
teepipe () {
  tee >(eval "$@")
}

# 平均値計算
ave_time () {
  cat | awk '{s += $1; c += 1} END {printf "\n  AVG: %f second\n", s/c}'
}

# zshの起動時間とその平均値の測定
zshtimes () {
  stdswap zt 10 | teepipe "cut -d ' ' -f 9 | ave_time"
}

$ zshtimes 10
  0.01s user 0.00s system 97% cpu 0.018 total
  0.01s user 0.01s system 97% cpu 0.018 total
  0.01s user 0.00s system 97% cpu 0.018 total
  0.01s user 0.01s system 97% cpu 0.018 total
  0.01s user 0.01s system 97% cpu 0.018 total
  0.01s user 0.01s system 97% cpu 0.018 total
  0.01s user 0.01s system 97% cpu 0.018 total
  0.02s user 0.00s system 97% cpu 0.018 total
  0.01s user 0.00s system 97% cpu 0.018 total
  0.01s user 0.00s system 98% cpu 0.018 total

  AVG: 0.018000 second

もっとも、この例のように、出力がない、もしくはほぼないコマンドの時間の測定をしたいのであれば、標準出力を捨てた上で、標準エラー出力を標準出力にリダイレクトさせて、それをパイプで計測用のコマンドに渡せば十分です。わざわざ、標準出力と標準エラー出力を入れ替える必要はありません。

ですが、出力が大量にあるようなコマンドの計測には、このような入れ替えが必要です。出力を捨ててしますと、実際に画面に出力する部分に関する正しい測定ができません。ただし、ここでいう正しい測定とは、time some_commandで測定した時間と同じになるという意味です。つまり、出力を捨てることによって測定時間が変化してしまうということです。では、出力を捨てることによって測定時間が変化する具体例をみてみましょう。

# コマンドを実行する際の作業ディレクトリ内のファイルサイズ
#
# この情報だけでは、この具体例の結果を再現することはできませんが、大雑把なサイズ感をつかむために記載します。
$ du -d 0 -h .
592M  .

# コマンド出力を出力させる場合
$ time ls -ABFhvoR --color=auto
..
ls --color=auto -ABFhvoR --color=auto  0.11s user 0.08s system 98% cpu 0.196 total

# コマンド出力を捨てる場合
$ time ls -ABFhvoR --color=auto >/dev/null
ls --color=auto -ABFhvoR --color=auto > /dev/null  0.05s user 0.07s system 98% cpu 0.125 total

上記の例では、コマンドの出力を捨てるか否によって、0.08s程の違いがあります。もっと大量の出力があるコマンドであれば、より差が大きくなります。逆に、出力があまりないコマンドであれば、その差は小さくなります。このように、コマンドの実行結果を計測するにあたって、その出力を捨ててしまうと、測定時間が変化することがあります。そのため、標準出力と標準エラー出力の入れ替えて、コマンドの出力を捨てないようにする必要があるのです。

なんとか、標準出力と標準エラー出力を入れ替えが役立つ場面を捻り出してみました。ほかにも、役立つような場面について心当たりがある方がいらっしゃいましたら、コメント等で共有していただけるうれしいです。

Discussion

パイプで渡せるのが標準出力だけなので、標準エラー出力(や任意のファイルディスクリプタ)に何かしらの加工をしたい時に使っています。例えば標準エラー出力を赤にしたい時とかですね。標準出力と標準エラー出力を入れ替えて色を付けたのち、再度入れ替えて元の出力場所に戻したりしてます。

参考 シェルスクリプトで標準出力と標準エラー出力に別々の色をフィルタでつける

他にも GNU の time コマンド (/usr/bin/time) は計測結果を標準エラー出力ではなくファイルに出力する便利な機能があるのですが、各シェルビルトインの time コマンドはそういった機能がないので、time の計測結果と time から呼び出すコマンドの標準エラー出力が混ざってしまいます。そういった時にコマンドの標準エラー出力を一旦別のファイルディスクリプタに分離してから~とするときにも使います。

参考 How can I redirect the output of 'time' to a variable or file?

個人的には頻繁に使うものではないものの「ああ、またこのパターンを使わないといけないのか」と憂鬱になるぐらいは使います。(ごちゃごちゃしてるので、できればやりたくないんですよね。)

eval は、引数を受け取ってそれをシェルに解釈させます

少なくとも bash,zshではeval不要ですよ。単に"$@"だけで良いです。
引き渡したコマンド文字列にシェル特殊文字が入ってると、逆に意図しない結果を引き起こします。

redirect
(略) これについては以下のブログ記事が詳しいのでそちらを参照してください。

ファイルディスクリプタ∋標準入力・標準出力・標準エラーと、ファイルディスクリプタの接続先(ファイル)とを混同しているので、あんまりよろしくない記事かなと思います。

3>&2 2>&1 1>&3 3>&- は、以下のように「接続先の変更」で説明するところなので。

  • 初期: 1(標準出力)→ file1, 2(標準エラー)→file2
  • 3>&2: 1→file1, 2→file2, 3→file2 ( 2番の先と同じところへ3番を接続 )
  • 2>&1: 1→file1, 2→file1, 3→file2 ( 1番の先と同じところへ2番を接続 )
  • 1>&3: 1→file2, 2→file1, 3→file2 ( 3番の先と同じところへ1番を接続 )
  • 3>&-: 1→file2, 2→file1 ( 3番を閉じる )

ほかにも、役立つような場面について心当たりがある方がいらっしゃいましたら、

似たような使い方するのは、専ら strace ですね。strace command 2>&1 | less だとか、strace command 2>&1 1>/dev/null | less のようなことはよくやります。strace は標準エラーからの出力がメインコンテンツになるので。

ログインするとコメントできます