IFS と read -r の安全読込|空白・改行耐性

運用と安全設計

この記事の狙い

シェルスクリプトでテキストやファイル名を安全に読み込むための定石をまとめます。
IFSread -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 区切り)は Bash read の機能。find -print0(GNU/BSD どちらも可)と組み合わせる

セキュリティと安全設計

  • 未検証の入力をコマンドとして評価しないeval 禁止)
  • ログへユーザー入力を出すときは %q/${var@Q} で安全表示
  • ファイル名の受け渡しは NUL 区切りを基本に

参考リンク

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする