O/Rマッピングで緩和されるインピーダンスミスマッチには静的と動的の側面がある

一般的な業務アプリケーションではデータを永続化するために、RDBMS関係データベース管理システム)を利用します。RDBMSでは大量のデータを効率的に検索したり、集約してレポートを作ったりすることが得意ですし、一般的に業務システムで求められるトランザクションのACID特性*1を満たすことも容易です。また、適切にテーブル設計の正規化を行うことにより、運用面においてデータの管理コストを下げることもできます。最近ではスケーラビリティの問題などもあり、RDBMS以外のデータベースについても注目されるようになってきていますが、今後も業務アプリケーションの主流としてRDBMSは使われていくだろうと思われます。
従って、Javaなどのオブジェクト指向言語で開発を行い、DDDのようなオブジェクト指向の設計技法を利用する場合に必ず考えなくてはならない問題は、オブジェクト指向と関係モデルとのインピーダンスダンスミスマッチにいかに対処するかという問題を考えてなくてはなりません。
DDDにおいてRDBMSとのインピーダンスミスマッチをどう扱うべきか? - Togetter
インピーダンスミスマッチング - 都元ダイスケ IT-PRESS
RDBMSの関係モデルとオブジェクトモデルは、お互いに水と油のような関係のところもあり、一見似ているところもあるのですが、うまく混じり合わないところがあり、また、どちらか一方のモデルに適合させてもうまくいかない傾向があります。しかし、逆にアプリ層とデータ層を完全に分離するためにはマッピングのために相当のコストがかかります。
もちろん、ソフトウェアの世界で銀の弾丸というものはないのですが、最近は以前と違い、JPAに代表されるような本格的なO/Rマッピングのしくみが標準化されており、安価に利用することができます。こうしたしくみを正しく理解して使いこなすことで、両者のインピーダンスミスマッチを軽減させることができます。
ここでは、そもそもO/Rマッピングフレームワークとな何なのか、また、それによってどのようにインピーダンスミスマッチが緩和できるのかについて、簡単にまとめてみたいと思います。

O/Rマッピングフレームワークの分類

実は、O/Rマッピングという用語はかなり幅広い意味で利用されており、それが何をするものなのかを正しく理解していないと混乱が起きる場合があります。大別すると以下の通り分類できます。

部分か完全かの違いは後から説明するようにUnit Of Workパターンを実装して状態の更新に対する動的なインピーダンスダンスミスマッチの解決を行うかどうかの違いで分けています。もちろん、これらの分類のうち、どれが優れていて、どれが劣っているということは絶対的には決まりません。それぞれについては、以下のような特徴があるため、プロジェクトの目的によって使い分ける(あるいは併用する)ということが大切です。

分類 長所 短所 向いているシステム
SQL中心の
マッピング
レガシースキーマにも
柔軟に対処できる。RDBMS
パワーをフルに活用できる。
単純なケースでは似たような
SQLを大量に作成することによる
生産性や保守性低下。
オブジェクト指向設計
困難となることがある。
大量データのバッチ処理
帳票作成処理など。
静的
O/Rマッピング
単純なCRUD処理では生産性が高い。 複雑な業務ロジックや
データ構造に対処することが困難。
一般に大量データ処理は苦手。
マスターデータ管理など、
ロジックが単純な
オンラインアプリケーション
完全
O/Rマッピング
エンティティに振る舞いを持たせる
ことが可能になる。継承や
ポリモーフィズムを直接活用できる。
キャッシュによる性能向上。
学習コストが非常に高い。
使い方次第では性能面の問題に直面しやすい。
大量データ処理はSQL方式を併用すべき。
複雑な業務ドメイン
DDDで開発する場合。
データベーススキーマ
クラスの構造に合わせて、
自由にリファクタリングできる場合。

インピーダンスミスマッチには静的と動的の2種類の側面がある

さて、話をインピーダンスミスマッチの問題に戻しましょう。RDBMSオブジェクト指向言語とのインピーダンスミスマッチの問題については、

  • テーブル構造とクラス構造との静的なミスマッチ
  • オブジェクトとテーブルとの更新頻度の違いによる動的なミスマッチ

の2種類が存在します。前者の静的なミスマッチの問題は、オブジェクトモデルと関係モデルとの間での継承関係の有無の違いや、非正規化による粒度の違いのことを言います。一方、後者は多くの場合見過ごされがちな問題なのですが、オブジェクトの状態変更のタイミングとデータベースのUPDATEの頻度の違いについて言及しています。
そして、先ほどのO/Rマッピングフレームワークの分類で言えば

という関係があります。従って、単にインピーダンスミスマッチと言った場合、それがどの部分を指して言っているのかという点を勘違いしないように注意する必要があります。

静的なインピーダンスミスマッチを解決する

通常、多くの人がRDBMSオブジェクト指向とのインピーダンスミスマッチと聞いてイメージするのは静的なインピーダンスミスマッチの問題なのではないでしょうか。単純なマッピングでは

  • データベースのテーブルをエンティティクラスに対応させる
  • データベースの各行をエンティティクラスのインスタンスに対応させる
  • データベースのカラム属性をエンティティクラスのフィールドに対応させる

といったマッピングを行うことができます。実際、SQL中心のフレームワークSIer製や内製の自称O/Rマッピングフレームワーク*2ではこのような単純なマッピングを前提としていることがほとんどでしょう。一見このマッピングで問題が無いように見えてしまうかもしれませんが、詳細を考えると

  • データベースのテーブルには継承関係が定義できない(継承関係のミスマッチ)
  • 値オブジェクトなどを考えると粒度が異なる(粒度のミスマッチ)
  • 関係モデルでは本質的に双方向の関連しか定義できない(関連の方向性ミスマッチ)

といったオブジェクト指向設計との相違点がいろいろと出てきます。一つの解決策はデータベース設計を中心で考え、このミスマッチをアプリケーションの設計で制約として受け入れてしまうことです。もちろん、単純なCRUD処理やデータの加工が中心となるアプリケーションではこれで問題とはなりません。*3しかし、ドメインの複雑なビジネスロジックが関与する(実際に多くの業務アプリケーションの場合はそうなのですが)場合、オブジェクト指向プログラミングのメリットを活用できないと、ロジックの重複といった保守性や拡張性の問題を引き起こします。そうしたことが問題となる場合にはO/Rマッピングによる静的なインピーダンスミスマッチの解決策を取り入れることが有用になります。

継承関係のミスマッチ

継承関係のミスマッチに対しては、現在では具体的な解決策がパターンとして知られており、有名なPofEAAでも解説されています。特に継承関係のマッピングのパターンの解説としてはスコットアンブラーの記事が知られています。
Mapping Objects to Relational Databases: O/R Mapping In Detail
この場合、3種類のマッピング方法が知られています。

継承パターン 説明 使うべき時 JPAでの継承タイプ
Single Table Inheritance 継承階層を1テーブルに格納する。 商品種別ごとの階層など大量サブクラスが存在する場合。継承がフィールドの違いでなく、振る舞いの違いを中心とする場合に特に有利。JOINやUNIONを利用せず、多態的に親クラスをまとめて検索できるため、一般には性能的に有利。テーブルモデルの観点からは正規化されておらず、NOT NULL制約のないカラムが必要。 SINGLE_TABLE
Class Table Inheritance 抽象クラスも含めて各クラスを別々のテーブルに対応して格納する。 正規化された無駄のないテーブル設計が可能で、テーブルモデル自体としては拡張性や保守性が高い。必ずJOINが必要となるため一般には性能面でのコストがかかる。 JOINED
Concrete Table Inheritance 具象クラスのみ別々のテーブルに格納する。 具象クラスを別々のテーブルに格納できるためわかりやすいが、多態的な検索や関連の処理はコストがかかる。 TABLE_PER_CLASS*4

JPAを実装した本格的なO/Rマッピングフレームワークであれば、これらの継承マッピングをサポートしているため、本当にエンティティ間の継承が必要な場合にインピーダンスミスマッチを和らげてくれます。

粒度のミスマッチ

オブジェクト指向設計を行う場合には、一般に大量のフィールドを持ったクラスを作成することは責務の観点から望ましくありませんが、データベースのテーブルを設計する場合には性能面も考慮して、1対1の関係にあるデータはあえて一つのテーブルにまとめてしまうことが望ましい場合が少なくありません。たとえば、顧客に対して送付先住所、課金先住所などの情報を別々のテーブルで持つ代わりに、一つのテーブルにまとめて保持させるということは実際よくやります。また、DDDのようなオブジェクト指向設計であれば、Stringやintといった基本的な値だけでなく、Date、Moneyなど特定の値と振る舞いをカプセル化した値オブジェクトを抽出して利用する場合があります。このように、テーブルとクラスとの間で望ましい粒度の違いがあるため、インピーダンスミスマッチの一つの原因となります。
この点についてもJPAでは@Embeddableを用いて値の埋め込みという手法である程度対処することができるようになっています。詳しくは以下を参照してください。
JPAの@Embeddableの使い道 - 達人プログラマーを目指して
なお、DDDでいうところのエンティティ、値オブジェクトとJPAの@Entity、@Embeddableとでは意味が微妙にずれているところがあり、単純な対応付けができない部分があります。この点については別の機会に私の考えをまとめてみたいと思います。

関連の方向性のミスマッチ

RDB上のテーブル間の関連は外部キーと主キーとのカラムとのマッピングで行うことが基本です。したがって、本来関連にはどちらのテーブルからどちらのテーブルという方向性という概念がありません。一方、クラス間の関連の場合、関連先のオブジェクトの参照をもう一方が保持するかどうかで双方向の関連にも一方向の関連にもなり得ます。そして、関連の方向性は依存関係に関与してくるため、オブジェクト指向の設計では欠かすことのできない重要な要素となります。
高度なO/Rマッピングフレームワークでは、通常、一方向や双方向の関連を定義することができますが、方向性のないテーブルの関連にマッピングする際のミスマッチを吸収する工夫がされています。たとえば、Owner(飼い主)とDog(犬)というエンティティが一対多で関連づいていたとして

    @Entity
    public Owner {
        @Id
        private Long id;
        
        @OneToMany(mappedBy = "owner")
        private List<Dog> dogs;

        ...
    }

    @Entity
    public Dog {
        @Id
        private Long id; 

        @ManyToOne
        private Owner owner;
        ...
    }

のような双方向の関連を定義することができます。この場合、飼い主の犬との関連はクラス上は、飼い主→犬のリスト、犬→飼い主というお互いに独立した二つの方向の関連で定義されています。しかし、テーブル上はDOG表の外部キー列でOWNER表の主キーを参照することでマッピングします。もし、犬の所有者を変更するなどがあった場合に関連の紐付けを変更する必要があるのですが、その場合にオブジェクト上は2箇所の関連を書き換える必要がありますが、更新すべきテーブル上のカラムの値は一箇所です。オブジェクト上の変更を単純にテーブルの更新にマッピングしてしまうと、無意味に2回の更新をかけることになってしまうため、JPAでは@ManyToOne側の関連のみが更新に関与するという動作を行うことにより、この二重更新を防ぐ工夫がされています。*5

完全O/Rマッピングは動的なインピーダンスミスマッチも解決する

本格的なO/Rマッピングフレームワークではこのように継承、埋め込みオブジェクト、関連などのデータ構造の違いに起因する静的なインピーダンスミスマッチを吸収してくれますし、インピーダンスミスマッチといえばこのような静的なものを思い浮かべる人が実際多いと思います。実際、多くの内製フレームワークがサポートしている範囲はほとんどの場合せいぜいこの範囲のサポートに留まっています。しかし、JPAをサポートするような完全O/Rマッピングフレームワークでは、エンティティの状態更新とテーブルの更新頻度の違いというインピーダンスミスマッチの動的な側面にも光を当てています。
単純なCRUD処理が中心のアプリケーションであれば、データの更新はテーブルの行単位で実行されるため、特にこの動的なインピーダンスミスマッチが問題になることはありません。しかし、DDDが対象とするような本格的なドメインモデルにおいては、エンティティや集約そのものが状態をカプセル化する*6だけでなく、状態を部分的に更新したり、関連先の複数オブジェクトにまたがって更新するような複雑な振る舞いを保持すると考えます。このような場合には

  • エンティティの状態が更新されたかどうかによらず行全体を毎回UPDATEするのは非効率
  • エンティティの状態を部分的に更新するだけなので行全体のロックをトランザクションの最初から取得するのは無駄
  • 複数のエンティティの更新順序によってデッドロックの危険もある
  • 同一IDの複数のエンティティのインスタンスがメモリ中で存在した場合、更新漏れや上書きが起こるおそれがある
  • 関連先のエンティティを必ず参照するとは限らないため毎回ロードしてしまうのは無駄

