Edited at

[Swift] classにEquatableを実装するのは一筋縄ではいかない(ことがある)点に注意。

More than 1 year has passed since last update.


はじめに

Swiftという言語にはProtocolというとても便利なシステムがあって、特にその中でもEquatableは最も使われているProtocolだと思うのです1

Equatableをオリジナルのクラスに適用するには、単に2つのインスタンスが同じ値(とみなせる)かどうかを判断する==というstatic funcさえ実装すればいいのです2が、openなクラスに実装する場合は多少工夫が必要です。この記事では、工夫が必要な場面とその工夫を紹介します。


実例


MyClassEquatableを適用する

次のようなMyClassを考えてみましょう


myclass.swift

open class MyClass {

public var a:Int
public init(a:Int) { self.a = a }
}

EquatableMyClassに適用するにあたって、MyClassのインスタンスは.aの値が同じなら同じであるとみなしましょう。


myclass-eq.swift

extension MyClass: Equatable {

public static func ==(lhs:MyClass, rhs:MyClass) -> Bool {
return lhs.a == rhs.a
}
}

ね?簡単でしょ。


test-myclass-eq.swift

let object1 = MyClass(a:0)

let object2 = MyClass(a:1)
let object3 = MyClass(a:0)

print(object1 == object2) // -> false
print(object1 == object3) // -> true



MyClassを継承したMySubclassを作ってみよう

MyClassを継承したMySubclassを定義します。新たなるインスタンス変数として.bを追加します。


mysubclass.swift

open class MySubclass: MyClass {

public var b: Int
public init(a:Int, b:Int) { self.b = b; super.init(a:a) }
}

MyClassEquatableを適用しているので、それを継承したMySubclass==演算子を使うことができます。が、しかし…


test-mysubclass.swift

let object4 = MySubclass(a:0, b:1)

let object5 = MySubclass(a:0, b:2)
let object6 = MySubclass(a:0, b:1)

print(object4 == object5) // -> true!!
print(object4 == object6) // -> true


当たり前と言えば当たり前なのですが、MyClassで定義した==演算子は.aというインスタンス変数しか比較をしていないので、.bの値が違ってもtrueを返します。サブクラスの役割としてそれでいい場合もあるかもしれませんが、ここでは.bという値も同一でないといけないということにしましょう。


mysubclass-eq.swift

extension MySubclass {

public static func ==(lhs:MySubclass, rhs:MySubclass) -> Bool {
return lhs as MyClass == rhs as MyClass && lhs.b == rhs.b
}
}

こうすることで、先ほどのprint(object4 == object5)は期待通りfalseと表示されるはずです。なお、今回の例では、lhs as MyClass == rhs as MyClassの部分はlhs.a == rhs.aとしても構いませんが、親クラスであるMyClassに比較すべきインスタンス変数がたくさんある場合や後から比較すべきインスタンス変数を追加した場合などを考えると、前者の方が読みやすさと保守性の点で優れているでしょう。


だが、これで終わりではない

ここまでのソースを振り返って見ましょう。


myclass-and-mysubclass.swift

open class MyClass {

public var a:Int
public init(a:Int) { self.a = a }
}
extension MyClass: Equatable {
public static func ==(lhs:MyClass, rhs:MyClass) -> Bool {
return lhs.a == rhs.a
}
}

open class MySubclass: MyClass {
public var b: Int
public init(a:Int, b:Int) { self.b = b; super.init(a:a) }
}
extension MySubclass {
public static func ==(lhs:MySubclass, rhs:MySubclass) -> Bool {
return lhs as MyClass == rhs as MyClass && lhs.b == rhs.b
}
}


MyClassでもMySubclassでもEquatableを適用できていて完璧にみえます。ですが、まだ落とし穴があります。

ここで、MyClassまたはその子孫クラスのインスタンスを保持するラッパーMyWrapperを登場させてみましょう。


mywrapper.swift

public struct MyWrapper {

public var object: MyClass
public init(object:MyClass) { self.object = object }
}

こうなるとMyWrapperEquatableにしたいですよね?さぁ、してみましょう。


