4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

シェルスクリプトで文字列を置換するreplace_all関数を作りました(実はコーディングスタイルの解説)

Last updated at Posted at 2020-04-13

はじめに

タイトルのとおりですがシェルスクリプトで文字列を置換する replace_all 関数を作りました。一応テストはしているのですがまだ実戦投入はしていません。もしかしたら仕様変更するかもしれないしバグもあるかもしれませんありました、後日修正しますが、関数だけでも十分利用価値がある(例えば HTML エスケープなどもこの関数を使えばシェル依存することなく簡単に実装することができるはずです。)のと、これを題材に私のコーディングスタイルの解説をするのにちょうど良さそうだと思ったので記事として公開します。

ちなみに実は同等の関数は以前に作成しているのですが、今とコーディングスタイルが異なってるのと当時はいきあたりばったりで実装していたので再実装しています。近いうちに今使っている実装と置き換える予定ですがまだ十分テストが出来ていません。(Linux以外のOSでクリティカルな問題が発生しないことを祈っています・・・)

前提

コードの詳細な解説の前に私の(独特な)コードはなぜそうしているのかの方針を説明しておきます。まず実装したのは純粋な"シェル関数"です。外部コマンドの呼び出しはしていません。ビルトインコマンドのみを使用しています。なぜ外部コマンドを使用していないかと言うと遅いからです。例えば同じ echo コマンドでもビルトインコマンドと外部コマンドの実行時間はこれぐらいの差があります。(CPU 3.4 Ghz、SSD 搭載の Linux 物理マシン上で計測。およそ 131 倍。1 秒で終わる処理が 2 分以上かかる計算です。)

# ビルトインの echo コマンドを実行
$ time sh -c 'i=0; while [ "$i" -lt 100000 ]; do echo a > /dev/null; i=$((i + 1)); done'
real    0m0.778s
user    0m0.424s
sys     0m0.349s

# 外部コマンドの echo コマンドを実行
$ time sh -c 'i=0; while [ "$i" -lt 100000 ]; do /bin/echo a > /dev/null; i=$((i + 1)); done'
real    1m42.183s
user    1m16.502s
sys     0m28.248s

もちろん外部コマンドの使用を一切を禁止しているわけではなく大量の文字列をフィルタの形で置換する場合は sed コマンドや tr コマンドを使ったほうが速いです。ですが(例えばループの中などで)サイズの小さい文字列を何度も変換したい場合など、関数として使いたい場合は外部コマンド呼び出しのコストが大きくなります。つまり外部コマンドの代替ではなく用途の違いによって使い分けるためのものです。

また外部コマンドは末尾の複数の改行が消えてしまうという問題があります。例えば ret=$(printf 'hello\n\n\n\n\n') のようにいくら改行を追加したところで ret に入る文字は hello のみです。これを外部コマンドを使って文字するなら、このようにするしかありません。不可能ではありませんが面倒ですよね?

ret=$(printf 'hello\n\n\n\n\n'; echo _) # 改行が保持されるように _ を追加
ret=${ret%_} # 最後に追加した余計な _ を削除

そのため コマンド置換(ret=$(func 1 2 3))の形を使用せず func ret 1 2 3 のように関数の第一引数に戻り値を返す変数名を指定して、その変数に代入しています。(標準の read 関数 の read line と同じ仕様です。)