といったような問題が出てきます。つまり、更新頻度がオブジェクトとテーブルとで異なるということが本質的です。これは動的なインピーダンスミスマッチの問題と呼ばれていますが、RDBMS上で本格的なオブジェクト指向ドメインモデル設計を行う場合には無視のできない問題となります。これらの問題に対処するためには

  • Identity Map → エンティティのキーに対するエンティティオブジェクトのキャッシュを保持し、作業単位(unit of work)*7内でのエンティティインスタンスの同一性を保障することで混乱を避ける。
  • Unit of Work → 作業単位内でオブジェクトに起こった更新を記録し、必要に応じてコミット前などにまとめて更新する。*8
  • Lazy Load → 関連先オブジェクトを一度に取得せず、必要に応じて後から取得する。
  • Optimistic Offline Lock → バージョン番号チェックなどにより同時更新時の上書きを防ぐ

などのパターンが知られています。JPAでもこれらパターンが実装されています。実際、JPAのEntityManagerは伝統的なDAOと違って、UPDATEやINSERTなどのタイミングをアプリケーションプログラムが明示的に制御することは通常しません。普通にエンティティオブジェクトの状態を更新するとUPDATE文や行ロックの取得は裏で非同期に処理されます。そのため、動的なインピーダンスミスマッチの問題が軽減されます。

まとめ

O/Rマッピングといっても、テーブルの行を一つのクラスのインスタンスに単純に詰め替えるような単純なものから、JPAのように完全O/Rマッピングを実装したものまでさまざまなものがあります。O/Rマッピングの種類により、どのレベルまでインピーダンスミスマッチが吸収されるかについての違いがあります。

  • 静的なインピーダンスミスマッチ
    • 継承関係の有無
    • 粒度の違い
    • 関連の方向性の違い
  • 動的なインピーダンスミスマッチ
    • 更新頻度の違い
    • 関連先データ取得タイミングの違い

もちろん、インピーダンスミスマッチを吸収するためには複雑な仕組みが必要となるため、必ずしも常に高度なO/Rマッピングが望ましいということではなく、要件の性質に応じて適切な手段を使い分けることが必要です。しかし、少なくともDDDが目指すような本格的なオブジェクト指向設計ドメインモデルを実装に取り入れる際にはこれらのインピーダンスミスマッチに対処してくれるフレームワークをインフラストラクチャとして利用し、正しく使いこなすことが重要になってきます。

*1:Atomicity(原子性)、Consistency(一貫性)、Isolation(独立性)、Durability(永続性)

*2:未だに内製にこだわっている組織もあるかもしれませんが、少なくともO/Rマッピングのような汎用的なコンポーネントOSSなどの広く使われている製品を流用するのが今では常識ではないでしょうか。

*3:今では、そのような単純なアプリケーションをJavaでわざわざカスタム開発すること自体が疑問視されるべきですが。

*4:この方式への対応は必須でなくJPAプロバイダーによる

*5:mappedBy属性のある@OneToMany側をinverseサイド、外部キーの更新に関与する側をownerサイドと呼ぶ

*6:より望ましい設計としては一意性にかかわらない状態を値オブジェクトとして抽出して委譲させる

*7:多くの設定では作業単位はDBのトランザクションと一致するが、作業単位が複数のDBトランザクションを含むように構成することもできる。

*8:JPAでは作業単位(永続コンテキスト)内で状態の更新が記録されているエンティティのインスタンスをManagedエンティティと呼びます。

ドラゴンボールで学ぶオブジェクト指向 改(第弐話)

ドラゴンボールオブジェクト指向の学習という元ネタのアイデアがうけたのか、ドラゴンボールで学ぶオブジェクト指向 改 - 達人プログラマーを目指しての記事がかなり大きな反響がありました。前回の記事では、クラス図とデザインパターンを使った設計により、ドラゴンポールの世界をモデル化する例について説明しました。ただし、この説明の内容だと、静的なモデリングに偏っていたところもあり、具体的にどのようにプログラムの設計に役に立つのかという点が見えにくいところがあったかもしれません。今回は、前回説明しきれなかった、いくつかの点について、もう一歩踏み込んで説明してみたいと思います。

プログラミング言語におけるクラスとオブジェクト

前回の記事では、概念的なオブジェクト指向モデリングの観点から、

  • クラス→複数のオブジェクトが共通で満たす性質(属性や振る舞い)を概念として表現したもの。(地球人武道家サイヤ人、戦闘型ナメック星人、種としてのセルなど)
  • オブジェクト→特定のクラスの性質を満たす具体的な人、物など(クリリンヤムチャ、悟空、ピッコロ、セルなど)

