Quantcast
Channel: Bashタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 2802

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

$
0
0
はじめに シェルスクリプトで文字列を分割する時に気軽に IFS を使っている例をよく見かけるのですが文字列の分割として考えると直感的な動作とは言えないので注意が必要です。これは単語分割が他の言語でよくある split 相当の機能ではなく、文字列を複数の単語として解釈するための機能(トークン解析の一種)で、何を単語としてみなすかという解釈の余地があるからだと思われます。この機能は bash では Word Splitting(単語分割)と呼ばれておりこの記事でもそれを採用しますが、POSIX では Field Splitting と呼ばれており、dash では White Space Splitting とも呼ばれています。 単語分割の動作の説明 日本語訳を読んでも全く意味が理解できません・・・。(私だけですか?) 単語分割 パラメータ展開、コマンド置換、算術式展開が行われたのが、ダブル クォートの内側ではない場合、シェルは展開の結果をスキャンして、 単語分割 を行います。 シェルは 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 では単語分割はデフォルトとしては良くない動作であると言う理由でデフォルトでオフになっているのです。 $ setopt shwordsplit # zsh の場合 $ str=" foo bar " $ printf '[%s]\n' $str [foo] [bar] この結果から以下のことがわかります 連続するホワイトスペース(スペース、タブ、改行)は一つであるかのように扱われます 最初のホワイトスペースの前や最後のホワイトスペースの後は無いものとして扱われます この時点でも不思議な動作に感じるのではないでしょうか?実は似たような動作をするものが他にもあります。それは HTML です。以下の HTML は <span> foo bar </span> (普通に表示すると)foo bar と一行で連続して表示されます。前後のスペースは取り除かれ複数のスペースは一つとして扱われます。 私はこの動作を英語圏の文化だと考えています。英語のメールや小説を思い浮かべてください。英文では単語をスペースで区切りますが、単語の位置によっては複数のスペースやタブや改行で区切る場合があります。それでも文章としては一つのスペースで区切られているのと同じように考えますよね?英文で単語を認識するルールがそのまま単語分割 や HTML のルールになっているわけです。 TSV (タブ区切り)データの注意点 説明するまでもないと思うますが TSV とは各フィールドがタブ文字で区切られたファイルのことです。タブ文字はそのまま書いても分かりづらいので <TAB> と表記します。 a<TAB>b<TAB>c $ str=$(printf "a\tb\tc") $ printf '[%s]\n' $str [a] [b] [c] 一見自然な動作をしているように思うかもしれませんが、フィールドの値が空の場合に困ることになります。以下は先程の例で b が空の場合です。 a<TAB><TAB>c $ str=$(printf "a\t\tc") $ printf '[%s]\n' $str [a] [c] 先程は 3 フィールドあったものが 2 フィールドになってしまいました。これはタブがスペースと同じホワイトスペースの一種だからです。つまり以下のデータと同じわけです。 a c 同様に文字列の前後にあるタブも削除されてしまいます。空の項目が含まれる TSV の場合、単語分割を使ってデータを正しくパースすることは出来ないので注意が必要です。 CSV (カンマ区切り)データの注意点 さて先程の例でタブをカンマに置き換えてみます。 a,b,c IFS に , を代入することでカンマで分割することが出来ます。 $ IFS="," $ str="a,b,c" $ printf '[%s]\n' $str [a] [b] [c] では先程のタブの場合(a<TAB><TAB>c)と同じように 2 番目の値を空にしてみます。 a,,c $ IFS="," $ str="a,,c" $ printf '[%s]\n' $str [a] [] [c] えー、タブの場合は 2 フィールドに減ったのにカンマの場合は 3 フィールドのままです・・・。 つまりこれは IFS に代入した区切り文字がホワイトスペース(スペース、タブ、改行)とそれ以外の文字で解釈が異なるということです。タブ区切りの場合は空の項目が含まれる場合 IFS で正しくパース出来ませんでしたがカンマ区切りであれば正しくパースできるのです・・・と言いたい所ですが一つ注意点があります。それは最後の項目が空の場合です。 a,b, $ IFS="," $ str="a,b," $ printf '[%s]\n' $str [a] [b] 今度は最後のフィールドが消えてしまいました(ただし zsh では消えません・・・)。消えるのは最後が空の場合だけです。以下のように末尾に空の項目が連続している場合でも最後だけが消えます(最後以外は消えません)。 a,b,, $ IFS="," $ str="a,b,," $ printf '[%s]\n' $str [a] [b] [] また最初の項目は空でも消えません。 ,b,c $ IFS="," $ str=",b,c" $ printf '[%s]\n' $str [] [b] [c] 文字列全体が空文字の場合も消えません。 $ IFS="," $ str="" $ printf '[%s]\n' $str [] ややこしいと言ったらありませんね・・・。まあタブと違って最後の項目が消えるだけなので項目数が固定であれば空データが含まれていてもパースすることは出来ます。 IFS にホワイトスペースとそれ以外の文字が含まれている場合 例として IFS がスペース、カンマ、タブの場合を考えてみます。さて次のデータはどのように単語分割されるでしょうか? a <TAB> b , , c , 答え $ IFS=$(printf " ,\t") $ str=$(printf " a \t b , , c , ") $ printf '[%s]\n' $str [a] [b] [] [c] 正解できたでしょうか? なんとなく理解できなくもないのですが、これのルールを正しく説明しようとすると難しいです。誰かこのルールをわかりやすく説明してください・・・。一応頑張ってみます。 IFS の中にホワイトスペース(スペース、タブ、改行)が含まれていれば、対象文字列の前後の(IFS で指定された)ホワイトスペースを削除する IFS の中にホワイトスペース以外の文字があれば、その文字の前後の(IFS で指定された)ホワイトスペースを削除する 複数の(IFS で指定された)ホワイトスペースは一つに置き換える IFS で指定された文字列で分割する 項目数が 2 個以上で最後の項目が空文字の場合はなかったことする(zsh 以外) であってるんでしょうか?ぶっちゃけ言って自信はありません。もっとシンプルな定義が欲しいですね。 バグがあるシェル こういうわけのわからないルールなので単語分割はバグの温床です。各シェルで以下のコードの出力を調べてみました。細かいバージョンまでは調べていないので多少前後します。注意して下さい。以下の出力が正しい出力です。 $ IFS=$(printf " ,\t") $ str=$(printf " a \t b , , c , ") $ printf '[%s]\n' $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.7.1 まで一貫して最後の項目が消えません。これはもう仕様なのでしょう。 [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 delimiter=$(printf "\t") str=$(printf "a\t\tc") # 位置パラメータを配列とみなして、タブで区切って代入していく set -- until f=${str%%"$delimiter"*} set -- "$@" "$f" [ "$f" = "$str" ] do str=${str#*"$delimiter"} done printf '[%s]\n' "$@" # [a] # [] # [c] その他の方法として外部コマンドを使って処理する方法も考えられますが省略します。 まとめ ということで IFS による単語分割は罠だらけという話でした。一般的に IFS を使って文字列を分割する処理は速いですし扱うデータのフォーマットが限れられていれば使用できるのですが、ホワイトスペースとそうでない文字とで挙動が違うので注意が必要です。 しかしこの話は IFS で文字列を正しく分割しようとしたら必ずハマると思うのですが、このことを書いている記事がぜんぜん見つからない気がします。少し不思議です。

Viewing all articles
Browse latest Browse all 2802

Trending Articles