C# のベスト プラクティス

C# で SOLID の原則に違反する危険性

Brannon King

ソフトウェアを作成するプロセスが理論の段階から実際のエンジニアリングの段階に進化するにつれ、いくつか原則が生まれています。原則を当てはめるのは、コードの価値を維持できるコンピューター コードの 1 つの機能です。パターンとは、善し悪しは別にして、共通するコード シナリオのことです。

たとえば、マルチスレッド環境で安全に機能するコンピューター コードに価値を見出すかもしれません。あるいは、コードを変更してもクラッシュしないコンピューター コードに価値を求めることもあります。実際、有用な要素を数多く含むコンピューター コードには高い価値がありますが、日常目にするのはその逆です。

ソフトウェア開発には、SOLID と略される優れた原則があります。SOLID とは、S (Single responsibility: 単一責任)、O (Open for extension and closed for modification: 拡張に対しては開き、修正に対しては閉じる)、L (Liskov substitution: リスコフの置換)、I (Interface segregation: インターフェイス分離)、D (Dependency Inversion: 依存関係逆転) を表します。本コラムは、これらの原則に違反する C# 固有のさまざまなパターンを紹介するため、これらの原則についてある程度理解していることが前提です。SOLID の原則についてあまりご存じない方は、この先を読み進める前にこの原則について簡単に確認しておくことをお勧めします。また、モデルやビューモデルといったアーキテクチャに関する用語についても、ある程度の知識があることを前提として話を進めます。

SOLID という略語や原則は、筆者が創作したものではありません。我々と叡知を共有してくれた Robert C. Martin、Michael Feathers、Bertrand Meyer、James Coplien をはじめとする多数の方々に感謝いたします。この原則は多数の書籍やブログ記事で取り上げられ、詳しく説明されています。本稿が、これらの原則がより広範に当てはめられるきっかけになればさいわいです。

私は、若手のソフトウェア エンジニアと仕事をしたり彼らを教育するうちに、最初に力を注いだ本格的なコーディングと実際に利用され続けるコードとの間には大きな差があることに気付きました。今回は、気軽に行える方法でこの差を埋めようと考えています。SOLID の原則をあらゆる形式のソフトウェアに当てはめることができることを理解していただくために、少し奇妙な例を使います。

ソフトウェア エンジニアを目指す者にとって、本格的な開発環境は課題の連続です。学校では問題に対してトップダウンの視点を持って考えることを教わるため、業務環境で使用するボリュームのあるソフトウェアにも、まずトップダウンの手法を当てはめようとします。すると、間もなくトップレベルの関数が手に負えないサイズに増大していることに気付きます。変更を最小限に抑えるには、システム全体に関する十分な実務知識が必要になりますが、変更を阻止することはできません。ソフトウェアの原則 (今回はその一部についてのみ説明します) に従うと、建造物が基礎より大きくなることを防ぐことができます。

S (Single Responsibility: 単一責任) の原則

単一責任の原則は、多くの場合、「オブジェクトが果たす役割は 1 つであるべき」と定義されます。ファイルやクラスが大きくなるほど、この原則を守ることは難しくなります。この定義を念頭に置いて次のコードを見てください。

public IList> ComputeNerdClusters(
   List nerds,
   IPlotter plotter = null) {
   ...
   foreach (var nerd in nerds) {
     ...
     if (plotter != null)
       plotter.Draw(nerd.Location, 
       Brushes.PeachPuff, radius: 10);
     ...
   }
   ...
 }

このコードで問題になるのはどこでしょう。ソフトウェアは作成中でしょうか、それともデバッグ中でしょうか。おそらく、この特定の描画コードは、デバッグのみが目的です。インターフェイスから認識されるだけで、インターフェイスに所属していないサービスにしている点は優れています。解決の手掛かりはブラシです。ピーチパフが美しく広がっても、このコードはプラットフォーム固有なので、処理モデルの型階層からは外れています。処理を分離する方法や関連するデバッグ ユーティリティは数多く存在しており、少なくとも、継承やイベントによって必要なデータを公開することができます。テストとテストのビューは切り離すようにします。

問題のある例をもう 1 つ紹介します。

class Nerd {
   public int IQ { get; protected set; }
   public double SuspenderTension { get; set; }
   public double Radius { get; protected set; }
   /// Get books for growing IQ
   public event Func InTheMoodForBook;
   /// Get recommendations for growing Radius
   public event Func InTheMoodForTwink;
   public IList FitNerdsIntoPaddedRoom(
     IList nerds, IList boundary)
   {
     ...
   }
 }

