経緯
paizaで問題を解いていて、if文の条件式の中の変数を条件に応じてインクリメントしたい場面に遭遇。そんなときのやらかしを共有します。
急いでいる人向けに先に結論
このようなシチュエーション、おとなしく処理ブロック内にインクリメントを記述しましょう。 どうしてそうすべきなのかをここから例題を用いながら説明していきたいと思います。
今回使う例題
ちょっと詳しい問題の内容は忘れてしまったので、今回は作った例題を用いて説明していきます。
問題文
RPGゲームのログテキスト(文字列配列)を解析するプログラムを作っています。
入力を順番にチェックし、"skip" という文字を見つけたら、その直後にある要素は処理を飛ばして次に進みたいです。スキップされず実行されるログを出力してください。
入力例と想定される出力
1行目に入力の回数、2行目に入力が半角スペース区切りで与えられます。
// 入力
5
attack defence skip run run
// 出力
attack
defence
skip
run
実際にやってみる
今回は与えられる文字列をリストに格納し、if文で"skip"の有無をチェックしつつ、while文でログを出力するという方針でやってみます。
問題を見た僕はこんな感じで書きました。
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
Scanner sc = new Scanner(System.in);
// 入力回数受け取り
int n = sc.nextInt();
// 配列にコマンドを格納
String[] logs = new String[n];
for (int i = 0; i < n; i++){
logs[i] = sc.next();
}
// 配列の各要素で"skip"なら2回インクリメント、"skip"以外なら1回インクリメント
int i = 0;
while (i < n) {
if (logs[i++].equals("skip")) {
System.out.println(logs[i]);
} else {
System.out.println(logs[i]);
}
i++;
}
}
}
実行してみると、、、
// 出力
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5
at Main.main(Main.java:19)
エラーになってしまいました。エラー文を読むとSystem.out.println(logs[i])の部分で存在しないlogs[5]を参照してしまっているようです。いったんログの出力をコメントアウトしてiの動きだけ追ってみると、
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
Scanner sc = new Scanner(System.in);
//入力回数受け取り
int n = sc.nextInt();
//配列にコマンドを格納
String[] logs = new String[n];
for (int i = 0; i < n; i++){
logs[i] = sc.next();
}
// 配列の各要素で"skip"なら2回インクリメント、"skip"以外なら1回インクリメント
int i = 0;
while (i < n) {
if (logs[i++].equals("skip")) {
// System.out.println(logs[i]);
} else {
// System.out.println(logs[i]);
}
i++;
System.out.println(i);
}
}
}
//出力
2
4
6
想定していた挙動ではiは0→1→2→4と遷移するはずなのに毎回2回インクリメントされてしまっています。ちょっと調べてみるとこんな内容が。
条件式の中のインクリメントは式の結果(trueかfalseか)にかかわらず実行される
つまり、プログラムが条件式を参照したタイミングでインクリメントが行われてしまうのです。よって、さっきのコードだとこのようにiが遷移してしまうわけです。
| 周回 | 現在のインデックス (i) |
評価される要素 (logs[i]) |
① 条件式での評価と i の変化 |
② ループ最後の i++
|
次の周の i
|
|---|---|---|---|---|---|
| 1周目 | 0 |
logs[0] ("attack") |
false になり、直後に i=1
|
i=2 になる |
2 |
| 2周目 | 2 |
logs[2] ("skip") |
true になり、直後に i=3
|
i=4 になる |
4 |
| 3周目 | 4 |
logs[4] ("run") |
false になり、直後に i=5
|
i=6 になる |
➔ 終了 |
つまり条件式の中にインクリメントを書いたのが諸悪の根源だったわけですね。条件式のなかは"skip"のチェック、処理ブロックの中でインクリメントとすみわけして書くと、
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
String[] logs = new String[n];
for (int i = 0; i < n; i++){
logs[i] = sc.next();
}
int i = 0;
while (i < n) {
if (logs[i].equals("skip")) {
System.out.println(logs[i]);
i += 2;
} else {
System.out.println(logs[i]);
i++;
}
}
}
}
//出力
attack
defence
skip
run
はい。想定通りの出力になりました笑
というわけで1行で書けると横着せずに、条件式の中で演算を行うのは控えましょう。分けて書いたほうが読みやすいしね!
まとめ
条件式の中で演算を行うのはやめましょう!理由は以下の通りです。
条件の適合に関係なく、条件式を参照したタイミングで演算が行われてしまうので想定外の挙動をする可能性が高い
おまけ
if文に複数の条件式をANDでつないで書いた場合に、条件式内のインクリメントはどのように処理されるのでしょうか。例えばこんなコード。
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
String[] logs = new String[n];
for (int i = 0; i < n; i++){
logs[i] = sc.next();
}
int i = 0;
while (i < n) {
// if文にも i < n の条件を追加し、そこでインクリメント
if (logs[i].equals("skip") && i++ < n) {
System.out.println(logs[i] + " " + i); // ログとiを一緒に出力
} else {
System.out.println(logs[i] + " " + i);
}
i++;
}
}
}
//出力
attack 0
defence 1
run 3
run 4
さきほどは条件式が参照されたタイミングでインクリメントが行われて、2つずつiが増えていましたが、今回はそうでないようです。
これにはif文に複数条件をANDで結んだときの処理の順番が関係しています。
条件式は左から順に評価される。最初に条件を満たさなくなった時点で処理から出る。
当たり前のことでも意外と抜けやすいですよね。今回の例だとlogs[i].equals("skip")をクリアして初めてi++ < nのインクリメントが行われるわけです。つまり、iの遷移は以下の表のようになります。
| 周回 | 現在のインデックス (i) |
評価される要素 (logs[i]) |
① 条件式での評価と i の変化 |
② ループ最後の i++
|
次の周の i
|
|---|---|---|---|---|---|
| 1周目 | 0 |
logs[0] ("attack") |
false になるので、そのままlogs[0]を出力 |
i=1 になる |
1 |
| 2周目 | 1 |
logs[1] ("defence") |
false になるので、そのままlogs[1]を出力 |
i=2 になる |
2 |
| 3周目 | 2 |
logs[2] ("skip") |
true になり、直後にインクリメントしてlogs[3]を出力 |
i=4 になる |
4 |
| 4周目 | 4 |
logs[4]("run") |
falseになるので、そのままlogs[4]を出力 |
i=5 になる |
終了 |
というわけでANDでつないだ条件式のなかで演算を行うと、前の条件の結果によって演算が行われたり行われなかったりするバグの温床が生まれてしまいます。逆にうまくANDの条件式の順番を設定してやれば(厳しめの条件から書けば)計算量を減らすこともできますね!