みなさんこんにちは。都内のIT企業でiOSアプリを開発している @zrn-ns です。
プログラミングのメジャーな原則に「SOLID」という原則があります。
これは、かのボブおじさんによって提唱された原則の集まりで、メンテナンス性の高いコードを生み出すための5つの原則の頭文字を取ったものです。
僕がプログラミングを学び始めた頃から「SOLID原則」という名前自体は知ってはいたのですが、当時は保守性など気にしているほどの余裕はなかったので、字面だけ見てなんとなく理解したつもりでいたのですが、そろそろしっかり理解すべきかなと思い記事を書きました。
正直まだふわふわしていて、間違っている箇所があるかもしれません。
もし間違いに気づいたら、そっと教えて下さい。
SOLID原則の目的
オブジェクト指向プログラミングの分野において、SOLID(ソリッド)とは、ソフトウェア設計の5つの原則を記憶するための頭字語である。これらの原則は、ソフトウェアをより理解しやすく、より柔軟に、よりメンテナナンス性の高いものにするために考案されたものである。
https://ja.wikipedia.org/wiki/SOLID
SOLID原則はRobert C. Martin発案の、よりメンテナンス性の高いコードを書くための5つの原則(下記)の頭文字をとったものです。
- S: 単一責任の原則(Single responsibility principle)
- O: 開放閉鎖の原則(Open–closed principle)
- L: リスコフの置換原則(Liskov substitution principle)
- I: インタフェース分離の原則(Interface segregation principle)
- D: 依存性逆転の原則(Dependency inversion principle)
一言で言い表すと、オブジェクト指向的なプログラミングをするとき、どのようにしてモジュール同士を疎結合に保ちつつ、機能の拡張や修正をしやすい状態を保つか、ということが語られています。
このあとのセクションではそれぞれの原則について、具体例を上げつつ掘り下げていきます。
1. (S)単一責任の原則
単一責任の原則とは、「クラスを変更する理由は1つ以上存在してはならない」、言い換えると「クラスに変更が起こる理由は1つであるべき」という原則です。
クラスが複数の責任を持つということは、それだけクラスを変更される理由が増えるということです。
あるクラスがA, Bという2つの責任を持っていたとき、Aの責任をきっかけとしてクラスが修正された場合、Bの責任についてもこれまで通り満たせていることを確認する必要がでてきます。
各クラスを変更する理由を一つにする(責任を一つにする)ことで、プログラムの修正(&テスト)範囲を最小限に留める事ができるようになります。
具体例
例を示します。
店舗の従業員を管理するためのアプリに、下記のような Staff
クラス(正確には構造体ですが、便宜上クラスと表記します)が存在するとします。
// SRPに違反したコード
struct Staff: Codable {
let id: Int
var name: String
var introduction: String
static func find(byId id: Int) -> Staff? {
// ユーザをDBから検索する処理(省略)
}
func save() {
// ユーザをDBに保存する処理(省略)
}
}
このクラスは既に、複数の役割を持っています。
- ユーザの属性を保持する役割
- ユーザをDBに保存する役割(find, saveメソッド)
- ユーザの情報をDBに保存するためにエンコード/デコードする役割(Codable)
あるとき、このアプリはサーバと連携する機能を実装することになり、 Staff
クラスのCodable実装をそのまま使って、APIから取得したJSON形式の従業員情報のパース処理を行うことになったとします。
let response = APIClient.sendRequest(GetUserRequest())
let parsedStoreStaff = JSONDecoder().decode([User].self, from: jsonData)
これで、 Staff
クラスの役割は4つになりました。
- ユーザの属性を保持する役割
- ユーザをDBに保存する役割(find, saveメソッド)
- ユーザの情報をDBに保存するためにエンコード/デコードする役割
- APIから取得したユーザデータをモデルにマッピングする役割 ← new!!
このクラスは現在かなり不安定な状態です。
各プロパティ(id, name, introduction)はただデータを保持するだけの役割に加えて、DBからデータを取り出すときのマッパーとしての役割、さらにAPIから渡されたデータをパースするためのマッパーとしての役割も担っています。
4つの役割のうちのいずれかがきっかけでこのクラスを変更した場合、他の役割に影響を及ぼす可能性が高いですし、このクラスは多くの機能に依存されることになるため、他のクラスに連鎖的に影響を与える可能性もあります。
この過剰に役割を持った状態を解消するためには、 Staff
クラスの役割を別にクラスに逃がす必要があります。
// SRPに準拠したコード
/// 1. ユーザの情報をアプリ内で保持する役割
struct Staff {
let id: Int
var name: String
var introduction: String
}
/// 2. ユーザをDBに保存する役割(find, saveメソッド)
struct StaffStore {
static func find(byId id: Int) -> StaffStorageObject? {
// ユーザをDBから検索する処理(省略)
}
static func save(staff: StaffStorageObject) {
// ユーザをDBに保存する処理(省略)
}
}
/// 3. APIから取得したユーザデータをパースする役割
struct StaffResponse: Decodable {
let id: Int
var name: String
var introduction: String
}
/// 4. ユーザの情報をDBに保存するためにエンコード/デコードする役割
struct StaffStorageObject: Codable {
let id: Int
var name: String
var introduction: String
}
※このコードでは、各モデルの間の変換処理が省略されています。
コード量は増えてしまったので冗長に見えますが、それぞれが別の責任を担っています。
例えば、APIから返される従業員情報のプロパティが変化したり、使っているDBの種類が変更になった場合でも、責任ごとにクラスを分離することで、保守する際に変更するコード量を減らし、意図せずバグを埋め込むリスクを減らすことができます。
2. (O)開放/閉鎖の原則
クラス(およびその他のプログラム単位)は
- 拡張に対して開いて (open) いなければならず、
- 修正に対して閉じて (closed) いなければならない
という設計上の原則である。開放/閉鎖原則に従ったソフトウェアは、既存のソースコードを変更することなく、振る舞いを変更することができる。
「修正に対して閉じている」、「拡張に対して開いている」というのはどういう状態なのでしょうか。
「修正に対して閉じている」とは、要件の変化や不具合によりモジュール(特定のプログラム単位)を修正する際に、そのモジュールだけ修正すればよく、他のモジュールにに手を入れる必要がないということです。
「拡張に対して開いている」とは、プログラムに新たな機能を追加したい(拡張したい)場合に、既存のプログラムに手を入れずに、新たなモジュールを追加するだけで機能を追加できるということです。
なぜ必要?
修正に対して閉じていることで、他のモジュールに影響を与えずに修正を行う事ができます。
また拡張に対して開いていることで、拡張が必要になった際にも、既存のコードやテストを修正することなく、新たなコードを追加する事ができます。
開放閉鎖の原則に則っていないプログラムは、拡張の際に既存のモジュールの修正が必要になるため、開発やテストの工数が増えたり、バグを生み出すきっかけになったりします。
具体例
下記のような、動物に餌やりをするFeederクラスがあったとします。
/// OCPに違反したコード
struct Feeder {
static let shared: Feeder = .init()
/// 餌やりをする
func feed(to animal: Animal) {
if animal is Cat {
print("Fed 🐟!")
} else if animal is Snake {
print("Fed 🐛!")
} else {
fatalError("No appropriate feeds😭")
}
}
private init() {}
}
protocol Animal {}
struct Cat: Animal {}
struct Snake: Animal {}
Feeder.shared.feed(to: Cat()) // Fed 🐟!
Feeder.shared.feed(to: Snake()) // Fed 🐛!
feed(to:)
メソッドに動物(Animal)を渡すと、餌を与える事ができるというプログラムです。
現状FeederはOCPに則っていません。なぜなら、新たにAnimal protocolを実装したDogクラスを追加する場合、Feeder.feed(to:)
メソッドを修正する必要がある(拡張に対して開いていない)為です。
OCPに則り拡張に開いた状態にするには、Feeder.feed(to:)
内の型の分岐処理を消す必要があります。
/// OCPに準拠したコード
struct Feeder {
static let shared: Feeder = .init()
func feed(to animal: Animal) {
print("Fed \(animal.feed)!")
}
private init() {}
}
protocol Animal {
var feed: String { get }
}
struct Cat: Animal {
let feed: String = "🐟"
}
struct Snake: Animal {
let feed: String = "🐛"
}
// 新たにDogを追加しても、Feederを変更する必要はない
struct Dog: Animal {
let feed: String = "🍖"
}
Feeder.shared.feed(to: Cat()) // Fed 🐟!
Feeder.shared.feed(to: Snake())// Fed 🐛!
Feeder.shared.feed(to: Dog()) // Fed 🍖!
餌の情報をFeeder.feed(to:)
メソッドから切り出し各クラスに分離することで、Feederの変更をせずにクラスを拡張できるようになり、FeederはOCPに則った状態になりました。
3. (L)リスコフの置換原則
S が T の派生型であれば、プログラム内で T 型のオブジェクトが使われている箇所は全て S 型のオブジェクトで置換可能であれ、ということで、この原則が損なわれなければ、プログラムの型システムに照らした妥当性は損なわれない、ということである。
継承したクラスで継承元のクラスを置換可能であれ、という原則です。
リスコフの置換原則に違反した場合、親クラスを使う際に子クラスについて意識しないといけなくなります。
またそのようなコードは開放閉鎖の原則にも違反することになります。
具体例
下記のような、動物の餌やりをするプログラムがあったとします。
// LSPに違反したコード
protocol Animal {
var name: String { get }
func rubbed()
}
class Cat: Animal {
let name = "ニャンちゅう"
func rubbed() {
print("ニャー!")
}
}
class Dog: Animal {
let name = "ワンダー"
func rubbed() {
print("ワン!")
}
}
class Ant: Animal {
let name = "アリス"
func rubbed() {
fatalError("撫でないで!")
}
}
class PetOwner {
static let shared = PetOwner()
// 動物を紹介する
func introduction(_ animal: Animal) {
print("\(animal.name) is so cute!")
}
/// 撫でる
func rub(_ animal: Animal) {
// 具象クラスの内容を知った上で実装する必要がある。
// Ant以外にも撫でられない動物が追加されたとき、忘れずに分岐を追加できるか?
if !(animal is Ant) {
animal.rubbed()
}
}
}
[Animal](arrayLiteral: Cat(), Dog(), Ant()).forEach {
PetOwner.shared.introduction($0)
PetOwner.shared.rub($0)
}
動物を表すAnimalインタフェースと、それを実装した(派生型である)Dog🐕, Cat🐈, Ant🐜型があります。
Animalインタフェースには撫でられたときの振る舞い(rubbed関数)が定義されていますが、Ant型においてはrubbedメソッドが想定どおり実装されておらず(fatalErrorで落としている)、Animal型の代わりにAnt型を使えなくなっているので、LSPに違反しています。
LSPに違反していることで、rubbedメソッドを使う側(PetOwnerのrub関数)ではrubbed関数を呼ぶ前に具体的なクラスの判定が必要になっており、Animal継承型の詳細を知らないと実装ができない状態になっています。
これをLSPに準拠した実装に変えてみます。
/// LSPが守られたコード
protocol Animal {
var name: String { get }
}
protocol RubbableAnimal: Animal {
func rubbed()
}
class Cat: RubbableAnimal {
let name = "ニャンちゅう"
func rubbed() {
print("ニャー!")
}
}
class Dog: RubbableAnimal {
let name = "ワンダー"
func rubbed() {
print("ワン!")
}
}
class Ant: Animal {
let name = "アリス"
func rubbed() {
fatalError("撫でないで!")
}
}
class PetOwner {
static let shared = PetOwner()
// 動物を紹介する
func introduction(_ animal: Animal) {
print("\(animal.name) is so cute!")
}
/// 撫でる
/// 型をRubbableAnimalで縛る
func rub(_ animal: RubbableAnimal) {
// 具体的な型での判定が不要になる
animal.rubbed()
}
}
[Animal](arrayLiteral: Cat(), Dog(), Ant()).forEach {
PetOwner.shared.introduction($0)
if let rubbableAnimal = $0 as? RubbableAnimal {
PetOwner.shared.rub(rubbableAnimal)
}
}
LSP違反が発生していたそもそもの原因は、Animalインタフェースにrubbed関数が定義されていたためです。今回の実装において、必ずしもAnimalを撫でることができないのであれば、Animalにrubbedを定義するべきではありませんでした。
そこで、Animalから撫でる事ができるという性質だけを専用のインタフェース(RubbableAnimal)として切り出し、撫でる事ができる動物はRubbableAnimalに準拠するようにしました。
これにより、PetOwnerのrub関数ではRubbableAnimalインタフェースでアクセスすることができるようになり、具体的な型での判定が不要になりました。
4. (I)インタフェース分離の原則
「インターフェース分離の原則」は、「クライアントに対し、利用しないインターフェースへの依存を強制しないべき」という原則です。
【Clean Architecture】SOLID原則 – インターフェース分離の原則 | 全国個人事業主支援協会 より
「インターフェース分離の原則」という名前を聞くたび、「インタフェースを実装と分離すべき(≒実装に対してプログラミングするのではなくインタフェースに対してプログラミングしろ)」という原則なのかな、と思うのですが、これは間違いで、実際には「インタフェースではクライアントに対して必要がない機能は提供すべきでない」という意味の原則です。
ここでいうクライアントとは、インタフェースを使うインタフェースの実装者、およびインタフェースの利用者の両方を指すと考えています。
インタフェース分離の原則(ISP)原則に違反すると、インタフェースの実装者および呼び出す側において下記のような問題が発生します。
- インタフェースの実装者: 本来不要なメソッドを実装しなければならなくなる
- インタフェースの利用者: 本来不要な引数を渡す事になったり、使わない機能に依存することになる
具体例
具体例を示します。
下記のような、乗り物を運転するプログラムがあります。
protocol Vehicle {
/// エンジン始動
func startEngine()
/// 走る
func run()
}
class Car: Vehicle {
func startEngine() {
print("PURR!!")
}
func run() {
print("BRRR!!")
}
}
class Driver {
func drive(vehicle: Vehicle) {
vehicle.startEngine()
vehicle.run()
}
}
乗り物を示すVehicleインタフェース(protocol)と、それを実装した自動車(Car)クラス、それを利用する運転手(Driver)クラスが定義されています。
このプログラムを拡張して飛行機の運転もできるようにしたくなったとします。
// ISPに違反したコード
protocol Vehicle {
/// エンジン始動
func startEngine()
/// 走る
func run()
/// 追加した 飛ぶ 関数
func fly()
}
class Car: Vehicle {
func startEngine() {
print("PURR!!")
}
func run() {
print("BRRR!!")
}
func fly() {
preconditionFailure("車は飛びません!")
}
}
class Airplane: Vehicle {
func startEngine() {
print("PURR!!")
}
func run() {
print("WHIR!!")
}
func fly() {
print("ZOOM!!")
}
}
class Driver {
func drive(vehicle: Vehicle) {
vehicle.startEngine()
vehicle.run()
// 車によって飛べるものと飛べないものがある
if let plane = vehicle as? Airplane {
plane.fly()
}
}
}
Vehicleインタフェースに新たにfly関数を追加し、Vehicleインタフェースを実装したAirplaneクラスを実装しました。
しかし、既存の自動車(Car)クラスは飛ぶことができないので、Carクラスではこのメソッドを使えないようにしました。
この実装はISPに違反しています。インタフェースを実装する側(Car, Airplane)では不要なメソッドの実装を強要されており、インタフェースを利用する側(Driver)としては具象クラスを意識した実装をせざるを得ない状態になっています。
ISPに準拠させるために、インタフェースを適切に分離してみます。
// ISPに準拠したコード
protocol Vehicle {
/// エンジン始動
func startEngine()
/// 走る
func run()
}
/// 航空機プロトコルを新たに作成し、fly()はこちらに定義
protocol Aircraft: Vehicle {
/// 飛ぶ
func fly()
}
class Car: Vehicle {
func startEngine() {
print("PURR!!")
}
func run() {
print("BRRR!!")
}
}
class Airplane: Aircraft {
func startEngine() {
print("PURR!!")
}
func run() {
print("WHIR!!")
}
func fly() {
print("ZOOM!!")
}
}
class Driver {
func drive(vehicle: Vehicle) {
vehicle.startEngine()
vehicle.run()
}
}
class Pilot {
func drive(airplane: Airplane) {
airplane.startEngine()
airplane.run()
airplane.fly()
}
}
車両(Vehicle)と 航空機(Aircraft)のインタフェースを分離したことで、ISPが守られた状態になりました。
インタフェースを実装する側(Car, Airplane)では不要なメソッドの実装をする必要はなくなり、インタフェースを利用する側(Driver, Pilot)では具象型の判定が不要となりました。
5. (D)依存性逆転の原則
オブジェクト指向設計において、依存性逆転の原則、または依存関係逆転の原則[1]とはソフトウエアモジュールを疎結合に保つための特定の形式を指す用語。
この原則に従うとソフトウェアの振る舞いを定義する上位レベルのモジュールから下位レベルモジュールへの従来の依存関係は逆転し、結果として下位レベルモジュールの実装の詳細から上位レベルモジュールを独立に保つことができるようになる。
この原則で述べられていることは以下の2つである:
- A. 上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。両方とも抽象(abstractions)に依存すべきである。
- B. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。
上位モジュールとは、下位のモジュールを呼び出す立場のモジュールです。
通常、深く考えずにオブジェクト指向でプログラムを実装した場合、プログラムの依存の向きは、呼び出しに従った向きになるかと思います。
しかし保守性の観点では、上位モジュールと下位モジュール間では具象に依存することは避けるべきです。上位/下位モジュール間で具象に依存していると、下位モジュールを変更したときに、上位モジュール側も変更が必要になるためです。
具象に依存せず抽象に依存させるために、依存性の逆転を利用する事ができます。
具体例
具体例を示します。
下記のような、ユーザとカメラ、写真データを保存する内蔵ストレージであるHDDクラスを考えます。
struct User {
func useCamera() {
let camera: Camera = .init(storage: HDD())
camera.takePhoto()
}
}
↓依存↓
struct Camera {
let storage: HDD
func takePhoto() {
let image: UIImage = capture()
storage.save(image: image)
}
}
↓依存↓
struct HDD {
func save(image: UIImage) {
// HDDに保存する処理
}
}
CameraがHDDを使用しているので、Cameraが上位モジュール、HDDが下位モジュールです。
これは前項で話した、上位モジュールが下位モジュールの具象に依存した状態です。
もし後々写真データの保存先をHDDからMicroSDへ変更したくなった場合、下記のようにCameraクラス側を変更する必要が出てきます。
struct User {
func useCamera() {
let camera: Camera = .init(storage: MicroSD())
camera.takePhoto()
}
}
↓依存↓
struct Camera {
let storage: MicroSD
func takePhoto() {
let image: UIImage = capture()
storage.save(image: image)
}
}
↓依存↓
struct HDD {
func save(image: UIImage) {
// HDDに保存する処理
}
}
struct MicroSD {
func save(image: UIImage) {
// MicroSDに保存する処理
}
}
この状態だと、上位モジュール(Camera)が下位モジュール(HDD, MicroSD)の詳細に依存してしまっており、下位モジュールを変更するために上位モジュール側の変更を強いられます。
この問題を解消するため、まず保存先の詳細からインタフェースを分離します。
struct User {
func useCamera() {
let hddCamera: Camera = .init(storage: HDD())
hddCamera.takePhoto()
let microSdCamera: Camera = .init(storage: MicroSD())
microSdCamera.takePhoto()
}
}
↓依存↓
struct Camera {
let storage: Storage
func takePhoto() {
let image: UIImage = capture()
storage.save(image: image)
}
}
↓依存↓
protocol Storage {
func save(image: UIImage)
}
struct HDD: Storage {
func save(image: UIImage) {
// HDDに保存する処理
}
}
struct MicroSD: Storage {
func save(image: UIImage) {
// SDカードに保存する処理
}
}
これでとりあえず、Cameraは下位モジュールであるHDD/MicroSDの直接的な依存は解消されました。
しかし現状、まだ上位モジュールは下位モジュール(のインタフェース)に依存してしまっています
。Storageインタフェースは上位モジュール側に移動します。
struct User {
func useCamera() {
let hddCamera: Camera = .init(storage: HDD())
hddCamera.takePhoto()
let microSdCamera: Camera = .init(storage: MicroSD())
microSdCamera.takePhoto()
}
}
↓依存↓
struct Camera {
let storage: Storage
func takePhoto() {
let image: UIImage = capture()
storage.save(image: image)
}
}
// 下位モジュールとのインタフェースは上位モジュール側に定義する
protocol Storage {
func save(image: UIImage)
}
↑依存↑
struct HDD: Storage {
func save(image: UIImage) {
// HDDに保存する処理
}
}
struct MicroSD: Storage {
func save(image: UIImage) {
// SDカードに保存する処理
}
}
これで依存の向きが逆転し、「Camera -> HDD/MicroSD」から「Camera <- HDD/MicroSD」に変化しました。
上位モジュールは下位モジュールに依存せず、下位モジュール側は上位モジュールの抽象に依存しています。
更に言及すると、UserモジュールはCameraモジュールよりも上位のモジュールになりますが、Cameraの詳細に依存してしまっています。本当であれば下位モジュールの詳細に依存しない状態にすべきですが、DIPには限界があり、誰かが汚れ仕事をする必要があります。Userモジュールはすべてのモジュールを把握し、結合する役割を担っています。
総括
ここまで、各原則を解説してきました。
これらの原則は、プログラムの保守性を保つ一つの手法として役に立ちますが、必ずしも最適解とは限りません。
プロダクトの性質やプロジェクトの規模など、様々なパラメータによって、あるべき形は変化します。
脳死でこれらの原則を適用するのではなく、なぜこれらの原則が存在するのかを考え、現在の状況に合ったアーキテクチャを作っていくことが大切だと考えています。
参考にさせていただいたサイト🙏
- 依存性逆転の原則 - Wikipedia
- 依存関係逆転の原則の重要性について. こんにちは!eurekaのAPIチームでエンジニアをやっている@rikiiです。… | by riki(Rikitake) | Eureka Engineering | Medium
- 依存関係逆転の原則(DIP) – 野生のプログラマZ
- 単一責任原則で無責任な多目的クラスを爆殺する - Qiita
- 単一責任の原則(SRP)についての見解と方法論 - FLINTERS Engineer's Blog
- iOS開発の事例に寄せたSOLID原則の解説
- リスコフの置換原則とその違反を実例を踏まえて解説 - Qiita
- 【Clean Architecture】SOLID原則 – インターフェース分離の原則 | 全国個人事業主支援協会