LoginSignup
146
111

More than 1 year has passed since last update.

今どきのシェルスクリプトは数値計算にexprを使わない(POSIX準拠)

Last updated at Posted at 2022-10-06

はじめに

1992 年に POSIX でシェルが標準化されて以来、シェルスクリプトの数値計算に expr コマンドは使いません。expr コマンドを使って計算していたのは Bourne シェル(古い UNIX の sh)時代の話で、現在の POSIX sh (dash、bash、ksh 等)時代では数値計算に expr コマンドは不要です。今どきはシェルの機能だけで整数の計算を行うことができます。「今どき」って一体いつからだって話なわけですが……。

注意 シェルスクリプトでパフォーマンスの話をするとすぐに「他の言語で〜」という方がいますが、私はどんなことにでもシェルスクリプトを使えなんて一言も言っていません。パフォーマンスを気にしている理由は、そこが実際にシェルスクリプトのボトルネックになるポイントだからです。そもそもシェルスクリプトと一般的な言語は言語設計レベルで得意なことが違います。ユースケースが全く異なるため一般的な言語をシェルスクリプトの適切な代替として使うことは出来ません。他の言語に簡単に置き換えられるようなことをシェルスクリプトでしないでください。適材適所で複数の言語を適切に使い分けてください。

今どきのシェルが使えない環境はない

「現在の sh」とは AIX、HP-UX、Solaris など現役の商用 UNIX を含みます。さすがにサポート期限切れの OS は含めていません。これらは標準の sh として ksh が使われているはずです(参考 Various system shells)。仮に /bin/sh が POSIX シェルでなくとも UNIX を名乗ることができる条件が POSIX 準拠である以上、どこかに POSIX シェルがインストールされています。

Oracle Solaris 10 から Oracle Solaris 11 への移行」より

シェルの変更 - デフォルトのシェル /bin/sh が ksh93 にリンクされるようになりました。

AIX - 7.1 - 使用可能なシェル

Korn シェル (/usr/bin/ksh) は、 デフォルト・シェルとしてセットアップされます。 デフォルト・シェルまたは標準シェルとは、/usr/bin/sh コマンドにリンクされ、このコマンドで開始されるシェルのことです。

数値計算に使う $(( ... )) は読みやすい❗

今どきのシェルスクリプトの数値計算には $(( ... )) を使います。expr コマンドによる計算は書き方が面倒です。例えば $(( ... )) を使った場合、以下のように直感的で可読性が高い計算式が、

value=$(( (i + 1) * 5 ))

value=$(((i+1)*5)) # つなげすぎると読みづらくなるが、つなげて書いても良い

expr コマンドを使うと以下のように長く読みづらくなってしまいます。

value=`expr \( $i + 1 \) \* 5` # 外部コマンド呼び出しの `...` は非推奨の古い書き方

value=$(expr \( $i + 1 \) \* 5) # 今は外部コマンド呼び出しに $(...) を使う

変数は $ が必要ですし、( )* はシェルのメタ文字とみなされないようにエスケープが必要ですし、スペースを無くしてつなげて書いてはいけません。

$(( ... )) は bash の拡張機能ではなく全ての POSIX シェルで使える

「bash では $(( ... )) で数値計算ができます」みたいな、いかにも bash 専用の独自機能ですというような誤解を与えかねない書き方を見かけますが $(( ... )) は POSIX で標準化された移植性が高い書き方です。bash だけではなく全ての POSIX 準拠のシェルで使うことができます。$(( ... )) が使えないというのは古い UNIX で使われていた Bourne シェル(POSIX 非準拠)の話で、1990 年代前半ぐらいまで(?)はシェルスクリプトでの計算に expr コマンドをつかうのが普通でした。

