BOOK Preview
|
|
|
本コーナーは、.NET関連の新刊書籍から主要なチャプターをそのまま転載し、その内容を紹介するものです。 今回は、日経BPソフトプレス/マイクロソフトプレスより2005年3月28日に発行の書籍『Code Complete 第2版 上 ― 完全なプログラミングを目指して』より、同社の許可を得てその内容を転載しています。 同書は、11年前に出版された名著「Code Complete」の第2版です。第2版では、全体をとおしてオブジェクト指向の考え方が反映され、リファクタリングの章なども追加されています。また、開発言語としてC#やVisual Basic .NETも取り上げられています。“完全な”コーディングのための鉄則を凝縮した本書は、開発者ならば必読といえるでしょう。 本記事では「第6章 クラスの作成」の後半部分を転載しています。ここでは、クラス設計において、どのように包含(has a)と継承(is a)を使い分けるか、どのようにクラスのメンバを決定すべきか、どのように継承すべきか、そしてどんなときにクラスを作成すべきか、といった指針が明確に記されています。 なお、書籍の詳細については書籍情報のページをご覧ください。 |
6.3 設計と実装の問題
|
良いクラスインターフェイスを定義することは、高品質なプログラムの作成に大きく貢献する。内部クラスの設計と実装も同じように重要である。ここでは、包含、メンバルーチン(メンバ関数)とメンバデータ、クラスの結合度、コンストラクタ、値オブジェクトと参照オブジェクトに関連する問題について見ていく。
6.3.1 包含(「has a」の関係)
包含とは、クラスが基本のデータ要素やオブジェクトを保持しているという単純な概念である。継承に比べて取り上げられる機会は断然少ないが、それは継承の方が難解でエラーの原因になりやすいためであり、継承の方が良いからではない。包含は、オブジェクト指向プログラミングの動力とも言えるテクニックである。
■ 包含を使って「has a」を実装する
包含について考える方法の1つは、「has a」関係として考えることである。たとえば、社員は名前を持つ(has a)、電話番号を持つ(has a)、社会保険番号を持つ(has a)と考える。通常は、名前、電話番号、社会保険番号をEmployeeクラスのメンバデータにすることで達成できる。
■ プライベート継承を使って「has a」を実装するのは最後の手段である
状況によっては、あるオブジェクトを別のオブジェクトのメンバにする方法では、包含を実現できないことがある。そのような場合、一部の専門家は、包含されるオブジェクトをプライベートで継承することを提案している(Meyers1998; Sutter 2000)。そうする主な理由とは、含む側のクラスを含まれる側のクラスのprotectedのメンバルーチンまたはメンバデータにアクセスさせるためである。実際には、これでは祖先のクラスとの結合度が強くなりすぎて、カプセル化に違反してしまう。たいていは、プライベート継承以外の方法で解決しなければならない設計上のミスである。
■ クラスのメンバデータが7個を超えたあたりから、クラスを批判的に見る
他の作業を行いながら人が覚えられる項目は、だいたい「7プラスマイナス2」個であることがわかっている(Miller 1956)。クラスのメンバデータが7つ以上に増えたら、クラスを複数の小さなクラスに分解すべきかどうか検討しよう(Riel 1996)。メンバデータが整数や文字列といった基本データ型である場合は、「7プラスマイナス2」個の上限寄りで判断し、メンバデータが複雑なオブジェクトである場合は下限寄りで判断してもよいだろう。
6.3.2 継承(「is a」の関係)
継承とは、あるクラスが別のクラスの「特化」であるという概念だ。継承の目的は、複数の派生(サブ)クラスの共通要素を規定する基底(スーパー)クラスを定義して、コードを単純にすることである。共通要素としては、ルーチンのインターフェイス、実装、メンバデータ、データ型などが挙げられる。継承を利用すれば、コードやデータを基底クラスで一元的に管理できるようになるので、複数の場所で定義する必要がなくなる。継承を使用する際には、次の点について決断する必要がある。
-
メンバルーチンはそれぞれ派生クラスから参照できるか。既定の実装を持つか。既定の実装はオーバーライド可能か。
-
メンバデータ(変数、名前付き定数、列挙など)はそれぞれ派生クラスから参照できるか。
ここでは、これらの決断について詳しく見ていくことにしよう。
■ パブリック継承を通じて「is a」を実装する
既存のクラスを継承して新しいクラスを作成する場合、プログラマは新しいクラスが古いクラスの特化バージョンであること(is a)を示している。基底クラスは派生クラスに期待される動作を規定し、派生クラスの動作に制約を設ける(Meyers 1998)。
基底クラスが定義したインターフェイス規約に派生クラスが完全に従わないとしたら、継承は実装方法として正しくない。包含を使用するか、継承階層の上の方で変更を加えることを検討する。
|
■ 継承のために設計し、文書化する。そうでなければ継承を禁止する
継承はプログラムの複雑さを増大させる危険なテクニックである。Javaの第一人者であるJoshua Blochが言うように、「継承のために設計し、文書化する。そうでなければ継承を禁止する」こと。クラスが継承されるような設計になっていなければ、そのクラスを継承できないようにする。つまり、C++であれば、そのクラスのメンバをvirtualで定義しない。Javaであれば、finalで定義する。Visual Basicであれば、Overridableにしない。
■ リスコフの置換原則(LSP)に従う
Barbara Liskovは、オブジェクト指向プログラミングに最も影響を与えた論文の中で、派生クラスが基底クラスの特化したバージョン(is a)でない限り、基底クラスを継承させるべきではないと論じている(Liskov 1988)。AndyHuntとDave ThomasはLSPを次のように要約している。「派生クラスは、ユーザーが基底クラスとの違いに気付かずに、基底クラスのインターフェイスを通じて使用できるものでなければならない」(Hunt and Thomas 2000)。
つまり、基底クラスで定義されるすべてのルーチンは、派生クラスで使用する場合も同じものを意味しなければならない。
たとえば、基底クラスがAccount、派生クラスがCheckingAccount、SavingsAccount、AutoLoanAccountであるとしよう。プログラマは、そのAccountオブジェクトがどの派生クラスのインスタンスなのかを意識せずに、Accountクラスのすべてのルーチンをすべての派生クラスで呼び出せなければならない。
LSPに従ってプログラムが書かれている場合、プログラムは詳細を意識せずにオブジェクトの汎用的な属性に集中できるため、継承は複雑さを緩和するための強力なツールとなる。プログラムが派生クラスの実装の意味的な違いを常に意識しなければならないとしたら、継承は複雑さを軽減するどころか、逆に増大させる。プログラマが次のように考えなければならないとしよう。
「CheckingAccountクラスまたはSavingsAccountクラスのInterestRate()ルーチンを呼び出すと、銀行が支払う利子が返される。しかし、AutoLoanAccountクラスのInterestRate()ルーチンを呼び出すと、顧客が銀行に支払う利子が返されるため、符号を変えなければならない」 |
AutoLoanAccountクラスのInterestRate()ルーチンはAccountクラスのInterestRate()ルーチンとは別の意味を持つため、LSPに照らし合わせると、Accountクラスを継承させるべきではないことになる。
■ 継承したいものだけを継承する
派生クラスは、メンバルーチンのインターフェイス、実装、または両方を継承することができる。表6-1に、ルーチンの実装とオーバーライドの種類をまとめる。
オーバーライド可能 | オーバーライド不可 | |
実装:既定で提供される | オーバーライド可能なルーチン | オーバーライド不可能なルーチン |
実装:既定で提供されない | オーバーライド可能な抽象ルーチン | 使用されない(ルーチンを未定義のままにしておくことは意味を成さないため、オーバーライドは許可されない) |
表6-1 継承されるルーチンの種類 |
表6-1に示すように、継承されるルーチンは基本的に次の3種類である。
・オーバーライド可能な抽象ルーチン
派生クラスがルーチンのインターフェイスを継承するが、実装を継承しないことを意味する。
・オーバーライド可能なルーチン
派生クラスがルーチンのインターフェイスと既定の実装を継承し、既定の実装のオーバーライドが許可されることを意味する。
・オーバーライド不可能なルーチン
派生クラスがルーチンのインターフェイスとその既定の実装を継承し、ルーチンの実装のオーバーライドが許可されないことを意味する。
継承を通じて新しいクラスを実装する場合は、各メンバルーチンをどのように継承するのかについてじっくり考えよう。インターフェイスを継承するからという理由で、実装を継承しないように注意する。クラスの実装を使用したいが、そのインターフェイスは使用したくないという場合は、継承ではなく包含を使用する。
■ オーバーライド不可能なメンバルーチンを「オーバーライド」しない
C++とJavaでは、ある意味、プログラマがオーバーライド不可能なメンバルーチンをオーバーライドできる。基底クラスにprivateで定義されたルーチンがあれば、派生クラスでそれと同じ名前のルーチンを作成することができる。派生クラスのコードを読んだプログラマは、そのようなルーチンを見て混乱するだろう。なぜなら、ポリモーフィックでなければならないものに見えて、実際にはそうでなく、同じ名前が付いているだけなのだ。このガイドラインを別の方法で言い換えるなら、「基底クラスのオーバーライド不可能なルーチンの名前を派生クラスで使用しない」となる。
■ 共通のインターフェイス、データ、振る舞いを継承のできるだけ上位レベルへ移動する
インターフェイス、データ、振る舞いをより上位レベルへ移動するほど、派生クラスでそれらを使いやすくなる。どれくらいのレベルにすればよいだろうか。それには抽象化を目安にしよう。ルーチンを上位レベルへ移動すると、上位レベルのオブジェクトの抽象化がうまくいかなくなるような場合は、そのレベルへ移動しないこと。
■ インスタンスが1つしかないクラスを疑う
インスタンスが1つしか存在しないということは、オブジェクトとクラスを混同した設計であることを意味する。新しいクラスを使わずにオブジェクトを生成するだけで済ませることができるかどうか検討する。派生クラスの種類を、別個のクラスとしてではなくデータで表すことはできないだろうか。このガイドラインには、1つ明らかな例外がある。それはデザインパターンのSingletonパターン(第5章の表5-1を参照)である。
■ 派生クラスが1つしかない基底クラスを疑う
私は、派生クラスが1つしか存在しない基底クラスを見たら、プログラマが「先のことを考えた設計」をしたのではないかと疑う。将来必要になることを見込んでそうしたのだろうが、だいたい、将来の必要性が何なのかを十分に理解しないまま設計している。将来のために準備するとしたら、「いつか必要になるかもしれない」層の基底クラスなどは設計しないことが得策である。現時点の作業をできるだけ明白に、まっすぐに、単純にすること。それは、絶対に必要な継承構造以外、何も作成しないということだ。
■ ルーチンをオーバーライドしているが、そのルーチン内で何もしない派生クラスを疑う
一般に、これは基底クラスの設計に問題があることを示す。たとえば、CatというクラスとScratch() (爪とぎ)というメンバルーチンがあるとしよう。後になって、足にけがをして爪とぎのできない猫がいることに気付いた。そこで、Catクラスを継承するScratchlessCatというクラスを作成し、Scratch()ルーチンをオーバーライドして、何もしないという方法を思い付いた。だが、この方法には問題がいくつかある。
-
Catクラスのインターフェイスの意味が変わってしまうため、Catクラスの抽象化(インターフェイス規約)に違反する。
-
この方法は、他にも派生クラスを作成するとすぐに収拾がつかなくなる。無毛種の猫がいた場合はどうなるか。ねずみを捕まえない猫はどうか。ミルクを飲まない猫はどうか。ついにはScratchlessHairlessMicelessMilklessCatのような派生クラスができてしまうだろう。
-
祖先クラスのインターフェイスや振る舞いからは、子孫クラスの振る舞いについてほとんど、あるいはまったく何もわからないため、徐々にコードの保守が難しくなっていく。
この問題を修正する場所は、派生クラスではなく、最初のCatクラスである。Claws(爪)クラスを作成して、それをCatクラスで包含する。すべての猫が爪とぎをすると想定していることが根本的な問題なので、その問題が表面化した場所で取り繕うのではなく、問題の根源で解決する。
■ 深い継承ツリーを避ける
オブジェクト指向プログラミングは、複雑さに対処するためのさまざまなテクニックを提供する。しかし、強力なツールは危険と隣り合わせでもある。オブジェクト指向のテクニックによっては、複雑さを低減するどころか、かえって増大させる傾向がある。
Arthur Rielは、名著『Object-Oriented Design Heuristics』の中で、継承の階層を最大で6段階に制限することを提案している(Riel 1996)。Rielのアドバイスは「7プラスマイナス2」に基づくものだが、ひどく楽観的な考えであると思う。私の経験では、ほとんどの人は継承が2〜3段階以上になると、頭の中でいっぺんに整理できなくなる。「7プラスマイナス2」は、継承ツリーのレベル数というよりも、基底クラスから派生するサブクラスの総数を制限するのにふさわしい。
深い継承ツリーは、エラー率の上昇と深く結び付いていることがわかっている(Basili, Briand and Melo 1996)。複雑な継承階層をデバッグしようとした経験があれば、その理由はわかるはずだ。深い継承階層は複雑さを増大させる。まさに、継承を使って実現すべきこととは正反対である。本来の目的を見失ってはならない。継承を使ってコードの重複をなくし、複雑さを最小限に抑えること。
INDEX | ||
Code Complete 第2版 上・下 | ||
第6章 クラスの作成 | ||
1.6.1 クラスの基礎:抽象データ型(ADT)(1) | ||
2.6.1 クラスの基礎:抽象データ型(ADT)(2) | ||
3.6.2 良いクラスインターフェイス(1) | ||
4.6.2 良いクラスインターフェイス(2) | ||
5.6.3 設計と実装の問題(1) | ||
6.6.3 設計と実装の問題(2) | ||
7.6.4 クラスを作成する理由 | ||
8.6.5 言語固有の問題/6.6 クラスを超えて:パッケージ/6.7 参考資料/6.8 まとめ | ||
「BOOK Preview」 |
- 第2回 簡潔なコーディングのために (2017/7/26)
ラムダ式で記述できるメンバの増加、throw式、out変数、タプルなど、C# 7には以前よりもコードを簡潔に記述できるような機能が導入されている - 第1回 Visual Studio Codeデバッグの基礎知識 (2017/7/21)
Node.jsプログラムをデバッグしながら、Visual Studio Codeに統合されているデバッグ機能の基本の「キ」をマスターしよう - 第1回 明瞭なコーディングのために (2017/7/19)
C# 7で追加された新機能の中から、「数値リテラル構文の改善」と「ローカル関数」を紹介する。これらは分かりやすいコードを記述するのに使える - Presentation Translator (2017/7/18)
Presentation TranslatorはPowerPoint用のアドイン。プレゼンテーション時の字幕の付加や、多言語での質疑応答、スライドの翻訳を行える
|
|