rcmdnk's blog
Last update

NHK特集 行 ~比叡山 千日回峰~ [DVD]

シェルスクリプトで行数を数える、と言った場合に、 勝手に付けられたりする最後の行の改行コード等が 振る舞いを変える事があるのできちんと把握しておこう、と言う話。

以下は特に明記して無ければMacでBash 4.3.32での作業。 Zshは5.0.5です。 ですが、特に明記してないところでの作業については Bash/Zsh、GNU/BSDの区別無く使える、としているものです。

wc -l

ファイル等の行数を数える、と言ったらまず思い浮かぶのがwc。 行数、単語数、文字数を数えてくれるコマンドで、-lで行だけを数えます。

$ printf "$value" | wc -l
5
$ wc -l file.txt
10 file.txt

とか。

ちょっと注意が必要なのが、GNU wcだと上の様に左詰めで表示されますが、 Mac等のBSD系のwcだと

$ wc -l file.txt
      10 file.txt

みたいに左に余計な空白が入ります。 数を取ってきて計算に入れたりする場合はそれ程問題になりませんが、

$ n=$(printf "$value" | wc -l)
$ name="aaa_$n"

みたいな事をしようとすると、nameに余計な空白が含まれてしまうので

$ n=$(echo $(printf "$value" | wc -l))

とでもしておくとか空白を消す必要があります。 (echoとかにクォートしないで空白を持つ物を与えれば前後の空白は無視される。)

で、ここからが重要なところですが、 前回 のとこでも書きましたがwcで数える行数は newlineの数、つまり改行の数になります。

例えば文字だけで改行のないものは0行となります。

$ printf "aaa"| wc -l
0

ファイルでも同じで、最終行に改行コードが入ってない場合、 最後の行を除いた行数になります1

ただし、ファイルに適当なエディタで

file.txt
1
aaa

みたいな物を作って保存して試してみると

$ wc -l file.txt
1 file.txt

と、一行表示される事が殆どだと思いますが、これも 前回 書いたように、通常エディタでは最終行に自動的に改行コードを加えるからです。

$ od -c file.txt
0000000    a   a   a  \n
0000004

と、odとかで調べてみれば最後に改行コードが入ってることが分かります。

grep -c ‘’

grep-cオプションを与えると該当した行の数を数えてくれます。

これを使って検索する文字列に''("")(空白も含まない単なるクォート(もしくはダブルクォート)の連続)を与えると、 grepでは全ての行に該当する事になり、結果的に-cとの組み合わせで 行数を数える事が出来ます。

ただし、こちらの場合は最後の行に改行コードがあろうがなかろうが カウントします。

$ printf "aaa"| grep -c ''
1

単に該当行を見ていくだけなので当然といえば当然。

これのほうが直感的な感じがしますが、注意が必要なのは逆に 改行だけを最後に与えた場合、

$ printf "aaa\n"| grep -c ''
1
$ value="a
"
$ printf "$value"| grep -c ''
1

と、最後の改行の部分は無視されます。

改行が無視されるわけではなく、2つ以上あれば最後以外はカウントされます。

$ printf "aaa\n\n\n"| grep -c ''
3

wcと比べてみると

$ printf "aaa"|wc -l
0
$ printf "aaa"|grep -c ''
1


$ printf "aaa\n"|wc -l
1
$ printf "aaa\n"|grep -c ''
1


$ printf "aaa\n\n"|wc -l
2
$ printf "aaa\n\n"|grep -c ''
2

と言った感じに、一番最初の最終行に改行がない状態の時だけ 別の結果を出します。

外部コマンドを使わずに数える

wcgrepもほとんどの環境に入っていますし、 上ではGNU/BSDの違い等も気をつけていますが、 更にBash/Zshの機能だけで出来る様にしてみます。

while

まずはwhileを使って行でループを回して数える方法。

$ value="aaa"
$ n=0
$ while read l;do ((n++));done < <(printf "$value")
$ echo $n
0
$ value="aaa\n"
$ n=0
$ while read l;do ((n++));done < <(printf "$value")
$ echo $n
1

という感じでwhileでループで行を回して単にnをインクリメントしてるだけです。

見て分かるようにwhileは改行までを一文と読んで、 もし最後の行に改行がない場合にはその行は無視されます。

また、空白に関してはきちんとカウントされます。

$ value="a\n\nb\nc"
$ while read l;do echo "$l";done < <(printf "$value")
a

b
$

なのでこの方法はwc -lと同じ結果になります。

また、ちょっと気をつけないといけないのが、値を渡す時に、 パイプで渡してしまうとパイプの後ろがサブプロセスになってしまうので そこでnをインクリメントしても意味が無い、ということ。

$ value="aaa\n"
$ n=0
$ printf "$value"|while read l;do ((n++));done
$ echo $n
0

な感じでnはパイプ行の後では元に戻っています。

一方、whiledoneの後からファイルの内容を<で 与える形だと現在のプロセス上で実行されるので nの変更が外でも保たれます。

