LoginSignup
41
36

More than 1 year has passed since last update.

シェルスクリプトは ((i=i+1)) ではなく i=$((i+1)) で計算しなければいけない!という話

Last updated at Posted at 2021-08-14

TL; DR

(( 算術式 )) は比較用!

count=0 max=5
while ((count < max)); do  # (( 算術式 )) は条件文で使うものです
  count=$((count + 1))     # 計算したいだけなら算術式展開を使います
  echo "$count"
done

ついでに言うと (( 算術式 )) は POSIX シェルで規定されていません。bash、ksh、mksh、zsh では使えますが dash、yash などの純粋な POSIX シェルに近いシェルでは使えません。

なぜ?

(( 算術式 )) を使って変数に値を代入したり変更することができますが、それだけのために使ってはいけません。ifwhile などの条件文とともに使うものです。

C 言語を使ってる人なら比較的見かける書き方だと思いますが、(( 算術式 )) で値を変えるというのは、以下のように評価するついでに値も変えちゃえと、短く書くために使うものです。

count=0 max=5
while ((count++ < max)); do
  echo "$count"
done

remain=5
while ((remain--)); do
  echo "$remain"
done

計算だけ(評価なし)に使ってはいけない理由は、ここで述べられているように

(( expression ))
The arithmetic expression is evaluated according to the rules described below (see Shell Arithmetic). If the value of the expression is non-zero, the return status is 0; otherwise the return status is 1.

(( 算術式 )) は算術式を評価した結果が真(0 以外)であれば終了ステータスが成功、偽(0)であれば終了ステータスが失敗となるものだからです。つまりこれは ((count < max)) のような比較用に設計されています。評価がメインであって代入(変数への副作用)はおまけです。

終了ステータスが失敗であっても無視すりゃいいじゃんでやっていると set -e をした時に困ります。set -e するとコマンドが失敗した時に自動的にスクリプトを停止してくれるので便利ですが、算術式を評価した結果が失敗になった場合でも停止してしまいます。

set -e
i=0
((i++)) # 0 として評価されるのでここで停止する(+1 するのは評価した後)
echo "$i" # ここにはこない。

この挙動を set -e の問題点と勘違いする人がいますが、評価に使うべき (( 算術式 )) を変数への副作用だけに使ってるのが根本的な原因です。set -e の罠ではなく (( 算術式 )) (または後述の let) の罠です。(参考記事「シェルスクリプトのset -eを正しく使ってエラー処理を楽にしよう!」「シェルスクリプトのset -eを罠を避けて使う方法」)

計算だけをしたい場合は算術式展開を使います。

set -e
i=0
i=$((i + 1))
echo "$i"

# : $((i++)) 
# このような書き方もできるが、不要な : コマンドを呼び出してるので個人的には好きではない
# ただし変数名が長い場合は有効なので禁止とまでは言わない

多少面倒でもこれが計算だけをする場合の正しい方法です。

let を使えばいいのでは?

let(( 算術式 )) と全く同じ機能です。0 を代入 (let i=0) すると終了ステータスは失敗になります。let という単語から値を入れるだけに思われがちですが、これも比較する時に使うべきものであり非推奨レベルの使ってはいけないコマンドです。

そんな馬鹿なと思うかもしれませんが let コマンドを生み出したシェルの開発者の話「David Korn Tells All」を読めば納得するはずです。

There are also a number of features that I introduced into the shell that I wish that I had done differently or not at all. Some obvious examples are the poor design of the fc command, the let command which was superceded with ((...)), and the exporting of attributes for shell variables.

(意訳)私がシェルに導入したもので、別の方法で導入すべきだった、または導入しなかった方が良かった機能もたくさんある。明らかな例は ~ や、((...)) に取って代わられた let コマンド、 ~ である。

開発者自らが let コマンドは導入が間違っていたと認めています。元記事には書かれていませんが (( 算術式 )) を導入するにはそれまでのシェルの文法を変更しなければいけません。一方 let コマンドはそれが不要です。おそらく let コマンドは文法を変えることなく手っ取り早い方法として導入したものなのでしょう。そして良くない設計だと気づいて (( 算術式 )) に置き換えられました。そのため let コマンドは今では使う必要がないものです。それに ( ) を使う文法 if (( count < max )) は、まるで C 言語の if ( count < max ) みたいじゃないですか? これは C 言語スタイルの比較なんです。

expr を使えばいいのでは?

expr は POSIX シェル以前の Bourne シェル時代で使われていたものです。どうしても Bourne シェルでも動くようにしなければいけないというのなら話は別ですが、整数計算expr コマンドを使う必要はありません。(expr には整数計算以外にも正規表現による文字列比較機能がありますがこの記事では整数計算に限った話をしています。)

