bashのwhile readでループ後に変数が消える・最終行が読まれない原因と対処|パイプのサブシェル・IFS= read -r・< <(…)・lastpipe

bashのwhile readでループ後に変数が消える・最終行が読まれない原因と対処

こんにちは、おうどんです🍜

うどん屋の閉店後。 今日の注文票を、bash で数えます。

count=0
printf 'kitsune\nzaru\nkake\n' | while read -r menu; do
  count=$((count + 1))
  echo "in loop: $menu count=$count"
done
echo "after loop: count=$count"

ループの中では、ちゃんと数えています。 きつね、1杯。ざる、2杯。かけ、3杯。

そして、ループを出た瞬間。

in loop: kitsune count=1
in loop: zaru count=2
in loop: kake count=3
after loop: count=0

0杯。

店長(おうどん)「……え。いま3まで数えてたよね?」

readさん「はい。出張所の黒板に、ちゃんと3と書きました」

店長「本店の黒板は?」

readさん「本店のことは、存じ上げません」

……同じ店の店員なのに、本店を知らない。

(会話は説明用の架空の場面です。出力は今回の検証環境の Git Bash 5.2.26 で実際に実行した結果です)

これ、bash の while read ループで本当によくハマるやつです。 しかも仲間がいます。

  • ループのあとで変数が空・0に戻る
  • ファイルの最後の1行だけ読まれない
  • 行頭の空白や \ が消える
  • ループが1周で終わってしまう

全部、while read の「読み方」と「どこで動いているか」で説明がつきます。 今回は、消えた3杯を本店の黒板に取り戻しに行きます。

最初に、初心者向けのチェックリストです。

  • ループのあとで変数を使うなら、cmd | while read ではなく while read ...; done < ファイル で書く
  • 1行ずつ読むときは、while IFS= read -r line をセットで書く
  • 最後の行に改行がないファイルも読むなら、|| [[ -n $line ]] を足す
目次

パイプの後ろの while は「出張所」で動いている

さっきの readさんのセリフ、半分は本当です。 パイプの後ろに置いた while ループは、本店(いまのシェル)ではなく、出張所(サブシェル)で動いています。

GNU Bash のマニュアルの Pipelines の節には、パイプでつないだ複数のコマンドは、それぞれが自分のサブシェル(別のプロセス)で実行される、と書かれています。 サブシェルは、いまのシェルをコピーして作った分身のようなものです。

つまりこうです。

  1. printf ... が出張所Aで注文票を書く
  2. while read ... が出張所Bで注文票を読み、出張所Bの count を 3 にする
  3. ループが終わると、出張所Bは片付けられる
  4. 本店の count は、最初の 0 のまま

bash の作者が管理している Bash FAQ の E4 でも、パイプの各要素は別プロセスで動き、子プロセスは親の環境を変えられない、と説明されています。

出張所の黒板の写真を撮って、本店に送ればいいのでは?

その発想、方向性は合っています。 ただ bash の出張所は、閉店と同時に黒板ごと消えるタイプです。写真を撮るひまもありません。

readさん「書いたものは、出張所と運命をともにします」

……潔すぎる。もう少し本店に未練を持ってほしい。

ちなみに、ループの中で echo した文字が画面に出るのは、画面への出力は本店も出張所も同じ窓口を使っているからです。 「見えている」のに「残っていない」。これがこのバグのいやらしいところです。

直し方は「パイプをやめて、< で読ませる」

ループを本店で動かせば、黒板は本店に残ります。 while ループにパイプで流しこまず、< でループの入口に渡すのが基本の直し方です。

今回の検証環境で、5つの書き方を試しました。

printf 'kitsune\nzaru\nkake\n' > menu.txt

# A: redirect the file into the loop
count=0
while IFS= read -r menu; do
  count=$((count + 1))
done < menu.txt
echo "A redirect: count=$count"

# B: process substitution
count=0
while IFS= read -r menu; do
  count=$((count + 1))
done < <(grep -v '^#' menu.txt)
echo "B process substitution: count=$count"

# C: lastpipe (script = job control off)
shopt -s lastpipe
count=0
grep -v '^#' menu.txt | while IFS= read -r menu; do
  count=$((count + 1))
done
echo "C lastpipe: count=$count"
shopt -u lastpipe