のようにクラスとオブジェクトを説明しました。この基本的な考え方を、どのようにプログラミング言語で実現するかについては、武道と同じでいろいろな流派があるのですが、C++Javaなど現在主流のオブジェクト指向言語ではだいたい以下のようにマッピングされています。

  • クラスはデータ(フィールド、メンバ変数)とロジック(メソッド、メンバ関数)を定義するための言語構造
  • クラスの保持するデータをメモリ上に割り当てることでそのクラスのインスタンスであるオブジェクトを表現する
  • クラスはオブジェクト(の参照)を格納する変数の型になる(Javaにはクラスの型という側面のみに注目したインターフェースと呼ばれる特殊なクラスがある)
  • 実行時にサブクラスのメソッドを呼び分けることでポリモーフィズムを実現する(仮想関数呼び出し、遅延結合、ダイナミックバインディング

そして、言語によってオブジェクトの生成のしかたにはさまざまな方法がありますが、ほぼすべての言語でnewと呼ばれる演算子を使って動的にオブジェクト(インスタンス)を生成して利用できるようになっています。生成したオブジェクトはヒープ(フリーストア)と呼ばれるメモリ領域に動的に確保されます。
たとえば、

地球人武道家 クリリン = new 地球人武道家("クリリン");
地球人武道家 ヤムチャ = new 地球人武道家("ヤムチャ");

などのように記述します。以上のコードでは地球人武道家クラスのインスタンスであるクリリンヤムチャという二つのオブジェクトを生成し、そのオブジェクトを参照するクリリンヤムチャという変数に格納しています。Javaの場合new演算子の後にはコンストラクタの名前を指定するのですが、コンストラクタはクラス名であるという決まりがあるので、実質的にクラスからそのクラスに属するオブジェクトを生成してメモリに割り当て、そのメモリ領域を参照する変数に代入していると考えることができます。
ドラゴンボールの世界だと7個のドラゴンボールを集めても神龍に一つの願ごとしかできず、また、その神龍も万能ではなく、サイヤ人をやっつけられないなど制約が大きいのですが、オブジェクト指向プログラミングの世界だと、プログラマーは万能の神のようになれますね。newというお願いを何度でも実行でき、その都度、新しいオブジェクトが生成されるのです。もちろん、事前にオブジェクトを生成する基になるクラスを作成しておく必要がありますが、人間が思いつくあらゆるものをコンピューター上で「創造する」ことができます。

メソッド呼び出しは生成したオブジェクトに対する命令の実行

Cなどの構造化言語のプログラマーの方はオブジェクト型の変数は構造体の一種と考えるとよいと思いますが、データの集合を保持しているだけでなく、必殺技を繰り出すといった振る舞いも同時にカプセル化しているという点に注目してください。

クリリン.技を繰り出せ("気円斬");

のように「クリリン」オブジェクトに対してメソッドを呼び出すことで命令を与えることができます。クリリンに命令すると、後はクリリンが自発的に振舞い、技を実行するだけでなく、クリリンの気の残量も自動的に減少します。オブジェクトには振る舞いとデータが同時にカプセル化されているというポイントを思い出してください。気円斬を実行するとどれだけ気が減少するかといったことはクリリン気円斬といったオブジェクト自身の中に組み込まれています。こういった様子を視覚的に表現するにはUMLのシーケンス図を利用します。

オブジェクト指向のプログラミングでは、このようにクラスから生成したオブジェクトに対してメソッドを通して命令を与えることで、処理を実行していきます。
なお、publicメソッドがオブジェクトに対する外部からの命令であるという点は英語で考えるとよりすっきりと理解できます。以下のエントリを参考にしてください。多くの日本人がオブジェクト指向プログラミングを苦手とするのは英語アレルギーだからか? - 達人プログラマーを目指して

変数の型とポリモーフィズム

クラスに対してnewを適用することでオブジェクトが生成できるのですが、クラスに親子関係がある場合、親クラスの型の変数に子クラスのインスタンスを代入できるという大切なポイントがあります。だから、

地球人武道家 クリリン = new 地球人武道家("クリリン");
サイヤ人 悟空 = new サイヤ人("悟空");
戦闘型ナメック星人 ピッコロ = new 戦闘型ナメック星人("ピッコロ");

と記述する代わりに

戦士 クリリン = new 地球人武道家("クリリン");
戦士 悟空 = new サイヤ人("悟空");
戦士 ピッコロ = new 戦闘型ナメック星人("ピッコロ");

のように記述することができます。サブクラスのインスタンスは親クラスのインスタンスであるという法則を思い出せば、言語上のこうした規則は自然に理解できると思います。このようにすべてのオブジェクトを同一の戦士型として扱うことができれば、たとえば、以下のように配列を使って一括処理するような場合にも便利です。

戦士[] Z戦士 = {new 地球人武道家("クリリン"), new サイヤ人("悟空"), new 戦闘型ナメック星人("ピッコロ"), ...};
for (戦士 せんし : Z戦士) { // 日本語だと大文字小文字の区別がないためクラス名とオブジェクト名の識別がちょっと苦しい
  せんし.技を繰り出せ(); // ポリモーフィズムによりメソッド名が同じでも型に応じた振る舞いをする。
}

オブジェクトの生成に関するパターン

このように、オブジェクト指向プログラミングでは、new演算子を使ってクラスからオブジェクトを生成することができますが、場合によってはオブジェクトが複雑な関連を持っていたりすると、初期化が複雑で難しいことがあります。デザインパターンではオブジェクトの生成や初期化に関するパターンがいくつか知られています。オブジェクトの生成を専任で受け持つクラスを別途設計します。このようなクラスは一般にファクトリーやビルダーなどと呼ばれます。
ドラゴンボールの世界では武道家を育成する場所がこのファクトリーやビルダーに相当すると考えることができます。戦士は生まれながらに技を習得しているというわけではないため、厳しい修行を積んで技を習得する必要があります。

クリリンが亀仙流に入門して技を習得するシーケンスを以下に示します。

流派の教義の多様性はStrategyパターンで吸収する

最後に、流派の教義について。同じ武道の流派でも鶴仙流と亀仙流では教義が大きく異なります。単純に流派そのものを継承させてこの違いを吸収することも不可能ではありませんが、

  • 流派間で共通していることもある
  • 共通部分を親クラスに持たせることで親子間のクラスの結合度が高くなる
  • 多くの言語では多重継承ができないため、複数のバリエーションを表現できない

などの欠点があります。複雑なドメインに対する設計では、変化する部分をインターフェースとして抽出し、そのサブクラスに個々のバリエーションを持たせるといった設計をします。これをStrategyパターンと呼んでいます。前節の図で教義インターフェースとその実装クラス(亀仙流の教義と鶴仙流の教義)との関係がこのStrategyパターンになっています。

最後に

一部には業務システムではオブジェクト指向は関係ないという意見もあるようですが、JavaC#などの現代的なプログラミング言語を使った開発において、オブジェクト指向の考え方を習得することは欠かせないことであると私は思います。オブジェクト指向というのは武空術や、かめはめ波のように特殊な才能を持った人のみが使いこなせる技ではなく、呼吸法や筋力トレーニングなどのような基礎体力にあたる部分だと思います。しかし、そういった基礎技術を身につけているかどうかでプログラマーとしての戦闘力が桁違いに変わってくるということもまた事実です。
SI業界の世界では、以下のように基礎技術が軽視される傾向があることは非常に残念なことです。実際、スーパーSEとして一目置かれるような方からも、以下のような意見が語られています。
http://www.hitachi-system.co.jp/superse/vol2/

前回、プログラミング好きはSEには向いていないとお話しましたが、極端な話、できなくても構いません。実際には、プログラマを経験してからSEになるケースが多いようなので、スキルがある方がほとんどのようですが、SEアシスタントとしてスタートし、プログラミングをほとんど経験しないで独り立ちしたというケースも少なくありません。

ただし、以前プログラマーの成長を考えないSIerの仮説は間違っている - 達人プログラマーを目指してで私が書いたように、プログラマーの基本的な技術力を重視することで生産性、品質、保守性などを桁違いに上昇させることができると思いますし、SIerプログラマーとしての基礎技術であるオブジェクト指向の習得などにもっと真剣にとりくむべきだと思います。
なお、本文とは関係ありませんが、YouTube集英社による悟空とアラレちゃんの震災に対する応援メッセージのビデオが紹介されています。収益は全額寄付されるようです。

ドラゴンボールで学ぶオブジェクト指向 改

ドラゴンボールといえば、大変に人気の高い国民的、いや世界的な漫画、アニメですが、昨日匿名ダイアリーでドラゴンボールをネタにしたオブジェクト指向の解説がホッテントリに入っていました。
ドラゴンボールで学ぶオブジェクト指向
多くの人に親しみやすい題材でオブジェクト指向の考え方を解説するというのは非常に興味深い試みなのですが、オブジェクト指向の説明としては不適切なところがあり、ちょっと残念な内容になっています。私自身ドラゴンボールの専門家(ドメインエキスパート)ではないため、不正確なところがあるかもしれませんが、ストーリーを思い出しながら、私なりにドラゴンボールをネタとしたオブジェクト指向の解説にリトライしてみたいと思います。
なお、オブジェクト指向でもプログラミング言語によって表現できる内容が異なるため、当然設計技法は違ってきます。ここではJavaC++C#Visual Basicといった静的な型付けの古典的なオブジェクト指向言語*1で設計することを念頭において説明することにします。

オブジェクト指向理解の第一歩はクラスとオブジェクトの正しい区別から

オブジェクト指向で設計するためにはまず、問題領域の概念を抽象的に表現するためのクラスを正しく抽出する必要があります。抽象化というと哲学的で非常に難しそうに聞こえるかもしれませんが、日ごろから我々は無意識にこの抽象化を行って物事を判断したり考えたりしています。たとえば、会社に通勤するのに電車を使っていることを想定してみてください。我々は「線路の上を電気の力で走る箱型の乗り物」を「電車」として認識しているわけです。実際に乗り込む車両は毎日違っているはずですが、とにかくそれを電車として認識できているのは細かい部分を無視して本質的なポイントを抽出する抽象化を無意識のうちに行っているからに他なりません。オブジェクト指向では抽象化によって名前を与えられた概念をクラスと呼んでいます。一方、毎日通勤時に乗車している一台一台の電車そのものはクラスのインスタンス(一例)であり、オブジェクトと呼ばれます。まずは前提知識としてこのことをしっかりと頭に入れてください。
電車に限らず、一般的に辞書に載っている名詞は抽象度の差こそあれ具体的なものに共通する概念に名前を与えたものです。ですからこれらはクラスの候補となり得ます。ただし、難しいのは名詞は共通概念としてのクラスを表すこともある一方で、具体的な一つ一つのオブジェクトを指し示す場合もあるということです。電車といった場合、概念的に電車というクラスを示す場合もあれば、今乗っている電車のオブジェクトそのものを指す場合もあります。
だから、時としてクラスとオブジェクトとの混同が起こります。多くのオブジェクト指向の解説書では問題領域の名詞を抜き出してクラスとして抽出すればよいと説明していることもありますが、実はそんなに単純ではなく、初心者の人はクラスとオブジェクトをきちんと区別して分析できないことがあります。たとえば、元ネタの記事ではクリリンべジータなどのキャラクターをすべてクラスとしてモデル化しています。実現したいプログラムの機能によってはこのような設計を行う可能性もあるかもしれませんが、これは一般的には適切な設計ではありません。確かに、クリリンヤムチャでは身長などの見かけや繰り出せる技も違いますから、一見別々のクラスのように思えてしまいますが、抽象的に考えてどちらも地球人の武道家であるとすれば、共通している事項も多くみられます。
よって、抽象化してしまえば、クリリンヤムチャも共に「地球人武道家」というクラスに属するオブジェクトであると考えてしまうことができます。それでは、悟空やピッコロさんはどうでしょうか?これらのキャラクターは地球人ではありませんし、変身したり、傷を自己修復するなど地球人にはない特別な振る舞い(メソッド)を持っています。クリリンがどのようにがんばって修行してもそのような能力を習得することは不可能なのであり、クリリンたちと同一のクラスで表現することには無理があります。よって、「サイヤ人」「戦闘型ナメック星人」という別のクラスを抽出し、それらのオブジェクトであると考える必要があります。では、人造人間のセルはどうでしょうか?セルには他人を吸収することで戦闘力を向上させ、完全体に進化するといった特殊な能力があります。このような能力を持ったオブジェクトは劇中では他に存在しません。ですから、セルそのものはそういった特殊な生物を表すクラスなのですが、そのインスタンスであるオブジェクトは一体しか存在しないということになります。一般的にはクラスに対しては複数のオブジェクトのインスタンスが存在するのですが、このようにインスタンスが一つしかない特殊なクラスもあります。*2
ここまで説明したことをもう一度整理すると

  • クラス*3→複数のオブジェクトが共通で満たす性質(属性や振る舞い)を概念として表現したもの。(地球人武道家サイヤ人、戦闘型ナメック星人、種としてのセルなど)
  • オブジェクト→特定のクラスの性質を満たす具体的な人、物など(クリリンヤムチャ、悟空、ピッコロ、セルなど)

ということになります。そして、オブジェクトは必ず何かのクラスのインスタンス(例)となります。たとえば、クリリンは地球人武道家インスタンスとなるオブジェクトですし、悟空はサイヤ人インスタンスですね。*4

英語だと冠詞の有無でクラスとインスタンスの違いがより明確なところがある

ところで、残念ながら、日本語の文法には英語の「a」「the」などの冠詞や複数形といった概念がありません。そのため、英語で言うところのan appleもthe appleも冠詞のないappleもすべて「りんご」です。でも、英語ではこれらを厳密に区別しています。
Apple is one of the most popular fruits in the world. (りんごは世界でもっとも人気の高いフルーツだ)
この場合はりんごという概念について説明しているのでaやtheがつきません。*5この場合のりんごはクラスです。一方、
There is an apple on the table. (テーブルの上にりんごがある。)
の場合はりんごという概念ではなく、りんごのひとつのインスタンスが机の上に存在していることを表しています。ですからこの場合のりんごは一つのオブジェクトを表していることになります。*6このように、英語などを話す外国人の場合、可算名詞に対して無意識のうちにクラスとオブジェクトとを区別していることになるのですが、日本語だとその区別があいまいです。英語の冠詞については、以下の説明が参考になります。
http://homepage1.nifty.com/samito/articles.a.an.the.htm

クラス間の継承関係(汎化・特化関係、is-a関係)を考える

オブジェクトとクラスを正しく区別することができるようになったら、世の中のさまざまなオブジェクトや概念に基づいてクラスを抽出することができます。このようにして抽出したクラスはまったく独立しているわけではなく、さまざまな関係で結び付けられています。
たとえば、先ほどの例では「サイヤ人」「戦闘型ナメック星人」「地球人武道家」「セル」のように別々のクラスを抽出しましたが、これらのクラスはお互いにまったく無関係というわけではありません。これらのクラスには

  • 戦闘力という属性を持っている
  • 複数の技を保持し、繰り出すことができる

などの共通の性質があります。このような場合、この共通の性質を満たす抽象的な概念に名前をつけて、別途クラスとして抽出することができます。この場合は「戦士」といったクラスを抽出することができるでしょう。このような上位の概念を親クラス(スーパークラス)と呼びます。つまり、「戦士」は「サイヤ人」や「地球人武道家」の親クラスです。逆に「地球人武道家」は「戦士」の子クラス(サブクラス)と呼ばれます。このような親子関係を汎化関係と呼びます。(厳密にはちょっとニュアンスが異なりますが)一般的なプログラミング言語ではこのような関係はクラスの継承によって表現するため、継承関係とも呼ばれます。文字通り親クラスの属性やメソッドを子クラスが継承することができます。*7こうした関係をUMLのクラス図を使って以下のように表現するとお互いのクラスの関係が理解しやすくなります。

クラス図において、白抜きの三角矢印の線が継承関係を表しています。子供から親に向かって線を引きます。
なお、継承関係で重要なこととして、一般的に子供クラスのインスタンスは親クラスのインスタンスでもあるということを理解しておきましょう。つまり、悟空はサイヤ人クラスのインスタンスですが、親クラスの戦士のインスタンスでもあります。

クラス間の関連(has-a関係)を考える

次に、戦士が繰り出す技について考えてみます。技にはかめはめ波太陽拳などさまざまな種類のものが登場します。これらは、それぞれ効果もまったく異なるため、別々の子クラスとして抽出するのが適切です。*8先ほど説明した継承関係が使えるということです。一方、戦士は一般的に複数の技を習得して保持しています。このようにあるクラスのインスタンスが別のクラスのインスタンスを保持しているような関係は実際よく登場します。このような関係は先ほど説明した継承関係とは異なり関連と呼ばれます。*9重要なポイントとして関連には多重度(カージナリティー)というものがあり、1対1、1対多、多対多などの種類に分けられます。戦士は複数の技を保持でき、また、技も複数の戦士によって保持されますから、戦士と技の関係は多対多の関連になっています。この関係をクラス図で表現すると以下のようにモデル化できます。

Commandパターンを使ってさまざまな技をポリモーフィックに表現する

戦士は修行したりして成長するにつれて新しい技を習得していきます。*10新しい技を習得すればそれを繰り出すことができます。従来のオブジェクト指向でないプログラミングであれば、新しい技(機能)が加わるにつれてswitch文のような分岐が増えていくことになります。呼び出し側で処理を呼び分ける必要があるからです。しかし、オブジェクト指向言語ではどんなに技の種類が増えても、常に「繰り出す」という共通のメソッドの呼び出しで実行することができるのです。どうして、こういうことが可能かというと、戦士クラスが、かめはめ波クラスなど具象的な技のクラスと関連付けられておらず、抽象的な技と関連付けられているからですね。個々の技がどのような効果を持つかにかかわらずこれらは技という共通のクラス(Javaの場合、インターフェース)を継承しており、常に「繰り出す」という共通のメソッドの呼び出しで実行することができるのです。技ごとに別々のメソッドを呼び分ける必要がありません。このように共通のメソッド呼び出しで、対象とするオブジェクトの種類に応じてまったく異なるさまざまな処理を実行可能な性質をポリモーフィズム多態性)と呼びます。
最初はちょっと理解するのが難しいかもしれませんが、たとえとして、ここではドラゴンボールRPG的な(アクションゲームだと操作が複雑なので)ゲームを想像してみましょう。キャラクターの戦士が新しい技を習得するにしたがって、戦闘時に選択可能な技は増えていきます。この場合、どの技を実行するにしてもゲームのユーザーがすることは適切な技を画面の一覧から選択してAボタンを押すことです。どの技を選択したかにより実際の攻撃の効果はまったく異なるものになるのですが、ユーザーの操作は常にAボタンの押下で一定しています。どの技を実行するかで操作方法をswitch文的に変える必要がありません。そんなことになっていたらユーザーは操作を覚えるのが大変でいやになってしまうでしょう。これはまさにポリモーフィズムの例になっています。
なお、初心者のうちは、ここで示したクラス図のような関係をいきなり思いをつくことが困難かもしれませんが、実はこのようにあるオブジェクトが複数のオブジェクトの集合を保持して、その機能をポリモーフィックに呼び出すというパターンは実際よくあることなのです。WordなどのGUIアプリケーションのメニューバーやツールバーRPGのコマンドメニューなどもそうですね。このような設計上の常套手段はデザインパターンとして知られていますが、特に今回のようなパターンはCommandパターンと呼ばれます。こうしたデザインパターンを知っていれば、ポリモーフィズムを活用した適切なクラス設計を思いつくのが簡単になります。

サイヤ人の変身をStateパターンで実現する

