この記事の狙い
シェルスクリプトでテキストやファイル名を安全に読み込むための定石をまとめます。IFS と read -r を正しく使い、空白・タブ・末尾スペース・末尾改行なし行・NUL 区切りに耐える読み込みを実現します。
前提と対象
- Bash 4+/
set -Eeuo pipefail推奨 - 行単位の処理、ファイル名リストの処理、フィールド分解(軽量版)を対象
TL;DR(最小実装・コピペ可)
#!/usr/bin/env bash
set -Eeuo pipefail
# 1) 行単位(空白保持・末尾改行なし行も拾う)
while IFS= read -r line || [[ -n $line ]]; do
printf '<%s>\n' "$line"
done <"$1"
# 2) パイプの“サブシェル落とし穴”回避(変数を上位で使いたい)
count=0
while IFS= read -r _ || [[ -n $_ ]]; do ((count++)); done < <(some_cmd)
echo "$count"
# 3) ファイル名は NUL 区切りで(安全・推奨)
while IFS= read -r -d '' path; do
printf '[%s]\n' "$path"
done < <(find . -type f -print0)
基本:なぜ IFS= read -r なのか
IFS=- 分割・トリムを無効化(先頭末尾の空白やタブを消さない)
-r- バックスラッシュ解釈を無効化(
\t→ タブ化しない、末尾\を保持)
- バックスラッシュ解釈を無効化(
readは末尾改行のない最終行で非ゼロ終了します。
そのためwhile IFS= read -r line || [[ -n $line ]]; do …として最後の 1 行を取りこぼさないのが定石です。
よく使う読み込みパターン
1) ファイルを1行ずつ(素直な版)
# 変数が呼び出し元に残る(パイプではない)
while IFS= read -r line || [[ -n $line ]]; do
process_line "$line"
done <"$file"
2) コマンド出力を行単位で
while IFS= read -r line || [[ -n $line ]]; do
handle "$line"
done < <(cmd generating lines)
cmd | while …; do …; doneはサブシェルになり、ループ内の変数が外に残らないので非推奨。
プロセス置換< <( … )を使うと回避できます。
3) NUL 区切り(ファイル名・バイナリ安全)
while IFS= read -r -d '' path; do
use "$path"
done < <(find . -type f -print0)
-d ''は NUL(\0) を区切りとする指定- 改行・空白・タブ・
*?[]を含む名前も安全
4) すべての行を配列で一気に
# 行末改行は落ちるが、空行は保持(-t)
mapfile -t lines <"$file"
for l in "${lines[@]}"; do printf '%s\n' "$l"; done
NUL 区切りを配列へ:
mapfile -d '' -t paths < <(some -print0)
5) 簡易フィールド分割(軽量 CSV 風)
# タブ区切りの2列だけ取りたい等の“軽い用途”に
while IFS=$'\t' read -r col1 col2 _; do
printf 'A=%s B=%s\n' "$col1" "$col2"
done <"$tsv"
本格的な CSV(引用・改行内包)は 専用ツール(
csvtool,python -c,mlr等)を使いましょう。
IFS の扱い(局所化のコツ)
- 原則:局所化
while IFS= read -r …の形で読み取り行だけIFS を上書き- グローバルに
IFS=$'\n'などは副作用大で事故のもと
readの前だけ特定区切りにしたいとき:while IFS=$'\t' read -r a b; do …; done
末尾改行なし行を落とさない
read は「改行を読めなかった」時点で非ゼロを返します。
しかしデータ自体は line に入っている場合があるため、条件に || [[ -n $line ]] を足すのが定石です。
while IFS= read -r line || [[ -n $line ]]; do
…
done <"$file"
落とし穴と対策(アンチパターン → 改善)
| 悪い例 | 問題 | 良い例 |
|---|---|---|
while read line; do …; done | 空白・バックスラッシュが壊れる | while IFS= read -r line … |
cat file | while …; done | サブシェルで変数が外へ残らない | while …; done <"$file" or < <(cmd) |
IFS=$'\n' を冒頭で固定 | 副作用が広がる | while IFS=$'\n' read -r … の局所化 |
| 改行なし最終行を無視 | データ欠落 | `… |
| 改行区切りでファイル名処理 | スペースや改行で破綻 | NUL 区切り(-print0 / -d '') |
“コピペ可”テストブロック(最小)
#!/usr/bin/env bash
set -Eeuo pipefail
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# 入力準備:空白/タブ/末尾スペース/改行なし最終行 を含む
printf 'a b \n\tc\t\nend-no-nl' >"$TMP/in" # 最後の行は改行なし
mapfile -t got < <(
while IFS= read -r line || [[ -n $line ]]; do
printf '<%s>\n' "$line"
done <"$TMP/in"
)
[[ "${got[0]}" == "<a b >" ]] || { printf 'line0:%s\n' "${got[0]}"; exit 1; }
[[ "${got[1]}" == "<\tc\t>" ]] || { printf 'line1:%s\n' "${got[1]}"; exit 1; }
[[ "${got[2]}" == "<end-no-nl>" ]] || { printf 'line2:%s\n' "${got[2]}"; exit 1; }
# NUL 区切り:スペース/改行を含むファイル名を安全に処理
touch "$TMP/a b" "$TMP/c"$'\n'"d"
mapfile -d '' -t files < <(find "$TMP" -maxdepth 1 -type f -print0)
# 全て拾えているか(件数のみ確認)
[[ "${#files[@]}" -ge 2 ]] || { echo "nul fail"; exit 1; }
echo "PASS"
運用メモ(実務)
- ログと結果の分離:処理結果は stdout、進捗は stderr
- 巨大ファイルは
mapfileよりストリーム処理(行ごと)でメモリ節約 - ファイル名は最初から最後まで NUL セーフで扱い、外部コマンドには
-0/-print0を徹底 - パイプ前提の設計が必要なら、状態は一時ファイル/FDで受け渡す(サブシェル問題を避ける)
互換性と移植性
mapfile/readarrayは Bash 4+。Bash 3.x(古い macOS)ではwhile IFS= read -rで代替-d ''(NUL 区切り)は Bashreadの機能。find -print0(GNU/BSD どちらも可)と組み合わせる
セキュリティと安全設計
- 未検証の入力をコマンドとして評価しない(
eval禁止) - ログへユーザー入力を出すときは
%q/${var@Q}で安全表示 - ファイル名の受け渡しは NUL 区切りを基本に