このコードで問題になるのはどこでしょう。ここでは、いわゆる「学校の教科」が混在しています。学校では、異なる教科は別のクラスで学習していました。コードでは、この区別を維持することが重要です。全体的に関連性がないためではなく、体系的な取り組みにするためです。一般に、数学、モデル、文法、ビュー、物理アダプターやプラットフォーム アダプター、顧客固有のコードなど、さまざまな教科を 2 つ同じクラスに含めないようにします。

学校でも、彫刻、木材、金属を使って作品を作製する際にこれと似たような点があります。このような作品を作製するには、測定、分析、指示などが必要です。先ほどの例では、数学と (FitNerdsIntoPaddedRoom が属さない) モデルが混在していました。メソッドは、ユーティリティ クラスや静的クラスに簡単に移動できるので、数学テストのルーチンでモデルのインスタンスを作成する必要はありません。

役割を複数持たせた例をもう 1 つ紹介します。

class AvatarBotPath
 {
   public IReadOnlyList Segments { get; private set; }
   public double TargetVelocity { get; set; }
   public bool IsReverse { get { return TargetVelocity < 0; } }
   ...
 }
 public interface ISegment // Elsewhere
 {
   Point Start { get; }
   Point End { get; }
   ...
 }

ここでは何が問題になるでしょう。このコードでは、明らかに 1 つのオブジェクトで 2 つの異なる抽象化を表現しています。1 つは図形の移動に関連する抽象化で、もう 1 つは幾何学図形自体を表す抽象化です。これはコードでよく見られる例で、1 つの表現を用意し、その表現に対応するパラメーターを用途ごとに分離します。

ここで有効なのが継承です。TargetVelocity プロパティと IsReverse プロパティを継承クラスに移動し、簡潔な IHasTravelInfo インターフェイスでこれらのプロパティを取得します。また、一般的な機能のコレクションを図形に追加することもできます。このようにすると、速度が必要な場合に機能コレクションをクエリして、特定の図形にその機能が定義されているかどうかを確認できます。また、他のコレクション メカニズムを使用して、表現と移動のパラメーターをペアにすることもできます。

O (Open Closed: 開放/閉鎖) の原則

次は、「拡張に対しては開き、変更に対しては閉じる」という原則です。この原則に従うにはどうすればよいでしょう。できれば次のようなコードは避けるようにします。

void DrawNerd(Nerd nerd) {
   if (nerd.IsSelected)
     DrawEllipseAroundNerd(nerd.Position, nerd.Radius);
   if (nerd.Image != null)
     DrawImageOfNerd(nerd.Image, nerd.Position, nerd.Heading);
   if (nerd is IHasBelt) // a rare occurrence
     DrawBelt(((IHasBelt)nerd).Belt);
   // Etc.
 }

ここでは何が問題になるでしょう。このコードでは、新しいものを表示する必要が生じると毎回メソッドを変更しなければなりません。そして、常に新しいものを表示することが求められます。ソフトウェアの場合ほぼすべての新機能にはなんらかの UI 要素が必要です。結局のところ、新機能の必要性が問題になるのは、既存のインターフェイスに不足があるためです。解決の手掛かりはこのメソッドのパターンです。上記の if ステートメントは if ステートメントが制御しているメソッド内に移動できますが、それでは問題は残ったままです。

さらなる良案が必要です。しかし、どうすればよいでしょう。何が考えられるでしょう。わかっているのは、特定要素の描画方法に対応するコードがあることです。それで十分です。必要なのは、特定の要素を、その要素を描画するコードに対応させる汎用の処理だけです。基本的には次のようなパターンになります。

readonly IList _renderers = new List();
 void Draw(Nerd nerd)
 {
   foreach (var renderer in _renderers)
     renderer.DrawIfPossible(_context, nerd);
 }

レンダラーのリストに追加する手段は他にもあります。ですが、このコードで重要なのは、既知のインターフェイスを実装する描画クラス (または描画クラスに関連するクラス) を記述することです。レンダラーは、入力に基づいて、描画できる要素や描画すべき要素があるかどうかを判断する機能が必要です。たとえば、ベルトを描画するコードは、インターフェイスを確認して必要に応じて処理を進める固有の「ベルトのレンダラー」に移動します。

