シェルスクリプトで`while read`ループすると最終行が読み込まれない場合への対処

Bash,CentOS,Docker,Linux,Ubuntu

はじめに

シェルスクリプトでファイルの各行に対して複雑なロジックを書かざるを得ない場合、
while read を使って一行ずつ読み込む処理を騎獣します。

echocat を使ってファイルを作成した場合はファイルの末尾に必ず改行コード(LF)が付与されますが、
少々変わったファイルの作り方をすると改行コードが付与されないことがあります。
(どきどきチームで開発しているソースコードにもこういうファイルが紛れ込むケースもありますね。)

そんなファイルを while read する場合に最終行がループ処理の対象にならない、とハマりがちなので対処法をまとめます。

検証環境

$ uname -moi
x86_64 MacBookPro11,4 Darwin

$ bash -version | head -n 1
GNU bash, バージョン 5.0.16(1)-release (x86_64-apple-darwin18.7.0)

普通に作成したファイルを while read して一行ずつ読み込んでみる

普通に作成したファイルを while read して一行ずつ読み込む場合、
以下のようなコードになるかと思います。

( IFS= を付与しないと、 read 時に行頭・行末のスペースが除去されてしまいます。 -r オプションがないと各行末に \ があった場合におかしな挙動をしてしまいます。 )

# 後述するケースにつながるため、あえてちょっと変わった書き方で echo しています。
$ echo "\
aaa
  bbb
ccc" >test1.txt

# ファイルの中身は3行となっています。
$ cat test1.txt
aaa
  bbb
ccc

# 一行ずつ読み込んでみます
$ cat test1.txt | while IFS= read -r LINE; do
    echo "${LINE}"
  done
aaa
  bbb
ccc

問題なく3行とも読み込めました。

ファイルの最終行に改行コード(LF)が含まれないファイルを while read した場合

今度はファイルの最終行( ccc )に改行コードが付与されないようにファイルを作成した後、
while read してみます。

# -n オプションを付けると最終行に改行コードが付与されない
$ echo -n "\
aaa
  bbb
ccc" >test2.txt

# ちょっとわかりにくいが、最終行にコマンドプロンプト(%)が表示されており、
# 改行されていないことがわかる
$ cat test2.txt
aaa
  bbb
ccc%

# 一行ずつ読み込んでみると...2行しか出力されない!
$ cat test2.txt | while IFS= read -r LINE; do
    echo "${LINE}"
  done
aaa
  bbb

ccc の行が出力されません。
ループの処理対象となっていないことがわかります。

原因を噛み砕いてみる

原因をもう少し噛み砕いてみます。
read コマンドは while でループせずとも利用できます。
echo の実行結果を read で読み込む例を見ていきます。

まずは普通に echo した結果を read コマンドに読み込ませた場合の実行結果を見てみます。

$ read -r L < <(echo "111")

$ echo $?
0

$ echo "$L"
111

実行ステータスは 0 、 変数 $L の値は 111 となっています。
echo で渡された文字列を読み込み、変数に代入できていることがわかります。

次に echo -n として、行末に改行コードを出力させなかった場合の実行結果を見てみます。

$ read -r L < <(echo -n "222")

$ echo $?
1

$ echo "$L"
222

実行ステータスは 1 となりエラー扱いですが、 変数 $L の値は 222 となり、代入されていることがわかります。

read コマンドは標準入力に改行コードが含まれていなかった場合、実行ステータスはエラーとなるが変数への代入は行われます。

while readループすると最終行が読み込まれない場合への対処方法

それでは対処方法について。
「(1)read の実行が失敗した場合」だけでなく、「(2)readした文字列を代入した変数が空の場合」を終了条件とするように処理を追加します。

$ cat test2.txt | while IFS= read -r LINE || [[ -n "${LINE}" ]]; do
    echo "${LINE}"
  done

aaa
  bbb
ccc

一番最初のループとの違いは、 || [[ -n "${LINE}" ]] という「OR条件」が追加されている点です。

ひとこと

はじめに考えた人はよく考えたなぁ。