はじめに
case式を使ったまったく同じ条件分岐が同じクラス内に複数回登場するようなコードはStrategyパターンを使ってリファクタリングできる可能性があります。
この記事ではサンプルコードを使ってこのリファクタリング方法を説明します。
リファクタリング前のコード
たとえば以下のようなクラスがあったとします。
このクラスはitemの型(クラス)に応じてシリアライズ(ここではハッシュオブジェクトへの変換)の形式を切り替えています。
serialize_compactメソッドとserialize_fullメソッドで、まったく同じ条件分岐(case/in)が登場している点に注目してください。
class MediaItemSerializer
def initialize(item)
@item = item
end
def serialize_compact
case item
in Book
{
type: "book",
title: item.title,
creator: item.author,
price_label: price_label(item.price),
}
in Album
{
type: "album",
title: item.title,
creator: item.artist,
price_label: price_label(item.price),
}
in Movie
{
type: "movie",
title: item.title,
creator: item.director,
price_label: price_label(item.price),
}
end
end
def serialize_full
base = serialize_compact
# serialize_compactと同じcase/inがここにも登場する
case item
in Book
base.merge(isbn: item.isbn, pages: item.pages)
in Album
base.merge(
track_count: item.tracks.size,
duration_label: duration_label(item.duration),
)
in Movie
base.merge(
duration_label: duration_label(item.duration),
rating: item.rating,
)
end
end
private
attr_reader :item
def price_label(price)
"¥#{price}"
end
def duration_label(seconds)
"#{seconds / 60}分"
end
end
ちなみに上のコードの実行結果はこんなイメージです。
(MediaItemSerializerに渡したオブジェクトに応じて、出力結果の形式が切り替わる)
MediaItemSerializer.new(book).serialize_compact
#=> { type: "book", title: "Ruby入門", creator: "山田太郎", price_label: "¥1200" }
MediaItemSerializer.new(album).serialize_full
#=> { type: "album", title: "BEST HITS", creator: "スズキバンド", price_label: "¥2500",
# track_count: 12, duration_label: "74分" }
# NOTE: 実際の呼び出しでは以下のようなループ処理で呼ばれる想定
current_library.media_items.map do |item|
# itemはBook/Album/Movieのいずれか
MediaItemSerializer.new(item).serialize_compact
end
上のコードの問題点
先ほどのコードには以下のような問題点があります。
- 将来もし、
itemとしてBook/Album/Movie以外の別のデータ型が渡されるようになったら、serialize_compactとserialize_fullに同じ条件分岐(たとえばin Magazine)を追加しないといけない - Book用、Album用、Movie用の処理がクラス内で分散してしまう。BookならBook用のロジックを、AlbumならAlbum用のロジックを、MovieならMovie用のロジックをまとめて見たいケースもあるはず
- 将来もし、
serialize_minimalのように、よく似たメソッドを追加することがあれば、まったく同じ条件分岐がさらに増殖する
上に挙げたサンプルコードは、説明用に短くしてありますが、実務であれば、
- case/inの条件分岐(形式を切り替えたいデータ型)がもっとたくさんある
- データごとに処理を切り替えたいメソッドがもっとたくさんある
といった状況が十分考えられます。
その場合はここで見たコードよりも、さらに保守性が低く、コードの見通しも悪いプログラムができあがります。
Strategyパターンでリファクタリングしてみる
そこで、先ほどのコードをStrategyパターンを使ってリファクタリングしてみることにします。
Strategy パターン(ストラテジーパターン)は、アルゴリズムを共通のインターフェースを持つ独立したクラスとして定義し、実行時に交換可能にすることで、処理内容の変更を利用側のコードから独立させるデザインパターンである。これにより、複数のアルゴリズムを状況に応じて切り替えながら、利用側の構造を変更せずに拡張や保守を行えるようになる。
今回は既存のMediaItemSerializerに加えて、以下のクラス図のように、
- BaseStrategy
- BookStrategy
- AlbumStrategy
- MovieStrategy
という4つのクラスを追加してシリアライズ処理を実装します。
少し長いですが、リファクタリング後のコードは以下のようになります。
class MediaItemSerializer
def initialize(item)
@strategy =
case item
in Book then BookStrategy.new(item)
in Album then AlbumStrategy.new(item)
in Movie then MovieStrategy.new(item)
end
end
def serialize_compact
@strategy.serialize_compact
end
def serialize_full
@strategy.serialize_full
end
class BaseStrategy
def initialize(item)
@item = item
end
def serialize_compact
compact_attributes
end
def serialize_full
compact_attributes.merge(extra_attributes)
end
private
attr_reader :item
# サブクラスで実装漏れがあったら例外を発生させる
def compact_attributes
raise NotImplementedError
end
# 同上
def extra_attributes
raise NotImplementedError
end
def price_label(price)
"¥#{price}"
end
def duration_label(seconds)
"#{seconds / 60}分"
end
end
class BookStrategy < BaseStrategy
private
def compact_attributes
{
type: "book",
title: item.title,
creator: item.author,
price_label: price_label(item.price),
}
end
def extra_attributes
{ isbn: item.isbn, pages: item.pages }
end
end
class AlbumStrategy < BaseStrategy
private
def compact_attributes
{
type: "album",
title: item.title,
creator: item.artist,
price_label: price_label(item.price),
}
end
def extra_attributes
{
track_count: item.tracks.size,
duration_label: duration_label(item.duration),
}
end
end
class MovieStrategy < BaseStrategy
private
def compact_attributes
{
type: "movie",
title: item.title,
creator: item.director,
price_label: price_label(item.price),
}
end
def extra_attributes
{
duration_label: duration_label(item.duration),
rating: item.rating,
}
end
end
end
このリファクタリングの注目ポイント
このリファクタリングの注目ポイントを以下にまとめます。
条件分岐が1箇所になった
データ型に応じて条件分岐しているのはMediaItemSerializer#initializeの中だけです。
class MediaItemSerializer
def initialize(item)
@strategy =
case item
in Book then BookStrategy.new(item)
in Album then AlbumStrategy.new(item)
in Movie then MovieStrategy.new(item)
end
end
# ...
将来データ型が増えても、条件分岐の修正が必要になるのはこの1箇所だけで済みます。
データ型ごとに処理を担当するクラスが分かれた
Bookに対応するStrategyはBookStrategy、Albumに対応するStrategyはAlbumStrategy、というように、各データ型に対応するStrategyクラスを定義しました。
そのため、Book用のシリアライズ処理を確認したいと思ったら、BookStrategyだけに着目すればよくなります。
# Book用のシリアライズ処理を確認したいなら、このクラスだけを見ればよい
class BookStrategy < BaseStrategy
private
def compact_attributes
{
type: "book",
title: item.title,
creator: item.author,
price_label: price_label(item.price),
}
end
def extra_attributes
{ isbn: item.isbn, pages: item.pages }
end
end
共通の処理はBaseStrategyに定義した
BookでもAlbumでもMovieでも使う、共通の処理はBaseStrategyクラスに定義しています。
class BaseStrategy
# ...
def serialize_compact
compact_attributes
end
def serialize_full
compact_attributes.merge(extra_attributes)
end
private
# ...
def price_label(price)
"¥#{price}"
end
def duration_label(seconds)
"#{seconds / 60}分"
end
end
BookStrategyやAlbumStrategyはBaseStrategyを継承しているので、共通処理がそのまま使えます。
class BookStrategy < BaseStrategy
# ...
end
class AlbumStrategy < BaseStrategy
# ...
end
class MovieStrategy < BaseStrategy
# ...
end
呼び出し側のコードは同じ
MediaItemSerializerのAPIは変わっていないため、呼び出し側のコードは修正不要です。
また、メソッドの戻り値も修正前と同じです。
MediaItemSerializer.new(book).serialize_compact
#=> { type: "book", title: "Ruby入門", creator: "山田太郎", price_label: "¥1200" }
MediaItemSerializer.new(album).serialize_full
#=> { type: "album", title: "BEST HITS", creator: "スズキバンド", price_label: "¥2500",
# track_count: 12, duration_label: "74分" }
新しいメソッドが増えても条件分岐は増えない
将来もし、serialize_minimalのような、よく似たメソッドが増えたとしても、条件分岐は増殖しません。
BaseStrategyとBookStrategy/AlbumStrategy/MovieStrategyに必要な処理を書き足すだけで済みます。
class BaseStrategy
# ...
+ def serialize_minimal
+ minimal_attributes
+ end
private
# ...
+ # サブクラスで実装漏れがあったら例外を発生させる
+ def minimal_attributes
+ raise NotImplementedError
+ end
# ...
class BookStrategy < BaseStrategy
private
# ...
+ def minimal_attributes
+ { type: "book", title: item.title, creator: item.author }
+ end
end
(AlbumStrategy / MovieStrategy にも同様に minimal_attributes を追加)
まとめ
というわけで、この記事ではクラス内で繰り返し発生する同じ条件分岐をStrategyパターンでリファクタリングする方法を紹介しました。
「似て非なるものをまとめて処理したい」と思うようなメソッドが複数個あると、冒頭で挙げたサンプルコードのようなプログラムが作られがちです。
「まったく同じ条件分岐をあっちでもこっちでも書いてるわ〜」と思ったら、この記事で紹介したリファクタリング方法を検討してみてください!
あわせて読みたい
Strategyパターンは、いわゆる「デザインパターン」の一種です。
RailsのようなWebアプリケーションでも応用できるパターンもいくつかあるので、「デザインパターン」をしっかり勉強したことがない人は、以下のような書籍もチェックしてみましょう。