20
14

More than 1 year has passed since last update.

シェルスクリプトの単語分割 (IFS) は罠だらけ

Last updated at Posted at 2021-07-08

はじめに

シェルスクリプトで文字列を分割する時に気軽に IFS を使っている例をよく見かけるのですが文字列の分割として考えると直感的な動作とは言えないので注意が必要です。これは単語分割が他の言語でよくある split 相当の機能ではなく、文字列を複数の単語として解釈するための機能(トークン解析の一種)で、何を単語としてみなすかという解釈の余地があるからだと思われます。この機能は bash では Word Splitting(単語分割)と呼ばれておりこの記事でもそれを採用しますが、POSIX では Field Splitting と呼ばれており、dash では White Space Splitting とも呼ばれています。

2022-10-17 更新 「ホワイトスペースは、スペース、タブ、改行だけではない」を追加

単語分割の動作の説明

日本語訳を読んでも全く意味が理解できません・・・。(私だけですか?)

単語分割
パラメータ展開、コマンド置換、算術式展開が行われたのが、ダブル クォートの内側ではない場合、シェルは展開の結果をスキャンして、 単語分割 を行います。

シェルは IFS のそれぞれの文字を区切り文字として扱い、 ほかの展開の結果をこれらの文字によって単語に分割します。 IFS が設定されていないか、その値がデフォルト値の <スペース><タブ><改行> と全く同じならば、前段の展開の結果の先頭や末尾の <スペース>, <タブ>, <改行> の並びは無視され、 先頭と末尾以外の IFS 文字の並びで単語が区切られます。 IFS の値がデフォルト以外のときに、 スペース や タブ という空白文字の並びが単語の先頭と末尾で無視されるのは、 その空白文字が IFS の値に含まれるとき ( IFS の空白文字の一つであるとき) だけです。 IFS に含まれ、 IFS 空白文字ではない文字は全て、隣接する任意の IFS 空白文字と一緒になってフィールドの区切りとなります。 IFS 空白文字の列も区切り文字として扱われます。 IFS の値が空文字列であれば、単語分割は全く行われません。

明示的に指定した空の引き数 ("" または '') は削除されずに残ります。 クォートされていない暗黙的な空の引き数が、 値を持たないパラメータを展開した結果として得られますが、 これらは削除されます。 値を持たないパラメータがダブルクォート内部で展開されると、 空である引き数となり、消されずに残ります。

展開が行われなければ単語分割も行われない点に注意してください。

Korn シェル (POSIX シェル) におけるフィールドの分割
コマンド置換の実行後、Korn シェルは IFS (内部フィールド・セパレーター) 変数に指定されたフィールド区切り文字がないか、置換結果をスキャンします。 この文字が検出された場所で、シェルは置換を別個の引数に分割します。

シェルは明示的なヌル引数 ("" または '') は残し、暗黙的なヌル引数 (値のないパラメーターの結果として生じたもの) を除去します。
IFS の値がスペース、タブ、または改行文字のいずれかである場合や、設定されていない場合は、スペース、タブ、または改行文字のシーケンスは、入力の先頭または末尾にあるものは無視され、入力内にあるものはフィールドを区切ります。 例えば、以下の入力は school と days という 2 つのフィールドという結果になります。

<newline><space><tab>school<tab><tab>days<space>

それ以外で、IFS の値がヌルでない場合は、以下の規則がシーケンスに適用されます。 IFS ホワイト・スペース は、IFS の値の中にあるホワイト・スペース文字の任意のシーケンス (ゼロまたはそれ以上のインスタンス) を意味するのに使用されます (例えば、IFS に space/comma/tab が含まれている場合は、スペースとタブ文字の任意のシーケンスは IFS ホワイト・スペースであると見なされます)。
IFS ホワイト・スペースは、入力の先頭と末尾では無視されます。
IFS ホワイト・スペースではない IFS 文字が入力に現れると、そのたびにそれが隣接する IFS ホワイト・スペースと共に、フィールドを区切ります。
長さがゼロ以外の IFS ホワイト・スペースがフィールドを区切ります。

デフォルト値での動作

デフォルト値(スペース、タブ、改行)での動作は比較的わかりやすいのでまず最初のこの説明をします。IFS のデフォルト値は、スペース、タブ、改行の 3 文字です。zsh の場合はさらに NULL 文字 (\0) を加えた 4 文字です。これらの文字が単語(フィールド)の区切りとして扱われます。

単語分割を機能させるためには、変数をダブルクォートで括らずに引数に使用します。ただし zsh の場合は単語分割がデフォルトでオフになっているため setopt shwordsplit を行っておく必要があります。