次に、サイヤ人の変身について考えてみます。サイヤ人は他のクラスの戦士と違って、大猿に化けたり、スーパーサイヤ人になったり、まったく別の戦闘能力を持つ状態に動的に変化することが可能です。Javaのように実行時の動的なクラスの変更が認められない言語では、こうした状態を表現するにはちょっとした工夫が必要となります。悟空などのオブジェクトのクラスを途中で別のクラスに変更することはできないのです。そこで、以下のように設計してみることにしてみます。

悟空というキャラクター(魂?)は変えられないのですが、特定の条件を満たすことで大猿やスーパーサイヤ人などまったく別の肉体に変化すると考えるのです。この場合、我々が衣服を着替えるのと同じ発想で、サイヤ人が複数の肉体と関連を持つことができると考えます。同時に使用可能な肉体は一つという制約がありますが、状態によってどれかの肉体を選択して闘うと考えればよいのです。このように状態によって関連付けられるクラスを切り替える手法はStateパターンとして知られています。

フュージョンはCompositeパターンで表現できる

最後に、ちょっと難しい例としてフュージョンについて考えてみましょう。劇中ではトランクスと悟天が合体してゴテンクスという強力な戦士が誕生する話が魔人ブウの話で出てきます。厳密にはフュージョンサイヤ人固有の技ではなかったと思いますが、劇中ではサイヤ人のみが使っていたため、ここではサイヤ人固有の技として考えることにします。
どのような設計が考えられるでしょうか。ここで、ゴテンクスというクラスを作るということを考えてしまった人は残念ながらまだまだクラスとオブジェクトの違いがよく理解できていない人です。ゴテンクスについて、もう少し慎重に分析してみると

  • 2人のサイヤ人が合体することで誕生する
  • 合体後も、もともとのキャラクターの性質を保ちつつもまったくの能力の異なる戦士が誕生する

などの性質があることがわかります。したがって、以下のクラス図のように「複合サイヤ人」というサイヤ人のサブクラスを作成することにしましょう。

そして複合サイヤ人にはもともとの合体前の2人のサイヤ人と関連付けされています。このように複数のオブジェクトを組み合わせて、一つのオブジェクトのように処理するパターンもよく登場するのでCompositeパターンとしてパターン化されています。このパターンは他の戦士を吸収するセルやブウの設計にも応用できると思います。

まとめ

ここではドラゴンボールの戦士をモデル化することを例としてオブジェクト指向設計の基本的な考え方について説明しました。

  • クラスとオブジェクトとの違い。クラスは共通の概念、オブジェクトはどれかのクラスに属する一つ一つのものである。
  • クラスは概念を抽象化したものなので、戦士や複合サイヤ人などのように登場する具体的なものから直接には連想しにくいものもある。(適切なクラスを発見できてこそ一人前のオブジェクト指向プログラマーといえる)
  • さまざまなクラスは共通の性質に着目することで継承関係をモデル化することができる。
  • クラスは戦士が技を持つというような関連を持つことができる。
  • クラスが共通のクラス(インターフェース)を実装することでポリモーフィズムを実現することができる。
  • クラスの設計を行ううえではデザインパターンなどのパターンが役に立つ。

なお、ここではドラゴンボールを例としてオブジェクト指向の考え方の基本を説明しましたが、実際にはオブジェクト指向はプログラミングを行うことではじめて深く理解することができるものだと思います。私のブログではJava認定試験のリファクタリングを題材にした以下も参考にしていただけると思います。
Javaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その1) - 達人プログラマーを目指して
Javaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その2) - 達人プログラマーを目指して
Javaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その3) - 達人プログラマーを目指して

続編も書きました。
ドラゴンボールで学ぶオブジェクト指向 改(第弐話) - 達人プログラマーを目指して

*1:C++の設計者であるストラウストラップの定義に近い実装になっている言語

*2:広い意味でシングルトンと呼ばれることもあります。

*3:クラスという言葉には本来階級分けとか分類という意味があります。学校のクラスなども生徒を分類したものです。本来の用語の意味としては、クラスはオブジェクトを種類ごとに分類する手段と考えることもできます。ただし、分類の手段として概念の説明が付随するため、「クラスは概念」という説明をされることが多いと思われます。

*4:ここではオブジェクトとインスタンスは同義語として使っています。厳密にはオブジェクトは「もの」そのものを指し、インスタンスといった場合、どのクラスに分類されるオブジェクトかという点を強調する意味があります。

*5:英文法の教科書に出てくるA dog is a faithful animal. という文は犬という一般概念について説明しているのにaが付いています。この場合、犬が忠実な動物であることを典型的なインスタンスを頭に思い浮かべることで描写しているためと考えられます。つまり、クラスではなく、JavaScriptのようにプロトタイプのインスタンスを元に一般の性質を考えていることになります。

*6:実際、Smalltalkなどのコーディング規約ではオブジェクト名にaやtheの接頭辞付けるケースもあります。

*7:厳密にはフィールドやメソッドの実装を親クラスから継承することに加えて、型が継承されるということを区別する必要がありますがここでは説明しません。

*8:実際には、開発したいプログラムのユースケースに依存します。単に技のリストをデータとして保持するのが目的なのであれば、サブクラスを作成せず技自体を具象クラスにしてデータを保持させれば十分です。ここでは、ゲームなどのように個々の技のロジックにバリエーションあるようなケースを想定しています。

*9:場合よって集約、コンポジションとも呼ばれる関係となります。ここでは詳細の説明は割愛します。

*10:この動的な性質により、通常のオブジェクト指向言語ではある時点で戦士が保持する技の種類ごとに個別に戦士のクラスを作ることは不適切です。

Javaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その3)

少し間が開いてしまいましたが、前回のJavaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その2)に続いて、試験問題のリファクタリングについて説明します。

Template Methodデザインパターンを使った制御の反転

前回までのリファクタリングで、

  • 全体的にレイヤー化されたパッケージ構造を規定
  • わかりにくい変数名やクラス名をリネーム
  • Repository、Consoleなど複雑で再利用可能な処理をインタフーフェースとその実装クラスとして抽出
  • 各機能を担当するアプリケーション層のクラスに共通のインターフェースを実装させることでポリモーフィック(多態的)に処理を起動

といった手順を実行してきました。これまでのリファクタリングで、オリジナルのソースコードに比べると飛躍的に可読性の高いコードになっていると思いますが、ところどころで同じような制御構造が繰り返し出現することが気になります。つまり、達人プログラマーDRYの原理に違反しているということです。
たとえば、メインクラスでは以下のような制御構造が現れます。

	public void run() {
		
		while (true) {
			beforeDisplayMenu();
			
			String inputCode = printMenuAndWaitForInput();

			if (isEndCommand(inputCode)) {
				// 終了
				break;
			}
			
			runFunction(inputCode);
		}
	}

	@Override
	protected void runFunction(String inputCode) {
		Function subFunction = functionMap.get(inputCode);
		if (subFunction == null) return;
		
		assert subFunction != null;
		
		try {
			subFunction.run();
		} catch (Exception ex) {
			// TODO 適切な例外処理
			ex.printStackTrace();
		}
		
		if (isConfirm(inputCode)) {
			console.accept("エンターキーを押すとメニューに戻ります。");
		}
	}

つまり、「Eキー」が入力されるまで無限ループして、機能を呼び出すという処理を繰り返しています。このような処理はメインクラスだけでなく、各サブ機能を呼び出すクラスの中にも何回か登場しますし、また、別のコンソールアプリケーションでも必ず出現するものです。従来の言語では継承が利用できないため、このような場合には通常の手段ではコピペや生成ソースコードの自動生成に頼らざるを得ません。しかし、Java言語の場合、継承と抽象メソッドを利用することで汎用の制御構造を親クラスに定義することができます。そして、複数のサブクラス間で簡単にロジックを再利用することができます。実際、今の場合、次のような抽象クラスを定義することができます。

public abstract class AbstractMainProgram implements Runnable {

	protected Map<String, Function> functionMap = new HashMap<String, Function>();

	protected Console console;

	public void run() {
		
		while (true) {
			
			beforeDisplayMenu();
			
			String inputCode = printMenuAndWaitForInput();

			if (isEndCommand(inputCode)) {
				// 終了
				break;
			}
			
			runFunction(inputCode);
		}
	}

	protected void beforeDisplayMenu() {}

	protected boolean isEndCommand(String inputCode) {
		return "E".equals(inputCode);
	}

	protected abstract String printMenuAndWaitForInput();
	
	public Map<String, Function> getFunctionMap() {
		return functionMap;
	}

	public void setFunctionMap(Map<String, Function> functionMap) {
		this.functionMap = functionMap;
	}
	
	protected boolean isConfirm(String inputCode) {
		return !"S".equals(inputCode);
	}

	/**
	 * 機能一覧と機能コード一覧を表示し,機能コードを取得して該当の機能を呼び出す
	 */
	@Override
	protected void runFunction(String inputCode) {
		Function subFunction = functionMap.get(inputCode);
		if (subFunction == null) return;
		
		assert subFunction != null;
		
		try {
			subFunction.run();
		} catch (Exception ex) {
			// TODO 適切な例外処理
			ex.printStackTrace();
		}
		
		if (isConfirm(inputCode)) { // 人材管理と稼働状況管理のみ
			console.accept("エンターキーを押すとメニューに戻ります。");
		}
	}
}

このクラスのrun()メソッドでは固定の制御構造が記述されていますが、このメソッドから複数の抽象メソッドやフックメソッドが起動されています。この場合のrun()メソッドのように複数の抽象メソッドの呼び出し順序を規定するメソッドはTemplate Methodとして知られていますが、特に有名なGoFデザインパターンの中でももっとも有名なものの一つとして知られています。
このような親クラスが定義できると、後はサブクラスで必要な抽象メソッドやフックメソッドをオーバーライドするだけで済むようになるのです。以下の例を見てください。

public class TempHRManagementProgram extends AbstractMainProgram {

	/**
	 * 機能一覧
	 */
	private static final String[] MENU_LIST = {
			"_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/",
			"            人材管理システム",
			"                メニュー",
			"  [1].人材検索(S)",
			"  [2].人材管理(JI:追加 JU:更新 JD:削除)",
			"  [3].稼働状況管理(KI:追加 KD:削除)",
			"  [4].終了(E)",
			"_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/"};

	/**
	 * 機能コード一覧
	 */
	private static final List<String> CODE_LIST = Arrays.asList( "S", "JI", "JU", "JD", "KI", "KD", "E" );

	@Override
	protected String printMenuAndWaitForInput() {
		console.display(""); //改行
		console.display(MENU_LIST);
		return console.acceptFromList(CODE_LIST, "どの機能を実行しますか?");
	}

...	

}

このサブクラスでは、このアプリケーションに固有のメニュー文字列を定義して、表示するprintMenuAndWaitForInput()メソッドのオーバーライドが行われていますが、その他の基本的な制御構造は親クラスから継承したTemplate Method内で行われているため、サブクラスで定義する必要がありません。
確かに、このようなテクニックに慣れていないと、全体の制御の構造が追いかけにくいと感じる人もいるかもしれませんが、制御構造を決めるのは親クラス(一般にフレームワーク)であり、アプリケーションはフレームワークから呼び出されたタイミングで必要な処理を行うコードを記述するという流れになります。ちょっと受動的な感じですが、上から指示されて初めて何かをする人のように、全体の制御の流れは汎用的なコード内で行い、具体的なロジックは呼び出されたタイミングで実行されるのです。この考え方は制御の反転(Inversion of Control、IoC)と呼ばれており、オブジェクト指向プログラミングを行う上で欠かすことのできないテクニックとなっています。
なぜ、IoCが重要なのでしょうか?いろいろなポイントがあると思いますが、理解すべき重要なこととして、IoCにより無駄な重複ロジックが共通化されるだけでなく、関心事が分離されるということが大切だと思います。つまり、この例だとメニューコマンドの入力を受け取って、それによりサブ機能を呼び出すという部分は、フレームワークを作成するプログラマーが理解していればよく、一般に個別のアプリケーションロジックを記述するプログラマーが意識する必要のない部分となるのです。逆にアプリケーションロジックを記述するプログラマーはメニュー構成の文字列など具体的な仕様に意識を集中できるようになります。この関心事の分離によって、フレームワーク作成者、アプリケーションロジック作成者の双方にとってプログラムは理解しやすいものとなり、また、おのおの独立して機能を拡張しやすい構造が提供されるのです。

DIコンテナーの導入

