Bashシェルスクリプトで数値計算を行うとスクリプトが強制終了される問題でハマった話

2023-03-27Bash

はじめに

Web 上の競技プログラミングや 100 本ノックの問題をシェルで問いているときにハマってしまいました。

原因がわかれば「ああなるほど」と思いました。

ぱっと見、問題がないのですが、なぜだかスクリプトが強制終了してしまいます。

検証環境

$ uname -moi
x86_64 x86_64 GNU/Linux

$ head -n 2 /etc/os-release
NAME="CentOS Linux"
VERSION="7 (Core)"

$ bash -version | head -n 1
GNU bash, version 4.2.46(2)-release (x86_64-redhat-linux-gnu)

問題の発生したスクリプト

実際のスクリプトとは異なりますが、同じ問題を孕んだスクリプトを掲載します。

以下のスクリプトを test.sh という名前で保存しておきます。

#!/usr/bin/env bash
set -o errexit

SUM=0

for ((I = 0; I < 5; I++)); do
  ((SUM += I))
done

echo "$SUM"

set -o errexitset -e と同義です。
スクリプトのいずれかの行でエラーが発生した場に処理を中断するオプションになります。

スクリプトは単純です。
ループカウンタ I は 0〜4 までカウントしていきます。
その値をすべて SUM という変数に加算していきます。

結果は 0+1+2+3+4 = 10 なので、 10 と表示される想定です。

実行してみます。

# 実行しても何も表示されない。
$ ./test.sh

$

実行しても何も表示されません。

最初は echo する変数誤りかと思った。

最初は echo する変数が誤っていたのかな?と思いましたが、何度見ても SUM という変数で間違いありません。

もしかして、 ((...)) という構文が問題?

次に考えたのが、 ((...)) という構文の使い方の誤り。

数値演算するとなにかおかしくなるんだったっけ?と思いましたが、コマンドラインで以下のようなコマンドを実行しても問題なく動作します。

$ X=0

$ ((X+=1))

$ ((X+=2))

$ echo $X
$ 3

困ったらデバッグしてみる

困ったのでデバッグしてみます。
シェルスクリプトをデバッグするときには bash -x test.sh のように bash -x で実行してやるのが良いです。

$ bash -x test.sh
+ set -o errexit
+ SUM=0
+ (( I = 0 ))
+ (( I < 5 ))
+ (( SUM += I ))

どうやら、1 回目のループは実行されたようです。

(( SUM += I )) の部分で何故かループが終了してしまっていました。

少し頭を捻ったあと、「はっ!」と気が付きました。

((SUM += I)) の実行結果が 0 の場合、ループが終了してしまう

思うところがあり、以下のように I の初期値を 1 に変更して再実行してみました。

#!/usr/bin/env bash
set -o errexit

SUM=1

for ((I = 1; I < 5; I++)); do
  ((SUM += I))
done

echo "$SUM"

実行してみます。

$ ./test.sh
10

うまく行けました!
もう1つのケースについても確認です。
I の初期値は 0 のまま、 set -o errexit の行をコメントアウトして実行してみます。

#!/usr/bin/env bash
# set -o errexit

SUM=0

for ((I = 0; I < 5; I++)); do
  ((SUM += I))
done

echo "$SUM"

実行してみます。

$ ./test.sh
10

行けました!
原因がわかりました。

set -o errexit ( set -e ) オプションを有効にしていると、 ((...)) の演算結果が 0 となると強制停止する

set -o errexit オプションを有効にしている場合、、 ((...)) の演算結果が 0 となると強制停止してしまうのです。

以下のスクリプトを test2.sh というファイル名で保存します。

#!/usr/bin/env bash
set -o errexit

X=0

((X = 1))
echo $X
((X = -1))
echo $X
((X = 0))
echo $X

実行してみましょう。

$ ./test2.sh
1
-1

X0 となる演算行で処理が中断してしまいます。

それでは、この問題に対処していきましょう。

対処法 1

計算している行が 0 となってしまうことが原因なので、 0 ( false ) とならないようにしてやります。

#!/usr/bin/env bash
set -o errexit

SUM=0

for ((I = 0; I < 5; I++)); do
  # ((SUM += I)) && true でも同様
  ((SUM += I)) && :
done

echo "$SUM"

test.sh として保存し、実行してみます。

$ ./test.sh
10

ループの中で実行ステータス $?echo する処理を追加してみると、より動きがわかりやすくなります。

#!/usr/bin/env bash
set -o errexit

SUM=0

for ((I = 0; I < 5; I++)); do
  ((SUM += I)) && :
  echo $?    # <<< この行を追加
done

実行してみます。

$ ./test.sh
1     # << ループ1回目
0     # << ループ2回目
0     # << ループ3回目
0     # << ループ4回目
0     # << ループ5回目

初回のみ実行結果が 1 ( = false ) となっていますが、処理は中断せず実行されます。

ちなみに && : の部分を || : と記述してしまうと、 コマンドの実行結果がエラーとなっていても直後の $? で判定できません

「エラーが発生しても、無視して大丈夫!」という場合は || : でも問題ありません。

#!/usr/bin/env bash
set -o errexit

SUM=0

for ((I = 0; I < 5; I++)); do
  ((SUM += I)) || :  # <<< && ではなく || を利用した場合
  echo $?
done

実行してみます。

$ ./test.sh
0     # << ループ1回目
0     # << ループ2回目
0     # << ループ3回目
0     # << ループ4回目
0     # << ループ5回目

コードの修正量は少なく個人的にはこの方法が好きなのですが、 コードを読んだ他のエンジニアから && : ってなに?」 という質問が飛ぶでしょう。

対処法 2

set +e で一時的にオプションを無効化し、 set -e で再度有効化します。

#!/usr/bin/env bash
set -o errexit
set -o nounset

SUM=0

for ((I = 0; I < 5; I++)); do
  set +e
  ((SUM += I))
  set -e
done

echo "$SUM"

実行してみます。

$ ./test.sh
10

こちらのほうがやりたかったことは明確なのですが、なんだかなぁという気もします。
オプション無効にしたり、有効にしたりというのが場当たり的というか。

対処方 3

((...)) を使わなかったらそもそも問題となりません。

#!/usr/bin/env bash
set -o errexit

SUM=0

for ((I = 0; I < 5; I++)); do
  SUM=$((SUM + I))
done

echo "$SUM"

結局この方法が一番確かなのか。。。

ひとこと

((...)) 構文、便利なんですけどね。
+=-=*=%= といった競技プログラミングでありそうな処理を書くときには活躍するんですが、 set -o errexit ( set -e ) との相性が悪いですね。

競技プログラミングで set -o errexit 使わなきゃいい、という話でもありますが。

2023-03-27Bash