背景
オブジェクト指向のこころ10章「Bridgeパターン」を読んで
自分の中に落とし込むためのメモです。
本書ではJavaで書かれていますが実務ではRubyを使用しているため
Rubyに書き直しています。
このパターンの目的
機能と実装を切り出すことで、クラス数の爆発を招かず、それぞれを拡張可能にする
いつパターンを使用するのか
ある抽象的側面と様々な実装が共存している時
この記事の流れ
「オブジェクト指向のこころ」では図形を描画する一連のプログラムを題材にしています。
初期実装 → 機能追加による弊害 → パターンを当てはめる
の順番で説明していきます。
説明が抽象的で意味不明 😵💫 だと思うので、
実際のコードを見た方が早いです。
悲劇が起こる前
要求:
二つの描画プログラム(DP1, DP2)を使用して、四角形を描画する
※ DP: Drawing Program の略
※ 四角形は2組の座標があれば定義できる
四角形の描画に必要なプログラム(DP1, DP2)は、四角形の実体化時に決定するとし、
- DP1 を用いて実体化する四角形(V1Rectangle)
- DP2 を用いて実体化する四角形(V2Rectangle)
の2つのクラスを用意できる。
すると、以下のようなクラス図になる
二つの四角形はdraw_line
メソッドの実装方法のみが異なる。そのため、Rectangle
という抽象クラスから各クラスを派生させることで抽象化クラスによるカプセル化のメリットを活かせるようになる。
つまり、draw_rectangle
が共通のインターフェイスになっているので、
長方形を作るクライアント側は長方形の型(V1, V2)を意識しなくて済む。
Rubyで書いてみるとこんな感じ
# 抽象クラス Rectangle
class Rectangle
# 描画メソッド
def draw_rectangle(x, y, width, height)
draw_line(x, y, x + width, y)
draw_line(x + width, y, x + width, y + height)
draw_line(x + width, y + height, x, y + height)
draw_line(x, y + height, x, y)
end
private
# 抽象メソッド draw_line
def draw_line(x1, y1, x2, y2)
raise NotImplementedError, "#{self.class} は抽象メソッド `draw_line` を実装していません。"
end
end
class V1Rectangle < Rectangle
def initialize
@dp1 = DP1
end
private
def draw_line(x1, y1, x2, y2)
@dp1.draw_a_line(x1, y1, x2, y2)
end
end
class V2Rectangle < Rectangle
def initialize
@dp2 = DP2
end
private
def draw_line(x1, y1, x2, y2)
@dp2.draw_a_line(x1, y1, x2, y2)
end
end
class DP1
def self.draw_a_line(x1, y1, x2, y2)
puts "DP1を使って線を描きます。(#{x1}, #{y1}) - (#{x2}, #{y2})"
end
end
class DP2
def self.draw_a_line(x1, y1, x2, y2)
puts "DP2を使って線を描きます。(#{x1}, #{y1}) - (#{x2}, #{y2})"
end
end
準備ができたので実行してみる
puts "長方形を描きます (DP1):"
rect1 = V1Rectangle.new
rect1.draw_rectangle(0, 0, 5, 5)
# => DP1を使って線を描きます。(0, 0) - (5, 0)
# => DP1を使って線を描きます。(5, 0) - (5, 5)
# => DP1を使って線を描きます。(5, 5) - (0, 5)
# => DP1を使って線を描きます。(0, 5) - (0, 0)
puts "\n長方形を描きます (DP2):"
rect2 = V2Rectangle.new
rect2.draw_rectangle(0, 0, 5, 5)
# => DP2を使って線を描きます。(0, 0) - (5, 0)
# => DP2を使って線を描きます。(5, 0) - (5, 5)
# => DP2を使って線を描きます。(5, 5) - (0, 5)
# => DP2を使って線を描きます。(0, 5) - (0, 0)
機能拡張 「円を描画する」 と クラス爆発
追加要求:
- 円を描画できるように拡張したい
- Rectangle と Circle を区別なく扱いたい
この場合、抽象クラスでShape
を導入し、そこからCircle
とRectangle
を派生する。
そうすると、Shape.draw
と共通のインターフェイスを作ることができる。
また、Circle
も同様にDP1
, DP2
を使い分けて円を描画する。
すると、クラス図は以下のようになる
抽象クラスCircle
は共通のインターフェイス(draw
)を持つだけ
class Shape
def draw(*args)
raise NotImplementedError, "#{self.class} は抽象メソッド `draw` を実装していません。"
end
end
Circle
とRectangle
もdraw
に反応できるように拡張する
class Rectangle < Shape
def draw(x, y, width, height)
draw_rectangle(x, y, width, height)
end
private
def draw_rectangle(x, y, width, height)
draw_line(x, y, x + width, y)
draw_line(x + width, y, x + width, y + height)
draw_line(x + width, y + height, x, y + height)
draw_line(x, y + height, x, y)
end
def draw_line(x1, y1, x2, y2)
raise NotImplementedError, "#{self.class} は抽象メソッド `draw_line` を実装していません。"
end
end
DP
も円を描画できるように機能を追加する
class DP1
def self.draw_a_line(x1, y1, x2, y2)
puts "DP1を使って線を描きます。(#{x1}, #{y1}) - (#{x2}, #{y2})"
end
def self.draw_a_circle(x, y, radius)
puts "DP1を使って円を描きます。(#{x}, #{y}) 半径 #{radius}"
end
end
全体を書き直してみるとこんな感じ
class Shape
def draw(*args)
raise NotImplementedError, "#{self.class} は抽象メソッド `draw` を実装していません。"
end
end
class Rectangle < Shape
def draw(x, y, width, height)
draw_rectangle(x, y, width, height)
end
private
def draw_rectangle(x, y, width, height)
draw_line(x, y, x + width, y)
draw_line(x + width, y, x + width, y + height)
draw_line(x + width, y + height, x, y + height)
draw_line(x, y + height, x, y)
end
def draw_line(x1, y1, x2, y2)
raise NotImplementedError, "#{self.class} は抽象メソッド `draw_line` を実装していません。"
end
end
class V1Rectangle < Rectangle
def initialize
@dp1 = DP1
end
private
def draw_line(x1, y1, x2, y2)
@dp1.draw_a_line(x1, y1, x2, y2)
end
end
class V2Rectangle < Rectangle
def initialize
@dp2 = DP2
end
private
def draw_line(x1, y1, x2, y2)
@dp2.draw_a_line(x1, y1, x2, y2)
end
end
class Circle < Shape
def draw(x, y, radius)
draw_circle(x, y, radius)
end
private
def draw_circle(x, y, radius)
raise NotImplementedError, "#{self.class} は抽象メソッド `draw_circle` を実装していません。"
end
end
class V1Circle < Circle
def initialize
@dp = DP1
end
private
def draw_circle(x, y, radius)
@dp.draw_a_circle(x, y, radius)
end
end
class V2Circle < Circle
def initialize
@dp = DP2
end
private
def draw_circle(x, y, radius)
@dp.draw_a_circle(x, y, radius)
end
end
class DP1
def self.draw_a_line(x1, y1, x2, y2)
puts "DP1を使って線を描きます。(#{x1}, #{y1}) - (#{x2}, #{y2})"
end
def self.draw_a_circle(x, y, radius)
puts "DP1を使って円を描きます。(#{x}, #{y}) 半径 #{radius}"
end
end
class DP2
def self.draw_a_line(x1, y1, x2, y2)
puts "DP2を使って線を描きます。(#{x1}, #{y1}) - (#{x2}, #{y2})"
end
def self.draw_a_circle(x, y, radius)
puts "DP2を使って円を描きます。(#{x}, #{y}) 半径 #{radius}"
end
end
準備ができたので実行してみる
puts "\n円を描きます (DP1):"
circle1 = V1Circle.new
circle1.draw(0, 0, 7)
# => 円を描きます (DP1):
# => DP1を使って円を描きます。(0, 0) 半径 7
puts "\n円を描きます (DP2):"
circle1 = V2Circle.new
circle1.draw(0, 0, 8)
# => 円を描きます (DP2):
# => DP2を使って円を描きます。(0, 0) 半径 8
様々なクラスがやり取りを行なって複雑に見えるが
やり取りをしているのは以下のクラスだけ。
しかし、上記の方法で拡張すると、とある問題が浮上する。
そう、 図形クラス数の爆発である
最初は長方形のみだったので、図形のパターンもV1Rectangle
とV2Rectangle
の2種類だったが、円が追加されたことで4種類に増加した。
あまり考えたくないが、楕円Ellipse
も描画したいという要求が増えたのなら6種類にも膨れ上がる。
そして、これは図形だけではなく、描画プログラムの拡張が起きても同様に図形のパターンは膨れ上がっていく...
どうしてこんなことが起こるのか
それは、結合度の高さに起因する。
Shape と DP の蜜月
この問題が起きる原因は
抽象的側面(Shape)と実装(DP)が密結合になっているから
例えば、V1Circle
などの図形は自分がどの描画プログラムの型(DP1
)になるかを知っていないといけない。この問題を解決するためには、 抽象面における流動的要素、実装面における流動的要素をそれぞれ抽出しなければならない。
本例で言うなら、図形では長方形・円、描画プログラムではDP1・DP2の流動的要素を別々で切り出して、それぞれを独立して変更できるようにしたい。
これが冒頭であった
「機能と実装を切り出すことでそれぞれを拡張可能にする」 ということ
加えて、現状では継承を使いまくっているのが非常に嫌
継承は便利だが、コードを追うのが非常に厄介になるので減らしていきたい。
パターンを導き出す
基本戦略
- 流動的要素を見つけ出し、カプセル化する
- 継承ではなく、オブジェクトの集約を多様する
この例では様々な図形が様々な描画プログラムを持つので、流動的要素は 図形と描画プログラムということになる。
図形に関しては、Shape
内にカプセル化する。
Shape
には自らの描画方法を知っておく責務を持たせたい。
描画方法についてはDP1
とDP2
が存在する。
そこで、これらの抽象概念であるDrawing
を作成し、その中にカプセル化する。
そして、Shape
にはこの Drawing
を保持させる。
つまり 「Shape
が Drawing
を使用する」という関係性を構築する。
実装をオブジェクトの外に追い出して、オブジェクトから使用されるものにすることで、呼び出し側(Shape
)から実装における流動的な要素を隠蔽することができる。
実行イメージは以下のような感じ
v1_drawing = V1Drawing.new
# 実装を図形に喰わせる
rectangle_v1 = Rectangle.new(v1_drawing, 0, 0, 5, 5)
rectangle_v1.draw
クラス数は多く見えるがやりとりしているオブジェクトは以下のみ
Shape:
実際には Rectangle
やCircle
などの図形だが、共通のインターフェイス(draw
)を持っているため、利用する側からすると同じオブジェクトに見える。
Drawing:
実際には V1Drawing
などの描画プログラムだが、共通のインターフェイスを持っているため、Shape
からすると同じオブジェクトに見える。
DP:
適切なオブジェクトでなければならない。これを利用する Drawing
オブジェクトはDP
の型を知っている。
さて、これを踏まえてコードを書き直すと以下のような設計になる。
全体を書き直してみるとこんな感じ
class Shape
def initialize(drawing)
@drawing = drawing
end
def draw_line(x1, y1, x2, y2)
@drawing.draw_line(x1, y1, x2, y2)
end
def draw_circle(x, y, radius)
@drawing.draw_circle(x, y, radius)
end
def draw
raise NotImplementedError, "#{self.class} は抽象メソッド `draw` を実装していません。"
end
end
# 抽象クラス Rectangle
class Rectangle < Shape
def initialize(dp, x, y, width, height)
super(dp)
@x = x
@y = y
@width = width
@height = height
end
def draw
draw_line(@x, @y, @x + @width, @y)
draw_line(@x + @width, @y, @x + @width, @y + @height)
draw_line(@x + @width, @y + @height, @x, @y + @height)
draw_line(@x, @y + @height, @x, @y)
end
end
# 抽象クラス Rectangle
class Circle < Shape
def initialize(dp, x, y, radius)
super(dp)
@x = x
@y = y
@radius = radius
end
def draw
draw_circle(@x, @y, @radius)
end
end
class Drawing
def draw_line(x1, y1, x2, y2)
raise NotImplementedError, "#{self.class} は抽象メソッド `draw_line` を実装していません。"
end
def draw_circle(x, y, radius)
raise NotImplementedError, "#{self.class} は抽象メソッド `draw_circle` を実装していません。"
end
end
class V1Drawing < Drawing
def initialize
@dp = DP1.new
end
def draw_line(x1, y1, x2, y2)
@dp.draw_a_line(x1, y1, x2, y2)
end
def draw_circle(x, y, radius)
@dp.draw_a_circle(x, y, radius)
end
end
class V2Drawing < Drawing
def initialize
@dp = DP2.new
end
def draw_line(x1, y1, x2, y2)
@dp.draw_a_line(x1, y1, x2, y2)
end
def draw_circle(x, y, radius)
@dp.draw_a_circle(x, y, radius)
end
end
class DP1
def draw_a_line(x1, y1, x2, y2)
puts "DP1を使って線を描きます。(#{x1}, #{y1}) - (#{x2}, #{y2})"
end
def draw_a_circle(x, y, radius)
puts "DP1を使って円を描きます。座標(#{x}, #{y}) 半径 #{radius}"
end
end
class DP2
def draw_a_line(x1, y1, x2, y2)
puts "DP2を使って線を描きます。(#{x1}, #{y1}) - (#{x2}, #{y2})"
end
def draw_a_circle(x, y, radius)
puts "DP2を使って円を描きます。座標(#{x}, #{y}) 半径 #{radius}"
end
end
実行できるか試してみる
v1_drawing = V1Drawing.new
v2_drawing = V2Drawing.new
rectangle_v1 = Rectangle.new(v1_drawing, 0, 0, 5, 5)
rectangle_v1.draw
# => DP1を使って線を描きます。(0, 0) - (5, 0)
# => DP1を使って線を描きます。(5, 0) - (5, 5)
# => DP1を使って線を描きます。(5, 5) - (0, 5)
# => DP1を使って線を描きます。(0, 5) - (0, 0)
circle_v1 = Circle.new(v1_drawing, 0, 0, 5)
circle_v1.draw
# => DP1を使って円を描きます。座標(0, 0) 半径 5
rectangle_v2 = Rectangle.new(v2_drawing, 0, 0, 5, 5)
rectangle_v2.draw
# =>DP2を使って線を描きます。(0, 0) - (5, 0)
# =>DP2を使って線を描きます。(5, 0) - (5, 5)
# =>DP2を使って線を描きます。(5, 5) - (0, 5)
# =>DP2を使って線を描きます。(0, 5) - (0, 0)
circle_v2 = Circle.new(v2_drawing, 0, 0, 5)
補足
「オブジェクト指向のこころ」では、デザインパターンが提供する解決策を重視しがちになることに警鐘を鳴らしています。解決策を適用する前に、まず問題そのものを理解することが大切であるとも書かれています。
p.155
問題そのものを理解することなしに、問題領域の中からパターンが適用できそうな部分を探し出すというアプローチをとってしまうと、何をするかは検討がつくものの、いつそれを使うのかやなぜそれを使うのかといったことが分からないままになってしまうわけです。
パターンを使うことが目的にならないように、解決しようとする問題に着目することが大事だとわかりますね..
最後に
ここまで見ていただきありがとうございます m(_ _)m
デザパタでは
- インターフェイスを用いて設計する
- クラス継承よりもオブジェクトの集約を多用する
- 流動的要素を見つけ出し、カプセル化する
などのオブジェクト指向設計を行う上で必要な知識が採用されています。
デザパタを学ぶ中でその有用性を感じ取り、設計を磨いていきたいと思います。