Draw メソッドから CanDraw を切り離す必要がある場合がありますが、それは開放/閉鎖原則 (OCP) の違反にはなりません。レンダラーを使用するコードは、新しいレンダラー追加する場合でも変更する必要がありません。このように、この原則はとてもシンプルです。また、新しいレンダラーを正しい順序で追加できるようにもなります。今回は例としてレンダリングを使用していますが、入力の処理や、データの処理と保存でも同様です。この原則は、あらゆる種類のソフトウェアに対してさまざまな形で当てはめることができます。Windows Presentation Foundation (WPF) では、このパターンのエミュレーショが少し難しくなりますが、不可能ではありません。図 1 に使用可能な 1 つのオプションを示します。

図 1 Windows Presentation Foundation のレンダラーを単一のソースにマージする例

public abstract class RenderDefinition : ViewModelBase
 {
   public abstract DataTemplate Template { get; }
   public abstract Style TemplateStyle { get; }
   public abstract bool SourceContains(object o); // For selectors
   public abstract IEnumerable Source { get; }
 }
 public void LoadItemsControlFromRenderers(
     ItemsControl control,
     IEnumerable defs) {
   control.ItemTemplateSelector = new DefTemplateSelector(defs);
   control.ItemContainerStyleSelector = new DefStyleSelector(defs);
   var compositeCollection = new CompositeCollection();
   foreach (var renderDefinition in defs)
   {
     var container = new CollectionContainer
     {
       Collection = renderDefinition.Source
     };
     compositeCollection.Add(container);
   }
   control.ItemsSource = compositeCollection;
 }

問題のある例をもう 1 つ示します。

class Nerd
 {
   public void WriteName(string name)
   {
     var pocketProtector = new PocketProtector();
     WriteNameOnPaper(pocketProtector.Pen, name);
   }
   private void WriteNameOnPaper(Pen pen, string text)
   {
     ...
   }
 }

ここでは何が問題になるでしょう。このコードの問題は、膨大で多岐にわたります。特筆すべき最大の問題は、PocketProtector インスタンスの作成をオーバーライドする手段がないことです。このようなコードでは、継承クラスの記述が難しくなります。このシナリオに対処するオプションはいくつかあり、次の処理を実行するようにコードを変更します。

  • WriteName メソッドを仮想化します。この場合は、変更済みの PocketProtector のインスタンスを作成するという目標を達成するために、WriteNameOnPaper の保護も必要になります。
  • WriteNameOnPaper メソッドをパブリックにします。ただし、この場合は問題のある WriteName メソッドが継承クラスに残ります。WriteName を取り除かないのは良いオプションとは言えません。このような場合のオプションは、PocketProtector のインスタンスをメソッドに渡すことです。
  • PocketProtector の構築を唯一の目的とする保護された仮想メソッドを追加します。
  • PocketProtector の型であるジェネリック型 T をクラスに追加し、一種のオブジェクト ファクトリを使用して PocketProtector を構築します。このようにすると、同じようにオブジェクト ファクトリを挿入する必要があるだけです。
  • クラス内に PocketProtector を構築する代わりに、PocketProtector のインスタンスを、クラスのコンストラクターまたはパブリック プロパティを通じてこのクラスに渡します。

PocketProtector が再利用可能であると仮定すると、一般的に上記の最後のオプションが最良のプランになります。仮想の作成メソッドも簡単に実現できる優れたオプションです。

OCP を満たすには、どのメソッドを仮想化するかを考えます。多くの開発者は、「今使用していない継承クラスで呼び出す必要が出てきたときにメソッドを仮想化する」からという理由で、この決定を最後まで行いません。コードを拡張する開発者が最初のコードですべての修正に対処できるようにと、すべてのメソッドを仮想化する開発者もいます。

どちらのアプローチも正しくなく、オープン インターフェイスでの作業が不可能となる典型的な例です。仮想メソッドが多すぎると、コードを後で変更する可能性に制限が生じます。オーバーライド可能なメソッドが不足していると、コードの拡張や再利用に制限が生じます。これにより、コードの利便性や有効期間が限定されます。

OCP に違反している一般的な例をもう 1 つ示します。

class Nerd
 {
   public void DanceTheDisco()
   {
     if (this is ChildOfNerd)
             throw new CoordinationException("Can't");
     ...
   }
 }
 class ChildOfNerd : Nerd { ... }