bash などでは ret=${target//"from"/"to"} という書き方で文字列の置換(パターンの置換ではなく文字列です。)ができます。 対応しているシェルでは速度が速いので使っていますが、実装した replace_all 関数は dash などの POSIX 準拠シェル全てに対応しています。

POSIX 準拠シェルといっても実際には完全に準拠していない(バグ?)シェルもあります。POSIX 準拠してないという理由で切り捨てるのもありだとは思いますが、シェルスクリプトの移植性・可搬性を上げることを目的の一つとしているので、そういうバグにも可能な限り対応する方針です。

(一時的なものを除き)変数は使用していません。それは POSIX 準拠の範囲ではグローバル変数しかないからです。変数レスを実現するために位置パラメーターをローカル変数の代わりとして多用しています。(注意 変数の使用を完全に禁止しているわけではありません。変数を使わないようにしているのは関数、ライブラリの話です。呼び出し元では変数を使って構いませんし私も使っています。またパフォーマンスや実装が困難なために断念することもあります。その場合は変数がかぶらないようにプリフィックスをつけています。)

全般的にショートコーディングを目指しています。(短い名前にするとか無理やり ; でつなぐかそういう小手先の技のことではありません。)横 80 文字以内でなるべく行数が少なくなるような書き方をしています。

実装

解説の前に実装コードを提示します。(使いたい人はご自由にどうぞ)

最終的に replace_all 関数を定義しています。(補助的に replace_all_fast, replace_all_posix, replace_all_pattern, meta_escape 関数も定義しておりシェルに応じて適切な関数が自動的に使われます。)

使い方は replace_all [返り値を返す変数名] [置換対象の文字列] [置換する文字列] [置換後の文字列] です。(例 replace_all ret "Hello World" "Hello" "Good"

# $1: ret, $2: value, $3: from, $4: to
replace_all_fast() {
  eval "$1=\${2//\"\$3\"/\"\$4\"}"
}

# $1: ret, $2: value, $3: from, $4: to
replace_all_posix() {
  set -- "$1" "$2" "$3" "$4" ""
  until [ _"$2" = _"${2#*"$3"}" ] && eval "$1=\$5\$2"; do
    set -- "$1" "${2#*"$3"}" "$3" "$4" "$5${2%%"$3"*}$4"
  done
}

# $1: ret, $2: value, $3: from, $4: to
replace_all_pattern() {
  set -- "$1" "$2" "$3" "$4" ""
  until eval "[ _\"\$2\" = _\"\${2#*$3}\" ] && $1=\$5\$2"; do
    eval "set -- \"\$1\" \"\${2#*$3}\" \"\$3\" \"\$4\" \"\$5\${2%%$3*}\$4\""
  done
}

meta_escape() {
  # shellcheck disable=SC1003
  if [ "${1#*\?}" ]; then # posh <= 0.5.4
    set -- '\\\\:\\\\\\\\' '\\\[:[[]' '\\\?:[?]' '\\\*:[*]' '\\\$:[$]'
  elif [ "${2%%\\*}" ]; then # bosh = all (>= 20181007), busybox <= 1.22.0
    set -- '\\\\:\\\\\\\\' '\[:[[]' '\?:[?]' '\*:[*]' '\$:[$]'
  else # POSIX compliant
    set -- '\\:\\\\' '\[:[[]' '\?:[?]' '\*:[*]' '\$:[$]'
  fi

  set "$@" '\(:\\(' '\):\\)' '\|:\\|' '\":\\\"' '\`:\\\`' \
    '\{:\\{' '\}:\\}' "\\':\\\\'" '\&:\\&' '\=:\\=' '\>:\\>' "end"

  echo 'meta_escape() { set -- "$1" "$2" ""'
  until [ "$1" = "end" ] && shift && printf '%s\n' "$@"; do
    set -- "${1%:*}" "${1#*:}" "$@"
    set -- "$@" 'until [ _"$2" = _"${2#*'"$1"'}" ] && set -- "$1" "$3$2" ""; do'
    set -- "$@" '  set -- "$1" "${2#*'"$1"'}" "$3${2%%'"$1"'*}'"$2"'"'
    set -- "$@" 'done'
    shift 3
  done
  echo 'eval "$1=\"\$3\$2\""; }'
}
eval "$(meta_escape "a?" "\\")"

replace_all() {
  (eval 'v="*#*/" p="#*/"; [ "${v//"$p"/-}" = "*-" ]') 2>/dev/null && return 0
  [ "${1#"$2"}" = "a*b" ] && return 1 || return 2
}
eval 'replace_all "a*b" "a[*]" &&:' &&:
case $? in
  0) # Fast version (Not POSIX compliant)
    # ash(busybox)>=1.30.1, bash>=3.1.17, dash>=none, ksh>=93?, mksh>=54
    # yash>=2.30?, zsh>=3.1.9?, pdksh=none, posh=none, bosh=none
    replace_all() { replace_all_fast "$@"; }; ;;
  1) # POSIX version (POSIX compliant)
    # ash(busybox)>=1.1.3, bash>=2.05b, dash>=0.5.2, ksh>=93q, mksh>=40
    # yash>=2.30?, zsh>=3.1.9?, pdksh=none, posh=none, bosh=none
    replace_all() { replace_all_posix "$@"; }; ;;
  2) # Pattern version
    replace_all() {
      meta_escape "$1" "$3"
      eval "replace_all_pattern \"\$1\" \"\$2\" \"\${$1}\" \"\$4\""
    }
esac

解説

基本的に上の方から解説していきます。

replace_all_fast

パラメーター展開を使った置換です。POSIX 準拠ではありませんが、対応しているシェルではこれが一番速い実装です。eval と組み合わせているため一見分かりづらいかもしれませんが、以下のコード相当のことをしています。