名著として今も紹介されている「入門 UNIX シェルプログラミング シェルの基礎から学ぶ UNIX の世界 改訂第2版」でも「シェルに数式を処理する機能はありません」(P184) と書いてあったりして困ったものです。古い本ですがこの本の英語版原著の出版年である 1995 年は POSIX 標準化の後で、この本に bash、ksh、zsh への言及があるにも関わらずです。少なくとも 「POSIX シェルや一部のシェルではシェルに数式を処理する機能がある」と書くことはできたはずなので、著者が POSIX シェルを重視していなかったということなのでしょう。ともかくここから当時はまだ Bourne シェルが主流の時代だったということがわかります。なお、この本については「名著「入門UNIXシェルプログラミング」の超詳細なレビューをしてみた(古い内容の訂正)」で詳細に修正しています。

ちなみに似たような機能で let コマンドや ((...)) がありますが、こちらは POSIX 準拠ではなく dash (Debian・Ubuntu 系の /bin/sh)などでは使えないので注意してください。どの環境でも使えるのは $((...)) だけです。

# どの POSIX シェルでも使える書き方(POSIX 準拠)
i=$((i + 1)) 


# 以下は POSIX 準拠ではないので注意
let i=i+1      # 最初に誕生したが、すぐに同等の ((...)) に置き換えられた
((i = i + 1))   

# 補足 let や ((...)) は代入にも使えるが、どちらかといえば if 等の条件で使う
# (計算結果が 0 になると終了ステータスは 1 になる)
i=10; while ((i--)); do echo "$i"; done

こちら「シェルスクリプトは ((i=i+1)) ではなく i=$((i+1)) で計算しなければいけない!という話」の記事も参照してください。

expr による計算は $(( ... )) よりも約 1000 倍遅い

シェルに組み込まれた機能である $(( ... )) とは異なり expr コマンドは外部コマンドであるため、コマンド呼び出しに大きく時間がかかります。下記の例では 963.875 倍遅いことがわかります。今のコンピュータであれば数回呼び出す程度気にならないと思いますが、ループの中などで呼び出すとクリティカルな影響を与えることがあります。0.1 秒で終わる処理は 1000 倍だと 100 秒です。

え?シェルスクリプトでそんなに大量に計算しない?現実にシェルスクリプトが遅いと文句を言ってる人の大半の原因がこれで、外部コマンドの呼び出しコストの大きさを甘く見ているからなんですよ。実用上問題ない速度が出るのに(適切ではない)他の言語に置き換えるのは「早すぎる最適化」です。

$ time dash -c 'i=0; while [ $i -lt 100000 ]; do i=$(( i + 1 )); done; echo $i'
100000

real	0m0.152s
user	0m0.151s
sys	0m0.000s

$ time dash -c 'i=0; while [ $i -lt 100000 ]; do i=$(expr $i + 1); done; echo $i'
100000

real	2m26.509s
user	1m48.080s
sys	0m46.850s

シェルや環境によってどれくらい遅いかは異なりますが、本質的には外部コマンド呼び出しによる遅さが原因であるため、大体似たような結果になるはずです。

シェルで変数のインクリメントに expr を使うと100倍遅い件」の記事では 100 倍(控えめに書かれているので計算上の実際の倍率は 121 倍)遅いと書かれていますが、これは少し計測回数が少なくその他の処理のオーバーヘッドの影響を受けているようです。同じコードを私が計測した時、1000 回のループでは 126 倍とほぼ同じ結果になりましたが、これを 100000 回のループで計測すると 326 倍になりました。またこれは bash による計測であり、速いと言われる dash で計測すると 907 倍となったので、ここで書いているように expr コマンドは約 1000 倍(私は少し誇張してますw)遅いということになります。Cygwin や WSL1 環境ではもっと遅く、この結果のさらに 10 倍以上遅くなります。

$(( ... )) は bash ではなく ksh で発明された

多くの人が bash の拡張機能だと思いこんでいる機能の多くは、元々は ksh88 または ksh93 がオリジナルです。$(( ... )) も最初に実装されたシェルは ksh88 です。なお、ksh88 は POSIX シェルの標準規格のベースとなったシェルで、POSIX シェルは ksh88 のサブセットです。