ここでは何が問題になるでしょう。Nerd では、Nerd の子の型への参照がハードコーディングされています。これを見るのは残念ですが、若い開発者はよくこの間違いをよく犯します。このコードが OCP に違反していることはおわかりでしょう。ChildOfNerd を拡張またはリファクタリングするには、複数のクラスに変更を加える必要があります。

基本クラスでは、そのクラスの継承クラスを直接参照しないようにします。直接参照を行うと、継承クラスの機能が他の継承クラスと矛盾することになります。この矛盾を回避する優れた方法は、クラスの継承クラスを別のプロジェクトに移動することです。このようにすると、プロジェクトの参照ツリーの構造によって先ほどの不幸なシナリオを防ぐことができます。

この問題は親子関係に限ったことではなく、ピア クラスにも存在します。次のようなコードがあるとします。

class NerdsInAnArc
 {
   public bool Intersects(NerdsInAnLine line)
   {
     ...
   }
   ...
 }

弧と直線は、一般的にオブジェクト階層でピアになります。互いに関する継承されない詳細情報は、交差アルゴリズムの最適化に必要になることが多いため、互いに認識しないようにします。もう一方を変更することなく、片方だけを変更できるようにします。このようにすると、再び単一責任の原則違反の問題が持ち上がります。弧を格納または分析する場合は、その弧固有のユーティリティ クラスに分析処理を組み込みます。

ピアにまたがるこの特定の機能が必要な場合は、相応のインターフェイスを導入する必要があります。交差エンティティの混乱を防ぐには、「具象クラスではなく "is" キーワードと抽象化を使用する」という原則に従います。例として、IIntersectable インターフェイスまたは INerdsInAPattern インターフェイスを作成できます。しかし、それでもインターフェイス上で公開されているデータの分析には他の交差ユーティリティ クラスを利用する可能性が大きいでしょう。

L (Liskov Substitution: リスコフの置換) の原則

リスコフの置換の原則は、継承クラスの置換を管理する場合のガイドラインを定義しています。基本クラスの代わりにオブジェクトの継承クラスを渡しても、呼び出されるメソッドの既存機能に支障をきたすことがなく、特定のインターフェイスの実装はすべて互いに置き換えられるべきである、という原則です。

C# では、(戻り値の型が、基本クラスの戻り値の型を継承するクラスの場合でも) オーバーライド メソッドの戻り値やパラメーターの型を変更することはできません。そのため、置換の原則違反として最も一般的な、メソッド引数の反変性 (オーバーライド メソッドは親メソッドと同じ型または親メソッドの基本型を使用する必要がある) と戻り値の型の共変性 (オーバーライド メソッドの戻り値は、基本クラスの戻り値と同じ型か、その型を継承する必要がある) に対処する必要はありません。ただし、この制約は回避しようとするのが一般的です。

class Nerd : Mammal {
   public double Diopter { get; protected set; }
   public Nerd(int vertebrae, double diopter)
     : base(vertebrae) { Diopter = diopter; }
   protected Nerd(Nerd toBeCloned)
     : base (toBeCloned) { Diopter = toBeCloned.Diopter; }
   // Would prefer to return Nerd instead:
   // public override Mammal Clone() { return new Nerd(this); }
   public new Nerd Clone() { return new Nerd(this); }
 }

ここでは何が問題になるでしょう。抽象参照を指定して呼び出されると、オブジェクトの動作は変化します。クローン メソッドの new は仮想ではありません。したがって、Mammal 参照を使用すると実行されません。メソッド宣言のコンテキストにおける new キーワードはおそらく 1 つの機能ですが、基本クラスを制御しない場合、この機能を確実かつ適切に実行するにはどうすればよいでしょう。

C# にはあまり快適ではありませんが、代替として機能する方法がいくつか用意されています。ジェネリック インターフェイス (例: IComparable) を使用すると、各継承クラスで明示的に実装できます。しかし、まだ実際のクローン処理を行う仮想メソッドが必要です。これは、派生型にクローンを対応させるためです。C# では、イベントの使用時における戻り値の型の反変性やメソッド引数の共変性もサポートされていますが、公開されているインターフェイスをクラスの継承によって変更することはできません。

コードから判断して、クラス メソッドを解決した開発者が使用するメソッドのフットプリントに、戻り値の型が含まれていると考える読者もいるかもしれません。それは間違いです。名前と入力の型が同じで戻り値が異なるオーバーライド メソッドを複数使用することはできません。メソッドの制約はメソッドの解決においても無視されます。図 2に示しているコード例は、構文的には正しくても、メソッドが不明確であるためにコンパイルされません。

