Edited at

WWDC 2015「Swiftでプロトコル指向プログラミング」


はじめに

いまさらですが...

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関数で挿入位置を示せる事を目指す。


OOP_Version

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
}
}

ちょっと待って。otherLabelだったら?

もう罠にはまっている。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
}
}

まずはOrderedclassからprotocolに変える。

precedesメソッドの実装はコンパイラがエラーにするので削除する。これにはprecedesの呼び出しが動的ランタイムチェックから静的割り当てに変わるという利点がある。

precedesoverrideは何もオーバーライドしなくなったので消す。

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型の混合種のリストを扱うという宣言である。つまり、NumbersLabelを一緒に混ぜたリストを扱うつもりだ。

ところが、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クラスをプロトコルに変えて、元のRendererTestRendererに変えておく。

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の例に戻って、binarySearchIntStringでも使えるようにしてみよう。

以下はダメな実装例だ。

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をデフォルト実装として提供する。そして、IntStringを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じゃなくて、Comparableprecedesを直接拡張してしまえば、Orderedそのものが必要なくなるが、どうだろうか。

その場合、例えば以下がビルドできるようになってしまう。

3.14.precedes(98.6)  // true

Orderedwhere節で制約をつけて拡張することで、このような意図しない動作を防げる。

Standard Libraryで、プロトコルエクステンションによるいろんな機能の提供がどうなされているか、みてみる事をお勧めする。例えばOptionSetとか。


プロトコルの2つの世界の橋渡し

Self-requirementの有無で異なる動作をブリッジする例を示す。

まず、Polygon等は値型なので、Equatableプロトコルに対応させる。

値型は全部Equatableに適合させるのが良い。その理由は別のセッションSwiftで値型でより良いアプリを作る(Building Better Apps with Value Types in Swift)で詳しく説明する。

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.」とエラーが出る。DrawableEquatableに適合させてくれと言っている。

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の場合、リストは単一種でなければならず、メソッドも静的に割り当てられるためだ。

DiagramDrawableの混合種を扱う必要がある。PolygonCircleを混ぜて扱い、それぞれのメソッドを動的に呼び出したい。そのためには、Self-requirementであってはならず、つまりDrawableEquatableに適合させることはできない。

そこで、このようにする。

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として、DrawableEquatableでもあるときだけ、この共通処理を使うようにした。自分を示すSelfにダウンキャストして、うまくいったらEquatableの提供する==で比較し、そうでないならfalseと判定する。

CirclePolygonEquatableに対応させる。これで、isEqualToのデフォルト実装がCirclePolygonに適用され、いちいち定義する必要はない。

DiagramEquatableにできないので、独自の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で値型でより良いアプリを作る