変数に入ってる値を見たい場合には上の様に <(command)という形式の Process substitution を使って擬似ファイル化してそれをwhileに渡す事で上の様に 出来ます。

逆に、ファイルから入力する時に

$ cat file.txt | while read l;do ...

としてしまうと 勿論サブプロセス化するので中での変更は外に出てきません。

また、BashやZshには Here String という機能があり、 <<<を使って文字列をコマンドに後ろから標準入力として渡すことが出来ます。 これを使うと、

$ value="a"
$ n=0
$ while read l;do echo "$l";((n++));done <<< "$(printf "$value")"
a
$ echo $n
1
$ value="a\n\nb\nc\n\n\n"
$ n=0
$ while read l;do echo "$l";((n++));done <<< "$(printf "$value")"
a

b
c
$ echo $n
4

と言った感じに出来ます。

この場合は最後以降の改行は全て無視されます。 ただし、改行が無くても最後の行はカウントされます。

また、\nとかを渡すにあたって直接<<<に渡してしまうと

$ while read l;do echo "$l";((n++));done <<< "$value"
anbncnn

みたいになってしまうので、printfで改行表示した上で それをクォートして渡す必要があります。

なので、こういった場合にはProcess substitution を使ったほうがよっぽど完結。

\nでなく直接改行が入ってる場合には

$ value="a
b
c

"
$ while read l;do echo "$l";((n++));done <<< "$a"
a
b
c

$

な感じで直接渡すことが出来、かつ直接書いた改行は 最後のところでもカウントされます。

配列化

()を使った普通の配列化

改行を区切りにして配列に入れて、配列内の値の個数を調べる事も出来ます。

ただし、この方法はちょっと気をつけないと途中の改行だけ(空白)の行を 無視してしまうこともあるので注意。

まず、 通常配列を作るにあたってarray=($value)みたいにすると 空白や改行を区切りに配列を作りますが、 これを行ごとにしたければ 区切り文字設定のIFSというシェル変数を改行の$'\n'に変更します。

