📑

【Swift】型としてのprotocol

2024/12/17に公開

Swiftのprotocolに存在型という概念があるのは、カンファレンスで聞いて、言葉だけ知ってるレベルだったんですが、
正直理論の話で、実務で直面することはないと思っていたところ、
API周りの実装をしていたら普通にハマったので、改めて型としてのprotocolを学び直しました。

サンプル

説明のためにサンプルを紹介します。
実際に僕が直面したケースをめちゃくちゃ単純化したサンプルコードです。

APIに対するリクエストのprotocolがあったとします。

protocol Request {
    associatedtype Response
    var successResponse: Response { get }
    var path: String { get }
}

リクエストの中に successResponse があるのは変ですが、
本来は実際にAPI通信が発生して、その戻り値の型に Response を適用する処理が記述されており、難読になるので、
サンプルコードとしては色々省略させてもらって、successResponse に成功したレスポンスが格納される前提にさせてください。

個別のAPIリクエストは、下記のようにprotocolに準拠させます。

struct UserResponse {}
struct UserRequest: Request {
    typealias Response = UserResponse
    var successResponse = UserResponse() // 本当はAPIからのレスポンスを処理します
    var path: String = "api/user"
}

APIリクエストを送信するクライアントはこの UserRequest のような型情報をもらって、sendメソッドを実行します。

class APIClient {
    static let `default`: APIClient = APIClient()
    func send<T: Request>(_ request: T) -> Result<T.Response, Error> {
        // …
    }
}

sendメソッドはジェネリック関数として実装されております。
APIKitのsendメソッドがこういう実装で、このサンプルコードではそれを単純化しています)
戻り値に associatedtype Response を指定する関係で、ジェネリクスを使った実装になっています。

使用例は単純です。

let request = UserRequest()
let response = APIClient.default.send(request)
// …

ここまでは問題ありません。

問題になったケース

このAPIクライアントに、色々機能を追加していきます。
何らかの理由で、送信待機させたい要件が出てきたとします。

class APIClient {
    static let `default`: APIClient = APIClient()
    var needSendWait: Bool = false // 送信待機しなければならない条件
    var waitRequests: [Request] = []
    func send<T: Request>(_ request: T) -> Result<T.Response, Error> {
        if needSendWait {
            waitRequests.append(request)
            return .failure(NeedSendWaitError())
        }
        // 送信処理!
        // 送信成功したので、待ちになっていたリクエストを処理する
        waitRequests.forEach {
            let retryResult = send($0)
            // リトライ結果を伝えるための何らかの処理
        }
        waitRequests.removeAll()
    }
}

このように再送信かけることを考えます。
ここでコンパイラから怒られて、Requestにanyをつけないコンパイルが通りません。

var waitRequests: [Request] = []var waitRequests: [any Request] = []

指示に従って修正しても、sendメソッド呼ぶところでコンパイルエラーとなります。

Type 'any Request' cannot conform to 'Request'

なぜこのようなことが起こるのかを理解するために、型としてのprotocolを改めて勉強しました。

ちなみに

ちなみに、このサンプルコードを書いていると、単純化した例だとけっこーコンパイル通ることに気づきました。
コンパイルエラーになる条件は、protocol内にassociatedtypeを持っていて、この型がsendメソッドの中で使われていることが必要なようです。

protocol Request {
    associatedtype Response <-
}

これをなくすと、anyをつけろとも言われず、スッとコンパイルできました。

protocolは型なのか

「protocolは型でもある」という認識でSwift書いてましたが、これはちょっと浅い理解でした。
厳密には型ではありません。
言われてみれば当たり前でしたが、あくまでprotocolは「実装する必要がある要件の定義」であって、型そのものではありません。
Javaのinterfaceに相当する概念です。

しかしprotocolを型のように扱えると、記述上とても便利です。
そこで存在型(Existential types)という概念がSwiftでは採用されています。
「ある特定のインターフェース(プロトコルや型クラスなど)を満たす型が「存在する」ことを表現する型システムの概念」(by Claude)らしいです。
特別な指定をせず、protocolを型として使った場合、デフォルトで存在型として動作します。

protocolは2通りの型表現が可能になっており、それがOpaque型とBoxed Protocol型(=存在型)です。
定訳がなさそうなので、一旦英語表記にしましたが、直訳するなら不透明型とボックス化されたプロトコル型です。

用語だと難しそうですが、普段Swift書いてる人なら既に見たことあるかもしれません。
それぞれsome/anyで記述します。

