LoginSignup
2
2

More than 3 years have passed since last update.

【Swift】Protocol で宣言したオブジェクトの等価評価を AnyHashable でシンプルにする

Posted at

動機づけ

次のようにプロトコルで宣言したオブジェクトの等価性を扱いたいとします。

FooProtocol.swift
public protocol FooProtocol {
    var foo: String { get }
    var bar: Int { get }
    var baz: Double { get }
}
FooProtocolTest.swift
class FooProtocolTest: XCTestCase {

    var hoge: FooProtocol!
    var fuga: FooProtocol!

    func test_example() {
        hoge = ... // FooProtocol プロトコルに準拠したオブジェクト
        fuga = ... // FooProtocol プロトコルに準拠したオブジェクト

        XCTAssertEqual(hoge.foo, fuga.foo)
        XCTAssertEqual(hoge.bar, fuga.bar)
        XCTAssertEqual(hoge.baz, fuga.baz)
    }
}

このように、プロパティ各々を比較するのではなく、 XCTAssertEqual(hoge, fuga) のようにすることで、プロトコルで定義したプロパティをまとめて比較できれば便利そうです。

そのような単純化の方法について考えてみたいと思います。

【失敗】Equatable 準拠

Swift で等価性を扱う場合、通常は Equatable に準拠することになります。
しかし、この方法は簡単には上手くいきません。

たとえば、次のように Equatable のサブプロトコルとして定義します。

FooProtocol.swift
public protocol FooProtocol: Equatable {
    var foo: String { get }
    var bar: Int { get }
    var baz: Double { get }
}

このプロトコルに適合することで、== で評価することができるようになります。

Hoge.swift
public struct Hoge: FooProtocol {
    public var foo: String
    public var bar: Int
    public var baz: Double

    public var qux: [String]
}
let hoge1 = Hoge(foo: "ほげほげ", bar: 23, baz: 42, qux: ["ほげ"])
let hoge2 = Hoge(foo: "ほげほげ", bar: 23, baz: 42, qux: ["ほげ"])
print(hoge1 == hoge2) // true

しかし、これは Hoge としての等価性であって、FooProtocol で定義されたプロパティの等価性ではありません。
上記の場合は、FooProtocol で定義されていない変数 qux まで評価されます。

また、次のように FooProtocol に適合した別の型との比較はできません。

Fuga.swift
public struct Fuga: FooProtocol {
    public var foo: String
    public var bar: Int
    public var baz: Double

    public var quux: [Int : String]
}
let hoge = Hoge(foo: "ほげほげ", bar: 23, baz: 42, qux: ["ほげ"])
let fuga = Fuga(foo: "ほげほげ", bar: 23, baz: 42, quux: [1: "ほげ"])
print(hoge == fuga) // コンパイルエラー

HogeFuga がそれぞれ Equatable に準拠しているだけなので、自分自身と同じ型でなければ == による比較ができないためです。

また、プロトコルを Equatable に準拠させたので、冒頭のテストコードはコンパイルエラーになります。

FooProtocolTest.swift
class FooProtocolTest: XCTestCase {
    // 次の理由でコンパイルエラー
    // Protocol 'FooProtocol' can only be used as a generic constraint 
    // because it has Self or associated type requirements
    var hoge: FooProtocol!
    var fuga: FooProtocol!
}

Self または associated type を使用すると上記のような型宣言ができなくなります。
EquatableSelf を使用したプロトコルなので、上記の通りコンパイルエラーになります。

public protocol Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool
}

Equatable 準拠の問題点まとめ

失敗作の問題点をまとめると次の2点になります。

  • FooProtocol で定義したプロパティの等価性ではなく、適合先の構造体の等価性になってしまった
  • FooProtocolSelf を使用したプロトコルなので、型宣言ができなくなってしまった

上記から、FooProtocol 自体を Equatable のサブプロトコルにしてしまうのは、問題の解決にならないことがわかりました。

【成功】AnyHashable を利用する