図 2 不明確なメソッドのフットプリント

interface INerd {
   public int Smartness { get; set; }
 }
 static class Program
 {
   public static string RecallSomeDigitsOfPi(
     this IList nerdSmartnesses) where T : int
   {
     var smartest = nerdSmartnesses.Max();
     return Math.PI.ToString("F" + Math.Min(14, smartest));
   }
   public static string RecallSomeDigitsOfPi(
     this IList nerds) where T : INerd
   {
     var smartest = nerds.OrderByDescending(n => n.Smartness).First();
     return Math.PI.ToString("F" + Math.Min(14, smartest.Smartness));
   }
   static void Main(string[] args)
   {
     IList list = new List { 2, 3, 4 };
     var digits = list.RecallSomeDigitsOfPi();
     Console.WriteLine("Digits: " + digits);
   }
 }

図 3のコードに、置換する機能にどのように違反することになるかを示しています。継承クラスについて考えます。いずれかの継承クラスでは、isMoonWalking フィールドがランダムに変更される可能性があります。その場合は、重要なクリーンアップ セクションが基本クラスで抜け落ちる危険性があります。isMoonWalking フィールドはプライベートにすべきです。継承クラスが認識する必要がある場合は、変更を加えずにアクセスを提供する、保護された getter プロパティを使用します。

図 3 置換する機能に違反する過程の例

class GrooveControl: Control {
   protected bool isMoonWalking;
   protected override void OnMouseDown(MouseButtonEventArgs e) {
     isMoonWalking = CaptureMouse();
     base.OnMouseDown(e);
   }
   protected override void OnMouseUp(MouseButtonEventArgs e) {
     base.OnMouseUp(e);
     if (isMoonWalking) {
       ReleaseMouseCapture();
       isMoonWalking = false;
     }
   }
 }

知識のあるプログラマーや、細かい点を気にするプログラマーには、もう 1 歩先に進んだ手法があります。マウス ハンドラー (または、プライベート状態の利用や変更を行う他のメソッド) をシールし、呼び出しが必須でないイベントやその他の仮想メソッドを継承クラスで使用できるようにします。基本メソッドの呼び出しが必要になるパターンは、可能ですが理想的ではありません。基本メソッドの呼び出しを予定していて忘れた経験は、どのような開発者にもあるでしょう。継承クラスがカプセル化している状態を損なわれないようにします。

また、リスコフの置換では、継承クラスで新しい型の例外がスローされないようにします (ただし、既に基本クラスでスローされている例外の継承クラスは問題ありません)。C# にこの処理を強制する方法はありません。

I (Interface Segregation: インターフェイス分離) の原則

各インターフェイスには特定の目的があります。目的が異なるオブジェクトには、インターフェイスの実装を強制してはいけません。経験上、インターフェイスが大きくなると、インターフェイスに含まれるメソッドを一部利用しない実装者が出てくる可能性が高くなります。これが、インターフェイス分離の原則の根底にある考え方です。Microsoft .NET Framework で長年使用されている一般的なインターフェイスのペアについて考えてみます。

public interface ICollection : IEnumerable {
   void Add(T item);
   void Clear();
   bool Contains(T item);
   void CopyTo(T[] array, int arrayIndex);
   bool Remove(T item);
 }
 public interface IList : ICollection {
   T this[int index] { get; set; }
   int IndexOf(T item);
   void Insert(int index, T item);
   void RemoveAt(int index);
 }

インターフェイスはまだいくらか有用ですが、上記のインターフェイスを使用する場合にコレクションを変更することが暗黙の了解事項です。多くの場合、このようなデータ コレクションの作成者は他人がデータを変更することを防ぎたいと考えています。インターフェイスをソースとコンシューマーに分離することは、実際には非常に便利です。

多くのデータ ストアは、インデックス指定可能で書き込み不可の共通インターフェイスを共有します。データ分析ソフトウェアまたはデータ検索ソフトウェアについて考えます。これらのソフトウェアは、通常、分析のために大きなログ ファイルやデータベース テーブルを読み取ります。データの変更が、アジェンダに含まれることはありません。

確かに、IEnumerable インターフェイスは最小限の読み取り専用インターフェイスとして使用することを目的としていました。LINQ 拡張メソッドの追加により、このインターフェイスはその運命を全うし始めています。また、マイクロソフトは、インデックス指定可能なコレクション インターフェイス間に隔たりがあることにも気が付きました。同社は、現在多くのフレームワーク コレクションによって実装されている IReadOnlyList を .NET Framework の 4.5 バージョンに追加することでこれに対処しています。

