はじめに
【オブジェクト指向設計実践ガイド】SRP編の続き。
今回は異なるオブジェクト間の依存関係をいい感じにする方法について考えていきます。
特定のオブジェクトのインスタンスに依存しないこと
特定のクラスのインスタンスに依存するコードの例を出してみましょう。人間を表すHumanクラスの他にTwitterのユーザを表すTwitterUserクラスを追加し、新たにHumanクラスに呟くメソッドを足してみましょう。
+ class TwitterUser
+ def tweet
+ # ... Twitterでツイートする実装
+ end
+ end
class Human
# これはデータ
attr_reader :weight, :height
def initalizer(:weight, :height)
@weight = weight
@height = height
end
# ツイートするメソッド
+ def tweet
+ TwitterUser.new().tweet
+ end
# 適正体重を求めるメソッド(= 適正体重を返すという振る舞い)
def desirable_weight
(height ^ 2) * 22
end
# BMIを求めるメソッド
def bmi
weight + (height ^ 2)
end
end
さて、ツイートするメソッドがTwitterUserクラスにいて良いのかというのは一旦置いておきましょう。今問題なのは、HumanクラスからTwitterUserクラスという特定のクラスのインスタンスへの依存が生じている事です。以下のような依存と懸念があります。
- tweetメソッドを持つクラスの名前が
TwitterUser
であることをHuman
が知っている。- もし
TwitterUser
にクラス名の変更が生じた場合に、呼び出し側も変更しないといけない - もし
TwitterUser
クラスを削除することになったらHuman
のtweetメソッドも対応を迫られる
- もし
-
Human
はTwitterUser
がtweetメソッドを持っていることを知っている - もし
TwitterUser
クラスがコンストラクタに引数を取るように変更されたらHuman
のTwitterUser.new()
も対応しないといけない。- その場合、
Human
はTwitterUser
がどのような引数をどんな順番でコンストラクタに取るのかも知ることになる -
TwitterUser
の変更がしにくく、Humanクラスが弱くなっている。
- その場合、
そして何よりも 「TwitterUser以外でツイートすることができなくなっている」
...ということで特定のオブジェクトのインスタンスに依存するのはアンチパターンです。じゃあどうすりゃいいのか。
「メッセージ」こそが重要
オブジェクト同士が関わり合って何かする時に重要なのは、 「どのようなメッセージを送るか」 という事です。送り先が何のオブジェクトかということはどうでもいいのです。これを実践し、さっきの質問への答えとなるのが 「依存性注入」 です。やってみましょう。
class TwitterUser
def tweet
# ... Twitterでツイートする実装
end
end
class Human
attr_reader :sns
+ def initalizer(:weight,:height,:sns)
@weight = weight
@height = height
+ @sns = sns
end
# ツイートするメソッド
def tweet
+ sns.tweet
end
end
見やすいようにツイート関連以外のコードは消しました。そして、 Human
の tweet
メソッドは tweet
メソッドを持つような sns
オブジェクトを呼び出すだけになっています。 Human
クラスが依存しているオブジェクト(この場合は TwitterUser
) をコンストラクタで「注入」しているので依存性注入というのですね。僅かな変更ですが嬉しいことがあります。
-
Human
はtweetメソッドを持つオブジェクトであれば、どんなオブジェクトをsns
にとることもできる。- 例えば自前のSNSのツイート機能を
Human
クラスに組み込むことも今や簡単です。 - そして
Human
はsns
に 「ツイートせよ」というメッセージを送るだけで良くなっており、より本質的なコードになっています。
- 例えば自前のSNSのツイート機能を
依存は隔離せよ
さて、依存性注入の威力はわかりましたがいつもこう上手くできるとは限りません。特に、既にスパゲッティコードが大量に乗っているようなコードベースの場合、あるいは注入すべき上手い抽象が見つからない場合などにです。
しかし完璧に依存性注入を適用できなくても、コードをマシなものにすることはできます。 それが 「依存を隔離する」 ということです。
これを実践した例を先ほどの Human
と TwitterUser
で考えてみましょう。
class TwitterUser
def tweet
# ... Twitterでツイートする実装
end
end
class Human
attr_reader :sns
+ def initalizer(:weight,:height)
@weight = weight
@height = height
# コンストラクタで初期化
+ @sns = TwitterUser.new()
end
# ツイートするメソッド
def tweet
sns.tweet
end
# あるいはこう書くこともできる
def sns
@sns ||= TwitterUser.new()
end
end
Human
のコンストラクタ内やメソッドで直接 TwitterUser
を初期化しています。当然、 TwitterUser
という特定のオブジェクトへの依存がHuman
の中に生じることになるので、ベストなコードとは言えません。しかし、この記事の冒頭のコードに比べて改善したこともあります。
-
Human
がTwitterUser
に依存している問題がコード上で明確になった。 - 今後リファクタリングできるようになった時には、
Human
のコンストラクタだけを修正すれば良い
冒頭のコードではtweetメッセージを外部の TwittterUser
に直接送っていましたが、メッセージを自身のインスタンス変数の読み取りメソッドに送るようにしたことによってオブジェクト間の結合が弱まっています!
しかしこれではまだ不十分です。このコードの場合は Human
のtweetメソッドが TwitterUser
にtweetメソッドがあることを知っています。
よりマシなコードにするのであれば、以下のように TwitterUser
オブジェクトにメッセージを送っている部分をラップし、ラッパーを呼び出すようにします。こうすることで
-
TwitterUser
のtweetメソッドの名前やシグネチャが変更された場合でも、影響はラッパー(この場合はtwitter_tweetメソッド)の中に収まります。
class TwitterUser
def tweet
# ... Twitterでツイートする実装
end
end
class Human
attr_reader :sns
def initalizer(:weight,:height)
@weight = weight
@height = height
# コンストラクタで初期化
@sns = TwitterUser.new()
end
# ツイートするメソッド
def tweet
+ twitter_tweet
end
+ # TwitterUserをラップする
+ def twitter_tweet
+ sns.tweet
+ end
end
ということで依存性注入できないならせめて 「依存は隔離せよ」
引数やインデックスの順番に依存しないこと
さて、ここでは引数の順番に関する依存について改善していきましょう。引数を持つような関数を呼び出す側は、関数のシグネチャが変更されると常に対応を迫られます。これにはいくつかの対処法があります。
- 引数をハッシュ(JSやPHPでいう連想配列、Pythonでいう辞書、Goでいうmap)で受け取る
- あるいは必須の引数だけ普通に受け取り、オプショナルな引数をハッシュで受け取る
- 引数にデフォルト値を設定しておく
- 外部ライブラリのオブジェクトを初期化するなら、初期化だけのためのラッパー(いわゆるファクトリ)を実装する