“In early proposals, a form \$[expression] was used. It was functionally equivalent to the "\$(())" of the current text, but objections were lodged that the 1988 KornShell had already implemented "\$(())" and there was no compelling reason to invent yet another syntax. Furthermore, the "\$[]" syntax had a minor incompatibility involving the patterns in case statements.”

ksh (KornShell) は UNIX を開発した AT&T で開発されたシェルです。UNIX では長い間 Bourne シェルが 標準 sh (/bin/sh) として使われていましたが、徐々に ksh に置き換えられていきました。そのような経緯もあってか古い UNIX を使っていた人はシェルスクリプトと言ったら Bourne シェルが基本だと(今も?)考えているようです。Bourne シェルと POSIX シェルの違いは「Bourne Shell(レガシー sh)とPOSIXシェル(sh, bash, etc)の違い」を参照してください。また実際に UNIX でどのようなシェルが使われどのように置き換えられていったかは「Various system shells」を参照してください。

ついでに言いますが、紛らわしいので sh = Bourne シェルとは言わないようにしてください。現在の sh は POSIX 準拠のシェルに置き換わっており Bourne シェルはもはや使われていません。Bourne シェルが本来の sh で勝手に他のシェルが sh を名乗っているだけという考え方もあるかもしれませんが、それを言ったら最初に sh を名乗ったのは Thompson シェル(UNIX の誕生とともに生まれた最初の UNIX シェル)です。

注意: $((010)) は 8 進数かもしれないし 10 進数かもしれない

expr コマンドは頭に 0 をつけても 10 進数として解釈されますが、$((...)) の場合 8 進数として解釈されることもあれば 10 進数として解釈されることもあります。例えば ksh 93u+m や zsh ではデフォルトでは $((010)) は 10 進数として解釈され 10 になります。これは POSIX 準拠モードにすることで 8 進数として解釈され 8 として扱われます。

