はじめに
この記事では、依存性逆転の原則が表している内容を、抽象度と文脈の概念を使ってより深く理解します。
いいね・ストックが励みになります!
依存性逆転の原則の一般的な説明とその問題点
依存性逆転の原則の世間一般で語られている内容についておさらいしましょう。例えば @yokarikeri さんのプログラマーのための原則(2 万字)では、次のように説明されています。
上位レイヤー(呼び出し元)のクラスは、下位レイヤー(呼び出される側)のクラスを直接使わない(依存しない)方が良いケースが存在するということ
どの記事や書籍でもざっくりこの程度の説明がなされています。より詳しい記事ではインターフェースを使って具体的に依存性逆転の原則を実現するクラス図の解説が書かれていることもあります。例えば、依存関係逆転の原則の重要性についてというブログ記事にはコードレベルでの詳しい解説が行われています。
さて、実はこの原則は上位モジュールと下位モジュールの間で常に行うべき原則ではありません。それどころか、下手に適用すると設計ミスとなります。このことは引用元の記事でも認識されており、「依存しないほうがいいケースが存在する」という書き方になっています。
依存性逆転の原則の難しいところは、適用可否の判断基準がほとんど提供されていない点です。これはなぜかというえば、適応すべきかそうでないかの判断が「具体」「抽象」という1つの軸でしか説明されていないからです。これまでの記事で解説してきた通り、モジュールには抽象度だけでなく文脈、文脈依存性という情報が含まれています。文脈が依存性逆転の原則にどのように関係するのかは全くと言っていいほど語られてきませんでした。その結果、感覚的に文脈とはどのようなものかを認識できるような経験ある優秀なプログラマしか適切にインターフェースを使うことが出来なくなっています。
この記事では、適切な依存性逆転の原則の適用について、理論的に解説することで、設計をより多くの人間が行えるようにすることを目指しています。
隠れた文脈による問題
これまでのモジュール分割では、全てのモジュールは、配下のモジュールに勝手に文脈を増やされることがありませんでした。
- 文脈を保持する抽象化:親モジュールの文脈+親モジュールの処理を文脈に持つ
- 文脈を保持しない抽象化:親モジュールの文脈のうち一定以上の強さの文脈を持つ
これを図に表すと次のようになります。白い丸が文脈を保持する抽象化で、黒い四角が文脈を保持しない抽象化です。
次のように配下のモジュールが勝手に文脈を持つことはありませんでした。
これは当然の制約です。なぜなら、図の処理Cは、文脈Xを持っているため、Cを呼び出す処理はXのことを知っている必要があるからです。勝手に文脈が増える場合、親モジュールに勝手な仮定をしていることになります。そして、勝手な仮定をすると、処理Aの上位モジュールは文脈Xを持っている必要があります。なぜなら、文脈Xでないならば処理Cが無意味になるからです。したがって、処理Cが全く関係ない文脈Xを持ち込んだせいで、処理の文脈は次のように書き変わります。
このような仕組みで、モジュールAには「隠れた文脈」が発生します。隠れた文脈とは、次の2点を満たす情報のことです。
隠れた文脈
- モジュールの内容を見ても読み取ることが出来ない情報
- 上位モジュールに要求する情報
隠れた文脈が存在すると、親モジュールは知らない間に子モジュールの文脈に依存してしまい、再利用性が下がってしまいます。
具体例
リバースプロキシを実装しています。サブモジュールのひとつとして、リクエストのヘッダーを書き換える処理を行うサブモジュールを考えます。このモジュールは指示に従ってデータを取得して、指示に従って適切なヘッダーに情報を書き込むはずです。つまり、2つの文脈を保つ抽象化が出来ます。
問題はここからです。これらの処理が行う具体的な内容は、必ず勝手に文脈を追加せざるを得ないです。
具体的には、次のような書き換えが考えられます。いずれの場合にも、ヘッダーの名前と値の組から「リバースプロキシ一般」以外の情報が漏れてしまいます。
- Authorizationヘッダーの設定:何らかの認証認可の仕組みが存在するWebアプリでのリバースプロキシであることが分かる
- ID Tokenと呼ばれるのJSONのクレームに含まれる情報の設定:OIDCという規格を使って認証しているリバースプロキシであることが分かる
- キャッシュ関連のヘッダーの設定:何らかのキャッシュ制御を行っているリバースプロキシであることが分かる
- 特定の企業だけが使っている非標準ヘッダーの設定:特定の企業のサービスに関連するリバースプロキシであることが分かる
この中で、仮にAuthorizationヘッダーを書き換えてプロキシ先にbasic認証を行う処理を呼び出したとしましょう。モジュールの文脈は次のように変化します。
この修正の結果、もともとはすべてノリパースプロキシで使えていたはずのルートノードのモジュールは、「basic認証をするようなリバースプロキシ」以外で使えない処理になり、再利用性が下がってしまいました。
さて、次に設定ファイルのJSONの特定のクレーム情報をヘッダーに追加するという仕様が追加されたとします。すると、次のようになります。
ここまでの状況を反映したコード例を示します。
class HeaderInjectSource {
public string BasicAuthPassword;
public string ConfigJSONClaim;
}
static void InjectHeader(Request req, HeaderInjectSource[] sources) {
for(int i = 0;i < sources.Length;i++) {
if (sources[i].BasicAuthPassword != null) {
if (sources[i].JSONClaim == null) {
throw new Exception("Inject Source should not have more than one source");
}
// Authorizationヘッダーにusername:passwordをbase64エンコードして書き込む
} else if (sources[i].ConfigJSONClaim != null) {
if (sources[i].BasicAuthPassword == null) {
throw new Exception("Inject Source should not have more than one source");
}
// なんらかのJSONから特定のクレームを取得してヘッダーに書き込む
} else {
throw new Exception("HeaderInjection failed");
}
}
}
こうなると、呼び出し元は「basic認証を行いたい、かつ設定ファイルからのJSONをヘッダーに含めたい」という非常に限られた存在だけが許されます。本来、ヘッダーを書き換える処理自体はすべてのリバースプロキシで共有できる汎用的な処理のはずだったにも関わらず、隠れた文脈によって、メソッド名からは想定できない形で上位モジュールに強い制約が課されています。
さて、このようにがんじがらめになった処理の再利用性は低いため、キャッシュ情報を書き換える処理が全く関係ない別のソフトウェアで必要になった場合、取れる手段は2択です。
- InjectHeader関数をコピペして、basic認証およびJSONの処理を削除して、キャッシュ情報を使うように書き換える:再利用できるはずだったはずの処理がコピペされてしまった!
- InjectHeader関数に新たに分岐を追加してキャッシュ情報を設定するように書き換えたうえで無理やり再利用する:モジュールは文脈として、「basic認証、JSON、キャッシュのすべてを行うモジュール」を要求しているにも関わらず、上位モジュールはキャッシュしか使わないため、下位の処理を読んだときに混乱する。例えば、キャッシュだけを使うソフトウェアのほうだけを呼んでいたプログラマが 「え、もしかして自分が知らないだけで、このソフトウェアにはbasic認証の機能が!?」と勘違いする
隠れた文脈は再利用性を著しく下げます。
隠れた文脈があるソフトウェアを無理やり再利用することはさらなる問題を引き起こします。
設計によって排除できる2種類の依存
隠れた文脈について解説するとき、以下のような説明を行いました。
隠れた文脈が存在すると、親モジュールは知らない間に子モジュールの文脈に依存してしまい、再利用性が下がってしまいます。
この説明を理解するには、ここで使われている、依存とはどのような意味をもつ言葉なのかを知る必要があります。まずは依存の定義です。
Aが変更されたときBも変更しなければならないとき、BはAに依存している
ソフトウェア設計では、プログラマの認知コストを下げることが重要です。プログラマがあるモジュールを読むときやモジュールを変更しようとするとき、依存関係にある別のモジュールがあれば、依存先のモジュールも理解しなければなりません。
モジュールのもつ性質には抽象度と文脈があるため、依存関係にも2種類が存在します。
処理の依存
処理の依存とは、抽象度の概念に対応する依存関係です。
モジュールAの具体的な処理の詳細によってモジュールBの内容が影響を受けるとき、モジュールBはモジュールAの処理に依存している
このような一般的な処理の依存関係のうち、設計によって消さなければならないのは、次の処理の依存です。
子モジュールの具体的な処理の詳細によって親モジュールの内容が影響を受けるとき、親モジュールは子モジュールの処理に依存している
なぜなら、子モジュールは親モジュールを呼び出していないため、子モジュールは原理的に親モジュールの処理に依存しえないからです。
処理の依存という概念を用いることで、これまであまり深く考えずに使ってきた、モジュール分割による抽象化の正体を定義できます。
抽象化
モジュールの処理のうち、一定の意味を持ってまとまっている一部の処理を取り出して、意味をシグネチャとして取り出し、処理をサブモジュールの内容として取り出して、上位モジュールからはシグネチャだけを指定することにより処理の依存を消す手法のこと
親モジュールは子モジュールのシグネチャを指定して呼び出しているので、常に意味には依存しています。
例:ダメージ計算処理
ダメージ計算処理の内部で、攻撃力計算メソッドを呼び出しているときに、それが実は攻撃力を計算していなかったら呼び出し元モジュールは壊れてしまいますよね。逆に、攻撃力計算が武器の威力とキャラの攻撃力の足し算だったところが、掛け算に変わったとしても上位モジュールは壊れません。
例:道案内アプリ
文脈を保たない抽象化でも同様の仕組みが成り立ちます。道案内アプリで、最短距離を行うサブモジュールは、「辺に重みが付いた単純グラフ上で、特定の始点から任意の点までの最短距離を求める」という抽象的な意味だけをモジュールのシグネチャに与え、具体的にどのような手順でそれを実現するかを隠します。内部でダイクストラ法を使っていようが、ワーシャルフロイド法を使っていようが親モジュールからすれば関係ないのです。しかし、親モジュールはサブモジュールの入出力の意味には依存しているため、サブモジュールが勝手に意味の異なる「もっとも遠い点までの距離」を出力した場合は壊れます。
文脈の依存
文脈の依存とは、文脈の概念に対応する依存関係です。
モジュールAの持つ文脈によってモジュールBの文脈が影響を受けるとき、モジュールBはモジュールAの文脈に依存している
このような一般的な処理の依存関係のうち、設計によって消さなければならないのは、次の処理の依存です。
子モジュールの持つ文脈によって親モジュールの文脈が影響を受けるとき、親モジュールは子モジュールの文脈に依存している
モジュールを呼び出すと、上位モジュールの情報は下位モジュールの文脈をすべて含みます。なぜなら、前節で説明した通り、モジュールを呼び出した側は下位モジュールの意味を指定して呼び出すからです。モジュール名は、そのモジュールの考えうる文脈の範囲で最も抽象的につけるべきだという原則があるため、モジュール名から得られる情報はすなわちモジュールの文脈となります。モジュールの文脈とは、モジュール自体を見たときに、その呼び出し元に関して分かるすべての内容という定義だったので、次のことが分かります。
親モジュールが子モジュールの文脈に依存することの必要十分条件は、隠れた文脈が発生することである
依存性逆転の原則
依存性逆転の原則は、隠れた文脈による問題を解決するための方法論です。
依存性逆転の原則
- 隠れた文脈は、インターフェースを用いて解消しなければならない
- インターフェースを用いたことで失われた文脈を持つ処理は、引数で実装を与えることで呼び出さなければならない
「不適切な依存関係を解消するための設計上の手法」という点では、依存性逆転の操作は抽象化と対応するような非常に適用範囲の広い操作です。
図を用いて解説すると、このような隠れた依存関係を解決したいです。
このようにインターフェースを利用します。図の白い三角形はインターフェースの呼び出しです。
どうしてこれが「逆転」なのでしょうか。それは、文脈が引数として上位モジュールから与えられるからです。文脈を与えている様子を次に示します。
黒い三角形がインターフェースを満たす、より多くの文脈を持った処理です。上位モジュールはより多くの文脈を持っているため、黒い三角形を直接参照しても隠れた文脈の問題が起こりません。そのため、引数として渡すことが出来ます。その結果、白い三角形の場所で黒い三角形の処理が呼び出されます。
このように、依存性逆転の原則で逆転するのは、文脈の与えられる場所です。下位モジュールから上位モジュールへと与えられていた文脈が、上位モジュールが下位モジュールに与えるという形に逆転したのです。具体抽象の概念だけでは依存性逆転の原則の適用基準を説明しきれないことが理解できたかと思います。
依存性逆転の原則は、文脈についての話である。そのため、抽象に依存するという用語はこの記事における具体抽象の意味において誤りである。
具体例
interface IHeaderInjectSource {
string GetValue();
string GetKey();
}
static void InjectHeader(Request req, IHeaderInjectSource[] sources) {
for(int i = 0;i < sources.Length;i++) {
var k = sources[i].GetKey();
var v = sources[i].GetValue();
req.SetHeader(k, v);
}
}
依存性逆転の原則の失敗パターン
隠れた文脈が無いのにインターフェースを導入する
上位のモジュールが文脈依存性のある抽象化である場合、インターフェースを使う意味がありません。なぜなら、インターフェースを使わなくても隠れた文脈は発生しえないからです。
上位モジュールが「与えられたURLからリダイレクト先URLを見つける」だった場合、内部で「クエリパラメータrdが存在するかを判定する」「カスタムヘッダーX-Application-Redirectが存在するかを判定する」などの処理をやっていようが、それらは単なる文脈依存性のある抽象化であり、隠れた文脈を生成しません。したがって、次のようなインターフェースを作成することは誤りです。
interface URLGetter {
string GetURL(req Request);
}
なぜなら、このようなインターフェースを使ったところで、結局それは1回しか使われず、しかも使う対象はURLGetter
を実装するすべてのクラスを一度にインスタンス化して順に使うはずだからです。例えば次のようなコードです。
QueryParamGetter query = new QueryParamGetter();
query.GetURL(req);
CustomHeaderGetter header = new CustomHeaderGetter();
header.GetURL(req)
こんなややこしい書き方をするぐらいであれば、単に関数を呼び出した方が簡潔です。
上位の処理がなにもしていないのにインターフェースを導入する
文脈依存性のない抽象化をしたところで、そのモジュールは特に仕事をしないならば、当然再利用の価値はないため、特に役立ちません。
例えば、C#標準で提供されているIDisposable
は次のメソッドの実装を要求します。
public void Dispose ();
このインターフェースを利用する上位モジュールは「最終的に破棄しなければならないリソースである」という情報しか使うことが出来ません。上位の処理に使える情報があまりにも少ないため、IDisposableの正体を知らずに書けるのは結局これだけです。
void DisposeAfterUse(Action doSomething, Action<Exception> doErrorHandle IDisposable resource) {
try {
doSomething();
} catch(Exception e) {
doErrorHandle(e)
} finally {
resource.Dispose();
}
}
このインターフェースを再利用したところで何になるのでしょうか、もっと言えば「確保したリソースはかならず解放しよう」というコーディングのパターンを適用しようと思いつくための脳のリソースとDisposeAfterUse
を使おうと思いつくためのリソースに違いはあるのでしょうか。このように、インターフェースは文脈依存性のない抽象化の再利用のための機構であるため、不適切な文脈依存性のない抽象化に対して使おうとすると失敗するのです。
※それでは、どうしてC#でIDisposable
が提供されているかというと、C#の構文レベルでの支援が提供されているからです。より詳しくIDisposable
について知りたい人は公式ドキュメントなどの記事を参照してください
逆に、IEnumerable
とLINQ
は上位モジュールが意味のあるまとまった処理を提供している例です。IEnumerable
は要素を列挙することが出来るデータ構造を表します。列挙可能というだけの情報でも、以下のような再利用性が高くまとまった意味と内容を持った処理を実装できます。
-
First
:特定の条件を満たす最初の要素を返す -
Select
:全ての要素に対して何らかの変換を行った列挙可能なデータ構造を返す -
ToArray
:同じ要素を持つ配列を作成する
再利用されないのにインターフェースを導入する
複数のモジュールから呼ばれるとき、上位モジュールの文脈ごとに分岐をしなくても良いというのがインターフェースを導入する利点です。そのため、単一のモジュールからしか呼び出されないのであれば、インターフェースは無価値です。すでに解説したヘッダーの書き換えにおけるインターフェースの役割について、再利用があった場合となかった場合で比較してみましょう。
セッションに保存しているIDToken
からの情報を取得してヘッダーの書き換えを行うだけのソフトウェアと、OIDCで定義されたuserinfo
エンドポイントのレスポンスのJSONから情報を取得してヘッダーの書き換えを行うだけのソフトウェアが2つ存在したとします。
それらが、IHeaderInjectionSource
を介してInjectHeader
を共有しているとき、コードは次のようになります。
IDTokenSource source = new IDTokenSouce("クレーム名");
InjectHeader(new IHeaderInjectionSource[] {source});
UserInfoSource source = new UserInfoSouce("クレーム名");
InjectHeader(new IHeaderInjectionSource[] {source});
どちらのコードにも条件分岐が現れていません。なぜなら、複数の実装の選択肢の内どれを使うのかは、より多くの文脈を持つ呼び出し元モジュール側が知っているからです。複数のモジュールによって再利用される側で一気に分岐させるのではなく、呼び出し元モジュールが複数あることを活かして分岐を消したのです。つまり、複数モジュールによる再利用があると分岐が減らせて処理が簡潔に書けるということが分かります。
さて、これとほとんど同じ内容の処理ですが、IDToken
方式とuserinfo
方式のどちらでも読み込まなければならず、設定ファイルに書き込まれた方法のやり方でヘッダーを書き換えなければいけない単一のソフトウェアが存在したとします。
var data = loadConfig();
IHeaderInjectionSource[] sources = new IHeaderInjectionSource[data.HeaderInjections.Length];
for(int i = 0;i < data.HeaderInjections;i++){
var injectConfig = data.HeaderInjections[i];
// 結局ここで分岐するハメになる
switch(injectionConfig.Type) {
case IDToken:
sources[i] = new IDTokenSource(injectionConfig.Claim);
break;
case UserInfo:
sources[i] = new UserInfoSource(injectionConfig.Claim);
break;
}
}
InjectHeader(sources);
この場合、結局分岐は無くなっていません。つまり、インターフェースの利用はただの分岐の場所の変更です。もっと言えば本質的な問題の先送りにすぎません。素直に呼び出される側で分岐したほうが、上位モジュールがIDToken
やらUserInfo
やらを知らなくてよくなるため、より多くの抽象化を提供できます。
こうなってしまうのは、再利用しないのであれば、モジュールに隠れた文脈があろうが問題にならないので、インターフェースはただの冗長な書き方になってしまうからです。
おわりに
この記事では、一般に説明されている依存性逆転原則について、抽象度と文脈の概念を用いた再解釈を行いしました。これにより、依存性逆転が再利用を支援する概念であることが明らかになり、より適切な用途を理解できたかと思います。