古典的なJavaのプログラムでは以上のようなデザインパターンを利用することで制御の反転を実現することが普通でした。ただし、現在ではSpring FrameworkのようなDIコンテナーと呼ばれるフレームワークを利用することで、IoC的な設計をさらに簡単に実現することができるようになっています。本来、試験問題のリファクタリングとしてはこのようなフレームワークを利用することはズルなのかもしれませんが、実際のアプリケーション開発でDIやそれに類する技術を使わないということはほとんどなくなってきていますし、教育目的からもDIを使った方が好ましいと思いますので、あえてSpring Frameworkを導入することにしました。以下は人材情報の削除機能を実現するクラスです。

/**
 * 人材情報削除
 */
@Component
public class DeleteHRFunction implements Function {

	@Inject
	private HumanResourceRepository hrRepository;

	@Inject
	private Console console;

	@Inject
	private HumanResourceView hrView;
	
	private HumanResource selectedHumanResource;

	/**
	 * 人材管理(削除)メニューの実行
	 */
	public void run() {
		selectHumanResource();
		
		deleteHumanResource();
	}

	private void selectHumanResource() {
		// 人材ID入力
		long hrId = console.acceptLong("人材IDを入力してください。", new ValidInput<Long>() {
			@Override
			public boolean isValid(Long input) { // 人材ID存在チェック
				return hrRepository.findById(input) != null;
			}
		});
		
		selectedHumanResource = hrRepository.findById(hrId);
		
		hrView.display(selectedHumanResource);
	}
	
	private void deleteHumanResource() {
		if (console.confirm("この人材情報を削除しますか?(Y はい N いいえ)", "Y", "N")) {
			hrRepository.delete(selectedHumanResource.getId());
			console.display("削除しました。"); 
		}
	}
}

クラスのフィールド宣言を見るとわかるように、このクラスの実行にはファイル入出力を行うHumanResourceRepositoryと画面出力を行うConsoleの実装クラスと相互にやり取りすることが必要になります。前回までのリファクタリングでやったように、普通オブジェクト指向プログラミングでは、それぞれの役割ごとに適切なクラスを抽出し、それぞれに処理を担当させることでプログラムが動作するため、あるクラスを実行させるには、そのクラスが依存している他のクラスのインスタンスを生成し、準備してやらなくてはなりません。しかし、そうした準備は本来このクラス自身で行いたい本質的なロジックそのものとは無関係のものです。SpringのようなDIコンテナーを使うと、このように依存関係にあるオブジェクトを自動的に生成し、フィールドに代入(=インジェクション)してくれます。実際、上記の例のように@Injectというアノテーションをつけておくだけで、すべて自動的に代入が行われるのです。
前節のTemplate Methodパターンの例と同じで、全体の処理の流れは見えなくなっていますが、このクラスで実現したい本質部分はより明確になっていると思います。
なお、DIについては、依存関係の自動化によるコードの簡易化ということ以外に、インターフェースに対するコーディングを容易化し、クラス間の結合度を下げることで単体試験を容易にするといったメリットなど他にもさまざまな効果がありますが、ここでは説明は割愛させていただきます。(Web上で探せば、今ではたくさんの日本語の入門記事が見つかると思います。)

ここまでのリファクタリングの結果

参考までに、ここまでのリファクタリングの結果を以下に添付させていただきます。(ビルドするためにはMaven2の実行環境が必要です。)
refactored.zip 直
(最新版はGitHubで公開しています。GitHub - ryoasai/certification-refactoring: Java認定試験のリファクタリングサンプル
もちろん、あくまでもこれはリファクタリングの例に過ぎないので、別の形になることも大いにあり得ると思いますが、基本的なOOPのテクニックは参考にしていただけると思います。

さらなる課題

これまでのリファクタリングで相当コードは単純になりましたが、コードが単純化されるとやりたいことが新しく見つかるということもあります。

  • 単体試験を作成する。(本来はテストファーストでやるべきですが)
  • 配列とリストなど整合性のとれていないAPIを統一する
  • 単一責任原則(SRP)にしたがっていないメソッドやクラスの分割を調整する
  • 「display」と「print」などのボキャブラリーのゆれを統一する
  • コンソールクラスを拡張して、画面ログを保存したり、入力補完したりする機能を付加する
  • Webインターフェースを作ってみる
  • データをテキストファイルからDBに移行する
  • 複数ユーザーの同時操作を可能にする(マルチスレッド対応)
  • データアクセス層でメモリ上のキャッシュによる高速化を実現する
  • アプリ層のクラスをGroovyなど軽量言語で記述することによりさらに簡易化する
  • 画面構成の作成部分をDSL的にもっと簡単に定義できるようにする。
  • アノテーションなどのメタ情報を用いてPojoのフィールドと文字列配列との変換を自動化する

結局、保守に忍耐力が必要だったオリジナルのコード(SI業界(日本)のJavaプログラマーにはオブジェクト指向より忍耐力が求められている?)とのもっとも重要な違いは、プログラマーが楽しんで機能を追加できるようになるというところにあるのではないでしょうか?業務システムの開発でも、こうした楽しいプログラミングができるような環境になれば、プログラマーが幸せになれるのはもちろんのこと、高い品質と拡張性を手にいれられるユーザーにとってもメリットがあるのであり、Win-Winでお互いに幸せになれるはずなのです。そのためには、ちょっとした社員教育パラダイムシフトの導入を行えばよいのであり、どの会社でも決して不可能なことではないと信じます。

Javaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その2)

前回のJavaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その1)に引き続き、試験問題のリファクタリングについて考えます。

画面入出力処理の抽象化とカプセル化

前回はこの試験問題でもっともコーディングが面倒なファイル入出力関連の処理をRepositoryというインターフェース型で抽象化することで、処理が共通化されるだけでなく、全体のプログラムの記述が簡易化されることを説明しました。この試験問題のプログラムでファイル入出力とならんでもう一つ面倒なのは、コンソールを使ったユーザーとの対話処理を行う部分です。この処理は一見単純そうですが、不正な入力値があった場合に処理を繰り返すなど、COBOL級の分岐とループ構造だけで表すと処理が結構複雑化してしまいます。実際、オリジナルのソースでは、以下のようなきわめて複雑な多重ループ構造がいたるところに出現します。

(Javaプログラミング能力認定試験1級問題より引用)

//データのある人材IDが入力されるまでループ
DELETEKID:
while( true ) {
	
	String jID;	//人材ID
	
	while( true ) {	//人材IDが正しく入力されるまでループ
		jID = displayMessage( KD_MESS.GETID );	//人材IDの取得
		if( (new JDisplay(jID).getjData()) != null ) {
			break;
		}
	}
	
	KDisplay kdisplay = new KDisplay(jID);
	
	while( true ) {
		//人材IDの取得
		this.kData = kdisplay.startThis();	//稼働状況を表示
		if( kData != null ) {
		No = displayMessage( KD_MESS.GETKID );	//削除する稼働状況IDの取得
			if (chkKData(No))
				break DELETEKID;
		} else {
			continue DELETEKID;
		}
	}
	
}

String YorN = displayMessage( KD_MESS.GETYN );
while( true ) {
	if( YorN.equals("Y") ) {
		if( deleteData(No) )
			System.out.println( "削除しました。" );	//削除日付の格納
		else
			System.out.println( "削除できませんでした。" );
		break;
	} else if( YorN.equals("N") )
		break;
	else
		YorN = displayMessage( KD_MESS.R_GETYN );
}

以上のコードは多重の無限ループとなっており、多重ループを抜けるためにラベル付きbreak文(goto文に相当)が使われています。COBOL時代はこうした複雑なコードを一瞬で作成、理解できたら、相当の達人プログラマーとされていたのかもしれません。しかし、少なくともJavaオブジェクト指向言語です。このような複雑なコードの作成と保守に余分な神経をすり減らすのではなく、適切なインターフェースを抽出することではるかに楽ができます。
前回ファイル入出力に対してRepositoryというインターフェースを考えたのと同じ要領で、コンソールを使ったユーザーとの対話処理を抽象化するConsoleというインターフェースを作成することを考えます。既存のソースコードを読むとわかりますが、このインターフェースには以下のような機能を持たせます。

  • 単に文字列をメッセージとして表示する。
  • 文字列を入力する。
  • 文字列を入力するが、正しい入力が得られるまで入力を繰り返す。
  • 数値を入力する。
  • 日付を入力する。
  • 選択肢を表示し、その中から一つの項目を選択する。
  • はい、いいえを選択する。

Javaのインターフェースとしては具体的に以下のようなものを作成することになります。

package sample.common.console;

import java.util.Date;
import java.util.List;

import sample.common.entity.Identifiable;
import sample.common.entity.NameId;

public interface Console {

	/**
	 * メッセージを表示する。
	 * @param message 表示対象メッセージ
	 */
	void display(String message);

	/**
	 * YesNoの選択メッセージを表示する。
	 * @param message 表示対象メッセージ
	 * @return Yesが選択された場合はtrue
	 */
	boolean confirm(String message, String yes, String no);
	
	/**
	 * メッセージとともに入力プロンプトを表示し、標準入力からの入力を受け付ける
	 * 
	 * @param 表示メッセージ
	 * @return 入力文字列
	 */
	String accept(String message);

	/**
	 * メッセージとともに入力プロンプトを表示し、標準入力からの入力を受け付ける。
	 * 正しい入力値が得られるまで、再度入力を繰り返す。
	 * 
	 * @param 表示メッセージ
	 * @return 入力文字列
	 */
	String accept(String message, ValidInput<String> validInput);

	/**
	 * メッセージとともに入力プロンプトを表示し、標準入力からの整数入力を受け付ける
	 * 
	 * @param 表示メッセージ
	 * @return 入力値
	 */
	int acceptInt(String message);

	int acceptInt(String message, ValidInput<Integer> validInput);

	/**
	 * メッセージとともに入力プロンプトを表示し、標準入力からの長整数入力を受け付ける
	 * 
	 * @param 表示メッセージ
	 * @return 入力値
	 */
	long acceptLong(String message);

	long acceptLong(String message, ValidInput<Long> validInput);
	
	/**
	 * yyyyMMdd書式で日付を入力する。正しい日付が入力されるまで処理を繰り返す。
	 * 
	 * @param message 表示メッセージ
	 * @return 入力された日付
	 */
	Date acceptDate(String message);

	/**
	 * 日付を入力する。正しい日付が入力されるまで処理を繰り返す。
	 * 
	 * @param message 表示メッセージ
	 * @param format 日付フォーマット
	 * @return 入力された日付
	 */
	Date acceptDate(String message, String format);

	/**
	 * メッセージとともに選択肢のリストを表示する。
	 * 選択結果を返す。正しい選択結果が入力されるまで、内部で再入力を促す。
	 * 
	 * @param selectList
	 * @param message
	 * @return 選択結果
	 */
	String acceptFromNameIdList(List<? extends NameId<?>> selectList, String message);

	String acceptFromIdList(List<? extends Identifiable<?>> selectList, String message);

	String acceptFromList(List<String> selectList, String message);
}

文字列の出力をdisplay、入力をacceptとしたのは、あえてCOBOL風のボキャブラリーを利用するちょっとした遊び心からです。このクラスを実装してしまえば、基本的にあらゆる機能から再利用することができます。実際、前回のRepositoryによるファイル入出力の抽象化と合わせることで、「稼動」の登録処理は、以下のようにきわめて簡単に記述できます。

package sample.app.work_management;


import java.io.File;

import sample.common.console.Console;
import sample.common.console.ConsoleImpl;
import sample.common.console.ValidInput;
import sample.common.io.CharSeparatedFileRepository;
import sample.common.program.Function;
import sample.domain.HumanResource;
import sample.domain.Partner;
import sample.domain.Work;

/**
 * 稼働状況入力
 */
public class InputWorkFunction implements Function {

	// TODO DI化
	private CharSeparatedFileRepository<Work> workRepository = new CharSeparatedFileRepository<Work>();
	private CharSeparatedFileRepository<HumanResource> hrRepository = new CharSeparatedFileRepository<HumanResource>();
	private CharSeparatedFileRepository<Partner> partnerRepository = new CharSeparatedFileRepository<Partner>();
	private Console console = new ConsoleImpl();
	
	public InputWorkFunction() {
		workRepository.setMasterFile(new File("kadou.txt"));
		workRepository.setWorkFile(new File("kadou.tmp"));
		
		partnerRepository.setMasterFile(new File("torihiki.txt"));
		partnerRepository.setWorkFile(new File("torihiki.tmp"));
	}
	