ret=${2//"$3"/"$4"} # retは実際は $1 に入っている変数名

シェルの機能そのままなのであまり説明することはありませんが細かい説明をするなら

ret=${2//$3/"$4"} # $3 にダブルクォートをつけないとパターンとして扱われます。
ret="${2//"$3"/"$4"}" # 変数代入時のダブルクォートは無くて構いません。

replace_all_posix

POSIX 準拠しているシェルで使われます。replace_all_fast 関数で動くシェルであればこの関数でも動きます。

set -- "$1" "$2" "$3" "$4" ""

$5 を一時変数の代わりとして使用するために初期化しています。

until [ _"$2" = _"${2#*"$3"}" ] && eval "$1=\$5\$2"; do

変数につけている _! という文字列を ! オペレーターして扱ってしまうバグのワークアラウンドです。バグがあるシェルでは[ "!" = "foo" ][ ! "=" foo ] とみなしてしまい unknown operand のようなエラーメッセージが表示されます。このバグは(私が確認した限り)dash と busybox で発生します。dash は Debian 4.0 時代の 古い dash 0.5.3 あたりまでしか発生しませんが busybox は Debian 9 の 1.22.0 でも発生するので注意して下さい。(少なくとも 1.30.1 以降では修正されているようです。)

until の条件に続く && eval~ の部分はループを抜ける時に実行されます。(until が終わるのは条件が真になった時です。)また && に続く処理は変数代入など実行しても必ず真になるものしか使ってはいけません。偽になるとループ内を実行してしまいます。行を短く出来るので個人的には使いますが人によっては分かりづらいと思うので done の後に書いたほうがいいかもしれません。

set -- "$1" "${2#*"$3"}" "$3" "$4" "$5${2%%"$3"*}$4"

POSIX 版のキモです。$2 (対象の文字列)を($3で切断して)減らしつつ $5 に($4区切りで)移動しています。例えば ab,cd,ef,gh という文字列の ,@ に置き換える場合、ループを繰り返すたびに $2:cd,ef,gh $5:ab@$2:ef,gh $5:ab@cd@$2:gh $5:ab@cd@ef@ のように変化します。最後の一回(ループを抜ける時)は eval "$1=\$5\$2" によって $1 の変数名に残りが結合されて代入されます。

ついでに関連する内容として posh のバグ を紹介します。(グローバル変数を避けたプログラミングにとっては辛いバグです。)

set -- "abc" a && char=$2
echo ${1#"$char"} # => bc 期待したとおりに動きます
echo ${1#"$2"} # => 空文字 poshは両方が位置パラメーターだと正しく動作しません

replace_all_pattern

POSIX 準拠しているシェルは replace_all_posix 関数で動作しますが、前述の posh などバグのあるシェルもあります。新しいバージョンのシェルではほぼ問題はないのですが、posh や bosh は最新バージョンでもバグがあります。(マイナーなシェルですけどね。) replace_all_pattern 関数はバグがあるシェルだけではなく全てのシェルで動作します。(つまり最終手段は replace_all_pattern、POSIX 準拠していれば replace_all_posix、拡張機能があれば replace_all_fast を使うということです。)

POSIX 準拠していないシェルでは文字列による処理ではなくパターンを使って replace_all_posix 関数と同様のことを行っています。パターンを使いますが本当にやりたいことは固定の文字列による置換なのでメタ文字はエスケープしています。エスケープする処理は別で行っているので、この関数の違いはそれぐらいです。

eval "set -- \"\$1\" \"\${2#*$3}\" \"\$3\" \"\$4\" \"\$5\${2%%$3*}\$4\""

パターンを使った置換版のキモです。一見 eval なくてもできるんじゃ?と思うかもしれませんが、それだとうまくコードを共通化出来ませんでした。(おそらくシェルのバグです。)エスケープ処理の内容を見直して eval を無くしたほうが速いのでしょうが、このコードが使われる場合は少ないのでそれよりもコードの量を減らすことを優先しました。

meta_escape

もっとも苦しめられたコードです・・・。コードを見ると 3 パターンはしかないように見えますが、各シェルで挙動がバラバラでようやくここまで減らすことに成功しました。

この meta_escape 関数は直接定義していません。最終的に定義したい meta_escape 関数の「コードを出力する関数」になっており eval を使って定義しています。理由はその方が作りやすかった(コードを短く出来た)のとパフォーマンス向上のためです。メタ文字をエスケープする処理は関数内にインライン化されておりエスケープしたい文字種毎に関数を呼び出したりはしていません。最終的に定義される関数は一つで、この関数の中で複数の種類の文字をエスケープしています。


# shellcheck disable=SC1003

ShellCheck の以下の警告を非表示するためのものです。(そもそも警告内容がおかしい。)ダブルクォートでくくっても対処できますがバックスラッシュの数が更に倍になります。(嫌です。)

set -- '\\\\:\\\\\\\\' '\\\[:[[]' '\\\?:[?]' '\\\*:[*]' '\\\$:[$]'
                    ^-- SC1003: Want to escape a single quote? echo 'This is how it'\''s done'.

set -- '\\:\\\\' '\[:[[]' '\?:[?]' '\*:[*]' '\$:[$]'

位置パラメーターにエスケープ文字をセットしています。: で挟んで左の文字を右の文字にエスケープするという意味にしています。

set "$@" '\(:\\(' '\):\\)' '\|:\\|' '\":\\\"' '\`:\\\`' \
  '\{:\\{' '\}:\\}' "\\':\\\\'" '\&:\\&' '\=:\\=' '\>:\\>' "end"

どのシェルでも共通でエスケープする文字です。end は番兵君(ループで end が来たら終了という意味)です。


echo 'meta_escape() { set -- "$1" "$2" ""'
until [ "$1" = "end" ] && shift && printf '%s\n' "$@"; do
  set -- "${1%:*}" "${1#*:}" "$@"
  set -- "$@" 'until [ _"$2" = _"${2#*'"$1"'}" ]; do'
  set -- "$@" '  set -- "$1" "${2#*'"$1"'}" "$3${2%%'"$1"'*}'"$2"'"'
  set -- "$@" 'done'
  shift 3
done
echo 'eval "$1=\"\$3\$2\""; }'

コード生成部分です。set が多く分かりづらいですが以下のコードと同等です。(※ この場合は番兵君の end は不要)

echo 'meta_escape() { set -- "$1" "$2" ""'
until [ $# -lt 0 ]; do
  echo 'until [ _"$2" = _"${2#*'"${1%:*}"'}" ]; do'
  echo '  set -- "$1" "${2#*'"${1%:*}"'}" "$3${2%%'"${1%:*}"'*}'"${1#*:}"'"'
  echo 'done'
  shift
done
echo 'eval "$1=\"\$3\$2\""; }'

まず、ちょっとしたリファクタリングをします。${1%:*}(エスケープ前文字)と ${1#*:} (エスケープ後文字)の変数が見づらいので、位置パラメーターに入れます。ループの最後で消しやすいように前の方に追加します。前に 2 つ追加したのでループの最後で shift するのは 3 つです。(以下はリファクタリング後)

echo 'meta_escape() { set -- "$1" "$2" ""'
until [ $# -lt 0 ]; do
  set -- "${1%:*}" "${1#*:}" "$@"
  echo 'until [ _"$2" = _"${2#*'"$1"'}" ]; do'
  echo '  set -- "$1" "${2#*'"$1"'}" "$3${2%%'"$1"'*}'"$2"'"'
  echo 'done'
  shift 3
done
echo 'eval "$1=\"\$3\$2\""; }'

実はこのコードにはちょっとした問題があります。それは echo コマンドの非互換性です。echo コマンドは引数にバックスラッシュが入っている場合、それをメタ文字として扱うか、ただの文字として扱うか、実装がバラバラなのです。(それに関しては「シェルスクリプトのechoの移植性の問題に本気で対応する」で解説しています。)今回は運が悪いことに文字列の中にバックスラッシュがたくさん入っています。先のリンク先の記事でバックスラッシュに反応しない putsn 関数を実装したのでそれを使用してもよいのですが、今回定義する関数は基本的な関数だと考えているので他の関数に依存したくはありませんでした。代わりに printf コマンドを使用したいところなのですが、シェルによってはビルトインで実装されておらず外部コマンドを使用するので printf コマンドの呼び出し回数を減らしたい所です。そうした結果、位置パラメーターに出力する文字を蓄えていき、それらを一回の printf コマンド呼び出しで行ったのが最終的なコードです。位置パラメーターに出力文字列を蓄えていくため、位置パラメーターの数が 0 個になることがないのでループの終了判定に番兵君を使いました。


eval "$(meta_escape "a?" "\\")"

meta_escape 関数で出力した本当の meta_escape 関数のコードを eval することで meta_escape 関数を再定義しています。引数の "a?" "\\" はシェル判定に使用する $1 $2 を設定するためのものです。関数内で set -- "a?" "\\" した方がより明確だと思いますが、これぐらいは許容範囲かなと。

replace_all

最終的に定義したいのはこの関数ですが、この関数は二段階に分けて定義しています。一段階目はシェルを判定するための関数として利用しておりその判定結果を用いて二段階目で目的の関数を定義しています。(詳細は移植性・可搬性の高いシェルスクリプトを書くための技術まとめ

(eval 'v="*#*/" p="#*/"; [ "${v//"$p"/-}" = "*-" ]') 2>/dev/null && return 0

replace_all_fast 関数が使用できるかを判定するための処理です。変数を使用していますが、対応してないシェルでは文法エラーで落ちるのでどちらにしろサブシェルを使わなくてはならないので、サブシェル使うなら中で定義した変数は消えてしまうので気兼ねなく使っています。判定する処理自体はもしかしたらもっと良い方法があるかもしれませんが、結構短く仕上げられたと思っています。

[ "${1#"$2"}" = "a*b" ] && return 1 || return 2

同様にパラメータ展開が POSIX 準拠しているかを判定するための処理です。この書き方は注意して下さい。foo && bar || baz だと ShellCheck で以下のような警告が出力されます。

foo && bar || baz
    ^-- SC2015: Note that A && B || C is not if-then-else. C may run when A is true.

ようするにこの書き方は、if-then-else ではないということです。例えば foo が 真であっても bar で偽になれば baz が実行されてしまいます。しかし以下の場合は警告されません。

foo && return 1 || return 2
foo && value=bar || value=baz
foo && echo bar || echo baz
foo && printf bar || printf baz
foo && putsn bar || putsn baz # これは警告される (putsnは独自で定義したシェル関数)

どうやら ShellCheck は 書いてある内容をある程度解釈しているようです。例えば真偽の両方が必ず真を返すものであれば、if-then-else のような使い方をしても問題ないため、警告されないようです。コードが短くなるので警告がされないものに関しては普通に使うことにしています。

eval 'replace_all "a*b" "a[*]" &&:' &&:
# replace_all "a*b" "a[*]" &&: と同等

&&:&& true と同じ意味です。set -e 状態でもそこで停止すること無く $? を保持したまま次の行に進むためのものです。なお $? を正常終了に変えて次の行に進む(= エラーを無視する)ためには ||: と書きます。

eval を使用しているのは ksh のバグのワークアラウンドです。必ず発生するわけではないのですが(実際 WSL 上では発生しませんでした)「1. トップレベルに関数を定義している。」「2. トップレベルでその関数を呼び出している。」「3. その関数を再定義している。」のすべての条件を満たすと意味不明な不具合が発生することがあります。今回はスクリプトが停止するようになってしまったので eval を使用して2.の条件を満たさないようにしています。(おそらく1. 2. の条件を満たした時、最適化で再定義前の関数に何かしらの情報が結びついてしまってるのだと思います。)そして &&: が2つあるのは mksh のワークアラウンドです。eval の中に &&: がないと replace_all 関数の終了ステータスが 0 ではないので(set -eの効果で)終了してしまいました。

case $? in

先程保持した $? を使って replace_all 関数を定義するために処理を分岐させています。01 の場合はそれぞれ使える関数を呼び出しているだけです。こちらは説明は不要でしょう。以下は 2 の場合のコードです。

replace_all() {
  meta_escape "$1" "$3"
  eval "replace_all_pattern \"\$1\" \"\$2\" \"\${$1}\" \"\$4\""
}

処理内容としては文字列をエスケープしてから replace_all_pattern 関数に引き渡しているだけです。meta_escape 関数でエスケープした文字列は $1 で指定した名前の変数に代入されます。その後エスケープされた文字列を引数にして replace_all_pattern 関数を呼び出しています。その後置換された文字列が再度 $1 に代入されます。つまり途中で $1 の名前の変数を meta_escape 関数の結果を返すための一時変数として使用しています。

さいごに

以上 replace_all 関数を作りましたという話と、それを題材に私のコーディングスタイルの解説でした。シェルスクリプトとしてはかなり独特なコーディングスタイルだと思いますが、これは移植性・可搬性、使いやすさ、パフォーマンス、正確性を両立するために工夫した結果出来上がったものです。大抵の人はこんな事するなら別言語で(略)と言うと思いますが、シェルは多くの環境で標準で入っているのでシェルスクリプトで作ればどこでも簡単に動かすことが出来ます。動作環境を一番選ばない言語なのです。今回作った replace_all 関数は実装は大変でしたが、使うだけならば引数で指定した名前の変数に戻り値が返るというだけで、さほど違和感なく使えるのではないでしょうか?このスタイルの関数が増えてくればシェルスクリプトでのプログラミングももっと楽になると思います。そうすれば「こんな事」ももうする必要はないですよね?シェルスクリプトが使いづらいのは不足しているライブラリを補えば解決できる問題だと思っています。私もいつかは作った関数たちをライブラリとして公開できればと思います。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?