スタートアップCTOによるITエンジニアのためのブログ

Bashの単語分割(Word Splitting)を使いこなそう

Bashでスペースを含むファイル名をうまく扱えなかったり、文字列に含まれる複数の単語をループする方法がわからなかったりすることはないでしょうか。

Bashの単語分割(Word Splitting)という機能を理解すると、そのような事態にうまく対処できるようになります。本記事では、単語分割の仕組みや使い方を徹底解説します。また、使うべきでない状況や、代替案についても紹介します。

目次

  • 概要
  • IFSのデフォルト値と変更
  • 仕組み(展開の順序)
  • ダブルクォートによる防止
  • トークンパーシングとの違い
  • 配列における単語分割
  • readコマンドとの使い分け
  • まとめ

概要

単語分割(Word Splitting)は、Bashの展開プロセスの1つで、クォートされていない展開結果の文字列を「区切り文字」で別々の単語に分割する機能です。

# Bash

# スペースで3つの単語に分割される例
fruits="apple orange banana"
printf ' <%s>' $fruits
# 出力 <apple> <orange> <banana>

IFSのデフォルト値と変更

単語をどこで区切るかを決めているのが、内部フィールド区切り文字を格納する環境変数 IFS (Internal Field Separator) です。デフォルトでは以下の3つが設定されています。

  1. スペース
  2. タブ
  3. 改行
# Bash
# デフォルトのIFSを確認
printf "$IFS" | cat -A
# 出力
#  ^I$

結果の読み方:

  • (半角スペース): そのままスペースとして表示
  • ^I: タブ
  • $: 行の終わり(改行文字が含まれていることを示す)

連続する区切り文字の扱い

IFSの値がデフォルト(スペース・タブ・改行)の場合、文字列の先頭と末尾にある連続する空白は無視されます。また、文字列の中にある連続した空白は1つの区切り文字として扱われます。

# Bash
# 先頭・中間・末尾に複数のスペース
fruits="   apple   orange   "
printf " <%s>" $fruits
# 出力: <apple> <orange>

IFSの変更

IFSは一時的に変更して、カンマ区切り(CSV風)などで分割することも可能です。

# Bash
# サブシェルを使って一時的に変更(元のIFSを汚さない)
fruits="apple,orange,banana"
(IFS=','; printf ' <%s>' $fruits)
# 出力: <apple> <orange> <banana>

仕組み(展開の順序)

ここが重要ですが、単語分割は「パラメータ展開」「コマンド置換」「算術展開」が行われた直後にのみ実行されます。

種類実行例単語分割の結果
パラメータ展開$var分割される
コマンド置換$(echo a b)分割される
算術展開$((100 + 1))分割される(IFSに0が含まれる場合など)

逆に、ブレース展開、チルダ展開、プロセス置換の直後には単語分割は行われません。

# Bash
ブレース展開後はスペースがあっても単語分割されない
printf ' <%s>' 'h '{a,i}
# 出力: <h a> <h i>

# チルダ展開(~)は、IFSが'/'であっても分割されない
OLD_IFS=$IFS
IFS='/'
printf ' <%s>' ~
# 出力:  </home/user> (1つの単語として維持される)

# パラメータ展開($HOME)を使うと分割される
printf ' <%s>' $HOME
# 出力: <> <home> <user>

# プロセス置換後は、IFSが'/'であっても単語分割されない
printf ' <%s>' <(sort a.txt)
# 出力:  </dev/fd/63>

IFS=$OLD_IFS

トークンパースとの違い

文字列リテラルそのものがスペースで区切られているのは、Bashがコマンドを解釈する際の「トークンパース(解析)」であり、単語分割ではありません。

# Bash
fruits="apple orange banana"
echo $fruits melon
# 出力: apple orange banana melon
  1. トークンパース: echo$fruitsmelon の3つに分解される。
  2. 展開: $fruits"apple orange banana" という1つの文字列になる。
  3. 単語分割: その結果が "apple", "orange", "banana" に分かれる。
  4. 実行: echo に合計4つの引数が渡される。

ダブルクォートによる単語分割の防止

展開をダブルクォート " で囲むと、単語分割(およびパス名展開)を無効化できます。「迷ったらクォート」がBashの鉄則です。