	/**
	 * 稼働状況管理(追加)の実行
	 */
	public void run() {
		Work work = inputData();

		doCreate(work);
	}


	/**
	 * 稼働状況の入力
	 */
	private Work inputData() {
		Work work = new Work();
		
		long hrId = console.acceptLong("人材IDを入力してください。", new ValidInput<Long>() {
			@Override
			public boolean isValid(Long input) { // 人材ID存在チェック
				return hrRepository.findById(input) != null;
			}
		});

		work.setHrId(hrId);
		
		work.setPartnerId(console.acceptFromNameIdList(partnerRepository.findAll(), "取引先を選択してください。"));
		work.setStartDate(console.accept("稼動開始日を入力してください。"));
		work.setEndDate(console.accept("稼動終了日を入力してください。"));
		work.setContractSalary(console.accept("契約単価を入力してください。"));
		
		return work;
	}

	/**
	 * 稼働状況のファイルへの登録
	 */
	private void doCreate(Work work) {
		workRepository.create(work);
		
		console.display("登録されました。");
	}
}

このプログラムなら、よほどコードの読解が遅い人でも数分もあれば完全に内容を把握できると思います。複雑な処理を抽象化したことで、「入力を受け取る」「ファイルに登録する」など外部仕様書に近い非常に高いレベルでプログラムが記述されていることに注目してください。COBOL時代ではいかに大量のコードを速く記述し、理解できるかというところがプログラマーの能力だったのかもしれませんが、オブジェクト指向言語の場合は、いかにコードを単純にわかりやすく記述できるかというところがプログラマーにもっとも要求される能力になるのです。
参考までに、上記と同じ機能を実現しているオリジナルのソースを以下に引用しておきます。

(Javaプログラミング能力認定試験1級問題より引用)

/* KInput.java
 */

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.File;
import java.io.IOException;
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.FileNotFoundException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.ArrayList;

/** 稼働状況入力
 */
class KInput extends GChars {
	/** メッセージ選択用列挙型定数
	 */
	enum KI_MESS { GETID }

	/** 稼働状況管理(追加)の実行
	 */
	void startThis() {
		String jID;	//人材ID
		while( true ) {	//人材IDが正しく入力されるまでループ
			jID = displayMessage( KI_MESS.GETID );	//人材IDの取得
			if( (new JDisplay(jID).getjData()) != null ) {
				break;
			}
		}
		if( writeData(jID,getData(getTList())) ) {
			System.out.println( "登録されました。" );
		}
	}

	/** 人材IDと稼働状況マスタを照合,格納されている最大の稼働状況番号を検索し,それに
	 * 1を加えた値を入力された稼働状況に割り当てて稼働マスタに出力
	 * @param jID 人材IDを表す文字列
	 * @param inData 入力された稼働状況を表す文字列配列
	 * @return 正常終了フラグ
	 */
	boolean writeData( String jID,String[] inData ) {
		File textFile = new File( "kadou.txt" );
		File tempFile = new File( "kadou.tmp" );
		try {
			BufferedReader br = new BufferedReader( new FileReader(textFile) );
								//稼働状況マスタを開く
			BufferedWriter bw = new BufferedWriter( new FileWriter(tempFile) );
								//テンポラリーファイルを開く
			String instr, ID, strtmp;
			int No = 0, mNo = 0;

			//稼働状況マスタから1レコードずつ読込み
			while( (instr=br.readLine()) != null ) {
				ID = instr.substring( 0, instr.indexOf('\t') );
															//人材IDの取出し
				if( ID.equals(jID) ) {	//人材IDが一致
					strtmp = instr.substring(
								instr.indexOf('\t')+1, instr.length() );
					No = Integer.parseInt(
								strtmp.substring( 0, strtmp.indexOf('\t') ) );
								//稼働状況番号の取出し
					if( No > mNo ) {
						mNo = No;	//最大の稼働状況番号の取得
					}
				}
				bw.write( instr );	//人材情報の転記
				bw.newLine();	//改行
			}
			bw.write( jID+"\t"+(++mNo)+listtoStr_t(inData) );
							//新しいデータの書込み
			bw.newLine();	//改行
			br.close();
			bw.close();
			textFile.delete();
			tempFile.renameTo( textFile );
							//テンポラリーファイルを稼働状況マスタに置換え
			return true;
		} catch( IOException e ) {	//稼働状況マスタへのアクセスエラー
			System.err.println( e.getMessage() );
		}
		return false;
	}

	/** 稼働状況の入力
	 * @param TList 取引先リストを表す文字列配列
	 * @return 入力された稼働状況情報
	 */
	String[] getData( String[][] TList ) {
		String inData[][] = {
			{ "取引先ID", "稼働開始日", "稼働終了日", "契約単価" },
			{ "", "", "", "" }
		};
		for( int i = 0; i < inData[0].length; i++ ) {
			if( inData[0][i].equals("取引先ID") ) {
				RIGHTTID:
				while( true ) {	//有効な取引先IDが入力されるまでループ
					System.out.println( "取引先を選択してください。" );
					System.out.print( listtoStr_t(TList) );
					inData[1][i] = getChars();
					for( String TL : TList[0] ) {
						if( inData[1][i].equals( TL ) ) {
							break RIGHTTID;
						}
					}
				}
			} else {
				System.out.print( inData[0][i]+"を入力してください。\n>" );
				inData[1][i] = getChars();
			}
		}
		return inData[1];
	}

	/** 入力された稼働状況をタブ区切りの文字列に変換し,日付を付加する
	 * @param inData 入力された稼働状況
	 * @return 文字列
	 */
	String listtoStr_t( String[] inData ) {
		StringBuffer buff = new StringBuffer();
		for( String inD : inData )
			buff.append( "\t"+inD );	//稼働状況データ
		SimpleDateFormat dateFormat = new SimpleDateFormat( "yyyyMMdd" );
		String today = dateFormat.format( new Date() );
		buff.append( "\t"+today+"\t"+today+"\t" );	//日付
		return buff.toString();
	}

	/** 取引先リストの項目を文字列に変換する
	 * @param TList 取引先リストを表す文字列配列
	 * @return 文字列
	 */
	String listtoStr_t( String[][] TList ) {
		StringBuffer buff = new StringBuffer();
		for( int i = 0; i < TList[0].length; i++ ) {
			buff.append( TList[0][i]+" "+TList[1][i] );
			buff.append( "\n" );
		}
		buff.append( " [" );	//取引先IDリストの表示
		for( String TL : TList[0] ) {
			buff.append( TL );
			buff.append( "," );
		}
		buff.deleteCharAt( buff.length()-1 );	//末尾の","を削除
		buff.append( "]>" );
		return buff.toString();
	}

	/** 取引先リストをファイルから読み込む
	 * @return 取引先リスト
	 */
	String[][] getTList() {
		String instr;
		ArrayList<String> a_ID = new ArrayList<String>();
		ArrayList<String> a_Name = new ArrayList<String>();
		try {
			BufferedReader br =
						new BufferedReader( new FileReader( "torihiki.txt" ) );
							//取引先マスタを開く

			//取引先マスタから1レコードずつ読込み
			while( (instr = br.readLine()) != null ) {
				if( (instr.length()-1) == instr.lastIndexOf( '\t' ) ) {
							//削除日付なし
					String[] s = instr.split( "\t" );
					a_ID.add( s[0] );
					a_Name.add( s[1] );
				}
			}
			br.close();	//取引先マスタを閉じる
		} catch( FileNotFoundException e ) {	//取引先マスタがない
		} catch( IOException e ) {	//取引先マスタへのアクセスエラー
		}
		String[][] TList = new String[2][a_ID.size()];
		for( int i = 0; i < a_ID.size(); i++ ) {
			TList[0][i] = a_ID.get( i );	//取引先IDのセット
			TList[1][i] = a_Name.get( i );	//会社名のセット
		}
		return TList;
	}

	/** メッセージ番号に合わせてメッセージを選択し,標準入力からの入力を受け付ける
	 * @param mID メッセージ番号を表す列挙型定数
	 * @return 入力文字列
	 */
	String displayMessage( KI_MESS mID ) {
		String mess = "";
		switch( mID ) {
			case GETID:	//人材IDの取得
				mess = "人材IDを入力してください。\n>";
				break;
			default:	//エラー
		}
		System.out.print( mess );
		return getChars();
	}
}

オリジナルソースではRepositoryやConsoleなどの抽象型が抽出されていないため、分岐、ループ、配列、基本型のようなはるかに低水準の機能を使ってロジックが記述されています。そのため、プログラムで実現したいこと=外部仕様とのつながりがはるかに見えにくくなっています。両者を比較して、これでもオブジェクト指向の方が複雑で保守が大変と思われますか?単純にコードが数十パーセント短くなるとか、そういうレベルでの違いではありません。まさに、桁違いで単純化され、保守コストが下がっています。さらに、リファクタリングの結果抽出されたRepositoryやConsoleといったクラスは、人材管理という固有の機能にまったく依存しないため、別のアプリケーションでも再利用ができる点も見逃さないでください。

ポリモーフィズムの活用によるメインプログラムの単純化と拡張性の向上

オリジナルのメインプログラムではメニューの選択項目にしたがって、サブ機能にswitch文で分岐しています。

(Javaプログラミング能力認定試験1級問題より引用)

/** 各機能の呼出し.各機能より制御が戻ったら,キー入力を受け付ける
 * @param contentsNO 機能コードを表す整数値
 * @return 終了フラグ。「終了」の機能コードが渡されたときのみTrue
 */
boolean functionStart( int contentsNO ) {
	switch( contentsNO ) {
		case 0:	// 人材検索
			SInput sinput = new SInput();
			sinput.startThis();
			break;
		case 1:	// 人材管理(追加)
			JInput jinput = new JInput();
			jinput.startThis();
			break;
		case 2:	// 人材管理(更新)
			JUpdate jupdate = new JUpdate();
			jupdate.startThis();
			break;
		case 3:	// 人材管理(削除)
			JDelete jdelete = new JDelete();
			jdelete.startThis();
			break;
		case 4:	// 稼働状況管理(追加)
			KInput kinput = new KInput();
			kinput.startThis();
			break;
		case 5:	// 稼働状況管理(削除)
			KDelete kdelete = new KDelete();
			kdelete.startThis();
			break;
		case 6:	// 終了
			return true;
		default:	// 入力エラー
			return false;
	}
	if( contentsNO > 0 ) {	//人材管理と稼働状況管理のみ
		System.out.print( "エンターキーを押すとメニューに戻ります。\n>" );
		getChars();
	}
	return false;
	}

この試験問題程度のプログラムではこれでも何とか理解できるレベルではありますが、今後機能が追加される都度、このswitch文も修正していかなくてはなりません。ここで注目すべきことは、呼び出しの対象となっている各サブ機能のエントリーポイントは全てstartThis()という共通のメソッドで始まっていることです。このような場合こそ、継承とポリモーフィズムを使うべき時です。Java言語で、メソッドをポリモーフィックに呼び出すためには、単に共通のインターフェースを各クラスに実装させればよいだけなので、この場合のリファクタリングは簡単です。

実際に、メインクラスの中でサブ機能を起動している部分は以下のように非常に簡単になります。

protected Map<String, Function> functionMap = new HashMap<String, Function>();

/**
 * 機能コードに該当する機能を呼び出す
 */
private void runFunction(String inputCode) {
	Function subFunction = functionMap.get(inputCode);
	if (subFunction == null) return;	

	try {
		subFunction.run();
	} catch (Exception ex) {
		// TODO 適切な例外処理
		ex.printStackTrace();
	}
}

ポリモーフィズム多態性)という用語が難しく聞こえるのですが、以上のように同一のメソッドを呼び出し可能な任意の型のオブジェクトを呼び出し元で区別せず一様に扱えるということに過ぎません。マップの中からコードに対応するサブ機能オブジェクトを取り出して単にrun()メソッドを起動しています。そのオブジェクトがどの機能であるかを区別する必要はまったくありません。これはちょうど筆箱の中にボールペンや鉛筆などさまざまな筆記用具が入っている場合に、そこから任意の筆記用具を取り出して文字を書くということと同じです。「A」という文字を書く際に通常は筆記用具の違いをわざわざ意識する必要はありません。そんなことを考えていたらノイローゼになってしまいそうです。*1このようにオブジェクト指向言語では日常無意識に行っている抽象化や単純化の考え方が取り入れられています。決して、研究者やプログラミングマニア専用の機能というわけではありません。
※試験問題のソースを一部掲載している部分については、著作権法32条の引用にあたるため合法であると理解しています。引用 - Wikipedia
(その3につづく予定)

