LoginSignup
26
20

More than 3 years have passed since last update.

Bash $((算術式)) のすべて - B 罠・バグ回避編

Last updated at Posted at 2016-12-04

Bash 算術式の陥りやすい罠と Bash 自体のバグについてまとめました!

※この記事は AdC 2016 Shell Script 4日目 Bash $((算術式)) のすべて - Qiita の衛星記事です。

関連記事一覧:

記事の構成:
B1で Bash 算術式の初心者が陥りやすい罠についてまとめます。B2 で新旧の Bash の算術式にまつわるバグとその回避方法についてまとめます。まとめに関しては親記事 §2, §4 を御覧ください。

■ B1. Bash 算術式の罠にかからないために

ここではうっかりすると嵌ってしまう算術式の罠について列挙します!

B1.1 :warning: 変数を無駄に変数展開しない: ×$(($a*$b)) → ○ $((a*b))

b=$(($a+2)) などのようなスクリプトをよく見るのですが… (Bash では) b=$((a+2)) と書けます(というかもっと言うなら ((b=a+2)) と書ける)。つまりわざわざ $a などとして中身をばらす必要はないのです。こう書く理由は、文字数が少なくて読みやすいから…というだけではありません!

例えば $(($a*$b))$((a*b)) では意味が違ってきます。$(($a*$b)) だと罠に嵌る可能性があるので、算術式の変数を変数展開で算術式に渡すのは避けましょう。

例: a=1+2+3 b=2 の場合を考えると、$((a*b)) は既に説明したとおり 12 になります。しかし $(($a*$b))9 になります。次のように評価されるからです:

  1. まず変数展開が実行されて 1+2+3*2 になる
  2. 算術式評価が 1+2+3*2 に適用されて 9 になる

「展開した変数の中身がばらばらに周りと結合する」という C 言語のマクロと同様の問題があるのです。従って変数展開に固執する場合、解決方法は C 言語のマクロの時と同様に念入りに変数を括弧で囲むことになります。

# 展開される側で囲む
a=\(1+2+3\) b=2
echo $(($a*$b))

# または展開する側で囲む
a=1+2+3 b=2
echo $((($a)*($b)))

しかし、そんなまだるっこしいことをするぐらいならば、初めから

a=1+2+3 b=2
echo $((a*b))

と書くように習慣づけて置けば良いのです。$((a*b)) のような書き方をしない理由はありません! 以下の様な変なことを企まない限りは…

a='1+(2' b='4)'
echo $(($a*$b))

2.1.1: (註) POSIX sh では $((($a)*($b))) とする方が良い?

$((a*b)) などの書き方は Bash に限らず POSIX sh でも通用しそうな気がしてしまうかもしれないので、ここに補足しておきます。実際に POSIX を見ると $((a*b)) の結果がどうなるかについて厳密に規定されていないような (気がする) ので、POSIX sh をターゲットとするならば $((($a)*($b))) のように書かなければならないことに注意が必要です。POSIX では、変数 x の中身が (符号+)整数リテラル の時に $((x)) の結果が $(($x)) と同じになるということしか要求していません。

B1.2 :warning: 0で左詰めされた整数には基数 10# を指定する

例えば以下の様なファイルがあったとします。

$ ls
01.txt  03.txt  05.txt  07.txt  09.txt  11.txt  13.txt  15.txt  17.txt  19.txt
02.txt  04.txt  06.txt  08.txt  10.txt  12.txt  14.txt  16.txt  18.txt

ファイル番号を1ずつずらしたいと思って次の様にすると何が起きるでしょう?

$ for f in ??.txt; do mv "$f" "$(printf %02d.txt $((${f%.txt}-1)))"; done
bash: 08: 基底の値が大きすぎます (エラーのあるトークンは "08")
mv: `08.txt' から `' へ移動できません: そのようなファイルやディレクトリはありま
せん
bash: 09: 基底の値が大きすぎます (エラーのあるトークンは "09")
mv: `09.txt' から `' へ移動できません: そのようなファイルやディレクトリはありま
せん
$ ls
00.txt  02.txt  04.txt  06.txt  09.txt  11.txt  13.txt  15.txt  17.txt
01.txt  03.txt  05.txt  08.txt  10.txt  12.txt  14.txt  16.txt  18.txt

何やら変なエラーメッセージが出て、しかもファイルが一つ消えています!

そうです…算術式では 0 で始まる整数は8進数リテラルと解釈されます。従って、ファイル名などで一般的に使用される 10 進数の左詰めの 0 は算術式中では爆弾になりうるのです。

これを避けるためには、左詰めの 0 が含まれる可能性のある整数に 10# と "10進数" であることを明示的に指定しなければなりません。上記の例で言えば、$((${f%.txt}-1)) の部分を $((10#${f%.txt}-1)) と書かなければならないのです。

B1.3 :warning: 代入済の値は declare -i の対象外なことに注意する

