はじめに
いまさらですが...
WWDC2015で「Swiftでプロトコル指向プログラミング」というセッションが行われたのを、最近になって知りました。みてみたら、Appleはプロトコル指向言語が欲しかったのでSwiftを作った、というような事を言っているじゃありませんか。
もっと早く見たかった...
標準ライブラリがstructとenumとprotocolばかりなのは、プロトコル指向言語としてあるべき姿を反映しているらしいです。未だにclass主体のコードを書いているのはまずいと思い、プロトコル指向の勉強を始めました。
残念ながらセッションのtranscriptの日本語訳は提供されていないようなので、ちゃんと理解するために訳しながらメモしてみました。サンプルコードはXcode10.2(swift5)のPlaygroundで動くように、元のセッションから変更しています。
なお、プロトコル指向に関する記事は、Qiita上にももちろん既にあります。
Swift 2で提唱されているProtocol Oriented ProgrammingをWWDCセッションから学ぶ
[Swifty]プロトコル指向プログラミング
プロトコル指向言語としてのSwift - OOPからPOPへのパラダイムシフトと注意点
プロトコル指向プログラミングについて調べてみた
最初の@mono0926さんの記事を先にみつけていたら、この記事を書こうとは思わなかったでしょう。@mono0926さんの記事の方が何倍もいいのでおすすめします。
この記事は半分以上書いちゃったので、もったいない精神を発揮して自分用に最後までメモったものです。
なお、セッションには天才Crustyさんが出てきますが、なんだか怖い方なので大胆に省略させて頂きました。
ソース
元の動画はこちらです。Swiftを使うなら、必見だと思います。
https://developer.apple.com/videos/play/wwdc2015/408/
Protocol-Oriented Programming in Swift
Swift standard Library テクニカルリードのDave Abrahams氏:
これから40分、いつものプログラミングの考え方に関して話す。簡単な話じゃないが、ついて来てくれれば有益な時間になると約束する。Swiftの設計の中心テーマについて語り、全部を変えてしまう可能性のあるプログラミング方法を紹介する。
オブジェクト指向とその課題
1970年代に始まったオブジェクト指向プログラミングは生産性を高めるのに多いに役立った。
クラスはすごい
- カプセル化:データと処理をグループ化できる
- アクセス制御:コード内に壁を作って分離し不変性を確保する。
- 抽象化:関連性についてのアイデアを表現する。windowとか通信チャネルとか。
- 名前空間:名前の衝突を回避できる
- 記述的構文:メソッド呼び出しやプロパティを書いて一緒に繋げられる。サブスクリプト([])とかコンピューテッドプロパティさえある。
- 拡張性:クラスの作者が用意しなかったものを、後から追加できる。
これらの利点によって、プログラミングの主課題である複雑性の制御をしている。
クラス 型がすごい
しかし、これらはクラスの特徴というより型の特徴である。その証拠に、Swiftではクラスじゃなくて構造体と列挙型も同じ利点を持っている。
構造体に対し、クラスだけがもつ利点としては、サブクラスが親クラスの機能を継承し簡単に利用できること、オーバーライドによって親の機能を差し替えられること、がある。
クラスの課題3つ
クラスには特有の課題がある。
1. クラスは暗黙の共有を生じさせる
複数のオブジェクトが別のオブジェクトを共有していることで、様々な問題(防御的コピー、非効率、競合、排他制御、デッドロックなど)が生じ、バグを生む。
この問題は、ココアプログラマにはよく知られている。ココアのドキュメントによくミュータブル(変更可能)な集合を処理中に変更する事についての警告が書いてある。これはクラスが変更可能な内部状態を共有している為に生じていて、不可避だ。
しかし、ココアの集合にあるこの問題は、Swiftの集合にはない。
Swiftの場合、集合が全部値型なので、この問題は生じない。処理中の集合と、変更するものは(コピーされて)別のものになるからだ。
2. クラスは押し付けがましい(Intrusive)
クラスは一枚岩。親クラスを一つしか選べないので肥大しがち。最初に親クラスを決めないといけないし、複数の特徴をもつクラスを作ることができないし、動的に特徴を変えられない。
親クラスが持っているストアドプロパティを無条件に受け入れないといけないし、必ず初期化が必要で、どのように初期化するべきか知っていないといけない。
また継承のときにどのメソッドをオーバーライドしていいか、オーバーライドしたときに親クラスを呼ぶ必要があるかどうか、呼ぶなら最初か最後か、などの重要な、どこにも書かれていない規約が発生する。
この問題も昔からココアプログラマにはお馴染みで、ココア全体でデリゲートパターンを多用しているのはこれに対処するためだ。
3. クラスは型の関係性が大事な場面に弱い
この問題は、たとえば比較ができるクラスを作ろうとするとわかりやすい。
そこで、自分を他と比較して順序関係を返すprecedes
メソッドをもつOrdered
クラス群を作るとしよう。どれでも共通のbinarySearch
関数で挿入位置を示せる事を目指す。
class Ordered {
func precedes(other: Orderd) -> Bool { fatalError("要継承!") }
}
func binarySearch(sortedKeys: [Ordered], forKey k: Ordered) {
var lo = 0
var hi = sortedKeys.count
while hi > lo {
let mid = lo + (hi - lo) / 2
if sortedKeys[mid].precedes(k) { lo = mid + 1 }
else { hi = mid }
}
return lo
}
precedes
メソッドで比較して結果を返す必要があるが、親クラスとなるOrdered
では比較する対象が不明なので、書きようがない。Swiftは抽象メソッドを許さないので、結局(fatalError
で)トラップしておく事しかできない。これが型システムとの戦いの最初の予兆である。この予兆に気づかないと、サブクラスで具体的な比較を実装すれば大丈夫、と自分に嘘をつくことになる。
サブクラスを実装してみると、問題に気がつく。
class Number : Ordered {
var value: Double = 0
override func precedes(other: Orderd) -> Bool {
return value < other.value // otherはvalueを持たないのでコンパイルエラーになる。
}
}
other
は抽象化されたOrdered
クラスで、value
を持たないのでエラーになる。
例えばラベルが必要になってLabel
クラスを作ったら、(value
ではなく)text
プロパティを持たせるだろう。
class Label : Ordered {
var text:String = ""
...
}
結局正しい型を得るためにはダウンキャストするしかない。
class Number : Ordered {
var value: Double = 0
override func precedes(other: Ordered) -> Bool {
return value < (other as! Number).value
}
}
ちょっと待って。other
がLabel
だったら?
もう罠にはまっている。precedes
メソッドを書くのにたくさんの問題の匂いがしている。これは、静的型安全の弱点だ。
なぜこのようになってしまったか。それは、クラスが、重要な自分と他の型との間の関係性を表現させないからだ。
この強制的なダウンキャスト(as!)を「コード臭」だと考えるといい。これを見つける度に、重要な型の関係性が失われている兆候だと考える。これはだいたい、抽象化にクラスを使っているために発生している問題である。
クラスの課題まとめ
明らかに、他の抽象化の方法が必要だ。暗黙の共有がなく、型の関係性を失わず、一つしか継承できないわけでもなく、型を定義するときに決めなくてもよくて、不必要なストアドプロパティを扱わなくてもよく、複雑な初期化を必要としない、そんな別の方法が。オーバーライドの要否が不明瞭でないことも必要だ。
もちろん、それが、プロトコルだ。
Swiftはプロトコル指向言語である
プロトコルはこれらの優位性を全部持っている。だから、AppleはSwiftを作った。初のプロトコル指向プログラミング言語を作ったのだ。
Swiftはオブジェクト指向プログラミングにも素晴らしいが、ジェネリクスを使ったスタンダードライブラリの開発、forループと文字列リテラルの扱い等を重視した結果、Swiftはその中心部分でプロトコル指向である。そして、みなさんもこのセッションを聞いたら今までより少しプロトコル指向になるだろう。
「クラスから始めるのではなく、プロトコルから始めよ。」
プロトコル版のサンプル
サンプルをプロトコル指向で書き直そう。
protocol Ordered { // classからprotocolに変更
func precedes(other: Ordered) -> Bool // 実装を削除(プロトコルにはデフォルト実装を持たせられない)
}
struct Number : Ordered { // classからstructに変更
var value: Double = 0
func precedes(other: Orderd) -> Bool { // override削除
return value < (other as! Number).value
}
}
まずはOrdered
をclass
からprotocol
に変える。
precedes
メソッドの実装はコンパイラがエラーにするので削除する。これにはprecedes
の呼び出しが動的ランタイムチェックから静的割り当てに変わるという利点がある。
precedes
のoverride
は何もオーバーライドしなくなったので消す。
Number
も、class
からstruct
に変える。数字のように振る舞わせたいから。
ここまでで、もうコンパイルは通る。プロトコルでもクラスでやったのと完全に同じことができていることがわかる。しかもクラスより確実にちょっと良い。なぜなら、あの致命的な問題はもうないから。とはいえ、強制ダウンキャストが残っているので静的型付けの落とし穴は残っている。このダウンキャストを消してみよう。
protocol Ordered {
func precedes(other: Self) -> Bool // OrderedをSelfに
}
struct Number : Ordered {
var value: Double = 0
func precedes(other: Number) -> Bool { // 引数をNumberに
return value < other.value // ダウンキャストを削除
}
}
precedes
の引数をNumber
型に変更し、ダウンキャストを削除。
そうすると、precedes
の引数のシグネチャが一致していないというエラーになる。それを直すのに、プロトコルのprecedes
メソッドの引数をOrdered
からSelf
に変える。
これは、Self-requirementと呼ばれるものだ。プロトコルでSelf
をみたら、それは、そのプロトコルを適用した型を入れるプレースホルダだと思えば良い。
Self-requirementの影響
これがOrdered
がクラスだったときのbinarySearch
関数だ。
func binarySearch(sortedKeys: [Ordered], forKey k: Ordered) -> Int {
var lo = 0
var hi = sortedKeys.count
while hi > lo {
let mid = lo + (hi - lo) / 2
if sortedKeys[mid].precedes(other: k) { lo = mid + 1 }
else { hi = mid }
}
return lo
}
この関数はOrdered
プロトコルにSelf-requirementをつける前でもそのまま動作した。
sortedKeys
の[Ordered]
は、Ordered
型の混合種のリストを扱うという宣言である。つまり、Numbers
とLabel
を一緒に混ぜたリストを扱うつもりだ。
ところが、Ordered
にSelf-requirementを使ったあと、コンパイラは[Ordered]
の部分を単一種にするようにとエラーを出すようになる。ジェネリクスを使うと通せる。
func binarySearch<T: Ordered>(sortedKeys: [T], forKey k: T) -> Int {
var lo = 0
var hi = sortedKeys.count
while hi > lo {
let mid = lo + (hi - lo) / 2
if sortedKeys[mid].precedes(other: k) { lo = mid + 1 }
else { hi = mid }
}
return lo
}
sortedKeys
の[T]
はOrdered
に準拠した型のうち、T
型だけの単一種の配列を扱うという意味になる。(混合種を使える)クラス版に比べて、プロトコル版のこの制約は厳密すぎるし、機能や柔軟性を損なうと思うだろう。でもよくよく考えたら、元の混合種のリスト宣言は実は嘘だ。複数種を扱いたいという状況は、罠を掛ける以外ではありえない。
実際には、単一種の配列が正しい。
プロトコルの2つの世界
Self-requirementを使うと、プロトコルは全然違う世界に移る。そこでは、クラスとできることがだいぶ異なってくる。
- 型のように使えなくなる。
- 集合は混合種ではなく単一種になる。
- インスタンス間の相互作用を気にしなくてよくなる。
- 動的多態性から静的多態性にかわり、コンパイラに与えた追加の型情報によって最適化がよりやりやすくなる。
Self-Requirementなし | Self-Requirementあり | |
---|---|---|
宣言 | func precedes(other: Ordered) -> Bool | func precedes(other: Self) -> Bool |
型 | 型として使う | ジェネリクスで使う |
↑ | func sort(inout a: [Ordered]) | func sort<T: Ordered>(inout a:T) |
集合 | 混合種 | 単一種 |
相互作用 | 他のモデルと相互作用がある | モデルは相互作用を気にしなくて良い |
多態性 | 動的割り当て | 静的割り当て |
最適化 | しにくい | しやすい |
あとで二つの世界の間に橋をかける方法を紹介する。
プロトコル指向による原始的描画モデルの例
通常オブジェクト指向でつくるようなものをプロトコル指向で作ってみる。形状をドラッグドロップできるような、ドキュメント-ディスプレイモデルを作ってみよう。
Renderer
まず、描画を行うRenderer
構造体を用意しよう。簡単にCUI版にする。
struct Renderer {
func moveTo(_ p: CGPoint) { print("moveTo(\(p.x), \(p.y))") }
func lineTo(_ p: CGPoint) { print("lineTo(\(p.x), \(p.y))") }
func arcAt(center: CGPoint, radius:CGFloat, startAngle:CGFloat, endAngle: CGFloat) {
print("arcAt(center: \(center), radius: \(radius), startAngle: \(startAngle), endAngle:\(endAngle))")
}
}
Drawable プロトコル
それからDrawable
プロトコル。描画エレメントの共通処理をプロトコルで定義する。
protocol Drawable {
func draw(_ renderer: Renderer)
}
ここまでは単純。次に形を作ろう。
描画エレメント
Polygon
は値型だ。頂点の配列をもつ。
struct Polygon : Drawable {
func draw(_ renderer: Renderer) {
renderer.moveTo(corners.last!)
for p in corners {
renderer.lineTo(p)
}
}
var corners: [CGPoint] = []
}
Circle
はこうだ。
struct Circle : Drawable {
func draw(_ renderer: Renderer) {
renderer.arcAt(center: center, radius: radius, startAngle: 0.0, endAngle: CGFloat.pi * 2)
}
var center: CGPoint
var radius: CGFloat
}
Circle
もやはり値型だ。
Diagram
これらを組み合わせてDiagram
をつくろう。
struct Diagram : Drawable {
func draw(_ renderer: Renderer) {
for f in elements {
f.draw(renderer)
}
}
var elements:[Drawable] = []
}
これも値型だ。他のDrawable
も全部値型なので、これも値型にする。
テスト
次に、テストしてみよう。
var circle = Circle(center: CGPoint(x: 187.5, y: 133.5),
radius: 93.75)
var triangle = Polygon(corners: [
CGPoint(x: 187.5, y: 227.25),
CGPoint(x: 268.69, y: 86.625),
CGPoint(x: 106.31, y: 86.625)])
var diagram = Diagram(elements: [circle, triangle])
diagram.draw(Renderer())
テスト結果:
arcAt(center: (187.5, 133.5), radius: 93.75, startAngle: 0.0, endAngle:6.283185307179586)
moveTo(106.31, 86.625)
lineTo(187.5, 227.25)
lineTo(268.69, 86.625)
lineTo(106.31, 86.625)
うまくいった。。。これでも想定通りに動いていることはわかる。
プロトコル指向で機能を拡張する
やっぱり、本当に描画したほうが分かりやすいので、Renderer
をプロトコル指向を使って、CUI版からGUI版に書き直そう。
Renderer
クラスをプロトコルに変えて、元のRenderer
をTestRenderer
に変えておく。
protocol Renderer {
func moveTo(_ p: CGPoint) }
func lineTo(_ p: CGPoint) }
func arcAt(center: CGPoint, radius:CGFloat, startAngle:CGFloat, endAngle: CGFloat)
}
struct TestRenderer :Renderer {
func moveTo(_ p: CGPoint) { print("moveTo(\(p.x), \(p.y))") }
func lineTo(_ p: CGPoint) { print("lineTo(\(p.x), \(p.y))") }
func arcAt(center: CGPoint, radius:CGFloat, startAngle:CGFloat, endAngle: CGFloat) {
print("arcAt(center: \(center), radius: \(radius), startAngle: \(startAngle), endAngle:\(endAngle))")
}
}
そして、本当に描画するRenderer
を、直接CGContextにextensionで実装しよう。こうすると、GUI版のために新しい型をつくったりしなくてよい。Renderer
がプロトコルだからこそこういう芸当ができる。
extension CGContext : Renderer {
func moveTo(_ p: CGPoint) {
move(to: p)
}
func lineTo(_ p: CGPoint) {
self.addLine(to: p)
}
func arcAt(center: CGPoint, radius:CGFloat, startAngle:CGFloat, endAngle: CGFloat) {
addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
}
}
テストしよう。Playgroundに表示させる。
let circle = Circle(center: CGPoint(x: 187.5, y: 133.5), radius: 93.75)
let triangle = Polygon(corners: [
CGPoint(x: 187.5, y: 227.25),
CGPoint(x: 268.69, y: 86.625),
CGPoint(x: 106.31, y: 86.625)])
let diagram = Diagram(elements: [circle, triangle])
UIGraphicsBeginImageContext(CGSize(width: 400, height: 300))
let context = UIGraphicsGetCurrentContext()
context?.setLineWidth(2.0)
context?.setStrokeColor(UIColor.white.cgColor)
diagram.draw(context!)
let image = context?.makeImage()
UIGraphicsEndImageContext()
var imageView = UIImageView(image: UIImage(cgImage: image!))
imageView.backgroundColor = UIColor.black
PlaygroundPage.current.liveView = imageView
値型である事の利用例
試しにDiagram
をネストしてみる。
diagram.elements.append(diagram)
値型なので、無限に再帰したりはしない。
GUI表示には変わりがないが、TestRenderer
で出力をみれば、2回繰り返されていることがわかる。
プロトコル指向で機能のバリエーションを作る
GUI版でも2回繰り返されていることがわかるように、スケールを変えられる機能を追加してみよう。
struct ScaledRenderer : Renderer {
let base: Renderer
let scale: CGFloat
func moveTo(_ p: CGPoint) {
base.moveTo(CGPoint(x: p.x * scale, y: p.y * scale))
}
func lineTo(_ p: CGPoint) {
base.lineTo(CGPoint(x: p.x * scale, y: p.y * scale))
}
func stroke() {
base.stroke()
}
func arcAt(center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
base.arcAt(center: CGPoint(x: center.x * scale, y:center.y * scale), radius: radius * scale, startAngle: startAngle, endAngle: endAngle)
}
}
struct Scaled<T: Drawable> : Drawable {
var scale: CGFloat
var subject: T
func draw(_ renderer: Renderer) {
subject.draw(ScaledRenderer(base: renderer, scale: scale))
}
}
上記のアダプタについて説明はしないが、クラスと同じような事がプロトコルでできる事のデモになっている。よくあるアダプタデザインパターンの例だ。
スケール変換用のアダプタを作ったので、それでDiagram
をラップして大きさを変えたものを追加するようにしてみる。
diagram.elements.append(Scaled(scale: 0.3, subject: diagram))
2つあることが見えるようになった。
プロトコルとジェネリクスのテスト性
特定のRenderer
からドキュメントモデルを分離したことで、追加が簡単になった。
プロトコルで分離を進めると、テストしやすくなる。
テストはモックで行うものに似ているが、もっと良いものだ。モックは元々壊れやすい。テストコードとテスト対象の実装が結びつく。Swiftの強い静的型システムと壊れやすいモックはうまく合わない。これまで見せたようにプロトコルは必要なインターフェースを提供してくれるだけでなく、追加も容易だ。
プロトコルエクステンションと共有実装
描画エレメントとしてBubble
を追加してみよう。
struct Bubble : Drawable {
var center: CGPoint
var radius: CGFloat
func draw(_ renderer: Renderer) {
renderer.arcAt(center: center, radius: radius, startAngle: 0.0, endAngle: CGFloat.pi * 2)
renderer.arcAt(center: CGPoint(x: center.x + radius / 2, y: center.y - radius / 2), radius: radius * 0.3, startAngle: 0.0, endAngle: CGFloat.pi * 2)
}
}
またarcAtで円を書いた。同じコードを何回も書いているので、円を描くメソッドをRenderer
プロトコルに追加して再利用しよう。
protocol Renderer {
func moveTo(_ p: CGPoint)
func lineTo(_ p: CGPoint)
func stroke()
func arcAt(center: CGPoint, radius:CGFloat, startAngle:CGFloat, endAngle: CGFloat)
func circleAt(center: CGPoint, radius: CGFloat) // 追加
}
extension Renderer { // 追加
func circleAt(center: CGPoint, radius: CGFloat) {
arcAt(center: center, radius: radius, startAngle: 0, endAngle: CGFloat.pi * 2)
}
}
Renderer
プロトコルにcircleAt
の定義を追加する。
さらに、プロトコルエクステンションで、全部のRenderer
適用モデルが共有する共有実装を提供できる。
Renderer
プロトコルに追加せず、extensiton
だけ提供する事もできる。この場合、動的ディスパッチが起こらなくなる。
Renderer
プロトコルに追加すれば、それは変更ポイントとして機能するので、プロトコルを適用したそれぞれのモデルで内容を変更できるようになる。
必ずプロトコルに変更ポイントを追加したほうがいいかというと、そうでもない。プロトコルを適用したモデルで変更する必要がない場合は、共有実装のみでもかまわない。
Swift Standard Libraryではこれを魔法みたいに使っている。
プロトコルのwhere節
(動画ではindexOfメソッドの例が出ていますがSwift Standard Libraryにはないので、実際にあるCollectionプロトコルの定義を例示しています)
プロトコルエクステンションにwhere
をつけて、デフォルト実装を適用するための前提条件を指定する事ができる。
extension Collection where Self.Element : Equatable {
func split(separator: Self.Element, maxSplits: Int = Int.max, omittingEmptySubsequences: Bool = true) -> [Self.SubSequence]
func firstIndex(of element: Self.Element) -> Self.Index?
}
プロトコルエクステンションの応用
最初のOrdered
の例に戻って、binarySearch
をInt
やString
でも使えるようにしてみよう。
以下はダメな実装例だ。
extension Int : Ordered {
func precedes(other: Int) -> Bool {
return self < other
}
}
binarySearch(sortedKeys: [0,2,4,6,8], forKey: 3)
extension String : Ordered {
func precedes(other: String) -> Bool {
return self < other
}
}
binarySearch(sortedKeys: ["0","2","4","6","8"], forKey: "3")
流石に、同じような実装を型の数だけ繰り返すのはナンセンスだ。他に良い方法があるはずだ。
プロトコル指向における正しい拡張
"<"はComparable
プロトコルで提供されている。そこで、Comparable
プロトコルにOrdered
をデフォルト実装として提供する。そして、Int
やString
をextensionでOrdered
に対応させる事で、実装を一つにまとめる事ができる。
extension Ordered where Self : Comparable {
func precedes(other: Self) -> Bool {
return self < other
}
}
extension Int : Ordered {}
extension String : Ordered {}
これなら、Double
に対応したかったらextension Double: Ordered {}
を追加するだけでよい。
Ordered
じゃなくて、Comparable
にprecedes
を直接拡張してしまえば、Ordered
そのものが必要なくなるが、どうだろうか。
その場合、例えば以下がビルドできるようになってしまう。
3.14.precedes(98.6) // true
Ordered
をwhere
節で制約をつけて拡張することで、このような意図しない動作を防げる。
Standard Libraryで、プロトコルエクステンションによるいろんな機能の提供がどうなされているか、みてみる事をお勧めする。例えばOptionSet
とか。
プロトコルの2つの世界の橋渡し
Self-requirementの有無で異なる動作をブリッジする例を示す。
まず、Polygon
等は値型なので、Equatable
プロトコルに対応させる。
値型は全部Equatable
に適合させるのが良い。その理由は別のセッション[Swiftで値型でより良いアプリを作る(Building Better Apps with Value Types in Swift)]
(https://qiita.com/eytyet/items/75e6c839d675c442d493)で詳しく説明する。
func == (_ lhs: Polygon, _ rhs: Polygon) -> Bool {
return lhs.corners == rhs.corners
}
extension Polygon : Equatable {}
func == (_ lhs: Circle, _ rhs: Circle) -> Bool {
return lhs.center == rhs.center && lhs.radius == rhs.radius
}
extension Circle : Equatable {}
それぞれの型の==
関数を定義して、プロトコルエクステンションでEquatable
プロトコルに適応させてみよう。ここまではいいが、同じ事をDiagram
にはできない。
func == (_ lhs: Diagram, _ rhs: Diagram) -> Bool {
return lhs.elements == rhs.elements // コンパイルエラー
}
「'(Self.Type) -> (Self, Self) -> Bool' requires that Drawable
conform to Equatable
.」とエラーが出る。Drawable
をEquatable
に適合させてくれと言っている。
protocol Drawable : Equatable { // Equatableに適合させてみる
func draw(_ renderer: Renderer)
}
struct Diagram : Drawable {
func draw(_ renderer: Renderer) {
for f in elements {
f.draw(renderer)
}
}
var elements:[Drawable] = [] // エラー。同一種でないといけない。
}
今度はDiagram
でエラーが出る。Equatable
はSelf-Requirementなので、それに適合させるとDrawable
もSelf-Requirementになる。その結果、elements
に混合リストを持たせる事ができなくなり、単一種しか入れられなくなる。Self-Requirementの場合、リストは単一種でなければならず、メソッドも静的に割り当てられるためだ。
Diagram
はDrawable
の混合種を扱う必要がある。Polygon
やCircle
を混ぜて扱い、それぞれのメソッドを動的に呼び出したい。そのためには、Self-requirementであってはならず、つまりDrawable
をEquatable
に適合させることはできない。
そこで、このようにする。
protocol Drawable { // Equatableには対応させない
func isEqualTo(_ drawable:Drawable) -> Bool // 要求追加
func draw(_ renderer: Renderer)
}
extension Drawable where Self : Equatable { // 追加
func isEqualTo(_ other: Drawable) -> Bool {
if let o = other as? Self { return self == o }
return false
}
}
struct Polygon : Drawable, Equatable {...}
struct Circle : Drawable, Equatable {...}
Drawable
は、Equatable
に対応させない。代わりに、isEqualTo
要求を追加する。これでDrawable
は全部の種類の比較に対応しなくてはならなくなった。
幸い、'=='は特別で、違う種類であればそれをもってfalse
だとすることができるので、単一の共通処理を提供できる。extension
として、Drawable
がEquatable
でもあるときだけ、この共通処理を使うようにした。自分を示すSelf
にダウンキャストして、うまくいったらEquatable
の提供する==
で比較し、そうでないならfalse
と判定する。
Circle
やPolygon
はEquatable
に対応させる。これで、isEqualTo
のデフォルト実装がCircle
やPolygon
に適用され、いちいち定義する必要はない。
Diagram
はEquatable
にできないので、独自のisEqualTo
を実装しよう。Self
ではなくDiagram
にダウンキャストしている。
また、==
も独自に提供する。
struct Diagram : Drawable {
func isEqualTo(_ other: Drawable) -> Bool {
if let o = other as? Diagram { return o == self }
return false
}
func draw(_ renderer: Renderer) {
for f in elements {
f.draw(renderer)
}
}
var elements:[Drawable] = []
}
func == (lhs: Diagram, rhs: Diagram) -> Bool {
return lhs.elements.count == rhs.elements.count
&& !zip(lhs.elements, rhs.elements).contains(where: { !$0.isEqualTo($1) })
}
単一種でいくか、複数種でいくか、静的な世界と動的な世界のどちらでいくかは、設計上の魅力的なところだ。もっと深く見てみてほしい。
'=='のような特殊ケースと同じ条件でなくても、複数種を扱うことはできる。面白いやり方がいくつもある。
プロトコルベースで設計しよう。
クラスか、プロトコルか
クラスが必要な局面ももちろんある。
暗黙の共有が必要な時がある。例えば、値のコピーなどの値型の基本動作が意味をなさないような場合だ。コピーの意味は何かを考えてみて、それが意味をなさないような場合は、本当に参照型が必要だろう。
または、比較。比較が意味をなさない場合も同様だ。これも値型の基本のところだ。
例えば、Window
をコピーしたいだろうか?ビュー階層にウィンドウは入らない。ウィンドウのコピーには意味がない。
他には、インスタンスのライフサイクルが他の物に縛られている場合がある。ディスク上のファイルとか。値はコンパイラが作って削除するものであり、可能な限り最適化される(ので無くなったりする)。参照型はもっと安定しているので外部に依存するものは参照型であるクラスの方がいい。
また、抽象化の実体がただの「流し台」である場合。例えばRenderer
がそうだ。Renderer
には「線を描け」などの情報を流し込んでいるだけだ。
仮に、コンソール出力の代わりにStringに溜め込むだけのStringRenderer
を作ったら、こんな感じになるだろう。
final class StringRenderer : Renderer {
var result:String
....
}
気づくことがいくつかある。まず、final
である。次に、ベースクラスがない。プロトコルを適用しているだけだ。
抽象化にはプロトコルを使っている。
この他にも、いくつか注意点がある。
フレームワークがオブジェクト指向を要求していたら、そうしよう。フレームワークが提供するベースクラスを使い、APIにはクラスを渡そう。システムとは戦わないこと。そうはいっても、クラスが大きくなりすぎるのを防ぐときにはプロトコルを考えて良い。リファクタリングでクラスの一部を切り出す場合、プロトコルに変えて値型を使うことを考えよう。
まとめ
- 抽象化には、プロトコルのほうがクラスより良い。
- プロトコルエクステンションは、魔法のように使える新機能だ。
- セッション"Building Better Apps with Value Types in Swift"も聞いてね。
後日値型のセッションも聞いて、記事にしようと思います。
記事を作成しました。
[Swiftで値型でより良いアプリを作る]
(https://qiita.com/eytyet/items/75e6c839d675c442d493)