Edited at

bashで文字列分解する時、cutやawkもいいけど、setの方が早い、けどreadが最強

More than 1 year has passed since last update.

スペースやタブで区切られた文字列から、特定の箇所を抽出する、

といえば、真っ先にcutやawkが浮かびますが、実はsetでも似たようなことができます。


お題

例えば、こんなhostsがあったとします。空白はすべてスペースであるとします。


hosts

123.123.123.123         geeg1   # application server

123.123.123.124 geeg2 # web frontend server
123.123.123.125 geeg3 # super fabulous exciting backup server #1

これをループして、IP、ホスト名、コメント部分を別々の変数に格納し、表示します。


cutの場合


case_of_cut

while read line

do
ip=$(cut -d' ' -f 1 <<<${line})
hostn=$(cut -d' ' -f 2 <<<${line})
comment=$(cut -d' ' -f 4- <<<${line})
echo "[ip]${ip} [hostname]${hostn} [comment]${comment}"
done < hosts

他にも書きようがあるかもしれませんが、ちょっとごちゃっとしてますね。


awkの場合


case_of_awk

while read line

do
ip=$(awk '{print $1}' <<<${line})
hostn=$(awk '{print $2}' <<<${line})
comment=$(awk '{for(i=4;i<NF;i++){ printf("%s%s",$i,OFS=" ")}print $NF}' <<<${line})
echo "[ip]${ip} [hostname]${hostn} [comment]${comment}"
done < hosts

本当はawk内で完結させようと思ったのですが、知識及ばず…。

awkはフィールド範囲指定が弱いっぽいので、cutより複雑になってしまいますね。


setの場合


case_of_set

while read line

do
set ${line}
ip=${1}
hostn=${2}
comment=$(eval echo $(eval echo \\\$\{$(echo {4..${#}})\}))
echo "[ip]${ip} [hostname]${hostn} [comment]${comment}"
done < hosts

範囲指定部分がトリッキーですが…範囲指定ではない部分はいたってシンプルに書けます。

もしもちゃんと最後の要素の番号を判定せず、なんとなくの範囲指定をするなら、

comment=$(eval echo \$\{{4..99}\})と書いてもOKです。

2017/10/15追記

@akinomyoga さんに教えて頂きました。感謝です。

$(eval echo $(eval echo \\\$\{$(echo {4..${#}})\}))の部分ですが、なんと、

${*:4}…たったこれだけで行けてしまいます…。


case_of_set_2

while read line

do
set ${line}
ip=${1}
hostn=${2}
comment=${*:4}
echo "[ip]${ip} [hostname]${hostn} [comment]${comment}"
done < hosts

まず、変数\${*}は特殊パラメータであり、何を意味するかというと、全ての位置パラメータ(\$1とか\$2とか)を意味します。

こいつに部分文字列展開${parameter:offset}を適用することで、位置パラメータの4番以降…という表現ができてしまうんですね。

素晴らしいです。


readの場合

@magicant さんに教えて頂きました。感謝です。


case_of_read

while read ip hostname n comment

do
echo "[ip]${ip} [hostname]${hostn} [comment]${comment}"
done < hosts

いらない部分を捨てる必要がありますが、捨てる部分がないか、少なければ、

ものすごくシンプルに書けますね。


実行速度比較

もはやタイトルでネタバレですが、上記のwhileを以下な感じで500回ほど回して、実行時間を計測しました。

time for i in {0..500}; do ./case_of_cut.sh >/dev/null; done

結果は以下です。

command
time(s)

cut
23.692

awk
28.268

set(tricky)
13.493

set(4..99)
8.421

read
3.207

setがダブルスコアで圧勝です。

追記:readが更に早かったです!


setの仕組み

\$1,\$2等はPositional Parametersと言って、通常はシェル起動時に引数が代入されます。

代入分による代入はできませんが、setコマンドを使うことにより代入しなおすことが可能です。

この時、IFSで区切られてPositional Parametersに格納されるので、cutやawkの代替として使えるわけです。

また、setはシェルの組み込みコマンドのため、パフォーマンス的にも有利、かも。

尚、Positional Parametersが10以上の数字になる場合は、ブレース(これ→{})で括る必要があります。


結局どれを使うか


  • cut

    誰が見ても分かりやすいけど遅い


  • awk

    少々トリッキーな上、遅い


  • set

    途中途中に捨てる部分が多い文字列であれば有用


  • read

    文字列全部分解して使う際は勿論、途中で捨てるものがある場合でもOK

    使いようによってはsetの真似事も可能


結論:setをpushしましたが、readが万能すぎました


おまけ setの範囲指定部分の簡単な解説

comment=$(eval echo $(eval echo \\\$\{$(echo {4..${#}})\}))

まず、$(echo {4..${#}})で\${#}を展開、{4..9}みたいなのが取れます。これがブレース展開のネタ。

次に、evalで変数を評価してから表示します。{4..9}のブレース展開が行われます。

\$は次のevalの評価で使うため、エスケープする必要がありますので、\\\$という書き方になっています。

更に、10以上の数字になることを考慮して、ブレース展開される外側をブレースで括ります。

この時、外側ブレースはエスケープしないと評価されてしまうので、エスケープします。

ここまでで${4} ${5} ${6} ${7} ${8} ${9}が取れます。

最後に、それら変数を再度evalで評価すると、$4とかの中身がようやく取れます。

これ数か月後に自分が読んで理解できるかどうか…。