はじめに
SOLID原則とは
SOLID原則というものをご存知でしょうか?
これはオブジェクト指向プログラミングをする上での原則を5つにまとめたものになります。
オブジェクト指向っていったい何者??概念が難しくてよくわからないと思っている方も少なくないのではないでしょうか?これらの原則を理解することでオブジェクト指向でプログラミングするとはどういうことなのかという理解が深まってくるはずです。
SOLID原則の概要
-
S:単一責任の原則
- クラスの責務が1つになっているか?
-
O:オープン・クローズドの原則
- クラスを修正する際に変更に対しては閉じていて、拡張に開いているか?
-
L:リスコフの置換原則
子クラスは親クラスの機能を全て使用しているか?- 基底クラスの振る舞いは派生クラスの振る舞いと交換可能かどうか?
-
I:インターフェース分離の原則
- インターフェースに必要以上の機能を実装していないか?
-
D:依存関係逆転の原則
- 具象への依存を抽象への依存へと関係性を逆転させているか?
これらを一個ずつ詳細に考えていきます。
例としてメールを送信するサービスをあげてます。
単一責任の原則(SRP)
「クラスの責務が1つ」とはどう考えるべきか
- クラスを変更する理由が一つであるか?
- そのクラスは凝集度が高いかどうか?
- そのクラスのやっていることはさらに細かい粒度で分割できないか?
などを考えていくと良いと思います。
【参考】DRY原則(Don't Repeat Yourself)に関して
こちらの原則はコードの重複を避けようという原則です。同じロジックや情報を複数の場所に重複させるのではなく、共通の場所にまとめて管理することを意味しています。
注意したい点はコードの重複を避けることだけを考えて共通化するのは危険ということです。
(詳しくはこちらの動画へ)
なので1つの目的を達成するために1クラスということを念頭に置いておくといいかもしれません。他のクラスの事情が共通モジュールに流れ込んできた時点でその共通モジュールは2つの責務を負うわけなので、モジュールの切り出し方を再検討する必要があります。
単一責任の原則に則ったクラスのメリット
- 修正箇所を最小限にできる
- 修正箇所がすぐに分かる
例:メール送信クラスの責務の考え方
以下のようなメール送信クラスがあったとします
- メール送信クラス
- 社内メール送信機能
- 社外メール送信機能
- メール送信というサービスの捉え方が少し大きい(サービスの粒度をどう捉えるべきかはケースバイケースですが)
- 社外メール、社内メールで両方修正が必要になってしまったら、クラスを変更する理由が1つではなくなってしまいます
-
このようなクラス(一般的に神クラスと揶揄される)は他の開発者との競合が発生しやすく、一度の修正で他への影響範囲が広くなりがちです
※神クラス:様々な機能を1つのクラスで持っていること。実際の現場のコードではあるクラスが複数機能を担いすぎていて修正が困難なケースがあります。
この場合は
- 社内メール送信クラス
- 社外メール送信クラス
とクラスを分割して実装すべきです。
【参考】閉鎖性共通の原則(CCP)
単一責任の原則のコンポーネント(パッケージ)バージョンの原則ととらえておいてください。
オープンクローズドの原則(SRP)
「クラスを修正する際に変更に対しては閉じていて、拡張に開いているか」をどう考えるか
ある機能Aに対して、パターンABCがありえると行った場合を考えます。
パターンABCは1つのクラスに実装せず、ABCの呼び出しの切り替えができるような実装にしておくのが良いということです。つまり、処理の呼び出し側の処理を変更せずに、機能を拡張できるか?ということです。
先ほどの例で言えば、メール送信クラスはメールを送信するということに関しては共通しています。
抽象に対してプログラミングをしておくことで、具象クラスの生成を切り替えるだけで機能のパターンを実現できます。
つまり、機能パターンの実現は抽象クラスまたはインターフェースを使用してあげればいいのです。
// メール送信に関する抽象クラス
public abstract class MailSender
{
public abstract void Execute();
}
public class OutsideMailSender : MailSender
{
public override void Execute()
{
// 社外メール送信ロジック
}
}
public class InsideMailSender : MailSender
{
public override void Execute()
{
// 社内メール送信ロジック
}
}
// メール送信サービスを使用する側
public class MailSendClient
{
private readonly MailSender _sender
//コンストラクタ生成時にメール送信の具象クラスを確定させる
public MailSendClient(MailSender sender)
{
_sender = sender
}
public void Handle()
{
_sender.Execute();
}
}
※そもそも社内メール送信も社外メール送信も同じように抽象化すべきかと考えたら、それは別問題だと考えてます。今回は原則からみてメール送信の呼び出し処理自体は変えずに、どちらかの処理に飛ばせるような実装にしておくことが拡張しやすいシステム設計だということを主張したいです。
これはコンポーネントにも同様のことが当てはまります。下位のコンポーネントの変更に、上位のコンポーネントは影響を受けないように設計すべきと言えます。
リスコフの置換原則
子クラスは親クラスの機能を全て使用しているかをどう考えるか
基底クラスの振る舞いは派生クラスの振る舞いと交換可能かどうか?
リスコフの置換原則
・正しい継承とは何かのガイドラインを示すもの
・継承ではなく持つ(コンポジション)で解決できないか検討する
リスコフの置換原則に関しては、書籍からいくつか引用させていただきます。
ちょうぜつソフトウェア設計入門――PHPで理解するオブジェクト指向の活用より引用。
リスコフの置換原則(LSP)は、正しい継承とは何かを述べた原則です。(中略)文法上は正しいのに、実際には不具合のある誤った継承のパターンがあります。(中略)
基底クラスにない事前条件(入力値の制限など)を加えてはいけない以外に、基底クラスが持っていた事後条件(出力結果など)の範囲を逸脱するのもNGです。
Clean Architecture 達人に学ぶソフトウェアの構造と設計より引用。
オブジェクト指向の黎明期には、リスコフの置換原則(LSP)は(先ほどのセクションで紹介したような)継承の使い方の指針になるようなものと考えられていた。だが、時間をかけてその適用範囲は広がり、今ではインターフェイスと実装に関するソフトウェア設計の原則になっている。
派生クラスは基底クラスの代わりとして使用できるように、派生クラスは基底クラスと同じインターフェースを提供します。派生クラスにはインターフェースに定義されていないものを実装してしまうと原則違反になってしまいます。
基底クラスの想定する振る舞いを派生クラスでも同様に振る舞うように実装しないと原則違反になってしまいます。
修正前(読まなくてOKです)
極端な例ですが、先ほどの社外メール送信サービスにだけログ機能を実装しなければならないとします。
public class OutsideMailSender : MailSender
{
public override void Execute()
{
// 社外メール送信ロジック
}
public void WriteLog()
{
// ログ書き込み
}
}
public class InsideMailSender : MailSender
{
public override void Execute()
{
// 社内メール送信ロジック
}
}
// メール送信サービスを使用する側
public class MailSendClient
{
private readonly MailSender _sender
public MailSendClient(MailSender sender)
{
_sender = sender
}
public void Handle()
{
_sender.Execute();
//抽象に対してコーディングしたのに、クライアント側に知識が漏れ出している
if(_sender.GetType() == typeof(OutsideMailSender))
{
_sender.WriteLog()
}
}
}
public abstract class MailSender
{
public MailSender(string to, string from, string title, string body)
{
To = to;
From = to;
Title = to;
Body = to;
}
protected string To { get; }
protected string From { get; }
protected string Title { get; }
protected string Body { get; }
public abstract void Execute();
}
public class OutsideMailSender : MailSender
{
public OutsideMailSender(string to, string from, string title, string body)
: base(to, from, title, body)
{
}
public override void Execute()
{
// 社外メール送信ロジック(どんな状況でも成功すると仮定)
}
}
public class InsideMailSender : MailSender
{
public InsideMailSender(string to, string from, string title, string body)
: base(to, from, title, body)
{
}
public override void Execute()
{
// 基底クラスからは想定されていない振る舞いが派生クラスに定義された
if (this.Body.Length >= 10000)
{
throw new Exception("メール本文の長さがオーバーしてます");
}
}
}
// メール送信サービスを使用する側
public class MailSendClient
{
private readonly MailSender _sender;
public MailSendClient(MailSender sender)
{
_sender = sender;
}
public void Handle()
{
// 社内メール送信クラスを使用し、かつ本文長さがオーバーしていた場合、想定外エラーとなる
_sender.Execute();
}
}
派生クラスによるオーバーライドは、基底クラスのつもりで使うクライアントコードに対して、基底クラスト同じ使い方を完全保証しなければなりません。
使用する側からしたらなにしたって成功するような実装に見えてますが、派生クラス内の事情によって基底クラスで定義されている振る舞いが保証されていない例です。実はこのリクエスト投げる時は、こういう値渡しちゃうとエラーになるんだよね~~なんていうのは避けたい状況ですよね。。。
インターフェース分離の原則
インターフェースに必要以上の機能を実装していないか?
- クライアントから必要な機能だけが見えているか?
- 必要以上にインターフェースに色々な機能を定義していないか?
このあたりを考えると良いと思います。
じゃあどこまで分割して考えればいいんだ!!となるはずです。
表現したいものに関して、適切な大きさで機能を提供するインターフェースの設計を目指すのがベターな方法ではないでしょうか。
※ちなみに必要以上に機能をもってしまっているインターフェースはfatインターフェースと呼ばれます。
//インターフェースの粒度が大きくなってしまっている例
public interface IMailSender
{
void Execute();
void WriteLog();
void NoticeError();
}
public class OutsideMailSender : IMailSender
{
public void Execute()
{
// 社外メール送信ロジック
}
public void NoticeError()
{
// 必要のないクラスでも実装を強制させられている
throw new NotImplementedException();
}
public void WriteLog()
{
// ログ書き込み
}
}
public class InsideMailSender : IMailSender
{
public void Execute()
{
// 社内メール送信ロジック
}
public void NoticeError()
{
// エラー通知
}
public void WriteLog()
{
// 必要のないクラスでも実装を強制させられている
throw new NotImplementedException();
}
}
先ほどの例を改善し、インターフェースを分離させ、クライアント側から必要とされている機能のみを提供するように変更します。
// 機能ごとにインターフェースを分離
public interface IMailSender
{
void Execute();
}
public interface IMailWriteLog
{
void WriteLog();
}
public interface IMailNoticeError
{
void NoticeError();
}
// 必要な機能を提供するインターフェースのみを定義し、実装を強制
public class OutsideMailSender : IMailSender, IMailWriteLog
{
public void Execute()
{
// 社外メール送信ロジック
}
public void WriteLog()
{
// ログ書き込み
}
}
public class InsideMailSender : IMailSender, INoticeError
{
public void Execute()
{
// 社内メール送信ロジック
}
public void NoticeError()
{
// エラー通知
}
}
これがインターフェース分離の原則です。
依存関係逆転の原則
具象への依存を抽象への依存へと関係性を逆転させているか?
まず具象クラスに依存すると何がいけないのかというと
- モジュール間の結合度が高くなる
- 拡張性が失われる
ということです。
メール送信サービスをテストしたい場合はどうなるでしょうか??
public class OutsideMailSender
{
public void Execute()
{
// 社外メール送信ロジック
}
public void WriteLog()
{
// ログ書き込み
}
}
public class OutsideMailSenderMock
{
public void Execute()
{
// 社外メール送信ロジック
}
public void WriteLog()
{
// ログ書き込み
}
}
public class MailSendClient
{
public void Handle()
{
// 本番用
var sender = new OutsideMailSender();
sender.Execute();
sender.WriteLog();
// テスト用
// var sender = new OutsideMailSenderMock();
// sender.Execute();
// sender.WriteLog();
}
}
このようにクライアント側のコードに影響が出てしまっています。
他にはFTPやDBやAPIなどの外部環境への接続などテスト用のMockと差し替えたい処理が出てきたときは、具象への依存を抽象への依存に変更してあげます。
public interface IMailSender
{
void Execute();
void NoticeError();
}
public class OutsideMailSender : IMailSender
{
public void Execute()
{
// 社外メール送信ロジック
}
public void WriteLog()
{
// ログ書き込み
}
}
public class OutsideMailSenderMock : IMailSender
{
public void Execute()
{
// 社外メール送信ロジック
}
public void WriteLog()
{
// ログ書き込み
}
}
public class MailSendClient
{
private readonly IMailSender _sender;
public MailSendClient(IMailSender sender)
{
// MailSendClientのインスタンス生成時に本番用かテスト用のインスタンスを渡す
_sender = sender;
}
public void Handle()
{
// 渡されたインスタンスによって処理が切り替わる
_sender.Execute();
_sender.WriteLog();
}
}
これでテストも可能な実装になりました。
参考
書籍
Clean Architecture 達人に学ぶソフトウェアの構造と設計
ちょうぜつソフトウェア設計入門――PHPで理解するオブジェクト指向の活用
講義
オブジェクト指向の原則1:単一責務の原則とオープンクローズドの原則
オブジェクト指向の原則2:リスコフの置換原則と継承以外の解決方法
オブジェクト指向の原則3:依存関係逆転の原則とインタフェース分離の原則
以上、最後まで見ていただきありがとうございました!