# D: keep everything inside one group
grep -v '^#' menu.txt | {
  count=0
  while IFS= read -r menu; do
    count=$((count + 1))
  done
  echo "D group: count=$count"
}

# E: mapfile
mapfile -t menus < menu.txt
echo "E mapfile: ${#menus[@]} items, last=${menus[-1]}"
A redirect: count=3
B process substitution: count=3
C lastpipe: count=3
D group: count=3
E mapfile: 3 items, last=kake

(今回の検証環境の Git Bash 5.2.26 で実行した結果です)

どの書き方でも、3杯と表示できました。ただし D は、出張所の中で数えて発表しています。 使い分けはこうです。

書き方向いている場面注意
A done < ファイルファイルを1行ずつ読むいちばん素直。まずはこれ
B done < <(コマンド)コマンドの出力を読む<(...) はプロセス置換という bash の機能。< と <( のあいだに空白が要る
C shopt -s lastpipeパイプの形を変えたくないスクリプトでは効くが、条件付き(上級者向けで説明)
D { ...; } でまとめる結果をループの直後に使うだけ} の外の変数には反映されない(この例では C の結果の 3 のまま)
E mapfile -t全行を配列に入れて後で使う全部をメモリに読みこむ

B の < <(...) は、見た目が顔文字みたいですが、意味は「コマンドの出力をファイルのように見せて、それを < で渡す」です。 プロセス置換は bash の機能なので、sh script.sh のように別のシェルで動かすと使えないことがあります。スクリプトは bash script.sh か、先頭に #!/usr/bin/env bash を書いて動かしましょう。

