How can we make default implementations safer?

i think this is a common scenario, where you have a very heavily-used protocol and want to define another, much smaller category of types that support some additional functionality.

one way to do this is to have orthogonal protocols for the subset, like:

public protocol ServerResponse 
{
}

public protocol CacheableResponse 
{
    var cacheKey:MD5 { get }
}

but you can’t really do much with the conformers without some kind of dynamic casting. so i imagine the “swiftier” way to do this is by declaring the witness as an optionally-typed requirement on the base protocol. instead of performing a dynamic cast, the caller just accesses the cacheKey property from the base protocol and unwraps the optional.

since the vast majority of conforming types do not provide a cacheKey, it is motivating to provide a default implementation that returns nil.

public protocol ServerResponse 
{
    var cacheKey:MD5? { get }
}
extension ServerResponse 
{
    public
    var cacheKey:MD5? { nil }
}

but this is hazardous, because it is very easy to think you have overridden the requirement (for example, with a stored property of type MD5) when in fact it is still using the default implementation.

how can we make this safer?

Out of curiosity, in what way does this feel "Swiftier" to you? I don't see anything "less Swifty" about downcasting to the appropriate type, and it feels very reasonable to me to have CacheableResponse inherit from ServerResponse (unless you have responses which are cacheable but don't come from a server?).

TBH,

func handle(response: some ServerResponse) {
    // ...

    if let cacheKey = (response as? CacheableResponse)?.cacheKey {
        // <also cache it, or whatever>
    }
}

feels perfectly idiomatic to me.


Edit:

Though, if you're not really asking about this example specifically: I can imagine a scheme where you can offer a default implementation, but force all adopters to explicitly specify whether they want the default behavior or custom behavior. Whether the verbosity of something like

protocol ServerResponse {
    var cacheKey: MD5? { get }
}

extension ServerResponse {
    @mustImplement // <- straw name
    var cacheKey: MD5? { nil }
}

struct NonCacheable: ServerResponse {
    var cacheKey: MD5? = default
}

struct Cacheable: ServerResponse {
    var cacheKey: MD5? = ...
}

is preferable over splitting the protocols is a subjective matter, I think.

i suppose it is just a reflexive reaction to writing global conformance table lookups, as we are often advised to refactor these into direct calls to a protocol requirement.

i think that it would be more useful to attach this attribute to the witness so that we wouldn’t have to add any boilerplate to the non-cacheable types, instead we could have

struct Cacheable: ServerResponse {
    @implementsSomething
    var cacheKey: MD5? = ...
}

which would raise an error if the stored property doesn’t implement any protocol requirement.

This works, but misses two (IMO) important cases:

  1. You forget to implement cacheKey in the first place, and
  2. You forget to annotate it with @implementsSomething

If the only effect you want is "I'd like to annotate this to indicate that I expect it to override a default implementation from a protocol", it seems that we could just repurpose override for this:

  1. If you try to override something that doesn't exist in a supertype (or now, conforming protocol), then you get an error
  2. override in this position remains optional for backwards-compatibility: you just don't get the safety if you don't use it

(But, if we're talking safety: accidentally inheriting the default implementation because you forgot to provide an implementatino for a requirement altogether seems a lot more common in my experience than, say, misnaming a property/method — especially since in the latter case, we have near-miss warnings)

IMO, there is nothing wrong with downcasting here. To make it clear that downcasting is intentional, I would wrap it into a computed property:

public protocol ServerResponse 
{
}

public protocol CacheableResponse:  ServerResponse
{
    var cacheKey:MD5 { get }
}

extension ServerResponse {
    var asCachable: CacheableResponse? { self as? CacheableResponse  } 
}

And if you want to avoid lookup in the conformance tables, you could make this property part of protocol requirements:

public protocol ServerResponse 
{
    var asCachable: CacheableResponse? { get  }
}

public protocol CacheableResponse:  ServerResponse
{
    var cacheKey:MD5 { get }
}

extension ServerResponse {
    var asCachable: CacheableResponse? { nil  } 

extension CacheableResponse {
    var asCachable: CacheableResponse? { self  } 
}
2 Likes

I usually take a somewhat different approach here and try to always deal with concrete types in these scenarios. So I'd keep the original protocols:

public protocol ServerResponse {
}

public protocol CacheableResponse {
    var cacheKey: String { get }
}

And try to write code so every function that returns a response actually returns some ServerResponse.

Then, for the consumers of ServerResponse, I'd just maintain a separate path for types that conform to both ServerResponse and CacheableResponse:

struct ResponseHandler {
   func handleResponse(_ response: some ServerResponse) {
        // Common function for all paths
        _handleResponse(response)
    }
    
    func handleResponse<Response: ServerResponse & CacheableResponse>(
        _ response: Response
    ) {
        // Do whatever you need with the cacheKey here...
        print(response.cacheKey)
        // Common function for all paths
        _handleResponse(response)
    }
    
    private func _handleResponse(_ response: some ServerResponse) {
        // Actually handle the response...
    }
}
2 Likes

I'm not sure about your use case, but when I encountered a similar situation and wanted to know statically if the analog of your ServerResponse was cacheable, I used associated types with a default value:

protocol ServerResponse {
  associatedtype CacheKey: CacheKeyProtocol = CacheKeyUncacheable
  var cacheKey: CacheKey { get }
}
extension ServerResponse where CacheKey == CacheKeyUncacheable {
  var cacheKey: CacheKey { return CacheKeyUncacheable() }
}

In this case CacheKeyUncacheable will implement CacheKeyProtocol in a way that responses are never cached, but types with actual cache keys can provide their own implementation that gives you whatever you need.

2 Likes