Choosing between dynamic casts and requirements of optional type

i often struggle to decide between defining protocols with optional properties, and relying on dynamic casts to handle optionality.

approach 1:

protocol ProductCard:Identifiable<String?>
{
    override
    var id:String? { get }
}

func render(list:[any ProductCard])
{
    for card:any ProductCard in list 
    {
        if  let id:String = card.id 
        {
        }
    }
}

pros:

  • no dynamic casting needed
  • makes the relationship between ProductCard and its id more visible
  • feels more “swifty” (?)

cons:

  • conforming types that always have ids have no way of declaring an non-optional id property.
  • there is almost never a reason to have an optional id except to satisfy this protocol, and each type can only ever conform to Identifiable once, so this inhibits reuse of conforming types.

approach 2:

protocol ProductCard
{
}

func render(list:[any ProductCard])
{
    for card:any ProductCard in list 
    {
        if  let card:any Identifiable<String> = 
                card as? any Identifiable<String> 
        {
        }
    }
}

pros:

  • code that doesn’t use existentials (or generics) can simply access id non-optionally, if the type supports it.
  • somewhat less work to implement

cons:

  • loss of type safety - if a conforming type changes its Identifiable.ID associated type, the dynamic cast will start silently failing
  • feels less “swifty” (?)

IMO protocol conformance is more than just a bunch of properties and methods, it's a way of defining the nature of something. Declaring that something is Identifiable if it's essentially not is pretty weird, I think.

I often get the feeling Swift "wants" me to avoid using existentials, and maybe you do too - "feels less swifty?" - and that's why you're pondering this question, but I think it's unwise to follow that dogmatically. Unless one has some real constraint on existential use - e.g. Embedded Swift, or some observed performance problem - I don't think it's wise to go out of one's way to avoid them.

Also, having to unwrap optionals everywhere could end up more repetitive than dynamic casting, as once you've discovered your actual Identifiables you can potentially pass them as such to lower layers, avoiding redundant runtime checks.

P.S. If you leverage type inference more, you'll also see less difference in your code between the two approaches. e.g.:

func render(list: [any ProductCard]) {
    for card in list {
        if let card = card as? any Identifiable<String> {
            …
        }
    }
}

Tangentially, it's a pity that one cannot write if let card as? XYZ { like one can do if let card {. And also a bit annoying that the any is required here even though that's implicit. Future improvements, perhaps.

1 Like

You could try incorporating the optionally-available protocol into the base protocol as an associated type, using the habitability of that associated type to represent whether the capability is present or not. For example:

protocol ProductCard {
  // AsIdentifiable defaults to `Never`, when the card isn't identifiable
  associatedtype AsIdentifiable: Identifiable<String> = Never
}

extension ProductCard where Self: Identifiable<String> {
  // ...unless the conforming type does conform to Identifiable<String>
  typealias AsIdentifiable = Self
}

extension ProductCard {
  var id: String? {
    if let selfAsIdentifiable = self as? Self.AsIdentifiable {
      return selfAsIdentifiable.id
    } else {
      return nil
    }
  }
}

That way, when you statically have a type that's ProductCard & Identifiable<String>, you have direct non-optional access to the Identifiable<String>, but when you only have a ProductCard, you have the AsIdentifiable associated type to dynamically test whether an id is available using information directly in the protocol conformance without having to go to the global conformance table.

6 Likes

This is an interesting solution but doesn't seem to compile:

