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つが設定されています。
- スペース
- タブ
- 改行
# 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
- トークンパース:
echo、$fruits、melonの3つに分解される。 - 展開:
$fruitsが"apple orange banana"という1つの文字列になる。 - 単語分割: その結果が
"apple","orange","banana"に分かれる。 - 実行:
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
- 単語分割:
-fと*.txtに分かれる。 - パス名展開:
*.txtが展開される。 - 実行:
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を検討する。
単語分割の仕組みを正しく理解して、バグや脆弱性のない堅牢なスクリプトを目指しましょう。