こうした長所は、長年使用されている ICollection インターフェイスにも見られます。

public interface ICollection : IEnumerable {
   ...
   object SyncRoot { get; }
   bool IsSynchronized { get; }
   ...
 }

要するに、コレクションを反復処理する前に、まずコレクションの SyncRoot を潜在的にロックする必要があります。特定のこれらの要素は、とりあえず実装しなければならないという考えだけで数多くの継承クラスで明示的に実装されていました。マルチスレッドのシナリオでは、(SyncRoot を使用するのではなく) コレクションを使用するすべての場所でそのコレクションをロックすることが想定されるようになりました。

読者のほとんどは、スレッドセーフな方法でアクセスできるように、コレクションのカプセル化を考えるでしょう。この場合、foreach を使用するのではなく、マルチスレッドのデータ ストアをカプセル化し、代わりにデリゲートを受け取る ForEach メソッドのみを公開する必要があります。さいわい、こうした混乱のほとんどは、.NET Framework 4 の同時実行コレクションや現在 (NuGet によって) .NET Framework 4.5 で使用できる変更不可のコレクションといった新しいコレクション クラスでは起こりません。

.NET ストリームの抽象化では、同じように (読み取りおよび書き込み可能な要素と同期フラグの両方を含めて) 大きくなりすぎるという問題が発生します。ただし、この抽象化には、CanRead、CanWrite、CanSeek など書き込み可能かどうかを判別するプロパティが用意されています。if (stream.CanWrite) と if (stream is IWritableStream) を比べてみましょう。書き込み可能でないストリームを作成している場合、好ましいのは間違いなく後者です。

次に、図 4のコードを見てみましょう。

図 4 不要な初期化とクリーンアップの例

// Up a level in the project hierarchy
 public interface INerdService {
   Type[] Dependencies { get; }
   void Initialize(IEnumerable dependencies);
   void Cleanup();
 }
 public class SocialIntroductionsService: INerdService
 {
   public Type[] Dependencies { get { return Type.EmptyTypes; } }
   public void Initialize(IEnumerable dependencies)
   { ... }
   public void Cleanup() { ... }
   ...
 }

どこに問題があるのでしょう。サービスの初期化とクリーンアップは、新たに作成するのではなく、.NET Framework で共通して使用できる優れた制御の反転 (IoC) コンテナーの 1 つによって実行する必要があります。例を示すため、サービス マネージャー/サービス コンテナー/サービス ブートストラップ以外では (サービスを読み込むコードに関係なく) Initialization や Cleanup について考えないものとします。処理するのはサービスを読み込んだコードです。速すぎるタイミングで Cleanup を他人に呼び出されたくはないでしょう。C# には、これに役立つ明示的実装というメカニズムがあり、次のようにサービスを簡潔に実装できます。

public class SocialIntroductionsService: INerdService
 {
   Type[] INerdService.Dependencies { 
     get { return Type.EmptyTypes; } }
   void INerdService.Initialize(IEnumerable dependencies)
   { ... }
   void INerdService.Cleanup() {       ... }
   ...
 }

一般的に、純粋に 1 つの具象クラスを抽象化するのではなく、ある程度の目的を持ってインターフェイスを設計することが必要です。これにより、整理や拡張を行えるようになります。しかし、注目すべき例外が少なくとも 2 つあります。

まず、インターフェイスは、具体的な実装よりも変更の頻度は少なくなる傾向があります。これを利用できます。インターフェイスを別のアセンブリに含め、ユーザーがそのインターフェイス アセンブリだけを参照できるようにします。これにより、コンパイル速度が上がります。インターフェイス上に、そのインターフェイスに属さないプロパティを配置するのを防ぐことができます (固有のプロジェクト階層では、不適切なプロパティの型を使用できないため)。対応する抽象クラスとインターフェイスを同じファイルに含めると、いずれ問題になります。インターフェイスは、そのインターフェイス実装の親として、およびその実装を使用するサービスのピア (サービスの抽象化) としてプロジェクト階層に適合します。

2 つ目に、インターフェイスには本質的に依存関係を含めません。そのため、インターフェイスは、オブジェクトのモック/プロキシ フレームワークを通じて簡単に単体テストを実行するのに役立ちます。では、最後の原則に移りましょう。

