SOLIDとは
Robert C Martinが考案したSOLIDは、オブジェクト思考において良いクラスの設計手法をまとめた5つの原理原則です。各原則の頭文字を1文字ずつ取っています。
以下、書籍「Hands-On Software Architecture with Golang」の内容を一部借用してまとめます。
【S】単一責任の原則:Single Responsibility Principle
1つのクラスは1つの責任を持つべきで、1つしか持ってはいけない。
この原則は当たり前のようだが、要求が複雑化する中でこの原則に忠実であり続けることは難しい。
例えば、旅行予約のウェブサイトを作ろうとした時を考えてみる。
最初は航空機の予約のみを取り扱う予定だったが、ホテルの予約やバスチケットも追加で取り扱うことになった。
この時の重要なポイントは、コードを正しい状態に保ったままコードを修正することである。
Reservation
という基底クラスと、AirlineTicket
、BusTicket
のような派生クラスを作成して、階層構造を組み立ててみる。
以下のReservation
のみがクライアントコードの関心事となることで、航空機やバスのような予約の種類を気にする必要がなくなる。
type Reservation interface {
GetReservationDate() string
CalculateCancellationFee() float64
Cancel()
GetCustomerDetails() []Customer
GetSellerDetails() Seller
}
この原則はクラス設計だけでなく、パッケージ構成にも応用できる。
例えばutils
というパッケージ名の場合、雑多な機能の溜まり場となってしまう恐れがあるため、このような名前は避けるべきである。
Goの標準ライブラリでパッケージ名の良い例として、以下のものがある。
-
net/http
: HTTPクライアントとサーバを提供する -
encoding/json
: JSONのシリアライズ・デシリアライズを実装している
【O】Open/Closed Principle:オープン・クローズドの原則
コードを修正することなく、振る舞いを変更できるようにするべきである。
クラスは拡張には寛容で、修正には慎重になるよう設計されるべきだ。
振る舞いの変化は、メソッドのオーバライドや設定値の埋め込みによって行われることが望ましい。
このデザインを最も体現したWebフレームワークの例にSpring Framework
がある。(Java/Kotlinで現在もメジャーに使用されている。)
先ほどの旅行に関するWebサイト例として、新たに2つの要件を考えてみる。
-
Trip
オブジェクトの中に航空機とバスの予約を束ねたい - キャンセル料金を計算できるようにしたい
Trip
構造体を作成してreservation
をコレクションとして保持するようにし、各reservation
型にキャンセル料金の計算機能を持つようにする。reservation
のようにコレクションを保有する形をリポジトリと呼ぶ。
type Trip struct {
reservations []Reservation
}
func (t *Trip) CalculateCancellationFee() float64 {
total := 0.0
for _, r := range(t.reservations) {
total += r.CalculateCancellationFee()
}
return total
}
func (t *Trip) AddReservation(r Reservation) {
t.reservations = append(t.reservations, r)
}
将来、新たな予約の種類を追加する必要が生じた場合は、Reservation
インタフェースのCalculateCancellationFee()
を新たなクラスが実装することで、CalculateCancellationFee()
はキャンセル料金を計算するようになる。
【L】Liskov Substitution Principle:リスコフの置換原則
派生型は、基底型を代替可能としなければならない。
派生クラスは基底クラスであるインタフェースによって利用されるべきで、クライアントは派生クラスを知る必要がない。
Go言語の場合、オブジェクト指向は階層構造よりも組成(プロトタイプパターン)が有効である。
そのため、他のプログラミング言語ほどこの原則が強く意識されてはいないものの、この原則はインタフェース設計のガイドラインとして、インタフェースはインタフェースを実装する構造体を不足なく用意する必要があるということを示してくれる。
この原則に則ったアプリケーションの良い例として、前述のキャンセル料金の計算である。次に記載するように、関心ごとが綺麗に分けられている。
- クライアントは予約の種類が何であるかを気にする必要がない。
-
Trip
のコードでは、種類ごとにどのような計算を行っているか知らない。
もし仮に、クライアントが派生クラスの型を確認しようとすると、抽象レベルが下がってしまう。この原則があることによって、型を意識せずにコードを記述できないかを考えさせてくれる。
【I】Interface Segregation Principle:インタフェース分離の原則
クライアントが限定された複数のインタフェースは、汎用的な1つのインタフェースよりも優れている。
コードを書き進めるにつれ、あらゆる機能を持つ基底クラスが出来上がってしまう。しかし、これは派生クラスが必要のないメソッドを基底クラスに従い実装しなければならないことを意味しており、コード全体を脆くしてしまう。また、クライアント側も派生クラスの変わりやすい仕様に困惑せざるを得ない。これらを避けるために、この原則ではクライアントごとにインタフェースを作成することを推奨している。
前述の予約サービスを例に出すと、この原則を意識しない場合、ファットなReservation
クラスが完成する。
追加の荷物を運ぶ機能である航空機用メソッドAddExtraLuggageAllowance()
や、部屋の種類を変更するホテル用のメソッドChangeType()
などを基底クラスに追加しなければならない。
最も良い設計は、各種類共通の機能のみがReservation
クラスに存在し、航空機やバス、ホテルごとにそれぞれ専用のインタフェースを作成することである。
type Reservation interface {
GetReservationDate() string
CalculateCancellationFee() float64
}
type HotelReservation interface {
Reservation
ChangeType()
}
type FlightReservation interface {
Reservation
AddExtraLuggageAllowance(peices int)
}
type HotelReservationImpl struct{
reservationDate string
}
func (r HotelReservationImpl) GetReservationDate() string {
return r.reservationDate
}
func (r HotelReservationImpl) CalculateCancellationFee() float64 {
return 1.0 // ここでは一定額としている
}
type FlightReservationImpl struct{
reservationDate string
luggageAllowed int
}
func (r FlightReservationImpl) AddExtraLuggageAllowance(peices int) {
r.luggageAllowed = peices
}
func (r FlightReservationImpl) CalculateCancellationFee() float64 {
return 2.0 // ここでは一定額としている
}
func (r FlightReservationImpl) GetReservationDate() string {
return r.reservationDate
}
【D】Dependency Inversion Principle:依存関係逆転の原則
抽象に対して依存するべきであり、具象に対して依存してはならない。
高レベルのモジュールは、インタフェースのみを依存関係とするべきで、具体的な実装を依存関係に含めてはいけない。
Go言語においては、次の2つに解釈できる。
- 全てのパッケージはインタフェースを持つべきで、具体的な細部を知らなくても機能概要を把握できるようにすべきである。
- パッケージに依存関係が必要となった場合、パラメータとして依存関係を受け入れるべきである。
2つのポイントを解説するために、旅行のウェブサイトを2つのパッケージ(レイヤ)として構築する。
- サービスレイヤ: このレイヤーでは、検索や並び替えのなどのビジネスロジックを複数持つ。
-
コミュニケーションレイヤ: 異なる送り手からデータを受け取る責任のみを持つ。送り手はそれぞれ独自のAPIを持ち、例えば
SellerCommunication
インタフェースをそれぞれ異なる形で実装する。
本原則に則り、コミュニケーションレイヤのインスタンスはサービスレイヤにインジェクトされるべきである。コミュニケーションレイヤの具体的実装を埋め込むことは、ドライバのメイン関数によって実行される。これにより、サービスレイヤはSellerCommunication
インタフェース上の関数のみを知っていて、特定の実装に依存することはない。
テストで使用されるモックはこの原則を仕様した実用例の1つである。
最後に
SOLID原則といえば、
S:クラスは1つのみ責務を持つよう分割し、
O:コードを修正せずに拡張できるよう構成し、
L:基底クラスは抽象クラスで代替できるよう設計し、
I:インタフェースは1つにまとめずクライアントごとに分割し、
D:依存関係は実装ではなくインタフェースに限定することである。
という理解でOKです。
大きなシステムや一般的なフレームワークではよく見る構成だと思いますので、あまり難しく考える必要はありません。
設計時に迷ったらSOLIDの原則に立ち返ってみてはいかがでしょうか。