算術式評価が起こるのは declare -i より後の「代入時の右辺」です。

対話シェル
$ hello=1+2+3
$ declare -i hello
$ echo $hello
1+2+3
$ hello=$hello; echo $hello
6
$ hello+=2*9; echo $hello
24

B1.4 :warning: ${変数:式1:式2} の式1が -/+ で始まるときは半角空白を前置する

マニュアルにも書かれている基本的なことですが ${変数:-文字列}, ${変数:+文字列} と混同されてしまうので、半角空白を付加してそれを避けます。

対話シェル
$ hello=12345
$ echo ${hello:-2} ${hello:+2}
12345 2
$ echo ${hello: -2}  ${hello: +2}
45 345

B1.5 :warning: ${変数:式:式} に副作用のある式を指定しない

変数展開 ${変数:offset:length} の offset/length は必ず評価されるとは限らないようです。例えば、変数がそもそも存在しない場合は offset/length 共に算術式評価されないようです。また、offset の評価結果が範囲外 (変数の中身の文字数より大きい) の場合にも length は算術式評価されないようです。この辺りの動作はドキュメントに書かれておらず将来変更されるかもしれません。

副作用を期待する式が実行されるかされないか分からないとなると、後続の処理に影響を与えることになりますので、offset/length に副作用のある式を指定するのはやめておいた方が良い気がします。(ただし、変数が存在して offset/length が変数の中身の範囲内にあることが保証されている場合は OK?)。

対話シェル
# 以下の i=5 は評価されない
$ hello=1 i=2; : ${hello:i:i=5}; echo $i
2
$ unset hello; i=2; : ${hello:i=5}; echo $i
2

# $(( )) 展開も行われないようだ
$ hello=1 i=2; : ${hello:i:$((i=5))}; echo $i
2
$ unset hello; i=2; : ${hello:$((i=5))}; echo $i
2

B1.5 :warning: unset arr[式] はダメ。常にクォートして unset 'arr[式]' とする

unset は構文的には普通のコマンドと同じで、その引数はパス名展開の対象です。
例えば unset arr[123] とすると引数arr[123]は「arr1 または arr2 または arr3」という意味を持ちます。

  • たまたまカレントディレクトリに arr2 等のファイルが存在すると unset arr2 が実行されてしまいます。関係ない変数が消えてしまいます
  • また一致するファイル名がなかったとしても、シェルの設定で shopt -s nullglob が設定されていると、引数が消えて無引数の unset が実行されます。つまり何も起きません
  • 或いはシェルの設定で shopt -s failglob が設定されていた場合には、シェル自体が終了してしまいます
対話シェル
# ダメ
$ unset a[123]

# OK
$ unset 'a[123]'

■ B2. Bash 算術式のバグたちとその避け方

Bash の過去の version には算術式にまつわるバグさんがたくさん潜んでいます。可搬な算術式を書くためにはそれらのバグさんたちを巧妙に避けなければなりません。bash-3.0 未満は試したことがないので、ここでは bash-3.0 以降について取り扱います。bash-3.0 未満の算術式は誰か開拓してください!

B2.1 :boom: 特定の式でシェルがクラッシュする (bash-4.2)

bash-4.2 固有のバグ (bug-bash 1, bug-bash 2) です。例として以下の式でシェルごとクラッシュします。作業中だとデータが飛ぶので、試すときは注意して下さい

対話シェル
$ ((a=b[0],c=0))
Segmentation fault (コアダンプ)

一体どういう条件でこのクラッシュが起こる・起こらないのかよく分かっていないのですが、配列要素を参照した後に特定の式があると発生するようです。経験上分かっていることは以下の通りです:

  • 見た目の式の構造だけに依存して発生し、どの変数が定義されているか・変数にどのような値が入っているかには依らない
  • 問題のある式構造を評価すると必ずクラッシュする。一回試してクラッシュしなければ、その式は安全である。

:ballot_box_with_check: 少なくとも一回実行してみて問題なければOKです。クラッシュする様ならば算術式を一旦切りましょう。

対話シェル
$ ((a=b[0])); ((c=0)) # OK

一々安全かどうか試すのが面倒であれば、配列要素を参照したら必ず算術式を一旦切ることにするのが確実です。

B2.2 :boom: 条件演算子式の変数内の算術式評価が常に実行される (bash-3.0 ~ bash-4.4.7)

条件演算子式の分岐内に変数または配列要素を直接指定すると (条件が満たされていない場合でも) 変数内部の算術式評価が必ず実行されます。具体例を見ます:

$ expr='a=1'; ((a=0,0?expr:0)); echo $a
1
$ expr='a=1'; ((a=0,1?0:expr)); echo $a
1
$ expr='a=1'; ((a=0,0?b[expr]:0)); echo $a
1
$ ((a=0,0?b[a=1]:0)); echo $a
1

実行されない筈の分岐内に expr を指定しているにも拘らず、必ず expr の中身 a=1 が実行されてしまっています。

