Dec 15, 2016

シェルスクリプトの中でjoin()とsplit()相当の事をやる

このエントリは以下のエントリのフォローアップです。

(また、Qiitaにもクロスポストしています。)

結論を先に書くと、シェルスクリプトの中で普通のプログラミング言語で文字列を区切り文字で分割して配列にする操作、いわゆるsplit()相当の事はtr '区切り文字' '\n'でできます。その逆の、配列を結合して1つの文字列にする操作、いわゆるjoin()相当の事はpaste -s -d '区切り文字' -と覚えておくのが筆者的にはオススメです

(ちなみに、GNU coreutilsのコマンドでjoinという物がありますが、これは配列のjoin()ではなく、SQLで言うところのINNER JOINとかOUTER JOINとかの方の文脈の「join」に対応する物です。この記事の話とは関係ないので、忘れて下さい。)

以下、どういう場合にそれが言えるのかという事と、その理由の解説です。

シェルスクリプトではデータは行単位で扱うのが都合が良い

前の記事で自分は、シェルスクリプトやシェル関数ではデータは引数ではなく標準入出力で扱い、配列ではなく改行区切りのデータに対するwhile readのループで扱うのがベストプラクティスだ、と書きました。この前提の元では、データは基本的に行単位で加工する事になります。

(自分は「初学者向けの覚えやすさ」と「メンテナンスのための読みやすさ」を重視してこのように考えていますが、処理効率の面ではベストとは言い切れません。実際、覚えやすさと読みやすさを犠牲にする必要はありますが、速度面ではwhile readよりもxargsコマンドを使った方が高速だったりします。)

実際、そのようなデータに対しては様々なコマンドが有効に利用できます。以下はその一例です。

  • grepコマンドで条件に当てはまる項目を抽出する、または除外する。
  • sedコマンドで行頭・行末の指定を含む正規表現での操作を各項目に行う。
  • cutコマンドで各項目を切り分けて一部を取り出す。

正規表現やawk等を駆使すればこういった操作も1行のままで行えるかもしれませんが、熟練が必要ですし、そのようなシェルスクリプトは達人でない一般のシェルスクリプト利用者には理解しにくいでしょう。

1行のデータを複数行に分割する方法

ただ、そうすると必然的に、処理対象にしたい一続きのデータを、行単位に分割したいというニーズが出てきます。これは、一般的なプログラミング言語で1つの文字列を特定の区切り文字で分割して配列にする、いわゆるsplit()の操作に相当します。

split()に相当する操作は、tr '区切り文字' '\n'というコマンド列があります。これは入力中に表れた区切り文字を\n、つまり改行文字に置き換えるという物です。

入力をカンマで区切る例:

$ echo 'a=1,b=2,c=3' | tr ',' '\n'
a=1
b=2
c=3
$ █

非常に簡単ですね!

複数行のデータを1行に結合する方法

逆操作のはずが逆操作にならない落とし穴

ここからがこの記事のメインです。

最初から改行区切りで与えられたデータや、split()相当のtrで改行区切りに展開されたデータについて、加工後に最終的に1行に結合したい、というニーズもあります。これは、一般的なプログラミング言語で1つの配列を特定の区切り文字で結合して1つの文字列にする、いわゆるjoin()の操作に相当します。

trで区切り文字を改行文字に置換したときの逆で、改行文字を任意の区切り文字に置換すればjoin()相当のことができるのではないか? ……そう考えたあなた、惜しいです。確かにそれでうまくいくように思えますが、実はそれでは微妙に期待通りの結果にはならない場合があります。

具体的には、この方法では入力データの最後にある改行文字までが置換されてしまいます。例えばこんな感じです。

先の例の結果をカンマ区切りでもう1度結合する例:

$ echo 'a=1,b=2,c=3' | tr ',' '\n' | tr '\n' ','
a=1,b=2,c=3,

c=3の後に余計な,が付いているのがわかります。前の例の逆操作なのになぜ元に戻らないのでしょうか。それは、前の例が実際には何をしていたのかをよく見ると分かります。

echo 'a=1,b=2,c=3'というコマンド列はechoに指定した内容を出力しますが、実はこの時echoコマンドの仕様として最後に必ず改行文字が付与されます。改行文字が改行と分かりやすいようにで示すと、この時trに渡されるのはa=1,b=2,c=3ではなく、最後に改行文字が加わったa=1,b=2,c=3⏎になっています。その上で,を改行文字に置換するので、最終的な結果はa=1⏎b=2⏎c=3⏎になります。これがコンソールに出力されて、

