初歩のシェルスクリプトで遊ぶ[sedのモヤモヤを整理したい(1)]

「極める気にはなれないが、ちょっとは便利に使いたい」

sedは私はそんな感じなのだけど。思いついたことがあって、それをメモがてらに書いていきます。
遊びで独学してる素人の勉強メモなんで、調べものには使わんといてください。資料を当たって間違い探しをして勉強する、のならOKです。
記事の趣旨は、「経験でなんとなく覚えたsedだが、よくわからないモヤモヤがあるから、整理したい」です。情報の正確さや実用性は度外視してます。

sed『そのもの1個』は『while read ループ1個』に相当するのではないか

見出し通りで、こんなこと思いついたのが切っ掛けです。簡単なsedスクリプトなら、簡単なシェルスクリプトで真似ができるかもしれない。

最小限のsedモデル

#!/bin/bash
# =============================================================================
# ■ 簡易sed v0
# ・標準入力を受け取り、そのまま出力する
# ・pコマンドのみ使える
# 
# =============================================================================

# p:パターンスペース(PS)の内容を出力する
p_cmd(){
printf '%s\n' "${PATTERN_SPACE}"
}


NR='0'                               # 行番号
while IFS= read -r PATTERN_SPACE     # 入力を1行ずつシェル変数「パターンスペース」に読み込む
do
NR=$(( ${NR} + 1 ))                  # 行番号カウント

# sedのコマンドが入る場所----




# ----sedのコマンドが入る場所

echo "${PATTERN_SPACE}"              # パターンスペースを出力
done

見ての通りで、普通の「while read」ループで、echoをしてるだけです。入力されたテキストを、そのまま出力します。
readで行の内容を格納するシェル変数が「PATTERN_SPACE」で、これをsedでいう「パターンスペース」に模擬してます。
いきなりなんですが、簡易モデルとしても簡易すぎです。これだとコマンドのいくつかが表現できないので。

「パターンスペース内容を表示するpコマンド」

この中に「p」コマンドを置くと、どのように動作するか。(以降はシェル関数の記述は省略します)

#!/bin/bash

NR='0'
while IFS= read -r PATTERN_SPACE
do
NR=$(( ${NR} + 1 ))

# sedのコマンドが入る場所----

p_cmd


# ----sedのコマンドが入る場所

echo "${PATTERN_SPACE}"
done

pコマンドは「echo "パターンスペース"」です。1ループにechoがひとつ追加されたので、すべての行を2行ずつ出力するsedスクリプトになります。

sed -e 'p'

sedで書くと、こう。

$ seq 1 3 | ./sedSimple_0.sh 
1
1
2
2
3
3
$ seq 1 3 | sed -e 'p'
1
1
2
2
3
3

このループの中では、「pコマンド」は、行の数だけ、実行してます。3行あったら3回実行をしてる。これを踏まえて……

「アドレス」とは何だろうか

$ seq 1 3 | sed -e '2p'
1
2
2
3

アドレス指定をします。「2」を指定したから、2行目のみpコマンドが実行されて、2行目だけ増えてる。
このsedスクリプトを模擬するなら、どのように書くか。

#!/bin/bash

NR='0'
while IFS= read -r PATTERN_SPACE
do
NR=$(( ${NR} + 1 ))

# sedのコマンドが入る場所----
if test ${NR} -eq 2 ; then
p_cmd
fi

# ----sedのコマンドが入る場所

echo "${PATTERN_SPACE}"
done

if文を使って、「もし2行目ならば、~~を実行する」と表現しました。
「2行目を指定してpコマンドを実行」なのだけど、決して「1行目と3行目の処理中は、消えて無くなってる、わけではない」。毎行の処理で、毎回「if test~」は実行しています。
sedスクリプトも、これと同じように理解すべきなのではないか。
「2行目を指定してコマンドを適用する」と理解すると、まるでテキストファイルをGUIのエディタで編集するときのように、目視で2行目を選んでいる、ように考えてしまう。そうじゃなくて、毎行毎行「もし、現在の行番号が『2』ならば、~~を実行する」という条件分岐を繰り返している。

正規表現指定も、行番号指定も、どちらも条件分岐だと理解する

正規表現でのアドレス指定と、「y」や「s」の文字入れ替えコマンドが絡むと、「毎行のループで必ず条件分岐をしている」という視点が大事になります。

$ echo 'AAA
BBB
CCC' | sed -e '/A/y/A/B/' -e '/B/y/B/あ/'
あああ
あああ
CCC