:ballot_box_with_check: これの解決方法は簡単です。条件演算子式の分岐を括弧で囲むだけです。

$ expr='a=1'; ((a=0,0?(expr):0)); echo $a
0
$ expr='a=1'; ((a=0,1?0:(expr))); echo $a
0
$ expr='a=1'; ((a=0,0?(b[expr]):0)); echo $a
0
$ ((a=0,0?(b[a=1]):0)); echo $a
0

より厳密に書くと、条件演算子の分岐内に、算術式評価で副作用を伴う可能性のある変数・配列要素参照がある場合、分岐全体を括弧で囲むという事になりますが、考えるのが面倒であれば常に分岐を括弧で囲むように習慣づければ良いでしょう。

(※何時まで経っても修正されないので、これを期に bash に修正を送りました (bug-bash 3)。たぶん、bash-4.5 で直りますbash-4.4.8 パッチ として取り込まれました)

B2.3 :boom: 条件演算子の直列ができない (bash-3.1 以下)

bash-3.1 以下
$ echo $((i==0?2: i==1?3: 1))
syntax error in expression (error token is "?3: 1")

なんと文法エラーに!!

:ballot_box_with_check: これも括弧で囲めば問題ありません。厳密には条件演算子式の分岐内に、更に条件演算子式を入れる場合には、分岐全体を括弧で囲むです。

可搬コード
$ echo $((i==0?2:(i==1?3: 1)))
1

しかし条件分岐が沢山あるとどんどん括弧のネストが深くなって行きます。Lisp を書いているような気分に…(Lisp では、演算子の類が全部括弧になる代わりに、条件分岐に関しては cond があるので括弧の増え方が違いますが)。

条件演算子を複数含む算術式の例
((
  code<0xA0?(
    ret=1
  ):(0x3100<=code&&code<0xA4D0||0xAC00<=code&&code<0xD7A4?(
    ret=2
  ):(0x2000<=code&&code<0x2700?(
    tIndex=0x0100+code-0x2000
  ):(
    al=code&0xFF,
    ah=code/256,
    ah==0x00?(
      tIndex=al
    ):(ah==0x03?(
      ret=0xFF&((al-0x91)&~0x20),
      ret=ret<25&&ret!=17?2:1
    ):(ah==0x04?(
      ret=al==1||0x10<=al&&al<=0x50||al==0x51?2:1
    ):(ah==0x11?(
      ret=al<0x60?2:1
    ):(ah==0x2e?(
      ret=al>=0x80?2:1
    ):(ah==0x2f?(
      ret=2
    ):(ah==0x30?(
      ret=al!=0x3f?2:1
    ):(ah==0xf9||ah==0xfa?(
      ret=2
    ):(ah==0xfe?(
      ret=0x30<=al&&al<0x70?2:1
    ):(ah==0xff?(
      ret=0x01<=al&&al<0x61||0xE0<=al&&al<=0xE7?2:1
    ):(ret=1))))))))))
  )))
))

B2.4 :boom: 条件分岐中の配列要素参照が必ず実行される (bash-3.2 ~ 4.1)

詳しく書くと、条件&&分岐, 条件||分岐, 条件?分岐:分岐 などの分岐の中に、配列要素を参照する式 (代入式以外) が含まれていると、その式以降の分岐内の全ての式が無条件に実行されてしまいます。

例えば、以下の 0&&~ 部分は実行されないはずなのに、実際には a=1 が実行されています。

bash-3.2~4.1
$ ((a=0,0&&(arr[0],a=1))); echo $a
1

:ballot_box_with_check: これの綺麗な回避方法は…不明です。取り敢えず、分岐内に配列要素の参照がある場合は、算術式を一旦切るようにすれば大丈夫です。

可搬コード
$ ((a=0,0)) && ((arr[0],a=1)); echo $a
0

しかし、これは結構強い制限です。条件分岐毎に算術式を切るのが嫌ならば、面倒ですが一応以下のように問題を回避することも可能ではあります:

可搬コード
$ sub=(0 'arr[0],a=1'); (('a=0,cond=0,sub[!!cond]')); echo $a
0

B2.5 :boom: 変数展開の配列添字が2回算術式評価される (bash-3.0 ~ 4.1)

例えば ${a[算術式]} などと書いた場合、算術式はただ一回だけ評価するというのが自然な仕様です。しかし、${a[算術式]##...} などの一部の形式で算術式が2回評価されてしまいます。特に算術式が副作用を持つようなものであった場合に問題になります。

bash-3.0~4.1
$ i=0; : ${a[i++]##*/}; echo $i
2

:ballot_box_with_check: 変数展開の配列添え字に副作用のある式を指定しなければ問題ありません。

可搬コード
$ i=0; : ${a[i]##*/}; ((i++)); echo $i
1

B3. まとめ

まとめに関しては親記事 §2、§4を御覧ください。

26
20
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
26
20