< <( って、うどんを箸で2回つまんでる絵文字に見える。

見えなくもない。 ただ間の空白を消して <<( にすると構文エラー(書き方の誤り)になるので、箸と箸のあいだは離しておいてください。

D の書き方は、出張所の中で集計から発表まで済ませる作戦です。

readさん「出張所で全部終わらせて、結果だけ大声で叫びます」

……本店には何も残らないけど、お客さんには聞こえる。それで足りる場面なら、いちばん楽です。

ちなみに、変数が1つだけほしいなら、ループすら要りません。 Bash FAQ の E4 でも、read で受けるパイプの多くはコマンド置換 $(...) に書き換えられる、と紹介されています。

count=$(grep -vc '^#' menu.txt)

(この1行は今回の検証では実行していません。grep -c は一致した行の数、-v は一致しない行を対象にするオプションです)

最後の1行が読まれないのは、改行がないから

次の仲間です。 ファイルの最後の行に改行がないと、while read はその行を読んだのに、ループの中に入れてくれません。

printf 'kitsune\nzaru\nkake' > nonl.txt   # no newline at the end

echo "--- plain loop"
while IFS= read -r menu; do
  echo "got: $menu"
done < nonl.txt

echo "--- what read does on the last line"
{
  IFS= read -r a; echo "status=$? a=$a"
  IFS= read -r a; echo "status=$? a=$a"
  IFS= read -r a; echo "status=$? a=$a"
  IFS= read -r a; echo "status=$? a=[$a]"
} < nonl.txt

echo "--- fixed loop"
while IFS= read -r menu || [[ -n $menu ]]; do
  echo "got: $menu"
done < nonl.txt
--- plain loop
got: kitsune
got: zaru
--- what read does on the last line
status=0 a=kitsune
status=0 a=zaru
status=1 a=kake
status=1 a=[]
--- fixed loop
got: kitsune
got: zaru
got: kake

(今回の検証環境の Git Bash 5.2.26 で実行した結果です)

3回目の read を見てください。 a=kake と、ちゃんと読めています。でも終了コード(成功なら 0)が 1 です。

POSIX の read の仕様には、行の区切り(改行)の前にファイルの終わりが来たら、変数には読んだ内容を入れたうえで、終了コードを 1 にする、と書かれています。 while は終了コードが 0 のあいだだけ回るので、「読めたけど 1」の最後の行は、ループの外に置いていかれるわけです。

readさん「かけうどん、受け取りました。でも伝票の最後に句点がなかったので、未完成とみなします」

店長「中身は完成してるのに!」

……書類の不備で差し戻す、役所のような readさん。

直し方は || [[ -n $menu ]] です。 「read が 1 を返しても、変数に何か入っていれば、もう1周する」という意味です。 4回目の read のように本当に何もなければ、変数は空になるのでループは終わります。

店長「よし、今夜は全部の注文票ファイルの最後に、手で改行を足して回ろう」

……ファイルが1万個あったら、閉店後の作業が開店前まで続きます。

じゃあファイルの最後に改行を足して回ればいいのでは?

それも正解です。自分で作るファイルなら、最後に改行を入れる習慣にしておくと平和です。 ただ、よそから届くファイルの最後に改行があるかは、届くまで分かりません。読む側で守っておくと安心です。

read は「IFS= と -r」をセットで書く

read は、何も付けないと、読んだ行を少し加工します。

printf '  C:\\udon\\new  \n' > path.txt

read line < path.txt
echo "read         : [$line]"

read -r line < path.txt
echo "read -r      : [$line]"

IFS= read -r line < path.txt
echo "IFS= read -r : [$line]"
read         : [C:udonnew]
read -r      : [C:\udon\new]
IFS= read -r : [  C:\udon\new  ]

(今回の検証環境の Git Bash 5.2.26 で実行した結果です。ファイルの中身は C:\udon\new で、前後に空白が2つずつあります)

何も付けない read は、\ を消して、前後の空白も消しました。 C:\udon\new が C:udonnew に。……フォルダの区切りが全部どこかへ行きました。

readさん「\ は、次の文字を特別扱いしないための印なので、役目を終えたら捨てました」

……親切のつもりで、パスを1本の麺にしないでほしい。

それぞれの役割はこうです。

  • -r:\ を特別扱いしない。POSIX の read の仕様でも、-r は \ を入力行の一部として扱う、とされています。付けないと \ は次の文字のエスケープ(特別な意味を消す印)や、行末なら行の継続として扱われます
  • IFS=:read の前にだけ IFS(単語の区切りに使う文字の一覧。既定は空白・タブ・改行)を空にする。こうすると、行の前後の空白を削らない

IFS= read -r の IFS= は、そのコマンドを実行するあいだだけ効く書き方です。スクリプト全体の IFS は変わりません。

「IFS= read -r」って、呪文みたいで覚えられない。

「いふすいこーる・りーど・まいなすあーる」。 ……声に出すと、余計に呪文っぽくなりました。 意味で覚えるなら「区切らない・いじらない・そのまま読む」です。

🔰 ここまで読めば今日から困らない

ここまでで、よくあるトラブルの大半は直せます。

  • ループのあとで変数が消える → パイプの後ろの while は出張所(サブシェル)。done < ファイル か done < <(コマンド) で書く
  • 最後の1行が読まれない → 最後に改行がない。while IFS= read -r line || [[ -n $line ]] にする
  • \ や前後の空白が消える → IFS= read -r をセットで書く

迷ったら、この形をコピーしてください。

while IFS= read -r line || [[ -n $line ]]; do
  echo "[$line]"
done < input.txt

(この4行はひな形として載せています。同じ書き方の動作は、上の「最後の1行」と「IFS= と -r」の検証で確認済みです)

これ、毎回打つのめんどう。readさんの初期設定にしておいてよ。

readさん「初期設定を変えると、世界中の古いスクリプトがわたしを指名手配します」

……後方互換(昔の書き方を壊さないこと)は、うどんのコシより固い。

4行だけなら、うどんの湯切りより早く打てる?

打てます。 湯切りのほうは、慣れないと麺ごと流しに旅立ちますが、この4行は旅立ちません。

ここから先は中級者向け。 実務でハマる「ループが途中で止まる」系と、完成コードです。

ループが1周で終わる:中のコマンドが標準入力を食べている

ループの中で、標準入力(ふだんはキーボード、リダイレクトするとファイル)を読むコマンドを呼ぶと、そのコマンドが残りの行を食べてしまいます。

printf 'kitsune\nzaru\nkake\ntempura\n' > menu.txt

confirm() {
  read -r answer   # wants a reply from the keyboard...
}

echo "--- loop with a command that reads stdin"
while IFS= read -r menu; do
  echo "cook: $menu"
  confirm
done < menu.txt

echo "--- give the inner command its own stdin"
while IFS= read -r menu; do
  echo "cook: $menu"
  confirm < /dev/null
done < menu.txt

echo "--- or read the file from fd 3"
while IFS= read -r -u 3 menu; do
  echo "cook: $menu"
done 3< menu.txt
--- loop with a command that reads stdin
cook: kitsune
cook: kake
--- give the inner command its own stdin
cook: kitsune
cook: zaru
cook: kake
cook: tempura
--- or read the file from fd 3
cook: kitsune
cook: zaru
cook: kake
cook: tempura

(今回の検証環境の Git Bash 5.2.26 で実行した結果です)

1つ目のループ、ざるうどんと天ぷらうどんが消えました。 confirm の中の read は、キーボードからの返事を待っているつもりで、done < menu.txt の注文票から次の1行を持っていったんです。

confirm「お返事ください……あ、ざるうどんって書いてある。これを返事として受け取ります」

……返事じゃなくて注文です。

実務でよく食べるのは ssh です。 OpenBSD の ssh のマニュアルでは、-n オプションは標準入力を /dev/null(何も入っていない入力)につなぎ替え、標準入力を読まないようにする、と説明されています。 つまり -n なしの ssh は、標準入力を読みにいきます。サーバー一覧のファイルを while read で回しながら ssh すると、1台目で残りのサーバー名が食べられて、1台で終わる。これがその正体です。

直し方は2つ。

  • 中のコマンドに別の入力を渡す:confirm < /dev/null、ssh -n ホスト名 ...
  • ループの入力を別の番号(ファイル記述子 3)にする:read -u 3 と done 3< ファイル

ファイル記述子は、プロセスが開いている入出力の通し番号です。0 が標準入力、1 が標準出力、2 が標準エラー出力。3 番以降は自由に使えます。

じゃあ 3 番も食べられたら、4 番、5 番と逃げればいい?

ふつうのコマンドが勝手に読むのは 0 番です。3 番の注文票まで取りにくる店員はまずいません。 ……まずいない、なので、取りにくるコマンドを書いた人は名乗り出てください。

CRLF のファイルは、行の最後に \r がくっついてくる

Windows で作ったファイルを Git Bash で読むと、こうなることがあります。

printf 'kake\r\n' > crlf.txt

IFS= read -r menu < crlf.txt
if [[ $menu == kake ]]; then echo "match"; else echo "no match"; fi
printf '%q\n' "$menu"

menu=${menu%$'\r'}
if [[ $menu == kake ]]; then echo "match"; else echo "no match"; fi
no match
$'kake\r'
match

(今回の検証環境の Git Bash 5.2.26 で実行した結果です)

改行が CRLF(\r\n)のファイルは、read が \n で区切るので、行の最後に \r が残ります。 画面では見えないのに、kake と比べると一致しない。

readさん「かけうどんです。……と、見えないお冷やが1杯ついています」

……頼んでいないお冷やで、注文が別物扱いになる。

printf '%q' を使うと、見えない文字が $'kake\r' のように見える形で出てくるので、切り分けに便利です。 ${menu%$'\r'} は「末尾の \r を1つだけ取る」書き方です。 改行コードそのものの話は、Gitの「LF will be replaced by CRLF」の記事にまとめています。

いっそ全部のファイルを CRLF にそろえれば、bash も慣れるのでは?

bash は慣れません。 慣れないまま、$'\r': command not found と言い続けます。

完成コード:注文票を数えるスクリプト

ここまでの対策を全部入れた、コピーして使える形です。 空行と # で始まる行は飛ばし、CRLF と「最後に改行がないファイル」の両方に対応しています。

#!/usr/bin/env bash
# usage: count_menu.sh FILE
set -u

list=${1:?usage: count_menu.sh FILE}
[[ -r $list ]] || { echo "cannot read: $list" >&2; exit 2; }

count=0
skipped=0
while IFS= read -r line || [[ -n $line ]]; do
  line=${line%$'\r'}                       # tolerate CRLF files
  if [[ -z $line || $line == \#* ]]; then  # skip blank lines and comments
    skipped=$((skipped + 1))
    continue
  fi
  count=$((count + 1))
  printf 'order %d: [%s]\n' "$count" "$line"
  # commands that read stdin (ssh etc.) need < /dev/null or ssh -n here
done < "$list"

echo "total=$count skipped=$skipped"

試すためのファイルと、実行のしかたです。 CRLF・空行・コメント・前後の空白・\・最後の改行なし、を全部まぜました。

printf '# today\r\nkitsune udon\r\n\r\n  zaru\\soba  \r\nkake' > menu_today.txt
"$BASH" count_menu.sh menu_today.txt; echo "rc=$?"
"$BASH" count_menu.sh no_such_file.txt; echo "rc=$?"
order 1: [kitsune udon]
order 2: [  zaru\soba  ]
order 3: [kake]
total=3 skipped=2
rc=0
cannot read: no_such_file.txt
rc=2

(今回の検証環境の Git Bash 5.2.26 で実行した結果です。cannot read: の行は標準エラー出力で、検証では標準出力とまとめて記録しています)

ポイントは3つです。

  • total=3 がループの外で正しく出ている(done < "$list" なので本店で数えている)
  • 最後の kake も数えている(|| [[ -n $line ]])
  • zaru\soba の空白と \ がそのまま(IFS= read -r)

店長「やっと本店の黒板に 3 と書けた……」

readさん「出張所、閉めておきました」

……閉めたというか、最初から作らなかっただけです。

set -u は、未定義の変数を使ったらエラーにする設定です。変数名の打ち間違いを早めに見つけられます。 ${1:?...} は、引数がなければメッセージを出して止める書き方です。

これを毎日回せば、注文票の数え漏れはゼロ?

数え漏れはゼロにできます。 売上がゼロかどうかは、スクリプトの担当外です。

切り分けチェックリスト

症状から逆引きできるようにまとめました。

症状まず疑うこと確かめ方直し方
ループのあとで変数が空・0パイプの後ろの while(サブシェル)cmd | while の形になっていないか見るdone < ファイル / done < <(cmd)
最後の1行だけ読まれない最後の行に改行がないtail -c 1 ファイル | od -c で最後の1バイトを見る|| [[ -n $line ]] を足す
\ が消えるread に -r がないread の行を見るread -r
行頭・行末の空白が消えるIFS= がないecho "[$line]" で前後を見るIFS= read -r
ループが1周で終わる中のコマンドが標準入力を読む(ssh など)ループの中身を1つずつ消して試す< /dev/null、ssh -n、read -u 3
比較が一致しないCRLF の \rprintf '%q\n' "$line"line=${line%$'\r'}

(表のうち tail -c 1 ... | od -c は今回の検証では実行していません。それ以外の書き方は、上の各節で実行して確認したものです)

表が長い。結局どれから見ればいい?

上から順です。 上の2つで、だいたいの「消えた」は見つかります。

readさん「わたしが悪い行、多くないですか?」

店長「直し方の列、ほぼ全部きみの設定だからね」

……容疑者リストの主役。

ちなみに、表示によっては表の中に \| と見えることがありますが、これは Markdown の表を崩さないための書き方で、実際のコマンドでは | です。 ……表の中でもパイプが邪魔をしてくる。パイプ、今日は本当に出番が多い。

ここから先は上級者向け。読み飛ばしてもOKです。 lastpipe の条件、IFS の空白の扱い、ファイル名に改行が入る場合を見ていきます。

lastpipe は「ジョブ制御がオフのとき」だけ効く

lastpipe(shopt -s lastpipe)は、パイプの最後のコマンドを本店で動かす設定ですが、ジョブ制御がオンだと効きません。

GNU Bash のマニュアルの Pipelines の節には、lastpipe が有効で、かつジョブ制御が有効でないとき、パイプの最後の要素をシェル自身のプロセスで実行することがある、と書かれています。 ジョブ制御は、Ctrl+Z で止めたり fg で戻したりする機能のことです。対話シェル(ターミナルで打ちこんでいるシェル)ではふつうオンで、スクリプトではふつうオフです。

だから、さっきの C の書き方は、スクリプトとして実行したので count=3 になりました。 同じ行をターミナルに直接打ちこむと、ジョブ制御がオンなので、出張所に逆戻りします(今回の検証では、スクリプトの中で set -m を使ってジョブ制御をオンにし、count=0 に戻ることを確認しました)。

lastpipe「スクリプトのときだけ、本店で働きます」

店長「ターミナルで試したら?」

lastpipe「その日は、出張所で働きます」

……テストのときだけ動かない、いちばん困るタイプの店員。

設計としては、lastpipe は「パイプの形を変えたくない既存スクリプトの応急処置」に向いています。 新しく書くなら、どこで動かしても同じ結果になる done < <(cmd) のほうが読み手にやさしいです。

もう1つ大事なのは、出張所(サブシェル)の中から見える変数は、本店のコピーだということ。 ループの前に入れた値は中で読めます。でも中で書き換えた値は戻りません。「読めるのに書けない」ので、最初はバグに見えないんです。

本店の値が読めるなら、出張所から本店に書きこむ方法もあるはずでは?

プロセスが別なので、ありません。 どうしても値を持ち帰りたいなら、出張所で標準出力に書き、本店で $(...) で受け取る。これが正面玄関です。

IFS の空白はまとめられる:タブ区切りの空欄が詰まる

IFS に入っている空白・タブ・改行は、連続すると1つの区切りとして扱われます。 タブ区切りのファイル(TSV)で空欄があると、ここでハマります。

printf 'kitsune\t\t480\n' > tsv.txt      # 2nd column is empty
printf 'kitsune,,480\n'   > csv.txt

IFS=$'\t' read -r name topping price < tsv.txt
echo "tab  : name=[$name] topping=[$topping] price=[$price]"

IFS=, read -r name topping price < csv.txt
echo "comma: name=[$name] topping=[$topping] price=[$price]"
tab  : name=[kitsune] topping=[480] price=[]
comma: name=[kitsune] topping=[] price=[480]

(今回の検証環境の Git Bash 5.2.26 で実行した結果です)

タブ区切りでは、空欄が詰められて、値段がトッピングの欄に入りました。 カンマ区切りでは、空欄は空欄のままです。

bash の man ページ(man7.org)の Word Splitting の説明では、IFS の空白文字(空白類の文字)が続くと1つの区切りとして扱い、先頭と末尾の空白文字は無視し、空白以外の IFS 文字は連続すると空のフィールドを作る、とされています。 タブは空白類の文字なので、「連続したらまとめる」側に入るわけです。

readさん「トッピングなしのきつねうどんですね。では、480 というトッピングを」

……480円分のトッピング。たぶん天ぷらが乗りきらない。

対処としては、タブを空白類でない文字に置き換えてから読む方法があります。

printf 'kitsune\t\t480\n' > tsv.txt

while IFS=$'\037' read -r name topping price; do
  echo "name=[$name] topping=[$topping] price=[$price]"
done < <(tr '\t' '\037' < tsv.txt)
name=[kitsune] topping=[] price=[480]

(今回の検証環境の Git Bash 5.2.26 で実行した結果です。\037 は ASCII の「ユニット区切り」という制御文字で、ふつうのデータには出てこない文字です)

列の多いデータを本格的に扱うなら、awk -F'\t' や Python など、区切りの扱いがはっきりした道具に任せるのも立派な設計判断です。

全部 Excel で開けば解決では?

Excel は Excel で、先頭の 0 を消したり日付に変えたりする、別の出張所を持っています。

ファイル名に改行が入ってもいいように:-d ” と -print0

find の結果を while read で回すとき、ファイル名に改行が入っていると、1つのファイルが2行に割れます。 ファイル名に改行。……入れられるんです、これが。

rm -rf shop && mkdir shop
touch "shop/kitsune udon.txt" "shop/zaru.txt"
touch "shop/kake
udon.txt"                                   # a file name with a newline in it

echo "--- newline-separated"
n=0
while IFS= read -r f; do n=$((n + 1)); done < <(find shop -type f)
echo "lines read: $n"

echo "--- NUL-separated"
n=0
while IFS= read -r -d '' f; do n=$((n + 1)); done < <(find shop -type f -print0)
echo "files read: $n"
--- newline-separated
lines read: 4
--- NUL-separated
files read: 3

(今回の検証環境の Git Bash 5.2.26 で実行した結果です。Windows 上の Git Bash でも、改行の入ったファイル名を作れました)

ファイルは3つなのに、改行区切りだと4行。 find -print0 はファイル名を NUL(値が 0 のバイト)で区切って出し、read -d '' は NUL を行の区切りにして読みます。 POSIX の read の仕様でも、-d に空の文字列を渡すと、行の区切りは NUL になる、とされています。NUL はファイル名に入らない文字なので、これなら割れません。

改行入りのファイル名を作る人なんて、いる?

いないと信じたい。 でも、いないと信じたファイル名ほど、本番のサーバーにいます。

readさん「かけ、と、udon.txt、の2名様ですね」

……1名様です。1名様の名前に改行が入っているだけです。

mapfile・コマンド置換との使い分け

最後に、設計判断の話です。

やりたいこと向いている書き方理由
1行ずつ処理し、途中の状態をあとで使うwhile IFS= read -r ... done < ファイル本店で動き、巨大なファイルでも1行ずつ読める
全行を配列にして、あとで何度も使うmapfile -t 配列 < ファイル1行で書け、${#配列[@]} で件数も取れる
結果を1つの値で受け取る値=$(コマンド)ループを書かずに済む
パイプの形を変えられない既存スクリプトshopt -s lastpipeスクリプト内ならジョブ制御がオフで効く

mapfile は bash の組み込みコマンドで、今回の検証環境の help mapfile では、標準入力から行を読んで配列に入れるコマンドと説明されています。 -t は、各行の最後の改行を取りのぞくオプションです。

全部 mapfile で配列にすれば、出張所問題とは無縁では?

cmd | mapfile -t arr と書いたら、mapfile さんが出張所に行きます。 ……どの店員も、パイプの後ろに立たせたら出張です。

店長「じゃあ全部の書き方を1本のスクリプトに入れて、気分で使い分けよう」

……読む人が、毎回違う店に迷いこみます。1本のスクリプトの中では、書き方をそろえておきましょう。

逆に言うと、覚えることは1つだけです。 パイプの後ろで変数を変えても、本店には戻らない。

まとめ:消えた3杯は、出張所の黒板にいた

冒頭では、3杯数えたはずのループが、外に出たら 0杯と言っていました。

  • パイプの後ろの while は出張所(サブシェル)で動く。done < ファイル か done < <(cmd) で本店に戻す
  • 最後の行に改行がないと、read は読めても 1 を返す。|| [[ -n $line ]] で拾う
  • IFS= read -r で、空白と \ をそのまま読む
  • ループの中のコマンドが標準入力を食べるなら、< /dev/null、ssh -n、read -u 3
  • CRLF は ${line%$'\r'}、タブの空欄は区切り文字の置き換え、改行入りのファイル名は -print0 と -d ''

readさん「本店の黒板、3杯と書いておきました」

店長「最初からそこに書いてくれれば……」

readさん「最初から本店に呼んでくれれば」

……ぐうの音も出ない。

じゃあ明日から、パイプは禁止?

禁止しなくて大丈夫です。 後ろで変数を書き換えなければ、パイプはむしろ働き者。出張所も、出前の仕事なら得意です。

変数をループの外で使いたいなら、ループをパイプの後ろに置かない。それだけで、閉店後に注文が消える怪奇現象は起きなくなります。

パイプと終了コードの関係は、Bashで処理が失敗したのに成功扱い? pipefail・PIPESTATUSの記事でも書いています。あわせてどうぞ。

まずは自分のスクリプトで | while read を1つ探してみてください。 見つかったら done < <(...) に書き換える。それで今夜の注文票は、ちゃんと本店の黒板に並びます🍜

参考資料

  • GNU Bash Reference Manual(Bash 5.3) — Pipelines の節。パイプの各コマンドがそれぞれのサブシェルで実行されること、lastpipe が有効でジョブ制御が有効でないとき最後の要素をシェル自身で実行することがあること
  • Bash FAQ(Chet Ramey)E4 — パイプの各要素が別プロセスで動き、子プロセスは親の環境を変えられないこと、コマンド置換・プロセス置換への書き換え
  • POSIX:read — 区切りの前にファイルの終わりが来たら変数を設定して終了コード 1 にすること、-r の意味、\ による行の継続、IFS による分割と残りを最後の変数に入れること、-d に空文字列で NUL 区切りになること
  • bash(1)(man7.org) — Word Splitting の IFS の空白文字の扱い(連続はまとめる、空白以外の IFS 文字の連続は空のフィールド)
  • OpenBSD manual:ssh(1) — -n が標準入力を /dev/null につなぎ替え、標準入力を読まないようにすること

確認日:2026-10-04。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

おうどん|癒しと創作をたのしむ雑食クリエイター
写経アプリやクレイセラピー、優しい和風デザインがすき。
ZARDと刀剣と文字に癒されて、今はアプリ作ってます。

コメント

コメントする

目次