このようになった経緯の詳細は完全には調べていませんが、おそらく ksh88 の時点で頭に 0 が来ても 10 進数として解釈したのが元凶です。ksh88 は 16 進数の 0x 表記に対応しているのですが、8 進数表記には対応していませんでした(正確に言えば 8#010 のような表記で 8 進数を含め任意の基数に対応しています)。その後の互換シェルでは ksh88 と同じように 10 進数として解釈しましたが、bash のように 8 進数として解釈するシェルも登場しました。そして POSIX で標準化されたためか 10 進数として解釈していたシェル(mksh と zsh)でも POSIX 準拠モードで 8 進数として解釈するようになりました。

ksh は少しややしく ksh 93u+ では(ksh88 とは異なり)8 進数として解釈されていたのですが、最近 (2022-08-02) リリースされた ksh 93u+m ではデフォルトで(ksh88 と同じく)10 進数として解釈され、新しく追加された POSIX 準拠モード (set -o posix) を有効にすることで 8 進数として解釈されるように変わりました。長い間使われていた仕様が変わったのは気になる所ですが ksh88 との互換性を考えるとあるべき仕様とも言えます。

最近のシェルであればデフォルト、または POSIX 準拠モードにすることで 頭に 0 が来る数値は 8 進数として解釈するはずですが、古いシェルの中には、POSIX 準拠モードにしたとしても 10 進数として解釈するシェル(例 Debian 6 の pdksh や Debian 7 の mksh)があるので少し注意が必要かもしれません。なお以下のコードを利用すれば、頭 0 を削除することができるので expr と同じ動きに動作を統一させることができます。

# 以下の書き方も POSIX 準拠
num="001230"
num=$((${num#"${num%%[!0]*}"} + 0))
echo "$num" # => 1230

# 日付のように 2 桁を前提に出来るのであればこれでもよい
num="08"
echo $(( ${num#0} + 1 )) # => 9

macOS のデフォルトのログインシェルが zsh に変わったため、シェルを気にせずターミナルで echo $((010)) を実行すると 10 が出力されます。しかしシェルスクリプト (#!/bin/sh#!/bin/bash) にして動かすと 8 が出力されます。このような話を知らなければきっと混乱することでしょう。どの環境でも動くシェルスクリプトを書くためにはこのような問題も知って対処しなければならないのが辛い所です……。

数値かどうかの判定には case を使う

expr コマンドの使い方として数値かどうかの判定に使うというものがあります。

if expr "$i" + 0 >/dev/null 2>&1; then
  ... # 数値の場合
fi

これと同等のことを行うには case を使います。少々冗長になるのでシェル関数にしたほうが良いでしょう。また POSIX 準拠ではありませんが [[ ... ]] を使うこともできます。

# 数値(負の値も含む)の場合に真を返す
isnum() {
  case ${1#-} in # 頭のマイナスを取る(プラスも取りたい場合は ${1#[+-]}
    *[!0-9]*) return 1 ;; # 数字以外の文字があれば数値ではない
    *) return 0 ;;
  esac
}

if isnum "$i"; then
  ... # 数値の場合
fi

# [[ ]] をサポートしてるシェルではこちらでも良い
if [[ ${i#-} != *[!0-9]* ]]; then
  ... # 数値の場合
fi

expr コマンドと上記のコードの違いとして対応している数値の範囲の違いがあります。expr が数値とみなす最大値は signed integer(おそらく環境依存で 64 ビット環境では 263-1 = 9223372036854775807) のようですが、上記のコードは無限の桁数の数字を数値としてみなします。以下のように改良するとシェルが扱える範囲の値のみに制限することができます。なお殆どのシェルで 64 ビット環境では 64 ビットの範囲の数値を扱うことができますが、POSIX で保証しているのは 32 ビットまでであり、mksh では 64 ビット環境でも 32 ビットの範囲の数値しか扱うことができません。

isint() {
  case ${1#-} in
    *[!0-9]*) return 1 ;;
    *) { [ $(($1)) = "$1" ]; } 2>/dev/null || return 1 ;;
  esac
}

# 補足 上記の { ...; } 2>/dev/null は zsh 用でエラーが出力されるため

おまけ: 残る expr の役目は文字列の大小比較と正規表現マッチ

POSIX で標準化された範囲ではシェルに文字列の大小比較と正規表現マッチングがないため、expr コマンドの役目は残っています。しかしこれも bash、ksh、zsh であればシェルにその機能が含まれているため expr コマンドの役目は残っていません。

余談ですが expr コマンドは正規表現マッチングができますが、基本正規表現 (BRE) を使うため使いづらいコマンドです。この問題を解決したい方は「シェルスクリプトの世界から基本正規表現(BRE)をなくそう!」を参照してください。

おまけ: 小数の計算には bc ではなく awk の方が良い

expr コマンドも $((...)) も POSIX で標準化された範囲では整数のみの対応で小数の計算はできません。小数の計算には bc コマンドを使うという記事が多いのですが、どちらかといえば awk を使った方が良いです。bc コマンドではだめということはないのですが、bc コマンドは POSIX で標準化されたコマンドでありながら、インストールされていない環境があるので注意が必要です。まあインストールが必要というだけの話なので、インストールするのであれば bc コマンドでも良いですし、なんなら dc コマンドや Perl 等でも問題ありません。

ちなみに bc コマンドは「任意精度の計算言語 (An arbitrary precision calculator language)」と説明に書いてあるように、任意精度の計算が行えるので出番がなくなったわけではありません。大きな数の計算では必要になることもあるでしょう。ところで bc コマンドは計算言語でユーザー定義関数(POSIX 準拠では関数名は一文字のみ……)が定義できたりできるの知っていました?

さいごに

いい加減 expr コマンドを使って数値計算する記事は無くなった方がいいと思います。新しく記事を書く人は expr コマンドによる数値計算は書かないか古いやり方だと明記するようにしましょう。わざわざ不便で遅い expr コマンドを使って整数の数値計算をする必要はありません。これ以上古い書き方の記事を増やさないようにしてください。

146
111
2

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
146
111