expr は外部コマンドでありサブシェルを伴うコマンド置換を使って標準入出力経由で値を受け取るため遅いという欠点があります。シェルに計算機能があれば速くて便利。ということでシェルに実装されたのが算術式評価や算術式展開です。この経緯を知っていれば expr を使う意味がないことがわかると思います。

この話については「今どきのシェルスクリプトは数値計算に expr を使わない(POSIX準拠)」での詳しく解説しています。

ShellCheck による警告

ShellCheck では以下のように antiquated(時代遅れ)であると警告が出力されます。

SC2003: expr is antiquated. Consider rewriting this using $((..)), ${} or [[ ]].

0 で始まる数字を 10 進数として解釈する

算術式では多くのシェル(全てではない)で 0 で始まる数字を 8 進数として扱うのに対して expr コマンドでは常に 10 進数として扱うという違いがあります。そのため expr コマンドから算術式に置き換える場合はこの動作の違いが問題になる可能性があります。

# expr は必ず 10 進数として扱われる
expr 010 + 1 # => 11

# 算術式では 8 進数として扱われる
# (POSIX で指定された動作だが、シェルによって違いがあるので注意)
echo $((010 + 1)) # => 9

単純に頭の連続する 0 を取り除くだけでよいのですが、この処理を sed コマンド等の外部コマンドを使って実装してしまうと結局遅くなってしまい本末転倒であるため、基数を指定する(特定のシェルのみ対応)かパラメータ展開を用いて実装する(すべての POSIX シェルに対応)必要があります。詳細はこの記事の「参考 0 で始まる数字を 10 進数として解釈する方法」を参照してください。

変数=$[ 算術式 ] は使ってはいけないの?

これははるか昔 POSIX で提案されたが、すでに ksh で $(( 算術式 )) 実装されているとして POSIX で採用されなかったものです(参考)。

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.

bash や zsh では今でも使えるようですが他のシェルでは使えません。bash ではドキュメントにも記載されていませんし、使ってはいけません。

別解 typeset -ii=i+1 による計算

POSIX で規定されているわけではなく全てのシェルで使えるわけではありませんが、typeset -i でに整数属性をつけることで i=i+1 という形で計算することもできます。((i=i+1)) が使えるシェルであればおそらくすべて使えると思います。

typeset -i i=0
i=i+1
echo "$i" # => 1

i='( i + 1 ) * 2'
echo "$i" # => 4

複雑な計算で括弧が必要だったりスペースを入れる場合はクォートでくくらないといけないので、あまり便利とは思えませんが一つの手として紹介しておきます。

参考 算術式で 0 で始まる数字を 10 進数として解釈させる方法

頭に 0 がついている数字を算術式で使用した場合は、多くのシェル(全てではない)で 8 進数として扱われます。これは POSIX で指定された動作です。

頭に 0 がつく数字を扱う必要があり、それを(expr コマンドと同じように)必ず 10 進数として扱いたい場合は頭 0 を取り除く処理が必要になりますが、この処理を外部コマンドを使って実装すると遅くなってしまうため、基数を指定するかパラメータ展開を使うかのどちらで実装するのが推奨される方法です。

この記事では簡単な基数を指定する方法を紹介しますが POSIX に準拠した方法ではなくすべてのシェルで使えるわけではないので注意してください。すべての POSIX シェルで動作するパラメータ展開を用いる方法は「シェルスクリプト用のPOSIX準拠で高速な前ゼロ削除とtrim関数の実装 〜それ本当に外部コマンド(sed,awk,tr)が必要ですか?〜」を参照してください。

基数指定に対応しているシェル: bash、mksh、ksh、zsh、pdksh、FreeBSD ksh、OpenBSD sh

echo $(( 8#10 + 1 )) # => 8 (頭 0 がなくても 8 進数として解釈する)
echo $(( 10#010 + 1 )) # => 11 (頭 0 があっても 10 進数として解釈する)

# 注意 基数指定は POSIX に準拠した方法ではありません

注意1 mksh、zsh では基数を指定しない場合に、デフォルトで 10 進数として解釈します。mksh では set -o posix、zsh では emulate -R sh または setopt OCTAL_ZEROES を実行して POSIX に準拠させるモードにすることで 8 進数として解釈させる事ができます。

注意2 pdksh、FreeBSD ksh は基数を指定しない場合に 10 進数として解釈します。これを変更する方法はなさそうです。

まとめ

  • (( 算術式 )) は比較用で ifwhile 等と共に使う
  • 計算目的で使う場合は算術式展開 変数=$(( 算術式 )) を使う
  • letexpr変数=$[ 算術式 ] は使わない

さいごに

私にとっては (( 算術式 )) は POSIX に準拠してない時点で使いたくても使えないですけどね。あと意図してなかったけど、この間の記事「シェルスクリプトの[ -lt, -le, -gt, -ge, -eq, -ne ]が嫌いな人に送るvalハック」の関連記事になってしまった。

41
36
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
41
36