😎

[Swift] Opaque Result Typeを使ってジェネリック型の型パラメータを隠蔽しよう!

2021/05/04に公開

追記

より良い方法が出来ました。
https://zenn.dev/en3_hcl/articles/377e9d15e323be

状況

ライブラリを作ろうとするとしばしば遭遇するのが次のような状況です。

public protocol MyPublicProtocol: Equatable {
    init()
    var number: Int { get }
}

// 何らかの値
public struct MyValue: MyPublicProtocol {
    public init() {}
    public let number: Int = 46
}

public struct MyPublicStruct<T: MyPublicProtocol> {
    private var value: T

    func isEqual(to value: T) -> Bool {
        return value == self.value
    }
}

MyPublicStructは利用者にも使って欲しい構造体です。ですからこれがpublicであることに問題はありません。
問題はMyValueの方です。これは本当は利用者が「知らなくていい」もので、使って欲しくないのです。

ところが、これを利用者が使うためには以下のようにして呼び出すことが不可欠です。不本意ながらMyValuepublicにする必要が生じます。

// 別のモジュールで初期化する処理
let instance = MyPublicStruct<MyValue>()
どういう状況?

例えばJapaneseKeyboardInputManager<T: InputStyleData>が「日本語のキーボード入力を管理する型」だったとし、InputStyleDataに準拠するのは

  1. かな入力の場合のデータKanaInputStyleData
  2. ローマ字入力の場合のデータRomanInputStyleData

であるような状況を考えます。KanaInputStyleDataRomanInputStyleDataも実装の話なので外部には公開したくありません。けれどそれを管理する「日本語のキーボード入力を管理する型」は公開したいのです。

やりたいこと

どうにかしてMyValueを隠蔽したいですね。隠蔽といえばOpaque Result Typeです。あれを使えばどうにかなりそうです。

一番良いのは次のようなtypealiasが作れることです。しかしこんな構文はありませんし、今のところこんな構文が予定されているという話も聞いたことがありません。

// 利用者にはMyPublicStructForUserがMyPublicStruct<some MyPublicProtocol>に見えて欲しい
typealias MyPublicStructForUser = MyPublicStruct<MyValue> as MyPublicStruct<some MyPublicProtocol>

これができなくても、次のように書ければまだマシです。この方法ならば外部ではMyPublicStructFactory.instance()を通して初期化することにしておいて、MyValueは隠蔽することができるからです。

public enum MyPublicStructFactory {
    // 'some' types are only implemented for the declared type of properties and subscripts and the return type of functions
    static func instance() -> MyPublicStruct<some MyPublicProtocol> {
        return MyPublicStruct<MyValue>(value: .init())
    }
}

しかしこれも不可能です。エラーメッセージの通り、返値で型パラメータとしてOpaque Result Typeを使うことはまだできないのです。
これはOpaque Result Typeの現在の実装上の制約で、将来的には可能になるかもしれません。上のtypealiasよりは希望が持てそうです。しかしそれまで待たないといけないのでしょうか。

解決

その必要はありません。少しだけ実装の方法を変えれば、これは可能になります。

public protocol MyPublicStructProtocol {
    associatedtype T: MyPublicProtocol
    func isEqual(to value: T) -> Bool
}

private struct MyPublicStruct<T: MyPublicProtocol>: MyPublicStructProtocol {
    private var value: T

    func isEqual(to value: T) -> Bool {
        return value == self.value
    }
}

private struct MyValue: MyPublicProtocol {
    let number: Int = 46
}

public enum MyPublicStructFactory {
    static func instance() -> some MyPublicStructProtocol {
        return MyPublicStruct<MyValue>(value: .init())
    }
}

最終的に利用者が手にするのはsome MyPublicStructProtocolという型です。これでは何もしようがないように見えますが、MyPublicStructProtocolpublicにすべきものを全て約束しているため、この利用には何の支障もありません。また、外部に公開されるのはOpaqueな型にすぎないため、MyPublicStructMyValueも公開する必要がありませんし、valueについてはそもそもprotocolの要件に入れていないため、外部からはアクセスできません。

実際に利用者サイドではどのようにこれが動くのでしょうか。一見これが不思議なことになりますが、別に何も困ることはありません。

let instance = MyPublicStructFactory.instance()
let isEqual = instance.isEqual(to: .init())
let isEqualFunction = instance.isEqual

instancesome MyPublicStructProtocol型の値です。isEqualは問題なく呼べています。

面白いのは、isEqualを呼ぶには.init()をドット記法で呼ぶしかないということです。Tは秘匿されているため、利用者はそれがどのような型なのか知ることはできません。それでもMyPublicProtocolがイニシャライザの存在は約束しているため、ドット記法を通してのみ初期化ができるのです。
isEqualFunctionを試すとその意味がわかります。これは((some MyPublicStructProtocol).T) -> Boolという型を持つ関数になっています。

以上で確認した通り、無事に型パラメータを隠蔽したジェネリック型を手にすることができました。ただ、当初の目的ではMyPublicStructは公開していてよかったのに、この方法の場合はそれすら隠蔽されてしまい、「実装を過剰に隠蔽している」状況になっているのが若干悔しいところです。

もっといい方法があればぜひ教えてください!

Discussion