8.3 制御に潜むバグ

(1/1)
倒れるプログラムのキャラクター
JavaScriptを含むプログラミング言語には制御という仕組みが備わっており、条件によって計算式を切り換えたり、同じ計算式を繰り返し実行することができる、と「3.1 if~else文」の冒頭で紹介した。ところが、プログラムのバグ(不具合)の多くは、この制御が不適切なことによって起きる。制御に変数が使われている場合が多く、テストケースが漏れているとプログラムを実際に運用してはじめて不具合が発覚するということが出てくる。
そこで今回は、制御に潜むバグの代表例を取り上げ、こうしたバグを予防する方法を紹介する。

目次

サンプル・プログラム

条件には順序がある

正確なうるう年判定 - 3.1 if~else文」では、うるう年を正しく判定する if~else文 を紹介した。
一方、if~else文 の順序を変えた次のプログラムでは、西暦1900年もうるう年になってしまうというプログラムのバグが発生する。
このことから、条件には順序があることが分かる。

  19: <script>
  20: /**
  21:  * 判定と画面表示
  22: */
  23: function ifElse5() {
  24:     //変数宣言
  25:     let year = document.getElementById('year').value;   //西暦年
  26:     year = parseInt(year, 10);                          //10進整数化
  27:     let ret;
  28: 
  29:     //うるう年判定(順序の間違い)
  30:     if (year % 4 == 0) {
  31:         ret = 'うるう年';
  32:     } else if (year % 100 == 0) {
  33:         ret = '平年';
  34:     } else if (year % 400 == 0) {
  35:         ret = 'うるう年';
  36:     } else {
  37:         ret = '平年';
  38:     }
  39: 
  40:     //結果を表示する
  41:     document.getElementById('ret').innerHTML = ret;
  42: }
  43: </script>

