tom__bo’s Blog

情報系学生が筋トレしたり、筋トレしたり筋トレしたことを書くブログ。もはやダイアリー

オブジェクト指向設計実践ガイド読んだ

Web+DBの読者プレゼントで頂いた本(去年の10月号位だった気がする)が積まれたままだったので、読んだ。

www.amazon.co.jp

入門というタイトル通り、この本を通して扱うオブジェクト指向の原則やデザインパターン、設計をする上での心構えを丁寧に説明してくれている。 デザインパターンにはさほど踏み込まず、SOLID, DRY, デメテルの法則を満たすまでの工程、考え方がわかる。 実装としてはダックタイピング、継承、モジュールによるロールの管理、コンポジションの使いどころが書かれていて、最後にはそれぞれの実装に対するテストの仕方まである。

原著のタイトルは"Practical Object-Oriented Design in Ruby"で、タイトルどおり、Rubyのコードサンプルで解説されていく。 Rubyのコードを読んだことはほとんどないけれど、かなりすっきりかけることがわかって、設計の話をする上ではJava使うよりよいなーと思った。一方で静的型付けの言語との比較も出てくるし、多くの初学者は静的片付けでOOPを習うだろうから、その前提が若干ありそう。 また、Rubyなので、インターフェースが無かったり、モジュールがあったりするけど、Ruby書いたことなくても問題なく読めて、且つ分かりやすかった。

以降、各章の自分なりのまとめ。 各章でタイヤやギアと言ったパーツクラスからなる自転車クラスをどう設計するか、この自転車を提供する「自転車旅行会社」のサービスをどう設計するかを例に設計の議論があるが、具体的な話は飛ばす。 完全な引用でなく僕の解釈を書いているものもあるので注意。

1章 オブジェクト指向設計

オブジェクト指向の道具として、設計原則・パターンがある。 この本全体で議論されるのは、SOLID, DRY, Law of Demeterの3つ。 これらの原則を必ず守る必要はないが、逸脱するだけのコストを払うほどのものかよく検討する必要がある。 パターンにはさほど踏み込まないけれど、設計の基本的な心構え、依存の見つけ方、捉え方がわかって、この本を読んだ後にデザインパターンの本を読むと理解が深まってよかった。

  • SOLID
    • Single Responsibility (単一責任)
      • 2章
    • Open-Closed (オープン、クローズド)
      • 8章
    • Liskov Substitution (リスコフの置換)
      • 7章
    • Interface Segregation (インターフェース分離)
    • Dependency Inversion (依存性逆転)
  • DRY (Don’t Repeat Yourself)
    • 2章
    • 同じ振る舞いを複数箇所に書かない
  • LoD (Law of Demeter)
    • 3章, 4章

この本で中心的に扱っているのはSingle Responsibility, DRYとLoDで、Open-ClosedとLiskov Substitutionは若干出てくるけど、この本の説明だけではそれほど理解は深まらない気がする。 Interface SegregationとDependency Inversionはほぼ出てこないと言ってよく、前提知識になっているわけでもない。

この本のあとに「オブジェクト指向のこころ」を読んで理解が出来たので、ここではSOLIDについてはこの本に出てきたものだけ書いて終わる。

他用語

  • BUFD (Big Up Front Design)(p26)

2章 単一責任のクラスを設計する

変更が簡単なように設計する。 変更が簡単とは、(p37)

  • 変更は副作用をもたらさない
  • 要件の変更が小さければ、コードの変更も相応して小さい
  • 既存のコードは簡単に再利用できる
  • 最も簡単な変更方法はコードの追加である。ただし、追加するコードはそれ自体変更が容易

ということ。 これが実現できるように1クラスが1つの責任を持つようにメソッドをグルーピングする。 単一責任のクラスを作ることで、どんな振る舞いも1箇所にのみ存在するようになり、"DRY"なコードになる

3章 依存関係を管理する

オブジェクトが持つ依存関係には以下のものがある

  • 呼び出し先のクラスが存在することを知っている
  • どのインスタンスが呼び出すメッセージに応答するかを知っている
  • メッセージが要求する引数の型・数・順序を知っている

呼び出し先の変更に左右されないようにするためにDI(Dependency Injection)で依存を回避する。 DI出来ない場合は、依存オブジェクトのインスタンスの作成を隔離する(p68)。 引数の順番への依存は初期値付きのハッシュ値を使うことで回避する※1

依存関係を作る場合は依存先の不安定さを考える。 できるだけ仕様が変わりづらいほうに依存させる。

※1 引数の順番への依存をハッシュ値で渡すのってどうなの?という感じが未だにしている。シグニチャによる区別とはって感じ。。。 初期値つけると呼び出し側で省略される引数も出て、あとから書く人とか不可解な挙動をした時に困ると思うがどうなのか?

