iOS開発の事例に寄せたSOLID原則の解説
本記事はiOSDC2020にて発表した次の記事の補足となっています.
はじめに
はじめまして.突然ですが,皆さんはプログラムが思い通りに動かず気が狂いそうになった経験はありませんか??
...
僕はあります.
なぜ,思い通りにプログラムを書き,思った通りに動作させられないのでしょうか.理由はいくつか在ると思います.中でも大きな理由としては,ソフトウェアを形作るコードはただの文字列であるため抽象度が高い上に,それらが複雑に絡み合っているからだと思います.結果として,(1)ある箇所への変更が別の箇所へ伝搬し思わぬバグを生む,(2)読むことが難しく手を加えられない箇所が発生する,および(3)社内の別プロダクトへ流用したがうまく処理を切り出せない,等が発生します.
しかし,先述したような事象が起こり得ること知っていれば,それを避けるような実装を心がけることができます.その結果,ソフトウェアの保守性を担保することができる可能性があります.言い換えると,ソフトウェアの保守性担保には先述した問題を回避するような戦略が必要なのではないでしょうか.
本記事ではその戦略に用いることのできる設計原則として,参考文献にあるアジャイルソフトウェア開発の奥義を基にSOLID原則[1, 2]を説明します.また,本記事では筆者がiOSエンジニアであることを活かし,iOSにおけるSOLID原則の適応例を紹介します.
SOLID原則
SOLID原則は次のそれぞれの原則の集まりであり,それぞれの原則の頭文字を取って名付けられています.
- S: 単一責任の原則 [1, 2]
- O: オープンクローズドの原則 [1, 2]
- L: リスコフの置換原則 [1, 2]
- I: インタフェース分離の原則 [1, 2]
- D: 依存性逆転の原則 [1, 2]
また,SOLID原則の目的は次の条件を満たす設計をすることです[2].
- 変更に強いこと
- 理解しやすいこと
- 多くのソフトウェアシステムで利用できること
それぞれの項目と前章にて述べた事象は対応付いています.それぞれの項目が満たされれない場合にどのような事象が起こり得るかをイメージしていただけると思います.
- 変更に強いこと <=> (1)あるクラスへの変更が別のクラスへ伝搬しバグを生む
これは,思わぬところであるところの変更が別の所へ影響するような構造になっている場合に起きます. - 理解しやすいこと <=> (2)読むことが難しく手を加えられない箇所が発生する.
これは,既に離職した社員が残したものや社内に詳しい人がおらずドキュメントもないもの等だと1から読むほかないですが,それができない場合に起きます. - コンポーネントの基盤として,多くのソフトウェアシステムで利用できること <=> (3)社内の別プロダクトへ流用したがうまくコンポーネント化できない
これは,メールアドレスのバリデーションロジックや社内で使っているデザインシステムのUIコンポーネントなどの全社的に使えるはずなものが,他のプロダクトへ流用できない場合に起きます.
実例を用いた各原則の説明
それではSOLID原則を構成するそれぞれの原則について説明をしていきます.
単一責任の原則
単一責任の原則(以降,SRP)は,あるクラス(*1)は1つのアクターに対しての責務を負うべきである,という原則です[2].なお,ここで言うアクターとは,例えばCTOやCEOなどのステークホルダーとそのまとまりのことを指しています.この原則を適応する上で分かりやすく言い換えると,あるクラスは2つ以上のアクターからの要求を受け付けるようになっていてはいけない,ということです.理由は明白かと思いますが,そのような設計になっていると一方からの要求がもう一方の要求へ不用意に影響を及ぼしてしまうからです.小さい影響だと単にGit上の作業がコンフリクトしまう程度で済みますが,最悪の場合は一方の要求を壊してしまう可能性があります.
SRP違反の例
SRPに従っていない例として次のような設計が挙げられます.俗に言うFatViewControllerです.このViewControllerは,プランナーとデザイナーという2人のアクターからの要求を受け付けているため,SRPに違反しています.
結果として,お互いの要求による変更に影響を及ぼし,最悪の場合に壊してしまう可能性があります.
SRPの適応
修正した設計と実装のイメージを示します.今回はCoordinatorパターンを使って,ViewControllerからCoordinatorへ遷移に関する具体的な実装を委譲しました.具体的には,ViewControllerからは遷移先の指定しか行わず,どのようなアニメーションを使って遷移するかの情報を一切持たないように変更しました.代わりに,そういう遷移に関することはCoordinatorクラスが行います.
// XCoordinatorを使用した例
// 行き先だけを指定し,具体的にどのVCがどのように表示されるかを知らない
class UserListViewController {
let router: UnownedRouter<UserRoute>
/* ... */
func userButtonPressed() {
router.trigger(.user)
}
}
// XCoordinatorのサンプルコードから一部引用
// Coordinator内で具体的な遷移処理を記述
class UsersCoordinator: NavigationCoordinator<UserRoute> {
override func prepareTransition(for route: UserRoute) -> NavigationTransition {
switch route {
case .user(let name):
let animation = Animation(
presentationAnimation: YourAwesomePresentationTransitionAnimation(),
dismissalAnimation: YourAwesomeDismissalTransitionAnimation()
)
let viewController = UserViewController.instantiateFromNib()
return .push(viewController, animation: animation)
}
}
}
この変更によって,プランナーからの要求とデザイナーからの要求が同じクラスへ集中することを避けられました.もちろん,実際のケースにおいては,別の要因でプランナーとデザイナーとの要求が再びクラスへ集中することがあると思います(あるいは,別のアクターから).そのようなことが起こらないように,責務過多の匂いを感じたら,クラスを分けていくことが重要になると思います.
オープン・クローズドの原則
オープン・クローズの原則(以降,OCP)はプログラムの構成要素は拡張に対して開いて,修正について閉じていなければならないという原則です[1, 2].拡張に対して開いているとは,アプリの仕様が変わった時に,プログラムの構成要素へ振る舞いを追加するだけで対応できることを指しています.また,修正について閉じているとは,振る舞いを追加したとしても,他のソースコードへ全く影響を及ばさないことを指しています.
OCPに従った設計とす利点は次のとおりです.まず,あるソースコードに対する変更による別のソースコードへ影響を最小限にできます.結果として,壊れる心配が少なく安心して開発できるようになります.次に,新しい処理を追加するのが簡単になります.しかし,この原則は仕様に変更があるのにも関わらず,ソースコードへ変更せず対応すべきという,一見無茶なことを言っています.
抽象化によるOCPの実現
この無茶振りを実現する例として,抽象化を取り上げます.この例ではViewControllerからローカルのデータベースへ情報の書き込みを行おうとしています.
まず,抽象化を行わない場合のクラスの関係図を示します.DBにはsaveメソッドが実装されており,このメソッドを通して情報を保存できるとします.また,ViewControlelrはDBに依存しており,保持しているDBクラスのインスタンスを通して情報を保存します.
ここで問題となるのは,情報の保存先を変える度に,ViewControllerの修正が必要となる点です.
情報の保存先は,開発中のアプリがマルチプラットフォーム展開していく際に,ローカルDBからサーバ上のDBへ移行していくかもしれません(*2).そういうケースにおいてViewControlelrへ変更が伝搬してしまう現状は,OCP違反と言えそうです.
次に,抽象化を行った場合のクラスの関係図を示します.この場合は,ViewControlelrはDBを直接持つのではなく,抽象化されたServiceを持ちます.そのため,保存先をローカルDBからサーバ上のDBへ変えた場合も,ViewControllerには変更が伝搬することはありません.つまり,新しくクラスを追加しViewControllerへ注入するだけで振る舞いを変更できるため,拡張および修正に対して閉じた状態になりました.
class ViewController: UIViewControlelr {
let service: Service
.....
.....
func didTapedButton() {
service.save("something")
}
}
このように抽象化によってOCPを満たすことができます.
ただ,全ての変更に対して拡張および修正に対して閉じている状態を作るのは,(当たり前ですが)無理です.よって,閉じたい箇所を適切に抽象化していくことが重要です!
おまけ)可能性は無限大
インタフェース分離の原則
インタフェース分離の原則(以降,ISP)は,あるオブジェクトの利用者(以降,クライアント)へそのオブジェクトが利用しないメソッドの依存を強要してはならない,という原則です[1, 2].
この原則が満たされない時,クライアントが利用しないメソッドの変更から不用意に影響を受けてしまう可能性があるため,クライアントは利用するメソッドにのみ依存する必要があります.
具体的に,どのような影響があるのかを考えています.例えば,次の図のように,AnyProtocolをAclient,Bclient,およびCclientが準拠しているとします.なお,名前の通り,Aclientクラスはaメソッドしか利用しておらず,他のクラスも同様に1つのメソッドしか利用していません.
これは,利用していないメソッドへの依存を強要しているためSRP違反と言えます.この場合,Aclientはaメソッドしか利用していないのにも関わらず,bメソッドおよびcメソッドのrenameや引数の変更があった場合に,修正およびビルドを強要されます.また,必要以上にメソッドの依存を強要した結果,次のような実装をしてしまい意図しないバグを生んでしまう可能性もあります.これは,後に説明するリスコフの置換原則にも反しています.
class AClient: AnyProtocol {
func a() {
print("called a()")
}
func b() {
fatalError("このメソッドは使えません")
}
func c() {
fatalError("このメソッドは使えません")
}
}
func b(_ any: AnyClass) {
any.b()
}
b(AClient()) // クラッシュ
ISPを守る方法は非常にシンプル(かつ自明)で,クライアント毎にプロトコルを分離することです.Swiftでは多重継承が可能なため,準拠すべきプロトコルが増えた場合も問題となりません.具体的には次の図のようにクライアント毎にインタフェース(*3)を分けると依存を最小限にできます.
リスコフの置換原則
リスコフの置換原則(以降,LSP)は,あるものと交換可能なものを使用できるようにした場合に,その交換可能なものが元のものと同じ振る舞いをすることを保証しなければならないという原則です[1, 2].
継承を考えると分かりやすいかもしれません.例えば,UIViewを継承したEllipseViewは,UIViewとしても扱うことができます.そのため,UIViewControlelrのaddSubViewのようなUIViewを引数にとる関数に対しても,EllipseViewのインスタンスを渡すことができます.EllipseViewはUIViewとしてもコンパイラに解釈されるという点において,交換可能であると言えそうです.
final class EllipseView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = layer.frame.width / 2
}
}
addSubView(EllipseView())
継承によってLSPを満たせない例
上記の例だけを見ると,Swiftによるプログラミング(= 継承が可能なオブジェクト指向の言語を用いたプログラミング)では,継承を用いることによってリスコフの置換原則を満たすことができるように見えます.
しかし,継承によって子クラスは親クラスの性質を受け継ぐことはできますが,親クラスと同じ振る舞いをするかは実装者に委ねられています.よって,単に継承するだけではなく,子クラスが親クラスと交換可能になっているかに気を付ける必要があります.
例えば,UIStackViewはLSP違反を犯していると言えます.UIStackViewはUIViewの子クラスであるのに関わらずに,backgroundColorプロパティからは背景色を変えることができないからです.
つまり,親クラスできることが子クラスではできないようになっています.そのため,UIViewとして扱えるが,振る舞いの面では交換不可能になっています.よって,もしかしたらランタイムの型情報によって,処理を切り分ける必要がでてくるかもしれません.これは,OCP違反にも繋がります.
// このメソッドは,UIStackViewでは期待通りに動かない
extension UIView {
func fillIncludingBorder(with color: UIColor) {
backgroundColor = color
borderColor = color
}
}
// 使う側はそれに気づかないかもしれない
view.fillIncludingBorder(with: .white) // view is UIStackView
ちなみにこれはXcode12から解消されるみたいです.
LSP違反をチェックする
UIStackViewの例を出しましたが,殆どの場合これは問題ならないかと思います.なぜなら,UIKitはそんなに頻繁に変更されないからです.そのため,多くの場合に問題となるのは自身が自身のプロジェクト新しい型の定義を追加する際です.
定義した型がLSPに違反してないかを簡易的にこれをチェックするには,次の2つを確認すると良いです.
- 継承先で退化しているメソッド・プロパティはないか
- 継承先で継承元で投げていない例外を投げていないか
依存性逆転の原則
依存性逆転の原則(以降,DIP)は,簡単に説明すると抽象に依存せよという経験則です[1, 2].つまり,(単なる言い換えですが)具象クラスには極力参照すべきではないということです.これは,具象クラスは抽象に比較して変更されやすく,その結果そのクラスを参照していた箇所全てに変更の影響が伝搬してしまうためです.
依存性の逆転
ソフトウェアを形成するコードには,ソフトウェアの振る舞いを決めているアプリケーション・ビジネスの方針(以降,方針)とどのデータベースを使うか等の実装の詳細(以降,詳細)があります.
依存性逆転の原則の逆転とは,方針が詳細に依存している不健全な状態から,詳細が方針に依存するように依存の方向を逆転させることを示しています.これは,アプリケーションの存在理由である方針が詳細へ影響を与えるべきであり,その逆は許してはならないという主張です.
この依存性逆転の例としては,OCPにて説明した例をそのまま用いることができます.
OCPの例では詳細であるDBを方針を決定付けるServiceプロトコルへ依存させることによって,依存性を逆転させていました.(繰り返しになりますが)こうすることによって,詳細の変更が方針へ全く伝搬することがありません.
具象クラスへの参照を避ける
はじめにDIPとは次のような原則だと説明しました.
依存性逆転の原則は,簡単に説明すると抽象に依存せよという経験則です.
つまり,(単なる言い換えですが)具象クラスには極力参照すべきではないということです.
では,具象クラスを参照しないにはどうしたらよいでしょうか?
方法の1つとしてDIコンテナを使う方法が挙げられます.
例えば,DIコンテナの実装をしているSwinjectを用いると,次のようにcontainerインスタンス(DIコンテナ)を使って具象クラスの初期化を行うことができます.このとき,具象クラスの参照は行わずに初期化を行うことができます.つまり,インスタンスの使用者はインタフェースのみを知っていればよいです.
protocol UserServiceType {
func get(id: Int) -> User
}
let service: UserServiceType = container.resolve(UserServiceType.self)!
しかし,(当たり前ですが)具象クラスの参照はどこかで必要になってきます.次がDIコンテナへ具象クラスを登録するコードです.このようにDIコンテナへインスタンスを登録することによって,上記コードのようにプロトコルの指定によって具象クラスのインスタンスの生成が可能となります.
class UserService: UserServiceType {
func get(id: Int) -> User { .... }
}
container.register(UserService.self) { _ in UserService() }
抽象だけに依存することは不可能ですが,具象クラスへの参照を最小限にする努力はすることはできます.特に変更が多い具象クラスは,その参照を最小限にすることによって,不用意に変更が伝搬することを減らせます.そして,DIPに従うことによって自然にOCPを守ることにつながるため,コードを保守するのがずっと楽になるはずです!
おわり
本記事ではソフトウェアの保守性を向上させるのに有益なSOLID原則とその例を紹介しました.いろいろうだうだ書きましたが,個人的にはプログラムを洞察するには何かしらの視点が必要で,SOLID原則はその1つになり得るのではないかなと考えています.
もし他にも,より現実に即した例や面白い設計原則があれば教えてください.それでは.
注釈
(*1)正確にはType.EnumやStructにも同様のことが言えると思います.
(*2)適切にレイヤーを分けていくと,Repsitoryのような保存先を隠蔽するクラスが現れ,この問題は顕在化しないと思います.
(*3)本記事ではインタフェースとプロトコルを使い分けていません.ただし,プロトコルと使うときは,よりSwiftの言語仕様としての文脈が強い?かもしれません.
参考文献
- 書籍
- [1] Robert C.Martin,瀬谷 啓介.アジャイルソフトウェア開発の奥義 第2版 オブジェクト指向開発の神髄と匠の技(2008/7/1) SBクリエイティブ
- [2] Robert C.Martin,角 征典,高木 正弘.Clean Architecture 達人に学ぶソフトウェアの構造と設計(2018/7/27) KADOKAWA
- 記事
- インターフェイス分離の原則 https://think-on-object.blogspot.com/2011/11/interface-segregation-principle-isp-top.html
- SOLID原則を理解する https://shtnkgm.com/2020/05/23/solid.html
- インターフェース分離の法則(ISP) http://harumi.sakura.ne.jp/wordpress/2019/06/14/インターフェース分離の法則isp/
- Swift/iOSにおけるSOLID — Single Responsibility Principle https://medium.com/@r.izumita/swift-iosにおけるsolid-single-responsibility-principle-60a0ea67dd9e
- よくわかるSOLID原則5: D(依存性逆転の原則) https://note.com/erukiti/n/n913e571e8207
- 過去に開催した「アジャイルソフトウェア開発の奥義」の勉強会にて用いられた資料たち
Discussion