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.

シェルスクリプト用のPOSIX準拠で高速な前ゼロ削除とtrim関数の実装 〜それ本当に外部コマンド(sed,awk,tr)が必要ですか?〜

Last updated at Posted at 2021-09-02

はじめに

この記事は「シェルスクリプトは ((i=i+1)) ではなく i=$((i+1)) で計算しなければいけない!という話」を書いた時に、そう言えば算術式($((...)))では頭に 0 がついた時に 8 進数とみなされるという**ささいな問題**がありましたねーということで、その解決方法と一般化すればこれは前から書こうと思っていた外部コマンドを使わない trim 関数の実装の話なので、ついでにそれらを紹介する記事です。

このような処理をする場合に外部コマンドを使って実装する人が多いですが、実は**シェルのパラメータ展開を利用すればシェルの機能だけで実装することができます。**そしてデータが十分小さい場合にはその方が速いです。ここで紹介するコードは ShellSpec で実装済みの trim 関数readlinkf で使っている末尾の複数の / の削除処理を汎用的な形に直したものです。

要約

  • 本当に外部コマンドを使わずパラメータ展開だけを使って実装しています
  • ifcase やループは使っていません
  • 前ゼロ削除はマイナスの値にも対応しています
  • 読みづらい?そのためにシェル関数というものがあるのですよ
  • (さいごに)だから私はシェル関数ライブラリが必要でそれを作ると言っています
    • やりたいけど他にやらなければいけないことが多すぎて着手できない

注意事項

最初から POSIX 準拠シェルしか対象にしていません。

  • POSIX 以前 = Bourne シェルは古く問題が多く機能不足のシェルです
  • 例えば Solaris 11 の /usr/sunos/bin/sh は Bourne シェルです

BourneシェルとBourneシェル系(bash等のPOSIXシェル)の違いについて」はこちら

なぜ外部コマンドを避けるのか?

「小さいデータ」においては外部コマンド呼び出し(fork & exec)のコストが大きくパフォーマンスが悪いからです。数回呼び出す程度なら気づきませんが場合によっては大幅なパフォーマンス低下に繋がります。また外部コマンドで文字列処理して変数に戻す=コマンド置換を用いる場合は「末尾改行消失問題」(参照「シェルスクリプトにはコマンド出力を変数に入れると末尾の改行が全部消えてしまう罠がある!」)という罠に気をつける必要があります。

Bourne シェルでは不可能ですが POSIX 準拠シェルであればパラメータ置換を使って文字列処理ができるので sedawktr は必須ではありません。ただし大きいデータの場合には外部コマンドの呼び出しコストよりも外部コマンドの実行速度の速さの方が有利になります。状況に応じて適切な方法を使い分けましょう。

前ゼロ削除

ゼロサプレスとも言い 00123 のように固定桁数の数字列の頭を 0 を削除することです。反対に 0 を埋めることをゼロパディングと言います。

ちなみに個人的にゼロパディング / ゼロサプレスという用語は COBOL 界隈で使われる用語だと思っており(検索すればそれ以外で使っている例もありますが)一般的にはあまり使われてない気がします。なのでこの用語を使ってる人を見ると COBOL 界隈の人かな?って思ってしまいます。私は学生時代に少しだけ COBOL を勉強したことがあるのでこの用語を知っています。仕事で COBOL を使ったことはありません。

実装

書き方はややこしいですが、実装自体はそんなに大変なものではありません。

num="001230"
echo "${num#"${num%%[!0]*}"}" # => 1230

もしマイナス値にも対応したいのであればこのようにすればよいです。たった 1 ステップ(数え方によっては 2 ステップ)追加するだけです。(マイナス値の前ゼロ削除は私が今まで必要になったことがなく、思いつきで書いただけなので十分テストしてませんし、もっと良いコードがあるかもしれません。)