a=1⏎
b=2⏎
c=3⏎

と3行表示されていたのでした。

今の例はここでもう1度改行文字を,に置換する操作なので、echoが付け足した最後の1つの改行文字までもが置換されるために、a=1⏎b=2⏎c=3⏎からa=1,b=2,c=3,になったというわけです。

3要素の配列をjoin(',')で結合した場合には、こうはなりません。これは、trがあくまで文字置換のためのコマンドで、「行を結合する」ための物ではないから起こる現象です。

最後の改行が失われる

別の例として、区切り文字無しでの結合(join('')に相当)の場合も見てみましょう。これをtrでやる場合、指定文字を削除する-dオプションを使ってtr -d '\n'と書くことができるのですが、

入力をtrで区切り文字なしで結合する例:

$ cat input.txt
aaa
bbb
ccc
$ cat input.txt | tr -d '\n'
aaabbbccc$ █

この時aaabbbcccの直後で改行されずにその次のプロンプト($)が表示されることに注目してください(実は、省略していましたが1つ前の例の場合もそうなります)。知らない人もいると思います(筆者も当初はそうでした)が、プロンプトは常に新しい行に表示されるとは限りません。この時のテキストファイルの内容は実際には

input.txt(改行を「⏎」で示した物):

aaa⏎
bbb⏎
ccc⏎

となっています。最初のcatの実行後にプロンプトが次の行に表示されていたのは、catやBashがそうしているのではなく、単にcatが出力したデータの最後が改行だったから、そう表示されただけなのです。

最後の改行がなくなるくらいはいいんじゃないの?と思うかもしれませんが、複数行の入力を扱う場面では気をつけないといけません。例えば前の記事に出てきた「入力の各行をURLエンコードするシェル関数」では、入力を行ごとに区切った上で各行を加工していましたが、その過程で「1行が複数行になり、それをまた1行に戻す」という操作がありました。

複数行の入力の各行をURLエンコードして出力する関数の実装例(tr版):

url_encode() {
  while read -r line # 入力を1行ずつ取り出す
  do
    echo "$line" |
      nkf -W8MQ | # ←ここで複数行に分割される
      sed 's/=$//' |
      tr '=' '%' |
      tr -d '\n' | # ←ここで1行に戻す
      sed -e 's/%7E/~/g' \
          -e 's/%5F/_/g' \
          -e 's/%2D/-/g' \
          -e 's/%2E/./g'
  done
}

これを実行すると、

複数行の入力に対する実行例:

$ cat data.txt
おはよう
こんにちは
こんばんは
おやすみ
$ cat data.txt | url_encode
%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF%E3%81%93%E3%82%93%E3%81%B0%E3%82%93%E3%81%AF%E3%81%8A%E3%82%84%E3%81%99%E3%81%BF

このように各行の変換結果が1つに繋がってしまいます。nkfによって1行が複数行に分割された物を元の1行に結合するとき、最後の改行まで削除してしまったために、各行を区切る改行文字が無くなってしまったからです。

これを防ぐには、各行の加工結果を出力した後で以下のように明示的に改行文字を付け足さないといけません。

各行の区切りを明示的に出力するようにした例:

url_encode() {
  while read -r line
  do
    echo "$line" |
      nkf -W8MQ |
      sed 's/=$//' |
      tr '=' '%' |
      tr -d '\n' | # 
      sed -e 's/%7E/~/g' \
          -e 's/%5F/_/g' \
          -e 's/%2D/-/g' \
          -e 's/%2E/./g'
    printf '\n' # 行区切りの改行文字だけを出力
  done
}

これなら、ちゃんと結果が各行に区切られて出力されます。

実行例:

$ cat data.txt | url_encode
%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86
%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF
%E3%81%93%E3%82%93%E3%81%B0%E3%82%93%E3%81%AF
%E3%81%8A%E3%82%84%E3%81%99%E3%81%BF

論理的な意味で「複数行を1行に結合する」操作

上記のように「trで削除される改行文字を改めて出力して補う」方法もありますが、別解として、論理的な意味できちんと「複数行を1行にする」という方法があります。それを行うのがpasteコマンドです。

pasteコマンドは複数の改行区切りのファイルを入力として受け付けて、それらを横方向に結合するコマンドです。具体的にはこんな風に使います。

pasteでファイル同士を横に繋げる例:

$ cat ids.txt
1
2
3
4
$ cat data.txt
おはよう
こんにちは
こんばんは
おやすみ
$ paste -d ',' ids.txt data.txt
1,おはよう
2,こんにちは
3,こんばんは
4,おやすみ

ids.txtの内容を1列目、data.txtの内容を2列目として、区切り文字を指定するオプションの-d ','での指定に従い、各列をカンマ区切りで示したデータ(CSV)が得られました。

pasteコマンドのもう1つの機能は、行と列を入れ換えるというものです。-sオプションを指定すると、行として与えられた物が列になります。

pasteで行と列を入れ換える例:

$ paste -s -d ',' ids.txt data.txt
1,2,3,4
おはよう,こんにちは,こんばんは,おやすみ

カンのいい方は気付いたかもしれませんが、入力が1ファイルだけだったり標準入力から与えられた内容だったりする場合、2つ目の操作は改行区切りのデータを任意の区切り文字で1行に結合する操作になります。

pasteに様々な区切り文字を指定してみる(最後の例は既定の区切り文字=タブ文字):

$ paste -s -d ',' ids.txt
1,2,3,4
$ paste -s -d '|' ids.txt
1|2|3|4
$ paste -s ids.txt
1   2   3   4

こちらの方法であれば、最後に余計な区切り文字が付く事も最後の改行が失われる事もありません(正確には、ここでの最後の改行はechoコマンドがやるのと同様、pasteコマンドが自動的に付与した物ですが)。

これを使って先程の「与えた文字列をURLエンコードする関数」を書き直したのが、以下のコードです。実際、tweet.shではこの書き方を採用しています。

複数行の入力の各行をURLエンコードして出力する関数の実装例(paste版):

url_encode() {
  while read -r line # 入力を1行ずつ取り出す
  do
    echo "$line" |
      nkf -W8MQ | # ←ここで複数行に分割される
      sed 's/=$//' |
      tr '=' '%' |
      paste -s -d '\0' - | # ←ここで1行に戻す
      sed -e 's/%7E/~/g' \
          -e 's/%5F/_/g' \
          -e 's/%2D/-/g' \
          -e 's/%2E/./g'
  done
}

paste -s -d ''ではなくpaste -s -d '\0' -と書いていることに注意して下さい。UbuntuやFreeBSDではpaste -s -d ''(区切り文字を空文字と指定し、入力ファイルの指定を省略)という書き方でも問題無く動くのですが、筆者が確認した限りではこの書き方はmacOSではエラーになってしまいました。これは、同じpasteという名前のコマンドでも、各ディストリビューションによって採用している実装が異なるためです。paste -s -d '\0' -(区切り文字を\0=null文字=文字が何もないことを指す文字と指定し、入力ファイルを-=標準入力と明示)という指定の仕方であれば、この3プラットフォームすべてで同じ結果を得られました。

もちろん、別の区切り文字も使えます。

標準入力で渡された内容をpasteで1行に結合する例:

$ cat ids.txt | paste -s -d ',' -
1,2,3,4
$ cat ids.txt | paste -s -d '|' -
1|2|3|4

行の結合、どっちの方法が良い?

好みの分かれる所だとは思いますが、筆者は以下のような理由からpaste -s -d '\0' -を使う方法の方が好みです。

  • 「改行文字を削除(置換)して、結果的に結果が1行になる」のと「行と列を入れ換えて、指定の区切り文字で結合する」では、後者の方が論理的に意味が通るので誤解の余地がない。
  • trを使う場合、trを実行したタイミングより後のどこかで改行文字を付け直さないといけないが、この両者が別々になる事が好きではない。
    • ワンセットで必要な事をやっている箇所同士が不必要に離れていると、コードの可読性が落ちる。
    • trを使っている行だけを何らかの理由でコメントアウトや削除すると、各行に余計な改行文字が追加されるようになってしまう。消すなら両方をワンセットで扱わないといけないのだが、消すべきコマンド同士が分散していると見落とす。

しかし自分が気付いてないだけで、pasteを使う方法にもデメリットはありそうな気がします。実際、-d '\0'とnull文字を区切り文字に指定したり-を入力ファイルに指定したりしないとmacOSのpasteでは期待通りの結果にならないという事は、指摘を受けるまで気付いていませんでした。

皆さんのご意見はどうでしょうか。……と、オープンクエスチョンにしたままで投げ出してこの記事は終わりとしたいと思います。

エントリを編集します。

wikieditish message: Ready to edit this entry.











拡張機能