冒頭の XCTAssertEqual(hoge, fuga) のように、FooProtocol 自体を Equatable にすることは諦めて、
次のような作戦をとることにしました。

XCTAssertEqual(hoge.fooHashable, fuga.fooHashable)

この fooHashable の部分が「プロトコルで定義されているプロパティを内部的に保持している何か」で、Equatable に準拠していているようにします。

具体的には、次のような実装になります。

FooProtocol.swift
public protocol FooProtocol {
    var foo: String { get }
    var bar: Int { get }
    var baz: Double { get }
}

public extension FooProtocol {
    var fooHashable: AnyHashable {
        return Anonymous(foo: foo, bar: bar, baz: baz)
    }
}

private struct Anonymous: FooProtocol, Hashable {
    let foo: String
    let bar: Int
    let baz: Double
}

コードを言葉で表現すると次のようになります。

  • Hashable に適合したプライベートな型(Anonymous)を作る
  • プロトコルのデフォルト実装で AnyHashable にラップして上記のオブジェクトを返す

こうすることで、Anonymous で定義されたプロパティの等価評価をすることになり、実質的には FooProtocol で定義しているプロパティをまとめて評価することができるようになります。

次のように異なる型で宣言していても、FooProtocol の意味での等価評価ができます。

let hoge: Hoge = Hoge(foo: "ほげほげ", bar: 23, baz: 42, qux: ["ほげ"])
let fuga: Fuga = Fuga(foo: "ほげほげ", bar: 23, baz: 42, quux: [1: "ほげ"])
print(hoge.fooHashable == fuga.fooHashable) // true

そして、冒頭のようなプロトコルで宣言したオブジェクトの等価評価も、プロパティの各々を比較する必要がなくなります。

FooProtocolTest.swift
class FooProtocolTest: XCTestCase {

    var hoge: FooProtocol!
    var fuga: FooProtocol!

    // 等価比較:FooProtocol の意味で一致していること
    func test_example_1() {
        hoge = Hoge(foo: "ほげほげ", bar: 23, baz: 42, qux: ["ほげ"])
        fuga = Fuga(foo: "ほげほげ", bar: 23, baz: 42, quux: [1: "ほげ"])
        XCTAssertEqual(hoge.fooHashable, fuga.fooHashable)
    }

    // 等価比較:FooProtocol の意味で一致していないこと
    func test_example_2() {
        // 「ふが」と「ほげほげ」の不一致
        hoge = Hoge(foo: "ふが", bar: 23, baz: 42, qux: ["ほげ"])
        fuga = Fuga(foo: "ほげほげ", bar: 23, baz: 42, quux: [1: "ほげ"])
        XCTAssertNotEqual(hoge.fooHashable, fuga.fooHashable)
    }
}

ということで、コードを少し簡潔にすることができました。

また、この実装は、HogeFuga 自体の等価性と無関係な実装ですので、必要に応じて適合先の構造体やクラスで Equatable に準拠することも可能です。

そうすると、次のように FooProtocolHoge の2通りの意味での等価評価が可能になります。

Hoge.swift
public struct Hoge: FooProtocol, Equatable { // Equatable に準拠
    public var foo: String
    public var bar: Int
    public var baz: Double

    public var qux: [String]
}
let hoge1: Hoge = Hoge(foo: "ほげ", bar: 23, baz: 42, qux: ["ほげ"])
let hoge2: Hoge = Hoge(foo: "ほげ", bar: 23, baz: 42, qux: ["ほげ", "ふが"])

// `FooProtocol` の意味での評価は qux が無視されるので true
print(hoge1.fooHashable == hoge2.fooHashable) 

// Hoge の意味での評価は qux が不一致で false
print(hoge1 == hoge2) 

結び

AnyHashable と言えば、[AnyHashable: Any] のような辞書のキーとしての利用をよく見かけますが、Equatable から Self を型消去した等価評価のためのオブジェクトとしても利用できることが分かりました。

プロパティの個数が多い場合や、テストケースを増やしたい場合などに役に立ちそうなテクニックですね。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2