num="-001230"
sign=${num%%[0-9+]*} && num=${num#[-+]}
echo "${sign}${num#"${num%%[!0]*}"}" # => -1230

# 補足 符号の分離のための 2 つの変数代入を && で繋いでいるのは
# sign=${num%%[0-9+]*} num=${num#[-+]} だとシェルによっては変数代入の順番が
# 逆になったり直感的な動作にならず計算結果が変わってしまうからです。
# (古い dash や busybox、FreeBSD sh などが該当します。)
# 変数代入の順番は POSIX で規定されていない動作なので違反ではありません。
# このような処理を行う場合は && で繋いで実行順序を明確にする必要があります。

なぜこのような分かりづらいコードが必要なのかと言うと、POSIX 準拠の範囲では正規表現が使えず「先頭の連続する 0」というシェルパターンもないからです。そのため二段階にわけて処理する必要があるからです。

num="001230"

# 後ろから最も遠い 0 以外までを削除
echo "${num%%[!0]*}" # => 00

# 頭から先程の 00 を削除
echo "${num#"00"}" # => 1230

# 上2つを合わせると
echo "${num#"${num%%[!0]*}"}" # => 1230

ただし num が 00000 の場合は空文字になるので注意が必要です。算術式展開を使うと簡単に空文字を 0 にすることができます。

num="00000"
echo "${num#"${num%%[!0]*}"}" # => ""
echo $(( "${num#"${num%%[!0]*}"}" + 0 )) # => 0
# + 0 をしてるのは一部のシェル(dash、posh、yash、古い zsh)で `$(( ))` がエラーとなるため

前ゼロは算術式の中で直接使うこともできますが空文字になるということを忘れていると算術式エラーになる場合があるので注意が必要です。ややこしい場合は一旦変数に入れてから使うことをおすすめします。

echo $((1 + ${num#"${num%%[!0]*}"})) # value が 0 だと $((1 + )) となり算術式エラーになる
echo $((${num#"${num%%[!0]*}"} + 1)) # $(( + 1 )) となるので算術式エラーにならない

# 一度変数に入れておけばわかりやすいし、マイナス値でも対応できる
num="-001230"
sign=${num%%[0-9+]*} && num=${num#[-+]}
num="$(( ${sign}1 * (${num#"${num%%[!0]*}"} + 0) ))"
echo $(( num + 1 ))

このようにパラメータ展開を使うと理論上はシェルだけですべての文字列処理を行うことが可能です。

別解(簡易版)

数字の桁数が 2 桁(例えば月日等)と明らかになっている場合は ${num#0} のように簡易な方法で頭ゼロを削除することができます。

関数化

まあ読みづらいですよね。そういうときのためにシェルにはシェル関数というものがあるのです。実用的な形に仕上げられるかどうかはあなた次第です!(シェル関数を使わないでこれを書くと見づらくなるというなら、シェル関数を使わないという方針が間違ってる証拠なわけで)

ltrim0() {
  # 関数内で(グローバル)変数を使うと他と衝突する可能性があるので
  # 一時変数の代わりに位置パラメータを使っています
  set -- "$1" "${2#[-+]}" "${2%%[0-9+]*}"
  eval "$1=\$(( \${3}1 * (\${2#\"\${2%%[!0]*}\"} + 0) ))"
}

ltrim0 num "001230"
echo "$num" # => 1230

ltrim0 num "-001230"
echo "$num" # => -1230

シェル関数を使えばテストも簡単に可能になります。(シェル関数を使わないでこれを書くとテストできないというのなら、シェル関数を使わないという方針が間違ってる証拠なわけで)

ちなみに ShellSpec はこのようなシェル関数のテストを簡単に行いたいから開発したものです。(この程度のシェル関数であれば bats-core 等でテストできないことはないのですが、場合によっては面倒な前処理や補助コードが必要になったりします。)

補足 前ゼロ削除を行うタイミング

数字の頭に 0 が入っている可能性がある場合に、前ゼロ削除を算術式の中で行うとコードがややこしくなりますが、常にこのような書き方をする必要はありません。それどころかこのような書き方を常にしている方が問題です。

一般的に前ゼロ削除を行うタイミングは、数字文字列をデータとして読み取ったタイミングです。例えばテキストファイルの中に頭に 0 がついた数値が記録されているのであれば、それを読み取ったタイミングで前ゼロ削除を行って「数値化」します。また元々シェル内部で扱う数値には頭に 0 をつけることはありません。そのため算術式で計算を行うタイミングで頭に 0 がついていることはなく算術式の中で前ゼロ削除を行うこともありません。

もし前ゼロ削除の処理によって算術式が複雑になっているとしたら、コードの書き方自体に問題があると考えるべきです。

trim 関数

文字列の前後のスペースを取り除く方法です。基本的に前ゼロと同じ考えで実装することができます。ここではスペースの代わりにシェルスクリプトらしく IFS 変数(デフォルトではタブ・スペース・改行)を取り除く仕様とします。

実装

str="   abc   "
echo "[${str#"${str%%[!"$IFS"]*}"}]" # => [abc   ]
echo "[${str%"${str##*[!"$IFS"]}"}]" # => [   abc]

# 残念ながら両方を一度に取り除くことはできない
str=${str#"${str%%[!"$IFS"]*}"}
str=${str%"${str##*[!"$IFS"]}"}
echo "[$str]" # => [abc]

補足 スペース・タブ・改行に限れば変数展開を利用して実装することができるはずですが、汎用的にはならず ShellSpec で実戦投入してないので省略します。また効率は悪いですがループと ifcase を用いて 1 文字ずつ削除する方法でも実装することができます。以前はその方法を使っていた時もありましたが、文字列転送のコストを考えるとパラメータ展開の方が良いだろうと思い今の形にしています(まだどこかに残っているかもしれませんが)。

関数化

ltrim() {
  eval "$1=\${2#\"\${2%%[!\"\$IFS\"]*}\"}"
}

rtrim() {
  eval "$1=\${2%\"\${2##*[!\"\$IFS\"]}\"}"
}

trim() {
  ltrim "$1" "$2"
  eval "rtrim \"\$1\" \"\${$1}\""
}

str="   abc   "
trim str "$str"
echo "[$str]" # => [abc]

バグが多い posh 問題

普通の場合は気にする必要はありません。このシェルは切り捨てることを推奨します。posh は(私の中で)バグが多いことで有名なシェルです。今回のコードも posh では動作しません。その回避策はあるのですが長くなってしまうので今回は省略します(ShellSpec では対応していますがおかげでもっと酷いコードが必要になっています)。ヒントとしては一時変数を使います。(このバグは未報告です。ほとんどメンテナンスされてないので報告する気になりません。せめて何年も前から指摘されている set -u 問題を最優先で解決して欲しいです。)

str="   abc   "
# パラメータ展開でダブルクォートをネストできないバグ
echo "[${str#"${str%%[!"$IFS"]*}"}]" # => []

# 回避策(一時変数に入れてダブルクォートを回避する)
str="   abc   "
prefix=${str%%[!"$IFS"]*}
str=${str#"$prefix"}
echo "[$str]" # => [abc   ]


# 位置パラメータの組み合わせだとパラメータ展開が機能しないバグ
set -- abc a
echo ${1#"$2"}

# 回避策(どちらかを変数にすれば動く)
set -- abc a
prefix=$2
echo ${1#"$prefix"} # => bc

さいごに(苦言)

自分が外部コマンドを使わずに実装できない、実装を思いつかない、実装できないと思い込んでいるからって「私が実装できなかった」と勘違いしてあーだこーだ言うのは意味がない上に逆効果なだけですよ。(誰のことかはあえて言いませんが、ささいな問題に対してゴールポストを動かしながら文句を言ってる人たちです。前ゼロ除去のために外部コマンドが必要になるとか case 使うしかないという主張はどこへ?)

ShellSpec という信頼性重視のテスティングフレームワークを、すべての POSIX シェルで継続的に動作テストしながら開発している私が "一部のシェル"で算術式の値に前ゼロがあると 8 進数として解釈されるという問題に気づいてないわけがないでしょう?各シェルの算術式で使える式の違いも記事にする予定に入ってますよ。使う必要がないから書いた事がなかった(あったかもしれないけど思い出せない)だけです。外部コマンドを可能な限り使わないという設計(外部コマンドの互換性問題回避のための縛りプレイ)で ShellSpec を開発しているのにたかが前ゼロ削除や trim 関数程度を私が実装できないと本気で思ったのでしょうか?形が違うだけでほとんどの問題は ShellSpec の開発ですでに私が通った道です。

外部コマンドなしの前ゼロ削除は、やろうとした人が少なかった(いなかった?)だけで 20 年どころか 30 年ぐらい前のシェル(ksh88)でも実現可能だった。これが事実です。まあ trim 関数の実装コードの紹介は前から書きたいと思っていた記事の一つだったから丁度いい機会にはなりましたが。このコード見てまだ何かいうとしたら次は eval 多用問題ですかね?危険だと思うのならバリデーションぐらい自分で実装してくださいね。ん?数字の桁数限界問題?私は「POSIX準拠のシェルスクリプトでハッシュ値を計算する(FNV-1 / FNV-1a実装)」で精度が足りない時に桁を半分にして計算しましたね。私が扱える桁数に限界があることに気づいてないとでも?ShellSpec では少数の計算(ミリ秒単位の実行時間計算で使ってたはず)は10 進数として小数点位置をずらす関数を使って整数に変換することで(アドホックに)対応しています。本当はシェルスクリプト用の固定小数点数ライブラリでもあるといいのですが作るの大変そうですし。

他にも穴がある? 対応できない環境がある? 100% の解決策でないと意味がないと思ってますか?
それでしたら「90 パーセントの解を目指す」という言葉を紹介します。意味は検索してください。

ということで、将来開発する予定の POSIX 準拠シェルスクリプト用のシェル関数ライブラリに収録する trim 関数と前ゼロ削除関数の実装(仮)でした。なおこのコード(というほどのものでもない)のライセンスは主張しない、または CC0 としますので、そのまま or 修正を加えて自由に使用していただいて構いません。

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?