4章 柔軟なインターフェースをつくる

JavaでいうimplementsされるInterfaceではなく、外部に公開する呼び出し先(public interface)という意味でのインタフェース。

オブジェクトがどのインタフェースを備えるべきかは、オブジェクトを基準に考えるのではなく、メッセージ(メソッド呼び出し)を基準に考える。 (このメッセージを送る必要があるけど、これに答えるべきなのはどのオブジェクトだろうか?というふうに考える)

シーケンス図を書くことで必要以上に他のオブジェクトへの依存を作っていないか、移譲出来る呼び出しがないかを検討する。

コード上ではデメテルの法則*2に違反していないかで確認することも出来る。

※2 デメテルの法則: 3つめのオブジェクトにメッセージを送る際に、異なる方の2つ目のオブジェクトを介さない

5章 ダックタイピングでコストを削減する

ダックタイピング自体の説明。

ダックタイプであることを文書化する、テストを書くの両方をしなければならない。

6章 継承によって振る舞いを獲得する

抽象クラスの特化をするサブクラスが出来る構造にする。 抽象的なスーパークラスを作る最も良い方法は3つ以上のサブクラスから共通する処理を押し上げて作ること。テンプレートメソッドパターンをつかって、サブクラスが特化する部分を示す(スーパークラス側ではこのメソッドが呼ばれた場合Not Implemented Errorを返すなどする)。 また、サブクラスがsuper()コンストラクタを呼ぶ依存を解消するために、フックメッセージを使って、サブクラスを疎結合に保つ(p172)

7章 モジュールでロールの振る舞いを共有する

Ruby特有(?)のモジュールを利用することで、クラスが担うべきロールを実装する。 同じ振る舞いを共有するための仕組み。

モジュールも継承も再帰的にかけることで、呼び出しの木構造を作る。この階層構造は浅く保つようにする。 3階層になるとフックメッセージが使えないなどの問題が出る。

リスコフの置換原則(LSP)(p202コラム)

  • 「システムが正常であるためには、派生型は上位型と置換可能でなければならない。
    • スーパークラスが使えるところではサブクラスが使えないとならない。
    • モジュールにおいても、モジュールが表現するロールを担う(requireしている)

8章 コンポジションでオブジェクトを組み合わせる

コンポジションによりhas-aの関係を記述する コンポーズされるオブジェクトは1つのロールを意味する。 コンポジションとして切り出すことでコンポーズする側から明示的なインタフェースを介することによって、相互作用することが想定される。

一般的な広義の意味ではhas-a関係を意味するが、正式な定義では包含される側のオブジェクトが包含する側のオブジェクトから独立して存在し得ないものを指す 包含するオブジェクトと独立して存在するオブジェクトは「集約」されていると表現する。

※ 独立して存在しないとは、包含するオブジェクトが消えると包含されるオブジェクトも消える関係のこと

コンポジションと継承の選択

基本的に継承を利用するメリットがはっきりしていない状況であれば、コンポジションを利用するべき

継承の性質

  • 継承の階層構造において、頂点に近いところで定義されたメソッドの影響力は広範囲に及ぶ
    • 正しく設計されていれば、振る舞いの大きな変更をコードの小さな変更で成し遂げられる
    • 一方で、誤った継承を行うと振る舞いを変更したいのに出来ない状況に陥いる
  • 継承を使った結果得られるコードは、「オープンクローズド(Open-Closed)」と特徴づけられ、階層構造は拡張には開いていて、修正には閉じている
  • 継承を使うコストには、そのモデルの中で継承を利用することが正しくても他のプログラマが設計時に意図しない方法で利用した際に継承が要求する依存を許容できず利用できない可能性がある
    • フレームワークを作るときにフレームワークのオブジェクトを継承しなければならないコードを書くべきではない
    • ユーザのアプリケーションはすでにそれ自身の階層構造で構成されているかもしれない

コンポジションの性質

コンポジションの継承との違いは2つ

  • クラス階層の構造には依存しない
  • 自身のメッセージは自身で移譲する

コンポジションのメリット

  • コンポジションを使うと小さなオブジェクトが自然といくつも作られるようになる
  • コンポーズされるオブジェクトがロールを表すことで、責任が明解であり、明確に定義されたインタフェースを介してアクセス可能になる
  • コンポーズされたオブジェクトはわずかなコードしか継承せず、階層構造にあるコードの変更によって生じる副作用に悩まなくて良い
  • 構造的に独立していることで、適切に定義されたインタフェースをもつ他のオブジェクトと簡単に交換可能