*1:厳密に言えば、筆圧など微妙な調整が必要なことは言うまでもありませんが。

あの認定試験問題の品質をSI業界の代表的なプログラム品質と考えることの是非

SI業界(日本)のJavaプログラマーにはオブジェクト指向より忍耐力が求められている?に対して、既に何人かの方々から

単に駄目試験だってだけなのでは。

これ確かにヒドイけどこんな資格試験の内容ひとつで業界全体を語られても

のようなご指摘をいただきました。実際に試験問題の品質を持ち出して、実際のSI業界全体の品質を語るのは議論の飛躍し過ぎたところがあり、私としても反省しております。
しかし、既に指摘させていただいているように、実際のSIerの開発したシステムで使われているプログラムの品質というのは会社の知的財産権や秘密主義といったことから、オープンソース化されることがめったにないというのが事実であり、本当のところどうなのかということについては残念ながら神のみぞ知る事実ということになると思います。*1
したがって、こういったところは自分の少ない経験を通して推測するしかありません。(グルーポンのおせち事件を受けてSI業界が本当に教訓とすべきことでも指摘したように、こういったプログラム品質の見えにくさはSI業界固有の問題だと思います。)
私がアーキテクトとして全体の設計や規約に対して責任を持って開発をすることができたプロジェクトでは、ここまでひどい設計はしてこなかったという自信があります。しかし、デスマプロジェクトの火消しや、うまくいっていないプロジェクトのプログラムをリファクタリングしたりレビューしたりする仕事の機会が今まで少なからずあり、そうしたプロジェクトではほぼ間違いなくこの試験問題のレベルかそれ以下の、まったくオブジェクト指向でない設計で行われたコードとなっていました。

  • クラスの長さが10000行を超えるGod Class
  • パラメータの数が60個を超える
  • 業務レイヤーのクラスが画面固有のクラスに平気で依存する
  • ほとんど同じ内容のコピペクラスが何十回となく繰り返される。
  • 同一の文字列メッセージなどの定数値がいたるところに繰り返しハードコードされる。
  • フレームワークの規約により無駄なクラスが大量に作られる(アンチパターン―ソフトウェア危篤患者の救出ではお邪魔妖怪アンチパターンとして紹介されている。)
  • まったく利用されていないデッドコードが多数存在する。
  • 番号つきのおかしなクラス名

などといったレベルのプログラムは今までに実際何度も目にしてきています。それゆえ、今回この問題のことを発見して、「やっぱりそういうことだったのか」と思ってしまい、不適切な類推で議論を進めてしまったというきらいがあります。
しかし、上のコメントをいただくような方は

  • 高度な技術を発揮してSIをしている稀有な非常に恵まれた会社で仕事をしている
  • SI業界での開発をした経験がない。
  • 上流専門の方で下流工程のプログラムの中身にまで関与したことがない。

という方が多いのではとも思ってしまいます。実際、全体的にみればあの記事に対するコメントの多くは、例の問題の品質が実案件の多くのプログラムの品質を実際に暗示するということを裏付ける内容のものでした。これは、こうした品質の問題があるプロジェクトが多数存在する事実が無視できない件数存在する証拠であると言ってよいのではないでしょうか?
だから、私は単に資格試験の品質の問題として片付けてしまうのではなく、ここでいったん現状のSI案件の品質や設計、開発の進め方、オブジェクト指向の理解度などについて関係者が胸に手を当てて反省するタイミングなのではないかと考えています。もちろん、うちのシステム開発のやり方は問題ないと思えるのであれば、非常にすばらしいことでしょう。

*1:場合によっては、2chWikiLeaksなどに裏情報が流れていたりすることもあるのでしょうか?

Javaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その1)

