擬似コード (Pseudocode)
プログラムを作成する場合、直接 Java のプログラムを書くと何をしたいか分からなくなる場合が多い。
通常、何か問題を解決しようとする場合、自然言語で物事を考えることが多い。本演習では本来自然言語で考えている事象や問題を、わざわざプログラミング言語の一つである Java に変換している。
自然言語と一般的なプログラミング言語の違いを以下にまとめる。
言語 | 利点 | 欠点 |
---|---|---|
自然言語 | 日頃から使用しているため使いやすい、表現力が高いため物事を伝えるのに向いている | 曖昧である、コンパイルできない、など |
プログラミング言語 | 曖昧さが(ほとんど)ない、コンパイルして実行可能な形式に変換できる | 冗長で読みにくい、表現力が低い、(普通は)日常的に使わない |
自然言語とプログラミング言語は大きく形が異なるため、それらを相互に変換するには様々なことを考慮してやる必要がある。しかし、様々なことを考慮しているうちに、本来の目的である「解決しようとしている問題」が分からなくなってしまう可能性がある。
そこで用いるのが擬似コードである。擬似コードは自然言語と通常のプログラミング言語を混ぜたようなコード (プログラム) で、プログラムの処理や流れを簡単に記述するものである。プログラミング言語と違ってコンパイルできる必要はなく、アイデアを整理したり他人に伝えるために用いる。
例えば、「入力された2つの値のうち、大きいものを返す」ようなメソッドを考えた場合、自然言語、擬似コード、プログラミング言語 (Java) ではそれぞれ以下のように記述できる。
// 自然言語 入力された2つの値のうち、大きいものを返す
// 擬似コード max-of (a, b) if a のほうが b より大きい a を返す else b を返す
// プログラミング言語 (Java) public class Max { public static int maxOf(int a, int b) { if (a > b) { return a; } else { return b; } } }
自然言語→擬似コード→プログラミング言語 の順に文字数が多くなっていることが確認できる。これはこの順にあいまいさを無くしているためであって、指示を明確にしているためである。
また、上記の擬似コードは一例であり、必要な情報を正しく与えることができれば記法は問わない。例えば、以下のように書いても意味を読み取ることができるはずである。
// 擬似コード (2) - ほとんどJava maxOf(a,b) { if (a > b) { return a } else { return b } }
// 擬似コード (3) - ほとんど自然言語 大きいものを返すメソッド 入力: a, b もし (a のほうが b より大きい) ならば 結果は a そうでなければ 結果は b
擬似コードの形式は問わないが、解決しようとしている問題を「表現しやすい」ことと「プログラミング言語で再表現しやすい」ことが重要である。
そのような点から、一般的なアルゴリズム辞典では、擬似コードを用いてアルゴリズムの説明がなされている (アルゴリズム - 算法、演算手順のこと)。これによって、読みやすく、またプログラミング言語で再表現しやすい形で記載されている。
擬似コードの基本
擬似コードは「コンピュータが理解できるプログラム」で表現することを目標にするのではなく「人間が理解できる作業手順」で表現することを目標とすべきである。つまり、擬似コードに従えば、紙と鉛筆を用いて略解 (正解) を得られるように書くことが要求されている。
一般的には特定のプログラミング言語にとらわれずに書くべきであるが、本演習では Java を書き続けることになるので Java に近い擬似コードを書いても一向に構わない。ただし、その際においても Java そのもので書くべきではないし、Java特有の記述はできるだけしないほうがよい。
また、処理手順を中心に考えたコードを作成すべきである。したがって、各プログラミング言語に依存する、変数の型 (整数や実数など) についてほとんどの場合では考えなくてもよい。変数の型はJavaなどのプログラミング言語では非常に重要となるが、処理手順を理解する上では特に重要ではない。
ただし、型を考えない際に注意すべき点は、例えば Java では int 型の値に対して割り算を行うと、小数点以下が切り捨てられる。「切り捨て」を行うことがそのプログラムの中で必須である場合、それは擬似コードの中で明示的に書いてやるべきである。
また、決まりきった処理は、その処理の概略を自然言語で書いてやるのも良い。例えば、コンソールからの入力を取得する場合、Javaでは以下のようにいくつかの手続きを踏む必要がある。
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); int input1 = Integer.parseInt(reader.readLine()); int input2 = Double.parseDouble(reader.readLine());
これはJava特有のもので、他の言語を使用する場合は違ったプログラムになるし、非常に長くて分かりづらい。そこで、擬似コードでは簡単に自然言語で書いてしまう。
input1 = 入力された値 (整数) input2 = 入力された値 (実数)
上記のようにすれば、「何を行いたいか」ということが一目瞭然となる。
また、コンソールへ文字列を出力する場合は以下のように書いていた。
System.out.println("文字列");
これもJava特有のものである。そこで、簡単に以下のように書けばよい。
print "文字列"
これらを踏まえて、「コンソールに入力された値の絶対値を表示する」プログラムの擬似コードを考えた場合、以下のように書ける。
input = 入力された値 print |input|
Javaでこれを表現しようとした際、数学の絶対値記号である | ~ | は存在しない。Java のコードで表現しようとする場合には Math.abs(int) というメソッドを利用することにする。これは引数に与えられた値の絶対値を返すメソッドである。
package j1.lesson10; import java.io.*; public class Absolute { public static void main(String[] args) throws IOException { BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); int input = Integer.parseInt(reader.readLine()); System.out.println(Math.abs(input)); } }
擬似コードの作成
大まかな流れ
以下のような流れで問題を解決するための擬似コードを作成する
- 問題を分析する
- 問題を解決する手順を考える
- 概略レベルの擬似コードを作成する
- 問題を解決する手順に名前をつける
- 問題を解決する手順を詳細化する
- 詳細レベルの擬似コードを作成する
実際にはもっと様々な手順を踏む必要があるが、現状では上記程度の手順で構わない。
注意すべき点
- 処理を正確に、詳細に説明すること
- 特定のプログラミング言語に依存しない
- ただし、分岐 (if-else) や繰り返し (for, while) などはプログラミング言語の文法を使うことが多い
- プログラムする方法 (実装手段) ではなく、プログラムにしたいこと (目的) を中心に考える
上記の点に注意し、特定の言語に依存しないで書けば、何らかのプログラミング言語を少し理解しているだけで読めるような擬似コードを作成できる。
擬似コードの作成例 - ハノイの塔
課題0904(ハノイの塔)にある、ハノイの塔という問題を解決するプログラムを考えてみる。
「ハノイの塔」というのは、次のような問題である。
3つの柱がある。そのうちの1つの柱に穴のあいた円盤を何枚か通して積み重ねてある。それらの円盤は下のものほど大きく、上のものほど小さい。そのすべての円盤を以下の規則に従って動かし、指定した柱に円盤を全て移動させる。
ただし、
- 円盤は、一度に一つだけ移動させることができる
- 円盤は、それより小さな円盤の上に積むことはできない
まず、問題を分析して、解決する手順を考える。
詳細な手順は省くとして、ヒントを元に次のような手順で行う。
Step #0 - 最初に塔1にn段の円盤が積みあがっている
Step #1 - 塔1から塔2へ、n-1段の円盤を移動する
Step #2 - 塔1から塔3へ、(n段目の) 円盤を移動する
Step #3 - 塔2から塔3へ、n-1段の円盤を移動する
このように n 段の円盤を 1 (start) から 3 (target) へ移動するには、n-1 段までをいったん 2 (through) へ移動する必要がある。このことを「1から2を経由して3へ移動する」と書くとすると (概略レベルの) 疑似コードで次のように書ける。
円盤 n-1 までを 1 から 2 へ移動 print "n: 1 -> 3" 円盤 n-1 までを 2 から 3 へ移動
そして、この擬似コードに名前をつけてみる。
円盤を移動(n, 1から2を経由して3へ) 円盤 n-1 までを 1 から 2 へ移動 print "n: 1 -> 3" 円盤 n-1 までを 2 から 3 へ移動
ところで、「円盤 n-1 までを 1 から 2 へ移動」する方法も同様に考えれば、円盤 n-2 までをいったん 3 へ移動しなければならないから、それは「円盤を移動(n-1, 1から3を経由して2へ)」になるはずである。
したがって、疑似コードは次のように書ける。
円盤を移動(n, 1から2を経由して3へ) 円盤を移動(n-1, 1から3を経由して2へ) print "n: 1 -> 3" 円盤を移動(n-1, 2から1を経由して3へ)
ここまでにできた概略レベルの擬似コードを詳細化してみると、擬似コードにつけられた名前である「円盤を移動」と、擬似コードの内部に出てくる「円盤を移動」は同じ形式で書けそうである。ただし、両者の「円盤を移動」はほんの少しだけ異なる (移動先が違う) ため、その違いを分析する。
Step #1 の 「塔1から塔2へ、n-1段の円盤を移動する」を分析する。
n 段目の円盤をまったく考えないものとすると、塔の番号 start -> target へ移動することは n 段のハノイの塔を解く手順と変わらない。ただし、through が target へ、target が through へと名前を変えている。
Step #3 の 「塔2から塔3へ、n-1段の円盤を移動する」を分析する。
n 段目の円盤をまったく考えないものとすると、塔の番号 start -> target へ移動することは n 段のハノイの塔を解く手順と変わらない。ただし、start が through へ、through が start へと名前を変えている。
そう考えると、この「ハノイの塔を解く手順」は、次のように書くことができそうである。
円盤を移動 (n, start, through, target) 円盤を移動 (n-1, start, target, through) print "n: start -> target" 円盤を移動 (n-1, through, start, target)
しかしこれではまだ完全ではなく、これを実行すると
円盤を移動(n, ...)
から始まって
円盤を移動(n-1, ...) 円盤を移動(n-2, ...) 円盤を移動(n-3, ...) ... 円盤を移動(n-∞, ...)
と無限に実行されることになる。それをどこかで止めなければならない。このような場合には、通常 n = 1 や n = 0 の場合に止めればよい (自分を呼び出す手続きの終着点を作ってやる)。
n = 1 で再帰的呼び出しを止める場合の擬似コードは、「1枚の円盤を移動する」という作業を再帰的呼び出しを用いずに書くことになる。n = 1 の場合は一番小さな円盤を移動させるだけであるから以下のような擬似コードになる。
円盤を移動 (n, start, through, target) if n が 1 である場合 (1段の円盤を移動) print "n: start -> target" 終了 円盤を移動 (n-1, start, target, through) print "n: start -> target" 円盤を移動 (n-1, through, start, target)
n = 0 で再帰的呼び出しを止める場合の擬似コードは、「0枚の円盤を移動する」という作業を再帰的呼び出しを用いずに書くことになる。0枚の円盤を移動するということは、何もしないということなのでそのまま戻ればよい。
円盤を移動 (n, start, through, target) if n が 0 である場合 何もせずに終了 円盤を移動 (n-1, start, target, through) print "n: start -> target" 円盤を移動 (n-1, through, start, target)
となる。どちらのプログラムでも実行結果は同じになるので、分かりやすい方を用いればよい。
数学的帰納法では、P(k)がすべての自然数について成り立つのを証明するのに、まず、P(1)が成り立つことを証明し (0も含める場合は、まず、P(0)が成り立つことを証明し) 次に、P(k-1)が成り立つならばP(k)が成り立つことを証明する。
これで、P(1), P(2), P(3), ...の順番に成り立つことが分かる。
再帰的メソッドでは、呼び出しはこの逆の順になる。
- P(k)のメソッドからはP(k-1)のメソッドを呼び出す。
- P(1)(またはP(0))では、1または0の場合の処理をして戻る。
ただし、戻る順番は P(1), P(2), P(3), ... の順番に戻る。
擬似コード作成の失敗
擬似コードの作成につまづいてしまった場合、問題の分析ができていないことが多い。対象の問題を既に解決している人がいないか、それに関する文献を紐解いてやるとよい。
このように、自分に足りないものが何であるのか、正しく把握することができる。
- 問題の分析ができていない
- 手順を詳細化できない
- Javaが分からない
足りないものが分かれば、それを補うことによって解決できる可能性が高くなる。
擬似コードからプログラム (Java) への変換
大まかな流れ
以下のような流れで擬似コードからプログラムを作成する。
- データ型を選択する
- 擬似コードの最初の行をコメントとして貼り付ける
- メソッドを宣言する
- メソッドの名前を考えてやる必要がある
- 擬似コードをメソッド内部にコメントとして貼り付ける
- 各コメントの下にプログラムを記入する
- プログラムをテストする
5の作業を行っている最中に、擬似コードにあいまいな表現があったり、1行の擬似コードが10行以上のプログラムになってしまう場合がある。その際には擬似コードを見直して詳細にしたり、その部分を別のメソッドとして作成してやるとよい。
このようにプログラムを開発することを、「段階的詳細化」と呼ぶことがある。これは一つの問題をいくつかの手順で表し、その手順をさらにいくつかの手順で表し…と、再帰的に詳細化していく手法である。
プログラムへの変換例 - ハノイの塔
先ほど作成した擬似コードを、プログラムへと変換する。
円盤を移動 (n, start, through, target) if n が 0 である場合 (0段の円盤を移動) 何もせずに終了 円盤を移動 (n-1, start, target, through) print "n: start -> target" 円盤を移動 (n-1, through, start, target)
まず、データ型を選択する必要がある。
- 「円盤を移動」するメソッドは、特に戻り値を必要としない。そこで、このようなメソッドの戻り値型は void である。
- 「円盤を移動」するメソッドの引数 n は 円盤の段数であるから、整数型を表す int でよい
- 「円盤を移動」するメソッドの引数 start は塔の番号であるから、整数型を表す int でよい
- 「円盤を移動」するメソッドの引数 through は塔の番号であるから、整数型を表す int でよい
- 「円盤を移動」するメソッドの引数 target は塔の番号であるから、整数型を表す int でよい
- それ以外に変数を使っていないので、以上である
次に、クラス内に擬似コードの名前をコメントとして貼り付ける。
public class Hanoi { // 円盤を移動 (n, start, through, target) }
貼り付けられたコメントを元に、メソッドを宣言する。メソッドの名前は moveDisk とでもしておく。
public class Hanoi { // 円盤を移動 (n, start, through, target) public static void moveDisk(int n, int start, int through, int target) { } }
擬似コードをメソッド内部にコメントとして貼り付ける。
public class Hanoi { // 円盤を移動 (n, start, through, target) public static void moveDisk(int n, int start, int through, int target) { // if n が 0 である場合 (0段の円盤を移動) // 何もせずに終了 // 円盤を移動 (n-1, start, target, through) // print "n: start -> target" // 円盤を移動 (n-1, through, start, target) } }
各コメントの下にプログラムを記入する。
public class Hanoi { // 円盤を移動 (n, start, through, target) public static void moveDisk(int n, int start, int through, int target) { // if n が 0 である場合 (0段の円盤を移動) if (n == 0) { // 何もせずに終了 return; } // 円盤を移動 (n-1, start, target, through) moveDisk(n-1, start, target, through); // print "n: start -> target" System.out.println(n + ": " + start + " -> " + target); // 円盤を移動 (n-1, through, start, target) moveDisk(n-1, through, start, target); } }
もし、ここから課題0904に沿ったプログラムに変更するならば、次のようにいくつかメソッドを足してやればよい。
package j1.lesson09; import java.io.*; public class Hanoi { public static void main(String[] args) throws IOException { BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); System.out.print("円盤の数を入力:"); int disks = Integer.parseInt(reader.readLine()); if (disks < 1) { System.out.println("1以上の整数を入力してください"); } else { hanoi(disks); } } public static void hanoi(int n) { moveDisk(n, 1, 2, 3); } // 円盤を移動 (n, start, through, target) public static void moveDisk(int n, int start, int through, int target) { // if n が 0 である場合 (0段の円盤を移動) if (n == 0) { // 何もせずに終了 return; } // 円盤を移動 (n-1, start, target, through) moveDisk(n-1, start, target, through); // print "n: start -> target" System.out.println(n + ": " + start + " -> " + target); // 円盤を移動 (n-1, through, start, target) moveDisk(n-1, through, start, target); } }
最後にテストを行うが、これは次の項で紹介する。
プログラム作成の失敗
正しい擬似コードを書いて、それを元にしてプログラムが作成できなかった場合、対象のプログラミング言語に対する知識が足りない場合が多い。対象とするプログラミング言語に関する文献を紐解いてやるとよい。
擬似コードの例
以下の擬似コードを実際に様々なプログラミング言語で表現してみる。
// 擬似コード max-of (a, b) if a のほうが b より大きい a を返す else b を返す
c FORTRAN integer function maxof(a, b) implicit NONE integer a integer b if (a > b) then maxof = a else maxof = b endif return end
/* C */ int max_of(int a, int b) { if (a > b) { return a; } else { return b; } }
# Perl sub max_of($$) { my ($a, $b) = @_; return $a if ($a > $b); return $b; }
; Lisp (defun max-of (a b) (if (> a b) a b))
(* OCaml *) let maxof a b = if (a > b) then a else b ;;
プログラムのテスト
本演習では、これまでに合計280項目 (optionalを含めると447項目) のテストをパスしてきている。
テストを行うことによってバグを発見しやすくなり、テストを通過することによって品質がある程度高いプログラムになる。
以降ではプログラムのテスト (ソフトウェアテスト) について簡単に紹介する。
機能テスト
機能テストとは、プログラム全体に対して一連の操作を行い、プログラム全体が正しく動いているかテストするものである (本演習では *-func.xml を実行するとこれが行われていた)。全体の機能テストを行うと、プログラムが要求された仕様に沿って作られているかチェックすることができる。
単体テスト
単体テストとは、各メソッドをいくつかのパラメータを与えて起動し、それぞれが正しく動いているかテストするものである (本演習では *-unit.xml を実行するとこれが行われていた)。各メソッドの単体テストを行うと、それぞれが要求された仕様に沿って作られているかチェックすることができる。
一般的に、単体テスト→機能テストの順に行う。これによって、まずプログラムの部分部分が正しく作成できているかチェックすることができる。これによって、どのメソッドでどんな問題があるのかチェックできる。その後、それぞれの部分を結合した際にエラーが発生していたら、結合した部分にエラーが含まれている可能性が高くなり、プログラムの問題を見つけるヒントになる。
テストケース
テストケースとは、「実施する処理の内容」「入力データ」「期待する結果」をセットにしたものである。つまり、「この入力に対して この処理を行えば このような結果が期待できる」といったものである。そして、このテストケースを実際に実施してみることによって「テスト」が行われる。
例えば、次のようなメソッドがあったとする。
public static int square(int x) { return x * x; }
これに対するテストケースは以下のようなものがある。
実施する処理の内容 | 入力データ | 期待する結果 |
---|---|---|
square(int)を実行 | -10 | 100 |
square(int)を実行 | -1 | 1 |
square(int)を実行 | 0 | 0 |
square(int)を実行 | 1 | 1 |
square(int)を実行 | 5 | 25 |
square(int)を実行 | 20 | 400 |
テストケースの実行方法
演習で行う。
テストケースはいくつ存在するか
結論から言うと、テストケースは無限に近いくらい存在する。まず、入力すべき値として 4294967296(232) 種類存在する。今回は引数を1つしか取らないが、2つ以上の引数をとるメソッドでは、指数関数的にテストケースが増えていく。さらにsquare(int)を過負荷な状態で実行したり、システムリソース極端に不足している状態などで実行すると、結果が変わるかもしれないため、さらにテストケースの個数が増えていく。
そこで、一般的にはテストケースのうち、意味のあるものを選んで実行することになる。次項の「テストの技法」では、テストケースを選択する際のヒントを紹介する。
テストの技法
テストの技法のうち、最低限必要な物を紹介する。
同値分割法
同値分割法とは、入力された値を「同じ処理をする範囲」ごとに区切って、それぞれを同値クラスとしてテストする技法である。これによって、それぞれの範囲で適切な処理が行われているか調べることができる。
次のようなメソッドを考える。
「0から6が与えられた場合は120, 7から64が与えられた場合は200, 65以上が与えられた場合は120, それ以外が与えられたら-1を返すメソッド」
public static int equiv(int n) { if (0 <= n && n <= 6) { return 120; } else if (7 <= n && n <= 64) { return 200; } else if (65 <= n) { return 120; } else { return -1; } }
このメソッドの入力値 (引数) は、次のような同値クラスに分割できる。
範囲 | 処理 |
---|---|
~ -1 | -1 を返す |
0 ~ 6 | 120 を返す |
7 ~ 64 | 200 を返す |
65 ~ | 120 を返す |
このような同値クラスのうち、値を一つ選んで入力してやる。つまり、-5, 3, 10, 70 などをテストしてやればよい。
境界値分析法
同値分割法では、各同値クラスの「処理」に対するテストを行ったが、境界値分析法では各同値クラスの「範囲」に対してテストを行う。経験則ではあるが、同値クラスの境界値 (つまり、それぞれの範囲の最小値と最大値) にはバグが含まれていることが多い。例えば、先ほどのメソッド equiv(int) を少し間違えて以下のように書いたとする。
public static int equiv(int n) { if (0 < n && n <= 6) { return 120; } else if (7 <= n && n <= 64) { return 200; } else if (65 <= n) { return 120; } else { return -1; } }
これには誤りがあるがすぐに発見できるだろうか。
equiv(int)の境界値を洗い出すと、以下のようになる。
同値クラス | 境界値 |
---|---|
~ -1 | -1 |
0 ~ 6 | 0 と 6 |
7 ~ 64 | 7 と 64 |
65 ~ | 65 |
通常は、同値分割法と境界値分析法を同時に用いて、境界値と中間値 (同値クラス内でちょうど真ん中の値) をテストしてやるのが普通である。ただし、境界に上限や下限がない場合は一般的な値を用いる。
同値クラス | 境界値 | 中間値 (または一般的な値) |
---|---|---|
~ -1 | -1 | -10 |
0 ~ 6 | 0 と 6 | 3 |
7 ~ 64 | 7 と 64 | 35 |
65 ~ | 65 | 75 |
つまり、equiv(int)メソッドをテストする場合、入力 -10, 0, 3, 6, 7, 35, 64, 65, 75 についてそれぞれテストしてやればよい。
分岐のあるプログラムのテスト
分岐のあるプログラムをテストする場合、全ての分岐を網羅するテストを行うことが望ましい。
例えば次のようなメソッドを考える。
public static int branch(int a, int b) { int sign; if (a == 0) { return 0; // (1) } else if (a > 0) { sign = 1; // (2) } else { sign = -1; // (3) } if (b == 0) { return a; // (4) } else { return sign * b; // (5) } }
これを網羅するには、次のような流れが必要である。
- (1) を実行
- (2) を実行した後に (4) を実行
- (2) を実行した後に (5) を実行
- (3) を実行した後に (4) を実行
- (3) を実行した後に (5) を実行
つまり、次のように最低でも5回は呼び出す必要がある。
- branch(0, 0)
- branch(1, 0)
- branch(1, 1)
- branch(-1, 0)
- branch(-1, 1)
ループのあるプログラムのテスト
繰り返し回数が与えたテストパターンによって変動するようなプログラムである場合、次のようなテストパターンを試してみるとよい。
- 繰り返し回数が0回になる (一度も繰り返さない) ようなパターン
- 繰り返し回数が1回になる (一度だけ実行する) ようなパターン
- よく使用されると考えられる回数で繰り返すようなパターン
- 想定した繰り返し回数の最大値になる (できる限り繰り返す) ようなパターン
- 想定した繰り返し回数の最大値 - 1 になる (できる限り繰り返す - 1) ようなパターン
- 想定した繰り返し回数の最大値 + 1 になる (できる限り繰り返す + 1) ようなパターン
繰り返し回数の最大を特に設けない場合、非常に多くの回数の繰り返しを実行するようなパターンを与える。
例えば、1 から n までの合計を計算する以下のようなメソッド sum(int) について、
public static int sum(int n) { int total = 0; for (int i = 1; i <= n; i++) { total += i; } return total; }
このような場合、引数に 0, 1, 10, 100000 などの値を入れてやるとよい。
ただし、0 を入れた場合は 「1 から 0 までの合計」となり、日本語として不正な文章になってしまっている。これはメソッドに与える引数の有効値ではないので境界値分析法に基づいたテストで判別するべきである。