コンポジションのデメリット

  • コンポーズされたオブジェクト(パーツ)それぞれは小さく、責任が明確であっても、パーツが組み合わさった全体では理解し難いものである可能性がある
  • 構造的な独立性の利点はメッセージの自動的な以上を犠牲にしている
    • コンポーズされたオブジェクトは明示的にどのメッセージを移譲するか知っている必要がある
    • 全く同一の移譲のコードが多岐にわたるオブジェクトに必要になるかもしれない

9章 費用対効果の高いテストを設計する

  • テストを行う意図

    • バグを見つける
    • 仕様書となる
    • 設計の決定を遅らせる
      • 必要な抽象的なコードを書くための情報がない場合にはインタフェースに依存するテストを書くことで、具象的なコードを書くことを避ける
      • インタフェースに依存するテストを書いておくことで、根底にあるコードをリファクタリングしやすく出来る
    • 抽象を支える
      • 良い設計は自然と抽象に依存する、独立した小さなオブジェクトの集まりになっていく
      • 適切に設計されたアプリケーションの振る舞いは、徐々にそれらの抽象が相互作用した結果となっていく
      • テストはこれらの抽象のインタフェースを記録するものとして、設計の決定を遅らせられ、任意の有益な度合いの抽象を作ることが出来る
    • 設計の欠陥を明らかにする
      • テストのセットアップが苦痛なら、コードはコンテキストを要求しすぎている
      • テストを書くのが大変なら、他のオブジェクトから見ても再利用が難しい
      • 一方で、テストにコストがかかるからと言って、必ずしもアプリケーションの設計が悪いとは限らない
  • 何をテストするべきか

オブジェクト指向のアプリケーションを、ブラックボックスの集まりの間を飛び回る一連のメッセージだと考える。 すると、どのオブジェクトもブラックボックスであるかのように扱うことで、公開される知識はオブジェクトとの境界を突き通すメッセージのみになる。 このようにオブジェクトの内部を意図的に無視することが、設計の核心であり、オブジェクトを、オブジェクトが応答するメッセージそのもの、かつそれだけであるかのように扱うことで、変更可能なアプリケーションを設計することが出来る。 メッセージ(メソッド呼び出し)は必ず何かしらのオブジェクトのパブリックインタフェースに到達するため、オブジェクトの境界を出入りするメッセージに集中するべきである。

受信メッセージをテストする

例えばオブジェクトFOOがメッセージを受け、BARはFOOのメッセージを受けるアプリケーションを考える。 このとき、Fooの受信メッセージがFooのパブリックインタフェースを作り、Fooの送信メッセージはBarの受信メッセージであり、これがBarのパブリックインタフェースを作る。 Foo, Barは自身のインタフェースをテストする責任があり、メッセージの戻り値について、表明(アサーション)することでそれを行っている。 メッセージの戻り値について表明するテストは状態のテストであり、状態のテストは全て受信側に留められるべきである。 オブジェクトは自身のインタフェースに対する状態テストにのみ責任を持つことで、テストを一箇所に留められ、テストをDRYに保てる。

テストダブル

テストダブルとは、ロールの担い手を様式化したインスタンスであり、テストでのみ使われるもの

ロールとして依存オブジェクトを注入する(p258)

ロールの担い手として、テストダブルを作ることで、テストしたいオブジェクトのテストに集中する。 ここで導入するダブルはテスト対象のオブジェクトが送るメッセージをスタブする。

この節の説明ではDiameterizableというロールのメッセージが変更されても、呼び出し元とテストの両方に変更がされない場合、アプリケーションでは明らかに失敗するのにテストが通ってしまうことを「夢の世界に生きる」と表現している。 この対策として、オブジェクトがDiamterizableとして応答すべきメッセージに答えるかのテストを書いて、ロールの備えるべきインタフェースを明示化している(p262) しかし、この方法は、全てのDiameterizableロールを実装するクラスに書かなければならないなどの問題があるとしている。

送信メッセージをテストする

送信メッセージの状態をテストする必要はないが、送信メッセージ全てをテストする必要が無いわけではない。 送信メッセージは2つに分けることが出来る。

  • クエリ: 副作用がないもの、メッセージは送り手だけにとって問題であり、アプリケーションの他からは要求のないもの
    • クエリは送りて出テストされる必要はなく、受信側で状態テストとしてテストされるべき
  • コマンド: 副作用があるもの
    • データベースへの記録, 不ファイルへの追記、オブザーバーによってアクションが起こされるなど
    • 適切にメッセージ(コマンド)が送られたことの証明は振る舞いのテストであり、送りてのオブジェクトの責任

送信メッセージのテストを書く上ではクエリは無視し、コマンドを証明する必要がある。 クエリは受信側のクラスの状態テストとして記述されるため、送信メッセージを送るオブジェクトがテストをすると冗長になる。 コマンドはその副作用が発生していることを確認(証明)するために、モックを作って、検証(verify)することでテストする。

この本の次に「オブジェクト指向のこころ」を読んだのでそれを書く