条件制御
条件制御を書くときには、ベン図で考えるといい。
うるう年のケースでは、年を要素とするベン図を描いてみる。すると、左図のように入れ子になる。

  19: <script>
  20: //2次元配列:各配列の1番目は作物名,2番目以降は種類
  21: let Produces = [
  22:     ["キャベツ", "野菜"],
  23:     ["キュウリ", "野菜"],
  24:     ["リンゴ", "果物"],
  25:     ["ミカン", "果物"],
  26:     ["トマト", "野菜", "果物"],
  27: ];
  28: 
  29: /**
  30:  * 指定した作物が指定した種類かどうかを判定する.
  31:  * produceに作物名を,categoryに種類を代入し,配列Producesと照合する.
  32:  * Producesに未登録の作物についてもfalseを返す.
  33: */
  34: function isCategoryProduce(produce, category) {
  35:     for (let item of Produces) {
  36:         //作物名が見つかった
  37:         if (item[0] == produce) {
  38:             for (let i = 1i < item.lengthi++) {
  39:                 //種類が見つかった
  40:                 if (item[i] == category) {
  41:                     return true;
  42:                 }
  43:             }
  44:         }
  45:     }
  46:     return false;
  47: }
  48: 
  49: /**
  50:  * 入力した作物の種類を判定し,画面に表示する.
  51: */
  52: function ifElse6() {
  53:     //作物名
  54:     let produce = document.getElementById('produce').value;
  55: 
  56:     if (isCategoryProduce(produce, "野菜"&& isCategoryProduce(produce, "果物")) {
  57:         ret = "野菜かつ果物";
  58:     } else if (isCategoryProduce(produce, "野菜")) {
  59:         ret = "野菜";
  60:     } else if (isCategoryProduce(produce, "果物")) {
  61:         ret = "果物";
  62:     } else {
  63:         ret = "未登録";
  64:     }
  65: 
  66:     //判定結果を表示する
  67:     document.getElementById('ret').innerHTML = ret;
  68: }
  69: </script>

条件制御
次に、指定した作物が野菜か果物かを判定する条件制御を考えてみる。ベン図で描くと、左図のように部分集合が重なる形になる。
この場合も、部分集合が小さなもの(この場合は積集合)から大きなものへ向かって順に if~else文 を書いてゆく。"野菜" と "果物" の集合の大きさは不定なので、これらの順序はどちらが先でも構わない。未登録の作物は、最後に else として扱う。
原則:条件制御は、小さな部分集合から大きな部分集合へ向かって順に並べる。

条件の抜け漏れ

3.2 switch~case文」では、月の大小を求める switch~case文 を紹介した。switch~case文 も条件制御の一種だが、この場合、1~12月の12通りの条件がある。さらに条件数が多い条件制御では、条件の抜け漏れが出てくる可能性が高まるだろう。
そこで条件の抜け漏れがないことを単体テストでチェックできるよう、独立した関数にしてしまう方法がある。

  20: /**
  21:  * 指定した月が大かどうかを判定する.
  22:  * @param   int month 月(1~12)
  23:  * @return  bool true:大の月/false:小の月/null:monthが定義域外
  24: */
  25: function isLongMonth(month) {
  26:     //月の大小を配列にしておく
  27:     months = new Array();
  28:     months[1]  = true;
  29:     months[2]  = false;
  30:     months[3]  = true;
  31:     months[4]  = false;
  32:     months[5]  = true;
  33:     months[6]  = false;
  34:     months[7]  = true;
  35:     months[8]  = true;
  36:     months[9]  = false;
  37:     months[10] = true;
  38:     months[11] = false;
  39:     months[12] = true;
  40: 
  41:     let ret = null;
  42:     //monthsの中で月が一致するものを探して判定する
  43:     for (let m = 1m <months.lengthm++) {
  44:         if (m == month) {
  45:             ret = months[m];
  46:             break;
  47:         }
  48:     }
  49:     return ret;
  50: }

ユーザー定義関数 isLongMonth は、指定した月 month が大の月である場合はtrueを、小の月ならfalseを、monthが定義域外の時はnullを返す。
さらに、各々の条件が独立している(和集合がない)ことから、isLongMonth の中では、1~12月の大小定義を配列 months に持たせておき、forループを使って指定した月 month と合致するものの結果を返すようにしている。
条件を配列にすることで全体の見通しが良くなり、抜け漏れを防止することができる。
原則:条件が多い場合は、関数として分離して単体テストで抜け漏れを防ぐ。
原則:各々の条件が独立している場合は、配列を利用して見通しをよくする。

無限ループ

8.2 入力データを疑う」で無限ループの事例を挙げたが、これ以外にも無限ループに陥るケースが幾つかある。
たとえば近似計算法の一種である「3.4 whileループ - ニュートン法」だが、例で採り上げた平方根の場合はリニアに近似解が求められるのでいいのだが、ものによっては近似解が振動するものがある。
JavaScriptではなくPHPプログラムになるが、「PHPで月齢を計算」で紹介した、次の朔の日時を求める関数がそれである。しかも、振動が収束するとは限らず、ループを繰り返すとかえって拡散するケースがある。
このように振動するケースでは、whileループ が無限ループに陥る可能性がある。しかも、かならず無限ループになるわけではないというのが厄介なところだ。

ここで、ループ制御を別の視点から見てみよう。
forループ にしても whileループにしても、カウンタ変数を置いて、それが一様に増えていく(または減っていく)ことを前提に制御しているものは安全にループから脱出できる。逆に、振動するようなカウンタ変数(上述のケースでは誤差変数)をループ制御の終了条件とするのは、リスクが高い。
そこで、次の朔の日時を求める関数では、別にカウンタ変数を置いて、for文で繰り返す最大回数を制限することで無限ループに陥ることを回避した。
得られる結果の誤差は大きいかもしれない。しかし、誤差が大きいことを利用者に提示できれば、無限ループに陥ってシステムダウンするよりはマシであろう。

原則:ループ制御のカウンタは振動しないものを選ぶ。

また、カウンタ変数を使わず、「読みやすいプログラム:ループ処理」で紹介した方法を適用してみるのも有効な手段だ。

原則:ループ処理では、for...in, forEachメソッド, mapメソッドの適用を優先する。

コラム:その制御は必要か?

タブレットで説明する人のイラスト(男性)
DX(Digital Transformation)が流行っている。ノーコード/ローコードで誰でもプログラムを書けるという――Microsoft Power Platformはローコードでクラウド連携アプリを書くことができる。開発環境をインストールすることなく、ライセンスだけあればブラウザで開発でき、とても便利なツールだ。
だが、条件分岐やループ制御を書こうとすると、途端に面倒なことになる。これはどうしたことか――。
冒頭に述べたように、制御にはバグが隠れていることが多い。そして、プログラム初心者にとって、制御は難解な概念だ。
なぜ難解かといえば、実際の仕事や日常生活で制御を使うことはあまり多くないからだ。たとえば仕事上で何かを申請するときは、条件分岐がせいぜい2つか3つで、それに応じて申請書式が変わることが多い。つまり、条件制御するのではなく、入力画面を変えればいいのだ。
ループ制御に至っては、そんな業務があったら上司から省力化(リニア化)しろと言うに違いない。
だから、プログラム初心者は制御のイメージが頭に思い浮かばない。

ローコード/ノーコードは、利用者が利用者視線でツールを用意して業務の効率化・改善に繋げようという目論見から始まっているから、制御がなくても支障ないのである。
むしろ、われわれプログラマの方が、本当にその制御は必要なのか、省くことはできないのか――と考えた方が読みやすいプログラムに繋がる。本当に制御が必要な部分は独立させ、時間をかけて開発・テストを実施したうえで、DXツールとコネクトするといい。
(この項おわり)
header