$ # Normal Array
$ value="a b\nc\n\nd"
$ array=($(printf "$value"))
$ echo ${#array[@]}
4
$ for i in $(seq 0 3);do echo "array[$i]=${array[i]}";done
array[0]=a
array[1]=b
array[2]=c
array[3]=d
$ # Array divided by newline
$ orig_ifs=$IFS
$ IFS=$'\n'
$ array=($(printf "$value"))
$ echo ${#array[@]}
3
$ for i in $(seq 0 3);do echo "array[$i]=${array[i]}";done
array[0]=a b
array[1]=c
array[2]=d
array[3]=
$ IFS=$orig_ifs

こんな感じでIFS=$'\n'をした後には()内で展開されたものを 改行で区切って入れてくれます。

orig_ifsに関して、IFSを変更してそのままにしておくと困るので きちんと変更前の値を取っておいて戻す必要があります。

また、この場合は改行を区切りに使い、 さらに全体の前後や重複した改行は無視されるので、 まずは上の様に最後に改行が無くても最後の行も数えられます。

更に、最後に改行を入れても、この場合は幾つ入れても結果はおなじになります。

$ array=($(printf "aaa")); echo ${#array[@]}
1
$ array=($(printf "aaa\n")); echo ${#array[@]}
1
$ array=($(printf "aaa\n\n")); echo ${#array[@]}
1

また、途中に複数の改行(空白行)がある場合は

$ array=($(printf "a\n\nb")); echo ${#array[@]}
2
$ for i in $(seq 0 2);do echo "array[$i]=${array[i]}";done
array[0]=a
array[1]=b
array[2]=

な感じで途中の空白行は飛ばされます。 (区切り文字がいくつ続いても一つの区切りとして捉えられるため。)

ここではprintf "%b" "a\n\nb"みたいに%bフォーマットを使っても同じ結果になります。

なのでこれをwc -lとかと同じようには使えません。 空白以外の行数を数える事になります。

Bash: readarray

一方、Bashにはreadarrayというコマンドがあって、 これを使うと

$ value="a\n\nb"
$ readarray array < <(printf "$value")
$ echo ${#array[@]}
3
$ for i in $(seq 0 2);do echo "array[$i]=${array[i]}";done
array[0]=a

array[1]=b
array[2]=
$ value="a\n\nb\n\n\n"
$ readarray array < <(printf "$value")
$ echo ${#array[@]}
5
$ for i in $(seq 0 2);do echo "array[$i]=${array[i]}";done
array[0]=a

array[1]=

array[2]=b

$

こんな感じで標準入力の値を入れることが出来ます。

readarrayは標準入力を行毎に分けて配列に詰めてくれるのですが、 ここでもパイプを使うと

$ value="a\nb"
$ printf "$value"| readarray array
$ echo "${array[@]}"

$

という感じに、readarrayの部分がパイプ後のサブプロセスに入ってしまうので 配列に詰めてもその変数が外では変更されません。

また、配列に詰める時に、改行で区切った上で、改行も 各要素に詰めます。

なので最初のaの後には空白行があります(echoで一つ改行が加えられるので)。

-tオプションを使うと各行の最後の改行を消した状態で配列に詰めてくれるので、 配列に詰めた後に要素を使いたい場合は普通は

$ readarray -t array < <(printf "$value")

の様にして使う事が多いと思います。(今回は数を数えるだけなので特に必要ない。)

最後の行に関しては改行があっても無くても1行には数えられます。

なので、

$ readarray array < <(printf "aaa");echo ${#array[@]}
1
$ readarray array < <(printf "aaa\n");echo ${#array[@]}
1
$ readarray array < <(printf "aaa\n\n");echo ${#array[@]}
2
$

のように、こちらはgrep -c ""と同じ結果になります。

Zsh: $f

ZshとBashで少し配列に関する仕様が違いますが、 まず、Zshはデフォルトでは要素は[1]から入っていきます。

$ array=(a b c)
$ for i in $(seq 0 4);do echo "array[$i]=${array[i]}";done
array[0]=
array[1]=a
array[2]=b
array[3]=c
array[4]=

これに関しては

setopt ksharrays

ksharraysを設定する事で[0]から詰める様に変更することも出来ます。

また、変数を配列に入れようとすると、 Bashだと

$ value="a b c"
$ array=($value)
$ for i in $(seq 0 3);do echo "array[$i]=${array[i]}";done
array[0]=a
array[1]=b
array[2]=c
array[3]=
$

こんな感じでクォートしないで渡すと変数内の区切りも認識しますが、 Zshだと、

$ value="a b c"
$ array=($value)
$ for i in $(seq 0 3);do echo "array[$i]=${array[i]}";done
array[0]=
array[1]=a b c
array[2]=
array[3]=
$

な感じで、変数はひとかたまりとして認識されます。

ただ、printf等で展開する場合には中の区切りも見られるので、

$ value="a b c"
$ array=($(printf "$value"))
$ for i in $(seq 0 3);do echo "array[$i]=${array[i]}";done
array[0]=
array[1]=a
array[2]=b
array[3]=c
$

と出来ます。

ですが、Zshでもやはり途中に空白行がある場合(改行コードが続く場合)は 無視されるのでBash同様普通に配列に詰めてると空白行以外をカウントすることになります。

一方、Zshではreadarrayというコマンドはありませんが、 $()を使ってコマンド結果を使う時に、 さらに${}で囲って 前に()を付けて、

${(X)$(command)}

とすることでXによって色々と展開方法を変更することが出来ます 2

Xの部分には複数の表現を入れることが出来て、 fを入れると以降の出力をnewlineで分ける、 @を入れると"で全体を囲った時に、出力の分けられた通りに出力する、 という形になります。

@の説明が良くわからない感じですが、 BashやZshの配列をfor文で回そうとする時に使う@と一緒の意味です。

$ array=(a b c)
$ for v in "${array[@]}";do echo "$v";done
a
b
c
$

となるように、"で囲った部分を一つの固まり、ではなく、 中で区切られてる場合にはその区切りごとに一つとして扱う、ということ。

つまりZshでは"${array[@]}""${(@)array}"は全く同じ意味になります。

$ for v in "${(@)array}";do echo "$v";done
a
b
c
$

一方、fは区切りをIFSや配列内区切りも無視して新たに 改行を区切りとして出力します。

結果的に$(command)で出力されたものを改行で区切り要素に分け、 それを配列に分けた状態で要素を一つ一つ渡す、と言う形になります。

これを使うと途中の空白行とかもきちんと認識してくれます。

$ value="a b\n\nc\nd\n\n\n"
$ array=("${(@f)$(printf "$value")}")
$ for i in $(seq 0 5);do echo "array[$i]=${array[i]}";done
array[0]=
array[1]=a b
array[2]=
array[3]=c
array[4]=d
array[5]=
$ echo ${#array[@]}
4

ただし、この様に末尾の改行は全て無視されます。 (上では、配列の数が4で、a b<blank>cdの4行になってる)

なのでこの結果はwhile+<<<と同じ結果になります。

まとめ

  • grep -c ''
    • 末尾の改行を考慮しない。
    • Bashでreadarrayを使った方法も同じ様に出来る。
  • wc -l
    • 最終行に改行が無いと最終行が無視される。
    • whileを使った方法で同じ事が出来る。

これまで行を数えるのに大体wc -lを使ってきましたが、 直感(少なくとも自分の)と合うのはgrep -c ''なので こっちに切り替えて行こうかな、と言う感じです。

勿論、意図的な部分もあったり、意図してなくてもwc -lの方の仕様に 依っていたりすることもあるので単に書き換えるのは良くないですが。

Sponsored Links
  1. echo-nを入れない限り改行を最後に入れるので注意。

    $ echo "aaa"| wc -l
    1
    

  2. zsh: 14. Expansion

Sponsored Links

« Macのアイコン作成アプリのImg2icnsが名前が変わってImage2iconになってた Brew-fileにシェルスクリプト形式でBrewfileを出力出来るオプションを加えた »

}