Swift でクラスに同値比較性を実装する

Swift プログラミング

Swift で久しくなった気のするクラスですけど、それに同値比較可能な性質を持たせるときにどうしたら良いかを整理してみました。


Swift で 値の同値比較性 を持たせたいときには Equatable プロトコルに準拠させるのが一般的な方法ですけど、構造体に同値比較性を持たせるときにはそれで良くても、クラスに対して同値比較性を持たせたいときには クラス継承 も考慮して実装する必要が出てきます。

struct Value {

	var rawValue: Int
}

extension Value : Equatable {

	static func == (lhs: Value, rhs: Value) -> Bool {

		return lhs.rawValue == rhs.rawValue
	}
}

そもそも、それは値なのか

まず、最初に考えないといけないこととして そのクラスで表現されているものが値なのか を考えるのが大切なように思います。

仮に『構造体を値型、クラスを参照型』と捉えたときに、参照渡しをしたいと理由でクラスを選んだなら、それは値に成り得るかもしれません。また、クラスしか表現する手段のなかった Objective-C で値相当の物を作りたいときにも、値型に成り得ると思います。

ただ Swift で値を作ろうとしたときに、それが値を表現するものなら、通常は 構造体で表現 するのが、性格的にいちばん近くて、値を値らしく振る舞うためのさまざまな Swift による言語サポートが得られます。 値に対する Swift による言語サポートというのは、代入時の原則コピー、mutatingletvar といった振る舞いです。

なので、まずは値として使う それをクラスで表現する必要があるのか から考えるのが大切なように感じます。

Swift 純粋クラスに同値比較性を備える

NSObject から継承しない Swift 純粋のクラスに対して同値比較性を実装する場合は、クラス継承を考慮した実装を行うようにします。

継承させないクラスの場合

Swift では final を指定したクラスは継承できなくなります。継承できなければ構造体のときと同じ感覚で同値比較性を実装して、問題ないはずです。 具体的には、クラスを Equatable プロトコルに準拠させて、プロトコルが求める == 演算子を static func で定義してあげれば完成です。

final class ValueObject {

	var v1: Int

	init(v1 value: Int) {

		v1 = value
	}
}

extension ValueObject : Equatable {

	static func == (lhs: ValueObject, rhs: ValueObject) -> Bool {

		return lhs.v1 == rhs.v1
	}
}

継承を許可するクラスの場合

特に final を指定しなければ継承できるクラスになるので、その場合は、将来そのクラスが継承された場合も考慮して 継承先が同値比較の振る舞いを変更できる手段を用意しておく 必要があります。同値比較の振る舞いを継承先で変更できない設計にしたい場合はその限りではありませんけど。

継承先が振る舞いを変更する手段は、プロパティーまたはメソッドのオーバーライドになります。現時点では演算子で使う class func はオーバーライドできないようなので、これらのどれかを使って同値比較の機能を提供する必要があります。

Objective-C の流儀に則ると…

必ずしもそうする必要はないと思いますけど、Objective-C の同値比較の実装方法がきれいに感じるので、ここではその方法に則って実装してみることにします。

そのために、同値比較を提供するメソッド isEqual(_:) をクラスに実装します。この時、このメソッドはオーバーライドされる可能性も考慮して、型拡張ではなく型そのものに実装しておくようにします。今のところ 型拡張で実装した機能はオーバーライドできない ようなので。

そして、このメソッドを使って同値比較判定を行う == 演算子を static func で実装します。これは型拡張で実装しても大丈夫なので、今回は Equatable プロトコルの適用と合わせて型拡張で実装してみます。

class ValueObject {

	var v1: Int

	init(v1 value: Int) {

		v1 = value
	}

	func isEqual(_ value: ValueObject) -> Bool {

		return v1 == value.v1
	}
}

extension ValueObject : Equatable {

	static func == (lhs: ValueObject, rhs: ValueObject) -> Bool {

		return lhs.isEqual(rhs)
	}
}

これでクラスに、同値比較の機能を実装することができました。

継承先のクラスでの実装

このようにして同値比較の機能を実装したら、継承先では isEqual をオーバーライドして、適切な同値比較ができるように振る舞いを変えます。

このとき注意したいのは、オーバーライドした isEqual の引数は 基底クラスを受け取るようになっている ため、自身の型にダウンキャストして同値判定を行います。ダウンキャストできなかった場合は "同値ではない" と判断します。

class SubValueObject : ValueObject {

	var v2: Int

	init(v1 value1: Int, v2 value2: Int) {

		v2 = value2

		super.init(v1: value1)
	}

	override func isEqual(_ value: ValueObject) -> Bool {

		guard let value = value as? SubValueObject else {

			return false
		}

		return (v1, v2) == (value.v1, value.v2)
	}
}

動き方としては、演算子 == で同値比較を行うときには、今回の例であれば ValueObject 型に実装された static func == が呼び出され、その中で lhsisEqual が呼び出されます。 このとき、継承先の SubValueObject 同士の比較であれば、呼びされる isEqual はオーバーライドされた継承先のものになるので、継承関係に合った適切な同値判定が行われます。

