🚧

プロトコルのデフォルト実装でつまづいた話

2024/06/19に公開

この記事は

Swiftのprotocolのdefault implementations(デフォルト実装)が、思いもよらぬ挙動をしてびっくりという話です🤔

環境: Xcode15.4(swift5.10)

とあるAPIリクエストのIF

APIを呼び出す仕組みに、以下のようなRequestプロトコルを利用していました。

protocol Request {
    associatedtype Response
    associatedtype Serializer: DataResponseSerializerProtocol where Serializer.SerializedObject == Response
    var serializer: Serializer { get }
    // path, method, ...
}

レスポンスの型とレスポンスをデコードするためのシリアライザを指定できるようになっています。ここで登場するDataResponseSerializerProtocolはAlamofireで定義されるIFで、

public protocol DataResponseSerializerProtocol {
    associatedtype SerializedObject
    func serialize(request: URLRequest?, response: HTTPURLResponse?, data: Data?, error: Error?) throws -> SerializedObject
}

SerializedObjectを返す、serializeメソッドの実装が必要です。そしてRequestのSerializerにはSerializedObject == Responseという制約をつけています。

実際にRequestを作成する時にはほとんど同じシリアライザを利用しており、毎回指定するのが面倒なため以下のようなデフォルト実装を行なっていました。

extension Request where Response: Decodable {
    var serializer: DecodableSerializer<Response> {
        // SerializedObjectがDecodableなときに、デコードしたオブジェクトを返すシリアライザ
        DecodableSerializer<Response>()
    }
}

これによって、レスポンスがDecodableなリクエストを作成する場合はシリアライザの指定が必要なくなります。

なぜかコンパイルできるリクエスト

ここからが本題の元となったリクエストの話です。このリクエストでは特別にSerializerに DataResponseSerializer が指定されていました。Alamofireで用意されているシリアライザで以下のように実装されています。

public final class DataResponseSerializer: ResponseSerializer {
    // ...

    public func serialize(request: URLRequest?, response: HTTPURLResponse?, data: Data?, error: Error?) throws -> Data {
        // ...
    }

SerializedObjectにData型を利用しているシリアライザです。
Requestの制約でSerializedObject == Responseとしているため、ResponseにはDataを指定しなければなりませんが…

struct SomeResponse: Decodable {
    // ...
}

struct SomeRequest: Request {
    typealias Response = SomeRequponse
    let serializer = DataResponseSerializer()
    // ...
}

このようなリクエストのコンパイルが通ってしまいます😢。Requestの制約を無視したSerializerが指定されているようにも見えます。(そもそも何でこんなコードが生まれたのか…真相は闇の中です😉)

扱う型によって挙動が変わる

またこのとき👆のリクエストからserializerを取り出す時、扱う型によって挙動が変わります。正直少し面白いなと思ってしまいました。

// SomeRequestとして扱う
let request1: SomeRequest = .init()
print(request1.serializer.self) // DataResponseSerializer()
// Requestとして扱う
let request2: some Request = SomeRequest()
print(request2.serializer.self) // DecodableSerializer<SomeResponse>()

なぜこのリクエストがコンパイルできるのか

何となく察しはついていましたが、デフォルト実装が作用していたようです。

extension Request where Response: Decodable {
    var serializer: DecodableSerializer<Response> {
        // SerializedObjectがDecodableなときに、デコードしたオブジェクトを返すシリアライザ
        DecodableSerializer<Response>()
    }
}

ResponseのSomeResponseがDecodableに準拠していたため、デフォルト実装によって許されているようです。
この実装がない、ResponseがDecodableでない、以下のようにSerializerを明示した、などデフォルト実装を利用しないよう回避するとコンパイルは通りません。

struct SomeRequest: Request {
    typealias Response = SomeResponse
    typealias Serializer = DataResponseSerializer
    let serializer = DataResponseSerializer()
    // ...
}

おわり

同じオブジェクトから同じ引数名を利用しているにも関わらず、別の結果が得られるのはかなり違和感でした… 今回は大丈夫でしたが、実装によっては事故が起こっていたかもしれないです😨
そもそもコンパイルエラーにならないのが驚きでした😯 Swiftの仕様上なぜコンパイルできるのか、まで突き止められていないので調べてみたいです。(知ってる方がいたら誰か教えてください〜🙇)

Discussion