iTranslated by AI
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