シェルスクリプトで標準出力と標準エラー出力を入れ替える

Bash

はじめに

あまり利用する機会はありませんが、今回はシェルスクリプトを使って、標準出力と標準エラー出力を入れ替える方法についてお話しします。

検証環境

$ uname -moi
arm64 unknown Darwin

$ bash -version | head -n 1
GNU bash, バージョン 5.2.15(1)-release (aarch64-apple-darwin22.1.0)

解答

珍しく解答を掲載した後、ブログ記事を書きます。

次のコマンドはどちらを使っても標準出力、標準エラー出力を入れ替えることができます。

$ echo "Hello" 3>&2 2>&1 1>&3

$ echo "Hello" 3>&1 1>&2 2>&3

解説

ファイル記述子 ( ファイルディスクリプタ ) について

ファイル記述子 ( ファイルディスクリプタ ) とは、プログラムがファイルやデバイスとやり取りするために使用する識別子のことです。

ファイル記述子について当ブログで紹介したこともあるので、よけれご参考ください

Bash シェルにおけるファイル記述子には、次の 3 種類があります。

  1. 標準入力 ( stdin )
    標準入力は、プログラムにデータを入力するファイル記述子です。
    通常、キーボードからの入力が標準入力として扱われます。
    パイプを使用することで、標準入力にキーボードからの入力ではなく、別のプロセスからの出力を渡すことができます。

  2. 標準出力 ( stdout )
    標準出力は、プログラムから出力されるデータを受け取るファイル記述子です。
    通常、コンソールに出力されます。
    プログラムが標準出力を使用すると、コンソールにデータを表示できます。

  3. 標準エラー出力 ( stderr )
    標準エラー出力は、プログラムから出力されるエラーメッセージを受け取るファイル記述子です。
    通常、コンソールに出力されます。
    プログラムが標準エラー出力を使用すると、エラーメッセージをコンソールに表示できます。

それぞれのファイル記述子には一意の番号が割り当てられています。
標準入力の番号は 0 、標準出力の番号は 1 、標準エラー出力の番号は 2 です。
これらの番号を使ってプログラムは外部 ( キーボード、ディスプレイや他のプログラム ) とデータの受け渡しできます。

プログラムが実行される際には、システムが自動的にファイル記述子を割り当てます。

標準出力と標準エラー出力

標準出力とは、プログラムが実行された際に出力されるメッセージや結果を指します。
一般的には、コンソール画面に表示されるテキストが標準出力にあたります。

一方、標準エラー出力とは、プログラムの実行中に発生したエラーや警告などのメッセージを指します。
これらのメッセージは、標準出力と同じくコンソール画面に表示されますがぱっと見ではわかりません。

例えば、次のようなプログラムを考えます。

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello stdout!"); // 標準出力
        System.err.println("Hello stderr!"); // 標準エラー出力
    }
}

Java の System.out.println は標準出力に文字列を出力する命令、 System.err.println は標準出力に文字列を出力する命令です。
このプログラムを実行すると、コンソール画面には次のように表示されます。

$ cat main.java
public class Main {
    public static void main(String[] args) {
        System.out.println("Hello stdout!"); // 標準出力
        System.err.println("Hello stderr!"); // 標準エラー出力
    }
}

$ java main.java
Hello stdout!
Hello stderr!

標準出力と標準エラー出力の違いはメッセージからしかわかりませんね。

ここで、標準出力と標準エラー出力をリダイレクト ( > ) によって別々のファイルに出力してみます。
例えば、次のようにコマンドを実行すると、標準出力と標準エラー出力がそれぞれ別のファイルに出力されます。

# ファイルディスクリプタを指定して標準出力、標準エラー出力を別ファイルに出力
$ java main.java 1> stdout.txt 2> stderr.txt

# ファイルディスクリプタ 1 (標準出力) は番号を省略するのが一般的
$ java main.java > stdout.txt 2> stderr.txt

このコマンドを実行すると、標準出力は「stdout.txt」、標準エラー出力は「stderr.txt」というファイルにそれぞれ出力されます。

$ cat stdout.txt
Hello stdout!

$ cat stderr.txt
Hello stderr!

これにより、プログラムの実行結果とエラーメッセージを別々のファイルで管理できます。

標準エラー出力も標準出力にまとめたい

ここで、プログラムの出力内容(標準出力+標準エラー出力)を grep コマンドで絞り込みしたいとしましょう。