D (Dependency Inversion: 依存関係逆転) の原則

依存関係逆転は、具象型ではなく抽象化に依存することを意味します。この原則と前述のその他の原則には、重複する点が多数存在します。先に示した例には、抽象化の使用に失敗したものも含まれています。

Eric Evans は、著書である『エリック・エヴァンスのドメイン駆動設計』(翔泳社、2011 年) で、依存関係逆転について説明するのに役立つオブジェクトの分類をいくつか示しています。この書籍を要約すると、オブジェクトは値、エンティティ、サービスのいずれかに分類すると便利であるということになります。

値とは、依存関係がなく、通常は一時的な不変のオブジェクトを指します。一般的には抽象化されておらず、好きなようにインスタンスを作成することができます。しかし、(抽象化のメリットを全面的に享受できる場合は特に) 抽象化しても問題はありません。一部の値は、時間の経過とともにエンティティに成長する場合があります。エンティティは、ビジネスのモデルおよびビューモデルであり、値型やその他のエンティティで構成されています。このような要素では、特にさまざまな異なるモデルを表す 1 つのビューモデル (またはその逆) を使用する場合に、抽象化を使用すると便利です。サービスとは、エンティティを含むクラス、エンティティを編成するクラス、エンティティに情報を渡すクラス、エンティティを使用するクラスを指します。

依存関係逆転では、この分類を考慮に入れて、サービスとサービスを必要とするオブジェクトを主に扱います。サービス固有のメソッドは常に 1 つのインターフェイスでキャプチャします。サービスにアクセスする必要がある場合は、インターフェイスを介してアクセスします。コードでは、サービスが構築されている場所以外でサービスの具象型を使用しないようにします。

一般に、サービスは他のサービスに依存します。一部のビューモデルは、コンテナー サービスやファクトリ型のサービスをはじめとするサービスに依存しています。これにより、サービスのインスタンスを作成するには完全なサービス ツリーが必要になるため、テストを目的としてサービスのインスタンスを作成することは困難です。サービスの本質的な部分をインターフェイスに抽象化します。次に、そのインターフェイスを利用して、サービスへのすべての参照を作成します。これにより、テスト目的でその参照を簡単に再現できます。

コードでは任意のレベルで抽象化を作成できます。途中で新しい抽象化を取り入れる場合は、「A が B のインターフェイスをサポートして、B が A のインターフェイスをサポートするのは問題だろう」と考えるときが最適なタイミングです。役に立つインターフェイスを作成してそのインターフェイスを使用します。

好みのインターフェイスに適合させるには、Adapter パターンと Mediator パターンが役立ちます。抽象化が増えてコードが増えるように聞こえますが、ほとんどの場合はそうではありません。相互運用を目指して少しずつ取り組んでいけば、A と B がなんらかの方法で互いに対話するために必要なコードを編成できます。

数年前、「開発者は "常にコードを再利用" すべきだ」という記述を目にしました。当時はあまりにシンプルに思え、そのようなシンプルなスローガンで画面に広がるスパゲッティ コードを突破できるとは信じられませんでした。ですが、時間の経過とともに学びました。次のコードを見てください。

private readonly IRamenContainer _ramenContainer; // A dependency
 public bool Recharge()
 {
   if (_ramenContainer != null)
   {
     var toBeConsumed = _ramenContainer.Prepare();
     return Consume(toBeConsumed);
   }
   return false;
 }

どこかに繰り返されているコードはあるでしょうか。_ramenContainer は 2 回使用されています。厳密に言うと、「共通部分式の削除」と呼ばれる最適化により、_ramenContainer はコンパイラで削除されます。説明のため、マルチスレッド環境でコードを実行していて、コンパイラではメソッドのクラス フィールドの読み取りが繰り返されるとします。この場合、クラス変数が使用される前に null に変化する危険性があります。

この問題はどのように解決すればよいでしょう。if ステートメントの前にローカル参照を導入します。この再調整を行うには、外側のスコープまたはスコープの前に新しい要素を追加する必要があります。プロジェクト編成における原則は同じです。コードまたは抽象化を再利用すると、最終的にプロジェクト階層で有益なスコープを得られます。依存関係によってプロジェクト間参照の階層を操作できるようにしましょう。

では、次のコードを見てください。

public IList RestoreNerds(string filename)
 {
   if (File.Exists(filename))
   {
     var serializer = new XmlSerializer(typeof(List));
     using (var reader = new XmlTextReader(filename))
       return (List)serializer.Deserialize(reader);
   }
   return null;
 }

