シェルスクリプトで文字列の分割 (split) と結合 (join) をする時のベストプラクティス


はじめに

CSV のようにカンマ区切りの文字列をそれぞれのフィールドに分割するにはどうするか?というネタです。よく見るネタなのですが、良いとは言えないコードが多いのでまとめたいと思います。

なお例として CSV を扱っていますが、主題は文字列の分割 (split) と結合 (join) なので、ダブルクォートでくくることでカンマや改行も入れられるなどという本気の CSV 対応はしません。仕様が複雑なのでそのようなファイルを扱う場合は別の言語のよく知られたライブラリを使うことをおすすめします。この記事で扱う CSV とは各フィールドがカンマで区切られているというだけのものです。

前提 一行のデータは分割して位置パラメーターに入れる

一行の文字列を分割した結果は、位置パラメータ ($1, $2, $3, ...) に代入するのがおすすめです。位置パラメータは POSIX 準拠(つまり bash 依存なし)で唯一の配列(のように使えるもの)です。この記事では原則として一行を分割して位置パラメーターに入れていますが、bash 等であれば普通に配列が使えますし位置パラメーターに入れずに変数に入れたりして処理出来る場合もあると思います。状況に応じて変更してください。

位置パラメータに代入するには set コマンドを使用します。例えば set -- a b c と実行すると \$1: a, \$2: b, \$3: c になります。-- は引数 a-a だったときに set のオプションとして解釈されないようにするためのものです。この例では不要ですが明示的に書いています。

位置パラメータに代入するため元の位置パラメータは消えてしまいますが、関数ごとにローカル化されてますので関数の中で処理を行えば元の位置パラメータを残しておくのは簡単です。POSIX 準拠の範囲では配列変数(位置パラメーター)は一つしかありませんが、この方法を使うことで複数の配列を使うことができます。

# つまりこういうことです
set -- a b c
foo() {
  set -- 1 2 3 # 位置パラメータが変更されてしまう
}
foo
echo "$@" # => でもここでは a b c のまま残っている

また set で位置パラメーターに代入する代わりにユーザー定義のシェル関数を呼びだせば関数内で引数(位置パラメーター)として取得することできます。

IFS を使う方法

一番簡単に分割を行う方法は IFS を使う方法です。IFS に区切り記号を入れ set コマンドを実行すると各フィールド毎に分割することができます。

# zshの場合は setopt shwordsplit を実行しておく必要がある
set -f # これがないと * などがパス名展開されてしまう
while IFS= read -r line; do
  IFS=,
  set -- $line
done

フィールドの最大数が確定している場合は read しながらフィールド毎に変数に入れていくこともできます。ファイルや標準入力から読み取る場合はこの方法が一番速いでしょう。

while IFS=, read -r f1 f2 f3; do
  echo "f1: $f1, f2: $f2, f3: $f3"
done

逆に結合する場合は $* を使用します。区切り文字は IFS の最初の1文字が使用されます。

set -- a b c d e # $1~$5にそれぞれ代入
IFS=,
line=$*

