概要
Effective Java 第四章「継承よりもコンポジションを選ぶ」を読んで、
この問題を踏まえて、SwiftのProtocol Extensionをどのように扱ったら良いかを考えたので書き留めていきます。
継承よりコンポジション
継承による問題の例
例えば追加された要素を保持しておくMySet
クラスがあったとします。
要素を一個だけ追加したり、複数個追加できる機能があります。
/// Setをラップした自作クラス
class MySet {
typealias Element = Int
private(set) var internalSet = Set<Element>()
/// 渡された値を追加する
func myInsert(_ element: Element) {
self.internalSet.insert(element)
}
/// 渡された複数の値を追加する
func myInsert(multi elements: [Element]) {
elements.forEach { self.myInsert($0) }
}
}
このクラスを継承して、要素が追加された回数を記録できるInstrumentsSet
をさらに追加しました。
final class InstrumentsSet: MySet {
/// 追加された回数をカウント
private(set) var addCount: Int = 0
override func myInsert(_ element: MySet.Element) {
// ❗️追加される要素の数だけカウントアップしたい
self.addCount += 1;
super.myInsert(element)
}
override func myInsert(multi elements: [MySet.Element]) {
// ❗️追加される要素の数だけカウントアップしたい
self.addCount += elements.count
super.myInsert(multi: elements)
}
}
ですが、InstrumentsSet
を動作をさせてみると意図していない動作になりました。
var set = InstrumentsSet()
set.myInsert(multi: [1, 2, 3, 4, 5])
// ❗️❓追加された要素は5個なので5と表示されて欲しい
print(set.addCount) // 10と表示される
func myInsert(multi elements: [MySet.Element])
で追加した要素の数だけ、addCount
が増えて欲しかったのですがそうはなりませんでした。
問題調査のため親クラスの実装をよく見てみると以下のようになっていました。
/// Setをラップした自作クラス
class MySet {
typealias Element = Int
private(set) var internalSet = Set<Element>()
/// 渡された値を追加する
func myInsert(_ element: Element) {
self.internalSet.insert(element)
}
/// 渡された複数の値を追加する
func myInsert(multi elements: [Element]) {
elements.forEach {
// ⛔️func myInsert(_ element: Element)を呼び出している
self.myInsert($0)
}
}
}
親クラスのfunc myInsert(multi elements: [MySet.Element])
の中でfunc myInsert(_ element: Element)
を呼び出していたため、
子クラスのaddCount
の値が予期しないタイミングで書き換えられていたのです。
今回の例では、子クラスのInstrumentsSet
は親クラスであるMySet
の実装内容を把握して実装をしなければいけません。
この状態では子クラスは親クラスの実装の詳細に依存しており、親クラスの実装とその変更を常に気にする必要があります。
コンポジションによる問題の解決
これを解決する方法としてコンポジションによる解決が解説されていました。
早速適用をしてみましょう
親クラスだったMySet
をプロパティにとり、InstrumentsSet
のメソッドから MySet
のメソッドに転送しています。
/// 追加された
final class InstrumentsSet {
private(set) var addCount: Int = 0
// MySetをプロパティとしてとる
private let mySet = MySet()
func myInsert(_ element: MySet.Element) {
self.addCount += 1;
// MySetのプロパティを更新
self.mySet.myInsert(element)
}
func myInsert(multi elements: [MySet.Element]) {
self.addCount += elements.count
self.mySet.myInsert(multi: elements)
}
}
プロパティにMySet
をとることでInstrumentsSet
のメソッドを予期しないタイミングで呼び出されることが無くなりました。
これで子クラスは親クラスの実装に依存することがなくなりました。
前述した継承で発生した問題はこれで解決できます。
問題について再考する
本の中ではこの継承から起こる問題を以下のように表現していました。
継承によってカプセル化が破られ、子クラスが親クラスの実装の詳細に依存している。
ここで言われている「カプセル化を破る」とはどういうことなのかについて、
自分なりに考えてみます。
カプセル化とは
まずはカプセル化について考えていきます。
私が知っているカプセル化の特徴は以下の三つです。
- クラスは外部には公開する必要のあるインターフェースを定義して、他とのクラス間の相互依存を最小限にできる
- 外部に公開したインターフェースの互換性を保っていれば、クラス内部の実装を変更しても利用者に影響を与えない
- クラスの利用者は外部に公開したインターフェースを通してクラスのインスタンスを操作できる
ここでいうクラスはSwiftでのclass
ではなくオブジェクト指向での広義の概念を指し、Swiftのstruct
なども含みます。
以降の「クラス」はこの記事では概念の方を指します。
「カプセル化を破る」とは何を指すのか?
カプセル化の特徴を踏まえたときに、継承によって「カプセル化を破る」を指すものは以下を考えています。
- インターフェースの互換性を保って内部を実装しても、利用者に影響を与えてしまう。
クラスの内部実装を変更した際に別のクラスの実装も変更が必要になるのはSOLID原則のOCPに違反と似ていそうです。
Swiftではどうなるか?
プロトコル指向を採用しているSwiftでは、
継承とカプセル化の問題はどうなっているかについて考えていきます。
プロトコル指向の採用モチベーションとして、クラス継承による問題を解決したいのが理由の一つのはずです。
2015のWWDCのセッションの中でもクラス継承の問題について話されていました。
(このビデオは現在リンク先が開ませんでした )
Protocol-Oriented Programming in Swift - WWDC 2015 - Videos - Apple Developer
https://developer.apple.com/videos/play/wwdc2015/408/
先ほどのMySet
の例をProtocolに実装しなおして問題を解決できるかを考えていきます。
プロトコルから考える
プロトコル指向に基づいてプロトコルから考えていきます。
先ほどの例に出ていたMySet
をProtocolとProtocol Extensionで実装し直します。
「この例を継承で実装するのが正しいか」については「継承とカプセル化の問題」という考えたい主題とは別だと思うので置いておかせてください。
MySet
をProtocolで実装しなおします。
/// Setをラップした自作Protocol
protocol MySet {
typealias Element = Int
var internalSet: Set<Element> { get set }
}
extension MySet {
/// 渡された値を追加する
mutating func myInsert(_ element: Element) {
self.internalSet.insert(element)
}
/// 渡された複数の値を追加する
mutating func myInsert(multi elements: [Element]) {
elements.forEach {
self.myInsert($0)
}
}
}
このProtocolを使ってInstrumentsSet
実装し直すと以下のようになります。
struct InstrumentsSet: MySet {
private(set) var addCount = 0
var internalSet: Set<Element> = []
mutating func myInsert(_ element: Element) {
addCount += 1
self.internalSet.insert(element)
}
mutating func myInsert(multi elements: [Element]) {
addCount += elements.count
elements.forEach { self.internalSet.insert($0) }
}
}
Protocol Extensionで実装したメソッドと同じメソッドをInstrumentsSet
のクラス内に定義した場合、
Protocolを継承したInstrumentsSet
のクラスはメソッドをoverride
できずsuper
としてProtocol Extensionの実装を呼び出せません。
もし機能追加をしたい場合はメソッドの再実装が必要になります。
よって継承元から継承先を呼び出されることはなくなりました。
Protocol ExtensionからInstrumentsSet
のクラス側のメソッドを呼び出す場合はProtocolに記載されたインターフェースを経由して呼び出されてい(るように見え)ます。
Protocol Extensionの実装とクラスの実装は互いにMySet
のインターフェースに依存しており、
メソッドのoverrideができないことから、MySet
とInstrumentsSet
それぞれのメソッドの実装は独立しています。
継承による問題が発生しておらず
「外部に公開したインターフェースの互換性を保っていれば、クラス内部の実装を変更しても利用者に影響を与えない」
を一見、満たしているように見えます。
本当にこれでいいのか?
よく考えてみると、internalSet
のプロパティの書き換えがクラス外からも可能になっています。
/// Setをラップした自作Protocol
protocol MySet {
// ❗️継承したクラスはsetterを公開しなければいけない
var internalSet: Set<Element> { get set }
}
MySet
にはinsert用のメソッドがわざわざ用意されています。
プロパティのinternalSet
のsetterのインターフェースを公開し、プロパティをクラス外部から好きに書き換えられる状態は相応しくなさそうです。
これはカプセル化の利点の以下の内容を損なっています。
クラスは外部には公開する必要のあるインターフェースを定義して、他とのクラス間の相互依存を最小限にできる
不要なインターフェースを公開することによって、このMySet
を継承したクラスは、
プロパティがクラス外から書き換えられることを気にしなければいけません。
公開するべきでないインターフェースを公開することでクラスの動作はクラスの利用者の実装に依存することになります。
よって、プロパティのinternalSet
のsetterは非公開とし、それに伴ってProtocol Extensionでのinsertのメソッド実装は避けた方が良さそうです。
Protocol Extensionの使い所に対する考察
カプセル化の利点を最大限活かすのであれば、
Protocolに記載する定義は最小限に抑えて不要な定義はしないのが好ましいです。
よってProtocol Extensionを実装するときの考え方の順番としては以下になります。
- Protocolには最小限のインタフェースを定義する。
- 定義されたインタフェースから実装可能なもののみをProtocol Extensionで実装する。
Protocolに記述するインターフェースには必要なものだけを定義することを優先して考えるべきであり、
Protocol Extensionで実装を共有するために、無理にプロパティやメソッドを公開するのはあまり良くなさそうです。
「必要なものだけを定義」という規則を守れれば、Protocolを継承したクラス外にProtocol Extensionをつかって実装をするのは「カプセル化を破る」問題が発生することは無いと考えています。
なぜなら[「カプセル化を破る」とは何を指すのか?](### 「カプセル化を破る」とは何を指すのか?)で前述した、
「インターフェースの互換性を保って内部を実装しても、利用者に影響を与えてしまう」ことが無くなりそうに見えるからです。
Protocolで定義されたインターフェースの互換性が保たれれば、
Protocol Extensionの実装はProtocolを継承したクラスのインターフェースの振る舞いに依存して、クラスの振る舞いを単純に拡張するものになれると考えています。
よってクラスの実装とProtocol Extensionの実装が、
インターフェースを通して互いに独立となってカプセル化が守られます。
対して、Protocol Extensionでの実装の共有で問題になるときは
- クラス間でProtocolに定義されたインターフェースに対する解釈の違いが起きている
- Protocol Extension内でのProtocolに定義されたインターフェースに対する解釈が誤っている
の二つの可能性があると考えています。
クラス間でのインターフェースの解釈に違いが生じているのであれば、
継承するProtocol自体が間違っていそうなので継承を見直す必要がありそうです。
Protocolに定義されたインターフェースの解釈をプログラム全体で統一できれば、カプセル化の利点を発揮しながら実装の共有をできるようになるのではないかと考えています。
感想
ずっと「カプセル化を破る」ということがどういうことなのかを理解しつつも言語化できず、
Protocolの継承とProtocol Extensionでは状況が変わるのではないかと考えていました。
そんな中カプセル化に関する話を少しだけ調べて、何かが分かった気がしたのでこの記事を書きました。
考えてみれば当たり前に言われていそうなことですが、
Protocol Extensionでの実装のためにインターフェースを公開するのはよくないのは言語化できました。
ただ、この考え方だけではうまくいかないパターンがあるのではないかとまだ不安でモヤモヤしています。
考察の内容も「思います」や「考えています」など、断定する自信がなかなか持てないです。
Effective Javaでもデフォルト実装(SwiftのProtocol Extensionと似たようなものの認識)を適用してもうまくいかないパターンがあることについて書かれています。
現実的も規模の大きいプロジェクトでProtocolの解釈を全体で統一できることは難しく、Protocol Extensionの使い所は難しそうだと年々思うようになりました。
今後はProtocol ExtensionやJavaのデフォルト実装のベストプラクティスについて言及されている書籍を調べたり、別の開発チームでの扱い方のルールについて聞いてみたいです。