最近、Swift 2で提唱されているProtocol Oriented Programmingの理解がちょっと自信なくて、色々記事見つつも今いち掴めた気がしなかったので、原典であろうWWDCでそれを提唱していたセッションを見たところ、かなりすっきりした気がします。
セッション: Protocol-Oriented Programming in Swift - WWDC 2015 - Videos - Apple Developer
セッション冒頭は、クラス vs Protocolの話で知りたいこととちょっとズレていたのでそれはスキップしてます。
そのあたりは、Building Better Apps with Value Types in Swift - WWDC 2015 - Videos - Apple Developer の方が手厚そうなので、近々同じような感じで理解深めておきたいと思っています。
まずはイケてないコード
class Ordered {
func precedes(other: Ordered) -> Bool { fatalError("implement me!") } // 1
}
class Number: Ordered {
var value: Double = 0
override func precedes(other: Ordered) -> Bool {
return value < (other as! Number).value // 2
}
}
1. fatalError("implement me!")
がイケてない
特にSwiftは抽象クラスが無いので、こういう基底クラス的なものを作ると、イケてなさが目立ちますね。
2. other as! Number
でダウンキャストしている。
型の恩恵を殺しています(´・︵・`)
セッションでは、"code smell"という表現で、このコードのイケてなさが表現されています。
改善後
protocol Ordered { // 1
func precedes(other: Self) -> Bool // 2
}
struct Number: Ordered { // 3
var value: Double = 0
func precedes(other: Number) -> Bool { // 1
return value < other.value // 2
}
}
1. classからprotocolに変更
実装を抜くことが可能になります。
さらにNumberでのメソッド定義も、overrideは不要となります。
2. 受け取る型をSelf
に
Numberの実装で、Number型で受けられるようになり、ダウンキャストが無くなりました。
3. structに変更
これは明確な理由が述べられて無さそうに聞こえました。
冒頭で、参照型だとバグが生まれやすいなどの理由で、値型を推していたのでその一貫な気がします。
バイナリサーチ関数に使う例
func binarySearch(sortedKeys: [Ordered], forKey k: Ordered) -> Int { // 1
var lo = 0
var hi = sortedKeys.count
while hi > lo {
let mid = lo + (hi - lo) / 2
if sortedKeys[mid].precedes(k) { // 2
lo = mid + 1
} else {
hi = mid
}
}
return lo
}
コンパイルエラー発生
一見良い例に見えますが、まだこのままではコンパイルエラーが発生します。
1: error: protocol 'Ordered' can only be used as a generic constraint because it has Self or associated type requirements
2: error: member 'precedes' cannot be used on value of protocol type 'Ordered'; use a generic constraint instead
見ての通り、OrderedプロトコルのprecedesメソッドがSelfを受けることが原因とのことです。
なぜ、これがダメなのかというと、Orderedは、Number以外もあり得るからです。
struct Label: Ordered {
var value = ""
func precedes(other: Label) -> Bool {
return value < other.value
}
}
こういった、Labelというstructがあった時、それがsortedKeysに渡されると、LabelとNumberを比較することになってしまいます。
これは解決不能です。
ジェネリクスで解決
つまり、Orderedプロトコルを実装しているものであれば、何でも良いが、ただしそれは全て同じ型(セッションではhomogeniousと表現されている)である必要があります。
これは、このようにジェネリクスで表現出来ます。無事コンパイルエラーが解決しました( ´・‿・`)
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(k) {
lo = mid + 1
} else {
hi = mid
}
}
return lo
}
プロトコルで定義したメソッドの引数の型をSelfにするかどうかの違い
今のコンパイルエラーは、SelfにせずOrderedという具体的な型を指定していたら発生しませんでした。
それが良いことかという言うと逆で、型が入り交じった場合ダウンキャストのところで実行時エラーが発生します。
Self
指定としたことでそれをコンパイルエラーによって未然に防げるようになったわけです。
セッションのこの表がとても分かりやすいです。
図形を描くコードを例にとった説明
まず初めのコード
// 図形などの定義
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), radius: \(radius), startAngle: \(startAngle), endAngle: \(endAngle))")
}
}
protocol Drawable {
func draw(renderer: Renderer)
}
struct Polygon: Drawable {
func draw(renderer: Renderer) {
renderer.moveTo(corners.last!)
for p in corners {
renderer.lineTo(p)
}
}
var corners: [CGPoint] = []
}
struct Circle: Drawable {
func draw(renderer: Renderer) {
renderer.arcAt(center, radius: radius, startAngle: 0, endAngle: CGFloat(M_PI * 2))
}
var center: CGPoint
var radius: CGFloat
}
struct Diagram: Drawable {
func draw(renderer: Renderer) {
for f in elements {
f.draw(renderer)
}
}
var elements: [Drawable] = []
}
// テストコード
var circle = Circle(center: CGPoint(x: 187.5, y: 333.5), radius: 93.75)
var triangle = Polygon(corners: [
CGPoint(x: 187.5, y: 427.25),
CGPoint(x: 268.69, y: 286.625),
CGPoint(x: 106.31, y: 286.625)
])
var diagram = Diagram(elements: [circle, triangle])
diagram.draw(Renderer())
こういうログが吐かれて、円と三角形が描かれているようなことが何となく分かる
arcAt((187.5, 333.5), radius: 93.75, startAngle: 0.0, endAngle: 6.28318530717959)
moveTo(106.31, 286.625)
lineTo(187.5, 427.25)
lineTo(268.69, 286.625)
lineTo(106.31, 286.625)
実際の描画が見たい、と言われて変更
というわけで変更してみましょう。
まずは、Rendererをprotocolに変えて実装を削って、代わりに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), radius: \(radius), startAngle: \(startAngle), endAngle: \(endAngle))")
}
}
さらに、実行部分はこうすると、とりあえず先ほどの挙動は維持出来ました。
diagram.draw(TestRenderer())
さらに、実際に描画してみましょう。
Rendererを実装したCGContextを定義するだけで簡単に出来てしまいます。
protocol orientedの強力さが分かった気がします( ´・‿・`)
extension CGContext: Renderer {
func moveTo(p: CGPoint) {
CGContextMoveToPoint(self, p.x, p.y)
}
func lineTo(p: CGPoint) {
CGContextAddLineToPoint(self, p.x, p.y)
}
func arcAt(center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
let arc = CGPathCreateMutable()
CGPathAddArc(arc, nil, center.x, center.y, radius, startAngle, endAngle, true)
CGContextAddPath(self, arc)
}
}
実行はこうです。
showCoreGraphicsDiagram("title", size: CGSizeMake(400, 600)) { diagram.draw($0) }
PlaygroundでのCGContext描画のために、 https://developer.apple.com/sample-code/wwdc/2015/downloads/Crustacean.zip のヘルパーメソッドも必要です。
他のの図形を表現可能とするためにRendererを拡張
指定箇所に円を描くcircleAt
の定義を足します。
protocol Renderer {
func moveTo(p: CGPoint)
func lineTo(p: CGPoint)
func arcAt(center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat)
func circleAt(center: CGPoint, radius: 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), radius: \(radius), startAngle: \(startAngle), endAngle: \(endAngle))")
}
func circleAt(center: CGPoint, radius: CGFloat) {
arcAt(center, radius: radius, startAngle: 0, endAngle: CGFloat(M_PI * 2))
}
}
extension CGContext: Renderer {
func moveTo(p: CGPoint) {
CGContextMoveToPoint(self, p.x, p.y)
}
func lineTo(p: CGPoint) {
CGContextAddLineToPoint(self, p.x, p.y)
}
func arcAt(center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
let arc = CGPathCreateMutable()
CGPathAddArc(arc, nil, center.x, center.y, radius, startAngle, endAngle, true)
CGContextAddPath(self, arc)
}
func circleAt(center: CGPoint, radius: CGFloat) {
arcAt(center, radius: radius, startAngle: 0, endAngle: CGFloat(M_PI * 2))
}
}
TestRenderer
とCGContext
に、circleAt
のコピペ実装が発生してしまいました(´・︵・`)
ここでSwift 2のProtocol Extensions
このようにRenderを拡張してcircleAt
を実装しましょう。
protocol Renderer {
func moveTo(p: CGPoint)
func lineTo(p: CGPoint)
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, radius: radius, startAngle: 0, endAngle: CGFloat(M_PI * 2))
}
}
TestRenderer
とCGContext
に、circleAt
のコピペ実装は削除しましょうʕ ·ᴥ·ʔ
すっきりしました( ´・‿・`)
Swiftは抽象クラス無いですが、それをprotocol
とprotocol extension
の組み合わせですっきり実現されていると、僕は感じています。
抽象クラスがある言語だと、Interface使うか抽象クラス使うべきか迷う、みたいな声を良く聞きますが、Swiftだと
- Protocolでインターフェースを定義
- 必要ならProtocolを拡張してデフォルト実装を埋め込む
という感じですっきり解決出来ます。
そこの役割が言語レベルで明確に分けられているのが筋が良いと思います。
CollectionTypeのindexOfメソッドを実装する例
コンパイルエラーになってしまうもの
extension CollectionType {
public func indexOf(element: Generator.Element) -> Index? {
for i in indices {
if self[i] == element { // エラー
return i
}
}
return nil
}
}
error: binary operator '==' cannot be applied to two 'Self.Generator.Element' operands
というエラーが発生してしまいました(´・︵・`)
解決( ´・‿・`)
where Generator.Element: Equatable
の拘束条件を付けることで、Generator.Element
がEquatable
(==
評価可能なもの)に限定でき、上記のコンパイルエラーが解決します。
extension CollectionType where Generator.Element: Equatable {
public func indexOf(element: Generator.Element) -> Index? {
for i in indices {
if self[i] == element {
return i
}
}
return nil
}
}
バイナリサーチの例に戻ってProtocol Extensionsを活用
binarySearch
を呼んでみる
let position = binarySearch([1, 2, 3, 4], forKey: 2)
expected an argument list of type '([T], forKey: T)'
のエラーが出ます。
IntはOrderedプロトコルに従ってないので、仕方ないですね(´・︵・`)
Intを拡張すれば解決します( ´・‿・`)
extension Int: Ordered {
func precedes(other: Int) -> Bool {
return self < other
}
}
この勢いでString
も拡張しちゃいましょう( ´・‿・`)
extension String: Ordered {
func precedes(other: String) -> Bool {
return self < other
}
}
let position = binarySearch(["1", "2", "3", "4"], forKey: "2")
なんか、イヤな感じがしてきましたね(´・︵・`)
重複実装を無くしましょう
IntやStringはComparableを実装しているので、このように定義すれば、Int・StringはOrderedにさえ従っていれば良いです。
かなりマシになりましたね。
extension Comparable {
func precedes(other: Self) -> Bool {
return self < other
}
}
extension Int: Ordered {}
extension String: Ordered {}
Orderedに従う必要ないのでは?
そもそも、Comparable
に従っているものがfunc precedes(other: Self) -> Bool
を実装していることが宣言されているならば、extension Int: Ordered {}
などの記述は冗長なのでは?とも思ってきます。
しかし、extension Int: Ordered {}
を削るとコンパイルエラーになります。
error: cannot invoke 'binarySearch' with an argument list of type '([Int], forKey: Int)'
Swiftは、構造的部分型(ダックタイピングに近いけどコンパイル時に確定するものみたいな感じ)の発想は取り入れていないということだと思います。
ちぐはぐな挙動を改善
例えば、DoubleもComparableを実装しているので、これはコンパイルが通ります。
let truth = 3.14.precedes(98.6)
一方、こちらは
let position = binarySearch([1.0, 2.0, 3.0, 4.0], forKey: 2.0)
error: cannot invoke 'binarySearch' with an argument list of type '([Double], forKey: Double)'
というコンパイルエラーになります。
そもそもDoubleにprecedes
メソッドを生やす気が無いなら、Comparableの拡張を限定的にしましょう。
このように定義すれば、let truth = 3.14.precedes(98.6)
もコンパイルエラーになり、Doubleに意図しないメソッドを生やさずに全体的に整合感のある挙動になります。
extension Ordered where Self: Comparable {
func precedes(other: Self) -> Bool {
return self < other
}
}
より厳密な指定に
普通にジェネリクスで厳密に型を指定するとこうなるようです。
func binarySearch<
C: CollectionType where C.Index == RandomAccessIndexType,
C.Generator.Element: Ordered
>(sortedKeys: C, forKey k: C.Generator.Element) -> Int {
// ...
}
かなり見にくいですが、Protocol Extensionを活用すると、少し目に優しくなります( ´・‿・`)
extension CollectionType where Index == RandomAccessIndexType,
Generator.Element: Ordered {
func binarySearch(forKey k: C.Generator.Element) -> Int {
// ...
}
}
// 呼び出しもメソッドライクに( ´・‿・`)
let position = [1, 2, 3, 4].binarySearch(2)
かなり"Protocol Oriented"な雰囲気がしてきました( ´・‿・`)
※: おそらくWWDCの時からの仕様変更でRandomAccessIndexType
部分でコンパイルエラー(error: same-type constraint type 'RandomAccessIndexType' does not conform to required protocol 'ForwardIndexType'
)が発生します。原因分かったら反映します。
プロトコルを実装するとたくさんのデフォルト実装がくっついてくる
例えば、このようにOptionalSetTypeを実装すると、こういうSet系のメソッドが付いてきます。
これらはProtocol Extensionsで実装されたメソッド群です。
Swiftの標準ライブラリのようにうまく設計すると最小手数で既存実装の恩恵得られるようになるということです。
ちなみに、OptionSetTypeはこういう定義ですが、RawRepresentableで定義されたデフォルト定義の無いpublic var rawValue: Self.RawValue { get }
を満たすために、上のような実装となっているようです。
public protocol OptionSetType : SetAlgebraType, RawRepresentable {
/// An `OptionSet`'s `Element` type is normally `Self`.
typealias Element = Self
/// Convert from a value of `RawValue`, succeeding unconditionally.
public init(rawValue: Self.RawValue)
}
Equatableサポート
この後、図形を描くコードを例にとった説明で定義したstructの等価比較についての話になりましたが、ここはなかなか難しい + ボリューミーでややこしいので、ちょっと力尽きてしまいました。
プロトコルで定義したメソッドの引数の型をSelfにするかどうかの違いで書いた内容に関連があるのですが、型が厳密なのでイコール比較の際にそれを理解した上でちょっとした回避策を取る必要のある場面がある、というような内容です。
追記するかもしれませんが、とりあえずセッションご覧下さい。
関連
-
Protocol-Oriented Programming in Swift
- この記事書いた後に見つけましたが、ほぼ同内容の英語記事です。