Search code examples
swiftprotocolsconflictrequirementsdependency-inversion

Swift - Conform third-party type to my own protocol with conflicting requirement


Here's the boiled down situation:

Let's say a third-party framework written by Alice Allman provides a very useful class:

public class AATrackpad {
  public var cursorLocation: AAPoint = .zero
}

and another framework written by Bob Bell provides a different class:

public class BBMouse {
  public var where_is_the_mouse: BBPoint = .zero
}

At runtime either one of these classes may be needed depending on which piece of hardware the user has decided to use. Therefore, in keeping with the Dependency Inversion Principle, I do not want my own types to depend on AATrackpad or BBMouse directly. Rather, I want to define a protocol which describes the behaviors I need:

protocol CursorInput {
  var cursorLocation: CGPoint { get }
}

and then have my own types make use of that protocol instead:

class MyCursorDescriber {
  var cursorInput: CursorInput?

  func descriptionOfCursor () -> String {
    return "Cursor Location: \(cursorInput?.cursorLocation.description ?? "nil")"
  }
}

I want to be able to use an instance of BBMouse as the cursor input, like this:

let myCursorDescriber = MyCursorDescriber()
myCursorDescriber.cursorInput = BBMouse()

but in order for this to compile I have to retroactively conform BBMouse to my protocol:

extension BBMouse: CursorInput {
  var cursorLocation: CGPoint {
    return CGPoint(x: self.where_is_the_mouse.x, y: self.where_is_the_mouse.y)
  }
}

Now that I've conformed BBMouse to my CursorInput protocol, my code compiles and my architecture is the way I want it. The reason I have no problem here is that I think that where_is_the_mouse is a terrible name for that property, and I'm quite happy to never use that name again. However, with AATrackpad its a different story. I happen to think that Alice named her cursorLocation property perfectly, and as you can see I want to be able to use the same name for my protocol requirement. My problem is that AATrackpad does not use CGPoint as the type of this property, but instead uses a proprietary point type called AAPoint. The fact that my protocol requirement (cursorLocation) has the same name as an existing property of AATrackpad but a different type means that I can't retroactively conform to CursorInput:

extension AATrackpad: CursorInput {
  var cursorLocation: CGPoint { // -- Invalid redeclaration
    return CGPoint(x: self.cursorLocation.x, y: self.cursorLocation.y) // -- Infinite recursion
  }
}

As the comments in that snippet say, this code does not compile, and even if it did I'd be facing an infinite recursion at runtime because I have no way to specifically reference the AATrackpad version of cursorLocation. It would be great if something like this would work (self as? AATrackpad)?.cursorLocation, but I don't believe this makes sense in this context. Again though, the protocol conformance won't even compile in the first place, so disambiguating in order to solve the infinite recursion is secondary.

With all of that context in mind, my question is:

If I architect my app using protocols (which is widely recommended, for good reason), is it really true that my ability to use a certain third-party concrete type depends on the hope this third-party developer doesn't share my taste for naming conventions?


NOTE: The answer "Just pick a name that doesn't conflict with the types you want to use" won't be satisfactory. Maybe in the beginning I only had BBMouse and had no conflicts, and then a year later I decided that I wanted to add support for AATrackpad as well. I initially chose a great name and it's now used pervasively throughout my app - should I have to change it everywhere for the sake of one new concrete type? What happens later on when I want to add support for CCStylusTablet, which now conflicts with whatever new name I chose? Do I have to change the name of my protocol requirement again? I hope you see why I'm looking for a more sound answer than that.


Solution

  • Inspired by Jonas Maier's comment, I found what I believe to be an architecturally adequate solution to this problem. As Jonas said, function overloading exhibits the behavior that I'm looking for. I'm starting to think that maybe protocol requirements should only ever be functions, and not properties. Following this line of thinking, my protocol will now be:

    protocol CursorInput {
      func getCursorLocation () -> CGPoint
      func setCursorLocation (_ newValue: CGPoint)
    }
    

    (Note that in this answer I'm making it settable as well, unlike in the original post.)

    I can now retroactively conform AATrackpad to this protocol without conflict:

    extension AATrackpad: CursorInput {
      func getCursorLocation () -> CGPoint {
        return CGPoint(x: self.cursorLocation.x, y: self.cursorLocation.y)
      }
      func setCursorLocation (_ newValue: CGPoint) {
        self.cursorLocation = AAPoint(newValue)
      }
    }
    

    Important - This will still compile even if AATrackpad already has a function func getCursorLocation () -> AAPoint, which has the same name but a different type. This behavior is exactly what I was wanting from my property in the original post. Thus:

    The major problem with including a property in a protocol is that it can render certain concrete types literally incapable of conforming to that protocol due to namespace collisions.

    After solving this in this way, I have a new problem to solve: there was a reason I wanted cursorLocation to be a property and not a function. I definitely do not want to be forced to use the getPropertyName() syntax all across my app. Thankfully, this can be solved, like this:

    extension CursorInput {
      var cursorLocation: CGPoint {
        get { return self.getCursorLocation() }
        set { self.setCursorLocation(newValue) }
      }
    }
    

    This is what is so cool about protocol extensions. Anything declared in a protocol extension behaves analogously to a default argument for a function - only used if nothing else takes precedence. Because of this different mode of behavior, this property does not cause a conflict when I conform AATrackpad to CursorInput. I can now use the property semantics that I originally wanted and I don't have to worry about namespace conflicts. I'm satisfied.


    "Wait a second - now that AATrackpad conforms to CursorInput, doesn't it have two versions of cursorLocation? If I were to use trackpad.cursorLocation, would it be a CGPoint or an AAPoint?

    The way this works is this - if within this scope the object is known to be an AATrackpad then Alice's original property is used:

    let trackpad = AATrackpad()
    type(of: trackpad.cursorLocation) // This is AAPoint
    

    However, if the type is known only to be a CursorInput then the default property that I defined gets used:

    let cursorInput: CursorInput = AATrackpad()
    type(of: cursorInput.cursorLocation) // This is CGPoint
    

    This means that if I do happen to know that the type is AATrackpad then I can access either version of the property like this:

    let trackpad = AATrackpad()
    type(of: trackpad.cursorLocation) // This is AAPoint
    type(of: (trackpad as CursorInput).cursorLocation) // This is CGPoint
    

    and it also means that my use case is exactly solved, because I specifically wanted not to know whether my cursorInput happens to be an AATrackpad or a BBMouse - only that it is some kind of CursorInput. Therefore, wherever I am using my cursorInput: CursorInput?, its properties will be of the types which I defined in the protocol extension, not the original types defined in the class.


    There is one possibility that a protocol with only functions as requirements could cause a namespace conflict - Jonas pointed this out in his comment. If one of the protocol requirements is a function with no arguments and the conforming type already has a property with that name then the type will not be able to conform to the protocol. This is why I made sure to name my functions including verbs, not just nouns (func getCursorLocation () -> CGPoint) - if any third-party type is using a verb in a property name then I probably don't want to be using it anyway :)