なお一般的には変数は単語分割されないようにダブルクォートで括るべきです。単語分割が必要な場合は殆どありません。そのため zsh では単語分割はデフォルトとしては良くない動作であると言う理由でデフォルトでオフになっているのです。

$ # zsh の場合
$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ str="   foo
   bar   "
$ func $str
[   foo
   bar   ]

$ # zsh 以外の場合、または zsh で setopt shwordsplit を実行した場合
$ setopt shwordsplit # ← を実行する必要があるのは zsh だけ
$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ str="   foo
   bar   "
$ func $str
[foo]
[bar]

この結果から以下のことがわかります

  • 連続するホワイトスペース(スペース、タブ、改行)は一つであるかのように扱われます
  • 最初のホワイトスペースの前や最後のホワイトスペースの後は無いものとして扱われます

この時点でも不思議な動作に感じるのではないでしょうか?実は似たような動作をするものが他にもあります。それは HTML です。以下の HTML は

<span>   foo
   bar
</span>

(普通に表示すると)foo bar と一行で連続して表示されます。前後のスペースは取り除かれ複数のスペースは一つとして扱われます。

私はこの動作を英語圏の文化だと考えています。英語のメールや小説を思い浮かべてください。英文では単語をスペースで区切りますが、単語の位置によっては複数のスペースやタブや改行で区切る場合があります。それでも文章としては一つのスペースで区切られているのと同じように考えますよね?英文で単語を認識するルールがそのまま単語分割 や HTML のルールになっているわけです。

TSV (タブ区切り)データの注意点

説明するまでもないと思うますが TSV とは各フィールドがタブ文字で区切られたファイルのことです。タブ文字はそのまま書いても分かりづらいので <TAB> と表記します。

a<TAB>b<TAB>c
$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ str=$(printf "a\tb\tc")
$ func $str
[a]
[b]
[c]

一見自然な動作をしているように思うかもしれませんが、フィールドの値が空の場合に困ることになります。以下は先程の例で b が空の場合です。

a<TAB><TAB>c
$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ str=$(printf "a\t\tc")
$ func $str
[a]
[c]

先程は 3 フィールドあったものが 2 フィールドになってしまいました。これはタブがスペースと同じホワイトスペースの一種だからです。つまり以下のデータと同じわけです。

a  c

同様に文字列の前後にあるタブも削除されてしまいます。空の項目が含まれる TSV の場合、単語分割を使ってデータを正しくパースすることは出来ないので注意が必要です。

CSV (カンマ区切り)データの注意点

さて先程の例でタブをカンマに置き換えてみます。

a,b,c

IFS, を代入することでカンマで分割することが出来ます。

$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=","
$ str="a,b,c"
$ func $str
[a]
[b]
[c]

では先程のタブの場合(a<TAB><TAB>c)と同じように 2 番目の値を空にしてみます。

a,,c
$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=","
$ str="a,,c"
$ func $str
[a]
[]
[c]

えー、タブの場合は 2 フィールドに減ったのにカンマの場合は 3 フィールドのままです・・・。

つまりこれは IFS に代入した区切り文字がホワイトスペース(スペース、タブ、改行)とそれ以外の文字で解釈が異なるということです。タブ区切りの場合は空の項目が含まれる場合 IFS で正しくパース出来ませんでしたがカンマ区切りであれば正しくパースできるのです・・・と言いたい所ですが一つ注意点があります。それは最後の項目が空の場合です。

a,b,
$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=","
$ str="a,b,"
$ func $str
[a]
[b]

今度は最後のフィールドが消えてしまいました(ただし zsh では消えません・・・)。消えるのは最後が空の場合だけです。以下のように末尾に空の項目が連続している場合でも最後だけが消えます(最後以外は消えません)。

a,b,,
$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=","
$ str="a,b,,"
$ func $str
[a]
[b]
[]

また最初の項目は空でも消えません。

,b,c
$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=","
$ str=",b,c"
$ func $str
[]
[b]
[c]

文字列全体がカンマの場合は 1 フィールドだけあるように表示されます。(カンマの前が出力されています)

$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=","
$ str=","
$ func $str
[]

文字列全体が空文字の場合はさすがに消えます。

$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=","
$ str=""
$ func $str

$ # 注意: 2022-09-24 記事更新前はこのように書いていたため
$ # 「文字列全体が空文字の場合も消えません。」と間違った結果を書いていました。
$ # 申し訳ありません。この記事のコメントも参照してください。
$ printf '[%s]\n' $str # => [] と出力される。

