コンテンツへスキップ
Topos Uranos

要約

これまでの二つのレッスンでは、関数とは何か、そのデータがどのように出入りするかを学んだ。このレッスンでは、どの関数を書くかを決める方法を学ぶ。方法は段階的詳細化である。まだ存在しない関数をそれぞれ契約つきで呼び出す短い main を書き、それぞれの関数へ降りていって、数行で書ける部分に達するまで続ける。このレッスンは、よいインタフェースの基準、「一つの関数に一つの仕事」という規則とその違反を示す兆候、そして異なる型の上の同じ仕事に同じ名前を与えるオーバーロードを定める。これらの基準を使って、main の中にあった第 4 単元のアルゴリズムを書き直し、それぞれを別々にテストする。すべてをまとめる概念はトップダウンの分解である。よく設計されたプログラムは、それ自身の仕様として読める。

学習目標

  1. 段階的詳細化によって問題を部分問題に分解し、分解の木を書く。
  2. 本体を書く前に、各関数のインタフェース(名前、引数、戻り値、契約)を設計する。
  3. 第 4 単元の一枚岩のプログラムを凝集度の高い関数の集まりとして書き直し、テスト用の main でそれぞれを別々にテストする。
  4. 「一つの関数に一つの仕事」という規則と隠れた作用がないことに基づいて、設計の良し悪しを判断する。
  5. 関数をオーバーロードし、各呼び出しがどの版を選ぶかを予測し、ある呼び出しが曖昧になる理由を説明する。

段階的詳細化

問題文から数行の main へ

第 4 単元のプログラムは、すべて main の中に書かれていた。一画面に収まるうちはそれも許される。しかし、そのように書かれた二百行のプログラムは、すべてを同時に頭に入れておくことを要求し、全体をまとめてテストすることしかできない。段階的詳細化、あるいはトップダウン設計は、作業の順序を逆にする。まず、プログラムが何をするかを、問題の言葉で、まだ存在しない関数の呼び出しの列として書く。次に、それらの関数の一つ一つを新しい、より小さな問題として扱い、何も分解せずに本体が書けるほど単純な関数に達するまで、同じ手順を繰り返す。

例として次の問題を考える。整数 n≥1n \geq 1 は、その真の約数(nn と異なる正の約数)の和が nn に等しければ完全数、小さければ不足数、大きければ過剰数という。28=1+2+4+7+1428 = 1 + 2 + 4 + 7 + 14 なので 28 は完全数、8 は 1+2+4=71 + 2 + 4 = 7 なので不足数、12 は 1+2+3+4+6=161 + 2 + 3 + 4 + 6 = 16 なので過剰数である。nn を読み込み、その真の約数の和と分類を書き出すプログラムがほしい。設計の第一段階は main であり、ほかの何よりも先に書く。

C++ のプログラム
#include <cstdint>#include <iostream>#include <print>#include <string>​enum class Kind { deficient, perfect, abundant };​// n の正の約数の和。// 事前条件:n >= 1。// 事後条件:n 自身を含む、n のすべての正の約数の和を返す。std::int64_t divisorSum(int n);​// 真の約数の和による n の分類。// 事前条件:n >= 1。// 事後条件:和が n より小さいか、等しいか、大きいかに応じて deficient、perfect、abundant を返す。Kind classify(int n);​// 出力するための分類の名前。// 事前条件:なし。// 事後条件:「不足数」「完全数」「過剰数」のいずれかを返す。std::string kindName(Kind kind);​int main(){    int n{};    std::cin >> n;    std::println("{} の真の約数:和は {}", n, divisorSum(n) - n);    std::println("{} は{}", n, kindName(classify(n)));}
リンカのメッセージ
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':main.cpp:27:(.text+0x22a): undefined reference to `divisorSum(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x3fd): undefined reference to `classify(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x40d): undefined reference to `kindName[abi:cxx11](Kind)'collect2: error: ld returned 1 exit status

