Bashで読み込んだファイルに対して、リダイレクトで上書きするとファイルが空になる問題への対処
はじめに
Bash に限らずシェルスクリプトでも発生する事象ですが、 cat
や <
で読み込んだファイルに対して >
(リダイレクト) で上書きを行ったにも関わらず、ファイルが空になってしまうという事象に一度は遭遇したことがある方も多いでしょう。
シェル操作を行うことが多い貴兄にはよくある話ですが、この問題への対処法を紹介します。
検証環境
$ uname -moi
arm64 unknown Darwin
$ bash -version | head -n 1
GNU bash, バージョン 5.1.16(1)-release (aarch64-apple-darwin21.1.0)
「リダイレクトで上書きするとファイルが空になる」問題とは?
対処法の話をする前に、「リダイレクトで上書きするとファイルが空になる」問題とは何かについて説明しておきます。
例えば、以下のようなファイルがあるとします。
$ seq 1 10 > data.txt
$ cat data.txt
1
2
3
4
5
6
7
8
9
10
1~10 の数値が書かれている data.txt
というファイルを用意しました。
このファイルにから、 2
と 5
の数値が登場する行を除外し、 data.txt
に上書きします。
以下のようなコマンドを実行すれば、 2
と 5
の数値を含む行が除外されることを確認できます。
$ cat data.txt | grep -v -e 2 -e 5
1
3
4
6
7
8
9
10
実行結果を data.txt
に書き込みます。
$ cat data.txt | grep -v -e 2 -e 5 > data.txt
data.txt
の中身を確認してみます。
# ファイルの中身は空になっている
$ cat data.txt
$ ls -la data.txt
-rw-r--r-- 1 root root 0 7月 28 07:11 data.txt
このように、リダイレクトされたファイルは空になってしまいます。
原因
なぜファイルが空になってしまうのか。
先程のコマンドを少し変更してみます。
パイプの真ん中に tee hoge.txt
というコマンドを挿入します。hoge.txt
ファイルには grep
に渡る直前の情報が保存されます。
$ cat data.txt | tee hoge.txt | grep -v -e 2 -e 5 > data.txt
# ファイルの中身は空になっている
$ cat hoge.txt
cat data.txt
の実行結果が空になっていることがわかります。
パイプで繋いだコマンドの一番最後に記述されている > data.txt
がポイントです。
コマンド全体が実行されると、一番最初に > data.txt
が実行され、ファイルが空にされてしまうのです。
処理ステップを文字で表現すると以下のようになリます。
cat data.txt
:data.txt
を読み込む準備を行う(Open)> data.txt
:data.txt
に書き込む準備を行う(Open)data.txt
を読み込むgrep -v -e 2 -e 5
:2
と5
を含む行を除外 ( 入力が空なので、出力結果も空 )data.txt
に書き込む
対処法 1 : 一時的に別のファイルに書き出す
「>
による上書き」をやめる、というのが一番確かです。
tmp.txt
という一時ファイルに結果を書き出した後、 data.txt
にリネームしてみます。
$ cat data.txt | grep -v -e 2 -e 5 > tmp.txt
$ mv tmp.txt data.txt
$ cat data.txt
1
3
4
6
7
8
9
10
問題なく結果が保存されました。
対処法 2 : 「サブシェル(+ファイル削除)」を使う
サブシェル内でファイルを削除を行うというハックを行うことで、この問題に対処することができます。
リダイレクトで結果をファイルにリダイレクトする前に、削除
# リダイレクト直前にファイルを削除
$ cat data.txt | (rm data.txt; grep -v -e 2 -e 5 > data.txt)
# 中身を確認
$ cat data.txt
1
3
4
6
7
8
9
10
もしサブシェルを使ってもファイルの削除処理を行わなければ、やはり空になります。
$ cat data.txt | (grep -v -e 2 -e 5 > data.txt)
# ファイルの中身は空になっている
$ cat data.txt
処理ステップを文字で表現すると以下のようになリます。
cat data.txt
:data.txt
を読み込む準備を行う(Open)rm data.txt
:data.txt
を削除> data.txt
:data.txt
に書き込む準備を行う(Open)data.txt
を読み込むgrep -v -e 2 -e 5
:2
と5
を含む行を除外 ( 入力が空なので、出力結果も空 )data.txt
に書き込む
お恥ずかしながら、なぜこの方法で上書きファイルが空になる問題が解消されるか理解できておりません。
若い頃に先輩エンジニアに教えていただいた方法です。
2022-09-27追記
いつもアドバイスくださる @ko1nksm さんからコメントを頂き、記事を修正しました。
ありがとうございます!
ひとこと
rm
を実行するのは少し怖いこともあり、可能なら一時ファイルを作成した後 mv
で上書きするほうが確実かつ可読性が髙いです。
ディスカッション
コメント一覧
# リダイレクト直前にファイルを削除
$ cat data.txt | (rm data.txt; grep -v -e 2 -e 5 > data.txt)
この方法でうまくいく理由は、cat が data.txt をクローズするまでは、ファイルシステムからファイルを削除しても cat だけは引き続き読み込むことが可能だからです。
しかしながら、途中でエラーが起きるとファイルが消えてしまいますし、data.txt がシンボリックリンクだった場合に通常のファイルに変わってしまうなどの問題があるので、信頼性が必要な場合に使用するのはオススメできません。
同様のことを行うコマンドは moreutils の sponge コマンドが有名ですね。他「UNIXプログラミング環境」にシェルスクリプト製の overwrite コマンドの実装が載っています。overwrite コマンドはパイプを使わないので終了ステータスが消えてしまうという問題がないというメリットが有るのですが、少々コードが古く問題点がいくつかあるのでそれを修正したバージョンを作ろうかなと考えていたりします。
それと、これはプロセス置換ではないです……。
要はリダイレクト処理を cat よりも遅らせる、ということなので、rm である必要はなく、他のコマンドでもだいじょうぶですね。
e.g.
$ cat data.txt |(/usr/bin/true; grep -v -e 2 -e 5 > data.txt)
コメントありがとうございます。
こちらの方法のほうが、安全に操作できそうですね。( `rm` は怖い )