このコードは抽象化に依存しているでしょうか。

答えは「いいえ」です。このコードは、ファイル システムへの静的参照から始まり、ハードコードされたシリアル化解除メソッドでハードコードされた型参照を使用しています。このコードは、例外処理はクラスの外で実行することを前提としており、ストレージのコードがなければテストすることはできません。

一般的に、このコードは 2 つの抽象化 (ストレージ形式用とストレージ メディア用) に移動します。ストレージ形式の例には、XML、JSON、Protobuf バイナリ データなどがあります。ストレージ メディアとは、ディスク上の直接ファイルやデータベースなどです。3 つ目の抽象化もこのようなシステムでは一般的です。この抽象化は、保存するオブジェクトを表していて、変更されることの少ない遺物のようなものです。

次の例を考えてみます。

class MonsterCardCollection
 {
   private readonly IMsSqlDatabase _storage;
   public MonsterCardCollection(IMsSqlDatabase storage)
   {
     _storage = storage;
   }
   ...
 }

上記の依存関係に問題はあるでしょうか。ヒントは依存関係の名前です。名前はプラットフォーム固有です。サービスはプラットフォーム固有ではありません (または、外部のストレージ エンジンを使用することで少なくともプラットフォームの依存関係を回避しようとしています)。このような状況では、Adapter パターンを使用する必要があります。

依存関係がプラットフォーム固有である場合、依存関係にあるプロジェクトでは独自のプラットフォーム固有コードが作成されます。これは、階層を 1 つ追加することで回避できます。階層を追加すると、プラットフォーム固有の実装が、その実装の (プラットフォーム固有のあらゆる参照を備えた) 特殊なプロジェクト内に存在するようにプロジェクトを編成できます。必要なのは、アプリケーションの開始プロジェクトから、プラットフォーム固有のすべてのコードを含むプロジェクトを参照することだけです。プラットフォームのラッパーは大きくなる傾向があるため、必要以上にラッパーを複製しないようにしましょう。

依存関係逆転は、この記事で説明したすべての原則をまとめたものです。この原則では、基盤となるサービスの状態に支障を与えることのない具体的な実装で実現できる、簡潔で目的のある抽象化を使用することが目標です。

SOLID 原則は、確かに、長く利用されるコンピューター コードに対する効果としては重複しているのが一般的です。中間コードは奥が深く、あらゆるオブジェクトを拡張できる限界を明らかにするという機能においてはすばらしいものです。多くの .NET ライブラリ オブジェクトは、時間が経過するにつれて衰退していきます。これは、概念に問題があるためではなく、将来予期しないニーズや多様なニーズに向けて安全に拡張できないだけです。自分が記述したコードに誇りを持ってください。SOLID 原則を当てはめれば、コード有効期間が長くなります。

Brannon B. King は、12 年間常勤のソフトウェア開発者として勤務しており、そのうち 8 年間は C# および .NET Framework に深く携わっていました。最近では、米国ユタ州のローガン近くに拠点を置く Autonomous Solutions Inc. (ASI) と共同で作業に従事しました (asirobots.com、英語)。ASI は、C# への感染力の強い愛情を高めることに長けた独自性のある企業です。ASI の社員は、十分な言語の活用と .NET Framework の限界の超越に対し、情熱を持って取り組んでいます。連絡先は countprimes@gmail.com(英語のみ) です。

この記事のレビューに協力してくれた技術スタッフの Max Barfuss (ASI) と Brian Pepin (マイクロソフト) に心より感謝いたします。
Brian Pepin は、1994 年からマイクロソフトのソフトウェア エンジニアを務めていて、開発者向けの API やツールを中心に従事しています。彼は、Visual Studio の Visual Basic、Java、.NET Framework、Windows Forms、WPF、Silverlight、および Windows 8 XAML デザイナーに携わってきました。現在は、Xbox オペレーティング システムのコンポーネントに取り組んでいる Xbox チームに従事しています。余暇には、シアトルで妻の Danna と息子の Cole と一緒に過ごす時間を楽しんでいます。
Max Barfuss は、優れたコード作成、設計、およびコミュニケーションの習慣が、優れたソフトウェア エンジニアとそうでないエンジニアの境目である、という信念を持って従事しているソフトウェア設計者です。彼は、ソフトウェア開発における 16 年の経験を持っており、そのうち 11 年間は .NET 分野に携わっていました。