🤮

第1章 2 オブジェクトは「もの」ではない

10 min read

目次

これは連載「あすかの怪文書」の記事です。目次はこちらからご覧になれます

言い訳

多くの入門書では、オブジェクトを「もの」に例えます。間違いではありませんが、「もの」に固執していると適切なクラス分けを行うことが出来ません。本記事ではそれを示し、また私の考える最強(笑)の対処法を示します。
ちなみに「オブジェクト」と「インスタンス」の違いについて、本連載では以下のように定義します。

オブジェクト

インスタンスの上位集合。本連載では、インスタンスだけでなく、クラスそのものもオブジェクトとして含めます。一般論とは異なるかもしれません。この場合のオブジェクトは、C#において変数として扱うことが出来ない場合があることにご留意ください。
(詳しくはstaticコンストラクタについて調べてください。JavaやC#においてクラスはあらかじめ存在しているのではなく、クラスそのものの初期化処理が存在します)

インスタンス

newによってコンストラクタを実行することで生成されたオブジェクトをこう呼称します。

var instance = new Object();

「もの」に基づくクラス設計

オブジェクト指向の初心者は、往々にして、「もの」を基準にクラスを設計します。ここでの「もの」とは、以下のようなものをさします。

  • 画面
    • 1つの画面につき1つのクラス
    • コントローラクラスもこれに含まれる(別途作る場合もある)
  • データベースのテーブルに対応するクラス
    • 場合によってはDAOパターンに基づくクラスを作ることも
  • ユーティリティクラス

これらをなぜここで「もの」と表現しているかと言うと、すべて仕様書で可視化された概念だからです。
これらのクラス分けはドメインモデルにおいても間違いではありませんが、特筆すべきは、開発者がこのクラスの中で処理を完結させようとする点です。開発者は、上記以外のクラスを作ることを嫌います。なぜなら仕様書に書いていない概念を勝手に作ることができないからですし、その概念の必要性もないと判断しています。結果として、以下のようなクラスができあがります。
これは、ブックリーダーアプリの実装例です。仕様書では、「メインウィンドウ」「本」「チャプター(章)」「ページ」の4つの概念が記述されています。

仕様書の種類 書かれている「もの」
画面仕様書 メインウィンドウ
テーブル仕様書 本、チャプター、ページ
classDiagram
  BookReaderWindow <-- MainController
  MainController o-- Book
  MainController o-- Chapter
  MainController o-- Page
  class MainController {
    -int Money
    -List~Book~ Books
    -List~Page~ Pages
    +GetBooks() ReadOnlyList~Book~
    +GetChapters(Book) ReadOnlyList~Chapter~
    +GetPageAt(int) Page
    +GetComments(Book, int) ReadOnlyList~string~
    +SetCurrentBookFromIsbnCode(string)
    +GetChapterFirstPage(Chapter) Page
    +GetChapterPages(Chapter) ReadOnlyList~Page~
    +BuyBook(string) Book
  }
  class Book {
    +string IsbnCode
    +string Title
  }
  class Chapter {
    +string BookIsbnCode
    +int ChapterNumber
    +string Title
    +int FirstPageNumber
  }
  class Page {
    +string BookIsbnCode
    +int PageNumber
    +string Text
  }

いやしかしクラス図ダイアグラムは文字が小さいですね。読めなければお手数ですがGitHubのコードを読んでください

この構成には複数の問題があります。まず、私のDDDに関する残念な理解の記事でも述べたとおり、これはトランザクションスクリプトであり、BookChapterPageクラスは貧血ドメインモデルになっています。操作をすべてMainControllerクラスに集約してしまったためです。これは私が職場で経験した、MVCプログラムでよく見る失敗例です。
しかしこれは話の本筋ではないので、ささっと修正しましょう。

classDiagram
  BookReaderWindow <-- MainController
  MainController o-- Book
  Book *-- Chapter
  Book *-- Page
  class MainController {
    -int Money
    -List~Book~ Books
    +Book CurrentBook
    +GetBooks() ReadOnlyList~Book~
    +GetComments(Book, int) ReadOnlyList~string~
    +BuyBook(string) Book
  }
  class Book {
    +string IsbnCode
    +string Title
    +ReadOnlyList~Page~ Pages
    +ReadOnlyList~Chapter~ Chapters
    +GetPageAt(int) Page
    +GetChapterFirstPage(Chapter) Page
    +GetChapterPages(Chapter) ReadOnlyList~Page~
  }
  class Chapter {
    +string BookIsbnCode
    +int ChapterNumber
    +string Title
    +int FirstPageNumber
  }
  class Page {
    +string BookIsbnCode
    +int PageNumber
    +string Text
  }

いくつかのGetで始まるメソッドをプロパティに変更しましたが、本来Bookクラスに含めるべきだったロジックがMainControllerから移管されて、すっきりしたと思います。また、ChapterPageも、Bookが直接持つようにすることで、DDDの集約を表現しやすくなりました。

可視化されたもののみをオブジェクト化する限界

ところで、MainControllerにはまだロジックが残っています。

  • 本を購入する処理
  • 本の読者のコメントをサーバーから取得する処理

前提として、これらの処理は、本に関する情報を取得する他のメソッドと同様、コントローラクラスに含めるべきではありません。ドメインモデル設計において、適切なモデルクラスに移動させなければいけません。どこへ?
一番の候補として、Bookクラスが挙げられます。なぜならどちらも、一応は「本」に関係する処理だからです。しかしこれには、問題があります。それぞれ、詳しく見ていきます。

本を購入する処理

これをBookクラスに含める場合、以下の処理も付随してBookクラスに入ってきます。

  • 現在の所持金の保持
  • 本を購入して、新しいBookインスタンスを取得する

問題点は、「新しいBookをネットから取得する」という処理がBookクラスの中で行われていること。また、お金の管理をBookクラスが行っていることです。これは、拡張において、以下の問題をはらみます。

  • このアプリが今後、本だけでなく音楽も扱うようになったら、同じような購入処理をMusicクラス内にコピペするの?
  • 所持金のチェック、入金などもBookクラスの中でやるの?本関係なくない?
  • 購入時のインターネット接続処理を修正したい時にBookクラスを触るの?ネット接続触るのになぜBook?本関係なくない?

本の読者のコメントをサーバーから取得する処理

本を読んだ読者の感想、コメントなどをサーバーから取得する処理です。これもBookクラス内に処理を記述することになります。

  • 購入処理に伴うインターネット接続処理を使い回せないの?
  • ネット接続なのになぜBookry
  • Musicのコメントを取得したい時もコピペするの?

一応の解決策

上記処理を無理にBookクラスに押し込むことは、プログラムの拡張性を損ないます。もちろんブックリーダーアプリに音楽購入・鑑賞機能をつけるのは、当初の予定にも仕様書にも記述されていないことでしょう。今回はひとつの分かりやすい例として挙げましたが、実際はもっとこまこました要求が多いはずです。ただ、上記のプログラムは、そのような拡張すら難しい構造になっています。
ここでは、一応の解決策としてサービスクラスを作成して対応します。

classDiagram
  BookReaderWindow <-- MainController
  MainController o-- Book
  Book *-- Chapter
  Book *-- Page
  BookService -- Book
  BookService -- MainController
  class MainController {
    -int Money
    -List~Book~ Books
    +Book CurrentBook
    +GetBooks() ReadOnlyList~Book~
  }
  class BookService {
    +GetComments(Book, int)$ ReadOnlyList~string~
    +BuyBook(string)$ Book
  }
  class Book {
    +string IsbnCode
    +string Title
    +ReadOnlyList~Page~ Pages
    +ReadOnlyList~Chapter~ Chapters
    +GetPageAt(int) Page
    +GetChapterFirstPage(Chapter) Page
    +GetChapterPages(Chapter) ReadOnlyList~Page~
  }
  class Chapter {
    +string BookIsbnCode
    +int ChapterNumber
    +string Title
    +int FirstPageNumber
  }
  class Page {
    +string BookIsbnCode
    +int PageNumber
    +string Text
  }

しかし、これをサービスクラスにする最大の問題は、状態が存在するということです。所持金、接続状態は状態であり、どこかに変数として保持しなければいけません。これらをサービスクラスに含めるべきでしょうか?
そして、サービスクラスに含める処理は最小限にしたいというのが理想です。このサービスクラスを何とか減量できないものでしょうか。

責務に基づくクラス設計

責務とは、Responsibilityの和訳として、オブジェクト指向のクラスの分け方としてたびたび引き合いに出される言葉です。
私は正直、責務とは何であるかを全く理解していません。責務について調べましたが、頭の悪い私は正直言って、責務のことを正確に理解していない自信があります。代わりに、私は責務を以下のように言い換えています。

  • このクラスは、周囲からどのような期待をされているか?
    • どのような情報を取得できることが期待されているか?
    • どのような処理を行うことが期待されているか?
    • 逆に、何が期待されていないのか?
  • 期待を満たすために、どのようなデータを持つべきか?

オブジェクト指向の概要でも説明したとおり、クラスはある処理をカプセル化した部品です。部品を扱うとき、呼び出し側はこのクラスに何を入力して何を出力するか知っていればよい、と書きました。クラスは単一の明確な役割を持っているべきであり、またその役割は周囲に対して説明可能でなければいけません。かつ、その役割から外れたことをすべきではありません。

「もの」で設計したクラスの問題点

この考えに照らし合わせて、これまで作ったクラスを検証します。
問題となったBookクラスは、以下の責務を持っているものと考えられます。

  • 持っている情報:本のページ、チャプター
  • 可能な処理:本の情報を引き出す、本に関係するデータを操作する

さて、今回問題になった処理を振り返ります。

  • 本を購入する処理
  • 本の読者のコメントをサーバーから取得する処理

本を購入する処理

このメソッドは、インターネット接続処理、お金管理処理を通して、戻り値として本を返します。本は単なる戻り値に過ぎず、それに至るまでの処理は本とは関係なく、上述した本の責務から外れることは明らかです。
少しでも本が関係するからと言って、このようなメソッドを安易にBookクラスに含めると、前述したとおり拡張の観点から問題が発生します。
現在存在するどのクラスも、適切な責務を持っていません。これは新しいクラスを作るべきサインであり、以下のクラスを作成して対処すべきです。

  • お金を管理するクラスWallet
  • インターネット接続処理(ここではREST API呼び出し)をおこなうクライアントAppServerClient

現実的な設計では、REST APIを通した本の購入処理の戻り値は、購入結果(購入が成功したかどうか)です。購入した本を取得する処理は別途作ります。こうなると購入処理ってますます本関係ないですね

本の読者のコメントをサーバーから取得する処理

これも同じく、インターネット接続処理が要求されます。これも本の責務からは外れます。
しかし、新規に記述するコードはほとんどありません。なぜなら本の購入処理の時に作成したAppServerClientにちょっと機能を追加するだけで事足りるからです。そして、それ以外のクラスの変更点はありません。せいせい、ここで追加したメソッドへの呼び出しをMainControllerクラスに足すだけです。

たったこれだけの変更で済むことは、適切に責務分割できていることを意味します。責務分割はコードの修正を最小限にします。詳しくは本記事の下の方でも説明します。

設計は常に仕様書を超える

むろん、これらのクラスは仕様書には含まれていません。プログラム側で必要になったから、勝手に作ったクラスです。
仕様書通りにクラスを作ることは、間違いではありません。そのようなクラスは、仕様書に対応したインターフェースであり、仕様書からプログラムを理解するための入り口にすぎません。仕様書を偏重し、仕様書にあるクラスしか作ろうとしないことが問題なのです。これは「もの」をベースとしたクラス設計のときに必ず発生する欠陥です。

しかし実際のところ、どのようなクラスを作ればいいかの知識が、ほぼプログラマのこれまでの経験に依存することも問題です。同じ仕様書から、プログラマの数だけ多種多様な設計が生まれる以上、コードの品質はプログラマ個人の知識や経験の差に大きく依存します。これらはどうしても個人が努力しなければいけないポイントです。ちなみに私の設計は壊滅的なので参考にならないというのは既知の事実です。

classDiagram
  BookReaderWindow <-- MainController
  MainController o-- Book
  Book *-- Chapter
  Book *-- Page
  AppServerClient <-- Wallet
  MainController <-- AppServerClient
  class MainController {
    -int Money
    -List~Book~ Books
    -AppServerClient Client
    +Book CurrentBook
    +GetBooks() ReadOnlyList~Book~
  }
  class Wallet {
    +int Money
    +TryPay(int) bool
  }
  class AppServerClient {
    +Wallet Wallet
    +BuyBook(string) Book
    +GetComments(Book, int) ReadOnlyList~string~
  }
  class Book {
    +string IsbnCode
    +string Title
    +ReadOnlyList~Page~ Pages
    +ReadOnlyList~Chapter~ Chapters
    +GetPageAt(int) Page
    +GetChapterFirstPage(Chapter) Page
    +GetChapterPages(Chapter) ReadOnlyList~Page~
  }
  class Chapter {
    +string BookIsbnCode
    +int ChapterNumber
    +string Title
    +int FirstPageNumber
  }
  class Page {
    +string BookIsbnCode
    +int PageNumber
    +string Text
  }

それはともかくとして、この変更によりstaticメソッドが消えました。

ここで操作のないクラスが2つ(ChapterPage)ありますが、今回の定義に従うと責務は適切であると考えます。なぜなら、それらのクラスには操作を持つ余地が残されており、かつ他のどのメソッドもこのクラスの操作にすることは不適切であるからです。
1つのクラスが必ず状態・操作を両方持たなければいけないわけではなく、両方持たせることを目的化してはいけません。今回は、本にはChapterの存在しないページ(遊び、目次、奥付など)があるという仮説に基づいて、ChapterPageをお互いに結びつきのないクラスとして設計しました。

この設計をブラッシュアップするのであれば、Chapterクラスをサブクラスに持つSectionクラスを作り、Pageを保持するクラスはSectionのみとし、BookにはSectionのリストのみを持たせるというのもひとつの手です。

classDiagram
  Book *-- Section
  Section *-- Page
  Section *-- Section
  Section <|-- Chapter

Pageも「1ページだけの区切り」とみなしてSectionを継承する、という設計もありですね。ていうか今回のプログラム、こっちのほうがより合理的でよかったかもしれません。今回の説明の流れの中で挿入する設計としては読者の混乱を招きかねないかもしれませんが。

classDiagram
  Book <-- SectionList
  SectionList *-- Section
  Section *-- Section
  Section <|-- Chapter
  Section <|-- Page

このような設計へ到達するには、経験や知識も重要ですが、DDDの本で触れられているユビキタス言語を明確に定義・使用することで対象を深く理解すること、物事を抽象化することも重要です

クラスを責務で分割するメリット

ひとことで言うと、プログラムの拡張性、保守性の向上に貢献します。

この説明は、SOLID原則の1つである「単一責任の原則」とかぶりますが、責務に基づいてクラスを適切に分割することの本質は、修正時の影響範囲を最低限にすることにあります。

1つのクラスは1つだけの責任を持たなければならない。すなわち、ソフトウェアの仕様の一部分を変更したときには、それにより影響を受ける仕様は、そのクラスの仕様でなければならない。
ウィキペディア「SOLID」

などと書かれていますが、「クラスを変更する理由は唯一であるべき」と言い換えたほうが分かりやすいかもしれません。例えば、以下のようになるようにクラスを設計すべきです。

  • 本の仕様を変更する時に、ChapterクラスやPageクラスは変更してはいけません。Bookクラスのみを変更すべきです
  • インターネット接続処理の修正(例:REST APIのURL)など、本に関係ない理由でBookクラスを変更すべきではありません

というのは理想ですが‥‥まあ、理想通りになるよう最大限努力しましょう。諦めてはいけません

これを実現するための最適な手段が、クラスに単一の責務を持たせるということです。

目次

これは連載「あすかの怪文書」の記事です。目次はこちらからご覧になれます

GitHubで編集を提案

Discussion

ログインするとコメントできます