grep -o を使うとマッチした部分のみが出力されます。

$ java main.java | grep -o 'std.*'
Hello stderr!
stdout!

標準出力の内容は grep で絞り込みできましたが、 標準エラー出力の内容はそのまま出力されてしました。
これは、grep は標準出力を絞り込むコマンドで、 標準エラー出力を絞り込むコマンドではないためです。

main.java のプログラムを変更せずにこれを実現するにはどうしたらよいでしょう。

標準エラー出力も標準出力にまとめたいときは 、 2>&1 を使って標準エラー出力を標準出力にリダイレクトできます。
2 のファイル記述子に対して、 1 のファイル記述子の実態であります「標準出力」をセットします( > の向きに惑わされずプログラムにおける = として解釈)と理解すればよいです。
これにより、標準出力と標準エラー出力の両方を grep で絞り込むことができます。

例えば、次のようにコマンドを実行すると、 標準出力と標準エラー出力の両方から std.* を含む部分を絞り込んで表示できます。

$ java main.java 2>&1 | grep -o 'std.*'
stdout!
stderr!

さらに、リダイレクト 2>&1 を使わずに標準出力と標準エラー出力を両方表示できます。
これには、 |& を使います。

例えば、次のようにコマンドを実行すると、 標準出力と標準エラー出力の両方をパイプで grep に繋ぐことができます。

$ java main.java |& grep -o 'std.*'
stdout!
stderr!

標準エラー出力だけを grep で絞り込みたい

標準エラー出力だけを grep で絞り込みたい場合は、「標準エラー出力も標準出力にまとめたい」のやり方に更にひと手間加えます。
標準出力だけを /dev/null にリダイレクトすることで実現できます。

# ファイルディスクリプタ 1 (標準出力) は番号を省略するのが一般的
$ java main.java 2>&1 >/dev/null | grep -o 'std.*'
stderr!

# 1 を明記してもいい
$ java main.java 2>&1 1>/dev/null | grep -o 'std.*'
stderr!

ここでは、標準出力を /dev/null にリダイレクトすることで、 標準出力の内容は捨てられ、標準エラー出力だけが grep で絞り込まれて表示されます。

リダイレクト命令はコマンドの左から順番に実行されます。
つまり、最初にファイル記述子 21 の指す標準出力を代入し、 次にファイル記述子 1/dev/null を代入します。

  System.out.println("Hello stdout!"); // ファイル記述子 1 = /dev/null へ
  System.err.println("Hello stderr!"); // ファイル記述子 2 = 標準出力 へ

[本題] 標準出力と標準エラー出力を入れ替える

ようやく本題となります。

プログラミングにおける2つの変数の値の入れ替え同様、入れ替えのために3つ目のファイル記述子を利用します。

パイプで繋いだ grep は前方のコマンドの標準出力のみを対象としますが、 stderr! の出力内容から標準エラー出力を絞り込んでいることがわかります。

$ java main.java 3>&2 2>&1 1>&3 | grep -o 'std.*'
Hello stdout!
stderr!

# こちらの方法でも結果は同じ
$ java main.java 3>&1 1>&2 2>&3 | grep -o 'std.*'
Hello stdout!
stderr!

ちなみに、 不要になったファイル記述子の 3 は明示的に閉じることができます。
この例では閉じても閉じなくてもどちらでも結果は変わりません。

# &3- の部分がポイント
$ java main.java 3>&1 1>&2 2>&3- | grep -o 'std.*'
Hello stdout!
stderr!

標準出力と標準エラー出力を更に入れ替える

サブシェルで指定したファイル記述子をサブシェル外で参照することもできます。

$ (echo echo1; echo echo2>&2; echo echo3>&3) 1>1.txt 2>2.txt 3>3.txt

$ cat 1.txt
echo1

$ cat 2.txt
echo2

$ cat 3.txt
echo3

これを利用することで、一度入れ替えた標準出力と標準エラー出力を再び元に戻すこともできます。

# grep は 標準エラー出力 を絞り込んでいる
$ java main.java 3>&2 2>&1 1>&3 | grep -o 'std.*'
Hello stdout!
stderr!

# grep は 標準出力 を絞り込んでいる
$ (java main.java 3>&2 2>&1 1>&3) 3>&2 2>&1 1>&3 | grep -o 'std.*'
Hello stderr!
stdout!

Bash