「Aを含む行については、『A』を『B』に入れ替えよ」「『B』を含む行については、『B』を『あ』に入れ替えろ」というsedスクリプトです。
もし「『入力に』Bを含む行」を指定したのなら、1行目まで「あ」になるのはおかしい。これは「ひとつ目のコマンドでAがBに入れ替わって、ふたつ目のコマンドでBを含む行を指定したのだ」って説明されます。が。これは「行を指定した」「アドレスを指定した」と考えるよりも、です。
「パターンスペースの内容を正規表現で検索して、マッチしたかどうか、の条件分岐」だと解釈します。
シェルスクリプトで書き換えます。

#!/bin/bash

y_cmd(){
# 改行は「\n」の代わりに「\o000」を使うこと
XX=`printf "%s" "${PATTERN_SPACE}" | od -tx1 -An -v | sed -e "s/0a/00/g"`
eval 'PATTERN_SPACE=`printf "%s\n" "${XX}" | xxd -r -p | sed -e "'"${1}"'" | sed -e "s/\o000/\n/g"`_'
PATTERN_SPACE="${PATTERN_SPACE%_}"
}

NR='0'
while IFS= read -r PATTERN_SPACE
do
NR=$(( ${NR} + 1 ))

if echo "${PATTERN_SPACE}" | grep 'A' 1>/dev/null ; then
y_cmd 'y/A/B/'
fi

if echo "${PATTERN_SPACE}" | grep 'B' 1>/dev/null  ; then
y_cmd 'y/B/あ/'
fi


echo "${PATTERN_SPACE}"
done

yコマンドについては、sedをそのまま埋め込んでます。いろいろ書いてるのは、改行文字の代わりにヌル文字で処理するためのものです。
「if echo "${PATTERN_SPACE}" | grep 'A'」で、「もしパターンスペースの中に『A』が入ってるならば」の条件分岐になってます。これがsed正規表現でのアドレス指定にあたってる。
さらに先ほどのsedスクリプトも、少し書き換えます。

$ echo 'AAA
BBB
CCC' | sed -e '/A/ {
> y/A/B/
> }
> /B/ {
> y/B/あ/
> }'
あああ
あああ
CCC
# インデントして見やすく
'/A/ {                # もし「A」を含むならば
        y/A/B/     # 「A」を「B」に入れ替えよ
}
/B/ {                 # もし「B」を含むならば
        y/B/あ/     # 「B」を「あ」に入れ替えよ
}'

このように書き換えてみると、sedの数字による行番号指定も、正規表現でのアドレス指定も、同じように「if文で何を条件にしているか」の違いである、と言い換えることができます。

  • sedひとつが、while readのループひとつのようなもの。
  • sedのコマンドは、「sed内部のループに置くコマンドを、sedというプログラミング言語で実行する順番に」書いている。
  • たとえばsedをsコマンド一つで動作させたときは、「sedがsコマンドオプションで動作している」のではない。「ループの中で、テキスト1行にループ1回ずつ、sコマンドを実行している」ということ。
  • 行番号指定も、正規表現指定も、同じような条件分岐だ。ループ毎に必ず条件判定をしている。


と思っただけで、ソース読んだわけでも、読めるだけのスキルも無いんで、ただの思い付き書いてるだけですが。

「while read」ループの中のsed

while IFS='' read -r LINE
do

echo "${LINE}" | sed -e 's/入れ替えたい文字列/入れ替える文字列/g'

done

よくある「良くないsedの使い方」なのだけど……。別にいいんじゃないのかな……。
こういうスクリプトでも動作するし、個人的には「while read」に統一して一貫性を持たせるのも良いと思うんで、欠点としていうべきで悪いわけじゃ……と、個人的には思うんすよ。仕様を満たしてて、みんなが読みやすいんなら、それでいいような気はするので。中に条件分岐を追加するのも簡単だし。
でも、動作速度の点では劣るのは、それはしょうがない。

sed -e 's/入れ替えたい文字列/入れ替える文字列/g'

sedはそのものがwhile readと似ている」と意識しているなら、外側のwhile readそのものが要らない、ということが分かりやすくはなる、と思う。


「sコマンドそのもの」は複数行のテキストも扱える

テキストデータの改行を削除しようとして、

sed -e 's/\n//g'

と書いちゃうのがよくある、って聞きました。
なぜ期待通りに動かないのか。説明は「sedは1行ずつの繰り返し動作をしているから……」とされるようだけれども。この説明は、少し回りくどい。
sedが内部のsコマンドに文字を渡すとき、既に改行ごとに切り分けてしまっていて、sコマンドには改行が渡されていないから」「1行の改行のないテキストからは、改行は削れない」と言い換えられます。
つまり、なんとかして改行も丸ごとsコマンドに渡せば、改行を削れる。

$ echo '1111
2222
3333
4444
5555' | sed 's/\n//g'
1111
2222
3333
4444
5555
$ echo '1111
2222
3333
4444
5555' | sed --posix -n '1h;1!H;$x;$s/\n//g;$p'
11112222333344445555

「s/\n//g」も、ちゃんと効いてます。