Swiftをせっかく使うならProtocol Oriented Programmingしたい
まえがき
6月からAndroidエンジニアからiOSエンジニアになり、Objective-CをSwift化するプロジェクトをやっている。 iOSはiOS5,6時代に開発した経験はあるがSwiftは0からということで、最近色々記事を読んでいた。Swiftいいですね。僕は好きです。
その中でWWDCのセッションである「Protocol-Oriented Programming in Swift - WWDC 2015 - Videos - Apple Developer」に出会い、
オブジェクト指向な実装をしてしまっていたところを軌道修正中であります。
この記事はオブジェクト指向のアプローチからプロトコル指向のアプローチまで段階を踏んで実装することで、オブジェクト指向との違いやプロトコル指向の理解を深めようというモチベーションで書いた。
また、Playgroundのソースコードは下記のリポジトリにおいてある
プロトコル指向プログラミングとは
- プロトコルに性質を定義し、プロトコルに準拠していくことで処理の共通化をはかっていくアプローチ
- 主にprotocol, struct, enum, extensionで、基本的にはイミュータブルなデータ構造
対比されるもの
オブジェクト指向にとって代わるものとされている。
なぜオブジェクト指向と取って代わるのか
下記に挙げるオブジェクト指向の利点(目的)はSwiftのprotocol
, struct
, extension
で実現し、さらに欠点である複雑性を排除することが出来るから
オブジェクト指向の利点(引用)
Protocol-Oriented Programming in Swift - WWDC 2015 - Videos - Apple Developerのでは下記を上げている
- Encapsulation(カプセル化)
- Access Control(アクセスコントロール)
- Abstraction(抽象化)
- Namespace(名前空間)
- Expressive Syntax表現力のある構文。例えばメソッドチェーン
- Extensibility(拡張性)
これらは型の特徴であり、オブジェクト指向ではclassによって上記を実現している。
また、classでは継承を用いることで親クラスのメソッドを共有したり、オーバーライドによって振る舞いを変えるということ実現している。
しかし、これらの特徴はstructとenumで実現することが可能。
クラスの問題点
暗黙的オブジェクトの共有
classは参照であるため、プロパティの中身が書き換わると参照しているすべての箇所に影響が及ぶ。即ち、その変更を考慮した実装による複雑性が生まれているということ。
継承関係
Swiftを含め、多くの言語ではスーパークラスを1つしか持てないため、親を慎重に選ぶという作業が発生している。また、継承した後はそのクラスの継承先にも影響が及ぶので後から継承元を変えるという作業が非常に困難になる。
型関係の消失
オブジェクト指向をSwiftで実現しようとすると、ワークアラウンドが必要になる
/// データクラスをキャッシュするクラスをCacheとし、更新のためにmergePropertyというメソッドを用意した class Cache { func key() -> String { fatalError("Please override this function.") } func mergeProperty(other: Cache) { fatalError("Please override this function.") } } class FuelCar: Cache { var fuel: Int = 0 var id: String init(id: String) { self.id = id } override func key() -> String { return String(describing: FuelCar.self) + self.id } override func mergeProperty(other: Cache) { guard let car = other as? FuelCar { return } fuel = car.fuel } } var memoryCache = [String:Cache]()
発生しているワークアラウンド
- 抽象関数を実現するためにスーパークラスで
fatalError
を使っている - 各クラスの実装でランタイムのキャストを行っている
- もし、FuelCar, BatteryCarで共通処理を実装するCarというスーパークラスを定義したくなったら、CacheFuelCarなどとデータクラスを分けるような実装が必要になる
簡単なキャッシュをオブジェクト指向からプロトコル指向にリファクタしてみる
classを使ってオブジェクト指向な実装
typealias CacheKey = String class Cacheable { func key() -> CacheKey { fatalError("Please override this function") } func merge(other: Cacheable) { fatalError("Please override this function") } } class CacheStore<CacheableValue: Cacheable> { var cache = [CacheKey:CacheableValue]() func save(value: CacheableValue) { if let exist = cache[value.key()] { exist.merge(other: value) cache[value.key()] = exist return } cache[value.key()] = value } func load(cacheable: CacheableValue) -> CacheableValue? { return cache[cacheable.key()] } } class FuelCar: Car { var fuelGallon: Int init(id: String, fuelGallon: Int = 0) { self.fuelGallon = fuelGallon super.init(id: id) } override func key() -> CacheKey { return id } override func merge(other: Cacheable) { guard let fuelCar = other as? FuelCar else { return } self.fuelGallon = fuelCar.fuelGallon } } var fuelCarCache = CacheStore<FuelCar>() var car1 = FuelCar(id: "car1", fuelGallon: 0) fuelCarCache.save(value: car1) print(cacheable: car1, store: fuelCarCache) // print: 0 car1.fuelGallon = 10 print(cacheable: car1, store: fuelCarCache) // print: 10 fuelCarCache.save(value: car1) print(cacheable: car1, store: fuelCarCache) // print: 10
問題点
- CacheableとCarという共通クラスを持つために、CarがCacheableを継承する必要がある
- Carでインスタンスを作ってキャッシュに入れることができてしまう。ラインタイムでエラー
merge
メソッドではfuelCarへのランタイムでのキャストが発生するcar1.fuelGallon = 10
を記述した時点で、キャッシュを参照している部分全てに影響が出ている
protocolを使う
//: Playground - noun: a place where people can play import Foundation protocol HasId { var id: String { get } } protocol Mergeable { func merge(other: Self) -> Self } typealias CacheKey = String protocol KeyCreator { func key() -> CacheKey } protocol Cacheable: KeyCreator, Mergeable { } class CacheStore<CacheableValue: Cacheable> { var cache = [CacheKey:CacheableValue]() func save(value: CacheableValue) { if let exist = cache[value.key()] { cache[value.key()] = exist.merge(other: value) return } cache[value.key()] = value } func load(keyCreator: KeyCreator) -> CacheableValue? { return cache[keyCreator.key()] } } class Car: HasId { var id: String init (id: String) { self.id = id } } class FuelCar: Car, Cacheable { var fuelGallon: Int init(id: String, fuelGallon: Int = 0) { self.fuelGallon = fuelGallon super.init(id: id) } func key() -> CacheKey { return id } func merge(other: FuelCar) -> Self { if self.id == other.id { self.fuelGallon = other.fuelGallon } return self } } func print<Key: KeyCreator>(key: Key,store: CacheStore<FuelCar>) { print("fuelGallon: \(store.load(keyCreator: key)!.fuelGallon)") } var fuelCarCache = CacheStore<FuelCar>() var car1 = FuelCar(id: "car1", fuelGallon: 0) fuelCarCache.save(value: car1) print(key: car1, store: fuelCarCache) // print: 0 car1.fuelGallon = 10 print(key: car1, store: fuelCarCache) // print: 10 fuelCarCache.save(value: car1) print(key: car1, store: fuelCarCache) // print: 10
改善されたポイント
merge
では引数の型がコンパイル時に決まるようになったCar
クラスをCacheできないようになった。
structを使う
//: Playground - noun: a place where people can play import Foundation protocol HasId { var id: String { get } } protocol Mergeable { func merge(other: Self) -> Self } typealias CacheKey = String protocol KeyCreator { func key() -> CacheKey } protocol Cacheable: KeyCreator, Mergeable { } struct CacheStore<CacheableValue: Cacheable> { var cache = [CacheKey:CacheableValue]() mutating func save(value: CacheableValue) { if let exist = cache[value.key()] { cache[value.key()] = exist.merge(other: value) return } cache[value.key()] = value } func load(keyCreator: KeyCreator) -> CacheableValue? { return cache[keyCreator.key()] } } protocol Car: HasId { } struct FuelCar: Car, Cacheable { var id: String var fuelGallon: Int func key() -> CacheKey { return id } func merge(other: FuelCar) -> FuelCar { return FuelCar(id: self.id, fuelGallon: other.fuelGallon) } } func print<Key: KeyCreator>(key: Key,store: CacheStore<FuelCar>) { print("fuelGallon: \(store.load(keyCreator: key)!.fuelGallon)") } var fuelCarCache = CacheStore<FuelCar>() var car1 = FuelCar(id: "car1", fuelGallon: 0) fuelCarCache.save(value: car1) print(key: car1, store: fuelCarCache) // print: 0 car1.fuelGallon = 10 print(key: car1, store: fuelCarCache) // print: 0 fuelCarCache.save(value: car1) print(key: car1, store: fuelCarCache) // print: 10
改善されたポイント
- キャッシュを
save
するまで、キャッシュをロードした箇所・キャッシュ自体への影響がなくなった - イニシャライズ処理が簡潔になった(複雑なstructの場合この限りではない)
extensionをつかう
//: Playground - noun: a place where people can play import Foundation protocol HasId { var id: String { get } } protocol Mergeable { func merge(other: Self) -> Self } typealias CacheKey = String protocol KeyCreator { func key() -> CacheKey } protocol Cacheable : KeyCreator, Mergeable {} extension Cacheable where Self: HasId { func key() -> CacheKey { return id } } struct CacheStore<CacheableValue: Cacheable> { var cache = [CacheKey:CacheableValue]() mutating func save(value: CacheableValue) { if let exist = cache[value.key()] { cache[value.key()] = exist.merge(other: value) return } cache[value.key()] = value } func load(keyCreator: KeyCreator) -> CacheableValue? { return cache[keyCreator.key()] } } protocol Car: HasId { } struct FuelCar: Car { var id: String var fuelGallon: Int } extension FuelCar: Cacheable { func merge(other: FuelCar) -> FuelCar { return FuelCar(id: self.id, fuelGallon: other.fuelGallon) } } func print<Key: KeyCreator>(key: Key,store: CacheStore<FuelCar>) { print("fuelGallon: \(store.load(keyCreator: key)!.fuelGallon)") } var fuelCarCache = CacheStore<FuelCar>() var car1 = FuelCar(id: "car1", fuelGallon: 0) fuelCarCache.save(value: car1) print(key: car1, store: fuelCarCache) // print: 0 car1.fuelGallon = 10 print(key: car1, store: fuelCarCache) // print: 0 fuelCarCache.save(value: car1) car1 = fuelCarCache.load(keyCreator: car1)! print(key: car1, store: fuelCarCache) // print: 10
改善されたポイント
HasId
とCacheable
を準拠すれば、基本的にkeyの作成実装が不要になったstruct
の本実装と、キャッシュに保存するという戦略を別のブロックで書くことでコードの見通しがよくなった。
また、今回はPlaygroundなので出来ていないが
- FuelCar.swift
- FuelCar+Cacheable.swift
のように実装毎にファイルを分けることが出来るため、FuelCar.swift
ではFuelCarのドメインの処理を実装し、+Cacheable.swift
ではキャッシュの上書き戦略を実装するというパターン化が可能になる
ProtocolとStructのキモ(= POPの旨み)
- Protocolは抽象化・処理(性質)の共通化を記述する。
- Protocolのextensionでデフォルトの共通処理を定義していく
- 持っているプロトコルの組み合わせを条件としてextension共通処理を実装する事ができる
- Structに複数のProtocol(性質)を持つことで使えるメソッドが増えていく
Protocolとextensionの欠点
複数のプロトコル継承とextensionの実装
//: [Previous](@previous) import Foundation protocol HasId { var id: String { get } } protocol HasCategoryId { var id: String { get } } struct Book: HasId, HasCategoryId, CustomDebugStringConvertible { var id: String } let book = Book(id: "isbn-9784798142494") if let hasId = book as? HasId { print("id: \(hasId.id)") } if let categoryId = book as? HasCategoryId { print("id: \(categoryId.id)") } extension CustomDebugStringConvertible where Self: HasId { var debugDescription: String { return "hasId: \(id)" } } extension CustomDebugStringConvertible where Self: HasCategoryId { var debugDescription: String { return "hasCategiryId: \(id)" } } // If you make comment below, you can get any compile errors. extension CustomDebugStringConvertible where Self: HasId & HasCategoryId { var debugDescription: String { return "hasId: \(id), hasCategoryId: \(id)" } } debugPrint(book)
問題点
- 変数名の重複が予期せぬところでありうる
- 変数名が重複し、型が同じだった場合にコンパイルできてしまう
- 変数名が重複し、型が違った場合はコンパイルエラーになる
where Self: HasId
,where Self: HasCategoryId
のデフォルト実装をしつつ、両方のプロトコルを持つstruct
を定義すると、両方のプロトコルを定義したextensionも定義しなければならない(どっちの実装を使うかはコンパイラが判断できないため)
したがって、protocolの実装に気をつけなければ、バグを生む可能性やprotocolの定義の仕方によって実装方針が制限される可能性があるということも念頭に置かなくてはならない。
すべてPOPで書くことが出来るのか?
結論から言うと無理。理由はUIKitなどiOSのSDKがオブジェクト指向であるから、
その境界ではその限りではないし、structよりもclassが簡潔に書ける場面もありうる。例えばDI対象のオブジェクトとか、都度インスタンスを作りたくない場合はclassの参照を渡したほうがシンプル。
"実装できるならPOPに寄せる"という温度感で実装するのがちょうどいいように思える。
言い換えれば、「POPで実装できるか?」をOOPで実装する前に1度考えるということ。
まとめ
- Swift書くなら
protocol
,extension
,struct
orenum
でProtocol Oriented Programmingを意識する - 実装するときは1度POPで実装できるか考える