昨日書いたSI業界(日本)のJavaプログラマーにはオブジェクト指向より忍耐力が求められている? - 達人プログラマーを目指してが予想以上に大きな反響があり驚いています。特に、あの有名なひがさんにもSI業界の現状と未来に関してコメントをしていただきました。(SI業界からはさっさと抜けだしたほうがいい
ただし、SI業界の今後がどうかということや新しいサービスを使ったビジネスのことについては、私自身最先端技術に十分にキャッチアップできておらず、自分の考えを整理できていないため、一旦考えないことにして、ここでは例の試験問題の設計とリファクタリングについて考察してみたいと思います。具体的な例に基づいて説明することで、オブジェクト指向がSI業界の多くの方々に考えられているほど理解不能なものなのではなく、問題を単純化し、プログラムの保守性を桁違いに向上させるうえできわめて重要な役割を果たすということをご理解いただけるものと信じます。実際、ブクマのコメントの中で、

SEもPGも多くは会社員であって専門家ではないから、目先のコストを考えると仕方がない気がする。OOP的な設計、コーディングをした場合、後のメンテナンスを任せることができない悲惨な状況だからこそ。

というコメントをいただいているのですが、正しいオブジェクト指向アーキテクチャーを採用した設計を取り入れることで、従来のスパゲッティコードを保守することに比べて保守性をずっと高くできるのです。いまさらオブジェクト指向?という人も多いと思いますが、私としてはオブジェクト指向技術の魅力を伝えることで、多くの方々の誤解を解くことができればと思います。
もちろん、オブジェクト指向を学ぶには、たしかに、以前のやり方よりもずっと多くの体系的な知識が必要であり、敷居が高いことは否定できません。しかし、ロケットやCPUを設計するような天才エンジニアでなくても、きちんと勉強すれば少なくとも基本的な考え方は多くの人々が学ぶことができるものであると信じます。
ただし、この点についてはあまりはっきりとは言われないようですが、私の考えでは、オブジェクト指向を真に理解し、使いこなすことができるのはオブジェクト指向言語に習熟したプログラマーだけではないのかということがあります。確かに、UMLなどの記法を使った上流の分析や設計の技法もありますが、少なくとも1つのオブジェクト指向プログラミングに習熟したプログラマーとしての経験がないと、オブジェクト指向のエッセンスを本当に理解することはきわめて難しい、あるいは、ほとんど不可能なことのように思うのです。もちろんオブジェクト指向言語でプログラミングができればオブジェクト指向が理解できるということではないですが、少なくともオブジェクト指向プログラミング(OOP)の習熟はオブジェクト指向の理解に欠かすことのできない必要条件だと思います。オブジェクト指向の話がよく理解できないという方は、ほぼ例外なく、プログラミングをしない上流専門の方なのではないかと思います。たとえ話として何冊オブジェクト指向の解説書を読んでも、プログラミングの経験なしではポリモーフィズム、抽象化、カプセル化といったことのニュアンスを体得することはほぼ不可能に近いと思います。これはちょうど、一般相対性理論量子力学の問題を理解するのに数式を使わない一般向けの啓蒙書を何冊読んだところで結局正しい理解に到達できないのと同じことです。であるからこそ、現代的なオブジェクト指向開発ではよく言われるように設計者はプログラマーであるということが望ましいということになるのです。COBOL時代の発想でプログラムを作るPGは下級職であり、設計は上流のSEが行うという古くさい枠組みから離れられないのであれば、正しい設計などできるわけがないのではないでしょうか?
ですから、

  • 設計にかかわる上級職の方も真剣にオブジェクト指向プログラミングの勉強をする
  • プログラミングをする人を下級職とみなさず正しく評価し、設計者として意見やアイデアを尊重する

のいずれかを心がけていただければ、きっとうまくいくはずであると思います。

COBOL時代の設計と違い、現代的なオブジェクト指向設計では基本アーキテクチャーの設計が大切です

この試験問題のオリジナルの設計書は20年以上前のCOBOL時代の設計技法に基づいて記述されています。この時代にはJavaのようなオブジェクト指向言語はまだ一般的ではありませんでした。この時代の設計は構造化設計ですので、

  • 処理の流れをフローチャートとして設計する
  • 機能単位でプログラムをサブプログラムに分割する
  • データ構造(ファイルフォーマット)を設計する
  • 画面レイアウト、書式を設計する

ということがほとんどでした。実際、試験問題の設計書には、メソッドやパラメーターの一覧など、ソースやJava Docを読めばわかるような冗長な内容を除くと上のいずれかの内容以上のものは含まれていませんでした。このような設計であっても

  • 一度作成・試験が完了したら決して機能変更や追加がない
  • ファイルのソート、マージ、書き換えなど定型処理しかしない
  • 非常に長い時間とお金をかけて開発する

など昔ながらのプログラムであれば、何とかなったかもしれません。しかしながら、現代のコンピューターは当時とは速度や記憶容量は文字通り桁違いに進歩していますし、顧客からも以前よりはるかに複雑な要件を短期間に開発することを要求されるようになってきています。したがって、プログラミング言語もより効率的に開発や保守ができるものが登場してきているわけですし、設計やプログラミングのやり方も当然昔とは違った方法で行う必要があります。オブジェクト指向言語が現在主流になっているのは、そうした背景を考えれば必然のことであり、無視し続けることはできないのです。
それゆえ、Javaを使った開発では上記の設計に加えてオブジェクト指向的なアーキテクチャーをきちんと設計することが非常に大切になります。アーキテクチャーの設計ではまず以下のことを考える必要があります。

  • 抽象度(機能固有⇔共通機能)、データとの距離(画面より⇔DBより)などの基準によりレイヤーに分割する。
  • レイヤー間の依存関係や、それぞれのレイヤーの中で実装すべきサービスの内容を決める。
  • 全体的な機能をモジュールごとに分割する。

今回の例題だと、以下のUMLのようなモデルが考えられます。

アプリケーション層ではメニューの選択に対応する個々のユースケース固有の処理を記述するコントローラーロジックが主に格納されます。一方、ドメイン層には上位のレイヤーから参照されるエンティティクラスなどが格納され、インフラ層にはファイルアクセスや画面操作など汎用的なロジックを抽象化して扱いやすくするクラスライブラリーが格納されます。
なお、Java言語ではこうしたモジュールの構造はパッケージという言語構造で表現することができます。もともとの試験のオリジナルのソースでは全クラスがデフォルトパッケージに格納されていました。しかし、このような基本となるアーキテクチャーを決めた場合は、モジュールに対応してソースコード上のパッケージも作成します。実際に以下のようなイメージとなります。

このように、IDEなどの現代的な開発環境では、パッケージの構造もきれいに階層化して表示されますし、クラスの格納場所の移動も自動リファクタリング機能を使ってスイスイ実行できます。昔のコーディングのイメージだとプログラムは文字の羅列というイメージが強いと思いますが、実際はそうではなくて、かなりビジュアルな右脳の感覚を要求される作業であることがお分かりいただけると思います。また、パッケージのどこにどのようなクラスを配置するかということは、UML上よりソースコード上の方がわかりやすく変更も容易ということもあります。ちょうど数学者が数式を展開したりして方程式を解いたり、CADの図面を使って自動車の部品を設計したりするのと同じような感覚で、ソースコード上で設計を自由自在に変えていくということができるのです。最初にExcel方眼紙を使って作図してからソースを起こすということがいかに非効率で無意味なことか、お分かりいただけるのではないでしょうか。(実際この記事で紹介しているUML図はソースをリファクタリングしてからリバースして起こしたものです。)

共通的に使えそうなロジックを共通クラスとして抽象化し再利用する

オブジェクト指向の設計においては、クラスという抽象を適切に考え出すことが設計の中心となります。クラスは入門書だとわかりやすさの配慮から「乗り物」「自動車」など把握しやすい実世界のエンティティをクラスの例として扱うことが多いですが、実際のオブジェクト指向プログラミングにおいては、こうした実世界のもの以外にも機能やデータの塊として便利な単位を抽象化して抽出することが多くあります。実際、この例題の場合、単なるデータのCRUD処理なので、派遣ビジネスという業務ドメインに特化した複雑性というのはまったく存在しません。だから、この部分については、本当の意味でのクラスを抽出する必要はありません。単にファイルの一行分のデータを保持する構造体としてクラスを抽出しておけば十分です。(ただ、試験でなく実際の業務ではこういったコアドメインの領域は仕様変更や機能追加の可能性が高い部分なので将来的にはクラスを抽出するようにリファクタリングする意味が高いのですが。)
この問題において、むしろ、最もコーディングが面倒なのは

  • タブ区切りのテキストファイルを読み書きする処理
  • 画面コンソールにメニューを表示したり、入力を対話的に受け取ったりする処理

などの部分になります。これらの処理には単にロジックだけでなくて、処理を遂行するのに必要な変数も必要になります。したがって、こういう部分こそが、Java言語で言うところの本当のクラスとして抽出する価値がある部分になります。なお、設計の初期の段階ではクラスの実装の中身ではなく、まずは外部からアクセス可能な(publicな)インターフェースに着目して設計することが多いです。*1したがって、以上でそれぞれ抽出した概念にインターフェース名を与え、そのインターフェースに対して呼び出し可能な操作を定義します。たとえば、上記の「タブ区切りのテキストファイルを読み書きする処理」にはRepositoryという名前を与え、以下のようなJavaのインターフェースとして定義します。*2

package sample.common.io;

import java.util.List;

import sample.common.entity.EntityBase;

/**
 * エンティティを永続化するためのレポジトリークラスが実装すべきインターフェースです。
 *
 * @param <E> エンティティに対する総称型パラメーター
 */
public interface Repository<E extends EntityBase> {

	/**
	 * IDでエンティティを検索する。(論理削除済みのエンティティは除外する。)
	 * エンティティが見つからない場合はEntityNotFoundExceptionが送出される。
	 * 
	 * @param id ID
	 * @return 検索結果のエンティティ
	 * @throws EntityNotFoundException エンティティが見つからない場合
	 */
	E findById(long id);

	/**
	 * エンティティを全件検索する。(論理削除済みのエンティティは除外する。)
	 * エンティティが見つからない場合は空のListが返る。
	 * 
	 * @return エンティティのリスト
	 * @throws EntityNotFoundException エンティティが見つからない場合
	 */
	List<E> findAll();

	/**
	 * エンティティの属性値を照合して検索する
	 * @param example 検索対象の属性値を格納したオブジェクト
	 * @return 検索結果に一致したエンティティのリスト
	 */
	List<E> findByExample(E example);

	/**
	 * エンティティを新規作成する。
	 * @param data 作成対象のエンティティ
	 */
	void create(E data);

	/**
	 * エンティティを更新する。
	 * @param data 更新対象のエンティティ
	 */
	void update(E data);

	/**
	 * エンティティを論理削除する。
	 * @param data 論理削除対象のエンティティ
	 */
	void delete(long id);
}

このインターフェースは総称型のパラメーター付きで定義されているため、任意のエンティティクラスに対して汎用的に利用することができます。したがって、一度正しく実装してしまうと、HumanResouce(人材)だろうが、Work(稼動)だろうが、どのクラスに対しても再利用できます。しかも、ファイルを読み書きするロジックはこのインターフェースの中身にカプセル化されていますので、利用側はまったく意識する必要がありません。
たとえば、人材をIDで検索する処理は以下のように非常に簡単に記述できるようになります。

    HumanResource hr = hrRepository.findById(id);

同様に、稼動についても

    Work work = workRepository.findById(id);

とほとんど同じ一行で済んでしまいます。英語なので日本人にはちょっと読みにくいのですが、英語脳で読めば、「HRのレポジトリーからIDで検索したHR情報を保持する。」ということであり、上流の仕様書の記述レベルと変わるところがありません。(多くの日本人がオブジェクト指向プログラミングを苦手とするのは英語アレルギーだからか?)これを以下に引用する、もともとのオリジナルのソースと比べてみれば、どちらが単純であるか一目瞭然です。(この場合、個々のエンティティごとに似たようなロジックを作り直す必要もあります。)

(Javaプログラミング能力認定試験1級問題より引用)

	/** 指定された人材IDのすべての情報の読込み
	 * @return 人材情報
	 */
	String[][] getjData() {
		BufferedReader br = null;
		try {
			br = new BufferedReader( new FileReader( "jinzai.txt" ) );
						//人材情報マスタを開く
			String instr;

			//人材情報マスタから1レコードずつ読込み
			while( (instr = br.readLine()) != null ) {
				if( instr.substring( 0, instr.indexOf('\t') ).equals(jID) ) {
								//人材IDが一致
					if( (instr.length()-1) == instr.lastIndexOf('\t') ) {
								//削除日付なし
						return setjData( instr );	//人材情報を返す
					}
				}
			}
			
		} catch( FileNotFoundException e ) {
					//人材マスタがない
		} catch( IOException e ) {	
					//人材情報マスタへのアクセスエラー
		} finally {
			try {
				if ( br != null)
					br.close();	//人材情報マスタを閉じる
			} catch( IOException e) {
				//正常にクローズできなかった場合のエラー
			}
				
		}
		return null;
	}

カプセル化によるメリットは単にソースコードの行数が共通化により少なくなるということだけではありません。カプセル化によって、今後ファイルの項目が変更されたり、フォーマットが変更されても、あちこちのコードを修正する必要がなくなるため、保守性も実際にぐんと向上するのです。
ちなみに、Repositoryはインターフェースですので、別途実装クラスが必要になります。クラス図で関係を示すと以下のようになります。

なお、参考までに、Repositoryインタフェースの実装例を以下の記述します。このクラスを一つ実装、単体テストしておけば、あらゆるエンティティに対するCRUD処理で汎用的に再利用できるようになります。

package sample.common.io;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.apache.commons.lang.StringUtils;

import sample.common.SystemException;
import sample.common.entity.EntityBase;
import sample.common.util.ExampleMatcher;
import sample.common.util.IdMatcher;
import sample.common.util.Matcher;
import sample.common.util.TrueMatcher;

/**
 * 区切り文字を使ったテキストファイル上のデータを読み書きするためのレポジトリー実装クラスです。
 * このクラスはステートを持ちスレッドセーフではない点に注意すること。
 */
public class CharSeparatedFileRepository<E extends EntityBase> implements
		Repository<E> {

	// ====================================================
	// フィールド
	// ====================================================

	private File masterFile;

	private File workFile;

	private String separator = "\t";

	private BufferedReader reader;

	private BufferedWriter writer;

	private Class<E> entityClass;

	private final TrueMatcher<E> TRUE_MATCHER = TrueMatcher.instance();

	// ====================================================
	// プロパティ
	// ====================================================

	public File getMasterFile() {
		return masterFile;
	}

	public void setMasterFile(File masterFile) {
		this.masterFile = masterFile;
	}

	public File getWorkFile() {
		return workFile;
	}

	public void setWorkFile(File workFile) {
		this.workFile = workFile;
	}

	public Class<E> getEntityClass() {
		return entityClass;
	}

	public void setEntityClass(Class<E> entityClass) {
		this.entityClass = entityClass;
	}

	public String getSeparator() {
		return separator;
	}

	public void setSeparator(String separator) {
		this.separator = separator;
	}

	// ====================================================
	// メソッド
	// ====================================================

	private List<E> doFind(Matcher<E> matcher) {
		try {
			List<E> result = new ArrayList<E>();

			openForRead();
			String line;

			// マスタから1行ずつ読込み
			while ((line = reader.readLine()) != null) {
				E entity = toEntity(line);
				if (!entity.isLogicalDeleted() && matcher.isMatch(entity)) {

					result.add(entity);
				}
			}

			return result;

		} catch (IOException e) {
			throw new SystemException("検索処理実行時にIO例外が発生しました。", e);
		} finally {
			close();
		}
	}

	@Override
	public E findById(long id) {

		Matcher<E> idMatcher = new IdMatcher<E>(id);

		List<E> result = doFind(idMatcher);

		if (result.isEmpty()) {
			throw new EntityNotFoundException("id = " + id + "のエンティティは存在しません。");
		}

		// TODO 一意性チェックはしていない
		return result.get(0);
	}

	@Override
	public List<E> findAll() {
		return doFind(TRUE_MATCHER);
	}

	@Override
	public List<E> findByExample(E example) {
		Matcher<E> exampleMatcher = new ExampleMatcher<E>(example);

		return doFind(exampleMatcher);
	}

	static interface FileUpdator {
		void handle() throws IOException;
	}

	private void processUpdate(FileUpdator fileUpdator) {
		try {
			openForWrite();

			fileUpdator.handle();

		} catch (IOException e) {
			throw new SystemException("削除処理実行時にIO例外が発生しました。", e);
		} finally {
			close();
		}

		commit();
	}

	private void writeEntity(E data) throws IOException {
		String outputLine = fromEntity(data);
		writer.write(outputLine);
		writer.newLine();
	}

	@Override
	public void create(final E data) {
		if (data == null) throw new IllegalArgumentException("パラメーターが不正です。");

		processUpdate(new FileUpdator() {
			
			@Override
			public void handle() throws IOException {
				String line;

				List<Long> idList = new ArrayList<Long>();
				// マスタから1行ずつ読込み
				while ((line = reader.readLine()) != null) {
					E entity = toEntity(line);
					idList.add(entity.getId());

					writeEntity(entity);
				}

				long maxId = Collections.max(idList);
				data.setId(maxId + 1);

				data.preCreate(); // 更新、作成日付の発行
				writeEntity(data);
			}
		});
	}

	@Override
	public void update(final E data) {
		if (data == null)
			throw new IllegalArgumentException("パラメーターが不正です。");
		if (!data.isPersisted())
			throw new IllegalArgumentException("パラメーターが永続化されていません。");

		processUpdate(new FileUpdator() {
			@Override
			public void handle() throws IOException {
				String line;

				// マスタから1行ずつ読込み
				while ((line = reader.readLine()) != null) {
					E entity = toEntity(line);
					if (data.getId().equals(entity.getId())) {
						if (entity.isLogicalDeleted()) { // 既に論理削除済みの場合
							throw new EntityNotFoundException("id = "
									+ entity.getId() + "のエンティティは既に論理削除されています。");
						}

						data.preUpdate();
						entity = data;
					}

					writeEntity(entity);
				}
			}
		});
	}

	@Override
	public void delete(final long id) {
		processUpdate(new FileUpdator() {
			@Override
			public void handle() throws IOException {
				String line;
				boolean deleted = false;

				// マスタから1行ずつ読込み
				while ((line = reader.readLine()) != null) {
					E entity = toEntity(line);

					if (id == entity.getId()) {
						if (entity.isLogicalDeleted()) { // 既に論理削除済みの場合
							throw new EntityNotFoundException("id = " + id
									+ "のエンティティは既に論理削除されています。");
						}

						entity.logicalDelete();
						deleted = true;
					}

					writeEntity(entity);
				}

				if (!deleted) {
					// パラメーターで指定されたエンティティが存在しなかった場合
					throw new EntityNotFoundException("id = " + id
							+ "のエンティティは存在しません。");
				}
			}
		});
	}

	private String fromEntity(E entity) {
		return StringUtils.join(entity.toArray(), getSeparator());
	}

	private E toEntity(String line) {

		try {
			E entity = entityClass.newInstance();
			entity.fromArray(StringUtils.split(line, getSeparator()));

			return entity;
		} catch (InstantiationException e) {
			throw new SystemException("エンティティの復元時に例外が発生しました。", e);
		} catch (IllegalAccessException e) {
			throw new SystemException("エンティティの復元時に例外が発生しました。", e);
		}
	}

	private void commit() {
		try {
			if (!masterFile.delete()) {
				throw new IOException();
			}

			// テンポラリーファイルをマスタに置換え
			workFile.renameTo(masterFile);

		} catch (IOException e) {
			throw new SystemException("ワークファイルの変更をマスターファイルに反映できません。", e);
		}
	}

	// NOTE
	// 本来は全ファイルの内容をメモリ上に読み込んで処理したほうが簡単だが、
	// オリジナルの実装を極力残すことにした。

	private void openForWrite() throws IOException {
		reader = new BufferedReader(new FileReader(masterFile));
		writer = new BufferedWriter(new FileWriter(workFile));
	}

	private void openForRead() throws IOException {
		reader = new BufferedReader(new FileReader(masterFile));
	}

	private void close() {
		if (reader != null) {
			try {
				reader.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}

		if (writer != null) {
			try {
				writer.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

}

(その2につづく)

*1:これはテストファースト(TDD)のような手法でも本質的には変わりません。先にメソッドのインターフェースを決め、テストクラスを作ってからクラスの中身の実装を完成させます。

*2:適切なクラスの抽出は確かに長年の経験からくる匠の勘を要求されるところであり、初心者プログラマーには荷が重いところです。しかし、デザインパターンなどの本を勉強することで、ある程度クラス抽出のヒントを得ることが可能です。