ややこしいと言ったらありませんね・・・。まあタブと違って最後の項目が消えるだけなので項目数がわかっていれば最後が空ということはわかるので、空データが含まれていてもパースすることは出来ます。

IFS にホワイトスペースとそれ以外の文字が含まれている場合

例として IFS がスペース、カンマ、タブの場合を考えてみます。さて次のデータはどのように単語分割されるでしょうか?

  a <TAB> b ,  ,  c  ,

答え

$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=$(printf " ,\t")
$ str=$(printf "  a \t b ,  ,  c  ,  ")
$ func $str
[a]
[b]
[]
[c]

正解できたでしょうか?

なんとなく理解できなくもないのですが、これのルールを正しく説明しようとすると難しいです。誰かこのルールをわかりやすく説明してください・・・。一応頑張ってみます。

  1. IFS の中にホワイトスペース(スペース、タブ、改行)が含まれていれば、対象文字列の前後の(IFS で指定された)ホワイトスペースを削除する
  2. IFS の中にホワイトスペース以外の文字があれば、その文字の前後の(IFS で指定された)ホワイトスペースを削除する
  3. 複数の(IFS で指定された)ホワイトスペースは一つに置き換える
  4. IFS で指定された文字列で分割する
  5. 項目数が 2 個以上で最後の項目が空文字の場合はなかったことする(zsh 以外)

であってるんでしょうか?ぶっちゃけ言って自信はありません。もっとシンプルな定義が欲しいですね。

ホワイトスペースは、スペース、タブ、改行だけではない

2022-10-17 追記 今更ながらこの問題に気が付きました……

ここまではホワイトスペースは、スペース、タブ、改行と書いていましたが、シェルによってホワイトスペースとして扱われる文字は異なっているようです。すなわちスペースやタブと同じように、連続するこれらの複数の文字が一つとしてみなされるということです。

シェル
ksh88 HT, LF, SP に加え %\ の挙動がおかしい(バグ?)
bash 2.x, bash 3.x (macOS 含む) SOH, HT, LF, SP, DEL
dash, bash 4.x, mksh, zsh HT, LF, SP
bash 5.x, ksh93, yash HT, LF, VT, FF, CR, SP

傾向としては、ksh88 で怪しい挙動の文字があるものの、スペース (SP)、タブ (HT)、改行 (LF) がホワイトスペースとして定義され、他のシェルにも広まったが、古い bash はなぜか SOH と DEL を加えてしまい、bash 4.x で修正。その後 ksh93 で VT、FF、CR が追加され、bash 5.x と yash が互換性のために追従したといった所でしょうか。

仕様としては、まあわからなくはないのですが、おそらくこれは POSIX の標準規格で言及されていません。POSIX で標準化される後の話だと思われるので仕方ないのですが、これはPOSIX のバグだと思われます。本来なら「SOH、DEL、VT、FF、CR をホワイトスペースとしてみなすかどうかは未規定である(シェルによって異なる)」と書かなければいけないはずです。

また文字コードで 0x80 以上の文字は ASCII 文字コードの範囲から外れるためか、ksh、yash、zsh で期待した動作をしませんでしたが、おそらく文字コード周りの話に関係しており、これらの文字を区切り記号として使うことも無いと思われるので、ここでは追求しないことにします。

以下は検証に使ったスクリプトです。

#!/bin/sh

set -eu
[ "${ZSH_VERSION:-}" ] && setopt shwordsplit

gennum() {
    # { echo "obase=8; ibase=10;"; seq 255; } | bc
    # return
    echo 1 2 3 4 5 6 7 10 11 12 13 14 15 16 17 \
    20 21 22 23 24 25 26 27 30 31 32 33 34 35 36 37 \
    40 41 42 43 44 45 46 47 50 51 52 53 54 55 56 57 \
    60 61 62 63 64 65 66 67 70 71 72 73 74 75 76 77 \
    100 101 102 103 104 105 106 107 110 111 112 113 114 115 116 117 \
    120 121 122 123 124 125 126 127 130 131 132 133 134 135 136 137 \
    140 141 142 143 144 145 146 147 150 151 152 153 154 155 156 157 \
    160 161 162 163 164 165 166 167 170 171 172 173 174 175 176 177 \
    200 201 202 203 204 205 206 207 210 211 212 213 214 215 216 217 \
    220 221 222 223 224 225 226 227 230 231 232 233 234 235 236 237 \
    240 241 242 243 244 245 246 247 250 251 252 253 254 255 256 257 \
    260 261 262 263 264 265 266 267 270 271 272 273 274 275 276 277 \
    300 301 302 303 304 305 306 307 310 311 312 313 314 315 316 317 \
    320 321 322 323 324 325 326 327 330 331 332 333 334 335 336 337 \
    340 341 342 343 344 345 346 347 350 351 352 353 354 355 356 357 \
    360 361 362 363 364 365 366 367 370 371 372 373 374 375 376 377
}