mywrapper-eq.swift

extension MyWrapper: Equatable {

public static func ==(lhs:MyWrapper, rhs:MyWrapper) -> Bool {
return lhs.object == rhs.object
}
}

これでOKでしょうか?先ほどのobject1, 4, 5に再度登場してもらいましょう。


test-mywrapper.swift

let object1 = MyClass(a:0)

let object4 = MySubclass(a:0, b:1)
let object5 = MySubclass(a:0, b:2)

let wrapper1 = MyWrapper(object:object1)
let wrapper4 = MyWrapper(object:object4)
let wrapper5 = MyWrapper(object:object5)

print(wrapper1 == wrapper4) // -> true!!
print(wrapper4 == wrapper5) // -> true!!


はて?

object1,object4,object5はそれぞれ全てが違うと判断されるべきインスタンスですが、ラッパーで包んだ途端、同一とみなされてしまいました。

これも当たり前と言えば当たり前なのですが、インスタンス変数がpublic var object: MyClassと定義されているので、MyWrapperstatic func ==内にあるlhs.object == rhs.objectは、MyClass==が呼ばれてしまうのです。.objectMySubclassのインスタンスの場合はそれとして比較するためにはどうすればいいのでしょうか?


案①

MyWrapperの実装を工夫してみる。


mywrapper-eq-revised.swift

extension MyWrapper: Equatable {

public static func ==(lhs:MyWrapper, rhs:MyWrapper) -> Bool {
let (lobj, robj) = (lhs.object, rhs.object)
switch (type(of:lobj), type(of:robj)) {
// lobjとrobjのクラスが一致する場合のみ
// そのクラスのインスタンスとして比較
case let (ltype, rtype) where ltype == rtype:
if ltype == MySubclass.self {
return lobj as! MySubclass == robj as! MySubclass
} else {
return lobj == robj
}
// クラスが一致しない場合はfalse
default:
return false
}
}
}

これでMyWrapper==も期待通りに動作します。ただし、この場合、MyClassを継承する別のクラスやMySubclassをさらに継承するクラスを作ると、その都度この関数を修正する必要が生じます。即ち「MyWrapper.objectMyClassMySubclassのインスタンス以外ありえない」という状況でなければめんどくさいことになります。openなクラスでそれは…。


案②

MyClassMySubclassを継承する別のクラスを作る予定(作られる可能性)があるのであれば、作る人間にその責任を負わせることにしましょう。つまりMyWrapper==は最初のmywrapper-eq.swiftのままうまくいくように、今度はMyClassMySubclassのほうを弄ります。大改修(おおげさ)になるので、いっぺんにソース全体をお見せします。


myclass-mysubclass-mywrapper-revised.swift

open class MyClass: Equatable {

public var a:Int
public init(a:Int) { self.a = a }
public func isEqual(to another:MyClass) -> Bool {
return self.a == another.a
}
public static func ==(lhs:MyClass, rhs:MyClass) -> Bool {
return lhs.isEqual(to:rhs) && rhs.isEqual(to:lhs)
}
}

open class MySubclass: MyClass {
public var b: Int
public init(a:Int, b:Int) { self.b = b; super.init(a:a) }
public override func isEqual(to another:MyClass) -> Bool {
guard case let anotherInstance as MySubclass = another else {
return false
}
return super.isEqual(to:another) && self.b == anotherInstance.b
}
}

public struct MyWrapper: Equatable {
public var object: MyClass
public init(object:MyClass) { self.object = object }
public static func ==(lhs:MyWrapper, rhs:MyWrapper) -> Bool {
return lhs.object == rhs.object
}
}


いろいろ変わっていますが、変更点のキモとしては、MyClassisEqual(to:)というインスタンスメソッドを追加し、==からそのメソッドを呼ぶようにしたことです。そして、MySubclassには最早==というstatic funcはありません。その代わりに、isEqual(to:)をオーバライドしています。そしてMyWrapper==内では予定通りreturn lhs.object == rhs.objectしかありません。なぜ、これでうまくいくのでしょうか?

それは、次のようなコードを実行してみるとよく分かるかと思います。