# 余談ですが結合に使われるのは IFS の最初の 1 文字だけなので、IFS を一時的に変更して
# 元に戻したい場合は、このようにすれば元の値を保存するための変数は必要ありません
IFS=",$IFS"
line=$*
IFS=${IFS#?} # 最初の一文字を削除

問題点

この方法はいくつか問題点があります。IFS の場合、区切り文字は 1 文字に限られます。多くの場合区切り文字は 1 文字だと思いますが、区切り文字を複数文字にしたい場合には使えません。

読み取るデータが TSV (タブ区切り) の場合、さらに注意が必要です。カンマ区切りの場合は空のフィールドを扱うことができますが タブ区切りの場合は無かった事にされてしまいます。これは IFS による分割では、複数の連続する空白やタブは一つとして扱われるためです。また行の前後にある空白やタブも無視されます。(参考 2.6.5 Field Splitting

line="a,,c"
IFS=,
set -- $line # $1:a, $2:, $3:c

TAB=$(printf '\t')
line="a${TAB}${TAB}c"
IFS=$TAB
set -- $line # $1:a, $2:c

結合する場合は分割と違って区切り文字が空白やタブでもカンマと同じように動作します。

IFSを使用した分割と結合は手軽な方法ですが場合によっては使えない事もあるので注意が必要です。

sed + eval で分割する

次に紹介するのが sedeval を使う方法です。分割した後は同じように位置パラメータに代入します。この方法では区切り文字を 2 文字以上にすることもできますし TSV を扱うこともできます。

# カンマ区切り
sed "s/'/'\\\\''/g; s/^/'/g; s/$/'/g; s/,/' '/g" | while IFS= read -r line; do
  eval "set -- $line"
done

# 行の前後のシングルクォートはシェルスクリプト側でつけると短く出来る
sed "s/'/'\\\\''/g; s/,/' '/g" | while IFS= read -r line; do
  eval "set -- '$line'"
done

# タブ区切り
TAB=$(printf '\t')
sed "s/'/'\\\\''/g; s/$TAB/' '/g" | while IFS= read -r line; do
  eval "set -- '$line'"
done

この seda,b,c という文字列を各フィールド毎にシングルクォートで括って 'a' 'b' 'c' という文字列に変換しています。この文字列を set -- とくっつけて eval することで位置パラメータに代入されます。またフィールドの中にシングルクォートが入っている場合に備えてエスケープ処理も行っています。例えば that's right であれば 'that'\''s right' となります。

シングルクォートで括る処理(つまり sed の実行)は、CSV を一行読み取る毎に実行しているのではなく、先に全体を sed で処理してから実行した結果を読み取ってることに注意してください。こちらの記事では while ループの中で一行ずつ外部コマンドを呼び出して分割しているため速度の点からおすすめできません。ループの中で外部コマンドの呼び出しを行ってしまうと数百倍といったレベルで遅くなります。シェルスクリプトを遅くしないためには回数の多いループの中で外部コマンドを呼び出すのは厳禁です。

注意点

この方法の注意点はパイプを使用しているため whiledone の部分がサブシェルとなるため内部で使用した変数を参照できないというところです。例えば一行ずつ処理をしつつ、処理した行数を数えたりする場合に困ります。対処方法はいくつかありますが代表的な例を紹介します。

#!/bin/bash
# bash等用
count=0
while IFS= read -r line; do
  eval "set -- '$line'"
  count=$((count + 1))
done < <(sed "s/'/'\\\\''/g; s/,/' '/g; ")
echo "$count"
#!/bin/sh
# POSIX 準拠
sed "s/'/'\\\\''/g; s/,/' '/g; " | {
  count=0
  while IFS= read -r line; do
    eval "set -- '$line'"
    count=$((count + 1))
  done
  echo "$count"
}
# ※注意 ここで count は参照できません

# または
read_csv() {
  count=0
  while IFS= read -r line; do
    eval "set -- '$line'"
    count=$((count + 1))
  done
  echo "$count"
}
sed "s/'/'\\\\''/g; s/,/' '/g; " | read_csv
# ※注意 ここで count は参照できません

シェルスクリプトのみで分割する

個人的に紹介したいのがこの方法です。ここまで書いてきた方法はファイルや標準入力から複数行のデータを読み取る場合に適しています。ある程度の大きさのファイルを処理するのであれば sed+eval で十分なのですが、例えば変数に入っている値を分割したいなどの場合では sed を呼び出すコストが相対的に大きくなってしまいます。そういう場合にシェルスクリプトのみで変数に入っている文字列を分割する方法です。

var="a,b,c"

set --
work="$var,"
while [ "$work" ]; do
  field=${work%%,*} # 文字列の最後から見て一番遠いカンマまで削除 = 最初のフィールドを取得
  work=${work#*,} # 文字列の最初から見て一番近いカンマまで削除 = 最初のフィールドを削除
  set -- "$@" "$field"
done

今までの例と同様に位置パラメーター に代入しています。上記の書き方は個人的に行数が多いと感じるのでつなげて書くことが多いです。

var="a,b,c"

set --
work="$var,"
while [ "$work" ] && field=${work%%,*} && work=${work#*,}; do
  set -- "$@" "$field" 
done

変数展開(${work%%,*}${work#*,})は慣れないと読みづらいかもしれませんが(外部)コマンド実行を行わずシェルによって実行されるため実行速度が速いので、シェルスクリプトを遅くしないためには必須の知識です。

変数展開を用いると複雑な文字列(例えば日付の ISO 8601 表記 2020-08-23T12:34:56+09:00 のようなもの)のパースを行うこともできます。 sedと正規表現を使って一発で複雑な文字列編集ができればかっこいいですが、変数展開で地道に文字列を分割して加工した方がわかりやすく速いこともあります。シェルスクリプトだからといってデータを標準入出力で渡して(外部)コマンドで処理させるというのは必ずしも正しいとは限りません。

区切り文字を変数に入れる場合はこのようにします。

var="a,b,c"
sep="$TAB"

set --
work="$var$sep"
while [ "$work" ] && field=${work%%"$sep"*} && work=${work#*"$sep"}; do
  set -- "$@" "$field" 
done

$sep を囲っているダブルクォートは必要です。これがないと区切り文字によっては正しく動作しないことがあります。

IFS を使う方法に対してのメリットは区切り文字が複数であっても対応できるのと、区切り文字が空白やタブでもカンマと同様の形で処理できるというところです。パス名展開抑制のための set -f も不要で zsh の場合 setopt shwordsplit も必要ありません。複数行を処理する場合でもサブシェルが必要ないので変数にアクセスしやすくなります。

この方法のデメリットは区切り文字が多く文字列長が長くなると多数の大きな文字列のコピー処理によって、数KB~数十KB を超えたあたりから体感レベルで遅くなっていくことです。しかし外部コマンドを呼び出さないので短い文字列であれば十分速く処理することができます。

別解としては sed を使った方法と同様に eval できる文字列に変換する方法もあります。ループで分割せず文字列のコピー回数も少ないためこちらの方が速くて簡単ですが変数展開による文字列の置換は POSIX 準拠ではないので使えるのは bash 等に制限されます。

var="a,b,c"
work=${var//"'"/"'\''"}
eval "set -- '${work//,/"' '"}'"

シェルスクリプトのみで結合する

区切り文字が1文字の場合は IFS を使うのが簡単で速いです。区切り記号が 1 文字でない場合はループを使って結合する必要があります。1 行ごとに(外部)コマンドを実行して結合する方法は遅くなるので避けた方が良いでしょう。細かい書き方はいくつもあると思いますが一例です。

unset var
if [ $# -gt 0 ]; then
  var=$1
  shift
  for i in "$@"; do
    var="${var},${i}"
  done
fi

# 別解
unset var
for i in "$@"; do
  var="${var:-}${var+,}${i}"
done

変数に入れるのではなく標準出力に出力する場合は printf で結合することもできます。ただし mksh や posh では printf はシェルビルトインではないため遅くなります。

if [ $# -gt 0 ]; then
  printf '%s' "$1"
  shift
  printf ',%s' "$@"
  echo # 最後の改行
fi