# Bash
FILE_NAME="my memo.txt"

# NG: 引数が2つ(my と memo.txt)に分かれてしまう
ls -l $FILE_NAME 

# OK: スペースを含んだ1つの引数として渡される
ls -l "$FILE_NAME"

脆弱性の例

単語分割を放置すると、意図しないパス名展開(グロブ)と組み合わさって危険な挙動をすることがあります。

# Bash

# ユーザーが細工した入力
input="-f *.txt"

rm $input
  1. 単語分割: -f*.txt に分かれる。
  2. パス名展開: *.txt が展開される。
  3. 実行: rm -f file1.txt file2.txt ...

配列における単語分割

配列を扱う際は、[@][*] の違い、そしてクォートの有無が重要です。

# Bash
files=("my memo.txt" "notes.txt")
# クォートなしだと単語分割される
printf ' <%s>' ${files[@]}
# 出力:  <my> <memo.txt> <notes.txt>

# *でもクォートなしだと単語分割される
printf ' <%s>' ${files[*]}
# 出力:  <my> <memo.txt> <notes.txt>

# クォートで単語分割せず、配列要素を維持
printf ' <%s>' "${files[@]}"
# イメージ: printf ' <%s>' "${files[0]}" "${files[1]}"
# 出力: <my memo.txt> <notes.txt>

# 単語分割せず、配列要素を結合
printf ' <%s>' "${files[*]}"
# イメージ: printf ' <%s>' "${files[0]} ${files[1]}"
# 出力: <my memo.txt notes.txt>

配列を個別要素に分解し単語分割を防ぐのは同じですが、"${files[@]}"は配列要素を個別にダブルクォートし、"${files[*]}"は全体をダブルクォートするイメージです。

記述方法挙動推奨度
${files[@]}各要素がさらに単語分割される低(危険)
${files[*]}各要素がさらに単語分割される低(危険)
"${files[@]}"各要素を個別にクォートした状態で展開する高(安全・基本)
"${files[*]}"全要素をIFSの1文字目で連結して1単語にする中(用途による)

readビルトインコマンドとの使い分け

外部からの入力(ファイルやユーザー入力)を分割したい場合は、展開による単語分割よりも read ビルトインコマンドが推奨されます。

readコマンドはIFSを使って単語分割を行いますが、パス名展開を行わないためより安全です。

# Bash
# 安全な分割方法
DATA="apple,orange,*"
IFS=, read -ra FRUITS <<< "$DATA"
printf ' <%s>' "${FRUITS[@]}"
# 出力:  <apple> <orange> <*>

ただし、read構文はやや冗長なため、状況に応じて単語分割と使い分けるのがおすすめです。

1. 自分が作った文字列(安全)

スクリプト内で自分が文字列を定義しているなら、通常の単語分割が最も手軽でスマートです。

複数の引数(オプション)を動的に組み立てる場合。

# Bash
# 状況に応じたオプションの組み立て
DEBUG_OPTS="-v --trace"
COMMAND="curl -s"

# クォートせず、あえて単語分割させる
$COMMAND $DEBUG_OPTS https://example.com

自作の単純なリストをループする場合。

# Bash
SERVERS="web01 web02 db01"

for s in $SERVERS; do
    ssh "$s" "uptime"
done

意図的にパス名展開を使う場合。

# Bash
# 拡張子を指定する変数
TARGET_FILES="*.log *.txt"

# 単語分割 + パス名展開をダブルで狙う
ls -l $TARGET_FILES

2. 外部からの入力(危険)

ファイルの中身、ユーザー入力、APIのレスポンスなどは、中に *; が含まれている可能性があるため、必ず read -r や配列を使いましょう。

# Bash
# DATAは外部入力
IFS="," read -ra ADDR <<< "$DATA"
echo "${ADDR[0]}"

まとめ

  • 基本はダブルクォートで展開を保護する。
  • 単語分割を利用したいときだけ、あえてクォートを外す。(例:動的なオプション設定など)
  • 区切り文字を変えたいときは IFS を制御する。
  • 外部入力の分割には、より安全な read -a を検討する。

単語分割の仕組みを正しく理解して、バグや脆弱性のない堅牢なスクリプトを目指しましょう。