class A {

func f() { print("I am A.") }
}
class B: A {
override func f() { print("I am B.") }
}
let object: A = B()
(object as A).f()

なんと表示されましたか?

let object: A = B()objectの型はAであると宣言し、f()というインスタンスメソッドを実行する時にも(object as A).f()objectAだと散々言い聞かせたにもかかわらず、表示されたのは無情にも

I am B.

もちろんオブジェクト指向としては至極当然の動作です。objectB()と生成されているので、Bのインスタンスです。(object as A).f()としたところで、実行時にBのインスタンスメソッドが呼び出されることになります。

案②ではこの動作を利用しています。MyWrapperにおけるlhs.object == rhs.objectではMyClassのstatic funcである==が呼び出されるのは変更前と変わりませんが、その中ではisEqual(to:)というインスタンスメソッドが呼び出されています。となると、I am B.の時と同じように、lhs(またはrhs)がMyClassとして渡されたとしてもそれがMySubclassのインスタンスであれば、ちゃんとMySubclassisEqual(to:)が呼び出されるのです。仮にMyClassに別のサブクラスを作成したとしても、そのクラスのisEqual(to:)だけ実装すれば問題は生じません3

この実装では、EquatbleにおけるSymmetry4を保証するために、MyClass==return lhs.isEqual(to:rhs) && rhs.isEqual(to:lhs)と冗長になってしまっているところが玉に瑕。ただ、例えば、仕様上MyClassMySubclass.aの値は必ず異なるといった場合(.aがインスタンスごとに割り振るIDであるなど)であれば、return lhs.isEqual(to:rhs)だけで済むこともあるはずです。


案③

本当にそれはクラスである必要がありますか?

Swiftではstructもいい働きをしますよ?そして折角のProtocolという仕組みです。さらにGenericsなんていうものもあります。クラスにこだわる必要もないのではないですか?

まだ"point of no return"を超えていないのであれば設計から見直しませんか?

こんなコードはいかがですか?


mystructures.swift

public protocol MyProtocol: Equatable {

var a: Int { get }
}

public struct MyFirstStructure: MyProtocol {
public var a: Int
public init(a:Int) { self.a = a }
public static func ==(lhs:MyFirstStructure, rhs:MyFirstStructure) -> Bool {
return lhs.a == rhs.a
}
}

public struct MySecondStructure: MyProtocol {
public var a: Int
public var b: Int
public init(a:Int, b:Int) { self.a = a; self.b = b }
public static func ==(lhs:MySecondStructure, rhs:MySecondStructure) -> Bool {
return lhs.a == rhs.a && lhs.b == rhs.b
}
}

public struct MyWrapper<Instance: MyProtocol>: Equatable {
public var instance: Instance
public init(instance:Instance) { self.instance = instance }
public static func ==(lhs:MyWrapper<Instance>, rhs:MyWrapper<Instance>) -> Bool {
return lhs.instance == rhs.instance
}
}


これで済む場合も少なからずあるのではないでしょうか?ただ、もちろん、この場合はMyFirstStructureMySecondStructureには継承関係がないので、それぞれのインスタンスを直接比較するにはまためんどくさいコードを書かないといけません。一方、最初から同一のクラスでしか比較をしないような設計なら、思い切ってstructを使うのも手です。


その他の案

コメント欄を参照(他力本願)


おわりに

Swiftの教科書(なんてものがあれば)のProtocolの項で最初に出てきそうなEquatableですが、単純な実装では場合によってバグを生み出すことがあるかもしれないことは意識しておかないといけないようです。






  1. 私見。ComparableHashableなどもEquatableを継承しているから、直接ではなくても皆んな使用しているよね? 



  2. Swift 4.0-dev 現在: https://github.com/apple/swift/blob/swift-4.0-branch/stdlib/public/core/Equatable.swift#L156 



  3. その別のサブクラスでも.aの値さえ同じなら同一とみなすのであれば、改めて実装する必要さえありません。自動的にMyClassisEqual(to:)が呼び出されます。 



  4. 対称性。a == b ⇔ b == aというやつ。