ダウンキャストできなかった場合に false と決め込んでいいのか、不安に思ったことがありました。たとえば、ダウンキャストできなかったときには親クラスの isEqual に委ねる実装もあるかもしれないですけど、その場合はそもそもオーバーライドする必要がない気がするので、基本的には決め込んで問題なさそうに思います。

Objective-C 互換クラスの場合

Objective-C 互換クラスの場合は、規定クラスの NSObject 型で、既にこれまでに綴った通りの Equatable プロトコルへの適合と isEqual による同値判定機能の実装 が行われています。

そのため、この場合もこれまでに綴った通り、継承先で isEqual をオーバーライドしてあげることで、通常の == 演算子を使って適切な同値判定を行えるようになります。

class MyData : NSObject {

	override func isEqual(_ object: Any?) -> Bool {

		guard let data = object as? MyData else {

			return false
		}

		return ...
	}
}

汎用性の高いクラスの場合

今回の NSObject 型みたいな汎用性の高いクラスの場合、継承先の性格が大きく変わる場合があります。さらに、継承先のクラスで同値比較を頻繁に行う場合には、さらにもう少しだけ別の比較方法が採られる場合があります。

これは NSString 型などでも採られている方法ですけど、継承先に特化した同値比較をおこなうメソッドを用意して、それを isEqual メソッドでも利用 します。継承先に特化した同値比較メソッドの名前は Objective-C では一般に isEqual(to:) にする様子です。引数で受け取る型は一般に自分自身の型にします。

class MyData : NSObject {

	override func isEqual(_ object: Any?) -> Bool {

		guard let data = object as? MyData else {

			return false
		}

		return isEqual(to: data)
	}

	func isEqual(to data: MyData) -> Bool {

		return ...
	}
}

このようにすることで、もちろん isEqual(_:) を使って適切に同値比較することもできますし、それよりも isEqual(to:) を使った方が ダウンキャストの処理を省略できる分だけ高速に判定 することができます。

かつての Objective-C の頃には演算子でオブジェクトの同値性を判定することがなかった ので、このような約束事で効率的に判定できる isEqual(to:) の方を使うことがけっこう自然にできていました。ただし Swift では演算子 == を使って同値判定できるようになった 都合、このままだと、演算子で同値比較の判定をすると、処理の遅い方の isEqual(_:) が呼び出されてしまいます。

さらなる最適化を図る場合の問題点

そこで、さらに static func ==オーバーロード して 自分自身を両辺に採る等価比較演算子を実装して、その中で isEqual(to:) の方を呼び出すことで、等価比較演算子を使ったときに速い方の isEqual(to:) を使うようにすることもできます。

extension MyData {

	static func == (lhs: MyData, rhs: MyData) -> Bool {

		return lhs.isEqual(to: rhs)
	}
}

このようにすることで、継承先のオブジェクト同士でも高速に同値判定できるようになりますし、 将来さらにクラスが継承されたときでも継承先の方同士の比較のときにはこちらの等価演算子が利用されるので いっけんすると理想的な実装のよう に思えます。

ただし、等価比較演算子はオーバーライドされていないため、インスタンスを入れた型によって 実行される等価比較演算子が変わってきます。

let data1 = MyData()
let data2 = MyData()

(data1 as MyData) == (data2 as MyData)      // MyData の == が使われる
(data1 as NSObject) == (data2 as NSObject)  // NSObject の == が使われる

もしオーバーライドができれば NSObject== が呼ばれたときに MyData== にされた実装を使うことができるのですけど、それができないので、それを考慮してコーディングする必要があります。

継承元の ==isEqual(_:) が呼び出される前提であれば、派生先で == をオーバーロードするということは それ以外の処理をする ことになると思うのですけど、そうなると、たとえばさらに派生クラスを作成して、そこで isEqual(_:) だけをオーバーライドしたときに等価比較に矛盾が出てくる可能性があります。

class SubData : MyData {

	override func isEqual(_ object: Any?) -> Bool {

	}
}

このようにすると == 演算子で比較したときに、MyData 同士の比較は、継承元の MyData が持つ == が引き受けます。この中で isEqual(_:) 以外の同値比較判定をしていると、自身でオーバーライドしたisEqual(_:) が呼び出されません。isEqual(_:) は本来 NSObject に備えられた同値比較のメソッドなので、これをオーバーライドしても 同値比較判定の挙動が変わらないという誤解 を招く恐れがあります。

継承関係が深くなるほど、この問題は根深くなると思うので、余程の理由がない限りは Objective-C の流儀も踏まえると、次のあたりに気をつけてクラスを設計するのが良さそうです。

その上で 高速な比較を行いたいときは == 演算子ではなく isEqual(to:) メソッドを明示的に使う みたいなルールで実装するのが上手く行きそうです。