if let selfAsIdentifiable = self as? AsIdentifiable {  // 🛑

:stop_sign: Type 'Self' does not conform to protocol 'Identifiable'

Weird, that seems like a bug. Changing it to as? Self.AsIdentifiable makes it compile:

  var id: String? {
    if let selfAsIdentifiable = self as? Self.AsIdentifiable {
      return selfAsIdentifiable.id
    } else {
      return nil
    }
  }
1 Like

i think you misunderstood me slightly; both examples are using existentials, i was only referring to the ability to reuse the types in concrete contexts, for example when you have [PerscriptionCard] instead of [any ProductCard].

occasionally i will spell this as

if  case let card as any Identifiable<String>
{
}

:slight_smile:

i wasn’t aware there was a difference between self as? Self.AsIdentifiable and self as? any Identifiable<String>

Self.AsIdentifiable is a concrete type, and the cast will (generally) only succeed if the type matches. In this setup the type should only either be the same as Self or some uninhabited type like Never. Identifiable is a protocol so the cast has to look up whether the type conforms to the protocol to form the result.

2 Likes

In the 2nd approach how would one differentiate between an empty list and a list that contained only elements that did not unwrap successfully?

I guess it applies in 1st approach too.

It seems like there might be some ambiguity in the function either way.

Note getID func is just my choice for easy printing, the problem is the same without this function

protocol ProductCard:Identifiable<String?>
{
    override var id:String? { get }
    func getId() -> [String]
}
class MyProductCard: ProductCard {
    var id: String?
    init() {
        self.id = .init("play itchy by siriusmo")
    }
    func getId() -> [String] {
        guard let id = id else {
            return []
        }
        return [id]
    }
}

class MyOtherProductCard: ProductCard {
    var id: String?
    init() {
    }
    func getId() -> [String] {
        guard let id = id else {
            return []
        }
        return [id]
    }    
}

class ProductCardTests: XCTestCase {
    func render(list:[any ProductCard])
    {
        guard let identifiables = list as? [any Identifiable<String?>] else {
            return XCTFail("Malformed list")
        }
        for card:any ProductCard in list
        {
            print("\(card.getId().description)")
        }

    }

    func test_ProductCards() {
        let listA = [any ProductCard]()
        let myProductCard = MyProductCard()
        let listB = [myProductCard]
        let myOtherProductCard = MyOtherProductCard()
        let listC = [myOtherProductCard]
        render(list: listA)
        render(list: listB)
        render(list: listC)
    }
}

Kind of scary right?

listA survives the cast but doesn't run the loop while listC survives the cast and runs the loop with the id unset

So the ambiguity just got shifted around. Was there anything in the list parameter? Was there only one object but string was nil? Was there more than one object each having string nil?

Off topic (Embedded Swift x Existentials):

I would like to chime in and say that Embedded Swift has a real need for existentials of some form as well. They are a great way to describe that you the programmer want dynamic dispatch with a "vtable" and this can be very important for minimizing code size. Existentials in swift today are not a good match for Embedded Swift due to the use of type metadata; something more like Rust's dyn may be a future direction for embedded.

As "existentials" are very common in C firmware where you model them structs of function pointers and sometimes with a context/self pointer.

This is topic for another thread though.

1 Like

is there an example for this? I can't compile anything that both conforms to ProductCard and modifies the associatedtype of ID. Even in an extension.

Furthermore I thought about adding an optional function as a conformance requirement but then remembered that @objc is not compatible with swift protocols who are specialized. Additionally, I can't mark an unspecialized variant of ProductCard protocol as @objc because it would be a refinement of Identifiable which is an @objc protocol already

My gut instinct would be to avoid designs involving downcasting. I feel it makes code harder to understand.

The ambiguity posed by [any ProductCard] being able to be empty or completely full of objects that that all fail to unwrap suggests that nothing was gained by the use of any ProductCard. This is one of the sharper edges when using existentials, maybe a class bound is needed, or something, not sure.

Hi, what about these approaches?

protocol ProductCard {}

protocol IdentifiableProductCard: Identifiable<String> {
  var id: String { get }
}

func render(list:[any ProductCard])
{
    for card: any ProductCard in list {
        if  let identifiableCard = card as? IdentifiableProductCard {
          let id = identifiableCard.id
        } else {
         ....
        }
    }
}
protocol ProductCard: AnyOptionalIdentifiableProduct {}

protocol StringIdentifiableProductCard: Identifiable<String> {
  var id: String { get }
}

protocol UUIDIdentifiableProductCard: Identifiable<UUID> {
  var id: UUID { get }
}

protocol UInt64IdentifiableProductCard: Identifiable<UInt64> {
  var id: UInt64 { get }
}

enum AnyProductID {
  case string(String
  case uuid(UUID)
  case uint64(UUID)
}

protocol AnyIdentifiableProduct: ProductCard {
  var anyProductID: AnyProductID { get }
}

protocol AnyOptionalIdentifiableProduct {
  var maybeAnyProductID: AnyProductID? { get }
}

func render(list: [any ProductCard])
{
    for card: any ProductCard in list {
        if let id = card.maybeAnyProductID {
          switch id {
          case .string(let stringID): ...
          case ...
          }
        } else {
         .... no id
        }
    }
}

func render(list: [any AnyIdentifiableProduct]) {
   for card: any AnyIdentifiableProduct in list {
    let id = card.anyProductID
  }
}