このプログラムは警告を一つも出さずにコンパイルされる。各呼び出しがその宣言と合っているからであり、レッスン 関数 で学んだとおり、コンパイラが調べるのはそれだけである。失敗するのはリンクである。リンカは定義を探すが見つからない(「undefined reference」、つまり定義のない参照。付け加えられた [abi:cxx11] は、std::string を返す関数に GCC が付ける内部的な印である)。この状態は、この時点での設計の状態そのものである。インタフェースは決まっているが、本体はまだない。そうであれば、main はすでにプログラムの仕様として読め、三つの宣言は残された仕事であり、どれも元の問題より小さい。

分解の木

第二段階では各宣言へ降りていく。kindName は三つの場合の判断である。classify は divisorSum に頼る。数が完全数であるのは、そのすべての約数の和が 2n2n のときである。そして divisorSum は、約数を (d,n/d)(d, n/d) の組で調べるループ(レッスン 数値アルゴリズム のもの)であり、n\sqrt{n} で止まる。結果は木として描かれる。根は問題全体、各節点の子はそれを解く部分問題、葉は言語がすでに用意している演算である。

例整数の分類器の木

図は、設計した順に、木を一段ずつ広げていく。

証明

  1. 第一段階は問題全体であり、main が受け持つが、まだ分解されていない。

  2. 第二段階は五つの操作からなる main である。読み込む、合計する、分類する、名前をつける、書き出す。各節点は一つの呼び出しであり、ライブラリの呼び出しはすでに存在し、残りは宣言済みで未完成である。

  3. 第三段階では classify を分解する。これは divisorSum を呼び出して比較するだけである。同じ関数が木に二度現れるが、書くのは一度だけである。再利用は、コードを書く前に木の上で見える。

  4. 最後の段階は葉である。割り切れるかどうかの判定と組の上限であり、どちらも言語の演算である。ここに達すると詳細化は終わる。分解するものがもう残っていないからである。

木が完成したら、下から上へ定義を書いていく。こうすれば、どの関数もすでに書かれた関数に頼ることになる。

C++ のプログラム
#include <cstdint>#include <iostream>#include <print>#include <string>​enum class Kind { deficient, perfect, abundant };​// n の正の約数の和。// 事前条件:n >= 1。// 事後条件:n 自身を含む、n のすべての正の約数の和を返す。std::int64_t divisorSum(int n){    std::int64_t sum{0};    for (int d{1}; d <= n / d; ++d) {        if (n % d == 0) {            sum += d;            if (d != n / d) {                sum += n / d;            }        }    }    return sum;}​// 真の約数の和による n の分類。// 事前条件:n >= 1。// 事後条件:和が n より小さいか、等しいか、大きいかに応じて deficient、perfect、abundant を返す。Kind classify(int n){    const std::int64_t proper{divisorSum(n) - n};    if (proper < n) {        return Kind::deficient;    }    if (proper == n) {        return Kind::perfect;    }    return Kind::abundant;}​// 出力するための分類の名前。// 事前条件:なし。// 事後条件:「不足数」「完全数」「過剰数」のいずれかを返す。std::string kindName(Kind kind){    if (kind == Kind::deficient) {        return "不足数";    }    if (kind == Kind::perfect) {        return "完全数";    }    return "過剰数";}​int main(){    int n{};    std::cin >> n;    std::println("{} の真の約数:和は {}", n, divisorSum(n) - n);    std::println("{} は{}", n, kindName(classify(n)));}
プログラムへの入力
28
出力
28 の真の約数:和は 2828 は完全数

結果の三つの特徴は、この方法によるものである。第一に、下の部分を書いている間、main は変わらなかった。第二に、和は std::int64_t として返される(<cstdint> にある 64 ビットの整数で、レッスン 再帰 でも使う)。nn が int の最大値に近いと約数の和がそれを超えうるからであり、それをインタフェースで決めておけば、呼び出し側すべてが守られる。第三に、各関数は別々に確かめられる。divisorSum(28) は 56、divisorSum(1) は 1、divisorSum(36) は 91 にならなければならず、最後の場合には組 (6,6)(6, 6) を一度だけ数える。このレッスンの最後の節で、これらの確認をプログラムにする。