protocol View {}

var body: some View {} // 不透明型
var views: [any View] =// ボックス化されたプロトコル型

詳細を見ていきましょう。
説明の都合上、ボックス化されたプロトコル型から説明させてください。
(公式ドキュメントの紹介順と逆)

Boxed Protocol型(Boxed Protocol Types)

Boxed Protocol型はイコール存在型です。
公式ドキュメントでもそう記述されています。

Boxed Protocol型はSwiftの用語で、存在型の方が一般用語/学術用語という関係で、
厳密にはちょっとだけ違うと思いますが、Swiftという言語においては同一と見做せると思います。

anyをつけたらBoxed Protocol型ですが、もともとanyというパラメーターがprotocolにつけられるようになったのがSwift 5.6からです。
それまでデフォルトの挙動はこちらでしたが、後述するOpaque型の登場で、挙動がわかりづらくなったために、anyパラメーターをつけて区別されるように変更されました。
Swift 6だとanyをつけないと怒られるようです。

anyがつくとわかりやすいですが、存在型(Boxed Protocol型)は具体的な型は何が入ってくるかわからないという意味では、

var request: Any where element: Request // この記法はエラー

と書いているのと同義です。
これがエレガントな記法として、

var request: Request

と書けるようになっておりました。

Opaque型(Opaque Types)

Swift 5.1で導入されたのがOpaque型です。
個人的な理解だと、SwiftUIのためにできた記法だと思ってますが、正しいですかね?

var body: some View {}

上記のプロパティ body の呼び出し元は、Viewに準拠していることしか知りません。
ここはBoxed Protocol型と同じです。
最大の違いは、コンパイラは実際の型を知っているという点です。

たとえばSwiftUIで

var body: some View {
    Text("text")
}

と記述されていた場合、具体的な型は Text だとわかります。
このケースであれば、別に some View にしなくても、型を直接 Text にすることもできます。

次の場合はどうでしょう。

@State var isCircle = false
var body: some View {
    isCircle ? Circle() : Text("text")
}

この場合、CircleText が返ってきます。
ただ、コンパイラの静的解析によって、どちらが返るかは判断ができます。
なので、本当に必要な分のメモリ領域を確保することができます。
Boxed Protocol型はオーバーヘッドが存在します。

パフォーマンスを考えるなら、Opaque型が使えるシチュエーションなら、Opaque型を使う方が良いです。
ただ具体型がわかってないといけないという制約があるので、たとえば

var views: [some View] = []
// Underlying type for opaque result type '[some View]' could not be inferred from return expression

という書き方はできません。

以上を踏まえて、最初何が問題だったのか

ここまでの内容を踏まえると、記事冒頭のサンプルコードで発生した、

Type 'any Request' cannot conform to 'Request'

が理解できます。

通常のsendメソッド呼び出しをもう一度見てみましょう。

let request = UserRequest()
let response = APIClient.default.send(request)

このとき、ジェネリック関数は UserRequest という具体型を受け取っています。
一方で、送信待機を入れた場合、any Request の配列として保持しており、ここで具体型の情報が失われています。

僕の感覚的には実行できるんじゃないかと思ったんですが、抽象型のジェネリック関数に、更に抽象型をつっこむことになって、
メモリ領域たくさん確保しておかないと実行の安全性を担保できないので、防いでるんでしょうかね?

対策

あんまり今回の問題にハマる開発者は多くないと思いますが、一応対策を書いておきます。

一般的には型消去というのを行なってあげるといいそうです。
以下はClaudeが生成してきたもので、変なコードを出力したかと思って採用しなかったんですが、こういうケースだとそれなりに一般的な対応らしいです。

struct AnyRequest: Request {
    private let _wrapped: any Request
    
    init(_ request: any Request) {
        self._wrapped = request
    }
}

このラッパーをつくるのがちょっと嫌だったので、クロージャーで保持することにしました。

class APIClient {
    var waitActions: [() -> Void] = []
    func send<T: Request>(_ request: T) -> Result<T.Response, Error> {
        if needSendWait {
            waitActions.append({
                let retryResult = send($0)
                // リトライ結果を伝えるための何らかの処理
            })
            return .failure(NeedSendWaitError())
        }
        waitActions.forEach { $0() }
        waitActions.removeAll()
    }
}

いずれにせよ、protocolのまま配列で保持した場合、メソッドの引数としてそれを使う方法はありませんでした。

参照

(了)

Discussion