Trouble combining generic functions in protocol with protocol composition (with PAT)

Hi,

I was wondering if anybody had any ideas about how to solve this problem or find an elegant workaround.

I am trying to take a single protocol that has associated types and make it composable by splitting out some "not always required" parts of the protocol into other protocols.

The path to this appears to be to define the "companion" protocols with the same associated type names, and it largely works so long as any type constraints on the associated types in both protocols are met – which is pretty amazing.

However the problem comes when the protocol (in this case Action) is used as a generic constraint in function in another protocol (see Observer below). As soon as you do this, implementations of Observer can not use anything from the composed protocols as you cannot do if let x = input as? OtherComposableProtocolWithSameAssociatedType because that composable protocol has associated type requirements, and hence cannot be used in the function.

Is there any way to achieve this? See sample below.

protocol Action {
    associatedtype Input
    associatedtype Presenter
    
    static func present(i: Input, p: Presenter)
}

extension Action {
    static func present(i: TestAction.Input, p: TestAction.Presenter) {
        print(i)
    }
}

protocol AnalyticsTrackable {
    associatedtype Input where Input: CustomStringConvertible

    static var analyticsID: String { get }
    static func attributes(for i: Input)
}

extension AnalyticsTrackable {
    static func attributes(for i: TestAction.Input) {

    }
}

class SomeInput: CustomStringConvertible {
    let value: String = "something"
    
    var description: String { return value }
}

final class TestAction: Action, AnalyticsTrackable {
    typealias Input = SomeInput
    typealias Presenter = Any
    
    static var analyticsID = "test"
}

protocol Observer {
    func actionDidComplete<T>(action: T.Type) where T: Action
}

class TestObserver: Observer {
    func actionDidComplete<T>(action: T.Type) where T: Action {
        print("Completed: \(action)")
        
        // This is the part that breaks, as expected with PATs, but it's not clear if there is any workaround.
        // You cannot overload `actionDidComplete` for different type constraints as Swift will not select the correct one
        if let analyticsAction = action as? AnalyticsTrackable.Type {
            print("Analytics ID: \(analyticsAction.analyticsID)")
        }
    }
    
    // This does NOT work, as it understandably only resolves to the statically type method generic on Action:
/*    
    func actionDidComplete<T>(action: T.Type) where T: Action {
        func actionDidComplete<T>(action: T.Type) where T: Action {
        print("Completed: \(action)")
    }
    func actionDidComplete<T>(action: T.Type) where T: Action & AnalyticsTrackable {
        print("Completed: \(action)")
        print("Analytics ID: \(action.analyticsID)")
    }
*/
}

let observer = TestObserver()

func dispatch<T>(action: T.Type, input: T.Input, presenter: T.Presenter) where T: Action {
    action.present(i: input, p: presenter)
    observer.actionDidComplete(action: action)
}

dispatch(action: TestAction.self, input: SomeInput(), presenter: "UI")

This means you expect that action can conform to AnalyticsTrackable. If is does, you have passed
Action & AnalyticsTrackable, otherwise Action, and you want to account for both of these cases.
Since we don't have generalized existentials yet, what's wrong in having two methods?

protocol Observer {
  func actionDidComplete<T>(action: T.Type) where T: Action
  func actionDidComplete<T>(action: T.Type) where T: Action & AnalyticsTrackable
}

class TestObserver: Observer {
  
  func actionDidComplete<T>(action: T.Type) where T: Action {
    ...
  }
  func actionDidComplete<T>(action: T.Type) where T: Action & AnalyticsTrackable {
    ...
  }
}

It is true that passing Action & AnalyticsTrackable is kind of ambiguous, but the compiler will give precedence to the one whose constraints most accurately satisfy the parameter type.

... because this requires implementors to implement multiple methods and make sure they do all the common housekeeping in all of them. I am also unsure it will still select the correct one as this becomes a viral requirement doesn't it, that all callers in the stack have to be generic over Action & AnalyticsTrackable for it to be able to resolve the correct overload?

AFAIK there's no way to cast at runtime to PAT-protocols without existentials being implemented. Since in your use-case you don't actually need the PAT, maybe split AnalyticsTrackable into

protocol AnalyticsIdentifiable {
    static var analyticsID: String { get }
}
protocol AnalyticsTrackable: AnalyticsIdentifiable {
    associatedtype Input where Input: CustomStringConvertible
    static func attributes(for i: Input)
}

If you aren't publicly vending the protocol and the concern is that a possibly single method is broke up into several ones without repeating logic, I don't think the concern is justified for your case. Don't forget that you are casting to a potentially unrelated type. This isn't always good practice and is often a sign of following the wrong approach. You might consider trying to design a protocol hierarchy instead of composing. However, if you are confident composition is the right way to go, splitting methods, using default implementations where possible and delegating to one another to avoid repeating yourself is alright. The ability to open existentials can bring a 'solution' to you, but that doesn't justify the approach: it is, among everything else, a way of casting to potentially unrelated types that are protocols with type parameters.

Sadly @anthonylatsis I am vending the protocol publicly, this is for an OSS framework.

Also @DeFrenZ sadly this is not an option because there are other places in the code where similar checks have to be made and the attributes(for:) call made conditionally.

I take it from the responses so far that this means there is no workaround and I can't decompose this protocol until Swift improves. It was worth a try.

Unfortunately. There is a dirty trick you can try though – inheriting the given PAT from a protocol without generic traits and check by casting to that protocol. Just make sure you don't expose the helper protocol through any APIs.

protocol P { }
protocol P1: P { associatedtype T }

if let foo = obj as? P  { // rather than as? P1 

But since you are vending the protocol, I wonder why you're asking. It is the consumer's care to implement the protocol, right? Or are you implementing it yourself as well?