Swiftで振り返るオブジェクト指向プログラミング:避けるべきコーディング習慣
背景
コンピュータと絵画を専攻し、かなりの成功を収めた誰かは、プログラマーは科学者よりも画家と共通点が多いと言った。 確かに、プログラミング能力を高めようと努力をすると、科学よりは経験的に実力を積む必要がある運動や美術に似ていると感じられる。
良い絵を見極める能力と絵をうまく描くのは別の話である。コードを見て、良し悪しを判断する目と、良いコードを作成する実力は、残念ながらついてこない。 コードをたくさん見て、たくさん作成して、他人のコードをメンテナンスしてみながら、経験的にそれを判別する感覚が少し身についたと思う。 でも、私がキーボードを叩き始めると、いつの間にかモヤモヤ感が訪れてくる。本能的かつ経験的に、このままコードを作成してはいけないという感じがして仕方がない。それでも他の方法は見つけられず、どうすれば良いコードを作成できるのかに対する明快な答えはない状態だ。学生時代に試験を受けた時を振り返ってみれば、自信を持って答えを選ぶこともある一方で、明確な誤答を取り除いた後に残っている選択肢の中から選ぶこともある。明確な誤答を除去すると、残っている選択肢に更なる集中力を投資することができ、答えを選択する確率が高くなる。プログラミングの話に戻ると、 間違ったパターンやメンテナンス性の悪いコードを知ってそれを避けると、もっと良いコードを作成できるのではないかと考えてみた。
昨年、会社でSwiftによる関数型プログラミングの勉強会に参加したことがある。勉強会を始めた日、勉強の方向性をつかむためのガイドラインを提示してもらいたくて、社内で関数型Swiftの講座を進めたことのあるメンターを招き、アドバイスを受けた。その場でメンターは、これまで約1年間の私のプログラミング勉強の方向性を定めるきっかけとなった話をしてくれた。 「関数型プログラミングがいかに、どれほど優れているかを比べるためには、良く作成されている関数型コードと、良く作成されているオブジェクト指向型コードを比べてみる必要があるが、私はまだまだ良いオブジェクト指向型コードを作成しているとは言えないので、比較するのが難しい」。なるほど、毎日「オブジェクト指向」方式でコードを作成するからといって、私ができるのはオブジェクト指向しかないから、私がオブジェクト指向をしているんだと思っていた私の無意識の思考が根本から揺らいだ。それでもう一度、基本を着実にしなければいけないと思い、このノートはここ数か月間習得した知識のまとめだ。
SwiftとSOLID
SOLIDは、ロバート・マーティンというプログラマーが命名したオブジェクト指向プログラム及び設計の5つの基本原則だ。Swiftはマルチパラダイムプログラミング言語だが、Foundation、UIKitなどココアタッチフレームワークは基本的にオブジェクト指向をベースとしている。SOLID原則とiOSを関連づけて見てみると、私が使っている多くのクラスがなぜそのように作られたのかが分かり、また私がどのようにこれらの原則をコードに適用できるのかを実感することができた。本文では、各原則に従って作られた(あるいは違反している)iOSフレームワークのサンプルとともに、この原則をよく守るためどのようにコードを作成すべきか、特にこの原則を違反したのはどのような形なのかを重点的にまとめた。先に言ったように、最初から良いコードは作成できないので、少なくとも間違ったコードは何かを知ってそれを避けようという考え方だ。すると、難しい内容は全て理解できないとしても、なんとか正しい方向へと進むことはできると思うからだ。
このノートでは、最後の2つの原則を除いたSOLIDの「SOL」までを扱うことにする。色々な理由があるが、まだ後の部分は説明できるほど私自身も完璧に理解しておらず、またSOLIDの「ID」はこのノートの旨の通り、その原則が違反している状況を判断するのがはるかに難しく微妙だからだ。でも、「SOL」だけでも私のコードの改善点をたくさん洗い出すことには全く問題なく、終わりが見えない!
Single Responsibility Principle
単一責任の原則(SRP)は、全てのクラスは1つの責任を持たなければならないという原則だ。 ロバート・マーティンは責任を「変更の理由」として定義した。SRPの最も代表的な違反事例はMassive View Controller現象だ。 こうしたMVCの問題を解決するために、様々なパターンが考案されているが、それらの共通点は、数多くの機能を持っているビューコントローラを分割し、単一責任だけを持つ複数のクラスを作ろうとするのである。
SRPを守るためにすぐに試してみることができるのはクラスを小さくすることだ。小さなクラスはSRPの十分条件ではないが、必要条件である。この目標を達成するためにメンターが提示した10-200ルールに基づいてコードを作成している。10-200ルールとは、関数は10行以内、クラスは200行以内に作成すること。ただ、これを厳しく守るのは容易ではなく、まだ理想の領域だ。現実的には、コードが200行を超えると、自分の頭の中に警報を鳴らし、行数がそれ以上増えないように工夫したり、リファクタリングを試みる。10-200ルールは実際に試してみると本当にたくさん考えざるを得ず、私を悩ませるルールだ。しかし、そのような不便さを乗り越える中で得られることも多い。
まず、「関数は10行」ルールを守るためには、関数は一つの作業だけをしなければならないという大原則を守るしかなく、抽象化レベルについても考える必要がある。抽象化レベルとは、どのくらいの情報をその関数で公開するかということである。インスタントラーメンの袋に書いてあるレシピを見てみよう。お湯550mlを沸かす、麺とスープ、フレークを入れる、4分30秒間さらに茹でるなどとなっている。3段階の抽象化レベルが似ている。お湯を沸かす段階をさらに細かくすると鍋を取り出す、浄水器をつけて水を入れる、ガスコンロに乗せる、火をつけるなどがある。しかし、それらを全部ラーメンの作り方には含めない。なぜなら、それらはお湯550mlを沸かすという段階(関数)でまとめて表現したからだ。このように作業(関数)をどのレベルまで分割して説明(抽象化)するかについて考える必要がある。
「クラスは200行」ルールを守るには、プロパティや関数、メソッドをどこでも勝手に作成して使ってはいけない。これらの要素が必ずここにあるべき理由を探さなければならない。そのクラスにある理由がなければ、SRP違反である。最も簡単に判断できる方法の1つは、関数やメソッドの内部で selfのプロパティやメソッドをどれだけ使っているかを確認することだ。もし1つも使っていなければ、そのクラスの中にある理由が全くない。またはselfの呼び出し頻度が少ないほどクラスとの関連性が低いので、後でクラスをリファクタリングしたりダイエットさせる状況が来れば優先的に追い出す候補になる。
SRPは、全てのパターンの始まりと言われる。SRPをきちんと守れないままコードを作成すれば、いくえパターンを導入してみても、うまくいかない可能性が高いという。クラスは1つの役割を持つようにすること。オブジェクト指向を学ぶ時、最初に出てくる原則であるにもかかわらず、本当に守りにくいと思う。
Open-Closed Principle
開放閉鎖の原則は、拡張に対しては開いており、変更に対しては閉じているべきだという原則である。開いているということは、機能の追加・変更が可能であることであり、閉じているということは、機能を追加する時に、そのモジュールを使っているコードを改めて変更してはならないということだ。OCP違反の代表的な例は、任意の値型に対する分岐文の繰り返しである。つまり、1つのenumに対して複数の場所で繰り返してif/switch文を使っている場合は、考え直す必要がある。このような場合、機能の追加はcase文を1行だけ追加するのと同じくらい簡単だが、そのenumに繋がれている全てのコードを探して修正する必要がある。コードを徹底的に作成したら、少なくともコンパイラの助けを受けることができるかもしれないが、default文が追加していたら、非常に厄介な処理となる。
OCPは、if/switch文を最大限に使用しない方法で練習することができます。全ての分岐文を取り除くわけではなく(不可能である)enumのように値型を分岐する地点に対して行う。最初にこの話を聞いた時は、とんでもない話だと思った。If文はプログラミングの最も基本的な要素だから。どうすればif/switch文なしで分岐ができるのか?最初はとても厄介で不便だったが、色々練習してみながら、どのように私のコードに役立つかを少しずつ実感している。前述の「10-200ルール」とも相補的だ。分岐文を取り除くだけで、関数やクラスの長さを大幅に短縮できる。
では、if/switch文を置き換える2つの方法を紹介する。まず、プロトコル(あるいはクラス)を作成して継承して使う方法である。この方法こそが直接的にOCPを守る構造だ。詳細については、このブログの内容を参照。もう1つの簡単な方法は、ディクショナリを活用することだ。ただ、この方法はOCPを守る構造ではない。これはdefaultがあるswitch文と同じ弱点があるので、caseが頻繁に変更される場合はできるだけ使わずに、分岐文を取り除きたい時に制限的に使用すれば良い。
既存のswitch文のコード
switch reason {
case .initializing:
self.instructionMessage = "Move your phone".localized
case .excessiveMotion:
self.instructionMessage = "Slow down".localized
case .insufficientFeatures:
self.instructionMessage = "Keep moving your phone".localized
case .relocalizing:
self.instructionMessage = "Move your phone".localized
}
ディクショナリを活用して分岐文を削除したコード
//適切な場所にディクショナリを生成する
let trackingStateMessages: [TrackingState.Reason : String]
= [.initializing. : "Move your phone".localized,
.excessiveMotion : "Slow down".localized,
.insufficientFeatures : "Keep moving your phone".localized,
.relocalizing : "Move your phone".localized]
//switch文の置き換え
self.instructionMessage = trackingStateMessages[reason]
Liskov Subtitution Principle
最後に、リスコプの置換原則は、スーパークラスをサブクラスのインスタンスに型キャストしても、プログラムの動作に支障をきたさないという原則だ。定義は難しいが、これを守る方法は意外と簡単だ。子が親の動作を制限してはいけない。典型的な違反事例としては、長方形を継承して作った正方形クラスを考えてみよう。正方形は幅と高さが同じであるため、幅と高さを自由に変えることができる長方形の親クラスの動作を制限することで、必要な動作を作ることができる。これがLSPの違反だ。 LSPを守るためには、長方形が正方形を継承するか、または幅と高さを最初からletにすれば良い。(値を変える動作自体をなくすと、制限する動作がないから)
iOSフレームワークでもLSP違反のケースを確認することができる。
var label = UILabel(frame: .zero)
var button = UIButton(frame: .zero)
var segmentedControl = UISegmentedControl(frame: .zero)
var textField = UITextField(frame: .zero)
var slider = UISlider(frame: .zero)
var switchButton = UISwitch(frame: .zero)
var activityIndicator = UIActivityIndicatorView(frame: .zero)
var progressView = UIProgressView(frame: .zero)
var stepper = UIStepper(frame: .zero)
var imageView = UIImageView(frame: .zero)
let views: [UIView] = [...] //上記のビューをUIViewのArrayに保存
views.forEach { $0.frame.size.height = 30 } //ビューの高さを30に変更
let height = views.reduce(0) { $0 + $1.frame.height }
print(height) //結果は?
10個のUIViewのサブクラスの高さを30に変えたため、300になると予想することもできるが、実際の結果は272だ。一部のビューはintrinsicSizeを勝手に変えることができなくなっているからだ。このようにUIView(親タイプ)の動作(高さを変えること)を制限する一部のビュー(UIProgressView、UISwitchなど)がまさにLSP違反である。
また、iOS開発をしてみると、以下のコードを見たことがあり、作り出したこともあるだろう。このように親の関数をオーバーライドして退化させてしまう関数を作ればLSP違反だ。
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
LSPを順守しながらプログラミングをするのは難しい。しかし、あまりにも多くの場所でLSPを違反すると問題が発生する。親クラスに基づいてコーディングできなくなり、そうなると継承そのものの意味がなくなり、OCPも守れなくなる。例えば、UITableViewは、UITableViewCellをベースに作成されているため、作成したカスタムセルがLSPを守らないと、テーブルビューも正しく動作しない。また、プロトコルを作る理由も抽象化されたインターフェースを基準にコードを作成するためであるが、継承されたタイプがプロトコルのメソッドを退化させてしまうと、プロトコルを基準に作成されたコードがずっと壊れるしかない。
継承はオブジェクト指向プログラミングの重要な部分であり、よく書くと便利ですが、間違った継承を作成すると問題が発生する可能性があります。一方、場合によってはLSPに違反することで便利さと単純さを得ることもできる。 結論として、LSPをどこでも守ろうとすると非効率的であり、頻繁に違反すると、予想外の結果が発生して安定性を損なうことになる。従って、継承を作成する時は、LSP違反かどうかを熟知して使用することをお勧めする。
結論
このノートの目標は、SOLIDの大原則を完全に理解することではない。 更に、全ての原則を徹底的に守るコードだけを作成するのは非効率的である。例えば、全てをオブジェクト指向方式で作成するのは不便だ。分岐文を使わないために毎回プロトコルを作って継承をする場合、開発する時間も長くかかり、複雑度が増加するだけだ。従って、適切なトレードオフが必要だ。5つの原則の中でSRPとOCPは、理解したとしても最も守りにくく、たゆまぬ努力を必要とする部分だと思う。何が正解なのか分からなくて混乱する時、確かに正解ではないことを消去するように、上記の原則を破るコードを把握して、一応それを削除することで改善してみよう。
以上
参考文献(コード例題): OOD Principles in Swift、Design Patterns in Swift
このノートの草案を読んでくれたキム・ヒョンジュン、キム・チャンヒ、キム・チャンギさんに感謝の気持ちを伝えます。
이 글의 초안을 읽어준 김형중, 김찬희, 김창기님에게 고마움을 전합니다.
Tags: Objected Oriented Programming, SOLID, Swift, iOS