iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🚧

Getting Tripped Up by Protocol Default Implementations

に公開

About this article

This is a story about how Swift protocol default implementations behaved in an unexpected way and surprised me 🤔

Environment: Xcode 15.4 (Swift 5.10)

An API Request Interface

We used the following Request protocol as a mechanism for calling APIs.

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

It allows specifying the response type and the serializer for decoding that response. The DataResponseSerializerProtocol used here is an interface defined in Alamofire:

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

It requires an implementation of the serialize method that returns a SerializedObject. Furthermore, the Request's Serializer has a constraint that SerializedObject == Response.

In practice, almost the same serializer was used when creating a Request, and since specifying it every time was tedious, the following default implementation was provided:

extension Request where Response: Decodable {
    var serializer: DecodableSerializer<Response> {
        // A serializer that returns a decoded object when SerializedObject is Decodable
        DecodableSerializer<Response>()
    }
}

As a result, it is no longer necessary to specify a serializer when creating a request where the response is Decodable.

A Request That Somehow Compiles

This brings us to the actual request that sparked this topic. In this specific request, DataResponseSerializer was explicitly designated as the Serializer. This is a serializer provided by Alamofire, implemented as follows:

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

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

This is a serializer that uses the Data type for its SerializedObject. Since the Request constraint is SerializedObject == Response, the Response must be specified as Data, but...

struct SomeResponse: Decodable {
    // ...
}

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

A request like this actually compiles successfully 😢. It appears as though a Serializer is being specified while ignoring the Request constraints. (How this code even came to be in the first place... the truth remains a mystery 😉)

Behavior changes depending on the type being handled

Also, when extracting the serializer from the request above 👆, the behavior changes depending on the type it is being handled as. To be honest, I found it somewhat interesting.

// Handle as SomeRequest
let request1: SomeRequest = .init()
print(request1.serializer.self) // DataResponseSerializer()
// Handle as Request
let request2: some Request = SomeRequest()
print(request2.serializer.self) // DecodableSerializer<SomeResponse>()

Why does this request compile?

I had a feeling, but it seems the default implementation was taking effect.

extension Request where Response: Decodable {
    var serializer: DecodableSerializer<Response> {
        // A serializer that returns a decoded object when SerializedObject is Decodable
        DecodableSerializer<Response>()
    }
}

Since the response SomeResponse conforms to Decodable, it seems to be permitted by the default implementation.
If you avoid using the default implementation—such as by removing this implementation, having a Response that is not Decodable, or explicitly specifying the Serializer as shown below—it will not compile.

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

Conclusion

It felt quite strange to get different results even though I was using the same property name from the same object... It was fine this time, but depending on the implementation, it could have led to an accident —
I was surprised that it didn't cause a compilation error in the first place —. I haven't yet pinned down exactly why it compiles according to the Swift specifications, so I'd like to look into it further. (If anyone knows, please let me know! —)

Discussion