for i in $(gennum); do
    ch=$(printf "%b_" "\0$i") && ch=${ch%?}
    IFS="$ch"
    str="@${ch}${ch}${ch}@@"
    set -- $str
    [ "${4:-}" = "@@" ] && continue
    case $i in
        1) printf '%s\n' "$i SOH" ;;
        11) printf '%s\n' "$i HT (\t)" ;;
        12) printf '%s\n' "$i LF (\n)" ;;
        13) printf '%s\n' "$i VT (\v)" ;;
        14) printf '%s\n' "$i FF (\f)" ;;
        15) printf '%s\n' "$i CR (\r)" ;;
        40) printf '%s\n' "$i SP" ;;
        100) ;; # @
        177) printf '%s\n' "$i DEL" ;;
        2?? | 3??)
            printf '%s ' "$i non-printable"
            printf '[%s] ' "$@"
            echo
        ;;
        *)
            printf '%s ' "$i unknown"
            printf '[%s] ' "$@"
            echo
            exit 1
    esac
done

バグがあるシェル

こういうわけのわからないルールなので単語分割はバグの温床です。各シェルで以下のコードの出力を調べてみました。細かいバージョンまでは調べていないので多少前後します。注意して下さい。以下の出力が正しい出力です。

$ func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
$ IFS=$(printf " ,\t")
$ str=$(printf "  a \t b ,  ,  c  ,  ")
$ func $str
[a]
[b]
[]
[c]

結論を先にいうと zsh、NetBSD ksh は注意が必要。yash も少し古いバージョンは注意が必要かもしれません。

bash

bash は 2.03 では以下のように出力されます。

[a]
[b]
[]
[]
[c]
[]

2.05 では以下のように出力されます。

[a]
[b]
[c]

少なくとも 3.1.17(3系?)以降では正しく出力されました。古い macOS でも 3.2.57 なので今は問題ないと言って良いでしょう。

zsh

まず zsh は調べた限り 3.1.9 から 5.9 (私が検証した最初のバージョンから現時点の最新バージョン)まで一貫して最後の項目が消えません。これはもう仕様なのでしょう。

[a]
[b]
[]
[c]
[]

yash

yash は少なくとも 2.36 では zsh と同じ出力ですが、少なくとも 2.43 では修正されています。

pdksh

pdksh では以下のように出力されますが、シェル自体が古く使われてないので気にする必要はありません

[a]
[b]
[]
[]
[c]
[]
[]

posh

pdksh のフォークである posh も初期は pdksh と同じバグがありますが、少なくとも 0.8.5 以降は修正されているようです。

NetBSD ksh

これも pdksh のフォークであり pdksh と同じバグを抱えています。NetBSD 9.0 で確認しました。

参考 ホワイトスペースを一つづつ分割したい場合

IFS を使ってホワイトスペースで分割は複数の連続するホワイトスペースが一つとして扱われてしまう問題があります。これを回避したい場合はパラメータ展開を使用することが出来ます。例えば空の項目が含まれる TSV を適切にパースするには以下のようにします。

a<TAB>b<TAB>c
func() { for i in "$@"; do echo "[$i]"; done; } # 全引数をそれぞれ[]で囲って出力
TAB=$(printf "\t")

# 位置パラメータを配列とみなして、タブで区切った各要素を追加していく
str="a${TAB}${TAB}c"
set --
until
  f=${str%%"$TAB"*}
  set -- "$@" "$f"
  [ "$f" = "$str" ]
do
  str=${str#*"$TAB"}
done

# 別解
str="a${TAB}${TAB}c"
set --
str="${str}${TAB}"
while [ "$str" ]; do
  set -- "$@" "${str%%"$TAB"*}"
  str=${str#*"$TAB"}
done

func "$@"
# [a]
# []
# [c]

その他の方法として外部コマンドを使って処理する方法も考えられますが省略します。

まとめ

ということで IFS による単語分割は罠だらけという話でした。一般的に IFS を使って文字列を分割する処理は速いですし扱うデータのフォーマットが限れられていれば使用できるのですが、ホワイトスペースとそうでない文字とで挙動が違うので注意が必要です。

しかしこの話は IFS で文字列を正しく分割しようとしたら必ずハマると思うのですが、このことを書いている記事がぜんぜん見つからない気がします。少し不思議です。

20
14
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
14