Swift Advent Calendar その2の23日目の記事です。
Swiftはマルチパラダイム
私がiOS開発に手を出し始めたのはおよそ4,5年ほど前、当時はまだSwiftは存在せず
Objective-Cによる開発で、ちょうどMRCからARCに切り替わる手前ぐらいに初めて出会いました。
当時は元々アプリエンジニアがほとんど社内にいない中、Android開発をもくもくと開発しており
オブジェクト指向はなんぞや?という所からJava先生にいろいろ教えて貰っていた所だったので、
その名の通りオブジェクト指向ベースであるObjective-Cにはretainカウントに発狂する以外はある程度キャッチアップできていました。
Swiftはマルチパラダイム言語と呼ばれています。
Objective-Cゆずりのオブジェクト指向、そして関数型を取り入れ、
また各言語に散らばっていた素晴らしい機能(Null安全、ジェネリクス、タプル.. etc)を取り入れた言語です。
またSwiftの際立った特徴と言えば、プロトコル指向プログラミングです。
WWDC2015年のセッションで紹介されたプロトコル指向プログラミングは、「オブジェクト指向言語こそプログラミングの基本であり、全てはオブジェクト指向のもとに設計されるべき」と考えていた自分にとっては非常にセンセーショナルな話題でした。
Swift Standard Libraryにおいても、swift3ではもはやオブジェクト指向の特徴であるclassはManagedBuffer一つを残すのみとなり、Swiftの多くはprotocolとstructによって構成されています。
Swiftのパラダイム・シフトへの強い勢いを感じます。
今回は、オブジェクト指向の問題点とプロトコル指向がどのようにその問題点を解決したか、またプロトコル指向の課題について改めてまとめようかと思います。
オブジェクト指向(OOP)とは
オブジェクト指向とはオブジェクト同士の相互作用として、システムの振る舞いをとらえる考え方(Wikipedia)とされています。
上記WWDCのセッションでは、オブジェクト指向のクラスの利点として以下をあげています。
- Encapsulation(カプセル化)
- Access Control(可視性制御)
- Abstraction(抽象化)
- Namespace(名前空間)
- Expressive Syntax(表現力のある構文。メソッドやプロパティを繋げて書けたり など)
- Extensibility(拡張性)
ただしこれらは型(Type)の特徴であり、オブジェクト指向特有のclassはそれらを実装する為の一つの手段であり、Swiftでは構造体や列挙型でも同じことが可能です。
classでしか出来ないことの特徴として、継承があります。スーパークラスで定義したメソッドをサブクラスで使うことができるし、またスーパークラスで定義したメソッドをオーバーライドしてカスタマイズすることが可能です。
しかし、クラスには以下の3つの点で課題があると指摘されています。
暗黙的なオブジェクトの共有
一つ目は**Implicitly Sharing(暗黙的共有)**です。
例えば、データをストアしているオブジェクトが一つあるとして、それらを参照しているオブジェクトが2つ存在しているとします。
class DataStore {
var strings: [String] = ["Foo","Bar"]
}
とても単純に配列を持っているデータオブジェクトがあるとします。
class ReferenceClass {
let dataStore: DataStore
init(dataStore: DataStore) {
self.dataStore = dataStore
}
func add(string: String) {
dataStore.strings.append(string)
}
func dataStoreString() -> String {
return dataStore.strings.joined(separator: ",")
}
}
class ReferenceClassA: ReferenceClass { }
class ReferenceClassB: ReferenceClass {
override init(dataStore: DataStore) {
super.init(dataStore: dataStore)
dataStore.strings.removeAll()
}
}
DataStore
を参照するReferenceClassA
というクラスとReferenceClassB
というクラスを作成します。
ReferenceClassB
の方は、イニシャライザでdataSourceの中身を初期化しています。
let dataStore = DataStore()
let classA = ReferenceClassA(dataStore: dataStore)
classA.add(string: "hoge")
var string = classA.dataStoreString() // -> ["Foo","Bar","hoge"]
let classB = ReferenceClassB(dataStore: dataStore)
string = classA.dataStoreString() // -> []
classA
とclassB
をそれぞれ生成した際に同じデータオブジェクトを渡した場合、
classB
のイニシャライザにてDataStoreに対して破壊的な処理を行っている為、
同じものを参照しているclassAのDataSourceを参照しても、空配列となっています。
classの場合は参照値を保持する為、データオブジェクトを第三者が変更可能なので、それを考慮しない場合、思わぬバグを生む可能性があります。
継承関係
Swift(というより、多くのオブジェクト指向言語)では、スーパークラスを一つしかもてません。
最初の段階で何のスーパークラスを持つべきか、慎重に検討する必要があります。
(後から継承関係を変更するのは非常に辛い事になります。)
スーパークラスにプロパティを持っている場合は、カプセル化で保護された不変的なものに注意しながらサブクラス側で必ず初期化しなくてはなりません。
もちろん、サブクラスの作成者がスーパークラスはどのような仕様なのか、Overrideした時にどのような挙動をするのかをしっかり把握している事も必須ですし、
スーパークラスの作成者も、自分の作成したクラスのメソッドなどがOverrideされることを考慮して設計しなくてはなりません。
このような懸念を考慮して、Cocoa Frameworkでは頻繁にdelegateパターンを使い、
Overrideすること無くユーザに特定の処理を委任しています。
型関係性の消失
例えとして、Bookクラスからオブジェクトを生成し、並び替え処理を実装するとします。
まず最初にスーパークラスとなるBookクラスを作成します。
class Book {
func sort(book: Book) -> Bool {
fatalError("このメソッドはオーバーライドされなければなりません!")
}
}
JavaのようなAbstractクラスを作成できない為、サブクラスにて必ずオーバーライドされるべきメソッドには、fataErrorなどを定義してサブクラスの作成者にオーバーライドされるべきであることを明示しなくてはなりません。
class Magazine: Book {
var number: Int
override func sort(book: Book) -> Bool {
return number < book.number // ERROR!! - Value of type 'Book' has no member 'number'
}
}
class Comic: Book {
var authorName: String
override func sort(book: Book) -> Bool {
return authorName < (book as! Comic).authorName
}
}
続いてBook
クラスを継承し、Magazine
クラスとComic
クラスを作成します。
雑誌は発行順に、漫画は作者順に並び替えをしたいので、Magazine
クラスには独自にnumber
プロパティを付け、Comic
クラスにはauthorNameプロパティを付けてそれで並び替えを行おうかと思います。
ここで問題になるのはsortメソッドはスーパークラスにて定義されている為、渡される引数の型はBook
である為、numberプロパティを参照することはできません。
ここではもう一つ作成したComicクラスのように明示的に
return number < (book as! Magazine).number
とダウンキャストしなければなりません。
型がMagazineであることが保証されていない為、エラーを引き起こす可能性が非常に高いです。
プロトコル指向(POP)の登場
上記の課題を解決すべく、Swiftは新たなパラダイムを呼び込みました。
それが プロトコル指向です。
プロトコル指向とはオブジェクト指向とは違い、基本的にclassは用いず、structを使って設計します。
何故structを使うのでしょうか?
classとstruct
classとstructには以下のような特徴があります。
class | struct | |
---|---|---|
type | 参照型 | 値型 |
継承 | 継承できる | 継承できない |
プロパティ変更 | 可能 | 条件付きで可能(mutatingなど) |
structの最も注目すべき特徴は
- structは**(基本的に)不変**である
- structは継承ができない
この2点です。
いささか不便のようにも感じますが、プロトコル指向というパラダイムにとてもマッチしています。
オブジェクト指向からプロトコル指向へ
プロトコル指向はオブジェクト指向と対比させると以下のような特徴を備えています。
- 型を継承させず、protocolを用いて型の性質を定義する
- 不変である値型(struct)を用いてオブジェクトの共有はしない
- 型関係性を持たせない
オブジェクト指向での課題であった上記つの問題点についてprotocolを用いて解決しよう、というものです。
オブジェクト指向では継承が可能である為、クラスに性質を持たせる為に特定のスーパークラスを元にサブクラスを作成した場合、何をスーパークラスとして持つかを検討しなくてはなりません。
また、スーパークラスがどのような仕様で、継承してどのメソッドをオーバーライドしても仕様上問題ないかを判断しながらサブクラスを実装しなくてはならないため、継承関係が肥大化してくるにつれ、非常に辛いことになります。
一方protocolは一つの型に対して複数実装可能であるため、初期実装時に継承関係の複雑性を考慮しなくてもよく(どのようにprotocol実装するかは検討する必要はもちろんあります)、
プロジェクトが進むにつれ発生しがちな何層にも継承されたスーパークラスの仕様を全て把握せずとも、自分の実装に集中することができます。
また構造体を用いる事で暗黙的なオブジェクトの共有は発生しません。
structは値を渡したタイミングでコピーされます。
また基本的にはstructは不変であり、プロパティの変更はmutatingメソッドを用いないとstructの持つプロパティは変更できません。
実際にプロトコル指向に変換してみる
上記オブジェクト指向で定義したBookクラスをprotocolに置き換えます。
//class Book {
//
// func sort(book: Book) -> Bool {
// fatalError("このメソッドはオーバーライドされなければなりません!")
// }
//}
protocol Book {
func sort(book: Book) -> Bool
}
オブジェクト指向の時にはサブクラスにて定義すべきメソッドにはわざわざfatalErrorを付けなければなりませんでしたが、protocolの場合はインターフェースのみを定義できる為、実装側で処理内容を記述することをコードレベルで強制できます。
続いて、サブクラスとして定義していたMagazineをstructに変更します。
struct Magazine: Book {
var number: Int
func sort(book: Book) -> Bool {
return number < book.number // ERROR!! - Value of type 'Book' has no member 'number'
}
}
変更自体はclassをstructに置き換え、overrideを消すだけで対応できますが、これだけではまだ完全には対応できません。
Bookプロトコルにはnumberプロパティが存在しない為、このままでは(book as! Magazine)のように結局キャストしなければなりません。
この場合、型関係はBookを実装した型自身である為、Selfを用いて実装された型自身であることを明記します。
protocol Book {
func sort(book: Self) -> Bool
}
struct Magazine: Book {
var number: Int
func sort(book: Magazine) -> Bool {
return number < book.number
}
}
Selfをprotocol側に定義することで、Magazineクラスの実装側でsortメソッドの引数をMagazineである事を定義することができました。
これでBookプロトコルからMagazineにキャストする必要は無くなり、型の関係性が維持された状態で処理を実装することができます。
これで最も基本的なprotocol実装は完了しました!
上記実践したことにより、OOPの課題をいくつか解決することができました。
こちらで紹介した以外にもSwiftにおけるprotocolには様々な機能(Swift2 より追加された protocol extensions など)を有しており、型の特徴を維持したまま、オブジェクト指向におけるclassが持つ課題をいくつか解消することができます。
protocol extension
やgenerics
などSwiftの持つprotocolの素晴らしい機能などについては非常に詳しく解説して頂いている投稿が沢山存在する為、ここでは省略します。
プロトコル指向プログラミングは銀の弾丸となりうるか?
これまでオブジェクト指向の課題から、プロトコル指向の方向性、更にオブジェクト指向からプロトコル指向への簡単な置き換えについてまとめました。
では、果たしてプロトコル指向はこれまでのオブジェクト指向を完全にリプレイスするのでしょうか?
静的ディスパッチ
上記のサンプルで取り上げたMagazineクラス、Comicクラスが存在するとします。
仮に、Magazine,Comic関係なく全てを配列を保持したい、という時
以下の場合ではコンパイルエラーが発生し実行できません。
let books: [Book] = [Magazine(number: 0),Comic(authorName: "Araki Hirohiko")] // ERROR!! - it has Self or associated type requirements
これは、Bookプロトコルに定義されているSelf
の実態が何なのかを静的に判断できない為に発生しています。
Bookクラスをスーパークラスとして定義し継承して各クラスを作った場合、
Bookクラスが何のサブクラスに当たるのかは動的に判断される為、上記のような書き方をしても問題ありません。
静的ディスパッチであること自体に対してはパフォーマンスに対しても非常に有効であり、型関係性も維持できるので積極的に活用していくべきですが、
オブジェクト指向では当たり前であったロジックを活用できなくなる為、現行のオブジェクト指向的パラダイムで書かれた部分のリファクタリングで、無為なワークアラウンドを求められる可能性が存在します。
structは値渡し
structは値渡しである為、structを各型に受けたわす際はコピーが作られます。
CGFloat
等struct自体のサイズが小さい場合はさほど問題ではないのですが、
現行の参照渡し前提であるclassをstructに置き換える等、巨大なstructになってしまった場合、メモリ負荷が増大する可能性があります。
Cocoa Frameworkの存在
SwiftはSwift3になりオープンソース化されたものの、
まだまだiOSやmacOSのアプリケーション開発に用いる人が大半なのではないでしょうか。
そこで否が応でも避けられないのがCocoa Frameworkの存在です。
Cocoa FrameworkはObjective-Cをコア言語として開発されていました。
その為、Cocoa Frameworkはオブジェクト指向をメインのパラダイムとして開発されています。
例えば、画面を定義する場合は基本的にViewControllerをスーパークラスとして継承し使用される想定となっています。その他の機能についても同様です。
その為、Cocoa Frameworkと付き合いながらiOS/macOSの開発を進める場合、オブジェクト指向で実装しなければなりません。
それは、上記の暗黙的オブジェクト共有についてや肥大化する継承関係の問題が発生する可能性が常に潜んでいます。
幸い、protocol自体はclassにも実装する事が可能なので、プロトコル指向に寄せた形で開発することは可能ですが、
あくまでオブジェクト指向とプロトコル指向とのマルチパラダイムで実装している事を常に意識する必要があります。
まとめ
個人的にプロトコル指向とオブジェクト指向のキモはprotocolを活用するか否か、というよりも
classを使うか、structを使用するかにあると感じました。
structを使うことにより型に不変性が生まれ、protocolはstructを型としての拡張性を補助する役割であるイメージです。
ただ、structには上記で懸念したとおり、classを使っていた場合は懸念する必要がなかった課題が存在します。
また、Swift自体はprotocol指向的ではあるものの、各フレームワークの存在によりパラダイムを統一することはまだ難しいのではないかと思います。
これからはプロトコル指向や、とにかくstructとprotocolをガンガン使っていくで!となるより、
class、struct、protocolの特性を理解し、各プロダクトに合わせて仕様を決めていく必要があると思います。